locadot 2.1.0-beta.1 → 2.1.0-beta.3
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 +14 -0
- package/README.md +13 -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/lib/remotes.js +24 -9
- package/dist/proxy/cors.js +6 -2
- package/dist/proxy/hub.js +70 -10
- package/dist/proxy/remote.js +8 -4
- package/dist/proxy/request.js +5 -1
- package/dist/proxy/response.js +2 -1
- package/dist/proxy/rewrite.js +3 -1
- package/dist/proxy/router.js +31 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -25,9 +25,23 @@
|
|
|
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.
|
|
38
|
+
- `--cors` through remote access: a shared `--cors` mapping is now `--cors` on the receiver too (shown in `list` and the
|
|
39
|
+
dashboard, kept in step by `remote:sync` and `remote:update`). The sender used to rewrite its apps' origins in pages to its
|
|
40
|
+
*own* local URLs, which don't exist on the receiver. Now the receiver sends the names it uses (`X-Locadot-Names`), so a
|
|
41
|
+
page's API calls go to the receiver's name for that mapping (e.g. `api.alice.localhost` after a clash), on the receiver's
|
|
42
|
+
port. The upstream still sees the calling page's real origin. `<port>.<domain>.localhost` gets the CORS handling of a
|
|
43
|
+
`--cors` mapping on that port. The sender keeps names only for mappings the peer can see, and only as `*.localhost` URLs.
|
|
44
|
+
Both machines need this version for the rewriting; with an older receiver the sender behaves as before.
|
|
31
45
|
|
|
32
46
|
## 2.1.0-beta.0 (2026-09-27)
|
|
33
47
|
|
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,12 +123,20 @@ 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
|
|
|
134
|
+
`--cors` carries over: a sender's `--cors` mapping is `--cors` on the receiver too, with no extra flag. Preflights and CORS
|
|
135
|
+
headers work as they do locally, and origins in the sender's pages are rewritten to the receiver's own names and port (so
|
|
136
|
+
`http://localhost:8000` in a page becomes `http://api.alice.localhost` if that's the receiver's name for `api.localhost`).
|
|
137
|
+
An admin's `<port>.<domain>.localhost` gets the same treatment when a `--cors` mapping points at that port.
|
|
138
|
+
`remote:sync` picks up a sender turning `--cors` on or off.
|
|
139
|
+
|
|
132
140
|
### Certificates
|
|
133
141
|
|
|
134
142
|
| Command | What it does |
|
|
@@ -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/lib/remotes.js
CHANGED
|
@@ -91,6 +91,8 @@ const isDomainLabel = (value) => {
|
|
|
91
91
|
exports.isDomainLabel = isDomainLabel;
|
|
92
92
|
/** Sender allows the peer onto its localhost, treating an older sender (no `localhost` field) as "yes" when admin. */
|
|
93
93
|
const localhostAllowed = (data, role) => typeof data.localhost === "boolean" ? data.localhost : role === "admin";
|
|
94
|
+
/** A shared --cors mapping is --cors here too; the sender still does the CORS work (see router forwardRemote). */
|
|
95
|
+
const inherited = (h) => (h.cors ? { cors: true } : {});
|
|
94
96
|
/** Domains already spoken for: other remotes' `domain`, and any single-label `*.localhost` registry host. */
|
|
95
97
|
const takenDomains = (excludeName) => {
|
|
96
98
|
const store = readFile();
|
|
@@ -212,7 +214,7 @@ class Remotes {
|
|
|
212
214
|
continue;
|
|
213
215
|
}
|
|
214
216
|
taken.add(local);
|
|
215
|
-
registry.hosts[local] = { target: remote.url, remote: { name, host: h.host }, createdAt: now, updatedAt: now };
|
|
217
|
+
registry.hosts[local] = { target: remote.url, remote: { name, host: h.host }, ...inherited(h), createdAt: now, updatedAt: now };
|
|
216
218
|
mapped.push({ local, host: h.host });
|
|
217
219
|
}
|
|
218
220
|
});
|
|
@@ -244,9 +246,14 @@ class Remotes {
|
|
|
244
246
|
const now = new Date().toISOString();
|
|
245
247
|
await registry_1.default.mutate((registry) => {
|
|
246
248
|
const already = new Set();
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
249
|
+
const flags = new Map(hosts.map((h) => [h.host, h]));
|
|
250
|
+
for (const [local, entry] of Object.entries(registry.hosts)) {
|
|
251
|
+
if (entry.remote?.name !== name)
|
|
252
|
+
continue;
|
|
253
|
+
already.add(entry.remote.host);
|
|
254
|
+
const h = flags.get(entry.remote.host);
|
|
255
|
+
if (h && Boolean(h.cors) !== Boolean(entry.cors))
|
|
256
|
+
registry.hosts[local] = { ...entry, cors: h.cors || undefined };
|
|
250
257
|
}
|
|
251
258
|
const taken = new Set(Object.keys(registry.hosts));
|
|
252
259
|
for (const h of hosts) {
|
|
@@ -256,7 +263,7 @@ class Remotes {
|
|
|
256
263
|
if (!local)
|
|
257
264
|
continue;
|
|
258
265
|
taken.add(local);
|
|
259
|
-
registry.hosts[local] = { target: updated.url, remote: { name, host: h.host }, createdAt: now, updatedAt: now };
|
|
266
|
+
registry.hosts[local] = { target: updated.url, remote: { name, host: h.host }, ...inherited(h), createdAt: now, updatedAt: now };
|
|
260
267
|
}
|
|
261
268
|
});
|
|
262
269
|
const registry = registry_1.default.read();
|
|
@@ -270,13 +277,14 @@ class Remotes {
|
|
|
270
277
|
const local = localhost_1.default.requireHost(localHost);
|
|
271
278
|
const hostsRes = await call(`${apiBase(remote.url)}/hosts`, { token: remote.token });
|
|
272
279
|
const hosts = hostsRes.hosts;
|
|
273
|
-
|
|
280
|
+
const shared = hosts.find((h) => h.host === remoteHost);
|
|
281
|
+
if (!shared)
|
|
274
282
|
throw new hosts_1.NotFoundError(`"${remoteHost}" is not available on "${name}".`);
|
|
275
283
|
const now = new Date().toISOString();
|
|
276
284
|
await registry_1.default.mutate((registry) => {
|
|
277
285
|
if (registry.hosts[local])
|
|
278
286
|
throw new hosts_1.ConflictError(`❌ ${local} is already mapped to ${registry.hosts[local].target}.`);
|
|
279
|
-
registry.hosts[local] = { target: remote.url, remote: { name, host: remoteHost }, createdAt: now, updatedAt: now };
|
|
287
|
+
registry.hosts[local] = { target: remote.url, remote: { name, host: remoteHost }, ...inherited(shared), createdAt: now, updatedAt: now };
|
|
280
288
|
});
|
|
281
289
|
}
|
|
282
290
|
static setUrl(name, url) {
|
|
@@ -329,7 +337,7 @@ class Remotes {
|
|
|
329
337
|
const taken = new Set(Object.keys(registry.hosts));
|
|
330
338
|
const local = Remotes.localName(name, created.host, taken);
|
|
331
339
|
if (local)
|
|
332
|
-
registry.hosts[local] = { target: remote.url, remote: { name, host: created.host }, createdAt: now, updatedAt: now };
|
|
340
|
+
registry.hosts[local] = { target: remote.url, remote: { name, host: created.host }, ...inherited(created), createdAt: now, updatedAt: now };
|
|
333
341
|
});
|
|
334
342
|
return created;
|
|
335
343
|
}
|
|
@@ -340,7 +348,14 @@ class Remotes {
|
|
|
340
348
|
token: remote.token,
|
|
341
349
|
body: input,
|
|
342
350
|
});
|
|
343
|
-
|
|
351
|
+
const updated = data.host;
|
|
352
|
+
await registry_1.default.mutate((registry) => {
|
|
353
|
+
for (const [local, entry] of Object.entries(registry.hosts)) {
|
|
354
|
+
if (entry.remote?.name === name && entry.remote.host === host)
|
|
355
|
+
registry.hosts[local] = { ...entry, cors: updated.cors || undefined };
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
return updated;
|
|
344
359
|
}
|
|
345
360
|
static async removeHost(name, host) {
|
|
346
361
|
const remote = Remotes.mustGet(name);
|
package/dist/proxy/cors.js
CHANGED
|
@@ -24,12 +24,16 @@ exports.preflightHeaders = preflightHeaders;
|
|
|
24
24
|
/**
|
|
25
25
|
* --cors: the upstream should see a request from a site it trusts, so origin/CSRF checks pass.
|
|
26
26
|
* A page on another mapped domain (signalsant.localhost calling api.signalsant.localhost) is
|
|
27
|
-
* sent as that domain's real origin; anything else as the target's own origin.
|
|
27
|
+
* sent as that domain's real origin; anything else as the target's own origin. A peer's page is on the
|
|
28
|
+
* receiver's name for one of our domains, so it is matched by those names, never by our own.
|
|
28
29
|
*/
|
|
29
30
|
const sameOriginHeaders = (req, entry, lookup) => {
|
|
31
|
+
const names = (0, request_1.peerNamesOf)(req);
|
|
32
|
+
const callerHost = (url) => names ? Object.keys(names).find((host) => new URL(names[host]).host === url.host) : url.hostname;
|
|
30
33
|
const upstreamOrigin = (value) => {
|
|
31
34
|
try {
|
|
32
|
-
const
|
|
35
|
+
const host = callerHost(new URL(value));
|
|
36
|
+
const caller = host ? lookup(host) : undefined;
|
|
33
37
|
if (caller)
|
|
34
38
|
return new URL(caller.target).origin;
|
|
35
39
|
}
|
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,51 @@ 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 MAX_NAMES = 16 * 1024;
|
|
91
|
+
/**
|
|
92
|
+
* `X-Locadot-Names` from the receiver: `<our host>=<its URL for it>,…`. Kept only for hosts this peer can see and
|
|
93
|
+
* `http(s)://*.localhost[:port]` URLs; they only ever end up in responses to that same peer.
|
|
94
|
+
*/
|
|
95
|
+
const peerNames = (req, visible) => {
|
|
96
|
+
const header = req.headers["x-locadot-names"];
|
|
97
|
+
if (typeof header !== "string" || header.length > MAX_NAMES)
|
|
98
|
+
return undefined;
|
|
99
|
+
const names = {};
|
|
100
|
+
for (const pair of header.split(",")) {
|
|
101
|
+
const [host, url] = pair.trim().toLowerCase().split("=");
|
|
102
|
+
const match = url ? /^https?:\/\/([^/:]+)(?::(\d{1,5}))?$/.exec(url) : null;
|
|
103
|
+
if (host && match && visible.includes(host) && localhost_1.default.isValidLocalhostDomain(match[1]))
|
|
104
|
+
names[host] = url;
|
|
105
|
+
}
|
|
106
|
+
return names;
|
|
107
|
+
};
|
|
108
|
+
/** Sender side: a --cors mapping whose target is this machine's `port`, so port access gets its cors too. */
|
|
109
|
+
const corsMappingFor = (hosts, port) => Object.keys(hosts).find((host) => {
|
|
110
|
+
const entry = hosts[host];
|
|
111
|
+
if (!entry.cors || entry.remote)
|
|
112
|
+
return false;
|
|
113
|
+
try {
|
|
114
|
+
const url = new URL(entry.target);
|
|
115
|
+
const own = Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
116
|
+
return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) && own === port;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
const ownHosts = (hosts, peer) => links_1.default.visibleHosts(peer, Object.keys(hosts)).filter((host) => !hosts[host].remote);
|
|
123
|
+
const stripLocadotHeaders = (req) => {
|
|
124
|
+
for (const name of Object.keys(req.headers))
|
|
125
|
+
if (name.toLowerCase().startsWith("x-locadot-"))
|
|
126
|
+
delete req.headers[name];
|
|
127
|
+
};
|
|
83
128
|
const bearerToken = (req) => {
|
|
84
129
|
const header = req.headers.authorization;
|
|
85
130
|
const match = typeof header === "string" ? /^Bearer\s+(\S+)$/i.exec(header) : null;
|
|
@@ -103,11 +148,22 @@ function classify(req, publicHost, hosts, opts) {
|
|
|
103
148
|
return { kind: "deny", status: 401, message: "Unauthorized" };
|
|
104
149
|
}
|
|
105
150
|
const portHeader = req.headers["x-locadot-port"];
|
|
106
|
-
|
|
151
|
+
const wantsDashboard = req.headers["x-locadot-dashboard"] !== undefined;
|
|
152
|
+
if (portHeader !== undefined || wantsDashboard) {
|
|
107
153
|
if (!links_1.default.can(peer, "localhost"))
|
|
108
154
|
return { kind: "deny", status: 403, message: "Forbidden" };
|
|
109
155
|
if (opts?.localhost === false)
|
|
110
156
|
return { kind: "deny", status: 403, message: "Localhost access is off on this machine." };
|
|
157
|
+
// The dashboard's page and API trust this origin in place of their own (see peerOriginOf).
|
|
158
|
+
if (wantsDashboard) {
|
|
159
|
+
const origin = receiverHost(req);
|
|
160
|
+
if (!origin)
|
|
161
|
+
return { kind: "deny", status: 400, message: "Bad request" };
|
|
162
|
+
// Keep X-Locadot-Token: the dashboard page sends it on every mutating call.
|
|
163
|
+
for (const name of ["x-locadot-peer", "x-locadot-dashboard", "x-locadot-host", "x-locadot-port", "x-locadot-names"])
|
|
164
|
+
delete req.headers[name];
|
|
165
|
+
return { kind: "dashboard", peer, origin };
|
|
166
|
+
}
|
|
111
167
|
const raw = typeof portHeader === "string" ? portHeader.trim() : "";
|
|
112
168
|
if (!/^[0-9]+$/.test(raw))
|
|
113
169
|
return { kind: "deny", status: 400, message: "Bad request" };
|
|
@@ -116,10 +172,15 @@ function classify(req, publicHost, hosts, opts) {
|
|
|
116
172
|
return { kind: "deny", status: 400, message: "Bad request" };
|
|
117
173
|
if (opts?.blockedPorts?.includes(port))
|
|
118
174
|
return { kind: "deny", status: 403, message: "Forbidden" };
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
175
|
+
const names = req.headers["x-locadot-names"] === undefined ? undefined : peerNames(req, ownHosts(hosts, peer));
|
|
176
|
+
const host = corsMappingFor(hosts, port);
|
|
177
|
+
// The page stays on <port>.<domain>.localhost rather than moving to the receiver's name for the mapping.
|
|
178
|
+
const self = receiverHost(req);
|
|
179
|
+
const scheme = names && Object.values(names)[0]?.split(":")[0];
|
|
180
|
+
if (host && names && self && scheme)
|
|
181
|
+
names[host] = `${scheme}://${self}`;
|
|
182
|
+
stripLocadotHeaders(req);
|
|
183
|
+
return { kind: "local", port, peer, names, host };
|
|
123
184
|
}
|
|
124
185
|
const hostHeader = req.headers["x-locadot-host"];
|
|
125
186
|
const host = typeof hostHeader === "string" ? hostHeader.trim().toLowerCase() : "";
|
|
@@ -131,10 +192,9 @@ function classify(req, publicHost, hosts, opts) {
|
|
|
131
192
|
if (!visible.includes(host)) {
|
|
132
193
|
return { kind: "deny", status: 403, message: "Forbidden" };
|
|
133
194
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return { kind: "app", host, peer };
|
|
195
|
+
const names = req.headers["x-locadot-names"] === undefined ? undefined : peerNames(req, visible);
|
|
196
|
+
stripLocadotHeaders(req);
|
|
197
|
+
return { kind: "app", host, peer, names };
|
|
138
198
|
}
|
|
139
199
|
/* ---------- /_locadot/v1 API ---------- */
|
|
140
200
|
const send = (res, status, body) => {
|
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");
|
|
@@ -32,7 +32,7 @@ const remotes = () => {
|
|
|
32
32
|
};
|
|
33
33
|
const remoteFor = (name) => remotes()[name];
|
|
34
34
|
exports.remoteFor = remoteFor;
|
|
35
|
-
const remoteOptions = (req, entry, remote) => ({
|
|
35
|
+
const remoteOptions = (req, entry, remote, names) => ({
|
|
36
36
|
target: remote.url,
|
|
37
37
|
changeOrigin: true,
|
|
38
38
|
// Don't leak the receiver's LAN addresses to the sender.
|
|
@@ -47,9 +47,12 @@ const remoteOptions = (req, entry, remote) => ({
|
|
|
47
47
|
"X-Original-Host": req.headers.host || "",
|
|
48
48
|
"X-Locadot-Peer": remote.token,
|
|
49
49
|
"X-Locadot-Host": entry.remote.host,
|
|
50
|
+
...(names ? { "X-Locadot-Names": names } : {}),
|
|
50
51
|
},
|
|
51
52
|
});
|
|
52
53
|
exports.remoteOptions = remoteOptions;
|
|
54
|
+
/** Receiver side: the port picker and shared-host list, on `<domain>.localhost` itself (the rest of it is the dashboard). */
|
|
55
|
+
exports.LANDING_PATH = "/_locadot/ports";
|
|
53
56
|
/** Receiver side: `<domain>.localhost` / `<port>.<domain>.localhost` → the admin remote that owns `domain`. */
|
|
54
57
|
const localFor = (host) => {
|
|
55
58
|
if (!host.endsWith(".localhost"))
|
|
@@ -72,7 +75,8 @@ const localFor = (host) => {
|
|
|
72
75
|
exports.localFor = localFor;
|
|
73
76
|
const canReachLocalhost = (remote) => remote.role === "admin" && remote.localhost !== false;
|
|
74
77
|
exports.canReachLocalhost = canReachLocalhost;
|
|
75
|
-
|
|
78
|
+
/** Without a port the sender serves its dashboard. */
|
|
79
|
+
const localOptions = (req, remote, port, names) => ({
|
|
76
80
|
target: remote.url,
|
|
77
81
|
changeOrigin: true,
|
|
78
82
|
xfwd: false,
|
|
@@ -85,7 +89,7 @@ const localOptions = (req, remote, port) => ({
|
|
|
85
89
|
headers: {
|
|
86
90
|
"X-Original-Host": req.headers.host || "",
|
|
87
91
|
"X-Locadot-Peer": remote.token,
|
|
88
|
-
"X-Locadot-Port": String(port),
|
|
92
|
+
...(port ? { "X-Locadot-Port": String(port), ...(names ? { "X-Locadot-Names": names } : {}) } : { "X-Locadot-Dashboard": "1" }),
|
|
89
93
|
},
|
|
90
94
|
});
|
|
91
95
|
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.peerNamesOf = 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,10 @@ 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;
|
|
26
|
+
const peerNamesOf = (req) => tags.get(req)?.peerNames;
|
|
27
|
+
exports.peerNamesOf = peerNamesOf;
|
|
24
28
|
const viaOf = (req) => tags.get(req)?.via;
|
|
25
29
|
exports.viaOf = viaOf;
|
|
26
30
|
/** Tunnel visitors are on https even though cloudflared talks plain http to us. */
|
package/dist/proxy/response.js
CHANGED
|
@@ -23,7 +23,8 @@ const handleProxyResponse = (proxyRes, req, res, hosts, publicUrl = () => undefi
|
|
|
23
23
|
return;
|
|
24
24
|
stripHopByHop(proxyRes.headers);
|
|
25
25
|
const entry = hosts[(0, request_1.mappedHost)(req)];
|
|
26
|
-
|
|
26
|
+
// A remote mapping's cors flag is inherited: the sender already applied it.
|
|
27
|
+
if (!entry?.cors || entry.remote)
|
|
27
28
|
return;
|
|
28
29
|
(0, cors_1.applyCors)(req, proxyRes.headers);
|
|
29
30
|
const via = (0, request_1.viaOf)(req);
|
package/dist/proxy/rewrite.js
CHANGED
|
@@ -25,9 +25,11 @@ const originMap = (req, hosts, publicUrl = () => undefined) => {
|
|
|
25
25
|
const tls = (0, request_1.isTls)(req);
|
|
26
26
|
const self = (0, request_1.mappedHost)(req);
|
|
27
27
|
const tunnel = (0, request_1.fromTunnel)(req);
|
|
28
|
+
// A peer's browser knows our hosts by its own names; older receivers don't send them.
|
|
29
|
+
const peer = (0, request_1.peerNamesOf)(req);
|
|
28
30
|
const pairs = [];
|
|
29
31
|
for (const [host, entry] of Object.entries(hosts)) {
|
|
30
|
-
const local = tunnel ? publicUrl(host) : (0, urls_1.urlFor)(host, tls);
|
|
32
|
+
const local = tunnel ? publicUrl(host) : peer ? peer[host] : (0, urls_1.urlFor)(host, tls);
|
|
31
33
|
if (!local)
|
|
32
34
|
continue;
|
|
33
35
|
try {
|
package/dist/proxy/router.js
CHANGED
|
@@ -20,12 +20,17 @@ const stats_1 = require("./stats");
|
|
|
20
20
|
/** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
|
|
21
21
|
const resolveHost = (req, ctx, hub) => {
|
|
22
22
|
if (hub?.kind === "app") {
|
|
23
|
-
(0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure() });
|
|
23
|
+
(0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names });
|
|
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
|
-
|
|
28
|
-
|
|
31
|
+
// A port behind a --cors mapping is served as that mapping, cors included.
|
|
32
|
+
const host = hub.host ?? `localhost:${hub.port}`;
|
|
33
|
+
(0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names });
|
|
29
34
|
return host;
|
|
30
35
|
}
|
|
31
36
|
const host = (0, request_1.hostOf)(req);
|
|
@@ -38,6 +43,16 @@ const dashboardUrl = (req) => `${(0, urls_1.urlFor)("localhost", (0, request_1.i
|
|
|
38
43
|
const passThrough = (req, entry) => Boolean(entry.cors);
|
|
39
44
|
/** Same-origin only, and a tunnel visitor may only reach public hosts (see guard.ts). */
|
|
40
45
|
const viaAllowed = (req, via) => (0, passthrough_1.isSameOrigin)(req) && (!(0, request_1.fromTunnel)(req) || (0, guard_1.isPublicHost)(via.host));
|
|
46
|
+
/**
|
|
47
|
+
* Receiver side: `<sender host>=<our URL for it>` for every mapping of this remote, so a --cors sender rewrites
|
|
48
|
+
* its apps' origins to the names this machine uses rather than its own.
|
|
49
|
+
*/
|
|
50
|
+
const peerNames = (req, ctx, name) => (ctx.remoteHosts?.(name) || [])
|
|
51
|
+
.flatMap((local) => {
|
|
52
|
+
const host = ctx.lookup(local)?.remote?.host;
|
|
53
|
+
return host ? [`${host}=${(0, urls_1.urlFor)(local, (0, request_1.isTls)(req))}`] : [];
|
|
54
|
+
})
|
|
55
|
+
.join(",");
|
|
41
56
|
const reason = (err) => err?.code || err?.message;
|
|
42
57
|
const remoteGone = (entry) => `the connection to ${entry.remote.name} was removed`;
|
|
43
58
|
/** Sender side: an admin peer reaching one of this machine's ports. */
|
|
@@ -80,7 +95,7 @@ function handleRequest(req, res, ctx) {
|
|
|
80
95
|
ctx.dashboard(req, res);
|
|
81
96
|
return;
|
|
82
97
|
}
|
|
83
|
-
const entry = hub?.kind === "local" ? localEntry(hub.port) : ctx.lookup(host);
|
|
98
|
+
const entry = hub?.kind === "local" && !hub.host ? localEntry(hub.port) : ctx.lookup(host);
|
|
84
99
|
if (!entry) {
|
|
85
100
|
const local = localTarget(req, ctx, host, hub);
|
|
86
101
|
if (local) {
|
|
@@ -151,7 +166,7 @@ function forwardRemote(req, res, ctx, host, entry) {
|
|
|
151
166
|
}
|
|
152
167
|
const started = Date.now();
|
|
153
168
|
res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
|
|
154
|
-
ctx.proxy.web(req, res, (0, remote_1.remoteOptions)(req, entry, remote), (err) => {
|
|
169
|
+
ctx.proxy.web(req, res, (0, remote_1.remoteOptions)(req, entry, remote, peerNames(req, ctx, remote.name)), (err) => {
|
|
155
170
|
logger_1.default.warn(`${host} → ${remote.name} (${remote.url}): ${reason(err)}`);
|
|
156
171
|
if (res.headersSent) {
|
|
157
172
|
res.destroy();
|
|
@@ -161,25 +176,30 @@ function forwardRemote(req, res, ctx, host, entry) {
|
|
|
161
176
|
res.end((0, template_1.upstreamDown)(host, `${remote.name}: ${entry.remote.host}`, reason(err) || "error", dashboardUrl(req)));
|
|
162
177
|
});
|
|
163
178
|
}
|
|
164
|
-
/**
|
|
179
|
+
/**
|
|
180
|
+
* Receiver side: `<domain>.localhost` is the sender's dashboard and `<port>.<domain>.localhost` its localhost:<port>.
|
|
181
|
+
* The sender checks the role again on every request.
|
|
182
|
+
*/
|
|
165
183
|
function forwardLocal(req, res, ctx, host, local) {
|
|
166
184
|
const { remote, domain, port } = local;
|
|
167
185
|
const allowed = (0, remote_1.canReachLocalhost)(remote);
|
|
168
|
-
|
|
186
|
+
const landing = !port && (req.url || "/").split("?")[0] === remote_1.LANDING_PATH;
|
|
187
|
+
if (!allowed || landing) {
|
|
169
188
|
const hosts = (ctx.remoteHosts?.(remote.name) || []).map((h) => ({ local: h, url: `${(0, urls_1.urlFor)(h, (0, request_1.isTls)(req))}/` }));
|
|
170
189
|
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);
|
|
190
|
+
html(res, port && !allowed ? 403 : 200, body);
|
|
172
191
|
return;
|
|
173
192
|
}
|
|
193
|
+
const label = `${remote.name}: ${port ? `localhost:${port}` : "dashboard"}`;
|
|
174
194
|
const started = Date.now();
|
|
175
195
|
res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
|
|
176
|
-
ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port), (err) => {
|
|
177
|
-
logger_1.default.warn(`${host} → ${
|
|
196
|
+
ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port, port ? peerNames(req, ctx, remote.name) : undefined), (err) => {
|
|
197
|
+
logger_1.default.warn(`${host} → ${label}: ${reason(err)}`);
|
|
178
198
|
if (res.headersSent) {
|
|
179
199
|
res.destroy();
|
|
180
200
|
return;
|
|
181
201
|
}
|
|
182
|
-
html(res, 502, (0, template_1.upstreamDown)(host,
|
|
202
|
+
html(res, 502, (0, template_1.upstreamDown)(host, label, reason(err) || "error", dashboardUrl(req)));
|
|
183
203
|
});
|
|
184
204
|
}
|
|
185
205
|
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.3",
|
|
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",
|