locadot 2.1.0-beta.4 → 2.2.0
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 +21 -1
- package/README.md +38 -2
- package/dist/cli/commands/remote.js +21 -1
- package/dist/dashboard/api.js +13 -0
- package/dist/dashboard/index.js +1 -1
- package/dist/dashboard/login.js +17 -6
- package/dist/dashboard/page.js +38 -0
- package/dist/index.js +4 -0
- package/dist/lib/ui-auth.js +5 -0
- package/dist/proxy/hub.js +11 -3
- package/dist/proxy/request.js +3 -1
- package/dist/proxy/router.js +11 -5
- package/dist/server/index.js +1 -0
- package/dist/utils/startup.js +124 -34
- package/dist/utils/trust.js +52 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 2.2.0 (2026-09-29)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Share the dashboard in a browser: `locadot hub:panel on` (or **Share this dashboard** under Remote access) serves the
|
|
7
|
+
control panel at the hub's public URL, behind the dashboard password. It can't be turned on without a password, and
|
|
8
|
+
removing the password turns it off. The visitor's browser never gets the API token; sign-in is rate limited per visitor IP.
|
|
9
|
+
`GET /api/hub` reports `panel`; `PUT /api/hub/panel` turns it on or off.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Start at boot on macOS: `sudo locadot startup:enable` wrote a LaunchAgent into root's home, which never runs; it now
|
|
13
|
+
installs a LaunchDaemon that starts at boot, before login, as the sudo user with their state dir. Without sudo, the
|
|
14
|
+
LaunchAgent (starts at login) now clears a disabled override left by an earlier `startup:disable`, carries port
|
|
15
|
+
overrides, escapes paths, and survives a dashboard restart. Enabling or disabling no longer stops a running proxy.
|
|
16
|
+
- `locadot trust` on macOS failed with "The authorization was denied since no user interaction was possible". It ran
|
|
17
|
+
`security` through an osascript admin prompt, which can't show the trust-settings dialog macOS requires; it now runs
|
|
18
|
+
`sudo security` in the terminal (or `security` directly from the dashboard). `sudo locadot trust` is refused on macOS,
|
|
19
|
+
since it trusted root's CA rather than the one the proxy serves.
|
|
20
|
+
- Through a tunnel or remote access, a stopped or unmapped upstream is now a 503 with locadot's "upstream unreachable"
|
|
21
|
+
page. With 502, Cloudflare replaced the page with its own "origin bad gateway" error, which hid the real cause.
|
|
22
|
+
|
|
23
|
+
## 2.1.0 (2026-09-27)
|
|
4
24
|
|
|
5
25
|
### Added
|
|
6
26
|
- Dashboard pages: every sidebar item now opens its own page (`#/overview`, `#/hosts`, `#/sharing`, `#/remote`, `#/machines`,
|
package/README.md
CHANGED
|
@@ -20,6 +20,11 @@ HTTPS custom domains for local development. Point `https://app.localhost` at you
|
|
|
20
20
|
(`--target https://google.com`). Redirects and cookies are rewritten so the browser stays on the `.localhost` name.
|
|
21
21
|
- 🌍 **Share on a public URL.** `locadot tunnel --host app.localhost` (or **Share** in the dashboard) puts a mapping on
|
|
22
22
|
`https://<random>.trycloudflare.com` through a Cloudflare quick tunnel. No account needed.
|
|
23
|
+
- 🔗 **Remote access, locadot to locadot.** Publish your whole locadot on a Cloudflare hostname and let other machines pair
|
|
24
|
+
with it as viewer, editor or admin. Your mappings show up in their locadot. Admins can also open your dashboard and any
|
|
25
|
+
port on your localhost.
|
|
26
|
+
- 🖥️ **Share the dashboard in a browser.** `locadot hub:panel on` serves the control panel at the hub's public URL, behind the
|
|
27
|
+
dashboard password, so you can manage this machine from anywhere without installing locadot there.
|
|
23
28
|
- 🧩 **`--cors` for sites that call other domains.** Preflights are answered locally and cross-origin calls are routed
|
|
24
29
|
through the page's own origin, so a production frontend works against its real APIs from `.localhost`.
|
|
25
30
|
- 📋 **Control panel at `https://localhost`.** Add, edit, filter and remove mappings, see health and traffic, share them,
|
|
@@ -110,8 +115,10 @@ short-lived pairing string, and the sender's mappings then show up in the receiv
|
|
|
110
115
|
| --- | --- |
|
|
111
116
|
| `locadot hub:setup --domain dev.example.com` | Sender: publish through your own named tunnel on your Cloudflare domain (runs `cloudflared` login, creates the tunnel and routes DNS). The address stays the same. |
|
|
112
117
|
| `locadot hub:quick` | Sender: publish on a trycloudflare URL. No account is needed, but **the URL changes if the tunnel restarts**, so receivers have to run `remote:url`. |
|
|
118
|
+
| `locadot hub:manual --url <url>` | Sender: publish behind a URL something else already forwards to the proxy's HTTP port (ngrok, a reverse proxy). |
|
|
113
119
|
| `locadot hub` / `hub:off` | Show the hub status and URL, or stop it. |
|
|
114
120
|
| `locadot hub:localhost <on\|off>` | Sender: allow or block admin peers from opening this machine's dashboard and any port on its localhost. |
|
|
121
|
+
| `locadot hub:panel <on\|off>` | Sender: serve this dashboard to any browser at the hub's public URL, behind the [dashboard password](#dashboard-password). Off by default. See [Sharing the dashboard](#sharing-the-dashboard-in-a-browser). |
|
|
115
122
|
| `locadot share --role viewer\|editor\|admin [--hosts a.localhost,b.localhost]` | Sender: print a pairing string. It works once and expires after 5 minutes. `--hosts` limits a viewer to those mappings. |
|
|
116
123
|
| `locadot peers` / `peers:role <id> <role>` / `peers:revoke <id>` | Sender: list the connected machines, change a role, or cut one off. |
|
|
117
124
|
| `locadot connect "<pairing string>" [--name alice] [--domain dev]` | Receiver: connect and import the sender's mappings. If a name clashes, `app.localhost` becomes `app.alice.localhost`. Admins get a local domain that opens the sender's dashboard and localhost (see below); `--domain` picks it, otherwise it's a random `adjective-noun`. |
|
|
@@ -137,6 +144,31 @@ headers work as they do locally, and origins in the sender's pages are rewritten
|
|
|
137
144
|
An admin's `<port>.<domain>.localhost` gets the same treatment when a `--cors` mapping points at that port.
|
|
138
145
|
`remote:sync` picks up a sender turning `--cors` on or off.
|
|
139
146
|
|
|
147
|
+
When a target is down, requests through the hub or a public share get a **503** with locadot's "upstream unreachable" page.
|
|
148
|
+
Cloudflare replaces an origin's 502 with its own generic "bad gateway" error, which would hide the real cause.
|
|
149
|
+
|
|
150
|
+
#### Sharing the dashboard in a browser
|
|
151
|
+
|
|
152
|
+
Remote access above needs locadot on both machines. To manage this machine from a browser anywhere (a laptop, a phone),
|
|
153
|
+
share the dashboard itself on the hub's public URL:
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
locadot ui:password # required: visitors sign in with it
|
|
157
|
+
locadot hub:panel on # or the "Share this dashboard" switch under Remote access
|
|
158
|
+
locadot hub # shows the URL, e.g. https://dev.example.com
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Opening the hub URL in a browser then shows the sign-in page, and after that the full dashboard. Paired machines keep
|
|
162
|
+
working on the same URL, since they authenticate with their own tokens. Some details:
|
|
163
|
+
|
|
164
|
+
- It can't be turned on without a dashboard password, and removing the password turns sharing off.
|
|
165
|
+
- The session cookie is `HttpOnly`, `SameSite=Strict` and `Secure` on https. Wrong passwords are rate limited per visitor IP.
|
|
166
|
+
- The browser never gets this machine's API token. Changes are only accepted from the page's own origin.
|
|
167
|
+
- Only the dashboard is shared. Your mappings stay reachable only by paired machines, and links in the dashboard still point
|
|
168
|
+
at `*.localhost` names, which open on the visitor's own machine.
|
|
169
|
+
- Anyone with the password controls this locadot, including remote access and stopping the proxy. Use a strong one, or
|
|
170
|
+
prefer a named tunnel (`hub:setup`) behind Cloudflare Access if you need more.
|
|
171
|
+
|
|
140
172
|
### Certificates
|
|
141
173
|
|
|
142
174
|
| Command | What it does |
|
|
@@ -152,7 +184,7 @@ An admin's `<port>.<domain>.localhost` gets the same treatment when a `--cors` m
|
|
|
152
184
|
| `locadot clear:logs` | Clear the log file. |
|
|
153
185
|
| `locadot path` | Show every file locadot uses. `path:logs` and `path:hosts` print a single path. |
|
|
154
186
|
| `locadot token` | Print the dashboard API token, for scripts and AI agents. |
|
|
155
|
-
| `locadot startup:enable` / `startup:disable` / `startup:status` | Start the proxy at boot (Linux cron
|
|
187
|
+
| `locadot startup:enable` / `startup:disable` / `startup:status` | Start the proxy at boot (Linux cron; macOS LaunchDaemon with `sudo`, running as you) or logon (macOS LaunchAgent without `sudo`; a hidden script in the Windows Startup folder, no admin needed). |
|
|
156
188
|
|
|
157
189
|
---
|
|
158
190
|
|
|
@@ -184,6 +216,8 @@ hosts become cards:
|
|
|
184
216
|
Trust and start-at-boot may need elevation. locadot asks the OS for it (polkit on Linux, the password dialog on macOS, UAC on Windows).
|
|
185
217
|
If that isn't possible, for example on a headless Linux box, the panel shows the exact command to run instead: `sudo locadot trust`,
|
|
186
218
|
`locadot startup:enable`, and so on.
|
|
219
|
+
On macOS, run `locadot trust` without `sudo`: it asks for your password itself, and macOS then confirms the trust change in a
|
|
220
|
+
dialog, which only works in Terminal on the Mac, not over SSH. Under `sudo` it would trust root's own CA instead of yours.
|
|
187
221
|
|
|
188
222
|
### Dashboard password
|
|
189
223
|
|
|
@@ -196,6 +230,7 @@ echo "$PW" | locadot ui:password --stdin
|
|
|
196
230
|
```
|
|
197
231
|
|
|
198
232
|
You can also set, change or remove it under **Settings → Dashboard password** (changing or removing it there needs the current one).
|
|
233
|
+
It's also what [sharing the dashboard in a browser](#sharing-the-dashboard-in-a-browser) signs visitors in with.
|
|
199
234
|
Only the dashboard is protected. Your mapped hosts work as before, and so do scripts and the CLI that send `X-Locadot-Token`.
|
|
200
235
|
Sessions last 7 days, and changing the password signs everyone out. If you forget it, run `locadot ui:password` again in a terminal.
|
|
201
236
|
|
|
@@ -221,8 +256,9 @@ each time the proxy starts.
|
|
|
221
256
|
| `PUT /api/settings` | `{ "httpPort": 8080, "httpsPort": 8443 }` | Save new ports to the config file (400 if invalid or equal). Takes effect after a restart. |
|
|
222
257
|
| `POST /api/proxy/restart` | | Restart the proxy (e.g. to pick up saved ports). |
|
|
223
258
|
| `PUT /api/settings/ui-password` | `{ "password": "…", "current": "…" }` or `{ "enabled": false, "current": "…" }` | Set, change or remove the dashboard password (`current` is required once one is set). |
|
|
224
|
-
| `GET /api/hub` | | Hub status/config, plus `localhost` (sender: is localhost access on). |
|
|
259
|
+
| `GET /api/hub` | | Hub status/config, plus `localhost` (sender: is localhost access on) and `panel` (is the dashboard shared). |
|
|
225
260
|
| `PUT /api/hub/localhost` | `{ "enabled": true }` | Sender: turn localhost access for admin peers on/off (400 if the hub isn't configured). |
|
|
261
|
+
| `PUT /api/hub/panel` | `{ "enabled": true }` | Sender: share the dashboard at the hub's public URL on/off (400 if the hub isn't configured, or no dashboard password is set). |
|
|
226
262
|
| `GET /api/remotes` | | Every remote (never the token), including `domain` and `localhost`. |
|
|
227
263
|
| `POST /api/remotes` | `{ "string": "<pairing string>", "name": "alice", "domain": "dev" }` | Receiver: connect using a pairing string; `domain` is optional (admin only, random if omitted). |
|
|
228
264
|
| `PUT /api/remotes/:name` | `{ "domain": "dev" }` or `{ "domain": "random" }` or `{ "domain": null }` | Receiver: set, randomize or remove a remote's local domain (400 invalid, 409 clash, 404 unknown). |
|
|
@@ -9,6 +9,7 @@ exports.hubQuick = hubQuick;
|
|
|
9
9
|
exports.hubManual = hubManual;
|
|
10
10
|
exports.hubOff = hubOff;
|
|
11
11
|
exports.hubLocalhost = hubLocalhost;
|
|
12
|
+
exports.hubPanel = hubPanel;
|
|
12
13
|
exports.share = share;
|
|
13
14
|
exports.peers = peers;
|
|
14
15
|
exports.peersRole = peersRole;
|
|
@@ -102,7 +103,7 @@ const getHub = async () => {
|
|
|
102
103
|
};
|
|
103
104
|
const HUB_EMOJI = { off: "🔒", starting: "⏳", up: "🌍", error: "❌", login: "🔑" };
|
|
104
105
|
async function hub(options) {
|
|
105
|
-
const { hub } = await getHub();
|
|
106
|
+
const { hub, panel } = await getHub();
|
|
106
107
|
if (options.json) {
|
|
107
108
|
console.log(JSON.stringify(hub, null, 2));
|
|
108
109
|
return;
|
|
@@ -116,6 +117,8 @@ async function hub(options) {
|
|
|
116
117
|
(0, shared_1.print)(` Open to finish login: ${hub.loginUrl}`);
|
|
117
118
|
if (hub.error)
|
|
118
119
|
(0, shared_1.print)(` ${hub.error}`);
|
|
120
|
+
if (panel)
|
|
121
|
+
(0, shared_1.print)(` Dashboard shared${hub.url ? ` at ${hub.url}` : ""} (password sign-in). Turn off: locadot hub:panel off`);
|
|
119
122
|
}
|
|
120
123
|
async function hubSetup(options) {
|
|
121
124
|
if (!options.domain)
|
|
@@ -172,6 +175,23 @@ async function hubLocalhost(value) {
|
|
|
172
175
|
throw new localhost_1.InputError(`❌ Couldn't update (${status})${json?.error ? `: ${json.error}` : ""}.`);
|
|
173
176
|
(0, shared_1.print)(json.localhost ? "🖥️ Admin peers can now reach this machine's localhost." : "🔒 Admin peers can no longer reach this machine's localhost.");
|
|
174
177
|
}
|
|
178
|
+
async function hubPanel(value) {
|
|
179
|
+
if (value !== "on" && value !== "off")
|
|
180
|
+
throw new localhost_1.InputError('❌ Use "on" or "off".');
|
|
181
|
+
requireRunning();
|
|
182
|
+
const { status, json } = await dashboardRequest("PUT", "/api/hub/panel", {
|
|
183
|
+
headers: { "X-Locadot-Token": apiToken() },
|
|
184
|
+
body: { enabled: value === "on" },
|
|
185
|
+
});
|
|
186
|
+
if (status === 400)
|
|
187
|
+
throw new localhost_1.InputError(`❌ ${json?.error || "Set up the hub first: locadot hub:setup / hub:quick / hub:manual."}${json?.hint ? ` ${json.hint}` : ""}`);
|
|
188
|
+
if (status !== 200)
|
|
189
|
+
throw new localhost_1.InputError(`❌ Couldn't update (${status})${json?.error ? `: ${json.error}` : ""}.`);
|
|
190
|
+
if (!json.panel)
|
|
191
|
+
return (0, shared_1.print)("🔒 The dashboard is no longer shared.");
|
|
192
|
+
const { hub } = await getHub();
|
|
193
|
+
(0, shared_1.print)(`🌍 The dashboard is shared${hub.url ? ` at ${hub.url}` : ""}. Visitors sign in with the dashboard password.`);
|
|
194
|
+
}
|
|
175
195
|
async function share(options) {
|
|
176
196
|
const role = requireRole(options.role);
|
|
177
197
|
if (options.hosts && role !== "viewer") {
|
package/dist/dashboard/api.js
CHANGED
|
@@ -321,6 +321,7 @@ function buildHubBody(ctx) {
|
|
|
321
321
|
hub: ctx.hub(),
|
|
322
322
|
config: config ?? null,
|
|
323
323
|
localhost: config?.localhost !== false,
|
|
324
|
+
panel: config?.panel === true,
|
|
324
325
|
invites: invites.map(sanitizeInvite),
|
|
325
326
|
peers: peers.map(sanitizePeer),
|
|
326
327
|
};
|
|
@@ -355,6 +356,7 @@ async function route(req, url, ctx) {
|
|
|
355
356
|
(["/api/startup", "/api/trust", "/api/logs/clear", "/api/proxy/stop", "/api/proxy/restart", "/api/cloudflared/install", "/api/hub", "/api/invites", "/api/remotes"].includes(path) &&
|
|
356
357
|
method === "POST") ||
|
|
357
358
|
(path === "/api/hub/localhost" && method === "PUT") ||
|
|
359
|
+
(path === "/api/hub/panel" && method === "PUT") ||
|
|
358
360
|
(path === "/api/settings" && method === "PUT") ||
|
|
359
361
|
(inviteMatch && method === "DELETE") ||
|
|
360
362
|
(peerMatch && (method === "PUT" || method === "DELETE")) ||
|
|
@@ -467,6 +469,17 @@ async function route(req, url, ctx) {
|
|
|
467
469
|
ctx.reloadHub();
|
|
468
470
|
return { status: 200, body: { localhost: enabled } };
|
|
469
471
|
}
|
|
472
|
+
if (path === "/api/hub/panel" && method === "PUT") {
|
|
473
|
+
const enabled = requiredBool(body.enabled, "enabled");
|
|
474
|
+
const config = hub_config_1.default.read();
|
|
475
|
+
if (!config)
|
|
476
|
+
throw new ApiError(400, "The hub isn't configured yet. Set it up first: locadot hub:setup / hub:quick / hub:manual.");
|
|
477
|
+
if (enabled && !ui_auth_1.default.enabled())
|
|
478
|
+
throw new ApiError(400, "Set a dashboard password first.", "Settings → Dashboard password, or `locadot ui:password`.");
|
|
479
|
+
hub_config_1.default.write({ ...config, panel: enabled });
|
|
480
|
+
ctx.reloadHub();
|
|
481
|
+
return { status: 200, body: { panel: enabled } };
|
|
482
|
+
}
|
|
470
483
|
if (path === "/api/invites" && method === "POST") {
|
|
471
484
|
if (!isRole(body.role))
|
|
472
485
|
throw new ApiError(400, "`role` must be viewer, editor or admin.");
|
package/dist/dashboard/index.js
CHANGED
|
@@ -177,7 +177,7 @@ function handleDashboardRequest(req, res, ctx) {
|
|
|
177
177
|
async function uiPassword(req, res, ctx) {
|
|
178
178
|
(0, api_1.assertTrusted)(req, ctx);
|
|
179
179
|
const body = await (0, api_1.readJson)(req);
|
|
180
|
-
const ip = req
|
|
180
|
+
const ip = (0, login_1.loginIp)(req);
|
|
181
181
|
if (ui_auth_1.default.enabled()) {
|
|
182
182
|
if (ui_auth_1.default.limited(ip))
|
|
183
183
|
throw new api_1.ApiError(429, "Too many wrong passwords. Wait a few minutes and try again.");
|
package/dist/dashboard/login.js
CHANGED
|
@@ -36,13 +36,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.sessionCookie = exports.signedIn = void 0;
|
|
39
|
+
exports.sessionCookie = exports.signedIn = exports.loginIp = void 0;
|
|
40
40
|
exports.gate = gate;
|
|
41
41
|
const crypto_1 = __importDefault(require("crypto"));
|
|
42
42
|
const logger_1 = __importDefault(require("../utils/logger"));
|
|
43
43
|
const escape_1 = require("./escape");
|
|
44
44
|
const api_1 = require("./api");
|
|
45
45
|
const ui_auth_1 = __importStar(require("../lib/ui-auth"));
|
|
46
|
+
const request_1 = require("../proxy/request");
|
|
47
|
+
const hub_1 = require("../proxy/hub");
|
|
46
48
|
const MAX_LOGIN_BODY = 4096;
|
|
47
49
|
const loginPage = (nonce, error) => `<!doctype html>
|
|
48
50
|
<html lang="en">
|
|
@@ -90,7 +92,9 @@ const safeEqual = (a, b) => {
|
|
|
90
92
|
const y = Buffer.from(b);
|
|
91
93
|
return x.length === y.length && crypto_1.default.timingSafeEqual(x, y);
|
|
92
94
|
};
|
|
93
|
-
|
|
95
|
+
/** Through the shared dashboard every visitor arrives from cloudflared on loopback, so limit by the visitor's IP. */
|
|
96
|
+
const loginIp = (req) => ((0, request_1.isPanel)(req) ? (0, hub_1.clientIp)(req) : req.socket.remoteAddress || "");
|
|
97
|
+
exports.loginIp = loginIp;
|
|
94
98
|
const sameOrigin = (req) => {
|
|
95
99
|
// Browsers send `Origin: null` on form posts under Referrer-Policy: no-referrer, so trust Sec-Fetch-Site when present.
|
|
96
100
|
// "same-site" is refused: every *.localhost app is same-site with the dashboard.
|
|
@@ -102,7 +106,9 @@ const sameOrigin = (req) => {
|
|
|
102
106
|
return !origin;
|
|
103
107
|
return (0, api_1.isDashboardOrigin)(req, origin);
|
|
104
108
|
};
|
|
105
|
-
|
|
109
|
+
// The shared dashboard's public URL is https even though cloudflared talks plain http to us.
|
|
110
|
+
const secure = (req) => ((0, request_1.isPanel)(req) ? (0, request_1.isTls)(req) : Boolean(req.socket.encrypted));
|
|
111
|
+
const cookie = (req, value, maxAge) => `${ui_auth_1.SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure(req) ? "; Secure" : ""}`;
|
|
106
112
|
const sendLogin = (res, method, nonce, status, error) => {
|
|
107
113
|
// The dashboard's CSP forbids form posts; the login form needs one.
|
|
108
114
|
res.setHeader("Content-Security-Policy", `default-src 'none'; style-src 'nonce-${nonce}'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`);
|
|
@@ -149,8 +155,13 @@ exports.sessionCookie = sessionCookie;
|
|
|
149
155
|
* are let through untouched. Returns true when it answered the request itself.
|
|
150
156
|
*/
|
|
151
157
|
function gate(req, res, url, token, nonce) {
|
|
152
|
-
if (!ui_auth_1.default.enabled())
|
|
153
|
-
|
|
158
|
+
if (!ui_auth_1.default.enabled()) {
|
|
159
|
+
if (!(0, request_1.isPanel)(req))
|
|
160
|
+
return false;
|
|
161
|
+
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
162
|
+
res.end("locadot: set a dashboard password to share the dashboard.\n");
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
154
165
|
const method = req.method || "GET";
|
|
155
166
|
const path = url.pathname;
|
|
156
167
|
if (path === "/login") {
|
|
@@ -162,7 +173,7 @@ function gate(req, res, url, token, nonce) {
|
|
|
162
173
|
}
|
|
163
174
|
if (method !== "POST")
|
|
164
175
|
return false;
|
|
165
|
-
const ip = req
|
|
176
|
+
const ip = (0, exports.loginIp)(req);
|
|
166
177
|
if (!sameOrigin(req)) {
|
|
167
178
|
sendLogin(res, method, nonce, 403, "Cross-site sign-in is not allowed.");
|
|
168
179
|
return true;
|
package/dist/dashboard/page.js
CHANGED
|
@@ -1061,6 +1061,10 @@ const clientJs = `
|
|
|
1061
1061
|
var connectDomainInput = document.getElementById("connect-domain");
|
|
1062
1062
|
var hubLocalhostRow = document.getElementById("hub-localhost-row");
|
|
1063
1063
|
var hubLocalhostSwitch = document.getElementById("hub-localhost");
|
|
1064
|
+
var hubPanelRow = document.getElementById("hub-panel-row");
|
|
1065
|
+
var hubPanelSwitch = document.getElementById("hub-panel");
|
|
1066
|
+
var hubPanelSub = document.getElementById("hub-panel-sub");
|
|
1067
|
+
var hubPanelUrl = "";
|
|
1064
1068
|
var connectSubmitBtn = document.getElementById("connect-submit");
|
|
1065
1069
|
var connectErrorEl = document.getElementById("connect-error");
|
|
1066
1070
|
var remotesEmptyEl = document.getElementById("remotes-empty");
|
|
@@ -1509,6 +1513,9 @@ const clientJs = `
|
|
|
1509
1513
|
|
|
1510
1514
|
hubLocalhostRow.hidden = !data.config;
|
|
1511
1515
|
setSwitch(hubLocalhostSwitch, data.localhost !== false);
|
|
1516
|
+
hubPanelUrl = status === "up" && hub.url ? hub.url : "";
|
|
1517
|
+
hubPanelRow.hidden = !data.config;
|
|
1518
|
+
renderHubPanel(data.panel === true);
|
|
1512
1519
|
|
|
1513
1520
|
hubErrorRow.hidden = !(status === "error" && hub.error);
|
|
1514
1521
|
if (status === "error" && hub.error) hubErrorRow.textContent = hub.error;
|
|
@@ -1874,6 +1881,29 @@ const clientJs = `
|
|
|
1874
1881
|
return row;
|
|
1875
1882
|
}
|
|
1876
1883
|
|
|
1884
|
+
function renderHubPanel(on) {
|
|
1885
|
+
setSwitch(hubPanelSwitch, on);
|
|
1886
|
+
// Turning it on needs a password; turning it off never does.
|
|
1887
|
+
hubPanelSwitch.disabled = !on && !UI_AUTH;
|
|
1888
|
+
if (!UI_AUTH && !on) hubPanelSub.textContent = "Set a dashboard password in Settings first. Visitors sign in with it.";
|
|
1889
|
+
else if (on) hubPanelSub.textContent = "Open " + (hubPanelUrl || "the public URL") + " in any browser and sign in with the dashboard password.";
|
|
1890
|
+
else hubPanelSub.textContent = "Open this dashboard from any browser at the public URL, after signing in with the dashboard password.";
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
hubPanelSwitch.addEventListener("click", function () {
|
|
1894
|
+
var next = hubPanelSwitch.getAttribute("aria-checked") !== "true";
|
|
1895
|
+
hubPanelSwitch.disabled = true;
|
|
1896
|
+
apiFetch("/api/hub/panel", { method: "PUT", body: JSON.stringify({ enabled: next }) })
|
|
1897
|
+
.then(function () {
|
|
1898
|
+
renderHubPanel(next);
|
|
1899
|
+
showToast(next ? "The dashboard is shared at the public URL" : "The dashboard is no longer shared", "success");
|
|
1900
|
+
})
|
|
1901
|
+
.catch(function (err) {
|
|
1902
|
+
apiError(err, "Couldn't change dashboard sharing");
|
|
1903
|
+
renderHubPanel(!next);
|
|
1904
|
+
});
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1877
1907
|
hubLocalhostSwitch.addEventListener("click", function () {
|
|
1878
1908
|
var next = hubLocalhostSwitch.getAttribute("aria-checked") !== "true";
|
|
1879
1909
|
hubLocalhostSwitch.disabled = true;
|
|
@@ -2962,6 +2992,7 @@ const clientJs = `
|
|
|
2962
2992
|
uiAuthSave.textContent = enabled ? "Change password" : "Set password";
|
|
2963
2993
|
uiAuthRemove.hidden = !enabled;
|
|
2964
2994
|
signOutBtn.hidden = !enabled;
|
|
2995
|
+
renderHubPanel(enabled && hubPanelSwitch.getAttribute("aria-checked") === "true");
|
|
2965
2996
|
}
|
|
2966
2997
|
renderUiAuth(UI_AUTH);
|
|
2967
2998
|
|
|
@@ -3314,6 +3345,13 @@ function renderPage(nonce, token, uiAuth = false) {
|
|
|
3314
3345
|
</div>
|
|
3315
3346
|
<button type="button" id="hub-localhost" class="switch" role="switch" aria-checked="true" aria-label="Let admins open this dashboard and any port on this machine"><span class="switch-knob"></span></button>
|
|
3316
3347
|
</div>
|
|
3348
|
+
<div id="hub-panel-row" class="toggle-row" hidden>
|
|
3349
|
+
<div>
|
|
3350
|
+
<div class="label">Share this dashboard</div>
|
|
3351
|
+
<div id="hub-panel-sub" class="tile-sub"></div>
|
|
3352
|
+
</div>
|
|
3353
|
+
<button type="button" id="hub-panel" class="switch" role="switch" aria-checked="false" aria-label="Share this dashboard at the public URL, behind the dashboard password"><span class="switch-knob"></span></button>
|
|
3354
|
+
</div>
|
|
3317
3355
|
</div>
|
|
3318
3356
|
|
|
3319
3357
|
<div id="hub-share-section" class="hub-section" hidden>
|
package/dist/index.js
CHANGED
|
@@ -136,6 +136,10 @@ program
|
|
|
136
136
|
.command("hub:localhost <onOrOff>")
|
|
137
137
|
.description("Sender: let admin peers reach any port on this machine's localhost (on/off)")
|
|
138
138
|
.action(run((value) => commands_1.default.hubLocalhost(value)));
|
|
139
|
+
program
|
|
140
|
+
.command("hub:panel <onOrOff>")
|
|
141
|
+
.description("Sender: open this dashboard to browsers at the hub's public URL, behind the dashboard password (on/off)")
|
|
142
|
+
.action(run((value) => commands_1.default.hubPanel(value)));
|
|
139
143
|
program
|
|
140
144
|
.command("ui:password")
|
|
141
145
|
.description("Set or reset the dashboard password (prompts; hashed with scrypt). The API token keeps working for scripts")
|
package/dist/lib/ui-auth.js
CHANGED
|
@@ -9,6 +9,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
10
|
const constants_1 = __importDefault(require("../constants"));
|
|
11
11
|
const file_1 = __importDefault(require("../utils/file"));
|
|
12
|
+
const hub_config_1 = __importDefault(require("./hub-config"));
|
|
12
13
|
exports.SESSION_COOKIE = "locadot_ui";
|
|
13
14
|
exports.SESSION_TTL_SEC = 7 * 24 * 3600;
|
|
14
15
|
exports.MIN_PASSWORD = 8;
|
|
@@ -68,6 +69,10 @@ class UiAuth {
|
|
|
68
69
|
static clear() {
|
|
69
70
|
file_1.default.remove("UI_AUTH_FILE");
|
|
70
71
|
cache = undefined;
|
|
72
|
+
// The shared dashboard needs the password, so it doesn't come back on its own when a new one is set.
|
|
73
|
+
const hub = hub_config_1.default.read();
|
|
74
|
+
if (hub?.panel)
|
|
75
|
+
hub_config_1.default.write({ ...hub, panel: false });
|
|
71
76
|
}
|
|
72
77
|
static verify(password) {
|
|
73
78
|
const data = UiAuth.read();
|
package/dist/proxy/hub.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.resetRateLimiter = void 0;
|
|
39
|
+
exports.clientIp = exports.resetRateLimiter = void 0;
|
|
40
40
|
exports.classify = classify;
|
|
41
41
|
exports.handleHubApi = handleHubApi;
|
|
42
42
|
const os_1 = __importDefault(require("os"));
|
|
@@ -45,6 +45,7 @@ const logger_1 = __importDefault(require("../utils/logger"));
|
|
|
45
45
|
const links_1 = __importStar(require("../lib/links"));
|
|
46
46
|
const hosts_1 = __importStar(require("../lib/hosts"));
|
|
47
47
|
const hub_config_1 = __importDefault(require("../lib/hub-config"));
|
|
48
|
+
const ui_auth_1 = __importDefault(require("../lib/ui-auth"));
|
|
48
49
|
const localhost_1 = __importStar(require("../lib/localhost"));
|
|
49
50
|
const registry_1 = require("../lib/registry");
|
|
50
51
|
const request_1 = require("./request");
|
|
@@ -80,6 +81,7 @@ const clientIp = (req) => {
|
|
|
80
81
|
const value = Array.isArray(header) ? header[0] : header;
|
|
81
82
|
return value || req.socket.remoteAddress || "unknown";
|
|
82
83
|
};
|
|
84
|
+
exports.clientIp = clientIp;
|
|
83
85
|
/** `X-Original-Host` from the receiver, only if it is a `*.localhost` name with an optional port. */
|
|
84
86
|
const receiverHost = (req) => {
|
|
85
87
|
const header = req.headers["x-original-host"];
|
|
@@ -137,7 +139,13 @@ function classify(req, publicHost, hosts, opts) {
|
|
|
137
139
|
const pathname = (req.url || "/").split("?")[0];
|
|
138
140
|
if (pathname === "/_locadot" || pathname.startsWith("/_locadot/"))
|
|
139
141
|
return { kind: "api" };
|
|
140
|
-
|
|
142
|
+
// Peers always send X-Locadot-Peer; a plain browser gets the shared dashboard, which has its own sign-in limiter.
|
|
143
|
+
if (opts?.panel && req.headers["x-locadot-peer"] === undefined) {
|
|
144
|
+
if (!ui_auth_1.default.enabled())
|
|
145
|
+
return { kind: "deny", status: 403, message: "Set a dashboard password to share the dashboard." };
|
|
146
|
+
return { kind: "panel" };
|
|
147
|
+
}
|
|
148
|
+
const ip = (0, exports.clientIp)(req);
|
|
141
149
|
if (isLimited(ip))
|
|
142
150
|
return { kind: "deny", status: 429, message: "Too many failed attempts. Try again later." };
|
|
143
151
|
// Not Authorization: the sender's own apps may need that header from the visitor.
|
|
@@ -269,7 +277,7 @@ async function handleHubApi(req, res, ctx) {
|
|
|
269
277
|
const url = new URL(req.url || "/", "http://hub.invalid");
|
|
270
278
|
const path = url.pathname;
|
|
271
279
|
const method = req.method || "GET";
|
|
272
|
-
const ip = clientIp(req);
|
|
280
|
+
const ip = (0, exports.clientIp)(req);
|
|
273
281
|
try {
|
|
274
282
|
if (path === "/_locadot/v1/connect" && method === "POST") {
|
|
275
283
|
const body = await readJson(req);
|
package/dist/proxy/request.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.isTls = exports.viaOf = exports.loopbackOf = exports.peerNamesOf = exports.peerOriginOf = exports.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
|
|
3
|
+
exports.isTls = exports.viaOf = exports.loopbackOf = exports.peerNamesOf = exports.peerOriginOf = exports.isPanel = exports.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
|
|
4
4
|
const tags = new WeakMap();
|
|
5
5
|
const tag = (req, values) => {
|
|
6
6
|
tags.set(req, { ...tags.get(req), ...values });
|
|
@@ -21,6 +21,8 @@ const fromTunnel = (req) => Boolean(tags.get(req)?.tunnel);
|
|
|
21
21
|
exports.fromTunnel = fromTunnel;
|
|
22
22
|
const fromRemote = (req) => Boolean(tags.get(req)?.remote);
|
|
23
23
|
exports.fromRemote = fromRemote;
|
|
24
|
+
const isPanel = (req) => Boolean(tags.get(req)?.panel);
|
|
25
|
+
exports.isPanel = isPanel;
|
|
24
26
|
const peerOriginOf = (req) => tags.get(req)?.peerOrigin;
|
|
25
27
|
exports.peerOriginOf = peerOriginOf;
|
|
26
28
|
const peerNamesOf = (req) => tags.get(req)?.peerNames;
|
package/dist/proxy/router.js
CHANGED
|
@@ -17,12 +17,18 @@ const guard_1 = require("./guard");
|
|
|
17
17
|
const passthrough_1 = require("./passthrough");
|
|
18
18
|
const request_1 = require("./request");
|
|
19
19
|
const stats_1 = require("./stats");
|
|
20
|
+
// Cloudflare swaps an origin's 502/504 for its own "bad gateway" page, so through a tunnel say 503 and the page survives.
|
|
21
|
+
const downStatus = (req) => ((0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req) ? 503 : 502);
|
|
20
22
|
/** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
|
|
21
23
|
const resolveHost = (req, ctx, hub) => {
|
|
22
24
|
if (hub?.kind === "app") {
|
|
23
25
|
(0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names, loopback: hub.loopback });
|
|
24
26
|
return hub.host;
|
|
25
27
|
}
|
|
28
|
+
if (hub?.kind === "panel") {
|
|
29
|
+
(0, request_1.tag)(req, { host: "localhost", remote: true, secure: ctx.hub.secure(), peerOrigin: (req.headers.host || "").toLowerCase(), panel: true });
|
|
30
|
+
return "localhost";
|
|
31
|
+
}
|
|
26
32
|
if (hub?.kind === "dashboard") {
|
|
27
33
|
(0, request_1.tag)(req, { host: "localhost", remote: true, secure: ctx.hub.secure(), peerOrigin: hub.origin });
|
|
28
34
|
return "localhost";
|
|
@@ -110,7 +116,7 @@ function handleRequest(req, res, ctx) {
|
|
|
110
116
|
forwardLocal(req, res, ctx, host, local);
|
|
111
117
|
return;
|
|
112
118
|
}
|
|
113
|
-
res.writeHead(
|
|
119
|
+
res.writeHead(downStatus(req), { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
114
120
|
res.end((0, template_1.proxyNotFound)(host, dashboardUrl(req)));
|
|
115
121
|
return;
|
|
116
122
|
}
|
|
@@ -152,8 +158,8 @@ function handleRequest(req, res, ctx) {
|
|
|
152
158
|
res.destroy();
|
|
153
159
|
return;
|
|
154
160
|
}
|
|
155
|
-
// With --cors the page should see
|
|
156
|
-
res.writeHead(
|
|
161
|
+
// With --cors the page should see the error, not a CORS failure.
|
|
162
|
+
res.writeHead(downStatus(req), { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", ...(entry.cors ? (0, cors_1.allowOrigin)(req) : {}) });
|
|
157
163
|
res.end((0, template_1.upstreamDown)(host, upstream, reason(err) || "error", dashboardUrl(req)));
|
|
158
164
|
});
|
|
159
165
|
}
|
|
@@ -207,13 +213,13 @@ function forwardLocal(req, res, ctx, host, local) {
|
|
|
207
213
|
res.destroy();
|
|
208
214
|
return;
|
|
209
215
|
}
|
|
210
|
-
html(res,
|
|
216
|
+
html(res, downStatus(req), (0, template_1.upstreamDown)(host, label, reason(err) || "error", dashboardUrl(req)));
|
|
211
217
|
});
|
|
212
218
|
}
|
|
213
219
|
function handleUpgrade(req, socket, head, ctx) {
|
|
214
220
|
socket.on("error", () => socket.destroy());
|
|
215
221
|
const hub = ctx.hub?.classify(req);
|
|
216
|
-
if (hub?.kind === "api" || hub?.kind === "deny") {
|
|
222
|
+
if (hub?.kind === "api" || hub?.kind === "deny" || hub?.kind === "panel") {
|
|
217
223
|
const status = hub.kind === "deny" ? hub.status : 400;
|
|
218
224
|
socket.end(`HTTP/1.1 ${status} ${http_1.default.STATUS_CODES[status] || "Error"}\r\nConnection: close\r\n\r\n`);
|
|
219
225
|
return;
|
package/dist/server/index.js
CHANGED
|
@@ -76,6 +76,7 @@ async function startCentralProxy() {
|
|
|
76
76
|
hub: {
|
|
77
77
|
classify: (req) => (0, hub_1.classify)(req, hub.publicHost(), registry.get().hosts, {
|
|
78
78
|
localhost: hub_config_1.default.readCached()?.localhost !== false,
|
|
79
|
+
panel: hub_config_1.default.readCached()?.panel === true,
|
|
79
80
|
blockedPorts: [info.httpPort, info.httpsPort],
|
|
80
81
|
}),
|
|
81
82
|
api: (req, res) => {
|
package/dist/utils/startup.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.windowsLauncherScript = void 0;
|
|
6
|
+
exports.windowsLauncherScript = exports.macPlist = exports.macDirs = void 0;
|
|
7
7
|
const path_1 = __importDefault(require("path"));
|
|
8
8
|
const os_1 = __importDefault(require("os"));
|
|
9
9
|
const fs_1 = __importDefault(require("fs"));
|
|
@@ -16,11 +16,87 @@ const SERVICE_NAME = "locadot-proxy";
|
|
|
16
16
|
const MAC_LABEL = "com.locadot.proxy";
|
|
17
17
|
const CRON_MARK = "# locadot-proxy";
|
|
18
18
|
const MARKER = path_1.default.join(constants_1.default.paths.HOME, "startup.json");
|
|
19
|
+
const markerIn = (state) => path_1.default.join(state, "startup.json");
|
|
19
20
|
// Per-user Startup folder: unlike an ONLOGON scheduled task it needs no admin rights.
|
|
20
21
|
const windowsLauncher = () => path_1.default.join(process.env.APPDATA || path_1.default.join(os_1.default.homedir(), "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${SERVICE_NAME}.vbs`);
|
|
21
22
|
// Earlier versions registered an ONLOGON scheduled task and kept the script in the state dir.
|
|
22
23
|
const LEGACY_LAUNCHER = path_1.default.join(constants_1.default.paths.HOME, "startup-hidden.vbs");
|
|
23
|
-
|
|
24
|
+
// Tests point these elsewhere.
|
|
25
|
+
exports.macDirs = { daemons: "/Library/LaunchDaemons" };
|
|
26
|
+
const agentPath = (home = os_1.default.homedir()) => path_1.default.join(home, "Library", "LaunchAgents", `${MAC_LABEL}.plist`);
|
|
27
|
+
const daemonPath = () => path_1.default.join(exports.macDirs.daemons, `${MAC_LABEL}.plist`);
|
|
28
|
+
const xml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
29
|
+
/** Home of the user behind `sudo`, so root mode starts their proxy, not root's. */
|
|
30
|
+
const sudoUserHome = (user) => {
|
|
31
|
+
try {
|
|
32
|
+
const out = (0, child_process_1.execFileSync)("dscl", [".", "-read", `/Users/${user}`, "NFSHomeDirectory"], { encoding: "utf8", windowsHide: true });
|
|
33
|
+
const home = out.split(":").slice(1).join(":").trim();
|
|
34
|
+
if (home)
|
|
35
|
+
return home;
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
return path_1.default.join("/Users", user);
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Who the boot job runs as and which state dir it uses. As root under plain `sudo`, HOME is /var/root, so the
|
|
42
|
+
* state dir is re-derived for SUDO_USER; LOCADOT_HOME or `sudo -E` already point at the right one.
|
|
43
|
+
*/
|
|
44
|
+
const macTarget = () => {
|
|
45
|
+
const root = process.getuid?.() === 0;
|
|
46
|
+
const user = root ? process.env.SUDO_USER : undefined;
|
|
47
|
+
if (!user || user === "root")
|
|
48
|
+
return { root, user: undefined, home: os_1.default.homedir(), state: constants_1.default.paths.HOME };
|
|
49
|
+
const home = sudoUserHome(user);
|
|
50
|
+
const own = process.env.LOCADOT_HOME || constants_1.default.paths.HOME.startsWith(`${home}/`);
|
|
51
|
+
return { root, user, home, state: own ? constants_1.default.paths.HOME : path_1.default.join(home, "Library", "Application Support", "locadot") };
|
|
52
|
+
};
|
|
53
|
+
const macPlist = (label, argv, opts) => {
|
|
54
|
+
const env = { ...(opts.home ? { HOME: opts.home } : {}), ...opts.env };
|
|
55
|
+
const dict = (entries) => Object.entries(entries).map(([k, v]) => ` <key>${xml(k)}</key>\n <string>${xml(v)}</string>`).join("\n");
|
|
56
|
+
const log = path_1.default.join(opts.state, ".locadot.log");
|
|
57
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
58
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
59
|
+
<plist version="1.0">
|
|
60
|
+
<dict>
|
|
61
|
+
<key>Label</key>
|
|
62
|
+
<string>${xml(label)}</string>
|
|
63
|
+
<key>ProgramArguments</key>
|
|
64
|
+
<array>
|
|
65
|
+
${argv.map((arg) => `<string>${xml(arg)}</string>`).join("\n ")}
|
|
66
|
+
</array>${opts.user ? `\n <key>UserName</key>\n <string>${xml(opts.user)}</string>` : ""}${Object.keys(env).length ? `\n <key>EnvironmentVariables</key>\n <dict>\n${dict(env)}\n </dict>` : ""}
|
|
67
|
+
<key>RunAtLoad</key>
|
|
68
|
+
<true/>
|
|
69
|
+
<!-- A dashboard restart spawns the new proxy and exits; launchd must not take it down with the old one. -->
|
|
70
|
+
<key>AbandonProcessGroup</key>
|
|
71
|
+
<true/>
|
|
72
|
+
<key>StandardOutPath</key>
|
|
73
|
+
<string>${xml(log)}</string>
|
|
74
|
+
<key>StandardErrorPath</key>
|
|
75
|
+
<string>${xml(log)}</string>
|
|
76
|
+
</dict>
|
|
77
|
+
</plist>
|
|
78
|
+
`;
|
|
79
|
+
};
|
|
80
|
+
exports.macPlist = macPlist;
|
|
81
|
+
/** Port vars only when the user set them; otherwise the proxy reads the ports saved in the state dir. */
|
|
82
|
+
const userPortVars = () => {
|
|
83
|
+
const set = constants_1.default.userPortEnv();
|
|
84
|
+
if (set === "none")
|
|
85
|
+
return {};
|
|
86
|
+
const vars = { LOCADOT_USER_PORTS: set };
|
|
87
|
+
if (set.includes("LOCADOT_HTTP_PORT"))
|
|
88
|
+
vars.LOCADOT_HTTP_PORT = String(constants_1.default.server.httpPort);
|
|
89
|
+
if (set.includes("LOCADOT_HTTPS_PORT"))
|
|
90
|
+
vars.LOCADOT_HTTPS_PORT = String(constants_1.default.server.httpsPort);
|
|
91
|
+
return vars;
|
|
92
|
+
};
|
|
93
|
+
/** Clears a disabled override (left by `launchctl unload -w`), which would keep the job from ever running. */
|
|
94
|
+
const launchctlEnable = (domain) => {
|
|
95
|
+
try {
|
|
96
|
+
(0, child_process_1.execFileSync)("launchctl", ["enable", `${domain}/${MAC_LABEL}`], { stdio: "ignore", windowsHide: true });
|
|
97
|
+
}
|
|
98
|
+
catch { }
|
|
99
|
+
};
|
|
24
100
|
const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
25
101
|
// Resolved on use, not import: the entry path depends on how locadot was launched.
|
|
26
102
|
const launchArgs = () => {
|
|
@@ -102,30 +178,40 @@ class Startup {
|
|
|
102
178
|
break;
|
|
103
179
|
}
|
|
104
180
|
case "mac": {
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
181
|
+
// Not loaded now: a second proxy would only fail to bind next to the running one. launchd reads
|
|
182
|
+
// LaunchAgents at login and LaunchDaemons at boot on its own.
|
|
183
|
+
const target = macTarget();
|
|
184
|
+
const argv = [command, ...args.slice(0, -1), target.state];
|
|
185
|
+
if (target.root) {
|
|
186
|
+
// Root mode: a LaunchDaemon starts at boot, before anyone logs in, as the sudo user when there is one.
|
|
187
|
+
fs_1.default.mkdirSync(exports.macDirs.daemons, { recursive: true });
|
|
188
|
+
fs_1.default.writeFileSync(daemonPath(), (0, exports.macPlist)(MAC_LABEL, argv, { state: target.state, user: target.user, home: target.user ? target.home : undefined, env: userPortVars() }));
|
|
189
|
+
// launchd skips daemon plists that aren't root-owned or are group/world writable.
|
|
190
|
+
fs_1.default.chmodSync(daemonPath(), 0o644);
|
|
191
|
+
fs_1.default.rmSync(agentPath(target.home), { force: true });
|
|
192
|
+
launchctlEnable("system");
|
|
193
|
+
method = "launch-daemon";
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
// macOS (10.14+) lets unprivileged processes bind 80/443, so a user LaunchAgent is enough. It starts at login.
|
|
197
|
+
fs_1.default.mkdirSync(path_1.default.dirname(agentPath()), { recursive: true });
|
|
198
|
+
fs_1.default.writeFileSync(agentPath(), (0, exports.macPlist)(MAC_LABEL, argv, { state: target.state, env: userPortVars() }));
|
|
199
|
+
fs_1.default.chmodSync(agentPath(), 0o644);
|
|
200
|
+
if (fs_1.default.existsSync(daemonPath()))
|
|
201
|
+
await execSudo(`rm -f ${quote(daemonPath())}`);
|
|
202
|
+
launchctlEnable(`gui/${process.getuid?.()}`);
|
|
203
|
+
method = "launch-agent";
|
|
204
|
+
}
|
|
205
|
+
fs_1.default.mkdirSync(target.state, { recursive: true });
|
|
206
|
+
fs_1.default.writeFileSync(markerIn(target.state), JSON.stringify({ platform, method, enabledAt: new Date().toISOString() }, null, 2));
|
|
207
|
+
if (target.user) {
|
|
208
|
+
// Written as root into the user's state dir; the user's own proxy has to be able to replace them.
|
|
209
|
+
const { uid, gid } = fs_1.default.statSync(target.home);
|
|
210
|
+
for (const file of [target.state, markerIn(target.state)])
|
|
211
|
+
fs_1.default.chownSync(file, uid, gid);
|
|
212
|
+
}
|
|
213
|
+
logger_1.default.info(`✅ locadot will start at ${method === "launch-daemon" ? "boot" : "login"} (${method}).`);
|
|
214
|
+
return;
|
|
129
215
|
}
|
|
130
216
|
default:
|
|
131
217
|
throw new Error("Unknown platform");
|
|
@@ -148,15 +234,19 @@ class Startup {
|
|
|
148
234
|
else
|
|
149
235
|
(0, child_process_1.execFileSync)("sh", ["-c", cronEdit()], { stdio: "inherit", windowsHide: true });
|
|
150
236
|
break;
|
|
151
|
-
case "mac":
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
237
|
+
case "mac": {
|
|
238
|
+
// Only the plist goes: unloading the job would also stop a proxy launchd started.
|
|
239
|
+
const target = macTarget();
|
|
240
|
+
fs_1.default.rmSync(agentPath(target.home), { force: true });
|
|
241
|
+
if (fs_1.default.existsSync(daemonPath())) {
|
|
242
|
+
if (target.root)
|
|
243
|
+
fs_1.default.rmSync(daemonPath(), { force: true });
|
|
244
|
+
else
|
|
245
|
+
await execSudo(`rm -f ${quote(daemonPath())}`);
|
|
158
246
|
}
|
|
247
|
+
fs_1.default.rmSync(markerIn(target.state), { force: true });
|
|
159
248
|
break;
|
|
249
|
+
}
|
|
160
250
|
default:
|
|
161
251
|
throw new Error("Unknown platform");
|
|
162
252
|
}
|
|
@@ -188,7 +278,7 @@ class Startup {
|
|
|
188
278
|
return true;
|
|
189
279
|
return (0, child_process_1.execFileSync)("sh", ["-c", "crontab -l 2>/dev/null || true"], { encoding: "utf8", windowsHide: true }).includes(CRON_MARK);
|
|
190
280
|
case "mac":
|
|
191
|
-
return fs_1.default.existsSync(
|
|
281
|
+
return fs_1.default.existsSync(agentPath()) || fs_1.default.existsSync(daemonPath());
|
|
192
282
|
case "windows":
|
|
193
283
|
return fs_1.default.existsSync(windowsLauncher());
|
|
194
284
|
default:
|
package/dist/utils/trust.js
CHANGED
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.macSecurity = macSecurity;
|
|
6
7
|
exports.isCATrusted = isCATrusted;
|
|
7
8
|
exports.trustCA = trustCA;
|
|
8
9
|
exports.untrustCA = untrustCA;
|
|
@@ -126,11 +127,60 @@ function isTrustedLinux(certPath) {
|
|
|
126
127
|
return false;
|
|
127
128
|
}
|
|
128
129
|
}
|
|
130
|
+
const MAC_KEYCHAIN = "/Library/Keychains/System.keychain";
|
|
131
|
+
const macRootError = () => new Error(`Run \`locadot trust\` without sudo; it asks for your password itself. Under sudo it would trust root's own CA ` +
|
|
132
|
+
`(${(0, certs_1.caCertPath)()}), not the one your proxy serves.`);
|
|
133
|
+
// Plain `sudo` sets HOME to /var/root; `sudo -E` or LOCADOT_HOME keep the user's state dir, so those are fine.
|
|
134
|
+
const sudoAsRoot = () => {
|
|
135
|
+
const user = process.env.SUDO_USER;
|
|
136
|
+
return process.getuid?.() === 0 && Boolean(user) && !process.env.LOCADOT_HOME && !path_1.default.resolve(constants_1.default.paths.HOME).startsWith(`/Users/${user}/`);
|
|
137
|
+
};
|
|
138
|
+
// Changing admin trust settings needs macOS to show a password dialog. sudo-prompt elevates through
|
|
139
|
+
// `osascript ... with administrator privileges`, which can't, so it fails with "no user interaction was possible".
|
|
140
|
+
// A Terminal's sudo or the user's own session can.
|
|
141
|
+
// Async: from the dashboard the dialog can stay open for a while, and the proxy has to keep serving meanwhile.
|
|
142
|
+
async function macSecurity(args) {
|
|
143
|
+
const viaSudo = process.getuid?.() !== 0 && Boolean(process.stdin.isTTY);
|
|
144
|
+
const detail = await new Promise((resolve) => {
|
|
145
|
+
const child = viaSudo
|
|
146
|
+
? (0, child_process_1.spawn)("sudo", ["security", ...args], { stdio: ["inherit", "inherit", "pipe"], windowsHide: true })
|
|
147
|
+
: (0, child_process_1.spawn)("security", args, { stdio: ["ignore", "inherit", "pipe"], windowsHide: true });
|
|
148
|
+
let stderr = "";
|
|
149
|
+
child.stderr.on("data", (chunk) => {
|
|
150
|
+
stderr += chunk;
|
|
151
|
+
if (viaSudo)
|
|
152
|
+
process.stderr.write(chunk);
|
|
153
|
+
});
|
|
154
|
+
child.on("error", (error) => resolve(error.message));
|
|
155
|
+
child.on("close", (code) => resolve(code === 0 ? undefined : stderr.trim() || `security exited with ${code}`));
|
|
156
|
+
});
|
|
157
|
+
if (detail === undefined)
|
|
158
|
+
return;
|
|
159
|
+
if (/no user interaction was possible/i.test(detail)) {
|
|
160
|
+
throw new Error("macOS needs to show a password dialog to change certificate trust, and couldn't from here. " +
|
|
161
|
+
"Run `locadot trust` in Terminal on the Mac itself (not over SSH), or run:\n " +
|
|
162
|
+
`sudo security ${args.map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ")}`);
|
|
163
|
+
}
|
|
164
|
+
throw new Error(detail);
|
|
165
|
+
}
|
|
129
166
|
async function trustMac(certPath) {
|
|
130
|
-
|
|
167
|
+
if (sudoAsRoot())
|
|
168
|
+
throw macRootError();
|
|
169
|
+
await macSecurity(["add-trusted-cert", "-d", "-r", "trustRoot", "-k", MAC_KEYCHAIN, certPath]);
|
|
131
170
|
}
|
|
132
171
|
async function untrustMac() {
|
|
133
|
-
|
|
172
|
+
if (sudoAsRoot())
|
|
173
|
+
throw macRootError();
|
|
174
|
+
const certPath = (0, certs_1.caCertPath)();
|
|
175
|
+
if (fs_1.default.existsSync(certPath)) {
|
|
176
|
+
try {
|
|
177
|
+
await macSecurity(["remove-trusted-cert", "-d", certPath]);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// No trust settings left for it; deleting the certificate below is what matters.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
await macSecurity(["delete-certificate", "-c", CA_NAME, MAC_KEYCHAIN]);
|
|
134
184
|
}
|
|
135
185
|
function isTrustedMac(certPath) {
|
|
136
186
|
try {
|
package/package.json
CHANGED