feat: bootstrap awg2 harness rust workspace and docs

This commit is contained in:
2026-05-09 02:17:25 +05:00
commit d937f816bf
47 changed files with 4195 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/target/
/reboot-scheduler
*.log
*.log.*
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "reboot-scheduler"
version = "0.1.1"
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "reboot-scheduler"
version = "0.1.1"
edition = "2021"
description = "Демон планового перезапуска сервера в случайное время в окне 01:00–02:00 UTC"
[[bin]]
name = "reboot-scheduler"
path = "src/main.rs"
[dependencies]
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"
+111
View File
@@ -0,0 +1,111 @@
# reboot-scheduler
`reboot-scheduler` — небольшой `systemd`-сервис на Rust, который раз в запуск планирует перезагрузку в случайную секунду окна **[01:00, 02:00) UTC**, затем запрашивает reboot через `systemctl --no-block reboot`.
## Состав репозитория
```text
reboot-scheduler/
├── src/
│ └── main.rs
├── Cargo.toml
├── Cargo.lock
├── reboot-scheduler.service
├── .gitignore
└── README.md
```
## Требования
- Linux x86_64
- `systemd`
- Rust toolchain (cargo + rustc)
- Поддерживаемые дистрибутивы: Debian 12/13 (и совместимые)
## Сборка
```bash
cargo build --release
```
Итоговый бинарь:
```text
target/release/reboot-scheduler
```
По умолчанию release-сборка даёт **динамически связанный** glibc-бинарь. Это штатное поведение.
## Установка
Команды выполняются от root (или через `sudo`).
```bash
mkdir -p /opt/reboot-scheduler
cp target/release/reboot-scheduler /opt/reboot-scheduler/reboot-scheduler
chmod 755 /opt/reboot-scheduler/reboot-scheduler
cp reboot-scheduler.service /etc/systemd/system/reboot-scheduler.service
chmod 644 /etc/systemd/system/reboot-scheduler.service
systemd-analyze verify /etc/systemd/system/reboot-scheduler.service
systemctl daemon-reload
systemctl enable --now reboot-scheduler.service
```
## Поведение сервиса
1. На старте вычисляет целевой timestamp в окне **(01:00, 02:00) UTC**.
2. Ожидает наступления времени, проверяя раз в 30 секунд.
3. Вызывает `systemctl --no-block reboot`.
4. Завершается:
- кодом `0`, если reboot-запрос принят;
- кодом `1`, если команда завершилась ошибкой или не запустилась.
Это важно для корректной работы `Restart=on-failure` в unit-файле.
## Логирование
Сервис не пишет в собственные файлы. Логи идут в `journald` (stdout/stderr):
```bash
journalctl -u reboot-scheduler -f
```
Формат сообщений:
```text
[YYYY-MM-DD HH:MM:SS UTC] Сообщение
```
## Управление
```bash
systemctl status reboot-scheduler
systemctl restart reboot-scheduler
systemctl stop reboot-scheduler
systemctl disable reboot-scheduler
```
## Проверки качества
Рекомендуемый локальный прогон перед релизом:
```bash
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --release
cargo build --release
file target/release/reboot-scheduler
ldd target/release/reboot-scheduler
```
## Удаление
```bash
systemctl stop reboot-scheduler
systemctl disable reboot-scheduler
rm -f /etc/systemd/system/reboot-scheduler.service
rm -rf /opt/reboot-scheduler
systemctl daemon-reload
```
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Daily random reboot scheduler (01:00-02:00 UTC)
[Service]
Type=simple
User=root
ExecStart=/opt/reboot-scheduler/reboot-scheduler
Restart=on-failure
RestartSec=60
StandardOutput=journal
StandardError=journal
# Базовое hardening
CapabilityBoundingSet=CAP_SYS_BOOT
AmbientCapabilities=CAP_SYS_BOOT
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+286
View File
@@ -0,0 +1,286 @@
//! reboot-scheduler — демон планового перезапуска сервера.
//!
//! Логика работы:
//! 1. При запуске вычисляет случайное время в окне [01:00, 02:00) UTC
//! текущих суток (если окно ещё не началось) либо следующих суток.
//! 2. Ждёт до наступления выбранного момента.
//! 3. Выполняет `systemctl --no-block reboot` и завершается:
//! - код 0 при успешном запросе;
//! - код 1 при ошибке.
//!
//! Логирование ведётся только в stderr/stdout и собирается journald.
//! Нет сторонних зависимостей — только std.
use std::process::Command;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
// ─── Константы ────────────────────────────────────────────────────────────────
/// Начало окна перезагрузки — 01:00:00 UTC в секундах от полуночи.
const WINDOW_START_SEC: u64 = 3600;
/// Конец окна перезагрузки — 02:00:00 UTC в секундах от полуночи (не включая).
const WINDOW_END_SEC: u64 = 2 * 3600;
/// Интервал опроса при ожидании (проверяем раз в 30 секунд).
const POLL_INTERVAL: Duration = Duration::from_secs(30);
// ─── Логгер ───────────────────────────────────────────────────────────────────
/// Возвращает текущее время в виде строки «YYYY-MM-DD HH:MM:SS UTC».
fn fmt_time(ts: u64) -> String {
let secs = ts;
let days_total = secs / 86400;
let time_of_day = secs % 86400;
let hh = time_of_day / 3600;
let mm = (time_of_day % 3600) / 60;
let ss = time_of_day % 60;
// Алгоритм Фликера для перевода дней с эпохи в дату.
let z = days_total as i64 + 719468;
let era: i64 = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC",
y, m, d, hh, mm, ss
)
}
/// Записывает строку в журнал с временной меткой.
fn log(msg: &str) {
let now = unix_now();
let line = format!("[{}] {}", fmt_time(now), msg);
eprintln!("{}", line);
}
// ─── Время ────────────────────────────────────────────────────────────────────
/// Возвращает текущий unix-timestamp (секунды).
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Простой LCG-генератор псевдослучайных чисел, инициализированный от времени.
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
// Перемешиваем seed splitmix64, чтобы даже близкие значения давали
// разные последовательности.
let mut x = seed.wrapping_add(0x9e3779b97f4a7c15);
x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
x ^= x >> 31;
Self(x)
}
/// Возвращает следующее псевдослучайное число в диапазоне [0, range).
fn next_u64(&mut self, range: u64) -> u64 {
// LCG параметры (Knuth).
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.0 % range
}
}
/// Вычисляет unix-timestamp следующего момента перезагрузки.
///
/// Правила:
/// - Берём сегодняшнюю полночь UTC.
/// - Если сейчас до WINDOW_START, цель — сегодняшнее окно.
/// - Иначе — окно следующего дня.
/// - Внутри окна выбираем случайную секунду.
fn next_reboot_ts_from(now: u64, random_offset: u64) -> u64 {
let today_midnight = now - (now % 86400); // полночь текущих суток UTC
let time_of_day = now % 86400;
let base_midnight = if time_of_day < WINDOW_START_SEC {
today_midnight
} else {
today_midnight + 86400 // следующие сутки
};
let window_len = WINDOW_END_SEC - WINDOW_START_SEC; // 3600 секунд
let offset = random_offset % window_len;
base_midnight + WINDOW_START_SEC + offset
}
/// Вычисляет unix-timestamp следующего момента перезагрузки.
fn next_reboot_ts() -> u64 {
let now = unix_now();
let window_len = WINDOW_END_SEC - WINDOW_START_SEC;
let mut rng = Lcg::new(now);
let offset = rng.next_u64(window_len);
next_reboot_ts_from(now, offset)
}
// ─── Перезагрузка ─────────────────────────────────────────────────────────────
fn run_command(program: &str, args: &[&str]) -> Result<std::process::ExitStatus, String> {
Command::new(program)
.args(args)
.status()
.map_err(|e| format!("не удалось запустить `{}`: {}", program, e))
}
/// Выполняет reboot-запрос через указанную команду.
fn do_reboot_with(program: &str, args: &[&str]) -> Result<(), String> {
let status = run_command(program, args)?;
if status.success() {
Ok(())
} else {
Err(format!(
"команда `{}` завершилась с кодом {:?}",
program,
status.code()
))
}
}
/// Выполняет мягкую перезагрузку через systemctl.
fn do_reboot() -> Result<(), String> {
do_reboot_with("systemctl", &["--no-block", "reboot"])
}
// ─── Точка входа ──────────────────────────────────────────────────────────────
fn main() {
log("Демон запущен.");
let target_ts = next_reboot_ts();
log(&format!(
"Запланированная перезагрузка: {}",
fmt_time(target_ts)
));
loop {
let now = unix_now();
if now >= target_ts {
log("Наступило запланированное время. Инициируем перезагрузку.");
match do_reboot() {
Ok(()) => {
log("Запрос на перезагрузку принят (systemctl --no-block reboot).");
std::process::exit(0);
}
Err(e) => {
log(&format!(
"ОШИБКА: запрос на перезагрузку не выполнен: {}",
e
));
std::process::exit(1);
}
}
}
let remaining = target_ts - now;
// Логируем оставшееся время раз в час (попадаем в окно ±30 с).
if remaining % 3600 < 30 {
log(&format!("До перезагрузки осталось {} мин.", remaining / 60));
}
thread::sleep(POLL_INTERVAL);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn day(seconds: u64) -> u64 {
seconds - (seconds % 86400)
}
#[test]
fn next_reboot_before_window_uses_today_window() {
let now = 25 * 60;
let ts = next_reboot_ts_from(now, 123);
let base = day(now);
assert!(ts >= base + WINDOW_START_SEC);
assert!(ts < base + WINDOW_END_SEC);
}
#[test]
fn next_reboot_on_start_boundary_uses_next_day_window() {
let now = WINDOW_START_SEC;
let ts = next_reboot_ts_from(now, 0);
let base = day(now) + 86400;
assert!(ts >= base + WINDOW_START_SEC);
assert!(ts < base + WINDOW_END_SEC);
}
#[test]
fn next_reboot_after_window_uses_next_day_window() {
let now = WINDOW_END_SEC + 1;
let ts = next_reboot_ts_from(now, 42);
let base = day(now) + 86400;
assert!(ts >= base + WINDOW_START_SEC);
assert!(ts < base + WINDOW_END_SEC);
}
#[test]
fn next_reboot_always_inside_window() {
let now = 1_700_000_000;
let ts = next_reboot_ts_from(now, 3599);
let tod = ts % 86400;
assert!((WINDOW_START_SEC..WINDOW_END_SEC).contains(&tod));
}
#[cfg(unix)]
#[test]
fn do_reboot_with_success_command_returns_ok() {
let res = do_reboot_with("/bin/true", &[]);
assert!(res.is_ok());
}
#[cfg(unix)]
#[test]
fn do_reboot_with_failing_command_returns_err() {
let res = do_reboot_with("/bin/false", &[]);
assert!(res.is_err());
}
#[cfg(unix)]
#[test]
fn do_reboot_with_missing_command_returns_err() {
let res = do_reboot_with("/definitely/not/exist", &[]);
assert!(res.is_err());
}
#[cfg(windows)]
#[test]
fn do_reboot_with_success_command_returns_ok() {
let res = do_reboot_with("cmd", &["/C", "exit", "0"]);
assert!(res.is_ok());
}
#[cfg(windows)]
#[test]
fn do_reboot_with_failing_command_returns_err() {
let res = do_reboot_with("cmd", &["/C", "exit", "1"]);
assert!(res.is_err());
}
#[cfg(windows)]
#[test]
fn do_reboot_with_missing_command_returns_err() {
let res = do_reboot_with("C:\\definitely\\not\\exist\\cmd.exe", &[]);
assert!(res.is_err());
}
}