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
+6
View File
@@ -0,0 +1,6 @@
/vibeproxy
/data/
*.db
*.db-wal
*.db-shm
/dist/
+22
View File
@@ -0,0 +1,22 @@
FROM golang:1.25-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" -o /out/vibeproxy ./cmd/vibeproxy
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/vibeproxy /usr/local/bin/vibeproxy
VOLUME ["/data"]
# The nonroot container user can't bind ports <1024, so vibeproxy listens
# on 8080/8443 inside the container; map host 80/443 to these in
# docker-compose.yml or `docker run -p`.
EXPOSE 8080 8443 8081
ENV VIBEPROXY_DATA_DIR=/data \
VIBEPROXY_DB_PATH=/data/vibeproxy.db \
VIBEPROXY_HTTP_ADDR=:8080 \
VIBEPROXY_HTTPS_ADDR=:8443 \
VIBEPROXY_ADMIN_ADDR=:8081
ENTRYPOINT ["/usr/local/bin/vibeproxy"]
CMD ["serve"]
+188
View File
@@ -0,0 +1,188 @@
# vibeproxy
A small, self-contained reverse proxy manager, written in Go. Think
"Nginx Proxy Manager, but simpler and with no external dependencies" —
`vibeproxy` handles HTTP/HTTPS reverse proxying, TCP/UDP streams, TLS
termination, and Let's Encrypt certificates itself, using Go's standard
networking stack (`net/http`, `httputil.ReverseProxy`, `crypto/tls`,
`net`). It never shells out to nginx, HAProxy, or Caddy.
The admin UI is server-rendered Go templates + Bootstrap 5 + a little
vanilla JavaScript — no React/Vue/Node build step. Everything (templates,
CSS, JS, SQL migrations) is embedded into a single binary via `go:embed`,
so deployment is just:
```
./vibeproxy serve
```
## Features
- HTTP and HTTPS reverse proxying, routed by Host header / SNI
- Redirect hosts (301/302/307/308, with path & query preservation)
- TCP and UDP stream proxying, with optional PROXY protocol v1 to backends
- Multiple backends per proxy host: round robin, least-connections, or
weighted load balancing, with TCP/HTTP health checks and configurable
failure/recovery thresholds — unhealthy backends never receive traffic
- TLS termination, HTTP/2, WebSockets, keep-alive, streaming responses
- Let's Encrypt (HTTP-01 and DNS-01, via the [lego](https://github.com/go-acme/lego)
ACME client library) plus custom/uploaded certificates, with automatic
renewal
- Reusable Access Lists (CIDR allow/deny + optional HTTP Basic Auth)
- Per-host custom request/response headers
- Configurable trusted proxy networks — `X-Forwarded-For` is only trusted
from networks you explicitly configure
- SQLite storage (WAL mode, foreign keys, migrations) with the active
configuration cached in memory — the hot request path never touches the
database, and configuration changes apply live without a restart
- REST API under `/api/v1`, authenticated with revocable API tokens
- Single administrator account (HTTP Basic Auth, Argon2id password
hashing, CSRF-protected forms)
- Request logs (with configurable retention) and an audit log of every
configuration change
- Prometheus metrics, `/healthz`, `/readyz`, graceful shutdown, automatic
and on-demand SQLite backups
## Quick start
```
go build -o vibeproxy ./cmd/vibeproxy
sudo setcap 'cap_net_bind_service=+ep' ./vibeproxy # to bind :80/:443 without root
./vibeproxy serve
```
On first run, `vibeproxy` creates the `admin` account and prints a random
temporary password to stdout — save it, it's not shown again. Change it
under **Settings → Administrator Account** after logging in.
By default:
- `:80` and `:443` — proxy traffic (HTTP/HTTPS reverse proxy + redirect
hosts + Let's Encrypt HTTP-01 challenges)
- `:8081` — admin web UI, REST API, `/healthz`, `/readyz`, `/metrics`.
**Put this behind a firewall or VPN** — Basic Auth protects it, but it
shouldn't be exposed to the internet.
All of this is configurable via flags or environment variables:
| Flag | Env var | Default | Purpose |
|---|---|---|---|
| `--data-dir` | `VIBEPROXY_DATA_DIR` | `./data` | database, certs, backups |
| `--db-path` | `VIBEPROXY_DB_PATH` | `./data/vibeproxy.db` | SQLite file |
| `--http-addr` | `VIBEPROXY_HTTP_ADDR` | `:80` | HTTP proxy listener |
| `--https-addr` | `VIBEPROXY_HTTPS_ADDR` | `:443` | HTTPS proxy listener |
| `--admin-addr` | `VIBEPROXY_ADMIN_ADDR` | `:8081` | admin UI + API |
Everything else (ACME email, renewal window, log retention, trusted proxy
CIDRs, backup schedule, ...) is runtime configuration stored in SQLite and
editable from **Settings**, so it takes effect without a restart.
## CLI
```
vibeproxy serve # run the proxy, admin UI, and API
vibeproxy version # print the version
vibeproxy config check # validate config + run migrations, then exit
vibeproxy admin reset-password # generate a new admin password
vibeproxy database backup # write an on-demand SQLite backup
vibeproxy database migrate # apply pending migrations
vibeproxy certificates renew # renew any due Let's Encrypt certs
```
## systemd
```
sudo useradd --system --home /var/lib/vibeproxy --shell /usr/sbin/nologin vibeproxy
sudo cp vibeproxy /usr/local/bin/vibeproxy
sudo cp systemd/vibeproxy.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now vibeproxy
sudo journalctl -u vibeproxy -f # first run prints the admin password here
```
The unit grants `CAP_NET_BIND_SERVICE` so `vibeproxy` can bind `:80`/`:443`
without running as root, and sandboxes the process with `ProtectSystem`,
`ProtectHome`, `PrivateDevices`, etc. It stores everything under
`/var/lib/vibeproxy` (mode `0700`).
## Docker
```
docker compose up -d
```
See `docker-compose.yml` / `Dockerfile`. The container runs as a
non-root user, so it listens on `8080`/`8443` internally — `docker-compose.yml`
maps host `80`/`443` to those.
## REST API
All endpoints are under `/api/v1` on the admin listener, authenticated
with a bearer token (create one from **API** in the UI — it's shown once):
```
curl -H "Authorization: Bearer vp_xxxxxxxx" \
http://localhost:8081/api/v1/proxy-hosts
```
Resources: `proxy-hosts`, `streams`, `certificates`, `access-lists`,
`settings`, `logs/requests`, `logs/audit`, `stats`, `tokens`. See
`internal/api` for the full route list.
## Project layout
```
cmd/vibeproxy/ CLI entry point (cobra commands)
internal/
api/ REST API (/api/v1)
app/ wires every subsystem together for `serve`
auth/ password hashing, Basic Auth, API tokens, CSRF
access/ CIDR access lists + Basic Auth evaluation
backup/ SQLite backup/restore
certificates/ ACME (via lego) + custom cert storage & renewal
config/ process bootstrap flags/env
database/ SQLite connection, migrations, repositories
health/ backend health checking
loadbalancer/ backend pools: round robin / least-conn / weighted
logging/ buffered request logging + audit log + retention
metrics/ Prometheus collectors
models/ domain types shared across packages
proxy/ the HTTP/HTTPS reverse proxy engine
runtime/ in-memory active-configuration snapshot
stats/ dashboard/API statistics
streams/ TCP/UDP proxying
web/ server-rendered admin UI (Go templates + embedded assets)
templates/, static/
systemd/ vibeproxy.service
```
## Design notes
- **Hot path stays out of SQLite.** `internal/runtime` holds an
immutable, atomically-swapped snapshot of proxy hosts, backends, access
lists, and settings. Every proxied request/connection reads that
snapshot; database writes only happen when an admin changes
configuration, at which point `Store.Reload` rebuilds the snapshot.
- **`X-Forwarded-For` is not trusted by default.** The real client IP is
taken from the TCP peer address unless the peer is inside a configured
"trusted proxy" CIDR (Settings → Trusted Proxy Networks), in which case
the right-most untrusted entry in `X-Forwarded-For` is used instead.
- **Certificates never touch logs.** Private keys are written to disk
with `0600` permissions (directories `0700`) and are never logged or
returned by the API.
- **Reloads don't drop connections.** Stream (TCP/UDP) reconciliation
diffs the desired vs. running listener set and only restarts streams
whose configuration actually changed; unaffected streams, and all
in-flight HTTP requests on the previous configuration snapshot, are
left alone.
## Testing
```
go test ./...
```
Covers load-balancing strategies and health-check thresholds, access-list
CIDR evaluation, password/API-token hashing, SQLite migrations and
proxy-host CRUD round-trips, an end-to-end TCP stream proxy test, and a
render-smoke-test for every admin UI page.
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"flag"
"fmt"
"github.com/spf13/cobra"
"github.com/owen/vibeproxy/internal/app"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/config"
"github.com/owen/vibeproxy/internal/database"
)
func newAdminCmd() *cobra.Command {
root := &cobra.Command{Use: "admin", Short: "Administrator account utilities"}
root.AddCommand(newAdminResetPasswordCmd())
return root
}
func newAdminResetPasswordCmd() *cobra.Command {
cfg := config.Default()
cmd := &cobra.Command{
Use: "reset-password",
Short: "Generate a new random password for the admin account",
RunE: func(cmd *cobra.Command, args []string) error {
a, err := app.Open(cfg, logger)
if err != nil {
return err
}
defer a.Close()
password, err := auth.GenerateRandomPassword()
if err != nil {
return err
}
hash, err := auth.HashPassword(password)
if err != nil {
return err
}
if err := database.UpdateUserPassword(a.DB, "admin", hash, true); err != nil {
return err
}
database.InsertAuditLog(a.DB, "cli", "password_changed", "admin", "password reset via CLI")
fmt.Println("New admin password:")
fmt.Println(password)
return nil
},
}
fs := flag.NewFlagSet("admin-reset-password", flag.ContinueOnError)
config.RegisterFlags(fs, &cfg)
cmd.Flags().AddGoFlagSet(fs)
return cmd
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/spf13/cobra"
"github.com/owen/vibeproxy/internal/app"
"github.com/owen/vibeproxy/internal/config"
)
func newCertificatesCmd() *cobra.Command {
root := &cobra.Command{Use: "certificates", Short: "Certificate utilities"}
root.AddCommand(newCertificatesRenewCmd())
return root
}
func newCertificatesRenewCmd() *cobra.Command {
cfg := config.Default()
cmd := &cobra.Command{
Use: "renew",
Short: "Renew any Let's Encrypt certificates within their renewal window",
RunE: func(cmd *cobra.Command, args []string) error {
a, err := app.Open(cfg, logger)
if err != nil {
return err
}
defer a.Close()
settings := a.Store.Current().Settings
n, err := a.Certs.RenewDue(context.Background(), settings.ACMERenewalWindowDays, settings.ACMEEmail, settings.ACMEStaging)
if err != nil {
return err
}
fmt.Printf("renewed %d certificate(s)\n", n)
return nil
},
}
fs := flag.NewFlagSet("certificates-renew", flag.ContinueOnError)
config.RegisterFlags(fs, &cfg)
cmd.Flags().AddGoFlagSet(fs)
return cmd
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"flag"
"fmt"
"github.com/spf13/cobra"
"github.com/owen/vibeproxy/internal/app"
"github.com/owen/vibeproxy/internal/config"
)
func newConfigCmd() *cobra.Command {
root := &cobra.Command{Use: "config", Short: "Configuration utilities"}
root.AddCommand(newConfigCheckCmd())
return root
}
func newConfigCheckCmd() *cobra.Command {
cfg := config.Default()
cmd := &cobra.Command{
Use: "check",
Short: "Validate configuration and database migrations without starting listeners",
RunE: func(cmd *cobra.Command, args []string) error {
a, err := app.Open(cfg, logger)
if err != nil {
return fmt.Errorf("configuration check failed: %w", err)
}
defer a.Close()
fmt.Println("OK: database opens, migrations applied, and initial configuration loaded successfully.")
return nil
},
}
fs := flag.NewFlagSet("config-check", flag.ContinueOnError)
config.RegisterFlags(fs, &cfg)
cmd.Flags().AddGoFlagSet(fs)
return cmd
}
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"flag"
"fmt"
"github.com/spf13/cobra"
"github.com/owen/vibeproxy/internal/app"
"github.com/owen/vibeproxy/internal/backup"
"github.com/owen/vibeproxy/internal/config"
)
func newDatabaseCmd() *cobra.Command {
root := &cobra.Command{Use: "database", Short: "Database utilities"}
root.AddCommand(newDatabaseBackupCmd(), newDatabaseMigrateCmd())
return root
}
func newDatabaseBackupCmd() *cobra.Command {
cfg := config.Default()
var outDir string
cmd := &cobra.Command{
Use: "backup",
Short: "Create an on-demand SQLite backup",
RunE: func(cmd *cobra.Command, args []string) error {
a, err := app.Open(cfg, logger)
if err != nil {
return err
}
defer a.Close()
if outDir == "" {
outDir = cfg.DataDir + "/backups"
}
path, err := backup.Run(a.DB, outDir, 0)
if err != nil {
return err
}
fmt.Println("backup written to " + path)
return nil
},
}
fs := flag.NewFlagSet("database-backup", flag.ContinueOnError)
config.RegisterFlags(fs, &cfg)
cmd.Flags().AddGoFlagSet(fs)
cmd.Flags().StringVar(&outDir, "out", "", "backup output directory (default: <data-dir>/backups)")
return cmd
}
func newDatabaseMigrateCmd() *cobra.Command {
cfg := config.Default()
cmd := &cobra.Command{
Use: "migrate",
Short: "Apply any pending database migrations",
RunE: func(cmd *cobra.Command, args []string) error {
a, err := app.Open(cfg, logger) // Open() runs migrations
if err != nil {
return err
}
defer a.Close()
fmt.Println("database is up to date")
return nil
},
}
fs := flag.NewFlagSet("database-migrate", flag.ContinueOnError)
config.RegisterFlags(fs, &cfg)
cmd.Flags().AddGoFlagSet(fs)
return cmd
}
+32
View File
@@ -0,0 +1,32 @@
// Command vibeproxy is a self-contained HTTP/HTTPS/TCP/UDP reverse proxy
// manager with a Bootstrap 5 web UI, SQLite storage, and a REST API.
package main
import (
"fmt"
"log/slog"
"os"
"github.com/spf13/cobra"
)
var logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
func main() {
root := &cobra.Command{
Use: "vibeproxy",
Short: "vibeproxy is a self-contained reverse proxy manager",
}
root.AddCommand(
newServeCmd(),
newVersionCmd(),
newConfigCmd(),
newAdminCmd(),
newDatabaseCmd(),
newCertificatesCmd(),
)
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"context"
"flag"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"github.com/owen/vibeproxy/internal/app"
"github.com/owen/vibeproxy/internal/config"
)
func newServeCmd() *cobra.Command {
cfg := config.Default()
cmd := &cobra.Command{
Use: "serve",
Short: "Run vibeproxy: HTTP/HTTPS/TCP/UDP proxying, web UI, and REST API",
RunE: func(cmd *cobra.Command, args []string) error {
a, err := app.Open(cfg, logger)
if err != nil {
return err
}
defer a.Close()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
logger.Info("vibeproxy starting",
"http", cfg.HTTPAddr, "https", cfg.HTTPSAddr, "admin", cfg.AdminAddr)
return a.Run(ctx)
},
}
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
config.RegisterFlags(fs, &cfg)
cmd.Flags().AddGoFlagSet(fs)
return cmd
}
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
// Version is set via -ldflags "-X main.Version=..." at release build time.
var Version = "dev"
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the vibeproxy version",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("vibeproxy " + Version)
return nil
},
}
}
+29
View File
@@ -0,0 +1,29 @@
services:
vibeproxy:
build:
context: .
args:
VERSION: dev
# Or, once published: image: ghcr.io/owen/vibeproxy:latest
container_name: vibeproxy
restart: unless-stopped
ports:
- "80:8080" # HTTP proxy traffic
- "443:8443" # HTTPS proxy traffic
- "8081:8081" # Admin web UI + REST API (keep behind a firewall / VPN)
volumes:
- vibeproxy-data:/data
environment:
VIBEPROXY_DATA_DIR: /data
VIBEPROXY_DB_PATH: /data/vibeproxy.db
VIBEPROXY_HTTP_ADDR: ":8080"
VIBEPROXY_HTTPS_ADDR: ":8443"
VIBEPROXY_ADMIN_ADDR: ":8081"
healthcheck:
test: ["CMD", "/usr/local/bin/vibeproxy", "version"]
interval: 30s
timeout: 5s
retries: 3
volumes:
vibeproxy-data:
+40
View File
@@ -0,0 +1,40 @@
module github.com/owen/vibeproxy
go 1.25.0
require (
github.com/go-acme/lego/v4 v4.35.2
github.com/prometheus/client_golang v1.24.1
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.56.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+108
View File
@@ -0,0 +1,108 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+57
View File
@@ -0,0 +1,57 @@
// Package access evaluates reusable access lists: CIDR allow/deny rules
// plus an optional HTTP Basic Auth user set.
package access
import (
"net"
"sort"
"github.com/owen/vibeproxy/internal/models"
)
type rule struct {
allow bool
net *net.IPNet
prio int
}
// List is the runtime-evaluatable form of a models.AccessList.
type List struct {
ID int64
Name string
DefaultAllow bool
rules []rule
Users map[string]string // username -> argon2id hash
}
func Compile(m models.AccessList) *List {
l := &List{ID: m.ID, Name: m.Name, DefaultAllow: m.DefaultAllow, Users: map[string]string{}}
for _, r := range m.Rules {
_, ipnet, err := net.ParseCIDR(r.CIDR)
if err != nil {
continue
}
l.rules = append(l.rules, rule{allow: r.Action == models.AccessAllow, net: ipnet, prio: r.Priority})
}
sort.SliceStable(l.rules, func(i, j int) bool { return l.rules[i].prio < l.rules[j].prio })
for _, u := range m.BasicAuthUsers {
l.Users[u.Username] = u.PasswordHash
}
return l
}
// Allowed reports whether ip is permitted by this list's CIDR rules. The
// first matching rule (in priority order) wins; otherwise the default
// action applies.
func (l *List) Allowed(ip net.IP) bool {
for _, r := range l.rules {
if r.net.Contains(ip) {
return r.allow
}
}
return l.DefaultAllow
}
// RequiresAuth reports whether this list has HTTP Basic Auth users
// configured, meaning IP-allowed requests must still authenticate.
func (l *List) RequiresAuth() bool { return len(l.Users) > 0 }
+49
View File
@@ -0,0 +1,49 @@
package access
import (
"net"
"testing"
"github.com/owen/vibeproxy/internal/models"
)
func TestAccessListAllowDeny(t *testing.T) {
m := models.AccessList{
DefaultAllow: false,
Rules: []models.AccessRule{
{Action: models.AccessAllow, CIDR: "10.0.0.0/8", Priority: 0},
{Action: models.AccessDeny, CIDR: "10.1.0.0/16", Priority: 1},
},
}
l := Compile(m)
cases := []struct {
ip string
want bool
}{
{"10.2.3.4", true}, // matches allow 10.0.0.0/8 first
{"192.168.1.1", false}, // no match, default deny
}
for _, c := range cases {
got := l.Allowed(net.ParseIP(c.ip))
if got != c.want {
t.Errorf("Allowed(%s) = %v, want %v", c.ip, got, c.want)
}
}
}
func TestAccessListDefaultAllow(t *testing.T) {
m := models.AccessList{DefaultAllow: true}
l := Compile(m)
if !l.Allowed(net.ParseIP("8.8.8.8")) {
t.Fatal("expected default allow to permit unmatched IP")
}
}
func TestAccessListRequiresAuth(t *testing.T) {
m := models.AccessList{BasicAuthUsers: []models.AccessListUser{{Username: "bob", PasswordHash: "x"}}}
l := Compile(m)
if !l.RequiresAuth() {
t.Fatal("expected RequiresAuth to be true when users are configured")
}
}
+106
View File
@@ -0,0 +1,106 @@
package api
import (
"fmt"
"net/http"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/models"
)
func (a *API) listAccessLists(w http.ResponseWriter, r *http.Request) {
lists, err := database.ListAccessLists(a.db)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, lists)
}
func (a *API) getAccessList(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
l, err := database.GetAccessList(a.db, id)
if err != nil {
writeError(w, http.StatusNotFound, "access list not found")
return
}
writeJSON(w, http.StatusOK, l)
}
func (a *API) createAccessList(w http.ResponseWriter, r *http.Request) {
var l models.AccessList
if !decodeJSON(w, r, &l) {
return
}
if err := validateAccessList(&l); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
l.ID = 0
id, err := database.SaveAccessList(a.db, &l)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.reload(r)
audit(a.db, r, "access_list_changed", l.Name, fmt.Sprintf("id=%d created", id))
writeJSON(w, http.StatusCreated, l)
}
func (a *API) updateAccessList(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var l models.AccessList
if !decodeJSON(w, r, &l) {
return
}
l.ID = id
if err := validateAccessList(&l); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if _, err := database.SaveAccessList(a.db, &l); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.reload(r)
audit(a.db, r, "access_list_changed", l.Name, fmt.Sprintf("id=%d updated", id))
writeJSON(w, http.StatusOK, l)
}
func (a *API) deleteAccessList(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := database.DeleteAccessList(a.db, id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.reload(r)
audit(a.db, r, "access_list_changed", fmt.Sprintf("id=%d", id), "deleted")
w.WriteHeader(http.StatusNoContent)
}
func validateAccessList(l *models.AccessList) error {
if l.Name == "" {
return fmt.Errorf("name is required")
}
for i, rule := range l.Rules {
if rule.Action != models.AccessAllow && rule.Action != models.AccessDeny {
return fmt.Errorf("rule %d: action must be allow or deny", i)
}
if rule.CIDR == "" {
return fmt.Errorf("rule %d: CIDR is required", i)
}
}
return nil
}
+153
View File
@@ -0,0 +1,153 @@
package api
import (
"fmt"
"net/http"
"github.com/owen/vibeproxy/internal/certificates"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/models"
)
func (a *API) listCertificates(w http.ResponseWriter, r *http.Request) {
certs, err := database.ListCertificates(a.db)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, certs)
}
func (a *API) getCertificate(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
c, err := database.GetCertificate(a.db, id)
if err != nil {
writeError(w, http.StatusNotFound, "certificate not found")
return
}
writeJSON(w, http.StatusOK, c)
}
type issueCertRequest struct {
Name string `json:"name"`
Domains []string `json:"domains"`
Email string `json:"email"`
Staging bool `json:"staging"`
ChallengeType models.ChallengeType `json:"challenge_type"`
DNSProvider string `json:"dns_provider"`
}
func (a *API) issueCertificate(w http.ResponseWriter, r *http.Request) {
var req issueCertRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" || len(req.Domains) == 0 {
writeError(w, http.StatusBadRequest, "name and domains are required")
return
}
if req.ChallengeType == "" {
req.ChallengeType = models.ChallengeHTTP01
}
settings := a.store.Current().Settings
email := req.Email
if email == "" {
email = settings.ACMEEmail
}
cert, err := a.certs.IssueLetsEncrypt(r.Context(), certOpts(req, email))
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
audit(a.db, r, "certificate_issued", cert.Name, fmt.Sprintf("domains=%v challenge=%s", cert.Domains, cert.ChallengeType))
writeJSON(w, http.StatusCreated, cert)
}
func certOpts(req issueCertRequest, email string) certificates.IssueLetsEncryptOptions {
return certificates.IssueLetsEncryptOptions{
Name: req.Name,
Domains: req.Domains,
Email: email,
Staging: req.Staging,
ChallengeType: req.ChallengeType,
DNSProvider: req.DNSProvider,
}
}
func (a *API) uploadCertificate(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Domains []string `json:"domains"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
}
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" || req.CertPEM == "" || req.KeyPEM == "" {
writeError(w, http.StatusBadRequest, "name, cert_pem, and key_pem are required")
return
}
cert, err := a.certs.UploadCustom(req.Name, req.Domains, []byte(req.CertPEM), []byte(req.KeyPEM))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
audit(a.db, r, "certificate_issued", cert.Name, "custom certificate uploaded")
writeJSON(w, http.StatusCreated, cert)
}
func (a *API) renewCertificate(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
c, err := database.GetCertificate(a.db, id)
if err != nil {
writeError(w, http.StatusNotFound, "certificate not found")
return
}
if c.Type != models.CertTypeLetsEncrypt {
writeError(w, http.StatusBadRequest, "only Let's Encrypt certificates can be renewed")
return
}
settings := a.store.Current().Settings
updated, err := a.certs.IssueLetsEncrypt(r.Context(), certOptsFromModel(*c, settings.ACMEEmail, settings.ACMEStaging))
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
audit(a.db, r, "certificate_renewed", updated.Name, "")
writeJSON(w, http.StatusOK, updated)
}
func certOptsFromModel(c models.Certificate, email string, staging bool) certificates.IssueLetsEncryptOptions {
return certificates.IssueLetsEncryptOptions{
Name: c.Name,
Domains: c.Domains,
Email: email,
Staging: staging,
ChallengeType: c.ChallengeType,
DNSProvider: c.DNSProvider,
}
}
func (a *API) deleteCertificate(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := database.DeleteCertificate(a.db, id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.certs.LoadAll()
audit(a.db, r, "certificate_changed", fmt.Sprintf("id=%d", id), "deleted")
w.WriteHeader(http.StatusNoContent)
}
+63
View File
@@ -0,0 +1,63 @@
// Package api implements the REST API under /api/v1, authenticated with
// bearer API tokens (separate from the web UI's Basic Auth).
package api
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/logging"
)
const maxJSONBody = 10 << 20 // 10 MiB is plenty for config payloads
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return false
}
return true
}
func pathID(r *http.Request) (int64, error) {
return strconv.ParseInt(r.PathValue("id"), 10, 64)
}
func audit(db *sql.DB, r *http.Request, action, target, details string) {
logging.Audit(db, auth.ActorFromContext(r.Context()), action, target, details)
}
func queryInt(r *http.Request, key string, def int) int {
v := r.URL.Query().Get(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
func nilIfEmptyID(id int64) *int64 {
if id == 0 {
return nil
}
return &id
}
+37
View File
@@ -0,0 +1,37 @@
package api
import (
"net/http"
"github.com/owen/vibeproxy/internal/database"
)
func (a *API) listRequestLogs(w http.ResponseWriter, r *http.Request) {
f := database.LogFilter{
Status: queryInt(r, "status", 0),
Search: r.URL.Query().Get("q"),
Limit: queryInt(r, "limit", 50),
Offset: queryInt(r, "offset", 0),
}
if id := queryInt(r, "proxy_host_id", 0); id > 0 {
id64 := int64(id)
f.ProxyHostID = &id64
}
logs, total, err := database.QueryRequestLogs(a.db, f)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"logs": logs, "total": total})
}
func (a *API) listAuditLogs(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 50)
offset := queryInt(r, "offset", 0)
logs, total, err := database.ListAuditLogs(a.db, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"logs": logs, "total": total})
}
+201
View File
@@ -0,0 +1,201 @@
package api
import (
"fmt"
"net/http"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/models"
)
func (a *API) listProxyHosts(w http.ResponseWriter, r *http.Request) {
hosts, err := database.ListProxyHosts(a.db)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, hosts)
}
func (a *API) getProxyHost(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
h, err := database.GetProxyHost(a.db, id)
if err != nil {
writeError(w, http.StatusNotFound, "proxy host not found")
return
}
writeJSON(w, http.StatusOK, h)
}
func (a *API) createProxyHost(w http.ResponseWriter, r *http.Request) {
var h models.ProxyHost
if !decodeJSON(w, r, &h) {
return
}
if err := validateProxyHost(&h); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
h.ID = 0
id, err := database.SaveProxyHost(a.db, &h)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.reload(r)
audit(a.db, r, "proxy_created", h.Name, fmt.Sprintf("id=%d domains=%v", id, h.Domains))
writeJSON(w, http.StatusCreated, h)
}
func (a *API) updateProxyHost(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var h models.ProxyHost
if !decodeJSON(w, r, &h) {
return
}
h.ID = id
if err := validateProxyHost(&h); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if _, err := database.SaveProxyHost(a.db, &h); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.reload(r)
audit(a.db, r, "proxy_changed", h.Name, fmt.Sprintf("id=%d", id))
writeJSON(w, http.StatusOK, h)
}
func (a *API) deleteProxyHost(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := database.DeleteProxyHost(a.db, id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.reload(r)
audit(a.db, r, "proxy_deleted", fmt.Sprintf("id=%d", id), "")
w.WriteHeader(http.StatusNoContent)
}
func (a *API) enableProxyHost(w http.ResponseWriter, r *http.Request) {
a.setProxyHostEnabled(w, r, true)
}
func (a *API) disableProxyHost(w http.ResponseWriter, r *http.Request) {
a.setProxyHostEnabled(w, r, false)
}
func (a *API) setProxyHostEnabled(w http.ResponseWriter, r *http.Request, enabled bool) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := database.SetProxyHostEnabled(a.db, id, enabled); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.reload(r)
audit(a.db, r, "proxy_changed", fmt.Sprintf("id=%d", id), fmt.Sprintf("enabled=%v", enabled))
w.WriteHeader(http.StatusNoContent)
}
func (a *API) reload(r *http.Request) {
if err := a.store.Reload(r.Context()); err != nil {
a.log.Error("config reload failed", "err", err)
}
}
func validateProxyHost(h *models.ProxyHost) error {
if h.Name == "" {
return fmt.Errorf("name is required")
}
if h.Type == "" {
h.Type = models.ProxyHostTypeProxy
}
if len(h.Domains) == 0 {
return fmt.Errorf("at least one domain is required")
}
if h.Type == models.ProxyHostTypeProxy {
if len(h.Backends) == 0 {
return fmt.Errorf("at least one backend is required for a proxy host")
}
for i, b := range h.Backends {
if b.Host == "" {
return fmt.Errorf("backend %d: host is required", i)
}
if b.Port <= 0 || b.Port > 65535 {
return fmt.Errorf("backend %d: invalid port", i)
}
if b.Weight <= 0 {
h.Backends[i].Weight = 1
}
if b.HealthCheckType == "" {
h.Backends[i].HealthCheckType = models.HealthCheckTCP
}
if b.IntervalSeconds <= 0 {
h.Backends[i].IntervalSeconds = 10
}
if b.FailureThreshold <= 0 {
h.Backends[i].FailureThreshold = 3
}
if b.RecoveryThreshold <= 0 {
h.Backends[i].RecoveryThreshold = 2
}
}
if h.ForwardScheme == "" {
h.ForwardScheme = "http"
}
if h.LoadBalancingMethod == "" {
h.LoadBalancingMethod = "round_robin"
}
} else if h.Type == models.ProxyHostTypeRedirect {
if h.RedirectTargetURL == "" {
return fmt.Errorf("redirect target URL is required")
}
switch h.RedirectCode {
case 301, 302, 307, 308:
default:
h.RedirectCode = 301
}
} else {
return fmt.Errorf("invalid proxy host type %q", h.Type)
}
if h.ConnectTimeoutMS <= 0 {
h.ConnectTimeoutMS = 5000
}
if h.ReadTimeoutMS <= 0 {
h.ReadTimeoutMS = 60000
}
if h.WriteTimeoutMS <= 0 {
h.WriteTimeoutMS = 60000
}
if h.IdleTimeoutMS <= 0 {
h.IdleTimeoutMS = 120000
}
for _, hd := range h.CustomHeaders {
if hd.Name == "" {
return fmt.Errorf("custom header name is required")
}
if hd.Direction != models.HeaderDirectionRequest && hd.Direction != models.HeaderDirectionResponse {
return fmt.Errorf("custom header direction must be request or response")
}
if hd.Action != models.HeaderActionSet && hd.Action != models.HeaderActionRemove {
return fmt.Errorf("custom header action must be set or remove")
}
}
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"database/sql"
"log/slog"
"net/http"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/certificates"
"github.com/owen/vibeproxy/internal/runtime"
"github.com/owen/vibeproxy/internal/streams"
)
type API struct {
db *sql.DB
store *runtime.Store
certs *certificates.Manager
streams *streams.Manager
log *slog.Logger
}
// NewRouter builds the full /api/v1 handler tree, protected by API token
// bearer auth.
func NewRouter(db *sql.DB, store *runtime.Store, certs *certificates.Manager, streamMgr *streams.Manager, log *slog.Logger) http.Handler {
a := &API{db: db, store: store, certs: certs, streams: streamMgr, log: log}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/proxy-hosts", a.listProxyHosts)
mux.HandleFunc("POST /api/v1/proxy-hosts", a.createProxyHost)
mux.HandleFunc("GET /api/v1/proxy-hosts/{id}", a.getProxyHost)
mux.HandleFunc("PUT /api/v1/proxy-hosts/{id}", a.updateProxyHost)
mux.HandleFunc("DELETE /api/v1/proxy-hosts/{id}", a.deleteProxyHost)
mux.HandleFunc("POST /api/v1/proxy-hosts/{id}/enable", a.enableProxyHost)
mux.HandleFunc("POST /api/v1/proxy-hosts/{id}/disable", a.disableProxyHost)
mux.HandleFunc("GET /api/v1/streams", a.listStreams)
mux.HandleFunc("POST /api/v1/streams", a.createStream)
mux.HandleFunc("GET /api/v1/streams/{id}", a.getStream)
mux.HandleFunc("PUT /api/v1/streams/{id}", a.updateStream)
mux.HandleFunc("DELETE /api/v1/streams/{id}", a.deleteStream)
mux.HandleFunc("POST /api/v1/streams/{id}/enable", a.enableStream)
mux.HandleFunc("POST /api/v1/streams/{id}/disable", a.disableStream)
mux.HandleFunc("GET /api/v1/certificates", a.listCertificates)
mux.HandleFunc("POST /api/v1/certificates/letsencrypt", a.issueCertificate)
mux.HandleFunc("POST /api/v1/certificates/custom", a.uploadCertificate)
mux.HandleFunc("GET /api/v1/certificates/{id}", a.getCertificate)
mux.HandleFunc("DELETE /api/v1/certificates/{id}", a.deleteCertificate)
mux.HandleFunc("POST /api/v1/certificates/{id}/renew", a.renewCertificate)
mux.HandleFunc("GET /api/v1/access-lists", a.listAccessLists)
mux.HandleFunc("POST /api/v1/access-lists", a.createAccessList)
mux.HandleFunc("GET /api/v1/access-lists/{id}", a.getAccessList)
mux.HandleFunc("PUT /api/v1/access-lists/{id}", a.updateAccessList)
mux.HandleFunc("DELETE /api/v1/access-lists/{id}", a.deleteAccessList)
mux.HandleFunc("GET /api/v1/settings", a.getSettings)
mux.HandleFunc("PUT /api/v1/settings", a.updateSettings)
mux.HandleFunc("GET /api/v1/tokens", a.listTokens)
mux.HandleFunc("POST /api/v1/tokens", a.createToken)
mux.HandleFunc("DELETE /api/v1/tokens/{id}", a.revokeToken)
mux.HandleFunc("GET /api/v1/logs/requests", a.listRequestLogs)
mux.HandleFunc("GET /api/v1/logs/audit", a.listAuditLogs)
mux.HandleFunc("GET /api/v1/stats", a.getStats)
return auth.APIToken(db)(mux)
}
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"net/http"
"strconv"
"strings"
"github.com/owen/vibeproxy/internal/database"
)
type settingsDTO struct {
ACMEEmail string `json:"acme_email"`
ACMEStaging bool `json:"acme_staging"`
ACMERenewalWindowDays int `json:"acme_renewal_window_days"`
CertStoragePath string `json:"cert_storage_path"`
LogRetentionDays int `json:"log_retention_days"`
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs"`
BackupEnabled bool `json:"backup_enabled"`
BackupIntervalHours int `json:"backup_interval_hours"`
BackupRetentionCount int `json:"backup_retention_count"`
BackupPath string `json:"backup_path"`
}
func (a *API) getSettings(w http.ResponseWriter, r *http.Request) {
s := a.store.Current().Settings
writeJSON(w, http.StatusOK, settingsDTO{
ACMEEmail: s.ACMEEmail,
ACMEStaging: s.ACMEStaging,
ACMERenewalWindowDays: s.ACMERenewalWindowDays,
CertStoragePath: s.CertStoragePath,
LogRetentionDays: s.LogRetentionDays,
TrustedProxyCIDRs: s.TrustedProxyCIDRs,
BackupEnabled: s.BackupEnabled,
BackupIntervalHours: s.BackupIntervalHours,
BackupRetentionCount: s.BackupRetentionCount,
BackupPath: s.BackupPath,
})
}
func (a *API) updateSettings(w http.ResponseWriter, r *http.Request) {
var dto settingsDTO
if !decodeJSON(w, r, &dto) {
return
}
kv := map[string]string{
"acme_email": dto.ACMEEmail,
"acme_staging": strconv.FormatBool(dto.ACMEStaging),
"acme_renewal_window_days": strconv.Itoa(dto.ACMERenewalWindowDays),
"cert_storage_path": dto.CertStoragePath,
"log_retention_days": strconv.Itoa(dto.LogRetentionDays),
"trusted_proxy_cidrs": strings.Join(dto.TrustedProxyCIDRs, ","),
"backup_enabled": strconv.FormatBool(dto.BackupEnabled),
"backup_interval_hours": strconv.Itoa(dto.BackupIntervalHours),
"backup_retention_count": strconv.Itoa(dto.BackupRetentionCount),
"backup_path": dto.BackupPath,
}
for k, v := range kv {
if err := database.SetSetting(a.db, k, v); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
}
a.reload(r)
audit(a.db, r, "settings_changed", "settings", "")
writeJSON(w, http.StatusOK, dto)
}
+16
View File
@@ -0,0 +1,16 @@
package api
import (
"net/http"
"github.com/owen/vibeproxy/internal/stats"
)
func (a *API) getStats(w http.ResponseWriter, r *http.Request) {
d, err := stats.Compute(a.db, a.store.Current())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, d)
}
+137
View File
@@ -0,0 +1,137 @@
package api
import (
"fmt"
"net/http"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/models"
)
func (a *API) listStreams(w http.ResponseWriter, r *http.Request) {
s, err := database.ListStreams(a.db)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, s)
}
func (a *API) getStream(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
s, err := database.GetStream(a.db, id)
if err != nil {
writeError(w, http.StatusNotFound, "stream not found")
return
}
writeJSON(w, http.StatusOK, s)
}
func (a *API) createStream(w http.ResponseWriter, r *http.Request) {
var s models.Stream
if !decodeJSON(w, r, &s) {
return
}
if err := validateStream(&s); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.ID = 0
id, err := database.SaveStream(a.db, &s)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.reloadStreams(r)
audit(a.db, r, "stream_changed", fmt.Sprintf("id=%d", id), fmt.Sprintf("%s :%d -> %s:%d", s.Protocol, s.ListenPort, s.DestinationHost, s.DestinationPort))
writeJSON(w, http.StatusCreated, s)
}
func (a *API) updateStream(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var s models.Stream
if !decodeJSON(w, r, &s) {
return
}
s.ID = id
if err := validateStream(&s); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if _, err := database.SaveStream(a.db, &s); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.reloadStreams(r)
audit(a.db, r, "stream_changed", fmt.Sprintf("id=%d", id), "")
writeJSON(w, http.StatusOK, s)
}
func (a *API) deleteStream(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := database.DeleteStream(a.db, id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.reloadStreams(r)
audit(a.db, r, "stream_changed", fmt.Sprintf("id=%d", id), "deleted")
w.WriteHeader(http.StatusNoContent)
}
func (a *API) enableStream(w http.ResponseWriter, r *http.Request) { a.setStreamEnabled(w, r, true) }
func (a *API) disableStream(w http.ResponseWriter, r *http.Request) { a.setStreamEnabled(w, r, false) }
func (a *API) setStreamEnabled(w http.ResponseWriter, r *http.Request, enabled bool) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := database.SetStreamEnabled(a.db, id, enabled); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.reloadStreams(r)
audit(a.db, r, "stream_changed", fmt.Sprintf("id=%d", id), fmt.Sprintf("enabled=%v", enabled))
w.WriteHeader(http.StatusNoContent)
}
func (a *API) reloadStreams(r *http.Request) {
if err := a.streams.Reload(r.Context()); err != nil {
a.log.Error("stream reload failed", "err", err)
}
}
func validateStream(s *models.Stream) error {
if s.Protocol != models.StreamTCP && s.Protocol != models.StreamUDP {
return fmt.Errorf("protocol must be tcp or udp")
}
if s.ListenPort <= 0 || s.ListenPort > 65535 {
return fmt.Errorf("invalid listen port")
}
if s.DestinationHost == "" {
return fmt.Errorf("destination host is required")
}
if s.DestinationPort <= 0 || s.DestinationPort > 65535 {
return fmt.Errorf("invalid destination port")
}
if s.ConnectTimeoutMS <= 0 {
s.ConnectTimeoutMS = 5000
}
if s.IdleTimeoutMS <= 0 {
s.IdleTimeoutMS = 300000
}
return nil
}
+77
View File
@@ -0,0 +1,77 @@
package api
import (
"net/http"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/database"
)
func (a *API) listTokens(w http.ResponseWriter, r *http.Request) {
tokens, err := database.ListAPITokens(a.db)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Never expose the stored hash.
type dto struct {
ID int64 `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
Revoked bool `json:"revoked"`
}
out := make([]dto, 0, len(tokens))
for _, t := range tokens {
d := dto{ID: t.ID, Name: t.Name, Prefix: t.Prefix, CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"), Revoked: t.RevokedAt != nil}
if t.LastUsedAt != nil {
d.LastUsedAt = t.LastUsedAt.Format("2006-01-02T15:04:05Z")
}
out = append(out, d)
}
writeJSON(w, http.StatusOK, out)
}
func (a *API) createToken(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
plaintext, hash, prefix, err := auth.GenerateAPIToken()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
id, err := database.CreateAPIToken(a.db, req.Name, hash, prefix)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
audit(a.db, r, "token_created", req.Name, "")
writeJSON(w, http.StatusCreated, map[string]any{
"id": id,
"name": req.Name,
"token": plaintext, // shown once; never retrievable again
})
}
func (a *API) revokeToken(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := database.RevokeAPIToken(a.db, id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
audit(a.db, r, "token_revoked", "", "")
w.WriteHeader(http.StatusNoContent)
}
+66
View File
@@ -0,0 +1,66 @@
package app
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"time"
"github.com/owen/vibeproxy/internal/metrics"
)
type adminServer struct {
srv *http.Server
}
func newAdminServer(addr string, handler http.Handler) *adminServer {
return &adminServer{srv: &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}}
}
func (a *adminServer) Start(ctx context.Context) error {
errCh := make(chan error, 1)
go func() {
if err := a.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("admin listener: %w", err)
}
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return a.srv.Shutdown(shutdownCtx)
case err := <-errCh:
return err
}
}
func healthzHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
}
func readyzHandler(db *sql.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := db.PingContext(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("database unavailable"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ready"))
})
}
func metricsHandler() http.Handler {
return metrics.Handler()
}
+166
View File
@@ -0,0 +1,166 @@
// Package app wires together every subsystem (database, runtime store,
// certificate manager, HTTP/TCP/UDP proxying, web UI, REST API) into a
// single running vibeproxy process, and exposes the same wiring pieces
// individually for CLI subcommands that don't need the full server.
package app
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/owen/vibeproxy/internal/api"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/backup"
"github.com/owen/vibeproxy/internal/certificates"
"github.com/owen/vibeproxy/internal/config"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/proxy"
"github.com/owen/vibeproxy/internal/runtime"
"github.com/owen/vibeproxy/internal/streams"
"github.com/owen/vibeproxy/internal/web"
)
type App struct {
Cfg config.Config
DB *sql.DB
Log *slog.Logger
Store *runtime.Store
Certs *certificates.Manager
Streams *streams.Manager
}
// Open initializes the database, runtime store, and certificate manager.
// It does not start any listeners — used by both `serve` and one-shot CLI
// commands like `database migrate`.
func Open(cfg config.Config, log *slog.Logger) (*App, error) {
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
db, err := database.Open(cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := ensureAdminUser(db, log); err != nil {
db.Close()
return nil, err
}
certStoragePath := database.GetSetting(db, "cert_storage_path", filepath.Join(cfg.DataDir, "certs"))
certMgr, err := certificates.NewManager(db, certStoragePath, log)
if err != nil {
db.Close()
return nil, fmt.Errorf("init certificate manager: %w", err)
}
if err := certMgr.LoadAll(); err != nil {
log.Warn("failed loading certificates", "err", err)
}
store := runtime.NewStore(db)
if err := store.Reload(context.Background()); err != nil {
db.Close()
return nil, fmt.Errorf("initial config load: %w", err)
}
streamMgr := streams.NewManager(db, log)
return &App{Cfg: cfg, DB: db, Log: log, Store: store, Certs: certMgr, Streams: streamMgr}, nil
}
func (a *App) Close() error {
return a.DB.Close()
}
// ensureAdminUser creates the single `admin` account with a generated
// temporary password on first run, per the "no administrator exists yet"
// requirement.
func ensureAdminUser(db *sql.DB, log *slog.Logger) error {
n, err := database.CountUsers(db)
if err != nil {
return err
}
if n > 0 {
return nil
}
password, err := auth.GenerateRandomPassword()
if err != nil {
return err
}
hash, err := auth.HashPassword(password)
if err != nil {
return err
}
if err := database.CreateUser(db, "admin", hash, true); err != nil {
return err
}
database.InsertAuditLog(db, "system", "admin_created", "admin", "initial administrator account created")
fmt.Println("========================================================")
fmt.Println(" vibeproxy: created initial administrator account")
fmt.Println(" username: admin")
fmt.Printf(" password: %s\n", password)
fmt.Println(" Change this password after logging in (Settings > Admin Account).")
fmt.Println("========================================================")
log.Info("created initial admin account", "username", "admin")
return nil
}
// Run starts every listener (HTTP/HTTPS proxy, TCP/UDP streams, admin web
// UI + API) and background loops (health checks are started by the
// runtime store on reload; certificate renewal, log retention, backups),
// blocking until ctx is cancelled, then shutting everything down
// gracefully.
func (a *App) Run(ctx context.Context) error {
if err := a.Streams.Reload(ctx); err != nil {
return fmt.Errorf("initial stream load: %w", err)
}
settings := a.Store.Current().Settings
a.Certs.StartRenewalLoop(ctx, settings.ACMERenewalWindowDays, settings.ACMEEmail, settings.ACMEStaging)
logging.StartRetentionLoop(ctx, a.DB, a.Log, func() int {
return a.Store.Current().Settings.LogRetentionDays
})
backup.StartScheduledBackups(ctx, a.DB, a.Log,
func() string { return a.Store.Current().Settings.BackupPath },
func() int { return a.Store.Current().Settings.BackupIntervalHours },
func() int { return a.Store.Current().Settings.BackupRetentionCount },
func() bool { return a.Store.Current().Settings.BackupEnabled },
)
proxySrv := proxy.NewServer(proxy.Options{HTTPAddr: a.Cfg.HTTPAddr, HTTPSAddr: a.Cfg.HTTPSAddr}, a.Store, a.Certs, a.DB, a.Log)
csrf := auth.NewCSRF()
adminMux := web.NewRouter(a.DB, a.Store, a.Certs, a.Streams, csrf, a.Log)
apiMux := api.NewRouter(a.DB, a.Store, a.Certs, a.Streams, a.Log)
adminMux.Handle("/api/v1/", apiMux)
adminMux.Handle("/healthz", healthzHandler())
adminMux.Handle("/readyz", readyzHandler(a.DB))
adminMux.Handle("/metrics", metricsHandler())
adminSrv := newAdminServer(a.Cfg.AdminAddr, adminMux)
errCh := make(chan error, 4)
go func() {
if err := proxySrv.Start(ctx); err != nil {
errCh <- err
}
}()
go func() {
if err := adminSrv.Start(ctx); err != nil {
errCh <- err
}
}()
select {
case <-ctx.Done():
case err := <-errCh:
a.Log.Error("fatal listener error", "err", err)
}
a.Streams.StopAll()
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
)
// GenerateAPIToken returns a full plaintext token (shown once to the user),
// its sha256 hash (stored in the DB), and a short prefix (stored alongside
// the hash so the UI can display "vp_ab12..." without ever re-showing the
// secret).
func GenerateAPIToken() (plaintext, hash, prefix string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
secret := base64.RawURLEncoding.EncodeToString(b)
plaintext = "vp_" + secret
hash = HashAPIToken(plaintext)
prefix = plaintext[:8]
return
}
func HashAPIToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func FormatTokenPreview(prefix string) string {
return fmt.Sprintf("%s%s", prefix, "…")
}
+151
View File
@@ -0,0 +1,151 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"net/http"
"time"
"github.com/owen/vibeproxy/internal/database"
)
type ctxKey string
const actorCtxKey ctxKey = "actor"
func ActorFromContext(ctx context.Context) string {
if v, ok := ctx.Value(actorCtxKey).(string); ok {
return v
}
return "unknown"
}
// BasicAuthAdmin protects the web UI with HTTP Basic Auth against the
// single stored admin account.
func BasicAuthAdmin(db *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok {
unauthorized(w)
return
}
user, err := database.GetUserByUsername(db, username)
if err != nil {
unauthorized(w)
return
}
valid, err := VerifyPassword(password, user.PasswordHash)
if err != nil || !valid {
unauthorized(w)
return
}
ctx := context.WithValue(r.Context(), actorCtxKey, username)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func unauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="vibeproxy"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
// APIToken protects the REST API with a bearer token checked against
// hashed tokens stored in the database.
func APIToken(db *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok := bearerToken(r)
if tok == "" {
http.Error(w, `{"error":"missing bearer token"}`, http.StatusUnauthorized)
return
}
hash := HashAPIToken(tok)
rec, err := database.GetAPITokenByHash(db, hash)
if err != nil || rec.RevokedAt != nil {
http.Error(w, `{"error":"invalid or revoked token"}`, http.StatusUnauthorized)
return
}
go database.TouchAPIToken(db, rec.ID)
ctx := context.WithValue(r.Context(), actorCtxKey, "token:"+rec.Name)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
const p = "Bearer "
if len(h) > len(p) && h[:len(p)] == p {
return h[len(p):]
}
return ""
}
// CSRF implements a double-submit-cookie style CSRF protection for the
// server-rendered web UI (which authenticates via Basic Auth and so has no
// session to bind a per-session token to).
type CSRF struct {
secret []byte
}
func NewCSRF() *CSRF {
s := make([]byte, 32)
rand.Read(s)
return &CSRF{secret: s}
}
const csrfCookieName = "vp_csrf"
func (c *CSRF) sign(nonce string) string {
mac := hmac.New(sha256.New, c.secret)
mac.Write([]byte(nonce))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// Token issues (and sets a cookie for) a fresh CSRF token for rendering
// into a form's hidden field.
func (c *CSRF) Token(w http.ResponseWriter) string {
nonceB := make([]byte, 16)
rand.Read(nonceB)
nonce := base64.RawURLEncoding.EncodeToString(nonceB)
sig := c.sign(nonce)
value := nonce + "." + sig
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: value,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Expires: time.Now().Add(2 * time.Hour),
})
return value
}
// Protect rejects unsafe requests whose form value doesn't match the
// double-submitted cookie.
func (c *CSRF) Protect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie(csrfCookieName)
if err != nil {
http.Error(w, "CSRF token missing", http.StatusForbidden)
return
}
formTok := r.FormValue("csrf_token")
if formTok == "" || subtle.ConstantTimeCompare([]byte(formTok), []byte(cookie.Value)) != 1 {
http.Error(w, "CSRF token invalid", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
+78
View File
@@ -0,0 +1,78 @@
// Package auth handles admin credential hashing, HTTP Basic Auth, API
// tokens, and CSRF protection for the web UI.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters. Tuned for a low-traffic single-admin login, not a
// high-throughput multi-tenant service.
const (
argonTime = 1
argonMemory = 64 * 1024 // KiB
argonThreads = 4
argonKeyLen = 32
saltLen = 16
)
var ErrInvalidHash = errors.New("invalid password hash format")
// HashPassword returns an encoded argon2id hash in the standard
// $argon2id$v=19$m=...,t=...,p=...$salt$hash format.
func HashPassword(password string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash), nil
}
// VerifyPassword checks password against an encoded hash produced by
// HashPassword, using constant-time comparison.
func VerifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, ErrInvalidHash
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, ErrInvalidHash
}
var memory uint32
var time uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return false, ErrInvalidHash
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, ErrInvalidHash
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, ErrInvalidHash
}
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
// GenerateRandomPassword returns a strong, human-typeable temporary
// password (24 base64url characters, no padding).
func GenerateRandomPassword() (string, error) {
b := make([]byte, 18)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
+54
View File
@@ -0,0 +1,54 @@
package auth
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatal(err)
}
ok, err := VerifyPassword("correct horse battery staple", hash)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected password to verify")
}
ok, err = VerifyPassword("wrong password", hash)
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("expected wrong password to fail verification")
}
}
func TestGenerateRandomPassword(t *testing.T) {
p1, err := GenerateRandomPassword()
if err != nil {
t.Fatal(err)
}
p2, err := GenerateRandomPassword()
if err != nil {
t.Fatal(err)
}
if p1 == p2 {
t.Fatal("expected distinct random passwords")
}
if len(p1) < 20 {
t.Fatalf("password too short: %q", p1)
}
}
func TestAPITokenRoundTrip(t *testing.T) {
plaintext, hash, prefix, err := GenerateAPIToken()
if err != nil {
t.Fatal(err)
}
if HashAPIToken(plaintext) != hash {
t.Fatal("hash mismatch")
}
if len(prefix) != 8 {
t.Fatalf("expected 8-char prefix, got %q", prefix)
}
}
+112
View File
@@ -0,0 +1,112 @@
// Package backup handles SQLite backups: on-demand, scheduled, and
// restore. It uses SQLite's own online backup (via VACUUM INTO), which is
// safe to run against a live WAL-mode database without blocking writers
// for more than the duration of the copy.
package backup
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"time"
)
// Run performs a single backup of the database at db into dir, named with
// a timestamp, then prunes old backups beyond keep.
func Run(db *sql.DB, dir string, keep int) (string, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
name := fmt.Sprintf("vibeproxy-%s.db", time.Now().UTC().Format("20060102-150405"))
path := filepath.Join(dir, name)
if _, err := db.Exec(`VACUUM INTO ?`, path); err != nil {
return "", fmt.Errorf("backup failed: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return "", err
}
if keep > 0 {
if err := prune(dir, keep); err != nil {
return path, err
}
}
return path, nil
}
func prune(dir string, keep int) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
var names []string
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names) // timestamped names sort chronologically
if len(names) <= keep {
return nil
}
for _, n := range names[:len(names)-keep] {
os.Remove(filepath.Join(dir, n))
}
return nil
}
// StartScheduledBackups runs Run on the given interval until ctx is done.
func StartScheduledBackups(ctx context.Context, db *sql.DB, log *slog.Logger, getDir func() string, getIntervalHours func() int, getKeep func() int, getEnabled func() bool) {
ticker := time.NewTicker(1 * time.Hour)
lastRun := time.Time{}
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !getEnabled() {
continue
}
interval := time.Duration(getIntervalHours()) * time.Hour
if interval <= 0 {
interval = 24 * time.Hour
}
if time.Since(lastRun) < interval {
continue
}
lastRun = time.Now()
path, err := Run(db, getDir(), getKeep())
if err != nil {
log.Error("scheduled backup failed", "err", err)
} else {
log.Info("scheduled backup complete", "path", path)
}
}
}
}()
}
// Restore replaces the live database file at dbPath with backupPath. The
// caller must have closed all connections to dbPath before calling this
// and reopen afterward.
func Restore(backupPath, dbPath string) error {
data, err := os.ReadFile(backupPath)
if err != nil {
return err
}
tmp := dbPath + ".restoring"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
// Remove any stale WAL/SHM files so SQLite doesn't try to replay a
// journal that belongs to the old database.
os.Remove(dbPath + "-wal")
os.Remove(dbPath + "-shm")
return os.Rename(tmp, dbPath)
}
+56
View File
@@ -0,0 +1,56 @@
package certificates
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"errors"
"os"
"github.com/go-acme/lego/v4/registration"
)
// acmeUser implements lego's registration.User for our single ACME
// account. The private key never leaves this process and is stored on
// disk with 0600 permissions, separate from any issued certificate keys.
type acmeUser struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func newACMEUser(email string, key crypto.PrivateKey) *acmeUser {
return &acmeUser{Email: email, key: key}
}
func (u *acmeUser) GetEmail() string { return u.Email }
func (u *acmeUser) GetRegistration() *registration.Resource { return u.Registration }
func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key }
func newACMEAccountKey() (*ecdsa.PrivateKey, error) {
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
}
func writeECDSAKeyPEM(path string, key *ecdsa.PrivateKey) error {
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
return err
}
block := &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}
return os.WriteFile(path, pem.EncodeToMemory(block), 0o600)
}
func readECDSAKeyPEM(path string) (*ecdsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("invalid PEM account key")
}
return x509.ParseECPrivateKey(block.Bytes)
}
+56
View File
@@ -0,0 +1,56 @@
package certificates
import (
"net/http"
"strings"
"sync"
)
// httpChallengeProvider implements lego's challenge.Provider for HTTP-01,
// backed by an in-memory token map served by our own HTTP listener at
// /.well-known/acme-challenge/. This avoids spinning up a second server on
// port 80 — the main proxy HTTP listener delegates to ServeHTTP below.
type httpChallengeProvider struct {
mu sync.RWMutex
tokens map[string]string // token -> key authorization
}
func newHTTPChallengeProvider() *httpChallengeProvider {
return &httpChallengeProvider{tokens: map[string]string{}}
}
func (p *httpChallengeProvider) Present(domain, token, keyAuth string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.tokens[token] = keyAuth
return nil
}
func (p *httpChallengeProvider) CleanUp(domain, token, keyAuth string) error {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.tokens, token)
return nil
}
const acmeChallengePrefix = "/.well-known/acme-challenge/"
// ServeHTTP handles ACME HTTP-01 challenge requests. Returns true if the
// request was an ACME challenge (handled or not found), false if the
// caller should continue normal routing.
func (p *httpChallengeProvider) ServeHTTP(w http.ResponseWriter, r *http.Request) bool {
if !strings.HasPrefix(r.URL.Path, acmeChallengePrefix) {
return false
}
token := strings.TrimPrefix(r.URL.Path, acmeChallengePrefix)
p.mu.RLock()
keyAuth, ok := p.tokens[token]
p.mu.RUnlock()
if !ok {
http.NotFound(w, r)
return true
}
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(keyAuth))
return true
}
+335
View File
@@ -0,0 +1,335 @@
// Package certificates manages TLS certificates: Let's Encrypt issuance
// and renewal via lego (a proper ACME client library — we never speak the
// ACME protocol ourselves), plus custom uploaded certificates. It serves
// SNI lookups for the HTTPS listener from an in-memory cache and persists
// metadata to SQLite via the database package. Private key material only
// ever touches disk (0600) and is never logged.
package certificates
import (
"context"
"crypto/ecdsa"
"crypto/tls"
"crypto/x509"
"database/sql"
"encoding/pem"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
"github.com/go-acme/lego/v4/registration"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/models"
)
const (
letsEncryptProdURL = "https://acme-v02.api.letsencrypt.org/directory"
letsEncryptStagingURL = "https://acme-staging-v02.api.letsencrypt.org/directory"
)
type Manager struct {
db *sql.DB
storage *storage
log *slog.Logger
httpProvider *httpChallengeProvider
mu sync.RWMutex
byDom map[string]*tls.Certificate // lowercase domain -> cert
onChange func()
}
func NewManager(db *sql.DB, storagePath string, log *slog.Logger) (*Manager, error) {
st, err := newStorage(storagePath)
if err != nil {
return nil, err
}
return &Manager{
db: db,
storage: st,
log: log,
httpProvider: newHTTPChallengeProvider(),
byDom: map[string]*tls.Certificate{},
}, nil
}
// ServeChallenge handles ACME HTTP-01 requests on the plain HTTP listener.
// Returns true if the request was handled (was an ACME challenge path).
func (m *Manager) ServeChallenge(w http.ResponseWriter, r *http.Request) bool {
return m.httpProvider.ServeHTTP(w, r)
}
func (m *Manager) SetOnChange(f func()) { m.onChange = f }
// GetCertificateForDomain implements SNI lookup for tls.Config.GetCertificate.
func (m *Manager) GetCertificateForDomain(domain string) (*tls.Certificate, error) {
domain = strings.ToLower(domain)
m.mu.RLock()
defer m.mu.RUnlock()
if c, ok := m.byDom[domain]; ok {
return c, nil
}
if idx := strings.IndexByte(domain, '.'); idx >= 0 {
wildcard := "*" + domain[idx:]
if c, ok := m.byDom[wildcard]; ok {
return c, nil
}
}
return nil, fmt.Errorf("no certificate for domain %q", domain)
}
// LoadAll reads every stored certificate row, loads its key material from
// disk, and rebuilds the SNI cache. Call at startup and after any
// issuance/upload/deletion.
func (m *Manager) LoadAll() error {
certs, err := database.ListCertificates(m.db)
if err != nil {
return err
}
newMap := map[string]*tls.Certificate{}
for _, c := range certs {
if c.CertPath == "" || c.KeyPath == "" {
continue
}
tlsCert, err := loadTLSCertificate(c.CertPath, c.KeyPath)
if err != nil {
m.log.Warn("failed to load certificate", "name", c.Name, "err", err)
continue
}
for _, d := range c.Domains {
newMap[strings.ToLower(d)] = tlsCert
}
}
m.mu.Lock()
m.byDom = newMap
m.mu.Unlock()
if m.onChange != nil {
m.onChange()
}
return nil
}
// UploadCustom validates and stores a custom certificate/key pair.
func (m *Manager) UploadCustom(name string, domains []string, certPEM, keyPEM []byte) (*models.Certificate, error) {
leaf, err := ValidateKeyPair(certPEM, keyPEM)
if err != nil {
return nil, err
}
certPath, keyPath, err := m.storage.SavePair(name, certPEM, keyPEM)
if err != nil {
return nil, err
}
notBefore, notAfter := leaf.NotBefore, leaf.NotAfter
rec := &models.Certificate{
Name: name,
Type: models.CertTypeCustom,
Domains: domains,
CertPath: certPath,
KeyPath: keyPath,
NotBefore: &notBefore,
NotAfter: &notAfter,
AutoRenew: false,
}
if _, err := database.SaveCertificate(m.db, rec); err != nil {
return nil, err
}
if err := m.LoadAll(); err != nil {
return nil, err
}
return rec, nil
}
// IssueLetsEncryptOptions configures a new ACME issuance.
type IssueLetsEncryptOptions struct {
Name string
Domains []string
Email string
Staging bool
ChallengeType models.ChallengeType
DNSProvider string // e.g. "cloudflare"; required for dns-01
}
// IssueLetsEncrypt requests a new certificate from Let's Encrypt and
// persists it. HTTP-01 challenges are served via ServeChallenge on our own
// HTTP listener (port 80 must be reachable from the internet); DNS-01 uses
// the configured DNS provider and also supports wildcard domains.
func (m *Manager) IssueLetsEncrypt(ctx context.Context, opts IssueLetsEncryptOptions) (*models.Certificate, error) {
if opts.Email == "" {
return nil, errors.New("an ACME contact email is required (configure it under Settings)")
}
accountKey, err := m.loadOrCreateAccountKey()
if err != nil {
return nil, err
}
user := newACMEUser(opts.Email, accountKey)
cfg := lego.NewConfig(user)
if opts.Staging {
cfg.CADirURL = letsEncryptStagingURL
} else {
cfg.CADirURL = letsEncryptProdURL
}
cfg.Certificate.KeyType = certcrypto.RSA2048
client, err := lego.NewClient(cfg)
if err != nil {
return nil, err
}
if opts.ChallengeType == models.ChallengeDNS01 {
provider, err := m.dnsProvider(opts.DNSProvider)
if err != nil {
return nil, err
}
if err := client.Challenge.SetDNS01Provider(provider); err != nil {
return nil, err
}
} else {
if err := client.Challenge.SetHTTP01Provider(m.httpProvider); err != nil {
return nil, err
}
}
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, fmt.Errorf("acme registration: %w", err)
}
user.Registration = reg
res, err := client.Certificate.Obtain(certificate.ObtainRequest{
Domains: opts.Domains,
Bundle: true,
})
if err != nil {
return nil, fmt.Errorf("acme obtain: %w", err)
}
certPath, keyPath, err := m.storage.SavePair(opts.Name, res.Certificate, res.PrivateKey)
if err != nil {
return nil, err
}
var notBefore, notAfter *time.Time
if block, _ := pem.Decode(res.Certificate); block != nil {
if leaf, err := x509.ParseCertificate(block.Bytes); err == nil {
nb, na := leaf.NotBefore, leaf.NotAfter
notBefore, notAfter = &nb, &na
}
}
now := time.Now()
rec := &models.Certificate{
Name: opts.Name,
Type: models.CertTypeLetsEncrypt,
Domains: opts.Domains,
ChallengeType: opts.ChallengeType,
DNSProvider: opts.DNSProvider,
CertPath: certPath,
KeyPath: keyPath,
NotBefore: notBefore,
NotAfter: notAfter,
LastRenewedAt: &now,
AutoRenew: true,
}
if _, err := database.SaveCertificate(m.db, rec); err != nil {
return nil, err
}
if err := m.LoadAll(); err != nil {
return nil, err
}
return rec, nil
}
func (m *Manager) dnsProvider(name string) (challenge.Provider, error) {
switch name {
case "cloudflare":
return cloudflare.NewDNSProvider()
default:
return nil, fmt.Errorf("unsupported DNS provider %q (supported: cloudflare; set its env vars, or use HTTP-01 instead)", name)
}
}
// RenewDue re-issues any Let's Encrypt certificate whose expiry falls
// inside the configured renewal window. Returns the number renewed.
func (m *Manager) RenewDue(ctx context.Context, renewalWindowDays int, email string, staging bool) (int, error) {
certs, err := database.ListCertificates(m.db)
if err != nil {
return 0, err
}
renewed := 0
cutoff := time.Now().Add(time.Duration(renewalWindowDays) * 24 * time.Hour)
for _, c := range certs {
if c.Type != models.CertTypeLetsEncrypt || !c.AutoRenew {
continue
}
if c.NotAfter == nil || c.NotAfter.After(cutoff) {
continue
}
if _, err := m.IssueLetsEncrypt(ctx, IssueLetsEncryptOptions{
Name: c.Name,
Domains: c.Domains,
Email: email,
Staging: staging,
ChallengeType: c.ChallengeType,
DNSProvider: c.DNSProvider,
}); err != nil {
c.LastError = err.Error()
database.SaveCertificate(m.db, &c)
m.log.Error("certificate renewal failed", "name", c.Name, "err", err)
continue
}
renewed++
}
return renewed, nil
}
// StartRenewalLoop runs RenewDue once a day until ctx is cancelled.
func (m *Manager) StartRenewalLoop(ctx context.Context, renewalWindowDays int, email string, staging bool) {
ticker := time.NewTicker(24 * time.Hour)
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if n, err := m.RenewDue(ctx, renewalWindowDays, email, staging); err != nil {
m.log.Error("renewal loop error", "err", err)
} else if n > 0 {
m.log.Info("renewed certificates", "count", n)
}
}
}
}()
}
func (m *Manager) loadOrCreateAccountKey() (*ecdsa.PrivateKey, error) {
path := m.storage.accountKeyPath()
if _, err := os.Stat(path); err == nil {
return readECDSAKeyPEM(path)
}
key, err := newACMEAccountKey()
if err != nil {
return nil, err
}
if err := writeECDSAKeyPEM(path, key); err != nil {
return nil, err
}
return key, nil
}
func DeleteCertificate(db *sql.DB, id int64) error {
return database.DeleteCertificate(db, id)
}
+104
View File
@@ -0,0 +1,104 @@
package certificates
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
)
// storage manages certificate/key material on disk. Private keys are
// written with 0600 permissions and their directories with 0700; nothing
// under here is ever written to a log.
type storage struct {
root string
}
func newStorage(root string) (*storage, error) {
if err := os.MkdirAll(filepath.Join(root, "accounts"), 0o700); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(root, "certs"), 0o700); err != nil {
return nil, err
}
return &storage{root: root}, nil
}
func (s *storage) certDir(name string) string {
return filepath.Join(s.root, "certs", safeName(name))
}
func safeName(name string) string {
out := make([]rune, 0, len(name))
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
out = append(out, r)
default:
out = append(out, '_')
}
}
if len(out) == 0 {
return "cert"
}
return string(out)
}
// SavePair writes fullchain.pem and privkey.pem for the given logical
// certificate name and returns their paths.
func (s *storage) SavePair(name string, certPEM, keyPEM []byte) (certPath, keyPath string, err error) {
dir := s.certDir(name)
if err = os.MkdirAll(dir, 0o700); err != nil {
return
}
certPath = filepath.Join(dir, "fullchain.pem")
keyPath = filepath.Join(dir, "privkey.pem")
if err = os.WriteFile(certPath, certPEM, 0o644); err != nil {
return
}
if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return
}
return
}
func (s *storage) accountKeyPath() string {
return filepath.Join(s.root, "accounts", "account.key")
}
func loadTLSCertificate(certPath, keyPath string) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
if len(cert.Certificate) == 0 {
return nil, errors.New("empty certificate chain")
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return nil, err
}
cert.Leaf = leaf
return &cert, nil
}
// ValidateKeyPair checks that certPEM/keyPEM parse and form a matching
// pair before anything is written to disk.
func ValidateKeyPair(certPEM, keyPEM []byte) (*x509.Certificate, error) {
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("certificate/key do not match: %w", err)
}
block, _ := pem.Decode(certPEM)
if block == nil {
return nil, errors.New("no PEM data found in certificate")
}
leaf, err := x509.ParseCertificate(tlsCert.Certificate[0])
if err != nil {
return nil, err
}
return leaf, nil
}
+47
View File
@@ -0,0 +1,47 @@
// Package config holds process bootstrap configuration: everything needed
// before the database is even open. Runtime/admin-tunable settings (ACME
// email, retention, trusted proxies, etc.) live in the database instead —
// see internal/database's settings table — so they can change without a
// restart.
package config
import (
"flag"
"os"
)
type Config struct {
DataDir string
DBPath string
HTTPAddr string
HTTPSAddr string
AdminAddr string
}
func Default() Config {
return Config{
DataDir: "./data",
DBPath: "./data/vibeproxy.db",
HTTPAddr: ":80",
HTTPSAddr: ":443",
AdminAddr: ":8081",
}
}
// RegisterFlags binds cfg's fields to flags on fs, seeded from environment
// variables (VIBEPROXY_*) so the same binary works under systemd or Docker
// without a wrapper script.
func RegisterFlags(fs *flag.FlagSet, cfg *Config) {
fs.StringVar(&cfg.DataDir, "data-dir", envOr("VIBEPROXY_DATA_DIR", cfg.DataDir), "directory for the database, certificates, and backups")
fs.StringVar(&cfg.DBPath, "db-path", envOr("VIBEPROXY_DB_PATH", cfg.DBPath), "path to the SQLite database file")
fs.StringVar(&cfg.HTTPAddr, "http-addr", envOr("VIBEPROXY_HTTP_ADDR", cfg.HTTPAddr), "address for the plain HTTP proxy listener")
fs.StringVar(&cfg.HTTPSAddr, "https-addr", envOr("VIBEPROXY_HTTPS_ADDR", cfg.HTTPSAddr), "address for the HTTPS proxy listener")
fs.StringVar(&cfg.AdminAddr, "admin-addr", envOr("VIBEPROXY_ADMIN_ADDR", cfg.AdminAddr), "address for the admin web UI, REST API, and /metrics, /healthz, /readyz")
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
+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
}
+104
View File
@@ -0,0 +1,104 @@
// Package health runs periodic TCP/HTTP checks against backends and keeps
// their healthy state in loadbalancer.Backend up to date.
package health
import (
"context"
"fmt"
"net"
"net/http"
"time"
"github.com/owen/vibeproxy/internal/loadbalancer"
"github.com/owen/vibeproxy/internal/models"
)
// OnChange is invoked whenever a backend transitions healthy<->unhealthy.
type OnChange func(hostID int64, b *loadbalancer.Backend, healthy bool)
type Checker struct {
client *http.Client
onChange OnChange
cancels map[int64]context.CancelFunc
}
func NewChecker(onChange OnChange) *Checker {
return &Checker{
client: &http.Client{Timeout: 5 * time.Second},
onChange: onChange,
cancels: map[int64]context.CancelFunc{},
}
}
// Watch starts a background goroutine checking b at its configured interval
// until ctx is cancelled. hostID is used only for the OnChange callback.
func (c *Checker) Watch(ctx context.Context, hostID int64, b *loadbalancer.Backend) {
if b.Model.HealthCheckType == models.HealthCheckNone || b.Model.HealthCheckType == "" {
return
}
interval := time.Duration(b.Model.IntervalSeconds) * time.Second
if interval <= 0 {
interval = 10 * time.Second
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
c.check(ctx, hostID, b)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.check(ctx, hostID, b)
}
}
}()
}
func (c *Checker) check(ctx context.Context, hostID int64, b *loadbalancer.Backend) {
ok := false
switch b.Model.HealthCheckType {
case models.HealthCheckTCP:
ok = c.checkTCP(ctx, b)
case models.HealthCheckHTTP:
ok = c.checkHTTP(ctx, b)
default:
ok = true
}
if changed, healthy := b.RecordCheck(ok); changed && c.onChange != nil {
c.onChange(hostID, b, healthy)
}
}
func (c *Checker) checkTCP(ctx context.Context, b *loadbalancer.Backend) bool {
d := net.Dialer{Timeout: 3 * time.Second}
addr := net.JoinHostPort(b.Model.Host, fmt.Sprintf("%d", b.Model.Port))
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return false
}
conn.Close()
return true
}
func (c *Checker) checkHTTP(ctx context.Context, b *loadbalancer.Backend) bool {
path := b.Model.HealthCheckPath
if path == "" {
path = "/"
}
url := fmt.Sprintf("http://%s:%d%s", b.Model.Host, b.Model.Port, path)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false
}
resp, err := c.client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
want := b.Model.HealthCheckStatus
if want == 0 {
want = 200
}
return resp.StatusCode == want
}
+122
View File
@@ -0,0 +1,122 @@
// Package loadbalancer picks a healthy backend for a proxy host using
// round-robin, weighted, or least-connections strategies.
package loadbalancer
import (
"errors"
"sync/atomic"
"github.com/owen/vibeproxy/internal/models"
)
var ErrNoHealthyBackend = errors.New("no healthy backend available")
// Backend wraps a models.Backend with runtime-only state shared between the
// health checker and the load balancer.
type Backend struct {
Model models.Backend
healthy atomic.Bool
activeConns atomic.Int64
failCount atomic.Int32
okCount atomic.Int32
}
func NewBackend(m models.Backend) *Backend {
b := &Backend{Model: m}
b.healthy.Store(m.HealthCheckType == models.HealthCheckNone || m.HealthCheckType == "")
return b
}
func (b *Backend) Healthy() bool { return b.healthy.Load() && b.Model.Enabled }
func (b *Backend) ActiveConns() int64 { return b.activeConns.Load() }
func (b *Backend) Acquire() { b.activeConns.Add(1) }
func (b *Backend) Release() { b.activeConns.Add(-1) }
func (b *Backend) Addr() string { return b.Model.Host }
// RecordCheck updates the health state given a single check's outcome,
// applying the configured failure/recovery thresholds.
func (b *Backend) RecordCheck(ok bool) (changed bool, nowHealthy bool) {
wasHealthy := b.healthy.Load()
if ok {
b.failCount.Store(0)
n := b.okCount.Add(1)
threshold := int32(b.Model.RecoveryThreshold)
if threshold < 1 {
threshold = 1
}
if !wasHealthy && n >= threshold {
b.healthy.Store(true)
}
} else {
b.okCount.Store(0)
n := b.failCount.Add(1)
threshold := int32(b.Model.FailureThreshold)
if threshold < 1 {
threshold = 1
}
if wasHealthy && n >= threshold {
b.healthy.Store(false)
}
}
return wasHealthy != b.healthy.Load(), b.healthy.Load()
}
// Pool is the set of backends for one proxy host plus a pick strategy.
type Pool struct {
Method string // round_robin | least_conn | weighted
Backends []*Backend
counter atomic.Uint64
}
func NewPool(method string, backends []*Backend) *Pool {
return &Pool{Method: method, Backends: backends}
}
// Pick selects the next backend to use. Callers must call Release() on the
// returned backend (via a deferred call) once the request/connection ends.
func (p *Pool) Pick() (*Backend, error) {
healthy := make([]*Backend, 0, len(p.Backends))
for _, b := range p.Backends {
if b.Healthy() {
healthy = append(healthy, b)
}
}
if len(healthy) == 0 {
return nil, ErrNoHealthyBackend
}
switch p.Method {
case "least_conn":
best := healthy[0]
for _, b := range healthy[1:] {
if b.ActiveConns() < best.ActiveConns() {
best = b
}
}
return best, nil
case "weighted":
total := 0
for _, b := range healthy {
w := b.Model.Weight
if w < 1 {
w = 1
}
total += w
}
n := int(p.counter.Add(1)-1) % total
for _, b := range healthy {
w := b.Model.Weight
if w < 1 {
w = 1
}
if n < w {
return b, nil
}
n -= w
}
return healthy[0], nil
default: // round_robin
idx := int(p.counter.Add(1)-1) % len(healthy)
return healthy[idx], nil
}
}
@@ -0,0 +1,91 @@
package loadbalancer
import (
"testing"
"github.com/owen/vibeproxy/internal/models"
)
func TestRoundRobin(t *testing.T) {
b1 := NewBackend(models.Backend{Host: "a", Enabled: true, HealthCheckType: models.HealthCheckNone})
b2 := NewBackend(models.Backend{Host: "b", Enabled: true, HealthCheckType: models.HealthCheckNone})
pool := NewPool("round_robin", []*Backend{b1, b2})
seen := map[string]int{}
for i := 0; i < 10; i++ {
b, err := pool.Pick()
if err != nil {
t.Fatal(err)
}
seen[b.Model.Host]++
}
if seen["a"] != 5 || seen["b"] != 5 {
t.Fatalf("expected even split, got %v", seen)
}
}
func TestLeastConn(t *testing.T) {
b1 := NewBackend(models.Backend{Host: "a", Enabled: true, HealthCheckType: models.HealthCheckNone})
b2 := NewBackend(models.Backend{Host: "b", Enabled: true, HealthCheckType: models.HealthCheckNone})
pool := NewPool("least_conn", []*Backend{b1, b2})
b1.Acquire()
b1.Acquire()
picked, err := pool.Pick()
if err != nil {
t.Fatal(err)
}
if picked.Model.Host != "b" {
t.Fatalf("expected b (fewer active conns), got %s", picked.Model.Host)
}
}
func TestWeighted(t *testing.T) {
b1 := NewBackend(models.Backend{Host: "a", Weight: 3, Enabled: true, HealthCheckType: models.HealthCheckNone})
b2 := NewBackend(models.Backend{Host: "b", Weight: 1, Enabled: true, HealthCheckType: models.HealthCheckNone})
pool := NewPool("weighted", []*Backend{b1, b2})
counts := map[string]int{}
for i := 0; i < 8; i++ {
b, err := pool.Pick()
if err != nil {
t.Fatal(err)
}
counts[b.Model.Host]++
}
if counts["a"] != 6 || counts["b"] != 2 {
t.Fatalf("expected 3:1 weighted split (6:2 of 8), got %v", counts)
}
}
func TestNoHealthyBackend(t *testing.T) {
b1 := NewBackend(models.Backend{Host: "a", Enabled: true, HealthCheckType: models.HealthCheckTCP, FailureThreshold: 1})
pool := NewPool("round_robin", []*Backend{b1})
if _, err := pool.Pick(); err != ErrNoHealthyBackend {
t.Fatalf("expected ErrNoHealthyBackend, got %v", err)
}
}
func TestRecordCheckThresholds(t *testing.T) {
b := NewBackend(models.Backend{Host: "a", Enabled: true, HealthCheckType: models.HealthCheckTCP, FailureThreshold: 2, RecoveryThreshold: 2})
if b.Healthy() {
t.Fatal("expected backend to start unhealthy pending checks")
}
b.RecordCheck(true)
if b.Healthy() {
t.Fatal("should still be unhealthy after 1 of 2 required successes")
}
changed, healthy := b.RecordCheck(true)
if !changed || !healthy {
t.Fatal("expected backend to become healthy after 2 successes")
}
b.RecordCheck(false)
if !b.Healthy() {
t.Fatal("should still be healthy after 1 of 2 required failures")
}
changed, healthy = b.RecordCheck(false)
if !changed || healthy {
t.Fatal("expected backend to become unhealthy after 2 failures")
}
}
+13
View File
@@ -0,0 +1,13 @@
package logging
import (
"database/sql"
"github.com/owen/vibeproxy/internal/database"
)
// Audit records a configuration-change event. Callers must never pass
// passwords, API tokens, or private key material in details.
func Audit(db *sql.DB, actor, action, target, details string) {
_ = database.InsertAuditLog(db, actor, action, target, details)
}
+94
View File
@@ -0,0 +1,94 @@
// Package logging buffers HTTP request log entries and writes them to
// SQLite in batches so the hot request path never blocks on a DB write; it
// also runs the retention cleanup job and provides audit-log helpers.
package logging
import (
"context"
"database/sql"
"log/slog"
"time"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/models"
)
type RequestLogger struct {
db *sql.DB
log *slog.Logger
ch chan models.RequestLog
}
func NewRequestLogger(db *sql.DB, log *slog.Logger) *RequestLogger {
return &RequestLogger{db: db, log: log, ch: make(chan models.RequestLog, 2048)}
}
// Log enqueues an entry; if the buffer is full the entry is dropped rather
// than blocking the proxy hot path (a slow logger must never slow down
// traffic).
func (l *RequestLogger) Log(entry models.RequestLog) {
select {
case l.ch <- entry:
default:
l.log.Warn("request log buffer full, dropping entry")
}
}
// Run drains the buffer, flushing in batches, until ctx is cancelled.
func (l *RequestLogger) Run(ctx context.Context) {
const batchSize = 100
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
batch := make([]models.RequestLog, 0, batchSize)
flush := func() {
if len(batch) == 0 {
return
}
if err := database.InsertRequestLogBatch(l.db, batch); err != nil {
l.log.Error("request log flush failed", "err", err)
}
batch = batch[:0]
}
for {
select {
case <-ctx.Done():
flush()
return
case e := <-l.ch:
batch = append(batch, e)
if len(batch) >= batchSize {
flush()
}
case <-ticker.C:
flush()
}
}
}
// StartRetentionLoop periodically deletes request logs older than
// retentionDays so the database does not grow without bound.
func StartRetentionLoop(ctx context.Context, db *sql.DB, log *slog.Logger, getRetentionDays func() int) {
ticker := time.NewTicker(1 * time.Hour)
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
days := getRetentionDays()
if days <= 0 {
continue
}
n, err := database.DeleteOldRequestLogs(db, days)
if err != nil {
log.Error("log retention cleanup failed", "err", err)
} else if n > 0 {
log.Info("log retention cleanup", "deleted", n)
}
}
}
}()
}
+69
View File
@@ -0,0 +1,69 @@
// Package metrics exposes Prometheus counters/gauges/histograms for HTTP,
// TCP/UDP, backend, and TLS activity.
package metrics
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
HTTPRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "vibeproxy_http_requests_total",
Help: "Total HTTP requests handled by the proxy.",
}, []string{"proxy_host", "method", "status"})
HTTPRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "vibeproxy_http_request_duration_seconds",
Help: "HTTP request latency observed by the proxy, end to end.",
Buckets: prometheus.DefBuckets,
}, []string{"proxy_host"})
BackendRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "vibeproxy_backend_request_duration_seconds",
Help: "Backend response latency as observed by the proxy.",
Buckets: prometheus.DefBuckets,
}, []string{"proxy_host", "backend"})
BytesTransferred = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "vibeproxy_bytes_transferred_total",
Help: "Bytes transferred through the proxy.",
}, []string{"direction", "protocol"}) // direction=in|out, protocol=http|tcp|udp
ActiveConnections = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "vibeproxy_active_connections",
Help: "Currently active connections.",
}, []string{"protocol"}) // http|tcp|udp
BackendHealth = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "vibeproxy_backend_healthy",
Help: "1 if the backend is currently considered healthy, else 0.",
}, []string{"proxy_host", "backend"})
TCPConnectionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "vibeproxy_tcp_connections_total",
Help: "Total TCP stream connections accepted.",
}, []string{"stream"})
UDPPacketsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "vibeproxy_udp_packets_total",
Help: "Total UDP packets proxied.",
}, []string{"stream", "direction"})
TLSHandshakesTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "vibeproxy_tls_handshakes_total",
Help: "Total TLS handshakes, by result.",
}, []string{"result"}) // ok|error
CertificateExpirySeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "vibeproxy_certificate_expiry_seconds",
Help: "Seconds until certificate expiry (negative if already expired).",
}, []string{"name"})
)
func Handler() http.Handler {
return promhttp.Handler()
}
+237
View File
@@ -0,0 +1,237 @@
// Package models defines the persistent entities stored in SQLite.
package models
import "time"
type User struct {
ID int64
Username string
PasswordHash string
MustChangePW bool
CreatedAt time.Time
UpdatedAt time.Time
}
type APIToken struct {
ID int64
Name string
TokenHash string
Prefix string
CreatedAt time.Time
LastUsedAt *time.Time
RevokedAt *time.Time
}
type ProxyHostType string
const (
ProxyHostTypeProxy ProxyHostType = "proxy"
ProxyHostTypeRedirect ProxyHostType = "redirect"
)
type ProxyHost struct {
ID int64
Name string
Type ProxyHostType
Domains []string
ListenAddr string // e.g. "0.0.0.0" or "" for all
ForwardScheme string // http or https (upstream scheme)
PreserveHostHeader bool
WebsocketSupport bool
ForceHTTPS bool
HSTSEnabled bool
CertificateID *int64
AccessListID *int64
ConnectTimeoutMS int
ReadTimeoutMS int
WriteTimeoutMS int
IdleTimeoutMS int
LoadBalancingMethod string // round_robin, least_conn, weighted
RedirectTargetURL string
RedirectCode int
RedirectPreservePath bool
RedirectPreserveQS bool
Enabled bool
Comments string
CreatedAt time.Time
UpdatedAt time.Time
Backends []Backend
CustomHeaders []CustomHeader
}
type CustomHeaderAction string
const (
HeaderActionSet CustomHeaderAction = "set"
HeaderActionRemove CustomHeaderAction = "remove"
)
type CustomHeaderDirection string
const (
HeaderDirectionRequest CustomHeaderDirection = "request"
HeaderDirectionResponse CustomHeaderDirection = "response"
)
type CustomHeader struct {
ID int64
ProxyHostID int64
Direction CustomHeaderDirection
Action CustomHeaderAction
Name string
Value string
}
type HealthCheckType string
const (
HealthCheckTCP HealthCheckType = "tcp"
HealthCheckHTTP HealthCheckType = "http"
HealthCheckNone HealthCheckType = "none"
)
type Backend struct {
ID int64
ProxyHostID int64
Host string
Port int
Weight int
Enabled bool
HealthCheckType HealthCheckType
HealthCheckPath string
HealthCheckStatus int
IntervalSeconds int
FailureThreshold int
RecoveryThreshold int
// Runtime-only (not persisted)
Healthy bool `db:"-"`
}
type StreamProtocol string
const (
StreamTCP StreamProtocol = "tcp"
StreamUDP StreamProtocol = "udp"
)
type Stream struct {
ID int64
Protocol StreamProtocol
ListenIP string
ListenPort int
DestinationHost string
DestinationPort int
ConnectTimeoutMS int
IdleTimeoutMS int
ProxyProtocol bool
Enabled bool
Comments string
CreatedAt time.Time
UpdatedAt time.Time
}
type CertificateType string
const (
CertTypeLetsEncrypt CertificateType = "letsencrypt"
CertTypeCustom CertificateType = "custom"
)
type ChallengeType string
const (
ChallengeHTTP01 ChallengeType = "http-01"
ChallengeDNS01 ChallengeType = "dns-01"
)
type Certificate struct {
ID int64
Name string
Type CertificateType
Domains []string
ChallengeType ChallengeType
DNSProvider string
CertPath string
KeyPath string
NotBefore *time.Time
NotAfter *time.Time
LastRenewedAt *time.Time
LastError string
AutoRenew bool
CreatedAt time.Time
UpdatedAt time.Time
}
type AccessList struct {
ID int64
Name string
DefaultAllow bool
Comments string
CreatedAt time.Time
UpdatedAt time.Time
Rules []AccessRule
BasicAuthUsers []AccessListUser
}
type AccessRuleAction string
const (
AccessAllow AccessRuleAction = "allow"
AccessDeny AccessRuleAction = "deny"
)
type AccessRule struct {
ID int64
AccessListID int64
Action AccessRuleAction
CIDR string
Priority int
}
type AccessListUser struct {
ID int64
AccessListID int64
Username string
PasswordHash string
}
type RequestLog struct {
ID int64
Timestamp time.Time
ProxyHostID *int64
ProxyHostName string
ClientIP string
Method string
Host string
Path string
Status int
ResponseSize int64
Backend string
ResponseTimeMS int64
UserAgent string
}
type AuditLog struct {
ID int64
Timestamp time.Time
Actor string
Action string
Target string
Details string
}
type Settings struct {
AdminUsername string
ACMEEmail string
ACMEStaging bool
ACMERenewalWindowDays int
CertStoragePath string
LogRetentionDays int
TrustedProxyCIDRs []string
BackupEnabled bool
BackupIntervalHours int
BackupRetentionCount int
BackupPath string
}
+43
View File
@@ -0,0 +1,43 @@
package proxy
import (
"net"
"net/http"
"strings"
"github.com/owen/vibeproxy/internal/runtime"
)
// ClientIP returns the real client IP for r. If the immediate peer is a
// trusted proxy (per snap.TrustedProxies), the right-most untrusted entry
// in X-Forwarded-For is used; otherwise X-Forwarded-For is ignored
// entirely and the TCP peer address is authoritative. This is the only
// place XFF is trusted, and only for configured networks.
func ClientIP(r *http.Request, snap *runtime.Snapshot) string {
peerIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
peerIP = r.RemoteAddr
}
ip := net.ParseIP(peerIP)
if ip == nil || snap == nil || !snap.IsTrustedProxy(ip) {
return peerIP
}
xff := r.Header.Get("X-Forwarded-For")
if xff == "" {
return peerIP
}
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
candidate := strings.TrimSpace(parts[i])
cip := net.ParseIP(candidate)
if cip == nil {
continue
}
if snap.IsTrustedProxy(cip) {
continue // still a known proxy hop, keep walking left
}
return candidate
}
return peerIP
}
+193
View File
@@ -0,0 +1,193 @@
package proxy
import (
"database/sql"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/owen/vibeproxy/internal/access"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/certificates"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/metrics"
"github.com/owen/vibeproxy/internal/models"
"github.com/owen/vibeproxy/internal/runtime"
)
// maxRequestBodyBytes bounds request bodies to protect the proxy itself;
// it is generous enough for large uploads while still being a hard limit.
const maxRequestBodyBytes = 5 << 30 // 5 GiB
// Handler is the single entry point for both the HTTP and HTTPS listeners.
// It resolves the target proxy host, enforces access control and
// force-HTTPS/redirect rules, picks a healthy backend, and proxies the
// request — or serves an ACME HTTP-01 challenge.
type Handler struct {
store *runtime.Store
certMgr *certificates.Manager
reqLog *logging.RequestLogger
db *sql.DB
log *slog.Logger
}
func NewHandler(store *runtime.Store, certMgr *certificates.Manager, reqLog *logging.RequestLogger, db *sql.DB, log *slog.Logger) *Handler {
return &Handler{store: store, certMgr: certMgr, reqLog: reqLog, db: db, log: log}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.certMgr.ServeChallenge(w, r) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
snap := h.store.Current()
rt := snap.LookupHost(r.Host)
if rt == nil {
http.Error(w, "404 - no proxy host configured for this domain", http.StatusNotFound)
return
}
if !rt.Model.Enabled {
http.Error(w, "404 - not found", http.StatusNotFound)
return
}
clientIP := ClientIP(r, snap)
if rt.AccessList != nil {
if !h.checkAccess(w, r, rt.AccessList, clientIP) {
return
}
}
if rt.Model.ForceHTTPS && r.TLS == nil {
target := "https://" + normalizeHostHeader(r.Host) + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
return
}
if rt.Model.Type == models.ProxyHostTypeRedirect {
h.serveRedirect(w, r, rt)
return
}
h.serveProxy(w, r, rt, snap, clientIP)
}
func (h *Handler) checkAccess(w http.ResponseWriter, r *http.Request, list *access.List, clientIP string) bool {
ip := parseIPLoose(clientIP)
if ip == nil || !list.Allowed(ip) {
http.Error(w, "403 - forbidden", http.StatusForbidden)
return false
}
if list.RequiresAuth() {
username, password, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
http.Error(w, "401 - authentication required", http.StatusUnauthorized)
return false
}
hash, exists := list.Users[username]
if !exists {
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
http.Error(w, "401 - authentication required", http.StatusUnauthorized)
return false
}
valid, err := auth.VerifyPassword(password, hash)
if err != nil || !valid {
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
http.Error(w, "401 - authentication required", http.StatusUnauthorized)
return false
}
}
return true
}
func (h *Handler) serveRedirect(w http.ResponseWriter, r *http.Request, rt *runtime.ProxyHostRuntime) {
target := rt.Model.RedirectTargetURL
if target == "" {
http.Error(w, "502 - redirect host has no target configured", http.StatusBadGateway)
return
}
target = strings.TrimRight(target, "/")
if rt.Model.RedirectPreservePath {
target += r.URL.Path
}
if rt.Model.RedirectPreserveQS && r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
code := rt.Model.RedirectCode
switch code {
case 301, 302, 307, 308:
default:
code = http.StatusMovedPermanently
}
http.Redirect(w, r, target, code)
}
func (h *Handler) serveProxy(w http.ResponseWriter, r *http.Request, rt *runtime.ProxyHostRuntime, snap *runtime.Snapshot, clientIP string) {
if rt.Pool == nil {
http.Error(w, "502 - proxy host has no backends", http.StatusBadGateway)
return
}
backend, err := rt.Pool.Pick()
if err != nil {
h.log.Warn("no healthy backend", "proxy_host", rt.Model.Name, "err", err)
metrics.HTTPRequestsTotal.WithLabelValues(rt.Model.Name, r.Method, "503").Inc()
http.Error(w, "503 - no healthy backend available", http.StatusServiceUnavailable)
return
}
backend.Acquire()
defer backend.Release()
metrics.ActiveConnections.WithLabelValues("http").Inc()
defer metrics.ActiveConnections.WithLabelValues("http").Dec()
rp := buildReverseProxy(rt, backend, snap, h.log)
readTimeout := time.Duration(rt.Model.ReadTimeoutMS) * time.Millisecond
if readTimeout <= 0 {
readTimeout = 60 * time.Second
}
ctx, cancel := withConnectTimeout(r.Context(), readTimeout)
defer cancel()
r = r.WithContext(ctx)
rec := &statusRecorder{ResponseWriter: w}
start := time.Now()
rp.ServeHTTP(rec, r)
elapsed := time.Since(start)
backendAddr := backend.Addr() + ":" + strconv.Itoa(backend.Model.Port)
metrics.HTTPRequestsTotal.WithLabelValues(rt.Model.Name, r.Method, strconv.Itoa(rec.status)).Inc()
metrics.HTTPRequestDuration.WithLabelValues(rt.Model.Name).Observe(elapsed.Seconds())
metrics.BackendRequestDuration.WithLabelValues(rt.Model.Name, backendAddr).Observe(elapsed.Seconds())
metrics.BytesTransferred.WithLabelValues("out", "http").Add(float64(rec.bytes))
hostID := rt.Model.ID
h.reqLog.Log(models.RequestLog{
Timestamp: start,
ProxyHostID: &hostID,
ProxyHostName: rt.Model.Name,
ClientIP: clientIP,
Method: r.Method,
Host: r.Host,
Path: r.URL.Path,
Status: rec.status,
ResponseSize: rec.bytes,
Backend: backendAddr,
ResponseTimeMS: elapsed.Milliseconds(),
UserAgent: r.UserAgent(),
})
}
func parseIPLoose(s string) net.IP {
if h, _, err := net.SplitHostPort(s); err == nil {
s = h
}
return net.ParseIP(s)
}
+55
View File
@@ -0,0 +1,55 @@
package proxy
import (
"bufio"
"fmt"
"net"
"net/http"
)
// statusRecorder wraps http.ResponseWriter to capture status code and
// bytes written for logging/metrics, while still supporting hijacking
// (required for WebSocket upgrades) and flushing (required for streaming
// responses / SSE).
type statusRecorder struct {
http.ResponseWriter
status int
bytes int64
wroteHeader bool
}
func (w *statusRecorder) WriteHeader(code int) {
if w.wroteHeader {
return
}
w.status = code
w.wroteHeader = true
w.ResponseWriter.WriteHeader(code)
}
func (w *statusRecorder) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
n, err := w.ResponseWriter.Write(b)
w.bytes += int64(n)
return n, err
}
func (w *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("underlying ResponseWriter does not support hijacking")
}
// A hijacked WebSocket connection never goes through WriteHeader, so
// treat it as a successful 101 for logging purposes.
w.status = http.StatusSwitchingProtocols
w.wroteHeader = true
return hj.Hijack()
}
func (w *statusRecorder) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
+140
View File
@@ -0,0 +1,140 @@
package proxy
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/owen/vibeproxy/internal/loadbalancer"
"github.com/owen/vibeproxy/internal/metrics"
"github.com/owen/vibeproxy/internal/models"
"github.com/owen/vibeproxy/internal/runtime"
)
// sharedTransport is reused across every proxied request so backend
// connections are pooled (keep-alive) instead of dialed fresh each time.
// It supports HTTP/2 to upstreams that offer it via ALPN.
var sharedTransport = &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
// buildReverseProxy constructs a one-shot ReverseProxy for a single
// request/backend pair. Constructing it per request is cheap (no
// goroutines or connections are created here); the expensive parts —
// backend TCP/TLS connections — are pooled by sharedTransport across
// calls.
func buildReverseProxy(rt *runtime.ProxyHostRuntime, backend *loadbalancer.Backend, snap *runtime.Snapshot, log *slog.Logger) *httputil.ReverseProxy {
targetScheme := rt.Model.ForwardScheme
if targetScheme == "" {
targetScheme = "http"
}
targetAddr := net.JoinHostPort(backend.Model.Host, fmt.Sprintf("%d", backend.Model.Port))
director := func(req *http.Request) {
clientIP := ClientIP(req, snap)
priorFor := req.Header.Get("X-Forwarded-For")
req.URL.Scheme = targetScheme
req.URL.Host = targetAddr
if !rt.Model.PreserveHostHeader {
req.Host = targetAddr
}
// else: leave req.Host as the original inbound Host header
if priorFor == "" {
req.Header.Set("X-Forwarded-For", clientIP)
} else {
req.Header.Set("X-Forwarded-For", priorFor+", "+clientIP)
}
proto := "http"
if req.TLS != nil {
proto = "https"
}
req.Header.Set("X-Forwarded-Proto", proto)
if fh := req.Header.Get("X-Forwarded-Host"); fh == "" {
req.Header.Set("X-Forwarded-Host", req.Host)
}
req.Header.Set("X-Real-IP", clientIP)
applyCustomHeaders(req.Header, rt.Model.CustomHeaders, models.HeaderDirectionRequest)
// WebSocket upgrade headers must pass through untouched; Go's
// httputil.ReverseProxy handles the hijack/tunnel automatically
// once Connection: Upgrade is present, which we never strip.
if !rt.Model.WebsocketSupport {
req.Header.Del("Upgrade")
req.Header.Set("Connection", "close")
}
}
modifyResponse := func(resp *http.Response) error {
applyCustomHeaders(resp.Header, rt.Model.CustomHeaders, models.HeaderDirectionResponse)
if rt.Model.HSTSEnabled && resp.Request.TLS != nil {
resp.Header.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
return nil
}
errorHandler := func(w http.ResponseWriter, r *http.Request, err error) {
log.Warn("backend request failed", "proxy_host", rt.Model.Name, "backend", targetAddr, "err", err)
metrics.HTTPRequestsTotal.WithLabelValues(rt.Model.Name, r.Method, "502").Inc()
http.Error(w, "Bad Gateway", http.StatusBadGateway)
}
return &httputil.ReverseProxy{
Director: director,
ModifyResponse: modifyResponse,
ErrorHandler: errorHandler,
Transport: sharedTransport,
FlushInterval: 200 * time.Millisecond, // needed for SSE/streaming responses
}
}
func applyCustomHeaders(h http.Header, headers []models.CustomHeader, direction models.CustomHeaderDirection) {
for _, c := range headers {
if c.Direction != direction {
continue
}
switch c.Action {
case models.HeaderActionSet:
h.Set(c.Name, c.Value)
case models.HeaderActionRemove:
h.Del(c.Name)
}
}
}
// withConnectTimeout returns a context that bounds only the dial phase of
// an outbound request via a deadline; read/write timeouts are handled by
// the surrounding http.Server / http.ResponseController.
func withConnectTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
if d <= 0 {
d = 5 * time.Second
}
deadline := time.Now().Add(d)
return context.WithDeadline(ctx, deadline)
}
func normalizeHostHeader(host string) string {
if h, _, err := net.SplitHostPort(host); err == nil {
return strings.ToLower(h)
}
return strings.ToLower(host)
}
+120
View File
@@ -0,0 +1,120 @@
package proxy
import (
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/owen/vibeproxy/internal/certificates"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/metrics"
"github.com/owen/vibeproxy/internal/runtime"
)
// Server owns the plain-HTTP and TLS listeners that serve all proxy hosts.
type Server struct {
httpSrv *http.Server
httpsSrv *http.Server
log *slog.Logger
}
type Options struct {
HTTPAddr string // e.g. ":80"
HTTPSAddr string // e.g. ":443"
}
func NewServer(opts Options, store *runtime.Store, certMgr *certificates.Manager, db *sql.DB, log *slog.Logger) *Server {
reqLog := logging.NewRequestLogger(db, log)
go reqLog.Run(context.Background())
handler := NewHandler(store, certMgr, reqLog, db, log)
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2", "http/1.1"},
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := certMgr.GetCertificateForDomain(hello.ServerName)
if err != nil {
metrics.TLSHandshakesTotal.WithLabelValues("error").Inc()
return nil, err
}
metrics.TLSHandshakesTotal.WithLabelValues("ok").Inc()
return cert, nil
},
}
httpAddr := opts.HTTPAddr
if httpAddr == "" {
httpAddr = ":80"
}
httpsAddr := opts.HTTPSAddr
if httpsAddr == "" {
httpsAddr = ":443"
}
return &Server{
log: log,
httpSrv: &http.Server{
Addr: httpAddr,
Handler: handler,
ReadTimeout: 0, // per-request timeouts are enforced inside the handler
WriteTimeout: 0,
IdleTimeout: 120 * time.Second,
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
},
httpsSrv: &http.Server{
Addr: httpsAddr,
Handler: handler,
TLSConfig: tlsConfig,
IdleTimeout: 120 * time.Second,
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
},
}
}
// Start runs both listeners; it blocks until either fails or ctx is done.
func (s *Server) Start(ctx context.Context) error {
errCh := make(chan error, 2)
go func() {
s.log.Info("http listener starting", "addr", s.httpSrv.Addr)
if err := s.httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("http listener: %w", err)
}
}()
go func() {
s.log.Info("https listener starting", "addr", s.httpsSrv.Addr)
// Certificates are served dynamically via GetCertificate, so we
// don't pass cert/key file paths here.
if err := s.httpsSrv.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("https listener: %w", err)
}
}()
select {
case <-ctx.Done():
return s.Shutdown(context.Background())
case err := <-errCh:
s.Shutdown(context.Background())
return err
}
}
// Shutdown gracefully drains in-flight requests/connections before
// closing the listeners.
func (s *Server) Shutdown(ctx context.Context) error {
shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
err1 := s.httpSrv.Shutdown(shutdownCtx)
err2 := s.httpsSrv.Shutdown(shutdownCtx)
if err1 != nil {
return err1
}
return err2
}
+212
View File
@@ -0,0 +1,212 @@
// Package runtime holds the active, in-memory configuration snapshot that
// the hot proxy/stream paths read. SQLite is only touched when the
// configuration changes; every request/connection reads this snapshot.
package runtime
import (
"context"
"crypto/tls"
"database/sql"
"net"
"strconv"
"strings"
"sync"
"sync/atomic"
"github.com/owen/vibeproxy/internal/access"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/health"
"github.com/owen/vibeproxy/internal/loadbalancer"
"github.com/owen/vibeproxy/internal/models"
)
// ProxyHostRuntime is one HTTP(S) proxy or redirect host, resolved and
// ready to serve traffic.
type ProxyHostRuntime struct {
Model models.ProxyHost
Pool *loadbalancer.Pool
AccessList *access.List // nil if none attached
}
// StreamRuntime is one TCP/UDP stream, ready to serve traffic.
type StreamRuntime struct {
Model models.Stream
}
// Snapshot is an immutable view of the whole active configuration.
type Snapshot struct {
HostsByDomain map[string]*ProxyHostRuntime // lowercase domain -> host
AccessLists map[int64]*access.List
Settings models.Settings
TrustedProxies []*net.IPNet
}
func (s *Snapshot) LookupHost(hostHeader string) *ProxyHostRuntime {
name := hostHeader
if h, _, err := net.SplitHostPort(hostHeader); err == nil {
name = h
}
name = strings.ToLower(strings.TrimSuffix(name, "."))
return s.HostsByDomain[name]
}
func (s *Snapshot) IsTrustedProxy(ip net.IP) bool {
for _, n := range s.TrustedProxies {
if n.Contains(ip) {
return true
}
}
return false
}
// Store owns the current Snapshot (read via atomic pointer for lock-free,
// allocation-free reads on the hot path) and knows how to rebuild it from
// the database.
type Store struct {
db *sql.DB
current atomic.Pointer[Snapshot]
checker *health.Checker
watchCtx context.Context
watchStop context.CancelFunc
watchMu sync.Mutex
onHealthChange func(hostID int64, backendAddr string, healthy bool)
}
func NewStore(db *sql.DB) *Store {
s := &Store{db: db}
s.checker = health.NewChecker(s.handleHealthChange)
s.current.Store(&Snapshot{HostsByDomain: map[string]*ProxyHostRuntime{}, AccessLists: map[int64]*access.List{}})
return s
}
func (s *Store) Current() *Snapshot { return s.current.Load() }
func (s *Store) SetHealthChangeCallback(f func(hostID int64, backendAddr string, healthy bool)) {
s.onHealthChange = f
}
func (s *Store) handleHealthChange(hostID int64, b *loadbalancer.Backend, healthy bool) {
if s.onHealthChange != nil {
s.onHealthChange(hostID, net.JoinHostPort(b.Model.Host, strconv.Itoa(b.Model.Port)), healthy)
}
}
// Reload rebuilds the snapshot from the database and atomically swaps it
// in. Existing in-flight requests keep using the previous snapshot object
// until they finish; new requests immediately see the new one.
func (s *Store) Reload(ctx context.Context) error {
hosts, err := database.ListProxyHosts(s.db)
if err != nil {
return err
}
accessLists, err := database.ListAccessLists(s.db)
if err != nil {
return err
}
settingsMap, err := database.AllSettings(s.db)
if err != nil {
return err
}
settings := settingsFromMap(settingsMap)
compiledACLs := map[int64]*access.List{}
for _, a := range accessLists {
compiledACLs[a.ID] = access.Compile(a)
}
var trusted []*net.IPNet
for _, c := range settings.TrustedProxyCIDRs {
if _, n, err := net.ParseCIDR(c); err == nil {
trusted = append(trusted, n)
}
}
snap := &Snapshot{
HostsByDomain: map[string]*ProxyHostRuntime{},
AccessLists: compiledACLs,
Settings: settings,
TrustedProxies: trusted,
}
// Stop the previous health-check watchers before starting new ones so
// we don't leak goroutines across reloads.
s.watchMu.Lock()
if s.watchStop != nil {
s.watchStop()
}
watchCtx, cancel := context.WithCancel(context.Background())
s.watchCtx, s.watchStop = watchCtx, cancel
s.watchMu.Unlock()
for _, h := range hosts {
if !h.Enabled {
continue
}
var pool *loadbalancer.Pool
if h.Type == models.ProxyHostTypeProxy {
backends := make([]*loadbalancer.Backend, 0, len(h.Backends))
for _, bm := range h.Backends {
if !bm.Enabled {
continue
}
b := loadbalancer.NewBackend(bm)
backends = append(backends, b)
s.checker.Watch(watchCtx, h.ID, b)
}
pool = loadbalancer.NewPool(h.LoadBalancingMethod, backends)
}
var acl *access.List
if h.AccessListID != nil {
acl = compiledACLs[*h.AccessListID]
}
rt := &ProxyHostRuntime{Model: h, Pool: pool, AccessList: acl}
for _, d := range h.Domains {
snap.HostsByDomain[strings.ToLower(d)] = rt
}
}
s.current.Store(snap)
return nil
}
func settingsFromMap(m map[string]string) models.Settings {
s := models.Settings{
AdminUsername: get(m, "admin_username", "admin"),
ACMEEmail: get(m, "acme_email", ""),
ACMEStaging: get(m, "acme_staging", "false") == "true",
ACMERenewalWindowDays: atoi(get(m, "acme_renewal_window_days", "30"), 30),
CertStoragePath: get(m, "cert_storage_path", "./data/certs"),
LogRetentionDays: atoi(get(m, "log_retention_days", "30"), 30),
BackupEnabled: get(m, "backup_enabled", "true") == "true",
BackupIntervalHours: atoi(get(m, "backup_interval_hours", "24"), 24),
BackupRetentionCount: atoi(get(m, "backup_retention_count", "7"), 7),
BackupPath: get(m, "backup_path", "./data/backups"),
}
if tp := get(m, "trusted_proxy_cidrs", ""); tp != "" {
s.TrustedProxyCIDRs = strings.Split(tp, ",")
}
return s
}
func get(m map[string]string, k, def string) string {
if v, ok := m[k]; ok && v != "" {
return v
}
return def
}
func atoi(s string, def int) int {
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
// TLSCertLookup is implemented by the certificate manager and used for
// SNI-based certificate selection in the HTTPS listener.
type TLSCertLookup interface {
GetCertificateForDomain(domain string) (*tls.Certificate, error)
}
+46
View File
@@ -0,0 +1,46 @@
package runtime
import (
"net"
"testing"
"github.com/owen/vibeproxy/internal/models"
)
func TestSnapshotLookupHost(t *testing.T) {
snap := &Snapshot{
HostsByDomain: map[string]*ProxyHostRuntime{
"example.com": {Model: models.ProxyHost{Name: "example"}},
},
}
if rt := snap.LookupHost("Example.com:8443"); rt == nil || rt.Model.Name != "example" {
t.Fatalf("expected case-insensitive, port-stripped lookup to match, got %+v", rt)
}
if rt := snap.LookupHost("other.com"); rt != nil {
t.Fatalf("expected no match for unknown host, got %+v", rt)
}
}
func TestSnapshotTrustedProxy(t *testing.T) {
_, cidr, err := net.ParseCIDR("10.0.0.0/8")
if err != nil {
t.Fatal(err)
}
snap := &Snapshot{TrustedProxies: []*net.IPNet{cidr}}
if !snap.IsTrustedProxy(net.ParseIP("10.1.2.3")) {
t.Fatal("expected 10.1.2.3 to be trusted")
}
if snap.IsTrustedProxy(net.ParseIP("8.8.8.8")) {
t.Fatal("expected 8.8.8.8 not to be trusted")
}
}
func TestSettingsFromMapDefaults(t *testing.T) {
s := settingsFromMap(map[string]string{})
if s.LogRetentionDays != 30 {
t.Fatalf("expected default retention 30, got %d", s.LogRetentionDays)
}
if s.AdminUsername != "admin" {
t.Fatalf("expected default admin username, got %q", s.AdminUsername)
}
}
+125
View File
@@ -0,0 +1,125 @@
// Package stats computes dashboard/API statistics from request logs and
// the live runtime snapshot.
package stats
import (
"database/sql"
"time"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/models"
"github.com/owen/vibeproxy/internal/runtime"
)
type Dashboard struct {
ActiveProxyHosts int `json:"active_proxy_hosts"`
ActiveTCPStreams int `json:"active_tcp_streams"`
ActiveUDPStreams int `json:"active_udp_streams"`
RequestsLastHour int `json:"requests_last_hour"`
RequestsPerSecond float64 `json:"requests_per_second"`
TotalRequestsToday int `json:"total_requests_today"`
StatusBreakdown []database.StatusCount `json:"status_breakdown"`
BackendHealth []BackendHealthEntry `json:"backend_health"`
CertsExpiringSoon []models.Certificate `json:"certs_expiring_soon"`
TopHosts []database.HostCount `json:"top_hosts"`
TopClients []database.HostCount `json:"top_clients"`
RecentErrors []models.RequestLog `json:"recent_errors"`
}
type BackendHealthEntry struct {
ProxyHost string `json:"proxy_host"`
Backend string `json:"backend"`
Healthy bool `json:"healthy"`
}
func Compute(db *sql.DB, snap *runtime.Snapshot) (*Dashboard, error) {
d := &Dashboard{}
seen := map[int64]bool{}
for _, rt := range snap.HostsByDomain {
if seen[rt.Model.ID] {
continue
}
seen[rt.Model.ID] = true
if rt.Model.Type == models.ProxyHostTypeProxy {
d.ActiveProxyHosts++
}
if rt.Pool != nil {
for _, b := range rt.Pool.Backends {
d.BackendHealth = append(d.BackendHealth, BackendHealthEntry{
ProxyHost: rt.Model.Name,
Backend: b.Model.Host,
Healthy: b.Healthy(),
})
}
}
}
streams, err := database.ListStreams(db)
if err != nil {
return nil, err
}
for _, s := range streams {
if !s.Enabled {
continue
}
if s.Protocol == models.StreamTCP {
d.ActiveTCPStreams++
} else {
d.ActiveUDPStreams++
}
}
requestsLastHour, err := database.CountRequestLogsSince(db, "-1 hours")
if err != nil {
return nil, err
}
d.RequestsLastHour = requestsLastHour
d.RequestsPerSecond = float64(requestsLastHour) / 3600.0
totalToday, err := database.CountRequestLogsSince(db, "-24 hours")
if err != nil {
return nil, err
}
d.TotalRequestsToday = totalToday
statuses, err := database.StatusBreakdownSince(db, "-24 hours")
if err != nil {
return nil, err
}
d.StatusBreakdown = statuses
topHosts, err := database.TopHostsSince(db, "-24 hours", 10)
if err != nil {
return nil, err
}
d.TopHosts = topHosts
topClients, err := database.TopClientsSince(db, "-24 hours", 10)
if err != nil {
return nil, err
}
d.TopClients = topClients
recentErrors, err := database.RecentErrorsSince(db, "-24 hours", 20)
if err != nil {
return nil, err
}
d.RecentErrors = recentErrors
certs, err := database.ListCertificates(db)
if err != nil {
return nil, err
}
for _, c := range certs {
if c.NotAfter == nil {
continue
}
days := int(c.NotAfter.Sub(time.Now()).Hours() / 24)
if days <= 30 {
d.CertsExpiringSoon = append(d.CertsExpiringSoon, c)
}
}
return d, nil
}
+113
View File
@@ -0,0 +1,113 @@
package streams
import (
"context"
"database/sql"
"log/slog"
"reflect"
"sync"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/models"
)
type running struct {
model models.Stream
cancel context.CancelFunc
tcp *TCPProxy
udp *UDPProxy
}
// Manager owns the set of currently-listening TCP/UDP streams and can
// reconcile it against the database without disturbing streams that
// haven't changed, so existing connections on unaffected streams are never
// dropped by a reload.
type Manager struct {
db *sql.DB
log *slog.Logger
mu sync.Mutex
active map[int64]*running // stream ID -> running listener
baseCtx context.Context
}
func NewManager(db *sql.DB, log *slog.Logger) *Manager {
return &Manager{db: db, log: log, active: map[int64]*running{}, baseCtx: context.Background()}
}
// Reload fetches the current stream configuration and starts/stops
// listeners to match it. Streams whose configuration is unchanged are left
// running untouched.
func (m *Manager) Reload(ctx context.Context) error {
streams, err := database.ListStreams(m.db)
if err != nil {
return err
}
desired := map[int64]models.Stream{}
for _, s := range streams {
if s.Enabled {
desired[s.ID] = s
}
}
m.mu.Lock()
defer m.mu.Unlock()
// Stop streams that were removed or disabled.
for id, r := range m.active {
if _, ok := desired[id]; !ok {
m.stopLocked(id, r)
}
}
for id, s := range desired {
if r, ok := m.active[id]; ok {
if reflect.DeepEqual(r.model, s) {
continue // unchanged, leave the listener (and its connections) alone
}
m.stopLocked(id, r)
}
m.startLocked(id, s)
}
return nil
}
func (m *Manager) startLocked(id int64, s models.Stream) {
ctx, cancel := context.WithCancel(m.baseCtx)
r := &running{model: s, cancel: cancel}
var err error
switch s.Protocol {
case models.StreamTCP:
r.tcp = NewTCPProxy(s, m.log)
err = r.tcp.Start(ctx)
case models.StreamUDP:
r.udp = NewUDPProxy(s, m.log)
err = r.udp.Start(ctx)
}
if err != nil {
m.log.Error("failed to start stream", "id", id, "protocol", s.Protocol, "err", err)
cancel()
return
}
m.active[id] = r
}
func (m *Manager) stopLocked(id int64, r *running) {
r.cancel()
if r.tcp != nil {
r.tcp.Stop()
}
if r.udp != nil {
r.udp.Stop()
}
delete(m.active, id)
}
// StopAll shuts down every running stream (used on process shutdown).
func (m *Manager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id, r := range m.active {
m.stopLocked(id, r)
}
}
+162
View File
@@ -0,0 +1,162 @@
// Package streams implements raw TCP and UDP proxying, independent of the
// HTTP reverse proxy, for arbitrary protocols (game servers, VPNs, etc).
package streams
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"strconv"
"sync"
"time"
"github.com/owen/vibeproxy/internal/metrics"
"github.com/owen/vibeproxy/internal/models"
)
// TCPProxy listens for TCP connections and forwards each to the
// configured destination, optionally prefixing the outbound connection
// with a PROXY protocol v1 header so the backend can see the real client
// address.
type TCPProxy struct {
model models.Stream
listener net.Listener
log *slog.Logger
wg sync.WaitGroup
}
func NewTCPProxy(model models.Stream, log *slog.Logger) *TCPProxy {
return &TCPProxy{model: model, log: log.With("stream", streamLabel(model))}
}
func streamLabel(m models.Stream) string {
return fmt.Sprintf("tcp:%s:%d", m.ListenIP, m.ListenPort)
}
func (p *TCPProxy) Start(ctx context.Context) error {
addr := net.JoinHostPort(p.model.ListenIP, strconv.Itoa(p.model.ListenPort))
lc := net.ListenConfig{}
ln, err := lc.Listen(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
p.listener = ln
p.log.Info("tcp stream listening", "addr", addr, "destination", fmt.Sprintf("%s:%d", p.model.DestinationHost, p.model.DestinationPort))
go func() {
<-ctx.Done()
ln.Close()
}()
p.wg.Add(1)
go p.acceptLoop(ctx)
return nil
}
func (p *TCPProxy) acceptLoop(ctx context.Context) {
defer p.wg.Done()
for {
conn, err := p.listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
p.log.Warn("accept error", "err", err)
return
}
}
p.wg.Add(1)
go func() {
defer p.wg.Done()
p.handleConn(ctx, conn)
}()
}
}
func (p *TCPProxy) handleConn(ctx context.Context, client net.Conn) {
defer client.Close()
metrics.ActiveConnections.WithLabelValues("tcp").Inc()
defer metrics.ActiveConnections.WithLabelValues("tcp").Dec()
metrics.TCPConnectionsTotal.WithLabelValues(streamLabel(p.model)).Inc()
dest := net.JoinHostPort(p.model.DestinationHost, strconv.Itoa(p.model.DestinationPort))
connectTimeout := time.Duration(p.model.ConnectTimeoutMS) * time.Millisecond
if connectTimeout <= 0 {
connectTimeout = 5 * time.Second
}
d := net.Dialer{Timeout: connectTimeout}
backend, err := d.DialContext(ctx, "tcp", dest)
if err != nil {
p.log.Warn("backend dial failed", "dest", dest, "err", err)
return
}
defer backend.Close()
if p.model.ProxyProtocol {
if err := writeProxyProtocolV1(backend, client.RemoteAddr(), backend.LocalAddr()); err != nil {
p.log.Warn("failed to write PROXY protocol header", "err", err)
return
}
}
idleTimeout := time.Duration(p.model.IdleTimeoutMS) * time.Millisecond
if idleTimeout <= 0 {
idleTimeout = 5 * time.Minute
}
var wg sync.WaitGroup
wg.Add(2)
pipe := func(dst, src net.Conn, direction string) {
defer wg.Done()
buf := make([]byte, 32*1024)
for {
src.SetReadDeadline(time.Now().Add(idleTimeout))
n, err := src.Read(buf)
if n > 0 {
metrics.BytesTransferred.WithLabelValues(direction, "tcp").Add(float64(n))
if _, werr := dst.Write(buf[:n]); werr != nil {
return
}
}
if err != nil {
if err != io.EOF {
if ne, ok := dst.(interface{ CloseWrite() error }); ok {
ne.CloseWrite()
}
}
return
}
}
}
go pipe(backend, client, "in")
go pipe(client, backend, "out")
wg.Wait()
}
func (p *TCPProxy) Stop() {
if p.listener != nil {
p.listener.Close()
}
p.wg.Wait()
}
// writeProxyProtocolV1 writes a human-readable PROXY protocol v1 header
// (as used by nginx/HAProxy) so the backend can recover the real client
// address even though the connection now originates from vibeproxy.
func writeProxyProtocolV1(w io.Writer, clientAddr, proxyAddr net.Addr) error {
ct, ok1 := clientAddr.(*net.TCPAddr)
pt, ok2 := proxyAddr.(*net.TCPAddr)
if !ok1 || !ok2 {
_, err := fmt.Fprintf(w, "PROXY UNKNOWN\r\n")
return err
}
family := "TCP4"
if ct.IP.To4() == nil {
family = "TCP6"
}
_, err := fmt.Fprintf(w, "PROXY %s %s %s %d %d\r\n", family, ct.IP.String(), pt.IP.String(), ct.Port, pt.Port)
return err
}
+96
View File
@@ -0,0 +1,96 @@
package streams
import (
"context"
"io"
"log/slog"
"net"
"testing"
"time"
"github.com/owen/vibeproxy/internal/models"
)
func TestTCPProxyEndToEnd(t *testing.T) {
// Echo backend.
backendLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer backendLn.Close()
go func() {
for {
c, err := backendLn.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
io.Copy(c, c)
}()
}
}()
backendAddr := backendLn.Addr().(*net.TCPAddr)
model := models.Stream{
Protocol: models.StreamTCP, ListenIP: "127.0.0.1", ListenPort: 0,
DestinationHost: "127.0.0.1", DestinationPort: backendAddr.Port,
ConnectTimeoutMS: 2000, IdleTimeoutMS: 5000,
}
// Bind an ephemeral port ourselves so we know it up front, matching
// what NewTCPProxy will do.
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
model.ListenPort = probe.Addr().(*net.TCPAddr).Port
probe.Close()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
proxy := NewTCPProxy(model, log)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := proxy.Start(ctx); err != nil {
t.Fatal(err)
}
defer proxy.Stop()
time.Sleep(50 * time.Millisecond)
conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", itoa(model.ListenPort)))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if _, err := conn.Write([]byte("ping")); err != nil {
t.Fatal(err)
}
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
buf := make([]byte, 4)
if _, err := io.ReadFull(conn, buf); err != nil {
t.Fatal(err)
}
if string(buf) != "ping" {
t.Fatalf("expected echo of ping, got %q", buf)
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
if neg {
b = append([]byte{'-'}, b...)
}
return string(b)
}
+190
View File
@@ -0,0 +1,190 @@
package streams
import (
"context"
"fmt"
"log/slog"
"net"
"sync"
"time"
"github.com/owen/vibeproxy/internal/metrics"
"github.com/owen/vibeproxy/internal/models"
)
// UDPProxy is a connectionless proxy that tracks per-client "sessions" (a
// backend UDP socket per source address) so replies are routed back to the
// correct client, with idle sessions reaped after the configured timeout.
type UDPProxy struct {
model models.Stream
log *slog.Logger
conn *net.UDPConn
mu sync.Mutex
sessions map[string]*udpSession
}
type udpSession struct {
clientAddr *net.UDPAddr
backend *net.UDPConn
lastActive time.Time
}
func NewUDPProxy(model models.Stream, log *slog.Logger) *UDPProxy {
return &UDPProxy{model: model, log: log.With("stream", udpLabel(model)), sessions: map[string]*udpSession{}}
}
func udpLabel(m models.Stream) string { return fmt.Sprintf("udp:%s:%d", m.ListenIP, m.ListenPort) }
func (p *UDPProxy) Start(ctx context.Context) error {
addr := &net.UDPAddr{IP: net.ParseIP(p.model.ListenIP), Port: p.model.ListenPort}
if addr.IP == nil {
addr.IP = net.IPv4zero
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
return fmt.Errorf("listen udp %s:%d: %w", p.model.ListenIP, p.model.ListenPort, err)
}
p.conn = conn
p.log.Info("udp stream listening", "addr", addr.String(), "destination", fmt.Sprintf("%s:%d", p.model.DestinationHost, p.model.DestinationPort))
go func() {
<-ctx.Done()
conn.Close()
}()
go p.reaper(ctx)
go p.readLoop(ctx)
return nil
}
func (p *UDPProxy) idleTimeout() time.Duration {
d := time.Duration(p.model.IdleTimeoutMS) * time.Millisecond
if d <= 0 {
return 2 * time.Minute
}
return d
}
func (p *UDPProxy) readLoop(ctx context.Context) {
buf := make([]byte, 65535)
dest := fmt.Sprintf("%s:%d", p.model.DestinationHost, p.model.DestinationPort)
destAddr, err := net.ResolveUDPAddr("udp", dest)
if err != nil {
p.log.Error("failed to resolve destination", "dest", dest, "err", err)
return
}
for {
n, clientAddr, err := p.conn.ReadFromUDP(buf)
if err != nil {
select {
case <-ctx.Done():
return
default:
return
}
}
metrics.UDPPacketsTotal.WithLabelValues(udpLabel(p.model), "in").Inc()
metrics.BytesTransferred.WithLabelValues("in", "udp").Add(float64(n))
sess := p.getOrCreateSession(ctx, clientAddr, destAddr)
if sess == nil {
continue
}
if _, err := sess.backend.Write(buf[:n]); err != nil {
p.log.Warn("write to backend failed", "err", err)
}
}
}
func (p *UDPProxy) getOrCreateSession(ctx context.Context, clientAddr, destAddr *net.UDPAddr) *udpSession {
key := clientAddr.String()
p.mu.Lock()
sess, ok := p.sessions[key]
if ok {
sess.lastActive = time.Now()
p.mu.Unlock()
return sess
}
p.mu.Unlock()
backendConn, err := net.DialUDP("udp", nil, destAddr)
if err != nil {
p.log.Warn("failed to dial backend", "err", err)
return nil
}
sess = &udpSession{clientAddr: clientAddr, backend: backendConn, lastActive: time.Now()}
p.mu.Lock()
p.sessions[key] = sess
p.mu.Unlock()
metrics.ActiveConnections.WithLabelValues("udp").Inc()
go p.backendReadLoop(ctx, key, sess)
return sess
}
func (p *UDPProxy) backendReadLoop(ctx context.Context, key string, sess *udpSession) {
buf := make([]byte, 65535)
for {
sess.backend.SetReadDeadline(time.Now().Add(p.idleTimeout()))
n, err := sess.backend.Read(buf)
if err != nil {
p.closeSession(key)
return
}
sess.lastActive = time.Now()
metrics.UDPPacketsTotal.WithLabelValues(udpLabel(p.model), "out").Inc()
metrics.BytesTransferred.WithLabelValues("out", "udp").Add(float64(n))
if _, err := p.conn.WriteToUDP(buf[:n], sess.clientAddr); err != nil {
return
}
}
}
func (p *UDPProxy) closeSession(key string) {
p.mu.Lock()
sess, ok := p.sessions[key]
if ok {
delete(p.sessions, key)
}
p.mu.Unlock()
if ok {
sess.backend.Close()
metrics.ActiveConnections.WithLabelValues("udp").Dec()
}
}
func (p *UDPProxy) reaper(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cutoff := time.Now().Add(-p.idleTimeout())
p.mu.Lock()
for key, sess := range p.sessions {
if sess.lastActive.Before(cutoff) {
sess.backend.Close()
delete(p.sessions, key)
metrics.ActiveConnections.WithLabelValues("udp").Dec()
}
}
p.mu.Unlock()
}
}
}
func (p *UDPProxy) Stop() {
if p.conn != nil {
p.conn.Close()
}
p.mu.Lock()
for key, sess := range p.sessions {
sess.backend.Close()
delete(p.sessions, key)
}
p.mu.Unlock()
}
+133
View File
@@ -0,0 +1,133 @@
package web
import (
"fmt"
"net/http"
"strconv"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/models"
)
func (h *Web) handleAccessListsList(w http.ResponseWriter, r *http.Request) {
lists, err := database.ListAccessLists(h.db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, "access_lists", PageData{
Active: "access_lists", Title: "Access Lists", CSRFToken: h.csrf.Token(w),
Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"),
Data: lists,
})
}
func (h *Web) handleAccessListForm(w http.ResponseWriter, r *http.Request) {
var l models.AccessList
isNew := true
if idStr := r.PathValue("id"); idStr != "" {
isNew = false
id, _ := strconv.ParseInt(idStr, 10, 64)
existing, err := database.GetAccessList(h.db, id)
if err != nil {
http.Error(w, "access list not found", http.StatusNotFound)
return
}
l = *existing
}
render(w, "access_list_form", PageData{
Active: "access_lists", Title: pageTitle(isNew, "Access List"), CSRFToken: h.csrf.Token(w),
Data: map[string]any{"List": l, "IsNew": isNew},
})
}
func (h *Web) handleAccessListCreate(w http.ResponseWriter, r *http.Request) {
h.saveAccessListForm(w, r, 0)
}
func (h *Web) handleAccessListUpdate(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
h.saveAccessListForm(w, r, id)
}
func (h *Web) saveAccessListForm(w http.ResponseWriter, r *http.Request, id int64) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
l := models.AccessList{
ID: id,
Name: r.FormValue("name"),
DefaultAllow: r.FormValue("default_allow") == "on",
Comments: r.FormValue("comments"),
}
actions := r.Form["rule_action"]
cidrs := r.Form["rule_cidr"]
for i := range cidrs {
if cidrs[i] == "" {
continue
}
l.Rules = append(l.Rules, models.AccessRule{
Action: models.AccessRuleAction(get(actions, i)),
CIDR: cidrs[i],
Priority: i,
})
}
existingHashes := map[string]string{}
if id != 0 {
if existing, err := database.GetAccessList(h.db, id); err == nil {
for _, u := range existing.BasicAuthUsers {
existingHashes[u.Username] = u.PasswordHash
}
}
}
usernames := r.Form["auth_username"]
passwords := r.Form["auth_password"]
for i := range usernames {
if usernames[i] == "" {
continue
}
hash := ""
if passwords[i] != "" {
h2, err := auth.HashPassword(passwords[i])
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
hash = h2
} else if existing, ok := existingHashes[usernames[i]]; ok {
hash = existing // password left blank on edit: keep the existing hash
} else {
continue // new user with no password set — skip
}
l.BasicAuthUsers = append(l.BasicAuthUsers, models.AccessListUser{Username: usernames[i], PasswordHash: hash})
}
if l.Name == "" {
render(w, "access_list_form", PageData{
Active: "access_lists", Title: pageTitle(id == 0, "Access List"), CSRFToken: h.csrf.Token(w),
Flash: "Name is required", FlashKind: "danger",
Data: map[string]any{"List": l, "IsNew": id == 0},
})
return
}
if _, err := database.SaveAccessList(h.db, &l); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reload(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "access_list_changed", l.Name, "")
http.Redirect(w, r, "/access-lists?flash="+flashMsg("Access list saved")+"&kind=success", http.StatusFound)
}
func (h *Web) handleAccessListDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := database.DeleteAccessList(h.db, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reload(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "access_list_changed", fmt.Sprintf("id=%d", id), "deleted")
http.Redirect(w, r, "/access-lists?flash="+flashMsg("Access list deleted")+"&kind=success", http.StatusFound)
}
+57
View File
@@ -0,0 +1,57 @@
package web
import (
"net/http"
"strconv"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
)
func (h *Web) handleAPITokens(w http.ResponseWriter, r *http.Request) {
tokens, err := database.ListAPITokens(h.db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, "api_tokens", PageData{
Active: "api", Title: "API", CSRFToken: h.csrf.Token(w),
Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"),
Data: tokens,
})
}
func (h *Web) handleAPITokenCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := r.FormValue("name")
if name == "" {
http.Redirect(w, r, "/api-tokens?flash="+flashMsg("Name is required")+"&kind=danger", http.StatusFound)
return
}
plaintext, hash, prefix, err := auth.GenerateAPIToken()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := database.CreateAPIToken(h.db, name, hash, prefix); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "token_created", name, "")
// Shown once: pass the plaintext token through the flash message.
http.Redirect(w, r, "/api-tokens?flash="+flashMsg("Token created: "+plaintext+" (copy it now — it will not be shown again)")+"&kind=warning", http.StatusFound)
}
func (h *Web) handleAPITokenRevoke(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := database.RevokeAPIToken(h.db, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "token_revoked", "", "")
http.Redirect(w, r, "/api-tokens?flash="+flashMsg("Token revoked")+"&kind=success", http.StatusFound)
}
+34
View File
@@ -0,0 +1,34 @@
package web
import "net/http"
type backendRow struct {
ProxyHostID int64
ProxyHostName string
Host string
Port int
Weight int
Enabled bool
Healthy bool
HealthCheck string
}
func (h *Web) handleBackendsList(w http.ResponseWriter, r *http.Request) {
snap := h.store.Current()
seen := map[int64]bool{}
var rows []backendRow
for _, rt := range snap.HostsByDomain {
if seen[rt.Model.ID] || rt.Pool == nil {
continue
}
seen[rt.Model.ID] = true
for _, b := range rt.Pool.Backends {
rows = append(rows, backendRow{
ProxyHostID: rt.Model.ID, ProxyHostName: rt.Model.Name,
Host: b.Model.Host, Port: b.Model.Port, Weight: b.Model.Weight,
Enabled: b.Model.Enabled, Healthy: b.Healthy(), HealthCheck: string(b.Model.HealthCheckType),
})
}
}
render(w, "backends", PageData{Active: "backends", Title: "Backends", Data: rows})
}
+121
View File
@@ -0,0 +1,121 @@
package web
import (
"fmt"
"net/http"
"strconv"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/certificates"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/models"
)
func (h *Web) handleCertificatesList(w http.ResponseWriter, r *http.Request) {
certs, err := database.ListCertificates(h.db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, "certificates", PageData{
Active: "certificates", Title: "Certificates", CSRFToken: h.csrf.Token(w),
Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"),
Data: certs,
})
}
func (h *Web) handleCertificateIssue(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := r.FormValue("name")
domains := splitLines(r.FormValue("domains"))
if name == "" || len(domains) == 0 {
h.redirectCertError(w, r, "Name and at least one domain are required")
return
}
settings := h.store.Current().Settings
email := r.FormValue("email")
if email == "" {
email = settings.ACMEEmail
}
challengeType := models.ChallengeType(r.FormValue("challenge_type"))
if challengeType == "" {
challengeType = models.ChallengeHTTP01
}
cert, err := h.certs.IssueLetsEncrypt(r.Context(), certificates.IssueLetsEncryptOptions{
Name: name, Domains: domains, Email: email, Staging: settings.ACMEStaging,
ChallengeType: challengeType, DNSProvider: r.FormValue("dns_provider"),
})
if err != nil {
h.redirectCertError(w, r, "Issuance failed: "+err.Error())
return
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "certificate_issued", cert.Name, fmt.Sprintf("domains=%v", cert.Domains))
http.Redirect(w, r, "/certificates?flash="+flashMsg("Certificate issued")+"&kind=success", http.StatusFound)
}
func (h *Web) handleCertificateUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
name := r.FormValue("name")
domains := splitLines(r.FormValue("domains"))
certPEM := r.FormValue("cert_pem")
keyPEM := r.FormValue("key_pem")
if name == "" || certPEM == "" || keyPEM == "" {
h.redirectCertError(w, r, "Name, certificate, and private key are required")
return
}
cert, err := h.certs.UploadCustom(name, domains, []byte(certPEM), []byte(keyPEM))
if err != nil {
h.redirectCertError(w, r, "Upload failed: "+err.Error())
return
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "certificate_issued", cert.Name, "custom certificate uploaded")
http.Redirect(w, r, "/certificates?flash="+flashMsg("Certificate uploaded")+"&kind=success", http.StatusFound)
}
func (h *Web) redirectCertError(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/certificates?flash="+flashMsg(msg)+"&kind=danger", http.StatusFound)
}
func (h *Web) handleCertificateRenew(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
c, err := database.GetCertificate(h.db, id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if c.Type != models.CertTypeLetsEncrypt {
h.redirectCertError(w, r, "Only Let's Encrypt certificates can be renewed here")
return
}
settings := h.store.Current().Settings
_, err = h.certs.IssueLetsEncrypt(r.Context(), certificates.IssueLetsEncryptOptions{
Name: c.Name, Domains: c.Domains, Email: settings.ACMEEmail, Staging: settings.ACMEStaging,
ChallengeType: c.ChallengeType, DNSProvider: c.DNSProvider,
})
if err != nil {
h.redirectCertError(w, r, "Renewal failed: "+err.Error())
return
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "certificate_renewed", c.Name, "")
http.Redirect(w, r, "/certificates?flash="+flashMsg("Certificate renewed")+"&kind=success", http.StatusFound)
}
func (h *Web) handleCertificateDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := database.DeleteCertificate(h.db, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.certs.LoadAll()
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "certificate_changed", fmt.Sprintf("id=%d", id), "deleted")
http.Redirect(w, r, "/certificates?flash="+flashMsg("Certificate deleted")+"&kind=success", http.StatusFound)
}
+16
View File
@@ -0,0 +1,16 @@
package web
import (
"net/http"
"github.com/owen/vibeproxy/internal/stats"
)
func (h *Web) handleDashboard(w http.ResponseWriter, r *http.Request) {
d, err := stats.Compute(h.db, h.store.Current())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, "dashboard", PageData{Active: "dashboard", Title: "Dashboard", Data: d})
}
+25
View File
@@ -0,0 +1,25 @@
// Package web serves the server-rendered Bootstrap 5 admin UI: Go
// templates plus vanilla JS/CSS, all embedded into the binary. No
// frontend framework is used.
package web
import (
"embed"
"io/fs"
)
//go:embed all:templates
var templatesFS embed.FS
//go:embed all:static
var staticFSRaw embed.FS
// StaticFS returns the embedded static assets rooted at "static/..." so
// they can be served at /static/.
func StaticFS() fs.FS {
sub, err := fs.Sub(staticFSRaw, "static")
if err != nil {
panic(err)
}
return sub
}
+55
View File
@@ -0,0 +1,55 @@
package web
import (
"net/http"
"github.com/owen/vibeproxy/internal/database"
)
func (h *Web) handleRequestLogs(w http.ResponseWriter, r *http.Request) {
page := atoiDefault(r.URL.Query().Get("page"), 1)
if page < 1 {
page = 1
}
const pageSize = 50
f := database.LogFilter{
Search: r.URL.Query().Get("q"),
Status: atoiDefault(r.URL.Query().Get("status"), 0),
Limit: pageSize,
Offset: (page - 1) * pageSize,
}
logs, total, err := database.QueryRequestLogs(h.db, f)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
totalPages := (total + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
render(w, "logs", PageData{
Active: "logs", Title: "Request Logs",
Data: map[string]any{"Logs": logs, "Total": total, "Page": page, "TotalPages": totalPages, "Query": f.Search, "Status": f.Status},
})
}
func (h *Web) handleAuditLogs(w http.ResponseWriter, r *http.Request) {
page := atoiDefault(r.URL.Query().Get("page"), 1)
if page < 1 {
page = 1
}
const pageSize = 50
logs, total, err := database.ListAuditLogs(h.db, pageSize, (page-1)*pageSize)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
totalPages := (total + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
render(w, "logs_audit", PageData{
Active: "logs", Title: "Audit Log",
Data: map[string]any{"Logs": logs, "Total": total, "Page": page, "TotalPages": totalPages},
})
}
+264
View File
@@ -0,0 +1,264 @@
package web
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/models"
)
func (h *Web) handleProxyHostsList(w http.ResponseWriter, r *http.Request) {
hosts, err := database.ListProxyHosts(h.db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
lists, _ := database.ListAccessLists(h.db)
certs, _ := database.ListCertificates(h.db)
render(w, "proxy_hosts", PageData{
Active: "proxy_hosts", Title: "Proxy Hosts", CSRFToken: h.csrf.Token(w),
Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"),
Data: map[string]any{"Hosts": hosts, "AccessLists": lists, "Certificates": certs},
})
}
type proxyHostFormData struct {
Host models.ProxyHost
AccessLists []models.AccessList
Certificates []models.Certificate
IsNew bool
}
func (h *Web) handleProxyHostForm(w http.ResponseWriter, r *http.Request) {
var host models.ProxyHost
isNew := true
if idStr := r.PathValue("id"); idStr != "" {
isNew = false
id, _ := strconv.ParseInt(idStr, 10, 64)
existing, err := database.GetProxyHost(h.db, id)
if err != nil {
http.Error(w, "proxy host not found", http.StatusNotFound)
return
}
host = *existing
} else {
host = models.ProxyHost{
Type: models.ProxyHostTypeProxy, ForwardScheme: "http", LoadBalancingMethod: "round_robin",
ConnectTimeoutMS: 5000, ReadTimeoutMS: 60000, WriteTimeoutMS: 60000, IdleTimeoutMS: 120000,
WebsocketSupport: true, Enabled: true, RedirectCode: 301, RedirectPreservePath: true, RedirectPreserveQS: true,
Backends: []models.Backend{{Weight: 1, Enabled: true, HealthCheckType: models.HealthCheckTCP, HealthCheckPath: "/", HealthCheckStatus: 200, IntervalSeconds: 10, FailureThreshold: 3, RecoveryThreshold: 2}},
}
}
lists, _ := database.ListAccessLists(h.db)
certs, _ := database.ListCertificates(h.db)
render(w, "proxy_host_form", PageData{
Active: "proxy_hosts", Title: pageTitle(isNew, "Proxy Host"), CSRFToken: h.csrf.Token(w),
Data: proxyHostFormData{Host: host, AccessLists: lists, Certificates: certs, IsNew: isNew},
})
}
func pageTitle(isNew bool, kind string) string {
if isNew {
return "New " + kind
}
return "Edit " + kind
}
func (h *Web) handleProxyHostCreate(w http.ResponseWriter, r *http.Request) {
h.saveProxyHostFromForm(w, r, 0)
}
func (h *Web) handleProxyHostUpdate(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
h.saveProxyHostFromForm(w, r, id)
}
func (h *Web) saveProxyHostFromForm(w http.ResponseWriter, r *http.Request, id int64) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
host := parseProxyHostForm(r, id)
if err := validateProxyHostForm(&host); err != nil {
lists, _ := database.ListAccessLists(h.db)
certs, _ := database.ListCertificates(h.db)
render(w, "proxy_host_form", PageData{
Active: "proxy_hosts", Title: pageTitle(id == 0, "Proxy Host"), CSRFToken: h.csrf.Token(w),
Flash: err.Error(), FlashKind: "danger",
Data: proxyHostFormData{Host: host, AccessLists: lists, Certificates: certs, IsNew: id == 0},
})
return
}
if _, err := database.SaveProxyHost(h.db, &host); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reload(r)
action := "proxy_changed"
if id == 0 {
action = "proxy_created"
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), action, host.Name, fmt.Sprintf("domains=%v", host.Domains))
http.Redirect(w, r, "/proxy-hosts?flash="+flashMsg("Proxy host saved")+"&kind=success", http.StatusFound)
}
func flashMsg(s string) string { return strings.ReplaceAll(s, " ", "+") }
func parseProxyHostForm(r *http.Request, id int64) models.ProxyHost {
h := models.ProxyHost{
ID: id,
Name: r.FormValue("name"),
Type: models.ProxyHostType(r.FormValue("type")),
ListenAddr: r.FormValue("listen_addr"),
ForwardScheme: r.FormValue("forward_scheme"),
PreserveHostHeader: r.FormValue("preserve_host_header") == "on",
WebsocketSupport: r.FormValue("websocket_support") == "on",
ForceHTTPS: r.FormValue("force_https") == "on",
HSTSEnabled: r.FormValue("hsts_enabled") == "on",
ConnectTimeoutMS: atoiDefault(r.FormValue("connect_timeout_ms"), 5000),
ReadTimeoutMS: atoiDefault(r.FormValue("read_timeout_ms"), 60000),
WriteTimeoutMS: atoiDefault(r.FormValue("write_timeout_ms"), 60000),
IdleTimeoutMS: atoiDefault(r.FormValue("idle_timeout_ms"), 120000),
LoadBalancingMethod: r.FormValue("load_balancing_method"),
RedirectTargetURL: r.FormValue("redirect_target_url"),
RedirectCode: atoiDefault(r.FormValue("redirect_code"), 301),
RedirectPreservePath: r.FormValue("redirect_preserve_path") == "on",
RedirectPreserveQS: r.FormValue("redirect_preserve_qs") == "on",
Enabled: r.FormValue("enabled") == "on",
Comments: r.FormValue("comments"),
}
for _, d := range splitLines(r.FormValue("domains")) {
h.Domains = append(h.Domains, strings.ToLower(d))
}
if v := r.FormValue("certificate_id"); v != "" && v != "0" {
cid, _ := strconv.ParseInt(v, 10, 64)
h.CertificateID = &cid
}
if v := r.FormValue("access_list_id"); v != "" && v != "0" {
aid, _ := strconv.ParseInt(v, 10, 64)
h.AccessListID = &aid
}
// Backends: parallel arrays backend_host[], backend_port[], etc.
hosts := r.Form["backend_host"]
ports := r.Form["backend_port"]
weights := r.Form["backend_weight"]
enabledFlags := r.Form["backend_enabled"] // hidden input per row, always "true"/"false"
hcTypes := r.Form["backend_hc_type"]
hcPaths := r.Form["backend_hc_path"]
hcStatuses := r.Form["backend_hc_status"]
hcIntervals := r.Form["backend_hc_interval"]
hcFail := r.Form["backend_hc_fail"]
hcRecover := r.Form["backend_hc_recover"]
for i := range hosts {
if strings.TrimSpace(hosts[i]) == "" {
continue
}
b := models.Backend{
Host: hosts[i],
Port: atoiDefault(get(ports, i), 80),
Weight: atoiDefault(get(weights, i), 1),
Enabled: get(enabledFlags, i) == "true",
HealthCheckType: models.HealthCheckType(get(hcTypes, i)),
HealthCheckPath: get(hcPaths, i),
HealthCheckStatus: atoiDefault(get(hcStatuses, i), 200),
IntervalSeconds: atoiDefault(get(hcIntervals, i), 10),
FailureThreshold: atoiDefault(get(hcFail, i), 3),
RecoveryThreshold: atoiDefault(get(hcRecover, i), 2),
}
h.Backends = append(h.Backends, b)
}
// Custom headers: parallel arrays.
hDir := r.Form["header_direction"]
hAction := r.Form["header_action"]
hName := r.Form["header_name"]
hValue := r.Form["header_value"]
for i := range hName {
if strings.TrimSpace(hName[i]) == "" {
continue
}
h.CustomHeaders = append(h.CustomHeaders, models.CustomHeader{
Direction: models.CustomHeaderDirection(get(hDir, i)),
Action: models.CustomHeaderAction(get(hAction, i)),
Name: hName[i],
Value: get(hValue, i),
})
}
return h
}
func get(s []string, i int) string {
if i < len(s) {
return s[i]
}
return ""
}
func atoiDefault(s string, def int) int {
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
func splitLines(s string) []string {
var out []string
for _, line := range strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") {
line = strings.TrimSpace(line)
if line != "" {
out = append(out, line)
}
}
return out
}
func validateProxyHostForm(h *models.ProxyHost) error {
if h.Name == "" {
return fmt.Errorf("name is required")
}
if len(h.Domains) == 0 {
return fmt.Errorf("at least one domain is required")
}
if h.Type == models.ProxyHostTypeProxy && len(h.Backends) == 0 {
return fmt.Errorf("at least one backend is required")
}
if h.Type == models.ProxyHostTypeRedirect && h.RedirectTargetURL == "" {
return fmt.Errorf("redirect target URL is required")
}
return nil
}
func (h *Web) handleProxyHostDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := database.DeleteProxyHost(h.db, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reload(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "proxy_changed", fmt.Sprintf("id=%d", id), "deleted")
http.Redirect(w, r, "/proxy-hosts?flash="+flashMsg("Proxy host deleted")+"&kind=success", http.StatusFound)
}
func (h *Web) handleProxyHostToggle(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
existing, err := database.GetProxyHost(h.db, id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := database.SetProxyHostEnabled(h.db, id, !existing.Enabled); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reload(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "proxy_changed", existing.Name, fmt.Sprintf("enabled=%v", !existing.Enabled))
http.Redirect(w, r, "/proxy-hosts", http.StatusFound)
}
+99
View File
@@ -0,0 +1,99 @@
package web
import (
"fmt"
"html/template"
"net/http"
"strings"
"time"
)
var funcMap = template.FuncMap{
"join": func(items []string, sep string) string { return strings.Join(items, sep) },
"fmtTime": func(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.Format("2006-01-02 15:04:05")
},
"timeAgo": timeAgo,
"deref": func(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
},
"deref64": func(v *int64) int64 {
if v == nil {
return 0
}
return *v
},
"slice": func(items ...int) []int { return items },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"iterate": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i
}
return out
},
"statusClass": func(status int) string {
switch {
case status >= 500:
return "text-danger"
case status >= 400:
return "text-warning"
case status >= 300:
return "text-info"
default:
return "text-success"
}
},
}
func timeAgo(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
var templates *template.Template
func init() {
t, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html", "templates/partials/*.html")
if err != nil {
panic(err)
}
templates = t
}
// PageData is the common envelope passed to every page template.
type PageData struct {
Active string // nav item key for sidebar highlighting
Title string
CSRFToken string
Flash string
FlashKind string // success|danger|warning
Data any
}
func render(w http.ResponseWriter, name string, data PageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := templates.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
+97
View File
@@ -0,0 +1,97 @@
package web
import (
"database/sql"
"log/slog"
"net/http"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/certificates"
"github.com/owen/vibeproxy/internal/runtime"
"github.com/owen/vibeproxy/internal/streams"
)
type Web struct {
db *sql.DB
store *runtime.Store
certs *certificates.Manager
streams *streams.Manager
csrf *auth.CSRF
log *slog.Logger
}
// NewRouter builds the full server-rendered admin UI, protected by HTTP
// Basic Auth against the single administrator account.
func NewRouter(db *sql.DB, store *runtime.Store, certs *certificates.Manager, streamMgr *streams.Manager, csrf *auth.CSRF, log *slog.Logger) *http.ServeMux {
web := &Web{db: db, store: store, certs: certs, streams: streamMgr, csrf: csrf, log: log}
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(StaticFS()))))
protected := http.NewServeMux()
protected.HandleFunc("GET /{$}", web.handleRoot)
protected.HandleFunc("GET /dashboard", web.handleDashboard)
protected.HandleFunc("GET /proxy-hosts", web.handleProxyHostsList)
protected.HandleFunc("GET /proxy-hosts/new", web.handleProxyHostForm)
protected.HandleFunc("POST /proxy-hosts/new", web.handleProxyHostCreate)
protected.HandleFunc("GET /proxy-hosts/{id}/edit", web.handleProxyHostForm)
protected.HandleFunc("POST /proxy-hosts/{id}/edit", web.handleProxyHostUpdate)
protected.HandleFunc("POST /proxy-hosts/{id}/delete", web.handleProxyHostDelete)
protected.HandleFunc("POST /proxy-hosts/{id}/toggle", web.handleProxyHostToggle)
protected.HandleFunc("GET /streams", web.handleStreamsList)
protected.HandleFunc("GET /streams/new", web.handleStreamForm)
protected.HandleFunc("POST /streams/new", web.handleStreamCreate)
protected.HandleFunc("GET /streams/{id}/edit", web.handleStreamForm)
protected.HandleFunc("POST /streams/{id}/edit", web.handleStreamUpdate)
protected.HandleFunc("POST /streams/{id}/delete", web.handleStreamDelete)
protected.HandleFunc("POST /streams/{id}/toggle", web.handleStreamToggle)
protected.HandleFunc("GET /backends", web.handleBackendsList)
protected.HandleFunc("GET /certificates", web.handleCertificatesList)
protected.HandleFunc("POST /certificates/letsencrypt", web.handleCertificateIssue)
protected.HandleFunc("POST /certificates/custom", web.handleCertificateUpload)
protected.HandleFunc("POST /certificates/{id}/renew", web.handleCertificateRenew)
protected.HandleFunc("POST /certificates/{id}/delete", web.handleCertificateDelete)
protected.HandleFunc("GET /access-lists", web.handleAccessListsList)
protected.HandleFunc("GET /access-lists/new", web.handleAccessListForm)
protected.HandleFunc("POST /access-lists/new", web.handleAccessListCreate)
protected.HandleFunc("GET /access-lists/{id}/edit", web.handleAccessListForm)
protected.HandleFunc("POST /access-lists/{id}/edit", web.handleAccessListUpdate)
protected.HandleFunc("POST /access-lists/{id}/delete", web.handleAccessListDelete)
protected.HandleFunc("GET /logs", web.handleRequestLogs)
protected.HandleFunc("GET /logs/audit", web.handleAuditLogs)
protected.HandleFunc("GET /settings", web.handleSettings)
protected.HandleFunc("POST /settings", web.handleSettingsUpdate)
protected.HandleFunc("POST /settings/password", web.handlePasswordChange)
protected.HandleFunc("POST /settings/backup", web.handleBackupNow)
protected.HandleFunc("GET /api-tokens", web.handleAPITokens)
protected.HandleFunc("POST /api-tokens/new", web.handleAPITokenCreate)
protected.HandleFunc("POST /api-tokens/{id}/revoke", web.handleAPITokenRevoke)
mux.Handle("/", auth.BasicAuthAdmin(db)(csrf.Protect(protected)))
return mux
}
func (h *Web) handleRoot(rw http.ResponseWriter, r *http.Request) {
http.Redirect(rw, r, "/dashboard", http.StatusFound)
}
func (h *Web) reload(r *http.Request) {
if err := h.store.Reload(r.Context()); err != nil {
h.log.Error("config reload failed", "err", err)
}
}
func (h *Web) reloadStreams(r *http.Request) {
if err := h.streams.Reload(r.Context()); err != nil {
h.log.Error("stream reload failed", "err", err)
}
}
+103
View File
@@ -0,0 +1,103 @@
package web
import (
"net/http"
"strings"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/backup"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
)
func (h *Web) handleSettings(w http.ResponseWriter, r *http.Request) {
s := h.store.Current().Settings
render(w, "settings", PageData{
Active: "settings", Title: "Settings", CSRFToken: h.csrf.Token(w),
Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"),
Data: s,
})
}
func (h *Web) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
kv := map[string]string{
"acme_email": r.FormValue("acme_email"),
"acme_staging": boolStr(r.FormValue("acme_staging") == "on"),
"acme_renewal_window_days": r.FormValue("acme_renewal_window_days"),
"cert_storage_path": r.FormValue("cert_storage_path"),
"log_retention_days": r.FormValue("log_retention_days"),
"trusted_proxy_cidrs": strings.Join(splitLines(r.FormValue("trusted_proxy_cidrs")), ","),
"backup_enabled": boolStr(r.FormValue("backup_enabled") == "on"),
"backup_interval_hours": r.FormValue("backup_interval_hours"),
"backup_retention_count": r.FormValue("backup_retention_count"),
"backup_path": r.FormValue("backup_path"),
}
for k, v := range kv {
if err := database.SetSetting(h.db, k, v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
h.reload(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "settings_changed", "settings", "")
http.Redirect(w, r, "/settings?flash="+flashMsg("Settings saved")+"&kind=success", http.StatusFound)
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
func (h *Web) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := auth.ActorFromContext(r.Context())
current := r.FormValue("current_password")
newPass := r.FormValue("new_password")
confirm := r.FormValue("confirm_password")
user, err := database.GetUserByUsername(h.db, username)
if err != nil {
http.Error(w, "user not found", http.StatusInternalServerError)
return
}
ok, _ := auth.VerifyPassword(current, user.PasswordHash)
if !ok {
http.Redirect(w, r, "/settings?flash="+flashMsg("Current password is incorrect")+"&kind=danger", http.StatusFound)
return
}
if len(newPass) < 8 || newPass != confirm {
http.Redirect(w, r, "/settings?flash="+flashMsg("New passwords must match and be at least 8 characters")+"&kind=danger", http.StatusFound)
return
}
hash, err := auth.HashPassword(newPass)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := database.UpdateUserPassword(h.db, username, hash, false); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logging.Audit(h.db, username, "password_changed", username, "")
http.Redirect(w, r, "/settings?flash="+flashMsg("Password changed")+"&kind=success", http.StatusFound)
}
func (h *Web) handleBackupNow(w http.ResponseWriter, r *http.Request) {
s := h.store.Current().Settings
path, err := backup.Run(h.db, s.BackupPath, s.BackupRetentionCount)
if err != nil {
http.Redirect(w, r, "/settings?flash="+flashMsg("Backup failed: "+err.Error())+"&kind=danger", http.StatusFound)
return
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "settings_changed", "backup", "manual backup: "+path)
http.Redirect(w, r, "/settings?flash="+flashMsg("Backup created: "+path)+"&kind=success", http.StatusFound)
}
+118
View File
@@ -0,0 +1,118 @@
:root {
--vp-sidebar-w: 240px;
}
body {
background-color: #f4f6f9;
}
.vp-shell {
display: flex;
min-height: 100vh;
}
.vp-sidebar {
width: var(--vp-sidebar-w);
flex-shrink: 0;
background-color: #1b2430;
color: #cfd6e2;
position: sticky;
top: 0;
height: 100vh;
}
.vp-brand {
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 1.1rem;
padding: 1.1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.vp-brand i {
color: #4dabf7;
}
.vp-nav {
padding: 0.75rem 0.5rem;
flex: 1;
}
.vp-nav .nav-link {
color: #a9b4c4;
border-radius: 0.4rem;
padding: 0.55rem 0.85rem;
margin-bottom: 0.15rem;
font-size: 0.92rem;
}
.vp-nav .nav-link i {
width: 1.25rem;
display: inline-block;
text-align: center;
margin-right: 0.4rem;
}
.vp-nav .nav-link:hover {
background-color: rgba(255, 255, 255, 0.06);
color: #fff;
}
.vp-nav .nav-link.active {
background-color: #2c5cc5;
color: #fff;
}
.vp-sidebar-footer {
padding: 0.85rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.vp-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.vp-topbar {
background: #fff;
border-bottom: 1px solid #e5e9f0;
padding: 0.9rem 1.5rem;
}
.vp-content {
padding: 1.5rem;
flex: 1;
}
.vp-chip-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.vp-chip {
font-weight: 500;
padding: 0.4rem 0.6rem;
}
.stat-card {
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.stat-card .stat-value {
font-size: 1.7rem;
font-weight: 700;
}
.table-hover tbody tr {
cursor: default;
}
code.vp-token {
word-break: break-all;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
+104
View File
@@ -0,0 +1,104 @@
// vibeproxy admin UI — minimal vanilla JS, no framework.
(function () {
"use strict";
// Generic delete-confirmation modal. Any element with
// data-confirm-delete-form="<form id>" and data-confirm-message="..."
// opens #confirmDeleteModal and, on confirm, submits the named form.
document.addEventListener("click", function (e) {
var trigger = e.target.closest("[data-confirm-delete-form]");
if (!trigger) return;
e.preventDefault();
var formId = trigger.getAttribute("data-confirm-delete-form");
var message = trigger.getAttribute("data-confirm-message") || "Are you sure? This cannot be undone.";
var modalEl = document.getElementById("confirmDeleteModal");
if (!modalEl) return;
modalEl.querySelector(".modal-body").textContent = message;
var confirmBtn = modalEl.querySelector("[data-confirm-action]");
confirmBtn.onclick = function () {
var form = document.getElementById(formId);
if (form) form.submit();
};
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
});
// Dynamic add/remove rows for repeatable form sections (backends,
// custom headers), driven by a <template id="{tableId}RowTemplate">.
document.querySelectorAll("[data-add-row]").forEach(function (btn) {
btn.addEventListener("click", function () {
var tableId = btn.getAttribute("data-add-row");
var table = document.getElementById(tableId);
var tmpl = document.getElementById(tableId + "RowTemplate");
if (!table || !tmpl) return;
var tbody = table.querySelector("tbody");
tbody.appendChild(tmpl.content.firstElementChild.cloneNode(true));
});
});
document.addEventListener("click", function (e) {
var btn = e.target.closest("[data-remove-row]");
if (!btn) return;
var tr = btn.closest("tr");
if (tr) tr.remove();
});
// Live client-side filter for any table with data-searchable="true",
// driven by an <input data-search-target="tableId">.
document.querySelectorAll("[data-search-target]").forEach(function (input) {
input.addEventListener("input", function () {
var table = document.getElementById(input.getAttribute("data-search-target"));
if (!table) return;
var q = input.value.trim().toLowerCase();
table.querySelectorAll("tbody tr").forEach(function (row) {
row.style.display = row.textContent.toLowerCase().indexOf(q) === -1 ? "none" : "";
});
});
});
// Domain / CIDR chip inputs: comma or newline separated list rendered
// into a hidden textarea, e.g. for proxy host domains.
document.querySelectorAll("[data-chip-input]").forEach(function (wrap) {
var hidden = wrap.querySelector("textarea");
var display = wrap.querySelector(".vp-chip-list");
var entry = wrap.querySelector("input[type=text]");
if (!hidden || !display || !entry) return;
function items() {
return hidden.value.split("\n").map(function (s) { return s.trim(); }).filter(Boolean);
}
function renderChips() {
display.innerHTML = "";
items().forEach(function (item, idx) {
var chip = document.createElement("span");
chip.className = "badge text-bg-secondary vp-chip";
chip.textContent = item;
var x = document.createElement("button");
x.type = "button";
x.className = "btn-close btn-close-white btn-sm ms-2";
x.setAttribute("aria-label", "Remove");
x.addEventListener("click", function () {
var list = items();
list.splice(idx, 1);
hidden.value = list.join("\n");
renderChips();
});
chip.appendChild(x);
display.appendChild(chip);
});
}
entry.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
var v = entry.value.trim().replace(/,$/, "");
if (v) {
var list = items();
list.push(v);
hidden.value = list.join("\n");
entry.value = "";
renderChips();
}
}
});
renderChips();
});
})();
File diff suppressed because one or more lines are too long
+114
View File
@@ -0,0 +1,114 @@
package web
import (
"fmt"
"net/http"
"strconv"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/models"
)
func (h *Web) handleStreamsList(w http.ResponseWriter, r *http.Request) {
streams, err := database.ListStreams(h.db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, "streams", PageData{
Active: "streams", Title: "TCP/UDP Streams", CSRFToken: h.csrf.Token(w),
Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"),
Data: streams,
})
}
func (h *Web) handleStreamForm(w http.ResponseWriter, r *http.Request) {
var s models.Stream
isNew := true
if idStr := r.PathValue("id"); idStr != "" {
isNew = false
id, _ := strconv.ParseInt(idStr, 10, 64)
existing, err := database.GetStream(h.db, id)
if err != nil {
http.Error(w, "stream not found", http.StatusNotFound)
return
}
s = *existing
} else {
s = models.Stream{Protocol: models.StreamTCP, ConnectTimeoutMS: 5000, IdleTimeoutMS: 300000, Enabled: true}
}
render(w, "stream_form", PageData{
Active: "streams", Title: pageTitle(isNew, "Stream"), CSRFToken: h.csrf.Token(w),
Data: map[string]any{"Stream": s, "IsNew": isNew},
})
}
func (h *Web) handleStreamCreate(w http.ResponseWriter, r *http.Request) { h.saveStreamForm(w, r, 0) }
func (h *Web) handleStreamUpdate(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
h.saveStreamForm(w, r, id)
}
func (h *Web) saveStreamForm(w http.ResponseWriter, r *http.Request, id int64) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s := models.Stream{
ID: id,
Protocol: models.StreamProtocol(r.FormValue("protocol")),
ListenIP: r.FormValue("listen_ip"),
ListenPort: atoiDefault(r.FormValue("listen_port"), 0),
DestinationHost: r.FormValue("destination_host"),
DestinationPort: atoiDefault(r.FormValue("destination_port"), 0),
ConnectTimeoutMS: atoiDefault(r.FormValue("connect_timeout_ms"), 5000),
IdleTimeoutMS: atoiDefault(r.FormValue("idle_timeout_ms"), 300000),
ProxyProtocol: r.FormValue("proxy_protocol") == "on",
Enabled: r.FormValue("enabled") == "on",
Comments: r.FormValue("comments"),
}
if s.ListenPort <= 0 || s.ListenPort > 65535 || s.DestinationHost == "" || s.DestinationPort <= 0 {
render(w, "stream_form", PageData{
Active: "streams", Title: pageTitle(id == 0, "Stream"), CSRFToken: h.csrf.Token(w),
Flash: "Please provide a valid listen port and destination.", FlashKind: "danger",
Data: map[string]any{"Stream": s, "IsNew": id == 0},
})
return
}
if _, err := database.SaveStream(h.db, &s); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reloadStreams(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "stream_changed", fmt.Sprintf("%s:%d", s.Protocol, s.ListenPort), "")
http.Redirect(w, r, "/streams?flash="+flashMsg("Stream saved")+"&kind=success", http.StatusFound)
}
func (h *Web) handleStreamDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := database.DeleteStream(h.db, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reloadStreams(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "stream_changed", fmt.Sprintf("id=%d", id), "deleted")
http.Redirect(w, r, "/streams?flash="+flashMsg("Stream deleted")+"&kind=success", http.StatusFound)
}
func (h *Web) handleStreamToggle(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
existing, err := database.GetStream(h.db, id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := database.SetStreamEnabled(h.db, id, !existing.Enabled); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reloadStreams(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "stream_changed", fmt.Sprintf("id=%d", id), fmt.Sprintf("enabled=%v", !existing.Enabled))
http.Redirect(w, r, "/streams", http.StatusFound)
}
@@ -0,0 +1,72 @@
{{define "access_list_form"}}{{template "header" .}}
{{$d := .Data}}{{$l := $d.List}}
<div class="card"><div class="card-body">
<form method="post" action="{{if $d.IsNew}}/access-lists/new{{else}}/access-lists/{{$l.ID}}/edit{{end}}">
{{template "csrf_field" .CSRFToken}}
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Name</label>
<input class="form-control" name="name" value="{{$l.Name}}" required>
</div>
<div class="col-md-3 d-flex align-items-end">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="default_allow" id="da" {{if $l.DefaultAllow}}checked{{end}}>
<label class="form-check-label" for="da">Default allow (unmatched IPs are allowed)</label>
</div>
</div>
<div class="col-12">
<label class="form-label">Comments</label>
<input class="form-control" name="comments" value="{{$l.Comments}}">
</div>
</div>
<h6>CIDR Rules</h6>
<table class="table table-sm align-middle" id="rulesTable">
<thead><tr><th style="width:10rem">Action</th><th>CIDR</th><th></th></tr></thead>
<tbody>
{{range $l.Rules}}
<tr>
<td><select class="form-select form-select-sm" name="rule_action">
<option value="allow" {{if eq .Action "allow"}}selected{{end}}>Allow</option>
<option value="deny" {{if eq .Action "deny"}}selected{{end}}>Deny</option>
</select></td>
<td><input class="form-control form-control-sm" name="rule_cidr" value="{{.CIDR}}" placeholder="10.0.0.0/8"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr>
{{end}}
</tbody>
</table>
<button type="button" class="btn btn-sm btn-outline-primary mb-4" data-add-row="rulesTable"><i class="bi bi-plus-lg"></i> Add Rule</button>
<template id="rulesTableRowTemplate"><tr>
<td><select class="form-select form-select-sm" name="rule_action"><option value="allow" selected>Allow</option><option value="deny">Deny</option></select></td>
<td><input class="form-control form-control-sm" name="rule_cidr" placeholder="10.0.0.0/8"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr></template>
<h6>HTTP Basic Auth Users <span class="text-secondary small">(optional — leave off for IP-only lists)</span></h6>
<table class="table table-sm align-middle" id="usersTable">
<thead><tr><th>Username</th><th>Password <span class="text-secondary">(leave blank to keep existing)</span></th><th></th></tr></thead>
<tbody>
{{range $l.BasicAuthUsers}}
<tr>
<td><input class="form-control form-control-sm" name="auth_username" value="{{.Username}}"></td>
<td><input class="form-control form-control-sm" type="password" name="auth_password" placeholder="••••••••"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr>
{{end}}
</tbody>
</table>
<button type="button" class="btn btn-sm btn-outline-primary mb-4" data-add-row="usersTable"><i class="bi bi-plus-lg"></i> Add User</button>
<template id="usersTableRowTemplate"><tr>
<td><input class="form-control form-control-sm" name="auth_username"></td>
<td><input class="form-control form-control-sm" type="password" name="auth_password" placeholder="••••••••"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr></template>
<div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Save</button>
<a href="/access-lists" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div></div>
{{template "footer" .}}{{end}}
+33
View File
@@ -0,0 +1,33 @@
{{define "access_lists"}}{{template "header" .}}
<div class="d-flex justify-content-between align-items-center mb-3">
<input type="text" class="form-control w-auto" placeholder="Search access lists…" data-search-target="aclTable">
<a href="/access-lists/new" class="btn btn-primary"><i class="bi bi-plus-lg"></i> Add Access List</a>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="aclTable">
<thead class="table-light"><tr><th>Name</th><th>Default</th><th>Rules</th><th>Basic Auth Users</th><th></th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td><a href="/access-lists/{{.ID}}/edit">{{.Name}}</a>{{if .Comments}}<div class="small text-secondary">{{.Comments}}</div>{{end}}</td>
<td>{{if .DefaultAllow}}<span class="badge text-bg-success">allow</span>{{else}}<span class="badge text-bg-danger">deny</span>{{end}}</td>
<td>{{len .Rules}} rule(s)</td>
<td>{{len .BasicAuthUsers}} user(s)</td>
<td class="text-end">
<a href="/access-lists/{{.ID}}/edit" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
<form method="post" action="/access-lists/{{.ID}}/delete" id="del-acl-{{.ID}}" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
</form>
<button type="button" class="btn btn-sm btn-outline-danger" data-confirm-delete-form="del-acl-{{.ID}}" data-confirm-message="Delete access list &quot;{{.Name}}&quot;?"><i class="bi bi-trash"></i></button>
</td>
</tr>
{{else}}
<tr><td colspan="5" class="text-center text-secondary py-4">No access lists yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "confirm_delete_modal" .}}
{{template "footer" .}}{{end}}
+62
View File
@@ -0,0 +1,62 @@
{{define "api_tokens"}}{{template "header" .}}
<div class="card mb-4">
<div class="card-header">API Access</div>
<div class="card-body">
<p>The REST API is available under <code>/api/v1</code> on this admin listener, authenticated with a bearer token:</p>
<pre class="bg-light p-3 rounded"><code>curl -H "Authorization: Bearer vp_xxxxxxxx" https://your-server:8081/api/v1/proxy-hosts</code></pre>
<p class="mb-0 text-secondary">Resources: proxy-hosts, streams, certificates, access-lists, settings, logs/requests, logs/audit, stats, tokens.</p>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">API Tokens</h6>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newTokenModal"><i class="bi bi-plus-lg"></i> New Token</button>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr><th>Name</th><th>Prefix</th><th>Created</th><th>Last Used</th><th>Status</th><th></th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td>{{.Name}}</td>
<td><code>{{.Prefix}}…</code></td>
<td>{{fmtTime .CreatedAt}}</td>
<td>{{if .LastUsedAt}}{{timeAgo (deref .LastUsedAt)}}{{else}}never{{end}}</td>
<td>{{if .RevokedAt}}<span class="badge text-bg-secondary">revoked</span>{{else}}<span class="badge text-bg-success">active</span>{{end}}</td>
<td class="text-end">
{{if not .RevokedAt}}
<form method="post" action="/api-tokens/{{.ID}}/revoke" id="rev-tok-{{.ID}}" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
</form>
<button type="button" class="btn btn-sm btn-outline-danger" data-confirm-delete-form="rev-tok-{{.ID}}" data-confirm-message="Revoke token &quot;{{.Name}}&quot;? Anything using it will stop working immediately.">Revoke</button>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="6" class="text-center text-secondary py-4">No API tokens yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="modal fade" id="newTokenModal" tabindex="-1">
<div class="modal-dialog"><div class="modal-content">
<form method="post" action="/api-tokens/new">
{{template "csrf_field" .CSRFToken}}
<div class="modal-header"><h5 class="modal-title">New API Token</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<label class="form-label">Name</label>
<input class="form-control" name="name" placeholder="e.g. terraform, ci-pipeline" required>
<p class="small text-secondary mt-2 mb-0">The token is shown once after creation — store it securely.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create Token</button>
</div>
</form>
</div></div>
</div>
{{template "confirm_delete_modal" .}}
{{template "footer" .}}{{end}}
+24
View File
@@ -0,0 +1,24 @@
{{define "backends"}}{{template "header" .}}
<p class="text-secondary">Live backend status from the running configuration. Add or edit backends from the owning Proxy Host.</p>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="backendsListTable">
<thead class="table-light"><tr><th>Proxy Host</th><th>Backend</th><th>Weight</th><th>Health Check</th><th>Config</th><th>Live Health</th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td><a href="/proxy-hosts/{{.ProxyHostID}}/edit">{{.ProxyHostName}}</a></td>
<td>{{.Host}}:{{.Port}}</td>
<td>{{.Weight}}</td>
<td>{{.HealthCheck}}</td>
<td>{{if .Enabled}}<span class="badge text-bg-success">enabled</span>{{else}}<span class="badge text-bg-secondary">disabled</span>{{end}}</td>
<td>{{if .Healthy}}<span class="badge text-bg-success"><i class="bi bi-heart-pulse"></i> healthy</span>{{else}}<span class="badge text-bg-danger"><i class="bi bi-heart-pulse"></i> unhealthy</span>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="6" class="text-center text-secondary py-4">No backends configured.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "footer" .}}{{end}}
+85
View File
@@ -0,0 +1,85 @@
{{define "certificates"}}{{template "header" .}}
<div class="d-flex justify-content-end gap-2 mb-3">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#issueLEModal"><i class="bi bi-shield-plus"></i> Request Let's Encrypt</button>
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#uploadCertModal"><i class="bi bi-upload"></i> Upload Custom</button>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr><th>Name</th><th>Domains</th><th>Type</th><th>Expires</th><th>Status</th><th></th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td>{{.Name}}</td>
<td>{{join .Domains ", "}}</td>
<td><span class="badge text-bg-{{if eq .Type "letsencrypt"}}success{{else}}secondary{{end}}">{{.Type}}</span></td>
<td>{{if .NotAfter}}{{fmtTime (deref .NotAfter)}}{{else}}—{{end}}</td>
<td>{{if .LastError}}<span class="badge text-bg-danger" title="{{.LastError}}">error</span>{{else}}<span class="badge text-bg-success">ok</span>{{end}}</td>
<td class="text-end">
{{if eq .Type "letsencrypt"}}
<form method="post" action="/certificates/{{.ID}}/renew" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button class="btn btn-sm btn-outline-secondary" title="Renew now"><i class="bi bi-arrow-clockwise"></i></button>
</form>
{{end}}
<form method="post" action="/certificates/{{.ID}}/delete" id="del-cert-{{.ID}}" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
</form>
<button type="button" class="btn btn-sm btn-outline-danger" data-confirm-delete-form="del-cert-{{.ID}}" data-confirm-message="Delete certificate &quot;{{.Name}}&quot;? Proxy hosts using it will fall back to no TLS certificate."><i class="bi bi-trash"></i></button>
</td>
</tr>
{{else}}
<tr><td colspan="6" class="text-center text-secondary py-4">No certificates yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="modal fade" id="issueLEModal" tabindex="-1">
<div class="modal-dialog"><div class="modal-content">
<form method="post" action="/certificates/letsencrypt">
{{template "csrf_field" .CSRFToken}}
<div class="modal-header"><h5 class="modal-title">Request Let's Encrypt Certificate</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="mb-3"><label class="form-label">Name</label><input class="form-control" name="name" required></div>
<div class="mb-3"><label class="form-label">Domains (one per line, wildcards need DNS-01)</label><textarea class="form-control" name="domains" rows="3" required></textarea></div>
<div class="mb-3"><label class="form-label">Contact Email (optional — falls back to Settings)</label><input class="form-control" name="email" type="email"></div>
<div class="mb-3">
<label class="form-label">Challenge Type</label>
<select class="form-select" name="challenge_type">
<option value="http-01">HTTP-01 (port 80 reachable from the internet)</option>
<option value="dns-01">DNS-01 (required for wildcards)</option>
</select>
</div>
<div class="mb-3"><label class="form-label">DNS Provider (DNS-01 only)</label><input class="form-control" name="dns_provider" placeholder="cloudflare"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Request Certificate</button>
</div>
</form>
</div></div>
</div>
<div class="modal fade" id="uploadCertModal" tabindex="-1">
<div class="modal-dialog"><div class="modal-content">
<form method="post" action="/certificates/custom">
{{template "csrf_field" .CSRFToken}}
<div class="modal-header"><h5 class="modal-title">Upload Custom Certificate</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="mb-3"><label class="form-label">Name</label><input class="form-control" name="name" required></div>
<div class="mb-3"><label class="form-label">Domains (one per line)</label><textarea class="form-control" name="domains" rows="2"></textarea></div>
<div class="mb-3"><label class="form-label">Certificate (PEM, full chain)</label><textarea class="form-control font-monospace" name="cert_pem" rows="5" required placeholder="-----BEGIN CERTIFICATE-----"></textarea></div>
<div class="mb-3"><label class="form-label">Private Key (PEM)</label><textarea class="form-control font-monospace" name="key_pem" rows="5" required placeholder="-----BEGIN PRIVATE KEY-----"></textarea></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</form>
</div></div>
</div>
{{template "confirm_delete_modal" .}}
{{template "footer" .}}{{end}}
+115
View File
@@ -0,0 +1,115 @@
{{define "dashboard"}}{{template "header" .}}
{{$d := .Data}}
<div class="row g-3 mb-4">
<div class="col-sm-6 col-lg-3">
<div class="card stat-card h-100"><div class="card-body">
<div class="text-secondary small">Active Proxy Hosts</div>
<div class="stat-value">{{$d.ActiveProxyHosts}}</div>
</div></div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card stat-card h-100"><div class="card-body">
<div class="text-secondary small">TCP / UDP Streams</div>
<div class="stat-value">{{$d.ActiveTCPStreams}} / {{$d.ActiveUDPStreams}}</div>
</div></div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card stat-card h-100"><div class="card-body">
<div class="text-secondary small">Requests / sec (1h avg)</div>
<div class="stat-value">{{printf "%.2f" $d.RequestsPerSecond}}</div>
</div></div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card stat-card h-100"><div class="card-body">
<div class="text-secondary small">Requests (24h)</div>
<div class="stat-value">{{$d.TotalRequestsToday}}</div>
</div></div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Status Breakdown (24h)</div>
<div class="card-body">
{{if $d.StatusBreakdown}}
<table class="table table-sm mb-0">
<tbody>
{{range $d.StatusBreakdown}}
<tr><td class="{{statusClass .Status}}">{{.Status}}</td><td>{{.Count}}</td></tr>
{{end}}
</tbody>
</table>
{{else}}<p class="text-secondary mb-0">No requests logged yet.</p>{{end}}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Backend Health</div>
<div class="card-body">
{{if $d.BackendHealth}}
<table class="table table-sm mb-0">
<tbody>
{{range $d.BackendHealth}}
<tr>
<td>{{.ProxyHost}}</td><td>{{.Backend}}</td>
<td>{{if .Healthy}}<span class="badge text-bg-success">healthy</span>{{else}}<span class="badge text-bg-danger">unhealthy</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}<p class="text-secondary mb-0">No backends configured.</p>{{end}}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Top Hosts (24h)</div>
<div class="card-body">
{{if $d.TopHosts}}
<table class="table table-sm mb-0"><tbody>
{{range $d.TopHosts}}<tr><td>{{.Host}}</td><td>{{.Count}}</td></tr>{{end}}
</tbody></table>
{{else}}<p class="text-secondary mb-0">No data yet.</p>{{end}}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Top Clients (24h)</div>
<div class="card-body">
{{if $d.TopClients}}
<table class="table table-sm mb-0"><tbody>
{{range $d.TopClients}}<tr><td>{{.Host}}</td><td>{{.Count}}</td></tr>{{end}}
</tbody></table>
{{else}}<p class="text-secondary mb-0">No data yet.</p>{{end}}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Certificates Expiring Soon (30d)</div>
<div class="card-body">
{{if $d.CertsExpiringSoon}}
<table class="table table-sm mb-0"><tbody>
{{range $d.CertsExpiringSoon}}<tr><td>{{.Name}}</td><td>{{fmtTime (deref .NotAfter)}}</td></tr>{{end}}
</tbody></table>
{{else}}<p class="text-secondary mb-0">Nothing expiring soon.</p>{{end}}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Recent Errors (24h)</div>
<div class="card-body">
{{if $d.RecentErrors}}
<table class="table table-sm mb-0"><tbody>
{{range $d.RecentErrors}}<tr><td>{{fmtTime .Timestamp}}</td><td class="{{statusClass .Status}}">{{.Status}}</td><td>{{.Host}}{{.Path}}</td></tr>{{end}}
</tbody></table>
{{else}}<p class="text-secondary mb-0">No recent errors.</p>{{end}}
</div>
</div>
</div>
</div>
{{template "footer" .}}{{end}}
+51
View File
@@ -0,0 +1,51 @@
{{define "logs"}}{{template "header" .}}
{{$d := .Data}}
<ul class="nav nav-pills mb-3">
<li class="nav-item"><a class="nav-link active" href="/logs">Requests</a></li>
<li class="nav-item"><a class="nav-link" href="/logs/audit">Audit</a></li>
</ul>
<form method="get" action="/logs" class="row g-2 mb-3">
<div class="col-auto">
<input type="text" class="form-control" name="q" value="{{$d.Query}}" placeholder="Search host / path / IP…">
</div>
<div class="col-auto">
<input type="text" class="form-control" name="status" value="{{if $d.Status}}{{$d.Status}}{{end}}" placeholder="Status code">
</div>
<div class="col-auto"><button class="btn btn-outline-primary">Filter</button></div>
</form>
<div class="card">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr><th>Time</th><th>Proxy Host</th><th>Client IP</th><th>Method</th><th>Host</th><th>Path</th><th>Status</th><th>Size</th><th>Backend</th><th>ms</th><th>User Agent</th></tr>
</thead>
<tbody>
{{range $d.Logs}}
<tr>
<td>{{fmtTime .Timestamp}}</td>
<td>{{.ProxyHostName}}</td>
<td>{{.ClientIP}}</td>
<td>{{.Method}}</td>
<td>{{.Host}}</td>
<td class="text-truncate" style="max-width:16rem">{{.Path}}</td>
<td class="{{statusClass .Status}}">{{.Status}}</td>
<td>{{.ResponseSize}}</td>
<td>{{.Backend}}</td>
<td>{{.ResponseTimeMS}}</td>
<td class="text-truncate" style="max-width:12rem">{{.UserAgent}}</td>
</tr>
{{else}}
<tr><td colspan="11" class="text-center text-secondary py-4">No requests logged yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if gt $d.TotalPages 1}}
<nav class="mt-3"><ul class="pagination pagination-sm">
{{range $i := iterate $d.TotalPages}}
<li class="page-item {{if eq (add $i 1) $d.Page}}active{{end}}"><a class="page-link" href="/logs?page={{add $i 1}}&q={{$d.Query}}">{{add $i 1}}</a></li>
{{end}}
</ul></nav>
{{end}}
{{template "footer" .}}{{end}}
+34
View File
@@ -0,0 +1,34 @@
{{define "logs_audit"}}{{template "header" .}}
{{$d := .Data}}
<ul class="nav nav-pills mb-3">
<li class="nav-item"><a class="nav-link" href="/logs">Requests</a></li>
<li class="nav-item"><a class="nav-link active" href="/logs/audit">Audit</a></li>
</ul>
<div class="card">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light"><tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th><th>Details</th></tr></thead>
<tbody>
{{range $d.Logs}}
<tr>
<td>{{fmtTime .Timestamp}}</td>
<td>{{.Actor}}</td>
<td><span class="badge text-bg-light border">{{.Action}}</span></td>
<td>{{.Target}}</td>
<td class="text-secondary">{{.Details}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="text-center text-secondary py-4">No audit events yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if gt $d.TotalPages 1}}
<nav class="mt-3"><ul class="pagination pagination-sm">
{{range $i := iterate $d.TotalPages}}
<li class="page-item {{if eq (add $i 1) $d.Page}}active{{end}}"><a class="page-link" href="/logs/audit?page={{add $i 1}}">{{add $i 1}}</a></li>
{{end}}
</ul></nav>
{{end}}
{{template "footer" .}}{{end}}
@@ -0,0 +1,9 @@
{{define "footer"}}
</main>
</div>
</div>
<script src="/static/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
{{end}}
@@ -0,0 +1,44 @@
{{define "header"}}<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · vibeproxy</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<div class="vp-shell">
<nav class="vp-sidebar d-flex flex-column">
<a href="/dashboard" class="vp-brand d-flex align-items-center gap-2">
<i class="bi bi-hexagon-fill"></i>
<span>vibeproxy</span>
</a>
<ul class="nav flex-column vp-nav">
<li class="nav-item"><a class="nav-link {{if eq .Active "dashboard"}}active{{end}}" href="/dashboard"><i class="bi bi-speedometer2"></i> Dashboard</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "proxy_hosts"}}active{{end}}" href="/proxy-hosts"><i class="bi bi-globe2"></i> Proxy Hosts</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "streams"}}active{{end}}" href="/streams"><i class="bi bi-arrow-left-right"></i> TCP/UDP Streams</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "backends"}}active{{end}}" href="/backends"><i class="bi bi-hdd-network"></i> Backends</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "certificates"}}active{{end}}" href="/certificates"><i class="bi bi-shield-lock"></i> Certificates</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "access_lists"}}active{{end}}" href="/access-lists"><i class="bi bi-key"></i> Access Lists</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "logs"}}active{{end}}" href="/logs"><i class="bi bi-journal-text"></i> Logs</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "settings"}}active{{end}}" href="/settings"><i class="bi bi-gear"></i> Settings</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "api"}}active{{end}}" href="/api-tokens"><i class="bi bi-code-slash"></i> API</a></li>
</ul>
<div class="vp-sidebar-footer text-secondary small">
<i class="bi bi-box-seam"></i> vibeproxy
</div>
</nav>
<div class="vp-main">
<header class="vp-topbar d-flex align-items-center justify-content-between">
<h1 class="h5 mb-0">{{.Title}}</h1>
</header>
<main class="vp-content">
{{if .Flash}}
<div class="alert alert-{{if .FlashKind}}{{.FlashKind}}{{else}}success{{end}} alert-dismissible fade show" role="alert">
{{.Flash}}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{{end}}
{{end}}
@@ -0,0 +1,19 @@
{{define "confirm_delete_modal"}}
<div class="modal fade" id="confirmDeleteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-exclamation-triangle text-danger"></i> Confirm</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">Are you sure?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-confirm-action data-bs-dismiss="modal">Delete</button>
</div>
</div>
</div>
</div>
{{end}}
{{define "csrf_field"}}<input type="hidden" name="csrf_token" value="{{.}}">{{end}}
+266
View File
@@ -0,0 +1,266 @@
{{define "proxy_host_form"}}{{template "header" .}}
{{$d := .Data}}{{$h := $d.Host}}
<form method="post" action="{{if $d.IsNew}}/proxy-hosts/new{{else}}/proxy-hosts/{{$h.ID}}/edit{{end}}">
{{template "csrf_field" .CSRFToken}}
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-details" type="button">Details</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-backends" type="button">Backends</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-ssl" type="button">SSL &amp; Access</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-headers" type="button">Headers</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-advanced" type="button">Advanced</button></li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-details">
<div class="card mb-3"><div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name</label>
<input class="form-control" name="name" value="{{$h.Name}}" required>
</div>
<div class="col-md-6">
<label class="form-label">Type</label>
<select class="form-select" name="type" id="hostType">
<option value="proxy" {{if eq $h.Type "proxy"}}selected{{end}}>Proxy</option>
<option value="redirect" {{if eq $h.Type "redirect"}}selected{{end}}>Redirect</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Domains</label>
<div data-chip-input>
<div class="vp-chip-list"></div>
<input type="text" class="form-control" placeholder="example.com — press Enter to add">
<textarea name="domains" class="d-none">{{join $h.Domains "\n"}}</textarea>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Listen Address</label>
<input class="form-control" name="listen_addr" value="{{$h.ListenAddr}}" placeholder="0.0.0.0 (all interfaces)">
</div>
<div class="col-12" id="redirectFields" style="{{if ne $h.Type "redirect"}}display:none{{end}}">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Redirect Target URL</label>
<input class="form-control" name="redirect_target_url" value="{{$h.RedirectTargetURL}}" placeholder="https://new.example.com">
</div>
<div class="col-md-3">
<label class="form-label">Redirect Code</label>
<select class="form-select" name="redirect_code">
{{range $code := (slice 301 302 307 308)}}
<option value="{{$code}}" {{if eq $h.RedirectCode $code}}selected{{end}}>{{$code}}</option>
{{end}}
</select>
</div>
<div class="col-md-3 d-flex flex-column justify-content-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="redirect_preserve_path" id="rpp" {{if $h.RedirectPreservePath}}checked{{end}}>
<label class="form-check-label" for="rpp">Preserve path</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="redirect_preserve_qs" id="rpq" {{if $h.RedirectPreserveQS}}checked{{end}}>
<label class="form-check-label" for="rpq">Preserve query string</label>
</div>
</div>
</div>
</div>
<div class="col-12">
<label class="form-label">Comments</label>
<textarea class="form-control" name="comments" rows="2">{{$h.Comments}}</textarea>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="enabled" id="enabled" {{if $h.Enabled}}checked{{end}}>
<label class="form-check-label" for="enabled">Enabled</label>
</div>
</div>
</div>
</div></div>
</div>
<div class="tab-pane fade" id="tab-backends">
<div class="card mb-3"><div class="card-body">
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Upstream Scheme</label>
<select class="form-select" name="forward_scheme">
<option value="http" {{if eq $h.ForwardScheme "http"}}selected{{end}}>http</option>
<option value="https" {{if eq $h.ForwardScheme "https"}}selected{{end}}>https</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Load Balancing Method</label>
<select class="form-select" name="load_balancing_method">
<option value="round_robin" {{if eq $h.LoadBalancingMethod "round_robin"}}selected{{end}}>Round robin</option>
<option value="least_conn" {{if eq $h.LoadBalancingMethod "least_conn"}}selected{{end}}>Least connections</option>
<option value="weighted" {{if eq $h.LoadBalancingMethod "weighted"}}selected{{end}}>Weighted</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="websocket_support" id="ws" {{if $h.WebsocketSupport}}checked{{end}}>
<label class="form-check-label" for="ws">WebSocket support</label>
</div>
</div>
</div>
<table class="table table-sm align-middle" id="backendsTable">
<thead><tr><th>Host</th><th>Port</th><th>Weight</th><th>On</th><th>Health Check</th><th>Path</th><th>Status</th><th>Interval(s)</th><th>Fail</th><th>Recover</th><th></th></tr></thead>
<tbody>
{{range $i, $b := $h.Backends}}
<tr>
<td><input class="form-control form-control-sm" name="backend_host" value="{{$b.Host}}"></td>
<td><input class="form-control form-control-sm" style="width:5.5rem" name="backend_port" value="{{$b.Port}}"></td>
<td><input class="form-control form-control-sm" style="width:4.5rem" name="backend_weight" value="{{$b.Weight}}"></td>
<td>
<input type="checkbox" class="form-check-input" {{if $b.Enabled}}checked{{end}} onchange="this.nextElementSibling.value=this.checked">
<input type="hidden" name="backend_enabled" value="{{if $b.Enabled}}true{{else}}false{{end}}">
</td>
<td>
<select class="form-select form-select-sm" name="backend_hc_type">
<option value="tcp" {{if eq $b.HealthCheckType "tcp"}}selected{{end}}>TCP</option>
<option value="http" {{if eq $b.HealthCheckType "http"}}selected{{end}}>HTTP</option>
<option value="none" {{if eq $b.HealthCheckType "none"}}selected{{end}}>None</option>
</select>
</td>
<td><input class="form-control form-control-sm" style="width:6rem" name="backend_hc_path" value="{{$b.HealthCheckPath}}"></td>
<td><input class="form-control form-control-sm" style="width:4.5rem" name="backend_hc_status" value="{{$b.HealthCheckStatus}}"></td>
<td><input class="form-control form-control-sm" style="width:4.5rem" name="backend_hc_interval" value="{{$b.IntervalSeconds}}"></td>
<td><input class="form-control form-control-sm" style="width:4rem" name="backend_hc_fail" value="{{$b.FailureThreshold}}"></td>
<td><input class="form-control form-control-sm" style="width:4rem" name="backend_hc_recover" value="{{$b.RecoveryThreshold}}"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr>
{{end}}
</tbody>
</table>
<button type="button" class="btn btn-sm btn-outline-primary" data-add-row="backendsTable"><i class="bi bi-plus-lg"></i> Add Backend</button>
<template id="backendsTableRowTemplate"><tr>
<td><input class="form-control form-control-sm" name="backend_host"></td>
<td><input class="form-control form-control-sm" style="width:5.5rem" name="backend_port" value="80"></td>
<td><input class="form-control form-control-sm" style="width:4.5rem" name="backend_weight" value="1"></td>
<td>
<input type="checkbox" class="form-check-input" checked onchange="this.nextElementSibling.value=this.checked">
<input type="hidden" name="backend_enabled" value="true">
</td>
<td><select class="form-select form-select-sm" name="backend_hc_type">
<option value="tcp" selected>TCP</option><option value="http">HTTP</option><option value="none">None</option>
</select></td>
<td><input class="form-control form-control-sm" style="width:6rem" name="backend_hc_path" value="/"></td>
<td><input class="form-control form-control-sm" style="width:4.5rem" name="backend_hc_status" value="200"></td>
<td><input class="form-control form-control-sm" style="width:4.5rem" name="backend_hc_interval" value="10"></td>
<td><input class="form-control form-control-sm" style="width:4rem" name="backend_hc_fail" value="3"></td>
<td><input class="form-control form-control-sm" style="width:4rem" name="backend_hc_recover" value="2"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr></template>
</div></div>
</div>
<div class="tab-pane fade" id="tab-ssl">
<div class="card mb-3"><div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">SSL Certificate</label>
<select class="form-select" name="certificate_id">
<option value="0">None</option>
{{range $d.Certificates}}<option value="{{.ID}}" {{if and $h.CertificateID (eq (deref64 $h.CertificateID) .ID)}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Access List</label>
<select class="form-select" name="access_list_id">
<option value="0">None</option>
{{range $d.AccessLists}}<option value="{{.ID}}" {{if and $h.AccessListID (eq (deref64 $h.AccessListID) .ID)}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="force_https" id="fh" {{if $h.ForceHTTPS}}checked{{end}}>
<label class="form-check-label" for="fh">Force HTTPS</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="hsts_enabled" id="hsts" {{if $h.HSTSEnabled}}checked{{end}}>
<label class="form-check-label" for="hsts">HSTS</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="preserve_host_header" id="phh" {{if $h.PreserveHostHeader}}checked{{end}}>
<label class="form-check-label" for="phh">Preserve Host header</label>
</div>
</div>
</div>
</div></div>
</div>
<div class="tab-pane fade" id="tab-headers">
<div class="card mb-3"><div class="card-body">
<table class="table table-sm align-middle" id="headersTable">
<thead><tr><th>Direction</th><th>Action</th><th>Name</th><th>Value</th><th></th></tr></thead>
<tbody>
{{range $h.CustomHeaders}}
<tr>
<td><select class="form-select form-select-sm" name="header_direction">
<option value="request" {{if eq .Direction "request"}}selected{{end}}>Request</option>
<option value="response" {{if eq .Direction "response"}}selected{{end}}>Response</option>
</select></td>
<td><select class="form-select form-select-sm" name="header_action">
<option value="set" {{if eq .Action "set"}}selected{{end}}>Set</option>
<option value="remove" {{if eq .Action "remove"}}selected{{end}}>Remove</option>
</select></td>
<td><input class="form-control form-control-sm" name="header_name" value="{{.Name}}" placeholder="X-Frame-Options"></td>
<td><input class="form-control form-control-sm" name="header_value" value="{{.Value}}"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr>
{{end}}
</tbody>
</table>
<button type="button" class="btn btn-sm btn-outline-primary" data-add-row="headersTable"><i class="bi bi-plus-lg"></i> Add Header</button>
<template id="headersTableRowTemplate"><tr>
<td><select class="form-select form-select-sm" name="header_direction">
<option value="request" selected>Request</option><option value="response">Response</option>
</select></td>
<td><select class="form-select form-select-sm" name="header_action">
<option value="set" selected>Set</option><option value="remove">Remove</option>
</select></td>
<td><input class="form-control form-control-sm" name="header_name" placeholder="X-Frame-Options"></td>
<td><input class="form-control form-control-sm" name="header_value"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr></template>
</div></div>
</div>
<div class="tab-pane fade" id="tab-advanced">
<div class="card mb-3"><div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Connect timeout (ms)</label>
<input class="form-control" name="connect_timeout_ms" value="{{$h.ConnectTimeoutMS}}">
</div>
<div class="col-md-3">
<label class="form-label">Read timeout (ms)</label>
<input class="form-control" name="read_timeout_ms" value="{{$h.ReadTimeoutMS}}">
</div>
<div class="col-md-3">
<label class="form-label">Write timeout (ms)</label>
<input class="form-control" name="write_timeout_ms" value="{{$h.WriteTimeoutMS}}">
</div>
<div class="col-md-3">
<label class="form-label">Idle timeout (ms)</label>
<input class="form-control" name="idle_timeout_ms" value="{{$h.IdleTimeoutMS}}">
</div>
</div>
</div></div>
</div>
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Save</button>
<a href="/proxy-hosts" class="btn btn-outline-secondary">Cancel</a>
</form>
<script>
document.getElementById('hostType').addEventListener('change', function () {
document.getElementById('redirectFields').style.display = this.value === 'redirect' ? '' : 'none';
});
</script>
{{template "footer" .}}{{end}}
+45
View File
@@ -0,0 +1,45 @@
{{define "proxy_hosts"}}{{template "header" .}}
{{$d := .Data}}
<div class="d-flex justify-content-between align-items-center mb-3">
<input type="text" class="form-control w-auto" placeholder="Search proxy hosts…" data-search-target="hostsTable">
<a href="/proxy-hosts/new" class="btn btn-primary"><i class="bi bi-plus-lg"></i> Add Proxy Host</a>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="hostsTable">
<thead class="table-light">
<tr><th>Name</th><th>Domains</th><th>Type</th><th>Destination</th><th>SSL</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{{range $d.Hosts}}
<tr>
<td><a href="/proxy-hosts/{{.ID}}/edit">{{.Name}}</a>{{if .Comments}}<div class="small text-secondary">{{.Comments}}</div>{{end}}</td>
<td>{{join .Domains ", "}}</td>
<td><span class="badge text-bg-{{if eq .Type "redirect"}}warning{{else}}info{{end}}">{{.Type}}</span></td>
<td>
{{if eq .Type "redirect"}}{{.RedirectTargetURL}}
{{else}}{{range .Backends}}{{.Host}}:{{.Port}} {{end}}{{end}}
</td>
<td>{{if .CertificateID}}<i class="bi bi-shield-check text-success"></i>{{else}}<i class="bi bi-shield-slash text-secondary"></i>{{end}}</td>
<td>{{if .Enabled}}<span class="badge text-bg-success">enabled</span>{{else}}<span class="badge text-bg-secondary">disabled</span>{{end}}</td>
<td class="text-end">
<form method="post" action="/proxy-hosts/{{.ID}}/toggle" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button class="btn btn-sm btn-outline-secondary" title="Enable/disable"><i class="bi bi-power"></i></button>
</form>
<a href="/proxy-hosts/{{.ID}}/edit" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
<form method="post" action="/proxy-hosts/{{.ID}}/delete" id="del-host-{{.ID}}" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
</form>
<button type="button" class="btn btn-sm btn-outline-danger" data-confirm-delete-form="del-host-{{.ID}}" data-confirm-message="Delete proxy host &quot;{{.Name}}&quot;?"><i class="bi bi-trash"></i></button>
</td>
</tr>
{{else}}
<tr><td colspan="7" class="text-center text-secondary py-4">No proxy hosts yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "confirm_delete_modal" .}}
{{template "footer" .}}{{end}}
+91
View File
@@ -0,0 +1,91 @@
{{define "settings"}}{{template "header" .}}
{{$s := .Data}}
<div class="row g-3">
<div class="col-lg-7">
<div class="card mb-3">
<div class="card-header">Let's Encrypt / ACME</div>
<div class="card-body">
<form method="post" action="/settings">
{{template "csrf_field" .CSRFToken}}
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Contact Email</label>
<input class="form-control" name="acme_email" value="{{$s.ACMEEmail}}">
</div>
<div class="col-md-6">
<label class="form-label">Renewal Window (days before expiry)</label>
<input class="form-control" name="acme_renewal_window_days" value="{{$s.ACMERenewalWindowDays}}">
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" name="acme_staging" id="staging" {{if $s.ACMEStaging}}checked{{end}}>
<label class="form-check-label" for="staging">Use Let's Encrypt staging environment</label>
</div>
</div>
<div class="col-md-12">
<label class="form-label">Certificate Storage Path</label>
<input class="form-control" name="cert_storage_path" value="{{$s.CertStoragePath}}">
</div>
<div class="col-md-12">
<label class="form-label">Trusted Proxy Networks (CIDR, one per line — only these may set X-Forwarded-For)</label>
<textarea class="form-control" name="trusted_proxy_cidrs" rows="3">{{join $s.TrustedProxyCIDRs "\n"}}</textarea>
</div>
<div class="col-md-6">
<label class="form-label">Log Retention (days)</label>
<input class="form-control" name="log_retention_days" value="{{$s.LogRetentionDays}}">
</div>
</div>
<hr>
<h6>Backups</h6>
<div class="row g-3">
<div class="col-md-4">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" name="backup_enabled" id="backupEn" {{if $s.BackupEnabled}}checked{{end}}>
<label class="form-check-label" for="backupEn">Automatic backups enabled</label>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Interval (hours)</label>
<input class="form-control" name="backup_interval_hours" value="{{$s.BackupIntervalHours}}">
</div>
<div class="col-md-4">
<label class="form-label">Keep last N backups</label>
<input class="form-control" name="backup_retention_count" value="{{$s.BackupRetentionCount}}">
</div>
<div class="col-12">
<label class="form-label">Backup Directory</label>
<input class="form-control" name="backup_path" value="{{$s.BackupPath}}">
</div>
</div>
<div class="mt-4"><button class="btn btn-primary"><i class="bi bi-check-lg"></i> Save Settings</button></div>
</form>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Manual Backup</div>
<div class="card-body">
<form method="post" action="/settings/backup">
{{template "csrf_field" .CSRFToken}}
<button class="btn btn-outline-primary"><i class="bi bi-download"></i> Backup Now</button>
</form>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card mb-3">
<div class="card-header">Administrator Account</div>
<div class="card-body">
<form method="post" action="/settings/password">
{{template "csrf_field" .CSRFToken}}
<div class="mb-3"><label class="form-label">Current Password</label><input type="password" class="form-control" name="current_password" required></div>
<div class="mb-3"><label class="form-label">New Password</label><input type="password" class="form-control" name="new_password" minlength="8" required></div>
<div class="mb-3"><label class="form-label">Confirm New Password</label><input type="password" class="form-control" name="confirm_password" minlength="8" required></div>
<button class="btn btn-primary"><i class="bi bi-key"></i> Change Password</button>
</form>
</div>
</div>
</div>
</div>
{{template "footer" .}}{{end}}

Some files were not shown because too many files have changed in this diff Show More