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