From e8ad1e36495ba0847d0b36c83c37933788a2fe8b Mon Sep 17 00:00:00 2001 From: KingKDot Date: Fri, 20 Mar 2026 22:38:17 -0500 Subject: [PATCH] 1.5.0 update --- Overlord-Client/cmd/agent/config/identity.go | 52 ++ .../cmd/agent/config/identity_android.go | 7 + .../cmd/agent/config/identity_darwin.go | 27 + .../cmd/agent/config/identity_linux.go | 18 + .../cmd/agent/config/identity_windows.go | 25 + Overlord-Client/cmd/agent/session.go | 123 ++++ Overlord-Client/cmd/agent/wire/protocol.go | 12 + Overlord-Client/go.mod | 5 +- Overlord-Client/go.sum | 6 +- Overlord-Server/config.json.example | 3 + Overlord-Server/public/assets/nav/role-ui.js | 2 + Overlord-Server/public/assets/nav/template.js | 8 + Overlord-Server/public/assets/purgatory-ui.js | 445 ++++++++++++++ Overlord-Server/public/purgatory.html | 218 +++++++ Overlord-Server/src/auditLog.ts | 4 + Overlord-Server/src/config.ts | 33 ++ Overlord-Server/src/db.ts | 131 ++++- Overlord-Server/src/main-server.ts | 2 + Overlord-Server/src/protocol.ts | 20 +- Overlord-Server/src/purgatory.test.ts | 551 ++++++++++++++++++ Overlord-Server/src/server/http-dispatch.ts | 4 + .../src/server/routes/client-routes.ts | 4 +- .../src/server/routes/enrollment-routes.ts | 321 ++++++++++ .../src/server/routes/page-routes.ts | 1 + .../routes/websocket-lifecycle-routes.ts | 300 ++++++++-- .../src/server/websocket-runtime.ts | 2 +- Overlord-Server/src/sessions/types.ts | 1 + Overlord-Server/src/types.ts | 6 + go.work | 2 +- go.work.sum | 5 +- 30 files changed, 2259 insertions(+), 79 deletions(-) create mode 100644 Overlord-Client/cmd/agent/config/identity.go create mode 100644 Overlord-Client/cmd/agent/config/identity_android.go create mode 100644 Overlord-Client/cmd/agent/config/identity_darwin.go create mode 100644 Overlord-Client/cmd/agent/config/identity_linux.go create mode 100644 Overlord-Client/cmd/agent/config/identity_windows.go create mode 100644 Overlord-Server/public/assets/purgatory-ui.js create mode 100644 Overlord-Server/public/purgatory.html create mode 100644 Overlord-Server/src/purgatory.test.ts create mode 100644 Overlord-Server/src/server/routes/enrollment-routes.ts diff --git a/Overlord-Client/cmd/agent/config/identity.go b/Overlord-Client/cmd/agent/config/identity.go new file mode 100644 index 0000000..5b130d6 --- /dev/null +++ b/Overlord-Client/cmd/agent/config/identity.go @@ -0,0 +1,52 @@ +package config + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "io" + "log" + + "golang.org/x/crypto/hkdf" +) + +type Identity struct { + PublicKey ed25519.PublicKey + PrivateKey ed25519.PrivateKey + Fingerprint string // hex SHA256 of public key +} + +func DeriveIdentity() Identity { + hwid := deriveHWID() + machineID := platformMachineID() + if machineID == "" { + log.Printf("[identity] WARNING: no OS-specific machine ID available, using HWID-only derivation") + machineID = hwid + } + + hkdfReader := hkdf.New(sha256.New, []byte(machineID), []byte(hwid), []byte("overlord-identity")) + seed := make([]byte, ed25519.SeedSize) + if _, err := io.ReadFull(hkdfReader, seed); err != nil { + log.Fatalf("[identity] HKDF derivation failed: %v", err) + } + + privateKey := ed25519.NewKeyFromSeed(seed) + publicKey := privateKey.Public().(ed25519.PublicKey) + + fp := sha256.Sum256(publicKey) + + return Identity{ + PublicKey: publicKey, + PrivateKey: privateKey, + Fingerprint: hex.EncodeToString(fp[:]), + } +} + +func (id *Identity) Sign(data []byte) []byte { + return ed25519.Sign(id.PrivateKey, data) +} + +func (id *Identity) PublicKeyBase64() string { + return base64.StdEncoding.EncodeToString(id.PublicKey) +} diff --git a/Overlord-Client/cmd/agent/config/identity_android.go b/Overlord-Client/cmd/agent/config/identity_android.go new file mode 100644 index 0000000..e23374b --- /dev/null +++ b/Overlord-Client/cmd/agent/config/identity_android.go @@ -0,0 +1,7 @@ +//go:build android + +package config + +func platformMachineID() string { + return "" +} diff --git a/Overlord-Client/cmd/agent/config/identity_darwin.go b/Overlord-Client/cmd/agent/config/identity_darwin.go new file mode 100644 index 0000000..2fc1a96 --- /dev/null +++ b/Overlord-Client/cmd/agent/config/identity_darwin.go @@ -0,0 +1,27 @@ +//go:build darwin + +package config + +import ( + "log" + "os/exec" + "strings" +) + +func platformMachineID() string { + out, err := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output() + if err != nil { + log.Printf("[identity] WARNING: failed to run ioreg: %v", err) + return "" + } + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "IOPlatformUUID") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + return strings.Trim(strings.TrimSpace(parts[1]), `"`) + } + } + } + log.Printf("[identity] WARNING: IOPlatformUUID not found in ioreg output") + return "" +} diff --git a/Overlord-Client/cmd/agent/config/identity_linux.go b/Overlord-Client/cmd/agent/config/identity_linux.go new file mode 100644 index 0000000..c36b2ad --- /dev/null +++ b/Overlord-Client/cmd/agent/config/identity_linux.go @@ -0,0 +1,18 @@ +//go:build linux && !android + +package config + +import ( + "log" + "os" + "strings" +) + +func platformMachineID() string { + data, err := os.ReadFile("/etc/machine-id") + if err != nil { + log.Printf("[identity] WARNING: failed to read /etc/machine-id: %v", err) + return "" + } + return strings.TrimSpace(string(data)) +} diff --git a/Overlord-Client/cmd/agent/config/identity_windows.go b/Overlord-Client/cmd/agent/config/identity_windows.go new file mode 100644 index 0000000..70cc1e6 --- /dev/null +++ b/Overlord-Client/cmd/agent/config/identity_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package config + +import ( + "log" + + "golang.org/x/sys/windows/registry" +) + +func platformMachineID() string { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, registry.QUERY_VALUE) + if err != nil { + log.Printf("[identity] WARNING: failed to open Cryptography registry key: %v", err) + return "" + } + defer k.Close() + + val, _, err := k.GetStringValue("MachineGuid") + if err != nil { + log.Printf("[identity] WARNING: failed to read MachineGuid: %v", err) + return "" + } + return val +} diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index 88f9261..c51bf59 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/base64" "errors" "fmt" "log" @@ -134,6 +135,12 @@ func runClient(cfg config.Config) { sleepFor = reconnectDelay() log.Printf("reconnect: immediate retry in %s", sleepFor) } + if sessionErr != nil { + if d := enrollmentRetryDelay(sessionErr); d > 0 { + sleepFor = d + log.Printf("purgatory: retry in %s", sleepFor) + } + } time.Sleep(sleepFor) } } @@ -304,6 +311,9 @@ func shouldRetryImmediately(err error) bool { } var closeErr *websocket.CloseError if errors.As(err, &closeErr) { + if closeErr.Code == 4001 || closeErr.Code == 4002 || closeErr.Code == 4003 { + return false + } if closeErr.Code == websocket.StatusNormalClosure || closeErr.Code == websocket.StatusGoingAway { return true } @@ -321,6 +331,43 @@ func shouldRetryImmediately(err error) bool { return false } +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 +} + func getPingInterval() time.Duration { raw := strings.TrimSpace(os.Getenv("OVERLORD_PING_INTERVAL_MS")) if raw == "" { @@ -350,7 +397,50 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. defer cancel() defer conn.Close(websocket.StatusNormalClosure, "bye") + 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) + } + safeWriter := wire.NewSafeWriter(conn) + + 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) + } + env := &rt.Env{Conn: safeWriter, Cfg: cfg, Cancel: cancel, SelectedDisplay: handlers.GetPersistedDisplay()} env.SetLastPong(time.Now().UnixMilli()) env.Console = rt.NewConsoleHub(env) @@ -389,12 +479,45 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. MonitorInfo: toWireMonitorInfo(capture.MonitorInfos()), Country: cfg.Country, BuildTag: cfg.BuildTag, + PublicKey: publicKeyB64, + Signature: signatureB64, } if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil { return fmt.Errorf("send hello: %w", err) } + 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") + 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 + } + } + 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() diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index 173cd05..15151d0 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -13,6 +13,18 @@ type Hello struct { MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"` Country string `msgpack:"country,omitempty"` BuildTag string `msgpack:"buildTag,omitempty"` + PublicKey string `msgpack:"publicKey,omitempty"` + Signature string `msgpack:"signature,omitempty"` +} + +type EnrollmentChallenge struct { + Type string `msgpack:"type"` + Nonce string `msgpack:"nonce"` +} + +type EnrollmentStatus struct { + Type string `msgpack:"type"` + Status string `msgpack:"status"` } type MonitorInfo struct { diff --git a/Overlord-Client/go.mod b/Overlord-Client/go.mod index a92e81b..a67d1ec 100644 --- a/Overlord-Client/go.mod +++ b/Overlord-Client/go.mod @@ -1,6 +1,6 @@ module overlord-client -go 1.24 +go 1.25.0 require ( github.com/creack/pty v1.1.21 @@ -14,7 +14,8 @@ require ( require ( github.com/Kirizu-Official/windows-camera-go v0.2.0 github.com/gen2brain/malgo v0.11.24 - golang.org/x/sys v0.24.0 + golang.org/x/crypto v0.49.0 + golang.org/x/sys v0.42.0 ) require ( diff --git a/Overlord-Client/go.sum b/Overlord-Client/go.sum index a5fd751..6f9b899 100644 --- a/Overlord-Client/go.sum +++ b/Overlord-Client/go.sum @@ -32,9 +32,11 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/Overlord-Server/config.json.example b/Overlord-Server/config.json.example index 565aeee..1ea3388 100644 --- a/Overlord-Server/config.json.example +++ b/Overlord-Server/config.json.example @@ -32,5 +32,8 @@ "telegramEnabled": false, "telegramBotToken": "", "telegramChatId": "" + }, + "enrollment": { + "requireApproval": true } } diff --git a/Overlord-Server/public/assets/nav/role-ui.js b/Overlord-Server/public/assets/nav/role-ui.js index 1d126ee..3f3c4e3 100644 --- a/Overlord-Server/public/assets/nav/role-ui.js +++ b/Overlord-Server/public/assets/nav/role-ui.js @@ -8,6 +8,7 @@ export function applyUserRoleUI(user, refs) { scriptsLink, logsLink, notificationsLink, + enrollmentLink, } = refs; if (!user || !usernameDisplay || !roleBadge) return; @@ -70,6 +71,7 @@ export function applyUserRoleUI(user, refs) { if (user.role === "admin" || user.role === "operator") { buildLink?.classList.remove("hidden"); notificationsLink?.classList.remove("hidden"); + enrollmentLink?.classList.remove("hidden"); } if (user.role !== "viewer") { scriptsLink?.classList.remove("hidden"); diff --git a/Overlord-Server/public/assets/nav/template.js b/Overlord-Server/public/assets/nav/template.js index ad230f5..86e8c3f 100644 --- a/Overlord-Server/public/assets/nav/template.js +++ b/Overlord-Server/public/assets/nav/template.js @@ -77,6 +77,12 @@ export function mountNav(host) { class="hidden inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 hover:bg-slate-800 text-slate-300 transition-colors" > Users +