jupiter / proxy.py
arfandi7322's picture
fix: move DASHBOARD_HTML to module level (was unreachable after run_app)
6ad7c17
Raw
History Blame Contribute Delete
32.3 kB
"""
Universal multi-port reverse proxy + password gate for HF Spaces.
Routes:
/ → Dashboard (lists known ports, add new)
/__probe__/{port} → JSON probe: detect what app runs on a port
/{port}/... → Proxied to localhost:{port}
/login, /health → Auth gate + healthcheck
All routes protected by the same password gate (HMAC cookie session).
Designed to transparently host many app types behind a path-based proxy:
JupyterLab, VS Code Server, Laravel, Next.js, React dev server, etc.
Environment variables:
password – gate password (empty = no auth required)
SESSION_SECRET – HMAC signing key (auto-generated if unset)
"""
import asyncio
import hmac
import os
import re
import secrets
import time
from aiohttp import web, ClientSession, WSMsgType, ClientTimeout, TCPConnector
# ── Configuration ────────────────────────────────────────────────────
PASSWORD = os.environ.get("password", "")
SESSION_SECRET = os.environ.get("SESSION_SECRET") or secrets.token_hex(32)
print("PASSWORD loaded: {} (len={})".format("yes" if PASSWORD else "EMPTY", len(PASSWORD)), flush=True)
COOKIE_NAME = "jupiter_session"
COOKIE_MAX_AGE = 7 * 24 * 3600 # 7 days
CACHE_MAX_ITEMS = 512
CACHE_TTL = 300 # 5 min
# NOTE: .json deliberately NOT cached — many APIs serve dynamic JSON.
CACHEABLE_EXTENSIONS = {
'.js', '.css', '.woff', '.woff2', '.ttf', '.eot', '.svg', '.png',
'.jpg', '.jpeg', '.gif', '.ico', '.map',
}
MAX_CONNECTIONS = 200
MAX_PER_HOST = 100
CONNECTOR_KEEPALIVE = 30
# Regex to detect /{port}/... paths
PORT_RE = re.compile(r'^/(\d{2,5})(?P<rest>/.*)?$')
# ── In-memory static cache ──────────────────────────────────────────
class StaticCache:
"""LRU-ish cache for truly static assets (js/css/fonts/images)."""
def __init__(self, max_items=CACHE_MAX_ITEMS, ttl=CACHE_TTL):
self.max_items = max_items
self.ttl = ttl
self._cache = {}
def _is_cacheable(self, path, status, headers):
"""Decide if a response may be cached."""
if status != 200:
return False
lower = path.lower()
if not any(lower.endswith(ext) for ext in CACHEABLE_EXTENSIONS):
return False
# Respect explicit cache directives.
cc = headers.get("Cache-Control", "").lower()
if any(d in cc for d in ("no-store", "no-cache", "private")):
return False
# Never cache responses that set cookies or vary on auth.
if headers.get("Set-Cookie"):
return False
vary = headers.get("Vary", "").lower()
if "authorization" in vary or "cookie" in vary:
return False
return True
def get(self, key):
entry = self._cache.get(key)
if not entry:
return None
ts, status, headers, body = entry
if time.time() - ts < self.ttl:
return status, headers, body
del self._cache[key]
return None
def put(self, key, status, headers, body):
if not self._is_cacheable(key, status, headers):
return
if len(self._cache) >= self.max_items:
oldest_key = min(self._cache, key=lambda k: self._cache[k][0])
del self._cache[oldest_key]
self._cache[key] = (time.time(), status, headers, body)
# ── Session helpers ──────────────────────────────────────────────────
def _sign(value):
return hmac.new(SESSION_SECRET.encode(), value.encode(), "sha256").hexdigest()
def _make_cookie_value():
sid = secrets.token_hex(16)
return "{}:{}".format(sid, _sign(sid))
def _valid_session(value):
if not value or ":" not in value:
return False
sid, sig = value.split(":", 1)
return hmac.compare_digest(_sign(sid), sig)
# ── Cookie forwarding helpers ────────────────────────────────────────
def filter_request_cookies(cookie_header):
"""Strip the proxy's own session cookie before forwarding to upstream.
Apps behind the proxy shouldn't see jupiter_session.
"""
if not cookie_header:
return ""
kept = []
for part in cookie_header.split(";"):
part = part.strip()
if not part:
continue
if part.startswith(COOKIE_NAME + "="):
continue
kept.append(part)
return "; ".join(kept)
def rewrite_set_cookie(value, port):
"""Scope a Set-Cookie header under /{port}/ so cookies don't leak.
Rewrites Path=/ → Path=/{port}/ and strips Domain= attribute.
"""
if not value:
return value
attrs = value.split(";")
new_attrs = []
for attr in attrs:
a = attr.strip()
low = a.lower()
if low.startswith("domain="):
continue # drop domain scoping
if low.startswith("path="):
path_val = a[len("path="):]
# Normalise to the port-prefixed path.
expected = "/{}/".format(port)
if path_val == "/" or path_val == "":
new_attrs.append("Path=" + expected)
elif path_val.startswith(expected):
new_attrs.append(a) # already prefixed
else:
# Sub-path like /foo → /{port}/foo
new_attrs.append("Path=" + expected + path_val.lstrip("/"))
else:
new_attrs.append(a)
return "; ".join(new_attrs)
# ── Body URL rewriting (fixes absolute URLs in HTML/CSS) ────────────
# Matches: attr="/foo" attr='/foo' attr=/foo url(/foo) but NOT // http: https: data:
_URL_ATTR_RE = re.compile(
r'''(?P<pre>(?:href|src|action|poster|formaction|data-src|srcset)\s*=\s*)(?P<q>["']?)(?P<url>/[^\s"'>)]*)(?P<post>)''',
re.IGNORECASE,
)
_CSS_URL_RE = re.compile(
r'''(url\(\s*['"]?)(/[^\s'")]+)''',
re.IGNORECASE,
)
def rewrite_body_urls(body_bytes, port):
"""Prefix absolute root-relative URLs with /{port}/ in HTML/CSS.
Skips protocol-relative (//), absolute (http://), and data: URIs.
"""
try:
text = body_bytes.decode("utf-8", errors="replace")
except Exception:
return body_bytes # binary — don't touch
prefix = "/{}".format(port)
# Don't double-prefix paths that already start with /{port}/
def _pref(m):
url = m.group("url")
if url.startswith("//") or url.startswith(prefix + "/") or url == prefix:
return m.group(0)
return m.group("pre") + m.group("q") + prefix + url
def _css(m):
url = m.group(2)
if url.startswith("//") or url.startswith(prefix + "/"):
return m.group(0)
return m.group(1) + prefix + url
text = _URL_ATTR_RE.sub(_pref, text)
text = _CSS_URL_RE.sub(_css, text)
return text.encode("utf-8")
# ── Middleware ─────────────────────────────────────────────────────────
@web.middleware
async def auth_middleware(request, handler):
path = request.path
# Public routes
if path in ("/login", "/health", "/favicon.ico") or path == "/":
return await handler(request)
# Probe endpoint requires auth too (don't leak what's running)
if path.startswith("/__probe__/"):
if PASSWORD and not _valid_session(request.cookies.get(COOKIE_NAME)):
raise web.HTTPFound("/login?next={}".format(path))
return await handler(request)
# Everything else requires session cookie
if PASSWORD and not _valid_session(request.cookies.get(COOKIE_NAME)):
raise web.HTTPFound("/login?next={}".format(path))
return await handler(request)
# ── Route handlers: auth + dashboard ────────────────────────────────
async def health(request):
return web.Response(text="ok")
async def dashboard(request):
return web.Response(text=DASHBOARD_HTML, content_type="text/html")
async def login_page(request):
filepath = os.path.join(os.path.dirname(os.path.abspath(__file__)), "login.html")
with open(filepath, "r", encoding="utf-8") as f:
return web.Response(text=f.read(), content_type="text/html")
async def login_submit(request):
data = await request.post()
submitted = data.get("password", "")
if not PASSWORD or hmac.compare_digest(submitted, PASSWORD):
next_url = data.get("next") or request.query.get("next") or "/"
resp = web.Response(status=302, headers={"Location": next_url})
resp.set_cookie(
COOKIE_NAME,
_make_cookie_value(),
max_age=COOKIE_MAX_AGE,
httponly=True,
samesite="Lax",
secure=False,
path="/",
)
return resp
return web.Response(status=302, headers={"Location": "/login?error=1"})
# ── Probe endpoint: detect what app runs on a port ──────────────────
def detect_app_type(status, headers):
"""Guess the app framework from response headers."""
powered = headers.get("X-Powered-By", "").lower()
server = headers.get("Server", "").lower()
set_cookie = headers.get("Set-Cookie", "").lower()
location = headers.get("Location", "").lower()
if "next" in powered:
return "nextjs", "Next.js", "\u2705"
if "laravel" in set_cookie or "php" in powered:
return "laravel", "Laravel", "\U0001F418"
if "express" in powered or "webpack" in powered:
return "react", "React/Node", "\u269B\uFE0F"
if "code-server" in server or "vscode" in server:
return "vscode", "VS Code Server", "\U0001F4BB"
if "tornado" in server or "jupyter" in set_cookie:
return "jupyter", "JupyterLab", "\U0001F4D3"
if "vite" in powered or "vite" in server:
return "vite", "Vite", "\u26A1"
return "unknown", "Unknown", "\u2753"
async def probe(request):
"""GET /__probe__/{port} → JSON describing what's on that port."""
port_str = request.match_info["port"]
try:
port = int(port_str)
except ValueError:
return web.json_response({"error": "invalid port"}, status=400)
upstream = "http://127.0.0.1:{}/".format(port)
try:
session = request.app["http_session"]
# Try HEAD first (cheap), fall back to GET.
async with session.get(upstream, allow_redirects=False, timeout=ClientTimeout(total=8)) as resp:
status = resp.status
headers = resp.headers
await resp.read()
except Exception as e:
return web.json_response({
"port": port,
"status": "offline",
"error": str(e),
})
app_type, app_label, icon = detect_app_type(status, headers)
return web.json_response({
"port": port,
"status": "online",
"http_status": status,
"app_type": app_type,
"app_label": app_label,
"icon": icon,
"server": headers.get("Server", ""),
"powered_by": headers.get("X-Powered-By", ""),
})
# ── Catch-all: parse port and route ──────────────────────────────────
async def catch_all(request):
"""Parse /{port}/... and dispatch; otherwise serve dashboard."""
match = PORT_RE.match(request.path)
if match:
port = int(match.group(1))
rest = match.group("rest") or "/"
return await port_proxy(request, port, rest)
return await dashboard(request)
# ── Port proxy (HTTP + WebSocket) ────────────────────────────────────
async def port_proxy(request, port, rest_path):
"""Dispatch a request to localhost:{port}."""
is_ws = (request.headers.get("upgrade", "").lower() == "websocket")
upstream_http = "http://127.0.0.1:{}".format(port)
upstream_ws = "ws://127.0.0.1:{}".format(port)
# JupyterLab has base_url='/8888/' configured, so keep its prefix.
if port == 8888:
forward_path = request.path
keep_prefix = True
else:
forward_path = rest_path
keep_prefix = False
if is_ws:
return await ws_relay(request, upstream_ws, forward_path, port)
return await http_proxy(request, port, upstream_http, forward_path, keep_prefix)
# ── WebSocket relay (with subprotocol + Origin rewrite) ─────────────
async def ws_relay(request, upstream_base, path, port):
target_url = upstream_base + path
if request.query_string:
target_url += "?" + request.query_string
client_ws = web.WebSocketResponse()
await client_ws.prepare(request)
# Forward subprotocols and extensions so VS Code Server & friends connect.
subprotocols = []
proto_header = request.headers.get("Sec-WebSocket-Protocol", "")
if proto_header:
subprotocols = [p.strip() for p in proto_header.split(",") if p.strip()]
# Build forwarded headers: rewrite Origin so upstream trusts us.
fwd_headers = {
"Origin": "http://127.0.0.1:{}".format(port),
}
# Preserve Sec-WebSocket-Extensions (permessage-deflate etc.)
ext = request.headers.get("Sec-WebSocket-Extensions")
if ext:
fwd_headers["Sec-WebSocket-Extensions"] = ext
try:
kwargs = {
"autoclose": False,
"autoping": True,
"heartbeat": 30,
}
if subprotocols:
kwargs["protocols"] = subprotocols
async with request.app["http_session"].ws_connect(
target_url, headers=fwd_headers, **kwargs
) as upstream_ws:
async def c2u():
try:
async for msg in client_ws:
if msg.type == WSMsgType.TEXT:
await upstream_ws.send_str(msg.data)
elif msg.type == WSMsgType.BINARY:
await upstream_ws.send_bytes(msg.data)
elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING,
WSMsgType.CLOSED, WSMsgType.ERROR):
break
except Exception:
pass
finally:
if not upstream_ws.closed:
await upstream_ws.close()
async def u2c():
try:
async for msg in upstream_ws:
if msg.type == WSMsgType.TEXT:
await client_ws.send_str(msg.data)
elif msg.type == WSMsgType.BINARY:
await client_ws.send_bytes(msg.data)
elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING,
WSMsgType.CLOSED, WSMsgType.ERROR):
break
except Exception:
pass
finally:
if not client_ws.closed:
await client_ws.close()
await asyncio.gather(c2u(), u2c(), return_exceptions=True)
except Exception as e:
print("WS RELAY ERROR {} port={}: {}".format(request.path, port, e), flush=True)
finally:
if not client_ws.closed:
await client_ws.close()
return client_ws
# ── HTTP reverse proxy (streaming + rewriting) ─────────────────────
async def http_proxy(request, port, upstream_base, path, keep_prefix=False):
target_url = upstream_base + path
if request.query_string:
target_url += "?" + request.query_string
# Static cache lookup for GET
cache = request.app["static_cache"]
cache_key = "{}{}".format(port, path)
if request.method == "GET":
cached = cache.get(cache_key)
if cached:
status, headers, body = cached
content_type = headers.get("Content-Type", "")
if "text/html" in content_type or "text/css" in content_type:
body = rewrite_body_urls(body, port)
stream = web.StreamResponse(status=status, headers=dict(headers))
stream.content_length = len(body)
await stream.prepare(request)
await stream.write(body)
await stream.write_eof()
return stream
# Forward request headers (drop hop-by-hop + proxy-specific).
skip = {"host", "connection", "keep-alive", "transfer-encoding",
"upgrade", "content-length", "accept-encoding"}
headers = {k: v for k, v in request.headers.items() if k.lower() not in skip}
# Forward app cookies (strip our own session cookie).
cookie_val = filter_request_cookies(request.headers.get("Cookie", ""))
if cookie_val:
headers["Cookie"] = cookie_val
# Rewrite Referer to strip the /{port}/ prefix so upstream sees clean paths.
referer = headers.get("Referer")
if referer:
prefix = "/{}/".format(port)
# Replace any /{port}/ occurrence in the referer path.
try:
from urllib.parse import urlsplit, urlunsplit
parts = urlsplit(referer)
new_path = parts.path
if new_path.startswith(prefix):
new_path = "/" + new_path[len(prefix):]
headers["Referer"] = urlunsplit(
(parts.scheme, parts.netloc, new_path, parts.query, parts.fragment)
)
except Exception:
pass
# Determine if body needs streaming (non-empty) — stream it to upstream.
body_iter = None
if request.can_read_body:
body_iter = request.content # async stream of bytes
try:
session = request.app["http_session"]
async with session.request(
request.method,
target_url,
headers=headers,
data=body_iter,
allow_redirects=False,
) as upstream:
# Build response headers, dropping hop-by-hop + content-encoding.
skip_resp = {"transfer-encoding", "connection", "keep-alive",
"content-encoding", "content-length"}
resp_headers = {}
for k, v in upstream.headers.items():
if k.lower() in skip_resp:
continue
if k.lower() == "set-cookie":
# Scope cookie under /{port}/
resp_headers[k] = rewrite_set_cookie(v, port)
continue
if k.lower() == "location" and not keep_prefix:
# Rewrite redirect Location (both absolute path & localhost URL).
loc = v
if loc.startswith("/") and not loc.startswith("//"):
resp_headers[k] = "/{}{}".format(port, loc)
elif loc.startswith(upstream_base):
rest = loc[len(upstream_base):]
if rest and not rest.startswith("/"):
rest = "/" + rest
resp_headers[k] = "/{}{}".format(port, rest)
else:
resp_headers[k] = v
continue
resp_headers[k] = v
# Decide: stream as-is, or rewrite body for HTML/CSS?
content_type = upstream.headers.get("Content-Type", "").lower()
should_rewrite = ("text/html" in content_type or "text/css" in content_type)
if not should_rewrite:
# ── Pure streaming path (SSE, large files, API, everything else)
stream = web.StreamResponse(status=upstream.status, headers=resp_headers)
# Disable any upstream/edge buffering for SSE / HMR.
stream.headers["X-Accel-Buffering"] = "no"
await stream.prepare(request)
async for chunk in upstream.content.iter_any():
await stream.write(chunk)
await stream.write_eof()
return stream
# ── Rewrite path: buffer HTML/CSS, rewrite URLs, then send.
body_bytes = await upstream.read()
body_bytes = rewrite_body_urls(body_bytes, port)
# Cache static asset (mostly .css) if eligible.
if request.method == "GET":
cache.put(cache_key, upstream.status,
{k: v for k, v in resp_headers.items()}, body_bytes)
return web.Response(
status=upstream.status,
headers=resp_headers,
body=body_bytes,
)
except Exception as e:
print("PROXY ERROR {} port={}: {}".format(request.method, port, e), flush=True)
return web.Response(
status=502,
text="Bad Gateway: Cannot reach port {}. Is an app running on it? ({})".format(port, e),
)
# ── App lifecycle ───────────────────────────────────────────────────
async def on_startup(app):
connector = TCPConnector(
limit=MAX_CONNECTIONS,
limit_per_host=MAX_PER_HOST,
keepalive_timeout=CONNECTOR_KEEPALIVE,
enable_cleanup_closed=True,
)
# total=None so SSE/HMR long-poll connections never time out at the
# client-session level; connect timeouts stay bounded.
session = ClientSession(
connector=connector,
timeout=ClientTimeout(total=None, connect=10, sock_connect=10, sock_read=None),
)
app["http_session"] = session
app["static_cache"] = StaticCache()
print("Proxy pool: max_conn={} keepalive={}s cache_items={}".format(
MAX_CONNECTIONS, CONNECTOR_KEEPALIVE, CACHE_MAX_ITEMS), flush=True)
async def on_cleanup(app):
await app["http_session"].close()
# ── Dashboard HTML (defined at import time so handlers can reference it) ──
DASHBOARD_HTML = """<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jupiter — Dashboard</title>
<style>
:root {
--bg: #0f1117; --card: #1a1d29; --accent: #6366f1; --accent-hover: #4f46e5;
--text: #e5e7eb; --muted: #9ca3af; --green: #22c55e; --red: #ef4444; --yellow: #eab308;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh; padding: 2rem;
}
.header { display: flex; align-items: center; gap: 1rem; margin-bottom: 2rem; }
.header .logo { font-size: 2.5rem; }
.header h1 { font-size: 1.75rem; font-weight: 600; }
.header .subtitle { color: var(--muted); font-size: 0.875rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.port-card {
background: var(--card); border-radius: 12px; padding: 1.5rem;
border: 1px solid #2d3142; transition: border-color 0.2s, transform 0.15s;
cursor: pointer; text-decoration: none; color: inherit; display: flex;
flex-direction: column; gap: 0.5rem;
}
.port-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.port-card .top { display: flex; align-items: center; justify-content: space-between; }
.port-card .app-icon { font-size: 1.5rem; }
.port-card .port-num { font-size: 2rem; font-weight: 700; color: var(--accent); }
.port-card .app-label { color: var(--text); font-size: 0.95rem; font-weight: 500; }
.port-card .port-url { color: var(--muted); font-size: 0.8rem; font-family: monospace; }
.port-card .status { margin-top: 0.5rem; font-size: 0.8rem; display: flex; align-items: center; gap: 0.4rem; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.status-dot.on { background: var(--green); }
.status-dot.off { background: var(--red); }
.status-dot.checking { background: var(--yellow); }
.remove-btn {
background: none; border: none; color: var(--muted); cursor: pointer;
font-size: 1.1rem; padding: 2px 6px; border-radius: 6px;
}
.remove-btn:hover { color: var(--red); background: rgba(239,68,68,0.1); }
.add-card {
background: transparent; border: 2px dashed #2d3142; border-radius: 12px; padding: 1.5rem;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.5rem; cursor: pointer; transition: border-color 0.2s; color: var(--muted);
}
.add-card:hover { border-color: var(--accent); color: var(--text); }
.add-card .icon { font-size: 2rem; }
.add-card .text { font-size: 0.875rem; }
.modal-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); z-index: 100; align-items: center; justify-content: center;
}
.modal-overlay.show { display: flex; }
.modal { background: var(--card); border-radius: 16px; padding: 2rem; width: 90%; max-width: 360px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.modal h2 { font-size: 1.25rem; margin-bottom: 1rem; }
.modal label { display: block; color: var(--muted); font-size: 0.8rem; margin: 0.75rem 0 0.25rem; }
.modal input {
width: 100%; padding: 0.75rem 1rem; border-radius: 10px; border: 1px solid #2d3142;
background: #0f1117; color: var(--text); font-size: 1rem; outline: none;
}
.modal input:focus { border-color: var(--accent); }
.modal-actions { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
.modal-actions button {
flex: 1; padding: 0.75rem; border-radius: 10px; border: none; font-size: 0.9rem;
font-weight: 600; cursor: pointer; transition: opacity 0.2s;
}
.modal-actions .btn-primary { background: var(--accent); color: white; }
.modal-actions .btn-primary:hover { background: var(--accent-hover); }
.modal-actions .btn-secondary { background: #2d3142; color: var(--text); }
.modal-actions .btn-secondary:hover { background: #3d4152; }
.hint { color: var(--muted); font-size: 0.75rem; margin-top: 1rem; }
</style>
</head>
<body>
<div class="header">
<div class="logo">&#x1FA90;</div>
<div>
<h1>Jupiter</h1>
<div class="subtitle">Multi-Port Proxy Dashboard &middot; auto-detect</div>
</div>
</div>
<div class="grid" id="portGrid"></div>
<div class="modal-overlay" id="modal">
<div class="modal">
<h2>&#x2795; Connect to Port</h2>
<label for="portInput">Port Number</label>
<input type="number" id="portInput" placeholder="3000" min="1" max="65535">
<label for="labelInput">Custom Label (optional)</label>
<input type="text" id="labelInput" placeholder="My App">
<div class="modal-actions">
<button class="btn-secondary" onclick="closeModal()">Batal</button>
<button class="btn-primary" onclick="addPort()">Add &#x2192;</button>
</div>
<div class="hint">App type terdeteksi otomatis. Untuk Next.js/React, asset otomatis di-rewrite. Pastikan app sudah running di terminal.</div>
</div>
</div>
<script>
let ports = JSON.parse(localStorage.getItem('jupiter_ports') || '[]');
if (!ports.find(p => p.port === 8888)) {
ports.unshift({ port: 8888, label: 'JupyterLab' });
savePorts();
}
function savePorts() { localStorage.setItem('jupiter_ports', JSON.stringify(ports)); }
function renderPorts() {
const grid = document.getElementById('portGrid');
grid.innerHTML = '';
ports.forEach(item => {
const card = document.createElement('a');
card.className = 'port-card';
card.href = '/' + item.port + '/';
card.innerHTML = `
<div class="top">
<span class="app-icon" id="icon-${item.port}">&#x23F3;</span>
<button class="remove-btn" onclick="removePort(event, ${item.port})">&#x2715;</button>
</div>
<div class="port-num">${item.port}</div>
<div class="app-label" id="label-${item.port}">${item.label || '...'}</div>
<div class="port-url">localhost:${item.port}</div>
<div class="status">
<span class="status-dot checking" id="dot-${item.port}"></span>
<span id="status-${item.port}">Checking...</span>
</div>
`;
grid.appendChild(card);
});
const addCard = document.createElement('div');
addCard.className = 'add-card';
addCard.onclick = openModal;
addCard.innerHTML = '<div class="icon">&#x2795;</div><div class="text">Connect to Port</div>';
grid.appendChild(addCard);
ports.forEach(item => probePort(item.port));
}
async function probePort(port) {
const dot = document.getElementById('dot-' + port);
const status = document.getElementById('status-' + port);
const icon = document.getElementById('icon-' + port);
const label = document.getElementById('label-' + port);
if (!dot) return;
try {
const res = await fetch('/__probe__/' + port);
const data = await res.json();
if (data.status === 'online') {
dot.className = 'status-dot on';
status.textContent = data.app_label + ' \\u00b7 online';
icon.textContent = data.icon;
// Update label if user hadn't set a custom one
const item = ports.find(p => p.port === port);
if (item && (!item.label || item.label === 'JupyterLab' || item.label === '...')) {
item.label = data.app_label;
label.textContent = data.app_label;
savePorts();
}
} else {
dot.className = 'status-dot off';
status.textContent = 'offline';
icon.textContent = '\\u274C';
}
} catch (e) {
dot.className = 'status-dot off';
status.textContent = 'offline';
icon.textContent = '\\u274C';
}
}
function removePort(e, port) {
e.preventDefault();
e.stopPropagation();
ports = ports.filter(p => p.port !== port);
savePorts();
renderPorts();
}
function openModal() {
document.getElementById('modal').classList.add('show');
document.getElementById('portInput').value = '';
document.getElementById('labelInput').value = '';
document.getElementById('portInput').focus();
}
function closeModal() { document.getElementById('modal').classList.remove('show'); }
function addPort() {
const port = parseInt(document.getElementById('portInput').value);
const label = document.getElementById('labelInput').value;
if (!port || port < 1 || port > 65535) return;
if (!ports.find(p => p.port === port)) {
ports.push({ port: port, label: label || '' });
savePorts();
}
closeModal();
renderPorts();
window.open('/' + port + '/', '_blank');
}
document.getElementById('modal').onclick = function(e) { if (e.target === this) closeModal(); };
document.getElementById('portInput').onkeydown = function(e) { if (e.key === 'Enter') addPort(); };
renderPorts();
// Re-probe every 30s to catch apps starting/stopping.
setInterval(() => { ports.forEach(item => probePort(item.port)); }, 30000);
</script>
</body>
</html>
"""
def create_app():
app = web.Application(middlewares=[auth_middleware])
app.on_startup.append(on_startup)
app.on_cleanup.append(on_cleanup)
app.router.add_get("/health", health)
app.router.add_get("/login", login_page)
app.router.add_post("/login", login_submit)
app.router.add_get("/__probe__/{port}", probe)
# Catch-all: dashboard or /{port}/...
app.router.add_route("*", "/{tail:.*}", catch_all)
return app
if __name__ == "__main__":
app = create_app()
print("Proxy starting on http://0.0.0.0:7860", flush=True)
web.run_app(app, host="0.0.0.0", port=7860, print=None, handle_signals=True)