chankhavu commited on
Commit
63e165b
·
verified ·
1 Parent(s): e4cc589

Initial NII Relay

Browse files
Files changed (3) hide show
  1. README.md +27 -6
  2. app.py +255 -0
  3. requirements.txt +1 -0
README.md CHANGED
@@ -1,13 +1,34 @@
1
  ---
2
- title: Remote Shell
3
- emoji: 🏆
4
- colorFrom: green
5
  colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.15.2
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: NII Relay
3
+ emoji: 🛰️
4
+ colorFrom: blue
5
  colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 5.9.1
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # NII Relay
13
+
14
+ A private message relay between your browser and shell daemons running inside
15
+ no-SSH containers (e.g. on the NII cluster). The Space holds a queue of
16
+ commands; daemons poll it, run the commands in persistent bash sessions, and
17
+ post the output back.
18
+
19
+ ## Deploy
20
+
21
+ 1. Create a **private** Space (SDK: Gradio). Upload `app.py` and
22
+ `requirements.txt`.
23
+ 2. In **Settings → Variables and secrets**, add a secret named `RELAY_SECRET`
24
+ with a long random value. The daemons must use the same value.
25
+ 3. Wait for the Space to build. The URL (`https://huggingface.co/spaces/<you>/<name>`)
26
+ and its repo id (`<you>/<name>`) are what the daemon connects to.
27
+
28
+ ## Use
29
+
30
+ Open the Space, pick an online container (🟢), name a bash session, type a
31
+ command, hit **Run**. Reuse a session name to keep its `cd`/env/venv; type a new
32
+ name to open a fresh shell.
33
+
34
+ The daemon lives in `../daemon/`.
app.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NII Relay — a tiny HF Space that acts as a message relay between you (in the
3
+ browser) and shell daemons running inside no-SSH containers on the cluster.
4
+
5
+ Flow
6
+ ----
7
+ 1. A daemon inside a container registers with a CLIENT_ID and polls this Space
8
+ every few seconds (via the `gradio_client` API endpoints below).
9
+ 2. You pick a container + bash session in the web UI and submit a shell command.
10
+ 3. The next poll hands the command to the daemon, which marks it ACKNOWLEDGED,
11
+ runs it in a persistent bash session, and posts stdout + exit code back.
12
+ 4. The UI shows the output.
13
+
14
+ State is in-memory only. If the Space restarts, daemons simply re-register on
15
+ their next poll — but any not-yet-delivered commands are lost. That is fine for
16
+ interactive debugging.
17
+
18
+ Security: every API call must carry the shared RELAY_SECRET (set as a Space
19
+ secret). The Space being private is the first line of defense; the secret is the
20
+ second so a leaked HF read-token alone can't drive your shells.
21
+ """
22
+
23
+ import os
24
+ import time
25
+ import uuid
26
+ import threading
27
+ from collections import defaultdict
28
+
29
+ import gradio as gr
30
+
31
+ SECRET = os.environ.get("RELAY_SECRET", "") # set this as a Space secret
32
+ CLIENT_STALE_AFTER = 30 # seconds w/o a poll => "offline"
33
+ MAX_OUTPUT_CHARS = 200_000 # cap stored output per command
34
+ MAX_HISTORY_PER_CLIENT = 200 # ring-buffer of commands
35
+
36
+ LOCK = threading.Lock()
37
+ CLIENTS: dict[str, dict] = {} # cid -> {last_seen, meta}
38
+ COMMANDS: dict[str, dict] = {} # command_id -> record
39
+ BY_CLIENT: dict[str, list[str]] = defaultdict(list) # cid -> [command_id, ...]
40
+
41
+
42
+ def _now() -> float:
43
+ return time.time()
44
+
45
+
46
+ def _check(secret: str):
47
+ if SECRET and secret != SECRET:
48
+ raise gr.Error("bad secret")
49
+
50
+
51
+ def _touch_client(cid: str, meta: str = ""):
52
+ c = CLIENTS.setdefault(cid, {"first_seen": _now(), "meta": meta})
53
+ c["last_seen"] = _now()
54
+ if meta:
55
+ c["meta"] = meta
56
+
57
+
58
+ # --------------------------------------------------------------------------- #
59
+ # Machine API (called by the daemon via gradio_client) #
60
+ # --------------------------------------------------------------------------- #
61
+ def api_register(client_id: str, secret: str, meta: str):
62
+ _check(secret)
63
+ client_id = (client_id or "").strip()
64
+ if not client_id:
65
+ raise gr.Error("empty client_id")
66
+ with LOCK:
67
+ _touch_client(client_id, meta or "")
68
+ return {"ok": True, "ts": _now()}
69
+
70
+
71
+ def api_poll(client_id: str, secret: str):
72
+ """Return all pending commands for this client and flip them to acknowledged."""
73
+ _check(secret)
74
+ client_id = (client_id or "").strip()
75
+ out = []
76
+ with LOCK:
77
+ _touch_client(client_id)
78
+ for cmd_id in BY_CLIENT[client_id]:
79
+ rec = COMMANDS[cmd_id]
80
+ if rec["status"] == "pending":
81
+ rec["status"] = "acknowledged"
82
+ rec["acknowledged_at"] = _now()
83
+ out.append({
84
+ "id": rec["id"],
85
+ "session": rec["session"],
86
+ "command": rec["command"],
87
+ "timeout": rec["timeout"],
88
+ })
89
+ return {"commands": out, "ts": _now()}
90
+
91
+
92
+ def api_result(command_id: str, client_id: str, secret: str,
93
+ exit_code: float, stdout: str, session: str):
94
+ _check(secret)
95
+ with LOCK:
96
+ rec = COMMANDS.get(command_id)
97
+ if rec is None:
98
+ # Space probably restarted; record it anyway so output isn't lost.
99
+ rec = COMMANDS[command_id] = {
100
+ "id": command_id, "client_id": client_id, "session": session,
101
+ "command": "(unknown — relay restarted)", "created": _now(),
102
+ "status": "pending",
103
+ }
104
+ BY_CLIENT[client_id].append(command_id)
105
+ rec["status"] = "done"
106
+ rec["finished_at"] = _now()
107
+ rec["exit_code"] = int(exit_code)
108
+ rec["stdout"] = (stdout or "")[:MAX_OUTPUT_CHARS]
109
+ _touch_client(client_id)
110
+ return {"ok": True}
111
+
112
+
113
+ # --------------------------------------------------------------------------- #
114
+ # Human UI helpers #
115
+ # --------------------------------------------------------------------------- #
116
+ def _client_choices() -> list[str]:
117
+ with LOCK:
118
+ items = sorted(CLIENTS.items(), key=lambda kv: kv[1].get("last_seen", 0), reverse=True)
119
+ labels = []
120
+ for cid, c in items:
121
+ age = _now() - c.get("last_seen", 0)
122
+ dot = "🟢" if age < CLIENT_STALE_AFTER else "🔴"
123
+ labels.append(f"{dot} {cid}")
124
+ return labels
125
+
126
+
127
+ def _strip_dot(label: str) -> str:
128
+ return label.split(" ", 1)[1] if label and label[:1] in "🟢🔴" else (label or "")
129
+
130
+
131
+ def ui_send(client_label: str, session: str, command: str, timeout: int):
132
+ cid = _strip_dot(client_label)
133
+ if not cid:
134
+ return "⚠️ pick a container first", command
135
+ if not command.strip():
136
+ return "⚠️ empty command", command
137
+ session = (session or "main").strip()
138
+ cmd_id = uuid.uuid4().hex
139
+ with LOCK:
140
+ COMMANDS[cmd_id] = {
141
+ "id": cmd_id, "client_id": cid, "session": session,
142
+ "command": command, "timeout": int(timeout),
143
+ "status": "pending", "created": _now(),
144
+ }
145
+ hist = BY_CLIENT[cid]
146
+ hist.append(cmd_id)
147
+ # trim ring buffer
148
+ while len(hist) > MAX_HISTORY_PER_CLIENT:
149
+ old = hist.pop(0)
150
+ COMMANDS.pop(old, None)
151
+ return f"➡️ sent to `{cid}` [{session}]", ""
152
+
153
+
154
+ _STATUS_ICON = {"pending": "⏳", "acknowledged": "📨", "done": "✅"}
155
+
156
+
157
+ def ui_history(client_label: str, n: int = 12) -> str:
158
+ cid = _strip_dot(client_label)
159
+ if not cid:
160
+ return "_Select a container to see its command history._"
161
+ with LOCK:
162
+ ids = list(BY_CLIENT.get(cid, []))[-int(n):][::-1]
163
+ recs = [dict(COMMANDS[i]) for i in ids if i in COMMANDS]
164
+ if not recs:
165
+ return f"_No commands for `{cid}` yet._"
166
+ blocks = []
167
+ for r in recs:
168
+ icon = _STATUS_ICON.get(r["status"], "•")
169
+ head = f"{icon} **[{r.get('session','main')}]** `{r['command']}`"
170
+ if r["status"] == "done":
171
+ ec = r.get("exit_code", "?")
172
+ out = r.get("stdout", "") or "(no output)"
173
+ head += f" → exit {ec}"
174
+ blocks.append(head + f"\n```text\n{out}\n```")
175
+ else:
176
+ blocks.append(head + f"\n_{r['status']}…_")
177
+ return "\n\n".join(blocks)
178
+
179
+
180
+ def ui_refresh(client_label: str):
181
+ choices = _client_choices()
182
+ # keep the current selection if its cid is still present
183
+ keep = None
184
+ cur = _strip_dot(client_label)
185
+ for ch in choices:
186
+ if _strip_dot(ch) == cur:
187
+ keep = ch
188
+ break
189
+ if keep is None and choices:
190
+ keep = choices[0]
191
+ return gr.update(choices=choices, value=keep), ui_history(keep or "")
192
+
193
+
194
+ # --------------------------------------------------------------------------- #
195
+ # Layout #
196
+ # --------------------------------------------------------------------------- #
197
+ with gr.Blocks(title="NII Relay", theme=gr.themes.Soft()) as demo:
198
+ gr.Markdown(
199
+ "# 🛰️ NII Relay\n"
200
+ "Send shell commands to no-SSH containers via a polling daemon. "
201
+ "Pick a container, name a bash session (reuse a name to keep its `cd`/env, "
202
+ "or type a new name to open a fresh session), and run."
203
+ )
204
+
205
+ with gr.Row():
206
+ client_dd = gr.Dropdown(label="Container", choices=_client_choices(),
207
+ interactive=True, scale=3)
208
+ refresh_btn = gr.Button("↻ Refresh", scale=1)
209
+
210
+ with gr.Row():
211
+ session_tb = gr.Textbox(label="bash session", value="main", scale=2)
212
+ timeout_nb = gr.Number(label="timeout (s)", value=120, precision=0, scale=1)
213
+
214
+ command_tb = gr.Textbox(label="command", lines=2,
215
+ placeholder="nvidia-smi | cd /mnt/data && ls -la | tail -n 50 train.log")
216
+ with gr.Row():
217
+ send_btn = gr.Button("Run ▶", variant="primary")
218
+ send_status = gr.Markdown("")
219
+
220
+ history_md = gr.Markdown("_Select a container to see its command history._")
221
+
222
+ timer = gr.Timer(2.0)
223
+
224
+ # wiring
225
+ send_btn.click(ui_send, [client_dd, session_tb, command_tb, timeout_nb],
226
+ [send_status, command_tb])
227
+ command_tb.submit(ui_send, [client_dd, session_tb, command_tb, timeout_nb],
228
+ [send_status, command_tb])
229
+ refresh_btn.click(ui_refresh, [client_dd], [client_dd, history_md])
230
+ client_dd.change(ui_history, [client_dd], history_md)
231
+ timer.tick(ui_refresh, [client_dd], [client_dd, history_md])
232
+
233
+ # --- hidden machine API endpoints (called by the daemon) ---
234
+ with gr.Row(visible=False):
235
+ a_cid = gr.Textbox()
236
+ a_secret = gr.Textbox()
237
+ a_meta = gr.Textbox()
238
+ a_cmdid = gr.Textbox()
239
+ a_session = gr.Textbox()
240
+ a_exit = gr.Number()
241
+ a_stdout = gr.Textbox()
242
+ a_out = gr.JSON()
243
+ b_reg = gr.Button()
244
+ b_poll = gr.Button()
245
+ b_res = gr.Button()
246
+ b_reg.click(api_register, [a_cid, a_secret, a_meta], a_out, api_name="register")
247
+ b_poll.click(api_poll, [a_cid, a_secret], a_out, api_name="poll")
248
+ b_res.click(api_result, [a_cmdid, a_cid, a_secret, a_exit, a_stdout, a_session],
249
+ a_out, api_name="result")
250
+
251
+
252
+ if __name__ == "__main__":
253
+ demo.queue(default_concurrency_limit=16).launch(
254
+ server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860))
255
+ )
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ gradio>=5.0,<6