mirror of
https://github.com//cppla/ServerStatus
synced 2026-08-06 11:13:56 +08:00
622 lines
24 KiB
Python
622 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
import json
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import tempfile
|
|
import time
|
|
import errno
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from urllib.parse import unquote, urlparse
|
|
|
|
|
|
CONFIG_PATH = os.environ.get("CONFIG_PATH", "/ServerStatus/server/config.json")
|
|
STATS_PATH = os.environ.get("STATS_PATH", "/usr/share/nginx/html/json/stats.json")
|
|
SERGATE_PID_FILE = os.environ.get("SERGATE_PID_FILE", "/tmp/serverstatus-sergate.pid")
|
|
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")
|
|
API_BIND = os.environ.get("ADMIN_API_BIND", "127.0.0.1")
|
|
API_PORT = int(os.environ.get("ADMIN_API_PORT", "35602"))
|
|
CORS_ORIGIN = os.environ.get("ADMIN_CORS_ORIGIN", "")
|
|
|
|
|
|
class ApiError(Exception):
|
|
def __init__(self, status, message, details=None):
|
|
super().__init__(message)
|
|
self.status = status
|
|
self.message = message
|
|
self.details = details
|
|
|
|
|
|
def load_config():
|
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def load_json_file(path):
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def load_stats_file():
|
|
try:
|
|
return load_json_file(STATS_PATH)
|
|
except FileNotFoundError:
|
|
return load_json_file(f"{STATS_PATH}~")
|
|
|
|
|
|
def write_json_file(path, data):
|
|
directory = os.path.dirname(path) or "."
|
|
os.makedirs(directory, exist_ok=True)
|
|
mode = os.stat(path).st_mode if os.path.exists(path) else 0o644
|
|
payload = json.dumps(data, ensure_ascii=False, indent="\t") + "\n"
|
|
fd, tmp_path = tempfile.mkstemp(prefix=".json.", suffix=".tmp", dir=directory)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
f.write(payload)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.chmod(tmp_path, mode)
|
|
os.replace(tmp_path, path)
|
|
finally:
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
|
|
|
|
def normalize_required_strings(item, required, kind, index=None):
|
|
missing = [field for field in required if item.get(field) is None or not str(item.get(field, "")).strip()]
|
|
if missing:
|
|
raise ApiError(400, f"{kind} has missing required fields", {"missing": missing, "index": index})
|
|
normalized = dict(item)
|
|
for field in required:
|
|
normalized[field] = str(normalized[field]).strip()
|
|
return normalized
|
|
|
|
|
|
def normalize_optional_string(item, field):
|
|
value = item.get(field, "")
|
|
item[field] = "" if value is None else str(value).strip()
|
|
|
|
|
|
def normalize_int(value, field, index=None, default=None, min_value=None, max_value=None):
|
|
if value in (None, ""):
|
|
value = default
|
|
try:
|
|
value = int(value)
|
|
except (TypeError, ValueError):
|
|
raise ApiError(400, f"{field} must be an integer", {"index": index})
|
|
if min_value is not None:
|
|
value = max(min_value, value)
|
|
if max_value is not None:
|
|
value = min(max_value, value)
|
|
return value
|
|
|
|
|
|
def validate_server(server, index=None):
|
|
if not isinstance(server, dict):
|
|
raise ApiError(400, "server must be an object", {"index": index})
|
|
required = ["username", "name", "type", "host", "location", "password"]
|
|
normalized = normalize_required_strings(server, required, "server", index=index)
|
|
normalized["monthstart"] = normalize_int(normalized.get("monthstart"), "monthstart", index=index, default=1, min_value=1, max_value=28)
|
|
if "disabled" in normalized:
|
|
normalized["disabled"] = bool(normalized["disabled"])
|
|
return normalized
|
|
|
|
|
|
def validate_monitor(monitor, index=None):
|
|
if not isinstance(monitor, dict):
|
|
raise ApiError(400, "monitor must be an object", {"index": index})
|
|
normalized = normalize_required_strings(monitor, ["name", "host", "type"], "monitor", index=index)
|
|
normalized["interval"] = normalize_int(normalized.get("interval"), "interval", index=index, default=600, min_value=1)
|
|
return normalized
|
|
|
|
|
|
def validate_sslcert(sslcert, index=None):
|
|
if not isinstance(sslcert, dict):
|
|
raise ApiError(400, "sslcert must be an object", {"index": index})
|
|
normalized = normalize_required_strings(sslcert, ["name", "domain"], "sslcert", index=index)
|
|
normalized["port"] = normalize_int(normalized.get("port"), "port", index=index, default=443, min_value=1, max_value=65535)
|
|
normalized["interval"] = normalize_int(normalized.get("interval"), "interval", index=index, default=7200, min_value=1)
|
|
normalize_optional_string(normalized, "callback")
|
|
return normalized
|
|
|
|
|
|
def validate_watchdog(watchdog, index=None):
|
|
if not isinstance(watchdog, dict):
|
|
raise ApiError(400, "watchdog must be an object", {"index": index})
|
|
normalized = normalize_required_strings(watchdog, ["name", "rule"], "watchdog", index=index)
|
|
normalized["interval"] = normalize_int(normalized.get("interval"), "interval", index=index, default=600, min_value=1)
|
|
normalize_optional_string(normalized, "callback")
|
|
return normalized
|
|
|
|
|
|
COLLECTIONS = {
|
|
"servers": {
|
|
"item": "server",
|
|
"id_field": "username",
|
|
"validator": validate_server,
|
|
"required": ["username", "name", "type", "host", "location", "password"],
|
|
"optional": ["monthstart", "disabled"],
|
|
},
|
|
"monitors": {
|
|
"item": "monitor",
|
|
"id_field": "name",
|
|
"validator": validate_monitor,
|
|
"required": ["name", "host", "type"],
|
|
"optional": ["interval"],
|
|
},
|
|
"sslcerts": {
|
|
"item": "sslcert",
|
|
"id_field": "name",
|
|
"validator": validate_sslcert,
|
|
"required": ["name", "domain"],
|
|
"optional": ["port", "interval", "callback"],
|
|
},
|
|
"watchdog": {
|
|
"item": "watchdog",
|
|
"id_field": "name",
|
|
"validator": validate_watchdog,
|
|
"required": ["name", "rule"],
|
|
"optional": ["interval", "callback"],
|
|
},
|
|
}
|
|
|
|
|
|
def validate_config(config):
|
|
if not isinstance(config, dict):
|
|
raise ApiError(400, "config must be a JSON object")
|
|
config = dict(config)
|
|
for key, meta in COLLECTIONS.items():
|
|
items = config.get(key, [])
|
|
if not isinstance(items, list):
|
|
raise ApiError(400, f"{key} must be an array")
|
|
normalized = []
|
|
seen = set()
|
|
for index, item in enumerate(items):
|
|
entry = meta["validator"](item, index=index)
|
|
if key == "servers":
|
|
username = entry["username"]
|
|
if username in seen:
|
|
raise ApiError(409, "duplicate server username", {"username": username})
|
|
seen.add(username)
|
|
normalized.append(entry)
|
|
config[key] = normalized
|
|
return config
|
|
|
|
|
|
def write_config(config):
|
|
config = validate_config(config)
|
|
directory = os.path.dirname(CONFIG_PATH) or "."
|
|
os.makedirs(directory, exist_ok=True)
|
|
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
|
if os.path.exists(CONFIG_PATH):
|
|
shutil.copy2(CONFIG_PATH, f"{CONFIG_PATH}.bak-{timestamp}")
|
|
mode = os.stat(CONFIG_PATH).st_mode
|
|
else:
|
|
mode = 0o644
|
|
data = json.dumps(config, ensure_ascii=False, indent="\t") + "\n"
|
|
fd, tmp_path = tempfile.mkstemp(prefix=".config.", suffix=".tmp", dir=directory)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
f.write(data)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.chmod(tmp_path, mode)
|
|
try:
|
|
os.replace(tmp_path, CONFIG_PATH)
|
|
except OSError as exc:
|
|
if exc.errno != errno.EBUSY:
|
|
raise
|
|
# A single-file Docker bind mount cannot be atomically replaced.
|
|
# Keep the backup above, then rewrite the mounted file in place.
|
|
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
|
f.write(data)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
finally:
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
return config
|
|
|
|
|
|
def write_and_reload(config):
|
|
config = write_config(config)
|
|
pid = signal_sergate(signal.SIGHUP)
|
|
return config, pid
|
|
|
|
|
|
def read_body(handler):
|
|
length = int(handler.headers.get("Content-Length", "0") or "0")
|
|
if length <= 0:
|
|
return None
|
|
if length > 1024 * 1024:
|
|
raise ApiError(413, "request body is too large")
|
|
raw = handler.rfile.read(length).decode("utf-8")
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise ApiError(400, "invalid JSON body", {"error": str(exc)})
|
|
|
|
|
|
def get_sergate_pid():
|
|
try:
|
|
with open(SERGATE_PID_FILE, "r", encoding="utf-8") as f:
|
|
pid = int(f.read().strip())
|
|
os.kill(pid, 0)
|
|
return pid
|
|
except Exception:
|
|
pass
|
|
proc_dir = "/proc"
|
|
if not os.path.isdir(proc_dir):
|
|
return None
|
|
for name in os.listdir(proc_dir):
|
|
if not name.isdigit():
|
|
continue
|
|
try:
|
|
with open(os.path.join(proc_dir, name, "cmdline"), "rb") as f:
|
|
cmdline = f.read().replace(b"\x00", b" ").decode("utf-8", "ignore")
|
|
except Exception:
|
|
continue
|
|
if "sergate" in cmdline:
|
|
return int(name)
|
|
return None
|
|
|
|
|
|
def signal_sergate(sig):
|
|
pid = get_sergate_pid()
|
|
if not pid:
|
|
raise ApiError(503, "sergate process was not found")
|
|
os.kill(pid, sig)
|
|
return pid
|
|
|
|
|
|
def wait_for_pid_exit(pid, timeout=2.5):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
try:
|
|
os.kill(pid, 0)
|
|
except OSError:
|
|
return True
|
|
time.sleep(0.05)
|
|
return False
|
|
|
|
|
|
def wait_for_sergate_pid(previous_pid=None, timeout=4.0):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
pid = get_sergate_pid()
|
|
if pid and pid != previous_pid:
|
|
return pid
|
|
time.sleep(0.1)
|
|
return get_sergate_pid()
|
|
|
|
|
|
def find_server(config, username):
|
|
servers = config.get("servers", [])
|
|
for index, server in enumerate(servers):
|
|
if server.get("username") == username:
|
|
return index, server
|
|
return -1, None
|
|
|
|
|
|
def find_collection_item(config, key, item_id):
|
|
items = config.get(key, [])
|
|
if item_id.isdigit():
|
|
index = int(item_id)
|
|
if 0 <= index < len(items):
|
|
return index, items[index]
|
|
return -1, None
|
|
id_field = COLLECTIONS[key]["id_field"]
|
|
matches = [(index, item) for index, item in enumerate(items) if str(item.get(id_field, "")) == item_id]
|
|
if len(matches) > 1:
|
|
raise ApiError(409, f"{key} has duplicate {id_field}; use numeric index instead", {"id": item_id})
|
|
if matches:
|
|
return matches[0]
|
|
return -1, None
|
|
|
|
|
|
def stats_server_matches(config_server, stats_server):
|
|
return all(str(stats_server.get(field, "")) == str(config_server.get(field, "")) for field in ["name", "type", "host", "location"])
|
|
|
|
|
|
def find_stats_server(stats, config_server):
|
|
servers = stats.get("servers", [])
|
|
if not isinstance(servers, list):
|
|
raise ApiError(500, "stats.json has invalid servers data")
|
|
for index, stats_server in enumerate(servers):
|
|
if isinstance(stats_server, dict) and stats_server_matches(config_server, stats_server):
|
|
return index, stats_server
|
|
return -1, None
|
|
|
|
|
|
def as_counter(value, field):
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
raise ApiError(409, f"{field} is missing or invalid in stats.json")
|
|
|
|
|
|
def require_resettable_stats(stats, server, username):
|
|
stats_index, stats_server = find_stats_server(stats, server)
|
|
if stats_index < 0:
|
|
raise ApiError(404, "server stats were not found", {"username": username})
|
|
if "network_in" not in stats_server or "network_out" not in stats_server:
|
|
raise ApiError(409, "server has no current traffic counters; it may be offline", {"username": username})
|
|
return stats_index, stats_server
|
|
|
|
|
|
def reset_server_month_traffic(username):
|
|
config = load_config()
|
|
_, server = find_server(config, username)
|
|
if server is None:
|
|
raise ApiError(404, "server was not found", {"username": username})
|
|
|
|
require_resettable_stats(load_stats_file(), server, username)
|
|
|
|
old_pid = signal_sergate(signal.SIGTERM)
|
|
wait_for_pid_exit(old_pid)
|
|
|
|
stats = load_stats_file()
|
|
stats_index, stats_server = require_resettable_stats(stats, server, username)
|
|
|
|
network_in = as_counter(stats_server.get("network_in"), "network_in")
|
|
network_out = as_counter(stats_server.get("network_out"), "network_out")
|
|
previous_last_in = as_counter(stats_server.get("last_network_in", 0), "last_network_in")
|
|
previous_last_out = as_counter(stats_server.get("last_network_out", 0), "last_network_out")
|
|
|
|
stats["servers"][stats_index]["last_network_in"] = network_in
|
|
stats["servers"][stats_index]["last_network_out"] = network_out
|
|
stats["updated"] = str(int(time.time()))
|
|
write_json_file(STATS_PATH, stats)
|
|
|
|
new_pid = wait_for_sergate_pid(previous_pid=old_pid)
|
|
if new_pid:
|
|
os.kill(new_pid, signal.SIGHUP)
|
|
|
|
return {
|
|
"server": server,
|
|
"stats": {
|
|
"network_in": network_in,
|
|
"network_out": network_out,
|
|
"previous_last_network_in": previous_last_in,
|
|
"previous_last_network_out": previous_last_out,
|
|
"last_network_in": network_in,
|
|
"last_network_out": network_out,
|
|
"month_in_before": max(0, network_in - previous_last_in),
|
|
"month_out_before": max(0, network_out - previous_last_out),
|
|
},
|
|
"oldPid": old_pid,
|
|
"pid": new_pid,
|
|
}
|
|
|
|
|
|
def collection_routes():
|
|
endpoints = []
|
|
for key in ["monitors", "sslcerts", "watchdog"]:
|
|
endpoints.extend([
|
|
{"method": "GET", "path": f"/api/{key}", "auth": True},
|
|
{"method": "POST", "path": f"/api/{key}", "auth": True, "body": f"{COLLECTIONS[key]['item']} JSON"},
|
|
{"method": "PUT", "path": f"/api/{key}/{{index-or-name}}", "auth": True, "body": f"{COLLECTIONS[key]['item']} JSON"},
|
|
{"method": "DELETE", "path": f"/api/{key}/{{index-or-name}}", "auth": True},
|
|
])
|
|
return endpoints
|
|
|
|
|
|
def api_schema():
|
|
return {
|
|
"auth": {
|
|
"type": "bearer",
|
|
"header": "Authorization: Bearer <ADMIN_TOKEN>",
|
|
"enabled": bool(ADMIN_TOKEN),
|
|
},
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/health", "auth": False},
|
|
{"method": "GET", "path": "/api/schema", "auth": False},
|
|
{"method": "GET", "path": "/api/config", "auth": True},
|
|
{"method": "PUT", "path": "/api/config", "auth": True, "body": "full config JSON"},
|
|
{"method": "GET", "path": "/api/servers", "auth": True},
|
|
{"method": "POST", "path": "/api/servers", "auth": True, "body": "server JSON"},
|
|
{"method": "PUT", "path": "/api/servers/{username}", "auth": True, "body": "server JSON"},
|
|
{"method": "DELETE", "path": "/api/servers/{username}", "auth": True},
|
|
{"method": "POST", "path": "/api/servers/{username}/reset-traffic", "auth": True},
|
|
*collection_routes(),
|
|
{"method": "POST", "path": "/api/reload", "auth": True},
|
|
{"method": "POST", "path": "/api/restart", "auth": True},
|
|
],
|
|
"collections": {
|
|
key: {
|
|
"item": meta["item"],
|
|
"idField": meta["id_field"],
|
|
"required": meta["required"],
|
|
"optional": meta["optional"],
|
|
}
|
|
for key, meta in COLLECTIONS.items()
|
|
},
|
|
}
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "ServerStatusManageAPI/1.0"
|
|
|
|
def log_message(self, fmt, *args):
|
|
print("%s - %s" % (self.address_string(), fmt % args), flush=True)
|
|
|
|
def end_headers(self):
|
|
self.send_header("Cache-Control", "no-store")
|
|
if CORS_ORIGIN:
|
|
self.send_header("Access-Control-Allow-Origin", CORS_ORIGIN)
|
|
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Admin-Token")
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
super().end_headers()
|
|
|
|
def send_json(self, status, payload):
|
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def send_error_json(self, err):
|
|
payload = {"ok": False, "error": err.message}
|
|
if err.details is not None:
|
|
payload["details"] = err.details
|
|
self.send_json(err.status, payload)
|
|
|
|
def route(self):
|
|
parsed = urlparse(self.path)
|
|
path = parsed.path.rstrip("/") or "/"
|
|
method = self.command.upper()
|
|
if method == "OPTIONS":
|
|
self.send_response(204)
|
|
self.end_headers()
|
|
return
|
|
try:
|
|
if path == "/api/health" and method == "GET":
|
|
pid = get_sergate_pid()
|
|
self.send_json(200, {
|
|
"ok": True,
|
|
"enabled": bool(ADMIN_TOKEN),
|
|
"sergate": {"running": bool(pid), "pid": pid},
|
|
"configPath": CONFIG_PATH,
|
|
})
|
|
return
|
|
if path == "/api/schema" and method == "GET":
|
|
self.send_json(200, {"ok": True, "schema": api_schema()})
|
|
return
|
|
self.require_auth()
|
|
if path == "/api/config":
|
|
if method == "GET":
|
|
self.send_json(200, {"ok": True, "config": load_config()})
|
|
return
|
|
if method == "PUT":
|
|
config = read_body(self)
|
|
config, pid = write_and_reload(config)
|
|
self.send_json(200, {"ok": True, "reloaded": True, "pid": pid, "config": config})
|
|
return
|
|
if path == "/api/servers":
|
|
if method == "GET":
|
|
config = load_config()
|
|
self.send_json(200, {"ok": True, "servers": config.get("servers", [])})
|
|
return
|
|
if method == "POST":
|
|
server = validate_server(read_body(self))
|
|
config = load_config()
|
|
if find_server(config, server["username"])[1] is not None:
|
|
raise ApiError(409, "server username already exists", {"username": server["username"]})
|
|
config.setdefault("servers", []).append(server)
|
|
config, pid = write_and_reload(config)
|
|
self.send_json(201, {"ok": True, "server": server, "reloaded": True, "pid": pid, "config": config})
|
|
return
|
|
if path.startswith("/api/servers/") and path.endswith("/reset-traffic"):
|
|
username = unquote(path[len("/api/servers/"):-len("/reset-traffic")].rstrip("/"))
|
|
if not username:
|
|
raise ApiError(400, "username is required")
|
|
if method == "POST":
|
|
result = reset_server_month_traffic(username)
|
|
self.send_json(200, {"ok": True, "operation": "reset-traffic", **result})
|
|
return
|
|
if path.startswith("/api/servers/"):
|
|
username = unquote(path[len("/api/servers/"):])
|
|
if not username:
|
|
raise ApiError(400, "username is required")
|
|
config = load_config()
|
|
index, existing = find_server(config, username)
|
|
if index < 0:
|
|
raise ApiError(404, "server was not found", {"username": username})
|
|
if method == "PUT":
|
|
server = validate_server(read_body(self))
|
|
if server["username"] != username and find_server(config, server["username"])[1] is not None:
|
|
raise ApiError(409, "server username already exists", {"username": server["username"]})
|
|
config["servers"][index] = server
|
|
config, pid = write_and_reload(config)
|
|
self.send_json(200, {"ok": True, "server": server, "reloaded": True, "pid": pid, "config": config})
|
|
return
|
|
if method == "DELETE":
|
|
removed = config["servers"].pop(index)
|
|
config, pid = write_and_reload(config)
|
|
self.send_json(200, {"ok": True, "removed": removed, "reloaded": True, "pid": pid, "config": config})
|
|
return
|
|
for key in ["monitors", "sslcerts", "watchdog"]:
|
|
base = f"/api/{key}"
|
|
meta = COLLECTIONS[key]
|
|
if path == base:
|
|
if method == "GET":
|
|
config = load_config()
|
|
self.send_json(200, {"ok": True, key: config.get(key, [])})
|
|
return
|
|
if method == "POST":
|
|
item = meta["validator"](read_body(self))
|
|
config = load_config()
|
|
config.setdefault(key, []).append(item)
|
|
config, pid = write_and_reload(config)
|
|
self.send_json(201, {"ok": True, meta["item"]: item, "reloaded": True, "pid": pid, "config": config})
|
|
return
|
|
if path.startswith(base + "/"):
|
|
item_id = unquote(path[len(base) + 1:])
|
|
if not item_id:
|
|
raise ApiError(400, "item id is required")
|
|
config = load_config()
|
|
index, existing = find_collection_item(config, key, item_id)
|
|
if index < 0:
|
|
raise ApiError(404, f"{meta['item']} was not found", {"id": item_id})
|
|
if method == "PUT":
|
|
item = meta["validator"](read_body(self))
|
|
config[key][index] = item
|
|
config, pid = write_and_reload(config)
|
|
self.send_json(200, {"ok": True, meta["item"]: item, "reloaded": True, "pid": pid, "config": config})
|
|
return
|
|
if method == "DELETE":
|
|
removed = config[key].pop(index)
|
|
config, pid = write_and_reload(config)
|
|
self.send_json(200, {"ok": True, "removed": removed, "reloaded": True, "pid": pid, "config": config})
|
|
return
|
|
if path == "/api/reload" and method == "POST":
|
|
pid = signal_sergate(signal.SIGHUP)
|
|
self.send_json(200, {"ok": True, "operation": "reload", "pid": pid})
|
|
return
|
|
if path == "/api/restart" and method == "POST":
|
|
pid = signal_sergate(signal.SIGTERM)
|
|
self.send_json(202, {"ok": True, "operation": "restart", "pid": pid})
|
|
return
|
|
raise ApiError(404, "endpoint was not found")
|
|
except ApiError as err:
|
|
self.send_error_json(err)
|
|
except Exception as exc:
|
|
self.send_error_json(ApiError(500, "internal server error", {"error": str(exc)}))
|
|
|
|
def require_auth(self):
|
|
if not ADMIN_TOKEN:
|
|
raise ApiError(503, "management API is disabled; set ADMIN_TOKEN to enable it")
|
|
auth = self.headers.get("Authorization", "")
|
|
token = ""
|
|
if auth.lower().startswith("bearer "):
|
|
token = auth[7:].strip()
|
|
token = token or self.headers.get("X-Admin-Token", "").strip()
|
|
if token != ADMIN_TOKEN:
|
|
raise ApiError(401, "invalid or missing admin token")
|
|
|
|
def do_GET(self):
|
|
self.route()
|
|
|
|
def do_POST(self):
|
|
self.route()
|
|
|
|
def do_PUT(self):
|
|
self.route()
|
|
|
|
def do_DELETE(self):
|
|
self.route()
|
|
|
|
def do_OPTIONS(self):
|
|
self.route()
|
|
|
|
|
|
def main():
|
|
httpd = ThreadingHTTPServer((API_BIND, API_PORT), Handler)
|
|
print(f"manage-api listening on {API_BIND}:{API_PORT}", flush=True)
|
|
httpd.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|