214 lines
8.2 KiB
Python
Executable File
214 lines
8.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import base64, json, os, socket, ssl, time, urllib.request, shutil, http.cookiejar
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
OUT = Path(os.environ.get('GUKO_INV') or os.environ.get('VPSPILOT_INV') or ROOT / 'servers.json')
|
|
DEFAULT_KEY = os.environ.get('GUKO_DEFAULT_KEY') or os.environ.get('VPSPILOT_DEFAULT_KEY') or '/data/keys/id_ed25519'
|
|
DEFAULT_PORT = int(os.environ.get('GUKO_DEFAULT_PORT') or os.environ.get('VPSPILOT_DEFAULT_PORT') or '22')
|
|
DEFAULT_USER = os.environ.get('GUKO_DEFAULT_USER') or os.environ.get('VPSPILOT_DEFAULT_USER') or 'root'
|
|
PANEL = os.environ.get('NEZHA_URL', os.environ.get('NEZHA_PANEL', '')).strip().removeprefix('https://').removeprefix('http://').rstrip('/')
|
|
WS_PATH = os.environ.get('NEZHA_WS_PATH', '/api/v1/ws/server')
|
|
SERVICE_URL = f'https://{PANEL}/api/v1/service' if PANEL else ''
|
|
SERVER_URL = f'https://{PANEL}/api/v1/server' if PANEL else ''
|
|
LOGIN_URL = f'https://{PANEL}/api/v1/login' if PANEL else ''
|
|
NEZHA_USER = os.environ.get('NEZHA_USER', '')
|
|
NEZHA_PASSWORD = os.environ.get('NEZHA_PASSWORD', '')
|
|
ALIASES = json.loads(os.environ.get('GUKO_ALIASES_JSON') or os.environ.get('VPSPILOT_ALIASES_JSON') or '{}' or '{}')
|
|
|
|
def now_iso():
|
|
return datetime.now().astimezone().isoformat(timespec='seconds')
|
|
|
|
def fetch_json(url, opener=None, data=None):
|
|
req = urllib.request.Request(url)
|
|
if data is not None:
|
|
req.add_header('Content-Type', 'application/json')
|
|
req.data = json.dumps(data).encode()
|
|
if opener is None:
|
|
rctx = urllib.request.urlopen(req, timeout=10)
|
|
else:
|
|
rctx = opener.open(req, timeout=10)
|
|
with rctx as r:
|
|
return json.load(r)
|
|
|
|
def fetch_admin_servers():
|
|
jar = http.cookiejar.CookieJar()
|
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
login = fetch_json(LOGIN_URL, opener, {'username': NEZHA_USER, 'password': NEZHA_PASSWORD})
|
|
if not login.get('success'):
|
|
raise RuntimeError(login.get('error') or 'Nezha login failed')
|
|
data = fetch_json(SERVER_URL, opener)
|
|
if not data.get('success'):
|
|
raise RuntimeError(data.get('error') or 'Nezha server API failed')
|
|
return data.get('data') or []
|
|
|
|
def ws_read_one(host=PANEL, path=WS_PATH):
|
|
key = base64.b64encode(os.urandom(16)).decode()
|
|
ctx = ssl.create_default_context()
|
|
sock = ctx.wrap_socket(socket.create_connection((host, 443), timeout=10), server_hostname=host)
|
|
try:
|
|
req = (
|
|
f'GET {path} HTTP/1.1\r\n'
|
|
f'Host: {host}\r\n'
|
|
'Upgrade: websocket\r\n'
|
|
'Connection: Upgrade\r\n'
|
|
f'Sec-WebSocket-Key: {key}\r\n'
|
|
'Sec-WebSocket-Version: 13\r\n'
|
|
f'Origin: https://{host}\r\n\r\n'
|
|
)
|
|
sock.sendall(req.encode())
|
|
resp = b''
|
|
while b'\r\n\r\n' not in resp:
|
|
chunk = sock.recv(4096)
|
|
if not chunk:
|
|
break
|
|
resp += chunk
|
|
head, rest = resp.split(b'\r\n\r\n', 1)
|
|
if b' 101 ' not in head:
|
|
raise RuntimeError(head.decode(errors='replace'))
|
|
buf = rest
|
|
while len(buf) < 2:
|
|
buf += sock.recv(4096)
|
|
b2 = buf[1]
|
|
length = b2 & 0x7f
|
|
pos = 2
|
|
if length == 126:
|
|
while len(buf) < pos + 2:
|
|
buf += sock.recv(4096)
|
|
length = int.from_bytes(buf[pos:pos+2], 'big'); pos += 2
|
|
elif length == 127:
|
|
while len(buf) < pos + 8:
|
|
buf += sock.recv(4096)
|
|
length = int.from_bytes(buf[pos:pos+8], 'big'); pos += 8
|
|
mask = None
|
|
if b2 >> 7:
|
|
while len(buf) < pos + 4:
|
|
buf += sock.recv(4096)
|
|
mask = buf[pos:pos+4]; pos += 4
|
|
while len(buf) < pos + length:
|
|
buf += sock.recv(4096)
|
|
payload = bytearray(buf[pos:pos+length])
|
|
if mask:
|
|
for i in range(len(payload)):
|
|
payload[i] ^= mask[i % 4]
|
|
return json.loads(payload.decode())
|
|
finally:
|
|
sock.close()
|
|
|
|
def fetch_service_stats():
|
|
data = fetch_json(SERVICE_URL)
|
|
stats = data.get('data', {}).get('cycle_transfer_stats', {}) if data.get('success') else {}
|
|
by_sid = {}
|
|
for item in stats.values():
|
|
names = item.get('server_name') or {}
|
|
transfers = item.get('transfer') or {}
|
|
nexts = item.get('next_update') or {}
|
|
for sid in names:
|
|
try:
|
|
sid_i = int(sid)
|
|
except Exception:
|
|
continue
|
|
by_sid[sid_i] = {
|
|
'traffic_name': item.get('name'),
|
|
'cycle_from': item.get('from'),
|
|
'cycle_to': item.get('to'),
|
|
'traffic_max': item.get('max'),
|
|
'traffic_used': transfers.get(sid),
|
|
'next_update': nexts.get(sid),
|
|
}
|
|
return by_sid
|
|
|
|
def main():
|
|
if not PANEL:
|
|
raise SystemExit('NEZHA_URL is not configured')
|
|
try:
|
|
source_servers = fetch_admin_servers()
|
|
source = f'nezha:{PANEL} admin api + service api; pruned absent servers'
|
|
now = int(time.time() * 1000)
|
|
online = None
|
|
except Exception as e:
|
|
print(f'admin api failed, falling back to public websocket: {e}')
|
|
ws = ws_read_one()
|
|
source_servers = ws.get('servers', [])
|
|
source = f'nezha:{PANEL} public websocket + service api; pruned absent servers'
|
|
now = ws.get('now')
|
|
online = ws.get('online')
|
|
|
|
traffic = fetch_service_stats()
|
|
if OUT.exists():
|
|
backup = OUT.with_name(f'{OUT.name}.bak-{datetime.now().strftime("%Y%m%d-%H%M%S")}')
|
|
shutil.copy2(OUT, backup)
|
|
|
|
old_by_id = {}
|
|
if OUT.exists():
|
|
try:
|
|
old_data = json.loads(OUT.read_text() or '{}')
|
|
old_by_id = {int(x['nezha_id']): x for x in old_data.get('servers', []) if x.get('nezha_id') is not None}
|
|
except Exception:
|
|
old_by_id = {}
|
|
|
|
servers = []
|
|
online_count = 0
|
|
for s in source_servers:
|
|
sid = int(s['id'])
|
|
host = s.get('host') or {}
|
|
state = s.get('state') or {}
|
|
geoip = s.get('geoip') or {}
|
|
ip = geoip.get('ip') or {}
|
|
if s.get('last_active'):
|
|
online_count += 1
|
|
old = old_by_id.get(sid, {})
|
|
old_ssh = old.get('ssh') or {}
|
|
item = {
|
|
'name': s.get('name'),
|
|
'host': ip.get('ipv4_addr'),
|
|
'ipv6': ip.get('ipv6_addr'),
|
|
'port': old_ssh.get('port') or old.get('port') or DEFAULT_PORT,
|
|
'user': old_ssh.get('user') or old.get('user') or DEFAULT_USER,
|
|
'key': old_ssh.get('key') or old.get('key') or DEFAULT_KEY,
|
|
'role': 'nezha',
|
|
'risk': 'normal',
|
|
'source': f'nezha:{PANEL}',
|
|
'nezha_id': sid,
|
|
'country': geoip.get('country_code') or s.get('country_code'),
|
|
'platform': host.get('platform'),
|
|
'platform_version': host.get('platform_version'),
|
|
'arch': host.get('arch'),
|
|
'virtualization': host.get('virtualization'),
|
|
'cpu': ', '.join(host.get('cpu') or []),
|
|
'mem_total': host.get('mem_total'),
|
|
'disk_total': host.get('disk_total'),
|
|
'display_index': s.get('display_index'),
|
|
'last_active': s.get('last_active'),
|
|
'uuid': s.get('uuid'),
|
|
'public_note': s.get('public_note'),
|
|
'state': state,
|
|
}
|
|
password = old_ssh.get('password') or old.get('password')
|
|
auth = old_ssh.get('auth') or old.get('auth')
|
|
if password:
|
|
item['password'] = password
|
|
if auth:
|
|
item['auth'] = auth
|
|
aliases = ALIASES.get(str(sid)) or ALIASES.get(sid)
|
|
if aliases:
|
|
item['aliases'] = aliases
|
|
item.update(traffic.get(sid, {}))
|
|
servers.append(item)
|
|
|
|
data = {
|
|
'updated_at': now_iso(),
|
|
'source': source,
|
|
'now': now,
|
|
'online': online if online is not None else online_count,
|
|
'servers': servers,
|
|
}
|
|
OUT.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
|
print(f'updated {OUT}: {len(servers)} servers')
|
|
for s in servers:
|
|
print(f"{s['nezha_id']:>3} {s['name']:<18} {s.get('host') or '<no-host>':<15} {s.get('last_active')}")
|
|
|
|
if __name__ == '__main__':
|
|
main()
|