CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
All notable changes are recorded here. Every claim links back to a measured
decision record (`K-…`) in [`docs/decisions.md`](docs/decisions.md).
## v0.11.0 — 2026-11-06
A saved server list for the CLI and the desktop app (K-162) and a new desktop frame (K-163). The
protocol is unchanged (3), so a server that stays on v0.10.0 keeps working with this client. The
server programs are rebuilt with the new version number; `kadran-caddy` also carries a security fix
(below), so upgrade your servers to get it. The desktop app is not among the release files: build it
from source in `desktop/` (see the [desktop guide](docs/guides/desktop.md)).
### Security
- `kadran-caddy` is built with `github.com/klauspost/compress` v1.20.2, which fixes
[GO-2026-6865](https://pkg.go.dev/vuln/GO-2026-6865) (a panic on two-block frames). Kadran's proxy
compresses responses with zstd and gzip through this library. Upgrade your servers to v0.11.0 to
get the fixed binary (recorded under K-163).
### Added
- A saved server list shared by the CLI and the desktop app (K-162). `kadran server add|list|rm|rename`
manages it, and `-s <name>` picks a saved server on every command: `kadran status -s hetzner`,
`kadran bootstrap -s gcp` (install and upgrade use the saved admin target and `-sudo`). The name
must match exactly, and `-s` cannot be combined with a target. The list is
`servers.json` in your config directory and holds no key, password or token. The list lives on
your machine only; the server needs nothing new for it.
- Desktop: servers added from the CLI appear when you switch back to the window. On first start the
desktop's own list is copied into the shared one once; the old `profiles.json` is left as it was.
### Changed
- Desktop: glass is now only on the window frame (sidebar, title bar and the gaps), in both themes.
Content sits on opaque panels that float inside the glass. On Windows the frame uses Acrylic and
stays glass while the window is in the background; without native blur, or with reduced
transparency, the frame is opaque (K-163).
- Desktop: two themes, light (the default) and dark. The glass theme is gone; a saved glass setting
opens as light (K-163).
- Desktop: switching themes repaints the whole window at once. The sidebar selection slides to the
chosen row and a highlight follows the pointer; buttons no longer move on hover (K-163).
The new frame was checked in a real window on Windows 11 only; macOS, Linux and Windows 10 were not
run.
### Fixed
- `kadran domain check -s <name>` without a domain no longer probes the saved server: it stops before
any DNS, TCP or HTTP attempt (K-162).
- Server names with invisible characters (zero-width space, right-to-left marks, soft hyphen) are
rejected, so two entries can't look the same; the one-time desktop copy drops them (K-162).
- Copied command hints quote server names for bash, PowerShell and cmd, and never put a name with
characters those shells interpret inside quotes into a command (K-162).
- Desktop: adding servers quickly no longer loses some of them, and removing a server already deleted
from the CLI drops its card at once (K-162).
## v0.10.0 — 2026-11-02
A new look for the desktop app (K-161). The protocol is unchanged (3). The CLI and the server
programs are rebuilt with the new version number and nothing else, so a server that stays on v0.9.3
keeps working and needs no upgrade. The desktop app is not among the release files: build it from
source in `desktop/` (see the [desktop guide](docs/guides/desktop.md)).
### Changed
- Desktop: the layout follows Coolify's design language. The app opens on a card for every saved
server under a plain Windows-style title bar, with tabs once two or more servers are open, and a
new wordmark. The cards read your saved servers at once with the existing calls, so the server
does not change (K-161).
- Desktop: three themes, dark, light and glass. Glass is the default and shows your desktop blurred
behind the window; on Windows 11 it stays glass while the window is in the background. On systems
without native blur it isn't offered and the window is dark (K-161).
- Desktop: the window modes (Clear, Acrylic, Mica, Vibrancy, Solid), the tone switch and the glass
sliders are gone. Settings keep language and fonts (K-161).
The new look was checked in a real window on Windows 11 only; macOS, Linux and Windows 10 were not
run.
## v0.9.3 — 2026-10-30
Install from a release: no checkout and no Go on your machine (K-159). The protocol is unchanged
(3). The server programs are rebuilt with the new version number and nothing else, so a server that
stays on v0.9.2 keeps working. Only the v0.9.3 client can install from a release: an older
`kadran bootstrap` still needs local files.
### Added
- `kadran bootstrap` installs from the GitHub release of the client's own version: no checkout and
no Go on your machine. It refuses the server bundle unless it matches a SHA-256 built into the
client, and sends no files to the server before that check. `-binaries`, `-repo`,
`KADRAN_BINARY_DIR` and source builds keep installing from local files; `KADRAN_RELEASE_BASE`
points it at a mirror, and the hash is still checked (K-159).
- `kadran version` prints the server bundle hashes built into the client (K-159).
- `scripts/get-kadran.sh` (Linux, macOS) and `scripts/get-kadran.ps1` (Windows) install the client
from a release in one line, without administrator rights. They check `SHA256SUMS` and leave an
existing `kadran` untouched if anything fails (K-159).
### Fixed
- `kadran bootstrap` no longer rewrites a server program whose content has not changed; it only
resets its owner and mode. Rewriting left a service that was not restarted, such as the reverse
proxy, running from a deleted file. Ubuntu's `needrestart` then restarted it, and the proxy
broker with it, at the next package upgrade: the proxy stopped for about a second at that
hour (K-158).
## v0.9.2 — 2026-10-29
The authenticator entry's name now tells two setups of the same server apart (K-157). The protocol
is unchanged (3). Only `kadrand` changed: the other programs, the `kadran` client included, are
rebuilt with the new version number and nothing else, so a v0.9.1 client works with a v0.9.2
server.
### Changed
- The authenticator app entry is named after the server and the day TOTP was set up (UTC), for
example `Kadran: myhost 2026-10-29`. After TOTP is reset and set up again on the same server,
the old entry stays on the phone and never works again; it used to have the same name as the
new one (K-157). Only new setups get the date; existing entries keep their name. The label is
made by the server, so this needs the server upgraded.
## v0.9.1 — 2026-10-29
Easier TOTP setup, found when two-step sign-in was first turned on for real (K-156). The protocol
is unchanged (3). Only the `kadran` client changed: the server programs are rebuilt with the new
version number and nothing else, so upgrading the server is optional and a v0.9.1 client works
with a v0.9.0 server.
### Changed
- `kadran totp enable` shows a QR code to scan with the authenticator app, and the setup key in
one piece. When TOTP was first turned on for real, an app rejected the key grouped with spaces
(K-156). On a dark terminal the QR shows as light squares on the dark background, with no white
box; it leaves out the default parameters (SHA1, 6 digits, 30 seconds) to stay small. The key
and the complete `otpauth://` link are still printed below it, for apps that cannot read a
light-on-dark code.
- The code prompt reads `6-digit code from the app`. Input that is not 6 digits (the setup key, a
recovery code, a typo) is no longer sent to the server, so it does not count toward the lock:
kadran says what it looks like and asks again, up to 3 times (K-156).
- The `kadran` client now includes `rsc.io/qr` (BSD-3-Clause), listed in
`THIRD_PARTY_LICENSES.txt`. The server programs do not.
## v0.9.0 — 2026-10-28
Two-step sign-in with TOTP (K-155) and a strict check of the reverse proxy's live config
(K-154). The protocol is unchanged (3), so v0.8.x clients keep working; once TOTP is on, their
admin commands are refused with a message to upgrade and sign in, while deploy-only keys are
not affected. TOTP is off after the upgrade: turn it on with `kadran totp enable` once every
workstation runs a v0.9.0 client. Upgrade the server with `bootstrap` as usual. Before going
back to v0.8.x, read "Rolling back to v0.8.x" below. Building from source needs Go 1.26.10.
### Added
- Two-step sign-in (K-155). With TOTP on, an admin key alone can no longer manage the server:
`kadran login` exchanges a code from an authenticator app for an 8-hour session token, bound to
the signing-in key. `kadran totp enable|disable|status` manage it, `kadran logout` deletes the
local token, and the desktop uses the same saved session. TOTP is off until you turn it on,
and deploy-only CI keys are never affected. See the [TOTP guide](docs/guides/totp.md).
- Lockout and alarms: after 5 wrong codes a key is locked for 1 minute, doubling up to 1 hour.
Each attempt is counted before the code is checked. A lock raises a warning alarm, and so does
every use of a recovery code; turning TOTP off or resetting it closes them. Every sign-in,
failure and change goes into the audit log, never with the code, secret or token.
- 10 single-use recovery codes per enrollment. Turning TOTP off and re-enrolling need a fresh
code on top of the session; `-recovery` takes a recovery code instead, so a lost phone can be
replaced without root. Re-enrolling rotates the key that signs session tokens. Codes typed
with spaces (`123 456`) are accepted.
- `kadran totp reset [-sudo] root@server` turns TOTP off through root when the phone and the
recovery codes are both lost. On the server it runs `kadrand -totp-reset` as the `kadran`
user, and the daemon keeps running.
- The protocol stays 3: older clients talking to a server with TOTP on are told to upgrade and
sign in, and a newer client talking to an older server says the server does not support TOTP.
### Rolling back to v0.8.x
An older `kadrand` does not know TOTP and does not enforce it: every admin key works again
without a session. The database is left as it is, because an older `kadrand` skips the
migrations it does not know, so upgrading again turns TOTP back on with the same secret and
recovery codes (K-155).
### Fixed
- The proxy broker's live readback now decodes Caddy's config strictly. A field Kadran does
not model, a key spelled in a different case, or trailing data fails the check instead of
being dropped. Before, a config with `abort`, a `transport`, a file-reading
`handle_response` or a file-writing log `writer` added through the raw admin socket passed
as safe. Exploiting it needs write access to that socket, so this is defence in depth; a
tampered config now raises the critical proxy alarm (K-154).
- The startup alarm's detail carried a Turkish fragment in the middle of its English sentence
("rotalanamayan uygulama var:"); it now reads "some apps could not be routed:" (K-153).
- Backup audit records use the parameter keys `file` and `bytes`, like the other records; they
were `dosya` and `bayt`. Records already stored keep their keys and still verify (K-153).
### Changed
- The English-only check now also looks for Turkish words written without Turkish letters, the
class both fixes above slipped through (K-153).
## v0.8.1 — 2026-10-23
Client fixes to the DNS warnings, found while moving a live site to Kadran (K-151, K-152), and
English step headers in `bootstrap`. The protocol is unchanged (3). Only the `kadran` client
changed: the server programs are rebuilt with the new version number and nothing else, so
upgrading the server is optional and a v0.8.1 client works with a v0.8.0 server.
### Fixed
- A redirect name whose record is already right at the DNS host, while this machine still has
the old answer cached, was reported as "does not point at the server yet". That case is a
warning, not a stop: `app create/update` now prints the same reason as `kadran domain check`,
with the name in front. The exit code is unchanged (K-152).
- The stale-cache warning (in `kadran domain check`, the `-domain` precheck and the redirect
names) no longer suggests `kadran domain check -wait 10m`. The command had no domain or
target, and `-wait` only waits while the check stops, so it returned at once (K-151).
- `bootstrap` prints its "Binaries", "Vault" and "Reverse proxy" step headers in English; they
were in Turkish.
## v0.8.0 — 2026-10-22
DNS guidance and www/redirect names (K-151, K-152). The protocol is unchanged (3), so v0.7.x
clients keep working; a v0.8.0 client talking to an older server warns instead of claiming
that redirects exist. Apps created before this release keep www off after the upgrade; turn it
on with `kadran app update -www <app> <target>`. Upgrade the server with `bootstrap` as usual:
it restarts the proxy broker before `kadrand`. Before going back to v0.7.x, read "Rolling back
to v0.7.x" below. Building from source needs Go 1.26.10.
### Added
- DNS guidance (K-151): when a domain does not point at the server, `app create/update -domain`
and `kadran domain check` name the DNS host (~50 providers), print the exact record to add and
the records to delete, the place in that provider's panel and its official guide. See the
[domain setup guide](docs/guides/domains.md).
- `kadran domain check` asks the DNS host's own nameservers, so a freshly changed record is
recognised before caches expire; `-wait 10m` waits until the record points at the server.
A negative `-wait` is rejected, and Ctrl-C while waiting stops without running the remaining
checks.
- `kadran domain check` shows the domain's accredited registrar (RDAP).
- www and redirect names (K-152): `app create` also serves the www partner of a registrable
domain (or the bare domain when the domain is `www.…`) and redirects it with a 308 to
`https://<domain>`, keeping path and query. `-no-www` turns it off. `app create` and
`app update` take `-redirect <name>` (repeatable, at most 8 per app); `app update` also has
`-www`, `-no-www` and `-redirect-rm <name>`. Subdomains get no partner. Apps that exist
before the upgrade keep www off. `app update -domain` moves the names along and reports them.
See [www and other names](docs/guides/domains.md#www-and-other-names).
- Redirect names are checked for DNS after the server accepts the request; the check only warns
and `-skip-dns-check` skips it (K-152). `app show` prints a `Redirects` line.
- The proxy broker accepts one non-proxy handler: a fixed-shape 308 redirect whose target must
be a routed domain (K-152). A name that collides with another app's domain or name is
rejected by the API; a collision that gets through drops only that redirect, never the whole
route set. `kadrand` logs every name it could not apply (startup, deploy, heal and the proxy
watcher), not only `app update`.
- For an app that is not routed at the moment, removing a name takes effect at once; an added
name is saved and applied once the app is routed again (K-152).
### Security
- Both modules and CI now require Go 1.26.10, which fixes GO-2026-6809 in `crypto/x509`
(compromised intermediate certificates are distrusted); `govulncheck` reported it reachable
through the HTTP health probe. Building from source needs Go 1.26.10.
### Rolling back to v0.7.x
Remove redirects (`-no-www` and `-redirect-rm`; this works even for an app that is not routed)
before installing a `kadrand` older than this release (K-152). The older `kadrand` rejects the
proxy broker's reply while the live route set carries redirects, so proxy repair and any
reconcile with an app that is not routed fail until a reconcile with every app routed loads a
set without redirects.
### Changed
- A record set that contains the server AND other addresses now stops the DNS precheck (it used
to pass): part of the visitors and of Let's Encrypt's validation reached the other addresses.
## v0.7.0 — 2026-10-15
English server texts and a pinned Docker address pool (K-149, K-150). The protocol is
unchanged (3), so v0.6.x clients keep working. Upgrading with `bootstrap` writes
`/etc/docker/daemon.json` when the server has none. On a server that already runs
containers it takes effect at the next dockerd restart; run
`sudo systemctl restart docker` in a maintenance window, because it stops running
containers briefly. If you search the journal or forward it elsewhere, read the text
changes below first. Building from source needs Go 1.26.9.
### Changed
- **Docker's address pool is pinned (K-150).** When `/etc/docker/daemon.json` does not exist,
`kadran bootstrap` writes it with `default-address-pools` set to `172.16.0.0/12`, `/16` per
network. dockerd reads it only on restart: on a server with no containers bootstrap
restarts dockerd; otherwise it warns and leaves the restart to you, because running
containers stop briefly. An existing `daemon.json` is never changed. The executor also
checks each app network's subnet and stops the deploy when it falls outside
`172.16.0.0/12` (Docker's default pool moves to `192.168.0.0/16` after about 15 networks).
Before, such a deploy failed later on the health probe and the proxy. See the
[operations guide](docs/guides/operations.md#docker-adres-havuzu).
- **Server journal, alarm and Telegram texts are English (K-150).** This covers kadrand's and
kadran-exec's log messages and field names, the systemd status line, alarm details, the
alarm sender's Telegram messages and the heartbeat Worker's messages. If you search the
journal for the old Turkish text, update the search: `yetki reddedildi` is now
`authorization denied`, `rol=` is `role=`, `anahtar=` is `key=`, `uygulama=` is `app=`,
`hata=` is `error=`. The `ALARM` line carries `state=opened|escalated|closed`,
`severity=critical|warning`, `target=` and `detail=`. The sender still reads lines that
were written with the old keys just before an upgrade. Watchdog loops are named
`supervisor`, `proxy-watch`, `disk` and `backup`. Alarms already stored keep their
Turkish detail. A heartbeat Worker you deployed earlier keeps its old texts until you
run `npx wrangler deploy` again.
- **CI skips the mutation job when only documentation changed (K-150).** A change that
touches nothing but `*.md` files and `docs/` no longer waits ~45 minutes for mutation
testing. Any other file, or a diff CI cannot compute, still runs it.
- **No `NOTICE` file (K-149).** Release packages carry `LICENSE` and
`THIRD_PARTY_LICENSES.txt`; the latter already reproduces the `NOTICE` files of the Go
modules compiled in. The desktop app's bundled icons, font and runtime packages have
their license texts in the app's `THIRD_PARTY_NOTICES.txt`.
## v0.6.0 — 2026-10-14
Desktop management and a typed proxy broker (K-142–K-148). Upgrading with `bootstrap`
installs the `kadran-proxy` broker and takes the daemon off Caddy's raw admin socket. The
reverse proxy binary and its units change, so the proxy restarts: plan a short maintenance
window. Before upgrading, run the checks in the [upgrade guide](docs/guides/quality-upgrade.md):
app domains, Docker address pools and, if you use it, the private-Git drop-in. Building
from source needs Go 1.26.9.
### Added
- **Typed proxy broker (K-143).** The daemon can submit validated routes through
`kadran-proxy`; it can no longer send arbitrary JSON to Caddy's raw admin socket.
- **Desktop resource management and original Kadran logo (K-145).** Applications,
configuration, deployments, rollback, bounded logs, alarms, backups and audit
details are available in the glass interface, including the 760×520 window.
- **Private GitHub archives (K-146).** An opt-in executor credential file scopes
access to configured repositories. Credentials never enter Docker build URLs,
renderer state or deployment records. See `docs/guides/private-git.md`.
- **New desktop app (K-142).** A glass window — Clear, Acrylic, Mica or Vibrancy, with
the desktop showing through — and Appearance settings you tune yourself: tint, panel
glass, edge highlight, hover glow, accent, theme, interface and code fonts (any
installed font; JetBrains Mono ships with the app). Status gets an overview strip with
live sparklines, a health pill and a server switcher; Audit gets ranges, filters, an
outcome bar and "load older". English by default, Turkish in Settings.
- **The desktop app asks for no permissions** except the installed-font list. Before
this release Electron granted every permission request.
### Changed
- **Patched build dependencies (K-147).** Both modules and CI require Go 1.26.9;
the separate proxy module uses Caddy 2.11.7. Reachable dependency findings were
removed; one unused module-level OpenPGP advisory remains in the module graph.
- **The CLI speaks English (K-141).** Help, usage errors, tables, status labels and every
message the CLI produces itself are English, including `bootstrap`'s own steps and the
`kadran key` messages from the server-side script it ships. Commands and flags did not
change. Two labels changed meaning on screen: a broken audit chain now shows `BROKEN`
(was `GEÇERSİZ`) and an unverifiable one `UNVERIFIABLE`, so scripts can tell them apart
with `grep`. Errors the server sends back are English once the server is upgraded
too; an older server still answers in Turkish.
- **The server-side scripts speak English (K-141).** The install, migration and rollback
output of `bootstrap`, the vault rollback tool and the offsite and volume backup units
print English, and `deploy/offsite/README.md` is English. The offsite summary line is
now `summary: uploaded=… skipped=… failed=…` (was `özet: yüklendi=… atlandı=…
başarısız=…`); adjust anything that greps the journal for it. `OFFSITE_PRUNE` still
takes `evet`/`hayir`: it is a value in your configuration file, so it did not change.
- **Errors the server sends back are English (K-141).** Validation errors, permission
denials (`this key can only deploy`, `app is outside this key's scope`), deploy, rollback
and heal failures, `app update` warnings, the chain status details of `audit verify`, and
the `-h` text of `kadrand`, `kadran-exec`, `kadran-connect` and `kadran-vault`. `app prune`
labels the releases it keeps `active` and `rollback target`. New audit records carry
English details; records written before the upgrade keep theirs, since the chain is
append-only. Still Turkish: the server's journal lines (`journalctl -u kadrand`), alarm
and Telegram texts, and the database's own trigger messages.
### Fixed
- **Concurrent lifecycle operations (K-144).** Deploy, rollback, configuration,
deletion, pruning and automatic healing share a cancellable per-application queue.
Skipped target routes and failed proxy loads cannot report successful deployment.
Current validated routes for skipped existing domains are retained. Health probes
reject truncated or stalled response bodies; at most 4 KiB of the body is read, and a body that reaches that limit without error is healthy (the connection is closed without draining the rest).
- **Deletion fails closed (K-144).** Database errors while looking up the active
deployment no longer authorize container deletion.
- **Desktop protocol and stale state (K-145).** UTF-8 is decoded across chunks;
management results are shape-checked; deploy needs a matching success event;
connection setup counts toward the operation deadline. Failed or expired audit
verification clears a previous green result, and stale health cannot remain green.
- **Secret configuration edits (K-145).** An untouched secret placeholder does not
overwrite the stored secret; replacement and removal require explicit input.
- **Every desktop change asks first (K-148).** Create, change, deploy, rollback,
delete and backup each show a native confirmation in the main process. Cancel is
the default, and only one dialog can be open at a time. The dialog names the
server and shows what will change. Environment variables and build arguments show
names only, never values. Long values are cut in the middle so the host of
`user@host` stays visible. Invisible and look-alike characters are shown as escapes.
- **The desktop no longer gives up before the server (K-148).** Deploy, rollback,
delete and backup wait one minute longer than the sidecar does. Before, the
desktop could time out while a rollback was still running on the server and let
you start it a second time.
- **Desktop errors show the real reason (K-148).** Instead of only `call failed`,
the desktop shows the server's reason, with tokens and credentials removed. An
update sends only the fields you changed. Profiles are written atomically.
- **Migration starts the proxy broker before the daemon (K-148).** Upgrading to the
typed broker could restart `kadrand` before the broker's socket existed.
- **The proxy broker refuses network, gateway and broadcast addresses (K-148).**
A compromised daemon could point a route at the host through the Docker gateway.
- **One domain rule (K-148).** The API and the proxy now validate domains with the
same function. An app whose stored domain the proxy rejects is skipped instead of
blocking every route. ⚠ If such an app exists from an older version, it loses its
live route at the next reconcile and shows as skipped: fix its domain with
`kadran app update -domain <name> <app> <target>`.
- **Private Git archives with a pax global header are accepted (K-148).**
- **The private Git drop-in blocks cloud metadata (K-148).** The documented
`kadran-exec` drop-in now denies link-local addresses, including
169.254.169.254. If you already use it, add the `IPAddressDeny=` line from
`docs/guides/private-git.md`. Measure DNS first: on GCP the resolver can be the
metadata address.
- **Desktop "recent audit records" showed the oldest records.** It asked for the first
50 records after sequence 0; once a chain had more than 50 records, new ones never
appeared. It now reads the latest sequence first.
## v0.5.1 — 2026-10-05
Hardening of the client's SSH files (K-139, K-140). Same protocol and the same reverse
proxy binary as v0.5.0: upgrading with `bootstrap` does not restart the proxy (measured on
the test server). If you edited `/var/lib/kadran-client/.ssh` as root, `chown` it back to
`kadran-client` first (see the behaviour change below).
### Security
- **`bootstrap` writes the client's `.ssh` and `authorized_keys` as `kadran-client`
(K-139).** Root still reads the staged key and refuses a symlinked or non-regular
target, but the write itself now runs under `setpriv` as that user. Before, a link
swapped in between the check and the write made root read the link's target or change
the mode of the directory it pointed to. The file is born owned by the user with mode
0600, and the install step no longer runs `chown -R` or `chmod` there as root. One
path still writes that file as root: the one-time panely → kadran migration (K-136,
upgrades from v0.3.x and older).
- **`kadran key list/add/remove` touch `authorized_keys` as `kadran-client` (K-140).**
The server-side script still starts as root (SSH or `-sudo`) and re-runs itself under
`setpriv` as that user. Before, it had no link check at all: if `authorized_keys` was a
link to a root-only file, root listed that file and copied it into the new
`authorized_keys`. A failed read while removing a key is no longer ignored.
- Behaviour change: if `authorized_keys` or `.ssh` is owned by root (for example after
a manual edit as root), `bootstrap` stops without touching it and prints the `chown`
command to fix it; `kadran key` refuses the same file with the same hint. Before, both
went ahead as root and kept or fixed the owner silently.
## v0.5.0 — 2026-10-04
### Secret store (K-123)
Environment variable values are now sealed with the executor's key before they reach
the database. The daemon writes them but cannot read them; the executor opens them only
while it creates a container.
- **Each value is bound to its app and name.** The sealed plaintext is
`app NUL name NUL value`, and the executor refuses a value moved to another variable or
app. A compromised daemon can no longer copy one app's database password into a
variable another app logs.
- **The store is mandatory.** The executor rejects unsealed values; there is no plaintext
mode.
- **Upgrading seals existing values** on first start, empty ones included, in one
transaction, then runs `VACUUM` so old plaintext pages leave the database file. Measured:
without it, 17 of 30 fragments of a deleted app's secret stayed in the file.
- `bootstrap` creates the key with the new `kadran-vault` tool (`/var/lib/kadran-exec/vault.key`,
root 0600) and prints a reminder to save it. **Save it in a password manager, not next
to the offsite backup key.** Losing it makes every value unrecoverable. The public key
goes to `/etc/kadran/vault.pub`.
- If the database is sealed to a different key (for example a lost key replaced by a new
one), the daemon refuses to start instead of failing at the next deploy.
- Scrubbing is recorded only once it finishes; a crash between sealing and scrubbing makes
the next start scrub again. A value an older version wrote under the seal marker is
sealed on the next start.
- `bootstrap` warns when the executor has no `--allow-repo` list: without it a
compromised daemon could start an image of its choosing as an app and print that app's
values. It also lists the plaintext pre-migration copies on every run.
### Upgrading from v0.4.x
Create and save the key **before** the upgrade seals anything:
1. Copy `kadran-vault` from the v0.5.0 server package (`bin/linux-<arch>/kadran-vault` in
`kadran-server-v0.5.0-linux-<arch>.tar.gz`) to the server and run it once as root:
`kadran-vault -key /var/lib/kadran-exec/vault.key`. It creates the key and prints the
public key; nothing is sealed yet. Running it again keeps the key and prints the same
public key.
2. Save the key in a password manager: `sudo cat /var/lib/kadran-exec/vault.key`.
3. Run `kadran bootstrap` with the v0.5.0 files. It reuses the key, so the
`YENİ KASA ANAHTARI` (new vault key) warning does not appear. The daemon seals the
existing values on start.
4. Check the sites, then delete the plaintext pre-migration copies:
`sudo rm /var/lib/kadran/kadran.db.pre-*`.
A v0.4.x CLI keeps working against v0.5.0: the protocol did not change. Measured: a v0.4.1
CLI deployed to the release candidate and the new container got the value.
⚠ Never install v0.4.x over v0.5.0 without running `kadran-kasa-coz.sh` first: v0.4.x
would hand the sealed text (`age:…`) to containers as the value.
### Breaking changes
- `app show --json` and the API return variable names only; values come back empty. No
client reads values back, so nothing is silently cleared.
- The executor's privileged code limit went from 2500 to 2600 lines; the vault adds 83
(measured 2581). Key generation lives in `kadran-vault`, outside the executor.
### Rolling back to v0.4.x
Stop `kadrand`, run `/usr/local/lib/kadran/kadran-kasa-coz.sh` as root (needs `sqlite3`
and `age`), then install v0.4.x. The script copies the database first, writes the values
back as plaintext and removes the seal marker; upgrading again reseals them. It runs
`sqlite3` as the `kadran` user and keeps root for decryption, so a symlink the daemon
planted in its own directory cannot redirect root's writes. If an update fails, nothing is
committed and the marker stays.
### What still holds plaintext
- Backups taken before the upgrade: hourly local backups for 24 hours, offsite backups
(age-encrypted with the offsite key) until the bucket's lifecycle removes them, and the
pre-migration copies `kadran.db.pre-*` next to the database. The upgrade itself writes
`kadran.db.pre-0009_env_seal` before sealing, and these copies are only pruned when later
migrations add newer ones. Delete them by hand once v0.5.0 runs.
- Running containers: `docker inspect` on the host still shows the environment. Moving
values out of the environment into files is a separate step (K-123 option C).
### Fixed
- **Upgrades left the optional units on their old version** (K-138). `bootstrap` never
touched the offsite, volume-backup and alarm scripts or their units; they were
installed by hand or by the v0.4.0 migration. On the first live v0.4.1 upgrade the
daemon wrote its anchor but the old offsite script did not upload it. `bootstrap`
now reinstalls every optional unit it finds installed, leaves its timer on or off
as it was, and skips masked units. Settings kept in drop-ins survive.
## v0.4.1 — 2026-10-04
### Audit chain anchors (K-126)
A compromised daemon could rewrite its own audit chain into one that still verifies.
With offsite backups on, every daemon backup now carries an anchor, the chain's head
(`seq` and hash), uploaded unencrypted under the bucket-locked `kadran-` prefix.
`kadran audit verify -anchors <dir>` recomputes the daemon chain on your machine,
ignores the hashes the server sends, and exits `3` if any anchor disagrees or the
chain is shorter than an anchor. `-anchors-since` sets aside anchors from before a
database restore. It covers the daemon chain only, back to the lock period.
- `ListAuditRecordsRequest.after_seq` is exclusive; its comment said inclusive.
### Security
- **The install followed symlinks in the client user's `.ssh`** (K-137), the same
class the v0.4.0 migration fixed. Writing the admin key used a fixed temporary name
and appended through the path; creating `.ssh` followed a symlink and handed the
target directory to the client user. Both now stop on a symlink, and the new
`authorized_keys` is written to a `mktemp` copy and moved into place.
## v0.4.0 — 2026-10-03
The second half of the rename: every name on the server is now `kadran`, and
`bootstrap` migrates an existing install in place.
### Upgrading from v0.3.0
Run `kadran bootstrap` with the v0.4.0 files, as `root@server` or `-sudo user@server`.
It detects the old (`panely`) install and migrates it in place (K-136).
- **Before you start:** if you use offsite backups, add the bucket lock and lifecycle
rules for the `kadran-` prefix ([offsite guide](deploy/offsite/README.md)). Don't
push to repositories that deploy through CI while the migration runs.
- Users are renamed with `usermod`: uid/gid and the ownership of every file stay the
same. The database, backups, volumes, deploy keys, TLS certificates, both audit
chains and the optional offsite, volume-backup and alarm units move to the new
names. A timer that was off stays off.
- Your own systemd drop-ins follow their units. The migration stops before the new
executor starts if its effective `--allow-repo` list differs from the old one.
- The old proxy and containers keep serving until the new control plane has started
its own containers. The site is down only while the proxy switches: about 1.3 s on
the Debian 13 test server, read from the journal; 0 of 80 probe requests failed.
- While they overlap, the old and new replicas of an app mount the same volume, as in a
blue-green deploy but for longer. Stop apps that keep a single-writer database in a
volume before migrating.
- If the new containers don't come up within 5 minutes, the migration stops and the
site stays on the old stack. Running `bootstrap` again continues where it stopped.
- The daemon briefly cannot reach the proxy during the switch and raises "proxy not
reconciled". With Telegram delivery you get that alarm and its recovery.
- **Afterwards:** move CI to the v0.4.0 CLI and the `kadran-client@` target at the same
time. The desktop app moves its saved profiles on first start.
- **Rollback:** `/usr/local/lib/kadran/kadran-geri-donus.sh` on the server (installed
before anything moves), then `bootstrap` from v0.3.0. The site is down until the old
stack is back: ~75 s on the test server.
### Breaking changes
- **Protocol 3.** The wire protocol is now `kadran.v1`; a v0.3.0 CLI cannot talk to a
v0.4.0 server or the other way round. The new CLI recognizes an old server and says
to run `bootstrap`, and answers a `panely-client@` target with the `kadran-client@`
form.
- Server names: `kadrand`, `kadran-exec`, `kadran-connect`, `kadran-caddy`; users
`kadran`, `kadran-client`, `kadran-caddy`; `/var/lib/kadran`, `/etc/kadran`,
`/usr/local/lib/kadran`; `kadran.db`; Docker labels `kadran.*`, images `kadran/<app>`;
backups `kadran-<time>.db`; volume archives `kadran-hacim-…`; the rclone remote
`kadran-offsite`; environment variables `KADRAN_*`.
### Found by rehearsing the migration
The migration was run on the Debian 13 test server against a copy of the live setup:
migrate, reboot, roll back, migrate again, and once more after the security fixes
(K-136). That found four problems before they reached the live server:
- **The executor's repository allowlist would have been dropped.** On the live server it
lives in an operator drop-in (`panely-exec.service.d`). Without carrying it over, the
new executor would have started without the restriction. Drop-ins now follow their
units, and an allowlist gate stops the migration if the effective list changes.
- **An app without a domain would have blocked the migration from ever finishing.**
Cleanup waited for every container to appear in the proxy config, and an app with no
domain has no route. Finishing and cleanup are now separate. Old containers are
removed only once none of them receives traffic.
- **The first version was down for 16 s,** mostly waiting for the proxy watcher's 10 s
tick. The daemon now restarts right after the new proxy, and startup reconciliation
writes the routes immediately: about 1.3 s.
- **An installed but disabled optional unit was dropped** (found in a second rehearsal
after the security fixes). Only units with an enabled timer were reinstalled under the
new names. Every installed one is now carried over, and only the enabled ones are
turned on.
### Found by a security review of the migration
The migration scripts were reviewed separately after the rehearsal. No path started the
executor without its allowlist. Fixed, each with a test scenario and a mutant (K-136):
- **Cleanup could wedge every later install.** It ran before the post-install checks, and
a network or image still in use aborted the install, again on every rerun. It now runs
after the checks pass and only warns.
- **An interrupted run could not resume** if it stopped between renaming the rclone remote
and updating `offsite.conf`. Each file is now handled on its own.
- **Root rewrote files through symlinks.** A fixed temporary name inside the client user's
`.ssh` let that user make root overwrite another file. Temporary copies now come from
`mktemp`, which also keeps the rclone key from being briefly world-readable, and
symlinked files or directories stop the migration.
- **An unreadable `systemctl show` counted as "no allowlist".** It now stops the
migration.
- **Rollback deleted drop-ins edited after the migration.** It now keeps them in the
migration record.
### Documentation
- The README is reorganized around a quick start, the migration guide, and long
reference material folded into collapsible sections.
- The README's GitHub Actions example is now a complete workflow: the one that
deploys the author's site on every push to `main`. It downloads the CLI and
checks it against `SHA256SUMS`, reads only repository contents, and queues
deploys instead of running them side by side (K-135).
- The README no longer says the workflow deploys "the commit that was just
tested". The example runs no tests: it deploys whatever lands on `main`. To
deploy only tested commits, add a test job and `needs:` (K-135).
- `[skip ci]` in a CI-deployed repository: GitHub looks only at the last commit of a
push, so a code commit followed by a `[skip ci]` commit is not deployed (measured,
K-129).
## v0.3.0 — 2026-10-02
Deploy-only keys for CI, an audit log that finally records which key acted, the
rename to Kadran, and the move to Apache-2.0.
### Upgrading from v0.2.0
Run `kadran bootstrap` with the v0.3.0 files, as `root@server` or `-sudo user@server`.
The workstation tool is now called `kadran`; the server side keeps its names.
- The reverse proxy does **not** restart: its binary and units are unchanged.
Measured twice on the Debian 13 test server (K-131, K-134).
- The post-install check now requires every line in
`~panely-client/.ssh/authorized_keys` to be forced to `panely-connect`. If you
added lines by hand, it reports them.
- Deploy-only keys need the server and the CLI both at v0.3.0. An older
`panely-connect` does not know `-deploy` and refuses such a key (it never falls
back to admin rights).
- To roll back, remove any deploy-key lines (`kadran key remove`), then run
`bootstrap` from the v0.2.0 tree. Never swap a binary on its own.
### License: Apache-2.0
Kadran is now licensed under the Apache License 2.0 (was MIT). v0.1.0 and v0.2.0
were released under MIT and remain available under it. The change adds an explicit
patent grant and spells out that contributions come under the same terms; see
`NOTICE` and K-132.
- **Release files now carry license texts.** Earlier releases shipped bare
binaries; the server archive held four binaries and no license file. The
binaries embed Caddy and gRPC (Apache-2.0) and SQLite and protobuf (BSD), whose
licenses must travel with them. Each release now includes `LICENSE`, `NOTICE`
and `THIRD_PARTY_LICENSES.txt`, both inside the server archives and as separate
files. The third-party file is generated from `go list -deps` for every shipped
binary and platform, and the build fails if any module has no license file.
- Release files are named `kadran-<version>-<os>-<arch>` and
`kadran-server-<version>-linux-<arch>.tar.gz` (were `panely-…`).
### Renamed to Kadran
The project is now **Kadran** (from the Turkish word for a dial or gauge face). The
repository moved to `github.com/erkanrzgc/kadran`; GitHub redirects the old address.
- The command-line tool is `kadran` (was `panely`), built from `cmd/kadran`. The Go
module path is `github.com/erkanrzgc/kadran`.
- Server-side names are unchanged: the services (`panelyd`, `panely-exec`,
`panely-caddy`, `panely-connect`), the `panely` and `panely-client` users, the
directories, container labels, image names, and backup file prefixes. Existing
installs need nothing, and you keep connecting as `panely-client@server`.
- The systemd unit files and the reverse proxy's module are deliberately untouched.
Changing them would have restarted the reverse proxy on the next upgrade (K-130).
- Environment variables keep their `PANELY_` names.
### Security
- **Deploy-only keys.** An `authorized_keys` line with
`panely-connect -deploy=site,api` gives a key that can only deploy those apps:
`Ping` and `Deploy` for the listed apps, `PermissionDenied` for every other call.
Lines without the flag stay admin keys, so existing installs are unchanged.
- The key cannot read app definitions (they carry environment values). Pass the
commit with `kadran deploy -commit`; the CLI says so when it is missing.
- An empty, unknown, or malformed role is refused when the connection opens.
It never falls back to admin rights.
- Streaming calls (`Deploy`, `StreamLogs`) now go through an interceptor too.
Before this they skipped the server's interceptor chain entirely.
- Scope limits, including that a deploy key effectively owns the secrets of
the apps it may deploy, are in `SECURITY.md` (K-131).
- `kadran key add -deploy site,api ci.pub root@server` adds such a key;
`key list` shows every key with its role and fingerprint; `key remove`
deletes one. They use the same root path as `bootstrap` (`-sudo` works).
`key add` refuses a key that already has a line, `key remove` refuses the
last admin key, and `key list` exits non-zero on any line that is not forced
to `panely-connect`. The server re-checks every line it is asked to write.
- **Fixed: the audit log never recorded which SSH key acted.** The actor's key
fingerprint was read from `SSH_AUTH_INFO_0`, a PAM-internal variable OpenSSH
keeps out of the session, so it was always empty: on the live server, 0 of 56
SSH audit records carried one. It is now read from the file sshd names in
`SSH_USER_AUTH`, the documented `ExposeAuthInfo` interface. Records written
before the upgrade stay without a fingerprint (K-134).
- **Fixed: a multi-line public key file opened a shell.** `bootstrap` wrote the
admin line as `command=...,restrict <file contents>`. With two keys in the file
(for example `https://github.com/<user>.keys`), the second one landed in
`authorized_keys` as a separate line with no forced command and no `restrict`.
- `bootstrap` now refuses key files with more than one line and sends the
single validated line; `install.sh` refuses it again before writing.
- The post-install check used to pass if *any* line had a forced command. It
now requires every line to be forced to `panely-connect` with `restrict`.
If you added lines to `~panely-client/.ssh/authorized_keys` by hand, the next
upgrade will report them.
### CLI
- **DNS check before setting a domain.** `app create -domain` and
`app update -domain` compare the domain's A and AAAA records with the server's
address before they contact the server.
- They stop when the domain has no records or points somewhere else. The
command used to succeed, the certificate never came, and Caddy could wait up
to a day before retrying once the DNS was fixed.
- They warn and continue when the answer is unclear: a server on a private or
Tailscale address, an AAAA record when the server's IPv6 address is unknown,
or a failing DNS query.
- The server's address comes from the SSH target, including `HostName` from
your SSH config. `.localhost` names are skipped. `-skip-dns-check` bypasses
the check, for example behind Cloudflare's proxy.
- Measured with the real resolver: a wildcard record pointing elsewhere was
refused, as were a nonexistent name and a name that points at the other
server. The check refused all three before connecting (K-128).
- **`kadran domain check <domain> [target]`** diagnoses a missing certificate from
your machine: DNS against the server's address, TCP to ports 80 and 443, the
HTTP redirect on port 80, and the certificate on 443 (trusted, which issuer,
days left). It reports every step instead of stopping at the first problem, and
exits 1 if any step fails. An untrusted certificate is described (subject,
issuer, names). When the DNS points elsewhere, it says that the remaining lines
measured that other host (K-128).
### Known gaps
- Audit records written before the upgrade carry no key fingerprint; only new
records do (K-134).
- A deploy key can deploy any commit the configured repository serves, including
commits from open pull requests: `panelyd` has no outbound network to check the
branch. Use the key only in workflows triggered by pushes (K-131).
- The two audit chains are still verified separately, not against each other, and
there is still no secret store (K-123, K-126 drafts).
## v0.2.0 — 2026-10-01
Hang detection, a bounded start-up, installs without root SSH, and a second tested
platform (Debian 13).
### Upgrading from v0.1.0
Run `panely bootstrap` again with the v0.2.0 files, either as `root@server` or as
`-sudo user@server`.
- The unit file and the binary must change together. The new unit turns on
systemd's watchdog, and an old panelyd under it would be killed every minute.
- To roll back, run `bootstrap` from the v0.1.0 tree. Never swap a binary on its
own.
- The reverse proxy restarts once during this upgrade, because its unit changed.
The restart cost about 1.5 s of downtime (K-118).
### Server
- **Hang detection.** panelyd pings systemd's watchdog (`WatchdogSec=60s`) only while
its background loops (health supervisor, proxy watcher, disk check, backups) make
progress and its database pool can hand out a connection. A plain ping goroutine
would keep pinging through a deadlock. Measured under real systemd in CI:
- a normal run caused no restarts;
- a frozen daemon was killed and restarted;
- SIGABRT left a goroutine dump showing the loops in the journal;
- a clean stop was not counted as a watchdog failure.
The thresholds (15 minutes, 3 hours for backups) are derived from the code's
timeouts. Production was idle for its first 22 hourly reports: the longest gaps
were 2–3 s for the supervisor and 10 s for the proxy watcher (K-115, K-118).
Under normal load on a test server (deploys and killed containers), the longest
gaps were 7 s for the supervisor and 10 s for the proxy watcher. The hung-executor
cases that the 15-minute floor exists for were not measured (K-115).
- The watchdog's hourly report counts the interval that is still open. A loop that
is stuck but has not yet reached its threshold used to be invisible, and an hourly
loop always showed "0s" (K-118).
- panelyd's unit allows 180 s to start (was systemd's default 90 s). With a hung
executor or Docker daemon, startup can take up to 103 s before the daemon reports
ready. systemd used to kill it just before that point and restart it into the same
wall, so `panely status` never answered. The bound is derived from the code's
timeouts; the hang itself was not reproduced (K-117).
- The reverse-proxy unit no longer has a reload command. It never worked, and a
working one would have loaded the base configuration, which has no routes, and
taken every site down (K-112).
- `panely app show` marks which release is live. The old status column showed the
build status only; after a rollback the top "built" release does not get the
traffic. A separate line names the live release even when it is older than the
listed ones. Against an older server that does not report the live release, it
says "unknown" instead of claiming nothing is live (K-112, measured against the
old server in K-118).
Measured in production on 30 September (K-118):
- the upgrade itself cost ~1.5 s of downtime, measured from inside the server;
- a killed reverse proxy was serving again in ~6.4 s;
- a reboot onto a new kernel had sites back after ~43 s, and the startup alarm
closed on its own;
- the watchdog was armed after the upgrade and after the reboot;
- Telegram delivery worked end to end for the proxy crash and the reboot.
### Install
- `panely bootstrap -sudo user@server` installs and upgrades through the user's
passwordless sudo, so root SSH stays closed and sshd's policy is left alone.
- The privilege check and the install itself run under `sudo -n`, which never
prompts; the upload and the log follow run as the user. Before anything is
uploaded, the installer checks in the exact form it will use that it becomes
uid 0, and it stops with sudo's own message if it cannot.
- Root mode also checks the uid before uploading. `panely-client` is refused as an
install account in both modes.
- Measured on Debian 13 on GCP: 17/17 checks passed, root login was refused before
and after, sudo's log recorded the command, and sites answered throughout the
upgrade (K-122).
- **A dropped connection no longer restarts the install.** On 1 October three long
uploads in a row were cut with "Connection reset by peer", on two providers.
- The package goes to the SSH user's own directory, named by its SHA-256. After a
drop only the missing bytes are sent. They are written at an explicit offset, so
a dead session's late write cannot shorten or shift the file.
- The server checks the SHA-256 before installing. A corrupt package is deleted
and uploaded once more.
- The install runs detached from the SSH session and logs to a file. The CLI
follows the log from the last byte it printed and reconnects if the link drops.
- If the install process dies before it finishes (for example, out of memory),
the CLI says so and does not wait forever. Only one install runs at a time.
- SSH keepalives now detect a connection that dies without a reset.
Measured with the real tools on Debian 13, including a real `sudo -n` as an
unprivileged user (K-127).
- `panely bootstrap` sends a gzip-compressed package: 28.3 MiB instead of 74.7 MiB
with the same binaries. On a slow link the uncompressed upload took about
13 minutes and dropped once (K-119).
- **Debian 13** is a tested platform, alongside Ubuntu 24.04. Tested on a GCP
e2-micro with 1 GB RAM and 2 GB swap. Measured:
- all 17 post-install checks pass;
- HTTPS with a Let's Encrypt certificate works end to end;
- a Node build peaked with 237 MiB free and no OOM;
- after a reboot everything came back and the startup alarm closed on its own.
GCP's guest agent did not touch `panely-client`'s forced-command key through
metadata changes, an agent restart or a reboot. Never add `panely-client` to SSH
metadata (K-121).
### CLI
- When SSH cannot connect (unknown host, rejected key, changed host key), the CLI
shows SSH's own message. It used to show only "error reading server preface:
EOF", which hid even a host key verification failure. A child process that
keeps SSH's stderr open can no longer turn a fast failure into a long hang
(K-120).
### Known gaps
Listed in full in the README. In short:
- audit chains are not cross-checked;
- hang detection covers only what its loops and probe watch, with derived
thresholds;
- volume backups are not snapshots;
- no secret store;
- Dockerfile builds from public repositories only;
- single node;
- CLI messages are in Turkish.
## v0.1.0 — 2026-09-27
First tagged release. A single-node, security-first deployment panel: one server,
Git → container deploys, and a hard privilege boundary between the control plane
and the part that touches Docker.
### Architecture
- **Two daemons, one boundary.** `panelyd` (unprivileged, no network access,
`IPAddressDeny=any`) decides; `panely-exec` (the only process that can reach
Docker) executes a small, audited gRPC surface. The privileged code is held to a
line budget that CI enforces (2498 / 2500).
- **SSH as the only door.** The CLI talks to the daemon over SSH with a forced
command; no port is opened for the panel itself.
- **Hash-chained audit logs** on both sides of the boundary; `panely audit verify`
checks both.
### Deploys and traffic
- Git → container builds from a commit's `Dockerfile`; `github.com` only by default,
optional per-repository allowlist.
- Blue-green releases behind an HTTP health gate; rollback without rebuilding
(~18 s, 55 / 55 probes answered `200` during the switch).
- Automatic HTTPS via a custom Caddy build without a file server; atomic reloads.
- The daemon watches the reverse proxy and restores its routes after a restart or
crash: sites back after 3 s and 7 s; without the watcher they stayed down until
the daemon itself restarted (K-112).
- Live logs, scaling, environment variables, persistent volumes mounted
`nodev,nosuid`, pruning that always keeps the rollback target.
- A health supervisor that restarts failed releases with backoff.
### Backups
- Hourly SQLite snapshots with a tested restore path (K-091).
- Optional encrypted offsite copy with `age` public-key encryption; the server
cannot decrypt its own past backups (K-098, K-104…K-107).
- Optional volume backups: a separate unit that can read every volume but has no
network and no sockets, and emits only ciphertext. Restored end to end from
Cloudflare R2 (K-111).
### Alarms
- Four failure conditions, edge-triggered and persisted (K-092).
- Optional delivery to Telegram from a separate unit; the daemon still cannot reach
the network or read the bot token (K-108).
- Crashes and stops of the core services are reported; OnFailure was measured and
rejected because it floods during a crash loop (K-110).
- The startup "traffic not flowing" alarm closes once the apps are routed again;
it used to stay open after every reboot. It closes only when no app is skipped
and the live proxy config matches exactly, so a route someone else added keeps
it open (K-112).
- Optional external heartbeat on a Cloudflare Worker, for when the sender or the
whole server is down (K-109).
### Install
- `panely bootstrap root@server` installs everything and checks its own work.
Re-measured on a fresh Ubuntu 24.04 server before this release: fresh install,
upgrades across commits, reverse-proxy restart and crash, reboot (K-112).
- Upgrades restart the control plane; they used to leave the old binaries running.
The reverse proxy is restarted only when its binary or configuration changed, and
its binary no longer changes with every commit (K-112).
- The installer stops early and clearly when Docker is missing or the upload runs
past its time limit (K-112).
### Known gaps
Listed in full in the README. In short: audit chains are not cross-checked, hangs
are not detected, volume backups are not snapshots, no secret store, Dockerfile
builds from public repositories only, single node, CLI messages in Turkish.