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.
@@ -0,0 +1,215 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.scopeOf = exports.LOGIN_PATH = void 0;
40
+ exports.guard = guard;
41
+ exports.guardUpgrade = guardUpgrade;
42
+ const crypto_1 = __importDefault(require("crypto"));
43
+ const logger_1 = __importDefault(require("../utils/logger"));
44
+ const escape_1 = require("../dashboard/escape");
45
+ const host_auth_1 = __importStar(require("../lib/host-auth"));
46
+ const ui_auth_1 = require("../lib/ui-auth");
47
+ const passthrough_1 = require("./passthrough");
48
+ const request_1 = require("./request");
49
+ const hub_1 = require("./hub");
50
+ /**
51
+ * Password protection for a mapping (`protect`), on the paths in it was picked for. Visitors get a sign-in page and,
52
+ * with the password, a cookie signed for that host; the cookie is taken off before the request goes upstream.
53
+ */
54
+ exports.LOGIN_PATH = `${passthrough_1.PREFIX}login`;
55
+ const MAX_BODY = 4096;
56
+ const scopeOf = (req) => ((0, request_1.fromTunnel)(req) ? "shared" : (0, request_1.fromRemote)(req) ? "remote" : "local");
57
+ exports.scopeOf = scopeOf;
58
+ const protectedHere = (req, entry) => Boolean(entry.protect?.scopes.includes((0, exports.scopeOf)(req)));
59
+ const signedIn = (req, host) => host_auth_1.default.validSession(host, (0, ui_auth_1.cookieOf)(req.headers.cookie, host_auth_1.HOST_COOKIE));
60
+ const stripCookie = (req) => {
61
+ const header = req.headers.cookie;
62
+ if (!header)
63
+ return;
64
+ const rest = header.split(";").filter((part) => part.split("=")[0].trim() !== host_auth_1.HOST_COOKIE).join(";").trim();
65
+ if (rest)
66
+ req.headers.cookie = rest;
67
+ else
68
+ delete req.headers.cookie;
69
+ };
70
+ // Visitors on a tunnel or remote access all arrive from cloudflared, so count their failures by their own IP.
71
+ const limitKey = (req, host) => `${host}|${(0, exports.scopeOf)(req) === "local" ? req.socket.remoteAddress || "" : (0, hub_1.clientIp)(req)}`;
72
+ /** A path on this site to go back to after signing in; anything else goes to /. */
73
+ const safeNext = (value) => (typeof value === "string" && /^\/(?![/\\])/.test(value) ? value : "/");
74
+ const shownHost = (req) => (req.headers.host || "").replace(/:\d+$/, "") || "this site";
75
+ const page = (nonce, host, next, error) => `<!doctype html>
76
+ <html lang="en">
77
+ <head>
78
+ <meta charset="utf-8">
79
+ <meta name="viewport" content="width=device-width, initial-scale=1">
80
+ <meta name="color-scheme" content="dark light">
81
+ <meta name="robots" content="noindex">
82
+ <title>Password required ยท ${(0, escape_1.escapeHtml)(host)}</title>
83
+ <style nonce="${nonce}">
84
+ :root { --bg: #f6f7f9; --surface: #fff; --border: #e3e6eb; --fg: #111827; --muted: #6b7280; --accent: #2563eb; --accent-bg: #eff4ff; --down: #dc2626; --down-bg: #fef2f2; }
85
+ @media (prefers-color-scheme: dark) { :root { --bg: #0d1117; --surface: #161b22; --border: #30363d; --fg: #e6edf3; --muted: #8b949e; --accent: #3b82f6; --accent-bg: #172554; --down: #f87171; --down-bg: #2a1215; } }
86
+ * { box-sizing: border-box; }
87
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 16px; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
88
+ form { width: 100%; max-width: 360px; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 32px 28px 24px; display: flex; flex-direction: column; gap: 16px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.06); }
89
+ .icon { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; background: var(--accent-bg); color: var(--accent); }
90
+ .icon svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
91
+ h1 { margin: 0; font-size: 18px; }
92
+ p { margin: 0; color: var(--muted); }
93
+ .host { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--fg); overflow-wrap: anywhere; }
94
+ label { display: block; font-weight: 600; font-size: 13px; margin-bottom: 6px; }
95
+ input { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font: inherit; }
96
+ input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
97
+ button { padding: 10px; border: 0; border-radius: 8px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
98
+ button:hover { filter: brightness(1.05); }
99
+ .error { color: var(--down); background: var(--down-bg); border-radius: 8px; padding: 8px 10px; font-size: 13px; }
100
+ .foot { font-size: 12px; text-align: center; }
101
+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <form method="post" action="${exports.LOGIN_PATH}">
106
+ <div class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></div>
107
+ <div>
108
+ <h1>Password required</h1>
109
+ <p><span class="host">${(0, escape_1.escapeHtml)(host)}</span> is protected. Enter the password to continue.</p>
110
+ </div>
111
+ <input type="hidden" name="next" value="${(0, escape_1.escapeHtml)(next)}">
112
+ <input class="sr" type="text" name="username" value="${(0, escape_1.escapeHtml)(host)}" autocomplete="username" tabindex="-1" aria-hidden="true" readonly>
113
+ <div><label for="password">Password</label>
114
+ <input id="password" name="password" type="password" autocomplete="current-password" required autofocus></div>
115
+ ${error ? `<div class="error" role="alert">${(0, escape_1.escapeHtml)(error)}</div>` : ""}
116
+ <button type="submit">Continue</button>
117
+ <p class="foot">Protected by locadot</p>
118
+ </form>
119
+ </body>
120
+ </html>
121
+ `;
122
+ const sendPage = (req, res, status, next, error) => {
123
+ const nonce = crypto_1.default.randomBytes(16).toString("base64");
124
+ res.writeHead(status, {
125
+ "Content-Type": "text/html; charset=utf-8",
126
+ "Cache-Control": "no-store",
127
+ "Content-Security-Policy": `default-src 'none'; style-src 'nonce-${nonce}'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
128
+ "X-Robots-Tag": "noindex",
129
+ });
130
+ res.end(req.method === "HEAD" ? undefined : page(nonce, shownHost(req), next, error));
131
+ };
132
+ const readForm = (req) => new Promise((resolve, reject) => {
133
+ let size = 0;
134
+ const chunks = [];
135
+ req.on("data", (chunk) => {
136
+ size += chunk.length;
137
+ if (size <= MAX_BODY)
138
+ chunks.push(chunk);
139
+ });
140
+ req.on("error", reject);
141
+ req.on("end", () => resolve(new URLSearchParams(size > MAX_BODY ? "" : Buffer.concat(chunks).toString("utf8"))));
142
+ });
143
+ // Tunnel visitors are on https even though cloudflared talks plain http to us.
144
+ const cookie = (req, value) => `${host_auth_1.HOST_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${host_auth_1.HOST_SESSION_TTL_SEC}${(0, request_1.isTls)(req) || (0, request_1.fromTunnel)(req) ? "; Secure" : ""}`;
145
+ function login(req, res, host, url) {
146
+ if (req.method === "GET" || req.method === "HEAD") {
147
+ sendPage(req, res, 200, safeNext(url.searchParams.get("next")));
148
+ return;
149
+ }
150
+ if (req.method !== "POST") {
151
+ res.writeHead(405, { Allow: "GET, POST" });
152
+ res.end();
153
+ return;
154
+ }
155
+ const site = req.headers["sec-fetch-site"];
156
+ if (site && site !== "same-origin" && site !== "none") {
157
+ sendPage(req, res, 403, "/", "Sign in from this site's own page.");
158
+ return;
159
+ }
160
+ const key = limitKey(req, host);
161
+ if (host_auth_1.default.limited(key)) {
162
+ sendPage(req, res, 429, "/", "Too many wrong passwords. Wait a few minutes and try again.");
163
+ return;
164
+ }
165
+ readForm(req)
166
+ .then((form) => {
167
+ const next = safeNext(form.get("next"));
168
+ if (!host_auth_1.default.verify(host, form.get("password"))) {
169
+ host_auth_1.default.fail(key);
170
+ logger_1.default.warn(`๐Ÿ”’ ${host}: wrong password from ${key.split("|")[1]}`);
171
+ return sendPage(req, res, 401, next, "Wrong password.");
172
+ }
173
+ host_auth_1.default.succeed(key);
174
+ res.writeHead(303, { Location: next, "Set-Cookie": cookie(req, host_auth_1.default.issue(host)), "Cache-Control": "no-store" });
175
+ res.end();
176
+ })
177
+ .catch(() => sendPage(req, res, 400, "/", "Couldn't read the form."));
178
+ }
179
+ /** Answers the request itself (sign-in page, 401) unless it may go through; returns whether it did. */
180
+ function guard(req, res, host, entry) {
181
+ if (!protectedHere(req, entry))
182
+ return false;
183
+ const url = new URL(req.url || "/", "http://host.invalid");
184
+ if (!host_auth_1.default.has(host)) {
185
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
186
+ res.end(`locadot: ${host} is password protected but has no password set. Set one: locadot protect --host ${host}\n`);
187
+ return true;
188
+ }
189
+ if (url.pathname === exports.LOGIN_PATH) {
190
+ login(req, res, host, url);
191
+ return true;
192
+ }
193
+ if (signedIn(req, host)) {
194
+ stripCookie(req);
195
+ return false;
196
+ }
197
+ const html = (req.method === "GET" || req.method === "HEAD") && /text\/html|\*\/\*/.test(String(req.headers.accept || ""));
198
+ if (html) {
199
+ sendPage(req, res, 401, safeNext(req.url));
200
+ return true;
201
+ }
202
+ res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
203
+ res.end(`locadot: ${shownHost(req)} is password protected. Open it in a browser to sign in.\n`);
204
+ return true;
205
+ }
206
+ function guardUpgrade(req, socket, host, entry) {
207
+ if (!protectedHere(req, entry))
208
+ return false;
209
+ if (host_auth_1.default.has(host) && signedIn(req, host)) {
210
+ stripCookie(req);
211
+ return false;
212
+ }
213
+ socket.end("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
214
+ return true;
215
+ }
@@ -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,9 +15,11 @@ 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
+ const protect_1 = require("./protect");
20
23
  // Cloudflare swaps an origin's 502/504 for its own "bad gateway" page, so through a tunnel say 503 and the page survives.
21
24
  const downStatus = (req) => ((0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req) ? 503 : 502);
22
25
  /** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
@@ -55,8 +58,23 @@ const peerMayReach = (req, via) => {
55
58
  const port = Number(via.host.match(/:(\d+)$/)?.[1] || (via.scheme === "https" || via.scheme === "wss" ? 443 : 80));
56
59
  return Boolean(allowed && !allowed.includes(port));
57
60
  };
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);
61
+ /**
62
+ * An address on the mapping's `allow` list. The proxy's own ports on this machine never are (the dashboard is behind
63
+ * them): "not-self" means the name must be checked at connect time, on the address actually dialled.
64
+ */
65
+ const listed = (req, ctx, entry, via) => {
66
+ if (!((0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req)) || !(0, allow_1.allowMatches)(entry.allow, via.host, via.scheme))
67
+ return undefined;
68
+ const { host, port } = (0, allow_1.splitHost)(via.host, via.scheme);
69
+ if (!(ctx.ownPorts?.() ?? []).includes(port))
70
+ return "open";
71
+ return (0, guard_1.isSelfAddress)(host) ? undefined : net_1.default.isIP(host) ? "open" : "not-self";
72
+ };
73
+ /**
74
+ * Same-origin only, a tunnel visitor may only reach public hosts (see guard.ts), and a peer only its share of localhost,
75
+ * unless the mapping's owner listed the address in `allow`.
76
+ */
77
+ 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
78
  /**
61
79
  * Receiver side: `<sender host>=<our URL for it>` for every mapping of this remote, so a --cors sender rewrites
62
80
  * its apps' origins to the names this machine uses rather than its own.
@@ -135,10 +153,14 @@ function handleRequest(req, res, ctx) {
135
153
  res.end(passthrough_1.shimScript);
136
154
  return;
137
155
  }
156
+ if ((0, protect_1.guard)(req, res, host, entry))
157
+ return;
138
158
  const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
139
- if (via && !viaAllowed(req, via)) {
159
+ const allowed = via && listed(req, ctx, entry, via);
160
+ if (via && !viaAllowed(req, via, allowed)) {
140
161
  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");
162
+ res.end(`locadot: the pass-through only serves the page's own requests, and only public hosts to shared visitors. ` +
163
+ `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
164
  return;
143
165
  }
144
166
  const started = Date.now();
@@ -148,7 +170,7 @@ function handleRequest(req, res, ctx) {
148
170
  let options = (0, options_1.proxyOptions)(req, entry, ctx.lookup);
149
171
  if (via) {
150
172
  (0, request_1.tag)(req, { via });
151
- options = (0, options_1.viaOptions)(req, entry, via);
173
+ options = (0, options_1.viaOptions)(req, entry, via, allowed);
152
174
  req.url = via.path;
153
175
  }
154
176
  const upstream = via ? (0, options_1.viaOrigin)(via) : entry.target;
@@ -251,15 +273,18 @@ function handleUpgrade(req, socket, head, ctx) {
251
273
  });
252
274
  return;
253
275
  }
276
+ if ((0, protect_1.guardUpgrade)(req, socket, host, entry))
277
+ return;
254
278
  const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
255
- if (via && !viaAllowed(req, via)) {
279
+ const allowed = via && listed(req, ctx, entry, via);
280
+ if (via && !viaAllowed(req, via, allowed)) {
256
281
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
257
282
  return;
258
283
  }
259
284
  logger_1.default.debug(`WebSocket upgrade for ${host}`);
260
285
  let options = (0, options_1.proxyOptions)(req, entry, ctx.lookup);
261
286
  if (via) {
262
- options = (0, options_1.viaOptions)(req, entry, via);
287
+ options = (0, options_1.viaOptions)(req, entry, via, allowed);
263
288
  req.url = via.path;
264
289
  }
265
290
  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 cloudflare_logins_1 = require("./cloudflare-logins");
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 });
@@ -34,14 +37,17 @@ exports.cloudflaredPath = cloudflaredPath;
34
37
  // The dashboard polls status every few seconds; don't spawn cloudflared --version each time.
35
38
  let cached;
36
39
  const cloudflaredInfo = (fresh = false) => {
40
+ // Logins change when a share signs in, and reading them is cheap; only the version check is cached.
41
+ const zones = (0, cloudflare_logins_1.loggedInZones)();
42
+ const loggedIn = zones.length > 0 || fs_1.default.existsSync(path_1.default.join(os_1.default.homedir(), ".cloudflared", "cert.pem"));
37
43
  if (!fresh && cached && Date.now() - cached.at < 60000)
38
- return cached.info;
44
+ return { ...cached.info, loggedIn, zones };
39
45
  const file = (0, exports.cloudflaredPath)();
40
- let info = { installed: false };
46
+ let info = { installed: false, loggedIn, zones };
41
47
  if (file) {
42
48
  const result = (0, child_process_1.spawnSync)(file, ["--version"], { encoding: "utf8", timeout: 5000, windowsHide: true });
43
49
  const version = `${result.stdout || ""}${result.stderr || ""}`.match(/version\s+(\S+)/i)?.[1];
44
- info = { installed: result.status === 0, path: file, ...(version ? { version } : {}) };
50
+ info = { installed: result.status === 0, loggedIn, zones, path: file, ...(version ? { version } : {}) };
45
51
  }
46
52
  cached = { at: Date.now(), info };
47
53
  return info;
@@ -121,7 +127,7 @@ class TunnelManager {
121
127
  this.origin = origin;
122
128
  this.tunnels = new Map();
123
129
  }
124
- /** The mapping a public trycloudflare.com Host belongs to. */
130
+ /** The mapping a public Host (trycloudflare.com or a custom hostname) belongs to. */
125
131
  hostFor(publicHost) {
126
132
  for (const [host, tunnel] of this.tunnels)
127
133
  if (tunnel.publicHost === publicHost)
@@ -132,43 +138,80 @@ class TunnelManager {
132
138
  const tunnel = this.tunnels.get(host);
133
139
  if (!tunnel)
134
140
  return { enabled: false, status: "off" };
135
- const { child, publicHost, ...state } = tunnel;
141
+ const { child, publicHost, abort, ...state } = tunnel;
136
142
  return state;
137
143
  }
138
144
  /** Starts and stops tunnels to match the registry; `retry` also restarts ones that failed. */
139
145
  sync(hosts, retry = false) {
140
- for (const [host, tunnel] of [...this.tunnels])
141
- if (!hosts[host]?.tunnel || (retry && tunnel.status === "error"))
146
+ for (const [host, tunnel] of [...this.tunnels]) {
147
+ const entry = hosts[host];
148
+ if (!entry?.tunnel || (retry && tunnel.status === "error") || tunnel.domain !== (entry.tunnelDomain || undefined))
142
149
  this.stop(host);
150
+ }
143
151
  for (const [host, entry] of Object.entries(hosts))
144
152
  if (entry.tunnel && !this.tunnels.has(host))
145
- this.start(host);
153
+ this.start(host, entry.tunnelDomain);
146
154
  }
147
- start(host) {
155
+ start(host, domain) {
156
+ const mode = domain ? "custom" : "quick";
148
157
  const file = (0, exports.cloudflaredPath)();
149
158
  if (!file) {
150
- this.tunnels.set(host, { enabled: true, status: "error", error: "cloudflared is not installed. Run `locadot tunnel:install`." });
159
+ this.tunnels.set(host, { enabled: true, status: "error", mode, domain, error: "cloudflared is not installed. Run `locadot tunnel:install`." });
151
160
  return;
152
161
  }
153
- const tunnel = { enabled: true, status: "starting" };
162
+ const tunnel = { enabled: true, status: "starting", mode, domain };
154
163
  this.tunnels.set(host, tunnel);
164
+ if (domain) {
165
+ this.startCustom(file, host, domain, tunnel);
166
+ return;
167
+ }
155
168
  // An empty config file keeps a user's ~/.cloudflared/config.yml (named tunnels) out of the way.
156
169
  const config = path_1.default.join(constants_1.default.paths.HOME, "cloudflared-quick.yml");
157
170
  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,
171
+ this.run(host, tunnel, file, ["tunnel", "--no-autoupdate", "--config", config, "--url", this.origin()], (text) => {
172
+ const url = text.match(URL_PATTERN)?.[0];
173
+ if (url)
174
+ Object.assign(tunnel, { status: "up", url, publicHost: new URL(url).hostname });
175
+ });
176
+ }
177
+ /** Logs in if needed, creates the mapping's own named tunnel and points the hostname's DNS at it, then runs it. */
178
+ startCustom(file, host, domain, tunnel) {
179
+ const name = `locadot-${host.replace(/[^a-z0-9-]/gi, "-")}`;
180
+ tunnel.publicHost = domain;
181
+ tunnel.abort = new AbortController();
182
+ (0, cloudflare_logins_1.setupDomainTunnel)(domain, name, (loginUrl) => {
183
+ if (this.tunnels.get(host) === tunnel)
184
+ Object.assign(tunnel, { status: "login", loginUrl });
185
+ }, tunnel.abort.signal)
186
+ .then(({ cert, credentials }) => {
187
+ if (this.tunnels.get(host) !== tunnel)
188
+ return;
189
+ Object.assign(tunnel, { status: "starting", loginUrl: undefined, abort: undefined });
190
+ const args = ["tunnel", "--no-autoupdate", "--origincert", cert, "run", ...(credentials ? ["--credentials-file", credentials] : []), "--url", this.origin(), name];
191
+ this.run(host, tunnel, file, args, (text) => {
192
+ if (REGISTERED_PATTERN.test(text))
193
+ Object.assign(tunnel, { status: "up", url: `https://${domain}` });
194
+ });
195
+ })
196
+ .catch((error) => {
197
+ if (this.tunnels.get(host) !== tunnel)
198
+ return;
199
+ Object.assign(tunnel, { status: "error", error: error?.message || String(error), loginUrl: undefined });
200
+ logger_1.default.warn(`๐ŸŒ tunnel for ${host} failed: ${tunnel.error}`);
161
201
  });
202
+ }
203
+ run(host, tunnel, file, args, onData) {
204
+ const child = (0, child_process_1.spawn)(file, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
162
205
  tunnel.child = child;
163
206
  let tail = "";
164
207
  const read = (chunk) => {
165
208
  const text = chunk.toString("utf8");
166
209
  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
- }
210
+ if (tunnel.status === "up")
211
+ return;
212
+ onData(text);
213
+ if (tunnel.status === "up")
214
+ logger_1.default.info(`๐ŸŒ ${host} is public at ${tunnel.url}`);
172
215
  };
173
216
  child.stdout?.on("data", read);
174
217
  child.stderr?.on("data", read);
@@ -177,17 +220,19 @@ class TunnelManager {
177
220
  if (this.tunnels.get(host) !== tunnel)
178
221
  return;
179
222
  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 });
223
+ Object.assign(tunnel, { status: "error", error: reason || `cloudflared exited (${code})`, url: undefined, child: undefined });
224
+ if (!tunnel.domain)
225
+ tunnel.publicHost = undefined;
181
226
  logger_1.default.warn(`๐ŸŒ tunnel for ${host} stopped: ${tunnel.error}`);
182
227
  });
183
228
  }
184
229
  stop(host) {
185
230
  const tunnel = this.tunnels.get(host);
186
231
  this.tunnels.delete(host);
187
- if (tunnel?.child) {
188
- tunnel.child.kill();
232
+ if (tunnel?.child || tunnel?.status === "login")
189
233
  logger_1.default.info(`๐ŸŒ ${host} is no longer public`);
190
- }
234
+ tunnel?.child?.kill();
235
+ tunnel?.abort?.abort();
191
236
  }
192
237
  stopAll() {
193
238
  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.4.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",