Files
SimpleArmaAdmin/cmd/gateway/main.go

2072 lines
58 KiB
Go

package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"SimpleArmaAdmin/internal/auth"
"SimpleArmaAdmin/internal/crypto"
internal_nats "SimpleArmaAdmin/internal/nats"
"SimpleArmaAdmin/internal/parser"
"SimpleArmaAdmin/internal/webauthn"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/nats-io/nats.go"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// ============================================================================
// DEV MODE: In-memory stores
// ============================================================================
type DevCommunity struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
}
type DevUser struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
}
type DevMembership struct {
UserID string `json:"userId"`
CommunityID string `json:"communityId"`
Role string `json:"role"` // "owner" | "admin" | "member"
}
type DevServer struct {
ID string `json:"id"`
CommunityID string `json:"communityId"`
Name string `json:"name"`
Description string `json:"description"`
WorkerID string `json:"workerId"`
StorageID string `json:"storageId"`
LogPath string `json:"logPath"`
EncryptedRcon string `json:"encryptedRcon"`
EncryptedAutoMessages string `json:"encryptedAutoMessages"` // E2EE blob for AutoMsgs & MOTD
MockActive bool `json:"mockActive"`
CreatedAt time.Time `json:"createdAt"`
}
type DevPlayerNote struct {
ID string `json:"id"`
CommunityID string `json:"communityId"`
PlayerNameHash string `json:"playerNameHash"`
Category string `json:"category"` // "warning" | "info" | "custom"
Content string `json:"content"` // Plaintext for simplicity in dev
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
}
type DevPermission struct {
ID string `json:"id"`
CommunityID string `json:"communityId"`
UserID string `json:"userId"`
Username string `json:"username"`
ServerID string `json:"serverId"`
ServerName string `json:"serverName"`
Scopes []string `json:"scopes"`
GrantedBy string `json:"grantedBy"`
GrantedAt time.Time `json:"grantedAt"`
}
type DevPlayer struct {
ID string `json:"id"`
CommunityID string `json:"communityId"`
NameHash string `json:"nameHash"` // Blind Index
Data string `json:"data"` // Encrypted JSON (Name, IP, SteamID, etc.)
JoinedAt time.Time `json:"joinedAt"`
}
type DevBan struct {
ID string `json:"id"`
CommunityID string `json:"communityId"`
PlayerNameHash string `json:"playerNameHash"` // Blind Index for searching
Data string `json:"data"` // Encrypted JSON (PlayerName, Reason, BannedBy)
BannedAt time.Time `json:"bannedAt"`
ExpiresAt *time.Time `json:"expiresAt"` // nil = permanent
}
type DevNode struct {
ID string `json:"id"`
CommunityID string `json:"-"`
Name string `json:"name"`
Type string `json:"type"` // "storage" | "worker"
Token string `json:"-"` // only returned at creation
Endpoint string `json:"endpoint"`
Status string `json:"status"` // "pending" | "online" | "offline"
Version string `json:"version"`
LastSeen *time.Time `json:"lastSeen"`
CreatedAt time.Time `json:"createdAt"`
}
type DevPasskey struct {
ID string `json:"id"`
UserID string `json:"userId"`
CommunityID string `json:"communityId"`
CredentialID string `json:"credentialId"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
}
type DevStore struct {
communities map[string]*DevCommunity
users map[string]*DevUser
usersByName map[string]*DevUser
memberships []DevMembership
servers map[string]*DevServer
permissions map[string]*DevPermission
players map[string]*DevPlayer // key: communityID+":"+playerName
roster map[string]*DevPlayer // key: communityID+":"+playerNameHash
playerNotes []DevPlayerNote
bans map[string]*DevBan // key: ban ID
passkeys map[string]*DevPasskey
nodes map[string]*DevNode
logs []LogEntry // In-memory log history for dev mode
mockCancels map[string]context.CancelFunc
mu sync.RWMutex
}
type LogEntry struct {
CommunityID string `json:"communityId"`
Data []byte `json:"data"` // Encrypted payload
}
func newDevStore() *DevStore {
return &DevStore{
communities: make(map[string]*DevCommunity),
users: make(map[string]*DevUser),
usersByName: make(map[string]*DevUser),
servers: make(map[string]*DevServer),
permissions: make(map[string]*DevPermission),
players: make(map[string]*DevPlayer),
roster: make(map[string]*DevPlayer),
bans: make(map[string]*DevBan),
passkeys: make(map[string]*DevPasskey),
nodes: make(map[string]*DevNode),
mockCancels: make(map[string]context.CancelFunc),
}
}
func generateNodeToken() string {
return "nk_" + strings.ReplaceAll(uuid.NewString(), "-", "") + strings.ReplaceAll(uuid.NewString(), "-", "")
}
var devMockLogs = []string{
"12:30:01.122 SCRIPT : [RJSSupport][Chat] [Global] Zauberklöte: hi, leute kurze frage. zock seit monaten wieder mal arma, was ist aus dem gtg#4 und #5 geworden, da ist ja nix los",
"09:37:50.865 DEFAULT : BattlEye Server: 'Player #0 Mike1Delta (92.209.175.19:6679) connected'",
"13:29:19.727 SCRIPT : [RJSSupport][Chat] [Global] 纱雾.: WHAT",
"09:38:53.842 DEFAULT : BattlEye Server: 'Player #0 Mike1Delta disconnected'",
"14:56:34.622 SCRIPT : [RJSSupport][Chat] [Global] Toope: help?",
"15:04:22.868 SCRIPT : [RJSSupport][Chat] [Global] vatrano: Transpo 5-10min abwesend",
"10:11:22.001 DEFAULT : BattlEye Server: 'Player #2 SgtPepper47 (195.60.71.82:2201) connected'",
"10:15:00.123 SCRIPT : [RJSSupport][Chat] [Global] SgtPepper47: anyone in comms?",
"10:22:44.500 DEFAULT : BattlEye Server: 'Player #3 Delta_Force_R (91.77.88.99:4411) connected'",
"10:28:10.777 SCRIPT : [RJSSupport][Chat] [Global] Delta_Force_R: pushing north",
"10:35:00.900 DEFAULT : BattlEye Server: 'Player #2 SgtPepper47 disconnected'",
"10:40:18.200 SCRIPT : [RJSSupport][Chat] [Global] Zauberklöte: gg wp",
}
// mockPlayerIPs maps known mock player names to their IPs for JOIN events
var mockPlayerIPs = map[string]string{
"Mike1Delta": "92.209.175.19",
"Zauberklöte": "89.12.45.67",
"纱雾": "103.45.78.90",
"Toope": "178.22.33.44",
"vatrano": "88.99.100.11",
"SgtPepper47": "195.60.71.82",
"Delta_Force_R": "91.77.88.99",
}
func extractPlayerName(content string) string {
if strings.Contains(content, "connected to server") {
return strings.TrimSpace(strings.Split(content, " connected")[0])
}
if strings.Contains(content, "left the server") {
return strings.TrimSpace(strings.Split(content, " left")[0])
}
if strings.Contains(content, ":") {
return strings.TrimSpace(strings.SplitN(content, ":", 2)[0])
}
return ""
}
// ============================================================================
// GATEWAY
// ============================================================================
type Gateway struct {
natsClient *internal_nats.Client
dashboards map[*websocket.Conn]bool
mu sync.Mutex
devMode bool
store *DevStore
}
func (g *Gateway) startMock(communityID, serverID string) {
g.store.mu.Lock()
if _, exists := g.store.mockCancels[communityID]; exists {
g.store.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(context.Background())
g.store.mockCancels[communityID] = cancel
masterKey := []byte("this-is-a-32-byte-master-key-xyz")
// Seed initial online players
for name, ip := range mockPlayerIPs {
nameHash := crypto.GenerateBlindIndex(name, masterKey)
playerData, _ := json.Marshal(map[string]string{
"name": name,
"ip": ip,
})
encryptedData, _ := crypto.Encrypt(playerData, masterKey)
key := communityID + ":" + nameHash
p := &DevPlayer{
ID: uuid.NewString(),
CommunityID: communityID,
NameHash: nameHash,
Data: base64.StdEncoding.EncodeToString(encryptedData),
JoinedAt: time.Now().Add(-time.Duration(len(name)) * time.Minute),
}
g.store.players[key] = p
g.store.roster[key] = p
}
// Seed mock infrastructure nodes
seedTime := time.Now()
storageNode := &DevNode{
ID: uuid.NewString(),
CommunityID: communityID,
Name: "Primary Storage",
Type: "storage",
Token: generateNodeToken(),
Endpoint: "http://storage.example.com:9000",
Status: "online",
Version: "1.2.0",
LastSeen: &seedTime,
CreatedAt: time.Now().Add(-24 * time.Hour),
}
workerNode := &DevNode{
ID: uuid.NewString(),
CommunityID: communityID,
Name: "Log Worker #1",
Type: "worker",
Token: generateNodeToken(),
Endpoint: "http://worker.example.com:8001",
Status: "online",
Version: "0.9.5",
LastSeen: &seedTime,
CreatedAt: time.Now().Add(-12 * time.Hour),
}
g.store.nodes[storageNode.ID] = storageNode
g.store.nodes[workerNode.ID] = workerNode
g.store.mu.Unlock()
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
i := 0
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
line := devMockLogs[i%len(devMockLogs)]
event := parser.ParseLine(line)
if event != nil {
event.ServerID = serverID
event.ServerName = "AMS-NODE-01" // Mock server name
if event.PlayerName != "" {
event.PlayerNameHash = crypto.GenerateBlindIndex(event.PlayerName, masterKey)
} else {
// Assign to SYSTEM if no player is present
event.PlayerName = "SYSTEM"
event.PlayerNameHash = "system-blind-index"
}
// Keep player list in sync with mock events
g.store.mu.Lock()
switch event.Type {
case "JOIN":
if event.PlayerName != "SYSTEM" {
playerName := event.PlayerName
ip := mockPlayerIPs[playerName]
if ip == "" {
ip = "10.0.0.1"
}
nameHash := crypto.GenerateBlindIndex(playerName, masterKey)
playerData, _ := json.Marshal(map[string]string{
"name": playerName,
"ip": ip,
})
encryptedData, _ := crypto.Encrypt(playerData, masterKey)
key := communityID + ":" + nameHash
p := &DevPlayer{
ID: uuid.NewString(),
CommunityID: communityID,
NameHash: nameHash,
Data: base64.StdEncoding.EncodeToString(encryptedData),
JoinedAt: time.Now(),
}
g.store.players[key] = p
g.store.roster[key] = p
}
case "LEAVE":
if event.PlayerName != "SYSTEM" {
nameHash := crypto.GenerateBlindIndex(event.PlayerName, masterKey)
delete(g.store.players, communityID+":"+nameHash)
}
}
// Simulate heartbeats for mock nodes
hbNow := time.Now()
for _, node := range g.store.nodes {
if node.CommunityID == communityID {
node.LastSeen = &hbNow
node.Status = "online"
}
}
onlineCount := 0
for _, p := range g.store.players {
if p.CommunityID == communityID {
onlineCount++
}
}
g.store.mu.Unlock()
payload, err := json.Marshal(event)
if err == nil {
if encrypted, err := crypto.Encrypt(payload, masterKey); err == nil {
g.broadcast(websocket.BinaryMessage, encrypted)
}
}
telemetry, _ := json.Marshal(map[string]interface{}{
"type": "TELEMETRY",
"fps": 45.5 + float64(time.Now().Unix()%5),
"players": onlineCount,
})
g.broadcast(websocket.TextMessage, telemetry)
}
i++
}
}
}()
log.Printf("[DEV] Mock data stream started for community %s", communityID)
}
func (g *Gateway) stopMock(communityID string) {
g.store.mu.Lock()
defer g.store.mu.Unlock()
if cancel, exists := g.store.mockCancels[communityID]; exists {
cancel()
delete(g.store.mockCancels, communityID)
// Clear online players for this community
for key, p := range g.store.players {
if p.CommunityID == communityID {
delete(g.store.players, key)
}
}
log.Printf("[DEV] Mock data stream stopped for community %s", communityID)
}
}
// broadcastEvent encrypts an event and broadcasts it to all dashboards
func (g *Gateway) broadcastEvent(event *parser.LogEvent, serverID string) {
masterKey := []byte("this-is-a-32-byte-master-key-xyz")
event.ServerID = serverID
event.ServerName = "AMS-NODE-01" // Mock
if event.PlayerName == "" {
event.PlayerName = "SYSTEM"
event.PlayerNameHash = "system-blind-index"
} else if event.PlayerNameHash == "" {
event.PlayerNameHash = crypto.GenerateBlindIndex(event.PlayerName, masterKey)
}
payload, err := json.Marshal(event)
if err != nil {
return
}
if encrypted, err := crypto.Encrypt(payload, masterKey); err == nil {
g.broadcast(websocket.BinaryMessage, encrypted)
}
}
// ============================================================================
// HELPERS
// ============================================================================
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func (g *Gateway) requireAuth(r *http.Request) (*auth.Claims, error) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
return nil, fmt.Errorf("missing token")
}
return auth.VerifyJWT(strings.TrimPrefix(authHeader, "Bearer "))
}
// ============================================================================
// WEBSOCKET
// ============================================================================
func (g *Gateway) handleWebSocket(w http.ResponseWriter, r *http.Request) {
role := r.URL.Query().Get("role")
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Upgrade error: %v", err)
return
}
if role == "dashboard" {
g.mu.Lock()
g.dashboards[conn] = true
g.mu.Unlock()
}
defer func() {
if role == "dashboard" {
g.mu.Lock()
delete(g.dashboards, conn)
g.mu.Unlock()
}
conn.Close()
}()
communityID := "comm-123-abc"
for {
messageType, p, err := conn.ReadMessage()
if err != nil {
break
}
if role != "dashboard" {
if messageType == websocket.BinaryMessage {
g.store.mu.Lock()
g.store.logs = append(g.store.logs, LogEntry{
CommunityID: communityID,
Data: p,
})
if len(g.store.logs) > 1000 {
g.store.logs = g.store.logs[1:]
}
g.store.mu.Unlock()
g.natsClient.PublishLog(context.Background(), communityID, "live", p)
g.broadcast(websocket.BinaryMessage, p)
} else {
g.natsClient.Conn.Publish("telemetry."+communityID, p)
}
}
}
}
func (g *Gateway) broadcast(messageType int, data []byte) {
if messageType == websocket.BinaryMessage {
g.store.mu.Lock()
g.store.logs = append(g.store.logs, LogEntry{
CommunityID: "dev-community", // Hardcoded for dev mode simplicity
Data: data,
})
// Keep last 1000 logs
if len(g.store.logs) > 1000 {
g.store.logs = g.store.logs[1:]
}
g.store.mu.Unlock()
}
g.mu.Lock()
defer g.mu.Unlock()
for client := range g.dashboards {
if err := client.WriteMessage(messageType, data); err != nil {
client.Close()
delete(g.dashboards, client)
}
}
}
// ============================================================================
// REGISTRATION
// ============================================================================
func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
CommunityName string `json:"communityName"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" {
writeError(w, http.StatusBadRequest, "Username is required")
return
}
if err := auth.ValidatePasswordStrength(req.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
return
}
if g.devMode {
g.store.mu.Lock()
defer g.store.mu.Unlock()
if _, exists := g.store.usersByName[strings.ToLower(req.Username)]; exists {
writeError(w, http.StatusConflict, "Username already taken")
return
}
communityID := uuid.NewString()
communityName := req.CommunityName
if communityName == "" {
communityName = req.Username + "'s Team"
}
community := &DevCommunity{
ID: communityID,
Name: communityName,
CreatedAt: time.Now(),
}
g.store.communities[communityID] = community
userID := uuid.NewString()
user := &DevUser{
ID: userID,
Username: req.Username,
Email: req.Email,
PasswordHash: hash,
CreatedAt: time.Now(),
}
g.store.users[userID] = user
g.store.usersByName[strings.ToLower(req.Username)] = user
membership := DevMembership{
UserID: userID,
CommunityID: communityID,
Role: "owner",
}
g.store.memberships = append(g.store.memberships, membership)
log.Printf("[DEV] Registered user: %s, Created community: %s", req.Username, communityName)
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "success",
"token": token,
"userId": userID,
"communityId": communityID,
"username": req.Username,
"role": "owner",
"masterKey": "this-is-a-32-byte-master-key-xyz",
})
return
}
// Non-dev mode fallback
userID := "user-" + req.Username
communityID := "comm-" + req.Username
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "success",
"token": token,
"userId": userID,
"communityId": communityID,
"username": req.Username,
"role": "owner",
"masterKey": "this-is-a-32-byte-master-key-xyz",
})
}
func (g *Gateway) handleRegisterPasskeyBegin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
options, err := webauthn.CreateRegistrationOptions(
"user-"+req.Username,
req.Username,
req.DisplayName,
"ArmaAdmin Zero-Knowledge Cloud",
"localhost",
)
if err != nil {
http.Error(w, "Failed to create options", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(options)
}
func (g *Gateway) handleRegisterPasskeyFinish(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "success",
"message": "Passkey registered successfully",
})
}
// ============================================================================
// PASSKEY MANAGEMENT (authenticated: list, add, delete)
// ============================================================================
func detectDeviceName(userAgent string) string {
ua := strings.ToLower(userAgent)
var os string
switch {
case strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad"):
os = "iPhone/iPad"
case strings.Contains(ua, "android"):
os = "Android"
case strings.Contains(ua, "mac os x"):
os = "macOS"
case strings.Contains(ua, "windows"):
os = "Windows"
case strings.Contains(ua, "linux"):
os = "Linux"
default:
os = "Unknown Device"
}
var browser string
switch {
case strings.Contains(ua, "firefox"):
browser = "Firefox"
case strings.Contains(ua, "edg/"):
browser = "Edge"
case strings.Contains(ua, "chrome"):
browser = "Chrome"
case strings.Contains(ua, "safari"):
browser = "Safari"
default:
browser = "Browser"
}
return browser + " on " + os
}
func (g *Gateway) handlePasskeys(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
claims, err := g.requireAuth(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
g.store.mu.RLock()
var list []DevPasskey
for _, pk := range g.store.passkeys {
if pk.UserID == claims.UserID {
list = append(list, *pk)
}
}
g.store.mu.RUnlock()
if list == nil {
list = []DevPasskey{}
}
sort.Slice(list, func(i, j int) bool { return list[i].CreatedAt.Before(list[j].CreatedAt) })
writeJSON(w, http.StatusOK, map[string]interface{}{"passkeys": list})
case http.MethodDelete:
claims, err := g.requireAuth(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
id := r.URL.Query().Get("id")
if id == "" {
writeError(w, http.StatusBadRequest, "Missing id")
return
}
g.store.mu.Lock()
pk, exists := g.store.passkeys[id]
if !exists || pk.UserID != claims.UserID {
g.store.mu.Unlock()
writeError(w, http.StatusNotFound, "Passkey not found")
return
}
delete(g.store.passkeys, id)
g.store.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (g *Gateway) handleAddPasskeyBegin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
claims, err := g.requireAuth(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
g.store.mu.RLock()
user, exists := g.store.users[claims.UserID]
g.store.mu.RUnlock()
if !exists {
writeError(w, http.StatusNotFound, "User not found")
return
}
options, err := webauthn.CreateRegistrationOptions(
user.ID, user.Username, user.Username,
"ArmaAdmin Zero-Knowledge Cloud", "localhost",
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to create options")
return
}
writeJSON(w, http.StatusOK, options)
}
func (g *Gateway) handleAddPasskeyFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
claims, err := g.requireAuth(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var req struct {
ID string `json:"id"`
Name string `json:"name"`
}
json.NewDecoder(r.Body).Decode(&req)
name := req.Name
if name == "" {
name = detectDeviceName(r.Header.Get("User-Agent"))
}
pk := &DevPasskey{
ID: uuid.New().String(),
UserID: claims.UserID,
CommunityID: claims.CommunityID,
CredentialID: req.ID,
Name: name,
CreatedAt: time.Now(),
}
g.store.mu.Lock()
g.store.passkeys[pk.ID] = pk
g.store.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "passkey": pk})
}
// ============================================================================
// NODES
// ============================================================================
func (g *Gateway) handleNodes(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
switch r.Method {
case http.MethodGet:
g.store.mu.RLock()
var result []map[string]interface{}
for _, n := range g.store.nodes {
if n.CommunityID == claims.CommunityID {
result = append(result, map[string]interface{}{
"id": n.ID,
"name": n.Name,
"type": n.Type,
"endpoint": n.Endpoint,
"status": n.Status,
"version": n.Version,
"lastSeen": n.LastSeen,
"createdAt": n.CreatedAt,
})
}
}
g.store.mu.RUnlock()
if result == nil {
result = []map[string]interface{}{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"nodes": result})
case http.MethodPost:
var req struct {
Name string `json:"name"`
Type string `json:"type"`
Endpoint string `json:"endpoint"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.Type != "storage" && req.Type != "worker" {
writeError(w, http.StatusBadRequest, "type must be 'storage' or 'worker'")
return
}
token := generateNodeToken()
node := &DevNode{
ID: uuid.NewString(),
CommunityID: claims.CommunityID,
Name: req.Name,
Type: req.Type,
Token: token,
Endpoint: req.Endpoint,
Status: "pending",
CreatedAt: time.Now(),
}
g.store.mu.Lock()
g.store.nodes[node.ID] = node
g.store.mu.Unlock()
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": node.ID,
"name": node.Name,
"type": node.Type,
"endpoint": node.Endpoint,
"status": node.Status,
"version": node.Version,
"lastSeen": node.LastSeen,
"createdAt": node.CreatedAt,
"token": token,
})
case http.MethodDelete:
nodeID := r.URL.Query().Get("id")
if nodeID == "" {
writeError(w, http.StatusBadRequest, "Missing id")
return
}
g.store.mu.Lock()
node, exists := g.store.nodes[nodeID]
if !exists || node.CommunityID != claims.CommunityID {
g.store.mu.Unlock()
writeError(w, http.StatusNotFound, "Node not found")
return
}
delete(g.store.nodes, nodeID)
g.store.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "Missing token", http.StatusUnauthorized)
return
}
g.store.mu.RLock()
var node *DevNode
for _, n := range g.store.nodes {
if n.Token == token {
node = n
break
}
}
g.store.mu.RUnlock()
if node == nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[NODE] WebSocket upgrade failed for node %s: %v", node.Name, err)
return
}
defer conn.Close()
now := time.Now()
g.store.mu.Lock()
node.Status = "online"
node.LastSeen = &now
g.store.mu.Unlock()
log.Printf("[NODE] %s (%s) connected via WebSocket", node.Name, node.ID)
conn.WriteJSON(map[string]string{"type": "connected", "nodeId": node.ID, "name": node.Name})
for {
var msg struct {
Type string `json:"type"`
Version string `json:"version"`
}
if err := conn.ReadJSON(&msg); err != nil {
break
}
if msg.Type == "heartbeat" {
hbNow := time.Now()
g.store.mu.Lock()
node.LastSeen = &hbNow
node.Status = "online"
if msg.Version != "" {
node.Version = msg.Version
}
g.store.mu.Unlock()
}
}
g.store.mu.Lock()
node.Status = "offline"
g.store.mu.Unlock()
log.Printf("[NODE] %s (%s) disconnected", node.Name, node.ID)
}
func (g *Gateway) handleDevNodePing(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !g.devMode {
http.Error(w, "Not found", http.StatusNotFound)
return
}
nodeID := r.URL.Query().Get("id")
if nodeID == "" {
writeError(w, http.StatusBadRequest, "Missing id")
return
}
g.store.mu.Lock()
node, exists := g.store.nodes[nodeID]
if !exists {
g.store.mu.Unlock()
writeError(w, http.StatusNotFound, "Node not found")
return
}
now := time.Now()
node.LastSeen = &now
node.Status = "online"
token := node.Token
name := node.Name
g.store.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{
"status": "pinged",
"nodeId": nodeID,
"name": name,
"token": token,
})
}
func (g *Gateway) monitorNodes() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
g.store.mu.Lock()
for _, node := range g.store.nodes {
if node.Status == "online" && node.LastSeen != nil && now.Sub(*node.LastSeen) > 90*time.Second {
node.Status = "offline"
log.Printf("[NODE] Node %s (%s) went offline", node.Name, node.ID)
}
}
g.store.mu.Unlock()
}
}
// ============================================================================
// LOGIN
// ============================================================================
func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if g.devMode {
g.store.mu.RLock()
user, exists := g.store.usersByName[strings.ToLower(req.Username)]
var userMemberships []DevMembership
if exists {
for _, m := range g.store.memberships {
if m.UserID == user.ID {
userMemberships = append(userMemberships, m)
}
}
}
g.store.mu.RUnlock()
if !exists || len(userMemberships) == 0 {
writeError(w, http.StatusUnauthorized, "Invalid credentials or no community access")
return
}
ok, err := auth.VerifyPassword(req.Password, user.PasswordHash)
if err != nil || !ok {
writeError(w, http.StatusUnauthorized, "Invalid credentials")
return
}
// Use the first community as default for the initial token
defaultMembership := userMemberships[0]
// Map community names
type communityInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
}
var communities []communityInfo
g.store.mu.RLock()
for _, m := range userMemberships {
if comm, ok := g.store.communities[m.CommunityID]; ok {
communities = append(communities, communityInfo{
ID: comm.ID,
Name: comm.Name,
Role: m.Role,
})
}
}
g.store.mu.RUnlock()
token, err := auth.GenerateJWT(user.ID, defaultMembership.CommunityID, user.Username, 7*24*time.Hour)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "success",
"token": token,
"userId": user.ID,
"communityId": defaultMembership.CommunityID,
"username": user.Username,
"role": defaultMembership.Role,
"communities": communities,
"masterKey": "this-is-a-32-byte-master-key-xyz",
})
return
}
// Non-dev mode: demo fallback
log.Printf("[DEMO] Login attempt for user: %s (auto-accepting)", req.Username)
userID := "user-" + req.Username
communityID := "comm-" + req.Username
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "success",
"token": token,
"userId": userID,
"communityId": communityID,
"username": req.Username,
"role": "owner",
"masterKey": "this-is-a-32-byte-master-key-xyz",
})
}
func (g *Gateway) handleLoginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
options, err := webauthn.CreateAuthenticationOptions("localhost", []string{})
if err != nil {
http.Error(w, "Failed to create options", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(options)
}
func (g *Gateway) handleLoginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
token, err := auth.GenerateJWT("user-demo", "comm-demo", "demo", 7*24*time.Hour)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "success",
"token": token,
"userId": "user-demo",
"communityId": "comm-demo",
"username": "demo",
"role": "owner",
"masterKey": "this-is-a-32-byte-master-key-xyz",
})
}
// ============================================================================
// SESSION & PROFILE
// ============================================================================
func (g *Gateway) handleLogout(w http.ResponseWriter, r *http.Request) {
// TODO: Invalidate session in database
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
}
func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
resp := map[string]string{
"userId": claims.UserID,
"username": claims.Username,
"communityId": claims.CommunityID,
"role": "owner",
}
if g.devMode {
g.store.mu.RLock()
if user, exists := g.store.users[claims.UserID]; exists {
resp["role"] = user.Role
resp["email"] = user.Email
}
g.store.mu.RUnlock()
}
writeJSON(w, http.StatusOK, resp)
}
func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
switch r.Method {
case http.MethodGet:
resp := map[string]string{
"id": claims.UserID,
"username": claims.Username,
"communityId": claims.CommunityID,
"email": "",
"role": "owner",
}
if g.devMode {
g.store.mu.RLock()
if user, exists := g.store.users[claims.UserID]; exists {
resp["email"] = user.Email
resp["role"] = user.Role
}
g.store.mu.RUnlock()
}
writeJSON(w, http.StatusOK, resp)
case http.MethodPut:
var req struct {
Username string `json:"username"`
Email string `json:"email"`
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if !g.devMode {
writeError(w, http.StatusNotImplemented, "Profile update requires database")
return
}
g.store.mu.Lock()
defer g.store.mu.Unlock()
user, exists := g.store.users[claims.UserID]
if !exists {
http.Error(w, "User not found", http.StatusNotFound)
return
}
if req.Username != "" && req.Username != user.Username {
newKey := strings.ToLower(req.Username)
if _, taken := g.store.usersByName[newKey]; taken {
writeError(w, http.StatusConflict, "Username already taken")
return
}
delete(g.store.usersByName, strings.ToLower(user.Username))
user.Username = req.Username
g.store.usersByName[newKey] = user
}
if req.Email != "" {
user.Email = req.Email
}
if req.NewPassword != "" {
ok, err := auth.VerifyPassword(req.OldPassword, user.PasswordHash)
if err != nil || !ok {
writeError(w, http.StatusUnauthorized, "Current password is incorrect")
return
}
if err := auth.ValidatePasswordStrength(req.NewPassword); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
return
}
user.PasswordHash = hash
}
token, err := auth.GenerateJWT(user.ID, user.CommunityID, user.Username, 7*24*time.Hour)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "updated",
"token": token,
"username": user.Username,
"email": user.Email,
"role": user.Role,
"communityId": user.CommunityID,
})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// ============================================================================
// SERVER MANAGEMENT
// ============================================================================
func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
switch r.Method {
case http.MethodGet:
g.store.mu.RLock()
var result []*DevServer
for _, s := range g.store.servers {
if s.CommunityID == claims.CommunityID {
result = append(result, s)
}
}
g.store.mu.RUnlock()
if result == nil {
result = []*DevServer{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"servers": result})
case http.MethodPost:
var req struct {
Name string `json:"name"`
Description string `json:"description"`
WorkerID string `json:"workerId"`
StorageID string `json:"storageId"`
LogPath string `json:"logPath"`
EncryptedRcon string `json:"encryptedRcon"`
EncryptedAutoMessages string `json:"encryptedAutoMessages"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
writeError(w, http.StatusBadRequest, "Server name is required")
return
}
g.store.mu.Lock()
isFirst := true
for _, s := range g.store.servers {
if s.CommunityID == claims.CommunityID {
isFirst = false
break
}
}
serverID := uuid.NewString()
server := &DevServer{
ID: serverID,
CommunityID: claims.CommunityID,
Name: req.Name,
Description: req.Description,
WorkerID: req.WorkerID,
StorageID: req.StorageID,
LogPath: req.LogPath,
EncryptedRcon: req.EncryptedRcon,
EncryptedAutoMessages: req.EncryptedAutoMessages,
MockActive: g.devMode && isFirst,
CreatedAt: time.Now(),
}
g.store.servers[serverID] = server
g.store.mu.Unlock()
if g.devMode && isFirst {
g.startMock(claims.CommunityID, serverID)
}
writeJSON(w, http.StatusCreated, server)
case http.MethodPut:
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
WorkerID string `json:"workerId"`
StorageID string `json:"storageId"`
LogPath string `json:"logPath"`
EncryptedRcon string `json:"encryptedRcon"`
EncryptedAutoMessages string `json:"encryptedAutoMessages"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
g.store.mu.Lock()
defer g.store.mu.Unlock()
server, exists := g.store.servers[req.ID]
if !exists || server.CommunityID != claims.CommunityID {
http.Error(w, "Server not found", http.StatusNotFound)
return
}
if req.Name != "" {
server.Name = req.Name
}
server.Description = req.Description
server.WorkerID = req.WorkerID
server.StorageID = req.StorageID
server.LogPath = req.LogPath
if req.EncryptedRcon != "" {
server.EncryptedRcon = req.EncryptedRcon
}
if req.EncryptedAutoMessages != "" {
server.EncryptedAutoMessages = req.EncryptedAutoMessages
}
// Broadcast update to Workers and Dashboards
updateMsg, _ := json.Marshal(map[string]interface{}{
"type": "CONFIG_UPDATE",
"server": server,
})
g.broadcast(websocket.TextMessage, updateMsg)
writeJSON(w, http.StatusOK, server)
case http.MethodDelete:
serverID := r.URL.Query().Get("id")
if serverID == "" {
http.Error(w, "Missing id", http.StatusBadRequest)
return
}
g.store.mu.Lock()
server, exists := g.store.servers[serverID]
if !exists || server.CommunityID != claims.CommunityID {
g.store.mu.Unlock()
http.Error(w, "Not found", http.StatusNotFound)
return
}
delete(g.store.servers, serverID)
hasMore := false
for _, s := range g.store.servers {
if s.CommunityID == claims.CommunityID {
hasMore = true
break
}
}
g.store.mu.Unlock()
if !hasMore {
g.stopMock(claims.CommunityID)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// ============================================================================
// USER SEARCH
// ============================================================================
func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
g.store.mu.RLock()
defer g.store.mu.RUnlock()
type userResult struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
}
var results []userResult
for _, u := range g.store.users {
if u.CommunityID == claims.CommunityID && u.ID != claims.UserID {
if query == "" || strings.Contains(strings.ToLower(u.Username), query) {
results = append(results, userResult{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Role: u.Role,
})
}
}
}
if results == nil {
results = []userResult{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"users": results})
}
// ============================================================================
// PERMISSIONS
// ============================================================================
func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
switch r.Method {
case http.MethodGet:
userID := r.URL.Query().Get("userId")
g.store.mu.RLock()
var result []*DevPermission
for _, p := range g.store.permissions {
if p.CommunityID == claims.CommunityID {
if userID == "" || p.UserID == userID {
result = append(result, p)
}
}
}
g.store.mu.RUnlock()
if result == nil {
result = []*DevPermission{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"permissions": result})
case http.MethodPost:
var req struct {
UserID string `json:"userId"`
ServerID string `json:"serverId"`
Scopes []string `json:"scopes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.UserID == "" {
writeError(w, http.StatusBadRequest, "userId is required")
return
}
g.store.mu.Lock()
defer g.store.mu.Unlock()
targetUser, exists := g.store.users[req.UserID]
if !exists {
writeError(w, http.StatusNotFound, "User not found")
return
}
serverName := "All Servers"
if req.ServerID != "" {
if s, ok := g.store.servers[req.ServerID]; ok {
serverName = s.Name
}
}
// Upsert: update if same user+server combo exists
for _, p := range g.store.permissions {
if p.UserID == req.UserID && p.ServerID == req.ServerID && p.CommunityID == claims.CommunityID {
p.Scopes = req.Scopes
p.GrantedBy = claims.Username
p.GrantedAt = time.Now()
writeJSON(w, http.StatusOK, p)
return
}
}
perm := &DevPermission{
ID: uuid.NewString(),
CommunityID: claims.CommunityID,
UserID: req.UserID,
Username: targetUser.Username,
ServerID: req.ServerID,
ServerName: serverName,
Scopes: req.Scopes,
GrantedBy: claims.Username,
GrantedAt: time.Now(),
}
g.store.permissions[perm.ID] = perm
writeJSON(w, http.StatusCreated, perm)
case http.MethodDelete:
permID := r.URL.Query().Get("id")
if permID == "" {
http.Error(w, "Missing id", http.StatusBadRequest)
return
}
g.store.mu.Lock()
defer g.store.mu.Unlock()
perm, exists := g.store.permissions[permID]
if !exists || perm.CommunityID != claims.CommunityID {
http.Error(w, "Not found", http.StatusNotFound)
return
}
delete(g.store.permissions, permID)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// ============================================================================
// PLAYER NOTES & WARNINGS
// ============================================================================
func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
switch r.Method {
case http.MethodGet:
playerNameHash := r.URL.Query().Get("playerNameHash")
if playerNameHash == "" {
writeError(w, http.StatusBadRequest, "playerNameHash is required")
return
}
g.store.mu.RLock()
defer g.store.mu.RUnlock()
var results []DevPlayerNote
for _, note := range g.store.playerNotes {
if note.CommunityID == claims.CommunityID && note.PlayerNameHash == playerNameHash {
results = append(results, note)
}
}
if results == nil {
results = []DevPlayerNote{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"notes": results})
case http.MethodPost:
var req struct {
PlayerNameHash string `json:"playerNameHash"`
Category string `json:"category"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
note := DevPlayerNote{
ID: uuid.NewString(),
CommunityID: claims.CommunityID,
PlayerNameHash: req.PlayerNameHash,
Category: req.Category,
Content: req.Content,
CreatedBy: claims.Username,
CreatedAt: time.Now(),
}
g.store.mu.Lock()
g.store.playerNotes = append(g.store.playerNotes, note)
g.store.mu.Unlock()
writeJSON(w, http.StatusCreated, note)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (g *Gateway) handlePlayers(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
g.store.mu.RLock()
defer g.store.mu.RUnlock()
type playerWithNotes struct {
*DevPlayer
WarningCount int `json:"warningCount"`
}
var result []playerWithNotes
for _, p := range g.store.players {
if p.CommunityID == claims.CommunityID {
warnings := 0
for _, n := range g.store.playerNotes {
if n.CommunityID == claims.CommunityID && n.PlayerNameHash == p.NameHash && n.Category == "warning" {
warnings++
}
}
result = append(result, playerWithNotes{DevPlayer: p, WarningCount: warnings})
}
}
if result == nil {
result = []playerWithNotes{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"players": result})
}
func (g *Gateway) handleKick(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
claims, err := g.requireAuth(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req struct {
PlayerNameHash string `json:"playerNameHash"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.PlayerNameHash == "" {
writeError(w, http.StatusBadRequest, "playerNameHash is required")
return
}
key := claims.CommunityID + ":" + req.PlayerNameHash
g.store.mu.Lock()
_, exists := g.store.players[key]
if !exists {
g.store.mu.Unlock()
writeError(w, http.StatusNotFound, "Player not online")
return
}
delete(g.store.players, key)
g.store.mu.Unlock()
// Broadcast a LEAVE event so the log stream shows the kick
reason := req.Reason
if reason == "" {
reason = "Kicked by admin"
}
leaveEvent := &parser.LogEvent{
Type: "LEAVE",
Content: "Player left the server [KICK: " + reason + "]",
}
g.broadcastEvent(leaveEvent, "server-123")
log.Printf("[DEV] Kicked player %s (reason: %s) by %s", req.PlayerNameHash, reason, claims.Username)
writeJSON(w, http.StatusOK, map[string]string{"status": "kicked", "playerNameHash": req.PlayerNameHash})
}
func (g *Gateway) handleBan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
claims, err := g.requireAuth(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req struct {
PlayerNameHash string `json:"playerNameHash"`
Data string `json:"data"` // Encrypted JSON
DurationMinutes int `json:"durationMinutes"` // 0 = permanent
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.PlayerNameHash == "" {
writeError(w, http.StatusBadRequest, "playerNameHash is required")
return
}
var expiresAt *time.Time
if req.DurationMinutes > 0 {
t := time.Now().Add(time.Duration(req.DurationMinutes) * time.Minute)
expiresAt = &t
}
ban := &DevBan{
ID: uuid.NewString(),
CommunityID: claims.CommunityID,
PlayerNameHash: req.PlayerNameHash,
Data: req.Data,
BannedAt: time.Now(),
ExpiresAt: expiresAt,
}
g.store.mu.Lock()
g.store.bans[ban.ID] = ban
// Also remove from online players (kick)
delete(g.store.players, claims.CommunityID+":"+req.PlayerNameHash)
g.store.mu.Unlock()
// Broadcast LEAVE event
leaveEvent := &parser.LogEvent{
Type: "LEAVE",
Content: "Player left the server [BAN]",
}
g.broadcastEvent(leaveEvent, "server-123")
log.Printf("[DEV] Banned player %s (duration: %dmin) by %s", req.PlayerNameHash, req.DurationMinutes, claims.Username)
writeJSON(w, http.StatusCreated, ban)
}
func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
switch r.Method {
case http.MethodGet:
g.store.mu.RLock()
var result []*DevBan
for _, b := range g.store.bans {
if b.CommunityID == claims.CommunityID {
// Auto-expire
if b.ExpiresAt == nil || b.ExpiresAt.After(time.Now()) {
result = append(result, b)
}
}
}
g.store.mu.RUnlock()
if result == nil {
result = []*DevBan{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"bans": result})
case http.MethodDelete:
banID := r.URL.Query().Get("id")
if banID == "" {
http.Error(w, "Missing id", http.StatusBadRequest)
return
}
g.store.mu.Lock()
ban, exists := g.store.bans[banID]
if !exists || ban.CommunityID != claims.CommunityID {
g.store.mu.Unlock()
http.Error(w, "Not found", http.StatusNotFound)
return
}
delete(g.store.bans, banID)
g.store.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"status": "unbanned"})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// ============================================================================
// REMAINING ENDPOINTS
// ============================================================================
func (g *Gateway) handlePlayerSearch(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
g.store.mu.RLock()
defer g.store.mu.RUnlock()
type playerWithNotes struct {
*DevPlayer
WarningCount int `json:"warningCount"`
}
var result []playerWithNotes
for _, p := range g.store.roster {
if p.CommunityID == claims.CommunityID {
warnings := 0
for _, n := range g.store.playerNotes {
if n.CommunityID == claims.CommunityID && n.PlayerNameHash == p.NameHash && n.Category == "warning" {
warnings++
}
}
result = append(result, playerWithNotes{DevPlayer: p, WarningCount: warnings})
}
}
if result == nil {
result = []playerWithNotes{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"results": result})
}
func (g *Gateway) handleLogs(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
g.store.mu.RLock()
var result []LogEntry
for _, entry := range g.store.logs {
if entry.CommunityID == claims.CommunityID {
result = append(result, entry)
}
}
g.store.mu.RUnlock()
if result == nil {
result = []LogEntry{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"logs": result})
}
func (g *Gateway) handleDSGVOExport(w http.ResponseWriter, r *http.Request) {
playerID := r.URL.Query().Get("playerId")
if playerID == "" {
http.Error(w, "Missing playerId", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", "attachment; filename=player-data.json")
json.NewEncoder(w).Encode(map[string]interface{}{
"playerId": playerID,
"exportDate": time.Now().Format(time.RFC3339),
"logs": []interface{}{},
})
}
func (g *Gateway) handleDSGVODelete(w http.ResponseWriter, r *http.Request) {
var req struct {
PlayerID string `json:"playerId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (g *Gateway) handleIngest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
http.Error(w, "Missing API key", http.StatusUnauthorized)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
communityID := "comm-123-abc"
g.natsClient.PublishLog(context.Background(), communityID, "api", body)
w.WriteHeader(http.StatusAccepted)
w.Write([]byte("OK"))
}
// ============================================================================
// MAIN
// ============================================================================
func main() {
natsURL := os.Getenv("NATS_URL")
if natsURL == "" {
natsURL = "nats://localhost:4222"
}
devMode := os.Getenv("DEV_MODE") == "true"
if devMode {
log.Println("[DEV] Development mode enabled — using in-memory store")
}
nc, err := internal_nats.Connect(natsURL)
if err != nil {
log.Fatalf("NATS error: %v", err)
}
defer nc.Close()
nc.SetupStream(context.Background(), "LOGS", []string{"logs.>"})
gateway := &Gateway{
natsClient: nc,
dashboards: make(map[*websocket.Conn]bool),
devMode: devMode,
store: newDevStore(),
}
go func() {
nc.Conn.Subscribe("logs.>", func(m *nats.Msg) {
gateway.broadcast(websocket.BinaryMessage, m.Data)
})
nc.Conn.Subscribe("telemetry.>", func(m *nats.Msg) {
gateway.broadcast(websocket.TextMessage, m.Data)
})
}()
http.HandleFunc("/ws", gateway.handleWebSocket)
http.HandleFunc("/api/auth/register", gateway.handleRegister)
http.HandleFunc("/api/auth/register/passkey/begin", gateway.handleRegisterPasskeyBegin)
http.HandleFunc("/api/auth/register/passkey/finish", gateway.handleRegisterPasskeyFinish)
http.HandleFunc("/api/auth/login/password", gateway.handleLoginPassword)
http.HandleFunc("/api/auth/login/passkey/begin", gateway.handleLoginPasskeyBegin)
http.HandleFunc("/api/auth/login/passkey/finish", gateway.handleLoginPasskeyFinish)
http.HandleFunc("/api/auth/logout", gateway.handleLogout)
http.HandleFunc("/api/auth/me", gateway.handleMe)
http.HandleFunc("/api/auth/profile", gateway.handleProfile)
http.HandleFunc("/api/auth/passkeys", gateway.handlePasskeys)
http.HandleFunc("/api/auth/passkeys/begin", gateway.handleAddPasskeyBegin)
http.HandleFunc("/api/auth/passkeys/finish", gateway.handleAddPasskeyFinish)
go gateway.monitorNodes()
http.HandleFunc("/api/nodes", gateway.handleNodes)
http.HandleFunc("/ws/node", gateway.handleNodeWebSocket)
if devMode {
http.HandleFunc("/api/dev/nodes/ping", gateway.handleDevNodePing)
}
http.HandleFunc("/api/servers", gateway.handleServers)
http.HandleFunc("/api/users/search", gateway.handleUserSearch)
http.HandleFunc("/api/permissions", gateway.handlePermissions)
http.HandleFunc("/api/players", gateway.handlePlayers)
http.HandleFunc("/api/players/kick", gateway.handleKick)
http.HandleFunc("/api/players/ban", gateway.handleBan)
http.HandleFunc("/api/bans", gateway.handleBans)
http.HandleFunc("/api/players/search", gateway.handlePlayerSearch)
http.HandleFunc("/api/logs", gateway.handleLogs)
http.HandleFunc("/api/dsgvo/export", gateway.handleDSGVOExport)
http.HandleFunc("/api/dsgvo/delete", gateway.handleDSGVODelete)
http.HandleFunc("/api/ingest", gateway.handleIngest)
log.Println("Gateway listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}