commit a67cc3fef7605abd36956446c691c7b9a4c40dcc Author: ai_coder Date: Sat Oct 24 10:41:15 2026 +0000 feat: initial commit of FreeLLMAPI catalog proxy and compose stack diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..60645bd --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# FreeLLMAPI configuration +ENCRYPTION_KEY=your_generated_hex_key_here +HOST_IP=192.168.1.101 +DATA_DIR=/sata/freellmapi/data + +# SOCKS5 Proxy settings for catalog sync +PROXY_HOST=77.239.127.49 +PROXY_PORT=18322 +PROXY_USER=omni +PROXY_PASS=your_socks5_password_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e022ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +proxy_config.json +catalog_cache.* +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0c00f2e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.13-slim +WORKDIR /app +COPY catalog_proxy.py /app/catalog_proxy.py +ENV PORT=8080 CACHE_DIR=/data +VOLUME ["/data"] +EXPOSE 8080 +CMD ["python", "-u", "/app/catalog_proxy.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f597c17 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# FreeLLMAPI Catalog Proxy + +Прозрачный кэширующий реверс-прокси для доставки оригинального официального каталога моделей FreeLLMAPI (`api.freellmapi.co/v1/latest`). + +## Зачем это нужно + +FreeLLMAPI не запрашивает список моделей напрямую у сторонних провайдеров (кроме LongCat и SiliconFlow). Для остальных (Routeway, UnoRouter, BazaarLink, OrcaRouter и др.) список моделей подгружается из внешнего подписанного каталога: +`https://api.freellmapi.co/v1/latest` (~560 КБ). + +При прямой загрузке на российских каналах передача файла зависает после первых ~20 КБ из-за DPI/фильтрации больших ответов. Контейнер FreeLLMAPI падает по таймауту, и в базе остаются только предзашитые базовые модели, а добавленные ключи получают пустой список моделей. + +## Как это работает + +1. Прокси подключается к `https://api.freellmapi.co/v1/latest` через стабильный SOCKS5-маршрут (Германия). +2. Забирает **оригинальные байты** и официальный криптографический заголовок `x-catalog-signature` (Ed25519). +3. Кэширует файл локально на 12 часов. +4. Отдаёт каталог контейнеру FreeLLMAPI по локальной сети (`http://catalog-proxy:8080/v1/latest`). +5. FreeLLMAPI штатно верифицирует оригинальную цифровую подпись Ed25519 (`PINNED_CATALOG_PUBKEY`) и применяет актуальные модели. + +## Запуск в Docker Compose + +1. Скопируйте `.env.example` в `.env` и укажите переменные: + ```bash + cp .env.example .env + ``` +2. Запустите стек: + ```bash + docker compose up -d + ``` +3. Проверьте логи синхронизации: + ```bash + docker compose logs -f freellmapi + ``` diff --git a/catalog_proxy.py b/catalog_proxy.py new file mode 100644 index 0000000..05ea487 --- /dev/null +++ b/catalog_proxy.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e1ca4c8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + freellmapi: + image: ghcr.io/tashfeenahmed/freellmapi:latest + container_name: freellmapi + environment: + NODE_ENV: production + PORT: "3001" + ENCRYPTION_KEY: ${ENCRYPTION_KEY:?required} + CATALOG_BASE_URL: "http://catalog-proxy:8080" + ports: + - "${HOST_IP:-192.168.1.101}:3002:3001" + volumes: + - ${DATA_DIR:-/sata/freellmapi/data}:/app/server/data + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + depends_on: + - catalog-proxy + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/api/ping').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + + catalog-proxy: + image: python:3.13-slim + container_name: freellmapi-catalog-proxy + restart: unless-stopped + volumes: + - ${DATA_DIR:-/sata/freellmapi/data}:/data + environment: + PORT: "8080" + PROXY_HOST: "${PROXY_HOST:-77.239.127.49}" + PROXY_PORT: "${PROXY_PORT:-18322}" + PROXY_USER: "${PROXY_USER:-}" + PROXY_PASS: "${PROXY_PASS:-}" + command: ["python", "-u", "/data/catalog_proxy.py"]