mirror of
https://github.com//cppla/ServerStatus
synced 2026-08-06 07:13:57 +08:00
1.2.0
This commit is contained in:
+1
-2
@@ -2,8 +2,7 @@
|
||||
.github
|
||||
.idea
|
||||
*.sublime-workspace
|
||||
server/obj
|
||||
server/sergate
|
||||
server/serverstatus
|
||||
web/json/stats.json
|
||||
web/json/stats.json~
|
||||
*.bak-*
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
@@ -1,2 +1,4 @@
|
||||
sergate
|
||||
serverstatus
|
||||
*.test
|
||||
coverage.out
|
||||
.tags*
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
*.o
|
||||
@@ -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"}}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+204
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user