Compare commits

...
27 Commits
Author SHA1 Message Date
lewisandTangled f78b004df3 fix: user handle domains upgrade 2026-03-08 12:25:04 +00:00
nelind e02e8c9e8c fix(config): remove unused duplicate custom handle url config key 2026-03-08 00:05:38 +01:00
lewisandTangled 5450467011 fix: container build should use all-in-one backend+frontend 2026-03-07 21:06:25 +00:00
isabelandTangled 6200697ce0 fix(nix): typo 2026-03-07 19:59:19 +00:00
nelindandTangled 4005041ee2 fix(test): update test_server_basics to reflect new health endpoint format 2026-03-07 15:20:21 +00:00
nelindandTangled 1c75668b18 feat(nix): remove mold from the dev shell and add cargo nextest 2026-03-07 15:20:21 +00:00
lewis 2c470f77a5 fix: lewis shameful mistake on prod compose vol 2026-03-07 17:19:01 +02:00
nelindandTangled 844ba0eb70 refactor(nix): update nix module to use the built-in frontend server 2026-03-06 20:21:10 +00:00
nelindandTangled 34beff2553 feat: add back built-in frontend hosting to the backend 2026-03-06 20:21:10 +00:00
lewisandTangled 898c6a2c6e fix: no 2fa needed if passkey 2026-03-06 09:32:36 +00:00
lewisandTangled dcdef508de fix: first user invite code 2026-03-04 12:27:14 +00:00
pennyandTangled 1e02c5803f feat: add version information to _health endpoint 2026-03-01 21:00:53 +00:00
nelind e2f26c259f fix(sync): dont sequence sync events on *every* repo update 2026-02-28 18:52:41 +01:00
isabelandTangled 4e277eb7b2 fix(nix): use correct openssl command 2026-02-28 11:12:10 +00:00
lewisandTangled 28ca66624a fix: ability to send more verifications 2026-02-24 10:13:11 +00:00
lewisandTangled 3913bf5c1a fix: trusted device save 2026-02-23 10:40:36 +00:00
lewisandTangled dd53262a03 fix: update docs for toml env 2026-02-23 10:36:58 +00:00
nelind 61a60a3163 fix: only include timestamp in the build version if built in debug mode so release stays reproducible 2026-02-22 00:30:43 +01:00
nelindandTangled 09f8040135 fix: minor format cleanup and description fixing in the module 2026-02-21 20:51:03 +00:00
isabelandTangled dfc1ce3ddf refactor(nix): toml conf 2026-02-21 20:51:03 +00:00
lewisandTangled 5964601a11 fix: set env vars of paths for sendmail & signal from nix pkg not manual 2026-02-21 20:51:03 +00:00
lewisandTangled 1521f98b2e feat: actual good config from Isabel 2026-02-21 20:51:03 +00:00
lewisandTangled a7840e2ac5 fix: better defaults, add in pg & nginx 2026-02-21 20:51:03 +00:00
lewisandTangled a2cea49b0f feat: nix module 2026-02-21 20:51:03 +00:00
isabelandTangled 66eb9b7dbb refactor: toml config 2026-02-21 18:00:55 +00:00
lewisandTangled cbd3b79f41 fix: service token case sensitivity regression 2026-02-15 19:54:28 +00:00
lewisandTangled a83a67d219 chore: small md file updates 2026-02-11 16:17:21 +00:00
132 changed files with 4371 additions and 1785 deletions
+8
View File
@@ -21,6 +21,10 @@ heavy-load-tests = { max-threads = 4 }
filter = "test(/import_with_verification/) | test(/plc_migration/)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "binary(handle_domains)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "binary(ripple_cluster)"
test-group = "serial-env-tests"
@@ -41,6 +45,10 @@ test-group = "heavy-load-tests"
filter = "test(/import_with_verification/) | test(/plc_migration/)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "binary(handle_domains)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "binary(ripple_cluster)"
test-group = "serial-env-tests"
-227
View File
@@ -1,227 +0,0 @@
# =============================================================================
# Server
# =============================================================================
SERVER_HOST=127.0.0.1
SERVER_PORT=3000
# The public-facing hostname of the PDS (used in DID documents, JWTs, etc.)
PDS_HOSTNAME=localhost:3000
# =============================================================================
# Database
# =============================================================================
DATABASE_URL=postgres://postgres:postgres@localhost:5432/pds
# Connection pool settings (defaults are good for most deployments)
# DATABASE_MAX_CONNECTIONS=100
# DATABASE_MIN_CONNECTIONS=10
# DATABASE_ACQUIRE_TIMEOUT_SECS=30
# =============================================================================
# Blob Storage
# =============================================================================
# Backend: "filesystem" (default) or "s3"
# BLOB_STORAGE_BACKEND=filesystem
# For filesystem backend:
BLOB_STORAGE_PATH=/var/lib/tranquil/blobs
# For S3 backend:
# S3_ENDPOINT=http://localhost:9000
# AWS_REGION=us-east-1
# S3_BUCKET=pds-blobs
# AWS_ACCESS_KEY_ID=minioadmin
# AWS_SECRET_ACCESS_KEY=minioadmin
# =============================================================================
# Backups
# =============================================================================
# Enable/disable automatic repo backups
# BACKUP_ENABLED=true
# Backend: "filesystem" (default) or "s3"
# BACKUP_STORAGE_BACKEND=filesystem
# For filesystem backend:
BACKUP_STORAGE_PATH=/var/lib/tranquil/backups
# For S3 backend:
# BACKUP_S3_BUCKET=pds-backups
# Backup schedule and retention
# BACKUP_RETENTION_COUNT=7
# BACKUP_INTERVAL_SECS=86400
# =============================================================================
# Cache & Rate Limiting
# =============================================================================
# Ripple (in-process CRDT cache) is the default. No config needed for single-node.
# Set VALKEY_URL to use valkey instead (disables ripple).
# VALKEY_URL=redis://localhost:6379
#
# Ripple multi-node settings (only needed when clustering):
# RIPPLE_BIND=0.0.0.0:7890
# RIPPLE_PEERS=10.0.0.2:7890,10.0.0.3:7890
# RIPPLE_MACHINE_ID=1
# RIPPLE_GOSSIP_INTERVAL_MS=200
# RIPPLE_CACHE_MAX_MB=256
# =============================================================================
# Security Secrets
# =============================================================================
# These MUST be set in production (minimum 32 characters each)
# In development, set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 to use defaults
# Server-wide secret for OAuth token signing (HS256)
# JWT_SECRET=your-secure-random-string-at-least-32-chars
# Secret for DPoP proof validation
# DPOP_SECRET=your-secure-random-string-at-least-32-chars
# Key for encrypting user signing keys at rest (AES-256-GCM)
# MASTER_KEY=your-secure-random-string-at-least-32-chars
# Set this ONLY in development to allow default/weak secrets
# TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1
# =============================================================================
# PLC Directory
# =============================================================================
# PLC_DIRECTORY_URL=https://plc.directory
# PLC_TIMEOUT_SECS=10
# PLC_CONNECT_TIMEOUT_SECS=5
# Optional: rotation key for PLC operations (defaults to user's key)
# PLC_ROTATION_KEY=did:key:...
# =============================================================================
# DID Resolution
# =============================================================================
# Cache TTL for resolved DID documents (default: 300 seconds)
# DID_CACHE_TTL_SECS=300
# =============================================================================
# Relays
# =============================================================================
# Comma-separated list of relay URLs to notify via requestCrawl
# CRAWLERS=https://bsky.network,https://relay.upcloud.world
# =============================================================================
# Firehose (subscribeRepos WebSocket)
# =============================================================================
# Buffer size for firehose broadcast channel
# FIREHOSE_BUFFER_SIZE=10000
# Disconnect slow consumers after this many events of lag
# FIREHOSE_MAX_LAG=5000
# =============================================================================
# Notification Service
# =============================================================================
# Queue processing settings
# NOTIFICATION_BATCH_SIZE=100
# NOTIFICATION_POLL_INTERVAL_MS=1000
# Email notifications (via sendmail/msmtp)
# MAIL_FROM_ADDRESS=noreply@example.com
# MAIL_FROM_NAME=My PDS
# SENDMAIL_PATH=/usr/sbin/sendmail
# Discord notifications (via bot DM)
# DISCORD_BOT_TOKEN=bot-token
# Telegram notifications (via bot)
# TELEGRAM_BOT_TOKEN=bot-token
# TELEGRAM_WEBHOOK_SECRET=random-secret
# Signal notifications (via signal-cli)
# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
# SIGNAL_SENDER_NUMBER=+1234567890
# =============================================================================
# Upload Limits
# =============================================================================
# Maximum blob/body size in bytes (default: 10GB)
# This controls both the Axum body limit and blob upload limits.
# Make sure your nginx client_max_body_size matches or exceeds this value.
# MAX_BLOB_SIZE=10737418240
# =============================================================================
# Repository Import
# =============================================================================
# Set to "true" to accept repository imports
# ACCEPTING_REPO_IMPORTS=false
# Maximum import size in bytes (default: 100MB)
# MAX_IMPORT_SIZE=104857600
# Maximum blocks per import (default: 100000)
# MAX_IMPORT_BLOCKS=100000
# Skip verification during import (testing only)
# SKIP_IMPORT_VERIFICATION=false
# =============================================================================
# Account Registration
# =============================================================================
# Require invite codes for registration
# INVITE_CODE_REQUIRED=false
# Comma-separated list of available user domains
# AVAILABLE_USER_DOMAINS=example.com
# Enable self-hosted did:web identities (default: true)
# Hosting did:web requires a long-term commitment to serve DID documents.
# Set to false if you don't want to offer this option.
# ENABLE_SELF_HOSTED_DID_WEB=true
# =============================================================================
# Server Metadata (returned by describeServer)
# =============================================================================
# Privacy policy URL (optional)
# PRIVACY_POLICY_URL=https://example.com/privacy
# Terms of service URL (optional)
# TERMS_OF_SERVICE_URL=https://example.com/terms
# Contact email address (optional)
# CONTACT_EMAIL=admin@example.com
# =============================================================================
# Rate Limiting
# =============================================================================
# Disable all rate limiting (testing only, NEVER in production)
# DISABLE_RATE_LIMITING=1
# =============================================================================
# Account Deletion
# =============================================================================
# How often to check for scheduled account deletions (default: 3600 = 1 hour)
# SCHEDULED_DELETE_CHECK_INTERVAL_SECS=3600
# =============================================================================
# Moderation / Report Service
# =============================================================================
# If configured, moderation reports will be proxied to this service
# instead of being stored locally. The service should implement the
# com.atproto.moderation.createReport endpoint (eg., Bluesky's Ozone).
# Both URL and DID must be set for proxying to be enabled.
# REPORT_SERVICE_URL=https://mod.bsky.app
# REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
# =============================================================================
# Age Assurance Override
# =============================================================================
# Enable this if you have separately assured the ages of your users
# (eg., through your own age verification process). When enabled, the PDS
# will return "assured" status for age assurance checks instead of proxying
# to the appview. This helps migrated users avoid the age assurance
# catch-22 on bsky.app.
# PDS_AGE_ASSURANCE_OVERRIDE=1
# =============================================================================
# Miscellaneous
# =============================================================================
# Allow HTTP for proxy requests (development only)
# ALLOW_HTTP_PROXY=1
# =============================================================================
# SSO / Social Login
# =============================================================================
# Each provider requires ENABLED=true plus CLIENT_ID and CLIENT_SECRET.
# Register your PDS as an OAuth application with each provider to get credentials.
# GitHub
# SSO_GITHUB_ENABLED=true
# SSO_GITHUB_CLIENT_ID=
# SSO_GITHUB_CLIENT_SECRET=
# Discord
# SSO_DISCORD_ENABLED=true
# SSO_DISCORD_CLIENT_ID=
# SSO_DISCORD_CLIENT_SECRET=
# Google
# SSO_GOOGLE_ENABLED=true
# SSO_GOOGLE_CLIENT_ID=
# SSO_GOOGLE_CLIENT_SECRET=
# GitLab (set ISSUER for self-hosted instances)
# SSO_GITLAB_ENABLED=false
# SSO_GITLAB_CLIENT_ID=
# SSO_GITLAB_CLIENT_SECRET=
# SSO_GITLAB_ISSUER=https://gitlab.com
# Generic OIDC
# SSO_OIDC_ENABLED=false
# SSO_OIDC_CLIENT_ID=
# SSO_OIDC_CLIENT_SECRET=
# SSO_OIDC_ISSUER=https://your-identity-provider.com
# SSO_OIDC_NAME=Custom Provider
# Apple Sign-in
# SSO_APPLE_ENABLED=true
# SSO_APPLE_CLIENT_ID=com.example.signin # Services ID from Apple Developer Portal
# SSO_APPLE_TEAM_ID=XXXXXXXXXX # 10-character Team ID
# SSO_APPLE_KEY_ID=XXXXXXXXXX # Key ID from portal
# SSO_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
CARGO_MOMMYS_LITTLE=mister
CARGO_MOMMYS_PRONOUNS=his
CARGO_MOMMYS_ROLES=daddy
CARGO_MOMMYS_EMOTES="🚛/🧱/🚜/🔩/🦺"
CARGO_MOMMYS_MOODS=ominous
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "preferred_comms_channel: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
},
{
"ordinal": 3,
"name": "recovery_token",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "recovery_token_expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea"
}
@@ -1,40 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "recovery_token",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "recovery_token_expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true,
true
]
},
"hash": "a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE oauth_authorization_request\n SET expires_at = $2\n WHERE id = $1 AND did IS NOT NULL AND code IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "bd5861c7ed2021d025e78d63ef6a35b2bb07d2c11f88f3945fbcf099b9c7c1cf"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "password_reset_code_expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "e3e4b6131b7692edf87fcf3b67b59127d3a218afb7a34a4bcb3c56765f8cd4c6"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "preferred_comms_channel: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
},
{
"ordinal": 3,
"name": "password_reset_code_expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "eb3029b84fb58576a94987da1984cbe152f5ee7aa55b2b3f678603fe1e1c906b"
}
Generated
+252 -17
View File
@@ -104,6 +104,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.100"
@@ -1216,6 +1266,46 @@ dependencies = [
"inout",
]
[[package]]
name = "clap"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "cmake"
version = "0.1.57"
@@ -1240,6 +1330,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "combine"
version = "4.6.7"
@@ -1280,6 +1376,29 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "confique"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06b4f5ec222421e22bb0a8cbaa36b1d2b50fd45cdd30c915ded34108da78b29f"
dependencies = [
"confique-macro",
"serde",
"toml",
]
[[package]]
name = "confique-macro"
version = "0.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d1754680cd218e7bcb4c960cc9bae3444b5197d64563dccccfdf83cab9e1a7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -2624,6 +2743,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -2762,7 +2887,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.2",
"system-configuration",
"tokio",
"tower-service",
@@ -3059,6 +3184,12 @@ dependencies = [
"unsigned-varint 0.7.2",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
@@ -3488,6 +3619,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -3715,6 +3856,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
@@ -4209,7 +4356,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.35",
"socket2 0.5.10",
"socket2 0.6.2",
"thiserror 2.0.17",
"tokio",
"tracing",
@@ -4246,7 +4393,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
]
@@ -4909,6 +5056,15 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "serde_spanned"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -5708,6 +5864,45 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap 2.12.1",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.0.7+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "tonic"
version = "0.14.2"
@@ -5797,13 +5992,19 @@ dependencies = [
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -5893,7 +6094,7 @@ dependencies = [
[[package]]
name = "tranquil-auth"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"anyhow",
"base32",
@@ -5908,6 +6109,7 @@ dependencies = [
"sha2",
"subtle",
"totp-rs",
"tranquil-config",
"tranquil-crypto",
"urlencoding",
"uuid",
@@ -5915,20 +6117,21 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"redis",
"tokio-util",
"tracing",
"tranquil-config",
"tranquil-infra",
"tranquil-ripple",
]
[[package]]
name = "tranquil-comms"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5936,13 +6139,22 @@ dependencies = [
"serde_json",
"thiserror 2.0.17",
"tokio",
"tranquil-config",
"tranquil-db-traits",
"uuid",
]
[[package]]
name = "tranquil-config"
version = "0.3.0"
dependencies = [
"confique",
"serde",
]
[[package]]
name = "tranquil-crypto"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -5958,7 +6170,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"chrono",
@@ -5975,7 +6187,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5991,17 +6203,18 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"bytes",
"futures",
"thiserror 2.0.17",
"tranquil-config",
]
[[package]]
name = "tranquil-oauth"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"anyhow",
"axum",
@@ -6024,7 +6237,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"aes-gcm",
"anyhow",
@@ -6041,6 +6254,7 @@ dependencies = [
"chrono",
"ciborium",
"cid",
"clap",
"ctor",
"dotenvy",
"ed25519-dalek",
@@ -6091,6 +6305,7 @@ dependencies = [
"tranquil-auth",
"tranquil-cache",
"tranquil-comms",
"tranquil-config",
"tranquil-crypto",
"tranquil-db",
"tranquil-db-traits",
@@ -6109,7 +6324,7 @@ dependencies = [
[[package]]
name = "tranquil-repo"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"bytes",
"cid",
@@ -6121,7 +6336,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"backon",
@@ -6139,13 +6354,14 @@ dependencies = [
"tokio-util",
"tracing",
"tracing-subscriber",
"tranquil-config",
"tranquil-infra",
"uuid",
]
[[package]]
name = "tranquil-scopes"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"axum",
"futures",
@@ -6161,7 +6377,7 @@ dependencies = [
[[package]]
name = "tranquil-storage"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"aws-config",
@@ -6171,13 +6387,14 @@ dependencies = [
"sha2",
"tokio",
"tracing",
"tranquil-config",
"tranquil-infra",
"uuid",
]
[[package]]
name = "tranquil-types"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"chrono",
"cid",
@@ -6219,6 +6436,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -6356,6 +6579,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.19.0"
@@ -6930,6 +7159,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
[[package]]
name = "winreg"
version = "0.50.0"
+6 -2
View File
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"crates/tranquil-types",
"crates/tranquil-config",
"crates/tranquil-infra",
"crates/tranquil-crypto",
"crates/tranquil-storage",
@@ -18,12 +19,13 @@ members = [
]
[workspace.package]
version = "0.2.1"
version = "0.3.0"
edition = "2024"
license = "AGPL-3.0-or-later"
[workspace.dependencies]
tranquil-types = { path = "crates/tranquil-types" }
tranquil-config = { path = "crates/tranquil-config" }
tranquil-infra = { path = "crates/tranquil-infra" }
tranquil-crypto = { path = "crates/tranquil-crypto" }
tranquil-storage = { path = "crates/tranquil-storage" }
@@ -52,6 +54,8 @@ bs58 = "0.5"
bytes = "1.11"
chrono = { version = "0.4", features = ["serde"] }
cid = "0.11"
clap = { version = "4", features = ["derive", "env"] }
confique = { version = "0.4", features = ["toml"] }
dotenvy = "0.15"
ed25519-dalek = { version = "2.1", features = ["pkcs8"] }
foca = { version = "1", features = ["bincode-codec", "tracing"] }
@@ -96,7 +100,7 @@ tokio-util = "0.7.18"
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
totp-rs = { version = "5", features = ["qr"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
tower-http = { version = "0.6", features = ["fs", "cors"] }
tower-layer = "0.3"
tracing = "0.1"
tracing-subscriber = "0.3"
+6
View File
@@ -1,3 +1,8 @@
FROM denoland/deno:alpine AS frontend
WORKDIR /app
COPY frontend/ ./
RUN deno task build
FROM rust:1.92-alpine AS builder
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static
WORKDIR /app
@@ -18,6 +23,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
FROM alpine:3.23
RUN apk add --no-cache msmtp ca-certificates && ln -sf /usr/bin/msmtp /usr/sbin/sendmail
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
+14 -15
View File
@@ -6,27 +6,28 @@ Bluesky runs on a federated protocol called AT Protocol. Your account lives on a
This particular PDS thrives under harsh conditions. It is a dandelion growing through the cracks in the sidewalk concrete.
It has full compatibility with Bluesky's reference PDS: same endpoints, same behavior, same client compatibility. Everything works: repo operations, blob storage, firehose, OAuth, handle resolution, account migration, the lot.
Another excellent PDS is [Cocoon](https://tangled.org/hailey.at/cocoon), written in go.
It has full compatibility with Bluesky's reference PDS.
## What's different about Tranquil PDS
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), SSO login and signup, did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), automatic backups (configurable retention and frequency, one-click restore), and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), SSO login and signup, did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, repo browsing, and admin.
The PDS itself is a single small binary with no node/npm runtime. It requires postgres. Blobs are stored on the local filesystem by default (S3 optional). Valkey is optional (supported as an alternative to the built-in cache).
The PDS itself is a single binary with no nodeJS runtime. However, at time of writing, Tranquil requires postgres running separately. Blobs are stored on the local filesystem by default (S3 optional). Valkey is also optional (as an alternative to the built-in cache).
## Quick Start
```bash
cp .env.example .env
podman compose up -d
cp example.toml config.toml
podman compose up db -d
just run
```
## Configuration
See `.env.example` for all configuration options.
See `example.toml` for all configuration options.
> [!NOTE]
> The order of configuration precedence is: environment variables, then a config file passed via `--config`, then `/etc/tranquil-pds/config.toml`, then the built-in defaults. So you can use environment variables, or a config file, or both.
## Development
@@ -41,20 +42,18 @@ just lint
### Quick Deploy (Docker/Podman Compose)
Edit `.env.prod` with your values. Generate secrets with `openssl rand -base64 48`.
Edit `config.toml` with your values. Generate secrets with `openssl rand -base64 48`.
```bash
cp .env.prod.example .env.prod
cp example.toml config.toml
podman-compose -f docker-compose.prod.yaml up -d
```
### Installation Guides
| Guide | Best For |
|-------|----------|
| [Debian](docs/install-debian.md) | Debian 13+ with systemd |
| [Containers](docs/install-containers.md) | Podman with quadlets or OpenRC |
| [Kubernetes](docs/install-kubernetes.md) | You know what you're doing |
- [Debian](docs/install-debian.md)
- [Containers](docs/install-containers.md)
- [Kubernetes](docs/install-kubernetes.md)
## Maintainers to ping
-128
View File
@@ -1,128 +0,0 @@
# Lewis' Big Boy TODO list
## Active development
### Storage backend abstraction
Make storage layers swappable via traits.
sqlite database backend
- [ ] abstract db layer behind trait (queries, transactions, migrations)
- [ ] sqlite implementation matching postgres behavior
- [ ] handle sqlite's single-writer limitation (connection pooling strategy)
- [ ] migrations system that works for both
- [ ] testing: run full test suite against both backends
- [ ] config option to choose backend (postgres vs sqlite)
- [ ] document tradeoffs (sqlite for single-user/small, postgres for multi-user/scale)
- [ ] skip sqlite and just straight-up do our own db?!
### Plugin system
WASM component model plugins. Compile to wasm32-wasip2, sandboxed via wasmtime, capability-gated. Based on zed's extensions.
WIT interface
- [ ] record hooks before/after create, update, delete
- [ ] blob hooks before/after upload, validate
- [ ] xrpc hooks before/after (middleware), custom endpoint handler
- [ ] firehose hook on_commit
- [ ] host imports http client, kv store, logging, read records
wasmtime host
- [ ] engine with epoch interruption (kill runaway plugins)
- [ ] plugin manifest (plugin.toml): id, version, capabilities, hooks
- [ ] capability enforcement at runtime
- [ ] plugin loader, lifecycle (enable/disable/reload)
- [ ] resource limits (memory, time)
- [ ] per-plugin fs sandbox
capabilities
- [ ] http:fetch with domain allowlist
- [ ] kv:read, kv:write
- [ ] record:read, blob:read
- [ ] xrpc:register
- [ ] firehose:subscribe
pds-plugin-api (rust), MVP for plugin system
- [ ] plugin trait with default impls
- [ ] register_plugin! macro
- [ ] typed host import wrappers
- [ ] publish to crates.io
- [ ] docs + example
pds-plugin-api in golang, nice to have after the fact
- [ ] wit-bindgen-go bindings
- [ ] go wrappers
- [ ] tinygo build instructions
- [ ] example
@pds/plugin-api in typescript, nice to have after the fact
- [ ] jco/componentize-js bindings
- [ ] typeScript types
- [ ] build tooling
- [ ] example
example plugins
- [ ] content filter
- [ ] webhook notifier
- [ ] objsto backup mirror
- [ ] custom lexicon handler
- [ ] better audit logger
### Misc
cross-pds delegation
when a client (eg. tangled.org) tries to log into a delegated account:
- [ ] client starts oauth flow to delegated account's pds
- [ ] delegated pds sees account is externally controlled, launches oauth to controller's pds (delegated pds acts as oauth client)
- [ ] controller authenticates at their own pds
- [ ] delegated pds verifies controller perms and scope from its local delegation grants
- [ ] delegated pds issues session to client within the intersection of controller's granted scope and client's requested scope
per-request "act as"
- [ ] authed as user X, perform action as delegated user Y in single request
- [ ] approach decision
- [ ] option 1: `X-Act-As` header with target did, server verifies delegation grant
- [ ] option 2: token exchange (RFC 8693) for short-lived delegated token
- [ ] option 3 (lewis fav): extend existing `act` claim to support on-demand minting
- [ ] something else?
### Private/encrypted data
Records only authorized parties can see and decrypt.
research
- [ ] survey atproto discourse on private data
- [ ] document bluesky team's likely approach. wait.. are they even gonna do this? whatever
- [ ] look at matrix/signal for federated e2ee patterns
key management
- [ ] db schema for encryption keys (user_keys, key_grants, key_rotations)
- [ ] per-user encryption keypair generation (separate from signing keys)
- [ ] key derivation scheme (per-collection? per-record? both?)
- [ ] key storage (encrypted at rest, hsm option?)
- [ ] rotation and revocation flow
storage layer
- [ ] encrypted record format (encrypted cbor blob + metadata)
- [ ] collection-level vs per-record encryption flag
- [ ] how encrypted records appear in mst (hash of ciphertext? separate tree?)
- [ ] blob encryption (same keys? separate?)
api surface
- [ ] xrpc getPublicKey, grantAccess, revokeAccess, listGrants
- [ ] xrpc getEncryptedRecord (ciphertext for client-side decrypt)
- [ ] or transparent server-side decrypt if requester has grant?
- [ ] lexicon for key grant records
sync/federation
- [ ] how encrypted records appear on firehose (ciphertext? omitted? placeholder?)
- [ ] pds-to-pds key exchange protocol
- [ ] appview behavior (can't index without grants)
- [ ] relay behavior with encrypted commits
client integration
- [ ] client-side encryption (pds never sees plaintext) vs server-side with trust
- [ ] key backup/recovery (lose key = lose data)
plugin hooks (once core exists)
- [ ] on_access_grant_request for custom authorization
- [ ] on_key_rotation to notify interested parties
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
tranquil-config = { workspace = true }
tranquil-crypto = { workspace = true }
anyhow = { workspace = true }
+6 -2
View File
@@ -127,7 +127,9 @@ fn create_signed_token_with_act(
let jti = uuid::Uuid::new_v4().to_string();
let aud_hostname = hostname.map(|h| h.to_string()).unwrap_or_else(|| {
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
tranquil_config::try_get()
.map(|c| c.server.hostname.clone())
.unwrap_or_else(|| "localhost".to_string())
});
let claims = Claims {
@@ -253,7 +255,9 @@ fn create_hs256_token_with_metadata(
sub: did.to_owned(),
aud: format!(
"did:web:{}",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
tranquil_config::try_get()
.map(|c| c.server.hostname.clone())
.unwrap_or_else(|| "localhost".to_string())
),
exp: expiration,
iat: Utc::now().timestamp(),
+50 -2
View File
@@ -30,7 +30,7 @@ impl FromStr for TokenType {
type Err = TokenTypeParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
match s.to_ascii_lowercase().as_str() {
"at+jwt" => Ok(Self::Access),
"refresh+jwt" => Ok(Self::Refresh),
"jwt" => Ok(Self::Service),
@@ -88,7 +88,7 @@ impl FromStr for SigningAlgorithm {
type Err = SigningAlgorithmParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
match s.to_ascii_uppercase().as_str() {
"ES256K" => Ok(Self::ES256K),
"HS256" => Ok(Self::HS256),
_ => Err(SigningAlgorithmParseError(s.to_string())),
@@ -258,3 +258,51 @@ impl fmt::Display for TokenVerifyError {
}
impl std::error::Error for TokenVerifyError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_type_accepts_bluesky_uppercase_jwt() {
let result: Result<Header, _> = serde_json::from_str(r#"{"alg":"ES256K","typ":"JWT"}"#);
let header = result.expect("should parse uppercase JWT from bluesky reference pds");
assert_eq!(header.typ, TokenType::Service);
assert_eq!(header.alg, SigningAlgorithm::ES256K);
}
#[test]
fn token_type_accepts_lowercase_jwt() {
let result: Result<Header, _> = serde_json::from_str(r#"{"alg":"ES256K","typ":"jwt"}"#);
let header = result.expect("should parse lowercase jwt");
assert_eq!(header.typ, TokenType::Service);
}
#[test]
fn token_type_accepts_mixed_case_access() {
assert_eq!(TokenType::from_str("AT+JWT").unwrap(), TokenType::Access);
assert_eq!(TokenType::from_str("at+jwt").unwrap(), TokenType::Access);
assert_eq!(TokenType::from_str("At+Jwt").unwrap(), TokenType::Access);
}
#[test]
fn token_type_rejects_unknown() {
assert!(TokenType::from_str("bearer").is_err());
}
#[test]
fn signing_algorithm_case_insensitive() {
assert_eq!(
SigningAlgorithm::from_str("ES256K").unwrap(),
SigningAlgorithm::ES256K
);
assert_eq!(
SigningAlgorithm::from_str("es256k").unwrap(),
SigningAlgorithm::ES256K
);
assert_eq!(
SigningAlgorithm::from_str("hs256").unwrap(),
SigningAlgorithm::HS256
);
}
}
+1
View File
@@ -9,6 +9,7 @@ default = []
valkey = ["dep:redis"]
[dependencies]
tranquil-config = { workspace = true }
tranquil-infra = { workspace = true }
tranquil-ripple = { workspace = true }
+22 -14
View File
@@ -172,28 +172,36 @@ impl DistributedRateLimiter for NoOpRateLimiter {
pub async fn create_cache(
shutdown: tokio_util::sync::CancellationToken,
) -> (Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>) {
let cache_cfg = tranquil_config::try_get().map(|c| &c.cache);
let backend = cache_cfg.map(|c| c.backend.as_str()).unwrap_or("ripple");
let valkey_url = cache_cfg.and_then(|c| c.valkey_url.as_deref());
#[cfg(feature = "valkey")]
if let Ok(url) = std::env::var("VALKEY_URL") {
match ValkeyCache::new(&url).await {
Ok(cache) => {
tracing::info!("using valkey cache at {url}");
let rate_limiter = Arc::new(RedisRateLimiter::new(cache.connection()));
return (Arc::new(cache), rate_limiter);
}
Err(e) => {
tracing::warn!("failed to connect to valkey: {e}. falling back to ripple.");
if backend == "valkey" {
if let Some(url) = valkey_url {
match ValkeyCache::new(url).await {
Ok(cache) => {
tracing::info!("using valkey cache at {url}");
let rate_limiter = Arc::new(RedisRateLimiter::new(cache.connection()));
return (Arc::new(cache), rate_limiter);
}
Err(e) => {
tracing::warn!("failed to connect to valkey: {e}. falling back to ripple.");
}
}
} else {
tracing::warn!("cache.backend is \"valkey\" but VALKEY_URL is not set. using ripple.");
}
}
#[cfg(not(feature = "valkey"))]
if std::env::var("VALKEY_URL").is_ok() {
if backend == "valkey" {
tracing::warn!(
"VALKEY_URL is set but binary was compiled without valkey feature. using ripple."
"cache.backend is \"valkey\" but binary was compiled without valkey feature. using ripple."
);
}
match tranquil_ripple::RippleConfig::from_env() {
match tranquil_ripple::RippleConfig::from_config() {
Ok(config) => {
let peer_count = config.seed_peers.len();
match tranquil_ripple::RippleEngine::start(config, shutdown).await {
@@ -205,13 +213,13 @@ pub async fn create_cache(
(cache, rate_limiter)
}
Err(e) => {
tracing::error!("ripple engine failed to start: {e}. running without cache.");
tracing::error!("ripple engine failed to start: {e:#}. running without cache.");
(Arc::new(NoOpCache), Arc::new(NoOpRateLimiter))
}
}
}
Err(e) => {
tracing::error!("ripple config error: {e}. running without cache.");
tracing::error!("ripple config error: {e:#}. running without cache.");
(Arc::new(NoOpCache), Arc::new(NoOpRateLimiter))
}
}
+2
View File
@@ -5,6 +5,8 @@ edition.workspace = true
license.workspace = true
[dependencies]
tranquil-config = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
reqwest = { workspace = true }
+14 -16
View File
@@ -112,20 +112,19 @@ pub struct EmailSender {
}
impl EmailSender {
pub fn new(from_address: String, from_name: String) -> Self {
pub fn new(from_address: String, from_name: String, sendmail_path: String) -> Self {
Self {
from_address,
from_name,
sendmail_path: std::env::var("SENDMAIL_PATH")
.unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()),
sendmail_path,
}
}
pub fn from_env() -> Option<Self> {
let from_address = std::env::var("MAIL_FROM_ADDRESS").ok()?;
let from_name =
std::env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Tranquil PDS".to_string());
Some(Self::new(from_address, from_name))
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 {
@@ -190,8 +189,8 @@ impl DiscordSender {
}
}
pub fn from_env() -> Option<Self> {
let bot_token = std::env::var("DISCORD_BOT_TOKEN").ok()?;
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let bot_token = cfg.discord.bot_token.clone()?;
Some(Self::new(bot_token))
}
@@ -454,8 +453,8 @@ impl TelegramSender {
}
}
pub fn from_env() -> Option<Self> {
let bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok()?;
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let bot_token = cfg.telegram.bot_token.clone()?;
Some(Self::new(bot_token))
}
@@ -586,10 +585,9 @@ impl SignalSender {
}
}
pub fn from_env() -> Option<Self> {
let signal_cli_path = std::env::var("SIGNAL_CLI_PATH")
.unwrap_or_else(|_| "/usr/local/bin/signal-cli".to_string());
let sender_number = std::env::var("SIGNAL_SENDER_NUMBER").ok()?;
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let signal_cli_path = cfg.signal.cli_path.clone();
let sender_number = cfg.signal.sender_number.clone()?;
Some(Self::new(signal_cli_path, sender_number))
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "tranquil-config"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
confique = { workspace = true }
serde = { workspace = true }
+922
View File
@@ -0,0 +1,922 @@
use confique::Config;
use std::fmt;
use std::path::PathBuf;
use std::sync::OnceLock;
static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
/// Errors discovered during configuration validation.
#[derive(Debug)]
pub struct ConfigError {
pub errors: Vec<String>,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "configuration validation failed:")?;
for err in &self.errors {
writeln!(f, " - {err}")?;
}
Ok(())
}
}
impl std::error::Error for ConfigError {}
/// Initialize the global configuration. Must be called once at startup before
/// any other code accesses the configuration. Panics if called more than once.
pub fn init(config: TranquilConfig) {
CONFIG
.set(config)
.expect("tranquil-config: configuration already initialized");
}
/// Returns a reference to the global configuration.
/// Panics if [`init`] has not been called yet.
pub fn get() -> &'static TranquilConfig {
CONFIG
.get()
.expect("tranquil-config: not initialized - call tranquil_config::init() first")
}
/// Returns a reference to the global configuration if it has been initialized.
pub fn try_get() -> Option<&'static TranquilConfig> {
CONFIG.get()
}
/// Initialize with minimal defaults for unit tests.
/// Noop if already initialized.
pub fn ensure_test_defaults() {
use std::env;
let _ = CONFIG.get_or_init(|| {
unsafe {
if env::var("PDS_HOSTNAME").is_err() {
env::set_var("PDS_HOSTNAME", "test.local");
}
if env::var("DATABASE_URL").is_err() {
env::set_var("DATABASE_URL", "postgres://localhost/test");
}
if env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() {
env::set_var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1");
}
if env::var("INVITE_CODE_REQUIRED").is_err() {
env::set_var("INVITE_CODE_REQUIRED", "false");
}
if env::var("ENABLE_PDS_HOSTED_DID_WEB").is_err() {
env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true");
}
}
TranquilConfig::builder()
.env()
.load()
.expect("failed to load test config defaults")
});
}
/// Load configuration from an optional TOML file path, with environment
/// variable overrides applied on top. Fields annotated with `#[config(env)]`
/// are read from the corresponding environment variables when the `.env()`
/// layer is active.
///
/// Precedence (highest to lowest):
/// 1. Environment variables
/// 2. TOML config file (if provided)
/// 3. Built-in defaults
pub fn load(config_path: Option<&PathBuf>) -> Result<TranquilConfig, confique::Error> {
let mut builder = TranquilConfig::builder().env();
if let Some(path) = config_path {
builder = builder.file(path);
}
builder.file("/etc/tranquil-pds/config.toml").load()
}
// Root configuration
#[derive(Debug, Config)]
pub struct TranquilConfig {
#[config(nested)]
pub server: ServerConfig,
#[config(nested)]
pub frontend: FrontendConfig,
#[config(nested)]
pub database: DatabaseConfig,
#[config(nested)]
pub secrets: SecretsConfig,
#[config(nested)]
pub storage: StorageConfig,
#[config(nested)]
pub backup: BackupConfig,
#[config(nested)]
pub cache: CacheConfig,
#[config(nested)]
pub plc: PlcConfig,
#[config(nested)]
pub firehose: FirehoseConfig,
#[config(nested)]
pub email: EmailConfig,
#[config(nested)]
pub discord: DiscordConfig,
#[config(nested)]
pub telegram: TelegramConfig,
#[config(nested)]
pub signal: SignalConfig,
#[config(nested)]
pub notifications: NotificationConfig,
#[config(nested)]
pub sso: SsoConfig,
#[config(nested)]
pub moderation: ModerationConfig,
#[config(nested)]
pub import: ImportConfig,
#[config(nested)]
pub scheduled: ScheduledConfig,
}
impl TranquilConfig {
/// Validate cross-field constraints that cannot be expressed through
/// confique's declarative defaults alone. Call this once after loading
/// the configuration and before [`init`].
///
/// Returns `Ok(())` when the configuration is consistent, or a
/// [`ConfigError`] listing every problem found.
pub fn validate(&self, ignore_secrets: bool) -> Result<(), ConfigError> {
let mut errors = Vec::new();
// -- secrets ----------------------------------------------------------
if !ignore_secrets && !self.secrets.allow_insecure && !cfg!(test) {
if let Some(ref s) = self.secrets.jwt_secret {
if s.len() < 32 {
errors.push(
"secrets.jwt_secret (JWT_SECRET) must be at least 32 characters"
.to_string(),
);
}
} else {
errors.push(
"secrets.jwt_secret (JWT_SECRET) is required in production \
(set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)"
.to_string(),
);
}
if let Some(ref s) = self.secrets.dpop_secret {
if s.len() < 32 {
errors.push(
"secrets.dpop_secret (DPOP_SECRET) must be at least 32 characters"
.to_string(),
);
}
} else {
errors.push(
"secrets.dpop_secret (DPOP_SECRET) is required in production \
(set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)"
.to_string(),
);
}
if let Some(ref s) = self.secrets.master_key {
if s.len() < 32 {
errors.push(
"secrets.master_key (MASTER_KEY) must be at least 32 characters"
.to_string(),
);
}
} else {
errors.push(
"secrets.master_key (MASTER_KEY) is required in production \
(set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)"
.to_string(),
);
}
}
// -- telegram ---------------------------------------------------------
if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() {
errors.push(
"telegram.bot_token is set but telegram.webhook_secret is missing; \
both are required for secure Telegram integration"
.to_string(),
);
}
// -- blob storage -----------------------------------------------------
match self.storage.backend.as_str() {
"s3" => {
if self.storage.s3_bucket.is_none() {
errors.push(
"storage.backend is \"s3\" but storage.s3_bucket (S3_BUCKET) \
is not set"
.to_string(),
);
}
}
"filesystem" => {}
other => {
errors.push(format!(
"storage.backend must be \"filesystem\" or \"s3\", got \"{other}\""
));
}
}
// -- backup storage ---------------------------------------------------
if self.backup.enabled {
match self.backup.backend.as_str() {
"s3" => {
if self.backup.s3_bucket.is_none() {
errors.push(
"backup.backend is \"s3\" but backup.s3_bucket \
(BACKUP_S3_BUCKET) is not set"
.to_string(),
);
}
}
"filesystem" => {}
other => {
errors.push(format!(
"backup.backend must be \"filesystem\" or \"s3\", got \"{other}\""
));
}
}
}
// -- SSO providers ----------------------------------------------------
self.validate_sso_provider("sso.github", &self.sso.github, &mut errors);
self.validate_sso_provider("sso.google", &self.sso.google, &mut errors);
self.validate_sso_discord(&mut errors);
self.validate_sso_with_issuer("sso.gitlab", &self.sso.gitlab, &mut errors);
self.validate_sso_with_issuer("sso.oidc", &self.sso.oidc, &mut errors);
self.validate_sso_apple(&mut errors);
// -- moderation -------------------------------------------------------
let has_url = self.moderation.report_service_url.is_some();
let has_did = self.moderation.report_service_did.is_some();
if has_url != has_did {
errors.push(
"moderation.report_service_url and moderation.report_service_did \
must both be set or both be unset"
.to_string(),
);
}
// -- cache ------------------------------------------------------------
match self.cache.backend.as_str() {
"valkey" => {
if self.cache.valkey_url.is_none() {
errors.push(
"cache.backend is \"valkey\" but cache.valkey_url (VALKEY_URL) \
is not set"
.to_string(),
);
}
}
"ripple" => {}
other => {
errors.push(format!(
"cache.backend must be \"ripple\" or \"valkey\", got \"{other}\""
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(ConfigError { errors })
}
}
fn validate_sso_provider(&self, prefix: &str, p: &SsoProviderConfig, errors: &mut Vec<String>) {
if p.enabled {
if p.client_id.is_none() {
errors.push(format!(
"{prefix}.client_id is required when {prefix}.enabled = true"
));
}
if p.client_secret.is_none() {
errors.push(format!(
"{prefix}.client_secret is required when {prefix}.enabled = true"
));
}
}
}
fn validate_sso_discord(&self, errors: &mut Vec<String>) {
let p = &self.sso.discord;
if p.enabled {
if p.client_id.is_none() {
errors.push(
"sso.discord.client_id is required when sso.discord.enabled = true".to_string(),
);
}
if p.client_secret.is_none() {
errors.push(
"sso.discord.client_secret is required when sso.discord.enabled = true"
.to_string(),
);
}
}
}
fn validate_sso_with_issuer(
&self,
prefix: &str,
p: &SsoProviderWithIssuerConfig,
errors: &mut Vec<String>,
) {
if p.enabled {
if p.client_id.is_none() {
errors.push(format!(
"{prefix}.client_id is required when {prefix}.enabled = true"
));
}
if p.client_secret.is_none() {
errors.push(format!(
"{prefix}.client_secret is required when {prefix}.enabled = true"
));
}
if p.issuer.is_none() {
errors.push(format!(
"{prefix}.issuer is required when {prefix}.enabled = true"
));
}
}
}
fn validate_sso_apple(&self, errors: &mut Vec<String>) {
let p = &self.sso.apple;
if p.enabled {
if p.client_id.is_none() {
errors.push(
"sso.apple.client_id is required when sso.apple.enabled = true".to_string(),
);
}
if p.team_id.is_none() {
errors.push(
"sso.apple.team_id is required when sso.apple.enabled = true".to_string(),
);
}
if p.key_id.is_none() {
errors
.push("sso.apple.key_id is required when sso.apple.enabled = true".to_string());
}
if p.private_key.is_none() {
errors.push(
"sso.apple.private_key is required when sso.apple.enabled = true".to_string(),
);
}
}
}
}
// ---------------------------------------------------------------------------
// Server
// ---------------------------------------------------------------------------
#[derive(Debug, Config)]
pub struct ServerConfig {
/// Public hostname of the PDS (e.g. `pds.example.com`).
#[config(env = "PDS_HOSTNAME")]
pub hostname: String,
/// Address to bind the HTTP server to.
#[config(env = "SERVER_HOST", default = "127.0.0.1")]
pub host: String,
/// Port to bind the HTTP server to.
#[config(env = "SERVER_PORT", default = 3000)]
pub port: u16,
/// List of domains for user handles.
/// Defaults to the PDS hostname when not set.
#[config(env = "PDS_USER_HANDLE_DOMAINS", parse_env = split_comma_list)]
pub user_handle_domains: Option<Vec<String>>,
/// Enable PDS-hosted did:web identities. Hosting did:web requires a
/// long-term commitment to serve DID documents; opt-in only.
#[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)]
pub enable_pds_hosted_did_web: bool,
/// When set to true, skip age-assurance birthday prompt for all accounts.
#[config(env = "PDS_AGE_ASSURANCE_OVERRIDE", default = false)]
pub age_assurance_override: bool,
/// Require an invite code for new account registration.
#[config(env = "INVITE_CODE_REQUIRED", default = true)]
pub invite_code_required: bool,
/// Allow HTTP (non-TLS) proxy requests. Only useful during development.
#[config(env = "ALLOW_HTTP_PROXY", default = false)]
pub allow_http_proxy: bool,
/// Disable all rate limiting. Should only be used in testing.
#[config(env = "DISABLE_RATE_LIMITING", default = false)]
pub disable_rate_limiting: bool,
/// List of additional banned words for handle validation.
#[config(env = "PDS_BANNED_WORDS", parse_env = split_comma_list)]
pub banned_words: Option<Vec<String>>,
/// URL to a privacy policy page.
#[config(env = "PRIVACY_POLICY_URL")]
pub privacy_policy_url: Option<String>,
/// URL to terms of service page.
#[config(env = "TERMS_OF_SERVICE_URL")]
pub terms_of_service_url: Option<String>,
/// Operator contact email address.
#[config(env = "CONTACT_EMAIL")]
pub contact_email: Option<String>,
/// Maximum allowed blob size in bytes (default 10 GiB).
#[config(env = "MAX_BLOB_SIZE", default = 10_737_418_240u64)]
pub max_blob_size: u64,
}
impl ServerConfig {
/// The public HTTPS URL for this PDS.
pub fn public_url(&self) -> String {
format!("https://{}", self.hostname)
}
/// Hostname without port suffix (e.g. `pds.example.com` from
/// `pds.example.com:443`).
pub fn hostname_without_port(&self) -> &str {
self.hostname.split(':').next().unwrap_or(&self.hostname)
}
/// Returns the extra banned words list, or an empty vec when unset.
pub fn banned_word_list(&self) -> Vec<String> {
self.banned_words.clone().unwrap_or_default()
}
/// Returns the user handle domains, falling back to `[hostname_without_port]`.
pub fn user_handle_domain_list(&self) -> Vec<String> {
self.user_handle_domains
.as_deref()
.filter(|v| !v.is_empty())
.map(|v| v.to_vec())
.unwrap_or_else(|| vec![self.hostname_without_port().to_string()])
}
/// Alias for `user_handle_domain_list` (for callers that were using the now-removed `available_user_domains` field).
pub fn available_user_domain_list(&self) -> Vec<String> {
self.user_handle_domain_list()
}
}
#[derive(Debug, Config)]
pub struct FrontendConfig {
/// Whether to enable the built in serving of the frontend.
#[config(env = "FRONTEND_ENABLED", default = true)]
pub enabled: bool,
/// Directory to serve as the frontend. The oauth_client_metadata.json will have any references to
/// the frontend hostname replaced by the configured frontend hostname.
#[config(env = "FRONTEND_DIR", default = "/var/lib/tranquil-pds/frontend")]
pub dir: String,
}
#[derive(Debug, Config)]
pub struct DatabaseConfig {
/// PostgreSQL connection URL.
#[config(env = "DATABASE_URL")]
pub url: String,
/// Maximum number of connections in the pool.
#[config(env = "DATABASE_MAX_CONNECTIONS", default = 100)]
pub max_connections: u32,
/// Minimum number of idle connections kept in the pool.
#[config(env = "DATABASE_MIN_CONNECTIONS", default = 10)]
pub min_connections: u32,
/// Timeout in seconds when acquiring a connection from the pool.
#[config(env = "DATABASE_ACQUIRE_TIMEOUT_SECS", default = 10)]
pub acquire_timeout_secs: u64,
}
#[derive(Config)]
pub struct SecretsConfig {
/// Secret used for signing JWTs. Must be at least 32 characters in
/// production.
#[config(env = "JWT_SECRET")]
pub jwt_secret: Option<String>,
/// Secret used for DPoP proof validation. Must be at least 32 characters
/// in production.
#[config(env = "DPOP_SECRET")]
pub dpop_secret: Option<String>,
/// Master key used for key-encryption and HKDF derivation. Must be at
/// least 32 characters in production.
#[config(env = "MASTER_KEY")]
pub master_key: Option<String>,
/// PLC rotation key (DID key). If not set, user-level keys are used.
#[config(env = "PLC_ROTATION_KEY")]
pub plc_rotation_key: Option<String>,
/// Allow insecure/test secrets. NEVER enable in production.
#[config(env = "TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", default = false)]
pub allow_insecure: bool,
}
impl std::fmt::Debug for SecretsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretsConfig")
.field(
"jwt_secret",
&self.jwt_secret.as_ref().map(|_| "[REDACTED]"),
)
.field(
"dpop_secret",
&self.dpop_secret.as_ref().map(|_| "[REDACTED]"),
)
.field(
"master_key",
&self.master_key.as_ref().map(|_| "[REDACTED]"),
)
.field(
"plc_rotation_key",
&self.plc_rotation_key.as_ref().map(|_| "[REDACTED]"),
)
.field("allow_insecure", &self.allow_insecure)
.finish()
}
}
impl SecretsConfig {
/// Resolve the JWT secret, falling back to an insecure default if
/// `allow_insecure` is true.
pub fn jwt_secret_or_default(&self) -> String {
self.jwt_secret.clone().unwrap_or_else(|| {
if cfg!(test) || self.allow_insecure {
"test-jwt-secret-not-for-production".to_string()
} else {
panic!(
"JWT_SECRET must be set in production. \
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing."
);
}
})
}
/// Resolve the DPoP secret, falling back to an insecure default if
/// `allow_insecure` is true.
pub fn dpop_secret_or_default(&self) -> String {
self.dpop_secret.clone().unwrap_or_else(|| {
if cfg!(test) || self.allow_insecure {
"test-dpop-secret-not-for-production".to_string()
} else {
panic!(
"DPOP_SECRET must be set in production. \
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing."
);
}
})
}
/// Resolve the master key, falling back to an insecure default if
/// `allow_insecure` is true.
pub fn master_key_or_default(&self) -> String {
self.master_key.clone().unwrap_or_else(|| {
if cfg!(test) || self.allow_insecure {
"test-master-key-not-for-production".to_string()
} else {
panic!(
"MASTER_KEY must be set in production. \
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing."
);
}
})
}
}
#[derive(Debug, Config)]
pub struct StorageConfig {
/// Storage backend: `filesystem` or `s3`.
#[config(env = "BLOB_STORAGE_BACKEND", default = "filesystem")]
pub backend: String,
/// Path on disk for the filesystem blob backend.
#[config(env = "BLOB_STORAGE_PATH", default = "/var/lib/tranquil-pds/blobs")]
pub path: String,
/// S3 bucket name for blob storage.
#[config(env = "S3_BUCKET")]
pub s3_bucket: Option<String>,
/// Custom S3 endpoint URL (for MinIO, R2, etc.).
#[config(env = "S3_ENDPOINT")]
pub s3_endpoint: Option<String>,
}
#[derive(Debug, Config)]
pub struct BackupConfig {
/// Enable automatic backups.
#[config(env = "BACKUP_ENABLED", default = true)]
pub enabled: bool,
/// Backup storage backend: `filesystem` or `s3`.
#[config(env = "BACKUP_STORAGE_BACKEND", default = "filesystem")]
pub backend: String,
/// Path on disk for the filesystem backup backend.
#[config(env = "BACKUP_STORAGE_PATH", default = "/var/lib/tranquil-pds/backups")]
pub path: String,
/// S3 bucket name for backups.
#[config(env = "BACKUP_S3_BUCKET")]
pub s3_bucket: Option<String>,
/// Number of backup revisions to keep per account.
#[config(env = "BACKUP_RETENTION_COUNT", default = 7)]
pub retention_count: u32,
/// Seconds between backup runs.
#[config(env = "BACKUP_INTERVAL_SECS", default = 86400)]
pub interval_secs: u64,
}
#[derive(Debug, Config)]
pub struct CacheConfig {
/// Cache backend: `ripple` (default, built-in gossip) or `valkey`.
#[config(env = "CACHE_BACKEND", default = "ripple")]
pub backend: String,
/// Valkey / Redis connection URL. Required when `backend = "valkey"`.
#[config(env = "VALKEY_URL")]
pub valkey_url: Option<String>,
#[config(nested)]
pub ripple: RippleCacheConfig,
}
#[derive(Debug, Config)]
pub struct PlcConfig {
/// Base URL of the PLC directory.
#[config(env = "PLC_DIRECTORY_URL", default = "https://plc.directory")]
pub directory_url: String,
/// HTTP request timeout in seconds.
#[config(env = "PLC_TIMEOUT_SECS", default = 10)]
pub timeout_secs: u64,
/// TCP connect timeout in seconds.
#[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)]
pub connect_timeout_secs: u64,
/// Seconds to cache DID documents in memory.
#[config(env = "DID_CACHE_TTL_SECS", default = 300)]
pub did_cache_ttl_secs: u64,
}
#[derive(Debug, Config)]
pub struct FirehoseConfig {
/// Size of the in-memory broadcast buffer for firehose events.
#[config(env = "FIREHOSE_BUFFER_SIZE", default = 10000)]
pub buffer_size: usize,
/// How many hours of historical events to replay for cursor-based
/// firehose connections.
#[config(env = "FIREHOSE_BACKFILL_HOURS", default = 72)]
pub backfill_hours: i64,
/// Maximum number of lagged events before disconnecting a slow consumer.
#[config(env = "FIREHOSE_MAX_LAG", default = 5000)]
pub max_lag: u64,
/// List of relay / crawler notification URLs.
#[config(env = "CRAWLERS", parse_env = split_comma_list)]
pub crawlers: Option<Vec<String>>,
}
impl FirehoseConfig {
/// Returns the list of crawler URLs, falling back to `["https://bsky.network"]`
/// when none are configured.
pub fn crawler_list(&self) -> Vec<String> {
self.crawlers
.clone()
.unwrap_or_else(|| vec!["https://bsky.network".to_string()])
}
}
#[derive(Debug, Config)]
pub struct EmailConfig {
/// Sender email address. When unset, email sending is disabled.
#[config(env = "MAIL_FROM_ADDRESS")]
pub from_address: Option<String>,
/// Display name used in the `From` header.
#[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")]
pub from_name: String,
/// Path to the `sendmail` binary.
#[config(env = "SENDMAIL_PATH", default = "/usr/sbin/sendmail")]
pub sendmail_path: String,
}
#[derive(Debug, Config)]
pub struct DiscordConfig {
/// Discord bot token. When unset, Discord integration is disabled.
#[config(env = "DISCORD_BOT_TOKEN")]
pub bot_token: Option<String>,
}
#[derive(Debug, Config)]
pub struct TelegramConfig {
/// Telegram bot token. When unset, Telegram integration is disabled.
#[config(env = "TELEGRAM_BOT_TOKEN")]
pub bot_token: Option<String>,
/// Secret token for incoming webhook verification.
#[config(env = "TELEGRAM_WEBHOOK_SECRET")]
pub webhook_secret: Option<String>,
}
#[derive(Debug, Config)]
pub struct SignalConfig {
/// Path to the `signal-cli` binary.
#[config(env = "SIGNAL_CLI_PATH", default = "/usr/local/bin/signal-cli")]
pub cli_path: String,
/// Sender phone number. When unset, Signal integration is disabled.
#[config(env = "SIGNAL_SENDER_NUMBER")]
pub sender_number: Option<String>,
}
#[derive(Debug, Config)]
pub struct NotificationConfig {
/// Polling interval in milliseconds for the comms queue.
#[config(env = "NOTIFICATION_POLL_INTERVAL_MS", default = 1000)]
pub poll_interval_ms: u64,
/// Number of notifications to process per batch.
#[config(env = "NOTIFICATION_BATCH_SIZE", default = 100)]
pub batch_size: i64,
}
#[derive(Debug, Config)]
pub struct SsoConfig {
#[config(nested)]
pub github: SsoProviderConfig,
#[config(nested)]
pub discord: SsoDiscordProviderConfig,
#[config(nested)]
pub google: SsoProviderConfig,
#[config(nested)]
pub gitlab: SsoProviderWithIssuerConfig,
#[config(nested)]
pub oidc: SsoProviderWithIssuerConfig,
#[config(nested)]
pub apple: SsoAppleConfig,
}
// Generic SSO provider (GitHub, Google)
#[derive(Debug, Config)]
pub struct SsoProviderConfig {
#[config(default = false)]
pub enabled: bool,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub display_name: Option<String>,
}
// SSO provider with custom env prefixes for Discord
// (since the nested TOML key is `sso.discord` but env vars are `SSO_DISCORD_*`)
#[derive(Debug, Config)]
pub struct SsoDiscordProviderConfig {
#[config(default = false)]
pub enabled: bool,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub display_name: Option<String>,
}
// SSO providers that require an issuer URL (GitLab, OIDC)
#[derive(Debug, Config)]
pub struct SsoProviderWithIssuerConfig {
#[config(default = false)]
pub enabled: bool,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub issuer: Option<String>,
pub display_name: Option<String>,
}
#[derive(Debug, Config)]
pub struct SsoAppleConfig {
#[config(env = "SSO_APPLE_ENABLED", default = false)]
pub enabled: bool,
#[config(env = "SSO_APPLE_CLIENT_ID")]
pub client_id: Option<String>,
#[config(env = "SSO_APPLE_TEAM_ID")]
pub team_id: Option<String>,
#[config(env = "SSO_APPLE_KEY_ID")]
pub key_id: Option<String>,
#[config(env = "SSO_APPLE_PRIVATE_KEY")]
pub private_key: Option<String>,
}
#[derive(Debug, Config)]
pub struct ModerationConfig {
/// External report-handling service URL.
#[config(env = "REPORT_SERVICE_URL")]
pub report_service_url: Option<String>,
/// DID of the external report-handling service.
#[config(env = "REPORT_SERVICE_DID")]
pub report_service_did: Option<String>,
}
#[derive(Debug, Config)]
pub struct ImportConfig {
/// Whether the PDS accepts repo imports.
#[config(env = "ACCEPTING_REPO_IMPORTS", default = true)]
pub accepting: bool,
/// Maximum allowed import archive size in bytes (default 1 GiB).
#[config(env = "MAX_IMPORT_SIZE", default = 1_073_741_824)]
pub max_size: u64,
/// Maximum number of blocks allowed in an import.
#[config(env = "MAX_IMPORT_BLOCKS", default = 500000)]
pub max_blocks: u64,
/// Skip CAR verification during import. Only for development/debugging.
#[config(env = "SKIP_IMPORT_VERIFICATION", default = false)]
pub skip_verification: bool,
}
/// Parse a comma-separated environment variable into a `Vec<String>`,
/// trimming whitespace and dropping empty entries.
///
/// Signature matches confique's `parse_env` expectation: `fn(&str) -> Result<T, E>`.
fn split_comma_list(value: &str) -> Result<Vec<String>, std::convert::Infallible> {
Ok(value
.split(',')
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.collect())
}
#[derive(Debug, Config)]
pub struct RippleCacheConfig {
/// Address to bind the Ripple gossip protocol listener.
#[config(env = "RIPPLE_BIND", default = "0.0.0.0:0")]
pub bind_addr: String,
/// List of seed peer addresses.
#[config(env = "RIPPLE_PEERS", parse_env = split_comma_list)]
pub peers: Option<Vec<String>>,
/// Unique machine identifier. Auto-derived from hostname when not set.
#[config(env = "RIPPLE_MACHINE_ID")]
pub machine_id: Option<u64>,
/// Gossip protocol interval in milliseconds.
#[config(env = "RIPPLE_GOSSIP_INTERVAL_MS", default = 200)]
pub gossip_interval_ms: u64,
/// Maximum cache size in megabytes.
#[config(env = "RIPPLE_CACHE_MAX_MB", default = 256)]
pub cache_max_mb: usize,
}
#[derive(Debug, Config)]
pub struct ScheduledConfig {
/// Interval in seconds between scheduled delete checks.
#[config(env = "SCHEDULED_DELETE_CHECK_INTERVAL_SECS", default = 3600)]
pub delete_check_interval_secs: u64,
}
/// Generate a TOML configuration template with all available options,
/// defaults, and documentation comments.
pub fn template() -> String {
confique::toml::template::<TranquilConfig>(confique::toml::FormatOptions::default())
}
+5
View File
@@ -192,6 +192,11 @@ pub trait OAuthRepository: Send + Sync {
) -> Result<Option<RequestData>, DbError>;
async fn delete_authorization_request(&self, request_id: &RequestId) -> Result<(), DbError>;
async fn delete_expired_authorization_requests(&self) -> Result<u64, DbError>;
async fn extend_authorization_request_expiry(
&self,
request_id: &RequestId,
new_expires_at: DateTime<Utc>,
) -> Result<bool, DbError>;
async fn mark_request_authenticated(
&self,
request_id: &RequestId,
+3
View File
@@ -886,6 +886,8 @@ pub struct UserResendVerification {
#[derive(Debug, Clone)]
pub struct UserResetCodeInfo {
pub id: Uuid,
pub did: Did,
pub preferred_comms_channel: CommsChannel,
pub expires_at: Option<DateTime<Utc>>,
}
@@ -956,6 +958,7 @@ pub struct UserForPasskeyRecovery {
pub struct UserForRecovery {
pub id: Uuid,
pub did: Did,
pub preferred_comms_channel: CommsChannel,
pub recovery_token: Option<String>,
pub recovery_token_expires_at: Option<DateTime<Utc>>,
}
+20
View File
@@ -615,6 +615,26 @@ impl OAuthRepository for PostgresOAuthRepository {
Ok(result.rows_affected())
}
async fn extend_authorization_request_expiry(
&self,
request_id: &RequestId,
new_expires_at: DateTime<Utc>,
) -> Result<bool, DbError> {
let result = sqlx::query!(
r#"
UPDATE oauth_authorization_request
SET expires_at = $2
WHERE id = $1 AND did IS NOT NULL AND code IS NULL
"#,
request_id.as_str(),
new_expires_at
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected() > 0)
}
async fn mark_request_authenticated(
&self,
request_id: &RequestId,
+5 -2
View File
@@ -1715,7 +1715,7 @@ impl UserRepository for PostgresUserRepository {
code: &str,
) -> Result<Option<UserResetCodeInfo>, DbError> {
sqlx::query!(
"SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
"SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
code
)
.fetch_optional(&self.pool)
@@ -1724,6 +1724,8 @@ impl UserRepository for PostgresUserRepository {
.map(|opt| {
opt.map(|row| UserResetCodeInfo {
id: row.id,
did: Did::from(row.did),
preferred_comms_channel: row.preferred_comms_channel,
expires_at: row.password_reset_code_expires_at,
})
})
@@ -2202,7 +2204,7 @@ impl UserRepository for PostgresUserRepository {
async fn get_user_for_recovery(&self, did: &Did) -> Result<Option<UserForRecovery>, DbError> {
let row = sqlx::query!(
"SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
"SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
did.as_str()
)
.fetch_optional(&self.pool)
@@ -2212,6 +2214,7 @@ impl UserRepository for PostgresUserRepository {
Ok(row.map(|r| UserForRecovery {
id: r.id,
did: Did::from(r.did),
preferred_comms_channel: r.preferred_comms_channel,
recovery_token: r.recovery_token,
recovery_token_expires_at: r.recovery_token_expires_at,
}))
+2
View File
@@ -5,6 +5,8 @@ edition.workspace = true
license.workspace = true
[dependencies]
tranquil-config = { workspace = true }
async-trait = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
+4 -6
View File
@@ -45,16 +45,14 @@ pub trait BackupStorage: Send + Sync {
}
pub fn backup_retention_count() -> u32 {
std::env::var("BACKUP_RETENTION_COUNT")
.ok()
.and_then(|v| v.parse().ok())
tranquil_config::try_get()
.map(|c| c.backup.retention_count)
.unwrap_or(7)
}
pub fn backup_interval_secs() -> u64 {
std::env::var("BACKUP_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
tranquil_config::try_get()
.map(|c| c.backup.interval_secs)
.unwrap_or(86400)
}
+4 -1
View File
@@ -6,6 +6,7 @@ license.workspace = true
[dependencies]
tranquil-types = { workspace = true }
tranquil-config = { workspace = true }
tranquil-crypto = { workspace = true }
tranquil-storage = { workspace = true }
tranquil-cache = { workspace = true }
@@ -29,6 +30,7 @@ bs58 = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true }
cid = { workspace = true }
clap = { workspace = true }
dotenvy = { workspace = true }
ed25519-dalek = { workspace = true }
futures = { workspace = true }
@@ -81,11 +83,12 @@ aws-config = { workspace = true, optional = true }
aws-sdk-s3 = { workspace = true, optional = true }
[features]
default = ["s3", "valkey"]
default = ["frontend", "s3", "valkey"]
external-infra = []
s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"]
s3 = ["s3-storage"]
valkey = ["tranquil-cache/valkey", "dep:redis"]
frontend = []
[dev-dependencies]
ciborium = { workspace = true }
@@ -2,7 +2,6 @@ use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Admin, Auth};
use crate::state::AppState;
use crate::types::Did;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -45,7 +44,7 @@ pub async fn send_email(
let email = user.email.ok_or(ApiError::NoEmail)?;
let (user_id, handle) = (user.id, user.handle);
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let subject = input
.subject
.clone()
@@ -3,7 +3,6 @@ use crate::api::error::ApiError;
use crate::auth::{Admin, Auth};
use crate::state::AppState;
use crate::types::{Did, Handle, PlainPassword};
use crate::util::pds_hostname_without_port;
use axum::{
Json,
extract::State,
@@ -70,9 +69,9 @@ pub async fn update_account_handle(
{
return Err(ApiError::InvalidHandle(None));
}
let hostname_for_handles = pds_hostname_without_port();
let available_domains = tranquil_config::get().server.available_user_domain_list();
let handle = if !input_handle.contains('.') {
format!("{}.{}", input_handle, hostname_for_handles)
format!("{}.{}", input_handle, &available_domains[0])
} else {
input_handle.to_string()
};
+19 -15
View File
@@ -8,7 +8,6 @@ use crate::delegation::{
use crate::rate_limit::{AccountCreationLimit, RateLimited};
use crate::state::AppState;
use crate::types::{Did, Handle};
use crate::util::{pds_hostname, pds_hostname_without_port};
use axum::{
Json,
extract::{Query, State},
@@ -435,21 +434,23 @@ pub async fn create_delegated_account(
Err(response) => return Ok(response),
};
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let pds_suffix = format!(".{}", hostname_for_handles);
let hostname = &tranquil_config::get().server.hostname;
let available_domains = tranquil_config::get().server.available_user_domain_list();
let matched_domain = available_domains
.iter()
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len());
let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
input
let handle = if !input.handle.contains('.') || matched_domain.is_some() {
let handle_to_validate = match matched_domain {
Some(domain) => input
.handle
.strip_suffix(&pds_suffix)
.unwrap_or(&input.handle)
} else {
&input.handle
.strip_suffix(&format!(".{}", domain))
.unwrap_or(&input.handle),
None => &input.handle,
};
match crate::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => format!("{}.{}", h, hostname_for_handles),
Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])),
Err(e) => {
return Ok(ApiError::InvalidRequest(e.to_string()).into_response());
}
@@ -475,7 +476,7 @@ pub async fn create_delegated_account(
Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()),
}
} else {
let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
let invite_required = tranquil_config::get().server.invite_code_required;
if invite_required {
return Ok(ApiError::InviteCodeRequired.into_response());
}
@@ -497,8 +498,11 @@ pub async fn create_delegated_account(
}
};
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key));
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| crate::plc::signing_key_to_did_key(&signing_key));
let genesis_result = match crate::plc::create_genesis_operation(
&signing_key,
@@ -12,7 +12,7 @@ use tranquil_types::Handle;
use crate::comms::comms_repo;
use crate::state::AppState;
use crate::util::{discord_public_key, pds_hostname};
use crate::util::discord_public_key;
#[derive(Deserialize)]
struct Interaction {
@@ -185,7 +185,7 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
user_id,
tranquil_db_traits::CommsChannel::Discord,
&discord_user_id,
pds_hostname(),
&tranquil_config::get().server.hostname,
)
.await
{
+72 -52
View File
@@ -6,7 +6,6 @@ use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
use crate::rate_limit::{AccountCreationLimit, RateLimited};
use crate::state::AppState;
use crate::types::{Did, Handle, PlainPassword};
use crate::util::{pds_hostname, pds_hostname_without_port};
use crate::validation::validate_password;
use axum::{
Json,
@@ -141,19 +140,21 @@ pub async fn create_account(
}
}
let hostname_for_validation = pds_hostname_without_port();
let pds_suffix = format!(".{}", hostname_for_validation);
let available_domains = tranquil_config::get().server.available_user_domain_list();
let matched_domain = available_domains
.iter()
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len());
let validated_short_handle = if !input.handle.contains('.')
|| input.handle.ends_with(&pds_suffix)
|| matched_domain.is_some()
{
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
input
let handle_to_validate = match matched_domain {
Some(domain) => input
.handle
.strip_suffix(&pds_suffix)
.unwrap_or(&input.handle)
} else {
&input.handle
.strip_suffix(&format!(".{}", domain))
.unwrap_or(&input.handle),
None => &input.handle,
};
match crate::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => h,
@@ -233,16 +234,12 @@ pub async fn create_account(
},
})
};
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let hostname = &tranquil_config::get().server.hostname;
let pds_endpoint = format!("https://{}", hostname);
let suffix = format!(".{}", hostname_for_handles);
let handle = if input.handle.ends_with(&suffix) {
format!("{}.{}", validated_short_handle, hostname_for_handles)
} else if input.handle.contains('.') {
validated_short_handle.clone()
} else {
format!("{}.{}", validated_short_handle, hostname_for_handles)
let handle = match matched_domain {
Some(domain) => format!("{}.{}", validated_short_handle, domain),
None if input.handle.contains('.') => validated_short_handle.clone(),
None => format!("{}.{}", validated_short_handle, &available_domains[0]),
};
let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) =
if let Some(signing_key_did) = &input.signing_key {
@@ -277,7 +274,8 @@ pub async fn create_account(
if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
return ApiError::SelfHostedDidWebDisabled.into_response();
}
let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles);
let pds_hostname = tranquil_config::get().server.hostname_without_port();
let subdomain_host = format!("{}.{}", input.handle, pds_hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
@@ -326,8 +324,11 @@ pub async fn create_account(
)
.into_response();
} else {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| signing_key_to_did_key(&signing_key));
let genesis_result = match create_genesis_operation(
&signing_key,
&rotation_key,
@@ -359,8 +360,11 @@ pub async fn create_account(
genesis_result.did
}
} else {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| signing_key_to_did_key(&signing_key));
let genesis_result = match create_genesis_operation(
&signing_key,
&rotation_key,
@@ -473,7 +477,7 @@ pub async fn create_account(
error!("Error creating session: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let verification_required = if let Some(ref user_email) = email {
let token = crate::auth::verification_token::generate_migration_token(
&did_typed, user_email,
@@ -543,28 +547,38 @@ pub async fn create_account(
return ApiError::HandleTaken.into_response();
}
let invite_code_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
if invite_code_required
&& input
.invite_code
.as_ref()
.map(|c| c.trim().is_empty())
.unwrap_or(true)
{
return ApiError::InviteCodeRequired.into_response();
}
if let Some(code) = &input.invite_code
&& !code.trim().is_empty()
{
let valid = match state.user_repo.check_and_consume_invite_code(code).await {
Ok(v) => v,
Err(e) => {
error!("Error checking invite code: {:?}", e);
return ApiError::InternalError(None).into_response();
let is_bootstrap = state.bootstrap_invite_code.is_some()
&& state.user_repo.count_users().await.unwrap_or(1) == 0;
if is_bootstrap {
match input.invite_code.as_deref() {
Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => {}
_ => return ApiError::InvalidInviteCode.into_response(),
}
} else {
let invite_code_required = tranquil_config::get().server.invite_code_required;
if invite_code_required
&& input
.invite_code
.as_ref()
.map(|c| c.trim().is_empty())
.unwrap_or(true)
{
return ApiError::InviteCodeRequired.into_response();
}
if let Some(code) = &input.invite_code
&& !code.trim().is_empty()
{
let valid = match state.user_repo.check_and_consume_invite_code(code).await {
Ok(v) => v,
Err(e) => {
error!("Error checking invite code: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
if !valid {
return ApiError::InvalidInviteCode.into_response();
}
};
if !valid {
return ApiError::InvalidInviteCode.into_response();
}
}
@@ -632,12 +646,14 @@ pub async fn create_account(
let rev_str = rev.as_ref().to_string();
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| {
json!({
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
Some(json!({
"$type": "app.bsky.actor.defs#personalDetailsPref",
"birthDate": "1998-05-06T00:00:00.000Z"
})
});
}))
} else {
None
};
let preferred_comms_channel = verification_channel;
@@ -671,7 +687,11 @@ pub async fn create_account(
commit_cid: commit_cid_str.clone(),
repo_rev: rev_str.clone(),
genesis_block_cids,
invite_code: input.invite_code.clone(),
invite_code: if is_bootstrap {
None
} else {
input.invite_code.clone()
},
birthdate_pref,
};
@@ -748,7 +768,7 @@ pub async fn create_account(
warn!("Failed to create default profile for {}: {}", did, e);
}
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if !is_migration {
if let Some(ref recipient) = verification_recipient {
let verification_token = crate::auth::verification_token::generate_signup_token(
+27 -23
View File
@@ -6,7 +6,7 @@ use crate::rate_limit::{
};
use crate::state::AppState;
use crate::types::Handle;
use crate::util::{get_header_str, pds_hostname, pds_hostname_without_port};
use crate::util::get_header_str;
use axum::{
Json,
extract::{Path, Query, State},
@@ -122,8 +122,8 @@ pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, KeyError> {
}
pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
let hostname = pds_hostname();
let hostname_without_port = pds_hostname_without_port();
let hostname = &tranquil_config::get().server.hostname;
let hostname_without_port = tranquil_config::get().server.hostname_without_port();
let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname);
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
if host_without_port != hostname_without_port
@@ -275,8 +275,8 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s
}
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let hostname = &tranquil_config::get().server.hostname;
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let current_handle = format!("{}.{}", handle, hostname_for_handles);
let current_handle_typed: Handle = match current_handle.parse() {
Ok(h) => h,
@@ -571,7 +571,7 @@ pub async fn get_recommended_did_credentials(
ApiError::AuthenticationFailed(Some("OAuth tokens cannot get DID credentials".into()))
})?;
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let pds_endpoint = format!("https://{}", hostname);
let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)
.map_err(|_| ApiError::InternalError(None))?;
@@ -579,9 +579,9 @@ pub async fn get_recommended_did_credentials(
let rotation_keys = if auth.did.starts_with("did:web:") {
vec![]
} else {
let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") {
Ok(key) => key,
Err(_) => {
let server_rotation_key = match &tranquil_config::get().secrets.plc_rotation_key {
Some(key) => key.clone(),
None => {
warn!(
"PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation"
);
@@ -675,20 +675,24 @@ pub async fn update_handle(
"Inappropriate language in handle".into(),
)));
}
let hostname_for_handles = pds_hostname_without_port();
let suffix = format!(".{}", hostname_for_handles);
let is_service_domain =
crate::handle::is_service_domain_handle(&new_handle, hostname_for_handles);
let handle = if is_service_domain && new_handle != hostname_for_handles {
let short_part = if new_handle.ends_with(&suffix) {
new_handle.strip_suffix(&suffix).unwrap_or(&new_handle)
} else {
&new_handle
};
let full_handle = if new_handle.ends_with(&suffix) {
new_handle.clone()
} else {
format!("{}.{}", new_handle, hostname_for_handles)
let handle_domains = tranquil_config::get().server.user_handle_domain_list();
let matched_handle_domain = handle_domains
.iter()
.filter(|d| new_handle.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len())
.cloned();
let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle);
let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some()) && !is_domain_itself {
let (short_part, full_handle) = match &matched_handle_domain {
Some(domain) => {
let suffix = format!(".{}", domain);
let short = new_handle.strip_suffix(&suffix).unwrap_or(&new_handle);
(short.to_string(), new_handle.clone())
}
None => {
let primary = &handle_domains[0];
(new_handle.clone(), format!("{}.{}", new_handle, primary))
}
};
if full_handle == current_handle {
let handle_typed: Handle = match full_handle.parse() {
@@ -2,7 +2,6 @@ use crate::api::EmptyResponse;
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Auth, Permissive};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
extract::State,
response::{IntoResponse, Response},
@@ -41,7 +40,7 @@ pub async fn request_plc_operation_signature(
.await
.log_db_err("creating PLC token")?;
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_plc_operation(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -4,7 +4,6 @@ use crate::auth::{Auth, Permissive};
use crate::circuit_breaker::with_circuit_breaker;
use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -42,7 +41,7 @@ pub async fn submit_plc_operation(
.map_err(|e| ApiError::InvalidRequest(format!("Invalid operation: {}", e)))?;
let op = &input.operation;
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let public_url = format!("https://{}", hostname);
let user = state
.user_repo
@@ -70,8 +69,11 @@ pub async fn submit_plc_operation(
})?;
let user_did_key = signing_key_to_did_key(&signing_key);
let server_rotation_key =
std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone());
let server_rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| user_did_key.clone());
if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) {
let has_server_key = rotation_keys
.iter()
@@ -69,8 +69,9 @@ struct ReportServiceConfig {
}
fn get_report_service_config() -> Option<ReportServiceConfig> {
let url = std::env::var("REPORT_SERVICE_URL").ok()?;
let did = std::env::var("REPORT_SERVICE_DID").ok()?;
let cfg = tranquil_config::get();
let url = cfg.moderation.report_service_url.clone()?;
let did = cfg.moderation.report_service_did.clone()?;
if url.is_empty() || did.is_empty() {
return None;
}
@@ -1,7 +1,6 @@
use crate::api::error::ApiError;
use crate::auth::{Active, Auth};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -148,7 +147,7 @@ pub async fn request_channel_verification(
match channel {
CommsChannel::Email => {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let handle_str = handle.unwrap_or("user");
crate::comms::comms_repo::enqueue_email_update(
state.infra_repo.as_ref(),
@@ -167,7 +166,7 @@ pub async fn request_channel_verification(
})?;
}
_ => {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let encoded_token = urlencoding::encode(&formatted_token);
let encoded_identifier = urlencoding::encode(identifier);
let verify_link = format!(
+1 -1
View File
@@ -168,7 +168,7 @@ impl<S: Service<Request, Response = Response, Error = Infallible>> Service<Reque
}
// If the age assurance override is set and this is an age assurance call then we dont want to proxy even if the client requests it
if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok()
if tranquil_config::get().server.age_assurance_override
&& (path.ends_with("app.bsky.ageassurance.getState")
|| path.ends_with("app.bsky.unspecced.getAgeAssuranceState"))
{
+1 -1
View File
@@ -63,7 +63,7 @@ pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> {
let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?;
let scheme = parsed.scheme();
if scheme != "https" {
let allow_http = std::env::var("ALLOW_HTTP_PROXY").is_ok()
let allow_http = tranquil_config::try_get().is_some_and(|c| c.server.allow_http_proxy)
|| url.starts_with("http://127.0.0.1")
|| url.starts_with("http://localhost");
if !allow_http {
+2 -2
View File
@@ -3,7 +3,7 @@ use crate::auth::{Auth, AuthAny, NotTakendown, Permissive, VerifyScope};
use crate::delegation::DelegationActionType;
use crate::state::AppState;
use crate::types::{CidLink, Did};
use crate::util::{get_header_str, get_max_blob_size};
use crate::util::get_header_str;
use axum::body::Body;
use axum::{
Json,
@@ -89,7 +89,7 @@ pub async fn upload_blob(
.ok_or(ApiError::InternalError(None))?;
let temp_key = format!("temp/{}", uuid::Uuid::new_v4());
let max_size = u64::try_from(get_max_blob_size()).unwrap_or(u64::MAX);
let max_size = tranquil_config::get().server.max_blob_size;
let body_stream = body.into_data_stream();
let mapped_stream =
+12 -16
View File
@@ -18,26 +18,18 @@ use serde_json::json;
use tracing::{debug, error, info, warn};
use tranquil_types::{AtUri, CidLink};
const DEFAULT_MAX_IMPORT_SIZE: usize = 1024 * 1024 * 1024;
const DEFAULT_MAX_BLOCKS: usize = 500000;
pub async fn import_repo(
State(state): State<AppState>,
auth: Auth<NotTakendown>,
body: Bytes,
) -> Result<Response, ApiError> {
let accepting_imports = std::env::var("ACCEPTING_REPO_IMPORTS")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
let accepting_imports = tranquil_config::get().import.accepting;
if !accepting_imports {
return Err(ApiError::InvalidRequest(
"Service is not accepting repo imports".into(),
));
}
let max_size: usize = std::env::var("MAX_IMPORT_SIZE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_MAX_IMPORT_SIZE);
let max_size = tranquil_config::get().import.max_size as usize;
if body.len() > max_size {
return Err(ApiError::PayloadTooLarge(format!(
"Import size exceeds limit of {} bytes",
@@ -108,7 +100,14 @@ pub async fn import_repo(
commit_did, did
)));
}
let skip_verification = crate::util::parse_env_bool("SKIP_IMPORT_VERIFICATION");
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
.ok()
.map(|v| v == "true" || v == "1")
.unwrap_or_else(|| {
tranquil_config::try_get()
.map(|c| c.import.skip_verification)
.unwrap_or(false)
});
let is_migration = user.deactivated_at.is_some();
if skip_verification {
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
@@ -196,10 +195,7 @@ pub async fn import_repo(
}
}
}
let max_blocks: usize = std::env::var("MAX_IMPORT_BLOCKS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_MAX_BLOCKS);
let max_blocks = tranquil_config::get().import.max_blocks as usize;
let _write_lock = state.repo_write_locks.lock(user_id).await;
match apply_import(
&state.repo_repo,
@@ -324,7 +320,7 @@ pub async fn import_repo(
{
warn!("Failed to sequence import event: {:?}", e);
}
if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() {
if tranquil_config::get().server.age_assurance_override {
let birthdate_pref = json!({
"$type": "app.bsky.actor.defs#personalDetailsPref",
"birthDate": "1998-05-06T00:00:00.000Z"
+1 -2
View File
@@ -1,7 +1,6 @@
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::types::AtIdentifier;
use crate::util::pds_hostname_without_port;
use axum::{
Json,
extract::{Query, State},
@@ -19,7 +18,7 @@ pub async fn describe_repo(
State(state): State<AppState>,
Query(input): Query<DescribeRepoInput>,
) -> Response {
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let user_row = if input.repo.is_did() {
let did: crate::types::Did = match input.repo.as_str().parse() {
Ok(d) => d,
@@ -2,7 +2,6 @@ use super::pagination::{PaginationDirection, deserialize_pagination_direction};
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::types::{AtIdentifier, Nsid, Rkey};
use crate::util::pds_hostname_without_port;
use axum::{
Json,
extract::{Query, State},
@@ -60,7 +59,7 @@ pub async fn get_record(
_headers: HeaderMap,
Query(input): Query<GetRecordInput>,
) -> Response {
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let user_id_opt = if input.repo.is_did() {
let did: crate::types::Did = match input.repo.as_str().parse() {
Ok(d) => d,
@@ -159,7 +158,7 @@ pub async fn list_records(
State(state): State<AppState>,
Query(input): Query<ListRecordsInput>,
) -> Response {
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let user_id_opt = if input.repo.is_did() {
let did: crate::types::Did = match input.repo.as_str().parse() {
Ok(d) => d,
@@ -370,7 +370,7 @@ pub async fn commit_and_log(
commit_event,
};
let result = state
let _result = state
.repo_repo
.apply_commit(input)
.await
@@ -380,10 +380,6 @@ pub async fn commit_and_log(
ApplyCommitError::Database(msg) => CommitError::DatabaseError(msg),
})?;
if result.is_account_active {
let _ = sequence_sync_event(state, did, &new_root_cid.to_string(), Some(&rev_str)).await;
}
Ok(CommitResult {
commit_cid: new_root_cid,
rev: rev_str,
@@ -5,7 +5,6 @@ use crate::cache::Cache;
use crate::plc::PlcClient;
use crate::state::AppState;
use crate::types::PlainPassword;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -131,7 +130,7 @@ async fn assert_valid_did_document_for_service(
did: &crate::types::Did,
with_retry: bool,
) -> Result<(), ApiError> {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let expected_endpoint = format!("https://{}", hostname);
if did.as_str().starts_with("did:plc:") {
@@ -201,7 +200,7 @@ async fn assert_valid_did_document_for_service(
.await
.map_err(ApiError::InvalidRequest)?;
let server_rotation_key = std::env::var("PLC_ROTATION_KEY").ok();
let server_rotation_key = tranquil_config::get().secrets.plc_rotation_key.clone();
if let Some(ref expected_rotation_key) = server_rotation_key {
let rotation_keys = doc_data
.get("rotationKeys")
@@ -552,7 +551,7 @@ pub async fn request_account_delete(
.create_deletion_request(&confirmation_token, session_mfa.did(), expires_at)
.await
.log_db_err("creating deletion token")?;
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_account_deletion(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
+3 -4
View File
@@ -3,7 +3,6 @@ use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse};
use crate::auth::{Auth, NotTakendown};
use crate::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -105,7 +104,7 @@ pub async fn request_email_update(
}
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_short_token_email(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -367,7 +366,7 @@ pub async fn update_email(
);
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -531,7 +530,7 @@ pub async fn authorize_email_update(
info!(did = %did, "Email update authorized via link click");
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let redirect_url = format!(
"https://{}/app/verify?type=email-authorize-success",
hostname
+3 -4
View File
@@ -3,7 +3,6 @@ use crate::api::error::DbResultExt;
use crate::auth::{Admin, Auth, NotTakendown};
use crate::state::AppState;
use crate::types::Did;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -15,7 +14,7 @@ use tracing::error;
const BASE32_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
fn gen_random_token() -> String {
pub(crate) fn gen_random_token() -> String {
let mut rng = rand::thread_rng();
let gen_segment = |rng: &mut rand::rngs::ThreadRng, len: usize| -> String {
(0..len)
@@ -25,8 +24,8 @@ fn gen_random_token() -> String {
format!("{}-{}", gen_segment(&mut rng, 5), gen_segment(&mut rng, 5))
}
fn gen_invite_code() -> String {
let hostname = pds_hostname();
pub fn gen_invite_code() -> String {
let hostname = &tranquil_config::get().server.hostname;
let hostname_prefix = hostname.replace('.', "-");
format!("{}-{}", hostname_prefix, gen_random_token())
}
+27 -18
View File
@@ -1,18 +1,20 @@
use crate::BUILD_VERSION;
use crate::state::AppState;
use crate::util::{discord_app_id, discord_bot_username, pds_hostname, telegram_bot_username};
use crate::util::{discord_app_id, discord_bot_username, telegram_bot_username};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde_json::json;
fn get_available_comms_channels() -> Vec<tranquil_db_traits::CommsChannel> {
use tranquil_db_traits::CommsChannel;
let cfg = tranquil_config::get();
let mut channels = vec![CommsChannel::Email];
if std::env::var("DISCORD_BOT_TOKEN").is_ok() {
if cfg.discord.bot_token.is_some() {
channels.push(CommsChannel::Discord);
}
if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() {
if cfg.telegram.bot_token.is_some() {
channels.push(CommsChannel::Telegram);
}
if std::env::var("SIGNAL_CLI_PATH").is_ok() && std::env::var("SIGNAL_SENDER_NUMBER").is_ok() {
if cfg.signal.sender_number.is_some() {
channels.push(CommsChannel::Signal);
}
channels
@@ -26,20 +28,17 @@ pub async fn robots_txt() -> impl IntoResponse {
)
}
pub fn is_self_hosted_did_web_enabled() -> bool {
std::env::var("ENABLE_SELF_HOSTED_DID_WEB")
.map(|v| v != "false" && v != "0")
.unwrap_or(true)
tranquil_config::get().server.enable_pds_hosted_did_web
}
pub async fn describe_server() -> impl IntoResponse {
let pds_hostname = pds_hostname();
let domains_str =
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.to_string());
let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect();
let invite_code_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
let privacy_policy = std::env::var("PRIVACY_POLICY_URL").ok();
let terms_of_service = std::env::var("TERMS_OF_SERVICE_URL").ok();
let contact_email = std::env::var("CONTACT_EMAIL").ok();
let cfg = tranquil_config::get();
let pds_hostname = &cfg.server.hostname;
let domains = cfg.server.user_handle_domain_list();
let invite_code_required = cfg.server.invite_code_required;
let privacy_policy = cfg.server.privacy_policy_url.clone();
let terms_of_service = cfg.server.terms_of_service_url.clone();
let contact_email = cfg.server.contact_email.clone();
let mut links = serde_json::Map::new();
if let Some(pp) = privacy_policy {
links.insert("privacyPolicy".to_string(), json!(pp));
@@ -57,7 +56,7 @@ pub async fn describe_server() -> impl IntoResponse {
"did": format!("did:web:{}", pds_hostname),
"links": links,
"contact": contact,
"version": env!("CARGO_PKG_VERSION"),
"version": BUILD_VERSION,
"availableCommsChannels": get_available_comms_channels(),
"selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
});
@@ -74,7 +73,17 @@ pub async fn describe_server() -> impl IntoResponse {
}
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
match state.infra_repo.health_check().await {
Ok(true) => (StatusCode::OK, "OK"),
_ => (StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable"),
Ok(true) => (
StatusCode::OK,
Json(json!({
"version": format!("tranquil {}", BUILD_VERSION)
})),
),
_ => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"error": "Service Unavailable"
})),
),
}
}
@@ -2,7 +2,6 @@ use crate::api::ApiError;
use crate::api::error::DbResultExt;
use crate::auth::{Active, Auth};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -147,7 +146,7 @@ pub async fn get_did_document(
}
async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_json::Value {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let user = match state.user_repo.get_user_for_did_doc_build(did).await {
Ok(Some(row)) => row,
+3 -3
View File
@@ -50,9 +50,9 @@ pub use reauth::{
};
pub use service_auth::get_service_auth;
pub use session::{
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,
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,
};
pub use signing_key::reserve_signing_key;
pub use totp::{
@@ -24,7 +24,6 @@ use crate::auth::{ServiceTokenVerifier, generate_app_password, is_service_token}
use crate::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited};
use crate::state::AppState;
use crate::types::{Did, Handle, PlainPassword};
use crate::util::{pds_hostname, pds_hostname_without_port};
use crate::validation::validate_password;
fn generate_setup_token() -> String {
@@ -113,21 +112,23 @@ pub async fn create_passkey_account(
.map(|d| d.starts_with("did:web:"))
.unwrap_or(false);
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let pds_suffix = format!(".{}", hostname_for_handles);
let hostname = &tranquil_config::get().server.hostname;
let available_domains = tranquil_config::get().server.available_user_domain_list();
let matched_domain = available_domains
.iter()
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len());
let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
input
let handle = if !input.handle.contains('.') || matched_domain.is_some() {
let handle_to_validate = match matched_domain {
Some(domain) => input
.handle
.strip_suffix(&pds_suffix)
.unwrap_or(&input.handle)
} else {
&input.handle
.strip_suffix(&format!(".{}", domain))
.unwrap_or(&input.handle),
None => &input.handle,
};
match crate::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => format!("{}.{}", h, hostname_for_handles),
Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])),
Err(_) => {
return ApiError::InvalidHandle(None).into_response();
}
@@ -147,13 +148,21 @@ pub async fn create_passkey_account(
return ApiError::InvalidEmail.into_response();
}
let _validated_invite_code = if let Some(ref code) = input.invite_code {
let is_bootstrap = state.bootstrap_invite_code.is_some()
&& state.user_repo.count_users().await.unwrap_or(1) == 0;
let _validated_invite_code = if is_bootstrap {
match input.invite_code.as_deref() {
Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => None,
_ => return ApiError::InvalidInviteCode.into_response(),
}
} else if let Some(ref code) = input.invite_code {
match state.infra_repo.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return ApiError::InvalidInviteCode.into_response(),
}
} else {
let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
let invite_required = tranquil_config::get().server.invite_code_required;
if invite_required {
return ApiError::InviteCodeRequired.into_response();
}
@@ -237,7 +246,8 @@ pub async fn create_passkey_account(
let did = match did_type {
"web" => {
let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles);
let pds_hostname = tranquil_config::get().server.hostname_without_port();
let subdomain_host = format!("{}.{}", input.handle, pds_hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account");
@@ -309,8 +319,11 @@ pub async fn create_passkey_account(
.into_response();
}
} else {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key));
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| crate::plc::signing_key_to_did_key(&secret_key));
let genesis_result = match crate::plc::create_genesis_operation(
&secret_key,
@@ -401,12 +414,14 @@ pub async fn create_passkey_account(
};
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| {
json!({
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
Some(json!({
"$type": "app.bsky.actor.defs#personalDetailsPref",
"birthDate": "1998-05-06T00:00:00.000Z"
})
});
}))
} else {
None
};
let handle_typed: Handle = match handle.parse() {
Ok(h) => h,
@@ -443,7 +458,11 @@ pub async fn create_passkey_account(
commit_cid: commit_cid.to_string(),
repo_rev: rev.as_ref().to_string(),
genesis_block_cids,
invite_code: input.invite_code.clone(),
invite_code: if is_bootstrap {
None
} else {
input.invite_code.clone()
},
birthdate_pref,
};
@@ -820,7 +839,7 @@ pub async fn request_passkey_recovery(
_rate_limit: RateLimited<PasswordResetLimit>,
Json(input): Json<RequestPasskeyRecoveryInput>,
) -> Response {
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let identifier = input.email.trim().to_lowercase();
let identifier = identifier.strip_prefix('@').unwrap_or(&identifier);
let normalized_handle =
@@ -855,7 +874,7 @@ pub async fn request_passkey_recovery(
return ApiError::InternalError(None).into_response();
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let recovery_url = format!(
"https://{}/app/recover-passkey?did={}&token={}",
hostname,
@@ -942,6 +961,20 @@ pub async fn recover_passkey_account(
if result.passkeys_deleted > 0 {
info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery");
}
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user.id).await {
let actual_channel =
crate::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
if let Err(e) = state
.user_repo
.set_channel_verified(&input.did, actual_channel)
.await
{
warn!(
"Failed to implicitly verify channel on passkey recovery: {:?}",
e
);
}
}
info!(did = %input.did, "Passkey-only account recovered with temporary password");
SuccessResponse::ok().into_response()
}
+16 -3
View File
@@ -7,7 +7,6 @@ use crate::auth::{
use crate::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit};
use crate::state::AppState;
use crate::types::PlainPassword;
use crate::util::{pds_hostname, pds_hostname_without_port};
use crate::validation::validate_password;
use axum::{
Json,
@@ -38,7 +37,7 @@ pub async fn request_password_reset(
if identifier.is_empty() {
return ApiError::InvalidRequest("email or handle is required".into()).into_response();
}
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized = identifier.to_lowercase();
let normalized = normalized.strip_prefix('@').unwrap_or(&normalized);
let is_email_lookup = normalized.contains('@');
@@ -78,7 +77,7 @@ pub async fn request_password_reset(
error!("DB error setting reset code: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_password_reset(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -183,6 +182,20 @@ pub async fn reset_password(
}
}))
.await;
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user_id).await {
let actual_channel =
crate::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
if let Err(e) = state
.user_repo
.set_channel_verified(&user.did, actual_channel)
.await
{
warn!(
"Failed to implicitly verify channel on password reset: {:?}",
e
);
}
}
info!("Password reset completed for user {}", user_id);
EmptyResponse::ok().into_response()
}
+95 -12
View File
@@ -7,7 +7,6 @@ use crate::auth::{
use crate::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit};
use crate::state::AppState;
use crate::types::{AccountState, Did, Handle, PlainPassword};
use crate::util::{pds_hostname, pds_hostname_without_port};
use axum::{
Json,
extract::State,
@@ -66,10 +65,10 @@ pub async fn create_session(
"create_session called with identifier: {}",
input.identifier
);
let pds_host = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let pds_host = &tranquil_config::get().server.hostname;
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_identifier =
NormalizedLoginIdentifier::normalize(&input.identifier, hostname_for_handles);
NormalizedLoginIdentifier::normalize(&input.identifier, &hostname_for_handles);
info!(
"Normalized identifier: {} -> {}",
input.identifier, normalized_identifier
@@ -150,12 +149,23 @@ pub async fn create_session(
.unwrap_or(false);
if !is_verified && !is_delegated {
warn!("Login attempt for unverified account: {}", row.did);
let resend_info = auto_resend_verification(&state, &row.did).await;
let handle = resend_info
.as_ref()
.map(|r| r.handle.to_string())
.unwrap_or_else(|| row.handle.to_string());
let channel = resend_info
.as_ref()
.map(|r| r.channel.as_str())
.unwrap_or(row.preferred_comms_channel.as_str());
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AccountNotVerified",
"error": "account_not_verified",
"message": "Please verify your account before logging in",
"did": row.did
"did": row.did,
"handle": handle,
"channel": channel
})),
)
.into_response();
@@ -182,7 +192,7 @@ pub async fn create_session(
return ApiError::LegacyLoginBlocked.into_response();
}
Ok(crate::auth::legacy_2fa::Legacy2faOutcome::ChallengeSent(code)) => {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_2fa_code(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -286,7 +296,7 @@ pub async fn create_session(
ip = %client_ip,
"Legacy login on TOTP-enabled account - sending notification"
);
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_legacy_login(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -341,7 +351,7 @@ pub async fn get_session(
let preferred_channel_verified = row
.channel_verification
.is_verified(row.preferred_comms_channel);
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let handle = full_handle(&row.handle, pds_hostname);
let account_state = AccountState::from_db_fields(
row.deactivated_at,
@@ -545,7 +555,7 @@ pub async fn refresh_session(
let preferred_channel_verified = u
.channel_verification
.is_verified(u.preferred_comms_channel);
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let handle = full_handle(&u.handle, pds_hostname);
let account_state =
AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None);
@@ -707,7 +717,7 @@ pub async fn confirm_signup(
return ApiError::InternalError(None).into_response();
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_welcome(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -731,6 +741,79 @@ pub async fn confirm_signup(
.into_response()
}
const AUTO_VERIFY_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(120);
pub struct AutoResendResult {
pub handle: tranquil_types::Handle,
pub channel: tranquil_db_traits::CommsChannel,
}
pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option<AutoResendResult> {
let debounce_key = crate::cache_keys::auto_verify_sent_key(did.as_str());
let debounced = state.cache.get(&debounce_key).await.is_some();
let row = match state.user_repo.get_resend_verification_by_did(did).await {
Ok(Some(row)) => row,
Ok(None) => return None,
Err(e) => {
warn!(
"Failed to fetch resend verification info for {}: {:?}",
did, e
);
return None;
}
};
if row.channel_verification.has_any_verified() {
return None;
}
let result = AutoResendResult {
handle: row.handle.clone(),
channel: row.channel,
};
let is_bot_channel = matches!(
row.channel,
tranquil_db_traits::CommsChannel::Telegram | tranquil_db_traits::CommsChannel::Discord
);
if is_bot_channel || debounced {
return Some(result);
}
let recipient = match row.channel {
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(),
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(),
_ => return Some(result),
};
if recipient.is_empty() {
warn!(
"No recipient configured for auto-resend verification: {}",
did
);
return Some(result);
}
let verification_token =
crate::auth::verification_token::generate_signup_token(did, row.channel, &recipient);
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
row.id,
row.channel,
&recipient,
&formatted_token,
hostname,
)
.await
{
warn!("Failed to auto-resend verification for {}: {:?}", did, e);
return Some(result);
}
let _ = state
.cache
.set(&debounce_key, "1", AUTO_VERIFY_DEBOUNCE)
.await;
Some(result)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResendVerificationInput {
@@ -777,7 +860,7 @@ pub async fn resend_verification(
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
+1 -2
View File
@@ -9,7 +9,6 @@ use crate::auth::{
use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
use crate::state::AppState;
use crate::types::PlainPassword;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -52,7 +51,7 @@ pub async fn create_totp_secret(
.log_db_err("fetching handle")?
.ok_or(ApiError::AccountNotFound)?;
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let uri = generate_totp_uri(&secret, &handle, hostname);
let qr_code = generate_qr_png_base64(&secret, &handle, hostname).map_err(|e| {
@@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::state::AppState;
use crate::util::pds_hostname;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -71,7 +70,7 @@ pub async fn resend_migration_verification(
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
let token = crate::auth::verification_token::generate_migration_token(&user.did, &email);
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
@@ -1,7 +1,6 @@
use crate::api::error::{ApiError, DbResultExt};
use crate::comms::comms_repo;
use crate::types::Did;
use crate::util::pds_hostname;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
@@ -162,7 +161,7 @@ async fn handle_channel_update(
user_id,
channel,
&recipient,
pds_hostname(),
&tranquil_config::get().server.hostname,
)
.await
{
@@ -260,7 +259,7 @@ async fn handle_signup_verification(
user.id,
channel,
&recipient,
pds_hostname(),
&tranquil_config::get().server.hostname,
)
.await
{
@@ -8,7 +8,6 @@ use tracing::{debug, info, warn};
use crate::comms::comms_repo;
use crate::state::AppState;
use crate::util::pds_hostname;
#[derive(Deserialize)]
struct TelegramUpdate {
@@ -32,9 +31,9 @@ pub async fn handle_telegram_webhook(
headers: HeaderMap,
body: String,
) -> impl IntoResponse {
let expected_secret = match std::env::var("TELEGRAM_WEBHOOK_SECRET") {
Ok(s) => s,
Err(_) => {
let expected_secret = match &tranquil_config::get().telegram.webhook_secret {
Some(s) => s.clone(),
None => {
warn!("Telegram webhook called but TELEGRAM_WEBHOOK_SECRET is not configured");
return StatusCode::FORBIDDEN;
}
@@ -88,7 +87,7 @@ pub async fn handle_telegram_webhook(
user_id,
tranquil_db_traits::CommsChannel::Telegram,
&from.id.to_string(),
pds_hostname(),
&tranquil_config::get().server.hostname,
)
.await
{
+3 -6
View File
@@ -64,13 +64,10 @@ pub struct DidResolver {
impl DidResolver {
pub fn new() -> Self {
let cache_ttl_secs: u64 = std::env::var("DID_CACHE_TTL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300);
let cfg = tranquil_config::get();
let cache_ttl_secs = cfg.plc.did_cache_ttl_secs;
let plc_directory_url = std::env::var("PLC_DIRECTORY_URL")
.unwrap_or_else(|_| "https://plc.directory".to_string());
let plc_directory_url = cfg.plc.directory_url.clone();
let client = Client::builder()
.timeout(Duration::from_secs(10))
+2 -4
View File
@@ -1,4 +1,3 @@
use crate::util::pds_hostname;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
@@ -146,10 +145,9 @@ pub struct ServiceTokenVerifier {
impl ServiceTokenVerifier {
pub fn new() -> Self {
let plc_directory_url = std::env::var("PLC_DIRECTORY_URL")
.unwrap_or_else(|_| "https://plc.directory".to_string());
let plc_directory_url = tranquil_config::get().plc.directory_url.clone();
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let pds_did: Did = format!("did:web:{}", pds_hostname)
.parse()
.expect("PDS hostname produces a valid DID");
@@ -61,13 +61,7 @@ pub struct VerificationToken {
fn derive_verification_key() -> [u8; 32] {
use hkdf::Hkdf;
let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-master-key-not-for-production".to_string()
} else {
panic!("MASTER_KEY must be set");
}
});
let master_key = tranquil_config::get().secrets.master_key_or_default();
let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes());
let mut key = [0u8; 32];
hk.expand(b"tranquil-pds-verification-token-v1", &mut key)
@@ -327,8 +321,13 @@ pub fn normalize_token_input(input: &str) -> String {
mod tests {
use super::*;
fn init() {
tranquil_config::ensure_test_defaults();
}
#[test]
fn test_signup_token() {
init();
let did: Did = "did:plc:test123".parse().unwrap();
let channel = CommsChannel::Email;
let identifier = "test@example.com";
@@ -343,6 +342,7 @@ mod tests {
#[test]
fn test_migration_token() {
init();
let did: Did = "did:plc:test123".parse().unwrap();
let email = "test@example.com";
let token = generate_migration_token(&did, email);
@@ -355,6 +355,7 @@ mod tests {
#[test]
fn test_token_case_insensitive() {
init();
let did: Did = "did:plc:test123".parse().unwrap();
let token = generate_signup_token(&did, CommsChannel::Email, "Test@Example.COM");
let result = verify_signup_token(&token, CommsChannel::Email, "test@example.com");
@@ -363,6 +364,7 @@ mod tests {
#[test]
fn test_token_wrong_identifier() {
init();
let did: Did = "did:plc:test123".parse().unwrap();
let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com");
let result = verify_signup_token(&token, CommsChannel::Email, "other@example.com");
@@ -371,6 +373,7 @@ mod tests {
#[test]
fn test_token_wrong_channel() {
init();
let did: Did = "did:plc:test123".parse().unwrap();
let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com");
let result = verify_signup_token(&token, CommsChannel::Discord, "test@example.com");
@@ -379,6 +382,7 @@ mod tests {
#[test]
fn test_expired_token() {
init();
let did: Did = "did:plc:test123".parse().unwrap();
let token = generate_token_with_expiry(
&did,
@@ -394,12 +398,14 @@ mod tests {
#[test]
fn test_invalid_token() {
init();
let result = verify_signup_token("invalid-token", CommsChannel::Email, "test@example.com");
assert!(matches!(result, Err(VerifyError::InvalidFormat)));
}
#[test]
fn test_purpose_mismatch() {
init();
let did: Did = "did:plc:test123".parse().unwrap();
let email = "test@example.com";
let signup_token = generate_signup_token(&did, CommsChannel::Email, email);
@@ -409,6 +415,7 @@ mod tests {
#[test]
fn test_discord_channel() {
init();
let did: Did = "did:plc:test123".parse().unwrap();
let discord_id = "123456789012345678";
let token = generate_signup_token(&did, CommsChannel::Discord, discord_id);
+4
View File
@@ -33,3 +33,7 @@ pub fn email_update_key(did: &str) -> String {
pub fn scope_ref_key(cid: &str) -> String {
format!("scope_ref:{}", cid)
}
pub fn auto_verify_sent_key(did: &str) -> String {
format!("auto_verify_sent:{}", did)
}
+1 -1
View File
@@ -7,4 +7,4 @@ pub use tranquil_comms::{
mime_encode_header, sanitize_header_value, validate_locale,
};
pub use service::{CommsService, repo as comms_repo};
pub use service::{CommsService, repo as comms_repo, resolve_delivery_channel};
+10 -8
View File
@@ -21,14 +21,9 @@ pub struct CommsService {
impl CommsService {
pub fn new(infra_repo: Arc<dyn InfraRepository>) -> Self {
let poll_interval_ms: u64 = std::env::var("NOTIFICATION_POLL_INTERVAL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000);
let batch_size: i64 = std::env::var("NOTIFICATION_BATCH_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
let cfg = tranquil_config::get();
let poll_interval_ms = cfg.notifications.poll_interval_ms;
let batch_size = cfg.notifications.batch_size;
Self {
infra_repo,
senders: HashMap::new(),
@@ -174,6 +169,13 @@ struct ResolvedRecipient {
recipient: String,
}
pub fn resolve_delivery_channel(
prefs: &UserCommsPrefs,
channel: tranquil_db_traits::CommsChannel,
) -> tranquil_db_traits::CommsChannel {
resolve_recipient(prefs, channel).channel
}
fn resolve_recipient(
prefs: &UserCommsPrefs,
channel: tranquil_db_traits::CommsChannel,
+4 -48
View File
@@ -48,39 +48,10 @@ pub struct AuthConfig {
impl AuthConfig {
pub fn init() -> &'static Self {
CONFIG.get_or_init(|| {
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-jwt-secret-not-for-production".to_string()
} else {
panic!(
"JWT_SECRET environment variable must be set in production. \
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 for development/testing."
);
}
});
let secrets = &tranquil_config::get().secrets;
let dpop_secret = std::env::var("DPOP_SECRET").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-dpop-secret-not-for-production".to_string()
} else {
panic!(
"DPOP_SECRET environment variable must be set in production. \
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 for development/testing."
);
}
});
if jwt_secret.len() < 32
&& std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err()
{
panic!("JWT_SECRET must be at least 32 characters");
}
if dpop_secret.len() < 32
&& std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err()
{
panic!("DPOP_SECRET must be at least 32 characters");
}
let jwt_secret = secrets.jwt_secret_or_default();
let dpop_secret = secrets.dpop_secret_or_default();
let mut hasher = Sha256::new();
hasher.update(b"oauth-signing-key-derivation:");
@@ -114,22 +85,7 @@ impl AuthConfig {
let kid_hash = kid_hasher.finalize();
let signing_key_id = URL_SAFE_NO_PAD.encode(&kid_hash[..8]);
let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-master-key-not-for-production".to_string()
} else {
panic!(
"MASTER_KEY environment variable must be set in production. \
Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 for development/testing."
);
}
});
if master_key.len() < 32
&& std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err()
{
panic!("MASTER_KEY must be at least 32 characters");
}
let master_key = secrets.master_key_or_default();
let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes());
let mut key_encryption_key = [0u8; 32];
+3 -9
View File
@@ -1,6 +1,5 @@
use crate::circuit_breaker::CircuitBreaker;
use crate::sync::firehose::SequencedEvent;
use crate::util::pds_hostname;
use reqwest::Client;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -42,18 +41,13 @@ impl Crawlers {
self
}
pub fn from_env() -> Option<Self> {
let hostname = pds_hostname();
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let hostname = &cfg.server.hostname;
if hostname == "localhost" {
return None;
}
let crawler_urls: Vec<String> = std::env::var("CRAWLERS")
.unwrap_or_default()
.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.trim().to_string())
.collect();
let crawler_urls = cfg.firehose.crawler_list();
if crawler_urls.is_empty() {
return None;
+3 -3
View File
@@ -91,9 +91,9 @@ pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool {
if !handle.contains('.') {
return true;
}
let service_domains: Vec<String> = std::env::var("PDS_SERVICE_HANDLE_DOMAINS")
.map(|s| s.split(',').map(|d| d.trim().to_string()).collect())
.unwrap_or_else(|_| vec![hostname.to_string()]);
let service_domains = tranquil_config::try_get()
.map(|c| c.server.user_handle_domain_list())
.unwrap_or_else(|| vec![hostname.to_string()]);
service_domains
.iter()
.any(|domain| handle.ends_with(&format!(".{}", domain)) || handle == domain)
+50 -4
View File
@@ -39,10 +39,23 @@ use http::StatusCode;
use serde_json::json;
use state::AppState;
use tower::ServiceBuilder;
use tower_http::cors::{Any, CorsLayer};
use tower_http::{
cors::{Any, CorsLayer},
services::{ServeDir, ServeFile},
};
pub use tranquil_db_traits::AccountStatus;
pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey};
#[cfg(debug_assertions)]
pub const BUILD_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (built ",
env!("BUILD_TIMESTAMP"),
")"
);
#[cfg(not(debug_assertions))]
pub const BUILD_VERSION: &str = env!("CARGO_PKG_VERSION");
pub fn app(state: AppState) -> Router {
let xrpc_router = Router::new()
.route("/_health", get(api::server::health))
@@ -589,6 +602,7 @@ pub fn app(state: AppState) -> Router {
)
.route("/authorize/consent", get(oauth::endpoints::consent_get))
.route("/authorize/consent", post(oauth::endpoints::consent_post))
.route("/authorize/renew", post(oauth::endpoints::authorize_renew))
.route(
"/authorize/redirect",
get(oauth::endpoints::authorize_redirect),
@@ -639,7 +653,9 @@ pub fn app(state: AppState) -> Router {
get(oauth::endpoints::oauth_authorization_server),
);
Router::new()
if cfg!(feature = "frontend") {}
let router = Router::new()
.nest_service("/xrpc", xrpc_service)
.nest("/oauth", oauth_router)
.nest("/.well-known", well_known_router)
@@ -658,7 +674,9 @@ pub fn app(state: AppState) -> Router {
post(api::discord_webhook::handle_discord_webhook)
.layer(DefaultBodyLimit::max(64 * 1024)),
)
.layer(DefaultBodyLimit::max(util::get_max_blob_size()))
.layer(DefaultBodyLimit::max(
tranquil_config::get().server.max_blob_size as usize,
))
.layer(axum::middleware::map_response(rewrite_422_to_400))
.layer(middleware::from_fn(metrics::metrics_middleware))
.layer(
@@ -682,7 +700,35 @@ pub fn app(state: AppState) -> Router {
util::HEADER_ATPROTO_CONTENT_LABELERS,
]),
)
.with_state(state)
.with_state(state);
if cfg!(feature = "frontend") && tranquil_config::get().frontend.enabled {
let frontend_dir = &tranquil_config::get().frontend.dir;
let index_path = format!("{}/index.html", frontend_dir);
let homepage_path = format!("{}/homepage.html", frontend_dir);
let homepage_exists = std::path::Path::new(&homepage_path).exists();
let homepage_file = if homepage_exists {
homepage_path
} else {
index_path.clone()
};
let spa_router = Router::new().fallback_service(ServeFile::new(&index_path));
let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path));
return router
.route(
"/oauth-client-metadata.json",
get(oauth::endpoints::frontend_client_metadata),
)
.route_service("/", ServeFile::new(&homepage_file))
.nest("/app", spa_router)
.fallback_service(serve_dir);
}
router
}
async fn rewrite_422_to_400(response: axum::response::Response) -> axum::response::Response {
+93 -28
View File
@@ -1,16 +1,13 @@
use clap::{Parser, Subcommand};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use tranquil_pds::BUILD_VERSION;
use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender};
const BUILD_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (built ",
env!("BUILD_TIMESTAMP"),
")"
);
use tranquil_pds::crawlers::{Crawlers, start_crawlers_service};
use tranquil_pds::scheduled::{
backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks,
@@ -18,10 +15,82 @@ use tranquil_pds::scheduled::{
};
use tranquil_pds::state::AppState;
#[derive(Parser)]
#[command(name = "tranquil-pds", version = BUILD_VERSION, about = "Tranquil AT Protocol PDS")]
struct Cli {
/// Path to a TOML configuration file (also settable via TRANQUIL_PDS_CONFIG env var)
#[arg(short, long, value_name = "FILE", env = "TRANQUIL_PDS_CONFIG")]
config: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
/// Validate the configuration and exit
Validate {
/// Skip validation of secrets and database URL (useful when secrets
/// are provided at runtime via environment variables / secret files)
#[arg(long)]
ignore_secrets: bool,
},
/// Print a TOML configuration template to stdout
ConfigTemplate,
}
#[tokio::main]
async fn main() -> ExitCode {
dotenvy::dotenv().ok();
let cli = Cli::parse();
// Handle subcommands that don't need full startup
if let Some(command) = &cli.command {
return match command {
Command::ConfigTemplate => {
print!("{}", tranquil_config::template());
ExitCode::SUCCESS
}
Command::Validate { ignore_secrets } => {
let config = match tranquil_config::load(cli.config.as_ref()) {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to load configuration: {e:#}");
return ExitCode::FAILURE;
}
};
match config.validate(*ignore_secrets) {
Ok(()) => {
println!("Configuration is valid.");
ExitCode::SUCCESS
}
Err(e) => {
eprint!("{e}");
ExitCode::FAILURE
}
}
}
};
}
tracing_subscriber::fmt::init();
let config = match tranquil_config::load(cli.config.as_ref()) {
Ok(c) => c,
Err(e) => {
error!("Failed to load configuration: {e:#}");
return ExitCode::FAILURE;
}
};
if let Err(e) = config.validate(false) {
error!("{e}");
return ExitCode::FAILURE;
}
tranquil_config::init(config);
tranquil_pds::metrics::init_metrics();
match run().await {
@@ -66,14 +135,16 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut comms_service = CommsService::new(state.infra_repo.clone());
let mut deferred_discord_endpoint: Option<(DiscordSender, String, String)> = None;
if let Some(email_sender) = EmailSender::from_env() {
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)");
}
if let Some(discord_sender) = DiscordSender::from_env() {
if let Some(discord_sender) = DiscordSender::from_config(cfg) {
info!("Discord comms enabled");
match discord_sender.resolve_bot_username().await {
Ok(username) => {
@@ -96,8 +167,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Some(public_key) => {
tranquil_pds::util::set_discord_public_key(public_key);
info!("Discord Ed25519 public key loaded");
let hostname = std::env::var("PDS_HOSTNAME")
.unwrap_or_else(|_| "localhost".to_string());
let hostname = &tranquil_config::get().server.hostname;
let webhook_url = format!("https://{}/webhook/discord", hostname);
match discord_sender.register_slash_command(&app_id).await {
Ok(()) => info!("Discord /start slash command registered"),
@@ -118,22 +188,19 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
comms_service = comms_service.register_sender(discord_sender);
}
if let Some(telegram_sender) = TelegramSender::from_env() {
let secret_token = match std::env::var("TELEGRAM_WEBHOOK_SECRET") {
Ok(s) => s,
Err(_) => {
return Err(
"TELEGRAM_BOT_TOKEN is set but TELEGRAM_WEBHOOK_SECRET is missing. Both are required for secure Telegram integration.".into()
);
}
};
if let Some(telegram_sender) = TelegramSender::from_config(cfg) {
// Safe to unwrap: validated in TranquilConfig::validate()
let secret_token = tranquil_config::get()
.telegram
.webhook_secret
.clone()
.expect("telegram.webhook_secret checked during config validation");
info!("Telegram comms enabled");
match telegram_sender.resolve_bot_username().await {
Ok(username) => {
info!(bot_username = %username, "Resolved Telegram bot username");
tranquil_pds::util::set_telegram_bot_username(username);
let hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = tranquil_config::get().server.hostname.clone();
let webhook_url = format!("https://{}/webhook/telegram", hostname);
match telegram_sender
.set_webhook(&webhook_url, Some(&secret_token))
@@ -150,14 +217,14 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
comms_service = comms_service.register_sender(telegram_sender);
}
if let Some(signal_sender) = SignalSender::from_env() {
if let Some(signal_sender) = SignalSender::from_config(cfg) {
info!("Signal comms enabled");
comms_service = comms_service.register_sender(signal_sender);
}
let comms_handle = tokio::spawn(comms_service.run(shutdown.clone()));
let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() {
let crawlers_handle = if let Some(crawlers) = Crawlers::from_config(cfg) {
let crawlers = Arc::new(
crawlers.with_circuit_breaker(state.circuit_breakers.relay_notification.clone()),
);
@@ -197,11 +264,9 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let app = tranquil_pds::app(state);
let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("SERVER_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000);
let cfg = tranquil_config::get();
let host = &cfg.server.host;
let port = cfg.server.port;
let addr: SocketAddr = format!("{}:{}", host, port)
.parse()
+2 -5
View File
@@ -35,12 +35,9 @@ fn get_slur_regexes() -> &'static Vec<Regex> {
fn get_extra_banned_words() -> &'static Vec<String> {
EXTRA_BANNED_WORDS.get_or_init(|| {
std::env::var("PDS_BANNED_WORDS")
tranquil_config::try_get()
.map(|c| c.server.banned_word_list())
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect()
})
}
@@ -10,7 +10,7 @@ use crate::rate_limit::{
};
use crate::state::AppState;
use crate::types::{Did, Handle, PlainPassword};
use crate::util::{extract_client_ip, pds_hostname, pds_hostname_without_port};
use crate::util::extract_client_ip;
use axum::{
Json,
extract::{Query, State},
@@ -28,6 +28,8 @@ use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, Requ
use urlencoding::encode as url_encode;
const DEVICE_COOKIE_NAME: &str = "oauth_device_id";
const RENEW_EXPIRY_SECONDS: i64 = 600;
const MAX_RENEWAL_STALENESS_SECONDS: i64 = 3600;
fn redirect_see_other(uri: &str) -> Response {
(
@@ -253,8 +255,8 @@ pub async fn authorize_get(
if let Some(ref login_hint) = request_data.parameters.login_hint {
tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation");
let hostname_for_handles = pds_hostname_without_port();
let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles);
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized = NormalizedLoginIdentifier::normalize(login_hint, &hostname_for_handles);
tracing::info!(normalized = %normalized, "Normalized login_hint");
match state
@@ -526,13 +528,13 @@ pub async fn authorize_post(
url_encode(error_msg)
))
};
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_username =
NormalizedLoginIdentifier::normalize(&form.username, hostname_for_handles);
NormalizedLoginIdentifier::normalize(&form.username, &hostname_for_handles);
tracing::debug!(
original_username = %form.username,
normalized_username = %normalized_username,
pds_hostname = %pds_hostname(),
pds_hostname = %tranquil_config::get().server.hostname,
"Normalized username for lookup"
);
let user = match state
@@ -556,14 +558,6 @@ pub async fn authorize_post(
if user.takedown_ref.is_some() {
return show_login_error("This account has been taken down.", json_response);
}
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {
return show_login_error(
"Please verify your account before logging in.",
json_response,
);
}
if user.account_type.is_delegated() {
if state
.oauth_repo
@@ -630,6 +624,35 @@ pub async fn authorize_post(
if !password_valid {
return show_login_error("Invalid handle/email or password.", json_response);
}
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {
let resend_info = crate::api::server::auto_resend_verification(&state, &user.did).await;
let handle = resend_info
.as_ref()
.map(|r| r.handle.to_string())
.unwrap_or_else(|| form.username.clone());
let channel = resend_info
.map(|r| r.channel.as_str().to_owned())
.unwrap_or_else(|| user.preferred_comms_channel.as_str().to_owned());
if json_response {
return (
axum::http::StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "account_not_verified",
"error_description": "Please verify your account before logging in.",
"did": user.did,
"handle": handle,
"channel": channel
})),
)
.into_response();
}
return redirect_see_other(&format!(
"/app/oauth/login?request_uri={}&error={}",
url_encode(&form.request_uri),
url_encode("account_not_verified")
));
}
let has_totp = crate::api::server::has_totp_enabled(&state, &user.did).await;
if has_totp {
let device_cookie = extract_device_cookie(&headers);
@@ -677,7 +700,7 @@ pub async fn authorize_post(
.await
{
Ok(challenge) => {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = enqueue_2fa_code(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -955,31 +978,45 @@ pub async fn authorize_select(
};
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {
return json_error(
let resend_info = crate::api::server::auto_resend_verification(&state, &did).await;
return (
StatusCode::FORBIDDEN,
"access_denied",
"Please verify your account before logging in.",
);
Json(serde_json::json!({
"error": "account_not_verified",
"error_description": "Please verify your account before logging in.",
"did": did,
"handle": resend_info.as_ref().map(|r| r.handle.to_string()),
"channel": resend_info.as_ref().map(|r| r.channel.as_str())
})),
)
.into_response();
}
let has_totp = crate::api::server::has_totp_enabled(&state, &did).await;
let select_early_device_typed = device_id.clone();
if has_totp {
if state
.oauth_repo
.set_authorization_did(&select_request_id, &did, Some(&select_early_device_typed))
.await
.is_err()
{
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
let device_is_trusted =
crate::api::server::is_device_trusted(state.oauth_repo.as_ref(), &device_id, &did)
.await;
if !device_is_trusted {
if state
.oauth_repo
.set_authorization_did(&select_request_id, &did, Some(&select_early_device_typed))
.await
.is_err()
{
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
return Json(serde_json::json!({
"needs_totp": true
}))
.into_response();
}
return Json(serde_json::json!({
"needs_totp": true
}))
.into_response();
let _ =
crate::api::server::extend_device_trust(state.oauth_repo.as_ref(), &device_id).await;
}
if user.two_factor_enabled {
let _ = state
@@ -992,7 +1029,7 @@ pub async fn authorize_select(
.await
{
Ok(challenge) => {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = enqueue_2fa_code(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
@@ -1030,55 +1067,9 @@ pub async fn authorize_select(
.upsert_account_device(&did, &select_device_typed)
.await;
let requested_scope_str = request_data
.parameters
.scope
.as_deref()
.unwrap_or("atproto");
let requested_scopes: Vec<String> = requested_scope_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
let client_id_typed = ClientId::from(request_data.parameters.client_id.clone());
let needs_consent = should_show_consent(
state.oauth_repo.as_ref(),
&did,
&client_id_typed,
&requested_scopes,
)
.await
.unwrap_or(true);
if needs_consent {
if state
.oauth_repo
.set_authorization_did(&select_request_id, &did, Some(&select_device_typed))
.await
.is_err()
{
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
let consent_url = format!(
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
let code = Code::generate();
let select_code = AuthorizationCode::from(code.0.clone());
if state
.oauth_repo
.update_authorization_request(
&select_request_id,
&did,
Some(&select_device_typed),
&select_code,
)
.set_authorization_did(&select_request_id, &did, Some(&select_device_typed))
.await
.is_err()
{
@@ -1088,16 +1079,11 @@ pub async fn authorize_select(
"An error occurred. Please try again.",
);
}
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
let consent_url = format!(
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
Json(serde_json::json!({
"redirect_uri": redirect_url
}))
.into_response()
Json(serde_json::json!({"redirect_uri": consent_url})).into_response()
}
fn build_success_redirect(
@@ -1116,7 +1102,7 @@ fn build_success_redirect(
'?'
};
redirect_url.push(separator);
let pds_host = pds_hostname();
let pds_host = &tranquil_config::get().server.hostname;
redirect_url.push_str(&format!(
"iss={}",
url_encode(&format!("https://{}", pds_host))
@@ -1134,7 +1120,7 @@ fn build_intermediate_redirect_url(
state: Option<&str>,
response_mode: Option<&str>,
) -> String {
let pds_host = pds_hostname();
let pds_host = &tranquil_config::get().server.hostname;
let mut url = format!(
"https://{}/oauth/authorize/redirect?redirect_uri={}&code={}",
pds_host,
@@ -1390,13 +1376,9 @@ pub async fn consent_get(
}
},
Err(_) => {
let _ = state
.oauth_repo
.delete_authorization_request(&consent_request_id)
.await;
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"expired_request",
"Authorization request has expired",
);
}
@@ -1747,6 +1729,93 @@ pub async fn consent_post(
Json(serde_json::json!({ "redirect_uri": intermediate_url })).into_response()
}
#[derive(Debug, Deserialize)]
pub struct RenewRequest {
pub request_uri: String,
}
pub async fn authorize_renew(
State(state): State<AppState>,
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
Json(form): Json<RenewRequest>,
) -> Response {
let request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
.oauth_repo
.get_authorization_request(&request_id)
.await
{
Ok(Some(data)) => data,
Ok(None) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Unknown authorization request",
);
}
Err(_) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Database error",
);
}
};
if request_data.did.is_none() {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request not yet authenticated",
);
}
let now = Utc::now();
if request_data.expires_at >= now {
return Json(serde_json::json!({
"request_uri": form.request_uri,
"renewed": false
}))
.into_response();
}
let staleness = now - request_data.expires_at;
if staleness.num_seconds() > MAX_RENEWAL_STALENESS_SECONDS {
let _ = state
.oauth_repo
.delete_authorization_request(&request_id)
.await;
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request expired too long ago to renew",
);
}
let new_expires_at = now + chrono::Duration::seconds(RENEW_EXPIRY_SECONDS);
match state
.oauth_repo
.extend_authorization_request_expiry(&request_id, new_expires_at)
.await
{
Ok(true) => Json(serde_json::json!({
"request_uri": form.request_uri,
"renewed": true
}))
.into_response(),
Ok(false) => json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request could not be renewed",
),
Err(_) => json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Database error",
),
}
}
pub async fn authorize_2fa_post(
State(state): State<AppState>,
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
@@ -1912,11 +1981,37 @@ pub async fn authorize_2fa_post(
"Invalid verification code. Please try again.",
);
}
let device_id = extract_device_cookie(&headers);
if form.trust_device
&& let Some(ref dev_id) = device_id
{
let _ = crate::api::server::trust_device(state.oauth_repo.as_ref(), dev_id).await;
let mut device_id = extract_device_cookie(&headers);
let mut new_cookie: Option<String> = None;
if form.trust_device {
let trust_device_id = match &device_id {
Some(existing_id) => existing_id.clone(),
None => {
let new_id = DeviceId::generate();
let new_device_id_typed = DeviceIdType::new(new_id.0.clone());
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers, None),
last_seen_at: Utc::now(),
};
if state
.oauth_repo
.create_device(&new_device_id_typed, &device_data)
.await
.is_ok()
{
new_cookie = Some(make_device_cookie(&new_device_id_typed));
device_id = Some(new_device_id_typed.clone());
}
new_device_id_typed
}
};
let _ = state
.oauth_repo
.upsert_account_device(&did, &trust_device_id)
.await;
let _ = crate::api::server::trust_device(state.oauth_repo.as_ref(), &trust_device_id).await;
}
let requested_scope_str = request_data
.parameters
@@ -1941,6 +2036,14 @@ pub async fn authorize_2fa_post(
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
if let Some(cookie) = new_cookie {
return (
StatusCode::OK,
[(SET_COOKIE, cookie)],
Json(serde_json::json!({"redirect_uri": consent_url})),
)
.into_response();
}
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
let code = Code::generate();
@@ -1969,10 +2072,16 @@ pub async fn authorize_2fa_post(
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
);
Json(serde_json::json!({
"redirect_uri": redirect_url
}))
.into_response()
if let Some(cookie) = new_cookie {
(
StatusCode::OK,
[(SET_COOKIE, cookie)],
Json(serde_json::json!({"redirect_uri": redirect_url})),
)
.into_response()
} else {
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
}
}
#[derive(Debug, Deserialize)]
@@ -1991,9 +2100,9 @@ pub async fn check_user_has_passkeys(
State(state): State<AppState>,
Query(query): Query<CheckPasskeysQuery>,
) -> Response {
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let bare_identifier =
BareLoginIdentifier::from_identifier(&query.identifier, hostname_for_handles);
BareLoginIdentifier::from_identifier(&query.identifier, &hostname_for_handles);
let user = state
.user_repo
@@ -2023,9 +2132,9 @@ pub async fn check_user_security_status(
State(state): State<AppState>,
Query(query): Query<CheckPasskeysQuery>,
) -> Response {
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_identifier =
NormalizedLoginIdentifier::normalize(&query.identifier, hostname_for_handles);
NormalizedLoginIdentifier::normalize(&query.identifier, &hostname_for_handles);
let user = state
.user_repo
@@ -2131,9 +2240,9 @@ pub async fn passkey_start(
.into_response();
}
let hostname_for_handles = pds_hostname_without_port();
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_username =
NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles);
NormalizedLoginIdentifier::normalize(&form.identifier, &hostname_for_handles);
let user = match state
.user_repo
@@ -2188,11 +2297,15 @@ pub async fn passkey_start(
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {
let resend_info = crate::api::server::auto_resend_verification(&state, &user.did).await;
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Please verify your account before logging in."
"error": "account_not_verified",
"error_description": "Please verify your account before logging in.",
"did": user.did,
"handle": resend_info.as_ref().map(|r| r.handle.to_string()),
"channel": resend_info.as_ref().map(|r| r.channel.as_str())
})),
)
.into_response();
@@ -2579,61 +2692,6 @@ pub async fn passkey_finish(
tracing::info!(did = %did, "Passkey authentication successful");
let has_totp = crate::api::server::has_totp_enabled(&state, &did).await;
if has_totp {
return Json(serde_json::json!({
"needs_totp": true
}))
.into_response();
}
let user = state.user_repo.get_2fa_status_by_did(&did).await;
if let Ok(Some(user)) = user
&& user.two_factor_enabled
{
let _ = state
.oauth_repo
.delete_2fa_challenge_by_request_uri(&passkey_finish_request_id)
.await;
match state
.oauth_repo
.create_2fa_challenge(&did, &passkey_finish_request_id)
.await
{
Ok(challenge) => {
let hostname = pds_hostname();
if let Err(e) = enqueue_2fa_code(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user.id,
&challenge.code,
hostname,
)
.await
{
tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification");
}
let channel_name = user.preferred_comms_channel.display_name();
return Json(serde_json::json!({
"needs_2fa": true,
"channel": channel_name
}))
.into_response();
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
}
}
let device_id = extract_device_cookie(&headers);
let requested_scope_str = request_data
.parameters
@@ -2881,7 +2939,7 @@ pub async fn authorize_passkey_finish(
headers: HeaderMap,
Json(form): Json<AuthorizePasskeySubmit>,
) -> Response {
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let passkey_finish_request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
@@ -3337,11 +3395,15 @@ pub async fn register_complete(
};
if !is_verified {
let resend_info = crate::api::server::auto_resend_verification(&state, &did).await;
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Please verify your account before continuing."
"error": "account_not_verified",
"error_description": "Please verify your account before continuing.",
"did": did,
"handle": resend_info.as_ref().map(|r| r.handle.to_string()),
"channel": resend_info.as_ref().map(|r| r.channel.as_str())
})),
)
.into_response();
@@ -1,7 +1,9 @@
use std::fmt::Debug;
use crate::oauth::jwks::{JwkSet, create_jwk_set};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{Json, extract::State};
use http::{HeaderName, header};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
@@ -58,7 +60,7 @@ pub struct AuthorizationServerMetadata {
pub async fn oauth_protected_resource(
State(_state): State<AppState>,
) -> Json<ProtectedResourceMetadata> {
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let public_url = format!("https://{}", pds_hostname);
Json(ProtectedResourceMetadata {
resource: public_url.clone(),
@@ -72,7 +74,7 @@ pub async fn oauth_protected_resource(
pub async fn oauth_authorization_server(
State(_state): State<AppState>,
) -> Json<AuthorizationServerMetadata> {
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let issuer = format!("https://{}", pds_hostname);
Json(AuthorizationServerMetadata {
issuer: issuer.clone(),
@@ -141,3 +143,20 @@ pub async fn oauth_jwks(State(_state): State<AppState>) -> Json<JwkSet> {
};
Json(create_jwk_set(vec![server_key]))
}
pub async fn frontend_client_metadata()
-> axum::response::Result<([(HeaderName, &'static str); 1], String)> {
let frontend_hostname = &tranquil_config::get().server.hostname;
let metadata_string = tokio::fs::read_to_string(format!(
"{}/oauth-client-metadata.json",
&tranquil_config::get().frontend.dir
))
.await
// TODO: consider if a better conversion can be done here.
.map_err(|io_err| io_err.to_string())?;
Ok((
[(header::CONTENT_TYPE, "application/json")],
metadata_string.replace("__FRONTEND_HOSTNAME__", frontend_hostname),
))
}
@@ -12,7 +12,6 @@ use crate::oauth::{
verify_client_auth,
};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::Json;
use axum::http::{HeaderMap, Method};
use chrono::{Duration, Utc};
@@ -101,7 +100,7 @@ pub async fn handle_authorization_code_grant(
let dpop_jkt = if let Some(proof) = &dpop_proof {
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let token_endpoint = format!("https://{}/oauth/token", pds_hostname);
let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?;
if !state
@@ -348,7 +347,7 @@ pub async fn handle_refresh_token_grant(
let dpop_jkt = if let Some(proof) = &dpop_proof {
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let token_endpoint = format!("https://{}/oauth/token", pds_hostname);
let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?;
if !state
@@ -1,6 +1,5 @@
use crate::config::AuthConfig;
use crate::oauth::OAuthError;
use crate::util::pds_hostname;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
@@ -52,7 +51,7 @@ pub fn create_access_token_with_delegation(
) -> Result<String, OAuthError> {
use serde_json::json;
let jti = uuid::Uuid::new_v4().to_string();
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let issuer = format!("https://{}", pds_hostname);
let now = Utc::now().timestamp();
let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS;
@@ -2,7 +2,6 @@ use super::helpers::extract_token_claims;
use crate::oauth::OAuthError;
use crate::rate_limit::{OAuthIntrospectLimit, OAuthRateLimited};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::extract::State;
use axum::http::StatusCode;
use axum::{Form, Json};
@@ -112,7 +111,7 @@ pub async fn introspect_token(
if token_data.expires_at < Utc::now() {
return Ok(Json(inactive_response));
}
let pds_hostname = pds_hostname();
let pds_hostname = &tranquil_config::get().server.hostname;
let issuer = format!("https://{}", pds_hostname);
Ok(Json(IntrospectResponse {
active: true,
+9 -12
View File
@@ -124,18 +124,15 @@ impl PlcClient {
}
pub fn with_cache(base_url: Option<String>, cache: Option<Arc<dyn Cache>>) -> Self {
let base_url = base_url.unwrap_or_else(|| {
std::env::var("PLC_DIRECTORY_URL")
.unwrap_or_else(|_| "https://plc.directory".to_string())
});
let timeout_secs: u64 = std::env::var("PLC_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
let connect_timeout_secs: u64 = std::env::var("PLC_CONNECT_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5);
let cfg = tranquil_config::try_get();
let base_url = base_url
.or_else(|| std::env::var("PLC_DIRECTORY_URL").ok())
.unwrap_or_else(|| {
cfg.map(|c| c.plc.directory_url.clone())
.unwrap_or_else(|| "https://plc.directory".to_string())
});
let timeout_secs = cfg.map_or(10, |c| c.plc.timeout_secs);
let connect_timeout_secs = cfg.map_or(5, |c| c.plc.connect_timeout_secs);
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.connect_timeout(Duration::from_secs(connect_timeout_secs))
+2 -6
View File
@@ -438,12 +438,8 @@ pub async fn start_scheduled_tasks(
sso_repo: Arc<dyn SsoRepository>,
shutdown: CancellationToken,
) {
let check_interval = Duration::from_secs(
std::env::var("SCHEDULED_DELETE_CHECK_INTERVAL_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3600),
);
let check_interval =
Duration::from_secs(tranquil_config::get().scheduled.delete_check_interval_secs);
info!(
check_interval_secs = check_interval.as_secs(),
+87 -73
View File
@@ -1,4 +1,3 @@
use crate::util::pds_hostname;
use std::sync::OnceLock;
use tranquil_db_traits::SsoProviderType;
@@ -34,24 +33,58 @@ pub struct SsoConfig {
impl SsoConfig {
pub fn init() -> &'static Self {
SSO_CONFIG.get_or_init(|| {
let github = Self::load_provider("GITHUB", false);
let discord = Self::load_provider("DISCORD", false);
let google = Self::load_provider("GOOGLE", false);
let gitlab = Self::load_provider("GITLAB", true);
let oidc = Self::load_provider("OIDC", true);
let apple = Self::load_apple_provider();
let config = SsoConfig {
github,
discord,
google,
gitlab,
oidc,
apple,
let sso = &tranquil_config::get().sso;
let config = SsoConfig {
github: Self::provider_from_config(
sso.github.enabled,
sso.github.client_id.as_deref(),
sso.github.client_secret.as_deref(),
None,
sso.github.display_name.as_deref(),
"GITHUB",
false,
),
discord: Self::provider_from_config(
sso.discord.enabled,
sso.discord.client_id.as_deref(),
sso.discord.client_secret.as_deref(),
None,
sso.discord.display_name.as_deref(),
"DISCORD",
false,
),
google: Self::provider_from_config(
sso.google.enabled,
sso.google.client_id.as_deref(),
sso.google.client_secret.as_deref(),
None,
sso.google.display_name.as_deref(),
"GOOGLE",
false,
),
gitlab: Self::provider_from_config(
sso.gitlab.enabled,
sso.gitlab.client_id.as_deref(),
sso.gitlab.client_secret.as_deref(),
sso.gitlab.issuer.as_deref(),
sso.gitlab.display_name.as_deref(),
"GITLAB",
true,
),
oidc: Self::provider_from_config(
sso.oidc.enabled,
sso.oidc.client_id.as_deref(),
sso.oidc.client_secret.as_deref(),
sso.oidc.issuer.as_deref(),
sso.oidc.display_name.as_deref(),
"OIDC",
true,
),
apple: Self::apple_from_config(&sso.apple),
};
if config.is_any_enabled() {
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if hostname.is_empty() || hostname == "localhost" {
panic!(
"PDS_HOSTNAME must be set to a valid hostname when SSO is enabled. \
@@ -72,89 +105,70 @@ impl SsoConfig {
})
}
pub fn get_redirect_uri() -> &'static str {
SSO_REDIRECT_URI
.get()
.map(|s| s.as_str())
.expect("SSO redirect URI not initialized - call SsoConfig::init() first")
}
fn load_provider(name: &str, needs_issuer: bool) -> Option<ProviderConfig> {
let enabled = crate::util::parse_env_bool(&format!("SSO_{}_ENABLED", name));
fn provider_from_config(
enabled: bool,
client_id: Option<&str>,
client_secret: Option<&str>,
issuer: Option<&str>,
display_name: Option<&str>,
name: &str,
needs_issuer: bool,
) -> Option<ProviderConfig> {
if !enabled {
return None;
}
let client_id = client_id.filter(|s| !s.is_empty())?;
let client_secret = client_secret.filter(|s| !s.is_empty())?;
let client_id = std::env::var(format!("SSO_{}_CLIENT_ID", name)).ok()?;
let client_secret = std::env::var(format!("SSO_{}_CLIENT_SECRET", name)).ok()?;
if client_id.is_empty() || client_secret.is_empty() {
tracing::warn!(
"SSO_{} enabled but missing client_id or client_secret",
name
);
return None;
}
let issuer = if needs_issuer {
let issuer_val = std::env::var(format!("SSO_{}_ISSUER", name)).ok();
if issuer_val.is_none() || issuer_val.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
if needs_issuer {
let issuer_val = issuer.filter(|s| !s.is_empty());
if issuer_val.is_none() {
tracing::warn!("SSO_{} requires ISSUER but none provided", name);
return None;
}
issuer_val
} else {
None
};
let display_name = std::env::var(format!("SSO_{}_NAME", name)).ok();
}
Some(ProviderConfig {
client_id,
client_secret,
issuer,
display_name,
client_id: client_id.to_string(),
client_secret: client_secret.to_string(),
issuer: issuer.map(|s| s.to_string()),
display_name: display_name.map(|s| s.to_string()),
})
}
fn load_apple_provider() -> Option<AppleProviderConfig> {
let enabled = crate::util::parse_env_bool("SSO_APPLE_ENABLED");
if !enabled {
fn apple_from_config(cfg: &tranquil_config::SsoAppleConfig) -> Option<AppleProviderConfig> {
if !cfg.enabled {
return None;
}
let client_id = cfg.client_id.as_deref().filter(|s| !s.is_empty())?;
let team_id = cfg.team_id.as_deref().filter(|s| !s.is_empty())?;
let key_id = cfg.key_id.as_deref().filter(|s| !s.is_empty())?;
let private_key_pem = cfg.private_key.as_deref().filter(|s| !s.is_empty())?;
let client_id = std::env::var("SSO_APPLE_CLIENT_ID").ok()?;
let team_id = std::env::var("SSO_APPLE_TEAM_ID").ok()?;
let key_id = std::env::var("SSO_APPLE_KEY_ID").ok()?;
let private_key_pem = std::env::var("SSO_APPLE_PRIVATE_KEY").ok()?;
if client_id.is_empty() {
tracing::warn!("SSO_APPLE enabled but missing CLIENT_ID");
return None;
}
if team_id.is_empty() || team_id.len() != 10 {
if team_id.len() != 10 {
tracing::warn!("SSO_APPLE enabled but TEAM_ID is invalid (must be 10 characters)");
return None;
}
if key_id.is_empty() {
tracing::warn!("SSO_APPLE enabled but missing KEY_ID");
return None;
}
if private_key_pem.is_empty() || !private_key_pem.contains("PRIVATE KEY") {
if !private_key_pem.contains("PRIVATE KEY") {
tracing::warn!("SSO_APPLE enabled but PRIVATE_KEY is invalid");
return None;
}
Some(AppleProviderConfig {
client_id,
team_id,
key_id,
private_key_pem,
client_id: client_id.to_string(),
team_id: team_id.to_string(),
key_id: key_id.to_string(),
private_key_pem: private_key_pem.to_string(),
})
}
pub fn get_redirect_uri() -> &'static str {
SSO_REDIRECT_URI
.get()
.map(|s| s.as_str())
.expect("SSO redirect URI not initialized - call SsoConfig::init() first")
}
pub fn get() -> &'static Self {
SSO_CONFIG.get_or_init(SsoConfig::default)
}
+20 -15
View File
@@ -18,7 +18,6 @@ use crate::rate_limit::{
check_user_rate_limit_with_message,
};
use crate::state::AppState;
use crate::util::{pds_hostname, pds_hostname_without_port};
fn generate_state() -> String {
use rand::RngCore;
@@ -773,8 +772,8 @@ pub async fn check_handle_available(
}
};
let hostname_for_handles = pds_hostname_without_port();
let full_handle = format!("{}.{}", validated, hostname_for_handles);
let available_domains = tranquil_config::get().server.available_user_domain_list();
let full_handle = format!("{}.{}", validated, &available_domains[0]);
let handle_typed: crate::types::Handle = match full_handle.parse() {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
@@ -856,11 +855,11 @@ pub async fn complete_registration(
.await?
.ok_or(ApiError::SsoSessionExpired)?;
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let hostname = &tranquil_config::get().server.hostname;
let available_domains = tranquil_config::get().server.available_user_domain_list();
let handle = match crate::api::validation::validate_short_handle(&input.handle) {
Ok(h) => format!("{}.{}", h, hostname_for_handles),
Ok(h) => format!("{}.{}", h, &available_domains[0]),
Err(_) => return Err(ApiError::InvalidHandle(None)),
};
@@ -948,7 +947,7 @@ pub async fn complete_registration(
Err(_) => return Err(ApiError::InvalidInviteCode),
}
} else {
let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
let invite_required = tranquil_config::get().server.invite_code_required;
if invite_required {
return Err(ApiError::InviteCodeRequired);
}
@@ -982,7 +981,8 @@ pub async fn complete_registration(
let did = match did_type {
"web" => {
let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles);
let pds_hostname = tranquil_config::get().server.hostname_without_port();
let subdomain_host = format!("{}.{}", input.handle, pds_hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account");
@@ -1006,8 +1006,11 @@ pub async fn complete_registration(
d.to_string()
}
_ => {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key));
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| crate::plc::signing_key_to_did_key(&signing_key));
let genesis_result = match crate::plc::create_genesis_operation(
&signing_key,
@@ -1085,12 +1088,14 @@ pub async fn complete_registration(
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| {
json!({
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
Some(json!({
"$type": "app.bsky.actor.defs#personalDetailsPref",
"birthDate": "1998-05-06T00:00:00.000Z"
})
});
}))
} else {
None
};
let create_input = tranquil_db_traits::CreateSsoAccountInput {
handle: handle_typed.clone(),
@@ -1299,7 +1304,7 @@ pub async fn complete_registration(
return Err(ApiError::InternalError(None));
}
let hostname = pds_hostname();
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = crate::comms::comms_repo::enqueue_welcome(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
+31 -26
View File
@@ -9,7 +9,6 @@ use crate::repo_write_lock::RepoWriteLocks;
use crate::sso::{SsoConfig, SsoManager};
use crate::storage::{BackupStorage, BlobStorage, create_backup_storage, create_blob_storage};
use crate::sync::firehose::SequencedEvent;
use crate::util::pds_hostname;
use sqlx::PgPool;
use std::error::Error;
use std::sync::Arc;
@@ -25,10 +24,10 @@ use tranquil_db::{
static RATE_LIMITING_DISABLED: AtomicBool = AtomicBool::new(false);
pub fn init_rate_limit_override() {
let disabled = std::env::var("DISABLE_RATE_LIMITING").is_ok();
let disabled = tranquil_config::get().server.disable_rate_limiting;
RATE_LIMITING_DISABLED.store(disabled, Ordering::Relaxed);
if disabled {
tracing::warn!("rate limiting is DISABLED via DISABLE_RATE_LIMITING env var");
tracing::warn!("rate limiting is DISABLED via configuration");
}
}
@@ -59,6 +58,7 @@ pub struct AppState {
pub sso_manager: SsoManager,
pub webauthn_config: Arc<WebAuthnConfig>,
pub shutdown: CancellationToken,
pub bootstrap_invite_code: Option<String>,
}
#[derive(Debug, Clone, Copy)]
@@ -205,23 +205,11 @@ impl RateLimitKind {
impl AppState {
pub async fn new(shutdown: CancellationToken) -> Result<Self, Box<dyn Error>> {
let database_url = std::env::var("DATABASE_URL")
.map_err(|_| "DATABASE_URL environment variable must be set")?;
let max_connections: u32 = std::env::var("DATABASE_MAX_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
let min_connections: u32 = std::env::var("DATABASE_MIN_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
let acquire_timeout_secs: u64 = std::env::var("DATABASE_ACQUIRE_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
let cfg = tranquil_config::get();
let database_url = &cfg.database.url;
let max_connections = cfg.database.max_connections;
let min_connections = cfg.database.min_connections;
let acquire_timeout_secs = cfg.database.acquire_timeout_secs;
tracing::info!(
"Configuring database pool: max={}, min={}, acquire_timeout={}s",
@@ -245,7 +233,26 @@ impl AppState {
.await
.map_err(|e| format!("Failed to run migrations: {}", e))?;
Ok(Self::from_db(db, shutdown).await)
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::api::server::invite::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)
}
pub async fn from_db(db: PgPool, shutdown: CancellationToken) -> Self {
@@ -257,10 +264,7 @@ impl AppState {
let blob_store = create_blob_storage().await;
let backup_storage = create_backup_storage().await;
let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10000);
let firehose_buffer_size = tranquil_config::get().firehose.buffer_size;
let (firehose_tx, _) = broadcast::channel(firehose_buffer_size);
let rate_limiters = Arc::new(RateLimiters::new());
@@ -271,7 +275,7 @@ impl AppState {
let sso_config = SsoConfig::init();
let sso_manager = SsoManager::from_config(sso_config);
let webauthn_config = Arc::new(
WebAuthnConfig::new(pds_hostname())
WebAuthnConfig::new(&tranquil_config::get().server.hostname)
.expect("Failed to create WebAuthn config at startup"),
);
@@ -301,6 +305,7 @@ impl AppState {
sso_manager,
webauthn_config,
shutdown,
bootstrap_invite_code: None,
}
}
@@ -59,10 +59,7 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, params: Subscribe
}
fn get_backfill_hours() -> i64 {
std::env::var("FIREHOSE_BACKFILL_HOURS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(72)
tranquil_config::get().firehose.backfill_hours
}
async fn handle_socket_inner(
@@ -204,10 +201,7 @@ async fn handle_socket_inner(
}
}
}
let max_lag_before_disconnect: u64 = std::env::var("FIREHOSE_MAX_LAG")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5000);
let max_lag_before_disconnect: u64 = tranquil_config::get().firehose.max_lag;
loop {
tokio::select! {
result = rx.recv() => {
+1 -1
View File
@@ -146,7 +146,7 @@ impl CarVerifier {
async fn resolve_plc_did(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> {
let plc_url = std::env::var("PLC_DIRECTORY_URL")
.unwrap_or_else(|_| "https://plc.directory".to_string());
.unwrap_or_else(|_| tranquil_config::get().plc.directory_url.clone());
let url = format!("{}/{}", plc_url, urlencoding::encode(did));
let response = self
.http_client
+6 -31
View File
@@ -9,25 +9,12 @@ use std::str::FromStr;
use std::sync::OnceLock;
const BASE32_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz234567";
const DEFAULT_MAX_BLOB_SIZE: usize = 10 * 1024 * 1024 * 1024;
static MAX_BLOB_SIZE: OnceLock<usize> = OnceLock::new();
static PDS_HOSTNAME: OnceLock<String> = OnceLock::new();
static PDS_HOSTNAME_WITHOUT_PORT: OnceLock<String> = OnceLock::new();
static DISCORD_BOT_USERNAME: OnceLock<String> = OnceLock::new();
static DISCORD_PUBLIC_KEY: OnceLock<ed25519_dalek::VerifyingKey> = OnceLock::new();
static DISCORD_APP_ID: OnceLock<String> = OnceLock::new();
static TELEGRAM_BOT_USERNAME: OnceLock<String> = OnceLock::new();
pub fn get_max_blob_size() -> usize {
*MAX_BLOB_SIZE.get_or_init(|| {
std::env::var("MAX_BLOB_SIZE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_MAX_BLOB_SIZE)
})
}
pub fn generate_token_code() -> String {
generate_token_code_parts(2, 5)
}
@@ -109,18 +96,6 @@ pub fn extract_client_ip(headers: &HeaderMap, addr: Option<SocketAddr>) -> Strin
.unwrap_or_else(|| "unknown".to_string())
}
pub fn pds_hostname() -> &'static str {
PDS_HOSTNAME
.get_or_init(|| std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()))
}
pub fn pds_hostname_without_port() -> &'static str {
PDS_HOSTNAME_WITHOUT_PORT.get_or_init(|| {
let hostname = pds_hostname();
hostname.split(':').next().unwrap_or(hostname).to_string()
})
}
pub fn set_discord_bot_username(username: String) {
DISCORD_BOT_USERNAME.set(username).ok();
}
@@ -154,26 +129,25 @@ pub fn telegram_bot_username() -> Option<&'static str> {
}
pub fn parse_env_bool(key: &str) -> bool {
// Check the config system first, then fall back to env var for dynamic
// SSO keys that are not in the static config struct.
std::env::var(key)
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
}
pub fn pds_public_url() -> String {
format!("https://{}", pds_hostname())
}
pub fn build_full_url(path: &str) -> String {
let cfg = tranquil_config::get();
let normalized_path = if !path.starts_with("/xrpc/")
&& (path.starts_with("/com.atproto.")
|| path.starts_with("/app.bsky.")
|| path.starts_with("/_"))
{
format!("/xrpc{}", path)
format!("/xrpc{path}")
} else {
path.to_string()
};
format!("{}{}", pds_public_url(), normalized_path)
format!("{}{normalized_path}", cfg.server.public_url())
}
pub fn json_to_ipld(value: &JsonValue) -> Ipld {
@@ -400,6 +374,7 @@ mod tests {
#[test]
fn test_build_full_url_adds_xrpc_prefix_for_atproto_paths() {
unsafe { std::env::set_var("PDS_HOSTNAME", "example.com") };
tranquil_config::ensure_test_defaults();
assert_eq!(
build_full_url("/com.atproto.server.getSession"),
"https://example.com/xrpc/com.atproto.server.getSession"
+1
View File
@@ -548,6 +548,7 @@ async fn spawn_server(config: ServerConfig) -> ServerInstance {
unsafe {
std::env::set_var("PDS_HOSTNAME", format!("pds.test:{}", addr.port()));
}
tranquil_config::ensure_test_defaults();
let rate_limiters = RateLimiters::new()
.with_login_limit(10000)
.with_account_creation_limit(10000)
+278
View File
@@ -0,0 +1,278 @@
mod common;
use common::*;
use reqwest::StatusCode;
use reqwest::header;
use serde_json::{Value, json};
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);
}
}
async fn base_url_with_domain() -> &'static str {
set_handle_domain();
base_url().await
}
#[tokio::test]
async fn describe_server_returns_configured_domain() {
let client = client();
let base = base_url_with_domain().await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.server.describeServer",
base
))
.send()
.await
.expect("describeServer request failed");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let domains = body["availableUserDomains"]
.as_array()
.expect("No availableUserDomains");
assert!(
domains.iter().any(|d| d.as_str() == Some(HANDLE_DOMAIN)),
"availableUserDomains should contain {}, got {:?}",
HANDLE_DOMAIN,
domains
);
}
#[tokio::test]
async fn short_handle_uses_configured_domain() {
let client = client();
let base = base_url_with_domain().await;
let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let payload = json!({
"handle": short_handle,
"email": format!("{}@example.com", short_handle),
"password": "Testpass123!"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base
))
.json(&payload)
.send()
.await
.expect("createAccount request failed");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let handle = body["handle"].as_str().expect("No handle in response");
let expected_suffix = format!(".{}", HANDLE_DOMAIN);
assert!(
handle.ends_with(&expected_suffix),
"Handle '{}' should end with '{}' (not PDS hostname)",
handle,
expected_suffix
);
assert_eq!(
handle,
format!("{}.{}", short_handle, HANDLE_DOMAIN),
"Handle should be short_handle.configured_domain"
);
}
#[tokio::test]
async fn full_handle_with_configured_domain_accepted() {
let client = client();
let base = base_url_with_domain().await;
let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let full_handle = format!("{}.{}", short_handle, HANDLE_DOMAIN);
let payload = json!({
"handle": full_handle,
"email": format!("{}@example.com", short_handle),
"password": "Testpass123!"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base
))
.json(&payload)
.send()
.await
.expect("createAccount request failed");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let handle = body["handle"].as_str().expect("No handle in response");
assert_eq!(
handle, full_handle,
"Handle should match the full handle submitted"
);
}
#[tokio::test]
async fn handle_with_pds_hostname_treated_as_custom() {
let client = client();
let base = base_url_with_domain().await;
let pds_hostname = pds_hostname();
let pds_host_no_port = pds_hostname.split(':').next().unwrap_or(&pds_hostname);
let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let handle_with_hostname = format!("{}.{}", short_handle, pds_host_no_port);
let payload = json!({
"handle": handle_with_hostname,
"email": format!("{}@example.com", short_handle),
"password": "Testpass123!"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base
))
.json(&payload)
.send()
.await
.expect("createAccount request failed");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let handle = body["handle"].as_str().expect("No handle in response");
assert_eq!(
handle, handle_with_hostname,
"Handle with non-available domain suffix should be treated as custom handle (passed through)"
);
}
#[tokio::test]
async fn resolve_handle_works_with_configured_domain() {
let client = client();
let base = base_url_with_domain().await;
let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let payload = json!({
"handle": short_handle,
"email": format!("{}@example.com", short_handle),
"password": "Testpass123!"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base
))
.json(&payload)
.send()
.await
.expect("createAccount request failed");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let did = body["did"].as_str().expect("No DID").to_string();
let full_handle = body["handle"].as_str().expect("No handle").to_string();
let res = client
.get(format!(
"{}/xrpc/com.atproto.identity.resolveHandle",
base
))
.query(&[("handle", full_handle.as_str())])
.send()
.await
.expect("resolveHandle request failed");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(body["did"], did);
}
#[tokio::test]
async fn admin_update_handle_uses_configured_domain() {
let client = client();
let base = base_url_with_domain().await;
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
let (_, target_did) = create_account_and_login(&client).await;
let new_short = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let res = client
.post(format!(
"{}/xrpc/com.atproto.admin.updateAccountHandle",
base
))
.bearer_auth(&admin_jwt)
.json(&json!({
"did": target_did,
"handle": new_short,
}))
.send()
.await
.expect("admin updateAccountHandle request failed");
assert_eq!(res.status(), StatusCode::OK);
let res = client
.get(format!(
"{}/xrpc/com.atproto.identity.resolveHandle",
base
))
.query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))])
.send()
.await
.expect("resolveHandle request failed");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(
body["did"], target_did,
"Admin bare handle update should use configured domain, not PDS hostname"
);
}
#[tokio::test]
async fn update_handle_bare_uses_configured_domain() {
let client = client();
let base = base_url_with_domain().await;
let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let payload = json!({
"handle": short_handle,
"email": format!("{}@example.com", short_handle),
"password": "Testpass123!"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base
))
.json(&payload)
.send()
.await
.expect("createAccount request failed");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let did = body["did"].as_str().expect("No DID").to_string();
let access_jwt = verify_new_account(&client, &did).await;
let new_short = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let res = client
.post(format!(
"{}/xrpc/com.atproto.identity.updateHandle",
base
))
.bearer_auth(&access_jwt)
.header(header::CONTENT_TYPE, "application/json")
.json(&json!({ "handle": new_short }))
.send()
.await
.expect("updateHandle request failed");
assert_eq!(
res.status(),
StatusCode::OK,
"updateHandle failed: {:?}",
res.text().await
);
let res = client
.get(format!(
"{}/xrpc/com.atproto.identity.resolveHandle",
base
))
.query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))])
.send()
.await
.expect("resolveHandle request failed");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(
body["did"], did,
"updateHandle with bare handle should use configured domain, not PDS hostname"
);
}
@@ -64,7 +64,7 @@ async fn test_import_with_valid_signature_and_mock_plc() {
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
unsafe {
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
}
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
let import_res = client
@@ -108,7 +108,7 @@ async fn test_import_with_wrong_signing_key_fails() {
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
unsafe {
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
}
let (car_bytes, _root_cid) = build_car_with_signature(&did, &wrong_signing_key);
let import_res = client
@@ -157,7 +157,7 @@ async fn test_import_with_did_mismatch_fails() {
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
unsafe {
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
}
let (car_bytes, _root_cid) = build_car_with_signature(wrong_did, &signing_key);
let import_res = client
@@ -202,7 +202,7 @@ async fn test_import_with_plc_resolution_failure() {
.await;
unsafe {
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
}
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
let import_res = client
@@ -248,7 +248,7 @@ async fn test_import_with_no_signing_key_in_did_doc() {
let mock_plc = setup_mock_plc_directory(&did, did_doc_without_key).await;
unsafe {
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
}
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
let import_res = client
+3 -3
View File
@@ -698,7 +698,7 @@ async fn test_cross_pds_migration_with_records() {
.await;
unsafe {
std::env::set_var("PLC_DIRECTORY_URL", mock_server.uri());
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
}
let import_res = client
.post(format!(
@@ -775,7 +775,7 @@ async fn test_migration_rejects_wrong_did_document() {
.await;
unsafe {
std::env::set_var("PLC_DIRECTORY_URL", mock_server.uri());
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
}
let import_res = client
.post(format!(
@@ -931,7 +931,7 @@ async fn test_full_migration_flow_end_to_end() {
.expect("Submit failed");
assert_eq!(submit_res.status(), StatusCode::OK);
unsafe {
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
}
let import_res = client
.post(format!(
+7 -1
View File
@@ -11,7 +11,13 @@ async fn test_server_basics() {
let base = base_url().await;
let health = client.get(format!("{}/health", base)).send().await.unwrap();
assert_eq!(health.status(), StatusCode::OK);
assert_eq!(health.text().await.unwrap(), "OK");
assert!(
health
.text()
.await
.unwrap()
.starts_with("{\"version\":\"tranquil ")
);
let describe = client
.get(format!("{}/xrpc/com.atproto.server.describeServer", base))
.send()
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
tranquil-config = { workspace = true }
tranquil-infra = { workspace = true }
async-trait = { workspace = true }
+23 -42
View File
@@ -15,30 +15,20 @@ pub struct RippleConfig {
pub cache_max_bytes: usize,
}
fn parse_env_with_warning<T: std::str::FromStr>(var_name: &str, raw: &str) -> Option<T> {
match raw.parse::<T>() {
Ok(v) => Some(v),
Err(_) => {
tracing::warn!(
var = var_name,
value = raw,
"invalid env var value, using default"
);
None
}
}
}
impl RippleConfig {
pub fn from_env() -> Result<Self, RippleConfigError> {
let bind_addr: SocketAddr = std::env::var("RIPPLE_BIND")
.unwrap_or_else(|_| "0.0.0.0:0".into())
pub fn from_config() -> Result<Self, RippleConfigError> {
let ripple = &tranquil_config::get().cache.ripple;
let bind_addr: SocketAddr = ripple
.bind_addr
.parse()
.map_err(|e| RippleConfigError::InvalidAddr(format!("{e}")))?;
let seed_peers: Vec<SocketAddr> = std::env::var("RIPPLE_PEERS")
.unwrap_or_default()
.split(',')
let seed_peers: Vec<SocketAddr> = ripple
.peers
.as_deref()
.unwrap_or(&[])
.iter()
.filter(|s| !s.trim().is_empty())
.map(|s| {
s.trim()
@@ -47,30 +37,21 @@ impl RippleConfig {
})
.collect::<Result<Vec<_>, _>>()?;
let machine_id: u64 = std::env::var("RIPPLE_MACHINE_ID")
.ok()
.and_then(|v| parse_env_with_warning::<u64>("RIPPLE_MACHINE_ID", &v))
.unwrap_or_else(|| {
let host_str = std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| format!("pid-{}", std::process::id()));
let input = format!("{host_str}:{bind_addr}:{}", std::process::id());
fnv1a(input.as_bytes())
});
let machine_id = ripple.machine_id.unwrap_or_else(|| {
let host_str = std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| format!("pid-{}", std::process::id()));
let input = format!("{host_str}:{bind_addr}:{}", std::process::id());
fnv1a(input.as_bytes())
});
let gossip_interval_ms: u64 = std::env::var("RIPPLE_GOSSIP_INTERVAL_MS")
.ok()
.and_then(|v| parse_env_with_warning::<u64>("RIPPLE_GOSSIP_INTERVAL_MS", &v))
.unwrap_or(200)
.max(50);
let gossip_interval_ms = ripple.gossip_interval_ms.max(50);
let cache_max_mb: usize = std::env::var("RIPPLE_CACHE_MAX_MB")
.ok()
.and_then(|v| parse_env_with_warning::<usize>("RIPPLE_CACHE_MAX_MB", &v))
.unwrap_or(256)
.clamp(1, 16_384);
let cache_max_bytes = cache_max_mb.saturating_mul(1024).saturating_mul(1024);
let cache_max_bytes = ripple
.cache_max_mb
.clamp(1, 16_384)
.saturating_mul(1024)
.saturating_mul(1024);
Ok(Self {
bind_addr,
+1
View File
@@ -9,6 +9,7 @@ default = []
s3 = ["dep:aws-config", "dep:aws-sdk-s3"]
[dependencies]
tranquil-config = { workspace = true }
tranquil-infra = { workspace = true }
async-trait = { workspace = true }
+45 -45
View File
@@ -121,7 +121,12 @@ mod s3 {
impl S3BlobStorage {
pub async fn new() -> Self {
let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set");
let cfg = tranquil_config::get();
let bucket = cfg
.storage
.s3_bucket
.clone()
.expect("storage.s3_bucket (S3_BUCKET) must be set");
let client = create_s3_client().await;
Self { client, bucket }
}
@@ -140,16 +145,20 @@ mod s3 {
.load()
.await;
std::env::var("S3_ENDPOINT").ok().map_or_else(
|| Client::new(&config),
|endpoint| {
let s3_config = aws_sdk_s3::config::Builder::from(&config)
.endpoint_url(endpoint)
.force_path_style(true)
.build();
Client::from_conf(s3_config)
},
)
tranquil_config::get()
.storage
.s3_endpoint
.as_deref()
.map_or_else(
|| Client::new(&config),
|endpoint| {
let s3_config = aws_sdk_s3::config::Builder::from(&config)
.endpoint_url(endpoint)
.force_path_style(true)
.build();
Client::from_conf(s3_config)
},
)
}
pub struct S3BackupStorage {
@@ -159,7 +168,7 @@ mod s3 {
impl S3BackupStorage {
pub async fn new() -> Option<Self> {
let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?;
let bucket = tranquil_config::get().backup.s3_bucket.clone()?;
let client = create_s3_client().await;
Some(Self { client, bucket })
}
@@ -499,12 +508,6 @@ impl FilesystemBlobStorage {
})
}
pub async fn from_env() -> Result<Self, StorageError> {
let path = std::env::var("BLOB_STORAGE_PATH")
.map_err(|_| StorageError::Other("BLOB_STORAGE_PATH not set".into()))?;
Self::new(path).await
}
fn resolve_path(&self, key: &str) -> Result<PathBuf, StorageError> {
validate_key(key)?;
Ok(split_cid_path(key).map_or_else(
@@ -649,12 +652,6 @@ impl FilesystemBackupStorage {
})
}
pub async fn from_env() -> Result<Self, StorageError> {
let path = std::env::var("BACKUP_STORAGE_PATH")
.map_err(|_| StorageError::Other("BACKUP_STORAGE_PATH not set".into()))?;
Self::new(path).await
}
fn resolve_path(&self, key: &str) -> Result<PathBuf, StorageError> {
validate_key(key)?;
Ok(self.base_path.join(key))
@@ -701,7 +698,8 @@ impl BackupStorage for FilesystemBackupStorage {
}
pub async fn create_blob_storage() -> Arc<dyn BlobStorage> {
let backend = std::env::var("BLOB_STORAGE_BACKEND").unwrap_or_else(|_| "filesystem".into());
let cfg = tranquil_config::get();
let backend = &cfg.storage.backend;
match backend.as_str() {
#[cfg(feature = "s3")]
@@ -718,7 +716,8 @@ pub async fn create_blob_storage() -> Arc<dyn BlobStorage> {
}
_ => {
tracing::info!("Initializing filesystem blob storage");
FilesystemBlobStorage::from_env()
let path = cfg.storage.path.clone();
FilesystemBlobStorage::new(path)
.await
.unwrap_or_else(|e| {
panic!(
@@ -733,16 +732,14 @@ pub async fn create_blob_storage() -> Arc<dyn BlobStorage> {
}
pub async fn create_backup_storage() -> Option<Arc<dyn BackupStorage>> {
let enabled = std::env::var("BACKUP_ENABLED")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
let cfg = tranquil_config::get();
if !enabled {
tracing::info!("Backup storage disabled via BACKUP_ENABLED=false");
if !cfg.backup.enabled {
tracing::info!("Backup storage disabled via config");
return None;
}
let backend = std::env::var("BACKUP_STORAGE_BACKEND").unwrap_or_else(|_| "filesystem".into());
let backend = &cfg.backup.backend;
match backend.as_str() {
#[cfg(feature = "s3")]
@@ -767,21 +764,24 @@ pub async fn create_backup_storage() -> Option<Arc<dyn BackupStorage>> {
);
None
}
_ => FilesystemBackupStorage::from_env().await.map_or_else(
|e| {
tracing::error!(
"Failed to initialize filesystem backup storage: {}. \
_ => {
let path = cfg.backup.path.clone();
FilesystemBackupStorage::new(path).await.map_or_else(
|e| {
tracing::error!(
"Failed to initialize filesystem backup storage: {}. \
Set BACKUP_STORAGE_PATH to a valid directory path. \
Backups will be disabled.",
e
);
None
},
|storage| {
tracing::info!("Initialized filesystem backup storage");
Some(Arc::new(storage) as Arc<dyn BackupStorage>)
},
),
e
);
None
},
|storage| {
tracing::info!("Initialized filesystem backup storage");
Some(Arc::new(storage) as Arc<dyn BackupStorage>)
},
)
}
}
}
+1
View File
@@ -36,5 +36,6 @@ in rustPlatform.buildRustPackage {
meta = {
license = lib.licenses.agpl3Plus;
mainProgram = "tranquil-pds";
};
}
+1 -1
View File
@@ -62,7 +62,7 @@ http {
proxy_request_buffering off;
}
location = /oauth/client-metadata.json {
location = /oauth-client-metadata.json {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
+5 -32
View File
@@ -7,16 +7,8 @@ services:
restart: unless-stopped
environment:
SERVER_HOST: "0.0.0.0"
SERVER_PORT: "3000"
PDS_HOSTNAME: "${PDS_HOSTNAME:?PDS_HOSTNAME is required}"
DATABASE_URL: "postgres://tranquil_pds:${DB_PASSWORD:?DB_PASSWORD is required}@db:5432/pds"
BLOB_STORAGE_PATH: "/var/lib/tranquil/blobs"
BACKUP_STORAGE_PATH: "/var/lib/tranquil/backups"
JWT_SECRET: "${JWT_SECRET:?JWT_SECRET is required (min 32 chars)}"
DPOP_SECRET: "${DPOP_SECRET:?DPOP_SECRET is required (min 32 chars)}"
MASTER_KEY: "${MASTER_KEY:?MASTER_KEY is required (min 32 chars)}"
CRAWLERS: "${CRAWLERS:-https://bsky.network}"
volumes:
- ./config.toml:/etc/tranquil-pds/config.toml:ro
- blob_data:/var/lib/tranquil/blobs
- backup_data:/var/lib/tranquil/backups
depends_on:
@@ -35,34 +27,16 @@ services:
reservations:
memory: 256M
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
image: tranquil-pds-frontend:latest
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
deploy:
resources:
limits:
memory: 128M
reservations:
memory: 32M
db:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_USER: tranquil_pds
POSTGRES_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD is required}"
POSTGRES_PASSWORD: "CHANGE-ME"
POSTGRES_DB: pds
volumes:
- postgres_data:/var/lib/postgresql/data
# In memory of @mrrp.lol when Lewis had "/data" here and the account got nuked on restart including rotation key :(
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tranquil_pds -d pds"]
interval: 10s
@@ -83,12 +57,11 @@ services:
- "80:80"
- "443:443"
volumes:
- ./nginx.frontend.conf:/etc/nginx/nginx.conf:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
- acme_challenge:/var/www/acme:ro
depends_on:
- tranquil-pds
- frontend
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
+1 -2
View File
@@ -6,11 +6,10 @@ services:
image: tranquil-pds
ports:
- "3000:3000"
env_file:
- ./.env
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/pds
volumes:
- ./config.toml:/etc/tranquil-pds/config.toml:ro
- blob_data:/var/lib/tranquil/blobs
- backup_data:/var/lib/tranquil/backups
depends_on:
+61 -65
View File
@@ -1,4 +1,4 @@
# Tranquil PDS Containerized Production Deployment
# Tranquil PDS containerized production deployment
This guide covers deploying Tranquil PDS using containers with podman.
@@ -7,21 +7,21 @@ This guide covers deploying Tranquil PDS using containers with podman.
## Prerequisites
- A VPS with at least 2GB RAM
- A server :p
- Disk space for blobs (depends on usage; plan for ~1GB per active user as a baseline)
- A domain name pointing to your server's IP
- A **wildcard TLS certificate** for `*.pds.example.com` (user handles are served as subdomains)
- Root or sudo access
- Root/sudo/doas access
## Quick Start (Docker/Podman Compose)
## Quickstart (docker/podman compose)
If you just want to get running quickly:
```sh
cp .env.example .env
cp example.toml config.toml
```
Edit `.env` with your values. Generate secrets with `openssl rand -base64 48`.
Edit `config.toml` with your values. Generate secrets with `openssl rand -base64 48`.
Build and start:
```sh
@@ -39,9 +39,13 @@ ln -sf live/pds.example.com/privkey.pem certs/privkey.pem
podman-compose -f docker-compose.prod.yaml restart nginx
```
The end!!!
Or wait, you want more? Perhaps a deployment that comes back on server restart?
For production setups with proper service management, continue to either the Debian or Alpine section below.
## Standalone Containers (No Compose)
## Standalone containers (no compose)
If you already have postgres running on the host (eg. from the [Debian install guide](install-debian.md)), you can run just the app containers.
@@ -55,7 +59,7 @@ Run the backend with host networking (so it can access postgres on localhost) an
```sh
podman run -d --name tranquil-pds \
--network=host \
--env-file /etc/tranquil-pds/tranquil-pds.env \
-v /etc/tranquil-pds/config.toml:/etc/tranquil-pds/config.toml:ro,Z \
-v /var/lib/tranquil:/var/lib/tranquil:Z \
tranquil-pds:latest
```
@@ -91,39 +95,41 @@ See the [Debian install guide](install-debian.md) for the full nginx config with
---
# Debian 13+ with Systemd Quadlets
# Debian with systemd quadlets
Quadlets are the modern way to run podman containers under systemd.
Quadlets are a nice way to run podman containers under systemd.
## Install Podman
## Install podman
```bash
apt update
apt install -y podman
```
## Create Directory Structure
## Create the directory structure
```bash
mkdir -p /etc/containers/systemd
mkdir -p /srv/tranquil-pds/{postgres,blobs,backups,certs,acme,config}
```
## Create Environment File
## Create a configuration file
```bash
cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env
chmod 600 /srv/tranquil-pds/config/tranquil-pds.env
cp /opt/tranquil-pds/example.toml /srv/tranquil-pds/config/config.toml
chmod 600 /srv/tranquil-pds/config/config.toml
```
Edit `/srv/tranquil-pds/config/tranquil-pds.env` and fill in your values. Generate secrets with:
Edit `/srv/tranquil-pds/config/config.toml` and fill in your values. Generate secrets with:
```bash
openssl rand -base64 48
```
For quadlets, also add `DATABASE_URL` with the full connection string (systemd doesn't support variable expansion).
> **Note:** Every config option can also be set via environment variables
> (see comments in `example.toml`). Environment variables always take
> precedence over the config file.
## Install Quadlet Definitions
## Install quadlet definitions
Copy the quadlet files from the repository:
```bash
@@ -136,15 +142,13 @@ cp /opt/tranquil-pds/deploy/quadlets/tranquil-pds-nginx.container /etc/container
Optional quadlets for valkey and minio are also available in `deploy/quadlets/` if you need them.
Note: Systemd doesn't support shell-style variable expansion in `Environment=` lines. The quadlet files expect DATABASE_URL to be set in the environment file.
## Create nginx Configuration
## Create nginx configuration
```bash
cp /opt/tranquil-pds/nginx.frontend.conf /srv/tranquil-pds/config/nginx.conf
cp /opt/tranquil-pds/nginx.conf /srv/tranquil-pds/config/nginx.conf
```
## Clone and Build Images
## Clone and build images
```bash
cd /opt
@@ -154,14 +158,13 @@ podman build -t tranquil-pds:latest .
podman build -t tranquil-pds-frontend:latest ./frontend
```
## Create Podman Secrets
## Create podman secrets
```bash
source /srv/tranquil-pds/config/tranquil-pds.env
echo "$DB_PASSWORD" | podman secret create tranquil-pds-db-password -
```
## Start Services and Initialize
## Start services and initialize
```bash
systemctl daemon-reload
@@ -169,13 +172,7 @@ systemctl start tranquil-pds-db
sleep 10
```
Run migrations:
```bash
cargo install sqlx-cli --no-default-features --features postgres
DATABASE_URL="postgres://tranquil_pds:your-db-password@localhost:5432/pds" sqlx migrate run --source /opt/tranquil-pds/migrations
```
## Obtain Wildcard SSL Certificate
## Obtain a wildcard SSL cert
User handles are served as subdomains (eg. `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation.
@@ -209,13 +206,13 @@ ln -sf /srv/tranquil-pds/certs/live/pds.example.com/privkey.pem /srv/tranquil-pd
systemctl restart tranquil-pds-nginx
```
## Enable All Services
## Enable all services
```bash
systemctl enable tranquil-pds-db tranquil-pds-app tranquil-pds-frontend tranquil-pds-nginx
```
## Configure Firewall
## Configure firewall if you're into that sort of thing
```bash
apt install -y ufw
@@ -225,7 +222,7 @@ ufw allow 443/tcp
ufw enable
```
## Certificate Renewal
## Cert renewal
Add to root's crontab (`crontab -e`):
```
@@ -234,11 +231,11 @@ Add to root's crontab (`crontab -e`):
---
# Alpine 3.23+ with OpenRC
# Alpine with OpenRC
Alpine uses OpenRC, not systemd. We'll use podman-compose with an OpenRC service wrapper.
Alpine uses OpenRC, not systemd. So instead of quadlets we'll use podman-compose with an OpenRC service wrapper.
## Install Podman
## Install podman
```sh
apk update
@@ -253,14 +250,14 @@ rc-update add podman
rc-service podman start
```
## Create Directory Structure
## Create the directory structure
```sh
mkdir -p /srv/tranquil-pds/{data,config}
mkdir -p /srv/tranquil-pds/data/{postgres,blobs,backups,certs,acme}
```
## Clone Repository and Build Images
## Clone the repo and build images
```sh
cd /opt
@@ -270,24 +267,28 @@ podman build -t tranquil-pds:latest .
podman build -t tranquil-pds-frontend:latest ./frontend
```
## Create Environment File
## Create a configuration file
```sh
cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env
chmod 600 /srv/tranquil-pds/config/tranquil-pds.env
cp /opt/tranquil-pds/example.toml /srv/tranquil-pds/config/config.toml
chmod 600 /srv/tranquil-pds/config/config.toml
```
Edit `/srv/tranquil-pds/config/tranquil-pds.env` and fill in your values. Generate secrets with:
Edit `/srv/tranquil-pds/config/config.toml` and fill in your values. Generate secrets with:
```sh
openssl rand -base64 48
```
## Set Up Compose and nginx
> **Note:** Every config option can also be set via environment variables
> (see comments in `example.toml`). Environment variables always take
> precedence over the config file.
## Set up compose and nginx
Copy the production compose and nginx configs:
```sh
cp /opt/tranquil-pds/docker-compose.prod.yaml /srv/tranquil-pds/docker-compose.yml
cp /opt/tranquil-pds/nginx.frontend.conf /srv/tranquil-pds/config/nginx.conf
cp /opt/tranquil-pds/nginx.conf /srv/tranquil-pds/config/nginx.conf
```
Edit `/srv/tranquil-pds/docker-compose.yml` to adjust paths if needed:
@@ -297,13 +298,13 @@ Edit `/srv/tranquil-pds/docker-compose.yml` to adjust paths if needed:
Edit `/srv/tranquil-pds/config/nginx.conf` to update cert paths:
- Change `/etc/nginx/certs/live/${PDS_HOSTNAME}/` to `/etc/nginx/certs/`
## Create OpenRC Service
## Create OpenRC service
```sh
cat > /etc/init.d/tranquil-pds << 'EOF'
#!/sbin/openrc-run
name="tranquil-pds"
description="Tranquil PDS AT Protocol PDS (containerized)"
description="Tranquil PDS AT Protocol PDS"
command="/usr/bin/podman-compose"
command_args="-f /srv/tranquil-pds/docker-compose.yml up"
command_background=true
@@ -314,16 +315,11 @@ depend() {
after firewall
}
start_pre() {
set -a
. /srv/tranquil-pds/config/tranquil-pds.env
set +a
checkpath -d /srv/tranquil-pds
}
stop() {
ebegin "Stopping ${name}"
cd /srv/tranquil-pds
set -a
. /srv/tranquil-pds/config/tranquil-pds.env
set +a
podman-compose -f /srv/tranquil-pds/docker-compose.yml down
eend $?
}
@@ -331,7 +327,7 @@ EOF
chmod +x /etc/init.d/tranquil-pds
```
## Initialize Services
## Initialize services
Start services:
```sh
@@ -349,7 +345,7 @@ DB_IP=$(podman inspect tranquil-pds-db-1 --format '{{.NetworkSettings.Networks.t
DATABASE_URL="postgres://tranquil_pds:$DB_PASSWORD@$DB_IP:5432/pds" sqlx migrate run --source /opt/tranquil-pds/migrations
```
## Obtain Wildcard SSL Certificate
## Obtain wildcard SSL cert
User handles are served as subdomains (eg. `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation.
@@ -381,13 +377,13 @@ ln -sf /srv/tranquil-pds/data/certs/live/pds.example.com/privkey.pem /srv/tranqu
rc-service tranquil-pds restart
```
## Enable Service at Boot
## Enable service at boot time
```sh
rc-update add tranquil-pds
```
## Configure Firewall
## Configure firewall if you're into that sort of thing
```sh
apk add iptables ip6tables
@@ -409,7 +405,7 @@ rc-update add ip6tables
/etc/init.d/ip6tables save
```
## Certificate Renewal
## Cert renewal
Add to root's crontab (`crontab -e`):
```
@@ -418,16 +414,16 @@ Add to root's crontab (`crontab -e`):
---
# Verification and Maintenance
# Verification and maintenance
## Verify Installation
## Verify installation
```sh
curl -s https://pds.example.com/xrpc/_health | jq
curl -s https://pds.example.com/.well-known/atproto-did
```
## View Logs
## View logs
**Debian:**
```bash
@@ -462,7 +458,7 @@ Alpine:
rc-service tranquil-pds restart
```
## Backup Database
## Backup database
**Debian:**
```bash
@@ -474,7 +470,7 @@ podman exec tranquil-pds-db pg_dump -U tranquil_pds pds > /var/backups/pds-$(dat
podman exec tranquil-pds-db-1 pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql
```
## Custom Homepage
## Custom homepage
The frontend container serves `homepage.html` as the landing page. To customize it, either:
+43 -30
View File
@@ -1,23 +1,26 @@
# Tranquil PDS Production Installation on Debian
# Tranquil PDS production installation on debian
This guide covers installing Tranquil PDS on Debian 13.
This guide covers installing Tranquil PDS on Debian.
It is a "compile the thing on the server itself" -style guide.
This cop-out is because Tranquil isn't built and released via CI as of yet.
## Prerequisites
- A VPS with at least 2GB RAM
- Disk space for blobs (depends on usage; plan for ~1GB per active user as a baseline)
- A server :p
- Disk space enough for blobs (depends on usage; plan for ~1GB per active user as a baseline)
- A domain name pointing to your server's IP
- A wildcard TLS certificate for `*.pds.example.com` (user handles are served as subdomains)
- Root or sudo access
- Root/sudo/doas access
## System Setup
## System setup
```bash
apt update && apt upgrade -y
apt install -y curl git build-essential pkg-config libssl-dev
```
## Install Rust
## Install rust
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
@@ -38,7 +41,7 @@ sudo -u postgres psql -c "CREATE DATABASE pds OWNER tranquil_pds;"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE pds TO tranquil_pds;"
```
## Create Blob Storage Directories
## Create blob storage directories
```bash
mkdir -p /var/lib/tranquil/blobs /var/lib/tranquil/backups
@@ -54,7 +57,7 @@ export PATH="$HOME/.deno/bin:$PATH"
echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc
```
## Clone and Build Tranquil PDS
## Clone and build Tranquil PDS
```bash
cd /opt
@@ -66,28 +69,30 @@ cd ..
cargo build --release
```
## Install sqlx-cli and Run Migrations
```bash
cargo install sqlx-cli --no-default-features --features postgres
export DATABASE_URL="postgres://tranquil_pds:your-secure-password@localhost:5432/pds"
sqlx migrate run
```
## Configure Tranquil PDS
```bash
mkdir -p /etc/tranquil-pds
cp /opt/tranquil-pds/.env.example /etc/tranquil-pds/tranquil-pds.env
chmod 600 /etc/tranquil-pds/tranquil-pds.env
cp /opt/tranquil-pds/example.toml /etc/tranquil-pds/config.toml
chmod 600 /etc/tranquil-pds/config.toml
```
Edit `/etc/tranquil-pds/tranquil-pds.env` and fill in your values. Generate secrets with:
Edit `/etc/tranquil-pds/config.toml` and fill in your values. Generate secrets with:
```bash
openssl rand -base64 48
```
## Install Frontend Files
> **Note:** Every config option can also be set via environment variables
> (see comments in `example.toml`). Environment variables always take
> precedence over the config file. You can also pass the config file path
> via the `TRANQUIL_PDS_CONFIG` env var instead of `--config`.
You can validate your configuration before starting the service:
```bash
/usr/local/bin/tranquil-pds --config /etc/tranquil-pds/config.toml validate
```
## Install frontend files
```bash
mkdir -p /var/www/tranquil-pds
@@ -95,7 +100,7 @@ cp -r /opt/tranquil-pds/frontend/dist/* /var/www/tranquil-pds/
chown -R www-data:www-data /var/www/tranquil-pds
```
## Create Systemd Service
## Create systemd service
```bash
useradd -r -s /sbin/nologin tranquil-pds
@@ -110,8 +115,7 @@ After=network.target postgresql.service
Type=simple
User=tranquil-pds
Group=tranquil-pds
EnvironmentFile=/etc/tranquil-pds/tranquil-pds.env
ExecStart=/usr/local/bin/tranquil-pds
ExecStart=/usr/local/bin/tranquil-pds --config /etc/tranquil-pds/config.toml
Restart=always
RestartSec=5
ProtectSystem=strict
@@ -127,7 +131,7 @@ systemctl enable tranquil-pds
systemctl start tranquil-pds
```
## Install and Configure nginx
## Install and configure nginx
```bash
apt install -y nginx certbot python3-certbot-nginx
@@ -175,6 +179,14 @@ server {
proxy_request_buffering off;
}
location = /oauth-client-metadata.json {
root /var/www/tranquil-pds;
default_type application/json;
sub_filter_once off;
sub_filter_types application/json;
sub_filter '__PDS_HOSTNAME__' $host;
}
location /oauth/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
@@ -264,7 +276,7 @@ nginx -t
systemctl reload nginx
```
## Obtain Wildcard SSL Certificate
## Obtain a wildcard SSL cert
User handles are served as subdomains (eg., `alice.pds.example.com`), so you need a wildcard certificate.
@@ -289,7 +301,7 @@ After obtaining the cert, reload nginx:
systemctl reload nginx
```
## Configure Firewall
## Configure firewall if you're into that sort of thing
```bash
apt install -y ufw
@@ -299,7 +311,7 @@ ufw allow 443/tcp
ufw enable
```
## Verify Installation
## Verify installation
```bash
systemctl status tranquil-pds
@@ -323,16 +335,17 @@ cargo build --release
systemctl stop tranquil-pds
cp target/release/tranquil-pds /usr/local/bin/
cp -r frontend/dist/* /var/www/tranquil-pds/
DATABASE_URL="postgres://tranquil_pds:your-secure-password@localhost:5432/pds" sqlx migrate run
systemctl start tranquil-pds
```
Tranquil should auto-migrate if there are any new migrations to be applied to the db, so you don't need to worry.
Backup database:
```bash
sudo -u postgres pg_dump pds > /var/backups/pds-$(date +%Y%m%d).sql
```
## Custom Homepage
## Custom homepage
Drop a `homepage.html` in `/var/www/tranquil-pds/` and it becomes your landing page. Account dashboard is at `/app/` so you won't break anything.
+6 -3
View File
@@ -1,4 +1,4 @@
# Tranquil PDS on Kubernetes
# Tranquil PDS on kubernetes
If you're reaching for kubernetes for this app, you're experienced enough to know how to spin up:
@@ -9,17 +9,20 @@ If you're reaching for kubernetes for this app, you're experienced enough to kno
You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com`. User handles are served as subdomains.
The container image expects:
- A TOML config file mounted at `/etc/tranquil-pds/config.toml` (or passed via `--config`)
- `DATABASE_URL` - postgres connection string
- `BLOB_STORAGE_PATH` - path to blob storage (mount a PV here)
- `BACKUP_STORAGE_PATH` - path for repo backups (optional but recommended)
- `PDS_HOSTNAME` - your PDS hostname (without protocol)
- `JWT_SECRET`, `DPOP_SECRET`, `MASTER_KEY` - generate with `openssl rand -base64 48`
- `CRAWLERS` - typically `https://bsky.network`
and more, check the .env.example.
and more, check the example.toml for all options. Environment variables can override any TOML value.
You can also point to a config file via the `TRANQUIL_PDS_CONFIG` env var.
Health check: `GET /xrpc/_health`
## Custom Homepage
## Custom homepage
Mount a ConfigMap with your `homepage.html` into the container's frontend directory and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything.
+509
View File
@@ -0,0 +1,509 @@
[server]
# Public hostname of the PDS (e.g. `pds.example.com`).
#
# Can also be specified via environment variable `PDS_HOSTNAME`.
#
# Required! This value must be specified.
#hostname =
# Address to bind the HTTP server to.
#
# Can also be specified via environment variable `SERVER_HOST`.
#
# Default value: "127.0.0.1"
#host = "127.0.0.1"
# Port to bind the HTTP server to.
#
# Can also be specified via environment variable `SERVER_PORT`.
#
# Default value: 3000
#port = 3000
# List of domains for user handles.
# Defaults to the PDS hostname when not set.
#
# Can also be specified via environment variable `PDS_USER_HANDLE_DOMAINS`.
#user_handle_domains =
# List of domains available for user registration.
# Defaults to the PDS hostname when not set.
#
# Can also be specified via environment variable `AVAILABLE_USER_DOMAINS`.
#available_user_domains =
# Enable PDS-hosted did:web identities. Hosting did:web requires a
# long-term commitment to serve DID documents; opt-in only.
#
# Can also be specified via environment variable `ENABLE_PDS_HOSTED_DID_WEB`.
#
# Default value: false
#enable_pds_hosted_did_web = false
# When set to true, skip age-assurance birthday prompt for all accounts.
#
# Can also be specified via environment variable `PDS_AGE_ASSURANCE_OVERRIDE`.
#
# Default value: false
#age_assurance_override = false
# Require an invite code for new account registration.
#
# Can also be specified via environment variable `INVITE_CODE_REQUIRED`.
#
# Default value: true
#invite_code_required = true
# Allow HTTP (non-TLS) proxy requests. Only useful during development.
#
# Can also be specified via environment variable `ALLOW_HTTP_PROXY`.
#
# Default value: false
#allow_http_proxy = false
# Disable all rate limiting. Should only be used in testing.
#
# Can also be specified via environment variable `DISABLE_RATE_LIMITING`.
#
# Default value: false
#disable_rate_limiting = false
# List of additional banned words for handle validation.
#
# Can also be specified via environment variable `PDS_BANNED_WORDS`.
#banned_words =
# URL to a privacy policy page.
#
# Can also be specified via environment variable `PRIVACY_POLICY_URL`.
#privacy_policy_url =
# URL to terms of service page.
#
# Can also be specified via environment variable `TERMS_OF_SERVICE_URL`.
#terms_of_service_url =
# Operator contact email address.
#
# Can also be specified via environment variable `CONTACT_EMAIL`.
#contact_email =
# Maximum allowed blob size in bytes (default 10 GiB).
#
# Can also be specified via environment variable `MAX_BLOB_SIZE`.
#
# Default value: 10737418240
#max_blob_size = 10737418240
[database]
# PostgreSQL connection URL.
#
# Can also be specified via environment variable `DATABASE_URL`.
#
# Required! This value must be specified.
#url =
# Maximum number of connections in the pool.
#
# Can also be specified via environment variable `DATABASE_MAX_CONNECTIONS`.
#
# Default value: 100
#max_connections = 100
# Minimum number of idle connections kept in the pool.
#
# Can also be specified via environment variable `DATABASE_MIN_CONNECTIONS`.
#
# Default value: 10
#min_connections = 10
# Timeout in seconds when acquiring a connection from the pool.
#
# Can also be specified via environment variable `DATABASE_ACQUIRE_TIMEOUT_SECS`.
#
# Default value: 10
#acquire_timeout_secs = 10
[secrets]
# Secret used for signing JWTs. Must be at least 32 characters in
# production.
#
# Can also be specified via environment variable `JWT_SECRET`.
#jwt_secret =
# Secret used for DPoP proof validation. Must be at least 32 characters
# in production.
#
# Can also be specified via environment variable `DPOP_SECRET`.
#dpop_secret =
# Master key used for key-encryption and HKDF derivation. Must be at
# least 32 characters in production.
#
# Can also be specified via environment variable `MASTER_KEY`.
#master_key =
# PLC rotation key (DID key). If not set, user-level keys are used.
#
# Can also be specified via environment variable `PLC_ROTATION_KEY`.
#plc_rotation_key =
# Allow insecure/test secrets. NEVER enable in production.
#
# Can also be specified via environment variable `TRANQUIL_PDS_ALLOW_INSECURE_SECRETS`.
#
# Default value: false
#allow_insecure = false
[storage]
# Storage backend: `filesystem` or `s3`.
#
# Can also be specified via environment variable `BLOB_STORAGE_BACKEND`.
#
# Default value: "filesystem"
#backend = "filesystem"
# Path on disk for the filesystem blob backend.
#
# Can also be specified via environment variable `BLOB_STORAGE_PATH`.
#
# Default value: "/var/lib/tranquil-pds/blobs"
#path = "/var/lib/tranquil-pds/blobs"
# S3 bucket name for blob storage.
#
# Can also be specified via environment variable `S3_BUCKET`.
#s3_bucket =
# Custom S3 endpoint URL (for MinIO, R2, etc.).
#
# Can also be specified via environment variable `S3_ENDPOINT`.
#s3_endpoint =
[backup]
# Enable automatic backups.
#
# Can also be specified via environment variable `BACKUP_ENABLED`.
#
# Default value: true
#enabled = true
# Backup storage backend: `filesystem` or `s3`.
#
# Can also be specified via environment variable `BACKUP_STORAGE_BACKEND`.
#
# Default value: "filesystem"
#backend = "filesystem"
# Path on disk for the filesystem backup backend.
#
# Can also be specified via environment variable `BACKUP_STORAGE_PATH`.
#
# Default value: "/var/lib/tranquil-pds/backups"
#path = "/var/lib/tranquil-pds/backups"
# S3 bucket name for backups.
#
# Can also be specified via environment variable `BACKUP_S3_BUCKET`.
#s3_bucket =
# Number of backup revisions to keep per account.
#
# Can also be specified via environment variable `BACKUP_RETENTION_COUNT`.
#
# Default value: 7
#retention_count = 7
# Seconds between backup runs.
#
# Can also be specified via environment variable `BACKUP_INTERVAL_SECS`.
#
# Default value: 86400
#interval_secs = 86400
[cache]
# Cache backend: `ripple` (default, built-in gossip) or `valkey`.
#
# Can also be specified via environment variable `CACHE_BACKEND`.
#
# Default value: "ripple"
#backend = "ripple"
# Valkey / Redis connection URL. Required when `backend = "valkey"`.
#
# Can also be specified via environment variable `VALKEY_URL`.
#valkey_url =
[cache.ripple]
# Address to bind the Ripple gossip protocol listener.
#
# Can also be specified via environment variable `RIPPLE_BIND`.
#
# Default value: "0.0.0.0:0"
#bind_addr = "0.0.0.0:0"
# List of seed peer addresses.
#
# Can also be specified via environment variable `RIPPLE_PEERS`.
#peers =
# Unique machine identifier. Auto-derived from hostname when not set.
#
# Can also be specified via environment variable `RIPPLE_MACHINE_ID`.
#machine_id =
# Gossip protocol interval in milliseconds.
#
# Can also be specified via environment variable `RIPPLE_GOSSIP_INTERVAL_MS`.
#
# Default value: 200
#gossip_interval_ms = 200
# Maximum cache size in megabytes.
#
# Can also be specified via environment variable `RIPPLE_CACHE_MAX_MB`.
#
# Default value: 256
#cache_max_mb = 256
[plc]
# Base URL of the PLC directory.
#
# Can also be specified via environment variable `PLC_DIRECTORY_URL`.
#
# Default value: "https://plc.directory"
#directory_url = "https://plc.directory"
# HTTP request timeout in seconds.
#
# Can also be specified via environment variable `PLC_TIMEOUT_SECS`.
#
# Default value: 10
#timeout_secs = 10
# TCP connect timeout in seconds.
#
# Can also be specified via environment variable `PLC_CONNECT_TIMEOUT_SECS`.
#
# Default value: 5
#connect_timeout_secs = 5
# Seconds to cache DID documents in memory.
#
# Can also be specified via environment variable `DID_CACHE_TTL_SECS`.
#
# Default value: 300
#did_cache_ttl_secs = 300
[firehose]
# Size of the in-memory broadcast buffer for firehose events.
#
# Can also be specified via environment variable `FIREHOSE_BUFFER_SIZE`.
#
# Default value: 10000
#buffer_size = 10000
# How many hours of historical events to replay for cursor-based
# firehose connections.
#
# Can also be specified via environment variable `FIREHOSE_BACKFILL_HOURS`.
#
# Default value: 72
#backfill_hours = 72
# Maximum number of lagged events before disconnecting a slow consumer.
#
# Can also be specified via environment variable `FIREHOSE_MAX_LAG`.
#
# Default value: 5000
#max_lag = 5000
# List of relay / crawler notification URLs.
#
# Can also be specified via environment variable `CRAWLERS`.
#crawlers =
[email]
# Sender email address. When unset, email sending is disabled.
#
# Can also be specified via environment variable `MAIL_FROM_ADDRESS`.
#from_address =
# Display name used in the `From` header.
#
# Can also be specified via environment variable `MAIL_FROM_NAME`.
#
# Default value: "Tranquil PDS"
#from_name = "Tranquil PDS"
# Path to the `sendmail` binary.
#
# Can also be specified via environment variable `SENDMAIL_PATH`.
#
# Default value: "/usr/sbin/sendmail"
#sendmail_path = "/usr/sbin/sendmail"
[discord]
# Discord bot token. When unset, Discord integration is disabled.
#
# Can also be specified via environment variable `DISCORD_BOT_TOKEN`.
#bot_token =
[telegram]
# Telegram bot token. When unset, Telegram integration is disabled.
#
# Can also be specified via environment variable `TELEGRAM_BOT_TOKEN`.
#bot_token =
# Secret token for incoming webhook verification.
#
# Can also be specified via environment variable `TELEGRAM_WEBHOOK_SECRET`.
#webhook_secret =
[signal]
# Path to the `signal-cli` binary.
#
# Can also be specified via environment variable `SIGNAL_CLI_PATH`.
#
# Default value: "/usr/local/bin/signal-cli"
#cli_path = "/usr/local/bin/signal-cli"
# Sender phone number. When unset, Signal integration is disabled.
#
# Can also be specified via environment variable `SIGNAL_SENDER_NUMBER`.
#sender_number =
[notifications]
# Polling interval in milliseconds for the comms queue.
#
# Can also be specified via environment variable `NOTIFICATION_POLL_INTERVAL_MS`.
#
# Default value: 1000
#poll_interval_ms = 1000
# Number of notifications to process per batch.
#
# Can also be specified via environment variable `NOTIFICATION_BATCH_SIZE`.
#
# Default value: 100
#batch_size = 100
[sso]
[sso.github]
# Default value: false
#enabled = false
#client_id =
#client_secret =
#display_name =
[sso.discord]
# Default value: false
#enabled = false
#client_id =
#client_secret =
#display_name =
[sso.google]
# Default value: false
#enabled = false
#client_id =
#client_secret =
#display_name =
[sso.gitlab]
# Default value: false
#enabled = false
#client_id =
#client_secret =
#issuer =
#display_name =
[sso.oidc]
# Default value: false
#enabled = false
#client_id =
#client_secret =
#issuer =
#display_name =
[sso.apple]
# Can also be specified via environment variable `SSO_APPLE_ENABLED`.
# Default value: false
#enabled = false
# Can also be specified via environment variable `SSO_APPLE_CLIENT_ID`.
#client_id =
# Can also be specified via environment variable `SSO_APPLE_TEAM_ID`.
#team_id =
# Can also be specified via environment variable `SSO_APPLE_KEY_ID`.
#key_id =
# Can also be specified via environment variable `SSO_APPLE_PRIVATE_KEY`.
#private_key =
[moderation]
# External report-handling service URL.
#
# Can also be specified via environment variable `REPORT_SERVICE_URL`.
#report_service_url =
# DID of the external report-handling service.
#
# Can also be specified via environment variable `REPORT_SERVICE_DID`.
#report_service_did =
[import]
# Whether the PDS accepts repo imports.
#
# Can also be specified via environment variable `ACCEPTING_REPO_IMPORTS`.
#
# Default value: true
#accepting = true
# Maximum allowed import archive size in bytes (default 1 GiB).
#
# Can also be specified via environment variable `MAX_IMPORT_SIZE`.
#
# Default value: 1073741824
#max_size = 1073741824
# Maximum number of blocks allowed in an import.
#
# Can also be specified via environment variable `MAX_IMPORT_BLOCKS`.
#
# Default value: 500000
#max_blocks = 500000
# Skip CAR verification during import. Only for development/debugging.
#
# Can also be specified via environment variable `SKIP_IMPORT_VERIFICATION`.
#
# Default value: false
#skip_verification = false
[scheduled]
# Interval in seconds between scheduled delete checks.
#
# Can also be specified via environment variable `SCHEDULED_DELETE_CHECK_INTERVAL_SECS`.
#
# Default value: 3600
#delete_check_interval_secs = 3600

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