Files
2026-07-10 15:35:10 +08:00

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
}