diff options
| author | murilo ijanc | 2026-03-25 23:23:10 -0300 |
|---|---|---|
| committer | murilo ijanc | 2026-03-25 23:23:10 -0300 |
| commit | a96da5f0704c50e8a4e4f047dcd3fb7c73fdf600 (patch) | |
| tree | f98bb20411dbc6a49e8c286b054a88d89eea795f /src/bin/tu.rs | |
| download | tesseras-url-a96da5f0704c50e8a4e4f047dcd3fb7c73fdf600.tar.gz | |
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/tu.rs')
| -rw-r--r-- | src/bin/tu.rs | 222 |
1 files changed, 222 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); + } + } +} |