No description
  • Rust 92.4%
  • Shell 7.1%
  • Just 0.3%
  • Python 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
system-updater dev 9cf2abd0e1
All checks were successful
build / build (push) Successful in 1m13s
fix(daemon): schedule verification from the recorded attempt, not daemon start
2026-11-07 08:27:22 +00:00
.forgejo ci: publish the checked-in release notes as the release body 2026-11-04 19:38:24 +00:00
assets docs(backup): weekly verification and its status line 2026-11-07 08:26:44 +00:00
docs fix(daemon): schedule verification from the recorded attempt, not daemon start 2026-11-07 08:27:22 +00:00
scripts feat!: rebrand system-updater as kranz (Major Tom) 2026-10-17 16:57:51 +00:00
src fix(daemon): schedule verification from the recorded attempt, not daemon start 2026-11-07 08:27:22 +00:00
tests feat(cli): kranz backup verify 2026-11-07 08:26:20 +00:00
verification feat(install): apply sets in one call and add --dangerous-clear-old 2026-11-04 17:31:31 +00:00
.gitignore docs: add system-updater design and implementation plan 2026-10-14 07:17:08 +00:00
Cargo.lock release: 0.4.0 prep; notes for shutdown provenance 2026-11-06 10:34:41 +00:00
Cargo.toml release: 0.4.0 prep; notes for shutdown provenance 2026-11-06 10:34:41 +00:00
install.sh fix(config): write window times as HH:MM; quote the installer's dry-run sets 2026-11-04 19:10:33 +00:00
justfile feat(verify): just verify / verify-down for the local e2e rig 2026-10-19 15:49:33 +00:00
README.md docs(backup): weekly verification and its status line 2026-11-07 08:26:44 +00:00
rust-toolchain.toml chore: scaffold system-updater crate and CLI skeleton 2026-10-14 07:18:03 +00:00

kranz

Ground control to Major Tom...

A small Rust daemon that keeps a Linux machine's packages current and reboots it only when needed, inside a schedule you control. It supports Debian/Ubuntu (apt) and Arch-derived systems (pacman, optional AUR), runs under systemd, and is designed to feel like magic: set a window and forget about package maintenance.

It fills the gap left by unattended-upgrades (no real reboot scheduling, Debian-only) and by plain pacman -Syu (no scheduling, no reboot policy).

The cast

Name Role
kranz The binary and crate — the flight director you type commands at.
major-tom.service The systemd service — the daemon living on the tin can (your machine).
Major Tom The voice in the logs and telemetry.
Ground Control The receiving end — your OpenObserve instance, configured under [ground_control].

What it does

  • Every check_interval (default 30 minutes) it refreshes package metadata and installs updates if any are available.
    • Debian/Ubuntu: apt-get -y dist-upgrade, non-interactive.
    • Arch: pacman -Syu; AUR too, if enabled and a helper exists.
  • If an update requires a reboot, it reboots inside your configured window — and only when required (or always, if you ask it to).
  • On start, logs how the machine last went down: kranz's own reboot (and why), an external reboot, a poweroff, a halt (container stop or host shutdown), or an unclean stop such as a power loss. kranz status shows the same line.
  • Logs to /var/log/kranz/ and, optionally, to Ground Control — an OpenObserve instance fed over HTTPS.
  • Notifies on roadblocks via shoutrrr or a generic webhook.
  • Never installs an AUR helper for you and never removes essential packages.

Quick start

curl -fsSL https://git.h.srvs.ch/jordan-tec-public/kranz/raw/branch/main/install.sh | sudo bash

This downloads the latest x86_64 binary from the Forgejo release assets, installs it and the systemd unit, writes a default config, creates the AUR build user, and enables the service.

The same command updates an existing install and applies the settings you give it; nothing else on the machine changes. Overrides ride along:

curl -fsSL https://git.h.srvs.ch/jordan-tec-public/kranz/raw/branch/main/install.sh \
  | sudo bash -s -- --set notify.kind=shoutrrr --set notify.url=ntfy://your-topic

To start over from the commented default config, add --dangerous-clear-old: the old file is moved to /etc/kranz/config.toml.bak-<time>, never deleted.

Never sure which keys to pass? docs/config-builder.html is a single page you can open from disk: pick the options and copy the install-or-update command it prints.

Coming from system-updater

The first kranz install on a machine still running the old service retires it automatically: the old system-updater.service is stopped and removed, /etc/system-updater/config.toml is translated to /etc/kranz/config.toml ([observability] becomes [ground_control], old log paths and the AUR build user are renamed), and /var/log/system-updater/ is carried over to /var/log/kranz/. The old config is only dropped once the migrated file validates; if a kranz config already exists it wins and the old one is left beside it. The old system-updater-build account is left in place — remove it with userdel -r system-updater-build if you want it gone.

Upgrading to 0.6.0 — backups are verified, not just taken

Nothing to change in an existing config. Once a week kranz verifies the newest snapshot of every set on every destination, downloading 1% of the files through a throwaway cache so the check reads the destination rather than kopia's own copy. A backup that can no longer be read is a roadblock and a notification, and kranz backup status says when each set was last verified. Run the same check on demand with kranz backup verify — --percent 100 for a full read. Tune it with [backup] verify = "weekly" | "off" and verify_files_percent = 1.

Upgrading to 0.5.0 — safer backups, and paths you choose

Nothing to change in an existing config. Three additions:

  • A reboot kranz performs now waits while a backup is running, so a schedule inside the reboot window can no longer truncate a snapshot. The reboot happens on the next check (at most check_interval later).
  • [backup] max_age (default 36h) makes a set with no recent snapshot stale: kranz backup status marks it and the daemon alerts once per episode.
  • [[backup.set]] protects any absolute paths — /etc, /root, /home — with the same fan-out, retention, status and kranz restore.

See "Upgrading to 0.4.0" below for the shutdown provenance line.

Upgrading to 0.4.0 — the log says why the machine rebooted

Nothing to configure. On start, and in kranz status, kranz now reports how the machine last went down: its own reboot (with the reasons it had), an external reboot, a poweroff, a halt such as a host stopping a container, or an unclean stop. Reboot timing is unchanged — mode, the window, and when_required work exactly as before.

Upgrading to 0.3.0 — telemetry is opt-in per signal

Metrics and logs are now explicit: setting ground_control.url alone ships nothing. Set ground_control.metrics = true and/or ground_control.logs = true alongside kind = "otlp". A config written before 0.3 that already uses kind = "otlp" without either key keeps the previous behaviour (it ships) until 0.4; set a key to false to opt out. Setting metrics/logs = true with a non-OTLP kind is a validation error.

0.3 also adds encrypted backups (kopia), service updates for container groups, S3-compatible backup destinations (endpoint), and SFTP key auth (key_file, known_hosts_file). See docs/configuration.md.

Upgrading to 0.2.0 — OTLP needs ground_control.email

OTLP ingestion now authenticates with Authorization: Basic base64(email:token) instead of Bearer, because OpenObserve routes Bearer down its Dex-JWT path (which kranz's ingestion token is not) and answers 401. A config that already carries ground_control.token keeps working unchanged as the password half — add the user half once:

kranz config set ground_control.email ops@example.com

kranz config check fails with a clear message if kind = "otlp" and a token are set without an email. Nothing needs to be stripped or migrated.

Defaults

The installer writes /etc/kranz/config.toml on first install, containing every option commented out with its default. Until you change something, these sane defaults apply:

Setting Default Meaning
check_interval "30m" Check for updates every 30 minutes.
updates.refresh true Refresh package metadata before checking.
updates.dist_upgrade true Debian/Ubuntu: use dist-upgrade, non-interactive.
updates.cleanup_autoremove / cleanup_autoclean false Leave caches alone.
aur.enabled false AUR updates off (Arch-derived only).
reboot.mode "when_required" Reboot only when an update needs it.
reboot.window.start "02:30" Reboot window opens at 02:30, every day.
reboot.window.end "05:00" Reboot lands on a random point in 02:30-05:00.
reboot.grace_period "2m" Warn logged-in users for 2 minutes.
reboot.on_logged_in "warn" Warn and reboot even if someone is logged in.
logging.file /var/log/kranz/kranz.log Where to log.
logging.level "info" trace | debug | info | warn | error.
logging.max_files 14 Rotated files to keep (applied on restart).
ground_control.kind "none" Ground Control link off until configured.
ground_control.email "" OTLP Basic-auth user; required with kind = "otlp" + token.
ground_control.token "" OTLP credential (password half of Basic email:token).
ground_control.metrics false Set true to ship system, container, and kranz state metrics.
ground_control.metrics_interval "15s" Metrics cadence, independent of check_interval.
ground_control.logs false Set true to ship kranz's own logs over OTLP.
backup.enabled false Encrypted kopia backups off until configured.
backup.schedule "daily" Back up every day at 03:30 (or a custom "HH:MM").
[[service]] — Groups named here are the only ones updated and backed up.
notify.kind "none" No roadblock notifications until configured.

The same file ships as assets/config.toml.example, and kranz config init writes it by hand (--force to overwrite).

Configuration

Edit /etc/kranz/config.toml directly — uncomment a key to change it — or set one key at a time:

sudo kranz config set reboot.window.days_of_week mon,wed,fri

Every key and its meaning is listed in docs/configuration.md. Note that config set rewrites the file, so the commented template is not preserved after use.

Checking before you commit

Preview exactly what would run, changing nothing:

sudo kranz dry-run

Show the detected distro, package manager, reboot state, and next window:

kranz status

Backups (kopia)

Backups are off until you configure them. Declare a group's data and a destination:

[[service]]
name = "web"
compose_file = "/srv/web/compose.yaml"
databases = ["web"]
volumes = ["web_data"]

[backup]
enabled = true
schedule = "daily"
[[backup.destination]]
kind = "s3"
url = "s3://bucket/kranz/web01"

The installer installs a pinned, checksum-verified kopia only when [backup] enabled = true. On first setup run kranz backup init: it generates the repository secret, prints it once, and connects every destination. The secret is never in argv, logs, or the state repo — only in /etc/kranz/kopia.password (0600) and kopia's environment.

Backups run on schedule and before every package update. Restore is always manual and selective, at group, container, or database granularity:

kranz restore web                      # whole group
kranz restore web --database analytics # one database
kranz restore web --container web_data # one container's volumes

Incus instances

Containers and VMs are backed up the same way, opt-in by config. Each instance is exported from a consistent copy, then snapshotted into every destination:

kranz incus add web01            # back up web01 (add --project for others)
kranz incus list
kranz incus restore web01 --yes  # backs up the current state first, then replaces it

A restore records each attempt in the state repo, so the history is append-only. See docs/configuration.md.

Service updates

Container groups named in the config are the only ones touched. Declare how a group updates:

[[service]]
name = "web"
compose_file = "/srv/web/compose.yaml"
mode = "moving"        # follow a moving tag (default "latest")
# mode = "tags"        # newest tag matching tag_pattern, e.g. "v*"
# tag_pattern = "v*"

On each cycle kranz takes a pre-update backup, resolves the new image, commits the desired state to a local git repo (/var/lib/kranz/state/, gix-backed — no git binary), applies it, and verifies the result over a settle window. On failure it fails forward: the failed image stays recorded, the group is held out of further updates, and a persistent incident is raised. Recover by hand with kranz service rollback <name> (a new commit) or kranz service resume <name>. It never reverts on its own and never restores a database automatically.

kranz service list
kranz service status
kranz service update web

Vulnerability intel

kranz can enrich scan findings with context from an intel service: CISA KEV status, distro advisories, and fixed versions. It is off until you set [intel] url, and matching stays with the scanner. Enroll each host once with a single-use code from the service operator:

sudo kranz intel enroll --code-file /root/intel-code   # or pipe the code on stdin
kranz intel status
sudo kranz intel unenroll

See docs/configuration.md for the keys.

Vulnerability scanning

kranz can scan the host's OS packages and every container image a container on this host uses, with a pinned grype. It reports only findings that are new since the last scan, and it never changes an update, a hold, or a reboot. It is off until enabled:

sudo kranz vuln enable     # installs grype if it is missing
sudo kranz vuln scan       # scan now (root: the store and grype cache live under /var/lib/kranz)
sudo kranz vuln status     # last scan per target
sudo kranz vuln disable    # grype stays installed

Scans run every interval (default 6h), one at a time, and a scan waits for any update in progress. grype's vulnerability database lives in /var/lib/kranz/grype/ (about 3 GB); a copy under ~/.cache/grype from manual runs is not used by kranz and can be deleted. The keys are in docs/configuration.md. Context from the intel service (KEV status, fixed versions) is added when [intel] is enrolled.

AUR (Arch-derived systems only)

AUR updates are off by default. Enable them in the config:

[aur]
enabled = true
# helper = "paru"          # auto-detected if left empty
# build_user = "kranz-build"

AUR helpers never run as root, so the installer creates an unprivileged kranz-build user with a scoped passwordless sudoers rule. Use --no-aur-setup to manage that user yourself, or --aur-user <name> to rename it. If no helper is found, AUR is skipped — never installed.

Uninstalling

curl -fsSL https://git.h.srvs.ch/jordan-tec-public/kranz/raw/branch/main/install.sh \
  | sudo bash -s -- --uninstall

The config and logs are left in place; remove /etc/kranz/ manually to purge.

Coexistence with unattended-upgrades

The installer never touches unattended-upgrades. See docs/unattended-upgrades.md for the two supported postures.

For maintainers: CI runner

The GitHub-style workflow in .forgejo/workflows/build.yml runs on a self-hosted Forgejo runner. See docs/ci.md for the runner's labels, required tools, actions, and how the release publish authenticates.

License

See the repository for licensing information.