feat: 支持托管中转主机和节点接入线路
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
ID=__ID__
|
||||
ROOT=/etc/hy2-panel-relay
|
||||
[ "$(id -u)" -eq 0 ] && [ -d /run/systemd/system ] || { echo '需要 root 和 systemd Linux。'; exit 1; }
|
||||
if [ -d "$ROOT" ] || [ -e /etc/systemd/system/hy2-panel-relay.service ]; then
|
||||
[ "$(cat "$ROOT/host-id" 2>/dev/null || true)" = "$ID" ] || { echo '已有其他中转安装,停止以避免覆盖。'; exit 1; }
|
||||
fi
|
||||
case "$(uname -m)" in
|
||||
x86_64) ARCH=x86_64; SHA=b1cc335547bea8bb2a88178bef12ec7f2363e36200e7ea1d4e1e67627929bf65;;
|
||||
aarch64|arm64) ARCH=aarch64; SHA=f4c0318dd86854da483dcb7645b4f39cae2cc3f91c688fef969d53220b949488;;
|
||||
*) 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 ca-certificates python3 tar
|
||||
elif command -v dnf >/dev/null; then dnf install -y curl ca-certificates python3 tar
|
||||
else echo '需要 Debian/Ubuntu 或 dnf 发行版。'; exit 1; fi
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
curl -fsSL --retry 3 --connect-timeout 20 --max-time 300 "https://github.com/zhboner/realm/releases/download/v2.9.6/realm-$ARCH-unknown-linux-musl.tar.gz" -o "$WORK/realm.tar.gz"
|
||||
printf '%s %s\n' "$SHA" "$WORK/realm.tar.gz" | sha256sum -c -
|
||||
tar -xzf "$WORK/realm.tar.gz" -C "$WORK" realm
|
||||
systemctl stop hy2-panel-relay-agent.service hy2-panel-relay.service 2>/dev/null || true
|
||||
install -d -m 700 "$ROOT" /usr/local/lib/hy2-panel-relay
|
||||
printf '%s' "$ID" > "$ROOT/host-id"
|
||||
printf '%s' __PAYLOAD__ | base64 -d > "$WORK/payload.json"
|
||||
python3 - "$WORK/payload.json" <<'PY'
|
||||
import json,sys
|
||||
from pathlib import Path
|
||||
p=json.load(open(sys.argv[1]))
|
||||
Path('/etc/hy2-panel-relay/agent.json').write_text(json.dumps(p['credentials']))
|
||||
Path('/usr/local/lib/hy2-panel-relay/agent.py').write_text(p['source'])
|
||||
PY
|
||||
install -m 755 "$WORK/realm" /usr/local/lib/hy2-panel-relay/realm
|
||||
cat > /etc/systemd/system/hy2-panel-relay.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=HY2 Panel Realm relay
|
||||
After=network-online.target
|
||||
[Service]
|
||||
ExecStart=/usr/local/lib/hy2-panel-relay/realm -c /etc/hy2-panel-relay/config.json
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
LimitNOFILE=1048576
|
||||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
PrivateTmp=true
|
||||
UNIT
|
||||
cat > /etc/systemd/system/hy2-panel-relay-agent.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=HY2 Panel relay management agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /usr/local/lib/hy2-panel-relay/agent.py
|
||||
ExecStopPost=/bin/systemctl stop hy2-panel-relay.service
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/etc/hy2-panel-relay
|
||||
PrivateTmp=true
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now hy2-panel-relay-agent.service
|
||||
echo '中转管理程序已启动,请在面板配置线路并确认状态。无需开放公网管理端口。'
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Outbound-only management for a dedicated Realm instance."""
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
ROOT = Path('/etc/hy2-panel-relay')
|
||||
UNIT = 'hy2-panel-relay.service'
|
||||
|
||||
|
||||
def core(command):
|
||||
subprocess.run(['systemctl', command, UNIT], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def pid():
|
||||
return int(subprocess.check_output(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value'], text=True).strip())
|
||||
|
||||
|
||||
def active():
|
||||
return subprocess.run(['systemctl', 'is-active', '--quiet', UNIT]).returncode == 0
|
||||
|
||||
|
||||
def verify_listeners(config):
|
||||
process = pid()
|
||||
if not process:
|
||||
return False
|
||||
inodes = set()
|
||||
for fd in Path('/proc/' + str(process) + '/fd').iterdir():
|
||||
try:
|
||||
link = os.readlink(fd)
|
||||
if link.startswith('socket:['):
|
||||
inodes.add(link[8:-1])
|
||||
except OSError:
|
||||
pass
|
||||
ports = set()
|
||||
for network in ('tcp', 'udp'):
|
||||
for line in Path('/proc/net/' + network).read_text().splitlines()[1:]:
|
||||
fields = line.split()
|
||||
if fields[9] in inodes and (network != 'tcp' or fields[3] == '0A'):
|
||||
ports.add((network, int(fields[1].split(':')[1], 16)))
|
||||
return all(('udp' if endpoint['network']['use_udp'] else 'tcp', int(endpoint['listen'].rsplit(':', 1)[1])) in ports for endpoint in config['endpoints'])
|
||||
|
||||
|
||||
def write_config(config):
|
||||
staged = ROOT / 'next.json'
|
||||
with open(staged, 'w') as handle:
|
||||
json.dump(config, handle)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(staged, ROOT / 'config.json')
|
||||
|
||||
|
||||
def apply(config):
|
||||
core('stop')
|
||||
sockets = []
|
||||
try:
|
||||
for endpoint in config['endpoints']:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM if endpoint['network']['use_udp'] else socket.SOCK_STREAM)
|
||||
sockets.append(sock)
|
||||
# SO_REUSEADDR allows a stopped TCP listener's TIME_WAIT connections.
|
||||
if not endpoint['network']['use_udp']:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(('0.0.0.0', int(endpoint['listen'].rsplit(':', 1)[1])))
|
||||
except OSError:
|
||||
return 'port_conflict'
|
||||
finally:
|
||||
for sock in sockets:
|
||||
sock.close()
|
||||
write_config(config)
|
||||
if config['endpoints']:
|
||||
core('start')
|
||||
for _ in range(20):
|
||||
time.sleep(0.2)
|
||||
if active() and verify_listeners(config):
|
||||
return ''
|
||||
core('stop')
|
||||
return 'apply_failed'
|
||||
return ''
|
||||
|
||||
|
||||
def run():
|
||||
lock = open(ROOT / 'agent.lock', 'w')
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
credentials = json.loads((ROOT / 'agent.json').read_text())
|
||||
applied = ''
|
||||
error = ''
|
||||
deadline = 0
|
||||
core('stop')
|
||||
while True:
|
||||
try:
|
||||
request = urllib.request.Request(credentials['panel'] + '/api/relay/' + credentials['id'] + '/sync',
|
||||
data=json.dumps({'appliedHash': applied, 'active': active(), 'error': error}).encode(),
|
||||
headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + credentials['token']})
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
desired = json.load(response)
|
||||
if desired['hash'] != applied or (desired['config']['endpoints'] and not active()):
|
||||
error = apply(desired['config'])
|
||||
applied = desired['hash'] if not error else ''
|
||||
deadline = time.monotonic() + min(90, desired['leaseSeconds'])
|
||||
print('同步成功' if not error else error, flush=True)
|
||||
except Exception as exc:
|
||||
print('同步失败:' + type(exc).__name__, flush=True)
|
||||
if isinstance(exc, urllib.error.HTTPError) and exc.code in (400, 401, 403, 404):
|
||||
deadline = 0
|
||||
if time.monotonic() >= deadline:
|
||||
core('stop')
|
||||
applied = ''
|
||||
time.sleep(max(1, min(15, deadline - time.monotonic())) if deadline else 5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
os.umask(0o077)
|
||||
try:
|
||||
run()
|
||||
finally:
|
||||
core('stop')
|
||||
Reference in New Issue
Block a user