feat: bootstrap awg2 harness rust workspace and docs
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/target/
|
||||
/reboot-scheduler
|
||||
*.log
|
||||
*.log.*
|
||||
Generated
+7
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user