locadot 2.0.0 → 2.1.0-beta.1
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 +39 -0
- package/README.md +52 -1
- package/dist/cli/commands/index.js +3 -1
- package/dist/cli/commands/remote.js +315 -0
- package/dist/cli/commands/tunnel.js +2 -1
- package/dist/cli/commands/ui.js +94 -0
- package/dist/constants/index.js +23 -0
- package/dist/constants/template.js +24 -1
- package/dist/dashboard/api.js +381 -1
- package/dist/dashboard/index.js +94 -3
- package/dist/dashboard/login.js +228 -0
- package/dist/dashboard/page.js +1814 -103
- package/dist/index.js +102 -1
- package/dist/lib/hub-config.js +46 -0
- package/dist/lib/links.js +163 -0
- package/dist/lib/localhost.js +2 -2
- package/dist/lib/proxy-control.js +2 -1
- package/dist/lib/remotes.js +370 -0
- package/dist/lib/ui-auth.js +121 -0
- package/dist/lib/words.js +32 -0
- package/dist/proxy/hub-tunnel.js +241 -0
- package/dist/proxy/hub.js +302 -0
- package/dist/proxy/remote.js +91 -0
- package/dist/proxy/request.js +4 -2
- package/dist/proxy/router.js +126 -5
- package/dist/server/index.js +51 -3
- package/dist/utils/startup.js +2 -2
- package/package.json +1 -1
package/dist/proxy/router.js
CHANGED
|
@@ -5,10 +5,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.handleRequest = handleRequest;
|
|
7
7
|
exports.handleUpgrade = handleUpgrade;
|
|
8
|
+
const http_1 = __importDefault(require("http"));
|
|
8
9
|
const template_1 = require("../constants/template");
|
|
9
10
|
const constants_1 = __importDefault(require("../constants"));
|
|
10
11
|
const logger_1 = __importDefault(require("../utils/logger"));
|
|
11
12
|
const urls_1 = require("../lib/urls");
|
|
13
|
+
const remote_1 = require("./remote");
|
|
12
14
|
const cors_1 = require("./cors");
|
|
13
15
|
const options_1 = require("./options");
|
|
14
16
|
const guard_1 = require("./guard");
|
|
@@ -16,7 +18,16 @@ const passthrough_1 = require("./passthrough");
|
|
|
16
18
|
const request_1 = require("./request");
|
|
17
19
|
const stats_1 = require("./stats");
|
|
18
20
|
/** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
|
|
19
|
-
const resolveHost = (req, ctx) => {
|
|
21
|
+
const resolveHost = (req, ctx, hub) => {
|
|
22
|
+
if (hub?.kind === "app") {
|
|
23
|
+
(0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure() });
|
|
24
|
+
return hub.host;
|
|
25
|
+
}
|
|
26
|
+
if (hub?.kind === "local") {
|
|
27
|
+
const host = `localhost:${hub.port}`;
|
|
28
|
+
(0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure() });
|
|
29
|
+
return host;
|
|
30
|
+
}
|
|
20
31
|
const host = (0, request_1.hostOf)(req);
|
|
21
32
|
const local = ctx.tunnelFor?.(host);
|
|
22
33
|
if (local)
|
|
@@ -28,19 +39,62 @@ const passThrough = (req, entry) => Boolean(entry.cors);
|
|
|
28
39
|
/** Same-origin only, and a tunnel visitor may only reach public hosts (see guard.ts). */
|
|
29
40
|
const viaAllowed = (req, via) => (0, passthrough_1.isSameOrigin)(req) && (!(0, request_1.fromTunnel)(req) || (0, guard_1.isPublicHost)(via.host));
|
|
30
41
|
const reason = (err) => err?.code || err?.message;
|
|
42
|
+
const remoteGone = (entry) => `the connection to ${entry.remote.name} was removed`;
|
|
43
|
+
/** Sender side: an admin peer reaching one of this machine's ports. */
|
|
44
|
+
const localEntry = (port) => ({ target: `http://localhost:${port}`, createdAt: "", updatedAt: "" });
|
|
45
|
+
const html = (res, status, body) => {
|
|
46
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
47
|
+
res.end(body);
|
|
48
|
+
};
|
|
49
|
+
/** `<port>.<domain>.localhost` on this proxy's scheme and port. */
|
|
50
|
+
const portUrl = (req, domain) => {
|
|
51
|
+
const port = (req.headers.host || "").replace(/^\[[^\]]*\]|^[^:]*/, "");
|
|
52
|
+
return `${(0, request_1.isTls)(req) ? "https" : "http"}://PORT.${domain}.localhost${/^:\d{1,5}$/.test(port) ? port : ""}/`;
|
|
53
|
+
};
|
|
54
|
+
const loopback = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
55
|
+
/**
|
|
56
|
+
* `<domain>.localhost` spends this machine's admin token, so only this machine's own browser may use it: tunnels
|
|
57
|
+
* (cloudflared connects from loopback but adds Cf-* headers), hub peers and LAN clients all share the listener.
|
|
58
|
+
*/
|
|
59
|
+
const localTarget = (req, ctx, host, hub) => {
|
|
60
|
+
if ((hub && hub.kind !== "none") || (0, request_1.fromTunnel)(req) || !loopback.has(req.socket.remoteAddress || ""))
|
|
61
|
+
return undefined;
|
|
62
|
+
if (req.headers["cf-ray"] || req.headers["cf-connecting-ip"] || req.headers["cdn-loop"])
|
|
63
|
+
return undefined;
|
|
64
|
+
return ctx.localFor?.(host);
|
|
65
|
+
};
|
|
31
66
|
function handleRequest(req, res, ctx) {
|
|
32
|
-
const
|
|
67
|
+
const hub = ctx.hub?.classify(req);
|
|
68
|
+
if (hub?.kind === "api") {
|
|
69
|
+
ctx.hub.api(req, res);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (hub?.kind === "deny") {
|
|
73
|
+
res.writeHead(hub.status, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
|
|
74
|
+
res.end(`locadot: ${hub.message}\n`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const host = resolveHost(req, ctx, hub);
|
|
33
78
|
try {
|
|
34
79
|
if (constants_1.default.dashboardHosts.includes(host)) {
|
|
35
80
|
ctx.dashboard(req, res);
|
|
36
81
|
return;
|
|
37
82
|
}
|
|
38
|
-
const entry = ctx.lookup(host);
|
|
83
|
+
const entry = hub?.kind === "local" ? localEntry(hub.port) : ctx.lookup(host);
|
|
39
84
|
if (!entry) {
|
|
85
|
+
const local = localTarget(req, ctx, host, hub);
|
|
86
|
+
if (local) {
|
|
87
|
+
forwardLocal(req, res, ctx, host, local);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
40
90
|
res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
41
91
|
res.end((0, template_1.proxyNotFound)(host, dashboardUrl(req)));
|
|
42
92
|
return;
|
|
43
93
|
}
|
|
94
|
+
if (entry.remote) {
|
|
95
|
+
forwardRemote(req, res, ctx, host, entry);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
44
98
|
// Answered here: upstreams often reject OPTIONS or don't send the headers the browser wants.
|
|
45
99
|
if (entry.cors && (0, cors_1.isPreflight)(req)) {
|
|
46
100
|
res.writeHead(204, (0, cors_1.preflightHeaders)(req));
|
|
@@ -87,15 +141,82 @@ function handleRequest(req, res, ctx) {
|
|
|
87
141
|
res.end();
|
|
88
142
|
}
|
|
89
143
|
}
|
|
144
|
+
/** Receiver side: the sender does the routing, cors and rewriting; we only add the credentials. */
|
|
145
|
+
function forwardRemote(req, res, ctx, host, entry) {
|
|
146
|
+
const remote = ctx.remoteFor?.(entry.remote.name);
|
|
147
|
+
if (!remote) {
|
|
148
|
+
res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
149
|
+
res.end((0, template_1.upstreamDown)(host, entry.target, remoteGone(entry), dashboardUrl(req)));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const started = Date.now();
|
|
153
|
+
res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
|
|
154
|
+
ctx.proxy.web(req, res, (0, remote_1.remoteOptions)(req, entry, remote), (err) => {
|
|
155
|
+
logger_1.default.warn(`${host} → ${remote.name} (${remote.url}): ${reason(err)}`);
|
|
156
|
+
if (res.headersSent) {
|
|
157
|
+
res.destroy();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
161
|
+
res.end((0, template_1.upstreamDown)(host, `${remote.name}: ${entry.remote.host}`, reason(err) || "error", dashboardUrl(req)));
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** Receiver side: the sender checks the role again and proxies to its own localhost:<port>. */
|
|
165
|
+
function forwardLocal(req, res, ctx, host, local) {
|
|
166
|
+
const { remote, domain, port } = local;
|
|
167
|
+
const allowed = (0, remote_1.canReachLocalhost)(remote);
|
|
168
|
+
if (!port || !allowed) {
|
|
169
|
+
const hosts = (ctx.remoteHosts?.(remote.name) || []).map((h) => ({ local: h, url: `${(0, urls_1.urlFor)(h, (0, request_1.isTls)(req))}/` }));
|
|
170
|
+
const body = (0, template_1.remoteLocalhost)({ domain, name: remote.name, sender: remote.sender.hostname, allowed, hosts, portUrl: portUrl(req, domain), dashboardUrl: dashboardUrl(req) });
|
|
171
|
+
html(res, port ? 403 : 200, body);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const started = Date.now();
|
|
175
|
+
res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
|
|
176
|
+
ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port), (err) => {
|
|
177
|
+
logger_1.default.warn(`${host} → ${remote.name} localhost:${port}: ${reason(err)}`);
|
|
178
|
+
if (res.headersSent) {
|
|
179
|
+
res.destroy();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
html(res, 502, (0, template_1.upstreamDown)(host, `${remote.name}: localhost:${port}`, reason(err) || "error", dashboardUrl(req)));
|
|
183
|
+
});
|
|
184
|
+
}
|
|
90
185
|
function handleUpgrade(req, socket, head, ctx) {
|
|
91
|
-
const host = resolveHost(req, ctx);
|
|
92
186
|
socket.on("error", () => socket.destroy());
|
|
187
|
+
const hub = ctx.hub?.classify(req);
|
|
188
|
+
if (hub?.kind === "api" || hub?.kind === "deny") {
|
|
189
|
+
const status = hub.kind === "deny" ? hub.status : 400;
|
|
190
|
+
socket.end(`HTTP/1.1 ${status} ${http_1.default.STATUS_CODES[status] || "Error"}\r\nConnection: close\r\n\r\n`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const host = resolveHost(req, ctx, hub);
|
|
93
194
|
try {
|
|
94
|
-
const entry = ctx.lookup(host);
|
|
195
|
+
const entry = hub?.kind === "local" ? localEntry(hub.port) : ctx.lookup(host);
|
|
95
196
|
if (!entry) {
|
|
197
|
+
const local = localTarget(req, ctx, host, hub);
|
|
198
|
+
if (local?.port && (0, remote_1.canReachLocalhost)(local.remote)) {
|
|
199
|
+
ctx.proxy.ws(req, socket, head, (0, remote_1.localOptions)(req, local.remote, local.port), (err) => {
|
|
200
|
+
logger_1.default.warn(`${host} WebSocket → ${local.remote.name} localhost:${local.port}: ${reason(err)}`);
|
|
201
|
+
socket.destroy();
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
96
205
|
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
|
|
97
206
|
return;
|
|
98
207
|
}
|
|
208
|
+
if (entry.remote) {
|
|
209
|
+
const remote = ctx.remoteFor?.(entry.remote.name);
|
|
210
|
+
if (!remote) {
|
|
211
|
+
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
ctx.proxy.ws(req, socket, head, (0, remote_1.remoteOptions)(req, entry, remote), (err) => {
|
|
215
|
+
logger_1.default.warn(`${host} WebSocket → ${remote.name}: ${reason(err)}`);
|
|
216
|
+
socket.destroy();
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
99
220
|
const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
|
|
100
221
|
if (via && !viaAllowed(req, via)) {
|
|
101
222
|
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
package/dist/server/index.js
CHANGED
|
@@ -13,6 +13,10 @@ const localhost_1 = __importDefault(require("../lib/localhost"));
|
|
|
13
13
|
const router_1 = require("../proxy/router");
|
|
14
14
|
const response_1 = require("../proxy/response");
|
|
15
15
|
const tunnel_1 = require("../proxy/tunnel");
|
|
16
|
+
const hub_tunnel_1 = require("../proxy/hub-tunnel");
|
|
17
|
+
const hub_1 = require("../proxy/hub");
|
|
18
|
+
const remote_1 = require("../proxy/remote");
|
|
19
|
+
const hub_config_1 = __importDefault(require("../lib/hub-config"));
|
|
16
20
|
const file_1 = __importDefault(require("../utils/file"));
|
|
17
21
|
const logger_1 = __importDefault(require("../utils/logger"));
|
|
18
22
|
const certs_1 = require("../utils/certs");
|
|
@@ -24,10 +28,20 @@ const lifecycle_1 = require("./lifecycle");
|
|
|
24
28
|
const state_1 = require("./state");
|
|
25
29
|
const token_1 = require("./token");
|
|
26
30
|
async function startCentralProxy() {
|
|
27
|
-
const
|
|
31
|
+
const origin = () => {
|
|
28
32
|
const host = constants_1.default.server.bind.find((address) => !address.includes(":")) ? "127.0.0.1" : "[::1]";
|
|
29
33
|
return `http://${host}:${constants_1.default.server.httpPort}`;
|
|
30
|
-
}
|
|
34
|
+
};
|
|
35
|
+
const tunnels = new tunnel_1.TunnelManager(origin);
|
|
36
|
+
const hub = new hub_tunnel_1.HubTunnel(origin);
|
|
37
|
+
const reloadHub = () => {
|
|
38
|
+
try {
|
|
39
|
+
hub.sync(hub_config_1.default.read());
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
logger_1.default.error(error);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
31
45
|
const registry = (0, state_1.createRegistryState)((hosts) => tunnels.sync(hosts));
|
|
32
46
|
registry.reload();
|
|
33
47
|
const stats = new Map();
|
|
@@ -53,6 +67,27 @@ async function startCentralProxy() {
|
|
|
53
67
|
stats,
|
|
54
68
|
lookup: (host) => registry.get().hosts[tunnels.hostFor(host) ?? host],
|
|
55
69
|
tunnelFor: (host) => tunnels.hostFor(host),
|
|
70
|
+
remoteFor: remote_1.remoteFor,
|
|
71
|
+
localFor: remote_1.localFor,
|
|
72
|
+
remoteHosts: (name) => Object.entries(registry.get().hosts)
|
|
73
|
+
.filter(([, entry]) => entry.remote?.name === name)
|
|
74
|
+
.map(([host]) => host)
|
|
75
|
+
.sort(),
|
|
76
|
+
hub: {
|
|
77
|
+
classify: (req) => (0, hub_1.classify)(req, hub.publicHost(), registry.get().hosts, {
|
|
78
|
+
localhost: hub_config_1.default.readCached()?.localhost !== false,
|
|
79
|
+
blockedPorts: [info.httpPort, info.httpsPort],
|
|
80
|
+
}),
|
|
81
|
+
api: (req, res) => {
|
|
82
|
+
(0, hub_1.handleHubApi)(req, res, { getRegistry: registry.get, reload: registry.reload, retryTunnels: () => tunnels.sync(registry.get().hosts, true) }).catch((error) => {
|
|
83
|
+
logger_1.default.error(error);
|
|
84
|
+
if (!res.headersSent)
|
|
85
|
+
res.writeHead(500);
|
|
86
|
+
res.end();
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
secure: () => hub.state().url?.startsWith("https:") ?? false,
|
|
90
|
+
},
|
|
56
91
|
dashboard: (req, res) => (0, dashboard_1.handleDashboardRequest)(req, res, {
|
|
57
92
|
getRegistry: registry.get,
|
|
58
93
|
getStats: () => Object.fromEntries(stats),
|
|
@@ -67,6 +102,11 @@ async function startCentralProxy() {
|
|
|
67
102
|
shutdown: (reason) => shutdown(reason),
|
|
68
103
|
tunnel: (host) => tunnels.state(host),
|
|
69
104
|
retryTunnels: () => tunnels.sync(registry.get().hosts, true),
|
|
105
|
+
hub: () => hub.state(),
|
|
106
|
+
reloadHub,
|
|
107
|
+
setupNamedHub: (domain, tunnel) => {
|
|
108
|
+
hub.setupNamed(domain, tunnel).catch((error) => logger_1.default.warn(`hub setup for ${domain}: ${error?.message || error}`));
|
|
109
|
+
},
|
|
70
110
|
}),
|
|
71
111
|
};
|
|
72
112
|
const tls = { ...(await (0, certs_1.defaultContext)()), SNICallback: (0, certs_1.createSNICallback)() };
|
|
@@ -78,9 +118,17 @@ async function startCentralProxy() {
|
|
|
78
118
|
return server;
|
|
79
119
|
}, info);
|
|
80
120
|
const watcher = file_1.default.watch("REGISTRY_FILE", registry.reload);
|
|
121
|
+
// After binding, so the hub tunnel points at the port we actually got.
|
|
122
|
+
reloadHub();
|
|
123
|
+
const hubWatcher = file_1.default.watch("HUB_FILE", reloadHub);
|
|
81
124
|
await refreshTrust();
|
|
82
125
|
setInterval(refreshTrust, 5 * 60000).unref();
|
|
83
126
|
locadot_file_1.default.writeProxyInfo(info);
|
|
84
|
-
shutdown = (0, lifecycle_1.installShutdown)({ watcher, servers, stopTunnels: () =>
|
|
127
|
+
shutdown = (0, lifecycle_1.installShutdown)({ watcher, servers, stopTunnels: () => {
|
|
128
|
+
tunnels.stopAll();
|
|
129
|
+
hub.stop();
|
|
130
|
+
hubWatcher.close();
|
|
131
|
+
},
|
|
132
|
+
});
|
|
85
133
|
logger_1.default.info(`☑️ locadot ${package_json_1.version} ready (pid ${process.pid})`);
|
|
86
134
|
}
|
package/dist/utils/startup.js
CHANGED
|
@@ -33,7 +33,7 @@ const launchArgs = () => {
|
|
|
33
33
|
* and node hidden too. cmd also appends the output to the log, like the cron line does.
|
|
34
34
|
*/
|
|
35
35
|
const windowsLauncherScript = (command, args) => {
|
|
36
|
-
const env = `set LOCADOT_HTTP_PORT=${constants_1.default.server.httpPort}&& set LOCADOT_HTTPS_PORT=${constants_1.default.server.httpsPort}&&`;
|
|
36
|
+
const env = `set LOCADOT_HTTP_PORT=${constants_1.default.server.httpPort}&& set LOCADOT_HTTPS_PORT=${constants_1.default.server.httpsPort}&& set LOCADOT_USER_PORTS=${constants_1.default.userPortEnv()}&&`;
|
|
37
37
|
const line = `cmd /d /c ${env} ${[command, ...args].map((a) => `"${a}"`).join(" ")} >> "${constants_1.default.paths.LOGS}" 2>&1`;
|
|
38
38
|
return `CreateObject("WScript.Shell").Run "${line.replace(/"/g, '""')}", 0, False\r\n`;
|
|
39
39
|
};
|
|
@@ -88,7 +88,7 @@ class Startup {
|
|
|
88
88
|
break;
|
|
89
89
|
}
|
|
90
90
|
case "linux": {
|
|
91
|
-
const env = `LOCADOT_HOME=${quote(constants_1.default.paths.HOME)} LOCADOT_HTTP_PORT=${constants_1.default.server.httpPort} LOCADOT_HTTPS_PORT=${constants_1.default.server.httpsPort}`;
|
|
91
|
+
const env = `LOCADOT_HOME=${quote(constants_1.default.paths.HOME)} LOCADOT_HTTP_PORT=${constants_1.default.server.httpPort} LOCADOT_HTTPS_PORT=${constants_1.default.server.httpsPort} LOCADOT_USER_PORTS=${constants_1.default.userPortEnv()}`;
|
|
92
92
|
const line = `@reboot ${env} ${[command, ...args].map(quote).join(" ")} >> ${quote(constants_1.default.paths.LOGS)} 2>&1 ${CRON_MARK}`;
|
|
93
93
|
const edit = cronEdit(line);
|
|
94
94
|
if (linuxNeedsRoot()) {
|
package/package.json
CHANGED