feat: initial commit — void-installer multi-profile (stable-cinnamon + mainline-niri)
This commit is contained in:
35
tools/start-xbps-proxy.sh
Executable file
35
tools/start-xbps-proxy.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Start the XBPS caching proxy in the background.
|
||||
# Idempotent: does nothing if already running.
|
||||
set -Eeuo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CACHE_DIR="${XBPS_CACHE_DIR:-$PROJECT_DIR/cache/xbps-pkgs}"
|
||||
PORT="${XBPS_PROXY_PORT:-3142}"
|
||||
PIDFILE="${XBPS_PROXY_PIDFILE:-$PROJECT_DIR/out/xbps-proxy.pid}"
|
||||
LOGFILE="${XBPS_PROXY_LOG:-$PROJECT_DIR/out/xbps-proxy.log}"
|
||||
|
||||
mkdir -p "$(dirname "$PIDFILE")" "$CACHE_DIR"
|
||||
|
||||
# Already running?
|
||||
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||
echo ">>> XBPS proxy already running (pid $(cat "$PIDFILE"), port $PORT)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
XBPS_CACHE_DIR="$CACHE_DIR" \
|
||||
XBPS_PROXY_PORT="$PORT" \
|
||||
python3 "$PROJECT_DIR/tools/xbps-proxy.py" > "$LOGFILE" 2>&1 &
|
||||
echo $! > "$PIDFILE"
|
||||
|
||||
# Wait for the socket to open (up to 5 s).
|
||||
for i in $(seq 1 20); do
|
||||
if curl -sf --max-time 1 "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
|
||||
echo ">>> XBPS proxy started (pid $(cat "$PIDFILE"), port $PORT)"
|
||||
echo " cache : $CACHE_DIR"
|
||||
echo " log : $LOGFILE"
|
||||
19
tools/stop-xbps-proxy.sh
Executable file
19
tools/stop-xbps-proxy.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Stop the XBPS caching proxy.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PIDFILE="${XBPS_PROXY_PIDFILE:-$PROJECT_DIR/out/xbps-proxy.pid}"
|
||||
|
||||
if [[ -f "$PIDFILE" ]]; then
|
||||
PID="$(cat "$PIDFILE")"
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
kill "$PID"
|
||||
echo ">>> XBPS proxy stopped (pid $PID)"
|
||||
else
|
||||
echo ">>> XBPS proxy not running (stale pidfile)"
|
||||
fi
|
||||
rm -f "$PIDFILE"
|
||||
else
|
||||
echo ">>> no XBPS proxy pidfile found"
|
||||
fi
|
||||
160
tools/xbps-proxy.py
Normal file
160
tools/xbps-proxy.py
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user