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

473 lines
13 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"math"
"net"
"os"
"strconv"
"sync"
"sync/atomic"
"time"
)
type Options struct {
ConfigPath string
StatsPath string
WebDir string
HTTPAddr string
AgentAddr string
AdminToken string
CORSOrigin string
InsecureCallbackTLS bool
Verbose bool
}
type NodeState struct {
Config ServerConfig
Connected bool
Connection net.Conn
ConnectionID uint64
Family int
Online4 bool
Online6 bool
Stats AgentStats
HasUpdate bool
LastNetworkIn int64
LastNetworkOut int64
LastUpdate time.Time
AlarmLast map[string]time.Time
Pong bool
}
type App struct {
opts Options
startedAt time.Time
ctx context.Context
cancel context.CancelFunc
mutationMu sync.Mutex
configMu sync.RWMutex
document ConfigDocument
runtime RuntimeConfig
nodeMu sync.RWMutex
nodes map[string]*NodeState
connectionID atomic.Uint64
generation atomic.Uint64
agentRunning atomic.Bool
reloadWrites atomic.Int32
certMu sync.RWMutex
certs map[string]*CertState
statsWake chan struct{}
persistMu sync.Mutex
logger *log.Logger
}
func NewApp(opts Options) (*App, error) {
doc, runtime, err := readConfig(opts.ConfigPath)
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
app := &App{
opts: opts,
startedAt: time.Now(),
ctx: ctx,
cancel: cancel,
nodes: make(map[string]*NodeState),
certs: make(map[string]*CertState),
statsWake: make(chan struct{}, 1),
logger: log.New(os.Stdout, "serverstatus ", log.LstdFlags|log.Lmicroseconds),
}
app.applyValidatedConfig(doc, runtime, false)
app.restorePersistentState()
return app, nil
}
func (a *App) StartBackground() {
go a.statsLoop()
go a.sslLoop()
a.wakeStatsWriter()
}
func (a *App) Close() {
a.cancel()
a.disconnectAll("Server shutting down...")
_ = a.PersistStats()
}
func (a *App) ConfigSnapshot() ConfigDocument {
a.configMu.RLock()
defer a.configMu.RUnlock()
clone, err := cloneDocument(a.document)
if err != nil {
panic(err)
}
return clone
}
func (a *App) RuntimeSnapshot() RuntimeConfig {
a.configMu.RLock()
defer a.configMu.RUnlock()
result := a.runtime
result.Servers = append([]ServerConfig(nil), a.runtime.Servers...)
result.Monitors = append([]MonitorConfig(nil), a.runtime.Monitors...)
result.SSLCerts = append([]SSLCertConfig(nil), a.runtime.SSLCerts...)
result.Watchdogs = append([]CompiledWatchdog(nil), a.runtime.Watchdogs...)
return result
}
func (a *App) ReplaceConfig(input ConfigDocument) (ConfigDocument, *APIError) {
a.mutationMu.Lock()
defer a.mutationMu.Unlock()
normalized, runtime, apiErr := normalizeConfig(input)
if apiErr != nil {
return nil, apiErr
}
if err := writeConfig(a.opts.ConfigPath, normalized); err != nil {
return nil, &APIError{Status: 500, Message: "config could not be written", Details: map[string]any{"error": err.Error()}}
}
a.applyValidatedConfig(normalized, runtime, true)
return a.ConfigSnapshot(), nil
}
func (a *App) MutateConfig(mutate func(ConfigDocument) *APIError) (ConfigDocument, *APIError) {
a.mutationMu.Lock()
defer a.mutationMu.Unlock()
doc := a.ConfigSnapshot()
if apiErr := mutate(doc); apiErr != nil {
return nil, apiErr
}
normalized, runtime, apiErr := normalizeConfig(doc)
if apiErr != nil {
return nil, apiErr
}
if err := writeConfig(a.opts.ConfigPath, normalized); err != nil {
return nil, &APIError{Status: 500, Message: "config could not be written", Details: map[string]any{"error": err.Error()}}
}
a.applyValidatedConfig(normalized, runtime, true)
return a.ConfigSnapshot(), nil
}
func (a *App) ReloadConfig() *APIError {
a.mutationMu.Lock()
defer a.mutationMu.Unlock()
doc, runtime, err := readConfig(a.opts.ConfigPath)
if err != nil {
if apiErr, ok := err.(*APIError); ok {
return apiErr
}
return &APIError{Status: 500, Message: "config could not be reloaded", Details: map[string]any{"error": err.Error()}}
}
a.applyValidatedConfig(doc, runtime, true)
return nil
}
func (a *App) applyValidatedConfig(doc ConfigDocument, runtime RuntimeConfig, disconnect bool) {
a.configMu.Lock()
a.nodeMu.Lock()
oldNodes := a.nodes
newNodes := make(map[string]*NodeState, len(runtime.Servers))
connections := make([]net.Conn, 0)
for _, server := range runtime.Servers {
node := &NodeState{Config: server, AlarmLast: make(map[string]time.Time)}
if old := oldNodes[server.Username]; old != nil && sameServerIdentity(old.Config, server) {
node.LastNetworkIn = old.LastNetworkIn
node.LastNetworkOut = old.LastNetworkOut
node.Stats = old.Stats
node.HasUpdate = old.HasUpdate
node.AlarmLast = old.AlarmLast
if !disconnect {
node.Connected = old.Connected
node.Connection = old.Connection
node.ConnectionID = old.ConnectionID
node.Family = old.Family
node.Online4 = old.Online4
node.Online6 = old.Online6
}
}
newNodes[server.Username] = node
}
if disconnect {
for _, node := range oldNodes {
if node.Connection != nil {
connections = append(connections, node.Connection)
}
}
}
a.document = doc
a.runtime = runtime
a.nodes = newNodes
a.generation.Add(1)
a.nodeMu.Unlock()
a.configMu.Unlock()
a.reconcileCerts(runtime.SSLCerts)
if disconnect {
for _, conn := range connections {
_, _ = conn.Write([]byte("Server reloading...\n"))
_ = conn.Close()
}
}
a.reloadWrites.Store(2)
a.wakeStatsWriter()
}
func sameServerIdentity(left, right ServerConfig) bool {
return left.Username == right.Username && left.Name == right.Name && left.Type == right.Type && left.Host == right.Host && left.Location == right.Location
}
func (a *App) disconnectAll(reason string) {
a.nodeMu.Lock()
connections := make([]net.Conn, 0)
for _, node := range a.nodes {
if node.Connection != nil {
connections = append(connections, node.Connection)
node.Connection = nil
node.Connected = false
node.Online4 = false
node.Online6 = false
}
}
a.nodeMu.Unlock()
for _, conn := range connections {
if reason != "" {
_, _ = conn.Write([]byte(reason + "\n"))
}
_ = conn.Close()
}
}
func (a *App) statsLoop() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
case <-a.statsWake:
}
if err := a.PersistStats(); err != nil {
a.logger.Printf("write stats: %v", err)
}
}
}
func (a *App) wakeStatsWriter() {
select {
case a.statsWake <- struct{}{}:
default:
}
}
func (a *App) SnapshotStats() map[string]any {
return a.snapshotStats(false)
}
func (a *App) snapshotStats(consumeReload bool) map[string]any {
runtime := a.RuntimeSnapshot()
now := time.Now()
servers := make([]any, 0, len(runtime.Servers))
a.nodeMu.Lock()
for _, server := range runtime.Servers {
if server.Disabled {
continue
}
node := a.nodes[server.Username]
if node == nil {
continue
}
base := map[string]any{
"name": server.Name, "type": server.Type, "host": server.Host, "location": server.Location,
"online4": false, "online6": false,
}
if node.Connected && node.HasUpdate {
s := node.Stats
updateTrafficBaselines(node, s.NetworkIn, s.NetworkOut, monthResetWindow(now, server.MonthStart))
base["online4"] = node.Online4
base["online6"] = node.Online6
base["uptime"] = formatUptime(s.Uptime)
base["load_1"], base["load_5"], base["load_15"] = round2(s.Load1), round2(s.Load5), round2(s.Load15)
base["ping_10010"], base["ping_189"], base["ping_10086"] = round2(s.Ping10010), round2(s.Ping189), round2(s.Ping10086)
base["time_10010"], base["time_189"], base["time_10086"] = s.Time10010, s.Time189, s.Time10086
base["tcp_count"], base["udp_count"] = s.TCPCount, s.UDPCount
base["process_count"], base["thread_count"] = s.ProcessCount, s.ThreadCount
base["network_rx"], base["network_tx"] = s.NetworkRX, s.NetworkTX
base["network_in"], base["network_out"] = s.NetworkIn, s.NetworkOut
base["cpu"], base["cpu_cores"], base["cpu_model"] = int(s.CPU), s.CPUCores, s.CPUModel
base["memory_total"], base["memory_used"] = s.MemoryTotal, s.MemoryUsed
base["swap_total"], base["swap_used"] = s.SwapTotal, s.SwapUsed
base["hdd_total"], base["hdd_used"] = s.HDDTotal, s.HDDUsed
base["last_network_in"] = trafficBaseline(s.NetworkIn, node.LastNetworkIn)
base["last_network_out"] = trafficBaseline(s.NetworkOut, node.LastNetworkOut)
base["io_read"], base["io_write"] = s.IORead, s.IOWrite
base["custom"], base["os"] = s.Custom, s.OS
} else {
base["last_network_in"] = node.LastNetworkIn
base["last_network_out"] = node.LastNetworkOut
base["os"] = node.Stats.OS
base["cpu_model"] = node.Stats.CPUModel
}
servers = append(servers, base)
}
a.nodeMu.Unlock()
result := map[string]any{
"servers": servers,
"sslcerts": a.sslSnapshot(runtime.SSLCerts, now),
"updated": strconv.FormatInt(now.Unix(), 10),
}
if a.reloadWrites.Load() > 0 {
result["reload"] = true
if consumeReload {
a.reloadWrites.Add(-1)
}
}
return result
}
func (a *App) PersistStats() error {
a.persistMu.Lock()
defer a.persistMu.Unlock()
return writeStatsFile(a.opts.StatsPath, a.snapshotStats(true))
}
func monthResetWindow(now time.Time, monthStart int) bool {
return now.Day() == clamp(monthStart, 1, 28) && now.Hour() == 0 && now.Minute() < 5
}
func trafficBaseline(current, baseline int64) int64 {
if current == 0 || baseline == 0 {
return current
}
return baseline
}
func updateTrafficBaselines(node *NodeState, currentIn, currentOut int64, reset bool) {
if reset {
node.LastNetworkIn = currentIn
node.LastNetworkOut = currentOut
return
}
if node.LastNetworkIn == 0 || (currentIn != 0 && node.LastNetworkIn > currentIn) {
node.LastNetworkIn = currentIn
}
if node.LastNetworkOut == 0 || (currentOut != 0 && node.LastNetworkOut > currentOut) {
node.LastNetworkOut = currentOut
}
}
func round2(value float64) float64 {
return math.Round(value*100) / 100
}
func formatUptime(seconds int64) string {
days := seconds / 86400
if days > 0 {
return fmt.Sprintf("%d 天", days)
}
return fmt.Sprintf("%02d:%02d:%02d", seconds/3600, (seconds/60)%60, seconds%60)
}
func (a *App) restorePersistentState() {
data, err := os.ReadFile(a.opts.StatsPath)
if err != nil {
data, err = os.ReadFile(a.opts.StatsPath + "~")
}
if err != nil {
return
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var previous struct {
Servers []map[string]any `json:"servers"`
}
if err := decoder.Decode(&previous); err != nil {
a.logger.Printf("read previous stats: %v", err)
return
}
a.nodeMu.Lock()
defer a.nodeMu.Unlock()
for _, node := range a.nodes {
for _, saved := range previous.Servers {
if fmt.Sprint(saved["name"]) != node.Config.Name || fmt.Sprint(saved["type"]) != node.Config.Type || fmt.Sprint(saved["host"]) != node.Config.Host || fmt.Sprint(saved["location"]) != node.Config.Location {
continue
}
node.LastNetworkIn = anyInt64(saved["last_network_in"])
node.LastNetworkOut = anyInt64(saved["last_network_out"])
node.Stats.OS = anyString(saved["os"])
node.Stats.CPUModel = anyString(saved["cpu_model"])
break
}
}
}
func anyString(value any) string {
if value == nil {
return ""
}
return fmt.Sprint(value)
}
func anyInt64(value any) int64 {
switch number := value.(type) {
case json.Number:
parsed, _ := number.Int64()
return parsed
case float64:
return int64(number)
case int64:
return number
case int:
return int64(number)
case string:
parsed, _ := strconv.ParseInt(number, 10, 64)
return parsed
default:
return 0
}
}
func (a *App) ResetTraffic(username string) (map[string]any, *APIError) {
a.nodeMu.Lock()
node := a.nodes[username]
if node == nil {
a.nodeMu.Unlock()
return nil, &APIError{Status: 404, Message: "server was not found", Details: map[string]any{"username": username}}
}
if !node.Connected || !node.HasUpdate {
a.nodeMu.Unlock()
return nil, &APIError{Status: 409, Message: "server has no current traffic counters; it may be offline", Details: map[string]any{"username": username}}
}
previousIn, previousOut := node.LastNetworkIn, node.LastNetworkOut
networkIn, networkOut := node.Stats.NetworkIn, node.Stats.NetworkOut
node.LastNetworkIn, node.LastNetworkOut = networkIn, networkOut
server := node.Config
a.nodeMu.Unlock()
a.wakeStatsWriter()
return map[string]any{
"server": server,
"stats": map[string]any{
"network_in": networkIn, "network_out": networkOut,
"previous_last_network_in": previousIn, "previous_last_network_out": previousOut,
"last_network_in": networkIn, "last_network_out": networkOut,
"month_in_before": max64(0, networkIn-previousIn), "month_out_before": max64(0, networkOut-previousOut),
},
}, nil
}
func max64(left, right int64) int64 {
if left > right {
return left
}
return right
}