feat: 接入 AnyTLS 节点托管和用户流量同步

This commit is contained in:
2026-09-29 13:35:44 +08:00
parent 586c2399d1
commit ac5fed695c
24 changed files with 1413 additions and 185 deletions
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""HY2 Panel AnyTLS agent. Local-only core APIs; HTTPS outbound control plane."""
import fcntl
import json
import os
from pathlib import Path
import subprocess
import sys
import time
import urllib.request
import urllib.error
import grpc
import stats_pb2
ROOT = Path('/etc/hy2-panel-anytls')
STATE = Path('/var/lib/hy2-panel-anytls')
BINARY = '/usr/local/lib/hy2-panel-anytls/sing-box'
UNIT = 'hy2-panel-anytls.service'
def atomic_json(file, value):
temporary = file.with_suffix('.tmp')
with open(temporary, 'w') as handle:
os.chmod(temporary, 0o600)
json.dump(value, handle, sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, file)
def core(command):
subprocess.run(['systemctl', command, UNIT], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def read_json(file):
return json.loads(file.read_text())
def epoch():
pid = subprocess.check_output(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value'], text=True).strip()
if pid == '0':
return None
# PID + boot ID + start ticks survives agent restarts without re-counting.
start = Path('/proc/' + pid + '/stat').read_text().rsplit(')', 1)[1].split()[19]
return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + ':' + pid + ':' + start
def snapshot():
current = epoch()
if current is None:
return None
config = read_json(ROOT / 'config.json')
experimental = config['experimental']
with grpc.insecure_channel(experimental['v2ray_api']['listen']) as channel:
query = channel.unary_unary(
'/v2ray.core.app.stats.command.StatsService/QueryStats',
request_serializer=stats_pb2.QueryStatsRequest.SerializeToString,
response_deserializer=stats_pb2.QueryStatsResponse.FromString)
result = query(stats_pb2.QueryStatsRequest(pattern='user>>>', reset=False), timeout=5)
traffic = {}
for stat in result.stat:
parts = stat.name.split('>>>')
if len(parts) == 4 and parts[0] == 'user' and parts[2] == 'traffic':
value = traffic.setdefault(parts[1], {'tx': 0, 'rx': 0})
# HY2 ledgers use tx=server->client, rx=client->server.
if parts[3] == 'uplink':
value['rx'] = int(stat.value)
elif parts[3] == 'downlink':
value['tx'] = int(stat.value)
clash = experimental['clash_api']
request = urllib.request.Request('http://' + clash['external_controller'] + '/connections',
headers={'Authorization': 'Bearer ' + clash['secret']})
with urllib.request.urlopen(request, timeout=5) as response:
streams = len(json.load(response).get('connections', []))
if epoch() != current:
raise RuntimeError('core_restarted_during_snapshot')
return {'epoch': current, 'traffic': traffic, 'streamCount': streams}
def remember(state):
sample = snapshot()
if sample:
state['pending'][sample['epoch']] = sample
atomic_json(STATE / 'state.json', state)
return sample
def sync(credentials, sample):
request = urllib.request.Request(credentials['panel'] + '/api/agent/' + credentials['slug'] + '/sync',
data=json.dumps(sample).encode(), method='POST',
headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + credentials['token']})
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response)
def apply_config(config, state):
previous = read_json(ROOT / 'config.json')
if previous != config:
# Capture one last cumulative sample before replacing a core process.
remember(state)
staged = ROOT / 'next.json'
atomic_json(staged, config)
checked = subprocess.run([BINARY, 'check', '-c', str(staged)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if checked.returncode:
core('stop')
raise RuntimeError('invalid_core_configuration')
os.replace(staged, ROOT / 'config.json')
core('restart')
else:
core('start')
def run():
STATE.mkdir(mode=0o700, parents=True, exist_ok=True)
lock = open(STATE / 'agent.lock', 'w')
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
credentials = read_json(ROOT / 'agent.json')
state_file = STATE / 'state.json'
state = read_json(state_file) if state_file.exists() else {'pending': {}}
# No cached lease is trusted after an agent restart.
deadline = 0
while True:
try:
if time.monotonic() >= deadline:
try:
remember(state)
finally:
core('stop')
else:
remember(state)
if len(state['pending']) > 256:
core('stop')
raise RuntimeError('report_backlog_full')
reports = list(state['pending'].items())
if not reports:
reports = [('heartbeat', {'epoch': 'heartbeat', 'traffic': {}, 'streamCount': 0})]
desired = None
for report_epoch, report in reports:
desired = sync(credentials, report)
state['pending'].pop(report_epoch, None)
atomic_json(state_file, state)
apply_config(desired['config'], state)
deadline = time.monotonic() + min(90, max(1, desired['leaseSeconds']))
print('同步成功', flush=True)
except Exception as error:
# Do not log credentials, URLs, user lists or config contents.
print('同步失败:' + type(error).__name__, flush=True)
if isinstance(error, urllib.error.HTTPError) and error.code in (400, 401, 403, 404):
deadline = 0
core('stop')
if time.monotonic() >= deadline:
core('stop')
time.sleep(max(1, min(15, deadline - time.monotonic())) if deadline else 5)
if __name__ == '__main__':
os.umask(0o077)
try:
run()
except Exception as error:
core('stop')
print('管理程序停止:' + type(error).__name__, flush=True)
sys.exit(1)
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
set -Eeuo pipefail
umask 077
PANEL=__PANEL__
TOKEN=__TOKEN__
INSTALL_ID=__INSTALL_ID__
SLUG=__SLUG__
VERSION=__SING_BOX_VERSION__
echo "安装 sing-box $VERSION(带用户统计模块)"
DOMAIN='' PORT='' LISTEN_PORT=''
while [ "$#" -gt 0 ]; do
[ "$#" -ge 2 ] || { echo "参数缺少值:$1"; exit 1; }
case "$1" in
--domain) DOMAIN=$2;; --port) PORT=$2;; --listen-port) LISTEN_PORT=$2;;
*) echo "未知参数:$1"; exit 1;;
esac
shift 2
done
[ "$(id -u)" -eq 0 ] && [ -d /run/systemd/system ] || { echo '需要 root 和 systemd Linux。'; exit 1; }
ROOT=/etc/hy2-panel-anytls
if [ -d "$ROOT" ] || [ -e /etc/systemd/system/hy2-panel-anytls.service ]; then
[ "$(cat "$ROOT/install-id" 2>/dev/null || true)" = "$INSTALL_ID" ] || { echo '已有其他 AnyTLS 安装,停止以避免覆盖。'; exit 1; }
fi
[ -n "$DOMAIN" ] || read -r -p '域名前缀(例如 jp2)或完整域名:' DOMAIN </dev/tty
DOMAIN=$(printf '%s' "$DOMAIN" | tr '[:upper:]' '[:lower:]')
if [[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; then DOMAIN="$DOMAIN.p.xn--876a.net"; fi
[[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.p\.xn--876a\.net$ ]] || { echo '请输入 .p.xn--876a.net 下的单级域名。'; exit 1; }
[ -n "$PORT" ] || read -r -p 'AnyTLS 公网 TCP 端口:' PORT </dev/tty
if [ -z "$LISTEN_PORT" ] && [ -t 0 ]; then read -r -p "本机 TCP 监听端口(回车使用 $PORT):" LISTEN_PORT </dev/tty; fi
LISTEN_PORT=${LISTEN_PORT:-$PORT}
for port in "$PORT" "$LISTEN_PORT"; do
if ! [[ "$port" =~ ^[1-9][0-9]{0,4}$ ]] || (( port > 65535 )); then echo '端口须为 1–65535。'; exit 1; fi
done
case "$(uname -m)" in x86_64) ARCH=amd64;; aarch64|arm64) ARCH=arm64;; *) echo '暂支持 x86_64 和 ARM64。'; exit 1;; esac
if command -v apt-get >/dev/null; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y curl jq ca-certificates python3 python3-venv openssl
elif command -v dnf >/dev/null; then dnf install -y curl jq ca-certificates python3 python3-pip openssl
else echo '当前安装脚本支持 Debian/Ubuntu 或使用 dnf 的发行版。'; exit 1; fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
printf 'Authorization: Bearer %s\n' "$TOKEN" > "$WORK/headers"
# Panel serves a pinned, unmodified upstream build with the V2Ray stats module.
curl -fsSL --retry 3 --connect-timeout 20 --max-time 300 "$PANEL/api/install/sing-box/$ARCH" -o "$WORK/sing-box"
curl -fsSL --retry 3 --max-time 60 "$PANEL/api/install/sing-box/$ARCH?sha256" -o "$WORK/checksum"
printf '%s %s\n' "$(cat "$WORK/checksum")" "$WORK/sing-box" | sha256sum -c -
# Retain local management ports on retries. These are never exposed publicly.
if [ -f "$ROOT/ports.json" ]; then
jq -e --arg domain "$DOMAIN" --argjson port "$PORT" --argjson listen "$LISTEN_PORT" '.domain == $domain and .port == $port and .listenPort == $listen' "$ROOT/ports.json" >/dev/null || { echo '重试安装请使用原域名和端口;变更配置需生成新的安装记录。'; exit 1; }
cp "$ROOT/ports.json" "$WORK/ports.json"
else
python3 - "$PORT" "$LISTEN_PORT" "$DOMAIN" > "$WORK/ports.json" <<'PY'
import json,socket,sys
sockets=[]
for _ in range(2):
s=socket.socket();s.bind(('127.0.0.1',0));sockets.append(s)
print(json.dumps(dict(domain=sys.argv[3],port=int(sys.argv[1]),listenPort=int(sys.argv[2]),statsPort=sockets[0].getsockname()[1],controlPort=sockets[1].getsockname()[1])))
PY
fi
jq -n --slurpfile ports "$WORK/ports.json" '{action:"prepare",ports:$ports[0]}' > "$WORK/request"
api() {
local status
status=$(curl -sS --connect-timeout 15 --max-time 60 -o "$WORK/response" -w '%{http_code}' -H @"$WORK/headers" -H 'Content-Type: application/json' --data-binary @"$WORK/request" "$PANEL/api/install")
if [ "$status" != 200 ]; then jq -r '.error // "平台请求失败"' "$WORK/response" >&2; return 1; fi
}
api
systemctl stop hy2-panel-anytls-agent.service hy2-panel-anytls.service 2>/dev/null || true
install -d -m 700 "$ROOT" /usr/local/lib/hy2-panel-anytls /var/lib/hy2-panel-anytls
printf '%s' "$INSTALL_ID" > "$ROOT/install-id"
install -m 600 "$WORK/ports.json" "$ROOT/ports.json"
jq -er '.config' "$WORK/response" > "$ROOT/config.json"
jq -er '.certificate' "$WORK/response" > "$ROOT/server.crt"
jq -er '.privateKey' "$WORK/response" > "$ROOT/server.key"
jq -e '.agent' "$WORK/response" > "$ROOT/agent.json"
jq -er '.agentSource' "$WORK/response" > /usr/local/lib/hy2-panel-anytls/agent.py
jq -er '.statsProto' "$WORK/response" > /usr/local/lib/hy2-panel-anytls/stats.proto
install -m 755 "$WORK/sing-box" /usr/local/lib/hy2-panel-anytls/sing-box
/usr/local/lib/hy2-panel-anytls/sing-box check -c "$ROOT/config.json"
python3 -m venv /usr/local/lib/hy2-panel-anytls/venv
/usr/local/lib/hy2-panel-anytls/venv/bin/pip -q install 'grpcio==1.76.0' 'grpcio-tools==1.76.0'
/usr/local/lib/hy2-panel-anytls/venv/bin/python -m grpc_tools.protoc -I /usr/local/lib/hy2-panel-anytls --python_out=/usr/local/lib/hy2-panel-anytls /usr/local/lib/hy2-panel-anytls/stats.proto
cat > /etc/systemd/system/hy2-panel-anytls.service <<'UNIT'
[Unit]
Description=AnyTLS sing-box managed by HY2 Panel
After=network-online.target
[Service]
ExecStart=/usr/local/lib/hy2-panel-anytls/sing-box run -c /etc/hy2-panel-anytls/config.json
Restart=on-failure
RestartSec=3
LimitNOFILE=1048576
NoNewPrivileges=true
ProtectHome=true
ProtectSystem=strict
PrivateTmp=true
UNIT
cat > /etc/systemd/system/hy2-panel-anytls-agent.service <<'UNIT'
[Unit]
Description=HY2 Panel AnyTLS sync agent
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/lib/hy2-panel-anytls/venv/bin/python /usr/local/lib/hy2-panel-anytls/agent.py
ExecStopPost=/bin/systemctl stop hy2-panel-anytls.service
Restart=always
RestartSec=5
NoNewPrivileges=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/etc/hy2-panel-anytls /var/lib/hy2-panel-anytls
PrivateTmp=true
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl restart hy2-panel-anytls.service
CONTROL=$(jq -r '.controlPort' "$ROOT/ports.json")
jq -er '.experimental.clash_api.secret | "Authorization: Bearer " + .' "$ROOT/config.json" > "$WORK/control-header"
READY=0
for attempt in {1..15}; do
if curl -fsS --max-time 2 -H @"$WORK/control-header" "http://127.0.0.1:$CONTROL/connections" -o /dev/null; then READY=1; break; fi
sleep 1
done
[ "$READY" -eq 1 ] || { systemctl stop hy2-panel-anytls.service; echo 'sing-box 健康检查失败。'; exit 1; }
printf '{"action":"complete"}' > "$WORK/request"
for attempt in {1..3}; do
if api; then break; fi
[ "$attempt" -lt 3 ] || { systemctl stop hy2-panel-anytls.service; exit 1; }
sleep 2
done
# Only the agent starts at boot; it requires a valid panel lease before starting the core.
systemctl enable --now hy2-panel-anytls-agent.service
echo "AnyTLS 节点 $SLUG 已安装。TCP $PORT → $LISTEN_PORT;管理端口仅监听本机。"
echo '用户变更约 15 秒同步;应用变更会重启 sing-box 并断开现有连接。'
+17
View File
@@ -0,0 +1,17 @@
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
In addition, no derivative work may use the name or imply association
with this application without prior consent.
+55
View File
@@ -0,0 +1,55 @@
// Source: SagerNet/sing-box v1.14.2, commit af6e64c3b69e6132ebaee0e1a3d24e93903f6709
// Copyright (C) 2022 nekohasekai. GPL-3.0-or-later; see sing-box-LICENSE.
syntax = "proto3";
package experimental.v2rayapi;
option go_package = "github.com/sagernet/sing-box/experimental/v2rayapi";
message GetStatsRequest {
// Name of the stat counter.
string name = 1;
// Whether or not to reset the counter to fetching its value.
bool reset = 2;
}
message Stat {
string name = 1;
int64 value = 2;
}
message GetStatsResponse {
Stat stat = 1;
}
message QueryStatsRequest {
// Deprecated, use Patterns instead
string pattern = 1;
bool reset = 2;
repeated string patterns = 3;
bool regexp = 4;
}
message QueryStatsResponse {
repeated Stat stat = 1;
}
message SysStatsRequest {}
message SysStatsResponse {
uint32 NumGoroutine = 1;
uint32 NumGC = 2;
uint64 Alloc = 3;
uint64 TotalAlloc = 4;
uint64 Sys = 5;
uint64 Mallocs = 6;
uint64 Frees = 7;
uint64 LiveObjects = 8;
uint64 PauseTotalNs = 9;
uint32 Uptime = 10;
}
service StatsService {
rpc GetStats(GetStatsRequest) returns (GetStatsResponse) {}
rpc QueryStats(QueryStatsRequest) returns (QueryStatsResponse) {}
rpc GetSysStats(SysStatsRequest) returns (SysStatsResponse) {}
}