package admin
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"time"
"github.com/adrian-lorenz/noxway/certs"
"github.com/adrian-lorenz/noxway/config"
"github.com/adrian-lorenz/noxway/database"
"github.com/adrian-lorenz/noxway/global"
"github.com/adrian-lorenz/noxway/pservice"
"github.com/adrian-lorenz/noxway/waf"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ─── Dashboard ───────────────────────────────────────────────────────────────
type IPStat struct {
IP string
Count int
}
type ServiceStat struct {
Name string
Count int
AvgTime float32
}
type DashboardData struct {
TotalRequests int
AvgResponseMs float32
ErrorCount int
RoutedCount int
TopIPs []IPStat
TopServices []ServiceStat
ChartLabels string
ChartData string
Span string
GatewayName string
}
func showDashboard(c *gin.Context) {
renderPage(c, "dashboard", gin.H{"Span": "hour", "GatewayName": global.Config.Name})
}
func htmxDashboard(c *gin.Context) {
span := c.DefaultQuery("span", "hour")
var logs []database.Logtable
switch span {
case "day":
database.DB.Where("created > ?", time.Now().Add(-24*time.Hour)).Find(&logs)
case "all":
database.DB.Find(&logs)
default:
database.DB.Where("created > ?", time.Now().Add(-time.Hour)).Find(&logs)
span = "hour"
}
data := computeDashboard(logs, span)
renderPartial(c, "dashboard_stats", data, "dashboard_stats.html")
}
func computeDashboard(logs []database.Logtable, span string) DashboardData {
d := DashboardData{Span: span, GatewayName: global.Config.Name}
d.TotalRequests = len(logs)
ipCounts := map[string]int{}
svcCounts := map[string]int{}
svcTimes := map[string]float32{}
var totalTime float32
for _, l := range logs {
totalTime += l.TimeFull
if l.StatusCode >= 400 {
d.ErrorCount++
}
if l.Routed {
d.RoutedCount++
}
if l.IP != "" {
ipCounts[l.IP]++
}
if l.Service != "" {
svcCounts[l.Service]++
svcTimes[l.Service] += l.TimeFull
}
}
if d.TotalRequests > 0 {
d.AvgResponseMs = totalTime / float32(d.TotalRequests)
}
for ip, cnt := range ipCounts {
d.TopIPs = append(d.TopIPs, IPStat{ip, cnt})
}
sort.Slice(d.TopIPs, func(i, j int) bool { return d.TopIPs[i].Count > d.TopIPs[j].Count })
if len(d.TopIPs) > 5 {
d.TopIPs = d.TopIPs[:5]
}
for svc, cnt := range svcCounts {
avg := float32(0)
if cnt > 0 {
avg = svcTimes[svc] / float32(cnt)
}
d.TopServices = append(d.TopServices, ServiceStat{svc, cnt, avg})
}
sort.Slice(d.TopServices, func(i, j int) bool { return d.TopServices[i].Count > d.TopServices[j].Count })
if len(d.TopServices) > 5 {
d.TopServices = d.TopServices[:5]
}
// Chart: bucket by time
var buckets int
var bucketDur time.Duration
switch span {
case "day":
buckets = 24
bucketDur = time.Hour
case "all":
buckets = 20
bucketDur = 24 * time.Hour
default:
buckets = 12
bucketDur = 5 * time.Minute
}
now := time.Now()
counts := make([]int, buckets)
labels := make([]string, buckets)
for i := 0; i < buckets; i++ {
t := now.Add(-time.Duration(buckets-1-i) * bucketDur)
labels[i] = t.Format("15:04")
}
for _, l := range logs {
for i := 0; i < buckets; i++ {
bucketStart := now.Add(-time.Duration(buckets-1-i) * bucketDur)
bucketEnd := bucketStart.Add(bucketDur)
if l.Created.After(bucketStart) && l.Created.Before(bucketEnd) {
counts[i]++
break
}
}
}
lb, _ := json.Marshal(labels)
cb, _ := json.Marshal(counts)
d.ChartLabels = string(lb)
d.ChartData = string(cb)
return d
}
// ─── Logs ────────────────────────────────────────────────────────────────────
func showLogs(c *gin.Context) {
renderPage(c, "logs", gin.H{"Span": "hour"})
}
func htmxLogs(c *gin.Context) {
span := c.DefaultQuery("span", "hour")
var logs []database.Logtable
switch span {
case "day":
database.DB.Where("created > ?", time.Now().Add(-24*time.Hour)).Order("created desc").Find(&logs)
case "all":
database.DB.Order("created desc").Limit(500).Find(&logs)
default:
database.DB.Where("created > ?", time.Now().Add(-time.Hour)).Order("created desc").Find(&logs)
}
renderPartial(c, "log_rows", logs, "log_rows.html")
}
// ─── Gateway ─────────────────────────────────────────────────────────────────
func showGateway(c *gin.Context) {
cfg := global.GetConfig()
renderPage(c, "gateway", gin.H{"Config": cfg})
}
func saveGateway(c *gin.Context) {
cfg := global.GetConfig()
applyGatewayForm(c, &cfg)
global.SetGlobConfig(cfg)
global.SaveGlobalConfig()
renderPage(c, "gateway", gin.H{"Config": cfg, "Success": "Configuration saved"})
}
func htmxSaveGateway(c *gin.Context) {
cfg := global.GetConfig()
applyGatewayForm(c, &cfg)
global.SetGlobConfig(cfg)
global.SaveGlobalConfig()
c.String(http.StatusOK, `
Configuration saved
`)
}
func htmxRetrieveCert(c *gin.Context) {
cfg := global.GetConfig()
if cfg.SSLDomain == "" || cfg.SSLMail == "" {
c.String(http.StatusBadRequest, `Domain und Mail müssen zuerst gespeichert werden
`)
return
}
dnsCheck, err := certs.CheckDNS(cfg.SSLDomain)
if err != nil {
c.String(http.StatusOK, `DNS Fehler: `+err.Error()+`
`)
return
}
if !dnsCheck {
c.String(http.StatusOK, `DNS stimmt nicht überein - Domain zeigt nicht auf diesen Server
`)
return
}
if err := certs.RetriveCert(cfg.SSLDomain, cfg.SSLMail); err != nil {
c.String(http.StatusOK, `Zertifikat Fehler: `+err.Error()+`
`)
return
}
cp, kp, err := certs.CertExist(cfg.SSLDomain)
if err != nil {
c.String(http.StatusOK, `Zertifikat erstellt aber nicht gefunden: `+err.Error()+`
`)
return
}
cfg.PemCrt = cp
cfg.PemKey = kp
global.SetGlobConfig(cfg)
global.SaveGlobalConfig()
c.String(http.StatusOK, `Zertifikat erfolgreich erstellt und gespeichert
`)
}
func validPort(s, fallback string) string {
p, err := strconv.Atoi(s)
if err != nil || p < 1 || p > 65535 {
return fallback
}
return strconv.Itoa(p)
}
func applyGatewayForm(c *gin.Context, cfg *config.ConfigStruct) {
cfg.Name = c.PostForm("name")
cfg.Prefix = c.PostForm("prefix")
cfg.Debug = c.PostForm("debug") == "on"
cfg.Port = validPort(c.PostForm("port"), "8080")
cfg.SSLPort = validPort(c.PostForm("sslPort"), "443")
cfg.ExportLog = c.PostForm("exportLog") == "on"
cfg.ExportLogPath = c.PostForm("exportLogPath")
cfg.Hostnamecheck = c.PostForm("hostnamecheck") == "on"
cfg.Hostname = c.PostForm("hostname")
cfg.SSL = c.PostForm("ssl") == "on"
cfg.SSLDomain = c.PostForm("sslDomain")
cfg.SSLMail = c.PostForm("sslMail")
cfg.RateLimiter = c.PostForm("rateLimiter") == "on"
if rate, err := strconv.Atoi(c.PostForm("rateRate")); err == nil {
cfg.Rate.Rate = rate
}
if win, err := strconv.ParseFloat(c.PostForm("rateWindowMin"), 64); err == nil {
cfg.Rate.Window = time.Duration(win * float64(time.Minute))
}
cfg.Cors = c.PostForm("cors") == "on"
cfg.CorsAdvanced = c.PostForm("corsAdvanced") == "on"
cfg.SystemWhitelist = filterEmpty(c.PostFormArray("systemWhitelist[]"))
cfg.SystemWhitelistDNS = filterEmpty(c.PostFormArray("systemWhitelistDNS[]"))
cfg.RateWhitelist = filterEmpty(c.PostFormArray("rateWhitelist[]"))
cfg.CorsAllowOrigins = filterEmpty(c.PostFormArray("corsAllowOrigins[]"))
cfg.CorsAllowMethods = filterEmpty(c.PostFormArray("corsAllowMethods[]"))
cfg.CorsAllowHeaders = filterEmpty(c.PostFormArray("corsAllowHeaders[]"))
cfg.DNSResolver = c.PostForm("dnsResolver")
}
// ─── Endpoints ───────────────────────────────────────────────────────────────
func showEndpoints(c *gin.Context) {
svcs := global.GetServices()
renderPage(c, "endpoints", gin.H{"Services": svcs.Services})
}
func htmxEndpointsTable(c *gin.Context) {
svcs := global.GetServices()
renderPartial(c, "endpoints_table", gin.H{"Services": svcs.Services}, "endpoints.html")
}
func htmxEndpointEdit(c *gin.Context) {
svcUUID := c.Param("svc")
epUUID := c.Param("ep")
svcs := global.GetServices()
for _, svc := range svcs.Services {
if svc.BasicEndpoint.UUID == epUUID {
renderPartial(c, "endpoint_edit", gin.H{
"Endpoint": svc.BasicEndpoint,
"ServiceUUID": svcUUID,
"IsBasic": true,
}, "components.html", "endpoint_edit.html")
return
}
for _, ep := range svc.Endpoints {
if ep.UUID == epUUID {
renderPartial(c, "endpoint_edit", gin.H{
"Endpoint": ep,
"ServiceUUID": svcUUID,
"IsBasic": false,
}, "components.html", "endpoint_edit.html")
return
}
}
}
c.String(http.StatusNotFound, "Endpoint not found")
}
func htmxEndpointSave(c *gin.Context) {
svcUUID := c.PostForm("ServiceUUID")
epUUID := c.PostForm("UUID")
isBasic := c.PostForm("IsBasic") == "true"
ep := pservice.Endpoint{
UUID: epUUID,
Name: c.PostForm("Name"),
Endpoint: c.PostForm("Endpoint"),
Active: c.PostForm("Active") == "on",
VerifySSL: c.PostForm("VerifySSL") == "on",
CertAuth: c.PostForm("CertAuth") == "on",
WebSocket: c.PostForm("WebSocket") == "on",
JWTPreCheck: c.PostForm("JWTPreCheck") == "on",
OverrideTimeout: parseIntForm(c.PostForm("OverrideTimeout")),
Certs: pservice.Certs{
CertPEM: c.PostForm("CertPEM"),
CertKEY: c.PostForm("CertKEY"),
},
JWTData: pservice.JWTPreCheck{
Header: c.PostForm("JWTHeader"),
Key: c.PostForm("JWTKey"),
OnlySign: c.PostForm("JWTOnlySign") == "on",
Field: c.PostForm("JWTField"),
},
}
ep.HeaderRouteMatches = parseHeaderPairs(c, "HRM_Header", "HRM_Value")
ep.HeaderExists = parseHeaderPairs(c, "HEX_Header", "HEX_Value")
ep.HeaderAdd = parseHeaderPairs(c, "HA_Header", "HA_Value")
ep.HeaderReplace = parseHeaderReplace(c)
ep.JWTData.Match = filterEmpty(c.PostFormArray("JWTMatch[]"))
ep.Whitelist = filterEmpty(c.PostFormArray("Whitelist[]"))
svcs := global.GetServices()
updated := false
for i, svc := range svcs.Services {
if svc.UUID == svcUUID {
if isBasic && svc.BasicEndpoint.UUID == epUUID {
svcs.Services[i].BasicEndpoint = ep
updated = true
break
}
for j, e := range svc.Endpoints {
if e.UUID == epUUID {
svcs.Services[i].Endpoints[j] = ep
updated = true
break
}
}
}
if updated {
break
}
}
if !updated {
c.String(http.StatusNotFound, "Endpoint not found")
return
}
global.SetSrvConfig(svcs)
global.SaveServiceConfig()
c.Header("HX-Trigger", "refreshEndpoints")
c.String(http.StatusOK, `Saved
`)
}
func htmxCancelEdit(c *gin.Context) {
c.String(http.StatusOK, "")
}
func htmxDeleteService(c *gin.Context) {
svcUUID := c.Param("uuid")
svcs := global.GetServices()
filtered := svcs.Services[:0]
for _, s := range svcs.Services {
if s.UUID != svcUUID {
filtered = append(filtered, s)
}
}
svcs.Services = filtered
global.SetSrvConfig(svcs)
global.SaveServiceConfig()
c.Header("HX-Trigger", "refreshEndpoints")
c.String(http.StatusOK, "")
}
func htmxDeleteEndpoint(c *gin.Context) {
svcUUID := c.Param("svc")
epUUID := c.Param("ep")
svcs := global.GetServices()
for i, svc := range svcs.Services {
if svc.UUID == svcUUID {
filtered := svc.Endpoints[:0]
for _, ep := range svc.Endpoints {
if ep.UUID != epUUID {
filtered = append(filtered, ep)
}
}
svcs.Services[i].Endpoints = filtered
break
}
}
global.SetSrvConfig(svcs)
global.SaveServiceConfig()
c.Header("HX-Trigger", "refreshEndpoints")
c.String(http.StatusOK, "")
}
func htmxAddService(c *gin.Context) {
name := c.PostForm("name")
if name == "" {
c.String(http.StatusBadRequest, "Name required")
return
}
newSvc := pservice.Service{
UUID: uuid.New().String(),
Name: name,
Active: false,
BasicEndpoint: pservice.Endpoint{
UUID: uuid.New().String(),
Name: "basic",
Endpoint: "https://",
Active: false,
VerifySSL: true,
HeaderRouteMatches: []pservice.Header{},
HeaderExists: []pservice.Header{},
HeaderAdd: []pservice.Header{},
HeaderReplace: []pservice.HeaderReplace{},
Whitelist: []string{},
},
Endpoints: []pservice.Endpoint{},
}
svcs := global.GetServices()
svcs.Services = append(svcs.Services, newSvc)
global.SetSrvConfig(svcs)
global.SaveServiceConfig()
c.Header("HX-Trigger", "refreshEndpoints")
c.String(http.StatusOK, "")
}
func htmxAddEndpoint(c *gin.Context) {
svcUUID := c.Param("svc")
name := c.PostForm("name")
if name == "" {
name = "sub"
}
newEp := pservice.Endpoint{
UUID: uuid.New().String(),
Name: name,
Endpoint: "https://",
Active: false,
VerifySSL: true,
HeaderRouteMatches: []pservice.Header{{Header: "system", Value: "dev"}},
HeaderExists: []pservice.Header{},
HeaderAdd: []pservice.Header{},
HeaderReplace: []pservice.HeaderReplace{},
Whitelist: []string{},
}
svcs := global.GetServices()
for i, svc := range svcs.Services {
if svc.UUID == svcUUID {
svcs.Services[i].Endpoints = append(svcs.Services[i].Endpoints, newEp)
break
}
}
global.SetSrvConfig(svcs)
global.SaveServiceConfig()
c.Header("HX-Trigger", "refreshEndpoints")
c.String(http.StatusOK, "")
}
func htmxWAFEdit(c *gin.Context) {
svcUUID := c.Param("svc")
svcs := global.GetServices()
for _, svc := range svcs.Services {
if svc.UUID == svcUUID {
renderPartial(c, "waf_edit", gin.H{
"WAF": svc.WAF,
"ServiceUUID": svcUUID,
"ServiceName": svc.Name,
}, "components.html", "waf_edit.html")
return
}
}
c.String(http.StatusNotFound, "Service not found")
}
func htmxWAFSave(c *gin.Context) {
svcUUID := c.Param("svc")
wafCfg := waf.WAFConfig{
Enabled: c.PostForm("Enabled") == "on",
BlockSQLi: c.PostForm("BlockSQLi") == "on",
BlockXSS: c.PostForm("BlockXSS") == "on",
BlockPathTraversal: c.PostForm("BlockPathTraversal") == "on",
BlockCommandInj: c.PostForm("BlockCommandInj") == "on",
BlockLargeBody: c.PostForm("BlockLargeBody") == "on",
MaxBodyKB: parseIntForm(c.PostForm("MaxBodyKB")),
}
svcs := global.GetServices()
for i, svc := range svcs.Services {
if svc.UUID == svcUUID {
svcs.Services[i].WAF = wafCfg
break
}
}
global.SetSrvConfig(svcs)
global.SaveServiceConfig()
c.Header("HX-Trigger", "refreshEndpoints")
c.String(http.StatusOK, `WAF Konfiguration gespeichert
`)
}
func reloadConfig(c *gin.Context) {
global.LoadAllConfig()
c.String(http.StatusOK, `Gateway reloaded
`)
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
func parseIntForm(s string) int {
v, _ := strconv.Atoi(s)
return v
}
func parseJSONField[T any](s string, dest *T) {
if s == "" {
return
}
_ = json.Unmarshal([]byte(s), dest)
}
func parseHeaderPairs(c *gin.Context, headerKey, valueKey string) []pservice.Header {
headers := c.PostFormArray(headerKey + "[]")
values := c.PostFormArray(valueKey + "[]")
var result []pservice.Header
for i, h := range headers {
v := ""
if i < len(values) {
v = values[i]
}
if h != "" {
result = append(result, pservice.Header{Header: h, Value: v})
}
}
return result
}
func parseHeaderReplace(c *gin.Context) []pservice.HeaderReplace {
headers := c.PostFormArray("HR_Header[]")
values := c.PostFormArray("HR_Value[]")
newValues := c.PostFormArray("HR_NewValue[]")
var result []pservice.HeaderReplace
for i, h := range headers {
if h == "" {
continue
}
v, nv := "", ""
if i < len(values) {
v = values[i]
}
if i < len(newValues) {
nv = newValues[i]
}
result = append(result, pservice.HeaderReplace{Header: h, Value: v, NewValue: nv})
}
return result
}
func filterEmpty(in []string) []string {
var out []string
for _, s := range in {
if s != "" {
out = append(out, s)
}
}
return out
}
func templateDict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict requires an even number of arguments")
}
m := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict keys must be strings")
}
m[key] = values[i+1]
}
return m, nil
}
func toJSON(v any) string {
b, _ := json.MarshalIndent(v, "", " ")
return string(b)
}
func fmtTime(t time.Time) string {
return t.Format("02.01.2006 15:04:05")
}
func fmtMs(f float32) string {
return strconv.FormatFloat(float64(f), 'f', 1, 64)
}
func rateWindowMin(d time.Duration) int {
m := int(d.Minutes())
if m < 1 {
return 60
}
return m
}