Claude Code session 1.

This commit is contained in:
Kalzu Rekku
2026-01-08 12:11:26 +02:00
parent c59523060d
commit 6db2e58dcd
20 changed files with 5497 additions and 83 deletions

869
manager/handlers.go Normal file
View File

@@ -0,0 +1,869 @@
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>
`