PropNode Deploy commited on
Commit
32b00f5
·
0 Parent(s):

Deploy PropNode ZeroGPU gateway

Browse files
Files changed (3) hide show
  1. README.md +16 -0
  2. app.py +229 -0
  3. requirements.txt +11 -0
README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PropNode Admin
3
+ emoji: ⚡
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: 5.0.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: other
11
+ hardware: zerogpu
12
+ ---
13
+
14
+ # PropNode Compute Gateway
15
+
16
+ Internal compute and telemetry node.
app.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public Hugging Face ZeroGPU Gateway for PropNode Admin.
2
+
3
+ - Keeps the real codebase private in GitHub (`Gini-Eddie/propnode-admin`).
4
+ - Displays a public decoy Gradio interface to regular Hugging Face visitors.
5
+ - Unlocks the real PropNode Admin portal only via `/gate/<GATEWAY_KEY>` or authorized origins.
6
+ """
7
+ import hashlib
8
+ import hmac
9
+ import logging
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from http.cookies import SimpleCookie
15
+ from pathlib import Path
16
+
17
+ import huggingface_hub
18
+
19
+ # Compatibility shim if huggingface_hub >= 1.0 is present in the base container
20
+ if not hasattr(huggingface_hub, "HfFolder"):
21
+ class _HfFolder:
22
+ @classmethod
23
+ def get_token(cls):
24
+ return os.environ.get("HF_TOKEN")
25
+
26
+ @classmethod
27
+ def save_token(cls, token: str):
28
+ pass
29
+
30
+ huggingface_hub.HfFolder = _HfFolder
31
+
32
+ import gradio as gr
33
+ import spaces
34
+ import torch
35
+ import uvicorn
36
+ from fastapi import FastAPI
37
+ from fastapi.responses import RedirectResponse
38
+
39
+ logging.basicConfig(level=logging.INFO)
40
+ logger = logging.getLogger("propnode.gateway")
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # 1. ZeroGPU Decoy Interface (shown to public Hugging Face visitors)
44
+ # ---------------------------------------------------------------------------
45
+ zero = torch.Tensor([0]).cuda()
46
+
47
+
48
+ @spaces.GPU
49
+ def greet(n: float) -> str:
50
+ return f"Node telemetry ping: {zero.item() + float(n or 0):.1f} OK"
51
+
52
+
53
+ demo = gr.Interface(
54
+ fn=greet,
55
+ inputs=gr.Number(label="Diagnostic Signal", value=1),
56
+ outputs=gr.Text(label="Gateway Response"),
57
+ title="PropNode Telemetry Gateway",
58
+ description="Internal compute node status check.",
59
+ )
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # 2. Private Repository Loader
63
+ # ---------------------------------------------------------------------------
64
+ GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "").strip()
65
+ GITHUB_REPO = os.environ.get("GITHUB_REPO", "Gini-Eddie/propnode-admin").strip()
66
+ GATEWAY_KEY = os.environ.get("GATEWAY_KEY", "").strip()
67
+ RUNTIME_DIR = Path("/tmp/propnode_admin_runtime")
68
+ GATE_COOKIE = "__pn_gate"
69
+
70
+
71
+ def _expected_key() -> str:
72
+ return GATEWAY_KEY or os.environ.get("ADMIN_SECRET_KEY", "").strip()
73
+
74
+
75
+ def _gate_token() -> str:
76
+ secret = _expected_key() or "fallback-unconfigured"
77
+ return hashlib.sha256(f"propnode-gate:{secret}".encode()).hexdigest()
78
+
79
+
80
+ def _load_private_admin_app():
81
+ missing = [
82
+ name
83
+ for name in ("GITHUB_TOKEN", "DATABASE_URL", "ADMIN_SECRET_KEY")
84
+ if not os.environ.get(name, "").strip()
85
+ ]
86
+ if missing:
87
+ logger.warning(
88
+ "Gateway running in decoy-only mode. Configure Space Secrets to enable backend: %s",
89
+ ", ".join(missing),
90
+ )
91
+ return None
92
+
93
+ if RUNTIME_DIR.exists():
94
+ shutil.rmtree(RUNTIME_DIR)
95
+
96
+ repo_url = f"https://x-access-token:{GITHUB_TOKEN}@github.com/{GITHUB_REPO}.git"
97
+ logger.info("Fetching private repository %s into runtime directory...", GITHUB_REPO)
98
+ result = subprocess.run(
99
+ ["git", "clone", "--depth", "1", repo_url, str(RUNTIME_DIR)],
100
+ capture_output=True,
101
+ text=True,
102
+ check=False,
103
+ )
104
+ if result.returncode != 0:
105
+ logger.error("Failed to clone private repository (code %s).", result.returncode)
106
+ return None
107
+
108
+ sys.path.insert(0, str(RUNTIME_DIR))
109
+ try:
110
+ from backend.app.main import app as private_app
111
+ logger.info("Private PropNode Admin backend mounted successfully.")
112
+ return private_app
113
+ except Exception as exc:
114
+ logger.error("Failed to import private backend: %s", exc)
115
+ return None
116
+
117
+
118
+ admin_app = _load_private_admin_app()
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # 3. Public Decoy + Secret Gate FastAPI App
122
+ # ---------------------------------------------------------------------------
123
+ public_app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
124
+
125
+
126
+ @public_app.get("/gate/{key}")
127
+ async def unlock_gateway(key: str):
128
+ """Secret entry URL that sets an HttpOnly pass cookie and opens the admin login."""
129
+ expected = _expected_key()
130
+ if not expected or not hmac.compare_digest(key, expected):
131
+ return RedirectResponse(url="/decoy", status_code=302)
132
+
133
+ response = RedirectResponse(url="/login.html", status_code=302)
134
+ response.set_cookie(
135
+ GATE_COOKIE,
136
+ _gate_token(),
137
+ httponly=True,
138
+ secure=True,
139
+ samesite="lax",
140
+ path="/",
141
+ max_age=86400 * 30,
142
+ )
143
+ return response
144
+
145
+
146
+ @public_app.get("/")
147
+ async def root_redirect():
148
+ return RedirectResponse(url="/decoy", status_code=302)
149
+
150
+
151
+ public_app = gr.mount_gradio_app(public_app, demo, path="/decoy")
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # 4. Zero-Leakage ASGI Gateway Dispatcher
156
+ # ---------------------------------------------------------------------------
157
+ class GatewayRouter:
158
+ def __init__(self, decoy_asgi, private_asgi):
159
+ self.decoy_asgi = decoy_asgi
160
+ self.private_asgi = private_asgi
161
+
162
+ def _is_authorized(self, scope: dict) -> bool:
163
+ if self.private_asgi is None or scope.get("type") != "http":
164
+ return False
165
+
166
+ path: str = scope.get("path", "")
167
+ if path.startswith("/decoy") or path.startswith("/gate/"):
168
+ return False
169
+
170
+ headers = {
171
+ k.decode("latin1").lower(): v.decode("latin1")
172
+ for k, v in scope.get("headers", [])
173
+ }
174
+
175
+ # Never expose the admin app inside the Hugging Face Space iframe
176
+ if headers.get("sec-fetch-dest") == "iframe":
177
+ return False
178
+
179
+ # 1. Check browser gateway cookie set via /gate/<GATEWAY_KEY>
180
+ raw_cookie = headers.get("cookie", "")
181
+ if raw_cookie:
182
+ cookie = SimpleCookie()
183
+ try:
184
+ cookie.load(raw_cookie)
185
+ if GATE_COOKIE in cookie:
186
+ val = cookie[GATE_COOKIE].value
187
+ if hmac.compare_digest(val, _gate_token()):
188
+ return True
189
+ except Exception:
190
+ pass
191
+
192
+ # 2. Check explicit X-Gateway-Key header (from a reverse proxy)
193
+ expected = _expected_key()
194
+ header_key = headers.get("x-gateway-key", "")
195
+ if expected and header_key and hmac.compare_digest(header_key, expected):
196
+ return True
197
+
198
+ # 3. Check allowed external domain origin on /api/ calls
199
+ origin = headers.get("origin", "").rstrip("/")
200
+ allowed_origins = {
201
+ o.strip().rstrip("/")
202
+ for o in os.environ.get("ADMIN_ALLOWED_ORIGINS", "").split(",")
203
+ if o.strip() and not o.strip().endswith(".hf.space")
204
+ }
205
+ if origin and origin in allowed_origins and path.startswith("/api/"):
206
+ return True
207
+
208
+ return False
209
+
210
+ async def __call__(self, scope, receive, send):
211
+ if self._is_authorized(scope):
212
+ await self.private_asgi(scope, receive, send)
213
+ return
214
+
215
+ if scope.get("type") == "http":
216
+ path = scope.get("path", "")
217
+ if not (path == "/" or path.startswith("/decoy") or path.startswith("/gate/")):
218
+ response = RedirectResponse(url="/decoy", status_code=302)
219
+ await response(scope, receive, send)
220
+ return
221
+
222
+ await self.decoy_asgi(scope, receive, send)
223
+
224
+
225
+ app = GatewayRouter(public_app, admin_app)
226
+
227
+ if __name__ == "__main__":
228
+ port = int(os.environ.get("PORT", 7860))
229
+ uvicorn.run(app, host="0.0.0.0", port=port)
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ huggingface-hub>=0.30.0,<1.0.0
2
+ fastapi>=0.115.0,<0.129.0
3
+ starlette>=0.40.0,<1.0.0
4
+ uvicorn[standard]>=0.30,<1.0
5
+ SQLAlchemy>=2.0,<2.1
6
+ psycopg[binary]>=3.2,<4.0
7
+ pydantic-settings>=2.6,<3.0
8
+ python-jose[cryptography]>=3.3,<4.0
9
+ bcrypt>=4.2,<6.0
10
+ httpx>=0.27,<1.0
11
+ python-multipart>=0.0.18,<1.0