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.
@@ -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
- let info = { installed: false };
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 Host belongs to. */
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
- if (!hosts[host]?.tunnel || (retry && tunnel.status === "error"))
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
- const child = (0, child_process_1.spawn)(file, ["tunnel", "--no-autoupdate", "--config", config, "--url", this.origin()], {
159
- stdio: ["ignore", "pipe", "pipe"],
160
- windowsHide: true,
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
- const url = text.match(URL_PATTERN)?.[0];
168
- if (url && tunnel.status !== "up") {
169
- Object.assign(tunnel, { status: "up", url, publicHost: new URL(url).hostname });
170
- logger_1.default.info(`🌍 ${host} is public at ${url}`);
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, publicHost: undefined, child: 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()])
@@ -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) => {
@@ -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
- const plistPath = () => path_1.default.join(os_1.default.homedir(), "Library", "LaunchAgents", `${MAC_LABEL}.plist`);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
- // macOS (10.14+) lets unprivileged processes bind 80/443, so a user LaunchAgent is enough.
106
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
107
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
108
- <plist version="1.0">
109
- <dict>
110
- <key>Label</key>
111
- <string>${MAC_LABEL}</string>
112
- <key>ProgramArguments</key>
113
- <array>
114
- ${[command, ...args].map((arg) => `<string>${arg}</string>`).join("\n ")}
115
- </array>
116
- <key>RunAtLoad</key>
117
- <true/>
118
- <key>StandardOutPath</key>
119
- <string>${constants_1.default.paths.LOGS}</string>
120
- <key>StandardErrorPath</key>
121
- <string>${constants_1.default.paths.LOGS}</string>
122
- </dict>
123
- </plist>`;
124
- fs_1.default.mkdirSync(path_1.default.dirname(plistPath()), { recursive: true });
125
- fs_1.default.writeFileSync(plistPath(), plist);
126
- (0, child_process_1.execFileSync)("launchctl", ["load", "-w", plistPath()], { stdio: "inherit", windowsHide: true });
127
- method = "launch-agent";
128
- break;
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
- if (fs_1.default.existsSync(plistPath())) {
153
- try {
154
- (0, child_process_1.execFileSync)("launchctl", ["unload", "-w", plistPath()], { stdio: "ignore", windowsHide: true });
155
- }
156
- catch { }
157
- fs_1.default.rmSync(plistPath(), { force: true });
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(plistPath());
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:
@@ -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
- await execSudo(`security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`);
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
- await execSudo(`security delete-certificate -c "${CA_NAME}"`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
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",