symbio 0.2.1 → 0.3.0-beta.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/CHANGELOG.md +6 -0
- package/README.md +8 -0
- package/bin/symbio.js +39 -0
- package/dist/server/index.js +51 -3
- package/dist/server/remote-access.js +270 -0
- package/dist/web/assets/index-BK_T09wc.js +52 -0
- package/dist/web/assets/index-Cvhoj8Ir.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Cbd1sDvV.css +0 -1
- package/dist/web/assets/index-DrIslUUT.js +0 -52
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ versions follow [Semantic Versioning](https://semver.org/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.3.0]
|
|
9
|
+
|
|
10
|
+
- Remote access: `sym remote` (or Settings → Remote access) opens a temporary Cloudflare link with an
|
|
11
|
+
8-character access code; remote sessions need the code, 10 wrong codes shut it down, the agents'
|
|
12
|
+
tools and shutdown are never reachable through it, and a banner with a Turn off button shows it's on.
|
|
13
|
+
|
|
8
14
|
## [0.2.1]
|
|
9
15
|
|
|
10
16
|
- Releases are published from GitHub Actions with npm trusted publishing (no tokens), with
|
package/README.md
CHANGED
|
@@ -112,6 +112,13 @@ on your computer, including the agents, from using Symbio's API behind your back
|
|
|
112
112
|
| `sym logs` | The last lines of the log (`~/.symbio/logs/symbio.log`). |
|
|
113
113
|
| `sym doctor` | Check Node.js, git, OpenCode, the port and model providers. |
|
|
114
114
|
| `sym run` | Run in the foreground, with the log in the terminal. |
|
|
115
|
+
| `sym remote` | Open a temporary link and access code to use Symbio from another device (see below). `sym remote off` closes it. |
|
|
116
|
+
|
|
117
|
+
**From another device.** `sym remote` opens a temporary Cloudflare link to your Symbio and prints it
|
|
118
|
+
with an access code. Open the link anywhere (phone, another computer), enter the code, and you're
|
|
119
|
+
in. A banner shows it's on, with a button to turn it off; after 10 wrong codes it turns off by
|
|
120
|
+
itself. Whoever gets in controls your agents, so keep the link and code to yourself. Details in
|
|
121
|
+
[docs/security.md](docs/security.md#remote-access-optional).
|
|
115
122
|
|
|
116
123
|
`symbio` works too, as the long name of `sym`. To run from a clone instead: `npm install`,
|
|
117
124
|
`npm run build`, then `npm link` once to get the commands.
|
|
@@ -172,6 +179,7 @@ All optional, set as environment variables:
|
|
|
172
179
|
| `SYMBIO_NO_OPEN` | | Set to `1` to not open the browser on start (use the link Symbio prints) |
|
|
173
180
|
| `SYMBIO_DEBUG` | | Set to `1` to enable the read-only OpenCode debug routes (for development) |
|
|
174
181
|
| `SYMBIO_OPENCODE_BIN` | bundled | Path to an `opencode` executable to use instead of the bundled one |
|
|
182
|
+
| `SYMBIO_CLOUDFLARED_BIN` | auto | Path to `cloudflared` for remote access (otherwise the PATH, or a copy downloaded once) |
|
|
175
183
|
| `SYMBIO_STUCK_MINUTES` | `10` | Silence before the watchdog interrupts a working agent |
|
|
176
184
|
| `SYMBIO_RECYCLE_MB` | `1500` | Restart OpenCode above this memory use once every agent is idle |
|
|
177
185
|
| `SYMBIO_RECYCLE_HARD_MB` | `3000` | Restart OpenCode above this memory use regardless (agents resume) |
|
package/bin/symbio.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// sym logs the last lines of its log
|
|
9
9
|
// sym doctor check that everything Symbio needs is in place
|
|
10
10
|
// sym run run in the foreground (for debugging)
|
|
11
|
+
// sym remote open a temporary link to use Symbio from another device (sym remote off to close it)
|
|
11
12
|
|
|
12
13
|
import { execFileSync, exec, spawn } from "node:child_process";
|
|
13
14
|
import { existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
@@ -113,12 +114,43 @@ async function status() {
|
|
|
113
114
|
const runtime = (await api("GET", "/api/runtime")).body;
|
|
114
115
|
const projects = (await api("GET", "/api/projects")).body ?? [];
|
|
115
116
|
console.log(`OpenCode ${runtime?.version ?? "?"} (${runtime?.state ?? "?"}${runtime?.memoryMB ? `, ${runtime.memoryMB} MB` : ""})`);
|
|
117
|
+
const remote = (await api("GET", "/api/remote")).body;
|
|
118
|
+
if (remote?.active) console.log(`Remote access ON: ${remote.url} (${remote.sessions} session(s)) — sym remote off`);
|
|
116
119
|
for (const p of projects) {
|
|
117
120
|
const working = p.agents.filter((a) => a.status === "busy").length;
|
|
118
121
|
console.log(`- ${p.name}: ${p.agents.filter((a) => a.status !== "stopped").length} agents${working ? `, ${working} working` : ""}${p.paused ? " (paused)" : ""}`);
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
async function remoteOn() {
|
|
126
|
+
if ((await state()) !== "running") {
|
|
127
|
+
process.env.SYMBIO_NO_OPEN = "1";
|
|
128
|
+
await start();
|
|
129
|
+
}
|
|
130
|
+
console.log("Opening a temporary link (the first time, cloudflared is downloaded)…");
|
|
131
|
+
const res = await fetch(BASE + "/api/remote/start", { method: "POST", headers: { "x-symbio-token": token() }, signal: AbortSignal.timeout(180_000) });
|
|
132
|
+
const body = await res.json().catch(() => ({}));
|
|
133
|
+
if (!res.ok) {
|
|
134
|
+
console.error(`Couldn't open remote access: ${body.error ?? res.status}`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
const code = body.code ? `${body.code.slice(0, 4)}-${body.code.slice(4)}` : "?";
|
|
138
|
+
console.log(`
|
|
139
|
+
Remote access is on.
|
|
140
|
+
|
|
141
|
+
Link: ${body.url}
|
|
142
|
+
Code: ${code}
|
|
143
|
+
|
|
144
|
+
Whoever has both controls your agents, which run commands on this computer.
|
|
145
|
+
Turn it off with "sym remote off" (or the button in Symbio). After 10 wrong codes it turns off by itself.`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function remoteOff() {
|
|
149
|
+
if ((await state()) !== "running") return console.log("Symbio isn't running.");
|
|
150
|
+
await api("POST", "/api/remote/stop");
|
|
151
|
+
console.log("Remote access is off.");
|
|
152
|
+
}
|
|
153
|
+
|
|
122
154
|
function logs() {
|
|
123
155
|
if (!existsSync(LOG)) return console.log(`No log yet (${LOG}).`);
|
|
124
156
|
const lines = readFileSync(LOG, "utf8").split(/\r?\n/);
|
|
@@ -169,6 +201,8 @@ Usage: sym [command]
|
|
|
169
201
|
logs show the last lines of the log
|
|
170
202
|
doctor check that everything Symbio needs is in place
|
|
171
203
|
run run in the foreground (logs in this terminal)
|
|
204
|
+
remote open a temporary link + access code to use Symbio from another device
|
|
205
|
+
remote off close that link
|
|
172
206
|
--version print the version
|
|
173
207
|
|
|
174
208
|
Environment: SYMBIO_PORT (default 4310), SYMBIO_HOME (default ~/.symbio), SYMBIO_NO_OPEN=1.`);
|
|
@@ -191,6 +225,11 @@ switch (cmd) {
|
|
|
191
225
|
openBrowser(link());
|
|
192
226
|
} else console.log('Symbio isn\'t running. Start it with "sym".');
|
|
193
227
|
break;
|
|
228
|
+
case "remote":
|
|
229
|
+
case "--remote":
|
|
230
|
+
if (process.argv[3] === "off") await remoteOff();
|
|
231
|
+
else await remoteOn();
|
|
232
|
+
break;
|
|
194
233
|
case "logs":
|
|
195
234
|
logs();
|
|
196
235
|
break;
|
package/dist/server/index.js
CHANGED
|
@@ -10,6 +10,8 @@ import { RemoteOps } from "./remote.js";
|
|
|
10
10
|
import { handleMcp } from "./mcp.js";
|
|
11
11
|
import { isAllowedRequest } from "./origin.js";
|
|
12
12
|
import { loadUiToken, tokenMatches } from "./ui-token.js";
|
|
13
|
+
import { isTunnelRequest, loginPage, RemoteAccess, remoteDecision, SESSION_COOKIE } from "./remote-access.js";
|
|
14
|
+
import { bus } from "./bus.js";
|
|
13
15
|
import { OpencodeRuntime } from "./opencode/runtime.js";
|
|
14
16
|
import { Store, globalDir } from "./store.js";
|
|
15
17
|
import { Team } from "./team.js";
|
|
@@ -66,11 +68,42 @@ async function main() {
|
|
|
66
68
|
// are told to continue once OpenCode is up again.
|
|
67
69
|
team.markInterrupted("Symbio was stopped and has been restarted");
|
|
68
70
|
const uiToken = loadUiToken(globalDir);
|
|
71
|
+
const remote = new RemoteAccess(path.join(globalDir, "bin"));
|
|
72
|
+
remote.onChange = () => bus.publish({ type: "remote.updated" });
|
|
73
|
+
const remoteRequests = new WeakSet(); // requests that came through the tunnel
|
|
69
74
|
const app = new Hono();
|
|
70
75
|
app.use("*", async (c, next) => {
|
|
71
|
-
const
|
|
72
|
-
if (!
|
|
73
|
-
|
|
76
|
+
const viaTunnel = isTunnelRequest({ host: c.req.header("host"), cfRay: c.req.header("cf-ray"), cfIp: c.req.header("cf-connecting-ip") }, remote.host);
|
|
77
|
+
if (!viaTunnel) {
|
|
78
|
+
const ok = isAllowedRequest({ method: c.req.method, host: c.req.header("host"), origin: c.req.header("origin") }, PORT);
|
|
79
|
+
if (!ok)
|
|
80
|
+
return c.json({ error: "Requests must come from Symbio's own page on this computer." }, 403);
|
|
81
|
+
return next();
|
|
82
|
+
}
|
|
83
|
+
// Through the tunnel: only Symbio's own remote page may change things.
|
|
84
|
+
const origin = c.req.header("origin");
|
|
85
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD" && origin && origin !== `https://${remote.host}`) {
|
|
86
|
+
return c.json({ error: "Not allowed." }, 403);
|
|
87
|
+
}
|
|
88
|
+
const decision = remoteDecision({ method: c.req.method, path: c.req.path, hasSession: remote.hasSession(c.req.header("cookie")), active: remote.active });
|
|
89
|
+
if (decision === "deny")
|
|
90
|
+
return c.text("Not available through remote access.", 403);
|
|
91
|
+
if (decision === "login-page")
|
|
92
|
+
return c.html(loginPage());
|
|
93
|
+
if (decision === "need-login")
|
|
94
|
+
return c.json({ error: "Log in with the access code.", code: "remote-login" }, 401);
|
|
95
|
+
if (decision === "login-submit") {
|
|
96
|
+
const body = await c.req.json().catch(() => ({}));
|
|
97
|
+
const result = await remote.login(String(body.code ?? ""));
|
|
98
|
+
if (result.ok) {
|
|
99
|
+
c.header("Set-Cookie", `${SESSION_COOKIE}=${result.session}; Path=/; Max-Age=${result.maxAge}; HttpOnly; Secure; SameSite=Strict`);
|
|
100
|
+
return c.json({ token: uiToken });
|
|
101
|
+
}
|
|
102
|
+
if (!remote.active)
|
|
103
|
+
return c.json({ error: "Too many wrong codes: remote access was turned off." }, 423);
|
|
104
|
+
return c.json({ error: `Wrong code. ${result.left} attempt${result.left === 1 ? "" : "s"} left.` }, 401);
|
|
105
|
+
}
|
|
106
|
+
remoteRequests.add(c.req.raw);
|
|
74
107
|
await next();
|
|
75
108
|
});
|
|
76
109
|
// The UI's key: a header, or a query parameter for the event stream (EventSource can't set headers).
|
|
@@ -87,6 +120,20 @@ async function main() {
|
|
|
87
120
|
setTimeout(() => void shutdown?.(), 200);
|
|
88
121
|
return c.json({ ok: true });
|
|
89
122
|
});
|
|
123
|
+
// Remote access: status for everyone (the code only locally), start locally, stop from anywhere.
|
|
124
|
+
app.get("/api/remote", (c) => c.json(remote.status(!remoteRequests.has(c.req.raw))));
|
|
125
|
+
app.post("/api/remote/start", async (c) => {
|
|
126
|
+
try {
|
|
127
|
+
return c.json(await remote.start(PORT));
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
return c.json({ error: err.message }, 500);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
app.post("/api/remote/stop", (c) => {
|
|
134
|
+
remote.stop(remoteRequests.has(c.req.raw) ? "turned off from a remote session" : "turned off on this computer");
|
|
135
|
+
return c.json(remote.status(!remoteRequests.has(c.req.raw)));
|
|
136
|
+
});
|
|
90
137
|
app.route("/api", createApi(store, runtime, team, connections));
|
|
91
138
|
// Agents' tool server. Only reachable from this machine (we bind to 127.0.0.1) and only with a per-run token.
|
|
92
139
|
app.all("/mcp/:token", (c) => handleMcp(c, team.mcpResolver));
|
|
@@ -115,6 +162,7 @@ async function main() {
|
|
|
115
162
|
.catch((err) => console.error("OpenCode failed to start:", err.message));
|
|
116
163
|
team.startWatchdog();
|
|
117
164
|
shutdown = async () => {
|
|
165
|
+
remote.stop("Symbio stopped");
|
|
118
166
|
team.shuttingDown = true;
|
|
119
167
|
team.stopWatchdog();
|
|
120
168
|
await runtime.stop();
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Remote access: a temporary Cloudflare quick tunnel (https://….trycloudflare.com) to this
|
|
2
|
+
// Symbio, guarded by an access code.
|
|
3
|
+
//
|
|
4
|
+
// Through the tunnel Symbio controls agents that run commands on this computer, so:
|
|
5
|
+
// - remote requests need a session, obtained by entering the access code (8 characters);
|
|
6
|
+
// after 10 wrong codes the tunnel is shut down;
|
|
7
|
+
// - API calls also need the UI key, as locally (a leaked key alone isn't enough from outside);
|
|
8
|
+
// - the agents' tool endpoint, debug routes and shutdown are never reachable remotely;
|
|
9
|
+
// - turning remote access off ends every remote session; the code is shown only locally.
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import { randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
12
|
+
import { chmodSync, existsSync, promises as fs } from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no 0/O, 1/I/L
|
|
16
|
+
export const CODE_LENGTH = 8;
|
|
17
|
+
export const MAX_FAILURES = 10;
|
|
18
|
+
const SESSION_MS = 12 * 3600_000;
|
|
19
|
+
export const SESSION_COOKIE = "symbio_remote";
|
|
20
|
+
export function newAccessCode() {
|
|
21
|
+
let code = "";
|
|
22
|
+
for (let i = 0; i < CODE_LENGTH; i++)
|
|
23
|
+
code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
|
|
24
|
+
return code;
|
|
25
|
+
}
|
|
26
|
+
export function codesMatch(expected, given) {
|
|
27
|
+
const a = Buffer.from(expected);
|
|
28
|
+
const b = Buffer.from(given.trim().toUpperCase().replace(/[\s-]/g, ""));
|
|
29
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
30
|
+
}
|
|
31
|
+
/** Requests that came through Cloudflare (they carry its headers) or name the tunnel's host. */
|
|
32
|
+
export function isTunnelRequest(headers, tunnelHost) {
|
|
33
|
+
if (headers.cfRay || headers.cfIp)
|
|
34
|
+
return true;
|
|
35
|
+
const host = (headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
|
|
36
|
+
return !!tunnelHost && host === tunnelHost.toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
// Never through the tunnel: the agents' tools, debugging, shutting Symbio down, starting a
|
|
39
|
+
// tunnel, and the native folder dialog (it would open on this computer's screen).
|
|
40
|
+
const NEVER_REMOTE = [/^\/mcp(\/|$)/, /^\/api\/debug(\/|$)/, /^\/api\/shutdown$/, /^\/api\/remote\/start$/, /^\/api\/pick-folder$/];
|
|
41
|
+
/** What to do with a request that came through the tunnel. */
|
|
42
|
+
export function remoteDecision(req) {
|
|
43
|
+
if (!req.active)
|
|
44
|
+
return "deny";
|
|
45
|
+
if (NEVER_REMOTE.some((re) => re.test(req.path)))
|
|
46
|
+
return "deny";
|
|
47
|
+
if (req.path === "/remote-login")
|
|
48
|
+
return req.method === "POST" ? "login-submit" : "login-page";
|
|
49
|
+
if (req.hasSession)
|
|
50
|
+
return "allow";
|
|
51
|
+
return req.path.startsWith("/api/") ? "need-login" : "login-page";
|
|
52
|
+
}
|
|
53
|
+
export function cookieValue(header, name) {
|
|
54
|
+
for (const part of (header ?? "").split(";")) {
|
|
55
|
+
const [k, ...v] = part.trim().split("=");
|
|
56
|
+
if (k === name)
|
|
57
|
+
return v.join("=");
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
export class RemoteAccess {
|
|
62
|
+
binDir;
|
|
63
|
+
proc = null;
|
|
64
|
+
url = null;
|
|
65
|
+
code = null;
|
|
66
|
+
startedAt = null;
|
|
67
|
+
sessions = new Map(); // id → expiry
|
|
68
|
+
failures = 0;
|
|
69
|
+
lastStop = null;
|
|
70
|
+
onChange = () => { };
|
|
71
|
+
constructor(binDir) {
|
|
72
|
+
this.binDir = binDir;
|
|
73
|
+
}
|
|
74
|
+
get active() {
|
|
75
|
+
return !!this.proc && !!this.url;
|
|
76
|
+
}
|
|
77
|
+
get host() {
|
|
78
|
+
return this.url ? new URL(this.url).host : null;
|
|
79
|
+
}
|
|
80
|
+
status(local) {
|
|
81
|
+
this.pruneSessions();
|
|
82
|
+
return {
|
|
83
|
+
active: this.active,
|
|
84
|
+
url: this.url,
|
|
85
|
+
startedAt: this.startedAt,
|
|
86
|
+
sessions: this.sessions.size,
|
|
87
|
+
...(local ? { code: this.code } : {}),
|
|
88
|
+
lastStop: this.lastStop,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Opens the tunnel to http://127.0.0.1:<port>. Resolves once Cloudflare gave a URL. */
|
|
92
|
+
async start(port) {
|
|
93
|
+
if (this.active)
|
|
94
|
+
return this.status(true);
|
|
95
|
+
const bin = await this.cloudflared();
|
|
96
|
+
const args = ["tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${port}`];
|
|
97
|
+
// A .js stand-in (SYMBIO_CLOUDFLARED_BIN) runs with Node: used to test without opening a real tunnel.
|
|
98
|
+
const [cmd, argv] = /\.m?js$/.test(bin) ? [process.execPath, [bin, ...args]] : [bin, args];
|
|
99
|
+
const proc = spawn(cmd, argv, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
100
|
+
this.proc = proc;
|
|
101
|
+
const url = await new Promise((resolve, reject) => {
|
|
102
|
+
const timer = setTimeout(() => reject(new Error("Cloudflare didn't open a tunnel within 60 seconds.")), 60_000);
|
|
103
|
+
const onData = (buf) => {
|
|
104
|
+
const m = buf.toString().match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
105
|
+
if (m) {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
resolve(m[0]);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
proc.stdout.on("data", onData);
|
|
111
|
+
proc.stderr.on("data", onData);
|
|
112
|
+
proc.once("exit", (code) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
reject(new Error(`cloudflared exited (code ${code}) before opening the tunnel.`));
|
|
115
|
+
});
|
|
116
|
+
}).catch((err) => {
|
|
117
|
+
this.kill();
|
|
118
|
+
throw err;
|
|
119
|
+
});
|
|
120
|
+
this.url = url;
|
|
121
|
+
this.code = newAccessCode();
|
|
122
|
+
this.startedAt = Date.now();
|
|
123
|
+
this.failures = 0;
|
|
124
|
+
this.sessions.clear();
|
|
125
|
+
proc.once("exit", () => {
|
|
126
|
+
if (this.proc === proc)
|
|
127
|
+
this.reset("the tunnel closed");
|
|
128
|
+
});
|
|
129
|
+
console.log(`[remote] on: ${url}`);
|
|
130
|
+
this.onChange();
|
|
131
|
+
return this.status(true);
|
|
132
|
+
}
|
|
133
|
+
stop(reason) {
|
|
134
|
+
if (!this.proc && !this.url)
|
|
135
|
+
return;
|
|
136
|
+
this.kill();
|
|
137
|
+
this.reset(reason);
|
|
138
|
+
}
|
|
139
|
+
kill() {
|
|
140
|
+
const proc = this.proc;
|
|
141
|
+
this.proc = null;
|
|
142
|
+
if (proc && proc.exitCode === null)
|
|
143
|
+
proc.kill();
|
|
144
|
+
}
|
|
145
|
+
reset(reason) {
|
|
146
|
+
this.proc = null;
|
|
147
|
+
this.url = null;
|
|
148
|
+
this.code = null;
|
|
149
|
+
this.startedAt = null;
|
|
150
|
+
this.sessions.clear();
|
|
151
|
+
this.lastStop = { at: Date.now(), reason };
|
|
152
|
+
console.log(`[remote] off: ${reason}`);
|
|
153
|
+
this.onChange();
|
|
154
|
+
}
|
|
155
|
+
hasSession(cookieHeader) {
|
|
156
|
+
const id = cookieValue(cookieHeader, SESSION_COOKIE);
|
|
157
|
+
const expiry = id ? this.sessions.get(id) : undefined;
|
|
158
|
+
return !!expiry && expiry > Date.now();
|
|
159
|
+
}
|
|
160
|
+
/** Checks an access code. Too many wrong ones shut the tunnel down. */
|
|
161
|
+
async login(given) {
|
|
162
|
+
if (this.code && codesMatch(this.code, given)) {
|
|
163
|
+
const session = randomBytes(32).toString("base64url");
|
|
164
|
+
this.sessions.set(session, Date.now() + SESSION_MS);
|
|
165
|
+
console.log("[remote] a remote session started");
|
|
166
|
+
this.onChange();
|
|
167
|
+
return { ok: true, session, maxAge: SESSION_MS / 1000 };
|
|
168
|
+
}
|
|
169
|
+
this.failures++;
|
|
170
|
+
console.warn(`[remote] wrong access code (${this.failures}/${MAX_FAILURES})`);
|
|
171
|
+
if (this.failures >= MAX_FAILURES)
|
|
172
|
+
this.stop(`${MAX_FAILURES} wrong access codes; turned off for safety`);
|
|
173
|
+
await new Promise((r) => setTimeout(r, 1500)); // slows guessing down
|
|
174
|
+
return { ok: false, left: Math.max(0, MAX_FAILURES - this.failures) };
|
|
175
|
+
}
|
|
176
|
+
pruneSessions() {
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
for (const [id, exp] of this.sessions)
|
|
179
|
+
if (exp <= now)
|
|
180
|
+
this.sessions.delete(id);
|
|
181
|
+
}
|
|
182
|
+
/** cloudflared from SYMBIO_CLOUDFLARED_BIN, the PATH, or downloaded once into ~/.symbio/bin. */
|
|
183
|
+
async cloudflared() {
|
|
184
|
+
if (process.env.SYMBIO_CLOUDFLARED_BIN)
|
|
185
|
+
return process.env.SYMBIO_CLOUDFLARED_BIN;
|
|
186
|
+
const onPath = spawnSync("cloudflared", ["--version"], { windowsHide: true, encoding: "utf8" });
|
|
187
|
+
if (onPath.status === 0)
|
|
188
|
+
return "cloudflared";
|
|
189
|
+
const exe = path.join(this.binDir, process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
|
|
190
|
+
if (existsSync(exe))
|
|
191
|
+
return exe;
|
|
192
|
+
await downloadCloudflared(exe);
|
|
193
|
+
return exe;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Downloads cloudflared from Cloudflare's official GitHub releases. */
|
|
197
|
+
async function downloadCloudflared(dest) {
|
|
198
|
+
const arch = os.arch() === "arm64" ? "arm64" : "amd64";
|
|
199
|
+
const asset = process.platform === "win32"
|
|
200
|
+
? `cloudflared-windows-${arch}.exe`
|
|
201
|
+
: process.platform === "darwin"
|
|
202
|
+
? `cloudflared-darwin-${arch}.tgz`
|
|
203
|
+
: `cloudflared-linux-${arch}`;
|
|
204
|
+
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`;
|
|
205
|
+
console.log(`[remote] downloading cloudflared (${asset})…`);
|
|
206
|
+
const res = await fetch(url);
|
|
207
|
+
if (!res.ok)
|
|
208
|
+
throw new Error(`Couldn't download cloudflared (${res.status}). Install it yourself: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/`);
|
|
209
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
210
|
+
const tmp = `${dest}.download`;
|
|
211
|
+
await fs.writeFile(tmp, Buffer.from(await res.arrayBuffer()));
|
|
212
|
+
if (asset.endsWith(".tgz")) {
|
|
213
|
+
const r = spawnSync("tar", ["-xzf", tmp, "-C", path.dirname(dest)]);
|
|
214
|
+
await fs.rm(tmp, { force: true });
|
|
215
|
+
if (r.status !== 0)
|
|
216
|
+
throw new Error("Couldn't unpack cloudflared.");
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
await fs.rename(tmp, dest);
|
|
220
|
+
}
|
|
221
|
+
if (process.platform !== "win32")
|
|
222
|
+
chmodSync(dest, 0o755);
|
|
223
|
+
}
|
|
224
|
+
/** The page remote visitors see first: enter the access code. Self-contained (no assets). */
|
|
225
|
+
export function loginPage() {
|
|
226
|
+
return `<!doctype html>
|
|
227
|
+
<html lang="en">
|
|
228
|
+
<head>
|
|
229
|
+
<meta charset="utf-8" />
|
|
230
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
231
|
+
<meta name="robots" content="noindex" />
|
|
232
|
+
<title>Symbio — remote access</title>
|
|
233
|
+
<style>
|
|
234
|
+
:root { color-scheme: dark; }
|
|
235
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0b0c0e; color: #e8e9ec; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
236
|
+
.card { width: min(360px, calc(100vw - 32px)); padding: 28px 24px; border: 1px solid #23252b; border-radius: 12px; background: #111215; }
|
|
237
|
+
h1 { margin: 0 0 6px; font-size: 18px; display: flex; align-items: center; gap: 8px; }
|
|
238
|
+
p { margin: 0 0 18px; color: #9a9ca4; }
|
|
239
|
+
input { box-sizing: border-box; width: 100%; height: 44px; padding: 0 12px; border-radius: 8px; border: 1px solid #2c2f36; background: #0b0c0e; color: #e8e9ec; font: 600 20px/1 ui-monospace, monospace; letter-spacing: 0.25em; text-transform: uppercase; text-align: center; }
|
|
240
|
+
input:focus { outline: none; border-color: #6d6af5; box-shadow: 0 0 0 3px rgba(109,106,245,.25); }
|
|
241
|
+
button { margin-top: 12px; width: 100%; height: 40px; border: 0; border-radius: 8px; background: #6d6af5; color: white; font: 600 14px system-ui, sans-serif; cursor: pointer; }
|
|
242
|
+
button:disabled { opacity: .6; cursor: default; }
|
|
243
|
+
.error { margin-top: 12px; color: #f0726a; min-height: 1.5em; }
|
|
244
|
+
</style>
|
|
245
|
+
</head>
|
|
246
|
+
<body>
|
|
247
|
+
<form class="card" id="f">
|
|
248
|
+
<h1><svg width="22" height="22" viewBox="0 0 24 24"><circle cx="9" cy="12" r="6.5" fill="none" stroke="#6d6af5" stroke-width="2.2"/><circle cx="15" cy="12" r="6.5" fill="none" stroke="#3fb27f" stroke-width="2.2"/></svg>Symbio</h1>
|
|
249
|
+
<p>Enter the access code shown on the computer running Symbio.</p>
|
|
250
|
+
<input id="code" name="code" autocomplete="one-time-code" maxlength="12" autofocus required />
|
|
251
|
+
<button id="go">Open Symbio</button>
|
|
252
|
+
<div class="error" id="err"></div>
|
|
253
|
+
</form>
|
|
254
|
+
<script>
|
|
255
|
+
const f = document.getElementById("f"), go = document.getElementById("go"), err = document.getElementById("err");
|
|
256
|
+
f.addEventListener("submit", async (e) => {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
go.disabled = true; err.textContent = "";
|
|
259
|
+
try {
|
|
260
|
+
const res = await fetch("/remote-login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code: document.getElementById("code").value }) });
|
|
261
|
+
const data = await res.json().catch(() => ({}));
|
|
262
|
+
if (res.ok && data.token) { localStorage.setItem("symbio:token", data.token); location.href = "/"; return; }
|
|
263
|
+
err.textContent = data.error || "That didn't work.";
|
|
264
|
+
} catch { err.textContent = "Couldn't reach Symbio."; }
|
|
265
|
+
go.disabled = false;
|
|
266
|
+
});
|
|
267
|
+
</script>
|
|
268
|
+
</body>
|
|
269
|
+
</html>`;
|
|
270
|
+
}
|