diff --git a/README.md b/README.md index 7b8455a..f888b4a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ /lib/systemd/system/awg2-reboot-scheduler.service ``` -`awg2-harness.service` запускает `ensure-swap` перед стартом daemon, затем в фоне держит drift recovery. Daemon слушает Docker events и дополнительно делает периодический reconcile для `traffic`/`firewall`/`swap`; `sysctl_tuning` применяется только на старте daemon (если включён) или вручную отдельной командой. `awg2-reboot-scheduler.service` отдельно читает общий config, выбирает случайное время в заданном UTC-окне и запрашивает reboot через `systemctl --no-block reboot`. +`awg2-harness.service` запускает только daemon; `ensure-swap` выполняется первым этапом его собственной startup-последовательности, а не отдельным `ExecStartPre`. Дальше daemon в фоне держит drift recovery. Daemon слушает Docker events и дополнительно делает периодический reconcile для `traffic`/`firewall`/`swap`; `sysctl_tuning` применяется только на старте daemon (если включён) или вручную отдельной командой. `awg2-reboot-scheduler.service` отдельно читает общий config, выбирает случайное время в заданном UTC-окне и запрашивает reboot через `systemctl --no-block reboot`. Пакет **не** устанавливает Docker, **не** устанавливает AmneziaVPN, **не** включает сервисы автоматически и **не** создаёт боевой `/etc/awg2-harness/config.toml`. Это сделано намеренно: SSH, Amnezia и боевой config должны быть подготовлены оператором до включения firewall/reconcile. diff --git a/docs/ACCEPTANCE_CHECKLIST.md b/docs/ACCEPTANCE_CHECKLIST.md index e43c459..8e052c6 100644 --- a/docs/ACCEPTANCE_CHECKLIST.md +++ b/docs/ACCEPTANCE_CHECKLIST.md @@ -139,7 +139,13 @@ - [ ] `swap` пишет `vm.swappiness` в `/etc/sysctl.d/90-awg2-harness-swap.conf`, а не в `99-awg2-harness.conf`. - [ ] При обновлении с прежней версии `/etc/sysctl.d/99-awg2-harness.conf` удаляется, если несёт маркер `# Managed by awg2-harness`. - [ ] Файл по пути `99-awg2-harness.conf` без этого маркера остаётся нетронутым и не ломает `ensure-swap`. -- [ ] При `swap.enabled = false` файл `90-awg2-harness-swap.conf` удаляется, активный swap **не** выключается, runtime `vm.swappiness` не переопределяется. +- [ ] При `swap.enabled = false` файл `90-awg2-harness-swap.conf` удаляется, а swapfile, его активность и запись в `/etc/fstab` **остаются** — после перезагрузки swap снова включится; удаляется только persistence `vm.swappiness`. +- [ ] Изменение только `swap.priority` не пересоздаёт swapfile (проверяется по неизменившемуся mtime файла). +- [ ] При изменении `swap.size_mb` на активном swap хост не остаётся без swap во время `fallocate`/`mkswap`: в `/proc/swaps` всё время присутствует хотя бы одна запись. +- [ ] Если `swapoff` второго чужого swap падает, ранее отключённые чужие swap возвращаются в активное состояние. +- [ ] Нечитаемый managed sysctl-файл даёт в `status` причину `managed sysctl file is unreadable`, а не «файл отсутствует». +- [ ] При `reboot.enabled = false` файл `/run/awg2-harness/reboot.plan` удалён, `status` не показывает устаревший план. +- [ ] После принятия запроса на перезагрузку `reboot.plan` удалён. - [ ] Любой managed-файл под `/etc`, у которого первая строка не содержит маркер владельца, не перезаписывается и не удаляется. - [ ] Runtime apply меняет только drifted keys (`sysctl -w`), без `sysctl --system`. - [ ] При `tcp_bbr = auto` и недоступном BBR сервис не падает, в статусе есть warning. diff --git a/docs/IMPLEMENTATION_SPEC.md b/docs/IMPLEMENTATION_SPEC.md index 2f420ea..a0acd9d 100644 --- a/docs/IMPLEMENTATION_SPEC.md +++ b/docs/IMPLEMENTATION_SPEC.md @@ -123,7 +123,9 @@ awg2-harness validate-runtime Модуль `swap` раньше писал `vm.swappiness` в `/etc/sysctl.d/99-awg2-harness.conf` с другим маркером, без проверки владения перед перезаписью и без удаления при отключении. Файл переехал на `90-`, как и остальные. Старый путь удаляется автоматически, но **только** если он всё ещё несёт прежний маркер `# Managed by awg2-harness`; файл администратора по этому пути не трогается, и это не считается ошибкой миграции. -При `swap.enabled = false` управляемый файл удаляется. Runtime-значение `vm.swappiness` при этом намеренно не меняется: назначать произвольное значение означало бы выдумать политику, о которой оператор не просил. После перезагрузки действует kernel default. Активный swap при отключении модуля тоже не выключается — деструктивное действие не должно запускаться самим фактом отключения функции. +Если управляемый файл существует, но не читается, `status` показывает отдельную причину `managed sysctl file is unreadable`, а не «файл отсутствует». Оператору важна разница между файлом, который пакет ещё не писал, и файлом, который он не может прочитать. + +При `swap.enabled = false` управляемый файл удаляется. **Остаются на месте**: сам swapfile, его активность в ядре и запись в `/etc/fstab` — то есть после перезагрузки swap снова включится. Удаляется только persistence `vm.swappiness`. Runtime-значение `vm.swappiness` при этом намеренно не меняется: назначать произвольное значение означало бы выдумать политику, о которой оператор не просил. После перезагрузки действует kernel default. Активный swap при отключении модуля тоже не выключается — деструктивное действие не должно запускаться самим фактом отключения функции. `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` попадают в целевое состояние только при активном BBR. @@ -572,8 +574,9 @@ Production-путь перезагрузки — внешний бинарь/с - окно берётся из конфига, а не из констант; - случайная точка внутри окна, включая окна через полночь; - вызов остаётся `systemctl --no-block reboot`; -- при `reboot.enabled = false` планирование отключено; -- запланированная отметка времени сохраняется в `/run/awg2-harness/reboot.plan`. +- при `reboot.enabled = false` планирование отключено, а оставшийся от прежней конфигурации `/run/awg2-harness/reboot.plan` удаляется — иначе `status` продолжал бы показывать перезагрузку, которая никогда не наступит; +- запланированная отметка времени сохраняется в `/run/awg2-harness/reboot.plan` атомарно (временный файл, `sync_all`, `rename`), и ошибка записи прекращает работу. Это единственный источник информации о том, когда хост перезагрузится; прежняя best-effort запись позволяла планировщику молча уйти в ожидание момента, о котором в системе не было никаких данных; +- после принятия запроса на перезагрузку план удаляется, чтобы прошедшая отметка времени не выглядела как ожидающая. ## 17. Модуль swap diff --git a/reboot_module/src/main.rs b/reboot_module/src/main.rs index 7b4f319..a491ba2 100644 --- a/reboot_module/src/main.rs +++ b/reboot_module/src/main.rs @@ -1,4 +1,11 @@ -use std::{fs, path::PathBuf, process::Command, thread, time::Duration as StdDuration}; +use std::{ + fs, + io::{ErrorKind, Write}, + path::PathBuf, + process::Command, + thread, + time::Duration as StdDuration, +}; use anyhow::Context; use chrono::{Duration, NaiveDateTime, NaiveTime, Timelike, Utc}; @@ -7,6 +14,7 @@ use rand::Rng; use serde::Deserialize; const DEFAULT_CONFIG_PATH: &str = "/etc/awg2-harness/config.toml"; +const RUNTIME_DIR: &str = "/run/awg2-harness"; const REBOOT_PLAN_FILE: &str = "/run/awg2-harness/reboot.plan"; #[derive(Debug, Parser)] @@ -36,12 +44,15 @@ fn main() -> anyhow::Result<()> { let cfg = load_config(&config_path)?; if !cfg.reboot.enabled { + // A plan left over from when the module was enabled would keep showing up in + // `status` as a scheduled reboot that will never happen. + clear_plan()?; println!("disabled by config"); return Ok(()); } let planned = next_reboot_time(&cfg.reboot.window_start_utc, &cfg.reboot.window_end_utc)?; - persist_plan(planned); + persist_plan(planned)?; println!("reboot planned at UTC {}", planned); loop { @@ -59,6 +70,9 @@ fn main() -> anyhow::Result<()> { .context("failed to run systemctl reboot")?; if out.status.success() { println!("reboot request accepted by systemctl"); + // The plan has been carried out. Leaving it behind would make `status` show a + // past timestamp as if a reboot were still pending. + clear_plan()?; return Ok(()); } eprintln!( @@ -77,14 +91,51 @@ fn load_config(path: &PathBuf) -> anyhow::Result { Ok(cfg) } -fn persist_plan(planned: NaiveDateTime) { - let _ = fs::create_dir_all("/run/awg2-harness"); - let line = format!( +/// Writes the plan atomically, and reports failure. +/// +/// This is the only thing that tells an operator when the host is going to reboot. It used +/// to be entirely best-effort - the directory creation, the write and any error all +/// discarded - so a failure produced a scheduler that would reboot at a time nothing on +/// the system could report. +fn persist_plan(planned: NaiveDateTime) -> anyhow::Result<()> { + fs::create_dir_all(RUNTIME_DIR).with_context(|| format!("failed to create {RUNTIME_DIR}"))?; + + let content = format!( "planned_reboot_utc={}\nplanned_reboot_unix={}\n", planned.format("%Y-%m-%dT%H:%M:%SZ"), planned.and_utc().timestamp() ); - let _ = fs::write(REBOOT_PLAN_FILE, line); + + let temp = PathBuf::from(format!("{REBOOT_PLAN_FILE}.tmp")); + match fs::remove_file(&temp) { + Ok(()) => {} + Err(e) if e.kind() == ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("failed to clear {}", temp.display())), + } + + let mut f = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp) + .with_context(|| format!("failed to create {}", temp.display()))?; + f.write_all(content.as_bytes()) + .with_context(|| format!("failed to write {}", temp.display()))?; + f.sync_all() + .with_context(|| format!("failed to flush {}", temp.display()))?; + drop(f); + + fs::rename(&temp, REBOOT_PLAN_FILE) + .with_context(|| format!("failed to install {REBOOT_PLAN_FILE}"))?; + Ok(()) +} + +/// Removes the plan file. An absent plan is the desired end state, not an error. +fn clear_plan() -> anyhow::Result<()> { + match fs::remove_file(REBOOT_PLAN_FILE) { + Ok(()) => Ok(()), + Err(e) if e.kind() == ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).with_context(|| format!("failed to remove {REBOOT_PLAN_FILE}")), + } } fn next_reboot_time(start_hhmm: &str, end_hhmm: &str) -> anyhow::Result { diff --git a/traffic_core/src/sysctl_tuning.rs b/traffic_core/src/sysctl_tuning.rs index cb32147..3f17a8b 100644 --- a/traffic_core/src/sysctl_tuning.rs +++ b/traffic_core/src/sysctl_tuning.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, fs, path::Path, process::Command, time::Duration}; +use std::{collections::BTreeMap, path::Path, process::Command, time::Duration}; use crate::{config::Config, lock::FileLock, managed_file::ManagedFile}; @@ -92,6 +92,29 @@ pub fn ensure_sysctl_tuning(cfg: &Config) -> anyhow::Result<()> { Ok(()) } +/// Status for a host where a managed file exists but cannot be read. +/// +/// Distinct from "missing": the operator needs to know the difference between a file this +/// package has not written yet and one it cannot inspect. +fn unreadable_status(cfg: &Config, plan: &RenderPlan, detail: String) -> SysctlTuningStatus { + SysctlTuningStatus { + enabled: cfg.sysctl_tuning.enabled, + apply_on_daemon_start: cfg.sysctl_tuning.apply_on_daemon_start, + tcp_bbr_mode: cfg.sysctl_tuning.tcp_bbr.clone(), + conf_path: SYSCTL_CONF_PATH.to_string(), + modules_load_path: BBR_MODULES_LOAD_PATH.to_string(), + persisted_present: false, + persisted_matches: false, + modules_load_present: false, + modules_load_matches: false, + bbr_available: plan.bbr_available, + bbr_active: plan.bbr_active, + current: Vec::new(), + drift_reason: Some(format!("managed sysctl file is unreadable: {detail}")), + warnings: vec![format!("managed sysctl file is unreadable: {detail}")], + } +} + pub fn inspect_sysctl_tuning(cfg: &Config) -> anyhow::Result { let plan = match build_render_plan(cfg, false) { Ok(plan) => plan, @@ -114,13 +137,33 @@ pub fn inspect_sysctl_tuning(cfg: &Config) -> anyhow::Result }); } }; - let persisted_raw = fs::read_to_string(SYSCTL_CONF_PATH).ok(); + // `.ok()` here used to turn a read failure into "the file is absent", so an + // unreadable managed file was reported as missing - a diagnostic saying the opposite + // of what is true. A read error now surfaces as its own drift reason. + let persisted_read = managed(Path::new(SYSCTL_CONF_PATH)).read(); + let modules_load_read = managed(Path::new(BBR_MODULES_LOAD_PATH)).read(); + if let Err(e) = &persisted_read { + return Ok(unreadable_status( + cfg, + &plan, + format!("{SYSCTL_CONF_PATH}: {e}"), + )); + } + if let Err(e) = &modules_load_read { + return Ok(unreadable_status( + cfg, + &plan, + format!("{BBR_MODULES_LOAD_PATH}: {e}"), + )); + } + let persisted_raw = persisted_read.unwrap_or_default(); + let modules_load_raw = modules_load_read.unwrap_or_default(); + let persisted_present = persisted_raw.is_some(); let persisted_matches = persisted_raw .as_deref() .map(|v| normalize(v) == normalize(&plan.persisted)) .unwrap_or(false); - let modules_load_raw = fs::read_to_string(BBR_MODULES_LOAD_PATH).ok(); let modules_load_present = modules_load_raw.is_some(); let modules_load_expected = plan.modules_load_persisted.as_deref(); let modules_load_matches = match (modules_load_raw.as_deref(), modules_load_expected) { @@ -450,6 +493,7 @@ mod tests { AmneziaConfig, AntiTorrentConfig, HostFirewallConfig, RebootConfig, SwapConfig, TrafficConfig, }; + use std::fs; fn cfg() -> Config { Config {