Compare commits

..
Author SHA1 Message Date
nelind e85394c314 build: bump version to 0.6.3 2026-05-27 19:28:10 +02:00
isabelandTangled 411c6be108 feat(nix): validate config at build time 2026-05-27 20:14:06 +03:00
isabelandTangled 28aa7ab7fc feat!(tranquil-config): error on unknown keys
an attempt to make it so that fixes like
fc6063dba8 never have to occur again
2026-05-27 20:13:58 +03:00
nelindandTangled 348b69d76b feat(nix): ensure that tranquil-store path is always writable by the service user just like file blob storage 2026-05-27 20:13:07 +03:00
nelindandTangled 4b23ca2c36 fix(docs): some wording on nix config and CI stuffs 2026-05-27 20:13:07 +03:00
LewisandTangled a7052e878c feat: tranquil's own TLS handling
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-27 13:54:33 +03:00
nelind fc6063dba8 fix(nix): override the right frontend package setting in the flake nixos module 2026-05-27 01:04:31 +02:00
LewisandTangled 44cb016762 fix(docs): install docs better
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-26 09:49:59 +03:00
LewisandTangled e6eee18ace fix(docs): migration oauth update
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-25 19:02:13 +03:00
Lewis 96c8375706 fix(test): oauth token eviction should be agnostic
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-25 16:17:10 +03:00
Lewis 86c5995568 fix(config): signal gate is useless since needs admin work to activate anyway
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-24 22:50:14 +03:00
LewisandTangled 036c317fd6 fix(migrate): oauth refreshing, embedded db first invite code
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-24 19:28:57 +03:00
LewisandTangled 4d2c7d4723 feat(auth): verification-gate override, inbound-migration bypass, store deleter improvement
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-23 23:02:43 +03:00
isabelandTangled f24a9f8bc0 build(nix): frontend pin pnpm
some future protections against this acursed toolchain breaking and
people who do .follows on their nixpkgs inputs
2026-05-22 23:24:45 +03:00
Lewis f6ef6ecbd9 feat(docs): first version of 'when PRing' section of contrib
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-21 22:46:59 +03:00
karitham.devandTangled a2567bdb1a fix(locales): french handle should be "identifier" not "username"
My bad translation on that earlier, made it consistent with the rest of the file and usage!
2026-05-21 10:39:29 +03:00
Lewis 56120d252d fix(locale): a little more drift
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-21 09:14:06 +03:00
TrezyandTangled 210b0f463c feat: add About page
Signed-off-by: Trezy <tre@trezy.com>
2026-05-21 09:06:42 +03:00
LewisandTangled c80f504dc0 fix(locale): a couple of drift keys missing in other langs
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-21 09:06:10 +03:00
karitham.devandTangled a3bd7c59ad feat(locales): add french locale, fix missing error keys, remove dead keys
Adds french locale registration and translation file. Fixes 4 missing error keys that were referenced in code but absent from all locale files. Removes 2 unreferenced keys from `register.validation` that duplicated `registerPasskey.errors`.

I *think* that's all that's needed for french locale? Couldn't find anything else required to make it work!
2026-05-20 22:20:20 +03:00
nelind 9ebde27540 fix(docs): markdown is tricky ... 2026-05-19 21:30:35 +02:00
nelindandTangled 28a7834304 docs: nel interjection: be right, be good, be fun 2026-05-19 22:18:38 +03:00
Lewis 191da5b311 chore: slight doc tweak
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-19 21:14:28 +03:00
Lewis a5b4ba7d65 feat(docs): init better docs
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-19 20:45:50 +03:00
TrezyandTangled 90dabd8840 docs: start a contributing file to track how to work with the repo
Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 19eaccea74 chore: add dev startup to justfile
Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled f620a6bc43 chore: add mailpit for local dev
Captures outbound emails, greatly simplifying local dev.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 04f370aaa1 chore: add frontend service to docker compose
Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 3fd8f7ebbf chore: add app service with cargo-watch
Spins up a container with Tranquil running, but automatically uses incremental rebuilds for local dev.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 0afcb2ee28 chore: add plc directory for local dev
Prevents us from polluting the real PLC directory.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 8bd556f65b chore: add db health check to docker compose
Safer wafe to depend on database startup.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled cc92594506 chore: add traefik for local dev certs and routing
Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 76f22b801b chore: allow native certs during local dev
Allows Traefik certs to be trusted by Tranquil.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 021b7dbec4 chore: add profiles to docker compose
Allows us to spin up groups from the compose file

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:57 +03:00
nelind eb034cb8b3 fix(ci): dont pass cachix cache name as a secret so it doesnt get redacted from CI logs 2026-05-17 17:07:34 +02:00
Lewis bdaf510898 build: bump workspace to 0.6.2
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 12:32:05 +03:00
Lewis deb2502112 feat(pds): phantom-file self-heal goes in scheduled compaction + reachability walk
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 12:32:05 +03:00
Lewis 1815ddba9f feat(gauntlet): index-backed/hint-backed/readable invariants, ExternalCorruption scenario
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 12:32:05 +03:00
Lewis a7517ed5c9 feat(store): consistency check & repair for orphan hints etc
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 11:50:32 +03:00
Lewis d07d702dd4 feat(store): try to self-heal phantom index entries on compaction
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 11:50:19 +03:00
isabelandTangled 1901b0a630 fix(nix): build against pnpm 11
side note: you may also notice that I have removed the `github:` url for
nixpkgs this is because it save a massive 15mb, is faster and more
reliable than github
See also:
-
<https://nix.dev/manual/nix/stable/protocols/tarball-fetcher#lockable-http-tarball-protocol>
-
<http://web.archive.org/web/20250806225139/https://nix.dev/manual/nix/2.28/protocols/tarball-fetcher#lockable-http-tarball-protocol>
2026-05-14 21:37:28 +03:00
serenityandTangled 58f8d327c1 feat(docs): clarify nix documentation 2026-05-14 16:52:10 +03:00
LewisandTangled a13343e1de fix(oauth): gc tokens in pg in the right order, more exposure of dpop err
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-14 16:51:53 +03:00
Bas van den WollenbergandTangled 60e10af4aa fix(Dockerfile): pnpm v11 build errors
- pnpm doesn't implicitly ignore post-install scripts anymore which means trying to build the docker image now fails. i've explicitly allowed them but idk could also ignore them maybe not sure what they actually do lol

- added `confirmModulesPurge: false` but not sure what it actually does as it's not documented? but either way it's also required for the `pnpm build` step to succeed non-interactively it seems idk weird stuff

- removed msmtp/sendmail while i was at it just like https://tangled.org/tranquil.farm/tranquil-pds/commit/f176f55862bc4c41472f8697cc2ca069c24d5e37
2026-05-14 09:56:07 +03:00
isabelandTangled f176f55862 chore(nix): remove sendmail 2026-05-02 23:32:57 +03:00
LewisandTangled fac9520a16 feat(tranquil-server): email config, tests, fmt
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
LewisandTangled eee6fb9ff4 feat(comms): EmailSender, permanent/transient routing
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
LewisandTangled 2462d0ab3b feat(tranquil-comms): smtp and dkim signing
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
LewisandTangled 85f87f7b28 feat(tranquil-comms): message construction and mx resolution
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
LewisandTangled b1d86caa78 feat(tranquil-comms): prework for email
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
Lewis 9b2cfb3a7e fix(tranquil-store): durable-tail recovery + sync semantics
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-30 11:11:28 +03:00
Lewis efd499bb26 fix(tranquil-store): barrier durability + torn-header recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-29 15:35:42 +03:00
170 changed files with 10936 additions and 2327 deletions
+8
View File
@@ -68,6 +68,10 @@ test-group = "serial-env-tests"
filter = "package(tranquil-signal)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "package(tranquil-config)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "binary(whole_story)"
test-group = "heavy-load-tests"
@@ -118,6 +122,10 @@ test-group = "serial-env-tests"
filter = "package(tranquil-signal)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "package(tranquil-config)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "binary(whole_story)"
test-group = "heavy-load-tests"
+2
View File
@@ -4,3 +4,5 @@ target/
result
frontend/node_modules/
frontend/dist/
frontend/.pnpm-store
frontend/.npmrc
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin\n FROM users WHERE did = $1",
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration\n FROM users WHERE handle = $1",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "inbound_migration",
"type_info": "Bool"
}
],
"parameters": {
@@ -57,8 +62,9 @@
false,
true,
true,
false,
false
]
},
"hash": "6b51995c40519a63f85c70f29ca8bd6ec1963c8562d78215d980785dc46a6384"
"hash": "18bbda5582db1b32d02ab8a3eee970c9508b9bd67239c2f936639a9f863b30ff"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET deactivated_at = NULL WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "23201d4e26bc650939e30f69fb0bca00d351d057098afebc1017f70a84b4bd22"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET deactivated_at = NULL, inbound_migration = FALSE WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "51e029e572777e6a103fd7fd5550494de9d4cac7e3ff84e27ddec1a6aaefc047"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE comms_queue\n SET status = 'failed'::comms_status,\n attempts = max_attempts,\n last_error = $2,\n updated_at = NOW()\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "5bee4ed5296667e4ca7e1a97aec28d30a470b8aee7b378ec9ca4e34de4faf349"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at",
"query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE attempts < max_attempts\n AND scheduled_for <= $1\n AND (\n status = 'pending'\n OR (status = 'processing'\n AND updated_at < $1 - INTERVAL '10 minutes')\n )\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at",
"describe": {
"columns": [
{
@@ -154,5 +154,5 @@
true
]
},
"hash": "8047fda41bd94f819213decb8b3e0aba49a8dbdb10217eefd77e3567f8c9694a"
"hash": "890aa92acdcb0fe2a3bf04d87e1f16a801d271da7cedc32fc42c2ef5b100faae"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY updated_at ASC\n OFFSET $2\n )\n ",
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY created_at DESC\n OFFSET $2\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "56cd24903171eddc2ededd9079ffe10937c34e99b0305f25c980ca754da44625"
"hash": "8f4357f7a18ddcf6b686a4555f244d37c35917364b8f917ca6ee2d4030ace742"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin\n FROM users WHERE handle = $1",
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration\n FROM users WHERE did = $1",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "inbound_migration",
"type_info": "Bool"
}
],
"parameters": {
@@ -57,8 +62,9 @@
false,
true,
true,
false,
false
]
},
"hash": "584bceda60d8b6a02e7dc44d833e3fba13151f36ba9f75c64e33d6cb534cc939"
"hash": "f1c4ec28b02d09ffce35aa8249c1747a70c12a3ecfc1ff6ca1847840f770db2f"
}
@@ -18,4 +18,4 @@ steps:
- name: Build and push aarch64
command: |
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds-aarch64 $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push "$CACHIX_CACHE_NAME"
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds-aarch64 $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
+3 -3
View File
@@ -18,11 +18,11 @@ steps:
- name: Build and push x86_64
command: |
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push "$CACHIX_CACHE_NAME"
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-frontend $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push "$CACHIX_CACHE_NAME"
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-frontend $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
- name: Build and push devShell
command: |
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
nix develop $SUBS --profile dev-profile -c true
cachix push "$CACHIX_CACHE_NAME" dev-profile
cachix push tranquil dev-profile
+135
View File
@@ -0,0 +1,135 @@
# Contributing to Tranquil PDS
## When PRing
In order of importance:
- **You must run your change! Every contribution that says "here's xyz. untested." does not help the project.**
- Relevant tests to your PR must pass. The whole suite doesn't have to be proven to have run, because there are a *ton* of tests and they're quite heavy, but hopefully there are existing tests for whatever you're PRing, and if there aren't, please add those too.
- Run cargo fmt :P
> 🦪 Lewis
>
> Good CI fixes some of these. We should really get around to that.
Things that would also be nice but aren't like, a pain in our side:
- Big changes should be stacked PRs that are broken up into digestible pieces. Those stacked PRs should hopefully be able to be merged individually if necessary.
## Local Development
### Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
- Add `pds.test` to your hosts file (one-time setup):
```
127.0.0.1 pds.test
```
- **macOS / Linux:** `/etc/hosts`
- **Windows:** `C:\Windows\System32\drivers\etc\hosts`
### Starting the dev environment
```bash
just run-dev
```
This starts the following services via `docker-compose`:
- **Traefik** — HTTPS reverse proxy at `https://pds.test`
- **Backend** — Rust server with `cargo-watch` (auto-rebuilds on file changes)
- **Frontend** — Vite dev server with hot module replacement
- **Postgres** — Database on port 5432
- **PLC Directory** — Local [did-method-plc](https://github.com/did-method-plc/did-method-plc) server for DID registration
- **Mailpit** — Local email server with web UI at [http://localhost:8025](http://localhost:8025)
Once all services are running, open **https://pds.test** in your browser.
### Trusting the self-signed certificate
Traefik generates a self-signed TLS certificate. Your browser will show a security warning on first visit. You can either click through it, or add the certificate to your system trust store for a seamless experience:
**macOS:**
```bash
# Extract the cert from traefik and add it to the system keychain
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 > /tmp/pds-test.pem
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/pds-test.pem
```
**Linux (Debian/Ubuntu):**
```bash
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 | sudo tee /usr/local/share/ca-certificates/pds-test.crt
sudo update-ca-certificates
```
**Linux (Fedora/RHEL):**
```bash
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 | sudo tee /etc/pki/ca-trust/source/anchors/pds-test.pem
sudo update-ca-trust
```
**Windows (PowerShell as Administrator):**
```powershell
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
$cert.Import([System.Text.Encoding]::UTF8.GetBytes((echo | openssl s_client -connect localhost:443 -servername pds.test 2>$null | openssl x509)))
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store("Root", "LocalMachine")
$store.Open("ReadWrite")
$store.Add($cert)
$store.Close()
```
Restart your browser after adding the certificate.
### Stopping the dev environment
```bash
# Stop containers (preserves database + build cache)
docker compose --profile dev down
# Stop and wipe all data (fresh start)
docker compose --profile dev down -v
```
### Direct database access
Postgres is exposed on port 5432:
```bash
psql postgres://postgres:postgres@localhost:5432/pds
```
### How it works
- **Source code** is bind-mounted into the containers so that changes made on the host will be immediately reflected in the application
- **Backend** uses `cargo-watch` to recompile and restart when Rust files change
- **Frontend** uses Vite's HMR for instant browser updates when frontend files change
- **Build cache** (`target/` directory and cargo registry) are stored in Docker volumes, so incremental compilation persists across container restarts
- **Traefik** routes `/`, `/xrpc`, `/oauth`, `/.well-known`, `/u`, and `/health` to the backend; everything else goes to the Vite dev server
- **Mailpit** captures all outgoing email — open [http://localhost:8025](http://localhost:8025) to view verification emails during registration
- **PLC Directory** runs locally so DID registration doesn't hit the real `plc.directory`
### Running the backend natively
If you prefer running the Rust backend outside Docker (faster incremental builds on host), you need:
- Rust toolchain (see `rust-toolchain.toml`)
- `protoc` (`brew install protobuf` on macOS)
- PostgreSQL (start with `docker compose up db`)
Then run:
```bash
cargo run -p tranquil-server -- --config config.toml
```
And start the frontend separately:
```bash
cd frontend && pnpm install && pnpm dev
```
Generated
+545 -732
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -26,7 +26,7 @@ members = [
]
[workspace.package]
version = "0.5.7"
version = "0.6.3"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -59,6 +59,7 @@ presage = { git = "https://github.com/whisperfish/presage", rev = "fe3ed54c4844a
unicode-segmentation = "1"
aes-gcm = "0.10"
arc-swap = "1"
backon = "1"
bincode = { version = "2", features = ["serde"] }
anyhow = "1.0"
@@ -86,6 +87,8 @@ hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
hkdf = "0.12"
hmac = "0.12"
http = "1.4"
hyper = { version = "1", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["server", "server-auto", "server-graceful", "service", "tokio"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
qrcodegen = "1.8"
infer = "0.19"
@@ -93,6 +96,7 @@ ipld-core = "0.4"
iroh-car = "0.5"
jacquard-common = { version = "0.9", features = ["crypto-k256"] }
jacquard-repo = "0.9"
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "pool", "dkim", "tracing"] }
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] }
k256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] }
metrics = "0.24"
@@ -105,6 +109,10 @@ p384 = { version = "0.13", features = ["ecdsa"] }
rand = "0.8"
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
regex = "1"
rsa = "0.9"
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-pemfile = "2"
secrecy = { version = "0.10", features = ["serde"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "http2", "charset", "macos-system-configuration"] }
serde = { version = "1.0", features = ["derive"] }
serde_bytes = "0.11"
@@ -116,8 +124,9 @@ sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"
subtle = "2.5"
thiserror = "2.0"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "signal", "process", "io-util", "fs"] }
tokio-util = "0.7.18"
tokio-util = { version = "0.7.18", features = ["rt"] }
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
totp-rs = { version = "5", features = ["qr"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "cors"] }
+3 -4
View File
@@ -1,7 +1,7 @@
FROM node:24-alpine AS frontend
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY frontend/package.json frontend/pnpm-lock.yaml ./
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm build
@@ -46,12 +46,11 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
cp target/release/tranquil-server /tmp/tranquil-pds
FROM alpine:3.23
RUN apk add --no-cache msmtp ca-certificates \
&& ln -sf /usr/bin/msmtp /usr/sbin/sendmail
RUN apk add --no-cache ca-certificates
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
WORKDIR /app
ENV SERVER_HOST=0.0.0.0
ENV SERVER_HOST=[::]
ENV SERVER_PORT=3000
EXPOSE 3000
CMD ["tranquil-pds"]
+20
View File
@@ -0,0 +1,20 @@
FROM rust:1.92-alpine
RUN apk add --no-cache \
ca-certificates \
musl-dev \
pkgconfig \
openssl-dev \
openssl-libs-static \
mold \
clang \
protoc
RUN cargo install cargo-watch
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
ENV SQLX_OFFLINE=true
WORKDIR /app
CMD ["cargo", "watch", "-x", "run -p tranquil-server -- --config /app/config.dev.toml"]
+13 -8
View File
@@ -6,7 +6,7 @@ A Personal Data Server for the AT Protocol.
We came together to make this PDS to enable and empower our users to better host their data on this shared protocol. All of our decisions as a project are guided by their usefulness to the community: PDS hosters and end-users both.
Comparatively: Bluesky the company created a "reference PDS" that we can self-host quite easily, and that's great, but Bluesky has an incentive to make software for themselvess first & foremost, then secondly their software can be useful for us self-hosters. In contrast, Tranquil is not from a company, and will never be.
Comparatively: Bluesky the company created a "reference PDS" that we can self-host quite easily, and that's great, but Bluesky has an incentive to make software for themselves first & foremost, then secondly their software can be useful for us self-hosters. In contrast, Tranquil is not from a company, and will never be.
## What's different about Tranquil PDS
@@ -20,7 +20,7 @@ It is a superset of the reference PDS, including:
- account delegation: letting others manage an account with configurable permission levels
- a built-in web UI for account management, repo browsing, and admin
Unlike the ref PDS, Tranquil itself is compiled to a single binary with no nodeJS runtime. However, at time of writing, Tranquil requires postgres running separately.
Unlike the ref PDS, Tranquil is a single binary with no nodejs runtime. That said, at time of writing, Tranquil does require postgres running separately.
## Quick Start
@@ -46,24 +46,28 @@ just test
just lint
```
Nix users can enter a devshell with `nix develop`, or `direnv allow` to auto-enter via the bundled `.envrc`. Pre-built artifacts (including the devshell) are available from our [binary cache](docs/install-nix.md#binary-cache).
Nix users can enter a devshell with `nix develop`, or `direnv allow` to auto-enter via the bundled `.envrc`. Pre-built artifacts including the devshell are available from our [binary cache](docs/2_INSTALL_NIX.md#binary-cache).
## Production Deployment
### Quick Deploy (Docker/Podman Compose)
Edit `config.toml` with your values. Generate secrets with `openssl rand -base64 48`.
`docker-compose.prod.yaml` pulls the prebuilt image `atcr.io/tranquil.farm/tranquil-pds:latest`. Sign in to the registry first with `podman login atcr.io`. The Containers guide covers building from source.
```bash
cp example.toml config.toml
```
Edit `config.toml` with your values and generate secrets with `openssl rand -base64 48`. Set the postgres password to match `docker-compose.prod.yaml`. nginx needs a TLS certificate before it starts, so follow the wildcard cert steps in the [Containers guide](docs/2_INSTALL_CONTAINERS.md).
```bash
podman-compose -f docker-compose.prod.yaml up -d
```
### Installation Guides
- [Nix](docs/install-nix.md)
- [Containers](docs/install-containers.md)
- [Kubernetes](docs/install-kubernetes.md)
- [Nix](docs/2_INSTALL_NIX.md)
- [Containers](docs/2_INSTALL_CONTAINERS.md)
## Community
@@ -76,7 +80,7 @@ We currently don't have a shared space to chat and organize Tranquil things, but
- [@oyster.cafe](https://tangled.org/did:plc:3fwecdnvtcscjnrx2p4n7alz)
- [@nel.pet](https://tangled.org/did:plc:h5wsnqetncv6lu2weom35lg2)
### Amazing contributers
### Amazing contributors
- [@isabelroses.com](https://tangled.org/did:plc:qxichs7jsycphrsmbujwqbfb)
- [@quilling.dev](https://tangled.org/did:plc:jrtgsidnmxaen4offglr5lsh)
@@ -87,6 +91,7 @@ We currently don't have a shared space to chat and organize Tranquil things, but
- [@a.starrysky.fyi](https://tangled.org/did:plc:uuyqs6y3pwtbteet4swt5i5y)
- [@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)
- Could be your name here too!
### Tranquil PDS instances in the wild!
+30
View File
@@ -0,0 +1,30 @@
[server]
hostname = "pds.test"
allow_http_proxy = true
invite_code_required = false
disable_rate_limiting = true
[frontend]
enabled = true
dir = "/app/frontend/public"
[database]
url = "postgres://postgres:postgres@db:5432/pds"
[storage]
path = "/var/lib/tranquil-pds/blobs"
[plc]
directory_url = "http://plc:2582"
[email]
from_address = "noreply@pds.test"
from_name = "Tranquil PDS (Dev)"
[email.smarthost]
host = "mailpit"
port = 1025
tls = "none"
[secrets]
allow_insecure = true
+1 -3
View File
@@ -9,7 +9,6 @@ use tranquil_pds::state::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalStatusOutput {
pub enabled: bool,
pub linked: bool,
}
@@ -23,13 +22,12 @@ pub async fn get_signal_status(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<SignalStatusOutput>, ApiError> {
let enabled = tranquil_config::get().signal.enabled;
let linked = match &state.signal_sender {
Some(slot) => slot.is_linked().await,
None => false,
};
Ok(Json(SignalStatusOutput { enabled, linked }))
Ok(Json(SignalStatusOutput { linked }))
}
pub async fn link_signal_device(
@@ -510,6 +510,7 @@ pub async fn create_account(
telegram_username: comms.telegram,
signal_username: comms.signal,
deactivated_at,
inbound_migration: is_migration || is_did_web_byod,
encrypted_key_bytes: repo.encrypted_key_bytes,
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
reserved_key_id,
+1 -1
View File
@@ -106,7 +106,7 @@ pub async fn import_repo(
.map(|c| c.import.skip_verification)
.unwrap_or(false)
});
let is_migration = user.deactivated_at.is_some();
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 {
+6 -4
View File
@@ -5,7 +5,7 @@ use tranquil_pds::BUILD_VERSION;
use tranquil_pds::state::AppState;
use tranquil_pds::util::{discord_app_id, discord_bot_username, telegram_bot_username};
fn get_available_comms_channels() -> Vec<CommsChannel> {
async fn get_available_comms_channels(state: &AppState) -> Vec<CommsChannel> {
let cfg = tranquil_config::get();
let mut channels = vec![CommsChannel::Email];
if cfg.discord.bot_token.is_some() {
@@ -14,7 +14,9 @@ fn get_available_comms_channels() -> Vec<CommsChannel> {
if cfg.telegram.bot_token.is_some() {
channels.push(CommsChannel::Telegram);
}
if cfg.signal.enabled {
if let Some(slot) = &state.signal_sender
&& slot.is_linked().await
{
channels.push(CommsChannel::Signal);
}
channels
@@ -66,7 +68,7 @@ pub struct DescribeServerOutput {
pub telegram_bot_username: Option<String>,
}
pub async fn describe_server() -> Json<DescribeServerOutput> {
pub async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerOutput> {
let cfg = tranquil_config::get();
let pds_hostname = &cfg.server.hostname;
@@ -82,7 +84,7 @@ pub async fn describe_server() -> Json<DescribeServerOutput> {
email: cfg.server.contact_email.clone(),
},
version: BUILD_VERSION,
available_comms_channels: get_available_comms_channels(),
available_comms_channels: get_available_comms_channels(&state).await,
self_hosted_did_web_enabled: is_self_hosted_did_web_enabled(),
discord_bot_username: discord_bot_username().map(String::from),
discord_app_id: discord_app_id().map(String::from),
+1
View File
@@ -51,6 +51,7 @@ pub use session::{
auto_resend_verification, confirm_signup, create_session, delete_session,
get_legacy_login_preference, get_session, list_sessions, refresh_session, resend_verification,
revoke_all_sessions, revoke_session, update_legacy_login_preference, update_locale,
verification_blocks_login,
};
pub use signing_key::reserve_signing_key;
pub use totp::{
+9 -3
View File
@@ -8,7 +8,7 @@ use bcrypt::verify;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
use tranquil_db_traits::{SessionId, TokenFamilyId};
use tranquil_db_traits::{ChannelVerificationStatus, SessionId, TokenFamilyId};
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::api::{EmptyResponse, PreferredLocaleOutput, SuccessResponse};
use tranquil_pds::auth::{
@@ -20,6 +20,13 @@ use tranquil_pds::state::AppState;
use tranquil_pds::types::{AccountState, Did, Handle, PlainPassword};
use tranquil_types::TokenId;
pub fn verification_blocks_login(channel_verification: &ChannelVerificationStatus) -> bool {
!tranquil_config::get()
.server
.disable_account_verification_gate
&& !channel_verification.has_any_verified()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSessionInput {
@@ -129,14 +136,13 @@ pub async fn create_session(
warn!("Login attempt for takendown account: {}", row.did);
return Err(ApiError::AccountTakedown);
}
let is_verified = row.channel_verification.has_any_verified();
let is_delegated = state
.repos
.delegation
.is_delegated_account(&row.did)
.await
.unwrap_or(false);
if !is_verified && !is_delegated {
if verification_blocks_login(&row.channel_verification) && !is_delegated {
warn!("Login attempt for unverified account: {}", row.did);
let resend_info = auto_resend_verification(&state, &row.did).await;
let handle = resend_info
+11
View File
@@ -10,7 +10,14 @@ tranquil-signal = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
ed25519-dalek = { workspace = true }
futures = { workspace = true }
hickory-resolver = { workspace = true }
lettre = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true }
rsa = { workspace = true }
secrecy = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
@@ -18,3 +25,7 @@ tokio = { workspace = true }
tracing = { workspace = true }
tranquil-db-traits = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
chrono = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "io-util", "net"] }
+227
View File
@@ -0,0 +1,227 @@
use std::fs;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use ed25519_dalek::pkcs8::DecodePrivateKey as _;
use lettre::Message;
use lettre::message::dkim::{
DkimCanonicalization, DkimCanonicalizationType, DkimConfig as LettreDkimConfig,
DkimSigningAlgorithm, DkimSigningKey,
};
use lettre::message::header::HeaderName;
use rsa::pkcs1::EncodeRsaPrivateKey;
use rsa::pkcs8::LineEnding;
use super::types::{DkimKeyPath, DkimSelector, EmailDomain};
use crate::sender::SendError;
const SIGNED_HEADERS: &[&str] = &[
"From",
"Sender",
"Reply-To",
"To",
"Cc",
"Subject",
"Date",
"In-Reply-To",
"References",
"MIME-Version",
"Content-Type",
"Content-Transfer-Encoding",
];
pub struct DkimSigner {
config: LettreDkimConfig,
}
impl DkimSigner {
pub fn load(
selector: DkimSelector,
domain: EmailDomain,
path: DkimKeyPath,
) -> Result<Self, SendError> {
let pem = fs::read_to_string(path.as_path()).map_err(|e| {
SendError::DkimSign(format!("read DKIM key {}: {e}", path.as_path().display()))
})?;
Self::from_pem(selector, domain, &pem)
}
pub fn from_pem(
selector: DkimSelector,
domain: EmailDomain,
pem: &str,
) -> Result<Self, SendError> {
let key = parse_key(pem)?;
let canonicalization = DkimCanonicalization {
header: DkimCanonicalizationType::Relaxed,
body: DkimCanonicalizationType::Relaxed,
};
let headers = SIGNED_HEADERS
.iter()
.copied()
.map(HeaderName::new_from_ascii_str)
.collect();
let config = LettreDkimConfig::new(
selector.into_inner(),
domain.into_inner(),
key,
headers,
canonicalization,
);
Ok(Self { config })
}
pub fn sign(&self, message: &mut Message) {
message.sign(&self.config);
}
}
impl std::fmt::Debug for DkimSigner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("DkimSigner")
}
}
fn parse_key(input: &str) -> Result<DkimSigningKey, SendError> {
let trimmed = input.trim_start();
match trimmed {
s if s.starts_with("-----BEGIN RSA PRIVATE KEY-----") => {
DkimSigningKey::new(input, DkimSigningAlgorithm::Rsa)
.map_err(|e| SendError::DkimSign(format!("RSA PKCS#1 PEM rejected: {e}")))
}
s if s.starts_with("-----BEGIN PRIVATE KEY-----") => parse_pkcs8(input),
s if s.starts_with("-----BEGIN") => Err(SendError::DkimSign(
"unrecognized PEM type; expected an RSA or Ed25519 private key".to_string(),
)),
_ => DkimSigningKey::new(input.trim(), DkimSigningAlgorithm::Ed25519).map_err(|e| {
SendError::DkimSign(format!(
"expected base64-encoded 32-byte Ed25519 seed or a PEM-wrapped key: {e}"
))
}),
}
}
fn parse_pkcs8(pem: &str) -> Result<DkimSigningKey, SendError> {
let ed25519_err = match ed25519_dalek::SigningKey::from_pkcs8_pem(pem) {
Ok(key) => {
let seed = BASE64_STANDARD.encode(key.to_bytes());
return DkimSigningKey::new(&seed, DkimSigningAlgorithm::Ed25519)
.map_err(|e| SendError::DkimSign(format!("re-import Ed25519 seed: {e}")));
}
Err(e) => e,
};
let rsa_err = match rsa::RsaPrivateKey::from_pkcs8_pem(pem) {
Ok(key) => {
let pkcs1 = key
.to_pkcs1_pem(LineEnding::LF)
.map_err(|e| SendError::DkimSign(format!("re-encode RSA PKCS#8 as PKCS#1: {e}")))?;
return DkimSigningKey::new(pkcs1.as_str(), DkimSigningAlgorithm::Rsa)
.map_err(|e| SendError::DkimSign(format!("re-import RSA PKCS#1: {e}")));
}
Err(e) => e,
};
Err(SendError::DkimSign(format!(
"PKCS#8 PEM rejected by both parsers; ed25519: {ed25519_err}; rsa: {rsa_err}"
)))
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
use lettre::message::Mailbox;
use lettre::message::header::ContentType;
use rsa::pkcs1::DecodeRsaPrivateKey as _;
const ED25519_RAW_SEED_B64: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
const RSA_PKCS1_PEM: &str = include_str!("test_fixtures/rsa2048-priv-pkcs1.pem");
fn ed25519_pkcs8_pem() -> String {
let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
}
fn rsa_pkcs8_pem() -> String {
let key = rsa::RsaPrivateKey::from_pkcs1_pem(RSA_PKCS1_PEM).unwrap();
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
}
fn signer(pem: &str) -> DkimSigner {
DkimSigner::from_pem(
DkimSelector::parse("default").unwrap(),
EmailDomain::parse("nel.pet").unwrap(),
pem,
)
.expect("key should load")
}
fn signed_headers(signer: &DkimSigner) -> String {
let from: Mailbox = "sender@nel.pet".parse().unwrap();
let to: Mailbox = "recipient@nel.pet".parse().unwrap();
let mut message = Message::builder()
.from(from)
.to(to)
.subject("Roundtrip")
.header(ContentType::TEXT_PLAIN)
.body("Body".to_string())
.unwrap();
signer.sign(&mut message);
String::from_utf8(message.formatted()).unwrap()
}
#[test]
fn rejects_garbage() {
assert!(matches!(
parse_key("not a key"),
Err(SendError::DkimSign(_))
));
}
#[test]
fn rejects_unknown_pem_type() {
let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----\n";
match parse_key(pem) {
Err(SendError::DkimSign(msg)) => assert!(msg.contains("unrecognized"), "msg: {msg}"),
other => panic!("expected unrecognized PEM error, got {other:?}"),
}
}
#[test]
fn ed25519_raw_seed_signs() {
let raw = signed_headers(&signer(ED25519_RAW_SEED_B64));
assert_signed_with(&raw, "a=ed25519-sha256");
}
#[test]
fn ed25519_pkcs8_pem_signs() {
let raw = signed_headers(&signer(&ed25519_pkcs8_pem()));
assert_signed_with(&raw, "a=ed25519-sha256");
}
#[test]
fn rsa_pkcs1_pem_signs() {
let raw = signed_headers(&signer(RSA_PKCS1_PEM));
assert_signed_with(&raw, "a=rsa-sha256");
}
#[test]
fn rsa_pkcs8_pem_signs() {
let raw = signed_headers(&signer(&rsa_pkcs8_pem()));
assert_signed_with(&raw, "a=rsa-sha256");
}
fn assert_signed_with(raw: &str, algorithm: &str) {
assert!(
raw.contains("DKIM-Signature:"),
"no signature header: {raw}"
);
assert!(raw.contains(algorithm), "missing {algorithm}: {raw}");
assert!(
raw.contains("c=relaxed/relaxed"),
"expected relaxed/relaxed canonicalization: {raw}"
);
}
}
+151
View File
@@ -0,0 +1,151 @@
use lettre::Message;
use lettre::message::Mailbox;
use lettre::message::header::ContentType;
use uuid::Uuid;
use super::types::EmailDomain;
use crate::sender::SendError;
use crate::types::QueuedComms;
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
let to: Mailbox = qc
.recipient
.parse()
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
let subject = qc.subject.as_deref().unwrap_or("Notification");
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
Message::builder()
.from(from.clone())
.to(to)
.subject(subject)
.message_id(Some(message_id))
.header(ContentType::TEXT_PLAIN)
.body(qc.body.clone())
.map_err(|e| SendError::MessageBuild(e.to_string()))
}
pub(super) fn recipient_domain(message: &Message) -> Result<EmailDomain, SendError> {
let envelope = message.envelope();
let first = envelope
.to()
.first()
.ok_or_else(|| SendError::MessageBuild("envelope has no recipients".to_string()))?;
EmailDomain::parse(first.domain())
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CommsChannel, CommsStatus, CommsType};
use chrono::Utc;
use uuid::Uuid;
fn from_mailbox() -> Mailbox {
"Test Sender <noreply@nel.pet>".parse().unwrap()
}
fn fixture(recipient: &str, subject: Option<&str>, body: &str) -> QueuedComms {
QueuedComms {
id: Uuid::new_v4(),
user_id: None,
channel: CommsChannel::Email,
comms_type: CommsType::Welcome,
status: CommsStatus::Pending,
recipient: recipient.to_string(),
subject: subject.map(String::from),
body: body.to_string(),
metadata: None,
attempts: 0,
max_attempts: 3,
last_error: None,
created_at: Utc::now(),
updated_at: Utc::now(),
scheduled_for: Utc::now(),
processed_at: None,
}
}
#[test]
fn build_basic_message() {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
let lower = raw.to_lowercase();
assert!(raw.contains("From: \"Test Sender\" <noreply@nel.pet>"));
assert!(raw.contains("To: user@nel.pet"));
assert!(raw.contains("Subject: Welcome"));
assert!(lower.contains("content-type: text/plain"));
assert!(raw.contains("Hello world."));
}
#[test]
fn utf8_subject_is_encoded() {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("héllo wörld"), "Body"),
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("=?utf-8?"));
assert!(!raw.contains("héllo"));
}
#[test]
fn header_injection_rejected() {
let result = build(
&from_mailbox(),
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
);
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
}
#[test]
fn subject_crlf_does_not_inject_headers() {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
)
.expect("subject CRLF should be encoded, not rejected");
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(
!raw.contains("Bcc:"),
"CRLF in subject must not produce a Bcc header: {raw}"
);
assert!(
raw.contains("Subject: ="),
"subject with non-printable chars should be RFC 2047 encoded: {raw}"
);
}
#[test]
fn message_id_uses_from_domain() {
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
let line = raw
.lines()
.find(|l| l.starts_with("Message-ID:") || l.starts_with("Message-Id:"))
.expect("message-id header present");
assert!(
line.contains("@nel.pet>"),
"message-id should use From domain: {line}"
);
}
#[test]
fn missing_subject_uses_default() {
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("Subject: Notification"));
}
#[test]
fn recipient_domain_extracted() {
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
let d = recipient_domain(&msg).unwrap();
assert_eq!(d.as_str(), "nel.pet");
}
}
+194
View File
@@ -0,0 +1,194 @@
pub mod dkim;
pub mod message;
mod mx;
pub mod transport;
pub mod types;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use hickory_resolver::TokioAsyncResolver;
use lettre::message::Mailbox;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::transport::smtp::PoolConfig;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::extension::ClientId;
use tokio::sync::Semaphore;
use tracing::{info, warn};
pub use self::dkim::DkimSigner;
pub use self::transport::SendMode;
use self::types::{
DkimKeyPath, DkimSelector, EmailDomain, HeloName, SmtpHost, SmtpPassword, SmtpPort,
SmtpUsername, TlsMode,
};
use crate::sender::{CommsSender, SendError};
use crate::types::{CommsChannel, QueuedComms};
pub struct EmailSender {
from: Mailbox,
mode: SendMode,
dkim: Option<DkimSigner>,
}
impl EmailSender {
pub fn new(from: Mailbox, mode: SendMode, dkim: Option<DkimSigner>) -> Self {
Self { from, mode, dkim }
}
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Result<Option<Self>, SendError> {
let Some(from_address) = cfg.email.from_address.as_deref().filter(|s| !s.is_empty()) else {
info!("Email sender disabled: MAIL_FROM_ADDRESS unset");
return Ok(None);
};
let from = build_from(&cfg.email.from_name, from_address)?;
let dkim = build_dkim(&cfg.email.dkim)?;
let mode = match cfg
.email
.smarthost
.host
.as_deref()
.filter(|h| !h.is_empty())
{
Some(host) => build_smarthost(cfg, host)?,
None => build_direct_mx(cfg)?,
};
info!(?mode, dkim = dkim.is_some(), "Email sender initialized");
Ok(Some(Self { from, mode, dkim }))
}
}
fn config_invalid(field: &str, error: impl std::fmt::Display) -> SendError {
SendError::ConfigInvalid(format!("{field}: {error}"))
}
fn build_from(from_name: &str, from_address: &str) -> Result<Mailbox, SendError> {
let raw = match from_name.is_empty() {
true => from_address.to_string(),
false => format!("\"{}\" <{}>", from_name.replace('"', "'"), from_address),
};
raw.parse::<Mailbox>()
.map_err(|e| config_invalid("MAIL_FROM_ADDRESS / MAIL_FROM_NAME", e))
}
fn build_smarthost(
cfg: &tranquil_config::TranquilConfig,
host_raw: &str,
) -> Result<SendMode, SendError> {
let host = SmtpHost::parse(host_raw).map_err(|e| config_invalid("MAIL_SMARTHOST_HOST", e))?;
let port = SmtpPort::parse(cfg.email.smarthost.port)
.map_err(|e| config_invalid("MAIL_SMARTHOST_PORT", e))?;
let tls = TlsMode::parse(&cfg.email.smarthost.tls)
.map_err(|e| config_invalid("MAIL_SMARTHOST_TLS", e))?;
let helo = resolve_helo(cfg)?;
let pool = PoolConfig::new()
.max_size(cfg.email.smarthost.pool_size)
.idle_timeout(Duration::from_secs(60));
let command_timeout = Duration::from_secs(cfg.email.smarthost.command_timeout_secs);
let total_timeout = Duration::from_secs(cfg.email.smarthost.total_timeout_secs);
let builder = match tls {
TlsMode::Implicit => AsyncSmtpTransport::<lettre::Tokio1Executor>::relay(host.as_str())
.map_err(|e| config_invalid("smarthost TLS setup", e))?,
TlsMode::Starttls => {
AsyncSmtpTransport::<lettre::Tokio1Executor>::starttls_relay(host.as_str())
.map_err(|e| config_invalid("smarthost TLS setup", e))?
}
TlsMode::None => {
AsyncSmtpTransport::<lettre::Tokio1Executor>::builder_dangerous(host.as_str())
}
};
let builder = builder
.port(port.as_u16())
.hello_name(ClientId::Domain(helo.into_inner()))
.timeout(Some(command_timeout))
.pool_config(pool);
let builder = match (
cfg.email.smarthost.username.as_deref(),
cfg.email.smarthost.password.as_deref(),
) {
(Some(u), Some(p)) => {
let username =
SmtpUsername::parse(u).map_err(|e| config_invalid("MAIL_SMARTHOST_USERNAME", e))?;
let password =
SmtpPassword::parse(p).map_err(|e| config_invalid("MAIL_SMARTHOST_PASSWORD", e))?;
builder.credentials(Credentials::new(
username.into_inner(),
password.expose().to_string(),
))
}
_ => builder,
};
Ok(SendMode::Smarthost {
transport: Box::new(builder.build()),
total_timeout,
})
}
fn build_direct_mx(cfg: &tranquil_config::TranquilConfig) -> Result<SendMode, SendError> {
let helo = resolve_helo(cfg)?;
let resolver = TokioAsyncResolver::tokio_from_system_conf()
.map(Arc::new)
.map_err(|e| config_invalid("system DNS configuration", e))?;
let max_concurrent = cfg.email.direct_mx.max_concurrent_sends.max(1);
Ok(SendMode::DirectMx {
resolver,
helo,
command_timeout: Duration::from_secs(cfg.email.direct_mx.command_timeout_secs),
total_timeout: Duration::from_secs(cfg.email.direct_mx.total_timeout_secs),
require_tls: cfg.email.direct_mx.require_tls,
inflight: Arc::new(Semaphore::new(max_concurrent)),
})
}
fn resolve_helo(cfg: &tranquil_config::TranquilConfig) -> Result<HeloName, SendError> {
let raw = cfg
.email
.helo_name
.clone()
.unwrap_or_else(|| cfg.server.hostname_without_port().to_string());
HeloName::parse(&raw).map_err(|e| config_invalid(&format!("HELO name {raw:?}"), e))
}
fn build_dkim(cfg: &tranquil_config::DkimConfig) -> Result<Option<DkimSigner>, SendError> {
let selector = match cfg.selector.as_deref() {
Some(s) => s,
None => return Ok(None),
};
let domain = cfg
.domain
.as_deref()
.ok_or_else(|| SendError::DkimSign("MAIL_DKIM_DOMAIN required when selector set".into()))?;
let key_path = cfg.private_key_path.as_deref().ok_or_else(|| {
SendError::DkimSign("MAIL_DKIM_KEY_PATH required when selector set".into())
})?;
let selector = DkimSelector::parse(selector)
.map_err(|e| SendError::DkimSign(format!("invalid DKIM selector: {e}")))?;
let domain = EmailDomain::parse(domain)
.map_err(|e| SendError::DkimSign(format!("invalid DKIM domain: {e}")))?;
let path = DkimKeyPath::parse(key_path)
.map_err(|e| SendError::DkimSign(format!("DKIM key path invalid: {e}")))?;
DkimSigner::load(selector, domain, path).map(Some)
}
#[async_trait]
impl CommsSender for EmailSender {
fn channel(&self) -> CommsChannel {
CommsChannel::Email
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let mut message = message::build(&self.from, notification)?;
if let Some(signer) = &self.dkim {
signer.sign(&mut message);
}
match transport::dispatch(&self.mode, message).await {
Ok(()) => Ok(()),
Err(e) => {
warn!(comms_id = %notification.id, error = %e, "SMTP send failed");
Err(e)
}
}
}
}
+250
View File
@@ -0,0 +1,250 @@
use hickory_resolver::TokioAsyncResolver;
use hickory_resolver::error::{ResolveError, ResolveErrorKind};
use hickory_resolver::proto::op::ResponseCode;
use rand::seq::SliceRandom;
use super::types::{EmailDomain, MxHost, MxPriority, MxRecord};
use crate::sender::SendError;
pub async fn resolve(
resolver: &TokioAsyncResolver,
domain: &EmailDomain,
) -> Result<Vec<MxRecord>, SendError> {
match resolver.mx_lookup(domain.as_str()).await {
Ok(lookup) => interpret_lookup(
lookup
.iter()
.map(|mx| (mx.preference(), mx.exchange().clone())),
domain,
),
Err(e) => classify_lookup_error(e, domain),
}
}
fn interpret_lookup(
items: impl IntoIterator<Item = (u16, hickory_resolver::Name)>,
domain: &EmailDomain,
) -> Result<Vec<MxRecord>, SendError> {
let entries: Vec<_> = items.into_iter().collect();
match entries.iter().any(|(_, name)| name.is_root()) {
true => Err(SendError::DnsPermanent(format!(
"null MX record at {}: domain refuses mail",
domain.as_str()
))),
false => {
let records: Vec<MxRecord> = entries
.into_iter()
.filter_map(|(prio, name)| {
MxHost::parse(&name.to_utf8()).ok().map(|host| MxRecord {
priority: MxPriority::new(prio),
host,
})
})
.collect();
match records.is_empty() {
true => implicit_mx(domain),
false => Ok(prioritize(records)),
}
}
}
}
fn prioritize(mut records: Vec<MxRecord>) -> Vec<MxRecord> {
records.shuffle(&mut rand::thread_rng());
records.sort_by_key(|r| r.priority);
records
}
fn classify_lookup_error(
e: ResolveError,
domain: &EmailDomain,
) -> Result<Vec<MxRecord>, SendError> {
match e.kind() {
ResolveErrorKind::NoRecordsFound { response_code, .. } => match *response_code {
ResponseCode::NoError => implicit_mx(domain),
ResponseCode::NXDomain => Err(SendError::DnsPermanent(format!(
"domain {} does not exist",
domain.as_str()
))),
other => Err(SendError::DnsTransient(format!(
"MX lookup for {} failed with {other}",
domain.as_str()
))),
},
_ => Err(SendError::DnsTransient(e.to_string())),
}
}
fn implicit_mx(domain: &EmailDomain) -> Result<Vec<MxRecord>, SendError> {
MxHost::parse(domain.as_str())
.map(|host| {
vec![MxRecord {
priority: MxPriority::new(0),
host,
}]
})
.map_err(|e| SendError::DnsPermanent(format!("invalid recipient domain: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn record(prio: u16, host: &str) -> MxRecord {
MxRecord {
priority: MxPriority::new(prio),
host: MxHost::parse(host).unwrap(),
}
}
#[test]
fn prioritize_sorts_by_priority_ascending() {
let result = prioritize(vec![
record(20, "mx2.nel.pet"),
record(10, "mx1.nel.pet"),
record(10, "mx1b.nel.pet"),
]);
assert_eq!(result[0].priority.as_u16(), 10);
assert_eq!(result[1].priority.as_u16(), 10);
assert_eq!(result[2].priority.as_u16(), 20);
}
#[test]
fn prioritize_randomizes_equal_priority_order() {
let attempts: Vec<Vec<String>> = (0..200)
.map(|_| {
prioritize(vec![
record(10, "a.nel.pet"),
record(10, "b.nel.pet"),
record(10, "c.nel.pet"),
record(10, "d.nel.pet"),
])
.into_iter()
.map(|r| r.host.as_str().to_string())
.collect()
})
.collect();
let distinct: std::collections::HashSet<_> = attempts.iter().cloned().collect();
assert!(
distinct.len() > 1,
"equal-priority MX order should vary across calls; got only {}",
distinct.len()
);
}
#[test]
fn implicit_mx_uses_domain_as_host() {
let d = EmailDomain::parse("nel.pet").unwrap();
let result = implicit_mx(&d).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].priority.as_u16(), 0);
assert_eq!(result[0].host.as_str(), "nel.pet");
}
#[test]
fn no_error_response_yields_implicit_mx() {
let d = EmailDomain::parse("nel.pet").unwrap();
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
query: Box::new(hickory_resolver::proto::op::Query::default()),
soa: None,
negative_ttl: None,
response_code: ResponseCode::NoError,
trusted: false,
});
let result = classify_lookup_error(err, &d).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].host.as_str(), "nel.pet");
}
#[test]
fn nxdomain_response_is_permanent() {
let d = EmailDomain::parse("does-not-exist.invalid").unwrap();
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
query: Box::new(hickory_resolver::proto::op::Query::default()),
soa: None,
negative_ttl: None,
response_code: ResponseCode::NXDomain,
trusted: true,
});
match classify_lookup_error(err, &d) {
Err(SendError::DnsPermanent(_)) => {}
other => panic!("expected DnsPermanent, got {other:?}"),
}
}
#[test]
fn servfail_response_is_transient() {
let d = EmailDomain::parse("nel.pet").unwrap();
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
query: Box::new(hickory_resolver::proto::op::Query::default()),
soa: None,
negative_ttl: None,
response_code: ResponseCode::ServFail,
trusted: false,
});
match classify_lookup_error(err, &d) {
Err(SendError::DnsTransient(_)) => {}
other => panic!("expected DnsTransient, got {other:?}"),
}
}
#[test]
fn timeout_is_transient() {
let d = EmailDomain::parse("nel.pet").unwrap();
let err = ResolveError::from(ResolveErrorKind::Timeout);
match classify_lookup_error(err, &d) {
Err(SendError::DnsTransient(_)) => {}
other => panic!("expected DnsTransient, got {other:?}"),
}
}
#[test]
fn message_variant_is_transient() {
let d = EmailDomain::parse("nel.pet").unwrap();
let err = ResolveError::from(ResolveErrorKind::Message("transient resolver glitch"));
match classify_lookup_error(err, &d) {
Err(SendError::DnsTransient(_)) => {}
other => panic!("expected DnsTransient default, got {other:?}"),
}
}
#[test]
fn null_mx_is_permanent() {
let d = EmailDomain::parse("nomail.nel.pet").unwrap();
let result = interpret_lookup(vec![(0, hickory_resolver::Name::root())], &d);
match result {
Err(SendError::DnsPermanent(msg)) => {
assert!(msg.contains("null MX"), "msg: {msg}")
}
other => panic!("expected DnsPermanent, got {other:?}"),
}
}
#[test]
fn null_mx_alongside_real_records_still_permanent() {
let d = EmailDomain::parse("mixed.nel.pet").unwrap();
let real = hickory_resolver::Name::from_ascii("mx1.nel.pet.").unwrap();
let result = interpret_lookup(vec![(10, real), (0, hickory_resolver::Name::root())], &d);
assert!(matches!(result, Err(SendError::DnsPermanent(_))));
}
#[test]
fn empty_lookup_uses_implicit_mx() {
let d = EmailDomain::parse("nel.pet").unwrap();
let result = interpret_lookup(Vec::<(u16, hickory_resolver::Name)>::new(), &d).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].host.as_str(), "nel.pet");
}
#[test]
fn valid_records_pass_through_with_priority_sort() {
let d = EmailDomain::parse("nel.pet").unwrap();
let mx1 = hickory_resolver::Name::from_ascii("mx1.nel.pet.").unwrap();
let mx2 = hickory_resolver::Name::from_ascii("mx2.nel.pet.").unwrap();
let result = interpret_lookup(vec![(20, mx2), (10, mx1)], &d).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].priority.as_u16(), 10);
assert_eq!(result[0].host.as_str(), "mx1.nel.pet");
assert_eq!(result[1].priority.as_u16(), 20);
}
}
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAtsQsUV8QpqrygsY+2+JCQ6Fw8/omM71IM2N/R8pPbzbgOl0p
78MZGsgPOQ2HSznjD0FPzsH8oO2B5Uftws04LHb2HJAYlz25+lN5cqfHAfa3fgmC
38FfwBkn7l582UtPWZ/wcBOnyCgb3yLcvJrXyrt8QxHJgvWO23ITrUVYszImbXQ6
7YGS0YhMrbixRzmo2tpm3JcIBtnHrEUMsT0NfFdfsZhTT8YbxBvA8FdODgEwx7u/
vf3J9qbi4+Kv8cvqyJuleIRSjVXPsIMnoejIn04APPKIjpMyQdnWlby7rNyQtE4+
CV+jcFjqJbE/Xilcvqxt6DirjFCvYeKYl1uHLwIDAQABAoIBAH7Mg2LA7bB0EWQh
XiL3SrnZG6BpAHAM9jaQ5RFNjua9z7suP5YUaSpnegg/FopeUuWWjmQHudl8bg5A
ZPgtoLdYoU8XubfUH19I4o1lUXBPVuaeeqn6Yw/HZCjAbSXkVdz8VbesK092ZD/e
0/4V/3irsn5lrMSq0L322yfvYKaRDFxKCF7UMnWrGcHZl6Msbv/OffLRk19uYB7t
4WGhK1zCfKIfgdLJnD0eoI6Q4wU6sJvvpyTe8NDDo8HpdAwNn3YSahSewKp9gHgg
VIQlTZUdsHxM+R+2RUwJZYj9WSTbq+s1nKICUmjQBPnWbrPW963BE5utQPFt3mOe
EWRzdsECgYEA3MBhJC1Okq+u5yrFE8plufdwNvm9fg5uYUYafvdlQiXsFTx+XDGm
FXpuWhP/bheOh1jByzPZ1rvjF57xiZjkIuzcvtePTs/b5fT82K7CydDchkc8qb0W
2dI40h+13e++sUPKYdC9aqjZHzOgl3kOlkDbyRCF3F8mNDujE49rLWcCgYEA0/MU
dX5A6VSDb5K+JCNq8vDaBKNGU8GAr2fpYAhtk/3mXLI+/Z0JN0di9ZgeNhhJr2jN
11OU/2pOButpsgnkIo2y36cOQPf5dQpSgXZke3iNDld3osuLIuPNJn/3C087AtOq
+w4YxZClZLAxiLCqX8SBVrB2IiFCQ70SJ++n8vkCgYEAzmi3rBsNEA1jblVIh1PF
wJhD/bOQ4nBd92iUV8m9jZdl4wl4YX4u/IBI9MMkIG24YIe2VOl7s9Rk5+4/jNg/
4QQ2998Y6aljxOZJEdZ+3jQELy4m49OhrTRq2ta5t/Z3CMsJTmLe6f9NXWZpr5iK
8iVdHOjtMXxqfYaR2jVNEtsCgYAl9uWUQiAoa037v0I1wO5YQ9IZgJGJUSDWynsg
C4JtPs5zji4ASY+sCipsqWnH8MPKGrC8QClxMr51ONe+30yw78a5jvfbpU9Wqpmq
vOU0xJwnlH1GeMUcY8eMfOFocjG0yOtYeubvBIDLr0/AFzz9WHp+Z69RX7m53nUR
GDlyKQKBgDGZVAbUBiB8rerqNbONBAxfipoa4IJ+ntBrFT2DtoIZNbSzaoK+nVbH
kbWMJycaV5PVOh1lfAiZeWCxQz5RcZh/RS8USnxyMG1j4dP/wLcbdasI8uRaSC6Y
hFHL5HjhLrIo0HRWySS2b2ztBI2FP1M+MaaGFPHDzm2OyZg85yr3
-----END RSA PRIVATE KEY-----
@@ -0,0 +1,164 @@
use std::sync::Arc;
use std::time::Duration;
use futures::StreamExt;
use hickory_resolver::TokioAsyncResolver;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::transport::smtp::Error as SmtpError;
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::transport::smtp::extension::ClientId;
use lettre::{AsyncTransport, Message, Tokio1Executor};
use tokio::sync::Semaphore;
use super::message::recipient_domain;
use super::mx;
use super::types::{HeloName, MxRecord};
use crate::sender::SendError;
pub enum SendMode {
Smarthost {
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
total_timeout: Duration,
},
DirectMx {
resolver: Arc<TokioAsyncResolver>,
helo: HeloName,
command_timeout: Duration,
total_timeout: Duration,
require_tls: bool,
inflight: Arc<Semaphore>,
},
}
impl std::fmt::Debug for SendMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Smarthost { total_timeout, .. } => {
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
}
Self::DirectMx {
helo, require_tls, ..
} => write!(
f,
"SendMode::DirectMx({}, require_tls={require_tls})",
helo.as_str()
),
}
}
}
pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError> {
match mode {
SendMode::Smarthost {
transport,
total_timeout,
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
SendMode::DirectMx {
resolver,
helo,
command_timeout,
total_timeout,
require_tls,
inflight,
} => {
with_total_timeout(*total_timeout, async {
let _permit =
inflight.clone().acquire_owned().await.map_err(|_| {
SendError::SmtpTransient("send semaphore closed".to_string())
})?;
send_direct(
resolver.as_ref(),
helo,
*command_timeout,
*require_tls,
message,
)
.await
})
.await
}
}
}
async fn with_total_timeout<F: std::future::Future<Output = Result<(), SendError>>>(
total: Duration,
fut: F,
) -> Result<(), SendError> {
tokio::time::timeout(total, fut)
.await
.unwrap_or(Err(SendError::Timeout))
}
async fn run_send(
transport: &AsyncSmtpTransport<Tokio1Executor>,
message: Message,
) -> Result<(), SendError> {
transport
.send(message)
.await
.map(|_| ())
.map_err(classify_smtp_error)
}
async fn send_direct(
resolver: &TokioAsyncResolver,
helo: &HeloName,
command_timeout: Duration,
require_tls: bool,
message: Message,
) -> Result<(), SendError> {
let domain = recipient_domain(&message)?;
let mxs = mx::resolve(resolver, &domain).await?;
let outcome = futures::stream::iter(mxs)
.fold(None::<Result<(), SendError>>, |acc, mx_record| {
let message = message.clone();
async move {
match &acc {
Some(Ok(())) | Some(Err(SendError::SmtpPermanent(_))) => acc,
_ => Some(
attempt_one_host(mx_record, helo, command_timeout, require_tls, message)
.await,
),
}
}
})
.await;
outcome.unwrap_or_else(|| {
Err(SendError::SmtpTransient(format!(
"no MX records returned for {}",
domain.as_str()
)))
})
}
async fn attempt_one_host(
mx_record: MxRecord,
helo: &HeloName,
command_timeout: Duration,
require_tls: bool,
message: Message,
) -> Result<(), SendError> {
let host = mx_record.host.as_str().to_string();
let tls_params = TlsParameters::new(host.clone())
.map_err(|e| SendError::SmtpTransient(format!("TLS params for {host}: {e}")))?;
let tls = match require_tls {
true => Tls::Required(tls_params),
false => Tls::Opportunistic(tls_params),
};
let transport: AsyncSmtpTransport<Tokio1Executor> =
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host)
.port(25)
.tls(tls)
.hello_name(ClientId::Domain(helo.as_str().to_string()))
.timeout(Some(command_timeout))
.build();
run_send(&transport, message).await
}
fn classify_smtp_error(e: SmtpError) -> SendError {
match () {
_ if e.is_permanent() => SendError::SmtpPermanent(e.to_string()),
_ if e.is_timeout() => SendError::Timeout,
_ => SendError::SmtpTransient(e.to_string()),
}
}
+291
View File
@@ -0,0 +1,291 @@
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("empty value")]
Empty,
#[error("invalid character {0:?}")]
InvalidChar(char),
#[error("zero {0}")]
Zero(&'static str),
#[error("invalid TLS mode {0:?}")]
InvalidTlsMode(String),
}
fn parse_token(raw: &str, lowercase: bool, strip_trailing_dot: bool) -> Result<String, ParseError> {
let mut s = raw.trim();
if strip_trailing_dot {
s = s.trim_end_matches('.');
}
match s {
"" => Err(ParseError::Empty),
_ if s.chars().any(char::is_whitespace) => Err(ParseError::InvalidChar(' ')),
_ => Ok(match lowercase {
true => s.to_lowercase(),
false => s.to_string(),
}),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SmtpHost(String);
impl SmtpHost {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
parse_token(raw, true, false).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SmtpPort(u16);
impl SmtpPort {
pub fn parse(raw: u16) -> Result<Self, ParseError> {
match raw {
0 => Err(ParseError::Zero("smtp port")),
n => Ok(Self(n)),
}
}
pub fn as_u16(self) -> u16 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct HeloName(String);
impl HeloName {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
parse_token(raw, false, false).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EmailDomain(String);
impl EmailDomain {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
parse_token(raw, true, true).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MxHost(String);
impl MxHost {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
parse_token(raw, true, true).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct MxPriority(u16);
impl MxPriority {
pub fn new(value: u16) -> Self {
Self(value)
}
pub fn as_u16(self) -> u16 {
self.0
}
}
#[derive(Debug, Clone)]
pub struct MxRecord {
pub priority: MxPriority,
pub host: MxHost,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DkimSelector(String);
impl DkimSelector {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
let trimmed = raw.trim();
let valid = !trimmed.is_empty() && trimmed.split('.').all(valid_subdomain);
match valid {
true => Ok(Self(trimmed.to_string())),
false => Err(ParseError::InvalidChar('?')),
}
}
pub fn into_inner(self) -> String {
self.0
}
}
fn valid_subdomain(seg: &str) -> bool {
let starts_alnum = seg
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric());
let ends_alnum = seg
.chars()
.next_back()
.is_some_and(|c| c.is_ascii_alphanumeric());
let body_ok = seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
starts_alnum && ends_alnum && body_ok
}
#[derive(Debug, Clone)]
pub struct DkimKeyPath(PathBuf);
impl DkimKeyPath {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
let trimmed = raw.trim();
match trimmed.is_empty() {
true => Err(ParseError::Empty),
false => Ok(Self(PathBuf::from(trimmed))),
}
}
pub fn as_path(&self) -> &std::path::Path {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SmtpUsername(String);
impl SmtpUsername {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
match raw.is_empty() {
true => Err(ParseError::Empty),
false => Ok(Self(raw.to_string())),
}
}
pub fn into_inner(self) -> String {
self.0
}
}
#[derive(Clone)]
pub struct SmtpPassword(secrecy::SecretString);
impl SmtpPassword {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
match raw.is_empty() {
true => Err(ParseError::Empty),
false => Ok(Self(secrecy::SecretString::from(raw.to_string()))),
}
}
pub fn expose(&self) -> &str {
use secrecy::ExposeSecret;
self.0.expose_secret()
}
}
impl std::fmt::Debug for SmtpPassword {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SmtpPassword(***)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TlsMode {
Implicit,
Starttls,
None,
}
impl TlsMode {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
match raw.to_ascii_lowercase().as_str() {
"implicit" => Ok(Self::Implicit),
"starttls" => Ok(Self::Starttls),
"none" => Ok(Self::None),
other => Err(ParseError::InvalidTlsMode(other.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smtp_host_lowercases_and_trims() {
let h = SmtpHost::parse(" SMTP.NEL.PET ").unwrap();
assert_eq!(h.as_str(), "smtp.nel.pet");
}
#[test]
fn smtp_host_rejects_whitespace() {
assert!(SmtpHost::parse("a b").is_err());
}
#[test]
fn smtp_host_rejects_empty() {
assert!(SmtpHost::parse("").is_err());
assert!(SmtpHost::parse(" ").is_err());
}
#[test]
fn smtp_port_rejects_zero() {
assert!(SmtpPort::parse(0).is_err());
assert_eq!(SmtpPort::parse(587).unwrap().as_u16(), 587);
}
#[test]
fn email_domain_strips_trailing_dot() {
assert_eq!(EmailDomain::parse("Nel.pet.").unwrap().as_str(), "nel.pet");
}
#[test]
fn dkim_selector_validates() {
assert!(DkimSelector::parse("default").is_ok());
assert!(DkimSelector::parse("s1.nel.pet").is_ok());
assert!(DkimSelector::parse("s2024-q1").is_ok());
assert!(DkimSelector::parse("mailo-2024.nel.pet").is_ok());
assert!(DkimSelector::parse("a-b").is_ok());
assert!(DkimSelector::parse("").is_err());
assert!(DkimSelector::parse("a..b").is_err());
assert!(DkimSelector::parse("-leading").is_err());
assert!(DkimSelector::parse("trailing-").is_err());
assert!(DkimSelector::parse("s_under").is_err());
}
#[test]
fn tls_mode_parses_known_modes() {
assert_eq!(TlsMode::parse("STARTTLS").unwrap(), TlsMode::Starttls);
assert_eq!(TlsMode::parse("implicit").unwrap(), TlsMode::Implicit);
assert_eq!(TlsMode::parse("none").unwrap(), TlsMode::None);
assert!(TlsMode::parse("garbage").is_err());
}
#[test]
fn smtp_password_redacts_in_debug() {
let p = SmtpPassword::parse("hunter2").unwrap();
let dbg = format!("{:?}", p);
assert_eq!(dbg, "SmtpPassword(***)");
assert!(!dbg.contains("hunter2"));
}
}
+4 -2
View File
@@ -1,13 +1,15 @@
pub mod email;
mod locale;
mod sender;
mod types;
pub use email::EmailSender;
pub use locale::{
DEFAULT_LOCALE, NotificationStrings, VALID_LOCALES, format_message, get_strings,
validate_locale,
};
pub use sender::{
CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender,
is_valid_phone_number, is_valid_signal_username, mime_encode_header, sanitize_header_value,
CommsSender, DiscordSender, SendError, SignalSender, TelegramSender, is_valid_phone_number,
is_valid_signal_username,
};
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+37 -1
View File
@@ -1,5 +1,5 @@
pub const DEFAULT_LOCALE: &str = "en";
pub const VALID_LOCALES: &[&str] = &["en", "zh", "ja", "ko", "sv", "fi"];
pub const VALID_LOCALES: &[&str] = &["en", "zh", "ja", "ko", "sv", "fi", "fr"];
pub fn validate_locale(locale: &str) -> &str {
if VALID_LOCALES.contains(&locale) {
@@ -44,6 +44,7 @@ pub fn get_strings(locale: &str) -> &'static NotificationStrings {
"ko" => &STRINGS_KO,
"sv" => &STRINGS_SV,
"fi" => &STRINGS_FI,
"fr" => &STRINGS_FR,
_ => &STRINGS_EN,
}
}
@@ -216,6 +217,34 @@ static STRINGS_FI: NotificationStrings = NotificationStrings {
channel_verification_body: "Vahvistuskoodisi on:\n{code}\n\nTai vahvista suoraan:\n{verify_link}",
};
static STRINGS_FR: NotificationStrings = NotificationStrings {
welcome_subject: "Bienvenue sur {hostname}",
welcome_body: "Bienvenue sur {hostname} !\n\nVotre identifiant est : @{handle}\n\nMerci de nous avoir rejoint.",
password_reset_subject: "Réinitialisation du mot de passe - {hostname}",
password_reset_body: "Bonjour @{handle},\n\nVotre code de réinitialisation du mot de passe est : {code}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, veuillez ignorer ce message.",
email_update_subject: "Confirmer votre nouvelle adresse e-mail - {hostname}",
email_update_body: "Bonjour @{handle},\n\nVotre code de vérification est :\n{code}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{verify_page}\n\nCe code expirera dans 10 minutes.\n\nOu si vous aimez vivre dangereusement :\n{verify_link}\n\nSi vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
short_token_body: "Bonjour @{handle},\n\nVotre code de vérification est :\n{code}\n\nCe code expirera dans 15 minutes.\n\nSi vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
account_deletion_subject: "Demande de suppression de compte - {hostname}",
account_deletion_body: "Bonjour @{handle},\n\nVotre code de confirmation de suppression de compte est : {code}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, sécurisez votre compte immédiatement.",
plc_operation_subject: "{hostname} - Jeton d'opération PLC",
plc_operation_body: "Bonjour @{handle},\n\nVous avez demandé à signer une opération PLC pour votre compte.\n\nVotre jeton de vérification est : {token}\n\nCe jeton expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, vous pouvez ignorer ce message en toute sécurité.",
two_factor_code_subject: "Vérification de connexion - {hostname}",
two_factor_code_body: "Bonjour @{handle},\n\nVotre code de vérification de connexion est : {code}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, sécurisez votre compte immédiatement.",
passkey_recovery_subject: "Récupération de compte - {hostname}",
passkey_recovery_body: "Bonjour @{handle},\n\nVous avez demandé la récupération de votre compte à clé d'accès uniquement.\n\nCliquez sur le lien ci-dessous pour définir un mot de passe temporaire et retrouver l'accès :\n{url}\n\nCe lien expirera dans 1 heure.\n\nSi vous n'avez pas demandé cela, veuillez ignorer ce message. Votre compte reste sécurisé.",
signup_verification_subject: "Vérifier votre compte - {hostname}",
signup_verification_body: "Bienvenue ! Votre code de vérification est :\n{code}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{verify_page}\n\nCe code expirera dans 30 minutes.\n\nOu si vous aimez vivre dangereusement :\n{verify_link}\n\nSi vous n'avez pas créé de compte sur {hostname}, veuillez ignorer ce message.",
legacy_login_subject: "Alerte de sécurité : Connexion classique détectée - {hostname}",
legacy_login_body: "Bonjour @{handle},\n\nUne connexion à votre compte a été détectée via une application classique (comme Bluesky) qui ne prend pas en charge la vérification TOTP.\n\nDétails :\n- Date : {timestamp}\n- Adresse IP : {ip}\n\nVotre protection TOTP a été contournée pour cette connexion. La session dispose de permissions limitées pour les opérations sensibles.\n\nSi ce n'était pas vous :\n1. Changez votre mot de passe immédiatement\n2. Vérifiez vos sessions actives\n3. Envisagez de désactiver les connexions d'applications classiques dans vos paramètres de sécurité\n\nRestez vigilant,\n{hostname}",
migration_verification_subject: "Vérifier votre adresse e-mail - {hostname}",
migration_verification_body: "Bienvenue sur {hostname} !\n\nVotre compte a été migré avec succès. Pour finaliser la configuration, veuillez vérifier votre adresse e-mail.\n\nVotre code de vérification est :\n{code}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{verify_page}\n\nCe code expirera dans 48 heures.\n\nOu si vous aimez vivre dangereusement :\n{verify_link}\n\nSi vous n'avez pas migré votre compte, veuillez ignorer cet e-mail.",
channel_verified_subject: "Canal de notification vérifié - {hostname}",
channel_verified_body: "Bonjour {handle},\n\n{channel} a été vérifié comme canal de notification pour votre compte sur {hostname}.",
channel_verification_subject: "Vérifier votre canal - {hostname}",
channel_verification_body: "Votre code de vérification est :\n{code}\n\nOu vérifiez directement :\n{verify_link}",
};
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
vars.iter()
.fold(template.to_string(), |result, (key, value)| {
@@ -233,6 +262,9 @@ mod tests {
assert_eq!(validate_locale("zh"), "zh");
assert_eq!(validate_locale("ja"), "ja");
assert_eq!(validate_locale("ko"), "ko");
assert_eq!(validate_locale("sv"), "sv");
assert_eq!(validate_locale("fi"), "fi");
assert_eq!(validate_locale("fr"), "fr");
assert_eq!(validate_locale("invalid"), DEFAULT_LOCALE);
assert_eq!(validate_locale(""), DEFAULT_LOCALE);
}
@@ -252,5 +284,9 @@ mod tests {
let zh = get_strings("zh");
assert!(zh.welcome_subject.contains("{hostname}"));
assert!(zh.welcome_body.contains("欢迎"));
let fr = get_strings("fr");
assert!(fr.welcome_subject.contains("{hostname}"));
assert!(fr.welcome_body.contains("Bienvenue"));
}
}
+60 -113
View File
@@ -1,11 +1,7 @@
use async_trait::async_trait;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use reqwest::Client;
use serde_json::json;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use super::types::{CommsChannel, QueuedComms};
@@ -21,25 +17,51 @@ pub trait CommsSender: Send + Sync {
#[derive(Debug, thiserror::Error)]
pub enum SendError {
#[error("Failed to spawn {command}: {source}")]
ProcessSpawn {
command: String,
source: std::io::Error,
},
#[error("{command} exited with non-zero status: {detail}")]
ProcessFailed { command: String, detail: String },
#[error("Channel not configured: {0:?}")]
NotConfigured(CommsChannel),
#[error("External service error: {0}")]
ExternalService(String),
#[error("Email configuration invalid: {0}")]
ConfigInvalid(String),
#[error("Invalid recipient format: {0}")]
InvalidRecipient(String),
#[error("Message construction failed: {0}")]
MessageBuild(String),
#[error("transient DNS lookup failure: {0}")]
DnsTransient(String),
#[error("permanent DNS lookup failure: {0}")]
DnsPermanent(String),
#[error("SMTP transient error: {0}")]
SmtpTransient(String),
#[error("SMTP permanent error: {0}")]
SmtpPermanent(String),
#[error("DKIM signing failed: {0}")]
DkimSign(String),
#[error("External service error: {0}")]
ExternalService(String),
#[error("Request timeout")]
Timeout,
#[error("Max retries exceeded: {0}")]
MaxRetriesExceeded(String),
}
impl SendError {
pub fn is_permanent(&self) -> bool {
match self {
Self::SmtpPermanent(_)
| Self::DnsPermanent(_)
| Self::InvalidRecipient(_)
| Self::MessageBuild(_)
| Self::DkimSign(_)
| Self::ConfigInvalid(_) => true,
Self::SmtpTransient(_)
| Self::DnsTransient(_)
| Self::Timeout
| Self::ExternalService(_)
| Self::MaxRetriesExceeded(_)
| Self::NotConfigured(_) => false,
}
}
}
fn create_http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
@@ -100,19 +122,6 @@ where
))
}
pub fn sanitize_header_value(value: &str) -> String {
value.replace(['\r', '\n'], " ").trim().to_string()
}
pub fn mime_encode_header(value: &str) -> String {
if value.is_ascii() {
sanitize_header_value(value)
} else {
let sanitized = sanitize_header_value(value);
format!("=?UTF-8?B?{}?=", BASE64.encode(sanitized.as_bytes()))
}
}
pub fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
@@ -135,93 +144,6 @@ pub fn is_valid_signal_username(username: &str) -> bool {
tranquil_signal::SignalUsername::parse(username).is_ok()
}
pub struct EmailSender {
from_address: String,
from_name: String,
sendmail_path: String,
}
impl EmailSender {
pub fn new(from_address: String, from_name: String, sendmail_path: String) -> Self {
Self {
from_address,
from_name,
sendmail_path,
}
}
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let from_address = cfg.email.from_address.clone()?;
let from_name = cfg.email.from_name.clone();
let sendmail_path = cfg.email.sendmail_path.clone();
Some(Self::new(from_address, from_name, sendmail_path))
}
pub fn format_email(&self, notification: &QueuedComms) -> String {
let subject = mime_encode_header(notification.subject.as_deref().unwrap_or("Notification"));
let recipient = sanitize_header_value(&notification.recipient);
let from_header = if self.from_name.is_empty() {
self.from_address.clone()
} else {
format!(
"{} <{}>",
sanitize_header_value(&self.from_name),
self.from_address
)
};
format!(
"From: {}\r\nTo: {}\r\nSubject: {}\r\nContent-Type: text/plain; charset=utf-8\r\nMIME-Version: 1.0\r\n\r\n{}",
from_header, recipient, subject, notification.body
)
}
}
#[async_trait]
impl CommsSender for EmailSender {
fn channel(&self) -> CommsChannel {
CommsChannel::Email
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let email_content = self.format_email(notification);
let mut child = Command::new(&self.sendmail_path)
.arg("-t")
.arg("-oi")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| SendError::ProcessSpawn {
command: self.sendmail_path.clone(),
source: e,
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(email_content.as_bytes())
.await
.map_err(|e| SendError::ProcessSpawn {
command: self.sendmail_path.clone(),
source: e,
})?;
}
let output = child
.wait_with_output()
.await
.map_err(|e| SendError::ProcessSpawn {
command: self.sendmail_path.clone(),
source: e,
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SendError::ProcessFailed {
command: self.sendmail_path.clone(),
detail: stderr.to_string(),
});
}
Ok(())
}
}
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
#[derive(Clone)]
@@ -610,3 +532,28 @@ impl CommsSender for SignalSender {
))
}
}
#[cfg(test)]
mod is_permanent_matrix {
use super::{CommsChannel, SendError};
#[test]
fn permanent_variants_are_permanent() {
assert!(SendError::SmtpPermanent("x".into()).is_permanent());
assert!(SendError::DnsPermanent("x".into()).is_permanent());
assert!(SendError::InvalidRecipient("x".into()).is_permanent());
assert!(SendError::MessageBuild("x".into()).is_permanent());
assert!(SendError::DkimSign("x".into()).is_permanent());
assert!(SendError::ConfigInvalid("x".into()).is_permanent());
}
#[test]
fn transient_variants_are_not_permanent() {
assert!(!SendError::SmtpTransient("x".into()).is_permanent());
assert!(!SendError::DnsTransient("x".into()).is_permanent());
assert!(!SendError::Timeout.is_permanent());
assert!(!SendError::ExternalService("x".into()).is_permanent());
assert!(!SendError::MaxRetriesExceeded("x".into()).is_permanent());
assert!(!SendError::NotConfigured(CommsChannel::Email).is_permanent());
}
}
+158
View File
@@ -0,0 +1,158 @@
use std::time::Duration;
use chrono::Utc;
use lettre::message::Mailbox;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::transport::smtp::extension::ClientId;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
use tranquil_comms::email::transport::SendMode;
use tranquil_comms::email::{EmailSender, types::HeloName};
use tranquil_comms::{CommsChannel, CommsSender, CommsStatus, CommsType, QueuedComms, SendError};
use uuid::Uuid;
fn fixture(recipient: &str, subject: &str, body: &str) -> QueuedComms {
QueuedComms {
id: Uuid::new_v4(),
user_id: None,
channel: CommsChannel::Email,
comms_type: CommsType::Welcome,
status: CommsStatus::Pending,
recipient: recipient.to_string(),
subject: Some(subject.to_string()),
body: body.to_string(),
metadata: None,
attempts: 0,
max_attempts: 3,
last_error: None,
created_at: Utc::now(),
updated_at: Utc::now(),
scheduled_for: Utc::now(),
processed_at: None,
}
}
fn build_smarthost_sender(host: &str, port: u16) -> EmailSender {
build_smarthost_sender_with_total_timeout(host, port, Duration::from_secs(10))
}
fn build_smarthost_sender_with_total_timeout(
host: &str,
port: u16,
total_timeout: Duration,
) -> EmailSender {
let from: Mailbox = "Tranquil Test <noreply@nel.pet>".parse().unwrap();
let helo = HeloName::parse("mta.nel.pet").unwrap();
let transport = AsyncSmtpTransport::<lettre::Tokio1Executor>::builder_dangerous(host)
.port(port)
.hello_name(ClientId::Domain(helo.into_inner()))
.timeout(Some(Duration::from_secs(5)))
.build();
EmailSender::new(
from,
SendMode::Smarthost {
transport: Box::new(transport),
total_timeout,
},
None,
)
}
async fn drive_stub(stream: TcpStream, rcpt_response: &'static [u8]) -> std::io::Result<()> {
let (read, mut write) = stream.into_split();
let mut reader = BufReader::new(read);
write.write_all(b"220 stub ESMTP\r\n").await?;
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).await?;
if n == 0 {
return Ok(());
}
let upper = line.to_ascii_uppercase();
let response: &[u8] = match upper.split_whitespace().next() {
Some("EHLO") | Some("HELO") => b"250-stub\r\n250 SIZE 10240000\r\n",
Some("MAIL") => b"250 OK\r\n",
Some("RCPT") => rcpt_response,
Some("DATA") => b"354 end with .\r\n",
Some("RSET") => b"250 OK\r\n",
Some("QUIT") => b"221 bye\r\n",
_ => b"500 unknown\r\n",
};
write.write_all(response).await?;
if upper.starts_with("QUIT") {
return Ok(());
}
}
}
async fn spawn_stub(rcpt_response: &'static [u8]) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let _ = drive_stub(stream, rcpt_response).await;
});
port
}
#[tokio::test]
async fn rcpt_550_classifies_as_smtp_permanent() {
let port = spawn_stub(b"550 5.1.1 user unknown\r\n").await;
let sender = build_smarthost_sender("127.0.0.1", port);
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
match result {
Err(SendError::SmtpPermanent(_)) => {}
other => panic!("expected SmtpPermanent, got {other:?}"),
}
}
#[tokio::test]
async fn rcpt_421_classifies_as_smtp_transient() {
let port = spawn_stub(b"421 4.7.0 try again later\r\n").await;
let sender = build_smarthost_sender("127.0.0.1", port);
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
match result {
Err(SendError::SmtpTransient(_)) => {}
other => panic!("expected SmtpTransient, got {other:?}"),
}
}
#[tokio::test]
async fn invalid_recipient_classifies_as_invalid_recipient() {
let port = spawn_stub(b"250 OK\r\n").await;
let sender = build_smarthost_sender("127.0.0.1", port);
let result = sender.send(&fixture("not-an-address", "x", "x")).await;
match result {
Err(SendError::InvalidRecipient(_)) => {}
other => panic!("expected InvalidRecipient, got {other:?}"),
}
}
async fn spawn_silent_stub() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (_stream, _) = listener.accept().await.unwrap();
std::future::pending::<()>().await;
});
port
}
#[tokio::test]
async fn smarthost_silent_relay_hits_total_timeout() {
let port = spawn_silent_stub().await;
let sender =
build_smarthost_sender_with_total_timeout("127.0.0.1", port, Duration::from_millis(500));
let start = std::time::Instant::now();
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
let elapsed = start.elapsed();
match result {
Err(SendError::Timeout) => {}
other => panic!("expected Timeout, got {other:?}"),
}
assert!(
elapsed < Duration::from_secs(2),
"send returned in {elapsed:?}, expected close to 500ms total_timeout"
);
}
+688 -14
View File
@@ -5,6 +5,14 @@ use std::sync::OnceLock;
static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
const REMOVED_ENV_VARS: &[(&str, &str)] = &[(
"SENDMAIL_PATH",
"the sendmail-binary transport was replaced with native SMTP. \
Configure MAIL_SMARTHOST_HOST for relay delivery, or leave it unset to \
deliver directly via recipient MX records. See example.toml for the full \
MAIL_* surface.",
)];
/// Errors discovered during configuration validation.
#[derive(Debug)]
pub struct ConfigError {
@@ -96,6 +104,7 @@ pub fn load(config_path: Option<&PathBuf>) -> Result<TranquilConfig, confique::E
// Root configuration
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct TranquilConfig {
#[config(nested)]
pub server: ServerConfig,
@@ -133,9 +142,6 @@ pub struct TranquilConfig {
#[config(nested)]
pub telegram: TelegramConfig,
#[config(nested)]
pub signal: SignalConfig,
#[config(nested)]
pub notifications: NotificationConfig,
@@ -162,6 +168,14 @@ impl TranquilConfig {
pub fn validate(&self, ignore_secrets: bool) -> Result<(), ConfigError> {
let mut errors = Vec::new();
// -- removed config ---------------------------------------------------
errors.extend(
REMOVED_ENV_VARS
.iter()
.filter(|(var, _)| std::env::var_os(var).is_some())
.map(|(var, guidance)| format!("{var} is no longer supported: {guidance}")),
);
// -- secrets ----------------------------------------------------------
if !ignore_secrets && !self.secrets.allow_insecure && !cfg!(test) {
if let Some(ref s) = self.secrets.jwt_secret {
@@ -210,6 +224,10 @@ impl TranquilConfig {
}
}
// -- email -----------------------------------------------------------
self.email
.validate(self.server.hostname_without_port(), &mut errors);
// -- telegram ---------------------------------------------------------
if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() {
errors.push(
@@ -238,6 +256,9 @@ impl TranquilConfig {
}
}
// -- tls --------------------------------------------------------------
self.server.tls.validate(&mut errors);
// -- SSO providers ----------------------------------------------------
self.validate_sso_provider("sso.github", &self.sso.github, &mut errors);
self.validate_sso_provider("sso.google", &self.sso.google, &mut errors);
@@ -397,6 +418,7 @@ impl TranquilConfig {
// ---------------------------------------------------------------------------
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct ServerConfig {
/// Public hostname of the PDS, such as `pds.example.com`.
#[config(env = "PDS_HOSTNAME")]
@@ -436,6 +458,11 @@ pub struct ServerConfig {
#[config(env = "DISABLE_RATE_LIMITING", default = false)]
pub disable_rate_limiting: bool,
/// Skip the verified-comms-channel gate for login and record writes.
/// Please keep this off unless you're an invite-only PDS!
#[config(env = "DISABLE_ACCOUNT_VERIFICATION_GATE", default = false)]
pub disable_account_verification_gate: bool,
/// List of additional banned words for handle validation.
#[config(env = "PDS_BANNED_WORDS", parse_env = split_comma_list)]
pub banned_words: Option<Vec<String>>,
@@ -459,6 +486,52 @@ pub struct ServerConfig {
/// Maximum allowed number of preferences
#[config(env = "MAX_PREFERENCES_COUNT", default = 1000)]
pub max_preferences_count: usize,
/// If you're not altering TLS config, you don't have to worry about this.
/// This is the number of trusted reverse proxies in front of Tranquil.
/// We read the client IP used for rate limiting and device records this many hops
/// from the right of the X-Forwarded-For header.
/// When left unset, Tranquil will assume:
/// - 0, if the TLS termination is happening here on Tranquil via the TLS config
/// - 1, if the TLS termination *isn't* happening here.
#[config(env = "TRUSTED_PROXY_COUNT")]
pub trusted_proxy_count: Option<usize>,
#[config(nested)]
pub tls: TlsConfig,
}
#[derive(Debug, Config)]
pub struct TlsConfig {
/// The path to the TLS cert chain.
/// If you set both this and `key_path`, the server terminates TLS itself rather than expecting
/// a reverse proxy to do it. The certificate and key reload on SIGHUP.
#[config(env = "TLS_CERT_PATH")]
pub cert_path: Option<String>,
/// Path to the TLS private key.
#[config(env = "TLS_KEY_PATH")]
pub key_path: Option<String>,
}
impl TlsConfig {
/// The certificate and key paths when both are configured.
pub fn material(&self) -> Option<(&str, &str)> {
match (self.cert_path.as_deref(), self.key_path.as_deref()) {
(Some(cert), Some(key)) => Some((cert, key)),
_ => None,
}
}
pub fn validate(&self, errors: &mut Vec<String>) {
if self.cert_path.is_some() != self.key_path.is_some() {
errors.push(
"server.tls.cert_path (TLS_CERT_PATH) and server.tls.key_path (TLS_KEY_PATH) \
must both be set to enable app-level TLS, or both be unset"
.to_string(),
);
}
}
}
impl ServerConfig {
@@ -493,6 +566,7 @@ impl ServerConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct FrontendConfig {
/// Whether to enable the built in serving of the frontend.
#[config(env = "FRONTEND_ENABLED", default = true)]
@@ -505,6 +579,7 @@ pub struct FrontendConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct DatabaseConfig {
/// PostgreSQL connection URL.
#[config(env = "DATABASE_URL")]
@@ -524,6 +599,7 @@ pub struct DatabaseConfig {
}
#[derive(Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct SecretsConfig {
/// Secret used for signing JWTs. Must be at least 32 characters in
/// production.
@@ -650,6 +726,7 @@ impl fmt::Display for RepoBackend {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct StorageConfig {
/// Storage backend: `filesystem` or `s3`.
#[config(env = "BLOB_STORAGE_BACKEND", default = "filesystem")]
@@ -682,6 +759,7 @@ impl StorageConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct CacheConfig {
/// Cache backend: `ripple` by default, or `valkey`.
#[config(env = "CACHE_BACKEND", default = "ripple")]
@@ -696,6 +774,7 @@ pub struct CacheConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct PlcConfig {
/// Base URL of the PLC directory.
#[config(env = "PLC_DIRECTORY_URL", default = "https://plc.directory")]
@@ -715,6 +794,7 @@ pub struct PlcConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct FirehoseConfig {
/// Size of the in-memory broadcast buffer for firehose events.
#[config(env = "FIREHOSE_BUFFER_SIZE", default = 10000)]
@@ -745,6 +825,7 @@ impl FirehoseConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct EmailConfig {
/// Sender email address. When unset, email sending is disabled.
#[config(env = "MAIL_FROM_ADDRESS")]
@@ -754,12 +835,285 @@ pub struct EmailConfig {
#[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")]
pub from_name: String,
/// Path to the `sendmail` binary.
#[config(env = "SENDMAIL_PATH", default = "/usr/sbin/sendmail")]
pub sendmail_path: String,
/// HELO/EHLO name announced to remote SMTP servers. Applies to both
/// smarthost and direct-MX modes. Defaults to the server hostname.
#[config(env = "MAIL_HELO_NAME")]
pub helo_name: Option<String>,
#[config(nested)]
pub smarthost: SmarthostConfig,
#[config(nested)]
pub direct_mx: DirectMxConfig,
#[config(nested)]
pub dkim: DkimConfig,
}
impl EmailConfig {
pub fn validate(&self, server_hostname: &str, errors: &mut Vec<String>) {
match self.smarthost.tls.to_ascii_lowercase().as_str() {
"implicit" | "starttls" => {}
"none" => {
if self.smarthost.password.is_some() {
errors.push(
"email.smarthost.tls = \"none\" with email.smarthost.password set \
would transmit credentials in plaintext; use \"starttls\" or \"implicit\""
.to_string(),
);
}
}
other => errors.push(format!(
"email.smarthost.tls must be \"implicit\", \"starttls\", or \"none\", got \"{other}\""
)),
}
let smarthost_host_set = self
.smarthost
.host
.as_deref()
.is_some_and(|h| !h.is_empty());
let username_set = self.smarthost.username.is_some();
let password_set = self.smarthost.password.is_some();
if !smarthost_host_set && (username_set || password_set) {
errors.push(
"email.smarthost.username or email.smarthost.password is set but \
email.smarthost.host is empty; credentials would be silently ignored"
.to_string(),
);
}
if smarthost_host_set && username_set != password_set {
errors.push(
"email.smarthost.username and email.smarthost.password must both be set or \
both unset; otherwise authentication would silently degrade to anonymous"
.to_string(),
);
}
if self.smarthost.command_timeout_secs == 0 {
errors.push("email.smarthost.command_timeout_secs must be at least 1".to_string());
}
if self.smarthost.total_timeout_secs == 0 {
errors.push("email.smarthost.total_timeout_secs must be at least 1".to_string());
}
if self.smarthost.pool_size == 0 {
errors.push("email.smarthost.pool_size must be at least 1".to_string());
}
if self.direct_mx.max_concurrent_sends == 0 {
errors.push("email.direct_mx.max_concurrent_sends must be at least 1".to_string());
}
if self.direct_mx.command_timeout_secs == 0 {
errors.push("email.direct_mx.command_timeout_secs must be at least 1".to_string());
}
if self.direct_mx.total_timeout_secs == 0 {
errors.push("email.direct_mx.total_timeout_secs must be at least 1".to_string());
}
let dkim_set = self.dkim.selector.is_some()
|| self.dkim.domain.is_some()
|| self.dkim.private_key_path.is_some();
if dkim_set {
if self.dkim.selector.is_none() {
errors
.push("email.dkim.selector is required when any DKIM field is set".to_string());
}
if self.dkim.domain.is_none() {
errors.push("email.dkim.domain is required when any DKIM field is set".to_string());
}
if self.dkim.private_key_path.is_none() {
errors.push(
"email.dkim.private_key_path is required when any DKIM field is set"
.to_string(),
);
}
}
let Some(from_address) = self.from_address.as_deref().filter(|s| !s.is_empty()) else {
return;
};
if !looks_like_email_address(from_address) {
errors.push(format!(
"email.from_address {from_address:?} is not a valid email address"
));
}
if self.from_name.chars().any(|c| c.is_control()) {
errors.push("email.from_name must not contain control characters".to_string());
}
let helo_raw = self
.helo_name
.as_deref()
.map(str::to_string)
.unwrap_or_else(|| server_hostname.to_string());
if !is_non_whitespace_token(&helo_raw) {
errors.push(format!(
"email HELO name {helo_raw:?} must be non-empty and contain no whitespace"
));
}
if smarthost_host_set {
let host = self.smarthost.host.as_deref().unwrap_or("");
if !is_non_whitespace_token(host) {
errors.push(format!(
"email.smarthost.host {host:?} must contain no whitespace"
));
}
if self.smarthost.port == 0 {
errors.push("email.smarthost.port must be non-zero".to_string());
}
if let Some(u) = self.smarthost.username.as_deref()
&& u.is_empty()
{
errors.push("email.smarthost.username must be non-empty".to_string());
}
if let Some(p) = self.smarthost.password.as_deref()
&& p.is_empty()
{
errors.push("email.smarthost.password must be non-empty".to_string());
}
}
if let Some(selector) = self.dkim.selector.as_deref()
&& !is_valid_dkim_selector(selector)
{
errors.push(format!(
"email.dkim.selector {selector:?} must be valid subdomain syntax"
));
}
if let Some(domain) = self.dkim.domain.as_deref()
&& !is_non_whitespace_token(domain)
{
errors.push(format!(
"email.dkim.domain {domain:?} must be non-empty and contain no whitespace"
));
}
if let Some(key_path) = self.dkim.private_key_path.as_deref()
&& key_path.trim().is_empty()
{
errors.push("email.dkim.private_key_path must be non-empty".to_string());
}
}
}
fn looks_like_email_address(s: &str) -> bool {
let trimmed = s.trim();
if trimmed.is_empty() || trimmed.chars().any(char::is_whitespace) {
return false;
}
let mut parts = trimmed.split('@');
let local = parts.next().unwrap_or("");
let domain = parts.next().unwrap_or("");
parts.next().is_none() && !local.is_empty() && !domain.is_empty() && domain.contains('.')
}
fn is_non_whitespace_token(s: &str) -> bool {
let trimmed = s.trim();
!trimmed.is_empty() && !trimmed.chars().any(char::is_whitespace)
}
fn is_valid_dkim_selector(s: &str) -> bool {
let trimmed = s.trim();
!trimmed.is_empty()
&& trimmed.split('.').all(|seg| {
let starts_alnum = seg
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric());
let ends_alnum = seg
.chars()
.next_back()
.is_some_and(|c| c.is_ascii_alphanumeric());
let body_ok = seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
starts_alnum && ends_alnum && body_ok
})
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct SmarthostConfig {
/// SMTP relay host. When set, mail is delivered through this host
/// instead of resolving recipient MX records directly.
#[config(env = "MAIL_SMARTHOST_HOST")]
pub host: Option<String>,
/// SMTP relay port.
#[config(env = "MAIL_SMARTHOST_PORT", default = 587)]
pub port: u16,
/// SMTP authentication username.
#[config(env = "MAIL_SMARTHOST_USERNAME")]
pub username: Option<String>,
/// SMTP authentication password.
#[config(env = "MAIL_SMARTHOST_PASSWORD")]
pub password: Option<String>,
/// TLS mode. Valid values: "implicit", "starttls", "none". Setting "none"
/// alongside a password is rejected at startup to prevent transmitting
/// credentials in plaintext.
#[config(env = "MAIL_SMARTHOST_TLS", default = "starttls")]
pub tls: String,
/// Max size of the connection pool.
#[config(env = "MAIL_SMARTHOST_POOL_SIZE", default = 4)]
pub pool_size: u32,
/// Per-command SMTP timeout in seconds. Bounds the security handshake.
#[config(env = "MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS", default = 30)]
pub command_timeout_secs: u64,
/// Total per-message timeout in seconds. Wraps the entire send so a
/// stuck relay cannot stall the comms queue.
#[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)]
pub total_timeout_secs: u64,
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct DirectMxConfig {
/// Per-command SMTP timeout in seconds.
#[config(env = "MAIL_COMMAND_TIMEOUT_SECS", default = 30)]
pub command_timeout_secs: u64,
/// Total per-message timeout across all MX attempts in seconds.
#[config(env = "MAIL_TOTAL_TIMEOUT_SECS", default = 60)]
pub total_timeout_secs: u64,
/// Max number of concurrent direct-MX sends. Limits the load placed
/// on any single recipient MX during a backlog drain.
#[config(env = "MAIL_MAX_CONCURRENT_SENDS", default = 8)]
pub max_concurrent_sends: usize,
/// Require STARTTLS on every MX hop. When false, TLS is
/// attempted opportunistically and the session falls back to plaintext
/// if the remote does not advertise STARTTLS. Set true to refuse
/// plaintext delivery, at the cost of failing sends to MX hosts that
/// do not support TLS.
#[config(env = "MAIL_REQUIRE_TLS", default = false)]
pub require_tls: bool,
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct DkimConfig {
/// DKIM selector. When unset, outgoing mail is not signed.
#[config(env = "MAIL_DKIM_SELECTOR")]
pub selector: Option<String>,
/// DKIM signing domain.
#[config(env = "MAIL_DKIM_DOMAIN")]
pub domain: Option<String>,
/// Path to the DKIM private key in PEM format. Supports RSA and
/// Ed25519 keys.
#[config(env = "MAIL_DKIM_KEY_PATH")]
pub private_key_path: Option<String>,
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct DiscordConfig {
/// Discord bot token. When unset, Discord integration is disabled.
#[config(env = "DISCORD_BOT_TOKEN")]
@@ -767,6 +1121,7 @@ pub struct DiscordConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct TelegramConfig {
/// Telegram bot token. When unset, Telegram integration is disabled.
#[config(env = "TELEGRAM_BOT_TOKEN")]
@@ -778,14 +1133,7 @@ pub struct TelegramConfig {
}
#[derive(Debug, Config)]
pub struct SignalConfig {
/// Protocol state is stored in postgres' signal_* tables.
/// Link a device via the admin API before enabling.
#[config(env = "SIGNAL_ENABLED", default = false)]
pub enabled: bool,
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct NotificationConfig {
/// Polling interval in milliseconds for the comms queue.
#[config(env = "NOTIFICATION_POLL_INTERVAL_MS", default = 1000)]
@@ -808,6 +1156,7 @@ pub trait SsoProviderIssuerConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct SsoConfig {
#[config(nested)]
pub github: SsoGitHubConfig,
@@ -829,6 +1178,7 @@ pub struct SsoConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct SsoGitHubConfig {
#[config(env = "SSO_GITHUB_ENABLED", default = false)]
pub enabled: bool,
@@ -862,6 +1212,7 @@ impl SsoProviderConfig for SsoGitHubConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct SsoDiscordConfig {
#[config(env = "SSO_DISCORD_ENABLED", default = false)]
pub enabled: bool,
@@ -895,6 +1246,7 @@ impl SsoProviderConfig for SsoDiscordConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct SsoGoogleConfig {
#[config(env = "SSO_GOOGLE_ENABLED", default = false)]
pub enabled: bool,
@@ -928,6 +1280,7 @@ impl SsoProviderConfig for SsoGoogleConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct SsoGitLabConfig {
#[config(env = "SSO_GITLAB_ENABLED", default = false)]
pub enabled: bool,
@@ -970,6 +1323,7 @@ impl SsoProviderIssuerConfig for SsoGitLabConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct SsoOidcConfig {
#[config(env = "SSO_OIDC_ENABLED", default = false)]
pub enabled: bool,
@@ -1012,6 +1366,7 @@ impl SsoProviderIssuerConfig for SsoOidcConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct SsoAppleConfig {
#[config(env = "SSO_APPLE_ENABLED", default = false)]
pub enabled: bool,
@@ -1030,6 +1385,7 @@ pub struct SsoAppleConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct ModerationConfig {
/// External report-handling service URL.
#[config(env = "REPORT_SERVICE_URL")]
@@ -1041,6 +1397,7 @@ pub struct ModerationConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct ImportConfig {
/// Whether the PDS accepts repo imports.
#[config(env = "ACCEPTING_REPO_IMPORTS", default = true)]
@@ -1072,6 +1429,7 @@ fn split_comma_list(value: &str) -> Result<Vec<String>, std::convert::Infallible
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct RippleCacheConfig {
/// Address to bind the Ripple gossip protocol listener.
#[config(env = "RIPPLE_BIND", default = "0.0.0.0:0")]
@@ -1095,6 +1453,7 @@ pub struct RippleCacheConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct ScheduledConfig {
/// Interval in seconds between scheduled delete checks.
#[config(env = "SCHEDULED_DELETE_CHECK_INTERVAL_SECS", default = 3600)]
@@ -1142,6 +1501,7 @@ pub struct ScheduledConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct TranquilStoreConfig {
/// Directory for tranquil-store data: the metastore, eventlog, and blockstore.
#[config(
@@ -1196,3 +1556,317 @@ pub struct TranquilStoreConfig {
pub fn template() -> String {
confique::toml::template::<TranquilConfig>(confique::toml::FormatOptions::default())
}
#[cfg(test)]
mod tests {
use super::*;
fn seed_required_env() {
let required = [
("PDS_HOSTNAME", "test.local"),
("DATABASE_URL", "postgres://localhost/test"),
("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1"),
("INVITE_CODE_REQUIRED", "false"),
("ENABLE_PDS_HOSTED_DID_WEB", "true"),
("TRANQUIL_LEXICON_OFFLINE", "1"),
];
required
.iter()
.filter(|(k, _)| std::env::var_os(k).is_none())
.for_each(|(k, v)| unsafe { std::env::set_var(k, v) });
}
#[test]
fn load_rejects_unknown_top_level_key() {
let dir = std::env::temp_dir().join(format!(
"tranquil-config-unknown-toplevel-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("mkdir tempdir");
let path = dir.join("config.toml");
std::fs::write(
&path,
r#"
[server]
hostname = "test.local"
[totally_made_up]
foo = "bar"
"#,
)
.expect("write tempfile");
let result = TranquilConfig::builder().file(&path).load();
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
let err = format!("{:#}", result.expect_err("load must reject unknown key"));
assert!(
err.contains("totally_made_up"),
"expected totally_made_up in error, got {err:?}"
);
}
#[test]
fn load_rejects_unknown_nested_key() {
let dir = std::env::temp_dir().join(format!(
"tranquil-config-unknown-nested-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("mkdir tempdir");
let path = dir.join("config.toml");
std::fs::write(
&path,
r#"
[server]
hostname = "test.local"
not_a_real_field = "oops"
"#,
)
.expect("write tempfile");
let result = TranquilConfig::builder().file(&path).load();
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
let err = format!("{:#}", result.expect_err("load must reject unknown key"));
assert!(
err.contains("not_a_real_field"),
"expected not_a_real_field in error, got {err:?}"
);
}
#[test]
fn load_accepts_known_keys() {
let dir = std::env::temp_dir().join(format!(
"tranquil-config-known-keys-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("mkdir tempdir");
let path = dir.join("config.toml");
std::fs::write(
&path,
r#"
[server]
hostname = "test.local"
port = 3000
[database]
url = "postgres://localhost/test"
[email.smarthost]
host = "smtp.example"
port = 587
"#,
)
.expect("write tempfile");
let result = TranquilConfig::builder().file(&path).load();
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
result.expect("known keys must load successfully");
}
#[test]
fn serial_validate_rejects_legacy_sendmail_path() {
seed_required_env();
unsafe { std::env::set_var("SENDMAIL_PATH", "/usr/sbin/sendmail") };
let config = TranquilConfig::builder()
.env()
.load()
.expect("load fresh config");
let result = config.validate(true);
unsafe { std::env::remove_var("SENDMAIL_PATH") };
let err = result.expect_err("validate must reject SENDMAIL_PATH");
let mentions_sendmail = err.errors.iter().any(|e| e.contains("SENDMAIL_PATH"));
assert!(
mentions_sendmail,
"errors did not mention SENDMAIL_PATH: {:?}",
err.errors
);
}
#[test]
fn serial_validate_passes_when_no_legacy_env_set() {
seed_required_env();
unsafe { std::env::remove_var("SENDMAIL_PATH") };
let config = TranquilConfig::builder()
.env()
.load()
.expect("load fresh config");
let result = config.validate(true);
let leaked_legacy = result
.as_ref()
.err()
.map(|e| e.errors.iter().any(|s| s.contains("SENDMAIL_PATH")))
.unwrap_or(false);
assert!(
!leaked_legacy,
"validate spuriously flagged SENDMAIL_PATH when unset: {:?}",
result
);
}
#[test]
fn email_address_predicate_accepts_typical_addresses() {
assert!(looks_like_email_address("alice@nel.pet"));
assert!(looks_like_email_address("a.b+tag@example.co.uk"));
}
#[test]
fn email_address_predicate_rejects_malformed() {
assert!(!looks_like_email_address(""));
assert!(!looks_like_email_address("no-at-sign"));
assert!(!looks_like_email_address("@nel.pet"));
assert!(!looks_like_email_address("alice@"));
assert!(!looks_like_email_address("alice@nel"));
assert!(!looks_like_email_address("a@b@c.com"));
assert!(!looks_like_email_address("alice @nel.pet"));
}
#[test]
fn dkim_selector_predicate_matches_subdomain_syntax() {
assert!(is_valid_dkim_selector("default"));
assert!(is_valid_dkim_selector("s2024-q1"));
assert!(is_valid_dkim_selector("mailo-2024.nel.pet"));
assert!(!is_valid_dkim_selector(""));
assert!(!is_valid_dkim_selector("a..b"));
assert!(!is_valid_dkim_selector("-leading"));
assert!(!is_valid_dkim_selector("trailing-"));
assert!(!is_valid_dkim_selector("s_under"));
}
#[test]
fn email_validate_disabled_when_from_address_unset() {
let cfg = email_config_for_test(EmailOverrides::default());
let mut errors = Vec::new();
cfg.validate("test.local", &mut errors);
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
}
#[test]
fn email_validate_rejects_bad_from_address() {
let cfg = email_config_for_test(EmailOverrides {
from_address: Some("not-an-email"),
..Default::default()
});
let mut errors = Vec::new();
cfg.validate("test.local", &mut errors);
assert!(
errors.iter().any(|e| e.contains("from_address")),
"expected from_address error, got {errors:?}"
);
}
#[test]
fn email_validate_rejects_smarthost_with_bad_credentials() {
let cfg = email_config_for_test(EmailOverrides {
from_address: Some("alice@nel.pet"),
smarthost_host: Some("smtp.nel.pet"),
smarthost_username: Some(""),
smarthost_password: Some("hunter2"),
..Default::default()
});
let mut errors = Vec::new();
cfg.validate("test.local", &mut errors);
assert!(
errors.iter().any(|e| e.contains("smarthost.username")),
"expected smarthost.username error, got {errors:?}"
);
}
#[test]
fn email_validate_rejects_bad_dkim_selector() {
let cfg = email_config_for_test(EmailOverrides {
from_address: Some("alice@nel.pet"),
dkim_selector: Some("-bad"),
dkim_domain: Some("nel.pet"),
dkim_key_path: Some("/etc/dkim.key"),
..Default::default()
});
let mut errors = Vec::new();
cfg.validate("test.local", &mut errors);
assert!(
errors.iter().any(|e| e.contains("dkim.selector")),
"expected dkim.selector error, got {errors:?}"
);
}
#[test]
fn tls_validate_accepts_both_paths_unset() {
let mut errors = Vec::new();
TlsConfig {
cert_path: None,
key_path: None,
}
.validate(&mut errors);
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
}
#[test]
fn tls_validate_accepts_both_paths_set() {
let mut errors = Vec::new();
TlsConfig {
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
key_path: Some("/etc/tranquil/key.pem".to_string()),
}
.validate(&mut errors);
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
}
#[test]
fn tls_validate_rejects_cert_without_key() {
let mut errors = Vec::new();
TlsConfig {
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
key_path: None,
}
.validate(&mut errors);
assert!(
errors.iter().any(|e| e.contains("server.tls")),
"expected server.tls error, got {errors:?}"
);
}
#[derive(Default)]
struct EmailOverrides {
from_address: Option<&'static str>,
smarthost_host: Option<&'static str>,
smarthost_username: Option<&'static str>,
smarthost_password: Option<&'static str>,
dkim_selector: Option<&'static str>,
dkim_domain: Option<&'static str>,
dkim_key_path: Option<&'static str>,
}
fn email_config_for_test(o: EmailOverrides) -> EmailConfig {
EmailConfig {
from_address: o.from_address.map(str::to_string),
from_name: "Tranquil PDS".to_string(),
helo_name: None,
smarthost: SmarthostConfig {
host: o.smarthost_host.map(str::to_string),
port: 587,
username: o.smarthost_username.map(str::to_string),
password: o.smarthost_password.map(str::to_string),
tls: "starttls".to_string(),
pool_size: 4,
command_timeout_secs: 30,
total_timeout_secs: 60,
},
direct_mx: DirectMxConfig {
command_timeout_secs: 30,
total_timeout_secs: 60,
max_concurrent_sends: 8,
require_tls: false,
},
dkim: DkimConfig {
selector: o.dkim_selector.map(str::to_string),
domain: o.dkim_domain.map(str::to_string),
private_key_path: o.dkim_key_path.map(str::to_string),
},
}
}
}
+2
View File
@@ -244,6 +244,8 @@ pub trait InfraRepository: Send + Sync {
async fn mark_comms_failed(&self, id: Uuid, error: &str) -> Result<(), DbError>;
async fn mark_comms_failed_permanent(&self, id: Uuid, error: &str) -> Result<(), DbError>;
async fn create_invite_code(
&self,
code: &str,
+2
View File
@@ -44,6 +44,7 @@ pub struct UserRow {
pub deactivated_at: Option<DateTime<Utc>>,
pub takedown_ref: Option<String>,
pub is_admin: bool,
pub inbound_migration: bool,
}
#[derive(Debug, Clone)]
@@ -995,6 +996,7 @@ pub struct CreatePasswordAccountInput {
pub telegram_username: Option<String>,
pub signal_username: Option<String>,
pub deactivated_at: Option<DateTime<Utc>>,
pub inbound_migration: bool,
pub encrypted_key_bytes: Vec<u8>,
pub encryption_version: i32,
pub reserved_key_id: Option<Uuid>,
+24 -2
View File
@@ -65,9 +65,13 @@ impl InfraRepository for PostgresInfraRepository {
SET status = 'processing', updated_at = NOW()
WHERE id IN (
SELECT id FROM comms_queue
WHERE status = 'pending'
WHERE attempts < max_attempts
AND scheduled_for <= $1
AND attempts < max_attempts
AND (
status = 'pending'
OR (status = 'processing'
AND updated_at < $1 - INTERVAL '10 minutes')
)
ORDER BY scheduled_for ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
@@ -127,6 +131,24 @@ impl InfraRepository for PostgresInfraRepository {
Ok(())
}
async fn mark_comms_failed_permanent(&self, id: Uuid, error: &str) -> Result<(), DbError> {
sqlx::query!(
r#"UPDATE comms_queue
SET status = 'failed'::comms_status,
attempts = max_attempts,
last_error = $2,
updated_at = NOW()
WHERE id = $1"#,
id,
error
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn create_invite_code(
&self,
code: &str,
+1 -1
View File
@@ -374,7 +374,7 @@ impl OAuthRepository for PostgresOAuthRepository {
WHERE id IN (
SELECT id FROM oauth_token
WHERE did = $1
ORDER BY updated_at ASC
ORDER BY created_at DESC
OFFSET $2
)
"#,
+8 -5
View File
@@ -47,7 +47,7 @@ pub(crate) fn map_sqlx_error(e: sqlx::Error) -> DbError {
impl UserRepository for PostgresUserRepository {
async fn get_by_did(&self, did: &Did) -> Result<Option<UserRow>, DbError> {
let row = sqlx::query!(
r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin
r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration
FROM users WHERE did = $1"#,
did.as_str()
)
@@ -64,12 +64,13 @@ impl UserRepository for PostgresUserRepository {
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
is_admin: r.is_admin,
inbound_migration: r.inbound_migration,
}))
}
async fn get_by_handle(&self, handle: &Handle) -> Result<Option<UserRow>, DbError> {
let row = sqlx::query!(
r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin
r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration
FROM users WHERE handle = $1"#,
handle.as_str()
)
@@ -86,6 +87,7 @@ impl UserRepository for PostgresUserRepository {
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
is_admin: r.is_admin,
inbound_migration: r.inbound_migration,
}))
}
@@ -1863,7 +1865,7 @@ impl UserRepository for PostgresUserRepository {
async fn activate_account(&self, did: &Did) -> Result<bool, DbError> {
let result = sqlx::query!(
"UPDATE users SET deactivated_at = NULL WHERE did = $1",
"UPDATE users SET deactivated_at = NULL, inbound_migration = FALSE WHERE did = $1",
did.as_str()
)
.execute(&self.pool)
@@ -2426,8 +2428,8 @@ impl UserRepository for PostgresUserRepository {
handle, email, did, password_hash,
preferred_comms_channel,
discord_username, telegram_username, signal_username,
is_admin, deactivated_at, email_verified
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, FALSE) RETURNING id"#,
is_admin, deactivated_at, inbound_migration, email_verified
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, FALSE) RETURNING id"#,
)
.bind(input.handle.as_str())
.bind(&input.email)
@@ -2439,6 +2441,7 @@ impl UserRepository for PostgresUserRepository {
.bind(&input.signal_username)
.bind(is_first_user)
.bind(input.deactivated_at)
.bind(input.inbound_migration)
.fetch_one(&mut *tx)
.await;
@@ -310,6 +310,7 @@ pub async fn authorize_post(
State(state): State<AppState>,
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
headers: HeaderMap,
client_ip: ClientIp,
Json(form): Json<AuthorizeSubmit>,
) -> Response {
let json_response = wants_json(&headers);
@@ -488,8 +489,7 @@ pub async fn authorize_post(
if !password_valid {
return show_login_error("Invalid identifier or password.", json_response);
}
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {
if tranquil_api::server::verification_blocks_login(&user.channel_verification) {
let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await;
let handle = resend_info
.as_ref()
@@ -617,7 +617,7 @@ pub async fn authorize_post(
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers, None),
ip_address: client_ip.into_string(),
last_seen_at: Utc::now(),
};
if state
@@ -854,8 +854,7 @@ pub async fn authorize_select(
);
}
};
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {
if tranquil_api::server::verification_blocks_login(&user.channel_verification) {
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
return (
StatusCode::FORBIDDEN,
@@ -23,7 +23,7 @@ use tranquil_pds::rate_limit::{
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Handle, PlainPassword};
use tranquil_pds::util::extract_client_ip;
use tranquil_pds::util::ClientIp;
use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, RequestId};
use urlencoding::encode as url_encode;
@@ -289,9 +289,7 @@ async fn passkey_start_named(
.into_response();
}
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {
if tranquil_api::server::verification_blocks_login(&user.channel_verification) {
let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await;
return (
StatusCode::FORBIDDEN,
@@ -160,8 +160,10 @@ pub async fn register_complete(
.into_response();
}
let is_verified = match state.repos.user.get_session_info_by_did(&did).await {
Ok(Some(info)) => info.channel_verification.has_any_verified(),
let login_blocked = match state.repos.user.get_session_info_by_did(&did).await {
Ok(Some(info)) => {
tranquil_api::server::verification_blocks_login(&info.channel_verification)
}
Ok(None) => {
return (
StatusCode::FORBIDDEN,
@@ -189,7 +191,7 @@ pub async fn register_complete(
}
};
if !is_verified {
if login_blocked {
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
return (
StatusCode::FORBIDDEN,
@@ -300,6 +302,7 @@ pub async fn register_complete(
pub async fn establish_session(
State(state): State<AppState>,
headers: HeaderMap,
client_ip: ClientIp,
auth: tranquil_pds::auth::Auth<tranquil_pds::auth::Active>,
) -> Response {
let did = &auth.did;
@@ -317,7 +320,7 @@ pub async fn establish_session(
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers, None),
ip_address: client_ip.into_string(),
last_seen_at: Utc::now(),
};
@@ -75,6 +75,7 @@ pub async fn authorize_2fa_post(
State(state): State<AppState>,
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
headers: HeaderMap,
client_ip: ClientIp,
Json(form): Json<Authorize2faSubmit>,
) -> Response {
let json_error = |status: StatusCode, error: &str, description: &str| -> Response {
@@ -251,7 +252,7 @@ pub async fn authorize_2fa_post(
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers, None),
ip_address: client_ip.into_string(),
last_seen_at: Utc::now(),
};
if state
@@ -12,7 +12,7 @@ use tranquil_pds::oauth::client::{build_client_metadata, delegation_oauth_urls};
use tranquil_pds::rate_limit::{LoginLimit, OAuthRateLimited, TotpVerifyLimit};
use tranquil_pds::state::AppState;
use tranquil_pds::types::PlainPassword;
use tranquil_pds::util::extract_client_ip;
use tranquil_pds::util::ClientIp;
use tranquil_types::did_doc::{extract_handle, extract_pds_endpoint};
use tranquil_types::{Did, RequestId};
@@ -402,6 +402,7 @@ pub struct DelegationTokenAuthSubmit {
pub async fn delegation_auth_token(
State(state): State<AppState>,
headers: HeaderMap,
client_ip: ClientIp,
auth: Auth<Active>,
Json(form): Json<DelegationTokenAuthSubmit>,
) -> Response {
@@ -428,7 +429,7 @@ pub async fn delegation_auth_token(
return resp;
}
let ip = extract_client_ip(&headers, None);
let ip = client_ip.into_string();
let user_agent = tranquil_pds::util::extract_user_agent(&headers);
finalize_delegation_auth(
@@ -402,13 +402,15 @@ async fn handle_sso_login(
}
};
let is_verified = match state
let login_blocked = match state
.repos
.user
.get_session_info_by_did(&identity.did)
.await
{
Ok(Some(info)) => info.channel_verification.has_any_verified(),
Ok(Some(info)) => {
tranquil_api::server::verification_blocks_login(&info.channel_verification)
}
Ok(None) => {
tracing::error!("User not found for SSO login: {}", identity.did);
return redirect_to_error("Account not found");
@@ -419,7 +421,7 @@ async fn handle_sso_login(
}
};
if !is_verified {
if login_blocked {
tracing::warn!(
did = %identity.did,
provider = %provider.as_str(),
+3
View File
@@ -23,3 +23,6 @@ sha2 = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
[features]
native-tls-roots = []
+15 -12
View File
@@ -78,18 +78,21 @@ impl ClientMetadataCache {
Self {
cache: Arc::new(RwLock::new(HashMap::new())),
jwks_cache: Arc::new(RwLock::new(HashMap::new())),
http_client: Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(10)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.user_agent(concat!(
"Tranquil-PDS/",
env!("CARGO_PKG_VERSION"),
" (ATProto; +https://tangled.org/tranquil.farm/tranquil-pds)"
))
.build()
.unwrap_or_else(|_| Client::new()),
http_client: {
let builder = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(10)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.user_agent(concat!(
"Tranquil-PDS/",
env!("CARGO_PKG_VERSION"),
" (ATProto; +https://tangled.org/tranquil.farm/tranquil-pds)"
));
#[cfg(feature = "native-tls-roots")]
let builder = builder.danger_accept_invalid_certs(true);
builder.build().unwrap_or_else(|_| Client::new())
},
cache_ttl_secs,
}
}
+2
View File
@@ -55,6 +55,7 @@ metrics-exporter-prometheus = { workspace = true }
multibase = { workspace = true }
multihash = { workspace = true }
p256 = { workspace = true }
parking_lot = { workspace = true }
rand = { workspace = true }
redis = { workspace = true, optional = true }
regex = { workspace = true }
@@ -90,6 +91,7 @@ s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"]
s3 = ["s3-storage"]
valkey = ["tranquil-cache/valkey", "dep:redis"]
frontend = []
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
[dev-dependencies]
ciborium = { workspace = true }
+42 -4
View File
@@ -21,6 +21,8 @@ pub enum ApiError {
InvalidToken(Option<String>),
ExpiredToken(Option<String>),
OAuthExpiredToken(Option<String>),
UseDpopNonce(String),
InvalidDpopProof(String),
TokenRequired,
AccountDeactivated,
AccountTakedown,
@@ -137,6 +139,8 @@ impl ApiError {
| Self::InvalidToken(_)
| Self::PasskeyCounterAnomaly
| Self::OAuthExpiredToken(_)
| Self::UseDpopNonce(_)
| Self::InvalidDpopProof(_)
| Self::ReauthRequired { .. } => StatusCode::UNAUTHORIZED,
Self::InvalidCode(_) => StatusCode::BAD_REQUEST,
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
@@ -236,6 +240,8 @@ impl ApiError {
Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"),
Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"),
Self::ExpiredToken(_) | Self::OAuthExpiredToken(_) => Cow::Borrowed("ExpiredToken"),
Self::UseDpopNonce(_) => Cow::Borrowed("use_dpop_nonce"),
Self::InvalidDpopProof(_) => Cow::Borrowed("invalid_dpop_proof"),
Self::TokenRequired => Cow::Borrowed("TokenRequired"),
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
@@ -335,6 +341,8 @@ impl ApiError {
Self::ExpiredToken(msg) | Self::OAuthExpiredToken(msg) => {
msg.clone().unwrap_or_else(|| "Token has expired".into())
}
Self::UseDpopNonce(_) => "DPoP nonce required".into(),
Self::InvalidDpopProof(msg) => msg.clone(),
Self::RepoNotFound(msg) => msg
.clone()
.unwrap_or_else(|| "Repository not found".into()),
@@ -560,6 +568,36 @@ impl IntoResponse for ApiError {
),
);
}
Self::UseDpopNonce(nonce) => {
match HeaderValue::from_str(nonce) {
Ok(val) => {
response
.headers_mut()
.insert(crate::util::HEADER_DPOP_NONCE, val);
}
Err(err) => {
tracing::error!(
?err,
nonce_len = nonce.len(),
"generated DPoP nonce is not a valid header value"
);
}
}
response.headers_mut().insert(
http::header::WWW_AUTHENTICATE,
HeaderValue::from_static(
"DPoP error=\"use_dpop_nonce\", error_description=\"Resource server requires nonce in DPoP proof\"",
),
);
}
Self::InvalidDpopProof(_) => {
response.headers_mut().insert(
http::header::WWW_AUTHENTICATE,
HeaderValue::from_static(
"DPoP error=\"invalid_dpop_proof\", error_description=\"Invalid DPoP proof\"",
),
);
}
_ => {}
}
response
@@ -596,6 +634,8 @@ impl From<crate::auth::TokenValidationError> for ApiError {
crate::auth::TokenValidationError::InvalidToken => {
Self::AuthenticationFailed(Some("Invalid token format".to_string()))
}
crate::auth::TokenValidationError::UseDpopNonce(nonce) => Self::UseDpopNonce(nonce),
crate::auth::TokenValidationError::InvalidDpopProof(msg) => Self::InvalidDpopProof(msg),
}
}
}
@@ -625,10 +665,8 @@ impl From<crate::auth::extractor::AuthError> for ApiError {
crate::auth::extractor::AuthError::OAuthExpiredToken(msg) => {
Self::OAuthExpiredToken(Some(msg))
}
crate::auth::extractor::AuthError::UseDpopNonce(_)
| crate::auth::extractor::AuthError::InvalidDpopProof(_) => {
Self::AuthenticationFailed(None)
}
crate::auth::extractor::AuthError::UseDpopNonce(nonce) => Self::UseDpopNonce(nonce),
crate::auth::extractor::AuthError::InvalidDpopProof(msg) => Self::InvalidDpopProof(msg),
}
}
}
@@ -21,6 +21,13 @@ pub async fn require_verified_or_delegated<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
) -> Result<AccountVerified<'a>, ApiError> {
if tranquil_config::get()
.server
.disable_account_verification_gate
{
return Ok(AccountVerified { user });
}
let is_verified = state
.repos
.user
+2 -27
View File
@@ -2,7 +2,7 @@ use std::marker::PhantomData;
use axum::{
extract::{FromRequestParts, OptionalFromRequestParts, OriginalUri},
http::{StatusCode, header::AUTHORIZATION, request::Parts},
http::{header::AUTHORIZATION, request::Parts},
response::{IntoResponse, Response},
};
use tracing::{debug, error, info};
@@ -35,32 +35,7 @@ pub enum AuthError {
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
match self {
Self::UseDpopNonce(nonce) => (
StatusCode::UNAUTHORIZED,
[
("DPoP-Nonce", nonce.as_str()),
("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""),
],
axum::Json(serde_json::json!({
"error": "use_dpop_nonce",
"message": "DPoP nonce required"
})),
)
.into_response(),
Self::OAuthExpiredToken(msg) => ApiError::OAuthExpiredToken(Some(msg)).into_response(),
Self::InvalidDpopProof(msg) => (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", "DPoP error=\"invalid_dpop_proof\"")],
axum::Json(serde_json::json!({
"error": "invalid_dpop_proof",
"message": msg
})),
)
.into_response(),
Self::InsufficientScope(msg) => ApiError::InsufficientScope(Some(msg)).into_response(),
other => ApiError::from(other).into_response(),
}
ApiError::from(self).into_response()
}
}
+11 -1
View File
@@ -106,7 +106,7 @@ struct CachedUserStatus {
is_admin: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenValidationError {
AccountDeactivated,
AccountTakedown,
@@ -115,6 +115,8 @@ pub enum TokenValidationError {
TokenExpired,
OAuthTokenExpired,
InvalidToken,
UseDpopNonce(String),
InvalidDpopProof(String),
}
impl fmt::Display for TokenValidationError {
@@ -126,6 +128,8 @@ impl fmt::Display for TokenValidationError {
Self::AuthenticationFailed => write!(f, "AuthenticationFailed"),
Self::TokenExpired | Self::OAuthTokenExpired => write!(f, "ExpiredToken"),
Self::InvalidToken => write!(f, "InvalidToken"),
Self::UseDpopNonce(_) => write!(f, "use_dpop_nonce"),
Self::InvalidDpopProof(_) => write!(f, "invalid_dpop_proof"),
}
}
}
@@ -613,6 +617,12 @@ pub async fn validate_token_with_dpop(
Err(crate::oauth::OAuthError::ExpiredToken(_)) => {
Err(TokenValidationError::OAuthTokenExpired)
}
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => {
Err(TokenValidationError::UseDpopNonce(nonce))
}
Err(crate::oauth::OAuthError::InvalidDpopProof(msg)) => {
Err(TokenValidationError::InvalidDpopProof(msg))
}
Err(_) => Err(TokenValidationError::AuthenticationFailed),
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ pub use tranquil_comms::{
CommsChannel, CommsSender, CommsStatus, CommsType, DEFAULT_LOCALE, DiscordSender, EmailSender,
NewComms, NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender,
VALID_LOCALES, format_message, get_strings, is_valid_phone_number, is_valid_signal_username,
mime_encode_header, sanitize_header_value, validate_locale,
validate_locale,
};
pub use service::{CommsService, repo as comms_repo, resolve_delivery_channel};
+15 -1
View File
@@ -149,13 +149,19 @@ impl CommsService {
}
}
Err(e) => {
let permanent = e.is_permanent();
let error_msg = e.to_string();
warn!(
comms_id = %comms_id,
error = %error_msg,
permanent,
"Failed to send comms"
);
if let Err(db_err) = self.mark_failed(comms_id, &error_msg).await {
let db_result = match permanent {
true => self.mark_failed_permanent(comms_id, &error_msg).await,
false => self.mark_failed(comms_id, &error_msg).await,
};
if let Err(db_err) = db_result {
error!(
comms_id = %comms_id,
error = %db_err,
@@ -173,6 +179,14 @@ impl CommsService {
async fn mark_failed(&self, id: Uuid, error: &str) -> Result<(), tranquil_db_traits::DbError> {
self.infra_repo.mark_comms_failed(id, error).await
}
async fn mark_failed_permanent(
&self,
id: Uuid,
error: &str,
) -> Result<(), tranquil_db_traits::DbError> {
self.infra_repo.mark_comms_failed_permanent(id, error).await
}
}
struct ResolvedRecipient {
@@ -9,7 +9,7 @@ use axum::{
use crate::api::error::ApiError;
use crate::oauth::OAuthError;
use crate::state::{AppState, RateLimitKind};
use crate::util::extract_client_ip;
use crate::util::client_ip_from_parts;
pub trait RateLimitPolicy: Send + Sync + 'static {
const KIND: RateLimitKind;
@@ -173,7 +173,7 @@ impl<P: RateLimitPolicy, R: RateLimitRejection> FromRequestParts<AppState>
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let client_ip = extract_client_ip(&parts.headers, None);
let client_ip = client_ip_from_parts(parts);
if !state.check_rate_limit(P::KIND, &client_ip).await {
tracing::warn!(
+2 -4
View File
@@ -526,10 +526,8 @@ pub async fn commit_and_log(
let obsolete_bytes: Vec<Vec<u8>> = obsolete_cids.iter().map(|c| c.to_bytes()).collect();
let final_ops: HashMap<(&Nsid, &Rkey), &RecordOp> = ops
.iter()
.map(|op| (op.collection_rkey(), op))
.collect();
let final_ops: HashMap<(&Nsid, &Rkey), &RecordOp> =
ops.iter().map(|op| (op.collection_rkey(), op)).collect();
let final_record_uris: HashSet<AtUri> = final_ops
.iter()
+151 -14
View File
@@ -399,6 +399,10 @@ pub async fn start_scheduled_tasks(
let mut compaction_ticker = interval(compaction_interval);
compaction_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let compaction_blocklist = Arc::new(parking_lot::Mutex::new(CompactionBlocklist::new(
Duration::from_secs(300),
)));
let mut reachability_ticker = interval(reachability_interval);
reachability_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -464,8 +468,9 @@ pub async fn start_scheduled_tasks(
let store = store.clone();
let threshold = cfg.scheduled.compaction_liveness_threshold;
let grace_ms = cfg.scheduled.compaction_grace_period_ms;
let blocklist = Arc::clone(&compaction_blocklist);
if let Err(e) = tokio::task::spawn_blocking(move || {
run_compaction_pass(&store, threshold, grace_ms)
run_compaction_pass(&store, threshold, grace_ms, &blocklist)
}).await.unwrap_or_else(|e| Err(anyhow::anyhow!("compaction task panicked: {e}"))) {
error!("Compaction error: {e}");
}
@@ -485,6 +490,8 @@ pub async fn start_scheduled_tasks(
live_refcounted = result.live_refcounted,
leaked_blocks = result.leaked_blocks,
repaired_blocks = result.repaired_blocks,
phantom_files_purged = result.phantom_files_purged,
phantom_blocks_purged = result.phantom_blocks_purged,
bloom_heap_mb = result.bloom_heap_bytes / (1024 * 1024),
"reachability walk complete"
);
@@ -536,11 +543,44 @@ pub async fn start_scheduled_tasks(
}
}
pub struct CompactionBlocklist {
entries: std::collections::HashMap<tranquil_store::blockstore::DataFileId, std::time::Instant>,
cool_off: Duration,
}
impl CompactionBlocklist {
pub fn new(cool_off: Duration) -> Self {
Self {
entries: std::collections::HashMap::new(),
cool_off,
}
}
pub fn record_failure(&mut self, file_id: tranquil_store::blockstore::DataFileId) {
self.entries.insert(file_id, std::time::Instant::now());
}
pub fn is_blocked(&self, file_id: tranquil_store::blockstore::DataFileId) -> bool {
self.entries
.get(&file_id)
.is_some_and(|recorded| recorded.elapsed() < self.cool_off)
}
pub fn prune_expired(&mut self) {
let cool_off = self.cool_off;
self.entries
.retain(|_, recorded| recorded.elapsed() < cool_off);
}
}
fn run_compaction_pass(
store: &tranquil_store::blockstore::TranquilBlockStore,
liveness_threshold: f64,
grace_period_ms: u64,
blocklist: &parking_lot::Mutex<CompactionBlocklist>,
) -> anyhow::Result<()> {
blocklist.lock().prune_expired();
match store.cleanup_gc_meta() {
Ok(0) => {}
Ok(n) => info!(count = n, "cleaned up stale gc_meta entries"),
@@ -553,7 +593,11 @@ fn run_compaction_pass(
let candidate = liveness_map
.iter()
.filter(|(_, info)| info.total_blocks > 0 && info.ratio() < liveness_threshold)
.filter(|(fid, info)| {
info.total_blocks > 0
&& info.ratio() < liveness_threshold
&& !blocklist.lock().is_blocked(**fid)
})
.min_by(|(_, a), (_, b)| {
a.ratio()
.partial_cmp(&b.ratio())
@@ -574,21 +618,35 @@ fn run_compaction_pass(
"compacting data file"
);
match store.compact_file(file_id, grace_period_ms) {
Ok(result) => {
Ok(tranquil_store::blockstore::CompactionResult::Compacted(stats)) => {
info!(
file_id = %result.file_id,
reclaimed_bytes = result.reclaimed_bytes,
live_blocks = result.live_blocks,
dead_blocks = result.dead_blocks,
file_id = %stats.file_id,
reclaimed_bytes = stats.reclaimed_bytes,
live_blocks = stats.live_blocks,
dead_blocks = stats.dead_blocks,
"compaction complete"
);
Ok(())
}
Ok(tranquil_store::blockstore::CompactionResult::Purged {
file_id,
phantom_blocks,
}) => {
warn!(
file_id = %file_id,
phantom_blocks,
"compaction target missing on disk, purged phantom index entries"
);
Ok(())
}
Err(tranquil_store::blockstore::CompactionError::ActiveFileCannotBeCompacted) => {
debug!(file_id = %file_id, "skipped active file");
Ok(())
}
Err(e) => Err(anyhow::anyhow!("compaction failed: {e}")),
Err(e) => {
blocklist.lock().record_failure(file_id);
Err(anyhow::anyhow!("compaction failed: {e}"))
}
}
}
}
@@ -693,6 +751,24 @@ pub async fn generate_repo_car(
.await
.context("Failed to fetch blocks")?;
let missing: Vec<Cid> = chunk
.iter()
.zip(blocks.iter())
.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(", ")
);
}
chunk
.iter()
.zip(blocks.iter())
@@ -746,6 +822,8 @@ pub struct ReachabilityResult {
pub leaked_blocks: u64,
pub repaired_blocks: u64,
pub bloom_heap_bytes: usize,
pub phantom_files_purged: u64,
pub phantom_blocks_purged: u64,
}
const REPO_PAGE_SIZE: i64 = 500;
@@ -761,6 +839,7 @@ fn walk_repo_dag_sync(
store: &tranquil_store::blockstore::TranquilBlockStore,
head_cid: &Cid,
reachable: &mut std::collections::HashSet<CidBytes>,
phantom_files: &mut std::collections::HashSet<tranquil_store::blockstore::DataFileId>,
) -> anyhow::Result<()> {
let mut to_visit = vec![cid_to_bytes(head_cid)?];
@@ -769,15 +848,56 @@ fn walk_repo_dag_sync(
continue;
}
let block = match store.get_block_sync(&cid_bytes)? {
Some(b) => b,
None => {
let block = match store.get_block_sync(&cid_bytes) {
Ok(Some(b)) => b,
Ok(None) => {
tracing::warn!(
?cid_bytes,
"referenced block missing during reachability walk"
);
continue;
}
Err(e) => {
let Some(entry) = store.block_index().get(&cid_bytes) else {
tracing::warn!(
?cid_bytes,
error = %e,
"reachability walk: index entry vanished between read attempt and re-check"
);
continue;
};
let file_path = store.data_file_path(entry.location.file_id);
match file_path.try_exists() {
Ok(false) => {
tracing::warn!(
?cid_bytes,
file_id = %entry.location.file_id,
error = %e,
"indexed block points at missing data file, scheduling phantom purge"
);
phantom_files.insert(entry.location.file_id);
continue;
}
Ok(true) => {
return Err(anyhow::anyhow!(
"reachability walk read error on present data file {}: {e}",
entry.location.file_id
));
}
Err(probe_err) => {
tracing::warn!(
?cid_bytes,
file_id = %entry.location.file_id,
existence_probe_error = %probe_err,
"could not probe data file existence after read error"
);
return Err(anyhow::anyhow!(
"reachability walk read error on file {}: {e}",
entry.location.file_id
));
}
}
}
};
if let Ok(commit) = Commit::from_cbor(&block) {
@@ -858,13 +978,15 @@ pub fn run_reachability_walk(
let mut repos_walked: u64 = 0;
let mut seen_heads: std::collections::HashMap<Did, CidLink> = std::collections::HashMap::new();
let mut phantom_files: std::collections::HashSet<tranquil_store::blockstore::DataFileId> =
std::collections::HashSet::new();
paginate_repos(&rt, repo_repo, |page| {
page.iter().try_for_each(|repo| -> anyhow::Result<()> {
let cid =
Cid::from_str(repo.repo_root_cid.as_str()).context("invalid repo_root_cid")?;
seen_heads.insert(repo.did.clone(), repo.repo_root_cid.clone());
walk_repo_dag_sync(store, &cid, &mut visited)?;
walk_repo_dag_sync(store, &cid, &mut visited, &mut phantom_files)?;
repos_walked = repos_walked.saturating_add(1);
if repos_walked.is_multiple_of(1000) {
info!(
@@ -894,7 +1016,7 @@ pub fn run_reachability_walk(
let cid =
Cid::from_str(repo.repo_root_cid.as_str()).context("invalid repo_root_cid")?;
let mut extra = std::collections::HashSet::new();
walk_repo_dag_sync(store, &cid, &mut extra)?;
walk_repo_dag_sync(store, &cid, &mut extra, &mut phantom_files)?;
extra.iter().for_each(|c| reachable.insert(c));
seen_heads.insert(repo.did.clone(), repo.repo_root_cid.clone());
stale_repos = stale_repos.saturating_add(1);
@@ -922,7 +1044,7 @@ pub fn run_reachability_walk(
let cid =
Cid::from_str(repo.repo_root_cid.as_str()).context("invalid repo_root_cid")?;
let mut extra = std::collections::HashSet::new();
walk_repo_dag_sync(store, &cid, &mut extra)?;
walk_repo_dag_sync(store, &cid, &mut extra, &mut phantom_files)?;
extra.iter().for_each(|c| reachable.insert(c));
quiesced_stale = quiesced_stale.saturating_add(1);
Ok(())
@@ -958,6 +1080,19 @@ pub fn run_reachability_walk(
}
};
let phantom_files_purged = u64::try_from(phantom_files.len()).unwrap_or(u64::MAX);
let phantom_blocks_purged = phantom_files
.iter()
.map(|fid| store.block_index().purge_by_file_id(*fid))
.sum::<u64>();
if phantom_files_purged > 0 {
warn!(
phantom_files_purged,
phantom_blocks_purged, "purged phantom index entries from unreadable data files"
);
}
Ok(ReachabilityResult {
repos_walked,
blocks_visited,
@@ -965,5 +1100,7 @@ pub fn run_reachability_walk(
leaked_blocks,
repaired_blocks,
bloom_heap_bytes,
phantom_files_purged,
phantom_blocks_purged,
})
}
+48 -29
View File
@@ -214,10 +214,10 @@ impl AppState {
pub async fn new(shutdown: CancellationToken) -> Result<Self, Box<dyn Error>> {
let cfg = tranquil_config::get();
match cfg.storage.repo_backend() {
let mut state = match cfg.storage.repo_backend() {
tranquil_config::RepoBackend::TranquilStore => {
tracing::info!("tranquil-store repo backend active. EXPERIMENTAL!");
Ok(Self::from_store(shutdown).await)
Self::from_store(shutdown).await
}
tranquil_config::RepoBackend::Postgres => {
let database_url = &cfg.database.url;
@@ -247,28 +247,21 @@ impl AppState {
.await
.map_err(|e| format!("Failed to run migrations: {}", e))?;
let bootstrap_invite_code = match (
cfg.server.invite_code_required,
sqlx::query_scalar!("SELECT COUNT(*) FROM users")
.fetch_one(&db)
.await,
) {
(true, Ok(Some(0))) => {
let code = crate::util::gen_invite_code();
tracing::info!(
"No users exist and invite codes are required. Bootstrap invite code: {}",
code
);
Some(code)
}
_ => None,
};
let mut state = Self::from_db(db, shutdown).await;
state.bootstrap_invite_code = bootstrap_invite_code;
Ok(state)
Self::from_db(db, shutdown).await
}
};
if cfg.server.invite_code_required && state.repos.user.count_users().await.unwrap_or(1) == 0
{
let code = crate::util::gen_invite_code();
tracing::info!(
"No users exist and invite codes are required. Bootstrap invite code: {}",
code
);
state.bootstrap_invite_code = Some(code);
}
Ok(state)
}
pub async fn from_db(db: PgPool, shutdown: CancellationToken) -> Self {
@@ -523,13 +516,16 @@ fn wire_tranquil_store(
let metastore =
Metastore::open(&metastore_dir, metastore_config).expect("failed to open metastore");
let blockstore = TranquilBlockStore::open(BlockStoreConfig {
data_dir: blockstore_data_dir,
index_dir: blockstore_index_dir,
max_file_size: store_cfg.max_blockstore_file_size,
group_commit: Default::default(),
shard_count: tranquil_store::blockstore::DEFAULT_SHARD_COUNT,
})
let blockstore = TranquilBlockStore::open_with_retry(
BlockStoreConfig {
data_dir: blockstore_data_dir,
index_dir: blockstore_index_dir,
max_file_size: store_cfg.max_blockstore_file_size,
group_commit: Default::default(),
shard_count: tranquil_store::blockstore::DEFAULT_SHARD_COUNT,
},
tranquil_store::blockstore::OpenRetryPolicy::default(),
)
.expect("failed to open blockstore");
let event_log = EventLog::open(
@@ -577,6 +573,18 @@ fn wire_tranquil_store(
"repaired orphan data files"
);
}
if repair.orphan_hints_removed > 0 {
tracing::info!(
removed = repair.orphan_hints_removed,
"repaired orphan hint files"
);
}
if repair.phantom_index_entries_purged > 0 {
tracing::info!(
purged = repair.phantom_index_entries_purged,
"purged phantom index entries pointing at missing data files"
);
}
if repair.had_errors() {
tracing::warn!(errors = repair.repair_errors, "some repairs failed");
}
@@ -595,6 +603,17 @@ fn wire_tranquil_store(
}
}
if std::env::var("TRANQUIL_PURGE_ORPHAN_REPOS").is_ok_and(|v| v == "1") {
match metastore
.repo_ops()
.purge_orphan_repos(metastore.database())
{
Ok(0) => tracing::info!("orphan repo purge: no orphans found"),
Ok(n) => tracing::info!(purged = n, "orphan repo purge: removed orphan repo_meta"),
Err(e) => tracing::error!(error = %e, "orphan repo purge failed"),
}
}
let notifier = bridge.notifier();
let signal_db = metastore.database().clone();
let signal_ks = metastore.signal_keyspace();
+212 -5
View File
@@ -7,6 +7,7 @@ use rand::Rng;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::num::NonZeroUsize;
use std::str::FromStr;
use std::sync::OnceLock;
@@ -96,22 +97,99 @@ pub fn generate_random_token() -> String {
URL_SAFE_NO_PAD.encode(bytes)
}
pub fn extract_client_ip(headers: &HeaderMap, addr: Option<SocketAddr>) -> String {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ForwardedTrust {
Peer,
Proxies(NonZeroUsize),
}
fn resolve_trust(configured: Option<usize>, terminates_tls: bool) -> ForwardedTrust {
let count = configured.unwrap_or(if terminates_tls { 0 } else { 1 });
match NonZeroUsize::new(count) {
Some(proxies) => ForwardedTrust::Proxies(proxies),
None => ForwardedTrust::Peer,
}
}
pub(crate) fn forwarded_trust() -> ForwardedTrust {
match tranquil_config::try_get() {
Some(cfg) => resolve_trust(
cfg.server.trusted_proxy_count,
cfg.server.tls.material().is_some(),
),
None => ForwardedTrust::Peer,
}
}
fn forwarded_client_ip(headers: &HeaderMap, trusted: NonZeroUsize) -> Option<String> {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next()
{
return first_ip.trim().to_string();
let hops: Vec<&str> = value
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if let Some(client) = hops
.len()
.checked_sub(trusted.get())
.and_then(|idx| hops.get(idx))
{
return Some((*client).to_string());
}
}
if let Some(real_ip) = headers.get("x-real-ip")
if trusted.get() == 1
&& let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str()
&& !value.trim().is_empty()
{
return value.trim().to_string();
return Some(value.trim().to_string());
}
None
}
pub(crate) fn extract_client_ip(
headers: &HeaderMap,
addr: Option<SocketAddr>,
trust: ForwardedTrust,
) -> String {
if let ForwardedTrust::Proxies(trusted) = trust
&& let Some(client) = forwarded_client_ip(headers, trusted)
{
return client;
}
addr.map(|a| a.ip().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
pub(crate) fn client_ip_from_parts(parts: &axum::http::request::Parts) -> String {
let addr = parts
.extensions
.get::<axum::extract::ConnectInfo<SocketAddr>>()
.map(|connect_info| connect_info.0);
extract_client_ip(&parts.headers, addr, forwarded_trust())
}
#[derive(Debug, Clone)]
pub struct ClientIp(String);
impl ClientIp {
pub fn into_string(self) -> String {
self.0
}
}
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for ClientIp {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
Ok(ClientIp(client_ip_from_parts(parts)))
}
}
pub fn set_discord_bot_username(username: String) {
DISCORD_BOT_USERNAME.set(username).ok();
}
@@ -227,6 +305,135 @@ pub fn is_self_hosted_did_web_enabled() -> bool {
#[cfg(test)]
mod tests {
use super::*;
use axum::extract::{ConnectInfo, FromRequestParts};
fn proxies(count: usize) -> ForwardedTrust {
ForwardedTrust::Proxies(NonZeroUsize::new(count).unwrap())
}
#[test]
fn resolve_trust_override_wins_over_tls() {
assert_eq!(resolve_trust(Some(1), true), proxies(1));
assert_eq!(resolve_trust(Some(3), false), proxies(3));
assert_eq!(resolve_trust(Some(0), true), ForwardedTrust::Peer);
assert_eq!(resolve_trust(Some(0), false), ForwardedTrust::Peer);
}
#[test]
fn resolve_trust_infers_from_tls_when_unset() {
assert_eq!(resolve_trust(None, true), ForwardedTrust::Peer);
assert_eq!(resolve_trust(None, false), proxies(1));
}
fn parts_with(
header: Option<(&str, &str)>,
peer: Option<SocketAddr>,
) -> axum::http::request::Parts {
let mut builder = axum::http::Request::builder();
if let Some((name, value)) = header {
builder = builder.header(name, value);
}
let mut parts = builder.body(()).unwrap().into_parts().0;
if let Some(addr) = peer {
parts.extensions.insert(ConnectInfo(addr));
}
parts
}
#[tokio::test]
async fn client_ip_falls_back_to_peer_socket() {
let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap();
let mut parts = parts_with(None, Some(peer));
let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(ip.into_string(), "203.0.113.7");
}
#[tokio::test]
async fn client_ip_ignores_forwarded_when_config_absent() {
let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap();
let mut parts = parts_with(
Some(("x-forwarded-for", "198.51.100.4, 10.0.0.1")),
Some(peer),
);
let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(ip.into_string(), "203.0.113.7");
}
#[tokio::test]
async fn client_ip_unknown_without_headers_or_peer() {
let mut parts = parts_with(None, None);
let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(ip.into_string(), "unknown");
}
#[tokio::test]
async fn client_ip_renders_ipv6_peer_without_brackets() {
let peer: SocketAddr = "[2001:db8::beef]:51000".parse().unwrap();
let mut parts = parts_with(None, Some(peer));
let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(ip.into_string(), "2001:db8::beef");
}
#[test]
fn extract_client_ip_single_proxy_takes_rightmost_forwarded_hop() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-for",
"9.9.9.9, 198.51.100.4, 10.0.0.1".parse().unwrap(),
);
let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap();
assert_eq!(
extract_client_ip(&headers, Some(peer), proxies(1)),
"10.0.0.1"
);
}
#[test]
fn extract_client_ip_two_proxies_skips_inner_hop() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-for",
"9.9.9.9, 198.51.100.4, 10.0.0.1".parse().unwrap(),
);
let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap();
assert_eq!(
extract_client_ip(&headers, Some(peer), proxies(2)),
"198.51.100.4"
);
}
#[test]
fn extract_client_ip_more_trusted_proxies_than_hops_uses_peer() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "10.0.0.1".parse().unwrap());
let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap();
assert_eq!(
extract_client_ip(&headers, Some(peer), proxies(2)),
"203.0.113.7"
);
}
#[test]
fn extract_client_ip_ignores_forwarded_headers_for_direct_peer() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "9.9.9.9".parse().unwrap());
headers.insert("x-real-ip", "9.9.9.9".parse().unwrap());
let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap();
assert_eq!(
extract_client_ip(&headers, Some(peer), ForwardedTrust::Peer),
"203.0.113.7"
);
}
#[test]
fn extract_client_ip_direct_peer_without_socket_is_unknown() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "9.9.9.9".parse().unwrap());
assert_eq!(
extract_client_ip(&headers, None, ForwardedTrust::Peer),
"unknown"
);
}
#[test]
fn test_parse_repeated_query_param_repeated() {
@@ -0,0 +1,167 @@
mod common;
mod helpers;
use chrono::{DateTime, Duration, Utc};
use common::{base_url, client, get_test_repos};
use futures::StreamExt;
use helpers::verify_new_account;
use reqwest::StatusCode;
use serde_json::{Value, json};
use tranquil_oauth::{
AuthorizationRequestParameters, ClientAuth, CodeChallengeMethod, ResponseType, TokenData,
TokenId,
};
use tranquil_types::Did;
async fn create_account_and_get_did(handle: &str, email: &str, password: &str) -> Did {
let client = client();
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&json!({
"handle": handle,
"email": email,
"password": password,
}))
.send()
.await
.expect("createAccount request failed");
assert_eq!(res.status(), StatusCode::OK, "createAccount failed");
let body: Value = res.json().await.expect("invalid createAccount JSON");
let did_str = body["did"]
.as_str()
.expect("no did in response")
.to_string();
let _ = verify_new_account(&client, &did_str).await;
Did::new(did_str).expect("invalid DID format")
}
fn make_token_data(did: &Did, token_id: &str, created_at: DateTime<Utc>) -> TokenData {
let client_id = "https://squid.nel.pet/client".to_string();
TokenData {
did: did.clone(),
token_id: TokenId(token_id.to_string()),
created_at,
updated_at: created_at,
expires_at: created_at + Duration::hours(1),
client_id: client_id.clone(),
client_auth: ClientAuth::None,
device_id: None,
parameters: AuthorizationRequestParameters {
response_type: ResponseType::Code,
client_id,
redirect_uri: "https://squid.nel.pet/cb".to_string(),
scope: None,
state: None,
code_challenge: "x".to_string(),
code_challenge_method: CodeChallengeMethod::S256,
response_mode: None,
login_hint: None,
dpop_jkt: None,
prompt: None,
extra: None,
},
details: None,
code: None,
current_refresh_token: None,
scope: None,
controller_did: None,
}
}
async fn seed_tokens(repos: &tranquil_db::PostgresRepositories, tokens: &[TokenData]) {
futures::stream::iter(tokens)
.for_each(|token| async move {
repos
.oauth
.create_token(token)
.await
.expect("token insert failed");
})
.await;
}
#[tokio::test]
async fn delete_oldest_tokens_evicts_lowest_created_at() {
let ts = Utc::now().timestamp_millis();
let handle = format!("tok-evict-{}.test", ts);
let email = format!("tok-evict-{}@test.com", ts);
let did = create_account_and_get_did(&handle, &email, "EvictTest123!").await;
let repos = get_test_repos().await;
let base = Utc::now();
let token_ids: Vec<String> = (0..5).map(|i| format!("tok-{}-{}", ts, i)).collect();
let tokens: Vec<TokenData> = (0i64..)
.zip(token_ids.iter())
.map(|(offset, tid)| make_token_data(&did, tid, base + Duration::seconds(offset)))
.collect();
seed_tokens(repos, &tokens).await;
let count_before = repos
.oauth
.count_tokens_for_user(&did)
.await
.expect("count failed");
assert_eq!(count_before, 5, "all 5 tokens should be present");
let deleted = repos
.oauth
.delete_oldest_tokens_for_user(&did, 3)
.await
.expect("delete failed");
assert_eq!(deleted, 2, "two oldest tokens should be deleted");
let remaining = repos
.oauth
.list_tokens_for_user(&did)
.await
.expect("list failed");
assert_eq!(remaining.len(), 3, "three newest tokens should remain");
let remaining_ids: std::collections::HashSet<String> =
remaining.iter().map(|t| t.token_id.0.clone()).collect();
let expected_ids: std::collections::HashSet<String> = token_ids[2..].iter().cloned().collect();
assert_eq!(
remaining_ids, expected_ids,
"surviving tokens must be the three newest by created_at"
);
}
#[tokio::test]
async fn delete_oldest_tokens_no_op_when_under_keep_count() {
let ts = Utc::now().timestamp_millis();
let handle = format!("tok-evict-noop-{}.test", ts);
let email = format!("tok-evict-noop-{}@test.com", ts);
let did = create_account_and_get_did(&handle, &email, "EvictTest123!").await;
let repos = get_test_repos().await;
let base = Utc::now();
let tokens: Vec<TokenData> = (0i64..2)
.map(|offset| {
make_token_data(
&did,
&format!("noop-tok-{}-{}", ts, offset),
base + Duration::seconds(offset),
)
})
.collect();
seed_tokens(repos, &tokens).await;
let deleted = repos
.oauth
.delete_oldest_tokens_for_user(&did, 5)
.await
.expect("delete failed");
assert_eq!(deleted, 0, "nothing to delete when count <= keep");
let remaining = repos
.oauth
.list_tokens_for_user(&did)
.await
.expect("list failed");
assert_eq!(remaining.len(), 2);
}
+2 -8
View File
@@ -402,10 +402,7 @@ async fn test_apply_writes_delete_then_create_same_rkey() {
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
])
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
.send()
.await
.expect("Failed to list records");
@@ -484,10 +481,7 @@ async fn test_apply_writes_create_then_delete_same_rkey() {
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
])
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
.send()
.await
.expect("Failed to list records");
+1 -37
View File
@@ -1,43 +1,7 @@
mod common;
use tranquil_pds::comms::{
SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value,
};
use tranquil_pds::comms::{SendError, is_valid_phone_number, is_valid_signal_username};
use tranquil_pds::image::{ImageError, ImageProcessor};
#[test]
fn test_header_injection_sanitization() {
let malicious = "Injected\r\nBcc: attacker@evil.com";
let sanitized = sanitize_header_value(malicious);
assert!(!sanitized.contains('\r') && !sanitized.contains('\n'));
assert!(sanitized.contains("Injected") && sanitized.contains("Bcc:"));
let normal = "Normal Subject Line";
assert_eq!(sanitize_header_value(normal), "Normal Subject Line");
let padded = " Subject ";
assert_eq!(sanitize_header_value(padded), "Subject");
let multi_newline = "Line1\r\nLine2\nLine3\rLine4";
let sanitized = sanitize_header_value(multi_newline);
assert!(!sanitized.contains('\r') && !sanitized.contains('\n'));
assert!(sanitized.contains("Line1") && sanitized.contains("Line4"));
let header_injection = "Normal Subject\r\nBcc: attacker@evil.com\r\nX-Injected: value";
let sanitized = sanitize_header_value(header_injection);
assert_eq!(sanitized.split("\r\n").count(), 1);
assert!(
sanitized.contains("Normal Subject")
&& sanitized.contains("Bcc:")
&& sanitized.contains("X-Injected:")
);
let with_null = "client\0id";
assert!(sanitize_header_value(with_null).contains("client"));
let long_input = "x".repeat(10000);
assert!(!sanitize_header_value(&long_input).is_empty());
}
#[test]
fn test_phone_number_validation() {
assert!(is_valid_phone_number("+1234567890"));
+32
View File
@@ -150,6 +150,7 @@ async fn seed_user(repos: &PostgresRepositories, did: &Did, handle: &Handle) ->
telegram_username: None,
signal_username: None,
deactivated_at: None,
inbound_migration: false,
encrypted_key_bytes: vec![0u8; 32],
encryption_version: 0,
reserved_key_id: None,
@@ -1465,6 +1466,37 @@ async fn parity_delete_all_records() {
assert_eq!(store_colls.len(), 0);
}
#[tokio::test]
async fn parity_account_deletion_clears_records_on_reregister() {
let f = ParityFixture::new().await;
let did = test_did("cuttle");
let handle = test_handle("cuttle");
let collection = test_nsid("post");
let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await;
let records: Vec<(Rkey, CidLink)> = (0u8..3)
.map(|i| (test_rkey(&format!("3l{:02}aaaaaaaaa", i)), test_cid(i + 1)))
.collect();
seed_records(&f.pg, pg_uid, &collection, &records).await;
seed_records(&f.store, store_uid, &collection, &records).await;
f.pg.user
.delete_account_complete(pg_uid, &did)
.await
.unwrap();
f.store
.user
.delete_account_complete(store_uid, &did)
.await
.unwrap();
let (pg_uid2, store_uid2) = seed_repos(&f, &did, &handle).await;
assert_eq!(f.pg.repo.count_records(pg_uid2).await.unwrap(), 0);
assert_eq!(f.store.repo.count_records(store_uid2).await.unwrap(), 0);
}
#[tokio::test]
async fn parity_plc_tokens() {
let f = ParityFixture::new().await;
+10
View File
@@ -12,13 +12,22 @@ tranquil-oauth-server = { workspace = true }
tranquil-config = { workspace = true }
tranquil-signal = { workspace = true }
arc-swap = { workspace = true }
axum = { workspace = true }
clap = { workspace = true }
dotenvy = { workspace = true }
ed25519-dalek = { workspace = true }
futures-util = { workspace = true }
hex = { workspace = true }
hyper = { workspace = true }
hyper-util = { workspace = true }
rustls = { workspace = true }
rustls-pemfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-rustls = { workspace = true }
tokio-util = { workspace = true }
tower = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
@@ -27,3 +36,4 @@ default = ["frontend", "s3", "valkey"]
frontend = ["tranquil-pds/frontend"]
s3 = ["tranquil-pds/s3"]
valkey = ["tranquil-pds/valkey"]
native-tls-roots = ["tranquil-pds/native-tls-roots"]
+65 -36
View File
@@ -14,6 +14,8 @@ use tranquil_pds::scheduled::{
};
use tranquil_pds::state::AppState;
mod tls;
#[derive(Parser)]
#[command(name = "tranquil-pds", version = BUILD_VERSION, about = "Tranquil AT Protocol PDS")]
struct Cli {
@@ -53,16 +55,19 @@ async fn main() -> ExitCode {
return ExitCode::FAILURE;
}
};
match config.validate(*ignore_secrets) {
Ok(()) => {
println!("Configuration is valid.");
ExitCode::SUCCESS
}
Err(e) => {
eprint!("{e}");
ExitCode::FAILURE
}
if let Err(e) = config.validate(*ignore_secrets) {
eprint!("{e}");
return ExitCode::FAILURE;
}
if !*ignore_secrets
&& let Some((cert, key)) = config.server.tls.material()
&& let Err(e) = tls::load_certified_key(cert, key)
{
eprintln!("TLS material invalid: {e}");
return ExitCode::FAILURE;
}
println!("Configuration is valid.");
ExitCode::SUCCESS
}
};
}
@@ -110,19 +115,15 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut state = AppState::new(shutdown.clone()).await?;
let signal_sender = if tranquil_config::get().signal.enabled {
let slot = Arc::new(tranquil_signal::SignalSlot::default());
state = state.with_signal_sender(slot.clone());
if let Some(provider) = &state.signal_store_provider
&& let Some(client) = provider.load_signal_client(shutdown.clone()).await
{
slot.set_client(client).await;
info!("Signal device already linked");
}
Some(SignalSender::new(slot))
} else {
None
};
let signal_slot = Arc::new(tranquil_signal::SignalSlot::default());
state = state.with_signal_sender(signal_slot.clone());
if let Some(provider) = &state.signal_store_provider
&& let Some(client) = provider.load_signal_client(shutdown.clone()).await
{
signal_slot.set_client(client).await;
info!("Signal device linked");
}
let signal_sender = SignalSender::new(signal_slot);
tranquil_sync::listener::start_sequencer_listener(state.clone()).await;
@@ -141,11 +142,18 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let cfg = tranquil_config::get();
if let Some(email_sender) = EmailSender::from_config(cfg) {
info!("Email comms enabled");
comms_service = comms_service.register_sender(email_sender);
} else {
warn!("Email comms disabled (MAIL_FROM_ADDRESS not set)");
match EmailSender::from_config(cfg) {
Ok(Some(email_sender)) => {
info!("Email comms enabled");
comms_service = comms_service.register_sender(email_sender);
}
Ok(None) => {
warn!("Email comms disabled (MAIL_FROM_ADDRESS unset)");
}
Err(e) => {
error!(error = %e, "Email configuration invalid");
return Err(e.into());
}
}
if let Some(discord_sender) = DiscordSender::from_config(cfg) {
@@ -220,10 +228,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
comms_service = comms_service.register_sender(telegram_sender);
}
if let Some(sender) = signal_sender {
info!("Signal comms enabled");
comms_service = comms_service.register_sender(sender);
}
comms_service = comms_service.register_sender(signal_sender);
let comms_handle = tokio::spawn(comms_service.run(shutdown.clone()));
@@ -281,11 +286,35 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.await
.map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
let server_handle = tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(shutdown.clone().cancelled_owned())
.await
});
let server_handle = match cfg.server.tls.material() {
Some((cert_path, key_path)) => {
let initial = tls::load_certified_key(cert_path, key_path)
.map_err(|e| format!("Failed to load TLS material: {e}"))?;
let resolver = Arc::new(tls::ReloadableCertResolver::new(initial));
let server_config = Arc::new(
tls::build_server_config(resolver.clone())
.map_err(|e| format!("Failed to build TLS configuration: {e}"))?,
);
tls::spawn_reload_handler(
resolver,
cert_path.to_string(),
key_path.to_string(),
shutdown.clone(),
);
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))
}
None => {
let make_service = app.into_make_service_with_connect_info::<SocketAddr>();
let shutdown = shutdown.clone();
tokio::spawn(async move {
axum::serve(listener, make_service)
.with_graceful_shutdown(shutdown.cancelled_owned())
.await
})
}
};
if let Some((sender, app_id, webhook_url)) = deferred_discord_endpoint {
tokio::spawn(async move {
+558
View File
@@ -0,0 +1,558 @@
use std::io::BufReader;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use axum::Router;
use axum::extract::ConnectInfo;
use futures_util::StreamExt;
use hyper::Request;
use hyper::body::Incoming;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto;
use rustls::ServerConfig;
use rustls::crypto::ring;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::server::{ClientHello, ResolvesServerCert};
use rustls::sign::CertifiedKey;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use tokio_rustls::TlsAcceptor;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use tower::Service;
use tracing::{debug, warn};
const SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
const ACCEPT_ERROR_BACKOFF: Duration = Duration::from_secs(1);
const MAX_CONCURRENT_HANDSHAKES: usize = 512;
#[derive(Debug, thiserror::Error)]
pub enum TlsError {
#[error("reading {path}: {source}")]
Read {
path: String,
source: std::io::Error,
},
#[error("parsing {path}: {message}")]
Parse { path: String, message: String },
#[error("no certificates found in {0}")]
NoCertificates(String),
#[error("no private key found in {0}")]
NoPrivateKey(String),
#[error("unusable private key: {0}")]
SigningKey(String),
#[error("building server config: {0}")]
Config(String),
#[error("certificate and private key do not match: {0}")]
KeyMismatch(String),
}
pub struct ReloadableCertResolver {
current: ArcSwap<CertifiedKey>,
}
impl std::fmt::Debug for ReloadableCertResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ReloadableCertResolver")
.finish_non_exhaustive()
}
}
impl ReloadableCertResolver {
pub fn new(initial: CertifiedKey) -> Self {
Self {
current: ArcSwap::from_pointee(initial),
}
}
pub fn store(&self, key: CertifiedKey) {
self.current.store(Arc::new(key));
}
}
impl ResolvesServerCert for ReloadableCertResolver {
fn resolve(&self, _client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
Some(self.current.load_full())
}
}
pub fn load_certified_key(cert_path: &str, key_path: &str) -> Result<CertifiedKey, TlsError> {
let certs = load_certs(cert_path)?;
let key = load_private_key(key_path)?;
let signing_key =
ring::sign::any_supported_type(&key).map_err(|e| TlsError::SigningKey(e.to_string()))?;
let certified = CertifiedKey::new(certs, signing_key);
certified
.keys_match()
.map_err(|e| TlsError::KeyMismatch(e.to_string()))?;
Ok(certified)
}
fn load_certs(path: &str) -> Result<Vec<CertificateDer<'static>>, TlsError> {
let bytes = std::fs::read(path).map_err(|source| TlsError::Read {
path: path.to_string(),
source,
})?;
let mut reader = BufReader::new(bytes.as_slice());
let certs = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| TlsError::Parse {
path: path.to_string(),
message: e.to_string(),
})?;
match certs.is_empty() {
true => Err(TlsError::NoCertificates(path.to_string())),
false => Ok(certs),
}
}
fn load_private_key(path: &str) -> Result<PrivateKeyDer<'static>, TlsError> {
let bytes = std::fs::read(path).map_err(|source| TlsError::Read {
path: path.to_string(),
source,
})?;
let mut reader = BufReader::new(bytes.as_slice());
rustls_pemfile::private_key(&mut reader)
.map_err(|e| TlsError::Parse {
path: path.to_string(),
message: e.to_string(),
})?
.ok_or_else(|| TlsError::NoPrivateKey(path.to_string()))
}
pub fn build_server_config(
resolver: Arc<ReloadableCertResolver>,
) -> Result<ServerConfig, TlsError> {
let provider = Arc::new(ring::default_provider());
let mut config = ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| TlsError::Config(e.to_string()))?
.with_no_client_auth()
.with_cert_resolver(resolver);
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Ok(config)
}
pub fn spawn_reload_handler(
resolver: Arc<ReloadableCertResolver>,
cert_path: String,
key_path: String,
shutdown: CancellationToken,
) {
#[cfg(unix)]
tokio::spawn(async move {
use tokio::signal::unix::{SignalKind, signal};
let mut hangup = match signal(SignalKind::hangup()) {
Ok(stream) => stream,
Err(e) => {
tracing::error!("Failed to install SIGHUP handler: {e}");
return;
}
};
loop {
tokio::select! {
_ = shutdown.cancelled() => break,
received = hangup.recv() => {
if received.is_none() {
break;
}
match load_certified_key(&cert_path, &key_path) {
Ok(key) => {
resolver.store(key);
tracing::info!("TLS certificate and key reloaded");
}
Err(e) => {
warn!("TLS reload failed, keeping existing certificate: {e}");
}
}
}
}
}
});
#[cfg(not(unix))]
let _ = (resolver, cert_path, key_path, shutdown);
}
fn is_connection_error(e: &std::io::Error) -> bool {
matches!(
e.kind(),
std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::ConnectionReset
)
}
pub async fn serve_tls(
listener: TcpListener,
app: Router,
server_config: Arc<ServerConfig>,
shutdown: CancellationToken,
) -> std::io::Result<()> {
let acceptor = TlsAcceptor::from(server_config);
let tracker = TaskTracker::new();
let handshake_limiter = Arc::new(Semaphore::new(MAX_CONCURRENT_HANDSHAKES));
let connections = futures_util::stream::unfold(listener, |listener| async move {
Some((listener.accept().await, listener))
});
connections
.take_until(shutdown.clone().cancelled_owned())
.for_each(|accepted| {
let acceptor = acceptor.clone();
let app = app.clone();
let conn_shutdown = shutdown.clone();
let limiter = handshake_limiter.clone();
let tracker = &tracker;
async move {
match accepted {
Ok((tcp, peer)) => {
let permit = tokio::select! {
biased;
_ = conn_shutdown.cancelled() => return,
permit = limiter.acquire_owned() => match permit {
Ok(permit) => permit,
Err(_) => return,
},
};
tracker.spawn(serve_connection(
acceptor,
app,
tcp,
peer,
conn_shutdown,
permit,
));
}
Err(e) if is_connection_error(&e) => {
debug!("TLS accept connection error: {e}");
}
Err(e) => {
warn!(
"TLS accept failed, pausing {ACCEPT_ERROR_BACKOFF:?} before retry: {e}"
);
tokio::select! {
_ = tokio::time::sleep(ACCEPT_ERROR_BACKOFF) => {}
_ = conn_shutdown.cancelled() => {}
}
}
}
}
})
.await;
tracker.close();
tracker.wait().await;
Ok(())
}
async fn serve_connection(
acceptor: TlsAcceptor,
app: Router,
tcp: TcpStream,
peer: SocketAddr,
shutdown: CancellationToken,
handshake_permit: OwnedSemaphorePermit,
) {
let tls_stream = tokio::select! {
result = tokio::time::timeout(HANDSHAKE_TIMEOUT, acceptor.accept(tcp)) => match result {
Ok(Ok(stream)) => stream,
Ok(Err(e)) => {
debug!("TLS handshake with {peer} failed: {e}");
return;
}
Err(_) => {
debug!("TLS handshake with {peer} timed out after {HANDSHAKE_TIMEOUT:?}");
return;
}
},
_ = shutdown.cancelled() => {
debug!("shutdown during TLS handshake with {peer}");
return;
}
};
drop(handshake_permit);
let service = hyper::service::service_fn(move |mut request: Request<Incoming>| {
request.extensions_mut().insert(ConnectInfo(peer));
app.clone().call(request)
});
let builder = auto::Builder::new(TokioExecutor::new());
let connection = builder.serve_connection_with_upgrades(TokioIo::new(tls_stream), service);
tokio::pin!(connection);
tokio::select! {
result = connection.as_mut() => {
if let Err(e) = result {
debug!("connection from {peer} ended: {e}");
}
}
_ = shutdown.cancelled() => {
connection.as_mut().graceful_shutdown();
match tokio::time::timeout(SHUTDOWN_GRACE, connection.as_mut()).await {
Ok(Err(e)) => debug!("connection from {peer} ended during shutdown: {e}"),
Err(_) => debug!("connection from {peer} did not drain within grace, dropping"),
Ok(Ok(())) => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
const CERT_1: &str = "-----BEGIN CERTIFICATE-----\n\
MIIBrTCCAVKgAwIBAgIUWIlnxLpgk7qp8We8ya6UW1I7p0MwCgYIKoZIzj0EAwIw\n\
FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUyNjEyMTQyMloXDTM2MDUyMzEy\n\
MTQyMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D\n\
AQcDQgAEgq5UvmRilQh66D5C+78TdULpCuIrI7dtvBB589iJK8Gq14SW9ewkbiWD\n\
QrXirV47GPzRnODrDIqFSCa4yH+dz6OBgTB/MB0GA1UdDgQWBBSVcvSAd4XB3SCU\n\
e8MKSOm9i6yigjAfBgNVHSMEGDAWgBSVcvSAd4XB3SCUe8MKSOm9i6yigjAPBgNV\n\
HRMBAf8EBTADAQH/MCwGA1UdEQQlMCOCCWxvY2FsaG9zdIcQAAAAAAAAAAAAAAAA\n\
AAAAAYcEfwAAATAKBggqhkjOPQQDAgNJADBGAiEA6pIKG7uRbgzuOCDY1Rm+QCuF\n\
/UTOjWKrfZhoDnXP+swCIQCV7p6vRSt0GnbRzIIcN8UM68cXDZX+Nk0XofZaN217\n\
mg==\n\
-----END CERTIFICATE-----\n";
const KEY_1: &str = "-----BEGIN PRIVATE KEY-----\n\
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMobX2BajiDVtV5Ti\n\
kiJ8qEbduI0HvT/qORtLjjCXQ5OhRANCAASCrlS+ZGKVCHroPkL7vxN1QukK4isj\n\
t228EHnz2IkrwarXhJb17CRuJYNCteKtXjsY/NGc4OsMioVIJrjIf53P\n\
-----END PRIVATE KEY-----\n";
const CERT_2: &str = "-----BEGIN CERTIFICATE-----\n\
MIIBrDCCAVKgAwIBAgIUJjaLQsKBClkIbtSmDK9vZ9gCrbQwCgYIKoZIzj0EAwIw\n\
FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUyNjEyMTQyMloXDTM2MDUyMzEy\n\
MTQyMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D\n\
AQcDQgAEI6ljji6CAII88C48Hu7kzEjnV9gMVs8v8Oom04PfcXPR/GSUc0MYz3y4\n\
LXZC2yNJl40ynzuXNhisk/mQjYbKYaOBgTB/MB0GA1UdDgQWBBTqLGV3rtN9hiuR\n\
oHUPNnvkwz/DbDAfBgNVHSMEGDAWgBTqLGV3rtN9hiuRoHUPNnvkwz/DbDAPBgNV\n\
HRMBAf8EBTADAQH/MCwGA1UdEQQlMCOCCWxvY2FsaG9zdIcQAAAAAAAAAAAAAAAA\n\
AAAAAYcEfwAAATAKBggqhkjOPQQDAgNIADBFAiAMVxuI5vyDYi1RtsyuiB+sIl1D\n\
SdSOaWIgtxPVs5E0CQIhAIrrra+TPrmE8JrjwJBlsONl3oTlOcfDA9WP/FnYbHuv\n\
-----END CERTIFICATE-----\n";
const KEY_2: &str = "-----BEGIN PRIVATE KEY-----\n\
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgyBJsGRjta0gqCcBH\n\
LI5Q1uj42QD1KUfmkOj+o4jlDlmhRANCAAQjqWOOLoIAgjzwLjwe7uTMSOdX2AxW\n\
zy/w6ibTg99xc9H8ZJRzQxjPfLgtdkLbI0mXjTKfO5c2GKyT+ZCNhsph\n\
-----END PRIVATE KEY-----\n";
#[derive(Debug)]
struct AcceptAnyServerCert;
impl rustls::client::danger::ServerCertVerifier for AcceptAnyServerCert {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
fn write_temp(contents: &str) -> std::path::PathBuf {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"tranquil_tls_test_{}_{unique}.pem",
std::process::id()
));
std::fs::write(&path, contents).expect("write temp pem");
path
}
#[test]
fn loads_certificate_and_key() {
let cert = write_temp(CERT_1);
let key = write_temp(KEY_1);
let certified = load_certified_key(cert.to_str().unwrap(), key.to_str().unwrap())
.expect("load certified key");
assert_eq!(certified.cert.len(), 1);
}
#[test]
fn missing_certificate_file_is_read_error() {
let result = load_certs("/nonexistent/tranquil/cert.pem");
assert!(matches!(result, Err(TlsError::Read { .. })));
}
#[test]
fn empty_certificate_file_has_no_certificates() {
let cert = write_temp("");
let result = load_certs(cert.to_str().unwrap());
assert!(matches!(result, Err(TlsError::NoCertificates(_))));
}
#[test]
fn certificate_without_key_is_missing_key() {
let cert_only = write_temp(CERT_1);
let result = load_private_key(cert_only.to_str().unwrap());
assert!(matches!(result, Err(TlsError::NoPrivateKey(_))));
}
#[test]
fn server_config_advertises_h2_and_http1() {
let cert = write_temp(CERT_1);
let key = write_temp(KEY_1);
let certified = load_certified_key(cert.to_str().unwrap(), key.to_str().unwrap()).unwrap();
let resolver = Arc::new(ReloadableCertResolver::new(certified));
let config = build_server_config(resolver).expect("build server config");
assert_eq!(
config.alpn_protocols,
vec![b"h2".to_vec(), b"http/1.1".to_vec()]
);
}
#[test]
fn reload_swaps_the_served_certificate() {
let cert1 = write_temp(CERT_1);
let key1 = write_temp(KEY_1);
let cert2 = write_temp(CERT_2);
let key2 = write_temp(KEY_2);
let first = load_certified_key(cert1.to_str().unwrap(), key1.to_str().unwrap()).unwrap();
let resolver = ReloadableCertResolver::new(first);
let before = resolver.current.load_full().cert.clone();
let second = load_certified_key(cert2.to_str().unwrap(), key2.to_str().unwrap()).unwrap();
resolver.store(second);
let after = resolver.current.load_full().cert.clone();
assert_ne!(before, after);
}
#[tokio::test]
async fn terminates_tls_over_ipv6_and_negotiates_alpn() {
use rustls::pki_types::ServerName;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_rustls::TlsConnector;
let cert = write_temp(CERT_1);
let key = write_temp(KEY_1);
let certified = load_certified_key(cert.to_str().unwrap(), key.to_str().unwrap()).unwrap();
let resolver = Arc::new(ReloadableCertResolver::new(certified));
let server_config = Arc::new(build_server_config(resolver).unwrap());
let app = Router::new().route("/", axum::routing::get(|| async { "ok" }));
let listener = TcpListener::bind("[::1]:0").await.unwrap();
let addr = listener.local_addr().unwrap();
assert!(addr.is_ipv6(), "expected ipv6 bind, got {addr}");
let shutdown = CancellationToken::new();
let server = tokio::spawn(serve_tls(listener, app, server_config, shutdown.clone()));
let mut client_config =
rustls::ClientConfig::builder_with_provider(Arc::new(ring::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert))
.with_no_client_auth();
client_config.alpn_protocols = vec![b"http/1.1".to_vec()];
let connector = TlsConnector::from(Arc::new(client_config));
let server_name = ServerName::try_from("localhost").unwrap();
let tcp = TcpStream::connect(addr).await.unwrap();
let mut tls = connector.connect(server_name, tcp).await.unwrap();
let alpn = tls.get_ref().1.alpn_protocol().map(<[u8]>::to_vec);
assert_eq!(alpn, Some(b"http/1.1".to_vec()));
tls.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response = Vec::new();
tls.read_to_end(&mut response).await.unwrap();
let text = String::from_utf8_lossy(&response);
assert!(
text.starts_with("HTTP/1.1 200"),
"unexpected response: {text}"
);
assert!(text.trim_end().ends_with("ok"), "unexpected body: {text}");
shutdown.cancel();
let _ = tokio::time::timeout(Duration::from_secs(5), server).await;
}
#[test]
fn mismatched_cert_and_key_is_rejected() {
let cert = write_temp(CERT_1);
let key = write_temp(KEY_2);
let result = load_certified_key(cert.to_str().unwrap(), key.to_str().unwrap());
assert!(matches!(result, Err(TlsError::KeyMismatch(_))));
}
#[tokio::test]
async fn negotiates_h2_when_client_offers_only_h2() {
use rustls::pki_types::ServerName;
use tokio_rustls::TlsConnector;
let cert = write_temp(CERT_1);
let key = write_temp(KEY_1);
let certified = load_certified_key(cert.to_str().unwrap(), key.to_str().unwrap()).unwrap();
let resolver = Arc::new(ReloadableCertResolver::new(certified));
let server_config = Arc::new(build_server_config(resolver).unwrap());
let app = Router::new().route("/", axum::routing::get(|| async { "ok" }));
let listener = TcpListener::bind("[::1]:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let shutdown = CancellationToken::new();
let server = tokio::spawn(serve_tls(listener, app, server_config, shutdown.clone()));
let mut client_config =
rustls::ClientConfig::builder_with_provider(Arc::new(ring::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert))
.with_no_client_auth();
client_config.alpn_protocols = vec![b"h2".to_vec()];
let connector = TlsConnector::from(Arc::new(client_config));
let server_name = ServerName::try_from("localhost").unwrap();
let tcp = TcpStream::connect(addr).await.unwrap();
let tls = connector.connect(server_name, tcp).await.unwrap();
let alpn = tls.get_ref().1.alpn_protocol().map(<[u8]>::to_vec);
assert_eq!(alpn, Some(b"h2".to_vec()));
shutdown.cancel();
let _ = tokio::time::timeout(Duration::from_secs(5), server).await;
}
}
+1 -1
View File
@@ -352,7 +352,7 @@ impl SignalClient {
let manager = init_rx
.await
.ok()?
.map_err(|e| tracing::error!(error = %e, "failed to load registered signal manager"))
.map_err(|e| tracing::debug!(error = %e, "no linked signal device"))
.ok()?;
Self::from_manager(manager, shutdown)
@@ -76,6 +76,7 @@ fn tiny_config() -> GauntletConfig {
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -64,6 +64,16 @@ enum Cmd {
#[arg(long)]
config: Option<PathBuf>,
/// Tempdir parent for `IoBackend::Real` seeds only - ignored for
/// flaky-mount and simulated backends. Repeatable; each rayon worker
/// thread is pinned to one root so concurrent seeds on different
/// threads land on different mounts. Default `/tmp`. Also reads
/// colon-separated paths from `GAUNTLET_SCRATCH_ROOTS`. Set
/// `RAYON_NUM_THREADS=N` to cap workers; for full distribution pass
/// one root per worker.
#[arg(long)]
scratch_root: Vec<PathBuf>,
/// Skip shrinking when dumping regressions.
#[arg(long)]
no_shrink: bool,
@@ -95,6 +105,13 @@ enum Cmd {
#[arg(long)]
dump_regressions: Option<PathBuf>,
/// Same as `farm --scratch-root`: tempdir parent for
/// `IoBackend::Real` seeds only, pinned per worker thread. Ignored
/// for flaky-mount and simulated backends. Repeatable; reads
/// colon-separated paths from `GAUNTLET_SCRATCH_ROOTS`.
#[arg(long)]
scratch_root: Vec<PathBuf>,
/// Skip shrinking when dumping regressions.
#[arg(long)]
no_shrink: bool,
@@ -159,6 +176,8 @@ struct ConfigFile {
#[serde(default)]
dump_regressions: Option<PathBuf>,
#[serde(default)]
scratch_roots: Vec<PathBuf>,
#[serde(default)]
overrides: ConfigOverrides,
}
@@ -178,6 +197,8 @@ struct SweepConfigFile {
#[serde(default)]
dump_regressions: Option<PathBuf>,
#[serde(default)]
scratch_roots: Vec<PathBuf>,
#[serde(default)]
base_overrides: ConfigOverrides,
#[serde(default)]
axes: SweepAxes,
@@ -405,6 +426,7 @@ struct FarmPlan {
seeds: u64,
hours: Option<f64>,
dump_regressions: Option<PathBuf>,
scratch_roots: Vec<PathBuf>,
overrides: ConfigOverrides,
shrink: bool,
shrink_budget: usize,
@@ -418,6 +440,7 @@ fn resolve_farm(
hours: Option<f64>,
dump_regressions: Option<PathBuf>,
config: Option<PathBuf>,
scratch_root: Vec<PathBuf>,
shrink: bool,
shrink_budget: usize,
) -> Result<FarmPlan, String> {
@@ -443,6 +466,11 @@ fn resolve_farm(
}
let dump_regressions =
dump_regressions.or_else(|| file.as_ref().and_then(|f| f.dump_regressions.clone()));
let file_scratch_roots = file
.as_ref()
.map(|f| f.scratch_roots.clone())
.unwrap_or_default();
let scratch_roots = resolve_scratch_roots(scratch_root, file_scratch_roots)?;
let overrides = file.map(|f| f.overrides).unwrap_or_default();
Ok(FarmPlan {
scenario,
@@ -450,12 +478,50 @@ fn resolve_farm(
seeds,
hours,
dump_regressions,
scratch_roots,
overrides,
shrink,
shrink_budget,
})
}
const SCRATCH_ROOTS_ENV: &str = "GAUNTLET_SCRATCH_ROOTS";
fn resolve_scratch_roots(
cli: Vec<PathBuf>,
config_file: Vec<PathBuf>,
) -> Result<Vec<PathBuf>, String> {
let env_roots: Vec<PathBuf> = std::env::var(SCRATCH_ROOTS_ENV)
.ok()
.filter(|s| !s.is_empty())
.map(|s| s.split(':').map(PathBuf::from).collect())
.unwrap_or_default();
let candidate: Vec<PathBuf> = if !cli.is_empty() {
cli
} else if !config_file.is_empty() {
config_file
} else {
env_roots
};
candidate
.into_iter()
.map(|p| validate_scratch_root(&p).map(|_| p))
.collect()
}
fn validate_scratch_root(path: &Path) -> Result<(), String> {
match path.metadata() {
Ok(m) if m.is_dir() => {}
Ok(_) => return Err(format!("scratch root not a directory: {}", path.display())),
Err(e) => return Err(format!("scratch root {}: {e}", path.display())),
}
tempfile::Builder::new()
.prefix(".tranquil-gauntlet-probe-")
.tempfile_in(path)
.map(|_| ())
.map_err(|e| format!("scratch root {} not writable: {e}", path.display()))
}
fn validate_hours(h: f64) -> Result<(), String> {
if !h.is_finite() || h <= 0.0 {
return Err(format!("invalid --hours={h}: must be positive and finite"));
@@ -564,6 +630,7 @@ fn main() -> ExitCode {
hours,
dump_regressions,
config,
scratch_root,
no_shrink,
shrink_budget,
} => {
@@ -574,6 +641,7 @@ fn main() -> ExitCode {
hours,
dump_regressions,
config,
scratch_root,
!no_shrink,
shrink_budget,
) {
@@ -625,6 +693,7 @@ fn main() -> ExitCode {
seed_start,
seeds,
dump_regressions,
scratch_root,
no_shrink,
shrink_budget,
max_runs,
@@ -634,6 +703,7 @@ fn main() -> ExitCode {
seed_start,
seeds,
dump_regressions,
scratch_root,
!no_shrink,
shrink_budget,
max_runs,
@@ -659,17 +729,20 @@ struct SweepPlan {
seed_start: u64,
seeds: u64,
dump_regressions: Option<PathBuf>,
scratch_roots: Vec<PathBuf>,
shrink: bool,
shrink_budget: usize,
base_overrides: ConfigOverrides,
axes: Vec<SweepAxisValues>,
}
#[allow(clippy::too_many_arguments)]
fn resolve_sweep(
config: PathBuf,
seed_start: Option<u64>,
seeds: Option<u64>,
dump_regressions: Option<PathBuf>,
scratch_root: Vec<PathBuf>,
shrink: bool,
shrink_budget: usize,
max_runs: u64,
@@ -687,6 +760,7 @@ fn resolve_sweep(
return Err("--shrink-budget must be greater than zero".to_string());
}
let dump_regressions = dump_regressions.or(file.dump_regressions.clone());
let scratch_roots = resolve_scratch_roots(scratch_root, file.scratch_roots.clone())?;
let axes = file.axes.axis_values();
if axes.is_empty() {
return Err("sweep produced no combinations".to_string());
@@ -705,6 +779,7 @@ fn resolve_sweep(
seed_start,
seeds,
dump_regressions,
scratch_roots,
shrink,
shrink_budget,
base_overrides: file.base_overrides,
@@ -749,6 +824,7 @@ fn run_sweep(plan: SweepPlan, rt: &Runtime, interrupt: Arc<AtomicBool>) -> ExitC
seed_start,
seeds,
dump_regressions,
scratch_roots,
shrink,
shrink_budget,
base_overrides,
@@ -782,12 +858,13 @@ fn run_sweep(plan: SweepPlan, rt: &Runtime, interrupt: Arc<AtomicBool>) -> ExitC
axis_values.apply_to(&mut overrides);
let combo_start = Instant::now();
let overrides_for_farm = overrides.clone();
let reports = farm::run_many_timed(
let reports = farm::run_many_timed_with_scratch_roots(
move |s| {
let mut cfg = config_for(scenario, s);
overrides_for_farm.apply_to(&mut cfg);
cfg
},
&scratch_roots,
(seed_start..end).map(Seed),
);
let combo_wall = combo_start.elapsed();
@@ -840,6 +917,7 @@ fn run_farm(plan: FarmPlan, rt: &Runtime, interrupt: Arc<AtomicBool>) -> ExitCod
seeds,
hours,
dump_regressions,
scratch_roots,
overrides,
shrink,
shrink_budget,
@@ -871,12 +949,13 @@ fn run_farm(plan: FarmPlan, rt: &Runtime, interrupt: Arc<AtomicBool>) -> ExitCod
};
let overrides_ref = &overrides;
let batch_start = Instant::now();
let reports = farm::run_many_timed(
let reports = farm::run_many_timed_with_scratch_roots(
|s| {
let mut cfg = config_for(scenario, s);
overrides_ref.apply_to(&mut cfg);
cfg
},
&scratch_roots,
(next_seed..end).map(Seed),
);
let batch_wall = batch_start.elapsed();
@@ -7,7 +7,9 @@ use super::group_commit::{ActiveFileSet, FileIdAllocator};
use super::hash_index::{BlockIndex, BlockIndexError};
use super::hint::{HintFileWriter, hint_file_path};
use super::manager::DataFileManager;
use super::types::{BlockLocation, CidBytes, CommitEpoch, CompactionResult, DataFileId};
use super::types::{
BlockLocation, CidBytes, CommitEpoch, CompactionResult, CompactionStats, DataFileId,
};
#[derive(Debug)]
pub enum CompactionError {
@@ -68,7 +70,13 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
return Err(CompactionError::ActiveFileCannotBeCompacted);
}
let source_handle = manager.open_for_read(source_file_id)?;
let source_handle = match manager.open_for_read(source_file_id) {
Ok(handle) => handle,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return purge_phantom_file(manager, index, hint_positions, epoch, source_file_id);
}
Err(e) => return Err(CompactionError::Io(e)),
};
let source_size = manager.io().file_size(source_handle.fd())?;
let new_file_id = file_ids.allocate();
@@ -92,10 +100,16 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
.ok();
Err(e)
}
Ok((new_size, live_count, dead_count)) => {
if let Err(e) = index.write_checkpoint(epoch.current(), hint_positions) {
tracing::warn!(error = %e, "pre-delete checkpoint failed during compaction");
Ok((new_size, live_count, dead_count, new_hint_offset)) => {
match live_count {
0 => hint_positions.forget_extra(new_file_id),
_ => hint_positions.record_extra(new_file_id, new_hint_offset),
}
hint_positions.forget_extra(source_file_id);
index
.write_checkpoint(epoch.current(), hint_positions)
.map_err(CompactionError::Io)?;
manager.delete_data_file(source_file_id)?;
manager
@@ -124,18 +138,51 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
"compaction complete"
);
Ok(CompactionResult {
Ok(CompactionResult::Compacted(CompactionStats {
file_id: source_file_id,
old_size: source_size,
new_size,
live_blocks: live_count,
dead_blocks: dead_count,
reclaimed_bytes,
})
}))
}
}
}
fn purge_phantom_file<S: StorageIO>(
manager: &DataFileManager<S>,
index: &BlockIndex,
hint_positions: &super::group_commit::ShardHintPositions,
epoch: &super::types::EpochCounter,
source_file_id: DataFileId,
) -> Result<CompactionResult, CompactionError> {
let phantom_blocks = index.purge_by_file_id(source_file_id);
tracing::warn!(
file_id = %source_file_id,
phantom_blocks,
"source data file missing on disk, purged phantom index entries"
);
hint_positions.forget_extra(source_file_id);
manager
.io()
.delete(&hint_file_path(manager.data_dir(), source_file_id))
.ok();
manager.io().sync_dir(manager.data_dir()).ok();
index
.write_checkpoint(epoch.current(), hint_positions)
.map_err(CompactionError::Io)?;
Ok(CompactionResult::Purged {
file_id: source_file_id,
phantom_blocks,
})
}
fn stream_compact<S: StorageIO>(
manager: &DataFileManager<S>,
index: &BlockIndex,
@@ -144,7 +191,7 @@ fn stream_compact<S: StorageIO>(
new_file_id: DataFileId,
current_epoch: CommitEpoch,
grace_period_ms: u64,
) -> Result<(u64, u64, u64), CompactionError> {
) -> Result<(u64, u64, u64, super::types::HintOffset), CompactionError> {
let mut reader = DataFileReader::open(manager.io(), source_fd)?;
let now = crate::wall_clock_ms();
@@ -222,8 +269,10 @@ fn stream_compact<S: StorageIO>(
.io()
.sync_dir(manager.data_dir())
.map_err(CompactionError::from)
});
})
.and_then(|()| manager.io().barrier().map_err(CompactionError::from));
let final_hint_offset = hint_writer.position();
let _ = manager.io().close(hint_fd);
finalize_result?;
@@ -232,5 +281,5 @@ fn stream_compact<S: StorageIO>(
index.apply_compaction(&relocations, &dead_cids);
Ok((new_size, live_count, dead_count))
Ok((new_size, live_count, dead_count, final_hint_offset))
}
@@ -74,30 +74,49 @@ impl ActiveFileSet {
}
pub struct ShardHintPositions {
positions: RwLock<Vec<(DataFileId, HintOffset)>>,
shard_positions: RwLock<Vec<(DataFileId, HintOffset)>>,
extra_positions: RwLock<HashMap<DataFileId, HintOffset>>,
}
impl ShardHintPositions {
pub fn new(shard_count: u8) -> Self {
Self {
positions: RwLock::new(
shard_positions: RwLock::new(
(0..shard_count as usize)
.map(|_| (DataFileId::new(0), HintOffset::new(0)))
.collect(),
),
extra_positions: RwLock::new(HashMap::new()),
}
}
pub fn update(&self, shard_id: ShardId, file_id: DataFileId, offset: HintOffset) {
let mut positions = self.positions.write();
let mut positions = self.shard_positions.write();
let idx = shard_id.as_usize();
if idx < positions.len() {
positions[idx] = (file_id, offset);
}
}
pub fn record_extra(&self, file_id: DataFileId, offset: HintOffset) {
self.extra_positions.write().insert(file_id, offset);
}
pub fn forget_extra(&self, file_id: DataFileId) {
self.extra_positions.write().remove(&file_id);
}
pub fn snapshot(&self) -> CheckpointPositions {
CheckpointPositions(self.positions.read().clone())
let shard = self.shard_positions.read().clone();
let extra = self.extra_positions.read().clone();
debug_assert!(
shard
.iter()
.filter(|(fid, _)| fid.raw() != 0)
.all(|(fid, _)| !extra.contains_key(fid)),
"shard_positions and extra_positions must not overlap on the same DataFileId"
);
CheckpointPositions(shard.into_iter().chain(extra).collect())
}
}
@@ -1343,6 +1362,10 @@ fn process_batch<S: StorageIO>(
)
.map_err(|e| rollback_on_err(CommitError::from(e)))?;
hint_writer.sync().map_err(|e| rollback_on_err(e.into()))?;
manager
.io()
.barrier()
.map_err(|e| rollback_on_err(e.into()))?;
let sync_nanos = t.elapsed().as_nanos() as u64;
if !rotations.is_empty() {
@@ -606,6 +606,34 @@ impl HashTable {
});
}
pub fn cids_in_file(&self, file_id: DataFileId) -> Vec<CidBytes> {
self.iter()
.filter(|s| s.file_id == file_id)
.map(|s| s.cid)
.collect()
}
pub fn purge_by_file_id(&mut self, file_id: DataFileId) -> u64 {
let victims: Vec<(CidBytes, RefCount)> = self
.iter()
.filter(|s| s.file_id == file_id)
.map(|s| (s.cid, s.refcount))
.collect();
let live_discarded = victims.iter().filter(|(_, rc)| !rc.is_zero()).count();
if live_discarded > 0 {
tracing::warn!(
file_id = %file_id,
live_discarded,
total_purged = victims.len(),
"discarding live index entries for missing data file"
);
}
let removed = victims.iter().filter(|(cid, _)| self.remove(cid)).count();
u64::try_from(removed).unwrap_or(u64::MAX)
}
pub fn cleanup_stale_gc(&mut self) -> u64 {
self.slots
.iter_mut()
@@ -1503,6 +1531,14 @@ impl BlockIndex {
})
}
pub fn cids_in_file(&self, file_id: DataFileId) -> Vec<CidBytes> {
self.table.read().cids_in_file(file_id)
}
pub fn purge_by_file_id(&self, file_id: DataFileId) -> u64 {
self.table.write().purge_by_file_id(file_id)
}
pub fn read_write_cursor(&self) -> Option<WriteCursor> {
self.table.read().write_cursor()
}
+3 -3
View File
@@ -27,11 +27,11 @@ pub use hint::{
pub use manager::{CachedHandle, DEFAULT_MAX_FILE_SIZE, DataFileManager};
pub use reader::{BlockStoreReader, ReadError};
pub use store::QuiesceGuard;
pub use store::{BlockStoreConfig, DEFAULT_SHARD_COUNT, TranquilBlockStore};
pub use store::{BlockStoreConfig, DEFAULT_SHARD_COUNT, OpenRetryPolicy, TranquilBlockStore};
pub use types::{
BlockLength, BlockLocation, BlockOffset, BlockstoreSnapshot, CidBytes, CollectionResult,
CommitEpoch, CompactionResult, DataFileId, EpochCounter, HintOffset, IndexEntry, LivenessInfo,
MAX_BLOCK_SIZE, RefCount, ShardId, WallClockMs, WriteCursor,
CommitEpoch, CompactionResult, CompactionStats, DataFileId, EpochCounter, HintOffset,
IndexEntry, LivenessInfo, MAX_BLOCK_SIZE, RefCount, ShardId, WallClockMs, WriteCursor,
};
use std::io;
+298 -15
View File
@@ -1,7 +1,9 @@
use std::collections::HashMap;
use std::io;
use std::num::NonZeroU8;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use cid::Cid;
@@ -150,6 +152,24 @@ impl Drop for WriterHandle {
}
}
#[derive(Clone, Copy, Debug)]
pub struct OpenRetryPolicy {
pub max_attempts: NonZeroU8,
pub initial_backoff: Duration,
pub max_backoff: Duration,
}
impl Default for OpenRetryPolicy {
fn default() -> Self {
const DEFAULT_MAX_ATTEMPTS: NonZeroU8 = NonZeroU8::new(5).unwrap();
Self {
max_attempts: DEFAULT_MAX_ATTEMPTS,
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(2),
}
}
}
impl TranquilBlockStore<RealIO> {
pub fn open(config: BlockStoreConfig) -> Result<Self, RepoError> {
Self::open_with_hook(config, None)
@@ -161,6 +181,50 @@ impl TranquilBlockStore<RealIO> {
) -> Result<Self, RepoError> {
Self::open_with_io_hook(config, RealIO::new, post_sync_hook)
}
pub fn open_with_retry(
config: BlockStoreConfig,
policy: OpenRetryPolicy,
) -> Result<Self, RepoError> {
retry_with_backoff(policy, &mut |_| Self::open(config.clone()))
}
}
fn retry_with_backoff<T, F>(policy: OpenRetryPolicy, op: &mut F) -> Result<T, RepoError>
where
F: FnMut(u8) -> Result<T, RepoError>,
{
retry_attempt(policy, op, 0, policy.initial_backoff)
}
fn retry_attempt<T, F>(
policy: OpenRetryPolicy,
op: &mut F,
attempt: u8,
backoff: Duration,
) -> Result<T, RepoError>
where
F: FnMut(u8) -> Result<T, RepoError>,
{
match op(attempt) {
Ok(t) => Ok(t),
Err(e) if attempt + 1 >= policy.max_attempts.get() => Err(e),
Err(e) => {
tracing::warn!(
attempt,
error = %e,
backoff_ms = u64::try_from(backoff.as_millis()).unwrap_or(u64::MAX),
"blockstore open failed, retrying"
);
std::thread::sleep(backoff);
retry_attempt(
policy,
op,
attempt + 1,
(backoff * 2).min(policy.max_backoff),
)
}
}
}
impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
@@ -331,15 +395,7 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
let scan_pos = &mut { start_offset };
let (scanned_entries, last_valid_end) = std::iter::from_fn(|| {
match super::data_file::decode_block_record(io, fd, *scan_pos, file_size) {
Err(e) => {
tracing::warn!(
file_id = %file_id,
offset = scan_pos.raw(),
error = %e,
"IO error during recovery scan, stopping"
);
None
}
Err(e) => Some(Err(e)),
Ok(None) => None,
Ok(Some(ReadBlockRecord::Valid {
offset,
@@ -354,7 +410,7 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
let record_size = BLOCK_RECORD_OVERHEAD as u64 + u64::from(raw_len);
let new_end = offset.advance(record_size);
*scan_pos = new_end;
Some((
Some(Ok((
cid_bytes,
BlockLocation {
file_id,
@@ -362,20 +418,30 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
length,
},
new_end,
))
)))
}
Ok(Some(ReadBlockRecord::Corrupted { .. } | ReadBlockRecord::Truncated { .. })) => {
None
}
}
})
.fold(
.try_fold(
(Vec::new(), start_offset),
|(mut entries, _), (cid, loc, new_end)| {
|(mut entries, _), item: io::Result<_>| {
let (cid, loc, new_end) = item?;
entries.push((cid, loc));
(entries, new_end)
Ok::<_, io::Error>((entries, new_end))
},
);
)
.map_err(|e| {
tracing::warn!(
file_id = %file_id,
offset = scan_pos.raw(),
error = %e,
"IO error during recovery scan, aborting to preserve durable tail"
);
RepoError::storage(e)
})?;
if file_size > last_valid_end.raw() {
tracing::info!(
@@ -533,6 +599,16 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
.map_err(RepoError::storage)
}
pub fn list_hint_files(&self) -> Result<Vec<DataFileId>, RepoError> {
let io = self.reader.manager().io();
super::list_files_by_extension(io, &self.data_dir, super::hint::HINT_FILE_EXTENSION)
.map_err(RepoError::storage)
}
pub fn hint_file_path(&self, file_id: DataFileId) -> std::path::PathBuf {
super::hint::hint_file_path(&self.data_dir, file_id)
}
pub fn put_blocks_blocking(
&self,
blocks: Vec<([u8; CID_SIZE], Vec<u8>)>,
@@ -713,3 +789,210 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
Ok(self.index.get(&cid_bytes).map(|entry| entry.refcount.raw()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::blockstore::data_file::{
BLOCK_FORMAT_VERSION, BLOCK_HEADER_SIZE, BLOCK_MAGIC, encode_block_record,
};
use crate::blockstore::manager::DATA_FILE_EXTENSION;
use crate::io::FileId;
struct EioOnReadAtRange {
inner: RealIO,
target_path: PathBuf,
target_min: u64,
target_max: u64,
fired: AtomicBool,
fd_paths: Mutex<HashMap<FileId, PathBuf>>,
}
impl StorageIO for EioOnReadAtRange {
fn open(&self, path: &Path, opts: OpenOptions) -> io::Result<FileId> {
let fd = self.inner.open(path, opts)?;
self.fd_paths.lock().unwrap().insert(fd, path.to_path_buf());
Ok(fd)
}
fn close(&self, fd: FileId) -> io::Result<()> {
self.fd_paths.lock().unwrap().remove(&fd);
self.inner.close(fd)
}
fn read_at(&self, fd: FileId, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
let path_match = self.fd_paths.lock().unwrap().get(&fd).cloned();
let in_target_range = path_match.as_ref() == Some(&self.target_path)
&& offset >= self.target_min
&& offset <= self.target_max;
if in_target_range && !self.fired.swap(true, Ordering::SeqCst) {
return Err(io::Error::other("simulated EIO on read"));
}
self.inner.read_at(fd, offset, buf)
}
fn write_at(&self, fd: FileId, offset: u64, buf: &[u8]) -> io::Result<usize> {
self.inner.write_at(fd, offset, buf)
}
fn sync(&self, fd: FileId) -> io::Result<()> {
self.inner.sync(fd)
}
fn file_size(&self, fd: FileId) -> io::Result<u64> {
self.inner.file_size(fd)
}
fn truncate(&self, fd: FileId, size: u64) -> io::Result<()> {
self.inner.truncate(fd, size)
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
self.inner.rename(from, to)
}
fn delete(&self, path: &Path) -> io::Result<()> {
self.inner.delete(path)
}
fn mkdir(&self, path: &Path) -> io::Result<()> {
self.inner.mkdir(path)
}
fn sync_dir(&self, path: &Path) -> io::Result<()> {
self.inner.sync_dir(path)
}
fn list_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
self.inner.list_dir(path)
}
}
#[test]
fn scan_and_index_does_not_truncate_acked_block_on_transient_eio() {
let tmp = tempfile::TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
let index_dir = tmp.path().join("index");
std::fs::create_dir_all(&data_dir).unwrap();
std::fs::create_dir_all(&index_dir).unwrap();
let file_id = DataFileId::new(0);
let file_path = data_dir.join(format!("{file_id}.{DATA_FILE_EXTENSION}"));
let setup = RealIO::new();
let fd = setup.open(&file_path, OpenOptions::read_write()).unwrap();
let mut header = [0u8; BLOCK_HEADER_SIZE];
header[..4].copy_from_slice(&BLOCK_MAGIC);
header[4] = BLOCK_FORMAT_VERSION;
setup.write_all_at(fd, 0, &header).unwrap();
let cid_a = [0xAAu8; CID_SIZE];
let data_a = vec![1u8; 64];
let block_a_offset = BlockOffset::new(BLOCK_HEADER_SIZE as u64);
let len_a = encode_block_record(&setup, fd, block_a_offset, &cid_a, &data_a).unwrap();
let block_b_offset_raw = BLOCK_HEADER_SIZE as u64 + len_a;
let block_b_offset = BlockOffset::new(block_b_offset_raw);
let cid_b = [0xBBu8; CID_SIZE];
let data_b = vec![2u8; 64];
let len_b = encode_block_record(&setup, fd, block_b_offset, &cid_b, &data_b).unwrap();
setup.sync(fd).unwrap();
setup.close(fd).unwrap();
drop(setup);
let total_size = block_b_offset_raw + len_b;
assert_eq!(std::fs::metadata(&file_path).unwrap().len(), total_size);
let wrapper = EioOnReadAtRange {
inner: RealIO::new(),
target_path: file_path.clone(),
target_min: block_b_offset_raw,
target_max: block_b_offset_raw + (BLOCK_RECORD_OVERHEAD as u64) - 1,
fired: AtomicBool::new(false),
fd_paths: Mutex::new(HashMap::new()),
};
let index = BlockIndex::open(&index_dir).unwrap();
let result = TranquilBlockStore::<EioOnReadAtRange>::replay_single_file(
&wrapper,
&data_dir,
&index,
file_id,
BlockOffset::new(BLOCK_HEADER_SIZE as u64),
);
assert!(
result.is_err(),
"replay must surface transient EIO instead of silently truncating"
);
let post_size = std::fs::metadata(&file_path).unwrap().len();
assert_eq!(
post_size, total_size,
"scan truncated durable acked block past EIO point: expected {total_size} bytes, got {post_size}"
);
}
fn instant_policy(max_attempts: u8) -> OpenRetryPolicy {
OpenRetryPolicy {
max_attempts: NonZeroU8::new(max_attempts).expect("max_attempts must be nonzero"),
initial_backoff: Duration::ZERO,
max_backoff: Duration::ZERO,
}
}
#[test]
fn retry_with_backoff_succeeds_on_first_attempt() {
let calls = std::sync::atomic::AtomicUsize::new(0);
let result = retry_with_backoff(instant_policy(5), &mut |_| {
calls.fetch_add(1, Ordering::Relaxed);
Ok::<u8, RepoError>(42)
});
assert_eq!(result.expect("ok"), 42);
assert_eq!(calls.load(Ordering::Relaxed), 1);
}
#[test]
fn retry_with_backoff_recovers_after_transient_failures() {
let calls = std::sync::atomic::AtomicUsize::new(0);
let result = retry_with_backoff(instant_policy(5), &mut |_| {
let n = calls.fetch_add(1, Ordering::Relaxed);
if n >= 2 {
Ok::<u8, RepoError>(7)
} else {
Err(RepoError::storage(io::Error::other("transient EIO")))
}
});
assert_eq!(result.expect("ok"), 7);
assert_eq!(calls.load(Ordering::Relaxed), 3);
}
#[test]
fn retry_with_backoff_gives_up_after_max_attempts() {
let calls = std::sync::atomic::AtomicUsize::new(0);
let result: Result<u8, RepoError> = retry_with_backoff(instant_policy(3), &mut |_| {
calls.fetch_add(1, Ordering::Relaxed);
Err(RepoError::storage(io::Error::other("permanent EIO")))
});
assert!(result.is_err(), "expected exhaustion error");
assert_eq!(calls.load(Ordering::Relaxed), 3);
}
#[test]
fn retry_with_backoff_passes_attempt_index_to_op() {
let observed = std::sync::Mutex::new(Vec::<u8>::new());
let _result: Result<(), RepoError> =
retry_with_backoff(instant_policy(4), &mut |attempt| {
observed.lock().unwrap().push(attempt);
Err(RepoError::storage(io::Error::other("EIO")))
});
assert_eq!(*observed.lock().unwrap(), vec![0, 1, 2, 3]);
}
}
+20 -1
View File
@@ -68,7 +68,8 @@ pub struct CollectionResult {
pub total_bytes: u64,
}
pub struct CompactionResult {
#[derive(Debug)]
pub struct CompactionStats {
pub file_id: DataFileId,
pub old_size: u64,
pub new_size: u64,
@@ -77,6 +78,24 @@ pub struct CompactionResult {
pub reclaimed_bytes: u64,
}
#[derive(Debug)]
pub enum CompactionResult {
Compacted(CompactionStats),
Purged {
file_id: DataFileId,
phantom_blocks: u64,
},
}
impl CompactionResult {
pub fn file_id(&self) -> DataFileId {
match self {
Self::Compacted(stats) => stats.file_id,
Self::Purged { file_id, .. } => *file_id,
}
}
}
pub struct LivenessInfo {
pub live_bytes: u64,
pub total_bytes: u64,
+109 -2
View File
@@ -28,6 +28,8 @@ pub struct ConsistencyReport {
pub orphaned_user_repos: Vec<OrphanedUserRepo>,
pub inconsistent_handles: Vec<InconsistentHandle>,
pub orphan_data_files: Vec<DataFileId>,
pub orphan_hint_files: Vec<DataFileId>,
pub missing_indexed_files: Vec<DataFileId>,
pub deserialization_failures: u64,
pub eventlog_contiguity: Option<SequenceContiguityResult>,
pub cursor_ahead_of_eventlog: bool,
@@ -74,6 +76,8 @@ impl ConsistencyReport {
&& self.orphaned_user_repos.is_empty()
&& self.inconsistent_handles.is_empty()
&& self.orphan_data_files.is_empty()
&& self.orphan_hint_files.is_empty()
&& self.missing_indexed_files.is_empty()
&& self.deserialization_failures == 0
&& self
.eventlog_contiguity
@@ -84,6 +88,8 @@ impl ConsistencyReport {
pub fn has_repairable_issues(&self) -> bool {
!self.orphan_data_files.is_empty()
|| !self.orphan_hint_files.is_empty()
|| !self.missing_indexed_files.is_empty()
}
pub fn has_unrecoverable_issues(&self) -> bool {
@@ -136,6 +142,20 @@ impl ConsistencyReport {
"orphan data files with no index references"
);
}
if !self.orphan_hint_files.is_empty() {
tracing::warn!(
count = self.orphan_hint_files.len(),
files = ?self.orphan_hint_files,
"orphan hint files with no matching data file"
);
}
if !self.missing_indexed_files.is_empty() {
tracing::warn!(
count = self.missing_indexed_files.len(),
files = ?self.missing_indexed_files,
"index references data files that are missing on disk"
);
}
if self.deserialization_failures > 0 {
tracing::error!(
count = self.deserialization_failures,
@@ -181,13 +201,15 @@ impl fmt::Display for ConsistencyReport {
write!(
f,
"INCONSISTENT: dangling_roots={}, dangling_records={}, orphaned_repos={}, \
inconsistent_handles={}, orphan_files={}, deserialize_failures={}, \
eventlog_gaps={}, cursor_ahead={}",
inconsistent_handles={}, orphan_files={}, orphan_hints={}, missing_indexed_files={}, \
deserialize_failures={}, eventlog_gaps={}, cursor_ahead={}",
self.dangling_root_cids.len(),
self.dangling_record_cids.len(),
self.orphaned_user_repos.len(),
self.inconsistent_handles.len(),
self.orphan_data_files.len(),
self.orphan_hint_files.len(),
self.missing_indexed_files.len(),
self.deserialization_failures,
self.eventlog_contiguity
.as_ref()
@@ -204,6 +226,8 @@ pub struct ConsistencyCheckOptions {
pub check_user_blocks: bool,
pub check_eventlog: bool,
pub check_orphan_files: bool,
pub check_missing_indexed_files: bool,
pub check_orphan_hint_files: bool,
}
impl Default for ConsistencyCheckOptions {
@@ -214,6 +238,8 @@ impl Default for ConsistencyCheckOptions {
check_user_blocks: true,
check_eventlog: true,
check_orphan_files: true,
check_missing_indexed_files: true,
check_orphan_hint_files: true,
}
}
}
@@ -269,6 +295,14 @@ pub fn verify_store_consistency_with_options<S: StorageIO + 'static>(
check_orphan_data_files(blockstore, block_index, &mut report);
}
if options.check_missing_indexed_files {
check_missing_indexed_files(blockstore, block_index, &mut report);
}
if options.check_orphan_hint_files {
check_orphan_hint_files(blockstore, &mut report);
}
report
}
@@ -565,6 +599,52 @@ fn check_orphan_data_files(
});
}
fn check_missing_indexed_files(
blockstore: &TranquilBlockStore,
block_index: &BlockIndex,
report: &mut ConsistencyReport,
) {
let disk_files: HashSet<DataFileId> = match blockstore.list_data_files() {
Ok(files) => files.into_iter().collect(),
Err(e) => {
tracing::warn!(error = %e, "failed to list data files for missing-file check");
return;
}
};
let epoch = blockstore.epoch().current();
let now = crate::wall_clock_ms();
let indexed_files = block_index.liveness_by_file(epoch, now, 0);
indexed_files
.iter()
.filter(|(fid, _)| !disk_files.contains(fid))
.for_each(|(fid, _)| report.missing_indexed_files.push(*fid));
}
fn check_orphan_hint_files(blockstore: &TranquilBlockStore, report: &mut ConsistencyReport) {
let data_files: HashSet<DataFileId> = match blockstore.list_data_files() {
Ok(files) => files.into_iter().collect(),
Err(e) => {
tracing::warn!(error = %e, "failed to list data files for orphan-hint check");
return;
}
};
let hint_files = match blockstore.list_hint_files() {
Ok(files) => files,
Err(e) => {
tracing::warn!(error = %e, "failed to list hint files for orphan-hint check");
return;
}
};
hint_files
.iter()
.filter(|fid| !data_files.contains(fid))
.for_each(|fid| report.orphan_hint_files.push(*fid));
}
fn try_cid_bytes_to_fixed(bytes: &[u8]) -> Option<[u8; CID_SIZE]> {
bytes.try_into().ok()
}
@@ -621,12 +701,39 @@ pub fn repair_known_issues(
}
});
report.orphan_hint_files.iter().for_each(|&file_id| {
let path = blockstore.hint_file_path(file_id);
match std::fs::remove_file(&path) {
Ok(()) => {
tracing::info!(%file_id, "removed orphan hint file");
result.orphan_hints_removed = result.orphan_hints_removed.saturating_add(1);
}
Err(e) => {
tracing::warn!(%file_id, error = %e, "failed to remove orphan hint file");
result.repair_errors = result.repair_errors.saturating_add(1);
}
}
});
report.missing_indexed_files.iter().for_each(|&file_id| {
let purged = blockstore.block_index().purge_by_file_id(file_id);
tracing::info!(
%file_id,
purged,
"purged phantom index entries for missing data file"
);
result.phantom_index_entries_purged =
result.phantom_index_entries_purged.saturating_add(purged);
});
result
}
#[derive(Debug, Default)]
pub struct RepairResult {
pub orphan_files_removed: u64,
pub orphan_hints_removed: u64,
pub phantom_index_entries_purged: u64,
pub repair_errors: u64,
}
+202 -42
View File
@@ -3,16 +3,27 @@ use std::sync::Arc;
use tracing::warn;
use crate::io::StorageIO;
use crate::io::{FileId, StorageIO};
use super::manager::SegmentManager;
use super::segment_file::{SEGMENT_HEADER_SIZE, SegmentWriter, ValidEvent};
use super::segment_file::{
SEGMENT_HEADER_SIZE, SEGMENT_MAGIC, SegmentWriter, ValidEvent, ValidateEventRecord,
validate_event_record,
};
use super::segment_index::{DEFAULT_INDEX_INTERVAL, SegmentIndex, rebuild_from_segment};
use super::sidecar::build_sidecar_from_segment;
use super::types::{
DidHash, EventSequence, EventTypeTag, SegmentId, SegmentOffset, TimestampMicros,
};
const VALIDATE_RETRY_ATTEMPTS: u32 = 32;
#[derive(Debug, Clone)]
struct PendingAppend {
event: ValidEvent,
offset: SegmentOffset,
}
#[derive(Debug)]
pub struct SyncResult {
pub synced_through: EventSequence,
@@ -31,7 +42,8 @@ pub struct EventLogWriter<S: StorageIO> {
max_payload: u32,
event_count_in_segment: usize,
last_event_offset: Option<SegmentOffset>,
pending_events: Vec<ValidEvent>,
pending: Vec<PendingAppend>,
poisoned: bool,
}
impl<S: StorageIO> EventLogWriter<S> {
@@ -83,10 +95,25 @@ impl<S: StorageIO> EventLogWriter<S> {
max_payload,
event_count_in_segment: 0,
last_event_offset: None,
pending_events: Vec::new(),
pending: Vec::new(),
poisoned: false,
})
}
fn truncate_and_init_fresh(
manager: Arc<SegmentManager<S>>,
fd: FileId,
active_id: SegmentId,
prev_segments: &[SegmentId],
index_interval: usize,
max_payload: u32,
) -> io::Result<Self> {
manager.io().truncate(fd, 0)?;
let next_seq = find_last_seq_from_segments(&manager, prev_segments, max_payload)?
.map_or(EventSequence::new(1), |s| s.next());
Self::init_fresh(manager, active_id, next_seq, index_interval, max_payload)
}
fn recover_active(
manager: Arc<SegmentManager<S>>,
segments: &[SegmentId],
@@ -97,6 +124,19 @@ impl<S: StorageIO> EventLogWriter<S> {
let handle = manager.open_for_append(active_id)?;
let fd = handle.fd();
let prev_segments = &segments[..segments.len().saturating_sub(1)];
if highest_segment_has_torn_header(manager.io(), fd)? {
return Self::truncate_and_init_fresh(
Arc::clone(&manager),
fd,
active_id,
prev_segments,
index_interval,
max_payload,
);
}
let (index, last_seq_in_active) = match rebuild_from_segment(
manager.io(),
fd,
@@ -107,15 +147,11 @@ impl<S: StorageIO> EventLogWriter<S> {
Err(rebuild_err) => {
let file_size = manager.io().file_size(fd)?;
if file_size <= SEGMENT_HEADER_SIZE as u64 {
manager.io().truncate(fd, 0)?;
let prev_segments = &segments[..segments.len().saturating_sub(1)];
let next_seq =
find_last_seq_from_segments(&manager, prev_segments, max_payload)?
.map_or(EventSequence::new(1), |s| s.next());
return Self::init_fresh(
return Self::truncate_and_init_fresh(
Arc::clone(&manager),
fd,
active_id,
next_seq,
prev_segments,
index_interval,
max_payload,
);
@@ -131,8 +167,6 @@ impl<S: StorageIO> EventLogWriter<S> {
let position = SegmentOffset::new(manager.io().file_size(fd)?);
let prev_segments = &segments[..segments.len().saturating_sub(1)];
let next_seq = match last_seq_in_active {
Some(seq) => {
if let Some(sealed_last) =
@@ -196,7 +230,8 @@ impl<S: StorageIO> EventLogWriter<S> {
max_payload,
event_count_in_segment,
last_event_offset,
pending_events: Vec::new(),
pending: Vec::new(),
poisoned: false,
})
}
@@ -227,28 +262,20 @@ impl<S: StorageIO> EventLogWriter<S> {
payload,
};
let offset = self.active_writer.append_event(self.manager.io(), &event)?;
let should_index = self.event_count_in_segment == 0
|| self
.event_count_in_segment
.is_multiple_of(self.index_interval);
if should_index {
self.active_index.record(seq, offset);
}
self.event_count_in_segment = self
.event_count_in_segment
.checked_add(1)
.expect("event_count_in_segment overflow");
self.last_event_offset = Some(offset);
self.next_seq = seq.next();
self.pending_events.push(event);
Ok(seq)
self.append_inner(event).map(|_| seq)
}
pub fn append_valid_event(&mut self, event: ValidEvent) -> io::Result<()> {
self.append_inner(event)
}
fn append_inner(&mut self, event: ValidEvent) -> io::Result<()> {
if self.poisoned {
return Err(io::Error::other(
"writer poisoned by partial-valid sync; reopen required",
));
}
let offset = self.active_writer.append_event(self.manager.io(), &event)?;
let should_index = self.event_count_in_segment == 0
@@ -265,21 +292,52 @@ impl<S: StorageIO> EventLogWriter<S> {
.expect("event_count_in_segment overflow");
self.last_event_offset = Some(offset);
self.next_seq = event.seq.next();
self.pending_events.push(event);
self.pending.push(PendingAppend { event, offset });
Ok(())
}
pub fn peek_pending_event(&self, seq: EventSequence) -> Option<&ValidEvent> {
self.pending_events.iter().find(|e| e.seq == seq)
}
pub fn sync(&mut self) -> io::Result<SyncResult> {
if !self.pending_events.is_empty() {
self.active_writer.sync(self.manager.io())?;
if self.poisoned {
return Err(io::Error::other(
"writer poisoned by partial-valid sync; reopen required",
));
}
let flushed = std::mem::take(&mut self.pending_events);
if !self.pending.is_empty() {
self.active_writer.sync(self.manager.io())?;
self.manager.io().barrier()?;
}
let pending = std::mem::take(&mut self.pending);
let fd = self.active_writer.fd();
let file_size = self.manager.io().file_size(fd)?;
let valid_count = pending
.iter()
.take_while(|p| {
validate_with_retry(
self.manager.io(),
fd,
p.offset,
file_size,
self.max_payload,
p.event.seq,
)
})
.count();
if valid_count < pending.len() {
self.poisoned = true;
}
let flushed: Vec<ValidEvent> = pending
.into_iter()
.take(valid_count)
.map(|p| p.event)
.collect();
self.synced_seq = flushed.last().map(|e| e.seq).unwrap_or(self.synced_seq);
Ok(SyncResult {
@@ -290,12 +348,22 @@ impl<S: StorageIO> EventLogWriter<S> {
})
}
pub fn is_poisoned(&self) -> bool {
self.poisoned
}
pub fn rotate_if_needed(&mut self) -> io::Result<Option<SegmentId>> {
if self.poisoned {
return Err(io::Error::other(
"writer poisoned by partial-valid sync; reopen required",
));
}
if !self.manager.should_rotate(self.active_writer.position()) {
return Ok(None);
}
if !self.pending_events.is_empty() {
if !self.pending.is_empty() {
return Ok(None);
}
@@ -386,6 +454,40 @@ impl<S: StorageIO> EventLogWriter<S> {
}
}
fn validate_with_retry<S: StorageIO>(
io: &S,
fd: FileId,
offset: SegmentOffset,
file_size: u64,
max_payload: u32,
expected_seq: EventSequence,
) -> bool {
(0..VALIDATE_RETRY_ATTEMPTS).any(|_| {
matches!(
validate_event_record(io, fd, offset, file_size, max_payload),
Ok(Some(ValidateEventRecord::Valid { seq, .. })) if seq == expected_seq
)
})
}
fn highest_segment_has_torn_header<S: StorageIO>(io: &S, fd: FileId) -> io::Result<bool> {
let file_size = io.file_size(fd)?;
if file_size < SEGMENT_HEADER_SIZE as u64 {
return Ok(true);
}
let outcomes: Vec<bool> = (0..VALIDATE_RETRY_ATTEMPTS)
.filter_map(|_| {
let mut header = [0u8; SEGMENT_MAGIC.len()];
io.read_exact_at(fd, 0, &mut header)
.ok()
.map(|()| header == SEGMENT_MAGIC)
})
.collect();
let saw_match = outcomes.iter().any(|&ok| ok);
let saw_mismatch = outcomes.iter().any(|&ok| !ok);
Ok(!saw_match && saw_mismatch)
}
fn find_last_seq_from_segments<S: StorageIO>(
manager: &SegmentManager<S>,
segments: &[SegmentId],
@@ -1094,4 +1196,62 @@ mod tests {
assert!(writer.rotate_if_needed().unwrap().is_none());
}
#[test]
fn sync_must_not_certify_durability_when_io_sync_silently_drops() {
use crate::sim::{FaultConfig, Probability};
let sim = Arc::new(SimulatedIO::new(
0,
FaultConfig {
sync_failure_probability: Probability::new(1.0),
..FaultConfig::none()
},
));
sim.set_pristine_mode(true);
let mgr = Arc::new(
SegmentManager::new(Arc::clone(&sim), PathBuf::from("/segments"), 64 * 1024).unwrap(),
);
let mut writer =
EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD)
.unwrap();
sim.set_pristine_mode(false);
writer
.append(
DidHash::from_did("did:plc:bug2"),
EventTypeTag::COMMIT,
b"bug2-payload".to_vec(),
)
.unwrap();
assert!(
writer.sync().is_err(),
"sync must surface dropped fsync as an error"
);
let claimed_synced = writer.synced_seq();
assert_eq!(
claimed_synced.raw(),
0,
"synced_seq must not advance past a failed sync"
);
drop(writer);
mgr.shutdown();
sim.crash();
sim.set_pristine_mode(true);
let reopened =
EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD)
.unwrap();
let actually_durable = reopened.current_seq();
assert!(
actually_durable >= claimed_synced,
"writer claimed sync through {claimed_synced} but post-crash recovery only reaches {actually_durable}"
);
}
}
@@ -0,0 +1,113 @@
use std::collections::HashSet;
use cid::Cid;
use jacquard_repo::mst::NodeData;
use super::oracle::{hex_short, try_cid_to_fixed};
use crate::StorageIO;
use crate::blockstore::{CidBytes, TranquilBlockStore};
pub enum LookupResult {
Found(Cid),
NotFound,
LostPath,
}
pub fn walk_mst_node_cids_tolerant<S: StorageIO + Send + Sync + 'static>(
store: &TranquilBlockStore<S>,
root: Cid,
lost: &HashSet<CidBytes>,
) -> Result<Vec<CidBytes>, String> {
let mut visited: HashSet<CidBytes> = HashSet::new();
let mut to_visit: Vec<Cid> = vec![root];
let mut result: Vec<CidBytes> = Vec::new();
while let Some(cid) = to_visit.pop() {
let cid_bytes = try_cid_to_fixed(&cid).map_err(|e| format!("cid format: {e}"))?;
if !visited.insert(cid_bytes) {
continue;
}
if lost.contains(&cid_bytes) {
continue;
}
let node = read_node(store, &cid_bytes)?;
result.push(cid_bytes);
if let Some(left) = node.left {
to_visit.push(left);
}
node.entries
.into_iter()
.filter_map(|e| e.tree)
.for_each(|t| to_visit.push(t));
}
Ok(result)
}
pub fn mst_get_tolerant<S: StorageIO + Send + Sync + 'static>(
store: &TranquilBlockStore<S>,
root: Cid,
target: &str,
lost: &HashSet<CidBytes>,
) -> Result<LookupResult, String> {
let mut cursor = root;
loop {
let cursor_bytes = try_cid_to_fixed(&cursor).map_err(|e| format!("cid format: {e}"))?;
if lost.contains(&cursor_bytes) {
return Ok(LookupResult::LostPath);
}
let node = read_node(store, &cursor_bytes)?;
let keys = full_keys(&node)?;
let index = keys
.iter()
.position(|k| k.as_str() >= target)
.unwrap_or(keys.len());
if index < keys.len() && keys[index] == target {
return Ok(LookupResult::Found(node.entries[index].value));
}
let subtree = match index {
0 => node.left,
n => node.entries[n - 1].tree,
};
match subtree {
Some(child) => cursor = child,
None => return Ok(LookupResult::NotFound),
}
}
}
fn read_node<S: StorageIO + Send + Sync + 'static>(
store: &TranquilBlockStore<S>,
cid_bytes: &CidBytes,
) -> Result<NodeData, String> {
let bytes = match store.get_block_sync(cid_bytes) {
Ok(Some(b)) => b,
Ok(None) => return Err(format!("missing block: {}", hex_short(cid_bytes))),
Err(e) => return Err(format!("read {}: {e}", hex_short(cid_bytes))),
};
serde_ipld_dagcbor::from_slice(&bytes)
.map_err(|e| format!("deserialize node {}: {e}", hex_short(cid_bytes)))
}
fn full_keys(node: &NodeData) -> Result<Vec<String>, String> {
node.entries
.iter()
.scan(String::new(), |last_key, entry| {
let suffix = match std::str::from_utf8(&entry.key_suffix) {
Ok(s) => s,
Err(e) => return Some(Err(format!("invalid utf-8 in key suffix: {e}"))),
};
let prefix_len = entry.prefix_len as usize;
if prefix_len > last_key.len() {
return Some(Err(format!(
"prefix length {} exceeds last key length {}",
prefix_len,
last_key.len()
)));
}
let full = format!("{}{}", &last_key[..prefix_len], suffix);
*last_key = full.clone();
Some(Ok(full))
})
.collect()
}
+81 -1
View File
@@ -1,5 +1,6 @@
use std::cell::RefCell;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use rayon::prelude::*;
@@ -44,6 +45,17 @@ pub fn run_many_timed<F>(
make_config: F,
seeds: impl IntoIterator<Item = Seed>,
) -> Vec<(GauntletReport, Duration)>
where
F: Fn(Seed) -> GauntletConfig + Sync + Send,
{
run_many_timed_with_scratch_roots(make_config, &[], seeds)
}
pub fn run_many_timed_with_scratch_roots<F>(
make_config: F,
scratch_roots: &[PathBuf],
seeds: impl IntoIterator<Item = Seed>,
) -> Vec<(GauntletReport, Duration)>
where
F: Fn(Seed) -> GauntletConfig + Sync + Send,
{
@@ -51,10 +63,14 @@ where
seeds
.into_par_iter()
.map(|s| {
let scratch = scratch_for_thread(scratch_roots, rayon::current_thread_index());
let start = Instant::now();
let outcome = catch_unwind(AssertUnwindSafe(|| {
let cfg = make_config(s);
let gauntlet = Gauntlet::new(cfg).expect("build gauntlet");
let mut gauntlet = Gauntlet::new(cfg).expect("build gauntlet");
if let Some(root) = scratch {
gauntlet = gauntlet.with_scratch_root(root);
}
with_runtime(|rt| rt.block_on(gauntlet.run()))
}));
let report = outcome.unwrap_or_else(|payload| {
@@ -66,6 +82,14 @@ where
.collect()
}
fn scratch_for_thread(roots: &[PathBuf], thread_idx: Option<usize>) -> Option<PathBuf> {
if roots.is_empty() {
None
} else {
Some(roots[thread_idx.unwrap_or(0) % roots.len()].clone())
}
}
fn panic_report(seed: Seed, payload: Box<dyn std::any::Any + Send>) -> GauntletReport {
let msg = payload
.downcast_ref::<&'static str>()
@@ -84,3 +108,59 @@ fn panic_report(seed: Seed, payload: Box<dyn std::any::Any + Send>) -> GauntletR
ops: OpStream::empty(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scratch_for_thread_returns_none_when_roots_empty() {
assert!(scratch_for_thread(&[], Some(0)).is_none());
assert!(scratch_for_thread(&[], Some(7)).is_none());
assert!(scratch_for_thread(&[], None).is_none());
}
#[test]
fn scratch_for_thread_round_robins_across_roots() {
let roots = vec![
PathBuf::from("/scratch/a"),
PathBuf::from("/scratch/b"),
PathBuf::from("/scratch/c"),
];
let assigned: Vec<PathBuf> = (0..7)
.map(|i| scratch_for_thread(&roots, Some(i)).expect("scratch path"))
.collect();
assert_eq!(
assigned,
vec![
PathBuf::from("/scratch/a"),
PathBuf::from("/scratch/b"),
PathBuf::from("/scratch/c"),
PathBuf::from("/scratch/a"),
PathBuf::from("/scratch/b"),
PathBuf::from("/scratch/c"),
PathBuf::from("/scratch/a"),
]
);
}
#[test]
fn scratch_for_thread_with_single_root_returns_same_path() {
let roots = vec![PathBuf::from("/scratch/only")];
(0..5).for_each(|i| {
assert_eq!(
scratch_for_thread(&roots, Some(i)),
Some(PathBuf::from("/scratch/only"))
);
});
}
#[test]
fn scratch_for_thread_falls_back_to_root_zero_outside_pool() {
let roots = vec![PathBuf::from("/scratch/a"), PathBuf::from("/scratch/b")];
assert_eq!(
scratch_for_thread(&roots, None),
Some(PathBuf::from("/scratch/a"))
);
}
}
@@ -405,6 +405,8 @@ fn mount_ext4(device: &Path, target: &Path) -> Result<(), FlakyError> {
let out = Command::new("mount")
.arg("-t")
.arg("ext4")
.arg("-o")
.arg("errors=continue")
.arg(device)
.arg(target)
.output()?;
@@ -31,6 +31,9 @@ impl InvariantSet {
pub const MONOTONIC_SEQ: Self = Self(1 << 10);
pub const FSYNC_ORDERING: Self = Self(1 << 11);
pub const TOMBSTONE_BOUND: Self = Self(1 << 12);
pub const INDEX_BACKED_BY_DISK: Self = Self(1 << 13);
pub const HINT_BACKED_BY_DATA: Self = Self(1 << 14);
pub const INDEX_BLOCKS_READABLE: Self = Self(1 << 15);
const ALL_KNOWN: u32 = Self::REFCOUNT_CONSERVATION.0
| Self::REACHABILITY.0
@@ -44,7 +47,10 @@ impl InvariantSet {
| Self::CHECKSUM_COVERAGE.0
| Self::MONOTONIC_SEQ.0
| Self::FSYNC_ORDERING.0
| Self::TOMBSTONE_BOUND.0;
| Self::TOMBSTONE_BOUND.0
| Self::INDEX_BACKED_BY_DISK.0
| Self::HINT_BACKED_BY_DATA.0
| Self::INDEX_BLOCKS_READABLE.0;
pub const fn contains(self, other: Self) -> bool {
(self.0 & other.0) == other.0
@@ -377,6 +383,164 @@ fn compact_by_liveness<S: StorageIO + Send + Sync + 'static>(
})
}
pub struct HintBackedByData;
#[async_trait]
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for HintBackedByData {
fn name(&self) -> &'static str {
"HintBackedByData"
}
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let store_c = ctx.store.clone();
let result = tokio::task::spawn_blocking(move || {
let data: std::collections::HashSet<_> = store_c
.list_data_files()
.map_err(|e| e.to_string())?
.into_iter()
.collect();
let hints = store_c.list_hint_files().map_err(|e| e.to_string())?;
let orphans: Vec<String> = hints
.iter()
.filter(|fid| !data.contains(fid))
.map(|fid| fid.to_string())
.collect();
Ok::<_, String>(orphans)
})
.await
.map_err(|e| InvariantViolation {
invariant: "HintBackedByData",
detail: format!("join: {e}"),
})?;
let orphans = result.map_err(|e| InvariantViolation {
invariant: "HintBackedByData",
detail: e,
})?;
if orphans.is_empty() {
Ok(())
} else {
Err(InvariantViolation {
invariant: "HintBackedByData",
detail: format!(
"hint files without matching data file (orphan hints): {}",
orphans.join(", ")
),
})
}
}
}
pub struct IndexBlocksReadable;
#[async_trait]
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for IndexBlocksReadable {
fn name(&self) -> &'static str {
"IndexBlocksReadable"
}
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let store_c = ctx.store.clone();
let result = tokio::task::spawn_blocking(move || {
let entries = store_c.block_index().live_entries_snapshot();
let unreadable: Vec<String> = entries
.iter()
.take(INDEX_READABLE_SAMPLE_CAP)
.filter_map(|(cid, _)| match store_c.get_block_sync(cid) {
Ok(Some(_)) => None,
Ok(None) => Some(format!(
"{}: index says present but reader missed",
hex_short(cid)
)),
Err(e) => Some(format!("{}: read error {e}", hex_short(cid))),
})
.take(INDEX_READABLE_REPORT_CAP)
.collect();
Ok::<_, String>(unreadable)
})
.await
.map_err(|e| InvariantViolation {
invariant: "IndexBlocksReadable",
detail: format!("join: {e}"),
})?;
let unreadable = result.map_err(|e| InvariantViolation {
invariant: "IndexBlocksReadable",
detail: e,
})?;
if unreadable.is_empty() {
Ok(())
} else {
Err(InvariantViolation {
invariant: "IndexBlocksReadable",
detail: format!(
"live index entries cannot be read back (first {INDEX_READABLE_REPORT_CAP}): {}",
unreadable.join("; ")
),
})
}
}
}
const INDEX_READABLE_SAMPLE_CAP: usize = 512;
const INDEX_READABLE_REPORT_CAP: usize = 20;
pub struct IndexBackedByDisk;
#[async_trait]
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for IndexBackedByDisk {
fn name(&self) -> &'static str {
"IndexBackedByDisk"
}
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let store_c = ctx.store.clone();
let result = tokio::task::spawn_blocking(move || {
let disk: std::collections::HashSet<_> = store_c
.list_data_files()
.map_err(|e| e.to_string())?
.into_iter()
.collect();
let liveness = store_c.compaction_liveness(0).map_err(|e| e.to_string())?;
let missing: Vec<String> = liveness
.iter()
.filter(|(fid, _)| !disk.contains(fid))
.map(|(fid, info)| {
format!(
"{fid} (live_blocks={}, total_blocks={})",
info.live_blocks, info.total_blocks
)
})
.collect();
Ok::<_, String>(missing)
})
.await
.map_err(|e| InvariantViolation {
invariant: "IndexBackedByDisk",
detail: format!("join: {e}"),
})?;
let missing = result.map_err(|e| InvariantViolation {
invariant: "IndexBackedByDisk",
detail: e,
})?;
if missing.is_empty() {
Ok(())
} else {
Err(InvariantViolation {
invariant: "IndexBackedByDisk",
detail: format!(
"index references data files missing on disk (iris-shaped corruption): {}",
missing.join(", ")
),
})
}
}
}
pub struct NoOrphanFiles;
#[async_trait]
@@ -766,6 +930,18 @@ pub fn invariants_for<S: StorageIO + Send + Sync + 'static>(
Box::new(CompactionIdempotent),
),
(InvariantSet::NO_ORPHAN_FILES, Box::new(NoOrphanFiles)),
(
InvariantSet::INDEX_BACKED_BY_DISK,
Box::new(IndexBackedByDisk),
),
(
InvariantSet::HINT_BACKED_BY_DATA,
Box::new(HintBackedByData),
),
(
InvariantSet::INDEX_BLOCKS_READABLE,
Box::new(IndexBlocksReadable),
),
(InvariantSet::BYTE_BUDGET, Box::new(ByteBudget::default())),
(
InvariantSet::MANIFEST_EQUALS_REALITY,
+5 -3
View File
@@ -1,3 +1,4 @@
pub mod chaos_walker;
pub mod farm;
pub mod flaky;
pub mod invariants;
@@ -17,13 +18,14 @@ pub use flaky::{
BackingMegabytes, DownIntervalSecs, FlakyConfig, FlakyError, FlakyMount, UpIntervalSecs,
};
pub use invariants::{
EventLogSnapshot, Invariant, InvariantSet, InvariantViolation, SnapshotEvent, invariants_for,
EventLogSnapshot, HintBackedByData, IndexBackedByDisk, IndexBlocksReadable, Invariant,
InvariantCtx, InvariantSet, InvariantViolation, SnapshotEvent, invariants_for,
};
pub use leak::{LeakGateBuildError, LeakGateConfig, LeakViolation, evaluate as evaluate_leak_gate};
pub use metrics::{MetricName, MetricsSample, sample_harness};
pub use op::{
CollectionName, DidSeed, EventKind, Op, OpStream, PayloadSeed, RecordKey, RetentionSecs, Seed,
ValueSeed,
CollectionName, DidSeed, EventKind, FileChoice, Op, OpStream, PayloadSeed, RecordKey,
RetentionSecs, Seed, ValueSeed,
};
pub use oracle::{EventExpectation, Oracle};
pub use overrides::{ConfigOverrides, GroupCommitOverrides, StoreOverrides};
+6
View File
@@ -29,6 +29,9 @@ pub enum EventKind {
Sync,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FileChoice(pub u32);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Op {
AddRecord {
@@ -58,6 +61,9 @@ pub enum Op {
ReadBlock {
value_seed: ValueSeed,
},
ExternalDeleteDataFile {
choice: FileChoice,
},
}
impl Op {
+23 -1
View File
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use cid::Cid;
@@ -29,6 +29,7 @@ pub struct Oracle {
unsynced_events: Vec<EventExpectation>,
last_synced_seq: Option<EventSequence>,
last_retention_cutoff_us: Option<u64>,
lost_blocks: HashSet<CidBytes>,
}
impl Oracle {
@@ -93,6 +94,27 @@ impl Oracle {
self.unsynced_events.push(event);
}
pub fn mark_blocks_lost(&mut self, cids: impl IntoIterator<Item = CidBytes>) -> usize {
let added: HashSet<CidBytes> = cids.into_iter().collect();
let added_count = added.len();
self.live
.retain(|_, record_cid| !added.contains(record_cid));
self.lost_blocks.extend(added);
added_count
}
pub fn lost_blocks(&self) -> &HashSet<CidBytes> {
&self.lost_blocks
}
pub fn is_block_lost(&self, cid: &CidBytes) -> bool {
self.lost_blocks.contains(cid)
}
pub fn has_lost_blocks(&self) -> bool {
!self.lost_blocks.is_empty()
}
pub fn record_event_sync(&mut self, synced_through: EventSequence) {
let (promoted, remaining): (Vec<_>, Vec<_>) = self
.unsynced_events
+279 -72
View File
@@ -23,7 +23,7 @@ use crate::eventlog::{
SegmentManager, SegmentReader, TimestampMicros, ValidEvent,
};
use crate::io::{RealIO, StorageIO};
use crate::sim::{FaultConfig, SimulatedIO};
use crate::sim::{FaultConfig, PristineGuard, SimulatedIO};
#[derive(Debug, Clone, Copy)]
pub enum IoBackend {
@@ -93,6 +93,7 @@ pub struct GauntletConfig {
pub store: StoreConfig,
pub eventlog: Option<EventLogConfig>,
pub writer_concurrency: WriterConcurrency,
pub tolerate_op_errors: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -178,6 +179,7 @@ pub struct SharedState<S: StorageIO + Send + Sync + 'static> {
pub struct Gauntlet {
config: GauntletConfig,
scratch_root: Option<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
@@ -185,7 +187,15 @@ pub enum GauntletBuildError {}
impl Gauntlet {
pub fn new(config: GauntletConfig) -> Result<Self, GauntletBuildError> {
Ok(Self { config })
Ok(Self {
config,
scratch_root: None,
})
}
pub fn with_scratch_root(mut self, root: PathBuf) -> Self {
self.scratch_root = Some(root);
self
}
pub fn generate_ops(&self) -> OpStream {
@@ -211,6 +221,7 @@ impl Gauntlet {
let ops_counter = Arc::new(AtomicUsize::new(0));
let op_errors_counter = Arc::new(AtomicUsize::new(0));
let restarts_counter = Arc::new(AtomicUsize::new(0));
let scratch_root = self.scratch_root;
let fut: std::pin::Pin<Box<dyn std::future::Future<Output = GauntletReport> + Send>> =
match self.config.io {
IoBackend::Real => Box::pin(run_inner_real(
@@ -219,6 +230,7 @@ impl Gauntlet {
ops_counter.clone(),
op_errors_counter.clone(),
restarts_counter.clone(),
scratch_root,
)),
IoBackend::RealWithFlaky { flaky } => Box::pin(run_inner_real_with_flaky(
self.config,
@@ -269,9 +281,14 @@ async fn run_inner_real(
ops_counter: Arc<AtomicUsize>,
op_errors_counter: Arc<AtomicUsize>,
restarts_counter: Arc<AtomicUsize>,
scratch_root: Option<PathBuf>,
) -> GauntletReport {
let dir = tempfile::TempDir::new().expect("tempdir");
let dir = match scratch_root.as_deref() {
Some(parent) => tempfile::TempDir::new_in(parent).expect("tempdir in scratch root"),
None => tempfile::TempDir::new().expect("tempdir"),
};
let root = dir.path().to_path_buf();
let tolerate = config.tolerate_op_errors;
let report = run_inner_real_on_root(
config,
root,
@@ -279,7 +296,7 @@ async fn run_inner_real(
ops_counter,
op_errors_counter,
restarts_counter,
false,
tolerate,
Duration::ZERO,
)
.await;
@@ -364,7 +381,7 @@ async fn run_inner_real_on_root(
let segments_dir = segments_subdir(&root);
let open = {
let segments_dir = segments_dir.clone();
move || -> Result<Harness<RealIO>, String> {
move |_attempt: usize| -> Result<Harness<RealIO>, String> {
let store = TranquilBlockStore::open(cfg.clone())
.map(Arc::new)
.map_err(|e| e.to_string())?;
@@ -417,14 +434,15 @@ async fn run_inner_simulated(
) -> GauntletReport {
let dir = tempfile::TempDir::new().expect("tempdir");
let cfg = blockstore_config(dir.path(), &config.store);
let tolerate_errors = fault.injects_errors();
let tolerate_errors = fault.injects_errors() || config.tolerate_op_errors;
let eventlog_cfg = config.eventlog;
let segments_dir = segments_subdir(dir.path());
let sim: Arc<SimulatedIO> = Arc::new(SimulatedIO::new(config.seed.0, fault));
let sim_for_open = Arc::clone(&sim);
let open = {
let segments_dir = segments_dir.clone();
move || -> Result<Harness<Arc<SimulatedIO>>, String> {
move |attempt: usize| -> Result<Harness<Arc<SimulatedIO>>, String> {
let _pristine = PristineGuard::new(Arc::clone(&sim_for_open), attempt > 0);
let factory_sim = Arc::clone(&sim_for_open);
let make_io = move || Arc::clone(&factory_sim);
let store = TranquilBlockStore::<Arc<SimulatedIO>>::open_with_io(cfg.clone(), make_io)
@@ -512,28 +530,30 @@ async fn run_inner_generic<S, Open, Crash>(
) -> GauntletReport
where
S: StorageIO + Send + Sync + 'static,
Open: FnMut() -> Result<Harness<S>, String>,
Open: FnMut(usize) -> Result<Harness<S>, String>,
Crash: FnMut(),
{
let mut oracle = Oracle::new();
let mut violations: Vec<InvariantViolation> = Vec::new();
let mut harness: Option<Harness<S>> = match open() {
Ok(h) => Some(h),
Err(e) => {
return GauntletReport {
seed: config.seed,
ops_executed: OpsExecuted(0),
op_errors: OpErrorCount(op_errors_counter.load(Ordering::Relaxed)),
restarts: RestartCount(0),
violations: vec![InvariantViolation {
invariant: "OpenStore",
detail: format!("initial open: {e}"),
}],
ops: OpStream::empty(),
};
}
};
let mut harness: Option<Harness<S>> =
match reopen_with_recovery(&mut open, &mut crash, tolerate_op_errors, reopen_backoff).await
{
Ok(h) => Some(h),
Err(e) => {
return GauntletReport {
seed: config.seed,
ops_executed: OpsExecuted(0),
op_errors: OpErrorCount(op_errors_counter.load(Ordering::Relaxed)),
restarts: RestartCount(0),
violations: vec![InvariantViolation {
invariant: "OpenStore",
detail: format!("initial open: {e}"),
}],
ops: OpStream::empty(),
};
}
};
let mut root: Option<Cid> = None;
let mut restart_rng = Lcg::new(Seed(config.seed.0 ^ 0xA5A5_A5A5_A5A5_A5A5));
let mut sample_rng = Lcg::new(Seed(config.seed.0 ^ 0x5A5A_5A5A_5A5A_5A5A));
@@ -750,7 +770,7 @@ async fn reopen_with_recovery<S, Open, Crash>(
) -> Result<Harness<S>, String>
where
S: StorageIO + Send + Sync + 'static,
Open: FnMut() -> Result<Harness<S>, String>,
Open: FnMut(usize) -> Result<Harness<S>, String>,
Crash: FnMut(),
{
let mut errors: Vec<String> = Vec::new();
@@ -758,7 +778,7 @@ where
if attempt > 0 && !backoff.is_zero() {
tokio::time::sleep(backoff).await;
}
match open() {
match open(attempt) {
Ok(h) => return Ok(h),
Err(e) => {
errors.push(format!("attempt {attempt}: {e}"));
@@ -810,7 +830,6 @@ async fn run_quick_check<S: StorageIO + Send + Sync + 'static>(
};
};
let mst = Mst::load(store.clone(), r, None);
let live: Vec<(super::op::CollectionName, super::op::RecordKey, CidBytes)> = oracle
.live_records()
.map(|(c, k, v)| (c.clone(), k.clone(), *v))
@@ -822,24 +841,39 @@ async fn run_quick_check<S: StorageIO + Send + Sync + 'static>(
sample_distinct(rng, total, sample_size)
};
let mut violations: Vec<String> = Vec::new();
for idx in picks {
let (coll, rkey, expected) = &live[idx];
let key = format!("{}/{}", coll.0, rkey.0);
match mst.get(&key).await {
Ok(Some(cid)) => match try_cid_to_fixed(&cid) {
Ok(actual) if actual == *expected => {}
Ok(actual) => violations.push(format!(
"{key}: MST cid {} != oracle cid {}",
hex_short(&actual),
hex_short(expected)
)),
Err(e) => violations.push(format!("{key}: cid format: {e}")),
},
Ok(None) => violations.push(format!("{key}: missing after reopen")),
Err(e) => violations.push(format!("{key}: mst.get error: {e}")),
}
}
let store_c = store.clone();
let lost_clone = oracle.lost_blocks().clone();
let live_clone = live.clone();
let picks_c = picks.clone();
let violations: Vec<String> = tokio::task::spawn_blocking(move || {
picks_c
.iter()
.filter_map(|&idx| {
let (coll, rkey, expected) = &live_clone[idx];
let key = format!("{}/{}", coll.0, rkey.0);
match super::chaos_walker::mst_get_tolerant(&store_c, r, &key, &lost_clone) {
Ok(super::chaos_walker::LookupResult::Found(cid)) => {
match try_cid_to_fixed(&cid) {
Ok(actual) if actual == *expected => None,
Ok(actual) => Some(format!(
"{key}: MST cid {} != oracle cid {}",
hex_short(&actual),
hex_short(expected)
)),
Err(e) => Some(format!("{key}: cid format: {e}")),
}
}
Ok(super::chaos_walker::LookupResult::NotFound) => {
Some(format!("{key}: missing after reopen"))
}
Ok(super::chaos_walker::LookupResult::LostPath) => None,
Err(e) => Some(format!("{key}: mst.get error: {e}")),
}
})
.collect()
})
.await
.unwrap_or_else(|e| vec![format!("quick_check join: {e}")]);
if violations.is_empty() {
Vec::new()
@@ -949,16 +983,13 @@ pub(super) async fn refresh_oracle_graph<S: StorageIO + Send + Sync + 'static>(
Ok(())
}
Some(r) => {
let settled = Mst::load(store.clone(), r, None);
let cids = settled
.collect_node_cids()
.await
.map_err(|e| format!("collect_node_cids: {e}"))?;
let fixed: Vec<CidBytes> = cids
.iter()
.map(try_cid_to_fixed)
.collect::<Result<_, _>>()
.map_err(|e| format!("mst node cid: {e}"))?;
let store_c = store.clone();
let lost_clone = oracle.lost_blocks().clone();
let fixed = tokio::task::spawn_blocking(move || {
super::chaos_walker::walk_mst_node_cids_tolerant(&store_c, r, &lost_clone)
})
.await
.map_err(|e| format!("refresh join: {e}"))??;
oracle.set_root(r);
oracle.set_mst_node_cids(fixed);
Ok(())
@@ -1182,6 +1213,40 @@ pub(super) async fn apply_op<S: StorageIO + Send + Sync + 'static>(
let _ = harness.store.get_block_sync(&record_cid);
Ok(())
}
Op::ExternalDeleteDataFile { choice } => {
let s = harness.store.clone();
let pick = choice.0;
let lost_cids =
tokio::task::spawn_blocking(move || externally_delete_data_file(&s, pick))
.await
.map_err(|e| OpError::Join(e.to_string()))??;
if !lost_cids.is_empty() {
oracle.mark_blocks_lost(lost_cids);
}
Ok(())
}
}
}
fn externally_delete_data_file(
store: &std::sync::Arc<TranquilBlockStore<impl StorageIO + 'static>>,
pick: u32,
) -> Result<Vec<CidBytes>, OpError> {
let active = store.block_index().read_write_cursor().map(|c| c.file_id);
let mut candidates = match store.list_data_files() {
Ok(files) => files,
Err(_) => return Ok(Vec::new()),
};
candidates.retain(|fid| active.is_none_or(|a| *fid < a));
if candidates.is_empty() {
return Ok(Vec::new());
}
let idx = (pick as usize) % candidates.len();
let victim = candidates[idx];
let cids = store.block_index().cids_in_file(victim);
match std::fs::remove_file(store.data_file_path(victim)) {
Ok(()) => Ok(cids),
Err(_) => Ok(Vec::new()),
}
}
@@ -1191,6 +1256,10 @@ fn run_retention<S: StorageIO + Send + Sync + 'static>(
max_age: RetentionSecs,
) -> Result<(), String> {
let sync_result = el.writer.sync().map_err(|e| e.to_string())?;
el.manager
.io()
.sync_dir(el.segments_dir.as_path())
.map_err(|e| e.to_string())?;
let _ = el.writer.rotate_if_needed();
oracle.record_event_sync(sync_result.synced_through);
let active_id = sync_result.segment_id;
@@ -1489,6 +1558,19 @@ async fn apply_op_concurrent<S: StorageIO + Send + Sync + 'static>(
let _ = shared.store.get_block_sync(&record_cid);
Ok(())
}
Op::ExternalDeleteDataFile { choice } => {
let mut guard = shared.write.lock().await;
let s = shared.store.clone();
let pick = choice.0;
let lost_cids =
tokio::task::spawn_blocking(move || externally_delete_data_file(&s, pick))
.await
.map_err(|e| OpError::Join(e.to_string()))??;
if !lost_cids.is_empty() {
guard.oracle.mark_blocks_lost(lost_cids);
}
Ok(())
}
}
}
@@ -1564,7 +1646,7 @@ async fn run_inner_generic_concurrent<S, Open, Crash>(
) -> GauntletReport
where
S: StorageIO + Send + Sync + 'static,
Open: FnMut() -> Result<Harness<S>, String>,
Open: FnMut(usize) -> Result<Harness<S>, String>,
Crash: FnMut(),
{
let ops: Vec<Op> = op_stream.into_vec();
@@ -1577,22 +1659,24 @@ where
let mut sample_rng = Lcg::new(Seed(config.seed.0 ^ 0x5A5A_5A5A_5A5A_5A5A));
let chunks = compute_chunks(config.restart_policy, total_ops, &mut restart_rng);
let mut harness: Option<Harness<S>> = match open() {
Ok(h) => Some(h),
Err(e) => {
return GauntletReport {
seed: config.seed,
ops_executed: OpsExecuted(0),
op_errors: OpErrorCount(op_errors_counter.load(Ordering::Relaxed)),
restarts: RestartCount(0),
violations: vec![InvariantViolation {
invariant: "OpenStore",
detail: format!("initial open: {e}"),
}],
ops: OpStream::empty(),
};
}
};
let mut harness: Option<Harness<S>> =
match reopen_with_recovery(&mut open, &mut crash, tolerate_op_errors, reopen_backoff).await
{
Ok(h) => Some(h),
Err(e) => {
return GauntletReport {
seed: config.seed,
ops_executed: OpsExecuted(0),
op_errors: OpErrorCount(op_errors_counter.load(Ordering::Relaxed)),
restarts: RestartCount(0),
violations: vec![InvariantViolation {
invariant: "OpenStore",
detail: format!("initial open: {e}"),
}],
ops: OpStream::empty(),
};
}
};
let mut root: Option<Cid> = None;
let mut oracle = Oracle::new();
let mut halt_ops = false;
@@ -1801,3 +1885,126 @@ where
ops: OpStream::empty(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_config() -> GauntletConfig {
GauntletConfig {
seed: Seed(0),
io: IoBackend::Real,
workload: WorkloadModel::default(),
op_count: OpCount(0),
invariants: InvariantSet::EMPTY,
limits: RunLimits {
max_wall_ms: Some(WallMs(30_000)),
},
restart_policy: RestartPolicy::Never,
store: StoreConfig {
max_file_size: MaxFileSize(8 * 1024),
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
fn flaky_open(
attempts: Arc<AtomicUsize>,
sim: Arc<SimulatedIO>,
store_cfg: BlockStoreConfig,
) -> impl FnMut(usize) -> Result<Harness<Arc<SimulatedIO>>, String> + Send + 'static {
move |_attempt: usize| -> Result<Harness<Arc<SimulatedIO>>, String> {
let n = attempts.fetch_add(1, Ordering::Relaxed);
if n == 0 {
return Err("simulated EIO on initial open".to_string());
}
let factory_sim = Arc::clone(&sim);
let make_io = move || Arc::clone(&factory_sim);
TranquilBlockStore::<Arc<SimulatedIO>>::open_with_io(store_cfg.clone(), make_io)
.map(|s| Harness {
store: Arc::new(s),
eventlog: None,
})
.map_err(|e| e.to_string())
}
}
#[tokio::test]
async fn run_inner_generic_retries_initial_open_on_transient_io_error() {
let dir = tempfile::TempDir::new().expect("tempdir");
let cfg = minimal_config();
let store_cfg = blockstore_config(dir.path(), &cfg.store);
let sim: Arc<SimulatedIO> = Arc::new(SimulatedIO::pristine(0));
let attempts = Arc::new(AtomicUsize::new(0));
let report = run_inner_generic::<Arc<SimulatedIO>, _, _>(
cfg,
OpStream::empty(),
Arc::new(AtomicUsize::new(0)),
Arc::new(AtomicUsize::new(0)),
Arc::new(AtomicUsize::new(0)),
flaky_open(Arc::clone(&attempts), Arc::clone(&sim), store_cfg),
|| {},
true,
Duration::ZERO,
)
.await;
let opens: Vec<&InvariantViolation> = report
.violations
.iter()
.filter(|v| v.invariant == "OpenStore")
.collect();
assert!(
opens.is_empty(),
"expected initial open to retry, got OpenStore violations: {opens:?}"
);
let total = attempts.load(Ordering::Relaxed);
assert!(
total >= 2,
"expected at least one retry after first failure, attempts={total}"
);
}
#[tokio::test]
async fn run_inner_generic_concurrent_retries_initial_open_on_transient_io_error() {
let dir = tempfile::TempDir::new().expect("tempdir");
let mut cfg = minimal_config();
cfg.writer_concurrency = WriterConcurrency(2);
let store_cfg = blockstore_config(dir.path(), &cfg.store);
let sim: Arc<SimulatedIO> = Arc::new(SimulatedIO::pristine(0));
let attempts = Arc::new(AtomicUsize::new(0));
let report = run_inner_generic_concurrent::<Arc<SimulatedIO>, _, _>(
cfg,
OpStream::empty(),
Arc::new(AtomicUsize::new(0)),
Arc::new(AtomicUsize::new(0)),
Arc::new(AtomicUsize::new(0)),
flaky_open(Arc::clone(&attempts), Arc::clone(&sim), store_cfg),
|| {},
true,
Duration::ZERO,
)
.await;
let opens: Vec<&InvariantViolation> = report
.violations
.iter()
.filter(|v| v.invariant == "OpenStore")
.collect();
assert!(
opens.is_empty(),
"expected initial open to retry, got OpenStore violations: {opens:?}"
);
let total = attempts.load(Ordering::Relaxed);
assert!(
total >= 2,
"expected at least one retry after first failure, attempts={total}"
);
}
}
@@ -9,7 +9,7 @@ use super::workload::{
ByteRange, DidSpaceSize, KeySpaceSize, OpCount, OpWeights, RetentionMaxSecs, SizeDistribution,
ValueBytes, WorkloadModel,
};
use crate::blockstore::GroupCommitConfig;
use crate::blockstore::{GroupCommitConfig, MAX_BLOCK_SIZE};
use crate::sim::FaultConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -31,6 +31,7 @@ pub enum Scenario {
ContendedReaders,
ContendedWriters,
FlakyDevice,
ExternalCorruption,
}
impl Scenario {
@@ -53,6 +54,7 @@ impl Scenario {
Self::ContendedReaders => "ContendedReaders",
Self::ContendedWriters => "ContendedWriters",
Self::FlakyDevice => "FlakyDevice",
Self::ExternalCorruption => "ExternalCorruption",
}
}
@@ -75,6 +77,7 @@ impl Scenario {
Self::ContendedReaders => "contended-readers",
Self::ContendedWriters => "contended-writers",
Self::FlakyDevice => "flaky-device",
Self::ExternalCorruption => "external-corruption",
}
}
@@ -109,6 +112,9 @@ impl Scenario {
Self::FlakyDevice => {
"Real IO on ext4 atop dm-flakey. Requires root with dm-flakey available, skips otherwise."
}
Self::ExternalCorruption => {
"Rare external data-file deletion mid-workload. Validates phantom-purge self-heal under chaos."
}
}
}
@@ -138,6 +144,7 @@ impl Scenario {
Self::ContendedReaders,
Self::ContendedWriters,
Self::FlakyDevice,
Self::ExternalCorruption,
];
}
@@ -211,6 +218,7 @@ pub fn config_for(scenario: Scenario, seed: Seed) -> GauntletConfig {
Scenario::ContendedReaders => contended_readers(seed),
Scenario::ContendedWriters => contended_writers(seed),
Scenario::FlakyDevice => flaky_device(seed),
Scenario::ExternalCorruption => external_corruption(seed),
}
}
@@ -280,6 +288,7 @@ fn smoke_pr(seed: Seed) -> GauntletConfig {
store: tiny_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -305,6 +314,7 @@ fn mst_churn(seed: Seed) -> GauntletConfig {
store: tiny_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -330,6 +340,7 @@ fn mst_restart_churn(seed: Seed) -> GauntletConfig {
store: tiny_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -359,6 +370,7 @@ fn full_stack_restart(seed: Seed) -> GauntletConfig {
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -372,6 +384,9 @@ fn phase2_invariants() -> InvariantSet {
| InvariantSet::BYTE_BUDGET
| InvariantSet::MANIFEST_EQUALS_REALITY
| InvariantSet::CHECKSUM_COVERAGE
| InvariantSet::INDEX_BACKED_BY_DISK
| InvariantSet::HINT_BACKED_BY_DATA
| InvariantSet::INDEX_BLOCKS_READABLE
}
fn catastrophic_churn(seed: Seed) -> GauntletConfig {
@@ -392,6 +407,7 @@ fn catastrophic_churn(seed: Seed) -> GauntletConfig {
store: tiny_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -402,7 +418,7 @@ fn huge_values(seed: Seed) -> GauntletConfig {
workload: block_workload(
block_weights(85, 5, 8, 2),
SizeDistribution::HeavyTail(
ByteRange::new(ValueBytes(256), ValueBytes(16 * 1024 * 1024))
ByteRange::new(ValueBytes(256), ValueBytes(MAX_BLOCK_SIZE))
.expect("huge_values ByteRange"),
),
KeySpaceSize(64),
@@ -424,6 +440,7 @@ fn huge_values(seed: Seed) -> GauntletConfig {
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -454,6 +471,7 @@ fn tiny_batches(seed: Seed) -> GauntletConfig {
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -484,6 +502,7 @@ fn giant_batches(seed: Seed) -> GauntletConfig {
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -509,6 +528,7 @@ fn many_files(seed: Seed) -> GauntletConfig {
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -521,6 +541,9 @@ fn sim_invariants() -> InvariantSet {
| InvariantSet::NO_ORPHAN_FILES
| InvariantSet::BYTE_BUDGET
| InvariantSet::CHECKSUM_COVERAGE
| InvariantSet::INDEX_BACKED_BY_DISK
| InvariantSet::HINT_BACKED_BY_DATA
| InvariantSet::INDEX_BLOCKS_READABLE
}
fn sim_microbench_workload() -> WorkloadModel {
@@ -558,6 +581,7 @@ fn moderate_faults(seed: Seed) -> GauntletConfig {
store: sim_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -577,6 +601,7 @@ fn aggressive_faults(seed: Seed) -> GauntletConfig {
store: sim_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -596,6 +621,7 @@ fn torn_pages(seed: Seed) -> GauntletConfig {
store: sim_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -615,6 +641,7 @@ fn fsyncgate(seed: Seed) -> GauntletConfig {
store: sim_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -654,6 +681,7 @@ fn firehose_fanout(seed: Seed) -> GauntletConfig {
max_segment_size: MaxSegmentSize(64 * 1024),
}),
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -688,6 +716,7 @@ fn contended_readers(seed: Seed) -> GauntletConfig {
store: sim_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(64),
tolerate_op_errors: false,
}
}
@@ -719,6 +748,7 @@ fn flaky_device(seed: Seed) -> GauntletConfig {
store: tiny_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
@@ -753,5 +783,35 @@ fn contended_writers(seed: Seed) -> GauntletConfig {
store: sim_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(32),
tolerate_op_errors: false,
}
}
fn external_corruption(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Real,
workload: block_workload(
OpWeights {
add: 50,
delete: 30,
compact: 18,
checkpoint: 1,
external_delete_data_file: 1,
..OpWeights::default()
},
SizeDistribution::Fixed(ValueBytes(128)),
KeySpaceSize(200),
),
op_count: OpCount(2_000),
invariants: InvariantSet::NO_ORPHAN_FILES | InvariantSet::BYTE_BUDGET,
limits: RunLimits {
max_wall_ms: Some(WallMs(60_000)),
},
restart_policy: RestartPolicy::EveryNOps(OpInterval(1_000)),
store: tiny_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: true,
}
}
@@ -142,6 +142,7 @@ mod tests {
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
tolerate_op_errors: false,
}
}
+10 -3
View File
@@ -1,6 +1,6 @@
use super::op::{
CollectionName, DidSeed, EventKind, Op, OpStream, PayloadSeed, RecordKey, RetentionSecs, Seed,
ValueSeed,
CollectionName, DidSeed, EventKind, FileChoice, Op, OpStream, PayloadSeed, RecordKey,
RetentionSecs, Seed, ValueSeed,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -23,6 +23,7 @@ pub struct OpWeights {
pub run_retention: u32,
pub read_record: u32,
pub read_block: u32,
pub external_delete_data_file: u32,
}
impl OpWeights {
@@ -36,6 +37,7 @@ impl OpWeights {
+ self.run_retention
+ self.read_record
+ self.read_block
+ self.external_delete_data_file
}
pub const fn touches_eventlog(&self) -> bool {
@@ -103,6 +105,7 @@ impl Default for WorkloadModel {
run_retention: 0,
read_record: 0,
read_block: 0,
external_delete_data_file: 0,
},
size_distribution: SizeDistribution::Fixed(ValueBytes(64)),
collections: vec![CollectionName("app.bsky.feed.post".to_string())],
@@ -138,6 +141,7 @@ impl WorkloadModel {
let t6 = t5 + w.sync_event_log;
let t7 = t6 + w.run_retention;
let t8 = t7 + w.read_record;
let t9 = t8 + w.read_block;
match bucket {
b if b < t1 => Op::AddRecord {
@@ -166,9 +170,12 @@ impl WorkloadModel {
collection: coll,
rkey,
},
_ => Op::ReadBlock {
b if b < t9 => Op::ReadBlock {
value_seed: ValueSeed(rng.next_u32()),
},
_ => Op::ExternalDeleteDataFile {
choice: FileChoice(rng.next_u32()),
},
}
})
.collect();
+7
View File
@@ -104,6 +104,10 @@ pub trait StorageIO: Send + Sync {
fn sync_dir(&self, path: &Path) -> io::Result<()>;
fn list_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>>;
fn barrier(&self) -> io::Result<()> {
Ok(())
}
fn write_all_at(&self, fd: FileId, offset: u64, buf: &[u8]) -> io::Result<()> {
let written = Cell::new(0usize);
std::iter::from_fn(|| (written.get() < buf.len()).then_some(()))
@@ -190,6 +194,9 @@ impl<S: StorageIO> StorageIO for Arc<S> {
fn list_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
(**self).list_dir(path)
}
fn barrier(&self) -> io::Result<()> {
(**self).barrier()
}
fn mmap_file(&self, fd: FileId) -> io::Result<MappedFile> {
(**self).mmap_file(fd)
}
+1 -1
View File
@@ -28,7 +28,7 @@ pub use record::{
};
#[cfg(any(test, feature = "test-harness"))]
pub use sim::{
FaultConfig, LatencyNs, OpRecord, Probability, SimulatedIO, SyncReorderWindow,
FaultConfig, LatencyNs, OpRecord, PristineGuard, Probability, SimulatedIO, SyncReorderWindow,
sim_proptest_cases, sim_seed_count, sim_seed_range, sim_single_seed,
};
@@ -1860,6 +1860,18 @@ impl<S: StorageIO + 'static> tranquil_db_traits::InfraRepository for MetastoreCl
recv(rx).await
}
async fn mark_comms_failed_permanent(&self, id: Uuid, error: &str) -> Result<(), DbError> {
let (tx, rx) = oneshot::channel();
self.pool.send(MetastoreRequest::Infra(
InfraRequest::MarkCommsFailedPermanent {
id,
error: error.to_owned(),
tx,
},
))?;
recv(rx).await
}
async fn create_invite_code(
&self,
code: &str,
+35 -8
View File
@@ -1789,6 +1789,11 @@ pub enum InfraRequest {
error: String,
tx: Tx<()>,
},
MarkCommsFailedPermanent {
id: Uuid,
error: String,
tx: Tx<()>,
},
CreateInviteCode {
code: String,
use_count: i32,
@@ -3888,6 +3893,14 @@ fn dispatch_infra<S: StorageIO>(state: &HandlerState<S>, req: InfraRequest) {
.map_err(metastore_to_db);
let _ = tx.send(result);
}
InfraRequest::MarkCommsFailedPermanent { id, error, tx } => {
let result = state
.metastore
.infra_ops()
.mark_comms_failed_permanent(id, &error)
.map_err(metastore_to_db);
let _ = tx.send(result);
}
InfraRequest::CreateInviteCode {
code,
use_count,
@@ -5013,6 +5026,20 @@ fn dispatch<S: StorageIO + 'static>(state: &HandlerState<S>, request: MetastoreR
}
}
fn purge_repo_side_data<S: StorageIO + 'static>(
state: &HandlerState<S>,
user_id: Uuid,
did: &Did,
) -> Result<(), MetastoreError> {
let _ = state.metastore.blob_ops().delete_blobs_by_user(user_id)?;
let mut batch = state.metastore.database().batch();
state
.metastore
.backlink_ops()
.remove_backlinks_by_repo(&mut batch, UserHash::from_did(did.as_str()))?;
batch.commit().map_err(MetastoreError::Fjall)
}
fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserRequest) {
let user = state.metastore.user_ops();
match req {
@@ -5685,10 +5712,10 @@ fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserReque
let _ = tx.send(user.get_user_key_by_did(&did).map_err(metastore_to_db));
}
UserRequest::DeleteAccountComplete { user_id, did, tx } => {
let _ = tx.send(
user.delete_account_complete(user_id, &did)
.map_err(metastore_to_db),
);
let result = purge_repo_side_data(state, user_id, &did)
.and_then(|()| user.delete_account_complete(user_id, &did))
.map_err(metastore_to_db);
let _ = tx.send(result);
}
UserRequest::SetUserTakedown {
did,
@@ -5701,10 +5728,10 @@ fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserReque
);
}
UserRequest::AdminDeleteAccountComplete { user_id, did, tx } => {
let _ = tx.send(
user.admin_delete_account_complete(user_id, &did)
.map_err(metastore_to_db),
);
let result = purge_repo_side_data(state, user_id, &did)
.and_then(|()| user.admin_delete_account_complete(user_id, &did))
.map_err(metastore_to_db);
let _ = tx.send(result);
}
UserRequest::GetUserForDidDoc { did, tx } => {
let _ = tx.send(user.get_user_for_did_doc(&did).map_err(metastore_to_db));
@@ -247,7 +247,6 @@ impl InfraOps {
val.status = status_to_u8(CommsStatus::Sent);
val.sent_at_ms = Some(Utc::now().timestamp_millis());
val.attempts = val.attempts.saturating_add(1);
let mut batch = self.db.batch();
batch.insert(&self.infra, key.as_slice(), val.serialize());
@@ -272,9 +271,46 @@ impl InfraOps {
)?
.ok_or(MetastoreError::InvalidInput("comms entry not found"))?;
let next_attempts = val.attempts.saturating_add(1);
let exhausted = next_attempts >= val.max_attempts;
let next_status = match exhausted {
true => CommsStatus::Failed,
false => CommsStatus::Pending,
};
let now_ms = Utc::now().timestamp_millis();
let backoff_ms = i64::from(next_attempts).saturating_mul(60_000);
val.status = status_to_u8(next_status);
val.error_message = Some(error.to_owned());
val.attempts = next_attempts;
val.scheduled_for_ms = now_ms.saturating_add(backoff_ms);
let mut batch = self.db.batch();
batch.insert(&self.infra, key.as_slice(), val.serialize());
if let Some((hk, mut hv)) =
self.find_history_entry(val.user_id.unwrap_or(Uuid::nil()), val.id)?
{
hv.status = status_to_u8(next_status);
batch.insert(&self.infra, hk.as_slice(), hv.serialize());
}
batch.commit().map_err(MetastoreError::Fjall)
}
pub fn mark_comms_failed_permanent(&self, id: Uuid, error: &str) -> Result<(), MetastoreError> {
let key = comms_queue_key(id);
let mut val: QueuedCommsValue = point_lookup(
&self.infra,
key.as_slice(),
QueuedCommsValue::deserialize,
"corrupt comms queue entry",
)?
.ok_or(MetastoreError::InvalidInput("comms entry not found"))?;
val.status = status_to_u8(CommsStatus::Failed);
val.error_message = Some(error.to_owned());
val.attempts = val.attempts.saturating_add(1);
val.attempts = val.max_attempts;
let mut batch = self.db.batch();
batch.insert(&self.infra, key.as_slice(), val.serialize());
@@ -70,6 +70,18 @@ pub fn handle_key(handle_lower: &str) -> SmallVec<[u8; 128]> {
.build()
}
pub fn stage_repo_meta_removal(
batch: &mut fjall::OwnedWriteBatch,
repo_data: &fjall::Keyspace,
user_hash: UserHash,
handle: &str,
) {
batch.remove(repo_data, repo_meta_key(user_hash).as_slice());
if !handle.is_empty() {
batch.remove(repo_data, handle_key(handle).as_slice());
}
}
#[cfg(test)]
mod tests {
use super::*;
+181 -9
View File
@@ -6,8 +6,12 @@ use uuid::Uuid;
use super::MetastoreError;
use super::encoding::KeyReader;
use super::keys::{KeyTag, UserHash};
use super::repo_meta::{RepoMetaValue, RepoStatus, handle_key, repo_meta_key, repo_meta_prefix};
use super::scan::{count_prefix, point_lookup};
use super::records::record_user_prefix;
use super::repo_meta::{
RepoMetaValue, RepoStatus, handle_key, repo_meta_key, repo_meta_prefix, stage_repo_meta_removal,
};
use super::scan::{count_prefix, delete_all_by_prefix, point_lookup};
use super::user_blocks::user_block_user_prefix;
use super::user_hash::UserHashMap;
use tranquil_types::{CidLink, Did, Handle};
@@ -241,13 +245,7 @@ impl RepoOps {
let meta = self.get_meta_value(key.as_slice())?;
let mut batch = db.batch();
batch.remove(&self.repo_data, key.as_slice());
match meta.handle.is_empty() {
true => {}
false => batch.remove(&self.repo_data, handle_key(&meta.handle).as_slice()),
}
stage_repo_meta_removal(&mut batch, &self.repo_data, user_hash, &meta.handle);
self.user_hashes.stage_remove(&mut batch, &user_id);
match batch.commit() {
@@ -259,6 +257,48 @@ impl RepoOps {
}
}
pub fn purge_orphan_repos(&self, db: &fjall::Database) -> Result<usize, MetastoreError> {
let prefix = repo_meta_prefix();
let orphans: Vec<(UserHash, String)> = self
.repo_data
.prefix(prefix.as_slice())
.map(|guard| -> Result<Option<(UserHash, String)>, MetastoreError> {
let (k, v) = guard.into_inner().map_err(MetastoreError::Fjall)?;
let user_hash = parse_repo_meta_key_hash(&k)
.ok_or(MetastoreError::CorruptData("invalid repo_meta key"))?;
match self.user_hashes.get_uuid(&user_hash) {
Some(_) => Ok(None),
None => {
let handle = match RepoMetaValue::deserialize(&v) {
Some(meta) => meta.handle,
None => {
tracing::warn!(
user_hash = user_hash.raw(),
"could not deserialize orphan repo_meta to recover handle for cleanup"
);
String::new()
}
};
Ok(Some((user_hash, handle)))
}
}
})
.filter_map(Result::transpose)
.collect::<Result<Vec<_>, _>>()?;
match orphans.is_empty() {
true => Ok(0),
false => {
let mut batch = db.batch();
orphans.iter().try_for_each(|(user_hash, handle)| {
stage_full_repo_data_removal(&mut batch, &self.repo_data, *user_hash, handle)
})?;
batch.commit().map_err(MetastoreError::Fjall)?;
Ok(orphans.len())
}
}
}
pub fn get_repo(&self, user_id: Uuid) -> Result<Option<RepoInfo>, MetastoreError> {
let user_hash = match self.user_hashes.get(&user_id) {
Some(h) => h,
@@ -569,9 +609,26 @@ fn parse_repo_meta_key_hash(key_bytes: &[u8]) -> Option<UserHash> {
Some(UserHash::from_raw(hash))
}
pub(super) fn stage_full_repo_data_removal(
batch: &mut fjall::OwnedWriteBatch,
repo_data: &Keyspace,
user_hash: UserHash,
handle: &str,
) -> Result<(), MetastoreError> {
stage_repo_meta_removal(batch, repo_data, user_hash, handle);
delete_all_by_prefix(repo_data, batch, record_user_prefix(user_hash).as_slice())?;
delete_all_by_prefix(
repo_data,
batch,
user_block_user_prefix(user_hash).as_slice(),
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metastore::partitions::Partition;
use crate::metastore::{Metastore, MetastoreConfig};
fn test_config() -> MetastoreConfig {
@@ -725,6 +782,121 @@ mod tests {
assert!(ops.get_repo(uid_a).unwrap().is_none());
}
#[test]
fn purge_orphan_repos_removes_entries_with_missing_reverse_mapping() {
let (_dir, ms) = open_fresh();
let ops = ms.repo_ops();
let orphan_id = uuid::Uuid::new_v4();
let orphan_did = test_did("limpet");
let orphan_handle = test_handle("limpet");
let live_id = uuid::Uuid::new_v4();
let live_did = test_did("whelk");
let live_handle = test_handle("whelk");
let cid = test_cid_link(9);
ops.create_repo(
ms.database(),
orphan_id,
&orphan_did,
&orphan_handle,
&cid,
"rev1",
)
.unwrap();
ops.create_repo(
ms.database(),
live_id,
&live_did,
&live_handle,
&cid,
"rev1",
)
.unwrap();
let mut batch = ms.database().batch();
ms.user_hashes().stage_remove(&mut batch, &orphan_id);
batch.commit().unwrap();
assert!(matches!(
ops.list_repos_paginated(None, 100),
Err(MetastoreError::CorruptData(
"user_hash has no reverse mapping"
))
));
assert_eq!(ops.purge_orphan_repos(ms.database()).unwrap(), 1);
let repos = ops.list_repos_paginated(None, 100).unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].user_id, live_id);
assert!(ops.lookup_handle(&orphan_handle).unwrap().is_none());
assert!(ops.lookup_handle(&live_handle).unwrap().is_some());
assert_eq!(ops.purge_orphan_repos(ms.database()).unwrap(), 0);
}
#[test]
fn purge_orphan_repos_removes_records_and_blocks_for_orphan_only() {
let (_dir, ms) = open_fresh();
let ops = ms.repo_ops();
let orphan_id = uuid::Uuid::new_v4();
let orphan_did = test_did("scallop");
let orphan_handle = test_handle("scallop");
let live_id = uuid::Uuid::new_v4();
let live_did = test_did("mussel");
let live_handle = test_handle("mussel");
let cid = test_cid_link(3);
ops.create_repo(
ms.database(),
orphan_id,
&orphan_did,
&orphan_handle,
&cid,
"rev1",
)
.unwrap();
ops.create_repo(
ms.database(),
live_id,
&live_did,
&live_handle,
&cid,
"rev1",
)
.unwrap();
let orphan_hash = ms.user_hashes().get(&orphan_id).unwrap();
let live_hash = ms.user_hashes().get(&live_id).unwrap();
let seed = |hash: UserHash| {
let mut batch = ms.database().batch();
let repo_data = ms.partition(Partition::RepoData);
let mut rec_key = record_user_prefix(hash);
rec_key.extend_from_slice(b"app.bsky.feed.post/seed");
batch.insert(repo_data, rec_key.as_slice(), b"r");
let mut blk_key = user_block_user_prefix(hash);
blk_key.extend_from_slice(b"seed-cid");
batch.insert(repo_data, blk_key.as_slice(), b"b");
batch.commit().unwrap();
};
seed(orphan_hash);
seed(live_hash);
let mut batch = ms.database().batch();
ms.user_hashes().stage_remove(&mut batch, &orphan_id);
batch.commit().unwrap();
let count = |prefix: &[u8]| ms.partition(Partition::RepoData).prefix(prefix).count();
assert_eq!(ops.purge_orphan_repos(ms.database()).unwrap(), 1);
assert_eq!(count(record_user_prefix(orphan_hash).as_slice()), 0);
assert_eq!(count(user_block_user_prefix(orphan_hash).as_slice()), 0);
assert_eq!(count(record_user_prefix(live_hash).as_slice()), 1);
assert_eq!(count(user_block_user_prefix(live_hash).as_slice()), 1);
}
#[test]
fn delete_nonexistent_user_returns_error() {
let (_dir, ms) = open_fresh();
@@ -8,7 +8,7 @@ use super::MetastoreError;
use super::infra_schema::{channel_to_u8, u8_to_channel};
use super::keys::UserHash;
use super::repo_meta::{RepoMetaValue, RepoStatus, handle_key, repo_meta_key};
use super::repo_ops::cid_link_to_bytes;
use super::repo_ops::{cid_link_to_bytes, stage_full_repo_data_removal};
use super::scan::{count_prefix, delete_all_by_prefix, point_lookup};
use super::sessions::{SessionIndexValue, session_by_access_key};
use super::user_hash::UserHashMap;
@@ -182,6 +182,7 @@ impl UserOps {
.and_then(DateTime::from_timestamp_millis),
takedown_ref: val.takedown_ref.clone(),
is_admin: val.is_admin,
inbound_migration: val.inbound_migration,
})
}
@@ -2244,6 +2245,7 @@ impl UserOps {
self.mutate_user(user_hash, |u| {
u.deactivated_at_ms = None;
u.delete_after_ms = None;
u.inbound_migration = false;
})
}
@@ -2421,10 +2423,27 @@ impl UserOps {
let mut batch = self.db.batch();
self.delete_user_data(&mut batch, user_hash, &user)?;
self.stage_repo_data_removal(&mut batch, user_hash)?;
self.user_hashes.stage_remove(&mut batch, &user_id);
batch.commit().map_err(MetastoreError::Fjall)
}
fn stage_repo_data_removal(
&self,
batch: &mut fjall::OwnedWriteBatch,
user_hash: UserHash,
) -> Result<(), MetastoreError> {
let handle = point_lookup(
&self.repo_data,
repo_meta_key(user_hash).as_slice(),
RepoMetaValue::deserialize,
"invalid repo_meta value",
)?
.map(|m| m.handle)
.unwrap_or_default();
stage_full_repo_data_removal(batch, &self.repo_data, user_hash, &handle)
}
pub fn set_user_takedown(
&self,
did: &Did,
@@ -2657,6 +2676,7 @@ impl UserOps {
account_type: AccountType,
password_required: bool,
is_admin: bool,
inbound_migration: bool,
) -> UserValue {
let now_ms = Utc::now().timestamp_millis();
UserValue {
@@ -2692,6 +2712,7 @@ impl UserOps {
signal_username: signal_username.map(str::to_owned),
signal_verified: false,
delete_after_ms: None,
inbound_migration,
}
}
@@ -2841,6 +2862,7 @@ impl UserOps {
AccountType::Personal,
true,
is_admin,
input.inbound_migration,
);
self.write_new_account(&user_value, &input.commit_cid, &input.repo_rev)
@@ -2867,6 +2889,7 @@ impl UserOps {
AccountType::Delegated,
false,
false,
false,
);
let result = self.write_new_account(&user_value, &input.commit_cid, &input.repo_rev)?;
@@ -2898,6 +2921,7 @@ impl UserOps {
AccountType::Personal,
false,
is_admin,
false,
);
let result = self.write_new_account(&user_value, &input.commit_cid, &input.repo_rev)?;
@@ -2942,6 +2966,7 @@ impl UserOps {
AccountType::Personal,
false,
is_admin,
false,
);
self.write_new_account(&user_value, &input.commit_cid, &input.repo_rev)

Some files were not shown because too many files have changed in this diff Show More