omp-mobile 0.4.4 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/relay.mjs ADDED
@@ -0,0 +1,368 @@
1
+ /**
2
+ * The daemon as a DSH Remote Host: it registers with a relay server (by
3
+ * default the public https://dsh.r2049.cn of ds-harness-remote) through
4
+ * account-less device pairing, and serves its own HTTP + WebSocket API to
5
+ * paired phones through that relay, end-to-end encrypted (Noise IK, pinned
6
+ * keys). The relay only forwards ciphertext; nothing of DSH runs here.
7
+ *
8
+ * Over the encrypted channel the phone speaks a small tunnel (`createTunnelRouter`):
9
+ * rpc `omp.http.open` {id, method, path, headers?, body? (text)} -> {status, headers}
10
+ * rpc `omp.ws.open` {id, path} -> {}
11
+ * rpc `omp.ws.send` {id, text} -> {}
12
+ * rpc `omp.close` {id} -> {}
13
+ * event `omp.data` {id, text? (a text body or WebSocket message part), data? (base64 of a binary body),
14
+ * more? (the WebSocket message continues), done?, error?}
15
+ * Requests go to the daemon's loopback tunnel listener with its token added,
16
+ * so a paired phone gets exactly what a phone on the tailnet gets.
17
+ */
18
+ import { request as httpRequest } from "node:http";
19
+ import { StringDecoder } from "node:string_decoder";
20
+ import { WebSocket } from "ws";
21
+ import {
22
+ ConnectionController,
23
+ HostPairing,
24
+ HostServerApi,
25
+ HostServerConnection,
26
+ IdentityStore,
27
+ SafeLogger,
28
+ ServerCredentialStore,
29
+ createEvent,
30
+ createRpcError,
31
+ createRpcResponse,
32
+ } from "../vendor/dsh-remote-host.mjs";
33
+
34
+ /** Marks a request that came through the relay tunnel (set by the tunnel, never by the phone). */
35
+ export const RELAY_HEADER = "x-omp-mobile-relay";
36
+ /** Characters per text part and bytes per base64 part (a multiple of 3): well under the 4 MiB secure message cap. */
37
+ const TEXT_CHUNK_CHARS = 1024 * 1024;
38
+ const BINARY_PART_BYTES = 3 * 256 * 1024;
39
+ /** A binary answer (an image) is sent whole: at most this large. */
40
+ const MAX_BINARY_BODY = 32 * 1024 * 1024;
41
+ const MAX_HANDLES = 64;
42
+ const MAX_REQUEST_BODY = 16 * 1024 * 1024;
43
+ const ID = /^[A-Za-z0-9_-]{1,64}$/;
44
+ const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
45
+ /** Headers the phone may pass on; the token and the relay mark are the tunnel's own. */
46
+ const PASSED_HEADERS = new Set(["accept", "content-type", "x-omp-mobile-probe"]);
47
+
48
+ class TunnelError extends Error {
49
+ constructor(code, message) {
50
+ super(message);
51
+ this.code = code;
52
+ }
53
+ }
54
+
55
+ /** A daemon path the phone may ask for: absolute, no scheme or host, and never the local `/relay` control. */
56
+ function tunnelPath(value) {
57
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.length > 8192 || /[\s\\]/.test(value)) {
58
+ throw new TunnelError("INVALID_MESSAGE", "Invalid path.");
59
+ }
60
+ const url = new URL(value, "http://tunnel");
61
+ if (url.pathname === "/relay" || url.pathname.startsWith("/relay/")) throw new TunnelError("METHOD_NOT_ALLOWED", "Relay control is local only.");
62
+ return url;
63
+ }
64
+
65
+ /**
66
+ * One phone connection's tunnel to the daemon at 127.0.0.1:`port`.
67
+ * @param {{port: number, token: string}} target
68
+ * @param {(message: object) => Promise<void>} send Encrypted delivery to that phone.
69
+ */
70
+ export function createTunnelRouter(target, send) {
71
+ /** @type {Map<string, {close(): void, socket?: WebSocket}>} */
72
+ const handles = new Map();
73
+ let out = Promise.resolve();
74
+ /** Events go out in order; a failed send (the phone left) ends the handle. */
75
+ const emit = (data) => {
76
+ out = out.then(() => send(createEvent("omp.data", data))).catch(() => handles.get(data.id)?.close());
77
+ return out;
78
+ };
79
+ const claim = (id) => {
80
+ if (typeof id !== "string" || !ID.test(id)) throw new TunnelError("INVALID_MESSAGE", "Invalid handle id.");
81
+ if (handles.has(id)) throw new TunnelError("REQUEST_CONFLICT", "Handle already open.");
82
+ if (handles.size >= MAX_HANDLES) throw new TunnelError("RATE_LIMITED", "Too many open requests.");
83
+ };
84
+
85
+ const openHttp = (params) => {
86
+ claim(params.id);
87
+ const { id } = params;
88
+ const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
89
+ if (!METHODS.has(method)) throw new TunnelError("INVALID_MESSAGE", "Invalid method.");
90
+ const url = tunnelPath(params.path);
91
+ const headers = { authorization: `Bearer ${target.token}`, [RELAY_HEADER]: "1" };
92
+ for (const [key, value] of Object.entries(params.headers ?? {})) {
93
+ if (PASSED_HEADERS.has(key.toLowerCase()) && typeof value === "string") headers[key.toLowerCase()] = value;
94
+ }
95
+ const body = typeof params.body === "string" ? Buffer.from(params.body, "utf8") : undefined;
96
+ if (body !== undefined && body.length > MAX_REQUEST_BODY) throw new TunnelError("FRAME_TOO_LARGE", "Request body too large.");
97
+ if (body !== undefined) headers["content-length"] = String(body.length);
98
+ return new Promise((resolveOpen, rejectOpen) => {
99
+ let settled = false;
100
+ const request = httpRequest({ host: "127.0.0.1", port: target.port, method, path: `${url.pathname}${url.search}`, headers, agent: false }, (response) => {
101
+ settled = true;
102
+ const type = String(response.headers["content-type"] ?? "");
103
+ resolveOpen({ status: response.statusCode ?? 502, headers: { "content-type": type } });
104
+ let ended = false;
105
+ // Text (JSON, the `/events` stream) goes as text, whole characters only; anything
106
+ // else (an image from `/blobs`) is collected and sent as base64 in 3-byte-aligned parts,
107
+ // so the phone can join the parts without decoding them.
108
+ const text = /^(text\/|application\/json)/.test(type) ? new StringDecoder("utf8") : undefined;
109
+ const bytes = [];
110
+ let total = 0;
111
+ response.on("data", (chunk) => {
112
+ if (text === undefined) {
113
+ total += chunk.length;
114
+ if (total > MAX_BINARY_BODY) request.destroy();
115
+ else bytes.push(chunk);
116
+ return;
117
+ }
118
+ const part = text.write(chunk);
119
+ if (part === "") return;
120
+ response.pause();
121
+ let sent = Promise.resolve();
122
+ for (let at = 0; at < part.length; at += TEXT_CHUNK_CHARS) sent = emit({ id, text: part.slice(at, at + TEXT_CHUNK_CHARS) });
123
+ void sent.then(() => response.resume());
124
+ });
125
+ response.on("end", () => {
126
+ ended = true;
127
+ handles.delete(id);
128
+ const rest = text?.end() ?? "";
129
+ if (rest !== "") void emit({ id, text: rest });
130
+ const all = Buffer.concat(bytes);
131
+ for (let at = 0; at < all.length; at += BINARY_PART_BYTES) void emit({ id, data: all.subarray(at, at + BINARY_PART_BYTES).toString("base64") });
132
+ void emit({ id, done: true });
133
+ });
134
+ response.on("close", () => {
135
+ if (ended || !handles.has(id)) return;
136
+ handles.delete(id);
137
+ void emit({ id, done: true, error: "closed" });
138
+ });
139
+ });
140
+ handles.set(id, { close: () => { handles.delete(id); request.destroy(); } });
141
+ request.on("error", () => {
142
+ handles.delete(id);
143
+ if (!settled) rejectOpen(new TunnelError("HARNESS_UNAVAILABLE", "The daemon did not answer."));
144
+ });
145
+ request.end(body);
146
+ });
147
+ };
148
+
149
+ const openWs = (params) => {
150
+ claim(params.id);
151
+ const { id } = params;
152
+ const url = tunnelPath(params.path);
153
+ url.searchParams.set("token", target.token);
154
+ return new Promise((resolveOpen, rejectOpen) => {
155
+ const socket = new WebSocket(`ws://127.0.0.1:${target.port}${url.pathname}${url.search}`, {
156
+ headers: { [RELAY_HEADER]: "1" },
157
+ perMessageDeflate: false,
158
+ maxPayload: 256 * 1024 * 1024,
159
+ });
160
+ let opened = false;
161
+ handles.set(id, { socket, close: () => { handles.delete(id); socket.terminate(); } });
162
+ socket.on("open", () => {
163
+ opened = true;
164
+ resolveOpen({});
165
+ });
166
+ socket.on("message", (data, binary) => {
167
+ if (binary) return;
168
+ const text = data.toString("utf8");
169
+ socket.pause();
170
+ let sent = Promise.resolve();
171
+ const parts = Math.max(1, Math.ceil(text.length / TEXT_CHUNK_CHARS));
172
+ for (let index = 0; index < parts; index += 1) {
173
+ sent = emit({ id, text: text.slice(index * TEXT_CHUNK_CHARS, (index + 1) * TEXT_CHUNK_CHARS), more: index < parts - 1 });
174
+ }
175
+ void sent.then(() => socket.resume());
176
+ });
177
+ socket.on("close", (code) => {
178
+ const known = handles.delete(id);
179
+ if (!opened) rejectOpen(new TunnelError("HARNESS_UNAVAILABLE", `The daemon refused the socket (${code}).`));
180
+ else if (known) void emit({ id, done: true, code });
181
+ });
182
+ socket.on("error", () => undefined);
183
+ });
184
+ };
185
+
186
+ const call = async (method, params) => {
187
+ if (params === null || typeof params !== "object") throw new TunnelError("INVALID_MESSAGE", "Missing parameters.");
188
+ switch (method) {
189
+ case "omp.http.open":
190
+ return openHttp(params);
191
+ case "omp.ws.open":
192
+ return openWs(params);
193
+ case "omp.ws.send": {
194
+ const socket = handles.get(params.id)?.socket;
195
+ if (socket === undefined || typeof params.text !== "string") throw new TunnelError("CONNECTION_NOT_FOUND", "No such socket.");
196
+ await new Promise((resolveSend, rejectSend) => socket.send(params.text, (error) => (error ? rejectSend(new TunnelError("CONNECTION_FAILED", "Socket closed.")) : resolveSend())));
197
+ return {};
198
+ }
199
+ case "omp.close":
200
+ handles.get(params.id)?.close();
201
+ return {};
202
+ default:
203
+ throw new TunnelError("METHOD_NOT_FOUND", `Unknown method ${String(method)}.`);
204
+ }
205
+ };
206
+
207
+ return {
208
+ async handle(message) {
209
+ if (message.type !== "rpc.request") return createRpcError(message.id, "INVALID_MESSAGE", "Only RPC requests are accepted.");
210
+ const { method, params } = message.payload ?? {};
211
+ try {
212
+ return createRpcResponse(message.id, await call(method, params));
213
+ } catch (error) {
214
+ const code = error instanceof TunnelError ? error.code : "HARNESS_UNAVAILABLE";
215
+ return createRpcError(message.id, code, error instanceof Error ? error.message : String(error));
216
+ }
217
+ },
218
+ closePeerStreams() {
219
+ for (const handle of [...handles.values()]) handle.close();
220
+ },
221
+ };
222
+ }
223
+
224
+ /**
225
+ * This daemon's Host identity, pairing and relay connection on one server.
226
+ * State (identity key, device tokens, pinned phones, a pairing in progress)
227
+ * lives in `dir`, owner-only.
228
+ */
229
+ export class RelayHost {
230
+ /**
231
+ * @param {{serverUrl: string, dir: string, name: string, version: string, port: () => number, token: string, log: (line: string) => void}} options
232
+ */
233
+ constructor(options) {
234
+ this.options = options;
235
+ this.serverUrl = options.serverUrl.replace(/\/+$/, "");
236
+ this.pairing = undefined;
237
+ this.connection = undefined;
238
+ }
239
+
240
+ async start() {
241
+ const { dir, name, log } = this.options;
242
+ this.identities = new IdentityStore({ directory: dir });
243
+ this.identity = await this.identities.loadOrCreate(name);
244
+ this.api = new HostServerApi(this.serverUrl, new ServerCredentialStore(this.identities.directory));
245
+ this.api.bindIdentity(this.identity);
246
+ const write = (level) => (line) => log(`relay ${level}: ${line}`);
247
+ this.logger = new SafeLogger({ debug() {}, info: write("info"), warn: write("warn"), error: write("error") });
248
+ this.connections = new ConnectionController(
249
+ this.identities,
250
+ (_context, send) => createTunnelRouter({ port: this.options.port(), token: this.options.token }, send),
251
+ this.logger,
252
+ );
253
+ if (await this.api.hasStoredAuthorization()) this.connect();
254
+ }
255
+
256
+ /** Go (back) online with the device tokens a pairing left. */
257
+ connect() {
258
+ if (this.connection !== undefined) {
259
+ this.connection.reconnect();
260
+ return;
261
+ }
262
+ const config = {
263
+ serverUrl: this.serverUrl,
264
+ // No WebRTC on the computer: phone traffic always goes through the relay, still end-to-end encrypted.
265
+ forceRelay: true,
266
+ rotatePinnedPeerKey: false,
267
+ reconnect: { enabled: true, initialDelayMs: 2_000, maxDelayMs: 30_000, jitter: 0.2 },
268
+ };
269
+ this.connection = new HostServerConnection(config, this.identity, this.identities, this.api, this.connections, this.logger);
270
+ this.connection.start();
271
+ }
272
+
273
+ /** The pairing workflow; its state survives restarts in `dir`. */
274
+ hostPairing() {
275
+ this.pairing ??= new HostPairing(
276
+ this.serverUrl,
277
+ this.identities.directory,
278
+ this.identity,
279
+ {
280
+ deviceId: this.identity.deviceId,
281
+ name: this.identity.name,
282
+ role: "host",
283
+ platform: process.platform,
284
+ identityKey: this.identity.publicKey,
285
+ clientVersion: `omp-mobile/${this.options.version}`,
286
+ },
287
+ async (credentials) => {
288
+ await this.api.adoptPairing(credentials);
289
+ this.connect();
290
+ },
291
+ );
292
+ return this.pairing;
293
+ }
294
+
295
+ /** Start a pairing: the view carries `qrPayload`, the link the phone scans. */
296
+ async pairStart() {
297
+ // A finished pairing is left on disk, and asking the server about it again can fail
298
+ // (MEMBERSHIP_REQUIRED once its phone was revoked): drop it; an open one `start` cancels itself.
299
+ const previous = await this.hostPairing()
300
+ .status()
301
+ .catch(() => undefined);
302
+ if (previous === undefined || !["created", "claimed", "approved"].includes(previous.state)) {
303
+ await this.pairing.discard();
304
+ this.pairing = undefined;
305
+ }
306
+ return this.hostPairing().start();
307
+ }
308
+
309
+ /** Where the pairing stands (a phone claimed it: `mobile` names it); undefined when none. */
310
+ pairStatus() {
311
+ return this.hostPairing().status();
312
+ }
313
+
314
+ /** Approve the phone with the 8-digit code it shows. One attempt: a wrong code ends the pairing. */
315
+ pairConfirm(sessionId, code) {
316
+ return this.hostPairing().confirm(sessionId, code);
317
+ }
318
+
319
+ pairCancel(sessionId) {
320
+ return this.hostPairing().cancel(sessionId);
321
+ }
322
+
323
+ /** Paired phones, as the server lists them, and whether each is connected now. */
324
+ async phones() {
325
+ if (!(await this.api.hasStoredAuthorization())) return [];
326
+ const connected = new Set(this.connections.peerDeviceIds());
327
+ // `/devices` is for client devices; a host lists its phones through its memberships.
328
+ const { accessToken } = await this.api.authenticate();
329
+ const response = await fetch(`${this.serverUrl}/api/v1/device-memberships?limit=100`, { headers: { authorization: `Bearer ${accessToken}` } });
330
+ if (!response.ok) throw new Error(`The relay refused the phone list (HTTP ${response.status}).`);
331
+ const { items } = await response.json();
332
+ return (Array.isArray(items) ? items : []).map(({ peer }) => ({
333
+ deviceId: peer.deviceId,
334
+ name: peer.name,
335
+ platform: peer.platform,
336
+ connected: connected.has(peer.deviceId),
337
+ }));
338
+ }
339
+
340
+ /** Revoke one phone: the server ends its membership; its connection closes now. */
341
+ async revoke(deviceId) {
342
+ const { accessToken } = await this.api.authenticate();
343
+ const response = await fetch(`${this.serverUrl}/api/v1/device-memberships/${encodeURIComponent(deviceId)}`, {
344
+ method: "DELETE",
345
+ headers: { authorization: `Bearer ${accessToken}` },
346
+ });
347
+ if (!response.ok && response.status !== 403) throw new Error(`The relay refused the revoke (HTTP ${response.status}).`);
348
+ await this.connections.revoke(deviceId);
349
+ }
350
+
351
+ async status() {
352
+ // Before `start` finished there is no identity yet: not paired, not online.
353
+ return {
354
+ server: this.serverUrl,
355
+ deviceId: this.identity?.deviceId ?? null,
356
+ paired: (await this.api?.hasStoredAuthorization()) ?? false,
357
+ online: this.connection?.isOnline() ?? false,
358
+ connected: this.connections?.connectionCount() ?? 0,
359
+ };
360
+ }
361
+
362
+ async close() {
363
+ await this.connection?.stop().catch(() => undefined);
364
+ await this.connections?.close().catch(() => undefined);
365
+ this.pairing?.close();
366
+ this.api?.close();
367
+ }
368
+ }
package/src/server.mjs CHANGED
@@ -21,6 +21,8 @@ import { AttentionTracker, serveEvents } from "./attention.mjs";
21
21
  import { listOmpSessions, oneLine, promptTitle, sessionOwners } from "./omp-sessions.mjs";
22
22
  import { askOwnerToLeave, forksOf, isOmpCommand, processInfo, sessionBusy, takeoverFarewell, tellTerminal } from "./takeover.mjs";
23
23
  import { TranscriptStore, deltaBody, deltaQuery } from "./transcripts.mjs";
24
+ import { RELAY_HEADER, RelayHost } from "./relay.mjs";
25
+ import { DEFAULT_RELAY, runRelayCli } from "./relay-control.mjs";
24
26
 
25
27
  const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
26
28
  const PROTOCOL = 1;
@@ -28,6 +30,7 @@ const DEFAULT_PORT = 7390;
28
30
  const DEFAULT_TOKEN_FILE = join(homedir(), ".omp-mobile", "token");
29
31
  const DEFAULT_REGISTRY_FILE = join(homedir(), ".omp-mobile", "sessions.json");
30
32
  const DEFAULT_STATE_FILE = join(homedir(), ".omp-mobile", "daemon.json");
33
+ const DEFAULT_RELAY_DIR = join(homedir(), ".omp-mobile", "relay");
31
34
  const DEFAULT_OMP_HOME = join(homedir(), ".omp");
32
35
  const MAX_BODY = 256 * 1024;
33
36
  /** Terminal sessions listed next to the daemon's own, newest first. */
@@ -146,6 +149,8 @@ function parseArgs(argv) {
146
149
  tailscale: "tailscale",
147
150
  whois: true,
148
151
  qr: true,
152
+ relay: "",
153
+ relayDir: DEFAULT_RELAY_DIR,
149
154
  };
150
155
  for (let i = 0; i < argv.length; i += 1) {
151
156
  const flag = argv[i];
@@ -208,6 +213,19 @@ function parseArgs(argv) {
208
213
  case "--no-qr":
209
214
  options.qr = false;
210
215
  break;
216
+ case "--relay":
217
+ // The server is optional: `--relay` alone means the public ds-harness-remote relay.
218
+ if (value !== undefined && !value.startsWith("--")) {
219
+ options.relay = value;
220
+ i += 1;
221
+ } else {
222
+ options.relay = DEFAULT_RELAY;
223
+ }
224
+ break;
225
+ case "--relay-dir":
226
+ options.relayDir = resolve(value);
227
+ i += 1;
228
+ break;
211
229
  case "--help":
212
230
  case "-h":
213
231
  printHelp();
@@ -245,6 +263,14 @@ Admission: on a non-loopback --host every request and WebSocket upgrade must nam
245
263
  this node in Host (421 otherwise) and come from a tailnet device of this node's
246
264
  user (403 otherwise), before the token is checked. Loopback hosts skip admission.
247
265
  --no-qr Do not print the pairing QR code at startup
266
+ --relay [url] Also serve paired phones through a ds-harness-remote relay (default ${DEFAULT_RELAY}),
267
+ end-to-end encrypted; pair with \`omp-mobile relay pair\` or /mobile relay
268
+ --relay-dir <dir> Relay identity and device tokens (default ~/.omp-mobile/relay)
269
+
270
+ Relay commands (talk to the running daemon; same --state-file):
271
+ omp-mobile relay pair Show a pairing QR code, then confirm the phone's 8-digit code
272
+ omp-mobile relay status Relay server, connection and paired phones
273
+ omp-mobile relay revoke <phone device id>
248
274
  `);
249
275
  }
250
276
 
@@ -746,6 +772,29 @@ class Daemon {
746
772
  // the phone's WebSocket client (OkHttp) asks for permessage-deflate on its own.
747
773
  this.wsServer = new WebSocketServer({ noServer: true, perMessageDeflate: { threshold: 1_024 } });
748
774
  this.server.on("upgrade", (req, socket, head) => void this.admitUpgrade(req, socket, head));
775
+ /**
776
+ * Phones paired through a relay reach the daemon through this loopback-only
777
+ * listener (`relay.mjs` adds the token): the same API, without the tailnet admission.
778
+ */
779
+ this.tunnelServer = undefined;
780
+ /** @type {RelayHost | undefined} */
781
+ this.relay = undefined;
782
+ if (options.relay) {
783
+ this.tunnelServer = createServer((req, res) => void this.handleHttp(req, res));
784
+ this.tunnelServer.on("upgrade", (req, socket, head) => {
785
+ socket.on("error", () => socket.destroy());
786
+ this.handleUpgrade(req, socket, head);
787
+ });
788
+ this.relay = new RelayHost({
789
+ serverUrl: options.relay,
790
+ dir: options.relayDir,
791
+ name: `omp-mobile on ${hostname().replace(/\.local$/, "")}`,
792
+ version: VERSION,
793
+ port: () => this.tunnelServer.address().port,
794
+ token: this.token,
795
+ log: (line) => this.log(line),
796
+ });
797
+ }
749
798
  }
750
799
 
751
800
  baseUrl() {
@@ -828,9 +877,14 @@ class Daemon {
828
877
  liveSessions: this.sessions.size,
829
878
  eventStreams: this.streams.size,
830
879
  lastPhone: this.lastPhone,
880
+ relay: this.relay === undefined ? null : await this.relay.status(),
831
881
  });
832
882
  return;
833
883
  }
884
+ if (url.pathname === "/relay" || url.pathname.startsWith("/relay/")) {
885
+ await this.handleRelay(req, res, url);
886
+ return;
887
+ }
834
888
  if (url.pathname === "/events" && req.method === "GET") {
835
889
  const end = serveEvents(this.attention, req, res);
836
890
  this.streams.add(end);
@@ -1328,17 +1382,72 @@ class Daemon {
1328
1382
  */
1329
1383
  noteClient(req) {
1330
1384
  if (req.headers["x-omp-mobile-probe"] !== undefined) return;
1331
- const address = String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/i, "");
1385
+ // Through the relay every request comes from loopback: name the path instead.
1386
+ const address = req.headers[RELAY_HEADER] !== undefined ? "relay" : String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/i, "");
1332
1387
  this.lastPhone = { address, at: Date.now() };
1333
1388
  }
1334
1389
 
1335
- listen() {
1336
- return new Promise((resolveListen) => {
1337
- this.server.listen(this.options.port, this.options.host, () => {
1338
- this.startTimers();
1339
- resolveListen();
1340
- });
1341
- });
1390
+ /**
1391
+ * Relay pairing and phones, for `omp-mobile relay` and `/mobile relay` on this
1392
+ * computer; never through the relay itself (a paired phone cannot pair or revoke).
1393
+ */
1394
+ async handleRelay(req, res, url) {
1395
+ if (req.headers[RELAY_HEADER] !== undefined) {
1396
+ sendJson(res, 403, { error: "relay control is local only" });
1397
+ return;
1398
+ }
1399
+ if (this.relay === undefined) {
1400
+ sendJson(res, 409, { error: "the daemon runs without --relay" });
1401
+ return;
1402
+ }
1403
+ try {
1404
+ if (url.pathname === "/relay" && req.method === "GET") {
1405
+ sendJson(res, 200, { ...(await this.relay.status()), phones: await this.relay.phones() });
1406
+ return;
1407
+ }
1408
+ if (url.pathname === "/relay/pair" && req.method === "POST") {
1409
+ sendJson(res, 200, await this.relay.pairStart());
1410
+ return;
1411
+ }
1412
+ if (url.pathname === "/relay/pair" && req.method === "GET") {
1413
+ sendJson(res, 200, { pairing: (await this.relay.pairStatus()) ?? null });
1414
+ return;
1415
+ }
1416
+ if ((url.pathname === "/relay/pair/confirm" || url.pathname === "/relay/pair/cancel") && req.method === "POST") {
1417
+ const body = await readBody(req);
1418
+ if (typeof body.sessionId !== "string") {
1419
+ sendJson(res, 400, { error: "sessionId is required" });
1420
+ return;
1421
+ }
1422
+ const confirm = url.pathname.endsWith("/confirm");
1423
+ if (confirm && (typeof body.code !== "string" || !/^\d{8}$/.test(body.code))) {
1424
+ sendJson(res, 400, { error: "the code is 8 digits" });
1425
+ return;
1426
+ }
1427
+ sendJson(res, 200, confirm ? await this.relay.pairConfirm(body.sessionId, body.code) : await this.relay.pairCancel(body.sessionId));
1428
+ return;
1429
+ }
1430
+ const phone = /^\/relay\/phones\/([^/]+)$/.exec(url.pathname);
1431
+ if (phone !== null && req.method === "DELETE") {
1432
+ await this.relay.revoke(decodeURIComponent(phone[1]));
1433
+ sendJson(res, 200, { ok: true });
1434
+ return;
1435
+ }
1436
+ sendJson(res, 404, { error: "not found" });
1437
+ } catch (error) {
1438
+ // The relay's own refusals carry a code (PAIRING_EXPIRED, PAIRING_ATTEMPT_USED, ...): pass it on.
1439
+ const code = typeof error?.code === "string" ? error.code : undefined;
1440
+ sendJson(res, 502, { error: error instanceof Error ? error.message : String(error), ...(code === undefined ? {} : { code }) });
1441
+ }
1442
+ }
1443
+
1444
+ async listen() {
1445
+ await new Promise((resolveListen) => this.server.listen(this.options.port, this.options.host, () => resolveListen()));
1446
+ if (this.relay !== undefined) {
1447
+ await new Promise((resolveListen) => this.tunnelServer.listen(0, "127.0.0.1", () => resolveListen()));
1448
+ await this.relay.start();
1449
+ }
1450
+ this.startTimers();
1342
1451
  }
1343
1452
 
1344
1453
  /** Keepalive pings and the idle-session reaper, once the daemon listens; old stashed images go. */
@@ -1395,7 +1504,9 @@ class Daemon {
1395
1504
  const outcomes = await Promise.all(closing);
1396
1505
  const stuck = outcomes.filter((outcome) => outcome === "running").length;
1397
1506
  if (stuck > 0) console.error(`omp-mobile: ${stuck} omp process(es) ignored SIGINT and SIGTERM and are still running`);
1507
+ await this.relay?.close();
1398
1508
  this.wsServer.close();
1509
+ this.tunnelServer?.close();
1399
1510
  await new Promise((resolveClose) => this.server.close(() => resolveClose()));
1400
1511
  }
1401
1512
  }
@@ -1473,6 +1584,10 @@ function clearState(path) {
1473
1584
  }
1474
1585
 
1475
1586
  async function main() {
1587
+ if (process.argv[2] === "relay") {
1588
+ await runRelayCli(process.argv.slice(3));
1589
+ return;
1590
+ }
1476
1591
  const options = parseArgs(process.argv.slice(2));
1477
1592
  if (options.host === "tailscale") options.host = await tailscaleHost(options);
1478
1593
  const token = loadToken(options);
@@ -1481,21 +1596,26 @@ async function main() {
1481
1596
  await daemon.listen();
1482
1597
  const url = daemon.baseUrl();
1483
1598
  const pair = `omp-mobile://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
1484
- writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, startedAt: daemon.startedAt });
1599
+ writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, relay: options.relay || null, startedAt: daemon.startedAt });
1485
1600
  console.log(`omp-mobile daemon ${VERSION}`);
1486
1601
  console.log(` listening ${url}`);
1487
1602
  console.log(` cwd ${options.cwd}`);
1488
1603
  for (const line of identity.notes) (line.includes("WARNING") ? console.warn : console.log)(line);
1489
1604
  console.log(` token ${token}`);
1490
1605
  console.log(` pair ${pair}`);
1491
- if (options.qr) {
1606
+ if (daemon.relay !== undefined) {
1607
+ const relay = await daemon.relay.status();
1608
+ console.log(` relay ${relay.server} · ${relay.paired ? "paired" : "no phone paired yet"} · pair a phone: omp-mobile relay pair`);
1609
+ }
1610
+ const tailnet = isTailnetAddress(options.host);
1611
+ if (options.qr && (tailnet || daemon.relay === undefined)) {
1492
1612
  console.log("");
1493
1613
  console.log(" Scan with OMP on your phone (Machines > Scan to pair):");
1494
1614
  console.log(renderQr(pair));
1495
1615
  // The app accepts only tailnet addresses from a code; say so before the user tries.
1496
- if (!isTailnetAddress(options.host)) {
1616
+ if (!tailnet) {
1497
1617
  console.log(` note: ${options.host} is not a Tailscale address, so the app will refuse this code.`);
1498
- console.log(" Start with --host <this computer's Tailscale IP> to pair a phone.");
1618
+ console.log(" Start with --host <this computer's Tailscale IP>, or --relay, to pair a phone.");
1499
1619
  }
1500
1620
  }
1501
1621
  const shutdown = async () => {
package/src/state.mjs ADDED
@@ -0,0 +1,36 @@
1
+ /** The running daemon's state file, as `/mobile` and `omp-mobile relay` read it. */
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export const STATE_FILE = join(homedir(), ".omp-mobile", "daemon.json");
7
+
8
+ /**
9
+ * The running daemon's record, if its process is alive.
10
+ * @returns {{pid: number, version: string, url: string, pair: string, relay?: string | null, startedAt: number} | undefined}
11
+ */
12
+ export function readState(path = STATE_FILE) {
13
+ let state;
14
+ try {
15
+ state = JSON.parse(readFileSync(path, "utf8"));
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ if (typeof state?.pid !== "number" || typeof state.url !== "string" || typeof state.pair !== "string") return undefined;
20
+ return alive(state.pid) ? state : undefined;
21
+ }
22
+
23
+ export function alive(pid) {
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ } catch (error) {
28
+ // EPERM: it exists but belongs to someone else.
29
+ return error?.code === "EPERM";
30
+ }
31
+ }
32
+
33
+ /** The token inside a pairing link. */
34
+ export function tokenOf(pair) {
35
+ return new URL(pair.replace(/^omp-mobile:\/\//, "http://x/")).searchParams.get("token") ?? "";
36
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DSH Remote contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ `dsh-remote-host.mjs` is the Host side of ds-harness-remote's relay (device pairing, the
2
+ relay control connection and its Noise IK channel), bundled from
3
+ [liguobao/ds-harness-remote](https://github.com/liguobao/ds-harness-remote) at commit
4
+ ea1c0925f8be825a5f5afac32ad2d0b2c699f7f7 with `bun build --target=node --format=esm` over an
5
+ entry that re-exports `HostPairing`, `HostServerConnection`, `ConnectionController`,
6
+ `IdentityStore`, `HostServerApi`, `ServerCredentialStore` and `SafeLogger` from
7
+ `packages/plugin/src`, and `createEvent`, `createRpcError`, `createRpcResponse` and
8
+ `MAX_SECURE_MESSAGE_BYTES` from `@dsh-remote/protocol`. Not edited here; see
9
+ `LICENSE.dsh-remote` (MIT).