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 -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 .
+10 -3
src/main.rs
··· 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}; 22 + use tower_governor::governor::GovernorConfigBuilder; 23 + use tower_governor::key_extractor::SmartIpKeyExtractor; 23 24 use tower_http::compression::CompressionLayer; 24 25 use tower_http::cors::{Any, CorsLayer}; 25 26 use tracing::log; ··· 97 98 "Error loading pds.env file (ignore if you loaded your variables in the environment somehow else): {e}" 98 99 ); 99 100 } 100 - let pds_root = env::var("PDS_DATA_DIRECTORY")?; 101 + 102 + let pds_root = 103 + env::var("PDS_DATA_DIRECTORY").expect("PDS_DATA_DIRECTORY is not set in your pds.env file"); 101 104 let account_db_url = format!("{pds_root}/account.sqlite"); 102 105 103 106 let account_options = SqliteConnectOptions::new() ··· 170 173 let create_session_governor_conf = GovernorConfigBuilder::default() 171 174 .per_second(60) 172 175 .burst_size(5) 176 + .key_extractor(SmartIpKeyExtractor) 173 177 .finish() 174 178 .expect("failed to create governor config for create session. this should not happen and is a bug"); 175 179 ··· 177 181 let sign_in_governor_conf = GovernorConfigBuilder::default() 178 182 .per_second(60) 179 183 .burst_size(5) 184 + .key_extractor(SmartIpKeyExtractor) 180 185 .finish() 181 186 .expect( 182 187 "failed to create governor config for sign in. this should not happen and is a bug", ··· 205 210 create_account_governor_conf.burst_size(burst); 206 211 } 207 212 208 - let create_account_governor_conf = create_account_governor_conf.finish().expect( 213 + let create_account_governor_conf = create_account_governor_conf 214 + .key_extractor(SmartIpKeyExtractor) 215 + .finish().expect( 209 216 "failed to create governor config for create account. this should not happen and is a bug", 210 217 ); 211 218
-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>,
+29 -11
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 { ··· 269 287 State(state): State<AppState>, 270 288 mut req: Request, 271 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 + 272 292 let uri = format!( 273 293 "{}{}", 274 294 state.pds_base_url, "/xrpc/com.atproto.server.createAccount" 275 295 ); 276 296 277 297 // Rewrite the URI to point at the upstream PDS; keep headers, method, and body intact 278 - *req.uri_mut() = uri 279 - .parse() 280 - .map_err(|_| StatusCode::BAD_REQUEST)?; 298 + *req.uri_mut() = uri.parse().map_err(|_| StatusCode::BAD_REQUEST)?; 281 299 282 300 let proxied = state 283 301 .reverse_proxy_client