mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-24 18:26:06 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfadc936c0 | ||
|
|
80afd764d3 | ||
|
|
5bbe2146ff | ||
|
|
b009ccdaf2 | ||
|
|
39f74b5adf | ||
|
|
a231d7da29 | ||
|
|
63d84d38fb | ||
|
|
fe9b88141c | ||
|
|
72f5dce32b | ||
|
|
cd7e01100e | ||
|
|
7c248be153 |
Generated
+170
-32
@@ -210,7 +210,7 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-derive 0.5.1",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
@@ -220,6 +220,22 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive 0.6.0",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.5.1"
|
||||
@@ -232,6 +248,18 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
@@ -1046,7 +1074,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1055,6 +1083,15 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
@@ -1970,7 +2007,21 @@ version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"asn1-rs 0.6.2",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs 0.7.2",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-bigint",
|
||||
@@ -2834,6 +2885,34 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h3"
|
||||
version = "0.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h3-quinn"
|
||||
version = "0.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
"h3",
|
||||
"quinn",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -4614,7 +4693,16 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"asn1-rs 0.6.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs 0.7.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5184,7 +5272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bit-vec 0.8.0",
|
||||
"bitflags",
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
@@ -5427,6 +5515,7 @@ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"futures-io",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
@@ -5630,6 +5719,20 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
|
||||
dependencies = [
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser 0.18.1",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.1.0"
|
||||
@@ -7527,7 +7630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7578,7 +7681,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -7601,7 +7704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7615,7 +7718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7641,7 +7744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
@@ -7649,7 +7752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -7665,7 +7768,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7682,7 +7785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7698,7 +7801,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -7709,7 +7812,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -7728,7 +7831,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7751,7 +7854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -7784,7 +7887,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -7837,6 +7940,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"testcontainers",
|
||||
"testcontainers-modules",
|
||||
"thiserror 2.0.18",
|
||||
@@ -7877,7 +7981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -7889,7 +7993,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
@@ -7914,7 +8018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -7930,17 +8034,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
"bytes",
|
||||
"clap",
|
||||
"dotenvy",
|
||||
"ed25519-dalek",
|
||||
"futures-util",
|
||||
"h3",
|
||||
"h3-quinn",
|
||||
"hex",
|
||||
"http 1.4.0",
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"quinn",
|
||||
"rcgen",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pemfile",
|
||||
"thiserror 2.0.18",
|
||||
@@ -7960,7 +8070,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-signal"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7983,7 +8093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -8000,7 +8110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-store"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -8049,7 +8159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -8071,7 +8181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cid",
|
||||
@@ -8584,7 +8694,7 @@ checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"base64urlsafedata",
|
||||
"der-parser",
|
||||
"der-parser 9.0.0",
|
||||
"hex",
|
||||
"nom 7.1.3",
|
||||
"openssl",
|
||||
@@ -8600,7 +8710,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"webauthn-attestation-ca",
|
||||
"webauthn-rs-proto",
|
||||
"x509-parser",
|
||||
"x509-parser 0.16.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9154,17 +9264,35 @@ version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"asn1-rs 0.6.2",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"der-parser 9.0.0",
|
||||
"lazy_static",
|
||||
"nom 7.1.3",
|
||||
"oid-registry",
|
||||
"oid-registry 0.7.1",
|
||||
"rusticata-macros",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs 0.7.2",
|
||||
"data-encoding",
|
||||
"der-parser 10.0.0",
|
||||
"lazy_static",
|
||||
"nom 7.1.3",
|
||||
"oid-registry 0.8.1",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
@@ -9193,6 +9321,16 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
|
||||
+5
-1
@@ -26,7 +26,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -82,6 +82,8 @@ foca = { version = "1", features = ["bincode-codec", "tracing"] }
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
governor = "0.10"
|
||||
h3 = "0.0.8"
|
||||
h3-quinn = "0.0.10"
|
||||
hex = "0.4"
|
||||
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
|
||||
hkdf = "0.12"
|
||||
@@ -106,7 +108,9 @@ parking_lot = "0.12"
|
||||
multihash = "0.19"
|
||||
p256 = { version = "0.13", features = ["ecdsa"] }
|
||||
p384 = { version = "0.13", features = ["ecdsa"] }
|
||||
quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring", "log"] }
|
||||
rand = "0.8"
|
||||
rcgen = { version = "0.14", default-features = false, features = ["ring", "pem"] }
|
||||
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
|
||||
regex = "1"
|
||||
rsa = "0.9"
|
||||
|
||||
@@ -92,6 +92,7 @@ We currently don't have a shared space to chat and organize Tranquil things, but
|
||||
- [@sans-self.org](https://tangled.org/did:plc:wydyrngmxbcsqdvhmd7whmye)
|
||||
- [@tachyonism.tngl.sh](https://tangled.org/did:plc:w6qiwij62bmdugsd3gemhpy2)
|
||||
- [@trezy.codes](https://tangled.org/did:plc:4jrld6fwpnwqehtce56qshzv)
|
||||
- [@ave.zone](https://tangled.org/did:plc:mchrltkrhuzpxleiwpmmvpar)
|
||||
- Could be your name here too!
|
||||
|
||||
### Tranquil PDS instances in the wild!
|
||||
|
||||
@@ -6,10 +6,6 @@ use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Auth, Permissive};
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
fn generate_plc_token() -> String {
|
||||
tranquil_pds::util::generate_token_code()
|
||||
}
|
||||
|
||||
pub async fn request_plc_operation_signature(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Permissive>,
|
||||
@@ -28,12 +24,13 @@ pub async fn request_plc_operation_signature(
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let _ = state.repos.infra.delete_plc_tokens_for_user(user_id).await;
|
||||
let plc_token = generate_plc_token();
|
||||
let display_token = tranquil_pds::util::generate_token_code();
|
||||
let stored_token = tranquil_pds::util::normalize_token_code(&display_token);
|
||||
let expires_at = Utc::now() + Duration::minutes(10);
|
||||
state
|
||||
.repos
|
||||
.infra
|
||||
.insert_plc_token(user_id, &plc_token, expires_at)
|
||||
.insert_plc_token(user_id, &stored_token, expires_at)
|
||||
.await
|
||||
.log_db_err("creating PLC token")?;
|
||||
|
||||
@@ -42,7 +39,7 @@ pub async fn request_plc_operation_signature(
|
||||
state.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user_id,
|
||||
&plc_token,
|
||||
&display_token,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -43,9 +43,10 @@ pub async fn sign_plc_operation(
|
||||
"PLC operations are only valid for did:plc identities".into(),
|
||||
));
|
||||
}
|
||||
let token = input.token.as_ref().ok_or_else(|| {
|
||||
let raw_token = input.token.as_ref().ok_or_else(|| {
|
||||
ApiError::InvalidRequest("Email confirmation token required to sign PLC operations".into())
|
||||
})?;
|
||||
let token = &tranquil_pds::util::normalize_token_code(raw_token);
|
||||
|
||||
let user_id = state
|
||||
.repos
|
||||
|
||||
@@ -15,8 +15,12 @@ pub mod temp;
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
pub fn api_routes() -> axum::Router<AppState> {
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{get, post};
|
||||
|
||||
let blob_body_limit =
|
||||
DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize);
|
||||
|
||||
axum::Router::new()
|
||||
.route("/_health", get(server::health))
|
||||
.route(
|
||||
@@ -68,7 +72,10 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
.route("/com.atproto.repo.deleteRecord", post(repo::delete_record))
|
||||
.route("/com.atproto.repo.listRecords", get(repo::list_records))
|
||||
.route("/com.atproto.repo.describeRepo", get(repo::describe_repo))
|
||||
.route("/com.atproto.repo.uploadBlob", post(repo::upload_blob))
|
||||
.route(
|
||||
"/com.atproto.repo.uploadBlob",
|
||||
post(repo::upload_blob).layer(blob_body_limit),
|
||||
)
|
||||
.route("/com.atproto.repo.applyWrites", post(repo::apply_writes))
|
||||
.route(
|
||||
"/com.atproto.server.checkAccountStatus",
|
||||
@@ -247,7 +254,10 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
"/_identity.verifyHandleOwnership",
|
||||
post(identity::verify_handle_ownership),
|
||||
)
|
||||
.route("/com.atproto.repo.importRepo", post(repo::import_repo))
|
||||
.route(
|
||||
"/com.atproto.repo.importRepo",
|
||||
post(repo::import_repo).layer(blob_body_limit),
|
||||
)
|
||||
.route(
|
||||
"/com.atproto.admin.deleteAccount",
|
||||
post(admin::delete_account),
|
||||
|
||||
@@ -77,27 +77,6 @@ pub async fn import_repo(
|
||||
blocks.len(),
|
||||
root
|
||||
);
|
||||
let Some(root_block) = blocks.get(&root) else {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Root block not found in CAR file".into(),
|
||||
));
|
||||
};
|
||||
let commit_did: Did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
|
||||
Ok(commit) => commit
|
||||
.did()
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidRequest("Commit contains invalid DID".into()))?,
|
||||
Err(e) => {
|
||||
return Err(ApiError::InvalidRequest(format!("Invalid commit: {}", e)));
|
||||
}
|
||||
};
|
||||
if commit_did != *did {
|
||||
return Err(ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, did
|
||||
)));
|
||||
}
|
||||
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
|
||||
.ok()
|
||||
.map(|v| v == "true" || v == "1")
|
||||
@@ -108,11 +87,13 @@ pub async fn import_repo(
|
||||
});
|
||||
let is_migration = user.inbound_migration && user.deactivated_at.is_some();
|
||||
if skip_verification {
|
||||
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
|
||||
} else if is_migration {
|
||||
debug!("Verifying CAR file structure for migration (skipping signature verification)");
|
||||
warn!("Skipping all CAR verification for repo import (SKIP_IMPORT_VERIFICATION=true)");
|
||||
} else {
|
||||
debug!(
|
||||
"Verifying CAR file structure for repo import (skipping signature and DID verification)"
|
||||
);
|
||||
let verifier = CarVerifier::new();
|
||||
match verifier.verify_car_structure_only(did, &root, &blocks) {
|
||||
match verifier.verify_car_structure_only(&root, &blocks) {
|
||||
Ok(verified) => {
|
||||
debug!(
|
||||
"CAR structure verification successful: rev={}, data_cid={}",
|
||||
@@ -142,56 +123,6 @@ pub async fn import_repo(
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("Verifying CAR file signature and structure for DID {}", did);
|
||||
let verifier = CarVerifier::new();
|
||||
match verifier.verify_car(did, &root, &blocks).await {
|
||||
Ok(verified) => {
|
||||
debug!(
|
||||
"CAR verification successful: rev={}, data_cid={}",
|
||||
verified.rev, verified.data_cid
|
||||
);
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::DidMismatch {
|
||||
commit_did,
|
||||
expected_did,
|
||||
}) => {
|
||||
return Err(ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, expected_did
|
||||
)));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::InvalidSignature) => {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"CAR file commit signature verification failed".into(),
|
||||
));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::DidResolutionFailed(msg)) => {
|
||||
warn!("DID resolution failed during import verification: {}", msg);
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"Failed to verify DID: {}",
|
||||
msg
|
||||
)));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::NoSigningKey) => {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"DID document does not contain a signing key".into(),
|
||||
));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::MstValidationFailed(msg)) => {
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"MST validation failed: {}",
|
||||
msg
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("CAR verification error: {:?}", e);
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"CAR verification failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let max_blocks = tranquil_config::get().import.max_blocks as usize;
|
||||
let _write_lock = state.repo_write_locks.lock(user_id).await;
|
||||
|
||||
@@ -13,10 +13,6 @@ use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::PlainPassword;
|
||||
use tranquil_pds::validation::validate_password;
|
||||
|
||||
fn generate_reset_code() -> String {
|
||||
tranquil_pds::util::generate_token_code()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestPasswordResetInput {
|
||||
#[serde(alias = "identifier")]
|
||||
@@ -70,12 +66,13 @@ pub async fn request_password_reset(
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
};
|
||||
let code = generate_reset_code();
|
||||
let display_code = tranquil_pds::util::generate_token_code();
|
||||
let stored_code = tranquil_pds::util::normalize_token_code(&display_code);
|
||||
let expires_at = Utc::now() + Duration::minutes(10);
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.user
|
||||
.set_password_reset_code(user_id, &code, expires_at)
|
||||
.set_password_reset_code(user_id, &stored_code, expires_at)
|
||||
.await
|
||||
{
|
||||
error!("DB error setting reset code: {:?}", e);
|
||||
@@ -86,7 +83,7 @@ pub async fn request_password_reset(
|
||||
state.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user_id,
|
||||
&code,
|
||||
&display_code,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
@@ -133,7 +130,13 @@ pub async fn reset_password(
|
||||
if let Err(e) = validate_password(password) {
|
||||
return Err(ApiError::InvalidRequest(e.to_string()));
|
||||
}
|
||||
let user = match state.repos.user.get_user_by_reset_code(token).await {
|
||||
let normalized_token = tranquil_pds::util::normalize_token_code(token);
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_user_by_reset_code(&normalized_token)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return Err(ApiError::InvalidToken(None));
|
||||
|
||||
@@ -15,7 +15,10 @@ use tranquil_pds::auth::{
|
||||
Active, Auth, NormalizedLoginIdentifier, Permissive, require_legacy_session_mfa,
|
||||
require_reauth_window,
|
||||
};
|
||||
use tranquil_pds::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit};
|
||||
use tranquil_pds::rate_limit::{
|
||||
LoginLimit, RateLimited, RefreshSessionLimit, TotpVerifyLimit,
|
||||
check_user_rate_limit_with_message,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AccountState, Did, Handle, PlainPassword};
|
||||
use tranquil_types::TokenId;
|
||||
@@ -168,17 +171,35 @@ pub async fn create_session(
|
||||
let has_totp = row.totp_enabled;
|
||||
let email_2fa_enabled = row.email_2fa_enabled;
|
||||
let is_legacy_login = has_totp || email_2fa_enabled;
|
||||
let used_totp_factor = tranquil_pds::auth::legacy_2fa::used_totp_factor(
|
||||
has_totp,
|
||||
input.auth_factor_token.as_deref(),
|
||||
);
|
||||
let twofa_ctx = tranquil_pds::auth::legacy_2fa::Legacy2faContext {
|
||||
is_app_password: app_password_name.is_some(),
|
||||
email_2fa_enabled,
|
||||
has_totp,
|
||||
allow_legacy_login: row.allow_legacy_login,
|
||||
};
|
||||
if let Some(token) = input.auth_factor_token.as_deref()
|
||||
&& has_totp
|
||||
&& tranquil_pds::auth::legacy_2fa::looks_like_totp_token(token)
|
||||
{
|
||||
check_user_rate_limit_with_message::<TotpVerifyLimit>(
|
||||
&state,
|
||||
row.did.as_str(),
|
||||
"Too many verification attempts. Please try again in a few minutes.",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
match tranquil_pds::auth::legacy_2fa::process_legacy_2fa(
|
||||
state.cache.as_ref(),
|
||||
&row.did,
|
||||
&twofa_ctx,
|
||||
input.auth_factor_token.as_deref(),
|
||||
async |t: &str| {
|
||||
crate::server::totp::verify_totp_or_backup_for_user(&state, &row.did, t).await
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -285,7 +306,7 @@ pub async fn create_session(
|
||||
error!("Failed to insert session: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
if is_legacy_login {
|
||||
if is_legacy_login && !used_totp_factor {
|
||||
warn!(
|
||||
did = %row.did,
|
||||
ip = %client_ip,
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn create_access_token_with_scope_metadata(
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
hostname,
|
||||
)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ pub fn create_access_token_with_delegation(
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
act,
|
||||
hostname,
|
||||
)
|
||||
@@ -69,7 +69,7 @@ pub fn create_refresh_token_with_metadata(
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
key_bytes,
|
||||
Duration::days(14),
|
||||
Duration::days(90),
|
||||
None,
|
||||
)
|
||||
}
|
||||
@@ -198,7 +198,7 @@ pub fn create_access_token_hs256_with_metadata(
|
||||
TokenScope::Access.as_str(),
|
||||
TokenType::Access,
|
||||
secret,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ pub fn create_refresh_token_hs256_with_metadata(
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
secret,
|
||||
Duration::days(14),
|
||||
Duration::days(90),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ impl TokenType {
|
||||
match self {
|
||||
Self::Access => "at+jwt",
|
||||
Self::Refresh => "refresh+jwt",
|
||||
Self::Service => "jwt",
|
||||
// RFC 7519 §5.1 recommends the uppercase "JWT".
|
||||
// and for atproto inter-service auth its a requirement.
|
||||
Self::Service => "JWT",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +292,17 @@ mod tests {
|
||||
assert!(TokenType::from_str("bearer").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_token_header_serializes_typ_as_uppercase_jwt() {
|
||||
// RFC 7519 §5.1 recommends the JWT `typ` header value be uppercase "JWT".
|
||||
let header = Header {
|
||||
alg: SigningAlgorithm::ES256K,
|
||||
typ: TokenType::Service,
|
||||
};
|
||||
let json = serde_json::to_string(&header).expect("serialize header");
|
||||
assert!(json.contains(r#""typ":"JWT""#), "got {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_algorithm_case_insensitive() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -513,6 +513,11 @@ pub struct TlsConfig {
|
||||
/// Path to the TLS private key.
|
||||
#[config(env = "TLS_KEY_PATH")]
|
||||
pub key_path: Option<String>,
|
||||
|
||||
/// Serve HTTP/3 over QUIC on the same UDP port as the TCP listener.
|
||||
/// Requires cert_path and key_path.
|
||||
#[config(env = "TLS_HTTP3", default = false)]
|
||||
pub http3: bool,
|
||||
}
|
||||
|
||||
impl TlsConfig {
|
||||
@@ -532,6 +537,13 @@ impl TlsConfig {
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if self.http3 && self.material().is_none() {
|
||||
errors.push(
|
||||
"server.tls.http3 (TLS_HTTP3) requires server.tls.cert_path \
|
||||
and erver.tls.key_path"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1799,6 +1811,7 @@ port = 587
|
||||
TlsConfig {
|
||||
cert_path: None,
|
||||
key_path: None,
|
||||
http3: false,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
|
||||
@@ -1810,6 +1823,7 @@ port = 587
|
||||
TlsConfig {
|
||||
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
|
||||
key_path: Some("/etc/tranquil/key.pem".to_string()),
|
||||
http3: false,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
|
||||
@@ -1821,6 +1835,7 @@ port = 587
|
||||
TlsConfig {
|
||||
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
|
||||
key_path: None,
|
||||
http3: false,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(
|
||||
@@ -1829,6 +1844,21 @@ port = 587
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_validate_rejects_http3_without_material() {
|
||||
let mut errors = Vec::new();
|
||||
TlsConfig {
|
||||
cert_path: None,
|
||||
key_path: None,
|
||||
http3: true,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(
|
||||
errors.iter().any(|e| e.contains("http3")),
|
||||
"expected http3 error, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EmailOverrides {
|
||||
from_address: Option<&'static str>,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cache::Cache;
|
||||
use crate::util::{generate_token_code, normalize_token_code};
|
||||
|
||||
const TOKEN_TTL_SECS: u64 = 900;
|
||||
const BASE32_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EmailTokenPurpose {
|
||||
@@ -46,14 +45,6 @@ fn cache_key(did: &str, purpose: EmailTokenPurpose) -> String {
|
||||
format!("email_token:{}:{}", purpose.as_str(), did)
|
||||
}
|
||||
|
||||
fn generate_short_token() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let token: String = (0..10)
|
||||
.map(|_| BASE32_CHARS[rng.gen_range(0..BASE32_CHARS.len())] as char)
|
||||
.collect();
|
||||
format!("{}-{}", &token[0..5], &token[5..10])
|
||||
}
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
|
||||
}
|
||||
@@ -67,9 +58,9 @@ pub async fn create_email_token(
|
||||
return Err(TokenError::CacheUnavailable);
|
||||
}
|
||||
|
||||
let token = generate_short_token();
|
||||
let token = generate_token_code();
|
||||
let data = TokenData {
|
||||
token: token.clone(),
|
||||
token: normalize_token_code(&token),
|
||||
created_at: current_timestamp(),
|
||||
};
|
||||
|
||||
@@ -108,10 +99,9 @@ pub async fn validate_email_token(
|
||||
return Err(TokenError::ExpiredToken);
|
||||
}
|
||||
|
||||
let normalized_input = token.to_uppercase().replace('-', "");
|
||||
let normalized_stored = data.token.to_uppercase().replace('-', "");
|
||||
let normalized_input = normalize_token_code(token);
|
||||
|
||||
if !constant_time_eq(normalized_input.as_bytes(), normalized_stored.as_bytes()) {
|
||||
if !constant_time_eq(normalized_input.as_bytes(), data.token.as_bytes()) {
|
||||
return Err(TokenError::InvalidToken);
|
||||
}
|
||||
|
||||
@@ -260,20 +250,19 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_format() {
|
||||
(0..100).for_each(|_| {
|
||||
let token = generate_short_token();
|
||||
// The emitted token is the display form: uppercase `XXXXX-XXXXX`.
|
||||
let cache = MockCache::new();
|
||||
let did = "did:plc:test123";
|
||||
(0..50).for_each(|_| {
|
||||
let token = futures::executor::block_on(create_email_token(
|
||||
&cache,
|
||||
did,
|
||||
EmailTokenPurpose::UpdateEmail,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(token.len(), 11);
|
||||
assert_eq!(&token[5..6], "-");
|
||||
assert!(
|
||||
token[0..5]
|
||||
.chars()
|
||||
.all(|c| BASE32_CHARS.contains(&(c as u8)))
|
||||
);
|
||||
assert!(
|
||||
token[6..11]
|
||||
.chars()
|
||||
.all(|c| BASE32_CHARS.contains(&(c as u8)))
|
||||
);
|
||||
assert_eq!(token, token.to_uppercase());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,6 +281,21 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hyphen_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = "did:plc:test123";
|
||||
|
||||
let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let no_hyphen = token.replace('-', "");
|
||||
let result =
|
||||
validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &no_hyphen).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_noop_cache_returns_unavailable() {
|
||||
let cache = crate::cache::NoOpCache;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
use chrono::Utc;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cache::Cache;
|
||||
use crate::types::Did;
|
||||
use crate::util::{generate_token_code, normalize_token_code};
|
||||
|
||||
const CHALLENGE_TTL_SECS: u64 = 300;
|
||||
const MIN_REMAINING_TTL_SECS: u64 = 10;
|
||||
const MAX_ATTEMPTS: u8 = 5;
|
||||
const CODE_LENGTH: usize = 8;
|
||||
const COOLDOWN_SECS: u64 = 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -94,7 +93,8 @@ async fn validate_challenge_internal(
|
||||
return Err(ValidationError::ChallengeExpired);
|
||||
}
|
||||
|
||||
if !constant_time_eq(code.as_bytes(), data.code.as_bytes()) {
|
||||
let normalized_input = normalize_token_code(code);
|
||||
if !constant_time_eq(normalized_input.as_bytes(), data.code.as_bytes()) {
|
||||
let updated = ChallengeData {
|
||||
code: data.code,
|
||||
attempts: data.attempts + 1,
|
||||
@@ -127,17 +127,19 @@ fn cooldown_key(did: &str) -> String {
|
||||
format!("legacy_2fa_cooldown:{}", did)
|
||||
}
|
||||
|
||||
fn generate_code() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..CODE_LENGTH)
|
||||
.map(|_| rng.gen_range(0..10).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
u64::try_from(Utc::now().timestamp()).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn looks_like_totp_token(code: &str) -> bool {
|
||||
let c = code.trim();
|
||||
(c.len() == 6 && c.bytes().all(|b| b.is_ascii_digit())) || crate::auth::is_backup_code_format(c)
|
||||
}
|
||||
|
||||
pub fn used_totp_factor(has_totp: bool, auth_factor_token: Option<&str>) -> bool {
|
||||
has_totp && auth_factor_token.is_some_and(looks_like_totp_token)
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
@@ -148,6 +150,7 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
== 0
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Legacy2faOutcome {
|
||||
NotRequired,
|
||||
Blocked,
|
||||
@@ -177,6 +180,7 @@ pub async fn process_legacy_2fa(
|
||||
did: &Did,
|
||||
ctx: &Legacy2faContext,
|
||||
auth_factor_token: Option<&str>,
|
||||
verify_totp: impl AsyncFnOnce(&str) -> bool,
|
||||
) -> Result<Legacy2faOutcome, Legacy2faFlowError> {
|
||||
if !ctx.requires_2fa() {
|
||||
return Ok(Legacy2faOutcome::NotRequired);
|
||||
@@ -192,8 +196,16 @@ pub async fn process_legacy_2fa(
|
||||
Ok(Legacy2faOutcome::ChallengeSent(code))
|
||||
}
|
||||
Some(token) => {
|
||||
validate_challenge(cache, did, token).await?;
|
||||
Ok(Legacy2faOutcome::Verified)
|
||||
if ctx.has_totp && looks_like_totp_token(token) {
|
||||
if verify_totp(token).await {
|
||||
Ok(Legacy2faOutcome::Verified)
|
||||
} else {
|
||||
Err(Legacy2faFlowError::Validation(ValidationError::InvalidCode))
|
||||
}
|
||||
} else {
|
||||
validate_challenge(cache, did, token).await?;
|
||||
Ok(Legacy2faOutcome::Verified)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,11 +231,11 @@ async fn create_challenge_code(
|
||||
return Err(ChallengeError::RateLimited);
|
||||
}
|
||||
|
||||
let code = generate_code();
|
||||
let display = generate_token_code();
|
||||
let now = current_timestamp();
|
||||
|
||||
let data = ChallengeData {
|
||||
code: code.clone(),
|
||||
code: normalize_token_code(&display),
|
||||
attempts: 0,
|
||||
created_at: now,
|
||||
};
|
||||
@@ -244,7 +256,7 @@ async fn create_challenge_code(
|
||||
.await
|
||||
.map_err(|_| ChallengeError::CacheError)?;
|
||||
|
||||
Ok(ChallengeCode(code))
|
||||
Ok(ChallengeCode(display))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -332,12 +344,46 @@ mod tests {
|
||||
let did = Did::new("did:plc:test123".to_string()).unwrap();
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
assert_eq!(code.as_str().len(), CODE_LENGTH);
|
||||
assert_eq!(code.as_str().len(), 11);
|
||||
|
||||
let result = validate_challenge(&cache, &did, code.as_str()).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_challenge_code_format() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test123".to_string()).unwrap();
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
let code = code.as_str();
|
||||
assert_eq!(code.len(), 11);
|
||||
assert_eq!(&code[5..6], "-");
|
||||
assert_eq!(code, code.to_uppercase());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_case_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test123".to_string()).unwrap();
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
let lowercase = code.as_str().to_lowercase();
|
||||
let result = validate_challenge(&cache, &did, &lowercase).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hyphen_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test123".to_string()).unwrap();
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
let no_hyphen = code.as_str().replace('-', "");
|
||||
let result = validate_challenge(&cache, &did, &no_hyphen).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_code_rejected() {
|
||||
let cache = MockCache::new();
|
||||
@@ -396,15 +442,6 @@ mod tests {
|
||||
assert_eq!(result.unwrap_err(), ChallengeError::CacheUnavailable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_code_generation_is_numeric() {
|
||||
(0..100).for_each(|_| {
|
||||
let code = generate_code();
|
||||
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
||||
assert_eq!(code.len(), CODE_LENGTH);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_constant_time_eq() {
|
||||
assert!(constant_time_eq(b"12345678", b"12345678"));
|
||||
@@ -425,7 +462,9 @@ mod tests {
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
|
||||
}
|
||||
|
||||
@@ -440,7 +479,9 @@ mod tests {
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
|
||||
}
|
||||
|
||||
@@ -455,7 +496,9 @@ mod tests {
|
||||
allow_legacy_login: false,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Blocked));
|
||||
}
|
||||
|
||||
@@ -470,7 +513,9 @@ mod tests {
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_)));
|
||||
}
|
||||
|
||||
@@ -485,7 +530,9 @@ mod tests {
|
||||
allow_legacy_login: false,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_)));
|
||||
}
|
||||
|
||||
@@ -502,7 +549,7 @@ mod tests {
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some(code.as_str()))
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some(code.as_str()), reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
@@ -532,4 +579,153 @@ mod tests {
|
||||
let result = validate_challenge(&cache, &did, "12345678").await;
|
||||
assert_eq!(result.unwrap_err(), ValidationError::CacheUnavailable);
|
||||
}
|
||||
|
||||
async fn reject_totp(_code: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn accept_totp(_code: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_totp_shaped_token_accepted_via_verifier() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp1".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: false,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some("123456"), accept_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_totp_shaped_token_rejected_does_not_touch_email_challenge() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp2".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: true,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
// An email challenge exists for this user.
|
||||
let email_code = create_challenge(&cache, &did).await.unwrap();
|
||||
|
||||
// Five wrong TOTP-shaped attempts. If these incremented the email attempt
|
||||
// counter, the email challenge would be exhausted (MAX_ATTEMPTS = 5).
|
||||
for _ in 0..5 {
|
||||
let err = process_legacy_2fa(&cache, &did, &ctx, Some("000000"), reject_totp)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
Legacy2faFlowError::Validation(ValidationError::InvalidCode)
|
||||
));
|
||||
}
|
||||
|
||||
// The email challenge is still valid and consumable.
|
||||
let outcome =
|
||||
process_legacy_2fa(&cache, &did, &ctx, Some(email_code.as_str()), reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_email_shaped_token_routes_to_email_path_when_totp_present() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp3".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: true,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let email_code = create_challenge(&cache, &did).await.unwrap();
|
||||
|
||||
// reject_totp would fail if this routed to the verifier; it must route to email.
|
||||
let outcome =
|
||||
process_legacy_2fa(&cache, &did, &ctx, Some(email_code.as_str()), reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_backup_code_shaped_token_routes_to_verifier() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp4".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: false,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
// No email challenge created. If this routed to email it would be
|
||||
// ChallengeNotFound; Verified proves it went to the verifier.
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some("ABCD2345"), accept_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_totp_shaped_token_ignored_when_no_totp() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp5".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: true,
|
||||
has_totp: false,
|
||||
allow_legacy_login: false,
|
||||
};
|
||||
|
||||
// has_totp = false -> 6-digit token routes to email path; no challenge -> NotFound.
|
||||
let err = process_legacy_2fa(&cache, &did, &ctx, Some("123456"), reject_totp)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
Legacy2faFlowError::Validation(ValidationError::ChallengeNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_looks_like_totp_token() {
|
||||
// 6-digit TOTP codes
|
||||
assert!(looks_like_totp_token("123456"));
|
||||
assert!(looks_like_totp_token(" 000000 "));
|
||||
// backup-code format (8 chars, backup alphabet)
|
||||
assert!(looks_like_totp_token("ABCD2345"));
|
||||
// email challenge codes normalize to 10 alphanumeric chars -> not TOTP-shaped
|
||||
assert!(!looks_like_totp_token("ABCDEFGHIJ"));
|
||||
assert!(!looks_like_totp_token("ABCDE-FGHIJ"));
|
||||
// wrong lengths / non-digits
|
||||
assert!(!looks_like_totp_token("12345"));
|
||||
assert!(!looks_like_totp_token("1234567"));
|
||||
assert!(!looks_like_totp_token("12345A"));
|
||||
assert!(!looks_like_totp_token(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_used_totp_factor() {
|
||||
// strong MFA factors completed the login -> true
|
||||
assert!(used_totp_factor(true, Some("123456")));
|
||||
assert!(used_totp_factor(true, Some("ABCD2345")));
|
||||
// email-shaped code, or no token, or no TOTP on the account -> false
|
||||
assert!(!used_totp_factor(true, Some("ABCDEFGHIJ")));
|
||||
assert!(!used_totp_factor(true, None));
|
||||
assert!(!used_totp_factor(false, Some("123456")));
|
||||
assert!(!used_totp_factor(true, Some("")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ pub const BUILD_VERSION: &str = concat!(
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub const BUILD_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub const GENERAL_BODY_LIMIT: usize = 16 * 1024 * 1024;
|
||||
|
||||
pub struct ExternalRoutes {
|
||||
pub xrpc: Router<AppState>,
|
||||
pub oauth: Router<AppState>,
|
||||
@@ -97,9 +99,7 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router {
|
||||
.nest("/.well-known", well_known_router)
|
||||
.route("/metrics", get(metrics::metrics_handler))
|
||||
.merge(external.extra)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
tranquil_config::get().server.max_blob_size as usize,
|
||||
))
|
||||
.layer(DefaultBodyLimit::max(GENERAL_BODY_LIMIT))
|
||||
.layer(axum::middleware::map_response(rewrite_extractor_errors))
|
||||
.layer(middleware::from_fn(metrics::metrics_middleware))
|
||||
.layer(
|
||||
|
||||
@@ -44,10 +44,26 @@ impl AnyBlockStore {
|
||||
expected_root: Cid,
|
||||
) -> Result<RepairOutcome, RepoError> {
|
||||
match self {
|
||||
Self::Postgres(_) => Ok(RepairOutcome {
|
||||
nodes_total: 0,
|
||||
nodes_repaired: 0,
|
||||
}),
|
||||
Self::Postgres(s) => {
|
||||
let nodes =
|
||||
tranquil_store::blockstore::rebuild_mst_nodes(entries, expected_root).await?;
|
||||
let nodes_total = nodes.len();
|
||||
let cids: Vec<Cid> = nodes.iter().map(|(cid, _)| *cid).collect();
|
||||
let present = s.get_many(&cids).await?;
|
||||
let missing: Vec<(Cid, Bytes)> = nodes
|
||||
.into_iter()
|
||||
.zip(present)
|
||||
.filter_map(|((cid, bytes), found)| found.is_none().then_some((cid, bytes)))
|
||||
.collect();
|
||||
let nodes_repaired = missing.len() as u64;
|
||||
if !missing.is_empty() {
|
||||
s.put_many(missing).await?;
|
||||
}
|
||||
Ok(RepairOutcome {
|
||||
nodes_total,
|
||||
nodes_repaired,
|
||||
})
|
||||
}
|
||||
Self::TranquilStore(s) => {
|
||||
tranquil_store::blockstore::rebuild_and_repair_mst(s, entries, expected_root).await
|
||||
}
|
||||
|
||||
@@ -259,12 +259,12 @@ pub async fn repair_repo_structure(
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.ok_or_else(|| ApiError::InternalError(Some("Commit block not found".into())))?;
|
||||
let data_root = Commit::from_cbor(&commit_bytes)
|
||||
.map_err(|e| {
|
||||
error!("repair: failed to parse commit: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.data;
|
||||
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| {
|
||||
error!("repair: failed to parse commit: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let data_root = commit.data;
|
||||
let repo_rev = commit.rev().to_string();
|
||||
|
||||
let records = state
|
||||
.repos
|
||||
@@ -290,14 +290,71 @@ pub async fn repair_repo_structure(
|
||||
"repair: rebuilding full MST from record set"
|
||||
);
|
||||
|
||||
state
|
||||
let outcome = state
|
||||
.block_store
|
||||
.repair_structure(&entries, data_root)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: structural repair failed: {}", e);
|
||||
ApiError::InternalError(Some("Structural repair failed".into()))
|
||||
})
|
||||
})?;
|
||||
|
||||
if outcome.nodes_repaired > 0 {
|
||||
let block_cids =
|
||||
crate::scheduled::collect_current_repo_blocks(&state.block_store, ¤t_root_cid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: re-walk for user_blocks backfill failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let cids = block_cids
|
||||
.iter()
|
||||
.map(|bytes| Cid::try_from(bytes.as_slice()))
|
||||
.collect::<Result<Vec<Cid>, _>>()
|
||||
.map_err(|e| {
|
||||
error!("repair: unparseable CID in repaired DAG walk: {e}");
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let present = state.block_store.get_many(&cids).await.map_err(|e| {
|
||||
error!("repair: presence check during user_blocks backfill failed: {e}");
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let missing: Vec<Cid> = cids
|
||||
.iter()
|
||||
.zip(present)
|
||||
.filter_map(|(cid, found)| found.is_none().then_some(*cid))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
error!(
|
||||
user_id = %user_id,
|
||||
missing = missing.len(),
|
||||
sample = ?missing.iter().take(5).map(|c| c.to_string()).collect::<Vec<_>>(),
|
||||
"repair: unrecoverable leaf data loss after structural repair"
|
||||
);
|
||||
return Err(ApiError::InternalError(Some(format!(
|
||||
"unrecoverable leaf data loss: {} record block(s) missing after structural repair",
|
||||
missing.len()
|
||||
))));
|
||||
}
|
||||
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
.insert_user_blocks(user_id, &block_cids, &repo_rev)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: user_blocks backfill failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
warn!(
|
||||
user_id = %user_id,
|
||||
blocks = block_cids.len(),
|
||||
"repair: backfilled user_blocks from repaired DAG"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub async fn with_repair_retry<T, F, Fut>(
|
||||
|
||||
@@ -730,10 +730,53 @@ async fn delete_account_data(
|
||||
|
||||
const CAR_BLOCK_BATCH_SIZE: usize = 500;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RepoCarError {
|
||||
MissingBlocks(Vec<Cid>),
|
||||
Source(anyhow::Error),
|
||||
}
|
||||
|
||||
impl RepoCarError {
|
||||
pub fn is_repairable(&self) -> bool {
|
||||
match self {
|
||||
Self::MissingBlocks(_) => true,
|
||||
Self::Source(e) => {
|
||||
crate::api::error::ApiError::detail_is_repo_corruption(&format!("{e:#}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RepoCarError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingBlocks(cids) => write!(
|
||||
f,
|
||||
"repo CAR is incomplete: {} block(s) referenced by the MST are missing from storage. First 5: {}",
|
||||
cids.len(),
|
||||
cids.iter()
|
||||
.take(5)
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
Self::Source(e) => write!(f, "{e:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RepoCarError {}
|
||||
|
||||
impl From<anyhow::Error> for RepoCarError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
Self::Source(e)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_repo_car(
|
||||
block_store: &AnyBlockStore,
|
||||
head_cid: &Cid,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
) -> Result<Vec<u8>, RepoCarError> {
|
||||
let block_cids_bytes = collect_current_repo_blocks(block_store, head_cid).await?;
|
||||
let block_cids: Vec<Cid> = block_cids_bytes
|
||||
.iter()
|
||||
@@ -760,16 +803,7 @@ pub async fn generate_repo_car(
|
||||
.filter_map(|(cid, block_opt)| block_opt.is_none().then_some(*cid))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
anyhow::bail!(
|
||||
"repo CAR is incomplete: {} block(s) referenced by the MST are missing from storage. First 5: {}",
|
||||
missing.len(),
|
||||
missing
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
return Err(RepoCarError::MissingBlocks(missing));
|
||||
}
|
||||
|
||||
chunk
|
||||
@@ -803,7 +837,7 @@ pub async fn generate_repo_car_from_user_blocks(
|
||||
block_store: &AnyBlockStore,
|
||||
user_id: uuid::Uuid,
|
||||
_head_cid: &Cid,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
) -> Result<Vec<u8>, RepoCarError> {
|
||||
use std::str::FromStr;
|
||||
|
||||
let repo_root_cid_str: String = repo_repo
|
||||
|
||||
@@ -92,30 +92,20 @@ impl CarVerifier {
|
||||
|
||||
pub fn verify_car_structure_only(
|
||||
&self,
|
||||
expected_did: &Did,
|
||||
root_cid: &Cid,
|
||||
blocks: &HashMap<Cid, Bytes>,
|
||||
) -> Result<VerifiedCar, VerifyError> {
|
||||
) -> Result<StructureVerifiedCar, VerifyError> {
|
||||
let root_block = blocks
|
||||
.get(root_cid)
|
||||
.ok_or_else(|| VerifyError::BlockNotFound(root_cid.to_string()))?;
|
||||
let commit =
|
||||
Commit::from_cbor(root_block).map_err(|e| VerifyError::InvalidCommit(e.to_string()))?;
|
||||
let commit_did = commit.did().as_str();
|
||||
if commit_did != expected_did.as_str() {
|
||||
return Err(VerifyError::DidMismatch {
|
||||
commit_did: commit_did.to_string(),
|
||||
expected_did: expected_did.to_string(),
|
||||
});
|
||||
}
|
||||
let commit_did = commit.did().to_string().into();
|
||||
let data_cid = commit.data();
|
||||
self.verify_mst_structure(data_cid, blocks)?;
|
||||
debug!(
|
||||
"MST structure verified for DID {} (signature verification skipped for migration)",
|
||||
expected_did
|
||||
);
|
||||
Ok(VerifiedCar {
|
||||
did: expected_did.clone(),
|
||||
debug!("MST structure verified for commit: {:?}", commit);
|
||||
Ok(StructureVerifiedCar {
|
||||
did: commit_did,
|
||||
rev: commit.rev().to_string(),
|
||||
data_cid: *data_cid,
|
||||
prev: commit.prev().cloned(),
|
||||
@@ -289,6 +279,14 @@ impl CarVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StructureVerifiedCar {
|
||||
pub did: Did,
|
||||
pub rev: String,
|
||||
pub data_cid: Cid,
|
||||
pub prev: Option<Cid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerifiedCar {
|
||||
pub did: Did,
|
||||
|
||||
@@ -33,7 +33,20 @@ pub fn generate_token_code() -> String {
|
||||
.map(|_| chars[rng.gen_range(0..chars.len())])
|
||||
.collect()
|
||||
};
|
||||
format!("{}-{}", gen_segment(&mut rng), gen_segment(&mut rng))
|
||||
// Human-entered short codes are displayed in uppercase; base32 digits are
|
||||
// unaffected by the conversion.
|
||||
format!("{}-{}", gen_segment(&mut rng), gen_segment(&mut rng)).to_uppercase()
|
||||
}
|
||||
|
||||
/// Normalize a user-entered short code so that codes are accepted
|
||||
/// case-insensitively and regardless of the separating hyphen or surrounding
|
||||
/// whitespace.
|
||||
pub fn normalize_token_code(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '-')
|
||||
.collect::<String>()
|
||||
.to_uppercase()
|
||||
}
|
||||
|
||||
pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> {
|
||||
@@ -491,10 +504,44 @@ mod tests {
|
||||
assert!(
|
||||
code.chars()
|
||||
.filter(|&c| c != '-')
|
||||
.all(|c| BASE32_ALPHABET.contains(c))
|
||||
.all(|c| BASE32_ALPHABET.to_uppercase().contains(c))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_token_code_is_uppercase() {
|
||||
(0..100).for_each(|_| {
|
||||
let code = generate_token_code();
|
||||
assert_eq!(code, code.to_uppercase(), "code must be uppercase: {code}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_token_code_strips_hyphen_and_uppercases() {
|
||||
assert_eq!(normalize_token_code("k7m2p-q9rst"), "K7M2PQ9RST");
|
||||
assert_eq!(normalize_token_code("K7M2P-Q9RST"), "K7M2PQ9RST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_token_code_strips_whitespace() {
|
||||
assert_eq!(normalize_token_code(" k7m2p-q9rst \n"), "K7M2PQ9RST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_token_code_is_idempotent() {
|
||||
let once = normalize_token_code("k7m2p-q9rst");
|
||||
assert_eq!(normalize_token_code(&once), once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generated_code_round_trips_through_normalize() {
|
||||
let code = generate_token_code();
|
||||
// A user re-typing the displayed code lowercased and without the hyphen
|
||||
// must normalize to the same canonical form as the code itself.
|
||||
let retyped = code.to_lowercase().replace('-', "");
|
||||
assert_eq!(normalize_token_code(&code), normalize_token_code(&retyped));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_to_ipld_cid_link() {
|
||||
let json = serde_json::json!({
|
||||
|
||||
@@ -3,9 +3,8 @@ use std::sync::Arc;
|
||||
use cid::Cid;
|
||||
use jacquard_repo::mst::Mst;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::repo::AnyBlockStore;
|
||||
use tranquil_pds::scheduled::generate_repo_car;
|
||||
use tranquil_pds::scheduled::{RepoCarError, generate_repo_car};
|
||||
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
|
||||
|
||||
const RECORD_COUNT: usize = 200;
|
||||
@@ -68,9 +67,32 @@ async fn car_export_error_is_classified_as_repo_corruption() {
|
||||
let err = generate_repo_car(&any, &root)
|
||||
.await
|
||||
.expect_err("corrupt CAR export must error");
|
||||
let chain = format!("{err:#}");
|
||||
assert!(
|
||||
ApiError::detail_is_repo_corruption(&chain),
|
||||
"CAR export error must carry the corruption marker so the sync path can schedule self-heal; got: {chain}"
|
||||
err.is_repairable(),
|
||||
"{err} should classify as repairable so the sync path can self-heal"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn car_export_missing_block_is_repairable() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let source = open_store(dir.path());
|
||||
let root = build_tree(&source).await;
|
||||
|
||||
let pristine = open_store(&dir.path().join("pristine"));
|
||||
let head_block = source
|
||||
.get(&root)
|
||||
.await
|
||||
.expect("read root")
|
||||
.expect("root present");
|
||||
pristine.put(&head_block).await.expect("seed root only");
|
||||
|
||||
let err = generate_repo_car(&pristine, &root)
|
||||
.await
|
||||
.expect_err("CAR export over a store missing MST children must error");
|
||||
assert!(
|
||||
matches!(err, RepoCarError::MissingBlocks(ref cids) if !cids.is_empty()),
|
||||
"{err} should surface as MissingBlocks when referenced blocks are absent"
|
||||
);
|
||||
assert!(err.is_repairable());
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ static TEST_TEMP_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
static CLUSTER: OnceLock<Vec<ServerInstance>> = OnceLock::new();
|
||||
static TEST_REPOS: OnceLock<Arc<tranquil_db::PostgresRepositories>> = OnceLock::new();
|
||||
static TEST_BLOCK_STORE: OnceLock<tranquil_pds::repo::AnyBlockStore> = OnceLock::new();
|
||||
static TEST_APP_STATE: OnceLock<AppState> = OnceLock::new();
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_store_backend() -> bool {
|
||||
@@ -586,6 +587,7 @@ async fn spawn_server(config: ServerConfig) -> ServerInstance {
|
||||
if let Some((cache, distributed_rate_limiter)) = config.cache {
|
||||
state = state.with_cache(cache, distributed_rate_limiter);
|
||||
}
|
||||
TEST_APP_STATE.set(state.clone()).ok();
|
||||
tranquil_sync::listener::start_sequencer_listener(state.clone()).await;
|
||||
let app = tranquil_pds::app_with_routes(
|
||||
state,
|
||||
@@ -928,6 +930,14 @@ pub async fn get_test_block_store() -> &'static tranquil_pds::repo::AnyBlockStor
|
||||
.expect("TEST_BLOCK_STORE not initialized")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_test_app_state() -> &'static AppState {
|
||||
base_url().await;
|
||||
TEST_APP_STATE
|
||||
.get()
|
||||
.expect("TEST_APP_STATE not initialized")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn flushed_max_seq(
|
||||
repos: &tranquil_db::PostgresRepositories,
|
||||
|
||||
@@ -73,7 +73,7 @@ fn write_varint(buf: &mut Vec<u8>, mut value: u64) {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_import_rejects_car_for_different_user() {
|
||||
async fn test_import_doesnt_reject_car_for_different_user() {
|
||||
let client = client();
|
||||
let (token_a, _did_a) = create_account_and_login(&client).await;
|
||||
let (_token_b, did_b) = create_account_and_login(&client).await;
|
||||
@@ -99,15 +99,9 @@ async fn test_import_rejects_car_for_different_user() {
|
||||
.send()
|
||||
.await
|
||||
.expect("Import failed");
|
||||
assert_eq!(import_res.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(import_res.status(), StatusCode::OK);
|
||||
let body: serde_json::Value = import_res.json().await.unwrap();
|
||||
assert!(
|
||||
body["error"] == "InvalidRepo"
|
||||
|| body["error"] == "InvalidRequest"
|
||||
|| body["error"] == "DidMismatch",
|
||||
"Expected InvalidRepo, DidMismatch, or InvalidRequest error, got: {:?}",
|
||||
body
|
||||
);
|
||||
assert!(body.is_object() && body.as_object().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -40,19 +40,17 @@ async fn get_2fa_code_from_queue(did: &str) -> Option<String> {
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
comms.first().and_then(|c| {
|
||||
c.body
|
||||
.lines()
|
||||
.find(|line: &&str| line.chars().all(|c: char| c.is_ascii_digit()) && line.len() == 8)
|
||||
.map(|s: &str| s.to_string())
|
||||
.or_else(|| {
|
||||
c.body
|
||||
.split_whitespace()
|
||||
.find(|word: &&str| {
|
||||
word.chars().all(|c: char| c.is_ascii_digit()) && word.len() == 8
|
||||
})
|
||||
.map(|s: &str| s.to_string())
|
||||
})
|
||||
c.body.split_whitespace().find_map(|word: &str| {
|
||||
let candidate = word.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-');
|
||||
let normalized = candidate.replace('-', "");
|
||||
if normalized.len() == 10 && normalized.chars().all(|ch| ALPHABET.contains(ch)) {
|
||||
Some(candidate.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -45,9 +45,12 @@ async fn test_request_password_reset_creates_code() {
|
||||
.expect("user not found");
|
||||
assert!(info.code.is_some());
|
||||
assert!(info.expires_at.is_some());
|
||||
// The stored code is normalized: uppercase base32, 10 chars, no hyphen.
|
||||
// The hyphenated display form only appears in the email.
|
||||
let code = info.code.unwrap();
|
||||
assert!(code.contains('-'));
|
||||
assert_eq!(code.len(), 11);
|
||||
assert!(!code.contains('-'));
|
||||
assert_eq!(code.len(), 10);
|
||||
assert_eq!(code, code.to_uppercase());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -109,7 +112,10 @@ async fn test_reset_password_with_valid_token() {
|
||||
.await
|
||||
.expect("failed to look up user")
|
||||
.expect("user not found");
|
||||
let token = info.code.expect("No reset code");
|
||||
let stored = info.code.expect("No reset code");
|
||||
// Submit a variant a user might actually type: lowercased, with the display
|
||||
// hyphen re-inserted. Normalization must still accept it.
|
||||
let token = format!("{}-{}", &stored[0..5], &stored[5..10]).to_lowercase();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.resetPassword",
|
||||
|
||||
@@ -188,12 +188,22 @@ async fn test_plc_token_lifecycle() {
|
||||
"PLC token should be created in database"
|
||||
);
|
||||
let first = &tokens[0];
|
||||
// The token is persisted in canonical (normalized) form: uppercase base32,
|
||||
// 10 chars, no hyphen. The hyphenated display form only appears in the email.
|
||||
assert_eq!(
|
||||
first.token.len(),
|
||||
11,
|
||||
"Token should be in format xxxxx-xxxxx"
|
||||
10,
|
||||
"Stored token should be the 10-char canonical form"
|
||||
);
|
||||
assert!(
|
||||
!first.token.contains('-'),
|
||||
"Stored token should not contain a hyphen"
|
||||
);
|
||||
assert_eq!(
|
||||
first.token,
|
||||
first.token.to_uppercase(),
|
||||
"Stored token should be uppercase"
|
||||
);
|
||||
assert!(first.token.contains('-'), "Token should contain hyphen");
|
||||
assert!(
|
||||
first.expires_at > chrono::Utc::now(),
|
||||
"Token should not be expired"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
mod common;
|
||||
mod helpers;
|
||||
|
||||
use cid::Cid;
|
||||
use common::*;
|
||||
use helpers::*;
|
||||
use jacquard_repo::commit::Commit;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use tranquil_types::Did;
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_fails_loud_on_missing_leaf_block() {
|
||||
let client = client();
|
||||
let repos = get_test_repos().await;
|
||||
let block_store = get_test_block_store().await;
|
||||
let state = get_test_app_state().await;
|
||||
|
||||
let Some(pg) = block_store.as_postgres() else {
|
||||
eprintln!(
|
||||
"repair_fails_loud_on_missing_leaf_block: requires postgres backend, skipping under store backend"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let pool = pg.pool();
|
||||
|
||||
let (did, jwt) = setup_new_user("repair-leaf-loss").await;
|
||||
let writes: Vec<serde_json::Value> = (0..6)
|
||||
.map(|i| {
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": format!("leafloss{i:05}"),
|
||||
"value": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": format!("repair leaf loss {i}"),
|
||||
"createdAt": "2026-01-01T00:00:00.000Z"
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.applyWrites",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({ "repo": did, "validate": false, "writes": writes }))
|
||||
.send()
|
||||
.await
|
||||
.expect("applyWrites send");
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
reqwest::StatusCode::OK,
|
||||
"applyWrites failed: {:?}",
|
||||
res.text().await
|
||||
);
|
||||
|
||||
let user_id = repos
|
||||
.user
|
||||
.get_id_by_did(&Did::new(did.clone()).unwrap())
|
||||
.await
|
||||
.expect("DB error")
|
||||
.expect("user not found");
|
||||
|
||||
let root_str = repos
|
||||
.repo
|
||||
.get_repo_root_cid_by_user_id(user_id)
|
||||
.await
|
||||
.expect("DB error")
|
||||
.expect("repo root not found");
|
||||
let commit_cid = Cid::from_str(&root_str).expect("parse commit cid");
|
||||
let commit_bytes = block_store
|
||||
.get(&commit_cid)
|
||||
.await
|
||||
.expect("read commit")
|
||||
.expect("commit present");
|
||||
let mst_root_cid = Commit::from_cbor(&commit_bytes).expect("parse commit").data;
|
||||
|
||||
let records = repos
|
||||
.repo
|
||||
.get_all_records(user_id)
|
||||
.await
|
||||
.expect("get_all_records");
|
||||
assert!(!records.is_empty(), "repo must contain records");
|
||||
let leaf_cid = Cid::from_str(records[0].record_cid.as_str()).expect("parse leaf cid");
|
||||
assert!(
|
||||
block_store
|
||||
.get(&leaf_cid)
|
||||
.await
|
||||
.expect("read leaf")
|
||||
.is_some(),
|
||||
"leaf must be present before corruption"
|
||||
);
|
||||
|
||||
repos
|
||||
.repo
|
||||
.delete_user_blocks(user_id, &[leaf_cid.to_bytes()])
|
||||
.await
|
||||
.expect("clear leaf user_blocks row");
|
||||
|
||||
sqlx::query("DELETE FROM blocks WHERE cid = $1")
|
||||
.bind(mst_root_cid.to_bytes())
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("delete mst root node block");
|
||||
sqlx::query("DELETE FROM blocks WHERE cid = $1")
|
||||
.bind(leaf_cid.to_bytes())
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("delete leaf record block");
|
||||
|
||||
assert!(
|
||||
block_store
|
||||
.get(&mst_root_cid)
|
||||
.await
|
||||
.expect("read")
|
||||
.is_none(),
|
||||
"mst root node must be gone to force a structural repair"
|
||||
);
|
||||
assert!(
|
||||
block_store.get(&leaf_cid).await.expect("read").is_none(),
|
||||
"leaf block must be gone to simulate data loss"
|
||||
);
|
||||
|
||||
let err = tranquil_pds::repo_ops::repair_repo_structure(state, user_id)
|
||||
.await
|
||||
.expect_err("repair must fail loud when a leaf block is unrecoverable");
|
||||
let detail = format!("{err:?}");
|
||||
assert!(
|
||||
detail.contains("leaf data loss"),
|
||||
"expected an unrecoverable-leaf-loss error, got: {detail}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
block_store
|
||||
.get(&mst_root_cid)
|
||||
.await
|
||||
.expect("read")
|
||||
.is_some(),
|
||||
"structural repair must still re-insert the regenerable MST node"
|
||||
);
|
||||
|
||||
let recorded = repos
|
||||
.repo
|
||||
.get_user_block_cids_since_rev(user_id, "")
|
||||
.await
|
||||
.expect("read user_blocks");
|
||||
assert!(
|
||||
!recorded.contains(&leaf_cid.to_bytes()),
|
||||
"missing leaf must not be phantom-inserted into user_blocks"
|
||||
);
|
||||
}
|
||||
@@ -14,13 +14,18 @@ tranquil-signal = { workspace = true }
|
||||
|
||||
arc-swap = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
h3 = { workspace = true }
|
||||
h3-quinn = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
http = { workspace = true }
|
||||
hyper = { workspace = true }
|
||||
hyper-util = { workspace = true }
|
||||
quinn = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
rustls-pemfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -31,6 +36,9 @@ tower = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rcgen = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["frontend", "s3", "valkey"]
|
||||
frontend = ["tranquil-pds/frontend"]
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use bytes::{Buf, Bytes};
|
||||
use futures_util::StreamExt;
|
||||
use http::header::ALT_SVC;
|
||||
use http::{HeaderValue, Request, Response, StatusCode};
|
||||
use quinn::crypto::rustls::QuicServerConfig;
|
||||
use quinn::{Endpoint, Incoming, ServerConfig, TransportConfig, VarInt};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
use tower::ServiceExt;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::tls::{ReloadableCertResolver, TlsError};
|
||||
|
||||
const MAX_CONCURRENT_BIDI_STREAMS: u32 = 256;
|
||||
const MAX_CONCURRENT_CONNECTIONS: usize = 512;
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
|
||||
const ALT_SVC_MAX_AGE_SECS: u32 = 86_400;
|
||||
|
||||
pub fn build_quic_server_config(
|
||||
resolver: Arc<ReloadableCertResolver>,
|
||||
) -> Result<ServerConfig, TlsError> {
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let mut crypto = rustls::ServerConfig::builder_with_provider(provider)
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.map_err(|e| TlsError::Config(e.to_string()))?
|
||||
.with_no_client_auth()
|
||||
.with_cert_resolver(resolver);
|
||||
crypto.alpn_protocols = vec![b"h3".to_vec()];
|
||||
|
||||
let quic_crypto =
|
||||
QuicServerConfig::try_from(crypto).map_err(|e| TlsError::Config(e.to_string()))?;
|
||||
let mut config = ServerConfig::with_crypto(Arc::new(quic_crypto));
|
||||
|
||||
let mut transport = TransportConfig::default();
|
||||
transport.max_concurrent_bidi_streams(VarInt::from_u32(MAX_CONCURRENT_BIDI_STREAMS));
|
||||
transport.max_idle_timeout(Some(
|
||||
IDLE_TIMEOUT
|
||||
.try_into()
|
||||
.expect("idle timeout fits in varint"),
|
||||
));
|
||||
config.transport_config(Arc::new(transport));
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn alt_svc_header(port: u16) -> HeaderValue {
|
||||
HeaderValue::from_str(&format!("h3=\":{port}\"; ma={ALT_SVC_MAX_AGE_SECS}"))
|
||||
.expect("alt-svc header value is valid ascii")
|
||||
}
|
||||
|
||||
pub fn with_alt_svc(app: Router, port: u16) -> Router {
|
||||
let value = alt_svc_header(port);
|
||||
app.layer(axum::middleware::map_response(
|
||||
move |mut response: Response<Body>| {
|
||||
let value = value.clone();
|
||||
async move {
|
||||
if response.status() != StatusCode::SWITCHING_PROTOCOLS {
|
||||
response.headers_mut().insert(ALT_SVC, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn with_host_from_authority(app: Router) -> Router {
|
||||
app.layer(axum::middleware::map_request(
|
||||
|mut request: Request<Body>| async move {
|
||||
let authority = request
|
||||
.uri()
|
||||
.authority()
|
||||
.map(|a| HeaderValue::from_str(a.as_str()));
|
||||
match (request.headers().contains_key(http::header::HOST), authority) {
|
||||
(false, Some(Ok(value))) => {
|
||||
request.headers_mut().insert(http::header::HOST, value);
|
||||
request
|
||||
}
|
||||
_ => request,
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn serve_http3(endpoint: Endpoint, app: Router, shutdown: CancellationToken) {
|
||||
let tracker = TaskTracker::new();
|
||||
let conn_limiter = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNECTIONS));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.cancelled() => break,
|
||||
incoming = endpoint.accept() => {
|
||||
let Some(incoming) = incoming else { break };
|
||||
let Ok(permit) = conn_limiter.clone().try_acquire_owned() else {
|
||||
debug!(
|
||||
peer = %incoming.remote_address(),
|
||||
max = MAX_CONCURRENT_CONNECTIONS,
|
||||
"refusing h3 connection: limit reached"
|
||||
);
|
||||
incoming.refuse();
|
||||
continue;
|
||||
};
|
||||
let app = app.clone();
|
||||
let conn_shutdown = shutdown.clone();
|
||||
let conn_tracker = tracker.clone();
|
||||
tracker.spawn(async move {
|
||||
let _permit = permit;
|
||||
if let Err(e) = serve_connection(incoming, app, conn_shutdown, conn_tracker).await {
|
||||
debug!("h3 connection ended: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
tracker.close();
|
||||
if tokio::time::timeout(SHUTDOWN_GRACE, tracker.wait())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
debug!("h3 connections did not drain within grace, closing");
|
||||
}
|
||||
endpoint.close(0u32.into(), b"shutdown");
|
||||
endpoint.wait_idle().await;
|
||||
}
|
||||
|
||||
async fn serve_connection(
|
||||
incoming: Incoming,
|
||||
app: Router,
|
||||
shutdown: CancellationToken,
|
||||
tracker: TaskTracker,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let conn = incoming.await?;
|
||||
let remote = conn.remote_address();
|
||||
let mut h3_conn =
|
||||
h3::server::Connection::<_, Bytes>::new(h3_quinn::Connection::new(conn)).await?;
|
||||
|
||||
let mut draining = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.cancelled(), if !draining => {
|
||||
draining = true;
|
||||
let _ = h3_conn.shutdown(0).await;
|
||||
}
|
||||
resolved = h3_conn.accept() => match resolved {
|
||||
Ok(Some(resolver)) => {
|
||||
let app = app.clone();
|
||||
tracker.spawn(async move {
|
||||
if let Err(e) = serve_request(resolver, app, remote).await {
|
||||
debug!(peer = %remote, "h3 request failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
debug!(peer = %remote, "h3 accept error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve_request(
|
||||
resolver: h3::server::RequestResolver<h3_quinn::Connection, Bytes>,
|
||||
app: Router,
|
||||
remote: SocketAddr,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (req, stream) = resolver.resolve_request().await?;
|
||||
let (mut send, recv) = stream.split();
|
||||
|
||||
let (mut parts, ()) = req.into_parts();
|
||||
parts.extensions.insert(ConnectInfo(remote));
|
||||
let request = Request::from_parts(parts, request_body(recv));
|
||||
|
||||
let response = match app.oneshot(request).await {
|
||||
Ok(response) => response,
|
||||
Err(infallible) => match infallible {},
|
||||
};
|
||||
|
||||
let (parts, body) = response.into_parts();
|
||||
send.send_response(Response::from_parts(parts, ())).await?;
|
||||
|
||||
let mut data = body.into_data_stream();
|
||||
while let Some(chunk) = data.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) if bytes.has_remaining() => send.send_data(bytes).await?,
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
debug!(peer = %remote, "h3 response body error: {e}");
|
||||
send.stop_stream(h3::error::Code::H3_INTERNAL_ERROR);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
send.finish().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct RecvGuard {
|
||||
stream: h3::server::RequestStream<h3_quinn::RecvStream, Bytes>,
|
||||
ended: bool,
|
||||
}
|
||||
|
||||
impl Drop for RecvGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.ended {
|
||||
self.stream.stop_sending(h3::error::Code::H3_NO_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_body(recv: h3::server::RequestStream<h3_quinn::RecvStream, Bytes>) -> Body {
|
||||
let guard = RecvGuard {
|
||||
stream: recv,
|
||||
ended: false,
|
||||
};
|
||||
let stream = futures_util::stream::unfold(Some(guard), |state| async move {
|
||||
let mut guard = state?;
|
||||
match guard.stream.recv_data().await {
|
||||
Ok(Some(mut buf)) => {
|
||||
let bytes = buf.copy_to_bytes(buf.remaining());
|
||||
Some((Ok::<Bytes, std::io::Error>(bytes), Some(guard)))
|
||||
}
|
||||
Ok(None) => {
|
||||
guard.ended = true;
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
guard.ended = true;
|
||||
Some((Err(std::io::Error::other(e.to_string())), None))
|
||||
}
|
||||
}
|
||||
});
|
||||
Body::from_stream(stream)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::routing::get;
|
||||
use rustls::pki_types::{
|
||||
CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime,
|
||||
};
|
||||
|
||||
fn self_signed_resolver() -> Arc<ReloadableCertResolver> {
|
||||
let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
|
||||
let cert_der = cert.cert.der().clone();
|
||||
let key_der =
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()));
|
||||
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der).unwrap();
|
||||
let certified = rustls::sign::CertifiedKey::new(vec![cert_der], signing_key);
|
||||
Arc::new(ReloadableCertResolver::new(certified))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SkipServerVerification(Arc<rustls::crypto::CryptoProvider>);
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for SkipServerVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.0.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.0.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
self.0.signature_verification_algorithms.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
fn client_endpoint() -> Endpoint {
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let mut crypto = rustls::ClientConfig::builder_with_provider(provider.clone())
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.unwrap()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(SkipServerVerification(provider)))
|
||||
.with_no_client_auth();
|
||||
crypto.alpn_protocols = vec![b"h3".to_vec()];
|
||||
let quic = quinn::crypto::rustls::QuicClientConfig::try_from(crypto).unwrap();
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap()).unwrap();
|
||||
endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic)));
|
||||
endpoint
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn h3_get_roundtrips_through_router() {
|
||||
let app = Router::new().route("/", get(|| async { "ok" }));
|
||||
let server_config = build_quic_server_config(self_signed_resolver()).unwrap();
|
||||
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
tokio::spawn(serve_http3(server, app, shutdown.clone()));
|
||||
|
||||
let client = client_endpoint();
|
||||
let conn = client.connect(addr, "localhost").unwrap().await.unwrap();
|
||||
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(conn))
|
||||
.await
|
||||
.unwrap();
|
||||
let drive =
|
||||
tokio::spawn(async move { std::future::poll_fn(|cx| driver.poll_close(cx)).await });
|
||||
|
||||
let req = Request::get("https://localhost/").body(()).unwrap();
|
||||
let mut stream = send_request.send_request(req).await.unwrap();
|
||||
stream.finish().await.unwrap();
|
||||
|
||||
let response = stream.recv_response().await.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
|
||||
let mut body = Vec::new();
|
||||
while let Some(mut chunk) = stream.recv_data().await.unwrap() {
|
||||
let bytes = chunk.copy_to_bytes(chunk.remaining());
|
||||
body.extend_from_slice(&bytes);
|
||||
}
|
||||
assert_eq!(body.as_slice(), b"ok");
|
||||
|
||||
shutdown.cancel();
|
||||
drive.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_svc_header_advertises_h3() {
|
||||
assert_eq!(
|
||||
alt_svc_header(443).to_str().unwrap(),
|
||||
"h3=\":443\"; ma=86400"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alt_svc_added_to_responses_except_switching_protocols() {
|
||||
let app = with_alt_svc(
|
||||
Router::new().route("/ok", get(|| async { "ok" })).route(
|
||||
"/upgrade",
|
||||
get(|| async {
|
||||
Response::builder()
|
||||
.status(StatusCode::SWITCHING_PROTOCOLS)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}),
|
||||
),
|
||||
443,
|
||||
);
|
||||
|
||||
let normal = app
|
||||
.clone()
|
||||
.oneshot(Request::get("/ok").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
normal.headers().get(ALT_SVC).and_then(|v| v.to_str().ok()),
|
||||
Some("h3=\":443\"; ma=86400")
|
||||
);
|
||||
|
||||
let upgrade = app
|
||||
.oneshot(Request::get("/upgrade").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
upgrade.headers().get(ALT_SVC).is_none(),
|
||||
"101 responses must not carry Alt-Svc"
|
||||
);
|
||||
}
|
||||
|
||||
fn make_cert(dns: &str) -> (rustls::sign::CertifiedKey, CertificateDer<'static>) {
|
||||
let cert = rcgen::generate_simple_self_signed(vec![dns.to_string()]).unwrap();
|
||||
let cert_der = cert.cert.der().clone();
|
||||
let key_der =
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()));
|
||||
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der).unwrap();
|
||||
let certified = rustls::sign::CertifiedKey::new(vec![cert_der.clone()], signing_key);
|
||||
(certified, cert_der)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordingVerifier {
|
||||
provider: Arc<rustls::crypto::CryptoProvider>,
|
||||
seen: Arc<std::sync::Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for RecordingVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
*self.seen.lock().unwrap() = end_entity.as_ref().to_vec();
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
self.provider
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
async fn observe_server_cert(addr: SocketAddr) -> Vec<u8> {
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let verifier = Arc::new(RecordingVerifier {
|
||||
provider: provider.clone(),
|
||||
seen: seen.clone(),
|
||||
});
|
||||
let mut crypto = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.unwrap()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(verifier)
|
||||
.with_no_client_auth();
|
||||
crypto.alpn_protocols = vec![b"h3".to_vec()];
|
||||
let quic = quinn::crypto::rustls::QuicClientConfig::try_from(crypto).unwrap();
|
||||
let mut endpoint = Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic)));
|
||||
let conn = endpoint.connect(addr, "localhost").unwrap().await.unwrap();
|
||||
conn.close(0u32.into(), b"done");
|
||||
endpoint.wait_idle().await;
|
||||
seen.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quic_handshake_observes_reloaded_certificate() {
|
||||
let (cert_a, der_a) = make_cert("localhost");
|
||||
let (cert_b, der_b) = make_cert("localhost");
|
||||
assert_ne!(der_a, der_b, "test must use two distinct certs");
|
||||
|
||||
let resolver = Arc::new(ReloadableCertResolver::new(cert_a));
|
||||
let server_config = build_quic_server_config(resolver.clone()).unwrap();
|
||||
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
let app = Router::new().route("/", get(|| async { "ok" }));
|
||||
tokio::spawn(serve_http3(server, app, shutdown.clone()));
|
||||
|
||||
let before = observe_server_cert(addr).await;
|
||||
assert_eq!(
|
||||
before.as_slice(),
|
||||
der_a.as_ref(),
|
||||
"first handshake must present the original cert"
|
||||
);
|
||||
|
||||
resolver.store(cert_b);
|
||||
|
||||
let after = observe_server_cert(addr).await;
|
||||
assert_eq!(
|
||||
after.as_slice(),
|
||||
der_b.as_ref(),
|
||||
"handshake after reload must present the new cert"
|
||||
);
|
||||
assert_ne!(before, after, "reload must change the presented cert");
|
||||
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn h3_requests_carry_remote_addr_connect_info() {
|
||||
let app = Router::new().route(
|
||||
"/",
|
||||
get(|ConnectInfo(addr): ConnectInfo<SocketAddr>| async move { addr.to_string() }),
|
||||
);
|
||||
let server_config = build_quic_server_config(self_signed_resolver()).unwrap();
|
||||
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
tokio::spawn(serve_http3(server, app, shutdown.clone()));
|
||||
|
||||
let client = client_endpoint();
|
||||
let client_port = client.local_addr().unwrap().port();
|
||||
let conn = client.connect(addr, "localhost").unwrap().await.unwrap();
|
||||
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(conn))
|
||||
.await
|
||||
.unwrap();
|
||||
let drive =
|
||||
tokio::spawn(async move { std::future::poll_fn(|cx| driver.poll_close(cx)).await });
|
||||
|
||||
let req = Request::get("https://localhost/").body(()).unwrap();
|
||||
let mut stream = send_request.send_request(req).await.unwrap();
|
||||
stream.finish().await.unwrap();
|
||||
assert_eq!(stream.recv_response().await.unwrap().status(), 200);
|
||||
|
||||
let mut body = Vec::new();
|
||||
while let Some(mut chunk) = stream.recv_data().await.unwrap() {
|
||||
let bytes = chunk.copy_to_bytes(chunk.remaining());
|
||||
body.extend_from_slice(&bytes);
|
||||
}
|
||||
let reported: SocketAddr = String::from_utf8(body).unwrap().parse().unwrap();
|
||||
assert!(reported.ip().is_loopback());
|
||||
assert_eq!(
|
||||
reported.port(),
|
||||
client_port,
|
||||
"handlers must see the QUIC remote address via ConnectInfo"
|
||||
);
|
||||
|
||||
shutdown.cancel();
|
||||
drive.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_header_filled_from_authority() {
|
||||
let app = with_host_from_authority(Router::new().route(
|
||||
"/",
|
||||
get(|headers: http::HeaderMap| async move {
|
||||
headers
|
||||
.get(http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
));
|
||||
let server_config = build_quic_server_config(self_signed_resolver()).unwrap();
|
||||
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
tokio::spawn(serve_http3(server, app, shutdown.clone()));
|
||||
|
||||
let client = client_endpoint();
|
||||
let conn = client.connect(addr, "localhost").unwrap().await.unwrap();
|
||||
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(conn))
|
||||
.await
|
||||
.unwrap();
|
||||
let drive =
|
||||
tokio::spawn(async move { std::future::poll_fn(|cx| driver.poll_close(cx)).await });
|
||||
|
||||
let req = Request::get("https://localhost/").body(()).unwrap();
|
||||
let mut stream = send_request.send_request(req).await.unwrap();
|
||||
stream.finish().await.unwrap();
|
||||
assert_eq!(stream.recv_response().await.unwrap().status(), 200);
|
||||
|
||||
let mut body = Vec::new();
|
||||
while let Some(mut chunk) = stream.recv_data().await.unwrap() {
|
||||
let bytes = chunk.copy_to_bytes(chunk.remaining());
|
||||
body.extend_from_slice(&bytes);
|
||||
}
|
||||
assert_eq!(
|
||||
String::from_utf8(body).unwrap(),
|
||||
"localhost",
|
||||
"handlers must see the authority as the Host header"
|
||||
);
|
||||
|
||||
shutdown.cancel();
|
||||
drive.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn h3_body_error_resets_stream_instead_of_truncating() {
|
||||
let app = Router::new().route(
|
||||
"/",
|
||||
get(|| async {
|
||||
Body::from_stream(futures_util::stream::iter(vec![
|
||||
Ok::<Bytes, std::io::Error>(Bytes::from_static(b"partial")),
|
||||
Err(std::io::Error::other("body source failed")),
|
||||
]))
|
||||
}),
|
||||
);
|
||||
let server_config = build_quic_server_config(self_signed_resolver()).unwrap();
|
||||
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
tokio::spawn(serve_http3(server, app, shutdown.clone()));
|
||||
|
||||
let client = client_endpoint();
|
||||
let conn = client.connect(addr, "localhost").unwrap().await.unwrap();
|
||||
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(conn))
|
||||
.await
|
||||
.unwrap();
|
||||
let drive =
|
||||
tokio::spawn(async move { std::future::poll_fn(|cx| driver.poll_close(cx)).await });
|
||||
|
||||
let req = Request::get("https://localhost/").body(()).unwrap();
|
||||
let mut stream = send_request.send_request(req).await.unwrap();
|
||||
stream.finish().await.unwrap();
|
||||
|
||||
let outcome = async {
|
||||
stream.recv_response().await?;
|
||||
let mut body = Vec::new();
|
||||
loop {
|
||||
match stream.recv_data().await {
|
||||
Ok(Some(mut chunk)) => {
|
||||
let bytes = chunk.copy_to_bytes(chunk.remaining());
|
||||
body.extend_from_slice(&bytes);
|
||||
}
|
||||
Ok(None) => return Ok(body),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
.await;
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"a mid-body error must reset the stream, not end the body cleanly after {} bytes",
|
||||
outcome.map(|b| b.len()).unwrap_or(0)
|
||||
);
|
||||
|
||||
shutdown.cancel();
|
||||
drive.abort();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use tranquil_pds::scheduled::{
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
mod http3;
|
||||
mod tls;
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -259,7 +260,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
shutdown.clone(),
|
||||
));
|
||||
|
||||
let app = tranquil_pds::app_with_routes(
|
||||
let app = http3::with_host_from_authority(tranquil_pds::app_with_routes(
|
||||
state,
|
||||
tranquil_pds::ExternalRoutes {
|
||||
xrpc: tranquil_api::api_routes().merge(tranquil_sync::sync_routes()),
|
||||
@@ -270,7 +271,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.merge(tranquil_api::webhook_routes())
|
||||
.merge(tranquil_oauth_server::frontend_client_metadata_route()),
|
||||
},
|
||||
);
|
||||
));
|
||||
|
||||
let cfg = tranquil_config::get();
|
||||
let host = &cfg.server.host;
|
||||
@@ -286,6 +287,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
|
||||
|
||||
let mut http3_handle: Option<tokio::task::JoinHandle<()>> = None;
|
||||
|
||||
let server_handle = match cfg.server.tls.material() {
|
||||
Some((cert_path, key_path)) => {
|
||||
let initial = tls::load_certified_key(cert_path, key_path)
|
||||
@@ -296,14 +299,35 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map_err(|e| format!("Failed to build TLS configuration: {e}"))?,
|
||||
);
|
||||
tls::spawn_reload_handler(
|
||||
resolver,
|
||||
resolver.clone(),
|
||||
cert_path.to_string(),
|
||||
key_path.to_string(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
|
||||
let tcp_app = if cfg.server.tls.http3 {
|
||||
let quic_config = http3::build_quic_server_config(resolver)
|
||||
.map_err(|e| format!("Failed to build HTTP/3 configuration: {e}"))?;
|
||||
let endpoint = quinn::Endpoint::server(quic_config, addr)
|
||||
.map_err(|e| format!("Failed to bind HTTP/3 endpoint on {addr}: {e}"))?;
|
||||
let h3_port = endpoint
|
||||
.local_addr()
|
||||
.map(|a| a.port())
|
||||
.map_err(|e| format!("Failed to read HTTP/3 local address: {e}"))?;
|
||||
info!("HTTP/3 enabled on udp/{h3_port}");
|
||||
http3_handle = Some(tokio::spawn(http3::serve_http3(
|
||||
endpoint,
|
||||
app.clone(),
|
||||
shutdown.clone(),
|
||||
)));
|
||||
http3::with_alt_svc(app, h3_port)
|
||||
} else {
|
||||
app
|
||||
};
|
||||
|
||||
info!("TLS termination enabled (h2, http/1.1), reload with SIGHUP");
|
||||
let shutdown = shutdown.clone();
|
||||
tokio::spawn(tls::serve_tls(listener, app, server_config, shutdown))
|
||||
tokio::spawn(tls::serve_tls(listener, tcp_app, server_config, shutdown))
|
||||
}
|
||||
None => {
|
||||
let make_service = app.into_make_service_with_connect_info::<SocketAddr>();
|
||||
@@ -332,6 +356,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await
|
||||
.map_err(|e| format!("Server task panicked: {}", e))?;
|
||||
|
||||
if let Some(handle) = http3_handle {
|
||||
handle.await.ok();
|
||||
}
|
||||
|
||||
comms_handle.await.ok();
|
||||
|
||||
if let Some(handle) = crawlers_handle {
|
||||
|
||||
@@ -211,6 +211,9 @@ pub async fn serve_tls(
|
||||
async move {
|
||||
match accepted {
|
||||
Ok((tcp, peer)) => {
|
||||
if let Err(e) = tcp.set_nodelay(true) {
|
||||
debug!("failed to set nodelay for {peer}: {e}");
|
||||
}
|
||||
let permit = tokio::select! {
|
||||
biased;
|
||||
_ = conn_shutdown.cancelled() => return,
|
||||
|
||||
@@ -27,7 +27,7 @@ pub use hint::{
|
||||
};
|
||||
pub use manager::{CachedHandle, DEFAULT_MAX_FILE_SIZE, DataFileManager};
|
||||
pub use reader::{BLOCK_CORRUPTION_MARKER, BlockStoreReader, ReadError};
|
||||
pub use repair::{RepairOutcome, rebuild_and_repair_mst};
|
||||
pub use repair::{RepairOutcome, rebuild_and_repair_mst, rebuild_mst_nodes};
|
||||
pub use store::QuiesceGuard;
|
||||
pub use store::{BlockStoreConfig, DEFAULT_SHARD_COUNT, OpenRetryPolicy, TranquilBlockStore};
|
||||
pub use types::{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use jacquard_repo::error::RepoError;
|
||||
use jacquard_repo::mst::Mst;
|
||||
@@ -23,13 +24,13 @@ fn rebuild_err(context: &str, e: impl std::fmt::Display) -> RepoError {
|
||||
RepoError::storage(std::io::Error::other(format!("{context}: {e}")))
|
||||
}
|
||||
|
||||
async fn rebuild_node_blocks(
|
||||
entries: Vec<(String, Cid)>,
|
||||
pub async fn rebuild_mst_nodes(
|
||||
entries: &[(String, Cid)],
|
||||
expected_root: Cid,
|
||||
) -> Result<Vec<(CidBytes, Vec<u8>)>, RepoError> {
|
||||
) -> Result<Vec<(Cid, Bytes)>, RepoError> {
|
||||
let scratch = Arc::new(MemoryBlockStore::new());
|
||||
let mut mst = Mst::new(scratch);
|
||||
for (key, cid) in &entries {
|
||||
for (key, cid) in entries {
|
||||
mst.add_mut(key.as_str(), *cid)
|
||||
.await
|
||||
.map_err(|e| rebuild_err("mst rebuild add", e))?;
|
||||
@@ -49,7 +50,15 @@ async fn rebuild_node_blocks(
|
||||
)));
|
||||
}
|
||||
|
||||
blocks
|
||||
Ok(blocks.into_iter().collect())
|
||||
}
|
||||
|
||||
async fn rebuild_node_blocks(
|
||||
entries: Vec<(String, Cid)>,
|
||||
expected_root: Cid,
|
||||
) -> Result<Vec<(CidBytes, Vec<u8>)>, RepoError> {
|
||||
rebuild_mst_nodes(&entries, expected_root)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(cid, bytes)| Ok((cid_to_bytes(&cid)?, bytes.to_vec())))
|
||||
.collect()
|
||||
|
||||
@@ -163,7 +163,7 @@ pub async fn get_repo(
|
||||
{
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
if ApiError::detail_is_repo_corruption(&format!("{e:#}")) {
|
||||
if e.is_repairable() {
|
||||
tranquil_pds::repo_ops::schedule_repo_repair(&state, account.user_id);
|
||||
}
|
||||
error!("Failed to generate repo CAR: {}", e);
|
||||
|
||||
@@ -128,6 +128,14 @@
|
||||
# Can also be specified via environment variable `TLS_KEY_PATH`.
|
||||
#key_path =
|
||||
|
||||
# Serve HTTP/3 over QUIC on the same UDP port as the TCP listener.
|
||||
# Requires cert_path and key_path.
|
||||
#
|
||||
# Can also be specified via environment variable `TLS_HTTP3`.
|
||||
#
|
||||
# Default value: false
|
||||
#http3 = false
|
||||
|
||||
[frontend]
|
||||
# Whether to enable the built in serving of the frontend.
|
||||
#
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
);
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (pkgs: {
|
||||
tranquil-pds = pkgs.callPackage ./default.nix { };
|
||||
tranquil-pds-aarch64 = pkgs.pkgsCross.aarch64-multiplatform.callPackage ./default.nix { };
|
||||
tranquil-frontend = pkgs.callPackage ./frontend.nix { };
|
||||
lib.mkPackages = import ./pkgs.nix;
|
||||
|
||||
packages = forAllSystems (pkgs: (self.lib.mkPackages pkgs) // {
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-pds;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
nixpkgs: {
|
||||
tranquil-pds = nixpkgs.callPackage ./default.nix { };
|
||||
tranquil-pds-aarch64 = nixpkgs.pkgsCross.aarch64-multiplatform.callPackage ./default.nix { };
|
||||
tranquil-frontend = nixpkgs.callPackage ./frontend.nix { };
|
||||
}
|
||||
@@ -15,6 +15,9 @@
|
||||
sqlx-cli,
|
||||
cargo-nextest,
|
||||
|
||||
# nix jemalloc for some tests that use jemalloc
|
||||
rust-jemalloc-sys,
|
||||
|
||||
# frontend tooling
|
||||
svelte-language-server,
|
||||
typescript-language-server,
|
||||
@@ -44,6 +47,8 @@ mkShell {
|
||||
sqlx-cli
|
||||
cargo-nextest
|
||||
|
||||
rust-jemalloc-sys
|
||||
|
||||
svelte-language-server
|
||||
typescript-language-server
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user