locadot 2.1.0-beta.1 → 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
@@ -25,6 +25,13 @@
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.
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,10 +123,12 @@ 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
 
132
134
  ### Certificates
@@ -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>
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,18 @@ 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 stripLocadotHeaders = (req) => {
91
+ for (const name of Object.keys(req.headers))
92
+ if (name.toLowerCase().startsWith("x-locadot-"))
93
+ delete req.headers[name];
94
+ };
83
95
  const bearerToken = (req) => {
84
96
  const header = req.headers.authorization;
85
97
  const match = typeof header === "string" ? /^Bearer\s+(\S+)$/i.exec(header) : null;
@@ -103,11 +115,22 @@ function classify(req, publicHost, hosts, opts) {
103
115
  return { kind: "deny", status: 401, message: "Unauthorized" };
104
116
  }
105
117
  const portHeader = req.headers["x-locadot-port"];
106
- if (portHeader !== undefined) {
118
+ const wantsDashboard = req.headers["x-locadot-dashboard"] !== undefined;
119
+ if (portHeader !== undefined || wantsDashboard) {
107
120
  if (!links_1.default.can(peer, "localhost"))
108
121
  return { kind: "deny", status: 403, message: "Forbidden" };
109
122
  if (opts?.localhost === false)
110
123
  return { kind: "deny", status: 403, message: "Localhost access is off on this machine." };
124
+ // The dashboard's page and API trust this origin in place of their own (see peerOriginOf).
125
+ if (wantsDashboard) {
126
+ const origin = receiverHost(req);
127
+ if (!origin)
128
+ return { kind: "deny", status: 400, message: "Bad request" };
129
+ // Keep X-Locadot-Token: the dashboard page sends it on every mutating call.
130
+ for (const name of ["x-locadot-peer", "x-locadot-dashboard", "x-locadot-host", "x-locadot-port"])
131
+ delete req.headers[name];
132
+ return { kind: "dashboard", peer, origin };
133
+ }
111
134
  const raw = typeof portHeader === "string" ? portHeader.trim() : "";
112
135
  if (!/^[0-9]+$/.test(raw))
113
136
  return { kind: "deny", status: 400, message: "Bad request" };
@@ -116,9 +139,7 @@ function classify(req, publicHost, hosts, opts) {
116
139
  return { kind: "deny", status: 400, message: "Bad request" };
117
140
  if (opts?.blockedPorts?.includes(port))
118
141
  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];
142
+ stripLocadotHeaders(req);
122
143
  return { kind: "local", port, peer };
123
144
  }
124
145
  const hostHeader = req.headers["x-locadot-host"];
@@ -131,9 +152,7 @@ function classify(req, publicHost, hosts, opts) {
131
152
  if (!visible.includes(host)) {
132
153
  return { kind: "deny", status: 403, message: "Forbidden" };
133
154
  }
134
- for (const name of Object.keys(req.headers))
135
- if (name.toLowerCase().startsWith("x-locadot-"))
136
- delete req.headers[name];
155
+ stripLocadotHeaders(req);
137
156
  return { kind: "app", host, peer };
138
157
  }
139
158
  /* ---------- /_locadot/v1 API ---------- */
@@ -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");
@@ -50,6 +50,8 @@ const remoteOptions = (req, entry, remote) => ({
50
50
  },
51
51
  });
52
52
  exports.remoteOptions = remoteOptions;
53
+ /** Receiver side: the port picker and shared-host list, on `<domain>.localhost` itself (the rest of it is the dashboard). */
54
+ exports.LANDING_PATH = "/_locadot/ports";
53
55
  /** Receiver side: `<domain>.localhost` / `<port>.<domain>.localhost` → the admin remote that owns `domain`. */
54
56
  const localFor = (host) => {
55
57
  if (!host.endsWith(".localhost"))
@@ -72,6 +74,7 @@ const localFor = (host) => {
72
74
  exports.localFor = localFor;
73
75
  const canReachLocalhost = (remote) => remote.role === "admin" && remote.localhost !== false;
74
76
  exports.canReachLocalhost = canReachLocalhost;
77
+ /** Without a port the sender serves its dashboard. */
75
78
  const localOptions = (req, remote, port) => ({
76
79
  target: remote.url,
77
80
  changeOrigin: true,
@@ -85,7 +88,7 @@ const localOptions = (req, remote, port) => ({
85
88
  headers: {
86
89
  "X-Original-Host": req.headers.host || "",
87
90
  "X-Locadot-Peer": remote.token,
88
- "X-Locadot-Port": String(port),
91
+ ...(port ? { "X-Locadot-Port": String(port) } : { "X-Locadot-Dashboard": "1" }),
89
92
  },
90
93
  });
91
94
  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.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,8 @@ 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;
24
26
  const viaOf = (req) => tags.get(req)?.via;
25
27
  exports.viaOf = viaOf;
26
28
  /** Tunnel visitors are on https even though cloudflared talks plain http to us. */
@@ -23,6 +23,10 @@ const resolveHost = (req, ctx, hub) => {
23
23
  (0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure() });
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
31
  const host = `localhost:${hub.port}`;
28
32
  (0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure() });
@@ -161,25 +165,30 @@ function forwardRemote(req, res, ctx, host, entry) {
161
165
  res.end((0, template_1.upstreamDown)(host, `${remote.name}: ${entry.remote.host}`, reason(err) || "error", dashboardUrl(req)));
162
166
  });
163
167
  }
164
- /** Receiver side: the sender checks the role again and proxies to its own localhost:<port>. */
168
+ /**
169
+ * Receiver side: `<domain>.localhost` is the sender's dashboard and `<port>.<domain>.localhost` its localhost:<port>.
170
+ * The sender checks the role again on every request.
171
+ */
165
172
  function forwardLocal(req, res, ctx, host, local) {
166
173
  const { remote, domain, port } = local;
167
174
  const allowed = (0, remote_1.canReachLocalhost)(remote);
168
- if (!port || !allowed) {
175
+ const landing = !port && (req.url || "/").split("?")[0] === remote_1.LANDING_PATH;
176
+ if (!allowed || landing) {
169
177
  const hosts = (ctx.remoteHosts?.(remote.name) || []).map((h) => ({ local: h, url: `${(0, urls_1.urlFor)(h, (0, request_1.isTls)(req))}/` }));
170
178
  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);
179
+ html(res, port && !allowed ? 403 : 200, body);
172
180
  return;
173
181
  }
182
+ const label = `${remote.name}: ${port ? `localhost:${port}` : "dashboard"}`;
174
183
  const started = Date.now();
175
184
  res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
176
185
  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)}`);
186
+ logger_1.default.warn(`${host} → ${label}: ${reason(err)}`);
178
187
  if (res.headersSent) {
179
188
  res.destroy();
180
189
  return;
181
190
  }
182
- html(res, 502, (0, template_1.upstreamDown)(host, `${remote.name}: localhost:${port}`, reason(err) || "error", dashboardUrl(req)));
191
+ html(res, 502, (0, template_1.upstreamDown)(host, label, reason(err) || "error", dashboardUrl(req)));
183
192
  });
184
193
  }
185
194
  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.2",
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",