Skip to content

Commit f0b3082

Browse files
retlehsswalkinshawclaude
authored
Replace chi router with net/http ServeMux (#22)
* Replace chi router with net/http ServeMux Remove the chi dependency in favor of Go 1.22+ ServeMux patterns and a small set of stdlib middleware (Recoverer, RealIP, TimeoutHandler). Drop ADMIN_ALLOW_CIDR and RequireAllowedIP — production already disabled IP restriction. Admin access relies solely on in-app auth. Keep TRUST_PROXY so clientIP() works behind Caddy for rate limiting and telemetry dedupe. Co-Authored-By: Scott Walkinshaw <scott.walkinshaw@gmail.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Remove TrustProxy config, always apply RealIP middleware RealIP is needed for login rate limiting and telemetry dedupe behind Caddy. No reason to gate it behind a config flag — the app is always proxied in production, and in dev the headers aren't present so RemoteAddr passes through unchanged. Co-Authored-By: Scott Walkinshaw <scott.walkinshaw@gmail.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix SSE log stream timeout and Flusher support The global http.TimeoutHandler replaced the ResponseWriter with a buffering writer that doesn't implement http.Flusher, breaking /admin/logs/stream in two ways: 1. The SSE handler's w.(http.Flusher) assertion always failed (500) 2. The previous noTimeout(context.WithoutCancel) couldn't bypass TimeoutHandler since it replaces the writer, not just the context Fix: path-based timeout bypass that skips http.TimeoutHandler entirely for streaming endpoints, preserving the original ResponseWriter. Add Flush()/Unwrap() to statusRecorder so Flusher propagates through the custom 404 layer. Remove the broken context-key noTimeout mechanism. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Scott Walkinshaw <scott.walkinshaw@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 107df97 commit f0b3082

File tree

16 files changed

+493
-320
lines changed

16 files changed

+493
-320
lines changed

deploy/ansible/group_vars/production/main.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,6 @@ r2_cdn_public_url: "https://cdn.wp-composer.com"
3232
# Sessions
3333
session_lifetime_minutes: 7200
3434

35-
# Admin access (empty = no IP restriction, set Tailscale CIDRs when ready)
36-
admin_allow_cidr: ""
37-
3835
# Repository
3936
repository_path: "{{ app_root }}/shared/storage/repository"
4037

deploy/ansible/roles/app/templates/env.j2

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ LOG_LEVEL={{ go_log_level }}
55

66
DB_PATH={{ db_path }}
77
LISTEN_ADDR={{ go_listen_addr }}
8-
TRUST_PROXY=true
9-
10-
ADMIN_ALLOW_CIDR={{ admin_allow_cidr }}
118
SESSION_LIFETIME_MINUTES={{ session_lifetime_minutes }}
129

1310
SEEDS_FILE={{ seeds_file }}

docs/admin-access.md

Lines changed: 2 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,9 @@
22

33
## Security Model
44

5-
Admin access uses defense in depth with two independent layers:
5+
Admin access is protected by in-app authentication. Email/password login and admin authorization are required for all protected `/admin/*` routes.
66

7-
1. **Network layer** — Tailscale restricts access to `/admin/*` routes. Only devices on the tailnet can reach admin endpoints.
8-
2. **Application layer** — in-app authentication (email/password) and admin authorization required for all protected `/admin/*` routes.
9-
10-
Both layers must pass. A valid Tailscale connection without app auth (or vice versa) is denied.
11-
12-
## Network Restriction
13-
14-
The Go app enforces IP-based access control on all `/admin/*` routes via the `ADMIN_ALLOW_CIDR` config.
15-
16-
Default allowed ranges (Tailscale):
17-
- `100.64.0.0/10` (Tailscale IPv4)
18-
- `fd7a:115c:a1e0::/48` (Tailscale IPv6)
19-
20-
Override via environment:
21-
22-
```env
23-
ADMIN_ALLOW_CIDR=100.64.0.0/10,fd7a:115c:a1e0::/48,10.0.0.0/8
24-
```
25-
26-
Set to empty to disable IP restriction (development only):
27-
28-
```env
29-
ADMIN_ALLOW_CIDR=
30-
```
31-
32-
If all configured CIDRs are invalid, the middleware fails closed — all admin access is blocked until the configuration is corrected.
33-
34-
### Proxy trust
35-
36-
By default, the app uses the raw `RemoteAddr` for IP checks. If behind a reverse proxy (Caddy, nginx), enable proxy header trust:
37-
38-
```env
39-
TRUST_PROXY=true
40-
```
41-
42-
This enables Chi's `RealIP` middleware, which reads `X-Forwarded-For` / `X-Real-IP` headers to determine the client IP. **Only enable this when the app is behind a trusted proxy** — otherwise clients can spoof their IP via forwarding headers and bypass admin IP restrictions.
7+
**Note:** The app always trusts `X-Real-IP` / `X-Forwarded-For` headers for client IP resolution (used for login rate limiting and telemetry dedupe). It must be deployed behind a trusted reverse proxy (Caddy) — never exposed directly to the internet.
438

449
## Admin Bootstrap
4510

@@ -78,18 +43,6 @@ wpcomposer cleanup-sessions
7843

7944
Run via systemd timer or cron (daily recommended).
8045

81-
## Exposure Verification
82-
83-
To verify admin is not publicly accessible:
84-
85-
```bash
86-
# From outside the tailnet — should return 403
87-
curl -s -o /dev/null -w "%{http_code}" https://app.example.com/admin/login
88-
89-
# From inside the tailnet — should return 200
90-
curl -s -o /dev/null -w "%{http_code}" https://app.example.com/admin/login
91-
```
92-
9346
## Emergency Password Reset
9447

9548
If locked out of the admin panel:

docs/architecture.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ WP Composer has two primary runtime concerns:
2929
### Web UI
3030

3131
- Public package browser/detail pages via server-rendered Go templates + Tailwind.
32-
- Admin panel at `/admin` with Tailscale network gating + in-app auth (defense in depth).
32+
- Admin panel at `/admin` with in-app auth (email/password + session).
3333

3434
## Module Layout
3535

@@ -43,7 +43,7 @@ internal/
4343
├── repository/ artifact generation, hashing, integrity validation
4444
├── deploy/ local promote/rollback/cleanup + R2 sync
4545
├── telemetry/ event ingestion, dedupe, rollups
46-
└── http/ Chi router, handlers, templates, static assets
46+
└── http/ stdlib router, handlers, templates, static assets
4747
```
4848

4949
## Data Flow
@@ -93,7 +93,7 @@ storage/repository/
9393
- `GET /admin/packages` — package management
9494
- `GET /admin/builds` — build history/status
9595
- Admin-triggered sync/build/deploy actions
96-
- Access: Tailscale network gating + in-app auth/authorization required
96+
- Access: in-app auth/authorization required
9797

9898
## Static Repository Deployment
9999

docs/development.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,23 +68,21 @@ Env-first with optional YAML config file (env overrides YAML).
6868
| `R2_SECRET_ACCESS_KEY` || R2 credentials |
6969
| `R2_BUCKET` || R2 bucket name |
7070
| `R2_ENDPOINT` || R2 S3-compatible endpoint |
71-
| `ADMIN_ALLOW_CIDR` | Tailscale ranges | Comma-separated CIDRs for `/admin/*` access |
72-
| `TRUST_PROXY` | `false` | Trust `X-Forwarded-For` for client IP (enable behind proxy) |
7371
| `SESSION_LIFETIME_MINUTES` | `7200` | Admin session lifetime |
7472

7573
## Technical Decisions
7674

7775
| Area | Choice |
7876
|------|--------|
7977
| CLI | Cobra |
80-
| HTTP router | Chi |
78+
| HTTP router | `net/http` (stdlib) |
8179
| Migrations | Goose (SQL-first) |
8280
| Templates | `html/template` + Tailwind |
8381
| Logging | `log/slog` |
8482
| SQLite driver | `modernc.org/sqlite` |
8583
| R2 | AWS SDK for Go v2 |
8684
| Config | env-first + optional YAML |
87-
| Admin access | Tailscale network gating + in-app auth |
85+
| Admin access | In-app auth (email/password + session) |
8886

8987
## Make Targets
9088

docs/operations.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,7 @@ echo 'new-password' | wpcomposer admin reset-password --email admin@example.com
165165

166166
## Admin Access Model
167167

168-
Admin access uses defense in depth:
169-
170-
1. **Network layer** — Tailscale restricts access to `/admin/*` routes to authorized devices on the tailnet.
171-
2. **Application layer** — in-app authentication and admin authorization required for all `/admin/*` routes.
172-
173-
Both layers must pass. A valid Tailscale connection without app auth (or vice versa) is denied.
168+
Admin access is protected by in-app authentication (email/password) and admin authorization for all `/admin/*` routes.
174169

175170
## Server Provisioning
176171

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ require (
88
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1
99
github.com/fogleman/gg v1.3.0
1010
github.com/getsentry/sentry-go v0.43.0
11-
github.com/go-chi/chi/v5 v5.2.5
1211
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
1312
github.com/pressly/goose/v3 v3.27.0
1413
github.com/spf13/cobra v1.10.2

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@ github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
3232
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
3333
github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4=
3434
github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
35-
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
36-
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
3735
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
3836
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
3937
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=

internal/config/config.go

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,7 @@ type DBConfig struct {
3030
}
3131

3232
type ServerConfig struct {
33-
Addr string `yaml:"addr"`
34-
AdminAllowCIDR []string `yaml:"admin_allow_cidr"`
35-
TrustProxy bool `yaml:"trust_proxy"`
33+
Addr string `yaml:"addr"`
3634
}
3735

3836
type R2Config struct {
@@ -68,8 +66,7 @@ func defaults() *Config {
6866
LogLevel: "info",
6967
DB: DBConfig{Path: "./storage/wpcomposer.db"},
7068
Server: ServerConfig{
71-
Addr: ":8080",
72-
AdminAllowCIDR: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"}, // Tailscale default ranges
69+
Addr: ":8080",
7370
},
7471
Session: SessionConfig{LifetimeMinutes: 7200},
7572
Telemetry: TelemetryConfig{
@@ -156,16 +153,6 @@ func applyEnv(cfg *Config) {
156153
cfg.Telemetry.DedupeWindowSeconds = n
157154
}
158155
}
159-
if v := os.Getenv("TRUST_PROXY"); v != "" {
160-
cfg.Server.TrustProxy = strings.EqualFold(v, "true") || v == "1"
161-
}
162-
if v, ok := os.LookupEnv("ADMIN_ALLOW_CIDR"); ok {
163-
if v == "" {
164-
cfg.Server.AdminAllowCIDR = nil // explicitly disable restriction
165-
} else {
166-
cfg.Server.AdminAllowCIDR = strings.Split(v, ",")
167-
}
168-
}
169156
if v := os.Getenv("SEEDS_FILE"); v != "" {
170157
cfg.Discovery.SeedsFile = v
171158
}

internal/http/handlers.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import (
2020
"time"
2121

2222
"github.com/getsentry/sentry-go"
23-
"github.com/go-chi/chi/v5"
2423
"github.com/roots/wp-composer/internal/app"
2524
"github.com/roots/wp-composer/internal/config"
2625
"github.com/roots/wp-composer/internal/deploy"
@@ -197,8 +196,8 @@ func handleRootsWordpress(a *app.App, tmpl *templateSet) http.HandlerFunc {
197196

198197
func handleDetail(a *app.App, tmpl *templateSet) http.HandlerFunc {
199198
return func(w http.ResponseWriter, r *http.Request) {
200-
pkgType := chi.URLParam(r, "type")
201-
name := chi.URLParam(r, "name")
199+
pkgType := r.PathValue("type")
200+
name := r.PathValue("name")
202201

203202
// Strip wp- prefix from type
204203
pkgType = strings.TrimPrefix(pkgType, "wp-")
@@ -630,7 +629,7 @@ func ogImageURL(cfg *config.Config, key string) string {
630629
// handleOGImage serves OG images from local disk (dev mode).
631630
func handleOGImage() http.HandlerFunc {
632631
return func(w http.ResponseWriter, r *http.Request) {
633-
key := chi.URLParam(r, "*")
632+
key := strings.TrimPrefix(r.URL.Path, "/og/")
634633
clean := filepath.Clean(key)
635634
if strings.Contains(clean, "..") {
636635
http.NotFound(w, r)

0 commit comments

Comments
 (0)