diff --git a/auth.go b/auth.go index d00d93d..0b9e47e 100644 --- a/auth.go +++ b/auth.go @@ -4,61 +4,29 @@ import ( "database/sql" "encoding/json" "log/slog" + "net" "net/http" - "sort" - "sync" - "time" - - _ "modernc.org/sqlite" ) -type tempEntry struct { - IP string `json:"ip"` - Reason string `json:"reason,omitempty"` - Expires time.Time `json:"expires"` -} - -type tempWhitelist struct { - mu sync.RWMutex - entries map[string]tempEntry -} - -func newTempWhitelist() *tempWhitelist { - return &tempWhitelist{entries: make(map[string]tempEntry)} -} - -type PermanentWhitelist struct { - mu sync.RWMutex - entries map[string]struct{} -} - -func newPermanentWhitelist() *PermanentWhitelist { - return &PermanentWhitelist{entries: make(map[string]struct{})} -} - -func (tw *tempWhitelist) CheckAuth(r *http.Request, perm *PermanentWhitelist) bool { +func CheckAuth(db *sql.DB, r *http.Request) bool { ip := r.Header.Get("X-Real-IP") if ip == "" { return false } - perm.mu.RLock() - _, ok := perm.entries[ip] - perm.mu.RUnlock() - if ok { + ok, err := IsPermanentWhitelisted(db, ip) + if err == nil && ok { return true } - tw.mu.RLock() - if e, exists := tw.entries[ip]; exists && e.Expires.After(time.Now()) { - tw.mu.RUnlock() + ok, err = IsTempWhitelisted(db, ip) + if err == nil && ok { return true } - tw.mu.RUnlock() return false } -func authHandler(cfg Config, tw *tempWhitelist, perm *PermanentWhitelist) http.HandlerFunc { +func authHandler(cfg Config, db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if !tw.CheckAuth(r, perm) { + if !CheckAuth(db, r) { w.WriteHeader(http.StatusUnauthorized) return } @@ -66,7 +34,7 @@ func authHandler(cfg Config, tw *tempWhitelist, perm *PermanentWhitelist) http.H } } -func (tw *tempWhitelist) whitelistTempHandler(cfg Config, db *sql.DB) http.HandlerFunc { +func whitelistTempHandler(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) @@ -90,40 +58,34 @@ func (tw *tempWhitelist) whitelistTempHandler(cfg Config, db *sql.DB) http.Handl http.Error(w, "ip and ttl_seconds required", http.StatusBadRequest) return } - expires := time.Now().Add(time.Duration(req.TTLSeconds) * time.Second) - - tw.mu.Lock() - tw.entries[req.IP] = tempEntry{IP: req.IP, Reason: req.Reason, Expires: expires} - tw.mu.Unlock() - - go logWhitelistEvent(db, "add_temp", req.IP, req.TTLSeconds, req.Reason) - - slog.Info("whitelist added", "ip", req.IP, "ttl", req.TTLSeconds, "reason", req.Reason) + clientIP := getClientIP(r) + if err := AddTempEntry(db, req.IP, req.TTLSeconds, req.Reason, clientIP); err != nil { + slog.Error("failed to add temp entry", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + slog.Info("temp whitelist added", "ip", req.IP, "ttl", req.TTLSeconds) w.WriteHeader(http.StatusNoContent) } } -func (tw *tempWhitelist) whitelistListHandler(cfg Config) http.HandlerFunc { +func whitelistListHandler(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 } - tw.mu.RLock() - entries := make([]tempEntry, 0, len(tw.entries)) - for _, e := range tw.entries { - entries = append(entries, e) + entries, err := ListTempEntries(db) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return } - tw.mu.RUnlock() - 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) } } -func (tw *tempWhitelist) whitelistDeleteHandler(cfg Config, db *sql.DB) http.HandlerFunc { +func whitelistDeleteHandler(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) @@ -134,12 +96,13 @@ func (tw *tempWhitelist) whitelistDeleteHandler(cfg Config, db *sql.DB) http.Han http.Error(w, "ip required", http.StatusBadRequest) return } - tw.mu.Lock() - delete(tw.entries, ip) - tw.mu.Unlock() - - go logWhitelistEvent(db, "delete_temp", ip, 0, "", getAPIUser(r)) - slog.Info("whitelist deleted", "ip", ip) + clientIP := getClientIP(r) + if err := DeleteTempEntry(db, ip, clientIP); err != nil { + slog.Error("failed to delete temp entry", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + slog.Info("temp whitelist deleted", "ip", ip) w.WriteHeader(http.StatusNoContent) } } @@ -150,8 +113,8 @@ func logsHandler(cfg Config, db *sql.DB) http.HandlerFunc { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - rows, err := db.Query(`SELECT timestamp, action, ip, ttl_seconds, reason, api_user - FROM whitelist_audit ORDER BY timestamp DESC LIMIT 100`) + rows, err := db.Query(`SELECT timestamp, action, ip, cidr, ttl_seconds, reason, api_client_ip + FROM audit_log ORDER BY timestamp DESC LIMIT 200`) if err != nil { http.Error(w, "database error", http.StatusInternalServerError) return @@ -160,29 +123,101 @@ func logsHandler(cfg Config, db *sql.DB) http.HandlerFunc { var logs []map[string]interface{} for rows.Next() { var ts time.Time - var action, ip, reason, apiUser string - var ttl int - if err := rows.Scan(&ts, &action, &ip, &ttl, &reason); err != nil { + var action, apiClientIP, reason string + var ip, cidr sql.NullString + var ttlSeconds sql.NullInt64 + if err := rows.Scan(&ts, &action, &ip, &cidr, &ttlSeconds, &reason, &apiClientIP); err != nil { continue } - logs = append(logs, map[string]interface{}{ + entry := map[string]interface{}{ "timestamp": ts, "action": action, - "ip": ip, - "ttl": ttl, + "api_client_ip": apiClientIP, "reason": reason, - }) + } + if ip.Valid { + entry["ip"] = ip.String + } + if cidr.Valid { + entry["cidr"] = cidr.String + } + if ttlSeconds.Valid { + entry["ttl_seconds"] = ttlSeconds.Int64 + } + logs = append(logs, entry) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(logs) } } -func statusHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte("ok\n")) +// --- Perm whitelist API endpoints (optional but useful) --- +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 { + Entry string `json:"entry"` // IP or CIDR + } + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if req.Entry == "" { + http.Error(w, "entry required", http.StatusBadRequest) + return + } + ok, err := AddPermanentEntry(db, req.Entry, getClientIP(r)) + if err != nil { + slog.Error("failed to add perm entry", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.Error(w, "entry 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 + } + entry := r.PathValue("entry") + if entry == "" { + http.Error(w, "entry required", http.StatusBadRequest) + return + } + if err := DeletePermanentEntry(db, entry, getClientIP(r)); err != nil { + slog.Error("failed to delete perm entry", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// Helper to get real client IP for audit (X-Real-IP or remote addr) +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 +} + +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