Initial vibeproxy implementation

Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go,
with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS
framework), and a REST API under /api/v1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
This commit is contained in:
2026-08-17 10:28:59 -05:00
co-authored by Claude Sonnet 5
commit ba1f7c4c03
105 changed files with 9142 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
// Package database owns the SQLite connection, pragmas, and migrations.
package database
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
"strings"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Open opens (creating if needed) the SQLite database at path, applies
// pragmas suited for a long-running server (WAL, foreign keys, busy
// timeout), and runs any pending migrations.
func Open(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// SQLite only tolerates a single writer; keep the pool modest so
// writers serialize instead of colliding on SQLITE_BUSY.
db.SetMaxOpenConns(8)
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA foreign_keys=ON",
"PRAGMA busy_timeout=5000",
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
db.Close()
return nil, fmt.Errorf("pragma %q: %w", p, err)
}
}
if err := migrate(db); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}
func migrate(db *sql.DB) error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)`); err != nil {
return err
}
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return err
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
var applied int
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, name).Scan(&applied); err != nil {
return err
}
if applied > 0 {
continue
}
content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(string(content)); err != nil {
tx.Rollback()
return fmt.Errorf("applying %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, name); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
+87
View File
@@ -0,0 +1,87 @@
package database
import (
"database/sql"
"path/filepath"
"testing"
"github.com/owen/vibeproxy/internal/models"
)
func openTest(t *testing.T) *sql.DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestMigrateAndProxyHostRoundTrip(t *testing.T) {
db := openTest(t)
h := models.ProxyHost{
Name: "test", Type: models.ProxyHostTypeProxy, Domains: []string{"a.example.com", "b.example.com"},
ForwardScheme: "http", LoadBalancingMethod: "round_robin", Enabled: true,
ConnectTimeoutMS: 5000, ReadTimeoutMS: 60000, WriteTimeoutMS: 60000, IdleTimeoutMS: 120000,
Backends: []models.Backend{
{Host: "10.0.0.1", Port: 8080, Weight: 1, Enabled: true, HealthCheckType: models.HealthCheckTCP, IntervalSeconds: 10, FailureThreshold: 3, RecoveryThreshold: 2},
},
CustomHeaders: []models.CustomHeader{
{Direction: models.HeaderDirectionResponse, Action: models.HeaderActionSet, Name: "X-Frame-Options", Value: "DENY"},
},
}
id, err := SaveProxyHost(db, &h)
if err != nil {
t.Fatal(err)
}
if id == 0 {
t.Fatal("expected non-zero id")
}
fetched, err := GetProxyHost(db, id)
if err != nil {
t.Fatal(err)
}
if fetched.Name != "test" || len(fetched.Domains) != 2 || len(fetched.Backends) != 1 || len(fetched.CustomHeaders) != 1 {
t.Fatalf("round trip mismatch: %+v", fetched)
}
// Update should replace domains/backends/headers cleanly.
fetched.Domains = []string{"only.example.com"}
fetched.Backends[0].Port = 9090
if _, err := SaveProxyHost(db, fetched); err != nil {
t.Fatal(err)
}
updated, err := GetProxyHost(db, id)
if err != nil {
t.Fatal(err)
}
if len(updated.Domains) != 1 || updated.Domains[0] != "only.example.com" {
t.Fatalf("expected domains replaced, got %v", updated.Domains)
}
if updated.Backends[0].Port != 9090 {
t.Fatalf("expected backend port updated, got %d", updated.Backends[0].Port)
}
if err := DeleteProxyHost(db, id); err != nil {
t.Fatal(err)
}
if _, err := GetProxyHost(db, id); err != ErrNotFound {
t.Fatalf("expected ErrNotFound after delete, got %v", err)
}
}
func TestSettingsRoundTrip(t *testing.T) {
db := openTest(t)
if err := SetSetting(db, "acme_email", "ops@example.com"); err != nil {
t.Fatal(err)
}
if got := GetSetting(db, "acme_email", ""); got != "ops@example.com" {
t.Fatalf("expected ops@example.com, got %q", got)
}
if got := GetSetting(db, "missing_key", "fallback"); got != "fallback" {
t.Fatalf("expected fallback default, got %q", got)
}
}
+177
View File
@@ -0,0 +1,177 @@
-- vibeproxy initial schema
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
must_change_pw INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
last_used_at TEXT,
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL, -- letsencrypt|custom
domains TEXT NOT NULL, -- json array
challenge_type TEXT, -- http-01|dns-01
dns_provider TEXT,
cert_path TEXT,
key_path TEXT,
not_before TEXT,
not_after TEXT,
last_renewed_at TEXT,
last_error TEXT,
auto_renew INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS access_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
default_allow INTEGER NOT NULL DEFAULT 0,
comments TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS access_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
access_list_id INTEGER NOT NULL REFERENCES access_lists(id) ON DELETE CASCADE,
action TEXT NOT NULL, -- allow|deny
cidr TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_access_rules_list ON access_rules(access_list_id);
CREATE TABLE IF NOT EXISTS access_list_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
access_list_id INTEGER NOT NULL REFERENCES access_lists(id) ON DELETE CASCADE,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
UNIQUE(access_list_id, username)
);
CREATE TABLE IF NOT EXISTS proxy_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'proxy', -- proxy|redirect
listen_addr TEXT NOT NULL DEFAULT '',
forward_scheme TEXT NOT NULL DEFAULT 'http',
preserve_host_header INTEGER NOT NULL DEFAULT 0,
websocket_support INTEGER NOT NULL DEFAULT 1,
force_https INTEGER NOT NULL DEFAULT 0,
hsts_enabled INTEGER NOT NULL DEFAULT 0,
certificate_id INTEGER REFERENCES certificates(id) ON DELETE SET NULL,
access_list_id INTEGER REFERENCES access_lists(id) ON DELETE SET NULL,
connect_timeout_ms INTEGER NOT NULL DEFAULT 5000,
read_timeout_ms INTEGER NOT NULL DEFAULT 60000,
write_timeout_ms INTEGER NOT NULL DEFAULT 60000,
idle_timeout_ms INTEGER NOT NULL DEFAULT 120000,
load_balancing_method TEXT NOT NULL DEFAULT 'round_robin',
redirect_target_url TEXT NOT NULL DEFAULT '',
redirect_code INTEGER NOT NULL DEFAULT 301,
redirect_preserve_path INTEGER NOT NULL DEFAULT 1,
redirect_preserve_qs INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
comments TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS proxy_host_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proxy_host_id INTEGER NOT NULL REFERENCES proxy_hosts(id) ON DELETE CASCADE,
domain TEXT NOT NULL COLLATE NOCASE,
UNIQUE(domain)
);
CREATE INDEX IF NOT EXISTS idx_phd_host ON proxy_host_domains(proxy_host_id);
CREATE INDEX IF NOT EXISTS idx_phd_domain ON proxy_host_domains(domain);
CREATE TABLE IF NOT EXISTS backends (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proxy_host_id INTEGER NOT NULL REFERENCES proxy_hosts(id) ON DELETE CASCADE,
host TEXT NOT NULL,
port INTEGER NOT NULL,
weight INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
health_check_type TEXT NOT NULL DEFAULT 'tcp', -- tcp|http|none
health_check_path TEXT NOT NULL DEFAULT '/',
health_check_status INTEGER NOT NULL DEFAULT 200,
interval_seconds INTEGER NOT NULL DEFAULT 10,
failure_threshold INTEGER NOT NULL DEFAULT 3,
recovery_threshold INTEGER NOT NULL DEFAULT 2
);
CREATE INDEX IF NOT EXISTS idx_backends_host ON backends(proxy_host_id);
CREATE TABLE IF NOT EXISTS custom_headers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proxy_host_id INTEGER NOT NULL REFERENCES proxy_hosts(id) ON DELETE CASCADE,
direction TEXT NOT NULL, -- request|response
action TEXT NOT NULL, -- set|remove
name TEXT NOT NULL,
value TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_headers_host ON custom_headers(proxy_host_id);
CREATE TABLE IF NOT EXISTS streams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
protocol TEXT NOT NULL, -- tcp|udp
listen_ip TEXT NOT NULL DEFAULT '',
listen_port INTEGER NOT NULL,
destination_host TEXT NOT NULL,
destination_port INTEGER NOT NULL,
connect_timeout_ms INTEGER NOT NULL DEFAULT 5000,
idle_timeout_ms INTEGER NOT NULL DEFAULT 300000,
proxy_protocol INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
comments TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(protocol, listen_ip, listen_port)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS request_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
proxy_host_id INTEGER,
proxy_host_name TEXT NOT NULL DEFAULT '',
client_ip TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 0,
response_size INTEGER NOT NULL DEFAULT 0,
backend TEXT NOT NULL DEFAULT '',
response_time_ms INTEGER NOT NULL DEFAULT 0,
user_agent TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_logs_ts ON request_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_logs_host ON request_logs(proxy_host_id);
CREATE INDEX IF NOT EXISTS idx_logs_status ON request_logs(status);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
actor TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
details TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_logs(timestamp);
+149
View File
@@ -0,0 +1,149 @@
package database
import (
"database/sql"
"errors"
"github.com/owen/vibeproxy/internal/models"
)
func ListAccessLists(db *sql.DB) ([]models.AccessList, error) {
rows, err := db.Query(`SELECT id, name, default_allow, comments, created_at, updated_at FROM access_lists ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.AccessList
for rows.Next() {
a, err := scanAccessList(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range out {
if err := hydrateAccessList(db, &out[i]); err != nil {
return nil, err
}
}
return out, nil
}
func scanAccessList(rs rowScanner) (models.AccessList, error) {
var a models.AccessList
var defaultAllow int
var created, updated string
err := rs.Scan(&a.ID, &a.Name, &defaultAllow, &a.Comments, &created, &updated)
if err != nil {
return a, err
}
a.DefaultAllow = defaultAllow == 1
a.CreatedAt = parseTime(created)
a.UpdatedAt = parseTime(updated)
return a, nil
}
func hydrateAccessList(db *sql.DB, a *models.AccessList) error {
rrows, err := db.Query(`SELECT id, access_list_id, action, cidr, priority FROM access_rules WHERE access_list_id = ? ORDER BY priority, id`, a.ID)
if err != nil {
return err
}
for rrows.Next() {
var r models.AccessRule
if err := rrows.Scan(&r.ID, &r.AccessListID, &r.Action, &r.CIDR, &r.Priority); err != nil {
rrows.Close()
return err
}
a.Rules = append(a.Rules, r)
}
rrows.Close()
if err := rrows.Err(); err != nil {
return err
}
urows, err := db.Query(`SELECT id, access_list_id, username, password_hash FROM access_list_users WHERE access_list_id = ? ORDER BY id`, a.ID)
if err != nil {
return err
}
for urows.Next() {
var u models.AccessListUser
if err := urows.Scan(&u.ID, &u.AccessListID, &u.Username, &u.PasswordHash); err != nil {
urows.Close()
return err
}
a.BasicAuthUsers = append(a.BasicAuthUsers, u)
}
urows.Close()
return urows.Err()
}
func GetAccessList(db *sql.DB, id int64) (*models.AccessList, error) {
row := db.QueryRow(`SELECT id, name, default_allow, comments, created_at, updated_at FROM access_lists WHERE id = ?`, id)
a, err := scanAccessList(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if err := hydrateAccessList(db, &a); err != nil {
return nil, err
}
return &a, nil
}
func SaveAccessList(db *sql.DB, a *models.AccessList) (int64, error) {
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
if a.ID == 0 {
res, err := tx.Exec(`INSERT INTO access_lists (name, default_allow, comments) VALUES (?, ?, ?)`, a.Name, boolToInt(a.DefaultAllow), a.Comments)
if err != nil {
return 0, err
}
a.ID, err = res.LastInsertId()
if err != nil {
return 0, err
}
} else {
if _, err := tx.Exec(`UPDATE access_lists SET name=?, default_allow=?, comments=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
a.Name, boolToInt(a.DefaultAllow), a.Comments, a.ID); err != nil {
return 0, err
}
if _, err := tx.Exec(`DELETE FROM access_rules WHERE access_list_id = ?`, a.ID); err != nil {
return 0, err
}
if _, err := tx.Exec(`DELETE FROM access_list_users WHERE access_list_id = ?`, a.ID); err != nil {
return 0, err
}
}
for _, r := range a.Rules {
if _, err := tx.Exec(`INSERT INTO access_rules (access_list_id, action, cidr, priority) VALUES (?,?,?,?)`,
a.ID, r.Action, r.CIDR, r.Priority); err != nil {
return 0, err
}
}
for _, u := range a.BasicAuthUsers {
if _, err := tx.Exec(`INSERT INTO access_list_users (access_list_id, username, password_hash) VALUES (?,?,?)`,
a.ID, u.Username, u.PasswordHash); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, err
}
return a.ID, nil
}
func DeleteAccessList(db *sql.DB, id int64) error {
_, err := db.Exec(`DELETE FROM access_lists WHERE id = ?`, id)
return err
}
+38
View File
@@ -0,0 +1,38 @@
package database
import (
"database/sql"
"github.com/owen/vibeproxy/internal/models"
)
func InsertAuditLog(db *sql.DB, actor, action, target, details string) error {
_, err := db.Exec(`INSERT INTO audit_logs (actor, action, target, details) VALUES (?, ?, ?, ?)`, actor, action, target, details)
return err
}
func ListAuditLogs(db *sql.DB, limit, offset int) ([]models.AuditLog, int, error) {
var total int
if err := db.QueryRow(`SELECT COUNT(*) FROM audit_logs`).Scan(&total); err != nil {
return nil, 0, err
}
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := db.Query(`SELECT id, timestamp, actor, action, target, details FROM audit_logs ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []models.AuditLog
for rows.Next() {
var a models.AuditLog
var ts string
if err := rows.Scan(&a.ID, &ts, &a.Actor, &a.Action, &a.Target, &a.Details); err != nil {
return nil, 0, err
}
a.Timestamp = parseTime(ts)
out = append(out, a)
}
return out, total, rows.Err()
}
+109
View File
@@ -0,0 +1,109 @@
package database
import (
"database/sql"
"encoding/json"
"errors"
"time"
"github.com/owen/vibeproxy/internal/models"
)
func scanCertificate(rs rowScanner) (models.Certificate, error) {
var c models.Certificate
var domainsJSON string
var challengeType, dnsProvider, certPath, keyPath, notBefore, notAfter, lastRenewed, lastErr sql.NullString
var autoRenew int
var created, updated string
err := rs.Scan(&c.ID, &c.Name, &c.Type, &domainsJSON, &challengeType, &dnsProvider, &certPath, &keyPath,
&notBefore, &notAfter, &lastRenewed, &lastErr, &autoRenew, &created, &updated)
if err != nil {
return c, err
}
json.Unmarshal([]byte(domainsJSON), &c.Domains)
c.ChallengeType = models.ChallengeType(challengeType.String)
c.DNSProvider = dnsProvider.String
c.CertPath = certPath.String
c.KeyPath = keyPath.String
if notBefore.Valid {
t := parseTime(notBefore.String)
c.NotBefore = &t
}
if notAfter.Valid {
t := parseTime(notAfter.String)
c.NotAfter = &t
}
if lastRenewed.Valid {
t := parseTime(lastRenewed.String)
c.LastRenewedAt = &t
}
c.LastError = lastErr.String
c.AutoRenew = autoRenew == 1
c.CreatedAt = parseTime(created)
c.UpdatedAt = parseTime(updated)
return c, nil
}
const certCols = `id, name, type, domains, challenge_type, dns_provider, cert_path, key_path, not_before, not_after, last_renewed_at, last_error, auto_renew, created_at, updated_at`
func ListCertificates(db *sql.DB) ([]models.Certificate, error) {
rows, err := db.Query(`SELECT ` + certCols + ` FROM certificates ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Certificate
for rows.Next() {
c, err := scanCertificate(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func GetCertificate(db *sql.DB, id int64) (*models.Certificate, error) {
row := db.QueryRow(`SELECT `+certCols+` FROM certificates WHERE id = ?`, id)
c, err := scanCertificate(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &c, nil
}
func SaveCertificate(db *sql.DB, c *models.Certificate) (int64, error) {
domainsJSON, _ := json.Marshal(c.Domains)
if c.ID == 0 {
res, err := db.Exec(`INSERT INTO certificates (name, type, domains, challenge_type, dns_provider, cert_path, key_path,
not_before, not_after, last_renewed_at, last_error, auto_renew) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
c.Name, c.Type, string(domainsJSON), string(c.ChallengeType), c.DNSProvider, c.CertPath, c.KeyPath,
timeOrNil(c.NotBefore), timeOrNil(c.NotAfter), timeOrNil(c.LastRenewedAt), c.LastError, boolToInt(c.AutoRenew))
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
c.ID = id
return id, err
}
_, err := db.Exec(`UPDATE certificates SET name=?, type=?, domains=?, challenge_type=?, dns_provider=?, cert_path=?,
key_path=?, not_before=?, not_after=?, last_renewed_at=?, last_error=?, auto_renew=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id=?`, c.Name, c.Type, string(domainsJSON), string(c.ChallengeType), c.DNSProvider, c.CertPath, c.KeyPath,
timeOrNil(c.NotBefore), timeOrNil(c.NotAfter), timeOrNil(c.LastRenewedAt), c.LastError, boolToInt(c.AutoRenew), c.ID)
return c.ID, err
}
func DeleteCertificate(db *sql.DB, id int64) error {
_, err := db.Exec(`DELETE FROM certificates WHERE id = ?`, id)
return err
}
func timeOrNil(t *time.Time) any {
if t == nil {
return nil
}
return t.UTC().Format(timeFmt)
}
+220
View File
@@ -0,0 +1,220 @@
package database
import (
"database/sql"
"github.com/owen/vibeproxy/internal/models"
)
func InsertRequestLog(db *sql.DB, l models.RequestLog) error {
_, err := db.Exec(`INSERT INTO request_logs (timestamp, proxy_host_id, proxy_host_name, client_ip, method, host,
path, status, response_size, backend, response_time_ms, user_agent) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
l.Timestamp.UTC().Format(timeFmt), nullInt64(l.ProxyHostID), l.ProxyHostName, l.ClientIP, l.Method, l.Host,
l.Path, l.Status, l.ResponseSize, l.Backend, l.ResponseTimeMS, l.UserAgent)
return err
}
func InsertRequestLogBatch(db *sql.DB, logs []models.RequestLog) error {
if len(logs) == 0 {
return nil
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`INSERT INTO request_logs (timestamp, proxy_host_id, proxy_host_name, client_ip, method, host,
path, status, response_size, backend, response_time_ms, user_agent) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, l := range logs {
if _, err := stmt.Exec(l.Timestamp.UTC().Format(timeFmt), nullInt64(l.ProxyHostID), l.ProxyHostName, l.ClientIP,
l.Method, l.Host, l.Path, l.Status, l.ResponseSize, l.Backend, l.ResponseTimeMS, l.UserAgent); err != nil {
return err
}
}
return tx.Commit()
}
type LogFilter struct {
ProxyHostID *int64
Status int
Search string
Limit int
Offset int
}
func QueryRequestLogs(db *sql.DB, f LogFilter) ([]models.RequestLog, int, error) {
where := "WHERE 1=1"
var args []any
if f.ProxyHostID != nil {
where += " AND proxy_host_id = ?"
args = append(args, *f.ProxyHostID)
}
if f.Status > 0 {
where += " AND status = ?"
args = append(args, f.Status)
}
if f.Search != "" {
where += " AND (host LIKE ? OR path LIKE ? OR client_ip LIKE ?)"
s := "%" + f.Search + "%"
args = append(args, s, s, s)
}
var total int
if err := db.QueryRow(`SELECT COUNT(*) FROM request_logs `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
limit := f.Limit
if limit <= 0 || limit > 500 {
limit = 50
}
q := `SELECT id, timestamp, proxy_host_id, proxy_host_name, client_ip, method, host, path, status, response_size,
backend, response_time_ms, user_agent FROM request_logs ` + where + ` ORDER BY id DESC LIMIT ? OFFSET ?`
args = append(args, limit, f.Offset)
rows, err := db.Query(q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []models.RequestLog
for rows.Next() {
var l models.RequestLog
var ts string
var hostID sql.NullInt64
if err := rows.Scan(&l.ID, &ts, &hostID, &l.ProxyHostName, &l.ClientIP, &l.Method, &l.Host, &l.Path, &l.Status,
&l.ResponseSize, &l.Backend, &l.ResponseTimeMS, &l.UserAgent); err != nil {
return nil, 0, err
}
l.Timestamp = parseTime(ts)
if hostID.Valid {
v := hostID.Int64
l.ProxyHostID = &v
}
out = append(out, l)
}
return out, total, rows.Err()
}
func DeleteOldRequestLogs(db *sql.DB, retentionDays int) (int64, error) {
res, err := db.Exec(`DELETE FROM request_logs WHERE timestamp < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)`, "-"+itoa(retentionDays)+" days")
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func itoa(i int) string {
if i < 0 {
i = 0
}
digits := []byte{}
if i == 0 {
return "0"
}
for i > 0 {
digits = append([]byte{byte('0' + i%10)}, digits...)
i /= 10
}
return string(digits)
}
// Stats
func CountRequestLogsSince(db *sql.DB, sinceExpr string) (int, error) {
var n int
err := db.QueryRow(`SELECT COUNT(*) FROM request_logs WHERE timestamp >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)`, sinceExpr).Scan(&n)
return n, err
}
type StatusCount struct {
Status int
Count int
}
func StatusBreakdownSince(db *sql.DB, sinceExpr string) ([]StatusCount, error) {
rows, err := db.Query(`SELECT status, COUNT(*) FROM request_logs WHERE timestamp >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) GROUP BY status ORDER BY status`, sinceExpr)
if err != nil {
return nil, err
}
defer rows.Close()
var out []StatusCount
for rows.Next() {
var s StatusCount
if err := rows.Scan(&s.Status, &s.Count); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
type HostCount struct {
Host string
Count int
}
func TopHostsSince(db *sql.DB, sinceExpr string, limit int) ([]HostCount, error) {
rows, err := db.Query(`SELECT host, COUNT(*) c FROM request_logs WHERE timestamp >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) GROUP BY host ORDER BY c DESC LIMIT ?`, sinceExpr, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []HostCount
for rows.Next() {
var h HostCount
if err := rows.Scan(&h.Host, &h.Count); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
func TopClientsSince(db *sql.DB, sinceExpr string, limit int) ([]HostCount, error) {
rows, err := db.Query(`SELECT client_ip, COUNT(*) c FROM request_logs WHERE timestamp >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) GROUP BY client_ip ORDER BY c DESC LIMIT ?`, sinceExpr, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []HostCount
for rows.Next() {
var h HostCount
if err := rows.Scan(&h.Host, &h.Count); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
func RecentErrorsSince(db *sql.DB, sinceExpr string, limit int) ([]models.RequestLog, error) {
rows, err := db.Query(`SELECT id, timestamp, proxy_host_id, proxy_host_name, client_ip, method, host, path, status,
response_size, backend, response_time_ms, user_agent FROM request_logs
WHERE status >= 400 AND timestamp >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) ORDER BY id DESC LIMIT ?`, sinceExpr, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.RequestLog
for rows.Next() {
var l models.RequestLog
var ts string
var hostID sql.NullInt64
if err := rows.Scan(&l.ID, &ts, &hostID, &l.ProxyHostName, &l.ClientIP, &l.Method, &l.Host, &l.Path, &l.Status,
&l.ResponseSize, &l.Backend, &l.ResponseTimeMS, &l.UserAgent); err != nil {
return nil, err
}
l.Timestamp = parseTime(ts)
if hostID.Valid {
v := hostID.Int64
l.ProxyHostID = &v
}
out = append(out, l)
}
return out, rows.Err()
}
+242
View File
@@ -0,0 +1,242 @@
package database
import (
"database/sql"
"errors"
"github.com/owen/vibeproxy/internal/models"
)
func ListProxyHosts(db *sql.DB) ([]models.ProxyHost, error) {
rows, err := db.Query(`SELECT id, name, type, listen_addr, forward_scheme, preserve_host_header, websocket_support,
force_https, hsts_enabled, certificate_id, access_list_id, connect_timeout_ms, read_timeout_ms, write_timeout_ms,
idle_timeout_ms, load_balancing_method, redirect_target_url, redirect_code, redirect_preserve_path,
redirect_preserve_qs, enabled, comments, created_at, updated_at FROM proxy_hosts ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ProxyHost
for rows.Next() {
h, err := scanProxyHost(rows)
if err != nil {
return nil, err
}
out = append(out, h)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range out {
if err := hydrateProxyHost(db, &out[i]); err != nil {
return nil, err
}
}
return out, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanProxyHost(rs rowScanner) (models.ProxyHost, error) {
var h models.ProxyHost
var certID, aclID sql.NullInt64
var preserveHost, ws, forceHTTPS, hsts, enabled, preservePath, preserveQS int
var created, updated string
err := rs.Scan(&h.ID, &h.Name, &h.Type, &h.ListenAddr, &h.ForwardScheme, &preserveHost, &ws,
&forceHTTPS, &hsts, &certID, &aclID, &h.ConnectTimeoutMS, &h.ReadTimeoutMS, &h.WriteTimeoutMS,
&h.IdleTimeoutMS, &h.LoadBalancingMethod, &h.RedirectTargetURL, &h.RedirectCode, &preservePath,
&preserveQS, &enabled, &h.Comments, &created, &updated)
if err != nil {
return h, err
}
h.PreserveHostHeader = preserveHost == 1
h.WebsocketSupport = ws == 1
h.ForceHTTPS = forceHTTPS == 1
h.HSTSEnabled = hsts == 1
h.RedirectPreservePath = preservePath == 1
h.RedirectPreserveQS = preserveQS == 1
h.Enabled = enabled == 1
h.CreatedAt = parseTime(created)
h.UpdatedAt = parseTime(updated)
if certID.Valid {
v := certID.Int64
h.CertificateID = &v
}
if aclID.Valid {
v := aclID.Int64
h.AccessListID = &v
}
return h, nil
}
func hydrateProxyHost(db *sql.DB, h *models.ProxyHost) error {
drows, err := db.Query(`SELECT domain FROM proxy_host_domains WHERE proxy_host_id = ? ORDER BY id`, h.ID)
if err != nil {
return err
}
for drows.Next() {
var d string
if err := drows.Scan(&d); err != nil {
drows.Close()
return err
}
h.Domains = append(h.Domains, d)
}
drows.Close()
if err := drows.Err(); err != nil {
return err
}
brows, err := db.Query(`SELECT id, proxy_host_id, host, port, weight, enabled, health_check_type, health_check_path,
health_check_status, interval_seconds, failure_threshold, recovery_threshold FROM backends WHERE proxy_host_id = ? ORDER BY id`, h.ID)
if err != nil {
return err
}
for brows.Next() {
var b models.Backend
var enabled int
if err := brows.Scan(&b.ID, &b.ProxyHostID, &b.Host, &b.Port, &b.Weight, &enabled, &b.HealthCheckType, &b.HealthCheckPath,
&b.HealthCheckStatus, &b.IntervalSeconds, &b.FailureThreshold, &b.RecoveryThreshold); err != nil {
brows.Close()
return err
}
b.Enabled = enabled == 1
b.Healthy = true
h.Backends = append(h.Backends, b)
}
brows.Close()
if err := brows.Err(); err != nil {
return err
}
hrows, err := db.Query(`SELECT id, proxy_host_id, direction, action, name, value FROM custom_headers WHERE proxy_host_id = ? ORDER BY id`, h.ID)
if err != nil {
return err
}
for hrows.Next() {
var c models.CustomHeader
if err := hrows.Scan(&c.ID, &c.ProxyHostID, &c.Direction, &c.Action, &c.Name, &c.Value); err != nil {
hrows.Close()
return err
}
h.CustomHeaders = append(h.CustomHeaders, c)
}
hrows.Close()
return hrows.Err()
}
func GetProxyHost(db *sql.DB, id int64) (*models.ProxyHost, error) {
row := db.QueryRow(`SELECT id, name, type, listen_addr, forward_scheme, preserve_host_header, websocket_support,
force_https, hsts_enabled, certificate_id, access_list_id, connect_timeout_ms, read_timeout_ms, write_timeout_ms,
idle_timeout_ms, load_balancing_method, redirect_target_url, redirect_code, redirect_preserve_path,
redirect_preserve_qs, enabled, comments, created_at, updated_at FROM proxy_hosts WHERE id = ?`, id)
h, err := scanProxyHost(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if err := hydrateProxyHost(db, &h); err != nil {
return nil, err
}
return &h, nil
}
// SaveProxyHost inserts (id==0) or fully replaces (id!=0) a proxy host,
// including its domains, backends and custom headers, in one transaction.
func SaveProxyHost(db *sql.DB, h *models.ProxyHost) (int64, error) {
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
if h.ID == 0 {
res, err := tx.Exec(`INSERT INTO proxy_hosts (name, type, listen_addr, forward_scheme, preserve_host_header,
websocket_support, force_https, hsts_enabled, certificate_id, access_list_id, connect_timeout_ms,
read_timeout_ms, write_timeout_ms, idle_timeout_ms, load_balancing_method, redirect_target_url,
redirect_code, redirect_preserve_path, redirect_preserve_qs, enabled, comments)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
h.Name, h.Type, h.ListenAddr, h.ForwardScheme, boolToInt(h.PreserveHostHeader),
boolToInt(h.WebsocketSupport), boolToInt(h.ForceHTTPS), boolToInt(h.HSTSEnabled), nullInt64(h.CertificateID),
nullInt64(h.AccessListID), h.ConnectTimeoutMS, h.ReadTimeoutMS, h.WriteTimeoutMS, h.IdleTimeoutMS,
h.LoadBalancingMethod, h.RedirectTargetURL, h.RedirectCode, boolToInt(h.RedirectPreservePath),
boolToInt(h.RedirectPreserveQS), boolToInt(h.Enabled), h.Comments)
if err != nil {
return 0, err
}
h.ID, err = res.LastInsertId()
if err != nil {
return 0, err
}
} else {
_, err := tx.Exec(`UPDATE proxy_hosts SET name=?, type=?, listen_addr=?, forward_scheme=?, preserve_host_header=?,
websocket_support=?, force_https=?, hsts_enabled=?, certificate_id=?, access_list_id=?, connect_timeout_ms=?,
read_timeout_ms=?, write_timeout_ms=?, idle_timeout_ms=?, load_balancing_method=?, redirect_target_url=?,
redirect_code=?, redirect_preserve_path=?, redirect_preserve_qs=?, enabled=?, comments=?,
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
h.Name, h.Type, h.ListenAddr, h.ForwardScheme, boolToInt(h.PreserveHostHeader),
boolToInt(h.WebsocketSupport), boolToInt(h.ForceHTTPS), boolToInt(h.HSTSEnabled), nullInt64(h.CertificateID),
nullInt64(h.AccessListID), h.ConnectTimeoutMS, h.ReadTimeoutMS, h.WriteTimeoutMS, h.IdleTimeoutMS,
h.LoadBalancingMethod, h.RedirectTargetURL, h.RedirectCode, boolToInt(h.RedirectPreservePath),
boolToInt(h.RedirectPreserveQS), boolToInt(h.Enabled), h.Comments, h.ID)
if err != nil {
return 0, err
}
if _, err := tx.Exec(`DELETE FROM proxy_host_domains WHERE proxy_host_id = ?`, h.ID); err != nil {
return 0, err
}
if _, err := tx.Exec(`DELETE FROM backends WHERE proxy_host_id = ?`, h.ID); err != nil {
return 0, err
}
if _, err := tx.Exec(`DELETE FROM custom_headers WHERE proxy_host_id = ?`, h.ID); err != nil {
return 0, err
}
}
for _, d := range h.Domains {
if _, err := tx.Exec(`INSERT INTO proxy_host_domains (proxy_host_id, domain) VALUES (?, ?)`, h.ID, d); err != nil {
return 0, err
}
}
for _, b := range h.Backends {
if _, err := tx.Exec(`INSERT INTO backends (proxy_host_id, host, port, weight, enabled, health_check_type,
health_check_path, health_check_status, interval_seconds, failure_threshold, recovery_threshold)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
h.ID, b.Host, b.Port, b.Weight, boolToInt(b.Enabled), b.HealthCheckType, b.HealthCheckPath,
b.HealthCheckStatus, b.IntervalSeconds, b.FailureThreshold, b.RecoveryThreshold); err != nil {
return 0, err
}
}
for _, c := range h.CustomHeaders {
if _, err := tx.Exec(`INSERT INTO custom_headers (proxy_host_id, direction, action, name, value) VALUES (?,?,?,?,?)`,
h.ID, c.Direction, c.Action, c.Name, c.Value); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, err
}
return h.ID, nil
}
func DeleteProxyHost(db *sql.DB, id int64) error {
_, err := db.Exec(`DELETE FROM proxy_hosts WHERE id = ?`, id)
return err
}
func SetProxyHostEnabled(db *sql.DB, id int64, enabled bool) error {
_, err := db.Exec(`UPDATE proxy_hosts SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, boolToInt(enabled), id)
return err
}
func nullInt64(v *int64) any {
if v == nil {
return nil
}
return *v
}
+35
View File
@@ -0,0 +1,35 @@
package database
import "database/sql"
// GetSetting returns the raw string value for key, or def if unset.
func GetSetting(db *sql.DB, key, def string) string {
var v string
err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if err != nil {
return def
}
return v
}
func SetSetting(db *sql.DB, key, value string) error {
_, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
func AllSettings(db *sql.DB) (map[string]string, error) {
rows, err := db.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = v
}
return out, rows.Err()
}
+85
View File
@@ -0,0 +1,85 @@
package database
import (
"database/sql"
"errors"
"github.com/owen/vibeproxy/internal/models"
)
func scanStream(rs rowScanner) (models.Stream, error) {
var s models.Stream
var proxyProto, enabled int
var created, updated string
err := rs.Scan(&s.ID, &s.Protocol, &s.ListenIP, &s.ListenPort, &s.DestinationHost, &s.DestinationPort,
&s.ConnectTimeoutMS, &s.IdleTimeoutMS, &proxyProto, &enabled, &s.Comments, &created, &updated)
if err != nil {
return s, err
}
s.ProxyProtocol = proxyProto == 1
s.Enabled = enabled == 1
s.CreatedAt = parseTime(created)
s.UpdatedAt = parseTime(updated)
return s, nil
}
const streamCols = `id, protocol, listen_ip, listen_port, destination_host, destination_port, connect_timeout_ms, idle_timeout_ms, proxy_protocol, enabled, comments, created_at, updated_at`
func ListStreams(db *sql.DB) ([]models.Stream, error) {
rows, err := db.Query(`SELECT ` + streamCols + ` FROM streams ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Stream
for rows.Next() {
s, err := scanStream(rows)
if err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func GetStream(db *sql.DB, id int64) (*models.Stream, error) {
row := db.QueryRow(`SELECT `+streamCols+` FROM streams WHERE id = ?`, id)
s, err := scanStream(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &s, nil
}
func SaveStream(db *sql.DB, s *models.Stream) (int64, error) {
if s.ID == 0 {
res, err := db.Exec(`INSERT INTO streams (protocol, listen_ip, listen_port, destination_host, destination_port,
connect_timeout_ms, idle_timeout_ms, proxy_protocol, enabled, comments) VALUES (?,?,?,?,?,?,?,?,?,?)`,
s.Protocol, s.ListenIP, s.ListenPort, s.DestinationHost, s.DestinationPort, s.ConnectTimeoutMS,
s.IdleTimeoutMS, boolToInt(s.ProxyProtocol), boolToInt(s.Enabled), s.Comments)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
s.ID = id
return id, err
}
_, err := db.Exec(`UPDATE streams SET protocol=?, listen_ip=?, listen_port=?, destination_host=?, destination_port=?,
connect_timeout_ms=?, idle_timeout_ms=?, proxy_protocol=?, enabled=?, comments=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id=?`, s.Protocol, s.ListenIP, s.ListenPort, s.DestinationHost, s.DestinationPort, s.ConnectTimeoutMS,
s.IdleTimeoutMS, boolToInt(s.ProxyProtocol), boolToInt(s.Enabled), s.Comments, s.ID)
return s.ID, err
}
func DeleteStream(db *sql.DB, id int64) error {
_, err := db.Exec(`DELETE FROM streams WHERE id = ?`, id)
return err
}
func SetStreamEnabled(db *sql.DB, id int64, enabled bool) error {
_, err := db.Exec(`UPDATE streams SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, boolToInt(enabled), id)
return err
}
+77
View File
@@ -0,0 +1,77 @@
package database
import (
"database/sql"
"errors"
"github.com/owen/vibeproxy/internal/models"
)
func CreateAPIToken(db *sql.DB, name, tokenHash, prefix string) (int64, error) {
res, err := db.Exec(`INSERT INTO api_tokens (name, token_hash, prefix) VALUES (?, ?, ?)`, name, tokenHash, prefix)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func ListAPITokens(db *sql.DB) ([]models.APIToken, error) {
rows, err := db.Query(`SELECT id, name, token_hash, prefix, created_at, last_used_at, revoked_at FROM api_tokens ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.APIToken
for rows.Next() {
var t models.APIToken
var created string
var lastUsed, revoked sql.NullString
if err := rows.Scan(&t.ID, &t.Name, &t.TokenHash, &t.Prefix, &created, &lastUsed, &revoked); err != nil {
return nil, err
}
t.CreatedAt = parseTime(created)
if lastUsed.Valid {
tt := parseTime(lastUsed.String)
t.LastUsedAt = &tt
}
if revoked.Valid {
tt := parseTime(revoked.String)
t.RevokedAt = &tt
}
out = append(out, t)
}
return out, rows.Err()
}
func GetAPITokenByHash(db *sql.DB, hash string) (*models.APIToken, error) {
row := db.QueryRow(`SELECT id, name, token_hash, prefix, created_at, last_used_at, revoked_at FROM api_tokens WHERE token_hash = ?`, hash)
var t models.APIToken
var created string
var lastUsed, revoked sql.NullString
if err := row.Scan(&t.ID, &t.Name, &t.TokenHash, &t.Prefix, &created, &lastUsed, &revoked); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
t.CreatedAt = parseTime(created)
if lastUsed.Valid {
tt := parseTime(lastUsed.String)
t.LastUsedAt = &tt
}
if revoked.Valid {
tt := parseTime(revoked.String)
t.RevokedAt = &tt
}
return &t, nil
}
func TouchAPIToken(db *sql.DB, id int64) error {
_, err := db.Exec(`UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, id)
return err
}
func RevokeAPIToken(db *sql.DB, id int64) error {
_, err := db.Exec(`UPDATE api_tokens SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ? AND revoked_at IS NULL`, id)
return err
}
+58
View File
@@ -0,0 +1,58 @@
package database
import (
"database/sql"
"errors"
"time"
"github.com/owen/vibeproxy/internal/models"
)
var ErrNotFound = errors.New("not found")
const timeFmt = "2006-01-02T15:04:05.000Z"
func parseTime(s string) time.Time {
t, _ := time.Parse(timeFmt, s)
return t
}
func GetUserByUsername(db *sql.DB, username string) (*models.User, error) {
row := db.QueryRow(`SELECT id, username, password_hash, must_change_pw, created_at, updated_at FROM users WHERE username = ?`, username)
u := &models.User{}
var mustChange int
var created, updated string
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &mustChange, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
u.MustChangePW = mustChange == 1
u.CreatedAt = parseTime(created)
u.UpdatedAt = parseTime(updated)
return u, nil
}
func CountUsers(db *sql.DB) (int, error) {
var n int
err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
func CreateUser(db *sql.DB, username, passwordHash string, mustChange bool) error {
_, err := db.Exec(`INSERT INTO users (username, password_hash, must_change_pw) VALUES (?, ?, ?)`, username, passwordHash, boolToInt(mustChange))
return err
}
func UpdateUserPassword(db *sql.DB, username, passwordHash string, mustChange bool) error {
_, err := db.Exec(`UPDATE users SET password_hash = ?, must_change_pw = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE username = ?`, passwordHash, boolToInt(mustChange), username)
return err
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}