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:
Wanjohi
2026-09-06 23:44:37 +03:00
parent 0e94620808
commit 6603383ad1
10 changed files with 3182 additions and 18 deletions

View File

@@ -98,7 +98,7 @@ export const auth: MiddlewareHandler = async (c, next) => {
if (machineId && machineSecret) {
const machine = await Machine.authenticate({ id: machineId, secret: machineSecret });
if (machine) {
await Machine.touchLastSeen(machine.id);
await Machine.touchLastSeen({ id: machine.id });
return Actor.with(
{
type: 'machine',

View File

@@ -216,7 +216,7 @@ export namespace MachineApi {
tags: ['Machine'],
summary: 'Say the host is alive',
description:
'Records liveness for the calling machine and returns how often it should call back. The interval comes from the server on purpose: a fleet whose cadence can only change by shipping a new agent is a fleet whose cadence never changes. Takes no body — what a host is *running* is reported separately, and reporting a shape we cannot yet act on would be worse than reporting nothing.',
'Records liveness for the calling machine and returns how often it should call back. The interval comes from the server on purpose: a fleet whose cadence can only change by shipping a new agent is a fleet whose cadence never changes. The body carries one optional fact — where this host can be reached — because only a host can say that about itself and this is the call it already makes as itself. What a host is *running* is reported separately.',
responses: {
200: {
content: {
@@ -237,12 +237,31 @@ export namespace MachineApi {
},
description: 'The beat was recorded'
},
400: ErrorResponses[400],
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
validator(
'json',
z
.object({
endpointId: Machine.EndpointId.optional().meta({
description:
'Where this host can be reached, as its own endpoint id. Omit it and the stored value is left alone — a host that does not mention where it is has not moved, and an absent field must never read as "nowhere"',
example: Examples.Machine.endpointId
})
})
// A host that has nothing to add sends no body at all, which
// is what every agent shipped before this field did.
.optional()
),
async (c) => {
const lastSeen = await Machine.touchLastSeen(Actor.machineID);
const body = c.req.valid('json');
const lastSeen = await Machine.touchLastSeen({
id: Actor.machineID,
endpointId: body?.endpointId
});
if (!lastSeen) {
// The credentials authenticated but the row is gone — a host
// deleted mid-beat. It must re-register rather than keep

View File

@@ -94,6 +94,69 @@ describe('POST /machine/heartbeat', () => {
expect((await Machine.fromID(host.id))?.lastSeen).toBeNull();
});
test('a host says where it is on the beat it already sends', async () => {
const host = await registeredHost('beat-endpoint');
const endpointId = 'd'.repeat(64);
// A beat carrying no body is what every agent shipped before this field
// sends, and it must still be a beat.
const bare = await app.request('/machine/heartbeat', {
method: 'POST',
headers: host.headers
});
expect(bare.status).toBe(200);
expect((await Machine.fromID(host.id))?.endpointId).toBeNull();
const res = await app.request('/machine/heartbeat', {
method: 'POST',
headers: { ...host.headers, 'content-type': 'application/json' },
body: JSON.stringify({ endpointId })
});
expect(res.status).toBe(200);
expect((await Machine.fromID(host.id))?.endpointId).toBe(endpointId);
// And a later beat that says nothing does not take the host off the map.
await app.request('/machine/heartbeat', { method: 'POST', headers: host.headers });
expect((await Machine.fromID(host.id))?.endpointId).toBe(endpointId);
});
test('a host cannot report where somebody else is', async () => {
// The report is authenticated as the machine it is about, and there is
// no field naming a different one. This is the assertion that keeps it
// that way: a body that tries anyway changes nothing.
const host = await registeredHost('beat-endpoint-other');
const victim = await registeredHost('beat-endpoint-victim');
const endpointId = 'e'.repeat(64);
const res = await app.request('/machine/heartbeat', {
method: 'POST',
headers: { ...host.headers, 'content-type': 'application/json' },
body: JSON.stringify({ endpointId, machineId: victim.id, id: victim.id })
});
expect(res.status).toBe(200);
expect((await Machine.fromID(host.id))?.endpointId).toBe(endpointId);
expect((await Machine.fromID(victim.id))?.endpointId).toBeNull();
});
test('an endpoint id that cannot be one is refused', async () => {
const host = await registeredHost('beat-endpoint-shape');
const res = await app.request('/machine/heartbeat', {
method: 'POST',
headers: { ...host.headers, 'content-type': 'application/json' },
body: JSON.stringify({ endpointId: 'not-an-endpoint-id' })
});
expect(res.status).toBe(400);
expect((await Machine.fromID(host.id))?.endpointId).toBeNull();
// Liveness is still recorded, and that is not a half-applied write:
// authenticating as this machine is itself proof it is alive, and the
// middleware records it before any route runs. What the refusal keeps
// out is the value that failed the check.
expect((await Machine.fromID(host.id))?.lastSeen).not.toBeNull();
});
test('a user session cannot beat on a hosts behalf', async () => {
// A box holds credentials but is not its owner, and the reverse holds
// too: `machineOnly` exists so a route written for a host cannot be

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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 = {

View File

@@ -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) => {
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()` })
.where(eq(MachineTable.id, id))
.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
};
}
}

View File

@@ -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)
]

View File

@@ -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 () => {