1.5.0 update
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
go 1.24
|
go 1.25.0
|
||||||
|
|
||||||
use (
|
use (
|
||||||
./Overlord-Client
|
./Overlord-Client
|
||||||
|
|||||||
+3
-2
@@ -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=
|
||||||
|
|||||||
Reference in New Issue
Block a user