Add configuration files, database migrations, and authentication implementation scaffolding

This commit is contained in:
Sebastian Unterschütz
2026-04-30 19:08:07 +02:00
commit 331d60581e
83 changed files with 222264 additions and 0 deletions

149
cmd/discord-bot/main.go Normal file
View 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
View 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
View 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
View 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)
}
}