locadot 2.2.0 → 2.4.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,28 @@ 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)));
58
+ program
59
+ .command("protect")
60
+ .description("Ask for a password before a domain opens: on the shared link, for connected machines and/or on this machine")
61
+ .addOption(hostOption())
62
+ .option("--shared", "Ask visitors of the shared (tunnel) link")
63
+ .option("--remote", "Ask machines connected through remote access")
64
+ .option("--local", "Ask on this machine and your network")
65
+ .option("--password <password>", "The password (prompted for when left out)")
66
+ .option("--stdin", "Read the password from stdin")
67
+ .option("--off", "Stop asking for a password and forget it")
68
+ .action(run((options) => commands_1.default.protect(options)));
49
69
  program
50
70
  .command("remove")
51
71
  .alias("rm")
@@ -74,8 +94,9 @@ program
74
94
  .action(run((host) => commands_1.default.open(host)));
75
95
  program
76
96
  .command("tunnel")
77
- .description("Share a domain on a public https://*.trycloudflare.com URL (Cloudflare quick tunnel, no account); lists shared domains without --host")
97
+ .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
98
  .option("-h, --host <host>", "Domain to share, e.g. app.localhost")
99
+ .option("-d, --domain <hostname>", "Share on this hostname in your Cloudflare account (named tunnel), e.g. app.example.com")
79
100
  .option("--off", "Stop sharing it")
80
101
  .action(run((options) => commands_1.default.tunnel(options)));
81
102
  program.command("tunnel:install").description("Download cloudflared into the locadot state dir").action(run(() => commands_1.default.installTunnel()));
@@ -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;
@@ -0,0 +1,123 @@
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.parseScopes = exports.MIN_HOST_PASSWORD = exports.HOST_SESSION_TTL_SEC = exports.HOST_COOKIE = exports.SCOPES = void 0;
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const constants_1 = __importDefault(require("../constants"));
10
+ const file_1 = __importDefault(require("../utils/file"));
11
+ exports.SCOPES = ["shared", "remote", "local"];
12
+ exports.HOST_COOKIE = "locadot_pw";
13
+ exports.HOST_SESSION_TTL_SEC = 7 * 24 * 3600;
14
+ exports.MIN_HOST_PASSWORD = 8;
15
+ const hashOf = (password, salt) => crypto_1.default.scryptSync(password, Buffer.from(salt, "base64"), 32).toString("base64");
16
+ const sign = (secret, value) => crypto_1.default.createHmac("sha256", secret).update(value).digest("base64url");
17
+ const safeEqual = (a, b) => {
18
+ const x = Buffer.from(a);
19
+ const y = Buffer.from(b);
20
+ return x.length === y.length && crypto_1.default.timingSafeEqual(x, y);
21
+ };
22
+ let cache;
23
+ const readAll = () => {
24
+ let mtimeMs;
25
+ try {
26
+ mtimeMs = fs_1.default.statSync(constants_1.default.paths.HOST_PASSWORDS_FILE).mtimeMs;
27
+ }
28
+ catch {
29
+ cache = undefined;
30
+ return {};
31
+ }
32
+ if (!cache || cache.mtimeMs !== mtimeMs) {
33
+ let value = {};
34
+ try {
35
+ const data = JSON.parse(file_1.default.read("HOST_PASSWORDS_FILE") || "{}");
36
+ if (data && typeof data === "object")
37
+ value = data;
38
+ }
39
+ catch { }
40
+ cache = { mtimeMs, value };
41
+ }
42
+ return cache.value;
43
+ };
44
+ const writeAll = (all) => {
45
+ file_1.default.writeAtomic("HOST_PASSWORDS_FILE", JSON.stringify(all, null, 2));
46
+ fs_1.default.chmodSync(constants_1.default.paths.HOST_PASSWORDS_FILE, 0o600);
47
+ cache = undefined;
48
+ };
49
+ const failures = new Map();
50
+ const FAIL_WINDOW_MS = 5 * 60000;
51
+ const MAX_FAILURES = 10;
52
+ const parseScopes = (value) => {
53
+ const list = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
54
+ const scopes = [...new Set(list.map((item) => String(item).trim().toLowerCase()).filter(Boolean))];
55
+ const bad = scopes.find((scope) => !exports.SCOPES.includes(scope));
56
+ if (bad)
57
+ throw new Error(`"${bad}" isn't a place to ask for the password; use shared, remote or local.`);
58
+ return exports.SCOPES.filter((scope) => scopes.includes(scope));
59
+ };
60
+ exports.parseScopes = parseScopes;
61
+ class HostAuth {
62
+ static has(host) {
63
+ return Boolean(readAll()[host]);
64
+ }
65
+ static updatedAt(host) {
66
+ return readAll()[host]?.updatedAt;
67
+ }
68
+ static set(host, password) {
69
+ if (typeof password !== "string" || password.length < exports.MIN_HOST_PASSWORD) {
70
+ throw new Error(`The password must be at least ${exports.MIN_HOST_PASSWORD} characters.`);
71
+ }
72
+ const salt = crypto_1.default.randomBytes(16).toString("base64");
73
+ const entry = { salt, hash: hashOf(password, salt), secret: crypto_1.default.randomBytes(32).toString("base64"), updatedAt: new Date().toISOString() };
74
+ writeAll({ ...readAll(), [host]: entry });
75
+ }
76
+ static clear(host) {
77
+ const all = { ...readAll() };
78
+ if (!all[host])
79
+ return;
80
+ delete all[host];
81
+ writeAll(all);
82
+ }
83
+ static verify(host, password) {
84
+ const data = readAll()[host];
85
+ if (!data || typeof password !== "string" || !password)
86
+ return false;
87
+ return safeEqual(hashOf(password, data.salt), data.hash);
88
+ }
89
+ /** `<expiry>.<mac>`, bound to the host; nothing is kept server-side. */
90
+ static issue(host, now = Date.now()) {
91
+ const data = readAll()[host];
92
+ if (!data)
93
+ throw new Error(`${host} has no password.`);
94
+ const exp = String(Math.floor(now / 1000) + exports.HOST_SESSION_TTL_SEC);
95
+ return `${exp}.${sign(data.secret, `${host}|${exp}`)}`;
96
+ }
97
+ static validSession(host, value, now = Date.now()) {
98
+ const data = readAll()[host];
99
+ if (!data || !value)
100
+ return false;
101
+ const [exp, mac] = value.split(".");
102
+ if (!/^\d+$/.test(exp || "") || !mac || Number(exp) * 1000 < now)
103
+ return false;
104
+ return safeEqual(mac, sign(data.secret, `${host}|${exp}`));
105
+ }
106
+ static limited(key, now = Date.now()) {
107
+ const entry = failures.get(key);
108
+ if (!entry || now - entry.since > FAIL_WINDOW_MS)
109
+ return false;
110
+ return entry.count >= MAX_FAILURES;
111
+ }
112
+ static fail(key, now = Date.now()) {
113
+ const entry = failures.get(key);
114
+ if (!entry || now - entry.since > FAIL_WINDOW_MS)
115
+ failures.set(key, { count: 1, since: now });
116
+ else
117
+ entry.count++;
118
+ }
119
+ static succeed(key) {
120
+ failures.delete(key);
121
+ }
122
+ }
123
+ exports.default = HostAuth;
package/dist/lib/hosts.js CHANGED
@@ -36,10 +36,13 @@ 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"));
45
+ const host_auth_1 = __importStar(require("./host-auth"));
43
46
  class NotFoundError extends localhost_1.InputError {
44
47
  }
45
48
  exports.NotFoundError = NotFoundError;
@@ -53,17 +56,30 @@ const requireTarget = (value) => {
53
56
  }
54
57
  return localhost_1.default.parseTarget(String(value));
55
58
  };
59
+ const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
60
+ /** A public hostname on the user's Cloudflare zone, e.g. app.example.com (scheme and trailing slash allowed). */
61
+ const parseTunnelDomain = (value) => {
62
+ const domain = typeof value === "string" ? value.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/$/, "") : "";
63
+ const labels = domain.split(".");
64
+ const tld = labels[labels.length - 1];
65
+ if (domain.length > 253 || labels.length < 2 || tld === "localhost" || !/[a-z]/.test(tld) || !labels.every((label) => LABEL.test(label))) {
66
+ throw new localhost_1.InputError(`❌ "${value}" isn't a public hostname, e.g. app.example.com.`);
67
+ }
68
+ return domain;
69
+ };
70
+ exports.parseTunnelDomain = parseTunnelDomain;
56
71
  class HostOps {
57
72
  static async add(input) {
58
73
  const host = localhost_1.default.requireHost(input.host);
59
74
  const target = requireTarget(input.target);
75
+ const allow = input.allow === undefined ? [] : (0, allow_1.parseAllowList)(input.allow);
60
76
  const now = new Date().toISOString();
61
77
  const entry = await registry_1.default.mutate((registry) => {
62
78
  const existing = registry.hosts[host];
63
79
  if (existing) {
64
80
  throw new ConflictError(`❌ ${host} is already mapped to ${existing.target}. Use \`locadot update --host ${host} ...\` instead.`);
65
81
  }
66
- registry.hosts[host] = { target, insecure: input.insecure || undefined, cors: input.cors || undefined, createdAt: now, updatedAt: now };
82
+ registry.hosts[host] = { target, insecure: input.insecure || undefined, cors: input.cors || undefined, allow: allow.length ? allow : undefined, createdAt: now, updatedAt: now };
67
83
  return registry.hosts[host];
68
84
  });
69
85
  return { host, entry };
@@ -71,24 +87,108 @@ class HostOps {
71
87
  static async update(input) {
72
88
  const host = localhost_1.default.requireHost(input.host);
73
89
  const target = requireTarget(input.target);
90
+ const allow = input.allow === undefined ? undefined : (0, allow_1.parseAllowList)(input.allow);
74
91
  const entry = await registry_1.default.mutate((registry) => {
75
92
  const existing = registry.hosts[host];
76
93
  if (!existing)
77
94
  throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
78
95
  const insecure = input.insecure ?? existing.insecure;
79
96
  const cors = input.cors ?? existing.cors;
80
- registry.hosts[host] = { ...existing, target, insecure: insecure || undefined, cors: cors || undefined, updatedAt: new Date().toISOString() };
97
+ const list = allow ?? existing.allow;
98
+ registry.hosts[host] = {
99
+ ...existing,
100
+ target,
101
+ insecure: insecure || undefined,
102
+ cors: cors || undefined,
103
+ allow: list?.length ? list : undefined,
104
+ updatedAt: new Date().toISOString(),
105
+ };
81
106
  return registry.hosts[host];
82
107
  });
83
108
  return { host, entry };
84
109
  }
110
+ /** Sharing on: `domain` shares on that hostname, none on a random trycloudflare.com URL. Sharing off keeps the hostname for next time. */
85
111
  static async setTunnel(input) {
86
112
  const host = localhost_1.default.requireHost(input.host);
113
+ const domain = input.tunnel && input.domain !== undefined && input.domain !== null && input.domain !== "" ? (0, exports.parseTunnelDomain)(input.domain) : undefined;
114
+ if (domain && hub_config_1.default.read()?.domain === domain)
115
+ throw new ConflictError(`❌ ${domain} is this machine's remote-access hostname.`);
116
+ const entry = await registry_1.default.mutate((registry) => {
117
+ const existing = registry.hosts[host];
118
+ if (!existing)
119
+ throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
120
+ if (domain && existing.remote)
121
+ throw new localhost_1.InputError(`❌ ${host} is a remote's mapping; share it on a custom hostname from that machine.`);
122
+ const taken = domain && Object.entries(registry.hosts).find(([other, e]) => other !== host && e.tunnelDomain === domain)?.[0];
123
+ if (taken)
124
+ throw new ConflictError(`❌ ${domain} is already the shared hostname of ${taken}.`);
125
+ const tunnelDomain = input.tunnel ? domain : existing.tunnelDomain;
126
+ registry.hosts[host] = { ...existing, tunnel: input.tunnel || undefined, tunnelDomain, updatedAt: new Date().toISOString() };
127
+ return registry.hosts[host];
128
+ });
129
+ return { host, entry };
130
+ }
131
+ static async setAllow(input) {
132
+ const host = localhost_1.default.requireHost(input.host);
133
+ const allow = (0, allow_1.parseAllowList)(input.allow);
87
134
  const entry = await registry_1.default.mutate((registry) => {
88
135
  const existing = registry.hosts[host];
89
136
  if (!existing)
90
137
  throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
91
- registry.hosts[host] = { ...existing, tunnel: input.tunnel || undefined, updatedAt: new Date().toISOString() };
138
+ if (existing.remote)
139
+ throw new localhost_1.InputError(`❌ ${host} is a remote's mapping; set its allowed addresses on that machine.`);
140
+ registry.hosts[host] = { ...existing, allow: allow.length ? allow : undefined, updatedAt: new Date().toISOString() };
141
+ return registry.hosts[host];
142
+ });
143
+ return { host, entry };
144
+ }
145
+ /**
146
+ * Password protection: `scopes` picks where it's asked for (at least one). `password` is needed the first time;
147
+ * leaving it out keeps the current one. `off` removes the protection and the password.
148
+ */
149
+ static async setProtect(input) {
150
+ const host = localhost_1.default.requireHost(input.host);
151
+ const existing = registry_1.default.read().hosts[host];
152
+ if (!existing)
153
+ throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
154
+ if (existing.remote)
155
+ throw new localhost_1.InputError(`❌ ${host} is a remote's mapping; protect it on that machine.`);
156
+ if (input.off) {
157
+ const entry = await registry_1.default.mutate((registry) => {
158
+ const current = registry.hosts[host];
159
+ if (!current)
160
+ throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
161
+ registry.hosts[host] = { ...current, protect: undefined, updatedAt: new Date().toISOString() };
162
+ return registry.hosts[host];
163
+ });
164
+ host_auth_1.default.clear(host);
165
+ return { host, entry };
166
+ }
167
+ let scopes;
168
+ try {
169
+ scopes = (0, host_auth_1.parseScopes)(input.scopes);
170
+ }
171
+ catch (error) {
172
+ throw new localhost_1.InputError(`❌ ${error.message}`);
173
+ }
174
+ if (!scopes.length)
175
+ throw new localhost_1.InputError("❌ Pick at least one place to ask for the password: shared, remote or local.");
176
+ const password = input.password === undefined || input.password === null || input.password === "" ? undefined : input.password;
177
+ if (password === undefined && !host_auth_1.default.has(host))
178
+ throw new localhost_1.InputError(`❌ Set a password to protect ${host}.`);
179
+ if (password !== undefined) {
180
+ try {
181
+ host_auth_1.default.set(host, String(password));
182
+ }
183
+ catch (error) {
184
+ throw new localhost_1.InputError(`❌ ${error.message}`);
185
+ }
186
+ }
187
+ const entry = await registry_1.default.mutate((registry) => {
188
+ const current = registry.hosts[host];
189
+ if (!current)
190
+ throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
191
+ registry.hosts[host] = { ...current, protect: { scopes }, updatedAt: new Date().toISOString() };
92
192
  return registry.hosts[host];
93
193
  });
94
194
  return { host, entry };
@@ -100,6 +200,7 @@ class HostOps {
100
200
  throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
101
201
  delete registry.hosts[host];
102
202
  });
203
+ host_auth_1.default.clear(host);
103
204
  return { host };
104
205
  }
105
206
  }
@@ -0,0 +1,197 @@
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.runCapture = exports.loggedInZones = exports.covers = void 0;
7
+ exports.setupDomainTunnel = setupDomainTunnel;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const os_1 = __importDefault(require("os"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const child_process_1 = require("child_process");
12
+ const constants_1 = __importDefault(require("../constants"));
13
+ const logger_1 = __importDefault(require("../utils/logger"));
14
+ const tunnel_1 = require("./tunnel");
15
+ /**
16
+ * Cloudflare logins for custom-domain sharing. `cloudflared tunnel login` authorizes a single zone,
17
+ * so each login is kept as <state>/cloudflare/<zone>.pem and the one whose zone covers a hostname
18
+ * is used; ~/.cloudflared/cert.pem counts as one but is never written. A cert's zone is looked up
19
+ * with the API token inside it.
20
+ */
21
+ const DIR = path_1.default.join(constants_1.default.paths.HOME, "cloudflare");
22
+ const ZONES_FILE = path_1.default.join(DIR, "zones.json");
23
+ const LOGIN_WAIT_MS = 10 * 60000;
24
+ const API = () => process.env.LOCADOT_CLOUDFLARE_API || "https://api.cloudflare.com/client/v4";
25
+ const defaultCert = () => path_1.default.join(os_1.default.homedir(), ".cloudflared", "cert.pem");
26
+ const readToken = (file) => {
27
+ try {
28
+ const body = fs_1.default.readFileSync(file, "utf8").match(/-----BEGIN ARGO TUNNEL TOKEN-----([\s\S]*?)-----END/)?.[1];
29
+ const token = body && JSON.parse(Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8"));
30
+ return token?.zoneID ? token : undefined;
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ };
36
+ /** zoneID → zone name, learned from the API or from where cloudflared put a DNS record. */
37
+ const readZones = () => {
38
+ try {
39
+ return JSON.parse(fs_1.default.readFileSync(ZONES_FILE, "utf8"));
40
+ }
41
+ catch {
42
+ return {};
43
+ }
44
+ };
45
+ const rememberZone = (zoneID, zone) => {
46
+ fs_1.default.mkdirSync(DIR, { recursive: true });
47
+ fs_1.default.writeFileSync(ZONES_FILE, JSON.stringify({ ...readZones(), [zoneID]: zone }, null, 2));
48
+ };
49
+ const certFiles = () => {
50
+ const stored = fs_1.default.existsSync(DIR) ? fs_1.default.readdirSync(DIR).filter((f) => f.endsWith(".pem")).map((f) => path_1.default.join(DIR, f)) : [];
51
+ return [defaultCert(), ...stored].filter((file) => fs_1.default.existsSync(file));
52
+ };
53
+ const covers = (zone, hostname) => hostname === zone || hostname.endsWith(`.${zone}`);
54
+ exports.covers = covers;
55
+ /** Zones there's a login for, as far as they're known (no network). */
56
+ const loggedInZones = () => {
57
+ const zones = readZones();
58
+ return [...new Set(certFiles().map((file) => zones[readToken(file)?.zoneID || ""]).filter(Boolean))].sort();
59
+ };
60
+ exports.loggedInZones = loggedInZones;
61
+ const zoneOf = async (file) => {
62
+ const token = readToken(file);
63
+ if (!token)
64
+ return undefined;
65
+ const known = readZones()[token.zoneID];
66
+ if (known)
67
+ return known;
68
+ try {
69
+ const res = await fetch(`${API()}/zones/${token.zoneID}`, {
70
+ headers: { Authorization: `Bearer ${token.apiToken}` },
71
+ signal: AbortSignal.timeout(10000),
72
+ });
73
+ const name = (await res.json())?.result?.name;
74
+ if (res.ok && typeof name === "string") {
75
+ rememberZone(token.zoneID, name);
76
+ return name;
77
+ }
78
+ logger_1.default.warn(`🌍 couldn't look up the zone of a Cloudflare login: HTTP ${res.status}`);
79
+ }
80
+ catch (error) {
81
+ logger_1.default.warn(`🌍 couldn't look up the zone of a Cloudflare login: ${error?.cause?.message || error?.message || error}`);
82
+ }
83
+ return undefined;
84
+ };
85
+ const runCapture = (file, args, opts = {}) => new Promise((resolve, reject) => {
86
+ const child = (0, child_process_1.spawn)(file, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: opts.env, signal: opts.signal });
87
+ let output = "";
88
+ let done = false;
89
+ const finish = (fn) => {
90
+ if (done)
91
+ return;
92
+ done = true;
93
+ if (timer)
94
+ clearTimeout(timer);
95
+ fn();
96
+ };
97
+ const timer = opts.timeoutMs
98
+ ? setTimeout(() => finish(() => (child.kill(), reject(new Error(`${args.join(" ")} timed out`)))), opts.timeoutMs)
99
+ : undefined;
100
+ const read = (chunk) => {
101
+ const text = chunk.toString("utf8");
102
+ output += text;
103
+ opts.onData?.(text);
104
+ };
105
+ child.stdout?.on("data", read);
106
+ child.stderr?.on("data", read);
107
+ child.on("error", (error) => finish(() => reject(error)));
108
+ child.on("exit", (code) => finish(() => resolve({ code, output })));
109
+ });
110
+ exports.runCapture = runCapture;
111
+ /** Runs `cloudflared tunnel login` against a scratch home so the new cert never replaces an existing one. */
112
+ const signIn = async (file, onLoginUrl, signal) => {
113
+ const home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "locadot-cf-login-"));
114
+ try {
115
+ let announced = false;
116
+ const login = await (0, exports.runCapture)(file, ["tunnel", "login"], {
117
+ timeoutMs: LOGIN_WAIT_MS,
118
+ signal,
119
+ env: { ...process.env, HOME: home, USERPROFILE: home },
120
+ onData: (text) => {
121
+ const url = !announced && text.match(/https:\/\/\S+/)?.[0];
122
+ if (url) {
123
+ announced = true;
124
+ onLoginUrl(url);
125
+ }
126
+ },
127
+ }).catch((error) => {
128
+ throw new Error(/timed out/.test(error.message) ? "The Cloudflare sign-in wasn't finished within 10 minutes." : error.message);
129
+ });
130
+ const cert = path_1.default.join(home, ".cloudflared", "cert.pem");
131
+ const token = readToken(cert);
132
+ if (login.code !== 0 || !token)
133
+ throw new Error(`Cloudflare sign-in failed: ${login.output.trim().slice(-300) || `cloudflared exited (${login.code})`}`);
134
+ fs_1.default.mkdirSync(DIR, { recursive: true });
135
+ const staged = path_1.default.join(DIR, `${token.zoneID}.pem`);
136
+ fs_1.default.copyFileSync(cert, staged);
137
+ fs_1.default.chmodSync(staged, 0o600);
138
+ const zone = await zoneOf(staged);
139
+ if (!zone)
140
+ return staged;
141
+ const named = path_1.default.join(DIR, `${zone}.pem`);
142
+ fs_1.default.renameSync(staged, named);
143
+ return named;
144
+ }
145
+ finally {
146
+ fs_1.default.rmSync(home, { recursive: true, force: true });
147
+ }
148
+ };
149
+ const notAuthorized = (domain, zone) => new Error(`Your Cloudflare sign-in authorized ${zone}, which doesn't include ${domain}. Share again and pick the zone ${domain} belongs to, or add that domain to your Cloudflare account first.`);
150
+ /**
151
+ * Makes `name` a named tunnel serving `domain`: uses the login whose zone covers it, or signs in
152
+ * (reporting the URL through onLoginUrl), creates the tunnel and routes the DNS record.
153
+ * Throws a readable error when no login covers the domain.
154
+ */
155
+ async function setupDomainTunnel(domain, name, onLoginUrl, signal) {
156
+ const file = (0, tunnel_1.cloudflaredPath)();
157
+ if (!file)
158
+ throw new Error("cloudflared is not installed. Run `locadot tunnel:install`.");
159
+ let cert;
160
+ const unknown = [];
161
+ for (const candidate of certFiles()) {
162
+ const zone = await zoneOf(candidate);
163
+ if (zone && (0, exports.covers)(zone, domain)) {
164
+ cert = candidate;
165
+ break;
166
+ }
167
+ if (!zone)
168
+ unknown.push(candidate);
169
+ }
170
+ // A login whose zone couldn't be looked up is tried; route dns shows which zone it's for.
171
+ cert || (cert = unknown[0]);
172
+ if (!cert) {
173
+ cert = await signIn(file, onLoginUrl, signal);
174
+ const zone = await zoneOf(cert);
175
+ if (zone && !(0, exports.covers)(zone, domain))
176
+ throw notAuthorized(domain, zone);
177
+ }
178
+ const token = readToken(cert);
179
+ const credentials = path_1.default.join(DIR, "tunnels", token.accountID, `${name}.json`);
180
+ fs_1.default.mkdirSync(path_1.default.dirname(credentials), { recursive: true });
181
+ const create = await (0, exports.runCapture)(file, ["tunnel", "--origincert", cert, "create", "--credentials-file", credentials, name], { signal });
182
+ if (create.code !== 0 && !/already exists/i.test(create.output)) {
183
+ throw new Error(`cloudflared tunnel create failed: ${create.output.trim().slice(-500)}`);
184
+ }
185
+ const route = await (0, exports.runCapture)(file, ["tunnel", "--origincert", cert, "route", "dns", "--overwrite-dns", name, domain], { signal });
186
+ if (route.code !== 0)
187
+ throw new Error(`cloudflared tunnel route dns failed: ${route.output.trim().slice(-500)}`);
188
+ // Outside the login's zone, cloudflared appends the zone and "succeeds" with <domain>.<zone>.
189
+ const routed = route.output.match(/Added CNAME (\S+)/i)?.[1] || route.output.match(/(\S+) is already configured to route/i)?.[1];
190
+ if (routed && routed.toLowerCase().replace(/\.$/, "") !== domain) {
191
+ const zone = routed.toLowerCase().startsWith(`${domain}.`) ? routed.slice(domain.length + 1) : undefined;
192
+ if (zone)
193
+ rememberZone(token.zoneID, zone);
194
+ throw new Error(`${domain} isn't in the Cloudflare zone this login is for${zone ? ` (${zone})` : ""}; cloudflared created ${routed} instead. Delete that record in Cloudflare, then share again to sign in for ${domain}'s zone.`);
195
+ }
196
+ return { cert, credentials: fs_1.default.existsSync(credentials) ? credentials : undefined };
197
+ }
@@ -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
+ };
@@ -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}`;