Spaces:
Sleeping
Sleeping
Commit ·
842fed4
1
Parent(s): ffdac8f
Add home-tunnel proxy control panel (tools/)
Browse filesTkinter GUI to run a local authenticated HTTP proxy on the user's residential IP, expose
it via a raw TCP tunnel (ngrok / bore / manual), test the egress IP + YouTube reach, and
one-click set the Space's YT_PROXY secret. Includes a step-by-step README. (Forward
proxies need a TCP tunnel; HTTP reverse tunnels like Cloudflare/Tailscale Funnel won't
carry CONNECT.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- tools/README.md +90 -0
- tools/home_proxy_panel.py +365 -0
- tools/requirements.txt +3 -0
tools/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Home Proxy Panel — give the Space your home IP
|
| 2 |
+
|
| 3 |
+
The TutorialMaker Space can't fetch transcripts or stream URLs from its datacenter IP
|
| 4 |
+
(YouTube blocks it). This tool runs a small **authenticated HTTP proxy on your computer**
|
| 5 |
+
(your home/residential IP) and exposes it through a **raw TCP tunnel**, then sets it as the
|
| 6 |
+
Space's **`YT_PROXY`** secret. The Space's YouTube requests then exit from *your* IP and
|
| 7 |
+
stop getting blocked.
|
| 8 |
+
|
| 9 |
+
> **Why a TCP tunnel (not Cloudflare/Tailscale):** a forward proxy speaks HTTP `CONNECT`,
|
| 10 |
+
> which only survives a transparent **TCP** tunnel. HTTP *reverse* tunnels (Cloudflare
|
| 11 |
+
> quick-tunnel, Tailscale Funnel) cannot carry it — use **ngrok TCP** or **bore**.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 1. Install the Python deps
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
pip install -r tools/requirements.txt
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
## 2. Install a tunnel (pick ONE)
|
| 22 |
+
|
| 23 |
+
**Option A — ngrok (more reliable, free account):**
|
| 24 |
+
1. Sign up at <https://ngrok.com> (free).
|
| 25 |
+
2. Install the ngrok agent and put `ngrok` on your PATH
|
| 26 |
+
(<https://ngrok.com/download>).
|
| 27 |
+
3. Copy your authtoken from the ngrok dashboard → you'll paste it into the panel.
|
| 28 |
+
|
| 29 |
+
**Option B — bore (no account, less reliable):**
|
| 30 |
+
1. Download `bore.exe` from <https://github.com/ekzhang/bore/releases> and put it on your
|
| 31 |
+
PATH (or in this folder).
|
| 32 |
+
2. That's it — it uses the shared public `bore.pub` server.
|
| 33 |
+
|
| 34 |
+
## 3. Launch the panel
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
python tools/home_proxy_panel.py
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## 4. Use it (in order)
|
| 41 |
+
|
| 42 |
+
1. **Proxy port / user / password** are pre-filled (a random password is generated). Keep
|
| 43 |
+
the password — it protects your public proxy from being used by strangers.
|
| 44 |
+
2. Pick the **Tunnel provider** (`ngrok`, `bore`, or `manual`). For ngrok, paste your
|
| 45 |
+
**authtoken**.
|
| 46 |
+
3. Click **Start proxy** → it runs `proxy.py` on `127.0.0.1:<port>`.
|
| 47 |
+
4. Click **Start tunnel** → the **Public endpoint** field fills in
|
| 48 |
+
(`x.tcp.ngrok.io:NNNN` or `bore.pub:NNNN`) and the **YT_PROXY URL** is computed.
|
| 49 |
+
5. Click **Test proxy** → in the log, the **egress IP should be your home IP** and YouTube
|
| 50 |
+
should be **reachable**. If so, the proxy works.
|
| 51 |
+
6. Click **Set YT_PROXY on Space** → it writes the `YT_PROXY` secret to your Space
|
| 52 |
+
(auto-detecting your HF token, or paste one). The Space restarts and picks it up.
|
| 53 |
+
- Or click **Copy YT_PROXY URL** and paste it into the Space's secret manually.
|
| 54 |
+
|
| 55 |
+
## 5. Verify on the Space
|
| 56 |
+
|
| 57 |
+
Run a topic on the Space. The **transcript** and **screenshot** steps should now succeed
|
| 58 |
+
instead of failing with an SSL/TLS block.
|
| 59 |
+
|
| 60 |
+
## Keep it running
|
| 61 |
+
|
| 62 |
+
The proxy + tunnel must stay running on your machine while the Space is in use. Close the
|
| 63 |
+
panel (or **Stop proxy/Stop tunnel**) when you're done — the `YT_PROXY` secret will then
|
| 64 |
+
point at a dead endpoint until you start it again (the Space falls back to text-only / a
|
| 65 |
+
clear error).
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
## Security notes
|
| 70 |
+
|
| 71 |
+
- The proxy is **public** while tunneled, so it **requires the username/password** — keep
|
| 72 |
+
them non-trivial (use **New password** to rotate). Without the right credentials nobody
|
| 73 |
+
can use your proxy.
|
| 74 |
+
- This routes YouTube traffic through your home connection and consumes your bandwidth.
|
| 75 |
+
- Stop the tunnel when not needed; rotate the password periodically.
|
| 76 |
+
|
| 77 |
+
## Manual endpoint (router port-forward / other tunnel)
|
| 78 |
+
|
| 79 |
+
If you forward a router port (and you're **not** behind CGNAT) or use another raw-TCP
|
| 80 |
+
tunnel, choose provider **`manual`** and enter the public `host:port`. The panel will
|
| 81 |
+
build the `YT_PROXY` URL and can set the secret + test it.
|
| 82 |
+
|
| 83 |
+
## Troubleshooting
|
| 84 |
+
|
| 85 |
+
- **`proxy.py` not found** → `pip install proxy.py`.
|
| 86 |
+
- **ngrok: no tcp tunnel** → ensure the authtoken is set; free TCP tunnels are supported
|
| 87 |
+
but only one at a time — close other ngrok agents.
|
| 88 |
+
- **Test egress IP is *not* your home IP** → the tunnel/proxy chain is wrong; restart both.
|
| 89 |
+
- **YouTube still blocked on the Space** → confirm the secret value matches the panel's
|
| 90 |
+
YT_PROXY URL exactly (including `user:pass@`), and that the panel is still running.
|
tools/home_proxy_panel.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Home-tunnel proxy control panel for the TutorialMaker Space.
|
| 2 |
+
|
| 3 |
+
Runs a local authenticated HTTP forward proxy on your machine (your residential IP) and
|
| 4 |
+
exposes it to the internet through a RAW TCP tunnel (ngrok / bore), so the Hugging Face
|
| 5 |
+
Space can route youtube-transcript-api + yt-dlp through your home IP and dodge the
|
| 6 |
+
datacenter-IP block. One-click sets the resulting URL as the Space's YT_PROXY secret.
|
| 7 |
+
|
| 8 |
+
Why a TCP tunnel: a forward proxy speaks HTTP CONNECT, which only survives a transparent
|
| 9 |
+
TCP tunnel. HTTP reverse tunnels (Cloudflare quick-tunnel, Tailscale Funnel) do NOT work
|
| 10 |
+
for this — don't use them here.
|
| 11 |
+
|
| 12 |
+
Run: python tools/home_proxy_panel.py
|
| 13 |
+
Deps: pip install proxy.py huggingface_hub requests (ngrok or bore on PATH for tunneling)
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import queue
|
| 20 |
+
import re
|
| 21 |
+
import secrets
|
| 22 |
+
import subprocess
|
| 23 |
+
import sys
|
| 24 |
+
import threading
|
| 25 |
+
|
| 26 |
+
CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".tutorialmaker_proxy.json")
|
| 27 |
+
DEFAULT_REPO = "vivekchakraverty/TutorialMaker"
|
| 28 |
+
_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ----------------------------------------------------------------- pure helpers (tested)
|
| 32 |
+
def build_proxy_url(host: str, port, user: str, pw: str) -> str:
|
| 33 |
+
"""Compose an http proxy URL, url-encoding the credentials."""
|
| 34 |
+
from urllib.parse import quote
|
| 35 |
+
auth = ""
|
| 36 |
+
if user or pw:
|
| 37 |
+
auth = f"{quote(user, safe='')}:{quote(pw, safe='')}@"
|
| 38 |
+
return f"http://{auth}{host}:{port}"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def parse_ngrok_tunnels(json_text: str) -> str | None:
|
| 42 |
+
"""Return 'host:port' from ngrok's /api/tunnels JSON (first tcp tunnel)."""
|
| 43 |
+
try:
|
| 44 |
+
data = json.loads(json_text)
|
| 45 |
+
except json.JSONDecodeError:
|
| 46 |
+
return None
|
| 47 |
+
for t in data.get("tunnels", []):
|
| 48 |
+
pub = t.get("public_url", "")
|
| 49 |
+
if pub.startswith("tcp://"):
|
| 50 |
+
return pub[len("tcp://"):]
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def parse_bore_endpoint(text: str) -> str | None:
|
| 55 |
+
"""Return 'bore.pub:PORT' parsed from bore's log output."""
|
| 56 |
+
m = re.search(r"(bore\.pub:\d+)", text)
|
| 57 |
+
if m:
|
| 58 |
+
return m.group(1)
|
| 59 |
+
m = re.search(r"listening at\s+([\w.-]+:\d+)", text)
|
| 60 |
+
return m.group(1) if m else None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def hf_candidate_tokens() -> list[str]:
|
| 64 |
+
"""HF tokens to try: cached token, then any huggingface.co git-credential."""
|
| 65 |
+
out, seen = [], set()
|
| 66 |
+
env = dict(os.environ)
|
| 67 |
+
env.pop("HF_TOKEN", None)
|
| 68 |
+
env.pop("HUGGING_FACE_HUB_TOKEN", None)
|
| 69 |
+
try:
|
| 70 |
+
from huggingface_hub import get_token
|
| 71 |
+
# get_token reads HF_TOKEN env first; temporarily clear it for the cached one
|
| 72 |
+
saved = os.environ.pop("HF_TOKEN", None)
|
| 73 |
+
try:
|
| 74 |
+
t = get_token()
|
| 75 |
+
finally:
|
| 76 |
+
if saved is not None:
|
| 77 |
+
os.environ["HF_TOKEN"] = saved
|
| 78 |
+
if t and t not in seen:
|
| 79 |
+
seen.add(t); out.append(t)
|
| 80 |
+
except Exception:
|
| 81 |
+
pass
|
| 82 |
+
cred = os.path.join(os.path.expanduser("~"), ".git-credentials")
|
| 83 |
+
if os.path.exists(cred):
|
| 84 |
+
with open(cred, encoding="utf-8", errors="ignore") as fh:
|
| 85 |
+
for line in fh:
|
| 86 |
+
m = re.search(r"https://[^:]*:([^@]+)@huggingface\.co", line)
|
| 87 |
+
if m and m.group(1) not in seen:
|
| 88 |
+
seen.add(m.group(1)); out.append(m.group(1))
|
| 89 |
+
return out
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def set_space_secret(repo: str, key: str, value: str, token: str | None = None) -> str:
|
| 93 |
+
"""Set a Space secret; return the authenticated username. Raises on failure."""
|
| 94 |
+
from huggingface_hub import HfApi
|
| 95 |
+
tokens = [token] if token else hf_candidate_tokens()
|
| 96 |
+
last = None
|
| 97 |
+
for tok in tokens:
|
| 98 |
+
try:
|
| 99 |
+
api = HfApi(token=tok)
|
| 100 |
+
who = api.whoami()["name"]
|
| 101 |
+
api.add_space_secret(repo_id=repo, key=key, value=value)
|
| 102 |
+
return who
|
| 103 |
+
except Exception as exc:
|
| 104 |
+
last = exc
|
| 105 |
+
raise RuntimeError(f"Could not set secret: {last}")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_through_proxy(proxy_url: str) -> dict:
|
| 109 |
+
"""Route a couple of requests through proxy_url; report egress IP + YouTube reach."""
|
| 110 |
+
import requests
|
| 111 |
+
proxies = {"http": proxy_url, "https": proxy_url}
|
| 112 |
+
out = {}
|
| 113 |
+
try:
|
| 114 |
+
out["egress_ip"] = requests.get("https://api.ipify.org", proxies=proxies,
|
| 115 |
+
timeout=25).text.strip()
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
out["egress_ip"] = f"FAILED: {type(exc).__name__}: {str(exc)[:120]}"
|
| 118 |
+
try:
|
| 119 |
+
r = requests.get("https://www.youtube.com/robots.txt", proxies=proxies, timeout=25)
|
| 120 |
+
out["youtube"] = f"reachable (HTTP {r.status_code})"
|
| 121 |
+
except Exception as exc:
|
| 122 |
+
out["youtube"] = f"FAILED: {type(exc).__name__}: {str(exc)[:120]}"
|
| 123 |
+
return out
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ------------------------------------------------------------------- subprocess manager
|
| 127 |
+
class Proc:
|
| 128 |
+
"""A subprocess whose stdout/stderr lines are streamed into a queue."""
|
| 129 |
+
|
| 130 |
+
def __init__(self, log_q: "queue.Queue", tag: str):
|
| 131 |
+
self.p = None
|
| 132 |
+
self.log_q = log_q
|
| 133 |
+
self.tag = tag
|
| 134 |
+
|
| 135 |
+
def start(self, cmd: list[str]):
|
| 136 |
+
self.stop()
|
| 137 |
+
self.log_q.put(f"[{self.tag}] $ " + " ".join(cmd))
|
| 138 |
+
self.p = subprocess.Popen(
|
| 139 |
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
| 140 |
+
text=True, bufsize=1, creationflags=_NO_WINDOW)
|
| 141 |
+
threading.Thread(target=self._pump, daemon=True).start()
|
| 142 |
+
|
| 143 |
+
def _pump(self):
|
| 144 |
+
for line in self.p.stdout:
|
| 145 |
+
self.log_q.put(f"[{self.tag}] " + line.rstrip())
|
| 146 |
+
|
| 147 |
+
def running(self) -> bool:
|
| 148 |
+
return self.p is not None and self.p.poll() is None
|
| 149 |
+
|
| 150 |
+
def stop(self):
|
| 151 |
+
if self.running():
|
| 152 |
+
try:
|
| 153 |
+
self.p.terminate()
|
| 154 |
+
except Exception:
|
| 155 |
+
pass
|
| 156 |
+
self.p = None
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ---------------------------------------------------------------------------- GUI shell
|
| 160 |
+
def main():
|
| 161 |
+
import tkinter as tk
|
| 162 |
+
from tkinter import ttk, messagebox
|
| 163 |
+
|
| 164 |
+
cfg = {}
|
| 165 |
+
if os.path.exists(CONFIG_PATH):
|
| 166 |
+
try:
|
| 167 |
+
cfg = json.load(open(CONFIG_PATH, encoding="utf-8"))
|
| 168 |
+
except Exception:
|
| 169 |
+
cfg = {}
|
| 170 |
+
|
| 171 |
+
root = tk.Tk()
|
| 172 |
+
root.title("TutorialMaker — Home Proxy Panel")
|
| 173 |
+
root.geometry("760x620")
|
| 174 |
+
log_q: "queue.Queue" = queue.Queue()
|
| 175 |
+
proxy_proc = Proc(log_q, "proxy")
|
| 176 |
+
tunnel_proc = Proc(log_q, "tunnel")
|
| 177 |
+
state = {"endpoint": None}
|
| 178 |
+
|
| 179 |
+
def v(name, default=""):
|
| 180 |
+
return tk.StringVar(value=str(cfg.get(name, default)))
|
| 181 |
+
|
| 182 |
+
port = v("port", "8899")
|
| 183 |
+
user = v("user", "tm")
|
| 184 |
+
pw = v("pw", secrets.token_urlsafe(10))
|
| 185 |
+
provider = v("provider", "ngrok")
|
| 186 |
+
ngrok_token = v("ngrok_token", "")
|
| 187 |
+
manual_ep = v("manual_ep", "")
|
| 188 |
+
repo = v("repo", DEFAULT_REPO)
|
| 189 |
+
hf_token = v("hf_token", "")
|
| 190 |
+
endpoint_var = tk.StringVar(value="(not running)")
|
| 191 |
+
yturl_var = tk.StringVar(value="")
|
| 192 |
+
status_var = tk.StringVar(value="idle")
|
| 193 |
+
|
| 194 |
+
def log(msg):
|
| 195 |
+
log_q.put(msg)
|
| 196 |
+
|
| 197 |
+
def save_cfg():
|
| 198 |
+
json.dump({"port": port.get(), "user": user.get(), "pw": pw.get(),
|
| 199 |
+
"provider": provider.get(), "ngrok_token": ngrok_token.get(),
|
| 200 |
+
"manual_ep": manual_ep.get(), "repo": repo.get()},
|
| 201 |
+
open(CONFIG_PATH, "w", encoding="utf-8"))
|
| 202 |
+
log(f"[cfg] saved to {CONFIG_PATH}")
|
| 203 |
+
|
| 204 |
+
def compute_yturl():
|
| 205 |
+
ep = state["endpoint"]
|
| 206 |
+
if not ep:
|
| 207 |
+
yturl_var.set("")
|
| 208 |
+
return None
|
| 209 |
+
host, _, p = ep.partition(":")
|
| 210 |
+
url = build_proxy_url(host, p, user.get(), pw.get())
|
| 211 |
+
yturl_var.set(url)
|
| 212 |
+
return url
|
| 213 |
+
|
| 214 |
+
# --- proxy ---
|
| 215 |
+
def start_proxy():
|
| 216 |
+
cmd = [sys.executable, "-m", "proxy", "--hostname", "127.0.0.1",
|
| 217 |
+
"--port", port.get(), "--basic-auth", f"{user.get()}:{pw.get()}"]
|
| 218 |
+
proxy_proc.start(cmd)
|
| 219 |
+
status_var.set("proxy starting…")
|
| 220 |
+
|
| 221 |
+
def stop_proxy():
|
| 222 |
+
proxy_proc.stop(); log("[proxy] stopped")
|
| 223 |
+
|
| 224 |
+
# --- tunnel ---
|
| 225 |
+
def start_tunnel():
|
| 226 |
+
prov = provider.get()
|
| 227 |
+
if prov == "manual":
|
| 228 |
+
ep = manual_ep.get().strip()
|
| 229 |
+
if not ep:
|
| 230 |
+
messagebox.showwarning("Manual", "Enter host:port for the manual endpoint.")
|
| 231 |
+
return
|
| 232 |
+
state["endpoint"] = ep
|
| 233 |
+
endpoint_var.set(ep); compute_yturl(); log(f"[tunnel] manual endpoint {ep}")
|
| 234 |
+
return
|
| 235 |
+
if prov == "ngrok":
|
| 236 |
+
if ngrok_token.get().strip():
|
| 237 |
+
subprocess.run(["ngrok", "config", "add-authtoken", ngrok_token.get().strip()],
|
| 238 |
+
creationflags=_NO_WINDOW)
|
| 239 |
+
tunnel_proc.start(["ngrok", "tcp", port.get(), "--log", "stdout"])
|
| 240 |
+
root.after(2500, _grab_ngrok)
|
| 241 |
+
elif prov == "bore":
|
| 242 |
+
tunnel_proc.start(["bore", "local", port.get(), "--to", "bore.pub"])
|
| 243 |
+
status_var.set("tunnel starting…")
|
| 244 |
+
|
| 245 |
+
def _grab_ngrok():
|
| 246 |
+
try:
|
| 247 |
+
import requests
|
| 248 |
+
j = requests.get("http://127.0.0.1:4040/api/tunnels", timeout=5).text
|
| 249 |
+
ep = parse_ngrok_tunnels(j)
|
| 250 |
+
if ep:
|
| 251 |
+
state["endpoint"] = ep; endpoint_var.set(ep); compute_yturl()
|
| 252 |
+
log(f"[tunnel] ngrok endpoint {ep}")
|
| 253 |
+
else:
|
| 254 |
+
log("[tunnel] ngrok: no tcp tunnel yet, retrying…"); root.after(2000, _grab_ngrok)
|
| 255 |
+
except Exception as exc:
|
| 256 |
+
log(f"[tunnel] ngrok api: {exc}"); root.after(2000, _grab_ngrok)
|
| 257 |
+
|
| 258 |
+
def stop_tunnel():
|
| 259 |
+
tunnel_proc.stop(); state["endpoint"] = None
|
| 260 |
+
endpoint_var.set("(not running)"); yturl_var.set(""); log("[tunnel] stopped")
|
| 261 |
+
|
| 262 |
+
# --- actions ---
|
| 263 |
+
def copy_url():
|
| 264 |
+
url = compute_yturl()
|
| 265 |
+
if url:
|
| 266 |
+
root.clipboard_clear(); root.clipboard_append(url); log("[yturl] copied")
|
| 267 |
+
|
| 268 |
+
def do_test():
|
| 269 |
+
url = compute_yturl()
|
| 270 |
+
if not url:
|
| 271 |
+
messagebox.showinfo("Test", "Start the tunnel first."); return
|
| 272 |
+
status_var.set("testing…")
|
| 273 |
+
def work():
|
| 274 |
+
res = test_through_proxy(url)
|
| 275 |
+
log(f"[test] egress IP: {res['egress_ip']}")
|
| 276 |
+
log(f"[test] youtube: {res['youtube']}")
|
| 277 |
+
status_var.set("test done")
|
| 278 |
+
threading.Thread(target=work, daemon=True).start()
|
| 279 |
+
|
| 280 |
+
def do_set_secret():
|
| 281 |
+
url = compute_yturl()
|
| 282 |
+
if not url:
|
| 283 |
+
messagebox.showinfo("Secret", "Start the tunnel first."); return
|
| 284 |
+
status_var.set("setting YT_PROXY…")
|
| 285 |
+
def work():
|
| 286 |
+
try:
|
| 287 |
+
who = set_space_secret(repo.get().strip(), "YT_PROXY", url,
|
| 288 |
+
hf_token.get().strip() or None)
|
| 289 |
+
log(f"[hf] YT_PROXY set on {repo.get()} (as {who})")
|
| 290 |
+
status_var.set("YT_PROXY set ✓")
|
| 291 |
+
except Exception as exc:
|
| 292 |
+
log(f"[hf] FAILED: {exc}"); status_var.set("secret failed")
|
| 293 |
+
threading.Thread(target=work, daemon=True).start()
|
| 294 |
+
|
| 295 |
+
# --- layout ---
|
| 296 |
+
pad = {"padx": 6, "pady": 3}
|
| 297 |
+
frm = ttk.Frame(root); frm.pack(fill="x", **pad)
|
| 298 |
+
r = 0
|
| 299 |
+
def row(label, var, show=None, width=42):
|
| 300 |
+
nonlocal r
|
| 301 |
+
ttk.Label(frm, text=label).grid(row=r, column=0, sticky="w", **pad)
|
| 302 |
+
e = ttk.Entry(frm, textvariable=var, width=width, show=show)
|
| 303 |
+
e.grid(row=r, column=1, columnspan=3, sticky="w", **pad); r += 1
|
| 304 |
+
return e
|
| 305 |
+
|
| 306 |
+
row("Proxy port", port, width=10)
|
| 307 |
+
row("Proxy user", user, width=18)
|
| 308 |
+
row("Proxy password", pw, width=24)
|
| 309 |
+
ttk.Label(frm, text="Tunnel provider").grid(row=r, column=0, sticky="w", **pad)
|
| 310 |
+
ttk.Combobox(frm, textvariable=provider, values=["ngrok", "bore", "manual"],
|
| 311 |
+
width=10, state="readonly").grid(row=r, column=1, sticky="w", **pad); r += 1
|
| 312 |
+
row("ngrok authtoken (ngrok only)", ngrok_token, show="*")
|
| 313 |
+
row("Manual endpoint host:port (manual only)", manual_ep, width=28)
|
| 314 |
+
row("HF Space repo", repo, width=34)
|
| 315 |
+
row("HF token (blank = auto-detect)", hf_token, show="*")
|
| 316 |
+
|
| 317 |
+
btns = ttk.Frame(root); btns.pack(fill="x", **pad)
|
| 318 |
+
for txt, fn in [("Start proxy", start_proxy), ("Stop proxy", stop_proxy),
|
| 319 |
+
("Start tunnel", start_tunnel), ("Stop tunnel", stop_tunnel)]:
|
| 320 |
+
ttk.Button(btns, text=txt, command=fn).pack(side="left", **pad)
|
| 321 |
+
|
| 322 |
+
btns2 = ttk.Frame(root); btns2.pack(fill="x", **pad)
|
| 323 |
+
for txt, fn in [("Test proxy", do_test), ("Set YT_PROXY on Space", do_set_secret),
|
| 324 |
+
("Copy YT_PROXY URL", copy_url), ("Save config", save_cfg),
|
| 325 |
+
("New password", lambda: pw.set(secrets.token_urlsafe(10)))]:
|
| 326 |
+
ttk.Button(btns2, text=txt, command=fn).pack(side="left", **pad)
|
| 327 |
+
|
| 328 |
+
info = ttk.Frame(root); info.pack(fill="x", **pad)
|
| 329 |
+
ttk.Label(info, text="Public endpoint:").grid(row=0, column=0, sticky="w", **pad)
|
| 330 |
+
ttk.Label(info, textvariable=endpoint_var, foreground="#0a7").grid(row=0, column=1, sticky="w", **pad)
|
| 331 |
+
ttk.Label(info, text="YT_PROXY URL:").grid(row=1, column=0, sticky="w", **pad)
|
| 332 |
+
ttk.Entry(info, textvariable=yturl_var, width=70, state="readonly").grid(row=1, column=1, sticky="w", **pad)
|
| 333 |
+
ttk.Label(info, text="Status:").grid(row=2, column=0, sticky="w", **pad)
|
| 334 |
+
ttk.Label(info, textvariable=status_var, foreground="#06c").grid(row=2, column=1, sticky="w", **pad)
|
| 335 |
+
|
| 336 |
+
txt = tk.Text(root, height=16, wrap="word", bg="#111", fg="#ddd")
|
| 337 |
+
txt.pack(fill="both", expand=True, **pad)
|
| 338 |
+
|
| 339 |
+
def poll():
|
| 340 |
+
try:
|
| 341 |
+
while True:
|
| 342 |
+
line = log_q.get_nowait()
|
| 343 |
+
txt.insert("end", line + "\n"); txt.see("end")
|
| 344 |
+
except queue.Empty:
|
| 345 |
+
pass
|
| 346 |
+
# refresh running status
|
| 347 |
+
s = []
|
| 348 |
+
s.append("proxy:on" if proxy_proc.running() else "proxy:off")
|
| 349 |
+
s.append("tunnel:on" if tunnel_proc.running() else "tunnel:off")
|
| 350 |
+
if status_var.get() in ("idle", "proxy starting…", "tunnel starting…"):
|
| 351 |
+
status_var.set(" ".join(s))
|
| 352 |
+
root.after(400, poll)
|
| 353 |
+
|
| 354 |
+
def on_close():
|
| 355 |
+
proxy_proc.stop(); tunnel_proc.stop(); root.destroy()
|
| 356 |
+
|
| 357 |
+
root.protocol("WM_DELETE_WINDOW", on_close)
|
| 358 |
+
log("Ready. 1) Start proxy 2) Start tunnel 3) Test proxy 4) Set YT_PROXY on Space.")
|
| 359 |
+
log("Tip: 'Test proxy' egress IP should be your HOME IP, and YouTube should be reachable.")
|
| 360 |
+
poll()
|
| 361 |
+
root.mainloop()
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
if __name__ == "__main__":
|
| 365 |
+
main()
|
tools/requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
proxy.py>=2.4
|
| 2 |
+
huggingface_hub>=0.27
|
| 3 |
+
requests>=2.31
|