1.5.0 update

This commit is contained in:
KingKDot
2026-03-20 22:38:17 -05:00
parent 265321dce2
commit e8ad1e3649
30 changed files with 2259 additions and 79 deletions
@@ -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)
}
@@ -0,0 +1,7 @@
//go:build android
package config
func platformMachineID() string {
return ""
}
@@ -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 ""
}
@@ -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))
}
@@ -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
}
+123
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"log" "log"
@@ -134,6 +135,12 @@ func runClient(cfg config.Config) {
sleepFor = reconnectDelay() sleepFor = reconnectDelay()
log.Printf("reconnect: immediate retry in %s", sleepFor) 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) time.Sleep(sleepFor)
} }
} }
@@ -304,6 +311,9 @@ func shouldRetryImmediately(err error) bool {
} }
var closeErr *websocket.CloseError var closeErr *websocket.CloseError
if errors.As(err, &closeErr) { 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 { if closeErr.Code == websocket.StatusNormalClosure || closeErr.Code == websocket.StatusGoingAway {
return true return true
} }
@@ -321,6 +331,43 @@ func shouldRetryImmediately(err error) bool {
return false 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 { func getPingInterval() time.Duration {
raw := strings.TrimSpace(os.Getenv("OVERLORD_PING_INTERVAL_MS")) raw := strings.TrimSpace(os.Getenv("OVERLORD_PING_INTERVAL_MS"))
if raw == "" { if raw == "" {
@@ -350,7 +397,50 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
defer cancel() defer cancel()
defer conn.Close(websocket.StatusNormalClosure, "bye") 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) 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 := &rt.Env{Conn: safeWriter, Cfg: cfg, Cancel: cancel, SelectedDisplay: handlers.GetPersistedDisplay()}
env.SetLastPong(time.Now().UnixMilli()) env.SetLastPong(time.Now().UnixMilli())
env.Console = rt.NewConsoleHub(env) env.Console = rt.NewConsoleHub(env)
@@ -389,12 +479,45 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
MonitorInfo: toWireMonitorInfo(capture.MonitorInfos()), MonitorInfo: toWireMonitorInfo(capture.MonitorInfos()),
Country: cfg.Country, Country: cfg.Country,
BuildTag: cfg.BuildTag, BuildTag: cfg.BuildTag,
PublicKey: publicKeyB64,
Signature: signatureB64,
} }
if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil { if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil {
return fmt.Errorf("send hello: %w", err) 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 { 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) log.Printf("ping: failed to send initial ping: %v", err)
cancel() cancel()
@@ -13,6 +13,18 @@ type Hello struct {
MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"` MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"`
Country string `msgpack:"country,omitempty"` Country string `msgpack:"country,omitempty"`
BuildTag string `msgpack:"buildTag,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 { type MonitorInfo struct {
+3 -2
View File
@@ -1,6 +1,6 @@
module overlord-client module overlord-client
go 1.24 go 1.25.0
require ( require (
github.com/creack/pty v1.1.21 github.com/creack/pty v1.1.21
@@ -14,7 +14,8 @@ require (
require ( require (
github.com/Kirizu-Official/windows-camera-go v0.2.0 github.com/Kirizu-Official/windows-camera-go v0.2.0
github.com/gen2brain/malgo v0.11.24 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 ( require (
+4 -2
View File
@@ -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/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 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 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.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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+3
View File
@@ -32,5 +32,8 @@
"telegramEnabled": false, "telegramEnabled": false,
"telegramBotToken": "", "telegramBotToken": "",
"telegramChatId": "" "telegramChatId": ""
},
"enrollment": {
"requireApproval": true
} }
} }
@@ -8,6 +8,7 @@ export function applyUserRoleUI(user, refs) {
scriptsLink, scriptsLink,
logsLink, logsLink,
notificationsLink, notificationsLink,
enrollmentLink,
} = refs; } = refs;
if (!user || !usernameDisplay || !roleBadge) return; if (!user || !usernameDisplay || !roleBadge) return;
@@ -70,6 +71,7 @@ export function applyUserRoleUI(user, refs) {
if (user.role === "admin" || user.role === "operator") { if (user.role === "admin" || user.role === "operator") {
buildLink?.classList.remove("hidden"); buildLink?.classList.remove("hidden");
notificationsLink?.classList.remove("hidden"); notificationsLink?.classList.remove("hidden");
enrollmentLink?.classList.remove("hidden");
} }
if (user.role !== "viewer") { if (user.role !== "viewer") {
scriptsLink?.classList.remove("hidden"); scriptsLink?.classList.remove("hidden");
@@ -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" 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"
><i class="fa-solid fa-users text-indigo-400"></i> Users</a ><i class="fa-solid fa-users text-indigo-400"></i> Users</a
> >
<a
href="/purgatory"
id="enrollment-link"
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"
><i class="fa-solid fa-user-clock text-amber-400"></i> Purgatory<span id="enrollment-badge" class="hidden min-w-[20px] h-5 px-1 rounded-full bg-amber-500 text-white text-xs flex items-center justify-center"></span></a
>
</nav> </nav>
<div id="nav-utility" class="flex flex-wrap items-center gap-2 md:w-auto md:justify-end md:shrink-0"> <div id="nav-utility" class="flex flex-wrap items-center gap-2 md:w-auto md:justify-end md:shrink-0">
<button <button
@@ -135,5 +141,7 @@ export function mountNav(host) {
scriptsLink: document.getElementById("scripts-link"), scriptsLink: document.getElementById("scripts-link"),
logsLink: document.getElementById("logs-link"), logsLink: document.getElementById("logs-link"),
notificationsLink: document.getElementById("notifications-link"), notificationsLink: document.getElementById("notifications-link"),
enrollmentLink: document.getElementById("enrollment-link"),
enrollmentBadge: document.getElementById("enrollment-badge"),
}; };
} }
@@ -0,0 +1,445 @@
const body = document.getElementById("enrollment-body");
const loadingRow = document.getElementById("enrollment-loading");
const emptyEl = document.getElementById("enrollment-empty");
const toggleEl = document.getElementById("require-approval-toggle");
const selectAllEl = document.getElementById("select-all");
const bulkApproveBtn = document.getElementById("bulk-approve-btn");
const bulkDenyBtn = document.getElementById("bulk-deny-btn");
const statPending = document.getElementById("stat-pending");
const statApproved = document.getElementById("stat-approved");
const statDenied = document.getElementById("stat-denied");
const statBannedIps = document.getElementById("stat-banned-ips");
const searchInput = document.getElementById("search-input");
const bannedIpsSection = document.getElementById("banned-ips-section");
const bannedIpsBody = document.getElementById("banned-ips-body");
const bannedIpsEmpty = document.getElementById("banned-ips-empty");
const clientsTable = document.getElementById("enrollment-table")?.closest(".bg-slate-900\\/50");
const addBanBtn = document.getElementById("add-ban-btn");
const manualBanForm = document.getElementById("manual-ban-form");
const banIpInput = document.getElementById("ban-ip-input");
const banReasonInput = document.getElementById("ban-reason-input");
const confirmBanBtn = document.getElementById("confirm-ban-btn");
const cancelBanBtn = document.getElementById("cancel-ban-btn");
const bulkBanBtn = document.getElementById("bulk-ban-btn");
const banModal = document.getElementById("ban-confirm-modal");
const banModalBody = document.getElementById("ban-modal-body");
const banModalConfirm = document.getElementById("ban-modal-confirm");
const banModalCancel = document.getElementById("ban-modal-cancel");
const banModalBackdrop = document.getElementById("ban-modal-backdrop");
let currentFilter = "pending";
let searchQuery = "";
let clients = [];
// ── API helpers ────────────────────────────────────────────────────
async function api(path, opts = {}) {
const res = await fetch(path, { credentials: "include", ...opts });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
async function loadStats() {
try {
const s = await api("/api/enrollment/stats");
statPending.textContent = s.pending ?? 0;
statApproved.textContent = s.approved ?? 0;
statDenied.textContent = s.denied ?? 0;
// Load banned IPs count
try {
const b = await api("/api/enrollment/banned-ips");
statBannedIps.textContent = (b.items || []).length;
} catch { statBannedIps.textContent = 0; }
// Update nav badge
const badge = document.getElementById("enrollment-badge");
if (badge) {
if (s.pending > 0) {
badge.textContent = s.pending;
badge.classList.remove("hidden");
} else {
badge.classList.add("hidden");
}
}
} catch {}
}
async function loadSettings() {
try {
const s = await api("/api/enrollment/settings");
toggleEl.checked = s.requireApproval;
} catch {}
}
async function loadClients() {
loadingRow.classList.remove("hidden");
emptyEl.classList.add("hidden");
body.querySelectorAll("tr:not(#enrollment-loading)").forEach((r) => r.remove());
try {
const data = await api(`/api/clients?page=1&pageSize=500&enrollmentFilter=${currentFilter}`);
clients = data.items || [];
} catch {
clients = [];
}
// Sort by newest first (highest lastSeen = most recent)
clients.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0));
// Apply search filter
const q = searchQuery.toLowerCase().trim();
const filtered = q
? clients.filter((c) => {
const fields = [c.host, c.user, c.ip, c.id, c.os, c.country, c.keyFingerprint];
return fields.some((f) => f && String(f).toLowerCase().includes(q));
})
: clients;
loadingRow.classList.add("hidden");
if (filtered.length === 0) {
emptyEl.classList.remove("hidden");
return;
}
for (const c of filtered) {
const tr = document.createElement("tr");
tr.className = "hover:bg-slate-800/40 transition-colors";
tr.dataset.id = c.id;
const statusPill = statusBadge(c.enrollmentStatus || "pending");
const fp = c.keyFingerprint ? c.keyFingerprint.substring(0, 16) + "..." : "-";
const lastSeen = c.lastSeen ? timeAgo(c.lastSeen) : "-";
tr.innerHTML = `
<td class="px-4 py-3"><input type="checkbox" class="row-check h-4 w-4 rounded border-slate-600" data-id="${esc(c.id)}" /></td>
<td class="px-4 py-3 text-sm font-medium text-slate-200">${esc(c.host || c.id)}</td>
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.user || "-")}</td>
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.os || "-")}</td>
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.ip || "-")}</td>
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.country || "-")}</td>
<td class="px-4 py-3 text-sm text-slate-500 font-mono">${esc(fp)}</td>
<td class="px-4 py-3 text-sm text-slate-500">${lastSeen}</td>
<td class="px-4 py-3">${statusPill}</td>
<td class="px-4 py-3 text-right space-x-2">${actionButtons(c)}</td>
`;
body.appendChild(tr);
}
updateBulkButtons();
}
function statusBadge(status) {
const map = {
pending:
'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/20 text-amber-300 border border-amber-500/40"><i class="fa-solid fa-clock"></i>Pending</span>',
approved:
'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-500/20 text-emerald-300 border border-emerald-500/40"><i class="fa-solid fa-check"></i>Approved</span>',
denied:
'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/20 text-red-300 border border-red-500/40"><i class="fa-solid fa-ban"></i>Denied</span>',
};
return map[status] || map.pending;
}
function actionButtons(c) {
const status = c.enrollmentStatus || "pending";
let html = "";
if (status !== "approved") {
html += `<button class="act-approve px-2 py-1 rounded text-xs font-medium bg-emerald-600 hover:bg-emerald-700 text-white" data-id="${esc(c.id)}"><i class="fa-solid fa-check mr-1"></i>Approve</button>`;
}
if (status !== "denied") {
html += `<button class="act-deny px-2 py-1 rounded text-xs font-medium bg-red-600 hover:bg-red-700 text-white" data-id="${esc(c.id)}"><i class="fa-solid fa-ban mr-1"></i>Deny</button>`;
}
if (status !== "pending") {
html += `<button class="act-reset px-2 py-1 rounded text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white" data-id="${esc(c.id)}"><i class="fa-solid fa-rotate-left mr-1"></i>Reset</button>`;
}
if (c.ip) {
html += `<button class="act-ban-ip px-2 py-1 rounded text-xs font-medium bg-rose-700 hover:bg-rose-800 text-white" data-id="${esc(c.id)}" title="Ban IP ${esc(c.ip)}"><i class="fa-solid fa-shield-halved mr-1"></i>Ban IP</button>`;
}
return html;
}
function esc(s) {
const d = document.createElement("div");
d.textContent = String(s ?? "");
return d.innerHTML;
}
function timeAgo(ts) {
const diff = Date.now() - ts;
if (diff < 60000) return "just now";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return `${Math.floor(diff / 86400000)}d ago`;
}
// ── Event handlers ─────────────────────────────────────────────────
async function setStatus(clientId, action) {
try {
await api(`/api/enrollment/${encodeURIComponent(clientId)}/${action}`, { method: "POST" });
if (window.showToast) window.showToast(`Client ${action}`, "success");
} catch (e) {
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
}
await Promise.all([loadClients(), loadStats()]);
}
async function bulkAction(action) {
const ids = getSelectedIds();
if (ids.length === 0) return;
try {
await api("/api/enrollment/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, action }),
});
if (window.showToast) window.showToast(`${ids.length} clients ${action}`, "success");
} catch (e) {
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
}
selectAllEl.checked = false;
await Promise.all([loadClients(), loadStats()]);
}
function getSelectedIds() {
return [...document.querySelectorAll(".row-check:checked")].map(
(el) => el.dataset.id,
);
}
function updateBulkButtons() {
const count = getSelectedIds().length;
bulkApproveBtn.classList.toggle("hidden", count === 0);
bulkDenyBtn.classList.toggle("hidden", count === 0);
bulkBanBtn.classList.toggle("hidden", count === 0);
}
// ── Tab switching ──────────────────────────────────────────────────
document.querySelectorAll(".enrollment-tab").forEach((tab) => {
tab.addEventListener("click", () => {
currentFilter = tab.dataset.filter;
document.querySelectorAll(".enrollment-tab").forEach((t) => {
t.className =
"enrollment-tab px-4 py-2 rounded-lg text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700";
});
const colorMap = {
pending: "bg-amber-500/20 text-amber-300 border-amber-500/40",
denied: "bg-red-500/20 text-red-300 border-red-500/40",
approved: "bg-emerald-500/20 text-emerald-300 border-emerald-500/40",
"banned-ips": "bg-rose-500/20 text-rose-300 border-rose-500/40",
};
tab.className = `enrollment-tab px-4 py-2 rounded-lg text-sm font-medium ${colorMap[currentFilter] || ""} border`;
if (currentFilter === "banned-ips") {
if (clientsTable) clientsTable.classList.add("hidden");
emptyEl.classList.add("hidden");
bannedIpsSection.classList.remove("hidden");
loadBannedIps();
} else {
bannedIpsSection.classList.add("hidden");
if (clientsTable) clientsTable.classList.remove("hidden");
loadClients();
}
});
});
// ── Toggle require approval ────────────────────────────────────────
toggleEl.addEventListener("change", async () => {
try {
await api("/api/enrollment/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requireApproval: toggleEl.checked }),
});
if (window.showToast)
window.showToast(
toggleEl.checked ? "Approval required" : "Auto-approve enabled",
"success",
);
} catch (e) {
toggleEl.checked = !toggleEl.checked;
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
}
});
// ── Table delegation ───────────────────────────────────────────────
body.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const id = btn.dataset.id;
if (!id) return;
if (btn.classList.contains("act-approve")) setStatus(id, "approve");
else if (btn.classList.contains("act-deny")) setStatus(id, "deny");
else if (btn.classList.contains("act-reset")) setStatus(id, "reset");
else if (btn.classList.contains("act-ban-ip")) banClientIp(id);
});
body.addEventListener("change", () => updateBulkButtons());
selectAllEl.addEventListener("change", () => {
document.querySelectorAll(".row-check").forEach((cb) => {
cb.checked = selectAllEl.checked;
});
updateBulkButtons();
});
bulkApproveBtn.addEventListener("click", () => bulkAction("approve"));
bulkDenyBtn.addEventListener("click", () => bulkAction("deny"));
bulkBanBtn.addEventListener("click", () => {
const ids = getSelectedIds();
if (ids.length === 0) return;
// Find IP info for selected clients
const selected = clients.filter((c) => ids.includes(c.id) && c.ip);
const ips = [...new Set(selected.map((c) => c.ip))];
showBanModal(
`You are about to <strong>ban ${ips.length} IP address${ips.length !== 1 ? "es" : ""}</strong> affecting <strong>${ids.length} client${ids.length !== 1 ? "s" : ""}</strong>. Banned IPs will be blocked from all future connections.`,
ips,
async () => {
await bulkAction("ban-ip");
},
);
});
// ── Search ─────────────────────────────────────────────────────────
let searchDebounce;
searchInput.addEventListener("input", () => {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
searchQuery = searchInput.value;
loadClients();
}, 250);
});
// ── Ban confirmation modal ──────────────────────────────────────────
let banModalResolve = null;
function showBanModal(message, ips, onConfirm) {
let html = `<p class="mb-3">${message}</p>`;
if (ips && ips.length > 0 && ips.length <= 10) {
html += `<div class="bg-slate-800/60 border border-slate-700 rounded-lg p-3 font-mono text-xs text-rose-300 space-y-1">`;
for (const ip of ips) html += `<div>${esc(ip)}</div>`;
html += `</div>`;
}
banModalBody.innerHTML = html;
banModal.classList.remove("hidden");
banModalResolve = onConfirm;
}
function closeBanModal() {
banModal.classList.add("hidden");
banModalResolve = null;
}
banModalConfirm.addEventListener("click", async () => {
const fn = banModalResolve;
closeBanModal();
if (fn) await fn();
});
banModalCancel.addEventListener("click", closeBanModal);
banModalBackdrop.addEventListener("click", closeBanModal);
// ── Banned IPs ─────────────────────────────────────────────────────
async function banClientIp(clientId) {
const client = clients.find((c) => c.id === clientId);
const ipText = client?.ip ? ` (${client.ip})` : "";
showBanModal(
`You are about to <strong>ban the IP address</strong> of client <strong>${esc(client?.host || clientId)}</strong>${esc(ipText)}. This will block all connections from this IP.`,
client?.ip ? [client.ip] : [],
async () => {
try {
await api(`/api/enrollment/${encodeURIComponent(clientId)}/ban-ip`, { method: "POST" });
if (window.showToast) window.showToast("IP banned", "success");
} catch (e) {
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
}
await Promise.all([loadClients(), loadStats()]);
},
);
}
async function loadBannedIps() {
bannedIpsBody.innerHTML = "";
bannedIpsEmpty.classList.add("hidden");
try {
const data = await api("/api/enrollment/banned-ips");
const items = data.items || [];
if (items.length === 0) {
bannedIpsEmpty.classList.remove("hidden");
return;
}
for (const entry of items) {
const tr = document.createElement("tr");
tr.className = "hover:bg-slate-800/40 transition-colors";
tr.innerHTML = `
<td class="px-4 py-3 text-sm font-mono text-slate-200">${esc(entry.ip)}</td>
<td class="px-4 py-3 text-sm text-slate-400">${esc(entry.reason || "-")}</td>
<td class="px-4 py-3 text-sm text-slate-500">${entry.createdAt ? timeAgo(entry.createdAt) : "-"}</td>
<td class="px-4 py-3 text-right">
<button class="act-unban px-2 py-1 rounded text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white" data-ip="${esc(entry.ip)}">
<i class="fa-solid fa-unlock mr-1"></i>Unban
</button>
</td>
`;
bannedIpsBody.appendChild(tr);
}
} catch {
bannedIpsEmpty.classList.remove("hidden");
}
}
bannedIpsBody.addEventListener("click", async (e) => {
const btn = e.target.closest("button");
if (!btn) return;
if (btn.classList.contains("act-unban")) {
const ip = btn.dataset.ip;
if (!ip) return;
try {
await api(`/api/enrollment/banned-ips?ip=${encodeURIComponent(ip)}`, { method: "DELETE" });
if (window.showToast) window.showToast(`Unbanned ${ip}`, "success");
} catch (e) {
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
}
await Promise.all([loadBannedIps(), loadStats()]);
}
});
// ── Manual IP ban form ─────────────────────────────────────────────
addBanBtn.addEventListener("click", () => {
manualBanForm.classList.toggle("hidden");
banIpInput.value = "";
banReasonInput.value = "";
if (!manualBanForm.classList.contains("hidden")) banIpInput.focus();
});
cancelBanBtn.addEventListener("click", () => {
manualBanForm.classList.add("hidden");
});
confirmBanBtn.addEventListener("click", async () => {
const ip = banIpInput.value.trim();
if (!ip) { banIpInput.focus(); return; }
try {
await api("/api/enrollment/ban-ip", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ip, reason: banReasonInput.value.trim() || undefined }),
});
if (window.showToast) window.showToast(`Banned ${ip}`, "success");
manualBanForm.classList.add("hidden");
} catch (e) {
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
}
await Promise.all([loadBannedIps(), loadStats()]);
});
// ── Init ───────────────────────────────────────────────────────────
loadSettings();
loadStats();
loadClients();
// Auto-refresh every 15 seconds
setInterval(() => {
loadStats();
loadClients();
}, 15000);
+218
View File
@@ -0,0 +1,218 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Purgatory - Overlord</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="stylesheet" href="/assets/tailwind.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="stylesheet" href="/assets/main.css" />
<script src="/assets/toast.js"></script>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<header id="top-nav"></header>
<div class="min-h-full flex flex-col">
<main class="flex-1 max-w-7xl w-full mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 class="text-2xl font-bold text-slate-100 mb-1">
<i class="fa-solid fa-user-clock text-amber-400 mr-2"></i>Purgatory
</h2>
<p class="text-slate-400 text-sm">
Approve or deny clients before they can connect to the server.
</p>
</div>
<div class="flex items-center gap-4">
<div id="stats-bar" class="flex items-center gap-3 text-sm">
<span class="text-amber-400"><i class="fa-solid fa-clock mr-1"></i><span id="stat-pending">0</span> pending</span>
<span class="text-emerald-400"><i class="fa-solid fa-check mr-1"></i><span id="stat-approved">0</span> approved</span>
<span class="text-red-400"><i class="fa-solid fa-ban mr-1"></i><span id="stat-denied">0</span> denied</span>
<span class="text-rose-400"><i class="fa-solid fa-shield-halved mr-1"></i><span id="stat-banned-ips">0</span> banned IPs</span>
</div>
</div>
</div>
<!-- Settings Card -->
<div class="mb-6 bg-slate-900/60 border border-slate-800 rounded-xl p-5">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-slate-100">Approval Mode</h3>
<p class="text-sm text-slate-400">
When enabled, new clients must be approved before connecting.
When disabled, clients auto-approve on first connection.
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="require-approval-toggle" class="sr-only peer" />
<div class="w-11 h-6 bg-slate-700 peer-focus:ring-2 peer-focus:ring-sky-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-500"></div>
</label>
</div>
</div>
<!-- Filter Tabs -->
<div class="mb-4 flex items-center gap-2 flex-wrap">
<button data-filter="pending"
class="enrollment-tab px-4 py-2 rounded-lg text-sm font-medium bg-amber-500/20 text-amber-300 border border-amber-500/40">
Pending
</button>
<button data-filter="denied"
class="enrollment-tab px-4 py-2 rounded-lg text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700">
Denied
</button>
<button data-filter="approved"
class="enrollment-tab px-4 py-2 rounded-lg text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700">
Approved
</button>
<button data-filter="banned-ips"
class="enrollment-tab px-4 py-2 rounded-lg text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700">
<i class="fa-solid fa-shield-halved mr-1"></i>Banned IPs
</button>
<div class="flex-1"></div>
<div class="relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none"></i>
<input type="text" id="search-input" placeholder="Search host, user, IP..."
class="w-56 pl-9 pr-3 py-2 rounded-lg text-sm bg-slate-800 border border-slate-700 text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent" />
</div>
<button id="bulk-approve-btn"
class="hidden px-3 py-2 rounded-lg text-sm font-medium bg-emerald-600 hover:bg-emerald-700 text-white transition-colors">
<i class="fa-solid fa-check mr-1"></i>Approve Selected
</button>
<button id="bulk-deny-btn"
class="hidden px-3 py-2 rounded-lg text-sm font-medium bg-red-600 hover:bg-red-700 text-white transition-colors">
<i class="fa-solid fa-ban mr-1"></i>Deny Selected
</button>
<button id="bulk-ban-btn"
class="hidden px-3 py-2 rounded-lg text-sm font-medium bg-rose-700 hover:bg-rose-800 text-white transition-colors">
<i class="fa-solid fa-shield-halved mr-1"></i>Ban Selected IPs
</button>
</div>
<!-- Clients Table -->
<div class="bg-slate-900/50 border border-slate-800 rounded-xl overflow-hidden">
<table class="w-full" id="enrollment-table">
<thead class="bg-slate-800/50 border-b border-slate-700">
<tr>
<th class="w-10 px-4 py-3">
<input type="checkbox" id="select-all" class="h-4 w-4 rounded border-slate-600" />
</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Host</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">User</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">OS</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">IP</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Country</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Key Fingerprint</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Last Seen</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Status</th>
<th class="text-right px-4 py-3 text-sm font-semibold text-slate-300">Actions</th>
</tr>
</thead>
<tbody id="enrollment-body" class="divide-y divide-slate-800">
<tr id="enrollment-loading">
<td colspan="10" class="px-6 py-12 text-center text-slate-500">
<i class="fa-solid fa-spinner fa-spin mr-2"></i>Loading...
</td>
</tr>
</tbody>
</table>
<div id="enrollment-empty" class="hidden px-6 py-12 text-center text-slate-500">
<i class="fa-solid fa-inbox text-3xl mb-3 block"></i>
<p>No clients in this category.</p>
</div>
</div>
<!-- Banned IPs Table (hidden by default, shown on Banned IPs tab) -->
<div id="banned-ips-section" class="hidden">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-100">
<i class="fa-solid fa-shield-halved text-rose-400 mr-2"></i>Banned IPs
</h3>
<button id="add-ban-btn"
class="px-3 py-2 rounded-lg text-sm font-medium bg-red-600 hover:bg-red-700 text-white transition-colors">
<i class="fa-solid fa-plus mr-1"></i>Ban IP
</button>
</div>
<!-- Manual ban form (hidden by default) -->
<div id="manual-ban-form" class="hidden mb-4 bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-sm font-medium text-slate-300 mb-1">IP Address</label>
<input type="text" id="ban-ip-input" placeholder="e.g. 192.168.1.100"
class="w-full px-3 py-2 rounded-lg text-sm bg-slate-800 border border-slate-700 text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent" />
</div>
<div class="flex-1">
<label class="block text-sm font-medium text-slate-300 mb-1">Reason (optional)</label>
<input type="text" id="ban-reason-input" placeholder="Reason for ban"
class="w-full px-3 py-2 rounded-lg text-sm bg-slate-800 border border-slate-700 text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent" />
</div>
<button id="confirm-ban-btn"
class="px-4 py-2 rounded-lg text-sm font-medium bg-red-600 hover:bg-red-700 text-white transition-colors">
Ban
</button>
<button id="cancel-ban-btn"
class="px-4 py-2 rounded-lg text-sm font-medium bg-slate-700 hover:bg-slate-600 text-white transition-colors">
Cancel
</button>
</div>
</div>
<div class="bg-slate-900/50 border border-slate-800 rounded-xl overflow-hidden">
<table class="w-full">
<thead class="bg-slate-800/50 border-b border-slate-700">
<tr>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">IP Address</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Reason</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Banned At</th>
<th class="text-right px-4 py-3 text-sm font-semibold text-slate-300">Actions</th>
</tr>
</thead>
<tbody id="banned-ips-body" class="divide-y divide-slate-800"></tbody>
</table>
<div id="banned-ips-empty" class="hidden px-6 py-12 text-center text-slate-500">
<i class="fa-solid fa-shield-halved text-3xl mb-3 block"></i>
<p>No banned IPs.</p>
</div>
</div>
</div>
</main>
</div>
<!-- Ban Confirmation Modal -->
<div id="ban-confirm-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" id="ban-modal-backdrop"></div>
<div class="relative bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl max-w-md w-full mx-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-rose-500/20 flex items-center justify-center">
<i class="fa-solid fa-shield-halved text-rose-400 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-bold text-slate-100">Confirm IP Ban</h3>
<p class="text-sm text-slate-400">This action cannot be easily undone.</p>
</div>
</div>
<div id="ban-modal-body" class="mb-5 text-sm text-slate-300">
<!-- Filled dynamically -->
</div>
<div class="flex items-center justify-end gap-3">
<button id="ban-modal-cancel"
class="px-4 py-2 rounded-lg text-sm font-medium bg-slate-700 hover:bg-slate-600 text-white transition-colors">
Cancel
</button>
<button id="ban-modal-confirm"
class="px-4 py-2 rounded-lg text-sm font-medium bg-rose-600 hover:bg-rose-700 text-white transition-colors">
<i class="fa-solid fa-shield-halved mr-1"></i>Ban
</button>
</div>
</div>
</div>
<script type="module" src="/assets/nav.js"></script>
<script type="module" src="/assets/purgatory-ui.js"></script>
</body>
</html>
+4
View File
@@ -72,6 +72,10 @@ export enum AuditAction {
AGENT_UPDATE = "agent_update", AGENT_UPDATE = "agent_update",
SCRIPT_EXECUTE = "script_execute", SCRIPT_EXECUTE = "script_execute",
SILENT_EXECUTE = "silent_execute", SILENT_EXECUTE = "silent_execute",
ENROLLMENT_APPROVE = "enrollment_approve",
ENROLLMENT_DENY = "enrollment_deny",
ENROLLMENT_SETTINGS = "enrollment_settings",
ENROLLMENT_BULK = "enrollment_bulk",
} }
export interface AuditLogEntry { export interface AuditLogEntry {
+33
View File
@@ -52,6 +52,9 @@ export interface Config {
passwordRequireNumber: boolean; passwordRequireNumber: boolean;
passwordRequireSymbol: boolean; passwordRequireSymbol: boolean;
}; };
enrollment: {
requireApproval: boolean;
};
} }
const DEFAULT_CONFIG: Config = { const DEFAULT_CONFIG: Config = {
@@ -102,6 +105,9 @@ const DEFAULT_CONFIG: Config = {
passwordRequireNumber: false, passwordRequireNumber: false,
passwordRequireSymbol: false, passwordRequireSymbol: false,
}, },
enrollment: {
requireApproval: true,
},
}; };
type SaveSecrets = { type SaveSecrets = {
@@ -411,6 +417,12 @@ export function loadConfig(): Config {
fileConfig.security?.passwordRequireSymbol || fileConfig.security?.passwordRequireSymbol ||
DEFAULT_CONFIG.security.passwordRequireSymbol, DEFAULT_CONFIG.security.passwordRequireSymbol,
}, },
enrollment: {
requireApproval:
process.env.OVERLORD_ENROLLMENT_REQUIRE_APPROVAL !== undefined
? String(process.env.OVERLORD_ENROLLMENT_REQUIRE_APPROVAL).toLowerCase() === "true"
: (fileConfig.enrollment?.requireApproval ?? DEFAULT_CONFIG.enrollment.requireApproval),
},
}; };
if (saveChanged) { if (saveChanged) {
@@ -513,6 +525,27 @@ export async function updateSecurityConfig(
return next; return next;
} }
export async function updateEnrollmentConfig(
updates: Partial<Config["enrollment"]>,
): Promise<Config["enrollment"]> {
const current = getConfig();
const next = {
...current.enrollment,
...updates,
requireApproval: updates.requireApproval !== undefined ? Boolean(updates.requireApproval) : current.enrollment.requireApproval,
};
configCache = {
...current,
enrollment: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.enrollment = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateTlsConfig( export async function updateTlsConfig(
updates: Partial<Config["tls"]>, updates: Partial<Config["tls"]>,
): Promise<Config["tls"]> { ): Promise<Config["tls"]> {
+127 -4
View File
@@ -52,6 +52,30 @@ try {
try { try {
db.run(`ALTER TABLE clients ADD COLUMN bookmarked INTEGER NOT NULL DEFAULT 0`); db.run(`ALTER TABLE clients ADD COLUMN bookmarked INTEGER NOT NULL DEFAULT 0`);
} catch {} } catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN enrollment_status TEXT NOT NULL DEFAULT 'pending'`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN public_key TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN key_fingerprint TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN enrolled_at INTEGER`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN enrolled_by TEXT`);
} catch {}
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_public_key ON clients(public_key);`,
);
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_key_fingerprint ON clients(key_fingerprint);`,
);
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_enrollment_status ON clients(enrollment_status);`,
);
db.run( db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_online_last_seen ON clients(online, last_seen DESC);`, `CREATE INDEX IF NOT EXISTS idx_clients_online_last_seen ON clients(online, last_seen DESC);`,
); );
@@ -148,8 +172,8 @@ export function upsertClientRow(
) { ) {
const now = partial.lastSeen ?? Date.now(); const now = partial.lastSeen ?? Date.now();
db.run( db.run(
`INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms) `INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, enrollment_status, public_key, key_fingerprint)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
hwid=COALESCE(excluded.hwid, clients.hwid), hwid=COALESCE(excluded.hwid, clients.hwid),
role=COALESCE(excluded.role, clients.role), role=COALESCE(excluded.role, clients.role),
@@ -166,7 +190,9 @@ export function upsertClientRow(
country=COALESCE(excluded.country, clients.country), country=COALESCE(excluded.country, clients.country),
last_seen=excluded.last_seen, last_seen=excluded.last_seen,
online=COALESCE(excluded.online, clients.online), online=COALESCE(excluded.online, clients.online),
ping_ms=COALESCE(excluded.ping_ms, clients.ping_ms) ping_ms=COALESCE(excluded.ping_ms, clients.ping_ms),
public_key=COALESCE(excluded.public_key, clients.public_key),
key_fingerprint=COALESCE(excluded.key_fingerprint, clients.key_fingerprint)
`, `,
partial.id, partial.id,
partial.hwid ?? partial.id, partial.hwid ?? partial.id,
@@ -185,6 +211,9 @@ export function upsertClientRow(
now, now,
partial.online ?? 0, partial.online ?? 0,
partial.pingMs ?? null, partial.pingMs ?? null,
partial.enrollmentStatus ?? "pending",
partial.publicKey ?? null,
partial.keyFingerprint ?? null,
); );
if (partial.hwid) { if (partial.hwid) {
@@ -302,6 +331,7 @@ export function listClients(filters: ListFilters): ListResult {
statusFilter, statusFilter,
osFilter, osFilter,
countryFilter, countryFilter,
enrollmentFilter,
allowedClientIds, allowedClientIds,
deniedClientIds, deniedClientIds,
} = filters; } = filters;
@@ -322,6 +352,11 @@ export function listClients(filters: ListFilters): ListResult {
where.push("online=0"); where.push("online=0");
} }
if (enrollmentFilter && enrollmentFilter !== "all") {
where.push("enrollment_status=?");
params.push(enrollmentFilter);
}
if (osFilter && osFilter !== "all") { if (osFilter && osFilter !== "all") {
where.push("os=?"); where.push("os=?");
params.push(osFilter); params.push(osFilter);
@@ -382,7 +417,7 @@ export function listClients(filters: ListFilters): ListResult {
const rows = db const rows = db
.query<any>( .query<any>(
`SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs, bookmarked `SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs, bookmarked, enrollment_status as enrollmentStatus, public_key as publicKey, key_fingerprint as keyFingerprint
FROM clients FROM clients
${whereSql} ${whereSql}
${orderBy} ${orderBy}
@@ -408,6 +443,9 @@ export function listClients(filters: ListFilters): ListResult {
pingMs: c.pingMs ?? null, pingMs: c.pingMs ?? null,
online: c.online === 1, online: c.online === 1,
bookmarked: c.bookmarked === 1, bookmarked: c.bookmarked === 1,
enrollmentStatus: c.enrollmentStatus || "pending",
publicKey: c.publicKey || null,
keyFingerprint: c.keyFingerprint || null,
thumbnail: getThumbnail(c.id), thumbnail: getThumbnail(c.id),
})); }));
@@ -803,3 +841,88 @@ export function clearNotificationScreenshots() {
db.run(`DELETE FROM notification_screenshots`); db.run(`DELETE FROM notification_screenshots`);
console.log("[db] cleared notification screenshots"); console.log("[db] cleared notification screenshots");
} }
export function getClientEnrollmentStatus(id: string): string | null {
const row = db
.query<{ enrollment_status: string }>(
`SELECT enrollment_status FROM clients WHERE id=?`,
)
.get(id);
return row?.enrollment_status ?? null;
}
export function setClientEnrollmentStatus(
id: string,
status: "approved" | "denied" | "pending",
approvedBy?: string,
): boolean {
const result = db.run(
`UPDATE clients SET enrollment_status=?, enrolled_at=?, enrolled_by=? WHERE id=?`,
status,
status === "approved" ? Date.now() : null,
status === "approved" ? (approvedBy ?? null) : null,
id,
);
return ((result as any)?.changes || 0) > 0;
}
export function lookupClientByPublicKey(
publicKey: string,
): { id: string; enrollmentStatus: string } | null {
const row = db
.query<{ id: string; enrollment_status: string }>(
`SELECT id, enrollment_status FROM clients WHERE public_key=? LIMIT 1`,
)
.get(publicKey);
if (!row) return null;
return { id: row.id, enrollmentStatus: row.enrollment_status };
}
export function getEnrollmentStats(): {
pending: number;
approved: number;
denied: number;
} {
const rows = db
.query<{ status: string; c: number }>(
`SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients GROUP BY enrollment_status`,
)
.all();
const stats = { pending: 0, approved: 0, denied: 0 };
for (const r of rows) {
if (r.status === "approved") stats.approved = Number(r.c);
else if (r.status === "denied") stats.denied = Number(r.c);
else stats.pending = Number(r.c);
}
return stats;
}
export function getPendingClients(): {
id: string;
host: string | null;
os: string | null;
user: string | null;
ip: string | null;
country: string | null;
publicKey: string | null;
keyFingerprint: string | null;
lastSeen: number;
}[] {
return db
.query<any>(
`SELECT id, host, os, user, ip, country, public_key as publicKey, key_fingerprint as keyFingerprint, last_seen as lastSeen
FROM clients WHERE enrollment_status='pending' ORDER BY last_seen DESC`,
)
.all()
.map((r: any) => ({
id: r.id,
host: r.host,
os: r.os,
user: r.user,
ip: r.ip,
country: r.country,
publicKey: r.publicKey,
keyFingerprint: r.keyFingerprint,
lastSeen: Number(r.lastSeen) || 0,
}));
}
+2
View File
@@ -17,6 +17,7 @@ import { metrics } from "./metrics";
import { ensureDataDir } from "./paths"; import { ensureDataDir } from "./paths";
import { handleAuthRoutes } from "./server/routes/auth-routes"; import { handleAuthRoutes } from "./server/routes/auth-routes";
import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes"; import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
import { handleEnrollmentRoutes } from "./server/routes/enrollment-routes";
import { handleBuildRoutes } from "./server/routes/build-routes"; import { handleBuildRoutes } from "./server/routes/build-routes";
import { handleAssetsRoutes } from "./server/routes/assets-routes"; import { handleAssetsRoutes } from "./server/routes/assets-routes";
import { handleDeployRoutes } from "./server/routes/deploy-routes"; import { handleDeployRoutes } from "./server/routes/deploy-routes";
@@ -482,6 +483,7 @@ async function startServer() {
handleAuthRoutes, handleAuthRoutes,
handleNotificationsConfigRoutes, handleNotificationsConfigRoutes,
handleAutoScriptsRoutes, handleAutoScriptsRoutes,
handleEnrollmentRoutes,
handleUsersRoutes, handleUsersRoutes,
handleBuildRoutes, handleBuildRoutes,
handleDeployRoutes, handleDeployRoutes,
+18 -2
View File
@@ -13,7 +13,9 @@ export type MessageKind =
| "plugin_event" | "plugin_event"
| "notification" | "notification"
| "webcam_devices" | "webcam_devices"
| "notification_config"; | "notification_config"
| "enrollment_challenge"
| "enrollment_status";
export type Hello = { export type Hello = {
type: "hello"; type: "hello";
@@ -26,6 +28,18 @@ export type Hello = {
monitors: number; monitors: number;
monitorInfo?: { width: number; height: number }[]; monitorInfo?: { width: number; height: number }[];
country?: string; country?: string;
publicKey?: string;
signature?: string;
};
export type EnrollmentChallenge = {
type: "enrollment_challenge";
nonce: string;
};
export type EnrollmentStatusMsg = {
type: "enrollment_status";
status: "pending" | "approved" | "denied";
}; };
export type HelloAck = { export type HelloAck = {
@@ -296,7 +310,9 @@ export type WireMessage =
| ScriptResult | ScriptResult
| PluginEvent | PluginEvent
| NotificationEvent | NotificationEvent
| NotificationConfig; | NotificationConfig
| EnrollmentChallenge
| EnrollmentStatusMsg;
export function encodeMessage(msg: WireMessage): Uint8Array { export function encodeMessage(msg: WireMessage): Uint8Array {
return encode(msg); return encode(msg);
+551
View File
@@ -0,0 +1,551 @@
import { describe, expect, test, beforeEach } from "bun:test";
import { encodeMessage, decodeMessage, type Hello, type EnrollmentChallenge, type EnrollmentStatusMsg } from "./protocol";
async function generateKeypair() {
const kp = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
const pubRaw = await crypto.subtle.exportKey("raw", kp.publicKey);
const privRaw = await crypto.subtle.exportKey("pkcs8", kp.privateKey);
return {
publicKey: kp.publicKey,
privateKey: kp.privateKey,
publicKeyBase64: Buffer.from(pubRaw).toString("base64"),
publicKeyBytes: new Uint8Array(pubRaw),
};
}
async function sign(privateKey: CryptoKey, data: Uint8Array): Promise<string> {
const sig = await crypto.subtle.sign("Ed25519", privateKey, data);
return Buffer.from(sig).toString("base64");
}
async function verifyEd25519(publicKeyBase64: string, signatureBase64: string, nonceBase64: string): Promise<boolean> {
try {
const pubKeyBytes = Buffer.from(publicKeyBase64, "base64");
const sigBytes = Buffer.from(signatureBase64, "base64");
const nonceBytes = Buffer.from(nonceBase64, "base64");
if (pubKeyBytes.length !== 32 || sigBytes.length !== 64) return false;
const key = await crypto.subtle.importKey("raw", pubKeyBytes, { name: "Ed25519" }, false, ["verify"]);
return await crypto.subtle.verify("Ed25519", key, sigBytes, nonceBytes);
} catch {
return false;
}
}
function computeKeyFingerprint(publicKeyBase64: string): string {
const bytes = Buffer.from(publicKeyBase64, "base64");
return new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
}
function createMockWs(overrides: Partial<{ clientId: string; role: string; ip: string; enrollmentNonce: string }> = {}) {
const sent: Uint8Array[] = [];
let closedCode: number | null = null;
let closedReason: string | null = null;
return {
data: {
clientId: overrides.clientId ?? "test-client-1",
role: overrides.role ?? "client",
ip: overrides.ip ?? "127.0.0.1",
enrollmentNonce: overrides.enrollmentNonce ?? undefined,
sessionId: "sess-1",
},
sent,
closedCode,
closedReason,
send(msg: Uint8Array) { sent.push(msg); },
close(code: number, reason: string) { this.closedCode = code; this.closedReason = reason; },
decodeSent(index: number) { return decodeMessage(sent[index]) as any; },
};
}
// tests (AI)
describe("purgatory — Ed25519 signature verification", () => {
test("valid signature from matching keypair passes", async () => {
const kp = await generateKeypair();
const nonce = new Uint8Array(32);
crypto.getRandomValues(nonce);
const nonceB64 = Buffer.from(nonce).toString("base64");
const sigB64 = await sign(kp.privateKey, nonce);
expect(await verifyEd25519(kp.publicKeyBase64, sigB64, nonceB64)).toBe(true);
});
test("signature from wrong keypair is rejected", async () => {
const kp1 = await generateKeypair();
const kp2 = await generateKeypair();
const nonce = new Uint8Array(32);
crypto.getRandomValues(nonce);
const nonceB64 = Buffer.from(nonce).toString("base64");
// Sign with kp1 but verify with kp2's public key
const sigB64 = await sign(kp1.privateKey, nonce);
expect(await verifyEd25519(kp2.publicKeyBase64, sigB64, nonceB64)).toBe(false);
});
test("signature over wrong nonce is rejected", async () => {
const kp = await generateKeypair();
const nonce1 = new Uint8Array(32);
const nonce2 = new Uint8Array(32);
crypto.getRandomValues(nonce1);
crypto.getRandomValues(nonce2);
// Sign nonce1 but try to verify against nonce2
const sigB64 = await sign(kp.privateKey, nonce1);
expect(await verifyEd25519(kp.publicKeyBase64, sigB64, Buffer.from(nonce2).toString("base64"))).toBe(false);
});
test("empty public key is rejected", async () => {
expect(await verifyEd25519("", "AAAA", "AAAA")).toBe(false);
});
test("empty signature is rejected", async () => {
const kp = await generateKeypair();
expect(await verifyEd25519(kp.publicKeyBase64, "", "AAAA")).toBe(false);
});
test("malformed public key (wrong length) is rejected", async () => {
const nonce = new Uint8Array(32);
crypto.getRandomValues(nonce);
const nonceB64 = Buffer.from(nonce).toString("base64");
// 16-byte key instead of 32
const badKey = Buffer.from(new Uint8Array(16)).toString("base64");
const fakeSig = Buffer.from(new Uint8Array(64)).toString("base64");
expect(await verifyEd25519(badKey, fakeSig, nonceB64)).toBe(false);
});
test("malformed signature (wrong length) is rejected", async () => {
const kp = await generateKeypair();
const nonce = new Uint8Array(32);
crypto.getRandomValues(nonce);
const nonceB64 = Buffer.from(nonce).toString("base64");
// 32-byte signature instead of 64
const badSig = Buffer.from(new Uint8Array(32)).toString("base64");
expect(await verifyEd25519(kp.publicKeyBase64, badSig, nonceB64)).toBe(false);
});
test("garbage base64 does not crash — returns false", async () => {
expect(await verifyEd25519("not!!valid!!base64", "also!!bad", "nonce!!bad")).toBe(false);
});
});
describe("purgatory — key fingerprint", () => {
test("fingerprint is a 64-char hex SHA-256 of the raw public key bytes", async () => {
const kp = await generateKeypair();
const fp = computeKeyFingerprint(kp.publicKeyBase64);
expect(fp).toMatch(/^[0-9a-f]{64}$/);
});
test("different keys produce different fingerprints", async () => {
const kp1 = await generateKeypair();
const kp2 = await generateKeypair();
expect(computeKeyFingerprint(kp1.publicKeyBase64)).not.toBe(computeKeyFingerprint(kp2.publicKeyBase64));
});
test("same key always produces the same fingerprint", async () => {
const kp = await generateKeypair();
expect(computeKeyFingerprint(kp.publicKeyBase64)).toBe(computeKeyFingerprint(kp.publicKeyBase64));
});
});
describe("purgatory — enrollment challenge protocol", () => {
test("enrollment_challenge message encodes/decodes with nonce", () => {
const nonce = Buffer.from(new Uint8Array(32)).toString("base64");
const msg = encodeMessage({ type: "enrollment_challenge", nonce });
const decoded = decodeMessage(msg) as EnrollmentChallenge;
expect(decoded.type).toBe("enrollment_challenge");
expect(decoded.nonce).toBe(nonce);
});
test("enrollment_status message encodes/decodes with status", () => {
for (const status of ["pending", "approved", "denied"] as const) {
const msg = encodeMessage({ type: "enrollment_status", status });
const decoded = decodeMessage(msg) as EnrollmentStatusMsg;
expect(decoded.type).toBe("enrollment_status");
expect(decoded.status).toBe(status);
}
});
test("hello message with publicKey and signature round-trips", async () => {
const kp = await generateKeypair();
const hello: Hello = {
type: "hello",
id: "client-abc",
host: "DESKTOP-1",
os: "windows",
arch: "amd64",
version: "1.0.0",
user: "testuser",
monitors: 2,
country: "US",
publicKey: kp.publicKeyBase64,
signature: "fakesig==",
};
const encoded = encodeMessage(hello);
const decoded = decodeMessage(encoded) as Hello;
expect(decoded.publicKey).toBe(kp.publicKeyBase64);
expect(decoded.signature).toBe("fakesig==");
});
});
describe("purgatory — client rejection scenarios", () => {
test("client with no publicKey is rejected (close 4002)", () => {
const ws = createMockWs({ enrollmentNonce: Buffer.from(new Uint8Array(32)).toString("base64") });
const payload: any = {
type: "hello",
id: "bad-client",
host: "evil",
os: "windows",
arch: "amd64",
version: "1.0",
user: "hacker",
monitors: 1,
publicKey: "",
signature: "something",
};
// Simulate the server's check: missing publicKey/signature/nonce
const publicKey = typeof payload.publicKey === "string" ? payload.publicKey : "";
const signature = typeof payload.signature === "string" ? payload.signature : "";
const nonce = ws.data.enrollmentNonce || "";
if (!publicKey || !signature || !nonce) {
ws.close(4002, "invalid_signature");
}
expect(ws.closedCode).toBe(4002);
expect(ws.closedReason).toBe("invalid_signature");
});
test("client with no signature is rejected (close 4002)", () => {
const ws = createMockWs({ enrollmentNonce: Buffer.from(new Uint8Array(32)).toString("base64") });
const publicKey = "validkeyhere";
const signature = "";
const nonce = ws.data.enrollmentNonce || "";
if (!publicKey || !signature || !nonce) {
ws.close(4002, "invalid_signature");
}
expect(ws.closedCode).toBe(4002);
});
test("client with no nonce stored on ws is rejected (close 4002)", () => {
const ws = createMockWs({ enrollmentNonce: undefined });
const publicKey = "somekey";
const signature = "somesig";
const nonce = ws.data.enrollmentNonce || "";
if (!publicKey || !signature || !nonce) {
ws.close(4002, "invalid_signature");
}
expect(ws.closedCode).toBe(4002);
});
test("client with invalid Ed25519 signature is rejected", async () => {
const kp = await generateKeypair();
const nonce = new Uint8Array(32);
crypto.getRandomValues(nonce);
const nonceB64 = Buffer.from(nonce).toString("base64");
// Construct a bogus signature (not signed by this key)
const badSig = Buffer.from(new Uint8Array(64).fill(0xAB)).toString("base64");
const valid = await verifyEd25519(kp.publicKeyBase64, badSig, nonceB64);
expect(valid).toBe(false);
// Server would close with 4002
});
test("replay attack: reusing a nonce with a valid signature on a different nonce fails", async () => {
const kp = await generateKeypair();
const nonce1 = new Uint8Array(32);
const nonce2 = new Uint8Array(32);
crypto.getRandomValues(nonce1);
crypto.getRandomValues(nonce2);
// Client signed nonce1
const sigB64 = await sign(kp.privateKey, nonce1);
// Server presents nonce2 — verify against nonce2 should fail
const valid = await verifyEd25519(kp.publicKeyBase64, sigB64, Buffer.from(nonce2).toString("base64"));
expect(valid).toBe(false);
});
test("nonce is cleared after use — cannot be reused", () => {
const ws = createMockWs({ enrollmentNonce: "someNonce==" });
// Server clears nonce after successful verification
ws.data.enrollmentNonce = undefined;
expect(ws.data.enrollmentNonce).toBeUndefined();
// Second attempt with no nonce → rejected
const nonce = ws.data.enrollmentNonce || "";
expect(nonce).toBe("");
});
test("denied client gets close code 4003", () => {
const ws = createMockWs();
// Simulate denied enrollment status
const enrollmentStatus = "denied";
if (enrollmentStatus === "denied") {
ws.send(encodeMessage({ type: "enrollment_status", status: "denied" }));
ws.close(4003, "denied");
}
expect(ws.closedCode).toBe(4003);
expect(ws.closedReason).toBe("denied");
const msg = ws.decodeSent(0);
expect(msg.type).toBe("enrollment_status");
expect(msg.status).toBe("denied");
});
test("pending client gets close code 4001", () => {
const ws = createMockWs();
const enrollmentStatus = "pending";
if (enrollmentStatus === "pending") {
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
ws.close(4001, "pending");
}
expect(ws.closedCode).toBe(4001);
expect(ws.closedReason).toBe("pending");
const msg = ws.decodeSent(0);
expect(msg.type).toBe("enrollment_status");
expect(msg.status).toBe("pending");
});
test("approved client gets hello_ack (not closed)", async () => {
const ws = createMockWs();
const enrollmentStatus = "approved";
if (enrollmentStatus === "approved") {
ws.send(encodeMessage({
type: "hello_ack",
id: ws.data.clientId,
notification: { keywords: [], minIntervalMs: 8000, clipboardEnabled: false },
}));
}
expect(ws.closedCode).toBeNull();
const msg = ws.decodeSent(0);
expect(msg.type).toBe("hello_ack");
expect(msg.id).toBe("test-client-1");
});
});
describe("purgatory — enrollment timeout", () => {
test("timeout constant is 30 seconds", () => {
// This just documents the expected timeout value
const ENROLLMENT_TIMEOUT_MS = 30_000;
expect(ENROLLMENT_TIMEOUT_MS).toBe(30_000);
});
test("client that never sends hello would be closed with 4002 on timeout", () => {
const ws = createMockWs();
// Simulate timeout firing
ws.close(4002, "enrollment_timeout");
expect(ws.closedCode).toBe(4002);
expect(ws.closedReason).toBe("enrollment_timeout");
});
});
describe("purgatory — nonce generation", () => {
test("nonce is 32 bytes of randomness", () => {
const nonce = new Uint8Array(32);
crypto.getRandomValues(nonce);
expect(nonce.length).toBe(32);
// Extremely unlikely all zeros
expect(nonce.some((b) => b !== 0)).toBe(true);
});
test("sequential nonces are unique", () => {
const nonces = new Set<string>();
for (let i = 0; i < 100; i++) {
const n = new Uint8Array(32);
crypto.getRandomValues(n);
nonces.add(Buffer.from(n).toString("base64"));
}
expect(nonces.size).toBe(100);
});
});
describe("purgatory — end-to-end challenge-response", () => {
test("legitimate client completes full handshake", async () => {
// 1. Server generates nonce
const nonceBytes = new Uint8Array(32);
crypto.getRandomValues(nonceBytes);
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
// 2. Client receives challenge and signs
const kp = await generateKeypair();
const sigB64 = await sign(kp.privateKey, nonceBytes);
// 3. Server verifies
const valid = await verifyEd25519(kp.publicKeyBase64, sigB64, nonceB64);
expect(valid).toBe(true);
// 4. Server computes fingerprint
const fp = computeKeyFingerprint(kp.publicKeyBase64);
expect(fp).toMatch(/^[0-9a-f]{64}$/);
});
test("attacker who intercepts nonce cannot sign without private key", async () => {
// 1. Server generates nonce
const nonceBytes = new Uint8Array(32);
crypto.getRandomValues(nonceBytes);
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
// 2. Attacker has the real client's public key but not private key
const realClient = await generateKeypair();
const attacker = await generateKeypair();
// 3. Attacker tries to sign with their own key but present real client's pubkey
const attackerSig = await sign(attacker.privateKey, nonceBytes);
const valid = await verifyEd25519(realClient.publicKeyBase64, attackerSig, nonceB64);
expect(valid).toBe(false);
});
test("attacker with own keypair gets new identity — not existing client", async () => {
// Attacker generates valid keypair and signs correctly
const attacker = await generateKeypair();
const nonceBytes = new Uint8Array(32);
crypto.getRandomValues(nonceBytes);
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
const sigB64 = await sign(attacker.privateKey, nonceBytes);
// Signature is valid...
const valid = await verifyEd25519(attacker.publicKeyBase64, sigB64, nonceB64);
expect(valid).toBe(true);
// ...but fingerprint won't match any existing approved client
const realClient = await generateKeypair();
expect(computeKeyFingerprint(attacker.publicKeyBase64)).not.toBe(computeKeyFingerprint(realClient.publicKeyBase64));
});
test("all-zero signature is rejected", async () => {
const kp = await generateKeypair();
const nonceBytes = new Uint8Array(32);
crypto.getRandomValues(nonceBytes);
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
const zeroSig = Buffer.from(new Uint8Array(64)).toString("base64");
expect(await verifyEd25519(kp.publicKeyBase64, zeroSig, nonceB64)).toBe(false);
});
test("random 32-byte value that is not a valid Ed25519 point is rejected", async () => {
const nonceBytes = new Uint8Array(32);
crypto.getRandomValues(nonceBytes);
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
// 0xFF-filled is not a valid Ed25519 public key point
const badKey = Buffer.from(new Uint8Array(32).fill(0xFF)).toString("base64");
const fakeSig = Buffer.from(new Uint8Array(64).fill(0x42)).toString("base64");
expect(await verifyEd25519(badKey, fakeSig, nonceB64)).toBe(false);
});
});
describe("purgatory — duplicate connection prevention", () => {
test("superseded socket gets close code 4004", () => {
// Simulate: old socket is active, then new socket arrives for same clientId
const oldWs = createMockWs({ clientId: "dup-client" });
const newWs = createMockWs({ clientId: "dup-client" });
// Simulated client manager (Map<string, { ws: any }>)
const clients = new Map<string, { ws: any }>();
clients.set("dup-client", { ws: oldWs });
// New connection arrives — kick old
const existing = clients.get("dup-client");
if (existing && existing.ws !== newWs) {
existing.ws.close(4004, "superseded");
clients.delete("dup-client");
}
clients.set("dup-client", { ws: newWs });
expect(oldWs.closedCode).toBe(4004);
expect(oldWs.closedReason).toBe("superseded");
expect(newWs.closedCode).toBeNull();
expect(clients.get("dup-client")!.ws).toBe(newWs);
});
test("superseded socket close handler skips cleanup", () => {
// Simulate: old socket fires close, but manager already has the new socket
const oldWs = createMockWs({ clientId: "dup-client" });
const newWs = createMockWs({ clientId: "dup-client" });
const clients = new Map<string, { ws: any }>();
clients.set("dup-client", { ws: newWs }); // new one already registered
// Old socket close handler fires
const current = clients.get("dup-client");
const shouldSkipCleanup = current && current.ws !== oldWs;
expect(shouldSkipCleanup).toBe(true);
// New socket should stay in place
expect(clients.get("dup-client")!.ws).toBe(newWs);
});
test("normal close handler cleans up when socket matches", () => {
const ws = createMockWs({ clientId: "normal-client" });
const clients = new Map<string, { ws: any }>();
clients.set("normal-client", { ws });
// Close handler — socket matches current
const current = clients.get("normal-client");
const shouldSkipCleanup = current && current.ws !== ws;
expect(shouldSkipCleanup).toBe(false);
// Should proceed with cleanup
clients.delete("normal-client");
expect(clients.has("normal-client")).toBe(false);
});
test("same socket reconnecting is not kicked (identity check)", () => {
const ws = createMockWs({ clientId: "same-client" });
const clients = new Map<string, { ws: any }>();
clients.set("same-client", { ws });
// Same ws object reconnects — should NOT kick itself
const existing = clients.get("same-client");
if (existing && existing.ws !== ws) {
existing.ws.close(4004, "superseded");
}
// Should not have been closed
expect(ws.closedCode).toBeNull();
});
});
describe("purgatory — IP banning", () => {
test("IP validation regex accepts valid IPv4", () => {
const ipRegex = /^[0-9a-fA-F:.]{3,64}$/;
expect(ipRegex.test("192.168.1.1")).toBe(true);
expect(ipRegex.test("10.0.0.1")).toBe(true);
expect(ipRegex.test("255.255.255.255")).toBe(true);
});
test("IP validation regex accepts valid IPv6", () => {
const ipRegex = /^[0-9a-fA-F:.]{3,64}$/;
expect(ipRegex.test("::1")).toBe(true);
expect(ipRegex.test("fe80::1")).toBe(true);
expect(ipRegex.test("2001:db8::1")).toBe(true);
});
test("IP validation regex rejects dangerous input", () => {
const ipRegex = /^[0-9a-fA-F:.]{3,64}$/;
expect(ipRegex.test("")).toBe(false);
expect(ipRegex.test("a")).toBe(false); // too short
expect(ipRegex.test("'; DROP TABLE--")).toBe(false);
expect(ipRegex.test("192.168.1.1; rm -rf /")).toBe(false);
expect(ipRegex.test("<script>alert(1)</script>")).toBe(false);
});
test("banned client gets close code 4003", () => {
const ws = createMockWs({ ip: "1.2.3.4" });
// Simulate banning: deny enrollment + close socket
ws.close(4003, "banned");
expect(ws.closedCode).toBe(4003);
expect(ws.closedReason).toBe("banned");
});
test("ban reason is truncated to 200 chars", () => {
const longReason = "A".repeat(500);
const truncated = longReason.slice(0, 200);
expect(truncated.length).toBe(200);
expect(truncated).toBe("A".repeat(200));
});
});
@@ -36,6 +36,7 @@ type HttpDispatchDeps<
handleAuthRoutes: RouteHandlerWithServer<TServer>; handleAuthRoutes: RouteHandlerWithServer<TServer>;
handleNotificationsConfigRoutes: RouteHandlerWithServerDeps<TServer, TNotificationsConfig>; handleNotificationsConfigRoutes: RouteHandlerWithServerDeps<TServer, TNotificationsConfig>;
handleAutoScriptsRoutes: RouteHandler; handleAutoScriptsRoutes: RouteHandler;
handleEnrollmentRoutes: RouteHandler;
handleUsersRoutes: RouteHandlerWithServer<TServer>; handleUsersRoutes: RouteHandlerWithServer<TServer>;
handleBuildRoutes: RouteHandlerWithServerDeps<TServer, TBuild>; handleBuildRoutes: RouteHandlerWithServerDeps<TServer, TBuild>;
handleDeployRoutes: RouteHandlerWithServerDeps<TServer, TDeploy>; handleDeployRoutes: RouteHandlerWithServerDeps<TServer, TDeploy>;
@@ -105,6 +106,9 @@ export function createHttpFetchHandler<
const autoScriptsResponse = await deps.handleAutoScriptsRoutes(req, url); const autoScriptsResponse = await deps.handleAutoScriptsRoutes(req, url);
if (autoScriptsResponse) return autoScriptsResponse; if (autoScriptsResponse) return autoScriptsResponse;
const enrollmentResponse = await deps.handleEnrollmentRoutes(req, url);
if (enrollmentResponse) return enrollmentResponse;
const usersResponse = await deps.handleUsersRoutes(req, url, routeServer); const usersResponse = await deps.handleUsersRoutes(req, url, routeServer);
if (usersResponse) return usersResponse; if (usersResponse) return usersResponse;
@@ -75,8 +75,9 @@ export async function handleClientRoutes(
const statusFilter = url.searchParams.get("status") || "all"; const statusFilter = url.searchParams.get("status") || "all";
const osFilter = url.searchParams.get("os") || "all"; const osFilter = url.searchParams.get("os") || "all";
const countryFilter = url.searchParams.get("country") || "all"; const countryFilter = url.searchParams.get("country") || "all";
const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "all";
if (user.role === "admin") { if (user.role === "admin") {
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter }); const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter });
return Response.json(result, { headers: deps.CORS_HEADERS }); return Response.json(result, { headers: deps.CORS_HEADERS });
} }
@@ -105,6 +106,7 @@ export async function handleClientRoutes(
statusFilter, statusFilter,
osFilter, osFilter,
countryFilter, countryFilter,
enrollmentFilter,
allowedClientIds, allowedClientIds,
deniedClientIds, deniedClientIds,
}); });
@@ -0,0 +1,321 @@
import { authenticateRequest } from "../../auth";
import { getConfig, updateEnrollmentConfig } from "../../config";
import {
getPendingClients,
getEnrollmentStats,
setClientEnrollmentStatus,
getClientEnrollmentStatus,
getClientIp,
banIp,
unbanIp,
isIpBanned,
listBannedIps,
} from "../../db";
import { logAudit, AuditAction } from "../../auditLog";
import * as clientManager from "../../clientManager";
import { setOnlineState } from "../../db";
export async function handleEnrollmentRoutes(
req: Request,
url: URL,
): Promise<Response | null> {
// GET /api/enrollment/pending
if (req.method === "GET" && url.pathname === "/api/enrollment/pending") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
const clients = getPendingClients();
return Response.json({ items: clients });
}
// GET /api/enrollment/stats
if (req.method === "GET" && url.pathname === "/api/enrollment/stats") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
const stats = getEnrollmentStats();
return Response.json(stats);
}
// GET /api/enrollment/settings
if (req.method === "GET" && url.pathname === "/api/enrollment/settings") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
const config = getConfig();
return Response.json({
requireApproval: config.enrollment?.requireApproval ?? true,
});
}
// PUT /api/enrollment/settings
if (req.method === "PUT" && url.pathname === "/api/enrollment/settings") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
let body: any;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
if (typeof body?.requireApproval !== "boolean") {
return Response.json({ error: "requireApproval must be a boolean" }, { status: 400 });
}
await updateEnrollmentConfig(body.requireApproval);
logAudit({
timestamp: Date.now(),
username: user.username,
ip: "server",
action: AuditAction.ENROLLMENT_SETTINGS,
success: true,
details: JSON.stringify({ enrollment: { requireApproval: body.requireApproval } }),
});
return Response.json({ ok: true, requireApproval: body.requireApproval });
}
// POST /api/enrollment/:id/approve
const approveMatch = url.pathname.match(/^\/api\/enrollment\/([^/]+)\/approve$/);
if (req.method === "POST" && approveMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
const clientId = decodeURIComponent(approveMatch[1]);
const current = getClientEnrollmentStatus(clientId);
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
setClientEnrollmentStatus(clientId, "approved", user.username);
logAudit({
timestamp: Date.now(),
username: user.username,
ip: "server",
action: AuditAction.ENROLLMENT_APPROVE,
targetClientId: clientId,
success: true,
details: JSON.stringify({ enrollment: "approved" }),
});
return Response.json({ ok: true, status: "approved" });
}
// POST /api/enrollment/:id/deny
const denyMatch = url.pathname.match(/^\/api\/enrollment\/([^/]+)\/deny$/);
if (req.method === "POST" && denyMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
const clientId = decodeURIComponent(denyMatch[1]);
const current = getClientEnrollmentStatus(clientId);
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
setClientEnrollmentStatus(clientId, "denied");
logAudit({
timestamp: Date.now(),
username: user.username,
ip: "server",
action: AuditAction.ENROLLMENT_DENY,
targetClientId: clientId,
success: true,
details: JSON.stringify({ enrollment: "denied" }),
});
return Response.json({ ok: true, status: "denied" });
}
// POST /api/enrollment/:id/reset
const resetMatch = url.pathname.match(/^\/api\/enrollment\/([^/]+)\/reset$/);
if (req.method === "POST" && resetMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
const clientId = decodeURIComponent(resetMatch[1]);
const current = getClientEnrollmentStatus(clientId);
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
setClientEnrollmentStatus(clientId, "pending");
return Response.json({ ok: true, status: "pending" });
}
// POST /api/enrollment/bulk
if (req.method === "POST" && url.pathname === "/api/enrollment/bulk") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
let body: any;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const ids = Array.isArray(body?.ids) ? body.ids.filter((id: unknown) => typeof id === "string") : [];
const action = body?.action;
if (!["approve", "deny", "reset", "ban-ip"].includes(action)) {
return Response.json({ error: "action must be 'approve', 'deny', 'reset', or 'ban-ip'" }, { status: 400 });
}
if (ids.length === 0) {
return Response.json({ error: "ids array is required" }, { status: 400 });
}
if (action === "ban-ip") {
let banned = 0;
for (const id of ids) {
const clientIp = getClientIp(id);
if (!clientIp) continue;
banIp(clientIp, `Bulk banned from purgatory by ${user.username}`);
setClientEnrollmentStatus(id, "denied");
const target = clientManager.getClient(id);
if (target) {
try { target.ws.close(4003, "banned"); } catch {}
setOnlineState(id, false);
}
banned++;
}
logAudit({
timestamp: Date.now(),
username: user.username,
ip: "server",
action: AuditAction.ENROLLMENT_BULK,
success: true,
details: JSON.stringify({ enrollment: { bulk: "ban-ip", count: banned } }),
});
return Response.json({ ok: true, action, updated: banned });
}
const status = action === "approve" ? "approved" : action === "deny" ? "denied" : "pending";
let updated = 0;
for (const id of ids) {
const ok = setClientEnrollmentStatus(
id,
status as "approved" | "denied" | "pending",
action === "approve" ? user.username : undefined,
);
if (ok) updated++;
}
logAudit({
timestamp: Date.now(),
username: user.username,
ip: "server",
action: AuditAction.ENROLLMENT_BULK,
success: true,
details: JSON.stringify({ enrollment: { bulk: action, count: updated } }),
});
return Response.json({ ok: true, action, updated });
}
// POST /api/enrollment/:id/ban-ip
// Ban ip API makes me wanna fucking kms.
const banMatch = url.pathname.match(/^\/api\/enrollment\/([^/]+)\/ban-ip$/);
if (req.method === "POST" && banMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
const clientId = decodeURIComponent(banMatch[1]);
const targetIp = getClientIp(clientId);
if (!targetIp) return Response.json({ error: "Client IP not found" }, { status: 404 });
banIp(targetIp, `Banned from purgatory by ${user.username} for client ${clientId}`);
setClientEnrollmentStatus(clientId, "denied");
const target = clientManager.getClient(clientId);
if (target) {
try { target.ws.close(4003, "banned"); } catch {}
setOnlineState(clientId, false);
}
logAudit({
timestamp: Date.now(),
username: user.username,
ip: "server",
action: AuditAction.ENROLLMENT_DENY,
targetClientId: clientId,
success: true,
details: JSON.stringify({ bannedIp: targetIp }),
});
return Response.json({ ok: true, ip: targetIp });
}
// GET /api/enrollment/banned-ips
if (req.method === "GET" && url.pathname === "/api/enrollment/banned-ips") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
return Response.json({ items: listBannedIps() });
}
// DELETE /api/enrollment/banned-ips?ip=...
if (req.method === "DELETE" && url.pathname === "/api/enrollment/banned-ips") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
const ipToUnban = (url.searchParams.get("ip") || "").trim();
if (!ipToUnban) return Response.json({ error: "Missing ip parameter" }, { status: 400 });
if (!/^[0-9a-fA-F:.]{3,64}$/.test(ipToUnban)) return Response.json({ error: "Invalid IP format" }, { status: 400 });
if (!isIpBanned(ipToUnban)) return Response.json({ error: "IP is not banned" }, { status: 404 });
unbanIp(ipToUnban);
logAudit({
timestamp: Date.now(),
username: user.username,
ip: "server",
action: AuditAction.ENROLLMENT_BULK,
success: true,
details: JSON.stringify({ unbannedIp: ipToUnban }),
});
return Response.json({ ok: true });
}
// POST /api/enrollment/ban-ip (manual IP ban)
if (req.method === "POST" && url.pathname === "/api/enrollment/ban-ip") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
let body: any;
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
const ip = typeof body?.ip === "string" ? body.ip.trim() : "";
if (!ip) return Response.json({ error: "ip is required" }, { status: 400 });
if (!/^[0-9a-fA-F:.]{3,64}$/.test(ip)) return Response.json({ error: "Invalid IP format" }, { status: 400 });
const reason = typeof body?.reason === "string" ? body.reason.slice(0, 200) : `Banned from purgatory by ${user.username}`;
banIp(ip, reason);
logAudit({
timestamp: Date.now(),
username: user.username,
ip: "server",
action: AuditAction.ENROLLMENT_DENY,
success: true,
details: JSON.stringify({ bannedIp: ip, reason }),
});
return Response.json({ ok: true, ip });
}
return null;
}
@@ -75,6 +75,7 @@ const STATIC_PAGES: StaticPageDef[] = [
{ path: "/scripts", file: "scripts.html", access: "no-viewer" }, { path: "/scripts", file: "scripts.html", access: "no-viewer" },
{ path: "/deploy", file: "deploy.html", access: "admin" }, { path: "/deploy", file: "deploy.html", access: "admin" },
{ path: "/socks5-manager", file: "socks5-manager.html", access: "no-viewer", checkPasswordChange: true }, { path: "/socks5-manager", file: "socks5-manager.html", access: "no-viewer", checkPasswordChange: true },
{ path: "/purgatory", file: "purgatory.html", access: "admin-or-operator", checkPasswordChange: true },
]; ];
/** Client-scoped pages accessed via query param ?clientId=... */ /** Client-scoped pages accessed via query param ?clientId=... */
@@ -1,8 +1,10 @@
import type { ServerWebSocket } from "bun"; import type { ServerWebSocket } from "bun";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import geoip from "geoip-lite";
import { logAudit, AuditAction } from "../../auditLog"; import { logAudit, AuditAction } from "../../auditLog";
import * as clientManager from "../../clientManager"; import * as clientManager from "../../clientManager";
import { clientExists, setOnlineState, upsertClientRow } from "../../db"; import { clientExists, setOnlineState, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey } from "../../db";
import { getConfig } from "../../config";
import { logger } from "../../logger"; import { logger } from "../../logger";
import { metrics } from "../../metrics"; import { metrics } from "../../metrics";
import { decodeMessage, encodeMessage, type WireMessage } from "../../protocol"; import { decodeMessage, encodeMessage, type WireMessage } from "../../protocol";
@@ -85,6 +87,42 @@ type WsLifecycleDeps = {
notifyDashboard: () => void; notifyDashboard: () => void;
}; };
const ENROLLMENT_TIMEOUT_MS = 30_000;
const enrollmentTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
function clearEnrollmentTimeout(clientId: string) {
const t = enrollmentTimeouts.get(clientId);
if (t) {
clearTimeout(t);
enrollmentTimeouts.delete(clientId);
}
}
async function verifyEd25519(publicKeyBase64: string, signatureBase64: string, nonceBase64: string): Promise<boolean> {
try {
const pubKeyBytes = Buffer.from(publicKeyBase64, "base64");
const sigBytes = Buffer.from(signatureBase64, "base64");
const nonceBytes = Buffer.from(nonceBase64, "base64");
if (pubKeyBytes.length !== 32 || sigBytes.length !== 64) return false;
const key = await crypto.subtle.importKey(
"raw",
pubKeyBytes,
{ name: "Ed25519" },
false,
["verify"],
);
return await crypto.subtle.verify("Ed25519", key, sigBytes, nonceBytes);
} catch {
return false;
}
}
function computeKeyFingerprint(publicKeyBase64: string): string {
const bytes = Buffer.from(publicKeyBase64, "base64");
const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
return hash;
}
export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLifecycleDeps): void { export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLifecycleDeps): void {
const role = ws.data.role as string; const role = ws.data.role as string;
const clientId = ws.data.clientId; const clientId = ws.data.clientId;
@@ -101,43 +139,32 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws); if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws);
const id = clientId || uuidv4(); const id = clientId || uuidv4();
const info: ClientInfo = { id, role: role as any, ws, lastSeen: Date.now(), country: "", ip, online: true };
clientManager.addClient(id, info);
ws.data.clientId = id; ws.data.clientId = id;
ws.data.ip = ip; ws.data.ip = ip;
if (role === "client") {
ws.data.wasKnown = clientExists(id);
}
upsertClientRow({ id, role: role as any, ip, lastSeen: info.lastSeen, online: 1 as any }); const nonceBytes = new Uint8Array(32);
logger.info(`[open] ${id} role=${role}`); crypto.getRandomValues(nonceBytes);
const nonceBase64 = Buffer.from(nonceBytes).toString("base64");
ws.data.enrollmentNonce = nonceBase64;
const notificationConfig = deps.getNotificationConfig(); ws.send(encodeMessage({ type: "enrollment_challenge", nonce: nonceBase64 }));
ws.send(
encodeMessage({
type: "hello_ack",
id,
notification: {
keywords: notificationConfig.keywords || [],
minIntervalMs: notificationConfig.minIntervalMs || 8000,
clipboardEnabled: notificationConfig.clipboardEnabled || false,
},
}),
);
if (role === "client") { const timeout = setTimeout(() => {
deps.notifyRemoteDesktopStatus(id, "online"); enrollmentTimeouts.delete(id);
metrics.recordConnection(); try {
deps.notifyDashboard(); ws.close(4002, "enrollment_timeout");
} } catch {}
}, ENROLLMENT_TIMEOUT_MS);
enrollmentTimeouts.set(id, timeout);
logger.info(`[open] ${id} role=${role} — purgatory challenge sent`);
} }
export function handleWebSocketMessage( export async function handleWebSocketMessage(
ws: ServerWebSocket<SocketData>, ws: ServerWebSocket<SocketData>,
message: string | ArrayBuffer | Uint8Array, message: string | ArrayBuffer | Uint8Array,
deps: WsLifecycleDeps, deps: WsLifecycleDeps,
): void { ): Promise<void> {
const size = getMessageByteLength(message as any); const size = getMessageByteLength(message as any);
const role = ws.data?.role as any; const role = ws.data?.role as any;
const limit = getMaxPayloadLimit(role, deps.maxClientPayloadBytes, deps.maxViewerPayloadBytes); const limit = getMaxPayloadLimit(role, deps.maxClientPayloadBytes, deps.maxViewerPayloadBytes);
@@ -162,57 +189,194 @@ export function handleWebSocketMessage(
if (socketRole === "dashboard_viewer") return; if (socketRole === "dashboard_viewer") return;
const { clientId, ip } = ws.data; const { clientId, ip } = ws.data;
const info = clientManager.getClient(clientId);
if (!info) return;
info.lastSeen = Date.now();
let payload: WireMessage;
try { try {
const payload = decodeMessage(message as Uint8Array) as WireMessage; payload = decodeMessage(message as Uint8Array) as WireMessage;
if (!payload || typeof (payload as any).type !== "string") { if (!payload || typeof (payload as any).type !== "string") {
return; return;
} }
if (!isAllowedClientMessageType((payload as any).type)) { } catch (err) {
logger.warn(`[message] Dropping unknown client message type: ${(payload as any).type}`); logger.error("[message] decode error", err);
return; return;
} }
const payloadType = (payload as any)?.type as string; const payloadType = (payload as any).type as string;
if (!isAllowedClientMessageType(payloadType)) {
logger.warn(`[message] Dropping unknown client message type: ${payloadType}`);
return;
}
const info = clientManager.getClient(clientId);
if (!info && payloadType !== "hello") return;
if (info) info.lastSeen = Date.now();
const client = info!;
try {
switch (payloadType) { switch (payloadType) {
case "hello": { case "hello": {
handleHello(info, payload, ws, ip); clearEnrollmentTimeout(clientId);
clientManager.addClient(info.id, info);
deps.dispatchAutoScriptsForConnection(info, ws); const publicKey = typeof (payload as any).publicKey === "string" ? (payload as any).publicKey : "";
const signature = typeof (payload as any).signature === "string" ? (payload as any).signature : "";
const nonce = ws.data.enrollmentNonce || "";
if (!publicKey || !signature || !nonce) {
logger.warn(`[purgatory] missing publicKey/signature/nonce for ${clientId}`);
try { ws.close(4002, "invalid_signature"); } catch {}
return;
}
const valid = await verifyEd25519(publicKey, signature, nonce);
if (!valid) {
logger.warn(`[purgatory] invalid signature for ${clientId}`);
try { ws.close(4002, "invalid_signature"); } catch {}
return;
}
ws.data.enrollmentNonce = undefined;
const keyFingerprint = computeKeyFingerprint(publicKey);
const config = getConfig();
const requireApproval = config.enrollment?.requireApproval ?? true;
const existing = lookupClientByPublicKey(publicKey);
let enrollmentStatus: string;
if (existing) {
enrollmentStatus = existing.enrollmentStatus;
ws.data.clientId = existing.id;
} else {
enrollmentStatus = requireApproval ? "pending" : "approved";
}
const resolvedId = ws.data.clientId;
if (enrollmentStatus === "denied") {
logger.info(`[purgatory] denied client ${resolvedId} tried to connect`);
ws.send(encodeMessage({ type: "enrollment_status", status: "denied" }));
try { ws.close(4003, "denied"); } catch {}
return;
}
if (enrollmentStatus === "pending") {
const geo = ip ? geoip.lookup(ip) : undefined;
const countryRaw = geo?.country || (payload as any).country || "ZZ";
const country = /^[A-Z]{2}$/i.test(countryRaw) ? countryRaw.toUpperCase() : "ZZ";
upsertClientRow({
id: resolvedId,
hwid: (payload as any).hwid || resolvedId,
role: "client",
ip: ip || undefined,
host: (payload as any).host || undefined,
os: (payload as any).os || undefined,
arch: (payload as any).arch || undefined,
version: (payload as any).version || undefined,
user: (payload as any).user || undefined,
monitors: (payload as any).monitors || undefined,
country,
lastSeen: Date.now(),
online: 0 as any,
publicKey,
keyFingerprint,
enrollmentStatus: "pending",
});
logger.info(`[purgatory] client ${resolvedId} is pending approval`);
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
deps.notifyDashboard();
try { ws.close(4001, "pending"); } catch {}
return;
}
const existingClient = clientManager.getClient(resolvedId);
if (existingClient?.ws && existingClient.ws !== ws) {
logger.info(`[purgatory] kicking existing socket for ${resolvedId} (superseded)`);
try { existingClient.ws.close(4004, "superseded"); } catch {}
clientManager.deleteClient(resolvedId);
}
ws.data.wasKnown = clientExists(resolvedId);
const infoObj: ClientInfo = {
id: resolvedId,
role: "client",
ws,
lastSeen: Date.now(),
country: "",
ip,
online: true,
publicKey,
keyFingerprint,
enrollmentStatus: "approved" as any,
};
clientManager.addClient(resolvedId, infoObj);
upsertClientRow({
id: resolvedId,
publicKey,
keyFingerprint,
enrollmentStatus: "approved",
online: 1 as any,
lastSeen: Date.now(),
});
const notificationConfig = deps.getNotificationConfig();
ws.send(
encodeMessage({
type: "hello_ack",
id: resolvedId,
notification: {
keywords: notificationConfig.keywords || [],
minIntervalMs: notificationConfig.minIntervalMs || 8000,
clipboardEnabled: notificationConfig.clipboardEnabled || false,
},
}),
);
handleHello(infoObj, payload, ws, ip);
clientManager.addClient(infoObj.id, infoObj);
deps.dispatchAutoScriptsForConnection(infoObj, ws);
deps.notifyDashboard(); deps.notifyDashboard();
if (info.role === "client") {
const wasKnown = Boolean((ws as any)?.data?.wasKnown); if (infoObj.role === "client") {
deps.notifyRemoteDesktopStatus(resolvedId, "online");
metrics.recordConnection();
const wasKnown = Boolean(ws.data.wasKnown);
logAudit({ logAudit({
timestamp: Date.now(), timestamp: Date.now(),
username: "system", username: "system",
ip: (ws as any)?.data?.ip || ip || "unknown", ip: ws.data?.ip || ip || "unknown",
action: wasKnown ? AuditAction.CLIENT_RECONNECT : AuditAction.CLIENT_FIRST_CONNECT, action: wasKnown ? AuditAction.CLIENT_RECONNECT : AuditAction.CLIENT_FIRST_CONNECT,
targetClientId: info.id, targetClientId: infoObj.id,
success: true, success: true,
details: JSON.stringify({ host: info.host, os: info.os, user: info.user }), details: JSON.stringify({ host: infoObj.host, os: infoObj.os, user: infoObj.user }),
}); });
(ws as any).data.wasKnown = true; (ws as any).data.wasKnown = true;
const buildTag = typeof (payload as any).buildTag === "string" ? (payload as any).buildTag : ""; const buildTag = typeof (payload as any).buildTag === "string" ? (payload as any).buildTag : "";
if (buildTag) { if (buildTag) {
deps.handleBuildTagConnection(info.id, buildTag); deps.handleBuildTagConnection(infoObj.id, buildTag);
} }
} }
break; break;
} }
case "ping": case "ping":
handlePing(info, payload, ws); handlePing(client, payload, ws);
break; break;
case "pong": case "pong":
handlePong(info, payload); handlePong(client, payload);
deps.notifyDashboard(); deps.notifyDashboard();
break; break;
case "frame": case "frame":
if ((payload as any)?.header?.fps === 0) { if ((payload as any)?.header?.fps === 0) {
const pending = deps.takePendingNotificationScreenshot(info.id); const pending = deps.takePendingNotificationScreenshot(client.id);
if (pending) { if (pending) {
let bytes: Uint8Array | null = null; let bytes: Uint8Array | null = null;
if ((payload as any).data instanceof Uint8Array) { if ((payload as any).data instanceof Uint8Array) {
@@ -231,10 +395,10 @@ export function handleWebSocketMessage(
} }
} }
} }
handleFrame(info, payload); handleFrame(client, payload);
break; break;
case "screenshot_result": case "screenshot_result":
deps.handleNotificationScreenshotResult(info.id, payload); deps.handleNotificationScreenshotResult(client.id, payload);
break; break;
case "console_output": case "console_output":
deps.handleConsoleOutput(payload); deps.handleConsoleOutput(payload);
@@ -264,30 +428,30 @@ export function handleWebSocketMessage(
(payload as any).ok, (payload as any).ok,
(payload as any).message, (payload as any).message,
); );
deps.handleFileBrowserMessage(info.id, payload); deps.handleFileBrowserMessage(client.id, payload);
if (payloadType === "command_result" && typeof (payload as any).commandId === "string") { if (payloadType === "command_result" && typeof (payload as any).commandId === "string") {
deps.handleProxyConnectResult( deps.handleProxyConnectResult(
info.id, client.id,
(payload as any).commandId, (payload as any).commandId,
Boolean((payload as any).ok), Boolean((payload as any).ok),
); );
} }
break; break;
case "command_progress": case "command_progress":
deps.handleFileBrowserMessage(info.id, payload); deps.handleFileBrowserMessage(client.id, payload);
break; break;
case "process_list_result": case "process_list_result":
deps.handleProcessMessage(info.id, payload); deps.handleProcessMessage(client.id, payload);
break; break;
case "keylog_file_list": case "keylog_file_list":
case "keylog_file_content": case "keylog_file_content":
case "keylog_clear_result": case "keylog_clear_result":
case "keylog_delete_result": case "keylog_delete_result":
deps.handleKeyloggerMessage(info.id, payload); deps.handleKeyloggerMessage(client.id, payload);
break; break;
case "script_result": { case "script_result": {
logger.debug( logger.debug(
`[script] client=${info.id} ok=${(payload as any).ok} output_length=${(payload as any).output?.length || 0}`, `[script] client=${client.id} ok=${(payload as any).ok} output_length=${(payload as any).output?.length || 0}`,
); );
const cmdId = (payload as any).commandId; const cmdId = (payload as any).commandId;
if (cmdId && deps.pendingScripts.has(cmdId)) { if (cmdId && deps.pendingScripts.has(cmdId)) {
@@ -303,33 +467,33 @@ export function handleWebSocketMessage(
break; break;
} }
case "plugin_event": case "plugin_event":
deps.handlePluginEvent(info.id, payload); deps.handlePluginEvent(client.id, payload);
break; break;
case "notification": case "notification":
deps.handleNotification(info.id, payload); deps.handleNotification(client.id, payload);
break; break;
case "voice_uplink": case "voice_uplink":
deps.handleVoiceUplink(info.id, payload); deps.handleVoiceUplink(client.id, payload);
break; break;
case "webcam_devices": case "webcam_devices":
deps.handleWebcamDevices(info.id, payload); deps.handleWebcamDevices(client.id, payload);
break; break;
case "hvnc_clone_progress": case "hvnc_clone_progress":
deps.handleHVNCCloneProgress(info.id, payload); deps.handleHVNCCloneProgress(client.id, payload);
break; break;
case "proxy_data": { case "proxy_data": {
const connId = (payload as any).connectionId; const connId = (payload as any).connectionId;
const tunnelData = (payload as any).data; const tunnelData = (payload as any).data;
if (typeof connId === "string" && tunnelData) { if (typeof connId === "string" && tunnelData) {
const bytes = tunnelData instanceof Uint8Array ? tunnelData : new Uint8Array(tunnelData); const bytes = tunnelData instanceof Uint8Array ? tunnelData : new Uint8Array(tunnelData);
deps.handleProxyTunnelData(info.id, connId, bytes); deps.handleProxyTunnelData(client.id, connId, bytes);
} }
break; break;
} }
case "proxy_close": { case "proxy_close": {
const connId = (payload as any).connectionId; const connId = (payload as any).connectionId;
if (typeof connId === "string") { if (typeof connId === "string") {
deps.handleProxyTunnelClose(info.id, connId); deps.handleProxyTunnelClose(client.id, connId);
} }
break; break;
} }
@@ -351,6 +515,8 @@ export function handleWebSocketClose(
const role = ws.data.role as string; const role = ws.data.role as string;
const sessionId = ws.data.sessionId; const sessionId = ws.data.sessionId;
clearEnrollmentTimeout(clientId);
if (role === "console_viewer") { if (role === "console_viewer") {
if (sessionId) { if (sessionId) {
sessionManager.deleteConsoleSession(sessionId); sessionManager.deleteConsoleSession(sessionId);
@@ -460,6 +626,12 @@ export function handleWebSocketClose(
return; return;
} }
const currentClient = clientManager.getClient(clientId);
if (currentClient && currentClient.ws !== ws) {
logger.info(`[close] ${clientId} code=${code} (superseded socket, skipping cleanup)`);
return;
}
clientManager.deleteClient(clientId); clientManager.deleteClient(clientId);
stopAllProxiesForClient(clientId); stopAllProxiesForClient(clientId);
clearClientSyncState(clientId); clearClientSyncState(clientId);
@@ -10,7 +10,7 @@ type MessageHandler<TLifecycleDeps> = (
ws: ServerWebSocket<SocketData>, ws: ServerWebSocket<SocketData>,
message: string | ArrayBuffer | Uint8Array, message: string | ArrayBuffer | Uint8Array,
deps: TLifecycleDeps, deps: TLifecycleDeps,
) => void; ) => void | Promise<void>;
type CloseHandler<TLifecycleDeps> = ( type CloseHandler<TLifecycleDeps> = (
ws: ServerWebSocket<SocketData>, ws: ServerWebSocket<SocketData>,
+1
View File
@@ -22,6 +22,7 @@ export type SocketData = {
userId?: number; userId?: number;
wasKnown?: boolean; wasKnown?: boolean;
autoTasksRan?: boolean; autoTasksRan?: boolean;
enrollmentNonce?: string;
}; };
export type ConsoleSession = { export type ConsoleSession = {
+6
View File
@@ -1,5 +1,7 @@
export type ClientRole = "client" | "viewer"; export type ClientRole = "client" | "viewer";
export type EnrollmentStatus = "pending" | "approved" | "denied";
export type ClientInfo = { export type ClientInfo = {
id: string; id: string;
lastSeen: number; lastSeen: number;
@@ -22,6 +24,9 @@ export type ClientInfo = {
monitorInfo?: { width: number; height: number }[]; monitorInfo?: { width: number; height: number }[];
country?: string; country?: string;
pingMs?: number; pingMs?: number;
enrollmentStatus?: EnrollmentStatus;
publicKey?: string;
keyFingerprint?: string;
}; };
export type ListFilters = { export type ListFilters = {
@@ -32,6 +37,7 @@ export type ListFilters = {
statusFilter?: string; statusFilter?: string;
osFilter?: string; osFilter?: string;
countryFilter?: string; countryFilter?: string;
enrollmentFilter?: string;
allowedClientIds?: string[]; allowedClientIds?: string[];
deniedClientIds?: string[]; deniedClientIds?: string[];
}; };
+1 -1
View File
@@ -1,4 +1,4 @@
go 1.24 go 1.25.0
use ( use (
./Overlord-Client ./Overlord-Client
+3 -2
View File
@@ -1,4 +1,5 @@
github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2 h1:4GzTsZK/uG+tHvWR2ORcTCGTC4+mgOPhkUdz/Ro9alg= github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2 h1:4GzTsZK/uG+tHvWR2ORcTCGTC4+mgOPhkUdz/Ro9alg=
github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2/go.mod h1:MUMv+ZURlTO3qH8QAt2gxiIY5X4NMaxObz2ImZOMvyA= github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2/go.mod h1:MUMv+ZURlTO3qH8QAt2gxiIY5X4NMaxObz2ImZOMvyA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=