locadot 2.1.0-beta.1 → 2.1.0-beta.3

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
@@ -25,9 +25,23 @@
25
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
26
  Mapped hosts are not affected, and neither are scripts or the CLI that send `X-Locadot-Token`.
27
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
+
28
35
  ### Fixed
29
36
  - The proxy can now tell a user's `LOCADOT_HTTP_PORT`/`LOCADOT_HTTPS_PORT` from the copies the CLI pins on every spawn
30
37
  (`LOCADOT_USER_PORTS`), so saved ports aren't reported as env-overridden.
38
+ - `--cors` through remote access: a shared `--cors` mapping is now `--cors` on the receiver too (shown in `list` and the
39
+ dashboard, kept in step by `remote:sync` and `remote:update`). The sender used to rewrite its apps' origins in pages to its
40
+ *own* local URLs, which don't exist on the receiver. Now the receiver sends the names it uses (`X-Locadot-Names`), so a
41
+ page's API calls go to the receiver's name for that mapping (e.g. `api.alice.localhost` after a clash), on the receiver's
42
+ port. The upstream still sees the calling page's real origin. `<port>.<domain>.localhost` gets the CORS handling of a
43
+ `--cors` mapping on that port. The sender keeps names only for mappings the peer can see, and only as `*.localhost` URLs.
44
+ Both machines need this version for the rewriting; with an older receiver the sender behaves as before.
31
45
 
32
46
  ## 2.1.0-beta.0 (2026-09-27)
33
47
 
package/README.md CHANGED
@@ -111,10 +111,10 @@ 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
+ | `locadot hub:localhost <on\|off>` | Sender: allow or block admin peers from opening this machine's dashboard and any port on its localhost. |
115
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
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`. |
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
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
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
120
  | `locadot remote:alias <name> <remote host> <local host>` | Receiver: give a sender mapping another local name, e.g. `he.localhost`. |
@@ -123,12 +123,20 @@ short-lived pairing string, and the sender's mappings then show up in the receiv
123
123
 
124
124
  Roles: **viewer** can only browse (optionally limited to the mappings picked on the invite). **editor** can also add and change
125
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 →
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 →
128
130
  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.
131
+ of the tokens are stored, and failed attempts are rate limited. Only admins can open the sender's dashboard through the hub.
130
132
  The dashboard has **Remote access** and **Connected machines** cards for all of this.
131
133
 
134
+ `--cors` carries over: a sender's `--cors` mapping is `--cors` on the receiver too, with no extra flag. Preflights and CORS
135
+ headers work as they do locally, and origins in the sender's pages are rewritten to the receiver's own names and port (so
136
+ `http://localhost:8000` in a page becomes `http://api.alice.localhost` if that's the receiver's name for `api.localhost`).
137
+ An admin's `<port>.<domain>.localhost` gets the same treatment when a `--cors` mapping points at that port.
138
+ `remote:sync` picks up a sender turning `--cors` on or off.
139
+
132
140
  ### Certificates
133
141
 
134
142
  | Command | What it does |
@@ -219,10 +219,10 @@ async function peersRevoke(id) {
219
219
  links_1.default.revoke(id);
220
220
  (0, shared_1.print)(`🗑️ Revoked peer ${id}.`);
221
221
  }
222
- /** After connect/sync/remote:domain: the sender's own localhost, reachable when this remote has a domain and we're admin. */
222
+ /** After connect/sync/remote:domain: the sender's dashboard and localhost, reachable when this remote has a domain and we're admin. */
223
223
  const printDomain = (remote) => {
224
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`)})`);
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
226
  }
227
227
  };
228
228
  async function connect(value, options) {
@@ -62,9 +62,10 @@ 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. */
65
+ /** Receiver side: `<domain>.localhost/_locadot/ports`, the port picker and shared hosts of an admin remote. */
66
66
  const remoteLocalhost = (opts) => page(`locadot: ${opts.sender} localhost`, opts.allowed
67
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/>
68
69
  Any port on that machine is at <code>${(0, escape_1.escapeHtml)(opts.portUrl.replace("PORT", "<port>"))}</code>.<br/><br/>
69
70
  <form id="go" style="display:flex;gap:8px">
70
71
  <input id="port" inputmode="numeric" pattern="[0-9]{1,5}" placeholder="3000" required
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.ApiError = void 0;
39
+ exports.isDashboardOrigin = exports.PEER_TOKEN = exports.ApiError = void 0;
40
40
  exports.assertTrusted = assertTrusted;
41
41
  exports.readJson = readJson;
42
42
  exports.route = route;
@@ -59,6 +59,7 @@ const links_1 = __importStar(require("../lib/links"));
59
59
  const hub_config_1 = __importDefault(require("../lib/hub-config"));
60
60
  const remotes_1 = __importStar(require("../lib/remotes"));
61
61
  const ui_auth_1 = __importDefault(require("../lib/ui-auth"));
62
+ const request_1 = require("../proxy/request");
62
63
  const MAX_BODY = 64 * 1024;
63
64
  class ApiError extends Error {
64
65
  constructor(status, message, hint) {
@@ -74,15 +75,25 @@ const safeEqual = (a, b) => {
74
75
  const y = Buffer.from(b);
75
76
  return x.length === y.length && crypto_1.default.timingSafeEqual(x, y);
76
77
  };
77
- const isDashboardOrigin = (origin) => {
78
+ /**
79
+ * What the page carries instead of the API token when an admin peer views it through the hub: the hub already
80
+ * authenticated the peer, and the real token would outlive a revoked peer.
81
+ */
82
+ exports.PEER_TOKEN = "hub-peer";
83
+ /** The page's own origin: the dashboard hosts, or for an admin peer the receiver's `<domain>.localhost`. */
84
+ const isDashboardOrigin = (req, origin) => {
78
85
  try {
79
- const hostname = new URL(origin).hostname.replace(/^\[|\]$/g, "");
80
- return constants_1.default.dashboardHosts.includes(hostname);
86
+ const url = new URL(origin);
87
+ const peerOrigin = (0, request_1.peerOriginOf)(req);
88
+ if (peerOrigin)
89
+ return url.host === peerOrigin;
90
+ return constants_1.default.dashboardHosts.includes(url.hostname.replace(/^\[|\]$/g, ""));
81
91
  }
82
92
  catch {
83
93
  return false;
84
94
  }
85
95
  };
96
+ exports.isDashboardOrigin = isDashboardOrigin;
86
97
  /**
87
98
  * Any web page can make the browser send requests to localhost, so every mutating
88
99
  * call must prove it came from the dashboard page itself (or a local script that
@@ -91,12 +102,12 @@ const isDashboardOrigin = (origin) => {
91
102
  */
92
103
  function assertTrusted(req, ctx) {
93
104
  const origin = req.headers.origin;
94
- if (origin && !isDashboardOrigin(origin))
105
+ if (origin && !(0, exports.isDashboardOrigin)(req, origin))
95
106
  throw new ApiError(403, "Cross-origin requests are not allowed.");
96
107
  if (req.headers["sec-fetch-site"] === "cross-site")
97
108
  throw new ApiError(403, "Cross-site requests are not allowed.");
98
109
  const token = req.headers["x-locadot-token"];
99
- if (typeof token !== "string" || !safeEqual(token, ctx.token)) {
110
+ if (typeof token !== "string" || !safeEqual(token, (0, request_1.peerOriginOf)(req) ? exports.PEER_TOKEN : ctx.token)) {
100
111
  throw new ApiError(403, "Missing or wrong X-Locadot-Token.", `Token file: ${constants_1.default.paths.API_TOKEN}`);
101
112
  }
102
113
  }
@@ -40,6 +40,7 @@ exports.handleDashboardRequest = handleDashboardRequest;
40
40
  const crypto_1 = __importDefault(require("crypto"));
41
41
  const page_1 = require("./page");
42
42
  const api_1 = require("./api");
43
+ const request_1 = require("../proxy/request");
43
44
  const login_1 = require("./login");
44
45
  const ui_auth_1 = __importStar(require("../lib/ui-auth"));
45
46
  const system_1 = require("../lib/system");
@@ -141,7 +142,7 @@ function handleDashboardRequest(req, res, ctx) {
141
142
  }
142
143
  switch (url.pathname) {
143
144
  case "/":
144
- sendBody(res, method, 200, "text/html; charset=utf-8", (0, page_1.renderPage)(nonce, ctx.token, ui_auth_1.default.enabled()));
145
+ sendBody(res, method, 200, "text/html; charset=utf-8", (0, page_1.renderPage)(nonce, (0, request_1.peerOriginOf)(req) ? api_1.PEER_TOKEN : ctx.token, ui_auth_1.default.enabled()));
145
146
  return;
146
147
  case "/favicon.ico":
147
148
  res.statusCode = 204;
@@ -39,9 +39,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.sessionCookie = exports.signedIn = void 0;
40
40
  exports.gate = gate;
41
41
  const crypto_1 = __importDefault(require("crypto"));
42
- const constants_1 = __importDefault(require("../constants"));
43
42
  const logger_1 = __importDefault(require("../utils/logger"));
44
43
  const escape_1 = require("./escape");
44
+ const api_1 = require("./api");
45
45
  const ui_auth_1 = __importStar(require("../lib/ui-auth"));
46
46
  const MAX_LOGIN_BODY = 4096;
47
47
  const loginPage = (nonce, error) => `<!doctype html>
@@ -100,12 +100,7 @@ const sameOrigin = (req) => {
100
100
  const origin = req.headers.origin;
101
101
  if (!origin || origin === "null")
102
102
  return !origin;
103
- try {
104
- return constants_1.default.dashboardHosts.includes(new URL(origin).hostname.replace(/^\[|\]$/g, ""));
105
- }
106
- catch {
107
- return false;
108
- }
103
+ return (0, api_1.isDashboardOrigin)(req, origin);
109
104
  };
110
105
  const cookie = (req, value, maxAge) => `${ui_auth_1.SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${isTls(req) ? "; Secure" : ""}`;
111
106
  const sendLogin = (res, method, nonce, status, error) => {
@@ -1782,7 +1782,7 @@ const clientJs = `
1782
1782
  apiFetch("/api/remotes", { method: "POST", body: JSON.stringify({ string: value, name: name || undefined, domain: domain || undefined }) })
1783
1783
  .then(function (data) {
1784
1784
  var r = data.remote;
1785
- showToast("Connected to " + r.name, "success", r.domain && r.role === "admin" ? "Its localhost: " + localUrl(r.domain) : (domain && r.role !== "admin" ? "Domain ignored: you're " + r.role + ", not admin." : undefined));
1785
+ showToast("Connected to " + r.name, "success", r.domain && r.role === "admin" ? "Its dashboard: " + localUrl(r.domain) : (domain && r.role !== "admin" ? "Domain ignored: you're " + r.role + ", not admin." : undefined));
1786
1786
  connectForm.reset();
1787
1787
  return Promise.all([loadRemotes(), loadHosts()]);
1788
1788
  })
@@ -1854,7 +1854,7 @@ const clientJs = `
1854
1854
  link.target = "_blank";
1855
1855
  link.rel = "noopener noreferrer";
1856
1856
  link.textContent = remote.domain + ".localhost";
1857
- link.title = "Any port: " + localUrl(remote.domain, "<port>");
1857
+ link.title = "Its dashboard. Any port: " + localUrl(remote.domain, "<port>");
1858
1858
  row.appendChild(link);
1859
1859
  row.appendChild(makeCopyButton(function () { return localUrl(remote.domain); }));
1860
1860
  var change = document.createElement("button");
@@ -1880,7 +1880,7 @@ const clientJs = `
1880
1880
  apiFetch("/api/hub/localhost", { method: "PUT", body: JSON.stringify({ enabled: next }) })
1881
1881
  .then(function () {
1882
1882
  setSwitch(hubLocalhostSwitch, next);
1883
- showToast(next ? "Admins can open any port on this machine" : "Localhost access is off", "success");
1883
+ showToast(next ? "Admins can open this dashboard and any port" : "Localhost access is off", "success");
1884
1884
  })
1885
1885
  .catch(function (err) { apiError(err, "Couldn't change localhost access"); })
1886
1886
  .then(function () { hubLocalhostSwitch.disabled = false; });
@@ -3309,10 +3309,10 @@ function renderPage(nonce, token, uiAuth = false) {
3309
3309
  </div>
3310
3310
  <div id="hub-localhost-row" class="toggle-row" hidden>
3311
3311
  <div>
3312
- <div class="label">Admins can open any port</div>
3313
- <div class="tile-sub">Admin peers reach this machine's localhost at <span class="mono">&lt;port&gt;.&lt;domain&gt;.localhost</span> on their side.</div>
3312
+ <div class="label">Admins can open this dashboard and any port</div>
3313
+ <div class="tile-sub">Admin peers get this dashboard at <span class="mono">&lt;domain&gt;.localhost</span> and this machine's localhost at <span class="mono">&lt;port&gt;.&lt;domain&gt;.localhost</span> on their side.</div>
3314
3314
  </div>
3315
- <button type="button" id="hub-localhost" class="switch" role="switch" aria-checked="true" aria-label="Let admins open any port on this machine"><span class="switch-knob"></span></button>
3315
+ <button type="button" id="hub-localhost" class="switch" role="switch" aria-checked="true" aria-label="Let admins open this dashboard and any port on this machine"><span class="switch-knob"></span></button>
3316
3316
  </div>
3317
3317
  </div>
3318
3318
 
@@ -3361,7 +3361,7 @@ function renderPage(nonce, token, uiAuth = false) {
3361
3361
  <div class="role-table">
3362
3362
  <div><strong>Viewer</strong><span>browse the hosts you pick</span></div>
3363
3363
  <div><strong>Editor</strong><span>also add and change hosts here</span></div>
3364
- <div><strong>Admin</strong><span>also delete hosts, change sharing, and open any port on this machine</span></div>
3364
+ <div><strong>Admin</strong><span>also delete hosts, change sharing, and open this dashboard and any port on this machine</span></div>
3365
3365
  </div>
3366
3366
  <p class="dim">Editors can reach anything this machine can. Change or revoke a role any time under Peers.</p>
3367
3367
  </div>
@@ -91,6 +91,8 @@ const isDomainLabel = (value) => {
91
91
  exports.isDomainLabel = isDomainLabel;
92
92
  /** Sender allows the peer onto its localhost, treating an older sender (no `localhost` field) as "yes" when admin. */
93
93
  const localhostAllowed = (data, role) => typeof data.localhost === "boolean" ? data.localhost : role === "admin";
94
+ /** A shared --cors mapping is --cors here too; the sender still does the CORS work (see router forwardRemote). */
95
+ const inherited = (h) => (h.cors ? { cors: true } : {});
94
96
  /** Domains already spoken for: other remotes' `domain`, and any single-label `*.localhost` registry host. */
95
97
  const takenDomains = (excludeName) => {
96
98
  const store = readFile();
@@ -212,7 +214,7 @@ class Remotes {
212
214
  continue;
213
215
  }
214
216
  taken.add(local);
215
- registry.hosts[local] = { target: remote.url, remote: { name, host: h.host }, createdAt: now, updatedAt: now };
217
+ registry.hosts[local] = { target: remote.url, remote: { name, host: h.host }, ...inherited(h), createdAt: now, updatedAt: now };
216
218
  mapped.push({ local, host: h.host });
217
219
  }
218
220
  });
@@ -244,9 +246,14 @@ class Remotes {
244
246
  const now = new Date().toISOString();
245
247
  await registry_1.default.mutate((registry) => {
246
248
  const already = new Set();
247
- for (const entry of Object.values(registry.hosts)) {
248
- if (entry.remote?.name === name)
249
- already.add(entry.remote.host);
249
+ const flags = new Map(hosts.map((h) => [h.host, h]));
250
+ for (const [local, entry] of Object.entries(registry.hosts)) {
251
+ if (entry.remote?.name !== name)
252
+ continue;
253
+ already.add(entry.remote.host);
254
+ const h = flags.get(entry.remote.host);
255
+ if (h && Boolean(h.cors) !== Boolean(entry.cors))
256
+ registry.hosts[local] = { ...entry, cors: h.cors || undefined };
250
257
  }
251
258
  const taken = new Set(Object.keys(registry.hosts));
252
259
  for (const h of hosts) {
@@ -256,7 +263,7 @@ class Remotes {
256
263
  if (!local)
257
264
  continue;
258
265
  taken.add(local);
259
- registry.hosts[local] = { target: updated.url, remote: { name, host: h.host }, createdAt: now, updatedAt: now };
266
+ registry.hosts[local] = { target: updated.url, remote: { name, host: h.host }, ...inherited(h), createdAt: now, updatedAt: now };
260
267
  }
261
268
  });
262
269
  const registry = registry_1.default.read();
@@ -270,13 +277,14 @@ class Remotes {
270
277
  const local = localhost_1.default.requireHost(localHost);
271
278
  const hostsRes = await call(`${apiBase(remote.url)}/hosts`, { token: remote.token });
272
279
  const hosts = hostsRes.hosts;
273
- if (!hosts.some((h) => h.host === remoteHost))
280
+ const shared = hosts.find((h) => h.host === remoteHost);
281
+ if (!shared)
274
282
  throw new hosts_1.NotFoundError(`"${remoteHost}" is not available on "${name}".`);
275
283
  const now = new Date().toISOString();
276
284
  await registry_1.default.mutate((registry) => {
277
285
  if (registry.hosts[local])
278
286
  throw new hosts_1.ConflictError(`❌ ${local} is already mapped to ${registry.hosts[local].target}.`);
279
- registry.hosts[local] = { target: remote.url, remote: { name, host: remoteHost }, createdAt: now, updatedAt: now };
287
+ registry.hosts[local] = { target: remote.url, remote: { name, host: remoteHost }, ...inherited(shared), createdAt: now, updatedAt: now };
280
288
  });
281
289
  }
282
290
  static setUrl(name, url) {
@@ -329,7 +337,7 @@ class Remotes {
329
337
  const taken = new Set(Object.keys(registry.hosts));
330
338
  const local = Remotes.localName(name, created.host, taken);
331
339
  if (local)
332
- registry.hosts[local] = { target: remote.url, remote: { name, host: created.host }, createdAt: now, updatedAt: now };
340
+ registry.hosts[local] = { target: remote.url, remote: { name, host: created.host }, ...inherited(created), createdAt: now, updatedAt: now };
333
341
  });
334
342
  return created;
335
343
  }
@@ -340,7 +348,14 @@ class Remotes {
340
348
  token: remote.token,
341
349
  body: input,
342
350
  });
343
- return data.host;
351
+ const updated = data.host;
352
+ await registry_1.default.mutate((registry) => {
353
+ for (const [local, entry] of Object.entries(registry.hosts)) {
354
+ if (entry.remote?.name === name && entry.remote.host === host)
355
+ registry.hosts[local] = { ...entry, cors: updated.cors || undefined };
356
+ }
357
+ });
358
+ return updated;
344
359
  }
345
360
  static async removeHost(name, host) {
346
361
  const remote = Remotes.mustGet(name);
@@ -24,12 +24,16 @@ exports.preflightHeaders = preflightHeaders;
24
24
  /**
25
25
  * --cors: the upstream should see a request from a site it trusts, so origin/CSRF checks pass.
26
26
  * A page on another mapped domain (signalsant.localhost calling api.signalsant.localhost) is
27
- * sent as that domain's real origin; anything else as the target's own origin.
27
+ * sent as that domain's real origin; anything else as the target's own origin. A peer's page is on the
28
+ * receiver's name for one of our domains, so it is matched by those names, never by our own.
28
29
  */
29
30
  const sameOriginHeaders = (req, entry, lookup) => {
31
+ const names = (0, request_1.peerNamesOf)(req);
32
+ const callerHost = (url) => names ? Object.keys(names).find((host) => new URL(names[host]).host === url.host) : url.hostname;
30
33
  const upstreamOrigin = (value) => {
31
34
  try {
32
- const caller = lookup(new URL(value).hostname);
35
+ const host = callerHost(new URL(value));
36
+ const caller = host ? lookup(host) : undefined;
33
37
  if (caller)
34
38
  return new URL(caller.target).origin;
35
39
  }
package/dist/proxy/hub.js CHANGED
@@ -45,7 +45,7 @@ const logger_1 = __importDefault(require("../utils/logger"));
45
45
  const links_1 = __importStar(require("../lib/links"));
46
46
  const hosts_1 = __importStar(require("../lib/hosts"));
47
47
  const hub_config_1 = __importDefault(require("../lib/hub-config"));
48
- const localhost_1 = require("../lib/localhost");
48
+ const localhost_1 = __importStar(require("../lib/localhost"));
49
49
  const registry_1 = require("../lib/registry");
50
50
  const request_1 = require("./request");
51
51
  // eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -80,6 +80,51 @@ const clientIp = (req) => {
80
80
  const value = Array.isArray(header) ? header[0] : header;
81
81
  return value || req.socket.remoteAddress || "unknown";
82
82
  };
83
+ /** `X-Original-Host` from the receiver, only if it is a `*.localhost` name with an optional port. */
84
+ const receiverHost = (req) => {
85
+ const header = req.headers["x-original-host"];
86
+ const raw = typeof header === "string" ? header.trim().toLowerCase() : "";
87
+ const match = /^([^:]+)(?::(\d{1,5}))?$/.exec(raw);
88
+ return match && localhost_1.default.isValidLocalhostDomain(match[1]) ? raw : undefined;
89
+ };
90
+ const MAX_NAMES = 16 * 1024;
91
+ /**
92
+ * `X-Locadot-Names` from the receiver: `<our host>=<its URL for it>,…`. Kept only for hosts this peer can see and
93
+ * `http(s)://*.localhost[:port]` URLs; they only ever end up in responses to that same peer.
94
+ */
95
+ const peerNames = (req, visible) => {
96
+ const header = req.headers["x-locadot-names"];
97
+ if (typeof header !== "string" || header.length > MAX_NAMES)
98
+ return undefined;
99
+ const names = {};
100
+ for (const pair of header.split(",")) {
101
+ const [host, url] = pair.trim().toLowerCase().split("=");
102
+ const match = url ? /^https?:\/\/([^/:]+)(?::(\d{1,5}))?$/.exec(url) : null;
103
+ if (host && match && visible.includes(host) && localhost_1.default.isValidLocalhostDomain(match[1]))
104
+ names[host] = url;
105
+ }
106
+ return names;
107
+ };
108
+ /** Sender side: a --cors mapping whose target is this machine's `port`, so port access gets its cors too. */
109
+ const corsMappingFor = (hosts, port) => Object.keys(hosts).find((host) => {
110
+ const entry = hosts[host];
111
+ if (!entry.cors || entry.remote)
112
+ return false;
113
+ try {
114
+ const url = new URL(entry.target);
115
+ const own = Number(url.port || (url.protocol === "https:" ? 443 : 80));
116
+ return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) && own === port;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ });
122
+ const ownHosts = (hosts, peer) => links_1.default.visibleHosts(peer, Object.keys(hosts)).filter((host) => !hosts[host].remote);
123
+ const stripLocadotHeaders = (req) => {
124
+ for (const name of Object.keys(req.headers))
125
+ if (name.toLowerCase().startsWith("x-locadot-"))
126
+ delete req.headers[name];
127
+ };
83
128
  const bearerToken = (req) => {
84
129
  const header = req.headers.authorization;
85
130
  const match = typeof header === "string" ? /^Bearer\s+(\S+)$/i.exec(header) : null;
@@ -103,11 +148,22 @@ function classify(req, publicHost, hosts, opts) {
103
148
  return { kind: "deny", status: 401, message: "Unauthorized" };
104
149
  }
105
150
  const portHeader = req.headers["x-locadot-port"];
106
- if (portHeader !== undefined) {
151
+ const wantsDashboard = req.headers["x-locadot-dashboard"] !== undefined;
152
+ if (portHeader !== undefined || wantsDashboard) {
107
153
  if (!links_1.default.can(peer, "localhost"))
108
154
  return { kind: "deny", status: 403, message: "Forbidden" };
109
155
  if (opts?.localhost === false)
110
156
  return { kind: "deny", status: 403, message: "Localhost access is off on this machine." };
157
+ // The dashboard's page and API trust this origin in place of their own (see peerOriginOf).
158
+ if (wantsDashboard) {
159
+ const origin = receiverHost(req);
160
+ if (!origin)
161
+ return { kind: "deny", status: 400, message: "Bad request" };
162
+ // Keep X-Locadot-Token: the dashboard page sends it on every mutating call.
163
+ for (const name of ["x-locadot-peer", "x-locadot-dashboard", "x-locadot-host", "x-locadot-port", "x-locadot-names"])
164
+ delete req.headers[name];
165
+ return { kind: "dashboard", peer, origin };
166
+ }
111
167
  const raw = typeof portHeader === "string" ? portHeader.trim() : "";
112
168
  if (!/^[0-9]+$/.test(raw))
113
169
  return { kind: "deny", status: 400, message: "Bad request" };
@@ -116,10 +172,15 @@ function classify(req, publicHost, hosts, opts) {
116
172
  return { kind: "deny", status: 400, message: "Bad request" };
117
173
  if (opts?.blockedPorts?.includes(port))
118
174
  return { kind: "deny", status: 403, message: "Forbidden" };
119
- for (const name of Object.keys(req.headers))
120
- if (name.toLowerCase().startsWith("x-locadot-"))
121
- delete req.headers[name];
122
- return { kind: "local", port, peer };
175
+ const names = req.headers["x-locadot-names"] === undefined ? undefined : peerNames(req, ownHosts(hosts, peer));
176
+ const host = corsMappingFor(hosts, port);
177
+ // The page stays on <port>.<domain>.localhost rather than moving to the receiver's name for the mapping.
178
+ const self = receiverHost(req);
179
+ const scheme = names && Object.values(names)[0]?.split(":")[0];
180
+ if (host && names && self && scheme)
181
+ names[host] = `${scheme}://${self}`;
182
+ stripLocadotHeaders(req);
183
+ return { kind: "local", port, peer, names, host };
123
184
  }
124
185
  const hostHeader = req.headers["x-locadot-host"];
125
186
  const host = typeof hostHeader === "string" ? hostHeader.trim().toLowerCase() : "";
@@ -131,10 +192,9 @@ function classify(req, publicHost, hosts, opts) {
131
192
  if (!visible.includes(host)) {
132
193
  return { kind: "deny", status: 403, message: "Forbidden" };
133
194
  }
134
- for (const name of Object.keys(req.headers))
135
- if (name.toLowerCase().startsWith("x-locadot-"))
136
- delete req.headers[name];
137
- return { kind: "app", host, peer };
195
+ const names = req.headers["x-locadot-names"] === undefined ? undefined : peerNames(req, visible);
196
+ stripLocadotHeaders(req);
197
+ return { kind: "app", host, peer, names };
138
198
  }
139
199
  /* ---------- /_locadot/v1 API ---------- */
140
200
  const send = (res, status, body) => {
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.localOptions = exports.canReachLocalhost = exports.localFor = exports.remoteOptions = exports.remoteFor = void 0;
6
+ exports.localOptions = exports.canReachLocalhost = exports.localFor = exports.LANDING_PATH = exports.remoteOptions = exports.remoteFor = void 0;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const constants_1 = __importDefault(require("../constants"));
9
9
  const request_1 = require("./request");
@@ -32,7 +32,7 @@ const remotes = () => {
32
32
  };
33
33
  const remoteFor = (name) => remotes()[name];
34
34
  exports.remoteFor = remoteFor;
35
- const remoteOptions = (req, entry, remote) => ({
35
+ const remoteOptions = (req, entry, remote, names) => ({
36
36
  target: remote.url,
37
37
  changeOrigin: true,
38
38
  // Don't leak the receiver's LAN addresses to the sender.
@@ -47,9 +47,12 @@ const remoteOptions = (req, entry, remote) => ({
47
47
  "X-Original-Host": req.headers.host || "",
48
48
  "X-Locadot-Peer": remote.token,
49
49
  "X-Locadot-Host": entry.remote.host,
50
+ ...(names ? { "X-Locadot-Names": names } : {}),
50
51
  },
51
52
  });
52
53
  exports.remoteOptions = remoteOptions;
54
+ /** Receiver side: the port picker and shared-host list, on `<domain>.localhost` itself (the rest of it is the dashboard). */
55
+ exports.LANDING_PATH = "/_locadot/ports";
53
56
  /** Receiver side: `<domain>.localhost` / `<port>.<domain>.localhost` → the admin remote that owns `domain`. */
54
57
  const localFor = (host) => {
55
58
  if (!host.endsWith(".localhost"))
@@ -72,7 +75,8 @@ const localFor = (host) => {
72
75
  exports.localFor = localFor;
73
76
  const canReachLocalhost = (remote) => remote.role === "admin" && remote.localhost !== false;
74
77
  exports.canReachLocalhost = canReachLocalhost;
75
- const localOptions = (req, remote, port) => ({
78
+ /** Without a port the sender serves its dashboard. */
79
+ const localOptions = (req, remote, port, names) => ({
76
80
  target: remote.url,
77
81
  changeOrigin: true,
78
82
  xfwd: false,
@@ -85,7 +89,7 @@ const localOptions = (req, remote, port) => ({
85
89
  headers: {
86
90
  "X-Original-Host": req.headers.host || "",
87
91
  "X-Locadot-Peer": remote.token,
88
- "X-Locadot-Port": String(port),
92
+ ...(port ? { "X-Locadot-Port": String(port), ...(names ? { "X-Locadot-Names": names } : {}) } : { "X-Locadot-Dashboard": "1" }),
89
93
  },
90
94
  });
91
95
  exports.localOptions = localOptions;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isTls = exports.viaOf = exports.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
3
+ exports.isTls = exports.viaOf = exports.peerNamesOf = exports.peerOriginOf = exports.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
4
4
  const tags = new WeakMap();
5
5
  const tag = (req, values) => {
6
6
  tags.set(req, { ...tags.get(req), ...values });
@@ -21,6 +21,10 @@ const fromTunnel = (req) => Boolean(tags.get(req)?.tunnel);
21
21
  exports.fromTunnel = fromTunnel;
22
22
  const fromRemote = (req) => Boolean(tags.get(req)?.remote);
23
23
  exports.fromRemote = fromRemote;
24
+ const peerOriginOf = (req) => tags.get(req)?.peerOrigin;
25
+ exports.peerOriginOf = peerOriginOf;
26
+ const peerNamesOf = (req) => tags.get(req)?.peerNames;
27
+ exports.peerNamesOf = peerNamesOf;
24
28
  const viaOf = (req) => tags.get(req)?.via;
25
29
  exports.viaOf = viaOf;
26
30
  /** Tunnel visitors are on https even though cloudflared talks plain http to us. */
@@ -23,7 +23,8 @@ const handleProxyResponse = (proxyRes, req, res, hosts, publicUrl = () => undefi
23
23
  return;
24
24
  stripHopByHop(proxyRes.headers);
25
25
  const entry = hosts[(0, request_1.mappedHost)(req)];
26
- if (!entry?.cors)
26
+ // A remote mapping's cors flag is inherited: the sender already applied it.
27
+ if (!entry?.cors || entry.remote)
27
28
  return;
28
29
  (0, cors_1.applyCors)(req, proxyRes.headers);
29
30
  const via = (0, request_1.viaOf)(req);
@@ -25,9 +25,11 @@ const originMap = (req, hosts, publicUrl = () => undefined) => {
25
25
  const tls = (0, request_1.isTls)(req);
26
26
  const self = (0, request_1.mappedHost)(req);
27
27
  const tunnel = (0, request_1.fromTunnel)(req);
28
+ // A peer's browser knows our hosts by its own names; older receivers don't send them.
29
+ const peer = (0, request_1.peerNamesOf)(req);
28
30
  const pairs = [];
29
31
  for (const [host, entry] of Object.entries(hosts)) {
30
- const local = tunnel ? publicUrl(host) : (0, urls_1.urlFor)(host, tls);
32
+ const local = tunnel ? publicUrl(host) : peer ? peer[host] : (0, urls_1.urlFor)(host, tls);
31
33
  if (!local)
32
34
  continue;
33
35
  try {
@@ -20,12 +20,17 @@ const stats_1 = require("./stats");
20
20
  /** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
21
21
  const resolveHost = (req, ctx, hub) => {
22
22
  if (hub?.kind === "app") {
23
- (0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure() });
23
+ (0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names });
24
24
  return hub.host;
25
25
  }
26
+ if (hub?.kind === "dashboard") {
27
+ (0, request_1.tag)(req, { host: "localhost", remote: true, secure: ctx.hub.secure(), peerOrigin: hub.origin });
28
+ return "localhost";
29
+ }
26
30
  if (hub?.kind === "local") {
27
- const host = `localhost:${hub.port}`;
28
- (0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure() });
31
+ // A port behind a --cors mapping is served as that mapping, cors included.
32
+ const host = hub.host ?? `localhost:${hub.port}`;
33
+ (0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names });
29
34
  return host;
30
35
  }
31
36
  const host = (0, request_1.hostOf)(req);
@@ -38,6 +43,16 @@ const dashboardUrl = (req) => `${(0, urls_1.urlFor)("localhost", (0, request_1.i
38
43
  const passThrough = (req, entry) => Boolean(entry.cors);
39
44
  /** Same-origin only, and a tunnel visitor may only reach public hosts (see guard.ts). */
40
45
  const viaAllowed = (req, via) => (0, passthrough_1.isSameOrigin)(req) && (!(0, request_1.fromTunnel)(req) || (0, guard_1.isPublicHost)(via.host));
46
+ /**
47
+ * Receiver side: `<sender host>=<our URL for it>` for every mapping of this remote, so a --cors sender rewrites
48
+ * its apps' origins to the names this machine uses rather than its own.
49
+ */
50
+ const peerNames = (req, ctx, name) => (ctx.remoteHosts?.(name) || [])
51
+ .flatMap((local) => {
52
+ const host = ctx.lookup(local)?.remote?.host;
53
+ return host ? [`${host}=${(0, urls_1.urlFor)(local, (0, request_1.isTls)(req))}`] : [];
54
+ })
55
+ .join(",");
41
56
  const reason = (err) => err?.code || err?.message;
42
57
  const remoteGone = (entry) => `the connection to ${entry.remote.name} was removed`;
43
58
  /** Sender side: an admin peer reaching one of this machine's ports. */
@@ -80,7 +95,7 @@ function handleRequest(req, res, ctx) {
80
95
  ctx.dashboard(req, res);
81
96
  return;
82
97
  }
83
- const entry = hub?.kind === "local" ? localEntry(hub.port) : ctx.lookup(host);
98
+ const entry = hub?.kind === "local" && !hub.host ? localEntry(hub.port) : ctx.lookup(host);
84
99
  if (!entry) {
85
100
  const local = localTarget(req, ctx, host, hub);
86
101
  if (local) {
@@ -151,7 +166,7 @@ function forwardRemote(req, res, ctx, host, entry) {
151
166
  }
152
167
  const started = Date.now();
153
168
  res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
154
- ctx.proxy.web(req, res, (0, remote_1.remoteOptions)(req, entry, remote), (err) => {
169
+ ctx.proxy.web(req, res, (0, remote_1.remoteOptions)(req, entry, remote, peerNames(req, ctx, remote.name)), (err) => {
155
170
  logger_1.default.warn(`${host} → ${remote.name} (${remote.url}): ${reason(err)}`);
156
171
  if (res.headersSent) {
157
172
  res.destroy();
@@ -161,25 +176,30 @@ function forwardRemote(req, res, ctx, host, entry) {
161
176
  res.end((0, template_1.upstreamDown)(host, `${remote.name}: ${entry.remote.host}`, reason(err) || "error", dashboardUrl(req)));
162
177
  });
163
178
  }
164
- /** Receiver side: the sender checks the role again and proxies to its own localhost:<port>. */
179
+ /**
180
+ * Receiver side: `<domain>.localhost` is the sender's dashboard and `<port>.<domain>.localhost` its localhost:<port>.
181
+ * The sender checks the role again on every request.
182
+ */
165
183
  function forwardLocal(req, res, ctx, host, local) {
166
184
  const { remote, domain, port } = local;
167
185
  const allowed = (0, remote_1.canReachLocalhost)(remote);
168
- if (!port || !allowed) {
186
+ const landing = !port && (req.url || "/").split("?")[0] === remote_1.LANDING_PATH;
187
+ if (!allowed || landing) {
169
188
  const hosts = (ctx.remoteHosts?.(remote.name) || []).map((h) => ({ local: h, url: `${(0, urls_1.urlFor)(h, (0, request_1.isTls)(req))}/` }));
170
189
  const body = (0, template_1.remoteLocalhost)({ domain, name: remote.name, sender: remote.sender.hostname, allowed, hosts, portUrl: portUrl(req, domain), dashboardUrl: dashboardUrl(req) });
171
- html(res, port ? 403 : 200, body);
190
+ html(res, port && !allowed ? 403 : 200, body);
172
191
  return;
173
192
  }
193
+ const label = `${remote.name}: ${port ? `localhost:${port}` : "dashboard"}`;
174
194
  const started = Date.now();
175
195
  res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
176
- ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port), (err) => {
177
- logger_1.default.warn(`${host} → ${remote.name} localhost:${port}: ${reason(err)}`);
196
+ ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port, port ? peerNames(req, ctx, remote.name) : undefined), (err) => {
197
+ logger_1.default.warn(`${host} → ${label}: ${reason(err)}`);
178
198
  if (res.headersSent) {
179
199
  res.destroy();
180
200
  return;
181
201
  }
182
- html(res, 502, (0, template_1.upstreamDown)(host, `${remote.name}: localhost:${port}`, reason(err) || "error", dashboardUrl(req)));
202
+ html(res, 502, (0, template_1.upstreamDown)(host, label, reason(err) || "error", dashboardUrl(req)));
183
203
  });
184
204
  }
185
205
  function handleUpgrade(req, socket, head, ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "2.1.0-beta.1",
3
+ "version": "2.1.0-beta.3",
4
4
  "description": "Secure your local development environment with HTTPS and custom domains like dev.localhost.",
5
5
  "homepage": "https://www.npmjs.com/package/locadot",
6
6
  "main": "dist/index.js",