locadot 2.4.0 → 2.6.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 +21 -0
- package/README.md +5 -1
- package/dist/cli/commands/hosts.js +20 -1
- package/dist/cli/commands/proxy.js +15 -4
- package/dist/dashboard/api.js +39 -28
- package/dist/dashboard/index.js +1 -0
- package/dist/dashboard/page.js +20 -4724
- package/dist/dashboard/ui/core.js +803 -0
- package/dist/dashboard/ui/hosts.js +1543 -0
- package/dist/dashboard/ui/remote.js +958 -0
- package/dist/dashboard/ui/shell.js +509 -0
- package/dist/dashboard/ui/system.js +781 -0
- package/dist/dashboard/ui/theme.js +1710 -0
- package/dist/index.js +5 -0
- package/dist/lib/config.js +46 -0
- package/dist/lib/hosts.js +13 -1
- package/dist/proxy/https-redirect.js +40 -0
- package/dist/proxy/router.js +5 -0
- package/dist/server/index.js +3 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -38,6 +38,7 @@ const destinationOptions = (command) => command
|
|
|
38
38
|
.option("-k, --insecure", "Don't verify the TLS certificate of an https target")
|
|
39
39
|
.option("--cors", "Send Origin/Referer as the target's own and let any origin call this domain")
|
|
40
40
|
.option("--no-cors", "Turn --cors off again (update)")
|
|
41
|
+
.option("--https-redirect <mode>", "Send plain http to https: on, off, or default (follow `locadot https:redirect`)")
|
|
41
42
|
.option("--no-start", "Only save the mapping; don't start the proxy");
|
|
42
43
|
program
|
|
43
44
|
.name("locadot")
|
|
@@ -106,6 +107,10 @@ const portOptions = (command) => command
|
|
|
106
107
|
portOptions(program.command("start").description("Start the central proxy")).action(run((options) => commands_1.default.start(options)));
|
|
107
108
|
program.command("stop").description("Stop the central proxy (keeps hosts and logs)").action(run(() => commands_1.default.stop()));
|
|
108
109
|
portOptions(program.command("restart").description("Restart the central proxy")).action(run((options) => commands_1.default.restart(options)));
|
|
110
|
+
program
|
|
111
|
+
.command("https:redirect [onOrOff]")
|
|
112
|
+
.description("Redirect plain http to https for every domain and the dashboard (on/off); shows the setting without an argument")
|
|
113
|
+
.action(run((value) => commands_1.default.httpsRedirect(value)));
|
|
109
114
|
program.command("kill").description("Stop the proxy, remove all hosts and clear logs").action(run(() => commands_1.default.kill()));
|
|
110
115
|
program.command("trust").description("Install the locadot CA in the system trust store (sudo)").action(run(() => commands_1.default.trust()));
|
|
111
116
|
program.command("untrust").description("Remove the locadot CA from the system trust store (sudo)").action(run(() => commands_1.default.untrust()));
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const constants_1 = __importDefault(require("../constants"));
|
|
8
|
+
const file_1 = __importDefault(require("../utils/file"));
|
|
9
|
+
let cached;
|
|
10
|
+
class ConfigStore {
|
|
11
|
+
/** The file as-is, tolerating a missing or corrupt one. */
|
|
12
|
+
static read() {
|
|
13
|
+
try {
|
|
14
|
+
const raw = JSON.parse(fs_1.default.readFileSync(constants_1.default.paths.CONFIG_FILE, "utf8"));
|
|
15
|
+
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Merges into the file, so saving the ports keeps the other settings and the other way round. */
|
|
22
|
+
static write(patch) {
|
|
23
|
+
file_1.default.ensureDir();
|
|
24
|
+
const next = { ...ConfigStore.read(), ...patch };
|
|
25
|
+
for (const key of Object.keys(next))
|
|
26
|
+
if (next[key] === undefined)
|
|
27
|
+
delete next[key];
|
|
28
|
+
fs_1.default.writeFileSync(constants_1.default.paths.CONFIG_FILE, JSON.stringify(next, null, 2) + "\n");
|
|
29
|
+
cached = undefined;
|
|
30
|
+
}
|
|
31
|
+
/** For the request path: re-reads only when the file changed. */
|
|
32
|
+
static readCached() {
|
|
33
|
+
let mtimeMs = -1;
|
|
34
|
+
try {
|
|
35
|
+
mtimeMs = fs_1.default.statSync(constants_1.default.paths.CONFIG_FILE).mtimeMs;
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
if (cached?.mtimeMs !== mtimeMs)
|
|
39
|
+
cached = { mtimeMs, config: mtimeMs < 0 ? {} : ConfigStore.read() };
|
|
40
|
+
return cached.config;
|
|
41
|
+
}
|
|
42
|
+
static httpsRedirect() {
|
|
43
|
+
return ConfigStore.readCached().httpsRedirect === true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
exports.default = ConfigStore;
|
package/dist/lib/hosts.js
CHANGED
|
@@ -79,7 +79,7 @@ class HostOps {
|
|
|
79
79
|
if (existing) {
|
|
80
80
|
throw new ConflictError(`❌ ${host} is already mapped to ${existing.target}. Use \`locadot update --host ${host} ...\` instead.`);
|
|
81
81
|
}
|
|
82
|
-
registry.hosts[host] = { target, insecure: input.insecure || undefined, cors: input.cors || undefined, allow: allow.length ? allow : undefined, createdAt: now, updatedAt: now };
|
|
82
|
+
registry.hosts[host] = { target, insecure: input.insecure || undefined, cors: input.cors || undefined, allow: allow.length ? allow : undefined, httpsRedirect: input.httpsRedirect ?? undefined, createdAt: now, updatedAt: now };
|
|
83
83
|
return registry.hosts[host];
|
|
84
84
|
});
|
|
85
85
|
return { host, entry };
|
|
@@ -101,12 +101,24 @@ class HostOps {
|
|
|
101
101
|
insecure: insecure || undefined,
|
|
102
102
|
cors: cors || undefined,
|
|
103
103
|
allow: list?.length ? list : undefined,
|
|
104
|
+
httpsRedirect: input.httpsRedirect === undefined ? existing.httpsRedirect : input.httpsRedirect ?? undefined,
|
|
104
105
|
updatedAt: new Date().toISOString(),
|
|
105
106
|
};
|
|
106
107
|
return registry.hosts[host];
|
|
107
108
|
});
|
|
108
109
|
return { host, entry };
|
|
109
110
|
}
|
|
111
|
+
static async setHttpsRedirect(input) {
|
|
112
|
+
const host = localhost_1.default.requireHost(input.host);
|
|
113
|
+
const entry = await registry_1.default.mutate((registry) => {
|
|
114
|
+
const existing = registry.hosts[host];
|
|
115
|
+
if (!existing)
|
|
116
|
+
throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
|
|
117
|
+
registry.hosts[host] = { ...existing, httpsRedirect: input.httpsRedirect ?? undefined, updatedAt: new Date().toISOString() };
|
|
118
|
+
return registry.hosts[host];
|
|
119
|
+
});
|
|
120
|
+
return { host, entry };
|
|
121
|
+
}
|
|
110
122
|
/** Sharing on: `domain` shares on that hostname, none on a random trycloudflare.com URL. Sharing off keeps the hostname for next time. */
|
|
111
123
|
static async setTunnel(input) {
|
|
112
124
|
const host = localhost_1.default.requireHost(input.host);
|
|
@@ -0,0 +1,40 @@
|
|
|
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.redirectToHttps = exports.wantsHttps = void 0;
|
|
7
|
+
const net_1 = __importDefault(require("net"));
|
|
8
|
+
const constants_1 = __importDefault(require("../constants"));
|
|
9
|
+
const urls_1 = require("../lib/urls");
|
|
10
|
+
const request_1 = require("./request");
|
|
11
|
+
/** The CLI and scripts talk to the dashboard's API and health check over plain http. */
|
|
12
|
+
const dashboardExempt = (url) => {
|
|
13
|
+
const path = url.split("?")[0];
|
|
14
|
+
return path === "/healthz" || path === "/api" || path.startsWith("/api/");
|
|
15
|
+
};
|
|
16
|
+
/** Whether this request should be sent to https: the mapping's own choice, else the global setting. */
|
|
17
|
+
const wantsHttps = (req, ctx, host, hub) => {
|
|
18
|
+
// Tunnels, the hub and peers reach the http port on purpose and are already https on their public side.
|
|
19
|
+
if ((0, request_1.isTls)(req) || (hub && hub.kind !== "none") || (0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req))
|
|
20
|
+
return false;
|
|
21
|
+
if (req.headers["cf-ray"] || req.headers["cf-connecting-ip"] || req.headers["cdn-loop"])
|
|
22
|
+
return false;
|
|
23
|
+
const global = ctx.httpsRedirect?.() === true;
|
|
24
|
+
if (constants_1.default.dashboardHosts.includes(host))
|
|
25
|
+
return global && !dashboardExempt(req.url || "/");
|
|
26
|
+
const entry = ctx.lookup(host);
|
|
27
|
+
if (entry)
|
|
28
|
+
return entry.httpsRedirect ?? global;
|
|
29
|
+
return global && Boolean(ctx.localFor?.(host));
|
|
30
|
+
};
|
|
31
|
+
exports.wantsHttps = wantsHttps;
|
|
32
|
+
/** 307 keeps the method and body, and isn't cached, so turning the setting off takes effect straight away. */
|
|
33
|
+
const redirectToHttps = (req, res, ctx) => {
|
|
34
|
+
const name = (0, request_1.hostOf)(req);
|
|
35
|
+
const origin = (0, urls_1.formatUrl)(net_1.default.isIPv6(name) ? `[${name}]` : name, true, ctx.httpsPort?.() ?? constants_1.default.server.httpsPort);
|
|
36
|
+
const path = req.url?.startsWith("/") ? req.url : "/";
|
|
37
|
+
res.writeHead(307, { Location: origin + path, "Cache-Control": "no-store", "Content-Type": "text/plain; charset=utf-8" });
|
|
38
|
+
res.end(`Redirecting to ${origin}${path}\n`);
|
|
39
|
+
};
|
|
40
|
+
exports.redirectToHttps = redirectToHttps;
|
package/dist/proxy/router.js
CHANGED
|
@@ -20,6 +20,7 @@ const passthrough_1 = require("./passthrough");
|
|
|
20
20
|
const request_1 = require("./request");
|
|
21
21
|
const stats_1 = require("./stats");
|
|
22
22
|
const protect_1 = require("./protect");
|
|
23
|
+
const https_redirect_1 = require("./https-redirect");
|
|
23
24
|
// Cloudflare swaps an origin's 502/504 for its own "bad gateway" page, so through a tunnel say 503 and the page survives.
|
|
24
25
|
const downStatus = (req) => ((0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req) ? 503 : 502);
|
|
25
26
|
/** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
|
|
@@ -123,6 +124,10 @@ function handleRequest(req, res, ctx) {
|
|
|
123
124
|
}
|
|
124
125
|
const host = resolveHost(req, ctx, hub);
|
|
125
126
|
try {
|
|
127
|
+
if ((0, https_redirect_1.wantsHttps)(req, ctx, host, hub)) {
|
|
128
|
+
(0, https_redirect_1.redirectToHttps)(req, res, ctx);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
126
131
|
if (constants_1.default.dashboardHosts.includes(host)) {
|
|
127
132
|
ctx.dashboard(req, res);
|
|
128
133
|
return;
|
package/dist/server/index.js
CHANGED
|
@@ -17,6 +17,7 @@ const hub_tunnel_1 = require("../proxy/hub-tunnel");
|
|
|
17
17
|
const hub_1 = require("../proxy/hub");
|
|
18
18
|
const remote_1 = require("../proxy/remote");
|
|
19
19
|
const hub_config_1 = __importDefault(require("../lib/hub-config"));
|
|
20
|
+
const config_1 = __importDefault(require("../lib/config"));
|
|
20
21
|
const file_1 = __importDefault(require("../utils/file"));
|
|
21
22
|
const logger_1 = __importDefault(require("../utils/logger"));
|
|
22
23
|
const certs_1 = require("../utils/certs");
|
|
@@ -68,6 +69,8 @@ async function startCentralProxy() {
|
|
|
68
69
|
lookup: (host) => registry.get().hosts[tunnels.hostFor(host) ?? host],
|
|
69
70
|
tunnelFor: (host) => tunnels.hostFor(host),
|
|
70
71
|
ownPorts: () => [info.httpPort, info.httpsPort],
|
|
72
|
+
httpsRedirect: () => config_1.default.httpsRedirect(),
|
|
73
|
+
httpsPort: () => info.httpsPort,
|
|
71
74
|
remoteFor: remote_1.remoteFor,
|
|
72
75
|
localFor: remote_1.localFor,
|
|
73
76
|
remoteHosts: (name) => Object.entries(registry.get().hosts)
|
package/package.json
CHANGED