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
12 changes: 12 additions & 0 deletions changelog.d/pc-update-from-here.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
### Added
- The server can update every PC added here, through the relay or over SSH, from this PC: each PC on
the list tells whether it has an Ovis update it can take, and this PC's owner can start it for one PC
or all of them, with each PC's steps and, when one fails, why. The buttons for it come with a later
update of the app's screens.

### Changed
- Changing what runs on another PC from here is the owner's, from any device he signed in or paired
himself (this PC, its token or Tailscale login, his phone or other PCs paired directly or over the
relay): updating a PC's bridge or its Ovis, approving what a PC's setup installs, turning a PC on
(which may update it) and turning automatic PC updates on. A visitor of an open install with nothing
paired, a Portal visitor and a device that only watches can no longer do these.
68 changes: 67 additions & 1 deletion docs/remote-pcs.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ an address the latest look found in the last 10 minutes, including a PC it left
it was already added).

Its workspaces then join the sidebar as an SSH PC's do. Nothing is installed on that PC: it runs
its own Ovis, which updates itself, so it has no bridge controls. Its connection carries the PC's
its own Ovis, which updates itself, so it has no bridge controls; its update can still be started
from here ([Updating PCs from here](#updating-pcs-from-here)). Its connection carries the PC's
status, its API routes and its terminals as streams of one relay session, which holds at most 8
WebSockets, so about 7 of its terminals can be open at once. A dropped connection comes back with
the same backoff as an SSH PC's and signs in with the saved key; nothing typed is sent again.
Expand Down Expand Up @@ -135,6 +136,69 @@ code, `POST /api/machines/relay/code` with `{ "code": "<code>" }` runs one excha
`unreachable`, `forged`); then `POST /api/machines/relay` with `{ "found": "<ticket>" }` adds
that PC, once.

## Updating PCs from here

Every PC added here, through the relay (direct or relayed) or over SSH, is updated from the PC in
use, without going to it. Each PC in `GET /api/machines` (and the machine events) carries
`ovis_update` when it has an update it can take from here, unasked:

- `how: "own"`: the PC runs its own Ovis (a relay PC, or an SSH PC's own server) and its updater
offers a newer release. A connected PC is asked through its connection (`GET /api/updates`
there) when it connects and every 5 minutes; an SSH PC whose own Ovis is older than this app
says so on its connect.
- `how: "bridge"`: an SSH PC's bridge from this app is of another version.
- `updatable: false` with `reason`, in plain words, when an update is on offer but cannot run from
here (not started by its updater, blocked there, too old to install by revision, a saved herdr
machine whose bridge was never approved). An SSH PC's own Ovis is never updated from here to a
release newer than this app, beta builds included (`0.6.0-beta.2` is newer than `0.5.1` and older
than `0.6.0`), nor to one whose version it does not tell: this app would then refuse to connect
to it. Absent or null: up to date, or not known yet.

`POST /api/machines/:id/update` starts that update (202 `{ job_id }`), whichever way the PC is
reached, and refuses one that cannot run with 409 `update_unavailable` and the same words. A
PC's own offer names its release as `revision` (a connected PC's, or the one an SSH PC's own
Ovis older than this app told at its last connect); a body `{ "revision": … }` with the one shown
makes sure only that release is installed: one the PC offers instead by then is refused, never installed
unseen. Update all installs each PC's release as this server last read it when it was asked, also for
a PC that waits its turn. The 409 comes from the offer this server holds; when the PC itself offers
another release by the time it is asked (or already installs one), the 202 job ends with
`update_error` saying so, and nothing is installed. A
connected PC's own Ovis is asked to install the release it offers, by revision, through its
connection (no redirect is followed); its connection drops while it restarts, the PC reconnects as
any PC does, and the update ends once it answers on that release (at most 15 minutes, or a minute
for the install to start). Anything else runs the bridge update above. While it runs, the PC's
`updating` shows its steps; a failed one leaves `update_error`, in plain words, until the next try
starts or one succeeds. `DELETE /api/machines/:id/update` ends the wait (an install already running
there runs on).

`POST /api/machines/update-all` starts every PC's update at once: 202 `{ started, queued, skipped }`.
Own updates run side by side; bridge updates run one after another, sharing one bundle download,
and show `updating.queued` until their turn; a PC with an update it cannot take is `skipped` with
why. Up-to-date PCs are not listed. At most four PCs' own updates run at once; the others show
`updating.queued` and start as one ends.

### Who may change PCs from here

One rule covers every change to this PC's PCs: every `POST`, `PATCH` and `DELETE` under
`/api/machines/` except a PC's own routes (its panes, workspaces and files, which are driven as this
PC's are). That covers adding a PC (SSH setup, relay, Tailscale), answering, approving or cancelling a
setup, its settings, renaming, turning on or off and removing a PC, and every update
(`/:id/update`, `update-all`, `/:id/update-bridge`, which takes `{ "revision" }` and otherwise uses
the release its card shows). The same rule covers installing an update on this PC itself:
`POST /api/updates/install` and `POST /api/herdr/update`. The one exception is the connection
server's bridge token, which asks for its own install by revision.

| May | Who |
|---|---|
| Yes | a driving sign-in on this PC, its token, its Tailscale login |
| Yes | a driving device paired with this install, directly or over the relay (the owner's phone, his other PCs) |
| No | a visitor of an open install, with nothing paired |
| No | a Portal visitor, whatever role Portal gives |
| No | any device that only watches |

The request must also come from this app's own page: the `X-Herdr-Update: 1` header, same origin,
no query. Anything else gets 403 `forbidden`. The app sends the header on every PC request.

## Runtime and endpoints

```
Expand All @@ -154,6 +218,8 @@ The current server keeps one status-only WebSocket per remote PC without attachi
| `GET /api/machines/events` | SSE roster and machine-scoped status events |
| `POST /api/machines/setup` | Create an interactive setup job |
| `GET/POST/DELETE /api/machines/setup/:id` | Inspect, answer/approve, cancel |
| `POST/DELETE /api/machines/:id/update` | Start or stop waiting for the PC's update ([Updating PCs from here](#updating-pcs-from-here)) |
| `POST /api/machines/update-all` | Start every PC's update |
| `PATCH /api/machines/:id` | Rename or change `enabled` |
| `DELETE /api/machines/:id` | Forget the PC and remove its local private key |
| `/api/machines/:id/{session,agents,pane/*,workspace/*}` | Allowlisted target API routes |
Expand Down
2 changes: 1 addition & 1 deletion scripts/herdr-profiles-ssh-qa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ async function until<T>(read: () => Promise<T>, done: (value: T) => boolean, lab
throw new Error(`Timed out ${label}: ${JSON.stringify(value!)}`);
}
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const response = await fetch(`http://127.0.0.1:${server!.port}${path}`, { method, headers: { "x-herdr-machine": "1", "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const response = await fetch(`http://127.0.0.1:${server!.port}${path}`, { method, headers: { "x-herdr-machine": "1", "x-herdr-update": "1", "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
assert.ok(response.ok, `${path}: ${response.status} ${response.ok ? "" : await response.text()}`);
return response.json() as Promise<T>;
}
Expand Down
2 changes: 1 addition & 1 deletion scripts/ssh-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async function until<T>(read: () => Promise<T>, done: (value: T) => boolean, lab
throw new Error(`Timed out ${label}: ${JSON.stringify(last)}`);
}
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const r = await fetch(`http://127.0.0.1:${server!.port}${path}`, { method, headers: { "x-herdr-machine": "1", "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const r = await fetch(`http://127.0.0.1:${server!.port}${path}`, { method, headers: { "x-herdr-machine": "1", "x-herdr-update": "1", "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
assert.ok(r.ok, `${path}: ${r.status} ${r.ok ? "" : await r.text()}`);
return r.json() as Promise<T>;
}
Expand Down
2 changes: 1 addition & 1 deletion scripts/ssh-password-docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async function pullImage(): Promise<string> {
throw refusal;
}
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const response = await fetch(`http://127.0.0.1:${server!.port}${path}`, { method, headers: { "x-herdr-machine": "1", "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const response = await fetch(`http://127.0.0.1:${server!.port}${path}`, { method, headers: { "x-herdr-machine": "1", "x-herdr-update": "1", "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
assert.ok(response.ok, `${path}: ${response.status} ${response.ok ? "" : await response.text()}`);
return response.json();
}
Expand Down
2 changes: 1 addition & 1 deletion scripts/windows-host-qa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ async function until<T>(read: () => Promise<T>, done: (value: T) => boolean, lab
throw new Error(`Timed out ${label}: ${JSON.stringify(last)}`);
}
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const r = await fetch(`http://127.0.0.1:${server.port}${path}`, { method, headers: { "x-herdr-machine": "1", "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const r = await fetch(`http://127.0.0.1:${server.port}${path}`, { method, headers: { "x-herdr-machine": "1", "x-herdr-update": "1", "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
assert.ok(r.ok, `${path}: ${r.status} ${r.ok ? "" : await r.text()}`);
return r.json() as Promise<T>;
}
Expand Down
10 changes: 10 additions & 0 deletions server/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,16 @@ export function portalLogin(input: Pick<AccessInput, "portal">): PortalLogin | n
return { user, method };
}

/**
* The owner, from any device he signed in or paired himself, for actions that change what runs on this PC's PCs
* (updates, installs, approving a setup, server/machine-api.ts): a driving sign-in on this PC, its token, its
* Tailscale login, or a driving device paired with this install, directly or over the relay (his phone, his other
* PCs). Never a visitor of an open install (nothing paired), a Portal visitor, or a device that only watches.
*/
export function ownerAccess(access: Access): boolean {
return access.level === "full" && access.role === "drive" && access.via !== "open" && access.via !== "portal";
}

export function decideAccess(input: AccessInput): Access {
if (input.relayPeer !== undefined) return { level: "full", via: "relay", role: input.relayPeer.role, device: input.relayPeer };
if (input.tokenMatched) return { level: "full", via: "token", role: "drive" };
Expand Down
4 changes: 2 additions & 2 deletions server/api.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1518,7 +1518,7 @@ describe("agent rename", () => {
const settings = await (await fetch(url)).json() as MachineSettings;
expect(settings).toEqual({ auto_update_bridges: true, set_aside: [expect.stringMatching(/^machines\.json\.corrupt-\S+$/)] });
expect(readdirSync(state)).toContain(settings.set_aside![0]!);
const patched = await fetch(url, { method: "PATCH", headers: { "content-type": "application/json", "x-herdr-machine": "1" }, body: JSON.stringify({ auto_update_bridges: false, set_aside: [] }) });
const patched = await fetch(url, { method: "PATCH", headers: { "content-type": "application/json", "x-herdr-machine": "1", "x-herdr-update": "1" }, body: JSON.stringify({ auto_update_bridges: false, set_aside: [] }) });
expect(patched.status).toBe(200);
expect(await patched.json()).toEqual({ auto_update_bridges: false, set_aside: settings.set_aside });
} finally {
Expand Down Expand Up @@ -4732,7 +4732,7 @@ describe("PC management API", () => {
const response = await fetch(`${base()}/api/machines/setup`, { method: "POST", headers, body: JSON.stringify({ destination: "host" }) });
expect(response.status).toBe(403);
}
const bad = await fetch(`${base()}/api/machines/setup`, { method: "POST", headers: { "x-herdr-machine": "1" }, body: JSON.stringify({ destination: "-oProxyCommand=bad" }) });
const bad = await fetch(`${base()}/api/machines/setup`, { method: "POST", headers: { "x-herdr-machine": "1", "x-herdr-update": "1" }, body: JSON.stringify({ destination: "-oProxyCommand=bad" }) });
expect(bad.status).toBe(400);
expect((await fetch(`${base()}/api/machines/missing/pane/read?pane_id=p1`)).status).toBe(503);
expect((await fetch(`${base()}/api/machines/missing/auth`)).status).toBe(404);
Expand Down
4 changes: 3 additions & 1 deletion server/core/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export function createStatusHub<Owner, Lease>({ options, devices, preferences, c
relayPcSocket?: (url: string) => RelaySocket;
/** how they dial those PCs' direct carriers; tests point it at a local listener */
relayPcDirectSocket?: (url: string) => RelaySocket;
/** how often another PC's own update is looked at while it runs; tests shorten it */
pcUpdateTimings?: { pollMs?: number; startMs?: number; checkMs?: number; offerMs?: number };
/** how other PCs on this PC's Tailscale are found (server/tailnet-pcs.ts); tests give their own status and addresses */
tailnetPcs?: TailnetPcsOptions;
sshCandidates?: SshCandidatesOptions;
Expand Down Expand Up @@ -365,7 +367,7 @@ export function createStatusHub<Owner, Lease>({ options, devices, preferences, c
/** A pane's status frame: every one says whether the pane waits on its turn's background work. */
const paneStatus = (paneId: string, status: AgentStatus, about: { background_tasks?: number; plan?: PlanSummary | null; context?: PaneContext | null } = {}): ServerMessage =>
({ type: "pane-status", pane_id: paneId, agent_status: status, ...finishes.status(paneId, status), ...about, ...(waits.waiting(paneId) ? { background_wait: true as const } : {}) });
const machines = options.machines === false ? null : new MachineManager(options.stateDir ?? defaultStateDir(), push, completions, clientSnapshot, { bootId: options.orderSequence?.epoch, localFinishEpoch: () => finishes.epoch, relaySocket: options.relayPcSocket, relayDirectSocket: options.relayPcDirectSocket, serverId: options.serverId, tailnet: options.tailnetPcs, sshCandidates: options.sshCandidates });
const machines = options.machines === false ? null : new MachineManager(options.stateDir ?? defaultStateDir(), push, completions, clientSnapshot, { bootId: options.orderSequence?.epoch, localFinishEpoch: () => finishes.epoch, relaySocket: options.relayPcSocket, relayDirectSocket: options.relayPcDirectSocket, serverId: options.serverId, tailnet: options.tailnetPcs, sshCandidates: options.sshCandidates, ...(options.pcUpdateTimings ? { ownUpdate: options.pcUpdateTimings } : {}) });
if (machines && options.herdrProfiles) machines.watchHerdrProfiles(options.herdrProfiles);
// on herdr-core, the core's other machines are PCs too, each reached through this PC's core
if (machines && servingCore()) machines.watchHcMachines(() => listHcMachines(coreConnection()), (machine) => startMachineBridge(machine, options.stateDir ?? defaultStateDir()));
Expand Down
10 changes: 8 additions & 2 deletions server/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { OrderSequence } from "./order-sequence.ts";
import type { DevicePairedEvent, DeviceRemovedEvent, HealthAuth, RelayPairingsEvent, RemotePairingsEvent, SecuritySummary, SessionSnapshot } from "../shared/protocol.ts";
import { DEFAULT_PORT } from "../shared/protocol.ts";
import { authClient, deviceAuthorizationValue, deviceCookieValue, bearerMatches, handleAuthRequest, isAuthenticated, isSecureRequest, parseCookies, presentedToken, presentedTokenHeld, recordPresentedTokenFailure, requiresAuth, unauthorizedJson } from "./auth.ts";
import { bindScope, cameThroughProxy, decideAccess, forwardingHeaders, inTailnet, isLoopbackAddress, isServeOwnerRequest, lanExposed, observedIngress } from "./access.ts";
import { bindScope, cameThroughProxy, decideAccess, forwardingHeaders, inTailnet, isLoopbackAddress, isServeOwnerRequest, lanExposed, observedIngress, ownerAccess } from "./access.ts";
import { type DeviceStore, handleDeviceRequest, newStreamKey, sameBrowser, streamCookie, streamKeys, type DeviceMatch } from "./devices.ts";
import { handlePreferencesRequest, type PreferenceStore } from "./preferences.ts";
import { handleSeenRequest, type SeenStore } from "./seen.ts";
Expand Down Expand Up @@ -391,7 +391,7 @@ export async function handleRequest(request: Request, ctx: ServerContext): Promi
if (!approver) listener({ type: "relay-pairings", pairings: [] });
return () => { stopPaired(); stopRemoved?.(); stopRelay?.(); };
} };
const response = await handleMachineRequest(request, machines, deviceId ? (close) => devices.onRevoke(deviceId, close) : portalRecheckMs !== undefined ? (close) => { const timer = setTimeout(close, portalRecheckMs); return () => clearTimeout(timer); } : undefined, preferences, readOnly, herdrSidebar, seen, pairings, ctx.relayBodyBudget);
const response = await handleMachineRequest(request, machines, deviceId ? (close) => devices.onRevoke(deviceId, close) : portalRecheckMs !== undefined ? (close) => { const timer = setTimeout(close, portalRecheckMs); return () => clearTimeout(timer); } : undefined, preferences, readOnly, herdrSidebar, seen, pairings, ctx.relayBodyBudget, ownerAccess(access));
response.headers.set("cache-control", "no-store");
if (issuedKey !== null && response.ok) response.headers.append("set-cookie", streamCookie(issuedKey, isSecureRequest(request)));
return response;
Expand Down Expand Up @@ -483,6 +483,12 @@ export async function handleRequest(request: Request, ctx: ServerContext): Promi
} catch (error) { return errorResponse(error); }
}

// installing an update on this PC (Ovis or herdr; a check installs too where OVIS_AUTO_UPDATE=1) is the owner's, as changing its PCs is (machine-api.ts): from any device
// he signed in or paired himself. The connection server's bridge token asks its own install by revision (bridgeUpdatePath)
if (request.method === "POST" && (pathname === "/api/updates/install" || pathname === "/api/updates/check" || pathname === "/api/herdr/update") && !ownerAccess(access) && !(bridgeAuthorized && bridgeUpdatePath)) {
setRequestRoute(pathname);
return jsonResponse({ error: { code: "forbidden", message: "Only the owner's own signed-in or paired devices can update this PC, from this app's own page." } }, 403);
}
if (pathname === "/api/updates" || pathname.startsWith("/api/updates/")) {
// the bridge token cannot read /api/access: where this server answers comes with its update status (#2191)
// the port is this server's own; a tailscale that cannot be read only leaves the https address out
Expand Down
2 changes: 1 addition & 1 deletion server/herdr-profiles.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ it("publishes inherited rows on the existing authenticated roster and refuses co
const profile: HerdrMachineProfile = { id: "catalog-test", label: "Saved PC", enabled: true, target: { destination: "fixture-host", session: "named-session" } };
const server = createServer({ port: 0, hostname: "127.0.0.1", stateDir, token: "test-token", tailscaleOwner: null, herdrProfiles: async () => [profile] });
const base = `http://127.0.0.1:${server.port}`;
const headers = { authorization: "Bearer test-token", "x-herdr-machine": "1", "content-type": "application/json" };
const headers = { authorization: "Bearer test-token", "x-herdr-machine": "1", "x-herdr-update": "1", "content-type": "application/json" };
try {
expect((await fetch(`${base}/api/machines`)).status).toBe(401);
const response = await fetch(`${base}/api/machines`, { headers });
Expand Down
Loading
Loading