mirror of
https://github.com//cppla/ServerStatus
synced 2026-08-06 10:13:57 +08:00
1.2.0
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user