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
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.exe
|
||||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -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 []
|
||||||
52
README.md
Normal file
52
README.md
Normal file
@@ -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
|
||||||
332
auth.go
Normal file
332
auth.go
Normal file
@@ -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 <api-token>
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
48
cleanup.go
Normal file
48
cleanup.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
config.go
Normal file
108
config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
1
config/permanent_whitelist.txt
Normal file
1
config/permanent_whitelist.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
217.219.87.178
|
||||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -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
|
||||||
85
docs/api.md
Normal file
85
docs/api.md
Normal file
@@ -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.
|
||||||
113
docs/deploy.md
Normal file
113
docs/deploy.md
Normal file
@@ -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).
|
||||||
126
docs/design.md
Normal file
126
docs/design.md
Normal file
@@ -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.
|
||||||
84
docs/security.md
Normal file
84
docs/security.md
Normal file
@@ -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.
|
||||||
51
load_whitelist.go
Normal file
51
load_whitelist.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
75
main.go
Normal file
75
main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
109
watcher.go
Normal file
109
watcher.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user