Files
joulenap/config.example.yaml
T
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

199 lines
9.7 KiB
YAML

# =====================================================================
# Joulenap — example configuration
# Copy to config.yaml and fill in your values. config.yaml is git-ignored.
# All secrets (tokens, passwords, ssh key) live here — keep it private.
# =====================================================================
app:
language: en # UI language: en, it, ...
theme: dark # dark | light — also switchable from the header toggle
port: 8080 # web UI port
scheduler_enabled: true # global kill-switch: false arms no route at all (manual runs
# still work). Pause one route with routes[].enabled: false.
timezone: "" # IANA tz for the scheduler, e.g. "Europe/Rome".
# Empty -> the TZ env var, then UTC. Set this (or TZ) or
# "backup at 02:00" runs at 02:00 UTC in a container.
secret_key: "CHANGE_ME" # random string for session signing
# Read-only key for the machine-readable endpoints: GET /api/dashboard (dashboard
# widgets) and GET /metrics (Prometheus). Leave empty to disable both.
# Generate/rotate it from Settings -> Integrations in the UI.
api_key: ""
# Ask GitHub once a day whether a newer Joulenap release exists, and show a footer
# badge if so. Off by default: the app makes no outbound internet call unless you
# turn this on (Settings -> Advanced).
update_check: false
session:
https_only: false
max_age_days: 14
auth:
username: admin
# password is stored hashed (bcrypt); set it on first run via the UI, or
# pre-hash it here. Never store plaintext. Generate a hash with:
# docker exec -it joulenap python -m app.hashpw
# (or, from a local checkout: cd backend && python -m app.hashpw)
# Quote the value — the hash contains '$' characters.
password_hash: ""
# =====================================================================
# Devices and routes
#
# Devices are listed once and referenced by id. A *route* is "sources -> target +
# schedule" and covers every reason Joulenap wakes a PBS:
#
# backup one or more PVE sources -> a PBS target (vzdump)
# sync one PBS source -> another PBS target (remote + sync job)
# external no source -> a PBS target (PVE/PBS run their own
# scheduled jobs; Joulenap
# wakes, watches, powers off)
# verify no source -> a PBS target (verify its snapshots)
#
# All three lists start empty: the wizards under Settings -> Devices -> + Add fill in the
# devices, then you draw routes between them from the homepage. The commented blocks below
# show the full shape of each entry.
# =====================================================================
# --- Proxmox VE endpoints (where the guests live) ---
pves: []
# pves:
# - id: pve-01 # slug, unique; also the name shown in the UI
# host: 192.168.1.10 # or pve.lan. A cluster is ONE entry — it proxies its nodes,
# # which Joulenap discovers at runtime.
# port: 8006
# verify_tls: false # self-signed cert on most homelabs
# # Token privileges: VM.Audit, VM.Backup, Datastore.Audit, Datastore.AllocateSpace and
# # Datastore.Allocate on the PBS storage. The last one is required for vzdump's
# # retention/prune (it deletes old backups) — without it, backups run but pruning fails.
# api_token_id: "root@pam!joulenap"
# api_token_secret: ""
# # For each PBS this node backs up to: the storage as named here in PVE
# # (Datacenter > Storage). Different PVEs may name the same PBS differently.
# storages:
# pbs-01: pbs
# --- Proxmox Backup Servers (the boxes that sleep) ---
pbss: []
# pbss:
# - id: pbs-01
# host: 192.168.1.20 # or pbs.lan
# port: 8007
# datastore: backup
# fingerprint: "" # PBS dashboard > Show Fingerprint, e.g. "aa:bb:cc:..."
# # Leave this empty and the connection to this PBS is NOT pinned and NOT validated —
# # its API token then travels over TLS nothing has checked. The wizard fills it in.
# # Token privileges: DatastoreAdmin on the datastore (status, GC, verify) plus Audit on
# # /system (read-only node CPU/RAM/network for the dashboard). A PBS taking part in a
# # sync route also needs RemoteAdmin AND RemoteSyncPushOperator on /remote — PBS refuses
# # ACL writes from a token, so those must be granted on the box itself or by the wizard's
# # root mode (see docs/CONFIG-WIZARD.md).
# api_token_id: "root@pam!joulenap"
# api_token_secret: ""
# # Set to false for an always-on PBS (a VM, or a cloud-hosted one): Joulenap then only
# # schedules routes onto it and never touches its power, so everything below is unused.
# managed_power: true
# # Wake-on-LAN
# mac: "00:11:22:33:44:55" # PBS NIC MAC
# wol_broadcast_iface: "" # interface on the Joulenap host used to send the magic packet, e.g. eth0 (blank = auto-detect)
# wait_timeout: 180 # seconds to wait for PBS to become reachable (per wake attempt)
# wol_retries: 2 # EXTRA re-sends after the first: total attempts = wol_retries + 1,
# # each waiting up to wait_timeout
# # Power-off (no PBS API for this -> SSH)
# poweroff_task_wait: 600 # wait up to N s for a running PBS task to finish before power-off (0 = no wait)
# ssh_user: root
# ssh_key_path: /app/data/id_ed25519 # dedicated key; ideally restricted to a poweroff command on PBS
# # How long External routes onto this PBS wait — i.e. how slow this box is.
# # Both are worst-case timeouts, not fixed delays.
# external:
# first_task_wait: 900 # wait at most N s for the first task to appear after wake-up
# idle_wait: 300 # power off after N s of continuous task silence
# --- Routes ---
routes: []
# routes:
# - id: nightly
# name: Nightly backup
# color: "#f5a524" # the route's colour in the topology diagram
# enabled: true
# notify: true # send notifications for this route (channels are global, below)
# kind: backup # backup | sync | external | verify
# # Guests are per source: vmids collide between PVEs, so each source picks its own.
# # A newly created VM/CT is backed up automatically in "all" mode but NOT in
# # "include" mode — that list is explicit, so add new guests to it yourself.
# sources:
# - pve: pve-01
# guests:
# mode: all # all | include
# list: [] # used when mode = include, e.g. [104, 106, 202]
# target: pbs-01
# schedule:
# time: "04:00"
# days: [true, true, true, true, true, true, true] # Mon..Sun; at least one must be
# # true (pause with enabled: false)
# # Escape hatch for a schedule time+days cannot express (a day-of-month or month
# # pattern, a step value, a weekday range). A 5-field crontab string; when set it
# # wins over time/days, and the UI shows it read-only.
# # cron: "0 4 1 * *"
# retention: # applied on the target
# keep_last: 0
# keep_daily: 7
# keep_weekly: 4
# keep_monthly: 6
# keep_yearly: 0
# options:
# mode: snapshot # snapshot | suspend | stop
# bwlimit: 0 # KiB/s, 0 = unlimited
# min_free_percent: 0 # abort if the target datastore has less than this % free (0 = disabled)
# gc: true # garbage-collect the target once this route is done. Two routes
# # onto the same PBS with gc on both will each run one.
# verify_after: false # quick verify of this run's new snapshots, before power-off
# reverify_days: 30 # verify routes only: skip snapshots verified in the last N
# # days, so the route stays incremental (0 = verify everything)
#
# # A sync route copies snapshots from one PBS to another (pull or push).
# - id: offsite
# name: Offsite copy
# color: "#3b82f6"
# kind: sync
# source_pbs: pbs-01
# target: pbs-02
# sync_direction: pull # pull | push
# schedule:
# time: "05:00"
# days: [false, false, false, false, false, false, true] # Sundays only
# --- Maintenance ---
# GC and post-backup verify are per-route (routes[].options.gc / .verify_after), and a
# periodic full verification is a route of kind "verify". Only history retention is global.
maintenance:
history:
retention_days: 14 # auto-delete run history + activity-log entries older than N days (0 = keep forever)
# --- Notifications ---
# Delivered via Apprise (one engine, 100+ services). The friendly fields below
# are turned into Apprise URLs under the hood; use custom_urls for anything else.
notifications:
on_success: true
on_failure: true
telegram:
enabled: false
bot_token: ""
chat_id: ""
ntfy:
enabled: false
url: "" # self-hosted, e.g. http://192.168.1.30, or https://ntfy.sh
topic: "" # e.g. homelab
email:
enabled: false # SMTP client
smtp_host: ""
smtp_port: 587
smtp_user: ""
smtp_password: ""
from_addr: "joulenap@example.com"
to_addr: ""
discord:
enabled: false
webhook_url: ""
# Catch-all: any Apprise URL (https://github.com/caronc/apprise) or a plain webhook.
# e.g. tgram://token/chatid, ntfy://host/topic, gotify://host/token,
# pover://user@token, slack://..., matrixs://..., json://your-endpoint
custom_urls: []