locadot 2.0.0 → 2.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
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
+
32
+ ## 2.1.0-beta.0 (2026-09-27)
33
+
34
+ ### Added
35
+ - Remote access: share your whole locadot with another locadot over a Cloudflare named tunnel, a quick tunnel, or any URL.
36
+ Receivers pair with a one-time string (5 minutes) and get viewer, editor or admin access, which the sender can change or
37
+ revoke. The sender's mappings appear in the receiver's locadot; clashing names become `<host>.<remote>.localhost`.
38
+ New commands: `hub*`, `share`, `peers*`, `connect`, `remotes`, `remote:*` and `disconnect`. There are also new dashboard cards.
39
+ - Dashboard: a collapsible sidebar with section links, active-section highlighting and counts. Collapse it to an icon rail
40
+ (remembered per browser); on small screens it is a slide-out drawer.
41
+
3
42
  ## 2.0.0 (2026-09-26)
4
43
 
5
44
  ### Changed
package/README.md CHANGED
@@ -101,6 +101,34 @@ pass-through calls may only go to public addresses: loopback, LAN, link-local an
101
101
  on the address actually dialled, so the tunnel can't be used to reach your machine or network. The dashboard can also do this:
102
102
  use **Share** / **Unshare** on a row.
103
103
 
104
+ ### Remote access (locadot to locadot)
105
+
106
+ One machine (the sender) publishes its whole locadot on a Cloudflare hostname. Other machines (receivers) connect with a
107
+ short-lived pairing string, and the sender's mappings then show up in the receiver's own locadot and dashboard.
108
+
109
+ | Command | What it does |
110
+ | --- | --- |
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
+ | `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
+ | `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. |
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. |
116
+ | `locadot peers` / `peers:role <id> <role>` / `peers:revoke <id>` | Sender: list the connected machines, change a role, or cut one off. |
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`). |
120
+ | `locadot remote:alias <name> <remote host> <local host>` | Receiver: give a sender mapping another local name, e.g. `he.localhost`. |
121
+ | `locadot remote:add\|remote:update\|remote:rm <name> …` | Receiver: change mappings on the sender (editor or admin). |
122
+ | `locadot remote:url <name> <url>` | Receiver: point a connection at the sender's new URL. |
123
+
124
+ Roles: **viewer** can only browse (optionally limited to the mappings picked on the invite). **editor** can also add and change
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 →
128
+ target, so an editor can reach anything the sender's machine can reach. Only give that role to people you trust. Only hashes
129
+ of the tokens are stored, and failed attempts are rate limited. The sender's dashboard is never reachable through the hub.
130
+ The dashboard has **Remote access** and **Connected machines** cards for all of this.
131
+
104
132
  ### Certificates
105
133
 
106
134
  | Command | What it does |
@@ -149,9 +177,23 @@ Trust and start-at-boot may need elevation. locadot asks the OS for it (polkit o
149
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`,
150
178
  `locadot startup:enable`, and so on.
151
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
+
152
194
  ### JSON API (for scripts and AI agents)
153
195
 
154
- 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
155
197
  `X-Locadot-Token` header. Get the token with `locadot token`. It lives in `<state dir>/.locadot-token` (mode 0600) and is regenerated
156
198
  each time the proxy starts.
157
199
 
@@ -167,6 +209,15 @@ each time the proxy starts.
167
209
  | `POST /api/trust` | `{ "trusted": true }` | Trust or untrust the CA. |
168
210
  | `POST /api/logs/clear` | | Clear the log. |
169
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). |
170
221
 
171
222
  Errors are `{ "error": "...", "hint": "<CLI command>" }` with a 4xx/5xx status.
172
223
 
@@ -38,6 +38,8 @@ const proxy = __importStar(require("./proxy"));
38
38
  const files = __importStar(require("./files"));
39
39
  const system = __importStar(require("./system"));
40
40
  const tunnel = __importStar(require("./tunnel"));
41
+ const remote = __importStar(require("./remote"));
42
+ const ui = __importStar(require("./ui"));
41
43
  const doctor_1 = require("./doctor");
42
- const Commands = { ...hosts, ...proxy, ...files, ...system, ...tunnel, doctor: doctor_1.doctor };
44
+ const Commands = { ...hosts, ...proxy, ...files, ...system, ...tunnel, ...remote, ...ui, doctor: doctor_1.doctor };
43
45
  exports.default = Commands;
@@ -0,0 +1,315 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.hub = hub;
7
+ exports.hubSetup = hubSetup;
8
+ exports.hubQuick = hubQuick;
9
+ exports.hubManual = hubManual;
10
+ exports.hubOff = hubOff;
11
+ exports.hubLocalhost = hubLocalhost;
12
+ exports.share = share;
13
+ exports.peers = peers;
14
+ exports.peersRole = peersRole;
15
+ exports.peersRevoke = peersRevoke;
16
+ exports.connect = connect;
17
+ exports.remotes = remotes;
18
+ exports.remoteSync = remoteSync;
19
+ exports.remoteDomain = remoteDomain;
20
+ exports.remoteAlias = remoteAlias;
21
+ exports.remoteUrl = remoteUrl;
22
+ exports.remoteAdd = remoteAdd;
23
+ exports.remoteUpdate = remoteUpdate;
24
+ exports.remoteRm = remoteRm;
25
+ exports.disconnect = disconnect;
26
+ const http_1 = __importDefault(require("http"));
27
+ const constants_1 = __importDefault(require("../../constants"));
28
+ const proxy_control_1 = __importDefault(require("../../lib/proxy-control"));
29
+ const localhost_1 = require("../../lib/localhost");
30
+ const urls_1 = require("../../lib/urls");
31
+ const registry_1 = __importDefault(require("../../lib/registry"));
32
+ const file_1 = __importDefault(require("../../utils/file"));
33
+ const links_1 = __importDefault(require("../../lib/links"));
34
+ const hub_config_1 = __importDefault(require("../../lib/hub-config"));
35
+ const hub_tunnel_1 = require("../../proxy/hub-tunnel");
36
+ const remotes_1 = __importDefault(require("../../lib/remotes"));
37
+ const shared_1 = require("../shared");
38
+ const ROLES = ["viewer", "editor", "admin"];
39
+ const requireRole = (value) => {
40
+ if (typeof value !== "string" || !ROLES.includes(value)) {
41
+ throw new localhost_1.InputError(`❌ Invalid role "${value}". Use viewer, editor or admin.`);
42
+ }
43
+ return value;
44
+ };
45
+ const splitHosts = (value) => value
46
+ ? value
47
+ .split(",")
48
+ .map((s) => s.trim())
49
+ .filter(Boolean)
50
+ : undefined;
51
+ const requireRunning = () => {
52
+ if (!proxy_control_1.default.running())
53
+ throw new localhost_1.InputError("❌ The proxy isn't running. Run `locadot start`.");
54
+ };
55
+ const apiToken = () => {
56
+ const token = proxy_control_1.default.running() ? file_1.default.read("API_TOKEN")?.trim() : undefined;
57
+ if (!token)
58
+ throw new localhost_1.InputError("❌ No API token found. Run `locadot start`.");
59
+ return token;
60
+ };
61
+ /** Talks to the running proxy's local dashboard API on its HTTP port. */
62
+ const dashboardRequest = (method, path, options = {}) => new Promise((resolve, reject) => {
63
+ const data = options.body !== undefined ? JSON.stringify(options.body) : undefined;
64
+ const token = file_1.default.read("API_TOKEN")?.trim();
65
+ const req = http_1.default.request({
66
+ host: "127.0.0.1",
67
+ port: constants_1.default.server.httpPort,
68
+ path,
69
+ method,
70
+ headers: {
71
+ Host: "localhost",
72
+ // Reads need it too once the dashboard has a password.
73
+ ...(token ? { "X-Locadot-Token": token } : {}),
74
+ ...(data ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } : {}),
75
+ ...options.headers,
76
+ },
77
+ timeout: 5000,
78
+ }, (res) => {
79
+ let body = "";
80
+ res.on("data", (chunk) => (body += chunk));
81
+ res.on("end", () => {
82
+ try {
83
+ resolve({ status: res.statusCode || 0, json: body ? JSON.parse(body) : undefined });
84
+ }
85
+ catch {
86
+ reject(new localhost_1.InputError("❌ The proxy didn't answer. Is it running? Try `locadot status`."));
87
+ }
88
+ });
89
+ });
90
+ req.on("timeout", () => req.destroy(new Error("timeout")));
91
+ req.on("error", () => reject(new localhost_1.InputError("❌ The proxy isn't running. Run `locadot start`.")));
92
+ if (data)
93
+ req.write(data);
94
+ req.end();
95
+ });
96
+ const getHub = async () => {
97
+ requireRunning();
98
+ const { status, json } = await dashboardRequest("GET", "/api/hub");
99
+ if (status !== 200)
100
+ throw new localhost_1.InputError("❌ Couldn't read hub status. Try `locadot status`.");
101
+ return json;
102
+ };
103
+ const HUB_EMOJI = { off: "🔒", starting: "⏳", up: "🌍", error: "❌", login: "🔑" };
104
+ async function hub(options) {
105
+ const { hub } = await getHub();
106
+ if (options.json) {
107
+ console.log(JSON.stringify(hub, null, 2));
108
+ return;
109
+ }
110
+ if (!hub.enabled) {
111
+ (0, shared_1.print)("🔒 Remote access is off. Turn it on: locadot hub:setup / hub:quick / hub:manual");
112
+ return;
113
+ }
114
+ (0, shared_1.print)(`${HUB_EMOJI[hub.status] ?? "❔"} ${hub.mode} — ${hub.status}${hub.url ? `: ${hub.url}` : ""}`);
115
+ if (hub.loginUrl)
116
+ (0, shared_1.print)(` Open to finish login: ${hub.loginUrl}`);
117
+ if (hub.error)
118
+ (0, shared_1.print)(` ${hub.error}`);
119
+ }
120
+ async function hubSetup(options) {
121
+ if (!options.domain)
122
+ throw new localhost_1.InputError("❌ Missing --domain <domain>.");
123
+ (0, shared_1.print)(`🔗 Setting up a named tunnel for ${options.domain}…`);
124
+ const config = await (0, hub_tunnel_1.setupNamedTunnel)(options.domain, options.tunnel, (loginUrl) => {
125
+ (0, shared_1.print)(`🔑 Open this URL to log in to Cloudflare: ${loginUrl}`);
126
+ (0, shared_1.print)(" Waiting for login…");
127
+ });
128
+ hub_config_1.default.write(config);
129
+ (0, shared_1.print)(`✅ Hub configured: https://${options.domain}`);
130
+ (0, shared_1.print)(" The running proxy watches this and starts the tunnel automatically. Not running? `locadot start`.");
131
+ }
132
+ async function hubQuick() {
133
+ hub_config_1.default.write({ mode: "quick" });
134
+ (0, shared_1.print)("🌍 Quick tunnel enabled (trycloudflare.com).");
135
+ (0, shared_1.print)("⚠️ The trycloudflare URL changes whenever cloudflared or the proxy restarts.");
136
+ (0, shared_1.print)(" When that happens, tell receivers to run: locadot remote:url <name> <new-url>");
137
+ (0, shared_1.print)(" Check the current URL any time: locadot hub");
138
+ (0, shared_1.print)(" The running proxy watches this and starts the tunnel automatically. Not running? `locadot start`.");
139
+ }
140
+ async function hubManual(options) {
141
+ if (!options.url)
142
+ throw new localhost_1.InputError("❌ Missing --url <url>.");
143
+ let parsed;
144
+ try {
145
+ parsed = new URL(options.url);
146
+ }
147
+ catch {
148
+ throw new localhost_1.InputError(`❌ Invalid URL "${options.url}".`);
149
+ }
150
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
151
+ throw new localhost_1.InputError(`❌ Invalid URL "${options.url}". Use http:// or https://.`);
152
+ }
153
+ hub_config_1.default.write({ mode: "manual", url: options.url.replace(/\/+$/, "") });
154
+ (0, shared_1.print)(`✅ Hub configured: ${parsed.origin}`);
155
+ (0, shared_1.print)(" The running proxy watches this automatically. Not running? `locadot start`.");
156
+ }
157
+ async function hubOff() {
158
+ hub_config_1.default.clear();
159
+ (0, shared_1.print)("🔒 Remote access turned off.");
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
+ }
175
+ async function share(options) {
176
+ const role = requireRole(options.role);
177
+ if (options.hosts && role !== "viewer") {
178
+ (0, shared_1.print)(`⚠️ --hosts is ignored for ${role}s: they see every host.`);
179
+ }
180
+ const hosts = role === "viewer" ? splitHosts(options.hosts) : undefined;
181
+ requireRunning();
182
+ const { status, json } = await dashboardRequest("POST", "/api/invites", {
183
+ headers: { "X-Locadot-Token": apiToken() },
184
+ body: { role, hosts },
185
+ });
186
+ if (status === 409) {
187
+ throw new localhost_1.InputError("❌ The hub isn't up. Set it up first: locadot hub:setup / hub:quick / hub:manual.");
188
+ }
189
+ if (status !== 200 && status !== 201) {
190
+ throw new localhost_1.InputError(`❌ Couldn't create an invite (${status})${json?.error ? `: ${json.error}` : ""}.`);
191
+ }
192
+ (0, shared_1.print)("🔗 Pairing code (valid for 5 minutes, one use):");
193
+ (0, shared_1.print)("");
194
+ (0, shared_1.print)(` ${json.string}`);
195
+ (0, shared_1.print)("");
196
+ (0, shared_1.print)(` Role: ${json.role}${json.hosts ? ` — hosts: ${json.hosts.join(", ")}` : ""}`);
197
+ }
198
+ async function peers(options) {
199
+ const { invites, peers } = links_1.default.list();
200
+ if (options.json) {
201
+ console.log(JSON.stringify({ invites, peers }, null, 2));
202
+ return;
203
+ }
204
+ if (!peers.length)
205
+ (0, shared_1.print)("No peers yet. Create an invite: locadot share --role viewer");
206
+ for (const peer of peers) {
207
+ (0, shared_1.print)(`${peer.id} ${peer.name} ${peer.role}${peer.hosts ? ` [${peer.hosts.join(", ")}]` : ""} last seen: ${peer.lastSeen ?? "never"}`);
208
+ }
209
+ if (invites.length)
210
+ (0, shared_1.print)(`⏳ Pending invites: ${invites.length}`);
211
+ }
212
+ async function peersRole(id, role, options) {
213
+ const parsedRole = requireRole(role);
214
+ const hosts = parsedRole === "viewer" ? splitHosts(options.hosts) : undefined;
215
+ const peer = links_1.default.setRole(id, parsedRole, hosts);
216
+ (0, shared_1.print)(`✅ ${peer.name} is now ${peer.role}${peer.hosts ? ` [${peer.hosts.join(", ")}]` : ""}.`);
217
+ }
218
+ async function peersRevoke(id) {
219
+ links_1.default.revoke(id);
220
+ (0, shared_1.print)(`🗑️ Revoked peer ${id}.`);
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
+ };
228
+ async function connect(value, options) {
229
+ const { remote, mapped, skipped, note } = await remotes_1.default.connect(value, { name: options.name, domain: options.domain });
230
+ (0, shared_1.print)(`✅ Connected to ${remote.name} (${remote.sender.hostname}) as ${remote.role}.`);
231
+ for (const m of mapped)
232
+ (0, shared_1.print)(` ${(0, urls_1.urlFor)(m.local)} → ${m.host}`);
233
+ for (const reason of skipped)
234
+ (0, shared_1.print)(` ⚠️ skipped: ${reason}`);
235
+ if (note)
236
+ (0, shared_1.print)(` ⚠️ ${note}`);
237
+ printDomain(remote);
238
+ }
239
+ async function remotes(options) {
240
+ const list = remotes_1.default.list();
241
+ const registry = registry_1.default.read();
242
+ const aliasesFor = (name) => Object.entries(registry.hosts)
243
+ .filter(([, entry]) => entry.remote?.name === name)
244
+ .map(([local, entry]) => ({ local, host: entry.remote.host }));
245
+ if (options.json) {
246
+ console.log(JSON.stringify(list.map(({ token, ...remote }) => ({ ...remote, aliases: aliasesFor(remote.name) })), null, 2));
247
+ return;
248
+ }
249
+ if (!list.length) {
250
+ (0, shared_1.print)("No remotes. Connect to one: locadot connect <pairing-string>");
251
+ return;
252
+ }
253
+ for (const remote of list) {
254
+ (0, shared_1.print)(`${remote.name} ${remote.url} (${remote.role})${remote.domain ? ` domain: ${remote.domain}.localhost` : ""}`);
255
+ for (const alias of aliasesFor(remote.name))
256
+ (0, shared_1.print)(` ${(0, urls_1.urlFor)(alias.local)} → ${alias.host}`);
257
+ }
258
+ }
259
+ async function remoteSync(name) {
260
+ const { remote, mapped } = await remotes_1.default.sync(name);
261
+ (0, shared_1.print)(`✅ ${name} synced.`);
262
+ for (const m of mapped)
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);
275
+ }
276
+ async function remoteAlias(name, remoteHost, localHost) {
277
+ await remotes_1.default.alias(name, remoteHost, localHost);
278
+ (0, shared_1.print)(`✅ ${(0, urls_1.urlFor)(localHost)} → ${remoteHost} (${name})`);
279
+ }
280
+ async function remoteUrl(name, url) {
281
+ let parsed;
282
+ try {
283
+ parsed = new URL(url);
284
+ }
285
+ catch {
286
+ throw new localhost_1.InputError(`❌ Invalid URL "${url}".`);
287
+ }
288
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
289
+ throw new localhost_1.InputError(`❌ Invalid URL "${url}". Use http:// or https://.`);
290
+ }
291
+ remotes_1.default.setUrl(name, url.replace(/\/+$/, ""));
292
+ (0, shared_1.print)(`✅ ${name} now points to ${parsed.origin}.`);
293
+ }
294
+ async function remoteAdd(name, options) {
295
+ if (!options.host)
296
+ throw new localhost_1.InputError("❌ Missing --host <host>.");
297
+ if (!options.target)
298
+ throw new localhost_1.InputError("❌ Missing --target <url>.");
299
+ await remotes_1.default.addHost(name, { host: options.host, target: options.target, insecure: options.insecure, cors: options.cors });
300
+ (0, shared_1.print)(`✅ ${options.host} → ${options.target} on ${name}.`);
301
+ }
302
+ async function remoteUpdate(name, options) {
303
+ if (!options.host)
304
+ throw new localhost_1.InputError("❌ Missing --host <host>.");
305
+ await remotes_1.default.updateHost(name, options.host, { target: options.target, cors: options.cors });
306
+ (0, shared_1.print)(`✅ Updated ${options.host} on ${name}.`);
307
+ }
308
+ async function remoteRm(name, host) {
309
+ await remotes_1.default.removeHost(name, host);
310
+ (0, shared_1.print)(`🗑️ Removed ${host} from ${name}.`);
311
+ }
312
+ async function disconnect(name) {
313
+ await remotes_1.default.disconnect(name);
314
+ (0, shared_1.print)(`🔌 Disconnected from ${name}.`);
315
+ }
@@ -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,8 +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"),
58
+ // Remote access. Sender: public hostname config, invites + peers (0600). Receiver: remotes (0600).
59
+ HUB_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-hub.json"),
60
+ LINKS_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-links.json"),
61
+ REMOTES_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-remotes.json"),
41
62
  };
42
63
  Constants.validPort = validPort;
64
+ Constants.userPortEnv = userPortEnv;
65
+ Constants.userEnvPort = userEnvPort;
43
66
  Constants.server = {
44
67
  httpPort: envPort("LOCADOT_HTTP_PORT", savedPorts().httpPort, 80),
45
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;