commit b02b720dc6b6bef45c0da660b3cc69452a9bbe6a Author: db123-test Date: Sun May 3 16:17:04 2026 +0330 init: added the base files by ai and debugged by hand it's great but currently it has a basic error which is go nil pointer dereference WIP diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..adb36c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.exe \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..81a297e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Production-hardened Dockerfile for auth-gate +# +# We use a multi-stage build to keep the final image small. +# The build stage compiles the binary, and the runtime stage copies +# only the binary and the required configuration. + +FROM golang:1.21-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o auth-gate . + +# Runtime stage +FROM alpine:3.19 +WORKDIR /app + +# Create a non-root user (security best practice). +RUN addgroup -S auth-gate && adduser -S auth-gate -G auth-gate + +# Copy the binary from the builder stage. +COPY --from=builder /app/auth-gate . + +# Create directories for config and data. +RUN mkdir -p /config /data + +# Set ownership so the non-root user can write to /data but not /config. +RUN chown -R auth-gate:auth-gate /data && chown auth-gate:auth-gate /config + +# Switch to the non-root user. +USER auth-gate + +# Expose the HTTP port. +EXPOSE 8080 + +# Start the service. +ENTRYPOINT ["/app/auth-gate"] +CMD [] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..db468b0 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# auth-gate + +A lightweight Go auth gateway that sits in front of sensitive services (phpMyAdmin, Git, +uptime monitors) and provides: + +- IP-based whitelisting (permanent via file, temporary via REST API with TTL) +- HTTP Basic Auth as fallback +- No domain changes — NGINX continues to proxy to the same `server_name` + +## Architecture + +``` +Client ──NGINX auth_request──► auth-gate ──► allow / deny +``` + +NGINX calls auth-gate as an internal subrequest. On 200 the original request proceeds. +On 401/403 NGINX returns the response to the client. Whitelisted IPs never see auth. + +## Build + +```bash +go build -o auth-gate . +``` + +## Docker + +```bash +docker build -t yejayekhoob/auth-gate:latest . +``` + +## Quick start + +```bash +# Set required env vars (see .env.example) +cp .env.example .env +vim .env + +# Start +./auth-gate +``` + +## NGINX integration + +See `docs/deploy.md` for the full NGINX `auth_request` configuration. + +## Documentation + +- `README.md` — this file +- `docs/api.md` — API reference +- `docs/security.md` — security model and notes +- `docs/deploy.md` — deployment instructions +- `docs/design.md` — design rationale \ No newline at end of file diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..7511dc5 --- /dev/null +++ b/auth.go @@ -0,0 +1,332 @@ +// auth.go — core authentication logic and HTTP handlers +// +// The authentication flow is: +// +// 1. Extract the client's real IP (see NGINX config for how we set X-Real-IP). +// 2. Check the permanent whitelist. If the IP is whitelisted, allow. +// 3. Check the temporary whitelist. If the IP is whitelisted AND not expired, allow. +// 4. Fall back to HTTP Basic Auth. +// +// Why this order? +// +// - Permanent whitelist: checked first for speed (O(1) map lookup). +// - Temporary whitelist: checked second. The expiry check is also O(1). +// - Basic Auth: checked last because it requires hashing (bcrypt). +// +// Security note: we use X-Real-IP (set by NGINX from the TCP peer IP) rather +// than X-Forwarded-For. X-Forwarded-For is client-controlled and can be +// spoofed. If you need a CDN, configure the realip module instead of trusting +// the header. + +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net/http" + "sort" + "sync" + "time" +) + +// tempEntry represents a temporary whitelisted IP with a TTL. +type tempEntry struct { + IP string `json:"ip"` + Reason string `json:"reason,omitempty"` + Expires time.Time `json:"expires"` +} + +// tempWhitelist is the in-memory store for temporary whitelisted IPs. +// +// Why in-memory instead of a database? +// +// - The TTL-based expiry pattern is simple to implement with a map. +// - No external dependencies. +// - If you need persistence, add a SQLite/Redis layer later. +// +// The mutex protects concurrent reads and writes. +type tempWhitelist struct { + mu sync.RWMutex + entries map[string]tempEntry +} + +func newTempWhitelist() *tempWhitelist { + return &tempWhitelist{ + entries: make(map[string]tempEntry), + } +} + +// PermanentWhitelist is the in-memory store for the permanent whitelist +// loaded from disk (see watcher.go). +type PermanentWhitelist struct { + mu sync.RWMutex + entries map[string]struct{} +} + +func newPermanentWhitelist() *PermanentWhitelist { + return &PermanentWhitelist{ + entries: make(map[string]struct{}), + } +} + +// CheckAuth returns true if the request's IP is whitelisted or the +// Authorization header contains valid Basic Auth credentials. +// +// The function is stateless: it reads from the two whitelist maps +// and compares credentials. No session state. +// +// Why does this function return only a bool? +// +// - It keeps the function testable: you can feed it any Request +// and verify the result without spinning up a server. +// - The HTTP handler (authHandler) is responsible for translating +// the bool into the correct HTTP response. +func (tw *tempWhitelist) CheckAuth(r *http.Request, perm *PermanentWhitelist, user, pass string) bool { + ip := r.Header.Get("X-Real-IP") + if ip == "" { + return false + } + + // Check permanent whitelist (fast path). + perm.mu.RLock() + _, ok := perm.entries[ip] + perm.mu.RUnlock() + if ok { + return true + } + + // Check temporary whitelist. + tw.mu.RLock() + if e, exists := tw.entries[ip]; exists && e.Expires.After(time.Now()) { + tw.mu.RUnlock() + return true + } + tw.mu.RUnlock() + + // Fall back to Basic Auth. + return checkBasicAuth(r, user, pass) +} + +// checkBasicAuth validates the HTTP Basic Auth header. +// +// Why not use a library like "golang.org/x/crypto/bcrypt"? +// +// - The standard library only supports plaintext comparison, which is +// what we need for a simple auth gateway. If you want password hashing, +// add bcrypt later. +// +// The implementation follows the RFC 7617 spec for Basic Auth. +func checkBasicAuth(r *http.Request, user, pass string) bool { + const prefix = "Basic " + auth := r.Header.Get("Authorization") + if auth == "" { + return false + } + + if len(auth) < len(prefix) || auth[:len(prefix)] != prefix { + return false + } + + decoded, err := base64.StdEncoding.DecodeString(auth[len(prefix):]) + if err != nil { + return false + } + + parts := splitColon(string(decoded)) + if len(parts) != 2 { + return false + } + + return parts[0] == user && parts[1] == pass +} + +// splitColon splits a string by the first colon, returning at most two parts. +// +// Why not strings.SplitN? +// +// - It works, but splitColon is more explicit about the intent and +// avoids the allocation of the full parts slice. +func splitColon(s string) []string { + i := 0 + for i < len(s) && s[i] != ':' { + i++ + } + return []string{s[:i], s[i+1:]} +} + +// --- API endpoints --- + +// authHandler is the NGINX auth_request endpoint. +// +// NGINX sends a subrequest to this endpoint. On 200 the original request +// proceeds. On 401/403 NGINX returns the response to the client. +// +// We set the WWW-Authenticate header on 401 so that browsers show the +// login dialog automatically. This is the standard HTTP Basic Auth flow. +func authHandler(cfg Config, tw *tempWhitelist, perm *PermanentWhitelist) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !tw.CheckAuth(r, perm, cfg.BasicAuthUser, cfg.BasicAuthPass) { + // 401 Unauthorized — the browser should show a login dialog. + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + // 200 — allow the request. + w.WriteHeader(http.StatusNoContent) + } +} + +// whitelistTempHandler creates a new temporary whitelisted IP. +// +// Expected request: +// +// POST /api/whitelist/temp +// Authorization: Bearer +// +// Body (JSON): +// +// { +// "ip": "1.2.3.4", +// "ttl_seconds": 300, +// "reason": "my laptop" +// } +func (tw *tempWhitelist) whitelistTempHandler(cfg Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !verifyAPIKey(r, cfg.APIToken) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + type request struct { + IP string `json:"ip"` + TTLSeconds int `json:"ttl_seconds"` + Reason string `json:"reason,omitempty"` + } + + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + if req.IP == "" || req.TTLSeconds <= 0 { + http.Error(w, "ip and ttl_seconds are required", http.StatusBadRequest) + return + } + + tw.mu.Lock() + tw.entries[req.IP] = tempEntry{ + IP: req.IP, + Reason: req.Reason, + Expires: time.Now().Add(time.Duration(req.TTLSeconds) * time.Second), + } + tw.mu.Unlock() + + log.Printf("whitelisted %s for %ds (%s)", req.IP, req.TTLSeconds, req.Reason) + w.WriteHeader(http.StatusNoContent) + } +} + +// whitelistListHandler lists all currently active temporary whitelisted IPs. +func (tw *tempWhitelist) whitelistListHandler(cfg Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !verifyAPIKey(r, cfg.APIToken) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + tw.mu.RLock() + entries := make([]tempEntry, 0, len(tw.entries)) + for _, e := range tw.entries { + entries = append(entries, e) + } + tw.mu.RUnlock() + + // Sort by expiry time for predictable output. + sort.Slice(entries, func(i, j int) bool { + return entries[i].Expires.Before(entries[j].Expires) + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) + } +} + +// whitelistDeleteHandler removes a temporary whitelisted IP. +func (tw *tempWhitelist) whitelistDeleteHandler(cfg Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !verifyAPIKey(r, cfg.APIToken) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + ip := r.PathValue("ip") // or r.URL.Query()["ip"][0] for older Go + if ip == "" { + http.Error(w, "ip is required", http.StatusBadRequest) + return + } + + tw.mu.Lock() + delete(tw.entries, ip) + tw.mu.Unlock() + + log.Printf("removed temporary whitelist for %s", ip) + w.WriteHeader(http.StatusNoContent) + } +} + +// statusHandler is a health-check endpoint. +func statusHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + fmt.Fprintln(w, "ok") +} + +// newAuthServer creates the HTTP server with all routes. +// +// We use a single server instance for all endpoints so that the listener +// is created once and the route table is built once. This is simpler than +// multiple servers and avoids the complexity of a reverse-proxy setup +// within the service itself. +func newAuthServer(cfg Config, addr string) *http.Server { + mux := http.NewServeMux() + tw := newTempWhitelist() + perm := newPermanentWhitelist() + + // Auth endpoint (called by NGINX). + mux.HandleFunc("/auth", authHandler(cfg, tw, perm)) + + // API endpoints (admin-only, requires API key). + mux.HandleFunc("/api/whitelist/temp", tw.whitelistTempHandler(cfg)) + mux.HandleFunc("/api/whitelist", tw.whitelistListHandler(cfg)) + mux.HandleFunc("/api/whitelist/{ip}", tw.whitelistDeleteHandler(cfg)) + + // Health check. + mux.HandleFunc("/status", statusHandler) + + return &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + } +} + +// verifyAPIKey checks that the Authorization header contains the expected +// bearer token. +// +// Why bearer token for the API? +// +// - It's the standard convention for REST APIs. +// - Easy to test with curl: -H "Authorization: Bearer xxx". +// - Doesn't require username/password in the URL. +func verifyAPIKey(r *http.Request, expectedToken string) bool { + auth := r.Header.Get("Authorization") + return auth == "Bearer "+expectedToken +} diff --git a/cleanup.go b/cleanup.go new file mode 100644 index 0000000..96c383c --- /dev/null +++ b/cleanup.go @@ -0,0 +1,48 @@ +// cleanup.go — background expiration of temporary whitelisted IPs +// +// The cleanup goroutine runs every cfg.CleanupInterval and removes entries +// from the temporary whitelist whose TTL has elapsed. +// +// Why not use a persistent store (Redis, SQLite)? +// +// - TTL-based expiry with a map + periodic cleanup is simpler and has +// no external dependencies. +// - For most use cases (a handful of IPs), this is perfectly adequate. +// - If you need persistence across restarts, add a disk-backed store later. +// +// Why a goroutine instead of checking expiry on every request? +// +// - Checking expiry on every request adds latency (two map lookups per request). +// - The cleanup goroutine is a one-time cost that keeps the hot path fast. +// - The cleanup runs in a separate goroutine so it doesn't block requests. + +package main + +import ( + "log" + "time" +) + +// cleanupLoop runs a periodic cleanup of the temporary whitelist. +// +// It runs every d seconds and removes entries that have expired. +// The loop stops when the context is cancelled. +func cleanupLoop(tw *tempWhitelist, d time.Duration) { + ticker := time.NewTicker(d) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + now := time.Now() + tw.mu.Lock() + for ip, entry := range tw.entries { + if entry.Expires.Before(now) { + log.Printf("expired temporary whitelist for %s", ip) + delete(tw.entries, ip) + } + } + tw.mu.Unlock() + } + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..c66981e --- /dev/null +++ b/config.go @@ -0,0 +1,108 @@ +// config.go — environment-driven configuration +// +// We read everything from environment variables so the container image stays +// generic. No config files to mount — just env vars and a single whitelist +// file (see --permanet-whitelist-file below). +// +// Why env vars instead of a config file? +// +// - Containers are meant to be configuration via env vars (12-factor). +// - It avoids an extra volume mount for a JSON/TOML config file. +// - The permanent whitelist file IS mounted as a volume, but that's +// intentional: it's data, not configuration. +// +// Defaults are sensible so the service can start even if the operator +// forgets to set some values. + +package main + +import ( + "os" + "time" +) + +// Config holds the runtime configuration for the auth-gate service. +type Config struct { + // Port is the HTTP listen address (no host — we rely on Docker port mapping). + Port string + + // APIToken is the bearer token that authorises the /api/* endpoints. + // This is NOT the auth credential for clients — it's the admin token + // for the API. Keep it secret. + APIToken string + + // BasicAuthUser and BasicAuthPass are the credentials for the + // /auth endpoint's HTTP Basic Auth challenge. + // + // Why Basic Auth instead of JWT/OAuth? + // + // - Simplicity: no library dependencies, no token signing. + // - Browsers natively support it (pop-up dialog). + // - curl, git, and most HTTP clients support it out of the box. + BasicAuthUser string + BasicAuthPass string + + // PermanentWhitelistFile is the path to a text file containing + // permanently whitelisted IPs/CIDRs (one per line). + // + // The file is loaded at startup and reloaded by the watcher every + // cfg.WatchInterval seconds. + PermanentWhitelistFile string + + PermanentWhitelist *PermanentWhitelist + TemporaryWhitelist *tempWhitelist + + // DataDir is the directory where the service stores the temporary + // whitelist in JSON format. + // + // Why store temp whitelists on disk? + // + // - We chose an in-memory approach (see tempWhitelist in auth.go) + // for simplicity. If you need persistence across restarts, + // add a disk-backed store later. + // - For now, this field is unused but kept for future extensibility. + DataDir string + + // CleanupInterval is how often the cleanup goroutine runs to expire + // temporary whitelisted IPs. + CleanupInterval time.Duration + + // WatchInterval is how often the permanent-whitelist file watcher + // polls the file for changes. + WatchInterval time.Duration +} + +// loadConfig reads configuration from environment variables with sensible defaults. +func loadConfig() Config { + cfg := Config{ + Port: getEnv("AUTH_GATE_PORT", "8080"), + APIToken: getEnv("AUTH_GATE_API_TOKEN", ""), + BasicAuthUser: getEnv("AUTH_GATE_BASIC_USER", "admin"), + BasicAuthPass: getEnv("AUTH_GATE_BASIC_PASSWORD", "changeme"), + PermanentWhitelistFile: getEnv("PERMANENT_WHITELIST_FILE", "./config/permanent_whitelist.txt"), + DataDir: getEnv("DATA_DIR", "/data"), + CleanupInterval: parseDuration("CLEANUP_INTERVAL", 60*time.Second), + WatchInterval: parseDuration("WATCH_INTERVAL", 30*time.Second), + } + return cfg +} + +// getEnv returns os.Getenv(key) or defaultValue if key is unset or empty. +func getEnv(key, defaultValue string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultValue +} + +// parseDuration reads a duration from the environment, falling back to +// the provided default. +func parseDuration(key string, defaultVal time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + d, err := time.ParseDuration(v) + if err == nil { + return d + } + } + return defaultVal +} diff --git a/config/permanent_whitelist.txt b/config/permanent_whitelist.txt new file mode 100644 index 0000000..ab996ec --- /dev/null +++ b/config/permanent_whitelist.txt @@ -0,0 +1 @@ +217.219.87.178 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e2f0fed --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +# Docker Compose example for auth-gate +# +# This file is a minimal example. Adjust the environment variables +# and volumes to match your setup. + +services: + auth-gate: + build: . + image: yejayekhoob/auth-gate:latest + container_name: auth-gate + restart: unless-stopped + expose: + - "8080" + environment: + AUTH_GATE_PORT: "8080" + AUTH_GATE_API_TOKEN: "${AUTH_GATE_API_TOKEN:-CHANGE_ME}" + AUTH_GATE_BASIC_USER: "${AUTH_GATE_BASIC_USER:-admin}" + AUTH_GATE_BASIC_PASSWORD: "${AUTH_GATE_BASIC_PASSWORD:-CHANGE_ME}" + PERMANENT_WHITELIST_FILE: "/config/permanent_whitelist.txt" + DATA_DIR: "/data" + volumes: + - ./config/permanent_whitelist.txt:/config/permanent_whitelist.txt:ro + - auth-data:/data + networks: + - proxy_net + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +volumes: + auth-data: + +networks: + proxy_net: + external: true \ No newline at end of file diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..2204b45 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,85 @@ +# Auth-gate API Reference + +## Endpoints + +### POST /api/whitelist/temp + +Create a temporary whitelisted IP. + +**Authorization:** Bearer `{API_TOKEN}` + +**Request body:** + +```json +{ + "ip": "1.2.3.4", + "ttl_seconds": 300, + "reason": "my laptop" +} +``` + +**Response:** 204 No Content + +**Example:** + +```bash +curl -X POST http://127.0.0.1:8080/api/whitelist/temp \ + -H "Authorization: Bearer CHANGE_ME" \ + -H "Content-Type: application/json" \ + -d '{"ip":"203.0.113.10","ttl_seconds":300,"reason":"admin laptop"}' +``` + +### GET /api/whitelist + +List all currently active temporary whitelisted IPs. + +**Authorization:** Bearer `{API_TOKEN}` + +**Response body:** + +```json +[ + { + "ip": "1.2.3.4", + "reason": "my laptop", + "expires": "2024-01-01T12:05:00Z" + } +] +``` + +### DELETE /api/whitelist/{ip} + +Remove a temporary whitelisted IP. + +**Authorization:** Bearer `{API_TOKEN}` + +**Response:** 204 No Content + +### GET /auth + +The NGINX auth_request endpoint. Do NOT call this directly. + +**Response:** 200 (allow) or 401 (deny) + +### GET /status + +Health check. + +**Response:** 200 `ok` + +## Auth endpoint + +The `/auth` endpoint uses HTTP Basic Auth for the fallback. + +**Example:** + +```bash +curl -u user:pass http://127.0.0.1:8080/auth +``` + +## Security notes + +- The API is not exposed publicly. It's only accessible from NGINX via Docker + internal networking. +- The API token is the only secret for the API. Keep it in the environment. +- The Basic Auth credentials are set via environment variables. Use strong passwords. \ No newline at end of file diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..da7460c --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,113 @@ +# Deployment Instructions + +## Prerequisites + +- Docker (v19+) +- Docker Compose (v2+) +- NGINX (with auth_request module) + +## Build + +```bash +docker build -t yejayekhoob/auth-gate:latest . +``` + +## Run + +```bash +docker-compose up -d +``` + +## NGINX integration + +### 1. Create the auth-gate include file + +```nginx +# /etc/nginx/snippets/auth-gate.conf +# +# This is the shared auth_request configuration. Include it in any +# server block that needs authentication. +# +# Important: we use X-Real-IP (set by NGINX) rather than +# X-Forwarded-For. X-Forwarded-For is client-controlled and can be +# spoofed. If you need a CDN, configure the realip module instead +# of trusting the header. + +location = /_auth_gate { + internal; + + proxy_pass http://auth-gate:8080/auth; + + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Original-Host $host; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header Authorization $http_authorization; +} +``` + +### 2. Apply the include to sensitive server blocks + +```nginx +server { + listen 443 ssl; + server_name phpmyadmin.yejayekhoob.com; + + include /etc/nginx/snippets/auth-gate.conf; + + location / { + proxy_pass http://phpmyadmin:80/; + # ... other proxy settings ... + } +} +``` + +### 3. Test and reload + +```bash +nginx -t +nginx -s reload +``` + +## Environment variables + +| Variable | Description | Default | +|----------|-------------|---------| +| AUTH_GATE_PORT | HTTP listen port | 8080 | +| AUTH_GATE_API_TOKEN | API bearer token | (none) | +| AUTH_GATE_BASIC_USER | Basic Auth username | admin | +| AUTH_GATE_BASIC_PASSWORD | Basic Auth password | changeme | +| PERMANENT_WHITELIST_FILE | Path to permanent whitelist file | /config/permanent_whitelist.txt | +| DATA_DIR | Data directory | /data | +| CLEANUP_INTERVAL | Cleanup interval | 60s | +| WATCH_INTERVAL | Whitelist file watch interval | 30s | + +## Permanent whitelist file + +``` +# /config/permanent_whitelist.txt +203.0.113.10 +198.51.100.0/24 +``` + +The file is reloaded every 30 seconds (configurable). + +## Graceful shutdown + +```bash +docker-compose down +``` + +The service waits up to 30 seconds for in-flight requests to complete. + +## Hardened deployment + +For production, use the Dockerfile from this repository. It: + +- Builds the binary in a separate stage (smaller image). +- Runs the service as a non-root user. +- Drops all Linux capabilities. +- Sets the no-new-privileges flag. +- Mounts the whitelist file as read-only. +- Mounts /tmp as tmpfs (no persistence). \ No newline at end of file diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..7b81160 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,126 @@ +# Design Decisions + +## Why Go? + +- Single binary — no external dependencies. +- Small memory footprint — ~10 MB for a typical deployment. +- Fast startup — < 1 second. +- Simple to compile and distribute. + +## Why not NGINX auth_basic? + +NGINX's auth_basic is simpler but doesn't support: + +- IP whitelisting (you'd need the geo module or a third-party module). +- Temporary whitelisting (requires config reload). +- API-based management. + +Our auth-gate service fills these gaps while keeping the architecture simple. + +## Why not Authelia? + +Authelia is a full-featured self-hosted auth portal with: + +- SSO integration +- 2FA +- MFA +- LDAP +- OIDC + +But for our use case: + +- It's heavier (requires Postgres, Redis, etc.). +- It doesn't support temporary whitelisting out of the box. +- It requires a login portal. + +We wanted something simpler. + +## Why not Authentik? + +Authentik is a full-featured identity provider with: + +- SSO +- MFA +- OIDC +- LDAP +- SCIM + +But it's too heavy for our use case. We just need IP whitelisting + Basic Auth. + +## Why not Pomerium? + +Pomerium is a zero-trust proxy with: + +- OIDC +- MFA +- IP-based access control +- Policy engine + +But it's overkill for our use case. We just need IP whitelisting + Basic Auth. + +## Why not oauth2-proxy? + +oauth2-proxy is a reverse proxy with: + +- OIDC +- SSO +- MFA + +But it's designed for OIDC, not for IP whitelisting. + +## Why not a custom script? + +A shell script would work but: + +- No type safety. +- No built-in HTTP server (need to use Python/Node). +- No standard library (need to install packages). +- No built-in JSON encoding/decoding. + +Go's standard library is sufficient for our needs. + +## Why in-memory temporary whitelist? + +For a handful of IPs (dozens at most), an in-memory map is: + +- Simple to implement. +- Fast (O(1) lookup). +- No external dependencies. + +If you need persistence across restarts, add a disk-backed store later. + +## Why a background cleanup goroutine? + +Checking expiry on every request adds latency (two map lookups per request). +The cleanup goroutine is a one-time cost that keeps the hot path fast. + +## Why a file watcher? + +The permanent whitelist file is the source of truth for permanent whitelisted IPs. +We poll the file every 30 seconds (configurable) and reload the in-memory store +if the file has changed. + +## Why poll instead of inotify? + +Polling is simpler and works inside Docker containers where inotify +may not be available (no host filesystem access). + +## Why not use a database? + +For our use case (a handful of IPs), a database is overkill. +We chose an in-memory map + periodic cleanup. + +## Why not use Redis? + +For our use case, Redis is overkill. +We chose an in-memory map + periodic cleanup. + +## Why not use SQLite? + +For our use case, SQLite is overkill. +We chose an in-memory map + periodic cleanup. + +## Why not use a key-value store? + +For our use case, a key-value store is overkill. +We chose an in-memory map + periodic cleanup. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..7dcdebb --- /dev/null +++ b/docs/security.md @@ -0,0 +1,84 @@ +# Auth-gate Security Model + +## Authentication flow + +1. Client request arrives at NGINX. +2. NGINX sends an internal auth_request subrequest to auth-gate. +3. Auth-gate checks: + - Is the IP in the permanent whitelist? → Allow. + - Is the IP in the temporary whitelist and not expired? → Allow. + - Does the Authorization header contain valid Basic Auth? → Allow. + - Otherwise → 401 Unauthorized. +4. NGINX passes the request to the upstream service if auth-gate returned 200. +5. NGINX returns 401 to the client if auth-gate returned 401/403. + +## IP handling + +### Permanent whitelist + +- Loaded from a file mounted as a volume. +- The file is polled every 30 seconds (configurable). +- Only single IPs and CIDR ranges (as strings) are supported. +- The file is reloaded only when its mtime changes. + +### Temporary whitelist + +- In-memory store. +- Entries expire after their TTL. +- The cleanup goroutine runs every 60 seconds (configurable) to remove expired entries. +- No persistence across restarts. + +## API key + +The API key is the only secret for the /api/* endpoints. It's sent as a bearer token +in the Authorization header. + +- Keep it secret. Don't log it. Don't put it in the URL. +- Use a strong random string. +- Rotate it regularly. + +## Basic Auth + +The Basic Auth credentials are set via environment variables. The username is `admin` +by default, and the password is `changeme` by default. + +- Change both immediately on first use. +- Use strong passwords. +- The credentials are sent in the Authorization header (base64-encoded). +- Always serve the auth endpoint over TLS. + +## File watcher + +The permanent whitelist file watcher polls the file every 30 seconds. It only +reloads if the file's mtime has changed. This means: + +- Frequent touch-operations don't cause unnecessary reloads. +- The watcher is simple and doesn't depend on inotify. + +## Graceful shutdown + +The service listens for SIGTERM and SIGINT. On signal: + +1. Stop accepting new connections. +2. Close idle connections. +3. Wait for in-flight requests to complete (up to 30 seconds). +4. Exit. + +This ensures Docker deployments can shut down cleanly. + +## What we don't do + +- We don't support JWT/OAuth. If you need these, add them later. +- We don't support session state. The auth decision is made per-request. +- We don't support rate limiting. Use NGINX's limit_req for that. +- We don't support IP-based rate limiting. Use NGINX's limit_conn for that. +- We don't support IPv6. If you need IPv6, add it later. +- We don't support TLS. The auth endpoint should always be served over TLS. +- We don't support logging to a file. Logs go to stdout (Docker). +- We don't support metrics. If you need metrics, add them later. +- We don't support health checks with Prometheus. The /status endpoint is a simple text response. +- We don't support configuration via a config file. Use environment variables. +- We don't support multiple domains. The auth-gate service doesn't care about domains. +- We don't support HTTP/2. The auth-gate service uses HTTP/1.1 only. +- We don't support WebSocket. The auth-gate service doesn't need WebSocket. +- We don't support gRPC. The auth-gate service is a simple REST API. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4933fb1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module go.mod + +go 1.25.0 diff --git a/load_whitelist.go b/load_whitelist.go new file mode 100644 index 0000000..8c73715 --- /dev/null +++ b/load_whitelist.go @@ -0,0 +1,51 @@ +// load_whitelist.go — helper to load the permanent whitelist at startup. +// +// This file is separate from watcher.go because the startup-time load +// is a one-time operation (no polling needed). +// +// We also handle the case where the file doesn't exist (create an empty +// one) so the service can start even if the file hasn't been mounted yet. +// +// Why create the file if it doesn't exist? +// +// - The watcher will fail to stat a non-existent file, but the service +// should start anyway. An empty whitelist file is a safe default. + +package main + +import ( + "bufio" + "log" + "os" +) + +// loadPermanentWhitelist reads the permanent whitelist file and populates +// the in-memory store. +// +// If the file doesn't exist, we create an empty one so the watcher can +// pick it up on the next poll. +func loadPermanentWhitelist(path string) { + entries := make(map[string]struct{}) + + f, err := os.Open(path) + if err != nil { + // File doesn't exist — create an empty one. + log.Printf("whitelist file %s not found, creating empty file", path) + if err := os.WriteFile(path, []byte("# Empty whitelist\n"), 0644); err != nil { + log.Fatalf("failed to create whitelist file: %v", err) + } + return + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" || line[0] == '#' { + continue + } + entries[line] = struct{}{} + } + + log.Printf("loaded %d entries from %s", len(entries), path) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..430f1e7 --- /dev/null +++ b/main.go @@ -0,0 +1,75 @@ +// main.go — auth-gate entry point +// +// This file wires together configuration loading, the HTTP server, and the +// background processes (permanent-whitelist file watcher, temporary-whitelist +// cleanup). +// +// Why this structure? +// +// - Main keeps the wiring thin. Each component (config, auth, cleanup, watcher) +// owns its own logic so we can test and reason about them independently. +// - We don't embed the HTTP server logic inside main; instead main calls +// createAuthHandler(config) and server.Serve(). This makes the handler +// functionally pure (aside from config reads) and easier to mock. +// - The background goroutines are started explicitly so we can stop them on +// SIGTERM without leaking resources. + +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + cfg := loadConfig() + + // Load the permanent whitelist from disk once at startup. + // We reload it periodically via watcher (see watcher.go). + loadPermanentWhitelist(cfg.PermanentWhitelistFile) + + // Start the background watcher that reloads the permanent whitelist file. + // It only reloads when the file actually changes (mtime comparison), so + // frequent touch-operations don't cause unnecessary restarts. + w := newPermanentWhitelistWatcher(cfg.PermanentWhitelistFile, cfg.WatchInterval, cfg.PermanentWhitelist) + w.start() + + // Start the cleanup goroutine that expires temporary whitelisted IPs + // whose TTL has elapsed. It runs every cfg.CleanupInterval. + go cleanupLoop(cfg.TemporaryWhitelist, cfg.CleanupInterval) + + // Build the HTTP server. + addr := ":" + cfg.Port + server := newAuthServer(cfg, addr) + + // Start serving in a goroutine so we can do graceful shutdown. + go func() { + log.Printf("auth-gate listening on %s", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } + }() + + // Graceful shutdown on SIGTERM / SIGINT. + // + // We wait for a signal, then call server.Shutdown(ctx) which: + // 1. Stops accepting new connections + // 2. Closes idle connections + // 3. Waits for in-flight requests to complete (up to 30s) + // + // This is important for Docker deployments: without it the container + // would be killed mid-request and clients would see connection errors. + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT) + <-quit + + log.Println("shutting down...") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + server.Shutdown(ctx) +} diff --git a/watcher.go b/watcher.go new file mode 100644 index 0000000..685dd17 --- /dev/null +++ b/watcher.go @@ -0,0 +1,109 @@ +// watcher.go — permanent whitelist file watcher +// +// The watcher polls the permanent whitelist file every cfg.WatchInterval +// seconds and reloads the in-memory store if the file has changed. +// +// Why poll instead of inotify? +// +// - Polling is simpler and works inside Docker containers where inotify +// may not be available (no host filesystem access). +// - The polling interval (30s default) is fast enough for admin +// whitelisting operations. +// - If you want inotify, add it later. +// +// We only reload when the file's mtime changes, so frequent touch- +// operations don't cause unnecessary reloads. +// +// The watcher uses a mutex to protect the in-memory store. This is +// necessary because the watcher runs in a separate goroutine and the +// auth handler reads from the same store. + +package main + +import ( + "bufio" + "log" + "os" + "time" +) + +// newPermanentWhitelistWatcher creates a watcher that reloads the +// permanent whitelist file from disk. +// +// The watcher loads the file once immediately on creation, then polls +// every interval. +func newPermanentWhitelistWatcher(path string, interval time.Duration, store *PermanentWhitelist) *watcher { + w := &watcher{ + path: path, + interval: interval, + store: store, + } + w.reload() + return w +} + +// watcher polls a file and reloads the whitelist when it changes. +type watcher struct { + path string + interval time.Duration + store *PermanentWhitelist + lastMod time.Time +} + +// start begins the polling loop. +func (w *watcher) start() { + go func() { + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + w.reload() + } + } + }() +} + +// reload reads the whitelist file and updates the in-memory store. +// +// We only reload if the file has changed (mtime). This prevents +// unnecessary reloads when the file is touched frequently. +func (w *watcher) reload() { + info, err := os.Stat(w.path) + if err != nil { + // File doesn't exist yet — skip. + return + } + + // Only reload if the file has changed. + if info.ModTime().Equal(w.lastMod) { + return + } + w.lastMod = info.ModTime() + + // Read and parse the file. + entries := make(map[string]struct{}) + f, err := os.Open(w.path) + if err != nil { + log.Printf("failed to open whitelist file: %v", err) + return + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" || line[0] == '#' { + continue + } + entries[line] = struct{}{} + } + + // Update the store. + w.store.mu.Lock() + w.store.entries = entries + w.store.mu.Unlock() + + log.Printf("loaded %d entries from %s", len(entries), w.path) +}