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