commit - /dev/null
commit + a96da5f0704c50e8a4e4f047dcd3fb7c73fdf600
blob - /dev/null
blob + ea8c4bf7f35f6f77f75d92ad8ce8349f6e81ddba (mode 644)
--- /dev/null
+++ .gitignore
+/target
blob - /dev/null
blob + bf955640bde5a20b8c2b53bb4b40cac7e8adcfc9 (mode 644)
--- /dev/null
+++ .rustfmt.toml
+# group_imports = "StdExternalCrate"
+# imports_granularity = "Module"
+max_width = 80
+reorder_imports = true
blob - /dev/null
blob + 43cb2864b90378e226355908e5a8b5be09b5abf5 (mode 644)
--- /dev/null
+++ Cargo.lock
+# 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
+[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
+//! 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<u8> = 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<Vec<u8>> {
+ 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<u8> = 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<u8> = (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::<u8>::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
+//! 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);
+ }
+ }
+}
blob - /dev/null
blob + 976ca46c07bf354ac3c240c4401e8b8f132e1483 (mode 644)
--- /dev/null
+++ src/bin/tud.rs
+//! 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;
+}
blob - /dev/null
blob + 8c63f47e01489723c859b7e534ddbf77d004efb6 (mode 644)
--- /dev/null
+++ src/daemon.rs
+//! 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<Response>,
+}
+
+/// Run the daemon main loop.
+pub fn run_daemon(
+ node: &mut Node,
+ store: &UrlStore,
+ rx: &mpsc::Receiver<DaemonRequest>,
+ 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::<u16>() {
+ 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<String> = 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<DaemonRequest>,
+ 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<DaemonRequest>,
+) -> Result<(), Box<dyn std::error::Error>> {
+ 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 /<slug>.
+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<String> {
+ 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
+//! 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 `<netdb.h>`.
+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<SrvRecord> {
+ lookup_srv(SRV_NAME)
+}
+
+/// Perform the SRV query and parse the response.
+fn lookup_srv(name: &str) -> Vec<SrvRecord> {
+ 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<u16> {
+ 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<String> {
+ 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<SrvRecord> {
+ 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
+//! 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<String, UrlError> {
+ 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<String, UrlError> {
+ 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
+//! Unix socket protocol for daemon ↔ CLI.
+//!
+//! Simple line-oriented text protocol:
+//! SHORTEN <ttl_secs> <slug|auto> <url>\n
+//! RESOLVE <slug>\n
+//! DEL <slug>\n
+//! LIST\n
+//! STATUS\n
+//! SHUTDOWN\n
+//!
+//! Responses:
+//! OK <data>\n
+//! ERR <message>\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<Request, String> {
+ 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 <ttl> <slug> <url>")?;
+ let slug = parts
+ .next()
+ .ok_or("SHORTEN requires: SHORTEN <ttl> <slug> <url>")?;
+ let target_url = parts
+ .next()
+ .ok_or("SHORTEN requires: SHORTEN <ttl> <slug> <url>")?;
+ 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 <slug>")?;
+ Ok(Request::Resolve {
+ slug: slug.to_string(),
+ })
+ }
+ "DEL" => {
+ let slug = parts.next().ok_or("DEL requires: DEL <slug>")?;
+ 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
+//! 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
+//! Filesystem-based URL entry storage.
+//!
+//! Simple directory layout:
+//! <root>/urls/<hash>
+//! <root>/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<Self> {
+ 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<Vec<u8>> {
+ 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<Vec<u8>> {
+ 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<usize> {
+ 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<Vec<tesseras_dht::persist::ContactRecord>, 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<Vec<tesseras_dht::persist::StoredRecord>, tesseras_dht::Error>
+ {
+ Ok(Vec::new())
+ }
+}
blob - /dev/null
blob + f52b4a14152600cb2714a5b2180b8db6d0057744 (mode 644)
--- /dev/null
+++ src/url_entry.rs
+//! 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<u8> {
+ 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<Self> {
+ 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());
+ }
+}