add TLS, service persistence, blocking panel, query types
- Local TLS: auto-generated CA + per-service certs (explicit SANs, not wildcards — browsers reject *.numa under single-label TLDs). HTTPS proxy on :443 via rustls/tokio-rustls. `numa install` trusts CA in macOS Keychain / Linux ca-certificates. - Service persistence: user-added services saved to ~/.config/numa/services.json, survive restarts. - Blocking panel: renamed "Check Domain" to "Blocking" with sources display, allowlist management UI, unpause button. - Query types: recognize SOA, PTR, TXT, SRV, HTTPS (type 65) instead of logging as UNKNOWN. - Blocklist gzip: reqwest now decompresses gzip responses from CDNs. - Unified config_dir() in lib.rs for consistent path resolution under sudo and launchd. TLS certs use /usr/local/var/numa/ (writable as root daemon). - Dashboard UX: panel subtitles differentiating overrides vs services, better placeholders, proxy route display, 600px query log height. - Deploy: make deploy handles build+copy+codesign+restart cycle. - Demo: scripts/record-demo.sh for recording hero GIF with CDP. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
|
||||
.route("/blocking/stats", get(blocking_stats))
|
||||
.route("/blocking/toggle", put(blocking_toggle))
|
||||
.route("/blocking/pause", post(blocking_pause))
|
||||
.route("/blocking/unpause", post(blocking_unpause))
|
||||
.route("/blocking/allowlist", get(blocking_allowlist))
|
||||
.route("/blocking/allowlist", post(blocking_allowlist_add))
|
||||
.route("/blocking/check/{domain}", get(blocking_check))
|
||||
@@ -536,6 +537,11 @@ async fn blocking_pause(
|
||||
Json(serde_json::json!({ "paused_minutes": req.minutes }))
|
||||
}
|
||||
|
||||
async fn blocking_unpause(State(ctx): State<Arc<ServerCtx>>) -> Json<serde_json::Value> {
|
||||
ctx.blocklist.lock().unwrap().unpause();
|
||||
Json(serde_json::json!({ "paused": false }))
|
||||
}
|
||||
|
||||
async fn blocking_check(
|
||||
State(ctx): State<Arc<ServerCtx>>,
|
||||
Path(domain): Path<String>,
|
||||
|
||||
@@ -161,6 +161,10 @@ impl BlocklistStore {
|
||||
self.paused_until = Some(Instant::now() + std::time::Duration::from_secs(seconds));
|
||||
}
|
||||
|
||||
pub fn unpause(&mut self) {
|
||||
self.paused_until = None;
|
||||
}
|
||||
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.paused_until
|
||||
.map(|until| Instant::now() < until)
|
||||
@@ -233,6 +237,7 @@ pub fn parse_blocklist(text: &str) -> HashSet<String> {
|
||||
pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.gzip(true)
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
|
||||
@@ -166,6 +166,8 @@ pub struct ProxyConfig {
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_proxy_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_proxy_tls_port")]
|
||||
pub tls_port: u16,
|
||||
#[serde(default = "default_proxy_tld")]
|
||||
pub tld: String,
|
||||
}
|
||||
@@ -175,6 +177,7 @@ impl Default for ProxyConfig {
|
||||
ProxyConfig {
|
||||
enabled: default_proxy_enabled(),
|
||||
port: default_proxy_port(),
|
||||
tls_port: default_proxy_tls_port(),
|
||||
tld: default_proxy_tld(),
|
||||
}
|
||||
}
|
||||
@@ -186,6 +189,9 @@ fn default_proxy_enabled() -> bool {
|
||||
fn default_proxy_port() -> u16 {
|
||||
80
|
||||
}
|
||||
fn default_proxy_tls_port() -> u16 {
|
||||
443
|
||||
}
|
||||
fn default_proxy_tld() -> String {
|
||||
"numa".to_string()
|
||||
}
|
||||
|
||||
27
src/lib.rs
27
src/lib.rs
@@ -14,7 +14,34 @@ pub mod question;
|
||||
pub mod record;
|
||||
pub mod service_store;
|
||||
pub mod stats;
|
||||
pub mod tls;
|
||||
pub mod system_dns;
|
||||
|
||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Shared config directory: ~/.config/numa/
|
||||
/// Handles sudo (uses SUDO_USER) and launchd (falls back to /usr/local/var/numa/).
|
||||
pub fn config_dir() -> std::path::PathBuf {
|
||||
// When run via sudo, SUDO_USER has the real user
|
||||
if let Ok(user) = std::env::var("SUDO_USER") {
|
||||
let home = if cfg!(target_os = "macos") {
|
||||
format!("/Users/{}", user)
|
||||
} else {
|
||||
format!("/home/{}", user)
|
||||
};
|
||||
return std::path::PathBuf::from(home).join(".config").join("numa");
|
||||
}
|
||||
|
||||
// Normal user (not root)
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let path = std::path::PathBuf::from(&home);
|
||||
// /var/root on macOS is read-only (SIP), use /usr/local/var/numa instead
|
||||
if !home.starts_with("/var/root") && !home.starts_with("/root") {
|
||||
return path.join(".config").join("numa");
|
||||
}
|
||||
}
|
||||
|
||||
// Running as root daemon (launchd/systemd) — use system-wide path
|
||||
std::path::PathBuf::from("/usr/local/var/numa")
|
||||
}
|
||||
|
||||
42
src/main.rs
42
src/main.rs
@@ -104,12 +104,13 @@ async fn main() -> numa::Result<()> {
|
||||
blocklist.set_enabled(false);
|
||||
}
|
||||
|
||||
// Build service store from config, always include numa dashboard
|
||||
// Build service store: config services + persisted user services
|
||||
let mut service_store = ServiceStore::new();
|
||||
service_store.insert("numa", config.server.api_port);
|
||||
service_store.insert_from_config("numa", config.server.api_port);
|
||||
for svc in &config.services {
|
||||
service_store.insert(&svc.name, svc.target_port);
|
||||
service_store.insert_from_config(&svc.name, svc.target_port);
|
||||
}
|
||||
service_store.load_persisted();
|
||||
|
||||
let forwarding_rules = system_dns.forwarding_rules;
|
||||
|
||||
@@ -150,8 +151,12 @@ async fn main() -> numa::Result<()> {
|
||||
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mBlocking\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m",
|
||||
if config.blocking.enabled { format!("{} lists", config.blocking.lists.len()) } else { "disabled".to_string() });
|
||||
if config.proxy.enabled {
|
||||
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mProxy\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m",
|
||||
format!("http://*.{} on :{}", config.proxy.tld, config.proxy.port));
|
||||
let schemes = if config.proxy.tls_port > 0 {
|
||||
format!("http://:{} https://:{}", config.proxy.port, config.proxy.tls_port)
|
||||
} else {
|
||||
format!("http://*.{} on :{}", config.proxy.tld, config.proxy.port)
|
||||
};
|
||||
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mProxy\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", schemes);
|
||||
}
|
||||
if !ctx.forwarding_rules.is_empty() {
|
||||
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mRouting\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m",
|
||||
@@ -198,12 +203,35 @@ async fn main() -> numa::Result<()> {
|
||||
if config.proxy.enabled {
|
||||
let proxy_ctx = Arc::clone(&ctx);
|
||||
let proxy_port = config.proxy.port;
|
||||
let proxy_tld = config.proxy.tld.clone();
|
||||
tokio::spawn(async move {
|
||||
numa::proxy::start_proxy(proxy_ctx, proxy_port, &proxy_tld).await;
|
||||
numa::proxy::start_proxy(proxy_ctx, proxy_port).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn HTTPS reverse proxy with TLS termination
|
||||
if config.proxy.enabled && config.proxy.tls_port > 0 {
|
||||
let service_names: Vec<String> = ctx
|
||||
.services
|
||||
.lock()
|
||||
.unwrap()
|
||||
.list()
|
||||
.iter()
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
match numa::tls::build_tls_config(&config.proxy.tld, &service_names) {
|
||||
Ok(tls_config) => {
|
||||
let proxy_ctx = Arc::clone(&ctx);
|
||||
let tls_port = config.proxy.tls_port;
|
||||
tokio::spawn(async move {
|
||||
numa::proxy::start_proxy_tls(proxy_ctx, tls_port, tls_config).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UDP DNS listener
|
||||
#[allow(clippy::infinite_loop)]
|
||||
loop {
|
||||
|
||||
76
src/proxy.rs
76
src/proxy.rs
@@ -11,7 +11,9 @@ use hyper::StatusCode;
|
||||
use hyper_util::client::legacy::Client;
|
||||
use hyper_util::rt::TokioExecutor;
|
||||
use log::{debug, error, info, warn};
|
||||
use rustls::ServerConfig;
|
||||
use tokio::io::copy_bidirectional;
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
use crate::ctx::ServerCtx;
|
||||
|
||||
@@ -21,10 +23,9 @@ type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Bod
|
||||
struct ProxyState {
|
||||
ctx: Arc<ServerCtx>,
|
||||
client: HttpClient,
|
||||
tld_suffix: String, // pre-computed ".{tld}"
|
||||
}
|
||||
|
||||
pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16, tld: &str) {
|
||||
pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16) {
|
||||
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(l) => l,
|
||||
@@ -45,7 +46,6 @@ pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16, tld: &str) {
|
||||
let state = ProxyState {
|
||||
ctx,
|
||||
client,
|
||||
tld_suffix: format!(".{}", tld),
|
||||
};
|
||||
|
||||
let app = Router::new().fallback(any(proxy_handler)).with_state(state);
|
||||
@@ -53,6 +53,68 @@ pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16, tld: &str) {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, tls_config: Arc<ServerConfig>) {
|
||||
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"proxy: could not bind TLS port {} ({}) — HTTPS proxy disabled",
|
||||
port, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
info!("HTTPS proxy listening on {}", addr);
|
||||
|
||||
let acceptor = TlsAcceptor::from(tls_config);
|
||||
let client: HttpClient = Client::builder(TokioExecutor::new())
|
||||
.http1_preserve_header_case(true)
|
||||
.build_http();
|
||||
|
||||
let state = ProxyState {
|
||||
ctx,
|
||||
client,
|
||||
};
|
||||
|
||||
let app = Router::new().fallback(any(proxy_handler)).with_state(state);
|
||||
|
||||
loop {
|
||||
let (tcp_stream, remote_addr) = match listener.accept().await {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
error!("TLS accept error: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let acceptor = acceptor.clone();
|
||||
let app = app.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let tls_stream = match acceptor.accept(tcp_stream).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
debug!("TLS handshake failed from {}: {}", remote_addr, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let io = hyper_util::rt::TokioIo::new(tls_stream);
|
||||
let svc = hyper_util::service::TowerToHyperService::new(app.into_service());
|
||||
|
||||
if let Err(e) = hyper::server::conn::http1::Builder::new()
|
||||
.preserve_header_case(true)
|
||||
.serve_connection(io, svc)
|
||||
.with_upgrades()
|
||||
.await
|
||||
{
|
||||
debug!("TLS connection error from {}: {}", remote_addr, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_host(req: &Request) -> Option<String> {
|
||||
req.headers()
|
||||
.get(hyper::header::HOST)
|
||||
@@ -68,12 +130,12 @@ async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::r
|
||||
}
|
||||
};
|
||||
|
||||
let service_name = match hostname.strip_suffix(state.tld_suffix.as_str()) {
|
||||
let service_name = match hostname.strip_suffix(state.ctx.proxy_tld_suffix.as_str()) {
|
||||
Some(name) => name.to_string(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("not a {} domain: {}", state.tld_suffix, hostname),
|
||||
format!("not a {} domain: {}", state.ctx.proxy_tld_suffix, hostname),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -86,7 +148,7 @@ async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::r
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("unknown service: {}{}", service_name, state.tld_suffix),
|
||||
format!("unknown service: {}{}", service_name, state.ctx.proxy_tld_suffix),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -98,7 +160,7 @@ async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::r
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or("/");
|
||||
let target_uri: hyper::Uri = format!("http://127.0.0.1:{}{}", target_port, path_and_query)
|
||||
let target_uri: hyper::Uri = format!("http://localhost:{}{}", target_port, path_and_query)
|
||||
.parse()
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -7,8 +7,13 @@ pub enum QueryType {
|
||||
A, // 1
|
||||
NS, // 2
|
||||
CNAME, // 5
|
||||
SOA, // 6
|
||||
PTR, // 12
|
||||
MX, // 15
|
||||
TXT, // 16
|
||||
AAAA, // 28
|
||||
SRV, // 33
|
||||
HTTPS, // 65
|
||||
}
|
||||
|
||||
impl QueryType {
|
||||
@@ -18,8 +23,13 @@ impl QueryType {
|
||||
QueryType::A => 1,
|
||||
QueryType::NS => 2,
|
||||
QueryType::CNAME => 5,
|
||||
QueryType::SOA => 6,
|
||||
QueryType::PTR => 12,
|
||||
QueryType::MX => 15,
|
||||
QueryType::TXT => 16,
|
||||
QueryType::AAAA => 28,
|
||||
QueryType::SRV => 33,
|
||||
QueryType::HTTPS => 65,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +38,13 @@ impl QueryType {
|
||||
1 => QueryType::A,
|
||||
2 => QueryType::NS,
|
||||
5 => QueryType::CNAME,
|
||||
6 => QueryType::SOA,
|
||||
12 => QueryType::PTR,
|
||||
15 => QueryType::MX,
|
||||
16 => QueryType::TXT,
|
||||
28 => QueryType::AAAA,
|
||||
33 => QueryType::SRV,
|
||||
65 => QueryType::HTTPS,
|
||||
_ => QueryType::UNKNOWN(num),
|
||||
}
|
||||
}
|
||||
@@ -39,25 +54,30 @@ impl QueryType {
|
||||
QueryType::A => "A",
|
||||
QueryType::NS => "NS",
|
||||
QueryType::CNAME => "CNAME",
|
||||
QueryType::SOA => "SOA",
|
||||
QueryType::PTR => "PTR",
|
||||
QueryType::MX => "MX",
|
||||
QueryType::TXT => "TXT",
|
||||
QueryType::AAAA => "AAAA",
|
||||
QueryType::SRV => "SRV",
|
||||
QueryType::HTTPS => "HTTPS",
|
||||
QueryType::UNKNOWN(_) => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_str(s: &str) -> Option<QueryType> {
|
||||
if s.eq_ignore_ascii_case("A") {
|
||||
Some(QueryType::A)
|
||||
} else if s.eq_ignore_ascii_case("NS") {
|
||||
Some(QueryType::NS)
|
||||
} else if s.eq_ignore_ascii_case("CNAME") {
|
||||
Some(QueryType::CNAME)
|
||||
} else if s.eq_ignore_ascii_case("MX") {
|
||||
Some(QueryType::MX)
|
||||
} else if s.eq_ignore_ascii_case("AAAA") {
|
||||
Some(QueryType::AAAA)
|
||||
} else {
|
||||
None
|
||||
match s.to_ascii_uppercase().as_str() {
|
||||
"A" => Some(QueryType::A),
|
||||
"NS" => Some(QueryType::NS),
|
||||
"CNAME" => Some(QueryType::CNAME),
|
||||
"SOA" => Some(QueryType::SOA),
|
||||
"PTR" => Some(QueryType::PTR),
|
||||
"MX" => Some(QueryType::MX),
|
||||
"TXT" => Some(QueryType::TXT),
|
||||
"AAAA" => Some(QueryType::AAAA),
|
||||
"SRV" => Some(QueryType::SRV),
|
||||
"HTTPS" => Some(QueryType::HTTPS),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ impl DnsRecord {
|
||||
ttl,
|
||||
})
|
||||
}
|
||||
QueryType::UNKNOWN(_) => {
|
||||
_ => {
|
||||
buffer.step(data_len as usize)?;
|
||||
|
||||
Ok(DnsRecord::UNKNOWN {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Serialize;
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ServiceEntry {
|
||||
pub name: String,
|
||||
pub target_port: u16,
|
||||
@@ -10,6 +12,9 @@ pub struct ServiceEntry {
|
||||
|
||||
pub struct ServiceStore {
|
||||
entries: HashMap<String, ServiceEntry>,
|
||||
/// Services defined in numa.toml (not persisted to user file)
|
||||
config_services: std::collections::HashSet<String>,
|
||||
persist_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for ServiceStore {
|
||||
@@ -20,13 +25,18 @@ impl Default for ServiceStore {
|
||||
|
||||
impl ServiceStore {
|
||||
pub fn new() -> Self {
|
||||
let persist_path = dirs_path();
|
||||
ServiceStore {
|
||||
entries: HashMap::new(),
|
||||
config_services: std::collections::HashSet::new(),
|
||||
persist_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, name: &str, target_port: u16) {
|
||||
/// Insert a service from numa.toml config (not persisted)
|
||||
pub fn insert_from_config(&mut self, name: &str, target_port: u16) {
|
||||
let key = name.to_lowercase();
|
||||
self.config_services.insert(key.clone());
|
||||
self.entries.insert(
|
||||
key.clone(),
|
||||
ServiceEntry {
|
||||
@@ -36,12 +46,30 @@ impl ServiceStore {
|
||||
);
|
||||
}
|
||||
|
||||
/// Insert a user-defined service (persisted to ~/.config/numa/services.json)
|
||||
pub fn insert(&mut self, name: &str, target_port: u16) {
|
||||
let key = name.to_lowercase();
|
||||
self.entries.insert(
|
||||
key.clone(),
|
||||
ServiceEntry {
|
||||
name: key,
|
||||
target_port,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn lookup(&self, name: &str) -> Option<&ServiceEntry> {
|
||||
self.entries.get(&name.to_lowercase())
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, name: &str) -> bool {
|
||||
self.entries.remove(&name.to_lowercase()).is_some()
|
||||
let key = name.to_lowercase();
|
||||
let removed = self.entries.remove(&key).is_some();
|
||||
if removed {
|
||||
self.save();
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<&ServiceEntry> {
|
||||
@@ -49,4 +77,56 @@ impl ServiceStore {
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
entries
|
||||
}
|
||||
|
||||
/// Load user-defined services from ~/.config/numa/services.json
|
||||
pub fn load_persisted(&mut self) {
|
||||
if !self.persist_path.exists() {
|
||||
return;
|
||||
}
|
||||
match std::fs::read_to_string(&self.persist_path) {
|
||||
Ok(contents) => match serde_json::from_str::<Vec<ServiceEntry>>(&contents) {
|
||||
Ok(entries) => {
|
||||
let count = entries.len();
|
||||
for entry in entries {
|
||||
let key = entry.name.to_lowercase();
|
||||
// Don't overwrite config-defined services
|
||||
if !self.config_services.contains(&key) {
|
||||
self.entries.insert(key, entry);
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
info!("loaded {} persisted services from {:?}", count, self.persist_path);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("failed to parse {:?}: {}", self.persist_path, e),
|
||||
},
|
||||
Err(e) => warn!("failed to read {:?}: {}", self.persist_path, e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save user-defined services (excluding config and "numa") to disk
|
||||
fn save(&self) {
|
||||
let user_services: Vec<&ServiceEntry> = self
|
||||
.entries
|
||||
.values()
|
||||
.filter(|e| !self.config_services.contains(&e.name))
|
||||
.collect();
|
||||
|
||||
if let Some(parent) = self.persist_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
match serde_json::to_string_pretty(&user_services) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&self.persist_path, json) {
|
||||
warn!("failed to save services to {:?}: {}", self.persist_path, e);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("failed to serialize services: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dirs_path() -> PathBuf {
|
||||
crate::config_dir().join("services.json")
|
||||
}
|
||||
|
||||
@@ -211,21 +211,25 @@ pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option<S
|
||||
/// Saves the original DNS settings for later restoration.
|
||||
pub fn install_system_dns() -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
install_macos()
|
||||
}
|
||||
let result = install_macos();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
install_linux()
|
||||
}
|
||||
let result = install_linux();
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
Err("system DNS configuration not supported on this OS".to_string())
|
||||
let result = Err("system DNS configuration not supported on this OS".to_string());
|
||||
|
||||
if result.is_ok() {
|
||||
if let Err(e) = trust_ca() {
|
||||
eprintln!(" warning: could not trust CA: {}", e);
|
||||
eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n");
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Restore the original system DNS settings saved during install.
|
||||
pub fn uninstall_system_dns() -> Result<(), String> {
|
||||
let _ = untrust_ca();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
uninstall_macos()
|
||||
@@ -761,3 +765,82 @@ fn run_systemctl(args: &[&str]) -> Result<(), String> {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// --- CA trust management ---
|
||||
|
||||
fn trust_ca() -> Result<(), String> {
|
||||
let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem");
|
||||
if !ca_path.exists() {
|
||||
return Err("CA not generated yet — start numa first to create certificates".into());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let status = std::process::Command::new("security")
|
||||
.args([
|
||||
"add-trusted-cert",
|
||||
"-d",
|
||||
"-r",
|
||||
"trustRoot",
|
||||
"-k",
|
||||
"/Library/Keychains/System.keychain",
|
||||
])
|
||||
.arg(&ca_path)
|
||||
.status()
|
||||
.map_err(|e| format!("security: {}", e))?;
|
||||
if !status.success() {
|
||||
return Err("security add-trusted-cert failed".into());
|
||||
}
|
||||
eprintln!(" Trusted Numa CA in system keychain");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt");
|
||||
std::fs::copy(&ca_path, dest).map_err(|e| format!("copy CA: {}", e))?;
|
||||
let status = std::process::Command::new("update-ca-certificates")
|
||||
.status()
|
||||
.map_err(|e| format!("update-ca-certificates: {}", e))?;
|
||||
if !status.success() {
|
||||
return Err("update-ca-certificates failed".into());
|
||||
}
|
||||
eprintln!(" Trusted Numa CA system-wide");
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
return Err("CA trust not supported on this OS".into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn untrust_ca() -> Result<(), String> {
|
||||
let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if ca_path.exists() {
|
||||
let _ = std::process::Command::new("security")
|
||||
.args(["remove-trusted-cert", "-d"])
|
||||
.arg(&ca_path)
|
||||
.status();
|
||||
eprintln!(" Removed Numa CA from system keychain");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt");
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_file(dest);
|
||||
let _ = std::process::Command::new("update-ca-certificates")
|
||||
.arg("--fresh")
|
||||
.status();
|
||||
eprintln!(" Removed Numa CA from system trust store");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = ca_path; // suppress unused warning on other platforms
|
||||
Ok(())
|
||||
}
|
||||
|
||||
125
src/tls.rs
Normal file
125
src/tls.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{info, warn};
|
||||
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use rustls::ServerConfig;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
const CA_VALIDITY_DAYS: i64 = 3650; // 10 years
|
||||
const CERT_VALIDITY_DAYS: i64 = 365; // 1 year
|
||||
|
||||
/// TLS certs use a fixed system path — both the daemon and `sudo numa install` must agree.
|
||||
pub const TLS_DIR: &str = "/usr/local/var/numa";
|
||||
|
||||
/// Build a TLS config with a cert covering all provided service names.
|
||||
/// Wildcards under single-label TLDs (*.numa) are rejected by browsers,
|
||||
/// so we list each service explicitly as a SAN.
|
||||
pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result<Arc<ServerConfig>> {
|
||||
let dir = std::path::PathBuf::from(TLS_DIR);
|
||||
let (ca_cert, ca_key) = ensure_ca(&dir)?;
|
||||
let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?;
|
||||
|
||||
// Ensure a crypto provider is installed (rustls needs one)
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(cert_chain, key)?;
|
||||
|
||||
info!("TLS configured for {} .{} domains", service_names.len(), tld);
|
||||
Ok(Arc::new(config))
|
||||
}
|
||||
|
||||
fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
||||
let ca_key_path = dir.join("ca.key");
|
||||
let ca_cert_path = dir.join("ca.pem");
|
||||
|
||||
if ca_key_path.exists() && ca_cert_path.exists() {
|
||||
let key_pem = std::fs::read_to_string(&ca_key_path)?;
|
||||
let cert_pem = std::fs::read_to_string(&ca_cert_path)?;
|
||||
let key_pair = KeyPair::from_pem(&key_pem)?;
|
||||
let params = CertificateParams::from_ca_cert_pem(&cert_pem)?;
|
||||
let cert = params.self_signed(&key_pair)?;
|
||||
info!("loaded CA from {:?}", ca_cert_path);
|
||||
return Ok((cert, key_pair));
|
||||
}
|
||||
|
||||
// Generate new CA
|
||||
std::fs::create_dir_all(dir)?;
|
||||
|
||||
let key_pair = KeyPair::generate()?;
|
||||
let mut params = CertificateParams::default();
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, "Numa Local CA");
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + Duration::days(CA_VALIDITY_DAYS);
|
||||
|
||||
let cert = params.self_signed(&key_pair)?;
|
||||
|
||||
std::fs::write(&ca_key_path, key_pair.serialize_pem())?;
|
||||
std::fs::write(&ca_cert_path, cert.pem())?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&ca_key_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
info!("generated CA at {:?}", ca_cert_path);
|
||||
Ok((cert, key_pair))
|
||||
}
|
||||
|
||||
/// Generate a cert with explicit SANs for each service name.
|
||||
/// Always regenerated at startup (~5ms) — no disk caching needed.
|
||||
fn generate_service_cert(
|
||||
ca_cert: &rcgen::Certificate,
|
||||
ca_key: &KeyPair,
|
||||
tld: &str,
|
||||
service_names: &[String],
|
||||
) -> crate::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
|
||||
let key_pair = KeyPair::generate()?;
|
||||
let mut params = CertificateParams::default();
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, format!("Numa .{} services", tld));
|
||||
|
||||
// Add each service as an explicit SAN: numa.numa, peekm.numa, api.numa, etc.
|
||||
let mut sans = Vec::new();
|
||||
for name in service_names {
|
||||
let fqdn = format!("{}.{}", name, tld);
|
||||
match fqdn.clone().try_into() {
|
||||
Ok(ia5) => sans.push(SanType::DnsName(ia5)),
|
||||
Err(e) => warn!("invalid SAN {}: {}", fqdn, e),
|
||||
}
|
||||
}
|
||||
|
||||
if sans.is_empty() {
|
||||
return Err("no valid service names for TLS cert".into());
|
||||
}
|
||||
|
||||
params.subject_alt_names = sans;
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + Duration::days(CERT_VALIDITY_DAYS);
|
||||
|
||||
let cert = params.signed_by(&key_pair, ca_cert, ca_key)?;
|
||||
|
||||
info!(
|
||||
"generated TLS cert for: {}",
|
||||
service_names
|
||||
.iter()
|
||||
.map(|n| format!("{}.{}", n, tld))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
let cert_der = CertificateDer::from(cert.der().to_vec());
|
||||
let ca_der = CertificateDer::from(ca_cert.der().to_vec());
|
||||
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
|
||||
|
||||
Ok((vec![cert_der, ca_der], key_der))
|
||||
}
|
||||
Reference in New Issue
Block a user