21 Commits
Author SHA1 Message Date
Catubba 6478adc845 docs: correct what a pre-release audit found across the shipped markdown
Checked every tracked .md against the code rather than for plausibility. The
API table, the metric names, the dashboard payload, the permissions
cheat-sheet, every config field and SECURITY's numbers all already matched.
Four things did not.

ARCHITECTURE contradicted itself on the power lease: keying it on the host
updated the description a few lines up but left a later paragraph still saying
the lease is per device, complete with the extra sleep/wake cycle that no
longer happens. Replaced with the consequence that is now true -- every device
on a held machine reads busy, which is what disables the power button on its
siblings.

The startup catch-up was documented nowhere despite sending notifications.
ARCHITECTURE's scheduler section now covers it and the heartbeat that decides
what counts as downtime.

frontend/README pointed at design/joulenap-remix/, which .gitignore excludes --
a path no clone has. It also filed the setup wizard under the settings panels,
where it has not lived since it moved to src/wizard/, and said npm install
where the lockfile wants npm ci.

Two wordings tightened: the WoL interface is a dropdown now, not free text with
an override; and "stores a copy needed to verify requests" read as though the
API key were hashed, when it sits in config.yaml like every other secret.
2026-08-07 15:35:28 +02:00
Catubba 0170773f03 docs: collapse the install alternatives and the per-dashboard snippets
Both files spent most of their length on content any given reader skips. The
three install paths are mutually exclusive and Option A is the recommended one,
yet B and C sat between its last step and First run -- a hundred lines
interrupting the path most people are on. The four dashboard snippets are the
same shape: a reader runs one of Homepage, Glance, Homarr or Dashy and scrolls
past the other three.

INSTALL keeps its Option B and Option C headings and collapses only the bodies,
because the file's own table of contents links to those anchors and a summary
in place of the heading would break them. The dashboard blocks have no
incoming links, so there the summary replaces the heading and names the widget
each one configures.

INTEGRATIONS' 0.9 -> 1.0 field mapping is collapsed too: it is legacy-only and
it was the first thing every reader met, including everyone who never ran 0.9.
Its summary keeps the words README and CHANGELOG use to point at it.

Visible length: INSTALL 339 -> 251 lines, INTEGRATIONS 397 -> 267. Reference
tables were deliberately left open -- find-in-page does not reliably reach
inside a collapsed block, which is the whole reason only alternatives and
legacy content are hidden.
2026-08-07 14:31:53 +02:00
Catubba 4c2fa4a77e docs: drop the power-lease limitation the per-machine key removed
The readme, the changelog and the architecture notes all told a first-time
reader that two datastores on one backup server carry independent power leases
and can therefore sleep and wake that machine once more than needed. Keying the
lease on the host removed it, so what shipped in three files was no longer true.

The architecture notes now describe the key itself, and what a sync route
between two datastores of one machine actually does: one lease, one wake, one
power-off.
2026-08-07 12:18:22 +02:00
Catubba 0d5d7697d3 fix(wizard): detect a backup server's MAC by connecting to it, not by pinging
Detect MAC primed the ARP cache with a ping subprocess and then read the cache.
The runtime image is python:3.12-slim with no apt layer, so it has neither ping
nor arp; _ping swallowed the OSError, leaving the priming step dead code in
every shipped container. The lookup then read a table nothing had populated, and
succeeded only when other traffic happened to have kept the neighbour warm --
which a from-zero wizard, with no registered device and so no probe traffic, has
no reason to.

Priming is now a TCP connect through the existing net.tcp_reachable. ARP is
resolved below TCP, so the kernel must learn the MAC before it can send the SYN
and a refused port primes the cache just as well as an open one; the port is
therefore not threaded through from the caller, since it cannot change the
outcome.

_ping, _read_arp_command and the Windows branch go with it, which removes the
last use of subprocess in the backend. /proc/net/arp is now the only reader, so
detect_mac returns None off Linux -- Joulenap ships as a Linux container and the
frontend dev loop runs against the stub.
2026-08-07 12:16:30 +02:00
Catubba a447e7ee39 docs: state the disk a Joulenap container actually needs
INSTALL.md's prerequisites said "1-2 GB disk is plenty" while its own Create-CT
step said 2 GB, so the two disagreed and the smaller figure cannot work at all.
Measured on a fresh debian-12-standard CT: 0.5 GB after creation, 1.3 GB once
Docker is installed, 1.6 GB with the image loaded. That leaves 267 MB on a 2 GB
disk for the database, its WAL, and an update that has to pull the new image
before it can drop the old one.

Both lines now say 4 GB.
2026-08-07 12:16:04 +02:00
Catubba 3a6865fabb refactor(ui): rename the Topology card to Backup map
"Topology" is networking jargon for what the card actually shows: which hosts back
up to which backup servers. The key is renamed too, so a translator is not handed
"topology" holding "Mappa dei backup".

The word had leaked into body copy as well - the power-on note and the route-delete
confirmation both referred to "the topology" - plus the changelog, the install guide
and the route colour comment in the example config. All follow the card.

The component, its geometry helpers and the CSS classes keep the Topology name:
it is a fine internal name for the diagram and no user sees it.
2026-08-05 23:25:33 +02:00
Catubba 151b2a53f6 feat(wizard): name a backup server's token after its datastore
A backup server serving two datastores is two devices, which the duplicate
guard deliberately allows -- but both wanted a token called joulenap, so
setting up the second one deleted and recreated the first one's token. The
first device was left holding a dead secret, and re-entering the new one did
not repair it: deleting a token also drops its ACL entries, and provisioning
re-grants only /datastore/<its own datastore>, so the other datastore stayed
locked out until root ran acl update by hand. The product supported a
configuration its own wizard could not provision.

Tokens on a backup server are now named joulenap-<datastore>, sanitised to the
character set PBS accepts and falling back to the bare prefix if nothing
survives. The two never meet, and each keeps the narrow per-datastore grant
rather than widening to /datastore. A Proxmox host is a single device and
cannot collide with itself, so its token stays plain joulenap. The name is
derived rather than exposed: a field would only invite tokens Joulenap later
fails to find. Tokens already in use are untouched.

The conflict dialog no longer claims the name is "joulenap", since on a backup
server it is not.

A device card also stops reporting "Connected - API OK" for what is a one
second TCP connect to the API port. The authenticated call behind it is made
and its failure discarded, so a server whose credential had been revoked
advertised itself as healthy indefinitely, with cached usage figures beside it
to match. The label now reads "Reachable", which is what is actually checked;
the Test button, which surfaces the same call's error, owns the API verdict.
Changing the underlying field was rejected: it is a published contract, both
in the dashboard payload and as joulenap_pbs_online, documented as answering
on the API port.

Documented in the architecture, the wizard guide and the example config,
including that replacing a token clears its permissions -- so a hand-made
setup where one token served several datastores needs re-granting.
2026-08-05 19:47:32 +02:00
Catubba e7d23ec4b4 fix(wizard): ask per box before replacing an API token
Approving "replace the existing token" for the Proxmox host also replaced it
on the backup server, without asking a second time. One ref held the answer
for the whole wizard run -- deliberately, the comment above it said "for both
boxes this flow provisions" -- but the two boxes are different machines whose
tokens are held by different things, so consent for one is not consent for the
other. It is now kept per box, keyed the way the confirmation already picks
which host to name.

Reproduced on real hardware before fixing: one approval, given for a Proxmox
host, rotated the backup server's token and left a third machine's storage
entry answering 401 Unauthorized -- the outage the confirmation exists to
prevent, reached straight through it. It needs a host that still holds a
joulenap token but is no longer registered, which is what removing a device
leaves behind, plus a discovered storage that matches no registered server.

The topology card said "1 guests": both keys interpolated a variable that was
not named count, so they never pluralised. The parity test cannot see this --
Italian borrows "guest" uninflected -- and neither can the plural test, which
only checks that keys already using count come in complete sets.

The wizard documentation still described the duplicate-host refusal as
happening after a token had been provisioned. Since the guard moved to the
connection step nothing is provisioned before it.
2026-08-05 17:16:39 +02:00
Catubba 406897eddd feat(wizard): refuse a box already registered, and name what a token replacement breaks
An API token belongs to one server, so adding a second Proxmox host or a
second backup server can never disturb the ones already configured. The one
way to lose a working credential is to point a wizard at a box that already
has a token by the name it wants — and both routes to that are now closed.

Adding a box that is already registered is refused at the connection step,
before any password or token leaves the browser, naming the device that
already exists. Only the id was checked before, and the id is auto-picked so
it never collided: the save succeeded, root-mode provisioning replaced the
token on that host, and the original entry was left holding a secret that no
longer worked. A backup server serving a second datastore is still a
legitimate second device and is still allowed.

When a token really does have to be replaced, the confirmation now names
what Joulenap can see will break instead of warning in the abstract: another
of its own devices on that host, and — the case that actually caused an
outage — a Proxmox host whose PBS storage entry authenticates with that very
token, where the symptom is every backup through that storage failing with a
401 that mentions nothing about a token. Finding those means asking each
registered host what storages it has, so `GET /api/devices/pves/{id}/storages`
lists them without writing anything; a host that is down simply cannot be
warned about and the dialog falls back to the general wording.

Joulenap still cannot see every holder of a token — a script, another tool,
an unmanaged host — so the wizard documentation now points at pasting an
existing token, or giving Joulenap a name of its own, for those cases.
2026-08-05 14:15:42 +02:00
Catubba 74e8af2380 feat(devices): re-read a Proxmox host's storage map
Which PVE storage points at which backup server is discovered, never typed
— and until now it was only ever discovered while the Add-PVE wizard was
open. Register a backup server afterwards, which is the entire point of the
Add-PBS flow, and there was no way to complete the map from the interface:
the wizard only ever creates so it refuses a host it already knows, the
device editor renders storages read-only, and the connection test reads
guests rather than storages. Backup routes onto that server stayed
impossible, and the only way through was hand-editing YAML.

Worse, the advice the product gave was actively harmful. Both the Add-PBS
wizard's closing warning and the device editor's hint said to re-run
"+ Add -> Proxmox VE" — which provisions a token *before* it fails on the
duplicate id, so following it rotated the working host's token and then
errored out, taking every backup route with it.

Settings -> Devices -> edit the PVE -> Re-read from Proxmox now rebuilds the
map with the credentials the device already has, matching each storage to a
registered server by host and datastore. The map is replaced rather than
merged, so a storage removed on the Proxmox side goes away here too; the
ordinary save-time cross-reference check refuses a result that would leave
an existing route without a mapping. Both strings now point at it.
2026-08-05 13:17:06 +02:00
Catubba 9d65826278 fix: address the M15 pre-release review findings
Four review passes over the branch — backend, frontend, docs/packaging,
security — and the findings each one confirmed.

Config and secrets:

- omitting a device secret in `PUT /api/config` silently cleared it.
  `deep_merge` merges mappings key-by-key but replaces lists wholesale, and
  1.0 moved devices from mappings into lists, so deleting an
  `api_token_secret:` line in the YAML editor returned 200 and wiped the
  token. A stored secret is now carried over when the field is absent
  entirely; an explicit `""` still clears it.
- the pre-migration `.bak` was created by `copyfile` at 0644 and chmod'ed
  after, leaving a window in which every credential in it was world-readable.
  Opened 0600 up front, like every other secret-writing path.

Frontend:

- a failed SSH keygen put both wizards into an unbounded POST loop: the
  effect depended on the whole setup object, which is a fresh literal every
  render, and a failure clears the request cache so the retry fired from the
  next render instead of from a click.
- an expanded run's timeline froze at the finish, leaving `poweroff` stuck on
  "Running" forever, because polling stopped the moment the run did — and the
  power-off steps are recorded after the run reaches success.
- the route editor merged the PVE and PBS id namespaces, which are unique
  only per list. With a PVE and a PBS sharing a name, clicking the PVE chip
  lit the PBS one, flipped the badge to Sync and saved a route that synced a
  box to itself. The draft now carries kind-prefixed keys, as the topology
  already did.
- Add-PVE could not recover when the PBS landed but the PVE create then
  failed: each retry minted another orphan token on the PBS and re-posted a
  device that already existed. It now resumes instead of repeating.
- the route-enable and guest-include switches had no accessible name.
- upcoming occurrences drifted by an hour across a DST boundary — a fixed
  86_400_000 ms step is a UTC day, not a calendar day.
- a route whose last run aged out of the shared 50-run window claimed it had
  never run.
- the wizard's manual SSH-key step showed the unrestricted public key above
  the restricted `authorized_keys` line, inviting a copy of the wrong one.

Docs: the migration-failure fallback was documented backwards in three files
(the file on disk is preserved, but nothing in 1.0 reads the 0.9 sections, so
the app boots empty with the banner set); TLS pinning and SSH host-key
verification were both documented as unconditional when the runtime degrades
without a fingerprint and trusts on first use; the sync-route credential
propagation was documented nowhere; plus a dead Localization page, a wrong
endpoint for run steps, an omitted session-less endpoint, a self-contradiction
about which wizard calls write to disk, a password-change claim that
contradicted SECURITY.md, and a stale `[Unreleased]` link.
2026-08-05 09:44:32 +02:00
Catubba 5ed03db20c feat(demo): route-aware demo replay and 1.0 documentation
Rewrite demoTimeline.ts around the three-route scenario and rebuild the
scripted replay inside devStub.ts on top of the 1.0 endpoints, then bring
the shipped documentation in line with what actually ships.

Demo:
- demoTimeline.ts keys its online windows by PBS id, so one field covers a
  single-box backup and a sync route's two; steps carry the per-device
  names the backend really emits, plus their detail.
- The demo auto-plays: it opens mid-backup on Nightly and the queued Lab
  route starts by itself when that lands, with the target left awake
  between them and the skipped power-off recording why. The queue and the
  power lease are visible without a click.
- The clock ticks and the fixture calendar shifts by whole weeks, so
  weekdays and times survive and the schedules stay self-consistent. Dev
  stub mode keeps its frozen clock; every replay mutation sits behind the
  demo flag.
- Restore the "fake data" banner and make logout reload rather than strand
  the visitor on a login form.
- build:demo now type-checks first, which it never did.

Fix the expanded run history row refetching its detail only once, so a run
in flight showed a frozen step timeline while its task log kept streaming.

Docs:
- ARCHITECTURE: the route model, the queue and lease, a cycle per kind, the
  migration, and REST tables rebuilt from the shipped routers.
- CONFIG-WIZARD: the two device flows, and the /remote grant a sync route
  needs on a peer configured before 1.0.
- INTEGRATIONS: the new dashboard payload, snippets matching the ones the
  app generates, the labelled metric names, and a 0.9 mapping table.
- README, INSTALL: routes, the five settings tabs, upgrading from 0.9, and
  the Node version CI and the image actually build with.
- SECURITY: transport pinning, auth hardening, the two API-key endpoints
  outside the session, and what Joulenap deliberately does not do.
- CONTRIBUTING: npm test is a separate CI step, and the demo section now
  describes the demo that exists.
- CHANGELOG: the 1.0.0 entry, including the breaking dashboard and metrics
  shapes and the exclude guest mode widening to all.
2026-08-04 14:09:19 +02:00
Catubba 530cb5e9c3 release: 0.6.0 — run history, job cancellation, Prometheus metrics
Run history in the UI: the activity card gains a second tab listing every run
with job type, trigger, result, duration and guest count. Rows expand in place
to that run's steps and log lines. Backed by /api/runs, which had existed since
0.1 with no consumer; RunSummary gains guests_ok.

Job cancellation: Run backup / Run GC turn into Stop while a job is in flight,
behind a confirmation that can also power the PBS off afterwards. Cooperative
cancellation checked in the existing poll loops (task wait, PBS wake wait,
between steps), and the underlying PVE/PBS task is stopped, not abandoned, so a
cancelled backup does not keep running on the server. A running verify is
stoppable too. Previously a stuck job blocked every later run and manual
power-off until restart.

Prometheus /metrics for Grafana, protected by the existing dashboard API key.
Sixteen gauges including per-guest last-backup times, so a guest dropping out of
the backup set can be alerted on. Written directly in the text exposition format
rather than adding a dependency; a scrape never wakes the PBS.

Notifications now name the job that ran: a failed verify or GC no longer reports
"backup failed".

Removed the dead backup.guests.auto_include_new key. It was never read, while
its name and default implied new guests were picked up automatically. Existing
configs still load (the key is stripped) and the docs now state the real rule.

Documentation accuracy pass over README, ARCHITECTURE, INSTALL, INTEGRATIONS,
SECURITY and config.example: corrected the PVE and PBS token privilege lists,
the garbage-collection and guest-selection descriptions, the supported-versions
table and the API reference, and added a Settings walkthrough.

Toggle switches are announced as switches by screen readers and can no longer
submit a surrounding form.
2026-07-23 11:20:00 +02:00
Catubba d5f7132ee1 release: 0.5.0 — Advanced settings tab, config.yaml editor, update check
Closes re-review findings 11.8, 11.10 and 11.14.

- Advanced settings tab exposing the config-only knobs: backup mode,
  bwlimit, keep_last/keep_yearly, history retention, port, session
  lifetime and the HTTPS-only cookie flag.
- In-browser config.yaml editor (CodeMirror 6, lazy-loaded chunk) over
  new GET/PUT /api/config/yaml. The PUT path reuses the extracted
  _apply_config helper, so the editor gets the same redaction,
  deep-merge and validation as PUT /api/config; a Copy button yields a
  secret-free config for bug reports.
- Opt-in update check (app.update_check, default off): GET /api/update
  asks GitHub once a day behind a 24h cache and never touches
  /api/health, which the Docker healthcheck depends on.
2026-07-22 19:20:55 +02:00
Catubba 0cd59dcb40 fix: address re-review findings + keep-PBS-on manual jobs
Feature — manual jobs can keep the PBS awake:
- Run backup / Run GC are now available while the PBS is asleep; they wake
  it themselves and only require that no other run is in progress.
- Manual GC runs as a full wake -> GC -> power-off cycle (run_gc_cycle),
  mirroring the verify cycle, instead of failing on a sleeping PBS.
- A "Keep PBS powered on after the job" toggle on the confirm dialog leaves
  the box on instead of powering it off, defaulted to its current power
  state (already-on stays on; asleep goes back to sleep). POST
  /api/backup/run and /api/gc/run accept an optional {keep_on} body.
  Scheduled runs always power off, unchanged.

Fixes:
- BE-S9: PUT /api/account now requires the current password, so a stolen
  session alone can't rotate the admin credentials.
- BE-B1: an invalid backup cron is rejected on save (422) and, if already
  on disk, is skipped with a warning instead of crashing the scheduler on
  every restart.
- FE-C1: the setup wizard no longer clears the stored PVE token secret on
  re-save (mirrors the PBS redaction guard).
- FE-H1: exclude guest mode is shown read-only and preserved on save,
  instead of being displayed as an include set and inverted on Apply.
2026-07-10 21:58:35 +02:00
Catubba 256dfecbad fix(dashboard): 401 on non-ASCII API key + correct snippet icon path
secrets.compare_digest() rejects non-ASCII str with TypeError, turning
the unauthenticated /api/dashboard endpoint's 401 path into a 500 when
an attacker sends a non-ASCII ?key= or X-API-Key. Compare as UTF-8
bytes instead, and add a regression test.

Also fix the Homepage integration snippet (UI + docs) referencing the
non-existent /favicon.svg; the app serves its icon at
/assets/joulenap-icon.svg.
2026-07-09 00:33:49 +02:00
Catubba 61cdf7268e docs: add dashboard integration guide 2026-07-09 00:20:40 +02:00
Catubba 48e4bc9b42 release: v0.1.1 — version bump + doc corrections 2026-07-08 22:48:57 +02:00
Catubba be3182c8e0 docs: transport-security behavior + PVE-provision residual (JN-002/003) 2026-07-08 00:16:39 +02:00
Catubba 01495ea43b docs: note harmless locale warning during Docker install on fresh LXCs
Fresh Debian LXCs ship without a generated locale, so the get.docker.com
script prints "perl: warning: Setting locale failed" noise. Add a note
explaining it's harmless and how to silence it: update-locale
LANG=C.UTF-8 for future logins, plus an export for the current shell so
no re-login is needed.
2026-07-03 18:49:54 +02:00
Catubba 28cc0f425d Initial commit 2026-07-02 20:46:57 +02:00