feat: add SQLite persistence and REST API for temporary whitelisting
- Migrate IP-based temporary whitelisting from file to SQLite storage - Add REST API endpoints for managing temporary and permanent whitelists - Create `.env.example` with required environment variables - Document API endpoints in README.md and docs/api.md - Add new dependency `modernc.org/sqlite` for SQLite support - Update deployment and security documentation
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
AUTH_GATE_PORT
|
||||
AUTH_GATE_API_TOKEN
|
||||
DATA_DIR
|
||||
CLEANUP_INTERVAL
|
||||
AUTH_GATE_DB_PATH
|
||||
23
README.md
23
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.
|
||||
|
||||
1
auth.go
1
auth.go
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CheckAuth(db *sql.DB, r *http.Request) bool {
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
217.219.87.178
|
||||
2
db.go
2
db.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -189,7 +190,6 @@ func ListTempEntries(db *sql.DB) ([]map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
func ipMatchesEntry(ip, entry string) (bool, error) {
|
||||
// Simple case: exact IP match
|
||||
if entry == ip {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
78
docs/api.md
78
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.
|
||||
- The auth endpoint only checks IP whitelisting (no Basic Auth fallback).
|
||||
- Always serve the auth endpoint over TLS.
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
14
go.mod
14
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
|
||||
)
|
||||
|
||||
51
go.sum
Normal file
51
go.sum
Normal file
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
6
main.go
6
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() {
|
||||
|
||||
267
openapi.yaml
Normal file
267
openapi.yaml
Normal file
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
65
watcher.go
65
watcher.go
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user