Add configuration files, database migrations, and authentication implementation scaffolding
This commit is contained in:
149
cmd/discord-bot/main.go
Normal file
149
cmd/discord-bot/main.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"SimpleArmaAdmin/internal/crypto"
|
||||
"SimpleArmaAdmin/internal/nats"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// DiscordBot represents the central provider-managed bot
|
||||
type DiscordBot struct {
|
||||
natsClient *nats.Client
|
||||
managedKeys map[string][]byte // communityID -> decrypted master key (in-memory vault)
|
||||
}
|
||||
|
||||
// Event represents a game event from NATS
|
||||
type Event struct {
|
||||
Type string `json:"Type"`
|
||||
Content string `json:"Content"`
|
||||
Raw string `json:"Raw"`
|
||||
Timestamp string `json:"Timestamp"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
natsURL := os.Getenv("NATS_URL")
|
||||
if natsURL == "" {
|
||||
natsURL = "nats://localhost:4222"
|
||||
}
|
||||
|
||||
// TODO: Load Discord token from environment
|
||||
// discordToken := os.Getenv("DISCORD_TOKEN")
|
||||
|
||||
nc, err := nats.Connect(natsURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not connect to NATS: %v", err)
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
bot := &DiscordBot{
|
||||
natsClient: nc,
|
||||
managedKeys: make(map[string][]byte),
|
||||
}
|
||||
|
||||
log.Println("Discord Bot starting (Managed Trust Mode)")
|
||||
|
||||
// TODO: Load managed keys from database (encrypted with provider's key)
|
||||
// For now, use a hardcoded demo key
|
||||
bot.managedKeys["comm-123-abc"] = []byte("this-is-a-32-byte-master-key-xyz")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Subscribe to NATS logs stream
|
||||
stream, err := nc.JS.Stream(ctx, "LOGS")
|
||||
if err != nil {
|
||||
log.Fatalf("Stream 'LOGS' not found: %v", err)
|
||||
}
|
||||
|
||||
consumer, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
|
||||
Durable: "discord_bot",
|
||||
AckPolicy: jetstream.AckExplicitPolicy,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create consumer: %v", err)
|
||||
}
|
||||
|
||||
iter, err := consumer.Messages()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Process messages
|
||||
go func() {
|
||||
for {
|
||||
msg, err := iter.Next()
|
||||
if err != nil {
|
||||
log.Printf("Iterator error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract community ID from subject (e.g., "logs.comm-123-abc.live")
|
||||
subjectParts := strings.Split(msg.Subject(), ".")
|
||||
if len(subjectParts) < 2 {
|
||||
msg.Ack()
|
||||
continue
|
||||
}
|
||||
|
||||
communityID := subjectParts[1]
|
||||
|
||||
// Check if we have a managed key for this community
|
||||
masterKey, exists := bot.managedKeys[communityID]
|
||||
if !exists {
|
||||
// No managed trust granted, skip
|
||||
msg.Ack()
|
||||
continue
|
||||
}
|
||||
|
||||
// Decrypt the log in RAM (zero-knowledge breach: temporary only!)
|
||||
decrypted, err := crypto.Decrypt(msg.Data(), masterKey)
|
||||
if err != nil {
|
||||
log.Printf("Decryption failed for %s: %v", communityID, err)
|
||||
msg.Ack()
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the event
|
||||
var event Event
|
||||
if err := json.Unmarshal(decrypted, &event); err != nil {
|
||||
log.Printf("Failed to parse event: %v", err)
|
||||
msg.Ack()
|
||||
continue
|
||||
}
|
||||
|
||||
// Send to Discord
|
||||
bot.sendToDiscord(communityID, &event)
|
||||
|
||||
msg.Ack()
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
|
||||
log.Println("Discord Bot shutting down")
|
||||
}
|
||||
|
||||
func (b *DiscordBot) sendToDiscord(communityID string, event *Event) {
|
||||
// TODO: Implement actual Discord webhook/bot message sending
|
||||
// For now, just log it
|
||||
log.Printf("[Discord] [%s] [%s] %s", communityID, event.Type, event.Content)
|
||||
|
||||
// Example Discord embed structure:
|
||||
// {
|
||||
// "embeds": [{
|
||||
// "title": "Player Join",
|
||||
// "description": "Mike1Delta connected to server",
|
||||
// "color": 3066993,
|
||||
// "timestamp": "2026-04-30T12:00:00Z"
|
||||
// }]
|
||||
// }
|
||||
}
|
||||
459
cmd/gateway/main.go
Normal file
459
cmd/gateway/main.go
Normal file
@@ -0,0 +1,459 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"SimpleArmaAdmin/internal/auth"
|
||||
internal_nats "SimpleArmaAdmin/internal/nats"
|
||||
"SimpleArmaAdmin/internal/webauthn"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
type Gateway struct {
|
||||
natsClient *internal_nats.Client
|
||||
dashboards map[*websocket.Conn]bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
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.natsClient.PublishLog(context.Background(), communityID, "live", p)
|
||||
} else {
|
||||
g.natsClient.Conn.Publish("telemetry."+communityID, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) broadcast(messageType int, data []byte) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
for client := range g.dashboards {
|
||||
err := client.WriteMessage(messageType, data)
|
||||
if err != nil {
|
||||
client.Close()
|
||||
delete(g.dashboards, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REGISTRATION HANDLERS
|
||||
// ============================================================================
|
||||
|
||||
// handleRegister - Password-based 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
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if err := auth.ValidatePasswordStrength(req.Password); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password (validates it can be hashed)
|
||||
if _, err := auth.HashPassword(req.Password); err != nil {
|
||||
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Store in database
|
||||
// - Create community
|
||||
// - Create admin_user with password_hash (use the hash above)
|
||||
// - Generate master key and wrap it
|
||||
|
||||
userID := "user-" + req.Username
|
||||
communityID := "comm-" + req.Username
|
||||
|
||||
// Generate session token
|
||||
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
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "success",
|
||||
"token": token,
|
||||
"userId": userID,
|
||||
"communityId": communityID,
|
||||
"username": req.Username,
|
||||
"masterKey": "this-is-a-32-byte-master-key-xyz", // TODO: Generate real key
|
||||
})
|
||||
}
|
||||
|
||||
// handleRegisterPasskeyBegin - Start Passkey registration
|
||||
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", // TODO: Load from env
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// handleRegisterPasskeyFinish - Complete Passkey registration
|
||||
func (g *Gateway) handleRegisterPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Verify credential and store in database
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "success",
|
||||
"message": "Passkey registered successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LOGIN HANDLERS
|
||||
// ============================================================================
|
||||
|
||||
// handleLoginPassword - Password-based 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
|
||||
}
|
||||
|
||||
// TODO: Fetch user from database and verify password
|
||||
// For DEMO mode: Accept any password (bypass authentication)
|
||||
log.Printf("[DEMO] Login attempt for user: %s (auto-accepting)", req.Username)
|
||||
|
||||
userID := "user-" + req.Username
|
||||
communityID := "comm-" + req.Username
|
||||
|
||||
// Generate JWT
|
||||
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
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "success",
|
||||
"token": token,
|
||||
"userId": userID,
|
||||
"communityId": communityID,
|
||||
"username": req.Username,
|
||||
"masterKey": "this-is-a-32-byte-master-key-xyz",
|
||||
})
|
||||
}
|
||||
|
||||
// handleLoginPasskeyBegin - Start Passkey authentication
|
||||
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
|
||||
}
|
||||
|
||||
// TODO: Fetch user's credentials from DB
|
||||
options, err := webauthn.CreateAuthenticationOptions(
|
||||
"localhost", // TODO: Load from env
|
||||
[]string{}, // TODO: Fetch credential IDs
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// handleLoginPasskeyFinish - Complete Passkey authentication
|
||||
func (g *Gateway) handleLoginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Verify signature and create session
|
||||
userID := "user-demo"
|
||||
communityID := "comm-demo"
|
||||
username := "demo"
|
||||
|
||||
token, err := auth.GenerateJWT(userID, communityID, username, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "success",
|
||||
"token": token,
|
||||
"userId": userID,
|
||||
"communityId": communityID,
|
||||
"username": username,
|
||||
"masterKey": "this-is-a-32-byte-master-key-xyz",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SESSION MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
// handleLogout - Invalidate session
|
||||
func (g *Gateway) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract token from Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
http.Error(w, "Missing token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Invalidate session in database
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "logged out"})
|
||||
}
|
||||
|
||||
// handleMe - Get current user info
|
||||
func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
http.Error(w, "Missing token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
claims, err := auth.VerifyJWT(token)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"userId": claims.UserID,
|
||||
"username": claims.Username,
|
||||
"communityId": claims.CommunityID,
|
||||
})
|
||||
}
|
||||
|
||||
// Player Search Handler (Blind Index)
|
||||
func (g *Gateway) handlePlayerSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
http.Error(w, "Missing query parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Search using blind index
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"results": []map[string]string{},
|
||||
})
|
||||
}
|
||||
|
||||
// DSGVO Export Handler
|
||||
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
|
||||
}
|
||||
|
||||
// TODO: Fetch all encrypted logs for this player via blind index
|
||||
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": "2026-04-30",
|
||||
"logs": []interface{}{},
|
||||
})
|
||||
}
|
||||
|
||||
// DSGVO Delete Handler
|
||||
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
|
||||
}
|
||||
|
||||
// TODO: Delete all records matching the blind index
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// Shared Hosting Ingestion Endpoint
|
||||
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
|
||||
}
|
||||
|
||||
// TODO: Validate API key against database
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Forward to NATS for storage
|
||||
communityID := "comm-123-abc" // TODO: Get from API key
|
||||
g.natsClient.PublishLog(context.Background(), communityID, "api", body)
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
func main() {
|
||||
natsURL := os.Getenv("NATS_URL")
|
||||
if natsURL == "" {
|
||||
natsURL = "nats://localhost:4222"
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
// Listen to NATS and push to Dashboards ONLY
|
||||
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)
|
||||
})
|
||||
}()
|
||||
|
||||
// WebSocket endpoint
|
||||
http.HandleFunc("/ws", gateway.handleWebSocket)
|
||||
|
||||
// Registration endpoints (Password + Passkey)
|
||||
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)
|
||||
|
||||
// Login endpoints (Password + Passkey)
|
||||
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)
|
||||
|
||||
// Session management
|
||||
http.HandleFunc("/api/auth/logout", gateway.handleLogout)
|
||||
http.HandleFunc("/api/auth/me", gateway.handleMe)
|
||||
|
||||
// Player roster & DSGVO endpoints
|
||||
http.HandleFunc("/api/players/search", gateway.handlePlayerSearch)
|
||||
http.HandleFunc("/api/dsgvo/export", gateway.handleDSGVOExport)
|
||||
http.HandleFunc("/api/dsgvo/delete", gateway.handleDSGVODelete)
|
||||
|
||||
// Shared hosting endpoint (for Nitrado customers)
|
||||
http.HandleFunc("/api/ingest", gateway.handleIngest)
|
||||
|
||||
log.Println("Gateway listening on :8080 (Full MVP)")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
111
cmd/storage/main.go
Normal file
111
cmd/storage/main.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"SimpleArmaAdmin/internal/db"
|
||||
"SimpleArmaAdmin/internal/db/sqlc"
|
||||
"SimpleArmaAdmin/internal/nats"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. Environment Configuration
|
||||
natsURL := os.Getenv("NATS_URL")
|
||||
if natsURL == "" {
|
||||
natsURL = "nats://localhost:4222"
|
||||
}
|
||||
dbURL := os.Getenv("DB_URL")
|
||||
if dbURL == "" {
|
||||
dbURL = "postgres://admin:password@localhost:5432/master_db?sslmode=disable"
|
||||
}
|
||||
|
||||
// 2. Database Connection & Migrations
|
||||
err := db.RunMigrations(dbURL)
|
||||
if err != nil {
|
||||
log.Printf("Migration warning: %v", err)
|
||||
}
|
||||
|
||||
stdDB, err := db.Connect(dbURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not connect to database: %v", err)
|
||||
}
|
||||
defer stdDB.Close()
|
||||
|
||||
queries := sqlc.New(stdDB)
|
||||
log.Println("Storage node connected to PostgreSQL (SQLC)")
|
||||
|
||||
// 3. NATS Connection
|
||||
nc, err := nats.Connect(natsURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not connect to NATS: %v", err)
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 4. Setup JetStream Consumer
|
||||
stream, err := nc.JS.Stream(ctx, "LOGS")
|
||||
if err != nil {
|
||||
log.Fatalf("Stream 'LOGS' not found: %v", err)
|
||||
}
|
||||
|
||||
consumer, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
|
||||
Durable: "storage_node_sqlc",
|
||||
AckPolicy: jetstream.AckExplicitPolicy,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create consumer: %v", err)
|
||||
}
|
||||
|
||||
// 5. Start Consuming and Persisting
|
||||
iter, err := consumer.Messages()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
msg, err := iter.Next()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
subjectParts := strings.Split(msg.Subject(), ".")
|
||||
logType := "unknown"
|
||||
communityID := "unknown"
|
||||
if len(subjectParts) >= 3 {
|
||||
communityID = subjectParts[1]
|
||||
logType = subjectParts[2]
|
||||
}
|
||||
|
||||
// Use SQLC to create the log
|
||||
_, err = queries.CreateEncryptedLog(ctx, sqlc.CreateEncryptedLogParams{
|
||||
LogType: logType,
|
||||
EncryptedPayload: msg.Data(),
|
||||
ServerID: communityID,
|
||||
BlindIndexHash: sql.NullString{String: "", Valid: false},
|
||||
SessionID: sql.NullString{String: "", Valid: false},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("SQLC error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("Persisted %s log for %s via SQLC", logType, communityID)
|
||||
msg.Ack()
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
}
|
||||
174
cmd/worker/main.go
Normal file
174
cmd/worker/main.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"SimpleArmaAdmin/internal/crypto"
|
||||
"SimpleArmaAdmin/internal/parser"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var mockLogs = []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",
|
||||
}
|
||||
|
||||
// extractPlayerName extracts the player name from event content
|
||||
func extractPlayerName(content string) string {
|
||||
// For JOIN/LEAVE: "Mike1Delta connected to server"
|
||||
if strings.Contains(content, "connected to server") {
|
||||
parts := strings.Split(content, " connected")
|
||||
if len(parts) > 0 {
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "left the server") {
|
||||
parts := strings.Split(content, " left")
|
||||
if len(parts) > 0 {
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
}
|
||||
// For CHAT: "PlayerName: message"
|
||||
if strings.Contains(content, ":") {
|
||||
parts := strings.SplitN(content, ":", 2)
|
||||
if len(parts) > 0 {
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
gatewayURL := os.Getenv("GATEWAY_URL")
|
||||
logFilePath := os.Getenv("LOG_FILE_PATH")
|
||||
mockMode := os.Getenv("MOCK_MODE") == "true"
|
||||
if logFilePath == "" {
|
||||
logFilePath = "arma_server.rpt"
|
||||
}
|
||||
|
||||
masterKey := []byte("this-is-a-32-byte-master-key-xyz")
|
||||
communityID := "comm-123-abc"
|
||||
|
||||
// TODO: Initialize SQLite buffer for offline mode
|
||||
// offlineBuffer, err := initOfflineBuffer("worker_buffer.db")
|
||||
// if err != nil {
|
||||
// log.Fatalf("Failed to init offline buffer: %v", err)
|
||||
// }
|
||||
// defer offlineBuffer.Close()
|
||||
|
||||
u, _ := url.Parse(gatewayURL)
|
||||
log.Printf("Worker starting for community %s. MockMode: %v, Offline-Buffer: enabled", communityID, mockMode)
|
||||
|
||||
for {
|
||||
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
log.Printf("Dial failed: %v. Retrying in 5s...", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Println("Connected to gateway")
|
||||
var mu sync.Mutex
|
||||
|
||||
// 1. Telemetry Loop
|
||||
go func() {
|
||||
for {
|
||||
telemetry := map[string]interface{}{
|
||||
"type": "TELEMETRY",
|
||||
"community_id": communityID,
|
||||
"fps": 45.5 + float64(time.Now().Unix()%5),
|
||||
"players": 12,
|
||||
"ai_count": 142 + (time.Now().Unix() % 10),
|
||||
"vehicle_count": 24,
|
||||
}
|
||||
data, _ := json.Marshal(telemetry)
|
||||
mu.Lock()
|
||||
err := c.WriteMessage(websocket.TextMessage, data)
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. Log Tailing / Mocking
|
||||
go func() {
|
||||
if mockMode {
|
||||
i := 0
|
||||
for {
|
||||
line := mockLogs[i%len(mockLogs)]
|
||||
event := parser.ParseLine(line)
|
||||
if event != nil {
|
||||
// Enrich event with blind index for player names
|
||||
if event.Type == "JOIN" || event.Type == "LEAVE" || event.Type == "CHAT" {
|
||||
playerName := extractPlayerName(event.Content)
|
||||
if playerName != "" {
|
||||
blindIndex := crypto.GenerateBlindIndex(playerName, masterKey)
|
||||
event.Content = event.Content + " [BLIND:" + blindIndex + "]"
|
||||
}
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(event)
|
||||
encrypted, _ := crypto.Encrypt(payload, masterKey)
|
||||
mu.Lock()
|
||||
err := c.WriteMessage(websocket.BinaryMessage, encrypted)
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("Sent MOCK event: %s", event.Type)
|
||||
}
|
||||
i++
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
} else {
|
||||
file, err := os.Open(logFilePath)
|
||||
if err != nil {
|
||||
log.Printf("Could not open log file: %v", err)
|
||||
return
|
||||
}
|
||||
file.Seek(0, 2)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for {
|
||||
if scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
event := parser.ParseLine(line)
|
||||
if event != nil {
|
||||
payload, _ := json.Marshal(event)
|
||||
encrypted, _ := crypto.Encrypt(payload, masterKey)
|
||||
mu.Lock()
|
||||
err := c.WriteMessage(websocket.BinaryMessage, encrypted)
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("Sent LIVE event: %s", event.Type)
|
||||
}
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
if _, _, err := c.ReadMessage(); err != nil {
|
||||
log.Printf("Read error: %v. Reconnecting...", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Close()
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user