locadot 2.1.0-beta.2 → 2.1.0-beta.4
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 +11 -0
- package/README.md +8 -0
- package/dist/lib/remotes.js +24 -9
- package/dist/proxy/cors.js +6 -2
- package/dist/proxy/guard.js +12 -1
- package/dist/proxy/hub.js +46 -3
- package/dist/proxy/passthrough.js +7 -3
- package/dist/proxy/remote.js +4 -3
- 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 +27 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -33,8 +33,19 @@
|
|
|
33
33
|
Senders must run this version too; an older sender answers `<domain>.localhost` with a 404.
|
|
34
34
|
|
|
35
35
|
### Fixed
|
|
36
|
+
- `--cors` pages that call an API on a bare `localhost:<port>` (e.g. `VITE_SERVER_URL=http://localhost:3100`) now send those
|
|
37
|
+
calls through the page's own origin (`/__locadot/x/http/localhost:3100/…`) like any other cross-origin call. Opened through
|
|
38
|
+
remote access, the API used to be called on the *viewer's* localhost, where it doesn't exist. A peer's pass-through reaches
|
|
39
|
+
the sender's localhost only with localhost access (admin, `hub:localhost on`), and never the proxy's own ports.
|
|
36
40
|
- The proxy can now tell a user's `LOCADOT_HTTP_PORT`/`LOCADOT_HTTPS_PORT` from the copies the CLI pins on every spawn
|
|
37
41
|
(`LOCADOT_USER_PORTS`), so saved ports aren't reported as env-overridden.
|
|
42
|
+
- `--cors` through remote access: a shared `--cors` mapping is now `--cors` on the receiver too (shown in `list` and the
|
|
43
|
+
dashboard, kept in step by `remote:sync` and `remote:update`). The sender used to rewrite its apps' origins in pages to its
|
|
44
|
+
*own* local URLs, which don't exist on the receiver. Now the receiver sends the names it uses (`X-Locadot-Names`), so a
|
|
45
|
+
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
|
|
46
|
+
port. The upstream still sees the calling page's real origin. `<port>.<domain>.localhost` gets the CORS handling of a
|
|
47
|
+
`--cors` mapping on that port. The sender keeps names only for mappings the peer can see, and only as `*.localhost` URLs.
|
|
48
|
+
Both machines need this version for the rewriting; with an older receiver the sender behaves as before.
|
|
38
49
|
|
|
39
50
|
## 2.1.0-beta.0 (2026-09-27)
|
|
40
51
|
|
package/README.md
CHANGED
|
@@ -131,6 +131,12 @@ target, so an editor can reach anything the sender's machine can reach. Only giv
|
|
|
131
131
|
of the tokens are stored, and failed attempts are rate limited. Only admins can open the sender's dashboard through the hub.
|
|
132
132
|
The dashboard has **Remote access** and **Connected machines** cards for all of this.
|
|
133
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
|
+
|
|
134
140
|
### Certificates
|
|
135
141
|
|
|
136
142
|
| Command | What it does |
|
|
@@ -289,6 +295,8 @@ locadot add --host signalsant.localhost --target https://signalsant.com --cors
|
|
|
289
295
|
`XMLHttpRequest`, `EventSource`, `WebSocket` and `sendBeacon` calls to other origins through the page's own origin
|
|
290
296
|
(`/__locadot/x/https/api.signalsant.com/…`). The browser sees a same-origin request, so CORS never applies, and locadot
|
|
291
297
|
forwards it with `Origin: https://signalsant.com`. Only the page itself can use it: requests from other sites get a 403.
|
|
298
|
+
Calls to a bare `localhost:<port>` (an API or dev server next to the app) go the same way, so the page works when opened
|
|
299
|
+
from another machine through remote access too. There, only admin peers reach the sender's localhost this way.
|
|
292
300
|
- **Cookies.** The page's cookies are forwarded only to the same site (`api.signalsant.com`), never to third parties.
|
|
293
301
|
Cookies set by third parties are dropped.
|
|
294
302
|
- **Mapped domains.** If you also map a domain (`api.signalsant.localhost` → `https://api.signalsant.com --cors`), its URLs in
|
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/guard.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.publicOnlyAgents = exports.isPublicHost = exports.isPublicAddress = void 0;
|
|
6
|
+
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"));
|
|
@@ -54,6 +54,17 @@ const isPublicAddress = (address) => {
|
|
|
54
54
|
return net_1.default.isIPv6(address) && !blocked.check(address, "ipv6");
|
|
55
55
|
};
|
|
56
56
|
exports.isPublicAddress = isPublicAddress;
|
|
57
|
+
/** localhost, *.localhost, 127.0.0.0/8, ::1 or 0.0.0.0: this machine itself. */
|
|
58
|
+
const isLoopbackHost = (host) => {
|
|
59
|
+
const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase().replace(/\.$/, "");
|
|
60
|
+
if (name === "localhost" || name.endsWith(".localhost"))
|
|
61
|
+
return true;
|
|
62
|
+
const v4 = net_1.default.isIPv4(name) ? name : net_1.default.isIPv6(name) ? embeddedV4(name) : undefined;
|
|
63
|
+
if (v4)
|
|
64
|
+
return v4.startsWith("127.") || v4 === "0.0.0.0";
|
|
65
|
+
return name === "::1" || name === "::";
|
|
66
|
+
};
|
|
67
|
+
exports.isLoopbackHost = isLoopbackHost;
|
|
57
68
|
/** A host the pass-through may call for a tunnel visitor, judged before any DNS lookup. */
|
|
58
69
|
const isPublicHost = (host) => {
|
|
59
70
|
const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
|
package/dist/proxy/hub.js
CHANGED
|
@@ -87,6 +87,39 @@ const receiverHost = (req) => {
|
|
|
87
87
|
const match = /^([^:]+)(?::(\d{1,5}))?$/.exec(raw);
|
|
88
88
|
return match && localhost_1.default.isValidLocalhostDomain(match[1]) ? raw : undefined;
|
|
89
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);
|
|
90
123
|
const stripLocadotHeaders = (req) => {
|
|
91
124
|
for (const name of Object.keys(req.headers))
|
|
92
125
|
if (name.toLowerCase().startsWith("x-locadot-"))
|
|
@@ -127,7 +160,7 @@ function classify(req, publicHost, hosts, opts) {
|
|
|
127
160
|
if (!origin)
|
|
128
161
|
return { kind: "deny", status: 400, message: "Bad request" };
|
|
129
162
|
// 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"])
|
|
163
|
+
for (const name of ["x-locadot-peer", "x-locadot-dashboard", "x-locadot-host", "x-locadot-port", "x-locadot-names"])
|
|
131
164
|
delete req.headers[name];
|
|
132
165
|
return { kind: "dashboard", peer, origin };
|
|
133
166
|
}
|
|
@@ -139,8 +172,15 @@ function classify(req, publicHost, hosts, opts) {
|
|
|
139
172
|
return { kind: "deny", status: 400, message: "Bad request" };
|
|
140
173
|
if (opts?.blockedPorts?.includes(port))
|
|
141
174
|
return { kind: "deny", status: 403, message: "Forbidden" };
|
|
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}`;
|
|
142
182
|
stripLocadotHeaders(req);
|
|
143
|
-
return { kind: "local", port, peer };
|
|
183
|
+
return { kind: "local", port, peer, names, host, loopback: opts?.blockedPorts ?? [] };
|
|
144
184
|
}
|
|
145
185
|
const hostHeader = req.headers["x-locadot-host"];
|
|
146
186
|
const host = typeof hostHeader === "string" ? hostHeader.trim().toLowerCase() : "";
|
|
@@ -152,8 +192,11 @@ function classify(req, publicHost, hosts, opts) {
|
|
|
152
192
|
if (!visible.includes(host)) {
|
|
153
193
|
return { kind: "deny", status: 403, message: "Forbidden" };
|
|
154
194
|
}
|
|
195
|
+
const names = req.headers["x-locadot-names"] === undefined ? undefined : peerNames(req, visible);
|
|
155
196
|
stripLocadotHeaders(req);
|
|
156
|
-
|
|
197
|
+
// A --cors page's pass-through calls to localhost:<port> reach this machine only for peers allowed its localhost.
|
|
198
|
+
const loopback = links_1.default.can(peer, "localhost") && opts?.localhost !== false ? opts?.blockedPorts ?? [] : undefined;
|
|
199
|
+
return { kind: "app", host, peer, names, loopback };
|
|
157
200
|
}
|
|
158
201
|
/* ---------- /_locadot/v1 API ---------- */
|
|
159
202
|
const send = (res, status, body) => {
|
|
@@ -56,12 +56,16 @@ exports.shimScript = `(() => {
|
|
|
56
56
|
if (window.__locadotShim) return;
|
|
57
57
|
window.__locadotShim = true;
|
|
58
58
|
const VIA = ${JSON.stringify(VIA)};
|
|
59
|
+
const portOf = (url) => url.port || (url.protocol === "https:" || url.protocol === "wss:" ? "443" : "80");
|
|
59
60
|
const route = (input) => {
|
|
60
61
|
try {
|
|
61
62
|
const url = new URL(String(input), location.href);
|
|
62
63
|
if (!/^(https?|wss?):$/.test(url.protocol)) return null;
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
if (url.hostname.endsWith(".localhost") || url.host === location.host) return null;
|
|
65
|
+
// Bare localhost:<port> (an app's API or dev server) exists only where the app runs, so it goes through the
|
|
66
|
+
// page's origin too; a remote viewer's own localhost doesn't have it. The page's own proxy port stays direct.
|
|
67
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
68
|
+
if (loopback && portOf(url) === portOf(location)) return null;
|
|
65
69
|
const ws = url.protocol === "ws:" || url.protocol === "wss:";
|
|
66
70
|
const base = ws ? (location.protocol === "https:" ? "wss://" : "ws://") + location.host : location.origin;
|
|
67
71
|
return base + VIA + url.protocol.slice(0, -1) + "/" + url.host + url.pathname + url.search + url.hash;
|
|
@@ -150,7 +154,7 @@ const rewriteViaResponse = (headers, via, target) => {
|
|
|
150
154
|
else {
|
|
151
155
|
try {
|
|
152
156
|
const url = new URL(location, `${via.scheme}://${via.host}/`);
|
|
153
|
-
if (/^https?:$/.test(url.protocol) &&
|
|
157
|
+
if (/^https?:$/.test(url.protocol) && !url.hostname.endsWith(".localhost"))
|
|
154
158
|
headers.location = (0, exports.viaPath)(url) + url.hash;
|
|
155
159
|
}
|
|
156
160
|
catch { }
|
package/dist/proxy/remote.js
CHANGED
|
@@ -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,6 +47,7 @@ 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;
|
|
@@ -75,7 +76,7 @@ exports.localFor = localFor;
|
|
|
75
76
|
const canReachLocalhost = (remote) => remote.role === "admin" && remote.localhost !== false;
|
|
76
77
|
exports.canReachLocalhost = canReachLocalhost;
|
|
77
78
|
/** Without a port the sender serves its dashboard. */
|
|
78
|
-
const localOptions = (req, remote, port) => ({
|
|
79
|
+
const localOptions = (req, remote, port, names) => ({
|
|
79
80
|
target: remote.url,
|
|
80
81
|
changeOrigin: true,
|
|
81
82
|
xfwd: false,
|
|
@@ -88,7 +89,7 @@ const localOptions = (req, remote, port) => ({
|
|
|
88
89
|
headers: {
|
|
89
90
|
"X-Original-Host": req.headers.host || "",
|
|
90
91
|
"X-Locadot-Peer": remote.token,
|
|
91
|
-
...(port ? { "X-Locadot-Port": String(port) } : { "X-Locadot-Dashboard": "1" }),
|
|
92
|
+
...(port ? { "X-Locadot-Port": String(port), ...(names ? { "X-Locadot-Names": names } : {}) } : { "X-Locadot-Dashboard": "1" }),
|
|
92
93
|
},
|
|
93
94
|
});
|
|
94
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.peerOriginOf = exports.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
|
|
3
|
+
exports.isTls = exports.viaOf = exports.loopbackOf = exports.peerNamesOf = exports.peerOriginOf = exports.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 });
|
|
@@ -23,6 +23,10 @@ const fromRemote = (req) => Boolean(tags.get(req)?.remote);
|
|
|
23
23
|
exports.fromRemote = fromRemote;
|
|
24
24
|
const peerOriginOf = (req) => tags.get(req)?.peerOrigin;
|
|
25
25
|
exports.peerOriginOf = peerOriginOf;
|
|
26
|
+
const peerNamesOf = (req) => tags.get(req)?.peerNames;
|
|
27
|
+
exports.peerNamesOf = peerNamesOf;
|
|
28
|
+
const loopbackOf = (req) => tags.get(req)?.loopback;
|
|
29
|
+
exports.loopbackOf = loopbackOf;
|
|
26
30
|
const viaOf = (req) => tags.get(req)?.via;
|
|
27
31
|
exports.viaOf = viaOf;
|
|
28
32
|
/** 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,7 +20,7 @@ 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, loopback: hub.loopback });
|
|
24
24
|
return hub.host;
|
|
25
25
|
}
|
|
26
26
|
if (hub?.kind === "dashboard") {
|
|
@@ -28,8 +28,9 @@ const resolveHost = (req, ctx, hub) => {
|
|
|
28
28
|
return "localhost";
|
|
29
29
|
}
|
|
30
30
|
if (hub?.kind === "local") {
|
|
31
|
-
|
|
32
|
-
|
|
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, loopback: hub.loopback });
|
|
33
34
|
return host;
|
|
34
35
|
}
|
|
35
36
|
const host = (0, request_1.hostOf)(req);
|
|
@@ -40,8 +41,26 @@ const resolveHost = (req, ctx, hub) => {
|
|
|
40
41
|
};
|
|
41
42
|
const dashboardUrl = (req) => `${(0, urls_1.urlFor)("localhost", (0, request_1.isTls)(req))}/`;
|
|
42
43
|
const passThrough = (req, entry) => Boolean(entry.cors);
|
|
43
|
-
/**
|
|
44
|
-
const
|
|
44
|
+
/** A hub peer reaches this machine's localhost:<port> only with localhost access, and never the proxy's own ports. */
|
|
45
|
+
const peerMayReach = (req, via) => {
|
|
46
|
+
if (!(0, request_1.fromRemote)(req) || !(0, guard_1.isLoopbackHost)(via.host))
|
|
47
|
+
return true;
|
|
48
|
+
const allowed = (0, request_1.loopbackOf)(req);
|
|
49
|
+
const port = Number(via.host.match(/:(\d+)$/)?.[1] || (via.scheme === "https" || via.scheme === "wss" ? 443 : 80));
|
|
50
|
+
return Boolean(allowed && !allowed.includes(port));
|
|
51
|
+
};
|
|
52
|
+
/** Same-origin only, a tunnel visitor may only reach public hosts (see guard.ts), and a peer only its share of localhost. */
|
|
53
|
+
const viaAllowed = (req, via) => (0, passthrough_1.isSameOrigin)(req) && (!(0, request_1.fromTunnel)(req) || (0, guard_1.isPublicHost)(via.host)) && peerMayReach(req, via);
|
|
54
|
+
/**
|
|
55
|
+
* Receiver side: `<sender host>=<our URL for it>` for every mapping of this remote, so a --cors sender rewrites
|
|
56
|
+
* its apps' origins to the names this machine uses rather than its own.
|
|
57
|
+
*/
|
|
58
|
+
const peerNames = (req, ctx, name) => (ctx.remoteHosts?.(name) || [])
|
|
59
|
+
.flatMap((local) => {
|
|
60
|
+
const host = ctx.lookup(local)?.remote?.host;
|
|
61
|
+
return host ? [`${host}=${(0, urls_1.urlFor)(local, (0, request_1.isTls)(req))}`] : [];
|
|
62
|
+
})
|
|
63
|
+
.join(",");
|
|
45
64
|
const reason = (err) => err?.code || err?.message;
|
|
46
65
|
const remoteGone = (entry) => `the connection to ${entry.remote.name} was removed`;
|
|
47
66
|
/** Sender side: an admin peer reaching one of this machine's ports. */
|
|
@@ -84,7 +103,7 @@ function handleRequest(req, res, ctx) {
|
|
|
84
103
|
ctx.dashboard(req, res);
|
|
85
104
|
return;
|
|
86
105
|
}
|
|
87
|
-
const entry = hub?.kind === "local" ? localEntry(hub.port) : ctx.lookup(host);
|
|
106
|
+
const entry = hub?.kind === "local" && !hub.host ? localEntry(hub.port) : ctx.lookup(host);
|
|
88
107
|
if (!entry) {
|
|
89
108
|
const local = localTarget(req, ctx, host, hub);
|
|
90
109
|
if (local) {
|
|
@@ -155,7 +174,7 @@ function forwardRemote(req, res, ctx, host, entry) {
|
|
|
155
174
|
}
|
|
156
175
|
const started = Date.now();
|
|
157
176
|
res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
|
|
158
|
-
ctx.proxy.web(req, res, (0, remote_1.remoteOptions)(req, entry, remote), (err) => {
|
|
177
|
+
ctx.proxy.web(req, res, (0, remote_1.remoteOptions)(req, entry, remote, peerNames(req, ctx, remote.name)), (err) => {
|
|
159
178
|
logger_1.default.warn(`${host} → ${remote.name} (${remote.url}): ${reason(err)}`);
|
|
160
179
|
if (res.headersSent) {
|
|
161
180
|
res.destroy();
|
|
@@ -182,7 +201,7 @@ function forwardLocal(req, res, ctx, host, local) {
|
|
|
182
201
|
const label = `${remote.name}: ${port ? `localhost:${port}` : "dashboard"}`;
|
|
183
202
|
const started = Date.now();
|
|
184
203
|
res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
|
|
185
|
-
ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port), (err) => {
|
|
204
|
+
ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port, port ? peerNames(req, ctx, remote.name) : undefined), (err) => {
|
|
186
205
|
logger_1.default.warn(`${host} → ${label}: ${reason(err)}`);
|
|
187
206
|
if (res.headersSent) {
|
|
188
207
|
res.destroy();
|
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.4",
|
|
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",
|