summaryrefslogtreecommitdiffstats
path: root/src/bin
diff options
context:
space:
mode:
authormurilo ijanc2026-03-25 23:23:10 -0300
committermurilo ijanc2026-03-25 23:23:10 -0300
commita96da5f0704c50e8a4e4f047dcd3fb7c73fdf600 (patch)
treef98bb20411dbc6a49e8c286b054a88d89eea795f /src/bin
downloadtesseras-url-a96da5f0704c50e8a4e4f047dcd3fb7c73fdf600.tar.gz
Initial implementation of tesseras-urlHEADmain
Decentralized URL shortener built on tesseras-dht. Includes: - tud daemon: DHT node, Unix socket API, HTTP 302 redirect server - tu CLI: shorten, resolve, del, list, status commands - Auto-generated slugs (8-byte SHA256, base58) or custom slugs - TTL support (default: forever) - Automatic re-join of bootstrap nodes when routing table is empty - OpenBSD pledge(2) and unveil(2) sandboxing - DNS SRV bootstrap discovery - Verbose mode (-v) for both binaries
Diffstat (limited to 'src/bin')
-rw-r--r--src/bin/tu.rs222
-rw-r--r--src/bin/tud.rs362
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;
+}