fbi-proxy 1.14.0 → 1.16.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/README.md +18 -5
- package/dist/cli.js +2158 -1692
- package/package.json +3 -1
- package/release/fbi-proxy-linux-arm64 +0 -0
- package/release/fbi-proxy-linux-x64 +0 -0
- package/release/fbi-proxy-macos-arm64 +0 -0
- package/release/fbi-proxy-macos-x64 +0 -0
- package/release/fbi-proxy-windows-arm64.exe +0 -0
- package/release/fbi-proxy-windows-x64.exe +0 -0
- package/rs/fbi-proxy.rs +209 -20
- package/rs/lib.rs +4 -8
- package/rs/metrics.rs +111 -0
- package/rs/tls.rs +243 -0
- package/ts/auth/authConfig.ts +19 -1
- package/ts/cli.ts +148 -2
- package/ts/install-port-forward.ts +149 -0
- package/ts/setup.ts +370 -0
package/rs/tls.rs
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
//! Self-signed TLS support for `--tls` mode (Phase 1: no system trust
|
|
2
|
+
//! install). Generates a self-signed certificate for the configured
|
|
3
|
+
//! domain (with `*.<domain>` SAN) and persists it under
|
|
4
|
+
//! `~/.config/fbi-proxy/certs/` so the same fingerprint survives
|
|
5
|
+
//! restarts — browsers can "remember the exception" once.
|
|
6
|
+
//!
|
|
7
|
+
//! The browser warning is expected in this phase. Use Phase 2
|
|
8
|
+
//! (`fbi-proxy trust`) to install a local CA into the system trust
|
|
9
|
+
//! store for a clean lock-icon experience.
|
|
10
|
+
|
|
11
|
+
use std::path::{Path, PathBuf};
|
|
12
|
+
use std::sync::Arc;
|
|
13
|
+
|
|
14
|
+
use rcgen::{CertificateParams, DnType, DistinguishedName, KeyPair, SanType};
|
|
15
|
+
use tokio_rustls::TlsAcceptor;
|
|
16
|
+
use tokio_rustls::rustls::ServerConfig;
|
|
17
|
+
use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
|
|
18
|
+
|
|
19
|
+
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
|
20
|
+
|
|
21
|
+
/// Where on-disk certs live. Layout: `{base}/certs/{domain}.{pem,key}`.
|
|
22
|
+
pub fn default_cert_dir() -> PathBuf {
|
|
23
|
+
let base = std::env::var_os("XDG_CONFIG_HOME")
|
|
24
|
+
.map(PathBuf::from)
|
|
25
|
+
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
|
26
|
+
.unwrap_or_else(|| PathBuf::from("."));
|
|
27
|
+
base.join("fbi-proxy").join("certs")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Path to the cert file for a given domain (sibling `.key` lives at
|
|
31
|
+
/// the same stem). Use this when you need to install the cert into a
|
|
32
|
+
/// system trust store after `build_acceptor` has materialized it.
|
|
33
|
+
pub fn cert_pem_path(domain: &str, cert_dir: &Path) -> PathBuf {
|
|
34
|
+
let slug = if domain.is_empty() { "localhost" } else { domain };
|
|
35
|
+
cert_dir.join(format!("{slug}.pem"))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Whether the given cert is currently a trusted anchor on this
|
|
39
|
+
/// system. Returns `false` if the check itself can't be performed
|
|
40
|
+
/// (unsupported platform, missing tool) — callers should treat that
|
|
41
|
+
/// as "no, attempt install."
|
|
42
|
+
pub fn is_trusted(cert_path: &Path) -> bool {
|
|
43
|
+
#[cfg(target_os = "macos")]
|
|
44
|
+
{
|
|
45
|
+
std::process::Command::new("security")
|
|
46
|
+
.args(["verify-cert", "-c"])
|
|
47
|
+
.arg(cert_path)
|
|
48
|
+
.stdout(std::process::Stdio::null())
|
|
49
|
+
.stderr(std::process::Stdio::null())
|
|
50
|
+
.status()
|
|
51
|
+
.map(|s| s.success())
|
|
52
|
+
.unwrap_or(false)
|
|
53
|
+
}
|
|
54
|
+
#[cfg(not(target_os = "macos"))]
|
|
55
|
+
{
|
|
56
|
+
let _ = cert_path;
|
|
57
|
+
false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Install `cert_path` as a trusted root anchor in the system trust
|
|
62
|
+
/// store. Idempotent — checks `is_trusted` first and returns `Ok(false)`
|
|
63
|
+
/// if no install was performed.
|
|
64
|
+
///
|
|
65
|
+
/// Requires root on macOS (writes to `/Library/Keychains/System.keychain`).
|
|
66
|
+
/// On other platforms this is a no-op for now (Linux / Windows is a
|
|
67
|
+
/// follow-up — see TODO.md).
|
|
68
|
+
pub fn install_to_system_trust(cert_path: &Path) -> Result<bool, BoxError> {
|
|
69
|
+
if is_trusted(cert_path) {
|
|
70
|
+
return Ok(false);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#[cfg(target_os = "macos")]
|
|
74
|
+
{
|
|
75
|
+
log::info!("installing {} to System.keychain", cert_path.display());
|
|
76
|
+
let status = std::process::Command::new("security")
|
|
77
|
+
.args([
|
|
78
|
+
"add-trusted-cert",
|
|
79
|
+
"-d",
|
|
80
|
+
"-r",
|
|
81
|
+
"trustRoot",
|
|
82
|
+
"-k",
|
|
83
|
+
"/Library/Keychains/System.keychain",
|
|
84
|
+
])
|
|
85
|
+
.arg(cert_path)
|
|
86
|
+
.status()?;
|
|
87
|
+
if !status.success() {
|
|
88
|
+
return Err(format!(
|
|
89
|
+
"security add-trusted-cert failed (exit {:?}); needs root (sudo)",
|
|
90
|
+
status.code(),
|
|
91
|
+
)
|
|
92
|
+
.into());
|
|
93
|
+
}
|
|
94
|
+
Ok(true)
|
|
95
|
+
}
|
|
96
|
+
#[cfg(not(target_os = "macos"))]
|
|
97
|
+
{
|
|
98
|
+
let _ = cert_path;
|
|
99
|
+
log::warn!("auto-trust-install: only macOS supported in this build");
|
|
100
|
+
Ok(false)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Build a `TlsAcceptor` for the given domain, reusing a persisted
|
|
105
|
+
/// cert if one exists or generating + writing a fresh one if not.
|
|
106
|
+
///
|
|
107
|
+
/// `domain` is the apex (e.g. `"fbi.com"`); the cert SAN includes both
|
|
108
|
+
/// the apex and `*.{domain}` so any subdomain validates. If `domain`
|
|
109
|
+
/// is empty or `"localhost"`, only `localhost` + `127.0.0.1` are
|
|
110
|
+
/// covered.
|
|
111
|
+
pub fn build_acceptor(domain: &str, cert_dir: &Path) -> Result<TlsAcceptor, BoxError> {
|
|
112
|
+
let (cert_pem, key_pem) = load_or_generate(domain, cert_dir)?;
|
|
113
|
+
|
|
114
|
+
let cert_chain: Vec<CertificateDer<'static>> = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
|
|
115
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
116
|
+
let key = PrivateKeyDer::from_pem_slice(key_pem.as_bytes())?;
|
|
117
|
+
|
|
118
|
+
let config = ServerConfig::builder()
|
|
119
|
+
.with_no_client_auth()
|
|
120
|
+
.with_single_cert(cert_chain, key)?;
|
|
121
|
+
|
|
122
|
+
Ok(TlsAcceptor::from(Arc::new(config)))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fn load_or_generate(domain: &str, cert_dir: &Path) -> Result<(String, String), BoxError> {
|
|
126
|
+
let slug = if domain.is_empty() { "localhost" } else { domain };
|
|
127
|
+
let cert_path = cert_dir.join(format!("{slug}.pem"));
|
|
128
|
+
let key_path = cert_dir.join(format!("{slug}.key"));
|
|
129
|
+
|
|
130
|
+
if cert_path.exists() && key_path.exists() {
|
|
131
|
+
let cert = std::fs::read_to_string(&cert_path)?;
|
|
132
|
+
let key = std::fs::read_to_string(&key_path)?;
|
|
133
|
+
return Ok((cert, key));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let (cert_pem, key_pem) = generate_self_signed(domain)?;
|
|
137
|
+
std::fs::create_dir_all(cert_dir)?;
|
|
138
|
+
std::fs::write(&cert_path, &cert_pem)?;
|
|
139
|
+
// 0600 on the key — std::fs::write opens 0644 by default
|
|
140
|
+
write_private(&key_path, key_pem.as_bytes())?;
|
|
141
|
+
|
|
142
|
+
Ok((cert_pem, key_pem))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#[cfg(unix)]
|
|
146
|
+
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
|
147
|
+
use std::io::Write;
|
|
148
|
+
use std::os::unix::fs::OpenOptionsExt;
|
|
149
|
+
let mut f = std::fs::OpenOptions::new()
|
|
150
|
+
.write(true)
|
|
151
|
+
.create(true)
|
|
152
|
+
.truncate(true)
|
|
153
|
+
.mode(0o600)
|
|
154
|
+
.open(path)?;
|
|
155
|
+
f.write_all(bytes)?;
|
|
156
|
+
Ok(())
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[cfg(not(unix))]
|
|
160
|
+
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
|
161
|
+
std::fs::write(path, bytes)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// Generate a SAN-only self-signed cert valid for ~1 year. Returns
|
|
165
|
+
/// `(cert_pem, key_pem)`. The Common Name is intentionally left blank
|
|
166
|
+
/// — modern browsers ignore CN and only honor SAN entries.
|
|
167
|
+
pub fn generate_self_signed(domain: &str) -> Result<(String, String), BoxError> {
|
|
168
|
+
let mut sans: Vec<SanType> = Vec::new();
|
|
169
|
+
if domain.is_empty() || domain == "localhost" {
|
|
170
|
+
sans.push(SanType::DnsName("localhost".try_into()?));
|
|
171
|
+
sans.push(SanType::IpAddress("127.0.0.1".parse()?));
|
|
172
|
+
} else {
|
|
173
|
+
sans.push(SanType::DnsName(domain.try_into()?));
|
|
174
|
+
sans.push(SanType::DnsName(format!("*.{domain}").try_into()?));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let mut params = CertificateParams::default();
|
|
178
|
+
params.subject_alt_names = sans;
|
|
179
|
+
|
|
180
|
+
// Browsers ignore CN, but a non-empty DN avoids some tooling
|
|
181
|
+
// warnings. Use OrganizationName so the CN stays empty.
|
|
182
|
+
let mut dn = DistinguishedName::new();
|
|
183
|
+
dn.push(DnType::OrganizationName, "fbi-proxy (self-signed)");
|
|
184
|
+
params.distinguished_name = dn;
|
|
185
|
+
|
|
186
|
+
let now = time::OffsetDateTime::now_utc();
|
|
187
|
+
params.not_before = now - time::Duration::days(1);
|
|
188
|
+
params.not_after = now + time::Duration::days(365);
|
|
189
|
+
|
|
190
|
+
let key_pair = KeyPair::generate()?;
|
|
191
|
+
let cert = params.self_signed(&key_pair)?;
|
|
192
|
+
|
|
193
|
+
Ok((cert.pem(), key_pair.serialize_pem()))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#[cfg(test)]
|
|
197
|
+
mod tests {
|
|
198
|
+
use super::*;
|
|
199
|
+
|
|
200
|
+
#[test]
|
|
201
|
+
fn generates_pem_with_domain_san() {
|
|
202
|
+
let (cert, key) = generate_self_signed("fbi.com").unwrap();
|
|
203
|
+
assert!(cert.contains("BEGIN CERTIFICATE"));
|
|
204
|
+
assert!(key.contains("BEGIN PRIVATE KEY"));
|
|
205
|
+
|
|
206
|
+
// Parse the cert and check SAN entries
|
|
207
|
+
let der = CertificateDer::pem_slice_iter(cert.as_bytes())
|
|
208
|
+
.next()
|
|
209
|
+
.unwrap()
|
|
210
|
+
.unwrap();
|
|
211
|
+
// We don't fully x509-parse here — but the cert+key should be
|
|
212
|
+
// accepted by rustls' single-cert builder, which is what the
|
|
213
|
+
// real server uses. That's the real-world contract.
|
|
214
|
+
let key_der = PrivateKeyDer::from_pem_slice(key.as_bytes()).unwrap();
|
|
215
|
+
let config = ServerConfig::builder()
|
|
216
|
+
.with_no_client_auth()
|
|
217
|
+
.with_single_cert(vec![der], key_der);
|
|
218
|
+
assert!(config.is_ok(), "rustls should accept generated cert+key");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#[test]
|
|
222
|
+
fn generates_for_localhost_fallback() {
|
|
223
|
+
let (cert, _key) = generate_self_signed("").unwrap();
|
|
224
|
+
assert!(cert.contains("BEGIN CERTIFICATE"));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#[test]
|
|
228
|
+
fn load_or_generate_round_trips_persisted_certs() {
|
|
229
|
+
let tmp = std::env::temp_dir().join(format!(
|
|
230
|
+
"fbi-tls-test-{}",
|
|
231
|
+
std::process::id()
|
|
232
|
+
));
|
|
233
|
+
let _ = std::fs::remove_dir_all(&tmp);
|
|
234
|
+
|
|
235
|
+
let (cert1, key1) = load_or_generate("test.dev", &tmp).unwrap();
|
|
236
|
+
// Second call should return the same content (loaded from disk)
|
|
237
|
+
let (cert2, key2) = load_or_generate("test.dev", &tmp).unwrap();
|
|
238
|
+
assert_eq!(cert1, cert2);
|
|
239
|
+
assert_eq!(key1, key2);
|
|
240
|
+
|
|
241
|
+
let _ = std::fs::remove_dir_all(&tmp);
|
|
242
|
+
}
|
|
243
|
+
}
|
package/ts/auth/authConfig.ts
CHANGED
|
@@ -10,15 +10,21 @@ export type FirebaseSubConfig = {
|
|
|
10
10
|
authDomain?: string;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
export type LocalSubConfig = {
|
|
14
|
+
email: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
13
18
|
export type AuthConfigShape = {
|
|
14
19
|
version: 1;
|
|
15
20
|
domain: string;
|
|
16
21
|
cookieDomain: string;
|
|
17
22
|
ssoHost: string;
|
|
18
|
-
provider: "google" | "firebase" | "snolab";
|
|
23
|
+
provider: "google" | "firebase" | "snolab" | "local";
|
|
19
24
|
clientId?: string;
|
|
20
25
|
clientSecret?: string;
|
|
21
26
|
firebase?: FirebaseSubConfig;
|
|
27
|
+
local?: LocalSubConfig;
|
|
22
28
|
sessionSecret: string;
|
|
23
29
|
allowlist: {
|
|
24
30
|
emails?: string[];
|
|
@@ -63,10 +69,16 @@ export function configFromEnv(domain: string): AuthConfigShape | null {
|
|
|
63
69
|
(process.env.FBI_AUTH_PROVIDER as AuthConfigShape["provider"]) ?? "google";
|
|
64
70
|
const clientId = process.env.FBI_AUTH_CLIENT_ID;
|
|
65
71
|
const firebaseProjectId = process.env.FBI_AUTH_FIREBASE_PROJECT_ID;
|
|
72
|
+
const localUser = process.env.FBI_AUTH_LOCAL_USER;
|
|
66
73
|
|
|
67
74
|
if (provider === "firebase") {
|
|
68
75
|
if (!firebaseProjectId) return null;
|
|
76
|
+
} else if (provider === "snolab") {
|
|
77
|
+
// snolab uses baked-in defaults; nothing required beyond the provider name
|
|
78
|
+
} else if (provider === "local") {
|
|
79
|
+
if (!localUser) return null;
|
|
69
80
|
} else {
|
|
81
|
+
// google (default)
|
|
70
82
|
if (!clientId) return null;
|
|
71
83
|
}
|
|
72
84
|
|
|
@@ -86,6 +98,12 @@ export function configFromEnv(domain: string): AuthConfigShape | null {
|
|
|
86
98
|
authDomain: process.env.FBI_AUTH_FIREBASE_AUTH_DOMAIN,
|
|
87
99
|
}
|
|
88
100
|
: undefined,
|
|
101
|
+
local: localUser
|
|
102
|
+
? {
|
|
103
|
+
email: localUser,
|
|
104
|
+
name: process.env.FBI_AUTH_LOCAL_NAME,
|
|
105
|
+
}
|
|
106
|
+
: undefined,
|
|
89
107
|
sessionSecret:
|
|
90
108
|
process.env.FBI_AUTH_SESSION_SECRET ??
|
|
91
109
|
randomBytes(32).toString("base64url"),
|
package/ts/cli.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
2
4
|
import getPort from "get-port";
|
|
3
5
|
import hotMemo from "hot-memo";
|
|
4
6
|
import path from "path";
|
|
@@ -29,6 +31,41 @@ import {
|
|
|
29
31
|
} from "./auth/spawnCaddy";
|
|
30
32
|
|
|
31
33
|
const originalCwd = process.cwd();
|
|
34
|
+
|
|
35
|
+
// Subcommand routing & default behavior
|
|
36
|
+
//
|
|
37
|
+
// `fbi-proxy setup` (or `fbi-proxy` with no escape-hatch flags) runs the
|
|
38
|
+
// one-shot setup orchestrator: registers an oxmgr daemon, generates+trusts
|
|
39
|
+
// a TLS cert, installs a pf :443→:8443 forward, and verifies https://<domain>.
|
|
40
|
+
//
|
|
41
|
+
// Old foreground modes (Caddy, dev, raw TLS, auth wizard) are preserved by
|
|
42
|
+
// flags below — explicit opt-in keeps the existing surface intact for users
|
|
43
|
+
// who rely on it.
|
|
44
|
+
{
|
|
45
|
+
const rawArgs = hideBin(process.argv);
|
|
46
|
+
const firstPositional = rawArgs.find((a) => !a.startsWith("-"));
|
|
47
|
+
const FOREGROUND_FLAGS = [
|
|
48
|
+
"--dev",
|
|
49
|
+
"--with-caddy",
|
|
50
|
+
"--with-auth",
|
|
51
|
+
"--tls",
|
|
52
|
+
"--reconfigure",
|
|
53
|
+
];
|
|
54
|
+
const wantsForeground = rawArgs.some((a) =>
|
|
55
|
+
FOREGROUND_FLAGS.some((f) => a === f || a.startsWith(`${f}=`)),
|
|
56
|
+
);
|
|
57
|
+
const hasExplicitPortEnv = !!process.env.FBI_PROXY_PORT;
|
|
58
|
+
const isSetupCmd = firstPositional === "setup";
|
|
59
|
+
const isDefault = !firstPositional && !wantsForeground && !hasExplicitPortEnv;
|
|
60
|
+
|
|
61
|
+
if (isSetupCmd || isDefault) {
|
|
62
|
+
const { runSetup } = await import("./setup");
|
|
63
|
+
const passArgs = rawArgs.filter((a) => a !== "setup");
|
|
64
|
+
await runSetup(passArgs, { originalCwd });
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
32
69
|
process.chdir(path.resolve(import.meta.dir, ".."));
|
|
33
70
|
|
|
34
71
|
const argv = await yargs(hideBin(process.argv))
|
|
@@ -71,12 +108,33 @@ const argv = await yargs(hideBin(process.argv))
|
|
|
71
108
|
description:
|
|
72
109
|
"TLS strategy for --with-caddy. 'auto' uses ACME (Let's Encrypt); 'internal' uses Caddy's local CA. Defaults to 'internal' for fbi.com, 'auto' otherwise.",
|
|
73
110
|
})
|
|
111
|
+
.option("tls", {
|
|
112
|
+
type: "boolean",
|
|
113
|
+
default: false,
|
|
114
|
+
description:
|
|
115
|
+
"Terminate TLS in the Rust proxy using a self-signed cert (no Caddy). Browser warning expected (Phase 1 — no system trust install). Use with --port 443 + sudo to serve standard HTTPS.",
|
|
116
|
+
})
|
|
74
117
|
.help().argv;
|
|
75
118
|
|
|
76
|
-
|
|
119
|
+
if (argv.tls && argv["with-caddy"]) {
|
|
120
|
+
console.error(
|
|
121
|
+
"[fbi-proxy] --tls and --with-caddy are mutually exclusive (Caddy already terminates TLS).",
|
|
122
|
+
);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
77
125
|
|
|
78
126
|
const FBI_PROXY_PORT =
|
|
79
|
-
process.env.FBI_PROXY_PORT ||
|
|
127
|
+
process.env.FBI_PROXY_PORT ||
|
|
128
|
+
(argv.tls ? "443" : String(await getPort({ port: 2432 })));
|
|
129
|
+
|
|
130
|
+
if (argv.tls) {
|
|
131
|
+
await ensureRootIfTlsNeedsIt({
|
|
132
|
+
domain: argv.domain,
|
|
133
|
+
port: Number(FBI_PROXY_PORT),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log("Preparing Binaries");
|
|
80
138
|
|
|
81
139
|
const proxyProcess = await hotMemo(async () => {
|
|
82
140
|
const proxy = await getFbiProxyBinary({ originalCwd });
|
|
@@ -85,6 +143,17 @@ const proxyProcess = await hotMemo(async () => {
|
|
|
85
143
|
env: {
|
|
86
144
|
...process.env,
|
|
87
145
|
FBI_PROXY_PORT,
|
|
146
|
+
...(argv.tls
|
|
147
|
+
? {
|
|
148
|
+
FBI_PROXY_TLS: "true",
|
|
149
|
+
FBI_PROXY_DOMAIN: argv.domain,
|
|
150
|
+
// Forward CERT_DIR if the sudo wrapper set it (so the elevated
|
|
151
|
+
// Rust binary writes to the original user's $HOME, not /var/root)
|
|
152
|
+
...(process.env.FBI_PROXY_CERT_DIR
|
|
153
|
+
? { FBI_PROXY_CERT_DIR: process.env.FBI_PROXY_CERT_DIR }
|
|
154
|
+
: {}),
|
|
155
|
+
}
|
|
156
|
+
: {}),
|
|
88
157
|
},
|
|
89
158
|
})`${proxy}`.process;
|
|
90
159
|
|
|
@@ -136,6 +205,83 @@ process.on("uncaughtException", (err) => {
|
|
|
136
205
|
exit();
|
|
137
206
|
});
|
|
138
207
|
|
|
208
|
+
async function ensureRootIfTlsNeedsIt(opts: {
|
|
209
|
+
domain: string;
|
|
210
|
+
port: number;
|
|
211
|
+
}): Promise<void> {
|
|
212
|
+
if (process.platform !== "darwin") {
|
|
213
|
+
// Linux/Windows trust install is a follow-up. The Rust side prints a
|
|
214
|
+
// friendly fallback message if untrusted.
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (process.getuid?.() === 0) return;
|
|
218
|
+
|
|
219
|
+
const home = process.env.HOME ?? "";
|
|
220
|
+
const certDir =
|
|
221
|
+
process.env.FBI_PROXY_CERT_DIR ??
|
|
222
|
+
path.join(home, ".config/fbi-proxy/certs");
|
|
223
|
+
const slug = opts.domain || "localhost";
|
|
224
|
+
const certPath = path.join(certDir, `${slug}.pem`);
|
|
225
|
+
|
|
226
|
+
const needsPortBind = opts.port < 1024;
|
|
227
|
+
const certMissing = !existsSync(certPath);
|
|
228
|
+
const certUntrusted = !certMissing && !isMacosCertTrusted(certPath);
|
|
229
|
+
const needsTrustInstall = certMissing || certUntrusted;
|
|
230
|
+
|
|
231
|
+
if (!needsPortBind && !needsTrustInstall) return;
|
|
232
|
+
|
|
233
|
+
const reasons = [
|
|
234
|
+
needsPortBind && `bind :${opts.port}`,
|
|
235
|
+
needsTrustInstall && "install cert to system trust",
|
|
236
|
+
]
|
|
237
|
+
.filter(Boolean)
|
|
238
|
+
.join(" + ");
|
|
239
|
+
console.log(`[fbi-proxy] --tls needs root to: ${reasons}`);
|
|
240
|
+
|
|
241
|
+
// Preserve HOME and CERT_DIR so the elevated process writes cert/auth
|
|
242
|
+
// files into the original user's directory, not /var/root.
|
|
243
|
+
const sudoArgs = [
|
|
244
|
+
`HOME=${home}`,
|
|
245
|
+
`FBI_PROXY_CERT_DIR=${certDir}`,
|
|
246
|
+
process.execPath,
|
|
247
|
+
...process.argv.slice(1),
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
// Prefer terminal sudo when a TTY is attached; otherwise fall back to the
|
|
251
|
+
// macOS GUI authentication dialog via osascript so non-TTY contexts (agent
|
|
252
|
+
// shells, oxmgr-spawned children) can still escalate with a single password
|
|
253
|
+
// prompt instead of erroring out with "a terminal is required".
|
|
254
|
+
const hasTty = !!process.stdin.isTTY;
|
|
255
|
+
if (hasTty) {
|
|
256
|
+
console.log(
|
|
257
|
+
`[fbi-proxy] re-launching via sudo (terminal password prompt)…`,
|
|
258
|
+
);
|
|
259
|
+
const result = spawnSync("sudo", sudoArgs, { stdio: "inherit" });
|
|
260
|
+
process.exit(result.status ?? 1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
console.log(`[fbi-proxy] no TTY — opening macOS authentication dialog…`);
|
|
264
|
+
const shellCmd = sudoArgs.map(shellQuote).join(" ");
|
|
265
|
+
const script = `do shell script ${appleScriptQuote(shellCmd)} with administrator privileges`;
|
|
266
|
+
const result = spawnSync("osascript", ["-e", script], { stdio: "inherit" });
|
|
267
|
+
process.exit(result.status ?? 1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function shellQuote(s: string): string {
|
|
271
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function appleScriptQuote(s: string): string {
|
|
275
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function isMacosCertTrusted(certPath: string): boolean {
|
|
279
|
+
const result = spawnSync("security", ["verify-cert", "-c", certPath], {
|
|
280
|
+
stdio: "ignore",
|
|
281
|
+
});
|
|
282
|
+
return result.status === 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
139
285
|
async function startFbiAuth(opts: {
|
|
140
286
|
domain: string;
|
|
141
287
|
reconfigure: boolean;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import yargs from "yargs";
|
|
5
|
+
import { hideBin } from "yargs/helpers";
|
|
6
|
+
|
|
7
|
+
const ANCHOR_NAME = "com.snomiao.fbi-proxy";
|
|
8
|
+
const ANCHOR_FILE = `/etc/pf.anchors/${ANCHOR_NAME}`;
|
|
9
|
+
const PLIST_FILE = `/Library/LaunchDaemons/${ANCHOR_NAME}-pf.plist`;
|
|
10
|
+
|
|
11
|
+
const argv = await yargs(hideBin(process.argv))
|
|
12
|
+
.option("from", {
|
|
13
|
+
type: "number",
|
|
14
|
+
default: 443,
|
|
15
|
+
description: "Public-facing port to redirect (privileged)",
|
|
16
|
+
})
|
|
17
|
+
.option("to", {
|
|
18
|
+
type: "number",
|
|
19
|
+
default: 8443,
|
|
20
|
+
description: "Backend port the oxmgr-managed proxy listens on",
|
|
21
|
+
})
|
|
22
|
+
.option("uninstall", {
|
|
23
|
+
type: "boolean",
|
|
24
|
+
default: false,
|
|
25
|
+
description: "Remove the pf rule and LaunchDaemon",
|
|
26
|
+
})
|
|
27
|
+
.help().argv;
|
|
28
|
+
|
|
29
|
+
if (process.platform !== "darwin") {
|
|
30
|
+
console.error("install-port-forward: macOS only (pf rules)");
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (argv.uninstall) {
|
|
35
|
+
const script = [
|
|
36
|
+
`launchctl unload "${PLIST_FILE}" 2>/dev/null`,
|
|
37
|
+
`rm -f "${PLIST_FILE}" "${ANCHOR_FILE}"`,
|
|
38
|
+
`pfctl -a ${ANCHOR_NAME} -F all 2>/dev/null`,
|
|
39
|
+
"echo uninstalled",
|
|
40
|
+
].join("\n");
|
|
41
|
+
runAsRoot(script);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// pf-rdr is loopback-only here because fbi.com resolves to 127.0.0.1 via local
|
|
46
|
+
// DNS. If you ever expose this on a real interface, widen the rule.
|
|
47
|
+
const anchorContent = `rdr pass on lo0 inet proto tcp from any to any port ${argv.from} -> 127.0.0.1 port ${argv.to}\n`;
|
|
48
|
+
|
|
49
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
50
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
51
|
+
<plist version="1.0">
|
|
52
|
+
<dict>
|
|
53
|
+
<key>Label</key><string>${ANCHOR_NAME}-pf</string>
|
|
54
|
+
<key>ProgramArguments</key>
|
|
55
|
+
<array>
|
|
56
|
+
<string>/bin/sh</string>
|
|
57
|
+
<string>-c</string>
|
|
58
|
+
<string>/sbin/pfctl -E 2>/dev/null; /sbin/pfctl -a ${ANCHOR_NAME} -f ${ANCHOR_FILE}</string>
|
|
59
|
+
</array>
|
|
60
|
+
<key>RunAtLoad</key><true/>
|
|
61
|
+
<key>StandardOutPath</key><string>/var/log/${ANCHOR_NAME}-pf.out.log</string>
|
|
62
|
+
<key>StandardErrorPath</key><string>/var/log/${ANCHOR_NAME}-pf.err.log</string>
|
|
63
|
+
</dict>
|
|
64
|
+
</plist>
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
if (alreadyInstalledFor(argv.from, argv.to)) {
|
|
68
|
+
console.log(
|
|
69
|
+
`pf forward :${argv.from} -> :${argv.to} already installed and active`,
|
|
70
|
+
);
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(
|
|
75
|
+
`installing pf forward :${argv.from} -> :${argv.to} via macOS auth dialog…`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Single elevated shell — writes both files, loads the LaunchDaemon, and
|
|
79
|
+
// applies the rule immediately so we don't wait for a reboot.
|
|
80
|
+
const heredoc = (path: string, body: string) =>
|
|
81
|
+
`cat > "${path}" <<'__FBI_PROXY_PF_EOF__'\n${body}__FBI_PROXY_PF_EOF__`;
|
|
82
|
+
|
|
83
|
+
const script = [
|
|
84
|
+
heredoc(ANCHOR_FILE, anchorContent),
|
|
85
|
+
`chmod 644 "${ANCHOR_FILE}"`,
|
|
86
|
+
heredoc(PLIST_FILE, plistContent),
|
|
87
|
+
`chown root:wheel "${PLIST_FILE}"`,
|
|
88
|
+
`chmod 644 "${PLIST_FILE}"`,
|
|
89
|
+
`launchctl unload "${PLIST_FILE}" 2>/dev/null || true`,
|
|
90
|
+
`launchctl load -w "${PLIST_FILE}"`,
|
|
91
|
+
`/sbin/pfctl -E 2>/dev/null || true`,
|
|
92
|
+
`/sbin/pfctl -a ${ANCHOR_NAME} -f "${ANCHOR_FILE}"`,
|
|
93
|
+
`echo OK`,
|
|
94
|
+
].join("\n");
|
|
95
|
+
|
|
96
|
+
const status = runAsRoot(script);
|
|
97
|
+
if (status !== 0) {
|
|
98
|
+
console.error(`pf forward install failed (exit ${status})`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (alreadyInstalledFor(argv.from, argv.to)) {
|
|
103
|
+
console.log(
|
|
104
|
+
`pf forward :${argv.from} -> :${argv.to} active. LaunchDaemon: ${PLIST_FILE}`,
|
|
105
|
+
);
|
|
106
|
+
} else {
|
|
107
|
+
console.warn(
|
|
108
|
+
`pf forward installed but verification failed — check 'sudo pfctl -a ${ANCHOR_NAME} -s nat'`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function runAsRoot(script: string): number {
|
|
113
|
+
const hasTty = !!process.stdin.isTTY;
|
|
114
|
+
if (hasTty && process.getuid?.() !== 0) {
|
|
115
|
+
const result = spawnSync("sudo", ["sh", "-c", script], {
|
|
116
|
+
stdio: "inherit",
|
|
117
|
+
});
|
|
118
|
+
return result.status ?? 1;
|
|
119
|
+
}
|
|
120
|
+
if (process.getuid?.() === 0) {
|
|
121
|
+
const result = spawnSync("sh", ["-c", script], { stdio: "inherit" });
|
|
122
|
+
return result.status ?? 1;
|
|
123
|
+
}
|
|
124
|
+
// GUI password dialog — works without TTY (Claude Code, oxmgr children, etc.)
|
|
125
|
+
const osascript = `do shell script ${appleScriptQuote(script)} with administrator privileges`;
|
|
126
|
+
const result = spawnSync("osascript", ["-e", osascript], {
|
|
127
|
+
stdio: "inherit",
|
|
128
|
+
});
|
|
129
|
+
return result.status ?? 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function alreadyInstalledFor(from: number, to: number): boolean {
|
|
133
|
+
if (!existsSync(ANCHOR_FILE) || !existsSync(PLIST_FILE)) return false;
|
|
134
|
+
// -s nat needs root to read the runtime rule table on most setups
|
|
135
|
+
const probe = spawnSync(
|
|
136
|
+
"sudo",
|
|
137
|
+
["-n", "/sbin/pfctl", "-a", ANCHOR_NAME, "-s", "nat"],
|
|
138
|
+
{
|
|
139
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
if (probe.status !== 0) return false;
|
|
143
|
+
const out = probe.stdout?.toString() ?? "";
|
|
144
|
+
return out.includes(`port = ${from}`) && out.includes(`port ${to}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function appleScriptQuote(s: string): string {
|
|
148
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
149
|
+
}
|