mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(machine): record where a host can be reached, as the host reports it
The machine table said who owns a host, which team it belongs to and when it was last seen, and nothing about how to reach it. Anything standing in front of a host and authenticating browsers on its behalf could therefore authorise a request perfectly and then have nowhere to send it. Reported, never assigned. A host holds the secret half of this identity and is the only thing that can know the public half first, so it rides on the beat it already sends as itself. Omitting the field leaves the stored value alone -- an agent that does not mention where it is has not moved, and an absent field must never read as "nowhere", which would take every host shipped before this field off the map on its next beat. Nullable, because "has never reported one" is a real state that every host registered before today is in. Unique, because an endpoint id belongs to one host: two rows claiming the same one would send a request addressed to one machine to another machine's agent, which is the one mistake here that the authorisation in front of it cannot catch.
This commit is contained in:
26
packages/core/migrations/0013_machine_endpoint_id.sql
Normal file
26
packages/core/migrations/0013_machine_endpoint_id.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Where a host actually is, so that a request authorised for it has somewhere
|
||||
-- to go.
|
||||
--
|
||||
-- Until now this table said who owns a host, which team it belongs to and when
|
||||
-- it was last seen, and nothing at all about how to reach it. A proxy that
|
||||
-- authenticates a browser on a host's behalf can therefore authorise a request
|
||||
-- perfectly and then have nowhere to send it.
|
||||
--
|
||||
-- **Reported, never assigned.** A host holds the secret half of this identity
|
||||
-- and is the only thing that can know the public half first, so this column
|
||||
-- records what a host says about itself on a call it already makes as itself.
|
||||
-- Nothing here mints one. ref(d-0010)
|
||||
--
|
||||
-- Nullable, because "has never reported one" is a real state rather than an
|
||||
-- error: every host registered before this column existed is in it, and null
|
||||
-- reads as "not reachable yet". A default would read as an address and route
|
||||
-- somewhere wrong.
|
||||
--
|
||||
-- Unique, because an endpoint id belongs to exactly one host. Two rows claiming
|
||||
-- the same one would send a request addressed to one machine to another
|
||||
-- machine's agent, which is the one mistake this column can make that the
|
||||
-- authorisation in front of it cannot catch. Postgres allows many nulls under a
|
||||
-- unique index, so the hosts that have never reported are unaffected.
|
||||
|
||||
ALTER TABLE "machine" ADD COLUMN "endpoint_id" text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "machine_endpoint_id_unique" ON "machine" USING btree ("endpoint_id");
|
||||
2951
packages/core/migrations/meta/0013_snapshot.json
Normal file
2951
packages/core/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,13 @@
|
||||
"when": 1788691753961,
|
||||
"tag": "0012_steam_enrolment_without_a_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1788725541386,
|
||||
"tag": "0013_machine_endpoint_id",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -129,7 +129,8 @@ export namespace Examples {
|
||||
ownerUserId: Id('user'),
|
||||
teamId: Id('team'),
|
||||
label: 'living-room-box',
|
||||
lastSeen: '2026-07-28T12:00:00.000Z'
|
||||
lastSeen: '2026-07-28T12:00:00.000Z',
|
||||
endpointId: 'a'.repeat(64)
|
||||
};
|
||||
|
||||
export const SteamEnrolment = {
|
||||
|
||||
@@ -22,6 +22,19 @@ export namespace Machine {
|
||||
/** Length in bytes before base64url encoding. */
|
||||
const SECRET_BYTES = 32;
|
||||
|
||||
/**
|
||||
* A host's endpoint id: 32 bytes of public key, lowercase hex.
|
||||
*
|
||||
* Checked for shape and nothing else. What it addresses is meaningless
|
||||
* here — this is a string the control plane stores and hands back — so the
|
||||
* only thing worth refusing is a value that cannot possibly be one, which
|
||||
* is what keeps a truncated or double-encoded id from being written and
|
||||
* then failing far away, at whoever tries to dial it.
|
||||
*/
|
||||
export const EndpointId = z.string().regex(/^[0-9a-f]{64}$/, {
|
||||
message: 'An endpoint id is 64 lowercase hex characters'
|
||||
});
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
@@ -44,6 +57,11 @@ export namespace Machine {
|
||||
lastSeen: z.iso.datetime().optional().nullable().meta({
|
||||
description: 'When this machine last authenticated',
|
||||
example: Examples.Machine.lastSeen
|
||||
}),
|
||||
endpointId: EndpointId.optional().nullable().meta({
|
||||
description:
|
||||
'Where this host can be reached, as its own endpoint id. Null until the host has reported one — it holds the secret half of this identity, so it is the only thing that can say what the public half is',
|
||||
example: Examples.Machine.endpointId
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
@@ -195,17 +213,34 @@ export namespace Machine {
|
||||
* Returns the stored timestamp rather than void so a caller can hand it
|
||||
* straight back to the host — which is what lets a heartbeat be one round
|
||||
* trip instead of a write followed by a read.
|
||||
*
|
||||
* `endpointId` rides along for the same reason. Where a host is reachable
|
||||
* is a fact about the host, it changes when the agent's identity does, and
|
||||
* a caller that has just proved it is that host is the only one who can
|
||||
* report it — so it is written by the call that already says "still here",
|
||||
* in the same statement, rather than by a second one that could succeed
|
||||
* alone and leave the two facts disagreeing.
|
||||
*/
|
||||
export const touchLastSeen = fn(Info.shape.id, async (id) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.update(MachineTable)
|
||||
.set({ lastSeen: sql`now()` })
|
||||
.where(eq(MachineTable.id, id))
|
||||
.returning({ lastSeen: MachineTable.lastSeen })
|
||||
.then((rows) => rows.at(0)?.lastSeen ?? null);
|
||||
});
|
||||
});
|
||||
export const touchLastSeen = fn(
|
||||
Info.pick({ id: true }).extend({ endpointId: EndpointId.optional() }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.update(MachineTable)
|
||||
.set({
|
||||
lastSeen: sql`now()`,
|
||||
// Omitted rather than nulled when it is absent: a caller
|
||||
// that does not mention where it is has not moved, and
|
||||
// clearing the column would deregister a working host
|
||||
// from every route that reads it.
|
||||
...(input.endpointId ? { endpointId: input.endpointId } : {})
|
||||
})
|
||||
.where(eq(MachineTable.id, input.id))
|
||||
.returning({ lastSeen: MachineTable.lastSeen })
|
||||
.then((rows) => rows.at(0)?.lastSeen ?? null);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether a host has beaten recently enough to place work on.
|
||||
@@ -306,7 +341,8 @@ export namespace Machine {
|
||||
ownerUserId: input.ownerUserId,
|
||||
teamId: input.teamId,
|
||||
label: input.label,
|
||||
lastSeen: input.lastSeen?.toISOString() ?? null
|
||||
lastSeen: input.lastSeen?.toISOString() ?? null,
|
||||
endpointId: input.endpointId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,21 @@ export const MachineTable = pgTable(
|
||||
.notNull()
|
||||
.references(() => TeamTable.id, { onDelete: 'restrict' }),
|
||||
label: text('label').notNull(),
|
||||
// Where this host can actually be reached: its own endpoint id, as
|
||||
// hex. A row can be authorised perfectly and still have nowhere to
|
||||
// send the request without it, which is what this column fixes.
|
||||
//
|
||||
// **Reported, never assigned.** A host holds the secret half and is
|
||||
// the only thing that can know the public one first, so the control
|
||||
// plane records what it is told rather than handing one out. ref(d-0010)
|
||||
//
|
||||
// Nullable because a host that has never reported one is a real state
|
||||
// — every host registered before this column existed is in it — and
|
||||
// the honest reading of null is "not reachable yet" rather than a
|
||||
// default that would route somewhere wrong. Unique because an endpoint
|
||||
// id belongs to one host: two rows claiming the same one would send a
|
||||
// request addressed to one machine to a different machine's agent.
|
||||
endpointId: text('endpoint_id'),
|
||||
// The secret itself is returned exactly once, at registration, and never
|
||||
// stored: a leaked database must not yield working box credentials.
|
||||
secretHash: text('secret_hash').notNull(),
|
||||
@@ -36,6 +51,7 @@ export const MachineTable = pgTable(
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('machine_secret_hash_unique').on(t.secretHash),
|
||||
uniqueIndex('machine_endpoint_id_unique').on(t.endpointId),
|
||||
index('machine_owner_idx').on(t.ownerUserId),
|
||||
index('machine_team_idx').on(t.teamId)
|
||||
]
|
||||
|
||||
@@ -87,17 +87,62 @@ describe('Machine heartbeat', () => {
|
||||
|
||||
expect((await Machine.fromID(machineId))?.lastSeen).toBeNull();
|
||||
|
||||
const first = await Machine.touchLastSeen(machineId);
|
||||
const first = await Machine.touchLastSeen({ id: machineId });
|
||||
expect(first).not.toBeNull();
|
||||
|
||||
const second = await Machine.touchLastSeen(machineId);
|
||||
const second = await Machine.touchLastSeen({ id: machineId });
|
||||
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime());
|
||||
});
|
||||
|
||||
test('beating for a machine that is gone reports nothing rather than pretending', async () => {
|
||||
// A host deleted mid-beat must be told to re-register, so this returns
|
||||
// null and the route turns that into a 404.
|
||||
expect(await Machine.touchLastSeen('mch_deletedmiddeletedmid___')).toBeNull();
|
||||
expect(await Machine.touchLastSeen({ id: 'mch_deletedmiddeletedmid___' })).toBeNull();
|
||||
});
|
||||
|
||||
test('a host reports where it is, and a beat that says nothing leaves it alone', async () => {
|
||||
const owner = await newOwner('mch-endpoint');
|
||||
const machineId = await Fixtures.machine(owner);
|
||||
const endpointId = 'b'.repeat(64);
|
||||
|
||||
// Nothing assigns this. Until the host says so, there is nowhere to
|
||||
// send a request that was authorised for it.
|
||||
expect((await Machine.fromID(machineId))?.endpointId).toBeNull();
|
||||
|
||||
await Machine.touchLastSeen({ id: machineId, endpointId });
|
||||
expect((await Machine.fromID(machineId))?.endpointId).toBe(endpointId);
|
||||
|
||||
// The regression this guards: an agent that beats without the field —
|
||||
// every agent shipped before it existed — must not clear the column and
|
||||
// take a working host off every route that reads it.
|
||||
await Machine.touchLastSeen({ id: machineId });
|
||||
expect((await Machine.fromID(machineId))?.endpointId).toBe(endpointId);
|
||||
});
|
||||
|
||||
test('an endpoint id that cannot be one is refused before it is stored', async () => {
|
||||
const owner = await newOwner('mch-endpoint-shape');
|
||||
const machineId = await Fixtures.machine(owner);
|
||||
|
||||
// Truncated, upper-cased, and carrying an encoding that is not this
|
||||
// one. Each would be written happily by a text column and would fail
|
||||
// far away, at whoever tried to dial it.
|
||||
for (const bad of ['abc', 'A'.repeat(64), `${'a'.repeat(63)}z`, `0x${'a'.repeat(64)}`]) {
|
||||
expect(() => Machine.touchLastSeen({ id: machineId, endpointId: bad })).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('two machines cannot claim the same endpoint id', async () => {
|
||||
const owner = await newOwner('mch-endpoint-unique');
|
||||
const first = await Fixtures.machine(owner);
|
||||
const second = await Fixtures.machine(owner);
|
||||
const endpointId = 'c'.repeat(64);
|
||||
|
||||
await Machine.touchLastSeen({ id: first, endpointId });
|
||||
|
||||
// Two rows claiming one endpoint id would send a request addressed to
|
||||
// one machine to another machine's agent, and the authorisation in
|
||||
// front of it cannot catch that.
|
||||
expect(Machine.touchLastSeen({ id: second, endpointId })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('online is derived from the last beat, not stored', async () => {
|
||||
|
||||
Reference in New Issue
Block a user