Add configuration files, database migrations, and authentication implementation scaffolding
This commit is contained in:
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))
|
||||
}
|
||||
Reference in New Issue
Block a user