161 lines
5.4 KiB
Python
161 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
XBPS caching HTTP reverse proxy.
|
|
Cache-first, web fallback. Caches .xbps packages forever; never caches
|
|
-repodata files (must stay fresh for xbps to see new package versions).
|
|
|
|
Usage:
|
|
XBPS_CACHE_DIR=./cache/xbps-pkgs \\
|
|
XBPS_UPSTREAM=https://repo-default.voidlinux.org \\
|
|
XBPS_PROXY_PORT=3142 \\
|
|
python3 tools/xbps-proxy.py
|
|
"""
|
|
import http.server
|
|
import socket
|
|
import time
|
|
import urllib.request
|
|
import urllib.error
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Force IPv4 only — many hosts have no working IPv6 route to voidlinux mirrors,
|
|
# which surfaces as `[Errno 101] Network is unreachable` mid-download. Pin
|
|
# socket.getaddrinfo to AF_INET so urllib never tries an AAAA record.
|
|
_orig_getaddrinfo = socket.getaddrinfo
|
|
|
|
|
|
def _ipv4_only(host, port, family=0, type=0, proto=0, flags=0): # noqa: A002
|
|
return _orig_getaddrinfo(host, port, socket.AF_INET, type, proto, flags)
|
|
|
|
|
|
socket.getaddrinfo = _ipv4_only
|
|
|
|
UPSTREAM = os.environ.get("XBPS_UPSTREAM", "https://repo-default.voidlinux.org")
|
|
CACHE_DIR = Path(os.environ.get("XBPS_CACHE_DIR", "./cache/xbps-pkgs")).resolve()
|
|
PORT = int(os.environ.get("XBPS_PROXY_PORT", "3142"))
|
|
BIND = os.environ.get("XBPS_PROXY_BIND", "0.0.0.0")
|
|
|
|
# Suffixes that must always be fetched fresh (small metadata files).
|
|
ALWAYS_FRESH = ("-repodata",)
|
|
|
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def should_cache(path: str) -> bool:
|
|
for suffix in ALWAYS_FRESH:
|
|
if path.endswith(suffix):
|
|
return False
|
|
return True
|
|
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
|
def do_GET(self):
|
|
self._handle(head_only=False)
|
|
|
|
def do_HEAD(self):
|
|
self._handle(head_only=True)
|
|
|
|
def _handle(self, head_only: bool):
|
|
path = self.path.lstrip("/")
|
|
if not path:
|
|
self.send_error(404, "empty path")
|
|
return
|
|
fpath = CACHE_DIR / path
|
|
hit = should_cache(path) and fpath.is_file()
|
|
|
|
if hit:
|
|
self._serve_cached(fpath, head_only)
|
|
print(f"[HIT ] {path}", flush=True)
|
|
return
|
|
|
|
upstream_url = f"{UPSTREAM}/{path}"
|
|
print(f"[MISS] {path} <- {upstream_url}", flush=True)
|
|
last_exc = None
|
|
for attempt in range(3):
|
|
try:
|
|
self._fetch_upstream(path, fpath, upstream_url, head_only)
|
|
return
|
|
except urllib.error.HTTPError as exc:
|
|
print(f"[ERR ] {path} HTTP {exc.code}", flush=True)
|
|
try:
|
|
self.send_error(exc.code, exc.reason)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return
|
|
except (urllib.error.URLError, OSError) as exc:
|
|
last_exc = exc
|
|
if attempt < 2:
|
|
print(f"[WARN] {path} attempt {attempt + 1} failed: {exc}; retrying", flush=True)
|
|
time.sleep(1 + attempt)
|
|
print(f"[ERR ] {path} {last_exc}", flush=True)
|
|
try:
|
|
self.send_error(503, str(last_exc))
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
def _fetch_upstream(self, path, fpath, upstream_url, head_only):
|
|
req = urllib.request.Request(upstream_url)
|
|
req.get_method = lambda: "HEAD" if head_only else "GET"
|
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
ctype = resp.headers.get("Content-Type", "application/octet-stream")
|
|
clen = resp.headers.get("Content-Length")
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", ctype)
|
|
if clen:
|
|
self.send_header("Content-Length", clen)
|
|
self.end_headers()
|
|
if head_only:
|
|
return
|
|
|
|
cache_this = should_cache(path)
|
|
tmp = None
|
|
tmp_fh = None
|
|
if cache_this:
|
|
fpath.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = fpath.with_suffix(fpath.suffix + ".tmp")
|
|
tmp_fh = open(tmp, "wb")
|
|
try:
|
|
while True:
|
|
chunk = resp.read(64 * 1024)
|
|
if not chunk:
|
|
break
|
|
self.wfile.write(chunk)
|
|
if tmp_fh is not None:
|
|
tmp_fh.write(chunk)
|
|
finally:
|
|
if tmp_fh is not None:
|
|
tmp_fh.close()
|
|
if cache_this and tmp is not None and tmp.exists() and not fpath.is_dir():
|
|
tmp.rename(fpath)
|
|
|
|
def _serve_cached(self, fpath: Path, head_only: bool):
|
|
size = fpath.stat().st_size
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/octet-stream")
|
|
self.send_header("Content-Length", str(size))
|
|
self.end_headers()
|
|
if head_only:
|
|
return
|
|
with open(fpath, "rb") as fh:
|
|
while True:
|
|
chunk = fh.read(64 * 1024)
|
|
if not chunk:
|
|
break
|
|
self.wfile.write(chunk)
|
|
|
|
# Silence default access log — we do our own.
|
|
def log_message(self, *_):
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
server = http.server.ThreadingHTTPServer((BIND, PORT), Handler)
|
|
print(f"XBPS proxy : {BIND}:{PORT} -> {UPSTREAM}", flush=True)
|
|
print(f"Cache dir : {CACHE_DIR}", flush=True)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\nProxy stopped.", flush=True)
|