9remote 2.0.2 → 2.0.7
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/dist/cli.cjs +1 -1
- package/dist/install.cjs +2 -0
- package/dist/ptyDaemon.cjs +1 -1
- package/dist/server.cjs +1 -1
- package/dist/ui/assets/{index-COWVKicT.css → index-BMHG73CL.css} +1 -1
- package/dist/ui/assets/index-Bg86Demx.js +8 -0
- package/dist/ui/index.html +2 -2
- package/package.json +4 -6
- package/cli/index.js +0 -1330
- package/cli/scripts/install.js +0 -19
- package/cli/utils/apiKey.js +0 -77
- package/cli/utils/assets/trayIcon.ico +0 -0
- package/cli/utils/cloudflared.js +0 -493
- package/cli/utils/machineId.js +0 -22
- package/cli/utils/permissions.js +0 -45
- package/cli/utils/pids.js +0 -114
- package/cli/utils/state.js +0 -115
- package/cli/utils/token.js +0 -32
- package/cli/utils/tray.js +0 -251
- package/cli/utils/tui.js +0 -445
- package/cli/utils/updateChecker.js +0 -358
- package/dist/ui/assets/index-BWfJSBGG.js +0 -8
- package/index.js +0 -275
- package/lib/constants.js +0 -64
- package/lib/deviceApproval.js +0 -152
- package/lib/router.js +0 -134
- package/lib/socketio.js +0 -240
package/index.js
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server entry point - config-driven HTTP router + Socket.IO
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { createServer, request as httpRequest } from "http";
|
|
6
|
-
import { exec, spawn } from "child_process";
|
|
7
|
-
import { readFileSync, existsSync } from "fs";
|
|
8
|
-
import { join, extname } from "path";
|
|
9
|
-
import { fileURLToPath } from "url";
|
|
10
|
-
import chalk from "chalk";
|
|
11
|
-
|
|
12
|
-
import { createRouter, jsonOk, jsonErr } from "./lib/router.js";
|
|
13
|
-
import { STEP, browserFetch, PERMISSION_POLL_MS } from "./lib/constants.js";
|
|
14
|
-
import { setupSocketIO, getIO } from "./lib/socketio.js";
|
|
15
|
-
import { setCorsHeaders, handlePreflight } from "./middleware/cors.js";
|
|
16
|
-
import { createProxyServer, handleProxyRequest, startProxySession, endProxySession } from "./proxy/index.js";
|
|
17
|
-
import { initializeTerminal } from "./features/terminal/terminalSocket.js";
|
|
18
|
-
import { handleLocalSites } from "./api/localSites.js";
|
|
19
|
-
import { verifyApiKeyCrc } from "./cli/utils/apiKey.js";
|
|
20
|
-
import { isNewerVersion } from "./cli/utils/updateChecker.js";
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
loadUiState, loadDesktopState, refreshPermissionsAsync, pushUiEvent, setRemoteAvailable,
|
|
24
|
-
handleSseEvents, handleStateGet, handleStatePost,
|
|
25
|
-
handleStop, handleStart, handleShutdown,
|
|
26
|
-
handleConnections, handleDesktopToggle,
|
|
27
|
-
handlePermissionsGet, handlePermissionsRequest,
|
|
28
|
-
} from "./api/ui.js";
|
|
29
|
-
import { handleOneTimeKey, handleRegenerate } from "./api/key.js";
|
|
30
|
-
import { handleApprove, handleReject, handlePending, handleApproved, handleRemove, handleDisconnect, handleRejected, handleApproveRejected, handleClearRejected, handleGetAutoApprove, handleSetAutoApprove } from "./api/device.js";
|
|
31
|
-
import { handleNotifyPost, handleNotifyGet } from "./api/notify.js";
|
|
32
|
-
|
|
33
|
-
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
34
|
-
const IS_DEV = process.env.NODE_ENV === "development";
|
|
35
|
-
const VITE_PORT = 5173;
|
|
36
|
-
const NPM_REGISTRY_URL = "https://registry.npmjs.org/9remote/latest";
|
|
37
|
-
|
|
38
|
-
const UI_DIST = existsSync(join(__dirname, "ui", "dist"))
|
|
39
|
-
? join(__dirname, "ui", "dist")
|
|
40
|
-
: join(__dirname, "ui");
|
|
41
|
-
|
|
42
|
-
const MIME_TYPES = {
|
|
43
|
-
".html": "text/html", ".js": "application/javascript", ".css": "text/css",
|
|
44
|
-
".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon",
|
|
45
|
-
".woff2": "font/woff2", ".woff": "font/woff",
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// ── Static / Dev helpers ─────────────────────────────────────────
|
|
49
|
-
|
|
50
|
-
function proxyToVite(req, res, fallbackFn) {
|
|
51
|
-
const proxy = httpRequest(
|
|
52
|
-
{ hostname: "localhost", port: VITE_PORT, path: req.url, method: req.method, headers: req.headers },
|
|
53
|
-
(proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }
|
|
54
|
-
);
|
|
55
|
-
proxy.on("error", () => fallbackFn ? fallbackFn() : (() => { res.writeHead(502); res.end("Vite dev server not ready"); })());
|
|
56
|
-
req.pipe(proxy);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function serveStatic(res, filePath) {
|
|
60
|
-
if (!existsSync(filePath)) return false;
|
|
61
|
-
const mime = MIME_TYPES[extname(filePath)] || "application/octet-stream";
|
|
62
|
-
res.setHeader("Content-Type", mime);
|
|
63
|
-
res.writeHead(200);
|
|
64
|
-
res.end(readFileSync(filePath));
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function checkForUpdate(currentVersion) {
|
|
69
|
-
try {
|
|
70
|
-
const res = await browserFetch(NPM_REGISTRY_URL);
|
|
71
|
-
if (!res.ok) return;
|
|
72
|
-
const { version } = await res.json();
|
|
73
|
-
if (version && isNewerVersion(currentVersion, version)) pushUiEvent("updateAvailable", { version });
|
|
74
|
-
} catch { /* non-critical */ }
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ── Codespace handler ────────────────────────────────────────
|
|
78
|
-
|
|
79
|
-
function handleCodespaceStop(req, res) {
|
|
80
|
-
if (process.env.CODESPACES !== "true") { jsonErr(res, 400, "Not running on Codespaces"); return; }
|
|
81
|
-
const name = process.env.CODESPACE_NAME;
|
|
82
|
-
if (!name) { jsonErr(res, 400, "Codespace name not found"); return; }
|
|
83
|
-
const io = getIO();
|
|
84
|
-
if (io) io.emit("codespace:stopping");
|
|
85
|
-
jsonOk(res, { success: true, message: "Stopping codespace..." });
|
|
86
|
-
setTimeout(() => exec(`gh codespace stop -c ${name}`, { windowsHide: true }), 500);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ── Proxy handlers ────────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
let proxyServer;
|
|
92
|
-
|
|
93
|
-
async function handleProxyStartEnd(req, res, { pathname }) {
|
|
94
|
-
// Verify API key (required for tunnel access)
|
|
95
|
-
const authHeader = req.headers.authorization;
|
|
96
|
-
if (!authHeader || !authHeader.startsWith("Bearer ") || !verifyApiKeyCrc(authHeader.slice(7))) {
|
|
97
|
-
jsonErr(res, 401, "Unauthorized");
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
const { parseJsonBody } = await import("./lib/router.js");
|
|
101
|
-
const data = await parseJsonBody(req, res);
|
|
102
|
-
if (!data) return;
|
|
103
|
-
if (!data.port) { jsonErr(res, 400, "Port required"); return; }
|
|
104
|
-
pathname.endsWith("start") ? startProxySession(data.port) : endProxySession(data.port);
|
|
105
|
-
jsonOk(res, { success: true });
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function handleProxy(req, res, { pathname, search }) {
|
|
109
|
-
const match = pathname.match(/^\/proxy\/(\d+)(\/.*)?$/);
|
|
110
|
-
if (match) {
|
|
111
|
-
handleProxyRequest(proxyServer, req, res, match[1], match[2] || "/", search);
|
|
112
|
-
} else {
|
|
113
|
-
jsonErr(res, 404, "Not found");
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
118
|
-
// ROUTE TABLE — single source of truth for all HTTP endpoints
|
|
119
|
-
// public: true = accessible via tunnel, false/omit = localhost-only
|
|
120
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
const ROUTES = [
|
|
123
|
-
// Public routes (accessible via tunnel)
|
|
124
|
-
{ path: "/api/health", method: "GET", public: true, handler: (req, res) => jsonOk(res, { status: "ok", timestamp: Date.now() }) },
|
|
125
|
-
{ path: "/api/version", method: "GET", public: true, handler: (req, res) => {
|
|
126
|
-
const version = typeof __CLI_VERSION__ !== "undefined"
|
|
127
|
-
? __CLI_VERSION__
|
|
128
|
-
: JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).version;
|
|
129
|
-
jsonOk(res, { version });
|
|
130
|
-
}},
|
|
131
|
-
{ path: "/api/notify", method: "POST", public: true, handler: handleNotifyPost },
|
|
132
|
-
{ path: "/api/notify", method: "GET", public: true, handler: handleNotifyGet },
|
|
133
|
-
{ path: "/proxy/*", method: "*", public: true, handler: handleProxy },
|
|
134
|
-
|
|
135
|
-
// UI state & SSE (localhost-only)
|
|
136
|
-
{ path: "/api/ui/events", method: "GET", handler: handleSseEvents },
|
|
137
|
-
{ path: "/api/ui/state", method: "GET", handler: handleStateGet },
|
|
138
|
-
{ path: "/api/ui/state", method: "POST", handler: handleStatePost },
|
|
139
|
-
{ path: "/api/ui/stop", method: "POST", handler: handleStop },
|
|
140
|
-
{ path: "/api/ui/start", method: "POST", handler: handleStart },
|
|
141
|
-
{ path: "/api/ui/shutdown", method: "POST", handler: handleShutdown },
|
|
142
|
-
|
|
143
|
-
// Key management (localhost-only)
|
|
144
|
-
{ path: "/api/key/one-time", method: "POST", handler: handleOneTimeKey },
|
|
145
|
-
{ path: "/api/key/regenerate", method: "POST", handler: handleRegenerate },
|
|
146
|
-
|
|
147
|
-
// Device approval (localhost-only)
|
|
148
|
-
{ path: "/api/device/approve", method: "POST", handler: handleApprove },
|
|
149
|
-
{ path: "/api/device/reject", method: "POST", handler: handleReject },
|
|
150
|
-
{ path: "/api/device/pending", method: "GET", handler: handlePending },
|
|
151
|
-
{ path: "/api/device/approved", method: "GET", handler: handleApproved },
|
|
152
|
-
{ path: "/api/device/rejected", method: "GET", handler: handleRejected },
|
|
153
|
-
{ path: "/api/device/approve-rejected", method: "POST", handler: handleApproveRejected },
|
|
154
|
-
{ path: "/api/device/clear-rejected", method: "POST", handler: handleClearRejected },
|
|
155
|
-
{ path: "/api/device/remove", method: "POST", handler: handleRemove },
|
|
156
|
-
{ path: "/api/device/disconnect", method: "POST", handler: handleDisconnect },
|
|
157
|
-
{ path: "/api/device/auto-approve", method: "GET", handler: handleGetAutoApprove },
|
|
158
|
-
{ path: "/api/device/auto-approve", method: "POST", handler: handleSetAutoApprove },
|
|
159
|
-
|
|
160
|
-
// System (localhost-only)
|
|
161
|
-
{ path: "/api/connections", method: "GET", handler: handleConnections },
|
|
162
|
-
{ path: "/api/permissions", method: "GET", handler: handlePermissionsGet },
|
|
163
|
-
{ path: "/api/permissions/request", method: "POST", handler: handlePermissionsRequest },
|
|
164
|
-
{ path: "/api/desktop/toggle", method: "POST", handler: handleDesktopToggle },
|
|
165
|
-
{ path: "/api/local-sites", method: "*", public: true, handler: handleLocalSites },
|
|
166
|
-
{ path: "/api/codespace/stop", method: "POST", handler: handleCodespaceStop },
|
|
167
|
-
|
|
168
|
-
// Proxy session management (tunnel-accessible, requires API key)
|
|
169
|
-
{ path: "/api/proxy/start", method: "POST", public: true, handler: handleProxyStartEnd },
|
|
170
|
-
{ path: "/api/proxy/end", method: "POST", public: true, handler: handleProxyStartEnd },
|
|
171
|
-
];
|
|
172
|
-
|
|
173
|
-
// ── Server bootstrap ───────────────────────────────────────────────────────
|
|
174
|
-
|
|
175
|
-
const hostname = "localhost";
|
|
176
|
-
const port = parseInt(process.env.PORT || "2208", 10);
|
|
177
|
-
|
|
178
|
-
// Auto-start Vite dev server in dev mode
|
|
179
|
-
let viteProcess = null;
|
|
180
|
-
function startViteDev() {
|
|
181
|
-
if (!IS_DEV) return;
|
|
182
|
-
const viteConfigPath = join(__dirname, "vite.config.js");
|
|
183
|
-
if (!existsSync(viteConfigPath)) return;
|
|
184
|
-
const viteBin = existsSync(join(__dirname, "node_modules", ".bin", "vite"))
|
|
185
|
-
? join(__dirname, "node_modules", ".bin", "vite")
|
|
186
|
-
: join(__dirname, "..", "node_modules", ".bin", "vite");
|
|
187
|
-
viteProcess = spawn("node", [viteBin, "--config", viteConfigPath], {
|
|
188
|
-
cwd: __dirname,
|
|
189
|
-
stdio: "ignore",
|
|
190
|
-
detached: false,
|
|
191
|
-
});
|
|
192
|
-
viteProcess.on("error", () => {});
|
|
193
|
-
viteProcess.unref();
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export async function startServer() {
|
|
197
|
-
await initializeTerminal();
|
|
198
|
-
proxyServer = createProxyServer();
|
|
199
|
-
startViteDev();
|
|
200
|
-
|
|
201
|
-
// Static file / Vite dev fallback (localhost-only)
|
|
202
|
-
const staticFallback = (req, res, { pathname }) => {
|
|
203
|
-
if (pathname.startsWith("/api/") || pathname.startsWith("/socket.io")) {
|
|
204
|
-
jsonErr(res, 404, "Not found");
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
const isTunnel = !!req.headers["cf-connecting-ip"];
|
|
208
|
-
const isLocal = req.socket.remoteAddress === "127.0.0.1" || req.socket.remoteAddress === "::1";
|
|
209
|
-
if (isTunnel || !isLocal) { jsonErr(res, 403, "Forbidden"); return; }
|
|
210
|
-
const serveStaticFiles = () => {
|
|
211
|
-
const filePath = (pathname === "/" || pathname === "") ? join(UI_DIST, "index.html") : join(UI_DIST, pathname);
|
|
212
|
-
if (serveStatic(res, filePath)) return;
|
|
213
|
-
serveStatic(res, join(UI_DIST, "index.html"));
|
|
214
|
-
};
|
|
215
|
-
if (IS_DEV) { proxyToVite(req, res, serveStaticFiles); return; }
|
|
216
|
-
serveStaticFiles();
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
const router = createRouter(ROUTES, { fallback: staticFallback });
|
|
220
|
-
|
|
221
|
-
const server = createServer(async (req, res) => {
|
|
222
|
-
setCorsHeaders(res);
|
|
223
|
-
if (handlePreflight(req, res)) return;
|
|
224
|
-
await router(req, res);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
loadUiState();
|
|
228
|
-
loadDesktopState();
|
|
229
|
-
refreshPermissionsAsync();
|
|
230
|
-
// macOS TCC has no change event — poll to detect permission revoke/grant
|
|
231
|
-
if (process.platform === "darwin") {
|
|
232
|
-
setInterval(refreshPermissionsAsync, PERMISSION_POLL_MS);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
await setupSocketIO(server);
|
|
236
|
-
|
|
237
|
-
server.listen(port, (err) => {
|
|
238
|
-
if (err) throw err;
|
|
239
|
-
const version = typeof __CLI_VERSION__ !== "undefined"
|
|
240
|
-
? __CLI_VERSION__
|
|
241
|
-
: JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).version;
|
|
242
|
-
checkForUpdate(version);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
// Graceful shutdown
|
|
246
|
-
let isShuttingDown = false;
|
|
247
|
-
const gracefulShutdown = async (signal) => {
|
|
248
|
-
if (isShuttingDown) return;
|
|
249
|
-
isShuttingDown = true;
|
|
250
|
-
console.log(chalk.yellow(`\n🛑 Received ${signal}, shutting down gracefully...`));
|
|
251
|
-
const forceExit = setTimeout(() => { console.log(chalk.red("⚠️ Forced exit")); process.exit(1); }, 5000);
|
|
252
|
-
try {
|
|
253
|
-
server.close(() => console.log(chalk.gray("✓ HTTP server closed")));
|
|
254
|
-
if (viteProcess) { try { viteProcess.kill(); } catch {} }
|
|
255
|
-
const io = getIO();
|
|
256
|
-
if (io) { io.emit("server:shutdown"); io.close(() => console.log(chalk.gray("✓ Socket.IO closed"))); }
|
|
257
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
258
|
-
clearTimeout(forceExit);
|
|
259
|
-
console.log(chalk.green("✅ Server stopped cleanly"));
|
|
260
|
-
process.exit(0);
|
|
261
|
-
} catch (error) {
|
|
262
|
-
console.error(chalk.red("❌ Error during shutdown:"), error);
|
|
263
|
-
clearTimeout(forceExit);
|
|
264
|
-
process.exit(1);
|
|
265
|
-
}
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
269
|
-
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
270
|
-
if (process.platform === "win32") process.on("SIGBREAK", () => gracefulShutdown("SIGBREAK"));
|
|
271
|
-
|
|
272
|
-
return server;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
startServer();
|
package/lib/constants.js
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import dns from "dns";
|
|
2
|
-
|
|
3
|
-
// Bypass broken macOS system DNS resolver for long hostnames (e.g. trycloudflare).
|
|
4
|
-
// Skip localhost/IP to avoid slow/hanging DNS resolve on Windows.
|
|
5
|
-
const _originalLookup = dns.lookup;
|
|
6
|
-
const IP_REGEX = /^(\d{1,3}\.){3}\d{1,3}$|^::1$|^[0-9a-f:]+$/i;
|
|
7
|
-
dns.lookup = (hostname, options, cb) => {
|
|
8
|
-
if (typeof options === "function") { cb = options; options = {}; }
|
|
9
|
-
if (hostname === "localhost" || IP_REGEX.test(hostname)) {
|
|
10
|
-
return _originalLookup(hostname, options, cb);
|
|
11
|
-
}
|
|
12
|
-
dns.resolve4(hostname, (err, addrs) => {
|
|
13
|
-
if (err) return _originalLookup(hostname, options, cb);
|
|
14
|
-
if (options.all) {
|
|
15
|
-
cb(null, addrs.map(a => ({ address: a, family: 4 })));
|
|
16
|
-
} else {
|
|
17
|
-
cb(null, addrs[0], 4);
|
|
18
|
-
}
|
|
19
|
-
});
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Shared constants for agent server + CLI
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
// Connection step states (UI progress tracking)
|
|
27
|
-
export const STEP = {
|
|
28
|
-
STOPPED: 0,
|
|
29
|
-
PREPARING: 1,
|
|
30
|
-
CONNECTING: 2,
|
|
31
|
-
TUNNELING: 3,
|
|
32
|
-
VERIFYING: 4,
|
|
33
|
-
READY: 5,
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
// Debug flags — toggle diagnostic displays without touching logic
|
|
37
|
-
export const DEBUG = {
|
|
38
|
-
showTunnelUrlInMenu: false,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
// macOS permission poll — TCC has no change event, so we poll periodically.
|
|
42
|
-
// Fast cadence kicks in right after user clicks "Grant" so UI reflects the toggle quickly.
|
|
43
|
-
export const PERMISSION_POLL_MS = 5000;
|
|
44
|
-
export const PERMISSION_POLL_FAST_MS = 1000;
|
|
45
|
-
export const PERMISSION_POLL_FAST_DURATION = 60000;
|
|
46
|
-
|
|
47
|
-
// Browser-like headers to avoid CDN/firewall blocks
|
|
48
|
-
const BROWSER_HEADERS = {
|
|
49
|
-
"Accept": "application/json, text/plain, */*",
|
|
50
|
-
"Accept-Language": "en-US,en;q=0.9",
|
|
51
|
-
"Cache-Control": "no-cache",
|
|
52
|
-
"Pragma": "no-cache",
|
|
53
|
-
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* fetch() wrapper with browser-like headers for external requests
|
|
58
|
-
*/
|
|
59
|
-
export function browserFetch(url, options = {}) {
|
|
60
|
-
return fetch(url, {
|
|
61
|
-
...options,
|
|
62
|
-
headers: { ...BROWSER_HEADERS, ...options.headers },
|
|
63
|
-
});
|
|
64
|
-
}
|
package/lib/deviceApproval.js
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Device approval manager — persists approved deviceIds to ~/.9remote/approvedDevices.json
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
6
|
-
import { join } from "path";
|
|
7
|
-
import { homedir } from "os";
|
|
8
|
-
|
|
9
|
-
const STATE_DIR = join(homedir(), ".9remote");
|
|
10
|
-
const DEVICES_FILE = join(STATE_DIR, "approvedDevices.json");
|
|
11
|
-
const CONFIG_FILE = join(STATE_DIR, "config.json");
|
|
12
|
-
|
|
13
|
-
// deviceId -> { approvedAt }
|
|
14
|
-
let approvedDevices = new Map();
|
|
15
|
-
|
|
16
|
-
// Auto-approve new device connections (default false). Persisted to config.json.
|
|
17
|
-
let autoApprove = false;
|
|
18
|
-
|
|
19
|
-
// Pending approval requests: socketId -> { deviceId, ip }
|
|
20
|
-
const pendingApprovals = new Map();
|
|
21
|
-
|
|
22
|
-
// Rejected devices (RAM only, cleared on restart): deviceId -> { ip, rejectedAt, socketId }
|
|
23
|
-
const rejectedDevices = new Map();
|
|
24
|
-
|
|
25
|
-
function ensureDir() {
|
|
26
|
-
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function loadApprovedDevices() {
|
|
30
|
-
try {
|
|
31
|
-
ensureDir();
|
|
32
|
-
if (existsSync(DEVICES_FILE)) {
|
|
33
|
-
const data = JSON.parse(readFileSync(DEVICES_FILE, "utf8"));
|
|
34
|
-
// Migrate from old array format to new map format
|
|
35
|
-
if (Array.isArray(data)) {
|
|
36
|
-
approvedDevices = new Map(data.map((id) => [id, { approvedAt: null }]));
|
|
37
|
-
} else {
|
|
38
|
-
approvedDevices = new Map(Object.entries(data));
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
} catch {
|
|
42
|
-
approvedDevices = new Map();
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function saveApprovedDevices() {
|
|
47
|
-
try {
|
|
48
|
-
ensureDir();
|
|
49
|
-
writeFileSync(DEVICES_FILE, JSON.stringify(Object.fromEntries(approvedDevices), null, 2));
|
|
50
|
-
} catch {}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function isDeviceApproved(deviceId) {
|
|
54
|
-
if (!deviceId) return false;
|
|
55
|
-
return approvedDevices.has(deviceId);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function approveDevice(deviceId) {
|
|
59
|
-
if (!deviceId) return;
|
|
60
|
-
approvedDevices.set(deviceId, { approvedAt: new Date().toISOString() });
|
|
61
|
-
saveApprovedDevices();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function removeDevice(deviceId) {
|
|
65
|
-
approvedDevices.delete(deviceId);
|
|
66
|
-
saveApprovedDevices();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function getApprovedDevices() {
|
|
70
|
-
return [...approvedDevices.entries()].map(([id, meta]) => ({ deviceId: id, ...meta }));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Pending approval queue
|
|
74
|
-
export function addPendingApproval(socketId, data) {
|
|
75
|
-
pendingApprovals.set(socketId, data);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function getPendingApproval(socketId) {
|
|
79
|
-
return pendingApprovals.get(socketId) || null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function removePendingApproval(socketId) {
|
|
83
|
-
pendingApprovals.delete(socketId);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function isDevicePending(deviceId) {
|
|
87
|
-
if (!deviceId) return false;
|
|
88
|
-
for (const data of pendingApprovals.values()) {
|
|
89
|
-
if (data.deviceId === deviceId) return true;
|
|
90
|
-
}
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function getAllPendingApprovals() {
|
|
95
|
-
return [...pendingApprovals.entries()].map(([socketId, data]) => ({ socketId, ...data }));
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Rejected devices (pending re-approval from Clients list)
|
|
99
|
-
export function markDeviceRejected(deviceId, data) {
|
|
100
|
-
if (!deviceId) return;
|
|
101
|
-
rejectedDevices.set(deviceId, { ...data, rejectedAt: new Date().toISOString() });
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function isDeviceRejected(deviceId) {
|
|
105
|
-
if (!deviceId) return false;
|
|
106
|
-
return rejectedDevices.has(deviceId);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function updateRejectedSocket(deviceId, socketId, ip) {
|
|
110
|
-
const entry = rejectedDevices.get(deviceId);
|
|
111
|
-
if (entry) rejectedDevices.set(deviceId, { ...entry, socketId, ip });
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function getRejectedDevices() {
|
|
115
|
-
return [...rejectedDevices.entries()].map(([deviceId, meta]) => ({ deviceId, ...meta }));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function clearRejectedDevice(deviceId) {
|
|
119
|
-
rejectedDevices.delete(deviceId);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ── Auto-approve setting (persisted) ────────────────────────────────
|
|
123
|
-
function readConfig() {
|
|
124
|
-
try {
|
|
125
|
-
if (existsSync(CONFIG_FILE)) return JSON.parse(readFileSync(CONFIG_FILE, "utf8")) || {};
|
|
126
|
-
} catch {}
|
|
127
|
-
return {};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function writeConfig(cfg) {
|
|
131
|
-
try {
|
|
132
|
-
ensureDir();
|
|
133
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
|
|
134
|
-
} catch {}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function loadAutoApprove() {
|
|
138
|
-
autoApprove = !!readConfig().autoApprove;
|
|
139
|
-
return autoApprove;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export function isAutoApprove() {
|
|
143
|
-
return autoApprove;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function setAutoApprove(enabled) {
|
|
147
|
-
autoApprove = !!enabled;
|
|
148
|
-
const cfg = readConfig();
|
|
149
|
-
cfg.autoApprove = autoApprove;
|
|
150
|
-
writeConfig(cfg);
|
|
151
|
-
return autoApprove;
|
|
152
|
-
}
|
package/lib/router.js
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lightweight config-driven HTTP router
|
|
3
|
-
* - Route table with path, method, handler
|
|
4
|
-
* - Public route whitelist (bypass localhost check)
|
|
5
|
-
* - Auto body parsing for POST/PUT
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { parse } from "url";
|
|
9
|
-
|
|
10
|
-
function readBody(req) {
|
|
11
|
-
return new Promise((resolve) => {
|
|
12
|
-
let body = "";
|
|
13
|
-
req.on("data", (chunk) => body += chunk);
|
|
14
|
-
req.on("end", () => resolve(body));
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function jsonOk(res, data = { ok: true }) {
|
|
19
|
-
res.setHeader("Content-Type", "application/json");
|
|
20
|
-
res.writeHead(200);
|
|
21
|
-
res.end(JSON.stringify(data));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function jsonErr(res, code, msg) {
|
|
25
|
-
res.setHeader("Content-Type", "application/json");
|
|
26
|
-
res.writeHead(code);
|
|
27
|
-
res.end(JSON.stringify({ error: msg }));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Parse JSON body with error handling, returns parsed object or null
|
|
32
|
-
*/
|
|
33
|
-
export async function parseJsonBody(req, res) {
|
|
34
|
-
try {
|
|
35
|
-
const raw = await readBody(req);
|
|
36
|
-
return JSON.parse(raw || "{}");
|
|
37
|
-
} catch {
|
|
38
|
-
jsonErr(res, 400, "Invalid JSON");
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Create router from route config
|
|
45
|
-
* @param {Array<{path: string, method: string, handler: Function, public?: boolean}>} routes
|
|
46
|
-
* @param {Object} options
|
|
47
|
-
* @param {Function} options.fallback - Called when no route matches
|
|
48
|
-
* @returns {Function} HTTP request handler (req, res) => void
|
|
49
|
-
*/
|
|
50
|
-
export function createRouter(routes, { fallback } = {}) {
|
|
51
|
-
// Pre-build lookup map: "METHOD:/path" → { handler, public }
|
|
52
|
-
const exactMap = new Map();
|
|
53
|
-
const prefixRoutes = [];
|
|
54
|
-
|
|
55
|
-
for (const route of routes) {
|
|
56
|
-
const methods = route.method === "*" ? ["GET", "POST", "PUT", "DELETE"] : [route.method];
|
|
57
|
-
for (const m of methods) {
|
|
58
|
-
if (route.path.endsWith("/*")) {
|
|
59
|
-
prefixRoutes.push({ ...route, prefix: route.path.slice(0, -2), method: m });
|
|
60
|
-
} else {
|
|
61
|
-
exactMap.set(`${m}:${route.path}`, route);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Collect public paths for fast localhost check
|
|
67
|
-
const publicExact = new Set();
|
|
68
|
-
const publicPrefixes = [];
|
|
69
|
-
for (const route of routes) {
|
|
70
|
-
if (!route.public) continue;
|
|
71
|
-
if (route.path.endsWith("/*")) {
|
|
72
|
-
publicPrefixes.push(route.path.slice(0, -2));
|
|
73
|
-
} else {
|
|
74
|
-
publicExact.add(route.path);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function isPublic(pathname) {
|
|
79
|
-
if (publicExact.has(pathname)) return true;
|
|
80
|
-
return publicPrefixes.some((p) => pathname.startsWith(p));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return async (req, res) => {
|
|
84
|
-
const parsedUrl = parse(req.url, true);
|
|
85
|
-
const { pathname, search } = parsedUrl;
|
|
86
|
-
|
|
87
|
-
// Localhost guard — block non-public routes from remote/tunnel
|
|
88
|
-
const isTunnel = !!req.headers["cf-connecting-ip"];
|
|
89
|
-
const isLocal = req.socket.remoteAddress === "127.0.0.1" || req.socket.remoteAddress === "::1";
|
|
90
|
-
if (!isPublic(pathname) && (isTunnel || !isLocal)) {
|
|
91
|
-
jsonErr(res, 403, "Forbidden");
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Exact match
|
|
96
|
-
const key = `${req.method}:${pathname}`;
|
|
97
|
-
const route = exactMap.get(key);
|
|
98
|
-
if (route) {
|
|
99
|
-
try {
|
|
100
|
-
await route.handler(req, res, { pathname, query: parsedUrl.query, search });
|
|
101
|
-
} catch (err) {
|
|
102
|
-
console.error("Error:", req.url, err);
|
|
103
|
-
jsonErr(res, 500, err.message);
|
|
104
|
-
}
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Prefix match
|
|
109
|
-
for (const pr of prefixRoutes) {
|
|
110
|
-
if ((pr.method === req.method) && pathname.startsWith(pr.prefix)) {
|
|
111
|
-
try {
|
|
112
|
-
await pr.handler(req, res, { pathname, query: parsedUrl.query, search });
|
|
113
|
-
} catch (err) {
|
|
114
|
-
console.error("Error:", req.url, err);
|
|
115
|
-
jsonErr(res, 500, err.message);
|
|
116
|
-
}
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Fallback (static files, etc.)
|
|
122
|
-
if (fallback) {
|
|
123
|
-
try {
|
|
124
|
-
await fallback(req, res, { pathname, search });
|
|
125
|
-
} catch (err) {
|
|
126
|
-
console.error("Error:", req.url, err);
|
|
127
|
-
jsonErr(res, 500, err.message);
|
|
128
|
-
}
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
jsonErr(res, 404, "Not found");
|
|
133
|
-
};
|
|
134
|
-
}
|