locadot 2.1.0 → 2.3.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 +32 -0
- package/README.md +56 -4
- package/dist/cli/commands/hosts.js +29 -1
- package/dist/cli/commands/remote.js +21 -1
- package/dist/cli/commands/tunnel.js +9 -3
- package/dist/dashboard/api.js +35 -5
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/login.js +17 -6
- package/dist/dashboard/page.js +778 -86
- package/dist/index.js +16 -2
- package/dist/lib/allow.js +60 -0
- package/dist/lib/hosts.js +52 -4
- package/dist/lib/ui-auth.js +5 -0
- package/dist/proxy/guard.js +30 -1
- package/dist/proxy/hub.js +11 -3
- package/dist/proxy/options.js +10 -2
- package/dist/proxy/request.js +3 -1
- package/dist/proxy/router.js +38 -12
- package/dist/proxy/tunnel.js +62 -22
- package/dist/server/index.js +2 -0
- package/dist/utils/startup.js +124 -34
- package/dist/utils/trust.js +52 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -44,8 +44,17 @@ program
|
|
|
44
44
|
.description("HTTPS custom *.localhost domains for any local or remote upstream")
|
|
45
45
|
.version(package_json_1.version)
|
|
46
46
|
.helpOption("--help", "Display help for command");
|
|
47
|
-
destinationOptions(program.command("add").description("Map a new domain to a port or URL").addOption(hostOption()))
|
|
47
|
+
destinationOptions(program.command("add").description("Map a new domain to a port or URL").addOption(hostOption()))
|
|
48
|
+
.option("--allow <addresses>", "Internal addresses shared visitors may reach through --cors, e.g. localhost:3000,localhost:4000")
|
|
49
|
+
.action(run((options) => commands_1.default.add(options)));
|
|
48
50
|
destinationOptions(program.command("update").description("Change the destination of a mapped domain").addOption(hostOption())).action(run((options) => commands_1.default.update(options)));
|
|
51
|
+
program
|
|
52
|
+
.command("allow [addresses...]")
|
|
53
|
+
.description("Let tunnel visitors and connected machines reach internal addresses (localhost:3000) through a --cors domain")
|
|
54
|
+
.addOption(hostOption())
|
|
55
|
+
.option("--rm", "Remove the given addresses instead of adding them")
|
|
56
|
+
.option("--clear", "Remove every allowed address")
|
|
57
|
+
.action(run((addresses, options) => commands_1.default.allow(addresses, options)));
|
|
49
58
|
program
|
|
50
59
|
.command("remove")
|
|
51
60
|
.alias("rm")
|
|
@@ -74,8 +83,9 @@ program
|
|
|
74
83
|
.action(run((host) => commands_1.default.open(host)));
|
|
75
84
|
program
|
|
76
85
|
.command("tunnel")
|
|
77
|
-
.description("Share a domain on a public https://*.trycloudflare.com URL (Cloudflare quick tunnel, no account); lists shared domains without --host")
|
|
86
|
+
.description("Share a domain on a public https://*.trycloudflare.com URL (Cloudflare quick tunnel, no account) or on your own hostname; lists shared domains without --host")
|
|
78
87
|
.option("-h, --host <host>", "Domain to share, e.g. app.localhost")
|
|
88
|
+
.option("-d, --domain <hostname>", "Share on this hostname in your Cloudflare account (named tunnel), e.g. app.example.com")
|
|
79
89
|
.option("--off", "Stop sharing it")
|
|
80
90
|
.action(run((options) => commands_1.default.tunnel(options)));
|
|
81
91
|
program.command("tunnel:install").description("Download cloudflared into the locadot state dir").action(run(() => commands_1.default.installTunnel()));
|
|
@@ -136,6 +146,10 @@ program
|
|
|
136
146
|
.command("hub:localhost <onOrOff>")
|
|
137
147
|
.description("Sender: let admin peers reach any port on this machine's localhost (on/off)")
|
|
138
148
|
.action(run((value) => commands_1.default.hubLocalhost(value)));
|
|
149
|
+
program
|
|
150
|
+
.command("hub:panel <onOrOff>")
|
|
151
|
+
.description("Sender: open this dashboard to browsers at the hub's public URL, behind the dashboard password (on/off)")
|
|
152
|
+
.action(run((value) => commands_1.default.hubPanel(value)));
|
|
139
153
|
program
|
|
140
154
|
.command("ui:password")
|
|
141
155
|
.description("Set or reset the dashboard password (prompts; hashed with scrypt). The API token keeps working for scripts")
|
|
@@ -0,0 +1,60 @@
|
|
|
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.allowMatches = exports.splitHost = exports.parseAllowList = exports.parseAllow = exports.MAX_ALLOW = void 0;
|
|
7
|
+
const net_1 = __importDefault(require("net"));
|
|
8
|
+
const guard_1 = require("../proxy/guard");
|
|
9
|
+
const localhost_1 = require("./localhost");
|
|
10
|
+
/**
|
|
11
|
+
* A mapping's `allow` list: internal addresses (`localhost:3000`, `api.internal`) that a tunnel visitor or hub peer
|
|
12
|
+
* may reach through that mapping's --cors pass-through. Everyone else stays limited to public hosts.
|
|
13
|
+
*/
|
|
14
|
+
exports.MAX_ALLOW = 32;
|
|
15
|
+
const LABEL = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?";
|
|
16
|
+
const HOSTNAME = new RegExp(`^${LABEL}(?:\\.${LABEL})*$`);
|
|
17
|
+
/** Loopback spellings (127.0.0.1, 0.0.0.0) all mean this machine, and the shim sends whichever the app wrote. */
|
|
18
|
+
const canonicalHost = (host) => ((0, guard_1.isLoopbackHost)(host) && !host.endsWith(".localhost") ? "localhost" : host);
|
|
19
|
+
const parseAllow = (value) => {
|
|
20
|
+
if (typeof value !== "string")
|
|
21
|
+
throw new localhost_1.InputError("❌ Each allowed address must be a string, e.g. localhost:3000.");
|
|
22
|
+
const raw = value.trim().toLowerCase();
|
|
23
|
+
const bare = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/\/$/, "");
|
|
24
|
+
const match = bare.match(/^([^:/\s]+)(?::(\d{1,5}))?$/);
|
|
25
|
+
const port = match?.[2] === undefined ? undefined : Number(match[2]);
|
|
26
|
+
if (!match || !(net_1.default.isIPv4(match[1]) || HOSTNAME.test(match[1])) || (port !== undefined && (port < 1 || port > 65535))) {
|
|
27
|
+
throw new localhost_1.InputError(`❌ "${value}" isn't a host or host:port, e.g. localhost:3000 or api.internal.`);
|
|
28
|
+
}
|
|
29
|
+
const host = canonicalHost(match[1]);
|
|
30
|
+
return port === undefined ? host : `${host}:${port}`;
|
|
31
|
+
};
|
|
32
|
+
exports.parseAllow = parseAllow;
|
|
33
|
+
/** Normalizes and de-duplicates; an empty list clears it. */
|
|
34
|
+
const parseAllowList = (value) => {
|
|
35
|
+
const items = typeof value === "string" ? value.split(",").filter((item) => item.trim()) : value;
|
|
36
|
+
if (!Array.isArray(items))
|
|
37
|
+
throw new localhost_1.InputError("❌ `allow` must be a list of addresses, e.g. [\"localhost:3000\"].");
|
|
38
|
+
const list = [...new Set(items.map(exports.parseAllow))];
|
|
39
|
+
if (list.length > exports.MAX_ALLOW)
|
|
40
|
+
throw new localhost_1.InputError(`❌ At most ${exports.MAX_ALLOW} allowed addresses per mapping.`);
|
|
41
|
+
return list;
|
|
42
|
+
};
|
|
43
|
+
exports.parseAllowList = parseAllowList;
|
|
44
|
+
const defaultPort = (scheme) => (scheme === "https" || scheme === "wss" ? 443 : 80);
|
|
45
|
+
/** `host` from the pass-through URL (no brackets: parseVia only accepts names and IPv4). */
|
|
46
|
+
const splitHost = (host, scheme) => {
|
|
47
|
+
const match = host.toLowerCase().match(/^(.*?)(?::(\d+))?$/);
|
|
48
|
+
return { host: canonicalHost(match[1].replace(/\.$/, "")), port: match[2] ? Number(match[2]) : defaultPort(scheme) };
|
|
49
|
+
};
|
|
50
|
+
exports.splitHost = splitHost;
|
|
51
|
+
const allowMatches = (allow, host, scheme) => {
|
|
52
|
+
if (!allow?.length)
|
|
53
|
+
return false;
|
|
54
|
+
const target = (0, exports.splitHost)(host, scheme);
|
|
55
|
+
return allow.some((entry) => {
|
|
56
|
+
const [name, port] = entry.split(":");
|
|
57
|
+
return name === target.host && (port === undefined || Number(port) === target.port);
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
exports.allowMatches = allowMatches;
|
package/dist/lib/hosts.js
CHANGED
|
@@ -36,10 +36,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.ConflictError = exports.NotFoundError = void 0;
|
|
39
|
+
exports.parseTunnelDomain = exports.ConflictError = exports.NotFoundError = void 0;
|
|
40
40
|
const constants_1 = __importDefault(require("../constants"));
|
|
41
41
|
const localhost_1 = __importStar(require("./localhost"));
|
|
42
42
|
const registry_1 = __importDefault(require("./registry"));
|
|
43
|
+
const allow_1 = require("./allow");
|
|
44
|
+
const hub_config_1 = __importDefault(require("./hub-config"));
|
|
43
45
|
class NotFoundError extends localhost_1.InputError {
|
|
44
46
|
}
|
|
45
47
|
exports.NotFoundError = NotFoundError;
|
|
@@ -53,17 +55,30 @@ const requireTarget = (value) => {
|
|
|
53
55
|
}
|
|
54
56
|
return localhost_1.default.parseTarget(String(value));
|
|
55
57
|
};
|
|
58
|
+
const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
59
|
+
/** A public hostname on the user's Cloudflare zone, e.g. app.example.com (scheme and trailing slash allowed). */
|
|
60
|
+
const parseTunnelDomain = (value) => {
|
|
61
|
+
const domain = typeof value === "string" ? value.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/$/, "") : "";
|
|
62
|
+
const labels = domain.split(".");
|
|
63
|
+
const tld = labels[labels.length - 1];
|
|
64
|
+
if (domain.length > 253 || labels.length < 2 || tld === "localhost" || !/[a-z]/.test(tld) || !labels.every((label) => LABEL.test(label))) {
|
|
65
|
+
throw new localhost_1.InputError(`❌ "${value}" isn't a public hostname, e.g. app.example.com.`);
|
|
66
|
+
}
|
|
67
|
+
return domain;
|
|
68
|
+
};
|
|
69
|
+
exports.parseTunnelDomain = parseTunnelDomain;
|
|
56
70
|
class HostOps {
|
|
57
71
|
static async add(input) {
|
|
58
72
|
const host = localhost_1.default.requireHost(input.host);
|
|
59
73
|
const target = requireTarget(input.target);
|
|
74
|
+
const allow = input.allow === undefined ? [] : (0, allow_1.parseAllowList)(input.allow);
|
|
60
75
|
const now = new Date().toISOString();
|
|
61
76
|
const entry = await registry_1.default.mutate((registry) => {
|
|
62
77
|
const existing = registry.hosts[host];
|
|
63
78
|
if (existing) {
|
|
64
79
|
throw new ConflictError(`❌ ${host} is already mapped to ${existing.target}. Use \`locadot update --host ${host} ...\` instead.`);
|
|
65
80
|
}
|
|
66
|
-
registry.hosts[host] = { target, insecure: input.insecure || undefined, cors: input.cors || undefined, createdAt: now, updatedAt: now };
|
|
81
|
+
registry.hosts[host] = { target, insecure: input.insecure || undefined, cors: input.cors || undefined, allow: allow.length ? allow : undefined, createdAt: now, updatedAt: now };
|
|
67
82
|
return registry.hosts[host];
|
|
68
83
|
});
|
|
69
84
|
return { host, entry };
|
|
@@ -71,24 +86,57 @@ class HostOps {
|
|
|
71
86
|
static async update(input) {
|
|
72
87
|
const host = localhost_1.default.requireHost(input.host);
|
|
73
88
|
const target = requireTarget(input.target);
|
|
89
|
+
const allow = input.allow === undefined ? undefined : (0, allow_1.parseAllowList)(input.allow);
|
|
74
90
|
const entry = await registry_1.default.mutate((registry) => {
|
|
75
91
|
const existing = registry.hosts[host];
|
|
76
92
|
if (!existing)
|
|
77
93
|
throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
|
|
78
94
|
const insecure = input.insecure ?? existing.insecure;
|
|
79
95
|
const cors = input.cors ?? existing.cors;
|
|
80
|
-
|
|
96
|
+
const list = allow ?? existing.allow;
|
|
97
|
+
registry.hosts[host] = {
|
|
98
|
+
...existing,
|
|
99
|
+
target,
|
|
100
|
+
insecure: insecure || undefined,
|
|
101
|
+
cors: cors || undefined,
|
|
102
|
+
allow: list?.length ? list : undefined,
|
|
103
|
+
updatedAt: new Date().toISOString(),
|
|
104
|
+
};
|
|
81
105
|
return registry.hosts[host];
|
|
82
106
|
});
|
|
83
107
|
return { host, entry };
|
|
84
108
|
}
|
|
109
|
+
/** Sharing on: `domain` shares on that hostname, none on a random trycloudflare.com URL. Sharing off keeps the hostname for next time. */
|
|
85
110
|
static async setTunnel(input) {
|
|
86
111
|
const host = localhost_1.default.requireHost(input.host);
|
|
112
|
+
const domain = input.tunnel && input.domain !== undefined && input.domain !== null && input.domain !== "" ? (0, exports.parseTunnelDomain)(input.domain) : undefined;
|
|
113
|
+
if (domain && hub_config_1.default.read()?.domain === domain)
|
|
114
|
+
throw new ConflictError(`❌ ${domain} is this machine's remote-access hostname.`);
|
|
115
|
+
const entry = await registry_1.default.mutate((registry) => {
|
|
116
|
+
const existing = registry.hosts[host];
|
|
117
|
+
if (!existing)
|
|
118
|
+
throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
|
|
119
|
+
if (domain && existing.remote)
|
|
120
|
+
throw new localhost_1.InputError(`❌ ${host} is a remote's mapping; share it on a custom hostname from that machine.`);
|
|
121
|
+
const taken = domain && Object.entries(registry.hosts).find(([other, e]) => other !== host && e.tunnelDomain === domain)?.[0];
|
|
122
|
+
if (taken)
|
|
123
|
+
throw new ConflictError(`❌ ${domain} is already the shared hostname of ${taken}.`);
|
|
124
|
+
const tunnelDomain = input.tunnel ? domain : existing.tunnelDomain;
|
|
125
|
+
registry.hosts[host] = { ...existing, tunnel: input.tunnel || undefined, tunnelDomain, updatedAt: new Date().toISOString() };
|
|
126
|
+
return registry.hosts[host];
|
|
127
|
+
});
|
|
128
|
+
return { host, entry };
|
|
129
|
+
}
|
|
130
|
+
static async setAllow(input) {
|
|
131
|
+
const host = localhost_1.default.requireHost(input.host);
|
|
132
|
+
const allow = (0, allow_1.parseAllowList)(input.allow);
|
|
87
133
|
const entry = await registry_1.default.mutate((registry) => {
|
|
88
134
|
const existing = registry.hosts[host];
|
|
89
135
|
if (!existing)
|
|
90
136
|
throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
|
|
91
|
-
|
|
137
|
+
if (existing.remote)
|
|
138
|
+
throw new localhost_1.InputError(`❌ ${host} is a remote's mapping; set its allowed addresses on that machine.`);
|
|
139
|
+
registry.hosts[host] = { ...existing, allow: allow.length ? allow : undefined, updatedAt: new Date().toISOString() };
|
|
92
140
|
return registry.hosts[host];
|
|
93
141
|
});
|
|
94
142
|
return { host, entry };
|
package/dist/lib/ui-auth.js
CHANGED
|
@@ -9,6 +9,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
10
|
const constants_1 = __importDefault(require("../constants"));
|
|
11
11
|
const file_1 = __importDefault(require("../utils/file"));
|
|
12
|
+
const hub_config_1 = __importDefault(require("./hub-config"));
|
|
12
13
|
exports.SESSION_COOKIE = "locadot_ui";
|
|
13
14
|
exports.SESSION_TTL_SEC = 7 * 24 * 3600;
|
|
14
15
|
exports.MIN_PASSWORD = 8;
|
|
@@ -68,6 +69,10 @@ class UiAuth {
|
|
|
68
69
|
static clear() {
|
|
69
70
|
file_1.default.remove("UI_AUTH_FILE");
|
|
70
71
|
cache = undefined;
|
|
72
|
+
// The shared dashboard needs the password, so it doesn't come back on its own when a new one is set.
|
|
73
|
+
const hub = hub_config_1.default.read();
|
|
74
|
+
if (hub?.panel)
|
|
75
|
+
hub_config_1.default.write({ ...hub, panel: false });
|
|
71
76
|
}
|
|
72
77
|
static verify(password) {
|
|
73
78
|
const data = UiAuth.read();
|
package/dist/proxy/guard.js
CHANGED
|
@@ -3,11 +3,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.publicOnlyAgents = exports.isPublicHost = exports.isLoopbackHost = exports.isPublicAddress = void 0;
|
|
6
|
+
exports.notSelfAgents = exports.isSelfAddress = exports.publicOnlyAgents = exports.isPublicHost = exports.isLoopbackHost = exports.isPublicAddress = void 0;
|
|
7
7
|
const dns_1 = __importDefault(require("dns"));
|
|
8
8
|
const http_1 = __importDefault(require("http"));
|
|
9
9
|
const https_1 = __importDefault(require("https"));
|
|
10
10
|
const net_1 = __importDefault(require("net"));
|
|
11
|
+
const os_1 = __importDefault(require("os"));
|
|
11
12
|
/**
|
|
12
13
|
* Tunnel visitors are the internet. Their --cors pass-through calls may only leave this machine
|
|
13
14
|
* for public addresses, never loopback, the LAN or cloud metadata.
|
|
@@ -93,3 +94,31 @@ exports.publicOnlyAgents = {
|
|
|
93
94
|
http: new http_1.default.Agent({ keepAlive: true, lookup: publicLookup }),
|
|
94
95
|
https: new https_1.default.Agent({ keepAlive: true, lookup: publicLookup }),
|
|
95
96
|
};
|
|
97
|
+
/** Loopback or one of this machine's own interface addresses (its LAN IP reaches the proxy just as well). */
|
|
98
|
+
const isSelfAddress = (address) => {
|
|
99
|
+
if ((0, exports.isLoopbackHost)(address))
|
|
100
|
+
return true;
|
|
101
|
+
const own = Object.values(os_1.default.networkInterfaces()).flatMap((list) => (list || []).map((item) => item.address.toLowerCase()));
|
|
102
|
+
return own.includes(address.replace(/^\[|\]$/g, "").toLowerCase());
|
|
103
|
+
};
|
|
104
|
+
exports.isSelfAddress = isSelfAddress;
|
|
105
|
+
// For an allowed address on the proxy's own port: whatever the name resolves to, it must not be this machine.
|
|
106
|
+
const notSelfLookup = (hostname, options, callback) => {
|
|
107
|
+
dns_1.default.lookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
108
|
+
if (err)
|
|
109
|
+
return callback(err, "", 0);
|
|
110
|
+
const allowed = addresses.filter((entry) => !(0, exports.isSelfAddress)(entry.address));
|
|
111
|
+
if (!allowed.length) {
|
|
112
|
+
const error = new Error(`${hostname} is this machine`);
|
|
113
|
+
error.code = "ESELF";
|
|
114
|
+
return callback(error, "", 0);
|
|
115
|
+
}
|
|
116
|
+
if (options.all)
|
|
117
|
+
return callback(null, allowed);
|
|
118
|
+
callback(null, allowed[0].address, allowed[0].family);
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
exports.notSelfAgents = {
|
|
122
|
+
http: new http_1.default.Agent({ keepAlive: true, lookup: notSelfLookup }),
|
|
123
|
+
https: new https_1.default.Agent({ keepAlive: true, lookup: notSelfLookup }),
|
|
124
|
+
};
|
package/dist/proxy/hub.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.resetRateLimiter = void 0;
|
|
39
|
+
exports.clientIp = exports.resetRateLimiter = void 0;
|
|
40
40
|
exports.classify = classify;
|
|
41
41
|
exports.handleHubApi = handleHubApi;
|
|
42
42
|
const os_1 = __importDefault(require("os"));
|
|
@@ -45,6 +45,7 @@ const logger_1 = __importDefault(require("../utils/logger"));
|
|
|
45
45
|
const links_1 = __importStar(require("../lib/links"));
|
|
46
46
|
const hosts_1 = __importStar(require("../lib/hosts"));
|
|
47
47
|
const hub_config_1 = __importDefault(require("../lib/hub-config"));
|
|
48
|
+
const ui_auth_1 = __importDefault(require("../lib/ui-auth"));
|
|
48
49
|
const localhost_1 = __importStar(require("../lib/localhost"));
|
|
49
50
|
const registry_1 = require("../lib/registry");
|
|
50
51
|
const request_1 = require("./request");
|
|
@@ -80,6 +81,7 @@ const clientIp = (req) => {
|
|
|
80
81
|
const value = Array.isArray(header) ? header[0] : header;
|
|
81
82
|
return value || req.socket.remoteAddress || "unknown";
|
|
82
83
|
};
|
|
84
|
+
exports.clientIp = clientIp;
|
|
83
85
|
/** `X-Original-Host` from the receiver, only if it is a `*.localhost` name with an optional port. */
|
|
84
86
|
const receiverHost = (req) => {
|
|
85
87
|
const header = req.headers["x-original-host"];
|
|
@@ -137,7 +139,13 @@ function classify(req, publicHost, hosts, opts) {
|
|
|
137
139
|
const pathname = (req.url || "/").split("?")[0];
|
|
138
140
|
if (pathname === "/_locadot" || pathname.startsWith("/_locadot/"))
|
|
139
141
|
return { kind: "api" };
|
|
140
|
-
|
|
142
|
+
// Peers always send X-Locadot-Peer; a plain browser gets the shared dashboard, which has its own sign-in limiter.
|
|
143
|
+
if (opts?.panel && req.headers["x-locadot-peer"] === undefined) {
|
|
144
|
+
if (!ui_auth_1.default.enabled())
|
|
145
|
+
return { kind: "deny", status: 403, message: "Set a dashboard password to share the dashboard." };
|
|
146
|
+
return { kind: "panel" };
|
|
147
|
+
}
|
|
148
|
+
const ip = (0, exports.clientIp)(req);
|
|
141
149
|
if (isLimited(ip))
|
|
142
150
|
return { kind: "deny", status: 429, message: "Too many failed attempts. Try again later." };
|
|
143
151
|
// Not Authorization: the sender's own apps may need that header from the visitor.
|
|
@@ -269,7 +277,7 @@ async function handleHubApi(req, res, ctx) {
|
|
|
269
277
|
const url = new URL(req.url || "/", "http://hub.invalid");
|
|
270
278
|
const path = url.pathname;
|
|
271
279
|
const method = req.method || "GET";
|
|
272
|
-
const ip = clientIp(req);
|
|
280
|
+
const ip = (0, exports.clientIp)(req);
|
|
273
281
|
try {
|
|
274
282
|
if (path === "/_locadot/v1/connect" && method === "POST") {
|
|
275
283
|
const body = await readJson(req);
|
package/dist/proxy/options.js
CHANGED
|
@@ -22,14 +22,22 @@ const proxyOptions = (req, entry, lookup) => ({
|
|
|
22
22
|
headers: { "X-Original-Host": req.headers.host || "", ...(entry.cors ? { ...(0, cors_1.sameOriginHeaders)(req, entry, lookup), ...DECODABLE } : {}) },
|
|
23
23
|
});
|
|
24
24
|
exports.proxyOptions = proxyOptions;
|
|
25
|
-
const
|
|
25
|
+
const agentFor = (req, via, listed) => {
|
|
26
|
+
const tls = via.scheme === "https" || via.scheme === "wss";
|
|
27
|
+
if (listed === "not-self")
|
|
28
|
+
return { agent: tls ? guard_1.notSelfAgents.https : guard_1.notSelfAgents.http };
|
|
29
|
+
if ((0, request_1.fromTunnel)(req) && !listed)
|
|
30
|
+
return { agent: tls ? guard_1.publicOnlyAgents.https : guard_1.publicOnlyAgents.http };
|
|
31
|
+
return {};
|
|
32
|
+
};
|
|
33
|
+
const viaOptions = (req, entry, via, listed) => ({
|
|
26
34
|
target: (0, exports.viaOrigin)(via),
|
|
27
35
|
changeOrigin: true,
|
|
28
36
|
ws: true,
|
|
29
37
|
secure: !entry.insecure,
|
|
30
38
|
cookieDomainRewrite: { "*": "" },
|
|
31
39
|
headers: { ...(0, passthrough_1.viaHeaders)(req, via, entry.target), ...DECODABLE },
|
|
32
|
-
...(
|
|
40
|
+
...agentFor(req, via, listed),
|
|
33
41
|
});
|
|
34
42
|
exports.viaOptions = viaOptions;
|
|
35
43
|
const viaOrigin = (via) => `${via.scheme}://${via.host}`;
|
package/dist/proxy/request.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.isTls = exports.viaOf = exports.loopbackOf = exports.peerNamesOf = exports.peerOriginOf = exports.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
|
|
3
|
+
exports.isTls = exports.viaOf = exports.loopbackOf = exports.peerNamesOf = exports.peerOriginOf = exports.isPanel = exports.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
|
|
4
4
|
const tags = new WeakMap();
|
|
5
5
|
const tag = (req, values) => {
|
|
6
6
|
tags.set(req, { ...tags.get(req), ...values });
|
|
@@ -21,6 +21,8 @@ const fromTunnel = (req) => Boolean(tags.get(req)?.tunnel);
|
|
|
21
21
|
exports.fromTunnel = fromTunnel;
|
|
22
22
|
const fromRemote = (req) => Boolean(tags.get(req)?.remote);
|
|
23
23
|
exports.fromRemote = fromRemote;
|
|
24
|
+
const isPanel = (req) => Boolean(tags.get(req)?.panel);
|
|
25
|
+
exports.isPanel = isPanel;
|
|
24
26
|
const peerOriginOf = (req) => tags.get(req)?.peerOrigin;
|
|
25
27
|
exports.peerOriginOf = peerOriginOf;
|
|
26
28
|
const peerNamesOf = (req) => tags.get(req)?.peerNames;
|
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,15 +15,22 @@ 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
|
+
// Cloudflare swaps an origin's 502/504 for its own "bad gateway" page, so through a tunnel say 503 and the page survives.
|
|
23
|
+
const downStatus = (req) => ((0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req) ? 503 : 502);
|
|
20
24
|
/** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
|
|
21
25
|
const resolveHost = (req, ctx, hub) => {
|
|
22
26
|
if (hub?.kind === "app") {
|
|
23
27
|
(0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names, loopback: hub.loopback });
|
|
24
28
|
return hub.host;
|
|
25
29
|
}
|
|
30
|
+
if (hub?.kind === "panel") {
|
|
31
|
+
(0, request_1.tag)(req, { host: "localhost", remote: true, secure: ctx.hub.secure(), peerOrigin: (req.headers.host || "").toLowerCase(), panel: true });
|
|
32
|
+
return "localhost";
|
|
33
|
+
}
|
|
26
34
|
if (hub?.kind === "dashboard") {
|
|
27
35
|
(0, request_1.tag)(req, { host: "localhost", remote: true, secure: ctx.hub.secure(), peerOrigin: hub.origin });
|
|
28
36
|
return "localhost";
|
|
@@ -49,8 +57,23 @@ const peerMayReach = (req, via) => {
|
|
|
49
57
|
const port = Number(via.host.match(/:(\d+)$/)?.[1] || (via.scheme === "https" || via.scheme === "wss" ? 443 : 80));
|
|
50
58
|
return Boolean(allowed && !allowed.includes(port));
|
|
51
59
|
};
|
|
52
|
-
/**
|
|
53
|
-
|
|
60
|
+
/**
|
|
61
|
+
* An address on the mapping's `allow` list. The proxy's own ports on this machine never are (the dashboard is behind
|
|
62
|
+
* them): "not-self" means the name must be checked at connect time, on the address actually dialled.
|
|
63
|
+
*/
|
|
64
|
+
const listed = (req, ctx, entry, via) => {
|
|
65
|
+
if (!((0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req)) || !(0, allow_1.allowMatches)(entry.allow, via.host, via.scheme))
|
|
66
|
+
return undefined;
|
|
67
|
+
const { host, port } = (0, allow_1.splitHost)(via.host, via.scheme);
|
|
68
|
+
if (!(ctx.ownPorts?.() ?? []).includes(port))
|
|
69
|
+
return "open";
|
|
70
|
+
return (0, guard_1.isSelfAddress)(host) ? undefined : net_1.default.isIP(host) ? "open" : "not-self";
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Same-origin only, a tunnel visitor may only reach public hosts (see guard.ts), and a peer only its share of localhost,
|
|
74
|
+
* unless the mapping's owner listed the address in `allow`.
|
|
75
|
+
*/
|
|
76
|
+
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)));
|
|
54
77
|
/**
|
|
55
78
|
* Receiver side: `<sender host>=<our URL for it>` for every mapping of this remote, so a --cors sender rewrites
|
|
56
79
|
* its apps' origins to the names this machine uses rather than its own.
|
|
@@ -110,7 +133,7 @@ function handleRequest(req, res, ctx) {
|
|
|
110
133
|
forwardLocal(req, res, ctx, host, local);
|
|
111
134
|
return;
|
|
112
135
|
}
|
|
113
|
-
res.writeHead(
|
|
136
|
+
res.writeHead(downStatus(req), { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
114
137
|
res.end((0, template_1.proxyNotFound)(host, dashboardUrl(req)));
|
|
115
138
|
return;
|
|
116
139
|
}
|
|
@@ -130,9 +153,11 @@ function handleRequest(req, res, ctx) {
|
|
|
130
153
|
return;
|
|
131
154
|
}
|
|
132
155
|
const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
|
|
133
|
-
|
|
156
|
+
const allowed = via && listed(req, ctx, entry, via);
|
|
157
|
+
if (via && !viaAllowed(req, via, allowed)) {
|
|
134
158
|
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
135
|
-
res.end(
|
|
159
|
+
res.end(`locadot: the pass-through only serves the page's own requests, and only public hosts to shared visitors. ` +
|
|
160
|
+
`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`);
|
|
136
161
|
return;
|
|
137
162
|
}
|
|
138
163
|
const started = Date.now();
|
|
@@ -142,7 +167,7 @@ function handleRequest(req, res, ctx) {
|
|
|
142
167
|
let options = (0, options_1.proxyOptions)(req, entry, ctx.lookup);
|
|
143
168
|
if (via) {
|
|
144
169
|
(0, request_1.tag)(req, { via });
|
|
145
|
-
options = (0, options_1.viaOptions)(req, entry, via);
|
|
170
|
+
options = (0, options_1.viaOptions)(req, entry, via, allowed);
|
|
146
171
|
req.url = via.path;
|
|
147
172
|
}
|
|
148
173
|
const upstream = via ? (0, options_1.viaOrigin)(via) : entry.target;
|
|
@@ -152,8 +177,8 @@ function handleRequest(req, res, ctx) {
|
|
|
152
177
|
res.destroy();
|
|
153
178
|
return;
|
|
154
179
|
}
|
|
155
|
-
// With --cors the page should see
|
|
156
|
-
res.writeHead(
|
|
180
|
+
// With --cors the page should see the error, not a CORS failure.
|
|
181
|
+
res.writeHead(downStatus(req), { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", ...(entry.cors ? (0, cors_1.allowOrigin)(req) : {}) });
|
|
157
182
|
res.end((0, template_1.upstreamDown)(host, upstream, reason(err) || "error", dashboardUrl(req)));
|
|
158
183
|
});
|
|
159
184
|
}
|
|
@@ -207,13 +232,13 @@ function forwardLocal(req, res, ctx, host, local) {
|
|
|
207
232
|
res.destroy();
|
|
208
233
|
return;
|
|
209
234
|
}
|
|
210
|
-
html(res,
|
|
235
|
+
html(res, downStatus(req), (0, template_1.upstreamDown)(host, label, reason(err) || "error", dashboardUrl(req)));
|
|
211
236
|
});
|
|
212
237
|
}
|
|
213
238
|
function handleUpgrade(req, socket, head, ctx) {
|
|
214
239
|
socket.on("error", () => socket.destroy());
|
|
215
240
|
const hub = ctx.hub?.classify(req);
|
|
216
|
-
if (hub?.kind === "api" || hub?.kind === "deny") {
|
|
241
|
+
if (hub?.kind === "api" || hub?.kind === "deny" || hub?.kind === "panel") {
|
|
217
242
|
const status = hub.kind === "deny" ? hub.status : 400;
|
|
218
243
|
socket.end(`HTTP/1.1 ${status} ${http_1.default.STATUS_CODES[status] || "Error"}\r\nConnection: close\r\n\r\n`);
|
|
219
244
|
return;
|
|
@@ -246,14 +271,15 @@ function handleUpgrade(req, socket, head, ctx) {
|
|
|
246
271
|
return;
|
|
247
272
|
}
|
|
248
273
|
const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
|
|
249
|
-
|
|
274
|
+
const allowed = via && listed(req, ctx, entry, via);
|
|
275
|
+
if (via && !viaAllowed(req, via, allowed)) {
|
|
250
276
|
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
251
277
|
return;
|
|
252
278
|
}
|
|
253
279
|
logger_1.default.debug(`WebSocket upgrade for ${host}`);
|
|
254
280
|
let options = (0, options_1.proxyOptions)(req, entry, ctx.lookup);
|
|
255
281
|
if (via) {
|
|
256
|
-
options = (0, options_1.viaOptions)(req, entry, via);
|
|
282
|
+
options = (0, options_1.viaOptions)(req, entry, via, allowed);
|
|
257
283
|
req.url = via.path;
|
|
258
284
|
}
|
|
259
285
|
ctx.proxy.ws(req, socket, head, options, (err) => {
|