feat: initial commit of FreeLLMAPI catalog proxy and compose stack
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
FreeLLMAPI Catalog Proxy
|
||||
Transparent caching reverse-proxy for FreeLLMAPI model catalog sync.
|
||||
Fetches signed catalog from https://api.freellmapi.co/v1/latest via SOCKS5 proxy,
|
||||
preserves the official Ed25519 digital signature (x-catalog-signature),
|
||||
and serves it locally to the FreeLLMAPI container.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
CACHE_DIR = os.environ.get("CACHE_DIR", "/data")
|
||||
CONFIG_FILE = os.path.join(CACHE_DIR, "proxy_config.json")
|
||||
|
||||
# Defaults
|
||||
PORT = int(os.environ.get("PORT", "8080"))
|
||||
PROXY_HOST = os.environ.get("PROXY_HOST", "77.239.127.49")
|
||||
PROXY_PORT = int(os.environ.get("PROXY_PORT", "18322"))
|
||||
PROXY_USER = os.environ.get("PROXY_USER", "")
|
||||
PROXY_PASS = os.environ.get("PROXY_PASS", "")
|
||||
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
with open(CONFIG_FILE, "r") as f:
|
||||
c = json.load(f)
|
||||
PROXY_HOST = c.get("proxy_host", PROXY_HOST)
|
||||
PROXY_PORT = int(c.get("proxy_port", PROXY_PORT))
|
||||
PROXY_USER = c.get("proxy_user", PROXY_USER)
|
||||
PROXY_PASS = c.get("proxy_pass", PROXY_PASS)
|
||||
print("[proxy] Loaded credentials from proxy_config.json")
|
||||
except Exception as e:
|
||||
print(f"[proxy] Error loading config: {e}")
|
||||
|
||||
CACHE_FILE = os.path.join(CACHE_DIR, "catalog_cache.json")
|
||||
SIG_FILE = os.path.join(CACHE_DIR, "catalog_cache.sig")
|
||||
META_FILE = os.path.join(CACHE_DIR, "catalog_cache.meta")
|
||||
CACHE_TTL = int(os.environ.get("CACHE_TTL", "43200")) # 12 hours
|
||||
|
||||
|
||||
def fetch_upstream(path, timeout=25):
|
||||
s = socket.create_connection((PROXY_HOST, PROXY_PORT), timeout=timeout)
|
||||
s.sendall(b"\x05\x01\x02")
|
||||
res = s.recv(2)
|
||||
if res != b"\x05\x02":
|
||||
raise RuntimeError(f"SOCKS5 auth choice failed: {res}")
|
||||
auth_payload = (
|
||||
b"\x01"
|
||||
+ bytes([len(PROXY_USER)])
|
||||
+ PROXY_USER.encode()
|
||||
+ bytes([len(PROXY_PASS)])
|
||||
+ PROXY_PASS.encode()
|
||||
)
|
||||
s.sendall(auth_payload)
|
||||
res = s.recv(2)
|
||||
if res != b"\x01\x00":
|
||||
raise RuntimeError(f"SOCKS5 auth rejected: {res}")
|
||||
target_host = b"api.freellmapi.co"
|
||||
req = b"\x05\x01\x00\x03" + bytes([len(target_host)]) + target_host + (443).to_bytes(2, "big")
|
||||
s.sendall(req)
|
||||
res = s.recv(4)
|
||||
if res[1] != 0:
|
||||
raise RuntimeError(f"SOCKS5 connect error code: {res[1]}")
|
||||
if res[3] == 0x01:
|
||||
s.recv(6)
|
||||
elif res[3] == 0x03:
|
||||
s.recv(s.recv(1)[0] + 2)
|
||||
elif res[3] == 0x04:
|
||||
s.recv(18)
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ss = ctx.wrap_socket(s, server_hostname="api.freellmapi.co")
|
||||
http_req = (
|
||||
f"GET {path} HTTP/1.1\r\n"
|
||||
f"Host: api.freellmapi.co\r\n"
|
||||
f"User-Agent: freellmapi-catalog-proxy/1.0\r\n"
|
||||
f"Connection: close\r\n\r\n"
|
||||
).encode()
|
||||
ss.sendall(http_req)
|
||||
|
||||
chunks = []
|
||||
while True:
|
||||
data = ss.recv(65536)
|
||||
if not data:
|
||||
break
|
||||
chunks.append(data)
|
||||
ss.close()
|
||||
raw = b"".join(chunks)
|
||||
header_data, body = raw.split(b"\r\n\r\n", 1)
|
||||
status_line = header_data.split(b"\r\n")[0].decode("latin1")
|
||||
status_code = int(status_line.split()[1])
|
||||
headers = {}
|
||||
for line in header_data.decode("latin1").split("\r\n")[1:]:
|
||||
if ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
headers[k.strip().lower()] = v.strip()
|
||||
return status_code, headers, body
|
||||
|
||||
|
||||
def get_catalog(force=False):
|
||||
now = time.time()
|
||||
if not force and os.path.exists(CACHE_FILE) and os.path.exists(SIG_FILE) and os.path.exists(META_FILE):
|
||||
try:
|
||||
with open(META_FILE, "r") as f:
|
||||
meta = json.load(f)
|
||||
if now - meta.get("timestamp", 0) < CACHE_TTL:
|
||||
with open(CACHE_FILE, "rb") as f:
|
||||
body = f.read()
|
||||
with open(SIG_FILE, "r") as f:
|
||||
sig = f.read().strip()
|
||||
return body, sig, meta.get("version", "")
|
||||
except Exception as e:
|
||||
print(f"[proxy] Cache read error: {e}")
|
||||
|
||||
try:
|
||||
print("[proxy] Fetching fresh catalog from upstream...")
|
||||
code, hdrs, body = fetch_upstream("/v1/latest")
|
||||
if code == 200 and "x-catalog-signature" in hdrs and len(body) > 100000:
|
||||
sig = hdrs["x-catalog-signature"]
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
version = parsed.get("version", "")
|
||||
except Exception:
|
||||
version = ""
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
with open(CACHE_FILE, "wb") as f:
|
||||
f.write(body)
|
||||
with open(SIG_FILE, "w") as f:
|
||||
f.write(sig)
|
||||
with open(META_FILE, "w") as f:
|
||||
json.dump({"timestamp": now, "version": version, "size": len(body)}, f)
|
||||
for path in [CACHE_FILE, SIG_FILE, META_FILE]:
|
||||
try:
|
||||
os.chmod(path, 0o666)
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[proxy] Cached catalog v{version} ({len(body)} bytes)")
|
||||
return body, sig, version
|
||||
else:
|
||||
raise RuntimeError(f"Upstream returned status {code}, headers: {hdrs}")
|
||||
except Exception as e:
|
||||
print(f"[proxy] Fetch upstream failed: {e}")
|
||||
if os.path.exists(CACHE_FILE) and os.path.exists(SIG_FILE):
|
||||
print("[proxy] Falling back to existing cached catalog")
|
||||
with open(CACHE_FILE, "rb") as f:
|
||||
body = f.read()
|
||||
with open(SIG_FILE, "r") as f:
|
||||
sig = f.read().strip()
|
||||
return body, sig, "cached-fallback"
|
||||
raise
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_HEAD(self):
|
||||
if self.path.startswith("/v1/latest"):
|
||||
try:
|
||||
body, sig, ver = get_catalog()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("x-catalog-signature", sig)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
except Exception as e:
|
||||
self.send_response(502)
|
||||
self.end_headers()
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/v1/latest"):
|
||||
try:
|
||||
body, sig, ver = get_catalog()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("x-catalog-signature", sig)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
print(f"[proxy] Served catalog v{ver} ({len(body)} bytes) to {self.client_address[0]}")
|
||||
except Exception as e:
|
||||
self.send_response(502)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"error": str(e)}).encode())
|
||||
elif self.path in ("/health", "/ping", "/"):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
has_cache = os.path.exists(CACHE_FILE)
|
||||
self.wfile.write(json.dumps({"status": "ok", "cached": has_cache}).encode())
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f"[proxy] {self.address_string()} - {format % args}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"[proxy] Starting catalog-proxy on 0.0.0.0:{PORT}...")
|
||||
try:
|
||||
get_catalog()
|
||||
except Exception as e:
|
||||
print(f"[proxy] Initial catalog fetch warning: {e}")
|
||||
server = HTTPServer(("0.0.0.0", PORT), Handler)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
Reference in New Issue
Block a user