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