locadot 1.6.0-beta.2 → 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 +3 -1
- package/README.md +30 -8
- package/dist/constants/index.js +19 -5
- package/dist/dashboard/api.js +2 -2
- package/dist/dashboard/index.js +1 -0
- package/dist/dashboard/page.js +36 -12
- package/dist/index.js +7 -2
- package/dist/lib/commands.js +43 -10
- package/dist/lib/hosts.js +3 -2
- package/dist/lib/http.js +207 -9
- package/dist/lib/passthrough.js +174 -0
- package/dist/proxy.js +7 -1
- package/dist/server.js +47 -2
- package/package.json +1 -1
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.
|
|
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,6 +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
|
+
- `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)
|
|
16
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,
|
|
17
19
|
tail and clear logs, and stop the proxy. (FEAT-05)
|
|
18
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/`).
|
|
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,15 +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). |
|
|
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)). |
|
|
57
59
|
| `--no-start` | Save the mapping without starting the proxy. |
|
|
58
60
|
|
|
59
61
|
### Proxy
|
|
60
62
|
|
|
61
63
|
| Command | What it does |
|
|
62
64
|
| --- | --- |
|
|
63
|
-
| `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. |
|
|
64
66
|
| `locadot stop` | Stop the proxy. Mappings and logs are kept. |
|
|
65
|
-
| `locadot restart` | Stop, then start. |
|
|
67
|
+
| `locadot restart [--port …] [--https-port …]` | Stop, then start. |
|
|
66
68
|
| `locadot kill` | Stop the proxy, remove all mappings and clear the logs. |
|
|
67
69
|
| `locadot status [--json]` | Is it running, its PID, ports, number of hosts, CA trust, start-at-boot, dashboard URL. |
|
|
68
70
|
| `locadot doctor [--host h]` | Check the proxy, ports, permissions, CA trust, the registry and every target. Exits 1 if anything fails. |
|
|
@@ -93,7 +95,7 @@ are answered by locadot itself, not proxied. The page is a dark control panel:
|
|
|
93
95
|
|
|
94
96
|
- **System:** whether the proxy runs as root/admin, whether ports below 1024 can be bound (with the Linux sysctl fix when they
|
|
95
97
|
can't), CA trust with an on/off toggle, start-at-boot with an on/off toggle, ports, bind addresses and state dir. It also has a **Stop proxy** button.
|
|
96
|
-
- **Proxies:** add a mapping (host + port / host:port / URL, and
|
|
98
|
+
- **Proxies:** add a mapping (host + port / host:port / URL, and optional "Insecure TLS" and "Bypass CORS" boxes), edit a target inline, or remove one. The table shows
|
|
97
99
|
each source → destination with up/down, status, latency, hits, errors, last access and average ms. It refreshes every 5 s.
|
|
98
100
|
- **Logs:** a live tail with refresh and clear.
|
|
99
101
|
- **CLI / API snippets** you can copy.
|
|
@@ -113,8 +115,8 @@ each time the proxy starts.
|
|
|
113
115
|
| `GET /api/status` | | Proxy info, uptime, host count, and `system` (`platform`, `isRoot`, `canBindPrivileged`, `unprivilegedPortStart`, `caTrusted`, `startup`, `stateDir`). |
|
|
114
116
|
| `GET /api/hosts` | | Every mapping with urls, probe (`up`, `status`, `ms`) and stats. |
|
|
115
117
|
| `GET /api/logs?lines=200` | | `{ lines: [...] }` |
|
|
116
|
-
| `POST /api/hosts` | `{ "host": "app.localhost", "target": "3000", "insecure": false }` | Add a mapping (201; 409 if it exists). |
|
|
117
|
-
| `PUT /api/hosts/:host` | `{ "target": "https://example.com", "insecure": false }` | Change a mapping (404 if unknown). |
|
|
118
|
+
| `POST /api/hosts` | `{ "host": "app.localhost", "target": "3000", "insecure": false, "cors": false }` | Add a mapping (201; 409 if it exists). |
|
|
119
|
+
| `PUT /api/hosts/:host` | `{ "target": "https://example.com", "insecure": false, "cors": true }` | Change a mapping (404 if unknown). |
|
|
118
120
|
| `DELETE /api/hosts/:host` | | Remove a mapping. |
|
|
119
121
|
| `POST /api/startup` | `{ "enabled": true }` | Start at boot on/off. |
|
|
120
122
|
| `POST /api/trust` | `{ "trusted": true }` | Trust or untrust the CA. |
|
|
@@ -147,8 +149,8 @@ All optional, via environment variables. Set them for the CLI; the proxy it star
|
|
|
147
149
|
| Variable | Default | Purpose |
|
|
148
150
|
| --- | --- | --- |
|
|
149
151
|
| `LOCADOT_HOME` | OS app-data dir (`~/.config/locadot` on Linux) | Where the registry, logs, lock file and certs live. |
|
|
150
|
-
| `LOCADOT_HTTP_PORT` | `80` | HTTP port of the proxy. |
|
|
151
|
-
| `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`. |
|
|
152
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). |
|
|
153
155
|
| `LOCADOT_LOG_LEVEL` | `info` | winston log level (`debug` logs every WebSocket upgrade). |
|
|
154
156
|
|
|
@@ -164,6 +166,26 @@ redirect to a *different* domain (for example `google.com` → `www.google.com`)
|
|
|
164
166
|
locadot, map the final host instead (`--target https://www.google.com`). Some sites refuse to be framed or proxied; that's up to
|
|
165
167
|
the site.
|
|
166
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
|
+
|
|
167
189
|
---
|
|
168
190
|
|
|
169
191
|
## 🛠️ Contributing
|
package/dist/constants/index.js
CHANGED
|
@@ -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
|
|
14
|
-
|
|
15
|
-
|
|
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/dashboard/api.js
CHANGED
|
@@ -165,7 +165,7 @@ async function route(req, url, ctx) {
|
|
|
165
165
|
const body = await readJson(req);
|
|
166
166
|
try {
|
|
167
167
|
if (path === "/api/hosts") {
|
|
168
|
-
const { host, entry } = await hosts_1.default.add({ host: body.host, target: body.target, insecure: optionalBool(body.insecure, "insecure") });
|
|
168
|
+
const { host, entry } = await hosts_1.default.add({ host: body.host, target: body.target, insecure: optionalBool(body.insecure, "insecure"), cors: optionalBool(body.cors, "cors") });
|
|
169
169
|
ctx.reload();
|
|
170
170
|
logger_1.default.info(`➕ dashboard: ${host} → ${entry.target}`);
|
|
171
171
|
return { status: 201, body: { ok: true, host, ...entry, url: hostUrl(host, ctx) } };
|
|
@@ -178,7 +178,7 @@ async function route(req, url, ctx) {
|
|
|
178
178
|
logger_1.default.info(`🗑️ dashboard: removed ${host}`);
|
|
179
179
|
return { status: 200, body: { ok: true, host } };
|
|
180
180
|
}
|
|
181
|
-
const updated = await hosts_1.default.update({ host, target: body.target, insecure: optionalBool(body.insecure, "insecure") });
|
|
181
|
+
const updated = await hosts_1.default.update({ host, target: body.target, insecure: optionalBool(body.insecure, "insecure"), cors: optionalBool(body.cors, "cors") });
|
|
182
182
|
ctx.reload();
|
|
183
183
|
logger_1.default.info(`✏️ dashboard: ${updated.host} → ${updated.entry.target}`);
|
|
184
184
|
return { status: 200, body: { ok: true, host: updated.host, ...updated.entry, url: hostUrl(updated.host, ctx) } };
|
package/dist/dashboard/index.js
CHANGED
package/dist/dashboard/page.js
CHANGED
|
@@ -305,6 +305,9 @@ tr:last-child td { border-bottom: none; }
|
|
|
305
305
|
width: 100%;
|
|
306
306
|
min-width: 160px;
|
|
307
307
|
}
|
|
308
|
+
.badges { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
309
|
+
.badge-cors { color: var(--accent-cyan); border-color: var(--accent-cyan); background: rgba(34, 211, 238, 0.1); }
|
|
310
|
+
td .inline-check + .inline-check { margin-top: 4px; }
|
|
308
311
|
.inline-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
|
309
312
|
.dim { color: var(--muted-dim); }
|
|
310
313
|
|
|
@@ -449,6 +452,7 @@ const clientJs = `
|
|
|
449
452
|
var addHostInput = document.getElementById("add-host");
|
|
450
453
|
var addTargetInput = document.getElementById("add-target");
|
|
451
454
|
var addInsecureInput = document.getElementById("add-insecure");
|
|
455
|
+
var addCorsInput = document.getElementById("add-cors");
|
|
452
456
|
var addSubmitBtn = document.getElementById("add-submit");
|
|
453
457
|
var addErrorEl = document.getElementById("add-error");
|
|
454
458
|
|
|
@@ -757,9 +761,10 @@ const clientJs = `
|
|
|
757
761
|
}
|
|
758
762
|
if (hostVal.indexOf(".") === -1) hostVal = hostVal + ".localhost";
|
|
759
763
|
var insecureVal = !!addInsecureInput.checked;
|
|
764
|
+
var corsVal = !!addCorsInput.checked;
|
|
760
765
|
addSubmitBtn.disabled = true;
|
|
761
766
|
addSubmitBtn.classList.add("busy");
|
|
762
|
-
apiFetch("/api/hosts", { method: "POST", body: JSON.stringify({ host: hostVal, target: targetVal, insecure: insecureVal }) })
|
|
767
|
+
apiFetch("/api/hosts", { method: "POST", body: JSON.stringify({ host: hostVal, target: targetVal, insecure: insecureVal, cors: corsVal }) })
|
|
763
768
|
.then(function () {
|
|
764
769
|
addForm.reset();
|
|
765
770
|
showToast("Added " + hostVal, "success");
|
|
@@ -822,17 +827,24 @@ const clientJs = `
|
|
|
822
827
|
tr.appendChild(makeCell("\\u2192", "arrow"));
|
|
823
828
|
tr.appendChild(makeCell(row.target, "mono"));
|
|
824
829
|
|
|
825
|
-
var
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
830
|
+
var optionsTd = document.createElement("td");
|
|
831
|
+
var flags = [];
|
|
832
|
+
if (row.insecure) flags.push(["insecure", "Upstream TLS certificate is not verified"]);
|
|
833
|
+
if (row.cors) flags.push(["cors", "Origin/Referer rewritten to the target; any origin may call this host"]);
|
|
834
|
+
if (flags.length) {
|
|
835
|
+
optionsTd.className = "badges";
|
|
836
|
+
flags.forEach(function (flag) {
|
|
837
|
+
var badge = document.createElement("span");
|
|
838
|
+
badge.className = "badge" + (flag[0] === "cors" ? " badge-cors" : "");
|
|
839
|
+
badge.textContent = flag[0];
|
|
840
|
+
badge.title = flag[1];
|
|
841
|
+
optionsTd.appendChild(badge);
|
|
842
|
+
});
|
|
831
843
|
} else {
|
|
832
|
-
|
|
833
|
-
|
|
844
|
+
optionsTd.className = "dim";
|
|
845
|
+
optionsTd.textContent = "\\u2014";
|
|
834
846
|
}
|
|
835
|
-
tr.appendChild(
|
|
847
|
+
tr.appendChild(optionsTd);
|
|
836
848
|
|
|
837
849
|
var statusTd = document.createElement("td");
|
|
838
850
|
statusTd.appendChild(statusPill(row));
|
|
@@ -899,6 +911,14 @@ const clientJs = `
|
|
|
899
911
|
insecureLabel.appendChild(insecureInput);
|
|
900
912
|
insecureLabel.appendChild(document.createTextNode("insecure"));
|
|
901
913
|
insecureTd.appendChild(insecureLabel);
|
|
914
|
+
var corsLabel = document.createElement("label");
|
|
915
|
+
corsLabel.className = "inline-check";
|
|
916
|
+
var corsInput = document.createElement("input");
|
|
917
|
+
corsInput.type = "checkbox";
|
|
918
|
+
corsInput.checked = !!row.cors;
|
|
919
|
+
corsLabel.appendChild(corsInput);
|
|
920
|
+
corsLabel.appendChild(document.createTextNode("cors"));
|
|
921
|
+
insecureTd.appendChild(corsLabel);
|
|
902
922
|
tr.appendChild(insecureTd);
|
|
903
923
|
|
|
904
924
|
tr.appendChild(makeCell("\\u2014", "dim"));
|
|
@@ -927,7 +947,7 @@ const clientJs = `
|
|
|
927
947
|
cancelBtn.disabled = true;
|
|
928
948
|
apiFetch("/api/hosts/" + encodeURIComponent(row.host), {
|
|
929
949
|
method: "PUT",
|
|
930
|
-
body: JSON.stringify({ target: newTarget, insecure: !!insecureInput.checked })
|
|
950
|
+
body: JSON.stringify({ target: newTarget, insecure: !!insecureInput.checked, cors: !!corsInput.checked })
|
|
931
951
|
})
|
|
932
952
|
.then(function () {
|
|
933
953
|
editingHost = null;
|
|
@@ -1134,6 +1154,10 @@ function renderPage(nonce, token) {
|
|
|
1134
1154
|
<input type="checkbox" id="add-insecure" name="insecure">
|
|
1135
1155
|
<label for="add-insecure">Insecure TLS</label>
|
|
1136
1156
|
</div>
|
|
1157
|
+
<div class="field field-checkbox" title="Send Origin/Referer as the target's own and let any origin call this host">
|
|
1158
|
+
<input type="checkbox" id="add-cors" name="cors">
|
|
1159
|
+
<label for="add-cors">Bypass CORS</label>
|
|
1160
|
+
</div>
|
|
1137
1161
|
<button type="submit" id="add-submit" class="btn btn-primary">Add proxy</button>
|
|
1138
1162
|
</div>
|
|
1139
1163
|
<div id="add-error" class="field-error" role="alert" hidden></div>
|
|
@@ -1153,7 +1177,7 @@ function renderPage(nonce, token) {
|
|
|
1153
1177
|
<th scope="col">Host</th>
|
|
1154
1178
|
<th scope="col" class="visually-hidden">Flow</th>
|
|
1155
1179
|
<th scope="col">Target</th>
|
|
1156
|
-
<th scope="col">
|
|
1180
|
+
<th scope="col">Options</th>
|
|
1157
1181
|
<th scope="col">Status</th>
|
|
1158
1182
|
<th scope="col">Hits</th>
|
|
1159
1183
|
<th scope="col">Errors</th>
|
package/dist/index.js
CHANGED
|
@@ -30,6 +30,8 @@ const destinationOptions = (command) => command
|
|
|
30
30
|
.option("-p, --port <port>", "Local port to forward to (shorthand for --target http://localhost:<port>)")
|
|
31
31
|
.option("-t, --target <url>", "Any upstream: 3000, 127.0.0.1:8080, http://192.168.1.5:8080, https://google.com")
|
|
32
32
|
.option("-k, --insecure", "Don't verify the TLS certificate of an https target")
|
|
33
|
+
.option("--cors", "Send Origin/Referer as the target's own and let any origin call this domain")
|
|
34
|
+
.option("--no-cors", "Turn --cors off again (update)")
|
|
33
35
|
.option("--no-start", "Only save the mapping; don't start the proxy");
|
|
34
36
|
program
|
|
35
37
|
.name("locadot")
|
|
@@ -64,9 +66,12 @@ program
|
|
|
64
66
|
.command("open [host]")
|
|
65
67
|
.description("Open a domain, or the dashboard when no host is given, in the browser")
|
|
66
68
|
.action(run((host) => commands_1.default.open(host)));
|
|
67
|
-
|
|
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)));
|
|
68
73
|
program.command("stop").description("Stop the central proxy (keeps hosts and logs)").action(run(() => commands_1.default.stop()));
|
|
69
|
-
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)));
|
|
70
75
|
program.command("kill").description("Stop the proxy, remove all hosts and clear logs").action(run(() => commands_1.default.kill()));
|
|
71
76
|
program.command("trust").description("Install the locadot CA in the system trust store (sudo)").action(run(() => commands_1.default.trust()));
|
|
72
77
|
program.command("untrust").description("Remove the locadot CA from the system trust store (sudo)").action(run(() => commands_1.default.untrust()));
|
package/dist/lib/commands.js
CHANGED
|
@@ -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;
|
|
@@ -90,7 +111,7 @@ const warnIfDown = async (entry) => {
|
|
|
90
111
|
};
|
|
91
112
|
class Commands {
|
|
92
113
|
static async add(options) {
|
|
93
|
-
const { host, entry } = await hosts_1.default.add({ host: options.host, target: resolveTarget(options), insecure: options.insecure });
|
|
114
|
+
const { host, entry } = await hosts_1.default.add({ host: options.host, target: resolveTarget(options), insecure: options.insecure, cors: options.cors });
|
|
94
115
|
const { target } = entry;
|
|
95
116
|
await ensureRunning(options);
|
|
96
117
|
print(`✅ ${(0, exports.urlFor)(host)} → ${target}`);
|
|
@@ -100,7 +121,7 @@ class Commands {
|
|
|
100
121
|
await warnIfDown(entry);
|
|
101
122
|
}
|
|
102
123
|
static async update(options) {
|
|
103
|
-
const { host, entry } = await hosts_1.default.update({ host: options.host, target: resolveTarget(options), insecure: options.insecure });
|
|
124
|
+
const { host, entry } = await hosts_1.default.update({ host: options.host, target: resolveTarget(options), insecure: options.insecure, cors: options.cors });
|
|
104
125
|
const { target } = entry;
|
|
105
126
|
await ensureRunning(options);
|
|
106
127
|
print(`✅ Updated ${(0, exports.urlFor)(host)} → ${target}`);
|
|
@@ -122,7 +143,7 @@ class Commands {
|
|
|
122
143
|
}
|
|
123
144
|
const width = Math.max(...hosts.map(([host]) => (0, exports.urlFor)(host).length));
|
|
124
145
|
for (const [host, entry] of hosts) {
|
|
125
|
-
print(`${(0, exports.urlFor)(host).padEnd(width)} → ${entry.target}${entry.insecure ? " (insecure)" : ""}`);
|
|
146
|
+
print(`${(0, exports.urlFor)(host).padEnd(width)} → ${entry.target}${entry.insecure ? " (insecure)" : ""}${entry.cors ? " (cors)" : ""}`);
|
|
126
147
|
}
|
|
127
148
|
print(`☑️ Total: ${hosts.length}.`);
|
|
128
149
|
}
|
|
@@ -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
|
|
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
|
|
218
|
-
const
|
|
219
|
-
|
|
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/hosts.js
CHANGED
|
@@ -69,7 +69,7 @@ class HostOps {
|
|
|
69
69
|
if (existing) {
|
|
70
70
|
throw new ConflictError(`❌ ${host} is already mapped to ${existing.target}. Use \`locadot update --host ${host} ...\` instead.`);
|
|
71
71
|
}
|
|
72
|
-
registry.hosts[host] = { target, insecure: input.insecure || undefined, createdAt: now, updatedAt: now };
|
|
72
|
+
registry.hosts[host] = { target, insecure: input.insecure || undefined, cors: input.cors || undefined, createdAt: now, updatedAt: now };
|
|
73
73
|
return registry.hosts[host];
|
|
74
74
|
});
|
|
75
75
|
return { host, entry };
|
|
@@ -82,7 +82,8 @@ class HostOps {
|
|
|
82
82
|
if (!existing)
|
|
83
83
|
throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
|
|
84
84
|
const insecure = input.insecure ?? existing.insecure;
|
|
85
|
-
|
|
85
|
+
const cors = input.cors ?? existing.cors;
|
|
86
|
+
registry.hosts[host] = { ...existing, target, insecure: insecure || undefined, cors: cors || undefined, updatedAt: new Date().toISOString() };
|
|
86
87
|
return registry.hosts[host];
|
|
87
88
|
});
|
|
88
89
|
return { host, entry };
|
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.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,8 +36,161 @@ 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 || "" },
|
|
39
|
+
headers: { "X-Original-Host": req.headers.host || "", ...(entry.cors ? { ...sameOriginHeaders(req, entry, lookup), ...DECODABLE } : {}) },
|
|
38
40
|
});
|
|
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
|
+
};
|
|
66
|
+
const headers = {};
|
|
67
|
+
if (req.headers.origin)
|
|
68
|
+
headers.Origin = upstreamOrigin(req.headers.origin);
|
|
69
|
+
if (req.headers.referer) {
|
|
70
|
+
try {
|
|
71
|
+
const referer = new URL(req.headers.referer);
|
|
72
|
+
headers.Referer = upstreamOrigin(referer.href) + referer.pathname + referer.search;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
headers.Referer = new URL(entry.target).origin + "/";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return headers;
|
|
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;
|
|
165
|
+
const isPreflight = (req) => req.method === "OPTIONS" && !!req.headers.origin && !!req.headers["access-control-request-method"];
|
|
166
|
+
/** Headers that let the calling page read the response, credentials included. */
|
|
167
|
+
const allowOrigin = (req) => req.headers.origin
|
|
168
|
+
? { "Access-Control-Allow-Origin": req.headers.origin, "Access-Control-Allow-Credentials": "true", Vary: "Origin" }
|
|
169
|
+
: { "Access-Control-Allow-Origin": "*" };
|
|
170
|
+
/**
|
|
171
|
+
* Rewrites an upstream response for a --cors host: our CORS headers replace the upstream's,
|
|
172
|
+
* and cookies become SameSite=None so a page on another origin can send them back.
|
|
173
|
+
*/
|
|
174
|
+
const applyCors = (req, headers) => {
|
|
175
|
+
for (const name of Object.keys(headers)) {
|
|
176
|
+
if (name.startsWith("access-control-"))
|
|
177
|
+
delete headers[name];
|
|
178
|
+
}
|
|
179
|
+
const exposed = Object.keys(headers).filter((name) => name !== "set-cookie" && name !== "vary");
|
|
180
|
+
headers["access-control-allow-origin"] = req.headers.origin || "*";
|
|
181
|
+
if (req.headers.origin) {
|
|
182
|
+
headers["access-control-allow-credentials"] = "true";
|
|
183
|
+
const vary = String(headers.vary || "");
|
|
184
|
+
if (!/(^|,)\s*(origin|\*)\s*(,|$)/i.test(vary))
|
|
185
|
+
headers.vary = vary ? `${vary}, Origin` : "Origin";
|
|
186
|
+
}
|
|
187
|
+
if (exposed.length)
|
|
188
|
+
headers["access-control-expose-headers"] = exposed.join(", ");
|
|
189
|
+
if (isTls(req) && headers["set-cookie"]) {
|
|
190
|
+
headers["set-cookie"] = headers["set-cookie"].map((cookie) => cookie.replace(/;\s*samesite=[^;]*/gi, "").replace(/;\s*secure\b(?!=)/gi, "") + "; SameSite=None; Secure");
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
exports.applyCors = applyCors;
|
|
39
194
|
const record = (stats, host, status, ms, error) => {
|
|
40
195
|
const current = stats.get(host) || { hits: 0, errors: 0 };
|
|
41
196
|
current.hits += 1;
|
|
@@ -60,18 +215,51 @@ class HttpModule {
|
|
|
60
215
|
res.end((0, template_1.proxyNotFound)(host, dashboardUrl(req)));
|
|
61
216
|
return;
|
|
62
217
|
}
|
|
218
|
+
// Answered here: upstreams often reject OPTIONS or don't send the headers the browser wants.
|
|
219
|
+
if (entry.cors && isPreflight(req)) {
|
|
220
|
+
const requested = req.headers["access-control-request-headers"];
|
|
221
|
+
res.writeHead(204, {
|
|
222
|
+
...allowOrigin(req),
|
|
223
|
+
"Access-Control-Allow-Methods": String(req.headers["access-control-request-method"]),
|
|
224
|
+
...(requested ? { "Access-Control-Allow-Headers": String(requested) } : {}),
|
|
225
|
+
...(req.headers["access-control-request-private-network"] ? { "Access-Control-Allow-Private-Network": "true" } : {}),
|
|
226
|
+
"Access-Control-Max-Age": "600",
|
|
227
|
+
"Content-Length": "0",
|
|
228
|
+
});
|
|
229
|
+
res.end();
|
|
230
|
+
return;
|
|
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
|
+
}
|
|
63
243
|
const started = Date.now();
|
|
64
244
|
res.once("finish", () => {
|
|
65
245
|
record(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500);
|
|
66
246
|
});
|
|
67
|
-
|
|
68
|
-
|
|
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}`);
|
|
69
256
|
if (res.headersSent) {
|
|
70
257
|
res.destroy();
|
|
71
258
|
return;
|
|
72
259
|
}
|
|
73
|
-
|
|
74
|
-
res.
|
|
260
|
+
// With --cors the page should see a 502, not a CORS error.
|
|
261
|
+
res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", ...(entry.cors ? allowOrigin(req) : {}) });
|
|
262
|
+
res.end((0, template_1.upstreamDown)(host, upstream, err?.code || err?.message || "error", dashboardUrl(req)));
|
|
75
263
|
});
|
|
76
264
|
}
|
|
77
265
|
catch (error) {
|
|
@@ -90,9 +278,19 @@ class HttpModule {
|
|
|
90
278
|
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
|
|
91
279
|
return;
|
|
92
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
|
+
}
|
|
93
286
|
logger_1.default.debug(`WebSocket upgrade for ${host}`);
|
|
94
|
-
|
|
95
|
-
|
|
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}`);
|
|
96
294
|
socket.destroy();
|
|
97
295
|
});
|
|
98
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: {
|
|
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
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
37
|
};
|
|
@@ -12,8 +45,9 @@ const http_proxy_1 = __importDefault(require("http-proxy"));
|
|
|
12
45
|
const constants_1 = __importDefault(require("./constants"));
|
|
13
46
|
const registry_1 = __importDefault(require("./lib/registry"));
|
|
14
47
|
const locadot_file_1 = __importDefault(require("./lib/locadot-file"));
|
|
15
|
-
const http_2 =
|
|
48
|
+
const http_2 = __importStar(require("./lib/http"));
|
|
16
49
|
const localhost_1 = __importDefault(require("./lib/localhost"));
|
|
50
|
+
const passthrough_1 = require("./lib/passthrough");
|
|
17
51
|
const file_1 = __importDefault(require("./utils/file"));
|
|
18
52
|
const logger_1 = __importDefault(require("./utils/logger"));
|
|
19
53
|
const certs_1 = require("./utils/certs");
|
|
@@ -68,7 +102,7 @@ async function startCentralProxy() {
|
|
|
68
102
|
// Hop-by-hop headers describe the upstream connection, not ours. Apache sends
|
|
69
103
|
// `Connection: Upgrade, close` + `Upgrade: h2`, which made us close the browser's
|
|
70
104
|
// socket after every response (ERR_TOO_MANY_RETRIES on asset-heavy pages).
|
|
71
|
-
proxy.on("proxyRes", (proxyRes) => {
|
|
105
|
+
proxy.on("proxyRes", (proxyRes, req, res) => {
|
|
72
106
|
if (proxyRes.statusCode === 101)
|
|
73
107
|
return;
|
|
74
108
|
const listed = String(proxyRes.headers.connection || "")
|
|
@@ -78,6 +112,17 @@ async function startCentralProxy() {
|
|
|
78
112
|
for (const name of [...listed, "connection", "keep-alive", "upgrade", "proxy-connection"]) {
|
|
79
113
|
delete proxyRes.headers[name];
|
|
80
114
|
}
|
|
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);
|
|
81
126
|
});
|
|
82
127
|
const info = {
|
|
83
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
|
+
"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",
|