This commit is contained in:
cppla
2026-07-10 15:35:10 +08:00
parent 5322a9105e
commit e4ca0558f7
50 changed files with 3292 additions and 54736 deletions
+1 -2
View File
@@ -2,8 +2,7 @@
.github
.idea
*.sublime-workspace
server/obj
server/sergate
server/serverstatus
web/json/stats.json
web/json/stats.json~
*.bak-*
+17 -8
View File
@@ -10,22 +10,31 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y gcc g++ make libcurl4-openssl-dev python3
- uses: actions/setup-go@v6
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Build server
run: make -C server -j2
- name: Check scripts
- name: Test Go server
working-directory: server
run: |
python3 -m py_compile server/manage_api.py clients/client-linux.py clients/client-psutil.py plugin/bot-telegram.py
sh -n server/entrypoint-server.sh clients/entrypoint.sh status.sh
test -z "$(gofmt -l .)"
go vet ./...
go test -race ./...
CGO_ENABLED=0 go build -trimpath -o /tmp/serverstatus .
- name: Check clients, plugins, WebUI and shell scripts
run: |
python3 -m py_compile clients/client-linux.py clients/client-psutil.py plugin/bot-telegram.py
sh -n clients/entrypoint.sh
bash -n status.sh
node --check web/js/app.js
- name: Validate compose files
run: |
docker compose -f docker-compose-server.yml config
docker compose -f docker-compose-client.yml config
TG_CHAT_ID=test TG_BOT_TOKEN=test ADMIN_TOKEN=test docker compose -f plugin/docker-compose-telegram.yml config
- name: Build Docker images
run: |
-1
View File
@@ -13,7 +13,6 @@ COPY clients/entrypoint.sh /app/entrypoint.sh
ENV SERVER=127.0.0.1 \
USER=s01 \
PORT=35601 \
PASSWORD=USER_DEFAULT_PASSWORD \
INTERVAL=1 \
PROBEPORT=80 \
PROBE_PROTOCOL_PREFER=ipv4 \
+35 -35
View File
@@ -1,42 +1,42 @@
FROM python:3.12-slim-bookworm AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /src/server
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/*.go ./
ARG VERSION=2.0.0
ARG COMMIT=none
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildTime=${BUILD_TIME}" \
-o /out/serverstatus .
FROM alpine:3.22
LABEL maintainer="cppla <https://cpp.la>"
RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \
&& apt-get -o Acquire::Retries=3 update -y \
&& apt-get install -y --no-install-recommends gcc g++ make libcurl4-openssl-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache ca-certificates tzdata \
&& mkdir -p /app/config /app/data /app/web
COPY server/ /server/
COPY --from=builder /out/serverstatus /usr/local/bin/serverstatus
COPY server/config.json /app/config/config.json
COPY web /app/web/
WORKDIR /server
RUN make -j && strip /server/sergate
FROM debian:bookworm-slim
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \
&& apt-get -o Acquire::Retries=3 update -y \
&& apt-get install -y --no-install-recommends nginx-light python3 openssl libcurl4 libstdc++6 tzdata \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /ServerStatus/server/ \
&& ln -sf /dev/null /var/log/nginx/access.log \
&& ln -sf /dev/null /var/log/nginx/error.log \
&& rm -f /etc/nginx/sites-enabled/default
COPY --from=builder /server/sergate /ServerStatus/server/sergate
COPY server/config.json /ServerStatus/server/config.json
COPY server/manage_api.py /ServerStatus/server/manage_api.py
COPY server/entrypoint-server.sh /ServerStatus/server/entrypoint-server.sh
COPY web /usr/share/nginx/html/
COPY server/nginx-serverstatus.conf /etc/nginx/conf.d/default.conf
RUN chmod +x /ServerStatus/server/entrypoint-server.sh
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
ENV TZ=Asia/Shanghai \
CONFIG_PATH=/app/config/config.json \
STATS_PATH=/app/data/stats.json \
WEB_DIR=/app/web \
HTTP_ADDR=:80 \
AGENT_ADDR=:35601
EXPOSE 80 35601
HEALTHCHECK --interval=10s --timeout=3s --retries=3 CMD python3 -c "import os,urllib.request; pid=int(open('/tmp/serverstatus-sergate.pid').read().strip()); os.kill(pid,0); urllib.request.urlopen('http://127.0.0.1/',timeout=2).read(1)"
CMD ["/ServerStatus/server/entrypoint-server.sh"]
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1/api/health || exit 1
ENTRYPOINT ["/usr/local/bin/serverstatus"]
+5 -3
View File
@@ -5,7 +5,7 @@ services:
dockerfile: Dockerfile.server
image: cppla/serverstatus:server
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import os,urllib.request; pid=int(open('/tmp/serverstatus-sergate.pid').read().strip()); os.kill(pid,0); urllib.request.urlopen('http://127.0.0.1/',timeout=2).read(1)\""]
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/api/health"]
interval: 30s
timeout: 10s
retries: 5
@@ -13,12 +13,14 @@ services:
restart: unless-stopped
environment:
ADMIN_TOKEN: "${ADMIN_TOKEN:-}"
HTTP_ADDR: ":80"
AGENT_ADDR: ":35601"
networks:
serverstatus-network:
ipv4_address: 172.23.0.2
volumes:
- ./server/config.json:/ServerStatus/server/config.json
- ./web/json:/usr/share/nginx/html/json
- ./server/config.json:/app/config/config.json
- ./web/json:/app/data
ports:
- 35601:35601
- 8080:80
+18 -7
View File
@@ -1,21 +1,29 @@
version: "3"
services:
serverstatus:
build:
context: ..
dockerfile: Dockerfile
image: serverstatus_server
dockerfile: Dockerfile.server
image: cppla/serverstatus:server
container_name: serverstatus
restart: unless-stopped
environment:
ADMIN_TOKEN: "${ADMIN_TOKEN:-}"
HTTP_ADDR: ":80"
AGENT_ADDR: ":35601"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/api/health"]
interval: 30s
timeout: 10s
retries: 5
networks:
serverstatus-network:
ipv4_address: 172.23.0.2
volumes:
- ../server/config.json:/ServerStatus/server/config.json
- ../web/json:/usr/share/nginx/html/json
- ../server/config.json:/app/config/config.json
- ../web/json:/app/data
ports:
- 35601:35601
- 8080:80
- "35601:35601"
- "8080:80"
bot:
build:
context: .
@@ -23,6 +31,9 @@ services:
image: serverstatus_bot
container_name: bot4sss
restart: unless-stopped
depends_on:
serverstatus:
condition: service_healthy
networks:
serverstatus-network:
ipv4_address: 172.23.0.3
+3 -1
View File
@@ -1,2 +1,4 @@
sergate
serverstatus
*.test
coverage.out
.tags*
-38
View File
@@ -1,38 +0,0 @@
OUT = sergate
.DEFAULT_GOAL := $(OUT)
#CC = clang
CC = gcc
CFLAGS = -Wall -O2
#CXX = clang++
CXX = g++
CXXFLAGS = -Wall -O2 -std=c++11
ODIR = obj
SDIR = src
LIBS = -pthread -lm
INC = -Iinclude
C_SRCS := $(wildcard $(SDIR)/*.c)
CXX_SRCS := $(wildcard $(SDIR)/*.cpp)
C_OBJS := $(patsubst $(SDIR)/%.c,$(ODIR)/%.o,$(C_SRCS))
CXX_OBJS := $(patsubst $(SDIR)/%.cpp,$(ODIR)/%.o,$(CXX_SRCS))
OBJS := $(C_OBJS) $(CXX_OBJS)
$(ODIR):
mkdir -p $(ODIR)
$(ODIR)/%.o: $(SDIR)/%.c | $(ODIR)
$(CC) -c $(INC) $(CFLAGS) $< -o $@
$(ODIR)/%.o: $(SDIR)/%.cpp | $(ODIR)
$(CXX) -c $(INC) $(CXXFLAGS) $< -o $@
$(OUT): $(OBJS)
$(CXX) $(LIBS) $^ -o $(OUT) -lcurl
.PHONY: clean
clean:
rm -f $(ODIR)/*.o $(OUT)
+472
View File
@@ -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
}
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"net"
"testing"
)
func TestTrafficBaselinesResetIndependently(t *testing.T) {
node := &NodeState{LastNetworkIn: 100, LastNetworkOut: 0}
updateTrafficBaselines(node, 150, 500, false)
if node.LastNetworkIn != 100 || node.LastNetworkOut != 500 {
t.Fatalf("missing outbound baseline was not initialized independently: %#v", node)
}
node.LastNetworkOut = 700
updateTrafficBaselines(node, 200, 50, false)
if node.LastNetworkIn != 100 || node.LastNetworkOut != 50 {
t.Fatalf("outbound counter reset changed the wrong baseline: %#v", node)
}
updateTrafficBaselines(node, 900, 800, true)
if node.LastNetworkIn != 900 || node.LastNetworkOut != 800 {
t.Fatalf("monthly reset did not reset both baselines: %#v", node)
}
}
func TestDisconnectPreservesOfflineDisplayMetadata(t *testing.T) {
app := newTestApp(t, minimalTestConfig())
client, server := net.Pipe()
defer client.Close()
defer server.Close()
app.nodeMu.Lock()
node := app.nodes["s01"]
node.Connected = true
node.Connection = server
node.ConnectionID = 42
node.HasUpdate = true
node.Stats = AgentStats{OS: "linux", CPUModel: "Test CPU"}
app.nodeMu.Unlock()
app.disconnectAgent("s01", server, 42)
serverStats := app.SnapshotStats()["servers"].([]any)[0].(map[string]any)
if serverStats["online4"] != false || serverStats["online6"] != false {
t.Fatalf("disconnected node remained online: %#v", serverStats)
}
if serverStats["os"] != "linux" || serverStats["cpu_model"] != "Test CPU" {
t.Fatalf("offline display metadata was discarded: %#v", serverStats)
}
}
-59
View File
@@ -1,59 +0,0 @@
#!/bin/sh
set -eu
: "${CONFIG_PATH:=/ServerStatus/server/config.json}"
: "${WEB_DIR:=/usr/share/nginx/html}"
: "${SERGATE_PID_FILE:=/tmp/serverstatus-sergate.pid}"
: "${ADMIN_API_BIND:=127.0.0.1}"
: "${ADMIN_API_PORT:=35602}"
STOPPING=0
SERGATE_PID=""
API_PID=""
stop_all() {
STOPPING=1
if [ -n "$SERGATE_PID" ] && kill -0 "$SERGATE_PID" 2>/dev/null; then
kill -TERM "$SERGATE_PID" 2>/dev/null || true
fi
if [ -n "$API_PID" ] && kill -0 "$API_PID" 2>/dev/null; then
kill -TERM "$API_PID" 2>/dev/null || true
fi
nginx -s quit 2>/dev/null || true
}
trap 'stop_all; exit 0' INT TERM QUIT
mkdir -p "$WEB_DIR/json"
nginx
CONFIG_PATH="$CONFIG_PATH" \
STATS_PATH="${STATS_PATH:-$WEB_DIR/json/stats.json}" \
SERGATE_PID_FILE="$SERGATE_PID_FILE" \
ADMIN_API_BIND="$ADMIN_API_BIND" \
ADMIN_API_PORT="$ADMIN_API_PORT" \
ADMIN_TOKEN="${ADMIN_TOKEN:-}" \
ADMIN_CORS_ORIGIN="${ADMIN_CORS_ORIGIN:-}" \
python3 /ServerStatus/server/manage_api.py &
API_PID="$!"
if [ -n "${ADMIN_TOKEN:-}" ]; then
echo "management API enabled on ${ADMIN_API_BIND}:${ADMIN_API_PORT}"
else
echo "management API running in read-only discovery mode; set ADMIN_TOKEN to enable writes"
fi
while [ "$STOPPING" -eq 0 ]; do
/ServerStatus/server/sergate --config="$CONFIG_PATH" --web-dir="$WEB_DIR" &
SERGATE_PID="$!"
echo "$SERGATE_PID" > "$SERGATE_PID_FILE"
set +e
wait "$SERGATE_PID"
STATUS="$?"
set -e
rm -f "$SERGATE_PID_FILE"
if [ "$STOPPING" -eq 1 ]; then
exit "$STATUS"
fi
echo "sergate exited with status ${STATUS}; restarting in 1s"
sleep 1
done
+161
View File
@@ -0,0 +1,161 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"syscall"
"time"
)
func readConfig(path string) (ConfigDocument, RuntimeConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, RuntimeConfig{}, err
}
doc, err := decodeDocument(data)
if err != nil {
return nil, RuntimeConfig{}, fmt.Errorf("parse %s: %w", path, err)
}
normalized, runtime, apiErr := normalizeConfig(doc)
if apiErr != nil {
return nil, RuntimeConfig{}, apiErr
}
return normalized, runtime, nil
}
func marshalIndented(value any) ([]byte, error) {
data, err := jsonMarshalIndent(value)
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}
// jsonMarshalIndent is a variable so file-writing failure paths can be tested.
var jsonMarshalIndent = func(value any) ([]byte, error) {
return json.MarshalIndent(value, "", "\t")
}
func writeConfig(path string, doc ConfigDocument) error {
data, err := marshalIndented(doc)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
mode := os.FileMode(0o644)
if info, statErr := os.Stat(path); statErr == nil {
mode = info.Mode().Perm()
backup := fmt.Sprintf("%s.bak-%s", path, time.Now().Format("20060102-150405.000000000"))
if err := copyFile(path, backup, mode); err != nil {
return fmt.Errorf("backup config: %w", err)
}
pruneBackups(path, 10)
} else if !errors.Is(statErr, os.ErrNotExist) {
return statErr
}
return atomicWrite(path, data, mode, true)
}
func writeStatsFile(path string, value any) error {
data, err := marshalIndented(value)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return atomicWrite(path, data, 0o644, false)
}
func atomicWrite(path string, data []byte, mode os.FileMode, allowBusyFallback bool) error {
directory := filepath.Dir(path)
tmp, err := os.CreateTemp(directory, ".serverstatus-*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err == nil {
return syncDirectory(directory)
} else if !allowBusyFallback || !errors.Is(err, syscall.EBUSY) {
return err
}
// Docker cannot rename over a single-file bind mount. The backup above is
// already durable, so truncate and sync the mounted inode as a fallback.
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
closeErr := file.Close()
if err != nil {
return err
}
return closeErr
}
func copyFile(source, destination string, mode os.FileMode) error {
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err = io.Copy(out, in); err == nil {
err = out.Sync()
}
closeErr := out.Close()
if err != nil {
return err
}
return closeErr
}
func pruneBackups(configPath string, keep int) {
matches, err := filepath.Glob(configPath + ".bak-*")
if err != nil || len(matches) <= keep {
return
}
sort.Strings(matches)
for _, path := range matches[:len(matches)-keep] {
_ = os.Remove(path)
}
}
func syncDirectory(directory string) error {
dir, err := os.Open(directory)
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
+40
View File
@@ -0,0 +1,40 @@
module github.com/cppla/serverstatus/server
go 1.25.0
require (
github.com/expr-lang/expr v1.17.8
github.com/gin-gonic/gin v1.12.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+91
View File
@@ -0,0 +1,91 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+397
View File
@@ -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)
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"testing"
)
func TestHTTPAPIAndStaticUI(t *testing.T) {
app := newTestApp(t, minimalTestConfig())
router := app.router()
response := performRequest(router, http.MethodGet, "/", "", "")
if response.Code != 200 || !strings.Contains(response.Body.String(), "test-ui") {
t.Fatalf("static UI: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodGet, "/api/health", "", "")
if response.Code != 200 || !strings.Contains(response.Body.String(), `"enabled":true`) {
t.Fatalf("health: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodGet, "/api/openapi.json", "", "")
if response.Code != 200 {
t.Fatalf("openapi: status=%d body=%s", response.Code, response.Body.String())
}
var openapi map[string]any
if err := json.Unmarshal(response.Body.Bytes(), &openapi); err != nil {
t.Fatal(err)
}
paths := openapi["paths"].(map[string]any)
if openapi["openapi"] != "3.1.0" || paths["/api/servers/{username}"] == nil || paths["/api/watchdog/{id}"] == nil {
t.Fatalf("OpenAPI document is incomplete: %#v", openapi)
}
monitorOperations := paths["/api/monitors"].(map[string]any)
createResponses := monitorOperations["post"].(map[string]any)["responses"].(map[string]any)
if createResponses["201"] == nil || createResponses["200"] != nil {
t.Fatalf("OpenAPI create response must describe HTTP 201: %#v", createResponses)
}
listResponses := monitorOperations["get"].(map[string]any)["responses"].(map[string]any)
listSchema := listResponses["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
if listSchema["properties"].(map[string]any)["monitors"] == nil {
t.Fatalf("OpenAPI list response does not expose monitors: %#v", listSchema)
}
restartResponses := paths["/api/restart"].(map[string]any)["post"].(map[string]any)["responses"].(map[string]any)
if restartResponses["202"] == nil {
t.Fatalf("OpenAPI restart response must describe HTTP 202: %#v", restartResponses)
}
response = performRequest(router, http.MethodGet, "/api/config", "", "wrong")
if response.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", response.Code)
}
response = performRequest(router, http.MethodGet, "/api/config", "", "test-token")
if response.Code != 200 {
t.Fatalf("config: status=%d body=%s", response.Code, response.Body.String())
}
monitor := `{"name":"新增服务","host":"tcp://127.0.0.1:80","type":"tcp","interval":"30"}`
response = performRequest(router, http.MethodPost, "/api/monitors", monitor, "test-token")
if response.Code != http.StatusCreated || !strings.Contains(response.Body.String(), "新增服务") {
t.Fatalf("create monitor: status=%d body=%s", response.Code, response.Body.String())
}
path := "/api/monitors/" + url.PathEscape("新增服务")
monitor = `{"name":"更新服务","host":"https://example.org","type":"https","interval":45}`
response = performRequest(router, http.MethodPut, path, monitor, "test-token")
if response.Code != 200 || !strings.Contains(response.Body.String(), "更新服务") {
t.Fatalf("update monitor: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodDelete, "/api/monitors/"+url.PathEscape("更新服务"), "", "test-token")
if response.Code != 200 {
t.Fatalf("delete monitor: status=%d body=%s", response.Code, response.Body.String())
}
percentMonitor := `{"name":"rate%check","host":"https://example.com","type":"https","interval":30}`
response = performRequest(router, http.MethodPost, "/api/monitors", percentMonitor, "test-token")
if response.Code != http.StatusCreated {
t.Fatalf("create percent monitor: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodDelete, "/api/monitors/"+url.PathEscape("rate%check"), "", "test-token")
if response.Code != 200 {
t.Fatalf("delete percent monitor: status=%d body=%s", response.Code, response.Body.String())
}
server := `{"username":"s02","name":"node2","type":"kvm","host":"host2","location":"JP","password":"secret","monthstart":31}`
response = performRequest(router, http.MethodPost, "/api/servers", server, "test-token")
if response.Code != http.StatusCreated || !strings.Contains(response.Body.String(), `"monthstart":28`) {
t.Fatalf("create server: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodDelete, "/api/servers/s02", "", "test-token")
if response.Code != 200 {
t.Fatalf("delete server: status=%d body=%s", response.Code, response.Body.String())
}
numericServer := `{"username":"0","name":"numeric","type":"kvm","host":"host0","location":"US","password":"secret","monthstart":1}`
response = performRequest(router, http.MethodPost, "/api/servers", numericServer, "test-token")
if response.Code != http.StatusCreated {
t.Fatalf("create numeric server: status=%d body=%s", response.Code, response.Body.String())
}
numericServer = `{"username":"0","name":"numeric-updated","type":"kvm","host":"host0","location":"US","password":"secret","monthstart":1}`
response = performRequest(router, http.MethodPut, "/api/servers/0", numericServer, "test-token")
if response.Code != 200 || !strings.Contains(response.Body.String(), "numeric-updated") {
t.Fatalf("update numeric server: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodDelete, "/api/servers/0", "", "test-token")
if response.Code != 200 || !strings.Contains(response.Body.String(), "numeric-updated") {
t.Fatalf("delete numeric server: status=%d body=%s", response.Code, response.Body.String())
}
if app.RuntimeSnapshot().Servers[0].Username != "s01" {
t.Fatal("numeric username operation modified the server at numeric index")
}
response = performRequest(router, http.MethodPost, "/api/servers/s01/reset-traffic", "", "test-token")
if response.Code != http.StatusConflict {
t.Fatalf("offline reset should conflict: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodPost, "/api/reload", "", "test-token")
if response.Code != 200 {
t.Fatalf("reload: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodPost, "/api/restart", "", "test-token")
if response.Code != http.StatusAccepted || !strings.Contains(response.Body.String(), "in-process") {
t.Fatalf("restart: status=%d body=%s", response.Code, response.Body.String())
}
data, err := os.ReadFile(app.opts.ConfigPath)
if err != nil {
t.Fatal(err)
}
var persisted map[string]any
if err := json.Unmarshal(data, &persisted); err != nil {
t.Fatalf("persisted config is invalid: %v", err)
}
}
func TestHTTPRejectsInvalidAndOversizedBodies(t *testing.T) {
app := newTestApp(t, minimalTestConfig())
router := app.router()
response := performRequest(router, http.MethodPost, "/api/servers", `{"name":`, "test-token")
if response.Code != 400 {
t.Fatalf("invalid JSON: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodPost, "/api/servers", `{"name":"x"}`, "test-token")
if response.Code != 400 {
t.Fatalf("missing fields: status=%d body=%s", response.Code, response.Body.String())
}
oversized := `{"name":"` + strings.Repeat("x", maxRequestBody) + `"}`
response = performRequest(router, http.MethodPost, "/api/servers", oversized, "test-token")
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized body: status=%d body=%s", response.Code, response.Body.String())
}
doc := app.ConfigSnapshot()
doc["watchdog"] = []any{map[string]any{"name": "broken", "rule": "cpu >", "interval": 10}}
data, _ := json.Marshal(doc)
response = performRequest(router, http.MethodPut, "/api/config", string(data), "test-token")
if response.Code != 400 {
t.Fatalf("invalid watchdog: status=%d body=%s", response.Code, response.Body.String())
}
}
-139
View File
@@ -1,139 +0,0 @@
#ifndef ARGPARSE_H
#define ARGPARSE_H
/**
* Command-line arguments parsing library.
*
* This module is inspired by parse-options.c (git) and python's argparse
* module.
*
* Arguments parsing is common task in cli program, but traditional `getopt`
* libraries are not easy to use. This library provides high-level arguments
* parsing solutions.
*
* The program defines what arguments it requires, and `argparse` will figure
* out how to parse those out of `argc` and `argv`, it also automatically
* generates help and usage messages and issues errors when users give the
* program invalid arguments.
*
* Reserved namespaces:
* argparse
* OPT
* Author: Yecheng Fu <cofyc.jackson@gmail.com>
*/
#include <assert.h>
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef __cplusplus
extern "C" {
#endif
struct argparse;
struct argparse_option;
typedef int argparse_callback(struct argparse *this_,
const struct argparse_option *option);
enum argparse_flag {
ARGPARSE_STOP_AT_NON_OPTION = 1,
};
enum argparse_option_type {
/* special */
ARGPARSE_OPT_END,
/* options with no arguments */
ARGPARSE_OPT_BOOLEAN,
ARGPARSE_OPT_BIT,
/* options with arguments (optional or required) */
ARGPARSE_OPT_INTEGER,
ARGPARSE_OPT_STRING,
};
enum argparse_option_flags {
OPT_NONEG = 1, /* Negation disabled. */
};
/*
* Argparse option struct.
*
* `type`:
* holds the type of the option, you must have an ARGPARSE_OPT_END last in your
* array.
*
* `short_name`:
* the character to use as a short option name, '\0' if none.
*
* `long_name`:
* the long option name, without the leading dash, NULL if none.
*
* `value`:
* stores pointer to the value to be filled.
*
* `help`:
* the short help message associated to what the option does.
* Must never be NULL (except for ARGPARSE_OPT_END).
*
* `callback`:
* function is called when corresponding argument is parsed.
*
* `data`:
* associated data. Callbacks can use it like they want.
*
* `flags`:
* option flags.
*
*/
struct argparse_option {
enum argparse_option_type type;
const char short_name;
const char *long_name;
void *value;
const char *help;
argparse_callback *callback;
intptr_t data;
int flags;
};
/*
* argpparse
*/
struct argparse {
// user supplied
const struct argparse_option *options;
const char *usage;
int flags;
// internal context
int argc;
const char **argv;
const char **out;
int cpidx;
const char *optvalue; // current option value
};
// builtin callbacks
int argparse_help_cb(struct argparse *this_,
const struct argparse_option *option);
// builtin option macros
#define OPT_END() { ARGPARSE_OPT_END, 0 }
#define OPT_BOOLEAN(...) { ARGPARSE_OPT_BOOLEAN, __VA_ARGS__ }
#define OPT_BIT(...) { ARGPARSE_OPT_BIT, __VA_ARGS__ }
#define OPT_INTEGER(...) { ARGPARSE_OPT_INTEGER, __VA_ARGS__ }
#define OPT_STRING(...) { ARGPARSE_OPT_STRING, __VA_ARGS__ }
#define OPT_HELP() OPT_BOOLEAN('h', "help", 0, "Show this help message and exit", argparse_help_cb)
int argparse_init(struct argparse *this_, struct argparse_option *options,
const char *usage, int flags);
int argparse_parse(struct argparse *this_, int argc, const char **argv);
void argparse_usage(struct argparse *this_);
#ifdef __cplusplus
}
#endif
#endif
-149
View File
@@ -1,149 +0,0 @@
/* (c) Magnus Auvinen. See licence.txt in the root of the distribution for more information. */
/* If you are missing that file, acquire a complete release at teeworlds.com. */
#ifndef BASE_DETECT_H
#define BASE_DETECT_H
/*
this file detected the family, platform and architecture
to compile for.
*/
/* platforms */
/* windows Family */
#if defined(WIN64) || defined(_WIN64)
/* Hmm, is this IA64 or x86-64? */
#define CONF_FAMILY_WINDOWS 1
#define CONF_FAMILY_STRING "windows"
#define CONF_PLATFORM_WIN64 1
#define CONF_PLATFORM_STRING "win64"
#elif defined(WIN32) || defined(_WIN32) || defined(__CYGWIN32__) || defined(__MINGW32__)
#define CONF_FAMILY_WINDOWS 1
#define CONF_FAMILY_STRING "windows"
#define CONF_PLATFORM_WIN32 1
#define CONF_PLATFORM_STRING "win32"
#endif
/* unix family */
#if defined(__FreeBSD__) || defined(__FreeBSD_kernel__)
#define CONF_FAMILY_UNIX 1
#define CONF_FAMILY_STRING "unix"
#define CONF_PLATFORM_FREEBSD 1
#define CONF_PLATFORM_STRING "freebsd"
#endif
#if defined(__OpenBSD__)
#define CONF_FAMILY_UNIX 1
#define CONF_FAMILY_STRING "unix"
#define CONF_PLATFORM_OPENBSD 1
#define CONF_PLATFORM_STRING "openbsd"
#endif
#if defined(__LINUX__) || defined(__linux__)
#define CONF_FAMILY_UNIX 1
#define CONF_FAMILY_STRING "unix"
#define CONF_PLATFORM_LINUX 1
#define CONF_PLATFORM_STRING "linux"
#endif
#if defined(__GNU__) || defined(__gnu__)
#define CONF_FAMILY_UNIX 1
#define CONF_FAMILY_STRING "unix"
#define CONF_PLATFORM_HURD 1
#define CONF_PLATFORM_STRING "gnu"
#endif
#if defined(MACOSX) || defined(__APPLE__) || defined(__DARWIN__)
#define CONF_FAMILY_UNIX 1
#define CONF_FAMILY_STRING "unix"
#define CONF_PLATFORM_MACOSX 1
#define CONF_PLATFORM_STRING "macosx"
#endif
#if defined(__sun)
#define CONF_FAMILY_UNIX 1
#define CONF_FAMILY_STRING "unix"
#define CONF_PLATFORM_SOLARIS 1
#define CONF_PLATFORM_STRING "solaris"
#endif
/* beos family */
#if defined(__BeOS) || defined(__BEOS__)
#define CONF_FAMILY_BEOS 1
#define CONF_FAMILY_STRING "beos"
#define CONF_PLATFORM_BEOS 1
#define CONF_PLATFORM_STRING "beos"
#endif
/* use gcc endianness definitions when available */
#if defined(__GNUC__) && !defined(__APPLE__) && !defined(__MINGW32__) && !defined(__sun)
#if defined(__FreeBSD__) || defined(__OpenBSD__)
#include <sys/endian.h>
#else
#include <endian.h>
#endif
#if __BYTE_ORDER == __LITTLE_ENDIAN
#define CONF_ARCH_ENDIAN_LITTLE 1
#elif __BYTE_ORDER == __BIG_ENDIAN
#define CONF_ARCH_ENDIAN_BIG 1
#endif
#endif
/* architectures */
#if defined(i386) || defined(__i386__) || defined(__x86__) || defined(CONF_PLATFORM_WIN32)
#define CONF_ARCH_IA32 1
#define CONF_ARCH_STRING "ia32"
#if !defined(CONF_ARCH_ENDIAN_LITTLE) && !defined(CONF_ARCH_ENDIAN_BIG)
#define CONF_ARCH_ENDIAN_LITTLE 1
#endif
#endif
#if defined(__ia64__) || defined(_M_IA64)
#define CONF_ARCH_IA64 1
#define CONF_ARCH_STRING "ia64"
#if !defined(CONF_ARCH_ENDIAN_LITTLE) && !defined(CONF_ARCH_ENDIAN_BIG)
#define CONF_ARCH_ENDIAN_LITTLE 1
#endif
#endif
#if defined(__amd64__) || defined(__x86_64__) || defined(_M_X64)
#define CONF_ARCH_AMD64 1
#define CONF_ARCH_STRING "amd64"
#if !defined(CONF_ARCH_ENDIAN_LITTLE) && !defined(CONF_ARCH_ENDIAN_BIG)
#define CONF_ARCH_ENDIAN_LITTLE 1
#endif
#endif
#if defined(__powerpc__) || defined(__ppc__)
#define CONF_ARCH_PPC 1
#define CONF_ARCH_STRING "ppc"
#if !defined(CONF_ARCH_ENDIAN_LITTLE) && !defined(CONF_ARCH_ENDIAN_BIG)
#define CONF_ARCH_ENDIAN_BIG 1
#endif
#endif
#if defined(__sparc__)
#define CONF_ARCH_SPARC 1
#define CONF_ARCH_STRING "sparc"
#if !defined(CONF_ARCH_ENDIAN_LITTLE) && !defined(CONF_ARCH_ENDIAN_BIG)
#define CONF_ARCH_ENDIAN_BIG 1
#endif
#endif
#ifndef CONF_FAMILY_STRING
#define CONF_FAMILY_STRING "unknown"
#endif
#ifndef CONF_PLATFORM_STRING
#define CONF_PLATFORM_STRING "unknown"
#endif
#ifndef CONF_ARCH_STRING
#define CONF_ARCH_STRING "unknown"
#endif
#endif
-269
View File
@@ -1,269 +0,0 @@
/* vim: set et ts=3 sw=3 sts=3 ft=c:
*
* Copyright (C) 2012, 2013, 2014 James McLaughlin et al. All rights reserved.
* https://github.com/udp/json-parser
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*/
#ifndef _JSON_H
#define _JSON_H
#ifndef json_char
#define json_char char
#endif
#ifndef json_int_t
#ifndef _MSC_VER
#include <inttypes.h>
#define json_int_t int64_t
#else
#define json_int_t __int64
#endif
#endif
#include <stdlib.h>
#ifdef __cplusplus
#include <string.h>
extern "C"
{
#endif
typedef struct
{
unsigned long max_memory;
int settings;
/* Custom allocator support (leave null to use malloc/free)
*/
void * (* mem_alloc) (size_t, int zero, void * user_data);
void (* mem_free) (void *, void * user_data);
void * user_data; /* will be passed to mem_alloc and mem_free */
} json_settings;
#define json_enable_comments 0x01
typedef enum
{
json_none,
json_object,
json_array,
json_integer,
json_double,
json_string,
json_boolean,
json_null
} json_type;
extern const struct _json_value json_value_none;
typedef struct _json_value
{
struct _json_value * parent;
json_type type;
union
{
int boolean;
json_int_t integer;
double dbl;
struct
{
unsigned int length;
json_char * ptr; /* null terminated */
} string;
struct
{
unsigned int length;
struct
{
json_char * name;
unsigned int name_length;
struct _json_value * value;
} * values;
#if defined(__cplusplus) && __cplusplus >= 201103L
decltype(values) begin () const
{ return values;
}
decltype(values) end () const
{ return values + length;
}
#endif
} object;
struct
{
unsigned int length;
struct _json_value ** values;
#if defined(__cplusplus) && __cplusplus >= 201103L
decltype(values) begin () const
{ return values;
}
decltype(values) end () const
{ return values + length;
}
#endif
} array;
} u;
union
{
struct _json_value * next_alloc;
void * object_mem;
} _reserved;
/* Some C++ operator sugar */
#ifdef __cplusplus
public:
inline _json_value ()
{ memset (this, 0, sizeof (_json_value));
}
inline const struct _json_value &operator [] (int index) const
{
if (type != json_array || index < 0
|| ((unsigned int) index) >= u.array.length)
{
return json_value_none;
}
return *u.array.values [index];
}
inline const struct _json_value &operator [] (const char * index) const
{
if (type != json_object)
return json_value_none;
for (unsigned int i = 0; i < u.object.length; ++ i)
if (!strcmp (u.object.values [i].name, index))
return *u.object.values [i].value;
return json_value_none;
}
inline operator const char * () const
{
switch (type)
{
case json_string:
return u.string.ptr;
default:
return "";
};
}
inline operator json_int_t () const
{
switch (type)
{
case json_integer:
return u.integer;
case json_double:
return (json_int_t) u.dbl;
default:
return 0;
};
}
inline operator bool () const
{
if (type != json_boolean)
return false;
return u.boolean != 0;
}
inline operator double () const
{
switch (type)
{
case json_integer:
return (double) u.integer;
case json_double:
return u.dbl;
default:
return 0;
};
}
#endif
} json_value;
json_value * json_parse (const json_char * json,
size_t length);
#define json_error_max 128
json_value * json_parse_ex (json_settings * settings,
const json_char * json,
size_t length,
char * error);
void json_value_free (json_value *);
/* Not usually necessary, unless you used a custom mem_alloc and now want to
* use a custom mem_free.
*/
void json_value_free_ex (json_settings * settings,
json_value *);
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
func main() {
var opts Options
var legacyBind string
var legacyPort int
var printVersion bool
flag.StringVar(&opts.ConfigPath, "config", envOr("CONFIG_PATH", "config.json"), "configuration file")
flag.StringVar(&opts.ConfigPath, "c", envOr("CONFIG_PATH", "config.json"), "configuration file (shorthand)")
flag.StringVar(&opts.StatsPath, "stats", os.Getenv("STATS_PATH"), "persistent stats JSON file")
flag.StringVar(&opts.WebDir, "web-dir", envOr("WEB_DIR", "../web"), "WebUI directory")
flag.StringVar(&opts.WebDir, "d", envOr("WEB_DIR", "../web"), "WebUI directory (shorthand)")
flag.StringVar(&opts.HTTPAddr, "http", envOr("HTTP_ADDR", ":8080"), "HTTP listen address")
flag.StringVar(&opts.AgentAddr, "agent", envOr("AGENT_ADDR", ":35601"), "agent TCP listen address")
flag.StringVar(&legacyBind, "bind", "", "agent bind address (legacy compatibility)")
flag.StringVar(&legacyBind, "b", "", "agent bind address (legacy shorthand)")
flag.IntVar(&legacyPort, "port", 0, "agent TCP port (legacy compatibility)")
flag.IntVar(&legacyPort, "p", 0, "agent TCP port (legacy shorthand)")
flag.BoolVar(&opts.Verbose, "verbose", envBool("VERBOSE", false), "verbose HTTP logging")
flag.BoolVar(&opts.Verbose, "v", envBool("VERBOSE", false), "verbose HTTP logging (shorthand)")
flag.BoolVar(&printVersion, "version", false, "print version and exit")
flag.Parse()
if printVersion {
fmt.Printf("serverstatus %s commit=%s built=%s\n", version, commit, buildTime)
return
}
if opts.StatsPath == "" {
opts.StatsPath = filepath.Join(opts.WebDir, "json", "stats.json")
}
if legacyPort != 0 || legacyBind != "" {
if legacyPort == 0 {
legacyPort = 35601
}
opts.AgentAddr = net.JoinHostPort(legacyBind, strconv.Itoa(legacyPort))
}
opts.AdminToken = os.Getenv("ADMIN_TOKEN")
opts.CORSOrigin = os.Getenv("ADMIN_CORS_ORIGIN")
opts.InsecureCallbackTLS = envBool("INSECURE_CALLBACK_TLS", false)
if err := os.MkdirAll(filepath.Dir(opts.StatsPath), 0o755); err != nil {
fatalf("create stats directory: %v", err)
}
app, err := NewApp(opts)
if err != nil {
fatalf("start: %v", err)
}
app.StartBackground()
httpServer := app.HTTPServer()
agentServer := NewAgentServer(app)
errorsChannel := make(chan error, 2)
go func() {
app.logger.Printf("HTTP listening on %s", opts.HTTPAddr)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errorsChannel <- fmt.Errorf("HTTP server: %w", err)
}
}()
go func() {
if err := agentServer.ListenAndServe(); err != nil {
errorsChannel <- fmt.Errorf("agent server: %w", err)
}
}()
reloadSignals := make(chan os.Signal, 1)
stopSignals := make(chan os.Signal, 1)
signal.Notify(reloadSignals, syscall.SIGHUP)
signal.Notify(stopSignals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
defer signal.Stop(reloadSignals)
defer signal.Stop(stopSignals)
go func() {
for range reloadSignals {
if apiErr := app.ReloadConfig(); apiErr != nil {
app.logger.Printf("reload config: %s", apiErr.Message)
continue
}
app.logger.Printf("configuration reloaded; generation=%d", app.generation.Load())
}
}()
var fatalErr error
select {
case signalValue := <-stopSignals:
app.logger.Printf("received %s; shutting down", signalValue)
case fatalErr = <-errorsChannel:
app.logger.Printf("fatal: %v", fatalErr)
}
app.cancel()
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_ = httpServer.Shutdown(shutdownContext)
cancel()
app.Close()
if fatalErr != nil {
os.Exit(1)
}
}
func envOr(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func envBool(name string, fallback bool) bool {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "serverstatus: "+format+"\n", args...)
os.Exit(1)
}
-621
View File
@@ -1,621 +0,0 @@
#!/usr/bin/env python3
import json
import os
import shutil
import signal
import tempfile
import time
import errno
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote, urlparse
CONFIG_PATH = os.environ.get("CONFIG_PATH", "/ServerStatus/server/config.json")
STATS_PATH = os.environ.get("STATS_PATH", "/usr/share/nginx/html/json/stats.json")
SERGATE_PID_FILE = os.environ.get("SERGATE_PID_FILE", "/tmp/serverstatus-sergate.pid")
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")
API_BIND = os.environ.get("ADMIN_API_BIND", "127.0.0.1")
API_PORT = int(os.environ.get("ADMIN_API_PORT", "35602"))
CORS_ORIGIN = os.environ.get("ADMIN_CORS_ORIGIN", "")
class ApiError(Exception):
def __init__(self, status, message, details=None):
super().__init__(message)
self.status = status
self.message = message
self.details = details
def load_config():
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def load_json_file(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def load_stats_file():
try:
return load_json_file(STATS_PATH)
except FileNotFoundError:
return load_json_file(f"{STATS_PATH}~")
def write_json_file(path, data):
directory = os.path.dirname(path) or "."
os.makedirs(directory, exist_ok=True)
mode = os.stat(path).st_mode if os.path.exists(path) else 0o644
payload = json.dumps(data, ensure_ascii=False, indent="\t") + "\n"
fd, tmp_path = tempfile.mkstemp(prefix=".json.", suffix=".tmp", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(payload)
f.flush()
os.fsync(f.fileno())
os.chmod(tmp_path, mode)
os.replace(tmp_path, path)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
def normalize_required_strings(item, required, kind, index=None):
missing = [field for field in required if item.get(field) is None or not str(item.get(field, "")).strip()]
if missing:
raise ApiError(400, f"{kind} has missing required fields", {"missing": missing, "index": index})
normalized = dict(item)
for field in required:
normalized[field] = str(normalized[field]).strip()
return normalized
def normalize_optional_string(item, field):
value = item.get(field, "")
item[field] = "" if value is None else str(value).strip()
def normalize_int(value, field, index=None, default=None, min_value=None, max_value=None):
if value in (None, ""):
value = default
try:
value = int(value)
except (TypeError, ValueError):
raise ApiError(400, f"{field} must be an integer", {"index": index})
if min_value is not None:
value = max(min_value, value)
if max_value is not None:
value = min(max_value, value)
return value
def validate_server(server, index=None):
if not isinstance(server, dict):
raise ApiError(400, "server must be an object", {"index": index})
required = ["username", "name", "type", "host", "location", "password"]
normalized = normalize_required_strings(server, required, "server", index=index)
normalized["monthstart"] = normalize_int(normalized.get("monthstart"), "monthstart", index=index, default=1, min_value=1, max_value=28)
if "disabled" in normalized:
normalized["disabled"] = bool(normalized["disabled"])
return normalized
def validate_monitor(monitor, index=None):
if not isinstance(monitor, dict):
raise ApiError(400, "monitor must be an object", {"index": index})
normalized = normalize_required_strings(monitor, ["name", "host", "type"], "monitor", index=index)
normalized["interval"] = normalize_int(normalized.get("interval"), "interval", index=index, default=600, min_value=1)
return normalized
def validate_sslcert(sslcert, index=None):
if not isinstance(sslcert, dict):
raise ApiError(400, "sslcert must be an object", {"index": index})
normalized = normalize_required_strings(sslcert, ["name", "domain"], "sslcert", index=index)
normalized["port"] = normalize_int(normalized.get("port"), "port", index=index, default=443, min_value=1, max_value=65535)
normalized["interval"] = normalize_int(normalized.get("interval"), "interval", index=index, default=7200, min_value=1)
normalize_optional_string(normalized, "callback")
return normalized
def validate_watchdog(watchdog, index=None):
if not isinstance(watchdog, dict):
raise ApiError(400, "watchdog must be an object", {"index": index})
normalized = normalize_required_strings(watchdog, ["name", "rule"], "watchdog", index=index)
normalized["interval"] = normalize_int(normalized.get("interval"), "interval", index=index, default=600, min_value=1)
normalize_optional_string(normalized, "callback")
return normalized
COLLECTIONS = {
"servers": {
"item": "server",
"id_field": "username",
"validator": validate_server,
"required": ["username", "name", "type", "host", "location", "password"],
"optional": ["monthstart", "disabled"],
},
"monitors": {
"item": "monitor",
"id_field": "name",
"validator": validate_monitor,
"required": ["name", "host", "type"],
"optional": ["interval"],
},
"sslcerts": {
"item": "sslcert",
"id_field": "name",
"validator": validate_sslcert,
"required": ["name", "domain"],
"optional": ["port", "interval", "callback"],
},
"watchdog": {
"item": "watchdog",
"id_field": "name",
"validator": validate_watchdog,
"required": ["name", "rule"],
"optional": ["interval", "callback"],
},
}
def validate_config(config):
if not isinstance(config, dict):
raise ApiError(400, "config must be a JSON object")
config = dict(config)
for key, meta in COLLECTIONS.items():
items = config.get(key, [])
if not isinstance(items, list):
raise ApiError(400, f"{key} must be an array")
normalized = []
seen = set()
for index, item in enumerate(items):
entry = meta["validator"](item, index=index)
if key == "servers":
username = entry["username"]
if username in seen:
raise ApiError(409, "duplicate server username", {"username": username})
seen.add(username)
normalized.append(entry)
config[key] = normalized
return config
def write_config(config):
config = validate_config(config)
directory = os.path.dirname(CONFIG_PATH) or "."
os.makedirs(directory, exist_ok=True)
timestamp = time.strftime("%Y%m%d-%H%M%S")
if os.path.exists(CONFIG_PATH):
shutil.copy2(CONFIG_PATH, f"{CONFIG_PATH}.bak-{timestamp}")
mode = os.stat(CONFIG_PATH).st_mode
else:
mode = 0o644
data = json.dumps(config, ensure_ascii=False, indent="\t") + "\n"
fd, tmp_path = tempfile.mkstemp(prefix=".config.", suffix=".tmp", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
os.chmod(tmp_path, mode)
try:
os.replace(tmp_path, CONFIG_PATH)
except OSError as exc:
if exc.errno != errno.EBUSY:
raise
# A single-file Docker bind mount cannot be atomically replaced.
# Keep the backup above, then rewrite the mounted file in place.
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
return config
def write_and_reload(config):
config = write_config(config)
pid = signal_sergate(signal.SIGHUP)
return config, pid
def read_body(handler):
length = int(handler.headers.get("Content-Length", "0") or "0")
if length <= 0:
return None
if length > 1024 * 1024:
raise ApiError(413, "request body is too large")
raw = handler.rfile.read(length).decode("utf-8")
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise ApiError(400, "invalid JSON body", {"error": str(exc)})
def get_sergate_pid():
try:
with open(SERGATE_PID_FILE, "r", encoding="utf-8") as f:
pid = int(f.read().strip())
os.kill(pid, 0)
return pid
except Exception:
pass
proc_dir = "/proc"
if not os.path.isdir(proc_dir):
return None
for name in os.listdir(proc_dir):
if not name.isdigit():
continue
try:
with open(os.path.join(proc_dir, name, "cmdline"), "rb") as f:
cmdline = f.read().replace(b"\x00", b" ").decode("utf-8", "ignore")
except Exception:
continue
if "sergate" in cmdline:
return int(name)
return None
def signal_sergate(sig):
pid = get_sergate_pid()
if not pid:
raise ApiError(503, "sergate process was not found")
os.kill(pid, sig)
return pid
def wait_for_pid_exit(pid, timeout=2.5):
deadline = time.time() + timeout
while time.time() < deadline:
try:
os.kill(pid, 0)
except OSError:
return True
time.sleep(0.05)
return False
def wait_for_sergate_pid(previous_pid=None, timeout=4.0):
deadline = time.time() + timeout
while time.time() < deadline:
pid = get_sergate_pid()
if pid and pid != previous_pid:
return pid
time.sleep(0.1)
return get_sergate_pid()
def find_server(config, username):
servers = config.get("servers", [])
for index, server in enumerate(servers):
if server.get("username") == username:
return index, server
return -1, None
def find_collection_item(config, key, item_id):
items = config.get(key, [])
if item_id.isdigit():
index = int(item_id)
if 0 <= index < len(items):
return index, items[index]
return -1, None
id_field = COLLECTIONS[key]["id_field"]
matches = [(index, item) for index, item in enumerate(items) if str(item.get(id_field, "")) == item_id]
if len(matches) > 1:
raise ApiError(409, f"{key} has duplicate {id_field}; use numeric index instead", {"id": item_id})
if matches:
return matches[0]
return -1, None
def stats_server_matches(config_server, stats_server):
return all(str(stats_server.get(field, "")) == str(config_server.get(field, "")) for field in ["name", "type", "host", "location"])
def find_stats_server(stats, config_server):
servers = stats.get("servers", [])
if not isinstance(servers, list):
raise ApiError(500, "stats.json has invalid servers data")
for index, stats_server in enumerate(servers):
if isinstance(stats_server, dict) and stats_server_matches(config_server, stats_server):
return index, stats_server
return -1, None
def as_counter(value, field):
try:
return int(value)
except (TypeError, ValueError):
raise ApiError(409, f"{field} is missing or invalid in stats.json")
def require_resettable_stats(stats, server, username):
stats_index, stats_server = find_stats_server(stats, server)
if stats_index < 0:
raise ApiError(404, "server stats were not found", {"username": username})
if "network_in" not in stats_server or "network_out" not in stats_server:
raise ApiError(409, "server has no current traffic counters; it may be offline", {"username": username})
return stats_index, stats_server
def reset_server_month_traffic(username):
config = load_config()
_, server = find_server(config, username)
if server is None:
raise ApiError(404, "server was not found", {"username": username})
require_resettable_stats(load_stats_file(), server, username)
old_pid = signal_sergate(signal.SIGTERM)
wait_for_pid_exit(old_pid)
stats = load_stats_file()
stats_index, stats_server = require_resettable_stats(stats, server, username)
network_in = as_counter(stats_server.get("network_in"), "network_in")
network_out = as_counter(stats_server.get("network_out"), "network_out")
previous_last_in = as_counter(stats_server.get("last_network_in", 0), "last_network_in")
previous_last_out = as_counter(stats_server.get("last_network_out", 0), "last_network_out")
stats["servers"][stats_index]["last_network_in"] = network_in
stats["servers"][stats_index]["last_network_out"] = network_out
stats["updated"] = str(int(time.time()))
write_json_file(STATS_PATH, stats)
new_pid = wait_for_sergate_pid(previous_pid=old_pid)
if new_pid:
os.kill(new_pid, signal.SIGHUP)
return {
"server": server,
"stats": {
"network_in": network_in,
"network_out": network_out,
"previous_last_network_in": previous_last_in,
"previous_last_network_out": previous_last_out,
"last_network_in": network_in,
"last_network_out": network_out,
"month_in_before": max(0, network_in - previous_last_in),
"month_out_before": max(0, network_out - previous_last_out),
},
"oldPid": old_pid,
"pid": new_pid,
}
def collection_routes():
endpoints = []
for key in ["monitors", "sslcerts", "watchdog"]:
endpoints.extend([
{"method": "GET", "path": f"/api/{key}", "auth": True},
{"method": "POST", "path": f"/api/{key}", "auth": True, "body": f"{COLLECTIONS[key]['item']} JSON"},
{"method": "PUT", "path": f"/api/{key}/{{index-or-name}}", "auth": True, "body": f"{COLLECTIONS[key]['item']} JSON"},
{"method": "DELETE", "path": f"/api/{key}/{{index-or-name}}", "auth": True},
])
return endpoints
def api_schema():
return {
"auth": {
"type": "bearer",
"header": "Authorization: Bearer <ADMIN_TOKEN>",
"enabled": bool(ADMIN_TOKEN),
},
"endpoints": [
{"method": "GET", "path": "/api/health", "auth": False},
{"method": "GET", "path": "/api/schema", "auth": False},
{"method": "GET", "path": "/api/config", "auth": True},
{"method": "PUT", "path": "/api/config", "auth": True, "body": "full config JSON"},
{"method": "GET", "path": "/api/servers", "auth": True},
{"method": "POST", "path": "/api/servers", "auth": True, "body": "server JSON"},
{"method": "PUT", "path": "/api/servers/{username}", "auth": True, "body": "server JSON"},
{"method": "DELETE", "path": "/api/servers/{username}", "auth": True},
{"method": "POST", "path": "/api/servers/{username}/reset-traffic", "auth": True},
*collection_routes(),
{"method": "POST", "path": "/api/reload", "auth": True},
{"method": "POST", "path": "/api/restart", "auth": True},
],
"collections": {
key: {
"item": meta["item"],
"idField": meta["id_field"],
"required": meta["required"],
"optional": meta["optional"],
}
for key, meta in COLLECTIONS.items()
},
}
class Handler(BaseHTTPRequestHandler):
server_version = "ServerStatusManageAPI/1.0"
def log_message(self, fmt, *args):
print("%s - %s" % (self.address_string(), fmt % args), flush=True)
def end_headers(self):
self.send_header("Cache-Control", "no-store")
if CORS_ORIGIN:
self.send_header("Access-Control-Allow-Origin", CORS_ORIGIN)
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Admin-Token")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
super().end_headers()
def send_json(self, status, payload):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def send_error_json(self, err):
payload = {"ok": False, "error": err.message}
if err.details is not None:
payload["details"] = err.details
self.send_json(err.status, payload)
def route(self):
parsed = urlparse(self.path)
path = parsed.path.rstrip("/") or "/"
method = self.command.upper()
if method == "OPTIONS":
self.send_response(204)
self.end_headers()
return
try:
if path == "/api/health" and method == "GET":
pid = get_sergate_pid()
self.send_json(200, {
"ok": True,
"enabled": bool(ADMIN_TOKEN),
"sergate": {"running": bool(pid), "pid": pid},
"configPath": CONFIG_PATH,
})
return
if path == "/api/schema" and method == "GET":
self.send_json(200, {"ok": True, "schema": api_schema()})
return
self.require_auth()
if path == "/api/config":
if method == "GET":
self.send_json(200, {"ok": True, "config": load_config()})
return
if method == "PUT":
config = read_body(self)
config, pid = write_and_reload(config)
self.send_json(200, {"ok": True, "reloaded": True, "pid": pid, "config": config})
return
if path == "/api/servers":
if method == "GET":
config = load_config()
self.send_json(200, {"ok": True, "servers": config.get("servers", [])})
return
if method == "POST":
server = validate_server(read_body(self))
config = load_config()
if find_server(config, server["username"])[1] is not None:
raise ApiError(409, "server username already exists", {"username": server["username"]})
config.setdefault("servers", []).append(server)
config, pid = write_and_reload(config)
self.send_json(201, {"ok": True, "server": server, "reloaded": True, "pid": pid, "config": config})
return
if path.startswith("/api/servers/") and path.endswith("/reset-traffic"):
username = unquote(path[len("/api/servers/"):-len("/reset-traffic")].rstrip("/"))
if not username:
raise ApiError(400, "username is required")
if method == "POST":
result = reset_server_month_traffic(username)
self.send_json(200, {"ok": True, "operation": "reset-traffic", **result})
return
if path.startswith("/api/servers/"):
username = unquote(path[len("/api/servers/"):])
if not username:
raise ApiError(400, "username is required")
config = load_config()
index, existing = find_server(config, username)
if index < 0:
raise ApiError(404, "server was not found", {"username": username})
if method == "PUT":
server = validate_server(read_body(self))
if server["username"] != username and find_server(config, server["username"])[1] is not None:
raise ApiError(409, "server username already exists", {"username": server["username"]})
config["servers"][index] = server
config, pid = write_and_reload(config)
self.send_json(200, {"ok": True, "server": server, "reloaded": True, "pid": pid, "config": config})
return
if method == "DELETE":
removed = config["servers"].pop(index)
config, pid = write_and_reload(config)
self.send_json(200, {"ok": True, "removed": removed, "reloaded": True, "pid": pid, "config": config})
return
for key in ["monitors", "sslcerts", "watchdog"]:
base = f"/api/{key}"
meta = COLLECTIONS[key]
if path == base:
if method == "GET":
config = load_config()
self.send_json(200, {"ok": True, key: config.get(key, [])})
return
if method == "POST":
item = meta["validator"](read_body(self))
config = load_config()
config.setdefault(key, []).append(item)
config, pid = write_and_reload(config)
self.send_json(201, {"ok": True, meta["item"]: item, "reloaded": True, "pid": pid, "config": config})
return
if path.startswith(base + "/"):
item_id = unquote(path[len(base) + 1:])
if not item_id:
raise ApiError(400, "item id is required")
config = load_config()
index, existing = find_collection_item(config, key, item_id)
if index < 0:
raise ApiError(404, f"{meta['item']} was not found", {"id": item_id})
if method == "PUT":
item = meta["validator"](read_body(self))
config[key][index] = item
config, pid = write_and_reload(config)
self.send_json(200, {"ok": True, meta["item"]: item, "reloaded": True, "pid": pid, "config": config})
return
if method == "DELETE":
removed = config[key].pop(index)
config, pid = write_and_reload(config)
self.send_json(200, {"ok": True, "removed": removed, "reloaded": True, "pid": pid, "config": config})
return
if path == "/api/reload" and method == "POST":
pid = signal_sergate(signal.SIGHUP)
self.send_json(200, {"ok": True, "operation": "reload", "pid": pid})
return
if path == "/api/restart" and method == "POST":
pid = signal_sergate(signal.SIGTERM)
self.send_json(202, {"ok": True, "operation": "restart", "pid": pid})
return
raise ApiError(404, "endpoint was not found")
except ApiError as err:
self.send_error_json(err)
except Exception as exc:
self.send_error_json(ApiError(500, "internal server error", {"error": str(exc)}))
def require_auth(self):
if not ADMIN_TOKEN:
raise ApiError(503, "management API is disabled; set ADMIN_TOKEN to enable it")
auth = self.headers.get("Authorization", "")
token = ""
if auth.lower().startswith("bearer "):
token = auth[7:].strip()
token = token or self.headers.get("X-Admin-Token", "").strip()
if token != ADMIN_TOKEN:
raise ApiError(401, "invalid or missing admin token")
def do_GET(self):
self.route()
def do_POST(self):
self.route()
def do_PUT(self):
self.route()
def do_DELETE(self):
self.route()
def do_OPTIONS(self):
self.route()
def main():
httpd = ThreadingHTTPServer((API_BIND, API_PORT), Handler)
print(f"manage-api listening on {API_BIND}:{API_PORT}", flush=True)
httpd.serve_forever()
if __name__ == "__main__":
main()
+367
View File
@@ -0,0 +1,367 @@
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
}
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/expr-lang/expr"
)
func TestNormalizeConfigPreservesCompatibility(t *testing.T) {
doc := minimalTestConfig()
doc["future"] = map[string]any{"enabled": true}
doc["watchdog"] = []any{
map[string]any{"name": "legacy", "rule": "cpu>90&load_1>5&username!='s01'", "interval": "600", "callback": nil},
map[string]any{"name": "type", "rule": "tcp_count>600&type='Oracle'", "interval": 60},
}
doc["servers"].([]any)[0].(map[string]any)["future_field"] = "kept"
normalized, runtime, apiErr := normalizeConfig(doc)
if apiErr != nil {
t.Fatal(apiErr)
}
if len(runtime.Watchdogs) != 2 || runtime.Watchdogs[0].Normalized != "cpu>90&&load_1>5&&username!='s01'" {
t.Fatalf("legacy rules were not normalized: %#v", runtime.Watchdogs)
}
server := normalized["servers"].([]any)[0].(map[string]any)
if server["future_field"] != "kept" || normalized["future"] == nil {
t.Fatal("unknown config fields were discarded")
}
if server["monthstart"] != json.Number("1") && server["monthstart"] != 1 {
t.Fatalf("monthstart not normalized: %#v", server["monthstart"])
}
}
func TestWatchdogLegacyRulesEvaluate(t *testing.T) {
rules := []string{
"online4=0&online6=0",
"(memory_used/memory_total)*100>90&memory_total>1048576",
"tcp_count>600&type='Oracle'",
"(network_out-last_network_out)/1024/1024/1024>18&(username='aliyun1'|username='aliyun2')",
}
for index, rule := range rules {
compiled, err := compileWatchdog(WatchdogConfig{Name: "test", Rule: rule, Interval: 1}, index)
if err != nil {
t.Fatalf("rule %q: %v", rule, err)
}
environment := watchdogEnvironment(ServerConfig{Username: "aliyun1", Type: "Oracle"}, AgentStats{MemoryTotal: 2_000_000, MemoryUsed: 1_900_000, TCPCount: 700, NetworkOut: 30 << 30}, false, false, 0, 0)
if _, err := expr.Run(compiled.Program, environment); err != nil {
t.Fatalf("run %q: %v", rule, err)
}
}
}
func TestConfigValidationErrors(t *testing.T) {
doc := minimalTestConfig()
doc["servers"] = append(doc["servers"].([]any), doc["servers"].([]any)[0])
_, _, apiErr := normalizeConfig(doc)
if apiErr == nil || apiErr.Status != 409 {
t.Fatalf("expected duplicate username error, got %#v", apiErr)
}
if _, err := decodeDocument([]byte(`{"servers":[]} {"servers":[]}`)); err == nil || !strings.Contains(err.Error(), "more than one") {
t.Fatalf("expected trailing JSON error, got %v", err)
}
}
func TestFormattingHelpers(t *testing.T) {
if got := formatUptime(90061); got != "1 天" {
t.Fatalf("formatUptime=%q", got)
}
if got := formatUptime(3661); got != "01:01:01" {
t.Fatalf("formatUptime=%q", got)
}
if got, err := certificateHost("https://example.com/path"); err != nil || got != "example.com" {
t.Fatalf("certificateHost=%q", got)
}
if got := secondsDuration(0); got != time.Second {
t.Fatalf("secondsDuration(0)=%s", got)
}
if got := secondsDuration(int(^uint(0) >> 1)); got < 365*24*time.Hour {
t.Fatalf("large interval overflowed or was truncated: %s", got)
}
}
-26
View File
@@ -1,26 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
location /json/ {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location /api/ {
proxy_pass http://127.0.0.1:35602;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
}
-1
View File
@@ -1 +0,0 @@
*.o
+223
View File
@@ -0,0 +1,223 @@
package main
import "strings"
func openAPISpec() map[string]any {
paths := map[string]any{
"/api/health": map[string]any{
"get": publicOperation("health", "服务健康状态", objectResponse("Go 服务、Agent TCP 与配置状态")),
},
"/api/schema": map[string]any{
"get": publicOperation("schema", "ServerStatus API 简表", objectResponse("端点和集合描述")),
},
"/api/openapi.json": map[string]any{
"get": publicOperation("openapi", "OpenAPI 3.1 文档", objectResponse("OpenAPI document")),
},
"/json/stats.json": map[string]any{
"get": publicOperation("stats", "实时状态快照", objectResponse("WebUI 状态数据")),
},
"/api/config": map[string]any{
"get": protectedOperation("getConfig", "读取完整配置", nil, configResponse("200", "配置文档")),
"put": protectedOperation("replaceConfig", "整体校验并替换配置", requestBody("Config"), configResponse("200", "已保存并热重载的配置")),
},
"/api/reload": map[string]any{
"post": protectedOperation("reloadConfig", "从磁盘热重载配置", nil, objectResponse("重载结果")),
},
"/api/restart": map[string]any{
"post": protectedOperation("restartRuntime", "在进程内重启采集运行时", nil, statusResponse("202", "重启结果", map[string]any{
"operation": map[string]any{"type": "string", "const": "restart"},
"mode": map[string]any{"type": "string", "const": "in-process"},
})),
},
"/api/servers/{username}/reset-traffic": map[string]any{
"post": withParameters(protectedOperation("resetServerTraffic", "重置节点本月流量基线", nil, objectResponse("流量重置结果")), pathParameter("username", "节点用户名")),
},
}
for _, key := range []string{"servers", "monitors", "sslcerts", "watchdog"} {
spec := collectionSpecs[key]
schemaName := map[string]string{"servers": "Server", "monitors": "Monitor", "sslcerts": "SSLCert", "watchdog": "Watchdog"}[key]
operationBase := strings.TrimSuffix(key, "s")
if key == "sslcerts" {
operationBase = "sslcert"
}
paths["/api/"+key] = map[string]any{
"get": protectedOperation("list"+titleWord(key), "查询 "+key, nil, collectionResponse(key, schemaName, "配置集合")),
"post": protectedOperation("create"+titleWord(operationBase), "新增 "+spec.itemName, requestBody(schemaName), itemResponse("201", spec.itemName, schemaName, "创建结果")),
}
parameterName := "id"
parameterDescription := "数字下标或唯一的 " + spec.idField
if key == "servers" {
parameterName = "username"
parameterDescription = "节点用户名"
}
paths["/api/"+key+"/{"+parameterName+"}"] = map[string]any{
"put": withParameters(protectedOperation("update"+titleWord(operationBase), "修改 "+spec.itemName, requestBody(schemaName), itemResponse("200", spec.itemName, schemaName, "修改结果")), pathParameter(parameterName, parameterDescription)),
"delete": withParameters(protectedOperation("delete"+titleWord(operationBase), "删除 "+spec.itemName, nil, itemResponse("200", "removed", schemaName, "删除结果")), pathParameter(parameterName, parameterDescription)),
}
}
return map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
"title": "ServerStatus HTTP API",
"version": version,
"description": "单进程 Go ServerStatus 的配置、运行状态和采集控制 API。",
},
"servers": []any{map[string]any{"url": "/", "description": "当前 ServerStatus 实例"}},
"paths": paths,
"components": map[string]any{
"securitySchemes": map[string]any{
"bearerAuth": map[string]any{"type": "http", "scheme": "bearer", "bearerFormat": "ADMIN_TOKEN"},
},
"schemas": openAPISchemas(),
},
}
}
func openAPISchemas() map[string]any {
stringProperty := func(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
integerProperty := func(description string, minimum, maximum int) map[string]any {
property := map[string]any{"type": "integer", "description": description, "minimum": minimum}
if maximum > 0 {
property["maximum"] = maximum
}
return property
}
server := map[string]any{
"type": "object", "required": collectionSpecs["servers"].required,
"properties": map[string]any{
"username": stringProperty("唯一客户端用户名"), "name": stringProperty("节点显示名称"),
"type": stringProperty("虚拟化或节点类型"), "host": stringProperty("主机标识"),
"location": stringProperty("位置"), "password": stringProperty("客户端密码"),
"monthstart": integerProperty("月流量重置日", 1, 28), "disabled": map[string]any{"type": "boolean", "default": false},
},
}
monitor := map[string]any{
"type": "object", "required": collectionSpecs["monitors"].required,
"properties": map[string]any{
"name": stringProperty("监测名称"), "host": stringProperty("HTTP(S) URL 或 TCP 地址"),
"type": stringProperty("https、http 或 tcp"), "interval": integerProperty("客户端探测间隔秒数", 1, 0),
},
}
sslcert := map[string]any{
"type": "object", "required": collectionSpecs["sslcerts"].required,
"properties": map[string]any{
"name": stringProperty("证书名称"), "domain": stringProperty("域名或 URL"),
"port": integerProperty("TLS 端口", 1, 65535), "interval": integerProperty("检查间隔秒数", 1, 0),
"callback": stringProperty("告警回调 URL 前缀"),
},
}
watchdog := map[string]any{
"type": "object", "required": collectionSpecs["watchdog"].required,
"properties": map[string]any{
"name": stringProperty("告警名称"), "rule": stringProperty("兼容 Exprtk 的状态表达式"),
"interval": integerProperty("通知冷却秒数", 1, 0), "callback": stringProperty("告警回调 URL 前缀"),
},
}
return map[string]any{
"Server": server, "Monitor": monitor, "SSLCert": sslcert, "Watchdog": watchdog,
"Config": map[string]any{
"type": "object", "required": []string{"servers", "monitors", "sslcerts", "watchdog"},
"properties": map[string]any{
"servers": arraySchema("Server"), "monitors": arraySchema("Monitor"),
"sslcerts": arraySchema("SSLCert"), "watchdog": arraySchema("Watchdog"),
},
},
"Error": map[string]any{
"type": "object", "required": []string{"ok", "error"},
"properties": map[string]any{"ok": map[string]any{"type": "boolean", "const": false}, "error": map[string]any{"type": "string"}, "details": map[string]any{}},
},
}
}
func titleWord(value string) string {
if value == "" {
return ""
}
return strings.ToUpper(value[:1]) + value[1:]
}
func schemaRef(name string) map[string]any {
return map[string]any{"$ref": "#/components/schemas/" + name}
}
func arraySchema(name string) map[string]any {
return map[string]any{"type": "array", "items": schemaRef(name)}
}
func requestBody(schemaName string) map[string]any {
return map[string]any{
"required": true,
"content": map[string]any{"application/json": map[string]any{"schema": schemaRef(schemaName)}},
}
}
func objectResponse(description string) map[string]any {
return map[string]any{"200": jsonResponse(description, map[string]any{"type": "object"}), "4XX": jsonResponse("请求错误", schemaRef("Error"))}
}
func configResponse(status, description string) map[string]any {
return statusResponse(status, description, map[string]any{"config": schemaRef("Config")})
}
func collectionResponse(key, schemaName, description string) map[string]any {
return statusResponse("200", description, map[string]any{key: arraySchema(schemaName)})
}
func itemResponse(status, key, schemaName, description string) map[string]any {
return statusResponse(status, description, map[string]any{
key: schemaRef(schemaName),
"reloaded": map[string]any{"type": "boolean", "const": true},
"pid": map[string]any{"type": "integer", "minimum": 1},
"config": schemaRef("Config"),
})
}
func statusResponse(status, description string, properties map[string]any) map[string]any {
required := []string{"ok"}
allProperties := map[string]any{"ok": map[string]any{"type": "boolean", "const": true}}
for key, property := range properties {
allProperties[key] = property
required = append(required, key)
}
return map[string]any{
status: jsonResponse(description, map[string]any{
"type": "object", "required": required, "properties": allProperties,
}),
"4XX": jsonResponse("请求错误", schemaRef("Error")),
}
}
func jsonResponse(description string, schema map[string]any) map[string]any {
return map[string]any{"description": description, "content": map[string]any{"application/json": map[string]any{"schema": schema}}}
}
func publicOperation(operationID, summary string, responses map[string]any) map[string]any {
return map[string]any{"operationId": operationID, "summary": summary, "security": []any{}, "responses": responses}
}
func protectedOperation(operationID, summary string, body map[string]any, responses map[string]any) map[string]any {
operation := map[string]any{
"operationId": operationID, "summary": summary,
"security": []any{map[string]any{"bearerAuth": []any{}}}, "responses": responses,
}
if body != nil {
operation["requestBody"] = body
}
return operation
}
func withParameters(operation map[string]any, parameters ...map[string]any) map[string]any {
items := make([]any, 0, len(parameters))
for _, parameter := range parameters {
items = append(items, parameter)
}
operation["parameters"] = items
return operation
}
func pathParameter(name, description string) map[string]any {
return map[string]any{"name": name, "in": "path", "required": true, "description": description, "schema": map[string]any{"type": "string"}}
}
-322
View File
@@ -1,322 +0,0 @@
#include "argparse.h"
#if defined(__cplusplus)
extern "C" {
#endif
#define OPT_UNSET 1
static const char *
prefix_skip(const char *str, const char *prefix)
{
size_t len = strlen(prefix);
return strncmp(str, prefix, len) ? NULL : str + len;
}
int
prefix_cmp(const char *str, const char *prefix)
{
for (;; str++, prefix++)
if (!*prefix)
return 0;
else if (*str != *prefix)
return (unsigned char)*prefix - (unsigned char)*str;
}
static void
argparse_error(struct argparse *this_, const struct argparse_option *opt,
const char *reason)
{
if (!strncmp(this_->argv[0], "--", 2)) {
fprintf(stderr, "error: option `%s` %s\n", opt->long_name, reason);
exit(-1);
} else {
fprintf(stderr, "error: option `%c` %s\n", opt->short_name, reason);
exit(-1);
}
}
static int
argparse_getvalue(struct argparse *this_, const struct argparse_option *opt,
int flags)
{
const char *s = NULL;
if (!opt->value)
goto skipped;
switch (opt->type) {
case ARGPARSE_OPT_BOOLEAN:
if (flags & OPT_UNSET) {
*(int *)opt->value = *(int *)opt->value - 1;
} else {
*(int *)opt->value = *(int *)opt->value + 1;
}
if (*(int *)opt->value < 0) {
*(int *)opt->value = 0;
}
break;
case ARGPARSE_OPT_BIT:
if (flags & OPT_UNSET) {
*(int *)opt->value &= ~opt->data;
} else {
*(int *)opt->value |= opt->data;
}
break;
case ARGPARSE_OPT_STRING:
if (this_->optvalue) {
*(const char **)opt->value = this_->optvalue;
this_->optvalue = NULL;
} else if (this_->argc > 1) {
this_->argc--;
*(const char **)opt->value = *++this_->argv;
} else {
argparse_error(this_, opt, "requires a value");
}
break;
case ARGPARSE_OPT_INTEGER:
if (this_->optvalue) {
*(int *)opt->value = strtol(this_->optvalue, (char **)&s, 0);
this_->optvalue = NULL;
} else if (this_->argc > 1) {
this_->argc--;
*(int *)opt->value = strtol(*++this_->argv, (char **)&s, 0);
} else {
argparse_error(this_, opt, "requires a value");
}
if (*s)
argparse_error(this_, opt, "expects a numerical value");
break;
default:
assert(0);
}
skipped:
if (opt->callback) {
return opt->callback(this_, opt);
}
return 0;
}
static void
argparse_options_check(const struct argparse_option *options)
{
for (; options->type != ARGPARSE_OPT_END; options++) {
switch (options->type) {
case ARGPARSE_OPT_END:
case ARGPARSE_OPT_BOOLEAN:
case ARGPARSE_OPT_BIT:
case ARGPARSE_OPT_INTEGER:
case ARGPARSE_OPT_STRING:
continue;
default:
fprintf(stderr, "wrong option type: %d", options->type);
break;
}
}
}
static int
argparse_short_opt(struct argparse *this_, const struct argparse_option *options)
{
for (; options->type != ARGPARSE_OPT_END; options++) {
if (options->short_name == *this_->optvalue) {
this_->optvalue = this_->optvalue[1] ? this_->optvalue + 1 : NULL;
return argparse_getvalue(this_, options, 0);
}
}
return -2;
}
static int
argparse_long_opt(struct argparse *this_, const struct argparse_option *options)
{
for (; options->type != ARGPARSE_OPT_END; options++) {
const char *rest;
int opt_flags = 0;
if (!options->long_name)
continue;
rest = prefix_skip(this_->argv[0] + 2, options->long_name);
if (!rest) {
// Negation allowed?
if (options->flags & OPT_NONEG) {
continue;
}
// Only boolean/bit allow negation.
if (options->type != ARGPARSE_OPT_BOOLEAN && options->type != ARGPARSE_OPT_BIT) {
continue;
}
if (!prefix_cmp(this_->argv[0] + 2, "no-")) {
rest = prefix_skip(this_->argv[0] + 2 + 3, options->long_name);
if (!rest)
continue;
opt_flags |= OPT_UNSET;
} else {
continue;
}
}
if (*rest) {
if (*rest != '=')
continue;
this_->optvalue = rest + 1;
}
return argparse_getvalue(this_, options, opt_flags);
}
return -2;
}
int
argparse_init(struct argparse *this_, struct argparse_option *options,
const char *usage, int flags)
{
memset(this_, 0, sizeof(*this_));
this_->options = options;
this_->usage = usage;
this_->flags = flags;
return 0;
}
int
argparse_parse(struct argparse *this_, int argc, const char **argv)
{
this_->argc = argc - 1;
this_->argv = argv + 1;
this_->out = argv;
argparse_options_check(this_->options);
for (; this_->argc; this_->argc--, this_->argv++) {
const char *arg = this_->argv[0];
if (arg[0] != '-' || !arg[1]) {
if (this_->flags & ARGPARSE_STOP_AT_NON_OPTION) {
goto end;
}
// if it's not option or is a single char '-', copy verbatimly
this_->out[this_->cpidx++] = this_->argv[0];
continue;
}
// short option
if (arg[1] != '-') {
this_->optvalue = arg + 1;
switch (argparse_short_opt(this_, this_->options)) {
case -1:
break;
case -2:
goto unknown;
}
while (this_->optvalue) {
switch (argparse_short_opt(this_, this_->options)) {
case -1:
break;
case -2:
goto unknown;
}
}
continue;
}
// if '--' presents
if (!arg[2]) {
this_->argc--;
this_->argv++;
break;
}
// long option
switch (argparse_long_opt(this_, this_->options)) {
case -1:
break;
case -2:
goto unknown;
}
continue;
unknown:
fprintf(stderr, "error: unknown option `%s`\n", this_->argv[0]);
argparse_usage(this_);
exit(0);
}
end:
memmove(this_->out + this_->cpidx, this_->argv,
this_->argc * sizeof(*this_->out));
this_->out[this_->cpidx + this_->argc] = NULL;
return this_->cpidx + this_->argc;
}
void
argparse_usage(struct argparse *this_)
{
fprintf(stdout, "Usage: %s\n", this_->usage);
fputc('\n', stdout);
const struct argparse_option *options;
// figure out best width
size_t usage_opts_width = 0;
size_t len;
options = this_->options;
for (; options->type != ARGPARSE_OPT_END; options++) {
len = 0;
if ((options)->short_name) {
len += 2;
}
if ((options)->short_name && (options)->long_name) {
len += 2; // separator ", "
}
if ((options)->long_name) {
len += strlen((options)->long_name) + 2;
}
if (options->type == ARGPARSE_OPT_INTEGER) {
len += strlen("=<int>");
} else if (options->type == ARGPARSE_OPT_STRING) {
len += strlen("=<str>");
}
len = ceil((float)len / 4) * 4;
if (usage_opts_width < len) {
usage_opts_width = len;
}
}
usage_opts_width += 4; // 4 spaces prefix
options = this_->options;
for (; options->type != ARGPARSE_OPT_END; options++) {
size_t pos;
int pad;
pos = fprintf(stdout, " ");
if (options->short_name) {
pos += fprintf(stdout, "-%c", options->short_name);
}
if (options->long_name && options->short_name) {
pos += fprintf(stdout, ", ");
}
if (options->long_name) {
pos += fprintf(stdout, "--%s", options->long_name);
}
if (options->type == ARGPARSE_OPT_INTEGER) {
pos += fprintf(stdout, "=<int>");
} else if (options->type == ARGPARSE_OPT_STRING) {
pos += fprintf(stdout, "=<str>");
}
if (pos <= usage_opts_width) {
pad = usage_opts_width - pos;
} else {
fputc('\n', stdout);
pad = usage_opts_width;
}
fprintf(stdout, "%*s%s\n", pad + 2, "", options->help);
}
}
int
argparse_help_cb(struct argparse *this_, const struct argparse_option *option)
{
(void)option;
argparse_usage(this_);
exit(0);
return 0;
}
#if defined(__cplusplus)
}
#endif
-46066
View File
File diff suppressed because it is too large Load Diff
-953
View File
@@ -1,953 +0,0 @@
/* vim: set et ts=3 sw=3 sts=3 ft=c:
*
* Copyright (C) 2012, 2013, 2014 James McLaughlin et al. All rights reserved.
* https://github.com/udp/json-parser
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*/
#include "json.h"
#ifdef _MSC_VER
#ifndef _CRT_SECURE_NO_WARNINGS
#define _CRT_SECURE_NO_WARNINGS
#endif
#endif
#ifdef __cplusplus
const struct _json_value json_value_none; /* zero-d by ctor */
#else
const struct _json_value json_value_none = { 0 };
#endif
#include <stdio.h>
#include <string.h>
#include <ctype.h>
#include <math.h>
typedef unsigned short json_uchar;
static unsigned char hex_value (json_char c)
{
if (isdigit(c))
return c - '0';
switch (c) {
case 'a': case 'A': return 0x0A;
case 'b': case 'B': return 0x0B;
case 'c': case 'C': return 0x0C;
case 'd': case 'D': return 0x0D;
case 'e': case 'E': return 0x0E;
case 'f': case 'F': return 0x0F;
default: return 0xFF;
}
}
typedef struct
{
unsigned long used_memory;
unsigned int uint_max;
unsigned long ulong_max;
json_settings settings;
int first_pass;
} json_state;
static void * default_alloc (size_t size, int zero, void * user_data)
{
return zero ? calloc (1, size) : malloc (size);
}
static void default_free (void * ptr, void * user_data)
{
free (ptr);
}
static void * json_alloc (json_state * state, unsigned long size, int zero)
{
if ((state->ulong_max - state->used_memory) < size)
return 0;
if (state->settings.max_memory
&& (state->used_memory += size) > state->settings.max_memory)
{
return 0;
}
return state->settings.mem_alloc (size, zero, state->settings.user_data);
}
static int new_value
(json_state * state, json_value ** top, json_value ** root, json_value ** alloc, json_type type)
{
json_value * value;
int values_size;
if (!state->first_pass)
{
value = *top = *alloc;
*alloc = (*alloc)->_reserved.next_alloc;
if (!*root)
*root = value;
switch (value->type)
{
case json_array:
if (! (value->u.array.values = (json_value **) json_alloc
(state, value->u.array.length * sizeof (json_value *), 0)) )
{
return 0;
}
value->u.array.length = 0;
break;
case json_object:
values_size = sizeof (*value->u.object.values) * value->u.object.length;
void *tmp_alloc = json_alloc(state, values_size + ((unsigned long) value->u.object.values), 0);
if (!tmp_alloc)
{
return 0;
}
/* 避免违反严格别名:通过中间变量复制 */
memcpy(&value->u.object.values, &tmp_alloc, sizeof(void*));
char *obj_mem = (char*)value->u.object.values + values_size;
memcpy(&value->_reserved.object_mem, &obj_mem, sizeof(char*));
value->u.object.length = 0;
break;
case json_string:
if (! (value->u.string.ptr = (json_char *) json_alloc
(state, (value->u.string.length + 1) * sizeof (json_char), 0)) )
{
return 0;
}
value->u.string.length = 0;
break;
default:
break;
};
return 1;
}
value = (json_value *) json_alloc (state, sizeof (json_value), 1);
if (!value)
return 0;
if (!*root)
*root = value;
value->type = type;
value->parent = *top;
if (*alloc)
(*alloc)->_reserved.next_alloc = value;
*alloc = *top = value;
return 1;
}
#define e_off \
((int) (i - cur_line_begin))
#define whitespace \
case '\n': ++ cur_line; cur_line_begin = i; \
case ' ': case '\t': case '\r'
#define string_add(b) \
do { if (!state.first_pass) string [string_length] = b; ++ string_length; } while (0);
static const long
flag_next = 1 << 0,
flag_reproc = 1 << 1,
flag_need_comma = 1 << 2,
flag_seek_value = 1 << 3,
flag_escaped = 1 << 4,
flag_string = 1 << 5,
flag_need_colon = 1 << 6,
flag_done = 1 << 7,
flag_num_negative = 1 << 8,
flag_num_zero = 1 << 9,
flag_num_e = 1 << 10,
flag_num_e_got_sign = 1 << 11,
flag_num_e_negative = 1 << 12,
flag_line_comment = 1 << 13,
flag_block_comment = 1 << 14;
json_value * json_parse_ex (json_settings * settings,
const json_char * json,
size_t length,
char * error_buf)
{
json_char error [json_error_max];
unsigned int cur_line;
const json_char * cur_line_begin, * i, * end;
json_value * top, * root, * alloc = 0;
json_state state = { 0 };
long flags;
long num_digits = 0, num_e = 0;
json_int_t num_fraction = 0;
/* Skip UTF-8 BOM
*/
if (length >= 3 && ((unsigned char) json [0]) == 0xEF
&& ((unsigned char) json [1]) == 0xBB
&& ((unsigned char) json [2]) == 0xBF)
{
json += 3;
length -= 3;
}
error[0] = '\0';
end = (json + length);
memcpy (&state.settings, settings, sizeof (json_settings));
if (!state.settings.mem_alloc)
state.settings.mem_alloc = default_alloc;
if (!state.settings.mem_free)
state.settings.mem_free = default_free;
memset (&state.uint_max, 0xFF, sizeof (state.uint_max));
memset (&state.ulong_max, 0xFF, sizeof (state.ulong_max));
state.uint_max -= 8; /* limit of how much can be added before next check */
state.ulong_max -= 8;
for (state.first_pass = 1; state.first_pass >= 0; -- state.first_pass)
{
json_uchar uchar;
unsigned char uc_b1, uc_b2, uc_b3, uc_b4;
json_char * string = 0;
unsigned int string_length = 0;
top = root = 0;
flags = flag_seek_value;
cur_line = 1;
cur_line_begin = json;
for (i = json ;; ++ i)
{
json_char b = (i == end ? 0 : *i);
if (flags & flag_string)
{
if (!b)
{ sprintf (error, "Unexpected EOF in string (at %d:%d)", cur_line, e_off);
goto e_failed;
}
if (string_length > state.uint_max)
goto e_overflow;
if (flags & flag_escaped)
{
flags &= ~ flag_escaped;
switch (b)
{
case 'b': string_add ('\b'); break;
case 'f': string_add ('\f'); break;
case 'n': string_add ('\n'); break;
case 'r': string_add ('\r'); break;
case 't': string_add ('\t'); break;
case 'u':
if (end - i < 4 ||
(uc_b1 = hex_value (*++ i)) == 0xFF || (uc_b2 = hex_value (*++ i)) == 0xFF
|| (uc_b3 = hex_value (*++ i)) == 0xFF || (uc_b4 = hex_value (*++ i)) == 0xFF)
{
sprintf (error, "Invalid character value `%c` (at %d:%d)", b, cur_line, e_off);
goto e_failed;
}
uc_b1 = uc_b1 * 16 + uc_b2;
uc_b2 = uc_b3 * 16 + uc_b4;
uchar = ((json_char) uc_b1) * 256 + uc_b2;
if (sizeof (json_char) >= sizeof (json_uchar) || (uc_b1 == 0 && uc_b2 <= 0x7F))
{
string_add ((json_char) uchar);
break;
}
if (uchar <= 0x7FF)
{
if (state.first_pass)
string_length += 2;
else
{ string [string_length ++] = 0xC0 | ((uc_b2 & 0xC0) >> 6) | ((uc_b1 & 0x7) << 2);
string [string_length ++] = 0x80 | (uc_b2 & 0x3F);
}
break;
}
if (state.first_pass)
string_length += 3;
else
{ string [string_length ++] = 0xE0 | ((uc_b1 & 0xF0) >> 4);
string [string_length ++] = 0x80 | ((uc_b1 & 0xF) << 2) | ((uc_b2 & 0xC0) >> 6);
string [string_length ++] = 0x80 | (uc_b2 & 0x3F);
}
break;
default:
string_add (b);
};
continue;
}
if (b == '\\')
{
flags |= flag_escaped;
continue;
}
if (b == '"')
{
if (!state.first_pass)
string [string_length] = 0;
flags &= ~ flag_string;
string = 0;
switch (top->type)
{
case json_string:
top->u.string.length = string_length;
flags |= flag_next;
break;
case json_object:
if (state.first_pass)
{
json_char *adv = (json_char*)top->u.object.values;
adv += string_length + 1;
memcpy(&top->u.object.values, &adv, sizeof(json_char*));
}
else
{
top->u.object.values[top->u.object.length].name = (json_char *)top->_reserved.object_mem;
top->u.object.values[top->u.object.length].name_length = string_length;
json_char *adv2 = (json_char*)top->_reserved.object_mem;
adv2 += string_length + 1;
memcpy(&top->_reserved.object_mem, &adv2, sizeof(json_char*));
}
flags |= flag_seek_value | flag_need_colon;
continue;
default:
break;
};
}
else
{
string_add (b);
continue;
}
}
if (state.settings.settings & json_enable_comments)
{
if (flags & (flag_line_comment | flag_block_comment))
{
if (flags & flag_line_comment)
{
if (b == '\r' || b == '\n' || !b)
{
flags &= ~ flag_line_comment;
-- i; /* so null can be reproc'd */
}
continue;
}
if (flags & flag_block_comment)
{
if (!b)
{ sprintf (error, "%d:%d: Unexpected EOF in block comment", cur_line, e_off);
goto e_failed;
}
if (b == '*' && i < (end - 1) && i [1] == '/')
{
flags &= ~ flag_block_comment;
++ i; /* skip closing sequence */
}
continue;
}
}
else if (b == '/')
{
if (! (flags & (flag_seek_value | flag_done)) && top->type != json_object)
{
sprintf (error, "%d:%d: Comment not allowed here", cur_line, e_off);
goto e_failed;
}
if (++ i == end)
{ sprintf (error, "%d:%d: EOF unexpected", cur_line, e_off);
goto e_failed;
}
switch (b = *i)
{
case '/':
flags |= flag_line_comment;
continue;
case '*':
flags |= flag_block_comment;
continue;
default:
sprintf (error, "%d:%d: Unexpected `%c` in comment opening sequence", cur_line, e_off, b);
goto e_failed;
};
}
}
if (flags & flag_done)
{
if (!b)
break;
switch (b)
{
whitespace:
continue;
default:
sprintf (error, "%d:%d: Trailing garbage: `%c`", cur_line, e_off, b);
goto e_failed;
};
}
if (flags & flag_seek_value)
{
switch (b)
{
whitespace:
continue;
case ']':
if (top->type == json_array)
flags = (flags & ~ (flag_need_comma | flag_seek_value)) | flag_next;
else
{ sprintf (error, "%d:%d: Unexpected ]", cur_line, e_off);
goto e_failed;
}
break;
default:
if (flags & flag_need_comma)
{
if (b == ',')
{ flags &= ~ flag_need_comma;
continue;
}
else
{ sprintf (error, "%d:%d: Expected , before %c", cur_line, e_off, b);
goto e_failed;
}
}
if (flags & flag_need_colon)
{
if (b == ':')
{ flags &= ~ flag_need_colon;
continue;
}
else
{ sprintf (error, "%d:%d: Expected : before %c", cur_line, e_off, b);
goto e_failed;
}
}
flags &= ~ flag_seek_value;
switch (b)
{
case '{':
if (!new_value (&state, &top, &root, &alloc, json_object))
goto e_alloc_failure;
continue;
case '[':
if (!new_value (&state, &top, &root, &alloc, json_array))
goto e_alloc_failure;
flags |= flag_seek_value;
continue;
case '"':
if (!new_value (&state, &top, &root, &alloc, json_string))
goto e_alloc_failure;
flags |= flag_string;
string = top->u.string.ptr;
string_length = 0;
continue;
case 't':
if ((end - i) < 3 || *(++ i) != 'r' || *(++ i) != 'u' || *(++ i) != 'e')
goto e_unknown_value;
if (!new_value (&state, &top, &root, &alloc, json_boolean))
goto e_alloc_failure;
top->u.boolean = 1;
flags |= flag_next;
break;
case 'f':
if ((end - i) < 4 || *(++ i) != 'a' || *(++ i) != 'l' || *(++ i) != 's' || *(++ i) != 'e')
goto e_unknown_value;
if (!new_value (&state, &top, &root, &alloc, json_boolean))
goto e_alloc_failure;
flags |= flag_next;
break;
case 'n':
if ((end - i) < 3 || *(++ i) != 'u' || *(++ i) != 'l' || *(++ i) != 'l')
goto e_unknown_value;
if (!new_value (&state, &top, &root, &alloc, json_null))
goto e_alloc_failure;
flags |= flag_next;
break;
default:
if (isdigit (b) || b == '-')
{
if (!new_value (&state, &top, &root, &alloc, json_integer))
goto e_alloc_failure;
if (!state.first_pass)
{
while (isdigit (b) || b == '+' || b == '-'
|| b == 'e' || b == 'E' || b == '.')
{
if ( (++ i) == end)
{
b = 0;
break;
}
b = *i;
}
flags |= flag_next | flag_reproc;
break;
}
flags &= ~ (flag_num_negative | flag_num_e |
flag_num_e_got_sign | flag_num_e_negative |
flag_num_zero);
num_digits = 0;
num_fraction = 0;
num_e = 0;
if (b != '-')
{
flags |= flag_reproc;
break;
}
flags |= flag_num_negative;
continue;
}
else
{ sprintf (error, "%d:%d: Unexpected %c when seeking value", cur_line, e_off, b);
goto e_failed;
}
};
};
}
else
{
switch (top->type)
{
case json_object:
switch (b)
{
whitespace:
continue;
case '"':
if (flags & flag_need_comma)
{
sprintf (error, "%d:%d: Expected , before \"", cur_line, e_off);
goto e_failed;
}
flags |= flag_string;
string = (json_char *) top->_reserved.object_mem;
string_length = 0;
break;
case '}':
flags = (flags & ~ flag_need_comma) | flag_next;
break;
case ',':
if (flags & flag_need_comma)
{
flags &= ~ flag_need_comma;
break;
}
default:
sprintf (error, "%d:%d: Unexpected `%c` in object", cur_line, e_off, b);
goto e_failed;
};
break;
case json_integer:
case json_double:
if (isdigit (b))
{
++ num_digits;
if (top->type == json_integer || flags & flag_num_e)
{
if (! (flags & flag_num_e))
{
if (flags & flag_num_zero)
{ sprintf (error, "%d:%d: Unexpected `0` before `%c`", cur_line, e_off, b);
goto e_failed;
}
if (num_digits == 1 && b == '0')
flags |= flag_num_zero;
}
else
{
flags |= flag_num_e_got_sign;
num_e = (num_e * 10) + (b - '0');
continue;
}
top->u.integer = (top->u.integer * 10) + (b - '0');
continue;
}
num_fraction = (num_fraction * 10) + (b - '0');
continue;
}
if (b == '+' || b == '-')
{
if ( (flags & flag_num_e) && !(flags & flag_num_e_got_sign))
{
flags |= flag_num_e_got_sign;
if (b == '-')
flags |= flag_num_e_negative;
continue;
}
}
else if (b == '.' && top->type == json_integer)
{
if (!num_digits)
{ sprintf (error, "%d:%d: Expected digit before `.`", cur_line, e_off);
goto e_failed;
}
top->type = json_double;
top->u.dbl = (double) top->u.integer;
num_digits = 0;
continue;
}
if (! (flags & flag_num_e))
{
if (top->type == json_double)
{
if (!num_digits)
{ sprintf (error, "%d:%d: Expected digit after `.`", cur_line, e_off);
goto e_failed;
}
top->u.dbl += ((double) num_fraction) / (pow (10, (double) num_digits));
}
if (b == 'e' || b == 'E')
{
flags |= flag_num_e;
if (top->type == json_integer)
{
top->type = json_double;
top->u.dbl = (double) top->u.integer;
}
num_digits = 0;
flags &= ~ flag_num_zero;
continue;
}
}
else
{
if (!num_digits)
{ sprintf (error, "%d:%d: Expected digit after `e`", cur_line, e_off);
goto e_failed;
}
top->u.dbl *= pow (10, (double) (flags & flag_num_e_negative ? - num_e : num_e));
}
if (flags & flag_num_negative)
{
if (top->type == json_integer)
top->u.integer = - top->u.integer;
else
top->u.dbl = - top->u.dbl;
}
flags |= flag_next | flag_reproc;
break;
default:
break;
};
}
if (flags & flag_reproc)
{
flags &= ~ flag_reproc;
-- i;
}
if (flags & flag_next)
{
flags = (flags & ~ flag_next) | flag_need_comma;
if (!top->parent)
{
/* root value done */
flags |= flag_done;
continue;
}
if (top->parent->type == json_array)
flags |= flag_seek_value;
if (!state.first_pass)
{
json_value * parent = top->parent;
switch (parent->type)
{
case json_object:
parent->u.object.values
[parent->u.object.length].value = top;
break;
case json_array:
parent->u.array.values
[parent->u.array.length] = top;
break;
default:
break;
};
}
if ( (++ top->parent->u.array.length) > state.uint_max)
goto e_overflow;
top = top->parent;
continue;
}
}
alloc = root;
}
return root;
e_unknown_value:
sprintf (error, "%d:%d: Unknown value", cur_line, e_off);
goto e_failed;
e_alloc_failure:
strcpy (error, "Memory allocation failure");
goto e_failed;
e_overflow:
sprintf (error, "%d:%d: Too long (caught overflow)", cur_line, e_off);
goto e_failed;
e_failed:
if (error_buf)
{
if (*error)
strcpy (error_buf, error);
else
strcpy (error_buf, "Unknown error");
}
if (state.first_pass)
alloc = root;
while (alloc)
{
top = alloc->_reserved.next_alloc;
state.settings.mem_free (alloc, state.settings.user_data);
alloc = top;
}
if (!state.first_pass)
json_value_free_ex (&state.settings, root);
return 0;
}
json_value * json_parse (const json_char * json, size_t length)
{
json_settings settings = { 0 };
return json_parse_ex (&settings, json, length, 0);
}
void json_value_free_ex (json_settings * settings, json_value * value)
{
json_value * cur_value;
if (!value)
return;
value->parent = 0;
while (value)
{
switch (value->type)
{
case json_array:
if (!value->u.array.length)
{
settings->mem_free (value->u.array.values, settings->user_data);
break;
}
value = value->u.array.values [-- value->u.array.length];
continue;
case json_object:
if (!value->u.object.length)
{
settings->mem_free (value->u.object.values, settings->user_data);
break;
}
value = value->u.object.values [-- value->u.object.length].value;
continue;
case json_string:
settings->mem_free (value->u.string.ptr, settings->user_data);
break;
default:
break;
};
cur_value = value;
value = value->parent;
settings->mem_free (cur_value, settings->user_data);
}
}
void json_value_free (json_value * value)
{
json_settings settings = { 0 };
settings.mem_free = default_free;
json_value_free_ex (&settings, value);
}
-1176
View File
File diff suppressed because it is too large Load Diff
-157
View File
@@ -1,157 +0,0 @@
#ifndef MAIN_H
#define MAIN_H
#include <stdint.h>
#include "server.h"
class CConfig
{
public:
bool m_Verbose;
char m_aConfigFile[1024];
char m_aWebDir[1024];
char m_aTemplateFile[1024];
char m_aJSONFile[1024];
char m_aBindAddr[256];
int m_Port;
CConfig();
};
class CMain
{
CConfig m_Config;
CServer m_Server;
struct CClient
{
bool m_Active;
bool m_Disabled;
bool m_Connected;
int m_ClientNetID;
int m_ClientNetType;
char m_aUsername[128];
char m_aName[128];
char m_aType[128];
char m_aHost[128];
char m_aLocation[128];
char m_aPassword[128];
int m_aMonthStart; //track month network traffic. by: https://cpp.la
int64_t m_LastNetworkIN; //restore month traffic info.
int64_t m_LastNetworkOUT; //restore month traffic info.
int64_t m_TimeConnected;
int64_t m_LastUpdate;
int64_t m_AlarmLastTime; //record last alarm time.
struct CStats
{
bool m_Online4;
bool m_Online6;
// bool m_IpStatus delete ip_status check, Duplicate packet loss rate detection
// mh361 or mh370, mourn mh370, 2014-03-08 01:20 lost from all over the world. by:https://cpp.la
int64_t m_Uptime;
double m_Load_1;
double m_Load_5;
double m_Load_15;
double m_ping_10010;
double m_ping_189;
double m_ping_10086;
int64_t m_time_10010;
int64_t m_time_189;
int64_t m_time_10086;
int64_t m_NetworkRx;
int64_t m_NetworkTx;
int64_t m_NetworkIN;
int64_t m_NetworkOUT;
int64_t m_MemTotal;
int64_t m_MemUsed;
int64_t m_SwapTotal;
int64_t m_SwapUsed;
int64_t m_HDDTotal;
int64_t m_HDDUsed;
int64_t m_tcpCount;
int64_t m_udpCount;
int64_t m_processCount;
int64_t m_threadCount;
int64_t m_IORead;
int64_t m_IOWrite;
int64_t m_CPUCores;
double m_CPU;
char m_aCustom[1024];
char m_aCPUModel[192];
// OS name reported by client (e.g. linux/windows/darwin/freebsd)
char m_aOS[64];
// Options
bool m_Pong;
} m_Stats;
} m_aClients[NET_MAX_CLIENTS];
struct CWatchDog{
char m_aName[128];
char m_aRule[128];
int m_aInterval;
char m_aCallback[1024];
} m_aCWatchDogs[NET_MAX_CLIENTS];
struct CMonitors{
char m_aName[128];
char m_aHost[128];
int m_aInterval;
char m_aType[128];
} m_aCMonitors[NET_MAX_CLIENTS];
public:
struct CSSLCerts{
char m_aName[128];
char m_aDomain[256];
int m_aPort;
int m_aInterval; // seconds
char m_aCallback[1024];
int64_t m_aExpireTS; // epoch seconds cache
int64_t m_aLastCheck; // last check time
int64_t m_aLastAlarm7;
int64_t m_aLastAlarm3;
int64_t m_aLastAlarm1;
int m_aHostnameMismatch; // 1: 域名与证书不匹配
int64_t m_aLastAlarmMismatch; // 上次不匹配告警时间
} m_aCSSLCerts[NET_MAX_CLIENTS];
struct CJSONUpdateThreadData
{
CClient *pClients;
CConfig *pConfig;
CWatchDog *pWatchDogs;
CMain *pMain;
volatile short m_ReloadRequired;
} m_JSONUpdateThreadData, m_OfflineAlarmThreadData;
static void JSONUpdateThread(void *pUser);
static void offlineAlarmThread(void *pUser);
public:
CMain(CConfig Config);
void OnNewClient(int ClienNettID, int ClientID);
void OnDelClient(int ClientNetID);
int HandleMessage(int ClientNetID, char *pMessage);
int ReadConfig();
int Run();
CWatchDog *Watchdog(int ruleID) { return &m_aCWatchDogs[ruleID]; }
CMonitors *Monitors(int ruleID) { return &m_aCMonitors[ruleID]; }
CSSLCerts *SSLCert(int ruleID) { return &m_aCSSLCerts[ruleID]; }
void WatchdogMessage(int ClientNetID,
double load_1, double load_5, double load_15, double ping_10010, double ping_189, double ping_10086,
double time_10010, double time_189, double time_10086, double tcp_count, double udp_count, double process_count, double thread_count,
double network_rx, double network_tx, double network_in, double network_out, double last_network_in, double last_network_out,
double memory_total, double memory_used,double swap_total, double swap_used, double hdd_total,
double hdd_used, double io_read, double io_write, double cpu,double online4, double online6);
CClient *Client(int ClientID) { return &m_aClients[ClientID]; }
CClient *ClientNet(int ClientNetID);
const CConfig *Config() const { return &m_Config; }
int ClientNetToClient(int ClientNetID);
};
#endif
-463
View File
@@ -1,463 +0,0 @@
#include <math.h>
#include "netban.h"
bool CNetBan::StrAllnum(const char *pStr)
{
while(*pStr)
{
if(!(*pStr >= '0' && *pStr <= '9'))
return false;
pStr++;
}
return true;
}
CNetBan::CNetHash::CNetHash(const NETADDR *pAddr)
{
if(pAddr->type==NETTYPE_IPV4)
m_Hash = (pAddr->ip[0]+pAddr->ip[1]+pAddr->ip[2]+pAddr->ip[3])&0xFF;
else
m_Hash = (pAddr->ip[0]+pAddr->ip[1]+pAddr->ip[2]+pAddr->ip[3]+pAddr->ip[4]+pAddr->ip[5]+pAddr->ip[6]+pAddr->ip[7]+
pAddr->ip[8]+pAddr->ip[9]+pAddr->ip[10]+pAddr->ip[11]+pAddr->ip[12]+pAddr->ip[13]+pAddr->ip[14]+pAddr->ip[15])&0xFF;
m_HashIndex = 0;
}
CNetBan::CNetHash::CNetHash(const CNetRange *pRange)
{
m_Hash = 0;
m_HashIndex = 0;
for(int i = 0; pRange->m_LB.ip[i] == pRange->m_UB.ip[i]; ++i)
{
m_Hash += pRange->m_LB.ip[i];
++m_HashIndex;
}
m_Hash &= 0xFF;
}
int CNetBan::CNetHash::MakeHashArray(const NETADDR *pAddr, CNetHash aHash[17])
{
int Length = pAddr->type==NETTYPE_IPV4 ? 4 : 16;
aHash[0].m_Hash = 0;
aHash[0].m_HashIndex = 0;
for(int i = 1, Sum = 0; i <= Length; ++i)
{
Sum += pAddr->ip[i-1];
aHash[i].m_Hash = Sum&0xFF;
aHash[i].m_HashIndex = i%Length;
}
return Length;
}
template<class T, int HashCount>
typename CNetBan::CBan<T> *CNetBan::CBanPool<T, HashCount>::Add(const T *pData, const CBanInfo *pInfo, const CNetHash *pNetHash)
{
if(!m_pFirstFree)
return 0;
// create new ban
CBan<T> *pBan = m_pFirstFree;
pBan->m_Data = *pData;
pBan->m_Info = *pInfo;
pBan->m_NetHash = *pNetHash;
if(pBan->m_pNext)
pBan->m_pNext->m_pPrev = pBan->m_pPrev;
if(pBan->m_pPrev)
pBan->m_pPrev->m_pNext = pBan->m_pNext;
else
m_pFirstFree = pBan->m_pNext;
// add it to the hash list
if(m_paaHashList[pNetHash->m_HashIndex][pNetHash->m_Hash])
m_paaHashList[pNetHash->m_HashIndex][pNetHash->m_Hash]->m_pHashPrev = pBan;
pBan->m_pHashPrev = 0;
pBan->m_pHashNext = m_paaHashList[pNetHash->m_HashIndex][pNetHash->m_Hash];
m_paaHashList[pNetHash->m_HashIndex][pNetHash->m_Hash] = pBan;
// insert it into the used list
if(m_pFirstUsed)
{
for(CBan<T> *p = m_pFirstUsed; ; p = p->m_pNext)
{
if(p->m_Info.m_Expires == CBanInfo::EXPIRES_NEVER || (pInfo->m_Expires != CBanInfo::EXPIRES_NEVER && pInfo->m_Expires <= p->m_Info.m_Expires))
{
// insert before
pBan->m_pNext = p;
pBan->m_pPrev = p->m_pPrev;
if(p->m_pPrev)
p->m_pPrev->m_pNext = pBan;
else
m_pFirstUsed = pBan;
p->m_pPrev = pBan;
break;
}
if(!p->m_pNext)
{
// last entry
p->m_pNext = pBan;
pBan->m_pPrev = p;
pBan->m_pNext = 0;
break;
}
}
}
else
{
m_pFirstUsed = pBan;
pBan->m_pNext = pBan->m_pPrev = 0;
}
// update ban count
++m_CountUsed;
return pBan;
}
template<class T, int HashCount>
int CNetBan::CBanPool<T, HashCount>::Remove(CBan<T> *pBan)
{
if(pBan == 0)
return -1;
// remove from hash list
if(pBan->m_pHashNext)
pBan->m_pHashNext->m_pHashPrev = pBan->m_pHashPrev;
if(pBan->m_pHashPrev)
pBan->m_pHashPrev->m_pHashNext = pBan->m_pHashNext;
else
m_paaHashList[pBan->m_NetHash.m_HashIndex][pBan->m_NetHash.m_Hash] = pBan->m_pHashNext;
pBan->m_pHashNext = pBan->m_pHashPrev = 0;
// remove from used list
if(pBan->m_pNext)
pBan->m_pNext->m_pPrev = pBan->m_pPrev;
if(pBan->m_pPrev)
pBan->m_pPrev->m_pNext = pBan->m_pNext;
else
m_pFirstUsed = pBan->m_pNext;
// add to recycle list
if(m_pFirstFree)
m_pFirstFree->m_pPrev = pBan;
pBan->m_pPrev = 0;
pBan->m_pNext = m_pFirstFree;
m_pFirstFree = pBan;
// update ban count
--m_CountUsed;
return 0;
}
template<class T, int HashCount>
void CNetBan::CBanPool<T, HashCount>::Update(CBan<CDataType> *pBan, const CBanInfo *pInfo)
{
pBan->m_Info = *pInfo;
// remove from used list
if(pBan->m_pNext)
pBan->m_pNext->m_pPrev = pBan->m_pPrev;
if(pBan->m_pPrev)
pBan->m_pPrev->m_pNext = pBan->m_pNext;
else
m_pFirstUsed = pBan->m_pNext;
// insert it into the used list
if(m_pFirstUsed)
{
for(CBan<T> *p = m_pFirstUsed; ; p = p->m_pNext)
{
if(p->m_Info.m_Expires == CBanInfo::EXPIRES_NEVER || (pInfo->m_Expires != CBanInfo::EXPIRES_NEVER && pInfo->m_Expires <= p->m_Info.m_Expires))
{
// insert before
pBan->m_pNext = p;
pBan->m_pPrev = p->m_pPrev;
if(p->m_pPrev)
p->m_pPrev->m_pNext = pBan;
else
m_pFirstUsed = pBan;
p->m_pPrev = pBan;
break;
}
if(!p->m_pNext)
{
// last entry
p->m_pNext = pBan;
pBan->m_pPrev = p;
pBan->m_pNext = 0;
break;
}
}
}
else
{
m_pFirstUsed = pBan;
pBan->m_pNext = pBan->m_pPrev = 0;
}
}
template<class T, int HashCount>
void CNetBan::CBanPool<T, HashCount>::Reset()
{
mem_zero(m_paaHashList, sizeof(m_paaHashList));
mem_zero(m_aBans, sizeof(m_aBans));
m_pFirstUsed = 0;
m_CountUsed = 0;
for(int i = 1; i < MAX_BANS-1; ++i)
{
m_aBans[i].m_pNext = &m_aBans[i+1];
m_aBans[i].m_pPrev = &m_aBans[i-1];
}
m_aBans[0].m_pNext = &m_aBans[1];
m_aBans[MAX_BANS-1].m_pPrev = &m_aBans[MAX_BANS-2];
m_pFirstFree = &m_aBans[0];
}
template<class T, int HashCount>
typename CNetBan::CBan<T> *CNetBan::CBanPool<T, HashCount>::Get(int Index) const
{
if(Index < 0 || Index >= Num())
return 0;
for(CNetBan::CBan<T> *pBan = m_pFirstUsed; pBan; pBan = pBan->m_pNext, --Index)
{
if(Index == 0)
return pBan;
}
return 0;
}
template<class T>
void CNetBan::MakeBanInfo(const CBan<T> *pBan, char *pBuf, unsigned BuffSize, int Type) const
{
if(pBan == 0 || pBuf == 0)
{
if(BuffSize > 0)
pBuf[0] = 0;
return;
}
// build type based part
char aBuf[256];
if(Type == MSGTYPE_PLAYER)
str_copy(aBuf, "You have been banned", sizeof(aBuf));
else
{
char aTemp[256];
switch(Type)
{
case MSGTYPE_LIST:
str_format(aBuf, sizeof(aBuf), "%s banned", NetToString(&pBan->m_Data, aTemp, sizeof(aTemp))); break;
case MSGTYPE_BANADD:
str_format(aBuf, sizeof(aBuf), "banned %s", NetToString(&pBan->m_Data, aTemp, sizeof(aTemp))); break;
case MSGTYPE_BANREM:
str_format(aBuf, sizeof(aBuf), "unbanned %s", NetToString(&pBan->m_Data, aTemp, sizeof(aTemp))); break;
default:
aBuf[0] = 0;
}
}
// add info part
if(pBan->m_Info.m_Expires != CBanInfo::EXPIRES_NEVER)
{
int Mins = ((pBan->m_Info.m_Expires-time_timestamp()) + 59) / 60;
if(Mins <= 1)
str_format(pBuf, BuffSize, "%s for 1 minute (%s)", aBuf, pBan->m_Info.m_aReason);
else
str_format(pBuf, BuffSize, "%s for %d minutes (%s)", aBuf, Mins, pBan->m_Info.m_aReason);
}
else
str_format(pBuf, BuffSize, "%s for life (%s)", aBuf, pBan->m_Info.m_aReason);
}
template<class T>
int CNetBan::Ban(T *pBanPool, const typename T::CDataType *pData, int Seconds, const char *pReason)
{
// do not ban localhost
if(NetMatch(pData, &m_LocalhostIPV4) || NetMatch(pData, &m_LocalhostIPV6))
{
dbg_msg("net_ban", "ban failed (localhost)");
return -1;
}
int Stamp = Seconds > 0 ? time_timestamp()+Seconds : CBanInfo::EXPIRES_NEVER;
// set up info
CBanInfo Info = {0};
Info.m_Expires = Stamp;
str_copy(Info.m_aReason, pReason, sizeof(Info.m_aReason));
// check if it already exists
CNetHash NetHash(pData);
CBan<typename T::CDataType> *pBan = pBanPool->Find(pData, &NetHash);
if(pBan)
{
// adjust the ban
pBanPool->Update(pBan, &Info);
char aBuf[128];
MakeBanInfo(pBan, aBuf, sizeof(aBuf), MSGTYPE_LIST);
dbg_msg("net_ban", aBuf);
return 1;
}
// add ban and print result
pBan = pBanPool->Add(pData, &Info, &NetHash);
if(pBan)
{
char aBuf[128];
MakeBanInfo(pBan, aBuf, sizeof(aBuf), MSGTYPE_BANADD);
dbg_msg("net_ban", aBuf);
return 0;
}
else
dbg_msg("net_ban", "ban failed (full banlist)");
return -1;
}
template<class T>
int CNetBan::Unban(T *pBanPool, const typename T::CDataType *pData)
{
CNetHash NetHash(pData);
CBan<typename T::CDataType> *pBan = pBanPool->Find(pData, &NetHash);
if(pBan)
{
char aBuf[256];
MakeBanInfo(pBan, aBuf, sizeof(aBuf), MSGTYPE_BANREM);
pBanPool->Remove(pBan);
dbg_msg("net_ban", aBuf);
return 0;
}
else
dbg_msg("net_ban", "unban failed (invalid entry)");
return -1;
}
void CNetBan::Init()
{
m_BanAddrPool.Reset();
m_BanRangePool.Reset();
net_host_lookup("localhost", &m_LocalhostIPV4, NETTYPE_IPV4);
net_host_lookup("localhost", &m_LocalhostIPV6, NETTYPE_IPV6);
}
void CNetBan::Update()
{
int Now = time_timestamp();
// remove expired bans
char aBuf[256], aNetStr[256];
while(m_BanAddrPool.First() && m_BanAddrPool.First()->m_Info.m_Expires != CBanInfo::EXPIRES_NEVER && m_BanAddrPool.First()->m_Info.m_Expires < Now)
{
str_format(aBuf, sizeof(aBuf), "ban %s expired", NetToString(&m_BanAddrPool.First()->m_Data, aNetStr, sizeof(aNetStr)));
dbg_msg("net_ban", aBuf);
m_BanAddrPool.Remove(m_BanAddrPool.First());
}
while(m_BanRangePool.First() && m_BanRangePool.First()->m_Info.m_Expires != CBanInfo::EXPIRES_NEVER && m_BanRangePool.First()->m_Info.m_Expires < Now)
{
str_format(aBuf, sizeof(aBuf), "ban %s expired", NetToString(&m_BanRangePool.First()->m_Data, aNetStr, sizeof(aNetStr)));
dbg_msg("net_ban", aBuf);
m_BanRangePool.Remove(m_BanRangePool.First());
}
}
int CNetBan::BanAddr(const NETADDR *pAddr, int Seconds, const char *pReason)
{
return Ban(&m_BanAddrPool, pAddr, Seconds, pReason);
}
int CNetBan::BanRange(const CNetRange *pRange, int Seconds, const char *pReason)
{
if(pRange->IsValid())
return Ban(&m_BanRangePool, pRange, Seconds, pReason);
dbg_msg("net_ban", "ban failed (invalid range)");
return -1;
}
int CNetBan::UnbanByAddr(const NETADDR *pAddr)
{
return Unban(&m_BanAddrPool, pAddr);
}
int CNetBan::UnbanByRange(const CNetRange *pRange)
{
if(pRange->IsValid())
return Unban(&m_BanRangePool, pRange);
dbg_msg("net_ban", "ban failed (invalid range)");
return -1;
}
int CNetBan::UnbanByIndex(int Index)
{
int Result;
char aBuf[256];
CBanAddr *pBan = m_BanAddrPool.Get(Index);
if(pBan)
{
NetToString(&pBan->m_Data, aBuf, sizeof(aBuf));
Result = m_BanAddrPool.Remove(pBan);
}
else
{
CBanRange *pBan = m_BanRangePool.Get(Index-m_BanAddrPool.Num());
if(pBan)
{
NetToString(&pBan->m_Data, aBuf, sizeof(aBuf));
Result = m_BanRangePool.Remove(pBan);
}
else
{
dbg_msg("net_ban", "unban failed (invalid index)");
return -1;
}
}
char aMsg[256];
str_format(aMsg, sizeof(aMsg), "unbanned index %i (%s)", Index, aBuf);
dbg_msg("net_ban", aMsg);
return Result;
}
void CNetBan::UnbanAll()
{
m_BanAddrPool.Reset();
m_BanRangePool.Reset();
}
bool CNetBan::IsBanned(const NETADDR *pAddr, char *pBuf, unsigned BufferSize) const
{
CNetHash aHash[17];
int Length = CNetHash::MakeHashArray(pAddr, aHash);
// check ban adresses
CBanAddr *pBan = m_BanAddrPool.Find(pAddr, &aHash[Length]);
if(pBan)
{
MakeBanInfo(pBan, pBuf, BufferSize, MSGTYPE_PLAYER);
return true;
}
// check ban ranges
for(int i = Length-1; i >= 0; --i)
{
for(CBanRange *pBan = m_BanRangePool.First(&aHash[i]); pBan; pBan = pBan->m_pHashNext)
{
if(NetMatch(&pBan->m_Data, pAddr, i, Length))
{
MakeBanInfo(pBan, pBuf, BufferSize, MSGTYPE_PLAYER);
return true;
}
}
}
return false;
}
-179
View File
@@ -1,179 +0,0 @@
#ifndef NETBAN_H
#define NETBAN_H
#include <system.h>
inline int NetComp(const NETADDR *pAddr1, const NETADDR *pAddr2)
{
return mem_comp(pAddr1, pAddr2, pAddr1->type==NETTYPE_IPV4 ? 8 : 20);
}
class CNetRange
{
public:
NETADDR m_LB;
NETADDR m_UB;
bool IsValid() const { return m_LB.type == m_UB.type && NetComp(&m_LB, &m_UB) < 0; }
};
inline int NetComp(const CNetRange *pRange1, const CNetRange *pRange2)
{
return NetComp(&pRange1->m_LB, &pRange2->m_LB) || NetComp(&pRange1->m_UB, &pRange2->m_UB);
}
class CNetBan
{
protected:
bool NetMatch(const NETADDR *pAddr1, const NETADDR *pAddr2) const
{
return NetComp(pAddr1, pAddr2) == 0;
}
bool NetMatch(const CNetRange *pRange, const NETADDR *pAddr, int Start, int Length) const
{
return pRange->m_LB.type == pAddr->type && (Start == 0 || mem_comp(&pRange->m_LB.ip[0], &pAddr->ip[0], Start) == 0) &&
mem_comp(&pRange->m_LB.ip[Start], &pAddr->ip[Start], Length-Start) <= 0 && mem_comp(&pRange->m_UB.ip[Start], &pAddr->ip[Start], Length-Start) >= 0;
}
bool NetMatch(const CNetRange *pRange, const NETADDR *pAddr) const
{
return NetMatch(pRange, pAddr, 0, pRange->m_LB.type==NETTYPE_IPV4 ? 4 : 16);
}
const char *NetToString(const NETADDR *pData, char *pBuffer, unsigned BufferSize) const
{
char aAddrStr[NETADDR_MAXSTRSIZE];
net_addr_str(pData, aAddrStr, sizeof(aAddrStr), false);
str_format(pBuffer, BufferSize, "'%s'", aAddrStr);
return pBuffer;
}
const char *NetToString(const CNetRange *pData, char *pBuffer, unsigned BufferSize) const
{
char aAddrStr1[NETADDR_MAXSTRSIZE], aAddrStr2[NETADDR_MAXSTRSIZE];
net_addr_str(&pData->m_LB, aAddrStr1, sizeof(aAddrStr1), false);
net_addr_str(&pData->m_UB, aAddrStr2, sizeof(aAddrStr2), false);
str_format(pBuffer, BufferSize, "'%s' - '%s'", aAddrStr1, aAddrStr2);
return pBuffer;
}
// todo: move?
static bool StrAllnum(const char *pStr);
class CNetHash
{
public:
int m_Hash;
int m_HashIndex; // matching parts for ranges, 0 for addr
CNetHash() {}
CNetHash(const NETADDR *pAddr);
CNetHash(const CNetRange *pRange);
static int MakeHashArray(const NETADDR *pAddr, CNetHash aHash[17]);
};
struct CBanInfo
{
enum
{
EXPIRES_NEVER=-1,
REASON_LENGTH=64,
};
int m_Expires;
char m_aReason[REASON_LENGTH];
};
template<class T> struct CBan
{
T m_Data;
CBanInfo m_Info;
CNetHash m_NetHash;
// hash list
CBan *m_pHashNext;
CBan *m_pHashPrev;
// used or free list
CBan *m_pNext;
CBan *m_pPrev;
};
template<class T, int HashCount> class CBanPool
{
public:
typedef T CDataType;
CBan<CDataType> *Add(const CDataType *pData, const CBanInfo *pInfo, const CNetHash *pNetHash);
int Remove(CBan<CDataType> *pBan);
void Update(CBan<CDataType> *pBan, const CBanInfo *pInfo);
void Reset();
int Num() const { return m_CountUsed; }
bool IsFull() const { return m_CountUsed == MAX_BANS; }
CBan<CDataType> *First() const { return m_pFirstUsed; }
CBan<CDataType> *First(const CNetHash *pNetHash) const { return m_paaHashList[pNetHash->m_HashIndex][pNetHash->m_Hash]; }
CBan<CDataType> *Find(const CDataType *pData, const CNetHash *pNetHash) const
{
for(CBan<CDataType> *pBan = m_paaHashList[pNetHash->m_HashIndex][pNetHash->m_Hash]; pBan; pBan = pBan->m_pHashNext)
{
if(NetComp(&pBan->m_Data, pData) == 0)
return pBan;
}
return 0;
}
CBan<CDataType> *Get(int Index) const;
private:
enum
{
MAX_BANS=1024,
};
CBan<CDataType> *m_paaHashList[HashCount][256];
CBan<CDataType> m_aBans[MAX_BANS];
CBan<CDataType> *m_pFirstFree;
CBan<CDataType> *m_pFirstUsed;
int m_CountUsed;
};
typedef CBanPool<NETADDR, 1> CBanAddrPool;
typedef CBanPool<CNetRange, 16> CBanRangePool;
typedef CBan<NETADDR> CBanAddr;
typedef CBan<CNetRange> CBanRange;
template<class T> void MakeBanInfo(const CBan<T> *pBan, char *pBuf, unsigned BuffSize, int Type) const;
template<class T> int Ban(T *pBanPool, const typename T::CDataType *pData, int Seconds, const char *pReason);
template<class T> int Unban(T *pBanPool, const typename T::CDataType *pData);
CBanAddrPool m_BanAddrPool;
CBanRangePool m_BanRangePool;
NETADDR m_LocalhostIPV4, m_LocalhostIPV6;
public:
enum
{
MSGTYPE_PLAYER=0,
MSGTYPE_LIST,
MSGTYPE_BANADD,
MSGTYPE_BANREM,
};
virtual ~CNetBan() {}
void Init();
void Update();
virtual int BanAddr(const NETADDR *pAddr, int Seconds, const char *pReason);
virtual int BanRange(const CNetRange *pRange, int Seconds, const char *pReason);
int UnbanByAddr(const NETADDR *pAddr);
int UnbanByRange(const CNetRange *pRange);
int UnbanByIndex(int Index);
void UnbanAll();
bool IsBanned(const NETADDR *pAddr, char *pBuf, unsigned BufferSize) const;
};
#endif
-144
View File
@@ -1,144 +0,0 @@
#include <system.h>
#include "netban.h"
#include "network.h"
bool CNetwork::Open(NETADDR BindAddr, CNetBan *pNetBan)
{
// zero out the whole structure
mem_zero(this, sizeof(*this));
m_Socket.type = NETTYPE_INVALID;
m_Socket.ipv4sock = -1;
m_Socket.ipv6sock = -1;
m_pNetBan = pNetBan;
// open socket
m_Socket = net_tcp_create(BindAddr);
if(!m_Socket.type)
return false;
if(net_tcp_listen(m_Socket, NET_MAX_CLIENTS))
return false;
net_set_non_blocking(m_Socket);
for(int i = 0; i < NET_MAX_CLIENTS; i++)
m_aSlots[i].m_Connection.Reset();
return true;
}
void CNetwork::SetCallbacks(NETFUNC_NEWCLIENT pfnNewClient, NETFUNC_DELCLIENT pfnDelClient, void *pUser)
{
m_pfnNewClient = pfnNewClient;
m_pfnDelClient = pfnDelClient;
m_UserPtr = pUser;
}
int CNetwork::Close()
{
for(int i = 0; i < NET_MAX_CLIENTS; i++)
m_aSlots[i].m_Connection.Disconnect("Closing connection.");
net_tcp_close(m_Socket);
return 0;
}
int CNetwork::Drop(int ClientID, const char *pReason)
{
if(m_pfnDelClient)
m_pfnDelClient(ClientID, pReason, m_UserPtr);
m_aSlots[ClientID].m_Connection.Disconnect(pReason);
return 0;
}
int CNetwork::AcceptClient(NETSOCKET Socket, const NETADDR *pAddr)
{
char aError[256] = { 0 };
int FreeSlot = -1;
// look for free slot or multiple client
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(FreeSlot == -1 && m_aSlots[i].m_Connection.State() == NET_CONNSTATE_OFFLINE)
FreeSlot = i;
if(m_aSlots[i].m_Connection.State() != NET_CONNSTATE_OFFLINE)
{
if(net_addr_comp(pAddr, m_aSlots[i].m_Connection.PeerAddress()) == 0)
{
str_copy(aError, "Only one client per IP allowed.", sizeof(aError));
break;
}
}
}
// accept client
if(!aError[0] && FreeSlot != -1)
{
m_aSlots[FreeSlot].m_Connection.Init(Socket, pAddr);
if(m_pfnNewClient)
m_pfnNewClient(FreeSlot, m_UserPtr);
return 0;
}
// reject client
if(!aError[0])
str_copy(aError, "No free slot available.", sizeof(aError));
net_tcp_send(Socket, aError, str_length(aError));
net_tcp_close(Socket);
return -1;
}
int CNetwork::Update()
{
NETSOCKET Socket;
NETADDR Addr;
if(net_tcp_accept(m_Socket, &Socket, &Addr) > 0)
{
// check if we should just drop the packet
char aBuf[128];
if(NetBan() && NetBan()->IsBanned(&Addr, aBuf, sizeof(aBuf)))
{
// banned, reply with a message and drop
net_tcp_send(Socket, aBuf, str_length(aBuf));
net_tcp_close(Socket);
}
else
AcceptClient(Socket, &Addr);
}
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(m_aSlots[i].m_Connection.State() == NET_CONNSTATE_ONLINE)
m_aSlots[i].m_Connection.Update();
if(m_aSlots[i].m_Connection.State() == NET_CONNSTATE_ERROR)
Drop(i, m_aSlots[i].m_Connection.ErrorString());
}
return 0;
}
int CNetwork::Recv(char *pLine, int MaxLength, int *pClientID)
{
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(m_aSlots[i].m_Connection.State() == NET_CONNSTATE_ONLINE && m_aSlots[i].m_Connection.Recv(pLine, MaxLength))
{
if(pClientID)
*pClientID = i;
return 1;
}
}
return 0;
}
int CNetwork::Send(int ClientID, const char *pLine)
{
if(m_aSlots[ClientID].m_Connection.State() == NET_CONNSTATE_ONLINE)
return m_aSlots[ClientID].m_Connection.Send(pLine);
else
return -1;
}
-87
View File
@@ -1,87 +0,0 @@
#ifndef NETWORK_H
#define NETWORK_H
enum
{
NET_CONNSTATE_OFFLINE=0,
NET_CONNSTATE_CONNECT=1,
NET_CONNSTATE_PENDING=2,
NET_CONNSTATE_ONLINE=3,
NET_CONNSTATE_ERROR=4,
NET_MAX_PACKETSIZE = 1400,
NET_MAX_CLIENTS = 512
};
typedef int (*NETFUNC_DELCLIENT)(int ClientID, const char* pReason, void *pUser);
typedef int (*NETFUNC_NEWCLIENT)(int ClientID, void *pUser);
class CNetworkClient
{
private:
int m_State;
NETADDR m_PeerAddr;
NETSOCKET m_Socket;
char m_aBuffer[NET_MAX_PACKETSIZE];
int m_BufferOffset;
char m_aErrorString[256];
bool m_LineEndingDetected;
char m_aLineEnding[3];
public:
void Init(NETSOCKET Socket, const NETADDR *pAddr);
void Disconnect(const char *pReason);
int State() const { return m_State; }
const NETADDR *PeerAddress() const { return &m_PeerAddr; }
const char *ErrorString() const { return m_aErrorString; }
void Reset();
int Update();
int Send(const char *pLine);
int Recv(char *pLine, int MaxLength);
};
class CNetwork
{
private:
struct CSlot
{
CNetworkClient m_Connection;
};
NETSOCKET m_Socket;
class CNetBan *m_pNetBan;
CSlot m_aSlots[NET_MAX_CLIENTS];
NETFUNC_NEWCLIENT m_pfnNewClient;
NETFUNC_DELCLIENT m_pfnDelClient;
void *m_UserPtr;
public:
void SetCallbacks(NETFUNC_NEWCLIENT pfnNewClient, NETFUNC_DELCLIENT pfnDelClient, void *pUser);
//
bool Open(NETADDR BindAddr, CNetBan *pNetBan);
int Close();
//
int Recv(char *pLine, int MaxLength, int *pClientID = 0);
int Send(int ClientID, const char *pLine);
int Update();
//
int AcceptClient(NETSOCKET Socket, const NETADDR *pAddr);
int Drop(int ClientID, const char *pReason);
// status requests
const NETADDR *ClientAddr(int ClientID) const { return m_aSlots[ClientID].m_Connection.PeerAddress(); }
const NETSOCKET *Socket() const { return &m_Socket; }
class CNetBan *NetBan() const { return m_pNetBan; }
};
#endif
-184
View File
@@ -1,184 +0,0 @@
#include <system.h>
#include "network.h"
void CNetworkClient::Reset()
{
m_State = NET_CONNSTATE_OFFLINE;
mem_zero(&m_PeerAddr, sizeof(m_PeerAddr));
m_aErrorString[0] = 0;
m_Socket.type = NETTYPE_INVALID;
m_Socket.ipv4sock = -1;
m_Socket.ipv6sock = -1;
m_aBuffer[0] = 0;
m_BufferOffset = 0;
m_LineEndingDetected = false;
#if defined(CONF_FAMILY_WINDOWS)
m_aLineEnding[0] = '\r';
m_aLineEnding[1] = '\n';
m_aLineEnding[2] = 0;
#else
m_aLineEnding[0] = '\n';
m_aLineEnding[1] = 0;
m_aLineEnding[2] = 0;
#endif
}
void CNetworkClient::Init(NETSOCKET Socket, const NETADDR *pAddr)
{
Reset();
m_Socket = Socket;
net_set_non_blocking(m_Socket);
m_PeerAddr = *pAddr;
m_State = NET_CONNSTATE_ONLINE;
}
void CNetworkClient::Disconnect(const char *pReason)
{
if(State() == NET_CONNSTATE_OFFLINE)
return;
if(pReason && pReason[0])
Send(pReason);
net_tcp_close(m_Socket);
Reset();
}
int CNetworkClient::Update()
{
if(State() == NET_CONNSTATE_ONLINE)
{
if((int)(sizeof(m_aBuffer)) <= m_BufferOffset)
{
m_State = NET_CONNSTATE_ERROR;
str_copy(m_aErrorString, "too weak connection (out of buffer)", sizeof(m_aErrorString));
return -1;
}
int Bytes = net_tcp_recv(m_Socket, m_aBuffer+m_BufferOffset, (int)(sizeof(m_aBuffer))-m_BufferOffset);
if(Bytes > 0)
{
m_BufferOffset += Bytes;
}
else if(Bytes < 0)
{
if(net_would_block()) // no data received
return 0;
m_State = NET_CONNSTATE_ERROR; // error
str_copy(m_aErrorString, "connection failure", sizeof(m_aErrorString));
return -1;
}
else
{
m_State = NET_CONNSTATE_ERROR;
str_copy(m_aErrorString, "remote end closed the connection", sizeof(m_aErrorString));
return -1;
}
}
return 0;
}
int CNetworkClient::Recv(char *pLine, int MaxLength)
{
if(State() == NET_CONNSTATE_ONLINE)
{
if(m_BufferOffset)
{
// find message start
int StartOffset = 0;
while(m_aBuffer[StartOffset] == '\r' || m_aBuffer[StartOffset] == '\n')
{
// detect clients line ending format
if(!m_LineEndingDetected)
{
m_aLineEnding[0] = m_aBuffer[StartOffset];
if(StartOffset+1 < m_BufferOffset && (m_aBuffer[StartOffset+1] == '\r' || m_aBuffer[StartOffset+1] == '\n') &&
m_aBuffer[StartOffset] != m_aBuffer[StartOffset+1])
m_aLineEnding[1] = m_aBuffer[StartOffset+1];
m_LineEndingDetected = true;
}
if(++StartOffset >= m_BufferOffset)
{
m_BufferOffset = 0;
return 0;
}
}
// find message end
int EndOffset = StartOffset;
while(m_aBuffer[EndOffset] != '\r' && m_aBuffer[EndOffset] != '\n')
{
if(++EndOffset >= m_BufferOffset)
{
if(StartOffset > 0)
{
mem_move(m_aBuffer, m_aBuffer+StartOffset, m_BufferOffset-StartOffset);
m_BufferOffset -= StartOffset;
}
return 0;
}
}
// extract message and update buffer
if(MaxLength-1 < EndOffset-StartOffset)
{
if(StartOffset > 0)
{
mem_move(m_aBuffer, m_aBuffer+StartOffset, m_BufferOffset-StartOffset);
m_BufferOffset -= StartOffset;
}
return 0;
}
mem_copy(pLine, m_aBuffer+StartOffset, EndOffset-StartOffset);
pLine[EndOffset-StartOffset] = 0;
str_sanitize_cc(pLine);
mem_move(m_aBuffer, m_aBuffer+EndOffset, m_BufferOffset-EndOffset);
m_BufferOffset -= EndOffset;
return 1;
}
}
return 0;
}
int CNetworkClient::Send(const char *pLine)
{
if(State() != NET_CONNSTATE_ONLINE)
return -1;
char aBuf[1024];
str_copy(aBuf, pLine, (int)(sizeof(aBuf))-2);
int Length = str_length(aBuf);
aBuf[Length] = m_aLineEnding[0];
aBuf[Length+1] = m_aLineEnding[1];
aBuf[Length+2] = m_aLineEnding[2];
Length += 3;
const char *pData = aBuf;
while(1)
{
int Send = net_tcp_send(m_Socket, pData, Length);
if(Send < 0)
{
m_State = NET_CONNSTATE_ERROR;
str_copy(m_aErrorString, "failed to send packet", sizeof(m_aErrorString));
return -1;
}
if(Send >= Length)
break;
pData += Send;
Length -= Send;
}
return 0;
}
-204
View File
@@ -1,204 +0,0 @@
#include <system.h>
#include "netban.h"
#include "network.h"
#include "main.h"
#include "server.h"
int CServer::NewClientCallback(int ClientID, void *pUser)
{
CServer *pThis = (CServer *)pUser;
char aAddrStr[NETADDR_MAXSTRSIZE];
net_addr_str(pThis->m_Network.ClientAddr(ClientID), aAddrStr, sizeof(aAddrStr), true);
if(pThis->Main()->Config()->m_Verbose)
dbg_msg("server", "Connection accepted. ncid=%d addr=%s'", ClientID, aAddrStr);
pThis->m_aClients[ClientID].m_State = CClient::STATE_CONNECTED;
pThis->m_aClients[ClientID].m_TimeConnected = time_get();
pThis->m_Network.Send(ClientID, "Authentication required:");
return 0;
}
int CServer::DelClientCallback(int ClientID, const char *pReason, void *pUser)
{
CServer *pThis = (CServer *)pUser;
char aAddrStr[NETADDR_MAXSTRSIZE];
net_addr_str(pThis->m_Network.ClientAddr(ClientID), aAddrStr, sizeof(aAddrStr), true);
if(pThis->Main()->Config()->m_Verbose)
dbg_msg("server", "Client dropped. ncid=%d addr=%s reason='%s'", ClientID, aAddrStr, pReason);
if(pThis->m_aClients[ClientID].m_State == CClient::STATE_AUTHED)
pThis->Main()->OnDelClient(ClientID);
pThis->m_aClients[ClientID].m_State = CClient::STATE_EMPTY;
return 0;
}
int CServer::Init(CMain *pMain, const char *Bind, int Port)
{
m_pMain = pMain;
m_NetBan.Init();
for(int i = 0; i < NET_MAX_CLIENTS; i++)
m_aClients[i].m_State = CClient::STATE_EMPTY;
m_Ready = false;
if(Port == 0)
{
dbg_msg("server", "Will not bind to port 0.");
return 1;
}
NETADDR BindAddr;
if(Bind[0] && net_host_lookup(Bind, &BindAddr, NETTYPE_ALL) == 0)
{
// got bindaddr
BindAddr.type = NETTYPE_ALL;
BindAddr.port = Port;
}
else
{
mem_zero(&BindAddr, sizeof(BindAddr));
BindAddr.type = NETTYPE_ALL;
BindAddr.port = Port;
}
if(m_Network.Open(BindAddr, &m_NetBan))
{
m_Network.SetCallbacks(NewClientCallback, DelClientCallback, this);
m_Ready = true;
dbg_msg("server", "Bound to %s:%d", Bind, Port);
return 0;
}
else
dbg_msg("server", "Couldn't open socket. Port (%d) might already be in use.", Port);
return 1;
}
void CServer::Update()
{
if(!m_Ready)
return;
m_NetBan.Update();
m_Network.Update();
char aBuf[NET_MAX_PACKETSIZE];
int ClientID;
while(m_Network.Recv(aBuf, (int)(sizeof(aBuf))-1, &ClientID))
{
dbg_assert(m_aClients[ClientID].m_State != CClient::STATE_EMPTY, "Got message from empty slot.");
if(m_aClients[ClientID].m_State == CClient::STATE_CONNECTED)
{
int ID = -1;
char aUsername[128] = {0};
char aPassword[128] = {0};
const char *pTmp;
if(!(pTmp = str_find(aBuf, ":"))
|| (unsigned)(pTmp - aBuf) > sizeof(aUsername) || (unsigned)(str_length(pTmp) - 1) > sizeof(aPassword))
{
m_Network.NetBan()->BanAddr(m_Network.ClientAddr(ClientID), 60, "You're an idiot, go away.");
m_Network.Drop(ClientID, "Fuck off.");
return;
}
str_copy(aUsername, aBuf, pTmp - aBuf + 1);
str_copy(aPassword, pTmp + 1, sizeof(aPassword));
if(!*aUsername || !*aPassword)
{
m_Network.NetBan()->BanAddr(m_Network.ClientAddr(ClientID), 60, "You're an idiot, go away.");
m_Network.Drop(ClientID, "Username and password must not be blank.");
return;
}
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(!Main()->Client(i)->m_Active)
continue;
if(str_comp(Main()->Client(i)->m_aUsername, aUsername) == 0 && str_comp(Main()->Client(i)->m_aPassword, aPassword) == 0)
ID = i;
}
if(ID == -1)
{
m_Network.NetBan()->BanAddr(m_Network.ClientAddr(ClientID), 60, "Wrong username and/or password.");
m_Network.Drop(ClientID, "Wrong username and/or password.");
}
else if(Main()->Client(ID)->m_ClientNetID != -1)
{
m_Network.Drop(ClientID, "Only one connection per user allowed.");
}
else
{
m_aClients[ClientID].m_State = CClient::STATE_AUTHED;
m_aClients[ClientID].m_LastReceived = time_get();
m_Network.Send(ClientID, "Authentication successful. Access granted.");
if(m_Network.ClientAddr(ClientID)->type == NETTYPE_IPV4)
m_Network.Send(ClientID, "You are connecting via: IPv4");
else if(m_Network.ClientAddr(ClientID)->type == NETTYPE_IPV6)
m_Network.Send(ClientID, "You are connecting via: IPv6");
if(Main()->Config()->m_Verbose)
dbg_msg("server", "ncid=%d authed", ClientID);
Main()->OnNewClient(ClientID, ID);
}
}
else if(m_aClients[ClientID].m_State == CClient::STATE_AUTHED)
{
m_aClients[ClientID].m_LastReceived = time_get();
if(Main()->Config()->m_Verbose)
dbg_msg("server", "ncid=%d cmd='%s'", ClientID, aBuf);
if(str_comp(aBuf, "logout") == 0)
m_Network.Drop(ClientID, "Logout. Bye Bye ~");
else
Main()->HandleMessage(ClientID, aBuf);
}
}
for(int i = 0; i < NET_MAX_CLIENTS; ++i)
{
if(m_aClients[i].m_State == CClient::STATE_CONNECTED &&
time_get() > m_aClients[i].m_TimeConnected + 5 * time_freq())
{
m_Network.NetBan()->BanAddr(m_Network.ClientAddr(i), 30, "Authentication timeout.");
m_Network.Drop(i, "Authentication timeout.");
}
else if(m_aClients[i].m_State == CClient::STATE_AUTHED &&
time_get() > m_aClients[i].m_LastReceived + 15 * time_freq())
m_Network.Drop(i, "Timeout.");
}
}
void CServer::Send(int ClientID, const char *pLine)
{
if(!m_Ready)
return;
if(ClientID == -1)
{
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(m_aClients[i].m_State == CClient::STATE_AUTHED)
m_Network.Send(i, pLine);
}
}
else if(ClientID >= 0 && ClientID < NET_MAX_CLIENTS && m_aClients[ClientID].m_State == CClient::STATE_AUTHED)
m_Network.Send(ClientID, pLine);
}
void CServer::Shutdown()
{
if(!m_Ready)
return;
m_Network.Close();
}
-46
View File
@@ -1,46 +0,0 @@
#ifndef SERVER_H
#define SERVER_H
#include "netban.h"
#include "network.h"
class CServer
{
class CClient
{
public:
enum
{
STATE_EMPTY=0,
STATE_CONNECTED,
STATE_AUTHED,
};
int m_State;
int64 m_TimeConnected;
int64 m_LastReceived;
};
CClient m_aClients[NET_MAX_CLIENTS];
CNetwork m_Network;
CNetBan m_NetBan;
class CMain *m_pMain;
bool m_Ready;
static int NewClientCallback(int ClientID, void *pUser);
static int DelClientCallback(int ClientID, const char *pReason, void *pUser);
public:
int Init(CMain *pMain, const char *Bind, int Port);
void Update();
void Send(int ClientID, const char *pLine);
void Shutdown();
CNetwork *Network() { return &m_Network; }
CNetBan *NetBan() { return &m_NetBan; }
CMain *Main() { return m_pMain; }
};
#endif
-2001
View File
File diff suppressed because it is too large Load Diff
+204
View File
@@ -0,0 +1,204 @@
package main
import (
"crypto/tls"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
)
type CertState struct {
Config SSLCertConfig
ExpireTS int64
Mismatch bool
LastError string
LastCheck time.Time
Checking bool
LastAlarm7 time.Time
LastAlarm3 time.Time
LastAlarm1 time.Time
LastAlarmMismatch time.Time
}
func certKey(config SSLCertConfig) string {
return fmt.Sprintf("%s\x00%s\x00%d", config.Name, config.Domain, config.Port)
}
func (a *App) reconcileCerts(configs []SSLCertConfig) {
a.certMu.Lock()
defer a.certMu.Unlock()
next := make(map[string]*CertState, len(configs))
for _, config := range configs {
key := certKey(config)
if existing := a.certs[key]; existing != nil {
existing.Config = config
next[key] = existing
} else {
next[key] = &CertState{Config: config}
}
}
a.certs = next
}
func (a *App) sslLoop() {
a.runDueSSLChecks()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
a.runDueSSLChecks()
}
}
}
func (a *App) runDueSSLChecks() {
now := time.Now()
type dueCheck struct {
key string
config SSLCertConfig
}
due := make([]dueCheck, 0)
a.certMu.Lock()
for key, state := range a.certs {
interval := secondsDuration(state.Config.Interval)
if !state.Checking && (state.LastCheck.IsZero() || now.Sub(state.LastCheck) >= interval) {
state.Checking = true
due = append(due, dueCheck{key: key, config: state.Config})
}
}
a.certMu.Unlock()
for _, check := range due {
check := check
go a.executeSSLCheck(check.key, check.config)
}
}
func (a *App) executeSSLCheck(key string, config SSLCertConfig) {
expireTS, mismatch, err := checkCertificate(config)
now := time.Now()
type notification struct {
message string
}
alerts := make([]notification, 0, 2)
a.certMu.Lock()
state := a.certs[key]
if state == nil {
a.certMu.Unlock()
return
}
state.Checking = false
state.LastCheck = now
if err != nil {
state.LastError = err.Error()
a.certMu.Unlock()
a.wakeStatsWriter()
return
}
state.ExpireTS = expireTS
state.Mismatch = mismatch
state.LastError = ""
if config.Callback != "" && mismatch && (state.LastAlarmMismatch.IsZero() || now.Sub(state.LastAlarmMismatch) >= 24*time.Hour) {
state.LastAlarmMismatch = now
alerts = append(alerts, notification{message: fmt.Sprintf("【SSL证书域名不匹配】%s(%s) 证书域名与配置不一致", config.Name, config.Domain)})
}
days := int((expireTS - now.Unix()) / 86400)
if config.Callback != "" {
var lastAlarm *time.Time
switch {
case days <= 7 && days > 3:
lastAlarm = &state.LastAlarm7
case days <= 3 && days > 1:
lastAlarm = &state.LastAlarm3
case days <= 1:
lastAlarm = &state.LastAlarm1
}
if lastAlarm != nil && (lastAlarm.IsZero() || now.Sub(*lastAlarm) >= 20*time.Hour) {
*lastAlarm = now
expire := time.Unix(expireTS, 0).UTC().Format("2006-01-02 15:04:05")
alerts = append(alerts, notification{message: fmt.Sprintf("【SSL证书提醒】%s(%s) 将在 %d 天后(%s UTC) 到期", config.Name, config.Domain, days, expire)})
}
}
a.certMu.Unlock()
a.wakeStatsWriter()
for _, alert := range alerts {
if err := a.sendCallback(config.Callback, alert.message, "ServerStatusSSL"); err != nil {
a.logger.Printf("SSL certificate %q callback: %v", config.Name, err)
}
}
}
func checkCertificate(config SSLCertConfig) (int64, bool, error) {
host, err := certificateHost(config.Domain)
if err != nil {
return 0, false, err
}
address := net.JoinHostPort(host, strconv.Itoa(config.Port))
dialer := &net.Dialer{Timeout: 6 * time.Second}
connection, err := tls.DialWithDialer(dialer, "tcp", address, &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true, // VerifyHostname below preserves the legacy chain-independent check.
})
if err != nil {
return 0, false, err
}
defer connection.Close()
certificates := connection.ConnectionState().PeerCertificates
if len(certificates) == 0 {
return 0, false, fmt.Errorf("server returned no certificate")
}
leaf := certificates[0]
mismatch := leaf.VerifyHostname(host) != nil
return leaf.NotAfter.Unix(), mismatch, nil
}
func certificateHost(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("domain is empty")
}
if parsed, err := url.Parse(raw); err == nil && parsed.Hostname() != "" {
return parsed.Hostname(), nil
}
withoutPath := strings.SplitN(raw, "/", 2)[0]
if host, _, err := net.SplitHostPort(withoutPath); err == nil {
return strings.Trim(host, "[]"), nil
}
host := strings.Trim(withoutPath, "[]")
if host == "" {
return "", fmt.Errorf("domain is invalid")
}
return host, nil
}
func (a *App) sslSnapshot(configs []SSLCertConfig, now time.Time) []any {
a.certMu.RLock()
defer a.certMu.RUnlock()
result := make([]any, 0, len(configs))
for _, config := range configs {
entry := map[string]any{
"name": config.Name, "domain": config.Domain, "port": config.Port,
"expire_ts": int64(0), "expire_days": 0, "mismatch": false,
}
if state := a.certs[certKey(config)]; state != nil {
entry["expire_ts"] = state.ExpireTS
if state.ExpireTS != 0 {
entry["expire_days"] = int((state.ExpireTS - now.Unix()) / 86400)
}
entry["mismatch"] = state.Mismatch
if state.LastError != "" {
entry["error"] = state.LastError
}
}
result = append(result, entry)
}
return result
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"sync/atomic"
"testing"
"time"
)
func TestCertificateCheckAndSnapshot(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }))
defer server.Close()
parsed, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
port, _ := strconv.Atoi(parsed.Port())
config := SSLCertConfig{Name: "local", Domain: server.URL, Port: port, Interval: 1}
expireTS, _, err := checkCertificate(config)
if err != nil {
t.Fatal(err)
}
if expireTS <= time.Now().Unix() {
t.Fatalf("unexpected expiration %d", expireTS)
}
doc := minimalTestConfig()
doc["sslcerts"] = []any{map[string]any{"name": config.Name, "domain": config.Domain, "port": config.Port, "interval": 1}}
app := newTestApp(t, doc)
app.runDueSSLChecks()
eventually(t, 2*time.Second, func() bool {
app.certMu.RLock()
defer app.certMu.RUnlock()
state := app.certs[certKey(config)]
return state != nil && !state.LastCheck.IsZero() && state.ExpireTS > 0
})
certs := app.SnapshotStats()["sslcerts"].([]any)
if certs[0].(map[string]any)["expire_ts"].(int64) <= time.Now().Unix() {
t.Fatalf("certificate snapshot: %#v", certs[0])
}
}
func TestCallbackDelivery(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
calls.Add(1)
if request.Method != http.MethodPost || request.URL.Query().Get("message") == "" {
t.Errorf("unexpected callback request: %s %s", request.Method, request.URL.String())
}
w.WriteHeader(204)
}))
defer server.Close()
doc := minimalTestConfig()
doc["watchdog"] = []any{map[string]any{
"name": "cpu", "rule": "cpu>90", "interval": 60, "callback": server.URL + "?message=",
}}
app := newTestApp(t, doc)
app.nodeMu.Lock()
node := app.nodes["s01"]
node.Connected = true
node.Stats = AgentStats{CPU: 99}
node.HasUpdate = true
app.nodeMu.Unlock()
app.evaluateWatchdogs("s01", false)
eventually(t, time.Second, func() bool { return calls.Load() == 1 })
app.evaluateWatchdogs("s01", false)
time.Sleep(30 * time.Millisecond)
if calls.Load() != 1 {
t.Fatalf("watchdog cooldown failed: calls=%d", calls.Load())
}
}
+247
View File
@@ -0,0 +1,247 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"strings"
"time"
)
type AgentServer struct {
app *App
}
func NewAgentServer(app *App) *AgentServer {
return &AgentServer{app: app}
}
func (s *AgentServer) ListenAndServe() error {
listener, err := net.Listen("tcp", s.app.opts.AgentAddr)
if err != nil {
return err
}
s.app.logger.Printf("agent TCP listening on %s", listener.Addr())
return s.Serve(listener)
}
func (s *AgentServer) Serve(listener net.Listener) error {
defer listener.Close()
s.app.agentRunning.Store(true)
defer s.app.agentRunning.Store(false)
go func() {
<-s.app.ctx.Done()
_ = listener.Close()
}()
for {
conn, err := listener.Accept()
if err != nil {
if s.app.ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
go s.handleConnection(conn)
}
}
func (s *AgentServer) handleConnection(conn net.Conn) {
defer conn.Close()
if tcpConn, ok := conn.(*net.TCPConn); ok {
_ = tcpConn.SetNoDelay(true)
_ = tcpConn.SetKeepAlive(true)
_ = tcpConn.SetKeepAlivePeriod(10 * time.Second)
}
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
if _, err := io.WriteString(conn, "Authentication required:\n"); err != nil {
return
}
reader := bufio.NewReaderSize(conn, 64*1024)
credentials, err := reader.ReadString('\n')
if err != nil {
return
}
credentials = strings.TrimSpace(credentials)
parts := strings.SplitN(credentials, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
_, _ = io.WriteString(conn, "Wrong username and/or password.\n")
return
}
family := remoteFamily(conn.RemoteAddr())
connectionID, monitors, apiErr := s.app.connectAgent(parts[0], parts[1], conn, family)
if apiErr != nil {
_, _ = io.WriteString(conn, apiErr.Message+"\n")
return
}
username := parts[0]
defer s.app.disconnectAgent(username, conn, connectionID)
if _, err := io.WriteString(conn, "Authentication successful. Access granted.\n"); err != nil {
return
}
// Existing Python agents use recv() instead of a line reader during the
// handshake. Keep the auth and metadata packets separate for compatibility.
time.Sleep(20 * time.Millisecond)
var metadata strings.Builder
fmt.Fprintf(&metadata, "You are connecting via: IPv%d\n", family)
for index, monitor := range monitors {
payload := map[string]any{"name": monitor.Name, "host": monitor.Host, "interval": monitor.Interval, "type": monitor.Type, "monitor": index}
data, _ := json.Marshal(payload)
metadata.Write(data)
metadata.WriteByte('\n')
}
if _, err := io.WriteString(conn, metadata.String()); err != nil {
return
}
_ = conn.SetDeadline(time.Now().Add(20 * time.Second))
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 4096), maxRequestBody)
for scanner.Scan() {
_ = conn.SetDeadline(time.Now().Add(20 * time.Second))
line := strings.TrimSpace(scanner.Text())
switch {
case strings.HasPrefix(line, "update"):
body := strings.TrimSpace(strings.TrimPrefix(line, "update"))
var update AgentStats
if err := json.Unmarshal([]byte(body), &update); err != nil {
if s.app.agentPong(username, connectionID) {
_, _ = io.WriteString(conn, "1\n")
}
continue
}
if !s.app.updateAgent(username, connectionID, update) {
return
}
if s.app.agentPong(username, connectionID) {
_, _ = io.WriteString(conn, "0\n")
}
case strings.HasPrefix(line, "pong"):
value := strings.TrimSpace(strings.TrimPrefix(line, "pong"))
s.app.setAgentPong(username, connectionID, value == "1" || strings.EqualFold(value, "on"))
default:
if s.app.agentPong(username, connectionID) {
_, _ = io.WriteString(conn, "1\n")
}
}
}
}
func remoteFamily(address net.Addr) int {
host, _, err := net.SplitHostPort(address.String())
if err == nil {
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
return 6
}
}
return 4
}
func (a *App) connectAgent(username, password string, conn net.Conn, family int) (uint64, []MonitorConfig, *APIError) {
a.configMu.RLock()
defer a.configMu.RUnlock()
var config *ServerConfig
for index := range a.runtime.Servers {
if a.runtime.Servers[index].Username == username {
server := a.runtime.Servers[index]
config = &server
break
}
}
if config == nil || config.Password != password {
return 0, nil, &APIError{Status: 401, Message: "Wrong username and/or password."}
}
if config.Disabled {
return 0, nil, &APIError{Status: 403, Message: "Server is disabled."}
}
a.nodeMu.Lock()
defer a.nodeMu.Unlock()
node := a.nodes[username]
if node == nil {
return 0, nil, &APIError{Status: 404, Message: "Server is not configured."}
}
if node.Connected {
return 0, nil, &APIError{Status: 409, Message: "Only one connection per user allowed."}
}
id := a.connectionID.Add(1)
node.Connected = true
node.Connection = conn
node.ConnectionID = id
node.Family = family
node.HasUpdate = false
node.Pong = false
node.Online4 = family == 4
node.Online6 = family == 6
a.wakeStatsWriter()
return id, append([]MonitorConfig(nil), a.runtime.Monitors...), nil
}
func (a *App) disconnectAgent(username string, conn net.Conn, connectionID uint64) {
a.nodeMu.Lock()
node := a.nodes[username]
if node == nil || node.ConnectionID != connectionID || node.Connection != conn {
a.nodeMu.Unlock()
return
}
node.Connected = false
node.Connection = nil
node.Online4 = false
node.Online6 = false
node.HasUpdate = false
node.Pong = false
a.nodeMu.Unlock()
a.wakeStatsWriter()
time.AfterFunc(25*time.Second, func() {
if a.ctx.Err() != nil {
return
}
a.nodeMu.RLock()
current := a.nodes[username]
stillOffline := current != nil && !current.Connected && current.ConnectionID == connectionID
a.nodeMu.RUnlock()
if stillOffline {
a.evaluateWatchdogs(username, true)
}
})
}
func (a *App) updateAgent(username string, connectionID uint64, update AgentStats) bool {
a.nodeMu.Lock()
node := a.nodes[username]
if node == nil || !node.Connected || node.ConnectionID != connectionID {
a.nodeMu.Unlock()
return false
}
if update.Online4 != nil {
node.Online4 = *update.Online4
}
if update.Online6 != nil {
node.Online6 = *update.Online6
}
node.Stats = update
node.HasUpdate = true
node.LastUpdate = time.Now()
a.nodeMu.Unlock()
a.wakeStatsWriter()
a.evaluateWatchdogs(username, false)
return true
}
func (a *App) setAgentPong(username string, connectionID uint64, enabled bool) {
a.nodeMu.Lock()
defer a.nodeMu.Unlock()
if node := a.nodes[username]; node != nil && node.ConnectionID == connectionID {
node.Pong = enabled
}
}
func (a *App) agentPong(username string, connectionID uint64) bool {
a.nodeMu.RLock()
defer a.nodeMu.RUnlock()
node := a.nodes[username]
return node != nil && node.ConnectionID == connectionID && node.Pong
}
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"net"
"strings"
"testing"
"time"
)
func TestAgentProtocolAndTrafficState(t *testing.T) {
app := newTestApp(t, minimalTestConfig())
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
serveDone := make(chan error, 1)
go func() { serveDone <- NewAgentServer(app).Serve(listener) }()
connection, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer connection.Close()
reader := bufio.NewReader(connection)
readContains(t, reader, "Authentication required")
if _, err := fmt.Fprintln(connection, "s01:secret"); err != nil {
t.Fatal(err)
}
readContains(t, reader, "Authentication successful")
readContains(t, reader, "You are connecting via: IPv4")
readContains(t, reader, `"monitor":0`)
if _, err := fmt.Fprintln(connection, "pong on"); err != nil {
t.Fatal(err)
}
online6 := true
update := AgentStats{
Uptime: 90061, Load1: 1.25, Load5: 1, Load15: 0.75, CPU: 33.5, CPUCores: 4, CPUModel: "Test CPU",
MemoryTotal: 1024, MemoryUsed: 512, SwapTotal: 128, SwapUsed: 2, HDDTotal: 10000, HDDUsed: 4000,
NetworkRX: 123, NetworkTX: 456, NetworkIn: 1_000_000, NetworkOut: 2_000_000,
Ping10010: 1, Ping189: 2, Ping10086: 3, Time10010: 10, Time189: 20, Time10086: 30,
TCPCount: 10, UDPCount: 2, ProcessCount: 30, ThreadCount: 60, IORead: 1, IOWrite: 2,
OS: "linux", Custom: "example=12", Online6: &online6,
}
payload, _ := json.Marshal(update)
if _, err := fmt.Fprintf(connection, "update %s\n", payload); err != nil {
t.Fatal(err)
}
readContains(t, reader, "0")
eventually(t, time.Second, func() bool {
servers := app.SnapshotStats()["servers"].([]any)
server := servers[0].(map[string]any)
return server["online4"] == true && server["online6"] == true && server["cpu_model"] == "Test CPU" && server["uptime"] == "1 天"
})
result, apiErr := app.ResetTraffic("s01")
if apiErr != nil {
t.Fatal(apiErr)
}
stats := result["stats"].(map[string]any)
if stats["last_network_in"] != int64(1_000_000) {
t.Fatalf("traffic reset result: %#v", stats)
}
duplicate, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
duplicateReader := bufio.NewReader(duplicate)
readContains(t, duplicateReader, "Authentication required")
_, _ = fmt.Fprintln(duplicate, "s01:secret")
readContains(t, duplicateReader, "Only one connection per user")
_ = duplicate.Close()
wrong, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
wrongReader := bufio.NewReader(wrong)
readContains(t, wrongReader, "Authentication required")
_, _ = fmt.Fprintln(wrong, "s01:wrong")
readContains(t, wrongReader, "Wrong username")
_ = wrong.Close()
if apiErr := app.ReloadConfig(); apiErr != nil {
t.Fatal(apiErr)
}
readContains(t, reader, "Server reloading")
eventually(t, time.Second, func() bool {
server := app.SnapshotStats()["servers"].([]any)[0].(map[string]any)
return server["online4"] == false && server["online6"] == false
})
app.cancel()
_ = listener.Close()
select {
case err := <-serveDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("agent server did not stop")
}
}
func readContains(t *testing.T, reader *bufio.Reader, expected string) string {
t.Helper()
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read %q: %v", expected, err)
}
if !strings.Contains(line, expected) {
t.Fatalf("expected %q in %q", expected, line)
}
return line
}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func minimalTestConfig() ConfigDocument {
return ConfigDocument{
"servers": []any{
map[string]any{
"username": "s01", "name": "node1", "type": "kvm", "host": "host1",
"location": "CN", "password": "secret", "monthstart": 1,
},
},
"monitors": []any{
map[string]any{"name": "example", "host": "https://example.com", "interval": 60, "type": "https"},
},
"sslcerts": []any{},
"watchdog": []any{},
}
}
func newTestApp(t *testing.T, doc ConfigDocument) *App {
t.Helper()
directory := t.TempDir()
configPath := filepath.Join(directory, "config.json")
statsPath := filepath.Join(directory, "data", "stats.json")
webDir := filepath.Join(directory, "web")
if err := os.MkdirAll(webDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "index.html"), []byte("<!doctype html><title>test-ui</title>"), 0o644); err != nil {
t.Fatal(err)
}
data, err := json.MarshalIndent(doc, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatal(err)
}
app, err := NewApp(Options{
ConfigPath: configPath,
StatsPath: statsPath,
WebDir: webDir,
HTTPAddr: "127.0.0.1:0",
AgentAddr: "127.0.0.1:0",
AdminToken: "test-token",
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(app.Close)
return app
}
func performRequest(handler http.Handler, method, path, body, token string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
request.Header.Set("Content-Type", "application/json")
}
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func eventually(t *testing.T, timeout time.Duration, condition func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if condition() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("condition was not met before timeout")
}
+217
View File
@@ -0,0 +1,217 @@
package main
import (
"crypto/tls"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/expr-lang/expr"
)
func compileWatchdog(rule WatchdogConfig, index int) (CompiledWatchdog, error) {
normalized := normalizeLegacyExpression(rule.Rule)
program, err := expr.Compile(normalized, expr.Env(WatchdogEnvironment{}))
if err != nil {
return CompiledWatchdog{}, err
}
return CompiledWatchdog{
WatchdogConfig: rule,
Key: fmt.Sprintf("%d:%s", index, rule.Name),
Normalized: normalized,
Program: program,
}, nil
}
func normalizeLegacyExpression(input string) string {
var output strings.Builder
var quote rune
runes := []rune(input)
for index := 0; index < len(runes); index++ {
current := runes[index]
if quote != 0 {
output.WriteRune(current)
if current == quote && (index == 0 || runes[index-1] != '\\') {
quote = 0
}
continue
}
if current == '\'' || current == '"' {
quote = current
output.WriteRune(current)
continue
}
switch current {
case '&':
output.WriteString("&&")
if index+1 < len(runes) && runes[index+1] == '&' {
index++
}
case '|':
output.WriteString("||")
if index+1 < len(runes) && runes[index+1] == '|' {
index++
}
case '=':
previousOperator := index > 0 && strings.ContainsRune("!<>=", runes[index-1])
nextEqual := index+1 < len(runes) && runes[index+1] == '='
if previousOperator || nextEqual {
output.WriteRune(current)
} else {
output.WriteString("==")
}
default:
output.WriteRune(current)
}
}
return output.String()
}
func (a *App) evaluateWatchdogs(username string, offline bool) {
runtime := a.RuntimeSnapshot()
now := time.Now()
type pendingAlert struct {
rule WatchdogConfig
node ServerConfig
}
pending := make([]pendingAlert, 0)
a.nodeMu.Lock()
node := a.nodes[username]
if node == nil || (!offline && !node.Connected) || (offline && node.Connected) {
a.nodeMu.Unlock()
return
}
stats := node.Stats
if offline {
stats = AgentStats{}
}
environment := watchdogEnvironment(node.Config, stats, node.Online4, node.Online6, node.LastNetworkIn, node.LastNetworkOut)
for _, rule := range runtime.Watchdogs {
result, err := expr.Run(rule.Program, environment)
if err != nil || !expressionTruthy(result) {
continue
}
if last := node.AlarmLast[rule.Key]; !last.IsZero() && now.Sub(last) < secondsDuration(rule.Interval) {
continue
}
node.AlarmLast[rule.Key] = now
if rule.Callback != "" {
pending = append(pending, pendingAlert{rule: rule.WatchdogConfig, node: node.Config})
}
}
a.nodeMu.Unlock()
for _, alert := range pending {
alert := alert
go func() {
message := fmt.Sprintf("【告警名称】 %s \n\n【告警时间】 %s \n\n【用户名】 %s \n\n【节点名】 %s \n\n【虚拟化】 %s \n\n【主机名】 %s \n\n【位 置】 %s",
alert.rule.Name, now.Format("2006-01-02 15:04:05"), alert.node.Username, alert.node.Name, alert.node.Type, alert.node.Host, alert.node.Location)
if err := a.sendCallback(alert.rule.Callback, message, "ServerStatus"); err != nil {
a.logger.Printf("watchdog %q callback: %v", alert.rule.Name, err)
}
}()
}
}
type WatchdogEnvironment struct {
Username string `expr:"username"`
Name string `expr:"name"`
NodeType string `expr:"type"`
Host string `expr:"host"`
Location string `expr:"location"`
Load1 float64 `expr:"load_1"`
Load5 float64 `expr:"load_5"`
Load15 float64 `expr:"load_15"`
Ping10010 float64 `expr:"ping_10010"`
Ping189 float64 `expr:"ping_189"`
Ping10086 float64 `expr:"ping_10086"`
Time10010 float64 `expr:"time_10010"`
Time189 float64 `expr:"time_189"`
Time10086 float64 `expr:"time_10086"`
TCPCount float64 `expr:"tcp_count"`
UDPCount float64 `expr:"udp_count"`
ProcessCount float64 `expr:"process_count"`
ThreadCount float64 `expr:"thread_count"`
NetworkRX float64 `expr:"network_rx"`
NetworkTX float64 `expr:"network_tx"`
NetworkIn float64 `expr:"network_in"`
NetworkOut float64 `expr:"network_out"`
LastNetworkIn float64 `expr:"last_network_in"`
LastNetworkOut float64 `expr:"last_network_out"`
MemoryTotal float64 `expr:"memory_total"`
MemoryUsed float64 `expr:"memory_used"`
SwapTotal float64 `expr:"swap_total"`
SwapUsed float64 `expr:"swap_used"`
HDDTotal float64 `expr:"hdd_total"`
HDDUsed float64 `expr:"hdd_used"`
IORead float64 `expr:"io_read"`
IOWrite float64 `expr:"io_write"`
CPU float64 `expr:"cpu"`
Online4 float64 `expr:"online4"`
Online6 float64 `expr:"online6"`
}
func watchdogEnvironment(config ServerConfig, stats AgentStats, online4, online6 bool, lastNetworkIn, lastNetworkOut int64) WatchdogEnvironment {
boolNumber := func(value bool) float64 {
if value {
return 1
}
return 0
}
return WatchdogEnvironment{
Username: config.Username, Name: config.Name, NodeType: config.Type, Host: config.Host, Location: config.Location,
Load1: stats.Load1, Load5: stats.Load5, Load15: stats.Load15,
Ping10010: stats.Ping10010, Ping189: stats.Ping189, Ping10086: stats.Ping10086,
Time10010: float64(stats.Time10010), Time189: float64(stats.Time189), Time10086: float64(stats.Time10086),
TCPCount: float64(stats.TCPCount), UDPCount: float64(stats.UDPCount),
ProcessCount: float64(stats.ProcessCount), ThreadCount: float64(stats.ThreadCount),
NetworkRX: float64(stats.NetworkRX), NetworkTX: float64(stats.NetworkTX),
NetworkIn: float64(stats.NetworkIn), NetworkOut: float64(stats.NetworkOut),
LastNetworkIn: float64(lastNetworkIn), LastNetworkOut: float64(lastNetworkOut),
MemoryTotal: float64(stats.MemoryTotal), MemoryUsed: float64(stats.MemoryUsed),
SwapTotal: float64(stats.SwapTotal), SwapUsed: float64(stats.SwapUsed),
HDDTotal: float64(stats.HDDTotal), HDDUsed: float64(stats.HDDUsed),
IORead: float64(stats.IORead), IOWrite: float64(stats.IOWrite), CPU: stats.CPU,
Online4: boolNumber(online4), Online6: boolNumber(online6),
}
}
func expressionTruthy(result any) bool {
switch value := result.(type) {
case bool:
return value
case int:
return value != 0
case int64:
return value != 0
case float64:
return value != 0
case string:
parsed, _ := strconv.ParseBool(value)
return parsed
default:
return false
}
}
func (a *App) sendCallback(baseURL, message, signature string) error {
requestURL := baseURL + url.QueryEscape(message)
transport := http.DefaultTransport.(*http.Transport).Clone()
if a.opts.InsecureCallbackTLS {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec -- explicit compatibility option
}
client := &http.Client{Timeout: 6 * time.Second, Transport: transport}
response, err := client.Post(requestURL, "application/x-www-form-urlencoded", strings.NewReader("signature="+url.QueryEscape(signature)))
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("HTTP %s", response.Status)
}
return nil
}
+20 -9
View File
@@ -1,9 +1,20 @@
[Unit]
Description=ServerStatus-Server
After=network.target
[Service]
ExecStart=/usr/local/ServerStatus/server/sergate --config=/usr/local/ServerStatus/server/config.json --web-dir=/usr/local/ServerStatus/web
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
[Install]
WantedBy=multi-user.target
[Unit]
Description=ServerStatus Go Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/usr/local/ServerStatus/server
EnvironmentFile=-/usr/local/ServerStatus/server/config.conf
ExecStart=/usr/local/ServerStatus/server/serverstatus --config=/usr/local/ServerStatus/server/config.json --web-dir=/usr/local/ServerStatus/web
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/usr/local/ServerStatus/server /usr/local/ServerStatus/web/json
[Install]
WantedBy=multi-user.target
+55 -80
View File
@@ -2,7 +2,7 @@
PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:~/bin
export PATH
sh_ver="1.0.0"
sh_ver="2.0.0"
filepath=$(
cd "$(dirname "$0")" || exit
@@ -49,7 +49,7 @@ check_sys() {
}
check_installed_server_status() {
[[ ! -e "${server_file}/sergate" ]] && echo -e "${Error} $NAME 服务端没有安装,请检查 !" && exit 1
[[ ! -x "${server_file}/serverstatus" ]] && echo -e "${Error} $NAME Go 服务端没有安装,请检查 !" && exit 1
}
check_installed_client_status() {
@@ -58,27 +58,24 @@ check_installed_client_status() {
Download_Server_Status_server() {
cd "/tmp" || exit 1
rm -rf "/tmp/ServerStatus-master" "/tmp/master.zip"
wget -N --no-check-certificate https://github.com/cppla/ServerStatus/archive/refs/heads/master.zip
[[ ! -e "master.zip" ]] && echo -e "${Error} ServerStatus 服务端下载失败 !" && exit 1
[[ ! -e "master.zip" ]] && echo -e "${Error} ServerStatus 服务端下载失败 !" && exit 1
unzip master.zip
rm -rf master.zip
[[ ! -d "/tmp/ServerStatus-master" ]] && echo -e "${Error} ServerStatus 服务端解压失败 !" && exit 1
cd "/tmp/ServerStatus-master/server" || exit 1
make
[[ ! -e "sergate" ]] && echo -e "${Error} ServerStatus 服务端编译失败 !" && cd "${file_1}" && rm -rf "/tmp//ServerStatus-master" && exit 1
go build -trimpath -ldflags="-s -w -X main.version=${sh_ver}" -o /tmp/serverstatus .
[[ ! -x "/tmp/serverstatus" ]] && echo -e "${Error} ServerStatus Go 服务端编译失败,请确认 Go 版本满足 go.mod !" && cd "${file_1}" && rm -rf "/tmp/ServerStatus-master" && exit 1
cd "${file_1}" || exit 1
mkdir -p "${server_file}"
mv "/tmp/ServerStatus-master/server" "${file}"
mv "/tmp/ServerStatus-master/web" "${file}"
mv "/tmp/ServerStatus-master/plugin" "${file}"
mkdir -p "${server_file}" "${web_file}/json" "${plugin_file}"
install -m 0755 /tmp/serverstatus "${server_file}/serverstatus"
[[ ! -e "${server_conf}" ]] && install -m 0644 "/tmp/ServerStatus-master/server/config.json" "${server_conf}"
cp -a "/tmp/ServerStatus-master/web/." "${web_file}/"
cp -a "/tmp/ServerStatus-master/plugin/." "${plugin_file}/"
rm -f /tmp/serverstatus
rm -rf "/tmp/ServerStatus-master"
if [[ ! -e "${server_file}/sergate" ]]; then
echo -e "${Error} ServerStatus 服务端移动重命名失败 !"
[[ -e "${server_file}/sergate1" ]] && mv "${server_file}/sergate1" "${server_file}/sergate"
exit 1
else
[[ -e "${server_file}/sergate1" ]] && rm -rf "${server_file}/sergate1"
fi
[[ ! -x "${server_file}/serverstatus" ]] && echo -e "${Error} ServerStatus Go 服务端安装失败 !" && exit 1
}
Download_Server_Status_client() {
@@ -113,18 +110,27 @@ Installation_dependency() {
if [[ ${release} == "centos" ]]; then
yum makecache
yum -y install unzip
yum -y install python3 >/dev/null 2>&1 || yum -y install python
[[ ${mode} == "server" ]] && yum -y groupinstall "Development Tools"
if [[ ${mode} == "server" ]]; then
yum -y install golang
else
yum -y install python3 >/dev/null 2>&1 || yum -y install python
fi
elif [[ ${release} == "debian" ]]; then
apt -y update
apt -y install unzip
apt -y install python3 >/dev/null 2>&1 || apt -y install python
[[ ${mode} == "server" ]] && apt -y install build-essential
if [[ ${mode} == "server" ]]; then
apt -y install golang-go
else
apt -y install python3 >/dev/null 2>&1 || apt -y install python
fi
elif [[ ${release} == "archlinux" ]]; then
pacman -Sy python python-pip unzip --noconfirm
[[ ${mode} == "server" ]] && pacman -Sy base-devel --noconfirm
if [[ ${mode} == "server" ]]; then
pacman -Sy go unzip --noconfirm
else
pacman -Sy python python-pip unzip --noconfirm
fi
fi
[[ ! -e /usr/bin/python ]] && ln -s /usr/bin/python3 /usr/bin/python
[[ ${mode} == "client" && ! -e /usr/bin/python ]] && ln -s /usr/bin/python3 /usr/bin/python
}
Write_server_config() {
@@ -147,7 +153,9 @@ EOF
Write_server_config_conf() {
cat >${server_conf_1} <<-EOF
PORT = ${server_port_s}
AGENT_ADDR=:${server_port_s}
HTTP_ADDR=:${server_http_port_s}
ADMIN_TOKEN=${admin_token_s}
EOF
}
@@ -162,10 +170,19 @@ Read_config_client() {
Read_config_server() {
if [[ ! -e "${server_conf_1}" ]]; then
server_port_s="35601"
server_http_port_s="8080"
admin_token_s=""
Write_server_config_conf
server_port="35601"
server_http_port="8080"
else
server_port="$(grep "PORT = " ${server_conf_1} | awk '{print $3}')"
agent_addr="$(grep '^AGENT_ADDR=' "${server_conf_1}" | head -1 | cut -d= -f2-)"
http_addr="$(grep '^HTTP_ADDR=' "${server_conf_1}" | head -1 | cut -d= -f2-)"
admin_token_s="$(grep '^ADMIN_TOKEN=' "${server_conf_1}" | head -1 | cut -d= -f2-)"
server_port="${agent_addr##*:}"
server_http_port="${http_addr##*:}"
server_port_s="${server_port:-35601}"
server_http_port_s="${server_http_port:-8080}"
fi
}
@@ -191,8 +208,8 @@ Set_server() {
Set_server_http_port() {
while true; do
echo -e "请输入 $NAME 服务端中网站要设置的 域名/IP的端口[1-65535](如果是域名的话,一般用 80 端口)"
read -erp "(默认: 8888):" server_http_port_s
[[ -z "$server_http_port_s" ]] && server_http_port_s="8888"
read -erp "(默认: 8080):" server_http_port_s
[[ -z "$server_http_port_s" ]] && server_http_port_s="8080"
if [[ "$server_http_port_s" =~ ^[0-9]*$ ]]; then
if [[ ${server_http_port_s} -ge 1 ]] && [[ ${server_http_port_s} -le 65535 ]]; then
echo && echo " ================================================"
@@ -623,54 +640,16 @@ Install_jq() {
fi
}
Install_caddy() {
echo
echo -e "${Info} 是否由脚本自动配置HTTP服务(服务端的在线监控网站),如果选择 N,则请在其他HTTP服务中配置网站根目录为:${Green_font_prefix}${web_file}${Font_color_suffix} [Y/n]"
read -erp "(默认: Y 自动部署):" caddy_yn
[[ -z "$caddy_yn" ]] && caddy_yn="y"
if [[ "${caddy_yn}" == [Yy] ]]; then
caddy_file="/etc/caddy/Caddyfile" # Where is the default Caddyfile specified in Archlinux?
[[ ! -e /usr/bin/caddy ]] && {
if [[ ${release} == "debian" ]]; then
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/gpg.key" | tee /etc/apt/trusted.gpg.d/caddy-stable.asc
curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install caddy
elif [[ ${release} == "centos" ]]; then
yum install yum-plugin-copr -y
yum copr enable @caddy/caddy -y
yum install caddy -y
elif [[ ${release} == "archlinux" ]]; then
pacman -Sy caddy --noconfirm
fi
[[ ! -e "/usr/bin/caddy" ]] && echo -e "${Error} Caddy安装失败,请手动部署,Web网页文件位置:${web_file}" && exit 1
systemctl enable caddy
echo "" >${caddy_file}
}
Set_server "server"
Set_server_http_port
cat >>${caddy_file} <<-EOF
http://${server_s}:${server_http_port_s} {
root * ${web_file}
encode gzip
file_server
}
EOF
systemctl restart caddy
else
echo -e "${Info} 跳过 HTTP服务部署,请手动部署,Web网页文件位置:${web_file} ,如果位置改变,请注意修改服务脚本文件 /etc/init.d/status-server 中的 WEB_BIN 变量 !"
fi
}
Install_ServerStatus_server() {
[[ -e "${server_file}/sergate" ]] && echo -e "${Error} 检测到 $NAME 服务端已安装 !" && exit 1
[[ -x "${server_file}/serverstatus" ]] && echo -e "${Error} 检测到 $NAME 服务端已安装 !" && exit 1
Set_server_port
Set_server_http_port
admin_token_s="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)"
echo -e "${Info} 开始安装/配置 依赖..."
Installation_dependency "server"
Install_caddy
echo -e "${Info} 开始下载/安装..."
Download_Server_Status_server
Install_jq
Install_jq
echo -e "${Info} 开始下载/安装 服务脚本..."
Service_Server_Status_server
echo -e "${Info} 开始写入 配置文件..."
@@ -678,6 +657,8 @@ Install_jq
Write_server_config_conf
echo -e "${Info} 所有步骤 安装完毕,开始启动..."
Start_ServerStatus_server
echo -e "${Info} WebUI: http://127.0.0.1:${server_http_port_s}/"
echo -e "${Info} ADMIN_TOKEN: ${admin_token_s}"
}
Install_ServerStatus_client() {
@@ -700,8 +681,9 @@ Install_ServerStatus_client() {
Update_ServerStatus_server() {
check_installed_server_status
systemctl stop status-server 2>/dev/null || true
Download_Server_Status_server
rm -rf /etc/init.d/status-server
rm -f "${service}/status-server.service"
Service_Server_Status_server
Start_ServerStatus_server
}
@@ -723,12 +705,12 @@ Update_ServerStatus_client() {
}
Start_ServerStatus_server() {
port="$(grep "m_Port = " ${server_file}/src/main.cpp | awk '{print $3}' | sed '{s/;$//}')"
check_installed_server_status
Read_config_server
systemctl -q is-active status-server && echo -e "${Error} $NAME 正在运行,请检查 !" && exit 1
systemctl start status-server
if (systemctl -q is-active status-server) then
echo -e "${Info} $NAME 服务端启动成功[监听端口${port}] !"
echo -e "${Info} $NAME Go 服务端启动成功[Agent${server_port}Web${server_http_port}] !"
else
echo -e "${Error} $NAME 服务端启动失败 !"
fi
@@ -777,13 +759,6 @@ Uninstall_ServerStatus_server() {
else
rm -rf "${file}"
fi
if [[ -e "/usr/bin/caddy" ]]; then
systemctl stop caddy
systemctl disable caddy
[[ ${release} == "debian" ]] && apt purge -y caddy
[[ ${release} == "centos" ]] && yum -y remove caddy
[[ ${release} == "archlinux" ]] && pacman -R caddy --noconfirm
fi
systemctl daemon-reload
systemctl reset-failed
echo && echo "ServerStatus 卸载完成 !" && echo
@@ -977,7 +952,7 @@ menu_server() {
${Green_font_prefix} 9.${Font_color_suffix} 查看 服务端日志
————————————
${Green_font_prefix}10.${Font_color_suffix} 切换为 客户端菜单" && echo
if [[ -e "${server_file}/sergate" ]]; then
if [[ -x "${server_file}/serverstatus" ]]; then
if (systemctl -q is-active status-server) then
echo -e " 当前状态: 服务端 ${Green_font_prefix}已安装${Font_color_suffix}${Green_font_prefix}已启动${Font_color_suffix}"
else
+3 -3
View File
@@ -6,7 +6,7 @@
<meta name="description" content="云监控,ServerStatus中文版,ServerStatus,ServerStatus cppla" />
<title>云监控</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="stylesheet" href="css/app.css?v=20260709-19" />
<link rel="stylesheet" href="css/app.css?v=20260710-1" />
</head>
<body>
<header class="topbar">
@@ -170,7 +170,7 @@
<div class="section-head">
<div>
<h2 id="configEditorTitle">新增节点</h2>
<p class="muted" id="configEditorHint">保存后会写入 config.json,并向 sergate 发送 SIGHUP 重载。</p>
<p class="muted" id="configEditorHint">保存后会写入 config.json,并由 Go 服务热重载。</p>
</div>
</div>
<form id="configForm" class="config-form">
@@ -200,6 +200,6 @@
<a href="https://github.com/cppla/ServerStatus" target="_blank" rel="noopener">ServerStatus中文版</a>
</footer>
<script src="js/app.js?v=20260709-19" defer></script>
<script src="js/app.js?v=20260710-1" defer></script>
</body>
</html>
+4 -4
View File
@@ -328,8 +328,8 @@ function renderOverview(){
$('overviewCards').innerHTML = [
card('在线主机', `${online}/${total}`, '当前在线节点', online === total ? 'ok' : 'warn'),
card('证书风险', sslWarn, sslWarn ? '过期或域名不匹配' : '证书正常', sslWarn ? 'warn' : 'ok'),
card('本月下行', humanMinMBFromB(monthDown), '下载累计', 'traffic-down'),
card('本月上行', humanMinMBFromB(monthUp), '上传累计', 'traffic-up'),
card('本月下行', humanMinMBFromB(monthDown), '下载累计', 'traffic-down'),
card('活跃告警', alerts.total, `离线 ${alerts.offline} / 异常 ${alerts.abnormal} / 被墙 ${alerts.blocked}`, alerts.total ? (alerts.offline || alerts.blocked ? 'err' : 'warn') : 'ok')
].join('');
}
@@ -812,7 +812,7 @@ const CONFIG_TYPES = {
label: '节点',
addLabel: '新增节点',
empty: '暂无节点配置',
hint: '客户端登录使用 username/password,保存后自动重载 sergate。',
hint: '客户端登录使用 username/password,保存后 Go 服务会热重载并让客户端自动重连。',
fields: [
{ name:'username', label:'用户名', required:true, max:120 },
{ name:'name', label:'节点名', required:true, max:120 },
@@ -1041,8 +1041,8 @@ function bindAdmin(){
catch(err){ setAdminStatus('重载失败:' + err.message, 'err'); }
});
$('adminRestart').addEventListener('click', async () => {
if(!confirm('重启 sergate 采集服务?客户端会短暂断开后自动重连。')) return;
try{ await api('/api/restart', { method:'POST' }); setAdminStatus('服务重启已触发,等待容器入口脚本拉起 sergate。', 'ok'); }
if(!confirm('重启采集运行时?客户端会短暂断开后自动重连。')) return;
try{ await api('/api/restart', { method:'POST' }); setAdminStatus('采集运行时已在进程内重启,客户端正在自动重连。', 'ok'); }
catch(err){ setAdminStatus('重启失败:' + err.message, 'err'); }
});
$('configForm').addEventListener('submit', async e => {