diff --git a/site/dashboard.html b/site/dashboard.html
index 76c2a80..ef07202 100644
--- a/site/dashboard.html
+++ b/site/dashboard.html
@@ -875,6 +875,8 @@ async function refresh() {
document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs);
document.getElementById('uptimeSub').textContent = formatUptimeSub(stats.uptime_secs);
document.getElementById('footerUpstream').textContent = stats.upstream || '';
+ document.getElementById('footerConfig').textContent = stats.config_path || '';
+ document.getElementById('footerData').textContent = stats.data_dir || '';
// LAN status indicator
const lanEl = document.getElementById('lanToggle');
@@ -1006,14 +1008,16 @@ async function checkDomain(event) {
if (result.blocked) {
el.style.background = 'rgba(181, 68, 58, 0.1)';
el.style.color = 'var(--rose)';
- el.innerHTML = `Blocked — ${result.reason}` +
- (result.matched_rule ? `
Rule: ${result.matched_rule}` : '') +
- ` `;
+ const hd = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
+ el.innerHTML = `Blocked — ${hd(result.reason)}` +
+ (result.matched_rule ? `
Rule: ${hd(result.matched_rule)}` : '') +
+ ` `;
} else {
+ const hd = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
el.style.background = 'rgba(82, 122, 82, 0.1)';
el.style.color = 'var(--emerald)';
- el.innerHTML = `Allowed — ${result.reason}` +
- (result.matched_rule ? `
Rule: ${result.matched_rule}` : '');
+ el.innerHTML = `Allowed — ${hd(result.reason)}` +
+ (result.matched_rule ? `
Rule: ${hd(result.matched_rule)}` : '');
}
} catch (err) {
el.style.display = 'block';
@@ -1118,26 +1122,27 @@ function renderServices(entries) {
? 'LAN'
: 'local only')
: '';
- const esc = s => s.replace(/'/g, "\\'").replace(/"/g, '"');
+ const h = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
const routeLines = (e.routes || []).map(r =>
`
` +
- `${r.path} ` +
- `→ :${r.port}` +
+ `${h(r.path)} ` +
+ `→ :${parseInt(r.port)||0}` +
(r.strip ? ` (strip)` : '') +
- (e.name === 'numa' ? '' : ` `) +
+ (e.name === 'numa' ? '' : ` `) +
`
`
).join('');
const deletable = e.source !== 'config' && e.name !== 'numa';
+ const name = h(e.name);
return `
-
-
localhost:${e.target_port} → proxied
+
+
localhost:${parseInt(e.target_port)||0} → proxied
${routeLines}
- ${e.name === 'numa' ? '' : `
`}
+ ${e.name === 'numa' ? '' : `
`}
- ${deletable ? `
` : ''}
+ ${deletable ? `
` : ''}
`}).join('');
}
@@ -1222,8 +1227,9 @@ setInterval(refresh, 2000);
- Upstream:
- · Logs:
macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f
+ Config:
+ · Data:
+ · Upstream:
·
GitHub
diff --git a/src/api.rs b/src/api.rs
index be67e29..4166fb3 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -49,6 +49,7 @@ pub fn router(ctx: Arc) -> Router {
.route("/services/{name}/routes", get(list_routes))
.route("/services/{name}/routes", post(add_route))
.route("/services/{name}/routes", delete(remove_route))
+ .route("/ca.pem", get(serve_ca))
.with_state(ctx)
}
@@ -130,6 +131,8 @@ struct QueryLogResponse {
struct StatsResponse {
uptime_secs: u64,
upstream: String,
+ config_path: String,
+ data_dir: String,
queries: QueriesStats,
cache: CacheStats,
overrides: OverrideStats,
@@ -451,6 +454,8 @@ async fn stats(State(ctx): State>) -> Json {
Json(StatsResponse {
uptime_secs: snap.uptime_secs,
upstream,
+ config_path: ctx.config_path.clone(),
+ data_dir: ctx.data_dir.to_string_lossy().to_string(),
queries: QueriesStats {
total: snap.total,
forwarded: snap.forwarded,
@@ -810,6 +815,24 @@ async fn remove_route(
}
}
+async fn serve_ca(State(ctx): State>) -> Result {
+ let ca_path = ctx.data_dir.join("ca.pem");
+ let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path))
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
+ .map_err(|_| StatusCode::NOT_FOUND)?;
+ Ok((
+ [
+ (header::CONTENT_TYPE, "application/x-pem-file"),
+ (
+ header::CONTENT_DISPOSITION,
+ "attachment; filename=\"numa-ca.pem\"",
+ ),
+ ],
+ bytes,
+ ))
+}
+
async fn check_tcp(addr: std::net::SocketAddr) -> bool {
tokio::time::timeout(
std::time::Duration::from_millis(100),
diff --git a/src/config.rs b/src/config.rs
index e7eb607..f0cd811 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -316,13 +316,62 @@ mod tests {
}
}
-pub fn load_config(path: &str) -> Result {
- if !Path::new(path).exists() {
- return Ok(Config::default());
+pub struct ConfigLoad {
+ pub config: Config,
+ pub path: String,
+ pub found: bool,
+}
+
+fn resolve_path(path: &str) -> String {
+ // canonicalize gives the real absolute path for existing files;
+ // for non-existent files, build an absolute path manually
+ std::fs::canonicalize(path)
+ .or_else(|_| std::env::current_dir().map(|cwd| cwd.join(path)))
+ .unwrap_or_else(|_| Path::new(path).to_path_buf())
+ .to_string_lossy()
+ .to_string()
+}
+
+pub fn load_config(path: &str) -> Result {
+ // Try the given path first, then well-known locations (for service mode where cwd is /)
+ let candidates: Vec = {
+ let p = Path::new(path);
+ let mut v = vec![p.to_path_buf()];
+ if p.is_relative() {
+ let filename = p.file_name().unwrap_or(p.as_os_str());
+ v.push(crate::config_dir().join(filename));
+ v.push(crate::data_dir().join(filename));
+ }
+ v
+ };
+
+ for candidate in &candidates {
+ match std::fs::read_to_string(candidate) {
+ Ok(contents) => {
+ let resolved = resolve_path(&candidate.to_string_lossy());
+ let config: Config = toml::from_str(&contents)?;
+ return Ok(ConfigLoad {
+ config,
+ path: resolved,
+ found: true,
+ });
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
+ Err(e) => return Err(e.into()),
+ }
}
- let contents = std::fs::read_to_string(path)?;
- let config: Config = toml::from_str(&contents)?;
- Ok(config)
+
+ // Show config_dir candidate as the "expected" path — it's actionable
+ let display_path = candidates
+ .get(1)
+ .map(|p| p.to_string_lossy().to_string())
+ .unwrap_or_else(|| resolve_path(path));
+ log::info!("config not found, using defaults (create {})", display_path);
+ Ok(ConfigLoad {
+ config: Config::default(),
+ path: display_path,
+ found: false,
+ })
}
pub type ZoneMap = HashMap>>;
diff --git a/src/ctx.rs b/src/ctx.rs
index b5d896a..167caba 100644
--- a/src/ctx.rs
+++ b/src/ctx.rs
@@ -1,4 +1,5 @@
use std::net::SocketAddr;
+use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime};
@@ -40,6 +41,10 @@ pub struct ServerCtx {
pub proxy_tld: String,
pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation
pub lan_enabled: bool,
+ pub config_path: String,
+ pub config_found: bool,
+ pub config_dir: PathBuf,
+ pub data_dir: PathBuf,
}
pub async fn handle_query(
diff --git a/src/lan.rs b/src/lan.rs
index e76fdd0..609a351 100644
--- a/src/lan.rs
+++ b/src/lan.rs
@@ -85,11 +85,10 @@ pub fn detect_lan_ip() -> Option {
fn get_hostname() -> String {
std::process::Command::new("hostname")
- .arg("-s")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
- .map(|h| h.trim().to_string())
+ .map(|h| h.trim().split('.').next().unwrap_or("numa").to_string())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "numa".to_string())
}
diff --git a/src/main.rs b/src/main.rs
index 6e09442..35029b3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,7 +8,7 @@ use tokio::net::UdpSocket;
use numa::blocklist::{download_blocklists, parse_blocklist, BlocklistStore};
use numa::buffer::BytePacketBuffer;
use numa::cache::DnsCache;
-use numa::config::{build_zone_map, load_config};
+use numa::config::{build_zone_map, load_config, ConfigLoad};
use numa::ctx::{handle_query, ServerCtx};
use numa::override_store::OverrideStore;
use numa::query_log::QueryLog;
@@ -96,7 +96,11 @@ async fn main() -> numa::Result<()> {
} else {
arg1 // treat as config path for backwards compatibility
};
- let config = load_config(&config_path)?;
+ let ConfigLoad {
+ config,
+ path: resolved_config_path,
+ found: config_found,
+ } = load_config(&config_path)?;
// Discover system DNS in a single pass (upstream + forwarding rules)
let system_dns = discover_system_dns();
@@ -160,6 +164,10 @@ async fn main() -> numa::Result<()> {
},
proxy_tld: config.proxy.tld.clone(),
lan_enabled: config.lan.enabled,
+ config_path: resolved_config_path,
+ config_found,
+ config_dir: numa::config_dir(),
+ data_dir: numa::data_dir(),
});
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
@@ -193,6 +201,15 @@ async fn main() -> numa::Result<()> {
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",
format!("{} conditional rules", ctx.forwarding_rules.len()));
}
+ eprintln!("\x1b[38;2;192;98;58m ╠──────────────────────────────────────────╣\x1b[0m");
+ let config_label = if ctx.config_found {
+ ctx.config_path.clone()
+ } else {
+ format!("{} (defaults)", ctx.config_path)
+ };
+ eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;163;152;136mConfig\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", config_label);
+ eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;163;152;136mData\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", ctx.data_dir.display());
+ eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;163;152;136mServices\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", ctx.config_dir.join("services.json").display());
eprintln!("\x1b[38;2;192;98;58m ╚══════════════════════════════════════════╝\x1b[0m\n");
info!(
diff --git a/src/service_store.rs b/src/service_store.rs
index 3e0bf9c..7db3ffd 100644
--- a/src/service_store.rs
+++ b/src/service_store.rs
@@ -29,9 +29,9 @@ impl ServiceEntry {
.iter()
.filter(|r| {
request_path == r.path
- || request_path.starts_with(&r.path)
+ || (request_path.starts_with(&r.path)
&& (r.path.ends_with('/')
- || request_path.as_bytes().get(r.path.len()) == Some(&b'/'))
+ || request_path.as_bytes().get(r.path.len()) == Some(&b'/')))
})
.max_by_key(|r| r.path.len());