+1
-1
Cargo.lock
+1
-1
Cargo.lock
+3
-3
Cargo.toml
+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
+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
+1
examples/Caddyfile
+1
-1
examples/compose.yml
+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
+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
+1
-1
justfile
+34
-23
src/main.rs
+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
-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
+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
+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
+
}