diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..688782a --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +AUTH_GATE_PORT +AUTH_GATE_API_TOKEN +DATA_DIR +CLEANUP_INTERVAL +AUTH_GATE_DB_PATH \ No newline at end of file diff --git a/README.md b/README.md index db468b0..3b0510d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # auth-gate -A lightweight Go auth gateway that sits in front of sensitive services (phpMyAdmin, Git, -uptime monitors) and provides: +A lightweight Go auth gateway that sits in front of sensitive services (phpMyAdmin, Gitea, +uptime kuma) and provides: -- IP-based whitelisting (permanent via file, temporary via REST API with TTL) -- HTTP Basic Auth as fallback +- IP-based whitelisting (permanent and temporary via REST API with TTL, stored in SQLite) - No domain changes — NGINX continues to proxy to the same `server_name` ## Architecture @@ -39,6 +38,22 @@ vim .env ./auth-gate ``` +## API + +The API requires a bearer token set via `AUTH_GATE_API_TOKEN`. All endpoints return `401 Unauthorized` if the token is missing or invalid. + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/whitelist/temp` | Add a temporary whitelisted IP | +| GET | `/api/whitelist/temp/list` | List active temporary entries | +| DELETE | `/api/whitelist/temp/{ip}` | Remove a temporary entry | +| POST | `/api/whitelist/perm` | Add a permanent whitelisted IP/CIDR | +| DELETE | `/api/whitelist/perm/{entry}` | Remove a permanent entry | +| GET | `/api/logs` | Retrieve audit log entries | +| GET | `/status` | Health check | + +See `docs/api.md` for full API reference. + ## NGINX integration See `docs/deploy.md` for the full NGINX `auth_request` configuration. @@ -49,4 +64,4 @@ See `docs/deploy.md` for the full NGINX `auth_request` configuration. - `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 +- `docs/design.md` — design rationale diff --git a/auth.go b/auth.go index 9d7269b..0632655 100644 --- a/auth.go +++ b/auth.go @@ -6,6 +6,7 @@ import ( "log/slog" "net" "net/http" + "time" ) func CheckAuth(db *sql.DB, r *http.Request) bool { @@ -130,10 +131,10 @@ func logsHandler(cfg Config, db *sql.DB) http.HandlerFunc { continue } entry := map[string]interface{}{ - "timestamp": ts, - "action": action, + "timestamp": ts, + "action": action, "api_client_ip": apiClientIP, - "reason": reason, + "reason": reason, } if ip.Valid { entry["ip"] = ip.String @@ -218,4 +219,4 @@ func verifyAPIKey(r *http.Request, expectedToken string) bool { func statusHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.Write([]byte("ok\n")) -} \ No newline at end of file +} diff --git a/cleanup.go b/cleanup.go index 535cbca..662be7b 100644 --- a/cleanup.go +++ b/cleanup.go @@ -10,4 +10,4 @@ func cleanupLoop(db *sql.DB, interval time.Duration) { for range ticker.C { _ = CleanupExpiredTemp(db) } -} \ No newline at end of file +} diff --git a/config.go b/config.go index 463fcf0..6925214 100644 --- a/config.go +++ b/config.go @@ -19,7 +19,7 @@ func loadConfig() Config { APIToken: getEnv("AUTH_GATE_API_TOKEN", ""), DataDir: getEnv("DATA_DIR", "/data"), CleanupInterval: parseDuration("CLEANUP_INTERVAL", 60*time.Second), - DBPath: getEnv("AUTH_GATE_DB_PATH", "/data/auth-gate.db"), + DBPath: getEnv("AUTH_GATE_DB_PATH", "./data/auth-gate.db"), } } @@ -38,4 +38,4 @@ func parseDuration(key string, defaultVal time.Duration) time.Duration { } } return defaultVal -} \ No newline at end of file +} diff --git a/config/permanent_whitelist.txt b/config/permanent_whitelist.txt deleted file mode 100644 index ab996ec..0000000 --- a/config/permanent_whitelist.txt +++ /dev/null @@ -1 +0,0 @@ -217.219.87.178 \ No newline at end of file diff --git a/db.go b/db.go index 7e8ced1..2faa013 100644 --- a/db.go +++ b/db.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "log/slog" + "net" "time" _ "modernc.org/sqlite" @@ -180,16 +181,15 @@ func ListTempEntries(db *sql.DB) ([]map[string]interface{}, error) { continue } list = append(list, map[string]interface{}{ - "ip": ip, - "expires": expiresAt, - "reason": reason, + "ip": ip, + "expires": expiresAt, + "reason": reason, }) } return list, nil } func ipMatchesEntry(ip, entry string) (bool, error) { - // Simple case: exact IP match if entry == ip { return true, nil } @@ -222,4 +222,4 @@ func isUniqueConstraintError(err error) bool { return false } return err.Error() == "UNIQUE constraint failed: permanent_whitelist.entry" -} \ No newline at end of file +} diff --git a/docs/api.md b/docs/api.md index 2204b45..4035cc8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -29,7 +29,7 @@ curl -X POST http://127.0.0.1:8080/api/whitelist/temp \ -d '{"ip":"203.0.113.10","ttl_seconds":300,"reason":"admin laptop"}' ``` -### GET /api/whitelist +### GET /api/whitelist/temp/list List all currently active temporary whitelisted IPs. @@ -47,7 +47,7 @@ List all currently active temporary whitelisted IPs. ] ``` -### DELETE /api/whitelist/{ip} +### DELETE /api/whitelist/temp/{ip} Remove a temporary whitelisted IP. @@ -55,6 +55,67 @@ Remove a temporary whitelisted IP. **Response:** 204 No Content +### POST /api/whitelist/perm + +Add a permanent whitelisted IP or CIDR range. + +**Authorization:** Bearer `{API_TOKEN}` + +**Request body:** + +```json +{ + "entry": "1.2.3.4" +} +``` + +**Response:** 204 No Content + +**Example:** + +```bash +curl -X POST http://127.0.0.1:8080/api/whitelist/perm \ + -H "Authorization: Bearer CHANGE_ME" \ + -H "Content-Type: application/json" \ + -d '{"entry":"203.0.113.10"}' +``` + +### DELETE /api/whitelist/perm/{entry} + +Remove a permanent whitelisted IP or CIDR range. + +**Authorization:** Bearer `{API_TOKEN}` + +**Response:** 204 No Content + +### GET /api/logs + +Retrieve audit log entries. + +**Authorization:** Bearer `{API_TOKEN}` + +**Response body:** + +```json +[ + { + "timestamp": "2024-01-01T12:00:00Z", + "action": "add_temp", + "ip": "1.2.3.4", + "reason": "admin laptop", + "ttl_seconds": 300, + "api_client_ip": "10.0.0.1" + } +] +``` + +**Example:** + +```bash +curl -X GET http://127.0.0.1:8080/api/logs \ + -H "Authorization: Bearer CHANGE_ME" +``` + ### GET /auth The NGINX auth_request endpoint. Do NOT call this directly. @@ -67,19 +128,10 @@ 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 +- The auth endpoint only checks IP whitelisting (no Basic Auth fallback). +- Always serve the auth endpoint over TLS. diff --git a/docs/deploy.md b/docs/deploy.md index da7460c..7e9b136 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -76,22 +76,9 @@ nginx -s reload |----------|-------------|---------| | 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 | +| AUTH_GATE_DB_PATH | SQLite database path | ./data/auth-gate.db | | 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 @@ -109,5 +96,4 @@ For production, use the Dockerfile from this repository. It: - 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 +- Mounts /tmp as tmpfs (no persistence). diff --git a/docs/design.md b/docs/design.md index 7b81160..f5766b9 100644 --- a/docs/design.md +++ b/docs/design.md @@ -79,6 +79,21 @@ A shell script would work but: Go's standard library is sufficient for our needs. +## Why SQLite? + +SQLite provides: + +- Single file — no external dependencies. +- Persistence across restarts. +- ACID transactions. +- Simple WAL mode for concurrent reads. + +For a handful of IPs (dozens at most), SQLite is: + +- Simple to deploy. +- Fast (single-file, no network). +- No external dependencies. + ## Why in-memory temporary whitelist? For a handful of IPs (dozens at most), an in-memory map is: @@ -94,33 +109,12 @@ If you need persistence across restarts, add a disk-backed store later. 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 index 7dcdebb..17264b9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -7,7 +7,6 @@ 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. @@ -16,17 +15,15 @@ ### Permanent whitelist -- Loaded from a file mounted as a volume. -- The file is polled every 30 seconds (configurable). +- Stored in SQLite `permanent_whitelist` table. - Only single IPs and CIDR ranges (as strings) are supported. -- The file is reloaded only when its mtime changes. +- Added/removed via the `/api/whitelist/perm` API. ### Temporary whitelist -- In-memory store. +- Stored in SQLite `temp_whitelist` table. - Entries expire after their TTL. - The cleanup goroutine runs every 60 seconds (configurable) to remove expired entries. -- No persistence across restarts. ## API key @@ -37,23 +34,18 @@ in the Authorization header. - Use a strong random string. - Rotate it regularly. -## Basic Auth +## Audit log -The Basic Auth credentials are set via environment variables. The username is `admin` -by default, and the password is `changeme` by default. +All whitelist operations are logged to the `audit_log` table in SQLite. +Each entry records: -- 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. +- `action` — what happened (`add_temp`, `delete_temp`, `add_perm`, `delete_perm`, `expire_temp`) +- `ip` or `cidr` — the affected IP or CIDR +- `ttl_seconds` — for temp entries +- `reason` — the reason provided +- `api_client_ip` — the IP that made the API call -## 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. +Retrieve logs via the `/api/logs` endpoint. ## Graceful shutdown @@ -81,4 +73,4 @@ This ensures Docker deployments can shut down cleanly. - 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 +- We don't support gRPC. The auth-gate service is a simple REST API. diff --git a/go.mod b/go.mod index 4933fb1..a8d2bc3 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,17 @@ module go.mod go 1.25.0 + +require modernc.org/sqlite v1.50.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2dc5885 --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= +modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= +modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= +modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/load_whitelist.go b/load_whitelist.go deleted file mode 100644 index 8c73715..0000000 --- a/load_whitelist.go +++ /dev/null @@ -1,51 +0,0 @@ -// 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 index 6bd4e5b..b1ad51a 100644 --- a/main.go +++ b/main.go @@ -7,15 +7,14 @@ import ( "os" "os/signal" "syscall" + "time" ) func main() { cfg := loadConfig() - // Set up structured logging slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) - // Initialize database db, err := InitDB(cfg.DBPath) if err != nil { slog.Error("failed to init database", "error", err) @@ -23,10 +22,8 @@ func main() { } defer db.Close() - // Start background cleanup of expired temp entries go cleanupLoop(db, cfg.CleanupInterval) - // Setup HTTP server mux := http.NewServeMux() mux.HandleFunc("/auth", authHandler(cfg, db)) mux.HandleFunc("/api/whitelist/temp", whitelistTempHandler(cfg, db)) @@ -40,7 +37,6 @@ func main() { server := &http.Server{ Addr: ":" + cfg.Port, Handler: mux, - // timeouts as before } go func() { @@ -59,4 +55,4 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() server.Shutdown(ctx) -} \ No newline at end of file +} diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..e98447b --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,267 @@ +openapi: "3.0.3" +info: + title: Auth-gate API + description: Lightweight Go auth gateway for IP-based whitelisting with temporary entries and audit logging. + version: "1.0.0" + contact: + name: Auth-gate Project + url: https://github.com/your-org/auth-gate + +servers: + - url: http://127.0.0.1:8080 + description: Local development server + +security: + - BearerAuth: [] + +paths: + /auth: + get: + summary: Auth check (NGINX auth_request) + description: | + The NGINX auth_request endpoint. Do NOT call this directly. + Returns 200 if the client IP is whitelisted, 401 otherwise. + Only checks permanent and temporary IP whitelists. + operationId: checkAuth + parameters: + - name: X-Real-IP + in: header + required: true + schema: + type: string + description: Client IP set by NGINX + responses: + '200': + description: OK + content: + text/plain: + schema: + type: string + example: "ok" + '401': + description: Unauthorized — IP not whitelisted + + /status: + get: + summary: Health check + description: Simple health check endpoint. + operationId: healthCheck + security: [] + responses: + '200': + description: OK + content: + text/plain: + schema: + type: string + example: "ok\n" + + /api/whitelist/temp: + post: + summary: Add a temporary whitelisted IP + description: Create a temporary whitelisted IP entry with a TTL. + operationId: addTempWhitelist + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - ip + - ttl_seconds + properties: + ip: + type: string + description: The IP address to whitelist + example: "203.0.113.10" + ttl_seconds: + type: integer + description: Time-to-live in seconds + minimum: 1 + example: 300 + reason: + type: string + description: Optional reason for whitelisting + example: "admin laptop" + responses: + '204': + description: Created + '400': + description: Bad request — missing or invalid parameters + content: + text/plain: + schema: + type: string + example: "ip and ttl_seconds required" + '401': + description: Unauthorized — invalid API token + '500': + description: Internal error — database failure + + /api/whitelist/temp/list: + get: + summary: List temporary whitelisted IPs + description: List all currently active temporary whitelisted IPs. + operationId: listTempWhitelist + responses: + '200': + description: List of temporary whitelisted IPs + content: + application/json: + schema: + type: array + items: + type: object + properties: + ip: + type: string + example: "1.2.3.4" + reason: + type: string + example: "my laptop" + expires: + type: string + format: date-time + example: "2024-01-01T12:05:00Z" + '401': + description: Unauthorized — invalid API token + '500': + description: Internal error — database failure + + /api/whitelist/temp/{ip}: + delete: + summary: Remove a temporary whitelisted IP + description: Remove a specific temporary whitelisted IP entry. + operationId: deleteTempWhitelist + parameters: + - name: ip + in: path + required: true + schema: + type: string + description: The IP address to remove + responses: + '204': + description: Deleted + '400': + description: Bad request — IP required + '401': + description: Unauthorized — invalid API token + '500': + description: Internal error + + /api/whitelist/perm: + post: + summary: Add a permanent whitelisted IP or CIDR + description: Add a permanent whitelisted IP address or CIDR range. + operationId: addPermWhitelist + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - entry + properties: + entry: + type: string + description: IP address or CIDR range to whitelist + example: "203.0.113.10" + responses: + '204': + description: Created + '400': + description: Bad request — entry required + '401': + description: Unauthorized — invalid API token + '409': + description: Conflict — entry already exists + content: + text/plain: + schema: + type: string + example: "entry already exists" + '500': + description: Internal error + + /api/whitelist/perm/{entry}: + delete: + summary: Remove a permanent whitelisted IP or CIDR + description: Remove a permanent whitelisted IP address or CIDR range. + operationId: deletePermWhitelist + parameters: + - name: entry + in: path + required: true + schema: + type: string + description: The IP address or CIDR range to remove + responses: + '204': + description: Deleted + '400': + description: Bad request — entry required + '401': + description: Unauthorized — invalid API token + '500': + description: Internal error + + /api/logs: + get: + summary: Retrieve audit log entries + description: | + Retrieve the last 200 audit log entries. + Each entry records a whitelist operation with its timestamp, action, affected IP, + and the IP of the API client that performed the action. + operationId: getLogs + responses: + '200': + description: List of audit log entries + content: + application/json: + schema: + type: array + items: + type: object + properties: + timestamp: + type: string + format: date-time + example: "2024-01-01T12:00:00Z" + action: + type: string + example: "add_temp" + ip: + type: string + nullable: true + example: "1.2.3.4" + cidr: + type: string + nullable: true + example: "10.0.0.0/24" + ttl_seconds: + type: integer + nullable: true + example: 300 + reason: + type: string + nullable: true + example: "admin laptop" + api_client_ip: + type: string + nullable: true + example: "10.0.0.1" + '401': + description: Unauthorized — invalid API token + '500': + description: Internal error — database failure + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: token + description: API token for authenticating to the API endpoints diff --git a/sqlite_logger.go b/sqlite_logger.go deleted file mode 100644 index 283448f..0000000 --- a/sqlite_logger.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "database/sql" - "log/slog" - "time" - - _ "modernc.org/sqlite" -) - -func initDB(dbPath string) (*sql.DB, error) { - db, err := sql.Open("sqlite", dbPath) - if err != nil { - return nil, err - } - query := ` - CREATE TABLE IF NOT EXISTS whitelist_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp DATETIME NOT NULL, - action TEXT NOT NULL, - ip TEXT NOT NULL, - ttl_seconds INTEGER DEFAULT 0, - reason TEXT - ); - CREATE INDEX IF NOT EXISTS idx_timestamp ON whitelist_audit(timestamp); - ` - _, err = db.Exec(query) - if err != nil { - return nil, err - } - return db, nil -} - -func logWhitelistEvent(db *sql.DB, action, ip string, ttl int, reason, apiUser string) { - _, err := db.Exec( - `INSERT INTO whitelist_audit(timestamp, action, ip, ttl_seconds, reason) - VALUES(?, ?, ?, ?, ?)`, - time.Now(), action, ip, ttl, reason, - ) - if err != nil { - slog.Error("failed to log whitelist event", "error", err) - } -} \ No newline at end of file diff --git a/watcher.go b/watcher.go deleted file mode 100644 index b0207bd..0000000 --- a/watcher.go +++ /dev/null @@ -1,65 +0,0 @@ -package main - -import ( - "bufio" - "log/slog" - "os" - "time" -) - -type watcher struct { - path string - interval time.Duration - store *PermanentWhitelist - lastMod time.Time -} - -func newPermanentWhitelistWatcher(path string, interval time.Duration, store *PermanentWhitelist) *watcher { - w := &watcher{ - path: path, - interval: interval, - store: store, - } - w.reload() - return w -} - -func (w *watcher) start() { - go func() { - ticker := time.NewTicker(w.interval) - defer ticker.Stop() - for range ticker.C { - w.reload() - } - }() -} - -func (w *watcher) reload() { - info, err := os.Stat(w.path) - if err != nil { - return - } - if info.ModTime().Equal(w.lastMod) { - return - } - w.lastMod = info.ModTime() - entries := make(map[string]struct{}) - f, err := os.Open(w.path) - if err != nil { - slog.Error("failed to open whitelist file", "error", err) - return - } - defer f.Close() - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - if line == "" || line[0] == '#' { - continue - } - entries[line] = struct{}{} - } - w.store.mu.Lock() - w.store.entries = entries - w.store.mu.Unlock() - slog.Info("reloaded permanent whitelist", "count", len(entries)) -} \ No newline at end of file