@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/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Coming Soon.
|
package/bin/ocduet.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { install } from "../src/commands/install.js";
|
|
3
|
+
import { uninstall } from "../src/commands/uninstall.js";
|
|
4
|
+
import { token } from "../src/commands/token.js";
|
|
5
|
+
import { relay } from "../src/commands/relay.js";
|
|
6
|
+
import { start, stop, restart, status } from "../src/commands/service.js";
|
|
7
|
+
|
|
8
|
+
const HELP = `ocduet — pair your phone with a local opencode instance
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
ocduet install install the plugins into opencode (idempotent)
|
|
12
|
+
ocduet install relayer install the relayer service (run on a public VPS)
|
|
13
|
+
ocduet uninstall remove the plugins (--purge also deletes token/state)
|
|
14
|
+
ocduet token [--regen] print the pairing token + QR (--regen makes a new one)
|
|
15
|
+
ocduet relay serve run the relayer server (on a public VPS)
|
|
16
|
+
ocduet relay link <url> <secret> [--insecure] link this desktop to a relayer
|
|
17
|
+
ocduet relay status tunnel state (also in ocduet status)
|
|
18
|
+
|
|
19
|
+
After install: start the opencode TUI, open the "Connect" sidebar panel,
|
|
20
|
+
click [connect] and scan the QR with your phone.
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
25
|
+
switch (cmd) {
|
|
26
|
+
case "install":
|
|
27
|
+
return install(rest);
|
|
28
|
+
case "uninstall":
|
|
29
|
+
return uninstall(rest);
|
|
30
|
+
case "token":
|
|
31
|
+
case "qr":
|
|
32
|
+
return token(rest);
|
|
33
|
+
case "relayer":
|
|
34
|
+
return relay(["serve", ...rest]);
|
|
35
|
+
case "relay":
|
|
36
|
+
return relay(rest);
|
|
37
|
+
case "start":
|
|
38
|
+
return start(rest);
|
|
39
|
+
case "stop":
|
|
40
|
+
return stop(rest);
|
|
41
|
+
case "restart":
|
|
42
|
+
return restart(rest);
|
|
43
|
+
case "status":
|
|
44
|
+
return status(rest);
|
|
45
|
+
case "--help":
|
|
46
|
+
case "-h":
|
|
47
|
+
case "help":
|
|
48
|
+
case undefined:
|
|
49
|
+
console.log(HELP);
|
|
50
|
+
return;
|
|
51
|
+
default:
|
|
52
|
+
console.error(`unknown command: ${cmd}\n`);
|
|
53
|
+
console.log(HELP);
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
main().catch((err) => {
|
|
59
|
+
console.error(String(err?.stack || err));
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sksoftofficial/ocduet",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Duet for opencode: pair your phone with a local opencode TUI. Type on mobile, see it live on both screens. LAN-first, QR pairing, zero config.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ocduet": "./bin/ocduet.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"opencode",
|
|
16
|
+
"opencode-plugin",
|
|
17
|
+
"ocduet",
|
|
18
|
+
"duet",
|
|
19
|
+
"remote",
|
|
20
|
+
"mobile",
|
|
21
|
+
"tui",
|
|
22
|
+
"lan",
|
|
23
|
+
"bridge"
|
|
24
|
+
],
|
|
25
|
+
"author": "sksoftofficial",
|
|
26
|
+
"license": "ISC",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"ws": "^8.18.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { opencodeConfigDir, dataDir, pluginDir, ensureToken, daemonPointerPath, daemonWebDir, installationPath } from "../paths.js";
|
|
7
|
+
import { relayerDir } from "../relayer.js";
|
|
8
|
+
import { restart } from "./service.js";
|
|
9
|
+
const PLUGIN_FILES = [
|
|
10
|
+
{ src: "ocduet-server.js", dest: "ocduet-server.js" },
|
|
11
|
+
{ src: "ocduet-sidebar.jsx", dest: "ocduet-sidebar.tsx" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const WEB_FILES = [
|
|
15
|
+
{ src: "web/index.html", dest: "index.html" },
|
|
16
|
+
{ src: "web/app.js", dest: "app.js.txt" },
|
|
17
|
+
{ src: "web/app.css", dest: "app.css.txt" },
|
|
18
|
+
{ src: "web/pair.html", dest: "pair.html" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const SERVER_ENTRY_PREFIX = "ocduet-server";
|
|
22
|
+
|
|
23
|
+
function pkgRoot() {
|
|
24
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function copy(from, to) {
|
|
28
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
29
|
+
fs.copyFileSync(from, to);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Idempotently ensure the server plugin is in opencode.json(c)'s "plugin" array
|
|
33
|
+
// as a file:// entry. Pure-regex JSONC surgery: no deps, preserves formatting.
|
|
34
|
+
export function patchConfig() {
|
|
35
|
+
const dir = opencodeConfigDir();
|
|
36
|
+
const jsonc = path.join(dir, "opencode.jsonc");
|
|
37
|
+
const json = path.join(dir, "opencode.json");
|
|
38
|
+
const configPath = fs.existsSync(jsonc) ? jsonc : fs.existsSync(json) ? json : jsonc;
|
|
39
|
+
const entry = `file://${path.join(pluginDir(), "ocduet-server.js")}`;
|
|
40
|
+
|
|
41
|
+
let text = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : '{\n "$schema": "https://opencode.ai/config.json",\n "plugin": []\n}\n';
|
|
42
|
+
|
|
43
|
+
if (text.includes(entry)) return configPath;
|
|
44
|
+
|
|
45
|
+
const arrayOpen = text.search(/"plugin"\s*:\s*\[/);
|
|
46
|
+
if (arrayOpen >= 0) {
|
|
47
|
+
const insertAt = text.indexOf("[", arrayOpen) + 1;
|
|
48
|
+
const before = text.slice(0, insertAt);
|
|
49
|
+
const after = text.slice(insertAt);
|
|
50
|
+
const needsComma = /^\s*"/.test(after);
|
|
51
|
+
text = before + `\n ${JSON.stringify(entry)}${needsComma ? "," : ""}` + after;
|
|
52
|
+
} else {
|
|
53
|
+
const lastBrace = text.lastIndexOf("}");
|
|
54
|
+
if (lastBrace < 0) throw new Error(`cannot parse ${configPath}; add the plugin entry manually`);
|
|
55
|
+
const trailing = text.slice(0, lastBrace).trimEnd();
|
|
56
|
+
text = `${trailing.endsWith(",") || trailing.endsWith("{") ? "" : ","}\n "plugin": [\n ${JSON.stringify(entry)}\n ]\n}\n`;
|
|
57
|
+
}
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
59
|
+
fs.writeFileSync(configPath, text);
|
|
60
|
+
return configPath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Register the TUI sidebar plugin in tui.json (the TUI's own plugin registry).
|
|
64
|
+
export function patchTuiJson() {
|
|
65
|
+
const p = path.join(opencodeConfigDir(), "tui.json");
|
|
66
|
+
const entry = "./plugin/ocduet-sidebar.tsx";
|
|
67
|
+
let cfg = {};
|
|
68
|
+
if (fs.existsSync(p)) {
|
|
69
|
+
try {
|
|
70
|
+
cfg = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
71
|
+
} catch {
|
|
72
|
+
throw new Error(`cannot parse ${p}; add ["${entry}", {"enabled": true}] to its plugin array manually`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) {
|
|
76
|
+
throw new Error(`unexpected content in ${p}`);
|
|
77
|
+
}
|
|
78
|
+
if (!cfg.$schema) cfg.$schema = "https://opencode.ai/tui.json";
|
|
79
|
+
if (!Array.isArray(cfg.plugin)) cfg.plugin = [];
|
|
80
|
+
const exists = cfg.plugin.some((e) => e === entry || (Array.isArray(e) && e[0] === entry));
|
|
81
|
+
if (!exists) cfg.plugin.push([entry, { enabled: true }]);
|
|
82
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
|
|
83
|
+
return p;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function unitFlag(argv, name) {
|
|
87
|
+
const i = argv.indexOf(name);
|
|
88
|
+
return i !== -1 && argv[i + 1] ? argv[i + 1] : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `ocduet install relayer` — set the relayer up as a boot-persistent service
|
|
92
|
+
// on a public box. systemd user unit when available (enable-linger keeps it
|
|
93
|
+
// alive without a login session), detached process + pid file otherwise.
|
|
94
|
+
export async function installRelayer(argv = []) {
|
|
95
|
+
const root = pkgRoot();
|
|
96
|
+
const dir = relayerDir();
|
|
97
|
+
const port = unitFlag(argv, "--port") || process.env.OCDUET_RELAYER_PORT || "4290";
|
|
98
|
+
const cert = unitFlag(argv, "--cert");
|
|
99
|
+
const key = unitFlag(argv, "--key");
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
|
|
102
|
+
// make sure the store (and the enroll secret) exists before we print it
|
|
103
|
+
const secret = spawnSync(process.execPath, [path.join(root, "bin/ocduet.js"), "relay", "secret"], { encoding: "utf8", env: { ...process.env, OCDUET_RELAYER_DIR: dir } });
|
|
104
|
+
const enroll = (secret.stdout || "").trim() || "(unknown — run `ocduet relayer secret`)";
|
|
105
|
+
|
|
106
|
+
const serveArgs = ["ocduet.js", "relayer", "serve", "--port", port];
|
|
107
|
+
if (cert && key) serveArgs.push("--cert", cert, "--key", key);
|
|
108
|
+
|
|
109
|
+
let managedBy = null;
|
|
110
|
+
const unitDir = path.join(os.homedir(), ".config/systemd/user");
|
|
111
|
+
const unitPath = path.join(unitDir, "ocduet-relayer.service");
|
|
112
|
+
try {
|
|
113
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
114
|
+
fs.writeFileSync(unitPath, [
|
|
115
|
+
"[Unit]",
|
|
116
|
+
"Description=ocduet relayer — public tunnel for the ocduet daemon",
|
|
117
|
+
"After=network-online.target",
|
|
118
|
+
"Wants=network-online.target",
|
|
119
|
+
"",
|
|
120
|
+
"[Service]",
|
|
121
|
+
`WorkingDirectory=${root}`,
|
|
122
|
+
`ExecStart=${process.execPath} ${serveArgs.map((a) => a.includes(" ") ? JSON.stringify(a) : a).join(" ")}`,
|
|
123
|
+
"Environment=OCDUET_RELAYER_DIR=" + dir,
|
|
124
|
+
"Restart=always",
|
|
125
|
+
"RestartSec=3",
|
|
126
|
+
"",
|
|
127
|
+
"[Install]",
|
|
128
|
+
"WantedBy=default.target",
|
|
129
|
+
"",
|
|
130
|
+
].join("\n"));
|
|
131
|
+
const ctl = spawnSync("systemctl", ["--user", "daemon-reload"], { encoding: "utf8" });
|
|
132
|
+
if (ctl.status === 0) {
|
|
133
|
+
spawnSync("systemctl", ["--user", "enable", "--now", "ocduet-relayer.service"], { encoding: "utf8" });
|
|
134
|
+
managedBy = "systemd --user";
|
|
135
|
+
}
|
|
136
|
+
} catch {}
|
|
137
|
+
|
|
138
|
+
if (!managedBy) {
|
|
139
|
+
const log = fs.openSync(path.join(dir, "relayer.log"), "a", 0o600);
|
|
140
|
+
const child = spawn(process.execPath, [path.join(root, "bin/ocduet.js"), ...serveArgs], {
|
|
141
|
+
env: { ...process.env, OCDUET_RELAYER_DIR: dir },
|
|
142
|
+
detached: true,
|
|
143
|
+
stdio: ["ignore", log, log],
|
|
144
|
+
});
|
|
145
|
+
child.unref();
|
|
146
|
+
fs.writeFileSync(path.join(dir, "relayer.pid"), String(child.pid));
|
|
147
|
+
managedBy = `detached process (pid ${child.pid}) — no systemd; restart it manually after reboot`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ips = [];
|
|
151
|
+
for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
|
|
152
|
+
for (const a of addrs || []) {
|
|
153
|
+
if (a.family === "IPv4" && !a.internal) ips.push(a.address);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const host = ips[0] || "<this-host>";
|
|
157
|
+
console.log(`relayer installed (${managedBy})`);
|
|
158
|
+
console.log(`listening on: https://0.0.0.0:${port}`);
|
|
159
|
+
console.log(`enroll secret: ${enroll}`);
|
|
160
|
+
console.log("");
|
|
161
|
+
console.log("on your DESKTOP, link it:");
|
|
162
|
+
console.log(` ocduet relay link https://${host}:${port} ${enroll}`);
|
|
163
|
+
if (ips.length > 1) console.log(` (this box has multiple IPs: ${ips.join(", ")} — use the public one)`);
|
|
164
|
+
if (managedBy.startsWith("systemd")) {
|
|
165
|
+
console.log("");
|
|
166
|
+
console.log("to survive reboots without a login session, run once (may need root):");
|
|
167
|
+
console.log(` sudo loginctl enable-linger $USER`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function install(argv = []) {
|
|
172
|
+
if (argv[0] === "relayer") return installRelayer(argv.slice(1));
|
|
173
|
+
const root = pkgRoot();
|
|
174
|
+
const pdir = pluginDir();
|
|
175
|
+
const ddir = dataDir();
|
|
176
|
+
|
|
177
|
+
fs.mkdirSync(pdir, { recursive: true });
|
|
178
|
+
fs.writeFileSync(installationPath(), JSON.stringify({ dataDir: ddir }, null, 2) + "\n");
|
|
179
|
+
|
|
180
|
+
for (const { src, dest } of PLUGIN_FILES) {
|
|
181
|
+
const source = fs.readFileSync(path.join(root, "src/plugin", src), "utf8");
|
|
182
|
+
fs.writeFileSync(path.join(pdir, dest), source.replace(
|
|
183
|
+
"const INSTALLED_DATA_DIR = null;",
|
|
184
|
+
`const INSTALLED_DATA_DIR = ${JSON.stringify(ddir)};`,
|
|
185
|
+
));
|
|
186
|
+
console.log(`installed plugin: ${path.join(pdir, dest)}`);
|
|
187
|
+
}
|
|
188
|
+
for (const { src, dest } of WEB_FILES) {
|
|
189
|
+
copy(path.join(root, "src/plugin/web", path.basename(src)), path.join(daemonWebDir(), dest));
|
|
190
|
+
}
|
|
191
|
+
console.log(`installed web client: ${daemonWebDir()}`);
|
|
192
|
+
|
|
193
|
+
copy(path.join(root, "src/qrcodegen.js"), path.join(ddir, "qrcodegen.js"));
|
|
194
|
+
fs.writeFileSync(daemonPointerPath(), JSON.stringify({
|
|
195
|
+
daemonPath: path.join(root, "src/daemon.js"),
|
|
196
|
+
dataDir: ddir,
|
|
197
|
+
}, null, 2) + "\n");
|
|
198
|
+
console.log(`daemon pointer: ${daemonPointerPath()}`);
|
|
199
|
+
for (const legacy of fs.readdirSync(ddir).filter((f) => /^state-.*\.json$/.test(f))) {
|
|
200
|
+
try { fs.rmSync(path.join(ddir, legacy)); } catch {}
|
|
201
|
+
}
|
|
202
|
+
const t = ensureToken();
|
|
203
|
+
console.log(`pairing token: ${ddir}/token`);
|
|
204
|
+
|
|
205
|
+
const configPath = patchConfig();
|
|
206
|
+
console.log(`registered server plugin in: ${configPath}`);
|
|
207
|
+
|
|
208
|
+
const tuiPath = patchTuiJson();
|
|
209
|
+
console.log(`registered TUI sidebar in: ${tuiPath}`);
|
|
210
|
+
|
|
211
|
+
await restart([]);
|
|
212
|
+
|
|
213
|
+
console.log(`
|
|
214
|
+
Done. Now:
|
|
215
|
+
1. (Re)start opencode — plugins load automatically from ${pdir}
|
|
216
|
+
2. Open the Duet panel in the TUI sidebar
|
|
217
|
+
3. Click [connect] and scan the QR with your phone
|
|
218
|
+
|
|
219
|
+
Token (already generated, shown here for reference):
|
|
220
|
+
${t}
|
|
221
|
+
`);
|
|
222
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// `ocduet relay …` — manage the relayer tunnel.
|
|
2
|
+
// serve run the relayer server (VPS side, foreground)
|
|
3
|
+
// secret print the relayer's enroll secret (run on the VPS)
|
|
4
|
+
// link <url> [s] enroll this desktop against a relayer and keep it linked
|
|
5
|
+
// unlink remove the link (the relayer keeps the enrolled key)
|
|
6
|
+
// status link + live tunnel state
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import https from "node:https";
|
|
11
|
+
import { WebSocket } from "ws";
|
|
12
|
+
import { serve, printSecret, relayerDir } from "../relayer.js";
|
|
13
|
+
import { relayConfigPath, readRelayConfig, relayStatus } from "../relay-client.js";
|
|
14
|
+
import { readDaemonControlPort } from "../paths.js";
|
|
15
|
+
import { restart } from "./service.js";
|
|
16
|
+
|
|
17
|
+
const REG_MSG = (desktopId, pubB64, ts) => `ocduet-relay-reg|${desktopId}|${pubB64}|${ts}`;
|
|
18
|
+
|
|
19
|
+
// grab the relayer's cert PEM at first contact — stored and trusted as its
|
|
20
|
+
// own CA from then on, which pins the relayer exactly (self-signed included)
|
|
21
|
+
function fetchCertPem(url) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const u = new URL(url);
|
|
24
|
+
const req = https.request({
|
|
25
|
+
hostname: u.hostname, port: u.port || 443, path: "/status",
|
|
26
|
+
method: "GET", rejectUnauthorized: false, timeout: 8000,
|
|
27
|
+
}, (res) => {
|
|
28
|
+
const cert = res.socket.getPeerCertificate();
|
|
29
|
+
res.resume();
|
|
30
|
+
if (cert?.raw) resolve(`-----BEGIN CERTIFICATE-----\n${cert.raw.toString("base64").replace(/(.{64})/g, "$1\n")}\n-----END CERTIFICATE-----\n`);
|
|
31
|
+
else reject(new Error("no peer certificate"));
|
|
32
|
+
});
|
|
33
|
+
req.on("timeout", () => { req.destroy(new Error("timeout")); });
|
|
34
|
+
req.on("error", reject);
|
|
35
|
+
req.end();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function registerOnce(url, desktopId, pubB64, sign, secret, certPem) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const wsUrl = new URL("/desktop", url);
|
|
42
|
+
wsUrl.protocol = wsUrl.protocol === "http:" ? "ws:" : "wss:";
|
|
43
|
+
const tlsOpts = certPem ? { ca: certPem } : { rejectUnauthorized: false };
|
|
44
|
+
const ws = new WebSocket(wsUrl, { ...tlsOpts, handshakeTimeout: 10_000 });
|
|
45
|
+
const fail = (why) => { try { ws.close(); } catch {} reject(new Error(why)); };
|
|
46
|
+
const timer = setTimeout(() => fail("register timeout"), 12_000);
|
|
47
|
+
ws.on("open", () => {
|
|
48
|
+
const ts = Date.now();
|
|
49
|
+
ws.send(JSON.stringify({ t: "register", desktopId, pub: pubB64, ts, sig: sign(REG_MSG(desktopId, pubB64, ts)), secret: secret || undefined }));
|
|
50
|
+
});
|
|
51
|
+
ws.on("message", (raw) => {
|
|
52
|
+
let msg;
|
|
53
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
54
|
+
if (msg.t === "registered") { clearTimeout(timer); try { ws.close(); } catch {} resolve(msg); return; }
|
|
55
|
+
if (msg.t === "error") { clearTimeout(timer); fail(msg.error || "rejected"); }
|
|
56
|
+
});
|
|
57
|
+
ws.on("close", () => { clearTimeout(timer); reject(new Error("connection closed before registration")); });
|
|
58
|
+
ws.on("error", (e) => { clearTimeout(timer); reject(e); });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadOrCreateIdentity() {
|
|
63
|
+
const existing = readRelayConfig();
|
|
64
|
+
if (existing) return existing;
|
|
65
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
66
|
+
const pub = publicKey.export({ format: "der", type: "spki" }).slice(-32);
|
|
67
|
+
const priv = privateKey.export({ format: "der", type: "pkcs8" });
|
|
68
|
+
return { pub: pub.toString("base64"), priv: priv.toString("base64") };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function relay(argv = []) {
|
|
72
|
+
const [sub, ...rest] = argv;
|
|
73
|
+
switch (sub) {
|
|
74
|
+
case "serve":
|
|
75
|
+
return serve(rest);
|
|
76
|
+
|
|
77
|
+
case "secret":
|
|
78
|
+
printSecret();
|
|
79
|
+
return;
|
|
80
|
+
|
|
81
|
+
case "link": {
|
|
82
|
+
const insecure = rest.includes("--insecure");
|
|
83
|
+
const url = rest.find((a) => !a.startsWith("-"));
|
|
84
|
+
const secret = rest.filter((a) => !a.startsWith("-"))[1];
|
|
85
|
+
if (!url || !/^https?:\/\//.test(url)) {
|
|
86
|
+
console.error("usage: ocduet relay link https://<relayer-host>:<port> <enroll-secret> [--insecure]");
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const certPem = await fetchCertPem(url).catch(() => null);
|
|
91
|
+
// the enroll secret must never travel over an unverified TLS connection —
|
|
92
|
+
// a link-time MITM could capture it (and pin its own cert for the future).
|
|
93
|
+
// http:// URLs (local testing) and an explicit --insecure are the opt-outs.
|
|
94
|
+
if (!certPem && new URL(url).protocol !== "http:" && !insecure) {
|
|
95
|
+
console.error("refusing to link: could not fetch the relayer's TLS certificate, so the");
|
|
96
|
+
console.error("enroll secret would be sent over an unverified connection (possible MITM).");
|
|
97
|
+
console.error("check the URL/host, or pass --insecure to link anyway without cert pinning.");
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const id = loadOrCreateIdentity();
|
|
102
|
+
const desktopId = crypto.createHash("sha256").update(Buffer.from(id.pub, "base64")).digest("hex").slice(0, 10);
|
|
103
|
+
const sign = (msg) => crypto.sign(null, Buffer.from(msg, "utf8"), crypto.createPrivateKey({ key: Buffer.from(id.priv, "base64"), format: "der", type: "pkcs8" })).toString("base64");
|
|
104
|
+
console.log(`linking ${url} (desktop id ${desktopId})${certPem ? " — cert pinned" : insecure ? " — WARNING: --insecure, cert NOT pinned" : " — WARNING: plain http, cert NOT pinned"} ...`);
|
|
105
|
+
await registerOnce(url, desktopId, id.pub, sign, secret, certPem);
|
|
106
|
+
const cfgPath = relayConfigPath();
|
|
107
|
+
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
108
|
+
const tmp = cfgPath + `.${process.pid}.tmp`;
|
|
109
|
+
fs.writeFileSync(tmp, JSON.stringify({
|
|
110
|
+
url, desktopId, pub: id.pub, priv: id.priv, certPem, linkedAt: Date.now(),
|
|
111
|
+
}, null, 2) + "\n", { mode: 0o600 });
|
|
112
|
+
fs.renameSync(tmp, cfgPath);
|
|
113
|
+
console.log("linked — the daemon keeps the tunnel up from now on.");
|
|
114
|
+
await restart([]);
|
|
115
|
+
console.log(`next: ocduet qr (scan once — the link works from any network)`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case "unlink": {
|
|
120
|
+
if (!readRelayConfig()) { console.log("not linked"); return; }
|
|
121
|
+
fs.unlinkSync(relayConfigPath());
|
|
122
|
+
console.log("unlinked. (the relayer still knows this desktop — enroll secret rotates it out)");
|
|
123
|
+
await restart([]);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case "status": {
|
|
128
|
+
const cfg = readRelayConfig();
|
|
129
|
+
if (!cfg) { console.log("relay: not linked"); return; }
|
|
130
|
+
console.log(`relay: ${cfg.url} desktop id ${cfg.desktopId}`);
|
|
131
|
+
let live = null;
|
|
132
|
+
try {
|
|
133
|
+
const port = readDaemonControlPort();
|
|
134
|
+
const res = await fetch(`http://127.0.0.1:${port}/status`, { signal: AbortSignal.timeout(1000) });
|
|
135
|
+
live = await res.json();
|
|
136
|
+
} catch {}
|
|
137
|
+
const r = live?.relay ?? relayStatus();
|
|
138
|
+
console.log(`tunnel: ${r.connected ? `connected since ${new Date(r.since).toLocaleString()}` : `down${r.lastError ? ` (${r.lastError})` : ""}`}${r.reconnects ? ` reconnects: ${r.reconnects}` : ""}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
default:
|
|
143
|
+
console.log(`usage:
|
|
144
|
+
ocduet relay serve [--port N] [--cert f --key f] run the relayer (VPS)
|
|
145
|
+
ocduet relay secret print the enroll secret
|
|
146
|
+
ocduet relay link <url> <secret> [--insecure] link this desktop
|
|
147
|
+
ocduet relay unlink unlink this desktop
|
|
148
|
+
ocduet relay status link + tunnel state`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
daemonPidPath, daemonLogPath, daemonStatePath, readDaemonPort, readDaemonControlPort, ensureToken, dataDir,
|
|
7
|
+
} from "../paths.js";
|
|
8
|
+
|
|
9
|
+
const DAEMON_JS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../daemon.js");
|
|
10
|
+
|
|
11
|
+
function readPid() {
|
|
12
|
+
try {
|
|
13
|
+
return Number(fs.readFileSync(daemonPidPath(), "utf8").trim());
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pidAlive(pid) {
|
|
20
|
+
if (!pid) return false;
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function healthy() {
|
|
30
|
+
const port = readDaemonControlPort();
|
|
31
|
+
if (!port) return false;
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`http://127.0.0.1:${port}/status`, { signal: AbortSignal.timeout(1000) });
|
|
34
|
+
if (!res.ok) return false;
|
|
35
|
+
const data = await res.json();
|
|
36
|
+
return data?.daemon === true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function healthyInfo() {
|
|
43
|
+
const port = readDaemonControlPort();
|
|
44
|
+
if (!port) return null;
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(`http://127.0.0.1:${port}/status`, { signal: AbortSignal.timeout(1000) });
|
|
47
|
+
if (!res.ok) return null;
|
|
48
|
+
const data = await res.json();
|
|
49
|
+
return data?.daemon === true ? data : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function waitFor(ms) {
|
|
56
|
+
const deadline = Date.now() + ms;
|
|
57
|
+
while (Date.now() < deadline) {
|
|
58
|
+
if (await healthy()) return true;
|
|
59
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isRunning() {
|
|
65
|
+
const pid = readPid();
|
|
66
|
+
return pid !== null && pidAlive(pid) ? pid : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function start(argv = []) {
|
|
70
|
+
if (argv.includes("--foreground")) {
|
|
71
|
+
return import("../daemon.js");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const existing = isRunning();
|
|
75
|
+
if (existing) {
|
|
76
|
+
console.log(`ocduet already running (pid ${existing})`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
ensureToken();
|
|
81
|
+
fs.mkdirSync(path.dirname(daemonPidPath()), { recursive: true });
|
|
82
|
+
const out = fs.openSync(daemonLogPath(), "a", 0o600);
|
|
83
|
+
const child = spawn(process.execPath, [DAEMON_JS], {
|
|
84
|
+
env: { ...process.env, OCDUET_DATA_DIR: dataDir() },
|
|
85
|
+
detached: true,
|
|
86
|
+
stdio: ["ignore", out, out],
|
|
87
|
+
});
|
|
88
|
+
child.unref();
|
|
89
|
+
fs.writeFileSync(daemonPidPath(), String(child.pid));
|
|
90
|
+
|
|
91
|
+
console.log("starting ocduet daemon ...");
|
|
92
|
+
if ((await waitFor(15000)) && pidAlive(child.pid)) {
|
|
93
|
+
const info = await healthyInfo();
|
|
94
|
+
if (info && info.pid !== child.pid) {
|
|
95
|
+
console.log(`ocduet already running (pid ${info.pid})`);
|
|
96
|
+
try { fs.unlinkSync(daemonPidPath()); } catch {}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
console.log(`started (pid ${child.pid}, port ${readDaemonPort()})`);
|
|
100
|
+
console.log(`logs: ${daemonLogPath()}`);
|
|
101
|
+
console.log("stop: ocduet stop");
|
|
102
|
+
} else {
|
|
103
|
+
console.error("failed to start — last log lines:");
|
|
104
|
+
try {
|
|
105
|
+
const log = fs.readFileSync(daemonLogPath(), "utf8").trimEnd();
|
|
106
|
+
console.error(log.split("\n").slice(-10).join("\n"));
|
|
107
|
+
} catch {}
|
|
108
|
+
try { fs.unlinkSync(daemonPidPath()); } catch {}
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function stop(argv = []) {
|
|
114
|
+
const pid = isRunning();
|
|
115
|
+
if (!pid) {
|
|
116
|
+
console.log("ocduet not running");
|
|
117
|
+
try { fs.unlinkSync(daemonPidPath()); } catch {}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
process.kill(pid, "SIGTERM");
|
|
122
|
+
} catch {}
|
|
123
|
+
const deadline = Date.now() + 8000;
|
|
124
|
+
while (Date.now() < deadline && pidAlive(pid)) {
|
|
125
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
126
|
+
}
|
|
127
|
+
if (pidAlive(pid)) {
|
|
128
|
+
try { process.kill(pid, "SIGKILL"); } catch {}
|
|
129
|
+
}
|
|
130
|
+
try { fs.unlinkSync(daemonPidPath()); } catch {}
|
|
131
|
+
try { fs.unlinkSync(daemonStatePath()); } catch {}
|
|
132
|
+
console.log("stopped");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function restart(argv = []) {
|
|
136
|
+
await stop(argv);
|
|
137
|
+
await start(argv);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function status(_argv = []) {
|
|
141
|
+
const pid = isRunning();
|
|
142
|
+
if (!pid) {
|
|
143
|
+
console.log("ocduet: not running");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const state = await (async () => {
|
|
147
|
+
try {
|
|
148
|
+
const port = readDaemonControlPort();
|
|
149
|
+
const res = await fetch(`http://127.0.0.1:${port}/status`, { signal: AbortSignal.timeout(1000) });
|
|
150
|
+
return await res.json();
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
})();
|
|
155
|
+
if (!state) {
|
|
156
|
+
console.log(`ocduet: pid ${pid} alive but not answering`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
console.log(`ocduet: running (pid ${pid}, port ${readDaemonPort()})`);
|
|
160
|
+
console.log(` instances: ${state.instances} phones: ${state.clients}`);
|
|
161
|
+
if (state.relay?.linked) {
|
|
162
|
+
console.log(` relay: ${state.relay.connected ? "tunnel up" : "tunnel down"} → ${state.relay.url} (${state.relay.desktopId})`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { generateToken, lanIp, readToken, daemonStatePath } from "../paths.js";
|
|
3
|
+
import { qrText } from "../qrterm.js";
|
|
4
|
+
import { daemonWords } from "../e2ee.js";
|
|
5
|
+
import { readRelayConfig } from "../relay-client.js";
|
|
6
|
+
|
|
7
|
+
function readAnyState() {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(fs.readFileSync(daemonStatePath(), "utf8"));
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function token(argv = []) {
|
|
16
|
+
const regen = argv.includes("--regen");
|
|
17
|
+
const t = regen ? generateToken() : readToken();
|
|
18
|
+
if (!t) {
|
|
19
|
+
console.error("no token yet — run `ocduet install` first, or use --regen");
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const state = readAnyState();
|
|
24
|
+
const ip = state?.lanIp || lanIp();
|
|
25
|
+
const port = state?.port || 4098;
|
|
26
|
+
|
|
27
|
+
const words = daemonWords();
|
|
28
|
+
console.log(`security words: ${words.join(" ")}`);
|
|
29
|
+
console.log(" (compare with the words your phone shows while pairing — a mismatch means");
|
|
30
|
+
console.log(" something between this machine and the phone is impersonating the daemon)");
|
|
31
|
+
console.log(`token: ${t}`);
|
|
32
|
+
if (ip) {
|
|
33
|
+
const url = `https://${ip}:${port}/pair#t=${t}`;
|
|
34
|
+
console.log(`url: ${url}${state ? "" : " (port guessed — start opencode for the live one)"}`);
|
|
35
|
+
console.log();
|
|
36
|
+
console.log(qrText(url));
|
|
37
|
+
} else {
|
|
38
|
+
console.log("no LAN IP found — are you on a network? The TUI sidebar QR will contain the right URL.");
|
|
39
|
+
}
|
|
40
|
+
const relay = readRelayConfig();
|
|
41
|
+
if (relay) {
|
|
42
|
+
const origin = new URL(relay.url).origin;
|
|
43
|
+
const url = `${origin}/pair?d=${relay.desktopId}#t=${t}`;
|
|
44
|
+
console.log();
|
|
45
|
+
console.log(`relay url (works from any network): ${url}`);
|
|
46
|
+
console.log(qrText(url));
|
|
47
|
+
}
|
|
48
|
+
if (regen) console.log("\nnote: paired phones authenticate with their own keys and stay connected; new pairings must re-scan.");
|
|
49
|
+
}
|