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
265 lines
9.1 KiB
Go
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)
|
|
}
|