Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/sim-web/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
111 changes: 111 additions & 0 deletions cmd/sim-web/map.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -410,6 +453,16 @@
<div class="body"><div id="log"></div></div>
</div></div>

<div class="hud" id="panel-chatbot"><div class="frame">
<div class="header"><span>CHATBOT</span><span class="tag">peer</span></div>
<div class="body">
<div id="chatbot-status" class="stopped">— stopped —</div>
<div id="chatbot-detail"></div>
<button id="chatbot-start" class="chatbot-btn">START</button>
<button id="chatbot-stop" class="chatbot-btn stop" disabled>STOP</button>
</div>
</div></div>

<div class="hud" id="panel-listen"><div class="frame">
<div class="header"><span>LISTEN</span><span class="tag">WebAudio</span></div>
<div class="body">
Expand Down Expand Up @@ -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(); });

// ============================================================
Expand Down
155 changes: 155 additions & 0 deletions cmd/sim-web/peer_proxy.go
Original file line number Diff line number Diff line change
@@ -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/<name>/<path> handler that resolves <name> 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/<name>/<path> → http://<name>:<port>/<path>
//
// 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
}
Loading