Skip to content

Commit 3075ed5

Browse files
committed
Gate logviewer behind Discord role-based OAuth2
Add a Discord OAuth2 forward-auth proxy (authproxy) and a Caddy edge so only members of GUILD_ID holding REQUIRED_ROLE_ID can view logs. Caddy runs every request past the proxy's role check (via guilds.members.read) before serving the logviewer; the tunnel now points at caddy:8080. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013vqQJQAud7rmfykwWKJACY
1 parent 41847b6 commit 3075ed5

7 files changed

Lines changed: 258 additions & 14 deletions

File tree

deploy/oracle/.env.example

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ CONNECTION_URI=mongodb+srv://user:pass@cluster.mongodb.net/modmail
2020
LOG_URL=https://logs.yourdomain.com
2121

2222
# Cloudflare Tunnel token. Create a tunnel in the Cloudflare Zero Trust
23-
# dashboard (Networks -> Tunnels), route your hostname to http://logviewer:8000,
23+
# dashboard (Networks -> Connectors), route your hostname to http://caddy:8080,
2424
# then copy the connector token here. See deploy/oracle/README.md.
2525
TUNNEL_TOKEN=your-cloudflare-tunnel-token
26+
27+
# --- Discord OAuth2 login for the logviewer (role-gated access) ---------------
28+
# Only members of GUILD_ID holding REQUIRED_ROLE_ID can view the logs.
29+
30+
# Your Discord OAuth2 application's credentials (Developer Portal -> your app).
31+
DISCORD_CLIENT_ID=729540679365296178
32+
DISCORD_CLIENT_SECRET=your-discord-client-secret
33+
34+
# Must EXACTLY match a redirect added under OAuth2 -> Redirects in the portal.
35+
DISCORD_REDIRECT_URI=https://pebble.getplover.com/auth/callback
36+
37+
# The role a user must have (in GUILD_ID, set above) to view logs.
38+
REQUIRED_ROLE_ID=766320574733221939
39+
40+
# Random secret used to sign login session cookies. Generate one with:
41+
# openssl rand -hex 32
42+
SESSION_SECRET=change-me-to-a-long-random-string

deploy/oracle/Caddyfile

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Edge proxy for the logviewer, sitting between Cloudflare Tunnel and the app.
2+
# TLS is terminated by Cloudflare, so Caddy just serves plain HTTP on :8080.
3+
#
4+
# Point your Cloudflare Tunnel public hostname at http://caddy:8080 (NOT the
5+
# logviewer directly) so every request passes through the Discord auth check.
6+
7+
{
8+
admin off
9+
auto_https off
10+
}
11+
12+
:8080 {
13+
# Auth endpoints are handled directly by the Discord OAuth proxy.
14+
handle /auth/* {
15+
reverse_proxy authproxy:5000
16+
}
17+
18+
# Everything else must pass the role check before reaching the logviewer.
19+
handle {
20+
forward_auth authproxy:5000 {
21+
uri /auth/verify
22+
}
23+
reverse_proxy logviewer:8000
24+
}
25+
}

deploy/oracle/README.md

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ In the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com/) →
4444
3. Add a **Public Hostname**:
4545
- **Subdomain / Domain:** e.g. `logs` + your Cloudflare-managed domain.
4646
- **Type:** `HTTP`
47-
- **URL:** `logviewer:8000` (the container name + port — they share the
48-
Docker network)
47+
- **URL:** `caddy:8080` (the auth-proxy edge — **not** the logviewer
48+
directly, so every request goes through the Discord role check)
4949
4. Save. Cloudflare auto-creates the DNS record and HTTPS cert for that hostname.
5050

5151
## 3. Install Docker on the VM
@@ -79,6 +79,30 @@ Fill in:
7979
just reconnects to the same database).
8080
- `LOG_URL` — your tunnel hostname, e.g. `https://logs.yourdomain.com`
8181
- `TUNNEL_TOKEN` — the token you copied in step 2.
82+
- The `DISCORD_*`, `REQUIRED_ROLE_ID`, and `SESSION_SECRET` values — see the next
83+
step.
84+
85+
## 4b. Lock the logs behind Discord (role-gated)
86+
87+
The logs are protected by a small Discord OAuth2 proxy (`authproxy` + `caddy`):
88+
only members of `GUILD_ID` who hold `REQUIRED_ROLE_ID` can view them.
89+
90+
1. In the [Discord Developer Portal](https://discord.com/developers/applications)
91+
→ your application → **OAuth2**:
92+
- Copy the **Client ID** and **Client Secret** into `DISCORD_CLIENT_ID` /
93+
`DISCORD_CLIENT_SECRET`.
94+
- Under **Redirects**, add **exactly**:
95+
`https://<your-log-domain>/auth/callback`
96+
(e.g. `https://pebble.getplover.com/auth/callback`) and **Save Changes**.
97+
2. In `.env`, set:
98+
- `DISCORD_REDIRECT_URI` to that same `…/auth/callback` URL.
99+
- `REQUIRED_ROLE_ID` to the role ID allowed to view logs.
100+
- `GUILD_ID` is reused from the bot config above.
101+
- `SESSION_SECRET` to a random string: `openssl rand -hex 32`.
102+
103+
The proxy requests the `identify` and `guilds.members.read` scopes — the latter
104+
is what lets it read the visitor's roles in your server. No bot invite or extra
105+
permissions are needed.
82106

83107
## 5. Launch
84108

@@ -88,8 +112,9 @@ docker compose logs -f bot # watch it connect to Discord
88112
docker compose logs cloudflared # should show "Registered tunnel connection"
89113
```
90114

91-
Open `https://logs.yourdomain.com` in a browser — the logviewer should load.
92-
Closed-thread log links will now use that domain.
115+
Open `https://logs.yourdomain.com` in a browser — you'll be sent to Discord to
116+
log in, and only granted through if you hold the required role. Closed-thread log
117+
links will now use that domain.
93118

94119
## 6. Decommission the old host
95120

@@ -111,8 +136,14 @@ cd deploy/oracle && docker compose up -d --build
111136
MongoDB/Atlas network access list (Atlas → Network Access) allows the VM's
112137
public IP.
113138
- **Logviewer domain shows Cloudflare error 502/1033:** the tunnel can't reach
114-
the logviewer. Confirm the Public Hostname URL is exactly `logviewer:8000`,
115-
and that `docker compose logs cloudflared` shows a registered connection.
139+
the edge. Confirm the Public Hostname URL is exactly `caddy:8080`, and that
140+
`docker compose logs cloudflared` shows a registered connection.
141+
- **Discord login loops or "Invalid OAuth2 redirect_uri":** the redirect in the
142+
Developer Portal must match `DISCORD_REDIRECT_URI` exactly, including the
143+
`https://` and the `/auth/callback` path.
144+
- **"You do not have the required role":** the logged-in user lacks
145+
`REQUIRED_ROLE_ID` in `GUILD_ID`, or those IDs are wrong. Check
146+
`docker compose logs authproxy`.
116147
- **`cloudflared` keeps restarting:** the `TUNNEL_TOKEN` is wrong or missing —
117148
re-copy it from the tunnel's install screen.
118149
- **Logviewer container exits with "exec format error" on the ARM VM:** the

deploy/oracle/auth/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM python:3.11-slim
2+
3+
WORKDIR /app
4+
5+
COPY requirements.txt .
6+
RUN pip install --no-cache-dir -r requirements.txt
7+
8+
COPY app.py .
9+
10+
EXPOSE 5000
11+
12+
# Two workers is plenty for an auth gateway; keeps memory low on small VMs.
13+
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "app:app"]

deploy/oracle/auth/app.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Discord OAuth2 forward-auth proxy for the Modmail logviewer.
2+
3+
Sits behind Caddy's `forward_auth`. Visitors are sent through Discord's OAuth2
4+
flow; only members of GUILD_ID who hold REQUIRED_ROLE_ID are issued a signed
5+
session cookie and allowed through to the logviewer.
6+
7+
Endpoints (all under /auth, routed straight to this service by Caddy):
8+
/auth/verify - called by Caddy for every request; 200 if authed, else 302 to login
9+
/auth/login - starts the Discord OAuth2 flow
10+
/auth/callback - Discord redirects here; verifies role, sets session cookie
11+
/auth/logout - clears the session cookie
12+
"""
13+
14+
import os
15+
import time
16+
import secrets
17+
import urllib.parse
18+
19+
import requests
20+
from flask import Flask, request, redirect, make_response
21+
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
22+
23+
CLIENT_ID = os.environ["DISCORD_CLIENT_ID"]
24+
CLIENT_SECRET = os.environ["DISCORD_CLIENT_SECRET"]
25+
REDIRECT_URI = os.environ["DISCORD_REDIRECT_URI"] # https://<domain>/auth/callback
26+
GUILD_ID = os.environ["GUILD_ID"]
27+
REQUIRED_ROLE_ID = os.environ["REQUIRED_ROLE_ID"]
28+
SECRET_KEY = os.environ["SESSION_SECRET"]
29+
30+
COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "modmail_logs_session")
31+
SESSION_TTL = int(os.environ.get("SESSION_TTL", "86400")) # 24h
32+
33+
API_BASE = "https://discord.com/api"
34+
SCOPES = "identify guilds.members.read"
35+
36+
app = Flask(__name__)
37+
session_signer = URLSafeTimedSerializer(SECRET_KEY, salt="modmail-logs-session")
38+
state_signer = URLSafeTimedSerializer(SECRET_KEY, salt="modmail-logs-state")
39+
40+
41+
def _redirect_to_login():
42+
"""Send the browser into Discord's OAuth2 flow, remembering where it wanted to go."""
43+
original = request.headers.get("X-Forwarded-Uri", "/")
44+
state = state_signer.dumps({"nonce": secrets.token_urlsafe(8), "dest": original})
45+
params = urllib.parse.urlencode(
46+
{
47+
"client_id": CLIENT_ID,
48+
"response_type": "code",
49+
"redirect_uri": REDIRECT_URI,
50+
"scope": SCOPES,
51+
"state": state,
52+
}
53+
)
54+
return redirect(f"{API_BASE}/oauth2/authorize?{params}")
55+
56+
57+
@app.route("/auth/verify")
58+
def verify():
59+
token = request.cookies.get(COOKIE_NAME)
60+
if token:
61+
try:
62+
session_signer.loads(token, max_age=SESSION_TTL)
63+
return ("", 200)
64+
except (BadSignature, SignatureExpired):
65+
pass
66+
return _redirect_to_login()
67+
68+
69+
@app.route("/auth/login")
70+
def login():
71+
return _redirect_to_login()
72+
73+
74+
@app.route("/auth/callback")
75+
def callback():
76+
code = request.args.get("code")
77+
state = request.args.get("state")
78+
if not code or not state:
79+
return ("Missing code or state.", 400)
80+
try:
81+
state_data = state_signer.loads(state, max_age=600)
82+
except (BadSignature, SignatureExpired):
83+
return ("Invalid or expired login attempt. Please try again.", 400)
84+
85+
# Exchange the authorization code for an access token.
86+
token_resp = requests.post(
87+
f"{API_BASE}/oauth2/token",
88+
data={
89+
"client_id": CLIENT_ID,
90+
"client_secret": CLIENT_SECRET,
91+
"grant_type": "authorization_code",
92+
"code": code,
93+
"redirect_uri": REDIRECT_URI,
94+
},
95+
headers={"Content-Type": "application/x-www-form-urlencoded"},
96+
timeout=10,
97+
)
98+
if token_resp.status_code != 200:
99+
return ("Discord token exchange failed.", 403)
100+
access_token = token_resp.json().get("access_token")
101+
102+
# Read the caller's member object for the guild (includes their role IDs).
103+
member_resp = requests.get(
104+
f"{API_BASE}/users/@me/guilds/{GUILD_ID}/member",
105+
headers={"Authorization": f"Bearer {access_token}"},
106+
timeout=10,
107+
)
108+
if member_resp.status_code != 200:
109+
return ("You are not a member of the required server.", 403)
110+
member = member_resp.json()
111+
if REQUIRED_ROLE_ID not in member.get("roles", []):
112+
return ("You do not have the required role to view these logs.", 403)
113+
114+
# Authorised: issue a signed session cookie and return to the original page.
115+
user_id = member.get("user", {}).get("id")
116+
value = session_signer.dumps({"id": user_id, "ts": int(time.time())})
117+
dest = state_data.get("dest", "/")
118+
if not dest.startswith("/"):
119+
dest = "/"
120+
resp = make_response(redirect(dest))
121+
resp.set_cookie(
122+
COOKIE_NAME, value, max_age=SESSION_TTL, httponly=True, secure=True, samesite="Lax"
123+
)
124+
return resp
125+
126+
127+
@app.route("/auth/logout")
128+
def logout():
129+
resp = make_response(redirect("/auth/login"))
130+
resp.delete_cookie(COOKIE_NAME)
131+
return resp
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
flask==3.0.3
2+
requests==2.32.3
3+
gunicorn==22.0.0

deploy/oracle/docker-compose.yml

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,39 @@ services:
2828
environment:
2929
# Logviewer reads MONGO_URI; reuse the same database the bot writes to.
3030
- MONGO_URI=${CONNECTION_URI}
31-
# No public port: Cloudflare Tunnel reaches the logviewer privately over the
32-
# Docker network at http://logviewer:8000. To test locally without the
33-
# tunnel, temporarily add: ports: ["8000:8000"]
31+
# No public port: only the Caddy auth proxy talks to the logviewer, privately
32+
# over the Docker network at http://logviewer:8000.
33+
34+
# Discord OAuth2 forward-auth proxy. Verifies the visitor is a member of
35+
# GUILD_ID holding REQUIRED_ROLE_ID before Caddy lets them reach the logviewer.
36+
authproxy:
37+
build:
38+
context: ./auth
39+
image: modmail-authproxy:latest
40+
container_name: modmail-authproxy
41+
restart: always
42+
env_file:
43+
- .env
44+
depends_on:
45+
- logviewer
46+
47+
# Caddy sits in front of the logviewer and runs every request past the auth
48+
# proxy (Discord role check) before serving it. This is what the tunnel points
49+
# at. TLS is handled by Cloudflare, so Caddy serves plain HTTP on :8080.
50+
caddy:
51+
image: caddy:2-alpine
52+
container_name: modmail-caddy
53+
restart: always
54+
volumes:
55+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
56+
depends_on:
57+
- authproxy
58+
- logviewer
3459

3560
# Cloudflare Tunnel: makes an OUTBOUND connection to Cloudflare, so the
3661
# logviewer is published at your domain with automatic HTTPS and no inbound
37-
# ports opened. Create the tunnel in the Cloudflare Zero Trust dashboard,
38-
# route your hostname to the service http://logviewer:8000, and paste the
39-
# tunnel token into TUNNEL_TOKEN in .env. See README.md.
62+
# ports opened. In the Cloudflare dashboard, route your public hostname to
63+
# the service http://caddy:8080 (the auth proxy edge, NOT the logviewer).
4064
cloudflared:
4165
image: cloudflare/cloudflared:latest
4266
container_name: modmail-cloudflared
@@ -45,4 +69,4 @@ services:
4569
environment:
4670
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
4771
depends_on:
48-
- logviewer
72+
- caddy

0 commit comments

Comments
 (0)