locadot 2.2.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()));
@@ -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 };
@@ -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}`;
@@ -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,6 +15,7 @@ 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");
@@ -55,8 +57,23 @@ const peerMayReach = (req, via) => {
55
57
  const port = Number(via.host.match(/:(\d+)$/)?.[1] || (via.scheme === "https" || via.scheme === "wss" ? 443 : 80));
56
58
  return Boolean(allowed && !allowed.includes(port));
57
59
  };
58
- /** Same-origin only, a tunnel visitor may only reach public hosts (see guard.ts), and a peer only its share of localhost. */
59
- 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)));
60
77
  /**
61
78
  * Receiver side: `<sender host>=<our URL for it>` for every mapping of this remote, so a --cors sender rewrites
62
79
  * its apps' origins to the names this machine uses rather than its own.
@@ -136,9 +153,11 @@ function handleRequest(req, res, ctx) {
136
153
  return;
137
154
  }
138
155
  const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
139
- if (via && !viaAllowed(req, via)) {
156
+ const allowed = via && listed(req, ctx, entry, via);
157
+ if (via && !viaAllowed(req, via, allowed)) {
140
158
  res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
141
- 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`);
142
161
  return;
143
162
  }
144
163
  const started = Date.now();
@@ -148,7 +167,7 @@ function handleRequest(req, res, ctx) {
148
167
  let options = (0, options_1.proxyOptions)(req, entry, ctx.lookup);
149
168
  if (via) {
150
169
  (0, request_1.tag)(req, { via });
151
- options = (0, options_1.viaOptions)(req, entry, via);
170
+ options = (0, options_1.viaOptions)(req, entry, via, allowed);
152
171
  req.url = via.path;
153
172
  }
154
173
  const upstream = via ? (0, options_1.viaOrigin)(via) : entry.target;
@@ -252,14 +271,15 @@ function handleUpgrade(req, socket, head, ctx) {
252
271
  return;
253
272
  }
254
273
  const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
255
- if (via && !viaAllowed(req, via)) {
274
+ const allowed = via && listed(req, ctx, entry, via);
275
+ if (via && !viaAllowed(req, via, allowed)) {
256
276
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
257
277
  return;
258
278
  }
259
279
  logger_1.default.debug(`WebSocket upgrade for ${host}`);
260
280
  let options = (0, options_1.proxyOptions)(req, entry, ctx.lookup);
261
281
  if (via) {
262
- options = (0, options_1.viaOptions)(req, entry, via);
282
+ options = (0, options_1.viaOptions)(req, entry, via, allowed);
263
283
  req.url = via.path;
264
284
  }
265
285
  ctx.proxy.ws(req, socket, head, options, (err) => {
@@ -11,8 +11,10 @@ const path_1 = __importDefault(require("path"));
11
11
  const child_process_1 = require("child_process");
12
12
  const constants_1 = __importDefault(require("../constants"));
13
13
  const logger_1 = __importDefault(require("../utils/logger"));
14
+ const hub_tunnel_1 = require("./hub-tunnel");
14
15
  /**
15
- * Public sharing through Cloudflare quick tunnels (https://<random>.trycloudflare.com, no account).
16
+ * Public sharing through Cloudflare quick tunnels (https://<random>.trycloudflare.com, no account),
17
+ * or through a named tunnel per mapping on the user's own hostname (`tunnelDomain`).
16
18
  * cloudflared forwards to the proxy's HTTP port with the public Host header, and the proxy maps
17
19
  * that name back to the mapping, so redirects and cookies stay on the public name.
18
20
  */
@@ -20,6 +22,7 @@ const exe = os_1.default.platform() === "win32" ? "cloudflared.exe" : "cloudflar
20
22
  const BIN_DIR = path_1.default.join(constants_1.default.paths.HOME, "bin");
21
23
  const RELEASES = "https://github.com/cloudflare/cloudflared/releases/latest/download/";
22
24
  const URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
25
+ const REGISTERED_PATTERN = /Registered tunnel connection/i;
23
26
  const onPath = () => {
24
27
  const finder = os_1.default.platform() === "win32" ? "where" : "which";
25
28
  const result = (0, child_process_1.spawnSync)(finder, ["cloudflared"], { encoding: "utf8", windowsHide: true });
@@ -37,11 +40,12 @@ const cloudflaredInfo = (fresh = false) => {
37
40
  if (!fresh && cached && Date.now() - cached.at < 60000)
38
41
  return cached.info;
39
42
  const file = (0, exports.cloudflaredPath)();
40
- let info = { installed: false };
43
+ const loggedIn = fs_1.default.existsSync(path_1.default.join(os_1.default.homedir(), ".cloudflared", "cert.pem"));
44
+ let info = { installed: false, loggedIn };
41
45
  if (file) {
42
46
  const result = (0, child_process_1.spawnSync)(file, ["--version"], { encoding: "utf8", timeout: 5000, windowsHide: true });
43
47
  const version = `${result.stdout || ""}${result.stderr || ""}`.match(/version\s+(\S+)/i)?.[1];
44
- info = { installed: result.status === 0, path: file, ...(version ? { version } : {}) };
48
+ info = { installed: result.status === 0, loggedIn, path: file, ...(version ? { version } : {}) };
45
49
  }
46
50
  cached = { at: Date.now(), info };
47
51
  return info;
@@ -121,7 +125,7 @@ class TunnelManager {
121
125
  this.origin = origin;
122
126
  this.tunnels = new Map();
123
127
  }
124
- /** The mapping a public trycloudflare.com Host belongs to. */
128
+ /** The mapping a public Host (trycloudflare.com or a custom hostname) belongs to. */
125
129
  hostFor(publicHost) {
126
130
  for (const [host, tunnel] of this.tunnels)
127
131
  if (tunnel.publicHost === publicHost)
@@ -137,38 +141,73 @@ class TunnelManager {
137
141
  }
138
142
  /** Starts and stops tunnels to match the registry; `retry` also restarts ones that failed. */
139
143
  sync(hosts, retry = false) {
140
- for (const [host, tunnel] of [...this.tunnels])
141
- if (!hosts[host]?.tunnel || (retry && tunnel.status === "error"))
144
+ for (const [host, tunnel] of [...this.tunnels]) {
145
+ const entry = hosts[host];
146
+ if (!entry?.tunnel || (retry && tunnel.status === "error") || tunnel.domain !== (entry.tunnelDomain || undefined))
142
147
  this.stop(host);
148
+ }
143
149
  for (const [host, entry] of Object.entries(hosts))
144
150
  if (entry.tunnel && !this.tunnels.has(host))
145
- this.start(host);
151
+ this.start(host, entry.tunnelDomain);
146
152
  }
147
- start(host) {
153
+ start(host, domain) {
154
+ const mode = domain ? "custom" : "quick";
148
155
  const file = (0, exports.cloudflaredPath)();
149
156
  if (!file) {
150
- this.tunnels.set(host, { enabled: true, status: "error", error: "cloudflared is not installed. Run `locadot tunnel:install`." });
157
+ this.tunnels.set(host, { enabled: true, status: "error", mode, domain, error: "cloudflared is not installed. Run `locadot tunnel:install`." });
151
158
  return;
152
159
  }
153
- const tunnel = { enabled: true, status: "starting" };
160
+ const tunnel = { enabled: true, status: "starting", mode, domain };
154
161
  this.tunnels.set(host, tunnel);
162
+ if (domain) {
163
+ this.startCustom(file, host, domain, tunnel);
164
+ return;
165
+ }
155
166
  // An empty config file keeps a user's ~/.cloudflared/config.yml (named tunnels) out of the way.
156
167
  const config = path_1.default.join(constants_1.default.paths.HOME, "cloudflared-quick.yml");
157
168
  fs_1.default.writeFileSync(config, "");
158
- const child = (0, child_process_1.spawn)(file, ["tunnel", "--no-autoupdate", "--config", config, "--url", this.origin()], {
159
- stdio: ["ignore", "pipe", "pipe"],
160
- windowsHide: true,
169
+ this.run(host, tunnel, file, ["tunnel", "--no-autoupdate", "--config", config, "--url", this.origin()], (text) => {
170
+ const url = text.match(URL_PATTERN)?.[0];
171
+ if (url)
172
+ Object.assign(tunnel, { status: "up", url, publicHost: new URL(url).hostname });
173
+ });
174
+ }
175
+ /** Logs in if needed, creates the mapping's own named tunnel and points the hostname's DNS at it, then runs it. */
176
+ startCustom(file, host, domain, tunnel) {
177
+ const name = `locadot-${host.replace(/[^a-z0-9-]/gi, "-")}`;
178
+ tunnel.publicHost = domain;
179
+ (0, hub_tunnel_1.setupNamedTunnel)(domain, name, (loginUrl) => {
180
+ if (this.tunnels.get(host) === tunnel)
181
+ Object.assign(tunnel, { status: "login", loginUrl });
182
+ })
183
+ .then(() => {
184
+ if (this.tunnels.get(host) !== tunnel)
185
+ return;
186
+ Object.assign(tunnel, { status: "starting", loginUrl: undefined });
187
+ this.run(host, tunnel, file, ["tunnel", "--no-autoupdate", "run", "--url", this.origin(), name], (text) => {
188
+ if (REGISTERED_PATTERN.test(text))
189
+ Object.assign(tunnel, { status: "up", url: `https://${domain}` });
190
+ });
191
+ })
192
+ .catch((error) => {
193
+ if (this.tunnels.get(host) !== tunnel)
194
+ return;
195
+ Object.assign(tunnel, { status: "error", error: error?.message || String(error), loginUrl: undefined });
196
+ logger_1.default.warn(`🌍 tunnel for ${host} failed: ${tunnel.error}`);
161
197
  });
198
+ }
199
+ run(host, tunnel, file, args, onData) {
200
+ const child = (0, child_process_1.spawn)(file, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
162
201
  tunnel.child = child;
163
202
  let tail = "";
164
203
  const read = (chunk) => {
165
204
  const text = chunk.toString("utf8");
166
205
  tail = (tail + text).slice(-2000);
167
- const url = text.match(URL_PATTERN)?.[0];
168
- if (url && tunnel.status !== "up") {
169
- Object.assign(tunnel, { status: "up", url, publicHost: new URL(url).hostname });
170
- logger_1.default.info(`🌍 ${host} is public at ${url}`);
171
- }
206
+ if (tunnel.status === "up")
207
+ return;
208
+ onData(text);
209
+ if (tunnel.status === "up")
210
+ logger_1.default.info(`🌍 ${host} is public at ${tunnel.url}`);
172
211
  };
173
212
  child.stdout?.on("data", read);
174
213
  child.stderr?.on("data", read);
@@ -177,17 +216,18 @@ class TunnelManager {
177
216
  if (this.tunnels.get(host) !== tunnel)
178
217
  return;
179
218
  const reason = tail.split(/\r?\n/).filter((line) => /ERR|error|failed/i.test(line)).pop()?.trim();
180
- Object.assign(tunnel, { status: "error", error: reason || `cloudflared exited (${code})`, url: undefined, publicHost: undefined, child: undefined });
219
+ Object.assign(tunnel, { status: "error", error: reason || `cloudflared exited (${code})`, url: undefined, child: undefined });
220
+ if (!tunnel.domain)
221
+ tunnel.publicHost = undefined;
181
222
  logger_1.default.warn(`🌍 tunnel for ${host} stopped: ${tunnel.error}`);
182
223
  });
183
224
  }
184
225
  stop(host) {
185
226
  const tunnel = this.tunnels.get(host);
186
227
  this.tunnels.delete(host);
187
- if (tunnel?.child) {
188
- tunnel.child.kill();
228
+ if (tunnel?.child || tunnel?.status === "login")
189
229
  logger_1.default.info(`🌍 ${host} is no longer public`);
190
- }
230
+ tunnel?.child?.kill();
191
231
  }
192
232
  stopAll() {
193
233
  for (const host of [...this.tunnels.keys()])
@@ -67,6 +67,7 @@ async function startCentralProxy() {
67
67
  stats,
68
68
  lookup: (host) => registry.get().hosts[tunnels.hostFor(host) ?? host],
69
69
  tunnelFor: (host) => tunnels.hostFor(host),
70
+ ownPorts: () => [info.httpPort, info.httpsPort],
70
71
  remoteFor: remote_1.remoteFor,
71
72
  localFor: remote_1.localFor,
72
73
  remoteHosts: (name) => Object.entries(registry.get().hosts)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
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",