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