fbi-proxy 1.15.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 +17 -4
- 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 +128 -20
- package/rs/lib.rs +1 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fbi-proxy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "FBI-Proxy provides easy HTTPS access to your local services with intelligent domain routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"development-tools",
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"format": "prettier --write .",
|
|
40
40
|
"lint": "prettier --check .",
|
|
41
41
|
"prepare": "husky",
|
|
42
|
+
"port-forward:install": "bun ts/install-port-forward.ts",
|
|
43
|
+
"port-forward:uninstall": "bun ts/install-port-forward.ts --uninstall",
|
|
42
44
|
"start": "bun ts/cli.ts",
|
|
43
45
|
"start:rs": "./target/release/fbi-proxy",
|
|
44
46
|
"test": "vitest run",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/rs/fbi-proxy.rs
CHANGED
|
@@ -783,12 +783,23 @@ fn spawn_routes_watcher(
|
|
|
783
783
|
});
|
|
784
784
|
}
|
|
785
785
|
|
|
786
|
+
pub struct TlsOptions {
|
|
787
|
+
/// Apex domain used for SAN entries on the self-signed cert.
|
|
788
|
+
/// Empty string falls back to `localhost` + `127.0.0.1`.
|
|
789
|
+
pub domain: String,
|
|
790
|
+
/// Directory the generated cert+key are persisted to. The same
|
|
791
|
+
/// fingerprint is reused across boots so browsers can remember
|
|
792
|
+
/// "trust this exception" once.
|
|
793
|
+
pub cert_dir: std::path::PathBuf,
|
|
794
|
+
}
|
|
795
|
+
|
|
786
796
|
pub async fn start_proxy_server(
|
|
787
797
|
host: Option<&str>,
|
|
788
798
|
port: u16,
|
|
789
799
|
domain_filter: Option<String>,
|
|
790
800
|
compiled_routes: Vec<CompiledRoute>,
|
|
791
801
|
watch_path: Option<String>,
|
|
802
|
+
tls: Option<TlsOptions>,
|
|
792
803
|
) -> Result<(), BoxError> {
|
|
793
804
|
let host = host.unwrap_or("127.0.0.1");
|
|
794
805
|
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
|
|
@@ -821,10 +832,45 @@ pub async fn start_proxy_server(
|
|
|
821
832
|
}
|
|
822
833
|
}
|
|
823
834
|
|
|
835
|
+
let acceptor = match &tls {
|
|
836
|
+
Some(opts) => {
|
|
837
|
+
let acc = fbi_proxy::tls::build_acceptor(&opts.domain, &opts.cert_dir)?;
|
|
838
|
+
// Auto-install the self-signed leaf as a system trust anchor when
|
|
839
|
+
// running with the privileges to do so. Idempotent — no-op if
|
|
840
|
+
// already trusted. If unprivileged (and untrusted), this errors and
|
|
841
|
+
// we surface a clear message rather than booting silently into
|
|
842
|
+
// "browser warnings forever" mode.
|
|
843
|
+
let cert_path = fbi_proxy::tls::cert_pem_path(&opts.domain, &opts.cert_dir);
|
|
844
|
+
match fbi_proxy::tls::install_to_system_trust(&cert_path) {
|
|
845
|
+
Ok(true) => println!(
|
|
846
|
+
"TLS: cert installed to system trust store ({})",
|
|
847
|
+
cert_path.display()
|
|
848
|
+
),
|
|
849
|
+
Ok(false) => {}
|
|
850
|
+
Err(e) => {
|
|
851
|
+
eprintln!(
|
|
852
|
+
"[tls] could not auto-install cert to system trust: {e}\n \
|
|
853
|
+
start with sudo to install, or accept the browser warning."
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
Some(acc)
|
|
858
|
+
}
|
|
859
|
+
None => None,
|
|
860
|
+
};
|
|
861
|
+
|
|
824
862
|
let listener = TcpListener::bind(addr).await?;
|
|
825
863
|
|
|
826
|
-
|
|
827
|
-
|
|
864
|
+
let scheme = if acceptor.is_some() { "https" } else { "http" };
|
|
865
|
+
info!("FBI Proxy server running on {}://{}", scheme, addr);
|
|
866
|
+
println!("FBI Proxy listening on: {}://{}", scheme, addr);
|
|
867
|
+
if let Some(opts) = &tls {
|
|
868
|
+
println!(
|
|
869
|
+
"TLS: self-signed cert at {}/{}.pem (browser warning expected — Phase 1)",
|
|
870
|
+
opts.cert_dir.display(),
|
|
871
|
+
if opts.domain.is_empty() { "localhost" } else { &opts.domain }
|
|
872
|
+
);
|
|
873
|
+
}
|
|
828
874
|
if let Some(ref domain) = domain_filter {
|
|
829
875
|
if !domain.is_empty() {
|
|
830
876
|
println!("Domain filter: Only accepting requests for *.{}", domain);
|
|
@@ -856,20 +902,35 @@ pub async fn start_proxy_server(
|
|
|
856
902
|
|
|
857
903
|
loop {
|
|
858
904
|
let (stream, _) = listener.accept().await?;
|
|
859
|
-
let io = TokioIo::new(stream);
|
|
860
905
|
let proxy = proxy.clone();
|
|
906
|
+
let acceptor = acceptor.clone();
|
|
861
907
|
|
|
862
908
|
tokio::task::spawn(async move {
|
|
863
909
|
let service = service_fn(move |req| handle_connection(req, proxy.clone()));
|
|
864
910
|
|
|
865
|
-
|
|
911
|
+
let mut builder = http1::Builder::new();
|
|
912
|
+
builder
|
|
866
913
|
.preserve_header_case(true)
|
|
867
|
-
.title_case_headers(true)
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
.await
|
|
871
|
-
|
|
872
|
-
|
|
914
|
+
.title_case_headers(true);
|
|
915
|
+
|
|
916
|
+
match acceptor {
|
|
917
|
+
Some(a) => match a.accept(stream).await {
|
|
918
|
+
Ok(tls_stream) => {
|
|
919
|
+
let io = TokioIo::new(tls_stream);
|
|
920
|
+
if let Err(err) = builder.serve_connection(io, service).with_upgrades().await {
|
|
921
|
+
error!("Error serving TLS connection: {:?}", err);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
Err(err) => {
|
|
925
|
+
error!("TLS handshake failed: {:?}", err);
|
|
926
|
+
}
|
|
927
|
+
},
|
|
928
|
+
None => {
|
|
929
|
+
let io = TokioIo::new(stream);
|
|
930
|
+
if let Err(err) = builder.serve_connection(io, service).with_upgrades().await {
|
|
931
|
+
error!("Error serving connection: {:?}", err);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
873
934
|
}
|
|
874
935
|
});
|
|
875
936
|
}
|
|
@@ -956,16 +1017,47 @@ TRY RUN:
|
|
|
956
1017
|
.env("FBI_PROXY_ROUTES")
|
|
957
1018
|
.default_value("")
|
|
958
1019
|
)
|
|
1020
|
+
.arg(
|
|
1021
|
+
Arg::new("tls")
|
|
1022
|
+
.long("tls")
|
|
1023
|
+
.help("Terminate TLS using a self-signed cert (browser warning expected — Phase 1, no system trust install). Use --port 443 with sudo for the standard HTTPS port. (env: FBI_PROXY_TLS)")
|
|
1024
|
+
.env("FBI_PROXY_TLS")
|
|
1025
|
+
.num_args(0)
|
|
1026
|
+
.action(clap::ArgAction::SetTrue)
|
|
1027
|
+
)
|
|
1028
|
+
.arg(
|
|
1029
|
+
Arg::new("cert-dir")
|
|
1030
|
+
.long("cert-dir")
|
|
1031
|
+
.value_name("DIR")
|
|
1032
|
+
.help("Directory for the self-signed cert+key (env: FBI_PROXY_CERT_DIR, default: ~/.config/fbi-proxy/certs)")
|
|
1033
|
+
.env("FBI_PROXY_CERT_DIR")
|
|
1034
|
+
.default_value("")
|
|
1035
|
+
)
|
|
959
1036
|
.get_matches();
|
|
960
1037
|
|
|
961
|
-
let
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1038
|
+
let tls_enabled = matches.get_flag("tls");
|
|
1039
|
+
|
|
1040
|
+
// Default port jumps to 443 when --tls is set unless the user explicitly
|
|
1041
|
+
// overrode --port / FBI_PROXY_PORT. Binding :443 needs sudo on most
|
|
1042
|
+
// systems; the helpful failure path is documented in the bind error.
|
|
1043
|
+
let port_source = matches.value_source("port");
|
|
1044
|
+
let port_explicit = matches!(
|
|
1045
|
+
port_source,
|
|
1046
|
+
Some(clap::parser::ValueSource::CommandLine)
|
|
1047
|
+
| Some(clap::parser::ValueSource::EnvVariable)
|
|
1048
|
+
);
|
|
1049
|
+
let port = if tls_enabled && !port_explicit {
|
|
1050
|
+
443
|
|
1051
|
+
} else {
|
|
1052
|
+
matches
|
|
1053
|
+
.get_one::<String>("port")
|
|
1054
|
+
.unwrap()
|
|
1055
|
+
.parse::<u16>()
|
|
1056
|
+
.unwrap_or_else(|_| {
|
|
1057
|
+
error!("Invalid port value, using default 2432");
|
|
1058
|
+
2432
|
|
1059
|
+
})
|
|
1060
|
+
};
|
|
969
1061
|
|
|
970
1062
|
let host = matches.get_one::<String>("host").unwrap();
|
|
971
1063
|
let domain = matches.get_one::<String>("domain").unwrap();
|
|
@@ -996,11 +1088,26 @@ TRY RUN:
|
|
|
996
1088
|
)
|
|
997
1089
|
};
|
|
998
1090
|
|
|
1091
|
+
let cert_dir_raw = matches.get_one::<String>("cert-dir").unwrap();
|
|
1092
|
+
let tls_opts = if tls_enabled {
|
|
1093
|
+
let cert_dir = if cert_dir_raw.is_empty() {
|
|
1094
|
+
fbi_proxy::tls::default_cert_dir()
|
|
1095
|
+
} else {
|
|
1096
|
+
std::path::PathBuf::from(cert_dir_raw)
|
|
1097
|
+
};
|
|
1098
|
+
Some(TlsOptions {
|
|
1099
|
+
domain: domain.clone(),
|
|
1100
|
+
cert_dir,
|
|
1101
|
+
})
|
|
1102
|
+
} else {
|
|
1103
|
+
None
|
|
1104
|
+
};
|
|
1105
|
+
|
|
999
1106
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
1000
1107
|
rt.block_on(async {
|
|
1001
1108
|
info!(
|
|
1002
|
-
"Starting FBI-Proxy on {}:{} with domain filter: {:?}",
|
|
1003
|
-
host, port, domain_filter
|
|
1109
|
+
"Starting FBI-Proxy on {}:{} with domain filter: {:?}, tls: {}",
|
|
1110
|
+
host, port, domain_filter, tls_enabled
|
|
1004
1111
|
);
|
|
1005
1112
|
if let Err(e) = start_proxy_server(
|
|
1006
1113
|
Some(host),
|
|
@@ -1008,6 +1115,7 @@ TRY RUN:
|
|
|
1008
1115
|
domain_filter,
|
|
1009
1116
|
compiled_routes,
|
|
1010
1117
|
watch_path,
|
|
1118
|
+
tls_opts,
|
|
1011
1119
|
)
|
|
1012
1120
|
.await
|
|
1013
1121
|
{
|
package/rs/lib.rs
CHANGED
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"),
|