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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
¬Before, ¬After, &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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user