grpc: directory replication API for websites to mirror accounts/channels

Snapshot (full read) + Subscribe (live stream) over the same committed-entry
channel gossip uses. Identity and metadata only — password hashes, SCRAM
verifiers, cert fingerprints, and the finer channel-ops-list events never
cross this API. Bearer-token authenticated, optional server TLS. Verified
end-to-end against a live test-net registration, not just unit tests.
This commit is contained in:
Jean Chevronnet 2026-07-12 23:10:21 +00:00
parent bdcce01f11
commit e5a0d2acdf
No known key found for this signature in database
11 changed files with 1188 additions and 8 deletions

693
Cargo.lock generated
View file

@ -29,6 +29,51 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "aws-lc-rs" name = "aws-lc-rs"
version = "1.17.1" version = "1.17.1"
@ -52,6 +97,53 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"sync_wrapper",
"tower 0.5.3",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@ -64,6 +156,12 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]] [[package]]
name = "blake2" name = "blake2"
version = "0.10.6" version = "0.10.6"
@ -151,6 +249,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -164,9 +268,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]] [[package]]
name = "fedserv" name = "fedserv"
version = "0.0.1" version = "0.0.1"
@ -176,6 +286,7 @@ dependencies = [
"base64", "base64",
"hmac", "hmac",
"pbkdf2", "pbkdf2",
"prost",
"rustls-pemfile", "rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
@ -183,7 +294,10 @@ dependencies = [
"subtle", "subtle",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-stream",
"toml", "toml",
"tonic",
"tonic-build",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@ -194,12 +308,63 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "fs_extra" name = "fs_extra"
version = "1.3.0" version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@ -232,12 +397,43 @@ dependencies = [
"r-efi", "r-efi",
] ]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hmac" name = "hmac"
version = "0.12.1" version = "0.12.1"
@ -247,6 +443,116 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"libc",
"pin-project-lite",
"socket2 0.6.4",
"tokio",
"tower-service",
"tracing",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@ -254,7 +560,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.17.1",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
] ]
[[package]] [[package]]
@ -285,6 +600,12 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.33" version = "0.4.33"
@ -300,12 +621,24 @@ dependencies = [
"regex-automata", "regex-automata",
] ]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.1" version = "1.2.1"
@ -317,6 +650,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "multimap"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@ -353,6 +692,42 @@ dependencies = [
"hmac", "hmac",
] ]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap 2.14.0",
]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@ -365,6 +740,25 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@ -374,6 +768,58 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "prost"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck",
"itertools",
"log",
"multimap",
"once_cell",
"petgraph",
"prettyplease",
"prost",
"prost-types",
"regex",
"syn",
"tempfile",
]
[[package]]
name = "prost-derive"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "prost-types"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
dependencies = [
"prost",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.46" version = "1.0.46"
@ -389,6 +835,27 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.6.4" version = "0.6.4"
@ -398,6 +865,18 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "regex"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.15" version = "0.4.15"
@ -429,6 +908,19 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.41" version = "0.23.41"
@ -438,6 +930,7 @@ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"log", "log",
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
@ -474,6 +967,12 @@ dependencies = [
"untrusted", "untrusted",
] ]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@ -562,12 +1061,28 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.2" version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.4" version = "0.6.4"
@ -595,6 +1110,25 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.10" version = "1.1.10"
@ -615,7 +1149,7 @@ dependencies = [
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2 0.6.4",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -641,6 +1175,30 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.8.23" version = "0.8.23"
@ -668,7 +1226,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap", "indexmap 2.14.0",
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime",
@ -682,6 +1240,98 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tonic"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52"
dependencies = [
"async-stream",
"async-trait",
"axum",
"base64",
"bytes",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-timeout",
"hyper-util",
"percent-encoding",
"pin-project",
"prost",
"rustls-pemfile",
"socket2 0.5.10",
"tokio",
"tokio-rustls",
"tokio-stream",
"tower 0.4.13",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tonic-build"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build",
"prost-types",
"quote",
"syn",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"indexmap 1.9.3",
"pin-project",
"pin-project-lite",
"rand",
"slab",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@ -743,6 +1393,12 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@ -773,6 +1429,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
@ -876,6 +1541,26 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "zerocopy"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.9.0" version = "1.9.0"

View file

@ -20,3 +20,9 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio-rustls = "0.26.4" tokio-rustls = "0.26.4"
rustls-pemfile = "2.2.0" rustls-pemfile = "2.2.0"
tonic = { version = "0.12", features = ["tls"] }
prost = "0.13"
tokio-stream = "0.1"
[build-dependencies]
tonic-build = "0.12"

View file

@ -30,6 +30,19 @@ propagates in milliseconds. Ingest is idempotent, so re-delivery and reconnect
after a split both converge. The log is compacted to one entry per account as it after a split both converge. The log is compacted to one entry per account as it
grows. An account registered on any node works on all of them. grows. An account registered on any node works on all of them.
## Directory API (gRPC)
**`src/grpc.rs`** exposes the account/channel directory over gRPC (see
`proto/fedserv.proto`) so a website can mirror it without touching IRC: a
one-shot `Snapshot` for the initial load, then `Subscribe` for a live stream of
every change from that point on. It subscribes to the same committed-entry
channel gossip does, so a change reaches a subscriber in the same push, no
polling. Deliberately excludes anything credential-shaped (password hashes,
SCRAM verifiers, cert fingerprints) and the finer channel-ops-list events
(access/akick/mlock/entrymsg) — identity and metadata only. Bearer-token
authenticated, optional server TLS. Omit `[grpc]` in config.toml to run
without it.
## Config ## Config
```toml ```toml
@ -68,6 +81,7 @@ cargo run -- config.toml
## Status ## Status
Links to an InspIRCd uplink and runs NickServ (REGISTER, IDENTIFY, LOGOUT, CERT, Links to an InspIRCd uplink and runs NickServ (REGISTER, IDENTIFY, LOGOUT, CERT,
and SASL PLAIN / SCRAM-SHA-256/512 / EXTERNAL) over an event-sourced account store and SASL PLAIN / SCRAM-SHA-256/512 / EXTERNAL) and ChanServ over an event-sourced
replicated between nodes by gossip, with an optional mutually authenticated TLS account store replicated between nodes by gossip, with an optional mutually
peer link. Next: ChanServ. authenticated TLS peer link, plus a gRPC directory API for websites to mirror
the account/channel directory (bearer-token authenticated, optional TLS).

10
build.rs Normal file
View file

@ -0,0 +1,10 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Server only: the Rust side never needs to dial itself as a gRPC client.
// Django (or any other consumer) generates its own stub from proto/fedserv.proto.
tonic_build::configure()
.build_server(true)
.build_client(false)
.compile_protos(&["proto/fedserv.proto"], &["proto"])?;
println!("cargo:rerun-if-changed=proto/fedserv.proto");
Ok(())
}

View file

@ -24,3 +24,14 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205
# [[peer]] # one block per other node # [[peer]] # one block per other node
# addr = "other-node:16700" # addr = "other-node:16700"
# name = "other-node" # TLS name to expect; must match the peer certificate # name = "other-node" # TLS name to expect; must match the peer certificate
# Directory replication (gRPC) — lets a website mirror the account/channel
# directory (identity + metadata only, never credentials; see proto/fedserv.proto).
# Omit this section to run without it.
# [grpc]
# bind = "127.0.0.1:50051" # a private network hop is the expected deployment
# token = "shared-secret" # every RPC must send `authorization: Bearer <token>`
#
# [grpc.tls] # omit for plaintext (fine on a private/loopback hop)
# cert = "certs/node.crt"
# key = "certs/node.key"

85
proto/fedserv.proto Normal file
View file

@ -0,0 +1,85 @@
// Directory replication API: lets a website (e.g. a Django backend) mirror the
// account/channel directory this node owns, without touching IRC at all.
//
// Deliberately excludes anything credential-shaped (password hashes, SCRAM
// verifiers, TLS cert fingerprints) a subscriber gets identity and metadata
// only, never a way to authenticate as an account. Channel events are the
// events already scoped Local to this node (see Scope in engine/db.rs), so a
// website only ever sees its own network's channels, matching what gossip
// federates.
syntax = "proto3";
package fedserv.v1;
service Directory {
// One-shot full read of current state, for a subscriber's initial load.
rpc Snapshot(SnapshotRequest) returns (SnapshotResponse);
// Live feed of directory changes from the moment of the call onward. A
// dropped stream should be followed by a fresh Snapshot (this is at-least-
// once delivery, not a resumable cursor) appliers should be idempotent,
// keyed by account/channel name.
rpc Subscribe(SubscribeRequest) returns (stream ReplicationEvent);
}
message SnapshotRequest {}
message AccountRecord {
string name = 1;
string email = 2; // empty = none on file
bool verified = 3;
uint64 registered_at = 4; // unix seconds
string home = 5; // origin node sid that first registered it
}
message ChannelRecord {
string name = 1;
string founder = 2; // account name
uint64 registered_at = 3;
string description = 4;
}
message SnapshotResponse {
repeated AccountRecord accounts = 1;
repeated ChannelRecord channels = 2;
}
message SubscribeRequest {}
// The gossip envelope (origin/seq/lamport) is carried alongside the payload so
// a subscriber can log/dedupe by (origin, seq) if it wants to, even though the
// stream itself is not resumable by cursor in this version.
message ReplicationEvent {
string origin = 1;
uint64 seq = 2;
uint64 lamport = 3;
oneof kind {
AccountRegistered account_registered = 10;
AccountEmailSet account_email_set = 11;
AccountVerified account_verified = 12;
AccountDropped account_dropped = 13;
NickGrouped nick_grouped = 14;
NickUngrouped nick_ungrouped = 15;
ChannelRegistered channel_registered = 20;
ChannelDropped channel_dropped = 21;
ChannelFounderSet channel_founder_set = 22;
ChannelDescSet channel_desc_set = 23;
}
}
message AccountRegistered {
string name = 1;
string email = 2;
bool verified = 3;
uint64 registered_at = 4;
string home = 5;
}
message AccountEmailSet { string account = 1; string email = 2; }
message AccountVerified { string account = 1; }
message AccountDropped { string account = 1; }
message NickGrouped { string nick = 1; string account = 2; }
message NickUngrouped { string nick = 1; }
message ChannelRegistered { string name = 1; string founder = 2; uint64 registered_at = 3; }
message ChannelDropped { string name = 1; }
message ChannelFounderSet { string name = 1; string founder = 2; }
message ChannelDescSet { string name = 1; string description = 2; }

View file

@ -12,6 +12,28 @@ pub struct Config {
// Outbound email (password resets). Absent = email features are off. // Outbound email (password resets). Absent = email features are off.
#[serde(default)] #[serde(default)]
pub email: Option<Email>, pub email: Option<Email>,
// Directory replication (gRPC), for websites mirroring the account/channel
// directory. Absent = the RPC server does not start.
#[serde(default)]
pub grpc: Option<Grpc>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Grpc {
// Address to accept client connections on, e.g. "127.0.0.1:50051".
pub bind: String,
// Bearer token every RPC must present (`authorization: Bearer <token>`).
pub token: String,
// Optional server-side TLS (no client cert required, unlike gossip's mTLS —
// a subscriber is a website backend, not a federation peer).
#[serde(default)]
pub tls: Option<ServerTls>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServerTls {
pub cert: String, // certificate chain (PEM)
pub key: String, // private key (PEM)
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]

View file

@ -236,6 +236,24 @@ impl LogEntry {
} }
} }
// Read-only accessors for consumers outside this module (the gRPC replication
// layer subscribes to the same broadcast channel gossip does, and translates
// each entry to a wire message).
impl LogEntry {
pub fn origin(&self) -> &str {
&self.origin
}
pub fn seq(&self) -> u64 {
self.seq
}
pub fn lamport(&self) -> u64 {
self.lamport
}
pub fn event(&self) -> &Event {
&self.event
}
}
// Append-only log, the sole persistent source of truth: `open` replays it, // Append-only log, the sole persistent source of truth: `open` replays it,
// `append` stamps and writes a locally-authored entry, and `ingest` folds in an // `append` stamps and writes a locally-authored entry, and `ingest` folds in an
// entry authored by another node. The version vector (highest seq applied per // entry authored by another node. The version vector (highest seq applied per
@ -886,6 +904,11 @@ impl Db {
self.channels.values() self.channels.values()
} }
/// All registered accounts, for a directory snapshot (see the gRPC layer).
pub fn accounts(&self) -> impl Iterator<Item = &Account> {
self.accounts.values()
}
/// Names of channels founded by `account` (case-insensitive). /// Names of channels founded by `account` (case-insensitive).
pub fn channels_owned_by(&self, account: &str) -> Vec<String> { pub fn channels_owned_by(&self, account: &str) -> Vec<String> {
self.channels self.channels

View file

@ -122,6 +122,11 @@ impl Engine {
self.db.missing_for(peer) self.db.missing_for(peer)
} }
// A full read of current directory state, for the gRPC Snapshot RPC.
pub fn directory_snapshot(&self) -> (Vec<db::Account>, Vec<db::ChannelInfo>) {
(self.db.accounts().cloned().collect(), self.db.channels().cloned().collect())
}
pub fn gossip_ingest(&mut self, entry: LogEntry) -> std::io::Result<()> { pub fn gossip_ingest(&mut self, entry: LogEntry) -> std::io::Result<()> {
// If ingesting a peer's entry removed an account a local session relied on // If ingesting a peer's entry removed an account a local session relied on
// (lost a conflict, or dropped elsewhere), clean up after it. // (lost a conflict, or dropped elsewhere), clean up after it.

311
src/grpc.rs Normal file
View file

@ -0,0 +1,311 @@
// Directory replication over gRPC: lets a website (Django, or anything else
// with a protoc-generated client) mirror the account/channel directory this
// node owns. See proto/fedserv.proto for the wire contract and exactly which
// fields are exposed — no credentials of any kind cross this API.
use std::pin::Pin;
use std::sync::Arc;
use subtle::ConstantTimeEq;
use tokio::sync::{broadcast, Mutex};
use tokio_stream::wrappers::ReceiverStream;
use tokio_stream::Stream;
use tonic::transport::{Identity, Server, ServerTlsConfig};
use tonic::{Request, Response, Status};
use crate::config::Grpc as GrpcCfg;
use crate::engine::db::{Account, ChannelInfo, Event, LogEntry};
use crate::engine::Engine;
pub mod pb {
tonic::include_proto!("fedserv.v1");
}
use pb::directory_server::{Directory, DirectoryServer};
use pb::replication_event::Kind;
use pb::{
AccountDropped, AccountEmailSet, AccountRecord, AccountRegistered, AccountVerified, ChannelDescSet,
ChannelDropped, ChannelFounderSet, ChannelRecord, ChannelRegistered, NickGrouped, NickUngrouped,
ReplicationEvent, SnapshotRequest, SnapshotResponse, SubscribeRequest,
};
type Shared = Arc<Mutex<Engine>>;
// How many not-yet-sent events a slow subscriber may buffer before it starts
// blocking the broadcast (matches the gossip outbound channel's own sizing).
const SUBSCRIBER_BUFFER: usize = 1024;
struct DirectoryService {
engine: Shared,
outbound: broadcast::Sender<LogEntry>,
token: String,
}
impl DirectoryService {
// Every RPC needs `authorization: Bearer <token>` matching the configured
// secret. Constant-time compare — same posture as password/secret checks
// elsewhere in this codebase, cheap insurance against a timing side-channel.
fn authorize<T>(&self, req: &Request<T>) -> Result<(), Status> {
let got = req
.metadata()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("");
if got.as_bytes().ct_eq(self.token.as_bytes()).into() {
Ok(())
} else {
Err(Status::unauthenticated("bad or missing bearer token"))
}
}
}
fn account_record(a: &Account) -> AccountRecord {
AccountRecord {
name: a.name.clone(),
email: a.email.clone().unwrap_or_default(),
verified: a.verified,
registered_at: a.ts,
home: a.home.clone(),
}
}
fn channel_record(c: &ChannelInfo) -> ChannelRecord {
ChannelRecord { name: c.name.clone(), founder: c.founder.clone(), registered_at: c.ts, description: c.desc.clone() }
}
// Translate one committed log entry to a wire event. Returns None for events
// this API doesn't replicate (credential changes, and the finer-grained
// channel-ops-list events) — the subscriber simply never sees them, v1 scope.
fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
let kind = match entry.event() {
Event::AccountRegistered(a) => Kind::AccountRegistered(AccountRegistered {
name: a.name.clone(),
email: a.email.clone().unwrap_or_default(),
verified: a.verified,
registered_at: a.ts,
home: a.home.clone(),
}),
Event::AccountEmailSet { account, email } => {
Kind::AccountEmailSet(AccountEmailSet { account: account.clone(), email: email.clone().unwrap_or_default() })
}
Event::AccountVerified { account } => Kind::AccountVerified(AccountVerified { account: account.clone() }),
Event::AccountDropped { account } => Kind::AccountDropped(AccountDropped { account: account.clone() }),
Event::NickGrouped { nick, account } => Kind::NickGrouped(NickGrouped { nick: nick.clone(), account: account.clone() }),
Event::NickUngrouped { nick } => Kind::NickUngrouped(NickUngrouped { nick: nick.clone() }),
Event::ChannelRegistered { name, founder, ts } => {
Kind::ChannelRegistered(ChannelRegistered { name: name.clone(), founder: founder.clone(), registered_at: *ts })
}
Event::ChannelDropped { name } => Kind::ChannelDropped(ChannelDropped { name: name.clone() }),
Event::ChannelFounderSet { channel, founder } => {
Kind::ChannelFounderSet(ChannelFounderSet { name: channel.clone(), founder: founder.clone() })
}
Event::ChannelDescSet { channel, desc } => Kind::ChannelDescSet(ChannelDescSet { name: channel.clone(), description: desc.clone() }),
// Credentials (never leave this API) and the finer channel-ops-list
// events (access/akick/mlock/entrymsg — internal IRC bookkeeping, not
// directory identity) are intentionally not replicated.
Event::CertAdded { .. }
| Event::CertRemoved { .. }
| Event::AccountPasswordSet { .. }
| Event::ChannelMlock { .. }
| Event::ChannelAccessAdd { .. }
| Event::ChannelAccessDel { .. }
| Event::ChannelAkickAdd { .. }
| Event::ChannelAkickDel { .. }
| Event::ChannelEntryMsgSet { .. } => return None,
};
Some(ReplicationEvent { origin: entry.origin().to_string(), seq: entry.seq(), lamport: entry.lamport(), kind: Some(kind) })
}
#[tonic::async_trait]
impl Directory for DirectoryService {
async fn snapshot(&self, req: Request<SnapshotRequest>) -> Result<Response<SnapshotResponse>, Status> {
self.authorize(&req)?;
let (accounts, channels) = self.engine.lock().await.directory_snapshot();
Ok(Response::new(SnapshotResponse {
accounts: accounts.iter().map(account_record).collect(),
channels: channels.iter().map(channel_record).collect(),
}))
}
type SubscribeStream = Pin<Box<dyn Stream<Item = Result<ReplicationEvent, Status>> + Send + 'static>>;
async fn subscribe(&self, req: Request<SubscribeRequest>) -> Result<Response<Self::SubscribeStream>, Status> {
self.authorize(&req)?;
let mut rx = self.outbound.subscribe();
let (tx, out) = tokio::sync::mpsc::channel(SUBSCRIBER_BUFFER);
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(entry) => {
if let Some(ev) = to_wire(&entry) {
if tx.send(Ok(ev)).await.is_err() {
break; // subscriber went away
}
}
}
// We fell behind and the sender overwrote entries we hadn't read
// yet: the stream is no longer a complete replica of what
// changed, so end it. The documented contract (see the .proto)
// is that a subscriber re-Snapshots after a dropped stream.
Err(broadcast::error::RecvError::Lagged(_)) => {
let _ = tx.send(Err(Status::data_loss("subscriber lagged behind the change stream; re-run Snapshot"))).await;
break;
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
Ok(Response::new(Box::pin(ReceiverStream::new(out))))
}
}
// Start the listener, if configured. Absent [grpc] in config.toml = no-op, same
// pattern as gossip being optional.
pub async fn run(engine: Shared, cfg: GrpcCfg, outbound: broadcast::Sender<LogEntry>) {
let addr = match cfg.bind.parse() {
Ok(a) => a,
Err(e) => return tracing::error!(%e, bind = %cfg.bind, "bad grpc bind address"),
};
let has_tls = cfg.tls.is_some();
let svc = DirectoryService { engine, outbound, token: cfg.token };
let mut server = Server::builder();
if let Some(tls) = &cfg.tls {
let (cert, key) = match (std::fs::read(&tls.cert), std::fs::read(&tls.key)) {
(Ok(c), Ok(k)) => (c, k),
_ => return tracing::error!("grpc TLS cert/key unreadable"),
};
let identity = Identity::from_pem(cert, key);
server = match server.tls_config(ServerTlsConfig::new().identity(identity)) {
Ok(s) => s,
Err(e) => return tracing::error!(%e, "grpc TLS setup failed"),
};
}
tracing::info!(%addr, tls = has_tls, "grpc directory API listening");
if let Err(e) = server.add_service(DirectoryServer::new(svc)).serve(addr).await {
tracing::error!(%e, "grpc server exited");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::db::Db;
use crate::nickserv::NickServ;
use tokio_stream::StreamExt;
use tonic::metadata::MetadataValue;
fn engine_with(tag: &str) -> (Shared, broadcast::Sender<LogEntry>) {
let path = std::env::temp_dir().join(format!("fedserv-grpc-{tag}.jsonl"));
let _ = std::fs::remove_file(&path);
let (tx, _) = broadcast::channel(1024);
let mut db = Db::open(&path, "A");
db.scram_iterations = 4096;
db.set_outbound(tx.clone());
let ns = NickServ { uid: "AAAAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
(Arc::new(Mutex::new(Engine::new(vec![Box::new(ns)], db))), tx)
}
fn authed<T>(msg: T, token: &str) -> Request<T> {
let mut req = Request::new(msg);
req.metadata_mut().insert("authorization", MetadataValue::try_from(format!("Bearer {token}")).unwrap());
req
}
// Credential-shaped and ops-list events never cross the wire; directory
// identity events do, carrying only the fields the .proto exposes.
#[test]
fn to_wire_filters_credentials_and_maps_directory_events() {
let acct = Account {
name: "alice".into(),
password_hash: "secret-hash".into(),
email: Some("alice@example.com".into()),
ts: 111,
home: "A".into(),
scram256: Some("verifier".into()),
scram512: None,
certfps: vec!["deadbeef".into()],
verified: true,
};
let registered = LogEntry::for_test("A", 0, 1, Event::AccountRegistered(acct));
let wire = to_wire(&registered).expect("account registration replicates");
match wire.kind {
Some(Kind::AccountRegistered(a)) => {
assert_eq!(a.name, "alice");
assert_eq!(a.email, "alice@example.com");
assert!(a.verified);
}
other => panic!("expected AccountRegistered, got {other:?}"),
}
let pw_change = LogEntry::for_test(
"A", 1, 2,
Event::AccountPasswordSet { account: "alice".into(), password_hash: "x".into(), scram256: "x".into(), scram512: "x".into() },
);
assert!(to_wire(&pw_change).is_none(), "credential changes must never replicate");
let cert = LogEntry::for_test("A", 2, 3, Event::CertAdded { account: "alice".into(), fp: "deadbeef".into() });
assert!(to_wire(&cert).is_none(), "cert fingerprints must never replicate");
let akick = LogEntry::for_test("A", 3, 4, Event::ChannelAkickAdd { channel: "#x".into(), mask: "*!*@bad".into(), reason: String::new() });
assert!(to_wire(&akick).is_none(), "ops-list events are out of v1 scope");
let chan = LogEntry::for_test("A", 4, 5, Event::ChannelRegistered { name: "#tchatou".into(), founder: "alice".into(), ts: 222 });
match to_wire(&chan).expect("channel registration replicates").kind {
Some(Kind::ChannelRegistered(c)) => {
assert_eq!(c.name, "#tchatou");
assert_eq!(c.founder, "alice");
}
other => panic!("expected ChannelRegistered, got {other:?}"),
}
}
#[tokio::test]
async fn snapshot_requires_the_configured_token() {
let (engine, tx) = engine_with("auth");
engine.lock().await.test_register("alice");
let svc = DirectoryService { engine, outbound: tx, token: "right".into() };
let err = svc.snapshot(authed(SnapshotRequest {}, "wrong")).await.unwrap_err();
assert_eq!(err.code(), tonic::Code::Unauthenticated);
let ok = svc.snapshot(authed(SnapshotRequest {}, "right")).await.unwrap();
assert_eq!(ok.get_ref().accounts.len(), 1);
assert_eq!(ok.get_ref().accounts[0].name, "alice");
}
#[tokio::test]
async fn snapshot_never_includes_credential_fields() {
// AccountRecord (unlike the internal Account) has no field a credential
// could hide in — this test is a canary against ever adding one.
let (engine, tx) = engine_with("shape");
engine.lock().await.test_register("alice");
let svc = DirectoryService { engine, outbound: tx, token: "t".into() };
let resp = svc.snapshot(authed(SnapshotRequest {}, "t")).await.unwrap();
let rec = &resp.get_ref().accounts[0];
// Exhaustive destructure: adding a field to AccountRecord forces a look here.
let AccountRecord { name, email, verified, registered_at, home } = rec;
assert_eq!(name, "alice");
let _ = (email, verified, registered_at, home);
}
#[tokio::test]
async fn subscribe_streams_a_live_registration() {
let (engine, tx) = engine_with("live");
let svc = DirectoryService { engine: engine.clone(), outbound: tx, token: "t".into() };
let stream = svc.subscribe(authed(SubscribeRequest {}, "t")).await.unwrap().into_inner();
tokio::pin!(stream);
engine.lock().await.test_register("bob");
let ev = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next())
.await
.expect("event within timeout")
.expect("stream not closed")
.expect("not an error");
match ev.kind {
Some(Kind::AccountRegistered(a)) => assert_eq!(a.name, "bob"),
other => panic!("expected AccountRegistered, got {other:?}"),
}
}
}

View file

@ -3,6 +3,7 @@ mod config;
mod email; mod email;
mod engine; mod engine;
mod gossip; mod gossip;
mod grpc;
mod link; mod link;
#[path = "../modules/protocol/mod.rs"] #[path = "../modules/protocol/mod.rs"]
mod proto; mod proto;
@ -70,7 +71,14 @@ async fn main() -> Result<()> {
if let Some(gossip) = cfg.gossip.clone() { if let Some(gossip) = cfg.gossip.clone() {
tracing::info!(peers = cfg.peer.len(), "starting gossip"); tracing::info!(peers = cfg.peer.len(), "starting gossip");
tokio::spawn(gossip::run(engine.clone(), gossip, cfg.peer.clone(), cfg.server.sid.clone(), gossip_tx)); tokio::spawn(gossip::run(engine.clone(), gossip, cfg.peer.clone(), cfg.server.sid.clone(), gossip_tx.clone()));
}
// Directory replication for websites (Django, etc.) — subscribes to the same
// committed-entry stream gossip does, so a website sees the account/channel
// directory update in real time without touching IRC at all.
if let Some(grpc_cfg) = cfg.grpc.clone() {
tokio::spawn(grpc::run(engine.clone(), grpc_cfg, gossip_tx));
} }
// Periodically fold log churn into a snapshot when it grows past the accounts. // Periodically fold log churn into a snapshot when it grows past the accounts.