locadot 2.1.0 → 2.3.0

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/dist/index.js CHANGED
@@ -44,8 +44,17 @@ program
44
44
  .description("HTTPS custom *.localhost domains for any local or remote upstream")
45
45
  .version(package_json_1.version)
46
46
  .helpOption("--help", "Display help for command");
47
- destinationOptions(program.command("add").description("Map a new domain to a port or URL").addOption(hostOption())).action(run((options) => commands_1.default.add(options)));
47
+ destinationOptions(program.command("add").description("Map a new domain to a port or URL").addOption(hostOption()))
48
+ .option("--allow <addresses>", "Internal addresses shared visitors may reach through --cors, e.g. localhost:3000,localhost:4000")
49
+ .action(run((options) => commands_1.default.add(options)));
48
50
  destinationOptions(program.command("update").description("Change the destination of a mapped domain").addOption(hostOption())).action(run((options) => commands_1.default.update(options)));
51
+ program
52
+ .command("allow [addresses...]")
53
+ .description("Let tunnel visitors and connected machines reach internal addresses (localhost:3000) through a --cors domain")
54
+ .addOption(hostOption())
55
+ .option("--rm", "Remove the given addresses instead of adding them")
56
+ .option("--clear", "Remove every allowed address")
57
+ .action(run((addresses, options) => commands_1.default.allow(addresses, options)));
49
58
  program
50
59
  .command("remove")
51
60
  .alias("rm")
@@ -74,8 +83,9 @@ program
74
83
  .action(run((host) => commands_1.default.open(host)));
75
84
  program
76
85
  .command("tunnel")
77
- .description("Share a domain on a public https://*.trycloudflare.com URL (Cloudflare quick tunnel, no account); lists shared domains without --host")
86
+ .description("Share a domain on a public https://*.trycloudflare.com URL (Cloudflare quick tunnel, no account) or on your own hostname; lists shared domains without --host")
78
87
  .option("-h, --host <host>", "Domain to share, e.g. app.localhost")
88
+ .option("-d, --domain <hostname>", "Share on this hostname in your Cloudflare account (named tunnel), e.g. app.example.com")
79
89
  .option("--off", "Stop sharing it")
80
90
  .action(run((options) => commands_1.default.tunnel(options)));
81
91
  program.command("tunnel:install").description("Download cloudflared into the locadot state dir").action(run(() => commands_1.default.installTunnel()));
@@ -136,6 +146,10 @@ program
136
146
  .command("hub:localhost <onOrOff>")
137
147
  .description("Sender: let admin peers reach any port on this machine's localhost (on/off)")
138
148
  .action(run((value) => commands_1.default.hubLocalhost(value)));
149
+ program
150
+ .command("hub:panel <onOrOff>")
151
+ .description("Sender: open this dashboard to browsers at the hub's public URL, behind the dashboard password (on/off)")
152
+ .action(run((value) => commands_1.default.hubPanel(value)));
139
153
  program
140
154
  .command("ui:password")
141
155
  .description("Set or reset the dashboard password (prompts; hashed with scrypt). The API token keeps working for scripts")
@@ -0,0 +1,60 @@
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.allowMatches = exports.splitHost = exports.parseAllowList = exports.parseAllow = exports.MAX_ALLOW = void 0;
7
+ const net_1 = __importDefault(require("net"));
8
+ const guard_1 = require("../proxy/guard");
9
+ const localhost_1 = require("./localhost");
10
+ /**
11
+ * A mapping's `allow` list: internal addresses (`localhost:3000`, `api.internal`) that a tunnel visitor or hub peer
12
+ * may reach through that mapping's --cors pass-through. Everyone else stays limited to public hosts.
13
+ */
14
+ exports.MAX_ALLOW = 32;
15
+ const LABEL = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?";
16
+ const HOSTNAME = new RegExp(`^${LABEL}(?:\\.${LABEL})*$`);
17
+ /** Loopback spellings (127.0.0.1, 0.0.0.0) all mean this machine, and the shim sends whichever the app wrote. */
18
+ const canonicalHost = (host) => ((0, guard_1.isLoopbackHost)(host) && !host.endsWith(".localhost") ? "localhost" : host);
19
+ const parseAllow = (value) => {
20
+ if (typeof value !== "string")
21
+ throw new localhost_1.InputError("❌ Each allowed address must be a string, e.g. localhost:3000.");
22
+ const raw = value.trim().toLowerCase();
23
+ const bare = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/\/$/, "");
24
+ const match = bare.match(/^([^:/\s]+)(?::(\d{1,5}))?$/);
25
+ const port = match?.[2] === undefined ? undefined : Number(match[2]);
26
+ if (!match || !(net_1.default.isIPv4(match[1]) || HOSTNAME.test(match[1])) || (port !== undefined && (port < 1 || port > 65535))) {
27
+ throw new localhost_1.InputError(`❌ "${value}" isn't a host or host:port, e.g. localhost:3000 or api.internal.`);
28
+ }
29
+ const host = canonicalHost(match[1]);
30
+ return port === undefined ? host : `${host}:${port}`;
31
+ };
32
+ exports.parseAllow = parseAllow;
33
+ /** Normalizes and de-duplicates; an empty list clears it. */
34
+ const parseAllowList = (value) => {
35
+ const items = typeof value === "string" ? value.split(",").filter((item) => item.trim()) : value;
36
+ if (!Array.isArray(items))
37
+ throw new localhost_1.InputError("❌ `allow` must be a list of addresses, e.g. [\"localhost:3000\"].");
38
+ const list = [...new Set(items.map(exports.parseAllow))];
39
+ if (list.length > exports.MAX_ALLOW)
40
+ throw new localhost_1.InputError(`❌ At most ${exports.MAX_ALLOW} allowed addresses per mapping.`);
41
+ return list;
42
+ };
43
+ exports.parseAllowList = parseAllowList;
44
+ const defaultPort = (scheme) => (scheme === "https" || scheme === "wss" ? 443 : 80);
45
+ /** `host` from the pass-through URL (no brackets: parseVia only accepts names and IPv4). */
46
+ const splitHost = (host, scheme) => {
47
+ const match = host.toLowerCase().match(/^(.*?)(?::(\d+))?$/);
48
+ return { host: canonicalHost(match[1].replace(/\.$/, "")), port: match[2] ? Number(match[2]) : defaultPort(scheme) };
49
+ };
50
+ exports.splitHost = splitHost;
51
+ const allowMatches = (allow, host, scheme) => {
52
+ if (!allow?.length)
53
+ return false;
54
+ const target = (0, exports.splitHost)(host, scheme);
55
+ return allow.some((entry) => {
56
+ const [name, port] = entry.split(":");
57
+ return name === target.host && (port === undefined || Number(port) === target.port);
58
+ });
59
+ };
60
+ exports.allowMatches = allowMatches;
package/dist/lib/hosts.js CHANGED
@@ -36,10 +36,12 @@ 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.ConflictError = exports.NotFoundError = void 0;
39
+ exports.parseTunnelDomain = exports.ConflictError = exports.NotFoundError = void 0;
40
40
  const constants_1 = __importDefault(require("../constants"));
41
41
  const localhost_1 = __importStar(require("./localhost"));
42
42
  const registry_1 = __importDefault(require("./registry"));
43
+ const allow_1 = require("./allow");
44
+ const hub_config_1 = __importDefault(require("./hub-config"));
43
45
  class NotFoundError extends localhost_1.InputError {
44
46
  }
45
47
  exports.NotFoundError = NotFoundError;
@@ -53,17 +55,30 @@ const requireTarget = (value) => {
53
55
  }
54
56
  return localhost_1.default.parseTarget(String(value));
55
57
  };
58
+ const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
59
+ /** A public hostname on the user's Cloudflare zone, e.g. app.example.com (scheme and trailing slash allowed). */
60
+ const parseTunnelDomain = (value) => {
61
+ const domain = typeof value === "string" ? value.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/$/, "") : "";
62
+ const labels = domain.split(".");
63
+ const tld = labels[labels.length - 1];
64
+ if (domain.length > 253 || labels.length < 2 || tld === "localhost" || !/[a-z]/.test(tld) || !labels.every((label) => LABEL.test(label))) {
65
+ throw new localhost_1.InputError(`❌ "${value}" isn't a public hostname, e.g. app.example.com.`);
66
+ }
67
+ return domain;
68
+ };
69
+ exports.parseTunnelDomain = parseTunnelDomain;
56
70
  class HostOps {
57
71
  static async add(input) {
58
72
  const host = localhost_1.default.requireHost(input.host);
59
73
  const target = requireTarget(input.target);
74
+ const allow = input.allow === undefined ? [] : (0, allow_1.parseAllowList)(input.allow);
60
75
  const now = new Date().toISOString();
61
76
  const entry = await registry_1.default.mutate((registry) => {
62
77
  const existing = registry.hosts[host];
63
78
  if (existing) {
64
79
  throw new ConflictError(`❌ ${host} is already mapped to ${existing.target}. Use \`locadot update --host ${host} ...\` instead.`);
65
80
  }
66
- registry.hosts[host] = { target, insecure: input.insecure || undefined, cors: input.cors || undefined, createdAt: now, updatedAt: now };
81
+ registry.hosts[host] = { target, insecure: input.insecure || undefined, cors: input.cors || undefined, allow: allow.length ? allow : undefined, createdAt: now, updatedAt: now };
67
82
  return registry.hosts[host];
68
83
  });
69
84
  return { host, entry };
@@ -71,24 +86,57 @@ class HostOps {
71
86
  static async update(input) {
72
87
  const host = localhost_1.default.requireHost(input.host);
73
88
  const target = requireTarget(input.target);
89
+ const allow = input.allow === undefined ? undefined : (0, allow_1.parseAllowList)(input.allow);
74
90
  const entry = await registry_1.default.mutate((registry) => {
75
91
  const existing = registry.hosts[host];
76
92
  if (!existing)
77
93
  throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
78
94
  const insecure = input.insecure ?? existing.insecure;
79
95
  const cors = input.cors ?? existing.cors;
80
- registry.hosts[host] = { ...existing, target, insecure: insecure || undefined, cors: cors || undefined, updatedAt: new Date().toISOString() };
96
+ const list = allow ?? existing.allow;
97
+ registry.hosts[host] = {
98
+ ...existing,
99
+ target,
100
+ insecure: insecure || undefined,
101
+ cors: cors || undefined,
102
+ allow: list?.length ? list : undefined,
103
+ updatedAt: new Date().toISOString(),
104
+ };
81
105
  return registry.hosts[host];
82
106
  });
83
107
  return { host, entry };
84
108
  }
109
+ /** Sharing on: `domain` shares on that hostname, none on a random trycloudflare.com URL. Sharing off keeps the hostname for next time. */
85
110
  static async setTunnel(input) {
86
111
  const host = localhost_1.default.requireHost(input.host);
112
+ const domain = input.tunnel && input.domain !== undefined && input.domain !== null && input.domain !== "" ? (0, exports.parseTunnelDomain)(input.domain) : undefined;
113
+ if (domain && hub_config_1.default.read()?.domain === domain)
114
+ throw new ConflictError(`❌ ${domain} is this machine's remote-access hostname.`);
115
+ const entry = await registry_1.default.mutate((registry) => {
116
+ const existing = registry.hosts[host];
117
+ if (!existing)
118
+ throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
119
+ if (domain && existing.remote)
120
+ throw new localhost_1.InputError(`❌ ${host} is a remote's mapping; share it on a custom hostname from that machine.`);
121
+ const taken = domain && Object.entries(registry.hosts).find(([other, e]) => other !== host && e.tunnelDomain === domain)?.[0];
122
+ if (taken)
123
+ throw new ConflictError(`❌ ${domain} is already the shared hostname of ${taken}.`);
124
+ const tunnelDomain = input.tunnel ? domain : existing.tunnelDomain;
125
+ registry.hosts[host] = { ...existing, tunnel: input.tunnel || undefined, tunnelDomain, updatedAt: new Date().toISOString() };
126
+ return registry.hosts[host];
127
+ });
128
+ return { host, entry };
129
+ }
130
+ static async setAllow(input) {
131
+ const host = localhost_1.default.requireHost(input.host);
132
+ const allow = (0, allow_1.parseAllowList)(input.allow);
87
133
  const entry = await registry_1.default.mutate((registry) => {
88
134
  const existing = registry.hosts[host];
89
135
  if (!existing)
90
136
  throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
91
- registry.hosts[host] = { ...existing, tunnel: input.tunnel || undefined, updatedAt: new Date().toISOString() };
137
+ if (existing.remote)
138
+ throw new localhost_1.InputError(`❌ ${host} is a remote's mapping; set its allowed addresses on that machine.`);
139
+ registry.hosts[host] = { ...existing, allow: allow.length ? allow : undefined, updatedAt: new Date().toISOString() };
92
140
  return registry.hosts[host];
93
141
  });
94
142
  return { host, entry };
@@ -9,6 +9,7 @@ const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const constants_1 = __importDefault(require("../constants"));
11
11
  const file_1 = __importDefault(require("../utils/file"));
12
+ const hub_config_1 = __importDefault(require("./hub-config"));
12
13
  exports.SESSION_COOKIE = "locadot_ui";
13
14
  exports.SESSION_TTL_SEC = 7 * 24 * 3600;
14
15
  exports.MIN_PASSWORD = 8;
@@ -68,6 +69,10 @@ class UiAuth {
68
69
  static clear() {
69
70
  file_1.default.remove("UI_AUTH_FILE");
70
71
  cache = undefined;
72
+ // The shared dashboard needs the password, so it doesn't come back on its own when a new one is set.
73
+ const hub = hub_config_1.default.read();
74
+ if (hub?.panel)
75
+ hub_config_1.default.write({ ...hub, panel: false });
71
76
  }
72
77
  static verify(password) {
73
78
  const data = UiAuth.read();
@@ -3,11 +3,12 @@ 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.publicOnlyAgents = exports.isPublicHost = exports.isLoopbackHost = exports.isPublicAddress = void 0;
6
+ exports.notSelfAgents = exports.isSelfAddress = exports.publicOnlyAgents = exports.isPublicHost = exports.isLoopbackHost = exports.isPublicAddress = void 0;
7
7
  const dns_1 = __importDefault(require("dns"));
8
8
  const http_1 = __importDefault(require("http"));
9
9
  const https_1 = __importDefault(require("https"));
10
10
  const net_1 = __importDefault(require("net"));
11
+ const os_1 = __importDefault(require("os"));
11
12
  /**
12
13
  * Tunnel visitors are the internet. Their --cors pass-through calls may only leave this machine
13
14
  * for public addresses, never loopback, the LAN or cloud metadata.
@@ -93,3 +94,31 @@ exports.publicOnlyAgents = {
93
94
  http: new http_1.default.Agent({ keepAlive: true, lookup: publicLookup }),
94
95
  https: new https_1.default.Agent({ keepAlive: true, lookup: publicLookup }),
95
96
  };
97
+ /** Loopback or one of this machine's own interface addresses (its LAN IP reaches the proxy just as well). */
98
+ const isSelfAddress = (address) => {
99
+ if ((0, exports.isLoopbackHost)(address))
100
+ return true;
101
+ const own = Object.values(os_1.default.networkInterfaces()).flatMap((list) => (list || []).map((item) => item.address.toLowerCase()));
102
+ return own.includes(address.replace(/^\[|\]$/g, "").toLowerCase());
103
+ };
104
+ exports.isSelfAddress = isSelfAddress;
105
+ // For an allowed address on the proxy's own port: whatever the name resolves to, it must not be this machine.
106
+ const notSelfLookup = (hostname, options, callback) => {
107
+ dns_1.default.lookup(hostname, { ...options, all: true }, (err, addresses) => {
108
+ if (err)
109
+ return callback(err, "", 0);
110
+ const allowed = addresses.filter((entry) => !(0, exports.isSelfAddress)(entry.address));
111
+ if (!allowed.length) {
112
+ const error = new Error(`${hostname} is this machine`);
113
+ error.code = "ESELF";
114
+ return callback(error, "", 0);
115
+ }
116
+ if (options.all)
117
+ return callback(null, allowed);
118
+ callback(null, allowed[0].address, allowed[0].family);
119
+ });
120
+ };
121
+ exports.notSelfAgents = {
122
+ http: new http_1.default.Agent({ keepAlive: true, lookup: notSelfLookup }),
123
+ https: new https_1.default.Agent({ keepAlive: true, lookup: notSelfLookup }),
124
+ };
package/dist/proxy/hub.js CHANGED
@@ -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.resetRateLimiter = void 0;
39
+ exports.clientIp = exports.resetRateLimiter = void 0;
40
40
  exports.classify = classify;
41
41
  exports.handleHubApi = handleHubApi;
42
42
  const os_1 = __importDefault(require("os"));
@@ -45,6 +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 ui_auth_1 = __importDefault(require("../lib/ui-auth"));
48
49
  const localhost_1 = __importStar(require("../lib/localhost"));
49
50
  const registry_1 = require("../lib/registry");
50
51
  const request_1 = require("./request");
@@ -80,6 +81,7 @@ const clientIp = (req) => {
80
81
  const value = Array.isArray(header) ? header[0] : header;
81
82
  return value || req.socket.remoteAddress || "unknown";
82
83
  };
84
+ exports.clientIp = clientIp;
83
85
  /** `X-Original-Host` from the receiver, only if it is a `*.localhost` name with an optional port. */
84
86
  const receiverHost = (req) => {
85
87
  const header = req.headers["x-original-host"];
@@ -137,7 +139,13 @@ function classify(req, publicHost, hosts, opts) {
137
139
  const pathname = (req.url || "/").split("?")[0];
138
140
  if (pathname === "/_locadot" || pathname.startsWith("/_locadot/"))
139
141
  return { kind: "api" };
140
- const ip = clientIp(req);
142
+ // Peers always send X-Locadot-Peer; a plain browser gets the shared dashboard, which has its own sign-in limiter.
143
+ if (opts?.panel && req.headers["x-locadot-peer"] === undefined) {
144
+ if (!ui_auth_1.default.enabled())
145
+ return { kind: "deny", status: 403, message: "Set a dashboard password to share the dashboard." };
146
+ return { kind: "panel" };
147
+ }
148
+ const ip = (0, exports.clientIp)(req);
141
149
  if (isLimited(ip))
142
150
  return { kind: "deny", status: 429, message: "Too many failed attempts. Try again later." };
143
151
  // Not Authorization: the sender's own apps may need that header from the visitor.
@@ -269,7 +277,7 @@ async function handleHubApi(req, res, ctx) {
269
277
  const url = new URL(req.url || "/", "http://hub.invalid");
270
278
  const path = url.pathname;
271
279
  const method = req.method || "GET";
272
- const ip = clientIp(req);
280
+ const ip = (0, exports.clientIp)(req);
273
281
  try {
274
282
  if (path === "/_locadot/v1/connect" && method === "POST") {
275
283
  const body = await readJson(req);
@@ -22,14 +22,22 @@ const proxyOptions = (req, entry, lookup) => ({
22
22
  headers: { "X-Original-Host": req.headers.host || "", ...(entry.cors ? { ...(0, cors_1.sameOriginHeaders)(req, entry, lookup), ...DECODABLE } : {}) },
23
23
  });
24
24
  exports.proxyOptions = proxyOptions;
25
- const viaOptions = (req, entry, via) => ({
25
+ const agentFor = (req, via, listed) => {
26
+ const tls = via.scheme === "https" || via.scheme === "wss";
27
+ if (listed === "not-self")
28
+ return { agent: tls ? guard_1.notSelfAgents.https : guard_1.notSelfAgents.http };
29
+ if ((0, request_1.fromTunnel)(req) && !listed)
30
+ return { agent: tls ? guard_1.publicOnlyAgents.https : guard_1.publicOnlyAgents.http };
31
+ return {};
32
+ };
33
+ const viaOptions = (req, entry, via, listed) => ({
26
34
  target: (0, exports.viaOrigin)(via),
27
35
  changeOrigin: true,
28
36
  ws: true,
29
37
  secure: !entry.insecure,
30
38
  cookieDomainRewrite: { "*": "" },
31
39
  headers: { ...(0, passthrough_1.viaHeaders)(req, via, entry.target), ...DECODABLE },
32
- ...((0, request_1.fromTunnel)(req) ? { agent: via.scheme === "https" || via.scheme === "wss" ? guard_1.publicOnlyAgents.https : guard_1.publicOnlyAgents.http } : {}),
40
+ ...agentFor(req, via, listed),
33
41
  });
34
42
  exports.viaOptions = viaOptions;
35
43
  const viaOrigin = (via) => `${via.scheme}://${via.host}`;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isTls = exports.viaOf = exports.loopbackOf = exports.peerNamesOf = exports.peerOriginOf = exports.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
3
+ exports.isTls = exports.viaOf = exports.loopbackOf = exports.peerNamesOf = exports.peerOriginOf = exports.isPanel = 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 isPanel = (req) => Boolean(tags.get(req)?.panel);
25
+ exports.isPanel = isPanel;
24
26
  const peerOriginOf = (req) => tags.get(req)?.peerOrigin;
25
27
  exports.peerOriginOf = peerOriginOf;
26
28
  const peerNamesOf = (req) => tags.get(req)?.peerNames;
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.handleRequest = handleRequest;
7
7
  exports.handleUpgrade = handleUpgrade;
8
8
  const http_1 = __importDefault(require("http"));
9
+ const net_1 = __importDefault(require("net"));
9
10
  const template_1 = require("../constants/template");
10
11
  const constants_1 = __importDefault(require("../constants"));
11
12
  const logger_1 = __importDefault(require("../utils/logger"));
@@ -14,15 +15,22 @@ const remote_1 = require("./remote");
14
15
  const cors_1 = require("./cors");
15
16
  const options_1 = require("./options");
16
17
  const guard_1 = require("./guard");
18
+ const allow_1 = require("../lib/allow");
17
19
  const passthrough_1 = require("./passthrough");
18
20
  const request_1 = require("./request");
19
21
  const stats_1 = require("./stats");
22
+ // Cloudflare swaps an origin's 502/504 for its own "bad gateway" page, so through a tunnel say 503 and the page survives.
23
+ const downStatus = (req) => ((0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req) ? 503 : 502);
20
24
  /** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
21
25
  const resolveHost = (req, ctx, hub) => {
22
26
  if (hub?.kind === "app") {
23
27
  (0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names, loopback: hub.loopback });
24
28
  return hub.host;
25
29
  }
30
+ if (hub?.kind === "panel") {
31
+ (0, request_1.tag)(req, { host: "localhost", remote: true, secure: ctx.hub.secure(), peerOrigin: (req.headers.host || "").toLowerCase(), panel: true });
32
+ return "localhost";
33
+ }
26
34
  if (hub?.kind === "dashboard") {
27
35
  (0, request_1.tag)(req, { host: "localhost", remote: true, secure: ctx.hub.secure(), peerOrigin: hub.origin });
28
36
  return "localhost";
@@ -49,8 +57,23 @@ const peerMayReach = (req, via) => {
49
57
  const port = Number(via.host.match(/:(\d+)$/)?.[1] || (via.scheme === "https" || via.scheme === "wss" ? 443 : 80));
50
58
  return Boolean(allowed && !allowed.includes(port));
51
59
  };
52
- /** Same-origin only, a tunnel visitor may only reach public hosts (see guard.ts), and a peer only its share of localhost. */
53
- const viaAllowed = (req, via) => (0, passthrough_1.isSameOrigin)(req) && (!(0, request_1.fromTunnel)(req) || (0, guard_1.isPublicHost)(via.host)) && peerMayReach(req, via);
60
+ /**
61
+ * An address on the mapping's `allow` list. The proxy's own ports on this machine never are (the dashboard is behind
62
+ * them): "not-self" means the name must be checked at connect time, on the address actually dialled.
63
+ */
64
+ const listed = (req, ctx, entry, via) => {
65
+ if (!((0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req)) || !(0, allow_1.allowMatches)(entry.allow, via.host, via.scheme))
66
+ return undefined;
67
+ const { host, port } = (0, allow_1.splitHost)(via.host, via.scheme);
68
+ if (!(ctx.ownPorts?.() ?? []).includes(port))
69
+ return "open";
70
+ return (0, guard_1.isSelfAddress)(host) ? undefined : net_1.default.isIP(host) ? "open" : "not-self";
71
+ };
72
+ /**
73
+ * Same-origin only, a tunnel visitor may only reach public hosts (see guard.ts), and a peer only its share of localhost,
74
+ * unless the mapping's owner listed the address in `allow`.
75
+ */
76
+ const viaAllowed = (req, via, allowed) => (0, passthrough_1.isSameOrigin)(req) && (Boolean(allowed) || ((!(0, request_1.fromTunnel)(req) || (0, guard_1.isPublicHost)(via.host)) && peerMayReach(req, via)));
54
77
  /**
55
78
  * Receiver side: `<sender host>=<our URL for it>` for every mapping of this remote, so a --cors sender rewrites
56
79
  * its apps' origins to the names this machine uses rather than its own.
@@ -110,7 +133,7 @@ function handleRequest(req, res, ctx) {
110
133
  forwardLocal(req, res, ctx, host, local);
111
134
  return;
112
135
  }
113
- res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
136
+ res.writeHead(downStatus(req), { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
114
137
  res.end((0, template_1.proxyNotFound)(host, dashboardUrl(req)));
115
138
  return;
116
139
  }
@@ -130,9 +153,11 @@ function handleRequest(req, res, ctx) {
130
153
  return;
131
154
  }
132
155
  const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
133
- if (via && !viaAllowed(req, via)) {
156
+ const allowed = via && listed(req, ctx, entry, via);
157
+ if (via && !viaAllowed(req, via, allowed)) {
134
158
  res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
135
- res.end("locadot: the pass-through only serves the page's own requests, and only public hosts through a tunnel.\n");
159
+ res.end(`locadot: the pass-through only serves the page's own requests, and only public hosts to shared visitors. ` +
160
+ `To let them reach ${via.host}, run \`locadot allow --host ${host} ${via.host}\` on this machine, or use Internal access on the mapping in the dashboard.\n`);
136
161
  return;
137
162
  }
138
163
  const started = Date.now();
@@ -142,7 +167,7 @@ function handleRequest(req, res, ctx) {
142
167
  let options = (0, options_1.proxyOptions)(req, entry, ctx.lookup);
143
168
  if (via) {
144
169
  (0, request_1.tag)(req, { via });
145
- options = (0, options_1.viaOptions)(req, entry, via);
170
+ options = (0, options_1.viaOptions)(req, entry, via, allowed);
146
171
  req.url = via.path;
147
172
  }
148
173
  const upstream = via ? (0, options_1.viaOrigin)(via) : entry.target;
@@ -152,8 +177,8 @@ function handleRequest(req, res, ctx) {
152
177
  res.destroy();
153
178
  return;
154
179
  }
155
- // With --cors the page should see a 502, not a CORS error.
156
- res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", ...(entry.cors ? (0, cors_1.allowOrigin)(req) : {}) });
180
+ // With --cors the page should see the error, not a CORS failure.
181
+ res.writeHead(downStatus(req), { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", ...(entry.cors ? (0, cors_1.allowOrigin)(req) : {}) });
157
182
  res.end((0, template_1.upstreamDown)(host, upstream, reason(err) || "error", dashboardUrl(req)));
158
183
  });
159
184
  }
@@ -207,13 +232,13 @@ function forwardLocal(req, res, ctx, host, local) {
207
232
  res.destroy();
208
233
  return;
209
234
  }
210
- html(res, 502, (0, template_1.upstreamDown)(host, label, reason(err) || "error", dashboardUrl(req)));
235
+ html(res, downStatus(req), (0, template_1.upstreamDown)(host, label, reason(err) || "error", dashboardUrl(req)));
211
236
  });
212
237
  }
213
238
  function handleUpgrade(req, socket, head, ctx) {
214
239
  socket.on("error", () => socket.destroy());
215
240
  const hub = ctx.hub?.classify(req);
216
- if (hub?.kind === "api" || hub?.kind === "deny") {
241
+ if (hub?.kind === "api" || hub?.kind === "deny" || hub?.kind === "panel") {
217
242
  const status = hub.kind === "deny" ? hub.status : 400;
218
243
  socket.end(`HTTP/1.1 ${status} ${http_1.default.STATUS_CODES[status] || "Error"}\r\nConnection: close\r\n\r\n`);
219
244
  return;
@@ -246,14 +271,15 @@ function handleUpgrade(req, socket, head, ctx) {
246
271
  return;
247
272
  }
248
273
  const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
249
- if (via && !viaAllowed(req, via)) {
274
+ const allowed = via && listed(req, ctx, entry, via);
275
+ if (via && !viaAllowed(req, via, allowed)) {
250
276
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
251
277
  return;
252
278
  }
253
279
  logger_1.default.debug(`WebSocket upgrade for ${host}`);
254
280
  let options = (0, options_1.proxyOptions)(req, entry, ctx.lookup);
255
281
  if (via) {
256
- options = (0, options_1.viaOptions)(req, entry, via);
282
+ options = (0, options_1.viaOptions)(req, entry, via, allowed);
257
283
  req.url = via.path;
258
284
  }
259
285
  ctx.proxy.ws(req, socket, head, options, (err) => {