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
+ Purgatory