Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| NII Relay daemon — runs inside a no-SSH container, polls the HF Space relay, and | |
| executes shell commands in persistent named bash sessions. | |
| Run it (foreground, or under nohup / tmux / systemd): | |
| export RELAY_SPACE="your-username/nii-relay" # the Space repo id | |
| export HF_TOKEN="hf_..." # token with access to the private Space (this is the auth) | |
| export CLIENT_ID="$(hostname)" # how it shows up in the UI (optional) | |
| python client.py | |
| Each command carries a `session` name. Commands with the same session name share | |
| one long-lived bash process, so `cd`, exports, and activated venvs persist. | |
| A new session name spins up a fresh shell. Different sessions run concurrently | |
| (one worker thread each), so a long command in one session never blocks another | |
| session or the poll loop that keeps this client marked online. | |
| Airgapped/restricted-network friendly: outbound HTTPS only (it reaches | |
| huggingface.co AND <user>-<space>.hf.space), never binds a port, and disables | |
| all telemetry by default. | |
| """ | |
| import os | |
| # Disable all phone-home before importing gradio_client / huggingface_hub, so a | |
| # restricted-egress container never tries to reach api.gradio.app etc. | |
| os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False") | |
| os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1") | |
| os.environ.setdefault("DO_NOT_TRACK", "1") | |
| import sys | |
| import time | |
| import signal | |
| import socket | |
| import shutil | |
| import getpass | |
| import threading | |
| import subprocess | |
| import uuid | |
| import queue as queuelib | |
| from collections import defaultdict | |
| from gradio_client import Client, handle_file | |
| def _primary_ip() -> str: | |
| """Best-effort local IP. The UDP 'connect' just picks the outbound route's | |
| source address; no packets are sent and the host need not be reachable.""" | |
| s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | |
| try: | |
| s.connect(("8.8.8.8", 80)) | |
| return s.getsockname()[0] | |
| except OSError: | |
| try: | |
| return socket.gethostbyname(socket.gethostname()) | |
| except OSError: | |
| return os.uname().nodename | |
| finally: | |
| s.close() | |
| def _default_client_id() -> str: | |
| try: | |
| user = getpass.getuser() | |
| except Exception: | |
| user = os.environ.get("USER") or "user" | |
| return f"{user}@{_primary_ip()}" | |
| SPACE = os.environ.get("RELAY_SPACE", "") | |
| HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN") | |
| CLIENT_ID = os.environ.get("CLIENT_ID") or _default_client_id() | |
| POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", "5")) | |
| DEFAULT_TIMEOUT = int(os.environ.get("CMD_TIMEOUT", "120")) | |
| MAX_OUTPUT_CHARS = 200_000 | |
| MAX_FILE_BYTES = 100 * 1024 * 1024 # 100 MB cap on transfers | |
| MAX_SHELLS = int(os.environ.get("MAX_SHELLS", "48")) | |
| SHELL_IDLE_TTL = int(os.environ.get("SHELL_IDLE_TTL", "1800")) # reap idle shells after 30 min | |
| _log_lock = threading.Lock() | |
| def log(*a): | |
| with _log_lock: | |
| print(f"[{time.strftime('%H:%M:%S')}]", *a, flush=True) | |
| # --------------------------------------------------------------------------- # | |
| # Process-tree helpers (used to interrupt a command without killing bash) # | |
| # --------------------------------------------------------------------------- # | |
| def _descendants(pid: int) -> list[int]: | |
| """All descendant pids of `pid`, via /proc. Bash itself is NOT included.""" | |
| children: dict[int, list[int]] = defaultdict(list) | |
| try: | |
| entries = os.listdir("/proc") | |
| except OSError: | |
| return [] | |
| for d in entries: | |
| if not d.isdigit(): | |
| continue | |
| try: | |
| with open(f"/proc/{d}/stat") as f: | |
| data = f.read() | |
| # format: "pid (comm) state ppid ..."; comm may contain spaces/parens | |
| rest = data[data.rfind(")") + 2:].split() | |
| ppid = int(rest[1]) | |
| except (OSError, IndexError, ValueError): | |
| continue | |
| children[ppid].append(int(d)) | |
| out, stack = [], [pid] | |
| while stack: | |
| for c in children.get(stack.pop(), []): | |
| out.append(c) | |
| stack.append(c) | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| # Persistent bash session # | |
| # --------------------------------------------------------------------------- # | |
| class Shell: | |
| """A long-lived bash process. Commands run serially within the session; | |
| output is captured up to a unique sentinel line that carries the exit code.""" | |
| def __init__(self, name: str): | |
| self.name = name | |
| self.busy = False | |
| self.last_used = time.time() | |
| self._start() | |
| def _start(self): | |
| # Fresh queue + reader per incarnation so a previous reader's leftover | |
| # output (or its EOF marker) can never leak into the new shell. | |
| self._q: "queuelib.Queue[str | None]" = queuelib.Queue() | |
| self.proc = subprocess.Popen( | |
| ["bash", "--norc", "--noprofile"], | |
| stdin=subprocess.PIPE, stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, text=True, bufsize=1, | |
| start_new_session=True, | |
| env={**os.environ, "PS1": "", "TERM": "dumb"}, | |
| ) | |
| t = threading.Thread(target=self._pump, args=(self.proc, self._q), daemon=True) | |
| t.start() | |
| def _pump(proc, q): | |
| for line in proc.stdout: | |
| q.put(line) | |
| q.put(None) # EOF sentinel | |
| def _drain(self): | |
| while True: | |
| try: | |
| self._q.get_nowait() | |
| except queuelib.Empty: | |
| return | |
| def _interrupt(self): | |
| """Stop the running command but keep bash alive: signal only bash's | |
| descendants (the command + its children), never bash itself.""" | |
| for sig in (signal.SIGINT, signal.SIGTERM): | |
| kids = _descendants(self.proc.pid) | |
| if not kids: | |
| return | |
| for k in kids: | |
| try: | |
| os.kill(k, sig) | |
| except OSError: | |
| pass | |
| time.sleep(0.4) | |
| for k in _descendants(self.proc.pid): | |
| try: | |
| os.kill(k, signal.SIGKILL) | |
| except OSError: | |
| pass | |
| def close(self): | |
| try: | |
| self.proc.terminate() | |
| except Exception: | |
| pass | |
| def run(self, command: str, timeout: int) -> tuple[str, int]: | |
| if self.proc.poll() is not None: # shell died (e.g. `exit`) — respawn | |
| log(f"session {self.name}: shell exited, restarting") | |
| self._start() | |
| self.busy = True | |
| try: | |
| self._drain() | |
| marker = "__NII_END_" + uuid.uuid4().hex + "__" | |
| # Run the command as a group with stdin from /dev/null so commands | |
| # that read stdin (cat, a REPL, ssh, read) can't swallow the sentinel | |
| # and hang. The group is NOT a subshell, so cd/exports still persist. | |
| # Then print the sentinel + exit code on its own line. | |
| script = ( | |
| "{\n" + command + "\n} </dev/null\n" | |
| + f"printf '\\n%s %s\\n' '{marker}' \"$?\"\n" | |
| ) | |
| try: | |
| self.proc.stdin.write(script) | |
| self.proc.stdin.flush() | |
| except (BrokenPipeError, ValueError): | |
| return ("[relay: shell pipe broken]", 127) | |
| out_lines: list[str] = [] | |
| exit_code = 0 | |
| deadline = time.monotonic() + max(1, timeout) | |
| killed = False | |
| while True: | |
| remaining = deadline - time.monotonic() | |
| if remaining <= 0 and not killed: | |
| self._interrupt() | |
| out_lines.append(f"\n[relay: timed out after {timeout}s, interrupted]\n") | |
| killed = True | |
| deadline = time.monotonic() + 8 # grace to collect the sentinel | |
| continue | |
| try: | |
| wait = max(0.1, (deadline - time.monotonic()) if not killed else 0.5) | |
| line = self._q.get(timeout=wait) | |
| except queuelib.Empty: | |
| if killed and time.monotonic() > deadline: | |
| # bash never produced the sentinel — give up, respawn. | |
| self.close() | |
| self._start() | |
| exit_code = 124 | |
| break | |
| continue | |
| if line is None: # shell EOF | |
| if not killed: | |
| out_lines.append("\n[relay: shell closed]\n") | |
| exit_code = 137 if not killed else 130 | |
| break | |
| stripped = line.rstrip("\n") | |
| # Exact sentinel match: "<marker> <int>" only. | |
| if stripped.startswith(marker + " "): | |
| tail = stripped[len(marker) + 1:].strip() | |
| if tail.isdigit(): | |
| exit_code = int(tail) | |
| break | |
| out_lines.append(line) | |
| output = "".join(out_lines) | |
| if output.endswith("\n"): # drop the single newline our printf injected | |
| output = output[:-1] | |
| if len(output) > MAX_OUTPUT_CHARS: | |
| output = output[:MAX_OUTPUT_CHARS] + "\n[relay: output truncated]" | |
| return (output, exit_code) | |
| finally: | |
| self.busy = False | |
| self.last_used = time.time() | |
| SHELLS: dict[str, Shell] = {} | |
| SHELLS_LOCK = threading.Lock() | |
| def get_shell(name: str) -> Shell: | |
| with SHELLS_LOCK: | |
| sh = SHELLS.get(name) | |
| if sh is not None and sh.proc.poll() is not None: | |
| sh.close() | |
| sh = None | |
| if sh is None: | |
| # cap total shells: evict the least-recently-used idle one | |
| if len(SHELLS) >= MAX_SHELLS: | |
| idle = [(s.last_used, n) for n, s in SHELLS.items() if not s.busy] | |
| if idle: | |
| _, victim = min(idle) | |
| SHELLS.pop(victim).close() | |
| log(f"evicted idle session (cap): {victim}") | |
| sh = SHELLS[name] = Shell(name) | |
| log(f"opened bash session: {name}") | |
| return sh | |
| def _shell_reaper(): | |
| while True: | |
| time.sleep(120) | |
| now = time.time() | |
| with SHELLS_LOCK: | |
| for name in list(SHELLS): | |
| sh = SHELLS[name] | |
| if not sh.busy and (now - sh.last_used) > SHELL_IDLE_TTL: | |
| sh.close() | |
| SHELLS.pop(name, None) | |
| log(f"reaped idle session: {name}") | |
| # --------------------------------------------------------------------------- # | |
| # Relay client (shared, swapped atomically on reconnect) # | |
| # --------------------------------------------------------------------------- # | |
| CLIENT: Client | None = None | |
| def _make_client() -> Client: | |
| # gradio_client renamed the auth kwarg from `hf_token` to `token` (~v2.x). | |
| try: | |
| return Client(SPACE, token=HF_TOKEN, verbose=False) | |
| except TypeError: | |
| return Client(SPACE, hf_token=HF_TOKEN, verbose=False) | |
| def _meta() -> str: | |
| return f"{os.uname().sysname} {os.uname().release} pid={os.getpid()}" | |
| def connect() -> Client: | |
| global CLIENT | |
| while True: | |
| try: | |
| log(f"connecting to Space {SPACE} …") | |
| c = _make_client() | |
| c.predict(CLIENT_ID, _meta(), api_name="/register") | |
| CLIENT = c | |
| log(f"registered as '{CLIENT_ID}'") | |
| return c | |
| except Exception as e: | |
| # Don't log raw exception — it can include request payload (the secret). | |
| log(f"connect failed: {type(e).__name__} — retrying in 10s") | |
| time.sleep(10) | |
| def _post(*args, **kwargs): | |
| c = CLIENT | |
| if c is None: | |
| raise RuntimeError("no relay connection") | |
| return c.predict(*args, **kwargs) | |
| def _safe_result(cid: str, code: int, output: str, session: str): | |
| try: | |
| _post(cid, CLIENT_ID, code, output, session, api_name="/result") | |
| except Exception as e: | |
| log(f"result post failed for {session}: {type(e).__name__}: {e}") | |
| # --------------------------------------------------------------------------- # | |
| # Command handlers (run on per-session worker threads) # | |
| # --------------------------------------------------------------------------- # | |
| def _expand(p: str) -> str: | |
| return os.path.expanduser(os.path.expandvars(p or "")) | |
| def _result_path(ret) -> str: | |
| """gradio_client returns a File output as a path str, or a FileData dict.""" | |
| if isinstance(ret, dict): | |
| return ret.get("path") or ret.get("name") or "" | |
| if isinstance(ret, (list, tuple)) and ret: | |
| return _result_path(ret[0]) | |
| return ret if isinstance(ret, str) else "" | |
| def do_put(cmd: dict): | |
| """Server -> client: download the payload from the relay and write it to dest.""" | |
| cid = cmd["id"] | |
| dest = _expand(cmd.get("dest") or cmd.get("filename") or "downloaded.bin") | |
| log(f"[file] put → {dest}") | |
| try: | |
| ret = _post(cmd["file_id"], api_name="/fetch_file") | |
| src = _result_path(ret) | |
| if not src or not os.path.isfile(src): | |
| raise FileNotFoundError("relay returned no file") | |
| size = os.path.getsize(src) | |
| if size > MAX_FILE_BYTES: | |
| raise ValueError(f"{size} bytes exceeds the {MAX_FILE_BYTES}-byte limit") | |
| if os.path.isdir(dest) or dest.endswith(os.sep): | |
| dest = os.path.join(dest, cmd.get("filename") or os.path.basename(src)) | |
| os.makedirs(os.path.dirname(os.path.abspath(dest)) or ".", exist_ok=True) | |
| shutil.copy(src, dest) | |
| out, code = (f"wrote {os.path.getsize(dest)} bytes to {dest}", 0) | |
| except Exception as e: | |
| out, code = (f"[relay: put failed: {e}]", 1) | |
| _safe_result(cid, code, out, "file") | |
| log(f"[file] put → exit {code}") | |
| def do_get(cmd: dict): | |
| """Client -> server: read a local file and upload it to the relay.""" | |
| cid = cmd["id"] | |
| remote = _expand(cmd.get("remote_path")) | |
| log(f"[file] get {remote}") | |
| try: | |
| if not os.path.isfile(remote): | |
| raise FileNotFoundError(f"not a file: {remote}") | |
| size = os.path.getsize(remote) | |
| if size > MAX_FILE_BYTES: | |
| raise ValueError(f"{size} bytes exceeds the {MAX_FILE_BYTES}-byte limit") | |
| except Exception as e: | |
| try: | |
| _post(cid, CLIENT_ID, None, f"{e}", api_name="/upload_result") | |
| except Exception as e2: | |
| log(f"error report failed: {type(e2).__name__}: {e2}") | |
| log(f"[file] get → error: {e}") | |
| return | |
| try: | |
| _post(cid, CLIENT_ID, handle_file(remote), "", api_name="/upload_result") | |
| log(f"[file] get → sent {size} bytes") | |
| except Exception as e: | |
| log(f"[file] get upload failed: {type(e).__name__}: {e}") | |
| def handle(cmd: dict): | |
| typ = cmd.get("type", "exec") | |
| if typ == "put": | |
| return do_put(cmd) | |
| if typ == "get": | |
| return do_get(cmd) | |
| cid = cmd["id"] | |
| session = cmd.get("session") or "main" | |
| command = cmd["command"] | |
| timeout = int(cmd.get("timeout") or DEFAULT_TIMEOUT) | |
| log(f"[{session}] $ {command}") | |
| try: | |
| output, code = get_shell(session).run(command, timeout) | |
| except Exception as e: | |
| output, code = (f"[relay: daemon error: {e!r}]", 1) | |
| _safe_result(cid, code, output, session) | |
| log(f"[{session}] → exit {code} ({len(output)} bytes)") | |
| # --------------------------------------------------------------------------- # | |
| # Per-session workers: commands for a session run on that session's thread, # | |
| # so the poll loop never blocks and sessions run concurrently. # | |
| # --------------------------------------------------------------------------- # | |
| class Worker: | |
| def __init__(self, name: str): | |
| self.name = name | |
| self.q: "queuelib.Queue[dict]" = queuelib.Queue() | |
| threading.Thread(target=self._loop, daemon=True).start() | |
| def submit(self, cmd: dict): | |
| self.q.put(cmd) | |
| def _loop(self): | |
| while True: | |
| cmd = self.q.get() | |
| try: | |
| handle(cmd) | |
| except Exception as e: | |
| log(f"worker[{self.name}] error: {type(e).__name__}: {e}") | |
| WORKERS: dict[str, Worker] = {} | |
| WORKERS_LOCK = threading.Lock() | |
| def get_worker(name: str) -> Worker: | |
| with WORKERS_LOCK: | |
| w = WORKERS.get(name) | |
| if w is None: | |
| w = WORKERS[name] = Worker(name) | |
| return w | |
| def _shutdown(): | |
| with SHELLS_LOCK: | |
| for sh in list(SHELLS.values()): | |
| sh.close() | |
| def main(): | |
| if not SPACE: | |
| sys.exit("set RELAY_SPACE to the Space repo id, e.g. your-username/nii-relay") | |
| log(f"daemon starting — client_id='{CLIENT_ID}', poll every {POLL_INTERVAL}s") | |
| threading.Thread(target=_shell_reaper, daemon=True).start() | |
| connect() | |
| while True: | |
| try: | |
| res = CLIENT.predict(CLIENT_ID, api_name="/poll") | |
| for cmd in (res or {}).get("commands", []): | |
| get_worker(cmd.get("session") or "main").submit(cmd) | |
| except KeyboardInterrupt: | |
| log("bye") | |
| _shutdown() | |
| return | |
| except Exception as e: | |
| log(f"poll error: {type(e).__name__} — reconnecting") | |
| connect() | |
| time.sleep(POLL_INTERVAL) | |
| if __name__ == "__main__": | |
| try: | |
| main() | |
| except KeyboardInterrupt: | |
| _shutdown() | |