فهرست منبع

feat(bwtest): implement native high-performance Bandwidth Test engine (server, client, UDP/TCP, live graph and UI)

Proxmox Router 1 هفته پیش
والد
کامیت
6851851468

+ 4 - 1
cmd/server/main.go

@@ -13,6 +13,7 @@ import (
 
 	"proxmox-router/internal/api"
 	"proxmox-router/internal/bridge"
+	"proxmox-router/internal/bwtest"
 	"proxmox-router/internal/dhcp"
 	"proxmox-router/internal/dynamicrouting"
 	"proxmox-router/internal/firewall"
@@ -42,9 +43,11 @@ func main() {
 	dhcpMgr := dhcp.NewManager()
 	dhcpServerMgr := dhcp.NewServerManager()
 	vrrpMgr := vrrp.NewManager()
+	bwtestMgr := bwtest.NewManager("")
+	defer bwtestMgr.Stop()
 	webHandler := web.Handler()
 
-	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, dhcpMgr, dhcpServerMgr, vrrpMgr, webHandler)
+	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, dhcpMgr, dhcpServerMgr, vrrpMgr, bwtestMgr, webHandler)
 
 	httpServer := &http.Server{
 		Addr:         *addr,

+ 13 - 1
internal/api/api.go

@@ -7,6 +7,7 @@ import (
 
 	"proxmox-router/internal/bridge"
 	"proxmox-router/internal/dhcp"
+	"proxmox-router/internal/bwtest"
 	"proxmox-router/internal/dynamicrouting"
 	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/network"
@@ -30,11 +31,12 @@ type Server struct {
 	dhcpMgr       *dhcp.Manager
 	dhcpServerMgr *dhcp.ServerManager
 	vrrpMgr       *vrrp.Manager
+	bwtestMgr     *bwtest.Manager
 	mux           *http.ServeMux
 	fsHandler     http.Handler
 }
 
-func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, fsHandler http.Handler) *Server {
+func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, fsHandler http.Handler) *Server {
 	s := &Server{
 		netMgr:        netMgr,
 		persistMgr:    persistMgr,
@@ -48,6 +50,7 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 		dhcpMgr:       dhcpMgr,
 		dhcpServerMgr: dhcpServerMgr,
 		vrrpMgr:       vrrpMgr,
+		bwtestMgr:     bwtestMgr,
 		mux:           http.NewServeMux(),
 		fsHandler:     fsHandler,
 	}
@@ -337,6 +340,15 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("POST /api/tools/port-probe", s.handleToolsPortProbe)
 	s.mux.HandleFunc("POST /api/tools/mtr", s.handleToolsMTR)
 
+	// Bandwidth Test Tool (/tool bandwidth-test / BwTest)
+	s.mux.HandleFunc("GET /api/tools/bwtest/server", s.handleGetBwTestServer)
+	s.mux.HandleFunc("POST /api/tools/bwtest/server", s.handleUpdateBwTestServer)
+	s.mux.HandleFunc("POST /api/tools/bwtest/start", s.handleStartBwTest)
+	s.mux.HandleFunc("POST /api/tools/bwtest/stop", s.handleStopBwTest)
+	s.mux.HandleFunc("GET /api/tools/bwtest/status", s.handleGetBwTestStatus)
+	s.mux.HandleFunc("GET /api/tools/bwtest/stream", s.handleStreamBwTest)
+	s.mux.HandleFunc("GET /api/tools/bwtest/history", s.handleGetBwTestHistory)
+
 	// DHCP Client (/ip dhcp-client)
 	s.mux.HandleFunc("GET /api/dhcp/clients", s.handleListDHCPClients)
 	s.mux.HandleFunc("GET /api/dhcp/clients/summary", s.handleGetDHCPSummary)

+ 123 - 0
internal/api/bwtest_handlers.go

@@ -0,0 +1,123 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"time"
+
+	"proxmox-router/internal/bwtest"
+)
+
+// handleGetBwTestServer returns status and metrics for the local BwTest server
+func (s *Server) handleGetBwTestServer(w http.ResponseWriter, r *http.Request) {
+	status := s.bwtestMgr.GetServerStatus()
+	writeJSON(w, http.StatusOK, status)
+}
+
+// handleUpdateBwTestServer updates the server configuration
+func (s *Server) handleUpdateBwTestServer(w http.ResponseWriter, r *http.Request) {
+	var cfg bwtest.ServerConfig
+	if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+
+	if err := s.bwtestMgr.SetServerConfig(cfg); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, s.bwtestMgr.GetServerStatus())
+}
+
+// handleStartBwTest initiates a bandwidth test
+func (s *Server) handleStartBwTest(w http.ResponseWriter, r *http.Request) {
+	var req bwtest.SessionRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+
+	if req.Target == "" {
+		req.Target = "127.0.0.1"
+	}
+	if req.Port <= 0 {
+		req.Port = 2000
+	}
+
+	if err := s.bwtestMgr.StartTest(r.Context(), req); err != nil {
+		writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, s.bwtestMgr.GetTestStatus())
+}
+
+// handleStopBwTest terminates any running test
+func (s *Server) handleStopBwTest(w http.ResponseWriter, r *http.Request) {
+	s.bwtestMgr.StopTest()
+	writeJSON(w, http.StatusOK, map[string]bool{"stopped": true})
+}
+
+// handleGetBwTestStatus retrieves current status, telemetry, and metrics
+func (s *Server) handleGetBwTestStatus(w http.ResponseWriter, r *http.Request) {
+	status := s.bwtestMgr.GetTestStatus()
+	writeJSON(w, http.StatusOK, status)
+}
+
+// handleStreamBwTest streams real-time telemetry samples over Server-Sent Events (SSE)
+func (s *Server) handleStreamBwTest(w http.ResponseWriter, r *http.Request) {
+	flusher, ok := w.(http.Flusher)
+	if !ok {
+		http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+	w.Header().Set("X-Accel-Buffering", "no")
+
+	subCh := s.bwtestMgr.SubscribeClient()
+	ticker := time.NewTicker(500 * time.Millisecond)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-r.Context().Done():
+			return
+
+		case sample, ok := <-subCh:
+			if !ok {
+				return
+			}
+			data, err := json.Marshal(sample)
+			if err == nil {
+				fmt.Fprintf(w, "data: %s\n\n", data)
+				flusher.Flush()
+			}
+
+		case <-ticker.C:
+			// Send heartbeat/current status snapshot if no sample arrives
+			status := s.bwtestMgr.GetTestStatus()
+			if !status.Running {
+				summaryData, _ := json.Marshal(map[string]interface{}{
+					"type":    "status",
+					"running": false,
+					"summary": status.Summary,
+					"error":   status.Error,
+				})
+				fmt.Fprintf(w, "data: %s\n\n", summaryData)
+				flusher.Flush()
+				return
+			}
+		}
+	}
+}
+
+// handleGetBwTestHistory returns historical test records
+func (s *Server) handleGetBwTestHistory(w http.ResponseWriter, r *http.Request) {
+	history := s.bwtestMgr.GetHistory()
+	writeJSON(w, http.StatusOK, history)
+}

+ 570 - 0
internal/bwtest/client.go

@@ -0,0 +1,570 @@
+package bwtest
+
+import (
+	"bufio"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"math/rand"
+	"net"
+	"sync"
+	"sync/atomic"
+	"time"
+)
+
+var (
+	ErrTestAlreadyRunning = errors.New("a bandwidth test is already in progress")
+	ErrServerRejected     = errors.New("remote server rejected bandwidth test request")
+)
+
+// BwTestClient manages executing bandwidth tests against a remote BwTest server
+type BwTestClient struct {
+	mu            sync.RWMutex
+	running       bool
+	currentCancel context.CancelFunc
+
+	status  TestStatus
+	samples []*LiveSample
+
+	sampleCh chan LiveSample
+}
+
+// NewClient creates a new BwTestClient instance
+func NewClient() *BwTestClient {
+	return &BwTestClient{
+		sampleCh: make(chan LiveSample, 100),
+	}
+}
+
+// GetStatus returns the current or most recent test state
+func (c *BwTestClient) GetStatus() TestStatus {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	st := c.status
+	if len(c.samples) > 0 {
+		st.CurrentSample = c.samples[len(c.samples)-1]
+		st.RecentSamples = c.samples
+	}
+	return st
+}
+
+// Subscribe returns a channel of live telemetry samples
+func (c *BwTestClient) Subscribe() <-chan LiveSample {
+	return c.sampleCh
+}
+
+// Stop terminates any currently active bandwidth test
+func (c *BwTestClient) Stop() {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	if c.running && c.currentCancel != nil {
+		c.currentCancel()
+	}
+}
+
+// Run executes a bandwidth test asynchronously
+func (c *BwTestClient) Run(ctx context.Context, req SessionRequest) error {
+	c.mu.Lock()
+	if c.running {
+		c.mu.Unlock()
+		return ErrTestAlreadyRunning
+	}
+
+	if req.Target == "" {
+		req.Target = "127.0.0.1"
+	}
+	if req.Port <= 0 {
+		req.Port = 2000
+	}
+	if req.Protocol != "tcp" {
+		req.Protocol = "udp"
+	}
+	if req.Direction != "tx" && req.Direction != "rx" && req.Direction != "both" {
+		req.Direction = "both"
+	}
+	if req.PacketSize <= 0 {
+		req.PacketSize = 1472
+	}
+	if req.Streams <= 0 {
+		req.Streams = 1
+	}
+	if req.Streams > 8 {
+		req.Streams = 8
+	}
+	if req.Duration <= 0 {
+		req.Duration = 10
+	}
+
+	testCtx, cancel := context.WithCancel(context.Background())
+	c.currentCancel = cancel
+	c.running = true
+	c.samples = make([]*LiveSample, 0, 120)
+
+	sessionID := fmt.Sprintf("bw-%d-%d", time.Now().Unix(), rand.Intn(10000))
+	c.status = TestStatus{
+		Running:        true,
+		SessionID:      sessionID,
+		Target:         req.Target,
+		Port:           req.Port,
+		Protocol:       req.Protocol,
+		Direction:      req.Direction,
+		BandwidthLimit: req.BandwidthLimit,
+		PacketSize:     req.PacketSize,
+		Streams:        req.Streams,
+		Duration:       req.Duration,
+		StartTime:      time.Now(),
+	}
+	c.mu.Unlock()
+
+	go c.executeTest(testCtx, cancel, req, sessionID)
+	return nil
+}
+
+func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFunc, req SessionRequest, sessionID string) {
+	defer func() {
+		cancel()
+		c.mu.Lock()
+		c.running = false
+		c.status.Running = false
+		c.finalizeSummary()
+		c.mu.Unlock()
+	}()
+
+	targetAddr := fmt.Sprintf("%s:%d", req.Target, req.Port)
+	dialer := net.Dialer{Timeout: 5 * time.Second}
+	controlConn, err := dialer.DialContext(ctx, "tcp", targetAddr)
+	if err != nil {
+		c.mu.Lock()
+		c.status.Error = fmt.Sprintf("Failed to connect to control port %s: %v", targetAddr, err)
+		c.mu.Unlock()
+		return
+	}
+	defer controlConn.Close()
+
+	// If UDP, bind a local UDP socket
+	var udpConn *net.UDPConn
+	var clientUDPPort int
+	if req.Protocol == "udp" {
+		uaddr, err := net.ResolveUDPAddr("udp", ":0")
+		if err == nil {
+			udpConn, err = net.ListenUDP("udp", uaddr)
+		}
+		if err != nil {
+			c.mu.Lock()
+			c.status.Error = fmt.Sprintf("Failed to create local UDP socket: %v", err)
+			c.mu.Unlock()
+			return
+		}
+		defer udpConn.Close()
+		_ = udpConn.SetReadBuffer(8 * 1024 * 1024)
+		if la, ok := udpConn.LocalAddr().(*net.UDPAddr); ok {
+			clientUDPPort = la.Port
+		}
+	}
+
+	// Send handshake request
+	hs := SessionHandshake{
+		Type:           "request",
+		SessionID:      sessionID,
+		Protocol:       req.Protocol,
+		Direction:      req.Direction,
+		BandwidthLimit: req.BandwidthLimit,
+		PacketSize:     req.PacketSize,
+		Streams:        req.Streams,
+		Duration:       req.Duration,
+		UDPPort:        clientUDPPort,
+	}
+	b, _ := json.Marshal(hs)
+	if _, err := controlConn.Write(append(b, '\n')); err != nil {
+		c.mu.Lock()
+		c.status.Error = fmt.Sprintf("Handshake send failed: %v", err)
+		c.mu.Unlock()
+		return
+	}
+
+	// Read accept response
+	reader := bufio.NewReader(controlConn)
+	line, err := reader.ReadBytes('\n')
+	if err != nil {
+		c.mu.Lock()
+		c.status.Error = fmt.Sprintf("Server response error: %v", err)
+		c.mu.Unlock()
+		return
+	}
+
+	var acceptResp SessionHandshake
+	if err := json.Unmarshal(line, &acceptResp); err != nil || acceptResp.Type != "accept" {
+		c.mu.Lock()
+		c.status.Error = fmt.Sprintf("Server rejected test: %s", acceptResp.Message)
+		c.mu.Unlock()
+		return
+	}
+
+	serverUDPPort := acceptResp.UDPPort
+	if serverUDPPort <= 0 {
+		serverUDPPort = req.Port
+	}
+
+	// Statistics counters
+	var (
+		localTxBytes     atomic.Int64
+		localTxPackets   atomic.Int64
+		localRxBytes     atomic.Int64
+		localRxPackets   atomic.Int64
+		localLostPackets atomic.Int64
+		serverRxBytes    atomic.Int64
+		serverRxPackets  atomic.Int64
+		serverLostPackets atomic.Int64
+
+		curJitterMs atomic.Uint64 // float64 in bits
+		jitterMu    sync.Mutex
+		jitterCalc  JitterCalculator
+		maxSeqRecv  atomic.Uint64
+	)
+
+	sessionHash := HashSessionID(sessionID)
+
+	// Background worker for server feedback reports over TCP control socket
+	go func() {
+		for {
+			line, err := reader.ReadBytes('\n')
+			if err != nil {
+				return
+			}
+			var rep SessionHandshake
+			if err := json.Unmarshal(line, &rep); err == nil && rep.Type == "report" {
+				serverRxBytes.Store(rep.RxBytes)
+				serverRxPackets.Store(rep.RxPackets)
+				serverLostPackets.Store(rep.LostPackets)
+			}
+		}
+	}()
+
+	var wg sync.WaitGroup
+
+	// UDP Data path
+	if req.Protocol == "udp" {
+		serverUDPAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", req.Target, serverUDPPort))
+		if err != nil {
+			c.mu.Lock()
+			c.status.Error = fmt.Sprintf("Failed to resolve server UDP address: %v", err)
+			c.mu.Unlock()
+			return
+		}
+
+		// Transmitter (TX or Both)
+		if req.Direction == "tx" || req.Direction == "both" {
+			for s := 0; s < req.Streams; s++ {
+				wg.Add(1)
+				streamID := uint8(s)
+				go func() {
+					defer wg.Done()
+					limiter := NewRateLimiter(req.BandwidthLimit / int64(req.Streams))
+					buf := make([]byte, req.PacketSize)
+					var seq uint64
+
+					hdr := PacketHeader{
+						Magic:         MagicBytes,
+						Version:       ProtoVer,
+						PacketType:    PacketTypeData,
+						StreamID:      streamID,
+						SessionHash:   sessionHash,
+						PayloadLength: uint32(req.PacketSize - HeaderSize),
+					}
+
+					for {
+						select {
+						case <-ctx.Done():
+							return
+						default:
+						}
+
+						if limiter != nil {
+							limiter.Wait(req.PacketSize)
+						}
+
+						seq++
+						hdr.Sequence = seq
+						hdr.TimestampNano = time.Now().UnixNano()
+						EncodeHeader(buf, hdr)
+
+						n, err := udpConn.WriteToUDP(buf, serverUDPAddr)
+						if err == nil {
+							localTxBytes.Add(int64(n))
+							localTxPackets.Add(1)
+						}
+					}
+				}()
+			}
+		}
+
+		// Receiver (RX or Both)
+		if req.Direction == "rx" || req.Direction == "both" {
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				rxBuf := make([]byte, 65535)
+
+				for {
+					select {
+					case <-ctx.Done():
+						return
+					default:
+					}
+
+					n, _, err := udpConn.ReadFromUDP(rxBuf)
+					if err != nil {
+						select {
+						case <-ctx.Done():
+							return
+						default:
+							time.Sleep(10 * time.Millisecond)
+							continue
+						}
+					}
+
+					if n < HeaderSize {
+						continue
+					}
+
+					hdr, err := DecodeHeader(rxBuf[:n])
+					if err != nil || hdr.SessionHash != sessionHash {
+						continue
+					}
+
+					nowNano := time.Now().UnixNano()
+					localRxBytes.Add(int64(n))
+					localRxPackets.Add(1)
+
+					// Jitter calculation
+					jitterMu.Lock()
+					j := jitterCalc.Update(hdr.TimestampNano, nowNano)
+					jitterMu.Unlock()
+					curJitterMs.Store(uint64(j * 1000)) // ms * 1000
+
+					// Packet loss tracking
+					lastMax := maxSeqRecv.Load()
+					if hdr.Sequence > lastMax {
+						if lastMax > 0 && hdr.Sequence > lastMax+1 {
+							gap := int64(hdr.Sequence - lastMax - 1)
+							localLostPackets.Add(gap)
+						}
+						maxSeqRecv.Store(hdr.Sequence)
+					}
+				}
+			}()
+		}
+	} else if req.Protocol == "tcp" {
+		// TCP Data path
+		if req.Direction == "tx" || req.Direction == "both" {
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				chunk := make([]byte, 64*1024)
+				limiter := NewRateLimiter(req.BandwidthLimit)
+
+				for {
+					select {
+					case <-ctx.Done():
+						return
+					default:
+					}
+
+					if limiter != nil {
+						limiter.Wait(len(chunk))
+					}
+
+					n, err := controlConn.Write(chunk)
+					if err != nil {
+						return
+					}
+					localTxBytes.Add(int64(n))
+				}
+			}()
+		}
+
+		if req.Direction == "rx" || req.Direction == "both" {
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				buf := make([]byte, 64*1024)
+				for {
+					select {
+					case <-ctx.Done():
+						return
+					default:
+					}
+
+					n, err := controlConn.Read(buf)
+					if err != nil {
+						return
+					}
+					localRxBytes.Add(int64(n))
+				}
+			}()
+		}
+	}
+
+	// Telemetry sampling loop (every 500ms)
+	sampleTicker := time.NewTicker(500 * time.Millisecond)
+	defer sampleTicker.Stop()
+
+	var lastTxBytes, lastRxBytes int64
+	lastTime := time.Now()
+	startTime := lastTime
+
+	timeoutChan := make(<-chan time.Time)
+	if req.Duration > 0 {
+		timer := time.NewTimer(time.Duration(req.Duration) * time.Second)
+		defer timer.Stop()
+		timeoutChan = timer.C
+	}
+
+	for {
+		select {
+		case <-ctx.Done():
+			// Notify remote server to stop
+			stopMsg := SessionHandshake{Type: "stop", SessionID: sessionID}
+			sb, _ := json.Marshal(stopMsg)
+			_, _ = controlConn.Write(append(sb, '\n'))
+			return
+
+		case <-timeoutChan:
+			stopMsg := SessionHandshake{Type: "stop", SessionID: sessionID}
+			sb, _ := json.Marshal(stopMsg)
+			_, _ = controlConn.Write(append(sb, '\n'))
+			return
+
+		case now := <-sampleTicker.C:
+			intervalSec := now.Sub(lastTime).Seconds()
+			if intervalSec <= 0 {
+				intervalSec = 0.5
+			}
+			lastTime = now
+			elapsedSec := now.Sub(startTime).Seconds()
+
+			var curTxBytes, curRxBytes int64
+			var curLostPackets int64
+
+			if req.Direction == "tx" {
+				curTxBytes = localTxBytes.Load()
+				curRxBytes = serverRxBytes.Load()
+				curLostPackets = serverLostPackets.Load()
+			} else if req.Direction == "rx" {
+				curRxBytes = localRxBytes.Load()
+				curLostPackets = localLostPackets.Load()
+			} else { // both
+				curTxBytes = localTxBytes.Load()
+				curRxBytes = localRxBytes.Load()
+				curLostPackets = localLostPackets.Load() + serverLostPackets.Load()
+			}
+
+			deltaTx := curTxBytes - lastTxBytes
+			deltaRx := curRxBytes - lastRxBytes
+			lastTxBytes = curTxBytes
+			lastRxBytes = curRxBytes
+
+			txBps := int64(float64(deltaTx*8) / intervalSec)
+			rxBps := int64(float64(deltaRx*8) / intervalSec)
+
+			txMbps := float64(txBps) / 1e6
+			rxMbps := float64(rxBps) / 1e6
+
+			// Total packets & loss %
+			totalExpectedPkts := localRxPackets.Load() + localLostPackets.Load()
+			var lossPct float64
+			if totalExpectedPkts > 0 {
+				lossPct = (float64(curLostPackets) / float64(totalExpectedPkts)) * 100.0
+				if lossPct > 100.0 {
+					lossPct = 100.0
+				}
+			}
+
+			jitter := float64(curJitterMs.Load()) / 1000.0
+
+			sample := &LiveSample{
+				Timestamp:   now,
+				ElapsedSec:  elapsedSec,
+				TxBps:       txBps,
+				RxBps:       rxBps,
+				TxMbps:      txMbps,
+				RxMbps:      rxMbps,
+				TxPackets:   localTxPackets.Load(),
+				RxPackets:   localRxPackets.Load(),
+				TxBytes:     curTxBytes,
+				RxBytes:     curRxBytes,
+				LostPackets: curLostPackets,
+				LossPct:     lossPct,
+				JitterMs:    jitter,
+			}
+
+			c.mu.Lock()
+			c.samples = append(c.samples, sample)
+			if len(c.samples) > 200 {
+				c.samples = c.samples[len(c.samples)-200:]
+			}
+			c.status.ElapsedSec = elapsedSec
+			c.status.CurrentSample = sample
+			c.mu.Unlock()
+
+			// Non-blocking publish to subscriber channel
+			select {
+			case c.sampleCh <- *sample:
+			default:
+			}
+		}
+	}
+}
+
+func (c *BwTestClient) finalizeSummary() {
+	if len(c.samples) == 0 {
+		return
+	}
+
+	first := c.samples[0]
+	last := c.samples[len(c.samples)-1]
+	duration := last.ElapsedSec
+
+	var sumTxMbps, sumRxMbps float64
+	var peakTxMbps, peakRxMbps float64
+	var sumLossPct, sumJitter float64
+
+	for _, s := range c.samples {
+		sumTxMbps += s.TxMbps
+		sumRxMbps += s.RxMbps
+		sumLossPct += s.LossPct
+		sumJitter += s.JitterMs
+		if s.TxMbps > peakTxMbps {
+			peakTxMbps = s.TxMbps
+		}
+		if s.RxMbps > peakRxMbps {
+			peakRxMbps = s.RxMbps
+		}
+	}
+
+	count := float64(len(c.samples))
+	avgTx := sumTxMbps / count
+	avgRx := sumRxMbps / count
+	avgLoss := sumLossPct / count
+	avgJitter := sumJitter / count
+
+	c.status.Summary = &TestSummary{
+		DurationSec:      duration,
+		StartTime:        c.status.StartTime,
+		EndTime:          time.Now(),
+		TotalTxBytes:     last.TxBytes,
+		TotalRxBytes:     last.RxBytes,
+		AvgTxMbps:        avgTx,
+		AvgRxMbps:        avgRx,
+		PeakTxMbps:       peakTxMbps,
+		PeakRxMbps:       peakRxMbps,
+		TotalTxPackets:   last.TxPackets,
+		TotalRxPackets:   last.RxPackets,
+		TotalLostPackets: last.LostPackets,
+		AvgLossPct:       avgLoss,
+		AvgJitterMs:      avgJitter,
+	}
+	_ = first
+}

+ 169 - 0
internal/bwtest/manager.go

@@ -0,0 +1,169 @@
+package bwtest
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"sync"
+)
+
+const DefaultConfigPath = "/etc/proxmox-router/bwtest.json"
+
+// Manager coordinates the BwTest server and client subsystems
+type Manager struct {
+	mu         sync.RWMutex
+	configPath string
+	server     *BwTestServer
+	client     *BwTestClient
+	history    []*HistoricalTestRecord
+}
+
+// NewManager initializes the Bandwidth Test manager and starts the background server
+func NewManager(cfgPath string) *Manager {
+	if cfgPath == "" {
+		cfgPath = DefaultConfigPath
+	}
+
+	cfg := ServerConfig{
+		Enabled:        true,
+		Port:           2000,
+		ListenAddr:     "0.0.0.0",
+		AllowAnonymous: true,
+	}
+
+	m := &Manager{
+		configPath: cfgPath,
+		history:    make([]*HistoricalTestRecord, 0, 50),
+	}
+
+	// Try loading persisted configuration
+	if loaded, err := m.loadConfig(); err == nil {
+		cfg = *loaded
+	}
+
+	m.server = NewServer(cfg)
+	m.client = NewClient()
+
+	if cfg.Enabled {
+		if err := m.server.Start(); err != nil {
+			fmt.Printf("[BwTestManager] Warning: failed to start server on port %d: %v\n", cfg.Port, err)
+		}
+	}
+
+	return m
+}
+
+func (m *Manager) loadConfig() (*ServerConfig, error) {
+	data, err := os.ReadFile(m.configPath)
+	if err != nil {
+		return nil, err
+	}
+	var cfg ServerConfig
+	if err := json.Unmarshal(data, &cfg); err != nil {
+		return nil, err
+	}
+	return &cfg, nil
+}
+
+func (m *Manager) saveConfigLocked(cfg ServerConfig) error {
+	_ = os.MkdirAll("/etc/proxmox-router", 0755)
+	data, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(m.configPath, data, 0644)
+}
+
+// GetServerStatus returns metrics and status for the local BwTest server
+func (m *Manager) GetServerStatus() ServerStatus {
+	return m.server.Status()
+}
+
+// SetServerConfig updates server configuration (enabled state, port)
+func (m *Manager) SetServerConfig(cfg ServerConfig) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if cfg.Port <= 0 {
+		cfg.Port = 2000
+	}
+	if cfg.ListenAddr == "" {
+		cfg.ListenAddr = "0.0.0.0"
+	}
+
+	m.server.Stop()
+	m.server.config = cfg
+
+	if cfg.Enabled {
+		if err := m.server.Start(); err != nil {
+			return err
+		}
+	}
+
+	return m.saveConfigLocked(cfg)
+}
+
+// StartTest initiates an asynchronous bandwidth test against the specified target
+func (m *Manager) StartTest(ctx context.Context, req SessionRequest) error {
+	return m.client.Run(ctx, req)
+}
+
+// StopTest terminates any ongoing bandwidth test
+func (m *Manager) StopTest() {
+	m.client.Stop()
+}
+
+// GetTestStatus retrieves current live test status and telemetry samples
+func (m *Manager) GetTestStatus() TestStatus {
+	st := m.client.GetStatus()
+
+	// If test completed with a summary, persist into history
+	if !st.Running && st.Summary != nil {
+		m.mu.Lock()
+		alreadyExists := false
+		for _, h := range m.history {
+			if h.ID == st.SessionID {
+				alreadyExists = true
+				break
+			}
+		}
+		if !alreadyExists && st.SessionID != "" {
+			record := &HistoricalTestRecord{
+				ID:        st.SessionID,
+				Target:    fmt.Sprintf("%s:%d", st.Target, st.Port),
+				Protocol:  st.Protocol,
+				Direction: st.Direction,
+				Timestamp: st.StartTime,
+				Summary:   st.Summary,
+			}
+			m.history = append([]*HistoricalTestRecord{record}, m.history...)
+			if len(m.history) > 50 {
+				m.history = m.history[:50]
+			}
+		}
+		m.mu.Unlock()
+	}
+
+	return st
+}
+
+// SubscribeClient returns a stream of real-time telemetry events
+func (m *Manager) SubscribeClient() <-chan LiveSample {
+	return m.client.Subscribe()
+}
+
+// GetHistory returns past completed bandwidth test results
+func (m *Manager) GetHistory() []*HistoricalTestRecord {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	res := make([]*HistoricalTestRecord, len(m.history))
+	copy(res, m.history)
+	return res
+}
+
+// Stop shuts down the manager and server listeners cleanly
+func (m *Manager) Stop() {
+	m.client.Stop()
+	m.server.Stop()
+}

+ 156 - 0
internal/bwtest/protocol.go

@@ -0,0 +1,156 @@
+package bwtest
+
+import (
+	"encoding/binary"
+	"errors"
+	"hash/fnv"
+	"math"
+	"time"
+)
+
+const (
+	MagicBytes uint32 = 0x42575453 // "BWTS"
+	ProtoVer   uint8  = 1
+
+	PacketTypeData   uint8 = 1
+	PacketTypeReport uint8 = 2
+	PacketTypeStop   uint8 = 3
+
+	HeaderSize = 36
+)
+
+var (
+	ErrInvalidMagic = errors.New("invalid bandwidth test packet magic")
+	ErrShortPacket  = errors.New("packet shorter than minimum header size")
+)
+
+// PacketHeader represents the 36-byte binary header on every UDP test datagram
+type PacketHeader struct {
+	Magic         uint32
+	Version       uint8
+	PacketType    uint8
+	StreamID      uint8
+	Flags         uint8
+	SessionHash   uint64
+	Sequence      uint64
+	TimestampNano int64
+	PayloadLength uint32
+}
+
+// HashSessionID generates a fast 64-bit FNV-1a hash of the session ID string
+func HashSessionID(sessionID string) uint64 {
+	h := fnv.New64a()
+	_, _ = h.Write([]byte(sessionID))
+	return h.Sum64()
+}
+
+// EncodeHeader serializes the packet header into the provided byte slice (must be >= 36 bytes)
+func EncodeHeader(b []byte, hdr PacketHeader) {
+	binary.BigEndian.PutUint32(b[0:4], MagicBytes)
+	b[4] = ProtoVer
+	b[5] = hdr.PacketType
+	b[6] = hdr.StreamID
+	b[7] = hdr.Flags
+	binary.BigEndian.PutUint64(b[8:16], hdr.SessionHash)
+	binary.BigEndian.PutUint64(b[16:24], hdr.Sequence)
+	binary.BigEndian.PutUint64(b[24:32], uint64(hdr.TimestampNano))
+	binary.BigEndian.PutUint32(b[32:36], hdr.PayloadLength)
+}
+
+// DecodeHeader deserializes the binary packet header from byte slice
+func DecodeHeader(b []byte) (PacketHeader, error) {
+	if len(b) < HeaderSize {
+		return PacketHeader{}, ErrShortPacket
+	}
+	magic := binary.BigEndian.Uint32(b[0:4])
+	if magic != MagicBytes {
+		return PacketHeader{}, ErrInvalidMagic
+	}
+	return PacketHeader{
+		Magic:         magic,
+		Version:       b[4],
+		PacketType:    b[5],
+		StreamID:      b[6],
+		Flags:         b[7],
+		SessionHash:   binary.BigEndian.Uint64(b[8:16]),
+		Sequence:      binary.BigEndian.Uint64(b[16:24]),
+		TimestampNano: int64(binary.BigEndian.Uint64(b[24:32])),
+		PayloadLength: binary.BigEndian.Uint32(b[32:36]),
+	}, nil
+}
+
+// JitterCalculator computes RFC 3550 statistical interarrival jitter
+type JitterCalculator struct {
+	hasPrev     bool
+	prevTransit int64
+	jitterFloat float64 // in nanoseconds
+}
+
+// Update incorporates a newly received packet's sender timestamp and arrival time
+func (jc *JitterCalculator) Update(senderTsNano, arrivalTsNano int64) float64 {
+	transit := arrivalTsNano - senderTsNano
+	if !jc.hasPrev {
+		jc.prevTransit = transit
+		jc.hasPrev = true
+		return 0
+	}
+	diff := math.Abs(float64(transit - jc.prevTransit))
+	jc.prevTransit = transit
+	// RFC 3550: J(i) = J(i-1) + (|D(i-1,i)| - J(i-1))/16
+	jc.jitterFloat += (diff - jc.jitterFloat) / 16.0
+	return jc.jitterFloat / 1e6 // Return in milliseconds
+}
+
+// RateLimiter implements a high-precision token bucket for pacing outbound test traffic
+type RateLimiter struct {
+	bps           int64
+	tokens        float64
+	maxTokens     float64
+	lastCheckTime time.Time
+}
+
+// NewRateLimiter initializes a rate limiter for target bits per second (0 = no limit)
+func NewRateLimiter(targetBps int64) *RateLimiter {
+	if targetBps <= 0 {
+		return nil
+	}
+	bytesPerSec := float64(targetBps) / 8.0
+	return &RateLimiter{
+		bps:           targetBps,
+		tokens:        bytesPerSec * 0.05, // 50ms burst allowance
+		maxTokens:     bytesPerSec * 0.10, // 100ms max burst
+		lastCheckTime: time.Now(),
+	}
+}
+
+// Wait paces the sender goroutine to ensure bandwidth limit is respected
+func (rl *RateLimiter) Wait(bytes int) {
+	if rl == nil || rl.bps <= 0 {
+		return
+	}
+	neededTokens := float64(bytes)
+	bytesPerSec := float64(rl.bps) / 8.0
+
+	for {
+		now := time.Now()
+		elapsed := now.Sub(rl.lastCheckTime).Seconds()
+		rl.lastCheckTime = now
+
+		rl.tokens += elapsed * bytesPerSec
+		if rl.tokens > rl.maxTokens {
+			rl.tokens = rl.maxTokens
+		}
+
+		if rl.tokens >= neededTokens {
+			rl.tokens -= neededTokens
+			return
+		}
+
+		deficit := neededTokens - rl.tokens
+		sleepSec := deficit / bytesPerSec
+		if sleepSec > 0.05 {
+			sleepSec = 0.05
+		}
+		time.Sleep(time.Duration(sleepSec * float64(time.Second)))
+	}
+}

+ 539 - 0
internal/bwtest/server.go

@@ -0,0 +1,539 @@
+package bwtest
+
+import (
+	"bufio"
+	"context"
+	"encoding/json"
+	"fmt"
+	"log"
+	"math/rand"
+	"net"
+	"sync"
+	"sync/atomic"
+	"time"
+)
+
+// ServerSession tracks an active incoming bandwidth test session on the server
+type serverSession struct {
+	id          string
+	sessionHash uint64
+	clientTCP   net.Conn
+	clientAddr  net.IP
+	clientPort  int
+	handshake   SessionHandshake
+	cancel      context.CancelFunc
+	ctx         context.Context
+
+	// Statistics
+	rxBytes     atomic.Int64
+	rxPackets   atomic.Int64
+	txBytes     atomic.Int64
+	txPackets   atomic.Int64
+	lostPackets atomic.Int64
+	jitterMs    atomic.Uint64 // float64 bits
+
+	maxSeq     atomic.Uint64
+	seqTracker sync.Map // track recently seen sequences for loss detection
+
+	mu     sync.Mutex
+	jitter JitterCalculator
+}
+
+// BwTestServer represents the local Bandwidth Test server
+type BwTestServer struct {
+	config ServerConfig
+	mu     sync.RWMutex
+
+	tcpListener net.Listener
+	udpConn     *net.UDPConn
+	running     bool
+	cancel      context.CancelFunc
+
+	sessions map[string]*serverSession
+	sessMu   sync.RWMutex
+
+	totalServed atomic.Int64
+	totalBytes  atomic.Int64
+}
+
+// NewServer creates a new BwTestServer instance
+func NewServer(cfg ServerConfig) *BwTestServer {
+	if cfg.Port <= 0 {
+		cfg.Port = 2000
+	}
+	if cfg.ListenAddr == "" {
+		cfg.ListenAddr = "0.0.0.0"
+	}
+	return &BwTestServer{
+		config:   cfg,
+		sessions: make(map[string]*serverSession),
+	}
+}
+
+// Start launches the TCP control listener and UDP data listener
+func (s *BwTestServer) Start() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if s.running {
+		return nil
+	}
+
+	ctx, cancel := context.WithCancel(context.Background())
+	s.cancel = cancel
+
+	tcpAddr := fmt.Sprintf("%s:%d", s.config.ListenAddr, s.config.Port)
+	ln, err := net.Listen("tcp", tcpAddr)
+	if err != nil {
+		cancel()
+		return fmt.Errorf("failed to bind BwTest TCP listener on %s: %w", tcpAddr, err)
+	}
+	s.tcpListener = ln
+
+	udpAddr, err := net.ResolveUDPAddr("udp", tcpAddr)
+	if err != nil {
+		ln.Close()
+		cancel()
+		return fmt.Errorf("failed to resolve BwTest UDP address: %w", err)
+	}
+
+	uconn, err := net.ListenUDP("udp", udpAddr)
+	if err != nil {
+		ln.Close()
+		cancel()
+		return fmt.Errorf("failed to bind BwTest UDP listener on %s: %w", tcpAddr, err)
+	}
+	_ = uconn.SetReadBuffer(8 * 1024 * 1024) // 8MB OS UDP socket buffer
+	s.udpConn = uconn
+	s.running = true
+
+	go s.acceptLoop(ctx)
+	go s.udpReceiverLoop(ctx)
+
+	log.Printf("[BwTestServer] Started Bandwidth Test Server listening on %s (TCP & UDP)", tcpAddr)
+	return nil
+}
+
+// Stop terminates the server and all active sessions
+func (s *BwTestServer) Stop() {
+	s.mu.Lock()
+	if !s.running {
+		s.mu.Unlock()
+		return
+	}
+	s.running = false
+	if s.cancel != nil {
+		s.cancel()
+	}
+	if s.tcpListener != nil {
+		_ = s.tcpListener.Close()
+	}
+	if s.udpConn != nil {
+		_ = s.udpConn.Close()
+	}
+	s.mu.Unlock()
+
+	s.sessMu.Lock()
+	for _, sess := range s.sessions {
+		if sess.cancel != nil {
+			sess.cancel()
+		}
+		if sess.clientTCP != nil {
+			_ = sess.clientTCP.Close()
+		}
+	}
+	s.sessions = make(map[string]*serverSession)
+	s.sessMu.Unlock()
+
+	log.Printf("[BwTestServer] Bandwidth Test Server stopped")
+}
+
+// Status returns current operational metrics
+func (s *BwTestServer) Status() ServerStatus {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	s.sessMu.RLock()
+	activeCount := len(s.sessions)
+	s.sessMu.RUnlock()
+
+	return ServerStatus{
+		Enabled:             s.config.Enabled,
+		Port:                s.config.Port,
+		Running:             s.running,
+		ActiveSessions:      activeCount,
+		TotalSessionsServed: s.totalServed.Load(),
+		TotalBytesProcessed: s.totalBytes.Load(),
+	}
+}
+
+// SetPort updates the port and restarts the server if currently running
+func (s *BwTestServer) SetPort(port int) error {
+	s.mu.Lock()
+	wasRunning := s.running
+	s.config.Port = port
+	s.mu.Unlock()
+
+	if wasRunning {
+		s.Stop()
+		return s.Start()
+	}
+	return nil
+}
+
+func (s *BwTestServer) acceptLoop(ctx context.Context) {
+	for {
+		conn, err := s.tcpListener.Accept()
+		if err != nil {
+			select {
+			case <-ctx.Done():
+				return
+			default:
+				time.Sleep(50 * time.Millisecond)
+				continue
+			}
+		}
+
+		go s.handleClientControl(ctx, conn)
+	}
+}
+
+func (s *BwTestServer) handleClientControl(serverCtx context.Context, conn net.Conn) {
+	defer conn.Close()
+
+	remoteTCPAddr, _ := conn.RemoteAddr().(*net.TCPAddr)
+	clientIP := net.IPv4(127, 0, 0, 1)
+	if remoteTCPAddr != nil {
+		clientIP = remoteTCPAddr.IP
+	}
+
+	reader := bufio.NewReader(conn)
+	line, err := reader.ReadBytes('\n')
+	if err != nil {
+		return
+	}
+
+	var req SessionHandshake
+	if err := json.Unmarshal(line, &req); err != nil {
+		resp := SessionHandshake{Type: "reject", Message: "Invalid handshake JSON"}
+		b, _ := json.Marshal(resp)
+		_, _ = conn.Write(append(b, '\n'))
+		return
+	}
+
+	if req.Type != "request" {
+		return
+	}
+
+	// Session parameter sanitization
+	if req.SessionID == "" {
+		req.SessionID = fmt.Sprintf("bw-%d-%d", time.Now().UnixNano(), rand.Intn(10000))
+	}
+	if req.PacketSize <= 0 {
+		req.PacketSize = 1472
+	}
+	if req.PacketSize < 64 {
+		req.PacketSize = 64
+	}
+	if req.PacketSize > 65507 {
+		req.PacketSize = 65507
+	}
+	if req.Streams <= 0 {
+		req.Streams = 1
+	}
+	if req.Streams > 8 {
+		req.Streams = 8
+	}
+	if req.Duration < 0 {
+		req.Duration = 10
+	}
+
+	sessCtx, sessCancel := context.WithCancel(serverCtx)
+	if req.Duration > 0 {
+		sessCtx, sessCancel = context.WithTimeout(serverCtx, time.Duration(req.Duration+2)*time.Second)
+	}
+	defer sessCancel()
+
+	sess := &serverSession{
+		id:          req.SessionID,
+		sessionHash: HashSessionID(req.SessionID),
+		clientTCP:   conn,
+		clientAddr:  clientIP,
+		clientPort:  req.UDPPort,
+		handshake:   req,
+		ctx:         sessCtx,
+		cancel:      sessCancel,
+	}
+
+	s.sessMu.Lock()
+	s.sessions[sess.id] = sess
+	s.sessMu.Unlock()
+
+	s.totalServed.Add(1)
+
+	defer func() {
+		s.sessMu.Lock()
+		delete(s.sessions, sess.id)
+		s.sessMu.Unlock()
+	}()
+
+	// Respond with accept
+	s.mu.RLock()
+	listenPort := s.config.Port
+	s.mu.RUnlock()
+
+	acceptResp := SessionHandshake{
+		Type:      "accept",
+		SessionID: sess.id,
+		UDPPort:   listenPort,
+		Protocol:  req.Protocol,
+		Direction: req.Direction,
+		Message:   "Ready",
+	}
+	acceptBytes, _ := json.Marshal(acceptResp)
+	if _, err := conn.Write(append(acceptBytes, '\n')); err != nil {
+		return
+	}
+
+	// Launch worker routines based on direction and protocol
+	if req.Protocol == "udp" {
+		// If client is receiving (server transmitting)
+		if req.Direction == "rx" || req.Direction == "both" {
+			go s.runServerUDPTx(sessCtx, sess)
+		}
+	} else if req.Protocol == "tcp" {
+		if req.Direction == "rx" || req.Direction == "both" {
+			go s.runServerTCPTx(sessCtx, sess)
+		}
+		if req.Direction == "tx" || req.Direction == "both" {
+			go s.runServerTCPRx(sessCtx, sess)
+		}
+	}
+
+	// Read control messages / reports from client until termination
+	go s.reportLoop(sessCtx, sess)
+
+	for {
+		line, err := reader.ReadBytes('\n')
+		if err != nil {
+			break
+		}
+		var msg SessionHandshake
+		if err := json.Unmarshal(line, &msg); err == nil {
+			if msg.Type == "stop" {
+				break
+			}
+		}
+	}
+}
+
+// udpReceiverLoop processes incoming UDP test packets across all active sessions
+func (s *BwTestServer) udpReceiverLoop(ctx context.Context) {
+	buf := make([]byte, 65535)
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		default:
+		}
+
+		n, remoteAddr, err := s.udpConn.ReadFromUDP(buf)
+		if err != nil {
+			select {
+			case <-ctx.Done():
+				return
+			default:
+				time.Sleep(10 * time.Millisecond)
+				continue
+			}
+		}
+
+		if n < HeaderSize {
+			continue
+		}
+
+		hdr, err := DecodeHeader(buf[:n])
+		if err != nil {
+			continue
+		}
+
+		// Find matching session
+		s.sessMu.RLock()
+		var matched *serverSession
+		for _, sess := range s.sessions {
+			if sess.sessionHash == hdr.SessionHash {
+				matched = sess
+				break
+			}
+		}
+		s.sessMu.RUnlock()
+
+		if matched == nil {
+			continue
+		}
+
+		// Update remote client UDP address for reverse transmission
+		if matched.clientPort == 0 || matched.clientAddr == nil {
+			matched.clientAddr = remoteAddr.IP
+			matched.clientPort = remoteAddr.Port
+		}
+
+		nowNano := time.Now().UnixNano()
+		matched.rxBytes.Add(int64(n))
+		matched.rxPackets.Add(1)
+		s.totalBytes.Add(int64(n))
+
+		// Jitter calculation
+		matched.mu.Lock()
+		curJitter := matched.jitter.Update(hdr.TimestampNano, nowNano)
+		matched.mu.Unlock()
+
+		// Packet loss tracking via sequence gaps
+		lastMax := matched.maxSeq.Load()
+		if hdr.Sequence > lastMax {
+			if lastMax > 0 && hdr.Sequence > lastMax+1 {
+				gap := int64(hdr.Sequence - lastMax - 1)
+				matched.lostPackets.Add(gap)
+			}
+			matched.maxSeq.Store(hdr.Sequence)
+		}
+
+		_ = curJitter
+	}
+}
+
+// runServerUDPTx sends UDP packets from server to client (when client requested RX or Both)
+func (s *BwTestServer) runServerUDPTx(ctx context.Context, sess *serverSession) {
+	// Wait briefly for client to send first packet so we know client's UDP port, or use negotiated port
+	targetPort := sess.clientPort
+	if targetPort == 0 {
+		for i := 0; i < 20; i++ {
+			if sess.clientPort != 0 {
+				targetPort = sess.clientPort
+				break
+			}
+			time.Sleep(50 * time.Millisecond)
+		}
+	}
+	if targetPort == 0 {
+		targetPort = s.config.Port
+	}
+
+	dstUDPAddr := &net.UDPAddr{
+		IP:   sess.clientAddr,
+		Port: targetPort,
+	}
+
+	pktSize := sess.handshake.PacketSize
+	if pktSize < HeaderSize {
+		pktSize = HeaderSize
+	}
+	buf := make([]byte, pktSize)
+
+	limiter := NewRateLimiter(sess.handshake.BandwidthLimit)
+	var seq uint64
+
+	hdr := PacketHeader{
+		Magic:         MagicBytes,
+		Version:       ProtoVer,
+		PacketType:    PacketTypeData,
+		StreamID:      0,
+		Flags:         0,
+		SessionHash:   sess.sessionHash,
+		PayloadLength: uint32(pktSize - HeaderSize),
+	}
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		default:
+		}
+
+		if limiter != nil {
+			limiter.Wait(pktSize)
+		}
+
+		seq++
+		hdr.Sequence = seq
+		hdr.TimestampNano = time.Now().UnixNano()
+		EncodeHeader(buf, hdr)
+
+		n, err := s.udpConn.WriteToUDP(buf, dstUDPAddr)
+		if err == nil {
+			sess.txBytes.Add(int64(n))
+			sess.txPackets.Add(1)
+			s.totalBytes.Add(int64(n))
+		}
+	}
+}
+
+// runServerTCPTx generates continuous TCP stream data to client
+func (s *BwTestServer) runServerTCPTx(ctx context.Context, sess *serverSession) {
+	chunk := make([]byte, 64*1024)
+	limiter := NewRateLimiter(sess.handshake.BandwidthLimit)
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		default:
+		}
+
+		if limiter != nil {
+			limiter.Wait(len(chunk))
+		}
+
+		n, err := sess.clientTCP.Write(chunk)
+		if err != nil {
+			return
+		}
+		sess.txBytes.Add(int64(n))
+		s.totalBytes.Add(int64(n))
+	}
+}
+
+// runServerTCPRx reads data sent from client over TCP
+func (s *BwTestServer) runServerTCPRx(ctx context.Context, sess *serverSession) {
+	buf := make([]byte, 64*1024)
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		default:
+		}
+
+		n, err := sess.clientTCP.Read(buf)
+		if err != nil {
+			return
+		}
+		sess.rxBytes.Add(int64(n))
+		s.totalBytes.Add(int64(n))
+	}
+}
+
+// reportLoop periodically sends feedback reports back to the client over TCP
+func (s *BwTestServer) reportLoop(ctx context.Context, sess *serverSession) {
+	ticker := time.NewTicker(300 * time.Millisecond)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-ticker.C:
+			report := SessionHandshake{
+				Type:        "report",
+				SessionID:   sess.id,
+				RxBytes:     sess.rxBytes.Load(),
+				RxPackets:   sess.rxPackets.Load(),
+				LostPackets: sess.lostPackets.Load(),
+			}
+			b, err := json.Marshal(report)
+			if err == nil {
+				_, _ = sess.clientTCP.Write(append(b, '\n'))
+			}
+		}
+	}
+}

+ 120 - 0
internal/bwtest/types.go

@@ -0,0 +1,120 @@
+package bwtest
+
+import (
+	"time"
+)
+
+// ServerConfig defines the Bandwidth Test server configuration
+type ServerConfig struct {
+	Enabled        bool   `json:"enabled"`
+	Port           int    `json:"port"`
+	ListenAddr     string `json:"listen_addr"`
+	AllowAnonymous bool   `json:"allow_anonymous"`
+}
+
+// SessionRequest defines parameters sent by the client to initiate a test
+type SessionRequest struct {
+	Target         string `json:"target"`          // Target IP or hostname
+	Port           int    `json:"port"`            // Target control port (default 2000)
+	Protocol       string `json:"protocol"`        // "udp" or "tcp"
+	Direction      string `json:"direction"`       // "tx", "rx", "both"
+	BandwidthLimit int64  `json:"bandwidth_limit"` // Target bits/sec (0 = unlimited line-rate)
+	PacketSize     int    `json:"packet_size"`     // Packet payload size in bytes (e.g. 1500)
+	Streams        int    `json:"streams"`         // Number of parallel worker streams (1..8)
+	Duration       int    `json:"duration"`        // Duration in seconds (0 = continuous until stopped)
+}
+
+// SessionHandshake defines the control plane message exchanged between client and server
+type SessionHandshake struct {
+	Type           string  `json:"type"`            // "request", "accept", "reject", "report", "stop"
+	SessionID      string  `json:"session_id"`
+	Protocol       string  `json:"protocol"`        // "udp" or "tcp"
+	Direction      string  `json:"direction"`       // "tx", "rx", "both"
+	BandwidthLimit int64   `json:"bandwidth_limit"` // Target bits/sec
+	PacketSize     int     `json:"packet_size"`
+	Streams        int     `json:"streams"`
+	Duration       int     `json:"duration"`
+	UDPPort        int     `json:"udp_port,omitempty"`
+	Message        string  `json:"message,omitempty"`
+	RxBytes        int64   `json:"rx_bytes,omitempty"`
+	RxPackets      int64   `json:"rx_packets,omitempty"`
+	LostPackets    int64   `json:"lost_packets,omitempty"`
+	JitterMs       float64 `json:"jitter_ms,omitempty"`
+}
+
+// LiveSample represents real-time bandwidth metrics captured at an interval (e.g. every 500ms)
+type LiveSample struct {
+	Timestamp   time.Time `json:"timestamp"`
+	ElapsedSec  float64   `json:"elapsed_sec"`
+	TxBps       int64     `json:"tx_bps"`
+	RxBps       int64     `json:"rx_bps"`
+	TxMbps      float64   `json:"tx_mbps"`
+	RxMbps      float64   `json:"rx_mbps"`
+	TxPackets   int64     `json:"tx_packets"`
+	RxPackets   int64     `json:"rx_packets"`
+	TxBytes     int64     `json:"tx_bytes"`
+	RxBytes     int64     `json:"rx_bytes"`
+	LostPackets int64     `json:"lost_packets"`
+	LossPct     float64   `json:"loss_pct"`
+	JitterMs    float64   `json:"jitter_ms"`
+	RTTMs       float64   `json:"rtt_ms"`
+}
+
+// TestSummary represents the finalized results of a completed bandwidth test
+type TestSummary struct {
+	DurationSec      float64   `json:"duration_sec"`
+	StartTime        time.Time `json:"start_time"`
+	EndTime          time.Time `json:"end_time"`
+	TotalTxBytes     int64     `json:"total_tx_bytes"`
+	TotalRxBytes     int64     `json:"total_rx_bytes"`
+	AvgTxMbps        float64   `json:"avg_tx_mbps"`
+	AvgRxMbps        float64   `json:"avg_rx_mbps"`
+	PeakTxMbps       float64   `json:"peak_tx_mbps"`
+	PeakRxMbps       float64   `json:"peak_rx_mbps"`
+	TotalTxPackets   int64     `json:"total_tx_packets"`
+	TotalRxPackets   int64     `json:"total_rx_packets"`
+	TotalLostPackets int64     `json:"total_lost_packets"`
+	AvgLossPct       float64   `json:"avg_loss_pct"`
+	AvgJitterMs      float64   `json:"avg_jitter_ms"`
+}
+
+// TestStatus provides a complete real-time snapshot of the active client test
+type TestStatus struct {
+	Running        bool          `json:"running"`
+	SessionID      string        `json:"session_id"`
+	Target         string        `json:"target"`
+	Port           int           `json:"port"`
+	Protocol       string        `json:"protocol"`
+	Direction      string        `json:"direction"`
+	BandwidthLimit int64         `json:"bandwidth_limit"`
+	PacketSize     int           `json:"packet_size"`
+	Streams        int           `json:"streams"`
+	Duration       int           `json:"duration"`
+	StartTime      time.Time     `json:"start_time"`
+	ElapsedSec     float64       `json:"elapsed_sec"`
+	CurrentSample  *LiveSample   `json:"current_sample,omitempty"`
+	RecentSamples  []*LiveSample `json:"recent_samples,omitempty"`
+	Summary        *TestSummary  `json:"summary,omitempty"`
+	Error          string        `json:"error,omitempty"`
+}
+
+// ServerStatus provides metrics and status for the local BwTest server daemon
+type ServerStatus struct {
+	Enabled             bool   `json:"enabled"`
+	Port                int    `json:"port"`
+	Running             bool   `json:"running"`
+	ActiveSessions      int    `json:"active_sessions"`
+	TotalSessionsServed int64  `json:"total_sessions_served"`
+	TotalBytesProcessed int64  `json:"total_bytes_processed"`
+	Error               string `json:"error,omitempty"`
+}
+
+// HistoricalTestRecord stores past test results for review
+type HistoricalTestRecord struct {
+	ID        string       `json:"id"`
+	Target    string       `json:"target"`
+	Protocol  string       `json:"protocol"`
+	Direction string       `json:"direction"`
+	Timestamp time.Time    `json:"timestamp"`
+	Summary   *TestSummary `json:"summary"`
+}

+ 567 - 0
internal/web/static/app.js

@@ -136,6 +136,7 @@ function navigate(tabName) {
     l2tp: { title: 'L2TP VPN Client', sub: 'Layer 2 Tunneling Protocol via xl2tpd' },
     pptp: { title: 'PPTP VPN Client', sub: 'Point-to-Point Tunneling Protocol with MPPE-128 encryption' },
     vrrp: { title: 'VRRP High Availability', sub: 'Virtual Router Redundancy Protocol, active-backup gateway failover & Virtual IP (VIP) routing' },
+    bwtest: { title: 'Bandwidth Test Benchmark', sub: 'High-speed line-rate throughput benchmark, token-bucket rate limiting, loss & jitter telemetry' },
     tools: { title: 'Diagnostic & Troubleshooting Tools', sub: 'Network latency analysis, ICMP/MTR telemetry, path tracing, DNS resolution and TCP probing' },
     persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' }
   };
@@ -181,6 +182,7 @@ function navigate(tabName) {
   if (tabName === 'dhcp-client') loadDHCPClients();
   if (tabName === 'dhcp-server') loadDHCPServer();
   if (tabName === 'vrrp') loadVRRP();
+  if (tabName === 'bwtest') loadBwTestView();
   setTimeout(initTableSorting, 50);
 }
 
@@ -268,6 +270,7 @@ async function fetchData(isManual = false) {
     if (state.activeTab === 'dhcp-client') loadDHCPClients(false);
     if (state.activeTab === 'dhcp-server') loadDHCPServer(false);
     if (state.activeTab === 'vrrp') loadVRRP(false);
+    fetchBwTestServerStatus();
 
   } catch (err) {
     console.error('Failed to fetch router state:', err);
@@ -13884,4 +13887,568 @@ if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined')
   docTableObserver.observe(document.body || document.documentElement, { childList: true, subtree: true });
 }
 
+// =========================================================================
+// BANDWIDTH TEST (BwTest) BENCHMARK ENGINE
+// =========================================================================
+
+const bwtestState = {
+  running: false,
+  pollTimer: null,
+  eventSource: null,
+  chartPoints: [],
+  maxChartPoints: 60,
+  serverConfig: null,
+  peakTx: 0,
+  peakRx: 0,
+  targetDuration: 10,
+  lastSampleIndex: 0
+};
+
+async function loadBwTestView() {
+  fetchBwTestServerStatus();
+  loadBwTestHistory();
+  resizeBwTestCanvas();
+  checkActiveBwTest();
+}
+
+async function fetchBwTestServerStatus() {
+  try {
+    const res = await fetch(`${API_BASE}/tools/bwtest/server`);
+    if (!res.ok) return;
+    const s = await res.json();
+    bwtestState.serverConfig = s;
+
+    // Update indicator and badges
+    const indicator = document.getElementById('bwtest-server-indicator');
+    const badge = document.getElementById('bwtest-server-badge');
+    const sideBadge = document.getElementById('badge-bwtest-server');
+
+    if (s.enabled && s.running) {
+      if (indicator) indicator.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse';
+      if (badge) {
+        badge.innerText = `Port ${s.port} (Active)`;
+        badge.className = 'text-emerald-400 font-bold';
+      }
+      if (sideBadge) {
+        sideBadge.innerText = s.port;
+        sideBadge.className = 'text-[10px] bg-emerald-950 text-emerald-300 border border-emerald-800/40 px-1.5 py-0.2 rounded-full font-mono';
+      }
+    } else {
+      if (indicator) indicator.className = 'w-2 h-2 rounded-full bg-slate-600';
+      if (badge) {
+        badge.innerText = `Disabled`;
+        badge.className = 'text-slate-500 font-bold';
+      }
+      if (sideBadge) {
+        sideBadge.innerText = 'Off';
+        sideBadge.className = 'text-[10px] bg-slate-800 text-slate-500 border border-slate-700 px-1.5 py-0.2 rounded-full font-mono';
+      }
+    }
+
+    // Modal fields
+    const toggle = document.getElementById('bwtest-server-enable-toggle');
+    if (toggle) toggle.checked = !!s.enabled;
+    const portInp = document.getElementById('bwtest-server-port-input');
+    if (portInp) portInp.value = s.port || 2000;
+    const actSess = document.getElementById('bwtest-modal-active-sessions');
+    if (actSess) actSess.innerText = s.active_sessions || 0;
+    const totServ = document.getElementById('bwtest-modal-total-served');
+    if (totServ) totServ.innerText = s.total_sessions_served || 0;
+    const totBytes = document.getElementById('bwtest-modal-total-bytes');
+    if (totBytes) totBytes.innerText = formatBytes(s.total_bytes_processed || 0);
+
+  } catch (err) {
+    console.error('Failed to fetch BwTest server status:', err);
+  }
+}
+
+function openBwTestServerModal() {
+  fetchBwTestServerStatus();
+  document.getElementById('modal-bwtest-server')?.classList.remove('hidden');
+}
+
+function closeBwTestServerModal() {
+  document.getElementById('modal-bwtest-server')?.classList.add('hidden');
+}
+
+async function submitBwTestServerSettings(e) {
+  e.preventDefault();
+  const enabled = document.getElementById('bwtest-server-enable-toggle').checked;
+  const port = parseInt(document.getElementById('bwtest-server-port-input').value, 10) || 2000;
+
+  try {
+    const res = await fetch(`${API_BASE}/tools/bwtest/server`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ enabled, port, listen_addr: '0.0.0.0', allow_anonymous: true })
+    });
+    if (!res.ok) {
+      const err = await res.json();
+      throw new Error(err.error || 'Failed to update server configuration');
+    }
+    showToast('BwTest Server', `Bandwidth Test server updated (Port ${port})`, 'success');
+    closeBwTestServerModal();
+    fetchBwTestServerStatus();
+  } catch (err) {
+    showToast('Server Configuration Error', err.message, 'error');
+  }
+}
+
+function setBwTestTarget(target) {
+  const inp = document.getElementById('bwtest-input-target');
+  if (inp) inp.value = target;
+}
+
+function setBwTestDirection(dir) {
+  const hiddenInp = document.getElementById('bwtest-input-direction');
+  if (hiddenInp) hiddenInp.value = dir;
+
+  const btnBoth = document.getElementById('bwtest-dir-both');
+  const btnTx = document.getElementById('bwtest-dir-tx');
+  const btnRx = document.getElementById('bwtest-dir-rx');
+
+  const activeCls = 'py-2 px-2 rounded-lg text-xs font-bold border transition text-center bg-emerald-600/20 border-emerald-500/50 text-emerald-300 shadow-sm';
+  const inactiveCls = 'py-2 px-2 rounded-lg text-xs font-medium border border-slate-800 bg-slate-950 text-slate-400 hover:text-white transition text-center';
+
+  if (btnBoth) btnBoth.className = dir === 'both' ? activeCls : inactiveCls;
+  if (btnTx) btnTx.className = dir === 'tx' ? activeCls : inactiveCls;
+  if (btnRx) btnRx.className = dir === 'rx' ? activeCls : inactiveCls;
+}
+
+function setBwTestLimit(bps) {
+  const sel = document.getElementById('bwtest-input-limit');
+  if (sel) sel.value = String(bps);
+}
+
+async function checkActiveBwTest() {
+  try {
+    const res = await fetch(`${API_BASE}/tools/bwtest/status`);
+    if (!res.ok) return;
+    const st = await res.json();
+    if (st.running) {
+      setBwTestRunningUI(true);
+      if (st.current_sample) handleBwTestSample(st.current_sample);
+      startBwTestTelemetry();
+    }
+  } catch (err) {
+    console.error('Failed to check active BwTest:', err);
+  }
+}
+
+async function startBwTest(e) {
+  if (e && e.preventDefault) e.preventDefault();
+
+  const target = document.getElementById('bwtest-input-target').value.trim() || '127.0.0.1';
+  const port = parseInt(document.getElementById('bwtest-input-port').value, 10) || 2000;
+  const direction = document.getElementById('bwtest-input-direction').value || 'both';
+  const protocol = document.getElementById('bwtest-input-protocol').value || 'udp';
+  const bandwidth_limit = parseInt(document.getElementById('bwtest-input-limit').value, 10) || 0;
+  const packet_size = parseInt(document.getElementById('bwtest-input-pktsize').value, 10) || 1472;
+  const streams = parseInt(document.getElementById('bwtest-input-streams').value, 10) || 1;
+  const duration = parseInt(document.getElementById('bwtest-input-duration').value, 10);
+
+  bwtestState.targetDuration = duration;
+  bwtestState.chartPoints = [];
+  bwtestState.peakTx = 0;
+  bwtestState.peakRx = 0;
+  bwtestState.lastSampleIndex = 0;
+
+  // Clear UI elements
+  document.getElementById('bwtest-stat-tx-mbps').innerText = '0.00';
+  document.getElementById('bwtest-stat-tx-peak').innerText = '0.00 Mbps';
+  document.getElementById('bwtest-stat-tx-pkts').innerText = '0';
+  document.getElementById('bwtest-stat-rx-mbps').innerText = '0.00';
+  document.getElementById('bwtest-stat-rx-peak').innerText = '0.00 Mbps';
+  document.getElementById('bwtest-stat-rx-pkts').innerText = '0';
+  document.getElementById('bwtest-stat-loss').innerText = '0.0%';
+  document.getElementById('bwtest-stat-jitter').innerText = '0.00 ms';
+  document.getElementById('bwtest-stat-time').innerText = '0.0s';
+  document.getElementById('bwtest-stat-data').innerText = '0.0 MB';
+  document.getElementById('bwtest-stat-target-dur').innerText = duration > 0 ? `/ ${duration}s` : '/ ∞';
+
+  const tbody = document.getElementById('bwtest-samples-tbody');
+  if (tbody) tbody.innerHTML = '';
+  document.getElementById('bwtest-sample-count').innerText = '0 samples';
+
+  setBwTestRunningUI(true);
+
+  try {
+    const res = await fetch(`${API_BASE}/tools/bwtest/start`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        target,
+        port,
+        direction,
+        protocol,
+        bandwidth_limit,
+        packet_size,
+        streams,
+        duration
+      })
+    });
+
+    if (!res.ok) {
+      const err = await res.json();
+      throw new Error(err.error || 'Failed to start bandwidth test');
+    }
+
+    showToast('Benchmark Started', `Target: ${target}:${port} (${direction.toUpperCase()}, ${protocol.toUpperCase()})`, 'info');
+    startBwTestTelemetry();
+  } catch (err) {
+    setBwTestRunningUI(false);
+    showToast('Start Failed', err.message, 'error');
+  }
+}
+
+function setBwTestRunningUI(running) {
+  bwtestState.running = running;
+  const startBtn = document.getElementById('bwtest-btn-start');
+  const stopBtn = document.getElementById('bwtest-btn-stop');
+  const badge = document.getElementById('bwtest-run-status-badge');
+  const ph = document.getElementById('bwtest-chart-placeholder');
+
+  if (running) {
+    if (startBtn) {
+      startBtn.disabled = true;
+      startBtn.classList.add('opacity-50', 'cursor-not-allowed');
+    }
+    if (stopBtn) {
+      stopBtn.disabled = false;
+      stopBtn.classList.remove('opacity-40');
+    }
+    if (badge) {
+      badge.innerText = 'TESTING...';
+      badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-bold animate-pulse';
+    }
+    if (ph) ph.style.display = 'none';
+  } else {
+    if (startBtn) {
+      startBtn.disabled = false;
+      startBtn.classList.remove('opacity-50', 'cursor-not-allowed');
+    }
+    if (stopBtn) {
+      stopBtn.disabled = true;
+      stopBtn.classList.add('opacity-40');
+    }
+    if (badge) {
+      badge.innerText = 'IDLE';
+      badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono bg-slate-800 text-slate-400 border border-slate-700 font-semibold';
+    }
+  }
+}
+
+async function stopBwTest() {
+  try {
+    await fetch(`${API_BASE}/tools/bwtest/stop`, { method: 'POST' });
+    showToast('Test Stopped', 'Bandwidth test terminated by user', 'info');
+  } catch (err) {
+    console.error('Failed to stop test:', err);
+  }
+}
+
+function startBwTestTelemetry() {
+  if (bwtestState.pollTimer) clearInterval(bwtestState.pollTimer);
+
+  bwtestState.pollTimer = setInterval(async () => {
+    try {
+      const res = await fetch(`${API_BASE}/tools/bwtest/status`);
+      if (!res.ok) return;
+      const st = await res.json();
+
+      if (st.recent_samples && st.recent_samples.length > 0) {
+        // Process new samples
+        const newSamples = st.recent_samples.slice(bwtestState.lastSampleIndex);
+        newSamples.forEach(s => handleBwTestSample(s));
+        bwtestState.lastSampleIndex = st.recent_samples.length;
+      }
+
+      if (!st.running) {
+        clearInterval(bwtestState.pollTimer);
+        bwtestState.pollTimer = null;
+        setBwTestRunningUI(false);
+        handleBwTestFinish(st.summary, st.error);
+      }
+    } catch (err) {
+      console.error('BwTest polling error:', err);
+    }
+  }, 400);
+}
+
+function handleBwTestSample(s) {
+  if (!s) return;
+
+  const txMbps = s.tx_mbps || 0;
+  const rxMbps = s.rx_mbps || 0;
+
+  if (txMbps > bwtestState.peakTx) bwtestState.peakTx = txMbps;
+  if (rxMbps > bwtestState.peakRx) bwtestState.peakRx = rxMbps;
+
+  // KPI updates
+  const elTx = document.getElementById('bwtest-stat-tx-mbps');
+  if (elTx) elTx.innerText = txMbps.toFixed(2);
+  const elTxPeak = document.getElementById('bwtest-stat-tx-peak');
+  if (elTxPeak) elTxPeak.innerText = `${bwtestState.peakTx.toFixed(2)} Mbps`;
+  const elTxPkts = document.getElementById('bwtest-stat-tx-pkts');
+  if (elTxPkts) elTxPkts.innerText = s.tx_packets || 0;
+
+  const elRx = document.getElementById('bwtest-stat-rx-mbps');
+  if (elRx) elRx.innerText = rxMbps.toFixed(2);
+  const elRxPeak = document.getElementById('bwtest-stat-rx-peak');
+  if (elRxPeak) elRxPeak.innerText = `${bwtestState.peakRx.toFixed(2)} Mbps`;
+  const elRxPkts = document.getElementById('bwtest-stat-rx-pkts');
+  if (elRxPkts) elRxPkts.innerText = s.rx_packets || 0;
+
+  const elLoss = document.getElementById('bwtest-stat-loss');
+  if (elLoss) {
+    elLoss.innerText = `${(s.loss_pct || 0).toFixed(1)}%`;
+    elLoss.className = (s.loss_pct > 1.0) ? 'text-2xl font-bold text-rose-400' : 'text-2xl font-bold text-emerald-400';
+  }
+  const elLostPkts = document.getElementById('bwtest-stat-lost-pkts');
+  if (elLostPkts) elLostPkts.innerText = s.lost_packets || 0;
+
+  const elJitter = document.getElementById('bwtest-stat-jitter');
+  if (elJitter) elJitter.innerText = `${(s.jitter_ms || 0).toFixed(2)} ms`;
+
+  const elTime = document.getElementById('bwtest-stat-time');
+  if (elTime) elTime.innerText = `${(s.elapsed_sec || 0).toFixed(1)}s`;
+
+  const elData = document.getElementById('bwtest-stat-data');
+  if (elData) elData.innerText = formatBytes((s.tx_bytes || 0) + (s.rx_bytes || 0));
+
+  const elGraphRate = document.getElementById('bwtest-graph-rate');
+  if (elGraphRate) elGraphRate.innerText = `TX: ${txMbps.toFixed(1)} Mbps • RX: ${rxMbps.toFixed(1)} Mbps`;
+
+  // Append point to chart buffer
+  bwtestState.chartPoints.push({
+    elapsed: s.elapsed_sec || 0,
+    tx: txMbps,
+    rx: rxMbps
+  });
+  if (bwtestState.chartPoints.length > bwtestState.maxChartPoints) {
+    bwtestState.chartPoints.shift();
+  }
+  drawBwTestChart();
+
+  // Add row to breakdown table
+  const tbody = document.getElementById('bwtest-samples-tbody');
+  if (tbody) {
+    const tr = document.createElement('tr');
+    tr.className = 'hover:bg-slate-800/40 transition font-mono text-xs';
+    tr.innerHTML = `
+      <td class="py-1.5 px-3 font-semibold text-white">${(s.elapsed_sec || 0).toFixed(1)}s</td>
+      <td class="py-1.5 px-3 font-bold text-cyan-400">${txMbps.toFixed(2)} Mbps</td>
+      <td class="py-1.5 px-3 font-bold text-purple-400">${rxMbps.toFixed(2)} Mbps</td>
+      <td class="py-1.5 px-3 ${(s.loss_pct > 1.0) ? 'text-rose-400 font-bold' : 'text-emerald-400'}">${(s.loss_pct || 0).toFixed(1)}%</td>
+      <td class="py-1.5 px-3 text-slate-300">${(s.jitter_ms || 0).toFixed(2)} ms</td>
+      <td class="py-1.5 px-3 text-slate-400">${formatBytes((s.tx_bytes || 0) + (s.rx_bytes || 0))}</td>
+    `;
+    tbody.insertBefore(tr, tbody.firstChild);
+
+    const countEl = document.getElementById('bwtest-sample-count');
+    if (countEl) countEl.innerText = `${tbody.children.length} samples`;
+  }
+}
+
+function handleBwTestFinish(summary, error) {
+  const badge = document.getElementById('bwtest-run-status-badge');
+  if (error) {
+    showToast('Benchmark Error', error, 'error');
+    if (badge) {
+      badge.innerText = 'ERROR';
+      badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono bg-rose-500/20 text-rose-300 border border-rose-500/40 font-bold';
+    }
+  } else if (summary) {
+    const avgTx = summary.avg_tx_mbps.toFixed(1);
+    const avgRx = summary.avg_rx_mbps.toFixed(1);
+    showToast('Benchmark Complete', `Average TX: ${avgTx} Mbps | RX: ${avgRx} Mbps (Loss: ${summary.avg_loss_pct.toFixed(1)}%)`, 'success');
+    if (badge) {
+      badge.innerText = 'COMPLETED';
+      badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-bold';
+    }
+  }
+  loadBwTestHistory();
+  fetchBwTestServerStatus();
+}
+
+function resizeBwTestCanvas() {
+  const canvas = document.getElementById('bwtest-chart');
+  if (!canvas || !canvas.parentElement) return;
+  const rect = canvas.parentElement.getBoundingClientRect();
+  const dpr = window.devicePixelRatio || 1;
+  canvas.width = rect.width * dpr;
+  canvas.height = rect.height * dpr;
+  const ctx = canvas.getContext('2d');
+  ctx.scale(dpr, dpr);
+  drawBwTestChart();
+}
+
+window.addEventListener('resize', () => {
+  if (state.activeTab === 'bwtest') resizeBwTestCanvas();
+});
+
+function drawBwTestChart() {
+  const canvas = document.getElementById('bwtest-chart');
+  if (!canvas) return;
+  const ctx = canvas.getContext('2d');
+  const dpr = window.devicePixelRatio || 1;
+  const width = canvas.width / dpr;
+  const height = canvas.height / dpr;
+
+  ctx.clearRect(0, 0, width, height);
+
+  const points = bwtestState.chartPoints;
+  if (!points || points.length === 0) return;
+
+  // Determine scale ceiling
+  let maxRate = 10;
+  for (const p of points) {
+    if (p.tx > maxRate) maxRate = p.tx;
+    if (p.rx > maxRate) maxRate = p.rx;
+  }
+  maxRate = Math.ceil(maxRate * 1.25); // 25% headroom
+
+  const paddingLeft = 55;
+  const paddingBottom = 24;
+  const paddingTop = 12;
+  const paddingRight = 16;
+  const chartW = width - paddingLeft - paddingRight;
+  const chartH = height - paddingTop - paddingBottom;
+
+  // Grid Lines and Y-Axis labels
+  ctx.strokeStyle = 'rgba(51, 65, 85, 0.4)';
+  ctx.lineWidth = 1;
+  ctx.fillStyle = '#94a3b8';
+  ctx.font = '10px monospace';
+  ctx.textAlign = 'right';
+
+  const gridSteps = 4;
+  for (let i = 0; i <= gridSteps; i++) {
+    const val = (maxRate / gridSteps) * i;
+    const y = paddingTop + chartH - (i / gridSteps) * chartH;
+    ctx.beginPath();
+    ctx.moveTo(paddingLeft, y);
+    ctx.lineTo(width - paddingRight, y);
+    ctx.stroke();
+    ctx.fillText(`${Math.round(val)}M`, paddingLeft - 8, y + 3);
+  }
+
+  // Draw TX Line (Cyan #22d3ee)
+  drawSeries(ctx, points, p => p.tx, maxRate, paddingLeft, paddingTop, chartW, chartH, '#22d3ee', 'rgba(34, 211, 238, 0.15)');
+
+  // Draw RX Line (Purple #a855f7)
+  drawSeries(ctx, points, p => p.rx, maxRate, paddingLeft, paddingTop, chartW, chartH, '#a855f7', 'rgba(168, 85, 247, 0.15)');
+}
+
+function drawSeries(ctx, points, valFn, maxVal, left, top, w, h, strokeColor, fillColor) {
+  if (points.length < 2) return;
+
+  const stepX = w / Math.max(points.length - 1, 1);
+
+  // Gradient area fill
+  const grad = ctx.createLinearGradient(0, top, 0, top + h);
+  grad.addColorStop(0, fillColor);
+  grad.addColorStop(1, 'rgba(0, 0, 0, 0)');
+
+  ctx.beginPath();
+  points.forEach((p, idx) => {
+    const x = left + idx * stepX;
+    const val = valFn(p);
+    const y = top + h - (val / maxVal) * h;
+    if (idx === 0) {
+      ctx.moveTo(x, y);
+    } else {
+      ctx.lineTo(x, y);
+    }
+  });
+
+  // Complete path for fill
+  ctx.lineTo(left + (points.length - 1) * stepX, top + h);
+  ctx.lineTo(left, top + h);
+  ctx.closePath();
+  ctx.fillStyle = grad;
+  ctx.fill();
+
+  // Draw line stroke
+  ctx.beginPath();
+  ctx.lineWidth = 2.5;
+  ctx.strokeStyle = strokeColor;
+  points.forEach((p, idx) => {
+    const x = left + idx * stepX;
+    const val = valFn(p);
+    const y = top + h - (val / maxVal) * h;
+    if (idx === 0) {
+      ctx.moveTo(x, y);
+    } else {
+      ctx.lineTo(x, y);
+    }
+  });
+  ctx.stroke();
+
+  // Draw end point halo
+  const lastP = points[points.length - 1];
+  const lastX = left + (points.length - 1) * stepX;
+  const lastY = top + h - (valFn(lastP) / maxVal) * h;
+
+  ctx.fillStyle = strokeColor;
+  ctx.beginPath();
+  ctx.arc(lastX, lastY, 4, 0, 2 * Math.PI);
+  ctx.fill();
+}
+
+async function loadBwTestHistory() {
+  try {
+    const res = await fetch(`${API_BASE}/tools/bwtest/history`);
+    if (!res.ok) return;
+    const list = await res.json();
+    renderBwTestHistory(Array.isArray(list) ? list : []);
+  } catch (err) {
+    console.error('Failed to load BwTest history:', err);
+  }
+}
+
+function renderBwTestHistory(list) {
+  const tbody = document.getElementById('bwtest-history-tbody');
+  if (!tbody) return;
+
+  if (list.length === 0) {
+    tbody.innerHTML = `<tr><td colspan="10" class="p-6 text-center text-slate-500 text-xs font-sans">No past benchmark runs recorded yet.</td></tr>`;
+    return;
+  }
+
+  tbody.innerHTML = '';
+  list.forEach(item => {
+    const s = item.summary || {};
+    const tr = document.createElement('tr');
+    tr.className = 'hover:bg-slate-800/40 transition font-mono text-xs';
+
+    const dirBadge = item.direction === 'both'
+      ? `<span class="px-1.5 py-0.5 rounded text-[10px] bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 uppercase font-semibold">Both</span>`
+      : item.direction === 'tx'
+        ? `<span class="px-1.5 py-0.5 rounded text-[10px] bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 uppercase font-semibold">TX Only</span>`
+        : `<span class="px-1.5 py-0.5 rounded text-[10px] bg-purple-500/20 text-purple-300 border border-purple-500/30 uppercase font-semibold">RX Only</span>`;
+
+    const protoBadge = item.protocol === 'tcp'
+      ? `<span class="px-1.5 py-0.5 rounded text-[10px] bg-blue-500/20 text-blue-300 border border-blue-500/30 font-semibold uppercase">TCP</span>`
+      : `<span class="px-1.5 py-0.5 rounded text-[10px] bg-amber-500/20 text-amber-300 border border-amber-500/30 font-semibold uppercase">UDP</span>`;
+
+    const dateStr = item.timestamp ? new Date(item.timestamp).toLocaleString() : '-';
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-4 font-bold text-white font-mono text-[11px]">${escapeHtml(item.id || '-')}</td>
+      <td class="py-2.5 px-4 text-emerald-400 font-mono font-medium">${escapeHtml(item.target || '-')}</td>
+      <td class="py-2.5 px-4">${protoBadge}</td>
+      <td class="py-2.5 px-4">${dirBadge}</td>
+      <td class="py-2.5 px-4 text-slate-300">${(s.duration_sec || 0).toFixed(1)}s</td>
+      <td class="py-2.5 px-4 font-bold text-cyan-400 font-mono">${(s.avg_tx_mbps || 0).toFixed(1)} / ${(s.peak_tx_mbps || 0).toFixed(1)} <span class="text-[10px] text-slate-400 font-normal">Mbps</span></td>
+      <td class="py-2.5 px-4 font-bold text-purple-400 font-mono">${(s.avg_rx_mbps || 0).toFixed(1)} / ${(s.peak_rx_mbps || 0).toFixed(1)} <span class="text-[10px] text-slate-400 font-normal">Mbps</span></td>
+      <td class="py-2.5 px-4 ${(s.avg_loss_pct > 1.0) ? 'text-rose-400 font-bold' : 'text-emerald-400'} font-mono">${(s.avg_loss_pct || 0).toFixed(1)}% <span class="text-[10px] text-slate-400 font-normal">(${s.total_lost_packets || 0})</span></td>
+      <td class="py-2.5 px-4 text-slate-300 font-mono">${(s.avg_jitter_ms || 0).toFixed(2)} ms</td>
+      <td class="py-2.5 px-4 text-slate-400 text-[11px] font-sans">${escapeHtml(dateStr)}</td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+
 

+ 375 - 1
internal/web/static/index.html

@@ -325,6 +325,20 @@
           <span class="text-[10px] bg-amber-950 text-amber-300 border border-amber-800/40 px-1.5 py-0.2 rounded-full font-mono">Tools</span>
         </a>
 
+        <a href="#bwtest" onclick="navigate('bwtest')" id="nav-bwtest"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
+            </svg>
+            <span class="flex items-center gap-1.5">
+              <span>Bandwidth Test</span>
+              <span class="text-[9px] px-1 py-0.2 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-semibold font-mono">BwTest</span>
+            </span>
+          </div>
+          <span id="badge-bwtest-server" class="text-[10px] bg-emerald-950 text-emerald-300 border border-emerald-800/40 px-1.5 py-0.2 rounded-full font-mono">2000</span>
+        </a>
+
         <!-- CATEGORY: SYSTEM -->
         <div class="px-3 pt-3 pb-1 text-[10px] font-bold text-slate-500 uppercase tracking-wider">System</div>
 
@@ -3731,6 +3745,326 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: BANDWIDTH TEST (BwTest) ================= -->
+        <section id="section-bwtest" class="hidden space-y-6">
+          <!-- Top Banner -->
+          <div class="bg-gradient-to-r from-emerald-950/50 via-slate-900 to-slate-900 border border-emerald-800/40 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
+            <div>
+              <div class="flex items-center gap-2 mb-1">
+                <span class="px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 tracking-wide uppercase">Throughput Benchmark</span>
+                <h2 class="text-base font-bold text-white flex items-center gap-2">
+                  <span>Bandwidth Test Engine</span>
+                  <span class="px-2 py-0.5 text-[10px] rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-mono font-normal">UDP & TCP Line-Rate</span>
+                </h2>
+              </div>
+              <p class="text-xs text-slate-400">High-performance network throughput benchmark with line-rate packet generation, token-bucket pacing, sliding-window RFC 3550 jitter telemetry, and bidirectional stream testing.</p>
+            </div>
+
+            <!-- Server Status & Controls -->
+            <div class="flex items-center gap-3">
+              <div class="flex items-center gap-2 bg-slate-950/80 px-3 py-1.5 rounded-lg border border-slate-800 text-xs font-mono">
+                <span id="bwtest-server-indicator" class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
+                <span class="text-slate-400">Server:</span>
+                <span id="bwtest-server-badge" class="text-emerald-400 font-bold">Port 2000 (Active)</span>
+              </div>
+              <button onclick="openBwTestServerModal()" class="text-xs font-semibold px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition flex items-center gap-1.5">
+                <svg class="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/></svg>
+                <span>Server Settings</span>
+              </button>
+            </div>
+          </div>
+
+          <!-- 4 Top KPI Cards (Live Gauges) -->
+          <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
+            <!-- TX Card -->
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-4 flex items-center justify-between">
+              <div>
+                <div class="text-[11px] font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
+                  <span class="w-2 h-2 rounded-full bg-cyan-400"></span>
+                  <span>Upload (TX)</span>
+                </div>
+                <div class="flex items-baseline gap-1 mt-1 font-mono">
+                  <span id="bwtest-stat-tx-mbps" class="text-2xl font-bold text-cyan-400">0.00</span>
+                  <span class="text-xs text-slate-400">Mbps</span>
+                </div>
+                <div class="text-[10px] text-slate-500 mt-0.5 font-mono">
+                  Peak: <span id="bwtest-stat-tx-peak" class="text-cyan-300 font-semibold">0.00 Mbps</span> • <span id="bwtest-stat-tx-pkts">0</span> pkts
+                </div>
+              </div>
+              <div class="w-10 h-10 rounded-xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
+                <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/></svg>
+              </div>
+            </div>
+
+            <!-- RX Card -->
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-4 flex items-center justify-between">
+              <div>
+                <div class="text-[11px] font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
+                  <span class="w-2 h-2 rounded-full bg-purple-400"></span>
+                  <span>Download (RX)</span>
+                </div>
+                <div class="flex items-baseline gap-1 mt-1 font-mono">
+                  <span id="bwtest-stat-rx-mbps" class="text-2xl font-bold text-purple-400">0.00</span>
+                  <span class="text-xs text-slate-400">Mbps</span>
+                </div>
+                <div class="text-[10px] text-slate-500 mt-0.5 font-mono">
+                  Peak: <span id="bwtest-stat-rx-peak" class="text-purple-300 font-semibold">0.00 Mbps</span> • <span id="bwtest-stat-rx-pkts">0</span> pkts
+                </div>
+              </div>
+              <div class="w-10 h-10 rounded-xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
+                <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"/></svg>
+              </div>
+            </div>
+
+            <!-- Packet Loss & Jitter -->
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-4 flex items-center justify-between">
+              <div>
+                <div class="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">Loss & Jitter</div>
+                <div class="flex items-baseline gap-2 mt-1 font-mono">
+                  <span id="bwtest-stat-loss" class="text-2xl font-bold text-emerald-400">0.0%</span>
+                  <span class="text-xs text-slate-400">(0 lost)</span>
+                </div>
+                <div class="text-[10px] text-slate-500 mt-0.5 font-mono">
+                  Jitter: <span id="bwtest-stat-jitter" class="text-slate-300 font-semibold">0.00 ms</span>
+                </div>
+              </div>
+              <div class="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400">
+                <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
+              </div>
+            </div>
+
+            <!-- Time & Transferred Data -->
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-4 flex items-center justify-between">
+              <div>
+                <div class="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">Elapsed & Volume</div>
+                <div class="flex items-baseline gap-1 mt-1 font-mono">
+                  <span id="bwtest-stat-time" class="text-2xl font-bold text-white">0.0s</span>
+                  <span id="bwtest-stat-target-dur" class="text-xs text-slate-400">/ 10s</span>
+                </div>
+                <div class="text-[10px] text-slate-500 mt-0.5 font-mono">
+                  Data: <span id="bwtest-stat-data" class="text-slate-300 font-semibold">0.0 MB</span>
+                </div>
+              </div>
+              <div class="w-10 h-10 rounded-xl bg-slate-800 border border-slate-700 flex items-center justify-center text-slate-300">
+                <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
+              </div>
+            </div>
+          </div>
+
+          <!-- Main Grid: Benchmark Controls + Live Telemetry Graph -->
+          <div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
+            <!-- Left Panel: Test Setup Form (5 cols) -->
+            <div class="lg:col-span-5 bg-slate-900/90 border border-slate-800 rounded-xl p-5 space-y-4">
+              <div class="border-b border-slate-800 pb-3 flex items-center justify-between">
+                <h3 class="text-sm font-bold text-white flex items-center gap-2">
+                  <span>Test Parameters</span>
+                </h3>
+                <span id="bwtest-run-status-badge" class="px-2 py-0.5 rounded text-[10px] font-mono bg-slate-800 text-slate-400 border border-slate-700 font-semibold">IDLE</span>
+              </div>
+
+              <form id="bwtest-form" onsubmit="startBwTest(event)" class="space-y-4">
+                <!-- Target Host & Port -->
+                <div>
+                  <div class="flex justify-between items-center mb-1">
+                    <label class="block text-xs font-semibold text-slate-300">Target IP or Hostname</label>
+                    <div class="flex gap-1 text-[10px] font-mono">
+                      <button type="button" onclick="setBwTestTarget('127.0.0.1')" class="px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700 text-cyan-400 rounded transition border border-slate-700">127.0.0.1</button>
+                      <button type="button" onclick="setBwTestTarget('192.168.131.56')" class="px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700 text-emerald-400 rounded transition border border-slate-700">eth0</button>
+                    </div>
+                  </div>
+                  <div class="grid grid-cols-3 gap-2">
+                    <div class="col-span-2">
+                      <input type="text" id="bwtest-input-target" required value="127.0.0.1" placeholder="e.g. 192.168.88.1 or router.lan" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-emerald-500">
+                    </div>
+                    <div>
+                      <input type="number" id="bwtest-input-port" required value="2000" min="1" max="65535" placeholder="Port" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-emerald-500" title="Control Port (default 2000)">
+                    </div>
+                  </div>
+                </div>
+
+                <!-- Direction (Both / TX / RX) -->
+                <div>
+                  <label class="block text-xs font-semibold text-slate-300 mb-1.5">Traffic Direction</label>
+                  <div class="grid grid-cols-3 gap-2">
+                    <button type="button" id="bwtest-dir-both" onclick="setBwTestDirection('both')" class="py-2 px-2 rounded-lg text-xs font-bold border transition text-center bg-emerald-600/20 border-emerald-500/50 text-emerald-300 shadow-sm">
+                      Both (Full Duplex)
+                    </button>
+                    <button type="button" id="bwtest-dir-tx" onclick="setBwTestDirection('tx')" class="py-2 px-2 rounded-lg text-xs font-medium border border-slate-800 bg-slate-950 text-slate-400 hover:text-white transition text-center">
+                      Transmit (TX)
+                    </button>
+                    <button type="button" id="bwtest-dir-rx" onclick="setBwTestDirection('rx')" class="py-2 px-2 rounded-lg text-xs font-medium border border-slate-800 bg-slate-950 text-slate-400 hover:text-white transition text-center">
+                      Receive (RX)
+                    </button>
+                  </div>
+                  <input type="hidden" id="bwtest-input-direction" value="both">
+                </div>
+
+                <!-- Protocol & Streams -->
+                <div class="grid grid-cols-2 gap-3">
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1">Protocol</label>
+                    <select id="bwtest-input-protocol" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-emerald-500">
+                      <option value="udp" selected>UDP (Loss & Jitter)</option>
+                      <option value="tcp">TCP (Stream)</option>
+                    </select>
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1">Parallel Streams</label>
+                    <select id="bwtest-input-streams" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-emerald-500">
+                      <option value="1" selected>1 Stream</option>
+                      <option value="2">2 Streams</option>
+                      <option value="4">4 Streams</option>
+                      <option value="8">8 Streams</option>
+                    </select>
+                  </div>
+                </div>
+
+                <!-- Bandwidth Limit -->
+                <div>
+                  <div class="flex justify-between items-center mb-1">
+                    <label class="block text-xs font-semibold text-slate-300">Bandwidth Target Limit</label>
+                    <div class="flex gap-1 text-[10px] font-mono">
+                      <button type="button" onclick="setBwTestLimit(0)" class="px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded transition border border-slate-700">Max</button>
+                      <button type="button" onclick="setBwTestLimit(50000000)" class="px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded transition border border-slate-700">50M</button>
+                      <button type="button" onclick="setBwTestLimit(100000000)" class="px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded transition border border-slate-700">100M</button>
+                      <button type="button" onclick="setBwTestLimit(1000000000)" class="px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded transition border border-slate-700">1G</button>
+                    </div>
+                  </div>
+                  <select id="bwtest-input-limit" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-emerald-500">
+                    <option value="0" selected>Unlimited (Line Rate / Maximum)</option>
+                    <option value="10000000">10 Mbps</option>
+                    <option value="50000000">50 Mbps</option>
+                    <option value="100000000">100 Mbps</option>
+                    <option value="250000000">250 Mbps</option>
+                    <option value="500000000">500 Mbps</option>
+                    <option value="1000000000">1 Gbps</option>
+                  </select>
+                </div>
+
+                <!-- Packet Size & Duration -->
+                <div class="grid grid-cols-2 gap-3">
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1">Packet Payload Size</label>
+                    <select id="bwtest-input-pktsize" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-emerald-500">
+                      <option value="1472" selected>1500 (Standard MTU)</option>
+                      <option value="8972">9000 (Jumbo Frame)</option>
+                      <option value="512">512 (Small Payload)</option>
+                      <option value="64">64 (Min VoIP / Small)</option>
+                    </select>
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1">Duration</label>
+                    <select id="bwtest-input-duration" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-emerald-500">
+                      <option value="10" selected>10 Seconds</option>
+                      <option value="30">30 Seconds</option>
+                      <option value="60">60 Seconds</option>
+                      <option value="0">Continuous (Until Stop)</option>
+                    </select>
+                  </div>
+                </div>
+
+                <!-- Action Buttons -->
+                <div class="pt-2 flex items-center gap-3">
+                  <button type="submit" id="bwtest-btn-start" class="flex-1 py-2.5 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs shadow-lg shadow-emerald-500/20 transition flex items-center justify-center gap-2">
+                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
+                    <span>Run Bandwidth Test</span>
+                  </button>
+                  <button type="button" id="bwtest-btn-stop" onclick="stopBwTest()" disabled class="py-2.5 px-4 rounded-lg bg-slate-800 hover:bg-rose-600 text-slate-400 hover:text-white font-bold text-xs transition border border-slate-700 disabled:opacity-40 disabled:hover:bg-slate-800 disabled:hover:text-slate-400 flex items-center justify-center gap-2">
+                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/></svg>
+                    <span>Stop</span>
+                  </button>
+                </div>
+              </form>
+            </div>
+
+            <!-- Right Panel: Real-Time Dynamic Graph & Interval Log (7 cols) -->
+            <div class="lg:col-span-7 space-y-4">
+              <!-- Live Chart Card -->
+              <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5">
+                <div class="flex items-center justify-between mb-3">
+                  <div class="flex items-center gap-3">
+                    <h3 class="text-sm font-bold text-white">Live Real-Time Throughput</h3>
+                    <div class="flex items-center gap-3 text-xs font-mono">
+                      <span class="flex items-center gap-1.5 text-cyan-400"><span class="w-2.5 h-1 bg-cyan-400 rounded-full"></span> TX (Upload)</span>
+                      <span class="flex items-center gap-1.5 text-purple-400"><span class="w-2.5 h-1 bg-purple-400 rounded-full"></span> RX (Download)</span>
+                    </div>
+                  </div>
+                  <span id="bwtest-graph-rate" class="text-xs font-mono text-slate-400">0.00 Mbps total</span>
+                </div>
+                <div class="relative w-full h-52 bg-slate-950 rounded-lg border border-slate-800/80 overflow-hidden">
+                  <canvas id="bwtest-chart" class="w-full h-full block"></canvas>
+                  <div id="bwtest-chart-placeholder" class="absolute inset-0 flex items-center justify-center text-xs text-slate-500 font-sans pointer-events-none">
+                    Start a test to stream live throughput waveform in real time
+                  </div>
+                </div>
+              </div>
+
+              <!-- Second-by-Second Breakdown Table -->
+              <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+                <div class="p-3 border-b border-slate-800 flex items-center justify-between">
+                  <h4 class="text-xs font-bold text-white uppercase tracking-wider">Interval Telemetry Log</h4>
+                  <span id="bwtest-sample-count" class="text-[10px] font-mono text-slate-400">0 samples</span>
+                </div>
+                <div class="overflow-x-auto max-h-48 overflow-y-auto">
+                  <table class="w-full text-left text-xs font-mono">
+                    <thead class="text-slate-400 bg-slate-950/70 border-b border-slate-800 text-[10px] uppercase font-sans sticky top-0">
+                      <tr>
+                        <th class="py-1.5 px-3">Interval</th>
+                        <th class="py-1.5 px-3 text-cyan-400">TX Rate</th>
+                        <th class="py-1.5 px-3 text-purple-400">RX Rate</th>
+                        <th class="py-1.5 px-3">Loss %</th>
+                        <th class="py-1.5 px-3">Jitter</th>
+                        <th class="py-1.5 px-3">Total Volume</th>
+                      </tr>
+                    </thead>
+                    <tbody id="bwtest-samples-tbody" class="divide-y divide-slate-800/60">
+                      <tr>
+                        <td colspan="6" class="p-4 text-center text-slate-500 text-xs font-sans">No test active. Click "Run Bandwidth Test" to benchmark link.</td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <!-- Benchmark History Table -->
+          <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <div>
+                <h3 class="text-sm font-bold text-white">Recent Benchmark History</h3>
+                <p class="text-[11px] text-slate-400 mt-0.5">Summary of previous bandwidth test runs recorded on this router.</p>
+              </div>
+              <button onclick="loadBwTestHistory()" class="text-xs font-semibold px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition">
+                <span>🔄 Refresh</span>
+              </button>
+            </div>
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/70 border-b border-slate-800 text-[10px] uppercase font-sans">
+                  <tr>
+                    <th class="py-2.5 px-4">Session ID</th>
+                    <th class="py-2.5 px-4">Target Server</th>
+                    <th class="py-2.5 px-4">Protocol</th>
+                    <th class="py-2.5 px-4">Direction</th>
+                    <th class="py-2.5 px-4">Duration</th>
+                    <th class="py-2.5 px-4 text-cyan-400">Avg / Peak TX</th>
+                    <th class="py-2.5 px-4 text-purple-400">Avg / Peak RX</th>
+                    <th class="py-2.5 px-4">Packet Loss</th>
+                    <th class="py-2.5 px-4">Avg Jitter</th>
+                    <th class="py-2.5 px-4">Date / Time</th>
+                  </tr>
+                </thead>
+                <tbody id="bwtest-history-tbody" class="divide-y divide-slate-800/80">
+                  <tr>
+                    <td colspan="10" class="p-6 text-center text-slate-500 text-xs font-sans">No past benchmark runs recorded yet.</td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </section>
+
       </main>
     </div>
   </div>
@@ -10366,7 +10700,47 @@
     </div>
   </div>
 
+  <!-- ================= MODAL: BANDWIDTH TEST SERVER SETTINGS ================= -->
+  <div id="modal-bwtest-server" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center hidden p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md overflow-hidden shadow-2xl">
+      <div class="p-5 border-b border-slate-800 flex items-center justify-between">
+        <h3 class="text-base font-bold text-white flex items-center gap-2">
+          <span>⚙ Bandwidth Test Server Settings</span>
+        </h3>
+        <button type="button" onclick="closeBwTestServerModal()" class="text-slate-400 hover:text-white transition">✕</button>
+      </div>
+      <form onsubmit="submitBwTestServerSettings(event)" class="p-6 space-y-4">
+        <div class="bg-slate-950/80 border border-slate-800/80 rounded-xl p-4 space-y-3">
+          <label class="flex items-center justify-between cursor-pointer">
+            <div>
+              <span class="text-xs font-bold text-white block">Enable Bandwidth Test Server</span>
+              <span class="text-[11px] text-slate-400 block mt-0.5">Allows remote routers and clients to run bandwidth tests against this appliance</span>
+            </div>
+            <input type="checkbox" id="bwtest-server-enable-toggle" checked class="w-4 h-4 rounded text-emerald-500 bg-slate-800 border-slate-700">
+          </label>
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Server Listen Port</label>
+          <input type="number" id="bwtest-server-port-input" value="2000" min="1" max="65535" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-emerald-500">
+          <p class="text-[10px] text-slate-500 mt-1">Default port is 2000 (binds TCP control channel and UDP data port).</p>
+        </div>
+
+        <div class="bg-slate-950/50 p-3 rounded-lg border border-slate-800/60 font-mono text-[11px] space-y-1 text-slate-400">
+          <div class="flex justify-between"><span>Active Sessions:</span> <span id="bwtest-modal-active-sessions" class="text-white font-bold">0</span></div>
+          <div class="flex justify-between"><span>Total Served:</span> <span id="bwtest-modal-total-served" class="text-white font-bold">0</span></div>
+          <div class="flex justify-between"><span>Processed Data:</span> <span id="bwtest-modal-total-bytes" class="text-white font-bold">0 B</span></div>
+        </div>
+
+        <div class="flex justify-end gap-3 pt-3">
+          <button type="button" onclick="closeBwTestServerModal()" class="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold transition">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold transition shadow-lg shadow-emerald-500/20">Save Settings</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
   <datalist id="fw-addrlist-datalist"></datalist>
-  <script src="app.js?v=12.0"></script>
+  <script src="app.js?v=13.0"></script>
 </body>
 </html>