fix(wizard): recognise a backup server addressed by name and by IP

Discovery matched a Proxmox storage entry to a registered backup server by
comparing host strings, so the same machine written 192.168.1.50 in one place
and pbs.lan in the other looked like two. An already-registered server was
then offered as new, and configuring it provisioned a token over the one that
device was using -- with the datastore matching, the derived token name
matches too, so per-datastore naming does not save it.

The certificate fingerprint settles identity when the spellings differ: the
Proxmox storage config pins it and the device stores it, so both sides already
have it. An empty fingerprint never matches, including another empty one --
the field defaults to "", and equality alone would fold every unpinned server
into whichever device was registered first.

Resolving the hostnames would be the obvious fix and is the wrong one here:
the container's resolver cannot see internal-only hostnames, which is a known
open problem, so device identity would rest on a lookup that fails in exactly
this deployment.
This commit is contained in:
Catubba
2026-08-05 19:47:47 +02:00
parent 151b2a53f6
commit 6b5003d246
2 changed files with 31 additions and 5 deletions
+16
View File
@@ -154,6 +154,22 @@ test('host matching ignores case and surrounding whitespace', () => {
assert.equal(matchStorage(storage({ host: 'PBS.LAN' }), [pbs('x', { host: 'pbs.lan' })])?.id, 'x')
})
test('the same box spelled two ways is matched on its fingerprint', () => {
// The PVE storage entry says pbs.lan, the device was added as an IP. Missing this offers an
// already-registered server as "new", and provisioning it replaces the token in use.
const registered = [pbs('pbs-01', { host: '192.168.1.50', fingerprint: 'aa:bb' })]
assert.equal(matchStorage(storage({ host: 'pbs.lan' }), registered)?.id, 'pbs-01')
// Still the wrong datastore, so still a different device on the same machine.
assert.equal(matchStorage(storage({ host: 'pbs.lan', datastore: 'offsite' }), registered), null)
})
test('an empty fingerprint never matches, not even another empty one', () => {
// `fingerprint` defaults to "", so matching on equality alone would fold every unpinned
// server into whichever device happened to be registered first.
const registered = [pbs('pbs-01', { host: '192.168.1.50', fingerprint: '' })]
assert.equal(matchStorage(storage({ host: '10.0.0.9', fingerprint: '' }), registered), null)
})
test('already-registered storages become the PVE storages map, new ones do not', () => {
const storages = [storage(), storage({ storage: 'offsite', datastore: 'offsite' })]
const registered = [pbs('pbs-01')]
+15 -5
View File
@@ -113,14 +113,24 @@ export function validateDeviceId(id: string, existing: string[]): DeviceError |
* however it likes (which is exactly why `storages` lives on the PVE), so the id says nothing
* about identity. Host comparison is case-insensitive and trimmed — PVE stores whatever was
* typed into the storage form.
*
* The **fingerprint** is a second identity signal, because the host strings are two people's
* spelling of one machine: `192.168.1.10` here and `pbs.lan` there is the same box, and
* missing that match offers an already-registered server as "new" — which then provisions a
* token over the one the existing device holds. Resolving the names would be the obvious fix
* and is the wrong one here: the container's resolver cannot see internal-only hostnames, so
* identity would depend on a lookup that is known to fail in this deployment.
*
* An **empty fingerprint never matches**, including another empty one — the field defaults to
* `""`, so equality alone would fold every unpinned server into one device.
*/
export function matchStorage(storage: WizardStorage, pbss: PbsDevice[]): PbsDevice | null {
const host = storage.host.trim().toLowerCase()
return (
pbss.find(
(p) => p.host.trim().toLowerCase() === host && p.datastore === storage.datastore,
) ?? null
)
const print = storage.fingerprint.trim().toLowerCase()
const sameBox = (p: PbsDevice) =>
p.host.trim().toLowerCase() === host ||
(print !== '' && p.fingerprint.trim().toLowerCase() === print)
return pbss.find((p) => p.datastore === storage.datastore && sameBox(p)) ?? null
}
/**