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/NOTICE +3 -0
- package/README.md +5 -3
- package/package.json +3 -2
- package/src/omp-extension.mjs +88 -34
- package/src/relay-control.mjs +104 -0
- package/src/relay.mjs +368 -0
- package/src/server.mjs +132 -12
- package/src/state.mjs +36 -0
- package/vendor/LICENSE.dsh-remote +21 -0
- package/vendor/README.md +9 -0
- package/vendor/dsh-remote-host.mjs +20109 -0
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
|
-
|
|
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
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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 (
|
|
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 (!
|
|
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
|
|
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.
|
package/vendor/README.md
ADDED
|
@@ -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).
|