Commit Diff


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<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
@@ -0,0 +1,222 @@
+//! tu — tesseras-url CLI client.
+//!
+//! Sends commands to the `tud` daemon over a Unix socket.
+
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::UnixStream;
+use std::path::PathBuf;
+
+#[path = "../base58.rs"]
+mod base58;
+#[path = "../sandbox.rs"]
+mod sandbox;
+
+fn default_socket() -> PathBuf {
+    PathBuf::from("/var/tesseras-url/daemon.sock")
+}
+
+fn usage() {
+    eprintln!("usage: tu [-s sock] [-v] <command> [args]");
+    eprintln!();
+    eprintln!("commands:");
+    eprintln!("  shorten [-t ttl] [-s slug] <url>  create short URL");
+    eprintln!("  resolve <slug>    show target URL");
+    eprintln!("  del <slug>        delete short URL");
+    eprintln!("  list              list local entries");
+    eprintln!("  status            show daemon status");
+    eprintln!();
+    eprintln!("  -s sock   Unix socket path");
+    eprintln!("  -v        verbose output");
+    eprintln!("  -t ttl    time-to-live (e.g. 24h 30m 3600, 0=forever)");
+}
+
+fn parse_ttl(s: &str) -> Result<u64, String> {
+    let s = s.trim();
+    if s == "0" {
+        return Ok(0);
+    }
+    if let Some(h) = s.strip_suffix('h') {
+        h.parse::<u64>()
+            .map(|v| v * 3600)
+            .map_err(|e| e.to_string())
+    } else if let Some(m) = s.strip_suffix('m') {
+        m.parse::<u64>().map(|v| v * 60).map_err(|e| e.to_string())
+    } else if let Some(sec) = s.strip_suffix('s') {
+        sec.parse::<u64>().map_err(|e| e.to_string())
+    } else {
+        s.parse::<u64>().map_err(|e| e.to_string())
+    }
+}
+
+fn main() {
+    let args: Vec<String> = std::env::args().collect();
+
+    let mut sock_path = default_socket();
+    let mut verbose = false;
+    let mut cmd_start = 1;
+
+    // Parse global options before command
+    let mut i = 1;
+    while i < args.len() {
+        match args[i].as_str() {
+            "-s" => {
+                i += 1;
+                sock_path =
+                    args.get(i).map(PathBuf::from).unwrap_or_else(|| {
+                        eprintln!("error: -s requires path");
+                        std::process::exit(1);
+                    });
+                cmd_start = i + 1;
+            }
+            "-v" => {
+                verbose = true;
+                cmd_start = i + 1;
+            }
+            "-h" | "--help" => {
+                usage();
+                return;
+            }
+            _ => break,
+        }
+        i += 1;
+    }
+
+    let cmd_args = &args[cmd_start..];
+    if cmd_args.is_empty() {
+        usage();
+        std::process::exit(1);
+    }
+
+    let command = &cmd_args[0];
+
+    let request = match command.as_str() {
+        "shorten" => {
+            let mut ttl = "0".to_string();
+            let mut slug = "auto".to_string();
+            let mut url: Option<String> = None;
+            let mut j = 1;
+            while j < cmd_args.len() {
+                match cmd_args[j].as_str() {
+                    "-t" => {
+                        j += 1;
+                        if j < cmd_args.len() {
+                            ttl = cmd_args[j].clone();
+                        }
+                    }
+                    "-s" => {
+                        j += 1;
+                        if j < cmd_args.len() {
+                            slug = cmd_args[j].clone();
+                        }
+                    }
+                    arg if !arg.starts_with('-') => {
+                        url = Some(arg.to_string());
+                    }
+                    _ => {}
+                }
+                j += 1;
+            }
+            let url = match url {
+                Some(u) => u,
+                None => {
+                    eprintln!("error: shorten requires a URL");
+                    std::process::exit(1);
+                }
+            };
+            let ttl_secs = match parse_ttl(&ttl) {
+                Ok(s) => s,
+                Err(e) => {
+                    eprintln!("error: bad TTL: {e}");
+                    std::process::exit(1);
+                }
+            };
+            format!("SHORTEN {ttl_secs} {slug} {url}\n")
+        }
+        "resolve" => {
+            let slug = cmd_args.get(1).unwrap_or_else(|| {
+                eprintln!("error: resolve requires a slug");
+                std::process::exit(1);
+            });
+            format!("RESOLVE {slug}\n")
+        }
+        "del" => {
+            let slug = cmd_args.get(1).unwrap_or_else(|| {
+                eprintln!("error: del requires a slug");
+                std::process::exit(1);
+            });
+            format!("DEL {slug}\n")
+        }
+        "list" => "LIST\n".to_string(),
+        "status" => "STATUS\n".to_string(),
+        other => {
+            eprintln!("unknown command: {other}");
+            usage();
+            std::process::exit(1);
+        }
+    };
+
+    // ── Sandbox ─────────────────────────────────────
+    sandbox::do_unveil(&sock_path, "rw");
+    sandbox::unveil_lock();
+    sandbox::do_pledge("stdio unix rpath");
+
+    if verbose {
+        eprintln!("socket: {}", sock_path.display());
+        eprintln!(">> {}", request.trim());
+    }
+
+    let stream = match UnixStream::connect(&sock_path) {
+        Ok(s) => s,
+        Err(e) => {
+            eprintln!(
+                "error: cannot connect to {}: {e}",
+                sock_path.display(),
+            );
+            eprintln!("hint: is tud running?");
+            std::process::exit(1);
+        }
+    };
+
+    stream
+        .set_read_timeout(Some(std::time::Duration::from_secs(60)))
+        .ok();
+
+    let mut writer = &stream;
+    if let Err(e) = writer.write_all(request.as_bytes()) {
+        eprintln!("error: writing to socket: {e}");
+        std::process::exit(1);
+    }
+
+    let reader = BufReader::new(&stream);
+    for line in reader.lines() {
+        let line = match line {
+            Ok(l) => l,
+            Err(_) => break,
+        };
+        if verbose {
+            eprintln!("<< {}", line);
+        }
+        if let Some(data) = line.strip_prefix("OK ") {
+            if command == "list" && data != "(empty)" {
+                // Decode base58-encoded list
+                if let Some(decoded) = base58::decode(data) {
+                    if let Ok(text) = std::str::from_utf8(&decoded) {
+                        for entry in text.lines() {
+                            println!("{entry}");
+                        }
+                    } else {
+                        println!("{data}");
+                    }
+                } else {
+                    println!("{data}");
+                }
+            } else {
+                println!("{data}");
+            }
+            break;
+        } else if let Some(msg) = line.strip_prefix("ERR ") {
+            eprintln!("error: {msg}");
+            std::process::exit(1);
+        }
+    }
+}
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<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
@@ -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<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
@@ -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 `<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
@@ -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<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
@@ -0,0 +1,158 @@
+//! 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
@@ -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:
+//!   <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
@@ -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<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());
+    }
+}