Compare commits

..
170 Commits
Author SHA1 Message Date
cppla 1512d2999b fix client command-line argument parsing 2026-07-10 18:09:36 +08:00
cpplaandGitHub 27e85c9bfd Merge pull request #287 from cppla/dependabot/go_modules/server/golang.org/x/net-0.55.0
Bump golang.org/x/net from 0.54.0 to 0.55.0 in /server
2026-07-10 17:59:15 +08:00
cpplaandGitHub 8f8fcb2ff0 Merge pull request #286 from cppla/dependabot/go_modules/server/github.com/quic-go/quic-go-0.59.1
Bump github.com/quic-go/quic-go from 0.59.0 to 0.59.1 in /server
2026-07-10 17:11:54 +08:00
cppla 1483f9063a fix CI after removing telegram plugin 2026-07-10 17:07:23 +08:00
dependabot[bot]andGitHub 0d2c12a034 Bump golang.org/x/net from 0.54.0 to 0.55.0 in /server
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.54.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.54.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 09:01:40 +00:00
dependabot[bot]andGitHub 888af9bf12 Bump github.com/quic-go/quic-go from 0.59.0 to 0.59.1 in /server
Bumps [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go) from 0.59.0 to 0.59.1.
- [Release notes](https://github.com/quic-go/quic-go/releases)
- [Commits](https://github.com/quic-go/quic-go/compare/v0.59.0...v0.59.1)

---
updated-dependencies:
- dependency-name: github.com/quic-go/quic-go
  dependency-version: 0.59.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 09:01:32 +00:00
cpplaandGitHub 2e13110c3b Merge pull request #285 from cppla/dependabot/go_modules/server/golang.org/x/crypto-0.52.0
Bump golang.org/x/crypto from 0.48.0 to 0.52.0 in /server
2026-07-10 16:59:50 +08:00
cppla 950c4d4a9a update 2.0.0 2026-07-10 16:53:46 +08:00
dependabot[bot]andGitHub bdf5e45c5b Bump golang.org/x/crypto from 0.48.0 to 0.52.0 in /server
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.48.0 to 0.52.0.
- [Commits](https://github.com/golang/crypto/compare/v0.48.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 07:37:18 +00:00
cppla e4ca0558f7 1.2.0 2026-07-10 15:35:10 +08:00
cpplaandGitHub 5322a9105e Update README.md
CI / validate (push) Has been cancelled
1.1.9 screen
2026-07-09 19:15:34 +08:00
cppla 3b0705edc1 update client linux and psutil 2026-07-09 18:01:57 +08:00
cppla 2b799a524f raw域名被限流,替换为api 2026-07-09 17:13:58 +08:00
cppla 783d27732d cpu short 2026-07-09 17:07:59 +08:00
cppla efb2768fa8 1.1.9 2026-07-09 16:09:02 +08:00
cppla a8fc0b81ab 1.1.9
CI / validate (push) Has been cancelled
2026-07-09 10:43:40 +08:00
cppla 66e6179332 update 2026-07-09 10:10:08 +08:00
cppla 6ad3a8c294 update
CI / validate (push) Has been cancelled
2026-07-07 17:39:32 +08:00
cppla 03db91c873 update 2026-07-07 17:27:29 +08:00
cppla 007c9bccd5 new style 2026-07-07 16:52:04 +08:00
cppla e8e90275a2 增加月流量重置按钮
CI / validate (push) Has been cancelled
2026-07-06 14:02:12 +08:00
cppla 8e4590699b update css for nomac
CI / validate (push) Has been cancelled
2026-07-02 16:24:04 +08:00
cppla 51ae186243 update 2026-07-02 13:07:35 +08:00
cppla bbec425c51 少于10时折叠筛选 2026-07-02 12:02:36 +08:00
cppla e0aae47efc update css
CI / validate (push) Has been cancelled
2026-07-01 19:17:07 +08:00
cppla a31fb176c4 update
CI / validate (push) Has been cancelled
2026-07-01 17:53:28 +08:00
cppla f19d10966d update version 1.1.8 2026-07-01 17:13:30 +08:00
cppla 9ac276d443 更新说明 2026-07-01 15:32:17 +08:00
cppla c58eaf6ff5 version 1.1.8 2026-07-01 11:55:45 +08:00
cppla f24a70b9d7 修复传递参数优先级的问题,防止错误获取系统变量 2026-03-20 11:51:05 +08:00
cppla 23a620274d linux尝试从os中获取信息 2026-02-28 14:10:35 +08:00
cppla e903200d66 默认psutil,容器下获取更精准的宿主机读写情况 2026-02-28 11:58:00 +08:00
cppla 57c74b0c9e update 2026-02-27 18:40:30 +08:00
cppla caf5d5c34d update 2026-02-27 18:35:57 +08:00
cppla ed64656a06 修复bug 2026-02-27 17:57:31 +08:00
cppla bbde4cb1e9 fix bug 2026-02-27 17:52:03 +08:00
cppla 678cf43077 fix bug 2026-02-27 17:24:33 +08:00
cppla 4bbd54dd06 fix bug 2026-02-27 17:12:31 +08:00
cppla eea08f529e fix bug 2026-02-27 17:12:12 +08:00
cppla d1ad6b0f43 client alpine images 2026-02-27 15:36:01 +08:00
cppla 570e14c1fa update docker compose 2026-02-27 13:27:58 +08:00
cppla 7b9da31db0 add client docker 2026-02-26 18:10:24 +08:00
cppla 6552959ef3 解决总流量pb显示异常的问题 2026-02-10 16:56:33 +08:00
cppla 14ee075853 ai fix a bug 2025-10-16 10:03:06 +08:00
cppla c1955d7ca5 AI fix AI Bug 2025-10-09 10:46:35 +08:00
cppla 41ec81bed3 del not used variable 2025-09-12 10:38:56 +08:00
cppla 1557673db2 ssl expire tips bug 2025-09-12 10:33:09 +08:00
cppla 565f8c7ce0 修复ssl提醒的bug 2025-09-11 11:49:59 +08:00
cppla 163c46f4de 1.1.7 latest 2025-09-03 17:20:36 +08:00
cppla a46ed4ea1a update readme 2025-09-03 16:22:22 +08:00
cppla 4d9372768f update 2025-09-03 16:03:37 +08:00
cppla eed03f641d 1.1.7 2025-09-03 16:01:34 +08:00
cppla 25effc0e2f update 1.1.7 2025-09-02 17:44:30 +08:00
cppla dcec9598c1 1.1.7 update 2025-09-02 17:43:43 +08:00
cppla f8527cc297 update config 2025-09-01 19:39:33 +08:00
cppla a4fc285be6 系统着色更改 2025-09-01 15:26:05 +08:00
cppla 195594b8c4 update 2025-09-01 14:40:18 +08:00
cppla 405e95bff8 update 2025-09-01 14:39:44 +08:00
cppla 72b621e277 硬盘仪表盘初始化颜色为青色 2025-09-01 10:20:55 +08:00
cppla c089ac7834 update 2025-08-31 22:38:03 +08:00
cppla ae648fbe66 fix macos color 2025-08-29 23:41:04 +08:00
cppla 68a1d3719f update 2025-08-29 19:32:51 +08:00
cppla 9cd86ccaa3 update 2025-08-29 17:34:12 +08:00
cppla 956a5ace26 1.1.6 patch 2025-08-29 17:05:20 +08:00
cppla 15fe753a0f 1.1.6修复大部分bug 2025-08-15 19:05:28 +08:00
cppla 0d185f628b update appjs 2025-08-15 18:51:01 +08:00
cppla 83977b55bf update gpt agent 2025-08-15 14:14:23 +08:00
cppla 49decdfed1 gpt commit 2025-08-15 13:59:12 +08:00
cppla 227e15fe48 Merge branch 'master' of github.com:cppla/ServerStatus 2025-08-15 13:34:55 +08:00
cppla 73b3d16ff6 fix some bug 2025-08-15 13:33:39 +08:00
cpplaandGitHub c04256535d Update README.md 2025-08-14 20:51:14 +08:00
cppla 3c8ebcf710 update fix some 2025-08-14 19:45:14 +08:00
cpplaandGitHub 9a19d2f4bf Update README.md 2025-08-14 14:02:56 +08:00
cppla d3c5e4a02a update 1.1.6 screen 2025-08-14 13:34:21 +08:00
cppla a03cda0ef0 update 2025-08-14 10:12:23 +08:00
cppla d25e7871ba update Dockerfile 2025-08-13 19:46:57 +08:00
cppla fd6c3532d9 update 2025-08-13 14:16:29 +08:00
cppla 89745f3c3a 3600 2025-08-13 13:27:37 +08:00
cppla 266230f5f0 update theme 2025-08-12 19:04:44 +08:00
cppla 4c17623dff update bug 2025-08-12 17:56:55 +08:00
cppla 34e30d9873 del 2025-08-12 17:23:20 +08:00
cppla 1e190878dd update theme 2025-08-12 17:19:19 +08:00
cppla 874c122a62 del pch 2025-08-12 15:42:17 +08:00
cppla fa984cfae6 update 2025-08-12 15:26:45 +08:00
cppla a544f72db0 restore makefile 2025-08-12 14:55:54 +08:00
cppla eea993c6cc update 2025-08-12 14:30:01 +08:00
cppla a4a3734c30 update 2025-08-12 14:01:50 +08:00
cppla a10e39b6f2 update readme 2025-08-12 13:54:17 +08:00
cppla 360d8d008d add ssl check 2025-08-12 13:49:25 +08:00
cpplaandGitHub 830938eac9 Update README.md 2025-04-01 10:35:26 +08:00
cppla 1a764ed104 1111 2024-12-02 14:13:00 +08:00
cppla b0c543b44b update Dockerfile 2024-11-25 17:39:07 +08:00
cppla f85c0a87f9 update tcp example 2024-11-25 16:50:32 +08:00
cpplaandGitHub 67d7c5ea0f Merge pull request #261 from ArashPartow/arashpartow/update_exprtk_0.0.3
Update ExprTk to 0.0.3
2024-11-25 16:42:00 +08:00
cppla 8576f81404 some update for probe tcp slow 2024-11-18 13:56:01 +08:00
cppla fa0ee2bf57 重要线路丢包率告警例子 2024-09-18 13:24:06 +08:00
cppla e9776b0e69 增加重要节点丢包率监控 2024-09-18 10:07:43 +08:00
cppla e01cc118dc update css 2024-09-13 09:26:07 +08:00
cpplaandGitHub 784c40b153 Update README.md 2024-09-11 18:01:05 +08:00
cppla 6068a2ba17 yes 2024-09-11 17:54:24 +08:00
cppla ced526824f 增加阿里云免费20G告警示例,限制username是aliyun1和aliyun2 2024-09-09 19:18:42 +08:00
cppla 7d2a73bf2e 修复月流量监控的bug,月流量加入表达式计算 2024-09-09 18:55:11 +08:00
cppla 51e2f664cd update theme set bug 2024-08-18 21:16:38 +08:00
cppla 937b1ea90f fix some bug 2024-08-18 21:13:53 +08:00
cpplaandGitHub b95d1945bf Update README.md 2024-08-18 19:44:57 +08:00
cpplaandGitHub e8446cd52f 我震惊的是AI已经能够帮助重构前端页面
我震惊的是AI已经能够帮助重构前端页面
2024-08-18 13:09:25 +08:00
cppla 6098f0fb1a commit new css by ai 2024-08-18 13:00:45 +08:00
cppla fc89a76cc2 resize css 2024-08-17 18:50:43 +08:00
cppla f290947b2c update theme 2024-08-17 17:36:57 +08:00
cppla b3adc6b782 update all 2024-08-17 17:31:40 +08:00
cppla 705a957606 bai piao copilot 2024-08-15 19:32:50 +08:00
cppla a307ad7d19 ai niu bi 2024-08-15 15:59:26 +08:00
cppla ad95e00723 不更新代码 无法续Copilot 2024-08-15 14:46:49 +08:00
cppla 31330168f3 ai modify 2024-08-15 14:18:34 +08:00
cppla d9e8f8a7c1 del ie 2024-08-15 14:02:33 +08:00
cppla 08f15ebdc5 15s -> 25s 2024-04-18 15:34:43 +08:00
cppla adb05818b9 改为15秒再次判断是否离线 2024-04-08 14:25:53 +08:00
cpplaandGitHub 4e73e8185e Update README.md 2024-04-03 22:06:15 +08:00
cpplaandGitHub 0fe01064a4 Update README.md 2024-04-03 21:59:23 +08:00
cppla 510567eaec 修复驴头不对马嘴的bug 2024-04-03 21:31:35 +08:00
cppla 503037c7e2 add two logs 2024-04-03 19:53:04 +08:00
cppla d75d5438a3 docker compose healthcheck 2024-04-03 17:44:43 +08:00
cppla 91f11dad76 屏蔽无用的nginx日志,增加健康检查wq 2024-04-03 17:16:32 +08:00
cppla fdc5abacfc fix bug for offline 2024-04-03 16:42:40 +08:00
cppla 388938e02b 解决了网络闪断导致的错误报警 2024-04-03 16:39:08 +08:00
cppla f912794068 add todo 2024-04-02 10:24:12 +08:00
cppla 6331d7d45b update 2024-03-11 15:06:47 +08:00
cppla 25f878a38a 5分钟一次probe service 2024-03-11 15:03:57 +08:00
cppla a8b9b2d00d 默认一天在线率 2024-02-04 14:43:19 +08:00
cppla dc3868998a 256->512 2024-02-04 14:37:39 +08:00
cppla 75d06c8666 add some ua 2024-01-27 15:27:10 +08:00
cppla 360252182d Merge branch 'dev' 2024-01-23 18:51:49 +08:00
cppla aa0ccd254c update readme 2024-01-23 18:50:15 +08:00
cpplaandGitHub f7b2e7db42 Merge pull request #242 from cppla/dev
1.1.2 测试
2024-01-23 18:10:19 +08:00
cppla 052c75ef23 update 2024-01-23 18:09:23 +08:00
cppla 1f7827cc21 change 2024-01-23 18:00:24 +08:00
cppla c236aee5dc todo list 2024-01-23 17:52:59 +08:00
cppla fb3ecd1796 增加一个todo 2024-01-23 17:43:27 +08:00
cppla 810f6a0d8a fix 2024-01-23 16:47:26 +08:00
cppla eddf37c413 fix bug 2024-01-23 10:30:43 +08:00
cppla 8dfc6d4718 update 2024-01-22 19:49:29 +08:00
cppla 03446b8f29 update some for cl 2024-01-22 15:34:44 +08:00
cppla 6ab046be51 update 2024-01-22 15:32:20 +08:00
cppla 94708c588c css resize 2024-01-22 15:21:06 +08:00
cppla 144987bcf1 .2f 2024-01-21 22:55:07 +08:00
cppla 7e46b1062e 新增说明,比较粗糙不建议更新 2024-01-21 21:34:07 +08:00
cppla 71cadcfebe 测试监控重要服务 2024-01-21 21:09:37 +08:00
cppla d96e711a34 change ms and lost rate 2024-01-19 16:03:43 +08:00
Arash Partow 5ebf076330 Update ExprTk to 0.0.3 2024-01-01 00:00:00 +00:00
cpplaandGitHub 4a9f5adb6b Update index3.html
cppla/Serverstatus version modify
2023-10-17 09:49:44 +08:00
cppla 59fa2d8eab fix light bug 2023-10-17 01:46:19 +00:00
cpplaandGitHub bd4c22edff Merge pull request #233 from cppla/dev
内置一个light theme
2023-10-16 19:36:58 +08:00
windows11 88229b2f4a 内置一个light theme 2023-10-16 19:35:22 +08:00
cpplaandGitHub d70a56a767 更加丰富的限制条件和规则
更加丰富的限制条件和规则
2023-10-16 18:35:03 +08:00
cpplaandGitHub a177345858 添加静态字符串的计算支持
添加静态字符串的计算支持
username / name / type / host / location
2023-10-16 17:53:46 +08:00
cpplaandGitHub 11a03a020c Merge pull request #232 from cppla/dev
静态字符串参与计算,同时给出样例
2023-10-16 17:42:09 +08:00
windows11 ed330bd785 c style 2023-10-16 17:07:57 +08:00
windows11 77cb62ae80 测试是否支持静态字符串断言 2023-10-16 16:57:02 +08:00
cpplaandGitHub 7bbca5e881 Update README.md 2023-10-13 13:54:02 +08:00
cpplaandGitHub 2e0d1f7fb9 Update README.md 2023-10-13 13:53:20 +08:00
cpplaandGitHub da83c54e5b Update README.md 2023-10-13 13:34:18 +08:00
cpplaandGitHub 3ef6cb52c1 Update README.md 2023-10-13 13:13:05 +08:00
cpplaandGitHub 67877bacfc Update README.md 2023-10-13 13:11:46 +08:00
cpplaandGitHub a8383efe93 Update README.md 2023-10-13 12:47:24 +08:00
cpplaandGitHub 03682772a1 静态字符串无法参与计算
提交一个表达式说明,静态字符串无法参与计算
2023-10-13 12:46:25 +08:00
cpplaandGitHub fdca4fd353 静态字符串变量无法参与计算。
静态字符串变量无法参与计算。
2023-10-13 12:30:03 +08:00
windows11 a5330dcf2f 修复username等静态变量的表达式支持 2023-10-13 11:58:46 +08:00
cpplaandGitHub be78a7cc88 Update README.md 2023-10-13 11:45:12 +08:00
cpplaandGitHub 46d43c247f Update light.css 2023-10-09 15:51:45 +08:00
cpplaandGitHub 6ae278857a Merge pull request #229 from cppla/cppla-patch-1
Update dark.css
2023-10-09 15:48:30 +08:00
75 changed files with 6026 additions and 49619 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.github
.idea
*.sublime-workspace
server/serverstatus
web/json/stats.json
web/json/stats.json~
*.bak-*
__pycache__
*.pyc
+42
View File
@@ -0,0 +1,42 @@
name: CI
on:
push:
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Test Go server
working-directory: server
run: |
test -z "$(gofmt -l .)"
go vet ./...
go test -race ./...
CGO_ENABLED=0 go build -trimpath -o /tmp/serverstatus .
- name: Check clients, WebUI and shell scripts
run: |
python3 -m py_compile clients/client-linux.py clients/client-psutil.py
python3 -m unittest clients/test_client_args.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
- name: Build Docker images
run: |
docker build -f Dockerfile.server -t serverstatus-server:test .
docker build -f Dockerfile.client -t serverstatus-client:test .
-29
View File
@@ -1,29 +0,0 @@
# The Dockerfile for build localhost source, not git repo
FROM debian:buster as builder
MAINTAINER cppla https://cpp.la
RUN apt-get update -y && apt-get -y install gcc g++ make libcurl4-openssl-dev
COPY . .
WORKDIR /server
RUN make -j
RUN pwd && ls -a
# glibc env run
FROM nginx:latest
RUN mkdir -p /ServerStatus/server/
COPY --from=builder server /ServerStatus/server/
COPY --from=builder web /usr/share/nginx/html/
# china time
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
EXPOSE 80 35601
CMD nohup sh -c '/etc/init.d/nginx start && /ServerStatus/server/sergate --config=/ServerStatus/server/config.json --web-dir=/usr/share/nginx/html'
+28
View File
@@ -0,0 +1,28 @@
FROM alpine:3.13
WORKDIR /app
# Runtime deps for client-linux.py and client-psutil.py
RUN apk add --no-cache python3 py3-psutil iproute2 procps
COPY clients/client-linux.py /app/client-linux.py
COPY clients/client-psutil.py /app/client-psutil.py
COPY clients/entrypoint.sh /app/entrypoint.sh
# Default client and defaults (can be overridden by docker env)
ENV SERVER=127.0.0.1 \
USER=s01 \
PORT=35601 \
INTERVAL=1 \
PROBEPORT=80 \
PROBE_PROTOCOL_PREFER=ipv4 \
PING_PACKET_HISTORY_LEN=100 \
CU=cu.tz.cloudcpp.com \
CT=ct.tz.cloudcpp.com \
CM=cm.tz.cloudcpp.com \
CLIENT=psutil
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]
CMD []
+42
View File
@@ -0,0 +1,42 @@
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 apk add --no-cache ca-certificates tzdata \
&& mkdir -p /app/config /app/data /app/web
COPY --from=builder /out/serverstatus /usr/local/bin/serverstatus
COPY server/config.json /app/config/config.json
COPY web /app/web/
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 wget -q -O /dev/null http://127.0.0.1/api/health || exit 1
ENTRYPOINT ["/usr/local/bin/serverstatus"]
+304 -145
View File
@@ -1,181 +1,340 @@
# ServerStatus中文版
# ServerStatus 中文版
* ServerStatus中文版是一个酷炫高逼格的云探针云监控、服务器云监控、多服务器探针~
* 在线演示:https://tz.cloudcpp.com
ServerStatus 是一个轻量的服务器探针云监控面板,支持多节点在线状态、资源占用、三网延迟、服务监测、SSL 证书检查、Watchdog 告警、HTTP API 和 Web 配置管理
[![Python Support](https://img.shields.io/badge/python-3.6%2B%20-blue.svg)](https://github.com/cppla/ServerStatus)
[![C++ Compiler](http://img.shields.io/badge/C++-GNU-blue.svg?style=flat&logo=cplusplus)](https://github.com/cppla/ServerStatus)
[![License](https://img.shields.io/badge/license-MIT-4EB1BA.svg?style=flat-square)](https://github.com/cppla/ServerStatus)
[![Version](https://img.shields.io/badge/Version-Build%201.1.0-red)](https://github.com/cppla/ServerStatus)
在线演示:https://tz.cloudcpp.com
![Latest Version](http://dl.cpp.la/Archive/serverstatus_1.0.9.png)
[![Go](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go&logoColor=white)](https://go.dev/)
[![CI](https://github.com/cppla/ServerStatus/actions/workflows/ci.yml/badge.svg)](https://github.com/cppla/ServerStatus/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/license-MIT-4EB1BA.svg)](LICENSE)
[![Version](https://img.shields.io/badge/Version-2.0.0-red)](https://github.com/cppla/ServerStatus)
`Watchdog🐶已经加入,触发式告警。 interval只是为了防止频繁收到报警信息造成骚扰,并不是探测间隔。`
![ServerStatus WebUI](https://dl.cpp.la/Archive/serverstatus_2_0_0.webp)
# 目录:
* clients 客户端文件
* server 服务端文件
* web 网站文件
## 一、服务端
* server/config.json 探针配置文件      
* web/json 探针月流量
# 部署:
【服务端】:
```bash
`Docker`:
wget --no-check-certificate -qO ~/serverstatus-config.json https://raw.githubusercontent.com/cppla/ServerStatus/master/server/config.json && mkdir ~/serverstatus-monthtraffic
docker run -d --restart=always --name=serverstatus -v ~/serverstatus-config.json:/ServerStatus/server/config.json -v ~/serverstatus-monthtraffic:/usr/share/nginx/html/json -p 80:80 -p 35601:35601 cppla/serverstatus:latest
`Docker-compose(推荐)`: docker-compose up -d
# Docker Compose,本地构建加:--build
ADMIN_TOKEN='your-strong-token' docker compose -f docker-compose-server.yml up -d --build
```
【客户端】:
```bash
wget --no-check-certificate -qO client-linux.py 'https://raw.githubusercontent.com/cppla/ServerStatus/master/clients/client-linux.py' && nohup python3 client-linux.py SERVER={$SERVER} USER={$USER} PASSWORD={$PASSWORD} >/dev/null 2>&1 &
# Docker Run
wget -qO ~/serverstatus-config.json \
--header='Accept: application/vnd.github.raw' \
'https://api.github.com/repos/cppla/ServerStatus/contents/server/config.json?ref=master'
mkdir -p ~/serverstatus-data
eg:
wget --no-check-certificate -qO client-linux.py 'https://raw.githubusercontent.com/cppla/ServerStatus/master/clients/client-linux.py' && nohup python3 client-linux.py SERVER=45.79.67.132 USER=s04 >/dev/null 2>&1 &
docker run -d --restart=always --name=serverstatus-server \
-e ADMIN_TOKEN='your-strong-token' \
-v ~/serverstatus-config.json:/app/config/config.json \
-v ~/serverstatus-data:/app/data \
-p 8080:80 -p 35601:35601 \
cppla/serverstatus:server
```
# 主题:
启动后访问:
* layuihttps://github.com/zeyudada/StatusServerLayui ,预览https://sslt.8zyw.cn
<img src=https://dl.cpp.la/Archive/serverstatus_layui.png width=200 height=100 />
- WebUIhttp://127.0.0.1:8080/
- 健康检查:http://127.0.0.1:8080/api/health
- API 描述:http://127.0.0.1:8080/api/schema
- OpenAPI 3.1http://127.0.0.1:8080/api/openapi.json
- 客户端上报端口:`35601/tcp`
* lighthttps://github.com/orilights/ServerStatus-Theme-Light ,预览:https://tz.cloudcpp.com/index3.html
<img src=https://github.com/orilights/ServerStatus-Theme-Light/blob/main/docs/screenshot.png width=200 height=100 />
`ADMIN_TOKEN` 不设置时,监控页面仍可读取,管理 API 返回 `503`,WebUI 的“配置”页不能修改数据。
## 二、客户端
# 手动安装教程:
**【服务端配置】**
#### 一、生成服务端程序
```
`Debian/Ubuntu`: apt-get -y install gcc g++ make libcurl4-openssl-dev
`Centos/Redhat`: yum -y install gcc gcc-c++ make libcurl-devel
cd ServerStatus/server && make
./sergate
```
如果没错误提示,OK,ctrl+c关闭;如果有错误提示,检查35601端口是否被占用
#### 二、修改配置文件
```diff
! watchdog rule 可以为任何已知字段的表达式。
! watchdog interval 最小通知间隔。
! watchdog callback 可自定义为Post方法的URL,告警内容将拼接其后并发起回调。
! watchdog callback Telegram: https://api.telegram.org/bot你自己的密钥/sendMessage?parse_mode=HTML&disable_web_page_preview=true&chat_id=你自己的标识&text=
! watchdog callback Server酱: https://sctapi.ftqq.com/你自己的密钥.send?title=ServerStatus&desp=
! watchdog callback PushDeer: https://api2.pushdeer.com/message/push?pushkey=你自己的密钥&text=
! watchdog callback BasicAuth: https://用户名:密码@你自己的域名/api/push?message=
```bash
# Docker Compose,本地构建加:--build
SERVER=127.0.0.1 USER=s01 PASSWORD=USER_DEFAULT_PASSWORD \
docker compose -f docker-compose-client.yml up -d --force-recreate
```
```bash
# Docker Run
docker run -d --restart=always --name=serverstatus-client \
--network=host --pid=host \
-e SERVER=127.0.0.1 \
-e USER=s01 \
-e PASSWORD=USER_DEFAULT_PASSWORD \
cppla/serverstatus:client
```
```bash
# Shell Run
wget -qO client-linux.py --header='Accept: application/vnd.github.raw' \
'https://api.github.com/repos/cppla/ServerStatus/contents/clients/client-linux.py?ref=master'
nohup python3 client-linux.py SERVER=127.0.0.1 USER=s01 PASSWORD=USER_DEFAULT_PASSWORD >/dev/null 2>&1 &
```
`USER` 是常见的宿主机环境变量名。如果没有显式传递或传递方式错误,Compose 可能会把系统中的 `$USER` 解析成本机用户名,而不是默认的 `s01`。推荐优先级:
1. 运行命令显式传递 `USER=s01`
2. 修改 `docker-compose-client.yml` 中的 `USER` 默认值
3. Docker 或系统环境中的 `USER`
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `SERVER` | `127.0.0.1` | Go 服务端地址 |
| `USER` | `s01` | 客户端用户名,必须匹配服务端配置 |
| `PORT` | `35601` | Agent TCP 上报端口 |
| `PASSWORD` | `USER_DEFAULT_PASSWORD` | 客户端密码,必须匹配服务端配置 |
| `INTERVAL` | `1` | 状态上报间隔,单位秒 |
| `PROBEPORT` | `80` | 三网 TCP 探测端口 |
| `PROBE_PROTOCOL_PREFER` | `ipv4` | 探测协议偏好,可选 `ipv4``ipv6` |
| `PING_PACKET_HISTORY_LEN` | `100` | 丢包历史窗口 |
| `CU` | `cu.tz.cloudcpp.com` | 联通探测地址 |
| `CT` | `ct.tz.cloudcpp.com` | 电信探测地址 |
| `CM` | `cm.tz.cloudcpp.com` | 移动探测地址 |
| `CLIENT` | `psutil` | 客户端实现,可选 `psutil``linux` |
## 服务端参数
Docker 镜像中的默认路径为:
| 环境变量 | 默认值 | 说明 |
| --- | --- | --- |
| `CONFIG_PATH` | `/app/config/config.json` | 主配置文件 |
| `STATS_PATH` | `/app/data/stats.json` | 月流量与状态持久化文件 |
| `WEB_DIR` | `/app/web` | WebUI 静态文件目录 |
| `HTTP_ADDR` | `:80` | WebUI 与 HTTP API 监听地址 |
| `AGENT_ADDR` | `:35601` | 客户端 TCP 上报监听地址 |
| `ADMIN_TOKEN` | 空 | 管理 API Bearer Token;为空时禁用管理接口 |
| `ADMIN_CORS_ORIGIN` | 空 | 可选的 API CORS Origin |
| `INSECURE_CALLBACK_TLS` | `false` | 是否允许 Watchdog/证书回调使用不可信 TLS 证书 |
| `VERBOSE` | `false` | 输出 Gin HTTP 请求日志 |
| `TZ` | `Asia/Shanghai` | 容器时区 |
对应命令行参数:
```text
--config, -c config.json 路径
--stats stats.json 路径
--web-dir, -d WebUI 目录
--http HTTP 监听地址
--agent Agent TCP 监听地址
--verbose, -v 详细请求日志
--version 输出构建版本
```
旧参数 `--bind/-b``--port/-p` 仍可用于设置 Agent TCP 监听地址。
## HTTP 管理 API
管理接口使用 Bearer Token
```http
Authorization: Bearer <ADMIN_TOKEN>
```
无需认证:
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/api/health` | 进程、Agent TCP、版本和配置路径状态 |
| `GET` | `/api/schema` | 机器可读的端点与配置集合描述 |
| `GET` | `/api/openapi.json` | 可供 AI Agent 直接导入的 OpenAPI 3.1 文档 |
| `GET` | `/json/stats.json` | WebUI 使用的实时状态快照 |
需要认证:
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET/PUT` | `/api/config` | 读取或整体替换配置 |
| `GET/POST` | `/api/servers` | 查询或新增节点 |
| `PUT/DELETE` | `/api/servers/{username}` | 修改或删除节点 |
| `POST` | `/api/servers/{username}/reset-traffic` | 将当前流量设为本月基线 |
| `GET/POST` | `/api/monitors` | 查询或新增服务监测 |
| `PUT/DELETE` | `/api/monitors/{index-or-name}` | 修改或删除服务监测 |
| `GET/POST` | `/api/sslcerts` | 查询或新增证书监测 |
| `PUT/DELETE` | `/api/sslcerts/{index-or-name}` | 修改或删除证书监测 |
| `GET/POST` | `/api/watchdog` | 查询或新增 Watchdog |
| `PUT/DELETE` | `/api/watchdog/{index-or-name}` | 修改或删除 Watchdog |
| `POST` | `/api/reload` | 从磁盘重新读取配置 |
| `POST` | `/api/restart` | 在进程内重启采集运行时 |
配置修改采用“校验 → 备份 → 持久化 → 原子切换”的顺序。成功后现有 Agent 连接会被关闭,Python 客户端约 3 秒后自动重连并获取新的 `monitors``/api/restart` 不退出 Go 进程,因此 Docker 和手动运行方式具有一致语义。
常用调用:
```bash
TOKEN='请替换为 ADMIN_TOKEN'
curl http://127.0.0.1:8080/api/health
curl -H "Authorization: Bearer ${TOKEN}" \
http://127.0.0.1:8080/api/config
curl -X POST http://127.0.0.1:8080/api/servers \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json' \
-d '{"username":"s05","name":"node5","type":"kvm","host":"host5","location":"SG","password":"change-me","monthstart":1}'
curl -X DELETE \
-H "Authorization: Bearer ${TOKEN}" \
http://127.0.0.1:8080/api/servers/s05
```
请求体最大为 `1 MiB`。AI Agent 可直接导入 `/api/openapi.json`;轻量客户端也可以先读取 `/api/schema`,再根据返回的集合字段调用 CRUD 接口。
## 配置文件
```json
{
"servers":
[
{
"username": "s01",
"name": "vps-1",
"type": "kvm",
"host": "chengdu",
"location": "🇨🇳",
"password": "USER_DEFAULT_PASSWORD",
"monthstart": 1
},
],
"watchdog":
[
{
"name": "服务器负载高监控",
"rule": "cpu>90&load_5>3",
"interval": 600,
"callback": "https://yourSMSurl"
},
{
"name": "服务器内存使用率过高监控",
"rule": "(memory_used/memory_total)*100>90",
"interval": 600,
"callback": "https://yourSMSurl"
},
{
"name": "服务器宕机告警,排除经常掉线的",
"rule": "online4=0&online6=0&username!=s21",
"interval": 600,
"callback": "https://yourSMSurl"
},
{
"name": "DDOS和CC攻击监控",
"rule": "tcp_count>600",
"interval": 300,
"callback": "https://yourSMSurl"
},
{
"name": "服务器月出口流量999GB告警",
"rule": "(network_out-last_network_out)/1024/1024/1024>999",
"interval": 3600,
"callback": "https://yourSMSurl"
},
{
"name": "你可以组合任何已知字段的表达式",
"rule": "(hdd_used/hdd_total)*100>95",
"interval": 1800,
"callback": "https://yourSMSurl"
}
]
}
"servers": [
{
"username": "s01",
"name": "node1",
"type": "kvm",
"host": "host1",
"location": "CN",
"password": "USER_DEFAULT_PASSWORD",
"monthstart": 1,
"disabled": false
}
],
"monitors": [
{
"name": "example",
"host": "https://example.com",
"interval": 600,
"type": "https"
}
],
"sslcerts": [
{
"name": "example",
"domain": "https://example.com",
"port": 443,
"interval": 7200,
"callback": "https://example.net/push?message="
}
],
"watchdog": [
{
"name": "offline warning",
"rule": "online4=0&online6=0",
"interval": 600,
"callback": "https://example.net/push?message="
}
]
}
```
#### 三、拷贝ServerStatus/status到你的网站目录
例如:
```
sudo cp -r ServerStatus/web/* /home/wwwroot/default
约束:
- `servers.username` 必须唯一。
- `monthstart` 自动限制在 `1-28`
- `port` 自动限制在 `1-65535`
- `interval` 最小为 1 秒;Watchdog 中表示通知冷却时间,不是客户端采集间隔。
- 配置写入前会创建 `config.json.bak-*`,最多保留 10 份。
- Docker 单文件 bind mount 无法被 `rename` 覆盖时,服务端会在完成备份后安全地写回原 inode。
使用 Docker 单文件挂载时,配置备份位于容器 `/app/config` 的可写层;如需长期保留历史版本,建议同时在宿主机备份 `server/config.json`
### Watchdog 表达式
`rule` 由 Go `expr` 引擎执行,并兼容旧版 Exprtk 的常用写法。Go 服务会把字符串外的单个操作符自动转换:
```text
& -> &&
| -> ||
= -> ==
```
#### 四、运行服务端:
web-dir参数为上一步设置的网站根目录,务必修改成自己网站的路径
```
./sergate --config=config.json --web-dir=/home/wwwroot/default
例如以下两种写法等价:
```text
cpu>90&load_1>5&username!='s01'
cpu>90 && load_1>5 && username!='s01'
```
**【客户端配置】**
字符串值支持中文、Emoji 和其他 Unicode 字符,例如:
客户端有两个版本,client-linux为普通linuxclient-psutil为跨平台版,普通版不成功,换成跨平台版即可。
#### 一、client-linux版配置:
1、vim client-linux.py, 修改SERVER地址,username帐号, password密码
2、python3 client-linux.py 运行即可。
#### 二、client-psutil版配置:
1、安装psutil跨平台依赖库
```text
username='节点一号'&name='上海节点'&location='中国 🇨🇳'&type='云主机'
```
`Debian/Ubuntu`: apt -y install python3-pip && pip3 install psutil
`Centos/Redhat`: yum -y install python3-pip gcc python3-devel && pip3 install psutil
`Windows`: https://pypi.org/project/psutil/
字段名必须使用系统定义的英文名称。可用字段包括:`username``name``type``host``location``load_1``load_5``load_15``cpu``memory_total``memory_used``swap_total``swap_used``hdd_total``hdd_used``network_rx``network_tx``network_in``network_out``last_network_in``last_network_out``ping_10010``ping_189``ping_10086``time_10010``time_189``time_10086``tcp_count``udp_count``process_count``thread_count``io_read``io_write``online4``online6`
客户端断开 25 秒后仍未重连,服务端才计算离线规则,避免短暂网络波动触发告警。每个节点、每条规则分别记录冷却时间。
### SSL 证书
证书检查使用 Go `crypto/tls`,不再调用外部 `openssl`。服务端记录到期时间、剩余天数和域名匹配状态,并保留原来的 7/3/1 天通知档位与冷却时间。
回调默认校验 HTTPS 证书。仅在必须兼容自签名回调服务时设置:
```bash
INSECURE_CALLBACK_TLS=true
```
2、vim client-psutil.py, 修改SERVER地址,username帐号, password密码
3、python3 client-psutil.py 运行即可。
服务器和客户端自行加入开机启动,或进程守护,或后台方式运行。 例如: nohup python3 client-linux.py &
## 源码编译和运行
`extra scene (run web/ssview.py)`
![Shell View](https://dl.cpp.la/Archive/serverstatus-shell.png?version=2023)
需要 Go `1.25` 或更高版本:
```bash
cd server
go mod download
go test ./...
go build -trimpath -ldflags='-s -w' -o serverstatus .
```
`server/` 目录启动:
```bash
ADMIN_TOKEN='请替换为高强度随机字符串' \
./serverstatus \
--config=config.json \
--stats=../web/json/stats.json \
--web-dir=../web \
--http=:8080 \
--agent=:35601
```
访问 http://127.0.0.1:8080/。发送 `SIGHUP` 可以重新读取配置:
```bash
kill -HUP "$(pgrep -x serverstatus)"
```
Systemd 示例位于 `service/status-server.service`。一键脚本 `status.sh` 也已切换到 Go 构建,但 Docker 仍是推荐部署方式。
## 构建和测试
```bash
# Go 单元、协议、API、TLS 和回调测试
cd server
go test ./...
go test -race ./...
go vet ./...
# Docker 镜像
cd ..
docker build -f Dockerfile.server -t cppla/serverstatus:server .
docker build -f Dockerfile.client -t cppla/serverstatus:client .
# Compose 配置
docker compose -f docker-compose-server.yml config
docker compose -f docker-compose-client.yml config
```
CI 还会检查 Go 格式、Python 客户端、Shell 脚本、WebUI JavaScript、服务端/客户端 Compose 文件和两个 Docker 镜像。
## 从旧服务端迁移
1. 备份原来的 `config.json``web/json/stats.json`
2. 原配置结构和客户端账号无需转换。
3. Docker 挂载目标改为 `/app/config/config.json``/app/data`
4. 删除旧的 nginx、`manage_api.py``sergate` 启动或监督配置。
5. 启动 Go 服务端后检查 `/api/health`,再观察客户端自动重连。
`stats.json` 会按节点的 `name/type/host/location` 恢复月流量基线。修改这些身份字段会被视为新节点。
# Make Better
## 致谢
* BotoXhttps://github.com/BotoX/ServerStatus
* mojeda: https://github.com/mojeda
* mojeda's ServerStatus: https://github.com/mojeda/ServerStatus
* BlueVM's project: http://www.lowendtalk.com/discussion/comment/169690#Comment_169690
# Jetbrains
<a href="https://www.jetbrains.com/?from=ServerStatus"><img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_square.png" width="100px"></a>
- BotoXhttps://github.com/BotoX/ServerStatus
- mojedahttps://github.com/mojeda/ServerStatus
+274 -29
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
# coding: utf-8
# Update by : https://github.com/cppla/ServerStatus, Update date: 20220530
# 版本:1.0.3, 支持Python版本:2.7 to 3.10
# Update by : https://github.com/cppla/ServerStatus, Update date: 20250902
# 版本:1.1.0, 支持Python版本:3.6+
# 支持操作系统: Linux, OSX, FreeBSD, OpenBSD and NetBSD, both 32-bit and 64-bit architectures
# 说明: 默认情况下修改server和user就可以了。丢包率监测方向可以自定义,例如:CU = "www.facebook.com"。
SERVER = "127.0.0.1"
USER = "s01"
SERVER = ""
USER = ""
PASSWORD = "USER_DEFAULT_PASSWORD"
@@ -29,10 +29,44 @@ import json
import errno
import subprocess
import threading
try:
from queue import Queue # python3
except ImportError:
from Queue import Queue # python2
import platform
from queue import Queue
def _env_str(name, default):
value = os.getenv(name)
if value is None or value == "":
return default
return value
def _env_int(name, default):
value = os.getenv(name)
if value is None or value == "":
return default
try:
return int(value)
except ValueError:
return default
# Allow docker env overrides. 优先级:运行程序传递参数 > 用户修改的USER > Docker/系统
SERVER = _env_str("SERVER", SERVER) if SERVER == "" else SERVER
USER = _env_str("USER", USER) if USER == "" else USER
PASSWORD = _env_str("PASSWORD", PASSWORD)
PORT = _env_int("PORT", PORT)
INTERVAL = _env_int("INTERVAL", INTERVAL)
PROBEPORT = _env_int("PROBEPORT", PROBEPORT)
PROBE_PROTOCOL_PREFER = _env_str("PROBE_PROTOCOL_PREFER", PROBE_PROTOCOL_PREFER)
PING_PACKET_HISTORY_LEN = _env_int("PING_PACKET_HISTORY_LEN", PING_PACKET_HISTORY_LEN)
CU = _env_str("CU", CU)
CT = _env_str("CT", CT)
CM = _env_str("CM", CM)
def parse_cli_args(arguments):
overrides = {}
for argument in arguments:
key, separator, value = argument.partition('=')
if separator and key in {'SERVER', 'PORT', 'USER', 'PASSWORD', 'INTERVAL'}:
overrides[key] = value
return overrides
def get_uptime():
with open('/proc/uptime', 'r') as f:
@@ -55,11 +89,34 @@ def get_memory():
return int(MemTotal), int(MemUsed), int(SwapTotal), int(SwapFree)
def get_hdd():
p = subprocess.check_output(['df', '-Tlm', '--total', '-t', 'ext4', '-t', 'ext3', '-t', 'ext2', '-t', 'reiserfs', '-t', 'jfs', '-t', 'ntfs', '-t', 'fat32', '-t', 'btrfs', '-t', 'fuseblk', '-t', 'zfs', '-t', 'simfs', '-t', 'xfs']).decode("Utf-8")
total = p.splitlines()[-1]
used = total.split()[3]
size = total.split()[2]
return int(size), int(used)
valid_fs = {
"ext4", "ext3", "ext2", "reiserfs", "jfs", "btrfs", "fuseblk",
"zfs", "simfs", "ntfs", "fat32", "exfat", "xfs"
}
disks = {}
size = 0
used = 0
try:
with open("/proc/mounts", "r") as f:
for line in f:
parts = line.split()
if len(parts) < 3:
continue
device = parts[0]
mountpoint = parts[1]
fstype = parts[2].lower()
if fstype not in valid_fs or device in disks:
continue
disks[device] = mountpoint
for mountpoint in disks.values():
st = os.statvfs(mountpoint)
total_bytes = st.f_blocks * st.f_frsize
used_bytes = (st.f_blocks - st.f_bavail) * st.f_frsize
size += total_bytes
used += used_bytes
except Exception:
pass
return int(size / 1024 / 1024), int(used / 1024 / 1024)
def get_time():
with open("/proc/stat", "r") as f:
@@ -84,12 +141,79 @@ def get_cpu():
result = 100-(t[len(t)-1]*100.00/st)
return round(result, 1)
def get_cpu_cores():
try:
with open('/proc/stat') as f:
cores = sum(1 for line in f if re.match(r'^cpu\d+\s', line))
if cores > 0:
return cores
except Exception:
pass
return os.cpu_count() or 0
def normalize_cpu_model(value):
return re.sub(r'\s+', ' ', str(value or '')).strip()[:160]
def is_generic_cpu_model(value):
v = normalize_cpu_model(value).lower().replace('-', '').replace('_', '').replace(' ', '')
return v in ('', 'unknown', 'x8664', 'amd64', 'i386', 'i686', 'aarch64', 'arm64') or v.startswith('armv')
def get_lscpu_info():
result = {}
try:
output = subprocess.check_output(['lscpu'], stderr=subprocess.DEVNULL, timeout=2).decode(errors='ignore')
for line in output.splitlines():
if ':' not in line:
continue
key, value = line.split(':', 1)
key = key.strip().lower()
value = normalize_cpu_model(value)
if value and key not in result:
result[key] = value
except Exception:
pass
return result
def get_cpuinfo_values():
result = {}
try:
with open('/proc/cpuinfo') as f:
for line in f:
if ':' not in line:
continue
key, value = line.split(':', 1)
key = key.strip().lower()
value = normalize_cpu_model(value)
if value and key not in result:
result[key] = value
except Exception:
pass
return result
def get_cpu_model():
cpuinfo = get_cpuinfo_values()
lscpu = get_lscpu_info()
for value in (
cpuinfo.get('model name'),
lscpu.get('model name'),
cpuinfo.get('hardware'),
cpuinfo.get('processor'),
platform.processor(),
):
value = normalize_cpu_model(value)
if value and not value.isdigit() and not is_generic_cpu_model(value):
return value
vendor = normalize_cpu_model(lscpu.get('vendor id') or cpuinfo.get('vendor_id'))
if vendor:
return vendor
return normalize_cpu_model(lscpu.get('architecture') or platform.machine() or platform.processor())
def liuliang():
NET_IN = 0
NET_OUT = 0
with open('/proc/net/dev') as f:
for line in f.readlines():
netinfo = re.findall('([^\s]+):[\s]{0,}(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)', line)
netinfo = re.findall(r'([^\s]+):[\s]{0,}(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)', line)
if netinfo:
if netinfo[0][0] == 'lo' or 'tun' in netinfo[0][0] \
or 'docker' in netinfo[0][0] or 'veth' in netinfo[0][0] \
@@ -150,6 +274,7 @@ diskIO = {
'read': 0,
'write': 0
}
monitorServer = {}
def _ping_thread(host, mark, port):
lostPacket = 0
@@ -314,6 +439,70 @@ def get_realtime_data():
ti.daemon = True
ti.start()
def _monitor_thread(name, host, interval, type):
while True:
if name not in monitorServer.keys():
break
try:
# 1) 解析目标 host 与端口
if type == 'http':
addr = str(host).replace('http://','')
addr = addr.split('/',1)[0]
port = 80
if ':' in addr and not addr.startswith('['):
a, p = addr.rsplit(':',1)
if p.isdigit():
addr, port = a, int(p)
elif type == 'https':
addr = str(host).replace('https://','')
addr = addr.split('/',1)[0]
port = 443
if ':' in addr and not addr.startswith('['):
a, p = addr.rsplit(':',1)
if p.isdigit():
addr, port = a, int(p)
elif type == 'tcp':
addr = str(host)
if addr.startswith('[') and ']' in addr:
a = addr[1:addr.index(']')]
rest = addr[addr.index(']')+1:]
if rest.startswith(':') and rest[1:].isdigit():
addr, port = a, int(rest[1:])
else:
raise Exception('bad tcp target')
else:
a, p = addr.rsplit(':',1)
addr, port = a, int(p)
else:
time.sleep(interval)
continue
# 2) 解析 IP(按偏好族)
IP = addr
if addr.count(':') < 1: # 非纯 IPv6
try:
if PROBE_PROTOCOL_PREFER == 'ipv4':
IP = socket.getaddrinfo(addr, None, socket.AF_INET)[0][4][0]
else:
IP = socket.getaddrinfo(addr, None, socket.AF_INET6)[0][4][0]
except Exception:
pass
# 3) 建连耗时(timeout=1s),ECONNREFUSED 也计入
try:
b = timeit.default_timer()
socket.create_connection((IP, port), timeout=1).close()
monitorServer[name]["latency"] = int((timeit.default_timer() - b) * 1000)
except socket.error as error:
if getattr(error, 'errno', None) == errno.ECONNREFUSED:
monitorServer[name]["latency"] = int((timeit.default_timer() - b) * 1000)
else:
monitorServer[name]["latency"] = 0
except Exception:
monitorServer[name]["latency"] = 0
time.sleep(interval)
def byte_str(object):
'''
bytes to str, str to bytes
@@ -328,17 +517,12 @@ def byte_str(object):
print(type(object))
if __name__ == '__main__':
for argc in sys.argv:
if 'SERVER' in argc:
SERVER = argc.split('SERVER=')[-1]
elif 'PORT' in argc:
PORT = int(argc.split('PORT=')[-1])
elif 'USER' in argc:
USER = argc.split('USER=')[-1]
elif 'PASSWORD' in argc:
PASSWORD = argc.split('PASSWORD=')[-1]
elif 'INTERVAL' in argc:
INTERVAL = int(argc.split('INTERVAL=')[-1])
cli_args = parse_cli_args(sys.argv[1:])
SERVER = cli_args.get('SERVER', SERVER)
PORT = int(cli_args.get('PORT', PORT))
USER = cli_args.get('USER', USER)
PASSWORD = cli_args.get('PASSWORD', PASSWORD)
INTERVAL = int(cli_args.get('INTERVAL', INTERVAL))
socket.setdefaulttimeout(30)
get_realtime_data()
while True:
@@ -360,6 +544,26 @@ if __name__ == '__main__':
if data.find("You are connecting via") < 0:
data = byte_str(s.recv(1024))
print(data)
monitorServer.clear()
for i in data.split('\n'):
if "monitor" in i and "type" in i and "{" in i and "}" in i:
jdata = json.loads(i[i.find("{"):i.find("}")+1])
monitorServer[jdata.get("name")] = {
"type": jdata.get("type"),
"host": jdata.get("host"),
"latency": 0
}
t = threading.Thread(
target=_monitor_thread,
kwargs={
'name': jdata.get("name"),
'host': jdata.get("host"),
'interval': jdata.get("interval"),
'type': jdata.get("type")
}
)
t.daemon = True
t.start()
timer = 0
check_ip = 0
@@ -371,6 +575,8 @@ if __name__ == '__main__':
print(data)
raise socket.error
CPUCores = get_cpu_cores()
CPUModel = get_cpu_model()
while True:
CPU = get_cpu()
NET_IN, NET_OUT = liuliang()
@@ -378,7 +584,6 @@ if __name__ == '__main__':
Load_1, Load_5, Load_15 = os.getloadavg()
MemoryTotal, MemoryUsed, SwapTotal, SwapFree = get_memory()
HDDTotal, HDDUsed = get_hdd()
array = {}
if not timer:
array['online' + str(check_ip)] = get_network(check_ip)
@@ -397,12 +602,12 @@ if __name__ == '__main__':
array['hdd_total'] = HDDTotal
array['hdd_used'] = HDDUsed
array['cpu'] = CPU
array['cpu_cores'] = CPUCores
array['cpu_model'] = CPUModel
array['network_rx'] = netSpeed.get("netrx")
array['network_tx'] = netSpeed.get("nettx")
array['network_in'] = NET_IN
array['network_out'] = NET_OUT
# todo:兼容旧版本,下个版本删除ip_status
array['ip_status'] = True
array['ping_10010'] = lostRate.get('10010') * 100
array['ping_189'] = lostRate.get('189') * 100
array['ping_10086'] = lostRate.get('10086') * 100
@@ -412,16 +617,56 @@ if __name__ == '__main__':
array['tcp'], array['udp'], array['process'], array['thread'] = tupd()
array['io_read'] = diskIO.get("read")
array['io_write'] = diskIO.get("write")
# report OS (normalized)
try:
sysname = platform.system().lower()
if sysname.startswith('linux'):
os_name = 'linux'
# try distro from os-release
try:
with open('/etc/os-release') as f:
for line in f:
if line.startswith('ID='):
val = line.strip().split('=',1)[1].strip().strip('"')
if val: os_name = val
break
except Exception:
pass
elif sysname.startswith('darwin'):
os_name = 'darwin'
elif sysname.startswith('freebsd'):
os_name = 'freebsd'
elif sysname.startswith('openbsd'):
os_name = 'openbsd'
elif sysname.startswith('netbsd'):
os_name = 'netbsd'
else:
os_name = sysname or 'unknown'
except Exception:
os_name = 'unknown'
array['os'] = os_name
items = []
for _n, st in monitorServer.items():
key = str(_n)
try:
ms = int(st.get('latency') or 0)
except Exception:
ms = 0
items.append((key, max(0, ms)))
# 稳定顺序:按 key 排序
items.sort(key=lambda x: x[0])
array['custom'] = ';'.join(f"{k}={v}" for k,v in items)
s.send(byte_str("update " + json.dumps(array) + "\n"))
except KeyboardInterrupt:
raise
except socket.error:
monitorServer.clear()
print("Disconnected...")
if 's' in locals().keys():
del s
time.sleep(3)
except Exception as e:
monitorServer.clear()
print("Caught Exception:", e)
if 's' in locals().keys():
del s
+222 -23
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
# coding: utf-8
# Update by : https://github.com/cppla/ServerStatus, Update date: 20220530
# Update by : https://github.com/cppla/ServerStatus, Update date: 20250902
# 依赖于psutil跨平台库
# 版本:1.0.3, 支持Python版本:2.7 to 3.10
# 版本:1.1.0, 支持Python版本:3.6+
# 支持操作系统: Linux, Windows, OSX, Sun Solaris, FreeBSD, OpenBSD and NetBSD, both 32-bit and 64-bit architectures
# 说明: 默认情况下修改server和user就可以了。丢包率监测方向可以自定义,例如:CU = "www.facebook.com"。
SERVER = "127.0.0.1"
USER = "s01"
SERVER = ""
USER = ""
PASSWORD = "USER_DEFAULT_PASSWORD"
@@ -29,10 +29,44 @@ import json
import errno
import psutil
import threading
try:
from queue import Queue # python3
except ImportError:
from Queue import Queue # python2
import platform
from queue import Queue
def _env_str(name, default):
value = os.getenv(name)
if value is None or value == "":
return default
return value
def _env_int(name, default):
value = os.getenv(name)
if value is None or value == "":
return default
try:
return int(value)
except ValueError:
return default
# Allow docker env overrides. 优先级:运行程序传递参数 > 用户修改的USER > Docker/系统
SERVER = _env_str("SERVER", SERVER) if SERVER == "" else SERVER
USER = _env_str("USER", USER) if USER == "" else USER
PASSWORD = _env_str("PASSWORD", PASSWORD)
PORT = _env_int("PORT", PORT)
INTERVAL = _env_int("INTERVAL", INTERVAL)
PROBEPORT = _env_int("PROBEPORT", PROBEPORT)
PROBE_PROTOCOL_PREFER = _env_str("PROBE_PROTOCOL_PREFER", PROBE_PROTOCOL_PREFER)
PING_PACKET_HISTORY_LEN = _env_int("PING_PACKET_HISTORY_LEN", PING_PACKET_HISTORY_LEN)
CU = _env_str("CU", CU)
CT = _env_str("CT", CT)
CM = _env_str("CM", CM)
def parse_cli_args(arguments):
overrides = {}
for argument in arguments:
key, separator, value = argument.partition('=')
if separator and key in {'SERVER', 'PORT', 'USER', 'PASSWORD', 'INTERVAL'}:
overrides[key] = value
return overrides
def get_uptime():
return int(time.time() - psutil.boot_time())
@@ -66,6 +100,52 @@ def get_hdd():
def get_cpu():
return psutil.cpu_percent(interval=INTERVAL)
def get_cpu_cores():
return psutil.cpu_count(logical=True) or 0
def normalize_cpu_model(value):
return " ".join(str(value or "").split())[:160]
def is_generic_cpu_model(value):
v = normalize_cpu_model(value).lower().replace('-', '').replace('_', '').replace(' ', '')
return v in ('', 'unknown', 'x8664', 'amd64', 'i386', 'i686', 'aarch64', 'arm64') or v.startswith('armv')
def get_platform_cpu_vendor():
values = [
platform.processor(),
getattr(platform.uname(), 'processor', ''),
platform.machine(),
getattr(platform.uname(), 'machine', ''),
platform.platform(),
]
text = " ".join(normalize_cpu_model(v).lower() for v in values)
if 'genuineintel' in text:
return 'GenuineIntel'
if 'authenticamd' in text:
return 'AuthenticAMD'
if 'intel' in text:
return 'Intel'
if 'amd' in text:
return 'AMD'
if sys.platform.startswith('darwin') and platform.machine().lower() in ('arm64', 'aarch64'):
return 'Apple'
if any(token in text for token in ('aarch64', 'arm64', 'armv7', 'armv8', ' arm ')):
return 'ARM'
return ''
def get_platform_cpu_arch():
return normalize_cpu_model(platform.machine() or platform.processor() or platform.architecture()[0])
def get_cpu_model():
for value in (platform.processor(), getattr(platform.uname(), 'processor', '')):
value = normalize_cpu_model(value)
if value and not is_generic_cpu_model(value):
return value
vendor = normalize_cpu_model(get_platform_cpu_vendor())
if vendor:
return vendor
return get_platform_cpu_arch()
def liuliang():
NET_IN = 0
NET_OUT = 0
@@ -148,6 +228,7 @@ diskIO = {
'read': 0,
'write': 0
}
monitorServer = {}
def _ping_thread(host, mark, port):
lostPacket = 0
@@ -303,6 +384,72 @@ def get_realtime_data():
ti.daemon = True
ti.start()
def _monitor_thread(name, host, interval, type):
# 参考 _ping_thread 风格:每轮解析一次目标,按协议族偏好解析 IP,测 TCP 建连耗时
while True:
if name not in monitorServer:
break
try:
# 1) 解析目标 host 与端口
if type == 'http':
addr = str(host).replace('http://','')
addr = addr.split('/',1)[0]
port = 80
if ':' in addr and not addr.startswith('['):
a, p = addr.rsplit(':',1)
if p.isdigit():
addr, port = a, int(p)
elif type == 'https':
addr = str(host).replace('https://','')
addr = addr.split('/',1)[0]
port = 443
if ':' in addr and not addr.startswith('['):
a, p = addr.rsplit(':',1)
if p.isdigit():
addr, port = a, int(p)
elif type == 'tcp':
addr = str(host)
if addr.startswith('[') and ']' in addr:
# [v6]:port
a = addr[1:addr.index(']')]
rest = addr[addr.index(']')+1:]
if rest.startswith(':') and rest[1:].isdigit():
addr, port = a, int(rest[1:])
else:
raise Exception('bad tcp target')
else:
a, p = addr.rsplit(':',1)
addr, port = a, int(p)
else:
time.sleep(interval)
continue
# 2) 解析 IP(按偏好族),与 _ping_thread 保持一致的判定
IP = addr
if addr.count(':') < 1: # 非纯 IPv6,可能是 IPv4 或域名
try:
if PROBE_PROTOCOL_PREFER == 'ipv4':
IP = socket.getaddrinfo(addr, None, socket.AF_INET)[0][4][0]
else:
IP = socket.getaddrinfo(addr, None, socket.AF_INET6)[0][4][0]
except Exception:
pass
# 3) 测 TCP 建连耗时(timeout=1s);ECONNREFUSED 也记为耗时
try:
b = timeit.default_timer()
socket.create_connection((IP, port), timeout=1).close()
monitorServer[name]['latency'] = int((timeit.default_timer() - b) * 1000)
except socket.error as error:
if getattr(error, 'errno', None) == errno.ECONNREFUSED:
monitorServer[name]['latency'] = int((timeit.default_timer() - b) * 1000)
else:
monitorServer[name]['latency'] = 0
except Exception:
monitorServer[name]['latency'] = 0
time.sleep(interval)
def byte_str(object):
'''
bytes to str, str to bytes
@@ -317,17 +464,12 @@ def byte_str(object):
print(type(object))
if __name__ == '__main__':
for argc in sys.argv:
if 'SERVER' in argc:
SERVER = argc.split('SERVER=')[-1]
elif 'PORT' in argc:
PORT = int(argc.split('PORT=')[-1])
elif 'USER' in argc:
USER = argc.split('USER=')[-1]
elif 'PASSWORD' in argc:
PASSWORD = argc.split('PASSWORD=')[-1]
elif 'INTERVAL' in argc:
INTERVAL = int(argc.split('INTERVAL=')[-1])
cli_args = parse_cli_args(sys.argv[1:])
SERVER = cli_args.get('SERVER', SERVER)
PORT = int(cli_args.get('PORT', PORT))
USER = cli_args.get('USER', USER)
PASSWORD = cli_args.get('PASSWORD', PASSWORD)
INTERVAL = int(cli_args.get('INTERVAL', INTERVAL))
socket.setdefaulttimeout(30)
get_realtime_data()
while 1:
@@ -349,6 +491,25 @@ if __name__ == '__main__':
if data.find("You are connecting via") < 0:
data = byte_str(s.recv(1024))
print(data)
for i in data.split('\n'):
if "monitor" in i and "type" in i and "{" in i and "}" in i:
jdata = json.loads(i[i.find("{"):i.find("}")+1])
monitorServer[jdata.get("name")] = {
"type": jdata.get("type"),
"host": jdata.get("host"),
"latency": 0
}
t = threading.Thread(
target=_monitor_thread,
kwargs={
'name': jdata.get("name"),
'host': jdata.get("host"),
'interval': jdata.get("interval"),
'type': jdata.get("type")
}
)
t.daemon = True
t.start()
timer = 0
check_ip = 0
@@ -360,6 +521,8 @@ if __name__ == '__main__':
print(data)
raise socket.error
CPUCores = get_cpu_cores()
CPUModel = get_cpu_model()
while 1:
CPU = get_cpu()
NET_IN, NET_OUT = liuliang()
@@ -368,7 +531,6 @@ if __name__ == '__main__':
MemoryTotal, MemoryUsed = get_memory()
SwapTotal, SwapUsed = get_swap()
HDDTotal, HDDUsed = get_hdd()
array = {}
if not timer:
array['online' + str(check_ip)] = get_network(check_ip)
@@ -387,12 +549,12 @@ if __name__ == '__main__':
array['hdd_total'] = HDDTotal
array['hdd_used'] = HDDUsed
array['cpu'] = CPU
array['cpu_cores'] = CPUCores
array['cpu_model'] = CPUModel
array['network_rx'] = netSpeed.get("netrx")
array['network_tx'] = netSpeed.get("nettx")
array['network_in'] = NET_IN
array['network_out'] = NET_OUT
# todo:兼容旧版本,下个版本删除ip_status
array['ip_status'] = True
array['ping_10010'] = lostRate.get('10010') * 100
array['ping_189'] = lostRate.get('189') * 100
array['ping_10086'] = lostRate.get('10086') * 100
@@ -402,16 +564,53 @@ if __name__ == '__main__':
array['tcp'], array['udp'], array['process'], array['thread'] = tupd()
array['io_read'] = diskIO.get("read")
array['io_write'] = diskIO.get("write")
# report OS (normalized)
try:
sysname = platform.system().lower()
if sysname.startswith('windows'):
os_name = 'windows'
elif sysname.startswith('darwin') or 'mac' in sysname:
os_name = 'darwin'
elif 'bsd' in sysname:
os_name = 'bsd'
elif sysname.startswith('linux'):
# try distro from os-release
try:
with open('/etc/os-release') as f:
for line in f:
if line.startswith('ID='):
val = line.strip().split('=',1)[1].strip().strip('"')
if val: os_name = val
break
except Exception:
os_name = 'linux'
else:
os_name = sysname or 'unknown'
except Exception:
os_name = 'unknown'
array['os'] = os_name
items = []
for _n, st in monitorServer.items():
key = str(_n)
try:
ms = int(st.get('latency') or 0)
except Exception:
ms = 0
items.append((key, max(0, ms)))
# 稳定顺序:按 key 排序
items.sort(key=lambda x: x[0])
array['custom'] = ';'.join(f"{k}={v}" for k,v in items)
s.send(byte_str("update " + json.dumps(array) + "\n"))
except KeyboardInterrupt:
raise
except socket.error:
monitorServer.clear()
print("Disconnected...")
if 's' in locals().keys():
del s
time.sleep(3)
except Exception as e:
monitorServer.clear()
print("Caught Exception:", e)
if 's' in locals().keys():
del s
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -eu
if [ "$#" -gt 0 ]; then
exec "$@"
fi
case "${CLIENT:-linux}" in
linux|client-linux|client-linux.py)
exec python3 /app/client-linux.py
;;
psutil|client-psutil|client-psutil.py)
exec python3 /app/client-psutil.py
;;
*)
echo "Unknown CLIENT='$CLIENT'. Use 'linux' or 'psutil'." >&2
exit 2
;;
esac
+40
View File
@@ -0,0 +1,40 @@
import importlib.util
import runpy
import sys
import types
import unittest
from pathlib import Path
CLIENT_DIR = Path(__file__).resolve().parent
class ClientArgumentTests(unittest.TestCase):
def test_password_with_user_text_does_not_replace_username(self):
if importlib.util.find_spec("psutil") is None:
sys.modules["psutil"] = types.ModuleType("psutil")
arguments = [
"SERVER=127.0.0.1",
"PORT=35601",
"USER=s01",
"PASSWORD=USER_DEFAULT_PASSWORD",
"INTERVAL=2",
"NOTUSER=ignored",
]
expected = {
"SERVER": "127.0.0.1",
"PORT": "35601",
"USER": "s01",
"PASSWORD": "USER_DEFAULT_PASSWORD",
"INTERVAL": "2",
}
for filename in ("client-linux.py", "client-psutil.py"):
with self.subTest(client=filename):
namespace = runpy.run_path(str(CLIENT_DIR / filename))
self.assertEqual(namespace["parse_cli_args"](arguments), expected)
if __name__ == "__main__":
unittest.main()
+27
View File
@@ -0,0 +1,27 @@
services:
serverstatus-client:
build:
context: .
dockerfile: Dockerfile.client
image: cppla/serverstatus:client
container_name: serverstatus-client
restart: unless-stopped
network_mode: host
pid: host
environment:
SERVER: "${SERVER:-127.0.0.1}"
# 注意:USER 会被 docker compose 在宿主机环境中先插值。
# 如果运行时没有显式传递,或传递方式不正确,可能会被 Docker/系统环境中的 $USER
# 替换成本机用户名,而不是期望的默认 s01。
# 推荐优先级:运行程序时显式传递 USER > 用户修改这里的 USER 默认值 > Docker/系统环境。
USER: "${USER:-s01}"
PORT: "${PORT:-35601}"
PASSWORD: "${PASSWORD:-USER_DEFAULT_PASSWORD}"
INTERVAL: "${INTERVAL:-1}"
PROBEPORT: "${PROBEPORT:-80}"
PROBE_PROTOCOL_PREFER: "${PROBE_PROTOCOL_PREFER:-ipv4}"
PING_PACKET_HISTORY_LEN: "${PING_PACKET_HISTORY_LEN:-100}"
CU: "${CU:-cu.tz.cloudcpp.com}"
CT: "${CT:-ct.tz.cloudcpp.com}"
CM: "${CM:-cm.tz.cloudcpp.com}"
CLIENT: "${CLIENT:-psutil}"
+32
View File
@@ -0,0 +1,32 @@
services:
serverstatus-server:
build:
context: .
dockerfile: Dockerfile.server
image: cppla/serverstatus:server
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/api/health"]
interval: 30s
timeout: 10s
retries: 5
container_name: serverstatus-server
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:/app/config/config.json
- ./web/json:/app/data
ports:
- 35601:35601
- 8080:80
networks:
serverstatus-network:
ipam:
config:
- subnet: 172.23.0.0/24
-25
View File
@@ -1,25 +0,0 @@
version: "3"
services:
serverstatus:
build:
context: .
dockerfile: Dockerfile
image: serverstatus_server
container_name: serverstatus
restart: unless-stopped
networks:
serverstatus-network:
ipv4_address: 172.23.0.2
volumes:
- ./server/config.json:/ServerStatus/server/config.json
- ./web/json:/usr/share/nginx/html/json
ports:
- 35601:35601
- 8080:80
networks:
serverstatus-network:
name: serverstatus-network
ipam:
config:
- subnet: 172.23.0.0/24
-10
View File
@@ -1,10 +0,0 @@
FROM python:alpine
LABEL maintainer="lidalao"
LABEL version="0.0.1"
LABEL description="Telegram Bot for ServerStatus"
WORKDIR /app
RUN pip install requests
COPY ./bot-telegram.py .
CMD [ "python", "./bot-telegram.py" ]
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env python3
# coding: utf-8
# Create by : https://github.com/lidalao/ServerStatus
# 版本:0.0.1, 支持Python版本:2.7 to 3.9
# 支持操作系统: Linux, OSX, FreeBSD, OpenBSD and NetBSD, both 32-bit and 64-bit architectures
import os
import sys
import requests
import time
import traceback
NODE_STATUS_URL = 'http://serverstatus/json/stats.json'
offs = []
counterOff = {}
counterOn = {}
def _send(text):
chat_id = os.getenv('TG_CHAT_ID')
bot_token = os.environ.get('TG_BOT_TOKEN')
url = f"https://api.telegram.org/bot{bot_token}/sendMessage?parse_mode=HTML&disable_web_page_preview=true&chat_id=" + chat_id + "&text=" + text
try:
requests.get(url)
except Exception as e:
print("catch exception: ", traceback.format_exc())
def send2tg(srv, flag):
if srv not in counterOff:
counterOff[srv] = 0
if srv not in counterOn:
counterOn[srv] = 0
if flag == 1 : # online
if srv in offs:
if counterOn[srv] < 10:
counterOn[srv] += 1
return
#1. Remove srv from offs; 2. Send to tg: I am online
offs.remove(srv)
counterOn[srv] = 0
text = '<b>Server Status</b>' + '\n主机上线: ' + srv
_send(text)
else: #offline
if srv not in offs:
if counterOff[srv] < 10:
counterOff[srv] += 1
return
#1. Append srv to offs; 2. Send to tg: I am offline
offs.append(srv)
counterOff[srv] = 0
text = '<b>Server Status</b>' + '\n主机下线: ' + srv
_send(text)
def sscmd(address):
while True:
r = requests.get(url=address, headers={"User-Agent": "ServerStatus/20211116"})
try:
jsonR = r.json()
except Exception as e:
print('未发现任何节点')
continue
for i in jsonR["servers"]:
if i["online4"] is False and i["online6"] is False:
send2tg(i["name"], 0)
else:
send2tg(i["name"], 1)
time.sleep(3)
if __name__ == '__main__':
sscmd(NODE_STATUS_URL)
-38
View File
@@ -1,38 +0,0 @@
version: "3"
services:
serverstatus:
build:
context: ..
dockerfile: Dockerfile
image: serverstatus_server
container_name: serverstatus
restart: unless-stopped
networks:
serverstatus-network:
ipv4_address: 172.23.0.2
volumes:
- ../server/config.json:/ServerStatus/server/config.json
- ../web/json:/usr/share/nginx/html/json
ports:
- 35601:35601
- 8080:80
bot:
build:
context: .
dockerfile: Dockerfile-telegram
image: serverstatus_bot
container_name: bot4sss
restart: unless-stopped
networks:
serverstatus-network:
ipv4_address: 172.23.0.3
environment:
- TG_CHAT_ID=${TG_CHAT_ID}
- TG_BOT_TOKEN=${TG_BOT_TOKEN}
networks:
serverstatus-network:
name: serverstatus-network
ipam:
config:
- subnet: 172.23.0.0/24
+3 -1
View File
@@ -1,2 +1,4 @@
sergate
serverstatus
*.test
coverage.out
.tags*
-34
View File
@@ -1,34 +0,0 @@
OUT = sergate
#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)/%.o: $(SDIR)/%.c
$(CC) -c $(INC) $(CFLAGS) $< -o $@
$(ODIR)/%.o: $(SDIR)/%.cpp
$(CXX) -c $(INC) $(CXXFLAGS) $< -o $@
$(OUT): $(OBJS)
$(CXX) $(LIBS) $^ -o $(OUT) -lcurl
.PHONY: clean
clean:
rm -f $(ODIR)/*.o $(OUT)
+472
View File
@@ -0,0 +1,472 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"math"
"net"
"os"
"strconv"
"sync"
"sync/atomic"
"time"
)
type Options struct {
ConfigPath string
StatsPath string
WebDir string
HTTPAddr string
AgentAddr string
AdminToken string
CORSOrigin string
InsecureCallbackTLS bool
Verbose bool
}
type NodeState struct {
Config ServerConfig
Connected bool
Connection net.Conn
ConnectionID uint64
Family int
Online4 bool
Online6 bool
Stats AgentStats
HasUpdate bool
LastNetworkIn int64
LastNetworkOut int64
LastUpdate time.Time
AlarmLast map[string]time.Time
Pong bool
}
type App struct {
opts Options
startedAt time.Time
ctx context.Context
cancel context.CancelFunc
mutationMu sync.Mutex
configMu sync.RWMutex
document ConfigDocument
runtime RuntimeConfig
nodeMu sync.RWMutex
nodes map[string]*NodeState
connectionID atomic.Uint64
generation atomic.Uint64
agentRunning atomic.Bool
reloadWrites atomic.Int32
certMu sync.RWMutex
certs map[string]*CertState
statsWake chan struct{}
persistMu sync.Mutex
logger *log.Logger
}
func NewApp(opts Options) (*App, error) {
doc, runtime, err := readConfig(opts.ConfigPath)
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
app := &App{
opts: opts,
startedAt: time.Now(),
ctx: ctx,
cancel: cancel,
nodes: make(map[string]*NodeState),
certs: make(map[string]*CertState),
statsWake: make(chan struct{}, 1),
logger: log.New(os.Stdout, "serverstatus ", log.LstdFlags|log.Lmicroseconds),
}
app.applyValidatedConfig(doc, runtime, false)
app.restorePersistentState()
return app, nil
}
func (a *App) StartBackground() {
go a.statsLoop()
go a.sslLoop()
a.wakeStatsWriter()
}
func (a *App) Close() {
a.cancel()
a.disconnectAll("Server shutting down...")
_ = a.PersistStats()
}
func (a *App) ConfigSnapshot() ConfigDocument {
a.configMu.RLock()
defer a.configMu.RUnlock()
clone, err := cloneDocument(a.document)
if err != nil {
panic(err)
}
return clone
}
func (a *App) RuntimeSnapshot() RuntimeConfig {
a.configMu.RLock()
defer a.configMu.RUnlock()
result := a.runtime
result.Servers = append([]ServerConfig(nil), a.runtime.Servers...)
result.Monitors = append([]MonitorConfig(nil), a.runtime.Monitors...)
result.SSLCerts = append([]SSLCertConfig(nil), a.runtime.SSLCerts...)
result.Watchdogs = append([]CompiledWatchdog(nil), a.runtime.Watchdogs...)
return result
}
func (a *App) ReplaceConfig(input ConfigDocument) (ConfigDocument, *APIError) {
a.mutationMu.Lock()
defer a.mutationMu.Unlock()
normalized, runtime, apiErr := normalizeConfig(input)
if apiErr != nil {
return nil, apiErr
}
if err := writeConfig(a.opts.ConfigPath, normalized); err != nil {
return nil, &APIError{Status: 500, Message: "config could not be written", Details: map[string]any{"error": err.Error()}}
}
a.applyValidatedConfig(normalized, runtime, true)
return a.ConfigSnapshot(), nil
}
func (a *App) MutateConfig(mutate func(ConfigDocument) *APIError) (ConfigDocument, *APIError) {
a.mutationMu.Lock()
defer a.mutationMu.Unlock()
doc := a.ConfigSnapshot()
if apiErr := mutate(doc); apiErr != nil {
return nil, apiErr
}
normalized, runtime, apiErr := normalizeConfig(doc)
if apiErr != nil {
return nil, apiErr
}
if err := writeConfig(a.opts.ConfigPath, normalized); err != nil {
return nil, &APIError{Status: 500, Message: "config could not be written", Details: map[string]any{"error": err.Error()}}
}
a.applyValidatedConfig(normalized, runtime, true)
return a.ConfigSnapshot(), nil
}
func (a *App) ReloadConfig() *APIError {
a.mutationMu.Lock()
defer a.mutationMu.Unlock()
doc, runtime, err := readConfig(a.opts.ConfigPath)
if err != nil {
if apiErr, ok := err.(*APIError); ok {
return apiErr
}
return &APIError{Status: 500, Message: "config could not be reloaded", Details: map[string]any{"error": err.Error()}}
}
a.applyValidatedConfig(doc, runtime, true)
return nil
}
func (a *App) applyValidatedConfig(doc ConfigDocument, runtime RuntimeConfig, disconnect bool) {
a.configMu.Lock()
a.nodeMu.Lock()
oldNodes := a.nodes
newNodes := make(map[string]*NodeState, len(runtime.Servers))
connections := make([]net.Conn, 0)
for _, server := range runtime.Servers {
node := &NodeState{Config: server, AlarmLast: make(map[string]time.Time)}
if old := oldNodes[server.Username]; old != nil && sameServerIdentity(old.Config, server) {
node.LastNetworkIn = old.LastNetworkIn
node.LastNetworkOut = old.LastNetworkOut
node.Stats = old.Stats
node.HasUpdate = old.HasUpdate
node.AlarmLast = old.AlarmLast
if !disconnect {
node.Connected = old.Connected
node.Connection = old.Connection
node.ConnectionID = old.ConnectionID
node.Family = old.Family
node.Online4 = old.Online4
node.Online6 = old.Online6
}
}
newNodes[server.Username] = node
}
if disconnect {
for _, node := range oldNodes {
if node.Connection != nil {
connections = append(connections, node.Connection)
}
}
}
a.document = doc
a.runtime = runtime
a.nodes = newNodes
a.generation.Add(1)
a.nodeMu.Unlock()
a.configMu.Unlock()
a.reconcileCerts(runtime.SSLCerts)
if disconnect {
for _, conn := range connections {
_, _ = conn.Write([]byte("Server reloading...\n"))
_ = conn.Close()
}
}
a.reloadWrites.Store(2)
a.wakeStatsWriter()
}
func sameServerIdentity(left, right ServerConfig) bool {
return left.Username == right.Username && left.Name == right.Name && left.Type == right.Type && left.Host == right.Host && left.Location == right.Location
}
func (a *App) disconnectAll(reason string) {
a.nodeMu.Lock()
connections := make([]net.Conn, 0)
for _, node := range a.nodes {
if node.Connection != nil {
connections = append(connections, node.Connection)
node.Connection = nil
node.Connected = false
node.Online4 = false
node.Online6 = false
}
}
a.nodeMu.Unlock()
for _, conn := range connections {
if reason != "" {
_, _ = conn.Write([]byte(reason + "\n"))
}
_ = conn.Close()
}
}
func (a *App) statsLoop() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
case <-a.statsWake:
}
if err := a.PersistStats(); err != nil {
a.logger.Printf("write stats: %v", err)
}
}
}
func (a *App) wakeStatsWriter() {
select {
case a.statsWake <- struct{}{}:
default:
}
}
func (a *App) SnapshotStats() map[string]any {
return a.snapshotStats(false)
}
func (a *App) snapshotStats(consumeReload bool) map[string]any {
runtime := a.RuntimeSnapshot()
now := time.Now()
servers := make([]any, 0, len(runtime.Servers))
a.nodeMu.Lock()
for _, server := range runtime.Servers {
if server.Disabled {
continue
}
node := a.nodes[server.Username]
if node == nil {
continue
}
base := map[string]any{
"name": server.Name, "type": server.Type, "host": server.Host, "location": server.Location,
"online4": false, "online6": false,
}
if node.Connected && node.HasUpdate {
s := node.Stats
updateTrafficBaselines(node, s.NetworkIn, s.NetworkOut, monthResetWindow(now, server.MonthStart))
base["online4"] = node.Online4
base["online6"] = node.Online6
base["uptime"] = formatUptime(s.Uptime)
base["load_1"], base["load_5"], base["load_15"] = round2(s.Load1), round2(s.Load5), round2(s.Load15)
base["ping_10010"], base["ping_189"], base["ping_10086"] = round2(s.Ping10010), round2(s.Ping189), round2(s.Ping10086)
base["time_10010"], base["time_189"], base["time_10086"] = s.Time10010, s.Time189, s.Time10086
base["tcp_count"], base["udp_count"] = s.TCPCount, s.UDPCount
base["process_count"], base["thread_count"] = s.ProcessCount, s.ThreadCount
base["network_rx"], base["network_tx"] = s.NetworkRX, s.NetworkTX
base["network_in"], base["network_out"] = s.NetworkIn, s.NetworkOut
base["cpu"], base["cpu_cores"], base["cpu_model"] = int(s.CPU), s.CPUCores, s.CPUModel
base["memory_total"], base["memory_used"] = s.MemoryTotal, s.MemoryUsed
base["swap_total"], base["swap_used"] = s.SwapTotal, s.SwapUsed
base["hdd_total"], base["hdd_used"] = s.HDDTotal, s.HDDUsed
base["last_network_in"] = trafficBaseline(s.NetworkIn, node.LastNetworkIn)
base["last_network_out"] = trafficBaseline(s.NetworkOut, node.LastNetworkOut)
base["io_read"], base["io_write"] = s.IORead, s.IOWrite
base["custom"], base["os"] = s.Custom, s.OS
} else {
base["last_network_in"] = node.LastNetworkIn
base["last_network_out"] = node.LastNetworkOut
base["os"] = node.Stats.OS
base["cpu_model"] = node.Stats.CPUModel
}
servers = append(servers, base)
}
a.nodeMu.Unlock()
result := map[string]any{
"servers": servers,
"sslcerts": a.sslSnapshot(runtime.SSLCerts, now),
"updated": strconv.FormatInt(now.Unix(), 10),
}
if a.reloadWrites.Load() > 0 {
result["reload"] = true
if consumeReload {
a.reloadWrites.Add(-1)
}
}
return result
}
func (a *App) PersistStats() error {
a.persistMu.Lock()
defer a.persistMu.Unlock()
return writeStatsFile(a.opts.StatsPath, a.snapshotStats(true))
}
func monthResetWindow(now time.Time, monthStart int) bool {
return now.Day() == clamp(monthStart, 1, 28) && now.Hour() == 0 && now.Minute() < 5
}
func trafficBaseline(current, baseline int64) int64 {
if current == 0 || baseline == 0 {
return current
}
return baseline
}
func updateTrafficBaselines(node *NodeState, currentIn, currentOut int64, reset bool) {
if reset {
node.LastNetworkIn = currentIn
node.LastNetworkOut = currentOut
return
}
if node.LastNetworkIn == 0 || (currentIn != 0 && node.LastNetworkIn > currentIn) {
node.LastNetworkIn = currentIn
}
if node.LastNetworkOut == 0 || (currentOut != 0 && node.LastNetworkOut > currentOut) {
node.LastNetworkOut = currentOut
}
}
func round2(value float64) float64 {
return math.Round(value*100) / 100
}
func formatUptime(seconds int64) string {
days := seconds / 86400
if days > 0 {
return fmt.Sprintf("%d 天", days)
}
return fmt.Sprintf("%02d:%02d:%02d", seconds/3600, (seconds/60)%60, seconds%60)
}
func (a *App) restorePersistentState() {
data, err := os.ReadFile(a.opts.StatsPath)
if err != nil {
data, err = os.ReadFile(a.opts.StatsPath + "~")
}
if err != nil {
return
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var previous struct {
Servers []map[string]any `json:"servers"`
}
if err := decoder.Decode(&previous); err != nil {
a.logger.Printf("read previous stats: %v", err)
return
}
a.nodeMu.Lock()
defer a.nodeMu.Unlock()
for _, node := range a.nodes {
for _, saved := range previous.Servers {
if fmt.Sprint(saved["name"]) != node.Config.Name || fmt.Sprint(saved["type"]) != node.Config.Type || fmt.Sprint(saved["host"]) != node.Config.Host || fmt.Sprint(saved["location"]) != node.Config.Location {
continue
}
node.LastNetworkIn = anyInt64(saved["last_network_in"])
node.LastNetworkOut = anyInt64(saved["last_network_out"])
node.Stats.OS = anyString(saved["os"])
node.Stats.CPUModel = anyString(saved["cpu_model"])
break
}
}
}
func anyString(value any) string {
if value == nil {
return ""
}
return fmt.Sprint(value)
}
func anyInt64(value any) int64 {
switch number := value.(type) {
case json.Number:
parsed, _ := number.Int64()
return parsed
case float64:
return int64(number)
case int64:
return number
case int:
return int64(number)
case string:
parsed, _ := strconv.ParseInt(number, 10, 64)
return parsed
default:
return 0
}
}
func (a *App) ResetTraffic(username string) (map[string]any, *APIError) {
a.nodeMu.Lock()
node := a.nodes[username]
if node == nil {
a.nodeMu.Unlock()
return nil, &APIError{Status: 404, Message: "server was not found", Details: map[string]any{"username": username}}
}
if !node.Connected || !node.HasUpdate {
a.nodeMu.Unlock()
return nil, &APIError{Status: 409, Message: "server has no current traffic counters; it may be offline", Details: map[string]any{"username": username}}
}
previousIn, previousOut := node.LastNetworkIn, node.LastNetworkOut
networkIn, networkOut := node.Stats.NetworkIn, node.Stats.NetworkOut
node.LastNetworkIn, node.LastNetworkOut = networkIn, networkOut
server := node.Config
a.nodeMu.Unlock()
a.wakeStatsWriter()
return map[string]any{
"server": server,
"stats": map[string]any{
"network_in": networkIn, "network_out": networkOut,
"previous_last_network_in": previousIn, "previous_last_network_out": previousOut,
"last_network_in": networkIn, "last_network_out": networkOut,
"month_in_before": max64(0, networkIn-previousIn), "month_out_before": max64(0, networkOut-previousOut),
},
}, nil
}
func max64(left, right int64) int64 {
if left > right {
return left
}
return right
}
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"net"
"testing"
)
func TestTrafficBaselinesResetIndependently(t *testing.T) {
node := &NodeState{LastNetworkIn: 100, LastNetworkOut: 0}
updateTrafficBaselines(node, 150, 500, false)
if node.LastNetworkIn != 100 || node.LastNetworkOut != 500 {
t.Fatalf("missing outbound baseline was not initialized independently: %#v", node)
}
node.LastNetworkOut = 700
updateTrafficBaselines(node, 200, 50, false)
if node.LastNetworkIn != 100 || node.LastNetworkOut != 50 {
t.Fatalf("outbound counter reset changed the wrong baseline: %#v", node)
}
updateTrafficBaselines(node, 900, 800, true)
if node.LastNetworkIn != 900 || node.LastNetworkOut != 800 {
t.Fatalf("monthly reset did not reset both baselines: %#v", node)
}
}
func TestDisconnectPreservesOfflineDisplayMetadata(t *testing.T) {
app := newTestApp(t, minimalTestConfig())
client, server := net.Pipe()
defer client.Close()
defer server.Close()
app.nodeMu.Lock()
node := app.nodes["s01"]
node.Connected = true
node.Connection = server
node.ConnectionID = 42
node.HasUpdate = true
node.Stats = AgentStats{OS: "linux", CPUModel: "Test CPU"}
app.nodeMu.Unlock()
app.disconnectAgent("s01", server, 42)
serverStats := app.SnapshotStats()["servers"].([]any)[0].(map[string]any)
if serverStats["online4"] != false || serverStats["online6"] != false {
t.Fatalf("disconnected node remained online: %#v", serverStats)
}
if serverStats["os"] != "linux" || serverStats["cpu_model"] != "Test CPU" {
t.Fatalf("offline display metadata was discarded: %#v", serverStats)
}
}
+65 -16
View File
@@ -19,14 +19,14 @@
"monthstart": 1
},
{
"disabled": true,
"username": "s03",
"name": "node3",
"type": "hyper",
"host": "host3",
"location": "🇫🇷",
"password": "USER_DEFAULT_PASSWORD",
"monthstart": 1
"monthstart": 1,
"disabled": false
},
{
"username": "s04",
@@ -38,16 +38,53 @@
"monthstart": 1
}
],
"monitors": [
{
"name": "抖音",
"host": "https://www.douyin.com",
"interval": 600,
"type": "https"
},
{
"name": "百度",
"host": "https://www.baidu.com",
"interval": 600,
"type": "https"
}
],
"sslcerts": [
{
"name": "my.cloudcpp.com",
"domain": "https://my.cloudcpp.com",
"port": 443,
"interval": 7200,
"callback": "https://yourSMSurl"
},
{
"name": "tz.cloudcpp.com",
"domain": "https://tz.cloudcpp.com",
"port": 443,
"interval": 7200,
"callback": "https://yourSMSurl"
},
{
"name": "3.0.2.1",
"domain": "https://3.0.2.1",
"port": 443,
"interval": 7200,
"callback": "https://yourSMSurl"
}
],
"watchdog": [
{
"name": "cpu high warning",
"rule": "cpu>90&load_1>3",
"name": "cpu high warning,exclude username s01",
"rule": "cpu>90&load_1>5&username!='s01'",
"interval": 600,
"callback": "https://yourSMSurl"
},
{
"name": "memory high warning",
"rule": "(memory_used/memory_total)*100>90",
"name": "memory high warning, exclude less than 1GB vps",
"rule": "(memory_used/memory_total)*100>90&memory_total>1048576",
"interval": 300,
"callback": "https://yourSMSurl"
},
@@ -58,17 +95,29 @@
"callback": "https://yourSMSurl"
},
{
"name": "ddcc attack",
"rule": "tcp_count>500",
"interval": 300,
"callback": "https://yourSMSurl"
},
"name": "ddcc attack,limit type Oracle",
"rule": "tcp_count>600&type='Oracle'",
"interval": 300,
"callback": "https://yourSMSurl"
},
{
"name": "month traffic warning",
"rule": "(network_out-last_network_out)/1024/1024/1024>999",
"interval": 3600,
"callback": "https://yourSMSurl"
},
"name": "month 999GB traffic warning",
"rule": "(network_out-last_network_out)/1024/1024/1024>999",
"interval": 3600,
"callback": "https://yourSMSurl"
},
{
"name": "aliyun china free 18GB traffic warning",
"rule": "(network_out-last_network_out)/1024/1024/1024>18&(username='aliyun1'|username='aliyun2')",
"interval": 3600,
"callback": "https://yourSMSurl"
},
{
"name": "packet loss rate warning",
"rule": "(ping_10010>10|ping_189>10|ping_10086>10)&(host='sgp'|host='qqhk'|host='hk-21-x'|host='hk-31-x')",
"interval": 3600,
"callback": "https://yourSMSurl"
},
{
"name": "you can parse an expression combining any known field",
"rule": "load_5>3",
+161
View File
@@ -0,0 +1,161 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"syscall"
"time"
)
func readConfig(path string) (ConfigDocument, RuntimeConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, RuntimeConfig{}, err
}
doc, err := decodeDocument(data)
if err != nil {
return nil, RuntimeConfig{}, fmt.Errorf("parse %s: %w", path, err)
}
normalized, runtime, apiErr := normalizeConfig(doc)
if apiErr != nil {
return nil, RuntimeConfig{}, apiErr
}
return normalized, runtime, nil
}
func marshalIndented(value any) ([]byte, error) {
data, err := jsonMarshalIndent(value)
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}
// jsonMarshalIndent is a variable so file-writing failure paths can be tested.
var jsonMarshalIndent = func(value any) ([]byte, error) {
return json.MarshalIndent(value, "", "\t")
}
func writeConfig(path string, doc ConfigDocument) error {
data, err := marshalIndented(doc)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
mode := os.FileMode(0o644)
if info, statErr := os.Stat(path); statErr == nil {
mode = info.Mode().Perm()
backup := fmt.Sprintf("%s.bak-%s", path, time.Now().Format("20060102-150405.000000000"))
if err := copyFile(path, backup, mode); err != nil {
return fmt.Errorf("backup config: %w", err)
}
pruneBackups(path, 10)
} else if !errors.Is(statErr, os.ErrNotExist) {
return statErr
}
return atomicWrite(path, data, mode, true)
}
func writeStatsFile(path string, value any) error {
data, err := marshalIndented(value)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return atomicWrite(path, data, 0o644, false)
}
func atomicWrite(path string, data []byte, mode os.FileMode, allowBusyFallback bool) error {
directory := filepath.Dir(path)
tmp, err := os.CreateTemp(directory, ".serverstatus-*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err == nil {
return syncDirectory(directory)
} else if !allowBusyFallback || !errors.Is(err, syscall.EBUSY) {
return err
}
// Docker cannot rename over a single-file bind mount. The backup above is
// already durable, so truncate and sync the mounted inode as a fallback.
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
closeErr := file.Close()
if err != nil {
return err
}
return closeErr
}
func copyFile(source, destination string, mode os.FileMode) error {
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err = io.Copy(out, in); err == nil {
err = out.Sync()
}
closeErr := out.Close()
if err != nil {
return err
}
return closeErr
}
func pruneBackups(configPath string, keep int) {
matches, err := filepath.Glob(configPath + ".bak-*")
if err != nil || len(matches) <= keep {
return
}
sort.Strings(matches)
for _, path := range matches[:len(matches)-keep] {
_ = os.Remove(path)
}
}
func syncDirectory(directory string) error {
dir, err := os.Open(directory)
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
+40
View File
@@ -0,0 +1,40 @@
module github.com/cppla/serverstatus/server
go 1.25.0
require (
github.com/expr-lang/expr v1.17.8
github.com/gin-gonic/gin v1.12.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // 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.52.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+91
View File
@@ -0,0 +1,91 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+397
View File
@@ -0,0 +1,397 @@
package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
var (
version = "2.0.0"
commit = "none"
buildTime = "unknown"
)
func (a *App) HTTPServer() *http.Server {
if !a.opts.Verbose {
gin.SetMode(gin.ReleaseMode)
}
return &http.Server{
Addr: a.opts.HTTPAddr,
Handler: a.router(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
}
func (a *App) router() *gin.Engine {
router := gin.New()
router.Use(gin.Recovery(), a.securityHeaders(), a.corsMiddleware())
if a.opts.Verbose {
router.Use(gin.Logger())
}
router.GET("/api/health", a.healthHandler)
router.GET("/api/schema", a.schemaHandler)
router.GET("/api/openapi.json", func(c *gin.Context) {
c.JSON(http.StatusOK, openAPISpec())
})
router.GET("/json/stats.json", func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, a.SnapshotStats())
})
api := router.Group("/api", a.authMiddleware())
api.GET("/config", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "config": a.ConfigSnapshot()})
})
api.PUT("/config", func(c *gin.Context) {
body, apiErr := decodeRequestObject(c)
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
doc, apiErr := a.ReplaceConfig(ConfigDocument(body))
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "reloaded": true, "pid": os.Getpid(), "generation": a.generation.Load(), "config": doc})
})
for _, collection := range []string{"servers", "monitors", "sslcerts", "watchdog"} {
key := collection
api.GET("/"+key, func(c *gin.Context) { a.getCollectionHandler(c, key) })
api.POST("/"+key, func(c *gin.Context) { a.createCollectionHandler(c, key) })
api.PUT("/"+key+"/:id", func(c *gin.Context) { a.updateCollectionHandler(c, key) })
api.DELETE("/"+key+"/:id", func(c *gin.Context) { a.deleteCollectionHandler(c, key) })
}
api.POST("/servers/:id/reset-traffic", a.resetTrafficHandler)
api.POST("/reload", func(c *gin.Context) {
if apiErr := a.ReloadConfig(); apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "operation": "reload", "pid": os.Getpid(), "generation": a.generation.Load()})
})
api.POST("/restart", func(c *gin.Context) {
if apiErr := a.ReloadConfig(); apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
c.JSON(http.StatusAccepted, gin.H{"ok": true, "operation": "restart", "mode": "in-process", "pid": os.Getpid(), "generation": a.generation.Load()})
})
router.NoRoute(a.staticHandler)
return router
}
func (a *App) securityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "SAMEORIGIN")
c.Header("Referrer-Policy", "same-origin")
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.Header("Cache-Control", "no-store")
}
c.Next()
}
}
func (a *App) corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if a.opts.CORSOrigin != "" {
c.Header("Access-Control-Allow-Origin", a.opts.CORSOrigin)
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Admin-Token")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Vary", "Origin")
}
if c.Request.Method == http.MethodOptions && strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
func (a *App) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if a.opts.AdminToken == "" {
a.writeAPIError(c, &APIError{Status: 503, Message: "management API is disabled; set ADMIN_TOKEN to enable it"})
c.Abort()
return
}
token := ""
authorization := c.GetHeader("Authorization")
if len(authorization) >= 7 && strings.EqualFold(authorization[:7], "bearer ") {
token = strings.TrimSpace(authorization[7:])
}
if token == "" {
token = strings.TrimSpace(c.GetHeader("X-Admin-Token"))
}
if len(token) != len(a.opts.AdminToken) || subtle.ConstantTimeCompare([]byte(token), []byte(a.opts.AdminToken)) != 1 {
a.writeAPIError(c, &APIError{Status: 401, Message: "invalid or missing admin token"})
c.Abort()
return
}
c.Next()
}
}
func (a *App) healthHandler(c *gin.Context) {
running := a.agentRunning.Load()
c.JSON(http.StatusOK, gin.H{
"ok": true, "enabled": a.opts.AdminToken != "",
"service": gin.H{"running": true, "pid": os.Getpid(), "version": version, "uptime": int64(time.Since(a.startedAt).Seconds()), "generation": a.generation.Load()},
"agent": gin.H{"running": running, "address": a.opts.AgentAddr},
"configPath": a.opts.ConfigPath,
})
}
func (a *App) schemaHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "schema": apiSchema(a.opts.AdminToken != "")})
}
func apiSchema(enabled bool) map[string]any {
endpoints := []any{
map[string]any{"method": "GET", "path": "/api/health", "auth": false},
map[string]any{"method": "GET", "path": "/api/schema", "auth": false},
map[string]any{"method": "GET", "path": "/api/openapi.json", "auth": false},
map[string]any{"method": "GET", "path": "/api/config", "auth": true},
map[string]any{"method": "PUT", "path": "/api/config", "auth": true, "body": "full config JSON"},
}
for _, key := range []string{"servers", "monitors", "sslcerts", "watchdog"} {
spec := collectionSpecs[key]
endpoints = append(endpoints,
map[string]any{"method": "GET", "path": "/api/" + key, "auth": true},
map[string]any{"method": "POST", "path": "/api/" + key, "auth": true, "body": spec.itemName + " JSON"},
map[string]any{"method": "PUT", "path": "/api/" + key + "/{id}", "auth": true, "body": spec.itemName + " JSON"},
map[string]any{"method": "DELETE", "path": "/api/" + key + "/{id}", "auth": true},
)
}
endpoints = append(endpoints,
map[string]any{"method": "POST", "path": "/api/servers/{username}/reset-traffic", "auth": true},
map[string]any{"method": "POST", "path": "/api/reload", "auth": true},
map[string]any{"method": "POST", "path": "/api/restart", "auth": true},
)
collections := make(map[string]any)
for key, spec := range collectionSpecs {
collections[key] = map[string]any{"item": spec.itemName, "idField": spec.idField, "required": spec.required, "optional": spec.optional}
}
return map[string]any{
"version": version,
"auth": map[string]any{"type": "bearer", "header": "Authorization: Bearer <ADMIN_TOKEN>", "enabled": enabled},
"endpoints": endpoints, "collections": collections,
}
}
func decodeRequestObject(c *gin.Context) (map[string]any, *APIError) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBody)
decoder := json.NewDecoder(c.Request.Body)
decoder.UseNumber()
var object map[string]any
if err := decoder.Decode(&object); err != nil {
status := http.StatusBadRequest
if strings.Contains(err.Error(), "request body too large") {
status = http.StatusRequestEntityTooLarge
}
return nil, &APIError{Status: status, Message: "invalid JSON body", Details: map[string]any{"error": err.Error()}}
}
if object == nil {
return nil, &APIError{Status: 400, Message: "request body must be an object"}
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, &APIError{Status: 400, Message: "request body must contain one JSON value"}
}
return object, nil
}
func (a *App) getCollectionHandler(c *gin.Context, key string) {
doc := a.ConfigSnapshot()
items, _ := doc[key].([]any)
c.JSON(http.StatusOK, gin.H{"ok": true, key: items})
}
func (a *App) createCollectionHandler(c *gin.Context, key string) {
item, apiErr := decodeRequestObject(c)
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
doc, apiErr := a.MutateConfig(func(doc ConfigDocument) *APIError {
items, _ := doc[key].([]any)
doc[key] = append(items, item)
return nil
})
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
items, _ := doc[key].([]any)
created := items[len(items)-1]
c.JSON(http.StatusCreated, gin.H{"ok": true, collectionSpecs[key].itemName: created, "reloaded": true, "pid": os.Getpid(), "config": doc})
}
func (a *App) updateCollectionHandler(c *gin.Context, key string) {
item, apiErr := decodeRequestObject(c)
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
id := c.Param("id")
if id == "" {
a.writeAPIError(c, &APIError{Status: 400, Message: "item id is required"})
return
}
var updated any
doc, apiErr := a.MutateConfig(func(doc ConfigDocument) *APIError {
items, _ := doc[key].([]any)
index, _, findErr := findCollectionItem(items, collectionSpecs[key].idField, id, key != "servers")
if findErr != nil {
return findErr
}
if index < 0 {
return &APIError{Status: 404, Message: collectionSpecs[key].itemName + " was not found", Details: map[string]any{"id": id}}
}
items[index] = item
doc[key] = items
updated = item
return nil
})
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
// Return the normalized item from the resulting document.
items, _ := doc[key].([]any)
if itemMap, ok := updated.(map[string]any); ok {
idValue := fmt.Sprint(itemMap[collectionSpecs[key].idField])
if index, normalized, _ := findCollectionItem(items, collectionSpecs[key].idField, idValue, key != "servers"); index >= 0 {
updated = normalized
}
}
c.JSON(http.StatusOK, gin.H{"ok": true, collectionSpecs[key].itemName: updated, "reloaded": true, "pid": os.Getpid(), "config": doc})
}
func (a *App) deleteCollectionHandler(c *gin.Context, key string) {
id := c.Param("id")
if id == "" {
a.writeAPIError(c, &APIError{Status: 400, Message: "item id is required"})
return
}
var removed any
doc, apiErr := a.MutateConfig(func(doc ConfigDocument) *APIError {
items, _ := doc[key].([]any)
index, item, findErr := findCollectionItem(items, collectionSpecs[key].idField, id, key != "servers")
if findErr != nil {
return findErr
}
if index < 0 {
return &APIError{Status: 404, Message: collectionSpecs[key].itemName + " was not found", Details: map[string]any{"id": id}}
}
removed = item
doc[key] = append(items[:index], items[index+1:]...)
return nil
})
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "removed": removed, "reloaded": true, "pid": os.Getpid(), "config": doc})
}
func findCollectionItem(items []any, idField, id string, allowIndex bool) (int, any, *APIError) {
if numeric, err := strconv.Atoi(id); allowIndex && err == nil {
if numeric >= 0 && numeric < len(items) {
return numeric, items[numeric], nil
}
return -1, nil, nil
}
matches := make([]int, 0, 1)
for index, raw := range items {
item, ok := raw.(map[string]any)
if ok && fmt.Sprint(item[idField]) == id {
matches = append(matches, index)
}
}
if len(matches) > 1 {
return -1, nil, &APIError{Status: 409, Message: "collection has duplicate " + idField + "; use numeric index instead", Details: map[string]any{"id": id}}
}
if len(matches) == 1 {
return matches[0], items[matches[0]], nil
}
return -1, nil, nil
}
func (a *App) resetTrafficHandler(c *gin.Context) {
username := c.Param("id")
if username == "" {
a.writeAPIError(c, &APIError{Status: 400, Message: "username is required"})
return
}
result, apiErr := a.ResetTraffic(username)
if apiErr != nil {
a.writeAPIError(c, apiErr)
return
}
result["ok"] = true
result["operation"] = "reset-traffic"
result["pid"] = os.Getpid()
c.JSON(http.StatusOK, result)
}
func (a *App) writeAPIError(c *gin.Context, apiErr *APIError) {
payload := gin.H{"ok": false, "error": apiErr.Message}
if apiErr.Details != nil {
payload["details"] = apiErr.Details
}
c.JSON(apiErr.Status, payload)
}
func (a *App) staticHandler(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") || strings.HasPrefix(c.Request.URL.Path, "/json/") {
c.JSON(http.StatusNotFound, gin.H{"ok": false, "error": "endpoint was not found"})
return
}
requestPath := filepath.Clean("/" + c.Request.URL.Path)
if requestPath == "/" {
requestPath = "/index.html"
}
root, err := filepath.Abs(a.opts.WebDir)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
filePath := filepath.Join(root, strings.TrimPrefix(requestPath, "/"))
if filePath != root && !strings.HasPrefix(filePath, root+string(os.PathSeparator)) {
c.Status(http.StatusNotFound)
return
}
info, err := os.Stat(filePath)
if err != nil || info.IsDir() {
c.Status(http.StatusNotFound)
return
}
if contentType := mime.TypeByExtension(filepath.Ext(filePath)); contentType != "" {
c.Header("Content-Type", contentType)
}
if filepath.Base(filePath) == "index.html" {
c.Header("Cache-Control", "no-cache")
}
http.ServeFile(c.Writer, c.Request, filePath)
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"testing"
)
func TestHTTPAPIAndStaticUI(t *testing.T) {
app := newTestApp(t, minimalTestConfig())
router := app.router()
response := performRequest(router, http.MethodGet, "/", "", "")
if response.Code != 200 || !strings.Contains(response.Body.String(), "test-ui") {
t.Fatalf("static UI: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodGet, "/api/health", "", "")
if response.Code != 200 || !strings.Contains(response.Body.String(), `"enabled":true`) {
t.Fatalf("health: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodGet, "/api/openapi.json", "", "")
if response.Code != 200 {
t.Fatalf("openapi: status=%d body=%s", response.Code, response.Body.String())
}
var openapi map[string]any
if err := json.Unmarshal(response.Body.Bytes(), &openapi); err != nil {
t.Fatal(err)
}
paths := openapi["paths"].(map[string]any)
if openapi["openapi"] != "3.1.0" || paths["/api/servers/{username}"] == nil || paths["/api/watchdog/{id}"] == nil {
t.Fatalf("OpenAPI document is incomplete: %#v", openapi)
}
monitorOperations := paths["/api/monitors"].(map[string]any)
createResponses := monitorOperations["post"].(map[string]any)["responses"].(map[string]any)
if createResponses["201"] == nil || createResponses["200"] != nil {
t.Fatalf("OpenAPI create response must describe HTTP 201: %#v", createResponses)
}
listResponses := monitorOperations["get"].(map[string]any)["responses"].(map[string]any)
listSchema := listResponses["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
if listSchema["properties"].(map[string]any)["monitors"] == nil {
t.Fatalf("OpenAPI list response does not expose monitors: %#v", listSchema)
}
restartResponses := paths["/api/restart"].(map[string]any)["post"].(map[string]any)["responses"].(map[string]any)
if restartResponses["202"] == nil {
t.Fatalf("OpenAPI restart response must describe HTTP 202: %#v", restartResponses)
}
response = performRequest(router, http.MethodGet, "/api/config", "", "wrong")
if response.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", response.Code)
}
response = performRequest(router, http.MethodGet, "/api/config", "", "test-token")
if response.Code != 200 {
t.Fatalf("config: status=%d body=%s", response.Code, response.Body.String())
}
monitor := `{"name":"新增服务","host":"tcp://127.0.0.1:80","type":"tcp","interval":"30"}`
response = performRequest(router, http.MethodPost, "/api/monitors", monitor, "test-token")
if response.Code != http.StatusCreated || !strings.Contains(response.Body.String(), "新增服务") {
t.Fatalf("create monitor: status=%d body=%s", response.Code, response.Body.String())
}
path := "/api/monitors/" + url.PathEscape("新增服务")
monitor = `{"name":"更新服务","host":"https://example.org","type":"https","interval":45}`
response = performRequest(router, http.MethodPut, path, monitor, "test-token")
if response.Code != 200 || !strings.Contains(response.Body.String(), "更新服务") {
t.Fatalf("update monitor: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodDelete, "/api/monitors/"+url.PathEscape("更新服务"), "", "test-token")
if response.Code != 200 {
t.Fatalf("delete monitor: status=%d body=%s", response.Code, response.Body.String())
}
percentMonitor := `{"name":"rate%check","host":"https://example.com","type":"https","interval":30}`
response = performRequest(router, http.MethodPost, "/api/monitors", percentMonitor, "test-token")
if response.Code != http.StatusCreated {
t.Fatalf("create percent monitor: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodDelete, "/api/monitors/"+url.PathEscape("rate%check"), "", "test-token")
if response.Code != 200 {
t.Fatalf("delete percent monitor: status=%d body=%s", response.Code, response.Body.String())
}
server := `{"username":"s02","name":"node2","type":"kvm","host":"host2","location":"JP","password":"secret","monthstart":31}`
response = performRequest(router, http.MethodPost, "/api/servers", server, "test-token")
if response.Code != http.StatusCreated || !strings.Contains(response.Body.String(), `"monthstart":28`) {
t.Fatalf("create server: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodDelete, "/api/servers/s02", "", "test-token")
if response.Code != 200 {
t.Fatalf("delete server: status=%d body=%s", response.Code, response.Body.String())
}
numericServer := `{"username":"0","name":"numeric","type":"kvm","host":"host0","location":"US","password":"secret","monthstart":1}`
response = performRequest(router, http.MethodPost, "/api/servers", numericServer, "test-token")
if response.Code != http.StatusCreated {
t.Fatalf("create numeric server: status=%d body=%s", response.Code, response.Body.String())
}
numericServer = `{"username":"0","name":"numeric-updated","type":"kvm","host":"host0","location":"US","password":"secret","monthstart":1}`
response = performRequest(router, http.MethodPut, "/api/servers/0", numericServer, "test-token")
if response.Code != 200 || !strings.Contains(response.Body.String(), "numeric-updated") {
t.Fatalf("update numeric server: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodDelete, "/api/servers/0", "", "test-token")
if response.Code != 200 || !strings.Contains(response.Body.String(), "numeric-updated") {
t.Fatalf("delete numeric server: status=%d body=%s", response.Code, response.Body.String())
}
if app.RuntimeSnapshot().Servers[0].Username != "s01" {
t.Fatal("numeric username operation modified the server at numeric index")
}
response = performRequest(router, http.MethodPost, "/api/servers/s01/reset-traffic", "", "test-token")
if response.Code != http.StatusConflict {
t.Fatalf("offline reset should conflict: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodPost, "/api/reload", "", "test-token")
if response.Code != 200 {
t.Fatalf("reload: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodPost, "/api/restart", "", "test-token")
if response.Code != http.StatusAccepted || !strings.Contains(response.Body.String(), "in-process") {
t.Fatalf("restart: status=%d body=%s", response.Code, response.Body.String())
}
data, err := os.ReadFile(app.opts.ConfigPath)
if err != nil {
t.Fatal(err)
}
var persisted map[string]any
if err := json.Unmarshal(data, &persisted); err != nil {
t.Fatalf("persisted config is invalid: %v", err)
}
}
func TestHTTPRejectsInvalidAndOversizedBodies(t *testing.T) {
app := newTestApp(t, minimalTestConfig())
router := app.router()
response := performRequest(router, http.MethodPost, "/api/servers", `{"name":`, "test-token")
if response.Code != 400 {
t.Fatalf("invalid JSON: status=%d body=%s", response.Code, response.Body.String())
}
response = performRequest(router, http.MethodPost, "/api/servers", `{"name":"x"}`, "test-token")
if response.Code != 400 {
t.Fatalf("missing fields: status=%d body=%s", response.Code, response.Body.String())
}
oversized := `{"name":"` + strings.Repeat("x", maxRequestBody) + `"}`
response = performRequest(router, http.MethodPost, "/api/servers", oversized, "test-token")
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized body: status=%d body=%s", response.Code, response.Body.String())
}
doc := app.ConfigSnapshot()
doc["watchdog"] = []any{map[string]any{"name": "broken", "rule": "cpu >", "interval": 10}}
data, _ := json.Marshal(doc)
response = performRequest(router, http.MethodPut, "/api/config", string(data), "test-token")
if response.Code != 400 {
t.Fatalf("invalid watchdog: status=%d body=%s", response.Code, response.Body.String())
}
}
-139
View File
@@ -1,139 +0,0 @@
#ifndef ARGPARSE_H
#define ARGPARSE_H
/**
* Command-line arguments parsing library.
*
* This module is inspired by parse-options.c (git) and python's argparse
* module.
*
* Arguments parsing is common task in cli program, but traditional `getopt`
* libraries are not easy to use. This library provides high-level arguments
* parsing solutions.
*
* The program defines what arguments it requires, and `argparse` will figure
* out how to parse those out of `argc` and `argv`, it also automatically
* generates help and usage messages and issues errors when users give the
* program invalid arguments.
*
* Reserved namespaces:
* argparse
* OPT
* Author: Yecheng Fu <cofyc.jackson@gmail.com>
*/
#include <assert.h>
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef __cplusplus
extern "C" {
#endif
struct argparse;
struct argparse_option;
typedef int argparse_callback(struct argparse *this_,
const struct argparse_option *option);
enum argparse_flag {
ARGPARSE_STOP_AT_NON_OPTION = 1,
};
enum argparse_option_type {
/* special */
ARGPARSE_OPT_END,
/* options with no arguments */
ARGPARSE_OPT_BOOLEAN,
ARGPARSE_OPT_BIT,
/* options with arguments (optional or required) */
ARGPARSE_OPT_INTEGER,
ARGPARSE_OPT_STRING,
};
enum argparse_option_flags {
OPT_NONEG = 1, /* Negation disabled. */
};
/*
* Argparse option struct.
*
* `type`:
* holds the type of the option, you must have an ARGPARSE_OPT_END last in your
* array.
*
* `short_name`:
* the character to use as a short option name, '\0' if none.
*
* `long_name`:
* the long option name, without the leading dash, NULL if none.
*
* `value`:
* stores pointer to the value to be filled.
*
* `help`:
* the short help message associated to what the option does.
* Must never be NULL (except for ARGPARSE_OPT_END).
*
* `callback`:
* function is called when corresponding argument is parsed.
*
* `data`:
* associated data. Callbacks can use it like they want.
*
* `flags`:
* option flags.
*
*/
struct argparse_option {
enum argparse_option_type type;
const char short_name;
const char *long_name;
void *value;
const char *help;
argparse_callback *callback;
intptr_t data;
int flags;
};
/*
* argpparse
*/
struct argparse {
// user supplied
const struct argparse_option *options;
const char *usage;
int flags;
// internal context
int argc;
const char **argv;
const char **out;
int cpidx;
const char *optvalue; // current option value
};
// builtin callbacks
int argparse_help_cb(struct argparse *this_,
const struct argparse_option *option);
// builtin option macros
#define OPT_END() { ARGPARSE_OPT_END, 0 }
#define OPT_BOOLEAN(...) { ARGPARSE_OPT_BOOLEAN, __VA_ARGS__ }
#define OPT_BIT(...) { ARGPARSE_OPT_BIT, __VA_ARGS__ }
#define OPT_INTEGER(...) { ARGPARSE_OPT_INTEGER, __VA_ARGS__ }
#define OPT_STRING(...) { ARGPARSE_OPT_STRING, __VA_ARGS__ }
#define OPT_HELP() OPT_BOOLEAN('h', "help", 0, "Show this help message and exit", argparse_help_cb)
int argparse_init(struct argparse *this_, struct argparse_option *options,
const char *usage, int flags);
int argparse_parse(struct argparse *this_, int argc, const char **argv);
void argparse_usage(struct argparse *this_);
#ifdef __cplusplus
}
#endif
#endif
-149
View File
@@ -1,149 +0,0 @@
/* (c) Magnus Auvinen. See licence.txt in the root of the distribution for more information. */
/* If you are missing that file, acquire a complete release at teeworlds.com. */
#ifndef BASE_DETECT_H
#define BASE_DETECT_H
/*
this file detected the family, platform and architecture
to compile for.
*/
/* platforms */
/* windows Family */
#if defined(WIN64) || defined(_WIN64)
/* Hmm, is this IA64 or x86-64? */
#define CONF_FAMILY_WINDOWS 1
#define CONF_FAMILY_STRING "windows"
#define CONF_PLATFORM_WIN64 1
#define CONF_PLATFORM_STRING "win64"
#elif defined(WIN32) || defined(_WIN32) || defined(__CYGWIN32__) || defined(__MINGW32__)
#define CONF_FAMILY_WINDOWS 1
#define CONF_FAMILY_STRING "windows"
#define CONF_PLATFORM_WIN32 1
#define CONF_PLATFORM_STRING "win32"
#endif
/* unix family */
#if defined(__FreeBSD__) || defined(__FreeBSD_kernel__)
#define CONF_FAMILY_UNIX 1
#define CONF_FAMILY_STRING "unix"
#define CONF_PLATFORM_FREEBSD 1
#define CONF_PLATFORM_STRING "freebsd"
#endif
#if defined(__OpenBSD__)
#define CONF_FAMILY_UNIX 1
#define CONF_FAMILY_STRING "unix"
#define CONF_PLATFORM_OPENBSD 1
#define CONF_PLATFORM_STRING "openbsd"
#endif
#if defined(__LINUX__) || defined(__linux__)
#define CONF_FAMILY_UNIX 1
#define CONF_FAMILY_STRING "unix"
#define CONF_PLATFORM_LINUX 1
#define CONF_PLATFORM_STRING "linux"
#endif
#if defined(__GNU__) || defined(__gnu__)
#define CONF_FAMILY_UNIX 1
#define CONF_FAMILY_STRING "unix"
#define CONF_PLATFORM_HURD 1
#define CONF_PLATFORM_STRING "gnu"
#endif
#if defined(MACOSX) || defined(__APPLE__) || defined(__DARWIN__)
#define CONF_FAMILY_UNIX 1
#define CONF_FAMILY_STRING "unix"
#define CONF_PLATFORM_MACOSX 1
#define CONF_PLATFORM_STRING "macosx"
#endif
#if defined(__sun)
#define CONF_FAMILY_UNIX 1
#define CONF_FAMILY_STRING "unix"
#define CONF_PLATFORM_SOLARIS 1
#define CONF_PLATFORM_STRING "solaris"
#endif
/* beos family */
#if defined(__BeOS) || defined(__BEOS__)
#define CONF_FAMILY_BEOS 1
#define CONF_FAMILY_STRING "beos"
#define CONF_PLATFORM_BEOS 1
#define CONF_PLATFORM_STRING "beos"
#endif
/* use gcc endianness definitions when available */
#if defined(__GNUC__) && !defined(__APPLE__) && !defined(__MINGW32__) && !defined(__sun)
#if defined(__FreeBSD__) || defined(__OpenBSD__)
#include <sys/endian.h>
#else
#include <endian.h>
#endif
#if __BYTE_ORDER == __LITTLE_ENDIAN
#define CONF_ARCH_ENDIAN_LITTLE 1
#elif __BYTE_ORDER == __BIG_ENDIAN
#define CONF_ARCH_ENDIAN_BIG 1
#endif
#endif
/* architectures */
#if defined(i386) || defined(__i386__) || defined(__x86__) || defined(CONF_PLATFORM_WIN32)
#define CONF_ARCH_IA32 1
#define CONF_ARCH_STRING "ia32"
#if !defined(CONF_ARCH_ENDIAN_LITTLE) && !defined(CONF_ARCH_ENDIAN_BIG)
#define CONF_ARCH_ENDIAN_LITTLE 1
#endif
#endif
#if defined(__ia64__) || defined(_M_IA64)
#define CONF_ARCH_IA64 1
#define CONF_ARCH_STRING "ia64"
#if !defined(CONF_ARCH_ENDIAN_LITTLE) && !defined(CONF_ARCH_ENDIAN_BIG)
#define CONF_ARCH_ENDIAN_LITTLE 1
#endif
#endif
#if defined(__amd64__) || defined(__x86_64__) || defined(_M_X64)
#define CONF_ARCH_AMD64 1
#define CONF_ARCH_STRING "amd64"
#if !defined(CONF_ARCH_ENDIAN_LITTLE) && !defined(CONF_ARCH_ENDIAN_BIG)
#define CONF_ARCH_ENDIAN_LITTLE 1
#endif
#endif
#if defined(__powerpc__) || defined(__ppc__)
#define CONF_ARCH_PPC 1
#define CONF_ARCH_STRING "ppc"
#if !defined(CONF_ARCH_ENDIAN_LITTLE) && !defined(CONF_ARCH_ENDIAN_BIG)
#define CONF_ARCH_ENDIAN_BIG 1
#endif
#endif
#if defined(__sparc__)
#define CONF_ARCH_SPARC 1
#define CONF_ARCH_STRING "sparc"
#if !defined(CONF_ARCH_ENDIAN_LITTLE) && !defined(CONF_ARCH_ENDIAN_BIG)
#define CONF_ARCH_ENDIAN_BIG 1
#endif
#endif
#ifndef CONF_FAMILY_STRING
#define CONF_FAMILY_STRING "unknown"
#endif
#ifndef CONF_PLATFORM_STRING
#define CONF_PLATFORM_STRING "unknown"
#endif
#ifndef CONF_ARCH_STRING
#define CONF_ARCH_STRING "unknown"
#endif
#endif
-269
View File
@@ -1,269 +0,0 @@
/* vim: set et ts=3 sw=3 sts=3 ft=c:
*
* Copyright (C) 2012, 2013, 2014 James McLaughlin et al. All rights reserved.
* https://github.com/udp/json-parser
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*/
#ifndef _JSON_H
#define _JSON_H
#ifndef json_char
#define json_char char
#endif
#ifndef json_int_t
#ifndef _MSC_VER
#include <inttypes.h>
#define json_int_t int64_t
#else
#define json_int_t __int64
#endif
#endif
#include <stdlib.h>
#ifdef __cplusplus
#include <string.h>
extern "C"
{
#endif
typedef struct
{
unsigned long max_memory;
int settings;
/* Custom allocator support (leave null to use malloc/free)
*/
void * (* mem_alloc) (size_t, int zero, void * user_data);
void (* mem_free) (void *, void * user_data);
void * user_data; /* will be passed to mem_alloc and mem_free */
} json_settings;
#define json_enable_comments 0x01
typedef enum
{
json_none,
json_object,
json_array,
json_integer,
json_double,
json_string,
json_boolean,
json_null
} json_type;
extern const struct _json_value json_value_none;
typedef struct _json_value
{
struct _json_value * parent;
json_type type;
union
{
int boolean;
json_int_t integer;
double dbl;
struct
{
unsigned int length;
json_char * ptr; /* null terminated */
} string;
struct
{
unsigned int length;
struct
{
json_char * name;
unsigned int name_length;
struct _json_value * value;
} * values;
#if defined(__cplusplus) && __cplusplus >= 201103L
decltype(values) begin () const
{ return values;
}
decltype(values) end () const
{ return values + length;
}
#endif
} object;
struct
{
unsigned int length;
struct _json_value ** values;
#if defined(__cplusplus) && __cplusplus >= 201103L
decltype(values) begin () const
{ return values;
}
decltype(values) end () const
{ return values + length;
}
#endif
} array;
} u;
union
{
struct _json_value * next_alloc;
void * object_mem;
} _reserved;
/* Some C++ operator sugar */
#ifdef __cplusplus
public:
inline _json_value ()
{ memset (this, 0, sizeof (_json_value));
}
inline const struct _json_value &operator [] (int index) const
{
if (type != json_array || index < 0
|| ((unsigned int) index) >= u.array.length)
{
return json_value_none;
}
return *u.array.values [index];
}
inline const struct _json_value &operator [] (const char * index) const
{
if (type != json_object)
return json_value_none;
for (unsigned int i = 0; i < u.object.length; ++ i)
if (!strcmp (u.object.values [i].name, index))
return *u.object.values [i].value;
return json_value_none;
}
inline operator const char * () const
{
switch (type)
{
case json_string:
return u.string.ptr;
default:
return "";
};
}
inline operator json_int_t () const
{
switch (type)
{
case json_integer:
return u.integer;
case json_double:
return (json_int_t) u.dbl;
default:
return 0;
};
}
inline operator bool () const
{
if (type != json_boolean)
return false;
return u.boolean != 0;
}
inline operator double () const
{
switch (type)
{
case json_integer:
return (double) u.integer;
case json_double:
return u.dbl;
default:
return 0;
};
}
#endif
} json_value;
json_value * json_parse (const json_char * json,
size_t length);
#define json_error_max 128
json_value * json_parse_ex (json_settings * settings,
const json_char * json,
size_t length,
char * error);
void json_value_free (json_value *);
/* Not usually necessary, unless you used a custom mem_alloc and now want to
* use a custom mem_free.
*/
void json_value_free_ex (json_settings * settings,
json_value *);
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
func main() {
var opts Options
var legacyBind string
var legacyPort int
var printVersion bool
flag.StringVar(&opts.ConfigPath, "config", envOr("CONFIG_PATH", "config.json"), "configuration file")
flag.StringVar(&opts.ConfigPath, "c", envOr("CONFIG_PATH", "config.json"), "configuration file (shorthand)")
flag.StringVar(&opts.StatsPath, "stats", os.Getenv("STATS_PATH"), "persistent stats JSON file")
flag.StringVar(&opts.WebDir, "web-dir", envOr("WEB_DIR", "../web"), "WebUI directory")
flag.StringVar(&opts.WebDir, "d", envOr("WEB_DIR", "../web"), "WebUI directory (shorthand)")
flag.StringVar(&opts.HTTPAddr, "http", envOr("HTTP_ADDR", ":8080"), "HTTP listen address")
flag.StringVar(&opts.AgentAddr, "agent", envOr("AGENT_ADDR", ":35601"), "agent TCP listen address")
flag.StringVar(&legacyBind, "bind", "", "agent bind address (legacy compatibility)")
flag.StringVar(&legacyBind, "b", "", "agent bind address (legacy shorthand)")
flag.IntVar(&legacyPort, "port", 0, "agent TCP port (legacy compatibility)")
flag.IntVar(&legacyPort, "p", 0, "agent TCP port (legacy shorthand)")
flag.BoolVar(&opts.Verbose, "verbose", envBool("VERBOSE", false), "verbose HTTP logging")
flag.BoolVar(&opts.Verbose, "v", envBool("VERBOSE", false), "verbose HTTP logging (shorthand)")
flag.BoolVar(&printVersion, "version", false, "print version and exit")
flag.Parse()
if printVersion {
fmt.Printf("serverstatus %s commit=%s built=%s\n", version, commit, buildTime)
return
}
if opts.StatsPath == "" {
opts.StatsPath = filepath.Join(opts.WebDir, "json", "stats.json")
}
if legacyPort != 0 || legacyBind != "" {
if legacyPort == 0 {
legacyPort = 35601
}
opts.AgentAddr = net.JoinHostPort(legacyBind, strconv.Itoa(legacyPort))
}
opts.AdminToken = os.Getenv("ADMIN_TOKEN")
opts.CORSOrigin = os.Getenv("ADMIN_CORS_ORIGIN")
opts.InsecureCallbackTLS = envBool("INSECURE_CALLBACK_TLS", false)
if err := os.MkdirAll(filepath.Dir(opts.StatsPath), 0o755); err != nil {
fatalf("create stats directory: %v", err)
}
app, err := NewApp(opts)
if err != nil {
fatalf("start: %v", err)
}
app.StartBackground()
httpServer := app.HTTPServer()
agentServer := NewAgentServer(app)
errorsChannel := make(chan error, 2)
go func() {
app.logger.Printf("HTTP listening on %s", opts.HTTPAddr)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errorsChannel <- fmt.Errorf("HTTP server: %w", err)
}
}()
go func() {
if err := agentServer.ListenAndServe(); err != nil {
errorsChannel <- fmt.Errorf("agent server: %w", err)
}
}()
reloadSignals := make(chan os.Signal, 1)
stopSignals := make(chan os.Signal, 1)
signal.Notify(reloadSignals, syscall.SIGHUP)
signal.Notify(stopSignals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
defer signal.Stop(reloadSignals)
defer signal.Stop(stopSignals)
go func() {
for range reloadSignals {
if apiErr := app.ReloadConfig(); apiErr != nil {
app.logger.Printf("reload config: %s", apiErr.Message)
continue
}
app.logger.Printf("configuration reloaded; generation=%d", app.generation.Load())
}
}()
var fatalErr error
select {
case signalValue := <-stopSignals:
app.logger.Printf("received %s; shutting down", signalValue)
case fatalErr = <-errorsChannel:
app.logger.Printf("fatal: %v", fatalErr)
}
app.cancel()
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_ = httpServer.Shutdown(shutdownContext)
cancel()
app.Close()
if fatalErr != nil {
os.Exit(1)
}
}
func envOr(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func envBool(name string, fallback bool) bool {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "serverstatus: "+format+"\n", args...)
os.Exit(1)
}
+367
View File
@@ -0,0 +1,367 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"strconv"
"strings"
"time"
"github.com/expr-lang/expr/vm"
)
const maxRequestBody = 1 << 20
type ConfigDocument map[string]any
type ServerConfig struct {
Username string `json:"username"`
Name string `json:"name"`
Type string `json:"type"`
Host string `json:"host"`
Location string `json:"location"`
Password string `json:"password"`
MonthStart int `json:"monthstart"`
Disabled bool `json:"disabled,omitempty"`
}
type MonitorConfig struct {
Name string `json:"name"`
Host string `json:"host"`
Interval int `json:"interval"`
Type string `json:"type"`
}
type SSLCertConfig struct {
Name string `json:"name"`
Domain string `json:"domain"`
Port int `json:"port"`
Interval int `json:"interval"`
Callback string `json:"callback"`
}
type WatchdogConfig struct {
Name string `json:"name"`
Rule string `json:"rule"`
Interval int `json:"interval"`
Callback string `json:"callback"`
}
type CompiledWatchdog struct {
WatchdogConfig
Key string
Normalized string
Program *vm.Program
}
type RuntimeConfig struct {
Servers []ServerConfig
Monitors []MonitorConfig
SSLCerts []SSLCertConfig
Watchdogs []CompiledWatchdog
}
type AgentStats struct {
Uptime int64 `json:"uptime"`
Load1 float64 `json:"load_1"`
Load5 float64 `json:"load_5"`
Load15 float64 `json:"load_15"`
Ping10010 float64 `json:"ping_10010"`
Ping189 float64 `json:"ping_189"`
Ping10086 float64 `json:"ping_10086"`
Time10010 int64 `json:"time_10010"`
Time189 int64 `json:"time_189"`
Time10086 int64 `json:"time_10086"`
TCPCount int64 `json:"tcp"`
UDPCount int64 `json:"udp"`
ProcessCount int64 `json:"process"`
ThreadCount int64 `json:"thread"`
NetworkRX int64 `json:"network_rx"`
NetworkTX int64 `json:"network_tx"`
NetworkIn int64 `json:"network_in"`
NetworkOut int64 `json:"network_out"`
MemoryTotal int64 `json:"memory_total"`
MemoryUsed int64 `json:"memory_used"`
SwapTotal int64 `json:"swap_total"`
SwapUsed int64 `json:"swap_used"`
HDDTotal int64 `json:"hdd_total"`
HDDUsed int64 `json:"hdd_used"`
IORead int64 `json:"io_read"`
IOWrite int64 `json:"io_write"`
CPU float64 `json:"cpu"`
CPUCores int64 `json:"cpu_cores"`
CPUModel string `json:"cpu_model"`
Custom string `json:"custom"`
OS string `json:"os"`
Online4 *bool `json:"online4"`
Online6 *bool `json:"online6"`
}
type APIError struct {
Status int
Message string
Details any
}
func (e *APIError) Error() string {
if e.Details != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Details)
}
return e.Message
}
func decodeDocument(data []byte) (ConfigDocument, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var doc ConfigDocument
if err := decoder.Decode(&doc); err != nil {
return nil, err
}
if doc == nil {
return nil, fmt.Errorf("config must be a JSON object")
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("config contains more than one JSON value")
}
return doc, nil
}
func cloneDocument(doc ConfigDocument) (ConfigDocument, error) {
data, err := json.Marshal(doc)
if err != nil {
return nil, err
}
return decodeDocument(data)
}
type collectionSpec struct {
itemName string
idField string
required []string
optional []string
defaults map[string]int
}
var collectionSpecs = map[string]collectionSpec{
"servers": {
itemName: "server", idField: "username",
required: []string{"username", "name", "type", "host", "location", "password"},
optional: []string{"monthstart", "disabled"},
defaults: map[string]int{"monthstart": 1},
},
"monitors": {
itemName: "monitor", idField: "name",
required: []string{"name", "host", "type"},
optional: []string{"interval"},
defaults: map[string]int{"interval": 600},
},
"sslcerts": {
itemName: "sslcert", idField: "name",
required: []string{"name", "domain"},
optional: []string{"port", "interval", "callback"},
defaults: map[string]int{"port": 443, "interval": 7200},
},
"watchdog": {
itemName: "watchdog", idField: "name",
required: []string{"name", "rule"},
optional: []string{"interval", "callback"},
defaults: map[string]int{"interval": 600},
},
}
func normalizeConfig(input ConfigDocument) (ConfigDocument, RuntimeConfig, *APIError) {
doc, err := cloneDocument(input)
if err != nil {
return nil, RuntimeConfig{}, &APIError{Status: 400, Message: "config must be a JSON object", Details: map[string]any{"error": err.Error()}}
}
for _, key := range []string{"servers", "monitors", "sslcerts", "watchdog"} {
spec := collectionSpecs[key]
raw, exists := doc[key]
if !exists || raw == nil {
raw = []any{}
}
items, ok := raw.([]any)
if !ok {
return nil, RuntimeConfig{}, &APIError{Status: 400, Message: key + " must be an array"}
}
normalized := make([]any, 0, len(items))
seen := make(map[string]struct{})
for index, rawItem := range items {
item, ok := rawItem.(map[string]any)
if !ok {
return nil, RuntimeConfig{}, &APIError{Status: 400, Message: spec.itemName + " must be an object", Details: map[string]any{"index": index}}
}
missing := make([]string, 0)
for _, field := range spec.required {
value, ok := item[field]
text := ""
if ok && value != nil {
text = strings.TrimSpace(fmt.Sprint(value))
}
if text == "" {
missing = append(missing, field)
} else {
item[field] = text
}
}
if len(missing) > 0 {
return nil, RuntimeConfig{}, &APIError{Status: 400, Message: spec.itemName + " has missing required fields", Details: map[string]any{"missing": missing, "index": index}}
}
for field, fallback := range spec.defaults {
value, apiErr := normalizeInteger(item[field], field, index, fallback)
if apiErr != nil {
return nil, RuntimeConfig{}, apiErr
}
switch {
case key == "servers" && field == "monthstart":
value = clamp(value, 1, 28)
case key == "sslcerts" && field == "port":
value = clamp(value, 1, 65535)
default:
if value < 1 {
value = 1
}
}
item[field] = value
}
if key == "servers" {
if value, exists := item["disabled"]; exists {
disabled, ok := normalizeBool(value)
if !ok {
return nil, RuntimeConfig{}, &APIError{Status: 400, Message: "disabled must be a boolean", Details: map[string]any{"index": index}}
}
item["disabled"] = disabled
}
username := item["username"].(string)
if _, duplicate := seen[username]; duplicate {
return nil, RuntimeConfig{}, &APIError{Status: 409, Message: "duplicate server username", Details: map[string]any{"username": username}}
}
seen[username] = struct{}{}
}
if key == "sslcerts" || key == "watchdog" {
value := item["callback"]
if value == nil {
item["callback"] = ""
} else {
item["callback"] = strings.TrimSpace(fmt.Sprint(value))
}
}
normalized = append(normalized, item)
}
doc[key] = normalized
}
runtime, apiErr := buildRuntimeConfig(doc)
if apiErr != nil {
return nil, RuntimeConfig{}, apiErr
}
return doc, runtime, nil
}
func buildRuntimeConfig(doc ConfigDocument) (RuntimeConfig, *APIError) {
data, err := json.Marshal(doc)
if err != nil {
return RuntimeConfig{}, &APIError{Status: 400, Message: "config could not be encoded", Details: map[string]any{"error": err.Error()}}
}
var raw struct {
Servers []ServerConfig `json:"servers"`
Monitors []MonitorConfig `json:"monitors"`
SSLCerts []SSLCertConfig `json:"sslcerts"`
Watchdogs []WatchdogConfig `json:"watchdog"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return RuntimeConfig{}, &APIError{Status: 400, Message: "config has invalid field types", Details: map[string]any{"error": err.Error()}}
}
runtime := RuntimeConfig{Servers: raw.Servers, Monitors: raw.Monitors, SSLCerts: raw.SSLCerts}
for index, rule := range raw.Watchdogs {
compiled, err := compileWatchdog(rule, index)
if err != nil {
return RuntimeConfig{}, &APIError{Status: 400, Message: "watchdog rule is invalid", Details: map[string]any{"index": index, "name": rule.Name, "error": err.Error()}}
}
runtime.Watchdogs = append(runtime.Watchdogs, compiled)
}
return runtime, nil
}
func normalizeInteger(raw any, field string, index, fallback int) (int, *APIError) {
if raw == nil || raw == "" {
return fallback, nil
}
var value int64
var err error
switch v := raw.(type) {
case json.Number:
value, err = strconv.ParseInt(v.String(), 10, 64)
case float64:
if math.Trunc(v) != v {
err = fmt.Errorf("not an integer")
} else {
value = int64(v)
}
case float32:
if math.Trunc(float64(v)) != float64(v) {
err = fmt.Errorf("not an integer")
} else {
value = int64(v)
}
case int:
value = int64(v)
case int64:
value = v
case string:
value, err = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
default:
err = fmt.Errorf("unsupported value")
}
if err != nil || value > math.MaxInt || value < math.MinInt {
return 0, &APIError{Status: 400, Message: field + " must be an integer", Details: map[string]any{"index": index}}
}
return int(value), nil
}
func normalizeBool(raw any) (bool, bool) {
switch value := raw.(type) {
case bool:
return value, true
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return parsed, err == nil
case json.Number:
if value.String() == "0" {
return false, true
}
if value.String() == "1" {
return true, true
}
}
return false, false
}
func clamp(value, minimum, maximum int) int {
if value < minimum {
return minimum
}
if value > maximum {
return maximum
}
return value
}
func secondsDuration(value int) time.Duration {
if value < 1 {
value = 1
}
maxDuration := time.Duration(1<<63 - 1)
if int64(value) > int64(maxDuration/time.Second) {
return maxDuration
}
return time.Duration(value) * time.Second
}
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/expr-lang/expr"
)
func TestNormalizeConfigPreservesCompatibility(t *testing.T) {
doc := minimalTestConfig()
doc["future"] = map[string]any{"enabled": true}
doc["watchdog"] = []any{
map[string]any{"name": "legacy", "rule": "cpu>90&load_1>5&username!='s01'", "interval": "600", "callback": nil},
map[string]any{"name": "type", "rule": "tcp_count>600&type='Oracle'", "interval": 60},
}
doc["servers"].([]any)[0].(map[string]any)["future_field"] = "kept"
normalized, runtime, apiErr := normalizeConfig(doc)
if apiErr != nil {
t.Fatal(apiErr)
}
if len(runtime.Watchdogs) != 2 || runtime.Watchdogs[0].Normalized != "cpu>90&&load_1>5&&username!='s01'" {
t.Fatalf("legacy rules were not normalized: %#v", runtime.Watchdogs)
}
server := normalized["servers"].([]any)[0].(map[string]any)
if server["future_field"] != "kept" || normalized["future"] == nil {
t.Fatal("unknown config fields were discarded")
}
if server["monthstart"] != json.Number("1") && server["monthstart"] != 1 {
t.Fatalf("monthstart not normalized: %#v", server["monthstart"])
}
}
func TestWatchdogLegacyRulesEvaluate(t *testing.T) {
rules := []string{
"online4=0&online6=0",
"(memory_used/memory_total)*100>90&memory_total>1048576",
"tcp_count>600&type='Oracle'",
"(network_out-last_network_out)/1024/1024/1024>18&(username='aliyun1'|username='aliyun2')",
}
for index, rule := range rules {
compiled, err := compileWatchdog(WatchdogConfig{Name: "test", Rule: rule, Interval: 1}, index)
if err != nil {
t.Fatalf("rule %q: %v", rule, err)
}
environment := watchdogEnvironment(ServerConfig{Username: "aliyun1", Type: "Oracle"}, AgentStats{MemoryTotal: 2_000_000, MemoryUsed: 1_900_000, TCPCount: 700, NetworkOut: 30 << 30}, false, false, 0, 0)
if _, err := expr.Run(compiled.Program, environment); err != nil {
t.Fatalf("run %q: %v", rule, err)
}
}
}
func TestConfigValidationErrors(t *testing.T) {
doc := minimalTestConfig()
doc["servers"] = append(doc["servers"].([]any), doc["servers"].([]any)[0])
_, _, apiErr := normalizeConfig(doc)
if apiErr == nil || apiErr.Status != 409 {
t.Fatalf("expected duplicate username error, got %#v", apiErr)
}
if _, err := decodeDocument([]byte(`{"servers":[]} {"servers":[]}`)); err == nil || !strings.Contains(err.Error(), "more than one") {
t.Fatalf("expected trailing JSON error, got %v", err)
}
}
func TestFormattingHelpers(t *testing.T) {
if got := formatUptime(90061); got != "1 天" {
t.Fatalf("formatUptime=%q", got)
}
if got := formatUptime(3661); got != "01:01:01" {
t.Fatalf("formatUptime=%q", got)
}
if got, err := certificateHost("https://example.com/path"); err != nil || got != "example.com" {
t.Fatalf("certificateHost=%q", got)
}
if got := secondsDuration(0); got != time.Second {
t.Fatalf("secondsDuration(0)=%s", got)
}
if got := secondsDuration(int(^uint(0) >> 1)); got < 365*24*time.Hour {
t.Fatalf("large interval overflowed or was truncated: %s", got)
}
}
-1
View File
@@ -1 +0,0 @@
*.o
+223
View File
@@ -0,0 +1,223 @@
package main
import "strings"
func openAPISpec() map[string]any {
paths := map[string]any{
"/api/health": map[string]any{
"get": publicOperation("health", "服务健康状态", objectResponse("Go 服务、Agent TCP 与配置状态")),
},
"/api/schema": map[string]any{
"get": publicOperation("schema", "ServerStatus API 简表", objectResponse("端点和集合描述")),
},
"/api/openapi.json": map[string]any{
"get": publicOperation("openapi", "OpenAPI 3.1 文档", objectResponse("OpenAPI document")),
},
"/json/stats.json": map[string]any{
"get": publicOperation("stats", "实时状态快照", objectResponse("WebUI 状态数据")),
},
"/api/config": map[string]any{
"get": protectedOperation("getConfig", "读取完整配置", nil, configResponse("200", "配置文档")),
"put": protectedOperation("replaceConfig", "整体校验并替换配置", requestBody("Config"), configResponse("200", "已保存并热重载的配置")),
},
"/api/reload": map[string]any{
"post": protectedOperation("reloadConfig", "从磁盘热重载配置", nil, objectResponse("重载结果")),
},
"/api/restart": map[string]any{
"post": protectedOperation("restartRuntime", "在进程内重启采集运行时", nil, statusResponse("202", "重启结果", map[string]any{
"operation": map[string]any{"type": "string", "const": "restart"},
"mode": map[string]any{"type": "string", "const": "in-process"},
})),
},
"/api/servers/{username}/reset-traffic": map[string]any{
"post": withParameters(protectedOperation("resetServerTraffic", "重置节点本月流量基线", nil, objectResponse("流量重置结果")), pathParameter("username", "节点用户名")),
},
}
for _, key := range []string{"servers", "monitors", "sslcerts", "watchdog"} {
spec := collectionSpecs[key]
schemaName := map[string]string{"servers": "Server", "monitors": "Monitor", "sslcerts": "SSLCert", "watchdog": "Watchdog"}[key]
operationBase := strings.TrimSuffix(key, "s")
if key == "sslcerts" {
operationBase = "sslcert"
}
paths["/api/"+key] = map[string]any{
"get": protectedOperation("list"+titleWord(key), "查询 "+key, nil, collectionResponse(key, schemaName, "配置集合")),
"post": protectedOperation("create"+titleWord(operationBase), "新增 "+spec.itemName, requestBody(schemaName), itemResponse("201", spec.itemName, schemaName, "创建结果")),
}
parameterName := "id"
parameterDescription := "数字下标或唯一的 " + spec.idField
if key == "servers" {
parameterName = "username"
parameterDescription = "节点用户名"
}
paths["/api/"+key+"/{"+parameterName+"}"] = map[string]any{
"put": withParameters(protectedOperation("update"+titleWord(operationBase), "修改 "+spec.itemName, requestBody(schemaName), itemResponse("200", spec.itemName, schemaName, "修改结果")), pathParameter(parameterName, parameterDescription)),
"delete": withParameters(protectedOperation("delete"+titleWord(operationBase), "删除 "+spec.itemName, nil, itemResponse("200", "removed", schemaName, "删除结果")), pathParameter(parameterName, parameterDescription)),
}
}
return map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
"title": "ServerStatus HTTP API",
"version": version,
"description": "单进程 Go ServerStatus 的配置、运行状态和采集控制 API。",
},
"servers": []any{map[string]any{"url": "/", "description": "当前 ServerStatus 实例"}},
"paths": paths,
"components": map[string]any{
"securitySchemes": map[string]any{
"bearerAuth": map[string]any{"type": "http", "scheme": "bearer", "bearerFormat": "ADMIN_TOKEN"},
},
"schemas": openAPISchemas(),
},
}
}
func openAPISchemas() map[string]any {
stringProperty := func(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
integerProperty := func(description string, minimum, maximum int) map[string]any {
property := map[string]any{"type": "integer", "description": description, "minimum": minimum}
if maximum > 0 {
property["maximum"] = maximum
}
return property
}
server := map[string]any{
"type": "object", "required": collectionSpecs["servers"].required,
"properties": map[string]any{
"username": stringProperty("唯一客户端用户名"), "name": stringProperty("节点显示名称"),
"type": stringProperty("虚拟化或节点类型"), "host": stringProperty("主机标识"),
"location": stringProperty("位置"), "password": stringProperty("客户端密码"),
"monthstart": integerProperty("月流量重置日", 1, 28), "disabled": map[string]any{"type": "boolean", "default": false},
},
}
monitor := map[string]any{
"type": "object", "required": collectionSpecs["monitors"].required,
"properties": map[string]any{
"name": stringProperty("监测名称"), "host": stringProperty("HTTP(S) URL 或 TCP 地址"),
"type": stringProperty("https、http 或 tcp"), "interval": integerProperty("客户端探测间隔秒数", 1, 0),
},
}
sslcert := map[string]any{
"type": "object", "required": collectionSpecs["sslcerts"].required,
"properties": map[string]any{
"name": stringProperty("证书名称"), "domain": stringProperty("域名或 URL"),
"port": integerProperty("TLS 端口", 1, 65535), "interval": integerProperty("检查间隔秒数", 1, 0),
"callback": stringProperty("告警回调 URL 前缀"),
},
}
watchdog := map[string]any{
"type": "object", "required": collectionSpecs["watchdog"].required,
"properties": map[string]any{
"name": stringProperty("告警名称"), "rule": stringProperty("兼容 Exprtk 的状态表达式"),
"interval": integerProperty("通知冷却秒数", 1, 0), "callback": stringProperty("告警回调 URL 前缀"),
},
}
return map[string]any{
"Server": server, "Monitor": monitor, "SSLCert": sslcert, "Watchdog": watchdog,
"Config": map[string]any{
"type": "object", "required": []string{"servers", "monitors", "sslcerts", "watchdog"},
"properties": map[string]any{
"servers": arraySchema("Server"), "monitors": arraySchema("Monitor"),
"sslcerts": arraySchema("SSLCert"), "watchdog": arraySchema("Watchdog"),
},
},
"Error": map[string]any{
"type": "object", "required": []string{"ok", "error"},
"properties": map[string]any{"ok": map[string]any{"type": "boolean", "const": false}, "error": map[string]any{"type": "string"}, "details": map[string]any{}},
},
}
}
func titleWord(value string) string {
if value == "" {
return ""
}
return strings.ToUpper(value[:1]) + value[1:]
}
func schemaRef(name string) map[string]any {
return map[string]any{"$ref": "#/components/schemas/" + name}
}
func arraySchema(name string) map[string]any {
return map[string]any{"type": "array", "items": schemaRef(name)}
}
func requestBody(schemaName string) map[string]any {
return map[string]any{
"required": true,
"content": map[string]any{"application/json": map[string]any{"schema": schemaRef(schemaName)}},
}
}
func objectResponse(description string) map[string]any {
return map[string]any{"200": jsonResponse(description, map[string]any{"type": "object"}), "4XX": jsonResponse("请求错误", schemaRef("Error"))}
}
func configResponse(status, description string) map[string]any {
return statusResponse(status, description, map[string]any{"config": schemaRef("Config")})
}
func collectionResponse(key, schemaName, description string) map[string]any {
return statusResponse("200", description, map[string]any{key: arraySchema(schemaName)})
}
func itemResponse(status, key, schemaName, description string) map[string]any {
return statusResponse(status, description, map[string]any{
key: schemaRef(schemaName),
"reloaded": map[string]any{"type": "boolean", "const": true},
"pid": map[string]any{"type": "integer", "minimum": 1},
"config": schemaRef("Config"),
})
}
func statusResponse(status, description string, properties map[string]any) map[string]any {
required := []string{"ok"}
allProperties := map[string]any{"ok": map[string]any{"type": "boolean", "const": true}}
for key, property := range properties {
allProperties[key] = property
required = append(required, key)
}
return map[string]any{
status: jsonResponse(description, map[string]any{
"type": "object", "required": required, "properties": allProperties,
}),
"4XX": jsonResponse("请求错误", schemaRef("Error")),
}
}
func jsonResponse(description string, schema map[string]any) map[string]any {
return map[string]any{"description": description, "content": map[string]any{"application/json": map[string]any{"schema": schema}}}
}
func publicOperation(operationID, summary string, responses map[string]any) map[string]any {
return map[string]any{"operationId": operationID, "summary": summary, "security": []any{}, "responses": responses}
}
func protectedOperation(operationID, summary string, body map[string]any, responses map[string]any) map[string]any {
operation := map[string]any{
"operationId": operationID, "summary": summary,
"security": []any{map[string]any{"bearerAuth": []any{}}}, "responses": responses,
}
if body != nil {
operation["requestBody"] = body
}
return operation
}
func withParameters(operation map[string]any, parameters ...map[string]any) map[string]any {
items := make([]any, 0, len(parameters))
for _, parameter := range parameters {
items = append(items, parameter)
}
operation["parameters"] = items
return operation
}
func pathParameter(name, description string) map[string]any {
return map[string]any{"name": name, "in": "path", "required": true, "description": description, "schema": map[string]any{"type": "string"}}
}
-322
View File
@@ -1,322 +0,0 @@
#include "argparse.h"
#if defined(__cplusplus)
extern "C" {
#endif
#define OPT_UNSET 1
static const char *
prefix_skip(const char *str, const char *prefix)
{
size_t len = strlen(prefix);
return strncmp(str, prefix, len) ? NULL : str + len;
}
int
prefix_cmp(const char *str, const char *prefix)
{
for (;; str++, prefix++)
if (!*prefix)
return 0;
else if (*str != *prefix)
return (unsigned char)*prefix - (unsigned char)*str;
}
static void
argparse_error(struct argparse *this_, const struct argparse_option *opt,
const char *reason)
{
if (!strncmp(this_->argv[0], "--", 2)) {
fprintf(stderr, "error: option `%s` %s\n", opt->long_name, reason);
exit(-1);
} else {
fprintf(stderr, "error: option `%c` %s\n", opt->short_name, reason);
exit(-1);
}
}
static int
argparse_getvalue(struct argparse *this_, const struct argparse_option *opt,
int flags)
{
const char *s = NULL;
if (!opt->value)
goto skipped;
switch (opt->type) {
case ARGPARSE_OPT_BOOLEAN:
if (flags & OPT_UNSET) {
*(int *)opt->value = *(int *)opt->value - 1;
} else {
*(int *)opt->value = *(int *)opt->value + 1;
}
if (*(int *)opt->value < 0) {
*(int *)opt->value = 0;
}
break;
case ARGPARSE_OPT_BIT:
if (flags & OPT_UNSET) {
*(int *)opt->value &= ~opt->data;
} else {
*(int *)opt->value |= opt->data;
}
break;
case ARGPARSE_OPT_STRING:
if (this_->optvalue) {
*(const char **)opt->value = this_->optvalue;
this_->optvalue = NULL;
} else if (this_->argc > 1) {
this_->argc--;
*(const char **)opt->value = *++this_->argv;
} else {
argparse_error(this_, opt, "requires a value");
}
break;
case ARGPARSE_OPT_INTEGER:
if (this_->optvalue) {
*(int *)opt->value = strtol(this_->optvalue, (char **)&s, 0);
this_->optvalue = NULL;
} else if (this_->argc > 1) {
this_->argc--;
*(int *)opt->value = strtol(*++this_->argv, (char **)&s, 0);
} else {
argparse_error(this_, opt, "requires a value");
}
if (*s)
argparse_error(this_, opt, "expects a numerical value");
break;
default:
assert(0);
}
skipped:
if (opt->callback) {
return opt->callback(this_, opt);
}
return 0;
}
static void
argparse_options_check(const struct argparse_option *options)
{
for (; options->type != ARGPARSE_OPT_END; options++) {
switch (options->type) {
case ARGPARSE_OPT_END:
case ARGPARSE_OPT_BOOLEAN:
case ARGPARSE_OPT_BIT:
case ARGPARSE_OPT_INTEGER:
case ARGPARSE_OPT_STRING:
continue;
default:
fprintf(stderr, "wrong option type: %d", options->type);
break;
}
}
}
static int
argparse_short_opt(struct argparse *this_, const struct argparse_option *options)
{
for (; options->type != ARGPARSE_OPT_END; options++) {
if (options->short_name == *this_->optvalue) {
this_->optvalue = this_->optvalue[1] ? this_->optvalue + 1 : NULL;
return argparse_getvalue(this_, options, 0);
}
}
return -2;
}
static int
argparse_long_opt(struct argparse *this_, const struct argparse_option *options)
{
for (; options->type != ARGPARSE_OPT_END; options++) {
const char *rest;
int opt_flags = 0;
if (!options->long_name)
continue;
rest = prefix_skip(this_->argv[0] + 2, options->long_name);
if (!rest) {
// Negation allowed?
if (options->flags & OPT_NONEG) {
continue;
}
// Only boolean/bit allow negation.
if (options->type != ARGPARSE_OPT_BOOLEAN && options->type != ARGPARSE_OPT_BIT) {
continue;
}
if (!prefix_cmp(this_->argv[0] + 2, "no-")) {
rest = prefix_skip(this_->argv[0] + 2 + 3, options->long_name);
if (!rest)
continue;
opt_flags |= OPT_UNSET;
} else {
continue;
}
}
if (*rest) {
if (*rest != '=')
continue;
this_->optvalue = rest + 1;
}
return argparse_getvalue(this_, options, opt_flags);
}
return -2;
}
int
argparse_init(struct argparse *this_, struct argparse_option *options,
const char *usage, int flags)
{
memset(this_, 0, sizeof(*this_));
this_->options = options;
this_->usage = usage;
this_->flags = flags;
return 0;
}
int
argparse_parse(struct argparse *this_, int argc, const char **argv)
{
this_->argc = argc - 1;
this_->argv = argv + 1;
this_->out = argv;
argparse_options_check(this_->options);
for (; this_->argc; this_->argc--, this_->argv++) {
const char *arg = this_->argv[0];
if (arg[0] != '-' || !arg[1]) {
if (this_->flags & ARGPARSE_STOP_AT_NON_OPTION) {
goto end;
}
// if it's not option or is a single char '-', copy verbatimly
this_->out[this_->cpidx++] = this_->argv[0];
continue;
}
// short option
if (arg[1] != '-') {
this_->optvalue = arg + 1;
switch (argparse_short_opt(this_, this_->options)) {
case -1:
break;
case -2:
goto unknown;
}
while (this_->optvalue) {
switch (argparse_short_opt(this_, this_->options)) {
case -1:
break;
case -2:
goto unknown;
}
}
continue;
}
// if '--' presents
if (!arg[2]) {
this_->argc--;
this_->argv++;
break;
}
// long option
switch (argparse_long_opt(this_, this_->options)) {
case -1:
break;
case -2:
goto unknown;
}
continue;
unknown:
fprintf(stderr, "error: unknown option `%s`\n", this_->argv[0]);
argparse_usage(this_);
exit(0);
}
end:
memmove(this_->out + this_->cpidx, this_->argv,
this_->argc * sizeof(*this_->out));
this_->out[this_->cpidx + this_->argc] = NULL;
return this_->cpidx + this_->argc;
}
void
argparse_usage(struct argparse *this_)
{
fprintf(stdout, "Usage: %s\n", this_->usage);
fputc('\n', stdout);
const struct argparse_option *options;
// figure out best width
size_t usage_opts_width = 0;
size_t len;
options = this_->options;
for (; options->type != ARGPARSE_OPT_END; options++) {
len = 0;
if ((options)->short_name) {
len += 2;
}
if ((options)->short_name && (options)->long_name) {
len += 2; // separator ", "
}
if ((options)->long_name) {
len += strlen((options)->long_name) + 2;
}
if (options->type == ARGPARSE_OPT_INTEGER) {
len += strlen("=<int>");
} else if (options->type == ARGPARSE_OPT_STRING) {
len += strlen("=<str>");
}
len = ceil((float)len / 4) * 4;
if (usage_opts_width < len) {
usage_opts_width = len;
}
}
usage_opts_width += 4; // 4 spaces prefix
options = this_->options;
for (; options->type != ARGPARSE_OPT_END; options++) {
size_t pos;
int pad;
pos = fprintf(stdout, " ");
if (options->short_name) {
pos += fprintf(stdout, "-%c", options->short_name);
}
if (options->long_name && options->short_name) {
pos += fprintf(stdout, ", ");
}
if (options->long_name) {
pos += fprintf(stdout, "--%s", options->long_name);
}
if (options->type == ARGPARSE_OPT_INTEGER) {
pos += fprintf(stdout, "=<int>");
} else if (options->type == ARGPARSE_OPT_STRING) {
pos += fprintf(stdout, "=<str>");
}
if (pos <= usage_opts_width) {
pad = usage_opts_width - pos;
} else {
fputc('\n', stdout);
pad = usage_opts_width;
}
fprintf(stdout, "%*s%s\n", pad + 2, "", options->help);
}
}
int
argparse_help_cb(struct argparse *this_, const struct argparse_option *option)
{
(void)option;
argparse_usage(this_);
exit(0);
return 0;
}
#if defined(__cplusplus)
}
#endif
-40746
View File
File diff suppressed because it is too large Load Diff
-949
View File
@@ -1,949 +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;
if (! ((*(void **) &value->u.object.values) = json_alloc
(state, values_size + ((unsigned long) value->u.object.values), 0)) )
{
return 0;
}
value->_reserved.object_mem = (*(char **) &value->u.object.values) + values_size;
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 **) &top->u.object.values) += string_length + 1;
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 **) &top->_reserved.object_mem) += string_length + 1;
}
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);
}
-720
View File
@@ -1,720 +0,0 @@
#define __STDC_FORMAT_MACROS
#include <inttypes.h>
#include <time.h>
#include <detect.h>
#include <system.h>
#include <argparse.h>
#include <json.h>
#include "server.h"
#include "main.h"
#include "exprtk.hpp"
#include "curl/curl.h"
#if defined(CONF_FAMILY_UNIX)
#include <signal.h>
#endif
#ifndef PRId64
#define PRId64 "I64d"
#endif
static volatile int gs_Running = 1;
static volatile int gs_ReloadConfig = 0;
static void ExitFunc(int Signal)
{
printf("[EXIT] Caught signal %d\n", Signal);
gs_Running = 0;
}
static void ReloadFunc(int Signal)
{
printf("[RELOAD] Caught signal %d\n", Signal);
gs_ReloadConfig = 1;
}
CConfig::CConfig()
{
// Initialize to default values
m_Verbose = false; // -v, --verbose
str_copy(m_aConfigFile, "config.json", sizeof(m_aConfigFile)); // -c, --config
str_copy(m_aWebDir, "../web/", sizeof(m_aJSONFile)); // -d, --web-dir
str_copy(m_aTemplateFile, "template.html", sizeof(m_aTemplateFile));
str_copy(m_aJSONFile, "json/stats.json", sizeof(m_aJSONFile));
str_copy(m_aBindAddr, "", sizeof(m_aBindAddr)); // -b, --bind
m_Port = 35601; // -p, --port
}
CMain::CMain(CConfig Config) : m_Config(Config)
{
mem_zero(m_aClients, sizeof(m_aClients));
for(int i = 0; i < NET_MAX_CLIENTS; i++)
m_aClients[i].m_ClientNetID = -1;
}
CMain::CClient *CMain::ClientNet(int ClientNetID)
{
if(ClientNetID < 0 || ClientNetID >= NET_MAX_CLIENTS)
return 0;
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(Client(i)->m_ClientNetID == ClientNetID)
return Client(i);
}
return 0;
}
int CMain::ClientNetToClient(int ClientNetID)
{
if(ClientNetID < 0 || ClientNetID >= NET_MAX_CLIENTS)
return -1;
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(Client(i)->m_ClientNetID == ClientNetID)
return i;
}
return -1;
}
void CMain::OnNewClient(int ClientNetID, int ClientID)
{
dbg_msg("main", "OnNewClient(ncid=%d, cid=%d)", ClientNetID, ClientID);
Client(ClientID)->m_ClientNetID = ClientNetID;
Client(ClientID)->m_ClientNetType = m_Server.Network()->ClientAddr(ClientNetID)->type;
Client(ClientID)->m_TimeConnected = time_get();
Client(ClientID)->m_Connected = true;
if(Client(ClientID)->m_ClientNetType == NETTYPE_IPV4)
Client(ClientID)->m_Stats.m_Online4 = true;
else if(Client(ClientID)->m_ClientNetType == NETTYPE_IPV6)
Client(ClientID)->m_Stats.m_Online6 = true;
}
void CMain::OnDelClient(int ClientNetID)
{
int ClientID = ClientNetToClient(ClientNetID);
dbg_msg("main", "OnDelClient(ncid=%d, cid=%d)", ClientNetID, ClientID);
//copy offline message for watchdog
WatchdogMessage(ClientNetID,
0, 0, 0, 0, 0, 0,
0, 0, 0,0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0,0, 0, 0,
0, 0, 0, 0);
if(ClientID >= 0 && ClientID < NET_MAX_CLIENTS)
{
Client(ClientID)->m_Connected = false;
Client(ClientID)->m_ClientNetID = -1;
Client(ClientID)->m_ClientNetType = NETTYPE_INVALID;
mem_zero(&Client(ClientID)->m_Stats, sizeof(CClient::CStats));
}
}
int CMain::HandleMessage(int ClientNetID, char *pMessage)
{
CClient *pClient = ClientNet(ClientNetID);
if(!pClient)
return true;
if(str_comp_num(pMessage, "update", sizeof("update")-1) == 0)
{
char *pData = str_skip_whitespaces(&pMessage[sizeof("update")-1]);
// parse json data
json_settings JsonSettings;
mem_zero(&JsonSettings, sizeof(JsonSettings));
char aError[256];
json_value *pJsonData = json_parse_ex(&JsonSettings, pData, strlen(pData), aError);
if(!pJsonData)
{
dbg_msg("main", "JSON Error: %s", aError);
if(pClient->m_Stats.m_Pong)
m_Server.Network()->Send(ClientNetID, "1");
return 1;
}
// extract data
const json_value &rStart = (*pJsonData);
if(rStart["uptime"].type)
pClient->m_Stats.m_Uptime = rStart["uptime"].u.integer;
if(rStart["load_1"].type)
pClient->m_Stats.m_Load_1 = rStart["load_1"].u.dbl;
if(rStart["load_5"].type)
pClient->m_Stats.m_Load_5 = rStart["load_5"].u.dbl;
if(rStart["load_15"].type)
pClient->m_Stats.m_Load_15 = rStart["load_15"].u.dbl;
if(rStart["ping_10010"].type)
pClient->m_Stats.m_ping_10010 = rStart["ping_10010"].u.dbl;
if(rStart["ping_189"].type)
pClient->m_Stats.m_ping_189 = rStart["ping_189"].u.dbl;
if(rStart["ping_10086"].type)
pClient->m_Stats.m_ping_10086 = rStart["ping_10086"].u.dbl;
if(rStart["time_10010"].type)
pClient->m_Stats.m_time_10010 = rStart["time_10010"].u.integer;
if(rStart["time_189"].type)
pClient->m_Stats.m_time_189 = rStart["time_189"].u.integer;
if(rStart["time_10086"].type)
pClient->m_Stats.m_time_10086 = rStart["time_10086"].u.integer;
if(rStart["tcp"].type)
pClient->m_Stats.m_tcpCount = rStart["tcp"].u.integer;
if(rStart["udp"].type)
pClient->m_Stats.m_udpCount = rStart["udp"].u.integer;
if(rStart["process"].type)
pClient->m_Stats.m_processCount = rStart["process"].u.integer;
if(rStart["thread"].type)
pClient->m_Stats.m_threadCount = rStart["thread"].u.integer;
if(rStart["network_rx"].type)
pClient->m_Stats.m_NetworkRx = rStart["network_rx"].u.integer;
if(rStart["network_tx"].type)
pClient->m_Stats.m_NetworkTx = rStart["network_tx"].u.integer;
if(rStart["network_in"].type)
pClient->m_Stats.m_NetworkIN = rStart["network_in"].u.integer;
if(rStart["network_out"].type)
pClient->m_Stats.m_NetworkOUT = rStart["network_out"].u.integer;
if(rStart["memory_total"].type)
pClient->m_Stats.m_MemTotal = rStart["memory_total"].u.integer;
if(rStart["memory_used"].type)
pClient->m_Stats.m_MemUsed = rStart["memory_used"].u.integer;
if(rStart["swap_total"].type)
pClient->m_Stats.m_SwapTotal = rStart["swap_total"].u.integer;
if(rStart["swap_used"].type)
pClient->m_Stats.m_SwapUsed = rStart["swap_used"].u.integer;
if(rStart["hdd_total"].type)
pClient->m_Stats.m_HDDTotal = rStart["hdd_total"].u.integer;
if(rStart["hdd_used"].type)
pClient->m_Stats.m_HDDUsed = rStart["hdd_used"].u.integer;
if(rStart["io_read"].type)
pClient->m_Stats.m_IORead = rStart["io_read"].u.integer;
if(rStart["io_write"].type)
pClient->m_Stats.m_IOWrite = rStart["io_write"].u.integer;
if(rStart["cpu"].type)
pClient->m_Stats.m_CPU = rStart["cpu"].u.dbl;
if(rStart["online4"].type && pClient->m_ClientNetType == NETTYPE_IPV6)
pClient->m_Stats.m_Online4 = rStart["online4"].u.boolean;
if(rStart["online6"].type && pClient->m_ClientNetType == NETTYPE_IPV4)
pClient->m_Stats.m_Online6 = rStart["online6"].u.boolean;
if(rStart["custom"].type == json_string)
str_copy(pClient->m_Stats.m_aCustom, rStart["custom"].u.string.ptr, sizeof(pClient->m_Stats.m_aCustom));
//copy message for watchdog to analysis
WatchdogMessage(ClientNetID,
pClient->m_Stats.m_Load_1, pClient->m_Stats.m_Load_5, pClient->m_Stats.m_Load_15,
pClient->m_Stats.m_ping_10010, pClient->m_Stats.m_ping_189, pClient->m_Stats.m_ping_10086,
pClient->m_Stats.m_time_10010, pClient->m_Stats.m_time_189, pClient->m_Stats.m_time_10086,
pClient->m_Stats.m_tcpCount, pClient->m_Stats.m_udpCount, pClient->m_Stats.m_processCount,
pClient->m_Stats.m_threadCount, pClient->m_Stats.m_NetworkRx, pClient->m_Stats.m_NetworkTx,
pClient->m_Stats.m_NetworkIN, pClient->m_Stats.m_NetworkOUT,pClient->m_Stats.m_MemTotal,
pClient->m_Stats.m_MemUsed, pClient->m_Stats.m_SwapTotal, pClient->m_Stats.m_SwapUsed,
pClient->m_Stats.m_HDDTotal, pClient->m_Stats.m_HDDUsed, pClient->m_Stats.m_IORead,
pClient->m_Stats.m_IOWrite, pClient->m_Stats.m_CPU, pClient->m_Stats.m_Online4,
pClient->m_Stats.m_Online6);
if(m_Config.m_Verbose)
{
if(rStart["online4"].type)
dbg_msg("main", "Online4: %s\nUptime: %" PRId64 "\nLoad_1: %f\nLoad_5: %f\nLoad_15: %f\nPing_10010: %f\nPing_189: %f\nPing_10086: %f\nTime_10010: %" PRId64 "\nTime_189: %" PRId64 "\nTime_10086: %" PRId64 "\nTcp_count: %" PRId64 "\nUdp_count: %" PRId64 "\nprocess_count: %" PRId64 "\nthread_count: %" PRId64 "\nNetworkRx: %" PRId64 "\nNetworkTx: %" PRId64 "\nNetworkIN: %" PRId64 "\nNetworkOUT: %" PRId64 "\nMemTotal: %" PRId64 "\nMemUsed: %" PRId64 "\nSwapTotal: %" PRId64 "\nSwapUsed: %" PRId64 "\nHDDTotal: %" PRId64 "\nHDDUsed: %" PRId64 "\nCPU: %f\nIORead: %" PRId64 "\nIOWrite: %" PRId64 "\n",
rStart["online4"].u.boolean ? "true" : "false",
pClient->m_Stats.m_Uptime,
pClient->m_Stats.m_Load_1, pClient->m_Stats.m_Load_5, pClient->m_Stats.m_Load_15, pClient->m_Stats.m_ping_10010, pClient->m_Stats.m_ping_189, pClient->m_Stats.m_ping_10086, pClient->m_Stats.m_time_10010, pClient->m_Stats.m_time_189, pClient->m_Stats.m_time_10086,pClient->m_Stats.m_tcpCount,pClient->m_Stats.m_udpCount,pClient->m_Stats.m_processCount,pClient->m_Stats.m_threadCount,pClient->m_Stats.m_NetworkRx, pClient->m_Stats.m_NetworkTx, pClient->m_Stats.m_NetworkIN, pClient->m_Stats.m_NetworkOUT, pClient->m_Stats.m_MemTotal, pClient->m_Stats.m_MemUsed, pClient->m_Stats.m_SwapTotal, pClient->m_Stats.m_SwapUsed, pClient->m_Stats.m_HDDTotal, pClient->m_Stats.m_HDDUsed, pClient->m_Stats.m_CPU, pClient->m_Stats.m_IORead, pClient->m_Stats.m_IOWrite);
else if(rStart["online6"].type)
dbg_msg("main", "Online6: %s\nUptime: %" PRId64 "\nLoad_1: %f\nLoad_5: %f\nLoad_15: %f\nPing_10010: %f\nPing_189: %f\nPing_10086: %f\nTime_10010: %" PRId64 "\nTime_189: %" PRId64 "\nTime_10086: %" PRId64 "\nTcp_count: %" PRId64 "\nUdp_count: %" PRId64 "\nprocess_count: %" PRId64 "\nthread_count: %" PRId64 "\nNetworkRx: %" PRId64 "\nNetworkTx: %" PRId64 "\nNetworkIN: %" PRId64 "\nNetworkOUT: %" PRId64 "\nMemTotal: %" PRId64 "\nMemUsed: %" PRId64 "\nSwapTotal: %" PRId64 "\nSwapUsed: %" PRId64 "\nHDDTotal: %" PRId64 "\nHDDUsed: %" PRId64 "\nCPU: %f\nIORead: %" PRId64 "\nIOWrite: %" PRId64 "\n",
rStart["online6"].u.boolean ? "true" : "false",
pClient->m_Stats.m_Uptime,
pClient->m_Stats.m_Load_1, pClient->m_Stats.m_Load_5, pClient->m_Stats.m_Load_15, pClient->m_Stats.m_ping_10010, pClient->m_Stats.m_ping_189, pClient->m_Stats.m_ping_10086, pClient->m_Stats.m_time_10010, pClient->m_Stats.m_time_189, pClient->m_Stats.m_time_10086,pClient->m_Stats.m_tcpCount,pClient->m_Stats.m_udpCount,pClient->m_Stats.m_processCount,pClient->m_Stats.m_threadCount,pClient->m_Stats.m_NetworkRx, pClient->m_Stats.m_NetworkTx, pClient->m_Stats.m_NetworkIN, pClient->m_Stats.m_NetworkOUT, pClient->m_Stats.m_MemTotal, pClient->m_Stats.m_MemUsed, pClient->m_Stats.m_SwapTotal, pClient->m_Stats.m_SwapUsed, pClient->m_Stats.m_HDDTotal, pClient->m_Stats.m_HDDUsed, pClient->m_Stats.m_CPU, pClient->m_Stats.m_IORead, pClient->m_Stats.m_IOWrite);
else
dbg_msg("main", "Uptime: %" PRId64 "\nLoad_1: %f\nLoad_5: %f\nLoad_15: %f\nPing_10010: %f\nPing_189: %f\nPing_10086: %f\nTime_10010: %" PRId64 "\nTime_189: %" PRId64 "\nTime_10086: %" PRId64 "\nTcp_count: %" PRId64 "\nUdp_count: %" PRId64 "\nprocess_count: %" PRId64 "\nthread_count: %" PRId64 "\nNetworkRx: %" PRId64 "\nNetworkTx: %" PRId64 "\nNetworkIN: %" PRId64 "\nNetworkOUT: %" PRId64 "\nMemTotal: %" PRId64 "\nMemUsed: %" PRId64 "\nSwapTotal: %" PRId64 "\nSwapUsed: %" PRId64 "\nHDDTotal: %" PRId64 "\nHDDUsed: %" PRId64 "\nCPU: %f\nIORead: %" PRId64 "\nIOWrite: %" PRId64 "\n",
pClient->m_Stats.m_Uptime,
pClient->m_Stats.m_Load_1, pClient->m_Stats.m_Load_5, pClient->m_Stats.m_Load_15, pClient->m_Stats.m_ping_10010, pClient->m_Stats.m_ping_189, pClient->m_Stats.m_ping_10086, pClient->m_Stats.m_time_10010, pClient->m_Stats.m_time_189, pClient->m_Stats.m_time_10086,pClient->m_Stats.m_tcpCount,pClient->m_Stats.m_udpCount,pClient->m_Stats.m_processCount,pClient->m_Stats.m_threadCount,pClient->m_Stats.m_NetworkRx, pClient->m_Stats.m_NetworkTx, pClient->m_Stats.m_NetworkIN, pClient->m_Stats.m_NetworkOUT, pClient->m_Stats.m_MemTotal, pClient->m_Stats.m_MemUsed, pClient->m_Stats.m_SwapTotal, pClient->m_Stats.m_SwapUsed, pClient->m_Stats.m_HDDTotal, pClient->m_Stats.m_HDDUsed, pClient->m_Stats.m_CPU, pClient->m_Stats.m_IORead, pClient->m_Stats.m_IOWrite);
}
// clean up
json_value_free(pJsonData);
if(pClient->m_Stats.m_Pong)
m_Server.Network()->Send(ClientNetID, "0");
return 0;
}
else if(str_comp_num(pMessage, "pong", sizeof("pong")-1) == 0)
{
char *pData = str_skip_whitespaces(&pMessage[sizeof("pong")-1]);
if(!str_comp(pData, "0") || !str_comp(pData, "off"))
pClient->m_Stats.m_Pong = false;
else if(!str_comp(pData, "1") || !str_comp(pData, "on"))
pClient->m_Stats.m_Pong = true;
return 0;
}
if(pClient->m_Stats.m_Pong)
m_Server.Network()->Send(ClientNetID, "1");
return 1;
}
void CMain::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 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)
{
int ID = 0;
while (strcmp(Watchdog(ID)->m_aName, "NULL"))
{
typedef exprtk::symbol_table<double> symbol_table_t;
typedef exprtk::expression<double> expression_t;
typedef exprtk::parser<double> parser_t;
const std::string expression_string = Watchdog(ID)->m_aRule;
symbol_table_t symbol_table;
symbol_table.add_variable("load_1",load_1);
symbol_table.add_variable("load_5",load_5);
symbol_table.add_variable("load_15",load_15);
symbol_table.add_variable("ping_10010",ping_10010);
symbol_table.add_variable("ping_189",ping_189);
symbol_table.add_variable("ping_10086",ping_10086);
symbol_table.add_variable("time_10010",time_10010);
symbol_table.add_variable("time_189",time_189);
symbol_table.add_variable("time_10086",time_10086);
symbol_table.add_variable("tcp_count",tcp_count);
symbol_table.add_variable("udp_count",udp_count);
symbol_table.add_variable("process_count",process_count);
symbol_table.add_variable("thread_count",thread_count);
symbol_table.add_variable("network_rx",network_rx);
symbol_table.add_variable("network_tx",network_tx);
symbol_table.add_variable("network_in",network_in);
symbol_table.add_variable("network_out",network_out);
symbol_table.add_variable("memory_total",memory_total);
symbol_table.add_variable("memory_used",memory_used);
symbol_table.add_variable("swap_total",swap_total);
symbol_table.add_variable("swap_used",swap_used);
symbol_table.add_variable("hdd_total",hdd_total);
symbol_table.add_variable("hdd_used",hdd_used);
symbol_table.add_variable("io_read",io_read);
symbol_table.add_variable("io_write",io_write);
symbol_table.add_variable("cpu",cpu);
symbol_table.add_variable("online4",online4);
symbol_table.add_variable("online6",online6);
symbol_table.add_constants();
expression_t expression;
expression.register_symbol_table(symbol_table);
parser_t parser;
parser.compile(expression_string,expression);
if (expression.value() > 0)
{
int ClientID = ClientNetToClient(ClientNetID);
time_t currentStamp = (long long)time(/*ago*/0);
if ((currentStamp-Client(ClientID)->m_AlarmLastTime) > Watchdog(ID)->m_aInterval)
{
Client(ClientID)->m_AlarmLastTime = currentStamp;
CURL *curl;
CURLcode res;
curl_global_init(CURL_GLOBAL_ALL);
curl = curl_easy_init();
if(curl) {
//standard time
char standardTime[32]= { 0 };
strftime(standardTime, sizeof(standardTime), "%Y-%m-%d %H:%M:%S",localtime(&currentStamp));
//url encode, Rules conflict with url special characterseg&, del rules, by https://cpp.la, 2023-10-09
char encodeBuffer[2048] = { 0 };
sprintf(encodeBuffer, "【告警名称】 %s \n\n【告警时间】 %s \n\n【用户名】 %s \n\n【节点名】 %s \n\n【虚拟化】 %s \n\n【主机名】 %s \n\n【位 置】 %s",
Watchdog(ID)->m_aName,
standardTime,
Client(ClientID)->m_aUsername,
Client(ClientID)->m_aName,
Client(ClientID)->m_aType,
Client(ClientID)->m_aHost,
Client(ClientID)->m_aLocation);
char *encodeUrl = curl_easy_escape(curl, encodeBuffer, strlen(encodeBuffer));
//standard url
char urlBuffer[2048] = { 0 };
sprintf(urlBuffer, "%s%s",Watchdog(ID)->m_aCallback, encodeUrl);
curl_easy_setopt(curl, CURLOPT_POST, 1L);
curl_easy_setopt(curl, CURLOPT_URL, urlBuffer);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS,"signature=ServerStatus");
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 6L);
res = curl_easy_perform(curl);
if(res != CURLE_OK)
fprintf(stderr, "watchdog failed: %s\n", curl_easy_strerror(res));
if(encodeUrl)
curl_free(encodeUrl);
curl_easy_cleanup(curl);
}
curl_global_cleanup();
}
}
ID++;
}
}
void CMain::JSONUpdateThread(void *pUser)
{
CJSONUpdateThreadData *m_pJSONUpdateThreadData = (CJSONUpdateThreadData *)pUser;
CClient *pClients = m_pJSONUpdateThreadData->pClients;
CConfig *pConfig = m_pJSONUpdateThreadData->pConfig;
while(gs_Running)
{
char aFileBuf[2048*NET_MAX_CLIENTS];
char *pBuf = aFileBuf;
str_format(pBuf, sizeof(aFileBuf), "{\n\"servers\": [\n");
pBuf += strlen(pBuf);
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(!pClients[i].m_Active || pClients[i].m_Disabled)
continue;
if(pClients[i].m_Connected)
{
// Uptime
char aUptime[16];
int Days = pClients[i].m_Stats.m_Uptime/60.0/60.0/24.0;
if(Days > 0)
{
if(Days > 1)
str_format(aUptime, sizeof(aUptime), "%d 天", Days);
else
str_format(aUptime, sizeof(aUptime), "%d 天", Days);
}
else
str_format(aUptime, sizeof(aUptime), "%02d:%02d:%02d", (int)(pClients[i].m_Stats.m_Uptime/60.0/60.0), (int)((pClients[i].m_Stats.m_Uptime/60)%60), (int)((pClients[i].m_Stats.m_Uptime)%60));
// track month network traffic, diff: 2021-10-01 00:05, 5minutes
// last_network_in/out is last record flag.
time_t currentStamp = (long long)time(/*ago*/0);
if(0 == pClients[i].m_LastNetworkIN || (0 != pClients[i].m_Stats.m_NetworkIN && pClients[i].m_LastNetworkIN > pClients[i].m_Stats.m_NetworkIN) || (localtime(&currentStamp)->tm_mday == pClients[i].m_aMonthStart && localtime(&currentStamp)->tm_hour == 0 && localtime(&currentStamp)->tm_min < 5))
{
pClients[i].m_LastNetworkIN = pClients[i].m_Stats.m_NetworkIN;
pClients[i].m_LastNetworkOUT = pClients[i].m_Stats.m_NetworkOUT;
}
str_format(pBuf, sizeof(aFileBuf) - (pBuf - aFileBuf),
"{ \"name\": \"%s\",\"type\": \"%s\",\"host\": \"%s\",\"location\": \"%s\",\"online4\": %s, \"online6\": %s, \"uptime\": \"%s\",\"load_1\": %.2f, \"load_5\": %.2f, \"load_15\": %.2f,\"ping_10010\": %.2f, \"ping_189\": %.2f, \"ping_10086\": %.2f,\"time_10010\": %" PRId64 ", \"time_189\": %" PRId64 ", \"time_10086\": %" PRId64 ", \"tcp_count\": %" PRId64 ", \"udp_count\": %" PRId64 ", \"process_count\": %" PRId64 ", \"thread_count\": %" PRId64 ", \"network_rx\": %" PRId64 ", \"network_tx\": %" PRId64 ", \"network_in\": %" PRId64 ", \"network_out\": %" PRId64 ", \"cpu\": %d, \"memory_total\": %" PRId64 ", \"memory_used\": %" PRId64 ", \"swap_total\": %" PRId64 ", \"swap_used\": %" PRId64 ", \"hdd_total\": %" PRId64 ", \"hdd_used\": %" PRId64 ", \"last_network_in\": %" PRId64 ", \"last_network_out\": %" PRId64 ",\"io_read\": %" PRId64 ", \"io_write\": %" PRId64 ",\"custom\": \"%s\" },\n",
pClients[i].m_aName,pClients[i].m_aType,pClients[i].m_aHost,pClients[i].m_aLocation,
pClients[i].m_Stats.m_Online4 ? "true" : "false",pClients[i].m_Stats.m_Online6 ? "true" : "false",
aUptime, pClients[i].m_Stats.m_Load_1, pClients[i].m_Stats.m_Load_5, pClients[i].m_Stats.m_Load_15, pClients[i].m_Stats.m_ping_10010, pClients[i].m_Stats.m_ping_189, pClients[i].m_Stats.m_ping_10086,
pClients[i].m_Stats.m_time_10010, pClients[i].m_Stats.m_time_189, pClients[i].m_Stats.m_time_10086,pClients[i].m_Stats.m_tcpCount,pClients[i].m_Stats.m_udpCount,pClients[i].m_Stats.m_processCount,pClients[i].m_Stats.m_threadCount,
pClients[i].m_Stats.m_NetworkRx, pClients[i].m_Stats.m_NetworkTx, pClients[i].m_Stats.m_NetworkIN, pClients[i].m_Stats.m_NetworkOUT, (int)pClients[i].m_Stats.m_CPU, pClients[i].m_Stats.m_MemTotal, pClients[i].m_Stats.m_MemUsed,
pClients[i].m_Stats.m_SwapTotal, pClients[i].m_Stats.m_SwapUsed, pClients[i].m_Stats.m_HDDTotal, pClients[i].m_Stats.m_HDDUsed,
pClients[i].m_Stats.m_NetworkIN == 0 || pClients[i].m_LastNetworkIN == 0 ? pClients[i].m_Stats.m_NetworkIN : pClients[i].m_LastNetworkIN,
pClients[i].m_Stats.m_NetworkOUT == 0 || pClients[i].m_LastNetworkOUT == 0 ? pClients[i].m_Stats.m_NetworkOUT : pClients[i].m_LastNetworkOUT,
pClients[i].m_Stats.m_IORead, pClients[i].m_Stats.m_IOWrite,
pClients[i].m_Stats.m_aCustom);
pBuf += strlen(pBuf);
}
else
{
// sava network traffic record to json when close client
// last_network_in == last network in record, last_network_out == last network out record
str_format(pBuf, sizeof(aFileBuf) - (pBuf - aFileBuf), "{ \"name\": \"%s\", \"type\": \"%s\", \"host\": \"%s\", \"location\": \"%s\", \"online4\": false, \"online6\": false, \"last_network_in\": %" PRId64 ", \"last_network_out\": %" PRId64 " },\n",
pClients[i].m_aName, pClients[i].m_aType, pClients[i].m_aHost, pClients[i].m_aLocation, pClients[i].m_LastNetworkIN, pClients[i].m_LastNetworkOUT);
pBuf += strlen(pBuf);
}
}
if(!m_pJSONUpdateThreadData->m_ReloadRequired)
str_format(pBuf - 2, sizeof(aFileBuf) - (pBuf - aFileBuf), "\n],\n\"updated\": \"%lld\"\n}", (long long)time(/*ago*/0));
else
{
str_format(pBuf - 2, sizeof(aFileBuf) - (pBuf - aFileBuf), "\n],\n\"updated\": \"%lld\",\n\"reload\": true\n}", (long long)time(/*ago*/0));
m_pJSONUpdateThreadData->m_ReloadRequired--;
}
pBuf += strlen(pBuf);
char aJSONFileTmp[1024];
str_format(aJSONFileTmp, sizeof(aJSONFileTmp), "%s~", pConfig->m_aJSONFile);
IOHANDLE File = io_open(aJSONFileTmp, IOFLAG_WRITE);
if(!File)
{
dbg_msg("main", "Couldn't open %s", aJSONFileTmp);
exit(1);
}
io_write(File, aFileBuf, (pBuf - aFileBuf));
io_flush(File);
io_close(File);
fs_rename(aJSONFileTmp, pConfig->m_aJSONFile);
thread_sleep(1000);
}
// support by: https://cpp.la. don't remove month traffic record, storage as "stats.json~", remark: 2021-10-18
// fs_remove(pConfig->m_aJSONFile);
char aJSONFileTmp[1024];
str_format(aJSONFileTmp, sizeof(aJSONFileTmp), "%s~", pConfig->m_aJSONFile);
fs_rename(pConfig->m_aJSONFile, aJSONFileTmp);
}
int CMain::ReadConfig()
{
// read and parse config
IOHANDLE File = io_open(m_Config.m_aConfigFile, IOFLAG_READ);
if(!File)
{
dbg_msg("main", "Couldn't open %s", m_Config.m_aConfigFile);
return 1;
}
int FileSize = (int)io_length(File);
char *pFileData = (char *)mem_alloc(FileSize + 1, 1);
io_read(File, pFileData, FileSize);
pFileData[FileSize] = 0;
io_close(File);
// parse json data
json_settings JsonSettings;
mem_zero(&JsonSettings, sizeof(JsonSettings));
char aError[256];
json_value *pJsonData = json_parse_ex(&JsonSettings, pFileData, strlen(pFileData), aError);
if(!pJsonData)
{
dbg_msg("main", "JSON Error in file %s: %s", m_Config.m_aConfigFile, aError);
mem_free(pFileData);
return 1;
}
// reset clients
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(!Client(i)->m_Active || !Client(i)->m_Connected)
continue;
m_Server.Network()->Drop(Client(i)->m_ClientNetID, "Server reloading...");
}
mem_zero(m_aClients, sizeof(m_aClients));
for(int i = 0; i < NET_MAX_CLIENTS; i++)
m_aClients[i].m_ClientNetID = -1;
// extract data
int ID = 0;
const json_value &rStart = (*pJsonData)["servers"];
if(rStart.type == json_array)
{
for(unsigned i = 0; i < rStart.u.array.length; i++)
{
if(ID < 0 || ID >= NET_MAX_CLIENTS)
continue;
Client(ID)->m_Active = true;
Client(ID)->m_Disabled = rStart[i]["disabled"].u.boolean;
str_copy(Client(ID)->m_aName, rStart[i]["name"].u.string.ptr, sizeof(Client(ID)->m_aName));
str_copy(Client(ID)->m_aUsername, rStart[i]["username"].u.string.ptr, sizeof(Client(ID)->m_aUsername));
str_copy(Client(ID)->m_aType, rStart[i]["type"].u.string.ptr, sizeof(Client(ID)->m_aType));
str_copy(Client(ID)->m_aHost, rStart[i]["host"].u.string.ptr, sizeof(Client(ID)->m_aHost));
str_copy(Client(ID)->m_aLocation, rStart[i]["location"].u.string.ptr, sizeof(Client(ID)->m_aLocation));
str_copy(Client(ID)->m_aPassword, rStart[i]["password"].u.string.ptr, sizeof(Client(ID)->m_aPassword));
//if month start day > 28, diff: 3days(29,30,31)
Client(ID)->m_aMonthStart = rStart[i]["monthstart"].u.integer;
if(Client(ID)->m_aMonthStart > 28)
{
Client(ID)->m_aMonthStart = 28;
}
Client(ID)->m_LastNetworkIN = 0;
Client(ID)->m_LastNetworkOUT = 0;
if(m_Config.m_Verbose)
{
if(Client(ID)->m_Disabled)
dbg_msg("main", "[#%d: Name: \"%s\", Username: \"%s\", Type: \"%s\", Host: \"%s\", Location: \"%s\", Password: \"%s\", MonthStart: %\" PRId64 \"]",
ID, Client(ID)->m_aName, Client(ID)->m_aUsername, Client(ID)->m_aType, Client(ID)->m_aHost, Client(ID)->m_aLocation, Client(ID)->m_aPassword, Client(ID)->m_aMonthStart);
else
dbg_msg("main", "#%d: Name: \"%s\", Username: \"%s\", Type: \"%s\", Host: \"%s\", Location: \"%s\", Password: \"%s\", MonthStart: %\" PRId64 \"",
ID, Client(ID)->m_aName, Client(ID)->m_aUsername, Client(ID)->m_aType, Client(ID)->m_aHost, Client(ID)->m_aLocation, Client(ID)->m_aPassword, Client(ID)->m_aMonthStart);
}
ID++;
}
}
// watch dog
// support by: https://cpp.la
ID = 0;
const json_value &jStart = (*pJsonData)["watchdog"];
if(jStart.type == json_array)
{
for(unsigned i = 0; i < jStart.u.array.length; i++)
{
if(ID < 0 || ID >= NET_MAX_CLIENTS)
continue;
str_copy(Watchdog(ID)->m_aName, jStart[i]["name"].u.string.ptr, sizeof(Watchdog(ID)->m_aName));
str_copy(Watchdog(ID)->m_aRule, jStart[i]["rule"].u.string.ptr, sizeof(Watchdog(ID)->m_aRule));
Watchdog(ID)->m_aInterval = jStart[i]["interval"].u.integer;
str_copy(Watchdog(ID)->m_aCallback, jStart[i]["callback"].u.string.ptr, sizeof(Watchdog(ID)->m_aCallback));
ID++;
}
str_copy(Watchdog(ID)->m_aName, "NULL", sizeof(Watchdog(ID)->m_aName));
} else
str_copy(Watchdog(ID)->m_aName, "NULL", sizeof(Watchdog(ID)->m_aName));
// if file exists, read last network traffic recordreset m_LastNetworkIN and m_LastNetworkOUT
// support by: https://cpp.la
IOHANDLE nFile = io_open(m_Config.m_aJSONFile, IOFLAG_READ);
if(!nFile)
{
char aJSONFileTmp[1024];
str_format(aJSONFileTmp, sizeof(aJSONFileTmp), "%s~", m_Config.m_aJSONFile);
nFile = io_open(aJSONFileTmp, IOFLAG_READ);
}
if(nFile)
{
int nFileSize = (int)io_length(nFile);
char *pNFileData = (char *)mem_alloc(nFileSize + 1, 1);
io_read(nFile, pNFileData, nFileSize);
pNFileData[nFileSize] = 0;
io_close(nFile);
json_settings nJsonSettings;
mem_zero(&nJsonSettings, sizeof(nJsonSettings));
json_value *pNJsonData = json_parse_ex(&nJsonSettings, pNFileData, strlen(pNFileData), aError);
if(pNJsonData)
{
const json_value &cStart = (*pNJsonData)["servers"];
if(rStart.type == json_array)
{
int ID = 0;
for(unsigned i = 0; i < rStart.u.array.length; i++)
{
if(ID < 0 || ID >= NET_MAX_CLIENTS)
continue;
for(unsigned j = 0; j < cStart.u.array.length; j++)
{
if(strcmp(Client(ID)->m_aName, cStart[j]["name"].u.string.ptr)==0 &&
strcmp(Client(ID)->m_aType, cStart[j]["type"].u.string.ptr)==0 &&
strcmp(Client(ID)->m_aHost, cStart[j]["host"].u.string.ptr)==0 &&
strcmp(Client(ID)->m_aLocation, cStart[j]["location"].u.string.ptr)==0)
{
Client(ID)->m_LastNetworkIN = cStart[j]["last_network_in"].u.integer;
Client(ID)->m_LastNetworkOUT = cStart[j]["last_network_out"].u.integer;
break;
}
}
ID++;
}
}
json_value_free(pNJsonData);
}
mem_free(pNFileData);
}
// clean up
json_value_free(pJsonData);
mem_free(pFileData);
// tell clients to reload the page
m_JSONUpdateThreadData.m_ReloadRequired = 2;
return 0;
}
int CMain::Run()
{
if(m_Server.Init(this, m_Config.m_aBindAddr, m_Config.m_Port))
return 1;
if(ReadConfig())
return 1;
// Start JSON Update Thread
m_JSONUpdateThreadData.m_ReloadRequired = 2;
m_JSONUpdateThreadData.pClients = m_aClients;
m_JSONUpdateThreadData.pConfig = &m_Config;
void *LoadThread = thread_create(JSONUpdateThread, &m_JSONUpdateThreadData);
//thread_detach(LoadThread);
while(gs_Running)
{
if(gs_ReloadConfig)
{
if(ReadConfig())
return 1;
m_Server.NetBan()->UnbanAll();
gs_ReloadConfig = 0;
}
m_Server.Update();
// wait for incomming data
net_socket_read_wait(*m_Server.Network()->Socket(), 10);
}
dbg_msg("server", "Closing.");
m_Server.Network()->Close();
thread_wait(LoadThread);
return 0;
}
int main(int argc, const char *argv[])
{
int RetVal;
dbg_logger_stdout();
#if defined(CONF_FAMILY_UNIX)
signal(SIGINT, ExitFunc);
signal(SIGTERM, ExitFunc);
signal(SIGQUIT, ExitFunc);
signal(SIGHUP, ReloadFunc);
#endif
char aUsage[128];
CConfig Config;
str_format(aUsage, sizeof(aUsage), "%s [options]", argv[0]);
const char *pConfigFile = 0;
const char *pWebDir = 0;
const char *pBindAddr = 0;
struct argparse_option aOptions[] = {
OPT_HELP(),
OPT_BOOLEAN('v', "verbose", &Config.m_Verbose, "Verbose output", 0),
OPT_STRING('c', "config", &pConfigFile, "Config file to use", 0),
OPT_STRING('d', "web-dir", &pWebDir, "Location of the web directory", 0),
OPT_STRING('b', "bind", &pBindAddr, "Bind to address", 0),
OPT_INTEGER('p', "port", &Config.m_Port, "Listen on port", 0),
OPT_END(),
};
struct argparse Argparse;
argparse_init(&Argparse, aOptions, aUsage, 0);
argc = argparse_parse(&Argparse, argc, argv);
if(pConfigFile)
str_copy(Config.m_aConfigFile, pConfigFile, sizeof(Config.m_aConfigFile));
if(pWebDir)
str_copy(Config.m_aWebDir, pWebDir, sizeof(Config.m_aWebDir));
if(pBindAddr)
str_copy(Config.m_aBindAddr, pBindAddr, sizeof(Config.m_aBindAddr));
if(Config.m_aWebDir[strlen(Config.m_aWebDir)-1] != '/')
str_append(Config.m_aWebDir, "/", sizeof(Config.m_aWebDir));
if(!fs_is_dir(Config.m_aWebDir))
{
dbg_msg("main", "ERROR: Can't find web directory: %s", Config.m_aWebDir);
return 1;
}
char aTmp[1024];
str_format(aTmp, sizeof(aTmp), "%s%s", Config.m_aWebDir, Config.m_aJSONFile);
str_copy(Config.m_aJSONFile, aTmp, sizeof(Config.m_aJSONFile));
CMain Main(Config);
RetVal = Main.Run();
return RetVal;
}
-125
View File
@@ -1,125 +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;
double m_CPU;
char m_aCustom[512];
// 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 CJSONUpdateThreadData
{
CClient *pClients;
CConfig *pConfig;
volatile short m_ReloadRequired;
} m_JSONUpdateThreadData;
static void JSONUpdateThread(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]; }
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 memory_total,
double memory_used,double swap_total, double swap_used, double hdd_total,
double hdd_used, double io_read, double io_write, double cpu,double online4, double online6);
CClient *Client(int ClientID) { return &m_aClients[ClientID]; }
CClient *ClientNet(int ClientNetID);
const CConfig *Config() const { return &m_Config; }
int ClientNetToClient(int ClientNetID);
};
#endif
-463
View File
@@ -1,463 +0,0 @@
#include <math.h>
#include "netban.h"
bool CNetBan::StrAllnum(const char *pStr)
{
while(*pStr)
{
if(!(*pStr >= '0' && *pStr <= '9'))
return false;
pStr++;
}
return true;
}
CNetBan::CNetHash::CNetHash(const NETADDR *pAddr)
{
if(pAddr->type==NETTYPE_IPV4)
m_Hash = (pAddr->ip[0]+pAddr->ip[1]+pAddr->ip[2]+pAddr->ip[3])&0xFF;
else
m_Hash = (pAddr->ip[0]+pAddr->ip[1]+pAddr->ip[2]+pAddr->ip[3]+pAddr->ip[4]+pAddr->ip[5]+pAddr->ip[6]+pAddr->ip[7]+
pAddr->ip[8]+pAddr->ip[9]+pAddr->ip[10]+pAddr->ip[11]+pAddr->ip[12]+pAddr->ip[13]+pAddr->ip[14]+pAddr->ip[15])&0xFF;
m_HashIndex = 0;
}
CNetBan::CNetHash::CNetHash(const CNetRange *pRange)
{
m_Hash = 0;
m_HashIndex = 0;
for(int i = 0; pRange->m_LB.ip[i] == pRange->m_UB.ip[i]; ++i)
{
m_Hash += pRange->m_LB.ip[i];
++m_HashIndex;
}
m_Hash &= 0xFF;
}
int CNetBan::CNetHash::MakeHashArray(const NETADDR *pAddr, CNetHash aHash[17])
{
int Length = pAddr->type==NETTYPE_IPV4 ? 4 : 16;
aHash[0].m_Hash = 0;
aHash[0].m_HashIndex = 0;
for(int i = 1, Sum = 0; i <= Length; ++i)
{
Sum += pAddr->ip[i-1];
aHash[i].m_Hash = Sum&0xFF;
aHash[i].m_HashIndex = i%Length;
}
return Length;
}
template<class T, int HashCount>
typename CNetBan::CBan<T> *CNetBan::CBanPool<T, HashCount>::Add(const T *pData, const CBanInfo *pInfo, const CNetHash *pNetHash)
{
if(!m_pFirstFree)
return 0;
// create new ban
CBan<T> *pBan = m_pFirstFree;
pBan->m_Data = *pData;
pBan->m_Info = *pInfo;
pBan->m_NetHash = *pNetHash;
if(pBan->m_pNext)
pBan->m_pNext->m_pPrev = pBan->m_pPrev;
if(pBan->m_pPrev)
pBan->m_pPrev->m_pNext = pBan->m_pNext;
else
m_pFirstFree = pBan->m_pNext;
// add it to the hash list
if(m_paaHashList[pNetHash->m_HashIndex][pNetHash->m_Hash])
m_paaHashList[pNetHash->m_HashIndex][pNetHash->m_Hash]->m_pHashPrev = pBan;
pBan->m_pHashPrev = 0;
pBan->m_pHashNext = m_paaHashList[pNetHash->m_HashIndex][pNetHash->m_Hash];
m_paaHashList[pNetHash->m_HashIndex][pNetHash->m_Hash] = pBan;
// insert it into the used list
if(m_pFirstUsed)
{
for(CBan<T> *p = m_pFirstUsed; ; p = p->m_pNext)
{
if(p->m_Info.m_Expires == CBanInfo::EXPIRES_NEVER || (pInfo->m_Expires != CBanInfo::EXPIRES_NEVER && pInfo->m_Expires <= p->m_Info.m_Expires))
{
// insert before
pBan->m_pNext = p;
pBan->m_pPrev = p->m_pPrev;
if(p->m_pPrev)
p->m_pPrev->m_pNext = pBan;
else
m_pFirstUsed = pBan;
p->m_pPrev = pBan;
break;
}
if(!p->m_pNext)
{
// last entry
p->m_pNext = pBan;
pBan->m_pPrev = p;
pBan->m_pNext = 0;
break;
}
}
}
else
{
m_pFirstUsed = pBan;
pBan->m_pNext = pBan->m_pPrev = 0;
}
// update ban count
++m_CountUsed;
return pBan;
}
template<class T, int HashCount>
int CNetBan::CBanPool<T, HashCount>::Remove(CBan<T> *pBan)
{
if(pBan == 0)
return -1;
// remove from hash list
if(pBan->m_pHashNext)
pBan->m_pHashNext->m_pHashPrev = pBan->m_pHashPrev;
if(pBan->m_pHashPrev)
pBan->m_pHashPrev->m_pHashNext = pBan->m_pHashNext;
else
m_paaHashList[pBan->m_NetHash.m_HashIndex][pBan->m_NetHash.m_Hash] = pBan->m_pHashNext;
pBan->m_pHashNext = pBan->m_pHashPrev = 0;
// remove from used list
if(pBan->m_pNext)
pBan->m_pNext->m_pPrev = pBan->m_pPrev;
if(pBan->m_pPrev)
pBan->m_pPrev->m_pNext = pBan->m_pNext;
else
m_pFirstUsed = pBan->m_pNext;
// add to recycle list
if(m_pFirstFree)
m_pFirstFree->m_pPrev = pBan;
pBan->m_pPrev = 0;
pBan->m_pNext = m_pFirstFree;
m_pFirstFree = pBan;
// update ban count
--m_CountUsed;
return 0;
}
template<class T, int HashCount>
void CNetBan::CBanPool<T, HashCount>::Update(CBan<CDataType> *pBan, const CBanInfo *pInfo)
{
pBan->m_Info = *pInfo;
// remove from used list
if(pBan->m_pNext)
pBan->m_pNext->m_pPrev = pBan->m_pPrev;
if(pBan->m_pPrev)
pBan->m_pPrev->m_pNext = pBan->m_pNext;
else
m_pFirstUsed = pBan->m_pNext;
// insert it into the used list
if(m_pFirstUsed)
{
for(CBan<T> *p = m_pFirstUsed; ; p = p->m_pNext)
{
if(p->m_Info.m_Expires == CBanInfo::EXPIRES_NEVER || (pInfo->m_Expires != CBanInfo::EXPIRES_NEVER && pInfo->m_Expires <= p->m_Info.m_Expires))
{
// insert before
pBan->m_pNext = p;
pBan->m_pPrev = p->m_pPrev;
if(p->m_pPrev)
p->m_pPrev->m_pNext = pBan;
else
m_pFirstUsed = pBan;
p->m_pPrev = pBan;
break;
}
if(!p->m_pNext)
{
// last entry
p->m_pNext = pBan;
pBan->m_pPrev = p;
pBan->m_pNext = 0;
break;
}
}
}
else
{
m_pFirstUsed = pBan;
pBan->m_pNext = pBan->m_pPrev = 0;
}
}
template<class T, int HashCount>
void CNetBan::CBanPool<T, HashCount>::Reset()
{
mem_zero(m_paaHashList, sizeof(m_paaHashList));
mem_zero(m_aBans, sizeof(m_aBans));
m_pFirstUsed = 0;
m_CountUsed = 0;
for(int i = 1; i < MAX_BANS-1; ++i)
{
m_aBans[i].m_pNext = &m_aBans[i+1];
m_aBans[i].m_pPrev = &m_aBans[i-1];
}
m_aBans[0].m_pNext = &m_aBans[1];
m_aBans[MAX_BANS-1].m_pPrev = &m_aBans[MAX_BANS-2];
m_pFirstFree = &m_aBans[0];
}
template<class T, int HashCount>
typename CNetBan::CBan<T> *CNetBan::CBanPool<T, HashCount>::Get(int Index) const
{
if(Index < 0 || Index >= Num())
return 0;
for(CNetBan::CBan<T> *pBan = m_pFirstUsed; pBan; pBan = pBan->m_pNext, --Index)
{
if(Index == 0)
return pBan;
}
return 0;
}
template<class T>
void CNetBan::MakeBanInfo(const CBan<T> *pBan, char *pBuf, unsigned BuffSize, int Type) const
{
if(pBan == 0 || pBuf == 0)
{
if(BuffSize > 0)
pBuf[0] = 0;
return;
}
// build type based part
char aBuf[256];
if(Type == MSGTYPE_PLAYER)
str_copy(aBuf, "You have been banned", sizeof(aBuf));
else
{
char aTemp[256];
switch(Type)
{
case MSGTYPE_LIST:
str_format(aBuf, sizeof(aBuf), "%s banned", NetToString(&pBan->m_Data, aTemp, sizeof(aTemp))); break;
case MSGTYPE_BANADD:
str_format(aBuf, sizeof(aBuf), "banned %s", NetToString(&pBan->m_Data, aTemp, sizeof(aTemp))); break;
case MSGTYPE_BANREM:
str_format(aBuf, sizeof(aBuf), "unbanned %s", NetToString(&pBan->m_Data, aTemp, sizeof(aTemp))); break;
default:
aBuf[0] = 0;
}
}
// add info part
if(pBan->m_Info.m_Expires != CBanInfo::EXPIRES_NEVER)
{
int Mins = ((pBan->m_Info.m_Expires-time_timestamp()) + 59) / 60;
if(Mins <= 1)
str_format(pBuf, BuffSize, "%s for 1 minute (%s)", aBuf, pBan->m_Info.m_aReason);
else
str_format(pBuf, BuffSize, "%s for %d minutes (%s)", aBuf, Mins, pBan->m_Info.m_aReason);
}
else
str_format(pBuf, BuffSize, "%s for life (%s)", aBuf, pBan->m_Info.m_aReason);
}
template<class T>
int CNetBan::Ban(T *pBanPool, const typename T::CDataType *pData, int Seconds, const char *pReason)
{
// do not ban localhost
if(NetMatch(pData, &m_LocalhostIPV4) || NetMatch(pData, &m_LocalhostIPV6))
{
dbg_msg("net_ban", "ban failed (localhost)");
return -1;
}
int Stamp = Seconds > 0 ? time_timestamp()+Seconds : CBanInfo::EXPIRES_NEVER;
// set up info
CBanInfo Info = {0};
Info.m_Expires = Stamp;
str_copy(Info.m_aReason, pReason, sizeof(Info.m_aReason));
// check if it already exists
CNetHash NetHash(pData);
CBan<typename T::CDataType> *pBan = pBanPool->Find(pData, &NetHash);
if(pBan)
{
// adjust the ban
pBanPool->Update(pBan, &Info);
char aBuf[128];
MakeBanInfo(pBan, aBuf, sizeof(aBuf), MSGTYPE_LIST);
dbg_msg("net_ban", aBuf);
return 1;
}
// add ban and print result
pBan = pBanPool->Add(pData, &Info, &NetHash);
if(pBan)
{
char aBuf[128];
MakeBanInfo(pBan, aBuf, sizeof(aBuf), MSGTYPE_BANADD);
dbg_msg("net_ban", aBuf);
return 0;
}
else
dbg_msg("net_ban", "ban failed (full banlist)");
return -1;
}
template<class T>
int CNetBan::Unban(T *pBanPool, const typename T::CDataType *pData)
{
CNetHash NetHash(pData);
CBan<typename T::CDataType> *pBan = pBanPool->Find(pData, &NetHash);
if(pBan)
{
char aBuf[256];
MakeBanInfo(pBan, aBuf, sizeof(aBuf), MSGTYPE_BANREM);
pBanPool->Remove(pBan);
dbg_msg("net_ban", aBuf);
return 0;
}
else
dbg_msg("net_ban", "unban failed (invalid entry)");
return -1;
}
void CNetBan::Init()
{
m_BanAddrPool.Reset();
m_BanRangePool.Reset();
net_host_lookup("localhost", &m_LocalhostIPV4, NETTYPE_IPV4);
net_host_lookup("localhost", &m_LocalhostIPV6, NETTYPE_IPV6);
}
void CNetBan::Update()
{
int Now = time_timestamp();
// remove expired bans
char aBuf[256], aNetStr[256];
while(m_BanAddrPool.First() && m_BanAddrPool.First()->m_Info.m_Expires != CBanInfo::EXPIRES_NEVER && m_BanAddrPool.First()->m_Info.m_Expires < Now)
{
str_format(aBuf, sizeof(aBuf), "ban %s expired", NetToString(&m_BanAddrPool.First()->m_Data, aNetStr, sizeof(aNetStr)));
dbg_msg("net_ban", aBuf);
m_BanAddrPool.Remove(m_BanAddrPool.First());
}
while(m_BanRangePool.First() && m_BanRangePool.First()->m_Info.m_Expires != CBanInfo::EXPIRES_NEVER && m_BanRangePool.First()->m_Info.m_Expires < Now)
{
str_format(aBuf, sizeof(aBuf), "ban %s expired", NetToString(&m_BanRangePool.First()->m_Data, aNetStr, sizeof(aNetStr)));
dbg_msg("net_ban", aBuf);
m_BanRangePool.Remove(m_BanRangePool.First());
}
}
int CNetBan::BanAddr(const NETADDR *pAddr, int Seconds, const char *pReason)
{
return Ban(&m_BanAddrPool, pAddr, Seconds, pReason);
}
int CNetBan::BanRange(const CNetRange *pRange, int Seconds, const char *pReason)
{
if(pRange->IsValid())
return Ban(&m_BanRangePool, pRange, Seconds, pReason);
dbg_msg("net_ban", "ban failed (invalid range)");
return -1;
}
int CNetBan::UnbanByAddr(const NETADDR *pAddr)
{
return Unban(&m_BanAddrPool, pAddr);
}
int CNetBan::UnbanByRange(const CNetRange *pRange)
{
if(pRange->IsValid())
return Unban(&m_BanRangePool, pRange);
dbg_msg("net_ban", "ban failed (invalid range)");
return -1;
}
int CNetBan::UnbanByIndex(int Index)
{
int Result;
char aBuf[256];
CBanAddr *pBan = m_BanAddrPool.Get(Index);
if(pBan)
{
NetToString(&pBan->m_Data, aBuf, sizeof(aBuf));
Result = m_BanAddrPool.Remove(pBan);
}
else
{
CBanRange *pBan = m_BanRangePool.Get(Index-m_BanAddrPool.Num());
if(pBan)
{
NetToString(&pBan->m_Data, aBuf, sizeof(aBuf));
Result = m_BanRangePool.Remove(pBan);
}
else
{
dbg_msg("net_ban", "unban failed (invalid index)");
return -1;
}
}
char aMsg[256];
str_format(aMsg, sizeof(aMsg), "unbanned index %i (%s)", Index, aBuf);
dbg_msg("net_ban", aMsg);
return Result;
}
void CNetBan::UnbanAll()
{
m_BanAddrPool.Reset();
m_BanRangePool.Reset();
}
bool CNetBan::IsBanned(const NETADDR *pAddr, char *pBuf, unsigned BufferSize) const
{
CNetHash aHash[17];
int Length = CNetHash::MakeHashArray(pAddr, aHash);
// check ban adresses
CBanAddr *pBan = m_BanAddrPool.Find(pAddr, &aHash[Length]);
if(pBan)
{
MakeBanInfo(pBan, pBuf, BufferSize, MSGTYPE_PLAYER);
return true;
}
// check ban ranges
for(int i = Length-1; i >= 0; --i)
{
for(CBanRange *pBan = m_BanRangePool.First(&aHash[i]); pBan; pBan = pBan->m_pHashNext)
{
if(NetMatch(&pBan->m_Data, pAddr, i, Length))
{
MakeBanInfo(pBan, pBuf, BufferSize, MSGTYPE_PLAYER);
return true;
}
}
}
return false;
}
-179
View File
@@ -1,179 +0,0 @@
#ifndef NETBAN_H
#define NETBAN_H
#include <system.h>
inline int NetComp(const NETADDR *pAddr1, const NETADDR *pAddr2)
{
return mem_comp(pAddr1, pAddr2, pAddr1->type==NETTYPE_IPV4 ? 8 : 20);
}
class CNetRange
{
public:
NETADDR m_LB;
NETADDR m_UB;
bool IsValid() const { return m_LB.type == m_UB.type && NetComp(&m_LB, &m_UB) < 0; }
};
inline int NetComp(const CNetRange *pRange1, const CNetRange *pRange2)
{
return NetComp(&pRange1->m_LB, &pRange2->m_LB) || NetComp(&pRange1->m_UB, &pRange2->m_UB);
}
class CNetBan
{
protected:
bool NetMatch(const NETADDR *pAddr1, const NETADDR *pAddr2) const
{
return NetComp(pAddr1, pAddr2) == 0;
}
bool NetMatch(const CNetRange *pRange, const NETADDR *pAddr, int Start, int Length) const
{
return pRange->m_LB.type == pAddr->type && (Start == 0 || mem_comp(&pRange->m_LB.ip[0], &pAddr->ip[0], Start) == 0) &&
mem_comp(&pRange->m_LB.ip[Start], &pAddr->ip[Start], Length-Start) <= 0 && mem_comp(&pRange->m_UB.ip[Start], &pAddr->ip[Start], Length-Start) >= 0;
}
bool NetMatch(const CNetRange *pRange, const NETADDR *pAddr) const
{
return NetMatch(pRange, pAddr, 0, pRange->m_LB.type==NETTYPE_IPV4 ? 4 : 16);
}
const char *NetToString(const NETADDR *pData, char *pBuffer, unsigned BufferSize) const
{
char aAddrStr[NETADDR_MAXSTRSIZE];
net_addr_str(pData, aAddrStr, sizeof(aAddrStr), false);
str_format(pBuffer, BufferSize, "'%s'", aAddrStr);
return pBuffer;
}
const char *NetToString(const CNetRange *pData, char *pBuffer, unsigned BufferSize) const
{
char aAddrStr1[NETADDR_MAXSTRSIZE], aAddrStr2[NETADDR_MAXSTRSIZE];
net_addr_str(&pData->m_LB, aAddrStr1, sizeof(aAddrStr1), false);
net_addr_str(&pData->m_UB, aAddrStr2, sizeof(aAddrStr2), false);
str_format(pBuffer, BufferSize, "'%s' - '%s'", aAddrStr1, aAddrStr2);
return pBuffer;
}
// todo: move?
static bool StrAllnum(const char *pStr);
class CNetHash
{
public:
int m_Hash;
int m_HashIndex; // matching parts for ranges, 0 for addr
CNetHash() {}
CNetHash(const NETADDR *pAddr);
CNetHash(const CNetRange *pRange);
static int MakeHashArray(const NETADDR *pAddr, CNetHash aHash[17]);
};
struct CBanInfo
{
enum
{
EXPIRES_NEVER=-1,
REASON_LENGTH=64,
};
int m_Expires;
char m_aReason[REASON_LENGTH];
};
template<class T> struct CBan
{
T m_Data;
CBanInfo m_Info;
CNetHash m_NetHash;
// hash list
CBan *m_pHashNext;
CBan *m_pHashPrev;
// used or free list
CBan *m_pNext;
CBan *m_pPrev;
};
template<class T, int HashCount> class CBanPool
{
public:
typedef T CDataType;
CBan<CDataType> *Add(const CDataType *pData, const CBanInfo *pInfo, const CNetHash *pNetHash);
int Remove(CBan<CDataType> *pBan);
void Update(CBan<CDataType> *pBan, const CBanInfo *pInfo);
void Reset();
int Num() const { return m_CountUsed; }
bool IsFull() const { return m_CountUsed == MAX_BANS; }
CBan<CDataType> *First() const { return m_pFirstUsed; }
CBan<CDataType> *First(const CNetHash *pNetHash) const { return m_paaHashList[pNetHash->m_HashIndex][pNetHash->m_Hash]; }
CBan<CDataType> *Find(const CDataType *pData, const CNetHash *pNetHash) const
{
for(CBan<CDataType> *pBan = m_paaHashList[pNetHash->m_HashIndex][pNetHash->m_Hash]; pBan; pBan = pBan->m_pHashNext)
{
if(NetComp(&pBan->m_Data, pData) == 0)
return pBan;
}
return 0;
}
CBan<CDataType> *Get(int Index) const;
private:
enum
{
MAX_BANS=1024,
};
CBan<CDataType> *m_paaHashList[HashCount][256];
CBan<CDataType> m_aBans[MAX_BANS];
CBan<CDataType> *m_pFirstFree;
CBan<CDataType> *m_pFirstUsed;
int m_CountUsed;
};
typedef CBanPool<NETADDR, 1> CBanAddrPool;
typedef CBanPool<CNetRange, 16> CBanRangePool;
typedef CBan<NETADDR> CBanAddr;
typedef CBan<CNetRange> CBanRange;
template<class T> void MakeBanInfo(const CBan<T> *pBan, char *pBuf, unsigned BuffSize, int Type) const;
template<class T> int Ban(T *pBanPool, const typename T::CDataType *pData, int Seconds, const char *pReason);
template<class T> int Unban(T *pBanPool, const typename T::CDataType *pData);
CBanAddrPool m_BanAddrPool;
CBanRangePool m_BanRangePool;
NETADDR m_LocalhostIPV4, m_LocalhostIPV6;
public:
enum
{
MSGTYPE_PLAYER=0,
MSGTYPE_LIST,
MSGTYPE_BANADD,
MSGTYPE_BANREM,
};
virtual ~CNetBan() {}
void Init();
void Update();
virtual int BanAddr(const NETADDR *pAddr, int Seconds, const char *pReason);
virtual int BanRange(const CNetRange *pRange, int Seconds, const char *pReason);
int UnbanByAddr(const NETADDR *pAddr);
int UnbanByRange(const CNetRange *pRange);
int UnbanByIndex(int Index);
void UnbanAll();
bool IsBanned(const NETADDR *pAddr, char *pBuf, unsigned BufferSize) const;
};
#endif
-144
View File
@@ -1,144 +0,0 @@
#include <system.h>
#include "netban.h"
#include "network.h"
bool CNetwork::Open(NETADDR BindAddr, CNetBan *pNetBan)
{
// zero out the whole structure
mem_zero(this, sizeof(*this));
m_Socket.type = NETTYPE_INVALID;
m_Socket.ipv4sock = -1;
m_Socket.ipv6sock = -1;
m_pNetBan = pNetBan;
// open socket
m_Socket = net_tcp_create(BindAddr);
if(!m_Socket.type)
return false;
if(net_tcp_listen(m_Socket, NET_MAX_CLIENTS))
return false;
net_set_non_blocking(m_Socket);
for(int i = 0; i < NET_MAX_CLIENTS; i++)
m_aSlots[i].m_Connection.Reset();
return true;
}
void CNetwork::SetCallbacks(NETFUNC_NEWCLIENT pfnNewClient, NETFUNC_DELCLIENT pfnDelClient, void *pUser)
{
m_pfnNewClient = pfnNewClient;
m_pfnDelClient = pfnDelClient;
m_UserPtr = pUser;
}
int CNetwork::Close()
{
for(int i = 0; i < NET_MAX_CLIENTS; i++)
m_aSlots[i].m_Connection.Disconnect("Closing connection.");
net_tcp_close(m_Socket);
return 0;
}
int CNetwork::Drop(int ClientID, const char *pReason)
{
if(m_pfnDelClient)
m_pfnDelClient(ClientID, pReason, m_UserPtr);
m_aSlots[ClientID].m_Connection.Disconnect(pReason);
return 0;
}
int CNetwork::AcceptClient(NETSOCKET Socket, const NETADDR *pAddr)
{
char aError[256] = { 0 };
int FreeSlot = -1;
// look for free slot or multiple client
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(FreeSlot == -1 && m_aSlots[i].m_Connection.State() == NET_CONNSTATE_OFFLINE)
FreeSlot = i;
if(m_aSlots[i].m_Connection.State() != NET_CONNSTATE_OFFLINE)
{
if(net_addr_comp(pAddr, m_aSlots[i].m_Connection.PeerAddress()) == 0)
{
str_copy(aError, "Only one client per IP allowed.", sizeof(aError));
break;
}
}
}
// accept client
if(!aError[0] && FreeSlot != -1)
{
m_aSlots[FreeSlot].m_Connection.Init(Socket, pAddr);
if(m_pfnNewClient)
m_pfnNewClient(FreeSlot, m_UserPtr);
return 0;
}
// reject client
if(!aError[0])
str_copy(aError, "No free slot available.", sizeof(aError));
net_tcp_send(Socket, aError, str_length(aError));
net_tcp_close(Socket);
return -1;
}
int CNetwork::Update()
{
NETSOCKET Socket;
NETADDR Addr;
if(net_tcp_accept(m_Socket, &Socket, &Addr) > 0)
{
// check if we should just drop the packet
char aBuf[128];
if(NetBan() && NetBan()->IsBanned(&Addr, aBuf, sizeof(aBuf)))
{
// banned, reply with a message and drop
net_tcp_send(Socket, aBuf, str_length(aBuf));
net_tcp_close(Socket);
}
else
AcceptClient(Socket, &Addr);
}
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(m_aSlots[i].m_Connection.State() == NET_CONNSTATE_ONLINE)
m_aSlots[i].m_Connection.Update();
if(m_aSlots[i].m_Connection.State() == NET_CONNSTATE_ERROR)
Drop(i, m_aSlots[i].m_Connection.ErrorString());
}
return 0;
}
int CNetwork::Recv(char *pLine, int MaxLength, int *pClientID)
{
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(m_aSlots[i].m_Connection.State() == NET_CONNSTATE_ONLINE && m_aSlots[i].m_Connection.Recv(pLine, MaxLength))
{
if(pClientID)
*pClientID = i;
return 1;
}
}
return 0;
}
int CNetwork::Send(int ClientID, const char *pLine)
{
if(m_aSlots[ClientID].m_Connection.State() == NET_CONNSTATE_ONLINE)
return m_aSlots[ClientID].m_Connection.Send(pLine);
else
return -1;
}
-87
View File
@@ -1,87 +0,0 @@
#ifndef NETWORK_H
#define NETWORK_H
enum
{
NET_CONNSTATE_OFFLINE=0,
NET_CONNSTATE_CONNECT=1,
NET_CONNSTATE_PENDING=2,
NET_CONNSTATE_ONLINE=3,
NET_CONNSTATE_ERROR=4,
NET_MAX_PACKETSIZE = 1400,
NET_MAX_CLIENTS = 256
};
typedef int (*NETFUNC_DELCLIENT)(int ClientID, const char* pReason, void *pUser);
typedef int (*NETFUNC_NEWCLIENT)(int ClientID, void *pUser);
class CNetworkClient
{
private:
int m_State;
NETADDR m_PeerAddr;
NETSOCKET m_Socket;
char m_aBuffer[NET_MAX_PACKETSIZE];
int m_BufferOffset;
char m_aErrorString[256];
bool m_LineEndingDetected;
char m_aLineEnding[3];
public:
void Init(NETSOCKET Socket, const NETADDR *pAddr);
void Disconnect(const char *pReason);
int State() const { return m_State; }
const NETADDR *PeerAddress() const { return &m_PeerAddr; }
const char *ErrorString() const { return m_aErrorString; }
void Reset();
int Update();
int Send(const char *pLine);
int Recv(char *pLine, int MaxLength);
};
class CNetwork
{
private:
struct CSlot
{
CNetworkClient m_Connection;
};
NETSOCKET m_Socket;
class CNetBan *m_pNetBan;
CSlot m_aSlots[NET_MAX_CLIENTS];
NETFUNC_NEWCLIENT m_pfnNewClient;
NETFUNC_DELCLIENT m_pfnDelClient;
void *m_UserPtr;
public:
void SetCallbacks(NETFUNC_NEWCLIENT pfnNewClient, NETFUNC_DELCLIENT pfnDelClient, void *pUser);
//
bool Open(NETADDR BindAddr, CNetBan *pNetBan);
int Close();
//
int Recv(char *pLine, int MaxLength, int *pClientID = 0);
int Send(int ClientID, const char *pLine);
int Update();
//
int AcceptClient(NETSOCKET Socket, const NETADDR *pAddr);
int Drop(int ClientID, const char *pReason);
// status requests
const NETADDR *ClientAddr(int ClientID) const { return m_aSlots[ClientID].m_Connection.PeerAddress(); }
const NETSOCKET *Socket() const { return &m_Socket; }
class CNetBan *NetBan() const { return m_pNetBan; }
};
#endif
-184
View File
@@ -1,184 +0,0 @@
#include <system.h>
#include "network.h"
void CNetworkClient::Reset()
{
m_State = NET_CONNSTATE_OFFLINE;
mem_zero(&m_PeerAddr, sizeof(m_PeerAddr));
m_aErrorString[0] = 0;
m_Socket.type = NETTYPE_INVALID;
m_Socket.ipv4sock = -1;
m_Socket.ipv6sock = -1;
m_aBuffer[0] = 0;
m_BufferOffset = 0;
m_LineEndingDetected = false;
#if defined(CONF_FAMILY_WINDOWS)
m_aLineEnding[0] = '\r';
m_aLineEnding[1] = '\n';
m_aLineEnding[2] = 0;
#else
m_aLineEnding[0] = '\n';
m_aLineEnding[1] = 0;
m_aLineEnding[2] = 0;
#endif
}
void CNetworkClient::Init(NETSOCKET Socket, const NETADDR *pAddr)
{
Reset();
m_Socket = Socket;
net_set_non_blocking(m_Socket);
m_PeerAddr = *pAddr;
m_State = NET_CONNSTATE_ONLINE;
}
void CNetworkClient::Disconnect(const char *pReason)
{
if(State() == NET_CONNSTATE_OFFLINE)
return;
if(pReason && pReason[0])
Send(pReason);
net_tcp_close(m_Socket);
Reset();
}
int CNetworkClient::Update()
{
if(State() == NET_CONNSTATE_ONLINE)
{
if((int)(sizeof(m_aBuffer)) <= m_BufferOffset)
{
m_State = NET_CONNSTATE_ERROR;
str_copy(m_aErrorString, "too weak connection (out of buffer)", sizeof(m_aErrorString));
return -1;
}
int Bytes = net_tcp_recv(m_Socket, m_aBuffer+m_BufferOffset, (int)(sizeof(m_aBuffer))-m_BufferOffset);
if(Bytes > 0)
{
m_BufferOffset += Bytes;
}
else if(Bytes < 0)
{
if(net_would_block()) // no data received
return 0;
m_State = NET_CONNSTATE_ERROR; // error
str_copy(m_aErrorString, "connection failure", sizeof(m_aErrorString));
return -1;
}
else
{
m_State = NET_CONNSTATE_ERROR;
str_copy(m_aErrorString, "remote end closed the connection", sizeof(m_aErrorString));
return -1;
}
}
return 0;
}
int CNetworkClient::Recv(char *pLine, int MaxLength)
{
if(State() == NET_CONNSTATE_ONLINE)
{
if(m_BufferOffset)
{
// find message start
int StartOffset = 0;
while(m_aBuffer[StartOffset] == '\r' || m_aBuffer[StartOffset] == '\n')
{
// detect clients line ending format
if(!m_LineEndingDetected)
{
m_aLineEnding[0] = m_aBuffer[StartOffset];
if(StartOffset+1 < m_BufferOffset && (m_aBuffer[StartOffset+1] == '\r' || m_aBuffer[StartOffset+1] == '\n') &&
m_aBuffer[StartOffset] != m_aBuffer[StartOffset+1])
m_aLineEnding[1] = m_aBuffer[StartOffset+1];
m_LineEndingDetected = true;
}
if(++StartOffset >= m_BufferOffset)
{
m_BufferOffset = 0;
return 0;
}
}
// find message end
int EndOffset = StartOffset;
while(m_aBuffer[EndOffset] != '\r' && m_aBuffer[EndOffset] != '\n')
{
if(++EndOffset >= m_BufferOffset)
{
if(StartOffset > 0)
{
mem_move(m_aBuffer, m_aBuffer+StartOffset, m_BufferOffset-StartOffset);
m_BufferOffset -= StartOffset;
}
return 0;
}
}
// extract message and update buffer
if(MaxLength-1 < EndOffset-StartOffset)
{
if(StartOffset > 0)
{
mem_move(m_aBuffer, m_aBuffer+StartOffset, m_BufferOffset-StartOffset);
m_BufferOffset -= StartOffset;
}
return 0;
}
mem_copy(pLine, m_aBuffer+StartOffset, EndOffset-StartOffset);
pLine[EndOffset-StartOffset] = 0;
str_sanitize_cc(pLine);
mem_move(m_aBuffer, m_aBuffer+EndOffset, m_BufferOffset-EndOffset);
m_BufferOffset -= EndOffset;
return 1;
}
}
return 0;
}
int CNetworkClient::Send(const char *pLine)
{
if(State() != NET_CONNSTATE_ONLINE)
return -1;
char aBuf[1024];
str_copy(aBuf, pLine, (int)(sizeof(aBuf))-2);
int Length = str_length(aBuf);
aBuf[Length] = m_aLineEnding[0];
aBuf[Length+1] = m_aLineEnding[1];
aBuf[Length+2] = m_aLineEnding[2];
Length += 3;
const char *pData = aBuf;
while(1)
{
int Send = net_tcp_send(m_Socket, pData, Length);
if(Send < 0)
{
m_State = NET_CONNSTATE_ERROR;
str_copy(m_aErrorString, "failed to send packet", sizeof(m_aErrorString));
return -1;
}
if(Send >= Length)
break;
pData += Send;
Length -= Send;
}
return 0;
}
-204
View File
@@ -1,204 +0,0 @@
#include <system.h>
#include "netban.h"
#include "network.h"
#include "main.h"
#include "server.h"
int CServer::NewClientCallback(int ClientID, void *pUser)
{
CServer *pThis = (CServer *)pUser;
char aAddrStr[NETADDR_MAXSTRSIZE];
net_addr_str(pThis->m_Network.ClientAddr(ClientID), aAddrStr, sizeof(aAddrStr), true);
if(pThis->Main()->Config()->m_Verbose)
dbg_msg("server", "Connection accepted. ncid=%d addr=%s'", ClientID, aAddrStr);
pThis->m_aClients[ClientID].m_State = CClient::STATE_CONNECTED;
pThis->m_aClients[ClientID].m_TimeConnected = time_get();
pThis->m_Network.Send(ClientID, "Authentication required:");
return 0;
}
int CServer::DelClientCallback(int ClientID, const char *pReason, void *pUser)
{
CServer *pThis = (CServer *)pUser;
char aAddrStr[NETADDR_MAXSTRSIZE];
net_addr_str(pThis->m_Network.ClientAddr(ClientID), aAddrStr, sizeof(aAddrStr), true);
if(pThis->Main()->Config()->m_Verbose)
dbg_msg("server", "Client dropped. ncid=%d addr=%s reason='%s'", ClientID, aAddrStr, pReason);
if(pThis->m_aClients[ClientID].m_State == CClient::STATE_AUTHED)
pThis->Main()->OnDelClient(ClientID);
pThis->m_aClients[ClientID].m_State = CClient::STATE_EMPTY;
return 0;
}
int CServer::Init(CMain *pMain, const char *Bind, int Port)
{
m_pMain = pMain;
m_NetBan.Init();
for(int i = 0; i < NET_MAX_CLIENTS; i++)
m_aClients[i].m_State = CClient::STATE_EMPTY;
m_Ready = false;
if(Port == 0)
{
dbg_msg("server", "Will not bind to port 0.");
return 1;
}
NETADDR BindAddr;
if(Bind[0] && net_host_lookup(Bind, &BindAddr, NETTYPE_ALL) == 0)
{
// got bindaddr
BindAddr.type = NETTYPE_ALL;
BindAddr.port = Port;
}
else
{
mem_zero(&BindAddr, sizeof(BindAddr));
BindAddr.type = NETTYPE_ALL;
BindAddr.port = Port;
}
if(m_Network.Open(BindAddr, &m_NetBan))
{
m_Network.SetCallbacks(NewClientCallback, DelClientCallback, this);
m_Ready = true;
dbg_msg("server", "Bound to %s:%d", Bind, Port);
return 0;
}
else
dbg_msg("server", "Couldn't open socket. Port (%d) might already be in use.", Port);
return 1;
}
void CServer::Update()
{
if(!m_Ready)
return;
m_NetBan.Update();
m_Network.Update();
char aBuf[NET_MAX_PACKETSIZE];
int ClientID;
while(m_Network.Recv(aBuf, (int)(sizeof(aBuf))-1, &ClientID))
{
dbg_assert(m_aClients[ClientID].m_State != CClient::STATE_EMPTY, "Got message from empty slot.");
if(m_aClients[ClientID].m_State == CClient::STATE_CONNECTED)
{
int ID = -1;
char aUsername[128] = {0};
char aPassword[128] = {0};
const char *pTmp;
if(!(pTmp = str_find(aBuf, ":"))
|| (unsigned)(pTmp - aBuf) > sizeof(aUsername) || (unsigned)(str_length(pTmp) - 1) > sizeof(aPassword))
{
m_Network.NetBan()->BanAddr(m_Network.ClientAddr(ClientID), 60, "You're an idiot, go away.");
m_Network.Drop(ClientID, "Fuck off.");
return;
}
str_copy(aUsername, aBuf, pTmp - aBuf + 1);
str_copy(aPassword, pTmp + 1, sizeof(aPassword));
if(!*aUsername || !*aPassword)
{
m_Network.NetBan()->BanAddr(m_Network.ClientAddr(ClientID), 60, "You're an idiot, go away.");
m_Network.Drop(ClientID, "Username and password must not be blank.");
return;
}
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(!Main()->Client(i)->m_Active)
continue;
if(str_comp(Main()->Client(i)->m_aUsername, aUsername) == 0 && str_comp(Main()->Client(i)->m_aPassword, aPassword) == 0)
ID = i;
}
if(ID == -1)
{
m_Network.NetBan()->BanAddr(m_Network.ClientAddr(ClientID), 60, "Wrong username and/or password.");
m_Network.Drop(ClientID, "Wrong username and/or password.");
}
else if(Main()->Client(ID)->m_ClientNetID != -1)
{
m_Network.Drop(ClientID, "Only one connection per user allowed.");
}
else
{
m_aClients[ClientID].m_State = CClient::STATE_AUTHED;
m_aClients[ClientID].m_LastReceived = time_get();
m_Network.Send(ClientID, "Authentication successful. Access granted.");
if(m_Network.ClientAddr(ClientID)->type == NETTYPE_IPV4)
m_Network.Send(ClientID, "You are connecting via: IPv4");
else if(m_Network.ClientAddr(ClientID)->type == NETTYPE_IPV6)
m_Network.Send(ClientID, "You are connecting via: IPv6");
if(Main()->Config()->m_Verbose)
dbg_msg("server", "ncid=%d authed", ClientID);
Main()->OnNewClient(ClientID, ID);
}
}
else if(m_aClients[ClientID].m_State == CClient::STATE_AUTHED)
{
m_aClients[ClientID].m_LastReceived = time_get();
if(Main()->Config()->m_Verbose)
dbg_msg("server", "ncid=%d cmd='%s'", ClientID, aBuf);
if(str_comp(aBuf, "logout") == 0)
m_Network.Drop(ClientID, "Logout. Bye Bye ~");
else
Main()->HandleMessage(ClientID, aBuf);
}
}
for(int i = 0; i < NET_MAX_CLIENTS; ++i)
{
if(m_aClients[i].m_State == CClient::STATE_CONNECTED &&
time_get() > m_aClients[i].m_TimeConnected + 5 * time_freq())
{
m_Network.NetBan()->BanAddr(m_Network.ClientAddr(i), 30, "Authentication timeout.");
m_Network.Drop(i, "Authentication timeout.");
}
else if(m_aClients[i].m_State == CClient::STATE_AUTHED &&
time_get() > m_aClients[i].m_LastReceived + 15 * time_freq())
m_Network.Drop(i, "Timeout.");
}
}
void CServer::Send(int ClientID, const char *pLine)
{
if(!m_Ready)
return;
if(ClientID == -1)
{
for(int i = 0; i < NET_MAX_CLIENTS; i++)
{
if(m_aClients[i].m_State == CClient::STATE_AUTHED)
m_Network.Send(i, pLine);
}
}
else if(ClientID >= 0 && ClientID < NET_MAX_CLIENTS && m_aClients[ClientID].m_State == CClient::STATE_AUTHED)
m_Network.Send(ClientID, pLine);
}
void CServer::Shutdown()
{
if(!m_Ready)
return;
m_Network.Close();
}
-46
View File
@@ -1,46 +0,0 @@
#ifndef SERVER_H
#define SERVER_H
#include "netban.h"
#include "network.h"
class CServer
{
class CClient
{
public:
enum
{
STATE_EMPTY=0,
STATE_CONNECTED,
STATE_AUTHED,
};
int m_State;
int64 m_TimeConnected;
int64 m_LastReceived;
};
CClient m_aClients[NET_MAX_CLIENTS];
CNetwork m_Network;
CNetBan m_NetBan;
class CMain *m_pMain;
bool m_Ready;
static int NewClientCallback(int ClientID, void *pUser);
static int DelClientCallback(int ClientID, const char *pReason, void *pUser);
public:
int Init(CMain *pMain, const char *Bind, int Port);
void Update();
void Send(int ClientID, const char *pLine);
void Shutdown();
CNetwork *Network() { return &m_Network; }
CNetBan *NetBan() { return &m_NetBan; }
CMain *Main() { return m_pMain; }
};
#endif
-2001
View File
File diff suppressed because it is too large Load Diff
+204
View File
@@ -0,0 +1,204 @@
package main
import (
"crypto/tls"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
)
type CertState struct {
Config SSLCertConfig
ExpireTS int64
Mismatch bool
LastError string
LastCheck time.Time
Checking bool
LastAlarm7 time.Time
LastAlarm3 time.Time
LastAlarm1 time.Time
LastAlarmMismatch time.Time
}
func certKey(config SSLCertConfig) string {
return fmt.Sprintf("%s\x00%s\x00%d", config.Name, config.Domain, config.Port)
}
func (a *App) reconcileCerts(configs []SSLCertConfig) {
a.certMu.Lock()
defer a.certMu.Unlock()
next := make(map[string]*CertState, len(configs))
for _, config := range configs {
key := certKey(config)
if existing := a.certs[key]; existing != nil {
existing.Config = config
next[key] = existing
} else {
next[key] = &CertState{Config: config}
}
}
a.certs = next
}
func (a *App) sslLoop() {
a.runDueSSLChecks()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
a.runDueSSLChecks()
}
}
}
func (a *App) runDueSSLChecks() {
now := time.Now()
type dueCheck struct {
key string
config SSLCertConfig
}
due := make([]dueCheck, 0)
a.certMu.Lock()
for key, state := range a.certs {
interval := secondsDuration(state.Config.Interval)
if !state.Checking && (state.LastCheck.IsZero() || now.Sub(state.LastCheck) >= interval) {
state.Checking = true
due = append(due, dueCheck{key: key, config: state.Config})
}
}
a.certMu.Unlock()
for _, check := range due {
check := check
go a.executeSSLCheck(check.key, check.config)
}
}
func (a *App) executeSSLCheck(key string, config SSLCertConfig) {
expireTS, mismatch, err := checkCertificate(config)
now := time.Now()
type notification struct {
message string
}
alerts := make([]notification, 0, 2)
a.certMu.Lock()
state := a.certs[key]
if state == nil {
a.certMu.Unlock()
return
}
state.Checking = false
state.LastCheck = now
if err != nil {
state.LastError = err.Error()
a.certMu.Unlock()
a.wakeStatsWriter()
return
}
state.ExpireTS = expireTS
state.Mismatch = mismatch
state.LastError = ""
if config.Callback != "" && mismatch && (state.LastAlarmMismatch.IsZero() || now.Sub(state.LastAlarmMismatch) >= 24*time.Hour) {
state.LastAlarmMismatch = now
alerts = append(alerts, notification{message: fmt.Sprintf("【SSL证书域名不匹配】%s(%s) 证书域名与配置不一致", config.Name, config.Domain)})
}
days := int((expireTS - now.Unix()) / 86400)
if config.Callback != "" {
var lastAlarm *time.Time
switch {
case days <= 7 && days > 3:
lastAlarm = &state.LastAlarm7
case days <= 3 && days > 1:
lastAlarm = &state.LastAlarm3
case days <= 1:
lastAlarm = &state.LastAlarm1
}
if lastAlarm != nil && (lastAlarm.IsZero() || now.Sub(*lastAlarm) >= 20*time.Hour) {
*lastAlarm = now
expire := time.Unix(expireTS, 0).UTC().Format("2006-01-02 15:04:05")
alerts = append(alerts, notification{message: fmt.Sprintf("【SSL证书提醒】%s(%s) 将在 %d 天后(%s UTC) 到期", config.Name, config.Domain, days, expire)})
}
}
a.certMu.Unlock()
a.wakeStatsWriter()
for _, alert := range alerts {
if err := a.sendCallback(config.Callback, alert.message, "ServerStatusSSL"); err != nil {
a.logger.Printf("SSL certificate %q callback: %v", config.Name, err)
}
}
}
func checkCertificate(config SSLCertConfig) (int64, bool, error) {
host, err := certificateHost(config.Domain)
if err != nil {
return 0, false, err
}
address := net.JoinHostPort(host, strconv.Itoa(config.Port))
dialer := &net.Dialer{Timeout: 6 * time.Second}
connection, err := tls.DialWithDialer(dialer, "tcp", address, &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true, // VerifyHostname below preserves the legacy chain-independent check.
})
if err != nil {
return 0, false, err
}
defer connection.Close()
certificates := connection.ConnectionState().PeerCertificates
if len(certificates) == 0 {
return 0, false, fmt.Errorf("server returned no certificate")
}
leaf := certificates[0]
mismatch := leaf.VerifyHostname(host) != nil
return leaf.NotAfter.Unix(), mismatch, nil
}
func certificateHost(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("domain is empty")
}
if parsed, err := url.Parse(raw); err == nil && parsed.Hostname() != "" {
return parsed.Hostname(), nil
}
withoutPath := strings.SplitN(raw, "/", 2)[0]
if host, _, err := net.SplitHostPort(withoutPath); err == nil {
return strings.Trim(host, "[]"), nil
}
host := strings.Trim(withoutPath, "[]")
if host == "" {
return "", fmt.Errorf("domain is invalid")
}
return host, nil
}
func (a *App) sslSnapshot(configs []SSLCertConfig, now time.Time) []any {
a.certMu.RLock()
defer a.certMu.RUnlock()
result := make([]any, 0, len(configs))
for _, config := range configs {
entry := map[string]any{
"name": config.Name, "domain": config.Domain, "port": config.Port,
"expire_ts": int64(0), "expire_days": 0, "mismatch": false,
}
if state := a.certs[certKey(config)]; state != nil {
entry["expire_ts"] = state.ExpireTS
if state.ExpireTS != 0 {
entry["expire_days"] = int((state.ExpireTS - now.Unix()) / 86400)
}
entry["mismatch"] = state.Mismatch
if state.LastError != "" {
entry["error"] = state.LastError
}
}
result = append(result, entry)
}
return result
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"sync/atomic"
"testing"
"time"
)
func TestCertificateCheckAndSnapshot(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }))
defer server.Close()
parsed, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
port, _ := strconv.Atoi(parsed.Port())
config := SSLCertConfig{Name: "local", Domain: server.URL, Port: port, Interval: 1}
expireTS, _, err := checkCertificate(config)
if err != nil {
t.Fatal(err)
}
if expireTS <= time.Now().Unix() {
t.Fatalf("unexpected expiration %d", expireTS)
}
doc := minimalTestConfig()
doc["sslcerts"] = []any{map[string]any{"name": config.Name, "domain": config.Domain, "port": config.Port, "interval": 1}}
app := newTestApp(t, doc)
app.runDueSSLChecks()
eventually(t, 2*time.Second, func() bool {
app.certMu.RLock()
defer app.certMu.RUnlock()
state := app.certs[certKey(config)]
return state != nil && !state.LastCheck.IsZero() && state.ExpireTS > 0
})
certs := app.SnapshotStats()["sslcerts"].([]any)
if certs[0].(map[string]any)["expire_ts"].(int64) <= time.Now().Unix() {
t.Fatalf("certificate snapshot: %#v", certs[0])
}
}
func TestCallbackDelivery(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
calls.Add(1)
if request.Method != http.MethodPost || request.URL.Query().Get("message") == "" {
t.Errorf("unexpected callback request: %s %s", request.Method, request.URL.String())
}
w.WriteHeader(204)
}))
defer server.Close()
doc := minimalTestConfig()
doc["watchdog"] = []any{map[string]any{
"name": "cpu", "rule": "cpu>90", "interval": 60, "callback": server.URL + "?message=",
}}
app := newTestApp(t, doc)
app.nodeMu.Lock()
node := app.nodes["s01"]
node.Connected = true
node.Stats = AgentStats{CPU: 99}
node.HasUpdate = true
app.nodeMu.Unlock()
app.evaluateWatchdogs("s01", false)
eventually(t, time.Second, func() bool { return calls.Load() == 1 })
app.evaluateWatchdogs("s01", false)
time.Sleep(30 * time.Millisecond)
if calls.Load() != 1 {
t.Fatalf("watchdog cooldown failed: calls=%d", calls.Load())
}
}
+247
View File
@@ -0,0 +1,247 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"strings"
"time"
)
type AgentServer struct {
app *App
}
func NewAgentServer(app *App) *AgentServer {
return &AgentServer{app: app}
}
func (s *AgentServer) ListenAndServe() error {
listener, err := net.Listen("tcp", s.app.opts.AgentAddr)
if err != nil {
return err
}
s.app.logger.Printf("agent TCP listening on %s", listener.Addr())
return s.Serve(listener)
}
func (s *AgentServer) Serve(listener net.Listener) error {
defer listener.Close()
s.app.agentRunning.Store(true)
defer s.app.agentRunning.Store(false)
go func() {
<-s.app.ctx.Done()
_ = listener.Close()
}()
for {
conn, err := listener.Accept()
if err != nil {
if s.app.ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
go s.handleConnection(conn)
}
}
func (s *AgentServer) handleConnection(conn net.Conn) {
defer conn.Close()
if tcpConn, ok := conn.(*net.TCPConn); ok {
_ = tcpConn.SetNoDelay(true)
_ = tcpConn.SetKeepAlive(true)
_ = tcpConn.SetKeepAlivePeriod(10 * time.Second)
}
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
if _, err := io.WriteString(conn, "Authentication required:\n"); err != nil {
return
}
reader := bufio.NewReaderSize(conn, 64*1024)
credentials, err := reader.ReadString('\n')
if err != nil {
return
}
credentials = strings.TrimSpace(credentials)
parts := strings.SplitN(credentials, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
_, _ = io.WriteString(conn, "Wrong username and/or password.\n")
return
}
family := remoteFamily(conn.RemoteAddr())
connectionID, monitors, apiErr := s.app.connectAgent(parts[0], parts[1], conn, family)
if apiErr != nil {
_, _ = io.WriteString(conn, apiErr.Message+"\n")
return
}
username := parts[0]
defer s.app.disconnectAgent(username, conn, connectionID)
if _, err := io.WriteString(conn, "Authentication successful. Access granted.\n"); err != nil {
return
}
// Existing Python agents use recv() instead of a line reader during the
// handshake. Keep the auth and metadata packets separate for compatibility.
time.Sleep(20 * time.Millisecond)
var metadata strings.Builder
fmt.Fprintf(&metadata, "You are connecting via: IPv%d\n", family)
for index, monitor := range monitors {
payload := map[string]any{"name": monitor.Name, "host": monitor.Host, "interval": monitor.Interval, "type": monitor.Type, "monitor": index}
data, _ := json.Marshal(payload)
metadata.Write(data)
metadata.WriteByte('\n')
}
if _, err := io.WriteString(conn, metadata.String()); err != nil {
return
}
_ = conn.SetDeadline(time.Now().Add(20 * time.Second))
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 4096), maxRequestBody)
for scanner.Scan() {
_ = conn.SetDeadline(time.Now().Add(20 * time.Second))
line := strings.TrimSpace(scanner.Text())
switch {
case strings.HasPrefix(line, "update"):
body := strings.TrimSpace(strings.TrimPrefix(line, "update"))
var update AgentStats
if err := json.Unmarshal([]byte(body), &update); err != nil {
if s.app.agentPong(username, connectionID) {
_, _ = io.WriteString(conn, "1\n")
}
continue
}
if !s.app.updateAgent(username, connectionID, update) {
return
}
if s.app.agentPong(username, connectionID) {
_, _ = io.WriteString(conn, "0\n")
}
case strings.HasPrefix(line, "pong"):
value := strings.TrimSpace(strings.TrimPrefix(line, "pong"))
s.app.setAgentPong(username, connectionID, value == "1" || strings.EqualFold(value, "on"))
default:
if s.app.agentPong(username, connectionID) {
_, _ = io.WriteString(conn, "1\n")
}
}
}
}
func remoteFamily(address net.Addr) int {
host, _, err := net.SplitHostPort(address.String())
if err == nil {
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
return 6
}
}
return 4
}
func (a *App) connectAgent(username, password string, conn net.Conn, family int) (uint64, []MonitorConfig, *APIError) {
a.configMu.RLock()
defer a.configMu.RUnlock()
var config *ServerConfig
for index := range a.runtime.Servers {
if a.runtime.Servers[index].Username == username {
server := a.runtime.Servers[index]
config = &server
break
}
}
if config == nil || config.Password != password {
return 0, nil, &APIError{Status: 401, Message: "Wrong username and/or password."}
}
if config.Disabled {
return 0, nil, &APIError{Status: 403, Message: "Server is disabled."}
}
a.nodeMu.Lock()
defer a.nodeMu.Unlock()
node := a.nodes[username]
if node == nil {
return 0, nil, &APIError{Status: 404, Message: "Server is not configured."}
}
if node.Connected {
return 0, nil, &APIError{Status: 409, Message: "Only one connection per user allowed."}
}
id := a.connectionID.Add(1)
node.Connected = true
node.Connection = conn
node.ConnectionID = id
node.Family = family
node.HasUpdate = false
node.Pong = false
node.Online4 = family == 4
node.Online6 = family == 6
a.wakeStatsWriter()
return id, append([]MonitorConfig(nil), a.runtime.Monitors...), nil
}
func (a *App) disconnectAgent(username string, conn net.Conn, connectionID uint64) {
a.nodeMu.Lock()
node := a.nodes[username]
if node == nil || node.ConnectionID != connectionID || node.Connection != conn {
a.nodeMu.Unlock()
return
}
node.Connected = false
node.Connection = nil
node.Online4 = false
node.Online6 = false
node.HasUpdate = false
node.Pong = false
a.nodeMu.Unlock()
a.wakeStatsWriter()
time.AfterFunc(25*time.Second, func() {
if a.ctx.Err() != nil {
return
}
a.nodeMu.RLock()
current := a.nodes[username]
stillOffline := current != nil && !current.Connected && current.ConnectionID == connectionID
a.nodeMu.RUnlock()
if stillOffline {
a.evaluateWatchdogs(username, true)
}
})
}
func (a *App) updateAgent(username string, connectionID uint64, update AgentStats) bool {
a.nodeMu.Lock()
node := a.nodes[username]
if node == nil || !node.Connected || node.ConnectionID != connectionID {
a.nodeMu.Unlock()
return false
}
if update.Online4 != nil {
node.Online4 = *update.Online4
}
if update.Online6 != nil {
node.Online6 = *update.Online6
}
node.Stats = update
node.HasUpdate = true
node.LastUpdate = time.Now()
a.nodeMu.Unlock()
a.wakeStatsWriter()
a.evaluateWatchdogs(username, false)
return true
}
func (a *App) setAgentPong(username string, connectionID uint64, enabled bool) {
a.nodeMu.Lock()
defer a.nodeMu.Unlock()
if node := a.nodes[username]; node != nil && node.ConnectionID == connectionID {
node.Pong = enabled
}
}
func (a *App) agentPong(username string, connectionID uint64) bool {
a.nodeMu.RLock()
defer a.nodeMu.RUnlock()
node := a.nodes[username]
return node != nil && node.ConnectionID == connectionID && node.Pong
}
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"net"
"strings"
"testing"
"time"
)
func TestAgentProtocolAndTrafficState(t *testing.T) {
app := newTestApp(t, minimalTestConfig())
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
serveDone := make(chan error, 1)
go func() { serveDone <- NewAgentServer(app).Serve(listener) }()
connection, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer connection.Close()
reader := bufio.NewReader(connection)
readContains(t, reader, "Authentication required")
if _, err := fmt.Fprintln(connection, "s01:secret"); err != nil {
t.Fatal(err)
}
readContains(t, reader, "Authentication successful")
readContains(t, reader, "You are connecting via: IPv4")
readContains(t, reader, `"monitor":0`)
if _, err := fmt.Fprintln(connection, "pong on"); err != nil {
t.Fatal(err)
}
online6 := true
update := AgentStats{
Uptime: 90061, Load1: 1.25, Load5: 1, Load15: 0.75, CPU: 33.5, CPUCores: 4, CPUModel: "Test CPU",
MemoryTotal: 1024, MemoryUsed: 512, SwapTotal: 128, SwapUsed: 2, HDDTotal: 10000, HDDUsed: 4000,
NetworkRX: 123, NetworkTX: 456, NetworkIn: 1_000_000, NetworkOut: 2_000_000,
Ping10010: 1, Ping189: 2, Ping10086: 3, Time10010: 10, Time189: 20, Time10086: 30,
TCPCount: 10, UDPCount: 2, ProcessCount: 30, ThreadCount: 60, IORead: 1, IOWrite: 2,
OS: "linux", Custom: "example=12", Online6: &online6,
}
payload, _ := json.Marshal(update)
if _, err := fmt.Fprintf(connection, "update %s\n", payload); err != nil {
t.Fatal(err)
}
readContains(t, reader, "0")
eventually(t, time.Second, func() bool {
servers := app.SnapshotStats()["servers"].([]any)
server := servers[0].(map[string]any)
return server["online4"] == true && server["online6"] == true && server["cpu_model"] == "Test CPU" && server["uptime"] == "1 天"
})
result, apiErr := app.ResetTraffic("s01")
if apiErr != nil {
t.Fatal(apiErr)
}
stats := result["stats"].(map[string]any)
if stats["last_network_in"] != int64(1_000_000) {
t.Fatalf("traffic reset result: %#v", stats)
}
duplicate, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
duplicateReader := bufio.NewReader(duplicate)
readContains(t, duplicateReader, "Authentication required")
_, _ = fmt.Fprintln(duplicate, "s01:secret")
readContains(t, duplicateReader, "Only one connection per user")
_ = duplicate.Close()
wrong, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
wrongReader := bufio.NewReader(wrong)
readContains(t, wrongReader, "Authentication required")
_, _ = fmt.Fprintln(wrong, "s01:wrong")
readContains(t, wrongReader, "Wrong username")
_ = wrong.Close()
if apiErr := app.ReloadConfig(); apiErr != nil {
t.Fatal(apiErr)
}
readContains(t, reader, "Server reloading")
eventually(t, time.Second, func() bool {
server := app.SnapshotStats()["servers"].([]any)[0].(map[string]any)
return server["online4"] == false && server["online6"] == false
})
app.cancel()
_ = listener.Close()
select {
case err := <-serveDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("agent server did not stop")
}
}
func readContains(t *testing.T, reader *bufio.Reader, expected string) string {
t.Helper()
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read %q: %v", expected, err)
}
if !strings.Contains(line, expected) {
t.Fatalf("expected %q in %q", expected, line)
}
return line
}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func minimalTestConfig() ConfigDocument {
return ConfigDocument{
"servers": []any{
map[string]any{
"username": "s01", "name": "node1", "type": "kvm", "host": "host1",
"location": "CN", "password": "secret", "monthstart": 1,
},
},
"monitors": []any{
map[string]any{"name": "example", "host": "https://example.com", "interval": 60, "type": "https"},
},
"sslcerts": []any{},
"watchdog": []any{},
}
}
func newTestApp(t *testing.T, doc ConfigDocument) *App {
t.Helper()
directory := t.TempDir()
configPath := filepath.Join(directory, "config.json")
statsPath := filepath.Join(directory, "data", "stats.json")
webDir := filepath.Join(directory, "web")
if err := os.MkdirAll(webDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "index.html"), []byte("<!doctype html><title>test-ui</title>"), 0o644); err != nil {
t.Fatal(err)
}
data, err := json.MarshalIndent(doc, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatal(err)
}
app, err := NewApp(Options{
ConfigPath: configPath,
StatsPath: statsPath,
WebDir: webDir,
HTTPAddr: "127.0.0.1:0",
AgentAddr: "127.0.0.1:0",
AdminToken: "test-token",
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(app.Close)
return app
}
func performRequest(handler http.Handler, method, path, body, token string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
request.Header.Set("Content-Type", "application/json")
}
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func eventually(t *testing.T, timeout time.Duration, condition func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if condition() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("condition was not met before timeout")
}
+217
View File
@@ -0,0 +1,217 @@
package main
import (
"crypto/tls"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/expr-lang/expr"
)
func compileWatchdog(rule WatchdogConfig, index int) (CompiledWatchdog, error) {
normalized := normalizeLegacyExpression(rule.Rule)
program, err := expr.Compile(normalized, expr.Env(WatchdogEnvironment{}))
if err != nil {
return CompiledWatchdog{}, err
}
return CompiledWatchdog{
WatchdogConfig: rule,
Key: fmt.Sprintf("%d:%s", index, rule.Name),
Normalized: normalized,
Program: program,
}, nil
}
func normalizeLegacyExpression(input string) string {
var output strings.Builder
var quote rune
runes := []rune(input)
for index := 0; index < len(runes); index++ {
current := runes[index]
if quote != 0 {
output.WriteRune(current)
if current == quote && (index == 0 || runes[index-1] != '\\') {
quote = 0
}
continue
}
if current == '\'' || current == '"' {
quote = current
output.WriteRune(current)
continue
}
switch current {
case '&':
output.WriteString("&&")
if index+1 < len(runes) && runes[index+1] == '&' {
index++
}
case '|':
output.WriteString("||")
if index+1 < len(runes) && runes[index+1] == '|' {
index++
}
case '=':
previousOperator := index > 0 && strings.ContainsRune("!<>=", runes[index-1])
nextEqual := index+1 < len(runes) && runes[index+1] == '='
if previousOperator || nextEqual {
output.WriteRune(current)
} else {
output.WriteString("==")
}
default:
output.WriteRune(current)
}
}
return output.String()
}
func (a *App) evaluateWatchdogs(username string, offline bool) {
runtime := a.RuntimeSnapshot()
now := time.Now()
type pendingAlert struct {
rule WatchdogConfig
node ServerConfig
}
pending := make([]pendingAlert, 0)
a.nodeMu.Lock()
node := a.nodes[username]
if node == nil || (!offline && !node.Connected) || (offline && node.Connected) {
a.nodeMu.Unlock()
return
}
stats := node.Stats
if offline {
stats = AgentStats{}
}
environment := watchdogEnvironment(node.Config, stats, node.Online4, node.Online6, node.LastNetworkIn, node.LastNetworkOut)
for _, rule := range runtime.Watchdogs {
result, err := expr.Run(rule.Program, environment)
if err != nil || !expressionTruthy(result) {
continue
}
if last := node.AlarmLast[rule.Key]; !last.IsZero() && now.Sub(last) < secondsDuration(rule.Interval) {
continue
}
node.AlarmLast[rule.Key] = now
if rule.Callback != "" {
pending = append(pending, pendingAlert{rule: rule.WatchdogConfig, node: node.Config})
}
}
a.nodeMu.Unlock()
for _, alert := range pending {
alert := alert
go func() {
message := fmt.Sprintf("【告警名称】 %s \n\n【告警时间】 %s \n\n【用户名】 %s \n\n【节点名】 %s \n\n【虚拟化】 %s \n\n【主机名】 %s \n\n【位 置】 %s",
alert.rule.Name, now.Format("2006-01-02 15:04:05"), alert.node.Username, alert.node.Name, alert.node.Type, alert.node.Host, alert.node.Location)
if err := a.sendCallback(alert.rule.Callback, message, "ServerStatus"); err != nil {
a.logger.Printf("watchdog %q callback: %v", alert.rule.Name, err)
}
}()
}
}
type WatchdogEnvironment struct {
Username string `expr:"username"`
Name string `expr:"name"`
NodeType string `expr:"type"`
Host string `expr:"host"`
Location string `expr:"location"`
Load1 float64 `expr:"load_1"`
Load5 float64 `expr:"load_5"`
Load15 float64 `expr:"load_15"`
Ping10010 float64 `expr:"ping_10010"`
Ping189 float64 `expr:"ping_189"`
Ping10086 float64 `expr:"ping_10086"`
Time10010 float64 `expr:"time_10010"`
Time189 float64 `expr:"time_189"`
Time10086 float64 `expr:"time_10086"`
TCPCount float64 `expr:"tcp_count"`
UDPCount float64 `expr:"udp_count"`
ProcessCount float64 `expr:"process_count"`
ThreadCount float64 `expr:"thread_count"`
NetworkRX float64 `expr:"network_rx"`
NetworkTX float64 `expr:"network_tx"`
NetworkIn float64 `expr:"network_in"`
NetworkOut float64 `expr:"network_out"`
LastNetworkIn float64 `expr:"last_network_in"`
LastNetworkOut float64 `expr:"last_network_out"`
MemoryTotal float64 `expr:"memory_total"`
MemoryUsed float64 `expr:"memory_used"`
SwapTotal float64 `expr:"swap_total"`
SwapUsed float64 `expr:"swap_used"`
HDDTotal float64 `expr:"hdd_total"`
HDDUsed float64 `expr:"hdd_used"`
IORead float64 `expr:"io_read"`
IOWrite float64 `expr:"io_write"`
CPU float64 `expr:"cpu"`
Online4 float64 `expr:"online4"`
Online6 float64 `expr:"online6"`
}
func watchdogEnvironment(config ServerConfig, stats AgentStats, online4, online6 bool, lastNetworkIn, lastNetworkOut int64) WatchdogEnvironment {
boolNumber := func(value bool) float64 {
if value {
return 1
}
return 0
}
return WatchdogEnvironment{
Username: config.Username, Name: config.Name, NodeType: config.Type, Host: config.Host, Location: config.Location,
Load1: stats.Load1, Load5: stats.Load5, Load15: stats.Load15,
Ping10010: stats.Ping10010, Ping189: stats.Ping189, Ping10086: stats.Ping10086,
Time10010: float64(stats.Time10010), Time189: float64(stats.Time189), Time10086: float64(stats.Time10086),
TCPCount: float64(stats.TCPCount), UDPCount: float64(stats.UDPCount),
ProcessCount: float64(stats.ProcessCount), ThreadCount: float64(stats.ThreadCount),
NetworkRX: float64(stats.NetworkRX), NetworkTX: float64(stats.NetworkTX),
NetworkIn: float64(stats.NetworkIn), NetworkOut: float64(stats.NetworkOut),
LastNetworkIn: float64(lastNetworkIn), LastNetworkOut: float64(lastNetworkOut),
MemoryTotal: float64(stats.MemoryTotal), MemoryUsed: float64(stats.MemoryUsed),
SwapTotal: float64(stats.SwapTotal), SwapUsed: float64(stats.SwapUsed),
HDDTotal: float64(stats.HDDTotal), HDDUsed: float64(stats.HDDUsed),
IORead: float64(stats.IORead), IOWrite: float64(stats.IOWrite), CPU: stats.CPU,
Online4: boolNumber(online4), Online6: boolNumber(online6),
}
}
func expressionTruthy(result any) bool {
switch value := result.(type) {
case bool:
return value
case int:
return value != 0
case int64:
return value != 0
case float64:
return value != 0
case string:
parsed, _ := strconv.ParseBool(value)
return parsed
default:
return false
}
}
func (a *App) sendCallback(baseURL, message, signature string) error {
requestURL := baseURL + url.QueryEscape(message)
transport := http.DefaultTransport.(*http.Transport).Clone()
if a.opts.InsecureCallbackTLS {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec -- explicit compatibility option
}
client := &http.Client{Timeout: 6 * time.Second, Transport: transport}
response, err := client.Post(requestURL, "application/x-www-form-urlencoded", strings.NewReader("signature="+url.QueryEscape(signature)))
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("HTTP %s", response.Status)
}
return nil
}
+20 -9
View File
@@ -1,9 +1,20 @@
[Unit]
Description=ServerStatus-Server
After=network.target
[Service]
ExecStart=/usr/local/ServerStatus/server/sergate --config=/usr/local/ServerStatus/server/config.json --web-dir=/usr/local/ServerStatus/web
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
[Install]
WantedBy=multi-user.target
[Unit]
Description=ServerStatus Go Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/usr/local/ServerStatus/server
EnvironmentFile=-/usr/local/ServerStatus/server/config.conf
ExecStart=/usr/local/ServerStatus/server/serverstatus --config=/usr/local/ServerStatus/server/config.json --web-dir=/usr/local/ServerStatus/web
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/usr/local/ServerStatus/server /usr/local/ServerStatus/web/json
[Install]
WantedBy=multi-user.target
+55 -80
View File
@@ -2,7 +2,7 @@
PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:~/bin
export PATH
sh_ver="1.0.0"
sh_ver="2.0.0"
filepath=$(
cd "$(dirname "$0")" || exit
@@ -49,7 +49,7 @@ check_sys() {
}
check_installed_server_status() {
[[ ! -e "${server_file}/sergate" ]] && echo -e "${Error} $NAME 服务端没有安装,请检查 !" && exit 1
[[ ! -x "${server_file}/serverstatus" ]] && echo -e "${Error} $NAME Go 服务端没有安装,请检查 !" && exit 1
}
check_installed_client_status() {
@@ -58,27 +58,24 @@ check_installed_client_status() {
Download_Server_Status_server() {
cd "/tmp" || exit 1
rm -rf "/tmp/ServerStatus-master" "/tmp/master.zip"
wget -N --no-check-certificate https://github.com/cppla/ServerStatus/archive/refs/heads/master.zip
[[ ! -e "master.zip" ]] && echo -e "${Error} ServerStatus 服务端下载失败 !" && exit 1
[[ ! -e "master.zip" ]] && echo -e "${Error} ServerStatus 服务端下载失败 !" && exit 1
unzip master.zip
rm -rf master.zip
[[ ! -d "/tmp/ServerStatus-master" ]] && echo -e "${Error} ServerStatus 服务端解压失败 !" && exit 1
cd "/tmp/ServerStatus-master/server" || exit 1
make
[[ ! -e "sergate" ]] && echo -e "${Error} ServerStatus 服务端编译失败 !" && cd "${file_1}" && rm -rf "/tmp//ServerStatus-master" && exit 1
go build -trimpath -ldflags="-s -w -X main.version=${sh_ver}" -o /tmp/serverstatus .
[[ ! -x "/tmp/serverstatus" ]] && echo -e "${Error} ServerStatus Go 服务端编译失败,请确认 Go 版本满足 go.mod !" && cd "${file_1}" && rm -rf "/tmp/ServerStatus-master" && exit 1
cd "${file_1}" || exit 1
mkdir -p "${server_file}"
mv "/tmp/ServerStatus-master/server" "${file}"
mv "/tmp/ServerStatus-master/web" "${file}"
mv "/tmp/ServerStatus-master/plugin" "${file}"
mkdir -p "${server_file}" "${web_file}/json" "${plugin_file}"
install -m 0755 /tmp/serverstatus "${server_file}/serverstatus"
[[ ! -e "${server_conf}" ]] && install -m 0644 "/tmp/ServerStatus-master/server/config.json" "${server_conf}"
cp -a "/tmp/ServerStatus-master/web/." "${web_file}/"
cp -a "/tmp/ServerStatus-master/plugin/." "${plugin_file}/"
rm -f /tmp/serverstatus
rm -rf "/tmp/ServerStatus-master"
if [[ ! -e "${server_file}/sergate" ]]; then
echo -e "${Error} ServerStatus 服务端移动重命名失败 !"
[[ -e "${server_file}/sergate1" ]] && mv "${server_file}/sergate1" "${server_file}/sergate"
exit 1
else
[[ -e "${server_file}/sergate1" ]] && rm -rf "${server_file}/sergate1"
fi
[[ ! -x "${server_file}/serverstatus" ]] && echo -e "${Error} ServerStatus Go 服务端安装失败 !" && exit 1
}
Download_Server_Status_client() {
@@ -113,18 +110,27 @@ Installation_dependency() {
if [[ ${release} == "centos" ]]; then
yum makecache
yum -y install unzip
yum -y install python3 >/dev/null 2>&1 || yum -y install python
[[ ${mode} == "server" ]] && yum -y groupinstall "Development Tools"
if [[ ${mode} == "server" ]]; then
yum -y install golang
else
yum -y install python3 >/dev/null 2>&1 || yum -y install python
fi
elif [[ ${release} == "debian" ]]; then
apt -y update
apt -y install unzip
apt -y install python3 >/dev/null 2>&1 || apt -y install python
[[ ${mode} == "server" ]] && apt -y install build-essential
if [[ ${mode} == "server" ]]; then
apt -y install golang-go
else
apt -y install python3 >/dev/null 2>&1 || apt -y install python
fi
elif [[ ${release} == "archlinux" ]]; then
pacman -Sy python python-pip unzip --noconfirm
[[ ${mode} == "server" ]] && pacman -Sy base-devel --noconfirm
if [[ ${mode} == "server" ]]; then
pacman -Sy go unzip --noconfirm
else
pacman -Sy python python-pip unzip --noconfirm
fi
fi
[[ ! -e /usr/bin/python ]] && ln -s /usr/bin/python3 /usr/bin/python
[[ ${mode} == "client" && ! -e /usr/bin/python ]] && ln -s /usr/bin/python3 /usr/bin/python
}
Write_server_config() {
@@ -147,7 +153,9 @@ EOF
Write_server_config_conf() {
cat >${server_conf_1} <<-EOF
PORT = ${server_port_s}
AGENT_ADDR=:${server_port_s}
HTTP_ADDR=:${server_http_port_s}
ADMIN_TOKEN=${admin_token_s}
EOF
}
@@ -162,10 +170,19 @@ Read_config_client() {
Read_config_server() {
if [[ ! -e "${server_conf_1}" ]]; then
server_port_s="35601"
server_http_port_s="8080"
admin_token_s=""
Write_server_config_conf
server_port="35601"
server_http_port="8080"
else
server_port="$(grep "PORT = " ${server_conf_1} | awk '{print $3}')"
agent_addr="$(grep '^AGENT_ADDR=' "${server_conf_1}" | head -1 | cut -d= -f2-)"
http_addr="$(grep '^HTTP_ADDR=' "${server_conf_1}" | head -1 | cut -d= -f2-)"
admin_token_s="$(grep '^ADMIN_TOKEN=' "${server_conf_1}" | head -1 | cut -d= -f2-)"
server_port="${agent_addr##*:}"
server_http_port="${http_addr##*:}"
server_port_s="${server_port:-35601}"
server_http_port_s="${server_http_port:-8080}"
fi
}
@@ -191,8 +208,8 @@ Set_server() {
Set_server_http_port() {
while true; do
echo -e "请输入 $NAME 服务端中网站要设置的 域名/IP的端口[1-65535](如果是域名的话,一般用 80 端口)"
read -erp "(默认: 8888):" server_http_port_s
[[ -z "$server_http_port_s" ]] && server_http_port_s="8888"
read -erp "(默认: 8080):" server_http_port_s
[[ -z "$server_http_port_s" ]] && server_http_port_s="8080"
if [[ "$server_http_port_s" =~ ^[0-9]*$ ]]; then
if [[ ${server_http_port_s} -ge 1 ]] && [[ ${server_http_port_s} -le 65535 ]]; then
echo && echo " ================================================"
@@ -623,54 +640,16 @@ Install_jq() {
fi
}
Install_caddy() {
echo
echo -e "${Info} 是否由脚本自动配置HTTP服务(服务端的在线监控网站),如果选择 N,则请在其他HTTP服务中配置网站根目录为:${Green_font_prefix}${web_file}${Font_color_suffix} [Y/n]"
read -erp "(默认: Y 自动部署):" caddy_yn
[[ -z "$caddy_yn" ]] && caddy_yn="y"
if [[ "${caddy_yn}" == [Yy] ]]; then
caddy_file="/etc/caddy/Caddyfile" # Where is the default Caddyfile specified in Archlinux?
[[ ! -e /usr/bin/caddy ]] && {
if [[ ${release} == "debian" ]]; then
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/gpg.key" | tee /etc/apt/trusted.gpg.d/caddy-stable.asc
curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install caddy
elif [[ ${release} == "centos" ]]; then
yum install yum-plugin-copr -y
yum copr enable @caddy/caddy -y
yum install caddy -y
elif [[ ${release} == "archlinux" ]]; then
pacman -Sy caddy --noconfirm
fi
[[ ! -e "/usr/bin/caddy" ]] && echo -e "${Error} Caddy安装失败,请手动部署,Web网页文件位置:${web_file}" && exit 1
systemctl enable caddy
echo "" >${caddy_file}
}
Set_server "server"
Set_server_http_port
cat >>${caddy_file} <<-EOF
http://${server_s}:${server_http_port_s} {
root * ${web_file}
encode gzip
file_server
}
EOF
systemctl restart caddy
else
echo -e "${Info} 跳过 HTTP服务部署,请手动部署,Web网页文件位置:${web_file} ,如果位置改变,请注意修改服务脚本文件 /etc/init.d/status-server 中的 WEB_BIN 变量 !"
fi
}
Install_ServerStatus_server() {
[[ -e "${server_file}/sergate" ]] && echo -e "${Error} 检测到 $NAME 服务端已安装 !" && exit 1
[[ -x "${server_file}/serverstatus" ]] && echo -e "${Error} 检测到 $NAME 服务端已安装 !" && exit 1
Set_server_port
Set_server_http_port
admin_token_s="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)"
echo -e "${Info} 开始安装/配置 依赖..."
Installation_dependency "server"
Install_caddy
echo -e "${Info} 开始下载/安装..."
Download_Server_Status_server
Install_jq
Install_jq
echo -e "${Info} 开始下载/安装 服务脚本..."
Service_Server_Status_server
echo -e "${Info} 开始写入 配置文件..."
@@ -678,6 +657,8 @@ Install_jq
Write_server_config_conf
echo -e "${Info} 所有步骤 安装完毕,开始启动..."
Start_ServerStatus_server
echo -e "${Info} WebUI: http://127.0.0.1:${server_http_port_s}/"
echo -e "${Info} ADMIN_TOKEN: ${admin_token_s}"
}
Install_ServerStatus_client() {
@@ -700,8 +681,9 @@ Install_ServerStatus_client() {
Update_ServerStatus_server() {
check_installed_server_status
systemctl stop status-server 2>/dev/null || true
Download_Server_Status_server
rm -rf /etc/init.d/status-server
rm -f "${service}/status-server.service"
Service_Server_Status_server
Start_ServerStatus_server
}
@@ -723,12 +705,12 @@ Update_ServerStatus_client() {
}
Start_ServerStatus_server() {
port="$(grep "m_Port = " ${server_file}/src/main.cpp | awk '{print $3}' | sed '{s/;$//}')"
check_installed_server_status
Read_config_server
systemctl -q is-active status-server && echo -e "${Error} $NAME 正在运行,请检查 !" && exit 1
systemctl start status-server
if (systemctl -q is-active status-server) then
echo -e "${Info} $NAME 服务端启动成功[监听端口${port}] !"
echo -e "${Info} $NAME Go 服务端启动成功[Agent${server_port}Web${server_http_port}] !"
else
echo -e "${Error} $NAME 服务端启动失败 !"
fi
@@ -777,13 +759,6 @@ Uninstall_ServerStatus_server() {
else
rm -rf "${file}"
fi
if [[ -e "/usr/bin/caddy" ]]; then
systemctl stop caddy
systemctl disable caddy
[[ ${release} == "debian" ]] && apt purge -y caddy
[[ ${release} == "centos" ]] && yum -y remove caddy
[[ ${release} == "archlinux" ]] && pacman -R caddy --noconfirm
fi
systemctl daemon-reload
systemctl reset-failed
echo && echo "ServerStatus 卸载完成 !" && echo
@@ -977,7 +952,7 @@ menu_server() {
${Green_font_prefix} 9.${Font_color_suffix} 查看 服务端日志
————————————
${Green_font_prefix}10.${Font_color_suffix} 切换为 客户端菜单" && echo
if [[ -e "${server_file}/sergate" ]]; then
if [[ -x "${server_file}/serverstatus" ]]; then
if (systemctl -q is-active status-server) then
echo -e " 当前状态: 服务端 ${Green_font_prefix}已安装${Font_color_suffix}${Green_font_prefix}已启动${Font_color_suffix}"
else
+406
View File
@@ -0,0 +1,406 @@
:root{color-scheme:dark;--bg:#0d1117;--bg-alt:#171d27;--surface:#1d2530;--surface-soft:#151b24;--border:#334155;--text:#f1f5f9;--text-dim:#a7b4c8;--accent:#3b82f6;--accent-glow:#7db5ff;--danger:#f05252;--warn:#fbbf24;--ok:#10b981;--radius:10px;--radius-sm:4px;--shadow:0 8px 22px -10px rgba(0,0,0,.75);--font:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,'Noto Sans SC',sans-serif;--trans:.25s cubic-bezier(.4,0,.2,1);--logo-start:#3b82f6;--logo-end:#2563eb;--logo-accent-grad-start:#8bc1ff;--logo-accent-grad-end:#d4e8ff}
body.light{color-scheme:light;--bg:#f6f7f9;--bg-alt:#ffffff;--surface:#ffffff;--surface-soft:#f8fafc;--border:#e2e8f0;--text:#1e293b;--text-dim:#64748b;--accent:#2563eb;--accent-glow:#3b82f6;--shadow:0 4px 20px -4px rgba(0,0,0,.08);--logo-start:#2563eb;--logo-end:#1d4ed8;--logo-accent-grad-start:#1d4ed8;--logo-accent-grad-end:#60a5fa}
*{box-sizing:border-box}
html,body{height:100%;margin:0;padding:0;font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased}
html.light{background:var(--bg);color-scheme:light}
body,button{font-size:14px;line-height:1.35}
a{color:var(--accent);text-decoration:none}
a:hover{color:var(--accent-glow)}
.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:1rem;padding:.75rem 1.25rem;background:var(--bg-alt);border-bottom:1px solid var(--border)}
.brand{font-weight:600;letter-spacing:.5px;font-size:15px}
.nav{display:flex;gap:.5rem}
.nav button{background:var(--surface-soft);border:1px solid var(--border);color:var(--text-dim);padding:.45rem .9rem;border-radius:var(--radius-sm);cursor:pointer;display:flex;align-items:center;gap:.35rem;transition:var(--trans);font-weight:600}
.nav button.active,.nav button:hover{color:#fff;background:var(--accent);border-color:var(--accent);box-shadow:0 0 0 1px var(--accent-glow),0 4px 10px -2px rgba(0,0,0,.5)}
.actions{margin-left:auto;display:flex;align-items:center;gap:.75rem}
.actions button{background:var(--bg);border:1px solid var(--border);color:var(--text-dim);height:32px;width:38px;border-radius:8px;cursor:pointer;display:grid;place-items:center;transition:var(--trans)}
.actions button:hover{color:#fff;border-color:var(--accent);background:var(--accent)}
.wrapper{max-width:1680px;margin:1.2rem auto;padding:0 1.2rem;display:flex;flex-direction:column;gap:1.25rem}
.notice{padding:.9rem 1rem;border:1px solid var(--border);background:linear-gradient(145deg,var(--bg-alt),var(--bg));border-radius:var(--radius);display:flex;align-items:center;gap:.75rem;font-size:13px}
.notice.info:before{content:"";width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 4px color-mix(in srgb,var(--accent) 20%,transparent)}
.panel{display:none;flex-direction:column;gap:.75rem;animation:fade .4s ease}
.panel.active{display:flex}
.table-wrap{overflow:auto;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-alt);box-shadow:var(--shadow)}
table.data{width:100%;border-collapse:separate;border-spacing:0;min-width:960px}
table.data thead th{position:sticky;top:0;background:var(--surface-soft);font-weight:700;text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);padding:.7rem .75rem;border-bottom:1px solid var(--border);white-space:nowrap}
table.data tbody td{padding:.55rem .75rem;border-bottom:1px solid var(--border);font-size:13px;vertical-align:middle;white-space:nowrap}
/* 防止数值变化导致列抖动 */
table.data th,table.data td{font-variant-numeric:tabular-nums}
/* 月流量胶囊 */
.caps-traffic{display:inline-flex;align-items:center;gap:6px;background:linear-gradient(145deg,var(--bg),var(--bg-alt));border:1px solid var(--border);padding:3px 12px 3px 10px;border-radius:999px;font-size:12px;line-height:1;font-weight:500;position:relative;box-shadow:0 2px 4px -2px rgba(0,0,0,.35),0 0 0 1px rgba(255,255,255,.03);}
.caps-traffic:before{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(circle at 20% 20%,rgba(255,255,255,.06),transparent 70%);pointer-events:none;}
.caps-traffic.sm{padding:2px 8px 2px 7px;font-size:11px;gap:5px;}
/* 双色胶囊 */
.caps-traffic.duo{background:none;border:0;gap:0;padding:0;box-shadow:none;position:relative;border-radius:999px;overflow:hidden;font-size:12px;}
/* 宽度按内容自适应,每半边仅为其文本 + padding */
.caps-traffic.duo .half{flex:0 0 auto;display:flex;align-items:center;justify-content:center;padding:2px 4px;font-variant-numeric:tabular-nums;font-weight:600;line-height:1.25; /* 与 .pill 保持一致高度 */ letter-spacing:.25px;color:#fff;font-size:12px;white-space:nowrap;}
.caps-traffic.duo.normal .half.in{background:#d1fae5;color:#065f46;} /* emerald-100 / text-emerald-800 */
.caps-traffic.duo.normal .half.out{background:#bfdbfe;color:#1e3a8a;} /* blue-200 / text-blue-900 */
.caps-traffic.duo.heavy .half.in{background:#fde68a;color:#78350f;}
.caps-traffic.duo.heavy .half.out{background:#fbbf24;color:#111827;}
/* 半之间分隔线 */
.caps-traffic.duo .half + .half{border-left:1px solid rgba(0,0,0,.18);}
body.light .caps-traffic.duo .half + .half{border-left:1px solid rgba(0,0,0,.08);}
.caps-traffic.duo.sm .half{padding:1px 4px;font-size:10px;min-width:0;}
table.data tbody tr:last-child td{border-bottom:none}
table.data tbody tr:hover{background:rgba(59,130,246,.045)}
.badge{display:inline-block;padding:2px 6px;font-size:11px;border-radius:12px;font-weight:500;line-height:1.2;background:var(--bg);border:1px solid var(--border);color:var(--text-dim)}
.badge.ok{background:rgba(16,185,129,.15);color:var(--ok);border-color:rgba(16,185,129,.3)}
.badge.warn{background:rgba(245,158,11,.15);color:var(--warn);border-color:rgba(245,158,11,.4)}
.badge.err{background:rgba(239,68,68,.15);color:var(--danger);border-color:rgba(239,68,68,.4)}
.footer{margin:2rem 0 2.5rem;display:flex;align-items:center;justify-content:center;gap:.5rem;font-size:12px;color:var(--text-dim)}
.footer a{color:var(--text-dim)}
.footer a:hover{color:var(--accent)}
.muted{color:var(--text-dim)}
/* 旧状态文字样式已不再使用(采用 pill) */
@media (max-width:1100px){.nav{flex-wrap:wrap}.table-wrap{border-radius:8px}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
/* modal styles */
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;padding:5vh 1rem;z-index:50;backdrop-filter:blur(4px)}
.modal-box{position:relative;width:100%;max-width:560px;background:var(--bg-alt);border:1px solid var(--border);border-radius:16px;box-shadow:0 8px 30px -6px rgba(0,0,0,.6);padding:1.25rem 1.35rem;display:flex;flex-direction:column;gap:.9rem;animation:fade .25s ease}
.modal-title{margin:0;font-size:16px;font-weight:600;letter-spacing:.5px}
.modal-close{position:absolute;top:10px;right:12px;background:transparent;border:0;color:var(--text-dim);font-size:20px;line-height:1;cursor:pointer;padding:4px;border-radius:8px;transition:var(--trans)}
.modal-close:hover{color:var(--text);background:var(--bg)}
.modal-content{font-size:13px;line-height:1.5;display:grid;gap:.6rem}
.kv{display:flex;justify-content:space-between;gap:1rem;padding:.5rem .75rem;background:linear-gradient(145deg,var(--bg),var(--bg-alt));border:1px solid var(--border);border-radius:10px}
.kv span{white-space:nowrap}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
/* 半圆仪表盘 */
.gauge-half{--p:0;width:60px;height:34px;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;font-family:ui-monospace,monospace;font-size:10px;font-weight:600;gap:0;color:var(--text);}
.gauge-half svg{width:100%;height:28px;overflow:visible;}
.gauge-half path{fill:none;stroke-linecap:round;}
/* 基础颜色 (暗色/亮色自动过渡) */
.gauge-half path.track{stroke:color-mix(in srgb,var(--text-dim) 18%,transparent);stroke-width:6;}
.gauge-half path.arc{stroke:var(--gauge-base,#3b82f6);stroke-width:8;stroke-dasharray:126;stroke-dashoffset:calc(126*(1 - var(--p)));transition:stroke-dashoffset .8s cubic-bezier(.4,0,.2,1),stroke .35s;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45));}
.gauge-half[data-type=mem] path.arc{--gauge-base:#10b981}
.gauge-half[data-type=hdd] path.arc{--gauge-base:#06b6d4}
/* 阈值颜色:CPU>=75%、内存>=80%、硬盘>=85% 警告黄,>=90% 危险红 */
.gauge-half[data-warn] path.arc{stroke:var(--warn)}
.gauge-half[data-bad] path.arc{stroke:var(--danger)}
.gauge-half span{line-height:1;position:relative;top:-6px;font-size:12px;}
/* 亮色模式细化对比度 */
body.light .gauge-half path.track{stroke:color-mix(in srgb,var(--text-dim) 28%,transparent)}
body.light .gauge-half path.arc{filter:none}
/* status pill */
.pill{display:inline-block;padding:2px 8px;font-size:12px;font-weight:600;border-radius:999px;letter-spacing:.45px;min-width:48px;text-align:center;line-height:1.25;border:0;box-shadow:0 2px 4px -1px rgba(0,0,0,.4),0 0 0 1px rgba(255,255,255,.04);transition:var(--trans);color:#fff}
.pill.on{background:var(--ok)}
.pill.off{background:var(--danger)}
.pill.on:hover{filter:brightness(1.1)}
.pill.off:hover{filter:brightness(1.1)}
.proto-signal{display:inline-grid;grid-template-columns:1fr 1fr;width:18px;height:18px;border-radius:50%;overflow:hidden;vertical-align:middle;border:1px solid color-mix(in srgb,var(--border) 80%,transparent);box-shadow:0 2px 5px -2px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.04)}
.proto-signal i{display:block;min-width:0;background:color-mix(in srgb,var(--text-dim) 42%,var(--bg-alt));}
.proto-signal[data-v4="1"] .v4,.proto-signal[data-v6="1"] .v6{background:#22c55e}
.proto-signal[data-state="ipv4"] .v4{background:#22c55e}
.proto-signal[data-state="ipv6"] .v6{background:#22c55e}
.proto-signal[data-state="offline"] .v4,.proto-signal[data-state="offline"] .v6{background:var(--danger)}
body.light .proto-signal{border-color:color-mix(in srgb,var(--border) 92%,#94a3b8);box-shadow:0 1px 3px rgba(15,23,42,.12)}
body.light .proto-signal i{background:#cbd5e1}
.virt-pill{--virt:var(--accent);display:inline-flex;align-items:center;justify-content:center;min-width:50px;padding:2px 9px;border-radius:999px;border:1px solid color-mix(in srgb,var(--virt) 42%,var(--border));background:color-mix(in srgb,var(--virt) 14%,var(--bg-alt));color:color-mix(in srgb,var(--virt) 72%,#fff);font-size:12px;font-weight:650;line-height:1.2;letter-spacing:.25px;white-space:nowrap}
body.light .virt-pill{background:color-mix(in srgb,var(--virt) 12%,#fff);color:color-mix(in srgb,var(--virt) 68%,#111827)}
.node-name{display:inline-block;max-width:160px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:750;letter-spacing:.15px;color:var(--text)}
.row-server[data-online="0"] .node-name{color:color-mix(in srgb,var(--text) 72%,var(--text-dim));font-weight:650}
.row-server:hover .node-name{color:var(--accent)}
.load-with-cores{display:inline-flex;align-items:flex-start;gap:.28rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;line-height:1;white-space:nowrap}
.load-value{display:inline-block;min-width:2.55rem}
.load-core-bubble{display:inline-flex;align-items:center;justify-content:center;height:14px;min-width:24px;padding:0 5px;margin-top:-6px;border-radius:999px;border:1px solid color-mix(in srgb,var(--accent) 34%,var(--border));background:color-mix(in srgb,var(--accent) 13%,var(--bg-alt));color:color-mix(in srgb,var(--accent) 74%,#fff);font-size:9px;font-weight:800;line-height:1;letter-spacing:0;box-shadow:0 1px 3px rgba(0,0,0,.22)}
body.light .load-core-bubble{background:color-mix(in srgb,var(--accent) 8%,#fff);color:color-mix(in srgb,var(--accent) 78%,#111827);box-shadow:0 1px 2px rgba(15,23,42,.08)}
/* buckets CU/CT/CM (simple version) */
.buckets{display:flex;align-items:flex-end;gap:8px;min-width:140px}
.bucket{position:relative;width:26px;height:34px;background:linear-gradient(145deg,var(--bg),var(--bg-alt));border:1px solid var(--border);border-radius:8px;padding:4px 4px 16px;box-sizing:border-box;display:flex;justify-content:flex-end}
.bucket span{display:block;width:100%;background:var(--accent);border-radius:4px 4px 6px 6px;height:var(--h);align-self:flex-end;transition:height .8s cubic-bezier(.4,0,.2,1),background .3s}
.bucket[data-lv=warn] span{background:var(--warn)}
.bucket[data-lv=bad] span{background:var(--danger)}
.bucket label{position:absolute;left:0;right:0;bottom:2px;font-size:10px;text-align:center;color:var(--text-dim);pointer-events:none}
.bucket:hover label{color:var(--text)}
/* 调整“总流量”表头(第9列)padding 使标题文字居中,不受正文额外右 padding 影响 */
#serversTable thead th:nth-child(9){padding-left:.75rem;padding-right:.75rem;}
.buckets{justify-content:center}
/* 响应式隐藏非关键列,保持可读性 */
@media (max-width:1100px){
#serversTable{min-width:100%;}
#serversTable thead th:nth-child(3),
#serversTable tbody td:nth-child(3), /* 节点 */
#serversTable thead th:nth-child(4),
#serversTable tbody td:nth-child(4), /* 虚拟化 */
#serversTable thead th:nth-child(8),
#serversTable tbody td:nth-child(8) /* 当前网络 */ {display:none}
}
@media (max-width:820px){
#serversTable thead th:nth-child(5),
#serversTable tbody td:nth-child(5), /* 在线 */
#serversTable thead th:nth-child(6),
#serversTable tbody td:nth-child(6) /* 负载 */ {display:none}
.buckets{gap:6px;min-width:100px}
}
@media (max-width:640px){
#serversTable thead th:nth-child(4),
#serversTable tbody td:nth-child(4) /* 位置 */ {display:none}
.topbar{flex-wrap:wrap;padding:.6rem .8rem}
.actions{width:100%;justify-content:flex-end;margin-top:.4rem}
.wrapper{padding:0 .7rem}
#panel-servers .table-wrap{display:none;}
#serversCards{display:grid!important;grid-template-columns:1fr;gap:.75rem;margin-top:.5rem;}
/* 服务与证书移动端卡片 */
#panel-monitors .table-wrap, #panel-ssl .table-wrap{display:none;}
#monitorsCards,#sslCards{display:grid!important;grid-template-columns:1fr;gap:.75rem;margin-top:.5rem;}
.modal-box{max-width:100%;border-radius:14px;padding:1rem .95rem;}
.modal-content{max-height:65vh;overflow:auto;}
}
/* 三个数据表使用自动宽度,按关键列单独约束 */
#serversTable,#monitorsTable,#sslTable{min-width:0;}
#serversTable thead th,#serversTable tbody td,
#monitorsTable thead th,#monitorsTable tbody td,
#sslTable thead th,#sslTable tbody td{
width:auto!important;min-width:0!important;max-width:none!important;
padding:.55rem .7rem;
}
#sslTable th:nth-child(2),#sslTable td:nth-child(2){max-width:none;white-space:normal;overflow-wrap:anywhere;}
#serversTable thead th:nth-child(13),#serversTable tbody td:nth-child(13){width:112px!important;min-width:112px!important;max-width:112px!important;padding-left:0!important;padding-right:0!important;text-align:center;}
#serversTable thead th:nth-child(8),#serversTable tbody td:nth-child(8),
#serversTable thead th:nth-child(9),#serversTable tbody td:nth-child(9){
min-width:20ch !important; /* 保留自动宽度,但不小于此值 */
text-align:center;
font-variant-numeric:tabular-nums;
}
#serversTable thead th:nth-child(2),#serversTable tbody td:nth-child(2){
width:134px!important;min-width:134px!important;max-width:134px!important;text-align:center;padding-left:.35rem!important;padding-right:.35rem!important;font-variant-numeric:tabular-nums;
}
#serversTable thead th:nth-child(1),#serversTable tbody td:nth-child(1){
width:46px!important;min-width:46px!important;max-width:46px!important;text-align:center;padding-left:0!important;padding-right:0!important;
}
#serversTable thead th:nth-child(3),#serversTable tbody td:nth-child(3){
text-align:left;padding-left:18px!important;
}
#serversTable tbody td:nth-child(13) .buckets{
min-width:112px;
gap:6px;
margin:0 auto;
justify-content:center;
}
.cards .card{border:1px solid var(--border);border-radius:12px;padding:.75rem .85rem;background:linear-gradient(145deg,var(--bg),var(--bg-alt));display:flex;flex-direction:column;gap:.45rem;position:relative;}
.cards .card.offline{filter:saturate(.92);}
.cards .card.alert-critical{border-color:rgba(239,68,68,.6);background:linear-gradient(180deg, rgba(239,68,68,.22), rgba(239,68,68,.12)), var(--bg-alt);box-shadow:0 0 0 1px rgba(239,68,68,.48),0 6px 18px -6px rgba(239,68,68,.28);}
.cards .card.alert-warning{border-color:rgba(245,158,11,.55);background:linear-gradient(180deg, rgba(245,158,11,.16), rgba(245,158,11,.08)), var(--bg-alt);box-shadow:0 0 0 1px rgba(245,158,11,.32),0 6px 18px -8px rgba(245,158,11,.22);}
table.data tbody tr.alert-critical{background:rgba(239,68,68,.18) !important;}
table.data tbody tr.alert-critical:hover{background:rgba(239,68,68,.21) !important;}
table.data tbody tr.alert-warning{background:rgba(245,158,11,.12) !important;}
table.data tbody tr.alert-warning:hover{background:rgba(245,158,11,.14) !important;}
/* SSL 域名告警底色:与高负载相同 */
#sslTable td.alert-domain{background:rgba(239,68,68,.18) !important;}
#sslTable tr:hover td.alert-domain{background:rgba(239,68,68,.26) !important;}
/* OS 着色更明显
1) 为各 OS 类定义 --os-color 变量
2) 行左侧使用 inset box-shadow 4px 彩条
3) 行背景叠加轻度渐变以提示 OS
*/
table.data tbody tr[class*="os-"]{box-shadow:inset 4px 0 0 0 var(--os-color, transparent);background:linear-gradient(180deg, color-mix(in srgb, var(--os-color, transparent) 10%, transparent), transparent 60%);}
table.data tbody tr[class*="os-"]:hover{background:linear-gradient(180deg, color-mix(in srgb, var(--os-color, transparent) 12%, transparent), transparent 65%);}
.cards .card[class*="os-"]{border-color:color-mix(in srgb, var(--os-color, var(--accent)) 60%, transparent);box-shadow:0 0 0 1px color-mix(in srgb, var(--os-color, var(--accent)) 40%, transparent),0 4px 16px -6px color-mix(in srgb, var(--os-color, #000) 35%, transparent);}
/* 弹窗 OS 着色:左侧彩条 + 渐变与卡片一致 */
/* 取消弹窗背景着色,改为仅在标题展示系统胶囊 */
.os-chip{display:inline-flex;align-items:center;padding:2px 8px;margin-left:.5rem;border-radius:999px;font-size:12px;font-weight:600;line-height:1.2;background:var(--os-color, var(--border));color:#fff;border:0;white-space:nowrap;}
.spec-chip{display:inline-flex;align-items:center;padding:2px 8px;margin-left:.4rem;border-radius:999px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;font-weight:750;line-height:1.2;background:color-mix(in srgb,var(--accent) 16%,var(--bg-alt));color:color-mix(in srgb,var(--accent) 72%,#fff);border:1px solid color-mix(in srgb,var(--accent) 38%,var(--border));white-space:nowrap;}
body.light .spec-chip{background:color-mix(in srgb,var(--accent) 10%,#fff);color:color-mix(in srgb,var(--accent) 78%,#111827)}
.cpu-model-chip{display:inline-block;max-width:min(240px,58vw);overflow:hidden;text-overflow:ellipsis;vertical-align:middle;padding:2px 8px;margin-left:.4rem;border-radius:999px;border:1px solid color-mix(in srgb,var(--text-dim) 34%,var(--border));background:color-mix(in srgb,var(--text-dim) 12%,var(--bg-alt));color:color-mix(in srgb,var(--text) 84%,var(--text-dim));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;font-weight:700;line-height:1.2;white-space:nowrap;}
body.light .cpu-model-chip{background:color-mix(in srgb,var(--text-dim) 9%,#fff);color:color-mix(in srgb,var(--text) 82%,#111827)}
/* 为常见系统赋色 */
.os-linux{--os-color: rgba(16,185,129,.85);} /* 绿色 (通用 Linux,保持不变) */
.os-ubuntu{--os-color: rgba(221,72,20,.9);} /* Ubuntu 橙 (#dd4814) */
.os-debian{--os-color: rgba(215,10,83,.9);} /* Debian 品红 (#d70a53) */
.os-centos{--os-color: rgba(102,0,153,.9);} /* CentOS 紫 (#660099) */
.os-rocky{--os-color: rgba(1,122,66,.9);} /* Rocky Linux 绿 (#017a42) */
.os-almalinux{--os-color: rgba(0,92,170,.9);} /* AlmaLinux 蓝 (#005caa) */
.os-rhel{--os-color: rgba(204,0,0,.9);} /* Red Hat 红 (#cc0000) */
.os-arch{--os-color: rgba(23,147,209,.9);} /* Arch Linux 蓝 (#1793d1) */
.os-alpine{--os-color: rgba(14,87,123,.9);} /* Alpine Linux 蓝 (#0e577b) */
.os-fedora{--os-color: rgba(60,110,180,.9);} /* Fedora 蓝 (#3c6eb4) */
.os-amazon{--os-color: rgba(255,153,0,.9);} /* Amazon Linux 橙 (#ff9900) */
.os-suse{--os-color: rgba(0,150,0,.9);} /* openSUSE 绿 (#009600) */
.os-freebsd{--os-color: rgba(166,31,47,.9);} /* FreeBSD 红 (#a61f2f) */
.os-openbsd{--os-color: rgba(255,204,0,.9);} /* OpenBSD 黄 (#ffcc00) */
.os-bsd{--os-color: rgba(166,31,47,.9);} /* BSD 系统一般跟 FreeBSD 接近 */
.os-darwin{--os-color: rgba(29,29,31,.95);} /* macOS 深空灰 (#1d1d1f) */
.os-windows{--os-color: rgba(0,120,215,.95);} /* Windows 蓝 (#0078d7) */
/* 旧进度条相关样式已清理 */
.cards .card-header{display:flex;align-items:center;justify-content:space-between;gap:.5rem;}
.cards .card-title{display:flex;align-items:center;flex-wrap:wrap;gap:.24rem .34rem;min-width:0;font-weight:600;font-size:.95rem;}
.cards .card-spec-chip{display:inline-flex;align-items:center;justify-content:center;height:16px;padding:0 6px;border-radius:999px;border:1px solid color-mix(in srgb,var(--accent) 34%,var(--border));background:color-mix(in srgb,var(--accent) 13%,var(--bg-alt));color:color-mix(in srgb,var(--accent) 74%,#fff);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10px;font-weight:800;line-height:1;letter-spacing:0;white-space:nowrap}
body.light .cards .card-spec-chip{background:color-mix(in srgb,var(--accent) 8%,#fff);color:color-mix(in srgb,var(--accent) 78%,#111827)}
.cards .tag{font-size:.65rem;padding:.15rem .4rem;border-radius:4px;background:var(--surface);color:var(--text);letter-spacing:.5px;}
.cards .status-pill{font-size:.6rem;padding:.2rem .45rem;border-radius:999px;font-weight:500;}
.cards .status-pill.on{background:var(--ok);color:#fff;}
.cards .status-pill.off{background:var(--danger);color:#fff;}
.cards .kvlist{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.35rem .75rem;font-size:.7rem;line-height:1.25;}
.cards .kvlist div{display:flex;flex-direction:column;}
.cards .kvlist span.key{opacity:.6;}
.cards .buckets{margin-top:.25rem;}
/* 证书卡片:域名告警底色(与高负载卡片风格一致) */
.cards .kvlist .alert-domain{background:rgba(239,68,68,.18);border:1px solid rgba(239,68,68,.35);border-radius:8px;padding:.4rem .5rem;}
.cards .kvlist .alert-domain .key{opacity:.85}
/* 移除移动端卡片展开箭头与展开区域(已按需简化交互) */
/* 旧移动端 latency spark 样式移除 */
/* 简易信号格,用于服务连通性延迟展示 */
.sig{display:inline-flex;gap:2px;vertical-align:baseline;margin:0 4px 0 6px;align-items:flex-end;line-height:1}
.sig .b{width:3px;background:color-mix(in srgb,var(--text-dim) 35%,transparent);border-radius:2px;display:inline-block}
.sig .b:nth-child(1){height:7px}
.sig .b:nth-child(2){height:9px}
.sig .b:nth-child(3){height:11px}
.sig .b:nth-child(4){height:13px}
.sig .b:nth-child(5){height:15px}
.sig .b.on{background:var(--ok)}
.sig .b.off{opacity:.35}
/* 服务监测项:不同组竖排,同一组横排;不考虑自动换行 */
.mon-items{display:flex;flex-direction:column;gap:6px;align-items:flex-start}
.mon-item{display:inline-flex;align-items:center;white-space:nowrap;line-height:1}
.mon-item .name{margin-right:6px}
.mon-item .ms{margin-left:6px;font-variant-numeric:tabular-nums}
.mon-item .sig{margin:0 6px;transform:translateY(-1px)}
/* 新 Logo 样式 */
.brand{display:flex;align-items:center;gap:.55rem;font-weight:600;letter-spacing:.5px;font-size:16px;position:relative}
.brand .logo-mark{display:inline-flex;width:34px;height:34px;border-radius:10px;background:linear-gradient(145deg,var(--logo-start) 0%,var(--logo-end) 90%);color:#fff;align-items:center;justify-content:center;box-shadow:0 4px 12px -2px rgba(0,0,0,.45),0 0 0 1px rgba(255,255,255,.08);transition:var(--trans)}
.brand .logo-mark svg{display:block}
.brand .logo-text{font-size:17px;font-weight:700;line-height:1;display:flex;align-items:baseline;gap:2px;letter-spacing:1px}
.brand .logo-text .logo-accent{background:linear-gradient(90deg,var(--logo-accent-grad-start),var(--logo-accent-grad-end));-webkit-background-clip:text;background-clip:text;color:transparent;filter:drop-shadow(0 2px 4px rgba(0,0,0,.3))}
.brand:hover .logo-mark{transform:translateY(-2px) scale(1.05)}
.brand:hover .logo-text{color:var(--text)}
@media (max-width:640px){.brand .logo-text{font-size:15px}.brand .logo-mark{width:30px;height:30px}}
/* 运维控制台增强 */
.ops-overview{display:grid;grid-template-columns:repeat(5,minmax(140px,1fr));gap:.75rem}
.overview-card{border:1px solid var(--border);background:linear-gradient(180deg,var(--surface-soft),var(--bg-alt));border-radius:8px;padding:.8rem .9rem;display:flex;flex-direction:column;gap:.25rem;min-height:82px}
.overview-card .label{font-size:12px;color:var(--text-dim)}
.overview-card .value{font-size:24px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums}
.overview-card .hint{font-size:12px;color:var(--text-dim);line-height:1.3}
.overview-card.ok{border-color:rgba(16,185,129,.35)}
.overview-card.warn{border-color:rgba(245,158,11,.45)}
.overview-card.err{border-color:rgba(239,68,68,.5)}
.overview-card.traffic-down{border-color:rgba(16,185,129,.38)}
.overview-card.traffic-up{border-color:rgba(59,130,246,.38)}
.ops-toolbar{display:flex;align-items:end;gap:.75rem;flex-wrap:wrap;border:1px solid var(--border);background:var(--bg-alt);border-radius:8px;padding:.75rem}
.search-field,.select-field{display:flex;flex-direction:column;gap:.3rem;color:var(--text-dim);font-size:12px}
.search-field input,.select-field select,.config-form input,.config-form textarea{border:1px solid var(--border);background:var(--bg);color:var(--text);border-radius:6px;padding:0 .65rem;outline:none;transition:var(--trans)}
.search-field input::placeholder,.config-form input::placeholder,.config-form textarea::placeholder{color:color-mix(in srgb,var(--text-dim) 78%,transparent);opacity:1}
.search-field input,.select-field select,.config-form input{height:36px}
.config-form textarea{min-height:92px;resize:vertical;padding:.55rem .65rem;line-height:1.45;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.search-field input:focus,.select-field select:focus,.config-form input:focus,.config-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 18%,transparent)}
.search-field input{min-width:280px}
.segmented{display:inline-flex;border:1px solid var(--border);border-radius:7px;overflow:hidden;background:var(--surface-soft)}
.segmented button{height:36px;border:0;border-right:1px solid var(--border);background:transparent;color:var(--text-dim);padding:0 .8rem;cursor:pointer}
.segmented button:last-child{border-right:0}
.segmented button.active,.segmented button:hover{background:var(--accent);color:#fff}
.icon-text,.primary-btn,.warn-btn,.danger-btn{height:36px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);padding:0 .75rem;cursor:pointer;transition:var(--trans);font-weight:600}
.icon-text:hover{border-color:var(--accent);color:var(--accent)}
.primary-btn{background:var(--accent);border-color:var(--accent);color:#fff}
.primary-btn:hover{filter:brightness(1.08)}
.warn-btn{background:rgba(245,158,11,.14);border-color:rgba(245,158,11,.42);color:var(--warn)}
.warn-btn:hover{background:var(--warn);border-color:var(--warn);color:#111827}
.danger-btn{background:rgba(239,68,68,.14);border-color:rgba(239,68,68,.38);color:var(--danger)}
.danger-btn:hover{background:var(--danger);color:#fff}
th[data-sort]{cursor:pointer;user-select:none}
th[data-sort]:after{content:"";display:inline-block;margin-left:6px;border:4px solid transparent;border-top-color:color-mix(in srgb,var(--text-dim) 70%,transparent);transform:translateY(2px);opacity:.45}
th[data-sort].sorted-asc:after{border-top-color:transparent;border-bottom-color:var(--accent);transform:translateY(-2px)}
th[data-sort].sorted-desc:after{border-top-color:var(--accent);opacity:1}
.section-head h2{margin:0;font-size:18px}
.section-head p{margin:.25rem 0 0}
.empty-state{border:1px dashed var(--border);border-radius:8px;background:var(--bg-alt);padding:1.25rem;color:var(--text-dim);text-align:center}
/* 右侧详情抽屉 */
.drawer-backdrop{align-items:stretch;justify-content:flex-end;padding:0;background:rgba(0,0,0,.48)}
.detail-drawer{height:100dvh;max-height:100dvh;width:min(620px,100vw);max-width:min(620px,100vw);border-radius:0;border-top:0;border-right:0;border-bottom:0;padding:1.1rem;overflow:auto;animation:slideIn .24s ease}
.detail-drawer.alert-critical{border-color:rgba(239,68,68,.6);background:linear-gradient(180deg, rgba(239,68,68,.16), rgba(239,68,68,.08)), var(--bg-alt);box-shadow:0 0 0 1px rgba(239,68,68,.38),0 10px 28px -10px rgba(239,68,68,.28)}
.detail-drawer.alert-warning{border-color:rgba(245,158,11,.55);background:linear-gradient(180deg, rgba(245,158,11,.14), rgba(245,158,11,.06)), var(--bg-alt);box-shadow:0 0 0 1px rgba(245,158,11,.28),0 10px 28px -10px rgba(245,158,11,.2)}
.modal-title{padding-right:2.2rem;display:flex;align-items:center;flex-wrap:wrap;gap:.32rem .4rem}
.modal-title .os-chip,.modal-title .spec-chip,.modal-title .cpu-model-chip{margin-left:0}
.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.55rem}
.detail-section{border:1px solid var(--border);border-radius:8px;background:linear-gradient(145deg,var(--bg),var(--bg-alt));padding:.75rem;display:flex;flex-direction:column;gap:.65rem}
.detail-section h4{margin:0;font-size:13px;color:var(--text-dim);font-weight:700;letter-spacing:.4px}
.detail-section .kv{display:grid;grid-template-columns:max-content minmax(0,1fr);align-items:flex-start;gap:.6rem;min-width:0}
.detail-section .kv span{min-width:0;white-space:normal}
.detail-section .kv>span:last-child{text-align:right;overflow-wrap:anywhere;word-break:break-word}
.detail-section .mono{display:block;white-space:normal;overflow-wrap:anywhere;word-break:break-word;line-height:1.45}
.detail-inline{display:inline-flex;align-items:center;justify-content:flex-end;gap:.45rem;white-space:nowrap!important}
.detail-inline .mono{display:inline!important;white-space:nowrap!important;line-height:1}
.detail-values{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:.15rem .55rem}
.detail-values span{white-space:nowrap!important}
.resource-section{gap:.5rem}
.resource-meter{display:flex;flex-direction:column;gap:.26rem;min-width:0}
.resource-meter-head,.resource-mini{display:flex;align-items:center;justify-content:space-between;gap:.55rem;font-size:12px;min-width:0}
.resource-meter-head span,.resource-mini span{color:var(--text-dim);white-space:nowrap}
.resource-meter-head strong,.resource-mini strong{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;font-weight:700;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.resource-track{height:7px;border-radius:999px;background:color-mix(in srgb,var(--text-dim) 16%,transparent);overflow:hidden;border:1px solid color-mix(in srgb,var(--border) 75%,transparent)}
.resource-track i{display:block;height:100%;width:var(--p);border-radius:inherit;background:var(--accent);transition:width .5s ease}
.resource-meter[data-kind=mem] .resource-track i{background:var(--ok)}
.resource-meter[data-kind=swap] .resource-track i{background:#14b8a6}
.resource-meter[data-kind=hdd] .resource-track i{background:#06b6d4}
.resource-meter[data-level=warn] .resource-track i{background:var(--warn)}
.resource-meter[data-level=bad] .resource-track i{background:var(--danger)}
.resource-mini{border-top:1px solid var(--border);padding-top:.45rem}
.chart-section{gap:.55rem}
.chart-head{display:flex;align-items:center;justify-content:space-between;gap:.75rem;flex-wrap:wrap}
.chart-head h4{margin:0}
.chart-legend{display:inline-flex;align-items:center;justify-content:flex-end;gap:.7rem;flex-wrap:wrap;font-size:11px;color:var(--text-dim)}
.chart-legend span{display:inline-flex;align-items:center;gap:.28rem;white-space:nowrap}
.chart-legend i{display:inline-block;width:10px;height:3px;border-radius:999px;box-shadow:0 0 0 1px rgba(0,0,0,.16)}
.detail-chart{width:100%;border:1px solid var(--border);border-radius:8px;background:var(--bg)}
@keyframes slideIn{from{transform:translateX(22px);opacity:.7}to{transform:translateX(0);opacity:1}}
/* 管理配置 */
.config-grid{display:grid;grid-template-columns:minmax(320px,.95fr) minmax(320px,1.05fr);gap:.9rem;align-items:start}
.admin-card{border:1px solid var(--border);border-radius:8px;background:var(--bg-alt);padding:1rem;display:flex;flex-direction:column;gap:.85rem}
.section-head{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}
.section-actions{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;justify-content:flex-end}
.admin-status{border:1px solid var(--border);border-radius:8px;background:var(--bg);padding:.75rem;font-size:13px;color:var(--text-dim)}
.admin-status.ok{border-color:rgba(16,185,129,.35);color:var(--ok)}
.admin-status.err{border-color:rgba(239,68,68,.45);color:var(--danger)}
.admin-login,.config-actions,.form-actions{display:flex;align-items:end;gap:.65rem;flex-wrap:wrap}
.config-tabs{align-self:flex-start;max-width:100%}
.config-list{display:flex;flex-direction:column;gap:.5rem}
.config-row{display:grid;grid-template-columns:1fr auto;gap:.75rem;align-items:center;border:1px solid var(--border);border-radius:8px;background:var(--bg);padding:.7rem;cursor:pointer}
.config-row:hover,.config-row.active{border-color:var(--accent)}
.config-row .name{font-weight:700}
.config-row .meta{font-size:12px;color:var(--text-dim);margin-top:.2rem;word-break:break-all}
.config-form{display:flex;flex-direction:column;gap:.75rem}
.config-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}
.config-form label{display:flex;flex-direction:column;gap:.3rem;color:var(--text-dim);font-size:12px}
.config-form label.wide{grid-column:1/-1}
.config-form .check-row{flex-direction:row;align-items:center;gap:.5rem}
.config-form .check-row input{height:auto}
.config-form .form-actions{grid-column:1/-1}
.config-form input[name=password],.config-form textarea[name=rule]{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.config-form button:disabled{opacity:.5;cursor:not-allowed}
@media (max-width:980px){
.ops-overview{grid-template-columns:repeat(2,minmax(0,1fr))}
.config-grid{grid-template-columns:1fr}
.search-field input{min-width:220px}
}
@media (max-width:700px){
.ops-overview{display:none}
}
@media (max-width:640px){
.ops-toolbar{align-items:stretch}
.search-field,.select-field,.search-field input,.select-field select,.segmented,.icon-text,.primary-btn,.warn-btn,.danger-btn{width:100%}
.segmented button{flex:1;padding:0 .35rem}
.section-head{flex-direction:column}
.section-actions{width:100%;justify-content:stretch}
.detail-grid,.config-fields{grid-template-columns:1fr}
.detail-drawer{width:100vw;height:100dvh;max-height:100dvh;padding:calc(.95rem + env(safe-area-inset-top)) .95rem calc(.95rem + env(safe-area-inset-bottom));}
.detail-drawer .modal-content{max-height:none;overflow:visible;}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-6
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-70
View File
@@ -1,70 +0,0 @@
body { background: #212e36 url('../img/dark.png'); color: #dcdcdc; }
.navbar { min-height: 40px; }
.navbar-brand { color: #FFF !important; padding: 10px; font-size: 20px; }
.dropdown .dropdown-toggle { padding-bottom: 10px; padding-top: 10px; }
.dropdown-menu > li > a { color: #FFF !important; background-color: #222222 !important; }
.dropdown-menu > li > a:hover { color: #FFF !important; background: #000 !important; }
.dropdown-menu { background: #222 !important; background-color: #222222 !important; }
.navbar-inverse .navbar-inner { background-color:#1B1B1B; background-image:-moz-linear-gradient(top, #222222, #111111); background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#222222), to(#111111)); background-image:-webkit-linear-gradient(top, #222222, #111111); background-image:-o-linear-gradient(top, #222222, #111111); background-image:linear-gradient(to bottom, #212e36, #212e36); background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222', endColorstr='#ff111111', GradientType=0); border-color: #252525; }
.content { background: #212e36; padding: 20px; border-radius: 5px; border: 1px #212e36 solid; -webkit-box-shadow: 0 0px 0px rgba(0, 0, 0, 0); -moz-box-shadow: 0 0px 0px rgba(0, 0, 0, 0); box-shadow: 0 0px 0px rgba(0, 0, 0, 0); margin-bottom: 20px; }
.table { background: #363b40; margin-bottom: 0; border-collapse: collapse; border-radius: 3px; }
.table th { text-align: center; }
.table-striped tbody > tr.even > td, .table-striped tbody > tr.even > th { background-color: #212e36; }
.table-striped tbody > tr.odd > td, .table-striped tbody > tr.odd > th { background-color: #212e36; }
.table td { text-align: center; border-color: #2F2F2F; }
.progress { margin-bottom: 0; background: #363b40; }
.table-hover > tbody > tr:hover > td { background: #414141; }
tr.even.expandRow > :hover { background: #212e36 !important; }
tr.odd.expandRow > :hover { background: #212e36 !important; }
.expandRow > td { padding: 0 !important; border-top: 0px !important; }
#month_traffic { min-width: 85px; max-width: 95px;}
#network { min-width: 110px; }
#cpu, #ram, #hdd { min-width: 45px; max-width: 90px; }
#ping { max-width: 110px; }
@media only screen and (max-width: 1200px) {
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
}
@media only screen and (max-width: 720px) {
body { font-size: 10px; }
.content { padding: 0; }
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
}
@media only screen and (max-width: 620px) {
body { font-size: 10px; }
.content { padding: 0; }
#month_traffic, tr td:nth-child(2) { display:none; visibility:hidden; }
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
#traffic, tr td:nth-child(9) { display:none; visibility:hidden; }
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
}
@media only screen and (max-width: 533px) {
body { font-size: 10px; }
.content { padding: 0; }
#month_traffic, tr td:nth-child(2) { display:none; visibility:hidden; }
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
#traffic, tr td:nth-child(9) { display:none; visibility:hidden; }
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
}
@media only screen and (max-width: 450px) {
body { font-size: 10px; }
.content { padding: 0; }
#month_traffic, tr td:nth-child(2) { display:none; visibility:hidden; }
#name, tr td:nth-child(3) { min-width: 55px; max-width: 85px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; }
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
#traffic, tr td:nth-child(9) { display:none; visibility:hidden; }
#cpu, #ram, #hdd { min-width: 25px; max-width: 50px; }
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
}
-68
View File
@@ -1,68 +0,0 @@
body { background: #ebebeb url('../img/light.png'); }
.navbar { min-height: 40px; }
.navbar-brand { color: #fff; padding: 10px; font-size: 20px; }
.dropdown .dropdown-toggle { padding-bottom: 10px; padding-top: 10px; }
.navbar-inverse .navbar-brand { color: #fff; padding: 10px; font-size: 20px; }
.container { width: 1280px; }
.content { background: #ffffff; padding: 20px; border-radius: 5px; border: 1px #cecece solid; -webkit-box-shadow: 0 1px 10px rgba(0, 0, 0, .1); -moz-box-shadow: 0 1px 10px rgba(0, 0, 0, .1); box-shadow: 0 1px 10px rgba(0, 0, 0, .1); margin-bottom: 20px; }
.table { background: #ffffff; margin-bottom: 0; border-collapse: collapse; border-radius: 3px; }
.table th, .table td { text-align: center; }
.table-striped tbody > tr.even > td, .table-striped tbody > tr.even > th { background-color: #F9F9F9; }
.table-striped tbody > tr.odd > td, .table-striped tbody > tr.odd > th { background-color: #FFF; }
.progress { margin-bottom: 0; }
.progress-bar { color: #000; }
.table-hover > tbody > tr:hover > td { background: #E6E6E6; }
tr.even.expandRow > :hover { background: #F9F9F9 !important; }
tr.odd.expandRow > :hover { background: #FFF !important; }
.expandRow > td { padding: 0 !important; border-top: 0px !important; }
#month_traffic { min-width: 85px; max-width: 95px;}
#network { min-width: 110px; }
#cpu, #ram, #hdd { min-width: 45px; max-width: 90px; }
#ping { max-width: 110px; }
@media only screen and (max-width: 1200px) {
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
}
@media only screen and (max-width: 720px) {
body { font-size: 10px; }
.content { padding: 0; }
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
}
@media only screen and (max-width: 620px) {
body { font-size: 10px; }
.content { padding: 0; }
#month_traffic, tr td:nth-child(2) { display:none; visibility:hidden; }
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
#traffic, tr td:nth-child(9) { display:none; visibility:hidden; }
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
}
@media only screen and (max-width: 533px) {
body { font-size: 10px; }
.content { padding: 0; }
#month_traffic, tr td:nth-child(2) { display:none; visibility:hidden; }
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
#traffic, tr td:nth-child(9) { display:none; visibility:hidden; }
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
}
@media only screen and (max-width: 450px) {
body { font-size: 10px; }
.content { padding: 0; }
#month_traffic, tr td:nth-child(2) { display:none; visibility:hidden; }
#name, tr td:nth-child(3) { min-width: 55px; max-width: 85px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; }
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
#traffic, tr td:nth-child(9) { display:none; visibility:hidden; }
#cpu, #ram, #hdd { min-width: 25px; max-width: 50px; }
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 86" fill="none">
<path d="M4 46H28L38 34L55 82L65 4L74 47H88M100 47H110M122 47H126"
stroke="#111827"
stroke-width="8"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 266 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

+201 -106
View File
@@ -1,110 +1,205 @@
<!DOCTYPE html>
<!--
json字段保持完整, 自行自定义前端展示
ლ(•̀ _ •́ ლ)
ლ(•̀ _ •́ ლ)ლ(•̀ _ •́ ლ)
ლ(•̀ _ •́ ლ)ლ(•̀ _ •́ ლ)ლ(•̀ _ •́ ლ)
ლ(•̀ _ •́ ლ)ლ(•̀ _ •́ ლ)
ლ(•̀ _ •́ ლ)
follow me, better solution for you. by:https://cpp.la
-->
<html>
<head>
<title>云监控</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="云监控">
<meta name="author" content="BotoX">
<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/bootstrap-theme.min.css">
<link rel="stylesheet" href="css/light.css" title="light">
<link rel="stylesheet" href="css/dark.css" title="dark">
<style>
body {
padding-top: 70px;
padding-bottom: 30px;
}
</style>
<!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!--[if lt IE 9]>
<script src="js/html5shiv.js"></script>
<script src="js/respond.min.js"></script>
<![endif]-->
</head>
<body>
<div role="navigation" class="navbar navbar-inverse navbar-fixed-top">
<div class="navbar-inner">
<div class="container">
<div class="navbar-header">
<button data-target=".navbar-collapse" data-toggle="collapse" class="navbar-toggle" type="button">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a href="#" class="navbar-brand">云监控</a>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li class="dropdown">
<a data-toggle="dropdown" class="dropdown-toggle" href="#">风格<b class="caret"></b></a>
<ul class="dropdown-menu">
<li><a href="#" onclick="setActiveStyleSheet('dark', true)">黑夜</a></li>
<li><a href="#" onclick="setActiveStyleSheet('light', true)">白天</a></li>
</ul>
</li>
</ul>
</div><!--/.nav-collapse -->
</div>
</div>
</div>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<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=20260710-1" />
</head>
<body>
<header class="topbar">
<div class="brand" title="云监控">
<span class="logo-mark" aria-hidden="true">
<svg viewBox="0 0 128 86" width="24" height="18" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 46H28L38 34L55 82L65 4L74 47H88M100 47H110M122 47H126" />
</svg>
</span>
<span class="logo-text"><span class="logo-accent"></span>监控</span>
</div>
<nav class="nav" id="navTabs">
<button data-tab="servers" class="active">主机</button>
<button data-tab="monitors">服务</button>
<button data-tab="ssl">证书</button>
<button data-tab="config">配置</button>
</nav>
<div class="actions">
<button id="themeToggle" title="切换主题 (当前: 自动或手动)" aria-label="切换主题">🌓</button>
<span id="lastUpdate" class="muted">更新中...</span>
</div>
</header>
<div class="container content">
<div id="loading-notice">
<noscript>
<div class="alert alert-danger" style="text-align: center;">
<strong>Enable JavaScript</strong> , please do it.
</div>
</noscript>
<div style="text-align: center;">
警告:如果出现此消息,请确保您已启用Javascript! <br />否则云监控主服务没启动或已关闭.
</div>
<p></p>
</div>
<table class="table table-striped table-condensed table-hover">
<thead>
<tr>
<th id="online_status" style="text-align: center;">🔗协议</th>
<th id="month_traffic" style="text-align: center;">📊月流量↓|↑</th>
<th id="name">📌节点</th>
<th id="type">🗂️虚拟化</th>
<th id="location">🌍位置</th>
<th id="uptime">⏱️在线</th>
<th id="load">负载</th>
<th id="network">🚦网络↓|↑</th>
<th id="traffic">📋总流量↓|↑</th>
<th id="cpu">🎯核芯</th>
<th id="ram">⚡️内存</th>
<th id="hdd">💾硬盘</th>
<th id="ping">🌐CU|CT|CM</th>
</tr>
</thead>
<tbody id="servers">
<!-- Servers here \o/ -->
</tbody>
</table>
<br />
<div id="updated">Updating...</div>
</div>
<main class="wrapper">
<div id="notice" class="notice info">初始化中...</div>
<div class="container">
<p style="text-align: center; font-size: 10px;">
<a href="https://github.com/cppla/ServerStatus">ServerStatus中文版</a>
</p>
</div>
<script src="js/jquery.min.js"></script>
<script src="js/bootstrap.min.js"></script>
<script src="js/serverstatus.js"></script>
</body>
<section class="ops-overview" id="overviewCards" aria-label="运行概览"></section>
<section class="ops-toolbar" id="serversToolbar" aria-label="主机筛选" style="display:none;">
<label class="search-field">
<span>搜索</span>
<input id="serverSearch" type="search" autocomplete="off" placeholder="节点 / 地区 / 系统 / 主机名" />
</label>
<div class="segmented" id="statusFilter" role="group" aria-label="状态筛选">
<button data-filter="all" class="active">全部</button>
<button data-filter="online">在线</button>
<button data-filter="offline">离线</button>
<button data-filter="alert" title="只显示在线但资源、丢包或流量异常的节点">异常</button>
</div>
<label class="select-field">
<span>系统</span>
<select id="osFilter">
<option value="all">全部系统</option>
</select>
</label>
<label class="select-field">
<span>排序</span>
<select id="sortSelect">
<option value="name">节点</option>
<option value="status">在线状态</option>
<option value="load">负载</option>
<option value="cpu">CPU</option>
<option value="memory">内存</option>
<option value="hdd">硬盘</option>
<option value="traffic">月流量</option>
<option value="loss">丢包</option>
</select>
</label>
<button id="sortDirection" class="icon-text" title="切换排序方向">降序</button>
</section>
<section id="panel-servers" class="panel active" aria-labelledby="主机">
<div class="table-wrap">
<table class="data" id="serversTable">
<thead>
<tr>
<th data-sort="status">协议</th>
<th data-sort="traffic">月流量 ↓|↑</th>
<th data-sort="name">节点</th>
<th data-sort="type">虚拟化</th>
<th data-sort="location">位置</th>
<th>在线</th>
<th data-sort="load">负载</th>
<th>当前网络 ↓|↑</th>
<th>总流量 ↓|↑</th>
<th data-sort="cpu">CPU</th>
<th data-sort="memory">内存</th>
<th data-sort="hdd">硬盘</th>
<th data-sort="loss" style="text-align:center;">联通|电信|移动</th>
</tr>
</thead>
<tbody id="serversBody"></tbody>
</table>
</div>
<!-- 移动端卡片布局 -->
<div id="serversCards" class="cards" style="display:none;"></div>
</section>
<section id="panel-monitors" class="panel" aria-labelledby="服务">
<div class="table-wrap">
<table class="data" id="monitorsTable">
<thead>
<tr>
<th>协议</th>
<th>监测节点</th>
<th>监测位置</th>
<th>监测内容</th>
</tr>
</thead>
<tbody id="monitorsBody"></tbody>
</table>
</div>
<!-- 移动端卡片布局 (服务) -->
<div id="monitorsCards" class="cards" style="display:none;"></div>
</section>
<section id="panel-ssl" class="panel" aria-labelledby="证书">
<div class="table-wrap">
<table class="data" id="sslTable">
<thead>
<tr>
<th>名称</th>
<th>域名</th>
<th>端口</th>
<th>剩余(天)</th>
<th>到期(UTC)</th>
<th>状态</th>
</tr>
</thead>
<tbody id="sslBody"></tbody>
</table>
</div>
<!-- 移动端卡片布局 (证书) -->
<div id="sslCards" class="cards" style="display:none;"></div>
</section>
<section id="panel-config" class="panel" aria-labelledby="配置">
<div class="config-grid">
<section class="admin-card">
<div class="section-head">
<div>
<h2>配置管理</h2>
<p class="muted">可编辑节点、服务监测、证书与告警规则,保存后自动重载。</p>
</div>
<div class="section-actions">
<button id="adminReload" class="icon-text">重载配置</button>
<button id="adminRestart" class="danger-btn">重启服务</button>
</div>
</div>
<div id="adminStatus" class="admin-status">正在检测管理 API...</div>
<form id="adminTokenForm" class="admin-login">
<label class="search-field">
<span>管理令牌</span>
<input id="adminToken" type="password" autocomplete="current-password" placeholder="ADMIN_TOKEN" />
</label>
<button type="submit" class="primary-btn">连接</button>
</form>
<div class="segmented config-tabs" id="configTypeTabs" role="group" aria-label="配置类型">
<button type="button" data-type="servers" class="active">节点</button>
<button type="button" data-type="monitors">监测</button>
<button type="button" data-type="sslcerts">证书</button>
<button type="button" data-type="watchdog">告警</button>
</div>
<div class="config-actions">
<button id="addConfigItemBtn" class="primary-btn">新增节点</button>
<button id="refreshConfigBtn" class="icon-text">刷新配置</button>
</div>
<div id="configItemList" class="config-list"></div>
</section>
<section class="admin-card">
<div class="section-head">
<div>
<h2 id="configEditorTitle">新增节点</h2>
<p class="muted" id="configEditorHint">保存后会写入 config.json,并由 Go 服务热重载。</p>
</div>
</div>
<form id="configForm" class="config-form">
<div id="configFields" class="config-fields"></div>
<div class="form-actions">
<button type="submit" class="primary-btn">保存配置</button>
<button type="button" id="resetTrafficBtn" class="warn-btn" style="display:none;">重置月流量</button>
<button type="button" id="deleteConfigItemBtn" class="danger-btn">删除配置</button>
<button type="button" id="resetConfigFormBtn" class="icon-text">清空</button>
</div>
</form>
</section>
</div>
</section>
</main>
<!-- 详情抽屉 -->
<div id="detailModal" class="modal-backdrop drawer-backdrop" style="display:none;">
<aside class="modal-box detail-drawer" role="dialog" aria-modal="true" aria-labelledby="detailTitle">
<button class="modal-close" id="detailClose" aria-label="关闭">×</button>
<h3 id="detailTitle" class="modal-title">节点详情</h3>
<div id="detailContent" class="modal-content">加载中...</div>
</aside>
</div>
<footer class="footer">
<a href="https://github.com/cppla/ServerStatus" target="_blank" rel="noopener">ServerStatus中文版</a>
</footer>
<script src="js/app.js?v=20260710-1" defer></script>
</body>
</html>
+1097
View File
File diff suppressed because it is too large Load Diff
-7
View File
File diff suppressed because one or more lines are too long
-326
View File
@@ -1,326 +0,0 @@
/**
* @preserve HTML5 Shiv 3.7.3 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed
*/
;(function(window, document) {
/*jshint evil:true */
/** version */
var version = '3.7.3';
/** Preset options */
var options = window.html5 || {};
/** Used to skip problem elements */
var reSkip = /^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i;
/** Not all elements can be cloned in IE **/
var saveClones = /^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i;
/** Detect whether the browser supports default html5 styles */
var supportsHtml5Styles;
/** Name of the expando, to work with multiple documents or to re-shiv one document */
var expando = '_html5shiv';
/** The id for the the documents expando */
var expanID = 0;
/** Cached data for each document */
var expandoData = {};
/** Detect whether the browser supports unknown elements */
var supportsUnknownElements;
(function() {
try {
var a = document.createElement('a');
a.innerHTML = '<xyz></xyz>';
//if the hidden property is implemented we can assume, that the browser supports basic HTML5 Styles
supportsHtml5Styles = ('hidden' in a);
supportsUnknownElements = a.childNodes.length == 1 || (function() {
// assign a false positive if unable to shiv
(document.createElement)('a');
var frag = document.createDocumentFragment();
return (
typeof frag.cloneNode == 'undefined' ||
typeof frag.createDocumentFragment == 'undefined' ||
typeof frag.createElement == 'undefined'
);
}());
} catch(e) {
// assign a false positive if detection fails => unable to shiv
supportsHtml5Styles = true;
supportsUnknownElements = true;
}
}());
/*--------------------------------------------------------------------------*/
/**
* Creates a style sheet with the given CSS text and adds it to the document.
* @private
* @param {Document} ownerDocument The document.
* @param {String} cssText The CSS text.
* @returns {StyleSheet} The style element.
*/
function addStyleSheet(ownerDocument, cssText) {
var p = ownerDocument.createElement('p'),
parent = ownerDocument.getElementsByTagName('head')[0] || ownerDocument.documentElement;
p.innerHTML = 'x<style>' + cssText + '</style>';
return parent.insertBefore(p.lastChild, parent.firstChild);
}
/**
* Returns the value of `html5.elements` as an array.
* @private
* @returns {Array} An array of shived element node names.
*/
function getElements() {
var elements = html5.elements;
return typeof elements == 'string' ? elements.split(' ') : elements;
}
/**
* Extends the built-in list of html5 elements
* @memberOf html5
* @param {String|Array} newElements whitespace separated list or array of new element names to shiv
* @param {Document} ownerDocument The context document.
*/
function addElements(newElements, ownerDocument) {
var elements = html5.elements;
if(typeof elements != 'string'){
elements = elements.join(' ');
}
if(typeof newElements != 'string'){
newElements = newElements.join(' ');
}
html5.elements = elements +' '+ newElements;
shivDocument(ownerDocument);
}
/**
* Returns the data associated to the given document
* @private
* @param {Document} ownerDocument The document.
* @returns {Object} An object of data.
*/
function getExpandoData(ownerDocument) {
var data = expandoData[ownerDocument[expando]];
if (!data) {
data = {};
expanID++;
ownerDocument[expando] = expanID;
expandoData[expanID] = data;
}
return data;
}
/**
* returns a shived element for the given nodeName and document
* @memberOf html5
* @param {String} nodeName name of the element
* @param {Document|DocumentFragment} ownerDocument The context document.
* @returns {Object} The shived element.
*/
function createElement(nodeName, ownerDocument, data){
if (!ownerDocument) {
ownerDocument = document;
}
if(supportsUnknownElements){
return ownerDocument.createElement(nodeName);
}
if (!data) {
data = getExpandoData(ownerDocument);
}
var node;
if (data.cache[nodeName]) {
node = data.cache[nodeName].cloneNode();
} else if (saveClones.test(nodeName)) {
node = (data.cache[nodeName] = data.createElem(nodeName)).cloneNode();
} else {
node = data.createElem(nodeName);
}
// Avoid adding some elements to fragments in IE < 9 because
// * Attributes like `name` or `type` cannot be set/changed once an element
// is inserted into a document/fragment
// * Link elements with `src` attributes that are inaccessible, as with
// a 403 response, will cause the tab/window to crash
// * Script elements appended to fragments will execute when their `src`
// or `text` property is set
return node.canHaveChildren && !reSkip.test(nodeName) && !node.tagUrn ? data.frag.appendChild(node) : node;
}
/**
* returns a shived DocumentFragment for the given document
* @memberOf html5
* @param {Document} ownerDocument The context document.
* @returns {Object} The shived DocumentFragment.
*/
function createDocumentFragment(ownerDocument, data){
if (!ownerDocument) {
ownerDocument = document;
}
if(supportsUnknownElements){
return ownerDocument.createDocumentFragment();
}
data = data || getExpandoData(ownerDocument);
var clone = data.frag.cloneNode(),
i = 0,
elems = getElements(),
l = elems.length;
for(;i<l;i++){
clone.createElement(elems[i]);
}
return clone;
}
/**
* Shivs the `createElement` and `createDocumentFragment` methods of the document.
* @private
* @param {Document|DocumentFragment} ownerDocument The document.
* @param {Object} data of the document.
*/
function shivMethods(ownerDocument, data) {
if (!data.cache) {
data.cache = {};
data.createElem = ownerDocument.createElement;
data.createFrag = ownerDocument.createDocumentFragment;
data.frag = data.createFrag();
}
ownerDocument.createElement = function(nodeName) {
//abort shiv
if (!html5.shivMethods) {
return data.createElem(nodeName);
}
return createElement(nodeName, ownerDocument, data);
};
ownerDocument.createDocumentFragment = Function('h,f', 'return function(){' +
'var n=f.cloneNode(),c=n.createElement;' +
'h.shivMethods&&(' +
// unroll the `createElement` calls
getElements().join().replace(/[\w\-:]+/g, function(nodeName) {
data.createElem(nodeName);
data.frag.createElement(nodeName);
return 'c("' + nodeName + '")';
}) +
');return n}'
)(html5, data.frag);
}
/*--------------------------------------------------------------------------*/
/**
* Shivs the given document.
* @memberOf html5
* @param {Document} ownerDocument The document to shiv.
* @returns {Document} The shived document.
*/
function shivDocument(ownerDocument) {
if (!ownerDocument) {
ownerDocument = document;
}
var data = getExpandoData(ownerDocument);
if (html5.shivCSS && !supportsHtml5Styles && !data.hasCSS) {
data.hasCSS = !!addStyleSheet(ownerDocument,
// corrects block display not defined in IE6/7/8/9
'article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}' +
// adds styling not present in IE6/7/8/9
'mark{background:#FF0;color:#000}' +
// hides non-rendered elements
'template{display:none}'
);
}
if (!supportsUnknownElements) {
shivMethods(ownerDocument, data);
}
return ownerDocument;
}
/*--------------------------------------------------------------------------*/
/**
* The `html5` object is exposed so that more elements can be shived and
* existing shiving can be detected on iframes.
* @type Object
* @example
*
* // options can be changed before the script is included
* html5 = { 'elements': 'mark section', 'shivCSS': false, 'shivMethods': false };
*/
var html5 = {
/**
* An array or space separated string of node names of the elements to shiv.
* @memberOf html5
* @type Array|String
*/
'elements': options.elements || 'abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video',
/**
* current version of html5shiv
*/
'version': version,
/**
* A flag to indicate that the HTML5 style sheet should be inserted.
* @memberOf html5
* @type Boolean
*/
'shivCSS': (options.shivCSS !== false),
/**
* Is equal to true if a browser supports creating unknown/HTML5 elements
* @memberOf html5
* @type boolean
*/
'supportsUnknownElements': supportsUnknownElements,
/**
* A flag to indicate that the document's `createElement` and `createDocumentFragment`
* methods should be overwritten.
* @memberOf html5
* @type Boolean
*/
'shivMethods': (options.shivMethods !== false),
/**
* A string to describe the type of `html5` object ("default" or "default print").
* @memberOf html5
* @type String
*/
'type': 'default',
// shivs the document according to the specified `html5` object options
'shivDocument': shivDocument,
//creates a shived element
createElement: createElement,
//creates a shived documentFragment
createDocumentFragment: createDocumentFragment,
//extends list of elements
addElements: addElements
};
/*--------------------------------------------------------------------------*/
// expose html5
window.html5 = html5;
// shiv the document
shivDocument(document);
if(typeof module == 'object' && module.exports){
module.exports = html5;
}
}(typeof window !== "undefined" ? window : this, document));
-2
View File
File diff suppressed because one or more lines are too long
-5
View File
@@ -1,5 +0,0 @@
/*! Respond.js v1.4.2: min/max-width media query polyfill * Copyright 2013 Scott Jehl
* Licensed under https://github.com/scottjehl/Respond/blob/master/LICENSE-MIT
* */
!function(a){"use strict";a.matchMedia=a.matchMedia||function(a){var b,c=a.documentElement,d=c.firstElementChild||c.firstChild,e=a.createElement("body"),f=a.createElement("div");return f.id="mq-test-1",f.style.cssText="position:absolute;top:-100em",e.style.background="none",e.appendChild(f),function(a){return f.innerHTML='&shy;<style media="'+a+'"> #mq-test-1 { width: 42px; }</style>',c.insertBefore(e,d),b=42===f.offsetWidth,c.removeChild(e),{matches:b,media:a}}}(a.document)}(this),function(a){"use strict";function b(){u(!0)}var c={};a.respond=c,c.update=function(){};var d=[],e=function(){var b=!1;try{b=new a.XMLHttpRequest}catch(c){b=new a.ActiveXObject("Microsoft.XMLHTTP")}return function(){return b}}(),f=function(a,b){var c=e();c&&(c.open("GET",a,!0),c.onreadystatechange=function(){4!==c.readyState||200!==c.status&&304!==c.status||b(c.responseText)},4!==c.readyState&&c.send(null))};if(c.ajax=f,c.queue=d,c.regex={media:/@media[^\{]+\{([^\{\}]*\{[^\}\{]*\})+/gi,keyframes:/@(?:\-(?:o|moz|webkit)\-)?keyframes[^\{]+\{(?:[^\{\}]*\{[^\}\{]*\})+[^\}]*\}/gi,urls:/(url\()['"]?([^\/\)'"][^:\)'"]+)['"]?(\))/g,findStyles:/@media *([^\{]+)\{([\S\s]+?)$/,only:/(only\s+)?([a-zA-Z]+)\s?/,minw:/\([\s]*min\-width\s*:[\s]*([\s]*[0-9\.]+)(px|em)[\s]*\)/,maxw:/\([\s]*max\-width\s*:[\s]*([\s]*[0-9\.]+)(px|em)[\s]*\)/},c.mediaQueriesSupported=a.matchMedia&&null!==a.matchMedia("only all")&&a.matchMedia("only all").matches,!c.mediaQueriesSupported){var g,h,i,j=a.document,k=j.documentElement,l=[],m=[],n=[],o={},p=30,q=j.getElementsByTagName("head")[0]||k,r=j.getElementsByTagName("base")[0],s=q.getElementsByTagName("link"),t=function(){var a,b=j.createElement("div"),c=j.body,d=k.style.fontSize,e=c&&c.style.fontSize,f=!1;return b.style.cssText="position:absolute;font-size:1em;width:1em",c||(c=f=j.createElement("body"),c.style.background="none"),k.style.fontSize="100%",c.style.fontSize="100%",c.appendChild(b),f&&k.insertBefore(c,k.firstChild),a=b.offsetWidth,f?k.removeChild(c):c.removeChild(b),k.style.fontSize=d,e&&(c.style.fontSize=e),a=i=parseFloat(a)},u=function(b){var c="clientWidth",d=k[c],e="CSS1Compat"===j.compatMode&&d||j.body[c]||d,f={},o=s[s.length-1],r=(new Date).getTime();if(b&&g&&p>r-g)return a.clearTimeout(h),h=a.setTimeout(u,p),void 0;g=r;for(var v in l)if(l.hasOwnProperty(v)){var w=l[v],x=w.minw,y=w.maxw,z=null===x,A=null===y,B="em";x&&(x=parseFloat(x)*(x.indexOf(B)>-1?i||t():1)),y&&(y=parseFloat(y)*(y.indexOf(B)>-1?i||t():1)),w.hasquery&&(z&&A||!(z||e>=x)||!(A||y>=e))||(f[w.media]||(f[w.media]=[]),f[w.media].push(m[w.rules]))}for(var C in n)n.hasOwnProperty(C)&&n[C]&&n[C].parentNode===q&&q.removeChild(n[C]);n.length=0;for(var D in f)if(f.hasOwnProperty(D)){var E=j.createElement("style"),F=f[D].join("\n");E.type="text/css",E.media=D,q.insertBefore(E,o.nextSibling),E.styleSheet?E.styleSheet.cssText=F:E.appendChild(j.createTextNode(F)),n.push(E)}},v=function(a,b,d){var e=a.replace(c.regex.keyframes,"").match(c.regex.media),f=e&&e.length||0;b=b.substring(0,b.lastIndexOf("/"));var g=function(a){return a.replace(c.regex.urls,"$1"+b+"$2$3")},h=!f&&d;b.length&&(b+="/"),h&&(f=1);for(var i=0;f>i;i++){var j,k,n,o;h?(j=d,m.push(g(a))):(j=e[i].match(c.regex.findStyles)&&RegExp.$1,m.push(RegExp.$2&&g(RegExp.$2))),n=j.split(","),o=n.length;for(var p=0;o>p;p++)k=n[p],l.push({media:k.split("(")[0].match(c.regex.only)&&RegExp.$2||"all",rules:m.length-1,hasquery:k.indexOf("(")>-1,minw:k.match(c.regex.minw)&&parseFloat(RegExp.$1)+(RegExp.$2||""),maxw:k.match(c.regex.maxw)&&parseFloat(RegExp.$1)+(RegExp.$2||"")})}u()},w=function(){if(d.length){var b=d.shift();f(b.href,function(c){v(c,b.href,b.media),o[b.href]=!0,a.setTimeout(function(){w()},0)})}},x=function(){for(var b=0;b<s.length;b++){var c=s[b],e=c.href,f=c.media,g=c.rel&&"stylesheet"===c.rel.toLowerCase();e&&g&&!o[e]&&(c.styleSheet&&c.styleSheet.rawCssText?(v(c.styleSheet.rawCssText,e,f),o[e]=!0):(!/^([a-zA-Z:]*\/\/)/.test(e)&&!r||e.replace(RegExp.$1,"").split("/")[0]===a.location.host)&&("//"===e.substring(0,2)&&(e=a.location.protocol+e),d.push({href:e,media:f})))}w()};x(),c.update=x,c.getEmValue=t,a.addEventListener?a.addEventListener("resize",b,!1):a.attachEvent&&a.attachEvent("onresize",b)}}(this);
-399
View File
@@ -1,399 +0,0 @@
// serverstatus.js. big data boom today.
var error = 0;
var d = 0;
var server_status = new Array();
function timeSince(date) {
if(date == 0)
return "从未.";
var seconds = Math.floor((new Date() - date) / 1000);
var interval = Math.floor(seconds / 60);
if (interval > 1)
return interval + " 分钟前.";
else
return "几秒前.";
}
function bytesToSize(bytes, precision, si)
{
var ret;
si = typeof si !== 'undefined' ? si : 0;
if(si != 0) {
var megabyte = 1000 * 1000;
var gigabyte = megabyte * 1000;
var terabyte = gigabyte * 1000;
} else {
var megabyte = 1024 * 1024;
var gigabyte = megabyte * 1024;
var terabyte = gigabyte * 1024;
}
if ((bytes >= megabyte) && (bytes < gigabyte)) {
ret = (bytes / megabyte).toFixed(precision) + ' M';
} else if ((bytes >= gigabyte) && (bytes < terabyte)) {
ret = (bytes / gigabyte).toFixed(precision) + ' G';
} else if (bytes >= terabyte) {
ret = (bytes / terabyte).toFixed(precision) + ' T';
} else {
return bytes + ' B';
}
return ret;
/*if(si != 0) {
return ret + 'B';
} else {
return ret + 'iB';
}*/
}
function uptime() {
$.getJSON("json/stats.json", function(result) {
$("#loading-notice").remove();
if(result.reload)
setTimeout(function() { location.reload() }, 1000);
for (var i = 0, rlen=result.servers.length; i < rlen; i++) {
var TableRow = $("#servers tr#r" + i);
var ExpandRow = $("#servers #rt" + i);
var hack; // fuck CSS for making me do this
if(i%2) hack="odd"; else hack="even";
if (!TableRow.length) {
$("#servers").append(
"<tr id=\"r" + i + "\" data-toggle=\"collapse\" data-target=\"#rt" + i + "\" class=\"accordion-toggle " + hack + "\">" +
"<td id=\"online_status\"><div class=\"progress\"><div style=\"width: 100%;\" class=\"progress-bar progress-bar-warning\"><small>加载中</small></div></div></td>" +
"<td id=\"month_traffic\"><div class=\"progress\"><div style=\"width: 100%;\" class=\"progress-bar progress-bar-warning\"><small>加载中</small></div></div></td>" +
"<td id=\"name\">加载中</td>" +
"<td id=\"type\">加载中</td>" +
"<td id=\"location\">加载中</td>" +
"<td id=\"uptime\">加载中</td>" +
"<td id=\"load\">加载中</td>" +
"<td id=\"network\">加载中</td>" +
"<td id=\"traffic\">加载中</td>" +
"<td id=\"cpu\"><div class=\"progress\"><div style=\"width: 100%;\" class=\"progress-bar progress-bar-warning\"><small>加载中</small></div></div></td>" +
"<td id=\"memory\"><div class=\"progress\"><div style=\"width: 100%;\" class=\"progress-bar progress-bar-warning\"><small>加载中</small></div></div></td>" +
"<td id=\"hdd\"><div class=\"progress\"><div style=\"width: 100%;\" class=\"progress-bar progress-bar-warning\"><small>加载中</small></div></div></td>" +
"<td id=\"ping\"><div class=\"progress\"><div style=\"width: 100%;\" class=\"progress-bar progress-bar-warning\"><small>加载中</small></div></div></td>" +
"</tr>" +
"<tr class=\"expandRow " + hack + "\"><td colspan=\"16\"><div class=\"accordian-body collapse\" id=\"rt" + i + "\">" +
"<div id=\"expand_mem\">加载中</div>" +
"<div id=\"expand_hdd\">加载中</div>" +
"<div id=\"expand_tupd\">加载中</div>" +
"<div id=\"expand_ping\">加载中</div>" +
"<div id=\"expand_lost\">加载中</div>" +
"<div id=\"expand_custom\">加载中</div>" +
"</div></td></tr>"
);
TableRow = $("#servers tr#r" + i);
ExpandRow = $("#servers #rt" + i);
server_status[i] = true;
}
TableRow = TableRow[0];
if(error) {
TableRow.setAttribute("data-target", "#rt" + i);
server_status[i] = true;
}
// online_status
if (result.servers[i].online4 && !result.servers[i].online6) {
TableRow.children["online_status"].children[0].children[0].className = "progress-bar progress-bar-success";
TableRow.children["online_status"].children[0].children[0].innerHTML = "<small>IPv4</small>";
} else if (result.servers[i].online4 && result.servers[i].online6) {
TableRow.children["online_status"].children[0].children[0].className = "progress-bar progress-bar-success";
TableRow.children["online_status"].children[0].children[0].innerHTML = "<small>双栈</small>";
} else if (!result.servers[i].online4 && result.servers[i].online6) {
TableRow.children["online_status"].children[0].children[0].className = "progress-bar progress-bar-success";
TableRow.children["online_status"].children[0].children[0].innerHTML = "<small>IPv6</small>";
} else {
TableRow.children["online_status"].children[0].children[0].className = "progress-bar progress-bar-danger";
TableRow.children["online_status"].children[0].children[0].innerHTML = "<small>关闭</small>";
}
// Name
TableRow.children["name"].innerHTML = result.servers[i].name;
// Type
TableRow.children["type"].innerHTML = result.servers[i].type;
// Location
TableRow.children["location"].innerHTML = result.servers[i].location;
if (!result.servers[i].online4 && !result.servers[i].online6) {
if (server_status[i]) {
TableRow.children["uptime"].innerHTML = "";
TableRow.children["load"].innerHTML = "";
TableRow.children["network"].innerHTML = "";
TableRow.children["traffic"].innerHTML = "";
TableRow.children["month_traffic"].children[0].children[0].className = "progress-bar progress-bar-warning";
TableRow.children["month_traffic"].children[0].children[0].innerHTML = "<small>关闭</small>";
TableRow.children["cpu"].children[0].children[0].className = "progress-bar progress-bar-danger";
TableRow.children["cpu"].children[0].children[0].style.width = "100%";
TableRow.children["cpu"].children[0].children[0].innerHTML = "<small>关闭</small>";
TableRow.children["memory"].children[0].children[0].className = "progress-bar progress-bar-danger";
TableRow.children["memory"].children[0].children[0].style.width = "100%";
TableRow.children["memory"].children[0].children[0].innerHTML = "<small>关闭</small>";
TableRow.children["hdd"].children[0].children[0].className = "progress-bar progress-bar-danger";
TableRow.children["hdd"].children[0].children[0].style.width = "100%";
TableRow.children["hdd"].children[0].children[0].innerHTML = "<small>关闭</small>";
TableRow.children["ping"].children[0].children[0].className = "progress-bar progress-bar-danger";
TableRow.children["ping"].children[0].children[0].style.width = "100%";
TableRow.children["ping"].children[0].children[0].innerHTML = "<small>关闭</small>";
if(ExpandRow.hasClass("in")) {
ExpandRow.collapse("hide");
}
TableRow.setAttribute("data-target", "");
server_status[i] = false;
}
} else {
if (!server_status[i]) {
TableRow.setAttribute("data-target", "#rt" + i);
server_status[i] = true;
}
// month traffic
var monthtraffic = "";
var trafficdiff_in = result.servers[i].network_in - result.servers[i].last_network_in;
var trafficdiff_out = result.servers[i].network_out - result.servers[i].last_network_out;
if(trafficdiff_in < 1024*1024*1024*1024)
monthtraffic += (trafficdiff_in/1024/1024/1024).toFixed(1) + "G";
else
monthtraffic += (trafficdiff_in/1024/1024/1024/1024).toFixed(1) + "T";
monthtraffic += " | "
if(trafficdiff_out < 1024*1024*1024*1024)
monthtraffic += (trafficdiff_out/1024/1024/1024).toFixed(1) + "G";
else
monthtraffic += (trafficdiff_out/1024/1024/1024/1024).toFixed(1) + "T";
TableRow.children["month_traffic"].children[0].children[0].className = "progress-bar progress-bar-success";
TableRow.children["month_traffic"].children[0].children[0].innerHTML = "<small>"+monthtraffic+"</small>";
// Uptime
TableRow.children["uptime"].innerHTML = result.servers[i].uptime;
// Load: default load_1, you can change show: load_1, load_5, load_15
if(result.servers[i].load == -1) {
TableRow.children["load"].innerHTML = "";
} else {
TableRow.children["load"].innerHTML = result.servers[i].load_1.toFixed(2);
}
// Network
var netstr = "";
if(result.servers[i].network_rx < 1024*1024)
netstr += (result.servers[i].network_rx/1024).toFixed(1) + "K";
else
netstr += (result.servers[i].network_rx/1024/1024).toFixed(1) + "M";
netstr += " | "
if(result.servers[i].network_tx < 1024*1024)
netstr += (result.servers[i].network_tx/1024).toFixed(1) + "K";
else
netstr += (result.servers[i].network_tx/1024/1024).toFixed(1) + "M";
TableRow.children["network"].innerHTML = netstr;
//Traffic
var trafficstr = "";
if(result.servers[i].network_in < 1024*1024*1024*1024)
trafficstr += (result.servers[i].network_in/1024/1024/1024).toFixed(1) + "G";
else
trafficstr += (result.servers[i].network_in/1024/1024/1024/1024).toFixed(1) + "T";
trafficstr += " | "
if(result.servers[i].network_out < 1024*1024*1024*1024)
trafficstr += (result.servers[i].network_out/1024/1024/1024).toFixed(1) + "G";
else
trafficstr += (result.servers[i].network_out/1024/1024/1024/1024).toFixed(1) + "T";
TableRow.children["traffic"].innerHTML = trafficstr;
// CPU
if (result.servers[i].cpu >= 90)
TableRow.children["cpu"].children[0].children[0].className = "progress-bar progress-bar-danger";
else if (result.servers[i].cpu >= 80)
TableRow.children["cpu"].children[0].children[0].className = "progress-bar progress-bar-warning";
else
TableRow.children["cpu"].children[0].children[0].className = "progress-bar progress-bar-success";
TableRow.children["cpu"].children[0].children[0].style.width = result.servers[i].cpu + "%";
TableRow.children["cpu"].children[0].children[0].innerHTML = result.servers[i].cpu + "%";
// Memory
var Mem = ((result.servers[i].memory_used/result.servers[i].memory_total)*100.0).toFixed(0);
if (Mem >= 90)
TableRow.children["memory"].children[0].children[0].className = "progress-bar progress-bar-danger";
else if (Mem >= 80)
TableRow.children["memory"].children[0].children[0].className = "progress-bar progress-bar-warning";
else
TableRow.children["memory"].children[0].children[0].className = "progress-bar progress-bar-success";
TableRow.children["memory"].children[0].children[0].style.width = Mem + "%";
TableRow.children["memory"].children[0].children[0].innerHTML = Mem + "%";
// 内存|swap
ExpandRow[0].children["expand_mem"].innerHTML = "内存|虚存: " + bytesToSize(result.servers[i].memory_used*1024, 1) + " / " + bytesToSize(result.servers[i].memory_total*1024, 1) + " | " + bytesToSize(result.servers[i].swap_used*1024, 0) + " / " + bytesToSize(result.servers[i].swap_total*1024, 0);
// HDD
var HDD = ((result.servers[i].hdd_used/result.servers[i].hdd_total)*100.0).toFixed(0);
if (HDD >= 90)
TableRow.children["hdd"].children[0].children[0].className = "progress-bar progress-bar-danger";
else if (HDD >= 80)
TableRow.children["hdd"].children[0].children[0].className = "progress-bar progress-bar-warning";
else
TableRow.children["hdd"].children[0].children[0].className = "progress-bar progress-bar-success";
TableRow.children["hdd"].children[0].children[0].style.width = HDD + "%";
TableRow.children["hdd"].children[0].children[0].innerHTML = HDD + "%";
// IO Speed for HDD.
// IO, 过小的B字节单位没有意义
var io = "";
if(result.servers[i].io_read < 1024*1024)
io += parseInt(result.servers[i].io_read/1024) + "K";
else
io += parseInt(result.servers[i].io_read/1024/1024) + "M";
io += " / "
if(result.servers[i].io_write < 1024*1024)
io += parseInt(result.servers[i].io_write/1024) + "K";
else
io += parseInt(result.servers[i].io_write/1024/1024) + "M";
// Expand for HDD.
ExpandRow[0].children["expand_hdd"].innerHTML = "硬盘|读写: " + bytesToSize(result.servers[i].hdd_used*1024*1024, 2) + " / " + bytesToSize(result.servers[i].hdd_total*1024*1024, 2) + " | " + io;
// delay time
// tcp, udp, process, thread count
ExpandRow[0].children["expand_tupd"].innerHTML = "TCP/UDP/进/线: " + result.servers[i].tcp_count + " / " + result.servers[i].udp_count + " / " + result.servers[i].process_count+ " / " + result.servers[i].thread_count;
ExpandRow[0].children["expand_ping"].innerHTML = "联通/电信/移动: " + result.servers[i].time_10010 + "ms / " + result.servers[i].time_189 + "ms / " + result.servers[i].time_10086 + "ms"
// ping
var PING_10010 = result.servers[i].ping_10010.toFixed(0);
var PING_189 = result.servers[i].ping_189.toFixed(0);
var PING_10086 = result.servers[i].ping_10086.toFixed(0);
ExpandRow[0].children["expand_lost"].innerHTML = "丢包:联通/电信/移动: " + PING_10010 + "% / " + PING_189 + "% / " + PING_10086 + "%"
if (PING_10010 >= 20 || PING_189 >= 20 || PING_10086 >= 20)
TableRow.children["ping"].children[0].children[0].className = "progress-bar progress-bar-danger";
else if (PING_10010 >= 10 || PING_189 >= 10 || PING_10086 >= 10)
TableRow.children["ping"].children[0].children[0].className = "progress-bar progress-bar-warning";
else
TableRow.children["ping"].children[0].children[0].className = "progress-bar progress-bar-success";
TableRow.children["ping"].children[0].children[0].innerHTML = PING_10010 + "%💻" + PING_189 + "%💻" + PING_10086 + "%";
// Custom
if (result.servers[i].custom) {
ExpandRow[0].children["expand_custom"].innerHTML = result.servers[i].custom
} else {
ExpandRow[0].children["expand_custom"].innerHTML = ""
}
}
};
d = new Date(result.updated*1000);
error = 0;
}).fail(function(update_error) {
if (!error) {
$("#servers > tr.accordion-toggle").each(function(i) {
var TableRow = $("#servers tr#r" + i)[0];
var ExpandRow = $("#servers #rt" + i);
TableRow.children["online_status"].children[0].children[0].className = "progress-bar progress-bar-error";
TableRow.children["online_status"].children[0].children[0].innerHTML = "<small>错误</small>";
TableRow.children["month_traffic"].children[0].children[0].className = "progress-bar progress-bar-error";
TableRow.children["month_traffic"].children[0].children[0].innerHTML = "<small>错误</small>";
TableRow.children["uptime"].children[0].children[0].className = "progress-bar progress-bar-error";
TableRow.children["uptime"].children[0].children[0].innerHTML = "<small>错误</small>";
TableRow.children["load"].children[0].children[0].className = "progress-bar progress-bar-error";
TableRow.children["load"].children[0].children[0].innerHTML = "<small>错误</small>";
TableRow.children["network"].children[0].children[0].className = "progress-bar progress-bar-error";
TableRow.children["network"].children[0].children[0].innerHTML = "<small>错误</small>";
TableRow.children["traffic"].children[0].children[0].className = "progress-bar progress-bar-error";
TableRow.children["traffic"].children[0].children[0].innerHTML = "<small>错误</small>";
TableRow.children["cpu"].children[0].children[0].className = "progress-bar progress-bar-error";
TableRow.children["cpu"].children[0].children[0].style.width = "100%";
TableRow.children["cpu"].children[0].children[0].innerHTML = "<small>错误</small>";
TableRow.children["memory"].children[0].children[0].className = "progress-bar progress-bar-error";
TableRow.children["memory"].children[0].children[0].style.width = "100%";
TableRow.children["memory"].children[0].children[0].innerHTML = "<small>错误</small>";
TableRow.children["hdd"].children[0].children[0].className = "progress-bar progress-bar-error";
TableRow.children["hdd"].children[0].children[0].style.width = "100%";
TableRow.children["hdd"].children[0].children[0].innerHTML = "<small>错误</small>";
TableRow.children["ping"].children[0].children[0].className = "progress-bar progress-bar-error";
TableRow.children["ping"].children[0].children[0].style.width = "100%";
TableRow.children["ping"].children[0].children[0].innerHTML = "<small>错误</small>";
if(ExpandRow.hasClass("in")) {
ExpandRow.collapse("hide");
}
TableRow.setAttribute("data-target", "");
server_status[i] = false;
});
}
error = 1;
$("#updated").html("更新错误.");
});
}
function updateTime() {
if (!error)
$("#updated").html("最后更新: " + timeSince(d));
}
uptime();
updateTime();
setInterval(uptime, 2000);
setInterval(updateTime, 2000);
// styleswitcher.js
function setActiveStyleSheet(title, cookie=false) {
var i, a, main;
for(i=0; (a = document.getElementsByTagName("link")[i]); i++) {
if(a.getAttribute("rel").indexOf("style") != -1 && a.getAttribute("title")) {
a.disabled = true;
if(a.getAttribute("title") == title) a.disabled = false;
}
}
if (true==cookie) {
createCookie("style", title, 365);
}
}
function getActiveStyleSheet() {
var i, a;
for(i=0; (a = document.getElementsByTagName("link")[i]); i++) {
if(a.getAttribute("rel").indexOf("style") != -1 && a.getAttribute("title") && !a.disabled)
return a.getAttribute("title");
}
return null;
}
function createCookie(name,value,days) {
if (days) {
var date = new Date();
date.setTime(date.getTime()+(days*24*60*60*1000));
var expires = "; expires="+date.toGMTString();
}
else expires = "";
document.cookie = name+"="+value+expires+"; path=/";
}
function readCookie(name) {
var nameEQ = name + "=";
var ca = document.cookie.split(';');
for(var i=0;i < ca.length;i++) {
var c = ca[i];
while (c.charAt(0)==' ')
c = c.substring(1,c.length);
if (c.indexOf(nameEQ) == 0)
return c.substring(nameEQ.length,c.length);
}
return null;
}
window.onload = function(e) {
var cookie = readCookie("style");
if (cookie && cookie != 'null' ) {
setActiveStyleSheet(cookie);
} else {
function handleChange (mediaQueryListEvent) {
if (mediaQueryListEvent.matches) {
setActiveStyleSheet('dark');
} else {
setActiveStyleSheet('light');
}
}
const mediaQueryListDark = window.matchMedia('(prefers-color-scheme: dark)');
setActiveStyleSheet(mediaQueryListDark.matches ? 'dark' : 'light');
mediaQueryListDark.addEventListener("change",handleChange);
}
}
-83
View File
@@ -1,83 +0,0 @@
#!/usr/bin/env python3
# coding: utf-8
# Update by : https://github.com/cppla/ServerStatus, Update date: 20211009
# 支持Python版本:2.7 to 3.9; requirements.txt: requests, PrettyTable
# 主要是为了受到CC attack时候方便查看机器状态
import os
import sys
import requests
import time
from prettytable import PrettyTable
scroll = True
clear = lambda: os.system('clear' if 'linux' in sys.platform or 'darwin' in sys.platform else 'cls')
def sscmd(address):
while True:
r = requests.get(
url=address,
headers={
"User-Agent": "ServerStatus/20181203",
}
)
jsonR = r.json()
ss = PrettyTable(
[
"月流量 ↓|↑",
"节点名",
"位置",
"在线时间",
"负载",
"网络 ↓|↑",
"总流量 ↓|↑",
"处理器",
"内存",
"硬盘"
],
)
for i in jsonR["servers"]:
if i["online4"] is False and i["online6"] is False:
ss.add_row(
[
'0.00G',
"%s" % i["name"],
"%s" % i["location"],
'-',
'-',
'-',
'-',
'-',
'-',
'-',
]
)
else:
ss.add_row(
[
"%.2fG|%.2fG" % (float(i["last_network_in"]) / 1024 / 1024 / 1024, float(i["last_network_out"]) / 1024 / 1024 / 1024),
"%s" % i["name"],
# "%s" % i["type"],
"%s" % i["location"],
"%s" % i["uptime"],
"%s" % (i["load_1"]),
"%.2fM|%.2fM" % (float(i["network_rx"]) / 1000 / 1000, float(i["network_tx"]) / 1000 / 1000),
"%.2fG|%.2fG" % (
float(i["network_in"]) / 1024 / 1024 / 1024, float(i["network_out"]) / 1024 / 1024 / 1024),
"%d%%" % (i["cpu"]),
"%d%%" % (float(i["memory_used"]) / i["memory_total"] * 100),
"%d%%" % (float(i["hdd_used"]) / i["hdd_total"] * 100),
]
)
if scroll is True:
clear()
print(ss)
time.sleep(1)
if __name__ == '__main__':
default = 'https://tz.cloudcpp.com/json/stats.json'
ads = sys.argv[1] if len(sys.argv) == 2 else default
sscmd(ads)