feat: Extract Wild Central as standalone Go service
Extract the Central networking functionality from wild-cloud/api into a standalone service. Wild Central manages DNS (dnsmasq), gateway (HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot), security (CrowdSec), and DDNS — all the network appliance concerns. All Cloud-specific code (instances, clusters, nodes, apps, backups, operations, kubectl/talosctl tooling) has been removed. The API struct and route registration contain only Central endpoints. Tests updated to match the new API signature. Builds and all tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
148
internal/api/v1/handlers_sse.go
Normal file
148
internal/api/v1/handlers_sse.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/sse"
|
||||
)
|
||||
|
||||
// sendSSEEvent writes an SSE event to the response writer
|
||||
func sendSSEEvent(w http.ResponseWriter, event *sse.Event) error {
|
||||
// Set event ID
|
||||
if _, err := fmt.Fprintf(w, "id: %s\n", event.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set event type
|
||||
if _, err := fmt.Fprintf(w, "event: %s\n", event.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set retry interval (in milliseconds)
|
||||
if _, err := fmt.Fprintf(w, "retry: 5000\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Marshal event data to JSON
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal event: %w", err)
|
||||
}
|
||||
|
||||
// Write data field
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GlobalEventStream handles SSE connections for ALL events (global and instance-specific)
|
||||
func (api *API) GlobalEventStream(w http.ResponseWriter, r *http.Request) {
|
||||
// 1. Parse event filters from query parameters
|
||||
filters := sse.EventFilters{
|
||||
EventTypes: parseQueryList(r.URL.Query().Get("types")),
|
||||
Namespaces: parseQueryList(r.URL.Query().Get("namespaces")),
|
||||
Apps: parseQueryList(r.URL.Query().Get("apps")),
|
||||
}
|
||||
|
||||
// 2. Set SSE headers
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
|
||||
|
||||
// 3. Register client with SSE manager
|
||||
// Use "*" as a special instance name to receive ALL events
|
||||
client := api.sseManager.RegisterClient("*", filters)
|
||||
defer api.sseManager.UnregisterClient(client)
|
||||
|
||||
// 4. Send initial connected event
|
||||
connectedEvent := &sse.Event{
|
||||
Type: "connected",
|
||||
InstanceName: "global",
|
||||
Data: map[string]any{
|
||||
"message": "Successfully connected to global event stream",
|
||||
"filters": filters,
|
||||
},
|
||||
}
|
||||
if err := sendSSEEvent(w, connectedEvent); err != nil {
|
||||
slog.Error("failed to send SSE connected event", "stream", "global", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Flush immediately to establish connection
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// 6. Send heartbeat and handle events
|
||||
heartbeatInterval := 30 // seconds
|
||||
heartbeatTicker := time.NewTicker(time.Duration(heartbeatInterval) * time.Second)
|
||||
defer heartbeatTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-client.Context.Done():
|
||||
// Client disconnected
|
||||
return
|
||||
|
||||
case <-r.Context().Done():
|
||||
// Request cancelled
|
||||
return
|
||||
|
||||
case event := <-client.Channel:
|
||||
// Send event to client
|
||||
if err := sendSSEEvent(w, event); err != nil {
|
||||
slog.Error("failed to send SSE event", "stream", "global", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Flush after each event
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
case <-heartbeatTicker.C:
|
||||
// Send heartbeat to keep connection alive
|
||||
heartbeatEvent := &sse.Event{
|
||||
Type: "heartbeat",
|
||||
InstanceName: "global",
|
||||
Data: map[string]any{
|
||||
"timestamp": time.Now().Unix(),
|
||||
},
|
||||
}
|
||||
if err := sendSSEEvent(w, heartbeatEvent); err != nil {
|
||||
slog.Error("failed to send SSE heartbeat", "stream", "global", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Flush after heartbeat
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseQueryList parses comma-separated query parameter into slice
|
||||
func parseQueryList(param string) []string {
|
||||
if param == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(param, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user