TutorialMaker / tools /home_proxy_panel.py
vivekchakraverty's picture
Per-user residential proxy: proxy URL field + downloadable Home Proxy Panel
12e4183
Raw
History Blame Contribute Delete
19.1 kB
"""Home-tunnel proxy control panel for the TutorialMaker Space.
Runs a local authenticated HTTP forward proxy on your machine (your residential IP) and
exposes it to the internet through a RAW TCP tunnel (ngrok / bore), so the Hugging Face
Space can route youtube-transcript-api + yt-dlp through your home IP and dodge the
datacenter-IP block. One-click sets the resulting URL as the Space's YT_PROXY secret.
Why a TCP tunnel: a forward proxy speaks HTTP CONNECT, which only survives a transparent
TCP tunnel. HTTP reverse tunnels (Cloudflare quick-tunnel, Tailscale Funnel) do NOT work
for this — don't use them here.
Run: python tools/home_proxy_panel.py
Deps: pip install proxy.py huggingface_hub requests (ngrok or bore on PATH for tunneling)
"""
from __future__ import annotations
import json
import os
import platform
import queue
import re
import secrets
import shutil
import subprocess
import sys
import tarfile
import threading
import zipfile
CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".tutorialmaker_proxy.json")
DEFAULT_REPO = "vivekchakraverty/TutorialMaker"
_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
# Where to keep a downloaded bore binary. When frozen by PyInstaller sys.frozen is set and
# the binary lives next to the executable; otherwise next to this script.
APP_DIR = os.path.dirname(sys.executable if getattr(sys, "frozen", False)
else os.path.abspath(__file__))
def _bore_target_triple() -> tuple[str, str, str] | None:
"""(rust-triple, archive-ext, binary-name) for the current OS/arch, or None."""
sysname = platform.system()
mach = platform.machine().lower()
is_arm = mach in ("arm64", "aarch64")
if sysname == "Windows":
return ("x86_64-pc-windows-msvc", ".zip", "bore.exe")
if sysname == "Darwin":
triple = "aarch64-apple-darwin" if is_arm else "x86_64-apple-darwin"
return (triple, ".tar.gz", "bore")
if sysname == "Linux":
triple = "aarch64-unknown-linux-musl" if is_arm else "x86_64-unknown-linux-musl"
return (triple, ".tar.gz", "bore")
return None
def ensure_bore(log=lambda m: None) -> str | None:
"""Return a runnable bore path: PATH copy, a previously downloaded one, or a freshly
downloaded release binary for this OS/arch. Returns None if it can't be obtained."""
found = shutil.which("bore")
if found:
return found
tri = _bore_target_triple()
if not tri:
log("[bore] no prebuilt binary for this platform — install bore manually.")
return None
triple, ext, binname = tri
local = os.path.join(APP_DIR, binname)
if os.path.exists(local):
return local
import urllib.request
api = "https://api.github.com/repos/ekzhang/bore/releases/latest"
try:
log("[bore] not found — fetching latest release info…")
req = urllib.request.Request(api, headers={"User-Agent": "tutorialmaker-panel"})
with urllib.request.urlopen(req, timeout=30) as r:
rel = json.load(r)
asset = next((a for a in rel.get("assets", [])
if triple in a["name"] and a["name"].endswith(ext)), None)
if not asset:
log(f"[bore] no asset matching {triple}{ext} in the latest release.")
return None
url = asset["browser_download_url"]
archive = os.path.join(APP_DIR, asset["name"])
log(f"[bore] downloading {asset['name']}…")
dl = urllib.request.Request(url, headers={"User-Agent": "tutorialmaker-panel"})
with urllib.request.urlopen(dl, timeout=120) as resp, open(archive, "wb") as fh:
shutil.copyfileobj(resp, fh)
if ext == ".zip":
with zipfile.ZipFile(archive) as z:
z.extract(binname, APP_DIR)
else:
with tarfile.open(archive) as t:
member = next((m for m in t.getmembers()
if os.path.basename(m.name) == binname), None)
if member is None:
log("[bore] archive did not contain the bore binary.")
return None
member.name = binname
t.extract(member, APP_DIR)
try:
os.remove(archive)
except OSError:
pass
if os.name != "nt":
os.chmod(local, 0o755)
log(f"[bore] ready at {local}")
return local
except Exception as exc:
log(f"[bore] download failed: {type(exc).__name__}: {str(exc)[:160]}")
return None
# ----------------------------------------------------------------- pure helpers (tested)
def build_proxy_url(host: str, port, user: str, pw: str) -> str:
"""Compose an http proxy URL, url-encoding the credentials."""
from urllib.parse import quote
auth = ""
if user or pw:
auth = f"{quote(user, safe='')}:{quote(pw, safe='')}@"
return f"http://{auth}{host}:{port}"
def parse_ngrok_tunnels(json_text: str) -> str | None:
"""Return 'host:port' from ngrok's /api/tunnels JSON (first tcp tunnel)."""
try:
data = json.loads(json_text)
except json.JSONDecodeError:
return None
for t in data.get("tunnels", []):
pub = t.get("public_url", "")
if pub.startswith("tcp://"):
return pub[len("tcp://"):]
return None
def parse_bore_endpoint(text: str) -> str | None:
"""Return 'bore.pub:PORT' parsed from bore's log output."""
m = re.search(r"(bore\.pub:\d+)", text)
if m:
return m.group(1)
m = re.search(r"listening at\s+([\w.-]+:\d+)", text)
return m.group(1) if m else None
def hf_candidate_tokens() -> list[str]:
"""HF tokens to try: cached token, then any huggingface.co git-credential."""
out, seen = [], set()
env = dict(os.environ)
env.pop("HF_TOKEN", None)
env.pop("HUGGING_FACE_HUB_TOKEN", None)
try:
from huggingface_hub import get_token
# get_token reads HF_TOKEN env first; temporarily clear it for the cached one
saved = os.environ.pop("HF_TOKEN", None)
try:
t = get_token()
finally:
if saved is not None:
os.environ["HF_TOKEN"] = saved
if t and t not in seen:
seen.add(t); out.append(t)
except Exception:
pass
cred = os.path.join(os.path.expanduser("~"), ".git-credentials")
if os.path.exists(cred):
with open(cred, encoding="utf-8", errors="ignore") as fh:
for line in fh:
m = re.search(r"https://[^:]*:([^@]+)@huggingface\.co", line)
if m and m.group(1) not in seen:
seen.add(m.group(1)); out.append(m.group(1))
return out
def set_space_secret(repo: str, key: str, value: str, token: str | None = None) -> str:
"""Set a Space secret; return the authenticated username. Raises on failure."""
from huggingface_hub import HfApi
tokens = [token] if token else hf_candidate_tokens()
last = None
for tok in tokens:
try:
api = HfApi(token=tok)
who = api.whoami()["name"]
api.add_space_secret(repo_id=repo, key=key, value=value)
return who
except Exception as exc:
last = exc
raise RuntimeError(f"Could not set secret: {last}")
def test_through_proxy(proxy_url: str) -> dict:
"""Route a couple of requests through proxy_url; report egress IP + YouTube reach."""
import requests
proxies = {"http": proxy_url, "https": proxy_url}
out = {}
try:
out["egress_ip"] = requests.get("https://api.ipify.org", proxies=proxies,
timeout=25).text.strip()
except Exception as exc:
out["egress_ip"] = f"FAILED: {type(exc).__name__}: {str(exc)[:120]}"
try:
r = requests.get("https://www.youtube.com/robots.txt", proxies=proxies, timeout=25)
out["youtube"] = f"reachable (HTTP {r.status_code})"
except Exception as exc:
out["youtube"] = f"FAILED: {type(exc).__name__}: {str(exc)[:120]}"
return out
# ------------------------------------------------------------------- subprocess manager
class Proc:
"""A subprocess whose stdout/stderr lines are streamed into a queue."""
def __init__(self, log_q: "queue.Queue", tag: str):
self.p = None
self.log_q = log_q
self.tag = tag
def start(self, cmd: list[str]):
self.stop()
self.log_q.put(f"[{self.tag}] $ " + " ".join(cmd))
self.p = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, creationflags=_NO_WINDOW)
threading.Thread(target=self._pump, daemon=True).start()
def _pump(self):
for line in self.p.stdout:
self.log_q.put(f"[{self.tag}] " + line.rstrip())
def running(self) -> bool:
return self.p is not None and self.p.poll() is None
def stop(self):
if self.running():
try:
self.p.terminate()
except Exception:
pass
self.p = None
# ---------------------------------------------------------------------------- GUI shell
def main():
import tkinter as tk
from tkinter import ttk, messagebox
cfg = {}
if os.path.exists(CONFIG_PATH):
try:
cfg = json.load(open(CONFIG_PATH, encoding="utf-8"))
except Exception:
cfg = {}
root = tk.Tk()
root.title("TutorialMaker — Home Proxy Panel")
root.geometry("760x620")
log_q: "queue.Queue" = queue.Queue()
proxy_proc = Proc(log_q, "proxy")
tunnel_proc = Proc(log_q, "tunnel")
state = {"endpoint": None}
def v(name, default=""):
return tk.StringVar(value=str(cfg.get(name, default)))
port = v("port", "8899")
user = v("user", "tm")
pw = v("pw", secrets.token_urlsafe(10))
provider = v("provider", "ngrok")
ngrok_token = v("ngrok_token", "")
manual_ep = v("manual_ep", "")
repo = v("repo", DEFAULT_REPO)
hf_token = v("hf_token", "")
endpoint_var = tk.StringVar(value="(not running)")
yturl_var = tk.StringVar(value="")
status_var = tk.StringVar(value="idle")
def log(msg):
log_q.put(msg)
def save_cfg():
json.dump({"port": port.get(), "user": user.get(), "pw": pw.get(),
"provider": provider.get(), "ngrok_token": ngrok_token.get(),
"manual_ep": manual_ep.get(), "repo": repo.get()},
open(CONFIG_PATH, "w", encoding="utf-8"))
log(f"[cfg] saved to {CONFIG_PATH}")
def compute_yturl():
ep = state["endpoint"]
if not ep:
yturl_var.set("")
return None
host, _, p = ep.partition(":")
url = build_proxy_url(host, p, user.get(), pw.get())
yturl_var.set(url)
return url
# --- proxy ---
def start_proxy():
auth = f"{user.get()}:{pw.get()}"
pargs = ["--hostname", "127.0.0.1", "--port", port.get(), "--basic-auth", auth]
if getattr(sys, "frozen", False):
# In a PyInstaller build sys.executable is THIS app, not python, so `-m proxy`
# won't work — re-invoke ourselves with the --run-proxy sentinel (see __main__).
cmd = [sys.executable, "--run-proxy", *pargs]
else:
cmd = [sys.executable, "-m", "proxy", *pargs]
proxy_proc.start(cmd)
status_var.set("proxy starting…")
def stop_proxy():
proxy_proc.stop(); log("[proxy] stopped")
# --- tunnel ---
def start_tunnel():
prov = provider.get()
if prov == "manual":
ep = manual_ep.get().strip()
if not ep:
messagebox.showwarning("Manual", "Enter host:port for the manual endpoint.")
return
state["endpoint"] = ep
endpoint_var.set(ep); compute_yturl(); log(f"[tunnel] manual endpoint {ep}")
return
if prov == "ngrok":
if ngrok_token.get().strip():
subprocess.run(["ngrok", "config", "add-authtoken", ngrok_token.get().strip()],
creationflags=_NO_WINDOW)
tunnel_proc.start(["ngrok", "tcp", port.get(), "--log", "stdout"])
root.after(2500, _grab_ngrok)
elif prov == "bore":
status_var.set("preparing bore…")
def work():
bore = ensure_bore(log)
if not bore:
log("[tunnel] bore unavailable — download bore.exe from "
"https://github.com/ekzhang/bore/releases or pick ngrok.")
status_var.set("bore unavailable")
return
root.after(0, lambda: tunnel_proc.start(
[bore, "local", port.get(), "--to", "bore.pub"]))
status_var.set("tunnel starting…")
threading.Thread(target=work, daemon=True).start()
return
status_var.set("tunnel starting…")
def _grab_ngrok():
try:
import requests
j = requests.get("http://127.0.0.1:4040/api/tunnels", timeout=5).text
ep = parse_ngrok_tunnels(j)
if ep:
state["endpoint"] = ep; endpoint_var.set(ep); compute_yturl()
log(f"[tunnel] ngrok endpoint {ep}")
else:
log("[tunnel] ngrok: no tcp tunnel yet, retrying…"); root.after(2000, _grab_ngrok)
except Exception as exc:
log(f"[tunnel] ngrok api: {exc}"); root.after(2000, _grab_ngrok)
def stop_tunnel():
tunnel_proc.stop(); state["endpoint"] = None
endpoint_var.set("(not running)"); yturl_var.set(""); log("[tunnel] stopped")
# --- actions ---
def copy_url():
url = compute_yturl()
if url:
root.clipboard_clear(); root.clipboard_append(url); log("[yturl] copied")
def do_test():
url = compute_yturl()
if not url:
messagebox.showinfo("Test", "Start the tunnel first."); return
status_var.set("testing…")
def work():
res = test_through_proxy(url)
log(f"[test] egress IP: {res['egress_ip']}")
log(f"[test] youtube: {res['youtube']}")
status_var.set("test done")
threading.Thread(target=work, daemon=True).start()
def do_set_secret():
url = compute_yturl()
if not url:
messagebox.showinfo("Secret", "Start the tunnel first."); return
status_var.set("setting YT_PROXY…")
def work():
try:
who = set_space_secret(repo.get().strip(), "YT_PROXY", url,
hf_token.get().strip() or None)
log(f"[hf] YT_PROXY set on {repo.get()} (as {who})")
status_var.set("YT_PROXY set ✓")
except Exception as exc:
log(f"[hf] FAILED: {exc}"); status_var.set("secret failed")
threading.Thread(target=work, daemon=True).start()
# --- layout ---
pad = {"padx": 6, "pady": 3}
frm = ttk.Frame(root); frm.pack(fill="x", **pad)
r = 0
def row(label, var, show=None, width=42):
nonlocal r
ttk.Label(frm, text=label).grid(row=r, column=0, sticky="w", **pad)
e = ttk.Entry(frm, textvariable=var, width=width, show=show)
e.grid(row=r, column=1, columnspan=3, sticky="w", **pad); r += 1
return e
row("Proxy port", port, width=10)
row("Proxy user", user, width=18)
row("Proxy password", pw, width=24)
ttk.Label(frm, text="Tunnel provider").grid(row=r, column=0, sticky="w", **pad)
ttk.Combobox(frm, textvariable=provider, values=["ngrok", "bore", "manual"],
width=10, state="readonly").grid(row=r, column=1, sticky="w", **pad); r += 1
row("ngrok authtoken (ngrok only)", ngrok_token, show="*")
row("Manual endpoint host:port (manual only)", manual_ep, width=28)
row("HF Space repo", repo, width=34)
row("HF token (blank = auto-detect)", hf_token, show="*")
btns = ttk.Frame(root); btns.pack(fill="x", **pad)
for txt, fn in [("Start proxy", start_proxy), ("Stop proxy", stop_proxy),
("Start tunnel", start_tunnel), ("Stop tunnel", stop_tunnel)]:
ttk.Button(btns, text=txt, command=fn).pack(side="left", **pad)
btns2 = ttk.Frame(root); btns2.pack(fill="x", **pad)
for txt, fn in [("📋 Copy my Proxy URL", copy_url), ("Test proxy", do_test),
("Save config", save_cfg),
("New password", lambda: pw.set(secrets.token_urlsafe(10))),
("Set on Space (owner)", do_set_secret)]:
ttk.Button(btns2, text=txt, command=fn).pack(side="left", **pad)
info = ttk.Frame(root); info.pack(fill="x", **pad)
ttk.Label(info, text="Public endpoint:").grid(row=0, column=0, sticky="w", **pad)
ttk.Label(info, textvariable=endpoint_var, foreground="#0a7").grid(row=0, column=1, sticky="w", **pad)
ttk.Label(info, text="Your proxy URL:").grid(row=1, column=0, sticky="w", **pad)
ttk.Entry(info, textvariable=yturl_var, width=70, state="readonly").grid(row=1, column=1, sticky="w", **pad)
ttk.Label(info, text="Status:").grid(row=2, column=0, sticky="w", **pad)
ttk.Label(info, textvariable=status_var, foreground="#06c").grid(row=2, column=1, sticky="w", **pad)
txt = tk.Text(root, height=16, wrap="word", bg="#111", fg="#ddd")
txt.pack(fill="both", expand=True, **pad)
def poll():
try:
while True:
line = log_q.get_nowait()
txt.insert("end", line + "\n"); txt.see("end")
except queue.Empty:
pass
# refresh running status
s = []
s.append("proxy:on" if proxy_proc.running() else "proxy:off")
s.append("tunnel:on" if tunnel_proc.running() else "tunnel:off")
if status_var.get() in ("idle", "proxy starting…", "tunnel starting…"):
status_var.set(" ".join(s))
root.after(400, poll)
def on_close():
proxy_proc.stop(); tunnel_proc.stop(); root.destroy()
root.protocol("WM_DELETE_WINDOW", on_close)
log("Ready. 1) Start proxy 2) Start tunnel 3) Test proxy 4) Copy my Proxy URL")
log(" → paste that URL into the TutorialMaker Space's 'Your proxy URL' field, then Generate.")
log("Tip: 'Test proxy' egress IP should be your HOME IP, and YouTube should be reachable.")
log("(bore auto-downloads on first use; 'Set on Space (owner)' is only for the Space owner.)")
poll()
root.mainloop()
if __name__ == "__main__":
# Frozen (PyInstaller) re-entry: act as `python -m proxy` when asked, so the packaged
# executable can run the forward proxy as a normal killable subprocess of itself.
if "--run-proxy" in sys.argv:
_i = sys.argv.index("--run-proxy")
import proxy
sys.argv = ["proxy", *sys.argv[_i + 1:]]
proxy.main()
else:
main()