diff --git a/cmd/sim-web/main.go b/cmd/sim-web/main.go index 9aea097..4092593 100644 --- a/cmd/sim-web/main.go +++ b/cmd/sim-web/main.go @@ -120,6 +120,7 @@ func main() { mux.HandleFunc("/api/events", app.handleEvents) mux.HandleFunc("/api/audio", app.handleAudio) mux.HandleFunc("/api/observed", app.handleObserved) + mux.HandleFunc("/api/peer/", app.handlePeerProxy) mux.HandleFunc("/api/config", app.handleConfig) mux.HandleFunc("/api/topology", app.handleTopology) mux.HandleFunc("/api/stats", app.handleStats) diff --git a/cmd/sim-web/map.html b/cmd/sim-web/map.html index 5300267..ee39f90 100644 --- a/cmd/sim-web/map.html +++ b/cmd/sim-web/map.html @@ -303,6 +303,49 @@ #panel-listen { bottom: 76px; right: 18px; width: 280px; } + +#panel-chatbot { + top: 200px; right: 18px; width: 240px; + display: none; /* shown only when /api/peer/chatbot/state is reachable */ +} +#chatbot-status { + font-family: var(--font-display); + font-size: 22px; + letter-spacing: 4px; + color: var(--phos-hot); + text-shadow: 0 0 8px var(--phos-glow); + line-height: 1; +} +#chatbot-status.stopped { color: var(--phos-dim); text-shadow: none; } +#chatbot-status.running { color: var(--amber-hot); text-shadow: 0 0 8px rgba(255,224,102,0.5); } +#chatbot-detail { + font-family: var(--font-mono); + font-size: 10px; + color: var(--phos-dim); + letter-spacing: 1px; + margin-top: 4px; + min-height: 1.2em; +} +.chatbot-btn { + margin-top: 8px; + padding: 4px 14px; + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 3px; + cursor: pointer; + text-transform: uppercase; + background: rgba(78,220,169,0.1); + border: 1px solid var(--phos); + color: var(--phos-hot); +} +.chatbot-btn:hover { background: rgba(78,220,169,0.25); } +.chatbot-btn.stop { + background: rgba(255,90,90,0.15); + border-color: var(--red); + color: var(--red-hot); +} +.chatbot-btn.stop:hover { background: rgba(255,90,90,0.3); } +.chatbot-btn:disabled { opacity: 0.35; cursor: not-allowed; } #listen-status { font-family: var(--font-display); font-size: 18px; @@ -410,6 +453,16 @@
+
+
CHATBOTpeer
+
+
— stopped —
+
+ + +
+
+
LISTENWebAudio
@@ -1405,6 +1458,64 @@ setInterval(pollObserved, 5000); setTimeout(pollObserved, 2000); // first read shortly after boot +// ============================================================ +// Chatbot peer — start/stop a synthetic-traffic bot if one is +// deployed alongside us. /api/peer/chatbot/state returns 503 when +// no chatbot peer exists; we hide the panel in that case. +// ============================================================ +const chatbotPanel = $('panel-chatbot'); +const chatbotStatus = $('chatbot-status'); +const chatbotDetail = $('chatbot-detail'); +const chatbotStart = $('chatbot-start'); +const chatbotStop = $('chatbot-stop'); + +async function pollChatbot() { + try { + const r = await fetch('/api/peer/chatbot/state', { cache: 'no-store' }); + if (!r.ok) { + // 503 → no chatbot peer; hide the panel quietly. + chatbotPanel.style.display = 'none'; + return; + } + const j = await r.json(); + chatbotPanel.style.display = ''; + applyChatbotState(j); + } catch { + chatbotPanel.style.display = 'none'; + } +} + +function applyChatbotState(j) { + const running = j.state === 'running'; + const transition = j.state === 'starting' || j.state === 'stopping'; + chatbotStatus.textContent = transition ? `— ${j.state} —` : (running ? 'RUNNING' : '— STOPPED —'); + chatbotStatus.className = running ? 'running' : 'stopped'; + const connected = (j.connected || []).length; + const total = (j.personas || []).length; + chatbotDetail.textContent = running + ? `${connected}/${total} personas in chat: ${(j.connected || []).join(', ')}` + : `${total} personas configured, none connected`; + chatbotStart.disabled = running || transition; + chatbotStop.disabled = !running || transition; +} + +async function chatbotPost(path) { + chatbotStart.disabled = true; + chatbotStop.disabled = true; + chatbotDetail.textContent = path === '/start' ? 'starting…' : 'stopping…'; + try { + const r = await fetch('/api/peer/chatbot' + path, { method: 'POST' }); + const j = await r.json(); + applyChatbotState(j); + } catch (err) { + chatbotDetail.textContent = 'failed: ' + err.message; + } +} +chatbotStart.addEventListener('click', () => chatbotPost('/start')); +chatbotStop.addEventListener('click', () => chatbotPost('/stop')); +setInterval(pollChatbot, 4000); +setTimeout(pollChatbot, 500); + window.addEventListener('resize', () => { renderStatic(); }); // ============================================================ diff --git a/cmd/sim-web/peer_proxy.go b/cmd/sim-web/peer_proxy.go new file mode 100644 index 0000000..e059f2e --- /dev/null +++ b/cmd/sim-web/peer_proxy.go @@ -0,0 +1,155 @@ +package main + +// peer_proxy.go — a tiny generic HTTP reverse proxy for "peer +// services" deployed alongside net-sim in the same Docker network. +// +// The motivating use case is the test-net chatbot: it runs in its own +// container on the rfnet network, exposes an HTTP control plane on +// port 8090, and the visualiser wants START/STOP buttons. Rather than +// hardcoding "chatbot" knowledge into sim-web, this file exposes a +// generic /api/peer// handler that resolves via +// Docker DNS and forwards the request. If the peer isn't reachable +// (no such container, wrong network, peer down) the response is a +// clean 503 so the visualiser can show a tidy "not present" state. + +import ( + "context" + "io" + "net" + "net/http" + "strings" + "time" +) + +// peerProxyAllowlist is the set of peer hostnames + ports we are +// willing to proxy to. Hardcoded rather than open so a misconfigured +// network can't be turned into an SSRF probe; add entries here as new +// peer services land. +// +// The map value is the upstream TCP port; the path passed by the +// client maps directly onto the upstream's URL path. +var peerProxyAllowlist = map[string]int{ + "chatbot": 8090, +} + +// handlePeerProxy is mounted at /api/peer/. URL shape: +// +// /api/peer// → http://:/ +// +// Methods, headers, and body pass through. Response body and the +// upstream's Content-Type / Cache-Control headers are returned. If +// the upstream is unreachable the handler returns 503. +func (a *app) handlePeerProxy(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, "/api/peer/") + if rest == "" || rest == r.URL.Path { + http.Error(w, "bad path", http.StatusBadRequest) + return + } + parts := strings.SplitN(rest, "/", 2) + name := parts[0] + upPath := "/" + if len(parts) == 2 { + upPath += parts[1] + } + port, ok := peerProxyAllowlist[name] + if !ok { + http.Error(w, "unknown peer", http.StatusNotFound) + return + } + + // Short timeouts so an unreachable peer fails the request fast + // (the visualiser polls /state regularly and treats 5xx as "peer + // not present"). + cli := &http.Client{ + Timeout: 30 * time.Second, + } + upURL := "http://" + name + ":" + itoa(port) + upPath + if r.URL.RawQuery != "" { + upURL += "?" + r.URL.RawQuery + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, r.Method, upURL, r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // Pass through only safe-ish headers. + for _, h := range []string{"Content-Type", "Content-Length", "Accept"} { + if v := r.Header.Get(h); v != "" { + req.Header.Set(h, v) + } + } + resp, err := cli.Do(req) + if err != nil { + // DNS / dial / timeout — peer not present from our point of + // view. 503 is the right code; the visualiser hides the + // associated UI on this. + if isUnreachable(err) { + http.Error(w, "peer not reachable: "+err.Error(), http.StatusServiceUnavailable) + return + } + http.Error(w, "peer error: "+err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + for _, h := range []string{"Content-Type", "Cache-Control"} { + if v := resp.Header.Get(h); v != "" { + w.Header().Set(h, v) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +func itoa(n int) string { + // Tiny inline to avoid pulling in strconv at module-import scope. + if n == 0 { + return "0" + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} + +// isUnreachable reports whether err looks like "the peer container +// isn't there" rather than "the peer returned 500 / bad response." +// Used to map down to a 503 vs. 502 in the proxy response so the +// visualiser can distinguish "no peer deployed" from "peer is broken." +func isUnreachable(err error) bool { + if err == nil { + return false + } + var dnsErr *net.DNSError + if errAs(err, &dnsErr) { + return true + } + type timeouter interface{ Timeout() bool } + if t, ok := err.(timeouter); ok && t.Timeout() { + return true + } + s := err.Error() + return strings.Contains(s, "no such host") || + strings.Contains(s, "connection refused") || + strings.Contains(s, "deadline exceeded") || + strings.Contains(s, "timeout") +} + +// errAs is a tiny replacement for errors.As that handles the only +// type we need (net.DNSError) without pulling in the errors package +// for one use site. Falls through to nil on type mismatch. +func errAs(err error, target **net.DNSError) bool { + if err == nil { + return false + } + if d, ok := err.(*net.DNSError); ok { + *target = d + return true + } + return false +}