commit a96da5f0704c50e8a4e4f047dcd3fb7c73fdf600 from: murilo ijanc date: Thu Mar 26 02:23:10 2026 UTC Initial implementation of tesseras-url 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 commit - /dev/null commit + a96da5f0704c50e8a4e4f047dcd3fb7c73fdf600 blob - /dev/null blob + ea8c4bf7f35f6f77f75d92ad8ce8349f6e81ddba (mode 644) --- /dev/null +++ .gitignore @@ -0,0 +1 @@ +/target blob - /dev/null blob + bf955640bde5a20b8c2b53bb4b40cac7e8adcfc9 (mode 644) --- /dev/null +++ .rustfmt.toml @@ -0,0 +1,4 @@ +# group_imports = "StdExternalCrate" +# imports_granularity = "Module" +max_width = 80 +reorder_imports = true blob - /dev/null blob + 43cb2864b90378e226355908e5a8b5be09b5abf5 (mode 644) --- /dev/null +++ Cargo.lock @@ -0,0 +1,545 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tesseras-dht" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d3fe1b55bc14e2a7651441c82c3bbf1962acd9add00edf7a19f73adabfbdefe" +dependencies = [ + "ed25519-dalek", + "log", + "mio", + "sha2", +] + +[[package]] +name = "tesseras-url" +version = "0.1.0" +dependencies = [ + "env_logger", + "log", + "tesseras-dht", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" blob - /dev/null blob + b132b47aebb1cedf373d296512db9c1310f37edf (mode 644) --- /dev/null +++ Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tesseras-url" +version = "0.1.0" +edition = "2024" +license = "ISC" +readme = "README.md" +description = "Decentralized URL shortener built on tesseras-dht" +categories = ["network-programming"] +homepage = "https://tesseras.net" +keywords = ["dht", "kademlia", "p2p", "url-shortener", "distributed"] +repository = "https://got.tesseras.net/?action=summary&path=tesseras-url.git" + +[[bin]] +name = "tud" +path = "src/bin/tud.rs" + +[[bin]] +name = "tu" +path = "src/bin/tu.rs" + +[dependencies] +env_logger = "=0.11.10" +log = "=0.4.29" + +# tesseras +tesseras-dht = "=0.1.2" blob - /dev/null blob + c412bc39bf658c8c6fda93ae398b6e520c327697 (mode 644) --- /dev/null +++ src/base58.rs @@ -0,0 +1,153 @@ +//! Bitcoin-style Base58 encoding/decoding. +//! +//! No external dependencies. Uses the standard Bitcoin +//! alphabet (no 0, O, I, l to avoid ambiguity). + +const ALPHABET: &[u8; 58] = + b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +/// Decode table: ASCII byte → base58 value (255 = invalid). +const DECODE: [u8; 128] = { + let mut table = [255u8; 128]; + let mut i = 0; + while i < 58 { + table[ALPHABET[i] as usize] = i as u8; + i += 1; + } + table +}; + +/// Encode bytes to Base58 string. +pub fn encode(input: &[u8]) -> String { + if input.is_empty() { + return String::new(); + } + + // Count leading zeros + let leading_zeros = input.iter().take_while(|&&b| b == 0).count(); + + // Convert to base58 via repeated division + let mut digits: Vec = Vec::with_capacity(input.len() * 2); + for &byte in input { + let mut carry = byte as u32; + for d in &mut digits { + carry += (*d as u32) << 8; + *d = (carry % 58) as u8; + carry /= 58; + } + while carry > 0 { + digits.push((carry % 58) as u8); + carry /= 58; + } + } + + let mut result = String::with_capacity(leading_zeros + digits.len()); + for _ in 0..leading_zeros { + result.push('1'); + } + for &d in digits.iter().rev() { + result.push(ALPHABET[d as usize] as char); + } + result +} + +/// Decode a Base58 string to bytes. +pub fn decode(input: &str) -> Option> { + if input.is_empty() { + return Some(Vec::new()); + } + + // Count leading '1's (representing zero bytes) + let leading_ones = input.chars().take_while(|&c| c == '1').count(); + + // Convert from base58 via repeated multiplication + let mut bytes: Vec = Vec::with_capacity(input.len()); + for c in input.chars() { + let c = c as usize; + if c >= 128 { + return None; + } + let val = DECODE[c]; + if val == 255 { + return None; + } + let mut carry = val as u32; + for b in &mut bytes { + carry += (*b as u32) * 58; + *b = (carry & 0xFF) as u8; + carry >>= 8; + } + while carry > 0 { + bytes.push((carry & 0xFF) as u8); + carry >>= 8; + } + } + + let mut result = vec![0; leading_ones]; + result.extend(bytes.iter().rev()); + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_empty() { + assert_eq!(encode(b""), ""); + } + + #[test] + fn encode_hello() { + assert_eq!(encode(b"Hello World"), "JxF12TrwUP45BMd"); + } + + #[test] + fn roundtrip() { + let data = b"tesseras-paste test data"; + let encoded = encode(data); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn roundtrip_binary() { + let data: Vec = (0..=255).collect(); + let encoded = encode(&data); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn leading_zeros() { + let data = [0, 0, 0, 1, 2, 3]; + let encoded = encode(&data); + assert!(encoded.starts_with("111")); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn decode_invalid_char() { + assert!(decode("invalid0char").is_none()); + } + + #[test] + fn decode_empty() { + assert_eq!(decode("").unwrap(), Vec::::new()); + } + + #[test] + fn known_vector() { + // SHA-256 of "test" in base58 + let hash: [u8; 32] = { + use tesseras_dht::sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(b"test"); + h.finalize().into() + }; + let encoded = encode(&hash); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, hash); + } +} blob - /dev/null blob + 1bae614d5ab7bf3cb80d03ee3772b244a3437e52 (mode 644) --- /dev/null +++ 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] [args]"); + eprintln!(); + eprintln!("commands:"); + eprintln!(" shorten [-t ttl] [-s slug] create short URL"); + eprintln!(" resolve show target URL"); + eprintln!(" del 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 { + let s = s.trim(); + if s == "0" { + return Ok(0); + } + if let Some(h) = s.strip_suffix('h') { + h.parse::() + .map(|v| v * 3600) + .map_err(|e| e.to_string()) + } else if let Some(m) = s.strip_suffix('m') { + m.parse::().map(|v| v * 60).map_err(|e| e.to_string()) + } else if let Some(sec) = s.strip_suffix('s') { + sec.parse::().map_err(|e| e.to_string()) + } else { + s.parse::().map_err(|e| e.to_string()) + } +} + +fn main() { + let args: Vec = 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 = 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); + } + } +} blob - /dev/null blob + 976ca46c07bf354ac3c240c4401e8b8f132e1483 (mode 644) --- /dev/null +++ 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 = None; + let mut http_port: Option = None; + let mut global = false; + let mut no_auto_bootstrap = false; + let mut verbose = false; + let mut bootstrap: Vec = Vec::new(); + + let args: Vec = 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 { + 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; +} blob - /dev/null blob + 8c63f47e01489723c859b7e534ddbf77d004efb6 (mode 644) --- /dev/null +++ src/daemon.rs @@ -0,0 +1,535 @@ +//! Daemon main loop, Unix socket listener, and HTTP server. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use tesseras_dht::Node; + +use crate::base58; +use crate::ops; +use crate::protocol::{self, Request, Response}; +use crate::store::UrlStore; +use crate::url_entry::UrlEntry; + +/// How often to garbage-collect expired entries (10 min). +const GC_INTERVAL: Duration = Duration::from_secs(600); + +/// How often to republish local entries to the DHT (30 min). +const REPUBLISH_INTERVAL: Duration = Duration::from_secs(1800); + +/// How often to persist routing table and state (5 min). +const SAVE_INTERVAL: Duration = Duration::from_secs(300); + +/// How often to attempt re-join when routing table is empty (60 s). +const REJOIN_INTERVAL: Duration = Duration::from_secs(60); + +/// How often to sync DHT-replicated values to local store (5 s). +const SYNC_INTERVAL: Duration = Duration::from_secs(5); + +/// A request from the socket thread to the main thread. +pub struct DaemonRequest { + pub cmd: Request, + pub reply: mpsc::Sender, +} + +/// Run the daemon main loop. +pub fn run_daemon( + node: &mut Node, + store: &UrlStore, + rx: &mpsc::Receiver, + shutdown: &AtomicBool, + bootstrap: &[String], +) { + let mut last_gc = Instant::now(); + let mut last_republish = Instant::now() - REPUBLISH_INTERVAL; + let mut last_save = Instant::now(); + let mut last_sync = Instant::now(); + let mut last_rejoin = Instant::now(); + + log::info!("daemon main loop started"); + + while !shutdown.load(Ordering::Relaxed) { + let _ = node.poll_timeout(Duration::from_millis(100)); + + while let Ok(req) = rx.try_recv() { + let is_shutdown = matches!(req.cmd, Request::Shutdown); + let resp = handle_request(node, store, req.cmd); + let _ = req.reply.send(resp); + if is_shutdown { + shutdown.store(true, Ordering::Relaxed); + } + } + + // Re-join bootstrap nodes when the routing table is empty + if node.routing_table_size() == 0 + && !bootstrap.is_empty() + && last_rejoin.elapsed() >= REJOIN_INTERVAL + { + last_rejoin = Instant::now(); + log::warn!("routing table empty, re-joining bootstrap nodes"); + for peer in bootstrap { + let parts: Vec<&str> = peer.rsplitn(2, ':').collect(); + if parts.len() != 2 { + continue; + } + let host = parts[1]; + if let Ok(p) = parts[0].parse::() { + use std::net::ToSocketAddrs; + if let Ok(addrs) = + format!("{host}:{p}").to_socket_addrs() + { + for addr in addrs { + node.unban(&addr); + } + } + if let Err(e) = node.join(host, p) { + log::warn!("rejoin: failed to join {peer}: {e}"); + } else { + log::info!("rejoin: sent join to {peer}"); + } + } + } + } + + if last_sync.elapsed() >= SYNC_INTERVAL { + last_sync = Instant::now(); + sync_dht_to_store(node, store); + } + + if last_gc.elapsed() >= GC_INTERVAL { + last_gc = Instant::now(); + match store.gc() { + Ok(0) => {} + Ok(n) => log::info!("gc: removed {n} expired entries"), + Err(e) => log::warn!("gc: {e}"), + } + } + + if last_republish.elapsed() >= REPUBLISH_INTERVAL { + last_republish = Instant::now(); + republish(node, store); + } + + if last_save.elapsed() >= SAVE_INTERVAL { + last_save = Instant::now(); + node.save_state(); + } + } + + log::info!("daemon main loop stopped, shutting down"); + node.shutdown(); +} + +/// Dispatch a single client request. +fn handle_request( + node: &mut Node, + store: &UrlStore, + cmd: Request, +) -> Response { + match cmd { + Request::Shorten { + ttl_secs, + slug, + target_url, + } => match ops::shorten_url(node, store, &target_url, ttl_secs, &slug) + { + Ok(slug) => Response::Ok(slug), + Err(e) => Response::Err(e.to_string()), + }, + Request::Resolve { slug } => { + match ops::resolve_url(node, store, &slug) { + Ok(url) => Response::Ok(url), + Err(e) => Response::Err(e.to_string()), + } + } + Request::Del { slug } => { + match ops::delete_url(node, store, &slug) { + Ok(()) => Response::Ok("deleted".into()), + Err(e) => Response::Err(e.to_string()), + } + } + Request::List => { + let entries = store.list_entries(); + if entries.is_empty() { + return Response::Ok("(empty)".into()); + } + let list: Vec = entries + .iter() + .map(|(slug, url)| format!("{slug}\t{url}")) + .collect(); + Response::Ok(base58::encode(list.join("\n").as_bytes())) + } + Request::Status => { + let m = node.metrics(); + let status = format!( + "peers={} stored={} urls={} \ + sent={} recv={} lookups={}/{}", + node.routing_table_size(), + node.storage_count(), + store.entry_count(), + m.messages_sent, + m.messages_received, + m.lookups_completed, + m.lookups_started, + ); + Response::Ok(status) + } + Request::Shutdown => Response::Ok("shutting down".into()), + } +} + +/// Copy DHT-replicated values into the local file store. +fn sync_dht_to_store(node: &Node, store: &UrlStore) { + for (key, value) in node.dht_values() { + if key.len() != 32 { + continue; + } + if store.get_entry(&key).is_none() { + let _ = store.put_entry(&key, &value); + } + } +} + +/// Re-announce locally stored entries to the DHT. +fn republish(node: &mut Node, store: &UrlStore) { + let keys = store.original_keys(); + if keys.is_empty() { + return; + } + + let mut count = 0u32; + for key in &keys { + if let Some(data) = store.get_entry(key) + && let Some(entry) = UrlEntry::from_bytes(&data) + { + let remaining = if entry.ttl_secs == 0 { + u16::MAX + } else if entry.is_expired() { + continue; + } else { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let expires = + entry.created_at.saturating_add(entry.ttl_secs); + let rem = expires.saturating_sub(now); + std::cmp::min(rem, u16::MAX as u64) as u16 + }; + node.put(key, &data, remaining, false); + count += 1; + } + } + if count > 0 { + log::info!("republish: announced {count} entries to DHT"); + } +} + +/// Run the Unix socket listener thread. +pub fn run_unix_listener( + sock_path: &Path, + tx: mpsc::Sender, + shutdown: &AtomicBool, +) { + let _ = std::fs::remove_file(sock_path); + + let listener = match std::os::unix::net::UnixListener::bind(sock_path) { + Ok(l) => l, + Err(e) => { + log::error!( + "unix: failed to bind {}: {e}", + sock_path.display() + ); + return; + } + }; + + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o770); + if let Err(e) = std::fs::set_permissions(sock_path, perms) { + log::warn!("unix: failed to set socket permissions: {e}"); + } + + if let Err(e) = listener.set_nonblocking(true) { + log::error!("unix: failed to set non-blocking: {e}"); + return; + } + + log::info!("unix: listening on {}", sock_path.display()); + + while !shutdown.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + if let Err(e) = handle_client(stream, &tx) { + log::debug!("unix: client disconnected: {e}"); + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => { + log::warn!("unix: accept failed: {e}"); + std::thread::sleep(Duration::from_millis(100)); + } + } + } + + let _ = std::fs::remove_file(sock_path); +} + +/// Maximum protocol line size (16 KiB is generous for URLs). +const MAX_LINE_SIZE: usize = 16 * 1024; + +fn handle_client( + stream: std::os::unix::net::UnixStream, + tx: &mpsc::Sender, +) -> Result<(), Box> { + if let Err(e) = stream.set_nonblocking(false) { + log::warn!("unix: failed to set blocking mode: {e}"); + return Err(e.into()); + } + if let Err(e) = stream.set_read_timeout(Some(Duration::from_secs(60))) { + log::warn!("unix: failed to set read timeout: {e}"); + return Err(e.into()); + } + + let mut reader = BufReader::new(&stream); + let mut writer = &stream; + let mut line = String::new(); + + loop { + line.clear(); + let n = (&mut reader) + .take(MAX_LINE_SIZE as u64) + .read_line(&mut line)?; + if n == 0 { + break; + } + if !line.ends_with('\n') && n >= MAX_LINE_SIZE { + let resp = protocol::format_response(&Response::Err( + "request too large".into(), + )); + writer.write_all(resp.as_bytes())?; + let mut discard = Vec::new(); + let _ = (&mut reader) + .take(MAX_LINE_SIZE as u64) + .read_until(b'\n', &mut discard); + continue; + } + let line = line.trim(); + let cmd = match protocol::parse_request(line) { + Ok(c) => c, + Err(e) => { + let resp = protocol::format_response(&Response::Err(e)); + writer.write_all(resp.as_bytes())?; + continue; + } + }; + + let is_shutdown = matches!(cmd, Request::Shutdown); + + let (reply_tx, reply_rx) = mpsc::channel(); + tx.send(DaemonRequest { + cmd, + reply: reply_tx, + })?; + + let resp = reply_rx + .recv_timeout(Duration::from_secs(60)) + .unwrap_or(Response::Err("timeout".into())); + writer.write_all(protocol::format_response(&resp).as_bytes())?; + + if is_shutdown { + break; + } + } + Ok(()) +} + +// ── HTTP server ───────────────────────────────────── + +/// Maximum concurrent HTTP handler threads. +const MAX_HTTP_THREADS: usize = 8; + +/// Minimal HTTP server. Redirects at /. +pub fn run_http( + port: u16, + sock_path: &Path, + store: &UrlStore, + shutdown: &AtomicBool, +) { + let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port)); + let listener = match std::net::TcpListener::bind(addr) { + Ok(l) => l, + Err(e) => { + log::error!("http: failed to bind {addr}: {e}"); + return; + } + }; + if let Err(e) = listener.set_nonblocking(true) { + log::error!("http: failed to set non-blocking: {e}"); + return; + } + + log::info!("http: listening on {addr}"); + + let active = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let sock_owned = sock_path.to_path_buf(); + + while !shutdown.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + if active.load(Ordering::Relaxed) >= MAX_HTTP_THREADS { + log::warn!("http: max connections reached, rejecting"); + let mut s = stream; + let _ = s.write_all( + b"HTTP/1.1 503 Service Unavailable\r\n\ + Connection: close\r\n\r\n", + ); + continue; + } + let store = store.clone(); + let sock = sock_owned.clone(); + let counter = Arc::clone(&active); + counter.fetch_add(1, Ordering::Relaxed); + std::thread::spawn(move || { + handle_http(stream, &store, &sock); + counter.fetch_sub(1, Ordering::Relaxed); + }); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => { + log::debug!("http: accept failed: {e}"); + std::thread::sleep(Duration::from_millis(100)); + } + } + } +} + +fn handle_http( + mut stream: std::net::TcpStream, + store: &UrlStore, + sock_path: &Path, +) { + use std::io::Read; + + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + + let mut buf = [0u8; 4096]; + let n = match stream.read(&mut buf) { + Ok(n) => n, + Err(_) => return, + }; + let request = String::from_utf8_lossy(&buf[..n]); + + let mut parts = request.split_whitespace(); + let method = parts.next().unwrap_or(""); + let path = match parts.next() { + Some(p) => p, + None => { + http_response(&mut stream, 400, "text/plain", b"Bad Request"); + return; + } + }; + + if method != "GET" && method != "HEAD" { + http_response( + &mut stream, + 405, + "text/plain", + b"Method Not Allowed", + ); + return; + } + + if path == "/" || path == "/favicon.ico" { + http_response( + &mut stream, + 200, + "text/plain", + b"tesseras-url shortener\n", + ); + return; + } + + let slug = path.trim_start_matches('/'); + if slug.is_empty() { + http_response(&mut stream, 400, "text/plain", b"missing slug"); + return; + } + + // Try local store first + let dht_key = UrlEntry::dht_key(slug); + let target = if let Some(data) = store.get_entry(&dht_key) { + UrlEntry::from_bytes(&data).map(|e| e.target_url) + } else { + // Fall back to daemon (DHT lookup) + dht_resolve_via_socket(sock_path, slug) + }; + + match target { + Some(url) => http_redirect(&mut stream, &url), + None => { + http_response(&mut stream, 404, "text/plain", b"not found") + } + } +} + +/// Ask the daemon for a URL resolution via Unix socket. +fn dht_resolve_via_socket(sock_path: &Path, slug: &str) -> Option { + let sock = std::os::unix::net::UnixStream::connect(sock_path).ok()?; + sock.set_read_timeout(Some(Duration::from_secs(35))).ok(); + sock.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + let cmd = format!("RESOLVE {slug}\n"); + (&sock).write_all(cmd.as_bytes()).ok()?; + + let reader = BufReader::new(&sock); + let line = reader.lines().next()?.ok()?; + let url = line.strip_prefix("OK ")?; + Some(url.to_string()) +} + +fn http_redirect(stream: &mut std::net::TcpStream, location: &str) { + let header = format!( + "HTTP/1.1 302 Found\r\n\ + Location: {location}\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\ + \r\n" + ); + let _ = stream.write_all(header.as_bytes()); +} + +fn http_response( + stream: &mut std::net::TcpStream, + status: u16, + content_type: &str, + body: &[u8], +) { + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 405 => "Method Not Allowed", + 500 => "Internal Server Error", + 503 => "Service Unavailable", + _ => "Unknown", + }; + let header = format!( + "HTTP/1.1 {status} {reason}\r\n\ + Content-Type: {content_type}\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n", + body.len(), + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(body); +} blob - /dev/null blob + 8c2af0f730121f7b346134aaf7e06c1eb2746345 (mode 644) --- /dev/null +++ src/dns.rs @@ -0,0 +1,340 @@ +//! DNS SRV record lookup via libc `res_query`. +//! +//! Discovers bootstrap peers by querying +//! `_tesseras._udp.tesseras.net` for SRV records. +//! Falls back to an empty list on any DNS or parse error, +//! logging a warning so the operator knows discovery failed. +//! +//! ## Anti-spoofing +//! +//! Two mitigations against DNS spoofing are applied: +//! +//! - **DNSSEC (AD flag)**: if the resolver validated the response +//! via DNSSEC, the AD bit is set. When AD is absent, a warning +//! is logged so the operator knows the response is unauthenticated. +//! +//! - **Host suffix pinning**: only SRV targets ending in +//! `.tesseras.net` are accepted. This limits spoofed responses +//! to hosts within the trusted domain. +//! +//! These checks only apply to the automatic SRV discovery path. +//! Explicit `-b` peers bypass DNS entirely. + +/// Default SRV record name for bootstrap discovery. +const SRV_NAME: &str = "_tesseras._udp.tesseras.net"; + +/// DNS class: Internet. +const C_IN: i32 = 1; + +/// DNS record type: SRV (RFC 2782). +const T_SRV: i32 = 33; + +/// Fixed DNS header size (ID + flags + 4 counts). +const HFIXEDSZ: usize = 12; + +/// Maximum DNS response buffer. Covers UDP responses +/// (512 bytes standard, up to 4096 with EDNS0). +const MAX_ANSWER: usize = 4096; + +/// Sanity cap on qdcount/ancount to avoid looping on +/// malformed responses (no legitimate response has >64 RRs +/// in 4096 bytes). +const MAX_RR_COUNT: usize = 64; + +/// Only SRV targets under this domain suffix are accepted. +/// Prevents a spoofed DNS response from directing the daemon +/// to attacker-controlled hosts. +const TRUSTED_SUFFIX: &str = ".tesseras.net"; + +/// Bit mask for the AD (Authenticated Data) flag in the DNS +/// header flags field (byte 3, bit 5). Set by a validating +/// resolver when DNSSEC verification succeeded. +const DNS_FLAG_AD: u8 = 0x20; + +/// A resolved SRV record with target host and port. +pub struct SrvRecord { + pub host: String, + pub port: u16, +} + +/// `h_errno` values from ``. +const HOST_NOT_FOUND: i32 = 1; +const TRY_AGAIN: i32 = 2; +const NO_RECOVERY: i32 = 3; +const NO_DATA: i32 = 4; + +unsafe extern "C" { + fn res_query( + dname: *const u8, + class: i32, + rtype: i32, + answer: *mut u8, + anslen: i32, + ) -> i32; + + fn dn_expand( + msg: *const u8, + eomorig: *const u8, + comp_dn: *const u8, + exp_dn: *mut u8, + length: i32, + ) -> i32; + + /// Per-thread DNS error code, set by `res_query` on failure. + static h_errno: i32; +} + +/// Query DNS for SRV records at `_tesseras._udp.tesseras.net` +/// and return the discovered (host, port) pairs. +/// Returns an empty Vec on any DNS or parsing failure. +pub fn lookup_bootstrap() -> Vec { + lookup_srv(SRV_NAME) +} + +/// Perform the SRV query and parse the response. +fn lookup_srv(name: &str) -> Vec { + let mut buf = vec![0u8; MAX_ANSWER]; + + // res_query expects a null-terminated C string. + let mut cname = name.as_bytes().to_vec(); + cname.push(0); + + // SAFETY: cname is a valid null-terminated byte string, + // buf is a properly sized mutable buffer. + let len = unsafe { + res_query( + cname.as_ptr(), + C_IN, + T_SRV, + buf.as_mut_ptr(), + buf.len() as i32, + ) + }; + + if len < 0 { + let reason = match unsafe { h_errno } { + HOST_NOT_FOUND => "host not found", + TRY_AGAIN => "timeout or temporary failure", + NO_RECOVERY => "non-recoverable server error", + NO_DATA => "no SRV records for this name", + other => { + log::warn!( + "dns: SRV query for {name} failed (h_errno={other})" + ); + return Vec::new(); + } + }; + log::warn!("dns: SRV query for {name} failed: {reason}"); + return Vec::new(); + } + + let len = len as usize; + + if len < HFIXEDSZ { + log::warn!("dns: SRV response too short ({len} bytes)"); + return Vec::new(); + } + + // res_query returns the full (untruncated) response length, + // which may exceed the buffer when the answer was truncated. + // Cap to the actual buffer size to avoid an out-of-bounds slice. + let len = len.min(buf.len()); + + parse_srv_response(&buf[..len]) +} + +/// Read a big-endian u16 from `data[*pos..]`, advancing `*pos` by 2. +/// Returns `None` if there aren't enough bytes remaining. +fn read_u16(data: &[u8], pos: &mut usize) -> Option { + if *pos + 2 > data.len() { + return None; + } + let val = u16::from_be_bytes([data[*pos], data[*pos + 1]]); + *pos += 2; + Some(val) +} + +/// Skip over a compressed domain name in the DNS wire format. +/// Returns `false` if the name is malformed or extends past the buffer. +fn skip_name(data: &[u8], pos: &mut usize) -> bool { + while *pos < data.len() { + let label_len = data[*pos] as usize; + if label_len == 0 { + *pos += 1; + return true; + } + // Compression pointer: top 2 bits set, followed by 1 offset byte. + if label_len & 0xC0 == 0xC0 { + if *pos + 2 > data.len() { + return false; + } + *pos += 2; + return true; + } + if *pos + 1 + label_len > data.len() { + return false; + } + *pos += 1 + label_len; + } + false +} + +/// Expand a compressed domain name at `msg[*pos..]` using +/// libc `dn_expand`. Advances `*pos` past the compressed +/// name. Returns `None` on any error. +fn expand_name(msg: &[u8], pos: &mut usize) -> Option { + if *pos >= msg.len() { + return None; + } + + // MAXDNAME is 1025 on OpenBSD; 512 is more than enough + // for any valid domain name (max 253 chars + null) and + // gives headroom for malformed names without truncation. + let mut name_buf = [0u8; 512]; + + // SAFETY: pos is bounds-checked above, eomorig points to + // one past the last byte of msg. dn_expand will not read + // past eomorig and writes at most `length` bytes to exp_dn. + let n = unsafe { + dn_expand( + msg.as_ptr(), + msg.as_ptr().add(msg.len()), + msg.as_ptr().add(*pos), + name_buf.as_mut_ptr(), + name_buf.len() as i32, + ) + }; + if n < 0 { + return None; + } + *pos += n as usize; + + // dn_expand null-terminates the output. + let end = name_buf.iter().position(|&b| b == 0).unwrap_or(0); + String::from_utf8(name_buf[..end].to_vec()).ok() +} + +/// Parse a raw DNS response and extract SRV records. +/// Checks the AD flag for DNSSEC validation and rejects +/// SRV targets outside the trusted domain suffix. +fn parse_srv_response(data: &[u8]) -> Vec { + if data.len() < HFIXEDSZ { + return Vec::new(); + } + + // Check DNSSEC AD (Authenticated Data) flag. + // Byte 3 of the header contains AD at bit 5. + if data[3] & DNS_FLAG_AD != 0 { + log::info!("dns: response has AD flag (DNSSEC validated)"); + } else { + log::warn!( + "dns: response lacks AD flag (DNSSEC not validated); \ + results may be spoofed" + ); + } + + let mut pos = 4; // skip ID + flags + let qdcount = + (read_u16(data, &mut pos).unwrap_or(0) as usize).min(MAX_RR_COUNT); + let ancount = + (read_u16(data, &mut pos).unwrap_or(0) as usize).min(MAX_RR_COUNT); + pos += 4; // skip nscount + arcount + + // Skip question section. + for _ in 0..qdcount { + if !skip_name(data, &mut pos) { + log::debug!("dns: failed to skip question name"); + return Vec::new(); + } + if pos + 4 > data.len() { + return Vec::new(); + } + pos += 4; // qtype + qclass + } + + let mut records = Vec::new(); + + for _ in 0..ancount { + if !skip_name(data, &mut pos) { + break; + } + + let rtype = match read_u16(data, &mut pos) { + Some(v) => v, + None => break, + }; + // rclass + if read_u16(data, &mut pos).is_none() { + break; + } + // ttl (4 bytes) + if pos + 4 > data.len() { + break; + } + pos += 4; + + let rdlength = match read_u16(data, &mut pos) { + Some(v) => v as usize, + None => break, + }; + + // Guard: rdlength must not extend past the buffer. + if pos + rdlength > data.len() { + log::debug!("dns: rdlength extends past response buffer"); + break; + } + + if rtype != T_SRV as u16 || rdlength < 6 { + pos += rdlength; + continue; + } + + let rdata_start = pos; + + // SRV RDATA: priority(2) + weight(2) + port(2) + target + if read_u16(data, &mut pos).is_none() { + break; + } + if read_u16(data, &mut pos).is_none() { + break; + } + let srv_port = match read_u16(data, &mut pos) { + Some(v) => v, + None => break, + }; + + let target = match expand_name(data, &mut pos) { + Some(name) => name, + None => { + pos = rdata_start + rdlength; + continue; + } + }; + + // Advance to end of RDATA regardless of how much + // expand_name consumed (defensive against short reads). + pos = rdata_start + rdlength; + + // SRV target "." means no service available (RFC 2782). + if target.is_empty() || target == "." { + continue; + } + + // Anti-spoofing: only accept targets under the trusted domain. + let lower = target.to_ascii_lowercase(); + if !lower.ends_with(TRUSTED_SUFFIX) { + log::warn!( + "dns: rejecting SRV target '{target}' \ + (not under {TRUSTED_SUFFIX})" + ); + continue; + } + + records.push(SrvRecord { + host: target, + port: srv_port, + }); + } + + records +} blob - /dev/null blob + 714391379d57905f946e1401dcf56362e7f10d01 (mode 644) --- /dev/null +++ src/ops.rs @@ -0,0 +1,140 @@ +//! High-level URL shortener operations. +//! +//! Each function combines local storage and DHT interaction. + +use std::time::Duration; + +use tesseras_dht::Node; + +use crate::store::UrlStore; +use crate::url_entry::{MAX_URL_SIZE, UrlEntry}; + +/// Timeout for blocking DHT lookups. +const OP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Errors from URL operations. +#[derive(Debug)] +pub enum UrlError { + InvalidSlug, + InvalidUrl, + NotFound, + Expired, + TooLarge, + Internal(String), +} + +impl std::fmt::Display for UrlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidSlug => write!(f, "invalid slug"), + Self::InvalidUrl => write!(f, "invalid url"), + Self::NotFound => write!(f, "not found"), + Self::Expired => write!(f, "expired"), + Self::TooLarge => write!(f, "url too large"), + Self::Internal(msg) => write!(f, "internal: {msg}"), + } + } +} + +/// Create a shortened URL. Returns the slug. +pub fn shorten_url( + node: &mut Node, + store: &UrlStore, + target_url: &str, + ttl_secs: u64, + slug: &str, +) -> Result { + if target_url.len() > MAX_URL_SIZE { + return Err(UrlError::TooLarge); + } + if !target_url.starts_with("http://") + && !target_url.starts_with("https://") + { + return Err(UrlError::InvalidUrl); + } + + let slug = if slug == "auto" { + UrlEntry::auto_slug(target_url) + } else { + slug.to_string() + }; + + if slug.is_empty() { + return Err(UrlError::InvalidSlug); + } + + let dht_key = UrlEntry::dht_key(&slug); + let entry = UrlEntry::new(slug.clone(), target_url.to_string(), ttl_secs); + let serialized = entry.to_bytes(); + + store + .put_entry(&dht_key, &serialized) + .map_err(|e| UrlError::Internal(e.to_string()))?; + + let dht_ttl = if ttl_secs == 0 { + u16::MAX + } else { + std::cmp::min(ttl_secs, u16::MAX as u64) as u16 + }; + node.put(&dht_key, &serialized, dht_ttl, false); + + log::info!( + "shorten: {} -> {} (ttl={})", + slug, + target_url, + if ttl_secs == 0 { + "forever".to_string() + } else { + format!("{ttl_secs}s") + } + ); + + Ok(slug) +} + +/// Resolve a slug to its target URL. +/// Tries local store first, then falls back to DHT lookup. +pub fn resolve_url( + node: &mut Node, + store: &UrlStore, + slug: &str, +) -> Result { + let dht_key = UrlEntry::dht_key(slug); + + let data = if let Some(local) = store.get_entry(&dht_key) { + local + } else { + let vals = node.get_blocking(&dht_key, OP_TIMEOUT); + if vals.is_empty() { + return Err(UrlError::NotFound); + } + match vals.iter().find(|v| { + UrlEntry::from_bytes(v) + .map(|e| e.slug == slug) + .unwrap_or(false) + }) { + Some(v) => v.clone(), + None => return Err(UrlError::NotFound), + } + }; + + let entry = UrlEntry::from_bytes(&data).ok_or(UrlError::NotFound)?; + if entry.is_expired() { + return Err(UrlError::Expired); + } + + Ok(entry.target_url) +} + +/// Delete a URL entry from local store and the DHT. +pub fn delete_url( + node: &mut Node, + store: &UrlStore, + slug: &str, +) -> Result<(), UrlError> { + let dht_key = UrlEntry::dht_key(slug); + store.remove_entry(&dht_key); + node.delete(&dht_key); + log::info!("del: removed slug {slug}"); + Ok(()) +} blob - /dev/null blob + 60d2fa9ca454a7cd064c60762b5c98d31a9b0dc5 (mode 644) --- /dev/null +++ src/protocol.rs @@ -0,0 +1,158 @@ +//! Unix socket protocol for daemon ↔ CLI. +//! +//! Simple line-oriented text protocol: +//! SHORTEN \n +//! RESOLVE \n +//! DEL \n +//! LIST\n +//! STATUS\n +//! SHUTDOWN\n +//! +//! Responses: +//! OK \n +//! ERR \n + +/// A parsed request received from the CLI over the Unix socket. +pub enum Request { + Shorten { + ttl_secs: u64, + slug: String, + target_url: String, + }, + Resolve { + slug: String, + }, + Del { + slug: String, + }, + List, + Status, + Shutdown, +} + +/// A response sent back to the CLI over the Unix socket. +pub enum Response { + Ok(String), + Err(String), +} + +/// Parse a single protocol line into a [`Request`]. +pub fn parse_request(line: &str) -> Result { + let line = line.trim(); + if line.is_empty() { + return Err("empty request".into()); + } + + let mut parts = line.splitn(4, ' '); + let cmd = parts.next().unwrap(); + + match cmd { + "SHORTEN" => { + let ttl_str = parts + .next() + .ok_or("SHORTEN requires: SHORTEN ")?; + let slug = parts + .next() + .ok_or("SHORTEN requires: SHORTEN ")?; + let target_url = parts + .next() + .ok_or("SHORTEN requires: SHORTEN ")?; + let ttl_secs: u64 = + ttl_str.parse().map_err(|_| "invalid TTL number")?; + Ok(Request::Shorten { + ttl_secs, + slug: slug.to_string(), + target_url: target_url.to_string(), + }) + } + "RESOLVE" => { + let slug = parts.next().ok_or("RESOLVE requires: RESOLVE ")?; + Ok(Request::Resolve { + slug: slug.to_string(), + }) + } + "DEL" => { + let slug = parts.next().ok_or("DEL requires: DEL ")?; + Ok(Request::Del { + slug: slug.to_string(), + }) + } + "LIST" => Ok(Request::List), + "STATUS" => Ok(Request::Status), + "SHUTDOWN" => Ok(Request::Shutdown), + _ => Err(format!("unknown command: {cmd}")), + } +} + +/// Serialize a [`Response`] into a protocol line. +pub fn format_response(r: &Response) -> String { + match r { + Response::Ok(data) => format!("OK {data}\n"), + Response::Err(msg) => format!("ERR {msg}\n"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_shorten() { + let r = + parse_request("SHORTEN 0 auto https://example.com").unwrap(); + match r { + Request::Shorten { + ttl_secs, + slug, + target_url, + } => { + assert_eq!(ttl_secs, 0); + assert_eq!(slug, "auto"); + assert_eq!(target_url, "https://example.com"); + } + _ => panic!("expected Shorten"), + } + } + + #[test] + fn parse_shorten_custom_slug() { + let r = + parse_request("SHORTEN 3600 myslug https://example.com").unwrap(); + match r { + Request::Shorten { slug, .. } => assert_eq!(slug, "myslug"), + _ => panic!("expected Shorten"), + } + } + + #[test] + fn parse_resolve() { + let r = parse_request("RESOLVE abc123").unwrap(); + match r { + Request::Resolve { slug } => assert_eq!(slug, "abc123"), + _ => panic!("expected Resolve"), + } + } + + #[test] + fn parse_status() { + assert!(matches!( + parse_request("STATUS").unwrap(), + Request::Status + )); + } + + #[test] + fn parse_list() { + assert!(matches!(parse_request("LIST").unwrap(), Request::List)); + } + + #[test] + fn parse_empty_fails() { + assert!(parse_request("").is_err()); + } + + #[test] + fn parse_unknown_fails() { + assert!(parse_request("FOOBAR").is_err()); + } +} blob - /dev/null blob + 43ce4d21dd5d4cc2c9c63251336f7b6d72fc69a2 (mode 644) --- /dev/null +++ src/sandbox.rs @@ -0,0 +1,113 @@ +//! OpenBSD pledge(2) and unveil(2) wrappers. + +use std::ffi::{CString, c_char}; +use std::path::Path; + +unsafe extern "C" { + fn pledge(promises: *const c_char, execpromises: *const c_char) -> i32; + fn unveil(path: *const c_char, permissions: *const c_char) -> i32; +} + +/// Valid pledge promises on OpenBSD. +/// See `pledgereq[]` in `/usr/src/sys/kern/kern_pledge.c`. +const VALID_PROMISES: &[&str] = &[ + "audio", + "bpf", + "chown", + "cpath", + "disklabel", + "dns", + "dpath", + "drm", + "error", + "exec", + "fattr", + "flock", + "getpw", + "id", + "inet", + "mcast", + "pf", + "proc", + "prot_exec", + "ps", + "recvfd", + "route", + "rpath", + "sendfd", + "settime", + "stdio", + "tape", + "tmppath", + "tty", + "unix", + "unveil", + "video", + "vminfo", + "vmm", + "wpath", + "wroute", +]; + +/// Valid unveil permission characters. +const VALID_PERMS: &[u8] = b"rwcx"; + +/// Restrict the process to the given pledge promises. +pub fn do_pledge(promises: &str) { + for word in promises.split_whitespace() { + if !VALID_PROMISES.contains(&word) { + log::error!("pledge: unknown promise: {word}"); + std::process::exit(1); + } + } + let c = CString::new(promises).unwrap_or_else(|_| { + log::error!("pledge: promises contain NUL byte"); + std::process::exit(1); + }); + let ret = unsafe { pledge(c.as_ptr(), std::ptr::null()) }; + if ret != 0 { + let err = std::io::Error::last_os_error(); + log::error!("pledge failed: {err}"); + std::process::exit(1); + } + log::debug!("pledge applied"); +} + +/// Add a path to the unveil whitelist with the given permissions. +/// Permissions: "r" read, "w" write, "c" create, "x" execute. +pub fn do_unveil(path: &Path, perms: &str) { + if perms.is_empty() + || !perms.as_bytes().iter().all(|b| VALID_PERMS.contains(b)) + { + log::error!("unveil: invalid permissions"); + std::process::exit(1); + } + let p = CString::new(path.as_os_str().as_encoded_bytes()).unwrap_or_else( + |_| { + log::error!("unveil: path contains NUL byte"); + std::process::exit(1); + }, + ); + let f = CString::new(perms).unwrap_or_else(|_| { + log::error!("unveil: permissions contain NUL byte"); + std::process::exit(1); + }); + let ret = unsafe { unveil(p.as_ptr(), f.as_ptr()) }; + if ret != 0 { + let err = std::io::Error::last_os_error(); + log::error!("unveil failed: {err}"); + std::process::exit(1); + } + log::debug!("unveil: path added"); +} + +/// Lock the unveil list — no further unveil calls allowed. +pub fn unveil_lock() { + let ret = unsafe { unveil(std::ptr::null(), std::ptr::null()) }; + if ret != 0 { + let err = std::io::Error::last_os_error(); + log::error!("unveil lock failed: {err}"); + std::process::exit(1); + } + log::debug!("unveil locked"); +} blob - /dev/null blob + d15bf1c69cdd17e70efb770838c2a87802170ece (mode 644) --- /dev/null +++ src/store.rs @@ -0,0 +1,250 @@ +//! Filesystem-based URL entry storage. +//! +//! Simple directory layout: +//! /urls/ +//! /contacts.bin + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::base58; +use crate::url_entry::UrlEntry; + +/// Persistent URL store backed by the filesystem. +#[derive(Clone)] +pub struct UrlStore { + root: PathBuf, +} + +impl UrlStore { + /// Open or create a store rooted at the given directory. + pub fn open(root: &Path) -> std::io::Result { + fs::create_dir_all(root.join("urls"))?; + Ok(UrlStore { + root: root.to_path_buf(), + }) + } + + fn entry_path(&self, key: &[u8]) -> PathBuf { + self.root.join("urls").join(base58::encode(key)) + } + + /// Write a URL entry to disk atomically. + /// The key (32 bytes) is prepended to the file. + pub fn put_entry( + &self, + key: &[u8], + value: &[u8], + ) -> std::io::Result<()> { + let path = self.entry_path(key); + atomic_write(&path, &[key, value]) + } + + /// Read a URL entry from disk. Returns `None` if expired or absent. + pub fn get_entry(&self, key: &[u8]) -> Option> { + let path = self.entry_path(key); + let data = fs::read(&path).ok()?; + if data.len() < 32 { + return None; + } + let value = data[32..].to_vec(); + + if let Some(entry) = UrlEntry::from_bytes(&value) + && entry.is_expired() + { + return None; + } + Some(value) + } + + /// Delete a URL entry from disk. + pub fn remove_entry(&self, key: &[u8]) { + let _ = fs::remove_file(self.entry_path(key)); + } + + /// List all non-expired entry keys. + pub fn original_keys(&self) -> Vec> { + let dir = self.root.join("urls"); + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + + let mut keys = Vec::new(); + for entry in entries.flatten() { + let data = match fs::read(entry.path()) { + Ok(d) => d, + Err(_) => continue, + }; + if data.len() < 32 { + continue; + } + let key = &data[..32]; + let value = &data[32..]; + + if let Some(e) = UrlEntry::from_bytes(value) + && e.is_expired() + { + continue; + } + keys.push(key.to_vec()); + } + keys + } + + /// List all non-expired entries as (slug, target_url) pairs. + pub fn list_entries(&self) -> Vec<(String, String)> { + let dir = self.root.join("urls"); + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + + let mut out = Vec::new(); + for entry in entries.flatten() { + let data = match fs::read(entry.path()) { + Ok(d) => d, + Err(_) => continue, + }; + if data.len() < 32 { + continue; + } + let value = &data[32..]; + if let Some(e) = UrlEntry::from_bytes(value) { + if !e.is_expired() { + out.push((e.slug, e.target_url)); + } + } + } + out + } + + /// Remove expired entries from disk. + pub fn gc(&self) -> std::io::Result { + let dir = self.root.join("urls"); + let entries = fs::read_dir(&dir)?; + let mut removed = 0; + + for entry in entries.flatten() { + let data = match fs::read(entry.path()) { + Ok(d) => d, + Err(_) => continue, + }; + if data.len() < 32 { + continue; + } + let value = &data[32..]; + if let Some(e) = UrlEntry::from_bytes(value) + && e.is_expired() + { + let _ = fs::remove_file(entry.path()); + removed += 1; + } + } + Ok(removed) + } + + /// Count stored entries. + pub fn entry_count(&self) -> usize { + let dir = self.root.join("urls"); + fs::read_dir(&dir).map(|e| e.count()).unwrap_or(0) + } +} + +/// Write data to path atomically (temp + rename). +fn atomic_write(path: &Path, chunks: &[&[u8]]) -> std::io::Result<()> { + let parent = path.parent().unwrap_or(Path::new(".")); + let name = + path.file_name().and_then(|n| n.to_str()).unwrap_or("tmp"); + let tmp = + parent.join(format!(".tmp.{}.{}", std::process::id(), name)); + let mut f = fs::File::create(&tmp)?; + for chunk in chunks { + f.write_all(chunk)?; + } + f.sync_all()?; + fs::rename(&tmp, path) +} + +// ── tesseras-dht persistence traits ───────────────── + +impl tesseras_dht::persist::RoutingPersistence for UrlStore { + fn save_contacts( + &self, + contacts: &[tesseras_dht::persist::ContactRecord], + ) -> Result<(), tesseras_dht::Error> { + let path = self.root.join("contacts.bin"); + let mut buf = Vec::new(); + for c in contacts { + let id = c.id.as_bytes(); + let addr = c.addr.to_string(); + let addr_bytes = addr.as_bytes(); + let len = addr_bytes.len() as u16; + buf.extend_from_slice(&len.to_be_bytes()); + buf.extend_from_slice(id); + buf.extend_from_slice(addr_bytes); + } + atomic_write(&path, &[&buf]).map_err(tesseras_dht::Error::Io)?; + log::info!("store: persisted {} routing contacts", contacts.len()); + Ok(()) + } + + fn load_contacts( + &self, + ) -> Result, tesseras_dht::Error> + { + let path = self.root.join("contacts.bin"); + let data = match fs::read(&path) { + Ok(d) => d, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(e) => return Err(tesseras_dht::Error::Io(e)), + }; + + let mut out = Vec::new(); + let mut pos = 0; + while pos + 2 + 32 <= data.len() { + let addr_len = + u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += 2; + if pos + 32 + addr_len > data.len() { + break; + } + let mut id_bytes = [0u8; 32]; + id_bytes.copy_from_slice(&data[pos..pos + 32]); + pos += 32; + let addr_str = + std::str::from_utf8(&data[pos..pos + addr_len]) + .unwrap_or(""); + pos += addr_len; + if let Ok(addr) = addr_str.parse() { + out.push(tesseras_dht::persist::ContactRecord { + id: tesseras_dht::NodeId::from_bytes(id_bytes), + addr, + }); + } + } + if !out.is_empty() { + log::info!("store: loaded {} routing contacts", out.len()); + } + Ok(out) + } +} + +impl tesseras_dht::persist::DataPersistence for UrlStore { + fn save( + &self, + _records: &[tesseras_dht::persist::StoredRecord], + ) -> Result<(), tesseras_dht::Error> { + Ok(()) + } + + fn load( + &self, + ) -> Result, tesseras_dht::Error> + { + Ok(Vec::new()) + } +} blob - /dev/null blob + f52b4a14152600cb2714a5b2180b8db6d0057744 (mode 644) --- /dev/null +++ src/url_entry.rs @@ -0,0 +1,172 @@ +//! URL entry record format. +//! +//! Binary format (no external serialization deps): +//! version: u8 +//! created_at: u64 (big-endian) +//! ttl_secs: u64 (big-endian) 0 = no expiry +//! slug_len: u16 (big-endian) +//! slug: [u8; slug_len] +//! target_url: [u8] (remaining) + +use tesseras_dht::sha2::{Digest, Sha256}; + +/// Maximum URL size: 8 KiB. +pub const MAX_URL_SIZE: usize = 8 * 1024; + +/// Current format version. +const FORMAT_VERSION: u8 = 1; + +/// Fixed header: version(1) + created_at(8) + ttl(8) + slug_len(2) = 19. +const HEADER_SIZE: usize = 19; + +/// A URL entry stored locally and replicated via the DHT. +#[derive(Debug, Clone)] +pub struct UrlEntry { + pub version: u8, + pub created_at: u64, + pub ttl_secs: u64, + pub slug: String, + pub target_url: String, +} + +impl UrlEntry { + /// Create a new entry with the current timestamp. + pub fn new(slug: String, target_url: String, ttl_secs: u64) -> Self { + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + UrlEntry { + version: FORMAT_VERSION, + created_at, + ttl_secs, + slug, + target_url, + } + } + + /// Compute the 32-byte DHT key from a slug: SHA256(slug). + pub fn dht_key(slug: &str) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(slug.as_bytes()); + h.finalize().into() + } + + /// Generate a short slug from a URL: first 8 bytes of + /// SHA256(url), base58-encoded (~11 chars). + pub fn auto_slug(url: &str) -> String { + let mut h = Sha256::new(); + h.update(url.as_bytes()); + let hash: [u8; 32] = h.finalize().into(); + crate::base58::encode(&hash[..8]) + } + + /// Serialize to bytes. + pub fn to_bytes(&self) -> Vec { + let slug_bytes = self.slug.as_bytes(); + let url_bytes = self.target_url.as_bytes(); + let mut buf = + Vec::with_capacity(HEADER_SIZE + slug_bytes.len() + url_bytes.len()); + buf.push(self.version); + buf.extend_from_slice(&self.created_at.to_be_bytes()); + buf.extend_from_slice(&self.ttl_secs.to_be_bytes()); + buf.extend_from_slice(&(slug_bytes.len() as u16).to_be_bytes()); + buf.extend_from_slice(slug_bytes); + buf.extend_from_slice(url_bytes); + buf + } + + /// Deserialize from bytes. + pub fn from_bytes(data: &[u8]) -> Option { + if data.len() < HEADER_SIZE { + return None; + } + let version = data[0]; + let created_at = u64::from_be_bytes(data[1..9].try_into().ok()?); + let ttl_secs = u64::from_be_bytes(data[9..17].try_into().ok()?); + let slug_len = + u16::from_be_bytes(data[17..19].try_into().ok()?) as usize; + if data.len() < HEADER_SIZE + slug_len { + return None; + } + let slug = + std::str::from_utf8(&data[HEADER_SIZE..HEADER_SIZE + slug_len]) + .ok()? + .to_string(); + let target_url = + std::str::from_utf8(&data[HEADER_SIZE + slug_len..]) + .ok()? + .to_string(); + Some(UrlEntry { + version, + created_at, + ttl_secs, + slug, + target_url, + }) + } + + /// Whether this entry has expired. ttl_secs == 0 means never. + pub fn is_expired(&self) -> bool { + if self.ttl_secs == 0 { + return false; + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now > self.created_at.saturating_add(self.ttl_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let entry = UrlEntry::new( + "abc123".into(), + "https://example.com".into(), + 3600, + ); + let bytes = entry.to_bytes(); + let decoded = UrlEntry::from_bytes(&bytes).unwrap(); + assert_eq!(decoded.slug, "abc123"); + assert_eq!(decoded.target_url, "https://example.com"); + assert_eq!(decoded.ttl_secs, 3600); + assert_eq!(decoded.version, FORMAT_VERSION); + } + + #[test] + fn auto_slug_deterministic() { + let s1 = UrlEntry::auto_slug("https://example.com"); + let s2 = UrlEntry::auto_slug("https://example.com"); + assert_eq!(s1, s2); + } + + #[test] + fn auto_slug_differs() { + let s1 = UrlEntry::auto_slug("https://a.com"); + let s2 = UrlEntry::auto_slug("https://b.com"); + assert_ne!(s1, s2); + } + + #[test] + fn dht_key_deterministic() { + let k1 = UrlEntry::dht_key("test"); + let k2 = UrlEntry::dht_key("test"); + assert_eq!(k1, k2); + } + + #[test] + fn zero_ttl_never_expires() { + let entry = UrlEntry::new("s".into(), "https://x.com".into(), 0); + assert!(!entry.is_expired()); + } + + #[test] + fn too_short_fails() { + assert!(UrlEntry::from_bytes(&[0u8; 5]).is_none()); + } +}