Files
ping_service/manager/handlers.go
2026-01-08 12:11:26 +02:00

870 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = `<!DOCTYPE html>
<html>
<head>
<title>Ping Service Manager - Control Panel</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
header {
margin-bottom: 40px;
border-bottom: 2px solid #334155;
padding-bottom: 20px;
}
h1 {
font-size: 32px;
margin-bottom: 10px;
color: #60a5fa;
}
.subtitle {
color: #94a3b8;
font-size: 14px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
background: #1e293b;
padding: 20px;
border-radius: 8px;
border: 1px solid #334155;
}
.stat-label {
font-size: 12px;
text-transform: uppercase;
color: #94a3b8;
margin-bottom: 8px;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #60a5fa;
}
.stat-value.healthy {
color: #34d399;
}
.stat-value.unhealthy {
color: #f87171;
}
.controls {
margin-bottom: 30px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.btn:hover {
background: #2563eb;
}
.btn-secondary {
background: #475569;
}
.btn-secondary:hover {
background: #334155;
}
.workers-section {
margin-bottom: 40px;
}
.section-title {
font-size: 20px;
margin-bottom: 20px;
color: #e2e8f0;
display: flex;
align-items: center;
gap: 10px;
}
.type-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.type-input { background: #7c3aed; color: white; }
.type-ping { background: #0ea5e9; color: white; }
.type-output { background: #f59e0b; color: white; }
.workers-grid {
display: grid;
gap: 15px;
}
.worker-card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
padding: 20px;
transition: border-color 0.2s;
}
.worker-card:hover {
border-color: #475569;
}
.worker-card.unhealthy {
border-left: 4px solid #f87171;
}
.worker-card.healthy {
border-left: 4px solid #34d399;
}
.worker-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 15px;
}
.worker-title {
font-size: 18px;
font-weight: 600;
color: #e2e8f0;
}
.worker-url {
font-size: 12px;
color: #94a3b8;
font-family: 'Courier New', monospace;
margin-top: 4px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.healthy {
background: #34d399;
box-shadow: 0 0 8px #34d399;
}
.status-dot.unhealthy {
background: #f87171;
}
.worker-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #334155;
}
.meta-item {
font-size: 12px;
}
.meta-label {
color: #94a3b8;
margin-bottom: 4px;
}
.meta-value {
color: #e2e8f0;
font-weight: 500;
}
.error-msg {
background: #7f1d1d;
border: 1px solid #991b1b;
padding: 10px;
border-radius: 4px;
font-size: 12px;
margin-top: 10px;
color: #fca5a5;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: #1e293b;
padding: 30px;
border-radius: 8px;
border: 1px solid #334155;
max-width: 500px;
width: 90%;
}
.modal-title {
font-size: 24px;
margin-bottom: 20px;
color: #e2e8f0;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #94a3b8;
}
.form-input, .form-select {
width: 100%;
padding: 10px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 4px;
color: #e2e8f0;
font-size: 14px;
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: #3b82f6;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.refresh-info {
font-size: 12px;
color: #94a3b8;
text-align: right;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🌐 Ping Service Control Panel</h1>
<div class="subtitle">Distributed Internet Network Mapping System</div>
</header>
<div class="stats">
<div class="stat-card">
<div class="stat-label">Total Workers</div>
<div class="stat-value">{{.Stats.total_workers}}</div>
</div>
<div class="stat-card">
<div class="stat-label">Healthy</div>
<div class="stat-value healthy">{{.Stats.healthy}}</div>
</div>
<div class="stat-card">
<div class="stat-label">Unhealthy</div>
<div class="stat-value unhealthy">{{.Stats.unhealthy}}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Pings</div>
<div class="stat-value">{{.Stats.total_pings}}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Results</div>
<div class="stat-value">{{.Stats.total_results}}</div>
</div>
</div>
<div class="controls">
<button class="btn" onclick="openAddModal()"> Add Worker</button>
<button class="btn btn-secondary" onclick="location.reload()">🔄 Refresh</button>
</div>
<div class="workers-section">
<div class="section-title">
📍 Registered Workers
</div>
<div class="workers-grid">
{{range .Workers}}
<div class="worker-card {{if .Healthy}}healthy{{else}}unhealthy{{end}}">
<div class="worker-header">
<div>
<div class="worker-title">
{{.Name}}
<span class="type-badge type-{{.Type}}">{{.Type}}</span>
</div>
<div class="worker-url">{{.URL}}</div>
{{if .Location}}<div class="worker-url">📍 {{.Location}}</div>{{end}}
</div>
<div class="status-indicator">
<span class="status-dot {{if .Healthy}}healthy{{else}}unhealthy{{end}}"></span>
{{if .Healthy}}Online{{else}}Offline{{end}}
</div>
</div>
{{if .LastError}}
<div class="error-msg">
⚠️ {{.LastError}}
</div>
{{end}}
<div class="worker-meta">
<div class="meta-item">
<div class="meta-label">Response Time</div>
<div class="meta-value">{{.ResponseTime}}ms</div>
</div>
<div class="meta-item">
<div class="meta-label">Last Check</div>
<div class="meta-value">{{.LastCheck.Format "15:04:05"}}</div>
</div>
{{if .Stats}}
{{if index .Stats "total_consumers"}}
<div class="meta-item">
<div class="meta-label">Consumers</div>
<div class="meta-value">{{index .Stats "total_consumers"}}</div>
</div>
{{end}}
{{if index .Stats "total_pings"}}
<div class="meta-item">
<div class="meta-label">Pings</div>
<div class="meta-value">{{index .Stats "total_pings"}}</div>
</div>
{{end}}
{{if index .Stats "successful_pings"}}
<div class="meta-item">
<div class="meta-label">Success</div>
<div class="meta-value">{{index .Stats "successful_pings"}}</div>
</div>
{{end}}
{{if index .Stats "total_results"}}
<div class="meta-item">
<div class="meta-label">Results</div>
<div class="meta-value">{{index .Stats "total_results"}}</div>
</div>
{{end}}
{{if index .Stats "hops_discovered"}}
<div class="meta-item">
<div class="meta-label">Hops Found</div>
<div class="meta-value">{{index .Stats "hops_discovered"}}</div>
</div>
{{end}}
{{end}}
</div>
</div>
{{else}}
<div class="worker-card">
<div style="text-align: center; padding: 40px; color: #64748b;">
No workers registered yet. Click "Add Worker" to get started.
</div>
</div>
{{end}}
</div>
</div>
<div class="refresh-info">
Auto-refresh every 30 seconds • Health checks every 60 seconds
</div>
</div>
<!-- Add Worker Modal -->
<div id="addModal" class="modal">
<div class="modal-content">
<div class="modal-title">Add New Worker</div>
<form id="addWorkerForm">
<div class="form-group">
<label class="form-label">Base URL *</label>
<input type="text" class="form-input" id="workerURL" placeholder="http://10.0.0.5:8080" required>
</div>
<div class="form-group">
<label class="form-label">Worker Name (optional - auto-generated if empty)</label>
<input type="text" class="form-input" id="workerName" placeholder="e.g., Input Service EU-1">
</div>
<div class="form-group">
<label class="form-label">Worker Type (optional - auto-detected from service)</label>
<select class="form-select" id="workerType">
<option value="">Auto-detect from service...</option>
<option value="input">Input Service (manual)</option>
<option value="ping">Ping Service (manual)</option>
<option value="output">Output Service (manual)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Location (optional)</label>
<input type="text" class="form-input" id="workerLocation" placeholder="e.g., Helsinki, Finland">
</div>
<div class="form-group">
<label class="form-label">Description (optional)</label>
<input type="text" class="form-input" id="workerDescription" placeholder="e.g., Raspberry Pi 4, Home network">
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeAddModal()">Cancel</button>
<button type="submit" class="btn">Add Worker</button>
</div>
</form>
</div>
</div>
<script>
// Auto-refresh page every 30 seconds
setTimeout(function() {
location.reload();
}, 30000);
function openAddModal() {
document.getElementById('addModal').classList.add('active');
}
function closeAddModal() {
document.getElementById('addModal').classList.remove('active');
document.getElementById('addWorkerForm').reset();
}
document.getElementById('addWorkerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const worker = {
name: document.getElementById('workerName').value,
type: document.getElementById('workerType').value,
url: document.getElementById('workerURL').value,
location: document.getElementById('workerLocation').value,
description: document.getElementById('workerDescription').value
};
try {
const response = await fetch('/api/workers/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(worker)
});
if (response.ok) {
closeAddModal();
location.reload();
} else {
const error = await response.text();
alert('Failed to add worker: ' + error);
}
} catch (error) {
alert('Failed to add worker: ' + error.message);
}
});
// Close modal on background click
document.getElementById('addModal').addEventListener('click', (e) => {
if (e.target.id === 'addModal') {
closeAddModal();
}
});
</script>
</body>
</html>
`