package main import ( "crypto/tls" "encoding/json" "fmt" "html/template" "io" "net/http" "time" ) var ( workerStore *WorkerStore healthPoller *HealthPoller apiKeyStore *APIKeyStore proxyManager *ProxyManager ) // ServiceDiscoveryInfo matches the service-info response from workers type ServiceDiscoveryInfo struct { ServiceType string `json:"service_type"` Version string `json:"version"` Name string `json:"name"` InstanceID string `json:"instance_id"` Capabilities []string `json:"capabilities"` } // detectWorkerType tries to auto-detect worker type by calling /service-info func detectWorkerType(baseURL string) (WorkerType, string, error) { // Try both /service-info and /health/service-info (for services with separate health ports) endpoints := []string{"/service-info", "/health/service-info"} transport := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } client := &http.Client{ Timeout: 5 * time.Second, Transport: transport, } var lastErr error for _, endpoint := range endpoints { url := baseURL + endpoint resp, err := client.Get(url) if err != nil { lastErr = err continue } defer resp.Body.Close() if resp.StatusCode != 200 { lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) continue } body, err := io.ReadAll(resp.Body) if err != nil { lastErr = err continue } var info ServiceDiscoveryInfo if err := json.Unmarshal(body, &info); err != nil { lastErr = err continue } // Map service_type to WorkerType var workerType WorkerType switch info.ServiceType { case "input": workerType = WorkerTypeInput case "ping": workerType = WorkerTypePing case "output": workerType = WorkerTypeOutput default: lastErr = fmt.Errorf("unknown service type: %s", info.ServiceType) continue } // Generate name from service info if empty name := fmt.Sprintf("%s (%s)", info.Name, info.InstanceID) return workerType, name, nil } if lastErr != nil { return "", "", fmt.Errorf("auto-detection failed: %v", lastErr) } return "", "", fmt.Errorf("auto-detection failed: no endpoints responded") } // Dashboard handler - shows all workers and their status func handleDashboard(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } workers := workerStore.List() dashStats := workerStore.GetDashboardStats() data := struct { Workers []*WorkerInstance Stats map[string]interface{} }{ Workers: workers, Stats: dashStats, } tmpl := template.Must(template.New("dashboard").Parse(dashboardTemplate)) if err := tmpl.Execute(w, data); err != nil { logger.Error("Failed to render dashboard: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) } } // API: List all workers func handleAPIWorkersList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } workers := workerStore.List() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(workers) } // API: Register a new worker func handleAPIWorkersRegister(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var worker WorkerInstance if err := json.NewDecoder(r.Body).Decode(&worker); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } // Validate required fields if worker.URL == "" { http.Error(w, "Missing required field: url", http.StatusBadRequest) return } // Auto-detect worker type if not provided if worker.Type == "" { logger.Info("Auto-detecting worker type for %s", worker.URL) detectedType, suggestedName, err := detectWorkerType(worker.URL) if err != nil { logger.Warn("Auto-detection failed for %s: %v", worker.URL, err) http.Error(w, fmt.Sprintf("Auto-detection failed: %v. Please specify 'type' manually.", err), http.StatusBadRequest) return } worker.Type = detectedType // Use suggested name if name is empty if worker.Name == "" { worker.Name = suggestedName } logger.Info("Auto-detected type: %s, name: %s", worker.Type, worker.Name) } // Validate type if worker.Type != WorkerTypeInput && worker.Type != WorkerTypePing && worker.Type != WorkerTypeOutput { http.Error(w, "Invalid worker type. Must be: input, ping, or output", http.StatusBadRequest) return } // Generate default name if still empty if worker.Name == "" { worker.Name = fmt.Sprintf("%s-worker-%d", worker.Type, time.Now().Unix()) } if err := workerStore.Add(&worker); err != nil { logger.Error("Failed to add worker: %v", err) http.Error(w, "Failed to add worker", http.StatusInternalServerError) return } logger.Info("Registered new worker: %s (%s) at %s", worker.Name, worker.Type, worker.URL) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(worker) } // API: Remove a worker func handleAPIWorkersRemove(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } id := r.URL.Query().Get("id") if id == "" { http.Error(w, "Missing id parameter", http.StatusBadRequest) return } if err := workerStore.Remove(id); err != nil { logger.Error("Failed to remove worker: %v", err) http.Error(w, "Failed to remove worker", http.StatusInternalServerError) return } logger.Info("Removed worker: %s", id) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok", "removed": id}) } // API: Get worker details func handleAPIWorkersGet(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } id := r.URL.Query().Get("id") if id == "" { http.Error(w, "Missing id parameter", http.StatusBadRequest) return } worker, ok := workerStore.Get(id) if !ok { http.Error(w, "Worker not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(worker) } // ==================== GATEWAY HANDLERS ==================== // Gateway: Get next target IP (proxies to input service) func handleGatewayTarget(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if err := proxyManager.ProxyGetTarget(w, r); err != nil { logger.Error("Gateway proxy failed (target): %v", err) http.Error(w, err.Error(), http.StatusBadGateway) } } // Gateway: Submit ping/traceroute result (proxies to output service) func handleGatewayResult(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if err := proxyManager.ProxyPostResult(w, r); err != nil { logger.Error("Gateway proxy failed (result): %v", err) http.Error(w, err.Error(), http.StatusBadGateway) } } // Gateway: Get pool statistics func handleGatewayStats(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } stats := proxyManager.GetPoolStats() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stats) } // ==================== API KEY MANAGEMENT HANDLERS ==================== // API: Generate a new API key (admin only) func handleAPIKeyGenerate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { Name string `json:"name"` WorkerType string `json:"worker_type"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if req.Name == "" || req.WorkerType == "" { http.Error(w, "Missing required fields: name, worker_type", http.StatusBadRequest) return } key, err := apiKeyStore.Add(req.Name, req.WorkerType) if err != nil { logger.Error("Failed to generate API key: %v", err) http.Error(w, "Failed to generate API key", http.StatusInternalServerError) return } logger.Info("Generated API key: %s (type: %s)", req.Name, req.WorkerType) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{ "key": key, "name": req.Name, "worker_type": req.WorkerType, "note": "⚠️ Save this key! It won't be shown again.", }) } // API: List all API keys (admin only) func handleAPIKeyList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } keys := apiKeyStore.List() // Mask the actual keys for security (show only first/last 8 chars) type MaskedKey struct { KeyPreview string `json:"key_preview"` Name string `json:"name"` WorkerType string `json:"worker_type"` CreatedAt string `json:"created_at"` LastUsedAt string `json:"last_used_at,omitempty"` RequestCount int64 `json:"request_count"` Enabled bool `json:"enabled"` } masked := make([]MaskedKey, len(keys)) for i, key := range keys { preview := "****" if len(key.Key) >= 16 { preview = key.Key[:8] + "..." + key.Key[len(key.Key)-8:] } lastUsed := "" if !key.LastUsedAt.IsZero() { lastUsed = key.LastUsedAt.Format("2006-01-02 15:04:05") } masked[i] = MaskedKey{ KeyPreview: preview, Name: key.Name, WorkerType: key.WorkerType, CreatedAt: key.CreatedAt.Format("2006-01-02 15:04:05"), LastUsedAt: lastUsed, RequestCount: key.RequestCount, Enabled: key.Enabled, } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(masked) } // API: Revoke an API key (admin only) func handleAPIKeyRevoke(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } key := r.URL.Query().Get("key") if key == "" { http.Error(w, "Missing key parameter", http.StatusBadRequest) return } if err := apiKeyStore.Revoke(key); err != nil { logger.Error("Failed to revoke API key: %v", err) http.Error(w, err.Error(), http.StatusNotFound) return } logger.Info("Revoked API key: %s", key) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok", "revoked": key}) } const dashboardTemplate = `