@sksoftofficial/ocduet 0.2.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/README.md +1 -0
- package/bin/ocduet.js +61 -0
- package/package.json +33 -0
- package/src/commands/install.js +222 -0
- package/src/commands/relay.js +151 -0
- package/src/commands/service.js +164 -0
- package/src/commands/token.js +49 -0
- package/src/commands/uninstall.js +86 -0
- package/src/daemon.js +747 -0
- package/src/e2ee.js +141 -0
- package/src/paths.js +137 -0
- package/src/plugin/ocduet-server.js +609 -0
- package/src/plugin/ocduet-sidebar.jsx +309 -0
- package/src/plugin/web/app.css +290 -0
- package/src/plugin/web/app.js +1716 -0
- package/src/plugin/web/index.html +70 -0
- package/src/plugin/web/pair.html +131 -0
- package/src/qrcodegen.js +741 -0
- package/src/qrterm.js +5 -0
- package/src/relay-client.js +172 -0
- package/src/relayer.js +337 -0
package/src/qrterm.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// ocduet relay client — the desktop half of the relayer tunnel.
|
|
2
|
+
// Runs inside the daemon process. Keeps one persistent outbound connection
|
|
3
|
+
// to the relayer (`ocduet relay link <url> <secret>` writes the identity),
|
|
4
|
+
// replays relayed http requests against the daemon's own loopback server and
|
|
5
|
+
// splices relayed phone websockets onto the loopback upgrade path — so the
|
|
6
|
+
// existing E2EE phone protocol runs end-to-end, phone to daemon, with the
|
|
7
|
+
// relayer only ever seeing ciphertext.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { WebSocket } from "ws";
|
|
12
|
+
import { dataDir } from "./paths.js";
|
|
13
|
+
|
|
14
|
+
const REG_MSG = (desktopId, pubB64, ts) => `ocduet-relay-reg|${desktopId}|${pubB64}|${ts}`;
|
|
15
|
+
const HTTP_RESP_LIMIT = 10 * 1024 * 1024;
|
|
16
|
+
const BACKOFF_MIN_MS = 3_000;
|
|
17
|
+
const BACKOFF_MAX_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
export function relayConfigPath() {
|
|
20
|
+
return path.join(dataDir(), "relay.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readRelayConfig() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = JSON.parse(fs.readFileSync(relayConfigPath(), "utf8"));
|
|
26
|
+
if (raw?.url && raw?.desktopId && raw?.priv && raw?.pub) return raw;
|
|
27
|
+
} catch {}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// cert pinning for self-signed relayers: the exact cert PEM is captured at
|
|
32
|
+
// `relay link` time and trusted as its own CA on every connection. Mutual
|
|
33
|
+
// auth still comes from the ed25519 register signature — the pin kills
|
|
34
|
+
// silent MITM even without a domain.
|
|
35
|
+
export function relayTlsOpts(cfg) {
|
|
36
|
+
return cfg?.certPem ? { ca: cfg.certPem } : { rejectUnauthorized: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const state = { linked: false, connected: false, url: null, desktopId: null, since: null, lastError: null, reconnects: 0 };
|
|
40
|
+
let stopped = false;
|
|
41
|
+
|
|
42
|
+
export function relayStatus() {
|
|
43
|
+
return { ...state };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function stopRelayClient() {
|
|
47
|
+
stopped = true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function startRelayClient({ localPort }) {
|
|
51
|
+
const cfg = readRelayConfig();
|
|
52
|
+
if (!cfg) return null;
|
|
53
|
+
const priv = crypto.createPrivateKey({ key: Buffer.from(cfg.priv, "base64"), format: "der", type: "pkcs8" });
|
|
54
|
+
const pubB64 = cfg.pub;
|
|
55
|
+
const sign = (msg) => crypto.sign(null, Buffer.from(msg, "utf8"), priv).toString("base64");
|
|
56
|
+
const wsUrl = new URL("/desktop", cfg.url);
|
|
57
|
+
wsUrl.protocol = wsUrl.protocol === "http:" ? "ws:" : "wss:";
|
|
58
|
+
const tlsOpts = relayTlsOpts(cfg);
|
|
59
|
+
|
|
60
|
+
state.linked = true;
|
|
61
|
+
state.url = cfg.url;
|
|
62
|
+
state.desktopId = cfg.desktopId;
|
|
63
|
+
|
|
64
|
+
let ws = null; // control channel to the relayer
|
|
65
|
+
const conns = new Map(); // cid -> loopback ws into our own phone handler
|
|
66
|
+
|
|
67
|
+
const send = (obj) => {
|
|
68
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
69
|
+
try { ws.send(JSON.stringify(obj)); } catch {}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// replay a relayed http request against our own loopback plain-http server.
|
|
74
|
+
// The marker header lets the daemon serve relayed callers a reduced /status
|
|
75
|
+
// (they came in through the public relayer — not trusted for local detail).
|
|
76
|
+
async function handleHttpReq(msg) {
|
|
77
|
+
let out;
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(`http://127.0.0.1:${localPort}${msg.path}`, {
|
|
80
|
+
method: msg.method,
|
|
81
|
+
headers: { ...(msg.headers || {}), "x-ocduet-via-relay": "1" },
|
|
82
|
+
body: msg.body ? Buffer.from(msg.body, "base64") : undefined,
|
|
83
|
+
signal: AbortSignal.timeout(25_000),
|
|
84
|
+
});
|
|
85
|
+
const headers = {};
|
|
86
|
+
res.headers.forEach((v, k) => { headers[k] = v; });
|
|
87
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
88
|
+
if (body.length > HTTP_RESP_LIMIT) throw new Error("response too large");
|
|
89
|
+
out = { t: "httpResp", rid: msg.rid, status: res.status, headers, body: body.toString("base64") };
|
|
90
|
+
} catch (err) {
|
|
91
|
+
out = { t: "httpResp", rid: msg.rid, status: 502, headers: { "content-type": "text/plain" }, body: Buffer.from(String(err?.message || err)).toString("base64") };
|
|
92
|
+
}
|
|
93
|
+
send(out);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// splice a relayed phone websocket onto our own upgrade path: e2ee=1 marks
|
|
97
|
+
// it as a phone connection exactly like a direct LAN phone socket
|
|
98
|
+
function openConn(msg) {
|
|
99
|
+
const url = new URL(msg.path || "/", "http://x");
|
|
100
|
+
if (url.searchParams.get("e2ee") !== "1") url.searchParams.set("e2ee", "1");
|
|
101
|
+
const loop = new WebSocket(`ws://127.0.0.1:${localPort}${url.pathname}${url.search}`);
|
|
102
|
+
conns.set(msg.cid, loop);
|
|
103
|
+
loop.on("message", (data, isBinary) => {
|
|
104
|
+
if (!isBinary) send({ t: "data", cid: msg.cid, d: data.toString() });
|
|
105
|
+
});
|
|
106
|
+
loop.on("close", (code, reason) => {
|
|
107
|
+
conns.delete(msg.cid);
|
|
108
|
+
send({ t: "close", cid: msg.cid, code, reason: reason?.toString() || undefined });
|
|
109
|
+
});
|
|
110
|
+
loop.on("error", () => { try { loop.terminate(); } catch {} });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let backoff = BACKOFF_MIN_MS;
|
|
114
|
+
let dialTimer = null;
|
|
115
|
+
|
|
116
|
+
function dial() {
|
|
117
|
+
if (stopped) return;
|
|
118
|
+
ws = new WebSocket(wsUrl, { ...tlsOpts, handshakeTimeout: 10_000 });
|
|
119
|
+
ws.on("open", () => {
|
|
120
|
+
state.connected = true;
|
|
121
|
+
state.since = Date.now();
|
|
122
|
+
state.lastError = null;
|
|
123
|
+
const ts = Date.now();
|
|
124
|
+
ws.send(JSON.stringify({ t: "register", desktopId: cfg.desktopId, pub: pubB64, ts, sig: sign(REG_MSG(cfg.desktopId, pubB64, ts)) }));
|
|
125
|
+
backoff = BACKOFF_MIN_MS;
|
|
126
|
+
});
|
|
127
|
+
ws.on("message", (raw) => {
|
|
128
|
+
let msg;
|
|
129
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
130
|
+
if (msg.t === "registered") return; // tunnel confirmed by the relayer
|
|
131
|
+
if (msg.t === "httpReq") handleHttpReq(msg).catch(() => {});
|
|
132
|
+
else if (msg.t === "open") openConn(msg);
|
|
133
|
+
else if (msg.t === "data") { const c = conns.get(msg.cid); if (c) { try { c.send(msg.d); } catch {} } }
|
|
134
|
+
else if (msg.t === "close") {
|
|
135
|
+
const c = conns.get(msg.cid);
|
|
136
|
+
if (c) { try { c.close(msg.code || 1000); } catch { try { c.terminate(); } catch {} } }
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
ws.on("close", (code, reason) => {
|
|
140
|
+
for (const c of conns.values()) { try { c.terminate(); } catch {} }
|
|
141
|
+
conns.clear();
|
|
142
|
+
if (!stopped) {
|
|
143
|
+
state.connected = false;
|
|
144
|
+
state.reconnects++;
|
|
145
|
+
state.lastError = reason?.toString() || `closed ${code}`;
|
|
146
|
+
dialTimer = setTimeout(dial, backoff + Math.floor(Math.random() * 1000));
|
|
147
|
+
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
ws.on("error", () => {}); // surfaced via close
|
|
151
|
+
ws.on("pong", () => { ws.alive = true; });
|
|
152
|
+
}
|
|
153
|
+
dial();
|
|
154
|
+
|
|
155
|
+
const ping = setInterval(() => {
|
|
156
|
+
if (stopped) { clearInterval(ping); return; }
|
|
157
|
+
if (!ws || ws.readyState > WebSocket.OPEN) return;
|
|
158
|
+
if (ws.alive === false) { try { ws.terminate(); } catch {} return; }
|
|
159
|
+
ws.alive = false;
|
|
160
|
+
try { ws.ping(); } catch {}
|
|
161
|
+
}, 30_000);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
status: relayStatus,
|
|
165
|
+
stop() {
|
|
166
|
+
stopped = true;
|
|
167
|
+
clearTimeout(dialTimer);
|
|
168
|
+
clearInterval(ping);
|
|
169
|
+
try { ws && ws.close(); } catch {}
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
package/src/relayer.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// ocduet relayer — a public ciphertext pipe for the ocduet daemon.
|
|
2
|
+
// Runs on any VPS (`ocduet relayer serve`). It terminates TLS and nothing
|
|
3
|
+
// else: every http request and every websocket frame is relayed through a
|
|
4
|
+
// persistent outbound tunnel to a linked desktop daemon, which serves the
|
|
5
|
+
// web app, the pairing endpoints and the E2EE phone protocol. The relayer
|
|
6
|
+
// holds no pairing keys and never sees plaintext protocol frames.
|
|
7
|
+
//
|
|
8
|
+
// Trust model: desktops authenticate with an ed25519 keypair generated at
|
|
9
|
+
// `ocduet relay link` time. First enrollment additionally requires the
|
|
10
|
+
// relayer's enroll secret (printed at first start / `ocduet relayer secret`)
|
|
11
|
+
// so strangers cannot register on your relay. Phones reach a desktop by
|
|
12
|
+
// id — `?d=<desktopId>` sets the ocduet_d routing cookie.
|
|
13
|
+
import https from "node:https";
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { WebSocketServer } from "ws";
|
|
21
|
+
import { verifySig } from "./e2ee.js";
|
|
22
|
+
|
|
23
|
+
const VERSION = "0.1.0";
|
|
24
|
+
const REG_MSG = (desktopId, pubB64, ts) => `ocduet-relay-reg|${desktopId}|${pubB64}|${ts}`;
|
|
25
|
+
const HTTP_TIMEOUT_MS = 30_000;
|
|
26
|
+
const HTTP_REQ_LIMIT = 2 * 1024 * 1024; // phone -> desktop request bodies
|
|
27
|
+
const HTTP_RESP_LIMIT = 10 * 1024 * 1024; // desktop -> phone responses (assets)
|
|
28
|
+
|
|
29
|
+
export function relayerDir() {
|
|
30
|
+
const override = process.env.OCDUET_RELAYER_DIR;
|
|
31
|
+
if (override && path.isAbsolute(override)) return override;
|
|
32
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
33
|
+
const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".local", "share");
|
|
34
|
+
return path.join(base, "ocduet-relayer");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function storePath() { return path.join(relayerDir(), "store.json"); }
|
|
38
|
+
function logPath() { return path.join(relayerDir(), "relayer.log"); }
|
|
39
|
+
|
|
40
|
+
function log(...args) {
|
|
41
|
+
try {
|
|
42
|
+
fs.appendFileSync(logPath(), `${new Date().toISOString()} ${args.join(" ")}\n`, { mode: 0o600 });
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadStore() {
|
|
47
|
+
try {
|
|
48
|
+
const raw = JSON.parse(fs.readFileSync(storePath(), "utf8"));
|
|
49
|
+
if (raw?.secret && typeof raw.enrolled === "object") return raw;
|
|
50
|
+
} catch {}
|
|
51
|
+
const store = { secret: crypto.randomBytes(24).toString("base64url"), enrolled: {} };
|
|
52
|
+
fs.mkdirSync(relayerDir(), { recursive: true });
|
|
53
|
+
fs.writeFileSync(storePath(), JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
|
|
54
|
+
try { fs.chmodSync(storePath(), 0o600); } catch {}
|
|
55
|
+
log("generated enroll secret");
|
|
56
|
+
return store;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function saveStore(store) {
|
|
60
|
+
fs.mkdirSync(relayerDir(), { recursive: true });
|
|
61
|
+
const tmp = storePath() + `.${process.pid}.tmp`;
|
|
62
|
+
// the store holds the enroll secret — keep it 0600 across rewrites too,
|
|
63
|
+
// not just at first creation (a plain writeFileSync defaults to 0644)
|
|
64
|
+
fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
|
|
65
|
+
try { fs.chmodSync(tmp, 0o600); } catch {}
|
|
66
|
+
fs.renameSync(tmp, storePath());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sha256hex(s) { return crypto.createHash("sha256").update(s).digest("hex"); }
|
|
70
|
+
|
|
71
|
+
function ensureCert() {
|
|
72
|
+
const certP = path.join(relayerDir(), "cert.pem");
|
|
73
|
+
const keyP = path.join(relayerDir(), "cert.key.pem");
|
|
74
|
+
if (fs.existsSync(certP) && fs.existsSync(keyP)) {
|
|
75
|
+
return { cert: fs.readFileSync(certP), key: fs.readFileSync(keyP) };
|
|
76
|
+
}
|
|
77
|
+
fs.mkdirSync(relayerDir(), { recursive: true });
|
|
78
|
+
const out = spawnSync("openssl", [
|
|
79
|
+
"req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1",
|
|
80
|
+
"-keyout", keyP, "-out", certP, "-days", "3650", "-nodes",
|
|
81
|
+
"-subj", "/CN=ocduet-relayer",
|
|
82
|
+
"-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1",
|
|
83
|
+
], { stdio: "ignore" });
|
|
84
|
+
if (out.status !== 0) throw new Error("openssl cert generation failed — is openssl installed?");
|
|
85
|
+
try { fs.chmodSync(keyP, 0o600); } catch {}
|
|
86
|
+
log("generated tls cert");
|
|
87
|
+
return { cert: fs.readFileSync(certP), key: fs.readFileSync(keyP) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------- tunnels ----------
|
|
91
|
+
const tunnels = new Map(); // desktopId -> { ws, since, httpPending: Map<rid,res>, conns: Map<cid, ws> }
|
|
92
|
+
|
|
93
|
+
function routeId(req) {
|
|
94
|
+
const url = new URL(req.url || "/", "http://x");
|
|
95
|
+
const d = url.searchParams.get("d");
|
|
96
|
+
if (d && /^[a-f0-9]{6,32}$/.test(d)) return { id: d, setCookie: true };
|
|
97
|
+
const m = /(?:^|;\s*)ocduet_d=([a-f0-9]{6,32})(?:;|$)/.exec(req.headers.cookie || "");
|
|
98
|
+
if (m) return { id: m[1], setCookie: false };
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function offlinePage(host) {
|
|
103
|
+
return `<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><body style="font-family:system-ui;background:#151716;color:#e8ece9;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><div style="text-align:center"><div style="font-size:44px">⏸</div><h2 style="font-weight:600">Desktop offline</h2><p style="color:#9aa3a0">The ocduet desktop this link points at is not connected to the relayer.<br/>Start opencode on your desktop and try again.</p></div></body>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readBody(req, limit) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const chunks = [];
|
|
109
|
+
let size = 0;
|
|
110
|
+
req.on("data", (c) => {
|
|
111
|
+
size += c.length;
|
|
112
|
+
if (size > limit) { reject(new Error("body too large")); req.destroy(); return; }
|
|
113
|
+
chunks.push(c);
|
|
114
|
+
});
|
|
115
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
116
|
+
req.on("error", reject);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function relayHttp(req, res) {
|
|
121
|
+
const route = routeId(req);
|
|
122
|
+
if (!route) {
|
|
123
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
124
|
+
res.end("ocduet relayer — no desktop id. Use the link or QR from `ocduet qr`.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const tunnel = tunnels.get(route.id);
|
|
128
|
+
const headers = {};
|
|
129
|
+
if (route.setCookie) {
|
|
130
|
+
headers["set-cookie"] = `ocduet_d=${route.id}; Path=/; Max-Age=31536000; SameSite=Lax${req.socket.encrypted ? "; Secure" : ""}`;
|
|
131
|
+
}
|
|
132
|
+
if (!tunnel) {
|
|
133
|
+
res.writeHead(503, { "content-type": "text/html; charset=utf-8", ...headers });
|
|
134
|
+
res.end(offlinePage());
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const rid = crypto.randomUUID();
|
|
138
|
+
const fwdHeaders = {};
|
|
139
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
140
|
+
if (["host", "connection", "cookie", "content-length", "transfer-encoding", "accept-encoding"].includes(k)) continue;
|
|
141
|
+
fwdHeaders[k] = v;
|
|
142
|
+
}
|
|
143
|
+
let bodyB64 = "";
|
|
144
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
145
|
+
try { bodyB64 = (await readBody(req, HTTP_REQ_LIMIT)).toString("base64"); }
|
|
146
|
+
catch { res.writeHead(413, { "content-type": "text/plain", ...headers }); res.end("body too large"); return; }
|
|
147
|
+
}
|
|
148
|
+
const settle = (status, h, body) => {
|
|
149
|
+
if (tunnel.httpPending.delete(rid)) {
|
|
150
|
+
res.writeHead(status, { ...h, ...headers });
|
|
151
|
+
res.end(body);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
tunnel.httpPending.set(rid, settle);
|
|
155
|
+
const timer = setTimeout(() => settle(504, { "content-type": "text/plain" }, "desktop timeout"), HTTP_TIMEOUT_MS);
|
|
156
|
+
tunnel.httpPending.get(rid).timer = timer;
|
|
157
|
+
try {
|
|
158
|
+
tunnel.ws.send(JSON.stringify({ t: "httpReq", rid, method: req.method, path: req.url, headers: fwdHeaders, body: bodyB64 }));
|
|
159
|
+
} catch {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
settle(503, { "content-type": "text/html; charset=utf-8" }, offlinePage());
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function handleDesktopFrame(desktopId, raw) {
|
|
166
|
+
const tunnel = tunnels.get(desktopId);
|
|
167
|
+
if (!tunnel) return;
|
|
168
|
+
let msg;
|
|
169
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
170
|
+
if (msg.t === "httpResp" && tunnel.httpPending.has(msg.rid)) {
|
|
171
|
+
const settle = tunnel.httpPending.get(msg.rid);
|
|
172
|
+
clearTimeout(settle.timer);
|
|
173
|
+
const body = msg.body ? Buffer.from(msg.body, "base64") : "";
|
|
174
|
+
if (body.length > HTTP_RESP_LIMIT) return settle(502, { "content-type": "text/plain" }, "response too large");
|
|
175
|
+
const h = {};
|
|
176
|
+
for (const [k, v] of Object.entries(msg.headers || {})) {
|
|
177
|
+
if (["connection", "transfer-encoding", "content-encoding", "content-length", "keep-alive"].includes(k)) continue;
|
|
178
|
+
h[k] = v;
|
|
179
|
+
}
|
|
180
|
+
settle(msg.status || 200, h, body);
|
|
181
|
+
} else if (msg.t === "data" && tunnel.conns.has(msg.cid)) {
|
|
182
|
+
try { tunnel.conns.get(msg.cid).send(msg.d); } catch {}
|
|
183
|
+
} else if (msg.t === "close" && tunnel.conns.has(msg.cid)) {
|
|
184
|
+
const phoneWs = tunnel.conns.get(msg.cid);
|
|
185
|
+
tunnel.conns.delete(msg.cid);
|
|
186
|
+
try { phoneWs.close(msg.code || 1000, msg.reason || "desktop closed"); } catch { try { phoneWs.terminate(); } catch {} }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function dropTunnel(desktopId, why) {
|
|
191
|
+
const tunnel = tunnels.get(desktopId);
|
|
192
|
+
if (!tunnel) return;
|
|
193
|
+
tunnels.delete(desktopId);
|
|
194
|
+
log("tunnel down:", desktopId, why || "");
|
|
195
|
+
for (const settle of tunnel.httpPending.values()) {
|
|
196
|
+
clearTimeout(settle.timer);
|
|
197
|
+
try { settle(503, { "content-type": "text/html; charset=utf-8" }, offlinePage()); } catch {}
|
|
198
|
+
}
|
|
199
|
+
for (const ws of tunnel.conns.values()) {
|
|
200
|
+
try { ws.close(1013, "desktop tunnel lost"); } catch { try { ws.terminate(); } catch {} }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------- server ----------
|
|
205
|
+
export function serve(argv = []) {
|
|
206
|
+
const port = parseInt(argv.find((a, i) => argv[i - 1] === "--port") || process.env.OCDUET_RELAYER_PORT || "4290", 10);
|
|
207
|
+
const certIdx = argv.indexOf("--cert");
|
|
208
|
+
const keyIdx = argv.indexOf("--key");
|
|
209
|
+
const store = loadStore();
|
|
210
|
+
try { fs.chmodSync(logPath(), 0o600); } catch {} // tighten logs from older versions
|
|
211
|
+
const tls = (certIdx !== -1 && keyIdx !== -1 && argv[certIdx + 1] && argv[keyIdx + 1])
|
|
212
|
+
? { cert: fs.readFileSync(argv[certIdx + 1]), key: fs.readFileSync(argv[keyIdx + 1]) }
|
|
213
|
+
: ensureCert();
|
|
214
|
+
|
|
215
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
216
|
+
|
|
217
|
+
const onRequest = (req, res) => {
|
|
218
|
+
const url = new URL(req.url, "http://x");
|
|
219
|
+
if (url.pathname === "/status") {
|
|
220
|
+
// deliberately bare: desktop ids are the only routing secret this
|
|
221
|
+
// service knows — listing live tunnels would hand them to anyone
|
|
222
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
223
|
+
res.end(JSON.stringify({ app: "ocduet-relayer", relayer: true, ok: true, version: VERSION, port }));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
relayHttp(req, res).catch(() => {
|
|
227
|
+
try { res.writeHead(502); res.end("relay error"); } catch {}
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const onUpgrade = (req, socket, head) => {
|
|
232
|
+
const url = new URL(req.url, "http://x");
|
|
233
|
+
if (url.pathname === "/desktop") {
|
|
234
|
+
wss.handleUpgrade(req, socket, head, (ws) => attachDesktop(ws));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const route = routeId(req);
|
|
238
|
+
if (!route) {
|
|
239
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
240
|
+
socket.destroy();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const tunnel = tunnels.get(route.id);
|
|
244
|
+
if (!tunnel) {
|
|
245
|
+
socket.write("HTTP/1.1 503 Service Unavailable\r\n\r\ndesktop offline");
|
|
246
|
+
socket.destroy();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
250
|
+
const cid = crypto.randomUUID();
|
|
251
|
+
tunnel.conns.set(cid, ws);
|
|
252
|
+
ws.on("message", (data, isBinary) => {
|
|
253
|
+
if (isBinary) { try { ws.close(1003, "binary not supported"); } catch {} return; }
|
|
254
|
+
try { tunnel.ws.send(JSON.stringify({ t: "data", cid, d: data.toString() })); } catch {}
|
|
255
|
+
});
|
|
256
|
+
ws.on("close", (code) => {
|
|
257
|
+
if (tunnel.conns.get(cid) === ws) {
|
|
258
|
+
tunnel.conns.delete(cid);
|
|
259
|
+
try { tunnel.ws.send(JSON.stringify({ t: "close", cid, code })); } catch {}
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
ws.on("error", () => {});
|
|
263
|
+
try {
|
|
264
|
+
tunnel.ws.send(JSON.stringify({ t: "open", cid, path: req.url, headers: { "user-agent": req.headers["user-agent"] || "" } }));
|
|
265
|
+
} catch {
|
|
266
|
+
try { ws.close(1013, "tunnel lost"); } catch {}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
function attachDesktop(ws) {
|
|
272
|
+
let registeredId = null;
|
|
273
|
+
let settled = false;
|
|
274
|
+
const regTimer = setTimeout(() => { if (!settled) { try { ws.close(4001, "register timeout"); } catch {} } }, 10_000);
|
|
275
|
+
ws.on("message", (raw) => {
|
|
276
|
+
let msg;
|
|
277
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
278
|
+
if (!registeredId) {
|
|
279
|
+
if (msg?.t !== "register") { try { ws.close(4001, "expected register"); } catch {} return; }
|
|
280
|
+
const { desktopId, pub, ts, sig, secret } = msg;
|
|
281
|
+
if (!/^[a-f0-9]{6,32}$/.test(String(desktopId || ""))) { try { ws.close(4001, "bad id"); } catch {} return; }
|
|
282
|
+
if (Math.abs(Date.now() - Number(ts)) > 120_000) { try { ws.close(4001, "stale register"); } catch {} return; }
|
|
283
|
+
const pubBuf = Buffer.from(String(pub || ""), "base64");
|
|
284
|
+
if (pubBuf.length !== 32) { try { ws.close(4001, "bad pub"); } catch {} return; }
|
|
285
|
+
if (!verifySig(pubBuf, REG_MSG(desktopId, pub, ts), sig)) { try { ws.close(4003, "bad signature"); } catch {} return; }
|
|
286
|
+
const enrolledPub = store.enrolled[desktopId]?.pub;
|
|
287
|
+
let enrolled = false;
|
|
288
|
+
if (enrolledPub === String(pub)) enrolled = true;
|
|
289
|
+
else if (sha256hex(String(secret || "")) === sha256hex(store.secret)) {
|
|
290
|
+
store.enrolled[desktopId] = { pub: String(pub), addedAt: Date.now() };
|
|
291
|
+
saveStore(store);
|
|
292
|
+
enrolled = true;
|
|
293
|
+
log("enrolled desktop:", desktopId);
|
|
294
|
+
}
|
|
295
|
+
if (!enrolled) { try { ws.close(4003, "unknown desktop — link with the enroll secret"); } catch {} return; }
|
|
296
|
+
registeredId = desktopId;
|
|
297
|
+
settled = true;
|
|
298
|
+
clearTimeout(regTimer);
|
|
299
|
+
dropTunnel(desktopId, "replaced by new connection");
|
|
300
|
+
tunnels.set(desktopId, { id: desktopId, ws, since: Date.now(), httpPending: new Map(), conns: new Map() });
|
|
301
|
+
ws.send(JSON.stringify({ t: "registered", ok: true, desktopId }));
|
|
302
|
+
log("tunnel up:", desktopId);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
handleDesktopFrame(registeredId, raw);
|
|
306
|
+
});
|
|
307
|
+
ws.on("close", () => { clearTimeout(regTimer); if (registeredId) dropTunnel(registeredId, "closed"); });
|
|
308
|
+
ws.on("error", () => {});
|
|
309
|
+
ws.alive = true;
|
|
310
|
+
ws.on("pong", () => { ws.alive = true; });
|
|
311
|
+
const ping = setInterval(() => {
|
|
312
|
+
if (!ws.alive) { try { ws.terminate(); } catch {}; clearInterval(ping); return; }
|
|
313
|
+
ws.alive = false;
|
|
314
|
+
try { ws.ping(); } catch {}
|
|
315
|
+
}, 30_000);
|
|
316
|
+
ws.on("close", () => clearInterval(ping));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const server = https.createServer({ key: tls.key, cert: tls.cert }, onRequest);
|
|
320
|
+
server.on("upgrade", onUpgrade);
|
|
321
|
+
server.listen(port, "0.0.0.0", () => {
|
|
322
|
+
console.log(`ocduet relayer v${VERSION} listening on https://0.0.0.0:${port}`);
|
|
323
|
+
console.log(`data dir: ${relayerDir()}`);
|
|
324
|
+
console.log(`enroll secret: ${store.secret}`);
|
|
325
|
+
console.log(` (also printable later: ocduet relayer secret)`);
|
|
326
|
+
console.log(`desktops link with: ocduet relay link https://<this-host>:${port} ${store.secret}`);
|
|
327
|
+
});
|
|
328
|
+
const cleanup = () => { try { server.close(); } catch {} process.exit(0); };
|
|
329
|
+
process.on("SIGTERM", cleanup);
|
|
330
|
+
process.on("SIGINT", cleanup);
|
|
331
|
+
return server;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function printSecret() {
|
|
335
|
+
const store = loadStore();
|
|
336
|
+
console.log(store.secret);
|
|
337
|
+
}
|