mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-25 18:56:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e85394c314 | ||
|
|
411c6be108 | ||
|
|
28aa7ab7fc | ||
|
|
348b69d76b | ||
|
|
4b23ca2c36 | ||
|
|
a7052e878c | ||
|
|
fc6063dba8 | ||
|
|
44cb016762 | ||
|
|
e6eee18ace | ||
|
|
96c8375706 | ||
|
|
86c5995568 | ||
|
|
036c317fd6 | ||
|
|
4d2c7d4723 | ||
|
|
f24a9f8bc0 | ||
|
|
f6ef6ecbd9 | ||
|
|
a2567bdb1a | ||
|
|
56120d252d | ||
|
|
210b0f463c | ||
|
|
c80f504dc0 | ||
|
|
a3bd7c59ad | ||
|
|
9ebde27540 | ||
|
|
28a7834304 | ||
|
|
191da5b311 | ||
|
|
a5b4ba7d65 | ||
|
|
90dabd8840 | ||
|
|
19eaccea74 | ||
|
|
f620a6bc43 | ||
|
|
04f370aaa1 | ||
|
|
3fd8f7ebbf | ||
|
|
0afcb2ee28 | ||
|
|
8bd556f65b | ||
|
|
cc92594506 | ||
|
|
76f22b801b | ||
|
|
021b7dbec4 | ||
|
|
eb034cb8b3 | ||
|
|
bdaf510898 | ||
|
|
deb2502112 | ||
|
|
1815ddba9f | ||
|
|
a7517ed5c9 | ||
|
|
d07d702dd4 | ||
|
|
1901b0a630 | ||
|
|
58f8d327c1 | ||
|
|
a13343e1de | ||
|
|
60e10af4aa | ||
|
|
f176f55862 | ||
|
|
fac9520a16 | ||
|
|
eee6fb9ff4 | ||
|
|
2462d0ab3b | ||
|
|
85f87f7b28 | ||
|
|
b1d86caa78 | ||
|
|
9b2cfb3a7e | ||
|
|
efd499bb26 | ||
|
|
d4dfe838eb | ||
|
|
af3821514f | ||
|
|
8f7aad3756 | ||
|
|
75b9e3165f | ||
|
|
ccc9916109 | ||
|
|
bc8fd66a45 | ||
|
|
180de29984 | ||
|
|
0455dc20bd | ||
|
|
2770b9b14a | ||
|
|
d436597184 | ||
|
|
4cfca6d956 | ||
|
|
98b94fb170 | ||
|
|
4fe01cff72 | ||
|
|
00c9eb732f | ||
|
|
6d2d3b4be4 | ||
|
|
9d81e58803 | ||
|
|
2afd075496 | ||
|
|
c74bf967cf | ||
|
|
1285d5c675 | ||
|
|
c30d73cd4d | ||
|
|
0fab8f2eb9 | ||
|
|
57336fa124 | ||
|
|
ace105899f | ||
|
|
c80a525e0d | ||
|
|
7edb76507b | ||
|
|
a20e4e05e6 | ||
|
|
3e7a199736 | ||
|
|
7ed9c8ce59 | ||
|
|
b0c3402a82 | ||
|
|
b9574f3ec1 | ||
|
|
baef2be844 | ||
|
|
1f21b85069 | ||
|
|
09d437b3e3 | ||
|
|
7f2e83e92f | ||
|
|
ec273fa814 |
@@ -25,6 +25,28 @@ fail-fast = false
|
||||
test-threads = "num-cpus"
|
||||
slow-timeout = { period = "300s", terminate-after = 2 }
|
||||
|
||||
[profile.gauntlet-pr]
|
||||
retries = 0
|
||||
fail-fast = true
|
||||
test-threads = "num-cpus"
|
||||
slow-timeout = { period = "60s", terminate-after = 5 }
|
||||
|
||||
[[profile.gauntlet-pr.overrides]]
|
||||
filter = "binary(gauntlet_smoke)"
|
||||
slow-timeout = { period = "300s", terminate-after = 2 }
|
||||
|
||||
[profile.gauntlet-nightly]
|
||||
retries = 0
|
||||
fail-fast = false
|
||||
test-threads = "num-cpus"
|
||||
slow-timeout = { period = "600s", terminate-after = 1 }
|
||||
|
||||
[profile.gauntlet-soak]
|
||||
retries = 0
|
||||
fail-fast = false
|
||||
test-threads = 1
|
||||
slow-timeout = { period = "5m", terminate-after = 1000 }
|
||||
|
||||
[test-groups]
|
||||
serial-env-tests = { max-threads = 1 }
|
||||
heavy-load-tests = { max-threads = 4 }
|
||||
@@ -46,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"
|
||||
@@ -72,6 +98,10 @@ test-group = "io-heavy-sim"
|
||||
filter = "test(/test_scale_/) | test(/full_backup_and_restore/)"
|
||||
slow-timeout = { period = "120s", terminate-after = 4 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "binary(gauntlet_smoke)"
|
||||
slow-timeout = { period = "300s", terminate-after = 8 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "binary(compaction_restart) | binary(mst_refcount_integrity) | binary(gc_compaction_restart)"
|
||||
slow-timeout = { period = "120s", terminate-after = 4 }
|
||||
@@ -92,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"
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
/target
|
||||
target/
|
||||
.env
|
||||
.direnv
|
||||
result
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.pnpm-store
|
||||
frontend/.npmrc
|
||||
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
when:
|
||||
- event: ["push", "manual"]
|
||||
branch: ["main"]
|
||||
|
||||
engine: nixery
|
||||
|
||||
dependencies:
|
||||
nixpkgs:
|
||||
- nix
|
||||
- cachix
|
||||
- gnugrep
|
||||
|
||||
steps:
|
||||
- name: Authenticate
|
||||
command: |
|
||||
cachix authtoken "$CACHIX_AUTH_TOKEN"
|
||||
|
||||
- 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 tranquil
|
||||
@@ -0,0 +1,28 @@
|
||||
when:
|
||||
- event: ["push", "manual"]
|
||||
branch: ["main"]
|
||||
|
||||
engine: nixery
|
||||
|
||||
dependencies:
|
||||
nixpkgs:
|
||||
- nix
|
||||
- cachix
|
||||
- gnugrep
|
||||
|
||||
steps:
|
||||
- name: Authenticate
|
||||
command: |
|
||||
cachix authtoken "$CACHIX_AUTH_TOKEN"
|
||||
|
||||
- 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 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 tranquil dev-profile
|
||||
@@ -1,24 +1,36 @@
|
||||
when:
|
||||
- event: []
|
||||
branch: []
|
||||
- event: [ "manual" ]
|
||||
- event: [ "push" ]
|
||||
branch: [ "main" ]
|
||||
|
||||
engine: nixery
|
||||
|
||||
dependencies:
|
||||
nixpkgs:
|
||||
- podman
|
||||
- kaniko
|
||||
|
||||
environment:
|
||||
DOCKER_CONFIG: "/kaniko/.docker"
|
||||
|
||||
steps:
|
||||
- name: Create podman config
|
||||
- name: Configure Kaniko
|
||||
command: |
|
||||
mkdir -p ~/.config/containers
|
||||
echo "unqualified-search-registries = [\"docker.io\"]" >> ~/.config/containers/registries.conf
|
||||
mkdir -p /kaniko/.docker/
|
||||
echo "{
|
||||
\"auths\": {
|
||||
\"https://atcr.io/v1\":{
|
||||
\"auth\": \"$ATCR_CREDENTIALS\"
|
||||
}
|
||||
}
|
||||
}" > /kaniko/.docker/config.json
|
||||
|
||||
- name: Build image
|
||||
command: |
|
||||
podman build . -t tranquil-pds:latest -t "tranquil-pds:$TANGLED_COMMIT_SHA"
|
||||
|
||||
- name: Publish image
|
||||
command: |
|
||||
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil.farm/tranquil-pds:latest"
|
||||
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA"
|
||||
executor \
|
||||
--context=$(pwd) \
|
||||
--ignore-path=$(pwd) \
|
||||
--dockerfile=$(pwd)/Dockerfile \
|
||||
--destination="atcr.io/tranquil.farm/tranquil-pds:latest" \
|
||||
--destination="atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA" \
|
||||
--push-retry=3 \
|
||||
--skip-push-permission-check
|
||||
|
||||
+135
@@ -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
+179
-33
@@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a"
|
||||
dependencies = [
|
||||
"abnf-core",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -18,7 +18,7 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d"
|
||||
dependencies = [
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -213,7 +213,7 @@ dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 1.0.69",
|
||||
@@ -1558,7 +1558,7 @@ checksum = "06b4f5ec222421e22bb0a8cbaa36b1d2b50fd45cdd30c915ded34108da78b29f"
|
||||
dependencies = [
|
||||
"confique-macro",
|
||||
"serde",
|
||||
"toml",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1972,7 +1972,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
@@ -2216,6 +2216,22 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -3780,6 +3796,37 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"nom 8.0.0",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rsa",
|
||||
"rustls 0.23.37",
|
||||
"sha2",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tracing",
|
||||
"url",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
@@ -4409,6 +4456,15 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
@@ -4839,7 +4895,7 @@ checksum = "9114f9c1683dd09c5f4fa024c89fdad783eaae21d3d52dd23ddaaffa29ffb168"
|
||||
dependencies = [
|
||||
"either",
|
||||
"fnv",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"once_cell",
|
||||
"postcard",
|
||||
"quick-xml",
|
||||
@@ -5050,7 +5106,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
"toml_edit 0.25.5+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5427,6 +5483,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
@@ -5833,7 +5895,7 @@ version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6050,6 +6112,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrecy"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -6201,6 +6273,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
@@ -7144,6 +7225,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
@@ -7152,13 +7245,22 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"serde_spanned 1.0.4",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
@@ -7177,6 +7279,20 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.5+spec-1.1.0"
|
||||
@@ -7198,6 +7314,12 @@ dependencies = [
|
||||
"winnow 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.7+spec-1.1.0"
|
||||
@@ -7405,7 +7527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7456,7 +7578,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -7479,7 +7601,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7493,11 +7615,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"ed25519-dalek",
|
||||
"futures",
|
||||
"hickory-resolver",
|
||||
"lettre",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"rsa",
|
||||
"secrecy",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
@@ -7511,7 +7641,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
@@ -7519,7 +7649,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -7535,7 +7665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7552,7 +7682,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7568,7 +7698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -7579,9 +7709,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
"hickory-resolver",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
@@ -7597,7 +7728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7620,7 +7751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -7653,7 +7784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -7693,6 +7824,7 @@ dependencies = [
|
||||
"multibase",
|
||||
"multihash",
|
||||
"p256 0.13.2",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"redis",
|
||||
"regex",
|
||||
@@ -7745,7 +7877,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -7757,7 +7889,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
@@ -7782,7 +7914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -7798,15 +7930,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
"clap",
|
||||
"dotenvy",
|
||||
"ed25519-dalek",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pemfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tranquil-api",
|
||||
@@ -7819,7 +7960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-signal"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7842,7 +7983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -7859,12 +8000,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-store"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"cid",
|
||||
"clap",
|
||||
"dashmap",
|
||||
"fjall",
|
||||
"flume 0.11.1",
|
||||
@@ -7892,6 +8034,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tikv-jemallocator",
|
||||
"tokio",
|
||||
"toml 0.8.23",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tranquil-db",
|
||||
@@ -7906,7 +8049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7928,7 +8071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.5.4"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cid",
|
||||
@@ -8443,7 +8586,7 @@ dependencies = [
|
||||
"base64urlsafedata",
|
||||
"der-parser",
|
||||
"hex",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"rand 0.9.2",
|
||||
@@ -8853,6 +8996,9 @@ name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
@@ -9012,7 +9158,7 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"oid-registry",
|
||||
"rusticata-macros",
|
||||
"thiserror 1.0.69",
|
||||
|
||||
+11
-2
@@ -26,7 +26,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.4"
|
||||
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"] }
|
||||
|
||||
+4
-6
@@ -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
|
||||
@@ -35,7 +35,7 @@ COPY crates/tranquil-oauth-server ./crates/tranquil-oauth-server
|
||||
COPY crates/tranquil-store ./crates/tranquil-store
|
||||
COPY crates/tranquil-signal ./crates/tranquil-signal
|
||||
COPY crates/tranquil-server ./crates/tranquil-server
|
||||
COPY migrations ./crates/tranquil-pds/migrations
|
||||
COPY migrations ./migrations
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/target \
|
||||
if [ "$SLIM" = "true" ]; then \
|
||||
@@ -46,13 +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
|
||||
COPY migrations /app/migrations
|
||||
WORKDIR /app
|
||||
ENV SERVER_HOST=0.0.0.0
|
||||
ENV SERVER_HOST=[::]
|
||||
ENV SERVER_PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["tranquil-pds"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,22 +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/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
|
||||
|
||||
- [Debian](docs/install-debian.md)
|
||||
- [Containers](docs/install-containers.md)
|
||||
- [Kubernetes](docs/install-kubernetes.md)
|
||||
- [Nix](docs/2_INSTALL_NIX.md)
|
||||
- [Containers](docs/2_INSTALL_CONTAINERS.md)
|
||||
|
||||
## Community
|
||||
|
||||
@@ -74,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)
|
||||
@@ -85,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!
|
||||
@@ -99,4 +106,3 @@ This project is very grateful to [@nonbinary.computer](https://tangled.org/did:p
|
||||
## License
|
||||
|
||||
AGPL-3.0-or-later. Documentation is CC BY-SA 4.0. See [LICENSE](LICENSE) for details.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -12,7 +12,6 @@ use tranquil_pds::auth::{Auth, NotTakendown, Permissive};
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
const APP_BSKY_NAMESPACE: &str = "app.bsky";
|
||||
const MAX_PREFERENCES_COUNT: usize = 100;
|
||||
const MAX_PREFERENCE_SIZE: usize = 10_000;
|
||||
const PERSONAL_DETAILS_PREF: &str = "app.bsky.actor.defs#personalDetailsPref";
|
||||
const DECLARED_AGE_PREF: &str = "app.bsky.actor.defs#declaredAgePref";
|
||||
@@ -92,6 +91,8 @@ pub async fn put_preferences(
|
||||
auth: Auth<NotTakendown>,
|
||||
Json(input): Json<PutPreferencesInput>,
|
||||
) -> Response {
|
||||
let max_preferences_count: usize = tranquil_config::get().server.max_preferences_count;
|
||||
|
||||
let has_full_access = auth.permissions().has_full_access();
|
||||
let user_id: uuid::Uuid = match state.repos.user.get_id_by_did(&auth.did).await {
|
||||
Ok(Some(id)) => id,
|
||||
@@ -99,11 +100,11 @@ pub async fn put_preferences(
|
||||
return ApiError::InternalError(Some("User not found".into())).into_response();
|
||||
}
|
||||
};
|
||||
if input.preferences.len() > MAX_PREFERENCES_COUNT {
|
||||
if input.preferences.len() > max_preferences_count {
|
||||
return ApiError::InvalidRequest(format!(
|
||||
"Too many preferences: {} exceeds limit of {}",
|
||||
input.preferences.len(),
|
||||
MAX_PREFERENCES_COUNT
|
||||
max_preferences_count
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -76,7 +74,7 @@ pub async fn link_signal_device(
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.cancelled() => {
|
||||
tracing::info!("Signal linking aborted due to server shutdown");
|
||||
tracing::info!("server shutting down, aborting signal linking");
|
||||
return;
|
||||
}
|
||||
r = link_result.completion => r,
|
||||
@@ -84,10 +82,10 @@ pub async fn link_signal_device(
|
||||
match result {
|
||||
Ok(Ok(client)) => {
|
||||
if slot_for_task.complete_link(generation, client).await {
|
||||
tracing::info!("Signal device linked successfully");
|
||||
tracing::info!("signal device linked");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Signal link completed but generation mismatch or already linked; discarding"
|
||||
"discarding completed signal link, generation mismatch or already linked"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -282,7 +282,7 @@ async fn process_messaging_channel_update(
|
||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
||||
),
|
||||
CommsChannel::Signal => ApiError::InvalidRequest(
|
||||
"Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)".into(),
|
||||
"Invalid Signal username. Must be a 3-32 character nickname, a dot, then a 2-20 digit discriminator".into(),
|
||||
),
|
||||
CommsChannel::Email => ApiError::InvalidEmail,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -27,7 +27,6 @@ struct WriteAccumulator {
|
||||
mst: Mst<TrackingBlockStore>,
|
||||
results: Vec<WriteResult>,
|
||||
ops: Vec<RecordOp>,
|
||||
modified_keys: Vec<String>,
|
||||
all_blob_cids: Vec<String>,
|
||||
backlinks_to_add: Vec<Backlink>,
|
||||
backlinks_to_remove: Vec<AtUri>,
|
||||
@@ -44,7 +43,6 @@ async fn process_single_write(
|
||||
mst,
|
||||
mut results,
|
||||
mut ops,
|
||||
mut modified_keys,
|
||||
mut all_blob_cids,
|
||||
mut backlinks_to_add,
|
||||
mut backlinks_to_remove,
|
||||
@@ -69,8 +67,19 @@ async fn process_single_write(
|
||||
.await?,
|
||||
)
|
||||
};
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
let rkey = rkey.clone().unwrap_or_else(Rkey::generate);
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
if mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"Record already exists at {key}"
|
||||
)));
|
||||
}
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
let record_ipld = tranquil_pds::util::json_to_ipld(value);
|
||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
||||
@@ -78,8 +87,6 @@ async fn process_single_write(
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
modified_keys.push(key.clone());
|
||||
let new_mst = mst
|
||||
.add(&key, record_cid)
|
||||
.await
|
||||
@@ -100,7 +107,6 @@ async fn process_single_write(
|
||||
mst: new_mst,
|
||||
results,
|
||||
ops,
|
||||
modified_keys,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
@@ -124,16 +130,7 @@ async fn process_single_write(
|
||||
.await?,
|
||||
)
|
||||
};
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
let record_ipld = tranquil_pds::util::json_to_ipld(value);
|
||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
||||
let record_cid = tracking_store
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
modified_keys.push(key.clone());
|
||||
let prev_record_cid = mst
|
||||
.get(&key)
|
||||
.await
|
||||
@@ -143,6 +140,14 @@ async fn process_single_write(
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidRequest("Update target record does not exist".into())
|
||||
})?;
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
let record_ipld = tranquil_pds::util::json_to_ipld(value);
|
||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
||||
let record_cid = tracking_store
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
|
||||
let new_mst = mst
|
||||
.update(&key, record_cid)
|
||||
.await
|
||||
@@ -165,7 +170,6 @@ async fn process_single_write(
|
||||
mst: new_mst,
|
||||
results,
|
||||
ops,
|
||||
modified_keys,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
@@ -173,7 +177,6 @@ async fn process_single_write(
|
||||
}
|
||||
WriteOp::Delete { collection, rkey } => {
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
modified_keys.push(key.clone());
|
||||
let prev_record_cid = mst
|
||||
.get(&key)
|
||||
.await
|
||||
@@ -198,7 +201,6 @@ async fn process_single_write(
|
||||
mst: new_mst,
|
||||
results,
|
||||
ops,
|
||||
modified_keys,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
@@ -219,7 +221,6 @@ async fn process_writes(
|
||||
mst: initial_mst,
|
||||
results: Vec::new(),
|
||||
ops: Vec::new(),
|
||||
modified_keys: Vec::new(),
|
||||
all_blob_cids: Vec::new(),
|
||||
backlinks_to_add: Vec::new(),
|
||||
backlinks_to_remove: Vec::new(),
|
||||
@@ -351,7 +352,6 @@ pub async fn apply_writes(
|
||||
mst: final_mst,
|
||||
results,
|
||||
ops,
|
||||
modified_keys,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
@@ -407,7 +407,6 @@ pub async fn apply_writes(
|
||||
controller_did: controller_did.as_ref(),
|
||||
delegation_detail: write_summary,
|
||||
ops,
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
|
||||
@@ -74,7 +74,6 @@ pub async fn delete_record(
|
||||
prev: RecordCid::from(prev_record_cid),
|
||||
};
|
||||
|
||||
let modified_keys = [key];
|
||||
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
@@ -93,7 +92,6 @@ pub async fn delete_record(
|
||||
})
|
||||
}),
|
||||
ops: vec![op],
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &[],
|
||||
backlinks_to_add: vec![],
|
||||
backlinks_to_remove: vec![deleted_uri],
|
||||
|
||||
@@ -179,6 +179,18 @@ pub async fn create_record(
|
||||
}
|
||||
}
|
||||
|
||||
let key = format!("{}/{}", input.collection, rkey);
|
||||
if mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"Record already exists at {key}"
|
||||
)));
|
||||
}
|
||||
|
||||
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
|
||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
||||
@@ -187,8 +199,6 @@ pub async fn create_record(
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
|
||||
|
||||
let key = format!("{}/{}", input.collection, rkey);
|
||||
mst = mst
|
||||
.add(&key, record_cid)
|
||||
.await
|
||||
@@ -200,20 +210,6 @@ pub async fn create_record(
|
||||
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
|
||||
});
|
||||
|
||||
let modified_keys: Vec<String> = ops
|
||||
.iter()
|
||||
.map(|op| match op {
|
||||
RecordOp::Create {
|
||||
collection, rkey, ..
|
||||
}
|
||||
| RecordOp::Update {
|
||||
collection, rkey, ..
|
||||
}
|
||||
| RecordOp::Delete {
|
||||
collection, rkey, ..
|
||||
} => format!("{}/{}", collection, rkey),
|
||||
})
|
||||
.collect();
|
||||
let blob_cids = extract_blob_cids(&input.record);
|
||||
|
||||
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
|
||||
@@ -235,7 +231,6 @@ pub async fn create_record(
|
||||
})
|
||||
}),
|
||||
ops,
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove: conflict_uris_to_cleanup,
|
||||
@@ -367,7 +362,6 @@ pub async fn put_record(
|
||||
}
|
||||
};
|
||||
|
||||
let modified_keys = [key];
|
||||
let blob_cids = extract_blob_cids(&input.record);
|
||||
let backlinks_to_add = extract_backlinks(&record_uri, &input.record);
|
||||
|
||||
@@ -387,7 +381,6 @@ pub async fn put_record(
|
||||
})
|
||||
}),
|
||||
ops: vec![op],
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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
|
||||
@@ -163,6 +169,7 @@ pub async fn create_session(
|
||||
let email_2fa_enabled = row.email_2fa_enabled;
|
||||
let is_legacy_login = has_totp || email_2fa_enabled;
|
||||
let twofa_ctx = tranquil_pds::auth::legacy_2fa::Legacy2faContext {
|
||||
is_app_password: app_password_name.is_some(),
|
||||
email_2fa_enabled,
|
||||
has_totp,
|
||||
allow_legacy_login: row.allow_legacy_login,
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
@@ -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(¬ification.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)]
|
||||
@@ -299,7 +221,7 @@ impl DiscordSender {
|
||||
"type": 1,
|
||||
"options": [{
|
||||
"name": "handle",
|
||||
"description": "Your PDS handle (e.g. alice.example.com)",
|
||||
"description": "Your PDS handle",
|
||||
"type": 3,
|
||||
"required": false
|
||||
}]
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum BacklinkPath {
|
||||
Subject,
|
||||
SubjectUri,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
"#,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -24,3 +24,4 @@ urlencoding = { workspace = true, optional = true }
|
||||
[dev-dependencies]
|
||||
wiremock = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
@@ -5,46 +5,102 @@ use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const POSITIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const REFRESH_FAILURE_BACKOFF: Duration = Duration::from_secs(60);
|
||||
const MAX_DYNAMIC_SCHEMAS: usize = 1024;
|
||||
|
||||
struct NegativeEntry {
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
struct PositiveEntry {
|
||||
doc: Arc<LexiconDoc>,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
pub(crate) enum CacheEntry {
|
||||
Fresh(Arc<LexiconDoc>),
|
||||
Stale(Arc<LexiconDoc>),
|
||||
}
|
||||
|
||||
impl CacheEntry {
|
||||
#[cfg(test)]
|
||||
fn is_fresh(&self) -> bool {
|
||||
matches!(self, Self::Fresh(_))
|
||||
}
|
||||
}
|
||||
|
||||
struct SchemaStore {
|
||||
schemas: HashMap<String, Arc<LexiconDoc>>,
|
||||
schemas: HashMap<String, PositiveEntry>,
|
||||
insertion_order: VecDeque<String>,
|
||||
}
|
||||
|
||||
pub struct DynamicRegistry {
|
||||
store: RwLock<SchemaStore>,
|
||||
negative_cache: RwLock<HashMap<String, NegativeEntry>>,
|
||||
in_flight: RwLock<HashMap<String, Arc<Notify>>>,
|
||||
network_disabled: AtomicBool,
|
||||
}
|
||||
|
||||
struct InFlightGuard<'a> {
|
||||
registry: &'a DynamicRegistry,
|
||||
nsid: String,
|
||||
}
|
||||
|
||||
impl Drop for InFlightGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
let notify = self.registry.in_flight.write().remove(&self.nsid);
|
||||
if let Some(n) = notify {
|
||||
n.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DynamicRegistry {
|
||||
pub fn new() -> Self {
|
||||
let network_disabled =
|
||||
std::env::var("TRANQUIL_LEXICON_OFFLINE").is_ok_and(|v| v == "1" || v == "true");
|
||||
Self {
|
||||
store: RwLock::new(SchemaStore {
|
||||
schemas: HashMap::new(),
|
||||
insertion_order: VecDeque::new(),
|
||||
}),
|
||||
negative_cache: RwLock::new(HashMap::new()),
|
||||
network_disabled: AtomicBool::new(network_disabled),
|
||||
in_flight: RwLock::new(HashMap::new()),
|
||||
network_disabled: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn from_env() -> Self {
|
||||
let registry = Self::new();
|
||||
let disabled =
|
||||
std::env::var("TRANQUIL_LEXICON_OFFLINE").is_ok_and(|v| v == "1" || v == "true");
|
||||
registry.set_network_disabled(disabled);
|
||||
registry
|
||||
}
|
||||
|
||||
pub fn set_network_disabled(&self, disabled: bool) {
|
||||
self.network_disabled.store(disabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn get(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
|
||||
self.store.read().schemas.get(nsid).cloned()
|
||||
pub fn get_cached(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
|
||||
self.store
|
||||
.read()
|
||||
.schemas
|
||||
.get(nsid)
|
||||
.map(|e| Arc::clone(&e.doc))
|
||||
}
|
||||
|
||||
pub(crate) fn get_entry(&self, nsid: &str) -> Option<CacheEntry> {
|
||||
let now = Instant::now();
|
||||
self.store.read().schemas.get(nsid).map(|e| {
|
||||
if e.expires_at > now {
|
||||
CacheEntry::Fresh(Arc::clone(&e.doc))
|
||||
} else {
|
||||
CacheEntry::Stale(Arc::clone(&e.doc))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_negative_cached(&self, nsid: &str) -> bool {
|
||||
@@ -56,7 +112,7 @@ impl DynamicRegistry {
|
||||
|
||||
fn insert_negative(&self, nsid: &str) {
|
||||
let mut cache = self.negative_cache.write();
|
||||
if cache.len() > MAX_DYNAMIC_SCHEMAS {
|
||||
if cache.len() >= MAX_DYNAMIC_SCHEMAS {
|
||||
let now = Instant::now();
|
||||
cache.retain(|_, entry| entry.expires_at > now);
|
||||
}
|
||||
@@ -87,29 +143,95 @@ impl DynamicRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
if store
|
||||
.schemas
|
||||
.insert(nsid.clone(), Arc::clone(&arc))
|
||||
.is_some()
|
||||
{
|
||||
let entry = PositiveEntry {
|
||||
doc: Arc::clone(&arc),
|
||||
expires_at: Instant::now() + POSITIVE_CACHE_TTL,
|
||||
};
|
||||
if store.schemas.insert(nsid.clone(), entry).is_some() {
|
||||
store.insertion_order.retain(|k| k != &nsid);
|
||||
}
|
||||
store.insertion_order.push_back(nsid.clone());
|
||||
drop(store);
|
||||
|
||||
self.negative_cache.write().remove(&arc.id);
|
||||
|
||||
arc
|
||||
}
|
||||
|
||||
fn bump_expiry(&self, nsid: &str, duration: Duration) {
|
||||
let mut store = self.store.write();
|
||||
if let Some(entry) = store.schemas.get_mut(nsid) {
|
||||
entry.expires_at = Instant::now() + duration;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_and_cache(&self, nsid: &str) -> Result<Arc<LexiconDoc>, ResolveError> {
|
||||
if let Some(doc) = self.get(nsid) {
|
||||
return Ok(doc);
|
||||
self.resolve_and_cache_with(nsid, |n| async move { resolve_lexicon(&n).await })
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_and_cache_with<F, Fut>(
|
||||
&self,
|
||||
nsid: &str,
|
||||
resolver: F,
|
||||
) -> Result<Arc<LexiconDoc>, ResolveError>
|
||||
where
|
||||
F: FnOnce(String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
|
||||
{
|
||||
match self.get_entry(nsid) {
|
||||
Some(CacheEntry::Fresh(doc)) => Ok(doc),
|
||||
Some(CacheEntry::Stale(stale)) => self.refresh_stale(nsid, stale, resolver).await,
|
||||
None => self.resolve_fresh(nsid, resolver).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_stale<F, Fut>(
|
||||
&self,
|
||||
nsid: &str,
|
||||
stale: Arc<LexiconDoc>,
|
||||
resolver: F,
|
||||
) -> Result<Arc<LexiconDoc>, ResolveError>
|
||||
where
|
||||
F: FnOnce(String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
|
||||
{
|
||||
if self.network_disabled.load(Ordering::Relaxed) {
|
||||
return Ok(stale);
|
||||
}
|
||||
|
||||
match self.acquire_leadership(nsid) {
|
||||
Some(_guard) => match resolver(nsid.to_string()).await {
|
||||
Ok(doc) => Ok(self.insert_schema(doc)),
|
||||
Err(e) => {
|
||||
self.bump_expiry(nsid, REFRESH_FAILURE_BACKOFF);
|
||||
tracing::warn!(
|
||||
nsid = nsid,
|
||||
error = %e,
|
||||
"lexicon refresh failed, serving stale cached entry"
|
||||
);
|
||||
Ok(stale)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
self.wait_for_leader(nsid).await;
|
||||
Ok(self.get_cached(nsid).unwrap_or(stale))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_fresh<F, Fut>(
|
||||
&self,
|
||||
nsid: &str,
|
||||
resolver: F,
|
||||
) -> Result<Arc<LexiconDoc>, ResolveError>
|
||||
where
|
||||
F: FnOnce(String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
|
||||
{
|
||||
if self.network_disabled.load(Ordering::Relaxed) {
|
||||
return Err(ResolveError::NetworkDisabled);
|
||||
}
|
||||
|
||||
if self.is_negative_cached(nsid) {
|
||||
return Err(ResolveError::NegativelyCached {
|
||||
nsid: nsid.to_string(),
|
||||
@@ -117,19 +239,73 @@ impl DynamicRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
match resolve_lexicon(nsid).await {
|
||||
Ok(doc) => Ok(self.insert_schema(doc)),
|
||||
Err(e) => {
|
||||
tracing::debug!(nsid = nsid, error = %e, "caching negative resolution result");
|
||||
self.insert_negative(nsid);
|
||||
Err(e)
|
||||
match self.acquire_leadership(nsid) {
|
||||
Some(_guard) => match resolver(nsid.to_string()).await {
|
||||
Ok(doc) => Ok(self.insert_schema(doc)),
|
||||
Err(e) => {
|
||||
self.insert_negative(nsid);
|
||||
tracing::debug!(nsid = nsid, error = %e, "caching negative resolution result");
|
||||
Err(e)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
self.wait_for_leader(nsid).await;
|
||||
match self.get_cached(nsid) {
|
||||
Some(doc) => Ok(doc),
|
||||
None if self.is_negative_cached(nsid) => Err(ResolveError::NegativelyCached {
|
||||
nsid: nsid.to_string(),
|
||||
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
|
||||
}),
|
||||
None => Err(ResolveError::LeaderAborted {
|
||||
nsid: nsid.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_leadership(&self, nsid: &str) -> Option<InFlightGuard<'_>> {
|
||||
let mut map = self.in_flight.write();
|
||||
if map.contains_key(nsid) {
|
||||
None
|
||||
} else {
|
||||
map.insert(nsid.to_string(), Arc::new(Notify::new()));
|
||||
Some(InFlightGuard {
|
||||
registry: self,
|
||||
nsid: nsid.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_leader(&self, nsid: &str) {
|
||||
let notify = {
|
||||
let map = self.in_flight.read();
|
||||
match map.get(nsid) {
|
||||
Some(n) => Arc::clone(n),
|
||||
None => return,
|
||||
}
|
||||
};
|
||||
let notified = notify.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
let still_active = self.in_flight.read().contains_key(nsid);
|
||||
if !still_active {
|
||||
return;
|
||||
}
|
||||
notified.as_mut().await;
|
||||
}
|
||||
|
||||
pub fn schema_count(&self) -> usize {
|
||||
self.store.read().schemas.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn expire_now(&self, nsid: &str) {
|
||||
let mut store = self.store.write();
|
||||
if let Some(entry) = store.schemas.get_mut(nsid) {
|
||||
entry.expires_at = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DynamicRegistry {
|
||||
@@ -171,7 +347,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_empty_lookup() {
|
||||
let registry = DynamicRegistry::new();
|
||||
assert!(registry.get("com.example.nonexistent").is_none());
|
||||
assert!(registry.get_cached("com.example.nonexistent").is_none());
|
||||
assert_eq!(registry.schema_count(), 0);
|
||||
}
|
||||
|
||||
@@ -188,9 +364,12 @@ mod tests {
|
||||
assert_eq!(arc.id, "com.example.test");
|
||||
assert_eq!(registry.schema_count(), 1);
|
||||
|
||||
let retrieved = registry.get("com.example.test");
|
||||
let retrieved = registry.get_cached("com.example.test");
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().id, "com.example.test");
|
||||
|
||||
let entry = registry.get_entry("com.example.test").unwrap();
|
||||
assert!(entry.is_fresh(), "freshly inserted entry must be fresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -210,6 +389,218 @@ mod tests {
|
||||
assert!(!registry.is_negative_cached("com.example.test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_positive_entry_reports_stale_after_ttl() {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "pet.nel.stale".to_string(),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
|
||||
assert!(registry.get_entry("pet.nel.stale").unwrap().is_fresh());
|
||||
|
||||
registry.expire_now("pet.nel.stale");
|
||||
|
||||
assert!(
|
||||
!registry.get_entry("pet.nel.stale").unwrap().is_fresh(),
|
||||
"entry past expiry must be reported stale"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stale_served_on_resolve_failure() {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "pet.nel.flaky".to_string(),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
registry.expire_now("pet.nel.flaky");
|
||||
|
||||
let result = registry
|
||||
.resolve_and_cache_with("pet.nel.flaky", |n| async move {
|
||||
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
|
||||
domain: n,
|
||||
reason: "simulated failure".to_string(),
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
let served = result.expect("stale entry must be served when refresh fails");
|
||||
assert_eq!(served.id, "pet.nel.flaky");
|
||||
assert!(
|
||||
registry.get_entry("pet.nel.flaky").unwrap().is_fresh(),
|
||||
"failed refresh must bump expiry so subsequent lookups skip the resolver"
|
||||
);
|
||||
assert!(
|
||||
!registry.is_negative_cached("pet.nel.flaky"),
|
||||
"stale refresh failure must not poison negative cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fresh_hit_skips_resolver() {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "pet.nel.fresh".to_string(),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
|
||||
let result = registry
|
||||
.resolve_and_cache_with("pet.nel.fresh", |_| async move {
|
||||
panic!("resolver must not run on fresh hit")
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stale_served_when_network_disabled() {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "pet.nel.offline".to_string(),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
registry.expire_now("pet.nel.offline");
|
||||
registry.set_network_disabled(true);
|
||||
|
||||
let result = registry
|
||||
.resolve_and_cache_with("pet.nel.offline", |_| async move {
|
||||
panic!("resolver must not run when network disabled")
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_successful_refresh_updates_cached_at() {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "pet.nel.refresh".to_string(),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
registry.expire_now("pet.nel.refresh");
|
||||
|
||||
assert!(!registry.get_entry("pet.nel.refresh").unwrap().is_fresh());
|
||||
|
||||
let refreshed = registry
|
||||
.resolve_and_cache_with("pet.nel.refresh", |n| async move {
|
||||
Ok(LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: n,
|
||||
defs: HashMap::new(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(refreshed.id, "pet.nel.refresh");
|
||||
assert!(
|
||||
registry.get_entry("pet.nel.refresh").unwrap().is_fresh(),
|
||||
"refresh must restore freshness"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_single_flight_dedups_concurrent_resolves() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
let registry = Arc::new(DynamicRegistry::new());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let tasks: Vec<_> = (0..16)
|
||||
.map(|_| {
|
||||
let registry = Arc::clone(®istry);
|
||||
let calls = Arc::clone(&calls);
|
||||
tokio::spawn(async move {
|
||||
registry
|
||||
.resolve_and_cache_with("pet.nel.herd", |n| {
|
||||
let calls = Arc::clone(&calls);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
Ok(LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: n,
|
||||
defs: HashMap::new(),
|
||||
})
|
||||
}
|
||||
})
|
||||
.await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = futures_collect(tasks).await;
|
||||
results
|
||||
.iter()
|
||||
.for_each(|r| assert!(r.is_ok(), "all single-flight callers must succeed"));
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"single-flight must coalesce concurrent resolves"
|
||||
);
|
||||
assert_eq!(registry.schema_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_single_flight_followers_observe_leader_failure() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
let registry = Arc::new(DynamicRegistry::new());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let tasks: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
let registry = Arc::clone(®istry);
|
||||
let calls = Arc::clone(&calls);
|
||||
tokio::spawn(async move {
|
||||
registry
|
||||
.resolve_and_cache_with("pet.nel.failHerd", |n| {
|
||||
let calls = Arc::clone(&calls);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
|
||||
domain: n,
|
||||
reason: "simulated".to_string(),
|
||||
})
|
||||
}
|
||||
})
|
||||
.await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = futures_collect(tasks).await;
|
||||
results
|
||||
.iter()
|
||||
.for_each(|r| assert!(r.is_err(), "all followers must observe leader failure"));
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"single-flight must coalesce failing resolves too"
|
||||
);
|
||||
assert!(registry.is_negative_cached("pet.nel.failHerd"));
|
||||
}
|
||||
|
||||
async fn futures_collect<T>(handles: Vec<tokio::task::JoinHandle<T>>) -> Vec<T> {
|
||||
futures::future::join_all(handles)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|r| r.expect("task panicked"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eviction_is_fifo() {
|
||||
let registry = DynamicRegistry::new();
|
||||
@@ -217,7 +608,7 @@ mod tests {
|
||||
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: format!("com.example.schema{}", i),
|
||||
id: format!("pet.nel.schema{}", i),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
@@ -226,23 +617,23 @@ mod tests {
|
||||
|
||||
let trigger = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.trigger".to_string(),
|
||||
id: "pet.nel.trigger".to_string(),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(trigger);
|
||||
|
||||
assert!(
|
||||
registry.get("com.example.schema0").is_none(),
|
||||
registry.get_cached("pet.nel.schema0").is_none(),
|
||||
"oldest entry should be evicted"
|
||||
);
|
||||
assert!(
|
||||
registry.get("com.example.trigger").is_some(),
|
||||
registry.get_cached("pet.nel.trigger").is_some(),
|
||||
"newly inserted entry should exist"
|
||||
);
|
||||
let evict_count = MAX_DYNAMIC_SCHEMAS / 4;
|
||||
assert!(
|
||||
registry
|
||||
.get(&format!("com.example.schema{}", evict_count))
|
||||
.get_cached(&format!("pet.nel.schema{}", evict_count))
|
||||
.is_some(),
|
||||
"entry after eviction window should survive"
|
||||
);
|
||||
@@ -253,7 +644,7 @@ mod tests {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.tracked".to_string(),
|
||||
id: "pet.nel.tracked".to_string(),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
let arc = registry.insert_schema(doc);
|
||||
@@ -265,7 +656,7 @@ mod tests {
|
||||
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
|
||||
registry.insert_schema(LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: format!("com.example.filler{}", i),
|
||||
id: format!("pet.nel.filler{}", i),
|
||||
defs: HashMap::new(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ impl LexiconRegistry {
|
||||
Self {
|
||||
schemas: HashMap::new(),
|
||||
#[cfg(feature = "resolve")]
|
||||
dynamic: crate::dynamic::DynamicRegistry::new(),
|
||||
dynamic: crate::dynamic::DynamicRegistry::from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ impl LexiconRegistry {
|
||||
self.schemas.get(nsid).cloned().or_else(|| {
|
||||
#[cfg(feature = "resolve")]
|
||||
{
|
||||
self.dynamic.get(nsid)
|
||||
self.dynamic.get_cached(nsid)
|
||||
}
|
||||
#[cfg(not(feature = "resolve"))]
|
||||
{
|
||||
|
||||
@@ -70,6 +70,8 @@ pub enum ResolveError {
|
||||
NegativelyCached { nsid: String, ttl_secs: u64 },
|
||||
#[error("network resolution disabled")]
|
||||
NetworkDisabled,
|
||||
#[error("leader task for {nsid} aborted before completion")]
|
||||
LeaderAborted { nsid: String },
|
||||
}
|
||||
|
||||
pub fn nsid_to_authority(nsid: &str) -> Result<String, ResolveError> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -175,10 +175,7 @@ pub async fn passkey_start(
|
||||
}
|
||||
}
|
||||
|
||||
async fn passkey_start_discoverable(
|
||||
state: AppState,
|
||||
request_id: RequestId,
|
||||
) -> Response {
|
||||
async fn passkey_start_discoverable(state: AppState, request_id: RequestId) -> Response {
|
||||
let (rcr, auth_state) = match state.webauthn_config.start_discoverable_authentication() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
@@ -292,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,
|
||||
@@ -570,16 +565,13 @@ pub async fn passkey_finish(
|
||||
Err(response) => return response,
|
||||
},
|
||||
None => {
|
||||
let result = match passkey_finish_discoverable(
|
||||
&state,
|
||||
&credential,
|
||||
&passkey_finish_request_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let result =
|
||||
match passkey_finish_discoverable(&state, &credential, &passkey_finish_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -138,21 +138,29 @@ pub async fn pushed_authorization_request(
|
||||
}
|
||||
|
||||
fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError> {
|
||||
if let (Some(assertion), Some(assertion_type)) =
|
||||
(&request.client_assertion, &request.client_assertion_type)
|
||||
{
|
||||
let assertion = request
|
||||
.client_assertion
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty());
|
||||
let assertion_type = request
|
||||
.client_assertion_type
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty());
|
||||
let secret = request.client_secret.as_deref().filter(|s| !s.is_empty());
|
||||
|
||||
if let (Some(assertion), Some(assertion_type)) = (assertion, assertion_type) {
|
||||
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
|
||||
return Err(OAuthError::InvalidRequest(
|
||||
"Unsupported client_assertion_type".to_string(),
|
||||
));
|
||||
}
|
||||
return Ok(ClientAuth::PrivateKeyJwt {
|
||||
client_assertion: assertion.clone(),
|
||||
client_assertion: assertion.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(secret) = &request.client_secret {
|
||||
if let Some(secret) = secret {
|
||||
return Ok(ClientAuth::SecretPost {
|
||||
client_secret: secret.clone(),
|
||||
client_secret: secret.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(ClientAuth::None)
|
||||
|
||||
@@ -138,13 +138,17 @@ impl TokenRequest {
|
||||
}
|
||||
};
|
||||
|
||||
let client_auth = match (self.client_assertion, self.client_assertion_type) {
|
||||
let assertion = self.client_assertion.filter(|s| !s.is_empty());
|
||||
let assertion_type = self.client_assertion_type.filter(|s| !s.is_empty());
|
||||
let client_secret = self.client_secret.filter(|s| !s.is_empty());
|
||||
|
||||
let client_auth = match (assertion, assertion_type) {
|
||||
(Some(assertion), Some(assertion_type)) => RequestClientAuth::PrivateKeyJwt {
|
||||
client_id: self.client_id,
|
||||
assertion,
|
||||
assertion_type,
|
||||
},
|
||||
_ => match self.client_secret {
|
||||
_ => match client_secret {
|
||||
Some(secret) => RequestClientAuth::SecretPost {
|
||||
client_id: self.client_id,
|
||||
client_secret: secret,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -23,3 +23,6 @@ sha2 = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[features]
|
||||
native-tls-roots = []
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,8 +287,6 @@ pub fn is_valid_discord_username(username: &str) -> bool {
|
||||
&& username
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.')
|
||||
&& !username.starts_with('.')
|
||||
&& !username.ends_with('.')
|
||||
&& !username.contains("..")
|
||||
}
|
||||
|
||||
@@ -493,8 +491,6 @@ mod tests {
|
||||
assert!(!is_valid_discord_username("Alice"));
|
||||
assert!(!is_valid_discord_username("ALICE"));
|
||||
assert!(!is_valid_discord_username("user-name"));
|
||||
assert!(!is_valid_discord_username(".username"));
|
||||
assert!(!is_valid_discord_username("username."));
|
||||
assert!(!is_valid_discord_username("user..name"));
|
||||
assert!(!is_valid_discord_username("user name"));
|
||||
assert!(!is_valid_discord_username(
|
||||
|
||||
@@ -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,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ pub enum Legacy2faOutcome {
|
||||
}
|
||||
|
||||
pub struct Legacy2faContext {
|
||||
pub is_app_password: bool,
|
||||
pub email_2fa_enabled: bool,
|
||||
pub has_totp: bool,
|
||||
pub allow_legacy_login: bool,
|
||||
@@ -163,7 +164,7 @@ pub struct Legacy2faContext {
|
||||
|
||||
impl Legacy2faContext {
|
||||
pub fn requires_2fa(&self) -> bool {
|
||||
self.email_2fa_enabled || self.has_totp
|
||||
!self.is_app_password && (self.email_2fa_enabled || self.has_totp)
|
||||
}
|
||||
|
||||
pub fn is_blocked(&self) -> bool {
|
||||
@@ -418,6 +419,7 @@ mod tests {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: false,
|
||||
has_totp: false,
|
||||
allow_legacy_login: true,
|
||||
@@ -427,11 +429,27 @@ mod tests {
|
||||
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_process_flow_not_required_because_app_password() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: true,
|
||||
email_2fa_enabled: false,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_process_flow_blocked() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: false,
|
||||
has_totp: true,
|
||||
allow_legacy_login: false,
|
||||
@@ -446,6 +464,7 @@ mod tests {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: false,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
@@ -460,6 +479,7 @@ mod tests {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test2".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: true,
|
||||
has_totp: false,
|
||||
allow_legacy_login: false,
|
||||
@@ -474,6 +494,7 @@ mod tests {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: true,
|
||||
has_totp: false,
|
||||
allow_legacy_login: false,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -6,17 +6,19 @@ use crate::types::{Did, Handle, Nsid, Rkey};
|
||||
use backon::{ExponentialBuilder, Retryable};
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use jacquard_common::smol_str::SmolStr;
|
||||
use jacquard_common::types::{integer::LimitedU32, string::Tid};
|
||||
use jacquard_repo::commit::Commit;
|
||||
use jacquard_repo::mst::Mst;
|
||||
use jacquard_repo::mst::util::compute_cid;
|
||||
use jacquard_repo::mst::{Mst, VerifiedWriteOp};
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use k256::ecdsa::SigningKey;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::OwnedMutexGuard;
|
||||
use tracing::error;
|
||||
use tracing::{error, warn};
|
||||
use tranquil_db_traits::SequenceNumber;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -38,6 +40,7 @@ pub enum CommitError {
|
||||
MstOperationFailed(String),
|
||||
RecordSerializationFailed(String),
|
||||
InvalidCid(String),
|
||||
RecordAlreadyExists(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CommitError {
|
||||
@@ -63,6 +66,7 @@ impl std::fmt::Display for CommitError {
|
||||
write!(f, "Failed to serialize record: {}", e)
|
||||
}
|
||||
Self::InvalidCid(e) => write!(f, "Invalid CID: {}", e),
|
||||
Self::RecordAlreadyExists(key) => write!(f, "Record already exists at {}", key),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +81,9 @@ impl From<CommitError> for ApiError {
|
||||
}
|
||||
CommitError::RepoNotFound => ApiError::RepoNotFound(None),
|
||||
CommitError::UserNotFound => ApiError::RepoNotFound(Some("User not found".into())),
|
||||
CommitError::RecordAlreadyExists(key) => {
|
||||
ApiError::InvalidRequest(format!("Record already exists at {key}"))
|
||||
}
|
||||
other => {
|
||||
error!("Commit failed: {}", other);
|
||||
ApiError::InternalError(Some("Failed to commit changes".into()))
|
||||
@@ -160,7 +167,6 @@ pub struct FinalizeParams<'a> {
|
||||
pub controller_did: Option<&'a Did>,
|
||||
pub delegation_detail: Option<serde_json::Value>,
|
||||
pub ops: Vec<RecordOp>,
|
||||
pub modified_keys: &'a [String],
|
||||
pub blob_cids: &'a [String],
|
||||
pub backlinks_to_add: Vec<Backlink>,
|
||||
pub backlinks_to_remove: Vec<AtUri>,
|
||||
@@ -236,19 +242,84 @@ pub async fn finalize_repo_write(
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let block_bytes = ctx.tracking_store.take_written_blocks();
|
||||
let written_bytes = ctx.tracking_store.take_written_blocks();
|
||||
let new_tree_cids: Vec<Cid> = written_bytes.keys().copied().collect();
|
||||
|
||||
let storage_for_diff = Arc::new(ctx.tracking_store.clone());
|
||||
let original_settled = Mst::load(storage_for_diff.clone(), ctx.prev_data_cid, None);
|
||||
let new_settled = Mst::load(storage_for_diff, new_mst_root, None);
|
||||
let storage_for_proof = Arc::new(ctx.tracking_store.clone());
|
||||
let original_settled = Mst::load(storage_for_proof.clone(), ctx.prev_data_cid, None);
|
||||
let new_settled = Mst::load(storage_for_proof.clone(), new_mst_root, None);
|
||||
|
||||
let new_tree_cids: Vec<Cid> = block_bytes.keys().copied().collect();
|
||||
let mut inverse_trace = new_settled.clone();
|
||||
let mut non_invertible: Vec<String> = Vec::new();
|
||||
let mut invert_errors: Vec<String> = Vec::new();
|
||||
for op in params.ops.iter().rev() {
|
||||
let (collection, rkey) = op.collection_rkey();
|
||||
let key = SmolStr::new(format!("{}/{}", collection, rkey));
|
||||
let verified = match op {
|
||||
RecordOp::Create { cid, .. } => VerifiedWriteOp::Create {
|
||||
key,
|
||||
cid: *cid.as_cid(),
|
||||
},
|
||||
RecordOp::Update { cid, prev, .. } => VerifiedWriteOp::Update {
|
||||
key,
|
||||
cid: *cid.as_cid(),
|
||||
prev: *prev.as_cid(),
|
||||
},
|
||||
RecordOp::Delete { prev, .. } => VerifiedWriteOp::Delete {
|
||||
key,
|
||||
prev: *prev.as_cid(),
|
||||
},
|
||||
};
|
||||
match inverse_trace.invert_op(verified.clone()).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => non_invertible.push(format!("{:?}", verified)),
|
||||
Err(e) => invert_errors.push(format!("{:?} -> {:?}", verified, e)),
|
||||
}
|
||||
}
|
||||
if !non_invertible.is_empty() {
|
||||
warn!(
|
||||
user_id = %params.user_id,
|
||||
count = non_invertible.len(),
|
||||
ops = ?non_invertible,
|
||||
"firehose proof walk: ops not invertible on new MST, consumer will reject frame"
|
||||
);
|
||||
}
|
||||
if !invert_errors.is_empty() {
|
||||
warn!(
|
||||
user_id = %params.user_id,
|
||||
count = invert_errors.len(),
|
||||
failures = ?invert_errors,
|
||||
"firehose proof walk: invert_op errored, cover blocks may be incomplete"
|
||||
);
|
||||
}
|
||||
|
||||
let read_cid_set: HashSet<Cid> = ctx.tracking_store.get_read_cids().into_iter().collect();
|
||||
let missing_read_cids: Vec<Cid> = read_cid_set
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|cid| !written_bytes.contains_key(cid))
|
||||
.collect();
|
||||
let mut relevant: BTreeMap<Cid, Bytes> = BTreeMap::new();
|
||||
if !missing_read_cids.is_empty() {
|
||||
let fetched = ctx
|
||||
.tracking_store
|
||||
.get_many(&missing_read_cids)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("fetch cover read bytes: {e}");
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
for (cid, maybe) in missing_read_cids.into_iter().zip(fetched) {
|
||||
if let Some(bytes) = maybe {
|
||||
relevant.insert(cid, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let obsolete_cids = match original_settled.diff(&new_settled).await {
|
||||
Ok(diff) => {
|
||||
let mut obsolete: Vec<Cid> = Vec::with_capacity(
|
||||
1 + diff.removed_mst_blocks.len() + diff.removed_cids.len(),
|
||||
);
|
||||
let mut obsolete: Vec<Cid> =
|
||||
Vec::with_capacity(1 + diff.removed_mst_blocks.len() + diff.removed_cids.len());
|
||||
obsolete.push(ctx.current_root_cid);
|
||||
obsolete.extend(diff.removed_mst_blocks);
|
||||
obsolete.extend(diff.removed_cids);
|
||||
@@ -264,6 +335,9 @@ pub async fn finalize_repo_write(
|
||||
}
|
||||
};
|
||||
|
||||
let mut block_bytes = written_bytes;
|
||||
block_bytes.extend(relevant);
|
||||
|
||||
let result = commit_and_log(
|
||||
state,
|
||||
CommitParams {
|
||||
@@ -347,6 +421,22 @@ pub enum RecordOp {
|
||||
},
|
||||
}
|
||||
|
||||
impl RecordOp {
|
||||
pub fn collection_rkey(&self) -> (&Nsid, &Rkey) {
|
||||
match self {
|
||||
Self::Create {
|
||||
collection, rkey, ..
|
||||
}
|
||||
| Self::Update {
|
||||
collection, rkey, ..
|
||||
}
|
||||
| Self::Delete {
|
||||
collection, rkey, ..
|
||||
} => (collection, rkey),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CommitResult {
|
||||
pub commit_cid: Cid,
|
||||
pub rev: String,
|
||||
@@ -377,8 +467,6 @@ pub async fn commit_and_log(
|
||||
RecordUpsert, RepoEventType,
|
||||
};
|
||||
|
||||
let backlinks_to_add = params.backlinks_to_add;
|
||||
let backlinks_to_remove = params.backlinks_to_remove;
|
||||
let CommitParams {
|
||||
did,
|
||||
user_id,
|
||||
@@ -391,7 +479,8 @@ pub async fn commit_and_log(
|
||||
new_tree_cids,
|
||||
blobs,
|
||||
obsolete_cids,
|
||||
..
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
} = params;
|
||||
debug_assert_eq!(
|
||||
current_root_cid.is_some(),
|
||||
@@ -437,39 +526,63 @@ pub async fn commit_and_log(
|
||||
|
||||
let obsolete_bytes: Vec<Vec<u8>> = obsolete_cids.iter().map(|c| c.to_bytes()).collect();
|
||||
|
||||
let (record_upserts, record_deletes): (Vec<RecordUpsert>, Vec<RecordDelete>) = ops.iter().fold(
|
||||
(Vec::new(), Vec::new()),
|
||||
|(mut upserts, mut deletes), op| {
|
||||
match op {
|
||||
RecordOp::Create {
|
||||
collection,
|
||||
rkey,
|
||||
cid,
|
||||
}
|
||||
| RecordOp::Update {
|
||||
collection,
|
||||
rkey,
|
||||
cid,
|
||||
..
|
||||
} => {
|
||||
upserts.push(RecordUpsert {
|
||||
collection: collection.clone(),
|
||||
rkey: rkey.clone(),
|
||||
cid: crate::types::CidLink::from(cid.as_cid()),
|
||||
});
|
||||
}
|
||||
RecordOp::Delete {
|
||||
collection, rkey, ..
|
||||
} => {
|
||||
deletes.push(RecordDelete {
|
||||
collection: collection.clone(),
|
||||
rkey: rkey.clone(),
|
||||
});
|
||||
}
|
||||
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()
|
||||
.filter(|(_, op)| !matches!(op, RecordOp::Delete { .. }))
|
||||
.map(|((c, r), _)| AtUri::from_parts(did, c, r))
|
||||
.collect();
|
||||
|
||||
let record_upserts: Vec<RecordUpsert> = final_ops
|
||||
.values()
|
||||
.filter_map(|op| match op {
|
||||
RecordOp::Create {
|
||||
collection,
|
||||
rkey,
|
||||
cid,
|
||||
}
|
||||
(upserts, deletes)
|
||||
},
|
||||
);
|
||||
| RecordOp::Update {
|
||||
collection,
|
||||
rkey,
|
||||
cid,
|
||||
..
|
||||
} => Some(RecordUpsert {
|
||||
collection: collection.clone(),
|
||||
rkey: rkey.clone(),
|
||||
cid: crate::types::CidLink::from(cid.as_cid()),
|
||||
}),
|
||||
RecordOp::Delete { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let record_deletes: Vec<RecordDelete> = final_ops
|
||||
.values()
|
||||
.filter_map(|op| match op {
|
||||
RecordOp::Delete {
|
||||
collection, rkey, ..
|
||||
} => Some(RecordDelete {
|
||||
collection: collection.clone(),
|
||||
rkey: rkey.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let backlinks_to_add: Vec<Backlink> = backlinks_to_add
|
||||
.into_iter()
|
||||
.filter(|b| final_record_uris.contains(&b.uri))
|
||||
.map(|b| ((b.uri.clone(), b.path), b))
|
||||
.collect::<HashMap<_, _>>()
|
||||
.into_values()
|
||||
.collect();
|
||||
|
||||
let backlinks_to_remove: Vec<AtUri> = backlinks_to_remove
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let ops_json: Vec<serde_json::Value> = ops
|
||||
.iter()
|
||||
@@ -604,6 +717,16 @@ pub async fn create_record_internal(
|
||||
.await
|
||||
.map_err(to_commit_err)?;
|
||||
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
if mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| CommitError::MstOperationFailed(e.to_string()))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(CommitError::RecordAlreadyExists(key));
|
||||
}
|
||||
|
||||
let record_ipld = crate::util::json_to_ipld(record);
|
||||
let mut record_bytes = Vec::new();
|
||||
serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld)
|
||||
@@ -613,8 +736,6 @@ pub async fn create_record_internal(
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|e| CommitError::BlockStoreFailed(e.to_string()))?;
|
||||
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
let new_mst = mst
|
||||
.add(&key, record_cid)
|
||||
.await
|
||||
@@ -625,7 +746,6 @@ pub async fn create_record_internal(
|
||||
rkey: rkey.clone(),
|
||||
cid: RecordCid::from(record_cid),
|
||||
};
|
||||
let modified_keys = [key];
|
||||
let blob_cids = extract_blob_cids(record);
|
||||
let record_uri = AtUri::from_parts(did.as_str(), collection.as_str(), rkey.as_str());
|
||||
let backlinks = extract_backlinks(&record_uri, record);
|
||||
@@ -640,7 +760,6 @@ pub async fn create_record_internal(
|
||||
controller_did: None,
|
||||
delegation_detail: None,
|
||||
ops: vec![op],
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &blob_cids,
|
||||
backlinks_to_add: backlinks,
|
||||
backlinks_to_remove: vec![],
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -191,16 +191,12 @@ async fn setup_with_external_infra() -> String {
|
||||
async fn setup_with_testcontainers() -> String {
|
||||
let temp_dir = std::env::temp_dir().join(format!("tranquil-pds-test-{}", uuid::Uuid::new_v4()));
|
||||
let blob_path = temp_dir.join("blobs");
|
||||
let backup_path = temp_dir.join("backups");
|
||||
std::fs::create_dir_all(&blob_path).expect("Failed to create blob temp directory");
|
||||
std::fs::create_dir_all(&backup_path).expect("Failed to create backup temp directory");
|
||||
TEST_TEMP_DIR.set(temp_dir).ok();
|
||||
let plc_url = setup_mock_plc_directory().await;
|
||||
unsafe {
|
||||
std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem");
|
||||
std::env::set_var("BLOB_STORAGE_PATH", blob_path.to_str().unwrap());
|
||||
std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem");
|
||||
std::env::set_var("BACKUP_STORAGE_PATH", backup_path.to_str().unwrap());
|
||||
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
|
||||
std::env::set_var("PLC_DIRECTORY_URL", &plc_url);
|
||||
@@ -242,8 +238,6 @@ async fn setup_with_testcontainers() -> String {
|
||||
let plc_url = setup_mock_plc_directory().await;
|
||||
unsafe {
|
||||
std::env::set_var("BLOB_STORAGE_BACKEND", "s3");
|
||||
std::env::set_var("BACKUP_STORAGE_BACKEND", "s3");
|
||||
std::env::set_var("BACKUP_S3_BUCKET", "test-backups");
|
||||
std::env::set_var("S3_BUCKET", "test-bucket");
|
||||
std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin");
|
||||
std::env::set_var("AWS_SECRET_ACCESS_KEY", "minioadmin");
|
||||
@@ -333,8 +327,6 @@ unsafe fn configure_external_storage_env() {
|
||||
if std::env::var("S3_ENDPOINT").is_ok() {
|
||||
let s3_endpoint = std::env::var("S3_ENDPOINT").unwrap();
|
||||
std::env::set_var("BLOB_STORAGE_BACKEND", "s3");
|
||||
std::env::set_var("BACKUP_STORAGE_BACKEND", "s3");
|
||||
std::env::set_var("BACKUP_S3_BUCKET", "test-backups");
|
||||
std::env::set_var(
|
||||
"S3_BUCKET",
|
||||
std::env::var("S3_BUCKET").unwrap_or_else(|_| "test-bucket".to_string()),
|
||||
@@ -356,14 +348,10 @@ unsafe fn configure_external_storage_env() {
|
||||
let process_dir =
|
||||
std::env::temp_dir().join(format!("tranquil-pds-test-{}", std::process::id()));
|
||||
let blob_path = process_dir.join("blobs");
|
||||
let backup_path = process_dir.join("backups");
|
||||
std::fs::create_dir_all(&blob_path).expect("Failed to create blob directory");
|
||||
std::fs::create_dir_all(&backup_path).expect("Failed to create backup directory");
|
||||
TEST_TEMP_DIR.set(process_dir).ok();
|
||||
std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem");
|
||||
std::env::set_var("BLOB_STORAGE_PATH", blob_path.to_str().unwrap());
|
||||
std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem");
|
||||
std::env::set_var("BACKUP_STORAGE_PATH", backup_path.to_str().unwrap());
|
||||
}
|
||||
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
|
||||
@@ -622,18 +610,14 @@ async fn setup_store_backend() -> String {
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("tranquil-pds-store-{}", uuid::Uuid::new_v4()));
|
||||
let blob_path = temp_dir.join("blobs");
|
||||
let backup_path = temp_dir.join("backups");
|
||||
let store_path = temp_dir.join("store");
|
||||
std::fs::create_dir_all(&blob_path).expect("failed to create blob temp directory");
|
||||
std::fs::create_dir_all(&backup_path).expect("failed to create backup temp directory");
|
||||
std::fs::create_dir_all(&store_path).expect("failed to create store temp directory");
|
||||
TEST_TEMP_DIR.set(temp_dir).ok();
|
||||
let plc_url = setup_mock_plc_directory().await;
|
||||
unsafe {
|
||||
std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem");
|
||||
std::env::set_var("BLOB_STORAGE_PATH", blob_path.to_str().unwrap());
|
||||
std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem");
|
||||
std::env::set_var("BACKUP_STORAGE_PATH", backup_path.to_str().unwrap());
|
||||
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
|
||||
std::env::set_var("PLC_DIRECTORY_URL", &plc_url);
|
||||
@@ -790,18 +774,14 @@ async fn setup_cluster_store_backend() -> Option<sqlx::PgPool> {
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let blob_path = temp_dir.join("blobs");
|
||||
let backup_path = temp_dir.join("backups");
|
||||
let store_path = temp_dir.join("store");
|
||||
std::fs::create_dir_all(&blob_path).expect("failed to create blob temp directory");
|
||||
std::fs::create_dir_all(&backup_path).expect("failed to create backup temp directory");
|
||||
std::fs::create_dir_all(&store_path).expect("failed to create store temp directory");
|
||||
TEST_TEMP_DIR.set(temp_dir).ok();
|
||||
let plc_url = setup_mock_plc_directory().await;
|
||||
unsafe {
|
||||
std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem");
|
||||
std::env::set_var("BLOB_STORAGE_PATH", blob_path.to_str().unwrap());
|
||||
std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem");
|
||||
std::env::set_var("BACKUP_STORAGE_PATH", backup_path.to_str().unwrap());
|
||||
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
|
||||
std::env::set_var("PLC_DIRECTORY_URL", &plc_url);
|
||||
@@ -847,16 +827,12 @@ async fn setup_cluster_testcontainers() -> Option<sqlx::PgPool> {
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("tranquil-pds-cluster-{}", uuid::Uuid::new_v4()));
|
||||
let blob_path = temp_dir.join("blobs");
|
||||
let backup_path = temp_dir.join("backups");
|
||||
std::fs::create_dir_all(&blob_path).expect("Failed to create blob temp directory");
|
||||
std::fs::create_dir_all(&backup_path).expect("Failed to create backup temp directory");
|
||||
TEST_TEMP_DIR.set(temp_dir).ok();
|
||||
let plc_url = setup_mock_plc_directory().await;
|
||||
unsafe {
|
||||
std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem");
|
||||
std::env::set_var("BLOB_STORAGE_PATH", blob_path.to_str().unwrap());
|
||||
std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem");
|
||||
std::env::set_var("BACKUP_STORAGE_PATH", backup_path.to_str().unwrap());
|
||||
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
|
||||
std::env::set_var("PLC_DIRECTORY_URL", &plc_url);
|
||||
|
||||
@@ -13,12 +13,10 @@ fn run_compaction(store: &tranquil_store::blockstore::TranquilBlockStore) {
|
||||
.map(|(&fid, _)| fid)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.for_each(|fid| {
|
||||
match store.compact_file(fid, 0) {
|
||||
Ok(_) => {}
|
||||
Err(tranquil_store::blockstore::CompactionError::ActiveFileCannotBeCompacted) => {}
|
||||
Err(e) => eprintln!("compaction: {e}"),
|
||||
}
|
||||
.for_each(|fid| match store.compact_file(fid, 0) {
|
||||
Ok(_) => {}
|
||||
Err(tranquil_store::blockstore::CompactionError::ActiveFileCannotBeCompacted) => {}
|
||||
Err(e) => eprintln!("compaction: {e}"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,10 +82,7 @@ async fn mst_blocks_survive_full_store_reopen() {
|
||||
}
|
||||
|
||||
let data_dir = store.data_dir().to_path_buf();
|
||||
let index_dir = data_dir
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("index");
|
||||
let index_dir = data_dir.parent().unwrap().join("index");
|
||||
|
||||
let store_clone = store.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -107,17 +102,16 @@ async fn mst_blocks_survive_full_store_reopen() {
|
||||
|
||||
let head_cid = cid::Cid::try_from(repo_root_str.as_str()).expect("invalid cid");
|
||||
|
||||
let car_blocks =
|
||||
tranquil_pds::scheduled::collect_current_repo_blocks(block_store, &head_cid)
|
||||
.await
|
||||
.expect("collect blocks");
|
||||
let car_blocks = tranquil_pds::scheduled::collect_current_repo_blocks(block_store, &head_cid)
|
||||
.await
|
||||
.expect("collect blocks");
|
||||
|
||||
let block_count_before = car_blocks.len();
|
||||
|
||||
let max_file_size = store
|
||||
.list_data_files()
|
||||
.ok()
|
||||
.and_then(|_| Some(4 * 1024 * 1024u64))
|
||||
.map(|_| 4 * 1024 * 1024u64)
|
||||
.unwrap_or(4 * 1024 * 1024);
|
||||
|
||||
let reopened_missing = tokio::task::spawn_blocking(move || {
|
||||
@@ -131,8 +125,8 @@ async fn mst_blocks_survive_full_store_reopen() {
|
||||
group_commit: tranquil_store::blockstore::GroupCommitConfig::default(),
|
||||
shard_count: 1,
|
||||
};
|
||||
let fresh = tranquil_store::blockstore::TranquilBlockStore::open(config)
|
||||
.expect("reopen failed");
|
||||
let fresh =
|
||||
tranquil_store::blockstore::TranquilBlockStore::open(config).expect("reopen failed");
|
||||
|
||||
let missing: Vec<String> = car_blocks
|
||||
.iter()
|
||||
|
||||
@@ -8,7 +8,6 @@ const HANDLE_DOMAIN: &str = "handles.test";
|
||||
|
||||
fn set_handle_domain() {
|
||||
unsafe {
|
||||
std::env::set_var("AVAILABLE_USER_DOMAINS", HANDLE_DOMAIN);
|
||||
std::env::set_var("PDS_USER_HANDLE_DOMAINS", HANDLE_DOMAIN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +456,7 @@ async fn test_apply_writes_batch() {
|
||||
"writes": [
|
||||
{ "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.feed.post", "rkey": "batch-post-1", "value": { "$type": "app.bsky.feed.post", "text": "First batch post", "createdAt": now } },
|
||||
{ "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.feed.post", "rkey": "batch-post-2", "value": { "$type": "app.bsky.feed.post", "text": "Second batch post", "createdAt": now } },
|
||||
{ "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.actor.profile", "rkey": "self", "value": { "$type": "app.bsky.actor.profile", "displayName": "Batch User" } }
|
||||
{ "$type": "com.atproto.repo.applyWrites#update", "collection": "app.bsky.actor.profile", "rkey": "self", "value": { "$type": "app.bsky.actor.profile", "displayName": "Batch User" } }
|
||||
]
|
||||
});
|
||||
let apply_res = client
|
||||
|
||||
@@ -626,7 +626,12 @@ async fn create_app_password_session(
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to login with app password");
|
||||
assert_eq!(login_res.status(), StatusCode::OK, "App password login for '{}' failed", name);
|
||||
assert_eq!(
|
||||
login_res.status(),
|
||||
StatusCode::OK,
|
||||
"App password login for '{}' failed",
|
||||
name
|
||||
);
|
||||
let session: Value = login_res.json().await.unwrap();
|
||||
let jwt = session["accessJwt"].as_str().unwrap().to_string();
|
||||
(jwt, scopes_response)
|
||||
@@ -635,10 +640,7 @@ async fn create_app_password_session(
|
||||
async fn try_chat_service_auth(client: &reqwest::Client, jwt: &str) -> StatusCode {
|
||||
let base = base_url().await;
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.server.getServiceAuth",
|
||||
base
|
||||
))
|
||||
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
|
||||
.bearer_auth(jwt)
|
||||
.query(&[
|
||||
("aud", "did:web:api.bsky.app"),
|
||||
|
||||
@@ -15,7 +15,9 @@ fn test_cid(n: u32) -> Cid {
|
||||
Cid::new_v1(0x71, mh)
|
||||
}
|
||||
|
||||
async fn compute_obsolete_full_walk<S: jacquard_repo::storage::BlockStore + Sync + Send + 'static>(
|
||||
async fn compute_obsolete_full_walk<
|
||||
S: jacquard_repo::storage::BlockStore + Sync + Send + 'static,
|
||||
>(
|
||||
old: &Mst<S>,
|
||||
new: &Mst<S>,
|
||||
) -> BTreeSet<Cid> {
|
||||
@@ -34,9 +36,7 @@ async fn compute_obsolete_full_walk<S: jacquard_repo::storage::BlockStore + Sync
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn compute_obsolete_from_diff(
|
||||
diff: &jacquard_repo::mst::diff::MstDiff,
|
||||
) -> BTreeSet<Cid> {
|
||||
fn compute_obsolete_from_diff(diff: &jacquard_repo::mst::diff::MstDiff) -> BTreeSet<Cid> {
|
||||
diff.removed_mst_blocks
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -74,12 +74,17 @@ async fn assert_equivalence(
|
||||
let diff_obsolete = compute_obsolete_from_diff(&diff);
|
||||
|
||||
assert_eq!(
|
||||
full_walk_obsolete, diff_obsolete,
|
||||
full_walk_obsolete,
|
||||
diff_obsolete,
|
||||
"MISMATCH in scenario: {scenario}\n full_walk count: {}\n diff count: {}\n in full_walk but not diff: {:?}\n in diff but not full_walk: {:?}",
|
||||
full_walk_obsolete.len(),
|
||||
diff_obsolete.len(),
|
||||
full_walk_obsolete.difference(&diff_obsolete).collect::<Vec<_>>(),
|
||||
diff_obsolete.difference(&full_walk_obsolete).collect::<Vec<_>>(),
|
||||
full_walk_obsolete
|
||||
.difference(&diff_obsolete)
|
||||
.collect::<Vec<_>>(),
|
||||
diff_obsolete
|
||||
.difference(&full_walk_obsolete)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -256,7 +261,12 @@ async fn massive_to_empty() {
|
||||
async fn massive_complete_replacement() {
|
||||
let old = generate_records("app.bsky.feed.post", 0..1000);
|
||||
let new_rec = generate_records("app.bsky.feed.post", 1000..2000);
|
||||
assert_equivalence(&old, &new_rec, "1000 records fully replaced with 1000 different").await;
|
||||
assert_equivalence(
|
||||
&old,
|
||||
&new_rec,
|
||||
"1000 records fully replaced with 1000 different",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -276,7 +286,12 @@ async fn multi_collection_5_collections_500_each() {
|
||||
];
|
||||
let old = generate_multi_collection_records(&collections, 500);
|
||||
let new_rec = apply_scattered_updates(&old, 4, 30000);
|
||||
assert_equivalence(&old, &new_rec, "5 collections x 500 records - update every 4th").await;
|
||||
assert_equivalence(
|
||||
&old,
|
||||
&new_rec,
|
||||
"5 collections x 500 records - update every 4th",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -294,7 +309,12 @@ async fn multi_collection_wipe_one_collection() {
|
||||
.filter(|(key, _)| !key.starts_with("app.bsky.feed.repost"))
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_equivalence(&old, &new_rec, "4 collections x 400 - wipe repost collection").await;
|
||||
assert_equivalence(
|
||||
&old,
|
||||
&new_rec,
|
||||
"4 collections x 400 - wipe repost collection",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -313,10 +333,7 @@ async fn multi_collection_keep_only_one() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_collection_add_new_collection() {
|
||||
let old_collections = [
|
||||
"app.bsky.feed.like",
|
||||
"app.bsky.feed.post",
|
||||
];
|
||||
let old_collections = ["app.bsky.feed.like", "app.bsky.feed.post"];
|
||||
let old = generate_multi_collection_records(&old_collections, 500);
|
||||
let new_rec = append_records(&old, "app.bsky.graph.follow", 0..500, 40000);
|
||||
assert_equivalence(&old, &new_rec, "2 collections x 500 + add 500 follows").await;
|
||||
@@ -378,7 +395,12 @@ async fn interleaved_keys_disjoint_ranges() {
|
||||
let new_rec: Vec<_> = (0..1000u32)
|
||||
.map(|i| (make_key("app.bsky.feed.post", i * 2 + 1), i + 10000))
|
||||
.collect();
|
||||
assert_equivalence(&old, &new_rec, "1000 even-keyed records replaced by 1000 odd-keyed").await;
|
||||
assert_equivalence(
|
||||
&old,
|
||||
&new_rec,
|
||||
"1000 even-keyed records replaced by 1000 odd-keyed",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -426,7 +448,12 @@ async fn many_collections_few_records_each() {
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_equivalence(&old, &new_rec, "50 collections x 20 records - delete every 15th, update every 7th").await;
|
||||
assert_equivalence(
|
||||
&old,
|
||||
&new_rec,
|
||||
"50 collections x 20 records - delete every 15th, update every 7th",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -457,7 +484,12 @@ async fn one_to_massive() {
|
||||
async fn delete_head_and_tail() {
|
||||
let old = generate_records("app.bsky.feed.post", 0..2000);
|
||||
let new_rec: Vec<_> = old[200..1800].to_vec();
|
||||
assert_equivalence(&old, &new_rec, "2000 records - delete first 200 and last 200").await;
|
||||
assert_equivalence(
|
||||
&old,
|
||||
&new_rec,
|
||||
"2000 records - delete first 200 and last 200",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -465,7 +497,12 @@ async fn keep_head_and_tail_only() {
|
||||
let old = generate_records("app.bsky.feed.post", 0..2000);
|
||||
let mut new_rec: Vec<_> = old[..100].to_vec();
|
||||
new_rec.extend_from_slice(&old[1900..]);
|
||||
assert_equivalence(&old, &new_rec, "2000 records - keep only first 100 and last 100").await;
|
||||
assert_equivalence(
|
||||
&old,
|
||||
&new_rec,
|
||||
"2000 records - keep only first 100 and last 100",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -515,7 +552,12 @@ async fn swiss_cheese_deletions() {
|
||||
})
|
||||
.map(|(_, r)| r.clone())
|
||||
.collect();
|
||||
assert_equivalence(&old, &new_rec, "1500 records - delete every 3rd chunk of 50").await;
|
||||
assert_equivalence(
|
||||
&old,
|
||||
&new_rec,
|
||||
"1500 records - delete every 3rd chunk of 50",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -529,9 +571,7 @@ async fn mixed_ops_with_key_density_change() {
|
||||
.filter(|(_, val)| val % 4 != 0)
|
||||
.cloned()
|
||||
.collect();
|
||||
new_rec.extend((0..500u32).map(|i| {
|
||||
(make_key("app.bsky.feed.post", i * 3 + 1), i + 100000)
|
||||
}));
|
||||
new_rec.extend((0..500u32).map(|i| (make_key("app.bsky.feed.post", i * 3 + 1), i + 100000)));
|
||||
new_rec.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
|
||||
assert_equivalence(
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
mod common;
|
||||
mod firehose;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Cursor;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use common::*;
|
||||
use firehose::{FirehoseConsumer, ParsedCommitFrame};
|
||||
use iroh_car::CarReader;
|
||||
use jacquard_common::smol_str::SmolStr;
|
||||
use jacquard_repo::commit::Commit;
|
||||
use jacquard_repo::mst::{Mst, VerifiedWriteOp};
|
||||
use jacquard_repo::storage::{BlockStore, MemoryBlockStore};
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use tranquil_scopes::RepoAction;
|
||||
|
||||
async fn car_to_blocks(car_bytes: &[u8]) -> BTreeMap<Cid, Bytes> {
|
||||
let mut reader = CarReader::new(Cursor::new(car_bytes))
|
||||
.await
|
||||
.expect("parse CAR header");
|
||||
let mut blocks = BTreeMap::new();
|
||||
while let Ok(Some((cid, data))) = reader.next_block().await {
|
||||
blocks.insert(cid, Bytes::from(data));
|
||||
}
|
||||
blocks
|
||||
}
|
||||
|
||||
fn op_to_verified(op: &firehose::ParsedRepoOp) -> Result<VerifiedWriteOp, String> {
|
||||
let key = SmolStr::new(&op.path);
|
||||
match op.action {
|
||||
RepoAction::Create => {
|
||||
let cid = op.cid.ok_or("create op missing cid")?;
|
||||
Ok(VerifiedWriteOp::Create { key, cid })
|
||||
}
|
||||
RepoAction::Update => {
|
||||
let cid = op.cid.ok_or("update op missing cid")?;
|
||||
let prev = op.prev.ok_or("update op missing prev")?;
|
||||
Ok(VerifiedWriteOp::Update { key, cid, prev })
|
||||
}
|
||||
RepoAction::Delete => {
|
||||
let prev = op.prev.ok_or("delete op missing prev")?;
|
||||
Ok(VerifiedWriteOp::Delete { key, prev })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_frame_forward(frame: &ParsedCommitFrame) -> Result<(), String> {
|
||||
let prev_data = frame
|
||||
.prev_data
|
||||
.ok_or_else(|| "frame missing prev_data (v1.1 required)".to_string())?;
|
||||
|
||||
let blocks = car_to_blocks(&frame.blocks).await;
|
||||
let storage = Arc::new(MemoryBlockStore::new_from_blocks(blocks));
|
||||
|
||||
let commit_bytes = storage
|
||||
.get(&frame.commit)
|
||||
.await
|
||||
.map_err(|e| format!("get commit: {e:?}"))?
|
||||
.ok_or_else(|| format!("CAR missing commit {}", frame.commit))?;
|
||||
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| format!("parse commit: {e:?}"))?;
|
||||
let expected = *commit.data();
|
||||
|
||||
let mut mst = Mst::load(storage, prev_data, None);
|
||||
for op in &frame.ops {
|
||||
let path = &op.path;
|
||||
match op.action {
|
||||
RepoAction::Create | RepoAction::Update => {
|
||||
let cid = op.cid.ok_or_else(|| format!("{path}: op missing cid"))?;
|
||||
mst = mst
|
||||
.add(path, cid)
|
||||
.await
|
||||
.map_err(|e| format!("forward {path}: {e:?}"))?;
|
||||
}
|
||||
RepoAction::Delete => {
|
||||
mst = mst
|
||||
.delete(path)
|
||||
.await
|
||||
.map_err(|e| format!("forward delete {path}: {e:?}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let computed = mst.persist().await.map_err(|e| format!("persist: {e:?}"))?;
|
||||
if computed != expected {
|
||||
return Err(format!(
|
||||
"root mismatch expected={expected} computed={computed}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn verify_frame_inverse(frame: &ParsedCommitFrame) -> Result<(), String> {
|
||||
let prev_data = frame
|
||||
.prev_data
|
||||
.ok_or_else(|| "frame missing prev_data (v1.1 required)".to_string())?;
|
||||
|
||||
let blocks = car_to_blocks(&frame.blocks).await;
|
||||
let storage = Arc::new(MemoryBlockStore::new_from_blocks(blocks));
|
||||
|
||||
let commit_bytes = storage
|
||||
.get(&frame.commit)
|
||||
.await
|
||||
.map_err(|e| format!("get commit: {e:?}"))?
|
||||
.ok_or_else(|| format!("CAR missing commit {}", frame.commit))?;
|
||||
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| format!("parse commit: {e:?}"))?;
|
||||
let new_data = *commit.data();
|
||||
|
||||
let mut mst = Mst::load(storage, new_data, None);
|
||||
for op in &frame.ops {
|
||||
let verified = op_to_verified(op)?;
|
||||
let inverted = mst
|
||||
.invert_op(verified.clone())
|
||||
.await
|
||||
.map_err(|e| format!("invert {verified:?}: {e:?}"))?;
|
||||
if !inverted {
|
||||
return Err(format!("op not invertible: {verified:?}"));
|
||||
}
|
||||
}
|
||||
let computed_prev = mst
|
||||
.get_pointer()
|
||||
.await
|
||||
.map_err(|e| format!("get_pointer: {e:?}"))?;
|
||||
if computed_prev != prev_data {
|
||||
return Err(format!(
|
||||
"inverse root mismatch expected={prev_data} computed={computed_prev}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_record(client: &reqwest::Client, token: &str, did: &str, rkey: &str, text: &str) {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.createRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": rkey,
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": text,
|
||||
"createdAt": now,
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("createRecord");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
async fn put_record(client: &reqwest::Client, token: &str, did: &str, rkey: &str, text: &str) {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.putRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": rkey,
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": text,
|
||||
"createdAt": now,
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("putRecord");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
async fn delete_record(client: &reqwest::Client, token: &str, did: &str, rkey: &str) {
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.deleteRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": rkey,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("deleteRecord");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
async fn apply_writes_batch(client: &reqwest::Client, token: &str, did: &str, writes: Vec<Value>) {
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.applyWrites",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({ "repo": did, "writes": writes }))
|
||||
.send()
|
||||
.await
|
||||
.expect("applyWrites");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
fn rkey_for(i: usize) -> String {
|
||||
format!("3ke2e{:08}", i)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_firehose_frames_pass_inductive_forward_and_inverse() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
let repos = get_test_repos().await;
|
||||
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
|
||||
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let seed: Vec<Value> = (0..120)
|
||||
.map(|i| {
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": rkey_for(i),
|
||||
"value": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": format!("e2e {i}"),
|
||||
"createdAt": now,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for chunk in seed.chunks(40) {
|
||||
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
|
||||
}
|
||||
for i in (0..120).step_by(6) {
|
||||
put_record(&client, &token, &did, &rkey_for(i), &format!("upd {i}")).await;
|
||||
}
|
||||
for i in (2..120).step_by(11) {
|
||||
delete_record(&client, &token, &did, &rkey_for(i)).await;
|
||||
}
|
||||
create_record(&client, &token, &did, "3ke2efinal001", "final").await;
|
||||
|
||||
let target_commits = 3 + 20 + 11 + 1;
|
||||
let frames = consumer
|
||||
.wait_for_commits(&did, target_commits, Duration::from_secs(90))
|
||||
.await;
|
||||
assert!(
|
||||
frames.len() >= target_commits,
|
||||
"expected {} commit frames, got {}",
|
||||
target_commits,
|
||||
frames.len()
|
||||
);
|
||||
|
||||
let mut forward_failures = Vec::new();
|
||||
let mut inverse_failures = Vec::new();
|
||||
for frame in &frames {
|
||||
if frame.prev_data.is_none() {
|
||||
continue;
|
||||
}
|
||||
if frame.ops.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Err(msg) = verify_frame_forward(frame).await {
|
||||
forward_failures.push(format!("seq={}: {msg}", frame.seq));
|
||||
}
|
||||
if let Err(msg) = verify_frame_inverse(frame).await {
|
||||
inverse_failures.push(format!("seq={}: {msg}", frame.seq));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
forward_failures.is_empty(),
|
||||
"forward verification failures:\n - {}",
|
||||
forward_failures.join("\n - ")
|
||||
);
|
||||
assert!(
|
||||
inverse_failures.is_empty(),
|
||||
"inverse verification failures:\n - {}",
|
||||
inverse_failures.join("\n - ")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_firehose_car_root_matches_commit_cid() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
let repos = get_test_repos().await;
|
||||
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
|
||||
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
for i in 0..4 {
|
||||
create_record(&client, &token, &did, &rkey_for(i), "ck").await;
|
||||
}
|
||||
|
||||
let frames = consumer
|
||||
.wait_for_commits(&did, 4, Duration::from_secs(10))
|
||||
.await;
|
||||
|
||||
for frame in &frames {
|
||||
let mut reader = CarReader::new(Cursor::new(&frame.blocks))
|
||||
.await
|
||||
.expect("CAR header");
|
||||
let roots = reader.header().roots();
|
||||
assert_eq!(roots.len(), 1, "CAR must have exactly one root");
|
||||
assert_eq!(
|
||||
roots[0], frame.commit,
|
||||
"CAR root must equal frame commit CID"
|
||||
);
|
||||
let mut found = false;
|
||||
while let Ok(Some((cid, _))) = reader.next_block().await {
|
||||
if cid == frame.commit {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
assert!(found, "CAR body must contain commit block");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_firehose_resumption_from_cursor_yields_valid_frames() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
let repos = get_test_repos().await;
|
||||
|
||||
for i in 0..5 {
|
||||
create_record(&client, &token, &did, &rkey_for(i), "pre").await;
|
||||
}
|
||||
|
||||
let resume_cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
|
||||
|
||||
for i in 5..12 {
|
||||
create_record(&client, &token, &did, &rkey_for(i), "post").await;
|
||||
}
|
||||
|
||||
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), resume_cursor).await;
|
||||
let frames = consumer
|
||||
.wait_for_commits(&did, 7, Duration::from_secs(20))
|
||||
.await;
|
||||
assert!(
|
||||
frames.len() >= 7,
|
||||
"expected 7+ frames after cursor resume, got {}",
|
||||
frames.len()
|
||||
);
|
||||
|
||||
for frame in &frames {
|
||||
if frame.prev_data.is_none() || frame.ops.is_empty() {
|
||||
continue;
|
||||
}
|
||||
verify_frame_forward(frame)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("resumed frame seq={} invalid: {e}", frame.seq));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_firehose_ops_include_prev_field_for_update_delete() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
let repos = get_test_repos().await;
|
||||
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
|
||||
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
create_record(&client, &token, &did, "3ke2eprev01", "v1").await;
|
||||
put_record(&client, &token, &did, "3ke2eprev01", "v2").await;
|
||||
delete_record(&client, &token, &did, "3ke2eprev01").await;
|
||||
|
||||
let frames = consumer
|
||||
.wait_for_commits(&did, 3, Duration::from_secs(10))
|
||||
.await;
|
||||
assert!(frames.len() >= 3);
|
||||
|
||||
for frame in &frames {
|
||||
for op in &frame.ops {
|
||||
match op.action {
|
||||
RepoAction::Create => {
|
||||
assert!(op.cid.is_some(), "create must have cid");
|
||||
assert!(op.prev.is_none(), "create must not have prev");
|
||||
}
|
||||
RepoAction::Update => {
|
||||
assert!(op.cid.is_some(), "update must have cid");
|
||||
assert!(
|
||||
op.prev.is_some(),
|
||||
"v1.1 update must carry prev CID (seq={})",
|
||||
frame.seq
|
||||
);
|
||||
}
|
||||
RepoAction::Delete => {
|
||||
assert!(op.cid.is_none(), "delete must have null cid");
|
||||
assert!(
|
||||
op.prev.is_some(),
|
||||
"v1.1 delete must carry prev CID (seq={})",
|
||||
frame.seq
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_firehose_rebuild_new_mst_from_car_matches_commit_data() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
let repos = get_test_repos().await;
|
||||
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
|
||||
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let writes: Vec<Value> = (0..30)
|
||||
.map(|i| {
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": rkey_for(i),
|
||||
"value": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": format!("rb {i}"),
|
||||
"createdAt": now,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
apply_writes_batch(&client, &token, &did, writes).await;
|
||||
|
||||
let frames = consumer
|
||||
.wait_for_commits(&did, 1, Duration::from_secs(10))
|
||||
.await;
|
||||
let last = frames.last().expect("frame");
|
||||
|
||||
let blocks = car_to_blocks(&last.blocks).await;
|
||||
let storage = Arc::new(MemoryBlockStore::new_from_blocks(blocks));
|
||||
let commit_bytes = storage
|
||||
.get(&last.commit)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("commit block");
|
||||
let commit = Commit::from_cbor(&commit_bytes).unwrap();
|
||||
|
||||
let new_root_cid = *commit.data();
|
||||
let mst = Mst::load(storage, new_root_cid, None);
|
||||
let rehydrated_cid = mst.get_pointer().await.expect("rebuild mst");
|
||||
assert_eq!(
|
||||
rehydrated_cid, new_root_cid,
|
||||
"MST loaded from CAR must yield same root as commit.data()"
|
||||
);
|
||||
|
||||
for op in &last.ops {
|
||||
if op.action == RepoAction::Create {
|
||||
let expected_cid = op.cid.unwrap();
|
||||
let got = mst
|
||||
.get(&op.path)
|
||||
.await
|
||||
.expect("mst.get")
|
||||
.unwrap_or_else(|| panic!("key {} missing from rebuilt tree", op.path));
|
||||
assert_eq!(got, expected_cid, "record CID mismatch for {}", op.path);
|
||||
let _ = Cid::from_str(&expected_cid.to_string()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
mod common;
|
||||
mod mst_verify;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cid::Cid;
|
||||
use common::*;
|
||||
use jacquard_common::smol_str::SmolStr;
|
||||
use jacquard_repo::commit::Commit;
|
||||
use jacquard_repo::mst::{Mst, VerifiedWriteOp};
|
||||
use jacquard_repo::storage::{BlockStore, MemoryBlockStore};
|
||||
use mst_verify::{extract_event_blocks, inline_to_store};
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use tranquil_db_traits::{RepoEventType, SequenceNumber, SequencedEvent};
|
||||
use tranquil_types::Did;
|
||||
|
||||
async fn new_commit_data_cid(
|
||||
storage: &Arc<MemoryBlockStore>,
|
||||
commit_cid: &Cid,
|
||||
) -> Result<Cid, String> {
|
||||
let commit_bytes = storage
|
||||
.get(commit_cid)
|
||||
.await
|
||||
.map_err(|e| format!("get commit: {e:?}"))?
|
||||
.ok_or_else(|| format!("CAR missing commit block {commit_cid}"))?;
|
||||
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| format!("parse commit: {e:?}"))?;
|
||||
Ok(*commit.data())
|
||||
}
|
||||
|
||||
fn ops_json(event: &SequencedEvent) -> Result<&Vec<Value>, String> {
|
||||
event
|
||||
.ops
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| "event.ops not an array".into())
|
||||
}
|
||||
|
||||
fn parse_op_to_verified(op: &Value) -> Result<VerifiedWriteOp, String> {
|
||||
let action = op["action"].as_str().ok_or("op.action missing")?;
|
||||
let path = op["path"].as_str().ok_or("op.path missing")?;
|
||||
let key = SmolStr::new(path);
|
||||
match action {
|
||||
"create" => {
|
||||
let cid_str = op["cid"].as_str().ok_or("create missing cid")?;
|
||||
let cid = Cid::from_str(cid_str).map_err(|e| format!("parse cid: {e:?}"))?;
|
||||
Ok(VerifiedWriteOp::Create { key, cid })
|
||||
}
|
||||
"update" => {
|
||||
let cid_str = op["cid"].as_str().ok_or("update missing cid")?;
|
||||
let cid = Cid::from_str(cid_str).map_err(|e| format!("parse cid: {e:?}"))?;
|
||||
let prev_str = op["prev"].as_str().ok_or("update missing prev")?;
|
||||
let prev = Cid::from_str(prev_str).map_err(|e| format!("parse prev: {e:?}"))?;
|
||||
Ok(VerifiedWriteOp::Update { key, cid, prev })
|
||||
}
|
||||
"delete" => {
|
||||
let prev_str = op["prev"].as_str().ok_or("delete missing prev")?;
|
||||
let prev = Cid::from_str(prev_str).map_err(|e| format!("parse prev: {e:?}"))?;
|
||||
Ok(VerifiedWriteOp::Delete { key, prev })
|
||||
}
|
||||
other => Err(format!("unknown op action: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_inductive_forward(event: &SequencedEvent) -> Result<(Cid, Cid), String> {
|
||||
let prev_data_cid = event
|
||||
.prev_data_cid
|
||||
.as_ref()
|
||||
.and_then(|c| c.to_cid())
|
||||
.ok_or_else(|| "event missing prev_data_cid".to_string())?;
|
||||
let commit_cid = event
|
||||
.commit_cid
|
||||
.as_ref()
|
||||
.and_then(|c| c.to_cid())
|
||||
.ok_or_else(|| "event missing commit_cid".to_string())?;
|
||||
|
||||
let storage = inline_to_store(extract_event_blocks(event)?);
|
||||
let expected_new_data = new_commit_data_cid(&storage, &commit_cid).await?;
|
||||
|
||||
let mut mst = Mst::load(storage.clone(), prev_data_cid, None);
|
||||
for op_value in ops_json(event)? {
|
||||
let action = op_value["action"].as_str().ok_or("op.action missing")?;
|
||||
let path = op_value["path"].as_str().ok_or("op.path missing")?;
|
||||
match action {
|
||||
"create" | "update" => {
|
||||
let cid = Cid::from_str(op_value["cid"].as_str().ok_or("op.cid missing")?)
|
||||
.map_err(|e| format!("parse op.cid: {e:?}"))?;
|
||||
mst = mst
|
||||
.add(path, cid)
|
||||
.await
|
||||
.map_err(|e| format!("mst.add({path}): {e:?}"))?;
|
||||
}
|
||||
"delete" => {
|
||||
mst = mst
|
||||
.delete(path)
|
||||
.await
|
||||
.map_err(|e| format!("mst.delete({path}): {e:?}"))?;
|
||||
}
|
||||
other => return Err(format!("unknown op action: {other}")),
|
||||
}
|
||||
}
|
||||
let computed = mst
|
||||
.persist()
|
||||
.await
|
||||
.map_err(|e| format!("mst.persist: {e:?}"))?;
|
||||
Ok((expected_new_data, computed))
|
||||
}
|
||||
|
||||
async fn verify_inductive_inverse(event: &SequencedEvent) -> Result<(Cid, Cid), String> {
|
||||
let prev_data_cid = event
|
||||
.prev_data_cid
|
||||
.as_ref()
|
||||
.and_then(|c| c.to_cid())
|
||||
.ok_or_else(|| "event missing prev_data_cid".to_string())?;
|
||||
let commit_cid = event
|
||||
.commit_cid
|
||||
.as_ref()
|
||||
.and_then(|c| c.to_cid())
|
||||
.ok_or_else(|| "event missing commit_cid".to_string())?;
|
||||
|
||||
let storage = inline_to_store(extract_event_blocks(event)?);
|
||||
let new_data_cid = new_commit_data_cid(&storage, &commit_cid).await?;
|
||||
|
||||
let mut mst = Mst::load(storage.clone(), new_data_cid, None);
|
||||
for op_value in ops_json(event)?.iter().rev() {
|
||||
let verified = parse_op_to_verified(op_value)?;
|
||||
let inverted = mst
|
||||
.invert_op(verified.clone())
|
||||
.await
|
||||
.map_err(|e| format!("invert_op({verified:?}): {e:?}"))?;
|
||||
if !inverted {
|
||||
return Err(format!("op not invertible: {verified:?}"));
|
||||
}
|
||||
}
|
||||
let computed_prev = mst
|
||||
.get_pointer()
|
||||
.await
|
||||
.map_err(|e| format!("get_pointer: {e:?}"))?;
|
||||
Ok((prev_data_cid, computed_prev))
|
||||
}
|
||||
|
||||
fn report_failures(total: usize, failures: &[String], mode: &str) {
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"{} of {total} {mode} commit events failed inductive verification:\n - {}",
|
||||
failures.len(),
|
||||
failures.join("\n - "),
|
||||
);
|
||||
}
|
||||
|
||||
async fn apply_writes_batch(client: &reqwest::Client, token: &str, did: &str, writes: Vec<Value>) {
|
||||
let payload = json!({ "repo": did, "writes": writes });
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.applyWrites",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("applyWrites request failed");
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::OK,
|
||||
"applyWrites failed: {:?}",
|
||||
res.text().await
|
||||
);
|
||||
}
|
||||
|
||||
async fn create_record(client: &reqwest::Client, token: &str, did: &str, col: &str, rkey: &str) {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.createRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": col,
|
||||
"rkey": rkey,
|
||||
"record": {
|
||||
"$type": col,
|
||||
"text": format!("post {rkey}"),
|
||||
"createdAt": now,
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("createRecord request failed");
|
||||
assert_eq!(res.status(), StatusCode::OK, "createRecord failed");
|
||||
}
|
||||
|
||||
async fn put_record(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
did: &str,
|
||||
col: &str,
|
||||
rkey: &str,
|
||||
text: &str,
|
||||
) {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.putRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": col,
|
||||
"rkey": rkey,
|
||||
"record": {
|
||||
"$type": col,
|
||||
"text": text,
|
||||
"createdAt": now,
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("putRecord request failed");
|
||||
assert_eq!(res.status(), StatusCode::OK, "putRecord failed");
|
||||
}
|
||||
|
||||
async fn delete_record(client: &reqwest::Client, token: &str, did: &str, col: &str, rkey: &str) {
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.deleteRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({ "repo": did, "collection": col, "rkey": rkey }))
|
||||
.send()
|
||||
.await
|
||||
.expect("deleteRecord request failed");
|
||||
assert_eq!(res.status(), StatusCode::OK, "deleteRecord failed");
|
||||
}
|
||||
|
||||
const COLLECTION: &str = "app.bsky.feed.post";
|
||||
fn rkey_for(prefix: &str, i: usize) -> String {
|
||||
format!("3k{prefix}{:08}", i)
|
||||
}
|
||||
|
||||
async fn our_commit_events(did: &str) -> Vec<SequencedEvent> {
|
||||
let repos = get_test_repos().await;
|
||||
let typed_did = Did::new(did.to_string()).unwrap();
|
||||
let events = repos
|
||||
.repo
|
||||
.get_events_since_seq(SequenceNumber::ZERO, None)
|
||||
.await
|
||||
.expect("get_events_since_seq");
|
||||
events
|
||||
.into_iter()
|
||||
.filter(|e| e.did == typed_did && e.event_type == RepoEventType::Commit)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inductive_forward_verifies_delete_commits() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
const N_CREATE: usize = 200;
|
||||
|
||||
let all_writes: Vec<Value> = (0..N_CREATE)
|
||||
.map(|i| {
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": COLLECTION,
|
||||
"rkey": rkey_for("del", i),
|
||||
"value": {
|
||||
"$type": COLLECTION,
|
||||
"text": format!("record {i}"),
|
||||
"createdAt": now,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for chunk in all_writes.chunks(50) {
|
||||
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
|
||||
}
|
||||
|
||||
let delete_indices: Vec<usize> = (10..N_CREATE).step_by(7).collect();
|
||||
for i in &delete_indices {
|
||||
delete_record(&client, &token, &did, COLLECTION, &rkey_for("del", *i)).await;
|
||||
}
|
||||
|
||||
let our = our_commit_events(&did).await;
|
||||
let delete_events: Vec<&SequencedEvent> = our
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
ops_json(e)
|
||||
.map(|arr| arr.iter().any(|op| op["action"].as_str() == Some("delete")))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(delete_events.len(), delete_indices.len());
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for e in &delete_events {
|
||||
match verify_inductive_forward(e).await {
|
||||
Ok((exp, got)) if exp == got => {}
|
||||
Ok((exp, got)) => failures.push(format!(
|
||||
"seq={}: root mismatch exp={exp} got={got}",
|
||||
e.seq.as_i64()
|
||||
)),
|
||||
Err(msg) => failures.push(format!("seq={}: {msg}", e.seq.as_i64())),
|
||||
}
|
||||
}
|
||||
report_failures(delete_events.len(), &failures, "delete forward");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inductive_forward_verifies_create_commits() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
for i in 0..60usize {
|
||||
create_record(&client, &token, &did, COLLECTION, &rkey_for("cre", i)).await;
|
||||
}
|
||||
|
||||
let our = our_commit_events(&did).await;
|
||||
let create_events: Vec<&SequencedEvent> = our
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
ops_json(e)
|
||||
.map(|arr| arr.iter().all(|op| op["action"].as_str() == Some("create")))
|
||||
.unwrap_or(false)
|
||||
&& e.prev_data_cid.is_some()
|
||||
})
|
||||
.collect();
|
||||
assert!(!create_events.is_empty());
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for e in &create_events {
|
||||
match verify_inductive_forward(e).await {
|
||||
Ok((exp, got)) if exp == got => {}
|
||||
Ok((exp, got)) => failures.push(format!(
|
||||
"seq={}: root mismatch exp={exp} got={got}",
|
||||
e.seq.as_i64()
|
||||
)),
|
||||
Err(msg) => failures.push(format!("seq={}: {msg}", e.seq.as_i64())),
|
||||
}
|
||||
}
|
||||
report_failures(create_events.len(), &failures, "create forward");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inductive_forward_verifies_update_commits() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let creates: Vec<Value> = (0..80)
|
||||
.map(|i| {
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": COLLECTION,
|
||||
"rkey": rkey_for("upd", i),
|
||||
"value": {
|
||||
"$type": COLLECTION,
|
||||
"text": format!("original {i}"),
|
||||
"createdAt": now,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for chunk in creates.chunks(40) {
|
||||
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
|
||||
}
|
||||
|
||||
for i in (0..80).step_by(3) {
|
||||
put_record(
|
||||
&client,
|
||||
&token,
|
||||
&did,
|
||||
COLLECTION,
|
||||
&rkey_for("upd", i),
|
||||
&format!("updated {i}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let our = our_commit_events(&did).await;
|
||||
let update_events: Vec<&SequencedEvent> = our
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
ops_json(e)
|
||||
.map(|arr| arr.iter().any(|op| op["action"].as_str() == Some("update")))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
assert!(!update_events.is_empty());
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for e in &update_events {
|
||||
match verify_inductive_forward(e).await {
|
||||
Ok((exp, got)) if exp == got => {}
|
||||
Ok((exp, got)) => failures.push(format!(
|
||||
"seq={}: root mismatch exp={exp} got={got}",
|
||||
e.seq.as_i64()
|
||||
)),
|
||||
Err(msg) => failures.push(format!("seq={}: {msg}", e.seq.as_i64())),
|
||||
}
|
||||
}
|
||||
report_failures(update_events.len(), &failures, "update forward");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inductive_forward_verifies_mixed_applywrites() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let seed: Vec<Value> = (0..120)
|
||||
.map(|i| {
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": COLLECTION,
|
||||
"rkey": rkey_for("mix", i),
|
||||
"value": {
|
||||
"$type": COLLECTION,
|
||||
"text": format!("seed {i}"),
|
||||
"createdAt": now,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for chunk in seed.chunks(40) {
|
||||
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
|
||||
}
|
||||
|
||||
let mixed: Vec<Value> = (0..40)
|
||||
.flat_map(|i| {
|
||||
vec![
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": COLLECTION,
|
||||
"rkey": rkey_for("mxc", i),
|
||||
"value": {
|
||||
"$type": COLLECTION,
|
||||
"text": format!("new {i}"),
|
||||
"createdAt": now,
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#update",
|
||||
"collection": COLLECTION,
|
||||
"rkey": rkey_for("mix", i),
|
||||
"value": {
|
||||
"$type": COLLECTION,
|
||||
"text": format!("updated-mix {i}"),
|
||||
"createdAt": now,
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#delete",
|
||||
"collection": COLLECTION,
|
||||
"rkey": rkey_for("mix", i + 60),
|
||||
}),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
apply_writes_batch(&client, &token, &did, mixed).await;
|
||||
|
||||
let our = our_commit_events(&did).await;
|
||||
let last = our
|
||||
.iter()
|
||||
.rfind(|e| e.prev_data_cid.is_some())
|
||||
.expect("at least one non-genesis commit");
|
||||
|
||||
let actions: Vec<&str> = ops_json(last)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|op| op["action"].as_str())
|
||||
.collect();
|
||||
assert!(actions.contains(&"create"));
|
||||
assert!(actions.contains(&"update"));
|
||||
assert!(actions.contains(&"delete"));
|
||||
|
||||
let (exp, got) = verify_inductive_forward(last)
|
||||
.await
|
||||
.expect("mixed applyWrites forward verify");
|
||||
assert_eq!(exp, got, "mixed applyWrites commit forward-verify mismatch");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inductive_inverse_verifies_every_commit() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let seed: Vec<Value> = (0..100)
|
||||
.map(|i| {
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": COLLECTION,
|
||||
"rkey": rkey_for("inv", i),
|
||||
"value": {
|
||||
"$type": COLLECTION,
|
||||
"text": format!("seed {i}"),
|
||||
"createdAt": now,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for chunk in seed.chunks(50) {
|
||||
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
|
||||
}
|
||||
for i in (0..100).step_by(5) {
|
||||
put_record(
|
||||
&client,
|
||||
&token,
|
||||
&did,
|
||||
COLLECTION,
|
||||
&rkey_for("inv", i),
|
||||
&format!("upd {i}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
for i in (2..100).step_by(11) {
|
||||
delete_record(&client, &token, &did, COLLECTION, &rkey_for("inv", i)).await;
|
||||
}
|
||||
|
||||
let our = our_commit_events(&did).await;
|
||||
let non_genesis: Vec<&SequencedEvent> = our
|
||||
.iter()
|
||||
.filter(|e| e.prev_data_cid.is_some() && ops_json(e).is_ok())
|
||||
.collect();
|
||||
assert!(!non_genesis.is_empty());
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for e in &non_genesis {
|
||||
match verify_inductive_inverse(e).await {
|
||||
Ok((exp, got)) if exp == got => {}
|
||||
Ok((exp, got)) => failures.push(format!(
|
||||
"seq={}: inverse root mismatch exp={exp} got={got}",
|
||||
e.seq.as_i64()
|
||||
)),
|
||||
Err(msg) => failures.push(format!("seq={}: {msg}", e.seq.as_i64())),
|
||||
}
|
||||
}
|
||||
report_failures(non_genesis.len(), &failures, "any inverse");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inductive_inverse_handles_same_rkey_in_batch() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let rkey = rkey_for("dup", 0);
|
||||
create_record(&client, &token, &did, COLLECTION, &rkey).await;
|
||||
|
||||
let writes = vec![
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#update",
|
||||
"collection": COLLECTION,
|
||||
"rkey": rkey,
|
||||
"value": {
|
||||
"$type": COLLECTION,
|
||||
"text": "v1",
|
||||
"createdAt": now,
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#update",
|
||||
"collection": COLLECTION,
|
||||
"rkey": rkey,
|
||||
"value": {
|
||||
"$type": COLLECTION,
|
||||
"text": "v2",
|
||||
"createdAt": now,
|
||||
}
|
||||
}),
|
||||
];
|
||||
apply_writes_batch(&client, &token, &did, writes).await;
|
||||
|
||||
let our = our_commit_events(&did).await;
|
||||
let dup_event = our
|
||||
.iter()
|
||||
.find(|e| {
|
||||
ops_json(e)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter(|op| op["action"].as_str() == Some("update"))
|
||||
.count()
|
||||
== 2
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.expect("commit event with two same-rkey updates");
|
||||
|
||||
let (exp, got) = verify_inductive_inverse(dup_event)
|
||||
.await
|
||||
.expect("inverse verify should succeed for same-rkey batch");
|
||||
assert_eq!(
|
||||
exp, got,
|
||||
"inverse root mismatch for same-rkey batch: exp={exp} got={got}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prev_cid_chain_walks_to_genesis() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
for i in 0..8 {
|
||||
create_record(&client, &token, &did, COLLECTION, &rkey_for("cha", i)).await;
|
||||
}
|
||||
|
||||
let our = our_commit_events(&did).await;
|
||||
assert!(our.len() >= 2);
|
||||
|
||||
let last = our.last().unwrap();
|
||||
let mut current_prev: Option<Cid> = last.prev_cid.as_ref().and_then(|c| c.to_cid());
|
||||
let head_commit_cid = last
|
||||
.commit_cid
|
||||
.as_ref()
|
||||
.and_then(|c| c.to_cid())
|
||||
.expect("head commit_cid");
|
||||
|
||||
let by_commit: BTreeMap<Cid, &SequencedEvent> = our
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
e.commit_cid
|
||||
.as_ref()
|
||||
.and_then(|c| c.to_cid())
|
||||
.map(|c| (c, e))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut visited = 1;
|
||||
while let Some(prev) = current_prev {
|
||||
let e = by_commit
|
||||
.get(&prev)
|
||||
.unwrap_or_else(|| panic!("prev commit {prev} missing from event list"));
|
||||
visited += 1;
|
||||
current_prev = e.prev_cid.as_ref().and_then(|c| c.to_cid());
|
||||
}
|
||||
assert!(
|
||||
visited >= 2,
|
||||
"chain too short: visited={visited}, head_commit={head_commit_cid}"
|
||||
);
|
||||
assert_eq!(
|
||||
visited,
|
||||
our.len(),
|
||||
"chain did not reach genesis: walked {visited}, have {}",
|
||||
our.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_bytes_present_in_car_for_creates() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let writes: Vec<Value> = (0..5)
|
||||
.map(|i| {
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": COLLECTION,
|
||||
"rkey": rkey_for("rec", i),
|
||||
"value": {
|
||||
"$type": COLLECTION,
|
||||
"text": format!("rec {i}"),
|
||||
"createdAt": now,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
apply_writes_batch(&client, &token, &did, writes).await;
|
||||
|
||||
let our = our_commit_events(&did).await;
|
||||
let latest = our.iter().rfind(|e| e.prev_data_cid.is_some()).unwrap();
|
||||
|
||||
let inline = extract_event_blocks(latest).unwrap();
|
||||
let have_cids: std::collections::HashSet<Cid> = inline
|
||||
.iter()
|
||||
.map(|b| Cid::read_bytes(b.cid_bytes.as_slice()).unwrap())
|
||||
.collect();
|
||||
|
||||
for op in ops_json(latest).unwrap() {
|
||||
if op["action"].as_str() == Some("create")
|
||||
&& let Some(cid_str) = op["cid"].as_str()
|
||||
{
|
||||
let cid = Cid::from_str(cid_str).unwrap();
|
||||
assert!(
|
||||
have_cids.contains(&cid),
|
||||
"create op record CID {cid} not present in CAR inline blocks"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
mod common;
|
||||
mod firehose;
|
||||
mod helpers;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use common::*;
|
||||
use firehose::FirehoseConsumer;
|
||||
use helpers::build_car_with_signature;
|
||||
use iroh_car::CarReader;
|
||||
use jacquard_repo::commit::Commit;
|
||||
use jacquard_repo::mst::Mst;
|
||||
use jacquard_repo::storage::{BlockStore, MemoryBlockStore};
|
||||
use k256::ecdsa::SigningKey;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use tranquil_db_traits::{EventBlocks, RepoEventType, SequenceNumber, SequencedEvent};
|
||||
use tranquil_scopes::RepoAction;
|
||||
use tranquil_types::Did;
|
||||
|
||||
async fn car_to_blocks(car_bytes: &[u8]) -> (Vec<Cid>, BTreeMap<Cid, Bytes>) {
|
||||
let mut reader = CarReader::new(Cursor::new(car_bytes))
|
||||
.await
|
||||
.expect("parse CAR");
|
||||
let roots = reader.header().roots().to_vec();
|
||||
let mut blocks = BTreeMap::new();
|
||||
while let Ok(Some((cid, data))) = reader.next_block().await {
|
||||
blocks.insert(cid, Bytes::from(data));
|
||||
}
|
||||
(roots, blocks)
|
||||
}
|
||||
|
||||
async fn create_post(client: &reqwest::Client, token: &str, did: &str, rkey: &str, text: &str) {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.createRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": rkey,
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": text,
|
||||
"createdAt": now,
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("createRecord");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn getrepo_car_roundtrips_mst_structure_and_records() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
let expected_records: Vec<(String, String)> = (0..20)
|
||||
.map(|i| {
|
||||
let rkey = format!("3krtp{:08}", i);
|
||||
let text = format!("roundtrip record {i}");
|
||||
(rkey, text)
|
||||
})
|
||||
.collect();
|
||||
for (rkey, text) in &expected_records {
|
||||
create_post(&client, &token, &did, rkey, text).await;
|
||||
}
|
||||
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.sync.getRepo",
|
||||
base_url().await
|
||||
))
|
||||
.query(&[("did", did.as_str())])
|
||||
.send()
|
||||
.await
|
||||
.expect("getRepo");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let car_bytes = res.bytes().await.unwrap();
|
||||
|
||||
let (roots, block_map) = car_to_blocks(&car_bytes).await;
|
||||
assert_eq!(roots.len(), 1, "CAR must have exactly one root");
|
||||
let commit_cid = roots[0];
|
||||
let storage = Arc::new(MemoryBlockStore::new_from_blocks(block_map));
|
||||
|
||||
let commit_bytes = storage
|
||||
.get(&commit_cid)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("CAR contains commit block");
|
||||
let commit = Commit::from_cbor(&commit_bytes).expect("parse commit");
|
||||
let data_cid = *commit.data();
|
||||
|
||||
let mst = Mst::load(storage.clone(), data_cid, None);
|
||||
let loaded_root = mst.get_pointer().await.expect("load root");
|
||||
assert_eq!(loaded_root, data_cid, "loaded MST pointer == commit.data()");
|
||||
|
||||
for (rkey, _) in &expected_records {
|
||||
let path = format!("app.bsky.feed.post/{rkey}");
|
||||
let leaf = mst
|
||||
.get(&path)
|
||||
.await
|
||||
.expect("mst.get")
|
||||
.unwrap_or_else(|| panic!("record {path} missing from exported MST"));
|
||||
let leaf_bytes = storage
|
||||
.get(&leaf)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("record block {leaf} missing from CAR"));
|
||||
assert!(!leaf_bytes.is_empty(), "record bytes empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_swap_commit_writes_serialize() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
create_post(&client, &token, &did, "3kswap00000001", "anchor").await;
|
||||
|
||||
let latest_res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.sync.getLatestCommit",
|
||||
base_url().await
|
||||
))
|
||||
.query(&[("did", did.as_str())])
|
||||
.send()
|
||||
.await
|
||||
.expect("getLatestCommit");
|
||||
assert_eq!(latest_res.status(), StatusCode::OK);
|
||||
let latest: Value = latest_res.json().await.unwrap();
|
||||
let swap_cid = latest["cid"].as_str().unwrap().to_string();
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let payload_a = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": "3kswap00000002",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "writer A",
|
||||
"createdAt": now,
|
||||
},
|
||||
"swapCommit": swap_cid,
|
||||
});
|
||||
let payload_b = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": "3kswap00000003",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "writer B",
|
||||
"createdAt": now,
|
||||
},
|
||||
"swapCommit": swap_cid,
|
||||
});
|
||||
|
||||
let base = base_url().await;
|
||||
let (res_a, res_b) = tokio::join!(
|
||||
client
|
||||
.post(format!("{base}/xrpc/com.atproto.repo.putRecord"))
|
||||
.bearer_auth(&token)
|
||||
.json(&payload_a)
|
||||
.send(),
|
||||
client
|
||||
.post(format!("{base}/xrpc/com.atproto.repo.putRecord"))
|
||||
.bearer_auth(&token)
|
||||
.json(&payload_b)
|
||||
.send(),
|
||||
);
|
||||
let status_a = res_a.expect("A send").status();
|
||||
let status_b = res_b.expect("B send").status();
|
||||
|
||||
let ok_a = status_a == StatusCode::OK;
|
||||
let ok_b = status_b == StatusCode::OK;
|
||||
assert!(
|
||||
ok_a ^ ok_b,
|
||||
"exactly one swap_commit write must succeed: status_a={status_a}, status_b={status_b}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imported_repo_emits_commit_event_with_valid_car() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
let signing_key = SigningKey::random(&mut rand::thread_rng());
|
||||
let (car_bytes, _car_root_cid) = build_car_with_signature(&did, &signing_key);
|
||||
|
||||
let import_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.importRepo",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&token)
|
||||
.header("Content-Type", "application/vnd.ipld.car")
|
||||
.body(car_bytes)
|
||||
.send()
|
||||
.await
|
||||
.expect("importRepo");
|
||||
assert_eq!(
|
||||
import_res.status(),
|
||||
StatusCode::OK,
|
||||
"import failed: {:?}",
|
||||
import_res.text().await.unwrap_or_default()
|
||||
);
|
||||
|
||||
let repos = get_test_repos().await;
|
||||
let typed_did = Did::new(did.clone()).unwrap();
|
||||
let events = repos
|
||||
.repo
|
||||
.get_events_since_seq(SequenceNumber::ZERO, None)
|
||||
.await
|
||||
.expect("events");
|
||||
let our: Vec<&SequencedEvent> = events
|
||||
.iter()
|
||||
.filter(|e| e.did == typed_did && e.event_type == RepoEventType::Commit)
|
||||
.collect();
|
||||
let last = our.last().expect("at least one commit event after import");
|
||||
|
||||
let inline = match last.blocks.as_ref().expect("blocks present") {
|
||||
EventBlocks::Inline(v) => v,
|
||||
_ => panic!("expected inline blocks"),
|
||||
};
|
||||
assert!(
|
||||
!inline.is_empty(),
|
||||
"import event inline blocks must not be empty"
|
||||
);
|
||||
|
||||
let have_commit = inline.iter().any(|b| {
|
||||
let cid = Cid::read_bytes(b.cid_bytes.as_slice()).unwrap();
|
||||
last.commit_cid
|
||||
.as_ref()
|
||||
.and_then(|c| c.to_cid())
|
||||
.map(|commit_cid| cid == commit_cid)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
assert!(have_commit, "import event CAR must include commit block");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn firehose_commit_block_bytes_roundtrip_to_same_cid() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
let repos = get_test_repos().await;
|
||||
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
|
||||
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
create_post(&client, &token, &did, "3krt001", "round-trip me").await;
|
||||
let frames = consumer
|
||||
.wait_for_commits(&did, 1, Duration::from_secs(10))
|
||||
.await;
|
||||
let frame = frames.last().expect("frame");
|
||||
|
||||
let (_, block_map) = car_to_blocks(&frame.blocks).await;
|
||||
use sha2::{Digest, Sha256};
|
||||
for (cid, bytes) in &block_map {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
let hash = hasher.finalize();
|
||||
let mh = multihash::Multihash::wrap(0x12, hash.as_slice()).expect("wrap");
|
||||
let recomputed = Cid::new_v1(cid.codec(), mh);
|
||||
assert_eq!(
|
||||
recomputed, *cid,
|
||||
"CAR block {cid} bytes do not hash back to same CID"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn firehose_commit_car_contains_new_record_bytes_for_every_create() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
let repos = get_test_repos().await;
|
||||
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
|
||||
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let writes: Vec<Value> = (0..8)
|
||||
.map(|i| {
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": format!("3krec{:08}", i),
|
||||
"value": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": format!("rec {i}"),
|
||||
"createdAt": now,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.applyWrites",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&token)
|
||||
.json(&json!({ "repo": did, "writes": writes }))
|
||||
.send()
|
||||
.await
|
||||
.expect("applyWrites");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let frames = consumer
|
||||
.wait_for_commits(&did, 1, Duration::from_secs(10))
|
||||
.await;
|
||||
let frame = frames.last().expect("frame");
|
||||
|
||||
let (_, block_map) = car_to_blocks(&frame.blocks).await;
|
||||
for op in &frame.ops {
|
||||
if op.action == RepoAction::Create {
|
||||
let cid = op.cid.expect("create cid");
|
||||
assert!(
|
||||
block_map.contains_key(&cid),
|
||||
"record CID {cid} for path {} missing from CAR",
|
||||
op.path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
mod common;
|
||||
mod mst_verify;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use cid::Cid;
|
||||
use common::*;
|
||||
use jacquard_common::smol_str::SmolStr;
|
||||
use jacquard_repo::commit::Commit;
|
||||
use jacquard_repo::mst::{Mst, VerifiedWriteOp};
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use mst_verify::{extract_event_blocks, inline_to_store};
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use tranquil_db_traits::{RepoEventType, SequenceNumber, SequencedEvent};
|
||||
use tranquil_types::Did;
|
||||
|
||||
const COLLECTIONS: &[&str] = &[
|
||||
"app.bsky.feed.post",
|
||||
"app.bsky.feed.like",
|
||||
"app.bsky.graph.follow",
|
||||
"app.bsky.feed.repost",
|
||||
];
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
enum FuzzOp {
|
||||
Create,
|
||||
Update,
|
||||
Delete,
|
||||
}
|
||||
|
||||
fn pick_op(rng: &mut StdRng, have_keys: bool) -> FuzzOp {
|
||||
match (have_keys, rng.gen_range(0..10)) {
|
||||
(false, _) => FuzzOp::Create,
|
||||
(_, 0..=5) => FuzzOp::Create,
|
||||
(_, 6..=7) => FuzzOp::Update,
|
||||
_ => FuzzOp::Delete,
|
||||
}
|
||||
}
|
||||
|
||||
fn random_rkey(rng: &mut StdRng) -> String {
|
||||
let tid_char_pool = b"234567abcdefghijklmnopqrstuvwxyz";
|
||||
let mut out = Vec::with_capacity(13);
|
||||
(0..13).for_each(|_| {
|
||||
let c = tid_char_pool[rng.gen_range(0..tid_char_pool.len())];
|
||||
out.push(c);
|
||||
});
|
||||
String::from_utf8(out).unwrap()
|
||||
}
|
||||
|
||||
fn random_collection(rng: &mut StdRng) -> &'static str {
|
||||
COLLECTIONS[rng.gen_range(0..COLLECTIONS.len())]
|
||||
}
|
||||
|
||||
fn record_for_collection(col: &str, text: &str, now: &str) -> Value {
|
||||
match col {
|
||||
"app.bsky.feed.post" | "app.bsky.feed.repost" | "app.bsky.feed.like" => json!({
|
||||
"$type": col,
|
||||
"text": text,
|
||||
"createdAt": now,
|
||||
}),
|
||||
_ => json!({
|
||||
"$type": col,
|
||||
"subject": format!("did:plc:synthetic{text}"),
|
||||
"createdAt": now,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_commit_forward_and_inverse(event: &SequencedEvent) -> Result<(), String> {
|
||||
let prev_data = event
|
||||
.prev_data_cid
|
||||
.as_ref()
|
||||
.and_then(|c| c.to_cid())
|
||||
.ok_or("no prev_data_cid")?;
|
||||
let commit_cid = event
|
||||
.commit_cid
|
||||
.as_ref()
|
||||
.and_then(|c| c.to_cid())
|
||||
.ok_or("no commit_cid")?;
|
||||
let inline = extract_event_blocks(event)?;
|
||||
let ops = event
|
||||
.ops
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or("ops not array")?;
|
||||
|
||||
let storage = inline_to_store(inline);
|
||||
let commit_bytes = storage
|
||||
.get(&commit_cid)
|
||||
.await
|
||||
.map_err(|e| format!("get commit: {e:?}"))?
|
||||
.ok_or("missing commit block")?;
|
||||
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| format!("parse commit: {e:?}"))?;
|
||||
let new_data = *commit.data();
|
||||
|
||||
let mut forward = Mst::load(storage.clone(), prev_data, None);
|
||||
for op in ops {
|
||||
let action = op["action"].as_str().ok_or("op.action")?;
|
||||
let path = op["path"].as_str().ok_or("op.path")?;
|
||||
match action {
|
||||
"create" | "update" => {
|
||||
let cid = Cid::from_str(op["cid"].as_str().ok_or("op.cid")?)
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
forward = forward
|
||||
.add(path, cid)
|
||||
.await
|
||||
.map_err(|e| format!("fwd add {path}: {e:?}"))?;
|
||||
}
|
||||
"delete" => {
|
||||
forward = forward
|
||||
.delete(path)
|
||||
.await
|
||||
.map_err(|e| format!("fwd delete {path}: {e:?}"))?;
|
||||
}
|
||||
other => return Err(format!("unknown action {other}")),
|
||||
}
|
||||
}
|
||||
let got = forward
|
||||
.persist()
|
||||
.await
|
||||
.map_err(|e| format!("persist: {e:?}"))?;
|
||||
if got != new_data {
|
||||
return Err(format!("forward root mismatch exp={new_data} got={got}"));
|
||||
}
|
||||
|
||||
let mut inverse = Mst::load(storage, new_data, None);
|
||||
for op in ops {
|
||||
let action = op["action"].as_str().ok_or("op.action")?;
|
||||
let path = op["path"].as_str().ok_or("op.path")?;
|
||||
let key = SmolStr::new(path);
|
||||
let verified = match action {
|
||||
"create" => {
|
||||
let cid = Cid::from_str(op["cid"].as_str().ok_or("op.cid")?)
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
VerifiedWriteOp::Create { key, cid }
|
||||
}
|
||||
"update" => {
|
||||
let cid = Cid::from_str(op["cid"].as_str().ok_or("op.cid")?)
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let prev = Cid::from_str(op["prev"].as_str().ok_or("op.prev")?)
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
VerifiedWriteOp::Update { key, cid, prev }
|
||||
}
|
||||
"delete" => {
|
||||
let prev = Cid::from_str(op["prev"].as_str().ok_or("op.prev")?)
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
VerifiedWriteOp::Delete { key, prev }
|
||||
}
|
||||
other => return Err(format!("unknown action {other}")),
|
||||
};
|
||||
let inverted = inverse
|
||||
.invert_op(verified.clone())
|
||||
.await
|
||||
.map_err(|e| format!("invert {verified:?}: {e:?}"))?;
|
||||
if !inverted {
|
||||
return Err(format!("op not invertible: {verified:?}"));
|
||||
}
|
||||
}
|
||||
let got_prev = inverse
|
||||
.get_pointer()
|
||||
.await
|
||||
.map_err(|e| format!("get_pointer: {e:?}"))?;
|
||||
if got_prev != prev_data {
|
||||
return Err(format!(
|
||||
"inverse root mismatch exp={prev_data} got={got_prev}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fuzz_run_with_seed(seed: u64, steps: usize) -> Vec<String> {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
|
||||
let mut live_keys: HashMap<String, String> = HashMap::new();
|
||||
|
||||
for step in 0..steps {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let op = pick_op(&mut rng, !live_keys.is_empty());
|
||||
match op {
|
||||
FuzzOp::Create => {
|
||||
let col = random_collection(&mut rng);
|
||||
let rkey = random_rkey(&mut rng);
|
||||
let path = format!("{col}/{rkey}");
|
||||
if live_keys.contains_key(&path) {
|
||||
continue;
|
||||
}
|
||||
let record = record_for_collection(col, &format!("s{seed}-n{step}"), &now);
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.createRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&token)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": col,
|
||||
"rkey": rkey,
|
||||
"record": record,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("createRecord");
|
||||
if res.status() == StatusCode::OK {
|
||||
live_keys.insert(path, col.to_string());
|
||||
}
|
||||
}
|
||||
FuzzOp::Update => {
|
||||
let keys: Vec<&String> = live_keys.keys().collect();
|
||||
if keys.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let path = keys[rng.gen_range(0..keys.len())].clone();
|
||||
let col = live_keys.get(&path).unwrap().clone();
|
||||
let rkey = path.split('/').nth(1).unwrap().to_string();
|
||||
let record = record_for_collection(&col, &format!("s{seed}-u{step}"), &now);
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.putRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&token)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": col,
|
||||
"rkey": rkey,
|
||||
"record": record,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("putRecord");
|
||||
assert_eq!(res.status(), StatusCode::OK, "putRecord failed");
|
||||
}
|
||||
FuzzOp::Delete => {
|
||||
let keys: Vec<String> = live_keys.keys().cloned().collect();
|
||||
if keys.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let path = keys[rng.gen_range(0..keys.len())].clone();
|
||||
let col = live_keys.get(&path).unwrap().clone();
|
||||
let rkey = path.split('/').nth(1).unwrap().to_string();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.deleteRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&token)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": col,
|
||||
"rkey": rkey,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("deleteRecord");
|
||||
if res.status() == StatusCode::OK {
|
||||
live_keys.remove(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let repos = get_test_repos().await;
|
||||
let typed_did = Did::new(did.clone()).unwrap();
|
||||
let events = repos
|
||||
.repo
|
||||
.get_events_since_seq(SequenceNumber::ZERO, None)
|
||||
.await
|
||||
.expect("get_events_since_seq");
|
||||
|
||||
let our: Vec<SequencedEvent> = events
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
e.did == typed_did
|
||||
&& e.event_type == RepoEventType::Commit
|
||||
&& e.prev_data_cid.is_some()
|
||||
&& e.ops
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_array())
|
||||
.is_some_and(|a| !a.is_empty())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for event in &our {
|
||||
if let Err(msg) = verify_commit_forward_and_inverse(event).await {
|
||||
failures.push(format!(
|
||||
"seed={seed} seq={} ops={:?}: {msg}",
|
||||
event.seq.as_i64(),
|
||||
event
|
||||
.ops
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
));
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mst_property_fuzz_seed_1() {
|
||||
let failures = fuzz_run_with_seed(1, 150).await;
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"fuzz seed=1 found {} invalid commits:\n - {}",
|
||||
failures.len(),
|
||||
failures.join("\n - ")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mst_property_fuzz_seed_42() {
|
||||
let failures = fuzz_run_with_seed(42, 150).await;
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"fuzz seed=42 found {} invalid commits:\n - {}",
|
||||
failures.len(),
|
||||
failures.join("\n - ")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mst_property_fuzz_seed_9001() {
|
||||
let failures = fuzz_run_with_seed(9001, 150).await;
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"fuzz seed=9001 found {} invalid commits:\n - {}",
|
||||
failures.len(),
|
||||
failures.join("\n - ")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mst_property_fuzz_deep_tree_seed_7() {
|
||||
let failures = fuzz_run_with_seed(7, 400).await;
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"fuzz deep seed=7 found {} invalid commits:\n - {}",
|
||||
failures.len(),
|
||||
failures.join("\n - ")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use jacquard_repo::storage::MemoryBlockStore;
|
||||
use tranquil_db_traits::{EventBlockInline, EventBlocks, SequencedEvent};
|
||||
|
||||
pub fn extract_event_blocks(event: &SequencedEvent) -> Result<&[EventBlockInline], String> {
|
||||
match event.blocks.as_ref() {
|
||||
Some(EventBlocks::Inline(v)) => Ok(v.as_slice()),
|
||||
Some(EventBlocks::LegacyCids(_)) => Err("legacy cids, not inline".into()),
|
||||
None => Err("event missing blocks".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inline_to_store(inline: &[EventBlockInline]) -> Arc<MemoryBlockStore> {
|
||||
let map: BTreeMap<Cid, Bytes> = inline
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let cid = Cid::read_bytes(b.cid_bytes.as_slice()).expect("valid cid bytes");
|
||||
(cid, Bytes::from(b.data.clone()))
|
||||
})
|
||||
.collect();
|
||||
Arc::new(MemoryBlockStore::new_from_blocks(map))
|
||||
}
|
||||
@@ -187,6 +187,37 @@ async fn test_par_and_authorize() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_par_public_client_empty_assertion_fields() {
|
||||
let url = base_url().await;
|
||||
let client = client();
|
||||
let redirect_uri = "https://nels.evil.oauth.pet/callback";
|
||||
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
||||
let client_id = mock_client.uri();
|
||||
let (_, code_challenge) = generate_pkce();
|
||||
let par_res = client
|
||||
.post(format!("{}/oauth/par", url))
|
||||
.form(&[
|
||||
("response_type", "code"),
|
||||
("client_id", &client_id),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_challenge", &code_challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
("scope", "atproto"),
|
||||
("state", "test-state"),
|
||||
("client_assertion", ""),
|
||||
("client_assertion_type", ""),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
par_res.status(),
|
||||
StatusCode::CREATED,
|
||||
"PAR with empty assertion fields from a public client should succeed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_oauth_flow() {
|
||||
let url = base_url().await;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||
@@ -88,12 +52,17 @@ fn test_signal_username_validation() {
|
||||
assert!(is_valid_signal_username("bob_smith.99"));
|
||||
assert!(is_valid_signal_username("user123.42"));
|
||||
assert!(is_valid_signal_username("lu1.01"));
|
||||
assert!(is_valid_signal_username("abc.00"));
|
||||
assert!(is_valid_signal_username("a_very_long_username_here.55"));
|
||||
assert!(is_valid_signal_username("alice.123"));
|
||||
assert!(is_valid_signal_username("alice.999999999"));
|
||||
assert!(is_valid_signal_username("alice.18446744073709551615"));
|
||||
|
||||
assert!(!is_valid_signal_username("alice"));
|
||||
assert!(!is_valid_signal_username("alice.1"));
|
||||
assert!(!is_valid_signal_username("alice.001"));
|
||||
assert!(!is_valid_signal_username("abc.00"));
|
||||
assert!(!is_valid_signal_username("alice.0"));
|
||||
assert!(!is_valid_signal_username("alice.999999999999999999999"));
|
||||
assert!(!is_valid_signal_username(".01"));
|
||||
assert!(!is_valid_signal_username("ab.01"));
|
||||
assert!(!is_valid_signal_username(""));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -164,7 +164,10 @@ impl ScopePermissions {
|
||||
if self.has_transition_generic && !self.has_transition_chat {
|
||||
return Err(ScopeError::InsufficientScope {
|
||||
required: "transition:chat.bsky".to_string(),
|
||||
message: format!("Chat access requires transition:chat.bsky scope to call {}", lxm),
|
||||
message: format!(
|
||||
"Chat access requires transition:chat.bsky scope to call {}",
|
||||
lxm
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -33,15 +33,11 @@ impl SignalUsername {
|
||||
pub fn parse(username: &str) -> Result<Self, InvalidSignalUsername> {
|
||||
let reject = || Err(InvalidSignalUsername(username.to_string()));
|
||||
|
||||
if username.len() < 6 || username.len() > 35 {
|
||||
return reject();
|
||||
}
|
||||
|
||||
let Some((base, discriminator)) = username.rsplit_once('.') else {
|
||||
return reject();
|
||||
};
|
||||
|
||||
if base.len() < 3 || base.len() > 32 {
|
||||
if !matches!(base.len(), 3..=32) {
|
||||
return reject();
|
||||
}
|
||||
|
||||
@@ -53,7 +49,7 @@ impl SignalUsername {
|
||||
return reject();
|
||||
}
|
||||
|
||||
if discriminator.len() != 2 || !discriminator.chars().all(|c| c.is_ascii_digit()) {
|
||||
if !is_valid_discriminator(discriminator) {
|
||||
return reject();
|
||||
}
|
||||
|
||||
@@ -65,6 +61,19 @@ impl SignalUsername {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_discriminator(s: &str) -> bool {
|
||||
if !s.chars().all(|c| c.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
if !matches!(s.len(), 2..=20) {
|
||||
return false;
|
||||
}
|
||||
if s.len() > 2 && s.starts_with('0') {
|
||||
return false;
|
||||
}
|
||||
s.parse::<u64>().is_ok_and(|n| n != 0)
|
||||
}
|
||||
|
||||
impl fmt::Display for SignalUsername {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
@@ -113,11 +122,7 @@ pub struct MessageTooLong {
|
||||
|
||||
impl fmt::Display for MessageTooLong {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"message body too long: {} bytes (max {})",
|
||||
self.len, self.max
|
||||
)
|
||||
write!(f, "message body is {} bytes, max {}", self.len, self.max)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,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)
|
||||
@@ -368,13 +373,13 @@ impl SignalClient {
|
||||
let req = tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.cancelled() => {
|
||||
tracing::info!("signal worker shutting down (cancellation)");
|
||||
tracing::info!("signal worker cancelled, shutting down");
|
||||
break;
|
||||
}
|
||||
msg = rx.recv() => match msg {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tracing::info!("signal worker shutting down (channel closed)");
|
||||
tracing::info!("signal worker channel closed, shutting down");
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1050,7 +1050,7 @@ impl ContentsStore for FjallSignalStore {
|
||||
.and_then(|v| match <[u8; 32]>::try_from(v.as_ref()) {
|
||||
Ok(arr) => Some(ProfileKey { bytes: arr }),
|
||||
Err(_) => {
|
||||
warn!(%uuid, len = v.len(), "corrupted profile key (expected 32 bytes)");
|
||||
warn!(%uuid, len = v.len(), "corrupted profile key, expected 32 bytes");
|
||||
None
|
||||
}
|
||||
}))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user