fix: container logging, preview label, and two documentation gaps

Four small ones found alongside the Gate 2 defects.

Nothing configured the root logger, so every `log.info` in the package went
nowhere: `docker logs` showed uvicorn's handful of lines and nothing else,
including across a 0.9 -> 1.0 config migration — the riskiest thing the app
ever does, and it left no trace of having run. Both entry points now set
logging up first, with `JOULENAP_LOG_LEVEL` to turn it up.

The route editor's preview chip printed the internal kind-prefixed key
(`pve:pve`) instead of the device id, ever since the draft started carrying
keys so a PVE and a backup server could share a name.

SECURITY.md said backup-server API traffic is pinned to a stored fingerprint
without saying where that fingerprint comes from. Adding a server through a
Proxmox host takes it from that host's storage configuration; adding one
directly reads it off the box over a connection nothing has authenticated,
which is trust on first use. Pinning protects everything after setup, not
setup itself, and the document now says so.

The changelog now warns upgraders that history is tracked per route, so
converted routes read "never run" and every guest reads "never backed up"
until the first 1.0 run — with the old runs still listed underneath, which
makes it look like data was lost when nothing was.
This commit is contained in:
Catubba
2026-08-05 13:24:30 +02:00
parent f54c7f04e9
commit 27dc791762
4 changed files with 63 additions and 2 deletions
+27
View File
@@ -14,6 +14,12 @@ Joulenap is no longer built around one Proxmox host backing up to one backup ser
configurations are converted automatically on the first start; see Changed below for the one
conversion that is lossy, and for the two breaking changes outside the interface.
**After upgrading, expect one alarming-looking display that is not a problem.** Backup history is
tracked per route, and the conversion gives your old schedule new route ids, so *Last backup per
guest* reads "never" for every guest and the converted routes read "never run" — with all the old
runs still listed underneath. Nothing has been lost: the caches fill in again per guest as runs
happen, and the first run of each route restores its badge.
### Added
- **Routes.** A route is one scheduled flow of backup data between devices: sources, a target, its
@@ -101,6 +107,27 @@ conversion that is lossy, and for the two breaking changes outside the interface
### Fixed
- **Stopping a run while a backup server was still coming up left it running.** The magic packet
had already gone out, but the lease that decides when a box goes back to sleep was only taken
once the box answered — so a run stopped during the wake had nothing to release, recorded no
power-off step, and left the machine on until somebody noticed, whatever the stop dialog's
power-off toggle said. On a route with more than one server it was worse: once stopped, the
reachability check for the next box returned "unreachable" without touching the network, and
Joulenap woke a machine nobody had asked for. A run stopped during the wake also no longer
starts its cycle, which used to file it as failed — and notify about it — if the first call to
a just-booted server errored.
- **Provisioning a device replaced an API token of the same name without asking.** A token's
secret only exists at creation, so one that already had the name was deleted and recreated —
silently invalidating it for everything else using it, typically the backup server's storage
entry on a Proxmox host, and therefore every backup through it. Replacing a token is now
something you confirm, and the confirmation says what breaks. A create rejected for any other
reason no longer takes a live token down with it.
- **A backup server registered after its Proxmox host could never receive backups.** Which PVE
storage points at which server is discovered, not typed, and only the Add-PVE wizard ever
discovered it — so a server added afterwards stayed unlinked, with no way to fix it in the
interface. Worse, both the wizard's closing warning and the device editor told you to re-run
the Add-PVE wizard, which provisions a token before failing on the duplicate host. Settings →
Devices → edit the PVE → **Re-read from Proxmox** now rebuilds the map.
- **A sync route worked once and then never again.** Proxmox Backup Server refuses to delete a
remote that a sync job still references, so the second run of any sync route failed. The job is
now removed before the remote is touched. Two related failures went with it: the run and delete
+7
View File
@@ -71,6 +71,13 @@ sync run and **deletes it again when the run ends**, so it is not left sitting t
device with **no fingerprint is not pinned and its certificate is not validated at all**. Joulenap
refuses to send root credentials over such a connection, but ordinary API traffic (carrying the
device's API token) still goes over it. Keep the fingerprint set.
- **Where that fingerprint comes from decides what pinning is worth.** Adding a backup server
through a Proxmox host takes it from that host's storage configuration — a channel you have
already authenticated. Adding one directly reads it off the box over a connection nothing has
authenticated yet, which is trust on first use: an attacker already in the middle at that moment
would have their certificate pinned instead, and every later connection would faithfully verify
against it. Pinning protects everything after setup, not setup itself. On an untrusted network,
compare the fingerprint the wizard shows against `proxmox-backup-manager cert info` on the box.
- **A backup server's SSH host key is verified against `data/known_hosts`.** The wizard's automatic
key-install path shows it to you for confirmation first, and refuses to send a root password
before you have confirmed it. Two paths do not populate `known_hosts`: installing the public key
+25
View File
@@ -35,12 +35,34 @@ from .notify.messages import build_interrupted_message
log = logging.getLogger("joulenap.main")
#: Container log verbosity. `JOULENAP_LOG_LEVEL=DEBUG` for a noisy run.
_LOG_LEVEL_ENV = "JOULENAP_LOG_LEVEL"
def setup_logging() -> None:
"""Give the app's own loggers somewhere to go.
Without this nothing configures the root logger, so every ``log.info`` in the package is
dropped and ``docker logs`` shows uvicorn's handful of lines and nothing else — including
across a 0.9 -> 1.0 config migration, which is the single riskiest thing this app ever
does and left no trace of having happened.
``basicConfig`` is a no-op once handlers exist, so calling this from both entry points is
safe, and uvicorn's own loggers (which configure themselves) are untouched.
"""
logging.basicConfig(
level=os.environ.get(_LOG_LEVEL_ENV, "INFO").upper(),
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
)
#: How long shutdown waits for a startup thread. Both only make one notification round-trip, so
#: this is generous; it exists so a black-holing channel can't hang the process on exit. Kept
#: comfortably under Docker's 10s SIGTERM→SIGKILL grace, so a hung channel costs a slow stop
#: rather than a killed one.
_STARTUP_THREAD_JOIN_TIMEOUT = 5.0
def _frontend_dir() -> Path:
"""Directory of the built SPA (Vite output) served as static files.
@@ -150,6 +172,7 @@ def _send_startup_alerts(
def create_app() -> FastAPI:
setup_logging()
# Load (or first-run create) config before building the app: the session
# middleware needs the signing key, and routers read config via app.state.
store = ConfigStore.load_or_create()
@@ -213,6 +236,8 @@ def run() -> None:
"""
import uvicorn
# Before the config load below, which is where a 0.9 -> 1.0 migration runs and logs.
setup_logging()
port = ConfigStore.load_or_create().config.app.port
uvicorn.run("app.main:create_app", factory=True, host="0.0.0.0", port=port, reload=False)
+4 -2
View File
@@ -7,7 +7,7 @@ import { Modal } from '../../components/Modal'
import { Toggle } from '../../components/Toggle'
import { useRegisterDirty, useUnsavedGuard } from '../../shell/UnsavedGuard'
import { guestTypeLabel, type PveGuests } from '../../utils/guestPanel'
import { pbsNodeId, pveNodeId, routeKindBadge } from '../../utils/routes'
import { deviceId, pbsNodeId, pveNodeId, routeKindBadge } from '../../utils/routes'
import {
ROUTE_COLORS,
type FieldError,
@@ -349,7 +349,9 @@ export function RouteModal({ route, routes, pves, pbss, groups, onClose, onSaved
{draft.sourceIds.map((id, i) => (
<Fragment key={id}>
{i > 0 && <span className="arrow">+</span>}
<span className="chip-s">{id}</span>
{/* The draft holds kind-prefixed keys so a PVE and a PBS can share
an id; the preview shows the device, not the key. */}
<span className="chip-s">{deviceId(id)}</span>
</Fragment>
))}
<svg width="46" height="10" aria-hidden="true">