locadot 2.0.0 โ 2.1.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 +39 -0
- package/README.md +52 -1
- package/dist/cli/commands/index.js +3 -1
- package/dist/cli/commands/remote.js +315 -0
- package/dist/cli/commands/tunnel.js +2 -1
- package/dist/cli/commands/ui.js +94 -0
- package/dist/constants/index.js +23 -0
- package/dist/constants/template.js +24 -1
- package/dist/dashboard/api.js +381 -1
- package/dist/dashboard/index.js +94 -3
- package/dist/dashboard/login.js +228 -0
- package/dist/dashboard/page.js +1814 -103
- package/dist/index.js +102 -1
- package/dist/lib/hub-config.js +46 -0
- package/dist/lib/links.js +163 -0
- package/dist/lib/localhost.js +2 -2
- package/dist/lib/proxy-control.js +2 -1
- package/dist/lib/remotes.js +370 -0
- package/dist/lib/ui-auth.js +121 -0
- package/dist/lib/words.js +32 -0
- package/dist/proxy/hub-tunnel.js +241 -0
- package/dist/proxy/hub.js +302 -0
- package/dist/proxy/remote.js +91 -0
- package/dist/proxy/request.js +4 -2
- package/dist/proxy/router.js +126 -5
- package/dist/server/index.js +51 -3
- package/dist/utils/startup.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.sessionCookie = exports.signedIn = void 0;
|
|
40
|
+
exports.gate = gate;
|
|
41
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
42
|
+
const constants_1 = __importDefault(require("../constants"));
|
|
43
|
+
const logger_1 = __importDefault(require("../utils/logger"));
|
|
44
|
+
const escape_1 = require("./escape");
|
|
45
|
+
const ui_auth_1 = __importStar(require("../lib/ui-auth"));
|
|
46
|
+
const MAX_LOGIN_BODY = 4096;
|
|
47
|
+
const loginPage = (nonce, error) => `<!doctype html>
|
|
48
|
+
<html lang="en">
|
|
49
|
+
<head>
|
|
50
|
+
<meta charset="utf-8">
|
|
51
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
52
|
+
<meta name="color-scheme" content="dark light">
|
|
53
|
+
<title>Sign in ยท locadot</title>
|
|
54
|
+
<style nonce="${(0, escape_1.escapeHtml)(nonce)}">
|
|
55
|
+
:root { --bg: #f6f7f9; --surface: #fff; --border: #e3e6eb; --fg: #111827; --muted: #6b7280; --accent: #2563eb; --down: #dc2626; }
|
|
56
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #0d1117; --surface: #161b22; --border: #30363d; --fg: #e6edf3; --muted: #8b949e; --accent: #3b82f6; --down: #f87171; } }
|
|
57
|
+
* { box-sizing: border-box; }
|
|
58
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
59
|
+
form { width: 100%; max-width: 340px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 28px; display: flex; flex-direction: column; gap: 14px; }
|
|
60
|
+
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; }
|
|
61
|
+
.logo { width: 24px; height: 24px; border-radius: 7px; background: var(--accent); position: relative; }
|
|
62
|
+
.logo::after { content: ""; position: absolute; inset: 8px; border-radius: 50%; background: #fff; }
|
|
63
|
+
p { margin: 0; color: var(--muted); }
|
|
64
|
+
label { font-weight: 600; font-size: 13px; }
|
|
65
|
+
input { width: 100%; padding: 9px 11px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font: inherit; }
|
|
66
|
+
input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
67
|
+
button { padding: 9px; border: 0; border-radius: 8px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
|
|
68
|
+
.error { color: var(--down); font-size: 13px; }
|
|
69
|
+
.hint { font-size: 12px; }
|
|
70
|
+
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
|
71
|
+
code { font-family: ui-monospace, monospace; font-size: 12px; }
|
|
72
|
+
</style>
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<form method="post" action="/login">
|
|
76
|
+
<div class="brand"><span class="logo" aria-hidden="true"></span>locadot</div>
|
|
77
|
+
<p>This dashboard is password protected.</p>
|
|
78
|
+
<input class="sr" type="text" name="username" value="locadot" autocomplete="username" tabindex="-1" aria-hidden="true" readonly>
|
|
79
|
+
<div><label for="password">Password</label>
|
|
80
|
+
<input id="password" name="password" type="password" autocomplete="current-password" required autofocus></div>
|
|
81
|
+
${error ? `<div class="error" role="alert">${(0, escape_1.escapeHtml)(error)}</div>` : ""}
|
|
82
|
+
<button type="submit">Sign in</button>
|
|
83
|
+
<p class="hint">Forgot it? Run <code>locadot ui:password</code> in a terminal to reset it.</p>
|
|
84
|
+
</form>
|
|
85
|
+
</body>
|
|
86
|
+
</html>
|
|
87
|
+
`;
|
|
88
|
+
const safeEqual = (a, b) => {
|
|
89
|
+
const x = Buffer.from(a);
|
|
90
|
+
const y = Buffer.from(b);
|
|
91
|
+
return x.length === y.length && crypto_1.default.timingSafeEqual(x, y);
|
|
92
|
+
};
|
|
93
|
+
const isTls = (req) => Boolean(req.socket.encrypted);
|
|
94
|
+
const sameOrigin = (req) => {
|
|
95
|
+
// Browsers send `Origin: null` on form posts under Referrer-Policy: no-referrer, so trust Sec-Fetch-Site when present.
|
|
96
|
+
// "same-site" is refused: every *.localhost app is same-site with the dashboard.
|
|
97
|
+
const site = req.headers["sec-fetch-site"];
|
|
98
|
+
if (site)
|
|
99
|
+
return site === "same-origin" || site === "none";
|
|
100
|
+
const origin = req.headers.origin;
|
|
101
|
+
if (!origin || origin === "null")
|
|
102
|
+
return !origin;
|
|
103
|
+
try {
|
|
104
|
+
return constants_1.default.dashboardHosts.includes(new URL(origin).hostname.replace(/^\[|\]$/g, ""));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const cookie = (req, value, maxAge) => `${ui_auth_1.SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${isTls(req) ? "; Secure" : ""}`;
|
|
111
|
+
const sendLogin = (res, method, nonce, status, error) => {
|
|
112
|
+
// The dashboard's CSP forbids form posts; the login form needs one.
|
|
113
|
+
res.setHeader("Content-Security-Policy", `default-src 'none'; style-src 'nonce-${nonce}'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`);
|
|
114
|
+
res.statusCode = status;
|
|
115
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
116
|
+
res.end(method === "HEAD" ? undefined : loginPage(nonce, error));
|
|
117
|
+
};
|
|
118
|
+
const readForm = (req) => new Promise((resolve, reject) => {
|
|
119
|
+
let size = 0;
|
|
120
|
+
const chunks = [];
|
|
121
|
+
req.on("data", (chunk) => {
|
|
122
|
+
size += chunk.length;
|
|
123
|
+
if (size <= MAX_LOGIN_BODY)
|
|
124
|
+
chunks.push(chunk);
|
|
125
|
+
});
|
|
126
|
+
req.on("error", reject);
|
|
127
|
+
req.on("end", () => {
|
|
128
|
+
if (size > MAX_LOGIN_BODY)
|
|
129
|
+
return resolve({});
|
|
130
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
131
|
+
if (String(req.headers["content-type"] || "").includes("application/json")) {
|
|
132
|
+
try {
|
|
133
|
+
const data = JSON.parse(raw);
|
|
134
|
+
return resolve(data && typeof data === "object" ? data : {});
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return resolve({});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
resolve(Object.fromEntries(new URLSearchParams(raw)));
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
const signedIn = (req) => ui_auth_1.default.validSession((0, ui_auth_1.cookieOf)(req.headers.cookie, ui_auth_1.SESSION_COOKIE));
|
|
144
|
+
exports.signedIn = signedIn;
|
|
145
|
+
const hasToken = (req, token) => {
|
|
146
|
+
const sent = req.headers["x-locadot-token"];
|
|
147
|
+
return typeof sent === "string" && safeEqual(sent, token);
|
|
148
|
+
};
|
|
149
|
+
/** A fresh session for whoever just proved the password, so changing it from the dashboard doesn't sign them out. */
|
|
150
|
+
const sessionCookie = (req) => cookie(req, ui_auth_1.default.issue(), ui_auth_1.SESSION_TTL_SEC);
|
|
151
|
+
exports.sessionCookie = sessionCookie;
|
|
152
|
+
/**
|
|
153
|
+
* With a dashboard password set, the UI and its reads need a session cookie. Scripts that send the API token
|
|
154
|
+
* are let through untouched. Returns true when it answered the request itself.
|
|
155
|
+
*/
|
|
156
|
+
function gate(req, res, url, token, nonce) {
|
|
157
|
+
if (!ui_auth_1.default.enabled())
|
|
158
|
+
return false;
|
|
159
|
+
const method = req.method || "GET";
|
|
160
|
+
const path = url.pathname;
|
|
161
|
+
if (path === "/login") {
|
|
162
|
+
if (method === "GET" || method === "HEAD") {
|
|
163
|
+
if ((0, exports.signedIn)(req))
|
|
164
|
+
return redirect(res, "/");
|
|
165
|
+
sendLogin(res, method, nonce, 200);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
if (method !== "POST")
|
|
169
|
+
return false;
|
|
170
|
+
const ip = req.socket.remoteAddress || "";
|
|
171
|
+
if (!sameOrigin(req)) {
|
|
172
|
+
sendLogin(res, method, nonce, 403, "Cross-site sign-in is not allowed.");
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
if (ui_auth_1.default.limited(ip)) {
|
|
176
|
+
sendLogin(res, method, nonce, 429, "Too many wrong passwords. Wait a few minutes and try again.");
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
readForm(req)
|
|
180
|
+
.then((form) => {
|
|
181
|
+
if (!ui_auth_1.default.verify(form.password)) {
|
|
182
|
+
ui_auth_1.default.fail(ip);
|
|
183
|
+
logger_1.default.warn(`๐ dashboard: wrong password from ${ip}`);
|
|
184
|
+
return sendLogin(res, method, nonce, 401, "Wrong password.");
|
|
185
|
+
}
|
|
186
|
+
ui_auth_1.default.succeed(ip);
|
|
187
|
+
res.setHeader("Set-Cookie", (0, exports.sessionCookie)(req));
|
|
188
|
+
redirect(res, "/");
|
|
189
|
+
})
|
|
190
|
+
.catch(() => sendLogin(res, method, nonce, 400, "Couldn't read the form."));
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
if (path === "/logout" && method === "POST") {
|
|
194
|
+
if (!sameOrigin(req)) {
|
|
195
|
+
res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
|
|
196
|
+
res.end(JSON.stringify({ error: "Cross-origin requests are not allowed." }));
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
res.setHeader("Set-Cookie", cookie(req, "", 0));
|
|
200
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
201
|
+
res.end(JSON.stringify({ ok: true }));
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
if (path === "/healthz" || path === "/favicon.ico")
|
|
205
|
+
return false;
|
|
206
|
+
if ((0, exports.signedIn)(req) || hasToken(req, token))
|
|
207
|
+
return false;
|
|
208
|
+
if (path.startsWith("/api/")) {
|
|
209
|
+
res.writeHead(401, { "Content-Type": "application/json; charset=utf-8" });
|
|
210
|
+
res.end(JSON.stringify({ error: "Sign in to the dashboard, or send X-Locadot-Token.", hint: "locadot token" }));
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
if (method === "GET" || method === "HEAD") {
|
|
214
|
+
if (path === "/")
|
|
215
|
+
sendLogin(res, method, nonce, 200);
|
|
216
|
+
else
|
|
217
|
+
redirect(res, "/login");
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
res.writeHead(401, { "Content-Type": "application/json; charset=utf-8" });
|
|
221
|
+
res.end(JSON.stringify({ error: "Sign in to the dashboard." }));
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
function redirect(res, location) {
|
|
225
|
+
res.writeHead(303, { Location: location });
|
|
226
|
+
res.end();
|
|
227
|
+
return true;
|
|
228
|
+
}
|