fix: make the reboot plan trustworthy and report unreadable sysctl files [P2]
Three diagnostic defects from the follow-up audit. 1. The reboot plan was entirely best-effort. persist_plan discarded the directory creation, the write and every error. The plan file is the only thing on the host that says when it is going to reboot, so a failed write produced a scheduler sitting on a timer that nothing could report. It now creates the directory, writes through a temp file with sync_all and rename, and returns Result. The plan also outlived its meaning in two places. With reboot.enabled = false the scheduler exited without touching a plan written when it was still enabled, so status kept advertising a reboot that would never happen. And after systemd accepted the reboot request the plan stayed behind, leaving a past timestamp looking pending. Both paths now clear it; an absent plan is the desired end state, not an error. 2. sysctl status turned a read failure into "file absent". inspect_sysctl_tuning used fs::read_to_string(..).ok() for both managed files, so an unreadable file was reported as missing - a diagnostic stating the opposite of the truth, in the module whose whole job is to report drift accurately. Reads now go through ManagedFile::read, which distinguishes absent from unreadable, and an unreadable file produces its own drift reason naming the path and the error. 3. README described the removed ExecStartPre. It still said the service runs ensure-swap before starting the daemon. The daemon performs it as the first step of its own startup sequence. Docs also state explicitly what survives swap.enabled = false, which was implied but never written: the swapfile, its activation and the /etc/fstab entry all remain, so swap comes back after a reboot. Only the vm.swappiness persistence is removed. Acceptance checklist gained seven checks covering the priority-only path not recreating the file, swap remaining active throughout a resize, foreign-swap rollback, the unreadable status, and both reboot-plan lifecycle cases. cargo test --workspace: 179 + 4 passed. clippy -D warnings clean.
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
/lib/systemd/system/awg2-reboot-scheduler.service
|
/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.
|
Пакет **не** устанавливает Docker, **не** устанавливает AmneziaVPN, **не** включает сервисы автоматически и **не** создаёт боевой `/etc/awg2-harness/config.toml`. Это сделано намеренно: SSH, Amnezia и боевой config должны быть подготовлены оператором до включения firewall/reconcile.
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,13 @@
|
|||||||
- [ ] `swap` пишет `vm.swappiness` в `/etc/sysctl.d/90-awg2-harness-swap.conf`, а не в `99-awg2-harness.conf`.
|
- [ ] `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`.
|
- [ ] При обновлении с прежней версии `/etc/sysctl.d/99-awg2-harness.conf` удаляется, если несёт маркер `# Managed by awg2-harness`.
|
||||||
- [ ] Файл по пути `99-awg2-harness.conf` без этого маркера остаётся нетронутым и не ломает `ensure-swap`.
|
- [ ] Файл по пути `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`, у которого первая строка не содержит маркер владельца, не перезаписывается и не удаляется.
|
- [ ] Любой managed-файл под `/etc`, у которого первая строка не содержит маркер владельца, не перезаписывается и не удаляется.
|
||||||
- [ ] Runtime apply меняет только drifted keys (`sysctl -w`), без `sysctl --system`.
|
- [ ] Runtime apply меняет только drifted keys (`sysctl -w`), без `sysctl --system`.
|
||||||
- [ ] При `tcp_bbr = auto` и недоступном BBR сервис не падает, в статусе есть warning.
|
- [ ] При `tcp_bbr = auto` и недоступном BBR сервис не падает, в статусе есть warning.
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ awg2-harness validate-runtime
|
|||||||
|
|
||||||
Модуль `swap` раньше писал `vm.swappiness` в `/etc/sysctl.d/99-awg2-harness.conf` с другим маркером, без проверки владения перед перезаписью и без удаления при отключении. Файл переехал на `90-`, как и остальные. Старый путь удаляется автоматически, но **только** если он всё ещё несёт прежний маркер `# Managed by awg2-harness`; файл администратора по этому пути не трогается, и это не считается ошибкой миграции.
|
Модуль `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.
|
`net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` попадают в целевое состояние только при активном BBR.
|
||||||
|
|
||||||
@@ -572,8 +574,9 @@ Production-путь перезагрузки — внешний бинарь/с
|
|||||||
- окно берётся из конфига, а не из констант;
|
- окно берётся из конфига, а не из констант;
|
||||||
- случайная точка внутри окна, включая окна через полночь;
|
- случайная точка внутри окна, включая окна через полночь;
|
||||||
- вызов остаётся `systemctl --no-block reboot`;
|
- вызов остаётся `systemctl --no-block reboot`;
|
||||||
- при `reboot.enabled = false` планирование отключено;
|
- при `reboot.enabled = false` планирование отключено, а оставшийся от прежней конфигурации `/run/awg2-harness/reboot.plan` удаляется — иначе `status` продолжал бы показывать перезагрузку, которая никогда не наступит;
|
||||||
- запланированная отметка времени сохраняется в `/run/awg2-harness/reboot.plan`.
|
- запланированная отметка времени сохраняется в `/run/awg2-harness/reboot.plan` атомарно (временный файл, `sync_all`, `rename`), и ошибка записи прекращает работу. Это единственный источник информации о том, когда хост перезагрузится; прежняя best-effort запись позволяла планировщику молча уйти в ожидание момента, о котором в системе не было никаких данных;
|
||||||
|
- после принятия запроса на перезагрузку план удаляется, чтобы прошедшая отметка времени не выглядела как ожидающая.
|
||||||
|
|
||||||
## 17. Модуль swap
|
## 17. Модуль swap
|
||||||
|
|
||||||
|
|||||||
@@ -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 anyhow::Context;
|
||||||
use chrono::{Duration, NaiveDateTime, NaiveTime, Timelike, Utc};
|
use chrono::{Duration, NaiveDateTime, NaiveTime, Timelike, Utc};
|
||||||
@@ -7,6 +14,7 @@ use rand::Rng;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = "/etc/awg2-harness/config.toml";
|
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";
|
const REBOOT_PLAN_FILE: &str = "/run/awg2-harness/reboot.plan";
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
@@ -36,12 +44,15 @@ fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let cfg = load_config(&config_path)?;
|
let cfg = load_config(&config_path)?;
|
||||||
if !cfg.reboot.enabled {
|
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");
|
println!("disabled by config");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let planned = next_reboot_time(&cfg.reboot.window_start_utc, &cfg.reboot.window_end_utc)?;
|
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);
|
println!("reboot planned at UTC {}", planned);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -59,6 +70,9 @@ fn main() -> anyhow::Result<()> {
|
|||||||
.context("failed to run systemctl reboot")?;
|
.context("failed to run systemctl reboot")?;
|
||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
println!("reboot request accepted by systemctl");
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -77,14 +91,51 @@ fn load_config(path: &PathBuf) -> anyhow::Result<RootConfig> {
|
|||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_plan(planned: NaiveDateTime) {
|
/// Writes the plan atomically, and reports failure.
|
||||||
let _ = fs::create_dir_all("/run/awg2-harness");
|
///
|
||||||
let line = format!(
|
/// 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_reboot_utc={}\nplanned_reboot_unix={}\n",
|
||||||
planned.format("%Y-%m-%dT%H:%M:%SZ"),
|
planned.format("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
planned.and_utc().timestamp()
|
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<NaiveDateTime> {
|
fn next_reboot_time(start_hhmm: &str, end_hhmm: &str) -> anyhow::Result<NaiveDateTime> {
|
||||||
|
|||||||
@@ -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};
|
use crate::{config::Config, lock::FileLock, managed_file::ManagedFile};
|
||||||
|
|
||||||
@@ -92,6 +92,29 @@ pub fn ensure_sysctl_tuning(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
Ok(())
|
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<SysctlTuningStatus> {
|
pub fn inspect_sysctl_tuning(cfg: &Config) -> anyhow::Result<SysctlTuningStatus> {
|
||||||
let plan = match build_render_plan(cfg, false) {
|
let plan = match build_render_plan(cfg, false) {
|
||||||
Ok(plan) => plan,
|
Ok(plan) => plan,
|
||||||
@@ -114,13 +137,33 @@ pub fn inspect_sysctl_tuning(cfg: &Config) -> anyhow::Result<SysctlTuningStatus>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
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_present = persisted_raw.is_some();
|
||||||
let persisted_matches = persisted_raw
|
let persisted_matches = persisted_raw
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|v| normalize(v) == normalize(&plan.persisted))
|
.map(|v| normalize(v) == normalize(&plan.persisted))
|
||||||
.unwrap_or(false);
|
.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_present = modules_load_raw.is_some();
|
||||||
let modules_load_expected = plan.modules_load_persisted.as_deref();
|
let modules_load_expected = plan.modules_load_persisted.as_deref();
|
||||||
let modules_load_matches = match (modules_load_raw.as_deref(), modules_load_expected) {
|
let modules_load_matches = match (modules_load_raw.as_deref(), modules_load_expected) {
|
||||||
@@ -450,6 +493,7 @@ mod tests {
|
|||||||
AmneziaConfig, AntiTorrentConfig, HostFirewallConfig, RebootConfig, SwapConfig,
|
AmneziaConfig, AntiTorrentConfig, HostFirewallConfig, RebootConfig, SwapConfig,
|
||||||
TrafficConfig,
|
TrafficConfig,
|
||||||
};
|
};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
fn cfg() -> Config {
|
fn cfg() -> Config {
|
||||||
Config {
|
Config {
|
||||||
|
|||||||
Reference in New Issue
Block a user