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

286 lines
9.1 KiB
Python

"""Setup wizard endpoints (docs/CONFIG-WIZARD.md).
Discovery + provisioning actions the wizard cards call. All are stateless: they return
discovered values for the frontend to assemble and save via PUT /api/config; only
ssh/keygen writes to disk (the private key). Auth-guarded like the rest of /api.
"""
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from .. import paths
from ..connectors import net
from ..connectors.errors import ConnectorError, TokenExistsError
from ..connectors.provision import pbs_token_name
from ..core import wizard
from .deps import require_auth
router = APIRouter(prefix="/wizard", dependencies=[Depends(require_auth)], tags=["wizard"])
_KEY_FILENAME = "id_ed25519"
def _connector_call(func, **kwargs) -> Any:
"""Run a wizard helper, mapping connector failures to 502 Bad Gateway.
``TokenExistsError`` is the exception: nothing failed upstream, we declined to replace a
token the user has not agreed to lose. 409 so the wizard can tell it apart from a real
connection problem and offer to go ahead.
"""
try:
return func(**kwargs)
except TokenExistsError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except ConnectorError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
# --- PVE connect -------------------------------------------------------------
class PveConnectRequest(BaseModel):
host: str = Field(min_length=1)
port: int = Field(default=8006, ge=1, le=65535)
verify_tls: bool = False
mode: Literal["token", "root"] = "token"
# token mode
api_token_id: str | None = None
api_token_secret: str | None = None
# root (quick setup) mode
username: str | None = None
password: str | None = None
token_name: str = "joulenap"
#: The user's answer to the 409 this endpoint raises when ``token_name`` is taken.
replace_token: bool = False
@router.post("/pve/connect")
def pve_connect(body: PveConnectRequest) -> dict[str, Any]:
return _connector_call(
wizard.pve_connect,
host=body.host,
port=body.port,
verify_tls=body.verify_tls,
mode=body.mode,
token_id=body.api_token_id,
token_secret=body.api_token_secret,
username=body.username,
password=body.password,
token_name=body.token_name,
replace_token=body.replace_token,
)
# --- derive PBS from storage -------------------------------------------------
class StorageDeriveRequest(BaseModel):
host: str = Field(min_length=1)
port: int = Field(default=8006, ge=1, le=65535)
verify_tls: bool = False
api_token_id: str = Field(min_length=1)
api_token_secret: str = Field(min_length=1)
storage_id: str = Field(min_length=1)
@router.post("/storage/derive")
def storage_derive(body: StorageDeriveRequest) -> dict[str, Any]:
return _connector_call(
wizard.storage_derive,
host=body.host,
port=body.port,
verify_tls=body.verify_tls,
token_id=body.api_token_id,
token_secret=body.api_token_secret,
storage_id=body.storage_id,
)
# --- PBS reachability + fingerprint ------------------------------------------
class PbsCheckRequest(BaseModel):
host: str = Field(min_length=1)
port: int = Field(default=8007, ge=1, le=65535)
@router.post("/pbs/check")
def pbs_check(body: PbsCheckRequest) -> dict[str, Any]:
return wizard.pbs_check(host=body.host, port=body.port)
# --- PBS token auto-provision (quick setup) ----------------------------------
class PbsProvisionRequest(BaseModel):
host: str = Field(min_length=1)
port: int = Field(default=8007, ge=1, le=65535)
verify_tls: bool = False
username: str = "root@pam"
password: str = Field(min_length=1)
datastore: str = Field(min_length=1)
#: Left unset by the UI: the name is derived from the datastore, so two devices on one
#: backup server never contend for it. Still overridable for anyone driving the API.
token_name: str | None = None
fingerprint: str = ""
#: The user's answer to the 409 this endpoint raises when ``token_name`` is taken.
replace_token: bool = False
@router.post("/pbs/provision")
def pbs_provision(body: PbsProvisionRequest) -> dict[str, Any]:
return _connector_call(
wizard.pbs_provision,
host=body.host,
port=body.port,
verify_tls=body.verify_tls,
username=body.username,
password=body.password,
datastore=body.datastore,
token_name=body.token_name or pbs_token_name(body.datastore),
fingerprint=body.fingerprint,
replace_token=body.replace_token,
)
# --- PBS sync grants on an existing token ------------------------------------
class PbsGrantSyncRequest(BaseModel):
host: str = Field(min_length=1)
port: int = Field(default=8007, ge=1, le=65535)
verify_tls: bool = False
username: str = "root@pam"
password: str = Field(min_length=1)
api_token_id: str = Field(min_length=1)
fingerprint: str = ""
@router.post("/pbs/grant-sync")
def pbs_grant_sync(body: PbsGrantSyncRequest) -> dict[str, Any]:
"""Grant the /remote roles a Sync route needs to a PBS token that already exists."""
return _connector_call(
wizard.pbs_grant_sync,
host=body.host,
port=body.port,
verify_tls=body.verify_tls,
username=body.username,
password=body.password,
token_id=body.api_token_id,
fingerprint=body.fingerprint,
)
# --- local network interfaces (for the WoL interface picker) -----------------
@router.get("/interfaces")
def interfaces() -> list[dict[str, str]]:
"""List the host's IPv4 interfaces so the wizard can offer a WoL interface dropdown."""
return [
{"name": i.name, "address": i.address, "netmask": i.netmask, "broadcast": i.broadcast}
for i in net.list_interfaces()
]
# --- Wake-on-LAN MAC detection -----------------------------------------------
class DetectMacRequest(BaseModel):
host: str = Field(min_length=1)
@router.post("/wol/detect-mac")
def detect_mac(body: DetectMacRequest) -> dict[str, Any]:
return wizard.wol_detect_mac(host=body.host)
# --- SSH key generation + install --------------------------------------------
@router.post("/ssh/keygen")
def ssh_keygen() -> dict[str, Any]:
# Always write into the (writable, auto-created) data dir; the frontend points the
# device's ssh_key_path at the returned path.
key_path = paths.data_dir() / _KEY_FILENAME
return _connector_call(wizard.ssh_keygen, key_path=key_path)
class SshInstallRequest(BaseModel):
host: str = Field(min_length=1)
user: str = "root"
password: str = Field(min_length=1)
public_key: str = Field(min_length=1)
port: int = Field(default=22, ge=1, le=65535)
@router.post("/ssh/install")
def ssh_install(body: SshInstallRequest) -> dict[str, Any]:
return _connector_call(
wizard.ssh_install,
host=body.host,
user=body.user,
password=body.password,
public_key=body.public_key,
port=body.port,
)
class SshHostkeyRequest(BaseModel):
host: str = Field(min_length=1)
port: int = Field(default=22, ge=1, le=65535)
@router.post("/ssh/hostkey")
def ssh_hostkey(body: SshHostkeyRequest) -> dict[str, Any]:
return _connector_call(wizard.ssh_hostkey, host=body.host, port=body.port)
class SshTrustRequest(BaseModel):
host: str = Field(min_length=1)
key_type: str = Field(min_length=1)
key_base64: str = Field(min_length=1)
port: int = Field(default=22, ge=1, le=65535)
@router.post("/ssh/trust")
def ssh_trust(body: SshTrustRequest) -> dict[str, Any]:
return _connector_call(
wizard.ssh_trust, host=body.host, key_type=body.key_type,
key_base64=body.key_base64, port=body.port,
)
# --- Wake-on-LAN smoke test --------------------------------------------------
#
# Stateless like the rest of this router: the wizard tests a MAC it has just detected,
# before there is a device to save it on. Waking a *configured* PBS is
# POST /api/devices/pbss/{id}/power instead.
class WolTestRequest(BaseModel):
mac: str = Field(min_length=1)
# The PBS's address, so the packet goes to that subnet's directed broadcast rather than
# the whole network. Optional: pre-setup we may not know it yet.
host: str = ""
iface: str = ""
@router.post("/wol/test")
def wol_test(body: WolTestRequest) -> dict[str, Any]:
return _connector_call(
wizard.wol_test, mac=body.mac, host=body.host, iface=body.iface
)
# The per-device flows (M12) are orchestrated by the frontend wizard out of these same
# stateless calls and finalised with POST /api/devices/{kind} — there is no wizard-owned
# device state to keep on this side. The one thing that had to move into the wizard's
# transient-root step is the /remote ACL grant a Sync route needs on the peer: PBS refuses
# ACL writes from a token, so `PbsProvisioner.provision_token` now makes it while it still
# holds the root ticket.