Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions apps/cli/src/launch-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,32 @@ export interface RosterEntry {
url: string;
}

export interface ShipCredentialPair {
shipToken: string;
bridgeToken: string;
}

export interface ShipCredentialIssuer {
provisionShipCredentials(shipName: string): ShipCredentialPair;
}

export function provisionCredentials(
ships: NormalizedShip[],
auth?: ShipCredentialIssuer,
): Map<string, ShipCredentialPair> {
const credentials = new Map<string, ShipCredentialPair>();
for (const ship of ships) {
const configured =
ship.shipToken && ship.bridgeToken
? { shipToken: ship.shipToken, bridgeToken: ship.bridgeToken }
: undefined;
const pair =
configured ?? (ship.source === "local" ? auth?.provisionShipCredentials(ship.name) : undefined);
if (pair) credentials.set(ship.key, pair);
}
return credentials;
}

export type ShipRegistration = { ship: NormalizedShip; url: string } & (
| { skip: null }
| { skip: "on-bridge" }
Expand Down Expand Up @@ -83,19 +109,7 @@ async function runLaunch(configPath: string): Promise<void> {
}
const shipBridgeUrl = launchedBridgeUrl && isHttpUrl(launchedBridgeUrl) ? launchedBridgeUrl : undefined;

const registrations = planRegistrations(config.ships, manager?.listShips() ?? []);

const credentials = new Map<string, { shipToken: string; bridgeToken: string }>();
for (const { ship, skip } of registrations) {
const configured =
ship.shipToken && ship.bridgeToken
? { shipToken: ship.shipToken, bridgeToken: ship.bridgeToken }
: undefined;
const pair =
configured ??
(skip === null && ship.source === "local" ? auth?.createShipCredentials(ship.name) : undefined);
if (pair) credentials.set(ship.key, pair);
}
const credentials = provisionCredentials(config.ships, auth);

for (const ship of config.ships) {
if (ship.source !== "local") continue;
Expand All @@ -115,7 +129,7 @@ async function runLaunch(configPath: string): Promise<void> {
console.log(`no bridge configured; not registering ship "${ship.key}" (${shipUrl(ship)})`);
}
} else {
for (const entry of registrations) {
for (const entry of planRegistrations(config.ships, manager.listShips())) {
const { ship, url } = entry;
switch (entry.skip) {
case "duplicate-config-entry":
Expand Down
78 changes: 77 additions & 1 deletion apps/cli/tests/launch-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test";
import { planRegistrations, shipUrl, type RosterEntry } from "../src/launch-command";
import {
planRegistrations,
provisionCredentials,
shipUrl,
type RosterEntry,
type ShipCredentialIssuer,
} from "../src/launch-command";
import type { NormalizedShip } from "../src/launch-config";

const local = (key: string, port: number, name = key): NormalizedShip => ({
Expand Down Expand Up @@ -104,3 +110,73 @@ describe("planRegistrations", () => {
expect(planRegistrations([], [on("ship-a", "http://localhost:4700")])).toEqual([]);
});
});

function issuerReusingBridgeTokens(): ShipCredentialIssuer & { asked: string[] } {
const bridgeTokens = new Map<string, string>();
const asked: string[] = [];
let minted = 0;
return {
asked,
provisionShipCredentials(shipName: string) {
asked.push(shipName);
let bridgeToken = bridgeTokens.get(shipName);
if (bridgeToken === undefined) {
bridgeToken = `bridge-${shipName}`;
bridgeTokens.set(shipName, bridgeToken);
}
minted += 1;
return { shipToken: `ship-${shipName}-${minted}`, bridgeToken };
},
};
}

const withTokens = (ship: NormalizedShip, shipToken: string, bridgeToken: string): NormalizedShip => ({
...ship,
shipToken,
bridgeToken,
});

describe("provisionCredentials", () => {
test("provisions a pair for every local ship, keyed by config key and asked for by name", () => {
const auth = issuerReusingBridgeTokens();
const credentials = provisionCredentials([local("a", 4700, "ship-a"), local("b", 4701, "ship-b")], auth);

expect(auth.asked).toEqual(["ship-a", "ship-b"]);
expect([...credentials.keys()]).toEqual(["a", "b"]);
expect(credentials.get("a")).toEqual({ shipToken: "ship-ship-a-1", bridgeToken: "bridge-ship-a" });
});

test("a second launch provisions again rather than skipping a ship the bridge already holds", () => {
const auth = issuerReusingBridgeTokens();
const ships = [local("a", 4700, "ship-a")];

const first = provisionCredentials(ships, auth);
const second = provisionCredentials(ships, auth);

expect(auth.asked).toEqual(["ship-a", "ship-a"]);
expect(second.get("a")?.bridgeToken).toBe(first.get("a")?.bridgeToken);
expect(second.get("a")?.shipToken).not.toBe(first.get("a")?.shipToken);
});

test("a configured pair is used verbatim and never asks the bridge for one", () => {
const auth = issuerReusingBridgeTokens();
const ships = [
withTokens(local("a", 4700, "ship-a"), "pinned-ship", "pinned-bridge"),
withTokens(remote("b", "http://host-b:4700"), "remote-ship", "remote-bridge"),
];

const credentials = provisionCredentials(ships, auth);

expect(auth.asked).toEqual([]);
expect(credentials.get("a")).toEqual({ shipToken: "pinned-ship", bridgeToken: "pinned-bridge" });
expect(credentials.get("b")).toEqual({ shipToken: "remote-ship", bridgeToken: "remote-bridge" });
});

test("a remote ship with no configured pair gets none, and neither does anything without a bridge", () => {
const auth = issuerReusingBridgeTokens();

expect(provisionCredentials([remote("b", "http://host-b:4700")], auth).size).toBe(0);
expect(auth.asked).toEqual([]);
expect(provisionCredentials([local("a", 4700, "ship-a"), remote("b", "http://host-b:4700")]).size).toBe(0);
});
});
21 changes: 15 additions & 6 deletions apps/docs/src/content/docs/guides/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,17 @@ A referenced variable that is unset or empty fails the launch — better than qu
registering a ship with no credentials at all. See the
[fleet-config reference](/reference/fleet-config/) for the exact rules.

Ships with `source: local` that set neither key keep the behaviour they have always had:
`fleet launch` mints a fresh pair for each one, hands it to the ship it spawns, and
registers it with the bridge. Set both keys on a local ship to pin the pair instead.
Ships with `source: local` that set neither key are provisioned by the launch itself:
`fleet launch` settles a pair for each one, hands it to the ship it spawns, and registers
it with the bridge. Set both keys on a local ship to pin the pair instead.

Provisioning is not a blind mint. On the first launch both halves are generated. On every
launch after that the bridge **reuses the `bridgeToken` it already stored** for that ship
and rotates only the `shipToken`. It has to: the bridge presents the `bridgeToken` on
every call it makes to the ship, and a connection it has already opened holds the value it
was built with — rotating it would leave the bridge presenting a token the relaunched ship
no longer accepts. The `shipToken` is safe to rotate because only its hash is stored and
the ship is being handed the new one as it starts.

### Starting the remote ship

Expand Down Expand Up @@ -287,9 +295,10 @@ affected — workspaces on the ship keep working, which is exactly what makes it
miss.

:::note
A `source: local` ship under `fleet launch` needs neither variable. The launch generates
the pair, hands it to the ship it spawns in-process, and registers it with the bridge in
one step. These two variables are for ships you start yourself.
A `source: local` ship under `fleet launch` needs neither variable. The launch settles the
pair, hands it to the ship it spawns in-process, and registers it with the bridge in one
step — on a relaunch too, where it reuses the stored `bridgeToken` and rotates the
`shipToken`. These two variables are for ships you start yourself.
:::

## `--insecure-no-auth`
Expand Down
33 changes: 17 additions & 16 deletions apps/docs/src/content/docs/reference/fleet-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,8 @@ The ship is spawned inside the `fleet launch` process.
| `fleetDirectory` | string (non-empty) | no | `./fleet/<key>` | Directory holding this ship's workspaces (`<dir>/<repo>/<name>`). Resolved to an absolute path. |
| `port` | integer | no | `4700` | Port this ship listens on. |
| `name` | fleet identifier | no | the map key | Human-facing name of this ship. |
| `shipToken` | string (non-empty) | no | freshly minted | The token this ship presents to the bridge. See [ship credentials](#ship-credentials). |
| `bridgeToken` | string (non-empty) | no | freshly minted | The token the bridge presents to this ship. |
| `shipToken` | string (non-empty) | no | provisioned by the launch | The token this ship presents to the bridge. See [ship credentials](#ship-credentials). |
| `bridgeToken` | string (non-empty) | no | provisioned by the launch | The token the bridge presents to this ship. |

Because `port` defaults to `4700` for every local ship, two or more local ships
must each set a distinct `port`.
Expand Down Expand Up @@ -216,12 +216,14 @@ fleet launch: ship "gpu-box" sets shipToken but not bridgeToken; a ship is regis
What each token does, and which end holds it, is covered in
[authentication](/guides/authentication/). What matters here is the default:

- A `source: local` ship with **neither** key set gets a freshly minted pair —
`fleet launch` generates it, hands it to the ship it spawns, and registers the
ship with it. That is the usual case. A pair is only minted for a ship the
launch is about to register: one the bridge's roster already holds keeps the
credentials that roster entry was registered with, and is spawned without a
pair of its own.
- A `source: local` ship with **neither** key set is provisioned by the launch —
`fleet launch` settles a pair, hands it to the ship it spawns, and registers the
ship with it. That is the usual case. On the first launch both halves are
generated; on later launches the bridge reuses the `bridgeToken` it stored for
that ship and rotates only the `shipToken`, so a ship the roster already holds
comes back with credentials the bridge can still use. See
[authentication](/guides/authentication/) for why the `bridgeToken` cannot be
rotated out from under a running bridge.
- A `source: local` ship with **both** keys set uses those instead of minting.
- A `source: remote` ship with **neither** key set is registered with no
credentials, and the bridge talks to it unauthenticated.
Expand Down Expand Up @@ -299,15 +301,14 @@ duplicate-port check, then the gui/bridge check.
1. Loads and normalizes the config, resolving every `${VAR}` in a ship's tokens.
2. If `bridge` is present, starts the bridge — creating the first admin unless
`insecureNoAuth` is set — and keeps its manager.
3. Plans the registrations against the bridge's roster: a ship the bridge already
holds is skipped, and so is a config entry whose URL an earlier entry claimed.
4. Settles each ship's credentials in map order: the configured pair if both keys
are set, otherwise a freshly minted pair for a `source: local` ship the bridge
is about to register, and none for a `source: remote` ship or one step 3
skipped.
5. Starts every `source: local` ship — pinned to the launched bridge's
3. Settles each ship's credentials in map order: the configured pair if both keys
are set, otherwise a pair provisioned from the bridge for a `source: local`
ship, and none for a `source: remote` ship.
4. Starts every `source: local` ship — pinned to the launched bridge's
`publicUrl`, and handed its pair.
6. Registers the ships step 3 planned, at `http://localhost:<port>` (local) or
5. Plans the registrations against the bridge's roster: a ship the bridge already
holds is skipped, and so is a config entry whose URL an earlier entry claimed.
6. Registers the ships step 5 planned, at `http://localhost:<port>` (local) or
their `url` (remote), printing
`registered ship "<key>" (<url>) with the bridge` for each.
7. If `gui` is present, serves the GUI against `gui.bridgeUrl` or the local
Expand Down
8 changes: 8 additions & 0 deletions packages/fleet-bridge/src/auth/auth-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ function prepareStatements(db: Database) {
bridge_token = excluded.bridge_token,
agent_token_hash = excluded.agent_token_hash`,
),
updateShipTokens: db.query<void, [string, string, string]>(
"UPDATE ship_credentials SET ship_token_hash = ?, bridge_token = ? WHERE ship_name = ?",
),
findShipCredentials: db.query<unknown, [string]>("SELECT * FROM ship_credentials WHERE ship_name = ?"),
findShipByShipTokenHash: db.query<unknown, [string]>(
"SELECT * FROM ship_credentials WHERE ship_token_hash = ?",
Expand Down Expand Up @@ -291,6 +294,11 @@ export class AuthDatabase {
);
}

/** Leaves `agent_token_hash` and `created_at` as they are. */
updateShipTokens(shipName: string, tokens: { shipTokenHash: string; bridgeToken: string }): void {
this.s.updateShipTokens.run(tokens.shipTokenHash, tokens.bridgeToken, shipName);
}

findShipCredentials(shipName: string): ShipCredentialsRow | undefined {
return parseRow(ShipCredentialsRowSchema, this.s.findShipCredentials.get(shipName));
}
Expand Down
29 changes: 24 additions & 5 deletions packages/fleet-bridge/src/auth/auth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,30 @@ export class AuthService {
return entry.expiresAt <= this.now() ? null : entry.principal;
}

createShipCredentials(shipName: string): { shipToken: string; bridgeToken: string } {
const shipToken = generateToken();
const bridgeToken = generateToken();
this.setShipCredentials(shipName, { shipToken, bridgeToken });
return { shipToken, bridgeToken };
/**
* Reuses any stored `bridgeToken`: a live `ShipConnection` captured that
* value when it was built and cannot be told a new one, so rotating it would
* leave the bridge presenting a token the restarted ship no longer accepts.
*/
provisionShipCredentials(shipName: string): { shipToken: string; bridgeToken: string } {
const name = parseFleetIdentifier(shipName);
return this.db.immediateTransaction(() => {
const existing = this.db.findShipCredentials(name);
const shipToken = generateToken();
const bridgeToken = existing?.bridge_token ?? generateToken();
if (existing) {
this.db.updateShipTokens(name, { shipTokenHash: hashToken(shipToken), bridgeToken });
} else {
this.db.upsertShipCredentials({
ship_name: name,
ship_token_hash: hashToken(shipToken),
bridge_token: bridgeToken,
agent_token_hash: null,
created_at: this.now(),
});
}
return { shipToken, bridgeToken };
});
}

setShipCredentials(shipName: string, credentials: { shipToken: string; bridgeToken: string }): void {
Expand Down
15 changes: 13 additions & 2 deletions packages/fleet-bridge/tests/auth-enforcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe("bridge default-deny enforcement", () => {

test("the armory routes answer a ship or a user credential and nothing else", async () => {
const { app, auth, authorization } = await makeAuthedApp(manager);
const { shipToken } = auth.createShipCredentials("ship-a");
const { shipToken } = auth.provisionShipCredentials("ship-a");
const asShip = { headers: { authorization: `Bearer ${shipToken}` } };
const asUser = { headers: { authorization } };

Expand All @@ -100,6 +100,17 @@ describe("bridge default-deny enforcement", () => {
expect(await status(app, "GET", "/armory/file?path=nope", asUser)).toBe(404);
});

test("a relaunched ship reaches the armory with its new token, and its old one is dead", async () => {
const { app, auth } = await makeAuthedApp(manager);
const first = auth.provisionShipCredentials("ship-a");
const second = auth.provisionShipCredentials("ship-a");

const withToken = (token: string) => ({ headers: { authorization: `Bearer ${token}` } });

expect(await status(app, "GET", "/armory", withToken(second.shipToken))).toBe(200);
expect(await status(app, "GET", "/armory", withToken(first.shipToken))).toBe(401);
});

test("insecureNoAuth serves every route without credentials", async () => {
const { app } = await makeAuthedApp(manager, { insecureNoAuth: true });

Expand All @@ -115,7 +126,7 @@ describe("bridge default-deny enforcement", () => {
let app: ReturnType<typeof createApp>;

async function tokenFor(kind: "ship" | "ship-agent"): Promise<string> {
const { shipToken } = auth.createShipCredentials("ship-a");
const { shipToken } = auth.provisionShipCredentials("ship-a");
return kind === "ship" ? shipToken : auth.mintShipAgentToken("ship-a");
}

Expand Down
39 changes: 37 additions & 2 deletions packages/fleet-bridge/tests/auth-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ describe("AuthService", () => {
test("session, ship, and ship-agent tokens resolve to distinct principals", async () => {
const user = await auth.createUser({ username: "ada", email: "ada@fleet.test", password: PASSWORD, role: "admin" });
const { token } = await auth.login("ada", PASSWORD);
const { shipToken, bridgeToken } = auth.createShipCredentials("ship-a");
const { shipToken, bridgeToken } = auth.provisionShipCredentials("ship-a");
const agentToken = auth.mintShipAgentToken("ship-a");

expect(auth.authenticate(`Bearer ${token}`)).toEqual({
Expand All @@ -214,7 +214,7 @@ describe("AuthService", () => {
expect(auth.bridgeTokenFor("ship-a")).toBe("bridge-secret");
expect(auth.authenticate("Bearer ship-secret")).toEqual({ kind: "ship", ship: "ship-a" });

const rotated = auth.createShipCredentials("ship-a");
const rotated = auth.provisionShipCredentials("ship-a");
expect(auth.authenticate("Bearer ship-secret")).toBeNull();
expect(auth.authenticate(`Bearer ${rotated.shipToken}`)).toEqual({ kind: "ship", ship: "ship-a" });
expect(db.findShipCredentials("ship-a")?.ship_token_hash).toBe(sha256(rotated.shipToken));
Expand All @@ -225,6 +225,41 @@ describe("AuthService", () => {
expect(() => auth.mintShipAgentToken("ship-a")).toThrow();
});

test("provisioning a ship the database has never seen mints both halves", () => {
const a = auth.provisionShipCredentials("ship-a");
const b = auth.provisionShipCredentials("ship-b");

expect(new Set([a.shipToken, a.bridgeToken, b.shipToken, b.bridgeToken]).size).toBe(4);
expect(auth.bridgeTokenFor("ship-a")).toBe(a.bridgeToken);
expect(auth.authenticate(`Bearer ${a.shipToken}`)).toEqual({ kind: "ship", ship: "ship-a" });
expect(db.findShipCredentials("ship-a")?.agent_token_hash).toBeNull();
});

test("provisioning again reuses the stored bridgeToken and rotates only the shipToken", () => {
const first = auth.provisionShipCredentials("ship-a");
const createdAt = db.findShipCredentials("ship-a")?.created_at;

clock += 60_000;
const second = auth.provisionShipCredentials("ship-a");

expect(second.bridgeToken).toBe(first.bridgeToken);
expect(second.shipToken).not.toBe(first.shipToken);
expect(auth.bridgeTokenFor("ship-a")).toBe(first.bridgeToken);
expect(auth.authenticate(`Bearer ${first.shipToken}`)).toBeNull();
expect(auth.authenticate(`Bearer ${second.shipToken}`)).toEqual({ kind: "ship", ship: "ship-a" });
expect(db.findShipCredentials("ship-a")?.ship_token_hash).toBe(sha256(second.shipToken));
expect(db.findShipCredentials("ship-a")?.created_at).toBe(createdAt);
});

test("provisioning leaves an agent token the bridge already pushed alone", () => {
auth.provisionShipCredentials("ship-a");
const agentToken = auth.mintShipAgentToken("ship-a");

auth.provisionShipCredentials("ship-a");

expect(auth.authenticate(`Bearer ${agentToken}`)).toEqual({ kind: "ship-agent", ship: "ship-a" });
});

test("authenticate ignores malformed authorization headers", async () => {
await auth.createUser({ username: "ada", email: "ada@fleet.test", password: PASSWORD });
const { token } = await auth.login("ada", PASSWORD);
Expand Down
Loading