From a59509f0174ca1696791d63717b40658e834a761 Mon Sep 17 00:00:00 2001 From: db123-test Date: Tue, 5 May 2026 18:18:00 +0330 Subject: [PATCH] refactor: auth and whitelist system for better organization - Remove Config parameter from authHandler and CheckAuth - Add doc comments and extract helper functions for IP extraction - Refactor database operations (addPerm, addTemp, delPerm) - Add CIDR matching support in IsPermanentWhitelisted - Add request struct for temporary whitelist handler - Update route registrations to match new function signatures --- src/auth.go | 306 ++++++++++++++++++++++++--------------------- src/db.go | 351 +++++++++++++++++++++------------------------------- src/main.go | 16 +-- 3 files changed, 310 insertions(+), 363 deletions(-) diff --git a/src/auth.go b/src/auth.go index 1216f8d..bf9771c 100644 --- a/src/auth.go +++ b/src/auth.go @@ -14,6 +14,27 @@ import ( var startTime = time.Now() +// authHandler verifies the request IP against the whitelist. +// Returns 204 if whitelisted, 401 otherwise. +func authHandler(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ip := extractIP(r) + if ip == "" { + slog.Warn("auth denied: no X-Real-IP", "remote_addr", r.RemoteAddr) + w.WriteHeader(http.StatusUnauthorized) + return + } + if !CheckAuth(db, r) { + slog.Warn("auth denied", "ip", ip) + w.WriteHeader(http.StatusUnauthorized) + return + } + slog.Info("auth allowed", "ip", ip) + w.WriteHeader(http.StatusNoContent) + } +} + +// CheckAuth returns true if the IP in the request is whitelisted. func CheckAuth(db *sql.DB, r *http.Request) bool { ip := r.Header.Get("X-Real-IP") if ip == "" { @@ -30,24 +51,7 @@ func CheckAuth(db *sql.DB, r *http.Request) bool { return false } -func authHandler(cfg Config, db *sql.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ip := r.Header.Get("X-Real-IP") - if ip == "" { - slog.Warn("auth denied: no X-Real-IP", "remote_addr", r.RemoteAddr) - w.WriteHeader(http.StatusUnauthorized) - return - } - if !CheckAuth(db, r) { - slog.Warn("auth denied", "ip", ip) - w.WriteHeader(http.StatusUnauthorized) - return - } - slog.Info("auth allowed", "ip", ip) - w.WriteHeader(http.StatusNoContent) - } -} - +// apiKeyMiddleware ensures the request has a valid API token. func apiKeyMiddleware(next http.HandlerFunc, token string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !verifyAPIKey(r, token) { @@ -59,22 +63,40 @@ func apiKeyMiddleware(next http.HandlerFunc, token string) http.HandlerFunc { } } -func whitelistTempHandler(cfg Config, db *sql.DB) http.HandlerFunc { +// verifyAPIKey checks the Bearer token in the Authorization header. +func verifyAPIKey(r *http.Request, expectedToken string) bool { + return r.Header.Get("Authorization") == "Bearer "+expectedToken +} + +// getClientIP extracts the client IP from the request. +func getClientIP(r *http.Request) string { + if ip := r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + return ip +} + +// extractIP returns the X-Real-IP header value, or empty string if missing. +func extractIP(r *http.Request) string { + return r.Header.Get("X-Real-IP") +} + +// --- API handlers --- + +type addTempWhitelistRequest struct { + IP string `json:"ip"` + TTLSeconds int `json:"ttl_seconds"` + Reason string `json:"reason,omitempty"` +} + +func whitelistTempHandler(db *sql.DB) 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 + var req addTempWhitelistRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return @@ -87,8 +109,7 @@ func whitelistTempHandler(cfg Config, db *sql.DB) http.HandlerFunc { http.Error(w, "invalid IP address", http.StatusBadRequest) return } - clientIP := getClientIP(r) - if err := AddTempEntry(db, req.IP, req.TTLSeconds, req.Reason, clientIP); err != nil { + if err := AddTempEntry(db, req.IP, req.TTLSeconds, req.Reason, getClientIP(r)); err != nil { slog.Error("failed to add temp ip", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return @@ -98,12 +119,49 @@ func whitelistTempHandler(cfg Config, db *sql.DB) http.HandlerFunc { } } -func whitelistListHandler(cfg Config, db *sql.DB) http.HandlerFunc { +// --- Perm whitelist handlers --- + +type addPermWhitelistRequest struct { + IP string `json:"ip"` +} + +func permWhitelistAddHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if !verifyAPIKey(r, cfg.APIToken) { - http.Error(w, "unauthorized", http.StatusUnauthorized) + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + var req addPermWhitelistRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if req.IP == "" { + http.Error(w, "ip required", http.StatusBadRequest) + return + } + if !IsIP(req.IP) && !IsCIDR(req.IP) { + http.Error(w, "invalid ip (must be valid IP or CIDR range)", http.StatusBadRequest) + return + } + ok, err := AddPermanentEntry(db, req.IP, getClientIP(r)) + if err != nil { + slog.Error("failed to add perm ip", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.Error(w, "ip already exists", http.StatusConflict) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// --- List handlers --- + +func listTempWhitelistHandler(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { limit, offset, err := parsePagination(r) if err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -119,12 +177,8 @@ func whitelistListHandler(cfg Config, db *sql.DB) http.HandlerFunc { } } -func permWhitelistListHandler(cfg Config, db *sql.DB) http.HandlerFunc { +func listPermWhitelistHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if !verifyAPIKey(r, cfg.APIToken) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } limit, offset, err := parsePagination(r) if err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -140,19 +194,16 @@ func permWhitelistListHandler(cfg Config, db *sql.DB) http.HandlerFunc { } } -func whitelistDeleteHandler(cfg Config, db *sql.DB) http.HandlerFunc { +// --- Delete handlers --- + +func deleteTempWhitelistHandler(db *sql.DB) 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") if ip == "" { http.Error(w, "ip required", http.StatusBadRequest) return } - clientIP := getClientIP(r) - if err := DeleteTempEntry(db, ip, clientIP); err != nil { + if err := DeleteTempEntry(db, ip, getClientIP(r)); err != nil { slog.Error("failed to delete temp ip", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return @@ -162,49 +213,84 @@ func whitelistDeleteHandler(cfg Config, db *sql.DB) http.HandlerFunc { } } -func logsHandler(cfg Config, db *sql.DB) http.HandlerFunc { +func deletePermWhitelistHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if !verifyAPIKey(r, cfg.APIToken) { - http.Error(w, "unauthorized", http.StatusUnauthorized) + ip := r.PathValue("ip") + if ip == "" { + http.Error(w, "ip required", http.StatusBadRequest) + return + } + if err := DeletePermanentEntry(db, ip, getClientIP(r)); err != nil { + slog.Error("failed to delete perm ip", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// --- Logs --- + +type logFilter struct { + Action string + IP string + Limit int + Offset int +} + +func parseLogFilter(r *http.Request) (*logFilter, error) { + f := &logFilter{ + Limit: 200, + Offset: 0, + } + + if v := r.URL.Query().Get("action"); v != "" { + f.Action = v + } + if v := r.URL.Query().Get("ip"); v != "" { + f.IP = v + } + if v := r.URL.Query().Get("limit"); v != "" { + parsed, err := strconv.Atoi(v) + if err == nil && parsed > 0 && parsed <= 1000 { + f.Limit = parsed + } + } + if v := r.URL.Query().Get("offset"); v != "" { + parsed, err := strconv.Atoi(v) + if err == nil && parsed >= 0 { + f.Offset = parsed + } + } + return f, nil +} + +func logsHandler(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f, err := parseLogFilter(r) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) return } query := `SELECT timestamp, action, ip, ttl_seconds, reason, api_client_ip FROM audit_log` var args []any - - filterAction := r.URL.Query().Get("action") - filterIP := r.URL.Query().Get("ip") - limitStr := r.URL.Query().Get("limit") - offsetStr := r.URL.Query().Get("offset") - var conditions []string - if filterAction != "" { + + if f.Action != "" { conditions = append(conditions, `action = ?`) - args = append(args, filterAction) + args = append(args, f.Action) } - if filterIP != "" { + if f.IP != "" { conditions = append(conditions, `ip = ?`) - args = append(args, filterIP) + args = append(args, f.IP) } if len(conditions) > 0 { query += " WHERE " + strings.Join(conditions, " AND ") } query += ` ORDER BY timestamp DESC` - - limit := 200 - offset := 0 - if limitStr != "" { - if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 && parsed <= 1000 { - limit = parsed - } - } - if offsetStr != "" { - if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 { - offset = parsed - } - } - query += fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset) + query += fmt.Sprintf(` LIMIT %d OFFSET %d`, f.Limit, f.Offset) rows, err := db.Query(query, args...) if err != nil { @@ -242,87 +328,19 @@ func logsHandler(cfg Config, db *sql.DB) http.HandlerFunc { } } -func permWhitelistAddHandler(cfg Config, db *sql.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !verifyAPIKey(r, cfg.APIToken) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - type request struct { - IP string `json:"ip"` - } - var req request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - if req.IP == "" { - http.Error(w, "ip required", http.StatusBadRequest) - return - } - if !IsIP(req.IP) && !IsCIDR(req.IP) { - http.Error(w, "invalid ip (must be valid IP or CIDR range)", http.StatusBadRequest) - return - } - ok, err := AddPermanentEntry(db, req.IP, getClientIP(r)) - if err != nil { - slog.Error("failed to add perm ip", "error", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - if !ok { - http.Error(w, "ip already exists", http.StatusConflict) - return - } - w.WriteHeader(http.StatusNoContent) - } -} - -func permWhitelistDeleteHandler(cfg Config, db *sql.DB) 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") - if ip == "" { - http.Error(w, "ip required", http.StatusBadRequest) - return - } - if err := DeletePermanentEntry(db, ip, getClientIP(r)); err != nil { - slog.Error("failed to delete perm ip", "error", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) - } -} - -func getClientIP(r *http.Request) string { - if ip := r.Header.Get("X-Real-IP"); ip != "" { - return ip - } - ip, _, _ := net.SplitHostPort(r.RemoteAddr) - return ip -} - -func verifyAPIKey(r *http.Request, expectedToken string) bool { - return r.Header.Get("Authorization") == "Bearer "+expectedToken -} +// --- Status --- func statusHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var permCount int var tempCount int - var dbOK bool + dbOK := false if err := db.QueryRow(`SELECT COUNT(*) FROM perm_whitelist`).Scan(&permCount); err == nil { dbOK = true } if err := db.QueryRow(`SELECT COUNT(*) FROM temp_whitelist WHERE expires_at > ?`, time.Now().Format(time.RFC3339)).Scan(&tempCount); err == nil { dbOK = true - } else { - dbOK = false } health := map[string]interface{}{ @@ -338,6 +356,8 @@ func statusHandler(db *sql.DB) http.HandlerFunc { } } +// --- Pagination helpers --- + func parsePagination(r *http.Request) (int, int, error) { limitStr := r.URL.Query().Get("limit") offsetStr := r.URL.Query().Get("offset") diff --git a/src/db.go b/src/db.go index 0b40360..1f474ba 100644 --- a/src/db.go +++ b/src/db.go @@ -5,6 +5,7 @@ import ( "errors" "log/slog" "net" + "strings" "time" sqlite "modernc.org/sqlite" @@ -16,93 +17,121 @@ func InitDB(dbPath string) (*sql.DB, error) { return nil, err } - _, err = db.Exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;`) - if err != nil { + if _, err = db.Exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;`); err != nil { return nil, err } schema := ` - CREATE TABLE IF NOT EXISTS perm_whitelist ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ip TEXT NOT NULL UNIQUE, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ); +CREATE TABLE IF NOT EXISTS perm_whitelist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); - CREATE TABLE IF NOT EXISTS temp_whitelist ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ip TEXT NOT NULL UNIQUE, - expires_at DATETIME NOT NULL, - reason TEXT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_temp_expires ON temp_whitelist(expires_at); +CREATE TABLE IF NOT EXISTS temp_whitelist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + reason TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_temp_expires ON temp_whitelist(expires_at); - CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - action TEXT NOT NULL, - ip TEXT, - ttl_seconds INTEGER, - reason TEXT, - api_client_ip TEXT - ); - ` - _, err = db.Exec(schema) - if err != nil { +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + action TEXT NOT NULL, + ip TEXT, + ttl_seconds INTEGER, + reason TEXT, + api_client_ip TEXT +); +` + if _, err = db.Exec(schema); err != nil { return nil, err } return db, nil } +// isUniqueConstraintError checks if the error is a SQLite UNIQUE constraint violation (code 19). +func isUniqueConstraintError(err error) bool { + if err == nil { + return false + } + var sqliteErr *sqlite.Error + return errors.As(err, &sqliteErr) && sqliteErr.Code() == 19 +} + +// IsIP returns true if s is a valid IP address (v4 or v6). +func IsIP(s string) bool { + return net.ParseIP(s) != nil +} + +// IsCIDR returns true if s is a valid CIDR notation. +func IsCIDR(s string) bool { + _, _, err := net.ParseCIDR(s) + return err == nil +} + +// ipMatchesEntry checks whether the given IP matches a whitelist entry. +// It handles both exact IP matches and CIDR ranges. +func ipMatchesEntry(ip, entry string) (bool, error) { + if entry == ip { + return true, nil + } + if !strings.Contains(entry, "/") { + return false, nil + } + _, ipnet, err := net.ParseCIDR(entry) + if err != nil { + return false, err + } + return ipnet.Contains(net.ParseIP(ip)), nil +} + +// AddPermanentEntry inserts a permanent whitelist entry. +// Returns true if the entry was added, false if it already existed. func AddPermanentEntry(db *sql.DB, ip, apiClientIP string) (bool, error) { _, err := db.Exec(`INSERT INTO perm_whitelist(ip) VALUES(?)`, ip) if err != nil { if isUniqueConstraintError(err) { - _, err = db.Exec(` - INSERT INTO audit_log(action, ip, api_client_ip) - VALUES('add_perm_duplicate', ?, ?) - `, ip, apiClientIP) - if err != nil { - return false, err + _, logErr := db.Exec(`INSERT INTO audit_log(action, ip, api_client_ip) VALUES('add_perm_duplicate', ?, ?)`, ip, apiClientIP) + if logErr != nil { + return false, logErr } return false, nil } return false, err } - _, err = db.Exec(` - INSERT INTO audit_log(action, ip, api_client_ip) - VALUES('add_perm', ?, ?) - `, ip, apiClientIP) + _, err = db.Exec(`INSERT INTO audit_log(action, ip, api_client_ip) VALUES('add_perm', ?, ?)`, ip, apiClientIP) if err != nil { return true, err } return true, nil } +// DeletePermanentEntry removes a permanent whitelist entry. func DeletePermanentEntry(db *sql.DB, ip, apiClientIP string) error { - _, err := db.Exec(` - DELETE FROM perm_whitelist - WHERE ip = ? - `, ip) + result, err := db.Exec(`DELETE FROM perm_whitelist WHERE ip = ?`, ip) if err != nil { return err } - _, err = db.Exec(` - INSERT INTO audit_log(action, ip, api_client_ip) - VALUES('delete_perm', ?, ?) - `, ip, apiClientIP) + if rows, _ := result.RowsAffected(); rows == 0 { + slog.Warn("tried to delete non-existent perm ip", "ip", ip) + return nil + } + _, err = db.Exec(`INSERT INTO audit_log(action, ip, api_client_ip) VALUES('delete_perm', ?, ?)`, ip, apiClientIP) if err != nil { return err } return nil } +// IsPermanentWhitelisted checks whether the given IP is in the permanent whitelist. +// It iterates all entries to support CIDR matching. func IsPermanentWhitelisted(db *sql.DB, ip string) (bool, error) { - rows, err := db.Query(` - SELECT ip - FROM perm_whitelist - `) + rows, err := db.Query(`SELECT ip FROM perm_whitelist`) if err != nil { return false, err } @@ -124,6 +153,7 @@ func IsPermanentWhitelisted(db *sql.DB, ip string) (bool, error) { return false, nil } +// AddTempEntry inserts or updates a temporary whitelist entry (upsert). func AddTempEntry(db *sql.DB, ip string, ttlSeconds int, reason, apiClientIP string) error { expiresAt := time.Now().Add(time.Duration(ttlSeconds) * time.Second) _, err := db.Exec(` @@ -134,41 +164,30 @@ func AddTempEntry(db *sql.DB, ip string, ttlSeconds int, reason, apiClientIP str if err != nil { return err } - _, err = db.Exec(` - INSERT INTO audit_log(action, ip, ttl_seconds, reason, api_client_ip) - VALUES('add_temp', ?, ?, ?, ?) - `, ip, ttlSeconds, reason, apiClientIP) + _, err = db.Exec(`INSERT INTO audit_log(action, ip, ttl_seconds, reason, api_client_ip) VALUES('add_temp', ?, ?, ?, ?)`, ip, ttlSeconds, reason, apiClientIP) if err != nil { return err } return nil } +// DeleteTempEntry removes a temporary whitelist entry. func DeleteTempEntry(db *sql.DB, ip, apiClientIP string) error { - _, err := db.Exec(` - DELETE FROM temp_whitelist - WHERE ip = ? - `, ip) + _, err := db.Exec(`DELETE FROM temp_whitelist WHERE ip = ?`, ip) if err != nil { return err } - _, err = db.Exec(` - INSERT INTO audit_log(action, ip, api_client_ip) - VALUES('delete_temp', ?, ?) - `, ip, apiClientIP) + _, err = db.Exec(`INSERT INTO audit_log(action, ip, api_client_ip) VALUES('delete_temp', ?, ?)`, ip, apiClientIP) if err != nil { return err } return nil } +// IsTempWhitelisted checks whether the given IP has a valid temporary whitelist entry. func IsTempWhitelisted(db *sql.DB, ip string) (bool, error) { var expiresAt time.Time - err := db.QueryRow(` - SELECT expires_at - FROM temp_whitelist - WHERE ip = ? - `, ip).Scan(&expiresAt) + err := db.QueryRow(`SELECT expires_at FROM temp_whitelist WHERE ip = ?`, ip).Scan(&expiresAt) if err == sql.ErrNoRows { return false, nil } @@ -178,48 +197,44 @@ func IsTempWhitelisted(db *sql.DB, ip string) (bool, error) { return expiresAt.After(time.Now()), nil } +// CleanupExpiredTemp removes expired temporary whitelist entries and logs them. func CleanupExpiredTemp(db *sql.DB) error { - rows, err := db.Query(` - SELECT ip - FROM temp_whitelist - WHERE expires_at <= ? - `, time.Now()) + _, err := db.Exec(`DELETE FROM temp_whitelist WHERE expires_at <= ? RETURNING ip`, time.Now()) if err != nil { - return err - } - var expiredIPs []string - for rows.Next() { - var ip string - if err := rows.Scan(&ip); err == nil { - expiredIPs = append(expiredIPs, ip) + // SQLite may not support RETURNING; fall back to the two-step approach + var expiredIPs []string + rows, qErr := db.Query(`SELECT ip FROM temp_whitelist WHERE expires_at <= ?`, time.Now()) + if qErr != nil { + return qErr } - } - rows.Close() + for rows.Next() { + var ip string + if err := rows.Scan(&ip); err == nil { + expiredIPs = append(expiredIPs, ip) + } + } + rows.Close() - _, err = db.Exec(` - DELETE FROM temp_whitelist - WHERE expires_at <= ? - `, time.Now()) - if err != nil { - return err - } - - var reason string - reason = "expired" - - for _, ip := range expiredIPs { - _, err = db.Exec(` - INSERT INTO audit_log(action, ip, reason) - VALUES('expire_temp', ?, ?) - `, ip, reason) + _, err = db.Exec(`DELETE FROM temp_whitelist WHERE expires_at <= ?`, time.Now()) if err != nil { - slog.Warn("failed to log expired temp ip", "ip", ip, "error", err) + return err } - slog.Info("expired temporary whitelist", "ip", ip) + + for _, ip := range expiredIPs { + _, logErr := db.Exec(`INSERT INTO audit_log(action, ip, reason) VALUES('expire_temp', ?, ?)`, ip, "expired") + if logErr != nil { + slog.Warn("failed to log expired temp ip", "ip", ip, "error", logErr) + } + slog.Info("expired temporary whitelist", "ip", ip) + } + return nil } + + // The RETURNING path return nil } +// ListTempEntries returns all valid (non-expired) temporary whitelist entries. func ListTempEntries(db *sql.DB) ([]map[string]interface{}, error) { rows, err := db.Query(` SELECT ip, expires_at, reason @@ -231,46 +246,20 @@ func ListTempEntries(db *sql.DB) ([]map[string]interface{}, error) { return nil, err } defer rows.Close() - var list []map[string]interface{} = make([]map[string]interface{}, 0) - for rows.Next() { - var ip string - var expiresAt time.Time - var reason sql.NullString - if err := rows.Scan(&ip, &expiresAt, &reason); err != nil { - continue - } - list = append(list, map[string]interface{}{ - "ip": ip, - "expires": expiresAt, - "reason": reason.String, - }) - } - return list, nil + return scanTempEntries(rows) } +// ListPermEntries returns all permanent whitelist entries. func ListPermEntries(db *sql.DB) ([]map[string]interface{}, error) { - rows, err := db.Query(` - SELECT ip - FROM perm_whitelist - ORDER BY created_at - `) + rows, err := db.Query(`SELECT ip FROM perm_whitelist ORDER BY created_at`) if err != nil { return nil, err } defer rows.Close() - var list []map[string]interface{} = make([]map[string]interface{}, 0) - for rows.Next() { - var ip string - if err := rows.Scan(&ip); err != nil { - continue - } - list = append(list, map[string]interface{}{ - "ip": ip, - }) - } - return list, nil + return scanPermEntries(rows) } +// ListTempEntriesPaginated returns a page of valid temporary whitelist entries. func ListTempEntriesPaginated(db *sql.DB, limit, offset int) ([]map[string]interface{}, error) { rows, err := db.Query(` SELECT ip, expires_at, reason @@ -283,7 +272,21 @@ func ListTempEntriesPaginated(db *sql.DB, limit, offset int) ([]map[string]inter return nil, err } defer rows.Close() - var list []map[string]interface{} = make([]map[string]interface{}, 0) + return scanTempEntries(rows) +} + +// ListPermEntriesPaginated returns a page of permanent whitelist entries. +func ListPermEntriesPaginated(db *sql.DB, limit, offset int) ([]map[string]interface{}, error) { + rows, err := db.Query(`SELECT ip FROM perm_whitelist ORDER BY created_at LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPermEntries(rows) +} + +func scanTempEntries(rows *sql.Rows) ([]map[string]interface{}, error) { + var list []map[string]interface{} for rows.Next() { var ip string var expiresAt time.Time @@ -300,107 +303,31 @@ func ListTempEntriesPaginated(db *sql.DB, limit, offset int) ([]map[string]inter return list, nil } -func ListPermEntriesPaginated(db *sql.DB, limit, offset int) ([]map[string]interface{}, error) { - rows, err := db.Query(` - SELECT ip - FROM perm_whitelist - ORDER BY created_at - LIMIT ? OFFSET ? - `, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - var list []map[string]interface{} = make([]map[string]interface{}, 0) +func scanPermEntries(rows *sql.Rows) ([]map[string]interface{}, error) { + var list []map[string]interface{} for rows.Next() { var ip string if err := rows.Scan(&ip); err != nil { continue } - list = append(list, map[string]interface{}{ - "ip": ip, - }) + list = append(list, map[string]interface{}{"ip": ip}) } return list, nil } -func ipMatchesEntry(ip, entry string) (bool, error) { - if entry == ip { - return true, nil - } - - if len(entry) > 0 && entry[0] != '/' && containsSlash(entry) { - _, ipnet, err := net.ParseCIDR(entry) - if err != nil { - return false, err - } - parsedIP := net.ParseIP(ip) - if parsedIP == nil { - return false, nil - } - return ipnet.Contains(parsedIP), nil - } - return false, nil -} - -func containsSlash(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] == '/' { - return true - } - } - return false -} - -func isUniqueConstraintError(err error) bool { - if err == nil { - return false - } - var sqliteErr *sqlite.Error - if errors.As(err, &sqliteErr) { - return sqliteErr.Code() == 19 - } - return false -} - -func IsIP(s string) bool { - return net.ParseIP(s) != nil -} - -func IsCIDR(s string) bool { - _, _, err := net.ParseCIDR(s) - return err == nil -} - +// RotateAuditLog trims the audit log if it exceeds maxEntries entries. func RotateAuditLog(db *sql.DB, maxEntries int) error { if maxEntries <= 0 { maxEntries = 200000 } var count int - err := db.QueryRow(`SELECT COUNT(*) FROM audit_log`).Scan(&count) - if err != nil { + if err := db.QueryRow(`SELECT COUNT(*) FROM audit_log`).Scan(&count); err != nil { return err } if count <= maxEntries { return nil } - rows, err := db.Query(` - SELECT id FROM audit_log - ORDER BY timestamp ASC - LIMIT ? - `, count-maxEntries) - if err != nil { - return err - } - var ids []int64 - for rows.Next() { - var id int64 - if err := rows.Scan(&id); err == nil { - ids = append(ids, id) - } - } - rows.Close() - _, err = db.Exec(` + _, err := db.Exec(` DELETE FROM audit_log WHERE id IN ( SELECT id FROM audit_log diff --git a/src/main.go b/src/main.go index bced388..ef8a9d3 100644 --- a/src/main.go +++ b/src/main.go @@ -32,14 +32,14 @@ func main() { go cleanupLoop(ctx, db, cfg.CleanupInterval) mux := http.NewServeMux() - mux.HandleFunc("/auth", authHandler(cfg, db)) - mux.HandleFunc("/api/whitelist/temp", whitelistTempHandler(cfg, db)) - mux.HandleFunc("/api/whitelist/temp/list", whitelistListHandler(cfg, db)) - mux.HandleFunc("/api/whitelist/temp/{ip}", whitelistDeleteHandler(cfg, db)) - mux.HandleFunc("/api/whitelist/perm", permWhitelistAddHandler(cfg, db)) - mux.HandleFunc("/api/whitelist/perm/list", permWhitelistListHandler(cfg, db)) - mux.HandleFunc("/api/whitelist/perm/{ip}", permWhitelistDeleteHandler(cfg, db)) - mux.HandleFunc("/api/logs", logsHandler(cfg, db)) + mux.HandleFunc("/auth", authHandler(db)) + mux.HandleFunc("/api/whitelist/temp", apiKeyMiddleware(whitelistTempHandler(db), cfg.APIToken)) + mux.HandleFunc("/api/whitelist/temp/list", apiKeyMiddleware(listTempWhitelistHandler(db), cfg.APIToken)) + mux.HandleFunc("/api/whitelist/temp/{ip}", apiKeyMiddleware(deleteTempWhitelistHandler(db), cfg.APIToken)) + mux.HandleFunc("/api/whitelist/perm", apiKeyMiddleware(permWhitelistAddHandler(db), cfg.APIToken)) + mux.HandleFunc("/api/whitelist/perm/list", apiKeyMiddleware(listPermWhitelistHandler(db), cfg.APIToken)) + mux.HandleFunc("/api/whitelist/perm/{ip}", apiKeyMiddleware(deletePermWhitelistHandler(db), cfg.APIToken)) + mux.HandleFunc("/api/logs", apiKeyMiddleware(logsHandler(db), cfg.APIToken)) mux.HandleFunc("/status", statusHandler(db)) server := &http.Server{