Files
owenandClaude Sonnet 5 ba1f7c4c03 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
2026-08-17 10:28:59 -05:00

265 lines
9.1 KiB
Go

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)
}