Files
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

281 lines
9.6 KiB
Python

"""Setup-wizard endpoints: auth guard, request wiring, error mapping, real keygen."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.connectors.errors import ApiError, TokenExistsError
from app.connectors.provision import pbs_token_name
from app.core import wizard
from app.main import create_app
@pytest.fixture
def client(temp_config, temp_db):
app = create_app()
with TestClient(app) as c:
c.post("/api/auth/setup", json={"username": "admin", "password": "secret12"})
yield c
def test_wizard_requires_auth(temp_config, temp_db):
with TestClient(create_app()) as c:
r = c.post("/api/wizard/pve/connect", json={"host": "pve.local"})
assert r.status_code == 401
def test_pve_connect_passes_through(client, monkeypatch):
captured = {}
def fake_connect(**kwargs):
captured.update(kwargs)
return {"connected": True, "nodes": [], "storages": [], "token": None}
monkeypatch.setattr(wizard, "pve_connect", fake_connect)
r = client.post(
"/api/wizard/pve/connect",
json={"host": "pve.local", "mode": "root", "username": "root@pam", "password": "pw"},
)
assert r.status_code == 200
assert r.json()["connected"] is True
assert captured["host"] == "pve.local" and captured["mode"] == "root"
assert captured["username"] == "root@pam"
def test_connector_error_maps_to_502(client, monkeypatch):
def boom(**_kwargs):
raise ApiError("connection refused")
monkeypatch.setattr(wizard, "pve_connect", boom)
r = client.post("/api/wizard/pve/connect", json={"host": "pve.local"})
assert r.status_code == 502
def test_a_taken_token_name_is_409_not_502(client, monkeypatch):
"""Nothing failed upstream — we declined to replace a token the user has not agreed to
lose. The wizard keys on the status to offer "replace it" instead of a connection error."""
def taken(**_kwargs):
raise TokenExistsError("An API token named 'joulenap' already exists for root@pam.")
monkeypatch.setattr(wizard, "pbs_provision", taken)
r = client.post(
"/api/wizard/pbs/provision",
json={"host": "pbs.local", "password": "pw", "datastore": "backup"},
)
assert r.status_code == 409
assert "already exists" in r.json()["detail"]
def test_replace_token_is_forwarded(client, monkeypatch):
captured = {}
def fake_provision(**kwargs):
captured.update(kwargs)
return {"id": "root@pam!joulenap", "secret": "s"}
monkeypatch.setattr(wizard, "pbs_provision", fake_provision)
r = client.post(
"/api/wizard/pbs/provision",
json={
"host": "pbs.local",
"password": "pw",
"datastore": "backup",
"replace_token": True,
},
)
assert r.status_code == 200
assert captured["replace_token"] is True
def test_the_pbs_token_name_is_derived_from_the_datastore(client, monkeypatch):
"""Two devices on one backup server must not contend for a token name: naming both
``joulenap`` meant provisioning the second deleted the first one's token."""
captured = {}
def fake_provision(**kwargs):
captured.update(kwargs)
return {"id": "root@pam!joulenap-lab", "secret": "s"}
monkeypatch.setattr(wizard, "pbs_provision", fake_provision)
r = client.post(
"/api/wizard/pbs/provision",
json={"host": "pbs.local", "password": "pw", "datastore": "lab"},
)
assert r.status_code == 200
assert captured["token_name"] == "joulenap-lab"
def test_an_explicit_token_name_still_wins(client, monkeypatch):
"""The field stays honoured for anyone driving the API directly."""
captured = {}
def fake_provision(**kwargs):
captured.update(kwargs)
return {"id": "root@pam!mine", "secret": "s"}
monkeypatch.setattr(wizard, "pbs_provision", fake_provision)
r = client.post(
"/api/wizard/pbs/provision",
json={
"host": "pbs.local",
"password": "pw",
"datastore": "lab",
"token_name": "mine",
},
)
assert r.status_code == 200
assert captured["token_name"] == "mine"
@pytest.mark.parametrize(
("datastore", "expected"),
[
("lab", "joulenap-lab"),
("backup_2", "joulenap-backup_2"),
("dot.name", "joulenap-dot.name"),
(" spaced ", "joulenap-spaced"),
("a b/c", "joulenap-a-b-c"),
# Nothing survives sanitising, so fall back rather than send PBS a name it rejects.
("///", "joulenap"),
("", "joulenap"),
],
)
def test_token_name_sanitising(datastore, expected):
assert pbs_token_name(datastore) == expected
def test_pbs_check_passes_through(client, monkeypatch):
monkeypatch.setattr(
wizard, "pbs_check", lambda **_k: {"reachable": True, "fingerprint": "AA:BB"}
)
r = client.post("/api/wizard/pbs/check", json={"host": "pbs.local"})
assert r.json() == {"reachable": True, "fingerprint": "AA:BB"}
def test_pbs_grant_sync_passes_through(client, monkeypatch):
captured = {}
def fake_grant(**kwargs):
captured.update(kwargs)
return {"token_id": kwargs["token_id"], "roles": ["RemoteAdmin"]}
monkeypatch.setattr(wizard, "pbs_grant_sync", fake_grant)
r = client.post(
"/api/wizard/pbs/grant-sync",
json={
"host": "pbs.local",
"password": "pw",
"api_token_id": "root@pam!joulenap",
"fingerprint": "AA:BB",
},
)
assert r.status_code == 200
assert r.json()["roles"] == ["RemoteAdmin"]
# the endpoint renames api_token_id -> token_id and defaults the root realm
assert captured["token_id"] == "root@pam!joulenap"
assert captured["username"] == "root@pam" and captured["password"] == "pw"
assert "pw" not in r.text
def test_pbs_grant_sync_needs_a_password_and_a_token(client):
r = client.post("/api/wizard/pbs/grant-sync", json={"host": "pbs.local", "password": "pw"})
assert r.status_code == 422
def test_detect_mac_passes_through(client, monkeypatch):
monkeypatch.setattr(wizard, "wol_detect_mac", lambda **_k: {"mac": "00:11:22:33:44:55"})
r = client.post("/api/wizard/wol/detect-mac", json={"host": "pbs.local"})
assert r.json()["mac"] == "00:11:22:33:44:55"
def test_ssh_keygen_generates_real_key(client):
r = client.post("/api/wizard/ssh/keygen")
assert r.status_code == 200
body = r.json()
assert body["public_key"].startswith("ssh-ed25519 ")
assert body["key_path"].endswith("id_ed25519")
assert body["created"] is True
# The restricted line to paste/install locks the key to poweroff only.
assert body["authorized_keys_line"].startswith('command="systemctl poweroff",')
assert body["authorized_keys_line"].endswith(body["public_key"])
def test_ssh_keygen_reuses_an_existing_key(client):
"""Adding a second PBS must not invalidate the first one's power-off: every device's
ssh_key_path points at this same file, so a regenerated key would leave the first PBS
trusting a public half no private key matches any more."""
first = client.post("/api/wizard/ssh/keygen").json()
key_file = Path(first["key_path"])
written = key_file.read_bytes()
second = client.post("/api/wizard/ssh/keygen").json()
assert second["public_key"] == first["public_key"]
assert second["created"] is False
assert key_file.read_bytes() == written # not rewritten, byte for byte
def test_ssh_keygen_replaces_an_unreadable_key(client):
"""A file that isn't a usable private key can't be what any PBS trusts either, so there
is nothing to preserve — generate over it rather than failing the whole flow."""
key_file = Path(client.post("/api/wizard/ssh/keygen").json()["key_path"])
key_file.write_text("not a private key")
body = client.post("/api/wizard/ssh/keygen").json()
assert body["created"] is True
assert body["public_key"].startswith("ssh-ed25519 ")
def test_ssh_install_passes_through(client, monkeypatch):
captured = {}
def fake_install(**kwargs):
captured.update(kwargs)
return {"installed": True}
monkeypatch.setattr(wizard, "ssh_install", fake_install)
r = client.post(
"/api/wizard/ssh/install",
json={"host": "pbs.local", "password": "pw", "public_key": "ssh-ed25519 AAAA"},
)
assert r.json() == {"installed": True}
assert captured["host"] == "pbs.local" and captured["user"] == "root"
def test_wol_test_sends_a_packet_for_a_mac_not_yet_saved(client, monkeypatch):
# The wizard tests a MAC it has just detected, before there is a device to hang it on —
# so this takes the MAC in the body rather than reading one out of the config.
sent: list[tuple] = []
monkeypatch.setattr(
"app.core.wizard.send_magic_packet",
lambda mac, broadcast=None, source_ip=None: sent.append((mac, broadcast)),
)
monkeypatch.setattr(
"app.connectors.net.wol_target", lambda host, iface: ("192.0.2.255", "192.0.2.5")
)
resp = client.post(
"/api/wizard/wol/test", json={"mac": "00:11:22:33:44:55", "host": "192.0.2.20"}
)
assert resp.status_code == 200 and resp.json()["sent"] is True
assert sent == [("00:11:22:33:44:55", "192.0.2.255")]
def test_wol_test_falls_back_to_the_global_broadcast_without_a_host(client, monkeypatch):
sent: list[tuple] = []
monkeypatch.setattr(
"app.core.wizard.send_magic_packet",
lambda mac, broadcast=None, source_ip=None: sent.append((mac, broadcast)),
)
resp = client.post("/api/wizard/wol/test", json={"mac": "00:11:22:33:44:55"})
assert resp.status_code == 200
assert sent == [("00:11:22:33:44:55", "255.255.255.255")]