diff options
Diffstat (limited to 'src/bin')
| -rw-r--r-- | src/bin/tu.rs | 222 | ||||
| -rw-r--r-- | src/bin/tud.rs | 362 |
2 files changed, 584 insertions, 0 deletions
diff --git a/src/bin/tu.rs b/src/bin/tu.rs new file mode 100644 index 0000000..1bae614 --- /dev/null +++ b/src/bin/tu.rs @@ -0,0 +1,222 @@ +//! tu — tesseras-url CLI client. +//! +//! Sends commands to the `tud` daemon over a Unix socket. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; + +#[path = "../base58.rs"] +mod base58; +#[path = "../sandbox.rs"] +mod sandbox; + +fn default_socket() -> PathBuf { + PathBuf::from("/var/tesseras-url/daemon.sock") +} + +fn usage() { + eprintln!("usage: tu [-s sock] [-v] <command> [args]"); + eprintln!(); + eprintln!("commands:"); + eprintln!(" shorten [-t ttl] [-s slug] <url> create short URL"); + eprintln!(" resolve <slug> show target URL"); + eprintln!(" del <slug> delete short URL"); + eprintln!(" list list local entries"); + eprintln!(" status show daemon status"); + eprintln!(); + eprintln!(" -s sock Unix socket path"); + eprintln!(" -v verbose output"); + eprintln!(" -t ttl time-to-live (e.g. 24h 30m 3600, 0=forever)"); +} + +fn parse_ttl(s: &str) -> Result<u64, String> { + let s = s.trim(); + if s == "0" { + return Ok(0); + } + if let Some(h) = s.strip_suffix('h') { + h.parse::<u64>() + .map(|v| v * 3600) + .map_err(|e| e.to_string()) + } else if let Some(m) = s.strip_suffix('m') { + m.parse::<u64>().map(|v| v * 60).map_err(|e| e.to_string()) + } else if let Some(sec) = s.strip_suffix('s') { + sec.parse::<u64>().map_err(|e| e.to_string()) + } else { + s.parse::<u64>().map_err(|e| e.to_string()) + } +} + +fn main() { + let args: Vec<String> = std::env::args().collect(); + + let mut sock_path = default_socket(); + let mut verbose = false; + let mut cmd_start = 1; + + // Parse global options before command + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "-s" => { + i += 1; + sock_path = + args.get(i).map(PathBuf::from).unwrap_or_else(|| { + eprintln!("error: -s requires path"); + std::process::exit(1); + }); + cmd_start = i + 1; + } + "-v" => { + verbose = true; + cmd_start = i + 1; + } + "-h" | "--help" => { + usage(); + return; + } + _ => break, + } + i += 1; + } + + let cmd_args = &args[cmd_start..]; + if cmd_args.is_empty() { + usage(); + std::process::exit(1); + } + + let command = &cmd_args[0]; + + let request = match command.as_str() { + "shorten" => { + let mut ttl = "0".to_string(); + let mut slug = "auto".to_string(); + let mut url: Option<String> = None; + let mut j = 1; + while j < cmd_args.len() { + match cmd_args[j].as_str() { + "-t" => { + j += 1; + if j < cmd_args.len() { + ttl = cmd_args[j].clone(); + } + } + "-s" => { + j += 1; + if j < cmd_args.len() { + slug = cmd_args[j].clone(); + } + } + arg if !arg.starts_with('-') => { + url = Some(arg.to_string()); + } + _ => {} + } + j += 1; + } + let url = match url { + Some(u) => u, + None => { + eprintln!("error: shorten requires a URL"); + std::process::exit(1); + } + }; + let ttl_secs = match parse_ttl(&ttl) { + Ok(s) => s, + Err(e) => { + eprintln!("error: bad TTL: {e}"); + std::process::exit(1); + } + }; + format!("SHORTEN {ttl_secs} {slug} {url}\n") + } + "resolve" => { + let slug = cmd_args.get(1).unwrap_or_else(|| { + eprintln!("error: resolve requires a slug"); + std::process::exit(1); + }); + format!("RESOLVE {slug}\n") + } + "del" => { + let slug = cmd_args.get(1).unwrap_or_else(|| { + eprintln!("error: del requires a slug"); + std::process::exit(1); + }); + format!("DEL {slug}\n") + } + "list" => "LIST\n".to_string(), + "status" => "STATUS\n".to_string(), + other => { + eprintln!("unknown command: {other}"); + usage(); + std::process::exit(1); + } + }; + + // ── Sandbox ───────────────────────────────────── + sandbox::do_unveil(&sock_path, "rw"); + sandbox::unveil_lock(); + sandbox::do_pledge("stdio unix rpath"); + + if verbose { + eprintln!("socket: {}", sock_path.display()); + eprintln!(">> {}", request.trim()); + } + + let stream = match UnixStream::connect(&sock_path) { + Ok(s) => s, + Err(e) => { + eprintln!( + "error: cannot connect to {}: {e}", + sock_path.display(), + ); + eprintln!("hint: is tud running?"); + std::process::exit(1); + } + }; + + stream + .set_read_timeout(Some(std::time::Duration::from_secs(60))) + .ok(); + + let mut writer = &stream; + if let Err(e) = writer.write_all(request.as_bytes()) { + eprintln!("error: writing to socket: {e}"); + std::process::exit(1); + } + + let reader = BufReader::new(&stream); + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + if verbose { + eprintln!("<< {}", line); + } + if let Some(data) = line.strip_prefix("OK ") { + if command == "list" && data != "(empty)" { + // Decode base58-encoded list + if let Some(decoded) = base58::decode(data) { + if let Ok(text) = std::str::from_utf8(&decoded) { + for entry in text.lines() { + println!("{entry}"); + } + } else { + println!("{data}"); + } + } else { + println!("{data}"); + } + } else { + println!("{data}"); + } + break; + } else if let Some(msg) = line.strip_prefix("ERR ") { + eprintln!("error: {msg}"); + std::process::exit(1); + } + } +} diff --git a/src/bin/tud.rs b/src/bin/tud.rs new file mode 100644 index 0000000..976ca46 --- /dev/null +++ b/src/bin/tud.rs @@ -0,0 +1,362 @@ +//! tud — tesseras-url daemon. +//! +//! Runs a DHT node that stores and serves short URL mappings. +//! Communicates with the CLI (`tu`) over a Unix socket and +//! optionally serves HTTP redirects. + +#[path = "../base58.rs"] +mod base58; +#[path = "../daemon.rs"] +mod daemon; +#[path = "../dns.rs"] +mod dns; +#[path = "../ops.rs"] +mod ops; +#[path = "../protocol.rs"] +mod protocol; +#[path = "../sandbox.rs"] +mod sandbox; +#[path = "../store.rs"] +mod store; +#[path = "../url_entry.rs"] +mod url_entry; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, mpsc}; + +use tesseras_dht::nat::NatState; +use tesseras_dht::node::NodeBuilder; + +use store::UrlStore; + +fn default_dir() -> PathBuf { + PathBuf::from("/var/tesseras-url") +} + +fn usage() { + eprintln!( + "usage: tud [-p port] [-d dir] [-s sock] \ + [-w http_port] [-g] [-n] [-b host:port] [-v] [-h]" + ); + eprintln!(); + eprintln!(" -p port UDP port (0 = random)"); + eprintln!(" -d dir data directory"); + eprintln!(" -s sock Unix socket path"); + eprintln!(" -w port HTTP server port"); + eprintln!(" -g global NAT (public server)"); + eprintln!(" -n no auto-bootstrap (skip DNS SRV)"); + eprintln!(" -b host:port bootstrap peer (repeatable)"); + eprintln!(" -v verbose (debug logging)"); + eprintln!(" -h show this help"); +} + +fn main() { + let mut port: u16 = 0; + let mut dir = default_dir(); + let mut sock: Option<PathBuf> = None; + let mut http_port: Option<u16> = None; + let mut global = false; + let mut no_auto_bootstrap = false; + let mut verbose = false; + let mut bootstrap: Vec<String> = Vec::new(); + + let args: Vec<String> = std::env::args().collect(); + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "-p" => { + i += 1; + port = args + .get(i) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| { + eprintln!("error: -p requires a port"); + std::process::exit(1); + }); + } + "-d" => { + i += 1; + dir = args.get(i).map(PathBuf::from).unwrap_or_else(|| { + eprintln!("error: -d requires a path"); + std::process::exit(1); + }); + } + "-s" => { + i += 1; + sock = args.get(i).map(PathBuf::from); + if sock.is_none() { + eprintln!("error: -s requires a path"); + std::process::exit(1); + } + } + "-w" => { + i += 1; + http_port = Some( + args.get(i) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| { + eprintln!("error: -w requires a port"); + std::process::exit(1); + }), + ); + } + "-g" => global = true, + "-n" => no_auto_bootstrap = true, + "-v" => verbose = true, + "-b" => { + i += 1; + if let Some(addr) = args.get(i) { + bootstrap.push(addr.clone()); + } else { + eprintln!("error: -b requires host:port"); + std::process::exit(1); + } + } + "-h" | "--help" => { + usage(); + return; + } + other => { + eprintln!("unknown option: {other}"); + usage(); + std::process::exit(1); + } + } + i += 1; + } + + let default_level = if verbose { "debug" } else { "info" }; + env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or(default_level), + ) + .format(|buf, record| { + use std::io::Write; + writeln!(buf, "[{}]: {}", record.level(), record.args()) + }) + .init(); + + let sock_path = sock.unwrap_or_else(|| dir.join("daemon.sock")); + + if let Err(e) = std::fs::create_dir_all(&dir) { + eprintln!("error: cannot create {}: {e}", dir.display()); + std::process::exit(1); + } + if let Some(parent) = sock_path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + eprintln!("error: cannot create {}: {e}", parent.display()); + std::process::exit(1); + } + + let store = match UrlStore::open(&dir) { + Ok(s) => s, + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }; + + let identity_path = dir.join("identity.key"); + let identity_seed = load_or_create_identity(&identity_path); + + let mut builder = NodeBuilder::new().port(port).seed(&identity_seed); + if global { + builder = builder.nat(NatState::Global); + } + + let cfg = tesseras_dht::config::Config { + default_ttl: 65535, + max_value_size: 16 * 1024, + require_signatures: true, + ..Default::default() + }; + builder = builder.config(cfg); + + let mut node = match builder.build() { + Ok(n) => n, + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }; + + node.set_routing_persistence(Box::new(store.clone())); + node.set_data_persistence(Box::new(store.clone())); + node.load_persisted(); + + let addr = match node.local_addr() { + Ok(a) => a, + Err(e) => { + eprintln!("error: could not determine local address: {e}"); + std::process::exit(1); + } + }; + let id = node.id_hex(); + eprintln!("tud {addr} id={:.8}", id); + + // ── Sandbox ───────────────────────────────────── + sandbox::do_unveil(&dir, "rwc"); + if sock_path.parent() != Some(dir.as_ref()) + && let Some(parent) = sock_path.parent() + { + sandbox::do_unveil(parent, "rwc"); + } + if !no_auto_bootstrap || !bootstrap.is_empty() { + sandbox::do_unveil( + std::path::Path::new("/etc/resolv.conf"), + "r", + ); + } + sandbox::unveil_lock(); + + sandbox::do_pledge("stdio rpath wpath cpath fattr inet unix dns"); + + // Bootstrap + if bootstrap.is_empty() && !no_auto_bootstrap { + log::info!("bootstrap: resolving SRV records"); + let srv = dns::lookup_bootstrap(); + if srv.is_empty() { + log::warn!("bootstrap: no SRV records found"); + } + for rec in &srv { + bootstrap.push(format!("{}:{}", rec.host, rec.port)); + } + } + + for peer in &bootstrap { + let parts: Vec<&str> = peer.rsplitn(2, ':').collect(); + if parts.len() != 2 { + eprintln!("warning: bad bootstrap address: {peer}"); + continue; + } + let host = parts[1]; + let p: u16 = match parts[0].parse() { + Ok(p) => p, + Err(_) => { + eprintln!("warning: bad bootstrap port: {peer}"); + continue; + } + }; + if let Err(e) = node.join(host, p) { + log::warn!("bootstrap: failed to join {peer}: {e}"); + } else { + log::info!("bootstrap: connected to {peer}"); + } + } + + for _ in 0..10 { + let _ = node.poll(); + } + + eprintln!( + "peers={} socket={}", + node.routing_table_size(), + sock_path.display() + ); + + let shutdown = Arc::new(AtomicBool::new(false)); + + let sig = Arc::clone(&shutdown); + unsafe { + SHUTDOWN_PTR.store( + Arc::into_raw(sig) as *mut AtomicBool as usize, + Ordering::SeqCst, + ); + signal(SIGINT, sig_handler as *const () as usize); + signal(SIGTERM, sig_handler as *const () as usize); + } + + let (tx, rx) = mpsc::channel(); + + let listener_shutdown = Arc::clone(&shutdown); + let listener_path = sock_path.clone(); + let handle = std::thread::spawn(move || { + daemon::run_unix_listener( + &listener_path, + tx, + &listener_shutdown, + ); + }); + + let http_handle = http_port.map(|hp| { + let http_store = store.clone(); + let http_shutdown = Arc::clone(&shutdown); + let http_sock = sock_path.clone(); + eprintln!("http on 0.0.0.0:{hp}"); + std::thread::spawn(move || { + daemon::run_http(hp, &http_sock, &http_store, &http_shutdown); + }) + }); + + daemon::run_daemon(&mut node, &store, &rx, &shutdown, &bootstrap); + + let _ = std::fs::remove_file(&sock_path); + let _ = handle.join(); + if let Some(h) = http_handle { + let _ = h.join(); + } + eprintln!("shutdown complete"); +} + +fn load_or_create_identity(path: &std::path::Path) -> Vec<u8> { + if let Ok(data) = std::fs::read(path) + && data.len() == 32 + { + log::info!("identity: loaded from {}", path.display()); + return data; + } + let mut seed = [0u8; 32]; + tesseras_dht::sys::random_bytes(&mut seed); + match write_private_file(path, &seed) { + Ok(()) => { + log::info!( + "identity: generated new keypair at {}", + path.display() + ); + } + Err(e) => { + log::warn!( + "identity: failed to save to {}: {e}", + path.display() + ); + } + } + seed.to_vec() +} + +fn write_private_file( + path: &std::path::Path, + data: &[u8], +) -> std::io::Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + f.write_all(data)?; + f.sync_all() +} + +// ── Signal handling ───────────────────────────────── + +static SHUTDOWN_PTR: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +const SIGINT: i32 = 2; +const SIGTERM: i32 = 15; + +unsafe extern "C" fn sig_handler(_sig: i32) { + let ptr = SHUTDOWN_PTR.load(Ordering::SeqCst); + if ptr != 0 { + let flag = unsafe { &*(ptr as *const AtomicBool) }; + flag.store(true, Ordering::Relaxed); + } +} + +unsafe extern "C" { + fn signal(sig: i32, handler: usize) -> usize; +} |