diff --git a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md index 8fd2c93b..ba2cd533 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md +++ b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md @@ -4,9 +4,9 @@ anchor: function App created: 2026-10-15T03:38:04Z norm: '1' sig: 063d0f00fea02511 -body_hash: a558d5cc2db9572f -raw_hash: ff829592063e519f -vouched: 2026-10-21T22:03:14Z +body_hash: 247d8e002cbe5eea +raw_hash: ff8ae1733758ca88 +vouched: 2026-10-22T00:00:58Z lines: 235-824 --- diff --git a/.lane/memory/apps/host/src/gateway-client.ts/01M4YQRMKVKT45GACS27J2TXWY-channel-listings-must-outliv.md b/.lane/memory/apps/host/src/gateway-client.ts/01M4YQRMKVKT45GACS27J2TXWY-channel-listings-must-outliv.md index c0dba4ed..072712ea 100644 --- a/.lane/memory/apps/host/src/gateway-client.ts/01M4YQRMKVKT45GACS27J2TXWY-channel-listings-must-outliv.md +++ b/.lane/memory/apps/host/src/gateway-client.ts/01M4YQRMKVKT45GACS27J2TXWY-channel-listings-must-outliv.md @@ -4,9 +4,9 @@ anchor: class GatewayClient created: 2026-10-15T02:55:20Z norm: '1' sig: 41366b9e88f0e890 -body_hash: 667acf3cf8d2b033 -raw_hash: 4affce51e3e1735f -vouched: 2026-10-21T19:33:15Z +body_hash: 0ea1b5a225ceb8b0 +raw_hash: 99b659f96ee9516e +vouched: 2026-10-22T00:00:58Z lines: 33-240 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md index c3897110..49171b86 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T04:08:49Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 5950b974bd224a8d -raw_hash: ce8b5ae9fa8408ba -vouched: 2026-10-21T22:03:14Z +body_hash: 14495715208898b4 +raw_hash: 2426d3718700367c +vouched: 2026-10-22T00:00:58Z lines: 266-519 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md index 3f8ebe16..c843f55c 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T06:00:20Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 5950b974bd224a8d -raw_hash: ce8b5ae9fa8408ba -vouched: 2026-10-21T22:03:14Z +body_hash: 14495715208898b4 +raw_hash: 2426d3718700367c +vouched: 2026-10-22T00:00:58Z lines: 281-561 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md b/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md index 5d9417a6..59596158 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md @@ -4,9 +4,9 @@ anchor: function websocket created: 2026-10-15T20:16:37Z norm: '1' sig: 1b462e5d3bc33728 -body_hash: 39bbd535e37f47a0 -raw_hash: c1a585dfb617245a -vouched: 2026-10-20T17:10:51Z +body_hash: e4839023c586ff4f +raw_hash: cda22d4980cf5a80 +vouched: 2026-10-22T00:00:58Z lines: 673-737 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md b/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md index a14137f2..8cef3bb9 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-19T21:09:57Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 5950b974bd224a8d -raw_hash: ce8b5ae9fa8408ba -vouched: 2026-10-21T22:03:14Z +body_hash: 14495715208898b4 +raw_hash: 2426d3718700367c +vouched: 2026-10-22T00:00:58Z lines: 384-700 supersedes: 01M5APK9YCQH467YNMKAC8BT5B --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md index ccf18400..fd333b94 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-20T17:04:56Z norm: '1' sig: 1acdc0f6a0895ac5 -body_hash: 95b019291fdd466c -raw_hash: 561d1e864a3cd088 -vouched: 2026-10-21T22:03:14Z +body_hash: da1299c8d5811bd8 +raw_hash: fb9d9232096ff061 +vouched: 2026-10-22T00:00:58Z lines: 1-1153 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5GEJ78HX2MSSVN17V2PTKAS-only-the-local-app-can-answe.md b/.lane/memory/apps/host/src/gateway.ts/01M5GEJ78HX2MSSVN17V2PTKAS-only-the-local-app-can-answe.md new file mode 100644 index 00000000..bce083dd --- /dev/null +++ b/.lane/memory/apps/host/src/gateway.ts/01M5GEJ78HX2MSSVN17V2PTKAS-only-the-local-app-can-answe.md @@ -0,0 +1,12 @@ +--- +id: 01M5GEJ78HX2MSSVN17V2PTKAS +anchor: function asksChanged +created: 2026-10-22T00:00:52Z +norm: '1' +sig: 89d3d187f4a8cef0 +body_hash: 10d18a209d9cd21c +raw_hash: a2a5da6ab853d6c9 +lines: 391-394 +--- + +only the local app can answer an ask (team-answer is a keychain op), so asks reach local sockets only diff --git a/.lane/memory/apps/host/src/protocol.ts/01M5GEJ77XBRPQN89DW0CKNAWP-hello-answers-any-tailnet-pe.md b/.lane/memory/apps/host/src/protocol.ts/01M5GEJ77XBRPQN89DW0CKNAWP-hello-answers-any-tailnet-pe.md new file mode 100644 index 00000000..81affd80 --- /dev/null +++ b/.lane/memory/apps/host/src/protocol.ts/01M5GEJ77XBRPQN89DW0CKNAWP-hello-answers-any-tailnet-pe.md @@ -0,0 +1,12 @@ +--- +id: 01M5GEJ77XBRPQN89DW0CKNAWP +anchor: type Hello +created: 2026-10-22T00:00:52Z +norm: '1' +sig: a2275d05c454362d +body_hash: e3b0c44298fc1c14 +raw_hash: 7276efa4e6f4c00c +lines: 157-157 +--- + +hello answers any tailnet peer, so team carries only the directory's host, never the secret diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index 1ee849c8..55a94fcf 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -56,6 +56,7 @@ import { type AppProject, label, projects, title } from "./projects"; import { type Section, Settings } from "./settings"; import { SetupProject } from "./setup-project"; import { ShareProject, useCanShare } from "./share-project"; +import { TeamAsks } from "./team-asks"; import { TeamSetup } from "./team-setup"; import { UpdateNotice } from "./updates"; import { useWindowStorage } from "./window-storage"; @@ -1076,6 +1077,7 @@ export function App() { }} /> )} + {desktop && window.dispatchEvent(new Event("ace:updates"))} />} diff --git a/apps/app/src/invite.ts b/apps/app/src/invite.ts index d10843b6..e225b86c 100644 --- a/apps/app/src/invite.ts +++ b/apps/app/src/invite.ts @@ -2,7 +2,9 @@ * An invite link carries the team's directory URL and secret in one string a teammate can click or * paste. Opening one only fills in the join form; the person still checks and joins. */ -export type Invite = { url: string; secret: string }; +import type { Invite } from "@ace/host/protocol"; + +export type { Invite }; /** * An https page, because chat apps linkify it and not `ace://`; it offers the download, then hands diff --git a/apps/app/src/team-asks.tsx b/apps/app/src/team-asks.tsx new file mode 100644 index 00000000..0ee98ca8 --- /dev/null +++ b/apps/app/src/team-asks.tsx @@ -0,0 +1,62 @@ +import { useState, useSyncExternalStore } from "react"; + +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@ace/ui"; + +import { host } from "./host"; + +/** + * Someone on the tailnet asked to join this host's team. Approving sends them the directory URL + * and team secret; closing leaves the ask to expire. + */ +export function TeamAsks() { + const asks = useSyncExternalStore(host.subscribe, () => host.asks); + const [dismissed, setDismissed] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const ask = asks.find((value) => !dismissed.includes(value.id)); + if (!ask) return null; + const who = ask.github || ask.login; + + async function answer(approve: boolean) { + setBusy(true); + setError(""); + try { + await host.request({ op: "team-answer", ask: ask!.id, approve }); + } catch (error) { + setError((error as Error).message); + } finally { + setBusy(false); + } + } + + return ( + next || setDismissed([...dismissed, ask.id])}> + + + Let {who} join your team? + + {ask.login} on {ask.host}{" "} + asked from Ace. Approving sends this host's directory URL and team secret to {ask.host} + {" "} + over your tailnet. + + + {error &&

{error}

} + + + + +
+
+ ); +} diff --git a/apps/app/src/team-setup.tsx b/apps/app/src/team-setup.tsx index 874f5300..91e9aefe 100644 --- a/apps/app/src/team-setup.tsx +++ b/apps/app/src/team-setup.tsx @@ -13,6 +13,7 @@ import { Switch, } from "@ace/ui"; import { IconArrowUpRight, IconCheck, IconCopy, IconLoader } from "@ace/ui/icons"; +import type { TeamPeer } from "@ace/host/protocol"; import { host } from "./host"; import { useDiagnostics } from "./host-settings"; @@ -74,6 +75,7 @@ export function TeamSetup({ github, invite, inviting, onClose }: { title="Set up Ace for your team" description="Everyone on your tailnet is your team. Start with how they'll find each other's channels." /> +
+ {!invite && } setStep("fork")} /> )} @@ -431,6 +434,80 @@ function Members({ team }: { team: Team }) { ); } +/** Peers' hosts already in a team; asked again while open, since a teammate may come online. */ +function useTeamPeers(): TeamPeer[] { + const [peers, setPeers] = useState([]); + useEffect(() => { + let live = true; + const load = () => + host.request({ op: "team-peers" }).then( + (value) => live && setPeers(value), + () => {}, + ); + void load(); + const timer = setInterval(load, 5000); + return () => { + live = false; + clearInterval(timer); + }; + }, []); + return peers; +} + +/** Ask a teammate's host to let this one in; their owner approves in their Ace. */ +function Nearby({ onJoined }: { onJoined: () => void }) { + const peers = useTeamPeers(); + const [asking, setAsking] = useState(); + const [error, setError] = useState(""); + if (!peers.length) return null; + + async function ask(peer: TeamPeer) { + setAsking(peer); + setError(""); + try { + await host.request({ op: "team-ask", host: peer.host }); + onJoined(); + } catch (error) { + setError((error as Error).message); + } finally { + setAsking(undefined); + } + } + + return ( +
+ {peers.map((peer) => ( +
+ + {peer.github || peer.login} + + {" "}on {peer.host} is in a team. + + + +
+ ))} + {asking && ( +

+ {asking.github || asking.login} approves it in their Ace. You join as soon as they do. +

+ )} + {error &&

{error}

} +
+ ); +} + /** Today's Settings → Team join: the directory checks the secret before either is saved. */ function Join({ initial, onJoined, back }: { initial?: Team; diff --git a/apps/host/src/asks.ts b/apps/host/src/asks.ts new file mode 100644 index 00000000..8f20d1c8 --- /dev/null +++ b/apps/host/src/asks.ts @@ -0,0 +1,80 @@ +/** + * Asking to join over the tailnet. A host that isn't in a team asks a teammate's host, and that + * host's owner approves in their app; the directory URL and secret then go to the asking host over + * WireGuard, so nobody sends them by hand. Tailscale names who is asking, and only this host's + * owner, from its local app, can approve. + */ +import type { Invite, TeamAsk } from "./protocol"; +import { invite } from "./settings"; + +/** Long enough to notice the prompt; an unanswered ask then fails instead of waiting forever. */ +const EXPIRES = 5 * 60_000; + +type Waiting = { + ask: TeamAsk; + /** The asking peer's socket, so an ask ends with its connection. */ + from: object; + resolve: (value: Invite) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +const waiting = new Map(); + +export function list(): TeamAsk[] { + return [...waiting.values()].map(({ ask }) => ask); +} + +function end(id: string, changed: () => void) { + const entry = waiting.get(id); + if (!entry) return; + clearTimeout(entry.timer); + waiting.delete(id); + changed(); + return entry; +} + +/** Waits for the owner's answer; asking again from the same machine replaces the earlier ask. */ +export async function ask( + from: object, + person: { login: string; host: string; github?: string }, + changed: () => void, +): Promise { + // Refuse before prompting anyone when there is nothing to hand over. + await invite(); + for (const { ask } of waiting.values()) { + if (ask.login === person.login && ask.host === person.host) { + end(ask.id, changed)?.reject(new Error("Asked again")); + } + } + const id = crypto.randomUUID(); + const expires = Date.now() + EXPIRES; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + end(id, changed)?.reject( + new Error("Nobody answered in time. Ask again when they're around."), + ); + }, EXPIRES); + waiting.set(id, { ask: { id, ...person, expires }, from, resolve, reject, timer }); + changed(); + }); +} + +export async function answer(id: string, approve: boolean, changed: () => void): Promise { + const entry = waiting.get(id); + if (!entry) throw new Error("That request has ended"); + if (!approve) { + end(id, changed); + return entry.reject(new Error("They declined. Ask them for an invite link instead.")); + } + const team = await invite(); + end(id, changed); + entry.resolve(team); +} + +/** A peer that disconnects can't receive an approval. */ +export function drop(from: object, changed: () => void): void { + for (const entry of waiting.values()) { + if (entry.from === from) end(entry.ask.id, changed)?.reject(new Error("Disconnected")); + } +} diff --git a/apps/host/src/gateway-client.ts b/apps/host/src/gateway-client.ts index 43fc7485..74bd2ce2 100644 --- a/apps/host/src/gateway-client.ts +++ b/apps/host/src/gateway-client.ts @@ -11,6 +11,7 @@ import type { Project, Request, TabRename, + TeamAsk, TerminalFrame, WindowState, } from "./protocol"; @@ -61,6 +62,8 @@ export class GatewayClient { channels: Listing[] = []; projects: Project[] = []; people: People = {}; + /** Asks to join the team waiting for this host's owner; only the local app receives them. */ + asks: TeamAsk[] = []; /** Everyone here by project ID, while the host is connected. */ presence: Record = {}; settingsVersion = 0; @@ -137,6 +140,10 @@ export class GatewayClient { this.projects = frame.projects; return this.#emit(); } + if ("asks" in frame) { + this.asks = frame.asks; + return this.#emit(); + } if ("people" in frame) { this.people = frame.people; return this.#emit(); diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index 9d9a7669..fb7263d1 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -12,6 +12,7 @@ import * as compute from "./compute"; import { config } from "./config"; import * as demo from "./demo"; import { diagnostics } from "./diagnostics"; +import * as asks from "./asks"; import * as directory from "./directory"; import { retryLoginShell, shell } from "./environment"; import type { GatewayClient, Watch } from "./gateway-client"; @@ -73,6 +74,8 @@ type Client = { user: string; /** Arrived over the tailnet. Peers act only on this host's channels, and never as its owner. */ peer: boolean; + /** The tailnet machine a peer connected from, as Tailscale named it. */ + machine?: string; local: boolean; channels: Map>; /** This client's connections to peer hosts, opened when it first uses one of their channels. */ @@ -384,6 +387,12 @@ function projectsChanged(): Promise { }); } +/** Only the local app can answer an ask, so only it is told about them. */ +function asksChanged(): void { + const frame = JSON.stringify({ asks: asks.list() } satisfies HostFrame); + for (const socket of sockets) if (socket.data.local) socket.send(frame); +} + function settingsChanged(): void { for (const socket of sockets) { if (!socket.data.peer) socket.send(JSON.stringify({ settings: true } satisfies HostFrame)); @@ -451,6 +460,8 @@ const keychainOps = new Set([ "directory-set", "team-deploy", "team-invite", + "team-ask", + "team-answer", ]); const localOps = new Set([ "projects", @@ -469,6 +480,7 @@ const localOps = new Set([ "key-check", "key-remove", "preferences", + "team-peers", "compute", "folder", "compute-start", @@ -575,8 +587,15 @@ async function handle( updating = false; } return; - case "hello": - return { user: client.user, host: name, github: github.login(loginChanged) }; + case "hello": { + const team = directory.url(); + return { + user: client.user, + host: name, + github: github.login(loginChanged), + ...(team ? { team: new URL(team).host } : {}), + }; + } case "project-access": return access.describe(request.project); case "project-share": @@ -669,6 +688,20 @@ async function handle( return deployTeam(request.hosted, (waiting) => send({ id, waiting })); case "team-invite": return invite(); + case "team-peers": + return peers.teams(); + case "team-ask": { + const team = await peers.ask(request.host); + return setDirectory(team.url, team.secret); + } + case "team-request": { + if (!client.peer || !client.machine) throw new Error("Ask from your own host"); + const asker = { login: client.user, host: client.machine }; + const login = people()[client.user]; + return asks.ask(socket, login ? { ...asker, github: login } : asker, asksChanged); + } + case "team-answer": + return asks.answer(request.ask, request.approve, asksChanged); case "models": if (request.host && request.host !== name && !client.peer) { return (await remote(client, request.host)).request({ op: "models" }); @@ -852,6 +885,9 @@ function websocket(): Bun.WebSocketHandler { socket.send(JSON.stringify({ projects: await projects.list() } satisfies HostFrame)); socket.send(JSON.stringify({ people: people() } satisfies HostFrame)); socket.send(JSON.stringify({ presence: present() } satisfies HostFrame)); + if (socket.data.local) { + socket.send(JSON.stringify({ asks: asks.list() } satisfies HostFrame)); + } }); }, async message(socket, raw) { @@ -892,6 +928,7 @@ function websocket(): Bun.WebSocketHandler { close(socket, code) { sockets.delete(socket); windows.drop(socket); + if (socket.data.peer) asks.drop(socket, asksChanged); if (!socket.data.peer) lobbyChanged(); log("info", "gateway.close", { user: socket.data.user, peer: socket.data.peer, code }); for (const open of socket.data.channels.values()) { @@ -903,8 +940,16 @@ function websocket(): Bun.WebSocketHandler { }; } -function client(user: string, peer: boolean, local = false): Client { - return { user, peer, local, channels: new Map(), remotes: new Map(), terminals: new Map() }; +function client(user: string, peer: boolean, local = false, machine?: string): Client { + return { + user, + peer, + local, + machine, + channels: new Map(), + remotes: new Map(), + terminals: new Map(), + }; } async function serveApp(url: URL): Promise { @@ -997,8 +1042,9 @@ function team(machine: Machine, listener: web.Listener, tls?: Bun.TLSOptions): S const ip = server.requestIP(request); if (!ip) return new Response("Forbidden", { status: 403 }); let user: string; + let from: string; try { - user = await whois(ip.address, ip.port); + ({ login: user, host: from } = await whois(ip.address, ip.port)); } catch (error) { log("warn", "gateway.whois", { address: ip.address, ...failure(error) }); return new Response("Forbidden", { status: 403 }); @@ -1008,7 +1054,7 @@ function team(machine: Machine, listener: web.Listener, tls?: Bun.TLSOptions): S log("warn", "gateway.browser.refused", { user, origin }); return new Response("Open Ace from your own host", { status: 403 }); } - return server.upgrade(request, { data: client(user, !browser) }) + return server.upgrade(request, { data: client(user, !browser, false, from) }) ? undefined : new Response("Upgrade failed", { status: 400 }); }, diff --git a/apps/host/src/peers.ts b/apps/host/src/peers.ts index fff546fa..fa34a912 100644 --- a/apps/host/src/peers.ts +++ b/apps/host/src/peers.ts @@ -4,7 +4,7 @@ import { isIP } from "node:net"; import { config } from "./config"; import { GatewayClient } from "./gateway-client"; import { log } from "./log"; -import type { Hello, Listing, People } from "./protocol"; +import type { Hello, Invite, Listing, People, TeamPeer } from "./protocol"; import { type Machine, peers } from "./tailnet"; /** Hosts serve their tailnet listener on the same port unless told otherwise. */ @@ -139,6 +139,32 @@ export function find(channel: string): Machine | undefined { } } +/** Peers whose hosts are in a team, asked now: a peer may have joined since it first answered. */ +export async function teams(): Promise { + const found = await Promise.all( + [...hosts.values()].map(async ({ machine, client }) => { + if (client.status !== "open" || stalled.has(machine.name)) return; + const hello = await Promise.race([ + client.request({ op: "hello" }), + Bun.sleep(3000), + ]).catch(() => undefined); + if (!hello?.team) return; + const peer: TeamPeer = { host: machine.name, login: machine.login, team: hello.team }; + const github = hello.github || named[machine.login]; + if (github) peer.github = github; + return peer; + }), + ); + return found.filter((peer) => !!peer); +} + +/** Waits for the peer's owner to approve; see asks.ts. */ +export function ask(name: string): Promise { + const client = hosts.get(name)?.client; + if (client?.status !== "open") throw new Error(`${name} isn't reachable on the tailnet`); + return client.request({ op: "team-request" }); +} + export function machine(name: string): Machine | undefined { return hosts.get(name)?.machine; } diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index 2155511f..91ce86d8 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -153,7 +153,17 @@ export function isInactive( } /** `github` is the host owner's GitHub login, when its GitHub CLI is signed in. */ -export type Hello = { user: string; host: string; github?: string }; +/** `team` is the host of the directory this host joined, never its secret, so peers can ask to join. */ +export type Hello = { user: string; host: string; github?: string; team?: string }; + +/** What joining a team takes: its directory URL and the team secret. */ +export type Invite = { url: string; secret: string }; + +/** A peer whose host is in a team, which this host can ask to join. */ +export type TeamPeer = { host: string; login: string; github?: string; team: string }; + +/** Someone on the tailnet asking this host's owner to let them into the team. */ +export type TeamAsk = { id: string; login: string; host: string; github?: string; expires: number }; /** GitHub logins by participant (Tailscale login), from this host, its peers, and the directory. */ export type People = Record; @@ -309,6 +319,16 @@ export type HostRequest = | { op: "team-deploy"; hosted: boolean } /** The joined directory's URL and the saved team secret, for the owner to invite a teammate. */ | { op: "team-invite" } + /** Peers whose hosts are in a team, for a host that isn't in one yet. */ + | { op: "team-peers" } + /** + * Ask a peer's owner to let this host into their team; on approval it answers with the + * directory and secret over the tailnet, and this host joins as with `directory-set`. + */ + | { op: "team-ask"; host: string } + /** Sent by an asking peer host. Answers with the directory URL and secret once the owner approves. */ + | { op: "team-request" } + | { op: "team-answer"; ask: string; approve: boolean } /** Quiesce this helper before replacing its application, or recover a canceled update. */ | { op: "update-prepare" } | { op: "update-cancel" } @@ -374,6 +394,8 @@ export type HostFrame = | { settings: true } /** Opened projects belong to the local owner and are never sent to tailnet peers. */ | { projects: Project[] } + /** Asks to join the team that wait for the owner, sent to the local app only. */ + | { asks: TeamAsk[] } /** Sent to local clients when known GitHub logins change. */ | { people: People } /** diff --git a/apps/host/src/settings.ts b/apps/host/src/settings.ts index 8e5eb9a2..cf9bc96f 100644 --- a/apps/host/src/settings.ts +++ b/apps/host/src/settings.ts @@ -7,7 +7,7 @@ import { forget, key, KEYCHAIN_WAITING, latest, overriding, settled, store } fro import { models, parseModel } from "./manage"; import { canonical } from "./move"; import { isModel, preference, saveCompute, saveFolder, savePreference } from "./preferences"; -import type { Compute, ProviderId, Settings } from "./protocol"; +import type { Compute, Invite, ProviderId, Settings } from "./protocol"; const providers = new Map( [ @@ -176,7 +176,7 @@ export async function forgetKey(name: string): Promise { } /** What a teammate needs to join: this host's directory and the secret it was joined with. */ -export async function invite(): Promise<{ url: string; secret: string }> { +export async function invite(): Promise { const url = directory.url(); const secret = await key("ACE_SECRET"); if (!url || !secret) throw new Error("Join the team's directory first"); diff --git a/apps/host/src/tailnet.ts b/apps/host/src/tailnet.ts index 83c11353..db0813e4 100644 --- a/apps/host/src/tailnet.ts +++ b/apps/host/src/tailnet.ts @@ -139,15 +139,18 @@ export async function certificate( return { cert, key }; } -/** The person behind an inbound TCP connection, as Tailscale verified it. Tagged nodes are refused. */ -export async function whois(ip: string, port: number): Promise { +/** + * The person and machine behind an inbound TCP connection, as Tailscale verified them. Tagged + * nodes are refused. + */ +export async function whois(ip: string, port: number): Promise<{ login: string; host: string }> { const address = tailnet(ip); if (!address) throw new Error("Not a tailnet address"); const endpoint = isIP(address) === 6 ? `[${address}]:${port}` : `${address}:${port}`; const value = await run(["whois", "--json", "--proto=tcp", endpoint]) as { - Node: { Tags?: string[] }; + Node: { Name: string; Tags?: string[] }; UserProfile: { LoginName: string }; }; if (value.Node.Tags?.length) throw new Error("Tagged nodes cannot participate"); - return value.UserProfile.LoginName; + return { login: value.UserProfile.LoginName, host: value.Node.Name.split(".")[0] || address }; }