locadot 2.2.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 +12 -0
- package/README.md +18 -2
- package/dist/cli/commands/hosts.js +29 -1
- package/dist/cli/commands/tunnel.js +9 -3
- package/dist/dashboard/api.js +22 -5
- package/dist/dashboard/index.js +2 -0
- package/dist/dashboard/page.js +740 -86
- package/dist/index.js +12 -2
- package/dist/lib/allow.js +60 -0
- package/dist/lib/hosts.js +52 -4
- package/dist/proxy/guard.js +30 -1
- package/dist/proxy/options.js +10 -2
- package/dist/proxy/router.js +27 -7
- package/dist/proxy/tunnel.js +62 -22
- package/dist/server/index.js +1 -0
- 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()));
|
|
@@ -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/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/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/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,6 +15,7 @@ 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");
|
|
@@ -55,8 +57,23 @@ const peerMayReach = (req, via) => {
|
|
|
55
57
|
const port = Number(via.host.match(/:(\d+)$/)?.[1] || (via.scheme === "https" || via.scheme === "wss" ? 443 : 80));
|
|
56
58
|
return Boolean(allowed && !allowed.includes(port));
|
|
57
59
|
};
|
|
58
|
-
/**
|
|
59
|
-
|
|
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)));
|
|
60
77
|
/**
|
|
61
78
|
* Receiver side: `<sender host>=<our URL for it>` for every mapping of this remote, so a --cors sender rewrites
|
|
62
79
|
* its apps' origins to the names this machine uses rather than its own.
|
|
@@ -136,9 +153,11 @@ function handleRequest(req, res, ctx) {
|
|
|
136
153
|
return;
|
|
137
154
|
}
|
|
138
155
|
const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
|
|
139
|
-
|
|
156
|
+
const allowed = via && listed(req, ctx, entry, via);
|
|
157
|
+
if (via && !viaAllowed(req, via, allowed)) {
|
|
140
158
|
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
141
|
-
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`);
|
|
142
161
|
return;
|
|
143
162
|
}
|
|
144
163
|
const started = Date.now();
|
|
@@ -148,7 +167,7 @@ function handleRequest(req, res, ctx) {
|
|
|
148
167
|
let options = (0, options_1.proxyOptions)(req, entry, ctx.lookup);
|
|
149
168
|
if (via) {
|
|
150
169
|
(0, request_1.tag)(req, { via });
|
|
151
|
-
options = (0, options_1.viaOptions)(req, entry, via);
|
|
170
|
+
options = (0, options_1.viaOptions)(req, entry, via, allowed);
|
|
152
171
|
req.url = via.path;
|
|
153
172
|
}
|
|
154
173
|
const upstream = via ? (0, options_1.viaOrigin)(via) : entry.target;
|
|
@@ -252,14 +271,15 @@ function handleUpgrade(req, socket, head, ctx) {
|
|
|
252
271
|
return;
|
|
253
272
|
}
|
|
254
273
|
const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
|
|
255
|
-
|
|
274
|
+
const allowed = via && listed(req, ctx, entry, via);
|
|
275
|
+
if (via && !viaAllowed(req, via, allowed)) {
|
|
256
276
|
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
257
277
|
return;
|
|
258
278
|
}
|
|
259
279
|
logger_1.default.debug(`WebSocket upgrade for ${host}`);
|
|
260
280
|
let options = (0, options_1.proxyOptions)(req, entry, ctx.lookup);
|
|
261
281
|
if (via) {
|
|
262
|
-
options = (0, options_1.viaOptions)(req, entry, via);
|
|
282
|
+
options = (0, options_1.viaOptions)(req, entry, via, allowed);
|
|
263
283
|
req.url = via.path;
|
|
264
284
|
}
|
|
265
285
|
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 hub_tunnel_1 = require("./hub-tunnel");
|
|
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 });
|
|
@@ -37,11 +40,12 @@ const cloudflaredInfo = (fresh = false) => {
|
|
|
37
40
|
if (!fresh && cached && Date.now() - cached.at < 60000)
|
|
38
41
|
return cached.info;
|
|
39
42
|
const file = (0, exports.cloudflaredPath)();
|
|
40
|
-
|
|
43
|
+
const loggedIn = fs_1.default.existsSync(path_1.default.join(os_1.default.homedir(), ".cloudflared", "cert.pem"));
|
|
44
|
+
let info = { installed: false, loggedIn };
|
|
41
45
|
if (file) {
|
|
42
46
|
const result = (0, child_process_1.spawnSync)(file, ["--version"], { encoding: "utf8", timeout: 5000, windowsHide: true });
|
|
43
47
|
const version = `${result.stdout || ""}${result.stderr || ""}`.match(/version\s+(\S+)/i)?.[1];
|
|
44
|
-
info = { installed: result.status === 0, path: file, ...(version ? { version } : {}) };
|
|
48
|
+
info = { installed: result.status === 0, loggedIn, path: file, ...(version ? { version } : {}) };
|
|
45
49
|
}
|
|
46
50
|
cached = { at: Date.now(), info };
|
|
47
51
|
return info;
|
|
@@ -121,7 +125,7 @@ class TunnelManager {
|
|
|
121
125
|
this.origin = origin;
|
|
122
126
|
this.tunnels = new Map();
|
|
123
127
|
}
|
|
124
|
-
/** The mapping a public trycloudflare.com
|
|
128
|
+
/** The mapping a public Host (trycloudflare.com or a custom hostname) belongs to. */
|
|
125
129
|
hostFor(publicHost) {
|
|
126
130
|
for (const [host, tunnel] of this.tunnels)
|
|
127
131
|
if (tunnel.publicHost === publicHost)
|
|
@@ -137,38 +141,73 @@ class TunnelManager {
|
|
|
137
141
|
}
|
|
138
142
|
/** Starts and stops tunnels to match the registry; `retry` also restarts ones that failed. */
|
|
139
143
|
sync(hosts, retry = false) {
|
|
140
|
-
for (const [host, tunnel] of [...this.tunnels])
|
|
141
|
-
|
|
144
|
+
for (const [host, tunnel] of [...this.tunnels]) {
|
|
145
|
+
const entry = hosts[host];
|
|
146
|
+
if (!entry?.tunnel || (retry && tunnel.status === "error") || tunnel.domain !== (entry.tunnelDomain || undefined))
|
|
142
147
|
this.stop(host);
|
|
148
|
+
}
|
|
143
149
|
for (const [host, entry] of Object.entries(hosts))
|
|
144
150
|
if (entry.tunnel && !this.tunnels.has(host))
|
|
145
|
-
this.start(host);
|
|
151
|
+
this.start(host, entry.tunnelDomain);
|
|
146
152
|
}
|
|
147
|
-
start(host) {
|
|
153
|
+
start(host, domain) {
|
|
154
|
+
const mode = domain ? "custom" : "quick";
|
|
148
155
|
const file = (0, exports.cloudflaredPath)();
|
|
149
156
|
if (!file) {
|
|
150
|
-
this.tunnels.set(host, { enabled: true, status: "error", error: "cloudflared is not installed. Run `locadot tunnel:install`." });
|
|
157
|
+
this.tunnels.set(host, { enabled: true, status: "error", mode, domain, error: "cloudflared is not installed. Run `locadot tunnel:install`." });
|
|
151
158
|
return;
|
|
152
159
|
}
|
|
153
|
-
const tunnel = { enabled: true, status: "starting" };
|
|
160
|
+
const tunnel = { enabled: true, status: "starting", mode, domain };
|
|
154
161
|
this.tunnels.set(host, tunnel);
|
|
162
|
+
if (domain) {
|
|
163
|
+
this.startCustom(file, host, domain, tunnel);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
155
166
|
// An empty config file keeps a user's ~/.cloudflared/config.yml (named tunnels) out of the way.
|
|
156
167
|
const config = path_1.default.join(constants_1.default.paths.HOME, "cloudflared-quick.yml");
|
|
157
168
|
fs_1.default.writeFileSync(config, "");
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
169
|
+
this.run(host, tunnel, file, ["tunnel", "--no-autoupdate", "--config", config, "--url", this.origin()], (text) => {
|
|
170
|
+
const url = text.match(URL_PATTERN)?.[0];
|
|
171
|
+
if (url)
|
|
172
|
+
Object.assign(tunnel, { status: "up", url, publicHost: new URL(url).hostname });
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** Logs in if needed, creates the mapping's own named tunnel and points the hostname's DNS at it, then runs it. */
|
|
176
|
+
startCustom(file, host, domain, tunnel) {
|
|
177
|
+
const name = `locadot-${host.replace(/[^a-z0-9-]/gi, "-")}`;
|
|
178
|
+
tunnel.publicHost = domain;
|
|
179
|
+
(0, hub_tunnel_1.setupNamedTunnel)(domain, name, (loginUrl) => {
|
|
180
|
+
if (this.tunnels.get(host) === tunnel)
|
|
181
|
+
Object.assign(tunnel, { status: "login", loginUrl });
|
|
182
|
+
})
|
|
183
|
+
.then(() => {
|
|
184
|
+
if (this.tunnels.get(host) !== tunnel)
|
|
185
|
+
return;
|
|
186
|
+
Object.assign(tunnel, { status: "starting", loginUrl: undefined });
|
|
187
|
+
this.run(host, tunnel, file, ["tunnel", "--no-autoupdate", "run", "--url", this.origin(), name], (text) => {
|
|
188
|
+
if (REGISTERED_PATTERN.test(text))
|
|
189
|
+
Object.assign(tunnel, { status: "up", url: `https://${domain}` });
|
|
190
|
+
});
|
|
191
|
+
})
|
|
192
|
+
.catch((error) => {
|
|
193
|
+
if (this.tunnels.get(host) !== tunnel)
|
|
194
|
+
return;
|
|
195
|
+
Object.assign(tunnel, { status: "error", error: error?.message || String(error), loginUrl: undefined });
|
|
196
|
+
logger_1.default.warn(`🌍 tunnel for ${host} failed: ${tunnel.error}`);
|
|
161
197
|
});
|
|
198
|
+
}
|
|
199
|
+
run(host, tunnel, file, args, onData) {
|
|
200
|
+
const child = (0, child_process_1.spawn)(file, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
162
201
|
tunnel.child = child;
|
|
163
202
|
let tail = "";
|
|
164
203
|
const read = (chunk) => {
|
|
165
204
|
const text = chunk.toString("utf8");
|
|
166
205
|
tail = (tail + text).slice(-2000);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
206
|
+
if (tunnel.status === "up")
|
|
207
|
+
return;
|
|
208
|
+
onData(text);
|
|
209
|
+
if (tunnel.status === "up")
|
|
210
|
+
logger_1.default.info(`🌍 ${host} is public at ${tunnel.url}`);
|
|
172
211
|
};
|
|
173
212
|
child.stdout?.on("data", read);
|
|
174
213
|
child.stderr?.on("data", read);
|
|
@@ -177,17 +216,18 @@ class TunnelManager {
|
|
|
177
216
|
if (this.tunnels.get(host) !== tunnel)
|
|
178
217
|
return;
|
|
179
218
|
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,
|
|
219
|
+
Object.assign(tunnel, { status: "error", error: reason || `cloudflared exited (${code})`, url: undefined, child: undefined });
|
|
220
|
+
if (!tunnel.domain)
|
|
221
|
+
tunnel.publicHost = undefined;
|
|
181
222
|
logger_1.default.warn(`🌍 tunnel for ${host} stopped: ${tunnel.error}`);
|
|
182
223
|
});
|
|
183
224
|
}
|
|
184
225
|
stop(host) {
|
|
185
226
|
const tunnel = this.tunnels.get(host);
|
|
186
227
|
this.tunnels.delete(host);
|
|
187
|
-
if (tunnel?.child)
|
|
188
|
-
tunnel.child.kill();
|
|
228
|
+
if (tunnel?.child || tunnel?.status === "login")
|
|
189
229
|
logger_1.default.info(`🌍 ${host} is no longer public`);
|
|
190
|
-
|
|
230
|
+
tunnel?.child?.kill();
|
|
191
231
|
}
|
|
192
232
|
stopAll() {
|
|
193
233
|
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