Skip to content

Hardening, update awareness and ipmitool install - #12

Open
dev-luigi wants to merge 77 commits into
mainfrom
dev
Open

dev-luigi wants to merge 77 commits into
mainfrom
dev

Conversation

@dev-luigi

@dev-luigi dev-luigi commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Brings the hardening work and the update and ipmitool work on dev to main. Nothing is released by merging: a release still needs a version bump and a tag, and the notes come from the [Unreleased] section of CHANGELOG.md.

What changes for operators

  • Everyone is logged out once on upgrade. Session tokens are now bound to the account credentials, and tokens from earlier versions are refused. Changing the password now ends every session. ipmideck rotate-session-secret replaces the signing secret after a suspected leak.
  • Behind a reverse proxy that rewrites Host, set server.trusted_origins (and forwarded_allow_ips) before upgrading. Otherwise state-changing requests are refused as cross-origin.
  • Stored BMC credentials are converted to authenticated encryption on first start. The database and key are copied aside first (*.pre-authenc-<timestamp>.bak). Treat those copies as credential-grade.
  • HTTPS works without a manual certificate step, including in the container.

Updates and ipmitool

  • Update checks are on by default. At start-up and once a day IPMIDeck asks PyPI or Docker Hub whether a newer version exists, sending only the product name and version. It can be unticked in setup, turned off in Settings, or disabled everywhere with updates.enabled: false. A Check now button and the console [g] key check on demand.
  • Updates can be installed from the web UI. The data is backed up first (the last three backups are kept, and the dialog says where), then pip, pipx and uv installs are updated and restarted. Docker goes through Watchtower when it is set up; otherwise the dialog shows the command.
  • The release history opens from the version in the sidebar. It ships inside the package and works offline.
  • A missing ipmitool is named, with a way to fix it. One warning in the log instead of a traceback on every poll, a notice in the web UI with the install command and a Check again button, and a localized error on every action. When ipmi.auto_install_ipmitool is on and IPMIDeck is privileged, the notice installs it (apt, dnf, pacman, zypper, apk, FreeBSD pkg or Homebrew). In a container it explains that the official image already includes ipmitool. On Windows it finds Dell's bundled ipmitool.exe.

Main fixes

  • Security:
    • Path traversal in the web UI fallback.
    • Session and credential handling.
    • Origin checks on HTTP and on the telemetry WebSocket.
    • Rate cap on credential checks.
    • Size bounds on BMC responses.
    • Security headers.
    • CSV formula injection.
    • Host address validation.
  • FanPilot: an unusable fan curve (no points, missing values, NaN/Infinity, invalid JSON) holds the fans at 100%, as before. The operator now gets a notice and a command-log entry saying why. NaN/Infinity is refused when a profile is saved.
  • Container:
    • Restarts cleanly under host networking.
    • The health check follows the port the app was started with.
    • The image no longer runs as root.
    • Data files are created owner-only.
  • CI: pushes to dev are tested. The release smoke test reads the version from the image instead of /api/health. PyPI publishing is skipped when the Docker Hub preflight fails.
  • Frontend dependencies updated; npm audit reports no advisories.

The full list is in the [Unreleased] section of CHANGELOG.md.

Verification

  • Backend: 1127 passed. Frontend: 110 passed.
  • i18n parity across 12 locales, plus the new stale-translation check.
  • The committed SPA matches a clean build.
  • Docker smoke test: build, boot, demo, persistence and hygiene all pass.
  • A container started with --port 8080 reports healthy and comes back after docker restart.
  • The start-up port check was also exercised on Linux, including the TIME_WAIT, live-listener and IPv6 cases.
  • Missing ipmitool, tested in a Linux container without it: the log warns once with no traceback, every action returns ipmitool_missing, the install is refused inside Docker, and on a root Debian host it installs in about 10 seconds and is picked up without a restart.

Closes #14
Closes #15

The app shipped no HTTP middleware at all, so nothing set framing, sniffing
or referrer policy. Without frame-ancestors the dashboard can be loaded in a
hostile iframe, which is what turns a single stolen click into a power-off on
real hardware.

HSTS is emitted only when the request actually arrived over TLS. Sent over
plain http a browser ignores it, but honouring it on a LAN hostname would
strand the operator with no way back to http.

The policy is scoped to frame-ancestors deliberately: a script-src policy
would have to cover the bundled module script and the stylesheet, and getting
that wrong breaks the UI for a finding that is about framing.
Several endpoints take no request body (SEL refresh and clear, FRU refresh,
retention cleanup, connection test), so a browser can submit them
cross-origin as a simple request: no preflight, session cookie attached.
Nothing distinguished those from a click in the app's own UI.

Three changes close it:

- An Origin/Referer check on POST/PUT/PATCH/DELETE, comparing host AND port
  against the request's own Host header. The port matters: same-site cookie
  rules treat every port on a machine as one site, so another service on a
  different port of the same host was a valid attack position.
- The restore endpoint now requires an archive content type. It reads the raw
  body, so the content type is the only statement of intent available, and a
  cross-origin form POST can never produce one.
- The session cookie moves to SameSite=Strict, since Lax also ignores the port.

A request carrying neither header is allowed: curl, the container health check
and server-to-server callers legitimately send neither, and refusing them would
break every headless integration to stop an attack only a browser can mount.
Only 127.0.0.1 is trusted to set X-Forwarded-Proto by default. A TLS proxy
running in a separate container reaches the app from a bridge address, so its
forwarded scheme is discarded, the request still looks like plain http, and
the session cookie is issued without the Secure flag on what the user sees as
an https site.

server.forwarded_allow_ips lets the operator name the proxy. It is threaded
into both places the server is started and left unset by default, so nothing
changes for a deployment that does not use one. In the container the same
setting is available as an environment variable.

Startup also warns when the app binds to a non-loopback address without TLS.
Refusing to start would be wrong — a trusted LAN is a legitimate deployment —
but the operator should be told that cookies and BMC passwords are in the
clear rather than having to work it out.
The interactive OpenAPI pages published a complete map of every route to
anyone who could reach the port, and the health endpoint — unauthenticated so
an orchestrator can poll it — returned the build version, the module count and
the number of live connections. Together that tells a scanner which release to
look up exploits for and whether an operator is currently watching.

The docs pages are now off by default and turned back on for a demo instance
or a debug-level run: in a demo the API is the thing being shown, and at debug
level the operator explicitly asked for detail. They are re-enabled before the
SPA catch-all is registered, since the catch-all shadows anything added after it.

Health is reduced to a liveness status. The version and demo flag move to the
authenticated config endpoint, which the two UI callers now read instead.
The login handler consulted the brute-force lockout before verifying the
password. That counter is keyed on a username supplied by the caller, so
anyone able to reach the login form could burn the attempt budget on a
guessed name and have the operator's own correct credential rejected for
the whole lockout window — a denial of service against the account owner.

The password is now verified first and the lockout is only consulted on
the failure path, which keeps the throttle (every wrong attempt still
costs a bcrypt comparison and still records a failure) while making a
valid credential impossible to refuse.

A failed login also answers HTTP 401 instead of 200, so proxies, caches
and scripted clients can distinguish an authentication failure from a
success without parsing the body. JSONResponse keeps the response shape
unchanged for existing callers, and the frontend client returns the
parsed body for the login/boot paths so the localized message survives.
The host field went into the argument vector of an ipmitool subprocess after
only a denylist of URL-shaped mistakes. That let through values that are not
hosts at all: "-C0" is read by ipmitool as the cipher-suite flag, and suite 0
disables authentication on the IPMI session entirely. ".", "--", command
substitution syntax and a 300-character string were all accepted and stored.

Validation is now an allow-list — an IPv4 literal, a bracketed IPv6 literal,
or a DNS hostname within the 253-character limit — because a denylist cannot
enumerate what a subprocess argument may mean. The same guard was added to
the credential-test endpoint, which had none at all and passes its host
straight to ipmitool without touching the database.

A port other than 623 is now refused with an explanation instead of being
stored. Nothing ever passed that column to ipmitool, so any other value was
a silent lie: the connection went to 623 regardless of what the form showed.
Exported rows reproduce sensor names and event descriptions exactly as the
BMC reported them, and a cell starting with =, +, -, @, tab or carriage
return is evaluated as a formula when the file is opened in a spreadsheet.
The event log is the primary path: its descriptions come verbatim from
'ipmitool sel elist', so their content is not under this application's
control. Such cells are now prefixed with an apostrophe, the standard
treat-as-text marker, which the spreadsheet strips on display.

The download filename is built from a sensor name and a server id that were
interpolated raw into Content-Disposition, where a double quote would close
the quoted filename and let the rest of the value be read as further header
parameters. Both are now reduced to characters that cannot alter the header.

The history export's range is a closed set instead of a dictionary lookup
with a fallback: an unknown value returned 24 hours of data while echoing
the caller's string into the filename, so the file's name described a window
its contents did not come from.
bcrypt hashes at most 72 bytes and raises on anything longer instead of
truncating it, so a longer password reached the hashing call as an unhandled
exception: a 100-character password returned 500 from login and from first-run
setup, and the credential-change endpoint echoed the raw library exception
text back to the caller. Any anonymous request could trigger it.

The length is now checked in bytes before hashing — a character limit would
not do, since 72 multi-byte characters are already well over the byte limit —
and password verification answers False rather than raising, because a
password bcrypt cannot hash can never match a stored hash anyway. First-run
setup was the only route without a handler for the resulting error, so it
gained one.

Duration parsing keeps its never-raise contract, since a malformed value in
the configuration file must not stop the application from booting, but it now
logs when it falls back: substituting a different session lifetime in silence
left the operator with no way to discover their setting was never in effect.
Values are also clamped to thirty days, so a typo like '9999d' cannot hand out
a session that stays valid for centuries.
The database holds the BMC credentials and was created with the default
umask, so on a typical host every local account could read it. The same
applied to config.yaml.

Both are now restricted to their owner. The database is fixed on every
connect rather than only at creation, so an installation from an earlier
version repairs itself on the next start without a migration step. The
write-ahead log and shared-memory sidecars inherit the main file's mode when
SQLite creates them, which is why the mode is set before the first write —
but sidecars left behind by an abrupt shutdown are reused as they are, so an
existing pair is fixed explicitly.

Restoring a backup also widened permissions: extraction produces
world-readable files and the move preserved that mode, so a restore quietly
downgraded the encryption key that protects the stored credentials.
…dable

Curve points are stored as free-form JSON with no per-element schema, so a
point missing a key or holding a non-numeric value is accepted by the API and
reaches the interpolator, which raised on it. The control loop walks its
servers in a fixed order and the stored curve stays broken, so every pass
aborted at the same row: every server after it was permanently starved of
curve evaluation, fail-safe handling and auto-recovery, with its fans left at
the last speed they were commanded while temperatures climbed. The loop kept
sleeping and retrying and never healed itself.

The interpolator is now total — any curve it cannot read resolves to 100%,
which is the safe answer and already what an empty curve returns — and the
per-server speed computation runs inside the guard that already wraps the fan
write, so one unusable curve fails a single server instead of the fleet.
The image ran everything as root, so a flaw in the application or in ipmitool
had the run of the container, and every file written to the data volume was
owned by root on the host.

The application now runs as a dedicated user with a fixed uid/gid, which also
makes ownership predictable for anyone bind-mounting a host directory.

There is deliberately no USER directive. Dropping privileges that way would
break every existing installation on upgrade: the data volume was created by
root and the process could no longer write to it, so the operator would have
to chown it by hand before the container would start again. The entrypoint
instead starts as root, takes ownership of the data directory, and then drops
privileges, so the upgrade needs no intervention.

Nothing in that adoption is fatal. Read-only mounts, NFS without
no_root_squash, CIFS with a fixed uid/gid and userns-remapping all refuse
chown legitimately, and a permissions problem must not turn an upgrade into
an outage — the container starts anyway and warns. It also passes straight
through when already started with an explicit user, where dropping privileges
again would fail and the container would never start.

setpriv comes with the base image, so this adds no package, and exec keeps
uvicorn as PID 1 so SIGTERM still reaches it for a graceful shutdown.
config.example.yaml offered auth.enabled, auth.max_login_attempts and
auth.lockout_duration. None of the three had a single reader: whether
authentication is on is stored in the database, and the lockout thresholds are
fixed in the login path. An operator could switch auth.enabled to false, restart,
and still be asked to log in, with nothing to explain why.

They are removed rather than implemented. A working enabled key would be a
second and weaker switch for a security control — anyone able to edit the
configuration file could turn the login off with a text editor — so the honest
fix is to withdraw the promise, not to keep it. The matching environment
variable goes too, since it was the same claim in another form. Deployed
configuration files are unaffected: unknown keys are ignored, and
auth.session_expiry, which does work, is untouched.

The example's polling intervals were also wrong. command_timeout advertised 10
seconds against a real default of 30, and that default exists because a real
BMC's sensor listing can take around 16 seconds — following the example would
have caused timeouts.

Two README claims were false. Session tokens were described as opaque, but the
payload is base64url-encoded JSON that anyone holding the cookie can read; it is
the signature that makes it trustworthy. The file was also said to receive every
key on first run, which it does not.
The stored BMC username and password are root-equivalent on the hardware and
are never returned by the API. Changing only the host address re-pointed them
at a machine of the caller's choosing, and the next polling pass delivered
them there — a session that could reach the settings page was enough to
harvest credentials it could not read.

Changing the address now requires re-sending both credentials in the same
request, so the ability to redirect them depends on already knowing them. The
comparison is against the stored address rather than against the presence of
the field, because the edit form submits the host on every save and testing
for presence would reject ordinary renames. No new fields: the form already
has both inputs, and they are simply required when the address changes.

The save handler also announced success unconditionally. Refusals are
reported in the body with a 200 status, so an invalid address already showed
"Server updated" while nothing had been saved; the reported error is now
surfaced instead.
Until first-run setup completes there is no account, and nothing over HTTP
tells the operator apart from anyone else who can reach the port: whoever
answers the setup wizard first owns the instance.

The window closes by itself the moment setup is completed, and the payoff for
an attacker is lockout rather than disclosure — the database holds no BMC
credentials yet. What was missing was not a mechanism but the operator
knowing, so the existing startup warning now also covers this case.
The stored credential format concealed a value but did not authenticate it: anyone
able to write to the database could alter a stored username or password without the
change being detectable on read. Switch to AES-256-GCM and give stored values a
version marker so the format can change again later without a flag day.

Values written by earlier versions stay readable indefinitely, not as a courtesy but
because a backup archive is swapped in before the database is opened — a value in the
old format can therefore appear on any future boot.

Existing installs convert on the first start. The conversion copies the database and
the key file aside before writing anything, keeps them as a pair (the credentials are
worthless without the key), and refuses to write at all when nothing decrypts, which
is what a mismatched key file looks like. A single unreadable value is left alone
rather than dropped. Failure is logged and the boot continues: both formats read, so
an unconverted install keeps working instead of losing fan control at startup.

Also stop the connection-test and fan-mode endpoints from answering an unreadable
credential with a server error while an unreachable BMC gets an ordinary failure —
that difference distinguished the two cases for anyone probing.
Enabling https previously required generating a certificate by hand first; if you
turned it on without one, the embedded serve path fell back to plain HTTP silently.
So the setting looked available but the honest default stayed cleartext, with the
session cookie and every BMC password on the wire.

Now a certificate is created on demand when https is on and none is configured, and
both serve paths resolve TLS through the same helper so they cannot drift apart
again. Certificates the operator supplies are never overwritten, and generation
failure falls back to plain HTTP with the reason logged: locking someone out of
their own dashboard over a certificate problem is worse than the cleartext they
already had.

The generated certificate now also covers the machine's hostname and addresses, not
just localhost. A dashboard is reached at whatever the operator typed, and a name
mismatch is a hard browser failure with no way to click through — unlike the unknown
issuer warning, which is expected and documented. Marking it a CA is what allows
removing that warning by importing the file, since macOS and Windows will not anchor
trust on a plain end-entity certificate.

Adds environment overrides for the three transport settings, which is the only way a
container can turn TLS on, and makes the startup log state the scheme.
The telemetry socket checked authorisation once, at the handshake, and never again.
A connection can stay open for days, so a session that expired — or was ended by a
password change — kept streaming to whoever held the socket. It is now re-checked
once a minute and closed when it no longer holds; the client sees the policy close
and sends the operator back to the login page instead of retrying forever.

The handshake also accepted any Origin. That is not covered by the same-origin
policy, so any page the operator happened to visit could open a socket here and it
would carry their cookie. The stated origin is now required to match the Host the
request was addressed to, which needs no configuration and survives being reached by
address, hostname or a different port. A request with no Origin is still accepted:
command-line clients and the container health check send none, and rejecting them
would break every non-browser caller to stop something only a browser can do.

Login and the two endpoints that verify a password to change the authentication
settings are now capped at five attempts a minute per source. The disable and
reconfigure paths had no counter at all behind them, which made either one an
unlimited guessing oracle for anyone holding a session. A slot is consumed even when
the password turns out to be right, or a caller who already knows it would face no
limit; the rejection reuses the existing lockout wording exactly, so it cannot be
told apart from an account that is locked — which would itself reveal the account
exists.

Password verification now runs a throwaway comparison when the username has no row.
bcrypt is slow on purpose, so returning early made an unknown name answer in well
under a millisecond against tens for a real one — a difference that reads off the
wire and names the only valid account without guessing a password. The rate cap
alone only lowers the sample rate; it does not remove the difference.

The two attacker-keyed tables that back all this are now bounded and their keys
capped, since the login endpoint is reachable without a session and both the number
of entries and the length of each key were caller-chosen.
Every parser read as many entries as the subprocess handed back, and the subprocess
output itself had no byte limit — only a wall-clock timeout, which at LAN speed is
hundreds of megabytes. A BMC that is broken, hostile, or simply answering with
something other than what was asked could turn one poll cycle into unbounded memory,
and the sensor payload is cached and broadcast in full, so it stayed resident and was
replayed to every client that connected afterwards.

The caps are four to fifty times the worst legitimate case and each one logs when it
bites, naming the limit. That matters more than the number: a silently dropped sensor
could make a fan curve's source disappear and trip the fail-safe on a healthy
machine, so an operator with unusual hardware needs to be able to see why and raise
it.

The byte cap is applied after the read completes, not during it. Bounding the read
would mean replacing the pipe handling wholesale for a case the existing timeout
already covers; this bounds everything downstream — parsers, database rows, the
broadcast cache, error strings — which is where the memory actually stayed.

Deleting a server also now clears the dozen module-level trackers keyed by its id.
The database rows went with the delete but none of the in-memory state did, so a
removed server kept a fan controller, poll timers and counters alive for the process
lifetime. The cached telemetry was the worst of it: a server the operator deleted
kept reappearing on the dashboard of every newly connected client, because the
existing clear-cache call had never had a caller.

Two smaller leaks close with it: the power rate-limit entry was recorded before
checking the server existed, so a request naming a server that does not exist left a
permanent entry, and moving a server to a different address stranded the old
address's command lock.
Deleting both protections from the SEL export left the whole suite green. The
existing tests exercised csv_safe and safe_filename_part as pure functions, so
nothing proved the endpoints still called them.

These drive the real endpoints end to end. SEL is the one that matters: event
descriptions and sensor names are reproduced verbatim from the BMC's own log,
which is the untrusted source the sanitisation exists for. Reverting either
protection now fails four of them.
The cross-origin guard compares the browser's Origin against the Host header.
Behind a proxy that does not pass the browser's Host through — nginx does not,
unless told to — those two can never match, so every POST/PUT/PATCH/DELETE is
refused and the dashboard loads but saves nothing. There was no way to configure
around it and nothing in the docs said so.

server.trusted_origins (IPMIDECK_SERVER_TRUSTED_ORIGINS) is the operator's
statement of the address the dashboard is really served at. It narrows nothing
else: an origin that is neither this server's own authority nor on the list is
still rejected, and an entry written with a scheme does not trust its cleartext
twin.

Also documents the companion forwarded_allow_ips, which shipped configurable but
undocumented, and covers both middlewares — the guard and the defensive headers
had no test at all.
An installed instance could not show its own version history: the changelog lives at the
repository root, which never lands in the wheel. It is now mirrored into the package and
declared as package data, with a test that fails on drift between the two copies. The root
file stays authoritative — the release workflow slices the release body out of it.
… HTTP dependency

Version history parsing, install-method detection and the published-version lookup for the
three ways IPMIDeck is installed. Every request goes through the standard library, so the
runtime dependency list keeps carrying no HTTP client — a claim anyone can check against the
package metadata. Failures come back as a reason rather than an exception: an air-gapped box
is an ordinary state and must not fill the log with tracebacks. A new updates.enabled config
key is the switch that suppresses the socket outright.
The release notes said existing installations are repaired automatically on the
next start. That held for the database, which re-applies the mode on every
connect(), but not for config.yaml: it was only tightened when something wrote it
back, so an install whose operator never changed a network setting kept a
world-readable file holding the session secret for as long as it ran.

Repaired during startup against the resolved data dir, so a config passed by path
— the example file the test suite loads — is never touched.
Two routers, not one. The history and the cached state cannot reach the network, so they are
always mounted. The two routes that can open a socket are mounted during startup and only when
updates.enabled is on — with it off the endpoint does not exist, which is a stronger guarantee
than a runtime check a later change could step past. Registration is reversible so an
in-process restart that flips the switch off actually removes the endpoint. The startup log now
carries the running version and the changelog address, which sends nothing and makes every bug
report self-identifying.
It printed a promise that a check would arrive in a future release. It now runs one. The
lookup is scheduled off the key thread — doing it inline would freeze the render loop for the
length of the request, or the whole timeout on a machine with no route out — and the outcome
arrives as a log line, distinguishing up to date, a newer release, a security release, and a
check that could not complete. When the configuration forbids the lookup the key says so
instead of pretending to try.
…te cap

Conflict resolutions, all of them where the two branches touched the same
decision from opposite sides:

- IPMIDECK_AUTH_ENABLED stays deleted. AuthConfig no longer has an `enabled`
  field: whether the login is on lives in the database so write access to the
  configuration cannot switch it off. Restoring the variable would have created
  an attribute nothing reads.
- The username-keyed lockout pre-check stays out of the login path. Consulting
  it before verifying is what let anyone lock the operator out with a guessed
  name. The per-source attempt cap is kept, and is now the throttle the
  docstring points at.
- The README keeps the note that a session token's payload is readable by
  whoever holds the cookie, alongside the rewritten AES-256-GCM paragraph, and
  keeps the caveat about an interrupted key migration — that path still exists.
- resolve_tls_files() supersedes the manual cert/key branch; forwarded_allow_ips
  is independent of it and stays on both server paths.

Two things the merge itself made wrong, fixed here:

- The startup line reported the scheme from the config flag. A certificate can
  only be handed to the server as it is built, so a process started without one
  serves cleartext whatever the file says, and the line would have announced
  https over a plaintext socket. It now reports what the launcher actually
  resolved, and says so loudly when https is configured but did not happen.
- A throttled login answered HTTP 200. A failed one answers 401, and a caller
  is meant to tell them apart by status alone; the rate cap now answers exactly
  as the account lockout does, status and message alike.

Also extends server.trusted_origins to the WebSocket handshake, which compares
Origin against Host the same way and would otherwise leave a proxied dashboard
loaded but with no live data.
The image started the web server directly. A certificate can only be given to
that server as it is built, and only the ipmideck command resolves one, so
IPMIDECK_SERVER_HTTPS=true was accepted, generated nothing, and the container
went on serving cleartext. Docker is the main way this is deployed, so the one
documented way to turn on encryption did not work where most people would use
it.

The image now starts through the console entry point, which resolves (and if
needed generates) the certificate, and additionally routes SIGTERM through the
graceful shutdown that restores fan control. Without a TTY the interactive
console degrades to plain logs, which is what it was built to do.

--host/--port go BEFORE the subcommand: they belong to the top-level parser, and
the other order aborts with "unrecognized arguments" and never starts.

The health check could not stay fixed on http either — it would have reported
the container unhealthy precisely because the operator turned encryption on. It
now tries both schemes and does not verify the certificate, since it is asking
whether the app is alive, not whether the issuer is trusted.

Verified end to end: the certificate is generated, the socket negotiates
TLS 1.3, plain HTTP against it is refused, /api/health answers 200 over TLS and
the health check exits 0.
…page

The sidebar showed a hardcoded 'v2' that said nothing and did nothing. It now shows the version
actually running and opens the history that explains it, over whatever page the operator is on
— reading what changed is a glance, not a destination worth losing your place to. The dialog is
built on the dialog primitive already in the tree, so the focus trap, Escape handling, scroll
lock and focus return are the primitive's, not hand-rolled.

A security release is told apart from an ordinary one by a badge that carries the word and a
glyph as well as the colour. When there is no newer version, nothing is shown at all: no
banner, no toast, no modal that appears by itself.
… open

When no old-format credential could be decrypted but some rows were already in the new
format, the message said those rows were converted "with this key" without checking.
With the encryption key of another install (a half-restored backup) nothing opens at
all, and the rows were blamed instead of the key. A converted row now has to decrypt
before it counts as evidence that the key is right.
A failed or timed-out preflight also leaves the image job "skipped", which the PyPI and
draft-release conditions took for the intended "no Docker Hub token" case, so the
irreversible PyPI upload would still run. Both jobs now also require the preflight
itself to have succeeded.
except ValueError as e:
return {"success": False, "error": str(e)}
await auth.set_auth_enabled(True)
token = await auth.create_session_token_async(body.username)
Comment thread backend/console.py
try:
if sys.platform.startswith("linux"):
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((probe_host, port))
Comment thread backend/core/certs.py
fqdn = socket.getfqdn(hostname)
if fqdn and fqdn != hostname:
names.append(fqdn)
except OSError:
Comment thread backend/core/certs.py
try:
for info in socket.getaddrinfo(hostname, None):
addresses.append(str(info[4][0]))
except OSError:
Linux lets a new bind reuse a port in TIME_WAIT only when the socket that left it had
SO_REUSEADDR as well. uvicorn sets it; the test's listener did not, so the test failed
on Linux for a reason the real server never hits.
Resolved against dev:
- Startup keeps dev's route handling. Everything added during startup, the update routes
  included, is dropped by the next startup and the lifespan chain is restored, so this
  branch's separate per-router bookkeeping is removed. Its route-count ceiling test is
  replaced by the existing boot-loop test.
- The startup log keeps the TLS-aware line and its warnings and adds the version line.
- The console keeps the live scheme and adds the update-key wiring.
- The update-check bullets join the existing Added group in the changelog, and the
  packaged copy is re-synced.
- The docs describe the setup question as what it is: asked once, box ticked, nothing
  contacted before the answer. They also state that the endpoint sees the caller's IP.
- A console test opened /dev/null, which does not exist on Windows; it uses StringIO.
- The bundled interface is rebuilt.
…releases on every channel

- A redirect is followed only to https on pypi.org, hub.docker.com or api.github.com, and is
  logged like a request. The stock handler followed up to ten redirects to http, https or ftp,
  so a redirecting endpoint could move the request, version included, to a cleartext host.
- A release link from a response is kept only if it is a page of this project's releases.
  It is stored and rendered as an anchor, so a javascript: or foreign URL no longer gets there.
- The package index and the container registry carry no release notes, so a newer version
  found there was never marked as a security release, and most installs could not see the
  badge. That release's notes are now read from the published releases. If that fails, the
  version is still reported, just without the badge.
- Sockets are opened in one place, which the tests replace, instead of through the global
  urlopen.
…witches

The floor between unattended checks was kept in memory only, so every process start sent a
request and a crash-looping container sent one per restart. The stored time of the last check
now counts too. A normal start still checks; a start minutes after the last check waits out
the floor. A future timestamp or an unreadable one does not suppress anything. A cached
release link is re-validated when it is read.

Two things had no test: the configuration switch and the recorded answer gating the
unattended check, and the switch gating an on-demand check. Removing either check left the
suite green. Both now have tests.
…plies it

The answer was also writable through the generic app-config route. That write stored the
value without starting or stopping the check, so turning it off there did not hold until a
restart. The key stays readable there and is no longer writable.

Every update route is now tested to answer 401 without a session, and nothing it does reaches
the network. The test that the unattended check stays off under the configuration switch now
records consent first, so it can fail.
If recording the answer failed, the wizard moved on silently. Nothing was stored, so no
unattended check would run, whatever the box showed. A warning now tells the operator where
to set it later. A 404 is not reported: it means the configuration has switched update
checks off and there is nothing to record.

The About section's comment named the health endpoint as the version source. The version is
read from /api/config.
…heck found

- A lookup never raises: an answer that is not a JSON object, a field of the wrong
  type, a truncated body or anything unexpected becomes a reason, not an exception.
- Only a "### Security" section marks a security release; the phrase in prose no
  longer does.
- A stable install is not offered a pre-release.
- Only app containers (Docker, Podman, OCI) count as a container install; LXC and
  systemd-nspawn system containers do not.
- When a newer version is found but its release notes cannot be read yet, the
  security question is asked again after an hour instead of a day.
- A failed check keeps the update an earlier one found, and a check inside the
  rate-limit window with nothing stored reports "not_checked" rather than a blank
  success.
- Two overlapping consents start a single unattended check, and the loop stops on
  its own once consent is withdrawn.
- After a failure the check retries from 15 minutes, doubling up to six hours.
- Tests can no longer reach the network: a session-wide backstop refuses every
  real request, and the two tests that started the loop now stub the lookup.
…port a known update on [g]

- updates.enabled and IPMIDECK_UPDATES_ENABLED share one parser: a quoted "false",
  "no", "off" or "0" now switches the check off instead of loading as truthy text,
  and a value that reads as neither on nor off counts as off with a warning.
- "updates: false", written as a value rather than a section, is honoured as the
  switch instead of being ignored.
- The console [g] key reports an update an earlier check found even when this
  check fails, and no longer calls a failed check "the latest published release".
…behaves

- An open page re-reads the cached update state when it becomes visible again,
  hourly while the unattended check is on, and once shortly after it is turned on,
  so what the server found shows up without a reload. Nothing here ever asks for
  a check on its own.
- Setup no longer warns that the answer could not be saved when update checks are
  switched off in the configuration (the server answers 405 there, not 404).
- Check now keeps showing an update an earlier check found when this one fails.
- The setup hint names when and where the check asks, and the extra GitHub request;
  the suppressed notice names both the config key and the environment variable.
- README and changelog: nothing is contacted on its own before the answer or after
  unticking, Check now and [g] still work when pressed, upgraded installs are not
  asked and stay off until turned on, the retry cadence, and how a security release
  is recognised.
…hostile answers

- Version strings are parsed in linear time and never raise: input over 64
  characters is refused, numeric fields are bounded and the suffix is unambiguous.
  A crafted version string could otherwise hold the interpreter lock for minutes.
- Pre-releases are ordered by phase (dev, alpha, beta, release candidate), and a
  post-release counts as a final release.
- A clone running a final release is not offered a release candidate tag either;
  that answer is "nothing newer", not a failed check.
- Install detection never raises when a marker file cannot be inspected, and the
  Docker marker alone is enough to recognise a container.
- A connection dropped while the answer is being read is reported as unreachable.
- A lookup can be withdrawn: once withdrawn it opens no further connection, so the
  release-notes request is not made after the check was switched off.
…ce retries correctly

- Switching the check off tells a lookup already running in its worker thread to
  stop before its next request, and an abandoned attempt no longer holds the
  rate-limit window, so switching it back on checks straight away.
- A database that refuses to store the answer backs off like any other failure
  instead of producing a request every 15 minutes.
- A newer version whose release notes cannot be read yet is checked again hourly,
  at most six times, whether the unattended check or Check now found it; then the
  daily cadence resumes.
…le without settings

- The outcome of a [g] check is written to the console body like the line that
  announces it, so it shows at every verbosity instead of only at INFO.
- A config.yaml whose top level is a list or a single value is ignored with a
  warning naming the file, as before, instead of stopping the server at start-up.
- The decision to wire [g] to the update check is a small helper with its own
  tests, and the switch tests now fail if a documented spelling stops being
  recognised.
…al cadence

- A state read that started before Check now finished, or before a consent change
  was saved, no longer replaces the newer answer; open pages do not re-read while
  a check is running.
- After the unattended check is switched on, the page reads the result at 15, 30
  and 60 seconds, stopping as soon as the first check has landed.
- README, changelog and the setup question describe the retry cadence the server
  keeps, and a test pins those words to the values the service uses.
A new pre-push hook runs the i18n gate and refuses the push on failure; the
gate workflow runs the same checks.

- check-i18n-parity reads the languages from languages.ts (a language added
  there without a catalog, or a catalog not registered there, now fails) and
  flags empty values.
- check-i18n-stale fails when an en string changed in the pushed range but a
  translation did not. [i18n-skip] in a commit message overrides it for a
  deliberate en-only change such as a typo fix.
- test_backend_i18n_parity checks the backend error catalog: every message in
  every supported language, non-empty, same placeholders, not left in English,
  and SUPPORTED equal to the frontend's language list.
ipmitool (#14):
- detect a missing ipmitool at startup and in the API; the app starts anyway and warns
- a persistent banner with the install command for the host OS, and an install button
  that runs the package manager when IPMIDeck runs as root and the config allows it
- routes return the i18n code ipmitool_missing instead of a raw error, and the polling
  loops log one parsed warning instead of a traceback

Updates:
- checks on by default; the CLI announces a newer version with its upgrade command
- an icon button beside the version, a one-time toast and a dialog with the release
  notes, linked to the docs guide in the reader's language
- one-click install for pip, pipx and uv (backup of database, key and config first,
  then an automatic restart; a manual start on Windows) and for Docker through
  Watchtower; other installs get the command to run
- the backup folder is 0700 (it was 0600, which made the backup fail in Docker)
- index.html is served no-cache and a stale lazy chunk reloads the page

Also: changelog dialog formatting, CHANGELOG format test, setup hint cut to one line,
setup buttons keep their width while loading, Italian wording pass.
Git on Linux and macOS ignores a hook without the executable bit, so the i18n pre-push gate would never run there.
The stale-translation check searched the whole commit message for the skip token, so any commit that merely mentions the token in its body (the commit that introduced the gate did) silently disabled the check for every range containing it. Only the subject line counts now, which also keeps the override visible in git log --oneline.
…briefly

The install notice asks whether ipmitool could be installed here, and on Linux deciding
that starts `sudo -n true`. It ran on the event loop on every load of the config endpoint,
which every page makes, so fan control and the live telemetry stalled while sudo answered. The
check now runs in a worker thread and its answer is kept for a minute; an install asks sudo
again, because its rights may have just been granted.
…re they are

Every update left a new folder under backups/ and none was ever removed, each one another
copy of the database and the key that decrypts its BMC passwords. After a backup only the three
most recent pre-update folders are kept, ordered by the time in their name; anything else in
backups/ is left alone, and a folder that cannot be removed is logged without failing the
update. The update dialog now shows where the backup is and that it holds the BMC credentials.
…h the Alpine index

- The Windows helper waited at most two minutes for IPMIDeck to exit and then installed whether
  it had or not, over files the running app still held. It now installs only once the app has
  exited, logs that nothing was installed otherwise, and removes itself either way.
- apk add on Alpine now passes --no-cache, so it reads a fresh package index instead of failing
  on a host that never ran apk update.
The skip token anywhere in a pushed range switched the whole check off, so one typo fix
let every untranslated string pushed with it through. It now exempts only the en strings its
own commit changed, and the check names them. The range is measured from the merge base, so a
base branch that moved on no longer shows its own changes as untranslated ones.
The check has been on by default since installs that never saw the setup question started
checking too, but the example configuration still said an unattended check needed the opt-in
from setup, and the module's own principle said nothing left the machine unless asked for. Both
now say what happens: the check runs unless it is turned off in setup or Settings, and
updates.enabled: false removes it entirely.
Comment thread backend/core/ipmitool.py
if cached is not None and time.monotonic() - cached[0] < _SUDO_ANSWER_TTL:
return cached[1]
answer = _probe_sudo()
_sudo_answer = (time.monotonic(), answer)
Comment thread backend/core/ipmitool.py
label = value.strip().strip("\"'")[:80]
if label:
return label
except OSError:
@dev-luigi dev-luigi changed the title Hardening, authenticated credentials and container fixes Hardening, update awareness and ipmitool install Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants