Specs stay at repo root (cross-VM). Move deploy and code into logical projects with README per domain, updated manifest.yaml, and symlinks at legacy paths for VM122 backward compatibility.
150 lines
5.4 KiB
Python
Executable file
150 lines
5.4 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""OpenPanel Community → FOSSBilling API bridge (opencli backend). LAN only."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from urllib.parse import urlparse
|
|
|
|
HOST = os.environ.get("BRIDGE_HOST", "0.0.0.0")
|
|
PORT = int(os.environ.get("BRIDGE_PORT", "18087"))
|
|
ADMIN_USER = os.environ.get("BRIDGE_ADMIN_USER", "ligboxadmin")
|
|
ADMIN_PASS = os.environ.get("BRIDGE_ADMIN_PASS", "LbOpen805353")
|
|
TOKEN = os.environ.get("BRIDGE_TOKEN", "ligbox-community-bridge-token")
|
|
|
|
|
|
def run_opencli(*args: str, timeout: int = 120) -> tuple[int, str, str]:
|
|
cmd = ["opencli", *args]
|
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "OpenPanelCommunityBridge/1.0"
|
|
|
|
def log_message(self, fmt: str, *args) -> None:
|
|
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
|
|
|
def _read_json(self) -> dict:
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
if not length:
|
|
return {}
|
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
|
|
|
def _send(self, code: int, payload: dict) -> None:
|
|
body = json.dumps(payload).encode("utf-8")
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _auth_ok(self) -> bool:
|
|
auth = self.headers.get("Authorization", "")
|
|
if auth == f"Bearer {TOKEN}":
|
|
return True
|
|
return False
|
|
|
|
def do_POST(self) -> None:
|
|
path = urlparse(self.path).path.rstrip("/") or "/"
|
|
if path == "/api":
|
|
data = self._read_json()
|
|
if data.get("username") == ADMIN_USER and data.get("password") == ADMIN_PASS:
|
|
self._send(200, {"access_token": TOKEN})
|
|
else:
|
|
self._send(401, {"error": "Invalid credentials"})
|
|
return
|
|
|
|
if not self._auth_ok():
|
|
self._send(401, {"error": "Unauthorized"})
|
|
return
|
|
|
|
if path == "/api/users":
|
|
data = self._read_json()
|
|
username = data.get("username", "")
|
|
password = data.get("password", "")
|
|
email = data.get("email", "")
|
|
plan = data.get("plan_name", "ligbox-site-cms")
|
|
if not re.fullmatch(r"[a-z][a-z0-9]{2,15}", username):
|
|
self._send(400, {"success": False, "error": f"Invalid username: {username}"})
|
|
return
|
|
code, out, err = run_opencli(
|
|
"user-add", username, password, email, plan, "--no-sentinel"
|
|
)
|
|
msg = out or err
|
|
if code == 0 or "Successfully added user" in msg:
|
|
self._send(200, {
|
|
"success": True,
|
|
"response": {"message": msg or f"Successfully added user {username}"},
|
|
})
|
|
else:
|
|
self._send(500, {"success": False, "error": msg})
|
|
return
|
|
|
|
self._send(404, {"error": "This api route does not exist."})
|
|
|
|
def do_GET(self) -> None:
|
|
path = urlparse(self.path).path.rstrip("/") or "/"
|
|
if path == "/api":
|
|
if not self._auth_ok():
|
|
self._send(401, {"error": "Unauthorized"})
|
|
return
|
|
self._send(200, {"message": "API is working!"})
|
|
return
|
|
self._send(404, {"error": "This api route does not exist."})
|
|
|
|
def do_PATCH(self) -> None:
|
|
if not self._auth_ok():
|
|
self._send(401, {"error": "Unauthorized"})
|
|
return
|
|
path = urlparse(self.path).path
|
|
m = re.match(r"^/api/users/([a-z0-9]+)$", path)
|
|
if not m:
|
|
self._send(404, {"error": "This api route does not exist."})
|
|
return
|
|
username = m.group(1)
|
|
data = self._read_json()
|
|
action = data.get("action")
|
|
if action == "suspend":
|
|
code, out, err = run_opencli("user-suspend", username)
|
|
elif action == "unsuspend":
|
|
code, out, err = run_opencli("user-unsuspend", username)
|
|
elif "password" in data:
|
|
code, out, err = run_opencli("user-password", username, data["password"])
|
|
else:
|
|
self._send(400, {"success": False, "error": "Unknown action"})
|
|
return
|
|
if code == 0:
|
|
self._send(200, {"success": True})
|
|
else:
|
|
self._send(500, {"success": False, "error": out or err})
|
|
|
|
def do_DELETE(self) -> None:
|
|
if not self._auth_ok():
|
|
self._send(401, {"error": "Unauthorized"})
|
|
return
|
|
path = urlparse(self.path).path
|
|
m = re.match(r"^/api/users/([a-z0-9]+)$", path)
|
|
if not m:
|
|
self._send(404, {"error": "This api route does not exist."})
|
|
return
|
|
username = m.group(1)
|
|
code, out, err = run_opencli("user-delete", username, "-y")
|
|
if code == 0:
|
|
self._send(200, {"success": True})
|
|
else:
|
|
self._send(500, {"success": False, "error": out or err})
|
|
|
|
|
|
def main() -> None:
|
|
httpd = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
print(f"OpenPanel Community bridge on http://{HOST}:{PORT}", flush=True)
|
|
httpd.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|