locadot 2.1.0-beta.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 CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Added
6
+ - Dashboard pages: every sidebar item now opens its own page (`#/overview`, `#/hosts`, `#/sharing`, `#/remote`, `#/machines`,
7
+ `#/logs`, `#/settings`); they are bookmarkable and work with back/forward.
8
+ - **Overview**: tiles for hosts, down targets, requests, public shares, remote access and connected machines, plus host health, proxy checks and recent activity.
9
+ - **Public sharing**: a per-host share list.
10
+ - **Logs**: filter, level, line count, live mode and download.
11
+ - Settings page:
12
+ - HTTP/HTTPS ports with a **Restart proxy** button, which follows the dashboard to the new port.
13
+ - Bind address, state directory, log level and the API token (reveal/copy).
14
+ - Dashboard preferences: theme, refresh interval, start page, collapsed sidebar, confirm-before-remove.
15
+ - New-host defaults (insecure TLS, CORS) and a danger zone.
16
+ - API: `GET/PUT /api/settings` and `POST /api/proxy/restart`.
17
+ - Remote localhost for admins: an admin peer reaches any port on the sender's machine at `<port>.<domain>.localhost`, where
18
+ `<domain>.localhost` is a landing page listing that machine's shared hosts. The domain is chosen with
19
+ `connect --domain` or in the dashboard, and otherwise defaults to two random words (e.g. `brave-otter`). Change it with
20
+ `remote:domain`. The sender checks the role on every request, never exposes its own proxy ports, and can turn this off
21
+ with `hub:localhost off` or the dashboard switch.
22
+
23
+ - Dashboard password (UI only): `locadot ui:password` sets or resets it from the terminal (scrypt hash, 0600 file), `--off` removes it,
24
+ and **Settings → Dashboard password** changes it in the UI. When it is set, the dashboard shows a sign-in page and never serves the API token
25
+ to a signed-out visitor. It uses a 7-day HttpOnly SameSite=Strict session, rate-limits wrong passwords, and adds a Sign out button to the sidebar.
26
+ Mapped hosts are not affected, and neither are scripts or the CLI that send `X-Locadot-Token`.
27
+
28
+ ### Fixed
29
+ - The proxy can now tell a user's `LOCADOT_HTTP_PORT`/`LOCADOT_HTTPS_PORT` from the copies the CLI pins on every spawn
30
+ (`LOCADOT_USER_PORTS`), so saved ports aren't reported as env-overridden.
31
+
3
32
  ## 2.1.0-beta.0 (2026-09-27)
4
33
 
5
34
  ### Added
package/README.md CHANGED
@@ -111,16 +111,20 @@ short-lived pairing string, and the sender's mappings then show up in the receiv
111
111
  | `locadot hub:setup --domain dev.example.com` | Sender: publish through your own named tunnel on your Cloudflare domain (runs `cloudflared` login, creates the tunnel and routes DNS). The address stays the same. |
112
112
  | `locadot hub:quick` | Sender: publish on a trycloudflare URL. No account is needed, but **the URL changes if the tunnel restarts**, so receivers have to run `remote:url`. |
113
113
  | `locadot hub` / `hub:off` | Show the hub status and URL, or stop it. |
114
+ | `locadot hub:localhost <on\|off>` | Sender: allow or block admin peers from reaching any port on this machine's localhost. |
114
115
  | `locadot share --role viewer\|editor\|admin [--hosts a.localhost,b.localhost]` | Sender: print a pairing string. It works once and expires after 5 minutes. `--hosts` limits a viewer to those mappings. |
115
116
  | `locadot peers` / `peers:role <id> <role>` / `peers:revoke <id>` | Sender: list the connected machines, change a role, or cut one off. |
116
- | `locadot connect "<pairing string>" [--name alice]` | Receiver: connect and import the sender's mappings. If a name clashes, `app.localhost` becomes `app.alice.localhost`. |
117
- | `locadot remotes` / `remote:sync <name>` / `disconnect <name>` | Receiver: list the connections, pull new mappings, or remove the connection and its names. |
117
+ | `locadot connect "<pairing string>" [--name alice] [--domain dev]` | Receiver: connect and import the sender's mappings. If a name clashes, `app.localhost` becomes `app.alice.localhost`. Admins get a local domain to reach the sender's own localhost (see below); `--domain` picks it, otherwise it's a random `adjective-noun`. |
118
+ | `locadot remotes` / `remote:sync <name>` / `disconnect <name>` | Receiver: list the connections (with their domain), pull new mappings, or remove the connection and its names. |
119
+ | `locadot remote:domain <name> [domain] [--off]` | Receiver, admin only: set the local domain for a remote's localhost, randomize it (no domain given), or remove it (`--off`). |
118
120
  | `locadot remote:alias <name> <remote host> <local host>` | Receiver: give a sender mapping another local name, e.g. `he.localhost`. |
119
121
  | `locadot remote:add\|remote:update\|remote:rm <name> …` | Receiver: change mappings on the sender (editor or admin). |
120
122
  | `locadot remote:url <name> <url>` | Receiver: point a connection at the sender's new URL. |
121
123
 
122
124
  Roles: **viewer** can only browse (optionally limited to the mappings picked on the invite). **editor** can also add and change
123
- mappings on the sender. **admin** can also delete them and change sharing. Traffic goes receiver → Cloudflare → sender →
125
+ mappings on the sender. **admin** can also delete them and change sharing, and (unless the sender turned it off with
126
+ `hub:localhost off`) reach any port on the sender's own localhost through `https://<domain>.localhost` (the landing page) and
127
+ `https://<port>.<domain>.localhost` (`sender's localhost:<port>`). Traffic goes receiver → Cloudflare → sender →
124
128
  target, so an editor can reach anything the sender's machine can reach. Only give that role to people you trust. Only hashes
125
129
  of the tokens are stored, and failed attempts are rate limited. The sender's dashboard is never reachable through the hub.
126
130
  The dashboard has **Remote access** and **Connected machines** cards for all of this.
@@ -173,9 +177,23 @@ Trust and start-at-boot may need elevation. locadot asks the OS for it (polkit o
173
177
  If that isn't possible, for example on a headless Linux box, the panel shows the exact command to run instead: `sudo locadot trust`,
174
178
  `locadot startup:enable`, and so on.
175
179
 
180
+ ### Dashboard password
181
+
182
+ The dashboard is open to anyone on this machine by default. To require a password for the UI only:
183
+
184
+ ```sh
185
+ locadot ui:password # set or reset it (prompts twice; stored as an scrypt hash, 0600)
186
+ locadot ui:password --off # remove it
187
+ echo "$PW" | locadot ui:password --stdin
188
+ ```
189
+
190
+ You can also set, change or remove it under **Settings → Dashboard password** (changing or removing it there needs the current one).
191
+ Only the dashboard is protected. Your mapped hosts work as before, and so do scripts and the CLI that send `X-Locadot-Token`.
192
+ Sessions last 7 days, and changing the password signs everyone out. If you forget it, run `locadot ui:password` again in a terminal.
193
+
176
194
  ### JSON API (for scripts and AI agents)
177
195
 
178
- Everything the page does is a plain JSON API on the dashboard origin. The read routes are open to local callers. Every mutating route needs the
196
+ Everything the page does is a plain JSON API on the dashboard origin. The read routes are open to local callers, unless a [dashboard password](#dashboard-password) is set; then they need a signed-in session or the token. Every mutating route needs the
179
197
  `X-Locadot-Token` header. Get the token with `locadot token`. It lives in `<state dir>/.locadot-token` (mode 0600) and is regenerated
180
198
  each time the proxy starts.
181
199
 
@@ -191,6 +209,15 @@ each time the proxy starts.
191
209
  | `POST /api/trust` | `{ "trusted": true }` | Trust or untrust the CA. |
192
210
  | `POST /api/logs/clear` | | Clear the log. |
193
211
  | `POST /api/proxy/stop` | | Stop the proxy. Use `locadot start` to bring it back. |
212
+ | `GET /api/settings` | | Running ports/bind, `logLevel`, `stateDir`, `saved`/`env` overrides, and `restartRequired`. |
213
+ | `PUT /api/settings` | `{ "httpPort": 8080, "httpsPort": 8443 }` | Save new ports to the config file (400 if invalid or equal). Takes effect after a restart. |
214
+ | `POST /api/proxy/restart` | | Restart the proxy (e.g. to pick up saved ports). |
215
+ | `PUT /api/settings/ui-password` | `{ "password": "…", "current": "…" }` or `{ "enabled": false, "current": "…" }` | Set, change or remove the dashboard password (`current` is required once one is set). |
216
+ | `GET /api/hub` | | Hub status/config, plus `localhost` (sender: is localhost access on). |
217
+ | `PUT /api/hub/localhost` | `{ "enabled": true }` | Sender: turn localhost access for admin peers on/off (400 if the hub isn't configured). |
218
+ | `GET /api/remotes` | | Every remote (never the token), including `domain` and `localhost`. |
219
+ | `POST /api/remotes` | `{ "string": "<pairing string>", "name": "alice", "domain": "dev" }` | Receiver: connect using a pairing string; `domain` is optional (admin only, random if omitted). |
220
+ | `PUT /api/remotes/:name` | `{ "domain": "dev" }` or `{ "domain": "random" }` or `{ "domain": null }` | Receiver: set, randomize or remove a remote's local domain (400 invalid, 409 clash, 404 unknown). |
194
221
 
195
222
  Errors are `{ "error": "...", "hint": "<CLI command>" }` with a 4xx/5xx status.
196
223
 
@@ -39,6 +39,7 @@ const files = __importStar(require("./files"));
39
39
  const system = __importStar(require("./system"));
40
40
  const tunnel = __importStar(require("./tunnel"));
41
41
  const remote = __importStar(require("./remote"));
42
+ const ui = __importStar(require("./ui"));
42
43
  const doctor_1 = require("./doctor");
43
- const Commands = { ...hosts, ...proxy, ...files, ...system, ...tunnel, ...remote, doctor: doctor_1.doctor };
44
+ const Commands = { ...hosts, ...proxy, ...files, ...system, ...tunnel, ...remote, ...ui, doctor: doctor_1.doctor };
44
45
  exports.default = Commands;
@@ -8,6 +8,7 @@ exports.hubSetup = hubSetup;
8
8
  exports.hubQuick = hubQuick;
9
9
  exports.hubManual = hubManual;
10
10
  exports.hubOff = hubOff;
11
+ exports.hubLocalhost = hubLocalhost;
11
12
  exports.share = share;
12
13
  exports.peers = peers;
13
14
  exports.peersRole = peersRole;
@@ -15,6 +16,7 @@ exports.peersRevoke = peersRevoke;
15
16
  exports.connect = connect;
16
17
  exports.remotes = remotes;
17
18
  exports.remoteSync = remoteSync;
19
+ exports.remoteDomain = remoteDomain;
18
20
  exports.remoteAlias = remoteAlias;
19
21
  exports.remoteUrl = remoteUrl;
20
22
  exports.remoteAdd = remoteAdd;
@@ -59,6 +61,7 @@ const apiToken = () => {
59
61
  /** Talks to the running proxy's local dashboard API on its HTTP port. */
60
62
  const dashboardRequest = (method, path, options = {}) => new Promise((resolve, reject) => {
61
63
  const data = options.body !== undefined ? JSON.stringify(options.body) : undefined;
64
+ const token = file_1.default.read("API_TOKEN")?.trim();
62
65
  const req = http_1.default.request({
63
66
  host: "127.0.0.1",
64
67
  port: constants_1.default.server.httpPort,
@@ -66,6 +69,8 @@ const dashboardRequest = (method, path, options = {}) => new Promise((resolve, r
66
69
  method,
67
70
  headers: {
68
71
  Host: "localhost",
72
+ // Reads need it too once the dashboard has a password.
73
+ ...(token ? { "X-Locadot-Token": token } : {}),
69
74
  ...(data ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } : {}),
70
75
  ...options.headers,
71
76
  },
@@ -153,6 +158,20 @@ async function hubOff() {
153
158
  hub_config_1.default.clear();
154
159
  (0, shared_1.print)("🔒 Remote access turned off.");
155
160
  }
161
+ async function hubLocalhost(value) {
162
+ if (value !== "on" && value !== "off")
163
+ throw new localhost_1.InputError('❌ Use "on" or "off".');
164
+ requireRunning();
165
+ const { status, json } = await dashboardRequest("PUT", "/api/hub/localhost", {
166
+ headers: { "X-Locadot-Token": apiToken() },
167
+ body: { enabled: value === "on" },
168
+ });
169
+ if (status === 400)
170
+ throw new localhost_1.InputError(`❌ ${json?.error || "Set up the hub first: locadot hub:setup / hub:quick / hub:manual."}`);
171
+ if (status !== 200)
172
+ throw new localhost_1.InputError(`❌ Couldn't update (${status})${json?.error ? `: ${json.error}` : ""}.`);
173
+ (0, shared_1.print)(json.localhost ? "🖥️ Admin peers can now reach this machine's localhost." : "🔒 Admin peers can no longer reach this machine's localhost.");
174
+ }
156
175
  async function share(options) {
157
176
  const role = requireRole(options.role);
158
177
  if (options.hosts && role !== "viewer") {
@@ -200,13 +219,22 @@ async function peersRevoke(id) {
200
219
  links_1.default.revoke(id);
201
220
  (0, shared_1.print)(`🗑️ Revoked peer ${id}.`);
202
221
  }
222
+ /** After connect/sync/remote:domain: the sender's own localhost, reachable when this remote has a domain and we're admin. */
223
+ const printDomain = (remote) => {
224
+ if (remote.domain && remote.role === "admin") {
225
+ (0, shared_1.print)(`🖥️ ${remote.sender.hostname} localhost: ${(0, urls_1.urlFor)(`${remote.domain}.localhost`)} (any port: ${(0, urls_1.urlFor)(`<port>.${remote.domain}.localhost`)})`);
226
+ }
227
+ };
203
228
  async function connect(value, options) {
204
- const { remote, mapped, skipped } = await remotes_1.default.connect(value, { name: options.name });
229
+ const { remote, mapped, skipped, note } = await remotes_1.default.connect(value, { name: options.name, domain: options.domain });
205
230
  (0, shared_1.print)(`✅ Connected to ${remote.name} (${remote.sender.hostname}) as ${remote.role}.`);
206
231
  for (const m of mapped)
207
232
  (0, shared_1.print)(` ${(0, urls_1.urlFor)(m.local)} → ${m.host}`);
208
233
  for (const reason of skipped)
209
234
  (0, shared_1.print)(` ⚠️ skipped: ${reason}`);
235
+ if (note)
236
+ (0, shared_1.print)(` ⚠️ ${note}`);
237
+ printDomain(remote);
210
238
  }
211
239
  async function remotes(options) {
212
240
  const list = remotes_1.default.list();
@@ -223,16 +251,27 @@ async function remotes(options) {
223
251
  return;
224
252
  }
225
253
  for (const remote of list) {
226
- (0, shared_1.print)(`${remote.name} ${remote.url} (${remote.role})`);
254
+ (0, shared_1.print)(`${remote.name} ${remote.url} (${remote.role})${remote.domain ? ` domain: ${remote.domain}.localhost` : ""}`);
227
255
  for (const alias of aliasesFor(remote.name))
228
256
  (0, shared_1.print)(` ${(0, urls_1.urlFor)(alias.local)} → ${alias.host}`);
229
257
  }
230
258
  }
231
259
  async function remoteSync(name) {
232
- const { mapped } = await remotes_1.default.sync(name);
260
+ const { remote, mapped } = await remotes_1.default.sync(name);
233
261
  (0, shared_1.print)(`✅ ${name} synced.`);
234
262
  for (const m of mapped)
235
263
  (0, shared_1.print)(` + ${(0, urls_1.urlFor)(m.local)} → ${m.host}`);
264
+ printDomain(remote);
265
+ }
266
+ async function remoteDomain(name, domain, options) {
267
+ if (options.off) {
268
+ remotes_1.default.setDomain(name, null);
269
+ (0, shared_1.print)(`🗑️ Removed the local domain for ${name}.`);
270
+ return;
271
+ }
272
+ const remote = remotes_1.default.setDomain(name, domain || "random");
273
+ (0, shared_1.print)(`✅ ${name} domain: ${remote.domain}.localhost.`);
274
+ printDomain(remote);
236
275
  }
237
276
  async function remoteAlias(name, remoteHost, localHost) {
238
277
  await remotes_1.default.alias(name, remoteHost, localHost);
@@ -43,11 +43,12 @@ const localhost_1 = __importStar(require("../../lib/localhost"));
43
43
  const hosts_1 = __importDefault(require("../../lib/hosts"));
44
44
  const constants_1 = __importDefault(require("../../constants"));
45
45
  const tunnel_1 = require("../../proxy/tunnel");
46
+ const file_1 = __importDefault(require("../../utils/file"));
46
47
  const shared_1 = require("../shared");
47
48
  const TUNNEL_TIMEOUT_MS = 45000;
48
49
  /** Tunnel state lives in the proxy process; ask it over the local read-only API. */
49
50
  const tunnelRows = () => new Promise((resolve, reject) => {
50
- const req = http_1.default.get({ host: "127.0.0.1", port: constants_1.default.server.httpPort, path: "/api/hosts", headers: { Host: "localhost" }, timeout: 5000 }, (res) => {
51
+ const req = http_1.default.get({ host: "127.0.0.1", port: constants_1.default.server.httpPort, path: "/api/hosts", headers: { Host: "localhost", "X-Locadot-Token": file_1.default.read("API_TOKEN")?.trim() || "" }, timeout: 5000 }, (res) => {
51
52
  let body = "";
52
53
  res.on("data", (chunk) => (body += chunk));
53
54
  res.on("end", () => {
@@ -0,0 +1,94 @@
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.uiPassword = uiPassword;
40
+ const readline_1 = __importDefault(require("readline"));
41
+ const localhost_1 = require("../../lib/localhost");
42
+ const ui_auth_1 = __importStar(require("../../lib/ui-auth"));
43
+ const shared_1 = require("../shared");
44
+ const readStdin = () => new Promise((resolve, reject) => {
45
+ let data = "";
46
+ process.stdin.setEncoding("utf8");
47
+ process.stdin.on("data", (chunk) => (data += chunk));
48
+ process.stdin.on("end", () => resolve(data.split(/\r?\n/)[0]));
49
+ process.stdin.on("error", reject);
50
+ });
51
+ /** Reads a line without echoing it. */
52
+ const askHidden = (question) => new Promise((resolve) => {
53
+ const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
54
+ const write = rl._writeToOutput.bind(rl);
55
+ let prompted = false;
56
+ rl._writeToOutput = (s) => {
57
+ if (!prompted) {
58
+ prompted = true;
59
+ write(s);
60
+ }
61
+ else if (s.includes("\n"))
62
+ write("\n");
63
+ };
64
+ rl.question(question, (answer) => {
65
+ rl.close();
66
+ resolve(answer);
67
+ });
68
+ });
69
+ /** Sets, resets or removes the dashboard password. Works whether or not the proxy is running. */
70
+ async function uiPassword(options) {
71
+ if (options.off) {
72
+ const was = ui_auth_1.default.enabled();
73
+ ui_auth_1.default.clear();
74
+ (0, shared_1.print)(was ? "🔓 Dashboard password removed. The dashboard is open to anyone on this machine again." : "ℹ️ No dashboard password was set.");
75
+ return;
76
+ }
77
+ let password;
78
+ if (options.stdin || !process.stdin.isTTY) {
79
+ password = await readStdin();
80
+ }
81
+ else {
82
+ password = await askHidden("New dashboard password: ");
83
+ const again = await askHidden("Repeat it: ");
84
+ if (password !== again)
85
+ throw new localhost_1.InputError("❌ The passwords don't match.");
86
+ }
87
+ if (password.length < ui_auth_1.MIN_PASSWORD)
88
+ throw new localhost_1.InputError(`❌ The password must be at least ${ui_auth_1.MIN_PASSWORD} characters.`);
89
+ const was = ui_auth_1.default.enabled();
90
+ ui_auth_1.default.set(password);
91
+ (0, shared_1.print)(was
92
+ ? "🔑 Dashboard password reset. Everyone signed in has been signed out."
93
+ : "🔒 The dashboard now asks for this password. Scripts using the API token (X-Locadot-Token) are unaffected.");
94
+ }
@@ -26,6 +26,21 @@ const savedPorts = () => {
26
26
  }
27
27
  };
28
28
  const envPort = (name, saved, fallback) => validPort(process.env[name]) ?? validPort(saved) ?? fallback;
29
+ const PORT_ENV = ["LOCADOT_HTTP_PORT", "LOCADOT_HTTPS_PORT"];
30
+ // The CLI pins both port vars on every proxy it spawns, so the proxy can't tell them from a
31
+ // user's override. LOCADOT_USER_PORTS carries which ones the user actually set ("none" if neither).
32
+ const userPortEnv = () => {
33
+ if (process.env.LOCADOT_USER_PORTS)
34
+ return process.env.LOCADOT_USER_PORTS;
35
+ return PORT_ENV.filter((name) => validPort(process.env[name]) !== undefined).join(",") || "none";
36
+ };
37
+ /** The port var's value only if the user set it (not the CLI's pinned copy). */
38
+ const userEnvPort = (name) => {
39
+ const marker = process.env.LOCADOT_USER_PORTS;
40
+ if (marker && !marker.split(",").includes(name))
41
+ return undefined;
42
+ return validPort(process.env[name]);
43
+ };
29
44
  class Constants {
30
45
  }
31
46
  Constants.paths = {
@@ -38,12 +53,16 @@ Constants.paths = {
38
53
  // Secret for the dashboard's mutating API; rotated on every proxy start.
39
54
  API_TOKEN: path_1.default.join(PACKAGE_PATH, ".locadot-token"),
40
55
  CONFIG_FILE,
56
+ // Optional dashboard password: scrypt hash + session signing secret (0600).
57
+ UI_AUTH_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-ui-auth.json"),
41
58
  // Remote access. Sender: public hostname config, invites + peers (0600). Receiver: remotes (0600).
42
59
  HUB_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-hub.json"),
43
60
  LINKS_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-links.json"),
44
61
  REMOTES_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-remotes.json"),
45
62
  };
46
63
  Constants.validPort = validPort;
64
+ Constants.userPortEnv = userPortEnv;
65
+ Constants.userEnvPort = userEnvPort;
47
66
  Constants.server = {
48
67
  httpPort: envPort("LOCADOT_HTTP_PORT", savedPorts().httpPort, 80),
49
68
  httpsPort: envPort("LOCADOT_HTTPS_PORT", savedPorts().httpsPort, 443),
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.upstreamDown = exports.proxyNotFound = void 0;
3
+ exports.upstreamDown = exports.remoteLocalhost = exports.proxyNotFound = void 0;
4
4
  const escape_1 = require("../dashboard/escape");
5
5
  const page = (title, body, dashboardUrl) => `<!DOCTYPE html>
6
6
  <html lang="en">
@@ -62,3 +62,26 @@ const upstreamDown = (host, target, reason, dashboardUrl) => page("locadot: upst
62
62
  <code>${(0, escape_1.escapeHtml)(host)}</code> → <code>${(0, escape_1.escapeHtml)(target)}</code><br/><br/>
63
63
  ${(0, escape_1.escapeHtml)(reason)}. Is the app running?`, dashboardUrl);
64
64
  exports.upstreamDown = upstreamDown;
65
+ /** Receiver side: the landing page of `<domain>.localhost`, an admin's door to a sender's whole localhost. */
66
+ const remoteLocalhost = (opts) => page(`locadot: ${opts.sender} localhost`, opts.allowed
67
+ ? `<strong style="color: #7ee2a8;">${(0, escape_1.escapeHtml)(opts.sender)}</strong>: localhost<br/><br/>
68
+ Any port on that machine is at <code>${(0, escape_1.escapeHtml)(opts.portUrl.replace("PORT", "<port>"))}</code>.<br/><br/>
69
+ <form id="go" style="display:flex;gap:8px">
70
+ <input id="port" inputmode="numeric" pattern="[0-9]{1,5}" placeholder="3000" required
71
+ style="flex:1;padding:6px 10px;border-radius:4px;border:1px solid #444;background:#2e2e2e;color:#fff" />
72
+ <button style="padding:6px 14px;border-radius:4px;border:0;background:#61dafb;color:#000;font-weight:bold">Open</button>
73
+ </form>
74
+ <script>
75
+ document.getElementById("go").addEventListener("submit", function (e) {
76
+ e.preventDefault();
77
+ var port = document.getElementById("port").value.trim();
78
+ if (/^[0-9]{1,5}$/.test(port)) location.href = ${JSON.stringify(opts.portUrl)}.replace("PORT", port);
79
+ });
80
+ </script>
81
+ ${opts.hosts.length
82
+ ? `<br/>Shared hosts:<br/>${opts.hosts.map((h) => `<a href="${(0, escape_1.escapeHtml)(h.url)}">${(0, escape_1.escapeHtml)(h.local)}</a>`).join("<br/>")}`
83
+ : ""}`
84
+ : `<strong style="color: #ff6b6b;">Not available:</strong>
85
+ <code>${(0, escape_1.escapeHtml)(opts.domain)}.localhost</code> reaches ${(0, escape_1.escapeHtml)(opts.sender)}'s localhost only while you're an
86
+ admin there and it allows localhost access. Ask its owner, then run <code>locadot remote:sync ${(0, escape_1.escapeHtml)(opts.name)}</code>.`, opts.dashboardUrl);
87
+ exports.remoteLocalhost = remoteLocalhost;
@@ -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");
@@ -54,6 +58,7 @@ const urls_1 = require("../lib/urls");
54
58
  const links_1 = __importStar(require("../lib/links"));
55
59
  const hub_config_1 = __importDefault(require("../lib/hub-config"));
56
60
  const remotes_1 = __importStar(require("../lib/remotes"));
61
+ const ui_auth_1 = __importDefault(require("../lib/ui-auth"));
57
62
  const MAX_BODY = 64 * 1024;
58
63
  class ApiError extends Error {
59
64
  constructor(status, message, hint) {
@@ -199,6 +204,61 @@ const sanitizeRemote = (remote) => {
199
204
  const { token, ...rest } = remote;
200
205
  return rest;
201
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
+ }
202
262
  const REMOTE_SYNC_TIMEOUT_MS = 5000;
203
263
  async function buildRemoteRows(ctx) {
204
264
  const remotes = remotes_1.default.list();
@@ -215,6 +275,8 @@ async function buildRemoteRows(ctx) {
215
275
  hosts: result.remote.hosts,
216
276
  sender: result.remote.sender,
217
277
  connectedAt: result.remote.connectedAt,
278
+ domain: result.remote.domain,
279
+ localhost: result.remote.localhost,
218
280
  status: "ok",
219
281
  available: result.hosts,
220
282
  mapped: result.mapped,
@@ -228,6 +290,8 @@ async function buildRemoteRows(ctx) {
228
290
  hosts: remote.hosts,
229
291
  sender: remote.sender,
230
292
  connectedAt: remote.connectedAt,
293
+ domain: remote.domain,
294
+ localhost: remote.localhost,
231
295
  status: "error",
232
296
  error: clean(String(error?.message || error)),
233
297
  available: null,
@@ -245,6 +309,7 @@ function buildHubBody(ctx) {
245
309
  return {
246
310
  hub: ctx.hub(),
247
311
  config: config ?? null,
312
+ localhost: config?.localhost !== false,
248
313
  invites: invites.map(sanitizeInvite),
249
314
  peers: peers.map(sanitizePeer),
250
315
  };
@@ -263,6 +328,9 @@ async function route(req, url, ctx) {
263
328
  if (path === "/api/remotes" && (method === "GET" || method === "HEAD")) {
264
329
  return { status: 200, body: { remotes: await buildRemoteRows(ctx) } };
265
330
  }
331
+ if (path === "/api/settings" && (method === "GET" || method === "HEAD")) {
332
+ return { status: 200, body: buildSettingsBody(ctx) };
333
+ }
266
334
  const hostMatch = /^\/api\/hosts\/([^/]+)$/.exec(path);
267
335
  const inviteMatch = /^\/api\/invites\/([^/]+)$/.exec(path);
268
336
  const peerMatch = /^\/api\/peers\/([^/]+)$/.exec(path);
@@ -273,8 +341,10 @@ async function route(req, url, ctx) {
273
341
  const remoteHostMatch = /^\/api\/remotes\/([^/]+)\/hosts\/([^/]+)$/.exec(path);
274
342
  const known = (path === "/api/hosts" && method === "POST") ||
275
343
  (hostMatch && (method === "PUT" || method === "DELETE")) ||
276
- (["/api/startup", "/api/trust", "/api/logs/clear", "/api/proxy/stop", "/api/cloudflared/install", "/api/hub", "/api/invites", "/api/remotes"].includes(path) &&
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) &&
277
345
  method === "POST") ||
346
+ (path === "/api/hub/localhost" && method === "PUT") ||
347
+ (path === "/api/settings" && method === "PUT") ||
278
348
  (inviteMatch && method === "DELETE") ||
279
349
  (peerMatch && (method === "PUT" || method === "DELETE")) ||
280
350
  (remoteMatch && (method === "PUT" || method === "DELETE")) ||
@@ -287,6 +357,29 @@ async function route(req, url, ctx) {
287
357
  assertTrusted(req, ctx);
288
358
  const body = await readJson(req);
289
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
+ }
290
383
  if (path === "/api/hosts") {
291
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") });
292
385
  ctx.reload();
@@ -354,6 +447,15 @@ async function route(req, url, ctx) {
354
447
  }
355
448
  return { status: 200, body: { hub: ctx.hub() } };
356
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
+ }
357
459
  if (path === "/api/invites" && method === "POST") {
358
460
  if (!isRole(body.role))
359
461
  throw new ApiError(400, "`role` must be viewer, editor or admin.");
@@ -385,10 +487,20 @@ async function route(req, url, ctx) {
385
487
  if (typeof body.string !== "string" || !body.string.trim())
386
488
  throw new ApiError(400, "`string` is required.");
387
489
  const name = typeof body.name === "string" && body.name.trim() ? body.name.trim() : undefined;
388
- const result = await remotes_1.default.connect(body.string.trim(), { name });
490
+ const domain = optionalString(body.domain, "domain");
491
+ const result = await remotes_1.default.connect(body.string.trim(), { name, domain });
389
492
  ctx.reload();
390
493
  return { status: 200, body: { ...result, remote: sanitizeRemote(result.remote) } };
391
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
+ }
392
504
  if (remoteMatch && method === "PUT") {
393
505
  if (typeof body.url !== "string" || !body.url.trim())
394
506
  throw new ApiError(400, "`url` is required.");
@@ -491,6 +603,22 @@ async function route(req, url, ctx) {
491
603
  case "/api/proxy/stop":
492
604
  setTimeout(() => ctx.shutdown("dashboard stop"), 100);
493
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
+ }
494
622
  }
495
623
  return undefined;
496
624
  }