Files
ServerStatus/server/http_server.go
T
2026-07-10 15:35:10 +08:00

398 lines
13 KiB
Go

package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
var (
version = "2.0.0"
commit = "none"
buildTime = "unknown"
)
func (a *App) HTTPServer() *http.Server {
if !a.opts.Verbose {
gin.SetMode(gin.ReleaseMode)
}
return &http.Server{
Addr: a.opts.HTTPAddr,
Handler: a.router(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
}
func (a *App) router() *gin.Engine {
router := gin.New()
router.Use(gin.Recovery(), a.securityHeaders(), a.corsMiddleware())
if a.opts.Verbose {
router.Use(gin.Logger())
}
router.GET("/api/health", a.healthHandler)
router.GET("/api/schema", a.schemaHandler)
router.GET("/api/openapi.json", func(c *gin.Context) {
c.JSON(http.StatusOK, openAPISpec())
})
router.GET("/json/stats.json", func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, a.SnapshotStats())
})
api := router.Group("/api", a.authMiddleware())
api.GET("/config", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "config": a.ConfigSnapshot()})
})
api.PUT("/config", func(c *gin.Context) {
body, apiErr := decodeRequestObject(c)
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
doc, apiErr := a.ReplaceConfig(ConfigDocument(body))
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "reloaded": true, "pid": os.Getpid(), "generation": a.generation.Load(), "config": doc})
})
for _, collection := range []string{"servers", "monitors", "sslcerts", "watchdog"} {
key := collection
api.GET("/"+key, func(c *gin.Context) { a.getCollectionHandler(c, key) })
api.POST("/"+key, func(c *gin.Context) { a.createCollectionHandler(c, key) })
api.PUT("/"+key+"/:id", func(c *gin.Context) { a.updateCollectionHandler(c, key) })
api.DELETE("/"+key+"/:id", func(c *gin.Context) { a.deleteCollectionHandler(c, key) })
}
api.POST("/servers/:id/reset-traffic", a.resetTrafficHandler)
api.POST("/reload", func(c *gin.Context) {
if apiErr := a.ReloadConfig(); apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "operation": "reload", "pid": os.Getpid(), "generation": a.generation.Load()})
})
api.POST("/restart", func(c *gin.Context) {
if apiErr := a.ReloadConfig(); apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
c.JSON(http.StatusAccepted, gin.H{"ok": true, "operation": "restart", "mode": "in-process", "pid": os.Getpid(), "generation": a.generation.Load()})
})
router.NoRoute(a.staticHandler)
return router
}
func (a *App) securityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "SAMEORIGIN")
c.Header("Referrer-Policy", "same-origin")
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.Header("Cache-Control", "no-store")
}
c.Next()
}
}
func (a *App) corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if a.opts.CORSOrigin != "" {
c.Header("Access-Control-Allow-Origin", a.opts.CORSOrigin)
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Admin-Token")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Vary", "Origin")
}
if c.Request.Method == http.MethodOptions && strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
func (a *App) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if a.opts.AdminToken == "" {
a.writeAPIError(c, &APIError{Status: 503, Message: "management API is disabled; set ADMIN_TOKEN to enable it"})
c.Abort()
return
}
token := ""
authorization := c.GetHeader("Authorization")
if len(authorization) >= 7 && strings.EqualFold(authorization[:7], "bearer ") {
token = strings.TrimSpace(authorization[7:])
}
if token == "" {
token = strings.TrimSpace(c.GetHeader("X-Admin-Token"))
}
if len(token) != len(a.opts.AdminToken) || subtle.ConstantTimeCompare([]byte(token), []byte(a.opts.AdminToken)) != 1 {
a.writeAPIError(c, &APIError{Status: 401, Message: "invalid or missing admin token"})
c.Abort()
return
}
c.Next()
}
}
func (a *App) healthHandler(c *gin.Context) {
running := a.agentRunning.Load()
c.JSON(http.StatusOK, gin.H{
"ok": true, "enabled": a.opts.AdminToken != "",
"service": gin.H{"running": true, "pid": os.Getpid(), "version": version, "uptime": int64(time.Since(a.startedAt).Seconds()), "generation": a.generation.Load()},
"agent": gin.H{"running": running, "address": a.opts.AgentAddr},
"configPath": a.opts.ConfigPath,
})
}
func (a *App) schemaHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "schema": apiSchema(a.opts.AdminToken != "")})
}
func apiSchema(enabled bool) map[string]any {
endpoints := []any{
map[string]any{"method": "GET", "path": "/api/health", "auth": false},
map[string]any{"method": "GET", "path": "/api/schema", "auth": false},
map[string]any{"method": "GET", "path": "/api/openapi.json", "auth": false},
map[string]any{"method": "GET", "path": "/api/config", "auth": true},
map[string]any{"method": "PUT", "path": "/api/config", "auth": true, "body": "full config JSON"},
}
for _, key := range []string{"servers", "monitors", "sslcerts", "watchdog"} {
spec := collectionSpecs[key]
endpoints = append(endpoints,
map[string]any{"method": "GET", "path": "/api/" + key, "auth": true},
map[string]any{"method": "POST", "path": "/api/" + key, "auth": true, "body": spec.itemName + " JSON"},
map[string]any{"method": "PUT", "path": "/api/" + key + "/{id}", "auth": true, "body": spec.itemName + " JSON"},
map[string]any{"method": "DELETE", "path": "/api/" + key + "/{id}", "auth": true},
)
}
endpoints = append(endpoints,
map[string]any{"method": "POST", "path": "/api/servers/{username}/reset-traffic", "auth": true},
map[string]any{"method": "POST", "path": "/api/reload", "auth": true},
map[string]any{"method": "POST", "path": "/api/restart", "auth": true},
)
collections := make(map[string]any)
for key, spec := range collectionSpecs {
collections[key] = map[string]any{"item": spec.itemName, "idField": spec.idField, "required": spec.required, "optional": spec.optional}
}
return map[string]any{
"version": version,
"auth": map[string]any{"type": "bearer", "header": "Authorization: Bearer <ADMIN_TOKEN>", "enabled": enabled},
"endpoints": endpoints, "collections": collections,
}
}
func decodeRequestObject(c *gin.Context) (map[string]any, *APIError) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBody)
decoder := json.NewDecoder(c.Request.Body)
decoder.UseNumber()
var object map[string]any
if err := decoder.Decode(&object); err != nil {
status := http.StatusBadRequest
if strings.Contains(err.Error(), "request body too large") {
status = http.StatusRequestEntityTooLarge
}
return nil, &APIError{Status: status, Message: "invalid JSON body", Details: map[string]any{"error": err.Error()}}
}
if object == nil {
return nil, &APIError{Status: 400, Message: "request body must be an object"}
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, &APIError{Status: 400, Message: "request body must contain one JSON value"}
}
return object, nil
}
func (a *App) getCollectionHandler(c *gin.Context, key string) {
doc := a.ConfigSnapshot()
items, _ := doc[key].([]any)
c.JSON(http.StatusOK, gin.H{"ok": true, key: items})
}
func (a *App) createCollectionHandler(c *gin.Context, key string) {
item, apiErr := decodeRequestObject(c)
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
doc, apiErr := a.MutateConfig(func(doc ConfigDocument) *APIError {
items, _ := doc[key].([]any)
doc[key] = append(items, item)
return nil
})
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
items, _ := doc[key].([]any)
created := items[len(items)-1]
c.JSON(http.StatusCreated, gin.H{"ok": true, collectionSpecs[key].itemName: created, "reloaded": true, "pid": os.Getpid(), "config": doc})
}
func (a *App) updateCollectionHandler(c *gin.Context, key string) {
item, apiErr := decodeRequestObject(c)
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
id := c.Param("id")
if id == "" {
a.writeAPIError(c, &APIError{Status: 400, Message: "item id is required"})
return
}
var updated any
doc, apiErr := a.MutateConfig(func(doc ConfigDocument) *APIError {
items, _ := doc[key].([]any)
index, _, findErr := findCollectionItem(items, collectionSpecs[key].idField, id, key != "servers")
if findErr != nil {
return findErr
}
if index < 0 {
return &APIError{Status: 404, Message: collectionSpecs[key].itemName + " was not found", Details: map[string]any{"id": id}}
}
items[index] = item
doc[key] = items
updated = item
return nil
})
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
// Return the normalized item from the resulting document.
items, _ := doc[key].([]any)
if itemMap, ok := updated.(map[string]any); ok {
idValue := fmt.Sprint(itemMap[collectionSpecs[key].idField])
if index, normalized, _ := findCollectionItem(items, collectionSpecs[key].idField, idValue, key != "servers"); index >= 0 {
updated = normalized
}
}
c.JSON(http.StatusOK, gin.H{"ok": true, collectionSpecs[key].itemName: updated, "reloaded": true, "pid": os.Getpid(), "config": doc})
}
func (a *App) deleteCollectionHandler(c *gin.Context, key string) {
id := c.Param("id")
if id == "" {
a.writeAPIError(c, &APIError{Status: 400, Message: "item id is required"})
return
}
var removed any
doc, apiErr := a.MutateConfig(func(doc ConfigDocument) *APIError {
items, _ := doc[key].([]any)
index, item, findErr := findCollectionItem(items, collectionSpecs[key].idField, id, key != "servers")
if findErr != nil {
return findErr
}
if index < 0 {
return &APIError{Status: 404, Message: collectionSpecs[key].itemName + " was not found", Details: map[string]any{"id": id}}
}
removed = item
doc[key] = append(items[:index], items[index+1:]...)
return nil
})
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "removed": removed, "reloaded": true, "pid": os.Getpid(), "config": doc})
}
func findCollectionItem(items []any, idField, id string, allowIndex bool) (int, any, *APIError) {
if numeric, err := strconv.Atoi(id); allowIndex && err == nil {
if numeric >= 0 && numeric < len(items) {
return numeric, items[numeric], nil
}
return -1, nil, nil
}
matches := make([]int, 0, 1)
for index, raw := range items {
item, ok := raw.(map[string]any)
if ok && fmt.Sprint(item[idField]) == id {
matches = append(matches, index)
}
}
if len(matches) > 1 {
return -1, nil, &APIError{Status: 409, Message: "collection has duplicate " + idField + "; use numeric index instead", Details: map[string]any{"id": id}}
}
if len(matches) == 1 {
return matches[0], items[matches[0]], nil
}
return -1, nil, nil
}
func (a *App) resetTrafficHandler(c *gin.Context) {
username := c.Param("id")
if username == "" {
a.writeAPIError(c, &APIError{Status: 400, Message: "username is required"})
return
}
result, apiErr := a.ResetTraffic(username)
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
result["ok"] = true
result["operation"] = "reset-traffic"
result["pid"] = os.Getpid()
c.JSON(http.StatusOK, result)
}
func (a *App) writeAPIError(c *gin.Context, apiErr *APIError) {
payload := gin.H{"ok": false, "error": apiErr.Message}
if apiErr.Details != nil {
payload["details"] = apiErr.Details
}
c.JSON(apiErr.Status, payload)
}
func (a *App) staticHandler(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") || strings.HasPrefix(c.Request.URL.Path, "/json/") {
c.JSON(http.StatusNotFound, gin.H{"ok": false, "error": "endpoint was not found"})
return
}
requestPath := filepath.Clean("/" + c.Request.URL.Path)
if requestPath == "/" {
requestPath = "/index.html"
}
root, err := filepath.Abs(a.opts.WebDir)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
filePath := filepath.Join(root, strings.TrimPrefix(requestPath, "/"))
if filePath != root && !strings.HasPrefix(filePath, root+string(os.PathSeparator)) {
c.Status(http.StatusNotFound)
return
}
info, err := os.Stat(filePath)
if err != nil || info.IsDir() {
c.Status(http.StatusNotFound)
return
}
if contentType := mime.TypeByExtension(filepath.Ext(filePath)); contentType != "" {
c.Header("Content-Type", contentType)
}
if filepath.Base(filePath) == "index.html" {
c.Header("Cache-Control", "no-cache")
}
http.ServeFile(c.Writer, c.Request, filePath)
}