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.
@@ -38,8 +38,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.ApiError = void 0;
40
40
  exports.assertTrusted = assertTrusted;
41
+ exports.readJson = readJson;
41
42
  exports.route = route;
42
43
  const crypto_1 = __importDefault(require("crypto"));
44
+ const fs_1 = __importDefault(require("fs"));
45
+ const path_1 = __importDefault(require("path"));
46
+ const child_process_1 = require("child_process");
43
47
  const constants_1 = __importDefault(require("../constants"));
44
48
  const hosts_1 = __importStar(require("../lib/hosts"));
45
49
  const localhost_1 = require("../lib/localhost");
@@ -51,6 +55,10 @@ const logger_1 = __importDefault(require("../utils/logger"));
51
55
  const system_1 = require("../lib/system");
52
56
  const tunnel_1 = require("../proxy/tunnel");
53
57
  const urls_1 = require("../lib/urls");
58
+ const links_1 = __importStar(require("../lib/links"));
59
+ const hub_config_1 = __importDefault(require("../lib/hub-config"));
60
+ const remotes_1 = __importStar(require("../lib/remotes"));
61
+ const ui_auth_1 = __importDefault(require("../lib/ui-auth"));
54
62
  const MAX_BODY = 64 * 1024;
55
63
  class ApiError extends Error {
56
64
  constructor(status, message, hint) {
@@ -149,6 +157,163 @@ const privileged = async (action, command) => {
149
157
  }
150
158
  };
151
159
  const hostUrl = (host, ctx) => (0, urls_1.formatUrl)(host, true, ctx.proxyInfo.httpsPort);
160
+ const ROLES = ["viewer", "editor", "admin"];
161
+ const isRole = (value) => typeof value === "string" && ROLES.includes(value);
162
+ const optionalString = (value, name) => {
163
+ if (value === undefined)
164
+ return undefined;
165
+ if (typeof value !== "string")
166
+ throw new ApiError(400, `\`${name}\` must be a string.`);
167
+ return value;
168
+ };
169
+ const optionalHosts = (value) => {
170
+ if (value === undefined)
171
+ return undefined;
172
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
173
+ throw new ApiError(400, "`hosts` must be an array of strings.");
174
+ }
175
+ return value;
176
+ };
177
+ /** Public hostname a Cloudflare named tunnel can route to (not *.localhost, no scheme/path). */
178
+ const isValidPublicDomain = (value) => {
179
+ if (typeof value !== "string")
180
+ return false;
181
+ const host = value.trim().toLowerCase();
182
+ if (!host || host.length > 253 || host.includes("/") || host.includes(":"))
183
+ return false;
184
+ const labels = host.split(".");
185
+ if (labels.length < 2 || labels[labels.length - 1] === "localhost")
186
+ return false;
187
+ return labels.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
188
+ };
189
+ const sanitizeInvite = (invite) => ({
190
+ id: invite.id,
191
+ role: invite.role,
192
+ hosts: invite.hosts,
193
+ expiresAt: invite.expiresAt,
194
+ });
195
+ const sanitizePeer = (peer) => ({
196
+ id: peer.id,
197
+ name: peer.name,
198
+ role: peer.role,
199
+ hosts: peer.hosts,
200
+ createdAt: peer.createdAt,
201
+ lastSeen: peer.lastSeen,
202
+ });
203
+ const sanitizeRemote = (remote) => {
204
+ const { token, ...rest } = remote;
205
+ return rest;
206
+ };
207
+ /** Reads CONFIG_FILE as-is (whatever `locadot start --port/--https-port` last wrote), tolerating a missing/corrupt file. */
208
+ function readConfig() {
209
+ try {
210
+ const raw = JSON.parse(fs_1.default.readFileSync(constants_1.default.paths.CONFIG_FILE, "utf8"));
211
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
212
+ }
213
+ catch {
214
+ return {};
215
+ }
216
+ }
217
+ const savedPorts = () => {
218
+ const config = readConfig();
219
+ return {
220
+ httpPort: constants_1.default.validPort(config.httpPort),
221
+ httpsPort: constants_1.default.validPort(config.httpsPort),
222
+ };
223
+ };
224
+ /** Settings response: what the running proxy uses, what's saved/env-overridden, and whether they've drifted apart. */
225
+ function buildSettingsBody(ctx, warning) {
226
+ const saved = savedPorts();
227
+ const env = {
228
+ httpPort: constants_1.default.userEnvPort("LOCADOT_HTTP_PORT") !== undefined,
229
+ httpsPort: constants_1.default.userEnvPort("LOCADOT_HTTPS_PORT") !== undefined,
230
+ bind: Boolean(process.env.LOCADOT_BIND),
231
+ };
232
+ const nextHttpPort = constants_1.default.userEnvPort("LOCADOT_HTTP_PORT") ?? saved.httpPort ?? 80;
233
+ const nextHttpsPort = constants_1.default.userEnvPort("LOCADOT_HTTPS_PORT") ?? saved.httpsPort ?? 443;
234
+ const restartRequired = nextHttpPort !== ctx.proxyInfo.httpPort || nextHttpsPort !== ctx.proxyInfo.httpsPort;
235
+ return {
236
+ httpPort: ctx.proxyInfo.httpPort,
237
+ httpsPort: ctx.proxyInfo.httpsPort,
238
+ bind: ctx.proxyInfo.bind,
239
+ logLevel: logger_1.default.level,
240
+ stateDir: ctx.proxyInfo.stateDir,
241
+ saved,
242
+ env,
243
+ restartRequired,
244
+ uiAuth: { enabled: ui_auth_1.default.enabled(), updatedAt: ui_auth_1.default.read()?.updatedAt },
245
+ ...(warning ? { warning } : {}),
246
+ };
247
+ }
248
+ const validPortInput = (value, name) => {
249
+ if (value === undefined)
250
+ return undefined;
251
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65535) {
252
+ throw new ApiError(400, `\`${name}\` must be an integer between 1 and 65535.`);
253
+ }
254
+ return value;
255
+ };
256
+ /** Compiled layout: dist/dashboard/api.js -> dist/index.js. Under tsx (src/dashboard) the compiled dist/index.js is still what gets spawned. */
257
+ function resolveCliPath() {
258
+ const compiled = path_1.default.join(__dirname, "..", "index.js");
259
+ const fromSource = path_1.default.join(__dirname, "..", "..", "dist", "index.js");
260
+ return __filename.endsWith(".ts") ? fromSource : compiled;
261
+ }
262
+ const REMOTE_SYNC_TIMEOUT_MS = 5000;
263
+ async function buildRemoteRows(ctx) {
264
+ const remotes = remotes_1.default.list();
265
+ let changed = false;
266
+ const rows = await Promise.all(remotes.map(async (remote) => {
267
+ try {
268
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("timed out")), REMOTE_SYNC_TIMEOUT_MS));
269
+ const result = await Promise.race([remotes_1.default.sync(remote.name), timeout]);
270
+ changed = true;
271
+ return {
272
+ name: result.remote.name,
273
+ url: result.remote.url,
274
+ role: result.remote.role,
275
+ hosts: result.remote.hosts,
276
+ sender: result.remote.sender,
277
+ connectedAt: result.remote.connectedAt,
278
+ domain: result.remote.domain,
279
+ localhost: result.remote.localhost,
280
+ status: "ok",
281
+ available: result.hosts,
282
+ mapped: result.mapped,
283
+ };
284
+ }
285
+ catch (error) {
286
+ return {
287
+ name: remote.name,
288
+ url: remote.url,
289
+ role: remote.role,
290
+ hosts: remote.hosts,
291
+ sender: remote.sender,
292
+ connectedAt: remote.connectedAt,
293
+ domain: remote.domain,
294
+ localhost: remote.localhost,
295
+ status: "error",
296
+ error: clean(String(error?.message || error)),
297
+ available: null,
298
+ mapped: [],
299
+ };
300
+ }
301
+ }));
302
+ if (changed)
303
+ ctx.reload();
304
+ return rows;
305
+ }
306
+ function buildHubBody(ctx) {
307
+ const config = hub_config_1.default.read();
308
+ const { invites, peers } = links_1.default.list();
309
+ return {
310
+ hub: ctx.hub(),
311
+ config: config ?? null,
312
+ localhost: config?.localhost !== false,
313
+ invites: invites.map(sanitizeInvite),
314
+ peers: peers.map(sanitizePeer),
315
+ };
316
+ }
152
317
  /** Routes under /api/ other than the read-only status/hosts. Returns undefined when nothing matched. */
153
318
  async function route(req, url, ctx) {
154
319
  const method = req.method || "GET";
@@ -157,15 +322,64 @@ async function route(req, url, ctx) {
157
322
  const lines = Math.min(Math.max(Number(url.searchParams.get("lines")) || 200, 1), 2000);
158
323
  return { status: 200, body: { lines: file_1.default.lastLines("LOGS", lines) } };
159
324
  }
325
+ if (path === "/api/hub" && (method === "GET" || method === "HEAD")) {
326
+ return { status: 200, body: buildHubBody(ctx) };
327
+ }
328
+ if (path === "/api/remotes" && (method === "GET" || method === "HEAD")) {
329
+ return { status: 200, body: { remotes: await buildRemoteRows(ctx) } };
330
+ }
331
+ if (path === "/api/settings" && (method === "GET" || method === "HEAD")) {
332
+ return { status: 200, body: buildSettingsBody(ctx) };
333
+ }
160
334
  const hostMatch = /^\/api\/hosts\/([^/]+)$/.exec(path);
335
+ const inviteMatch = /^\/api\/invites\/([^/]+)$/.exec(path);
336
+ const peerMatch = /^\/api\/peers\/([^/]+)$/.exec(path);
337
+ const remoteMatch = /^\/api\/remotes\/([^/]+)$/.exec(path);
338
+ const remoteSyncMatch = /^\/api\/remotes\/([^/]+)\/sync$/.exec(path);
339
+ const remoteAliasMatch = /^\/api\/remotes\/([^/]+)\/aliases$/.exec(path);
340
+ const remoteHostsMatch = /^\/api\/remotes\/([^/]+)\/hosts$/.exec(path);
341
+ const remoteHostMatch = /^\/api\/remotes\/([^/]+)\/hosts\/([^/]+)$/.exec(path);
161
342
  const known = (path === "/api/hosts" && method === "POST") ||
162
343
  (hostMatch && (method === "PUT" || method === "DELETE")) ||
163
- (["/api/startup", "/api/trust", "/api/logs/clear", "/api/proxy/stop", "/api/cloudflared/install"].includes(path) && method === "POST");
344
+ (["/api/startup", "/api/trust", "/api/logs/clear", "/api/proxy/stop", "/api/proxy/restart", "/api/cloudflared/install", "/api/hub", "/api/invites", "/api/remotes"].includes(path) &&
345
+ method === "POST") ||
346
+ (path === "/api/hub/localhost" && method === "PUT") ||
347
+ (path === "/api/settings" && method === "PUT") ||
348
+ (inviteMatch && method === "DELETE") ||
349
+ (peerMatch && (method === "PUT" || method === "DELETE")) ||
350
+ (remoteMatch && (method === "PUT" || method === "DELETE")) ||
351
+ (remoteSyncMatch && method === "POST") ||
352
+ (remoteAliasMatch && method === "POST") ||
353
+ (remoteHostsMatch && method === "POST") ||
354
+ (remoteHostMatch && (method === "PUT" || method === "DELETE"));
164
355
  if (!known)
165
356
  return undefined;
166
357
  assertTrusted(req, ctx);
167
358
  const body = await readJson(req);
168
359
  try {
360
+ if (path === "/api/settings" && method === "PUT") {
361
+ const newHttp = validPortInput(body.httpPort, "httpPort");
362
+ const newHttps = validPortInput(body.httpsPort, "httpsPort");
363
+ if (newHttp === undefined && newHttps === undefined)
364
+ throw new ApiError(400, "Provide `httpPort` and/or `httpsPort`.");
365
+ const config = readConfig();
366
+ const saved = savedPorts();
367
+ const finalHttp = newHttp ?? saved.httpPort ?? ctx.proxyInfo.httpPort;
368
+ const finalHttps = newHttps ?? saved.httpsPort ?? ctx.proxyInfo.httpsPort;
369
+ if (finalHttp === finalHttps)
370
+ throw new ApiError(400, "`httpPort` and `httpsPort` can't be the same.");
371
+ file_1.default.ensureDir();
372
+ fs_1.default.writeFileSync(constants_1.default.paths.CONFIG_FILE, JSON.stringify({ ...config, httpPort: finalHttp, httpsPort: finalHttps }, null, 2) + "\n");
373
+ logger_1.default.info(`⚙️ dashboard: settings saved → http ${finalHttp}, https ${finalHttps}`);
374
+ const warnings = [];
375
+ if (newHttp !== undefined && constants_1.default.userEnvPort("LOCADOT_HTTP_PORT") !== undefined) {
376
+ warnings.push("LOCADOT_HTTP_PORT is set and overrides this");
377
+ }
378
+ if (newHttps !== undefined && constants_1.default.userEnvPort("LOCADOT_HTTPS_PORT") !== undefined) {
379
+ warnings.push("LOCADOT_HTTPS_PORT is set and overrides this");
380
+ }
381
+ return { status: 200, body: buildSettingsBody(ctx, warnings.length ? warnings.join("; ") : undefined) };
382
+ }
169
383
  if (path === "/api/hosts") {
170
384
  const { host, entry } = await hosts_1.default.add({ host: body.host, target: body.target, insecure: optionalBool(body.insecure, "insecure"), cors: optionalBool(body.cors, "cors") });
171
385
  ctx.reload();
@@ -196,6 +410,150 @@ async function route(req, url, ctx) {
196
410
  const { host: name, entry } = updated;
197
411
  return { status: 200, body: { ok: true, host: name, ...entry, url: hostUrl(name, ctx), tunnel: ctx.tunnel(name) } };
198
412
  }
413
+ if (path === "/api/hub" && method === "POST") {
414
+ const mode = body.mode;
415
+ if (mode === "named") {
416
+ if (!isValidPublicDomain(body.domain))
417
+ throw new ApiError(400, "`domain` must be a valid public hostname.");
418
+ const tunnel = body.tunnel === undefined ? undefined : String(body.tunnel);
419
+ ctx.setupNamedHub(String(body.domain).trim().toLowerCase(), tunnel);
420
+ }
421
+ else if (mode === "quick") {
422
+ hub_config_1.default.write({ mode: "quick" });
423
+ ctx.reloadHub();
424
+ }
425
+ else if (mode === "manual") {
426
+ if (typeof body.url !== "string" || !body.url.trim())
427
+ throw new ApiError(400, "`url` is required for manual mode.");
428
+ let parsed;
429
+ try {
430
+ parsed = new URL(body.url.trim());
431
+ }
432
+ catch {
433
+ throw new ApiError(400, "`url` must be a valid http(s) URL.");
434
+ }
435
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
436
+ throw new ApiError(400, "`url` must be http or https.");
437
+ const config = { mode: "manual", url: body.url.trim().replace(/\/+$/, "") };
438
+ hub_config_1.default.write(config);
439
+ ctx.reloadHub();
440
+ }
441
+ else if (mode === "off") {
442
+ hub_config_1.default.clear();
443
+ ctx.reloadHub();
444
+ }
445
+ else {
446
+ throw new ApiError(400, "`mode` must be one of named, quick, manual, off.");
447
+ }
448
+ return { status: 200, body: { hub: ctx.hub() } };
449
+ }
450
+ if (path === "/api/hub/localhost" && method === "PUT") {
451
+ const enabled = requiredBool(body.enabled, "enabled");
452
+ const config = hub_config_1.default.read();
453
+ if (!config)
454
+ throw new ApiError(400, "The hub isn't configured yet. Set it up first: locadot hub:setup / hub:quick / hub:manual.");
455
+ hub_config_1.default.write({ ...config, localhost: enabled });
456
+ ctx.reloadHub();
457
+ return { status: 200, body: { localhost: enabled } };
458
+ }
459
+ if (path === "/api/invites" && method === "POST") {
460
+ if (!isRole(body.role))
461
+ throw new ApiError(400, "`role` must be viewer, editor or admin.");
462
+ const hosts = optionalHosts(body.hosts);
463
+ if (ctx.hub().status !== "up") {
464
+ throw new ApiError(409, "The hub isn't up. Start sharing first.", "locadot hub:quick");
465
+ }
466
+ const { invite, code } = links_1.default.createInvite({ role: body.role, hosts });
467
+ const string = links_1.default.inviteString(ctx.hub().url, code);
468
+ return { status: 200, body: { id: invite.id, code, string, role: invite.role, hosts: invite.hosts, expiresAt: invite.expiresAt } };
469
+ }
470
+ if (inviteMatch && method === "DELETE") {
471
+ links_1.default.revokeInvite(decodeURIComponent(inviteMatch[1]));
472
+ return { status: 200, body: { ok: true } };
473
+ }
474
+ if (peerMatch && method === "PUT") {
475
+ const id = decodeURIComponent(peerMatch[1]);
476
+ if (!isRole(body.role))
477
+ throw new ApiError(400, "`role` must be viewer, editor or admin.");
478
+ const hosts = optionalHosts(body.hosts);
479
+ const peer = links_1.default.setRole(id, body.role, hosts);
480
+ return { status: 200, body: { peer: sanitizePeer(peer) } };
481
+ }
482
+ if (peerMatch && method === "DELETE") {
483
+ links_1.default.revoke(decodeURIComponent(peerMatch[1]));
484
+ return { status: 200, body: { ok: true } };
485
+ }
486
+ if (path === "/api/remotes" && method === "POST") {
487
+ if (typeof body.string !== "string" || !body.string.trim())
488
+ throw new ApiError(400, "`string` is required.");
489
+ const name = typeof body.name === "string" && body.name.trim() ? body.name.trim() : undefined;
490
+ const domain = optionalString(body.domain, "domain");
491
+ const result = await remotes_1.default.connect(body.string.trim(), { name, domain });
492
+ ctx.reload();
493
+ return { status: 200, body: { ...result, remote: sanitizeRemote(result.remote) } };
494
+ }
495
+ if (remoteMatch && method === "PUT" && "domain" in body) {
496
+ const domain = body.domain;
497
+ if (domain !== null && domain !== "random" && typeof domain !== "string") {
498
+ throw new ApiError(400, '`domain` must be a string, "random" or null.');
499
+ }
500
+ const remote = remotes_1.default.setDomain(decodeURIComponent(remoteMatch[1]), domain);
501
+ ctx.reload();
502
+ return { status: 200, body: { remote: sanitizeRemote(remote) } };
503
+ }
504
+ if (remoteMatch && method === "PUT") {
505
+ if (typeof body.url !== "string" || !body.url.trim())
506
+ throw new ApiError(400, "`url` is required.");
507
+ remotes_1.default.setUrl(decodeURIComponent(remoteMatch[1]), body.url.trim());
508
+ return { status: 200, body: { ok: true } };
509
+ }
510
+ if (remoteMatch && method === "DELETE") {
511
+ await remotes_1.default.disconnect(decodeURIComponent(remoteMatch[1]));
512
+ ctx.reload();
513
+ return { status: 200, body: { ok: true } };
514
+ }
515
+ if (remoteSyncMatch && method === "POST") {
516
+ const result = await remotes_1.default.sync(decodeURIComponent(remoteSyncMatch[1]));
517
+ ctx.reload();
518
+ return { status: 200, body: { ...result, remote: sanitizeRemote(result.remote) } };
519
+ }
520
+ if (remoteAliasMatch && method === "POST") {
521
+ if (typeof body.host !== "string" || typeof body.local !== "string") {
522
+ throw new ApiError(400, "`host` and `local` are required.");
523
+ }
524
+ await remotes_1.default.alias(decodeURIComponent(remoteAliasMatch[1]), body.host, body.local);
525
+ ctx.reload();
526
+ return { status: 200, body: { ok: true } };
527
+ }
528
+ if (remoteHostsMatch && method === "POST") {
529
+ if (typeof body.host !== "string" || typeof body.target !== "string") {
530
+ throw new ApiError(400, "`host` and `target` are required.");
531
+ }
532
+ const host = await remotes_1.default.addHost(decodeURIComponent(remoteHostsMatch[1]), {
533
+ host: body.host,
534
+ target: body.target,
535
+ insecure: optionalBool(body.insecure, "insecure"),
536
+ cors: optionalBool(body.cors, "cors"),
537
+ });
538
+ ctx.reload();
539
+ return { status: 201, body: { host } };
540
+ }
541
+ if (remoteHostMatch) {
542
+ const name = decodeURIComponent(remoteHostMatch[1]);
543
+ const hostName = decodeURIComponent(remoteHostMatch[2]);
544
+ if (method === "DELETE") {
545
+ await remotes_1.default.removeHost(name, hostName);
546
+ ctx.reload();
547
+ return { status: 200, body: { ok: true } };
548
+ }
549
+ const host = await remotes_1.default.updateHost(name, hostName, {
550
+ target: optionalString(body.target, "target"),
551
+ insecure: optionalBool(body.insecure, "insecure"),
552
+ cors: optionalBool(body.cors, "cors"),
553
+ tunnel: optionalBool(body.tunnel, "tunnel"),
554
+ });
555
+ return { status: 200, body: { host } };
556
+ }
199
557
  }
200
558
  catch (error) {
201
559
  if (error instanceof hosts_1.NotFoundError)
@@ -206,6 +564,12 @@ async function route(req, url, ctx) {
206
564
  throw new ApiError(400, clean(error.message));
207
565
  if (error instanceof registry_1.RegistryError)
208
566
  throw new ApiError(500, clean(error.message));
567
+ if (error instanceof links_1.LinkError)
568
+ throw new ApiError(error.status, clean(error.message));
569
+ if (error instanceof remotes_1.RemoteError)
570
+ throw new ApiError(error.status || 502, clean(error.message));
571
+ if (error instanceof ApiError)
572
+ throw error;
209
573
  throw error;
210
574
  }
211
575
  switch (path) {
@@ -239,6 +603,22 @@ async function route(req, url, ctx) {
239
603
  case "/api/proxy/stop":
240
604
  setTimeout(() => ctx.shutdown("dashboard stop"), 100);
241
605
  return { status: 200, body: { ok: true, hint: "locadot start" } };
606
+ case "/api/proxy/restart": {
607
+ const cli = resolveCliPath();
608
+ if (!fs_1.default.existsSync(cli)) {
609
+ throw new ApiError(500, `❌ ${cli} not found. Run \`pnpm build\` first.`, "locadot restart");
610
+ }
611
+ // Drop the CLI's pinned port copies so the child re-reads CONFIG_FILE; keep genuine user overrides.
612
+ const restartEnv = { ...process.env };
613
+ delete restartEnv.LOCADOT_USER_PORTS;
614
+ for (const name of ["LOCADOT_HTTP_PORT", "LOCADOT_HTTPS_PORT"]) {
615
+ if (constants_1.default.userEnvPort(name) === undefined)
616
+ delete restartEnv[name];
617
+ }
618
+ (0, child_process_1.spawn)(process.execPath, [cli, "restart"], { detached: true, stdio: "ignore", env: restartEnv, windowsHide: true }).unref();
619
+ logger_1.default.info("🔁 dashboard: restarting proxy");
620
+ return { status: 200, body: { ok: true, hint: "locadot restart" } };
621
+ }
242
622
  }
243
623
  return undefined;
244
624
  }
@@ -1,4 +1,37 @@
1
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
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
@@ -7,9 +40,23 @@ exports.handleDashboardRequest = handleDashboardRequest;
7
40
  const crypto_1 = __importDefault(require("crypto"));
8
41
  const page_1 = require("./page");
9
42
  const api_1 = require("./api");
43
+ const login_1 = require("./login");
44
+ const ui_auth_1 = __importStar(require("../lib/ui-auth"));
10
45
  const system_1 = require("../lib/system");
11
46
  const tunnel_1 = require("../proxy/tunnel");
12
47
  const urls_1 = require("../lib/urls");
48
+ const localhost_1 = __importDefault(require("../lib/localhost"));
49
+ const remote_1 = require("../proxy/remote");
50
+ const remotes_1 = __importDefault(require("../lib/remotes"));
51
+ /** A remote mapping is only reachable through its sender, with the peer's credentials. */
52
+ function probeEntry(ctx, entry) {
53
+ if (!entry.remote)
54
+ return ctx.probe(entry.target);
55
+ const remote = (0, remote_1.remoteFor)(entry.remote.name);
56
+ if (!remote)
57
+ return Promise.resolve({ up: false, error: "disconnected" });
58
+ return localhost_1.default.probe(remote.url, 3000, false, { "X-Locadot-Peer": remote.token, "X-Locadot-Host": entry.remote.host });
59
+ }
13
60
  function hostUrls(host, httpPort, httpsPort) {
14
61
  return { https: (0, urls_1.formatUrl)(host, true, httpsPort), http: (0, urls_1.formatUrl)(host, false, httpPort) };
15
62
  }
@@ -37,7 +84,7 @@ async function buildHosts(ctx) {
37
84
  const { httpPort, httpsPort } = ctx.proxyInfo;
38
85
  const entries = Object.entries(registry.hosts);
39
86
  const rows = await Promise.all(entries.map(async ([host, entry]) => {
40
- const probe = await ctx.probe(entry.target);
87
+ const probe = await probeEntry(ctx, entry);
41
88
  return {
42
89
  host,
43
90
  target: entry.target,
@@ -49,6 +96,9 @@ async function buildHosts(ctx) {
49
96
  stats: stats[host] ?? null,
50
97
  probe,
51
98
  tunnel: ctx.tunnel(host),
99
+ remote: entry.remote
100
+ ? { name: entry.remote.name, host: entry.remote.host, role: remotes_1.default.get(entry.remote.name)?.role ?? "viewer" }
101
+ : undefined,
52
102
  };
53
103
  }));
54
104
  return rows.sort((a, b) => a.host.localeCompare(b.host));
@@ -69,6 +119,12 @@ function handleDashboardRequest(req, res, ctx) {
69
119
  sendJson(res, method, 500, { error: String(error?.message || error) });
70
120
  }
71
121
  };
122
+ if ((0, login_1.gate)(req, res, url, ctx.token, nonce))
123
+ return;
124
+ if (url.pathname === "/api/settings/ui-password" && method === "PUT") {
125
+ uiPassword(req, res, ctx).catch(fail);
126
+ return;
127
+ }
72
128
  if (url.pathname.startsWith("/api/") && !(["/api/status", "/api/hosts"].includes(url.pathname) && (method === "GET" || method === "HEAD"))) {
73
129
  (0, api_1.route)(req, url, ctx)
74
130
  .then((result) => {
@@ -85,7 +141,7 @@ function handleDashboardRequest(req, res, ctx) {
85
141
  }
86
142
  switch (url.pathname) {
87
143
  case "/":
88
- sendBody(res, method, 200, "text/html; charset=utf-8", (0, page_1.renderPage)(nonce, ctx.token));
144
+ sendBody(res, method, 200, "text/html; charset=utf-8", (0, page_1.renderPage)(nonce, ctx.token, ui_auth_1.default.enabled()));
89
145
  return;
90
146
  case "/favicon.ico":
91
147
  res.statusCode = 204;
@@ -97,7 +153,13 @@ function handleDashboardRequest(req, res, ctx) {
97
153
  case "/api/status": {
98
154
  const uptimeSec = Math.max(0, Math.round((Date.now() - new Date(ctx.proxyInfo.startedAt).getTime()) / 1000));
99
155
  (0, system_1.systemStatus)(ctx.proxyInfo.caTrusted)
100
- .then((system) => sendJson(res, method, 200, { proxy: ctx.proxyInfo, uptimeSec, hosts: Object.keys(ctx.getRegistry().hosts).length, system: { ...system, cloudflared: (0, tunnel_1.cloudflaredInfo)() } }))
156
+ .then((system) => sendJson(res, method, 200, {
157
+ proxy: ctx.proxyInfo,
158
+ uptimeSec,
159
+ hosts: Object.keys(ctx.getRegistry().hosts).length,
160
+ system: { ...system, cloudflared: (0, tunnel_1.cloudflaredInfo)() },
161
+ hub: ctx.hub(),
162
+ }))
101
163
  .catch(fail);
102
164
  return;
103
165
  }
@@ -110,3 +172,32 @@ function handleDashboardRequest(req, res, ctx) {
110
172
  sendJson(res, method, 404, { error: "not found" });
111
173
  }
112
174
  }
175
+ /** Set, change or remove the dashboard password from the dashboard; once one is set, the current one is required. */
176
+ async function uiPassword(req, res, ctx) {
177
+ (0, api_1.assertTrusted)(req, ctx);
178
+ const body = await (0, api_1.readJson)(req);
179
+ const ip = req.socket.remoteAddress || "";
180
+ if (ui_auth_1.default.enabled()) {
181
+ if (ui_auth_1.default.limited(ip))
182
+ throw new api_1.ApiError(429, "Too many wrong passwords. Wait a few minutes and try again.");
183
+ if (!ui_auth_1.default.verify(body.current)) {
184
+ ui_auth_1.default.fail(ip);
185
+ throw new api_1.ApiError(403, "The current password is wrong.", "Forgot it? Run `locadot ui:password` in a terminal.");
186
+ }
187
+ ui_auth_1.default.succeed(ip);
188
+ }
189
+ if (body.enabled === false) {
190
+ ui_auth_1.default.clear();
191
+ res.setHeader("Set-Cookie", `${ui_auth_1.SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`);
192
+ sendJson(res, req.method || "PUT", 200, { uiAuth: { enabled: false } });
193
+ return;
194
+ }
195
+ try {
196
+ ui_auth_1.default.set(body.password);
197
+ }
198
+ catch (err) {
199
+ throw new api_1.ApiError(400, err.message);
200
+ }
201
+ res.setHeader("Set-Cookie", (0, login_1.sessionCookie)(req));
202
+ sendJson(res, req.method || "PUT", 200, { uiAuth: { enabled: true } });
203
+ }