- Rust 92.4%
- Shell 7.1%
- Just 0.3%
- Python 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo | ||
| assets | ||
| docs | ||
| scripts | ||
| src | ||
| tests | ||
| verification | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| install.sh | ||
| justfile | ||
| README.md | ||
| rust-toolchain.toml | ||
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.
- Debian/Ubuntu:
- 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 statusshows the same line. - Logs to
/var/log/kranz/and, optionally, to Ground Control — an OpenObserve instance fed over HTTPS. - Notifies on roadblocks via
shoutrrror a generic webhook. - Never installs an AUR helper for you and never removes essential packages.
Quick start
From a built package (recommended)
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_intervallater). [backup] max_age(default36h) makes a set with no recent snapshot stale:kranz backup statusmarks it and the daemon alerts once per episode.[[backup.set]]protects any absolute paths —/etc,/root,/home— with the same fan-out, retention, status andkranz 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.