init: add code

This commit is contained in:
Xory 2025-12-22 18:33:55 +02:00
commit c2f9a6e600
4 changed files with 593 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/.vscode
/frp_0.65.0_linux_amd64
/__pycache__

172
main.py Normal file
View file

@ -0,0 +1,172 @@
import asyncio
import itertools
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Set
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, StreamingResponse
from pydantic import BaseModel
class OutboundPayload(BaseModel):
payload: Any
app = FastAPI()
# In-memory registries
clients: Dict[int, WebSocket] = {}
client_lock = asyncio.Lock()
client_id_seq = itertools.count(1)
sse_subscribers: Set[asyncio.Queue[str]] = set()
sse_lock = asyncio.Lock()
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def format_sse_event(data: Dict[str, Any]) -> str:
"""Build a Server-Sent Event payload with an optional event name."""
event_name = data.get("event")
payload = json.dumps(data, separators=(",", ":"))
lines: List[str] = []
if event_name:
lines.append(f"event: {event_name}")
for line in payload.splitlines():
lines.append(f"data: {line}")
lines.append("")
return "\n".join(lines) + "\n"
async def broadcast_event(event: Dict[str, Any]) -> None:
"""Send an event to all SSE subscribers without blocking the websocket loop."""
message = format_sse_event(event)
async with sse_lock:
for queue in list(sse_subscribers):
try:
queue.put_nowait(message)
except asyncio.QueueFull:
# Should not happen with the default unbounded queues, but guard anyway.
pass
@app.get("/", response_class=HTMLResponse)
async def index() -> HTMLResponse:
html_path = Path(__file__).parent / "webui.html"
return HTMLResponse(html_path.read_text(encoding="utf-8"))
@app.get("/clients")
async def list_clients() -> Dict[str, List[int]]:
async with client_lock:
return {"clients": sorted(clients.keys())}
@app.post("/clients/{client_id}/send")
async def send_to_client(client_id: int, body: OutboundPayload) -> Dict[str, Any]:
async with client_lock:
websocket = clients.get(client_id)
if websocket is None:
raise HTTPException(status_code=404, detail="Client not connected")
try:
payload_text = json.dumps(body.payload)
except TypeError as exc: # noqa: BLE001
raise HTTPException(
status_code=422, detail="Payload must be JSON serializable"
) from exc
try:
await websocket.send_text(payload_text)
except Exception as exc: # noqa: BLE001
raise HTTPException(
status_code=500, detail=f"Failed to send to client {client_id}"
) from exc
await broadcast_event(
{
"event": "outbound",
"timestamp": now_iso(),
"client_id": client_id,
"payload": body.payload,
}
)
return {"status": "sent"}
@app.get("/events")
async def sse_events() -> StreamingResponse:
queue: asyncio.Queue[str] = asyncio.Queue()
async with sse_lock:
sse_subscribers.add(queue)
async def event_stream():
try:
while True:
try:
message = await asyncio.wait_for(queue.get(), timeout=15)
yield message
except asyncio.TimeoutError:
# Keep connection alive
yield "event: keepalive\ndata: {}\n\n"
except asyncio.CancelledError:
# Client disconnected
pass
finally:
async with sse_lock:
sse_subscribers.discard(queue)
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.websocket("/ws")
async def websocket_handler(websocket: WebSocket) -> None:
await websocket.accept()
client_id = next(client_id_seq)
async with client_lock:
clients[client_id] = websocket
await broadcast_event(
{"event": "client_connected", "timestamp": now_iso(), "client_id": client_id}
)
try:
while True:
message = await websocket.receive_text()
try:
parsed = json.loads(message)
except json.JSONDecodeError:
print("json decode error")
print(message)
parsed = {"raw": message}
await broadcast_event(
{
"event": "inbound",
"timestamp": now_iso(),
"client_id": client_id,
"payload": parsed,
}
)
except WebSocketDisconnect:
pass
finally:
async with client_lock:
clients.pop(client_id, None)
await broadcast_event(
{
"event": "client_disconnected",
"timestamp": now_iso(),
"client_id": client_id,
}
)
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
fastapi==0.110.2
uvicorn[standard]==0.30.1

416
webui.html Normal file
View file

@ -0,0 +1,416 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Skylink Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<style>
:root,
html,
body {
background: #05070b;
color: #e5e7eb;
height: 100%;
}
textarea {
background: #05070b !important;
color: #e5e7eb !important;
}
pre {
background: #05070b !important;
}
#paneGrid {
height: calc(100vh - 96px);
}
</style>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
mono: ["JetBrains Mono", "ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
},
colors: {
base: "#05070b",
panel: "#0c1019",
line: "#1a2233",
accent: "#3bef8b",
accent2: "#4dd5ff",
},
boxShadow: {
frame: "0 0 0 1px rgba(59, 239, 139, 0.15)",
},
},
},
};
</script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-base text-slate-100 font-mono">
<div class="min-h-screen flex flex-col bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:24px_24px]">
<header class="border-b border-line bg-panel/95 sticky top-0 z-10">
<div class="w-full max-w-none px-4 py-3 flex items-center gap-3 overflow-x-auto scrollbar-thin scrollbar-thumb-slate-700/50">
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Clients</span>
<div id="clientTabs" class="flex items-center gap-2">
<div class="text-sm text-slate-500">No clients yet.</div>
</div>
<div class="flex items-center gap-2 ml-auto">
<button
id="refreshClients"
class="px-3 py-1 text-xs border border-line bg-base/70 rounded-lg hover:border-accent/40 transition"
>
sync
</button>
<button
id="reconnectBtn"
class="px-3 py-1 text-xs border border-line bg-base/70 rounded-lg hover:border-accent/40 transition"
>
stream
</button>
</div>
</div>
</header>
<main class="w-full max-w-none px-4 py-4 flex-1 min-h-0">
<div id="paneGrid" class="grid grid-cols-2 gap-4 w-full h-full min-h-0">
<section class="rounded-xl border border-line bg-panel shadow-frame flex flex-col h-full min-h-0">
<div class="flex items-center justify-between px-4 py-3 border-b border-line/70">
<div class="flex flex-col">
<span class="text-sm text-slate-200">Send JSON</span>
<span id="selectedLabel" class="text-xs text-slate-500">No client selected</span>
</div>
<div class="flex items-center gap-2">
<button
id="formatBtn"
class="px-3 py-1 text-xs border border-line rounded-lg bg-base/70 hover:border-accent/40 transition"
>
format
</button>
<button
id="sendBtn"
class="px-4 py-1 text-xs font-semibold rounded-lg border border-accent/60 text-base bg-accent/10 hover:bg-accent/20 transition"
>
send
</button>
</div>
</div>
<textarea
id="payloadInput"
class="flex-1 min-h-0 w-full h-full bg-base/70 text-slate-100 text-sm p-4 resize-none focus:outline-none focus:ring-1 focus:ring-accent/50 border-b border-line/70"
spellcheck="false"
></textarea>
<div class="px-4 py-2 text-xs text-slate-500 border-t border-line/70" id="statusText">
waiting for clients...
</div>
</section>
<section class="rounded-xl border border-line bg-panel shadow-frame flex flex-col h-full min-h-0">
<div class="flex items-center justify-between px-4 py-3 border-b border-line/70">
<div class="flex flex-col">
<span class="text-sm text-slate-200">Live stream</span>
<span class="text-xs text-slate-500">All inbound/outbound + connect/disconnect</span>
</div>
<button
id="clearConsole"
class="px-3 py-1 text-xs border border-line rounded-lg bg-base/70 hover:border-accent/40 transition"
>
clear
</button>
</div>
<div
id="console"
class="flex-1 min-h-0 h-full overflow-y-auto p-4 space-y-3 bg-base/70"
>
<div class="text-sm text-slate-500">Streaming events will appear here.</div>
</div>
<div class="px-4 py-2 text-xs text-slate-500 border-t border-line/70 h-1/4">
<h2 class="text-sm text-slate-200">Client API reference</h2>
<ul class="text-xs text-slate-500">
<li >ClientInfo ➜ no arguments</li>
<li>RunCMD ➜ command: string, args: vector of strings</li>
<li>URunCMD ➜ command: string</li>
<li>URunExe ➜ path: string, args: string</li>
<li>Dnx ➜ params: subset containing</li>
<li>⤷ url: string</li>
<li>⤷ name: string</li>
<li>⤷ args: vec of strings</li>
<li>⤷ run_as_system: boolean</li>
<li>⤷ file_type: string which is one of Executable, Powershell or Python</li>
</ul>
</div>
</section>
</div>
</main>
</div>
<script>
const clientTabsEl = document.getElementById("clientTabs");
const statusTextEl = document.getElementById("statusText");
const selectedLabelEl = document.getElementById("selectedLabel");
const payloadInput = document.getElementById("payloadInput");
const sendBtn = document.getElementById("sendBtn");
const formatBtn = document.getElementById("formatBtn");
const refreshBtn = document.getElementById("refreshClients");
const consoleEl = document.getElementById("console");
const clearConsoleBtn = document.getElementById("clearConsole");
const reconnectBtn = document.getElementById("reconnectBtn");
let clients = [];
let selectedClient = null;
let eventSource = null;
const maxLogs = 400;
const setStatus = (text, tone = "muted") => {
const colors = {
muted: "text-slate-500",
ok: "text-emerald-300",
warn: "text-amber-300",
error: "text-rose-300",
};
statusTextEl.className = `px-4 py-2 text-xs border-t border-line/70 ${colors[tone] || colors.muted}`;
statusTextEl.textContent = text;
};
const renderTabs = () => {
clientTabsEl.innerHTML = "";
if (!clients.length) {
const placeholder = document.createElement("div");
placeholder.className = "text-sm text-slate-500";
placeholder.textContent = "No clients yet.";
clientTabsEl.appendChild(placeholder);
selectedLabelEl.textContent = "No client selected";
return;
}
if (!selectedClient) {
selectedClient = clients[0];
}
selectedLabelEl.textContent = `Sending to client ${selectedClient}`;
clients.forEach((id) => {
const tab = document.createElement("button");
const isSelected = id === selectedClient;
tab.className =
"px-3 py-1 text-xs rounded-lg border transition whitespace-nowrap " +
(isSelected
? "border-accent/70 bg-accent/15 text-accent shadow-frame"
: "border-line bg-base/70 text-slate-200 hover:border-accent/40");
tab.textContent = `client ${id}`;
tab.onclick = () => {
selectedClient = id;
renderTabs();
};
clientTabsEl.appendChild(tab);
});
};
const formatPayload = () => {
try {
const parsed = JSON.parse(payloadInput.value);
payloadInput.value = JSON.stringify(parsed, null, 2);
setStatus("formatted JSON", "ok");
} catch (err) {
setStatus(`cannot format: ${err.message}`, "warn");
}
};
const fetchClients = async () => {
try {
const res = await fetch("/clients");
const data = await res.json();
clients = data.clients || [];
if (selectedClient && !clients.includes(selectedClient)) {
selectedClient = clients[0] ?? null;
}
renderTabs();
} catch (err) {
setStatus(`failed to load clients: ${err.message}`, "error");
}
};
const safeStringify = (value) => {
try {
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
} catch (err) {
return String(value);
}
};
const trimConsole = () => {
while (consoleEl.children.length > maxLogs) {
consoleEl.removeChild(consoleEl.firstChild);
}
};
const appendLog = ({ type, clientId, payload, timestamp }) => {
const wrapper = document.createElement("div");
wrapper.className =
"border border-line rounded-lg bg-panel/80 px-3 py-2 space-y-2 shadow-frame";
const header = document.createElement("div");
header.className = "flex items-center justify-between text-[11px] text-slate-400 uppercase tracking-wide";
const badge = document.createElement("span");
badge.className =
"px-2 py-1 rounded border border-line bg-base/60 text-slate-200";
const labels = {
inbound: ["inbound", "border-emerald-400/60 text-emerald-200"],
outbound: ["outbound", "border-accent2/60 text-accent2"],
client_connected: ["connected", "border-emerald-400/60 text-emerald-200"],
client_disconnected: ["disconnected", "border-amber-400/60 text-amber-200"],
};
const [label, cls] = labels[type] || ["event", "border-line text-slate-300"];
badge.textContent = label;
badge.className += " " + cls;
const meta = document.createElement("div");
meta.textContent = `${clientId ? "client " + clientId : "—"} • ${
timestamp ? new Date(timestamp).toLocaleTimeString() : new Date().toLocaleTimeString()
}`;
header.append(badge, meta);
wrapper.appendChild(header);
const pre = document.createElement("pre");
pre.className =
"bg-base/60 border border-line rounded-md p-2 text-xs text-slate-100 overflow-x-auto";
pre.textContent = safeStringify(payload ?? {});
wrapper.appendChild(pre);
// Use firstElementChild to ignore whitespace/text nodes
if (consoleEl.firstElementChild && consoleEl.firstElementChild.classList.contains("text-sm")) {
consoleEl.firstElementChild.remove();
}
consoleEl.appendChild(wrapper);
trimConsole();
consoleEl.scrollTop = consoleEl.scrollHeight;
};
const handleSseEvent = (event) => {
if (!event?.data) return;
try {
const data = JSON.parse(event.data);
switch (event.type) {
case "inbound":
case "outbound":
appendLog({
type: event.type,
clientId: data.client_id,
payload: data.payload,
timestamp: data.timestamp,
});
break;
case "client_connected":
if (!clients.includes(data.client_id)) {
clients.push(data.client_id);
clients.sort((a, b) => a - b);
}
appendLog({
type: event.type,
clientId: data.client_id,
payload: { status: "connected" },
timestamp: data.timestamp,
});
if (!selectedClient) {
selectedClient = data.client_id;
}
renderTabs();
break;
case "client_disconnected":
clients = clients.filter((id) => id !== data.client_id);
appendLog({
type: event.type,
clientId: data.client_id,
payload: { status: "disconnected" },
timestamp: data.timestamp,
});
if (selectedClient === data.client_id) {
selectedClient = clients[0] ?? null;
}
renderTabs();
break;
default:
break;
}
} catch (err) {
setStatus(`stream parse error: ${err.message}`, "warn");
}
};
const connectStream = () => {
if (eventSource) {
eventSource.close();
}
setStatus("connecting stream...", "muted");
eventSource = new EventSource("/events");
eventSource.addEventListener("open", () => setStatus("stream live", "ok"));
eventSource.addEventListener("error", () => setStatus("stream error", "error"));
["inbound", "outbound", "client_connected", "client_disconnected"].forEach((evt) => {
eventSource.addEventListener(evt, handleSseEvent);
});
};
const sendPayload = async () => {
if (!selectedClient) {
setStatus("select a client first", "warn");
return;
}
let parsed;
try {
parsed = JSON.parse(payloadInput.value);
} catch (err) {
setStatus(`payload must be valid JSON: ${err.message}`, "warn");
return;
}
try {
const res = await fetch(`/clients/${selectedClient}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payload: parsed }),
});
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.detail || "send failed");
}
setStatus(`sent to client ${selectedClient}`, "ok");
} catch (err) {
setStatus(err.message, "error");
}
};
const clearConsole = () => {
consoleEl.innerHTML = '<div class="text-sm text-slate-500">Stream cleared.</div>';
};
sendBtn.addEventListener("click", sendPayload);
formatBtn.addEventListener("click", formatPayload);
refreshBtn.addEventListener("click", fetchClients);
clearConsoleBtn.addEventListener("click", clearConsole);
reconnectBtn.addEventListener("click", connectStream);
payloadInput.addEventListener("keydown", (e) => {
if (e.key === "Tab") {
e.preventDefault();
const start = e.target.selectionStart;
const end = e.target.selectionEnd;
const value = e.target.value;
e.target.value = value.substring(0, start) + " " + value.substring(end);
e.target.selectionStart = e.target.selectionEnd = start + 2;
}
});
connectStream();
fetchClients();
</script>
</body>
</html>