locadot 2.3.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,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
@@ -42,6 +42,7 @@ const localhost_1 = __importStar(require("./localhost"));
42
42
  const registry_1 = __importDefault(require("./registry"));
43
43
  const allow_1 = require("./allow");
44
44
  const hub_config_1 = __importDefault(require("./hub-config"));
45
+ const host_auth_1 = __importStar(require("./host-auth"));
45
46
  class NotFoundError extends localhost_1.InputError {
46
47
  }
47
48
  exports.NotFoundError = NotFoundError;
@@ -141,6 +142,57 @@ class HostOps {
141
142
  });
142
143
  return { host, entry };
143
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() };
192
+ return registry.hosts[host];
193
+ });
194
+ return { host, entry };
195
+ }
144
196
  static async remove(input) {
145
197
  const host = localhost_1.default.requireHost(input.host);
146
198
  await registry_1.default.mutate((registry) => {
@@ -148,6 +200,7 @@ class HostOps {
148
200
  throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
149
201
  delete registry.hosts[host];
150
202
  });
203
+ host_auth_1.default.clear(host);
151
204
  return { host };
152
205
  }
153
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
+ }
@@ -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
+ }
@@ -19,6 +19,7 @@ const allow_1 = require("../lib/allow");
19
19
  const passthrough_1 = require("./passthrough");
20
20
  const request_1 = require("./request");
21
21
  const stats_1 = require("./stats");
22
+ const protect_1 = require("./protect");
22
23
  // Cloudflare swaps an origin's 502/504 for its own "bad gateway" page, so through a tunnel say 503 and the page survives.
23
24
  const downStatus = (req) => ((0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req) ? 503 : 502);
24
25
  /** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
@@ -152,6 +153,8 @@ function handleRequest(req, res, ctx) {
152
153
  res.end(passthrough_1.shimScript);
153
154
  return;
154
155
  }
156
+ if ((0, protect_1.guard)(req, res, host, entry))
157
+ return;
155
158
  const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
156
159
  const allowed = via && listed(req, ctx, entry, via);
157
160
  if (via && !viaAllowed(req, via, allowed)) {
@@ -270,6 +273,8 @@ function handleUpgrade(req, socket, head, ctx) {
270
273
  });
271
274
  return;
272
275
  }
276
+ if ((0, protect_1.guardUpgrade)(req, socket, host, entry))
277
+ return;
273
278
  const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
274
279
  const allowed = via && listed(req, ctx, entry, via);
275
280
  if (via && !viaAllowed(req, via, allowed)) {