Initial vibeproxy implementation

Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go,
with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS
framework), and a REST API under /api/v1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
This commit is contained in:
2026-08-17 10:28:59 -05:00
co-authored by Claude Sonnet 5
commit ba1f7c4c03
105 changed files with 9142 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
package web
import (
"fmt"
"net/http"
"strconv"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/models"
)
func (h *Web) handleAccessListsList(w http.ResponseWriter, r *http.Request) {
lists, err := database.ListAccessLists(h.db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, "access_lists", PageData{
Active: "access_lists", Title: "Access Lists", CSRFToken: h.csrf.Token(w),
Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"),
Data: lists,
})
}
func (h *Web) handleAccessListForm(w http.ResponseWriter, r *http.Request) {
var l models.AccessList
isNew := true
if idStr := r.PathValue("id"); idStr != "" {
isNew = false
id, _ := strconv.ParseInt(idStr, 10, 64)
existing, err := database.GetAccessList(h.db, id)
if err != nil {
http.Error(w, "access list not found", http.StatusNotFound)
return
}
l = *existing
}
render(w, "access_list_form", PageData{
Active: "access_lists", Title: pageTitle(isNew, "Access List"), CSRFToken: h.csrf.Token(w),
Data: map[string]any{"List": l, "IsNew": isNew},
})
}
func (h *Web) handleAccessListCreate(w http.ResponseWriter, r *http.Request) {
h.saveAccessListForm(w, r, 0)
}
func (h *Web) handleAccessListUpdate(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
h.saveAccessListForm(w, r, id)
}
func (h *Web) saveAccessListForm(w http.ResponseWriter, r *http.Request, id int64) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
l := models.AccessList{
ID: id,
Name: r.FormValue("name"),
DefaultAllow: r.FormValue("default_allow") == "on",
Comments: r.FormValue("comments"),
}
actions := r.Form["rule_action"]
cidrs := r.Form["rule_cidr"]
for i := range cidrs {
if cidrs[i] == "" {
continue
}
l.Rules = append(l.Rules, models.AccessRule{
Action: models.AccessRuleAction(get(actions, i)),
CIDR: cidrs[i],
Priority: i,
})
}
existingHashes := map[string]string{}
if id != 0 {
if existing, err := database.GetAccessList(h.db, id); err == nil {
for _, u := range existing.BasicAuthUsers {
existingHashes[u.Username] = u.PasswordHash
}
}
}
usernames := r.Form["auth_username"]
passwords := r.Form["auth_password"]
for i := range usernames {
if usernames[i] == "" {
continue
}
hash := ""
if passwords[i] != "" {
h2, err := auth.HashPassword(passwords[i])
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
hash = h2
} else if existing, ok := existingHashes[usernames[i]]; ok {
hash = existing // password left blank on edit: keep the existing hash
} else {
continue // new user with no password set — skip
}
l.BasicAuthUsers = append(l.BasicAuthUsers, models.AccessListUser{Username: usernames[i], PasswordHash: hash})
}
if l.Name == "" {
render(w, "access_list_form", PageData{
Active: "access_lists", Title: pageTitle(id == 0, "Access List"), CSRFToken: h.csrf.Token(w),
Flash: "Name is required", FlashKind: "danger",
Data: map[string]any{"List": l, "IsNew": id == 0},
})
return
}
if _, err := database.SaveAccessList(h.db, &l); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reload(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "access_list_changed", l.Name, "")
http.Redirect(w, r, "/access-lists?flash="+flashMsg("Access list saved")+"&kind=success", http.StatusFound)
}
func (h *Web) handleAccessListDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := database.DeleteAccessList(h.db, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reload(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "access_list_changed", fmt.Sprintf("id=%d", id), "deleted")
http.Redirect(w, r, "/access-lists?flash="+flashMsg("Access list deleted")+"&kind=success", http.StatusFound)
}
+57
View File
@@ -0,0 +1,57 @@
package web
import (
"net/http"
"strconv"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
)
func (h *Web) handleAPITokens(w http.ResponseWriter, r *http.Request) {
tokens, err := database.ListAPITokens(h.db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, "api_tokens", PageData{
Active: "api", Title: "API", CSRFToken: h.csrf.Token(w),
Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"),
Data: tokens,
})
}
func (h *Web) handleAPITokenCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := r.FormValue("name")
if name == "" {
http.Redirect(w, r, "/api-tokens?flash="+flashMsg("Name is required")+"&kind=danger", http.StatusFound)
return
}
plaintext, hash, prefix, err := auth.GenerateAPIToken()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := database.CreateAPIToken(h.db, name, hash, prefix); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "token_created", name, "")
// Shown once: pass the plaintext token through the flash message.
http.Redirect(w, r, "/api-tokens?flash="+flashMsg("Token created: "+plaintext+" (copy it now — it will not be shown again)")+"&kind=warning", http.StatusFound)
}
func (h *Web) handleAPITokenRevoke(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := database.RevokeAPIToken(h.db, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "token_revoked", "", "")
http.Redirect(w, r, "/api-tokens?flash="+flashMsg("Token revoked")+"&kind=success", http.StatusFound)
}
+34
View File
@@ -0,0 +1,34 @@
package web
import "net/http"
type backendRow struct {
ProxyHostID int64
ProxyHostName string
Host string
Port int
Weight int
Enabled bool
Healthy bool
HealthCheck string
}
func (h *Web) handleBackendsList(w http.ResponseWriter, r *http.Request) {
snap := h.store.Current()
seen := map[int64]bool{}
var rows []backendRow
for _, rt := range snap.HostsByDomain {
if seen[rt.Model.ID] || rt.Pool == nil {
continue
}
seen[rt.Model.ID] = true
for _, b := range rt.Pool.Backends {
rows = append(rows, backendRow{
ProxyHostID: rt.Model.ID, ProxyHostName: rt.Model.Name,
Host: b.Model.Host, Port: b.Model.Port, Weight: b.Model.Weight,
Enabled: b.Model.Enabled, Healthy: b.Healthy(), HealthCheck: string(b.Model.HealthCheckType),
})
}
}
render(w, "backends", PageData{Active: "backends", Title: "Backends", Data: rows})
}
+121
View File
@@ -0,0 +1,121 @@
package web
import (
"fmt"
"net/http"
"strconv"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/certificates"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/models"
)
func (h *Web) handleCertificatesList(w http.ResponseWriter, r *http.Request) {
certs, err := database.ListCertificates(h.db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, "certificates", PageData{
Active: "certificates", Title: "Certificates", CSRFToken: h.csrf.Token(w),
Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"),
Data: certs,
})
}
func (h *Web) handleCertificateIssue(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := r.FormValue("name")
domains := splitLines(r.FormValue("domains"))
if name == "" || len(domains) == 0 {
h.redirectCertError(w, r, "Name and at least one domain are required")
return
}
settings := h.store.Current().Settings
email := r.FormValue("email")
if email == "" {
email = settings.ACMEEmail
}
challengeType := models.ChallengeType(r.FormValue("challenge_type"))
if challengeType == "" {
challengeType = models.ChallengeHTTP01
}
cert, err := h.certs.IssueLetsEncrypt(r.Context(), certificates.IssueLetsEncryptOptions{
Name: name, Domains: domains, Email: email, Staging: settings.ACMEStaging,
ChallengeType: challengeType, DNSProvider: r.FormValue("dns_provider"),
})
if err != nil {
h.redirectCertError(w, r, "Issuance failed: "+err.Error())
return
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "certificate_issued", cert.Name, fmt.Sprintf("domains=%v", cert.Domains))
http.Redirect(w, r, "/certificates?flash="+flashMsg("Certificate issued")+"&kind=success", http.StatusFound)
}
func (h *Web) handleCertificateUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
name := r.FormValue("name")
domains := splitLines(r.FormValue("domains"))
certPEM := r.FormValue("cert_pem")
keyPEM := r.FormValue("key_pem")
if name == "" || certPEM == "" || keyPEM == "" {
h.redirectCertError(w, r, "Name, certificate, and private key are required")
return
}
cert, err := h.certs.UploadCustom(name, domains, []byte(certPEM), []byte(keyPEM))
if err != nil {
h.redirectCertError(w, r, "Upload failed: "+err.Error())
return
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "certificate_issued", cert.Name, "custom certificate uploaded")
http.Redirect(w, r, "/certificates?flash="+flashMsg("Certificate uploaded")+"&kind=success", http.StatusFound)
}
func (h *Web) redirectCertError(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/certificates?flash="+flashMsg(msg)+"&kind=danger", http.StatusFound)
}
func (h *Web) handleCertificateRenew(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
c, err := database.GetCertificate(h.db, id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if c.Type != models.CertTypeLetsEncrypt {
h.redirectCertError(w, r, "Only Let's Encrypt certificates can be renewed here")
return
}
settings := h.store.Current().Settings
_, err = h.certs.IssueLetsEncrypt(r.Context(), certificates.IssueLetsEncryptOptions{
Name: c.Name, Domains: c.Domains, Email: settings.ACMEEmail, Staging: settings.ACMEStaging,
ChallengeType: c.ChallengeType, DNSProvider: c.DNSProvider,
})
if err != nil {
h.redirectCertError(w, r, "Renewal failed: "+err.Error())
return
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "certificate_renewed", c.Name, "")
http.Redirect(w, r, "/certificates?flash="+flashMsg("Certificate renewed")+"&kind=success", http.StatusFound)
}
func (h *Web) handleCertificateDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := database.DeleteCertificate(h.db, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.certs.LoadAll()
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "certificate_changed", fmt.Sprintf("id=%d", id), "deleted")
http.Redirect(w, r, "/certificates?flash="+flashMsg("Certificate deleted")+"&kind=success", http.StatusFound)
}
+16
View File
@@ -0,0 +1,16 @@
package web
import (
"net/http"
"github.com/owen/vibeproxy/internal/stats"
)
func (h *Web) handleDashboard(w http.ResponseWriter, r *http.Request) {
d, err := stats.Compute(h.db, h.store.Current())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, "dashboard", PageData{Active: "dashboard", Title: "Dashboard", Data: d})
}
+25
View File
@@ -0,0 +1,25 @@
// Package web serves the server-rendered Bootstrap 5 admin UI: Go
// templates plus vanilla JS/CSS, all embedded into the binary. No
// frontend framework is used.
package web
import (
"embed"
"io/fs"
)
//go:embed all:templates
var templatesFS embed.FS
//go:embed all:static
var staticFSRaw embed.FS
// StaticFS returns the embedded static assets rooted at "static/..." so
// they can be served at /static/.
func StaticFS() fs.FS {
sub, err := fs.Sub(staticFSRaw, "static")
if err != nil {
panic(err)
}
return sub
}
+55
View File
@@ -0,0 +1,55 @@
package web
import (
"net/http"
"github.com/owen/vibeproxy/internal/database"
)
func (h *Web) handleRequestLogs(w http.ResponseWriter, r *http.Request) {
page := atoiDefault(r.URL.Query().Get("page"), 1)
if page < 1 {
page = 1
}
const pageSize = 50
f := database.LogFilter{
Search: r.URL.Query().Get("q"),
Status: atoiDefault(r.URL.Query().Get("status"), 0),
Limit: pageSize,
Offset: (page - 1) * pageSize,
}
logs, total, err := database.QueryRequestLogs(h.db, f)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
totalPages := (total + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
render(w, "logs", PageData{
Active: "logs", Title: "Request Logs",
Data: map[string]any{"Logs": logs, "Total": total, "Page": page, "TotalPages": totalPages, "Query": f.Search, "Status": f.Status},
})
}
func (h *Web) handleAuditLogs(w http.ResponseWriter, r *http.Request) {
page := atoiDefault(r.URL.Query().Get("page"), 1)
if page < 1 {
page = 1
}
const pageSize = 50
logs, total, err := database.ListAuditLogs(h.db, pageSize, (page-1)*pageSize)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
totalPages := (total + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
render(w, "logs_audit", PageData{
Active: "logs", Title: "Audit Log",
Data: map[string]any{"Logs": logs, "Total": total, "Page": page, "TotalPages": totalPages},
})
}
+264
View File
@@ -0,0 +1,264 @@
package web
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/models"
)
func (h *Web) handleProxyHostsList(w http.ResponseWriter, r *http.Request) {
hosts, err := database.ListProxyHosts(h.db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
lists, _ := database.ListAccessLists(h.db)
certs, _ := database.ListCertificates(h.db)
render(w, "proxy_hosts", PageData{
Active: "proxy_hosts", Title: "Proxy Hosts", CSRFToken: h.csrf.Token(w),
Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"),
Data: map[string]any{"Hosts": hosts, "AccessLists": lists, "Certificates": certs},
})
}
type proxyHostFormData struct {
Host models.ProxyHost
AccessLists []models.AccessList
Certificates []models.Certificate
IsNew bool
}
func (h *Web) handleProxyHostForm(w http.ResponseWriter, r *http.Request) {
var host models.ProxyHost
isNew := true
if idStr := r.PathValue("id"); idStr != "" {
isNew = false
id, _ := strconv.ParseInt(idStr, 10, 64)
existing, err := database.GetProxyHost(h.db, id)
if err != nil {
http.Error(w, "proxy host not found", http.StatusNotFound)
return
}
host = *existing
} else {
host = models.ProxyHost{
Type: models.ProxyHostTypeProxy, ForwardScheme: "http", LoadBalancingMethod: "round_robin",
ConnectTimeoutMS: 5000, ReadTimeoutMS: 60000, WriteTimeoutMS: 60000, IdleTimeoutMS: 120000,
WebsocketSupport: true, Enabled: true, RedirectCode: 301, RedirectPreservePath: true, RedirectPreserveQS: true,
Backends: []models.Backend{{Weight: 1, Enabled: true, HealthCheckType: models.HealthCheckTCP, HealthCheckPath: "/", HealthCheckStatus: 200, IntervalSeconds: 10, FailureThreshold: 3, RecoveryThreshold: 2}},
}
}
lists, _ := database.ListAccessLists(h.db)
certs, _ := database.ListCertificates(h.db)
render(w, "proxy_host_form", PageData{
Active: "proxy_hosts", Title: pageTitle(isNew, "Proxy Host"), CSRFToken: h.csrf.Token(w),
Data: proxyHostFormData{Host: host, AccessLists: lists, Certificates: certs, IsNew: isNew},
})
}
func pageTitle(isNew bool, kind string) string {
if isNew {
return "New " + kind
}
return "Edit " + kind
}
func (h *Web) handleProxyHostCreate(w http.ResponseWriter, r *http.Request) {
h.saveProxyHostFromForm(w, r, 0)
}
func (h *Web) handleProxyHostUpdate(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
h.saveProxyHostFromForm(w, r, id)
}
func (h *Web) saveProxyHostFromForm(w http.ResponseWriter, r *http.Request, id int64) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
host := parseProxyHostForm(r, id)
if err := validateProxyHostForm(&host); err != nil {
lists, _ := database.ListAccessLists(h.db)
certs, _ := database.ListCertificates(h.db)
render(w, "proxy_host_form", PageData{
Active: "proxy_hosts", Title: pageTitle(id == 0, "Proxy Host"), CSRFToken: h.csrf.Token(w),
Flash: err.Error(), FlashKind: "danger",
Data: proxyHostFormData{Host: host, AccessLists: lists, Certificates: certs, IsNew: id == 0},
})
return
}
if _, err := database.SaveProxyHost(h.db, &host); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reload(r)
action := "proxy_changed"
if id == 0 {
action = "proxy_created"
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), action, host.Name, fmt.Sprintf("domains=%v", host.Domains))
http.Redirect(w, r, "/proxy-hosts?flash="+flashMsg("Proxy host saved")+"&kind=success", http.StatusFound)
}
func flashMsg(s string) string { return strings.ReplaceAll(s, " ", "+") }
func parseProxyHostForm(r *http.Request, id int64) models.ProxyHost {
h := models.ProxyHost{
ID: id,
Name: r.FormValue("name"),
Type: models.ProxyHostType(r.FormValue("type")),
ListenAddr: r.FormValue("listen_addr"),
ForwardScheme: r.FormValue("forward_scheme"),
PreserveHostHeader: r.FormValue("preserve_host_header") == "on",
WebsocketSupport: r.FormValue("websocket_support") == "on",
ForceHTTPS: r.FormValue("force_https") == "on",
HSTSEnabled: r.FormValue("hsts_enabled") == "on",
ConnectTimeoutMS: atoiDefault(r.FormValue("connect_timeout_ms"), 5000),
ReadTimeoutMS: atoiDefault(r.FormValue("read_timeout_ms"), 60000),
WriteTimeoutMS: atoiDefault(r.FormValue("write_timeout_ms"), 60000),
IdleTimeoutMS: atoiDefault(r.FormValue("idle_timeout_ms"), 120000),
LoadBalancingMethod: r.FormValue("load_balancing_method"),
RedirectTargetURL: r.FormValue("redirect_target_url"),
RedirectCode: atoiDefault(r.FormValue("redirect_code"), 301),
RedirectPreservePath: r.FormValue("redirect_preserve_path") == "on",
RedirectPreserveQS: r.FormValue("redirect_preserve_qs") == "on",
Enabled: r.FormValue("enabled") == "on",
Comments: r.FormValue("comments"),
}
for _, d := range splitLines(r.FormValue("domains")) {
h.Domains = append(h.Domains, strings.ToLower(d))
}
if v := r.FormValue("certificate_id"); v != "" && v != "0" {
cid, _ := strconv.ParseInt(v, 10, 64)
h.CertificateID = &cid
}
if v := r.FormValue("access_list_id"); v != "" && v != "0" {
aid, _ := strconv.ParseInt(v, 10, 64)
h.AccessListID = &aid
}
// Backends: parallel arrays backend_host[], backend_port[], etc.
hosts := r.Form["backend_host"]
ports := r.Form["backend_port"]
weights := r.Form["backend_weight"]
enabledFlags := r.Form["backend_enabled"] // hidden input per row, always "true"/"false"
hcTypes := r.Form["backend_hc_type"]
hcPaths := r.Form["backend_hc_path"]
hcStatuses := r.Form["backend_hc_status"]
hcIntervals := r.Form["backend_hc_interval"]
hcFail := r.Form["backend_hc_fail"]
hcRecover := r.Form["backend_hc_recover"]
for i := range hosts {
if strings.TrimSpace(hosts[i]) == "" {
continue
}
b := models.Backend{
Host: hosts[i],
Port: atoiDefault(get(ports, i), 80),
Weight: atoiDefault(get(weights, i), 1),
Enabled: get(enabledFlags, i) == "true",
HealthCheckType: models.HealthCheckType(get(hcTypes, i)),
HealthCheckPath: get(hcPaths, i),
HealthCheckStatus: atoiDefault(get(hcStatuses, i), 200),
IntervalSeconds: atoiDefault(get(hcIntervals, i), 10),
FailureThreshold: atoiDefault(get(hcFail, i), 3),
RecoveryThreshold: atoiDefault(get(hcRecover, i), 2),
}
h.Backends = append(h.Backends, b)
}
// Custom headers: parallel arrays.
hDir := r.Form["header_direction"]
hAction := r.Form["header_action"]
hName := r.Form["header_name"]
hValue := r.Form["header_value"]
for i := range hName {
if strings.TrimSpace(hName[i]) == "" {
continue
}
h.CustomHeaders = append(h.CustomHeaders, models.CustomHeader{
Direction: models.CustomHeaderDirection(get(hDir, i)),
Action: models.CustomHeaderAction(get(hAction, i)),
Name: hName[i],
Value: get(hValue, i),
})
}
return h
}
func get(s []string, i int) string {
if i < len(s) {
return s[i]
}
return ""
}
func atoiDefault(s string, def int) int {
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
func splitLines(s string) []string {
var out []string
for _, line := range strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") {
line = strings.TrimSpace(line)
if line != "" {
out = append(out, line)
}
}
return out
}
func validateProxyHostForm(h *models.ProxyHost) error {
if h.Name == "" {
return fmt.Errorf("name is required")
}
if len(h.Domains) == 0 {
return fmt.Errorf("at least one domain is required")
}
if h.Type == models.ProxyHostTypeProxy && len(h.Backends) == 0 {
return fmt.Errorf("at least one backend is required")
}
if h.Type == models.ProxyHostTypeRedirect && h.RedirectTargetURL == "" {
return fmt.Errorf("redirect target URL is required")
}
return nil
}
func (h *Web) handleProxyHostDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := database.DeleteProxyHost(h.db, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reload(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "proxy_changed", fmt.Sprintf("id=%d", id), "deleted")
http.Redirect(w, r, "/proxy-hosts?flash="+flashMsg("Proxy host deleted")+"&kind=success", http.StatusFound)
}
func (h *Web) handleProxyHostToggle(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
existing, err := database.GetProxyHost(h.db, id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := database.SetProxyHostEnabled(h.db, id, !existing.Enabled); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reload(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "proxy_changed", existing.Name, fmt.Sprintf("enabled=%v", !existing.Enabled))
http.Redirect(w, r, "/proxy-hosts", http.StatusFound)
}
+99
View File
@@ -0,0 +1,99 @@
package web
import (
"fmt"
"html/template"
"net/http"
"strings"
"time"
)
var funcMap = template.FuncMap{
"join": func(items []string, sep string) string { return strings.Join(items, sep) },
"fmtTime": func(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.Format("2006-01-02 15:04:05")
},
"timeAgo": timeAgo,
"deref": func(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
},
"deref64": func(v *int64) int64 {
if v == nil {
return 0
}
return *v
},
"slice": func(items ...int) []int { return items },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"iterate": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i
}
return out
},
"statusClass": func(status int) string {
switch {
case status >= 500:
return "text-danger"
case status >= 400:
return "text-warning"
case status >= 300:
return "text-info"
default:
return "text-success"
}
},
}
func timeAgo(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
var templates *template.Template
func init() {
t, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html", "templates/partials/*.html")
if err != nil {
panic(err)
}
templates = t
}
// PageData is the common envelope passed to every page template.
type PageData struct {
Active string // nav item key for sidebar highlighting
Title string
CSRFToken string
Flash string
FlashKind string // success|danger|warning
Data any
}
func render(w http.ResponseWriter, name string, data PageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := templates.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
+97
View File
@@ -0,0 +1,97 @@
package web
import (
"database/sql"
"log/slog"
"net/http"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/certificates"
"github.com/owen/vibeproxy/internal/runtime"
"github.com/owen/vibeproxy/internal/streams"
)
type Web struct {
db *sql.DB
store *runtime.Store
certs *certificates.Manager
streams *streams.Manager
csrf *auth.CSRF
log *slog.Logger
}
// NewRouter builds the full server-rendered admin UI, protected by HTTP
// Basic Auth against the single administrator account.
func NewRouter(db *sql.DB, store *runtime.Store, certs *certificates.Manager, streamMgr *streams.Manager, csrf *auth.CSRF, log *slog.Logger) *http.ServeMux {
web := &Web{db: db, store: store, certs: certs, streams: streamMgr, csrf: csrf, log: log}
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(StaticFS()))))
protected := http.NewServeMux()
protected.HandleFunc("GET /{$}", web.handleRoot)
protected.HandleFunc("GET /dashboard", web.handleDashboard)
protected.HandleFunc("GET /proxy-hosts", web.handleProxyHostsList)
protected.HandleFunc("GET /proxy-hosts/new", web.handleProxyHostForm)
protected.HandleFunc("POST /proxy-hosts/new", web.handleProxyHostCreate)
protected.HandleFunc("GET /proxy-hosts/{id}/edit", web.handleProxyHostForm)
protected.HandleFunc("POST /proxy-hosts/{id}/edit", web.handleProxyHostUpdate)
protected.HandleFunc("POST /proxy-hosts/{id}/delete", web.handleProxyHostDelete)
protected.HandleFunc("POST /proxy-hosts/{id}/toggle", web.handleProxyHostToggle)
protected.HandleFunc("GET /streams", web.handleStreamsList)
protected.HandleFunc("GET /streams/new", web.handleStreamForm)
protected.HandleFunc("POST /streams/new", web.handleStreamCreate)
protected.HandleFunc("GET /streams/{id}/edit", web.handleStreamForm)
protected.HandleFunc("POST /streams/{id}/edit", web.handleStreamUpdate)
protected.HandleFunc("POST /streams/{id}/delete", web.handleStreamDelete)
protected.HandleFunc("POST /streams/{id}/toggle", web.handleStreamToggle)
protected.HandleFunc("GET /backends", web.handleBackendsList)
protected.HandleFunc("GET /certificates", web.handleCertificatesList)
protected.HandleFunc("POST /certificates/letsencrypt", web.handleCertificateIssue)
protected.HandleFunc("POST /certificates/custom", web.handleCertificateUpload)
protected.HandleFunc("POST /certificates/{id}/renew", web.handleCertificateRenew)
protected.HandleFunc("POST /certificates/{id}/delete", web.handleCertificateDelete)
protected.HandleFunc("GET /access-lists", web.handleAccessListsList)
protected.HandleFunc("GET /access-lists/new", web.handleAccessListForm)
protected.HandleFunc("POST /access-lists/new", web.handleAccessListCreate)
protected.HandleFunc("GET /access-lists/{id}/edit", web.handleAccessListForm)
protected.HandleFunc("POST /access-lists/{id}/edit", web.handleAccessListUpdate)
protected.HandleFunc("POST /access-lists/{id}/delete", web.handleAccessListDelete)
protected.HandleFunc("GET /logs", web.handleRequestLogs)
protected.HandleFunc("GET /logs/audit", web.handleAuditLogs)
protected.HandleFunc("GET /settings", web.handleSettings)
protected.HandleFunc("POST /settings", web.handleSettingsUpdate)
protected.HandleFunc("POST /settings/password", web.handlePasswordChange)
protected.HandleFunc("POST /settings/backup", web.handleBackupNow)
protected.HandleFunc("GET /api-tokens", web.handleAPITokens)
protected.HandleFunc("POST /api-tokens/new", web.handleAPITokenCreate)
protected.HandleFunc("POST /api-tokens/{id}/revoke", web.handleAPITokenRevoke)
mux.Handle("/", auth.BasicAuthAdmin(db)(csrf.Protect(protected)))
return mux
}
func (h *Web) handleRoot(rw http.ResponseWriter, r *http.Request) {
http.Redirect(rw, r, "/dashboard", http.StatusFound)
}
func (h *Web) reload(r *http.Request) {
if err := h.store.Reload(r.Context()); err != nil {
h.log.Error("config reload failed", "err", err)
}
}
func (h *Web) reloadStreams(r *http.Request) {
if err := h.streams.Reload(r.Context()); err != nil {
h.log.Error("stream reload failed", "err", err)
}
}
+103
View File
@@ -0,0 +1,103 @@
package web
import (
"net/http"
"strings"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/backup"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
)
func (h *Web) handleSettings(w http.ResponseWriter, r *http.Request) {
s := h.store.Current().Settings
render(w, "settings", PageData{
Active: "settings", Title: "Settings", CSRFToken: h.csrf.Token(w),
Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"),
Data: s,
})
}
func (h *Web) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
kv := map[string]string{
"acme_email": r.FormValue("acme_email"),
"acme_staging": boolStr(r.FormValue("acme_staging") == "on"),
"acme_renewal_window_days": r.FormValue("acme_renewal_window_days"),
"cert_storage_path": r.FormValue("cert_storage_path"),
"log_retention_days": r.FormValue("log_retention_days"),
"trusted_proxy_cidrs": strings.Join(splitLines(r.FormValue("trusted_proxy_cidrs")), ","),
"backup_enabled": boolStr(r.FormValue("backup_enabled") == "on"),
"backup_interval_hours": r.FormValue("backup_interval_hours"),
"backup_retention_count": r.FormValue("backup_retention_count"),
"backup_path": r.FormValue("backup_path"),
}
for k, v := range kv {
if err := database.SetSetting(h.db, k, v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
h.reload(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "settings_changed", "settings", "")
http.Redirect(w, r, "/settings?flash="+flashMsg("Settings saved")+"&kind=success", http.StatusFound)
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
func (h *Web) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := auth.ActorFromContext(r.Context())
current := r.FormValue("current_password")
newPass := r.FormValue("new_password")
confirm := r.FormValue("confirm_password")
user, err := database.GetUserByUsername(h.db, username)
if err != nil {
http.Error(w, "user not found", http.StatusInternalServerError)
return
}
ok, _ := auth.VerifyPassword(current, user.PasswordHash)
if !ok {
http.Redirect(w, r, "/settings?flash="+flashMsg("Current password is incorrect")+"&kind=danger", http.StatusFound)
return
}
if len(newPass) < 8 || newPass != confirm {
http.Redirect(w, r, "/settings?flash="+flashMsg("New passwords must match and be at least 8 characters")+"&kind=danger", http.StatusFound)
return
}
hash, err := auth.HashPassword(newPass)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := database.UpdateUserPassword(h.db, username, hash, false); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logging.Audit(h.db, username, "password_changed", username, "")
http.Redirect(w, r, "/settings?flash="+flashMsg("Password changed")+"&kind=success", http.StatusFound)
}
func (h *Web) handleBackupNow(w http.ResponseWriter, r *http.Request) {
s := h.store.Current().Settings
path, err := backup.Run(h.db, s.BackupPath, s.BackupRetentionCount)
if err != nil {
http.Redirect(w, r, "/settings?flash="+flashMsg("Backup failed: "+err.Error())+"&kind=danger", http.StatusFound)
return
}
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "settings_changed", "backup", "manual backup: "+path)
http.Redirect(w, r, "/settings?flash="+flashMsg("Backup created: "+path)+"&kind=success", http.StatusFound)
}
+118
View File
@@ -0,0 +1,118 @@
:root {
--vp-sidebar-w: 240px;
}
body {
background-color: #f4f6f9;
}
.vp-shell {
display: flex;
min-height: 100vh;
}
.vp-sidebar {
width: var(--vp-sidebar-w);
flex-shrink: 0;
background-color: #1b2430;
color: #cfd6e2;
position: sticky;
top: 0;
height: 100vh;
}
.vp-brand {
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 1.1rem;
padding: 1.1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.vp-brand i {
color: #4dabf7;
}
.vp-nav {
padding: 0.75rem 0.5rem;
flex: 1;
}
.vp-nav .nav-link {
color: #a9b4c4;
border-radius: 0.4rem;
padding: 0.55rem 0.85rem;
margin-bottom: 0.15rem;
font-size: 0.92rem;
}
.vp-nav .nav-link i {
width: 1.25rem;
display: inline-block;
text-align: center;
margin-right: 0.4rem;
}
.vp-nav .nav-link:hover {
background-color: rgba(255, 255, 255, 0.06);
color: #fff;
}
.vp-nav .nav-link.active {
background-color: #2c5cc5;
color: #fff;
}
.vp-sidebar-footer {
padding: 0.85rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.vp-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.vp-topbar {
background: #fff;
border-bottom: 1px solid #e5e9f0;
padding: 0.9rem 1.5rem;
}
.vp-content {
padding: 1.5rem;
flex: 1;
}
.vp-chip-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.vp-chip {
font-weight: 500;
padding: 0.4rem 0.6rem;
}
.stat-card {
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.stat-card .stat-value {
font-size: 1.7rem;
font-weight: 700;
}
.table-hover tbody tr {
cursor: default;
}
code.vp-token {
word-break: break-all;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
+104
View File
@@ -0,0 +1,104 @@
// vibeproxy admin UI — minimal vanilla JS, no framework.
(function () {
"use strict";
// Generic delete-confirmation modal. Any element with
// data-confirm-delete-form="<form id>" and data-confirm-message="..."
// opens #confirmDeleteModal and, on confirm, submits the named form.
document.addEventListener("click", function (e) {
var trigger = e.target.closest("[data-confirm-delete-form]");
if (!trigger) return;
e.preventDefault();
var formId = trigger.getAttribute("data-confirm-delete-form");
var message = trigger.getAttribute("data-confirm-message") || "Are you sure? This cannot be undone.";
var modalEl = document.getElementById("confirmDeleteModal");
if (!modalEl) return;
modalEl.querySelector(".modal-body").textContent = message;
var confirmBtn = modalEl.querySelector("[data-confirm-action]");
confirmBtn.onclick = function () {
var form = document.getElementById(formId);
if (form) form.submit();
};
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
});
// Dynamic add/remove rows for repeatable form sections (backends,
// custom headers), driven by a <template id="{tableId}RowTemplate">.
document.querySelectorAll("[data-add-row]").forEach(function (btn) {
btn.addEventListener("click", function () {
var tableId = btn.getAttribute("data-add-row");
var table = document.getElementById(tableId);
var tmpl = document.getElementById(tableId + "RowTemplate");
if (!table || !tmpl) return;
var tbody = table.querySelector("tbody");
tbody.appendChild(tmpl.content.firstElementChild.cloneNode(true));
});
});
document.addEventListener("click", function (e) {
var btn = e.target.closest("[data-remove-row]");
if (!btn) return;
var tr = btn.closest("tr");
if (tr) tr.remove();
});
// Live client-side filter for any table with data-searchable="true",
// driven by an <input data-search-target="tableId">.
document.querySelectorAll("[data-search-target]").forEach(function (input) {
input.addEventListener("input", function () {
var table = document.getElementById(input.getAttribute("data-search-target"));
if (!table) return;
var q = input.value.trim().toLowerCase();
table.querySelectorAll("tbody tr").forEach(function (row) {
row.style.display = row.textContent.toLowerCase().indexOf(q) === -1 ? "none" : "";
});
});
});
// Domain / CIDR chip inputs: comma or newline separated list rendered
// into a hidden textarea, e.g. for proxy host domains.
document.querySelectorAll("[data-chip-input]").forEach(function (wrap) {
var hidden = wrap.querySelector("textarea");
var display = wrap.querySelector(".vp-chip-list");
var entry = wrap.querySelector("input[type=text]");
if (!hidden || !display || !entry) return;
function items() {
return hidden.value.split("\n").map(function (s) { return s.trim(); }).filter(Boolean);
}
function renderChips() {
display.innerHTML = "";
items().forEach(function (item, idx) {
var chip = document.createElement("span");
chip.className = "badge text-bg-secondary vp-chip";
chip.textContent = item;
var x = document.createElement("button");
x.type = "button";
x.className = "btn-close btn-close-white btn-sm ms-2";
x.setAttribute("aria-label", "Remove");
x.addEventListener("click", function () {
var list = items();
list.splice(idx, 1);
hidden.value = list.join("\n");
renderChips();
});
chip.appendChild(x);
display.appendChild(chip);
});
}
entry.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
var v = entry.value.trim().replace(/,$/, "");
if (v) {
var list = items();
list.push(v);
hidden.value = list.join("\n");
entry.value = "";
renderChips();
}
}
});
renderChips();
});
})();
File diff suppressed because one or more lines are too long
+114
View File
@@ -0,0 +1,114 @@
package web
import (
"fmt"
"net/http"
"strconv"
"github.com/owen/vibeproxy/internal/auth"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/logging"
"github.com/owen/vibeproxy/internal/models"
)
func (h *Web) handleStreamsList(w http.ResponseWriter, r *http.Request) {
streams, err := database.ListStreams(h.db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, "streams", PageData{
Active: "streams", Title: "TCP/UDP Streams", CSRFToken: h.csrf.Token(w),
Flash: r.URL.Query().Get("flash"), FlashKind: r.URL.Query().Get("kind"),
Data: streams,
})
}
func (h *Web) handleStreamForm(w http.ResponseWriter, r *http.Request) {
var s models.Stream
isNew := true
if idStr := r.PathValue("id"); idStr != "" {
isNew = false
id, _ := strconv.ParseInt(idStr, 10, 64)
existing, err := database.GetStream(h.db, id)
if err != nil {
http.Error(w, "stream not found", http.StatusNotFound)
return
}
s = *existing
} else {
s = models.Stream{Protocol: models.StreamTCP, ConnectTimeoutMS: 5000, IdleTimeoutMS: 300000, Enabled: true}
}
render(w, "stream_form", PageData{
Active: "streams", Title: pageTitle(isNew, "Stream"), CSRFToken: h.csrf.Token(w),
Data: map[string]any{"Stream": s, "IsNew": isNew},
})
}
func (h *Web) handleStreamCreate(w http.ResponseWriter, r *http.Request) { h.saveStreamForm(w, r, 0) }
func (h *Web) handleStreamUpdate(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
h.saveStreamForm(w, r, id)
}
func (h *Web) saveStreamForm(w http.ResponseWriter, r *http.Request, id int64) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s := models.Stream{
ID: id,
Protocol: models.StreamProtocol(r.FormValue("protocol")),
ListenIP: r.FormValue("listen_ip"),
ListenPort: atoiDefault(r.FormValue("listen_port"), 0),
DestinationHost: r.FormValue("destination_host"),
DestinationPort: atoiDefault(r.FormValue("destination_port"), 0),
ConnectTimeoutMS: atoiDefault(r.FormValue("connect_timeout_ms"), 5000),
IdleTimeoutMS: atoiDefault(r.FormValue("idle_timeout_ms"), 300000),
ProxyProtocol: r.FormValue("proxy_protocol") == "on",
Enabled: r.FormValue("enabled") == "on",
Comments: r.FormValue("comments"),
}
if s.ListenPort <= 0 || s.ListenPort > 65535 || s.DestinationHost == "" || s.DestinationPort <= 0 {
render(w, "stream_form", PageData{
Active: "streams", Title: pageTitle(id == 0, "Stream"), CSRFToken: h.csrf.Token(w),
Flash: "Please provide a valid listen port and destination.", FlashKind: "danger",
Data: map[string]any{"Stream": s, "IsNew": id == 0},
})
return
}
if _, err := database.SaveStream(h.db, &s); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reloadStreams(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "stream_changed", fmt.Sprintf("%s:%d", s.Protocol, s.ListenPort), "")
http.Redirect(w, r, "/streams?flash="+flashMsg("Stream saved")+"&kind=success", http.StatusFound)
}
func (h *Web) handleStreamDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := database.DeleteStream(h.db, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reloadStreams(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "stream_changed", fmt.Sprintf("id=%d", id), "deleted")
http.Redirect(w, r, "/streams?flash="+flashMsg("Stream deleted")+"&kind=success", http.StatusFound)
}
func (h *Web) handleStreamToggle(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
existing, err := database.GetStream(h.db, id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := database.SetStreamEnabled(h.db, id, !existing.Enabled); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.reloadStreams(r)
logging.Audit(h.db, auth.ActorFromContext(r.Context()), "stream_changed", fmt.Sprintf("id=%d", id), fmt.Sprintf("enabled=%v", !existing.Enabled))
http.Redirect(w, r, "/streams", http.StatusFound)
}
@@ -0,0 +1,72 @@
{{define "access_list_form"}}{{template "header" .}}
{{$d := .Data}}{{$l := $d.List}}
<div class="card"><div class="card-body">
<form method="post" action="{{if $d.IsNew}}/access-lists/new{{else}}/access-lists/{{$l.ID}}/edit{{end}}">
{{template "csrf_field" .CSRFToken}}
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Name</label>
<input class="form-control" name="name" value="{{$l.Name}}" required>
</div>
<div class="col-md-3 d-flex align-items-end">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="default_allow" id="da" {{if $l.DefaultAllow}}checked{{end}}>
<label class="form-check-label" for="da">Default allow (unmatched IPs are allowed)</label>
</div>
</div>
<div class="col-12">
<label class="form-label">Comments</label>
<input class="form-control" name="comments" value="{{$l.Comments}}">
</div>
</div>
<h6>CIDR Rules</h6>
<table class="table table-sm align-middle" id="rulesTable">
<thead><tr><th style="width:10rem">Action</th><th>CIDR</th><th></th></tr></thead>
<tbody>
{{range $l.Rules}}
<tr>
<td><select class="form-select form-select-sm" name="rule_action">
<option value="allow" {{if eq .Action "allow"}}selected{{end}}>Allow</option>
<option value="deny" {{if eq .Action "deny"}}selected{{end}}>Deny</option>
</select></td>
<td><input class="form-control form-control-sm" name="rule_cidr" value="{{.CIDR}}" placeholder="10.0.0.0/8"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr>
{{end}}
</tbody>
</table>
<button type="button" class="btn btn-sm btn-outline-primary mb-4" data-add-row="rulesTable"><i class="bi bi-plus-lg"></i> Add Rule</button>
<template id="rulesTableRowTemplate"><tr>
<td><select class="form-select form-select-sm" name="rule_action"><option value="allow" selected>Allow</option><option value="deny">Deny</option></select></td>
<td><input class="form-control form-control-sm" name="rule_cidr" placeholder="10.0.0.0/8"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr></template>
<h6>HTTP Basic Auth Users <span class="text-secondary small">(optional — leave off for IP-only lists)</span></h6>
<table class="table table-sm align-middle" id="usersTable">
<thead><tr><th>Username</th><th>Password <span class="text-secondary">(leave blank to keep existing)</span></th><th></th></tr></thead>
<tbody>
{{range $l.BasicAuthUsers}}
<tr>
<td><input class="form-control form-control-sm" name="auth_username" value="{{.Username}}"></td>
<td><input class="form-control form-control-sm" type="password" name="auth_password" placeholder="••••••••"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr>
{{end}}
</tbody>
</table>
<button type="button" class="btn btn-sm btn-outline-primary mb-4" data-add-row="usersTable"><i class="bi bi-plus-lg"></i> Add User</button>
<template id="usersTableRowTemplate"><tr>
<td><input class="form-control form-control-sm" name="auth_username"></td>
<td><input class="form-control form-control-sm" type="password" name="auth_password" placeholder="••••••••"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr></template>
<div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Save</button>
<a href="/access-lists" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div></div>
{{template "footer" .}}{{end}}
+33
View File
@@ -0,0 +1,33 @@
{{define "access_lists"}}{{template "header" .}}
<div class="d-flex justify-content-between align-items-center mb-3">
<input type="text" class="form-control w-auto" placeholder="Search access lists…" data-search-target="aclTable">
<a href="/access-lists/new" class="btn btn-primary"><i class="bi bi-plus-lg"></i> Add Access List</a>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="aclTable">
<thead class="table-light"><tr><th>Name</th><th>Default</th><th>Rules</th><th>Basic Auth Users</th><th></th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td><a href="/access-lists/{{.ID}}/edit">{{.Name}}</a>{{if .Comments}}<div class="small text-secondary">{{.Comments}}</div>{{end}}</td>
<td>{{if .DefaultAllow}}<span class="badge text-bg-success">allow</span>{{else}}<span class="badge text-bg-danger">deny</span>{{end}}</td>
<td>{{len .Rules}} rule(s)</td>
<td>{{len .BasicAuthUsers}} user(s)</td>
<td class="text-end">
<a href="/access-lists/{{.ID}}/edit" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
<form method="post" action="/access-lists/{{.ID}}/delete" id="del-acl-{{.ID}}" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
</form>
<button type="button" class="btn btn-sm btn-outline-danger" data-confirm-delete-form="del-acl-{{.ID}}" data-confirm-message="Delete access list &quot;{{.Name}}&quot;?"><i class="bi bi-trash"></i></button>
</td>
</tr>
{{else}}
<tr><td colspan="5" class="text-center text-secondary py-4">No access lists yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "confirm_delete_modal" .}}
{{template "footer" .}}{{end}}
+62
View File
@@ -0,0 +1,62 @@
{{define "api_tokens"}}{{template "header" .}}
<div class="card mb-4">
<div class="card-header">API Access</div>
<div class="card-body">
<p>The REST API is available under <code>/api/v1</code> on this admin listener, authenticated with a bearer token:</p>
<pre class="bg-light p-3 rounded"><code>curl -H "Authorization: Bearer vp_xxxxxxxx" https://your-server:8081/api/v1/proxy-hosts</code></pre>
<p class="mb-0 text-secondary">Resources: proxy-hosts, streams, certificates, access-lists, settings, logs/requests, logs/audit, stats, tokens.</p>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">API Tokens</h6>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newTokenModal"><i class="bi bi-plus-lg"></i> New Token</button>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr><th>Name</th><th>Prefix</th><th>Created</th><th>Last Used</th><th>Status</th><th></th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td>{{.Name}}</td>
<td><code>{{.Prefix}}…</code></td>
<td>{{fmtTime .CreatedAt}}</td>
<td>{{if .LastUsedAt}}{{timeAgo (deref .LastUsedAt)}}{{else}}never{{end}}</td>
<td>{{if .RevokedAt}}<span class="badge text-bg-secondary">revoked</span>{{else}}<span class="badge text-bg-success">active</span>{{end}}</td>
<td class="text-end">
{{if not .RevokedAt}}
<form method="post" action="/api-tokens/{{.ID}}/revoke" id="rev-tok-{{.ID}}" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
</form>
<button type="button" class="btn btn-sm btn-outline-danger" data-confirm-delete-form="rev-tok-{{.ID}}" data-confirm-message="Revoke token &quot;{{.Name}}&quot;? Anything using it will stop working immediately.">Revoke</button>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="6" class="text-center text-secondary py-4">No API tokens yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="modal fade" id="newTokenModal" tabindex="-1">
<div class="modal-dialog"><div class="modal-content">
<form method="post" action="/api-tokens/new">
{{template "csrf_field" .CSRFToken}}
<div class="modal-header"><h5 class="modal-title">New API Token</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<label class="form-label">Name</label>
<input class="form-control" name="name" placeholder="e.g. terraform, ci-pipeline" required>
<p class="small text-secondary mt-2 mb-0">The token is shown once after creation — store it securely.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create Token</button>
</div>
</form>
</div></div>
</div>
{{template "confirm_delete_modal" .}}
{{template "footer" .}}{{end}}
+24
View File
@@ -0,0 +1,24 @@
{{define "backends"}}{{template "header" .}}
<p class="text-secondary">Live backend status from the running configuration. Add or edit backends from the owning Proxy Host.</p>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="backendsListTable">
<thead class="table-light"><tr><th>Proxy Host</th><th>Backend</th><th>Weight</th><th>Health Check</th><th>Config</th><th>Live Health</th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td><a href="/proxy-hosts/{{.ProxyHostID}}/edit">{{.ProxyHostName}}</a></td>
<td>{{.Host}}:{{.Port}}</td>
<td>{{.Weight}}</td>
<td>{{.HealthCheck}}</td>
<td>{{if .Enabled}}<span class="badge text-bg-success">enabled</span>{{else}}<span class="badge text-bg-secondary">disabled</span>{{end}}</td>
<td>{{if .Healthy}}<span class="badge text-bg-success"><i class="bi bi-heart-pulse"></i> healthy</span>{{else}}<span class="badge text-bg-danger"><i class="bi bi-heart-pulse"></i> unhealthy</span>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="6" class="text-center text-secondary py-4">No backends configured.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "footer" .}}{{end}}
+85
View File
@@ -0,0 +1,85 @@
{{define "certificates"}}{{template "header" .}}
<div class="d-flex justify-content-end gap-2 mb-3">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#issueLEModal"><i class="bi bi-shield-plus"></i> Request Let's Encrypt</button>
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#uploadCertModal"><i class="bi bi-upload"></i> Upload Custom</button>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr><th>Name</th><th>Domains</th><th>Type</th><th>Expires</th><th>Status</th><th></th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td>{{.Name}}</td>
<td>{{join .Domains ", "}}</td>
<td><span class="badge text-bg-{{if eq .Type "letsencrypt"}}success{{else}}secondary{{end}}">{{.Type}}</span></td>
<td>{{if .NotAfter}}{{fmtTime (deref .NotAfter)}}{{else}}—{{end}}</td>
<td>{{if .LastError}}<span class="badge text-bg-danger" title="{{.LastError}}">error</span>{{else}}<span class="badge text-bg-success">ok</span>{{end}}</td>
<td class="text-end">
{{if eq .Type "letsencrypt"}}
<form method="post" action="/certificates/{{.ID}}/renew" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button class="btn btn-sm btn-outline-secondary" title="Renew now"><i class="bi bi-arrow-clockwise"></i></button>
</form>
{{end}}
<form method="post" action="/certificates/{{.ID}}/delete" id="del-cert-{{.ID}}" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
</form>
<button type="button" class="btn btn-sm btn-outline-danger" data-confirm-delete-form="del-cert-{{.ID}}" data-confirm-message="Delete certificate &quot;{{.Name}}&quot;? Proxy hosts using it will fall back to no TLS certificate."><i class="bi bi-trash"></i></button>
</td>
</tr>
{{else}}
<tr><td colspan="6" class="text-center text-secondary py-4">No certificates yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="modal fade" id="issueLEModal" tabindex="-1">
<div class="modal-dialog"><div class="modal-content">
<form method="post" action="/certificates/letsencrypt">
{{template "csrf_field" .CSRFToken}}
<div class="modal-header"><h5 class="modal-title">Request Let's Encrypt Certificate</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="mb-3"><label class="form-label">Name</label><input class="form-control" name="name" required></div>
<div class="mb-3"><label class="form-label">Domains (one per line, wildcards need DNS-01)</label><textarea class="form-control" name="domains" rows="3" required></textarea></div>
<div class="mb-3"><label class="form-label">Contact Email (optional — falls back to Settings)</label><input class="form-control" name="email" type="email"></div>
<div class="mb-3">
<label class="form-label">Challenge Type</label>
<select class="form-select" name="challenge_type">
<option value="http-01">HTTP-01 (port 80 reachable from the internet)</option>
<option value="dns-01">DNS-01 (required for wildcards)</option>
</select>
</div>
<div class="mb-3"><label class="form-label">DNS Provider (DNS-01 only)</label><input class="form-control" name="dns_provider" placeholder="cloudflare"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Request Certificate</button>
</div>
</form>
</div></div>
</div>
<div class="modal fade" id="uploadCertModal" tabindex="-1">
<div class="modal-dialog"><div class="modal-content">
<form method="post" action="/certificates/custom">
{{template "csrf_field" .CSRFToken}}
<div class="modal-header"><h5 class="modal-title">Upload Custom Certificate</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="mb-3"><label class="form-label">Name</label><input class="form-control" name="name" required></div>
<div class="mb-3"><label class="form-label">Domains (one per line)</label><textarea class="form-control" name="domains" rows="2"></textarea></div>
<div class="mb-3"><label class="form-label">Certificate (PEM, full chain)</label><textarea class="form-control font-monospace" name="cert_pem" rows="5" required placeholder="-----BEGIN CERTIFICATE-----"></textarea></div>
<div class="mb-3"><label class="form-label">Private Key (PEM)</label><textarea class="form-control font-monospace" name="key_pem" rows="5" required placeholder="-----BEGIN PRIVATE KEY-----"></textarea></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</form>
</div></div>
</div>
{{template "confirm_delete_modal" .}}
{{template "footer" .}}{{end}}
+115
View File
@@ -0,0 +1,115 @@
{{define "dashboard"}}{{template "header" .}}
{{$d := .Data}}
<div class="row g-3 mb-4">
<div class="col-sm-6 col-lg-3">
<div class="card stat-card h-100"><div class="card-body">
<div class="text-secondary small">Active Proxy Hosts</div>
<div class="stat-value">{{$d.ActiveProxyHosts}}</div>
</div></div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card stat-card h-100"><div class="card-body">
<div class="text-secondary small">TCP / UDP Streams</div>
<div class="stat-value">{{$d.ActiveTCPStreams}} / {{$d.ActiveUDPStreams}}</div>
</div></div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card stat-card h-100"><div class="card-body">
<div class="text-secondary small">Requests / sec (1h avg)</div>
<div class="stat-value">{{printf "%.2f" $d.RequestsPerSecond}}</div>
</div></div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card stat-card h-100"><div class="card-body">
<div class="text-secondary small">Requests (24h)</div>
<div class="stat-value">{{$d.TotalRequestsToday}}</div>
</div></div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Status Breakdown (24h)</div>
<div class="card-body">
{{if $d.StatusBreakdown}}
<table class="table table-sm mb-0">
<tbody>
{{range $d.StatusBreakdown}}
<tr><td class="{{statusClass .Status}}">{{.Status}}</td><td>{{.Count}}</td></tr>
{{end}}
</tbody>
</table>
{{else}}<p class="text-secondary mb-0">No requests logged yet.</p>{{end}}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Backend Health</div>
<div class="card-body">
{{if $d.BackendHealth}}
<table class="table table-sm mb-0">
<tbody>
{{range $d.BackendHealth}}
<tr>
<td>{{.ProxyHost}}</td><td>{{.Backend}}</td>
<td>{{if .Healthy}}<span class="badge text-bg-success">healthy</span>{{else}}<span class="badge text-bg-danger">unhealthy</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}<p class="text-secondary mb-0">No backends configured.</p>{{end}}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Top Hosts (24h)</div>
<div class="card-body">
{{if $d.TopHosts}}
<table class="table table-sm mb-0"><tbody>
{{range $d.TopHosts}}<tr><td>{{.Host}}</td><td>{{.Count}}</td></tr>{{end}}
</tbody></table>
{{else}}<p class="text-secondary mb-0">No data yet.</p>{{end}}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Top Clients (24h)</div>
<div class="card-body">
{{if $d.TopClients}}
<table class="table table-sm mb-0"><tbody>
{{range $d.TopClients}}<tr><td>{{.Host}}</td><td>{{.Count}}</td></tr>{{end}}
</tbody></table>
{{else}}<p class="text-secondary mb-0">No data yet.</p>{{end}}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Certificates Expiring Soon (30d)</div>
<div class="card-body">
{{if $d.CertsExpiringSoon}}
<table class="table table-sm mb-0"><tbody>
{{range $d.CertsExpiringSoon}}<tr><td>{{.Name}}</td><td>{{fmtTime (deref .NotAfter)}}</td></tr>{{end}}
</tbody></table>
{{else}}<p class="text-secondary mb-0">Nothing expiring soon.</p>{{end}}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header">Recent Errors (24h)</div>
<div class="card-body">
{{if $d.RecentErrors}}
<table class="table table-sm mb-0"><tbody>
{{range $d.RecentErrors}}<tr><td>{{fmtTime .Timestamp}}</td><td class="{{statusClass .Status}}">{{.Status}}</td><td>{{.Host}}{{.Path}}</td></tr>{{end}}
</tbody></table>
{{else}}<p class="text-secondary mb-0">No recent errors.</p>{{end}}
</div>
</div>
</div>
</div>
{{template "footer" .}}{{end}}
+51
View File
@@ -0,0 +1,51 @@
{{define "logs"}}{{template "header" .}}
{{$d := .Data}}
<ul class="nav nav-pills mb-3">
<li class="nav-item"><a class="nav-link active" href="/logs">Requests</a></li>
<li class="nav-item"><a class="nav-link" href="/logs/audit">Audit</a></li>
</ul>
<form method="get" action="/logs" class="row g-2 mb-3">
<div class="col-auto">
<input type="text" class="form-control" name="q" value="{{$d.Query}}" placeholder="Search host / path / IP…">
</div>
<div class="col-auto">
<input type="text" class="form-control" name="status" value="{{if $d.Status}}{{$d.Status}}{{end}}" placeholder="Status code">
</div>
<div class="col-auto"><button class="btn btn-outline-primary">Filter</button></div>
</form>
<div class="card">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr><th>Time</th><th>Proxy Host</th><th>Client IP</th><th>Method</th><th>Host</th><th>Path</th><th>Status</th><th>Size</th><th>Backend</th><th>ms</th><th>User Agent</th></tr>
</thead>
<tbody>
{{range $d.Logs}}
<tr>
<td>{{fmtTime .Timestamp}}</td>
<td>{{.ProxyHostName}}</td>
<td>{{.ClientIP}}</td>
<td>{{.Method}}</td>
<td>{{.Host}}</td>
<td class="text-truncate" style="max-width:16rem">{{.Path}}</td>
<td class="{{statusClass .Status}}">{{.Status}}</td>
<td>{{.ResponseSize}}</td>
<td>{{.Backend}}</td>
<td>{{.ResponseTimeMS}}</td>
<td class="text-truncate" style="max-width:12rem">{{.UserAgent}}</td>
</tr>
{{else}}
<tr><td colspan="11" class="text-center text-secondary py-4">No requests logged yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if gt $d.TotalPages 1}}
<nav class="mt-3"><ul class="pagination pagination-sm">
{{range $i := iterate $d.TotalPages}}
<li class="page-item {{if eq (add $i 1) $d.Page}}active{{end}}"><a class="page-link" href="/logs?page={{add $i 1}}&q={{$d.Query}}">{{add $i 1}}</a></li>
{{end}}
</ul></nav>
{{end}}
{{template "footer" .}}{{end}}
+34
View File
@@ -0,0 +1,34 @@
{{define "logs_audit"}}{{template "header" .}}
{{$d := .Data}}
<ul class="nav nav-pills mb-3">
<li class="nav-item"><a class="nav-link" href="/logs">Requests</a></li>
<li class="nav-item"><a class="nav-link active" href="/logs/audit">Audit</a></li>
</ul>
<div class="card">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light"><tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th><th>Details</th></tr></thead>
<tbody>
{{range $d.Logs}}
<tr>
<td>{{fmtTime .Timestamp}}</td>
<td>{{.Actor}}</td>
<td><span class="badge text-bg-light border">{{.Action}}</span></td>
<td>{{.Target}}</td>
<td class="text-secondary">{{.Details}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="text-center text-secondary py-4">No audit events yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if gt $d.TotalPages 1}}
<nav class="mt-3"><ul class="pagination pagination-sm">
{{range $i := iterate $d.TotalPages}}
<li class="page-item {{if eq (add $i 1) $d.Page}}active{{end}}"><a class="page-link" href="/logs/audit?page={{add $i 1}}">{{add $i 1}}</a></li>
{{end}}
</ul></nav>
{{end}}
{{template "footer" .}}{{end}}
@@ -0,0 +1,9 @@
{{define "footer"}}
</main>
</div>
</div>
<script src="/static/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
{{end}}
@@ -0,0 +1,44 @@
{{define "header"}}<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · vibeproxy</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<div class="vp-shell">
<nav class="vp-sidebar d-flex flex-column">
<a href="/dashboard" class="vp-brand d-flex align-items-center gap-2">
<i class="bi bi-hexagon-fill"></i>
<span>vibeproxy</span>
</a>
<ul class="nav flex-column vp-nav">
<li class="nav-item"><a class="nav-link {{if eq .Active "dashboard"}}active{{end}}" href="/dashboard"><i class="bi bi-speedometer2"></i> Dashboard</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "proxy_hosts"}}active{{end}}" href="/proxy-hosts"><i class="bi bi-globe2"></i> Proxy Hosts</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "streams"}}active{{end}}" href="/streams"><i class="bi bi-arrow-left-right"></i> TCP/UDP Streams</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "backends"}}active{{end}}" href="/backends"><i class="bi bi-hdd-network"></i> Backends</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "certificates"}}active{{end}}" href="/certificates"><i class="bi bi-shield-lock"></i> Certificates</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "access_lists"}}active{{end}}" href="/access-lists"><i class="bi bi-key"></i> Access Lists</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "logs"}}active{{end}}" href="/logs"><i class="bi bi-journal-text"></i> Logs</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "settings"}}active{{end}}" href="/settings"><i class="bi bi-gear"></i> Settings</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "api"}}active{{end}}" href="/api-tokens"><i class="bi bi-code-slash"></i> API</a></li>
</ul>
<div class="vp-sidebar-footer text-secondary small">
<i class="bi bi-box-seam"></i> vibeproxy
</div>
</nav>
<div class="vp-main">
<header class="vp-topbar d-flex align-items-center justify-content-between">
<h1 class="h5 mb-0">{{.Title}}</h1>
</header>
<main class="vp-content">
{{if .Flash}}
<div class="alert alert-{{if .FlashKind}}{{.FlashKind}}{{else}}success{{end}} alert-dismissible fade show" role="alert">
{{.Flash}}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{{end}}
{{end}}
@@ -0,0 +1,19 @@
{{define "confirm_delete_modal"}}
<div class="modal fade" id="confirmDeleteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-exclamation-triangle text-danger"></i> Confirm</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">Are you sure?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-confirm-action data-bs-dismiss="modal">Delete</button>
</div>
</div>
</div>
</div>
{{end}}
{{define "csrf_field"}}<input type="hidden" name="csrf_token" value="{{.}}">{{end}}
+266
View File
@@ -0,0 +1,266 @@
{{define "proxy_host_form"}}{{template "header" .}}
{{$d := .Data}}{{$h := $d.Host}}
<form method="post" action="{{if $d.IsNew}}/proxy-hosts/new{{else}}/proxy-hosts/{{$h.ID}}/edit{{end}}">
{{template "csrf_field" .CSRFToken}}
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-details" type="button">Details</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-backends" type="button">Backends</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-ssl" type="button">SSL &amp; Access</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-headers" type="button">Headers</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-advanced" type="button">Advanced</button></li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-details">
<div class="card mb-3"><div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name</label>
<input class="form-control" name="name" value="{{$h.Name}}" required>
</div>
<div class="col-md-6">
<label class="form-label">Type</label>
<select class="form-select" name="type" id="hostType">
<option value="proxy" {{if eq $h.Type "proxy"}}selected{{end}}>Proxy</option>
<option value="redirect" {{if eq $h.Type "redirect"}}selected{{end}}>Redirect</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Domains</label>
<div data-chip-input>
<div class="vp-chip-list"></div>
<input type="text" class="form-control" placeholder="example.com — press Enter to add">
<textarea name="domains" class="d-none">{{join $h.Domains "\n"}}</textarea>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Listen Address</label>
<input class="form-control" name="listen_addr" value="{{$h.ListenAddr}}" placeholder="0.0.0.0 (all interfaces)">
</div>
<div class="col-12" id="redirectFields" style="{{if ne $h.Type "redirect"}}display:none{{end}}">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Redirect Target URL</label>
<input class="form-control" name="redirect_target_url" value="{{$h.RedirectTargetURL}}" placeholder="https://new.example.com">
</div>
<div class="col-md-3">
<label class="form-label">Redirect Code</label>
<select class="form-select" name="redirect_code">
{{range $code := (slice 301 302 307 308)}}
<option value="{{$code}}" {{if eq $h.RedirectCode $code}}selected{{end}}>{{$code}}</option>
{{end}}
</select>
</div>
<div class="col-md-3 d-flex flex-column justify-content-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="redirect_preserve_path" id="rpp" {{if $h.RedirectPreservePath}}checked{{end}}>
<label class="form-check-label" for="rpp">Preserve path</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="redirect_preserve_qs" id="rpq" {{if $h.RedirectPreserveQS}}checked{{end}}>
<label class="form-check-label" for="rpq">Preserve query string</label>
</div>
</div>
</div>
</div>
<div class="col-12">
<label class="form-label">Comments</label>
<textarea class="form-control" name="comments" rows="2">{{$h.Comments}}</textarea>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="enabled" id="enabled" {{if $h.Enabled}}checked{{end}}>
<label class="form-check-label" for="enabled">Enabled</label>
</div>
</div>
</div>
</div></div>
</div>
<div class="tab-pane fade" id="tab-backends">
<div class="card mb-3"><div class="card-body">
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Upstream Scheme</label>
<select class="form-select" name="forward_scheme">
<option value="http" {{if eq $h.ForwardScheme "http"}}selected{{end}}>http</option>
<option value="https" {{if eq $h.ForwardScheme "https"}}selected{{end}}>https</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Load Balancing Method</label>
<select class="form-select" name="load_balancing_method">
<option value="round_robin" {{if eq $h.LoadBalancingMethod "round_robin"}}selected{{end}}>Round robin</option>
<option value="least_conn" {{if eq $h.LoadBalancingMethod "least_conn"}}selected{{end}}>Least connections</option>
<option value="weighted" {{if eq $h.LoadBalancingMethod "weighted"}}selected{{end}}>Weighted</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="websocket_support" id="ws" {{if $h.WebsocketSupport}}checked{{end}}>
<label class="form-check-label" for="ws">WebSocket support</label>
</div>
</div>
</div>
<table class="table table-sm align-middle" id="backendsTable">
<thead><tr><th>Host</th><th>Port</th><th>Weight</th><th>On</th><th>Health Check</th><th>Path</th><th>Status</th><th>Interval(s)</th><th>Fail</th><th>Recover</th><th></th></tr></thead>
<tbody>
{{range $i, $b := $h.Backends}}
<tr>
<td><input class="form-control form-control-sm" name="backend_host" value="{{$b.Host}}"></td>
<td><input class="form-control form-control-sm" style="width:5.5rem" name="backend_port" value="{{$b.Port}}"></td>
<td><input class="form-control form-control-sm" style="width:4.5rem" name="backend_weight" value="{{$b.Weight}}"></td>
<td>
<input type="checkbox" class="form-check-input" {{if $b.Enabled}}checked{{end}} onchange="this.nextElementSibling.value=this.checked">
<input type="hidden" name="backend_enabled" value="{{if $b.Enabled}}true{{else}}false{{end}}">
</td>
<td>
<select class="form-select form-select-sm" name="backend_hc_type">
<option value="tcp" {{if eq $b.HealthCheckType "tcp"}}selected{{end}}>TCP</option>
<option value="http" {{if eq $b.HealthCheckType "http"}}selected{{end}}>HTTP</option>
<option value="none" {{if eq $b.HealthCheckType "none"}}selected{{end}}>None</option>
</select>
</td>
<td><input class="form-control form-control-sm" style="width:6rem" name="backend_hc_path" value="{{$b.HealthCheckPath}}"></td>
<td><input class="form-control form-control-sm" style="width:4.5rem" name="backend_hc_status" value="{{$b.HealthCheckStatus}}"></td>
<td><input class="form-control form-control-sm" style="width:4.5rem" name="backend_hc_interval" value="{{$b.IntervalSeconds}}"></td>
<td><input class="form-control form-control-sm" style="width:4rem" name="backend_hc_fail" value="{{$b.FailureThreshold}}"></td>
<td><input class="form-control form-control-sm" style="width:4rem" name="backend_hc_recover" value="{{$b.RecoveryThreshold}}"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr>
{{end}}
</tbody>
</table>
<button type="button" class="btn btn-sm btn-outline-primary" data-add-row="backendsTable"><i class="bi bi-plus-lg"></i> Add Backend</button>
<template id="backendsTableRowTemplate"><tr>
<td><input class="form-control form-control-sm" name="backend_host"></td>
<td><input class="form-control form-control-sm" style="width:5.5rem" name="backend_port" value="80"></td>
<td><input class="form-control form-control-sm" style="width:4.5rem" name="backend_weight" value="1"></td>
<td>
<input type="checkbox" class="form-check-input" checked onchange="this.nextElementSibling.value=this.checked">
<input type="hidden" name="backend_enabled" value="true">
</td>
<td><select class="form-select form-select-sm" name="backend_hc_type">
<option value="tcp" selected>TCP</option><option value="http">HTTP</option><option value="none">None</option>
</select></td>
<td><input class="form-control form-control-sm" style="width:6rem" name="backend_hc_path" value="/"></td>
<td><input class="form-control form-control-sm" style="width:4.5rem" name="backend_hc_status" value="200"></td>
<td><input class="form-control form-control-sm" style="width:4.5rem" name="backend_hc_interval" value="10"></td>
<td><input class="form-control form-control-sm" style="width:4rem" name="backend_hc_fail" value="3"></td>
<td><input class="form-control form-control-sm" style="width:4rem" name="backend_hc_recover" value="2"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr></template>
</div></div>
</div>
<div class="tab-pane fade" id="tab-ssl">
<div class="card mb-3"><div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">SSL Certificate</label>
<select class="form-select" name="certificate_id">
<option value="0">None</option>
{{range $d.Certificates}}<option value="{{.ID}}" {{if and $h.CertificateID (eq (deref64 $h.CertificateID) .ID)}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Access List</label>
<select class="form-select" name="access_list_id">
<option value="0">None</option>
{{range $d.AccessLists}}<option value="{{.ID}}" {{if and $h.AccessListID (eq (deref64 $h.AccessListID) .ID)}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="force_https" id="fh" {{if $h.ForceHTTPS}}checked{{end}}>
<label class="form-check-label" for="fh">Force HTTPS</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="hsts_enabled" id="hsts" {{if $h.HSTSEnabled}}checked{{end}}>
<label class="form-check-label" for="hsts">HSTS</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="preserve_host_header" id="phh" {{if $h.PreserveHostHeader}}checked{{end}}>
<label class="form-check-label" for="phh">Preserve Host header</label>
</div>
</div>
</div>
</div></div>
</div>
<div class="tab-pane fade" id="tab-headers">
<div class="card mb-3"><div class="card-body">
<table class="table table-sm align-middle" id="headersTable">
<thead><tr><th>Direction</th><th>Action</th><th>Name</th><th>Value</th><th></th></tr></thead>
<tbody>
{{range $h.CustomHeaders}}
<tr>
<td><select class="form-select form-select-sm" name="header_direction">
<option value="request" {{if eq .Direction "request"}}selected{{end}}>Request</option>
<option value="response" {{if eq .Direction "response"}}selected{{end}}>Response</option>
</select></td>
<td><select class="form-select form-select-sm" name="header_action">
<option value="set" {{if eq .Action "set"}}selected{{end}}>Set</option>
<option value="remove" {{if eq .Action "remove"}}selected{{end}}>Remove</option>
</select></td>
<td><input class="form-control form-control-sm" name="header_name" value="{{.Name}}" placeholder="X-Frame-Options"></td>
<td><input class="form-control form-control-sm" name="header_value" value="{{.Value}}"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr>
{{end}}
</tbody>
</table>
<button type="button" class="btn btn-sm btn-outline-primary" data-add-row="headersTable"><i class="bi bi-plus-lg"></i> Add Header</button>
<template id="headersTableRowTemplate"><tr>
<td><select class="form-select form-select-sm" name="header_direction">
<option value="request" selected>Request</option><option value="response">Response</option>
</select></td>
<td><select class="form-select form-select-sm" name="header_action">
<option value="set" selected>Set</option><option value="remove">Remove</option>
</select></td>
<td><input class="form-control form-control-sm" name="header_name" placeholder="X-Frame-Options"></td>
<td><input class="form-control form-control-sm" name="header_value"></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" data-remove-row><i class="bi bi-x"></i></button></td>
</tr></template>
</div></div>
</div>
<div class="tab-pane fade" id="tab-advanced">
<div class="card mb-3"><div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Connect timeout (ms)</label>
<input class="form-control" name="connect_timeout_ms" value="{{$h.ConnectTimeoutMS}}">
</div>
<div class="col-md-3">
<label class="form-label">Read timeout (ms)</label>
<input class="form-control" name="read_timeout_ms" value="{{$h.ReadTimeoutMS}}">
</div>
<div class="col-md-3">
<label class="form-label">Write timeout (ms)</label>
<input class="form-control" name="write_timeout_ms" value="{{$h.WriteTimeoutMS}}">
</div>
<div class="col-md-3">
<label class="form-label">Idle timeout (ms)</label>
<input class="form-control" name="idle_timeout_ms" value="{{$h.IdleTimeoutMS}}">
</div>
</div>
</div></div>
</div>
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Save</button>
<a href="/proxy-hosts" class="btn btn-outline-secondary">Cancel</a>
</form>
<script>
document.getElementById('hostType').addEventListener('change', function () {
document.getElementById('redirectFields').style.display = this.value === 'redirect' ? '' : 'none';
});
</script>
{{template "footer" .}}{{end}}
+45
View File
@@ -0,0 +1,45 @@
{{define "proxy_hosts"}}{{template "header" .}}
{{$d := .Data}}
<div class="d-flex justify-content-between align-items-center mb-3">
<input type="text" class="form-control w-auto" placeholder="Search proxy hosts…" data-search-target="hostsTable">
<a href="/proxy-hosts/new" class="btn btn-primary"><i class="bi bi-plus-lg"></i> Add Proxy Host</a>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="hostsTable">
<thead class="table-light">
<tr><th>Name</th><th>Domains</th><th>Type</th><th>Destination</th><th>SSL</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{{range $d.Hosts}}
<tr>
<td><a href="/proxy-hosts/{{.ID}}/edit">{{.Name}}</a>{{if .Comments}}<div class="small text-secondary">{{.Comments}}</div>{{end}}</td>
<td>{{join .Domains ", "}}</td>
<td><span class="badge text-bg-{{if eq .Type "redirect"}}warning{{else}}info{{end}}">{{.Type}}</span></td>
<td>
{{if eq .Type "redirect"}}{{.RedirectTargetURL}}
{{else}}{{range .Backends}}{{.Host}}:{{.Port}} {{end}}{{end}}
</td>
<td>{{if .CertificateID}}<i class="bi bi-shield-check text-success"></i>{{else}}<i class="bi bi-shield-slash text-secondary"></i>{{end}}</td>
<td>{{if .Enabled}}<span class="badge text-bg-success">enabled</span>{{else}}<span class="badge text-bg-secondary">disabled</span>{{end}}</td>
<td class="text-end">
<form method="post" action="/proxy-hosts/{{.ID}}/toggle" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button class="btn btn-sm btn-outline-secondary" title="Enable/disable"><i class="bi bi-power"></i></button>
</form>
<a href="/proxy-hosts/{{.ID}}/edit" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
<form method="post" action="/proxy-hosts/{{.ID}}/delete" id="del-host-{{.ID}}" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
</form>
<button type="button" class="btn btn-sm btn-outline-danger" data-confirm-delete-form="del-host-{{.ID}}" data-confirm-message="Delete proxy host &quot;{{.Name}}&quot;?"><i class="bi bi-trash"></i></button>
</td>
</tr>
{{else}}
<tr><td colspan="7" class="text-center text-secondary py-4">No proxy hosts yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "confirm_delete_modal" .}}
{{template "footer" .}}{{end}}
+91
View File
@@ -0,0 +1,91 @@
{{define "settings"}}{{template "header" .}}
{{$s := .Data}}
<div class="row g-3">
<div class="col-lg-7">
<div class="card mb-3">
<div class="card-header">Let's Encrypt / ACME</div>
<div class="card-body">
<form method="post" action="/settings">
{{template "csrf_field" .CSRFToken}}
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Contact Email</label>
<input class="form-control" name="acme_email" value="{{$s.ACMEEmail}}">
</div>
<div class="col-md-6">
<label class="form-label">Renewal Window (days before expiry)</label>
<input class="form-control" name="acme_renewal_window_days" value="{{$s.ACMERenewalWindowDays}}">
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" name="acme_staging" id="staging" {{if $s.ACMEStaging}}checked{{end}}>
<label class="form-check-label" for="staging">Use Let's Encrypt staging environment</label>
</div>
</div>
<div class="col-md-12">
<label class="form-label">Certificate Storage Path</label>
<input class="form-control" name="cert_storage_path" value="{{$s.CertStoragePath}}">
</div>
<div class="col-md-12">
<label class="form-label">Trusted Proxy Networks (CIDR, one per line — only these may set X-Forwarded-For)</label>
<textarea class="form-control" name="trusted_proxy_cidrs" rows="3">{{join $s.TrustedProxyCIDRs "\n"}}</textarea>
</div>
<div class="col-md-6">
<label class="form-label">Log Retention (days)</label>
<input class="form-control" name="log_retention_days" value="{{$s.LogRetentionDays}}">
</div>
</div>
<hr>
<h6>Backups</h6>
<div class="row g-3">
<div class="col-md-4">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" name="backup_enabled" id="backupEn" {{if $s.BackupEnabled}}checked{{end}}>
<label class="form-check-label" for="backupEn">Automatic backups enabled</label>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Interval (hours)</label>
<input class="form-control" name="backup_interval_hours" value="{{$s.BackupIntervalHours}}">
</div>
<div class="col-md-4">
<label class="form-label">Keep last N backups</label>
<input class="form-control" name="backup_retention_count" value="{{$s.BackupRetentionCount}}">
</div>
<div class="col-12">
<label class="form-label">Backup Directory</label>
<input class="form-control" name="backup_path" value="{{$s.BackupPath}}">
</div>
</div>
<div class="mt-4"><button class="btn btn-primary"><i class="bi bi-check-lg"></i> Save Settings</button></div>
</form>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Manual Backup</div>
<div class="card-body">
<form method="post" action="/settings/backup">
{{template "csrf_field" .CSRFToken}}
<button class="btn btn-outline-primary"><i class="bi bi-download"></i> Backup Now</button>
</form>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card mb-3">
<div class="card-header">Administrator Account</div>
<div class="card-body">
<form method="post" action="/settings/password">
{{template "csrf_field" .CSRFToken}}
<div class="mb-3"><label class="form-label">Current Password</label><input type="password" class="form-control" name="current_password" required></div>
<div class="mb-3"><label class="form-label">New Password</label><input type="password" class="form-control" name="new_password" minlength="8" required></div>
<div class="mb-3"><label class="form-label">Confirm New Password</label><input type="password" class="form-control" name="confirm_password" minlength="8" required></div>
<button class="btn btn-primary"><i class="bi bi-key"></i> Change Password</button>
</form>
</div>
</div>
</div>
</div>
{{template "footer" .}}{{end}}
+61
View File
@@ -0,0 +1,61 @@
{{define "stream_form"}}{{template "header" .}}
{{$d := .Data}}{{$s := $d.Stream}}
<div class="card"><div class="card-body">
<form method="post" action="{{if $d.IsNew}}/streams/new{{else}}/streams/{{$s.ID}}/edit{{end}}">
{{template "csrf_field" .CSRFToken}}
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Protocol</label>
<select class="form-select" name="protocol">
<option value="tcp" {{if eq $s.Protocol "tcp"}}selected{{end}}>TCP</option>
<option value="udp" {{if eq $s.Protocol "udp"}}selected{{end}}>UDP</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Listen IP</label>
<input class="form-control" name="listen_ip" value="{{$s.ListenIP}}" placeholder="0.0.0.0 (all interfaces)">
</div>
<div class="col-md-2">
<label class="form-label">Listen Port</label>
<input class="form-control" name="listen_port" value="{{$s.ListenPort}}" required>
</div>
<div class="col-md-6">
<label class="form-label">Destination Host</label>
<input class="form-control" name="destination_host" value="{{$s.DestinationHost}}" required>
</div>
<div class="col-md-3">
<label class="form-label">Destination Port</label>
<input class="form-control" name="destination_port" value="{{$s.DestinationPort}}" required>
</div>
<div class="col-md-3">
<label class="form-label">Connect Timeout (ms)</label>
<input class="form-control" name="connect_timeout_ms" value="{{$s.ConnectTimeoutMS}}">
</div>
<div class="col-md-3">
<label class="form-label">Idle Timeout (ms)</label>
<input class="form-control" name="idle_timeout_ms" value="{{$s.IdleTimeoutMS}}">
</div>
<div class="col-md-9">
<label class="form-label">Comments</label>
<input class="form-control" name="comments" value="{{$s.Comments}}">
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="proxy_protocol" id="pp" {{if $s.ProxyProtocol}}checked{{end}}>
<label class="form-check-label" for="pp">Send PROXY protocol header to backend</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="enabled" id="en" {{if $s.Enabled}}checked{{end}}>
<label class="form-check-label" for="en">Enabled</label>
</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Save</button>
<a href="/streams" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div></div>
{{template "footer" .}}{{end}}
+40
View File
@@ -0,0 +1,40 @@
{{define "streams"}}{{template "header" .}}
<div class="d-flex justify-content-between align-items-center mb-3">
<input type="text" class="form-control w-auto" placeholder="Search streams…" data-search-target="streamsTable">
<a href="/streams/new" class="btn btn-primary"><i class="bi bi-plus-lg"></i> Add Stream</a>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="streamsTable">
<thead class="table-light">
<tr><th>Protocol</th><th>Listen</th><th>Destination</th><th>PROXY protocol</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{{range .Data}}
<tr>
<td><span class="badge text-bg-{{if eq .Protocol "tcp"}}primary{{else}}purple bg-secondary{{end}}">{{.Protocol}}</span></td>
<td>{{.ListenIP}}:{{.ListenPort}}</td>
<td>{{.DestinationHost}}:{{.DestinationPort}}</td>
<td>{{if .ProxyProtocol}}<i class="bi bi-check-lg text-success"></i>{{else}}—{{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="/streams/{{.ID}}/toggle" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button class="btn btn-sm btn-outline-secondary"><i class="bi bi-power"></i></button>
</form>
<a href="/streams/{{.ID}}/edit" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
<form method="post" action="/streams/{{.ID}}/delete" id="del-stream-{{.ID}}" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
</form>
<button type="button" class="btn btn-sm btn-outline-danger" data-confirm-delete-form="del-stream-{{.ID}}" data-confirm-message="Delete this stream?"><i class="bi bi-trash"></i></button>
</td>
</tr>
{{else}}
<tr><td colspan="6" class="text-center text-secondary py-4">No streams yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "confirm_delete_modal" .}}
{{template "footer" .}}{{end}}
+85
View File
@@ -0,0 +1,85 @@
package web
import (
"bytes"
"testing"
"time"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/models"
"github.com/owen/vibeproxy/internal/stats"
)
func mustRender(t *testing.T, name string, data PageData) {
t.Helper()
var buf bytes.Buffer
if err := templates.ExecuteTemplate(&buf, name, data); err != nil {
t.Fatalf("render %s: %v", name, err)
}
if buf.Len() == 0 {
t.Fatalf("render %s: empty output", name)
}
}
func TestRenderAllPages(t *testing.T) {
now := time.Now()
aid := int64(1)
host := models.ProxyHost{
ID: 1, Name: "example", Type: models.ProxyHostTypeProxy, Domains: []string{"example.com"},
ForwardScheme: "http", LoadBalancingMethod: "round_robin", CertificateID: &aid, AccessListID: &aid,
Backends: []models.Backend{{ID: 1, Host: "10.0.0.1", Port: 8080, Weight: 1, Enabled: true, HealthCheckType: models.HealthCheckTCP}},
CustomHeaders: []models.CustomHeader{{Direction: models.HeaderDirectionRequest, Action: models.HeaderActionSet, Name: "X-Test", Value: "1"}},
Enabled: true, CreatedAt: now, UpdatedAt: now,
}
redirectHost := host
redirectHost.Type = models.ProxyHostTypeRedirect
redirectHost.RedirectTargetURL = "https://new.example.com"
redirectHost.RedirectCode = 301
acl := models.AccessList{ID: 1, Name: "Internal", DefaultAllow: false,
Rules: []models.AccessRule{{Action: models.AccessAllow, CIDR: "10.0.0.0/8"}},
BasicAuthUsers: []models.AccessListUser{{Username: "bob", PasswordHash: "hash"}},
}
cert := models.Certificate{ID: 1, Name: "example", Type: models.CertTypeLetsEncrypt, Domains: []string{"example.com"}, NotAfter: &now}
stream := models.Stream{ID: 1, Protocol: models.StreamTCP, ListenIP: "0.0.0.0", ListenPort: 25565, DestinationHost: "10.0.0.1", DestinationPort: 25565, Enabled: true}
reqLog := models.RequestLog{ID: 1, Timestamp: now, ProxyHostName: "example", ClientIP: "1.2.3.4", Method: "GET", Host: "example.com", Path: "/", Status: 200, ResponseSize: 100, Backend: "10.0.0.1:8080", ResponseTimeMS: 12}
auditLog := models.AuditLog{ID: 1, Timestamp: now, Actor: "admin", Action: "proxy_created", Target: "example", Details: "domains=[example.com]"}
token := models.APIToken{ID: 1, Name: "ci", Prefix: "vp_abcd12", CreatedAt: now}
tests := []struct {
name string
pd PageData
}{
{"dashboard", PageData{Data: &stats.Dashboard{
BackendHealth: []stats.BackendHealthEntry{{ProxyHost: "example", Backend: "10.0.0.1", Healthy: true}},
StatusBreakdown: []database.StatusCount{{Status: 200, Count: 5}},
TopHosts: []database.HostCount{{Host: "example.com", Count: 5}},
TopClients: []database.HostCount{{Host: "1.2.3.4", Count: 5}},
CertsExpiringSoon: []models.Certificate{cert},
RecentErrors: []models.RequestLog{reqLog},
}}},
{"proxy_hosts", PageData{CSRFToken: "tok", Data: map[string]any{
"Hosts": []models.ProxyHost{host, redirectHost}, "AccessLists": []models.AccessList{acl}, "Certificates": []models.Certificate{cert},
}}},
{"proxy_host_form", PageData{CSRFToken: "tok", Data: proxyHostFormData{Host: host, AccessLists: []models.AccessList{acl}, Certificates: []models.Certificate{cert}, IsNew: false}}},
{"proxy_host_form", PageData{CSRFToken: "tok", Data: proxyHostFormData{Host: redirectHost, AccessLists: []models.AccessList{acl}, Certificates: []models.Certificate{cert}, IsNew: true}}},
{"streams", PageData{CSRFToken: "tok", Data: []models.Stream{stream}}},
{"stream_form", PageData{CSRFToken: "tok", Data: map[string]any{"Stream": stream, "IsNew": false}}},
{"backends", PageData{Data: []backendRow{{ProxyHostID: 1, ProxyHostName: "example", Host: "10.0.0.1", Port: 8080, Weight: 1, Enabled: true, Healthy: true, HealthCheck: "tcp"}}}},
{"certificates", PageData{CSRFToken: "tok", Data: []models.Certificate{cert}}},
{"access_lists", PageData{CSRFToken: "tok", Data: []models.AccessList{acl}}},
{"access_list_form", PageData{CSRFToken: "tok", Data: map[string]any{"List": acl, "IsNew": false}}},
{"logs", PageData{Data: map[string]any{"Logs": []models.RequestLog{reqLog}, "Total": 1, "Page": 1, "TotalPages": 1, "Query": "", "Status": 0}}},
{"logs_audit", PageData{Data: map[string]any{"Logs": []models.AuditLog{auditLog}, "Total": 1, "Page": 1, "TotalPages": 1}}},
{"settings", PageData{CSRFToken: "tok", Data: models.Settings{AdminUsername: "admin", TrustedProxyCIDRs: []string{"10.0.0.0/8"}}}},
{"api_tokens", PageData{CSRFToken: "tok", Data: []models.APIToken{token}}},
}
for _, tc := range tests {
mustRender(t, tc.name, tc.pd)
}
}
+22
View File
@@ -0,0 +1,22 @@
package web
import "testing"
// TestTemplatesParse ensures every embedded template parses successfully
// (render.go's init() panics on parse failure, so a passing test here just
// confirms the package loaded).
func TestTemplatesParse(t *testing.T) {
if templates == nil {
t.Fatal("templates not initialized")
}
names := []string{
"dashboard", "proxy_hosts", "proxy_host_form", "streams", "stream_form",
"backends", "certificates", "access_lists", "access_list_form",
"logs", "logs_audit", "settings", "api_tokens",
}
for _, n := range names {
if templates.Lookup(n) == nil {
t.Errorf("template %q not found", n)
}
}
}