From 7aff2e1d279a4e442b32f49ca0a0eca065355787 Mon Sep 17 00:00:00 2001 From: murilo ijanc Date: Wed, 25 Mar 2026 02:07:37 -0300 Subject: Initial commit: tesseras-paste decentralized pastebin DHT-backed encrypted pastebin with two binaries (tp/tpd), XChaCha20-Poly1305 encryption, content-addressed storage, and Unix socket + HTTP interfaces. --- .gitignore | 1 + .rustfmt.toml | 4 + Cargo.lock | 628 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 27 +++ LICENSE | 14 ++ Makefile | 30 +++ README.md | 5 + deny.toml | 243 ++++++++++++++++++++++ src/base58.rs | 153 ++++++++++++++ src/bin/tp.rs | 206 +++++++++++++++++++ src/bin/tpd.rs | 293 ++++++++++++++++++++++++++ src/crypto.rs | 84 ++++++++ src/daemon.rs | 504 +++++++++++++++++++++++++++++++++++++++++++++ src/ops.rs | 160 +++++++++++++++ src/paste.rs | 128 ++++++++++++ src/protocol.rs | 173 ++++++++++++++++ src/store.rs | 262 +++++++++++++++++++++++ tp.1 | 100 +++++++++ tpd.1 | 90 ++++++++ 19 files changed, 3105 insertions(+) create mode 100644 .gitignore create mode 100644 .rustfmt.toml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 deny.toml create mode 100644 src/base58.rs create mode 100644 src/bin/tp.rs create mode 100644 src/bin/tpd.rs create mode 100644 src/crypto.rs create mode 100644 src/daemon.rs create mode 100644 src/ops.rs create mode 100644 src/paste.rs create mode 100644 src/protocol.rs create mode 100644 src/store.rs create mode 100644 tp.1 create mode 100644 tpd.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..bf95564 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,4 @@ +# group_imports = "StdExternalCrate" +# imports_granularity = "Module" +max_width = 80 +reorder_imports = true diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..99b8065 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,628 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[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 = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[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", + "rand_core", + "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 = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[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 = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791adbfbbca157a854cf8721b466f4fb9b6f818548fa50eea6e3bb33b1e59d1c" +dependencies = [ + "ed25519-dalek", + "log", + "mio", + "sha2", +] + +[[package]] +name = "tesseras-paste" +version = "0.1.0" +dependencies = [ + "chacha20poly1305", + "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 = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8a163fc --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "tesseras-paste" +version = "0.1.0" +edition = "2024" +license = "ISC" +readme = "README.md" +description = "Decentralized pastebin built on tesseras-dht" +categories = ["network-programming"] +homepage = "https://tesseras.net" +keywords = ["dht", "kademlia", "p2p", "nat", "distributed"] +repository = "https://got.tesseras.net/?action=summary&path=tesseras-dht.git" + +[[bin]] +name = "tpd" +path = "src/bin/tpd.rs" + +[[bin]] +name = "tp" +path = "src/bin/tp.rs" + +[dependencies] +chacha20poly1305 = "=0.10.1" +env_logger = "=0.11.10" +log = "=0.4.29" + +# tesseras +tesseras-dht = "=0.1.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..832fca8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +Copyright (c) 2026 murilo ijanc' + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c670957 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +all: release + +release: + cargo build --release + +debug: + cargo build + +test: + cargo test + +check: + cargo check + +clean: + cargo clean + +fmt: + cargo fmt + +clippy: + cargo clippy -- -D warnings + +doc: + cargo doc --no-deps --open + +audit: + cargo deny check + +.PHONY: all release debug test test-release check clean fmt clippy doc audit diff --git a/README.md b/README.md new file mode 100644 index 0000000..26182a0 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# tesseras-paste + +## License + +ISC diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..42f8b79 --- /dev/null +++ b/deny.toml @@ -0,0 +1,243 @@ +# This template contains all of the possible sections and their default values + +# Note that all fields that take a lint level have these possible values: +# * deny - An error will be produced and the check will fail +# * warn - A warning will be produced, but the check will not fail +# * allow - No warning or error will be produced, though in some cases a note +# will be + +# The values provided in this template are the default values that will be used +# when any section or field is not specified in your own configuration + +# Root options + +# The graph table configures how the dependency graph is constructed and thus +# which crates the checks are performed against +[graph] +# If 1 or more target triples (and optionally, target_features) are specified, +# only the specified targets will be checked when running `cargo deny check`. +# This means, if a particular package is only ever used as a target specific +# dependency, such as, for example, the `nix` crate only being used via the +# `target_family = "unix"` configuration, that only having windows targets in +# this list would mean the nix crate, as well as any of its exclusive +# dependencies not shared by any other crates, would be ignored, as the target +# list here is effectively saying which targets you are building for. +targets = [ + # The triple can be any string, but only the target triples built in to + # rustc (as of 1.40) can be checked against actual config expressions + #"x86_64-unknown-linux-musl", + # You can also specify which target_features you promise are enabled for a + # particular target. target_features are currently not validated against + # the actual valid features supported by the target architecture. + #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, +] +# When creating the dependency graph used as the source of truth when checks are +# executed, this field can be used to prune crates from the graph, removing them +# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate +# is pruned from the graph, all of its dependencies will also be pruned unless +# they are connected to another crate in the graph that hasn't been pruned, +# so it should be used with care. The identifiers are [Package ID Specifications] +# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) +#exclude = [] +# If true, metadata will be collected with `--all-features`. Note that this can't +# be toggled off if true, if you want to conditionally enable `--all-features` it +# is recommended to pass `--all-features` on the cmd line instead +all-features = false +# If true, metadata will be collected with `--no-default-features`. The same +# caveat with `all-features` applies +no-default-features = false +# If set, these feature will be enabled when collecting metadata. If `--features` +# is specified on the cmd line they will take precedence over this option. +#features = [] + +# The output table provides options for how/if diagnostics are outputted +[output] +# When outputting inclusion graphs in diagnostics that include features, this +# option can be used to specify the depth at which feature edges will be added. +# This option is included since the graphs can be quite large and the addition +# of features from the crate(s) to all of the graph roots can be far too verbose. +# This option can be overridden via `--feature-depth` on the cmd line +feature-depth = 1 + +# This section is considered when running `cargo deny check advisories` +# More documentation for the advisories section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html +[advisories] +# The path where the advisory databases are cloned/fetched into +#db-path = "$CARGO_HOME/advisory-dbs" +# The url(s) of the advisory databases to use +#db-urls = ["https://github.com/rustsec/advisory-db"] +# A list of advisory IDs to ignore. Note that ignored advisories will still +# output a note when they are encountered. +ignore = [ + #"RUSTSEC-0000-0000", + #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, + #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish + #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, +] +# If this is true, then cargo deny will use the git executable to fetch advisory database. +# If this is false, then it uses a built-in git library. +# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. +# See Git Authentication for more information about setting up git authentication. +#git-fetch-with-cli = true + +# This section is considered when running `cargo deny check licenses` +# More documentation for the licenses section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html +[licenses] +# List of explicitly allowed licenses +# See https://spdx.org/licenses/ for list of possible licenses +# [possible values: any SPDX 3.11 short identifier (+ optional exception)]. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-3-Clause", + "ISC", + "Unlicense", + "Unicode-3.0", +] +# The confidence threshold for detecting a license from license text. +# The higher the value, the more closely the license text must be to the +# canonical license text of a valid SPDX license file. +# [possible values: any between 0.0 and 1.0]. +confidence-threshold = 0.8 +# Allow 1 or more licenses on a per-crate basis, so that particular licenses +# aren't accepted for every possible crate as with the normal allow list +exceptions = [ + # Each entry is the crate and version constraint, and its specific allow + # list + #{ allow = ["Zlib"], crate = "adler32" }, +] + +# Some crates don't have (easily) machine readable licensing information, +# adding a clarification entry for it allows you to manually specify the +# licensing information +#[[licenses.clarify]] +# The package spec the clarification applies to +#crate = "ring" +# The SPDX expression for the license requirements of the crate +#expression = "MIT AND ISC AND OpenSSL" +# One or more files in the crate's source used as the "source of truth" for +# the license expression. If the contents match, the clarification will be used +# when running the license check, otherwise the clarification will be ignored +# and the crate will be checked normally, which may produce warnings or errors +# depending on the rest of your configuration +#license-files = [ +# Each entry is a crate relative path, and the (opaque) hash of its contents +#{ path = "LICENSE", hash = 0xbd0eed23 } +#] + +[licenses.private] +# If true, ignores workspace crates that aren't published, or are only +# published to private registries. +# To see how to mark a crate as unpublished (to the official registry), +# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. +ignore = false +# One or more private registries that you might publish crates to, if a crate +# is only published to private registries, and ignore is true, the crate will +# not have its license(s) checked +registries = [ + #"https://sekretz.com/registry +] + +# This section is considered when running `cargo deny check bans`. +# More documentation about the 'bans' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html +[bans] +# Lint level for when multiple versions of the same crate are detected +multiple-versions = "warn" +# Lint level for when a crate version requirement is `*` +wildcards = "allow" +# The graph highlighting used when creating dotgraphs for crates +# with multiple versions +# * lowest-version - The path to the lowest versioned duplicate is highlighted +# * simplest-path - The path to the version with the fewest edges is highlighted +# * all - Both lowest-version and simplest-path are used +highlight = "all" +# The default lint level for `default` features for crates that are members of +# the workspace that is being checked. This can be overridden by allowing/denying +# `default` on a crate-by-crate basis if desired. +workspace-default-features = "allow" +# The default lint level for `default` features for external crates that are not +# members of the workspace. This can be overridden by allowing/denying `default` +# on a crate-by-crate basis if desired. +external-default-features = "allow" +# List of crates that are allowed. Use with care! +allow = [ + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, +] +# If true, workspace members are automatically allowed even when using deny-by-default +# This is useful for organizations that want to deny all external dependencies by default +# but allow their own workspace crates without having to explicitly list them +allow-workspace = false +# List of crates to deny +deny = [ + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, + # Wrapper crates can optionally be specified to allow the crate when it + # is a direct dependency of the otherwise banned crate + #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, +] + +# List of features to allow/deny +# Each entry the name of a crate and a version range. If version is +# not specified, all versions will be matched. +#[[bans.features]] +#crate = "reqwest" +# Features to not allow +#deny = ["json"] +# Features to allow +#allow = [ +# "rustls", +# "__rustls", +# "__tls", +# "hyper-rustls", +# "rustls", +# "rustls-pemfile", +# "rustls-tls-webpki-roots", +# "tokio-rustls", +# "webpki-roots", +#] +# If true, the allowed features must exactly match the enabled feature set. If +# this is set there is no point setting `deny` +#exact = true + +# Certain crates/versions that will be skipped when doing duplicate detection. +skip = [ + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" }, +] +# Similarly to `skip` allows you to skip certain crates during duplicate +# detection. Unlike skip, it also includes the entire tree of transitive +# dependencies starting at the specified crate, up to a certain depth, which is +# by default infinite. +skip-tree = [ + #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies + #{ crate = "ansi_term@0.11.0", depth = 20 }, +] + +# This section is considered when running `cargo deny check sources`. +# More documentation about the 'sources' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html +[sources] +# Lint level for what to happen when a crate from a crate registry that is not +# in the allow list is encountered +unknown-registry = "warn" +# Lint level for what to happen when a crate from a git repository that is not +# in the allow list is encountered +unknown-git = "warn" +# List of URLs for allowed crate registries. Defaults to the crates.io index +# if not specified. If it is specified but empty, no registries are allowed. +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +# List of URLs for allowed Git repositories +allow-git = ["https://git.tesseras.net/tesseras-dht"] + +[sources.allow-org] +# github.com organizations to allow git sources for +github = [] +# gitlab.com organizations to allow git sources for +gitlab = [] +# bitbucket.org organizations to allow git sources for +bitbucket = [] diff --git a/src/base58.rs b/src/base58.rs new file mode 100644 index 0000000..c412bc3 --- /dev/null +++ b/src/base58.rs @@ -0,0 +1,153 @@ +//! Bitcoin-style Base58 encoding/decoding. +//! +//! No external dependencies. Uses the standard Bitcoin +//! alphabet (no 0, O, I, l to avoid ambiguity). + +const ALPHABET: &[u8; 58] = + b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +/// Decode table: ASCII byte → base58 value (255 = invalid). +const DECODE: [u8; 128] = { + let mut table = [255u8; 128]; + let mut i = 0; + while i < 58 { + table[ALPHABET[i] as usize] = i as u8; + i += 1; + } + table +}; + +/// Encode bytes to Base58 string. +pub fn encode(input: &[u8]) -> String { + if input.is_empty() { + return String::new(); + } + + // Count leading zeros + let leading_zeros = input.iter().take_while(|&&b| b == 0).count(); + + // Convert to base58 via repeated division + let mut digits: Vec = Vec::with_capacity(input.len() * 2); + for &byte in input { + let mut carry = byte as u32; + for d in &mut digits { + carry += (*d as u32) << 8; + *d = (carry % 58) as u8; + carry /= 58; + } + while carry > 0 { + digits.push((carry % 58) as u8); + carry /= 58; + } + } + + let mut result = String::with_capacity(leading_zeros + digits.len()); + for _ in 0..leading_zeros { + result.push('1'); + } + for &d in digits.iter().rev() { + result.push(ALPHABET[d as usize] as char); + } + result +} + +/// Decode a Base58 string to bytes. +pub fn decode(input: &str) -> Option> { + if input.is_empty() { + return Some(Vec::new()); + } + + // Count leading '1's (representing zero bytes) + let leading_ones = input.chars().take_while(|&c| c == '1').count(); + + // Convert from base58 via repeated multiplication + let mut bytes: Vec = Vec::with_capacity(input.len()); + for c in input.chars() { + let c = c as usize; + if c >= 128 { + return None; + } + let val = DECODE[c]; + if val == 255 { + return None; + } + let mut carry = val as u32; + for b in &mut bytes { + carry += (*b as u32) * 58; + *b = (carry & 0xFF) as u8; + carry >>= 8; + } + while carry > 0 { + bytes.push((carry & 0xFF) as u8); + carry >>= 8; + } + } + + let mut result = vec![0; leading_ones]; + result.extend(bytes.iter().rev()); + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_empty() { + assert_eq!(encode(b""), ""); + } + + #[test] + fn encode_hello() { + assert_eq!(encode(b"Hello World"), "JxF12TrwUP45BMd"); + } + + #[test] + fn roundtrip() { + let data = b"tesseras-paste test data"; + let encoded = encode(data); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn roundtrip_binary() { + let data: Vec = (0..=255).collect(); + let encoded = encode(&data); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn leading_zeros() { + let data = [0, 0, 0, 1, 2, 3]; + let encoded = encode(&data); + assert!(encoded.starts_with("111")); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn decode_invalid_char() { + assert!(decode("invalid0char").is_none()); + } + + #[test] + fn decode_empty() { + assert_eq!(decode("").unwrap(), Vec::::new()); + } + + #[test] + fn known_vector() { + // SHA-256 of "test" in base58 + let hash: [u8; 32] = { + use tesseras_dht::sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(b"test"); + h.finalize().into() + }; + let encoded = encode(&hash); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, hash); + } +} diff --git a/src/bin/tp.rs b/src/bin/tp.rs new file mode 100644 index 0000000..e33c357 --- /dev/null +++ b/src/bin/tp.rs @@ -0,0 +1,206 @@ +//! tp — tesseras-paste CLI client. +//! +//! Sends commands to the `tpd` daemon over a Unix socket. +//! Reads paste content from stdin (put) and writes it to +//! stdout (get). + +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; + +#[path = "../base58.rs"] +mod base58; + +fn default_socket() -> PathBuf { + PathBuf::from("/var/tesseras-paste/daemon.sock") +} + +fn usage() { + eprintln!("usage: tp [-s sock] [args]"); + eprintln!(); + eprintln!("commands:"); + eprintln!(" put [-t ttl] [-p] read stdin, store paste"); + eprintln!(" -p public (no encryption)"); + eprintln!(" get retrieve paste to stdout"); + eprintln!(" del delete paste"); + eprintln!(" pin pin (never expires)"); + eprintln!(" unpin unpin"); + eprintln!(" status show daemon status"); + eprintln!(); + eprintln!(" -s sock Unix socket path"); + eprintln!(" -t ttl time-to-live (e.g. 24h 30m 3600)"); +} + +fn parse_ttl(s: &str) -> Result { + let s = s.trim(); + if let Some(h) = s.strip_suffix('h') { + h.parse::() + .map(|v| v * 3600) + .map_err(|e| e.to_string()) + } else if let Some(m) = s.strip_suffix('m') { + m.parse::().map(|v| v * 60).map_err(|e| e.to_string()) + } else if let Some(sec) = s.strip_suffix('s') { + sec.parse::().map_err(|e| e.to_string()) + } else { + s.parse::().map_err(|e| e.to_string()) + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + + let mut sock_path = default_socket(); + let mut 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; + } + "-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 mut is_get = false; + + let request = match command.as_str() { + "put" => { + let mut ttl = "24h".to_string(); + let mut public = false; + 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(); + } + } + "-p" => public = true, + _ => {} + } + j += 1; + } + let ttl_secs = match parse_ttl(&ttl) { + Ok(s) => s, + Err(e) => { + eprintln!("error: bad TTL: {e}"); + std::process::exit(1); + } + }; + let mut content = Vec::new(); + if let Err(e) = std::io::stdin().read_to_end(&mut content) { + eprintln!("error: reading stdin: {e}"); + std::process::exit(1); + } + if content.is_empty() { + eprintln!("error: empty input"); + std::process::exit(1); + } + let cmd = if public { "PUTP" } else { "PUT" }; + format!("{cmd} {ttl_secs} {}\n", base58::encode(&content)) + } + "get" => { + let key = cmd_args.get(1).unwrap_or_else(|| { + eprintln!("error: get requires a key"); + std::process::exit(1); + }); + is_get = true; + format!("GET {key}\n") + } + "del" => { + let key = cmd_args.get(1).unwrap_or_else(|| { + eprintln!("error: del requires a key"); + std::process::exit(1); + }); + format!("DEL {key}\n") + } + "pin" => { + let key = cmd_args.get(1).unwrap_or_else(|| { + eprintln!("error: pin requires a key"); + std::process::exit(1); + }); + format!("PIN {key}\n") + } + "unpin" => { + let key = cmd_args.get(1).unwrap_or_else(|| { + eprintln!("error: unpin requires a key"); + std::process::exit(1); + }); + format!("UNPIN {key}\n") + } + "status" => "STATUS\n".to_string(), + other => { + eprintln!("unknown command: {other}"); + usage(); + std::process::exit(1); + } + }; + + let stream = match UnixStream::connect(&sock_path) { + Ok(s) => s, + Err(e) => { + eprintln!("error: cannot connect to {}: {e}", sock_path.display(),); + eprintln!("hint: is tpd 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 let Some(data) = line.strip_prefix("OK ") { + if is_get { + // Decode base58 → raw bytes → stdout + match base58::decode(data) { + Some(bytes) => { + if let Err(e) = std::io::stdout().write_all(&bytes) { + eprintln!("error: writing to stdout: {e}"); + std::process::exit(1); + } + } + None => println!("{data}"), + } + } else { + println!("{data}"); + } + break; + } else if let Some(msg) = line.strip_prefix("ERR ") { + eprintln!("error: {msg}"); + std::process::exit(1); + } + } +} diff --git a/src/bin/tpd.rs b/src/bin/tpd.rs new file mode 100644 index 0000000..15e7d9b --- /dev/null +++ b/src/bin/tpd.rs @@ -0,0 +1,293 @@ +//! tpd — tesseras-paste daemon. +//! +//! Runs a DHT node that stores and serves encrypted pastes. +//! Communicates with the CLI (`tp`) over a Unix socket and +//! optionally serves pastes via HTTP. + +#[path = "../base58.rs"] +mod base58; +#[path = "../crypto.rs"] +mod crypto; +#[path = "../daemon.rs"] +mod daemon; +#[path = "../ops.rs"] +mod ops; +#[path = "../paste.rs"] +mod paste; +#[path = "../protocol.rs"] +mod protocol; +#[path = "../store.rs"] +mod store; + +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::PasteStore; + +fn default_dir() -> PathBuf { + PathBuf::from("/var/tesseras-paste") +} + +fn usage() { + eprintln!( + "usage: tpd [-p port] [-d dir] [-s sock] \ + [-w http_port] [-g] [-b host:port] [-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!(" -b host:port bootstrap peer (repeatable)"); + eprintln!(" -h show this help"); +} + +fn main() { + env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or("info"), + ) + .format(|buf, record| { + use std::io::Write; + writeln!(buf, "[{}]: {}", record.level(), record.args()) + }) + .init(); + + let mut port: u16 = 0; + let mut dir = default_dir(); + let mut sock: Option = None; + let mut http_port: Option = None; + let mut global = false; + let mut bootstrap: Vec = Vec::new(); + + let args: Vec = std::env::args().collect(); + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "-p" => { + i += 1; + port = args.get(i).and_then(|s| s.parse().ok()).unwrap_or_else( + || { + eprintln!("error: -p requires a port"); + std::process::exit(1); + }, + ); + } + "-d" => { + i += 1; + dir = args.get(i).map(PathBuf::from).unwrap_or_else(|| { + eprintln!("error: -d requires a path"); + std::process::exit(1); + }); + } + "-s" => { + i += 1; + sock = args.get(i).map(PathBuf::from); + if sock.is_none() { + eprintln!("error: -s requires a path"); + std::process::exit(1); + } + } + "-w" => { + i += 1; + http_port = Some( + args.get(i).and_then(|s| s.parse().ok()).unwrap_or_else( + || { + eprintln!("error: -w requires a port"); + std::process::exit(1); + }, + ), + ); + } + "-g" => global = true, + "-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 sock_path = sock.unwrap_or_else(|| dir.join("daemon.sock")); + + // Ensure directories exist + let _ = std::fs::create_dir_all(&dir); + if let Some(parent) = sock_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + let store = match PasteStore::open(&dir) { + Ok(s) => s, + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }; + + // Load or generate persistent identity + 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: 128 * 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!("tpd {addr} id={:.8}", id); + + for peer in &bootstrap { + let parts: Vec<&str> = peer.rsplitn(2, ':').collect(); + if parts.len() != 2 { + eprintln!("warning: bad bootstrap: {peer}"); + continue; + } + let host = parts[1]; + let p: u16 = match parts[0].parse() { + Ok(p) => p, + Err(_) => { + eprintln!("warning: bad port: {peer}"); + continue; + } + }; + if let Err(e) = node.join(host, p) { + eprintln!("warning: bootstrap {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)); + + // Signal handler + 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); + }); + + // HTTP server thread (optional) + 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); + + let _ = std::fs::remove_file(&sock_path); + let _ = handle.join(); + if let Some(h) = http_handle { + let _ = h.join(); + } + eprintln!("shutdown complete"); +} + +/// Load identity seed from file, or generate and save +/// a new one. This ensures the node keeps the same +/// Ed25519 keypair (and NodeId) across restarts. +fn load_or_create_identity(path: &std::path::Path) -> Vec { + if let Ok(data) = std::fs::read(path) + && data.len() == 32 + { + log::info!("identity: loaded from {}", path.display()); + return data; + } + let mut seed = [0u8; 32]; + tesseras_dht::sys::random_bytes(&mut seed); + if let Err(e) = std::fs::write(path, seed) { + log::warn!("identity: failed to save to {}: {e}", path.display()); + } else { + log::info!("identity: generated new keypair at {}", path.display()); + } + seed.to_vec() +} + +const SIGINT: i32 = 2; +const SIGTERM: i32 = 15; + +unsafe extern "C" { + fn signal(sig: i32, handler: usize) -> usize; +} + +static SHUTDOWN_PTR: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +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::SeqCst); + } +} diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..1480040 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,84 @@ +//! XChaCha20-Poly1305 authenticated encryption. +//! +//! Uses OS-provided randomness (`arc4random_buf`) via +//! `tesseras_dht::sys::random_bytes` for key and nonce +//! generation. + +use chacha20poly1305::{ + XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit}, +}; + +/// XChaCha20 extended nonce size (24 bytes). +const NONCE_SIZE: usize = 24; + +/// XChaCha20-Poly1305 key size (32 bytes). +pub const KEY_SIZE: usize = 32; + +/// Generate a random 32-byte encryption key. +pub fn generate_key() -> [u8; KEY_SIZE] { + let mut key = [0u8; KEY_SIZE]; + tesseras_dht::sys::random_bytes(&mut key); + key +} + +/// Encrypt plaintext with a random nonce. Returns `nonce || ciphertext`. +pub fn encrypt(key: &[u8; KEY_SIZE], plaintext: &[u8]) -> Vec { + let cipher = XChaCha20Poly1305::new(key.into()); + let mut nonce_bytes = [0u8; NONCE_SIZE]; + tesseras_dht::sys::random_bytes(&mut nonce_bytes); + let nonce = XNonce::from(nonce_bytes); + let ciphertext = cipher + .encrypt(&nonce, plaintext) + .expect("encryption should not fail"); + let mut out = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + out +} + +/// Decrypt `nonce || ciphertext`. Returns `None` if authentication fails. +pub fn decrypt(key: &[u8; KEY_SIZE], data: &[u8]) -> Option> { + if data.len() < NONCE_SIZE { + return None; + } + let (nonce_bytes, ciphertext) = data.split_at(NONCE_SIZE); + let nonce = XNonce::from_slice(nonce_bytes); + let cipher = XChaCha20Poly1305::new(key.into()); + cipher.decrypt(nonce, ciphertext).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let key = generate_key(); + let sealed = encrypt(&key, b"hello"); + let opened = decrypt(&key, &sealed).unwrap(); + assert_eq!(opened, b"hello"); + } + + #[test] + fn wrong_key_fails() { + let key = generate_key(); + let wrong = generate_key(); + let sealed = encrypt(&key, b"secret"); + assert!(decrypt(&wrong, &sealed).is_none()); + } + + #[test] + fn truncated_fails() { + let key = generate_key(); + assert!(decrypt(&key, &[0u8; 10]).is_none()); + } + + #[test] + fn different_nonces() { + let key = generate_key(); + let a = encrypt(&key, b"same"); + let b = encrypt(&key, b"same"); + assert_ne!(a, b); + } +} diff --git a/src/daemon.rs b/src/daemon.rs new file mode 100644 index 0000000..313a4aa --- /dev/null +++ b/src/daemon.rs @@ -0,0 +1,504 @@ +//! Daemon main loop, Unix socket listener, and HTTP server. +//! +//! The daemon loop drives the DHT node, processes client +//! requests, and runs periodic maintenance (GC, republish, +//! state persistence). Communication with the CLI happens +//! over a Unix socket using a line-oriented text protocol +//! (see [`crate::protocol`]). + +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +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::paste::Paste; +use crate::protocol::{self, Request, Response}; +use crate::store::PasteStore; + +/// How often to garbage-collect expired pastes (10 min). +const GC_INTERVAL: Duration = Duration::from_secs(600); + +/// How often to republish local pastes 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 sync DHT-replicated values to local store (5 s). +const SYNC_INTERVAL: Duration = Duration::from_secs(5); + +/// A request from the socket thread to the main thread. +pub struct DaemonRequest { + pub cmd: Request, + pub reply: mpsc::Sender, +} + +/// Run the daemon main loop. +pub fn run_daemon( + node: &mut Node, + store: &PasteStore, + rx: &mpsc::Receiver, + shutdown: &AtomicBool, +) { + 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(); + + 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); + } + } + + 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 pastes"), + 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 to the appropriate operation. +fn handle_request( + node: &mut Node, + store: &PasteStore, + cmd: Request, +) -> Response { + match cmd { + Request::Put { + ttl_secs, + content_b58, + encrypt, + } => { + let content = match base58::decode(&content_b58) { + Some(c) => c, + None => return Response::Err("invalid base58 content".into()), + }; + match ops::put_paste(node, store, &content, ttl_secs, encrypt) { + Ok(key) => Response::Ok(key), + Err(e) => Response::Err(e.to_string()), + } + } + Request::Get { key } => match ops::get_paste(node, store, &key) { + Ok(data) => Response::Ok(base58::encode(&data)), + Err(e) => Response::Err(e.to_string()), + }, + Request::Del { key } => match ops::delete_paste(node, store, &key) { + Ok(()) => Response::Ok("deleted".into()), + Err(e) => Response::Err(e.to_string()), + }, + Request::Pin { ref key } | Request::Unpin { ref key } => { + let is_pin = matches!(cmd, Request::Pin { .. }); + let key = key.clone(); + let hash = match ops::resolve_hash(&key) { + Ok(h) => h, + Err(e) => return Response::Err(e.to_string()), + }; + let result = if is_pin { + store.pin(&hash) + } else { + store.unpin(&hash) + }; + match result { + Ok(()) => { + let label = if is_pin { "pinned" } else { "unpinned" }; + Response::Ok(label.into()) + } + Err(e) => Response::Err(e.to_string()), + } + } + Request::Status => { + let m = node.metrics(); + let status = format!( + "peers={} stored={} pastes={} \ + sent={} recv={} lookups={}/{}", + node.routing_table_size(), + node.storage_count(), + store.paste_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 so +/// the HTTP server can serve them without a DHT lookup. +fn sync_dht_to_store(node: &Node, store: &PasteStore) { + for (key, value) in node.dht_values() { + if key.len() != 32 { + continue; + } + if store.get_paste(&key).is_none() { + let _ = store.put_paste(&key, &value); + } + } +} + +/// Re-announce locally stored pastes to the DHT so they +/// remain reachable as nodes join and leave the network. +fn republish(node: &mut Node, store: &PasteStore) { + let keys = store.original_keys(); + if keys.is_empty() { + return; + } + + let mut count = 0u32; + for key in &keys { + if let Some(data) = store.get_paste(key) + && let Some(paste) = Paste::from_bytes(&data) + { + let remaining = if store.is_pinned(key) { + u16::MAX + } else if paste.is_expired() { + continue; + } else { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let expires = paste.created_at + paste.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} pastes to DHT"); + } +} + +/// Run the Unix socket listener thread. +pub fn run_unix_listener( + sock_path: &Path, + tx: mpsc::Sender, + shutdown: &AtomicBool, +) { + let _ = std::fs::remove_file(sock_path); + + let listener = match std::os::unix::net::UnixListener::bind(sock_path) { + Ok(l) => l, + Err(e) => { + log::error!("unix: failed to bind {}: {e}", sock_path.display()); + return; + } + }; + + // Allow group members to connect (0o770) + 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); +} + +/// Read requests line-by-line from a connected Unix socket +/// client, forwarding each to the daemon main loop via `tx`. +fn handle_client( + stream: std::os::unix::net::UnixStream, + tx: &mpsc::Sender, +) -> Result<(), Box> { + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(60)))?; + + let reader = BufReader::new(&stream); + let mut writer = &stream; + + for line in reader.lines() { + let line = line?; + 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 ───────────────────────────────────── + +/// Minimal HTTP server. Serves pastes at / or +/// //. Queries the daemon via Unix socket +/// so it can access DHT-replicated data too. +pub fn run_http( + port: u16, + sock_path: &Path, + store: &PasteStore, + 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}"); + + while !shutdown.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + let store = store.clone(); + handle_http(stream, &store, sock_path); + } + 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: &PasteStore, + 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]); + + // Parse "GET / HTTP/1.x" + let path = match request.split_whitespace().nth(1) { + Some(p) => p, + None => { + http_response(&mut stream, 400, "text/plain", b"Bad Request"); + return; + } + }; + + if path == "/" || path == "/favicon.ico" { + http_response(&mut stream, 200, "text/plain", b"tesseras-paste\n"); + return; + } + + // Strip leading / + let key = path.trim_start_matches('/'); + if key.is_empty() { + http_response(&mut stream, 400, "text/plain", b"missing key"); + return; + } + + // Split hash#enckey (URL fragment won't arrive via + // HTTP, so also support hash/enckey as path) + let (hash_b58, enc_key_b58) = if let Some((h, k)) = key.split_once('/') { + (h, Some(k)) + } else { + (key, None) + }; + + let hash = match base58::decode(hash_b58) { + Some(h) if h.len() == 32 => h, + _ => { + http_response(&mut stream, 400, "text/plain", b"invalid key"); + return; + } + }; + + // Build the daemon-style key: hash#enckey + let daemon_key = match enc_key_b58 { + Some(ek) => format!("{hash_b58}#{ek}"), + None => hash_b58.to_string(), + }; + + // Try local store first (fast path) + let body = if let Some(data) = store.get_paste(&hash) { + match serve_paste_data(&data, enc_key_b58) { + Ok(b) => b, + Err((status, msg)) => { + http_response( + &mut stream, + status, + "text/plain", + msg.as_bytes(), + ); + return; + } + } + } else { + // Not local — ask daemon which does a DHT lookup + match dht_lookup_via_socket(sock_path, &daemon_key) { + Some(b) => b, + None => { + http_response(&mut stream, 404, "text/plain", b"not found"); + return; + } + } + }; + + let ct = if std::str::from_utf8(&body).is_ok() { + "text/plain; charset=utf-8" + } else { + "application/octet-stream" + }; + + http_response(&mut stream, 200, ct, &body); +} + +/// Deserialize a Paste from store bytes, optionally decrypt. +fn serve_paste_data( + data: &[u8], + enc_key_b58: Option<&str>, +) -> Result, (u16, &'static str)> { + let paste = Paste::from_bytes(data).ok_or((500, "corrupt paste"))?; + + if let Some(kb58) = enc_key_b58 { + let key_bytes = base58::decode(kb58).ok_or((400, "invalid enc key"))?; + if key_bytes.len() != 32 { + return Err((400, "invalid enc key")); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&key_bytes); + crate::crypto::decrypt(&key, &paste.content) + .ok_or((403, "decryption failed")) + } else { + Ok(paste.content) + } +} + +/// Ask the daemon for a paste via Unix socket (triggers +/// a DHT lookup if not in local store). +/// Key format: "hash" or "hash#enckey". +fn dht_lookup_via_socket(sock_path: &Path, key: &str) -> Option> { + let sock = std::os::unix::net::UnixStream::connect(sock_path).ok()?; + sock.set_read_timeout(Some(Duration::from_secs(35))).ok(); + sock.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + let cmd = format!("GET {key}\n"); + (&sock).write_all(cmd.as_bytes()).ok()?; + + let reader = BufReader::new(&sock); + let line = reader.lines().next()?.ok()?; + let rest = line.strip_prefix("OK ")?; + + base58::decode(rest) +} + +fn http_response( + stream: &mut std::net::TcpStream, + status: u16, + content_type: &str, + body: &[u8], +) { + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 403 => "Forbidden", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "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); +} diff --git a/src/ops.rs b/src/ops.rs new file mode 100644 index 0000000..302bd58 --- /dev/null +++ b/src/ops.rs @@ -0,0 +1,160 @@ +//! High-level paste operations. +//! +//! Each function combines local storage and DHT interaction +//! into a single call: put, get, delete, pin/unpin. + +use std::time::Duration; + +use tesseras_dht::Node; + +use crate::base58; +use crate::crypto; +use crate::paste::{MAX_PASTE_SIZE, Paste}; +use crate::store::PasteStore; + +/// Timeout for blocking DHT lookups. +const OP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Errors from paste operations. +#[derive(Debug)] +pub enum PasteError { + InvalidKey, + NotFound, + Expired, + DecryptionFailed, + TooLarge, + Internal(String), +} + +impl std::fmt::Display for PasteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidKey => write!(f, "invalid key"), + Self::NotFound => write!(f, "not found"), + Self::Expired => write!(f, "expired"), + Self::DecryptionFailed => write!(f, "decryption failed"), + Self::TooLarge => write!(f, "paste too large"), + Self::Internal(msg) => write!(f, "internal: {msg}"), + } + } +} + +/// Decode the hash portion of a key string ("hash#enckey" or "hash"). +/// Returns the 32-byte hash. +fn parse_hash(key_str: &str) -> Result, PasteError> { + let hash_b58 = key_str.split_once('#').map(|(h, _)| h).unwrap_or(key_str); + let hash = base58::decode(hash_b58).ok_or(PasteError::InvalidKey)?; + if hash.len() != 32 { + return Err(PasteError::InvalidKey); + } + Ok(hash) +} + +/// Store a paste. If `encrypt` is true, encrypts the content and +/// returns "hash#enckey" in base58. Otherwise returns just the hash. +pub fn put_paste( + node: &mut Node, + store: &PasteStore, + content: &[u8], + ttl_secs: u64, + encrypt: bool, +) -> Result { + if content.len() > MAX_PASTE_SIZE { + return Err(PasteError::TooLarge); + } + + let (paste_content, enc_key) = if encrypt { + let key = crypto::generate_key(); + (crypto::encrypt(&key, content), Some(key)) + } else { + (content.to_vec(), None) + }; + + let paste = Paste::new(paste_content, ttl_secs); + let serialized = paste.to_bytes(); + let hash = Paste::content_key(&paste.content); + + store + .put_paste(&hash, &serialized) + .map_err(|e| PasteError::Internal(e.to_string()))?; + + let dht_ttl = std::cmp::min(ttl_secs, u16::MAX as u64) as u16; + node.put(&hash, &serialized, dht_ttl, false); + + let hash_b58 = base58::encode(&hash); + let label = if encrypt { "encrypted" } else { "public" }; + log::info!( + "put: stored {label} paste {hash_b58} ({} bytes)", + content.len() + ); + + match enc_key { + Some(key) => Ok(format!("{hash_b58}#{}", base58::encode(&key))), + None => Ok(hash_b58), + } +} + +/// Retrieve a paste by key ("hash#enckey" or bare "hash"). +/// Tries local store first, then falls back to a blocking DHT lookup. +pub fn get_paste( + node: &mut Node, + store: &PasteStore, + key_str: &str, +) -> Result, PasteError> { + let (hash_b58, enc_key_b58) = match key_str.split_once('#') { + Some((h, k)) => (h, Some(k)), + None => (key_str, None), + }; + + let hash = base58::decode(hash_b58).ok_or(PasteError::InvalidKey)?; + if hash.len() != 32 { + return Err(PasteError::InvalidKey); + } + + let data = if let Some(local) = store.get_paste(&hash) { + local + } else { + let vals = node.get_blocking(&hash, OP_TIMEOUT); + if vals.is_empty() { + return Err(PasteError::NotFound); + } + vals[0].clone() + }; + + let paste = Paste::from_bytes(&data).ok_or(PasteError::InvalidKey)?; + if paste.is_expired() && !store.is_pinned(&hash) { + return Err(PasteError::Expired); + } + + if let Some(kb58) = enc_key_b58 { + let key_bytes = base58::decode(kb58).ok_or(PasteError::InvalidKey)?; + if key_bytes.len() != crypto::KEY_SIZE { + return Err(PasteError::InvalidKey); + } + let mut key = [0u8; crypto::KEY_SIZE]; + key.copy_from_slice(&key_bytes); + crypto::decrypt(&key, &paste.content) + .ok_or(PasteError::DecryptionFailed) + } else { + Ok(paste.content) + } +} + +/// Delete a paste from local store and the DHT. +pub fn delete_paste( + node: &mut Node, + store: &PasteStore, + key_str: &str, +) -> Result<(), PasteError> { + let hash = parse_hash(key_str)?; + store.remove_paste(&hash); + node.delete(&hash); + let hash_b58 = key_str.split_once('#').map(|(h, _)| h).unwrap_or(key_str); + log::info!("del: removed paste {hash_b58}"); + Ok(()) +} + +/// Parse a key and resolve the 32-byte hash (stripping any #enckey). +pub fn resolve_hash(key: &str) -> Result, PasteError> { + parse_hash(key) +} diff --git a/src/paste.rs b/src/paste.rs new file mode 100644 index 0000000..50b32b1 --- /dev/null +++ b/src/paste.rs @@ -0,0 +1,128 @@ +//! Paste record format. +//! +//! Binary format (no external serialization deps): +//! version: u8 +//! created_at: u64 (big-endian) +//! ttl_secs: u64 (big-endian) +//! content: [u8] (remaining bytes) + +use tesseras_dht::sha2::{Digest, Sha256}; + +/// Maximum paste size: 64 KiB. +pub const MAX_PASTE_SIZE: usize = 64 * 1024; + +/// Current format version. +const FORMAT_VERSION: u8 = 1; + +/// Header size: version(1) + created_at(8) + ttl(8) = 17. +const HEADER_SIZE: usize = 17; + +/// A paste record stored locally and replicated via the DHT. +#[derive(Debug, Clone)] +pub struct Paste { + pub version: u8, + pub content: Vec, + pub created_at: u64, + pub ttl_secs: u64, +} + +impl Paste { + /// Create a new paste with the current timestamp. + pub fn new(content: Vec, ttl_secs: u64) -> Self { + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + Paste { + version: FORMAT_VERSION, + content, + created_at, + ttl_secs, + } + } + + /// Compute the SHA-256 content-addressed key (32 bytes). + pub fn content_key(content: &[u8]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(content); + h.finalize().into() + } + + /// Serialize to bytes. + pub fn to_bytes(&self) -> Vec { + let mut buf = Vec::with_capacity(HEADER_SIZE + self.content.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(&self.content); + buf + } + + /// Deserialize from bytes. + pub fn from_bytes(data: &[u8]) -> Option { + if data.len() < HEADER_SIZE { + return None; + } + let version = data[0]; + let created_at = u64::from_be_bytes(data[1..9].try_into().ok()?); + let ttl_secs = u64::from_be_bytes(data[9..17].try_into().ok()?); + let content = data[HEADER_SIZE..].to_vec(); + Some(Paste { + version, + content, + created_at, + ttl_secs, + }) + } + + /// Whether this paste has expired. + pub fn is_expired(&self) -> bool { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now > self.created_at + self.ttl_secs + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let paste = Paste::new(b"hello world".to_vec(), 3600); + let bytes = paste.to_bytes(); + let decoded = Paste::from_bytes(&bytes).unwrap(); + assert_eq!(decoded.content, b"hello world"); + assert_eq!(decoded.ttl_secs, 3600); + assert_eq!(decoded.version, FORMAT_VERSION); + } + + #[test] + fn content_key_deterministic() { + let k1 = Paste::content_key(b"test"); + let k2 = Paste::content_key(b"test"); + assert_eq!(k1, k2); + } + + #[test] + fn content_key_differs() { + let k1 = Paste::content_key(b"aaa"); + let k2 = Paste::content_key(b"bbb"); + assert_ne!(k1, k2); + } + + #[test] + fn too_short_fails() { + assert!(Paste::from_bytes(&[0u8; 5]).is_none()); + } + + #[test] + fn empty_content() { + let paste = Paste::new(Vec::new(), 60); + let bytes = paste.to_bytes(); + let decoded = Paste::from_bytes(&bytes).unwrap(); + assert!(decoded.content.is_empty()); + } +} diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..d45cdd8 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,173 @@ +//! Unix socket protocol for daemon ↔ CLI. +//! +//! Simple line-oriented text protocol: +//! PUT \n +//! PUTP \n +//! GET \n +//! DEL \n +//! PIN \n +//! UNPIN \n +//! STATUS\n +//! SHUTDOWN\n +//! +//! Responses: +//! OK \n +//! ERR \n + +/// A parsed request received from the CLI over the Unix socket. +pub enum Request { + Put { + ttl_secs: u64, + content_b58: String, + encrypt: bool, + }, + Get { + key: String, + }, + Del { + key: String, + }, + Pin { + key: String, + }, + Unpin { + key: String, + }, + Status, + Shutdown, +} + +/// A response sent back to the CLI over the Unix socket. +pub enum Response { + Ok(String), + Err(String), +} + +/// Parse a single protocol line into a [`Request`]. +pub fn parse_request(line: &str) -> Result { + let line = line.trim(); + if line.is_empty() { + return Err("empty request".into()); + } + + let mut parts = line.splitn(3, ' '); + let cmd = parts.next().unwrap(); + + match cmd { + "PUT" | "PUTP" => { + let ttl_str = + parts.next().ok_or("PUT requires: PUT ")?; + let content_b58 = + parts.next().ok_or("PUT requires: PUT ")?; + let ttl_secs: u64 = + ttl_str.parse().map_err(|_| "invalid TTL number")?; + Ok(Request::Put { + ttl_secs, + content_b58: content_b58.to_string(), + encrypt: cmd == "PUT", + }) + } + "GET" => { + let key = parts.next().ok_or("GET requires: GET ")?; + Ok(Request::Get { + key: key.to_string(), + }) + } + "DEL" => { + let key = parts.next().ok_or("DEL requires: DEL ")?; + Ok(Request::Del { + key: key.to_string(), + }) + } + "PIN" => { + let key = parts.next().ok_or("PIN requires: PIN ")?; + Ok(Request::Pin { + key: key.to_string(), + }) + } + "UNPIN" => { + let key = parts.next().ok_or("UNPIN requires: UNPIN ")?; + Ok(Request::Unpin { + key: key.to_string(), + }) + } + "STATUS" => Ok(Request::Status), + "SHUTDOWN" => Ok(Request::Shutdown), + _ => Err(format!("unknown command: {cmd}")), + } +} + +/// Serialize a [`Response`] into a protocol line (`OK ...\n` or `ERR ...\n`). +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_put() { + let r = parse_request("PUT 3600 deadbeef").unwrap(); + match r { + Request::Put { + ttl_secs, + content_b58, + encrypt, + } => { + assert_eq!(ttl_secs, 3600); + assert_eq!(content_b58, "deadbeef"); + assert!(encrypt); + } + _ => panic!("expected Put"), + } + } + + #[test] + fn parse_putp() { + let r = parse_request("PUTP 60 abc").unwrap(); + match r { + Request::Put { encrypt, .. } => assert!(!encrypt), + _ => panic!("expected Put"), + } + } + + #[test] + fn parse_get() { + let r = parse_request("GET abc123#key456").unwrap(); + match r { + Request::Get { key } => assert_eq!(key, "abc123#key456"), + _ => panic!("expected Get"), + } + } + + #[test] + fn parse_status() { + assert!(matches!(parse_request("STATUS").unwrap(), Request::Status)); + } + + #[test] + fn parse_empty_fails() { + assert!(parse_request("").is_err()); + } + + #[test] + fn parse_unknown_fails() { + assert!(parse_request("FOOBAR").is_err()); + } + + #[test] + fn format_ok() { + let r = format_response(&Response::Ok("hello".into())); + assert_eq!(r, "OK hello\n"); + } + + #[test] + fn format_err() { + let r = format_response(&Response::Err("oops".into())); + assert_eq!(r, "ERR oops\n"); + } +} diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..18a7641 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,262 @@ +//! Filesystem-based paste storage. +//! +//! Simple directory layout: +//! /pastes/.bin +//! /pins/ +//! /blocked/ +//! /contacts.bin + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::base58; +use crate::paste::Paste; + +/// Persistent paste store backed by the filesystem. +#[derive(Clone)] +pub struct PasteStore { + root: PathBuf, +} + +impl PasteStore { + /// Open or create a store rooted at the given directory. + /// Creates `pastes/`, `pins/`, and `blocked/` subdirectories. + pub fn open(root: &Path) -> std::io::Result { + fs::create_dir_all(root.join("pastes"))?; + fs::create_dir_all(root.join("pins"))?; + fs::create_dir_all(root.join("blocked"))?; + Ok(PasteStore { + root: root.to_path_buf(), + }) + } + + fn paste_path(&self, key: &[u8]) -> PathBuf { + self.root.join("pastes").join(base58::encode(key)) + } + + fn pin_path(&self, key: &[u8]) -> PathBuf { + self.root.join("pins").join(base58::encode(key)) + } + + fn block_path(&self, key: &[u8]) -> PathBuf { + self.root.join("blocked").join(base58::encode(key)) + } + + // ── Paste CRUD ────────────────────────────────── + + /// Write a paste to disk. The key (32 bytes) is prepended + /// to the file so [`original_keys`] can reconstruct it. + pub fn put_paste(&self, key: &[u8], value: &[u8]) -> std::io::Result<()> { + let path = self.paste_path(key); + let mut f = fs::File::create(path)?; + f.write_all(key)?; + f.write_all(value)?; + Ok(()) + } + + /// Read a paste from disk. Returns `None` if the paste + /// is blocked, expired (and not pinned), or does not exist. + pub fn get_paste(&self, key: &[u8]) -> Option> { + if self.is_blocked(key) { + return None; + } + let path = self.paste_path(key); + let data = fs::read(&path).ok()?; + // Strip key prefix (32 bytes) + if data.len() < 32 { + return None; + } + let value = data[32..].to_vec(); + + // Check expiry (pinned never expire) + if let Some(paste) = Paste::from_bytes(&value) + && paste.is_expired() + && !self.is_pinned(key) + { + return None; + } + Some(value) + } + + /// Delete a paste file from disk (no-op if absent). + pub fn remove_paste(&self, key: &[u8]) { + let _ = fs::remove_file(self.paste_path(key)); + } + + /// List all non-expired, non-blocked paste keys. + pub fn original_keys(&self) -> Vec> { + let dir = self.root.join("pastes"); + 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 self.is_blocked(key) { + continue; + } + if let Some(paste) = Paste::from_bytes(value) + && paste.is_expired() + && !self.is_pinned(key) + { + continue; + } + keys.push(key.to_vec()); + } + keys + } + + // ── Pin / Block ───────────────────────────────── + + /// Mark a paste as pinned (never expires). + pub fn pin(&self, key: &[u8]) -> std::io::Result<()> { + fs::File::create(self.pin_path(key))?; + Ok(()) + } + + /// Remove the pin from a paste (re-enables expiry). + pub fn unpin(&self, key: &[u8]) -> std::io::Result<()> { + let _ = fs::remove_file(self.pin_path(key)); + Ok(()) + } + + pub fn is_pinned(&self, key: &[u8]) -> bool { + self.pin_path(key).exists() + } + + pub fn is_blocked(&self, key: &[u8]) -> bool { + self.block_path(key).exists() + } + + // ── GC ────────────────────────────────────────── + + /// Remove expired pastes from disk. Pinned pastes are kept. + pub fn gc(&self) -> std::io::Result { + let dir = self.root.join("pastes"); + 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 key = &data[..32]; + let value = &data[32..]; + + if self.is_pinned(key) { + continue; + } + if let Some(paste) = Paste::from_bytes(value) + && paste.is_expired() + { + let _ = fs::remove_file(entry.path()); + removed += 1; + } + } + Ok(removed) + } + + /// Count stored pastes. + pub fn paste_count(&self) -> usize { + let dir = self.root.join("pastes"); + fs::read_dir(&dir).map(|e| e.count()).unwrap_or(0) + } +} + +// ── tesseras-dht persistence traits ───────────────── + +impl tesseras_dht::persist::RoutingPersistence for PasteStore { + 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(); + // length-prefixed: addr_len(u16) + id(32) + addr + 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); + } + fs::write(&path, &buf).map_err(tesseras_dht::Error::Io)?; + log::info!("store: persisted {} routing contacts", contacts.len()); + Ok(()) + } + + fn load_contacts( + &self, + ) -> Result, tesseras_dht::Error> + { + let path = self.root.join("contacts.bin"); + let data = match fs::read(&path) { + Ok(d) => d, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(e) => return Err(tesseras_dht::Error::Io(e)), + }; + + let mut out = Vec::new(); + let mut pos = 0; + while pos + 2 + 32 <= data.len() { + let addr_len = + u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += 2; + if pos + 32 + addr_len > data.len() { + break; + } + let mut id_bytes = [0u8; 32]; + id_bytes.copy_from_slice(&data[pos..pos + 32]); + pos += 32; + let addr_str = + std::str::from_utf8(&data[pos..pos + addr_len]).unwrap_or(""); + pos += addr_len; + if let Ok(addr) = addr_str.parse() { + out.push(tesseras_dht::persist::ContactRecord { + id: tesseras_dht::NodeId::from_bytes(id_bytes), + addr, + }); + } + } + if !out.is_empty() { + log::info!("store: loaded {} routing contacts", out.len()); + } + Ok(out) + } +} + +impl tesseras_dht::persist::DataPersistence for PasteStore { + fn save( + &self, + _records: &[tesseras_dht::persist::StoredRecord], + ) -> Result<(), tesseras_dht::Error> { + Ok(()) // app-level storage handles this + } + + fn load( + &self, + ) -> Result, tesseras_dht::Error> + { + Ok(Vec::new()) // republish timer re-populates DHT + } +} diff --git a/tp.1 b/tp.1 new file mode 100644 index 0000000..7b5bc58 --- /dev/null +++ b/tp.1 @@ -0,0 +1,100 @@ +.\" +.\" Copyright (c) 2025 Murilo Ijanc +.\" +.\" Permission to use, copy, modify, and distribute this software for any +.\" purpose with or without fee is hereby granted, provided that the above +.\" copyright notice and this permission notice appear in all copies. +.\" +.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +.\" +.Dd March 25, 2025 +.Dt TP 1 +.Os +.Sh NAME +.Nm tp +.Nd tesseras-paste client +.Sh SYNOPSIS +.Nm +.Op Fl s Ar sock +.Ar command +.Op Ar args +.Sh DESCRIPTION +.Nm +is a command-line client for +.Xr tpd 1 , +the tesseras-paste daemon. +It communicates over a Unix domain socket to store and retrieve +encrypted pastes on the tesseras-dht distributed hash table. +.Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl s Ar sock +Path to the Unix domain socket. +The default is +.Pa /var/tesseras-paste/daemon.sock . +.El +.Pp +The following commands are available: +.Bl -tag -width Ds +.It Cm put Oo Fl t Ar ttl Oc Op Fl p +Read standard input and store it as a paste. +On success, print the paste key to standard output. +.Pp +.Fl t Ar ttl +sets the time-to-live for the paste. +The value can be suffixed with +.Sq h +for hours, +.Sq m +for minutes, or +.Sq s +for seconds. +A bare number is interpreted as seconds. +The default is +.Dq 24h . +.Pp +.Fl p +stores the paste in public mode without encryption. +.It Cm get Ar key +Retrieve the paste identified by +.Ar key +and write its contents to standard output. +.It Cm del Ar key +Delete the paste identified by +.Ar key . +.It Cm pin Ar key +Pin the paste so it never expires. +.It Cm unpin Ar key +Remove the pin from a paste, allowing it to expire normally. +.It Cm status +Display the daemon status. +.El +.Sh FILES +.Bl -tag -width "/var/tesseras-paste/daemon.sock" -compact +.It Pa /var/tesseras-paste/daemon.sock +Default Unix domain socket. +.El +.Sh EXIT STATUS +.Ex -std +.Sh EXAMPLES +Store a paste with default TTL: +.Pp +.Dl $ echo \&"hello world\&" | tp put +.Pp +Retrieve a paste: +.Pp +.Dl $ tp get Ar key +.Pp +Store a public paste with a 1-hour TTL: +.Pp +.Dl $ echo \&"public data\&" | tp put -t 1h -p +.Sh SEE ALSO +.Xr tpd 1 +.Sh AUTHORS +.An Murilo Ijanc Aq Mt murilo@ijanc.org diff --git a/tpd.1 b/tpd.1 new file mode 100644 index 0000000..73a4114 --- /dev/null +++ b/tpd.1 @@ -0,0 +1,90 @@ +.\" +.\" Copyright (c) 2025 Murilo Ijanc +.\" +.\" Permission to use, copy, modify, and distribute this software for any +.\" purpose with or without fee is hereby granted, provided that the above +.\" copyright notice and this permission notice appear in all copies. +.\" +.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +.\" +.Dd March 25, 2025 +.Dt TPD 1 +.Os +.Sh NAME +.Nm tpd +.Nd tesseras-paste daemon +.Sh SYNOPSIS +.Nm +.Op Fl g +.Op Fl p Ar port +.Op Fl d Ar dir +.Op Fl s Ar sock +.Op Fl w Ar http_port +.Op Fl b Ar host:port +.Sh DESCRIPTION +.Nm +is the tesseras-paste daemon. +It participates in the tesseras-dht distributed hash table network +to store and serve encrypted pastes. +It listens on a Unix domain socket for commands from +.Xr tp 1 +and optionally serves an HTTP interface. +.Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl b Ar host:port +Bootstrap peer address. +This option can be specified multiple times to connect to +several peers at startup. +.It Fl d Ar dir +Data directory for paste storage and identity key. +The default is +.Pa /var/tesseras-paste . +.It Fl g +Enable global NAT mode for public servers. +.It Fl p Ar port +UDP port for the DHT protocol. +A value of 0 selects a random port. +The default is 0. +.It Fl s Ar sock +Path to the Unix domain socket. +The default is +.Pa daemon.sock +inside the data directory. +.It Fl w Ar http_port +Enable the HTTP server on the specified port. +.El +.Sh FILES +.Bl -tag -width "/var/tesseras-paste/identity.key" -compact +.It Pa /var/tesseras-paste/ +Default data directory. +.It Pa /var/tesseras-paste/daemon.sock +Default Unix domain socket. +.It Pa /var/tesseras-paste/identity.key +Persistent Ed25519 identity key. +Generated automatically on first run. +.El +.Sh EXIT STATUS +.Ex -std +.Sh EXAMPLES +Start the daemon with default settings: +.Pp +.Dl # tpd +.Pp +Start with a bootstrap peer and HTTP interface: +.Pp +.Dl # tpd -b 198.51.100.1:6881 -w 8080 +.Pp +Start as a public server on port 6881: +.Pp +.Dl # tpd -g -p 6881 +.Sh SEE ALSO +.Xr tp 1 +.Sh AUTHORS +.An Murilo Ijanc Aq Mt murilo@ijanc.org -- cgit v1.2.3