Microservice to bring 2FA to self hosted PDSes

Compare changes

Choose any two refs to compare.

+1 -1
Cargo.lock
··· 1690 1690 1691 1691 [[package]] 1692 1692 name = "pds_gatekeeper" 1693 - version = "0.1.0" 1693 + version = "0.1.2" 1694 1694 dependencies = [ 1695 1695 "anyhow", 1696 1696 "aws-lc-rs",
+3 -3
Cargo.toml
··· 1 1 [package] 2 2 name = "pds_gatekeeper" 3 - version = "0.1.0" 3 + version = "0.1.2" 4 4 edition = "2024" 5 5 license = "MIT" 6 6 ··· 15 15 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 16 16 hyper-util = { version = "0.1.16", features = ["client", "client-legacy"] } 17 17 tower-http = { version = "0.6", features = ["cors", "compression-zstd"] } 18 - tower_governor = "0.8.0" 18 + tower_governor = { version = "0.8.0", features = ["axum", "tracing"] } 19 19 hex = "0.4" 20 20 jwt-compact = { version = "0.8.0", features = ["es256k"] } 21 21 scrypt = "0.11" 22 - #Leaveing these two cause I think it is needed by the 22 + #Leaveing these two cause I think it is needed by the email crate for ssl 23 23 aws-lc-rs = "1.13.0" 24 24 rustls = { version = "0.23", default-features = false, features = ["tls12", "std", "logging", "aws_lc_rs"] } 25 25 lettre = { version = "0.11", default-features = false, features = ["builder", "webpki-roots", "rustls", "aws-lc-rs", "smtp-transport", "tokio1", "tokio1-rustls"] }
+58 -2
README.md
··· 37 37 ```yml 38 38 gatekeeper: 39 39 container_name: gatekeeper 40 - image: fatfingers23/pds_gatekeeper:arm-latest 40 + image: fatfingers23/pds_gatekeeper:latest 41 41 network_mode: host 42 42 restart: unless-stopped 43 43 #This gives the container to the access to the PDS folder. Source is the location on your server of that directory ··· 49 49 - pds 50 50 ``` 51 51 52 + For Coolify, if you're using Traefik as your proxy you'll need to make sure the labels for the container are set up correctly. A full example can be found at [./examples/coolify-compose.yml](./examples/coolify-compose.yml). 53 + 54 + ```yml 55 + gatekeeper: 56 + container_name: gatekeeper 57 + image: 'fatfingers23/pds_gatekeeper:latest' 58 + restart: unless-stopped 59 + volumes: 60 + - '/pds:/pds' 61 + environment: 62 + - 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}' 63 + - 'PDS_BASE_URL=http://pds:3000' 64 + - GATEKEEPER_HOST=0.0.0.0 65 + depends_on: 66 + - pds 67 + healthcheck: 68 + test: 69 + - CMD 70 + - timeout 71 + - '1' 72 + - bash 73 + - '-c' 74 + - 'cat < /dev/null > /dev/tcp/0.0.0.0/8080' 75 + interval: 10s 76 + timeout: 5s 77 + retries: 3 78 + start_period: 10s 79 + labels: 80 + - traefik.enable=true 81 + - 'traefik.http.routers.pds-gatekeeper.rule=Host(`yourpds.com`) && (Path(`/xrpc/com.atproto.server.getSession`) || Path(`/xrpc/com.atproto.server.updateEmail`) || Path(`/xrpc/com.atproto.server.createSession`) || Path(`/xrpc/com.atproto.server.createAccount`) || Path(`/@atproto/oauth-provider/~api/sign-in`))' 82 + - traefik.http.routers.pds-gatekeeper.entrypoints=https 83 + - traefik.http.routers.pds-gatekeeper.tls=true 84 + - traefik.http.routers.pds-gatekeeper.priority=100 85 + - traefik.http.routers.pds-gatekeeper.middlewares=gatekeeper-cors 86 + - traefik.http.services.pds-gatekeeper.loadbalancer.server.port=8080 87 + - traefik.http.services.pds-gatekeeper.loadbalancer.server.scheme=http 88 + - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS,PATCH' 89 + - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowheaders=*' 90 + - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolalloworiginlist=*' 91 + - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolmaxage=100 92 + - traefik.http.middlewares.gatekeeper-cors.headers.addvaryheader=true 93 + - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowcredentials=true 94 + ``` 95 + 52 96 ## Caddy setup 53 97 54 98 For the reverse proxy I use caddy. This part is what overwrites the endpoints and proxies them to PDS gatekeeper to add ··· 60 104 path /xrpc/com.atproto.server.getSession 61 105 path /xrpc/com.atproto.server.updateEmail 62 106 path /xrpc/com.atproto.server.createSession 107 + path /xrpc/com.atproto.server.createAccount 63 108 path /@atproto/oauth-provider/~api/sign-in 64 109 } 65 110 ··· 79 124 path /xrpc/com.atproto.server.getSession 80 125 path /xrpc/com.atproto.server.updateEmail 81 126 path /xrpc/com.atproto.server.createSession 127 + path /xrpc/com.atproto.server.createAccount 82 128 path /@atproto/oauth-provider/~api/sign-in 83 129 } 84 130 85 131 handle @gatekeeper { 86 - reverse_proxy http://localhost:8080 132 + reverse_proxy http://localhost:8080 { 133 + #Makes sure the cloudflare ip is proxied and able to be picked up by pds gatekeeper 134 + header_up X-Forwarded-For {http.request.header.CF-Connecting-IP} 135 + } 87 136 } 88 137 89 138 reverse_proxy http://localhost:3000 ··· 113 162 `GATEKEEPER_HOST` - Host for pds gatekeeper. Defaults to `127.0.0.1` 114 163 115 164 `GATEKEEPER_PORT` - Port for pds gatekeeper. Defaults to `8080` 165 + 166 + `GATEKEEPER_CREATE_ACCOUNT_PER_SECOND` - Sets how often it takes a count off the limiter. example if you hit the rate 167 + limit of 5 and set to 60, then in 60 seconds you will be able to make one more. Or in 5 minutes be able to make 5 more. 168 + 169 + `GATEKEEPER_CREATE_ACCOUNT_BURST` - Sets how many requests can be made in a burst. In the prior example this is where 170 + the 5 comes from. Example can set this to 10 to allow for 10 requests in a burst, and after 60 seconds it will drop one 171 + off.
+1
examples/Caddyfile
··· 14 14 path /xrpc/com.atproto.server.getSession 15 15 path /xrpc/com.atproto.server.updateEmail 16 16 path /xrpc/com.atproto.server.createSession 17 + path /xrpc/com.atproto.server.createAccount 17 18 path /@atproto/oauth-provider/~api/sign-in 18 19 } 19 20
+1 -1
examples/compose.yml
··· 39 39 WATCHTOWER_SCHEDULE: "@midnight" 40 40 gatekeeper: 41 41 container_name: gatekeeper 42 - image: fatfingers23/pds_gatekeeper:arm-latest 42 + image: fatfingers23/pds_gatekeeper:latest 43 43 network_mode: host 44 44 restart: unless-stopped 45 45 #This gives the container to the access to the PDS folder. Source is the location on your server of that directory
+73
examples/coolify-compose.yml
··· 1 + services: 2 + pds: 3 + image: 'ghcr.io/bluesky-social/pds:0.4.182' 4 + volumes: 5 + - '/pds:/pds' 6 + environment: 7 + - SERVICE_URL_PDS_3000 8 + - 'PDS_HOSTNAME=${SERVICE_FQDN_PDS_3000}' 9 + - 'PDS_JWT_SECRET=${SERVICE_HEX_32_JWTSECRET}' 10 + - 'PDS_ADMIN_PASSWORD=${SERVICE_PASSWORD_ADMIN}' 11 + - 'PDS_ADMIN_EMAIL=${PDS_ADMIN_EMAIL}' 12 + - 'PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${SERVICE_HEX_32_ROTATIONKEY}' 13 + - 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}' 14 + - 'PDS_BLOBSTORE_DISK_LOCATION=${PDS_DATA_DIRECTORY:-/pds}/blocks' 15 + - 'PDS_BLOB_UPLOAD_LIMIT=${PDS_BLOB_UPLOAD_LIMIT:-104857600}' 16 + - 'PDS_DID_PLC_URL=${PDS_DID_PLC_URL:-https://plc.directory}' 17 + - 'PDS_EMAIL_FROM_ADDRESS=${PDS_EMAIL_FROM_ADDRESS}' 18 + - 'PDS_EMAIL_SMTP_URL=${PDS_EMAIL_SMTP_URL}' 19 + - 'PDS_BSKY_APP_VIEW_URL=${PDS_BSKY_APP_VIEW_URL:-https://api.bsky.app}' 20 + - 'PDS_BSKY_APP_VIEW_DID=${PDS_BSKY_APP_VIEW_DID:-did:web:api.bsky.app}' 21 + - 'PDS_REPORT_SERVICE_URL=${PDS_REPORT_SERVICE_URL:-https://mod.bsky.app/xrpc/com.atproto.moderation.createReport}' 22 + - 'PDS_REPORT_SERVICE_DID=${PDS_REPORT_SERVICE_DID:-did:plc:ar7c4by46qjdydhdevvrndac}' 23 + - 'PDS_CRAWLERS=${PDS_CRAWLERS:-https://bsky.network}' 24 + - 'LOG_ENABLED=${LOG_ENABLED:-true}' 25 + command: "sh -c '\n set -euo pipefail\n echo \"Installing required packages and pdsadmin...\"\n apk add --no-cache openssl curl bash jq coreutils gnupg util-linux-misc >/dev/null\n curl -o /usr/local/bin/pdsadmin.sh https://raw.githubusercontent.com/bluesky-social/pds/main/pdsadmin.sh\n chmod 700 /usr/local/bin/pdsadmin.sh\n ln -sf /usr/local/bin/pdsadmin.sh /usr/local/bin/pdsadmin\n echo \"Creating an empty pds.env file so pdsadmin works...\"\n touch ${PDS_DATA_DIRECTORY}/pds.env\n echo \"Launching PDS, enjoy!...\"\n exec node --enable-source-maps index.js\n'\n" 26 + healthcheck: 27 + test: 28 + - CMD 29 + - wget 30 + - '--spider' 31 + - 'http://127.0.0.1:3000/xrpc/_health' 32 + interval: 5s 33 + timeout: 10s 34 + retries: 10 35 + gatekeeper: 36 + container_name: gatekeeper 37 + image: 'fatfingers23/pds_gatekeeper:latest' 38 + restart: unless-stopped 39 + volumes: 40 + - '/pds:/pds' 41 + environment: 42 + - 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}' 43 + - 'PDS_BASE_URL=http://pds:3000' 44 + - GATEKEEPER_HOST=0.0.0.0 45 + depends_on: 46 + - pds 47 + healthcheck: 48 + test: 49 + - CMD 50 + - timeout 51 + - '1' 52 + - bash 53 + - '-c' 54 + - 'cat < /dev/null > /dev/tcp/0.0.0.0/8080' 55 + interval: 10s 56 + timeout: 5s 57 + retries: 3 58 + start_period: 10s 59 + labels: 60 + - traefik.enable=true 61 + - 'traefik.http.routers.pds-gatekeeper.rule=Host(`yourpds.com`) && (Path(`/xrpc/com.atproto.server.getSession`) || Path(`/xrpc/com.atproto.server.updateEmail`) || Path(`/xrpc/com.atproto.server.createSession`) || Path(`/xrpc/com.atproto.server.createAccount`) || Path(`/@atproto/oauth-provider/~api/sign-in`))' 62 + - traefik.http.routers.pds-gatekeeper.entrypoints=https 63 + - traefik.http.routers.pds-gatekeeper.tls=true 64 + - traefik.http.routers.pds-gatekeeper.priority=100 65 + - traefik.http.routers.pds-gatekeeper.middlewares=gatekeeper-cors 66 + - traefik.http.services.pds-gatekeeper.loadbalancer.server.port=8080 67 + - traefik.http.services.pds-gatekeeper.loadbalancer.server.scheme=http 68 + - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS,PATCH' 69 + - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowheaders=*' 70 + - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolalloworiginlist=*' 71 + - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolmaxage=100 72 + - traefik.http.middlewares.gatekeeper-cors.headers.addvaryheader=true 73 + - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowcredentials=true
+1 -1
justfile
··· 2 2 docker buildx build \ 3 3 --platform linux/arm64,linux/amd64 \ 4 4 --tag fatfingers23/pds_gatekeeper:latest \ 5 - --tag fatfingers23/pds_gatekeeper:0.1.0.1 \ 5 + --tag fatfingers23/pds_gatekeeper:0.1.0.3 \ 6 6 --push .
+34 -23
src/main.rs
··· 1 1 #![warn(clippy::unwrap_used)] 2 2 use crate::oauth_provider::sign_in; 3 - use crate::xrpc::com_atproto_server::{create_session, get_session, update_email}; 3 + use crate::xrpc::com_atproto_server::{create_account, create_session, get_session, update_email}; 4 4 use axum::body::Body; 5 5 use axum::handler::Handler; 6 6 use axum::http::{Method, header}; ··· 19 19 use std::time::Duration; 20 20 use std::{env, net::SocketAddr}; 21 21 use tower_governor::GovernorLayer; 22 - use tower_governor::governor::{GovernorConfig, GovernorConfigBuilder}; 23 - use tower_governor::key_extractor::PeerIpKeyExtractor; 22 + use tower_governor::governor::GovernorConfigBuilder; 23 + use tower_governor::key_extractor::SmartIpKeyExtractor; 24 24 use tower_http::compression::CompressionLayer; 25 25 use tower_http::cors::{Any, CorsLayer}; 26 26 use tracing::log; ··· 92 92 let pds_env_location = 93 93 env::var("PDS_ENV_LOCATION").unwrap_or_else(|_| "/pds/pds.env".to_string()); 94 94 95 - dotenvy::from_path(Path::new(&pds_env_location))?; 96 - let pds_root = env::var("PDS_DATA_DIRECTORY")?; 95 + let result_of_finding_pds_env = dotenvy::from_path(Path::new(&pds_env_location)); 96 + if let Err(e) = result_of_finding_pds_env { 97 + log::error!( 98 + "Error loading pds.env file (ignore if you loaded your variables in the environment somehow else): {e}" 99 + ); 100 + } 101 + 102 + let pds_root = 103 + env::var("PDS_DATA_DIRECTORY").expect("PDS_DATA_DIRECTORY is not set in your pds.env file"); 97 104 let account_db_url = format!("{pds_root}/account.sqlite"); 98 105 99 106 let account_options = SqliteConnectOptions::new() ··· 166 173 let create_session_governor_conf = GovernorConfigBuilder::default() 167 174 .per_second(60) 168 175 .burst_size(5) 176 + .key_extractor(SmartIpKeyExtractor) 169 177 .finish() 170 178 .expect("failed to create governor config for create session. this should not happen and is a bug"); 171 179 ··· 173 181 let sign_in_governor_conf = GovernorConfigBuilder::default() 174 182 .per_second(60) 175 183 .burst_size(5) 184 + .key_extractor(SmartIpKeyExtractor) 176 185 .finish() 177 186 .expect( 178 187 "failed to create governor config for sign in. this should not happen and is a bug", ··· 182 191 env::var("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND").ok(); 183 192 let create_account_limiter_burst: Option<String> = 184 193 env::var("GATEKEEPER_CREATE_ACCOUNT_BURST").ok(); 185 - let mut create_account_governor_conf = None; 186 194 187 - if create_account_governor_conf.is_some() && create_account_limiter_time.is_some() { 195 + //Default should be 608 requests per 5 minutes, PDS is 300 per 500 so will never hit it ideally 196 + let mut create_account_governor_conf = GovernorConfigBuilder::default(); 197 + if create_account_limiter_time.is_some() { 188 198 let time = create_account_limiter_time 189 199 .expect("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND not set") 190 200 .parse::<u64>() 191 201 .expect("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND must be a valid integer"); 202 + create_account_governor_conf.per_second(time); 203 + } 204 + 205 + if create_account_limiter_burst.is_some() { 192 206 let burst = create_account_limiter_burst 193 207 .expect("GATEKEEPER_CREATE_ACCOUNT_BURST not set") 194 208 .parse::<u32>() 195 209 .expect("GATEKEEPER_CREATE_ACCOUNT_BURST must be a valid integer"); 196 - 197 - create_account_governor_conf = Some( 198 - GovernorConfigBuilder::default() 199 - .per_second(time) 200 - .burst_size(burst) 201 - .finish() 202 - .expect("failed to create governor config for create account. this should not happen and is a bug"), 203 - ) 210 + create_account_governor_conf.burst_size(burst); 204 211 } 212 + 213 + let create_account_governor_conf = create_account_governor_conf 214 + .key_extractor(SmartIpKeyExtractor) 215 + .finish().expect( 216 + "failed to create governor config for create account. this should not happen and is a bug", 217 + ); 205 218 206 219 let create_session_governor_limiter = create_session_governor_conf.limiter().clone(); 207 220 let sign_in_governor_limiter = sign_in_governor_conf.limiter().clone(); 208 - let create_account_governor_limiter = match create_account_governor_conf { 209 - None => None, 210 - Some(conf) => Some(conf.limiter().clone()), 211 - }; 221 + let create_account_governor_limiter = create_account_governor_conf.limiter().clone(); 212 222 213 223 let interval = Duration::from_secs(60); 214 224 // a separate background task to clean up ··· 217 227 std::thread::sleep(interval); 218 228 create_session_governor_limiter.retain_recent(); 219 229 sign_in_governor_limiter.retain_recent(); 220 - if let Some(ref limiter) = create_account_governor_limiter { 221 - limiter.retain_recent(); 222 - } 230 + create_account_governor_limiter.retain_recent(); 223 231 } 224 232 }); 225 233 ··· 243 251 "/xrpc/com.atproto.server.createSession", 244 252 post(create_session.layer(GovernorLayer::new(create_session_governor_conf))), 245 253 ) 246 - .route("/xrpc/com.atproto.server.createAccount") 254 + .route( 255 + "/xrpc/com.atproto.server.createAccount", 256 + post(create_account).layer(GovernorLayer::new(create_account_governor_conf)), 257 + ) 247 258 .layer(CompressionLayer::new()) 248 259 .layer(cors) 249 260 .with_state(state);
-2
src/middleware.rs
··· 1 1 use crate::helpers::json_error_response; 2 2 use axum::extract::Request; 3 - use axum::http::header::AUTHORIZATION; 4 3 use axum::http::{HeaderMap, StatusCode}; 5 4 use axum::middleware::Next; 6 5 use axum::response::IntoResponse; ··· 73 72 .expect("Error creating an error response"); 74 73 } 75 74 let token = token.expect("Already checked for error,"); 76 - // Not going to worry about expiration since it still goes to the PDS 77 75 req.extensions_mut() 78 76 .insert(Did(Some(token.claims().custom.sub.clone()))); 79 77 }
+2 -1
src/oauth_provider.rs
··· 13 13 pub struct SignInRequest { 14 14 pub username: String, 15 15 pub password: String, 16 - pub remember: bool, 16 + #[serde(skip_serializing_if = "Option::is_none")] 17 + pub remember: Option<bool>, 17 18 pub locale: String, 18 19 #[serde(skip_serializing_if = "Option::is_none", rename = "emailOtp")] 19 20 pub email_otp: Option<String>,
+50 -8
src/xrpc/com_atproto_server.rs
··· 155 155 // Email update asked for 156 156 if email_auth_update { 157 157 let email = payload.email.clone(); 158 - let email_confirmed = sqlx::query_as::<_, (String,)>( 158 + let email_confirmed = match sqlx::query_as::<_, (String,)>( 159 159 "SELECT did FROM account WHERE emailConfirmedAt IS NOT NULL AND email = ?", 160 160 ) 161 161 .bind(&email) 162 162 .fetch_optional(&state.account_pool) 163 163 .await 164 - .map_err(|_| StatusCode::BAD_REQUEST)?; 164 + { 165 + Ok(row) => row, 166 + Err(err) => { 167 + log::error!("Error checking if email is confirmed: {err}"); 168 + return Err(StatusCode::BAD_REQUEST); 169 + } 170 + }; 165 171 166 172 //Since the email is already confirmed we can enable 2fa 167 173 return match email_confirmed { ··· 184 190 if !email_auth_update && !email_auth_not_set { 185 191 //User wants auth turned off and has a token 186 192 if let Some(token) = &payload.token { 187 - let token_found = sqlx::query_as::<_, (String,)>( 193 + let token_found = match sqlx::query_as::<_, (String,)>( 188 194 "SELECT token FROM email_token WHERE token = ? AND did = ? AND purpose = 'update_email'", 189 195 ) 190 196 .bind(token) 191 197 .bind(&did.0) 192 198 .fetch_optional(&state.account_pool) 193 - .await 194 - .map_err(|_| StatusCode::BAD_REQUEST)?; 199 + .await{ 200 + Ok(token) => token, 201 + Err(err) => { 202 + log::error!("Error checking if token is valid: {err}"); 203 + return Err(StatusCode::BAD_REQUEST); 204 + } 205 + }; 195 206 196 207 return if token_found.is_some() { 197 - let _ = sqlx::query( 208 + //TODO I think there may be a bug here and need to do some retry logic 209 + // First try was erroring, seconds was allowing 210 + match sqlx::query( 198 211 "INSERT INTO two_factor_accounts (did, required) VALUES (?, 0) ON CONFLICT(did) DO UPDATE SET required = 0", 199 212 ) 200 213 .bind(&did.0) 201 214 .execute(&state.pds_gatekeeper_pool) 202 - .await 203 - .map_err(|_| StatusCode::BAD_REQUEST)?; 215 + .await { 216 + Ok(_) => {} 217 + Err(err) => { 218 + log::error!("Error updating email auth: {err}"); 219 + return Err(StatusCode::BAD_REQUEST); 220 + } 221 + } 204 222 205 223 Ok(StatusCode::OK.into_response()) 206 224 } else { ··· 264 282 ProxiedResult::Passthrough(resp) => Ok(resp), 265 283 } 266 284 } 285 + 286 + pub async fn create_account( 287 + State(state): State<AppState>, 288 + mut req: Request, 289 + ) -> Result<Response<Body>, StatusCode> { 290 + //TODO if I add the block of only accounts authenticated just take the body as json here and grab the lxm token. No middle ware is needed 291 + 292 + let uri = format!( 293 + "{}{}", 294 + state.pds_base_url, "/xrpc/com.atproto.server.createAccount" 295 + ); 296 + 297 + // Rewrite the URI to point at the upstream PDS; keep headers, method, and body intact 298 + *req.uri_mut() = uri.parse().map_err(|_| StatusCode::BAD_REQUEST)?; 299 + 300 + let proxied = state 301 + .reverse_proxy_client 302 + .request(req) 303 + .await 304 + .map_err(|_| StatusCode::BAD_REQUEST)? 305 + .into_response(); 306 + 307 + Ok(proxied) 308 + }