Files
auth-proxy/src/db.go
db123-test c3869f4f10 feat: add env file support and parameterize SQL queries
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.
2026-05-05 19:13:42 +03:30

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
}