locadot 2.1.0-beta.0 → 2.1.0-beta.2

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,41 @@
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
+ ### Changed
29
+ - `<domain>.localhost` now opens the sender's own dashboard for an admin peer, with full control (hosts, sharing, peers,
30
+ settings), instead of a landing page; no port needed. The port picker moved to `<domain>.localhost/_locadot/ports`.
31
+ The page never carries the sender's API token, the API only accepts calls from that `<domain>.localhost` origin, the sender
32
+ checks the admin role on every request, `hub:localhost off` turns it off, and a dashboard password still asks for sign-in.
33
+ Senders must run this version too; an older sender answers `<domain>.localhost` with a 404.
34
+
35
+ ### Fixed
36
+ - The proxy can now tell a user's `LOCADOT_HTTP_PORT`/`LOCADOT_HTTPS_PORT` from the copies the CLI pins on every spawn
37
+ (`LOCADOT_USER_PORTS`), so saved ports aren't reported as env-overridden.
38
+
3
39
  ## 2.1.0-beta.0 (2026-09-27)
4
40
 
5
41
  ### Added
package/README.md CHANGED
@@ -111,18 +111,24 @@ 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 opening this machine's dashboard and any port on its 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 that opens the sender's dashboard and 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`) open the sender's own dashboard at `https://<domain>.localhost` and manage it as if they were sitting at it,
127
+ and reach any port on the sender's localhost at `https://<port>.<domain>.localhost` (`sender's localhost:<port>`). The port
128
+ picker is at `https://<domain>.localhost/_locadot/ports`. The admin's browser never gets the sender's API token, so revoking or
129
+ demoting the peer cuts off its dashboard access at once; a dashboard password on the sender still applies. Traffic goes receiver → Cloudflare → sender →
124
130
  target, so an editor can reach anything the sender's machine can reach. Only give that role to people you trust. Only hashes
125
- of the tokens are stored, and failed attempts are rate limited. The sender's dashboard is never reachable through the hub.
131
+ of the tokens are stored, and failed attempts are rate limited. Only admins can open the sender's dashboard through the hub.
126
132
  The dashboard has **Remote access** and **Connected machines** cards for all of this.
127
133
 
128
134
  ### Certificates
@@ -173,9 +179,23 @@ Trust and start-at-boot may need elevation. locadot asks the OS for it (polkit o
173
179
  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
180
  `locadot startup:enable`, and so on.
175
181
 
182
+ ### Dashboard password
183
+
184
+ The dashboard is open to anyone on this machine by default. To require a password for the UI only:
185
+
186
+ ```sh
187
+ locadot ui:password # set or reset it (prompts twice; stored as an scrypt hash, 0600)
188
+ locadot ui:password --off # remove it
189
+ echo "$PW" | locadot ui:password --stdin
190
+ ```
191
+
192
+ You can also set, change or remove it under **Settings → Dashboard password** (changing or removing it there needs the current one).
193
+ Only the dashboard is protected. Your mapped hosts work as before, and so do scripts and the CLI that send `X-Locadot-Token`.
194
+ Sessions last 7 days, and changing the password signs everyone out. If you forget it, run `locadot ui:password` again in a terminal.
195
+
176
196
  ### JSON API (for scripts and AI agents)
177
197
 
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
198
+ 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
199
  `X-Locadot-Token` header. Get the token with `locadot token`. It lives in `<state dir>/.locadot-token` (mode 0600) and is regenerated
180
200
  each time the proxy starts.
181
201
 
@@ -191,6 +211,15 @@ each time the proxy starts.
191
211
  | `POST /api/trust` | `{ "trusted": true }` | Trust or untrust the CA. |
192
212
  | `POST /api/logs/clear` | | Clear the log. |
193
213
  | `POST /api/proxy/stop` | | Stop the proxy. Use `locadot start` to bring it back. |
214
+ | `GET /api/settings` | | Running ports/bind, `logLevel`, `stateDir`, `saved`/`env` overrides, and `restartRequired`. |
215
+ | `PUT /api/settings` | `{ "httpPort": 8080, "httpsPort": 8443 }` | Save new ports to the config file (400 if invalid or equal). Takes effect after a restart. |
216
+ | `POST /api/proxy/restart` | | Restart the proxy (e.g. to pick up saved ports). |
217
+ | `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). |
218
+ | `GET /api/hub` | | Hub status/config, plus `localhost` (sender: is localhost access on). |
219
+ | `PUT /api/hub/localhost` | `{ "enabled": true }` | Sender: turn localhost access for admin peers on/off (400 if the hub isn't configured). |
220
+ | `GET /api/remotes` | | Every remote (never the token), including `domain` and `localhost`. |
221
+ | `POST /api/remotes` | `{ "string": "<pairing string>", "name": "alice", "domain": "dev" }` | Receiver: connect using a pairing string; `domain` is optional (admin only, random if omitted). |
222
+ | `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
223
 
195
224
  Errors are `{ "error": "...", "hint": "<CLI command>" }` with a 4xx/5xx status.
196
225
 
@@ -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 dashboard and 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} dashboard: ${(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,27 @@ 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: `<domain>.localhost/_locadot/ports`, the port picker and shared hosts of an admin remote. */
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
+ Its dashboard is <a href="/">${(0, escape_1.escapeHtml)(opts.domain)}.localhost</a>.<br/>
69
+ Any port on that machine is at <code>${(0, escape_1.escapeHtml)(opts.portUrl.replace("PORT", "<port>"))}</code>.<br/><br/>
70
+ <form id="go" style="display:flex;gap:8px">
71
+ <input id="port" inputmode="numeric" pattern="[0-9]{1,5}" placeholder="3000" required
72
+ style="flex:1;padding:6px 10px;border-radius:4px;border:1px solid #444;background:#2e2e2e;color:#fff" />
73
+ <button style="padding:6px 14px;border-radius:4px;border:0;background:#61dafb;color:#000;font-weight:bold">Open</button>
74
+ </form>
75
+ <script>
76
+ document.getElementById("go").addEventListener("submit", function (e) {
77
+ e.preventDefault();
78
+ var port = document.getElementById("port").value.trim();
79
+ if (/^[0-9]{1,5}$/.test(port)) location.href = ${JSON.stringify(opts.portUrl)}.replace("PORT", port);
80
+ });
81
+ </script>
82
+ ${opts.hosts.length
83
+ ? `<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/>")}`
84
+ : ""}`
85
+ : `<strong style="color: #ff6b6b;">Not available:</strong>
86
+ <code>${(0, escape_1.escapeHtml)(opts.domain)}.localhost</code> reaches ${(0, escape_1.escapeHtml)(opts.sender)}'s localhost only while you're an
87
+ 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);
88
+ exports.remoteLocalhost = remoteLocalhost;