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

205 lines
10 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
# # Joulenap reads the datastore's ROOT NAMESPACE. If the Proxmox storage entry writes
# # into a PBS namespace, backups, retention and GC all work normally, but "Last backup
# # per guest" reads "never" for those guests — Joulenap looks in the root and finds
# # nothing. One backup server can hold several datastores: add each as its own device.
# 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).
# # The wizard names a backup server's token after its datastore, so a second datastore on
# # the same machine gets its own instead of replacing this one.
# api_token_id: "root@pam!joulenap-backup"
# 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: []