mirror of
https://github.com//cppla/ServerStatus
synced 2026-08-06 09:13:57 +08:00
368 lines
10 KiB
Go
368 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/expr-lang/expr/vm"
|
|
)
|
|
|
|
const maxRequestBody = 1 << 20
|
|
|
|
type ConfigDocument map[string]any
|
|
|
|
type ServerConfig struct {
|
|
Username string `json:"username"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Host string `json:"host"`
|
|
Location string `json:"location"`
|
|
Password string `json:"password"`
|
|
MonthStart int `json:"monthstart"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
type MonitorConfig struct {
|
|
Name string `json:"name"`
|
|
Host string `json:"host"`
|
|
Interval int `json:"interval"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type SSLCertConfig struct {
|
|
Name string `json:"name"`
|
|
Domain string `json:"domain"`
|
|
Port int `json:"port"`
|
|
Interval int `json:"interval"`
|
|
Callback string `json:"callback"`
|
|
}
|
|
|
|
type WatchdogConfig struct {
|
|
Name string `json:"name"`
|
|
Rule string `json:"rule"`
|
|
Interval int `json:"interval"`
|
|
Callback string `json:"callback"`
|
|
}
|
|
|
|
type CompiledWatchdog struct {
|
|
WatchdogConfig
|
|
Key string
|
|
Normalized string
|
|
Program *vm.Program
|
|
}
|
|
|
|
type RuntimeConfig struct {
|
|
Servers []ServerConfig
|
|
Monitors []MonitorConfig
|
|
SSLCerts []SSLCertConfig
|
|
Watchdogs []CompiledWatchdog
|
|
}
|
|
|
|
type AgentStats struct {
|
|
Uptime int64 `json:"uptime"`
|
|
Load1 float64 `json:"load_1"`
|
|
Load5 float64 `json:"load_5"`
|
|
Load15 float64 `json:"load_15"`
|
|
Ping10010 float64 `json:"ping_10010"`
|
|
Ping189 float64 `json:"ping_189"`
|
|
Ping10086 float64 `json:"ping_10086"`
|
|
Time10010 int64 `json:"time_10010"`
|
|
Time189 int64 `json:"time_189"`
|
|
Time10086 int64 `json:"time_10086"`
|
|
TCPCount int64 `json:"tcp"`
|
|
UDPCount int64 `json:"udp"`
|
|
ProcessCount int64 `json:"process"`
|
|
ThreadCount int64 `json:"thread"`
|
|
NetworkRX int64 `json:"network_rx"`
|
|
NetworkTX int64 `json:"network_tx"`
|
|
NetworkIn int64 `json:"network_in"`
|
|
NetworkOut int64 `json:"network_out"`
|
|
MemoryTotal int64 `json:"memory_total"`
|
|
MemoryUsed int64 `json:"memory_used"`
|
|
SwapTotal int64 `json:"swap_total"`
|
|
SwapUsed int64 `json:"swap_used"`
|
|
HDDTotal int64 `json:"hdd_total"`
|
|
HDDUsed int64 `json:"hdd_used"`
|
|
IORead int64 `json:"io_read"`
|
|
IOWrite int64 `json:"io_write"`
|
|
CPU float64 `json:"cpu"`
|
|
CPUCores int64 `json:"cpu_cores"`
|
|
CPUModel string `json:"cpu_model"`
|
|
Custom string `json:"custom"`
|
|
OS string `json:"os"`
|
|
Online4 *bool `json:"online4"`
|
|
Online6 *bool `json:"online6"`
|
|
}
|
|
|
|
type APIError struct {
|
|
Status int
|
|
Message string
|
|
Details any
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
if e.Details != nil {
|
|
return fmt.Sprintf("%s: %v", e.Message, e.Details)
|
|
}
|
|
return e.Message
|
|
}
|
|
|
|
func decodeDocument(data []byte) (ConfigDocument, error) {
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.UseNumber()
|
|
var doc ConfigDocument
|
|
if err := decoder.Decode(&doc); err != nil {
|
|
return nil, err
|
|
}
|
|
if doc == nil {
|
|
return nil, fmt.Errorf("config must be a JSON object")
|
|
}
|
|
var extra any
|
|
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
|
return nil, fmt.Errorf("config contains more than one JSON value")
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
func cloneDocument(doc ConfigDocument) (ConfigDocument, error) {
|
|
data, err := json.Marshal(doc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return decodeDocument(data)
|
|
}
|
|
|
|
type collectionSpec struct {
|
|
itemName string
|
|
idField string
|
|
required []string
|
|
optional []string
|
|
defaults map[string]int
|
|
}
|
|
|
|
var collectionSpecs = map[string]collectionSpec{
|
|
"servers": {
|
|
itemName: "server", idField: "username",
|
|
required: []string{"username", "name", "type", "host", "location", "password"},
|
|
optional: []string{"monthstart", "disabled"},
|
|
defaults: map[string]int{"monthstart": 1},
|
|
},
|
|
"monitors": {
|
|
itemName: "monitor", idField: "name",
|
|
required: []string{"name", "host", "type"},
|
|
optional: []string{"interval"},
|
|
defaults: map[string]int{"interval": 600},
|
|
},
|
|
"sslcerts": {
|
|
itemName: "sslcert", idField: "name",
|
|
required: []string{"name", "domain"},
|
|
optional: []string{"port", "interval", "callback"},
|
|
defaults: map[string]int{"port": 443, "interval": 7200},
|
|
},
|
|
"watchdog": {
|
|
itemName: "watchdog", idField: "name",
|
|
required: []string{"name", "rule"},
|
|
optional: []string{"interval", "callback"},
|
|
defaults: map[string]int{"interval": 600},
|
|
},
|
|
}
|
|
|
|
func normalizeConfig(input ConfigDocument) (ConfigDocument, RuntimeConfig, *APIError) {
|
|
doc, err := cloneDocument(input)
|
|
if err != nil {
|
|
return nil, RuntimeConfig{}, &APIError{Status: 400, Message: "config must be a JSON object", Details: map[string]any{"error": err.Error()}}
|
|
}
|
|
|
|
for _, key := range []string{"servers", "monitors", "sslcerts", "watchdog"} {
|
|
spec := collectionSpecs[key]
|
|
raw, exists := doc[key]
|
|
if !exists || raw == nil {
|
|
raw = []any{}
|
|
}
|
|
items, ok := raw.([]any)
|
|
if !ok {
|
|
return nil, RuntimeConfig{}, &APIError{Status: 400, Message: key + " must be an array"}
|
|
}
|
|
normalized := make([]any, 0, len(items))
|
|
seen := make(map[string]struct{})
|
|
for index, rawItem := range items {
|
|
item, ok := rawItem.(map[string]any)
|
|
if !ok {
|
|
return nil, RuntimeConfig{}, &APIError{Status: 400, Message: spec.itemName + " must be an object", Details: map[string]any{"index": index}}
|
|
}
|
|
missing := make([]string, 0)
|
|
for _, field := range spec.required {
|
|
value, ok := item[field]
|
|
text := ""
|
|
if ok && value != nil {
|
|
text = strings.TrimSpace(fmt.Sprint(value))
|
|
}
|
|
if text == "" {
|
|
missing = append(missing, field)
|
|
} else {
|
|
item[field] = text
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
return nil, RuntimeConfig{}, &APIError{Status: 400, Message: spec.itemName + " has missing required fields", Details: map[string]any{"missing": missing, "index": index}}
|
|
}
|
|
|
|
for field, fallback := range spec.defaults {
|
|
value, apiErr := normalizeInteger(item[field], field, index, fallback)
|
|
if apiErr != nil {
|
|
return nil, RuntimeConfig{}, apiErr
|
|
}
|
|
switch {
|
|
case key == "servers" && field == "monthstart":
|
|
value = clamp(value, 1, 28)
|
|
case key == "sslcerts" && field == "port":
|
|
value = clamp(value, 1, 65535)
|
|
default:
|
|
if value < 1 {
|
|
value = 1
|
|
}
|
|
}
|
|
item[field] = value
|
|
}
|
|
|
|
if key == "servers" {
|
|
if value, exists := item["disabled"]; exists {
|
|
disabled, ok := normalizeBool(value)
|
|
if !ok {
|
|
return nil, RuntimeConfig{}, &APIError{Status: 400, Message: "disabled must be a boolean", Details: map[string]any{"index": index}}
|
|
}
|
|
item["disabled"] = disabled
|
|
}
|
|
username := item["username"].(string)
|
|
if _, duplicate := seen[username]; duplicate {
|
|
return nil, RuntimeConfig{}, &APIError{Status: 409, Message: "duplicate server username", Details: map[string]any{"username": username}}
|
|
}
|
|
seen[username] = struct{}{}
|
|
}
|
|
if key == "sslcerts" || key == "watchdog" {
|
|
value := item["callback"]
|
|
if value == nil {
|
|
item["callback"] = ""
|
|
} else {
|
|
item["callback"] = strings.TrimSpace(fmt.Sprint(value))
|
|
}
|
|
}
|
|
normalized = append(normalized, item)
|
|
}
|
|
doc[key] = normalized
|
|
}
|
|
|
|
runtime, apiErr := buildRuntimeConfig(doc)
|
|
if apiErr != nil {
|
|
return nil, RuntimeConfig{}, apiErr
|
|
}
|
|
return doc, runtime, nil
|
|
}
|
|
|
|
func buildRuntimeConfig(doc ConfigDocument) (RuntimeConfig, *APIError) {
|
|
data, err := json.Marshal(doc)
|
|
if err != nil {
|
|
return RuntimeConfig{}, &APIError{Status: 400, Message: "config could not be encoded", Details: map[string]any{"error": err.Error()}}
|
|
}
|
|
var raw struct {
|
|
Servers []ServerConfig `json:"servers"`
|
|
Monitors []MonitorConfig `json:"monitors"`
|
|
SSLCerts []SSLCertConfig `json:"sslcerts"`
|
|
Watchdogs []WatchdogConfig `json:"watchdog"`
|
|
}
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return RuntimeConfig{}, &APIError{Status: 400, Message: "config has invalid field types", Details: map[string]any{"error": err.Error()}}
|
|
}
|
|
runtime := RuntimeConfig{Servers: raw.Servers, Monitors: raw.Monitors, SSLCerts: raw.SSLCerts}
|
|
for index, rule := range raw.Watchdogs {
|
|
compiled, err := compileWatchdog(rule, index)
|
|
if err != nil {
|
|
return RuntimeConfig{}, &APIError{Status: 400, Message: "watchdog rule is invalid", Details: map[string]any{"index": index, "name": rule.Name, "error": err.Error()}}
|
|
}
|
|
runtime.Watchdogs = append(runtime.Watchdogs, compiled)
|
|
}
|
|
return runtime, nil
|
|
}
|
|
|
|
func normalizeInteger(raw any, field string, index, fallback int) (int, *APIError) {
|
|
if raw == nil || raw == "" {
|
|
return fallback, nil
|
|
}
|
|
var value int64
|
|
var err error
|
|
switch v := raw.(type) {
|
|
case json.Number:
|
|
value, err = strconv.ParseInt(v.String(), 10, 64)
|
|
case float64:
|
|
if math.Trunc(v) != v {
|
|
err = fmt.Errorf("not an integer")
|
|
} else {
|
|
value = int64(v)
|
|
}
|
|
case float32:
|
|
if math.Trunc(float64(v)) != float64(v) {
|
|
err = fmt.Errorf("not an integer")
|
|
} else {
|
|
value = int64(v)
|
|
}
|
|
case int:
|
|
value = int64(v)
|
|
case int64:
|
|
value = v
|
|
case string:
|
|
value, err = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
|
default:
|
|
err = fmt.Errorf("unsupported value")
|
|
}
|
|
if err != nil || value > math.MaxInt || value < math.MinInt {
|
|
return 0, &APIError{Status: 400, Message: field + " must be an integer", Details: map[string]any{"index": index}}
|
|
}
|
|
return int(value), nil
|
|
}
|
|
|
|
func normalizeBool(raw any) (bool, bool) {
|
|
switch value := raw.(type) {
|
|
case bool:
|
|
return value, true
|
|
case string:
|
|
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
|
return parsed, err == nil
|
|
case json.Number:
|
|
if value.String() == "0" {
|
|
return false, true
|
|
}
|
|
if value.String() == "1" {
|
|
return true, true
|
|
}
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func clamp(value, minimum, maximum int) int {
|
|
if value < minimum {
|
|
return minimum
|
|
}
|
|
if value > maximum {
|
|
return maximum
|
|
}
|
|
return value
|
|
}
|
|
|
|
func secondsDuration(value int) time.Duration {
|
|
if value < 1 {
|
|
value = 1
|
|
}
|
|
maxDuration := time.Duration(1<<63 - 1)
|
|
if int64(value) > int64(maxDuration/time.Second) {
|
|
return maxDuration
|
|
}
|
|
return time.Duration(value) * time.Second
|
|
}
|