Add .env and .env.local loading with InitEnv() helper that reads environment variables from config files without overwriting existing values. Refactor SQL queries to use parameterized arguments instead of string formatting for LIMIT/OFFSET clauses, improving query security and preventing SQL injection vulnerabilities. Clean up unnecessary rows.Close() calls and simplify error handling in database query functions.
322 lines
8.5 KiB
Go
322 lines
8.5 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"log/slog"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
sqlite "modernc.org/sqlite"
|
|
)
|
|
|
|
func InitDB(dbPath string) (*sql.DB, error) {
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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 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
|
|
);
|
|
`
|
|
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) {
|
|
_, 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)
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// DeletePermanentEntry removes a permanent whitelist entry.
|
|
func DeletePermanentEntry(db *sql.DB, ip, apiClientIP string) error {
|
|
result, err := db.Exec(`DELETE FROM perm_whitelist WHERE ip = ?`, ip)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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`)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var entry string
|
|
if err := rows.Scan(&entry); err != nil {
|
|
continue
|
|
}
|
|
if matches, _ := ipMatchesEntry(ip, entry); matches {
|
|
return true, nil
|
|
}
|
|
}
|
|
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).Format(time.RFC3339)
|
|
_, err := db.Exec(`
|
|
INSERT INTO temp_whitelist(ip, expires_at, reason)
|
|
VALUES(?, ?, ?)
|
|
ON CONFLICT(ip) DO UPDATE SET expires_at=excluded.expires_at, reason=excluded.reason
|
|
`, ip, expiresAt, reason)
|
|
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)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteTempEntry removes a temporary whitelist entry.
|
|
func DeleteTempEntry(db *sql.DB, ip, apiClientIP string) error {
|
|
result, err := db.Exec(`DELETE FROM temp_whitelist WHERE ip = ?`, ip)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rows, _ := result.RowsAffected(); rows == 0 {
|
|
slog.Warn("tried to delete non-existent temp ip", "ip", ip)
|
|
return nil
|
|
}
|
|
_, 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 string
|
|
err := db.QueryRow(`SELECT expires_at FROM temp_whitelist WHERE ip = ?`, ip).Scan(&expiresAt)
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
expires, parseErr := time.Parse(time.RFC3339, expiresAt)
|
|
if parseErr != nil {
|
|
slog.Error("failed to parse expires_at", "ip", ip, "error", parseErr)
|
|
return false, nil
|
|
}
|
|
return expires.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().Format(time.RFC3339))
|
|
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)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = db.Exec(`DELETE FROM temp_whitelist WHERE expires_at <= ?`, time.Now().Format(time.RFC3339))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
FROM temp_whitelist
|
|
WHERE expires_at > ?
|
|
ORDER BY expires_at
|
|
LIMIT ? OFFSET ?
|
|
`, time.Now().Format(time.RFC3339), limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
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) {
|
|
list := make([]map[string]interface{}, 0)
|
|
for rows.Next() {
|
|
var ip string
|
|
var expiresAt string
|
|
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,
|
|
})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
func scanPermEntries(rows *sql.Rows) ([]map[string]interface{}, error) {
|
|
list := 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})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return list, 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
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM audit_log`).Scan(&count); err != nil {
|
|
return err
|
|
}
|
|
if count <= maxEntries {
|
|
return nil
|
|
}
|
|
_, err := db.Exec(`
|
|
DELETE FROM audit_log
|
|
WHERE id IN (
|
|
SELECT id FROM audit_log
|
|
ORDER BY timestamp ASC
|
|
LIMIT ?
|
|
)
|
|
`, count-maxEntries)
|
|
return err
|
|
}
|