locadot 2.1.0-beta.1 → 2.1.0-beta.2
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 +7 -0
- package/README.md +7 -5
- package/dist/cli/commands/remote.js +2 -2
- package/dist/constants/template.js +2 -1
- package/dist/dashboard/api.js +17 -6
- package/dist/dashboard/index.js +2 -1
- package/dist/dashboard/login.js +2 -7
- package/dist/dashboard/page.js +7 -7
- package/dist/proxy/hub.js +27 -8
- package/dist/proxy/remote.js +5 -2
- package/dist/proxy/request.js +3 -1
- package/dist/proxy/router.js +14 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -25,6 +25,13 @@
|
|
|
25
25
|
to a signed-out visitor. It uses a 7-day HttpOnly SameSite=Strict session, rate-limits wrong passwords, and adds a Sign out button to the sidebar.
|
|
26
26
|
Mapped hosts are not affected, and neither are scripts or the CLI that send `X-Locadot-Token`.
|
|
27
27
|
|
|
28
|
+
### Changed
|
|
29
|
+
- `<domain>.localhost` now opens the sender's own dashboard for an admin peer, with full control (hosts, sharing, peers,
|
|
30
|
+
settings), instead of a landing page; no port needed. The port picker moved to `<domain>.localhost/_locadot/ports`.
|
|
31
|
+
The page never carries the sender's API token, the API only accepts calls from that `<domain>.localhost` origin, the sender
|
|
32
|
+
checks the admin role on every request, `hub:localhost off` turns it off, and a dashboard password still asks for sign-in.
|
|
33
|
+
Senders must run this version too; an older sender answers `<domain>.localhost` with a 404.
|
|
34
|
+
|
|
28
35
|
### Fixed
|
|
29
36
|
- The proxy can now tell a user's `LOCADOT_HTTP_PORT`/`LOCADOT_HTTPS_PORT` from the copies the CLI pins on every spawn
|
|
30
37
|
(`LOCADOT_USER_PORTS`), so saved ports aren't reported as env-overridden.
|
package/README.md
CHANGED
|
@@ -111,10 +111,10 @@ short-lived pairing string, and the sender's mappings then show up in the receiv
|
|
|
111
111
|
| `locadot hub:setup --domain dev.example.com` | Sender: publish through your own named tunnel on your Cloudflare domain (runs `cloudflared` login, creates the tunnel and routes DNS). The address stays the same. |
|
|
112
112
|
| `locadot hub:quick` | Sender: publish on a trycloudflare URL. No account is needed, but **the URL changes if the tunnel restarts**, so receivers have to run `remote:url`. |
|
|
113
113
|
| `locadot hub` / `hub:off` | Show the hub status and URL, or stop it. |
|
|
114
|
-
| `locadot hub:localhost <on\|off>` | Sender: allow or block admin peers from
|
|
114
|
+
| `locadot hub:localhost <on\|off>` | Sender: allow or block admin peers from opening this machine's dashboard and any port on its localhost. |
|
|
115
115
|
| `locadot share --role viewer\|editor\|admin [--hosts a.localhost,b.localhost]` | Sender: print a pairing string. It works once and expires after 5 minutes. `--hosts` limits a viewer to those mappings. |
|
|
116
116
|
| `locadot peers` / `peers:role <id> <role>` / `peers:revoke <id>` | Sender: list the connected machines, change a role, or cut one off. |
|
|
117
|
-
| `locadot connect "<pairing string>" [--name alice] [--domain dev]` | Receiver: connect and import the sender's mappings. If a name clashes, `app.localhost` becomes `app.alice.localhost`. Admins get a local domain
|
|
117
|
+
| `locadot connect "<pairing string>" [--name alice] [--domain dev]` | Receiver: connect and import the sender's mappings. If a name clashes, `app.localhost` becomes `app.alice.localhost`. Admins get a local domain that opens the sender's dashboard and localhost (see below); `--domain` picks it, otherwise it's a random `adjective-noun`. |
|
|
118
118
|
| `locadot remotes` / `remote:sync <name>` / `disconnect <name>` | Receiver: list the connections (with their domain), pull new mappings, or remove the connection and its names. |
|
|
119
119
|
| `locadot remote:domain <name> [domain] [--off]` | Receiver, admin only: set the local domain for a remote's localhost, randomize it (no domain given), or remove it (`--off`). |
|
|
120
120
|
| `locadot remote:alias <name> <remote host> <local host>` | Receiver: give a sender mapping another local name, e.g. `he.localhost`. |
|
|
@@ -123,10 +123,12 @@ short-lived pairing string, and the sender's mappings then show up in the receiv
|
|
|
123
123
|
|
|
124
124
|
Roles: **viewer** can only browse (optionally limited to the mappings picked on the invite). **editor** can also add and change
|
|
125
125
|
mappings on the sender. **admin** can also delete them and change sharing, and (unless the sender turned it off with
|
|
126
|
-
`hub:localhost off`)
|
|
127
|
-
`https://<port>.<domain>.localhost` (`sender's localhost:<port>`).
|
|
126
|
+
`hub:localhost off`) open the sender's own dashboard at `https://<domain>.localhost` and manage it as if they were sitting at it,
|
|
127
|
+
and reach any port on the sender's localhost at `https://<port>.<domain>.localhost` (`sender's localhost:<port>`). The port
|
|
128
|
+
picker is at `https://<domain>.localhost/_locadot/ports`. The admin's browser never gets the sender's API token, so revoking or
|
|
129
|
+
demoting the peer cuts off its dashboard access at once; a dashboard password on the sender still applies. Traffic goes receiver → Cloudflare → sender →
|
|
128
130
|
target, so an editor can reach anything the sender's machine can reach. Only give that role to people you trust. Only hashes
|
|
129
|
-
of the tokens are stored, and failed attempts are rate limited.
|
|
131
|
+
of the tokens are stored, and failed attempts are rate limited. Only admins can open the sender's dashboard through the hub.
|
|
130
132
|
The dashboard has **Remote access** and **Connected machines** cards for all of this.
|
|
131
133
|
|
|
132
134
|
### Certificates
|
|
@@ -219,10 +219,10 @@ async function peersRevoke(id) {
|
|
|
219
219
|
links_1.default.revoke(id);
|
|
220
220
|
(0, shared_1.print)(`🗑️ Revoked peer ${id}.`);
|
|
221
221
|
}
|
|
222
|
-
/** After connect/sync/remote:domain: the sender's
|
|
222
|
+
/** After connect/sync/remote:domain: the sender's dashboard and localhost, reachable when this remote has a domain and we're admin. */
|
|
223
223
|
const printDomain = (remote) => {
|
|
224
224
|
if (remote.domain && remote.role === "admin") {
|
|
225
|
-
(0, shared_1.print)(`🖥️ ${remote.sender.hostname}
|
|
225
|
+
(0, shared_1.print)(`🖥️ ${remote.sender.hostname} dashboard: ${(0, urls_1.urlFor)(`${remote.domain}.localhost`)} (any port: ${(0, urls_1.urlFor)(`<port>.${remote.domain}.localhost`)})`);
|
|
226
226
|
}
|
|
227
227
|
};
|
|
228
228
|
async function connect(value, options) {
|
|
@@ -62,9 +62,10 @@ const upstreamDown = (host, target, reason, dashboardUrl) => page("locadot: upst
|
|
|
62
62
|
<code>${(0, escape_1.escapeHtml)(host)}</code> → <code>${(0, escape_1.escapeHtml)(target)}</code><br/><br/>
|
|
63
63
|
${(0, escape_1.escapeHtml)(reason)}. Is the app running?`, dashboardUrl);
|
|
64
64
|
exports.upstreamDown = upstreamDown;
|
|
65
|
-
/** Receiver side:
|
|
65
|
+
/** Receiver side: `<domain>.localhost/_locadot/ports`, the port picker and shared hosts of an admin remote. */
|
|
66
66
|
const remoteLocalhost = (opts) => page(`locadot: ${opts.sender} localhost`, opts.allowed
|
|
67
67
|
? `<strong style="color: #7ee2a8;">${(0, escape_1.escapeHtml)(opts.sender)}</strong>: localhost<br/><br/>
|
|
68
|
+
Its dashboard is <a href="/">${(0, escape_1.escapeHtml)(opts.domain)}.localhost</a>.<br/>
|
|
68
69
|
Any port on that machine is at <code>${(0, escape_1.escapeHtml)(opts.portUrl.replace("PORT", "<port>"))}</code>.<br/><br/>
|
|
69
70
|
<form id="go" style="display:flex;gap:8px">
|
|
70
71
|
<input id="port" inputmode="numeric" pattern="[0-9]{1,5}" placeholder="3000" required
|
package/dist/dashboard/api.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.ApiError = void 0;
|
|
39
|
+
exports.isDashboardOrigin = exports.PEER_TOKEN = exports.ApiError = void 0;
|
|
40
40
|
exports.assertTrusted = assertTrusted;
|
|
41
41
|
exports.readJson = readJson;
|
|
42
42
|
exports.route = route;
|
|
@@ -59,6 +59,7 @@ const links_1 = __importStar(require("../lib/links"));
|
|
|
59
59
|
const hub_config_1 = __importDefault(require("../lib/hub-config"));
|
|
60
60
|
const remotes_1 = __importStar(require("../lib/remotes"));
|
|
61
61
|
const ui_auth_1 = __importDefault(require("../lib/ui-auth"));
|
|
62
|
+
const request_1 = require("../proxy/request");
|
|
62
63
|
const MAX_BODY = 64 * 1024;
|
|
63
64
|
class ApiError extends Error {
|
|
64
65
|
constructor(status, message, hint) {
|
|
@@ -74,15 +75,25 @@ const safeEqual = (a, b) => {
|
|
|
74
75
|
const y = Buffer.from(b);
|
|
75
76
|
return x.length === y.length && crypto_1.default.timingSafeEqual(x, y);
|
|
76
77
|
};
|
|
77
|
-
|
|
78
|
+
/**
|
|
79
|
+
* What the page carries instead of the API token when an admin peer views it through the hub: the hub already
|
|
80
|
+
* authenticated the peer, and the real token would outlive a revoked peer.
|
|
81
|
+
*/
|
|
82
|
+
exports.PEER_TOKEN = "hub-peer";
|
|
83
|
+
/** The page's own origin: the dashboard hosts, or for an admin peer the receiver's `<domain>.localhost`. */
|
|
84
|
+
const isDashboardOrigin = (req, origin) => {
|
|
78
85
|
try {
|
|
79
|
-
const
|
|
80
|
-
|
|
86
|
+
const url = new URL(origin);
|
|
87
|
+
const peerOrigin = (0, request_1.peerOriginOf)(req);
|
|
88
|
+
if (peerOrigin)
|
|
89
|
+
return url.host === peerOrigin;
|
|
90
|
+
return constants_1.default.dashboardHosts.includes(url.hostname.replace(/^\[|\]$/g, ""));
|
|
81
91
|
}
|
|
82
92
|
catch {
|
|
83
93
|
return false;
|
|
84
94
|
}
|
|
85
95
|
};
|
|
96
|
+
exports.isDashboardOrigin = isDashboardOrigin;
|
|
86
97
|
/**
|
|
87
98
|
* Any web page can make the browser send requests to localhost, so every mutating
|
|
88
99
|
* call must prove it came from the dashboard page itself (or a local script that
|
|
@@ -91,12 +102,12 @@ const isDashboardOrigin = (origin) => {
|
|
|
91
102
|
*/
|
|
92
103
|
function assertTrusted(req, ctx) {
|
|
93
104
|
const origin = req.headers.origin;
|
|
94
|
-
if (origin && !isDashboardOrigin(origin))
|
|
105
|
+
if (origin && !(0, exports.isDashboardOrigin)(req, origin))
|
|
95
106
|
throw new ApiError(403, "Cross-origin requests are not allowed.");
|
|
96
107
|
if (req.headers["sec-fetch-site"] === "cross-site")
|
|
97
108
|
throw new ApiError(403, "Cross-site requests are not allowed.");
|
|
98
109
|
const token = req.headers["x-locadot-token"];
|
|
99
|
-
if (typeof token !== "string" || !safeEqual(token, ctx.token)) {
|
|
110
|
+
if (typeof token !== "string" || !safeEqual(token, (0, request_1.peerOriginOf)(req) ? exports.PEER_TOKEN : ctx.token)) {
|
|
100
111
|
throw new ApiError(403, "Missing or wrong X-Locadot-Token.", `Token file: ${constants_1.default.paths.API_TOKEN}`);
|
|
101
112
|
}
|
|
102
113
|
}
|
package/dist/dashboard/index.js
CHANGED
|
@@ -40,6 +40,7 @@ exports.handleDashboardRequest = handleDashboardRequest;
|
|
|
40
40
|
const crypto_1 = __importDefault(require("crypto"));
|
|
41
41
|
const page_1 = require("./page");
|
|
42
42
|
const api_1 = require("./api");
|
|
43
|
+
const request_1 = require("../proxy/request");
|
|
43
44
|
const login_1 = require("./login");
|
|
44
45
|
const ui_auth_1 = __importStar(require("../lib/ui-auth"));
|
|
45
46
|
const system_1 = require("../lib/system");
|
|
@@ -141,7 +142,7 @@ function handleDashboardRequest(req, res, ctx) {
|
|
|
141
142
|
}
|
|
142
143
|
switch (url.pathname) {
|
|
143
144
|
case "/":
|
|
144
|
-
sendBody(res, method, 200, "text/html; charset=utf-8", (0, page_1.renderPage)(nonce, ctx.token, ui_auth_1.default.enabled()));
|
|
145
|
+
sendBody(res, method, 200, "text/html; charset=utf-8", (0, page_1.renderPage)(nonce, (0, request_1.peerOriginOf)(req) ? api_1.PEER_TOKEN : ctx.token, ui_auth_1.default.enabled()));
|
|
145
146
|
return;
|
|
146
147
|
case "/favicon.ico":
|
|
147
148
|
res.statusCode = 204;
|
package/dist/dashboard/login.js
CHANGED
|
@@ -39,9 +39,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
39
39
|
exports.sessionCookie = exports.signedIn = void 0;
|
|
40
40
|
exports.gate = gate;
|
|
41
41
|
const crypto_1 = __importDefault(require("crypto"));
|
|
42
|
-
const constants_1 = __importDefault(require("../constants"));
|
|
43
42
|
const logger_1 = __importDefault(require("../utils/logger"));
|
|
44
43
|
const escape_1 = require("./escape");
|
|
44
|
+
const api_1 = require("./api");
|
|
45
45
|
const ui_auth_1 = __importStar(require("../lib/ui-auth"));
|
|
46
46
|
const MAX_LOGIN_BODY = 4096;
|
|
47
47
|
const loginPage = (nonce, error) => `<!doctype html>
|
|
@@ -100,12 +100,7 @@ const sameOrigin = (req) => {
|
|
|
100
100
|
const origin = req.headers.origin;
|
|
101
101
|
if (!origin || origin === "null")
|
|
102
102
|
return !origin;
|
|
103
|
-
|
|
104
|
-
return constants_1.default.dashboardHosts.includes(new URL(origin).hostname.replace(/^\[|\]$/g, ""));
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
103
|
+
return (0, api_1.isDashboardOrigin)(req, origin);
|
|
109
104
|
};
|
|
110
105
|
const cookie = (req, value, maxAge) => `${ui_auth_1.SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${isTls(req) ? "; Secure" : ""}`;
|
|
111
106
|
const sendLogin = (res, method, nonce, status, error) => {
|
package/dist/dashboard/page.js
CHANGED
|
@@ -1782,7 +1782,7 @@ const clientJs = `
|
|
|
1782
1782
|
apiFetch("/api/remotes", { method: "POST", body: JSON.stringify({ string: value, name: name || undefined, domain: domain || undefined }) })
|
|
1783
1783
|
.then(function (data) {
|
|
1784
1784
|
var r = data.remote;
|
|
1785
|
-
showToast("Connected to " + r.name, "success", r.domain && r.role === "admin" ? "Its
|
|
1785
|
+
showToast("Connected to " + r.name, "success", r.domain && r.role === "admin" ? "Its dashboard: " + localUrl(r.domain) : (domain && r.role !== "admin" ? "Domain ignored: you're " + r.role + ", not admin." : undefined));
|
|
1786
1786
|
connectForm.reset();
|
|
1787
1787
|
return Promise.all([loadRemotes(), loadHosts()]);
|
|
1788
1788
|
})
|
|
@@ -1854,7 +1854,7 @@ const clientJs = `
|
|
|
1854
1854
|
link.target = "_blank";
|
|
1855
1855
|
link.rel = "noopener noreferrer";
|
|
1856
1856
|
link.textContent = remote.domain + ".localhost";
|
|
1857
|
-
link.title = "Any port: " + localUrl(remote.domain, "<port>");
|
|
1857
|
+
link.title = "Its dashboard. Any port: " + localUrl(remote.domain, "<port>");
|
|
1858
1858
|
row.appendChild(link);
|
|
1859
1859
|
row.appendChild(makeCopyButton(function () { return localUrl(remote.domain); }));
|
|
1860
1860
|
var change = document.createElement("button");
|
|
@@ -1880,7 +1880,7 @@ const clientJs = `
|
|
|
1880
1880
|
apiFetch("/api/hub/localhost", { method: "PUT", body: JSON.stringify({ enabled: next }) })
|
|
1881
1881
|
.then(function () {
|
|
1882
1882
|
setSwitch(hubLocalhostSwitch, next);
|
|
1883
|
-
showToast(next ? "Admins can open
|
|
1883
|
+
showToast(next ? "Admins can open this dashboard and any port" : "Localhost access is off", "success");
|
|
1884
1884
|
})
|
|
1885
1885
|
.catch(function (err) { apiError(err, "Couldn't change localhost access"); })
|
|
1886
1886
|
.then(function () { hubLocalhostSwitch.disabled = false; });
|
|
@@ -3309,10 +3309,10 @@ function renderPage(nonce, token, uiAuth = false) {
|
|
|
3309
3309
|
</div>
|
|
3310
3310
|
<div id="hub-localhost-row" class="toggle-row" hidden>
|
|
3311
3311
|
<div>
|
|
3312
|
-
<div class="label">Admins can open any port</div>
|
|
3313
|
-
<div class="tile-sub">Admin peers
|
|
3312
|
+
<div class="label">Admins can open this dashboard and any port</div>
|
|
3313
|
+
<div class="tile-sub">Admin peers get this dashboard at <span class="mono"><domain>.localhost</span> and this machine's localhost at <span class="mono"><port>.<domain>.localhost</span> on their side.</div>
|
|
3314
3314
|
</div>
|
|
3315
|
-
<button type="button" id="hub-localhost" class="switch" role="switch" aria-checked="true" aria-label="Let admins open any port on this machine"><span class="switch-knob"></span></button>
|
|
3315
|
+
<button type="button" id="hub-localhost" class="switch" role="switch" aria-checked="true" aria-label="Let admins open this dashboard and any port on this machine"><span class="switch-knob"></span></button>
|
|
3316
3316
|
</div>
|
|
3317
3317
|
</div>
|
|
3318
3318
|
|
|
@@ -3361,7 +3361,7 @@ function renderPage(nonce, token, uiAuth = false) {
|
|
|
3361
3361
|
<div class="role-table">
|
|
3362
3362
|
<div><strong>Viewer</strong><span>browse the hosts you pick</span></div>
|
|
3363
3363
|
<div><strong>Editor</strong><span>also add and change hosts here</span></div>
|
|
3364
|
-
<div><strong>Admin</strong><span>also delete hosts, change sharing, and open any port on this machine</span></div>
|
|
3364
|
+
<div><strong>Admin</strong><span>also delete hosts, change sharing, and open this dashboard and any port on this machine</span></div>
|
|
3365
3365
|
</div>
|
|
3366
3366
|
<p class="dim">Editors can reach anything this machine can. Change or revoke a role any time under Peers.</p>
|
|
3367
3367
|
</div>
|
package/dist/proxy/hub.js
CHANGED
|
@@ -45,7 +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 localhost_1 = require("../lib/localhost");
|
|
48
|
+
const localhost_1 = __importStar(require("../lib/localhost"));
|
|
49
49
|
const registry_1 = require("../lib/registry");
|
|
50
50
|
const request_1 = require("./request");
|
|
51
51
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
@@ -80,6 +80,18 @@ const clientIp = (req) => {
|
|
|
80
80
|
const value = Array.isArray(header) ? header[0] : header;
|
|
81
81
|
return value || req.socket.remoteAddress || "unknown";
|
|
82
82
|
};
|
|
83
|
+
/** `X-Original-Host` from the receiver, only if it is a `*.localhost` name with an optional port. */
|
|
84
|
+
const receiverHost = (req) => {
|
|
85
|
+
const header = req.headers["x-original-host"];
|
|
86
|
+
const raw = typeof header === "string" ? header.trim().toLowerCase() : "";
|
|
87
|
+
const match = /^([^:]+)(?::(\d{1,5}))?$/.exec(raw);
|
|
88
|
+
return match && localhost_1.default.isValidLocalhostDomain(match[1]) ? raw : undefined;
|
|
89
|
+
};
|
|
90
|
+
const stripLocadotHeaders = (req) => {
|
|
91
|
+
for (const name of Object.keys(req.headers))
|
|
92
|
+
if (name.toLowerCase().startsWith("x-locadot-"))
|
|
93
|
+
delete req.headers[name];
|
|
94
|
+
};
|
|
83
95
|
const bearerToken = (req) => {
|
|
84
96
|
const header = req.headers.authorization;
|
|
85
97
|
const match = typeof header === "string" ? /^Bearer\s+(\S+)$/i.exec(header) : null;
|
|
@@ -103,11 +115,22 @@ function classify(req, publicHost, hosts, opts) {
|
|
|
103
115
|
return { kind: "deny", status: 401, message: "Unauthorized" };
|
|
104
116
|
}
|
|
105
117
|
const portHeader = req.headers["x-locadot-port"];
|
|
106
|
-
|
|
118
|
+
const wantsDashboard = req.headers["x-locadot-dashboard"] !== undefined;
|
|
119
|
+
if (portHeader !== undefined || wantsDashboard) {
|
|
107
120
|
if (!links_1.default.can(peer, "localhost"))
|
|
108
121
|
return { kind: "deny", status: 403, message: "Forbidden" };
|
|
109
122
|
if (opts?.localhost === false)
|
|
110
123
|
return { kind: "deny", status: 403, message: "Localhost access is off on this machine." };
|
|
124
|
+
// The dashboard's page and API trust this origin in place of their own (see peerOriginOf).
|
|
125
|
+
if (wantsDashboard) {
|
|
126
|
+
const origin = receiverHost(req);
|
|
127
|
+
if (!origin)
|
|
128
|
+
return { kind: "deny", status: 400, message: "Bad request" };
|
|
129
|
+
// Keep X-Locadot-Token: the dashboard page sends it on every mutating call.
|
|
130
|
+
for (const name of ["x-locadot-peer", "x-locadot-dashboard", "x-locadot-host", "x-locadot-port"])
|
|
131
|
+
delete req.headers[name];
|
|
132
|
+
return { kind: "dashboard", peer, origin };
|
|
133
|
+
}
|
|
111
134
|
const raw = typeof portHeader === "string" ? portHeader.trim() : "";
|
|
112
135
|
if (!/^[0-9]+$/.test(raw))
|
|
113
136
|
return { kind: "deny", status: 400, message: "Bad request" };
|
|
@@ -116,9 +139,7 @@ function classify(req, publicHost, hosts, opts) {
|
|
|
116
139
|
return { kind: "deny", status: 400, message: "Bad request" };
|
|
117
140
|
if (opts?.blockedPorts?.includes(port))
|
|
118
141
|
return { kind: "deny", status: 403, message: "Forbidden" };
|
|
119
|
-
|
|
120
|
-
if (name.toLowerCase().startsWith("x-locadot-"))
|
|
121
|
-
delete req.headers[name];
|
|
142
|
+
stripLocadotHeaders(req);
|
|
122
143
|
return { kind: "local", port, peer };
|
|
123
144
|
}
|
|
124
145
|
const hostHeader = req.headers["x-locadot-host"];
|
|
@@ -131,9 +152,7 @@ function classify(req, publicHost, hosts, opts) {
|
|
|
131
152
|
if (!visible.includes(host)) {
|
|
132
153
|
return { kind: "deny", status: 403, message: "Forbidden" };
|
|
133
154
|
}
|
|
134
|
-
|
|
135
|
-
if (name.toLowerCase().startsWith("x-locadot-"))
|
|
136
|
-
delete req.headers[name];
|
|
155
|
+
stripLocadotHeaders(req);
|
|
137
156
|
return { kind: "app", host, peer };
|
|
138
157
|
}
|
|
139
158
|
/* ---------- /_locadot/v1 API ---------- */
|
package/dist/proxy/remote.js
CHANGED
|
@@ -3,7 +3,7 @@ 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.localOptions = exports.canReachLocalhost = exports.localFor = exports.remoteOptions = exports.remoteFor = void 0;
|
|
6
|
+
exports.localOptions = exports.canReachLocalhost = exports.localFor = exports.LANDING_PATH = exports.remoteOptions = exports.remoteFor = void 0;
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const constants_1 = __importDefault(require("../constants"));
|
|
9
9
|
const request_1 = require("./request");
|
|
@@ -50,6 +50,8 @@ const remoteOptions = (req, entry, remote) => ({
|
|
|
50
50
|
},
|
|
51
51
|
});
|
|
52
52
|
exports.remoteOptions = remoteOptions;
|
|
53
|
+
/** Receiver side: the port picker and shared-host list, on `<domain>.localhost` itself (the rest of it is the dashboard). */
|
|
54
|
+
exports.LANDING_PATH = "/_locadot/ports";
|
|
53
55
|
/** Receiver side: `<domain>.localhost` / `<port>.<domain>.localhost` → the admin remote that owns `domain`. */
|
|
54
56
|
const localFor = (host) => {
|
|
55
57
|
if (!host.endsWith(".localhost"))
|
|
@@ -72,6 +74,7 @@ const localFor = (host) => {
|
|
|
72
74
|
exports.localFor = localFor;
|
|
73
75
|
const canReachLocalhost = (remote) => remote.role === "admin" && remote.localhost !== false;
|
|
74
76
|
exports.canReachLocalhost = canReachLocalhost;
|
|
77
|
+
/** Without a port the sender serves its dashboard. */
|
|
75
78
|
const localOptions = (req, remote, port) => ({
|
|
76
79
|
target: remote.url,
|
|
77
80
|
changeOrigin: true,
|
|
@@ -85,7 +88,7 @@ const localOptions = (req, remote, port) => ({
|
|
|
85
88
|
headers: {
|
|
86
89
|
"X-Original-Host": req.headers.host || "",
|
|
87
90
|
"X-Locadot-Peer": remote.token,
|
|
88
|
-
"X-Locadot-Port": String(port),
|
|
91
|
+
...(port ? { "X-Locadot-Port": String(port) } : { "X-Locadot-Dashboard": "1" }),
|
|
89
92
|
},
|
|
90
93
|
});
|
|
91
94
|
exports.localOptions = localOptions;
|
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.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
|
|
3
|
+
exports.isTls = exports.viaOf = exports.peerOriginOf = 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 peerOriginOf = (req) => tags.get(req)?.peerOrigin;
|
|
25
|
+
exports.peerOriginOf = peerOriginOf;
|
|
24
26
|
const viaOf = (req) => tags.get(req)?.via;
|
|
25
27
|
exports.viaOf = viaOf;
|
|
26
28
|
/** Tunnel visitors are on https even though cloudflared talks plain http to us. */
|
package/dist/proxy/router.js
CHANGED
|
@@ -23,6 +23,10 @@ const resolveHost = (req, ctx, hub) => {
|
|
|
23
23
|
(0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure() });
|
|
24
24
|
return hub.host;
|
|
25
25
|
}
|
|
26
|
+
if (hub?.kind === "dashboard") {
|
|
27
|
+
(0, request_1.tag)(req, { host: "localhost", remote: true, secure: ctx.hub.secure(), peerOrigin: hub.origin });
|
|
28
|
+
return "localhost";
|
|
29
|
+
}
|
|
26
30
|
if (hub?.kind === "local") {
|
|
27
31
|
const host = `localhost:${hub.port}`;
|
|
28
32
|
(0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure() });
|
|
@@ -161,25 +165,30 @@ function forwardRemote(req, res, ctx, host, entry) {
|
|
|
161
165
|
res.end((0, template_1.upstreamDown)(host, `${remote.name}: ${entry.remote.host}`, reason(err) || "error", dashboardUrl(req)));
|
|
162
166
|
});
|
|
163
167
|
}
|
|
164
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Receiver side: `<domain>.localhost` is the sender's dashboard and `<port>.<domain>.localhost` its localhost:<port>.
|
|
170
|
+
* The sender checks the role again on every request.
|
|
171
|
+
*/
|
|
165
172
|
function forwardLocal(req, res, ctx, host, local) {
|
|
166
173
|
const { remote, domain, port } = local;
|
|
167
174
|
const allowed = (0, remote_1.canReachLocalhost)(remote);
|
|
168
|
-
|
|
175
|
+
const landing = !port && (req.url || "/").split("?")[0] === remote_1.LANDING_PATH;
|
|
176
|
+
if (!allowed || landing) {
|
|
169
177
|
const hosts = (ctx.remoteHosts?.(remote.name) || []).map((h) => ({ local: h, url: `${(0, urls_1.urlFor)(h, (0, request_1.isTls)(req))}/` }));
|
|
170
178
|
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);
|
|
179
|
+
html(res, port && !allowed ? 403 : 200, body);
|
|
172
180
|
return;
|
|
173
181
|
}
|
|
182
|
+
const label = `${remote.name}: ${port ? `localhost:${port}` : "dashboard"}`;
|
|
174
183
|
const started = Date.now();
|
|
175
184
|
res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
|
|
176
185
|
ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port), (err) => {
|
|
177
|
-
logger_1.default.warn(`${host} → ${
|
|
186
|
+
logger_1.default.warn(`${host} → ${label}: ${reason(err)}`);
|
|
178
187
|
if (res.headersSent) {
|
|
179
188
|
res.destroy();
|
|
180
189
|
return;
|
|
181
190
|
}
|
|
182
|
-
html(res, 502, (0, template_1.upstreamDown)(host,
|
|
191
|
+
html(res, 502, (0, template_1.upstreamDown)(host, label, reason(err) || "error", dashboardUrl(req)));
|
|
183
192
|
});
|
|
184
193
|
}
|
|
185
194
|
function handleUpgrade(req, socket, head, ctx) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "locadot",
|
|
3
|
-
"version": "2.1.0-beta.
|
|
3
|
+
"version": "2.1.0-beta.2",
|
|
4
4
|
"description": "Secure your local development environment with HTTPS and custom domains like dev.localhost.",
|
|
5
5
|
"homepage": "https://www.npmjs.com/package/locadot",
|
|
6
6
|
"main": "dist/index.js",
|