Skip to content

Commit da909c6

Browse files
committed
Harden container packaging and document bridge operation
Keep local state and development artifacts out of the image, and generate configuration as the same configured user that runs the bridge. Document persistent state, configuration, login, media, and the native client API.
1 parent 59176b2 commit da909c6

6 files changed

Lines changed: 122 additions & 200 deletions

File tree

‎.dockerignore‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,17 @@ logs
44
start
55
config.yaml
66
registration.yaml
7-
*.db
7+
*.db*
8+
*.sqlite*
89
*.pickle
10+
*.log
11+
.git
12+
.claude
13+
.DS_Store
14+
**/.DS_Store
15+
.env*
16+
*cookies*.json
17+
*storage-state*.json
18+
/reddit
19+
/evidence/
20+
/runtime/

‎.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,8 @@ logs/
88
!.github/workflows/*.yml
99
!.gitlab-ci.yml
1010
.idea
11+
.env*
12+
*cookies*.json
13+
*storage-state*.json
14+
/evidence/
15+
/runtime/

‎Dockerfile‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ FROM golang:1-alpine3.23 AS builder
22

33
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
44

5+
ARG CI_COMMIT_SHA=unknown
6+
ARG CI_COMMIT_TAG=
57
COPY . /build
68
WORKDIR /build
7-
RUN ./build.sh
9+
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CI=true ./build.sh
810

911
FROM alpine:3.23
1012

@@ -15,6 +17,9 @@ RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl
1517

1618
COPY --from=builder /build/reddit /usr/bin/reddit
1719
COPY --from=builder /build/docker-run.sh /docker-run.sh
20+
ARG CI_COMMIT_SHA=unknown
21+
LABEL org.opencontainers.image.source="https://github.com/beeper/reddit" \
22+
org.opencontainers.image.revision="${CI_COMMIT_SHA}"
1823
VOLUME /data
19-
20-
CMD ["/docker-run.sh"]
24+
WORKDIR /data
25+
ENTRYPOINT ["/docker-run.sh"]

‎README.md‎

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,24 @@ require bridge updates.
1313
- Text and media send and receive.
1414
- Replies, edits, reactions, read receipts, and typing notifications.
1515

16+
## Configuration
17+
18+
Direct media uses the standard top-level `direct_media` configuration: enable
19+
it with a reachable media server name/delegation and a persistent server signing
20+
key. Keep that name and key stable so existing signed URLs remain usable. The
21+
bridge must retain the originating Reddit login and Reddit must still serve the
22+
file. Downloads use bounded memory and sniff the actual image MIME type; they
23+
are proxied on demand, without a second media cache or an upload to Matrix.
24+
Reddit can transcode WebP to JPEG, so native event metadata may differ from the
25+
bytes served by the proxy. Existing reuploaded messages are unchanged.
26+
27+
Ordinary Matrix leave events also require `bridge.bridge_matrix_leave: true`;
28+
Beeper membership state requests require `bridge.enable_send_state_requests: true`.
29+
The existing Delete/Ignore action remains available independently of
30+
the Matrix-leave switch. DMs are hidden for the owner, while groups are left.
31+
Group invite/kick capabilities remain rejected in DMs; Reddit enforces group
32+
permissions and invitation limits. Ban/unban and role changes are unsupported.
33+
1634
## Setup
1735

1836
Install and log in to [`bbctl`](https://github.com/beeper/bridge-manager):
@@ -21,20 +39,42 @@ Install and log in to [`bbctl`](https://github.com/beeper/bridge-manager):
2139
bbctl login
2240
```
2341

24-
Register the bridge and generate its config:
42+
Build in the source checkout (Go 1.26+ and libolm are required):
2543

2644
```sh
27-
bbctl config --type bridgev2 -o config.yaml sh-reddit
28-
bbctl register -g -o registration.yaml sh-reddit
45+
./build.sh
2946
```
3047

31-
Run the bridge (requires Go 1.26+ and libolm — `brew install libolm` on
32-
macOS, `apt-get install libolm-dev` on Debian/Ubuntu):
48+
Install libolm with `brew install libolm` on macOS or
49+
`apt-get install libolm-dev` on Debian/Ubuntu.
50+
Generate credentials in a private runtime directory outside the source
51+
checkout. Use a dedicated bridge name for the first registration:
3352

3453
```sh
35-
go run ./cmd/reddit -c config.yaml -r registration.yaml
54+
install -d -m 700 "$HOME/.local/share/beeper-reddit"
55+
cd "$HOME/.local/share/beeper-reddit"
56+
umask 077
57+
bbctl config --type bridgev2 --param pickle_key=generate -o "$PWD/config.yaml" sh-reddit
58+
bbctl register -g -o "$PWD/registration.yaml" sh-reddit
3659
```
3760

61+
Enable Beeper's state-request path in `config.yaml` so group-name changes can
62+
reach the connector. Set this field under the `bridge` section:
63+
64+
```yaml
65+
bridge:
66+
enable_send_state_requests: true
67+
```
68+
69+
Then start the bridge:
70+
71+
```sh
72+
/absolute/path/to/reddit/reddit -c config.yaml -r registration.yaml
73+
```
74+
75+
For updates, stop the bridge process and replace its binary while retaining
76+
the same runtime, registration, database and encryption key.
77+
3878
Then open Beeper Desktop, go to Settings -> Bridges -> Self-hosted Bridges,
3979
find `sh-reddit`, add an account, and complete the Reddit login flow.
4080

‎docker-run.sh‎

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
#!/bin/sh
2+
set -eu
3+
umask 077
24

3-
if [[ -z "$GID" ]]; then
4-
GID="$UID"
5-
fi
5+
bridge_uid="${UID:-1337}"
6+
bridge_gid="${GID:-$bridge_uid}"
7+
case "$bridge_uid:$bridge_gid" in
8+
*[!0-9:]*|:*|*:) echo "UID and GID must be numeric" >&2; exit 1 ;;
9+
esac
10+
11+
mkdir -p /data
12+
chmod 700 /data
13+
cd /data
614

7-
# Define functions.
8-
function fixperms {
9-
chown -R $UID:$GID /data
15+
# Generate and upgrade files as the same numeric user that runs the bridge.
16+
# The previous launcher wrote the initial 0600 config as root, preventing the
17+
# host user from editing it even when UID/GID were explicitly supplied.
18+
if [ "$(id -u)" = 0 ] && [ "$bridge_uid" != 0 ]; then
19+
chown -R "$bridge_uid:$bridge_gid" /data
20+
exec su-exec "$bridge_uid:$bridge_gid" "$0" "$@"
21+
fi
1022

11-
# /opt/reddit is read-only, so disable file logging if it's pointing there.
12-
if [[ "$(yq e '.logging.writers[1].filename' /data/config.yaml)" == "./logs/reddit.log" ]]; then
13-
yq -I4 e -i 'del(.logging.writers[1])' /data/config.yaml
14-
fi
15-
}
23+
if [ "$#" -gt 0 ]; then
24+
exec /usr/bin/reddit "$@"
25+
fi
1626

17-
if [[ ! -f /data/config.yaml ]]; then
27+
if [ ! -f /data/config.yaml ]; then
1828
/usr/bin/reddit -c /data/config.yaml -e
1929
echo "Didn't find a config file."
2030
echo "Copied default config file to /data/config.yaml"
@@ -23,14 +33,13 @@ if [[ ! -f /data/config.yaml ]]; then
2333
exit
2434
fi
2535

26-
if [[ ! -f /data/registration.yaml ]]; then
36+
if [ ! -f /data/registration.yaml ]; then
2737
/usr/bin/reddit -g -c /data/config.yaml -r /data/registration.yaml || exit $?
2838
echo "Didn't find a registration file."
2939
echo "Generated one for you."
3040
echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it."
3141
exit
3242
fi
3343

34-
cd /data
35-
fixperms
36-
exec su-exec $UID:$GID /usr/bin/reddit
44+
chmod 600 /data/config.yaml /data/registration.yaml
45+
exec /usr/bin/reddit -c /data/config.yaml -r /data/registration.yaml

‎pkg/redditchat/README.md‎

Lines changed: 25 additions & 174 deletions
Original file line numberDiff line numberDiff line change
@@ -1,182 +1,33 @@
1-
# Reddit Chat Go
1+
# Reddit chat protocol client
22

3-
Go wrapper for Reddit's Matrix-backed chat service using mautrix-go.
3+
This package implements the Reddit endpoints used by the bridge. It uses
4+
`maunium.net/go/mautrix` for Reddit's Matrix-shaped chat protocol; bridgev2 owns
5+
local message delivery and mappings.
46

5-
Note: the GitHub project is `github.com/mautrix/go`, but its `go.mod` declares the canonical Go import path as `maunium.net/go/mautrix`. Importing `github.com/mautrix/go` directly fails module path validation, so this library uses the canonical mautrix-go import path.
7+
## Authentication
68

7-
## Auth
9+
The connector drives the native operations through bridgev2 login steps:
810

9-
Reddit chat stores Matrix credentials in `https://www.reddit.com` local storage after the chat app loads. The same credentials can also be minted directly from a logged-in Reddit web session by POSTing to `/svc/shreddit/token` with the `csrf_token` cookie.
11+
1. Create a `RedditSession` and call `PrepareLogin` with the username.
12+
2. Present `CaptchaRequest(CaptchaStepPassword)` in the framework's hidden
13+
verification webview, then pass its result to `SubmitPassword`.
14+
3. If Reddit requires two-factor authentication, collect the user's code and
15+
a fresh `CaptchaRequest(CaptchaStepOTP)`, then call `SubmitOTP` on the same
16+
session. Do not repeat the password login.
17+
4. Call `CredentialsFromRedditSession` to mint chat credentials. The connector
18+
persists the session cookies for renewal and validates the account on refresh.
1019

11-
Supported extraction paths:
20+
Each operation accepts a context and performs only that step's HTTP requests.
21+
There is no background login worker or browser-debug connection. OTP codes are
22+
provided by the user; the bridge does not accept authenticator secrets.
1223

13-
- `LoginReddit(ctx, opts)` implements Reddit's current web login flow and returns Reddit session cookies plus Matrix credentials.
14-
- `NewFromRedditLogin(ctx, opts)` logs in, mints Matrix credentials, and returns a ready chat client.
15-
- `CredentialsFromRedditSession(ctx, session)` POSTs `/svc/shreddit/token`, decodes the chat JWT into a Matrix user ID, calls Matrix `whoami` for the device ID, and returns `Credentials`.
16-
- `RedditSessionFromPlaywrightStorageState(path)` imports Reddit session cookies from a Playwright storage-state JSON file without using the old chat localStorage credentials.
17-
- `CredentialsFromPlaywrightStorageState(path)` reads a Playwright storage-state JSON file.
18-
- `CredentialsFromChromeDebugURL(ctx, url)` attaches to a live Chrome/Chromium debug endpoint, opens `https://www.reddit.com/chat/`, and reads the same local storage keys through CDP.
19-
- `SeedChromeFromPlaywrightStorageState(ctx, url, path)` seeds a live Chrome/Chromium debug endpoint from a Playwright storage-state JSON file, then the CDP extractor can read from the real browser session.
24+
## Chat protocol
2025

21-
Chrome debug endpoints may be passed as `http://127.0.0.1:9222` or a browser websocket URL.
26+
Reddit uses `matrix.redditspace.com`, native `t2_` user IDs, opaque event IDs
27+
and provider pagination cursors. Its request previews also appear in
28+
`rooms.peek`. Media downloads use the native `/media/v3/download` endpoint;
29+
thread pages use Reddit's sequenced relations API. These differences are
30+
handled here and by the connector, without overriding bridgev2 delivery.
2231

23-
Login API notes:
24-
25-
- Reddit serves a deterministic JavaScript verification page before the login UI; the library follows that page transition.
26-
- The username/password step is `POST /svc/shreddit/account/login/check_is_oidc_required`, then `POST /svc/shreddit/account/login`.
27-
- Accounts with app-based 2FA receive HTTP 202 from the password step; the OTP step is `POST /svc/shreddit/account/login/otp`.
28-
- Reddit requires a reCAPTCHA Enterprise token for password and OTP submits. The library accepts a `CaptchaTokenProvider` callback and does not include CAPTCHA solving, media extraction, or bypass logic.
29-
- Reddit's current login flow does not POST an image/audio CAPTCHA answer to Reddit. Google reCAPTCHA runs in the browser and returns an opaque, short-lived `recaptcha_token`; that token is the value Reddit receives.
30-
- If no provider is configured, `LoginReddit` returns `CaptchaRequiredError` with the site key, action, and page URL needed by a caller-managed CAPTCHA flow.
31-
- `StaticCaptchaTokenProvider(tokens...)` can be used when the caller already has one or more fresh reCAPTCHA tokens. Accounts with 2FA need two tokens: one for the password step and one for the OTP step.
32-
- `CaptchaTokenProviderFromChromeDebugURL(url)` uses a real Chrome/Chromium debug session, opens the Reddit login page, runs Google reCAPTCHA Enterprise on Reddit's origin, and returns the resulting token. If Google presents an interactive challenge, the user handles it in that browser.
33-
- `CaptchaRequest.Step` is `password` or `otp`, so UI code can show why a token is being requested.
34-
- For an embedded WebView provider, load `CaptchaRequest.PageURL`, then evaluate `CaptchaRequest.EnterpriseExecuteJavaScript()` on that page and return the resulting token. Username, password, and 2FA do not need to be entered in the WebView.
35-
- TOTP generation is built in through `GenerateTOTP` and `RedditLoginOptions.TOTPSecret`.
36-
37-
Minimal login shape:
38-
39-
```go
40-
client, session, err := redditchat.NewFromRedditLogin(ctx, redditchat.RedditLoginOptions{
41-
Username: username,
42-
Password: password,
43-
TOTPSecret: totpSecret,
44-
CaptchaTokenProvider: redditchat.CaptchaTokenProviderFromChromeDebugURL("http://127.0.0.1:9222"),
45-
})
46-
_ = client
47-
_ = session
48-
```
49-
50-
The Chrome-backed provider needs a running browser with remote debugging enabled, for example:
51-
52-
```sh
53-
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/reddit-login-captcha
54-
```
55-
56-
The important detail is origin: the Reddit reCAPTCHA site key must be executed from `https://www.reddit.com/login/`. A local HTML page or unrelated WebView origin will usually produce an invalid token.
57-
58-
WebView provider shape:
59-
60-
```go
61-
CaptchaTokenProvider: func(ctx context.Context, req redditchat.CaptchaRequest) (string, error) {
62-
webview.Navigate(req.PageURL)
63-
token, err := webview.EvaluateJavaScript(ctx, req.EnterpriseExecuteJavaScript())
64-
if err != nil {
65-
return "", err
66-
}
67-
return token, nil
68-
}
69-
```
70-
71-
The WebView is only for reCAPTCHA. `LoginReddit` still submits username, password, and TOTP through the reversed Reddit HTTP endpoints.
72-
73-
## API Surface
74-
75-
The package exposes mautrix-backed helpers for:
76-
77-
- `LoginReddit`, `NewFromRedditLogin`, `CredentialsFromRedditSession`, `RedditSessionFromPlaywrightStorageState`
78-
- `CaptchaTokenProviderFromChromeDebugURL`, `CaptchaTokenFromChromeDebugURL`, `StaticCaptchaTokenProvider`
79-
- `Whoami`, `Capabilities`, `Sync`, `Messages`, `GetEvent`, `JoinedRooms`
80-
- `SearchUsers`, `RoomsWithUser`, `DirectRoomsWithUser`
81-
- `CreateDM`, `CreateOrGetDM`, `CreateGroup`, `Invite`, `AcceptInvite`, `Leave`
82-
- `SendText`, `SendNotice`, `SendMessage`
83-
- `SetTyping`, `MarkRead`
84-
- `MediaConfig`, `UploadMedia`, `SendFile`, `SendImage`, `SendVideo`, `SendAudio`, `SendMedia`
85-
- `SendMXCFile`, `SendMXCImage`, `SendMXCVideo`, `SendMXCAudio`, `SendMXCMedia`
86-
- `DownloadMedia`, `DownloadMediaBytes`
87-
88-
Raw mautrix access is available through `Client.Mautrix()`.
89-
90-
## Smoke Command
91-
92-
Authenticate both saved test sessions and sync:
93-
94-
```sh
95-
go run ./cmd/redditchat-smoke
96-
```
97-
98-
Derive Matrix credentials from Reddit session cookies instead of chat localStorage:
99-
100-
```sh
101-
go run ./cmd/redditchat-smoke -session-auth
102-
```
103-
104-
Use Chrome debug protocol instead of saved storage state:
105-
106-
```sh
107-
go run ./cmd/redditchat-smoke -cdp-a http://127.0.0.1:9222 -cdp-b http://127.0.0.1:9223
108-
```
109-
110-
Seed real Chrome debug sessions from the saved test states, then authenticate through CDP:
111-
112-
```sh
113-
go run ./cmd/redditchat-smoke \
114-
-seed-cdp \
115-
-cdp-a http://127.0.0.1:9222 \
116-
-cdp-b http://127.0.0.1:9223
117-
```
118-
119-
Send a text message through the verified DM room:
120-
121-
```sh
122-
go run ./cmd/redditchat-smoke \
123-
-room '!7qgWTmcqCdRXuLbvlk3ggseaFXdE5jltGuMrhpSbq6o:reddit.com' \
124-
-message 'hello from mautrix'
125-
```
126-
127-
Get or create a DM, or create a group room, then send the smoke message:
128-
129-
```sh
130-
go run ./cmd/redditchat-smoke -create-dm
131-
go run ./cmd/redditchat-smoke -create-group
132-
```
133-
134-
Send media through the Matrix media API:
135-
136-
```sh
137-
go run ./cmd/redditchat-smoke \
138-
-room '!7qgWTmcqCdRXuLbvlk3ggseaFXdE5jltGuMrhpSbq6o:reddit.com' \
139-
-image /tmp/reddit-chat-smoke.png
140-
```
141-
142-
Send an already-hosted Reddit MXC media event:
143-
144-
```sh
145-
go run ./cmd/redditchat-smoke \
146-
-room '!7qgWTmcqCdRXuLbvlk3ggseaFXdE5jltGuMrhpSbq6o:reddit.com' \
147-
-image-url 'mxc://reddit.com/<media-id>' \
148-
-media-name image.jpg \
149-
-media-content-type image/jpeg
150-
```
151-
152-
Print Reddit Matrix media limits:
153-
154-
```sh
155-
go run ./cmd/redditchat-smoke -media-config
156-
```
157-
158-
## Current Live Evidence
159-
160-
- Both test accounts authenticate via Reddit's Matrix tokens.
161-
- Reddit login capture reversed the current web endpoints: password login uses `/svc/shreddit/account/login`; 2FA uses `/svc/shreddit/account/login/otp`; chat token refresh uses `POST /svc/shreddit/token`.
162-
- `-session-auth` was verified with a post-login state that had no chat localStorage bootstrap: account A minted fresh Matrix credentials as `@t2_2foi1of47y:reddit.com` device `63491a13e9d7e54e91096051125ff818`, and account B also minted fresh credentials from Reddit session cookies. Both synced successfully.
163-
- Both test accounts now have verified email addresses in Reddit settings.
164-
- `Sync` works for both accounts.
165-
- Reddit's web client uses `preset: "reddit_dm"` for direct chats and `preset: "private_chat"` for group rooms. The wrapper now matches those presets.
166-
- The web client checks `/_matrix/client/v3/rooms?with_user=...&type=direct&include=state,timeline` before creating a DM. `CreateOrGetDM` implements the same reuse flow.
167-
- `CreateDM` worked previously with a minimal mautrix `CreateRoom` request; repeated smoke runs now reuse the existing DM to avoid room-creation quota.
168-
- Account B accepted the invite via `JoinRoomByID`.
169-
- Account A sent a text message via mautrix in the verified DM room and account B observed it via `Sync`.
170-
- Real Chromium CDP sessions were launched on ports 9222 and 9223, seeded from the saved states, and used for auth/sync through `CredentialsFromChromeDebugURL`.
171-
- CDP-backed send/receive worked in room `!7qgWTmcqCdRXuLbvlk3ggseaFXdE5jltGuMrhpSbq6o:reddit.com`; sent event `$bY18t2ZghWQqGKKUy3K2nn52UcwTlatxDjPiNaTi6lA` was observed by account B.
172-
- The latest `CreateOrGetDM` smoke reused that room and sent event `$9Pie9l3KG8pohOoV_vQayz-7nHoVt058QYW5DnjSm1s`, observed by account B.
173-
- `SendMXCImage` with a `mxc://reddit.com/...` URI sent event `$KpRv0U9TGl_0hYbM9Rd9xHSzOXBvzD4Q-95o80OqZMU`, observed by account B.
174-
175-
New-device note: a message send using a freshly minted session-auth Matrix device reached the existing DM room but Reddit returned `M_FORBIDDEN: User is flagged for spam`. The same accounts can still sync, and earlier established devices can send in the DM. This appears to be another Reddit account/device trust decision rather than a missing Matrix API call.
176-
177-
Group note: `CreateGroup` is implemented as a non-direct Matrix room creation with invites. Live creation is currently blocked by Reddit rate limits/account-establishment gates on the test accounts:
178-
179-
- After email verification and chat reload, account A receives `M_LIMIT_EXCEEDED: Limit exceeded for number of invites attempted` with `rate.score_invitation_limit`.
180-
- Earlier runs also hit `rate.score_room_creation_limit` on account A and `rate.score_invitation_limit_ln` when account B was used as the creator.
181-
182-
Media note: Reddit currently returns `M_FORBIDDEN: Media upload forbidden` for standard Matrix media upload from these sessions, even after both accounts were email verified and the chat app was reloaded. `MediaConfig` reports `m.upload.size: 20971520` and `com.reddit.upload.size: {"image/gif":104857600}`, so the endpoint is present. A headed browser capture of the real Reddit chat UI selected `/tmp/reddit-chat-smoke.png` and the UI itself posted to `https://matrix.redditspace.com/_matrix/media/v3/upload?filename=reddit-chat-smoke.png`, the same endpoint mautrix uses. Reddit returned HTTP 403 and displayed "An error occurred while uploading the image. Please try again." Reddit also rejects HTTPS media URLs and foreign MXC origins in message content, but accepts `mxc://reddit.com/...` media events. A synthetic Reddit-origin MXC URI sends and renders as an image event; a fake media ID will not download actual bytes.
32+
Local protocol tests use synthetic responses and do not establish end-to-end
33+
acceptance.

0 commit comments

Comments
 (0)