summaryrefslogtreecommitdiffstats
path: root/src/bin/tu.rs
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/tu.rs
downloadtesseras-url-main.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/tu.rs')
-rw-r--r--src/bin/tu.rs222
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);
+ }
+ }
+}