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:
Razvan Dimescu
2026-03-21 01:15:07 +02:00
parent 10502f2db2
commit 3bfcd827ac
17 changed files with 1377 additions and 68 deletions

View File

@@ -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")
}