mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(machine): mint a public name, and stop routing on the id
A host's hostname was its primary key. That worked and disclosed three things it should not have: ids here are monotonic, so an id in a hostname tells anyone who reads a URL roughly when that machine was registered and where it falls among its owner's others; an id is the primary key, so a name that had to change could only change by re-registering the machine, which is changing its identity to fix its name; and the hostname is also the OAuth audience and the cookie scope, so the id travelled into redirect URLs and browser history. Machines now carry a minted name -- two words and four digits, unique across the fleet, DNS-safe by construction, which an id was not. The words are a curated list rather than a dictionary, because every pair is shown to strangers. Names the fleet's own infrastructure answers on are refused at mint time: one minted onto the edge's own label would take the published key path away from every host at once. Minting retries on the unique index rather than checking first, because two registrations in the same instant both read "free" and both write. Only a name collision retries; a duplicate id or secret means something a new name cannot fix. The migration adds the column in three steps. Generated as a NOT NULL column it fails outright against a populated table, and a default would be worse: every row would share one value on a routing key.
This commit is contained in:
@@ -129,6 +129,7 @@ export namespace Examples {
|
||||
ownerUserId: Id('user'),
|
||||
teamId: Id('team'),
|
||||
label: 'living-room-box',
|
||||
slug: 'amber-otter-4821',
|
||||
lastSeen: '2026-07-28T12:00:00.000Z',
|
||||
endpointId: 'a'.repeat(64)
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Member } from '../team/member.js';
|
||||
import { MachineTable } from './machine.sql.js';
|
||||
import { Slug } from './slug.js';
|
||||
|
||||
/**
|
||||
* Registered host identity.
|
||||
@@ -55,6 +56,11 @@ export namespace Machine {
|
||||
description: 'Human-readable name for the box',
|
||||
example: Examples.Machine.label
|
||||
}),
|
||||
slug: z.string().meta({
|
||||
description:
|
||||
'The name this host is reached at, as the first label of its hostname. Minted here, unique across the fleet, and deliberately not the id \u2014 an id is monotonic, cannot be rotated, and would travel into every redirect URL the sign-in produces',
|
||||
example: Examples.Machine.slug
|
||||
}),
|
||||
lastSeen: z.iso.datetime().optional().nullable().meta({
|
||||
description: 'When this machine last authenticated',
|
||||
example: Examples.Machine.lastSeen
|
||||
@@ -111,20 +117,77 @@ export namespace Machine {
|
||||
Info.pick({ id: true, ownerUserId: true, teamId: true, label: true }),
|
||||
async (input) => {
|
||||
const secret = generateSecret();
|
||||
await Database.use(async (tx) => {
|
||||
await tx.insert(MachineTable).values({
|
||||
id: input.id,
|
||||
ownerUserId: input.ownerUserId,
|
||||
teamId: input.teamId,
|
||||
label: input.label,
|
||||
secretHash: await hashSecret(secret),
|
||||
lastSeen: null
|
||||
});
|
||||
});
|
||||
return { id: input.id, secret };
|
||||
const secretHash = await hashSecret(secret);
|
||||
|
||||
// The name space holds tens of millions, so a collision is rare
|
||||
// enough that retrying is cheaper than checking first -- and a
|
||||
// check-then-insert would be wrong as well as slower, because two
|
||||
// registrations in the same instant both read "free" and both write.
|
||||
// The unique index is the thing that actually decides.
|
||||
for (let attempt = 0; attempt < SLUG_ATTEMPTS; attempt++) {
|
||||
const slug = Slug.generate();
|
||||
try {
|
||||
await Database.use(async (tx) => {
|
||||
await tx.insert(MachineTable).values({
|
||||
id: input.id,
|
||||
ownerUserId: input.ownerUserId,
|
||||
teamId: input.teamId,
|
||||
label: input.label,
|
||||
slug,
|
||||
secretHash,
|
||||
lastSeen: null
|
||||
});
|
||||
});
|
||||
return { id: input.id, secret, slug };
|
||||
} catch (err) {
|
||||
if (!isUniqueViolation(err)) {
|
||||
throw err;
|
||||
}
|
||||
// Only a name collision is worth another go. A duplicate id
|
||||
// or secret means something is wrong that a new name cannot
|
||||
// fix, and retrying would bury it.
|
||||
if (!isSlugViolation(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new VisibleError(
|
||||
'internal',
|
||||
ErrorCodes.Server.INTERNAL_ERROR,
|
||||
'Could not mint an unused hostname for this machine'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
/** How many names to try before giving up. See `register`. */
|
||||
const SLUG_ATTEMPTS = 5;
|
||||
|
||||
/** Whether the constraint Postgres refused on was the hostname. */
|
||||
function isSlugViolation(err: unknown): boolean {
|
||||
const text = String(
|
||||
(err as { constraint?: string })?.constraint ??
|
||||
(err as { cause?: { constraint?: string } })?.cause?.constraint ??
|
||||
(err as Error)?.message ??
|
||||
''
|
||||
);
|
||||
return text.includes('machine_slug_unique');
|
||||
}
|
||||
|
||||
/** Resolve a hostname label to the machine it names. */
|
||||
export const fromSlug = fn(Info.shape.slug, async (slug) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(MachineTable)
|
||||
.where(and(eq(MachineTable.slug, slug), isNull(MachineTable.timeDeleted)))
|
||||
.then((rows) => {
|
||||
const row = rows.at(0);
|
||||
return row ? serialize(row) : null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve credentials to a machine, or `null`. Looks the row up by id and
|
||||
* then compares digests, so a wrong id and a wrong secret are refused the
|
||||
@@ -370,6 +433,7 @@ export namespace Machine {
|
||||
ownerUserId: input.ownerUserId,
|
||||
teamId: input.teamId,
|
||||
label: input.label,
|
||||
slug: input.slug,
|
||||
lastSeen: input.lastSeen?.toISOString() ?? null,
|
||||
endpointId: input.endpointId
|
||||
};
|
||||
|
||||
@@ -29,6 +29,20 @@ export const MachineTable = pgTable(
|
||||
.notNull()
|
||||
.references(() => TeamTable.id, { onDelete: 'restrict' }),
|
||||
label: text('label').notNull(),
|
||||
// The name this host is reached at: `amber-otter-4821.nestri.link`.
|
||||
//
|
||||
// Separate from `label`, which is what its owner calls it in a list and
|
||||
// is theirs to duplicate or leave blank-ish. This one is a routing key,
|
||||
// unique across the fleet, and the edge matches a Host header against
|
||||
// it.
|
||||
//
|
||||
// **Not the id, on purpose.** Ids here are monotonic, so an id in a
|
||||
// hostname discloses when a machine was registered and its order among
|
||||
// its owner's others; the id is the primary key, so a name that has to
|
||||
// change could only change by re-registering the machine; and the
|
||||
// hostname is also the OAuth audience, which puts whatever is in it
|
||||
// into redirect URLs and browser history. ref(d-0019)
|
||||
slug: text('slug').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.
|
||||
@@ -51,6 +65,11 @@ export const MachineTable = pgTable(
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('machine_secret_hash_unique').on(t.secretHash),
|
||||
// One name, one machine. This is also the whole of "the two key spaces
|
||||
// must not collide" while machines are the only things with names --
|
||||
// when boxes get theirs, the two have to share one index rather than
|
||||
// hold one each.
|
||||
uniqueIndex('machine_slug_unique').on(t.slug),
|
||||
uniqueIndex('machine_endpoint_id_unique').on(t.endpointId),
|
||||
index('machine_owner_idx').on(t.ownerUserId),
|
||||
index('machine_team_idx').on(t.teamId)
|
||||
|
||||
244
packages/core/src/machine/slug.ts
Normal file
244
packages/core/src/machine/slug.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* The public name a host is reached at: `amber-otter-4821.nestri.link`.
|
||||
*
|
||||
* Deliberately not the machine's id. Ids here are monotonic, so an id in a
|
||||
* hostname discloses roughly when a machine was registered and its order
|
||||
* relative to every other machine its owner holds. An id is also the primary
|
||||
* key, so a name that has to change — because it was scraped, shared with the
|
||||
* wrong person, or simply disliked — could only be changed by re-registering
|
||||
* the machine. And because the hostname is also the OAuth audience and the
|
||||
* cookie's scope, an id in it travels into redirect URLs, browser history and
|
||||
* every access log on the way to the issuer. ref(d-0019)
|
||||
*
|
||||
* Words rather than random characters because the people who read one of these
|
||||
* aloud are operators on a call, and a ten-character string is not something
|
||||
* anybody says twice. The digits carry the entropy; the words carry the
|
||||
* memorability.
|
||||
*/
|
||||
export namespace Slug {
|
||||
/**
|
||||
* Concrete, neutral, and short enough to say.
|
||||
*
|
||||
* Curated rather than taken from a dictionary: a generated name is shown to
|
||||
* strangers, so the list is chosen to have no combination that is obscene,
|
||||
* insulting or trademark-adjacent. **Adding a word means re-reading the
|
||||
* pairs it creates**, which is the whole reason this is a list in source
|
||||
* rather than a word file somebody drops in.
|
||||
*
|
||||
* No colour-plus-animal pairing here can read as a person or a slur, which
|
||||
* is the property that matters and the one an alphabetical scan does not
|
||||
* give you.
|
||||
*/
|
||||
const ADJECTIVES = [
|
||||
'amber',
|
||||
'ash',
|
||||
'autumn',
|
||||
'azure',
|
||||
'bright',
|
||||
'bronze',
|
||||
'calm',
|
||||
'cedar',
|
||||
'clay',
|
||||
'copper',
|
||||
'coral',
|
||||
'crisp',
|
||||
'dawn',
|
||||
'deep',
|
||||
'dusk',
|
||||
'ember',
|
||||
'fern',
|
||||
'flint',
|
||||
'frost',
|
||||
'gentle',
|
||||
'glass',
|
||||
'golden',
|
||||
'granite',
|
||||
'green',
|
||||
'harbor',
|
||||
'hazel',
|
||||
'indigo',
|
||||
'iron',
|
||||
'ivory',
|
||||
'jade',
|
||||
'lake',
|
||||
'linen',
|
||||
'maple',
|
||||
'marble',
|
||||
'meadow',
|
||||
'mellow',
|
||||
'mint',
|
||||
'misty',
|
||||
'north',
|
||||
'ochre',
|
||||
'olive',
|
||||
'onyx',
|
||||
'opal',
|
||||
'pearl',
|
||||
'pine',
|
||||
'quartz',
|
||||
'quiet',
|
||||
'rapid',
|
||||
'river',
|
||||
'rowan',
|
||||
'sable',
|
||||
'sage',
|
||||
'sandy',
|
||||
'scarlet',
|
||||
'silver',
|
||||
'slate',
|
||||
'smooth',
|
||||
'snowy',
|
||||
'solar',
|
||||
'spruce',
|
||||
'steady',
|
||||
'stone',
|
||||
'summer',
|
||||
'sunny',
|
||||
'teal',
|
||||
'tidal',
|
||||
'umber',
|
||||
'velvet',
|
||||
'violet',
|
||||
'willow',
|
||||
'winter',
|
||||
'zinc'
|
||||
] as const;
|
||||
|
||||
/** Animals and landscape features. Nothing that names a person or a brand. */
|
||||
const NOUNS = [
|
||||
'alcove',
|
||||
'anchor',
|
||||
'aspen',
|
||||
'badger',
|
||||
'basin',
|
||||
'beacon',
|
||||
'bison',
|
||||
'bluff',
|
||||
'brook',
|
||||
'canyon',
|
||||
'cavern',
|
||||
'cedar',
|
||||
'cliff',
|
||||
'comet',
|
||||
'cove',
|
||||
'crane',
|
||||
'crater',
|
||||
'creek',
|
||||
'delta',
|
||||
'dune',
|
||||
'eagle',
|
||||
'falcon',
|
||||
'fjord',
|
||||
'forest',
|
||||
'fossil',
|
||||
'garden',
|
||||
'geyser',
|
||||
'glacier',
|
||||
'glade',
|
||||
'gorge',
|
||||
'grotto',
|
||||
'harbor',
|
||||
'heron',
|
||||
'hollow',
|
||||
'island',
|
||||
'jetty',
|
||||
'lagoon',
|
||||
'lantern',
|
||||
'ledge',
|
||||
'lichen',
|
||||
'marsh',
|
||||
'meadow',
|
||||
'mesa',
|
||||
'moraine',
|
||||
'orchard',
|
||||
'otter',
|
||||
'pebble',
|
||||
'pelican',
|
||||
'plateau',
|
||||
'prairie',
|
||||
'puffin',
|
||||
'quarry',
|
||||
'rapids',
|
||||
'raven',
|
||||
'reef',
|
||||
'ridge',
|
||||
'sparrow',
|
||||
'spring',
|
||||
'summit',
|
||||
'thicket',
|
||||
'thistle',
|
||||
'tundra',
|
||||
'valley',
|
||||
'willow'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Labels the fleet's own infrastructure answers on, which nothing may mint.
|
||||
*
|
||||
* The edge publishes its own endpoint id at `edge.<zone>`, and every host in
|
||||
* the fleet reads that name to learn which peer it will accept. A machine
|
||||
* minted onto it would take that path away from every host at once — so
|
||||
* these are refused at mint time rather than filtered per request, because
|
||||
* a request-time filter leaves the bad row in the database.
|
||||
*/
|
||||
const RESERVED = new Set([
|
||||
'edge',
|
||||
'api',
|
||||
'auth',
|
||||
'www',
|
||||
'admin',
|
||||
'internal',
|
||||
'status',
|
||||
'assets',
|
||||
'static',
|
||||
'cdn',
|
||||
'mail',
|
||||
'ns1',
|
||||
'ns2'
|
||||
]);
|
||||
|
||||
/** `amber-otter-4821` — two words and four digits, lowercase. */
|
||||
export const PATTERN = /^[a-z]+-[a-z]+-\d{4}$/;
|
||||
|
||||
/**
|
||||
* How many distinct names exist: roughly 46 million.
|
||||
*
|
||||
* Worth stating because it is what makes minting a retry rather than a
|
||||
* search. At a thousand machines the chance any one attempt collides is
|
||||
* under one in forty thousand, so the loop below effectively never turns.
|
||||
*/
|
||||
export const SPACE = ADJECTIVES.length * NOUNS.length * 10_000;
|
||||
|
||||
/**
|
||||
* A name, or a different one if the first is reserved.
|
||||
*
|
||||
* `randomInt` rather than `Math.random`: these are public identifiers, and
|
||||
* a predictable sequence would let somebody who has seen one name guess the
|
||||
* next machine's before its owner does.
|
||||
*/
|
||||
export function generate(): string {
|
||||
for (;;) {
|
||||
const adjective = ADJECTIVES[randomInt(ADJECTIVES.length)]!;
|
||||
const noun = NOUNS[randomInt(NOUNS.length)]!;
|
||||
const digits = randomInt(10_000).toString().padStart(4, '0');
|
||||
const slug = `${adjective}-${noun}-${digits}`;
|
||||
// A reserved word can only appear as the whole label, and the whole
|
||||
// label is never one word — but the check is on the parts as well,
|
||||
// so that shortening the pattern later cannot quietly re-open this.
|
||||
if (!RESERVED.has(slug) && !RESERVED.has(adjective) && !RESERVED.has(noun)) {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a name is one this control plane would have minted. */
|
||||
export function isValid(slug: string): boolean {
|
||||
return PATTERN.test(slug) && !RESERVED.has(slug);
|
||||
}
|
||||
|
||||
export function isReserved(slug: string): boolean {
|
||||
return RESERVED.has(slug.trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user