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/CHANGELOG.md +27 -0
- package/README.md +34 -2
- package/dist/cli/commands/hosts.js +29 -1
- package/dist/cli/commands/index.js +2 -1
- package/dist/cli/commands/protect.js +76 -0
- package/dist/cli/commands/tunnel.js +20 -3
- package/dist/cli/commands/ui.js +6 -3
- package/dist/constants/index.js +1 -0
- package/dist/dashboard/api.js +32 -5
- package/dist/dashboard/index.js +4 -0
- package/dist/dashboard/page.js +1176 -83
- package/dist/index.js +23 -2
- package/dist/lib/allow.js +60 -0
- package/dist/lib/host-auth.js +123 -0
- package/dist/lib/hosts.js +105 -4
- package/dist/proxy/cloudflare-logins.js +197 -0
- package/dist/proxy/guard.js +30 -1
- package/dist/proxy/options.js +10 -2
- package/dist/proxy/protect.js +215 -0
- package/dist/proxy/router.js +32 -7
- package/dist/proxy/tunnel.js +69 -24
- package/dist/server/index.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/proxy/router.js
CHANGED
|
@@ -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
|
-
/**
|
|
59
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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) => {
|
package/dist/proxy/tunnel.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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,
|
|
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()])
|
package/dist/server/index.js
CHANGED
|
@@ -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