locadot 2.1.0 → 2.3.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 +32 -0
- package/README.md +56 -4
- package/dist/cli/commands/hosts.js +29 -1
- package/dist/cli/commands/remote.js +21 -1
- package/dist/cli/commands/tunnel.js +9 -3
- package/dist/dashboard/api.js +35 -5
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/login.js +17 -6
- package/dist/dashboard/page.js +778 -86
- package/dist/index.js +16 -2
- package/dist/lib/allow.js +60 -0
- package/dist/lib/hosts.js +52 -4
- package/dist/lib/ui-auth.js +5 -0
- package/dist/proxy/guard.js +30 -1
- package/dist/proxy/hub.js +11 -3
- package/dist/proxy/options.js +10 -2
- package/dist/proxy/request.js +3 -1
- package/dist/proxy/router.js +38 -12
- package/dist/proxy/tunnel.js +62 -22
- package/dist/server/index.js +2 -0
- package/dist/utils/startup.js +124 -34
- package/dist/utils/trust.js +52 -2
- package/package.json +1 -1
package/dist/proxy/tunnel.js
CHANGED
|
@@ -11,8 +11,10 @@ const path_1 = __importDefault(require("path"));
|
|
|
11
11
|
const child_process_1 = require("child_process");
|
|
12
12
|
const constants_1 = __importDefault(require("../constants"));
|
|
13
13
|
const logger_1 = __importDefault(require("../utils/logger"));
|
|
14
|
+
const hub_tunnel_1 = require("./hub-tunnel");
|
|
14
15
|
/**
|
|
15
|
-
* Public sharing through Cloudflare quick tunnels (https://<random>.trycloudflare.com, no account)
|
|
16
|
+
* Public sharing through Cloudflare quick tunnels (https://<random>.trycloudflare.com, no account),
|
|
17
|
+
* or through a named tunnel per mapping on the user's own hostname (`tunnelDomain`).
|
|
16
18
|
* cloudflared forwards to the proxy's HTTP port with the public Host header, and the proxy maps
|
|
17
19
|
* that name back to the mapping, so redirects and cookies stay on the public name.
|
|
18
20
|
*/
|
|
@@ -20,6 +22,7 @@ const exe = os_1.default.platform() === "win32" ? "cloudflared.exe" : "cloudflar
|
|
|
20
22
|
const BIN_DIR = path_1.default.join(constants_1.default.paths.HOME, "bin");
|
|
21
23
|
const RELEASES = "https://github.com/cloudflare/cloudflared/releases/latest/download/";
|
|
22
24
|
const URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
25
|
+
const REGISTERED_PATTERN = /Registered tunnel connection/i;
|
|
23
26
|
const onPath = () => {
|
|
24
27
|
const finder = os_1.default.platform() === "win32" ? "where" : "which";
|
|
25
28
|
const result = (0, child_process_1.spawnSync)(finder, ["cloudflared"], { encoding: "utf8", windowsHide: true });
|
|
@@ -37,11 +40,12 @@ const cloudflaredInfo = (fresh = false) => {
|
|
|
37
40
|
if (!fresh && cached && Date.now() - cached.at < 60000)
|
|
38
41
|
return cached.info;
|
|
39
42
|
const file = (0, exports.cloudflaredPath)();
|
|
40
|
-
|
|
43
|
+
const loggedIn = fs_1.default.existsSync(path_1.default.join(os_1.default.homedir(), ".cloudflared", "cert.pem"));
|
|
44
|
+
let info = { installed: false, loggedIn };
|
|
41
45
|
if (file) {
|
|
42
46
|
const result = (0, child_process_1.spawnSync)(file, ["--version"], { encoding: "utf8", timeout: 5000, windowsHide: true });
|
|
43
47
|
const version = `${result.stdout || ""}${result.stderr || ""}`.match(/version\s+(\S+)/i)?.[1];
|
|
44
|
-
info = { installed: result.status === 0, path: file, ...(version ? { version } : {}) };
|
|
48
|
+
info = { installed: result.status === 0, loggedIn, path: file, ...(version ? { version } : {}) };
|
|
45
49
|
}
|
|
46
50
|
cached = { at: Date.now(), info };
|
|
47
51
|
return info;
|
|
@@ -121,7 +125,7 @@ class TunnelManager {
|
|
|
121
125
|
this.origin = origin;
|
|
122
126
|
this.tunnels = new Map();
|
|
123
127
|
}
|
|
124
|
-
/** The mapping a public trycloudflare.com
|
|
128
|
+
/** The mapping a public Host (trycloudflare.com or a custom hostname) belongs to. */
|
|
125
129
|
hostFor(publicHost) {
|
|
126
130
|
for (const [host, tunnel] of this.tunnels)
|
|
127
131
|
if (tunnel.publicHost === publicHost)
|
|
@@ -137,38 +141,73 @@ class TunnelManager {
|
|
|
137
141
|
}
|
|
138
142
|
/** Starts and stops tunnels to match the registry; `retry` also restarts ones that failed. */
|
|
139
143
|
sync(hosts, retry = false) {
|
|
140
|
-
for (const [host, tunnel] of [...this.tunnels])
|
|
141
|
-
|
|
144
|
+
for (const [host, tunnel] of [...this.tunnels]) {
|
|
145
|
+
const entry = hosts[host];
|
|
146
|
+
if (!entry?.tunnel || (retry && tunnel.status === "error") || tunnel.domain !== (entry.tunnelDomain || undefined))
|
|
142
147
|
this.stop(host);
|
|
148
|
+
}
|
|
143
149
|
for (const [host, entry] of Object.entries(hosts))
|
|
144
150
|
if (entry.tunnel && !this.tunnels.has(host))
|
|
145
|
-
this.start(host);
|
|
151
|
+
this.start(host, entry.tunnelDomain);
|
|
146
152
|
}
|
|
147
|
-
start(host) {
|
|
153
|
+
start(host, domain) {
|
|
154
|
+
const mode = domain ? "custom" : "quick";
|
|
148
155
|
const file = (0, exports.cloudflaredPath)();
|
|
149
156
|
if (!file) {
|
|
150
|
-
this.tunnels.set(host, { enabled: true, status: "error", error: "cloudflared is not installed. Run `locadot tunnel:install`." });
|
|
157
|
+
this.tunnels.set(host, { enabled: true, status: "error", mode, domain, error: "cloudflared is not installed. Run `locadot tunnel:install`." });
|
|
151
158
|
return;
|
|
152
159
|
}
|
|
153
|
-
const tunnel = { enabled: true, status: "starting" };
|
|
160
|
+
const tunnel = { enabled: true, status: "starting", mode, domain };
|
|
154
161
|
this.tunnels.set(host, tunnel);
|
|
162
|
+
if (domain) {
|
|
163
|
+
this.startCustom(file, host, domain, tunnel);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
155
166
|
// An empty config file keeps a user's ~/.cloudflared/config.yml (named tunnels) out of the way.
|
|
156
167
|
const config = path_1.default.join(constants_1.default.paths.HOME, "cloudflared-quick.yml");
|
|
157
168
|
fs_1.default.writeFileSync(config, "");
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
169
|
+
this.run(host, tunnel, file, ["tunnel", "--no-autoupdate", "--config", config, "--url", this.origin()], (text) => {
|
|
170
|
+
const url = text.match(URL_PATTERN)?.[0];
|
|
171
|
+
if (url)
|
|
172
|
+
Object.assign(tunnel, { status: "up", url, publicHost: new URL(url).hostname });
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** Logs in if needed, creates the mapping's own named tunnel and points the hostname's DNS at it, then runs it. */
|
|
176
|
+
startCustom(file, host, domain, tunnel) {
|
|
177
|
+
const name = `locadot-${host.replace(/[^a-z0-9-]/gi, "-")}`;
|
|
178
|
+
tunnel.publicHost = domain;
|
|
179
|
+
(0, hub_tunnel_1.setupNamedTunnel)(domain, name, (loginUrl) => {
|
|
180
|
+
if (this.tunnels.get(host) === tunnel)
|
|
181
|
+
Object.assign(tunnel, { status: "login", loginUrl });
|
|
182
|
+
})
|
|
183
|
+
.then(() => {
|
|
184
|
+
if (this.tunnels.get(host) !== tunnel)
|
|
185
|
+
return;
|
|
186
|
+
Object.assign(tunnel, { status: "starting", loginUrl: undefined });
|
|
187
|
+
this.run(host, tunnel, file, ["tunnel", "--no-autoupdate", "run", "--url", this.origin(), name], (text) => {
|
|
188
|
+
if (REGISTERED_PATTERN.test(text))
|
|
189
|
+
Object.assign(tunnel, { status: "up", url: `https://${domain}` });
|
|
190
|
+
});
|
|
191
|
+
})
|
|
192
|
+
.catch((error) => {
|
|
193
|
+
if (this.tunnels.get(host) !== tunnel)
|
|
194
|
+
return;
|
|
195
|
+
Object.assign(tunnel, { status: "error", error: error?.message || String(error), loginUrl: undefined });
|
|
196
|
+
logger_1.default.warn(`🌍 tunnel for ${host} failed: ${tunnel.error}`);
|
|
161
197
|
});
|
|
198
|
+
}
|
|
199
|
+
run(host, tunnel, file, args, onData) {
|
|
200
|
+
const child = (0, child_process_1.spawn)(file, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
162
201
|
tunnel.child = child;
|
|
163
202
|
let tail = "";
|
|
164
203
|
const read = (chunk) => {
|
|
165
204
|
const text = chunk.toString("utf8");
|
|
166
205
|
tail = (tail + text).slice(-2000);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
206
|
+
if (tunnel.status === "up")
|
|
207
|
+
return;
|
|
208
|
+
onData(text);
|
|
209
|
+
if (tunnel.status === "up")
|
|
210
|
+
logger_1.default.info(`🌍 ${host} is public at ${tunnel.url}`);
|
|
172
211
|
};
|
|
173
212
|
child.stdout?.on("data", read);
|
|
174
213
|
child.stderr?.on("data", read);
|
|
@@ -177,17 +216,18 @@ class TunnelManager {
|
|
|
177
216
|
if (this.tunnels.get(host) !== tunnel)
|
|
178
217
|
return;
|
|
179
218
|
const reason = tail.split(/\r?\n/).filter((line) => /ERR|error|failed/i.test(line)).pop()?.trim();
|
|
180
|
-
Object.assign(tunnel, { status: "error", error: reason || `cloudflared exited (${code})`, url: undefined,
|
|
219
|
+
Object.assign(tunnel, { status: "error", error: reason || `cloudflared exited (${code})`, url: undefined, child: undefined });
|
|
220
|
+
if (!tunnel.domain)
|
|
221
|
+
tunnel.publicHost = undefined;
|
|
181
222
|
logger_1.default.warn(`🌍 tunnel for ${host} stopped: ${tunnel.error}`);
|
|
182
223
|
});
|
|
183
224
|
}
|
|
184
225
|
stop(host) {
|
|
185
226
|
const tunnel = this.tunnels.get(host);
|
|
186
227
|
this.tunnels.delete(host);
|
|
187
|
-
if (tunnel?.child)
|
|
188
|
-
tunnel.child.kill();
|
|
228
|
+
if (tunnel?.child || tunnel?.status === "login")
|
|
189
229
|
logger_1.default.info(`🌍 ${host} is no longer public`);
|
|
190
|
-
|
|
230
|
+
tunnel?.child?.kill();
|
|
191
231
|
}
|
|
192
232
|
stopAll() {
|
|
193
233
|
for (const host of [...this.tunnels.keys()])
|
package/dist/server/index.js
CHANGED
|
@@ -67,6 +67,7 @@ async function startCentralProxy() {
|
|
|
67
67
|
stats,
|
|
68
68
|
lookup: (host) => registry.get().hosts[tunnels.hostFor(host) ?? host],
|
|
69
69
|
tunnelFor: (host) => tunnels.hostFor(host),
|
|
70
|
+
ownPorts: () => [info.httpPort, info.httpsPort],
|
|
70
71
|
remoteFor: remote_1.remoteFor,
|
|
71
72
|
localFor: remote_1.localFor,
|
|
72
73
|
remoteHosts: (name) => Object.entries(registry.get().hosts)
|
|
@@ -76,6 +77,7 @@ async function startCentralProxy() {
|
|
|
76
77
|
hub: {
|
|
77
78
|
classify: (req) => (0, hub_1.classify)(req, hub.publicHost(), registry.get().hosts, {
|
|
78
79
|
localhost: hub_config_1.default.readCached()?.localhost !== false,
|
|
80
|
+
panel: hub_config_1.default.readCached()?.panel === true,
|
|
79
81
|
blockedPorts: [info.httpPort, info.httpsPort],
|
|
80
82
|
}),
|
|
81
83
|
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