chankhavu commited on
Commit
1d99cf9
·
verified ·
1 Parent(s): 755433d

Harden: fail-closed+const-time auth, id validation, FILES_DIR reaper+trim, server size cap, copy-outside-lock, api_poll .get(), no analytics

Browse files
Files changed (2) hide show
  1. __pycache__/app.cpython-311.pyc +0 -0
  2. app.py +105 -20
__pycache__/app.cpython-311.pyc CHANGED
Binary files a/__pycache__/app.cpython-311.pyc and b/__pycache__/app.cpython-311.pyc differ
 
app.py CHANGED
@@ -21,6 +21,8 @@ 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 shutil
@@ -36,10 +38,17 @@ CLIENT_STALE_AFTER = 30 # seconds w/o a poll => "
36
  MAX_OUTPUT_CHARS = 200_000 # cap stored output per command
37
  MAX_HISTORY_PER_CLIENT = 200 # ring-buffer of commands
38
  MAX_FILE_BYTES = 100 * 1024 * 1024 # 100 MB cap on transfers
 
39
 
40
  FILES_DIR = pathlib.Path(tempfile.gettempdir()) / "relay_files"
41
  FILES_DIR.mkdir(exist_ok=True)
42
 
 
 
 
 
 
 
43
  LOCK = threading.Lock()
44
  CLIENTS: dict[str, dict] = {} # cid -> {last_seen, meta}
45
  COMMANDS: dict[str, dict] = {} # command_id -> record
@@ -59,8 +68,10 @@ def _human(n: float) -> str:
59
 
60
 
61
  def _check(secret: str):
62
- if SECRET and secret != SECRET:
63
- raise gr.Error("bad secret")
 
 
64
 
65
 
66
  def _touch_client(cid: str, meta: str = ""):
@@ -70,6 +81,57 @@ def _touch_client(cid: str, meta: str = ""):
70
  c["meta"] = meta
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  # --------------------------------------------------------------------------- #
74
  # Machine API (called by the daemon via gradio_client) #
75
  # --------------------------------------------------------------------------- #
@@ -90,8 +152,10 @@ def api_poll(client_id: str, secret: str):
90
  out = []
91
  with LOCK:
92
  _touch_client(client_id)
93
- for cmd_id in BY_CLIENT[client_id]:
94
- rec = COMMANDS[cmd_id]
 
 
95
  if rec["status"] == "pending":
96
  rec["status"] = "acknowledged"
97
  rec["acknowledged_at"] = _now()
@@ -113,6 +177,8 @@ def api_poll(client_id: str, secret: str):
113
  def api_result(command_id: str, client_id: str, secret: str,
114
  exit_code: float, stdout: str, session: str):
115
  _check(secret)
 
 
116
  with LOCK:
117
  rec = COMMANDS.get(command_id)
118
  if rec is None:
@@ -122,7 +188,7 @@ def api_result(command_id: str, client_id: str, secret: str,
122
  "command": "(unknown — relay restarted)", "created": _now(),
123
  "status": "pending",
124
  }
125
- BY_CLIENT[client_id].append(command_id)
126
  rec["status"] = "done"
127
  rec["finished_at"] = _now()
128
  rec["exit_code"] = int(exit_code)
@@ -134,6 +200,8 @@ def api_result(command_id: str, client_id: str, secret: str,
134
  def api_fetch_file(file_id: str, secret: str):
135
  """Daemon calls this to download a server->client (push) payload."""
136
  _check(secret)
 
 
137
  info = PUT_FILES.get(file_id)
138
  if not info or not os.path.isfile(info["path"]):
139
  raise gr.Error("unknown or expired file_id")
@@ -144,6 +212,24 @@ def api_upload_file(command_id: str, client_id: str, secret: str, fileobj, error
144
  """Daemon calls this to post the result of a client->server (pull) request.
145
  `fileobj` is the uploaded file path on success, or None when `error` is set."""
146
  _check(secret)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  with LOCK:
148
  rec = COMMANDS.get(command_id)
149
  if rec is None: # relay restarted — keep the file anyway
@@ -152,15 +238,14 @@ def api_upload_file(command_id: str, client_id: str, secret: str, fileobj, error
152
  "type": "get", "command": "(file fetch — relay restarted)",
153
  "created": _now(), "status": "pending",
154
  }
155
- BY_CLIENT[client_id].append(command_id)
156
  rec["status"] = "done"
157
  rec["finished_at"] = _now()
158
  _touch_client(client_id)
159
- if fileobj:
160
- fname = os.path.basename(fileobj)
161
- saved = FILES_DIR / f"{command_id}__{fname}"
162
- shutil.copy(fileobj, saved)
163
- sz = saved.stat().st_size
164
  rec.update(stored_path=str(saved), filename=fname, size=sz,
165
  exit_code=0, stdout=f"fetched {fname} ({_human(sz)})")
166
  else:
@@ -200,12 +285,7 @@ def ui_send(client_label: str, session: str, command: str, timeout: int):
200
  "command": command, "timeout": int(timeout),
201
  "status": "pending", "created": _now(),
202
  }
203
- hist = BY_CLIENT[cid]
204
- hist.append(cmd_id)
205
- # trim ring buffer
206
- while len(hist) > MAX_HISTORY_PER_CLIENT:
207
- old = hist.pop(0)
208
- COMMANDS.pop(old, None)
209
  return f"➡️ sent to `{cid}` [{session}]", ""
210
 
211
 
@@ -233,7 +313,7 @@ def ui_put_file(client_label: str, fileobj, dest: str):
233
  "command": f"📤 send {fname} ({_human(sz)}) → {dest}",
234
  "timeout": 300, "status": "pending", "created": _now(),
235
  }
236
- BY_CLIENT[cid].append(cmd_id)
237
  return f"➡️ sending `{fname}` to `{cid}`:`{dest}`", None
238
 
239
 
@@ -252,7 +332,7 @@ def ui_get_file(client_label: str, remote_path: str):
252
  "remote_path": remote_path, "command": f"📥 fetch {remote_path}",
253
  "timeout": 300, "status": "pending", "created": _now(),
254
  }
255
- BY_CLIENT[cid].append(cmd_id)
256
  return f"➡️ requested `{remote_path}` from `{cid}`"
257
 
258
 
@@ -330,7 +410,7 @@ def ui_tick(client_label: str, last_choices: list[str], last_hist: str,
330
  # --------------------------------------------------------------------------- #
331
  # Layout #
332
  # --------------------------------------------------------------------------- #
333
- with gr.Blocks(title="NII Relay") as demo:
334
  gr.Markdown(
335
  "# 🛰️ NII Relay\n"
336
  "Send shell commands to no-SSH containers via a polling daemon. "
@@ -421,7 +501,12 @@ with gr.Blocks(title="NII Relay") as demo:
421
  a_out, api_name="upload_result")
422
 
423
 
 
 
424
  if __name__ == "__main__":
 
 
 
425
  demo.queue(default_concurrency_limit=16).launch(
426
  server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)),
427
  theme=gr.themes.Soft(), max_file_size="150mb",
 
21
  """
22
 
23
  import os
24
+ import re
25
+ import hmac
26
  import time
27
  import uuid
28
  import shutil
 
38
  MAX_OUTPUT_CHARS = 200_000 # cap stored output per command
39
  MAX_HISTORY_PER_CLIENT = 200 # ring-buffer of commands
40
  MAX_FILE_BYTES = 100 * 1024 * 1024 # 100 MB cap on transfers
41
+ FILE_TTL = 3600 # delete stored files older than 1h
42
 
43
  FILES_DIR = pathlib.Path(tempfile.gettempdir()) / "relay_files"
44
  FILES_DIR.mkdir(exist_ok=True)
45
 
46
+ _HEX32 = re.compile(r"\A[0-9a-f]{32}\Z")
47
+
48
+
49
+ def _valid_id(s) -> bool:
50
+ return isinstance(s, str) and bool(_HEX32.match(s))
51
+
52
  LOCK = threading.Lock()
53
  CLIENTS: dict[str, dict] = {} # cid -> {last_seen, meta}
54
  COMMANDS: dict[str, dict] = {} # command_id -> record
 
68
 
69
 
70
  def _check(secret: str):
71
+ # Fail closed: an empty/unset RELAY_SECRET rejects everything (no silent
72
+ # auth-off). Constant-time compare to avoid a timing oracle on the secret.
73
+ if not SECRET or not hmac.compare_digest(secret or "", SECRET):
74
+ raise gr.Error("unauthorized")
75
 
76
 
77
  def _touch_client(cid: str, meta: str = ""):
 
81
  c["meta"] = meta
82
 
83
 
84
+ def _unlink(path: str):
85
+ try:
86
+ os.unlink(path)
87
+ except OSError:
88
+ pass
89
+
90
+
91
+ def _drop_command(cmd_id: str):
92
+ """Remove a command record and any file it owns on disk. Call under LOCK."""
93
+ rec = COMMANDS.pop(cmd_id, None)
94
+ if not rec:
95
+ return
96
+ if rec.get("stored_path"):
97
+ _unlink(rec["stored_path"])
98
+ fid = rec.get("file_id")
99
+ if fid:
100
+ info = PUT_FILES.pop(fid, None)
101
+ if info:
102
+ _unlink(info["path"])
103
+
104
+
105
+ def _append_and_trim(cid: str, cmd_id: str):
106
+ """Append to a client's history and ring-buffer it, deleting evicted files.
107
+ Call under LOCK. Used by every append site so growth is bounded uniformly."""
108
+ hist = BY_CLIENT[cid]
109
+ hist.append(cmd_id)
110
+ while len(hist) > MAX_HISTORY_PER_CLIENT:
111
+ _drop_command(hist.pop(0))
112
+
113
+
114
+ def _file_reaper():
115
+ """Backstop against disk growth on the ephemeral Space: delete stored files
116
+ older than FILE_TTL and prune PUT_FILES whose file is gone."""
117
+ while True:
118
+ time.sleep(600)
119
+ cutoff = _now() - FILE_TTL
120
+ try:
121
+ for p in FILES_DIR.iterdir():
122
+ try:
123
+ if p.is_file() and p.stat().st_mtime < cutoff:
124
+ p.unlink()
125
+ except OSError:
126
+ pass
127
+ except OSError:
128
+ pass
129
+ with LOCK:
130
+ for fid in list(PUT_FILES):
131
+ if not os.path.isfile(PUT_FILES[fid]["path"]):
132
+ PUT_FILES.pop(fid, None)
133
+
134
+
135
  # --------------------------------------------------------------------------- #
136
  # Machine API (called by the daemon via gradio_client) #
137
  # --------------------------------------------------------------------------- #
 
152
  out = []
153
  with LOCK:
154
  _touch_client(client_id)
155
+ for cmd_id in list(BY_CLIENT.get(client_id, [])):
156
+ rec = COMMANDS.get(cmd_id)
157
+ if rec is None:
158
+ continue
159
  if rec["status"] == "pending":
160
  rec["status"] = "acknowledged"
161
  rec["acknowledged_at"] = _now()
 
177
  def api_result(command_id: str, client_id: str, secret: str,
178
  exit_code: float, stdout: str, session: str):
179
  _check(secret)
180
+ if not _valid_id(command_id):
181
+ raise gr.Error("bad command id")
182
  with LOCK:
183
  rec = COMMANDS.get(command_id)
184
  if rec is None:
 
188
  "command": "(unknown — relay restarted)", "created": _now(),
189
  "status": "pending",
190
  }
191
+ _append_and_trim(client_id, command_id)
192
  rec["status"] = "done"
193
  rec["finished_at"] = _now()
194
  rec["exit_code"] = int(exit_code)
 
200
  def api_fetch_file(file_id: str, secret: str):
201
  """Daemon calls this to download a server->client (push) payload."""
202
  _check(secret)
203
+ if not _valid_id(file_id):
204
+ raise gr.Error("bad file id")
205
  info = PUT_FILES.get(file_id)
206
  if not info or not os.path.isfile(info["path"]):
207
  raise gr.Error("unknown or expired file_id")
 
212
  """Daemon calls this to post the result of a client->server (pull) request.
213
  `fileobj` is the uploaded file path on success, or None when `error` is set."""
214
  _check(secret)
215
+ if not _valid_id(command_id):
216
+ raise gr.Error("bad command id")
217
+
218
+ # Do filesystem work BEFORE taking the lock (a 100 MB copy must not stall
219
+ # every concurrent poll/UI handler). Enforce the size cap server-side too —
220
+ # don't trust the client.
221
+ saved = None
222
+ fname = sz = None
223
+ too_big = False
224
+ if fileobj:
225
+ sz = os.path.getsize(fileobj)
226
+ if sz > MAX_FILE_BYTES:
227
+ too_big = True
228
+ else:
229
+ fname = os.path.basename(fileobj)
230
+ saved = FILES_DIR / f"{command_id}__{fname}"
231
+ shutil.copy(fileobj, saved)
232
+
233
  with LOCK:
234
  rec = COMMANDS.get(command_id)
235
  if rec is None: # relay restarted — keep the file anyway
 
238
  "type": "get", "command": "(file fetch — relay restarted)",
239
  "created": _now(), "status": "pending",
240
  }
241
+ _append_and_trim(client_id, command_id)
242
  rec["status"] = "done"
243
  rec["finished_at"] = _now()
244
  _touch_client(client_id)
245
+ if too_big:
246
+ rec.update(exit_code=1,
247
+ stdout=f"file exceeds the {_human(MAX_FILE_BYTES)} limit")
248
+ elif saved is not None:
 
249
  rec.update(stored_path=str(saved), filename=fname, size=sz,
250
  exit_code=0, stdout=f"fetched {fname} ({_human(sz)})")
251
  else:
 
285
  "command": command, "timeout": int(timeout),
286
  "status": "pending", "created": _now(),
287
  }
288
+ _append_and_trim(cid, cmd_id)
 
 
 
 
 
289
  return f"➡️ sent to `{cid}` [{session}]", ""
290
 
291
 
 
313
  "command": f"📤 send {fname} ({_human(sz)}) → {dest}",
314
  "timeout": 300, "status": "pending", "created": _now(),
315
  }
316
+ _append_and_trim(cid, cmd_id)
317
  return f"➡️ sending `{fname}` to `{cid}`:`{dest}`", None
318
 
319
 
 
332
  "remote_path": remote_path, "command": f"📥 fetch {remote_path}",
333
  "timeout": 300, "status": "pending", "created": _now(),
334
  }
335
+ _append_and_trim(cid, cmd_id)
336
  return f"➡️ requested `{remote_path}` from `{cid}`"
337
 
338
 
 
410
  # --------------------------------------------------------------------------- #
411
  # Layout #
412
  # --------------------------------------------------------------------------- #
413
+ with gr.Blocks(title="NII Relay", analytics_enabled=False) as demo:
414
  gr.Markdown(
415
  "# 🛰️ NII Relay\n"
416
  "Send shell commands to no-SSH containers via a polling daemon. "
 
501
  a_out, api_name="upload_result")
502
 
503
 
504
+ threading.Thread(target=_file_reaper, daemon=True).start()
505
+
506
  if __name__ == "__main__":
507
+ if not SECRET:
508
+ print("WARNING: RELAY_SECRET is empty — all daemon API calls will be rejected "
509
+ "(fail-closed). Set it as a Space secret.", flush=True)
510
  demo.queue(default_concurrency_limit=16).launch(
511
  server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)),
512
  theme=gr.themes.Soft(), max_file_size="150mb",