locadot 2.6.0 → 2.6.1
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 +9 -0
- package/README.md +1 -1
- package/dist/cli/commands/doctor.js +4 -0
- package/dist/lib/system.js +40 -3
- package/dist/utils/startup.js +57 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.6.1 (2026-09-30)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- `startup:enable` on ports 80/443 now asks for admin rights. macOS only lets non-root bind ports below 1024 on
|
|
7
|
+
all interfaces, and locadot listens on loopback, so the login LaunchAgent couldn't bind; it now installs a root
|
|
8
|
+
LaunchDaemon (after a password prompt), and `sudo startup:enable` no longer drops the job to your user. On Linux
|
|
9
|
+
a terminal gets a plain `sudo` prompt instead of a desktop-only dialog, and a missing prompt fails with a clear
|
|
10
|
+
message. The dashboard and `doctor` report on macOS that 80/443 need root.
|
|
11
|
+
|
|
3
12
|
## 2.6.0 (2026-09-30)
|
|
4
13
|
|
|
5
14
|
### Added
|
package/README.md
CHANGED
|
@@ -215,7 +215,7 @@ working on the same URL, since they authenticate with their own tokens. Some det
|
|
|
215
215
|
| `locadot clear:logs` | Clear the log file. |
|
|
216
216
|
| `locadot path` | Show every file locadot uses. `path:logs` and `path:hosts` print a single path. |
|
|
217
217
|
| `locadot token` | Print the dashboard API token, for scripts and AI agents. |
|
|
218
|
-
| `locadot startup:enable` / `startup:disable` / `startup:status` | Start the proxy at boot (Linux cron; macOS LaunchDaemon
|
|
218
|
+
| `locadot startup:enable` / `startup:disable` / `startup:status` | Start the proxy at boot (Linux cron; macOS LaunchDaemon) or logon (macOS LaunchAgent; a hidden script in the Windows Startup folder, no admin needed). On ports below 1024 it asks for your password (sudo in a terminal, the system dialog from the dashboard), since macOS and Linux need root to bind them on loopback; the boot job then runs as root. |
|
|
219
219
|
|
|
220
220
|
---
|
|
221
221
|
|
|
@@ -11,6 +11,7 @@ const proxy_control_1 = __importDefault(require("../../lib/proxy-control"));
|
|
|
11
11
|
const registry_1 = __importDefault(require("../../lib/registry"));
|
|
12
12
|
const constants_1 = __importDefault(require("../../constants"));
|
|
13
13
|
const trust_1 = require("../../utils/trust");
|
|
14
|
+
const startup_1 = require("../../utils/startup");
|
|
14
15
|
const shared_1 = require("../shared");
|
|
15
16
|
async function doctor(options) {
|
|
16
17
|
let failures = 0;
|
|
@@ -46,6 +47,9 @@ async function doctor(options) {
|
|
|
46
47
|
const canBind = process.getuid?.() === 0 || unprivileged <= Math.min(constants_1.default.server.httpPort, constants_1.default.server.httpsPort);
|
|
47
48
|
check(canBind || Boolean(info), "Allowed to bind ports 80/443", "Run with sudo, or allow it once: `sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80` (persist in /etc/sysctl.d/).");
|
|
48
49
|
}
|
|
50
|
+
if (os_1.default.platform() === "darwin" && (0, startup_1.macLowPortsNeedRoot)()) {
|
|
51
|
+
check(process.getuid?.() === 0 || Boolean(info), "Allowed to bind ports 80/443", "macOS lets non-root bind ports below 1024 only on all interfaces, and locadot listens on loopback. Run `sudo locadot start`.");
|
|
52
|
+
}
|
|
49
53
|
const trusted = await (0, trust_1.isCATrusted)().catch(() => false);
|
|
50
54
|
check(trusted, "Locadot CA trusted by the system", `Run \`locadot trust\`. Manual steps:\n${(0, trust_1.trustInstructions)()}`);
|
|
51
55
|
try {
|
package/dist/lib/system.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
|
};
|
|
@@ -8,7 +41,7 @@ exports.systemStatus = systemStatus;
|
|
|
8
41
|
const fs_1 = __importDefault(require("fs"));
|
|
9
42
|
const child_process_1 = require("child_process");
|
|
10
43
|
const constants_1 = __importDefault(require("../constants"));
|
|
11
|
-
const startup_1 =
|
|
44
|
+
const startup_1 = __importStar(require("../utils/startup"));
|
|
12
45
|
const trust_1 = require("../utils/trust");
|
|
13
46
|
let windowsAdmin;
|
|
14
47
|
/** Root on POSIX; on Windows `net session` only succeeds in an elevated shell. */
|
|
@@ -38,9 +71,13 @@ const unprivilegedPortStart = () => {
|
|
|
38
71
|
}
|
|
39
72
|
};
|
|
40
73
|
exports.unprivilegedPortStart = unprivilegedPortStart;
|
|
41
|
-
/**
|
|
74
|
+
/** Windows lets any user bind 80/443; macOS only on the wildcard address; Linux depends on the sysctl. */
|
|
42
75
|
const canBindPrivileged = () => {
|
|
43
|
-
if (process.platform
|
|
76
|
+
if (process.platform === "win32" || (0, exports.isElevated)())
|
|
77
|
+
return true;
|
|
78
|
+
if (process.platform === "darwin")
|
|
79
|
+
return !(0, startup_1.macLowPortsNeedRoot)();
|
|
80
|
+
if (process.platform !== "linux")
|
|
44
81
|
return true;
|
|
45
82
|
const start = (0, exports.unprivilegedPortStart)();
|
|
46
83
|
return start !== null && start <= Math.min(constants_1.default.server.httpPort, constants_1.default.server.httpsPort);
|
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 = exports.macPlist = exports.macDirs = void 0;
|
|
6
|
+
exports.macLowPortsNeedRoot = exports.privilege = exports.elevate = 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"));
|
|
@@ -135,6 +135,38 @@ function execSudo(cmd) {
|
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* In a terminal, plain sudo asks for the password right there. Elsewhere (the dashboard's proxy) sudo-prompt shows
|
|
140
|
+
* the system dialog: osascript on macOS, pkexec or kdesudo on Linux, which servers and SSH sessions often lack.
|
|
141
|
+
*/
|
|
142
|
+
const elevate = async (cmd, command) => {
|
|
143
|
+
if (process.stdin.isTTY) {
|
|
144
|
+
logger_1.default.info("The boot job for ports below 1024 runs as root; sudo will ask for your password.");
|
|
145
|
+
const result = (0, child_process_1.spawnSync)("sudo", ["sh", "-c", cmd], { stdio: "inherit", windowsHide: true });
|
|
146
|
+
if (!result.error) {
|
|
147
|
+
if (result.status === 0)
|
|
148
|
+
return;
|
|
149
|
+
throw new Error(`sudo failed (${result.status ?? result.signal}).`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
await execSudo(`sh -c ${quote(cmd)}`);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
throw new Error(`This needs admin rights and no password prompt is available here (${String(error?.message || error).trim()}). ` +
|
|
157
|
+
`Run \`${command}\` in a terminal.`);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
exports.elevate = elevate;
|
|
161
|
+
// Tests swap this out.
|
|
162
|
+
exports.privilege = { run: exports.elevate };
|
|
163
|
+
/**
|
|
164
|
+
* macOS (10.14+) lets any user bind ports below 1024, but only on the wildcard address. locadot listens on loopback
|
|
165
|
+
* by default, so 80/443 still need root there.
|
|
166
|
+
*/
|
|
167
|
+
const macLowPortsNeedRoot = () => Math.min(constants_1.default.server.httpPort, constants_1.default.server.httpsPort) < 1024 &&
|
|
168
|
+
constants_1.default.server.bind.some((host) => host !== "0.0.0.0" && host !== "::");
|
|
169
|
+
exports.macLowPortsNeedRoot = macLowPortsNeedRoot;
|
|
138
170
|
/** Linux needs root for ports below 1024 unless the sysctl allows otherwise. */
|
|
139
171
|
const linuxNeedsRoot = () => {
|
|
140
172
|
if (process.getuid?.() === 0)
|
|
@@ -168,7 +200,7 @@ class Startup {
|
|
|
168
200
|
const line = `@reboot ${env} ${[command, ...args].map(quote).join(" ")} >> ${quote(constants_1.default.paths.LOGS)} 2>&1 ${CRON_MARK}`;
|
|
169
201
|
const edit = cronEdit(line);
|
|
170
202
|
if (linuxNeedsRoot()) {
|
|
171
|
-
await
|
|
203
|
+
await exports.privilege.run(edit, "locadot startup:enable");
|
|
172
204
|
method = "root-crontab";
|
|
173
205
|
}
|
|
174
206
|
else {
|
|
@@ -182,23 +214,41 @@ class Startup {
|
|
|
182
214
|
// LaunchAgents at login and LaunchDaemons at boot on its own.
|
|
183
215
|
const target = macTarget();
|
|
184
216
|
const argv = [command, ...args.slice(0, -1), target.state];
|
|
217
|
+
const needsRoot = (0, exports.macLowPortsNeedRoot)();
|
|
185
218
|
if (target.root) {
|
|
186
|
-
// Root mode: a LaunchDaemon starts at boot, before anyone logs in, as the sudo user when there is one
|
|
219
|
+
// Root mode: a LaunchDaemon starts at boot, before anyone logs in, as the sudo user when there is one
|
|
220
|
+
// and the ports don't need root.
|
|
221
|
+
const user = needsRoot ? undefined : target.user;
|
|
187
222
|
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
|
|
223
|
+
fs_1.default.writeFileSync(daemonPath(), (0, exports.macPlist)(MAC_LABEL, argv, { state: target.state, user, home: target.user ? target.home : undefined, env: userPortVars() }));
|
|
189
224
|
// launchd skips daemon plists that aren't root-owned or are group/world writable.
|
|
190
225
|
fs_1.default.chmodSync(daemonPath(), 0o644);
|
|
191
226
|
fs_1.default.rmSync(agentPath(target.home), { force: true });
|
|
192
227
|
launchctlEnable("system");
|
|
193
228
|
method = "launch-daemon";
|
|
194
229
|
}
|
|
230
|
+
else if (needsRoot) {
|
|
231
|
+
// A LaunchAgent runs as the user and couldn't bind, so install a root LaunchDaemon, asking for the password.
|
|
232
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "locadot-daemon-"));
|
|
233
|
+
const tmp = path_1.default.join(dir, `${MAC_LABEL}.plist`);
|
|
234
|
+
fs_1.default.writeFileSync(tmp, (0, exports.macPlist)(MAC_LABEL, argv, { state: target.state, home: target.home, env: userPortVars() }));
|
|
235
|
+
try {
|
|
236
|
+
await exports.privilege.run(`mkdir -p ${quote(exports.macDirs.daemons)} && install -m 644 -o root -g wheel ${quote(tmp)} ${quote(daemonPath())} && ` +
|
|
237
|
+
`(launchctl enable system/${MAC_LABEL} || true)`, "locadot startup:enable");
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
fs_1.default.rmSync(dir, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
fs_1.default.rmSync(agentPath(target.home), { force: true });
|
|
243
|
+
method = "launch-daemon";
|
|
244
|
+
}
|
|
195
245
|
else {
|
|
196
246
|
// macOS (10.14+) lets unprivileged processes bind 80/443, so a user LaunchAgent is enough. It starts at login.
|
|
197
247
|
fs_1.default.mkdirSync(path_1.default.dirname(agentPath()), { recursive: true });
|
|
198
248
|
fs_1.default.writeFileSync(agentPath(), (0, exports.macPlist)(MAC_LABEL, argv, { state: target.state, env: userPortVars() }));
|
|
199
249
|
fs_1.default.chmodSync(agentPath(), 0o644);
|
|
200
250
|
if (fs_1.default.existsSync(daemonPath()))
|
|
201
|
-
await
|
|
251
|
+
await exports.privilege.run(`rm -f ${quote(daemonPath())}`, "locadot startup:enable");
|
|
202
252
|
launchctlEnable(`gui/${process.getuid?.()}`);
|
|
203
253
|
method = "launch-agent";
|
|
204
254
|
}
|
|
@@ -230,7 +280,7 @@ class Startup {
|
|
|
230
280
|
break;
|
|
231
281
|
case "linux":
|
|
232
282
|
if (method === "root-crontab")
|
|
233
|
-
await
|
|
283
|
+
await exports.privilege.run(cronEdit(), "locadot startup:disable");
|
|
234
284
|
else
|
|
235
285
|
(0, child_process_1.execFileSync)("sh", ["-c", cronEdit()], { stdio: "inherit", windowsHide: true });
|
|
236
286
|
break;
|
|
@@ -242,7 +292,7 @@ class Startup {
|
|
|
242
292
|
if (target.root)
|
|
243
293
|
fs_1.default.rmSync(daemonPath(), { force: true });
|
|
244
294
|
else
|
|
245
|
-
await
|
|
295
|
+
await exports.privilege.run(`rm -f ${quote(daemonPath())}`, "locadot startup:disable");
|
|
246
296
|
}
|
|
247
297
|
fs_1.default.rmSync(markerIn(target.state), { force: true });
|
|
248
298
|
break;
|
package/package.json
CHANGED