locadot 1.6.0-beta.3 → 1.6.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- The version in `package.json` is 1.6.0-beta.3 (published under the `beta` tag). The owner decides the bump. **2.0.0** is suggested because of the
5
+ The version in `package.json` is 1.6.0-beta.4 (published under the `beta` tag). The owner decides the bump. **2.0.0** is suggested because of the
6
6
  breaking items below.
7
7
 
8
8
  ### Breaking
@@ -13,7 +13,8 @@ breaking items below.
13
13
  - Bare `localhost` is reserved for the dashboard and can't be mapped.
14
14
 
15
15
  ### Added
16
- - `--cors` per mapping (CLI, API and dashboard). `Origin`/`Referer` are sent as the target's own origin, CORS preflights are answered locally, and any origin may call the domain with credentials. (ENH-12)
16
+ - `locadot start --port <http> --https-port <https>` (also on `restart`) runs the proxy on other ports than 80/443. The choice is saved in the state dir, so `add`, `status`, `open` and start-at-boot keep using it; `--port 80 --https-port 443` goes back. Running it while the proxy is up on other ports restarts it on the new ones.
17
+ - `--cors` per mapping (CLI, API and dashboard). `Origin`/`Referer` are sent as the target's own origin, CORS preflights are answered locally, and any origin may call the domain with credentials. Absolute URLs of other mapped domains in text responses are rewritten to their `.localhost` names, and a mapped caller is sent as its real origin, so a site plus its API both work locally. Pages also get a small script that sends calls to any other origin through the page's own origin (`/__locadot/x/…`), so no mapping per API or third-party domain is needed. (ENH-12, BUG-15)
17
18
  - A control panel at `https://localhost` with a dark theme. You can add, edit and remove mappings, toggle CA trust and start-at-boot, see root/admin and privileged-port status,
18
19
  tail and clear logs, and stop the proxy. (FEAT-05)
19
20
  - A token-protected JSON API for scripts and AI agents (`POST/PUT/DELETE /api/hosts`, `/api/startup`, `/api/trust`, `/api/logs`, `/api/proxy/stop`),
package/README.md CHANGED
@@ -29,7 +29,8 @@ npx locadot open # opens the dashboard at htt
29
29
  ```
30
30
 
31
31
  > **Linux:** ports 80/443 need root. Either run locadot with `sudo`, or allow unprivileged binding once:
32
- > `sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80` (persist it in `/etc/sysctl.d/`). `locadot doctor` checks this for you.
32
+ > `sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80` (persist it in `/etc/sysctl.d/`). Or use other ports:
33
+ > `locadot start --port 8080 --https-port 8443` (URLs then include the port). `locadot doctor` checks this for you.
33
34
 
34
35
  ---
35
36
 
@@ -54,16 +55,16 @@ Options for `add` / `update`:
54
55
  | `-p, --port <port>` | Shorthand for `--target http://localhost:<port>`. |
55
56
  | `-t, --target <url>` | Any http(s) upstream. A bare `host:port` means `http://host:port`. |
56
57
  | `-k, --insecure` | Don't verify the TLS certificate of an `https` target (self-signed upstreams). |
57
- | `--cors` / `--no-cors` | Bypass CORS for this domain. The upstream gets `Origin`/`Referer` as its own origin, preflights are answered locally, any origin may read responses (with credentials), and cookies become `SameSite=None` over HTTPS. URLs hard-coded in pages still go to the real domain. |
58
+ | `--cors` / `--no-cors` | Bypass CORS for this domain. The upstream gets `Origin`/`Referer` as its own origin, preflights are answered locally, any origin may read responses (with credentials), and cookies become `SameSite=None` over HTTPS. The page's calls to other domains go through locadot automatically, with no extra mapping (see [Sites that call other domains](#sites-that-call-other-domains---cors)). |
58
59
  | `--no-start` | Save the mapping without starting the proxy. |
59
60
 
60
61
  ### Proxy
61
62
 
62
63
  | Command | What it does |
63
64
  | --- | --- |
64
- | `locadot start` | Start the central proxy. Fails loudly with the reason if it can't bind its ports. |
65
+ | `locadot start [--port 8080] [--https-port 8443]` | Start the central proxy. Default ports are 80 and 443. Ports you pass are remembered for later commands and start-at-boot, and a running proxy is moved to them. Fails loudly with the reason if it can't bind its ports. |
65
66
  | `locadot stop` | Stop the proxy. Mappings and logs are kept. |
66
- | `locadot restart` | Stop, then start. |
67
+ | `locadot restart [--port …] [--https-port …]` | Stop, then start. |
67
68
  | `locadot kill` | Stop the proxy, remove all mappings and clear the logs. |
68
69
  | `locadot status [--json]` | Is it running, its PID, ports, number of hosts, CA trust, start-at-boot, dashboard URL. |
69
70
  | `locadot doctor [--host h]` | Check the proxy, ports, permissions, CA trust, the registry and every target. Exits 1 if anything fails. |
@@ -148,8 +149,8 @@ All optional, via environment variables. Set them for the CLI; the proxy it star
148
149
  | Variable | Default | Purpose |
149
150
  | --- | --- | --- |
150
151
  | `LOCADOT_HOME` | OS app-data dir (`~/.config/locadot` on Linux) | Where the registry, logs, lock file and certs live. |
151
- | `LOCADOT_HTTP_PORT` | `80` | HTTP port of the proxy. |
152
- | `LOCADOT_HTTPS_PORT` | `443` | HTTPS port of the proxy. |
152
+ | `LOCADOT_HTTP_PORT` | `80` | HTTP port of the proxy. Overrides the port saved by `locadot start --port`. |
153
+ | `LOCADOT_HTTPS_PORT` | `443` | HTTPS port of the proxy. Overrides the port saved by `locadot start --https-port`. |
153
154
  | `LOCADOT_BIND` | `127.0.0.1,::1` | Comma-separated addresses to listen on. Use `0.0.0.0` to expose your mappings to your LAN (not recommended). |
154
155
  | `LOCADOT_LOG_LEVEL` | `info` | winston log level (`debug` logs every WebSocket upgrade). |
155
156
 
@@ -165,6 +166,26 @@ redirect to a *different* domain (for example `google.com` → `www.google.com`)
165
166
  locadot, map the final host instead (`--target https://www.google.com`). Some sites refuse to be framed or proxied; that's up to
166
167
  the site.
167
168
 
169
+ ### Sites that call other domains (`--cors`)
170
+
171
+ A frontend that calls its API or third-party services by absolute URL works with just the site mapped:
172
+
173
+ ```bash
174
+ locadot add --host signalsant.localhost --target https://signalsant.com --cors
175
+ ```
176
+
177
+ - **Pass-through.** HTML pages get a small script (`/__locadot/shim.js`) as the first thing in `<head>`. It sends `fetch`,
178
+ `XMLHttpRequest`, `EventSource`, `WebSocket` and `sendBeacon` calls to other origins through the page's own origin
179
+ (`/__locadot/x/https/api.signalsant.com/…`). The browser sees a same-origin request, so CORS never applies, and locadot
180
+ forwards it with `Origin: https://signalsant.com`. Only the page itself can use it: requests from other sites get a 403.
181
+ - **Cookies.** The page's cookies are forwarded only to the same site (`api.signalsant.com`), never to third parties.
182
+ Cookies set by third parties are dropped.
183
+ - **Mapped domains.** If you also map a domain (`api.signalsant.localhost` → `https://api.signalsant.com --cors`), its URLs in
184
+ HTML/JS/CSS/JSON are rewritten to the `.localhost` name and it's called there directly.
185
+
186
+ Rewritten responses are buffered and sent uncompressed. Event streams and binary files pass through untouched. Not covered:
187
+ requests made by web workers or service workers, and `<form>` posts or `<img>`/`<script>` tags (these don't need CORS).
188
+
168
189
  ---
169
190
 
170
191
  ## 🛠️ Contributing
@@ -4,16 +4,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const appdata_path_1 = __importDefault(require("appdata-path"));
7
+ const fs_1 = __importDefault(require("fs"));
7
8
  const path_1 = __importDefault(require("path"));
8
9
  const os_1 = __importDefault(require("os"));
9
10
  // LOCADOT_HOME / LOCADOT_*_PORT let tests and hosts where 80/443 are taken run an isolated instance.
10
11
  const PACKAGE_PATH = process.env.LOCADOT_HOME
11
12
  ? path_1.default.resolve(process.env.LOCADOT_HOME)
12
13
  : (0, appdata_path_1.default)("locadot");
13
- const envPort = (name, fallback) => {
14
- const value = Number(process.env[name]);
15
- return Number.isInteger(value) && value > 0 && value < 65536 ? value : fallback;
14
+ const CONFIG_FILE = path_1.default.join(PACKAGE_PATH, ".locadot-config.json");
15
+ const validPort = (value) => {
16
+ const port = Number(value);
17
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;
16
18
  };
19
+ // Ports chosen with `locadot start --port/--https-port`, so later commands and boot startup reuse them.
20
+ const savedPorts = () => {
21
+ try {
22
+ return JSON.parse(fs_1.default.readFileSync(CONFIG_FILE, "utf8")) || {};
23
+ }
24
+ catch {
25
+ return {};
26
+ }
27
+ };
28
+ const envPort = (name, saved, fallback) => validPort(process.env[name]) ?? validPort(saved) ?? fallback;
17
29
  class Constants {
18
30
  }
19
31
  Constants.paths = {
@@ -25,10 +37,12 @@ Constants.paths = {
25
37
  CERT_DIR: path_1.default.join(PACKAGE_PATH, "certs"),
26
38
  // Secret for the dashboard's mutating API; rotated on every proxy start.
27
39
  API_TOKEN: path_1.default.join(PACKAGE_PATH, ".locadot-token"),
40
+ CONFIG_FILE,
28
41
  };
42
+ Constants.validPort = validPort;
29
43
  Constants.server = {
30
- httpPort: envPort("LOCADOT_HTTP_PORT", 80),
31
- httpsPort: envPort("LOCADOT_HTTPS_PORT", 443),
44
+ httpPort: envPort("LOCADOT_HTTP_PORT", savedPorts().httpPort, 80),
45
+ httpsPort: envPort("LOCADOT_HTTPS_PORT", savedPorts().httpsPort, 443),
32
46
  // Loopback only unless the user opts in; see tasks/enhancements ENH-06.
33
47
  bind: (process.env.LOCADOT_BIND || "127.0.0.1,::1")
34
48
  .split(",")
package/dist/index.js CHANGED
@@ -66,9 +66,12 @@ program
66
66
  .command("open [host]")
67
67
  .description("Open a domain, or the dashboard when no host is given, in the browser")
68
68
  .action(run((host) => commands_1.default.open(host)));
69
- program.command("start").description("Start the central proxy").action(run(() => commands_1.default.start()));
69
+ const portOptions = (command) => command
70
+ .option("-p, --port <port>", "HTTP port (default 80, or the last one you chose)")
71
+ .option("--https-port <port>", "HTTPS port (default 443, or the last one you chose)");
72
+ portOptions(program.command("start").description("Start the central proxy")).action(run((options) => commands_1.default.start(options)));
70
73
  program.command("stop").description("Stop the central proxy (keeps hosts and logs)").action(run(() => commands_1.default.stop()));
71
- program.command("restart").description("Restart the central proxy").action(run(() => commands_1.default.restart()));
74
+ portOptions(program.command("restart").description("Restart the central proxy")).action(run((options) => commands_1.default.restart(options)));
72
75
  program.command("kill").description("Stop the proxy, remove all hosts and clear logs").action(run(() => commands_1.default.kill()));
73
76
  program.command("trust").description("Install the locadot CA in the system trust store (sudo)").action(run(() => commands_1.default.trust()));
74
77
  program.command("untrust").description("Remove the locadot CA from the system trust store (sudo)").action(run(() => commands_1.default.untrust()));
@@ -52,6 +52,27 @@ const startup_1 = __importDefault(require("../utils/startup"));
52
52
  const certs_1 = require("../utils/certs");
53
53
  const trust_1 = require("../utils/trust");
54
54
  const print = (message) => logger_1.default.info(message);
55
+ /** Applies --port/--https-port and saves them, so `add`, `status`, `open` and boot startup use the same ports. */
56
+ const usePorts = (options) => {
57
+ if (options.port === undefined && options.httpsPort === undefined)
58
+ return false;
59
+ const parse = (value, flag) => {
60
+ if (value === undefined)
61
+ return undefined;
62
+ const port = constants_1.default.validPort(value);
63
+ if (!port)
64
+ throw new localhost_1.InputError(`❌ ${flag} must be a port between 1 and 65535, got "${value}".`);
65
+ return port;
66
+ };
67
+ const httpPort = parse(options.port, "--port") ?? constants_1.default.server.httpPort;
68
+ const httpsPort = parse(options.httpsPort, "--https-port") ?? constants_1.default.server.httpsPort;
69
+ if (httpPort === httpsPort)
70
+ throw new localhost_1.InputError(`❌ HTTP and HTTPS can't share port ${httpPort}.`);
71
+ Object.assign(constants_1.default.server, { httpPort, httpsPort });
72
+ file_1.default.ensureDir();
73
+ fs_1.default.writeFileSync(constants_1.default.paths.CONFIG_FILE, JSON.stringify({ httpPort, httpsPort }, null, 2) + "\n");
74
+ return true;
75
+ };
55
76
  const urlFor = (host, secure = true) => {
56
77
  const port = secure ? constants_1.default.server.httpsPort : constants_1.default.server.httpPort;
57
78
  const standard = secure ? 443 : 80;
@@ -174,7 +195,7 @@ class Commands {
174
195
  check(listening, `Port ${port} answering`, "The proxy is running but not listening; check `locadot logs`.");
175
196
  }
176
197
  else if (listening) {
177
- check(false, `Port ${port} is free`, `Something else holds port ${port}. Find it with \`sudo lsof -i :${port}\`, or set LOCADOT_${port === constants_1.default.server.httpPort ? "HTTP" : "HTTPS"}_PORT.`);
198
+ check(false, `Port ${port} is free`, `Something else holds port ${port}. Find it with \`sudo lsof -i :${port}\`, or pick another with \`locadot start ${port === constants_1.default.server.httpPort ? "--port" : "--https-port"} <port>\`.`);
178
199
  }
179
200
  }
180
201
  if (info && (await localhost_1.default.isPortListening(constants_1.default.server.httpPort))) {
@@ -213,19 +234,30 @@ class Commands {
213
234
  if (failures)
214
235
  process.exitCode = 1;
215
236
  }
216
- static async start() {
217
- const wasRunning = proxy_1.default.running();
218
- const info = await proxy_1.default.start();
219
- print(wasRunning ? `☑️ Proxy already running (pid ${info.pid}).` : `🚀 Central proxy started (pid ${info.pid}).`);
237
+ static async start(options = {}) {
238
+ const moved = usePorts(options);
239
+ const running = proxy_1.default.running();
240
+ // Already up on other ports: the new ones only take effect after a restart.
241
+ if (running && (running.httpPort !== constants_1.default.server.httpPort || running.httpsPort !== constants_1.default.server.httpsPort)) {
242
+ const info = await proxy_1.default.restart();
243
+ print(`🔁 Proxy restarted on http ${info.httpPort}, https ${info.httpsPort} (pid ${info.pid}).`);
244
+ }
245
+ else {
246
+ const info = await proxy_1.default.start();
247
+ print(running ? `☑️ Proxy already running (pid ${info.pid}).` : `🚀 Central proxy started (pid ${info.pid}).`);
248
+ if (moved || !running)
249
+ print(` Ports: http ${info.httpPort}, https ${info.httpsPort}`);
250
+ }
220
251
  print(` Dashboard: ${(0, exports.urlFor)("localhost")}`);
221
252
  }
222
253
  static async stop() {
223
254
  const stopped = await proxy_1.default.stop();
224
255
  print(stopped ? constants_1.default.proxyInfo.softClose : "☑️ Proxy was not running.");
225
256
  }
226
- static async restart() {
257
+ static async restart(options = {}) {
258
+ usePorts(options);
227
259
  const info = await proxy_1.default.restart();
228
- print(`☑️ Proxy restarted (pid ${info.pid}).`);
260
+ print(`☑️ Proxy restarted on http ${info.httpPort}, https ${info.httpsPort} (pid ${info.pid}).`);
229
261
  }
230
262
  static async kill() {
231
263
  await proxy_1.default.kill();
@@ -262,6 +294,7 @@ class Commands {
262
294
  ["Certs", constants_1.default.paths.CERT_DIR],
263
295
  ["CA cert", (0, certs_1.caCertPath)()],
264
296
  ["API token", constants_1.default.paths.API_TOKEN],
297
+ ["Config", constants_1.default.paths.CONFIG_FILE],
265
298
  ];
266
299
  rows.forEach(([label, value]) => console.log(`${label.padEnd(10)} ${value}`));
267
300
  }
package/dist/lib/http.js CHANGED
@@ -3,10 +3,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.applyCors = exports.hostOf = void 0;
6
+ exports.applyCors = exports.rewriteBody = exports.rewriteOrigins = exports.originMap = exports.hostOf = void 0;
7
+ const zlib_1 = __importDefault(require("zlib"));
7
8
  const template_1 = require("../constants/template");
8
9
  const constants_1 = __importDefault(require("../constants"));
9
10
  const logger_1 = __importDefault(require("../utils/logger"));
11
+ const passthrough_1 = require("./passthrough");
10
12
  /** Host header without port, lowercased; handles [::1]:443. */
11
13
  const hostOf = (req) => {
12
14
  const raw = (req.headers.host || "").trim().toLowerCase();
@@ -22,7 +24,7 @@ const dashboardUrl = (req) => {
22
24
  const standard = tls ? 443 : 80;
23
25
  return `${tls ? "https" : "http"}://localhost${port === standard ? "" : `:${port}`}/`;
24
26
  };
25
- const proxyOptions = (req, entry) => ({
27
+ const proxyOptions = (req, entry, lookup) => ({
26
28
  target: entry.target,
27
29
  changeOrigin: true,
28
30
  xfwd: true,
@@ -34,25 +36,132 @@ const proxyOptions = (req, entry) => ({
34
36
  hostRewrite: req.headers.host,
35
37
  protocolRewrite: isTls(req) ? "https" : "http",
36
38
  cookieDomainRewrite: { "*": "" },
37
- headers: { "X-Original-Host": req.headers.host || "", ...(entry.cors ? sameOriginHeaders(req, entry) : {}) },
39
+ headers: { "X-Original-Host": req.headers.host || "", ...(entry.cors ? { ...sameOriginHeaders(req, entry, lookup), ...DECODABLE } : {}) },
38
40
  });
39
- // --cors: the upstream should see a request from its own site, so origin/CSRF checks pass.
40
- const sameOriginHeaders = (req, entry) => {
41
- const origin = new URL(entry.target).origin;
41
+ // --cors bodies are rewritten, so only ask for encodings we can decode (browsers also offer zstd).
42
+ const DECODABLE = { "Accept-Encoding": "gzip, deflate, br" };
43
+ const viaOptions = (req, entry, via) => ({
44
+ target: `${via.scheme}://${via.host}`,
45
+ changeOrigin: true,
46
+ ws: true,
47
+ secure: !entry.insecure,
48
+ cookieDomainRewrite: { "*": "" },
49
+ headers: { ...(0, passthrough_1.viaHeaders)(req, via, entry.target), ...DECODABLE },
50
+ });
51
+ /**
52
+ * --cors: the upstream should see a request from a site it trusts, so origin/CSRF checks pass.
53
+ * A page on another mapped domain (signalsant.localhost calling api.signalsant.localhost) is
54
+ * sent as that domain's real origin; anything else as the target's own origin.
55
+ */
56
+ const sameOriginHeaders = (req, entry, lookup) => {
57
+ const upstreamOrigin = (value) => {
58
+ try {
59
+ const caller = lookup(new URL(value).hostname);
60
+ if (caller)
61
+ return new URL(caller.target).origin;
62
+ }
63
+ catch { }
64
+ return new URL(entry.target).origin;
65
+ };
42
66
  const headers = {};
43
67
  if (req.headers.origin)
44
- headers.Origin = origin;
68
+ headers.Origin = upstreamOrigin(req.headers.origin);
45
69
  if (req.headers.referer) {
46
70
  try {
47
71
  const referer = new URL(req.headers.referer);
48
- headers.Referer = origin + referer.pathname + referer.search;
72
+ headers.Referer = upstreamOrigin(referer.href) + referer.pathname + referer.search;
49
73
  }
50
74
  catch {
51
- headers.Referer = origin + "/";
75
+ headers.Referer = new URL(entry.target).origin + "/";
52
76
  }
53
77
  }
54
78
  return headers;
55
79
  };
80
+ const REWRITABLE = /^(text\/(?!event-stream)|application\/(javascript|x-javascript|ecmascript|json|xml|[\w.+-]+\+(json|xml))\b)/i;
81
+ const DECODERS = {
82
+ gzip: zlib_1.default.gunzipSync,
83
+ "x-gzip": zlib_1.default.gunzipSync,
84
+ deflate: zlib_1.default.inflateSync,
85
+ br: zlib_1.default.brotliDecompressSync,
86
+ };
87
+ /** [real origin, local origin] for every mapping, e.g. https://api.x.com → https://api.x.localhost. */
88
+ const originMap = (req, hosts) => {
89
+ const tls = isTls(req);
90
+ const port = tls ? constants_1.default.server.httpsPort : constants_1.default.server.httpPort;
91
+ const suffix = port === (tls ? 443 : 80) ? "" : `:${port}`;
92
+ const self = (0, exports.hostOf)(req);
93
+ const pairs = [];
94
+ for (const [host, entry] of Object.entries(hosts)) {
95
+ try {
96
+ pairs.push([new URL(entry.target).origin, `${tls ? "https" : "http"}://${host}${suffix}`, host === self]);
97
+ }
98
+ catch { }
99
+ }
100
+ // Longest first, so https://a.x.com is not half-replaced by a mapping for https://x.com;
101
+ // when two domains share a target, the one serving this response wins.
102
+ return pairs
103
+ .sort((a, b) => b[0].length - a[0].length || Number(b[2]) - Number(a[2]))
104
+ .map(([from, to]) => [from, to]);
105
+ };
106
+ exports.originMap = originMap;
107
+ const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108
+ /** Replaces real origins with local ones, plain and JSON-escaped (https:\/\/…). */
109
+ const rewriteOrigins = (text, pairs) => {
110
+ for (const [from, to] of pairs) {
111
+ for (const [a, b] of [[from, to], [from.replace(/\//g, "\\/"), to.replace(/\//g, "\\/")]]) {
112
+ text = text.replace(new RegExp(escapeRegex(a) + "(?![\\w.-])", "gi"), b);
113
+ }
114
+ }
115
+ return text;
116
+ };
117
+ exports.rewriteOrigins = rewriteOrigins;
118
+ /**
119
+ * --cors: pages that call other domains by absolute URL (`https://api.x.com`) bypass the proxy,
120
+ * so the browser applies the real API's CORS policy. Rewriting mapped origins in text bodies
121
+ * keeps those calls on .localhost. Buffers the body; streams (SSE, binary) pass through.
122
+ */
123
+ const rewriteBody = (req, res, proxyRes, pairs, inject = false) => {
124
+ const headers = proxyRes.headers;
125
+ const encoding = String(headers["content-encoding"] || "identity").toLowerCase();
126
+ if (!pairs.length || req.method === "HEAD" || !REWRITABLE.test(String(headers["content-type"] || "")))
127
+ return;
128
+ if (encoding !== "identity" && !DECODERS[encoding])
129
+ return;
130
+ if (proxyRes.statusCode === 204 || proxyRes.statusCode === 304)
131
+ return;
132
+ delete headers["content-length"];
133
+ delete headers["content-encoding"];
134
+ delete headers["transfer-encoding"];
135
+ const chunks = [];
136
+ const end = res.end.bind(res);
137
+ res.write = ((chunk) => {
138
+ if (chunk)
139
+ chunks.push(Buffer.from(chunk));
140
+ return true;
141
+ });
142
+ res.end = ((chunk) => {
143
+ if (chunk && typeof chunk !== "function")
144
+ chunks.push(Buffer.from(chunk));
145
+ let body = Buffer.concat(chunks);
146
+ try {
147
+ if (encoding !== "identity")
148
+ body = DECODERS[encoding](body);
149
+ let text = (0, exports.rewriteOrigins)(body.toString("utf8"), pairs);
150
+ if (inject && /^text\/html/i.test(String(headers["content-type"])))
151
+ text = (0, passthrough_1.injectShim)(text);
152
+ body = Buffer.from(text, "utf8");
153
+ }
154
+ catch (error) {
155
+ logger_1.default.warn(`${(0, exports.hostOf)(req)}: could not rewrite response body: ${error}`);
156
+ res.statusCode = 502;
157
+ body = Buffer.alloc(0);
158
+ }
159
+ if (!res.headersSent)
160
+ res.setHeader("Content-Length", body.length);
161
+ return end(body);
162
+ });
163
+ };
164
+ exports.rewriteBody = rewriteBody;
56
165
  const isPreflight = (req) => req.method === "OPTIONS" && !!req.headers.origin && !!req.headers["access-control-request-method"];
57
166
  /** Headers that let the calling page read the response, credentials included. */
58
167
  const allowOrigin = (req) => req.headers.origin
@@ -120,19 +229,37 @@ class HttpModule {
120
229
  res.end();
121
230
  return;
122
231
  }
232
+ if (entry.cors && req.url?.split("?")[0] === passthrough_1.SHIM_PATH) {
233
+ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-store" });
234
+ res.end(passthrough_1.shimScript);
235
+ return;
236
+ }
237
+ const via = entry.cors ? (0, passthrough_1.parseVia)(req.url) : undefined;
238
+ if (via && !(0, passthrough_1.isSameOrigin)(req)) {
239
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
240
+ res.end("locadot: the pass-through only serves the page's own requests.\n");
241
+ return;
242
+ }
123
243
  const started = Date.now();
124
244
  res.once("finish", () => {
125
245
  record(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500);
126
246
  });
127
- ctx.proxy.web(req, res, proxyOptions(req, entry), (err) => {
128
- logger_1.default.warn(`${host} → ${entry.target}: ${err?.code || err?.message}`);
247
+ let options = proxyOptions(req, entry, ctx.lookup);
248
+ if (via) {
249
+ req.locadotVia = via;
250
+ options = viaOptions(req, entry, via);
251
+ req.url = via.path;
252
+ }
253
+ const upstream = via ? `${via.scheme}://${via.host}` : entry.target;
254
+ ctx.proxy.web(req, res, options, (err) => {
255
+ logger_1.default.warn(`${host} → ${upstream}: ${err?.code || err?.message}`);
129
256
  if (res.headersSent) {
130
257
  res.destroy();
131
258
  return;
132
259
  }
133
260
  // With --cors the page should see a 502, not a CORS error.
134
261
  res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", ...(entry.cors ? allowOrigin(req) : {}) });
135
- res.end((0, template_1.upstreamDown)(host, entry.target, err?.code || err?.message || "error", dashboardUrl(req)));
262
+ res.end((0, template_1.upstreamDown)(host, upstream, err?.code || err?.message || "error", dashboardUrl(req)));
136
263
  });
137
264
  }
138
265
  catch (error) {
@@ -151,9 +278,19 @@ class HttpModule {
151
278
  socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
152
279
  return;
153
280
  }
281
+ const via = entry.cors ? (0, passthrough_1.parseVia)(req.url) : undefined;
282
+ if (via && !(0, passthrough_1.isSameOrigin)(req)) {
283
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
284
+ return;
285
+ }
154
286
  logger_1.default.debug(`WebSocket upgrade for ${host}`);
155
- ctx.proxy.ws(req, socket, head, proxyOptions(req, entry), (err) => {
156
- logger_1.default.warn(`${host} WebSocket → ${entry.target}: ${err?.code || err?.message}`);
287
+ let options = proxyOptions(req, entry, ctx.lookup);
288
+ if (via) {
289
+ options = viaOptions(req, entry, via);
290
+ req.url = via.path;
291
+ }
292
+ ctx.proxy.ws(req, socket, head, options, (err) => {
293
+ logger_1.default.warn(`${host} WebSocket → ${via ? `${via.scheme}://${via.host}` : entry.target}: ${err?.code || err?.message}`);
157
294
  socket.destroy();
158
295
  });
159
296
  }
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.rewriteViaResponse = exports.viaHeaders = exports.isSameSite = exports.shimScript = exports.injectShim = exports.shimTag = exports.isSameOrigin = exports.viaPath = exports.parseVia = exports.SHIM_PATH = exports.PREFIX = void 0;
4
+ /**
5
+ * --cors pass-through: a page on a --cors domain calls other origins (api.x.com, a CDN's JSON,
6
+ * a third-party API) by absolute URL, and the browser enforces *their* CORS policy. The shim
7
+ * injected into its HTML sends those calls to /__locadot/x/<scheme>/<host>/<path> on the page's
8
+ * own origin instead, so the browser sees a same-origin request, and locadot forwards it upstream
9
+ * as the real site. No per-domain mapping needed.
10
+ */
11
+ exports.PREFIX = "/__locadot/";
12
+ exports.SHIM_PATH = `${exports.PREFIX}shim.js`;
13
+ const VIA = `${exports.PREFIX}x/`;
14
+ /** Parses /__locadot/x/https/api.x.com:8443/v1?q → {https, api.x.com:8443, /v1?q}. */
15
+ const parseVia = (url) => {
16
+ const match = (url || "").match(/^\/__locadot\/x\/(https?|wss?)\/([a-z0-9.-]+(?::\d{1,5})?)(\/.*)?$/i);
17
+ if (!match)
18
+ return undefined;
19
+ return { scheme: match[1].toLowerCase(), host: match[2].toLowerCase(), path: match[3] || "/" };
20
+ };
21
+ exports.parseVia = parseVia;
22
+ /** The local path the shim uses for an absolute URL; the inverse of parseVia. */
23
+ const viaPath = (url) => `${VIA}${url.protocol.slice(0, -1)}/${url.host}${url.pathname}${url.search}`;
24
+ exports.viaPath = viaPath;
25
+ /**
26
+ * Only the page itself may use the pass-through. A link or form on another site would otherwise
27
+ * turn the proxy into a way to reach any host from the user's machine.
28
+ */
29
+ const isSameOrigin = (req) => {
30
+ const site = req.headers["sec-fetch-site"];
31
+ if (site)
32
+ return site === "same-origin";
33
+ const origin = req.headers.origin;
34
+ if (!origin)
35
+ return true;
36
+ try {
37
+ return new URL(origin).host === req.headers.host;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ };
43
+ exports.isSameOrigin = isSameOrigin;
44
+ exports.shimTag = `<script src="${exports.SHIM_PATH}"></script>`;
45
+ /** Adds the shim as the first script in <head>, so it runs before the app's own scripts. */
46
+ const injectShim = (html) => {
47
+ if (html.includes(exports.shimTag))
48
+ return html;
49
+ const head = html.match(/<head\b[^>]*>/i);
50
+ if (head)
51
+ return html.replace(head[0], head[0] + exports.shimTag);
52
+ return exports.shimTag + html;
53
+ };
54
+ exports.injectShim = injectShim;
55
+ exports.shimScript = `(() => {
56
+ if (window.__locadotShim) return;
57
+ window.__locadotShim = true;
58
+ const VIA = ${JSON.stringify(VIA)};
59
+ const route = (input) => {
60
+ try {
61
+ const url = new URL(String(input), location.href);
62
+ if (!/^(https?|wss?):$/.test(url.protocol)) return null;
63
+ const local = url.hostname === "localhost" || url.hostname.endsWith(".localhost") || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
64
+ if (local || url.host === location.host) return null;
65
+ const ws = url.protocol === "ws:" || url.protocol === "wss:";
66
+ const base = ws ? (location.protocol === "https:" ? "wss://" : "ws://") + location.host : location.origin;
67
+ return base + VIA + url.protocol.slice(0, -1) + "/" + url.host + url.pathname + url.search + url.hash;
68
+ } catch (e) {
69
+ return null;
70
+ }
71
+ };
72
+ const fetch0 = window.fetch;
73
+ if (fetch0) {
74
+ window.fetch = function (input, init) {
75
+ if (typeof Request !== "undefined" && input instanceof Request) {
76
+ const to = route(input.url);
77
+ if (to) input = new Request(to, input);
78
+ } else {
79
+ const to = route(input);
80
+ if (to) input = to;
81
+ }
82
+ return fetch0.call(this, input, init);
83
+ };
84
+ }
85
+ if (window.XMLHttpRequest) {
86
+ const open0 = XMLHttpRequest.prototype.open;
87
+ XMLHttpRequest.prototype.open = function (method, url) {
88
+ const args = Array.prototype.slice.call(arguments);
89
+ const to = route(url);
90
+ if (to) args[1] = to;
91
+ return open0.apply(this, args);
92
+ };
93
+ }
94
+ const wrap = (name) => {
95
+ const Original = window[name];
96
+ if (!Original) return;
97
+ const Wrapped = function (url, options) {
98
+ return new Original(route(url) || url, options);
99
+ };
100
+ Wrapped.prototype = Original.prototype;
101
+ Object.getOwnPropertyNames(Original).forEach((key) => {
102
+ if (!(key in Wrapped)) try { Wrapped[key] = Original[key]; } catch (e) {}
103
+ });
104
+ window[name] = Wrapped;
105
+ };
106
+ wrap("EventSource");
107
+ wrap("WebSocket");
108
+ if (navigator.sendBeacon) {
109
+ const beacon0 = navigator.sendBeacon.bind(navigator);
110
+ navigator.sendBeacon = (url, data) => beacon0(route(url) || url, data);
111
+ }
112
+ })();
113
+ `;
114
+ /** The site's own domain without a leading www: signalsant.com for https://www.signalsant.com. */
115
+ const siteDomain = (target) => new URL(target).hostname.replace(/^www\./, "");
116
+ /** api.signalsant.com is the same site as signalsant.com; a CDN or third-party API is not. */
117
+ const isSameSite = (host, target) => {
118
+ const hostname = host.replace(/:\d+$/, "");
119
+ const domain = siteDomain(target);
120
+ return hostname === domain || hostname.endsWith(`.${domain}`);
121
+ };
122
+ exports.isSameSite = isSameSite;
123
+ /** Upstream headers for a pass-through call: the request looks like it came from the real site. */
124
+ const viaHeaders = (req, via, target) => {
125
+ const site = new URL(target).origin;
126
+ const headers = { Origin: site };
127
+ try {
128
+ if (req.headers.referer) {
129
+ const referer = new URL(req.headers.referer);
130
+ headers.Referer = site + referer.pathname + referer.search;
131
+ }
132
+ }
133
+ catch { }
134
+ // The page's cookies belong to its own site; don't hand them to third parties.
135
+ if (!(0, exports.isSameSite)(via.host, target))
136
+ delete req.headers.cookie;
137
+ return headers;
138
+ };
139
+ exports.viaHeaders = viaHeaders;
140
+ /**
141
+ * Keeps a pass-through response on the pass-through: redirects are routed back through it,
142
+ * host-only cookies are scoped to the upstream's path, and third-party cookies are dropped.
143
+ */
144
+ const rewriteViaResponse = (headers, via, target) => {
145
+ const base = `${VIA}${via.scheme}/${via.host}`;
146
+ const location = headers.location;
147
+ if (location) {
148
+ if (location.startsWith("/") && !location.startsWith("//"))
149
+ headers.location = base + location;
150
+ else {
151
+ try {
152
+ const url = new URL(location, `${via.scheme}://${via.host}/`);
153
+ if (/^https?:$/.test(url.protocol) && !/(^|\.)localhost$/.test(url.hostname))
154
+ headers.location = (0, exports.viaPath)(url) + url.hash;
155
+ }
156
+ catch { }
157
+ }
158
+ }
159
+ const cookies = headers["set-cookie"];
160
+ if (!cookies)
161
+ return;
162
+ if (!(0, exports.isSameSite)(via.host, target)) {
163
+ delete headers["set-cookie"];
164
+ return;
165
+ }
166
+ headers["set-cookie"] = cookies.map((cookie) => {
167
+ // Domain=.signalsant.com is shared with the page on the real site, so keep its path.
168
+ if (/;\s*domain=/i.test(cookie))
169
+ return cookie;
170
+ const path = cookie.match(/;\s*path=([^;]*)/i)?.[1]?.trim() || "/";
171
+ return cookie.replace(/;\s*path=[^;]*/gi, "") + `; Path=${base}${path === "/" ? "" : path}`;
172
+ });
173
+ };
174
+ exports.rewriteViaResponse = rewriteViaResponse;
package/dist/proxy.js CHANGED
@@ -52,7 +52,13 @@ class ProxyHandler {
52
52
  const child = (0, child_process_1.spawn)(command, args, {
53
53
  detached: true,
54
54
  stdio: ["ignore", logFd, logFd],
55
- env: { ...process.env, LOCADOT_HOME: constants_1.default.paths.HOME, LOCADOT_ROLE: "proxy" },
55
+ env: {
56
+ ...process.env,
57
+ LOCADOT_HOME: constants_1.default.paths.HOME,
58
+ LOCADOT_ROLE: "proxy",
59
+ LOCADOT_HTTP_PORT: String(constants_1.default.server.httpPort),
60
+ LOCADOT_HTTPS_PORT: String(constants_1.default.server.httpsPort),
61
+ },
56
62
  windowsHide: true,
57
63
  });
58
64
  fs_1.default.closeSync(logFd);
package/dist/server.js CHANGED
@@ -47,6 +47,7 @@ const registry_1 = __importDefault(require("./lib/registry"));
47
47
  const locadot_file_1 = __importDefault(require("./lib/locadot-file"));
48
48
  const http_2 = __importStar(require("./lib/http"));
49
49
  const localhost_1 = __importDefault(require("./lib/localhost"));
50
+ const passthrough_1 = require("./lib/passthrough");
50
51
  const file_1 = __importDefault(require("./utils/file"));
51
52
  const logger_1 = __importDefault(require("./utils/logger"));
52
53
  const certs_1 = require("./utils/certs");
@@ -101,7 +102,7 @@ async function startCentralProxy() {
101
102
  // Hop-by-hop headers describe the upstream connection, not ours. Apache sends
102
103
  // `Connection: Upgrade, close` + `Upgrade: h2`, which made us close the browser's
103
104
  // socket after every response (ERR_TOO_MANY_RETRIES on asset-heavy pages).
104
- proxy.on("proxyRes", (proxyRes, req) => {
105
+ proxy.on("proxyRes", (proxyRes, req, res) => {
105
106
  if (proxyRes.statusCode === 101)
106
107
  return;
107
108
  const listed = String(proxyRes.headers.connection || "")
@@ -111,8 +112,17 @@ async function startCentralProxy() {
111
112
  for (const name of [...listed, "connection", "keep-alive", "upgrade", "proxy-connection"]) {
112
113
  delete proxyRes.headers[name];
113
114
  }
114
- if (registry.hosts[(0, http_2.hostOf)(req)]?.cors)
115
- (0, http_2.applyCors)(req, proxyRes.headers);
115
+ const entry = registry.hosts[(0, http_2.hostOf)(req)];
116
+ if (!entry?.cors)
117
+ return;
118
+ (0, http_2.applyCors)(req, proxyRes.headers);
119
+ const via = req.locadotVia;
120
+ const pairs = (0, http_2.originMap)(req, registry.hosts);
121
+ if (via)
122
+ (0, passthrough_1.rewriteViaResponse)(proxyRes.headers, via, entry.target);
123
+ else if (proxyRes.headers.location)
124
+ proxyRes.headers.location = (0, http_2.rewriteOrigins)(proxyRes.headers.location, pairs);
125
+ (0, http_2.rewriteBody)(req, res, proxyRes, pairs, !via);
116
126
  });
117
127
  const info = {
118
128
  pid: process.pid,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "1.6.0-beta.3",
3
+ "version": "1.6.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",