tstest/natlab/vmtest: add web UI for watching VM tests live

Add an optional --vmtest-web flag that starts an HTTP server showing a
live dashboard for vmtest runs. The dashboard includes:

- Step progress tracker showing all test phases (compile, image prep,
  QEMU launch, agent connect, tailscale up, test-specific steps)
  with status icons and elapsed times
- Per-VM "virtual monitor" cards showing serial console output
  streamed in realtime via WebSocket
- Per-NIC DHCP status (supporting multi-homed VMs like subnet routers)
- Per-node Tailscale status (hidden for non-tailnet VMs)
- Test status badge (Running/Passed/Failed) with live elapsed timer
- Event log showing all lifecycle events chronologically

Architecture follows the existing util/eventbus HTMX+WebSocket pattern:
the server pushes HTML fragments with hx-swap-oob attributes over a
WebSocket, and HTMX routes them to the correct DOM elements by ID.

Key components:
- vmstatus.go: Step tracker (Begin/End lifecycle), EventBus (pub/sub
  with history for late joiners), VMEvent types, NodeStatus tracking
- web.go: HTTP server, WebSocket handler, template loading, ANSI-to-HTML
  conversion via robert-nix/ansihtml, deterministic port selection
- assets/: HTML templates, CSS, HTMX library (copied from eventbus)
- vnet/vnet.go: DHCP event callback on Server for observing DHCP lifecycle
- qemu.go: Console log file tailing with manual offset-based reading

Usage:
  go test ./tstest/natlab/vmtest/ --run-vm-tests --vmtest-web=:0 -v

When using :0, a deterministic port based on the test name is tried
first so re-runs get the same URL, falling back to OS-assigned on
conflict.

Updates #13038

Change-Id: I45281347b3d7af78ed9f4ff896033984f84dcb4d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2026-04-11 04:33:48 +00:00 committed by Brad Fitzpatrick
parent 0ac09721df
commit b9eac14ef9
14 changed files with 1322 additions and 39 deletions

View File

@ -163,4 +163,4 @@
});
};
}
# nix-direnv cache busting line: sha256-5uzkG6NQh0znjgE6yV5b01y8bUlTvLqXyAoWbMRQNEY=
# nix-direnv cache busting line: sha256-ruRbOB2W9snyOYY0+6OD5IndI/JJKqrhTuPlBsKikRc=

1
go.mod
View File

@ -86,6 +86,7 @@ require (
github.com/prometheus/client_golang v1.23.0
github.com/prometheus/common v0.65.0
github.com/prometheus/prometheus v0.49.2-0.20240125131847-c3b8ef1694ff
github.com/robert-nix/ansihtml v1.0.1
github.com/safchain/ethtool v0.3.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/studio-b12/gowebdav v0.9.0

View File

@ -1 +1 @@
sha256-5uzkG6NQh0znjgE6yV5b01y8bUlTvLqXyAoWbMRQNEY=
sha256-ruRbOB2W9snyOYY0+6OD5IndI/JJKqrhTuPlBsKikRc=

2
go.sum
View File

@ -1019,6 +1019,8 @@ github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRl
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=

View File

@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-5uzkG6NQh0znjgE6yV5b01y8bUlTvLqXyAoWbMRQNEY=
# nix-direnv cache busting line: sha256-ruRbOB2W9snyOYY0+6OD5IndI/JJKqrhTuPlBsKikRc=

View File

@ -0,0 +1,39 @@
{{if eq .Type "test_status"}}
<span class="test-status test-{{.Message}}" id="test-status" hx-swap-oob="outerHTML">{{.Message}} ({{.Detail}})</span>
{{end}}
{{if eq .Type "step_changed"}}
<div class="step step-{{.Step.Status}}" id="step-{{.Step.Index}}" hx-swap-oob="outerHTML">
<span class="step-icon">{{.Step.Status.Icon}}</span>
<span class="step-name">{{.Step.Name}}</span>
<span class="step-time">{{formatDuration .Step.Elapsed}}</span>
</div>
{{end}}
{{if eq .Type "console_output"}}
<div id="console-{{.NodeName}}" hx-swap-oob="beforeend">{{ansi .Message}}
</div>
{{end}}
{{if eq .Type "dhcp_discover"}}
<span id="dhcp-{{.NodeName}}-{{.NIC}}" hx-swap-oob="innerHTML">Discover sent</span>
{{end}}
{{if eq .Type "dhcp_offer"}}
<span id="dhcp-{{.NodeName}}-{{.NIC}}" hx-swap-oob="innerHTML">Offered {{.Detail}}</span>
{{end}}
{{if eq .Type "dhcp_request"}}
<span id="dhcp-{{.NodeName}}-{{.NIC}}" hx-swap-oob="innerHTML">Requesting {{.Detail}}</span>
{{end}}
{{if eq .Type "dhcp_ack"}}
<span id="dhcp-{{.NodeName}}-{{.NIC}}" hx-swap-oob="innerHTML">Got {{.Detail}}</span>
{{end}}
{{if eq .Type "tailscale"}}
<span id="ts-{{.NodeName}}" hx-swap-oob="innerHTML">{{.Detail}}</span>
{{end}}
<div id="events" hx-swap-oob="beforeend"><div class="event event-{{.Type}}"><span class="event-time">{{.Time.Format "15:04:05.000"}}</span> {{if .NodeName}}<span class="event-node">[{{.NodeName}}]</span> {{end}}<span class="event-msg">{{.Message}}</span>{{if .Detail}} <span class="event-detail">{{.Detail}}</span>{{end}}</div>
</div>

View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>VMTest: {{.TestName}}</title>
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx-ext-ws@2.0.2"
integrity="sha384-932iIqjARv+Gy0+r6RTGrfCkCKS5MsF539Iqf6Vt8L4YmbnnWI2DSFoMD90bvXd0"
crossorigin="anonymous"></script>
<link rel="stylesheet" href="style.css">
</head>
<body hx-ext="ws" ws-connect="ws">
<h1>VMTest: {{.TestName}} <span class="test-status test-{{.TestStatus.State}}" id="test-status">{{.TestStatus.State}} ({{formatDuration .TestStatus.Elapsed}})</span></h1>
<div class="steps">
<h2>Progress</h2>
{{range .Steps}}
<div class="step step-{{.Status}}" id="step-{{.Index}}">
<span class="step-icon">{{.Status.Icon}}</span>
<span class="step-name">{{.Name}}</span>
<span class="step-time">{{if ne .Status.String "pending"}}{{formatDuration .Elapsed}}{{end}}</span>
</div>
{{end}}
</div>
<div class="vm-grid">
{{range $node := .Nodes}}
<div class="vm-card" id="vm-{{$node.Name}}">
<div class="vm-header">
<span class="vm-name">{{$node.Name}}</span>
<span class="vm-os">{{$node.OS}}</span>
</div>
<div class="vm-status">
{{range $i, $nic := $node.NICs}}
<div class="vm-status-line">
<span class="vm-status-label">DHCP{{if gt (len $node.NICs) 1}} ({{$nic.NetName}}){{end}}:</span>
<span class="vm-status-value" id="dhcp-{{$node.Name}}-{{$i}}">{{$nic.DHCP}}</span>
</div>
{{end}}
{{if $node.JoinsTailnet}}
<div class="vm-status-line">
<span class="vm-status-label">Tailscale:</span>
<span class="vm-status-value" id="ts-{{$node.Name}}">{{$node.Tailscale}}</span>
</div>
{{end}}
</div>
<div class="console" id="console-{{$node.Name}}">{{range $node.Console}}{{ansi .}}
{{end}}</div>
</div>
{{end}}
</div>
<div class="event-log">
<h2>Events</h2>
<div class="events" id="events"></div>
</div>
<script>
// Tick the elapsed time on the test status badge while the test is running.
(function() {
var startTime = {{.TestStatus.StartUnixMilli}};
var el = document.getElementById("test-status");
var timer = setInterval(function() {
if (!el || !el.classList.contains("test-Running")) {
clearInterval(timer);
return;
}
var elapsed = Date.now() - startTime;
var secs = elapsed / 1000;
var text;
if (secs < 1) {
text = Math.round(elapsed) + "ms";
} else {
text = secs.toFixed(1) + "s";
}
el.textContent = "Running (" + text + ")";
}, 100);
})();
// Auto-scroll console divs to bottom unless user has scrolled up.
// Re-enable auto-scroll when user scrolls back to the bottom.
(function() {
var consoles = document.querySelectorAll(".console");
consoles.forEach(function(el) {
el._autoScroll = true;
el.addEventListener("scroll", function() {
// At bottom if scrollTop + clientHeight >= scrollHeight - small threshold
var atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 5;
el._autoScroll = atBottom;
});
});
// Use MutationObserver to detect when content is added to console divs.
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
var el = m.target;
if (el.classList && el.classList.contains("console") && el._autoScroll) {
el.scrollTop = el.scrollHeight;
}
});
});
consoles.forEach(function(el) {
observer.observe(el, { childList: true, characterData: true, subtree: true });
});
})();
</script>
</body>
</html>

View File

@ -0,0 +1,168 @@
/* CSS reset */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
background: #1a1a2e;
color: #e0e0e0;
padding: 16px;
}
h1 {
font-size: 1.4em;
margin-bottom: 16px;
color: #fff;
}
.test-status {
font-size: 0.7em;
padding: 2px 10px;
border-radius: 4px;
font-weight: bold;
vertical-align: middle;
}
.test-Running { background: #2563eb; color: #fff; }
.test-Passed { background: #16a34a; color: #fff; }
.test-Failed { background: #dc2626; color: #fff; }
h2 {
font-size: 1.1em;
margin-bottom: 8px;
color: #ccc;
}
/* Step progress panel */
.steps {
background: #16213e;
border: 1px solid #333;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
}
.step {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
font-family: monospace;
font-size: 13px;
border-radius: 3px;
}
.step-pending { color: #666; }
.step-running { color: #4af; font-weight: bold; background: rgba(68, 170, 255, 0.1); }
.step-done { color: #4a4; }
.step-failed { color: #f44; font-weight: bold; background: rgba(255, 68, 68, 0.1); }
.step-icon { width: 1.2em; text-align: center; }
.step-name { flex: 1; }
.step-time { color: #666; font-size: 12px; min-width: 6em; text-align: right; }
/* VM card grid */
.vm-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.vm-card {
background: #16213e;
border: 1px solid #333;
border-radius: 6px;
padding: 12px;
}
.vm-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.vm-name {
font-weight: bold;
font-size: 1.1em;
color: #fff;
}
.vm-os {
font-size: 0.8em;
background: #333;
padding: 1px 6px;
border-radius: 3px;
color: #aaa;
}
.vm-status {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 8px;
font-family: monospace;
font-size: 13px;
}
.vm-status-line {
display: flex;
gap: 8px;
}
.vm-status-label {
color: #888;
min-width: 7em;
}
.vm-status-value {
color: #4af;
}
/* Console output */
.console {
background: #0a0a0a;
color: #ccc;
font-family: "Cascadia Code", "Fira Code", "Consolas", monospace;
font-size: 11px;
line-height: 1.3;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
padding: 8px;
border-radius: 4px;
border: 1px solid #222;
}
/* Event log */
.event-log {
background: #16213e;
border: 1px solid #333;
border-radius: 6px;
padding: 12px;
}
.events {
max-height: 300px;
overflow-y: auto;
}
.event {
font-family: monospace;
font-size: 12px;
padding: 1px 0;
border-bottom: 1px solid #1a1a2e;
}
.event-time { color: #666; }
.event-node { color: #4af; font-weight: bold; }
.event-msg { color: #ccc; }
.event-detail { color: #888; }
.event-dhcp_discover .event-msg,
.event-dhcp_request .event-msg { color: #fa4; }
.event-dhcp_offer .event-msg,
.event-dhcp_ack .event-msg { color: #4f4; }
.event-step_changed .event-msg { color: #aaf; }

View File

@ -5,6 +5,7 @@ package vmtest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
@ -13,6 +14,7 @@ import (
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"tailscale.com/tstest/natlab/vnet"
@ -163,6 +165,11 @@ func (e *Env) launchQEMU(name, logPath string, args []string) error {
}
e.t.Logf("launched QEMU for %s (pid %d), log: %s", name, cmd.Process.Pid, logPath)
e.qemuProcs = append(e.qemuProcs, cmd)
// Start tailing the VM console log for the web UI.
if e.ctx != nil {
go e.tailLogFile(e.ctx, name, logPath)
}
e.t.Cleanup(func() {
cmd.Process.Kill()
cmd.Wait()
@ -237,3 +244,69 @@ func qmpQueryHostFwd(sockPath string) (int, error) {
}
return strconv.Atoi(m[1])
}
// tailLogFile tails a VM's serial console log file and publishes each line
// as an EventConsoleOutput to the event bus for the web UI.
func (e *Env) tailLogFile(ctx context.Context, name, logPath string) {
// Wait for the file to appear (QEMU may not have created it yet).
var f *os.File
for {
var err error
f, err = os.Open(logPath)
if err == nil {
break
}
select {
case <-ctx.Done():
return
case <-time.After(100 * time.Millisecond):
}
}
defer f.Close()
// Read the file in a loop, tracking our position manually.
// We can't use bufio.Scanner because it caches EOF and won't
// pick up new data appended by QEMU after the first EOF.
var buf []byte
var partial string // incomplete line (no trailing newline yet)
readBuf := make([]byte, 4096)
for {
n, err := f.Read(readBuf)
if n > 0 {
buf = append(buf, readBuf[:n]...)
// Split into complete lines.
for {
idx := bytes.IndexByte(buf, '\n')
if idx < 0 {
break
}
line := partial + string(buf[:idx])
partial = ""
buf = buf[idx+1:]
// Strip trailing \r from serial consoles.
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
e.appendConsoleLine(name, line)
e.eventBus.Publish(VMEvent{
NodeName: name,
Type: EventConsoleOutput,
Message: line,
})
}
if len(buf) > 0 {
partial = string(buf)
buf = buf[:0]
}
}
if err != nil || n == 0 {
// EOF or error — wait for more data.
select {
case <-ctx.Done():
return
case <-time.After(100 * time.Millisecond):
}
}
}
}

View File

@ -0,0 +1,320 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package vmtest
import (
"fmt"
"sync"
"time"
)
// StepStatus is the state of a declared test step.
type StepStatus int
const (
StepPending StepStatus = iota // not yet started
StepRunning // Begin called
StepDone // End(nil) called
StepFailed // End(non-nil) called
)
func (s StepStatus) String() string {
switch s {
case StepPending:
return "pending"
case StepRunning:
return "running"
case StepDone:
return "done"
case StepFailed:
return "failed"
}
return fmt.Sprintf("StepStatus(%d)", int(s))
}
// Icon returns a Unicode icon for the step status.
func (s StepStatus) Icon() string {
switch s {
case StepPending:
return "○"
case StepRunning:
return "◉"
case StepDone:
return "✓"
case StepFailed:
return "✗"
}
return "?"
}
// Step is a declared stage of a test, created by [Env.AddStep].
// The web UI shows all steps from the start, tracking their progress.
type Step struct {
mu sync.Mutex
name string
index int // 0-based position in Env.steps
env *Env
status StepStatus
err error
started time.Time
ended time.Time
}
// Name returns the step's display name.
func (s *Step) Name() string { return s.name }
// Index returns the step's 0-based position.
func (s *Step) Index() int { return s.index }
// Status returns the current status.
func (s *Step) Status() StepStatus {
s.mu.Lock()
defer s.mu.Unlock()
return s.status
}
// Err returns the error if the step failed, or nil.
func (s *Step) Err() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.err
}
// Elapsed returns how long the step has been running (if running)
// or how long it took (if done/failed). Returns 0 if pending.
func (s *Step) Elapsed() time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
if s.started.IsZero() {
return 0
}
if !s.ended.IsZero() {
return s.ended.Sub(s.started)
}
return time.Since(s.started)
}
// Begin marks the step as running. Publishes an event to the web UI.
func (s *Step) Begin() {
s.mu.Lock()
if s.status != StepPending {
s.mu.Unlock()
panic(fmt.Sprintf("Step %q: Begin called in state %s", s.name, s.status))
}
s.started = time.Now()
s.status = StepRunning
s.mu.Unlock()
s.env.publishStepChange(s)
}
// End marks the step as done (err == nil) or failed (err != nil).
// It publishes a status change event to the web UI.
// It does not call t.Fatalf; callers should handle the error as appropriate
// (return it from errgroup, call t.Fatalf on the test goroutine, etc).
func (s *Step) End(err error) {
s.mu.Lock()
if s.status != StepRunning {
s.mu.Unlock()
panic(fmt.Sprintf("Step %q: End called in state %s", s.name, s.status))
}
s.ended = time.Now()
if err != nil {
s.status = StepFailed
s.err = err
} else {
s.status = StepDone
}
s.mu.Unlock()
s.env.publishStepChange(s)
}
// EventType identifies the kind of event published to the EventBus.
type EventType string
const (
EventStepChanged EventType = "step_changed" // a Step changed status
EventConsoleOutput EventType = "console_output" // serial console line
EventDHCPDiscover EventType = "dhcp_discover" // VM sent DHCP Discover
EventDHCPOffer EventType = "dhcp_offer" // server sent DHCP Offer
EventDHCPRequest EventType = "dhcp_request" // VM sent DHCP Request
EventDHCPAck EventType = "dhcp_ack" // server sent DHCP Ack
EventTailscale EventType = "tailscale" // Tailscale status change
EventTestStatus EventType = "test_status" // test Running/Passed/Failed
)
// TestStatus tracks whether the overall test is running, passed, or failed.
type TestStatus struct {
mu sync.Mutex
state string // "Running", "Passed", "Failed"
started time.Time
ended time.Time
}
func newTestStatus() *TestStatus {
return &TestStatus{state: "Running", started: time.Now()}
}
// State returns the current test state.
func (ts *TestStatus) State() string {
ts.mu.Lock()
defer ts.mu.Unlock()
return ts.state
}
// Elapsed returns total test duration.
func (ts *TestStatus) Elapsed() time.Duration {
ts.mu.Lock()
defer ts.mu.Unlock()
if !ts.ended.IsZero() {
return ts.ended.Sub(ts.started)
}
return time.Since(ts.started)
}
// StartUnixMilli returns the test start time as Unix milliseconds,
// for the client-side elapsed timer.
func (ts *TestStatus) StartUnixMilli() int64 {
ts.mu.Lock()
defer ts.mu.Unlock()
return ts.started.UnixMilli()
}
// finish marks the test as passed or failed.
func (ts *TestStatus) finish(failed bool) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.ended = time.Now()
if failed {
ts.state = "Failed"
} else {
ts.state = "Passed"
}
}
// VMEvent is a single event published to the [EventBus].
type VMEvent struct {
Time time.Time
NodeName string // "" for global events
Type EventType
Message string // human-readable description
Detail string // e.g. IP address, node key
Step *Step // non-nil for EventStepChanged
NIC int // NIC index for DHCP events (0-based); -1 if not applicable
}
// NICStatus is the DHCP state for one NIC on a node.
type NICStatus struct {
NetName string // human label like "192.168.1.0/24" or "10.0.0.0/24"
DHCP string // "waiting", "Discover sent", "Got 10.0.0.101", etc.
}
// NodeStatus tracks the current DHCP and Tailscale state of a VM node
// for rendering on the web UI's initial page load.
type NodeStatus struct {
Name string
OS string
NICs []NICStatus // one per NIC; index matches NIC index
JoinsTailnet bool // whether this node runs Tailscale
Tailscale string // "--", "Up (100.64.0.1)", etc.
Console []string // recent console output lines (ring buffer)
}
const maxConsoleLines = 200
const (
eventBusHistorySize = 500
subscriberChannelSize = 1000
)
// EventBus broadcasts VMEvents to subscribers and keeps a history for
// late joiners. It is safe for concurrent use.
type EventBus struct {
mu sync.Mutex
history []VMEvent
subscribers map[*subscriber]struct{}
}
func newEventBus() *EventBus {
return &EventBus{
subscribers: make(map[*subscriber]struct{}),
}
}
// Publish sends an event to all subscribers and appends it to the history.
// Non-blocking: slow subscribers are skipped.
func (b *EventBus) Publish(ev VMEvent) {
if ev.Time.IsZero() {
ev.Time = time.Now()
}
b.mu.Lock()
defer b.mu.Unlock()
b.history = append(b.history, ev)
if len(b.history) > eventBusHistorySize {
// Trim old events.
copy(b.history, b.history[len(b.history)-eventBusHistorySize:])
b.history = b.history[:eventBusHistorySize]
}
for sub := range b.subscribers {
select {
case sub.ch <- ev:
default:
// Slow consumer, skip.
}
}
}
// Subscribe returns a new subscriber that receives the event history
// followed by live events.
func (b *EventBus) Subscribe() *subscriber {
b.mu.Lock()
defer b.mu.Unlock()
sub := &subscriber{
bus: b,
ch: make(chan VMEvent, subscriberChannelSize),
done: make(chan struct{}),
}
// Send history.
for _, ev := range b.history {
select {
case sub.ch <- ev:
default:
}
}
b.subscribers[sub] = struct{}{}
return sub
}
func (b *EventBus) unsubscribe(sub *subscriber) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.subscribers, sub)
}
// subscriber receives events from an [EventBus].
type subscriber struct {
bus *EventBus
ch chan VMEvent
done chan struct{}
once sync.Once
}
// Events returns the channel of events. Closed when Close is called.
func (s *subscriber) Events() <-chan VMEvent {
return s.ch
}
// Close unsubscribes and closes the event channel.
func (s *subscriber) Close() {
s.once.Do(func() {
if s.bus != nil {
s.bus.unsubscribe(s)
}
close(s.done)
})
}
// Done returns a channel that's closed when Close is called.
func (s *subscriber) Done() <-chan struct{} {
return s.done
}

View File

@ -31,6 +31,7 @@ import (
"testing"
"time"
"github.com/google/gopacket/layers"
"golang.org/x/sync/errgroup"
"tailscale.com/client/local"
"tailscale.com/ipn"
@ -67,6 +68,15 @@ type Env struct {
gokrazyKernel string // path to gokrazy kernel
qemuProcs []*exec.Cmd // launched QEMU processes
// Web UI support.
ctx context.Context // cancelled when test ends
eventBus *EventBus
testStatus *TestStatus
steps []*Step
nodeStatusMu sync.Mutex
nodeStatus map[string]*NodeStatus // keyed by node name
}
// logVerbosef logs a message only when --verbose-vm-debug is set.
@ -77,6 +87,145 @@ func (e *Env) logVerbosef(format string, args ...any) {
}
}
// AddStep declares an expected stage of the test. The web UI shows all steps
// from the start, tracking their progress. Call before or during the test.
// Returns a *Step whose Begin/End methods drive the progress display.
func (e *Env) AddStep(name string) *Step {
s := &Step{
name: name,
index: len(e.steps),
env: e,
}
e.steps = append(e.steps, s)
return s
}
// Steps returns all declared steps in order.
func (e *Env) Steps() []*Step {
return e.steps
}
// publishStepChange publishes a step status change event.
func (e *Env) publishStepChange(s *Step) {
e.eventBus.Publish(VMEvent{
Type: EventStepChanged,
Message: fmt.Sprintf("%s %s", s.Status().Icon(), s.name),
Step: s,
})
}
// initNodeStatus initializes the NodeStatus for all nodes. Called after
// AddNode but before Start so the web UI can render them.
func (e *Env) initNodeStatus() {
e.nodeStatusMu.Lock()
defer e.nodeStatusMu.Unlock()
for _, n := range e.nodes {
nics := make([]NICStatus, len(n.nets))
for i := range n.nets {
nics[i] = NICStatus{
NetName: e.nicLabel(n, i),
DHCP: "waiting",
}
}
e.nodeStatus[n.name] = &NodeStatus{
Name: n.name,
OS: n.os.Name,
NICs: nics,
JoinsTailnet: n.joinTailnet,
Tailscale: "--",
}
}
}
// nicLabel returns a short human-readable label for a node's i-th NIC.
// After Start(), we can use the assigned LAN IP. Before that, we use "NIC N".
func (e *Env) nicLabel(n *Node, i int) string {
if n.vnetNode != nil {
ip := n.vnetNode.LanIP(n.nets[i])
if ip.IsValid() {
return ip.String()
}
}
return fmt.Sprintf("NIC %d", i)
}
// getNodeStatus returns the current status for a node.
func (e *Env) getNodeStatus(name string) NodeStatus {
e.nodeStatusMu.Lock()
defer e.nodeStatusMu.Unlock()
ns := e.nodeStatus[name]
if ns == nil {
return NodeStatus{Name: name, Tailscale: "--"}
}
return *ns
}
// setNodeDHCP updates the DHCP status for a specific NIC on a node.
func (e *Env) setNodeDHCP(name string, nicIdx int, status string) {
e.nodeStatusMu.Lock()
ns := e.nodeStatus[name]
if ns != nil && nicIdx < len(ns.NICs) {
ns.NICs[nicIdx].DHCP = status
}
e.nodeStatusMu.Unlock()
}
// setNodeTailscale updates the Tailscale status for a node and publishes
// an event so the web UI updates via WebSocket.
func (e *Env) setNodeTailscale(name, status string) {
e.nodeStatusMu.Lock()
ns := e.nodeStatus[name]
if ns != nil {
ns.Tailscale = status
}
e.nodeStatusMu.Unlock()
e.eventBus.Publish(VMEvent{
NodeName: name,
Type: EventTailscale,
Message: "Tailscale: " + status,
Detail: status,
})
}
// appendConsoleLine adds a line to a node's console buffer.
func (e *Env) appendConsoleLine(name, line string) {
e.nodeStatusMu.Lock()
ns := e.nodeStatus[name]
if ns != nil {
ns.Console = append(ns.Console, line)
if len(ns.Console) > maxConsoleLines {
ns.Console = ns.Console[len(ns.Console)-maxConsoleLines:]
}
}
e.nodeStatusMu.Unlock()
}
// nicIndexForMAC returns the NIC index (0-based) for a given MAC on a node.
// Returns -1 if not found.
func (e *Env) nicIndexForMAC(name string, mac vnet.MAC) int {
for _, n := range e.nodes {
if n.name != name {
continue
}
for i := range n.nets {
if n.vnetNode.NICMac(i) == mac {
return i
}
}
}
return -1
}
// nodeNameByNum returns the node name for a given vnet node number.
func (e *Env) nodeNameByNum(num int) string {
for _, n := range e.nodes {
if n.num == num {
return n.name
}
}
return fmt.Sprintf("node%d", num)
}
// New creates a new test environment. It skips the test if --run-vm-tests is not set.
func New(t testing.TB) *Env {
if !*runVMTests {
@ -84,11 +233,23 @@ func New(t testing.TB) *Env {
}
tempDir := t.TempDir()
return &Env{
t: t,
tempDir: tempDir,
binDir: filepath.Join(tempDir, "bin"),
e := &Env{
t: t,
tempDir: tempDir,
binDir: filepath.Join(tempDir, "bin"),
eventBus: newEventBus(),
testStatus: newTestStatus(),
nodeStatus: make(map[string]*NodeStatus),
}
t.Cleanup(func() {
e.testStatus.finish(t.Failed())
e.eventBus.Publish(VMEvent{
Type: EventTestStatus,
Message: e.testStatus.State(),
Detail: formatDuration(e.testStatus.Elapsed()),
})
})
return e
}
// AddNetwork creates a new virtual network. Arguments follow the same pattern as
@ -197,6 +358,11 @@ func (e *Env) Start() {
t := e.t
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
t.Cleanup(cancel)
e.ctx = ctx
// Initialize node status and start web UI as early as possible.
e.initNodeStatus()
e.maybeStartWebServer()
if err := os.MkdirAll(e.binDir, 0755); err != nil {
t.Fatal(err)
@ -223,27 +389,94 @@ func (e *Env) Start() {
}
}
// Compile binaries and download/build images in parallel.
// Any failure cancels the others via the errgroup context.
eg, egCtx := errgroup.WithContext(ctx)
// Declare framework steps for the web UI.
// User-declared steps (from AddStep before Start) get moved to the end
// so framework steps (compile, image, QEMU, etc.) come first.
userSteps := e.steps
e.steps = nil
compileSteps := map[platform]*Step{}
for _, p := range needPlatform.Slice() {
eg.Go(func() error {
return e.compileBinariesForOS(egCtx, p.goos, p.goarch)
})
compileSteps[p] = e.AddStep(fmt.Sprintf("Compile %s_%s binaries", p.goos, p.goarch))
}
didOS := set.Set[string]{} // dedup by image name
imageSteps := map[string]*Step{} // keyed by OS name
didOS := set.Set[string]{} // dedup by image name
for _, n := range e.nodes {
if didOS.Contains(n.os.Name) {
continue
}
didOS.Add(n.os.Name)
if n.os.IsGokrazy {
imageSteps["gokrazy"] = e.AddStep("Build gokrazy image")
} else {
imageSteps[n.os.Name] = e.AddStep(fmt.Sprintf("Prepare %s image", n.os.Name))
}
}
vnetStep := e.AddStep("Create virtual network")
qemuSteps := map[string]*Step{}
agentSteps := map[string]*Step{}
tsUpSteps := map[string]*Step{}
for _, n := range e.nodes {
qemuSteps[n.name] = e.AddStep(fmt.Sprintf("Launch QEMU: %s", n.name))
agentSteps[n.name] = e.AddStep(fmt.Sprintf("Wait for agent: %s", n.name))
if n.joinTailnet {
tsUpSteps[n.name] = e.AddStep(fmt.Sprintf("Tailscale up: %s", n.name))
}
}
// Re-append user-declared steps after all framework steps.
for _, s := range userSteps {
s.index = len(e.steps)
e.steps = append(e.steps, s)
}
// Compile binaries and download/build images in parallel.
// Any failure cancels the others via the errgroup context.
eg, egCtx := errgroup.WithContext(ctx)
for _, p := range needPlatform.Slice() {
step := compileSteps[p]
eg.Go(func() error {
step.Begin()
err := e.compileBinariesForOS(egCtx, p.goos, p.goarch)
if err != nil {
step.End(err)
return err
}
step.End(nil)
return nil
})
}
didOS = set.Set[string]{} // reset for second pass
for _, n := range e.nodes {
if didOS.Contains(n.os.Name) {
continue
}
didOS.Add(n.os.Name)
if n.os.IsGokrazy {
step := imageSteps["gokrazy"]
eg.Go(func() error {
return e.ensureGokrazy(egCtx)
step.Begin()
err := e.ensureGokrazy(egCtx)
if err != nil {
step.End(err)
return err
}
step.End(nil)
return nil
})
} else {
step := imageSteps[n.os.Name]
osImg := n.os
eg.Go(func() error {
return ensureImage(egCtx, n.os)
step.Begin()
err := ensureImage(egCtx, osImg)
if err != nil {
step.End(err)
return err
}
step.End(nil)
return nil
})
}
}
@ -252,6 +485,7 @@ func (e *Env) Start() {
}
// Create the vnet server.
vnetStep.Begin()
var err error
e.server, err = vnet.New(&e.cfg)
if err != nil {
@ -259,6 +493,50 @@ func (e *Env) Start() {
}
t.Cleanup(func() { e.server.Close() })
// Register DHCP event callback for the web UI.
e.server.SetDHCPCallback(func(mac vnet.MAC, nodeNum int, msgType layers.DHCPMsgType, ip netip.Addr) {
name := e.nodeNameByNum(nodeNum)
nicIdx := e.nicIndexForMAC(name, mac)
ipStr := ip.String()
switch msgType {
case layers.DHCPMsgTypeDiscover:
e.setNodeDHCP(name, nicIdx, "Discover sent")
e.eventBus.Publish(VMEvent{
NodeName: name,
Type: EventDHCPDiscover,
Message: "DHCP Discover sent",
NIC: nicIdx,
})
case layers.DHCPMsgTypeOffer:
e.setNodeDHCP(name, nicIdx, "Offered "+ipStr)
e.eventBus.Publish(VMEvent{
NodeName: name,
Type: EventDHCPOffer,
Message: "DHCP Offer received",
Detail: ipStr,
NIC: nicIdx,
})
case layers.DHCPMsgTypeRequest:
e.setNodeDHCP(name, nicIdx, "Requesting "+ipStr)
e.eventBus.Publish(VMEvent{
NodeName: name,
Type: EventDHCPRequest,
Message: "DHCP Request sent",
Detail: ipStr,
NIC: nicIdx,
})
case layers.DHCPMsgTypeAck:
e.setNodeDHCP(name, nicIdx, "Got "+ipStr)
e.eventBus.Publish(VMEvent{
NodeName: name,
Type: EventDHCPAck,
Message: "DHCP Ack: got " + ipStr,
Detail: ipStr,
NIC: nicIdx,
})
}
})
// Register compiled binaries with the file server VIP.
// Binaries are registered at <goos>_<goarch>/<name> (e.g. "linux_amd64/tta").
for _, p := range needPlatform.Slice() {
@ -271,6 +549,7 @@ func (e *Env) Start() {
e.server.RegisterFile(dir+"/"+name, data)
}
}
vnetStep.End(nil)
// Cloud-init config is delivered via local seed ISOs (created in startCloudQEMU),
// not via the cloud-init HTTP VIP, because network-config must be available
@ -296,9 +575,12 @@ func (e *Env) Start() {
// Launch QEMU processes.
for _, n := range e.nodes {
step := qemuSteps[n.name]
step.Begin()
if err := e.startQEMU(n); err != nil {
t.Fatalf("startQEMU(%s): %v", n.name, err)
}
step.End(nil)
}
// Set up agent clients and wait for all agents to connect.
@ -311,12 +593,15 @@ func (e *Env) Start() {
var agentEg errgroup.Group
for _, n := range e.nodes {
agentEg.Go(func() error {
aStep := agentSteps[n.name]
aStep.Begin()
t.Logf("[%s] waiting for agent...", n.name)
st, err := n.agent.Status(ctx)
if err != nil {
return fmt.Errorf("[%s] agent status: %w", n.name, err)
}
t.Logf("[%s] agent connected, backend state: %s", n.name, st.BackendState)
aStep.End(nil)
if n.vnetNode.HostFirewall() {
if err := n.agent.EnableHostFirewall(ctx); err != nil {
@ -325,6 +610,8 @@ func (e *Env) Start() {
}
if n.joinTailnet {
tsStep := tsUpSteps[n.name]
tsStep.Begin()
if err := e.tailscaleUp(ctx, n); err != nil {
return fmt.Errorf("[%s] tailscale up: %w", n.name, err)
}
@ -335,7 +622,10 @@ func (e *Env) Start() {
if st.BackendState != "Running" {
return fmt.Errorf("[%s] state = %q, want Running", n.name, st.BackendState)
}
ips := fmt.Sprintf("%v", st.Self.TailscaleIPs)
e.setNodeTailscale(n.name, "Running "+ips)
t.Logf("[%s] up with %v", n.name, st.Self.TailscaleIPs)
tsStep.End(nil)
}
return nil

View File

@ -37,13 +37,23 @@ func testSubnetRouterForOS(t testing.TB, srOS vmtest.OSImage) {
vmtest.DontJoinTailnet(),
vmtest.WebServer(8080))
env.Start()
env.ApproveRoutes(sr, "10.0.0.0/24")
// Declare test-specific steps for the web UI.
approveStep := env.AddStep("Approve subnet routes")
httpStep := env.AddStep("HTTP GET through subnet router")
env.Start()
approveStep.Begin()
env.ApproveRoutes(sr, "10.0.0.0/24")
approveStep.End(nil)
httpStep.Begin()
body := env.HTTPGet(client, fmt.Sprintf("http://%s:8080/", backend.LanIP(internalNet)))
if !strings.Contains(body, "Hello world I am backend") {
httpStep.End(fmt.Errorf("got %q", body))
t.Fatalf("got %q", body)
}
httpStep.End(nil)
}
func TestSiteToSite(t *testing.T) {
@ -95,9 +105,17 @@ func testSiteToSite(t *testing.T, srOS vmtest.OSImage) {
vmtest.DontJoinTailnet(),
vmtest.WebServer(8080))
// Declare test-specific steps for the web UI.
approveStep := env.AddStep("Approve subnet routes (sr-a, sr-b)")
staticRouteStep := env.AddStep("Add static routes on backends")
httpStep := env.AddStep("HTTP GET through site-to-site")
env.Start()
approveStep.Begin()
env.ApproveRoutes(srA, "10.1.0.0/24")
env.ApproveRoutes(srB, "10.2.0.0/24")
approveStep.End(nil)
// Add static routes on the backends so that traffic to the remote site's
// subnet goes through the local subnet router. This mirrors how a real
@ -107,16 +125,20 @@ func testSiteToSite(t *testing.T, srOS vmtest.OSImage) {
t.Logf("sr-a LAN IP: %s, sr-b LAN IP: %s", srALanIP, srBLanIP)
t.Logf("backend-a LAN IP: %s, backend-b LAN IP: %s", backendA.LanIP(lanA), backendB.LanIP(lanB))
staticRouteStep.Begin()
env.AddRoute(backendA, "10.2.0.0/24", srALanIP)
env.AddRoute(backendB, "10.1.0.0/24", srBLanIP)
staticRouteStep.End(nil)
// Make an HTTP request from backend-a to backend-b through the subnet routers.
// TTA's /http-get falls back to direct dial on non-Tailscale nodes.
httpStep.Begin()
backendBIP := backendB.LanIP(lanB)
body := env.HTTPGet(backendA, fmt.Sprintf("http://%s:8080/", backendBIP))
t.Logf("response: %s", body)
if !strings.Contains(body, "Hello world I am backend-b") {
httpStep.End(fmt.Errorf("expected response from backend-b, got %q", body))
t.Fatalf("expected response from backend-b, got %q", body)
}
@ -124,8 +146,10 @@ func testSiteToSite(t *testing.T, srOS vmtest.OSImage) {
// backend-b should see backend-a's LAN IP as the source, not sr-b's LAN IP.
backendAIP := backendA.LanIP(lanA).String()
if !strings.Contains(body, "from "+backendAIP) {
httpStep.End(fmt.Errorf("source IP not preserved: expected %q in response, got %q", backendAIP, body))
t.Fatalf("source IP not preserved: expected %q in response, got %q", backendAIP, body)
}
httpStep.End(nil)
}
// TestInterNetworkTCP verifies that vnet routes raw TCP between simulated
@ -151,16 +175,23 @@ func TestInterNetworkTCP(t *testing.T) {
vmtest.DontJoinTailnet(),
vmtest.WebServer(8080))
// Declare test-specific steps for the web UI.
httpStep := env.AddStep("HTTP GET across networks via NAT")
env.Start()
httpStep.Begin()
body := env.HTTPGet(client, fmt.Sprintf("http://%s:8080/", webWAN))
t.Logf("response: %s", body)
if !strings.Contains(body, "Hello world I am webserver") {
httpStep.End(fmt.Errorf("unexpected response: %q", body))
t.Fatalf("unexpected response: %q", body)
}
if !strings.Contains(body, "from "+clientWAN) {
httpStep.End(fmt.Errorf("expected source %q in response, got %q", clientWAN, body))
t.Fatalf("expected source %q in response, got %q", clientWAN, body)
}
httpStep.End(nil)
}
// TestSubnetRouterPublicIP verifies that toggling --accept-routes on the
@ -198,33 +229,45 @@ func TestSubnetRouterPublicIP(t *testing.T) {
vmtest.DontJoinTailnet(),
vmtest.WebServer(8080))
// Declare test-specific steps for the web UI.
approveStep := env.AddStep("Approve subnet route (public IP)")
checkOn1Step := env.AddStep("HTTP GET (accept-routes=on)")
checkOffStep := env.AddStep("HTTP GET (accept-routes=off)")
checkOn2Step := env.AddStep("HTTP GET (accept-routes=on, again)")
env.Start()
// ApproveRoutes also turns on RouteAll on the client.
approveStep.Begin()
env.ApproveRoutes(sr, webRoute)
approveStep.End(nil)
webURL := fmt.Sprintf("http://%s:8080/", webWAN)
check := func(label, wantSrc string) {
check := func(step *vmtest.Step, label, wantSrc string) {
t.Helper()
step.Begin()
body := env.HTTPGet(client, webURL)
t.Logf("[%s] response: %s", label, body)
if !strings.Contains(body, "Hello world I am webserver") {
step.End(fmt.Errorf("[%s] unexpected webserver response: %q", label, body))
t.Fatalf("[%s] unexpected webserver response: %q", label, body)
}
if !strings.Contains(body, "from "+wantSrc) {
step.End(fmt.Errorf("[%s] expected source %q in response, got %q", label, wantSrc, body))
t.Fatalf("[%s] expected source %q in response, got %q", label, wantSrc, body)
}
step.End(nil)
}
// accept-routes=on (set by ApproveRoutes): traffic flows via the subnet router.
check("accept-routes=on", routerWAN)
check(checkOn1Step, "accept-routes=on", routerWAN)
// accept-routes=off: client dials the webserver directly.
env.SetAcceptRoutes(client, false)
check("accept-routes=off", clientWAN)
check(checkOffStep, "accept-routes=off", clientWAN)
// Toggle back on to confirm the transition works in both directions.
env.SetAcceptRoutes(client, true)
check("accept-routes=on (again)", routerWAN)
check(checkOn2Step, "accept-routes=on (again)", routerWAN)
}
// TestSubnetRouterAndExitNode checks how the subnet router and exit node
@ -266,13 +309,8 @@ func TestSubnetRouterAndExitNode(t *testing.T) {
vmtest.DontJoinTailnet(),
vmtest.WebServer(8080))
env.Start()
env.ApproveRoutes(sr, webRoute)
env.ApproveRoutes(exit, "0.0.0.0/0", "::/0")
// Don't let the exit node itself forward via the subnet router: when the
// client is using the exit node only, we want the exit node to egress to
// the simulated internet directly so the webserver sees the exit's WAN.
env.SetAcceptRoutes(exit, false)
// Declare test-specific steps for the web UI.
approveStep := env.AddStep("Approve subnet & exit routes")
webURL := fmt.Sprintf("http://%s:8080/", webWAN)
tests := []struct {
@ -280,25 +318,44 @@ func TestSubnetRouterAndExitNode(t *testing.T) {
exit *vmtest.Node
subnet bool
wantSrc string
step *vmtest.Step
}{
{"exit-off,subnet-off", nil, false, clientWAN},
{"exit-off,subnet-on", nil, true, routerWAN},
{"exit-on,subnet-off", exit, false, exitWAN},
{"exit-off,subnet-off", nil, false, clientWAN, nil},
{"exit-off,subnet-on", nil, true, routerWAN, nil},
{"exit-on,subnet-off", exit, false, exitWAN, nil},
// More-specific 5.0.0.0/24 from sr beats 0.0.0.0/0 from exit.
{"exit-on,subnet-on", exit, true, routerWAN},
{"exit-on,subnet-on", exit, true, routerWAN, nil},
}
for i := range tests {
tests[i].step = env.AddStep("HTTP GET: " + tests[i].name)
}
env.Start()
approveStep.Begin()
env.ApproveRoutes(sr, webRoute)
env.ApproveRoutes(exit, "0.0.0.0/0", "::/0")
// Don't let the exit node itself forward via the subnet router: when the
// client is using the exit node only, we want the exit node to egress to
// the simulated internet directly so the webserver sees the exit's WAN.
env.SetAcceptRoutes(exit, false)
approveStep.End(nil)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tc.step.Begin()
env.SetExitNode(client, tc.exit)
env.SetAcceptRoutes(client, tc.subnet)
body := env.HTTPGet(client, webURL)
t.Logf("response: %s", body)
if !strings.Contains(body, "Hello world I am webserver") {
tc.step.End(fmt.Errorf("unexpected webserver response: %q", body))
t.Fatalf("unexpected webserver response: %q", body)
}
if !strings.Contains(body, "from "+tc.wantSrc) {
tc.step.End(fmt.Errorf("expected source %q in response, got %q", tc.wantSrc, body))
t.Fatalf("expected source %q in response, got %q", tc.wantSrc, body)
}
tc.step.End(nil)
})
}
}
@ -342,31 +399,45 @@ func TestExitNode(t *testing.T) {
vmtest.DontJoinTailnet(),
vmtest.WebServer(8080))
env.Start()
env.ApproveRoutes(exit1, "0.0.0.0/0", "::/0")
env.ApproveRoutes(exit2, "0.0.0.0/0", "::/0")
// Declare test-specific steps for the web UI.
approveStep := env.AddStep("Approve exit-node routes (exit1, exit2)")
webURL := fmt.Sprintf("http://%s:8080/", webWAN)
tests := []struct {
name string // subtest name
exit *vmtest.Node
wantSrc string
step *vmtest.Step
}{
{"off", nil, clientWAN},
{"exit1", exit1, exit1WAN},
{"exit2", exit2, exit2WAN},
{"off", nil, clientWAN, nil},
{"exit1", exit1, exit1WAN, nil},
{"exit2", exit2, exit2WAN, nil},
}
for i := range tests {
tests[i].step = env.AddStep("HTTP GET: exit=" + tests[i].name)
}
env.Start()
approveStep.Begin()
env.ApproveRoutes(exit1, "0.0.0.0/0", "::/0")
env.ApproveRoutes(exit2, "0.0.0.0/0", "::/0")
approveStep.End(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.step.Begin()
env.SetExitNode(client, tt.exit)
body := env.HTTPGet(client, webURL)
t.Logf("response: %s", body)
if !strings.Contains(body, "Hello world I am webserver") {
tt.step.End(fmt.Errorf("unexpected webserver response: %q", body))
t.Fatalf("unexpected webserver response: %q", body)
}
if !strings.Contains(body, "from "+tt.wantSrc) {
tt.step.End(fmt.Errorf("expected source %q in response, got %q", tt.wantSrc, body))
t.Fatalf("expected source %q in response, got %q", tt.wantSrc, body)
}
tt.step.End(nil)
})
}
}

189
tstest/natlab/vmtest/web.go Normal file
View File

@ -0,0 +1,189 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package vmtest
import (
"embed"
"flag"
"fmt"
"hash/crc32"
"html/template"
"io"
"io/fs"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/robert-nix/ansihtml"
)
var vmtestWeb = flag.String("vmtest-web", "", "listen address for vmtest web UI (e.g. :0, localhost:0, :8080)")
//go:embed assets/*.html
var templatesSrc embed.FS
//go:embed assets/*.css
var staticAssets embed.FS
var tmpl = sync.OnceValue(func() *template.Template {
d, err := fs.Sub(templatesSrc, "assets")
if err != nil {
panic(fmt.Errorf("getting vmtest web templates subdir: %w", err))
}
return template.Must(template.New("").Funcs(template.FuncMap{
"formatDuration": formatDuration,
"ansi": ansiToHTML,
}).ParseFS(d, "*"))
})
// ansiToHTML converts a string with ANSI escape sequences to HTML with
// inline styles. Returns template.HTML so html/template doesn't double-escape it.
func ansiToHTML(s string) template.HTML {
return template.HTML(ansihtml.ConvertToHTML([]byte(s)))
}
// formatDuration returns a human-readable duration like "1.2s" or "45.3s".
func formatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
return fmt.Sprintf("%.1fs", d.Seconds())
}
// deterministicPort returns a deterministic port in the range [20000, 40000)
// based on the test name, so re-running the same test gets the same URL.
func deterministicPort(testName string) int {
return int(crc32.ChecksumIEEE([]byte(testName)))%20000 + 20000
}
// listenWeb listens on the given address. If the port is 0, it first tries a
// deterministic port based on the test name so re-runs get the same URL.
// Falls back to :0 (OS-assigned) on any listen error.
func (e *Env) listenWeb(addr string) (net.Listener, error) {
host, port, _ := net.SplitHostPort(addr)
if port == "0" {
detPort := deterministicPort(e.t.Name())
detAddr := net.JoinHostPort(host, fmt.Sprintf("%d", detPort))
if ln, err := net.Listen("tcp", detAddr); err == nil {
return ln, nil
}
// Deterministic port busy; fall back to OS-assigned.
}
return net.Listen("tcp", addr)
}
// maybeStartWebServer starts the web UI if --vmtest-web is set.
// Called at the very top of Env.Start(), before compilation or image downloads.
func (e *Env) maybeStartWebServer() {
addr := *vmtestWeb
if addr == "" {
return
}
ln, err := e.listenWeb(addr)
if err != nil {
e.t.Fatalf("vmtest-web listen: %v", err)
}
e.t.Cleanup(func() { ln.Close() })
actualAddr := ln.Addr().(*net.TCPAddr)
host, _, _ := net.SplitHostPort(addr)
if host == "" || host == "0.0.0.0" || host == "::" {
hostname, err := os.Hostname()
if err != nil {
hostname = "localhost"
}
e.t.Logf("Status at http://%s:%d/", hostname, actualAddr.Port)
} else {
e.t.Logf("Status at http://%s/", actualAddr.String())
}
mux := http.NewServeMux()
mux.HandleFunc("GET /", e.serveIndex)
mux.HandleFunc("GET /ws", e.serveWebSocket)
mux.HandleFunc("GET /style.css", serveStaticAsset("style.css"))
srv := &http.Server{Handler: mux}
go srv.Serve(ln)
e.t.Cleanup(func() { srv.Close() })
}
func serveStaticAsset(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(name, ".css") {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/css")
f, err := staticAssets.Open(filepath.Join("assets", name))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
io.Copy(w, f)
}
}
func (e *Env) serveIndex(w http.ResponseWriter, r *http.Request) {
type indexData struct {
TestName string
TestStatus *TestStatus
Steps []*Step
Nodes []NodeStatus
}
data := indexData{
TestName: e.t.Name(),
TestStatus: e.testStatus,
Steps: e.Steps(),
}
for _, n := range e.nodes {
data.Nodes = append(data.Nodes, e.getNodeStatus(n.name))
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl().ExecuteTemplate(w, "index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (e *Env) serveWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer conn.CloseNow()
wsCtx := conn.CloseRead(r.Context())
sub := e.eventBus.Subscribe()
defer sub.Close()
for {
select {
case <-wsCtx.Done():
return
case <-sub.Done():
return
case ev := <-sub.Events():
msg, err := conn.Writer(r.Context(), websocket.MessageText)
if err != nil {
return
}
if err := tmpl().ExecuteTemplate(msg, "event.html", ev); err != nil {
msg.Close()
return
}
if err := msg.Close(); err != nil {
return
}
}
}
}

View File

@ -751,6 +751,10 @@ type Server struct {
cloudInitData map[int]*CloudInitData // node num → cloud-init config
fileContents map[string][]byte // filename → file bytes
// onDHCPEvent, if non-nil, is called when DHCP messages are processed.
// Parameters are: source MAC, node number, DHCP message type, assigned IP.
onDHCPEvent func(nodeMAC MAC, nodeNum int, msgType layers.DHCPMsgType, assignedIP netip.Addr)
}
func (s *Server) logf(format string, args ...any) {
@ -765,6 +769,13 @@ func (s *Server) SetLoggerForTest(logf func(format string, args ...any)) {
s.optLogf = logf
}
// SetDHCPCallback registers a function to be called when DHCP messages are
// processed. The callback receives the source MAC, node number, DHCP message
// type (Discover, Offer, Request, Ack), and the assigned IP address.
func (s *Server) SetDHCPCallback(fn func(MAC, int, layers.DHCPMsgType, netip.Addr)) {
s.onDHCPEvent = fn
}
var derpMap = &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {
@ -1990,6 +2001,10 @@ func (s *Server) createDHCPResponse(request gopacket.Packet) ([]byte, error) {
Length: 4,
},
)
if s.onDHCPEvent != nil {
s.onDHCPEvent(srcMAC, node.num, layers.DHCPMsgTypeDiscover, clientIP)
s.onDHCPEvent(srcMAC, node.num, layers.DHCPMsgTypeOffer, clientIP)
}
case layers.DHCPMsgTypeRequest:
response.Options = append(response.Options,
layers.DHCPOption{
@ -2018,6 +2033,10 @@ func (s *Server) createDHCPResponse(request gopacket.Packet) ([]byte, error) {
Length: 4,
},
)
if s.onDHCPEvent != nil {
s.onDHCPEvent(srcMAC, node.num, layers.DHCPMsgTypeRequest, clientIP)
s.onDHCPEvent(srcMAC, node.num, layers.DHCPMsgTypeAck, clientIP)
}
}
eth := &layers.Ethernet{