Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions deploy/ansible/group_vars/production/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,6 @@ r2_cdn_public_url: "https://cdn.wp-composer.com"
# Sessions
session_lifetime_minutes: 7200

# Admin access (empty = no IP restriction, set Tailscale CIDRs when ready)
admin_allow_cidr: ""

# Repository
repository_path: "{{ app_root }}/shared/storage/repository"

Expand Down
3 changes: 0 additions & 3 deletions deploy/ansible/roles/app/templates/env.j2
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ LOG_LEVEL={{ go_log_level }}

DB_PATH={{ db_path }}
LISTEN_ADDR={{ go_listen_addr }}
TRUST_PROXY=true

ADMIN_ALLOW_CIDR={{ admin_allow_cidr }}
SESSION_LIFETIME_MINUTES={{ session_lifetime_minutes }}

SEEDS_FILE={{ seeds_file }}
Expand Down
51 changes: 2 additions & 49 deletions docs/admin-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,9 @@

## Security Model

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

1. **Network layer** — Tailscale restricts access to `/admin/*` routes. Only devices on the tailnet can reach admin endpoints.
2. **Application layer** — in-app authentication (email/password) and admin authorization required for all protected `/admin/*` routes.

Both layers must pass. A valid Tailscale connection without app auth (or vice versa) is denied.

## Network Restriction

The Go app enforces IP-based access control on all `/admin/*` routes via the `ADMIN_ALLOW_CIDR` config.

Default allowed ranges (Tailscale):
- `100.64.0.0/10` (Tailscale IPv4)
- `fd7a:115c:a1e0::/48` (Tailscale IPv6)

Override via environment:

```env
ADMIN_ALLOW_CIDR=100.64.0.0/10,fd7a:115c:a1e0::/48,10.0.0.0/8
```

Set to empty to disable IP restriction (development only):

```env
ADMIN_ALLOW_CIDR=
```

If all configured CIDRs are invalid, the middleware fails closed — all admin access is blocked until the configuration is corrected.

### Proxy trust

By default, the app uses the raw `RemoteAddr` for IP checks. If behind a reverse proxy (Caddy, nginx), enable proxy header trust:

```env
TRUST_PROXY=true
```

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.
**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.

## Admin Bootstrap

Expand Down Expand Up @@ -78,18 +43,6 @@ wpcomposer cleanup-sessions

Run via systemd timer or cron (daily recommended).

## Exposure Verification

To verify admin is not publicly accessible:

```bash
# From outside the tailnet — should return 403
curl -s -o /dev/null -w "%{http_code}" https://app.example.com/admin/login

# From inside the tailnet — should return 200
curl -s -o /dev/null -w "%{http_code}" https://app.example.com/admin/login
```

## Emergency Password Reset

If locked out of the admin panel:
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ WP Composer has two primary runtime concerns:
### Web UI

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

## Module Layout

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

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

## Static Repository Deployment

Expand Down
6 changes: 2 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,21 @@ Env-first with optional YAML config file (env overrides YAML).
| `R2_SECRET_ACCESS_KEY` | — | R2 credentials |
| `R2_BUCKET` | — | R2 bucket name |
| `R2_ENDPOINT` | — | R2 S3-compatible endpoint |
| `ADMIN_ALLOW_CIDR` | Tailscale ranges | Comma-separated CIDRs for `/admin/*` access |
| `TRUST_PROXY` | `false` | Trust `X-Forwarded-For` for client IP (enable behind proxy) |
| `SESSION_LIFETIME_MINUTES` | `7200` | Admin session lifetime |

## Technical Decisions

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

## Make Targets

Expand Down
7 changes: 1 addition & 6 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,7 @@ echo 'new-password' | wpcomposer admin reset-password --email admin@example.com

## Admin Access Model

Admin access uses defense in depth:

1. **Network layer** — Tailscale restricts access to `/admin/*` routes to authorized devices on the tailnet.
2. **Application layer** — in-app authentication and admin authorization required for all `/admin/*` routes.

Both layers must pass. A valid Tailscale connection without app auth (or vice versa) is denied.
Admin access is protected by in-app authentication (email/password) and admin authorization for all `/admin/*` routes.

## Server Provisioning

Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1
github.com/fogleman/gg v1.3.0
github.com/getsentry/sentry-go v0.43.0
github.com/go-chi/chi/v5 v5.2.5
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/pressly/goose/v3 v3.27.0
github.com/spf13/cobra v1.10.2
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4=
github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
Expand Down
17 changes: 2 additions & 15 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,7 @@ type DBConfig struct {
}

type ServerConfig struct {
Addr string `yaml:"addr"`
AdminAllowCIDR []string `yaml:"admin_allow_cidr"`
TrustProxy bool `yaml:"trust_proxy"`
Addr string `yaml:"addr"`
}

type R2Config struct {
Expand Down Expand Up @@ -68,8 +66,7 @@ func defaults() *Config {
LogLevel: "info",
DB: DBConfig{Path: "./storage/wpcomposer.db"},
Server: ServerConfig{
Addr: ":8080",
AdminAllowCIDR: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"}, // Tailscale default ranges
Addr: ":8080",
},
Session: SessionConfig{LifetimeMinutes: 7200},
Telemetry: TelemetryConfig{
Expand Down Expand Up @@ -156,16 +153,6 @@ func applyEnv(cfg *Config) {
cfg.Telemetry.DedupeWindowSeconds = n
}
}
if v := os.Getenv("TRUST_PROXY"); v != "" {
cfg.Server.TrustProxy = strings.EqualFold(v, "true") || v == "1"
}
if v, ok := os.LookupEnv("ADMIN_ALLOW_CIDR"); ok {
if v == "" {
cfg.Server.AdminAllowCIDR = nil // explicitly disable restriction
} else {
cfg.Server.AdminAllowCIDR = strings.Split(v, ",")
}
}
if v := os.Getenv("SEEDS_FILE"); v != "" {
cfg.Discovery.SeedsFile = v
}
Expand Down
7 changes: 3 additions & 4 deletions internal/http/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"time"

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

func handleDetail(a *app.App, tmpl *templateSet) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
pkgType := chi.URLParam(r, "type")
name := chi.URLParam(r, "name")
pkgType := r.PathValue("type")
name := r.PathValue("name")

// Strip wp- prefix from type
pkgType = strings.TrimPrefix(pkgType, "wp-")
Expand Down Expand Up @@ -630,7 +629,7 @@ func ogImageURL(cfg *config.Config, key string) string {
// handleOGImage serves OG images from local disk (dev mode).
func handleOGImage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := chi.URLParam(r, "*")
key := strings.TrimPrefix(r.URL.Path, "/og/")
clean := filepath.Clean(key)
if strings.Contains(clean, "..") {
http.NotFound(w, r)
Expand Down
67 changes: 0 additions & 67 deletions internal/http/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ package http
import (
"context"
"database/sql"
"log/slog"
"net"
"net/http"

"github.com/roots/wp-composer/internal/auth"
Expand All @@ -14,15 +12,6 @@ type contextKey string

const userContextKey contextKey = "user"

// noTimeout wraps a handler to bypass the global timeout middleware.
// Client disconnects are detected via failed writes in the handler.
func noTimeout(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithoutCancel(r.Context())
h.ServeHTTP(w, r.WithContext(ctx))
}
}

func UserFromContext(ctx context.Context) *auth.User {
u, _ := ctx.Value(userContextKey).(*auth.User)
return u
Expand Down Expand Up @@ -63,59 +52,3 @@ func RequireAdmin(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}

// RequireAllowedIP restricts access to requests from allowed CIDR ranges.
// If allowCIDRs is nil or empty, all requests are allowed (no network restriction).
// If CIDRs are configured but none parse successfully, access is denied (fail closed).
func RequireAllowedIP(allowCIDRs []string, logger *slog.Logger) func(http.Handler) http.Handler {
configured := len(allowCIDRs) > 0
var nets []*net.IPNet
for _, cidr := range allowCIDRs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
logger.Error("invalid admin CIDR — admin access will be restricted", "cidr", cidr, "error", err)
continue
}
nets = append(nets, ipNet)
}

// Fail closed: CIDRs were configured but none parsed
failClosed := configured && len(nets) == 0
if failClosed {
logger.Error("all admin CIDRs are invalid — admin access is blocked")
}

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if failClosed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

if !configured {
next.ServeHTTP(w, r)
return
}

host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

for _, n := range nets {
if n.Contains(ip) {
next.ServeHTTP(w, r)
return
}
}

logger.Warn("admin access denied", "ip", host)
http.Error(w, "Forbidden", http.StatusForbidden)
})
}
}
Loading
Loading