Kaynağa Gözat

Add AeroTUN v3, UI and CLI fixes, installation and Git updates

Proxmox Router 4 gün önce
ebeveyn
işleme
c0f3e4b206
61 değiştirilmiş dosya ile 6404 ekleme ve 958 silme
  1. 6 0
      .gitignore
  2. 8 2
      Makefile
  3. 36 75
      README.md
  4. 46 0
      cmd/aerotun-keygen/main.go
  5. 79 62
      cmd/debtik-cli/main.go
  6. 58 0
      cmd/debtik-cli/main_test.go
  7. 1 0
      cmd/server/main.go
  8. 109 0
      docs/aerotun-live-deployment.md
  9. 151 0
      docs/aerotun-v2.md
  10. 192 0
      docs/aerotun-v3.md
  11. 92 0
      docs/bwtest-rx-fix.md
  12. 113 0
      docs/ui-cli-fixes.md
  13. 7 5
      go.mod
  14. 17 0
      go.sum
  15. 42 42
      install.sh
  16. 41 0
      internal/aerotun/benchmark_test.go
  17. 140 0
      internal/aerotun/config.go
  18. 129 27
      internal/aerotun/device.go
  19. 350 0
      internal/aerotun/integration_test.go
  20. 38 0
      internal/aerotun/keys.go
  21. 102 32
      internal/aerotun/manager.go
  22. 62 0
      internal/aerotun/manager_test.go
  23. 77 0
      internal/aerotun/mtu.go
  24. 175 0
      internal/aerotun/protocol.go
  25. 147 0
      internal/aerotun/protocol_test.go
  26. 88 0
      internal/aerotun/routes.go
  27. 86 0
      internal/aerotun/routes_test.go
  28. 372 0
      internal/aerotun/secure_io.go
  29. 540 0
      internal/aerotun/secure_protocol.go
  30. 376 0
      internal/aerotun/secure_protocol_test.go
  31. 285 283
      internal/aerotun/tunnel.go
  32. 38 24
      internal/aerotun/types.go
  33. 32 1
      internal/api/aerotun_handlers.go
  34. 74 0
      internal/api/aerotun_handlers_test.go
  35. 11 3
      internal/api/api.go
  36. 2 1
      internal/api/bwtest_handlers.go
  37. 41 0
      internal/api/management_services_test.go
  38. 172 0
      internal/api/update_handlers.go
  39. 39 0
      internal/api/update_handlers_test.go
  40. 200 108
      internal/bwtest/client.go
  41. 1 1
      internal/bwtest/manager.go
  42. 30 19
      internal/bwtest/protocol.go
  43. 124 64
      internal/bwtest/server.go
  44. 52 0
      internal/bwtest/tcp_frames.go
  45. 149 0
      internal/bwtest/tcp_frames_test.go
  46. 96 0
      internal/bwtest/telemetry_test.go
  47. 21 16
      internal/bwtest/types.go
  48. 162 0
      internal/bwtest/udp_control.go
  49. 86 0
      internal/bwtest/udp_control_test.go
  50. 180 0
      internal/cli/aerotun.go
  51. 58 0
      internal/cli/command.go
  52. 83 0
      internal/cli/command_test.go
  53. 23 0
      internal/cli/completer.go
  54. 53 7
      internal/cli/executor.go
  55. 18 18
      internal/cli/ssh.go
  56. 80 0
      internal/cli/ssh_test.go
  57. 325 138
      internal/web/static/app.js
  58. 57 30
      internal/web/static/index.html
  59. 79 0
      scripts/debtik-update
  60. 49 0
      scripts/ui-regression.test.cjs
  61. 104 0
      scripts/ui-smoke.cjs

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+/bin/
+*.log
+*.tmp
+*.bak
+*.previous
+.env

+ 8 - 2
Makefile

@@ -2,7 +2,7 @@ BINARY_NAME=proxmox-router
 BUILD_DIR=bin
 GO=go
 
-.PHONY: all build clean run install
+.PHONY: all build clean run install test
 
 all: build
 
@@ -10,6 +10,7 @@ build:
 	@echo "Building $(BINARY_NAME)..."
 	@mkdir -p $(BUILD_DIR)
 	CGO_ENABLED=0 $(GO) build -ldflags="-s -w" -o $(BUILD_DIR)/$(BINARY_NAME) cmd/server/main.go
+	CGO_ENABLED=0 $(GO) build -ldflags="-s -w" -o $(BUILD_DIR)/debtik-cli ./cmd/debtik-cli
 	@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
 
 run: build
@@ -18,7 +19,8 @@ run: build
 install: build
 	@echo "Installing $(BINARY_NAME) to /usr/local/bin/..."
 	cp $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/$(BINARY_NAME)
-	chmod +x /usr/local/bin/$(BINARY_NAME)
+	cp $(BUILD_DIR)/debtik-cli /usr/local/bin/debtik-cli
+	chmod +x /usr/local/bin/$(BINARY_NAME) /usr/local/bin/debtik-cli
 	@mkdir -p /etc/proxmox-router /etc/network/interfaces.d
 	cp systemd/proxmox-router.service /etc/systemd/system/proxmox-router.service
 	systemctl daemon-reload
@@ -27,3 +29,7 @@ install: build
 
 clean:
 	rm -rf $(BUILD_DIR)
+
+test:
+	$(GO) test ./...
+	node --test scripts/ui-regression.test.cjs

+ 36 - 75
README.md

@@ -1,86 +1,47 @@
-# ⚡ DebTik Network Appliance Controller
-
-## Overview
-DebTik is a lightweight, open‑source controller for network appliances running on Proxmox. It provides a web UI and CLI to manage BGP, routing, firewall, VPN, DHCP, DNS and other services. All components are designed to run on Debian‑based systems and integrate tightly with the BIRD routing daemon.
-
-## Features
-- **Unified Management** – Control BGP, OSPF, static routes, firewall, VPN, DHCP, DNS from a single interface.
-- **Web UI** – Responsive dashboard (HTTPS support optional, disabled by default).
-- **CLI** – Powerful command line tool (`debtik-cli`) for scripting and automation.
-- **Certificate Management** – Create self‑signed CA, client certificates, and optional Let's Encrypt integration.
-- **Dynamic Routing** – Automatic route updates based on service status.
-- **Extensible Plugins** – Add custom services via the internal API.
-
-## Installation
-### Prerequisites
-- Debian/Ubuntu (or compatible) server
-- `bird` daemon (installed by package manager)
-- `systemd` (for service management)
-- `git` (for source checkout)
-
-### Binary Installation
-```bash
-curl -L -o debtik.tar.gz https://example.com/debtik-latest.tar.gz
-mkdir -p /opt/debtik && tar xzf debtik.tar.gz -C /opt/debtik
-ln -s /opt/debtik/bin/proxmox-router /usr/local/bin/debtik
-```
+# DebTik
+
+DebTik is a Debian/Proxmox router appliance with an embedded web interface, REST API and RouterOS-style CLI. It manages network interfaces, routing, firewall/NAT, DHCP/DNS, WireGuard, IPsec, PPP, VRRP and AeroTUN. AeroTUN v3 uses pinned peer identities and encrypted tunnels; it works with native TUN or a veth fallback when `/dev/net/tun` is unavailable.
+
+## Install
+
+On a Debian 13 host or container with systemd and root access:
 
-### Build from Source
-```bash
-git clone https://github.com/yourorg/DebTik.git
+```sh
+git clone http://gogs.eparmak.local/eparmak/DebTik.git
 cd DebTik
-make build
-sudo cp bin/* /usr/local/bin/
+sudo ./install.sh
 ```
 
-### Systemd Service
-```bash
-sudo cp systemd/proxmox-router.service /etc/systemd/system/
-sudo systemctl daemon-reload
-sudo systemctl enable --now proxmox-router.service
-```
+`install.sh` installs the Debian packages used by the application, including BIRD, WireGuard, firewall, DHCP/DNS, PPP and diagnostic tools. It obtains the Go toolchain specified by `go.mod` when the installed Go is older, builds `proxmox-router` and `debtik-cli`, installs the updater helper, and starts `proxmox-router.service`. It can be run again; existing `/etc/proxmox-router` files and tunnel keys are preserved. Previous binaries are saved as `/usr/local/bin/*.previous`. For native TUN in an LXC container, enable `/dev/net/tun` on the Proxmox host; otherwise AeroTUN uses veth.
 
-## Configuration
-Configuration files are located in `/etc/debtik/`.
-- `debtik.yaml` – Main configuration (service ports, logging, etc.).
-- `bird/` – BIRD routing daemon configuration generated by DebTik.
-- `certs/` – Certificate storage. Use the **Certificates** section in the web UI to generate a CA, self‑signed certificates, or enable Let's Encrypt (requires a public domain and port 80).
-
-### Enabling HTTPS
-HTTPS is disabled by default. To enable it:
-1. Add a certificate via **Certificates → Add → Let's Encrypt** or upload a self‑signed cert.
-2. Edit `debtik.yaml`:
-   ```yaml
-   web:
-     https_enabled: true
-     cert_file: /etc/debtik/certs/fullchain.pem
-     key_file: /etc/debtik/certs/privkey.pem
-   ```
-3. Restart the service:
-   ```bash
-   sudo systemctl restart proxmox-router.service
-   ```
+The web UI and authenticated REST API use HTTP port **80** by default. HTTPS is an optional separate `www-ssl` management service, normally port **443**, configured in **Certificates** and **Management Services**. The application SSH CLI normally listens on **2222**, and Telnet/`debtik-cli` on **8282**. The built-in bandwidth test server uses **2000**. Ports can be changed in the management services UI where available. The main service command is `/usr/local/bin/proxmox-router -addr :80`; `systemd/proxmox-router.service` defines the installed unit.
+
+Configuration is JSON under `/etc/proxmox-router/` (including `services.json` and `vpn/aerotun_instances.json`). AeroTUN secrets and private identities remain on the local appliance. Do not copy one side's private identity to the other.
 
 ## Usage
-### Web UI
-Open `http://<host>:8080` (or `https://<host>:8443` when HTTPS is enabled). The dashboard shows system status, routing tables, VPN connections, and logs.
-
-### CLI (`debtik-cli`)
-```bash
-debtik-cli --help
-debtik-cli bgp peer list
-debtik-cli route add --dest 10.0.0.0/24 --gw 192.168.1.1
+
+Open `http://<appliance-ip>/` and sign in. For the appliance SSH CLI, connect to port 2222. The standalone `debtik-cli` connects to the Telnet CLI by default:
+
+```sh
+debtik-cli -addr 127.0.0.1:8282
+debtik-cli -addr 127.0.0.1:8282 -u admin -p '<password>' -c '/interface aerotun print'
 ```
-Refer to the built‑in help for each sub‑command.
 
-## API
-DebTik exposes a REST API on port `9090` (configurable). Authentication is token‑based.
+Run `help` within the CLI for supported commands. The API is under `/api/` on the web listener and requires an authenticated session except for login. Read-only users can inspect state; configuration changes require elevated roles. AeroTUN operations require the full administrator role.
+
+## Git updates
+
+In **Updates**, enter an HTTP(S) or SSH Git repository and a branch, then save, check the available commit and install. The updater clones into `/var/lib/debtik/update`, builds the server and CLI first, backs up the installed binaries, restarts the service and checks that it remains active. If installation fails, it restores the previous binaries. **Restore previous version** is available in the same menu. Logs and the current state are shown there. Only full administrators can change the source or run update actions. Each appliance updates independently; for paired AeroTUN peers, update one side and verify its connection before updating the other.
+
+`install.sh` must have been run from a Git checkout at least once to install the updater helper. Git access from the appliance to the selected repository and the required Go toolchain are needed. Git credentials belong in the appliance's Git/SSH configuration; repository URLs containing embedded credentials are rejected. For a private Git server whose CA is not trusted by Debian, enter its verified `sha256//...` TLS public key pin in Updates. Rotate the pin when that server changes its TLS key.
 
-## Contributing
-1. Fork the repository.
-2. Create a feature branch.
-3. Ensure code passes `make test`.
-4. Submit a pull request.
+## Development and checks
+
+```sh
+go test ./...
+go vet ./...
+node --test scripts/ui-regression.test.cjs
+make build
+```
 
-## License
-DebTik is released under the **MIT License**.
+The application embeds `internal/web/static`. See [AeroTUN v3](docs/aerotun-v3.md), [AeroTUN v2 compatibility](docs/aerotun-v2.md) and [UI/CLI changes](docs/ui-cli-fixes.md).

+ 46 - 0
cmd/aerotun-keygen/main.go

@@ -0,0 +1,46 @@
+// aerotun-keygen creates an identity on its owner host without printing secrets.
+package main
+
+import (
+	"encoding/json"
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"proxmox-router/internal/aerotun"
+)
+
+func main() {
+	out := flag.String("out", "", "new identity JSON path (created once with mode 0600)")
+	flag.Parse()
+	if *out == "" {
+		flag.Usage()
+		os.Exit(2)
+	}
+	private, public, err := aerotun.GenerateIdentity()
+	if err != nil {
+		log.Fatal(err)
+	}
+	data, err := json.MarshalIndent(map[string]string{"private_key": private, "public_key": public}, "", "  ")
+	if err != nil {
+		log.Fatal(err)
+	}
+	f, err := os.OpenFile(*out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
+	if err != nil {
+		log.Fatal(err)
+	}
+	if _, err = f.Write(data); err != nil {
+		f.Close()
+		os.Remove(*out)
+		log.Fatal(err)
+	}
+	if err = f.Sync(); err != nil {
+		f.Close()
+		os.Remove(*out)
+		log.Fatal(err)
+	}
+	if err = f.Close(); err != nil {
+		log.Fatal(err)
+	}
+	fmt.Println(public)
+}

+ 79 - 62
cmd/debtik-cli/main.go

@@ -2,6 +2,7 @@ package main
 
 import (
 	"bufio"
+	"errors"
 	"flag"
 	"fmt"
 	"io"
@@ -32,93 +33,109 @@ func main() {
 
 	// If one-shot command execution is requested:
 	if *cmdFlag != "" {
-		runOneShot(conn, *username, *password, *cmdFlag)
+		if err := runOneShot(conn, *username, *password, *cmdFlag); err != nil {
+			fmt.Fprintln(os.Stderr, err)
+			os.Exit(1)
+		}
 		return
 	}
 
 	// Interactive Mode
-	runInteractive(conn, *username, *password)
+	if err := runInteractive(conn, *username, *password); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
 }
 
-func runOneShot(conn net.Conn, username, password, command string) {
+func runOneShot(conn net.Conn, username, password, command string) error {
 	reader := bufio.NewReader(conn)
-	// If credentials were provided, handle initial prompt
-	if username != "" && password != "" {
-		_ = autoLogin(conn, reader, username, password)
+	if username == "" || password == "" {
+		return fmt.Errorf("single-command mode requires -u and -p")
 	}
-
-	// Send command followed by quit
-	_, _ = fmt.Fprintf(conn, "%s\nquit\n", command)
-
-	// Read until EOF
-	_, _ = io.Copy(os.Stdout, reader)
+	if err := autoLogin(conn, reader, username, password); err != nil {
+		return err
+	}
+	if _, err := fmt.Fprintf(conn, "%s\nquit\n", command); err != nil {
+		return err
+	}
+	_, err := io.Copy(os.Stdout, reader)
+	return err
 }
 
-func runInteractive(conn net.Conn, username, password string) {
+func runInteractive(conn net.Conn, username, password string) error {
+	reader := bufio.NewReader(conn)
+	if username != "" || password != "" {
+		if username == "" || password == "" {
+			return fmt.Errorf("provide both -u and -p")
+		}
+		if err := autoLogin(conn, reader, username, password); err != nil {
+			return err
+		}
+	}
 	fd := int(os.Stdin.Fd())
-	isTerm := terminal.IsTerminal(fd)
-
-	if isTerm {
+	if terminal.IsTerminal(fd) {
 		oldState, err := terminal.MakeRaw(fd)
-		if err == nil {
-			defer func() {
-				_ = terminal.Restore(fd, oldState)
-			}()
+		if err != nil {
+			return err
 		}
+		defer terminal.Restore(fd, oldState)
 	}
-
-	// Setup clean signal handling to restore terminal on SIGINT/SIGTERM
 	sigChan := make(chan os.Signal, 1)
 	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
-	go func() {
-		<-sigChan
-		if isTerm {
-			_ = terminal.Restore(fd, nil)
-		}
-		os.Exit(0)
-	}()
-
+	defer signal.Stop(sigChan)
 	errChan := make(chan error, 2)
-
-	// Copy from Remote Server -> Local Terminal
-	go func() {
-		_, err := io.Copy(os.Stdout, conn)
-		errChan <- err
-	}()
-
-	// Copy from Local Terminal -> Remote Server
-	go func() {
-		_, err := io.Copy(conn, os.Stdin)
-		errChan <- err
-	}()
-
-	// Wait for connection to close
-	<-errChan
+	go func() { _, err := io.Copy(os.Stdout, reader); errChan <- err }()
+	go func() { _, err := io.Copy(conn, os.Stdin); errChan <- err }()
+	select {
+	case <-sigChan:
+		return nil
+	case err := <-errChan:
+		if errors.Is(err, io.EOF) {
+			return nil
+		}
+		return err
+	}
 }
 
 func autoLogin(conn net.Conn, reader *bufio.Reader, username, password string) error {
-	// Read until "login: "
-	for {
-		line, err := reader.ReadString(':')
-		if err != nil {
-			return err
+	if err := conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
+		return err
+	}
+	defer conn.SetDeadline(time.Time{})
+	for _, prompt := range []struct{ text, reply string }{{"login:", username}, {"password:", password}} {
+		var received strings.Builder
+		for received.Len() < 65536 {
+			ch, err := reader.ReadByte()
+			if err != nil {
+				return fmt.Errorf("authentication failed: %w", err)
+			}
+			received.WriteByte(ch)
+			if strings.HasSuffix(strings.ToLower(received.String()), prompt.text) {
+				if _, err := fmt.Fprintf(conn, "%s\n", prompt.reply); err != nil {
+					return err
+				}
+				break
+			}
 		}
-		if strings.Contains(strings.ToLower(line), "login") {
-			_, _ = fmt.Fprintf(conn, "%s\n", username)
-			break
+		if received.Len() >= 65536 {
+			return fmt.Errorf("invalid login prompt")
 		}
 	}
-
-	// Read until "Password: "
-	for {
-		line, err := reader.ReadString(':')
+	var received strings.Builder
+	for received.Len() < 65536 {
+		ch, err := reader.ReadByte()
 		if err != nil {
-			return err
+			return fmt.Errorf("authentication failed: %w", err)
+		}
+		received.WriteByte(ch)
+		text := received.String()
+		if strings.Contains(strings.ToLower(text), "login failed:") {
+			return fmt.Errorf("authentication rejected")
 		}
-		if strings.Contains(strings.ToLower(line), "password") {
-			_, _ = fmt.Fprintf(conn, "%s\n", password)
-			break
+		if strings.HasSuffix(text, "] > ") {
+			_, err := fmt.Fprint(os.Stdout, text)
+			return err
 		}
 	}
-	return nil
+	return fmt.Errorf("invalid authentication response")
 }

+ 58 - 0
cmd/debtik-cli/main_test.go

@@ -0,0 +1,58 @@
+package main
+
+import (
+	"bufio"
+	"fmt"
+	"net"
+	"testing"
+)
+
+func TestAutomaticLoginWaitsForSuccessAndPreservesBufferedData(t *testing.T) {
+	for _, accepted := range []bool{true, false} {
+		t.Run(fmt.Sprint(accepted), func(t *testing.T) {
+			client, server := net.Pipe()
+			defer client.Close()
+			defer server.Close()
+			done := make(chan error, 1)
+			go func() {
+				r := bufio.NewReader(server)
+				fmt.Fprint(server, "DebTik login: ")
+				user, err := r.ReadString('\n')
+				if err != nil {
+					done <- err
+					return
+				}
+				fmt.Fprint(server, "Password: ")
+				password, err := r.ReadString('\n')
+				if err != nil {
+					done <- err
+					return
+				}
+				if user != "admin\n" || password != "test-password\n" {
+					done <- fmt.Errorf("wrong credentials sent")
+					return
+				}
+				if accepted {
+					_, err = fmt.Fprint(server, "Welcome\n[admin@router] > buffered")
+				} else {
+					_, err = fmt.Fprint(server, "Login failed: invalid credentials\n")
+				}
+				done <- err
+			}()
+			r := bufio.NewReader(client)
+			err := autoLogin(client, r, "admin", "test-password")
+			if (err == nil) != accepted {
+				t.Fatalf("accepted=%v err=%v", accepted, err)
+			}
+			if accepted {
+				buffered := make([]byte, 8)
+				if _, err := r.Read(buffered); err != nil || string(buffered) != "buffered" {
+					t.Fatal("buffered data was lost")
+				}
+			}
+			if err := <-done; err != nil {
+				t.Fatal(err)
+			}
+		})
+	}
+}

+ 1 - 0
cmd/server/main.go

@@ -47,6 +47,7 @@ func main() {
 	netMgr := network.NewManager(metaMgr)
 	persistMgr := network.NewPersistenceManager(netMgr)
 	vpnMgr := vpn.NewManager()
+	defer vpnMgr.AeroTUN.StopAll()
 	fwMgr := firewall.NewFirewallManager()
 	routingMgr := routing.NewRoutingManager()
 	defer routingMgr.Stop()

+ 109 - 0
docs/aerotun-live-deployment.md

@@ -0,0 +1,109 @@
+# AeroTUN live deployment — 2026-10-30
+
+Both `proxmox-router` services were upgraded through independent management SSH.
+The local host is Linux amd64 using the veth fallback; `192.168.132.45` is Linux
+arm64 using native TUN. Each received the correct architecture build.
+
+The existing generated PSK and overlay configuration were preserved:
+
+| Setting | Local | Remote |
+| --- | --- | --- |
+| Mode | client | server |
+| Overlay address | 172.16.233.2/30 | 172.16.233.1/30 |
+| Outer endpoint | 192.168.132.45:51830 | learned authenticated peer |
+| Configured maximum MTU | 1420 | 1420 |
+
+The effective MTU initially negotiated to 1332 based on the client's WireGuard
+link. The remote route subsequently reported a cached path MTU of 1380, so both
+peers converged to **1292** (1380 minus 88 bytes of IPv4/UDP/AeroTUN overhead).
+Control packets advertise the effective MTU. A newly lowered value is announced
+immediately, rather than leaving one direction oversized until the next periodic
+keepalive. Regression tests model different MTUs on the two underlay interfaces.
+
+## Live measurements
+
+These are short samples of a variable routed connection, not a maximum capacity
+claim. Tests targeted the overlay addresses, with ordinary incompressible iperf3
+payloads. Separate underlay tests bypassed AeroTUN.
+
+| Test | Received rate | Notes |
+| --- | ---: | --- |
+| Original implementation, TCP | no connection | control connection timed out; ping still worked |
+| AeroTUN TCP local → remote | 12.443 Mbit/s | two TCP streams, 5 seconds |
+| AeroTUN TCP remote → local | 24.641 Mbit/s | two TCP streams, 6 seconds |
+| AeroTUN UDP local → remote | 4.947 Mbit/s | target 5 Mbit/s; 0% loss |
+| Underlay TCP local → remote | 10.343 Mbit/s | two TCP streams, 6 seconds |
+| Underlay TCP remote → local | 38.970 Mbit/s | two TCP streams, 6 seconds |
+
+The earlier one-stream reverse TCP sample was 2.726 Mbit/s. Parallelism and
+congestion significantly affect these short measurements. Underlay TCP also
+showed retransmissions; AeroTUN telemetry showed no authentication failures or
+replay drops. These comparisons do not isolate all overhead or prove that the
+remaining throughput limit is solely outside AeroTUN.
+
+## Built-in Bandwidth Test fixes
+
+Testing the real application's benchmark revealed two independent problems:
+
+1. TCP data, JSON control messages, and reports shared a byte stream with multiple
+   readers. Readers could consume each other's bytes and raw writes could
+   interleave control reports with test data.
+2. The limiter capped its bucket at 100 ms of tokens. At 2 Mbit/s that is 25 KB,
+   so a 64 KB TCP block could never accumulate enough tokens and never be sent.
+
+Before these fixes, a live four-second bidirectional TCP test at a 2 Mbit/s limit
+reported **zero transmitted bytes**. Its small reported RX value was control
+traffic incorrectly consumed as data.
+
+TCP tests now negotiate `frames-v1` in the JSON handshake, use one reader and
+serialized framed writes, and count only data payload. Unsupported old TCP peers
+receive an explicit upgrade error. UDP control framing remains unchanged. The
+limiter can accumulate one complete block and can be interrupted on cancellation.
+Generated benchmark payloads are random rather than all zeroes, to avoid measuring
+compression of synthetic padding as if it were ordinary link capacity.
+
+Regression tests cover bidirectional framing, TX/RX/both, low-rate large blocks,
+stop/cancellation, malformed frames, and rejection of the old unframed TCP mode.
+The unit/race tests and all repository tests pass.
+
+After deploying the fixes to both hosts, the same four-second bidirectional
+built-in test at a 2 Mbit/s limit delivered:
+
+| Built-in test | TX | RX | Loss |
+| --- | ---: | ---: | ---: |
+| TCP | 1.966 Mbit/s | 1.966 Mbit/s | 0% |
+| UDP | 2.024 Mbit/s | 2.024 Mbit/s | 0% |
+
+TCP delivered 983,040 data bytes in each direction; UDP delivered 1,012,000 bytes
+in each direction. Both tests completed without errors. Final telemetry on both
+hosts showed `running=true`, `connected=true`, MTU 1292, zero authentication
+failures and zero replay drops.
+
+## Artifacts and recovery
+
+Builds and raw benchmark JSON are stored under:
+`/root/.local/share/debtik-builds/aerotun-v2/`.
+The benchmark JSON contains no login credentials or session tokens.
+
+Backups on **both** hosts:
+
+- `/root/.local/share/debtik-backups/aerotun-v2-20261030T112440Z/` — original v1
+  executable, AeroTUN configuration, complete application configuration, and
+  interface/address/route snapshots.
+- `/root/.local/share/debtik-backups/aerotun-v2-20261030T113257Z/` — first working v2
+  executable before the built-in Bandwidth Test fixes.
+
+Rollback timers protected activation and were cancelled after validation. The
+`committed` marker in each backup prevents its automatic rollback script from
+running after success. A deliberate manual rollback requires removing that
+marker's guard and coordinating both peers; v1 and v2 AeroTUN are incompatible.
+
+The original persistent AeroTUN configuration was not changed. iperf3 was added
+to the remote host for the measurements; its package service is inactive, and
+transient benchmark listeners are stopped after testing.
+
+## Follow-up: default UDP RX
+
+A later default/unlimited UDP test exposed congestion and incorrect offered-TX
+reporting that the fixed 2 Mbit/s checks had not covered. See the
+[RX investigation and subsequent fix](bwtest-rx-fix.md).

+ 151 - 0
docs/aerotun-v2.md

@@ -0,0 +1,151 @@
+# AeroTUN v2: operation, bandwidth diagnosis, and upgrade
+
+AeroTUN v2 is incompatible with v1. Upgrade both ends together. Existing tunnel
+addresses, names and generated PSKs can be reused. The implementation does not
+fall back to the insecure v1 wire format.
+
+## Why the veth fallback failed
+
+On the inspected LXC host, `/dev/net/tun` is absent, so AeroTUN uses veth plus an
+AF_PACKET socket. The original implementation forwarded packets with unfinished
+TX checksums and potentially unsegmented GSO payloads. A raw socket does not
+complete these operations. A TCP SYN can therefore arrive with an invalid
+checksum and be discarded, making a bandwidth test time out before data starts.
+
+There were two additional bugs: RX and TX used the same Ethernet buffer
+concurrently, and the socket read its own injected PACKET_OUTGOING frames. Those
+could corrupt packets and feed received traffic back through the tunnel.
+
+The fixes disable TX checksum/TSO/GSO/GRO offloads on both veth ends, use separate
+RX/TX buffers, and ignore outgoing frames both at the socket and in userspace.
+PeerAddress now installs an explicit peer host route and, for veth, a permanent
+neighbour entry. Required configuration errors are returned, rather than ignored.
+No existing interface is deleted to make room for a new tunnel.
+
+## Reproduction and validation on 2026-10-30
+
+All bandwidth tests used disposable network namespaces and a private veth
+underlay; they did not change production links, routes, or services.
+
+- Original HEAD with a test harness using connected /24 overlay addresses: TCP
+  connection timed out, both with and without compression.
+- Original protocol with only the veth offloads disabled: TCP connected and
+  transferred data (roughly 72–120 Mbit/s in this diagnostic run). Other veth
+  defects remained, so this was a diagnostic comparison, not a deployable fix.
+- The corrected v2 implementation passed forward TCP with two streams, reverse
+  TCP, and UDP with a 1472-byte payload, including inner IPv4 fragmentation.
+- A run after the MTU fix delivered about 603–666 Mbit/s TCP without compression,
+  and 788–790 Mbit/s with explicitly repeating/compressible iperf3 payloads.
+  UDP delivered the requested 20 Mbit/s in each case.
+- Authentication, forged counters, duplicate/old packets, reflection, both peer
+  restarts, handshake retries, persistence failures and route rollback have
+  regression tests. The integration suite also checks that the original default
+  survives and that the outer endpoint bypasses the overlay.
+
+These are local host measurements, not an Internet benchmark or a throughput
+promise. Compressible iperf payloads are useful for exercising LZ4 but do not
+represent already compressed or encrypted application traffic. The initial local tests exercised veth because this LXC has no `/dev/net/tun`.
+The subsequent deployment also exercised native TUN on the ARM64 peer; see
+[the live deployment report](aerotun-live-deployment.md).
+
+Run checks from the repository root:
+
+```sh
+go test ./...
+go test -race ./internal/aerotun
+go vet ./...
+node --check internal/web/static/app.js
+# Requires root, ip, nsenter, iperf3, network namespace privileges, and veth fallback:
+AEROTUN_INTEGRATION=1 go test -run TestVethBandwidthIntegration -v ./internal/aerotun
+```
+
+## Protocol and connection status
+
+Each runner creates a random 128-bit boot identity. Control messages carry a
+two-byte effective MTU advertisement. Both peers reduce to the lower advertised
+MTU after session authentication, including when their underlay MTUs differ.
+HELLO, ACK, and CONFIRM bind
+sessions to both current boot identities. HMAC-SHA256 derives distinct keys for
+each direction and session pair. XChaCha20-Poly1305 uses the sender identity and a
+monotonic 64-bit counter as its 24-byte nonce. Counters never wrap. Data from a
+previous receiver boot is rejected; retired sender sessions cannot replace a
+newer session. The replay window is updated only after AEAD authentication.
+This is a custom PSK protocol, without forward secrecy or an independent audit.
+Use a generated random PSK; validation requires at least 32 characters.
+
+A client sends HELLO immediately and retries every two seconds while disconnected.
+Keepalives run every 20 seconds. A server learns/roams its endpoint only from
+authenticated session traffic. A client pins its configured endpoint.
+
+`running` means the local runner exists. `connected` additionally requires an
+authenticated peer packet within 65 seconds. The dashboard uses `connected` for
+its connection flag and active count. `last_handshake` is retained for API
+compatibility and reports the last authenticated session packet. `last_error`,
+`dropped_packets`, `auth_failures`, and `replay_drops` expose failures. Traffic
+counters advance only after successful socket/device writes; wire counters cover
+UDP payloads, including control messages, and exclude outer IP/UDP headers.
+
+The server supports one peer per tunnel instance. Use different instances/ports
+and PSKs for independent peer connections. A peer restart may take up to the
+65-second liveness timeout to trigger a new handshake on the remaining peer.
+
+## MTU and routing
+
+V2 adds 60 bytes of authenticated header/tag, plus 28 bytes for IPv4/UDP or 48
+bytes for IPv6/UDP. The requested MTU is capped by the local route/link MTU minus
+that overhead, at startup or authenticated peer discovery, and to the peer
+advertisement. Both veth ends use
+the same effective MTU. Smaller MTUs elsewhere on the path still require an
+explicit lower setting; this is not full path MTU discovery.
+
+On the inspected host, the remote endpoint uses a WireGuard interface with MTU
+1420. The v2 IPv4 outer path therefore caps the AeroTUN MTU at **1332**. Keeping
+1420 as the overlay MTU would require outer fragmentation. The dashboard/API
+reports the effective MTU. During live deployment the remote cached path MTU
+was 1380, which further reduced the negotiated overlay MTU to 1292.
+
+PeerAddress adds a /32 or /128 host route. It does not automatically advertise LAN
+subnets or enable forwarding/NAT. Add those routes and firewall rules as needed.
+
+DefaultRoute is supported for clients with a fixed IPv4 endpoint. It pins the
+endpoint to its existing underlay route, then adds 0.0.0.0/1 and 128.0.0.0/1 via
+the overlay. The original default remains intact. Existing host routes are
+preserved; conflicting routes cause an error and rollback. Stop removes only
+routes added by this runner. The server shuts runners down on normal exit.
+Uncatchable process/host failures can leave veth devices behind; an existing
+interface is deliberately reported as an error rather than deleted blindly.
+
+## Coordinated installation
+
+The prepared binary is at:
+`/root/.local/share/debtik-builds/aerotun-v2/proxmox-router`.
+The ARM64 build is alongside it as `proxmox-router-linux-arm64`. Both hosts were
+subsequently upgraded; see [the deployment report](aerotun-live-deployment.md).
+For future upgrades, ensure both ends have v2 and management access independent
+of this AeroTUN connection. Preserve the
+old binary and `/etc/proxmox-router/vpn/aerotun_instances.json` on each host.
+
+On each end, after coordinating the restart:
+
+```sh
+cp /usr/local/bin/proxmox-router /usr/local/bin/proxmox-router.pre-aerotun-v2
+install -m 0755 /path/to/new/proxmox-router /usr/local/bin/proxmox-router.next
+systemctl stop proxmox-router
+mv /usr/local/bin/proxmox-router.next /usr/local/bin/proxmox-router
+systemctl start proxmox-router
+journalctl -u proxmox-router -n 50 --no-pager
+```
+
+If an old veth remains after stopping the OLD service, verify its ownership and
+that no runner uses it before removing that specific old AeroTUN interface. V1
+shutdown did not reliably clean up its veths. Do not delete unrelated interfaces.
+
+Then test the peer overlay address before testing LAN routing or a default route.
+For iperf3, start the server on the opposite overlay address and run the client
+against that address; testing the underlay address does not measure AeroTUN.
+Start UDP tests with a 1000-byte payload and increase only after confirming MTU.
+
+For the subsequent default-UDP congestion and RX fix, see
+[the Bandwidth Test follow-up](bwtest-rx-fix.md).
+
+The deployed pair has since moved to [AeroTUN v3](aerotun-v3.md). V2 remains an explicit legacy option for coordinated rollback.

+ 192 - 0
docs/aerotun-v3.md

@@ -0,0 +1,192 @@
+# AeroTUN v3 — implementation and deployment, 2026-10-30
+
+V3 is deployed on both DebTik hosts. It replaces the v2 PSK-only handshake with
+Noise IKpsk2, pinned X25519 identities and ephemeral session keys, while keeping
+XChaCha20-Poly1305 for IP data. This is a custom protocol integration; the testing
+below is not an independent cryptographic audit or a security certification.
+
+## Wire protocol and authentication
+
+V3 requires the same random 32-byte PSK on both hosts, plus the other endpoint's
+pinned static public key. Keys accept 64-character hex or base64 encoding
+of 32 bytes. `github.com/flynn/noise` v1.1.0 implements the handshake pattern
+`Noise_IKpsk2_25519_ChaChaPoly_SHA256`; the application does not implement its own
+Diffie-Hellman or AEAD primitive.
+
+The handshake prologue binds the AeroTUN v3 protocol name and the initiating
+16-byte header. Encrypted handshake metadata carries the receiver index, MTU,
+and compression capability. Application IP traffic is never sent as 0-RTT data.
+The responder verifies the initiator's static identity against its configured
+peer before publishing a pending session. It activates that session only after
+receiving authenticated transport data, proving completion and PSK possession.
+
+A domain-separated HMAC-SHA256 truncated to 16 bytes authenticates handshake
+packets before expensive DH work. A stateless source cookie binds the sender's
+IP/port and initiation identifier to a process-random secret and a 30-second time
+bucket; current and previous buckets are accepted. The cookie is itself carried
+in a PSK-authenticated request. This prevents moving recorded requests to a new
+source without knowing the PSK. New DH handshakes are limited to one per second
+per configured tunnel. State remains bounded; no unbounded source-address map is
+created for unauthenticated traffic.
+
+After Noise completes, its two directional split keys are exported solely to
+HKDF-SHA256, with the handshake channel binding as salt and an AeroTUN v3 data
+label as context. Each direction derives a distinct 32-byte XChaCha key and a
+16-byte nonce prefix. Noise CipherStates are not subsequently used for data.
+
+Each data/control datagram has this authenticated 16-byte header:
+
+| Field | Bytes |
+| --- | ---: |
+| Magic | 2 |
+| Protocol version | 1 |
+| Message kind | 1 |
+| Receiver session index | 4 |
+| Counter | 8 |
+
+The full header is AEAD additional data. The 24-byte nonce is the derived
+16-byte prefix followed by the counter. An atomic counter covers control and data
+senders, rejects zero/wrap, and triggers renewal before exhaustion. The receiver
+checks authentication before committing replay state. A fixed 128-byte bitmap
+supports a 1024-packet replay/reorder window. Unknown sessions and malformed or
+oversized packets are rejected without creating a data session.
+
+The client renews the handshake every 90 seconds; active traffic continues on
+the current keys while renewal runs. Previous receive keys have a 30-second grace
+period. Sessions expire after 180 seconds, pending sessions after five seconds.
+At most current, previous and pending session objects are retained. Go does not
+guarantee physical erasure of discarded heap objects, so process-memory compromise
+is outside any claim of secure key destruction.
+
+V3 does not negotiate down to v2. V2 remains an explicitly selected legacy mode
+for coordinated rollback. These are separate wire protocols.
+
+## Packet path and resource bounds
+
+- Separate reusable TX/RX buffers, including the veth Ethernet write buffer.
+- In-place data encryption and caller-owned decryption/nonce buffers; zero heap
+  allocations in the measured steady-state crypto round trip.
+- UDP `ReadBatch`/`WriteBatch`, up to 16 packets; available packets flush without
+  waiting to fill a batch.
+- A bounded 16-packet transmit queue backed by 32 preallocated packet buffers.
+- A fixed number of workers; none are created per packet.
+- Bulk encryption/decryption runs outside the handshake mutex. Replay state has
+  a short receive-only critical section.
+- Buffers scale with the validated configured MTU, not 64 KB per scratch packet.
+- V3 requests 1 MiB socket buffers in each direction instead of v2's 4 MiB.
+  Kernel accounting/limits can change actual buffer memory use.
+- Compression defaults off for new configurations and is disabled on the deployed
+  pair. It is accepted only when both peers explicitly negotiate support.
+
+Headers plus authentication tag shrink from 60 to 32 bytes. Including IPv4/UDP,
+the encapsulation overhead becomes 60 bytes instead of 88. The actual outer path
+later settled at MTU 1380, allowing an overlay MTU of **1320** and a UDP benchmark
+datagram of **1292** bytes. During an earlier phase the overlay was 1360; sizes
+are negotiated from route/link limits and peer advertisements.
+
+Offloads remain disabled for the raw veth backend because it does not implement
+GSO segmentation or completion of partial checksums. Enabling flags without that
+implementation would reintroduce the original TCP failure. Native TUN is preferred
+when available. The local container denies TUN device access; changing that needs
+Proxmox host configuration outside this container's access. The ARM64 peer uses
+native TUN successfully.
+
+## Peer policy and secret handling
+
+V3 requires explicit allowed source prefixes for the authenticated peer. IPv4 and
+IPv6 source addresses are checked before injection into the host interface.
+The deployed pair currently allows only each other's overlay /32 address. To carry
+LAN traffic, administrators must add the intended LAN prefixes and corresponding
+routes/firewall policy; a configured peer key does not authorize arbitrary source
+addresses.
+
+List/save API responses omit PSKs and private identity keys, including for admin
+clients. `has_psk`/`has_private_key` indicate presence. An empty secret on edit
+retains the stored value. Only full administrators can change/start/stop tunnels
+or generate PSKs. Generated-key responses use `Cache-Control: no-store`.
+Configuration and generated identity files are mode 0600. Existing management
+credentials and listeners are preserved; strong authentication and HTTPS/trusted
+management access are still required for appliance administration.
+
+New v3 tunnels can be saved disabled to generate a local identity. Copy its public
+key to the other endpoint over a trusted channel, set allowed prefixes, then enable
+both sides. Static private keys are never returned by the UI/API.
+
+For bootstrap on the owner host:
+
+```sh
+aerotun-keygen -out /root/new-aerotun-identity.json
+```
+
+The command creates the file once with mode 0600 and prints only the public key.
+The deployed identities were generated separately on their owner hosts and their
+public keys cross-pinned over the verified SSH channel. The existing PSK was kept.
+
+## Validation and observed results
+
+Both full repository tests and `go vet` pass. API regression tests cover redaction,
+privilege checks and editing without revealing/erasing a key. Protocol tests cover
+pinned identities, incorrect PSKs, tampered counters/headers, reflection, replay,
+reordering, restart, rekey under concurrent traffic, cookie source binding/expiry,
+and in-place encryption. Fuzzing ran about 270,000 inputs per ten-second run
+without a crash. Isolated v2/v3 namespace tests passed with race detection,
+including TCP both directions, UDP, compression negotiation and constrained links.
+
+A paired 1200-byte encrypt/decrypt microbenchmark on the local i5-4570T, using
+Go 1.26.10, measured approximately:
+
+| Crypto path | Time | Allocated bytes | Allocations |
+| --- | ---: | ---: | ---: |
+| v2 | 2.85–2.97 μs | 2608 | 4 |
+| v3 | 2.23–2.30 μs | 0 | 0 |
+
+These are crypto-path measurements, not Internet throughput claims. Network
+batch helpers and interface I/O have their own overhead. An isolated no-compression
+run delivered about 856/737 Mbit/s TCP for v3 versus 635/642 Mbit/s for v2;
+short runs vary with host load and are not a capacity guarantee.
+
+Live application benchmark results after deployment:
+
+| Test | Confirmed TX | RX | Reported loss |
+| --- | ---: | ---: | ---: |
+| Remote Both / Auto, 10 s | 7.862 Mbit/s | 10.457 Mbit/s | 0.983% |
+| Remote RX-only / Auto, 8 s | 0 | 10.098 Mbit/s | 1.162% |
+| Local Both / Auto, 4 streams, 8 s | 7.536 Mbit/s | 10.831 Mbit/s | 0.160% |
+| TCP Both, fixed 2 Mbit/s target | 1.835 Mbit/s | 1.966 Mbit/s | no application-reported loss |
+
+Live telemetry showed both peers connected at MTU 1320 with no error. Automatic
+key renewal completed repeatedly on both hosts, with the handshake count advancing
+to four during validation. A transient rejected packet during the v2/v3 transition
+is retained in the remote authentication-failure counter; no replay drops were
+observed. Counts of policy/oversize drops can include startup link-local traffic
+and MTU adjustment, and are not equivalent to WAN packet loss.
+
+`govulncheck` found no reachable vulnerable symbols or imported vulnerable packages.
+It flagged the unused/unmaintained `x/crypto/openpgp` package at module level; this
+application does not import that package. Builds use Go 1.26.10.
+
+## Artifacts and recovery
+
+Builds, test logs, benchmark output, deployment state and redacted runtime state:
+`/root/.local/share/debtik-builds/aerotun-v3/`.
+
+Backups on both hosts:
+`/root/.local/share/debtik-backups/aerotun-v3-20261030T130621Z/`.
+They contain the prior v2 executable/configuration and route/link snapshots.
+Rollback timers protected activation and were cancelled after validation. The
+`committed` marker guards the backup rollback script against accidental execution.
+Deliberate rollback must restore both peers' version and configuration together.
+Private identity files in the backup directory remain owner-only.
+
+For verification:
+
+```sh
+go test ./...
+go test -race ./internal/aerotun ./internal/api
+go vet ./...
+go test -run '^$' -bench 'Benchmark.*ProtocolRoundTrip' -benchmem ./internal/aerotun
+AEROTUN_INTEGRATION=1 go test -race -run TestVethBandwidthIntegration -v ./internal/aerotun
+```
+
+The integration harness needs root, namespace privileges, ip, tc, nsenter and
+iperf3. It creates disposable namespaces rather than changing production links.

+ 92 - 0
docs/bwtest-rx-fix.md

@@ -0,0 +1,92 @@
+# UDP RX investigation and follow-up fix — 2026-10-30
+
+The earlier live checks at a fixed 2 Mbit/s target did not cover the dashboard's
+default **UDP / Both / Unlimited / 1472-byte** case. The user's next test exposed
+that gap, so it was reproduced from the remote DebTik against 172.16.233.2.
+
+## Findings
+
+The user's ten-second test recorded an average TX of 1306 Mbit/s, but that number
+counted bytes accepted by the sender's local UDP socket, not delivery to the
+other node. Its RX averaged 0.963 Mbit/s and the final reported loss was 99.85%.
+This was an unpaced UDP flood, with large numbers of dropped/queued datagrams.
+The old sequence accounting also treated reordering as permanent loss.
+
+Separate RX-only checks confirmed that UDP RX at a fixed 2 Mbit/s delivered
+2.023 Mbit/s, while the old unlimited RX case delivered only 1.225 Mbit/s and
+reported very high loss. TCP RX at 2 Mbit/s delivered 1.966 Mbit/s. A subsequent
+limited UDP test could also suffer from the preceding flood's backlog.
+
+Additionally, a 1472-byte UDP payload plus IP/UDP headers exceeds the effective
+1292-byte AeroTUN MTU. Fragmenting every datagram increased packet work and made
+loss of a single fragment discard the whole UDP datagram.
+
+## Changes
+
+- TX throughput and TX bytes/packets now use peer-reported successful reception.
+  Locally offered TX is a separate metric and appears in the TX tooltip.
+- Zero/default UDP limit now selects **Auto**, using receiver-feedback pacing in
+  both directions. It starts at 2 Mbit/s, probes upward, and backs off when the
+  receiver cannot sustain the offered rate. Missing feedback also reduces the
+  rate. An explicitly selected nonzero limit remains a fixed offered rate.
+- The client sends reverse-receiver feedback to the server. All outbound client
+  UDP streams share one pacing budget, rather than multiplying the target rate.
+- Datagram size is negotiated to fit both routes. On this overlay the requested
+  1472 bytes become **1264 bytes** when the overlay MTU is 1292 (or 1304 bytes
+  at MTU 1332): the current route MTU minus IPv4/UDP headers. The effective
+  datagram size is shown in the dashboard.
+- RX-only clients send a UDP probe. The server learns the real UDP source port,
+  including NAT mappings, without depending on outgoing benchmark data. Probe
+  packets do not count as bandwidth. Discovery is restricted to the TCP peer IP.
+- Loss tracking is per stream, excludes duplicate packets, and accounts for late
+  packets within a bounded reorder window.
+- TX-only tests no longer label delivery reports as downloaded RX traffic.
+- A duplicate packet-size selector/HTML ID was removed so changing the visible
+  selector controls the requested size.
+
+Automatic UDP mode requires the new receiver-feedback support on both peers;
+unsupported peers return an upgrade error. It is a capacity probe, not a promise
+that a short run reaches the maximum possible rate. Use longer runs to observe
+convergence; excessive fixed offered rates can still cause intentional overload.
+
+## Validation
+
+Unit and race tests cover RX/TX/both, multiple streams, pacing feedback/backoff,
+cancellation, reordered/duplicate datagrams and confirmed byte accounting.
+
+The integration harness uses two isolated AeroTUN peers and constrains each outer
+link to 5 Mbit/s, with 15 ms delay and a 64-packet queue. Default automatic UDP
+RX delivered about 3.36 Mbit/s with 1.0% reported loss; Both delivered confirmed
+TX 2.34 / RX 3.36 Mbit/s with 0.4% reported loss. The payload negotiated to 1264
+bytes. These are controlled local tests, separate from live link measurements.
+
+Both production services are updated using architecture-specific amd64/arm64
+builds and independent SSH management. Before this follow-up, another backup was
+saved on both hosts at:
+
+`/root/.local/share/debtik-backups/aerotun-v2-20261030T115144Z/`
+
+The persistent AeroTUN configuration and PSK are preserved. Raw live validation
+JSON is stored in `/root/.local/share/debtik-builds/aerotun-v2/live-test-results/`
+with names beginning `rx-fix-`.
+
+## Live results after the follow-up deployment
+
+| Client and mode | Duration | Confirmed TX | RX | Reported loss | Negotiated UDP size |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| Remote → local, Both / Auto | 10 s | 3.934 Mbit/s | 12.988 Mbit/s | 0.951% | 1304 bytes |
+| Remote → local, RX-only / Auto | 8 s | 0 | 8.403 Mbit/s | 1.153% | 1304 bytes |
+| Local → remote, Both / Auto, 4 streams | 8 s | 8.002 Mbit/s | 5.324 Mbit/s | 0.162% | 1264 bytes |
+
+The first two tests started while the overlay MTU was 1332; a cached outer path
+MTU later reduced the overlay to 1292, so the third test negotiated 1264-byte
+datagrams. Datagram size is negotiated at the beginning of each test. The final
+telemetry shows both peers connected at MTU 1292 with no authentication failures
+or replay drops. Short automatic runs probe capacity and vary with the route;
+these values are observed throughput, not a promised maximum.
+
+The ten-second Both test repeated the user's direction, target overlay address,
+automatic/zero limit, one stream and requested 1472-byte packet size. RX no longer
+starved. Both services are active, installed hashes match the built artifacts,
+rollback timers were cancelled after verification, and the persistent AeroTUN
+configuration/PSK remained unchanged.

+ 113 - 0
docs/ui-cli-fixes.md

@@ -0,0 +1,113 @@
+# UI and CLI reliability fixes — 2026-10-30
+
+## Behavior
+
+- Bare `/log` works. The command executor contains handler panics so a bad
+  command cannot terminate the router service.
+- HTTP (`www`) and HTTPS (`www-ssl`) are gated independently, using the actual
+  TLS connection rather than a client-supplied forwarded header.
+- SSH exec reports failures with exit status 1. SSH request decoding preserves
+  full command lengths and decodes PTY sizes using the SSH wire format.
+- CLI arguments accept single/double quotes, including comments containing
+  spaces. Unterminated quotes produce a syntax error; no shell is invoked.
+- The standalone `debtik-cli` authenticates in interactive and single-command
+  modes, validates login success, preserves buffered terminal data and restores
+  terminal state on signals. Single-command mode requires credentials.
+- Bandwidth duration 0 runs until Stop. Rolling telemetry retains 200 samples,
+  while summary rates use complete byte totals and elapsed time; peaks and mean
+  jitter include samples evicted from the window. Loss uses cumulative counts.
+- The UI tracks sample elapsed time within a session rather than array length,
+  so rolling history keeps updating after 100 seconds. A new session resets the
+  cursor. SSE observers have individual bounded queues and unsubscribe cleanly.
+- Stop reports API rejection instead of showing success. A successful Stop
+  request waits for test status to confirm completion.
+- Concurrent identical API GETs share one request with independent response
+  copies. Core refresh cycles do not overlap; infrequently changing summaries
+  refresh every 30 seconds. Active views still refresh their own data. Duplicate
+  certificate/service requests were removed.
+- A visible header status identifies incomplete/unavailable telemetry and the
+  last successful update. API reads have a 15-second timeout. Core/traffic
+  updates pause in hidden tabs, resume on visibility, and session pollers stop
+  on logout. Bearer injection is restricted to this origin's API.
+- Both BGP status badges update; the HTML contains no duplicate IDs.
+- Bandwidth server telemetry preserves unsaved modal fields. Opening the modal
+  starts from the latest stored configuration rather than stale unsaved edits.
+- AeroTUN setup explains disabled drafts, local identity creation, reciprocal
+  peer keys, shared PSKs and allowed source prefixes. The first v3 draft stays
+  open and displays its generated public key with a copy control. Private keys
+  and saved PSKs are never returned by the UI/API. Copy the initial PSK before
+  saving if it needs to be transferred to the peer; later blank edits retain it.
+  Tunnel status distinguishes saved/disabled, stopped, connecting, connected and
+  failed. A successful Start reports interface startup rather than connection.
+
+## AeroTUN CLI
+
+Read-only users can inspect public configuration and runtime state:
+
+```text
+/interface aerotun print
+/interface aerotun print detail=yes
+```
+
+Only full administrators can generate a shared PSK or change configuration:
+
+```text
+/interface aerotun genkey
+/interface aerotun add name=aerotun1 mode=server local-address=10.99.1.1/30 peer-address=10.99.1.2/32 allowed-ips=10.99.1.2/32 psk=<shared-key> disabled=yes comment="office uplink"
+/interface aerotun set aerotun1 peer-public-key=<peer-key>
+/interface aerotun enable aerotun1
+/interface aerotun disable aerotun1
+/interface aerotun start aerotun1
+/interface aerotun stop aerotun1
+/interface aerotun remove aerotun1
+```
+
+`add` defaults to v3 with compression off and disabled until configured. It
+prints the generated public key. `set` preserves unspecified fields and stored
+identity. The identifier can be either name or ID. Completion includes actions,
+parameters and configured tunnel names. Allowed prefixes authorize peer source
+addresses; they do not install routed LAN prefixes.
+
+## Verification
+
+```sh
+go test ./...
+go vet ./...
+go test -race ./internal/cli ./internal/api ./internal/bwtest ./cmd/debtik-cli
+node --test scripts/ui-regression.test.cjs
+```
+
+The Go tests include a real loopback continuous session exceeding ten seconds,
+bidirectional traffic, Stop, broadcast observers, retained-window summaries,
+HTTP/HTTPS independence, quoted arguments, AeroTUN privilege checks and secret
+redaction, standalone authentication and actual SSH exec exit statuses.
+
+With Playwright available as a development tool:
+
+```sh
+DEBTIK_TEST_URL=http://127.0.0.1:8080 node scripts/ui-smoke.cjs
+```
+
+Optional environment variables: `DEBTIK_TEST_USER`, `DEBTIK_TEST_PASSWORD`,
+`PLAYWRIGHT_MODULE`, `UI_TEST_ARTIFACTS`. The smoke test logs into the specified
+appliance, visits 26 views, and uses intercepted responses for rejected Stop,
+failed telemetry and draft creation. It does not save real tunnel/server
+configuration. It logs out its own session afterward. Development browser tools
+are not a runtime dependency of the appliance.
+
+## Deployment and recovery
+
+Both Linux amd64/veth and Linux arm64/TUN appliances received the updated server
+and CLI. AeroTUN configuration hashes and peer identities were unchanged.
+Backups on both hosts: `/root/.local/share/debtik-backups/ui-cli-20261030T140028Z/`.
+Builds, test logs and browser artifacts:
+`/root/.local/share/debtik-builds/ui-cli-fixes/`.
+
+Live checks confirmed overlay ping, CLI inspection, SSH exit status, continuous
+UDP in both directions and two simultaneous SSE observers receiving the same
+23 samples. The capped 200 kbit/s test measured about 194/196 kbit/s with no
+reported loss; this was a functionality check, not a capacity benchmark.
+
+The 35 browser checks passed against each deployed host (26 views plus nine
+behavior checks), with zero JavaScript runtime errors. Rollback timers were
+cancelled after verification; both backups contain a `committed` marker.

+ 7 - 5
go.mod

@@ -2,14 +2,16 @@ module proxmox-router
 
 go 1.26.0
 
+toolchain go1.26.10
+
 require (
+	github.com/flynn/noise v1.1.0
 	github.com/pierrec/lz4/v4 v4.1.33
 	github.com/vishvananda/netlink v1.3.1
 	golang.org/x/crypto v0.58.0
+	golang.org/x/net v0.62.0
+	golang.org/x/sys v0.49.0
+	golang.org/x/term v0.47.0
 )
 
-require (
-	github.com/vishvananda/netns v0.0.5 // indirect
-	golang.org/x/sys v0.49.0 // indirect
-	golang.org/x/term v0.47.0 // indirect
-)
+require github.com/vishvananda/netns v0.0.5 // indirect

+ 17 - 0
go.sum

@@ -1,14 +1,31 @@
+github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
+github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
+github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/pierrec/lz4/v4 v4.1.33 h1:GjG1TJ1V4IzKP8L96muuuDNpTwd7D+l2ccXrjAbe014=
 github.com/pierrec/lz4/v4 v4.1.33/go.mod h1:7SE9MC2STkNtL4PIwGhjmyVwvILaGI9/COYQNBhKM/c=
 github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
 github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
 github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
 github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
+golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
 golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
+golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
 golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
 golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

+ 42 - 42
install.sh

@@ -1,44 +1,44 @@
 #!/usr/bin/env bash
-set -e
-
-echo "=== Installing DebTik Controller ==="
-
-# Check root
-if [ "$EUID" -ne 0 ]; then
-  echo "[-] Please run as root"
-  exit 1
+set -euo pipefail
+if [[ $EUID -ne 0 ]]; then echo 'Run as root' >&2; exit 1; fi
+src=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+command -v apt-get >/dev/null || { echo 'Debian apt-get required' >&2; exit 1; }
+export DEBIAN_FRONTEND=noninteractive
+apt-get update
+apt-get install -y --no-install-recommends ca-certificates curl git tar iproute2 bridge-utils iptables ipset conntrack wireguard-tools bird2 dnsmasq isc-dhcp-client ppp pppoe strongswan strongswan-swanctl xl2tpd iputils-ping iputils-tracepath traceroute procps openssl certbot python3 util-linux
+required=$(awk '$1=="toolchain" {sub(/^go/,"",$2);print $2}' "$src/go.mod")
+if [[ -z $required ]]; then required=$(awk '$1=="go" {print $2}' "$src/go.mod"); fi
+if ! command -v go >/dev/null || [[ $(printf '%s\n%s\n' "$required" "$(go version | awk '{sub(/^go/,"",$3);print $3}')" | sort -V | head -n1) != "$required" ]]; then
+  case $(uname -m) in x86_64) arch=amd64;; aarch64) arch=arm64;; *) echo 'Unsupported CPU' >&2; exit 1;; esac
+  archive=$(mktemp)
+  trap 'rm -f "$archive"' EXIT
+  curl --fail --location --retry 3 "https://go.dev/dl/go${required}.linux-${arch}.tar.gz" -o "$archive"
+  install -d /opt/debtik
+  rm -rf /opt/debtik/go
+  tar -C /opt/debtik -xzf "$archive"
+  rm -f "$archive"
+  export PATH=/opt/debtik/go/bin:$PATH
 fi
-
-APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-cd "$APP_DIR"
-
-# Build binary
-echo "[+] Compiling Go binary..."
-CGO_ENABLED=0 go build -ldflags="-s -w" -o /usr/local/bin/proxmox-router cmd/server/main.go
-chmod +x /usr/local/bin/proxmox-router
-
-# Create directories
-mkdir -p /etc/proxmox-router
-mkdir -p /etc/network/interfaces.d
-
-# Ensure interfaces.d is included in /etc/network/interfaces
-if [ -f /etc/network/interfaces ]; then
-  if ! grep -q "source /etc/network/interfaces.d/\*" /etc/network/interfaces; then
-    echo -e "\n# Proxmox Router interfaces inclusion\nsource /etc/network/interfaces.d/*" >> /etc/network/interfaces
-    echo "[+] Added source /etc/network/interfaces.d/* to /etc/network/interfaces"
-  fi
-fi
-
-# Install systemd service
-if [ -d /etc/systemd/system ]; then
-  cp systemd/proxmox-router.service /etc/systemd/system/proxmox-router.service
-  systemctl daemon-reload
-  systemctl enable proxmox-router
-  systemctl restart proxmox-router || true
-  echo "[+] Systemd service installed and active on port 80!"
-else
-  echo "[!] Systemd not detected, binary installed to /usr/local/bin/proxmox-router"
-fi
-
-echo "=== Installation complete! ==="
-echo "Access the Web UI at: http://<container-ip>/"
+cd "$src"
+build=$(mktemp -d)
+trap 'rm -rf "$build"' EXIT
+commit=$(git rev-parse HEAD 2>/dev/null || printf unknown)
+CGO_ENABLED=0 go build -trimpath -ldflags "-X proxmox-router/internal/api.BuildCommit=$commit" -o "$build/proxmox-router" ./cmd/server
+CGO_ENABLED=0 go build -trimpath -o "$build/debtik-cli" ./cmd/debtik-cli
+install -d -m 0700 /etc/proxmox-router
+install -d -m 0700 /var/lib/debtik/update
+install -d -m 0755 /usr/local/libexec
+install -m 0750 scripts/debtik-update /usr/local/libexec/debtik-update
+install -d -m 0755 /etc/network/interfaces.d
+for name in proxmox-router debtik-cli; do
+  if [[ -f /usr/local/bin/$name ]]; then cp -p "/usr/local/bin/$name" "/usr/local/bin/$name.previous"; fi
+  install -m 0755 "$build/$name" "/usr/local/bin/$name.next"
+  mv -f "/usr/local/bin/$name.next" "/usr/local/bin/$name"
+done
+if [[ -f /etc/network/interfaces ]] && ! grep -Eq '^source[[:space:]]+/etc/network/interfaces\.d/\*' /etc/network/interfaces; then printf '\nsource /etc/network/interfaces.d/*\n' >> /etc/network/interfaces; fi
+install -m 0644 systemd/proxmox-router.service /etc/systemd/system/proxmox-router.service
+systemctl daemon-reload
+systemctl enable proxmox-router.service
+systemctl restart proxmox-router.service
+systemctl is-active --quiet proxmox-router.service
+echo 'DebTik installed: http://<host>/ and debtik-cli'

+ 41 - 0
internal/aerotun/benchmark_test.go

@@ -0,0 +1,41 @@
+package aerotun
+
+import "testing"
+
+func BenchmarkProtocolRoundTrip(b *testing.B) {
+	a, err := newProtocol(testPSK)
+	if err != nil {
+		b.Fatal(err)
+	}
+	peer, err := newProtocol(testPSK)
+	if err != nil {
+		b.Fatal(err)
+	}
+	hello, _ := a.seal(flagHello, []byte{5, 140}, zeroSession)
+	_, _, sender, _, err := peer.open(hello)
+	if err != nil {
+		b.Fatal(err)
+	}
+	ack, _ := peer.seal(flagAck, []byte{5, 140}, sender)
+	_, _, sender, _, err = a.open(ack)
+	if err != nil {
+		b.Fatal(err)
+	}
+	confirm, _ := a.seal(flagConfirm, []byte{5, 140}, sender)
+	if _, _, _, _, err = peer.open(confirm); err != nil {
+		b.Fatal(err)
+	}
+	payload := make([]byte, 1200)
+	b.SetBytes(int64(len(payload)))
+	b.ReportAllocs()
+	b.ResetTimer()
+	for range b.N {
+		packet, err := a.seal(0, payload, zeroSession)
+		if err != nil {
+			b.Fatal(err)
+		}
+		if _, _, _, _, err = peer.open(packet); err != nil {
+			b.Fatal(err)
+		}
+	}
+}

+ 140 - 0
internal/aerotun/config.go

@@ -0,0 +1,140 @@
+package aerotun
+
+import (
+	"bytes"
+	"encoding/base64"
+	"fmt"
+	"golang.org/x/crypto/curve25519"
+	"net"
+	"net/netip"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+var interfaceName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,11}$`)
+
+func validateConfig(c *TunnelInstance) error {
+	if c.Protocol == 0 {
+		c.Protocol = 2
+	}
+	if c.Protocol != 2 && c.Protocol != 3 {
+		return fmt.Errorf("unsupported AeroTUN version")
+	}
+	if c.Protocol == 3 {
+		if _, err := decodeKey(c.PSK); err != nil {
+			return err
+		}
+		priv, err := decodeKey(c.PrivateKey)
+		if err != nil {
+			return fmt.Errorf("invalid local identity key")
+		}
+		pub, err := curve25519.X25519(priv, curve25519.Basepoint)
+		if err != nil {
+			return err
+		}
+		c.PublicKey = base64.StdEncoding.EncodeToString(pub)
+		if c.PeerPublicKey != "" || !c.Disabled {
+			peer, err := decodeKey(c.PeerPublicKey)
+			if err != nil || bytes.Equal(peer, pub) {
+				return fmt.Errorf("a different pinned peer public key is required")
+			}
+		}
+		if len(c.AllowedIPs) == 0 {
+			return fmt.Errorf("v3 requires explicit allowed source IP prefixes")
+		}
+		for _, prefix := range c.AllowedIPs {
+			if _, err := netip.ParsePrefix(prefix); err != nil {
+				return fmt.Errorf("invalid allowed source IP prefix")
+			}
+		}
+	}
+	c.Mode = strings.ToLower(strings.TrimSpace(c.Mode))
+	if c.Mode != "server" && c.Mode != "client" {
+		return fmt.Errorf("mode must be server or client")
+	}
+	// Reserve three characters for the fallback endpoint suffix.
+	if !interfaceName.MatchString(c.Name) {
+		return fmt.Errorf("interface name must contain 1–12 letters, digits, dots, underscores or dashes")
+	}
+	if c.MTU == 0 {
+		c.MTU = DefaultMTU
+	}
+	if c.MTU < 576 || c.MTU > 9000 {
+		return fmt.Errorf("MTU must be between 576 and 9000")
+	}
+	if c.ListenPort == 0 && c.Mode == "server" {
+		c.ListenPort = DefaultPort
+	}
+	if c.ListenPort < 0 || c.ListenPort > 65535 {
+		return fmt.Errorf("invalid UDP listen port")
+	}
+	if len(c.PSK) < 32 {
+		return fmt.Errorf("PSK must contain at least 32 characters; use Generate Key")
+	}
+	ip, _, err := net.ParseCIDR(c.LocalAddress)
+	if err != nil || ip.IsUnspecified() || ip.IsMulticast() {
+		return fmt.Errorf("local address must be a unicast IP in CIDR notation")
+	}
+	peer, err := peerIP(c.PeerAddress)
+	if err != nil {
+		return err
+	}
+	if peer != nil && ((ip.To4() == nil) != (peer.To4() == nil) || peer.Equal(ip)) {
+		return fmt.Errorf("peer and local address must be distinct and use the same IP family")
+	}
+	if ip.To4() == nil && c.MTU < 1280 {
+		return fmt.Errorf("IPv6 requires MTU of at least 1280")
+	}
+	if c.Mode == "client" && c.RemoteEndpoint == "" {
+		return fmt.Errorf("client requires a remote endpoint")
+	}
+	if c.RemoteEndpoint != "" {
+		host, port, err := net.SplitHostPort(c.RemoteEndpoint)
+		p, portErr := strconv.Atoi(port)
+		if err != nil || host == "" || portErr != nil || p < 1 || p > 65535 {
+			return fmt.Errorf("remote endpoint must be host:port")
+		}
+	}
+	if c.DefaultRoute && (c.Mode != "client" || c.RemoteEndpoint == "") {
+		return fmt.Errorf("default route requires a client with a fixed remote endpoint")
+	}
+	return nil
+}
+func peerIP(s string) (net.IP, error) {
+	if s == "" {
+		return nil, nil
+	}
+	ip := net.ParseIP(s)
+	if ip == nil {
+		ip, _, _ = net.ParseCIDR(s)
+	}
+	if ip == nil || ip.IsUnspecified() || ip.IsMulticast() {
+		return nil, fmt.Errorf("invalid peer IP address")
+	}
+	return ip, nil
+}
+func configurePeer(dev VirtualDevice, c TunnelInstance) error {
+	peer, err := peerIP(c.PeerAddress)
+	if err != nil {
+		return err
+	}
+	if peer == nil {
+		return nil
+	}
+	family := "-4"
+	prefix := "/32"
+	if peer.To4() == nil {
+		family = "-6"
+		prefix = "/128"
+	}
+	if _, err := runIP(family, "route", "add", peer.String()+prefix, "dev", dev.Name()); err != nil {
+		return fmt.Errorf("peer route: %w", err)
+	}
+	// ARP is disabled on the veth fallback: explicitly map the peer to its local
+	// endpoint MAC rather than relying on unresolved Ethernet neighbour discovery.
+	if v, ok := dev.(*vethTun); ok {
+		_, err = runIP(family, "neigh", "replace", peer.String(), "lladdr", net.HardwareAddr(v.epHwAddr[:]).String(), "nud", "permanent", "dev", dev.Name())
+	}
+	return err
+}

+ 129 - 27
internal/aerotun/device.go

@@ -2,12 +2,17 @@ package aerotun
 
 import (
 	"encoding/binary"
+	"errors"
 	"fmt"
 	"net"
 	"os"
 	"os/exec"
+	"runtime"
+	"sync"
 	"syscall"
 	"unsafe"
+
+	"golang.org/x/sys/unix"
 )
 
 type VirtualDevice interface {
@@ -20,6 +25,9 @@ type VirtualDevice interface {
 // CreateDevice creates either a native Linux TUN device, or falls back to a high-speed
 // veth pair with AF_PACKET raw socket if running in an unprivileged container without /dev/net/tun.
 func CreateDevice(name string, mtu int, localCIDR string) (VirtualDevice, error) {
+	if _, err := net.InterfaceByName(name); err == nil {
+		return nil, fmt.Errorf("interface %s already exists", name)
+	}
 	if mtu <= 0 {
 		mtu = DefaultMTU
 	}
@@ -45,7 +53,7 @@ type nativeTun struct {
 }
 
 func createNativeTun(name string, mtu int, localCIDR string) (*nativeTun, error) {
-	fd, err := syscall.Open("/dev/net/tun", syscall.O_RDWR, 0)
+	fd, err := syscall.Open("/dev/net/tun", syscall.O_RDWR|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0)
 	if err != nil {
 		return nil, err
 	}
@@ -59,7 +67,7 @@ func createNativeTun(name string, mtu int, localCIDR string) (*nativeTun, error)
 	var ifr ifreq
 	copy(ifr.name[:], name)
 	// IFF_TUN = 0x0001, IFF_NO_PI = 0x1000
-	ifr.flags = 0x0001 | 0x1000
+	ifr.flags = 0x0001 | 0x1000 | 0x8000 // IFF_TUN_EXCL: never attach an existing device
 
 	_, _, errno := syscall.Syscall(
 		syscall.SYS_IOCTL,
@@ -73,11 +81,20 @@ func createNativeTun(name string, mtu int, localCIDR string) (*nativeTun, error)
 	}
 
 	// Configure MTU and IP
-	_ = exec.Command("ip", "link", "set", name, "mtu", fmt.Sprintf("%d", mtu)).Run()
-	_ = exec.Command("ip", "link", "set", name, "up").Run()
+	if _, err := runIP("link", "set", name, "mtu", fmt.Sprintf("%d", mtu)); err != nil {
+		_ = syscall.Close(fd)
+		return nil, err
+	}
+	if _, err := runIP("link", "set", name, "up"); err != nil {
+		_ = syscall.Close(fd)
+		return nil, err
+	}
 
 	if localCIDR != "" {
-		_ = exec.Command("ip", "addr", "add", localCIDR, "dev", name).Run()
+		if _, err := runIP("addr", "add", localCIDR, "dev", name); err != nil {
+			_ = syscall.Close(fd)
+			return nil, err
+		}
 	}
 
 	return &nativeTun{
@@ -95,9 +112,8 @@ func (t *nativeTun) Write(buf []byte) (int, error) {
 }
 
 func (t *nativeTun) Close() error {
-	err := t.file.Close()
-	_ = exec.Command("ip", "link", "delete", t.name).Run()
-	return err
+	// Non-persistent TUN interfaces disappear when the descriptor closes.
+	return t.file.Close()
 }
 
 func (t *nativeTun) Name() string {
@@ -109,13 +125,16 @@ func (t *nativeTun) Name() string {
 // ---------------------------------------------------------------------------
 
 type vethTun struct {
+	mu        sync.RWMutex
+	closed    bool
 	name      string
 	epName    string
 	sockFd    int
 	ifIndex   int
 	hwAddr    [6]byte
 	epHwAddr  [6]byte
-	packetBuf [2048]byte
+	packetBuf []byte
+	writeBuf  []byte
 }
 
 func createVethTun(name string, mtu int, localCIDR string) (*vethTun, error) {
@@ -124,9 +143,7 @@ func createVethTun(name string, mtu int, localCIDR string) (*vethTun, error) {
 		epName = epName[:15]
 	}
 
-	// Clean up any stale interfaces
-	_ = exec.Command("ip", "link", "delete", name).Run()
-	_ = exec.Command("ip", "link", "delete", epName).Run()
+	// Existing interfaces may belong to another tunnel: creation must fail safely.
 
 	// 1. Create veth pair
 	out, err := exec.Command("ip", "link", "add", name, "type", "veth", "peer", "name", epName).CombinedOutput()
@@ -135,16 +152,46 @@ func createVethTun(name string, mtu int, localCIDR string) (*vethTun, error) {
 	}
 
 	// 2. Set Point-to-Point / ARP off behavior
-	_ = exec.Command("ip", "link", "set", name, "arp", "off").Run()
-	_ = exec.Command("ip", "link", "set", epName, "arp", "off").Run()
-	_ = exec.Command("ip", "link", "set", name, "mtu", fmt.Sprintf("%d", mtu)).Run()
-	_ = exec.Command("ip", "link", "set", epName, "mtu", fmt.Sprintf("%d", mtu)).Run()
-	_ = exec.Command("ip", "link", "set", name, "up").Run()
-	_ = exec.Command("ip", "link", "set", epName, "up").Run()
+	if _, err := runIP("link", "set", name, "arp", "off"); err != nil {
+		_ = exec.Command("ip", "link", "delete", name).Run()
+		return nil, err
+	}
+	if _, err := runIP("link", "set", epName, "arp", "off"); err != nil {
+		_ = exec.Command("ip", "link", "delete", name).Run()
+		return nil, err
+	}
+	if _, err := runIP("link", "set", name, "mtu", fmt.Sprintf("%d", mtu)); err != nil {
+		_ = exec.Command("ip", "link", "delete", name).Run()
+		return nil, err
+	}
+	if _, err := runIP("link", "set", epName, "mtu", fmt.Sprintf("%d", mtu)); err != nil {
+		_ = exec.Command("ip", "link", "delete", name).Run()
+		return nil, err
+	}
+	if _, err := runIP("link", "set", name, "up"); err != nil {
+		_ = exec.Command("ip", "link", "delete", name).Run()
+		return nil, err
+	}
+	if _, err := runIP("link", "set", epName, "up"); err != nil {
+		_ = exec.Command("ip", "link", "delete", name).Run()
+		return nil, err
+	}
 
 	// 3. Assign IP to main device
 	if localCIDR != "" {
-		_ = exec.Command("ip", "addr", "add", localCIDR, "dev", name).Run()
+		if _, err := runIP("addr", "add", localCIDR, "dev", name); err != nil {
+			_ = exec.Command("ip", "link", "delete", name).Run()
+			return nil, err
+		}
+	}
+
+	// Raw sockets do not complete CHECKSUM_PARTIAL or segment GSO packets.
+	// Disable these features on both ends before any traffic can be forwarded.
+	for _, iface := range []string{name, epName} {
+		if err := disableOffloads(iface); err != nil {
+			_ = exec.Command("ip", "link", "delete", name).Run()
+			return nil, err
+		}
 	}
 
 	// 4. Get endpoint interface index
@@ -156,13 +203,16 @@ func createVethTun(name string, mtu int, localCIDR string) (*vethTun, error) {
 
 	// 5. Open AF_PACKET raw socket bound to endpoint
 	// htons(ETH_P_ALL) = 0x0300
-	const ethPAll = 0x0300
-	sockFd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(htons(syscall.ETH_P_ALL)))
+	sockFd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW|syscall.SOCK_CLOEXEC, int(htons(syscall.ETH_P_ALL)))
 	if err != nil {
 		_ = exec.Command("ip", "link", "delete", name).Run()
 		return nil, fmt.Errorf("failed to open raw AF_PACKET socket: %w", err)
 	}
 
+	// PACKET_IGNORE_OUTGOING avoids injecting locally sent frames back into VPN.
+	_ = syscall.SetsockoptInt(sockFd, 263, 23, 1)
+	// A receive timeout lets Close reliably stop a blocked raw-socket reader.
+	_ = syscall.SetsockoptTimeval(sockFd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &syscall.Timeval{Sec: 1})
 	addr := syscall.SockaddrLinklayer{
 		Protocol: htons(syscall.ETH_P_ALL),
 		Ifindex:  epIface.Index,
@@ -174,6 +224,7 @@ func createVethTun(name string, mtu int, localCIDR string) (*vethTun, error) {
 	}
 
 	dev := &vethTun{
+		packetBuf: make([]byte, mtu+14), writeBuf: make([]byte, mtu+14),
 		name:    name,
 		epName:  epName,
 		sockFd:  sockFd,
@@ -192,10 +243,22 @@ func createVethTun(name string, mtu int, localCIDR string) (*vethTun, error) {
 
 func (v *vethTun) Read(buf []byte) (int, error) {
 	for {
-		n, _, err := syscall.Recvfrom(v.sockFd, v.packetBuf[:], 0)
+		v.mu.RLock()
+		if v.closed {
+			v.mu.RUnlock()
+			return 0, net.ErrClosed
+		}
+		n, addr, err := syscall.Recvfrom(v.sockFd, v.packetBuf[:], 0)
+		v.mu.RUnlock()
+		if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EINTR) {
+			continue
+		}
 		if err != nil {
 			return 0, err
 		}
+		if ll, ok := addr.(*syscall.SockaddrLinklayer); ok && ll.Pkttype == syscall.PACKET_OUTGOING {
+			continue
+		}
 		if n <= 14 {
 			continue // Too small for ethernet frame
 		}
@@ -220,12 +283,10 @@ func (v *vethTun) Write(buf []byte) (int, error) {
 
 	// Build raw Ethernet frame to inject into endpoint
 	frameLen := 14 + len(buf)
-	var frame []byte
-	if frameLen <= len(v.packetBuf) {
-		frame = v.packetBuf[:frameLen]
-	} else {
-		frame = make([]byte, frameLen)
+	if frameLen > len(v.writeBuf) {
+		return 0, fmt.Errorf("packet exceeds configured veth MTU")
 	}
+	frame := v.writeBuf[:frameLen] // Separate reusable RX/TX storage; one writer per device.
 
 	// Dst MAC: main interface, Src MAC: endpoint
 	copy(frame[0:6], v.hwAddr[:])
@@ -246,6 +307,11 @@ func (v *vethTun) Write(buf []byte) (int, error) {
 	}
 	copy(addr.Addr[:], v.hwAddr[:])
 
+	v.mu.RLock()
+	defer v.mu.RUnlock()
+	if v.closed {
+		return 0, net.ErrClosed
+	}
 	err := syscall.Sendto(v.sockFd, frame, 0, &addr)
 	if err != nil {
 		return 0, err
@@ -254,7 +320,14 @@ func (v *vethTun) Write(buf []byte) (int, error) {
 }
 
 func (v *vethTun) Close() error {
+	v.mu.Lock()
+	if v.closed {
+		v.mu.Unlock()
+		return nil
+	}
+	v.closed = true
 	_ = syscall.Close(v.sockFd)
+	v.mu.Unlock()
 	_ = exec.Command("ip", "link", "delete", v.name).Run()
 	return nil
 }
@@ -266,3 +339,32 @@ func (v *vethTun) Name() string {
 func htons(n uint16) uint16 {
 	return (n<<8)&0xff00 | (n>>8)&0x00ff
 }
+
+// Linux ethtool ioctls avoid requiring an external ethtool executable in LXC.
+func disableOffloads(name string) error {
+	fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM|unix.SOCK_CLOEXEC, 0)
+	if err != nil {
+		return err
+	}
+	defer unix.Close(fd)
+	for _, cmd := range []uint32{0x1f, 0x24, 0x2c, 0x17} { // TSO, GSO, GRO, TX checksum
+		value := struct{ Command, Data uint32 }{Command: cmd}
+		ifr := struct {
+			Name [16]byte
+			Data unsafe.Pointer
+			Pad  [16]byte
+		}{Data: unsafe.Pointer(&value)}
+		copy(ifr.Name[:], name)
+		_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), 0x8946, uintptr(unsafe.Pointer(&ifr)))
+		var err error
+		if errno != 0 {
+			err = errno
+		}
+		runtime.KeepAlive(&value)
+		// Unsupported offloads are already unavailable on this device.
+		if err != nil && !errors.Is(err, unix.EOPNOTSUPP) {
+			return fmt.Errorf("disable offload %#x on %s: %w", cmd, name, err)
+		}
+	}
+	return nil
+}

+ 350 - 0
internal/aerotun/integration_test.go

@@ -0,0 +1,350 @@
+package aerotun
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"syscall"
+	"testing"
+	"time"
+
+	"proxmox-router/internal/bwtest"
+)
+
+// Opt in: AEROTUN_INTEGRATION=1 go test -run TestVethBandwidthIntegration -v ./internal/aerotun
+// All links/routes and test processes live in disposable network namespaces.
+func TestVethBandwidthIntegration(t *testing.T) {
+	if os.Getenv("AEROTUN_INTEGRATION") != "1" {
+		t.Skip("requires root, network namespaces, ip, nsenter and iperf3")
+	}
+	stage := os.Getenv("AEROTUN_TEST_STAGE")
+	if stage == "peer" {
+		runNamespacePeer(t)
+		return
+	}
+	if stage == "" {
+		ctx, cancel := context.WithTimeout(t.Context(), 100*time.Second)
+		defer cancel()
+		cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestVethBandwidthIntegration$", "-test.v")
+		cmd.Env = append(os.Environ(), "AEROTUN_TEST_STAGE=harness")
+		cmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: syscall.CLONE_NEWNET}
+		out, err := cmd.CombinedOutput()
+		if err != nil {
+			t.Fatalf("isolated harness: %v\n%s", err, out)
+		}
+		t.Log(string(out))
+		return
+	}
+	t.Run("default-route", testNamespaceDefaultRoute)
+	versions := []string{"2", "3"}
+	if selected := os.Getenv("AEROTUN_TEST_ONLY_VERSION"); selected != "" {
+		versions = []string{selected}
+	}
+	for _, version := range versions {
+		t.Run("v"+version, func(t *testing.T) {
+			t.Setenv("AEROTUN_TEST_VERSION", version)
+			for _, compress := range []bool{false, true} {
+				t.Run(fmt.Sprintf("compression=%v", compress), func(t *testing.T) { runBandwidthPair(t, compress) })
+			}
+		})
+	}
+}
+func waitForFile(t *testing.T, path string) {
+	t.Helper()
+	deadline := time.Now().Add(12 * time.Second)
+	for time.Now().Before(deadline) {
+		if _, err := os.Stat(path); err == nil {
+			return
+		}
+		time.Sleep(25 * time.Millisecond)
+	}
+	t.Fatalf("timed out waiting for %s", path)
+}
+func runNamespacePeer(t *testing.T) {
+	dir := os.Getenv("AEROTUN_TEST_DIR")
+	role := os.Getenv("AEROTUN_TEST_ROLE")
+	waitForFile(t, filepath.Join(dir, role+".start"))
+	cfg := TunnelInstance{Name: "atest0", Mode: role, ListenPort: 51830, PSK: testPSK, MTU: 1420, LocalAddress: "10.99.0.1/32", PeerAddress: "10.99.0.2/32", Compression: os.Getenv("AEROTUN_TEST_COMPRESS") == "true"}
+	if role == "client" {
+		cfg.LocalAddress, cfg.PeerAddress = cfg.PeerAddress, cfg.LocalAddress
+		cfg.RemoteEndpoint = "192.0.2.1:51830"
+	}
+	expectedMTU := 1292
+	if data, err := os.ReadFile(filepath.Join(dir, role+".config")); err == nil {
+		if err = json.Unmarshal(data, &cfg); err != nil {
+			t.Fatal(err)
+		}
+		expectedMTU = 1320
+	}
+	r, err := NewTunnelRunner(cfg)
+	if err != nil {
+		t.Fatal(err)
+	}
+	// Exercise the container fallback even on hosts which provide native TUN.
+	if _, ok := r.dev.(*vethTun); !ok {
+		r.Stop()
+		t.Fatal("integration requires veth fallback (/dev/net/tun absent)")
+	}
+	if err = r.Start(); err != nil {
+		r.Stop()
+		t.Fatal(err)
+	}
+	defer r.Stop()
+	if role == "server" {
+		benchmark := bwtest.NewServer(bwtest.ServerConfig{Enabled: true, ListenAddr: "10.99.0.1", Port: 15200})
+		if err := benchmark.Start(); err != nil {
+			t.Fatal(err)
+		}
+		defer benchmark.Stop()
+	}
+	_ = os.WriteFile(filepath.Join(dir, role+".ready"), nil, 0600)
+	deadline := time.Now().Add(45 * time.Second)
+	for time.Now().Before(deadline) {
+		if r.GetTelemetry().Connected && r.GetTelemetry().MTU == expectedMTU {
+			_ = os.WriteFile(filepath.Join(dir, role+".connected"), nil, 0600)
+		}
+		if _, err := os.Stat(filepath.Join(dir, "stop")); err == nil {
+			stats := r.GetTelemetry()
+			if stats.TxBytes == 0 || stats.RxBytes == 0 {
+				t.Fatal("no bidirectional data delivered")
+			}
+			if cfg.Compression && stats.CompressedPackets == 0 {
+				t.Fatal("compressed path was not exercised")
+			}
+			t.Logf("delivered tx=%d rx=%d compressed=%d replay drops=%d", stats.TxBytes, stats.RxBytes, stats.CompressedPackets, stats.ReplayDrops)
+			return
+		}
+		time.Sleep(50 * time.Millisecond)
+	}
+	t.Fatal("peer helper timed out")
+}
+func runBandwidthPair(t *testing.T, compress bool) {
+	dir := t.TempDir()
+	type process struct {
+		cmd *exec.Cmd
+		log *os.File
+	}
+	if os.Getenv("AEROTUN_TEST_VERSION") == "3" {
+		client, server := secureConfigs(t)
+		client.Compression = compress
+		server.Compression = compress
+		client.ListenPort = 51830
+		server.ListenPort = 51830
+		for role, cfg := range map[string]TunnelInstance{"server": server, "client": client} {
+			b, _ := json.Marshal(cfg)
+			if err := os.WriteFile(filepath.Join(dir, role+".config"), b, 0600); err != nil {
+				t.Fatal(err)
+			}
+		}
+	}
+	var peers []process
+	defer func() {
+		_ = os.WriteFile(filepath.Join(dir, "stop"), nil, 0600)
+		for _, p := range peers {
+			err := p.cmd.Wait()
+			_ = p.log.Close()
+			b, _ := os.ReadFile(p.log.Name())
+			if err != nil {
+				t.Errorf("peer: %v\n%s", err, b)
+			}
+		}
+	}()
+	for _, role := range []string{"server", "client"} {
+		logFile, err := os.Create(filepath.Join(dir, role+".log"))
+		if err != nil {
+			t.Fatal(err)
+		}
+		cmd := exec.Command(os.Args[0], "-test.run=^TestVethBandwidthIntegration$", "-test.v")
+		cmd.Env = append(os.Environ(), "AEROTUN_TEST_STAGE=peer", "AEROTUN_TEST_DIR="+dir, "AEROTUN_TEST_ROLE="+role, "AEROTUN_TEST_COMPRESS="+strconv.FormatBool(compress))
+		cmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: syscall.CLONE_NEWNET}
+		cmd.Stdout = logFile
+		cmd.Stderr = logFile
+		if err = cmd.Start(); err != nil {
+			t.Fatal(err)
+		}
+		peers = append(peers, process{cmd, logFile})
+	}
+	run := func(args ...string) {
+		t.Helper()
+		out, err := exec.Command("ip", args...).CombinedOutput()
+		if err != nil {
+			t.Fatalf("ip %v: %v %s", args, err, out)
+		}
+	}
+	run("link", "add", "atest-a", "type", "veth", "peer", "name", "atest-b")
+	for i, p := range peers {
+		name := []string{"atest-a", "atest-b"}[i]
+		pid := strconv.Itoa(p.cmd.Process.Pid)
+		run("link", "set", name, "netns", pid)
+		for _, args := range [][]string{{"link", "set", "lo", "up"}, {"link", "set", name, "up"}, {"addr", "add", fmt.Sprintf("192.0.2.%d/30", i+1), "dev", name}} {
+			out, err := exec.Command("nsenter", append([]string{"-t", pid, "-n", "ip"}, args...)...).CombinedOutput()
+			if err != nil {
+				t.Fatalf("namespace IP setup: %v %s", err, out)
+			}
+		}
+	}
+	// Model production: server cached path MTU 1380, client WireGuard link 1420.
+	for i, mtu := range []string{"1380", "1420"} {
+		out, err := exec.Command("nsenter", "-t", strconv.Itoa(peers[i].cmd.Process.Pid), "-n", "ip", "link", "set", []string{"atest-a", "atest-b"}[i], "mtu", mtu).CombinedOutput()
+		if err != nil {
+			t.Fatalf("set asymmetric MTU: %v %s", err, out)
+		}
+	}
+	for _, role := range []string{"server", "client"} {
+		_ = os.WriteFile(filepath.Join(dir, role+".start"), nil, 0600)
+		waitForFile(t, filepath.Join(dir, role+".ready"))
+	}
+	for _, role := range []string{"server", "client"} {
+		waitForFile(t, filepath.Join(dir, role+".connected"))
+	}
+	nsCmd := func(ctx context.Context, idx int, args ...string) *exec.Cmd {
+		return exec.CommandContext(ctx, "nsenter", append([]string{"-t", strconv.Itoa(peers[idx].cmd.Process.Pid), "-n"}, args...)...)
+	}
+	for _, mode := range []string{"tcp-forward", "tcp-reverse", "udp"} {
+		ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+		defer cancel()
+		server := nsCmd(ctx, 0, "iperf3", "-s", "-1", "-B", "10.99.0.1")
+		if err := server.Start(); err != nil {
+			t.Fatal(err)
+		}
+		time.Sleep(150 * time.Millisecond)
+		args := []string{"iperf3", "-c", "10.99.0.1", "-t", "2", "-J"}
+		if compress {
+			args = append(args, "--repeating-payload")
+		}
+		switch mode {
+		case "tcp-forward":
+			args = append(args, "-P", "2")
+		case "tcp-reverse":
+			args = append(args, "-R")
+		case "udp":
+			args = append(args, "-u", "-b", "20M", "-l", "1472")
+		}
+		out, err := nsCmd(ctx, 1, args...).Output()
+		if err != nil {
+			_ = server.Process.Kill()
+			_ = server.Wait()
+			t.Fatalf("%s: %v %s", mode, err, out)
+		}
+		if err = server.Wait(); err != nil {
+			t.Fatal(err)
+		}
+		var result struct {
+			Error string `json:"error"`
+			End   struct {
+				Received struct {
+					Bytes uint64  `json:"bytes"`
+					BPS   float64 `json:"bits_per_second"`
+				} `json:"sum_received"`
+			} `json:"end"`
+		}
+		if err = json.Unmarshal(out, &result); err != nil || result.Error != "" {
+			t.Fatalf("iperf JSON: %v %s", err, out)
+		}
+		if result.End.Received.Bytes < 1<<20 {
+			t.Fatalf("%s delivered too little data: %s", mode, strings.TrimSpace(string(out)))
+		}
+		t.Logf("%s: %.1f Mbit/s, %d received bytes", mode, result.End.Received.BPS/1e6, result.End.Received.Bytes)
+	}
+	if !compress {
+		ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
+		defer cancel()
+		for i, name := range []string{"atest-a", "atest-b"} {
+			out, err := nsCmd(ctx, i, "tc", "qdisc", "add", "dev", name, "root", "netem", "rate", "5mbit", "delay", "15ms", "limit", "64").CombinedOutput()
+			if err != nil {
+				t.Fatalf("constrain underlay: %v %s", err, out)
+			}
+		}
+		cmd := nsCmd(ctx, 1, os.Args[0], "-test.run=^TestUDPOverAeroTunConstrainedLink$", "-test.v")
+		cmd.Env = append(os.Environ(), "AEROTUN_BW_CLIENT=1")
+		out, err := cmd.CombinedOutput()
+		if err != nil {
+			t.Fatalf("UDP feedback over constrained AeroTUN: %v\n%s", err, out)
+		}
+		t.Log(string(out))
+	}
+}
+
+func testNamespaceDefaultRoute(t *testing.T) {
+	run := func(args ...string) []byte {
+		t.Helper()
+		out, err := runIP(args...)
+		if err != nil {
+			t.Fatal(err)
+		}
+		return out
+	}
+	run("link", "add", "testwan", "type", "dummy")
+	defer runIP("link", "del", "testwan")
+	run("link", "set", "testwan", "up")
+	run("addr", "add", "198.18.0.2/24", "dev", "testwan")
+	run("route", "add", "default", "via", "198.18.0.1", "dev", "testwan")
+	run("link", "add", "testoverlay", "type", "dummy")
+	defer runIP("link", "del", "testoverlay")
+	run("link", "set", "testoverlay", "up")
+	r := routeManager{run: runIP}
+	if err := r.install("testoverlay", net.ParseIP("203.0.113.9")); err != nil {
+		t.Fatal(err)
+	}
+	defer r.cleanup()
+	for _, check := range []struct{ target, dev string }{{"203.0.113.9", "testwan"}, {"8.8.8.8", "testoverlay"}, {"200.0.0.1", "testoverlay"}} {
+		out := run("-j", "route", "get", check.target)
+		var paths []struct {
+			Dev string `json:"dev"`
+		}
+		if err := json.Unmarshal(out, &paths); err != nil || len(paths) != 1 || paths[0].Dev != check.dev {
+			t.Fatalf("route to %s: %s %v", check.target, out, err)
+		}
+	}
+	r.cleanup()
+	out := run("route", "show", "default")
+	if !strings.Contains(string(out), "via 198.18.0.1 dev testwan") {
+		t.Fatalf("original default lost: %s", out)
+	}
+	if out = run("route", "show", "exact", "203.0.113.9/32"); len(out) != 0 {
+		t.Fatalf("endpoint route leaked: %s", out)
+	}
+}
+
+func TestUDPOverAeroTunConstrainedLink(t *testing.T) {
+	if os.Getenv("AEROTUN_BW_CLIENT") != "1" {
+		t.Skip("run by isolated integration harness")
+	}
+	for _, direction := range []string{"rx", "both"} {
+		t.Run(direction, func(t *testing.T) {
+			client := bwtest.NewClient()
+			defer client.Stop()
+			if err := client.Run(t.Context(), bwtest.SessionRequest{Target: "10.99.0.1", Port: 15200, Protocol: "udp", Direction: direction, Streams: 4, PacketSize: 1472, Duration: 4}); err != nil {
+				t.Fatal(err)
+			}
+			deadline := time.Now().Add(6 * time.Second)
+			for client.GetStatus().Running && time.Now().Before(deadline) {
+				time.Sleep(20 * time.Millisecond)
+			}
+			st := client.GetStatus()
+			if st.Running || st.Error != "" || st.Summary == nil {
+				t.Fatalf("UDP benchmark failed: %+v", st)
+			}
+			expectedSize := 1264
+			if os.Getenv("AEROTUN_TEST_VERSION") == "3" {
+				expectedSize = 1292
+			}
+			if st.PacketSize != expectedSize {
+				t.Fatalf("datagram not clamped to negotiated overlay MTU: %d", st.PacketSize)
+			}
+			if st.Summary.TotalRxBytes < 100000 || (direction == "both" && st.Summary.TotalTxBytes < 100000) {
+				t.Fatalf("reverse transfer starved: %+v", st.Summary)
+			}
+			if st.Summary.AvgLossPct > 40 {
+				t.Fatalf("automatic UDP flooded constrained link: %+v", st.Summary)
+			}
+			t.Logf("%s: confirmed TX %.2f / RX %.2f Mbit/s, loss %.1f%%, payload %d", direction, st.Summary.AvgTxMbps, st.Summary.AvgRxMbps, st.Summary.AvgLossPct, st.PacketSize)
+		})
+	}
+}

+ 38 - 0
internal/aerotun/keys.go

@@ -0,0 +1,38 @@
+package aerotun
+
+import (
+	"encoding/base64"
+	"encoding/hex"
+	"errors"
+	"github.com/flynn/noise"
+)
+
+func decodeKey(s string) ([]byte, error) {
+	if len(s) == 64 {
+		if b, err := hex.DecodeString(s); err == nil {
+			return b, nil
+		}
+	}
+	b, err := base64.StdEncoding.DecodeString(s)
+	if err != nil || len(b) != 32 {
+		return nil, errors.New("key must encode exactly 32 bytes (64 hex characters or base64)")
+	}
+	return b, nil
+}
+func GenerateIdentity() (private, public string, err error) {
+	k, err := noise.DH25519.GenerateKeypair(nil)
+	if err != nil {
+		return "", "", err
+	}
+	return base64.StdEncoding.EncodeToString(k.Private), base64.StdEncoding.EncodeToString(k.Public), nil
+}
+
+// PublicView is the only configuration representation sent to API clients.
+// Secret fields remain available solely for private configuration persistence.
+func (c TunnelInstance) PublicView() TunnelInstance {
+	c.HasPSK = c.PSK != ""
+	c.PSK = ""
+	c.HasPrivateKey = c.PrivateKey != ""
+	c.PrivateKey = ""
+	return c
+}

+ 102 - 32
internal/aerotun/manager.go

@@ -15,14 +15,17 @@ type Manager struct {
 	mu        sync.RWMutex
 	instances map[string]TunnelInstance
 	runners   map[string]*TunnelRunner
+	errors    map[string]string
 	filePath  string
 }
 
-func NewManager() *Manager {
+func NewManager() *Manager { return NewManagerWithPath(StoragePath) }
+func NewManagerWithPath(path string) *Manager {
 	m := &Manager{
 		instances: make(map[string]TunnelInstance),
 		runners:   make(map[string]*TunnelRunner),
-		filePath:  StoragePath,
+		errors:    make(map[string]string),
+		filePath:  path,
 	}
 
 	_ = os.MkdirAll(filepath.Dir(m.filePath), 0755)
@@ -34,6 +37,7 @@ func NewManager() *Manager {
 	for id, inst := range m.instances {
 		if !inst.Disabled {
 			if err := m.startRunnerLocked(id); err != nil {
+				m.errors[id] = err.Error()
 				log.Printf("[AeroTUN] Failed to auto-start tunnel %s (%s): %v", inst.Name, id, err)
 			}
 		}
@@ -71,6 +75,15 @@ func (m *Manager) saveLocked() error {
 		// Do not persist volatile runtime counters
 		copyInst := inst
 		copyInst.Running = false
+		copyInst.Handshakes = 0
+		copyInst.Backend = ""
+		copyInst.HasPSK = false
+		copyInst.HasPrivateKey = false
+		copyInst.Connected = false
+		copyInst.LastError = ""
+		copyInst.DroppedPackets = 0
+		copyInst.AuthFailures = 0
+		copyInst.ReplayDrops = 0
 		copyInst.ActivePeerEndpoint = ""
 		copyInst.LastHandshake = ""
 		copyInst.RxBytes = 0
@@ -93,7 +106,27 @@ func (m *Manager) saveLocked() error {
 		return err
 	}
 
-	return os.WriteFile(m.filePath, data, 0600)
+	f, err := os.CreateTemp(filepath.Dir(m.filePath), ".aerotun-*.json")
+	if err != nil {
+		return err
+	}
+	defer os.Remove(f.Name())
+	if err = f.Chmod(0600); err != nil {
+		f.Close()
+		return err
+	}
+	if _, err = f.Write(data); err != nil {
+		f.Close()
+		return err
+	}
+	if err = f.Sync(); err != nil {
+		f.Close()
+		return err
+	}
+	if err = f.Close(); err != nil {
+		return err
+	}
+	return os.Rename(f.Name(), m.filePath)
 }
 
 func (m *Manager) List() []TunnelInstance {
@@ -109,6 +142,8 @@ func (m *Manager) List() []TunnelInstance {
 			result = append(result, tele)
 		} else {
 			inst.Running = false
+			inst.Connected = false
+			inst.LastError = m.errors[id]
 			result = append(result, inst)
 		}
 	}
@@ -135,6 +170,9 @@ func (m *Manager) Get(id string) (TunnelInstance, bool) {
 		tele.Comment = inst.Comment
 		return tele, true
 	}
+	inst.Running = false
+	inst.Connected = false
+	inst.LastError = m.errors[id]
 	return inst, true
 }
 
@@ -148,31 +186,61 @@ func (m *Manager) Save(inst TunnelInstance) (TunnelInstance, error) {
 	if inst.Name == "" {
 		inst.Name = fmt.Sprintf("aerotun%d", len(m.instances))
 	}
-	if inst.MTU <= 0 {
+	if inst.MTU == 0 {
 		inst.MTU = DefaultMTU
 	}
-	if inst.Mode == "server" && inst.ListenPort <= 0 {
+	if inst.Mode == "server" && inst.ListenPort == 0 {
 		inst.ListenPort = DefaultPort
 	}
 	if inst.CreatedAt.IsZero() {
 		inst.CreatedAt = time.Now()
 	}
 
-	// If existing runner is active, stop it before applying new config
-	if runner, exists := m.runners[inst.ID]; exists && runner != nil {
-		runner.Stop()
-		delete(m.runners, inst.ID)
+	if old, ok := m.instances[inst.ID]; ok {
+		if inst.PSK == "" {
+			inst.PSK = old.PSK
+		}
+		if inst.PrivateKey == "" {
+			inst.PrivateKey = old.PrivateKey
+			inst.PublicKey = old.PublicKey
+		}
 	}
-
+	if inst.Protocol == 3 && inst.PrivateKey == "" {
+		var err error
+		inst.PrivateKey, inst.PublicKey, err = GenerateIdentity()
+		if err != nil {
+			return inst, err
+		}
+	}
+	if err := validateConfig(&inst); err != nil {
+		return inst, err
+	}
+	for id, existing := range m.instances {
+		if id != inst.ID && existing.Name == inst.Name {
+			return inst, fmt.Errorf("interface name already configured: %s", inst.Name)
+		}
+	}
+	previous, existed := m.instances[inst.ID]
 	m.instances[inst.ID] = inst
 	if err := m.saveLocked(); err != nil {
+		if existed {
+			m.instances[inst.ID] = previous
+		} else {
+			delete(m.instances, inst.ID)
+		}
 		return inst, fmt.Errorf("failed to save configuration: %w", err)
 	}
+	if runner := m.runners[inst.ID]; runner != nil {
+		runner.Stop()
+		delete(m.runners, inst.ID)
+	}
+	delete(m.errors, inst.ID)
 
 	// Restart if not disabled
 	if !inst.Disabled {
 		if err := m.startRunnerLocked(inst.ID); err != nil {
-			log.Printf("[AeroTUN] Warning: saved tunnel %s but failed to start: %v", inst.Name, err)
+			m.errors[inst.ID] = err.Error()
+			return inst, fmt.Errorf("configuration saved but tunnel failed to start: %w", err)
 		}
 	}
 
@@ -192,6 +260,7 @@ func (m *Manager) Delete(id string) error {
 	}
 
 	delete(m.instances, id)
+	delete(m.errors, id)
 	return m.saveLocked()
 }
 
@@ -199,16 +268,7 @@ func (m *Manager) StartTunnel(id string) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
-	inst, exists := m.instances[id]
-	if !exists {
-		return fmt.Errorf("tunnel not found: %s", id)
-	}
-
-	inst.Disabled = false
-	m.instances[id] = inst
-	_ = m.saveLocked()
-
-	return m.startRunnerLocked(id)
+	return m.toggleStateLocked(id, false)
 }
 
 func (m *Manager) StopTunnel(id string) error {
@@ -226,25 +286,29 @@ func (m *Manager) ToggleState(id string, disabled bool) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
+	return m.toggleStateLocked(id, disabled)
+}
+func (m *Manager) toggleStateLocked(id string, disabled bool) error {
 	inst, exists := m.instances[id]
 	if !exists {
 		return fmt.Errorf("tunnel not found: %s", id)
 	}
-
+	previous := inst
 	inst.Disabled = disabled
 	m.instances[id] = inst
-	_ = m.saveLocked()
-
+	if err := m.saveLocked(); err != nil {
+		m.instances[id] = previous
+		return err
+	}
 	if disabled {
-		if runner, exists := m.runners[id]; exists && runner != nil {
+		if runner := m.runners[id]; runner != nil {
 			runner.Stop()
 			delete(m.runners, id)
 		}
-	} else {
-		return m.startRunnerLocked(id)
+		delete(m.errors, id)
+		return nil
 	}
-
-	return nil
+	return m.startRunnerLocked(id)
 }
 
 func (m *Manager) startRunnerLocked(id string) error {
@@ -258,12 +322,18 @@ func (m *Manager) startRunnerLocked(id string) error {
 		delete(m.runners, id)
 	}
 
+	delete(m.errors, id)
 	runner, err := NewTunnelRunner(inst)
 	if err != nil {
+		m.errors[id] = err.Error()
 		return err
 	}
 
-	runner.Start()
+	if err := runner.Start(); err != nil {
+		runner.Stop()
+		m.errors[id] = err.Error()
+		return err
+	}
 	m.runners[id] = runner
 	return nil
 }
@@ -284,13 +354,13 @@ func (m *Manager) GetSummaryStats() SummaryStats {
 	tunnels := m.List()
 	stats := SummaryStats{
 		TotalTunnels:      len(tunnels),
-		AlgorithmCrypto:   "ChaCha20-Poly1305 AEAD",
+		AlgorithmCrypto:   "XChaCha20-Poly1305 AEAD (v3: Noise IKpsk2)",
 		AlgorithmCompress: "Adaptive LZ4 (Dynamic Threshold)",
 	}
 
 	var totalRawOut, totalWireOut uint64
 	for _, t := range tunnels {
-		if t.Running {
+		if t.Connected {
 			stats.ActiveTunnels++
 		}
 		stats.TotalRxBytes += t.RxBytes

+ 62 - 0
internal/aerotun/manager_test.go

@@ -0,0 +1,62 @@
+package aerotun
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestManagerPersistenceValidationAndStartupErrors(t *testing.T) {
+	m := &Manager{instances: map[string]TunnelInstance{}, runners: map[string]*TunnelRunner{}, errors: map[string]string{}, filePath: filepath.Join(t.TempDir(), "instances.json")}
+	cfg := TunnelInstance{Name: "atest", Mode: "client", LocalAddress: "10.99.0.1/24", PeerAddress: "10.99.0.2/24", RemoteEndpoint: "192.0.2.1:51830", PSK: testPSK, Disabled: true}
+	saved, err := m.Save(cfg)
+	if err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(m.filePath)
+	if err != nil || info.Mode().Perm() != 0600 {
+		t.Fatalf("config permissions: %v %v", info, err)
+	}
+	m2 := &Manager{instances: map[string]TunnelInstance{}, filePath: m.filePath}
+	if err = m2.load(); err != nil {
+		t.Fatal(err)
+	}
+	if got := m2.instances[saved.ID]; got.PSK != testPSK || !got.Disabled {
+		t.Fatal("configuration did not roundtrip")
+	}
+	duplicate := cfg
+	duplicate.ID = "duplicate"
+	if _, err = m.Save(duplicate); err == nil {
+		t.Fatal("duplicate interface name accepted")
+	}
+	// A bad endpoint fails before any interface creation, and surfaces to the UI.
+	m.instances[saved.ID] = saved
+	saved.RemoteEndpoint = "bad endpoint"
+	m.instances[saved.ID] = saved
+	if err = m.StartTunnel(saved.ID); err == nil {
+		t.Fatal("startup failure hidden")
+	}
+	if got, _ := m.Get(saved.ID); got.Running || got.LastError == "" {
+		t.Fatal("startup failure not exposed in telemetry")
+	}
+}
+func TestManagerFailedPersistenceDoesNotChangeConfig(t *testing.T) {
+	dir := t.TempDir()
+	m := &Manager{instances: map[string]TunnelInstance{}, runners: map[string]*TunnelRunner{}, errors: map[string]string{}, filePath: filepath.Join(dir, "missing", "instances.json")}
+	cfg := TunnelInstance{ID: "test", Name: "atest", Mode: "server", LocalAddress: "10.99.0.1/24", PSK: testPSK, Disabled: true}
+	m.instances[cfg.ID] = cfg
+	changed := cfg
+	changed.Comment = "new"
+	if _, err := m.Save(changed); err == nil {
+		t.Fatal("expected persistence error")
+	}
+	if m.instances[cfg.ID].Comment != "" {
+		t.Fatal("failed save changed in-memory configuration")
+	}
+	if err := m.ToggleState(cfg.ID, false); err == nil {
+		t.Fatal("expected persistence error")
+	}
+	if !m.instances[cfg.ID].Disabled {
+		t.Fatal("failed persistence enabled the tunnel")
+	}
+}

+ 77 - 0
internal/aerotun/mtu.go

@@ -0,0 +1,77 @@
+package aerotun
+
+import (
+	"fmt"
+	"net"
+
+	"github.com/vishvananda/netlink"
+)
+
+// Account for our authenticated header/tag plus UDP and the outer IP header.
+// Route MTU and link MTU are local upper bounds; a smaller remote path MTU can
+// still require an explicitly lower configured MTU.
+func packetHeaderSize(version int) int {
+	if version == 3 {
+		return secureHeaderSize
+	}
+	return headerSize
+}
+func underlayMTU(requested int, remote net.IP) (int, error) {
+	return underlayMTUForHeader(requested, remote, headerSize)
+}
+func underlayMTUForHeader(requested int, remote net.IP, header int) (int, error) {
+	routes, err := netlink.RouteGet(remote)
+	if err != nil {
+		return 0, fmt.Errorf("remote underlay route: %w", err)
+	}
+	if len(routes) == 0 {
+		return 0, fmt.Errorf("no underlay route to %s", remote)
+	}
+	link, err := netlink.LinkByIndex(routes[0].LinkIndex)
+	if err != nil {
+		return 0, err
+	}
+	limit := link.Attrs().MTU
+	if routes[0].MTU > 0 && routes[0].MTU < limit {
+		limit = routes[0].MTU
+	}
+	overhead := header + 16 + 8 + 20
+	if remote.To4() == nil {
+		overhead += 20
+	}
+	limit -= overhead
+	if limit < 576 {
+		return 0, fmt.Errorf("underlay MTU too small for AeroTUN")
+	}
+	if requested > limit {
+		return limit, nil
+	}
+	return requested, nil
+}
+func (t *TunnelRunner) adjustMTU(remote net.IP, peerMTU int) error {
+	mtu, err := underlayMTUForHeader(int(t.effectiveMTU.Load()), remote, packetHeaderSize(t.cfg.Protocol))
+	if err != nil {
+		return err
+	}
+	if peerMTU < mtu {
+		mtu = peerMTU
+	}
+	if mtu == int(t.effectiveMTU.Load()) {
+		return nil
+	}
+	ip, _, _ := net.ParseCIDR(t.cfg.LocalAddress)
+	if ip.To4() == nil && mtu < 1280 {
+		return fmt.Errorf("underlay cannot carry IPv6 tunnel MTU 1280")
+	}
+	names := []string{t.dev.Name()}
+	if v, ok := t.dev.(*vethTun); ok {
+		names = append(names, v.epName)
+	}
+	for _, name := range names {
+		if _, err := runIP("link", "set", name, "mtu", fmt.Sprint(mtu)); err != nil {
+			return err
+		}
+	}
+	t.effectiveMTU.Store(int64(mtu))
+	return nil
+}

+ 175 - 0
internal/aerotun/protocol.go

@@ -0,0 +1,175 @@
+package aerotun
+
+import (
+	"crypto/cipher"
+	"crypto/hmac"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"sync"
+
+	"golang.org/x/crypto/chacha20poly1305"
+)
+
+// Version 2 binds data to fresh sender AND receiver boot identities. Directional
+// keys and XChaCha nonces cannot overlap between peers or restarts.
+const (
+	headerSize    = 44 // magic(2), version(1), flags(1), sender(16), receiver(16), counter(8)
+	flagHello     = 4
+	flagAck       = 8
+	flagConfirm   = 16
+	maxPacketSize = 65535
+)
+
+type sessionID [16]byte
+
+var zeroSession sessionID
+var errReplay = errors.New("replayed or stale packet")
+var errSession = errors.New("peer session is not established")
+
+type replayWindow struct{ max, bits uint64 }
+
+func (w *replayWindow) accept(n uint64) bool {
+	if n == 0 {
+		return false
+	}
+	if n > w.max {
+		d := n - w.max
+		if d >= 64 {
+			w.bits = 1
+		} else {
+			w.bits = (w.bits << d) | 1
+		}
+		w.max = n
+		return true
+	}
+	d := w.max - n
+	if d >= 64 || w.bits&(uint64(1)<<d) != 0 {
+		return false
+	}
+	w.bits |= uint64(1) << d
+	return true
+}
+
+type protocolState struct {
+	mu            sync.Mutex
+	secret        [32]byte
+	local, remote sessionID
+	counter       uint64
+	replay        replayWindow
+	retired       map[sessionID]bool
+	tx, rx        cipher.AEAD
+}
+
+func newProtocol(psk string) (*protocolState, error) {
+	p := &protocolState{secret: sha256.Sum256([]byte(psk)), retired: make(map[sessionID]bool)}
+	if _, err := rand.Read(p.local[:]); err != nil {
+		return nil, err
+	}
+	return p, nil
+}
+func (p *protocolState) aead(sender, receiver sessionID) cipher.AEAD {
+	mac := hmac.New(sha256.New, p.secret[:])
+	mac.Write([]byte("AeroTUN-v2-directional-key"))
+	mac.Write(sender[:])
+	mac.Write(receiver[:])
+	a, _ := chacha20poly1305.NewX(mac.Sum(nil))
+	return a
+}
+func packetNonce(sender sessionID, counter uint64) []byte {
+	nonce := make([]byte, chacha20poly1305.NonceSizeX)
+	copy(nonce, sender[:])
+	binary.BigEndian.PutUint64(nonce[16:], counter)
+	return nonce
+}
+func (p *protocolState) seal(flags byte, payload []byte, target sessionID) ([]byte, error) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	if target == zeroSession && flags != flagHello {
+		target = p.remote
+	}
+	if target == zeroSession && flags != flagHello {
+		return nil, errSession
+	}
+	if p.counter == ^uint64(0) {
+		return nil, fmt.Errorf("packet counter exhausted; restart tunnel")
+	}
+	p.counter++
+	b := make([]byte, headerSize, headerSize+len(payload)+chacha20poly1305.Overhead)
+	binary.BigEndian.PutUint16(b, MagicHeader)
+	b[2] = ProtocolVersion
+	b[3] = flags
+	copy(b[4:20], p.local[:])
+	copy(b[20:36], target[:])
+	binary.BigEndian.PutUint64(b[36:], p.counter)
+	a := p.tx
+	if target != p.remote || flags == flagHello || a == nil {
+		a = p.aead(p.local, target)
+	}
+	return a.Seal(b, packetNonce(p.local, p.counter), payload, b), nil
+}
+
+// Authenticate before changing replay or peer state. Only an ACK addressed to
+// our fresh boot ID (ACK or CONFIRM) establishes a session; HELLOs cannot
+// replace a working peer.
+func (p *protocolState) open(b []byte) (payload []byte, flags byte, sender sessionID, changed bool, err error) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	if len(b) < headerSize+chacha20poly1305.Overhead || binary.BigEndian.Uint16(b) != MagicHeader || b[2] != ProtocolVersion {
+		err = errors.New("invalid AeroTUN v2 packet")
+		return
+	}
+	flags = b[3]
+	if flags != 0 && flags != FlagCompressedLZ4 && flags != FlagKeepalive && flags != flagHello && flags != flagAck && flags != flagConfirm {
+		err = errors.New("invalid packet flags")
+		return
+	}
+	copy(sender[:], b[4:20])
+	var receiver sessionID
+	copy(receiver[:], b[20:36])
+	if sender == zeroSession || sender == p.local || (flags == flagHello && receiver != zeroSession) || (flags != flagHello && receiver != p.local) {
+		err = errSession
+		return
+	}
+	n := binary.BigEndian.Uint64(b[36:44])
+	if n == 0 {
+		err = errReplay
+		return
+	}
+	a := p.rx
+	if sender != p.remote || flags == flagHello || a == nil {
+		a = p.aead(sender, receiver)
+	}
+	payload, err = a.Open(nil, packetNonce(sender, n), b[headerSize:], b[:headerSize])
+	if err != nil {
+		return
+	}
+	if flags != 0 && flags != FlagCompressedLZ4 && (len(payload) != 2 || binary.BigEndian.Uint16(payload) < 576 || binary.BigEndian.Uint16(payload) > 9000) {
+		err = errSession
+		return
+	}
+	if flags == flagHello {
+		return
+	}
+	if sender != p.remote {
+		if (flags != flagAck && flags != flagConfirm) || p.retired[sender] || len(p.retired) >= 1024 {
+			err = errSession
+			return
+		}
+		if p.remote != zeroSession {
+			p.retired[p.remote] = true
+		}
+		p.remote = sender
+		p.replay = replayWindow{}
+		p.tx = p.aead(p.local, sender)
+		p.rx = a
+		changed = true
+	}
+	if !p.replay.accept(n) {
+		err = errReplay
+		return
+	}
+	return
+}

+ 147 - 0
internal/aerotun/protocol_test.go

@@ -0,0 +1,147 @@
+package aerotun
+
+import (
+	"bytes"
+	"encoding/binary"
+	"errors"
+	"testing"
+)
+
+const testPSK = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+
+func testProtocol(t *testing.T) *protocolState {
+	t.Helper()
+	p, err := newProtocol(testPSK)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return p
+}
+func testSeal(t *testing.T, p *protocolState, flags byte, b []byte, target sessionID) []byte {
+	t.Helper()
+	if flags != 0 && flags != FlagCompressedLZ4 && b == nil {
+		b = []byte{5, 140}
+	} // 1420-byte MTU advertisement
+	out, err := p.seal(flags, b, target)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return out
+}
+func establish(t *testing.T, a, b *protocolState) {
+	t.Helper()
+	hello := testSeal(t, a, flagHello, nil, zeroSession)
+	_, _, sender, _, err := b.open(hello)
+	if err != nil {
+		t.Fatal(err)
+	}
+	ack := testSeal(t, b, flagAck, nil, sender)
+	_, _, sender, _, err = a.open(ack)
+	if err != nil {
+		t.Fatal(err)
+	}
+	confirm := testSeal(t, a, flagConfirm, nil, sender)
+	if _, _, _, _, err = b.open(confirm); err != nil {
+		t.Fatal(err)
+	}
+}
+func TestProtocolAuthenticationReplayAndRestart(t *testing.T) {
+	a, b := testProtocol(t), testProtocol(t)
+	establish(t, a, b)
+	forward := testSeal(t, a, 0, []byte("forward"), zeroSession)
+	reverse := testSeal(t, b, 0, []byte("reverse"), zeroSession)
+	if bytes.Equal(forward[4:44], reverse[4:44]) {
+		t.Fatal("directions reuse nonce/key context")
+	}
+	bad := append([]byte(nil), forward...)
+	binary.BigEndian.PutUint64(bad[36:44], 1<<60)
+	if _, _, _, _, err := b.open(bad); err == nil {
+		t.Fatal("tampered counter authenticated")
+	}
+	got, _, _, _, err := b.open(forward)
+	if err != nil || string(got) != "forward" {
+		t.Fatalf("valid data rejected after forgery: %q %v", got, err)
+	}
+	if _, _, _, _, err = b.open(forward); !errors.Is(err, errReplay) {
+		t.Fatalf("duplicate accepted: %v", err)
+	}
+	if _, _, _, _, err = a.open(forward); err == nil {
+		t.Fatal("reflected data accepted")
+	}
+	if got, _, _, _, err = a.open(reverse); err != nil || string(got) != "reverse" {
+		t.Fatal(err)
+	}
+	oldAck := testSeal(t, a, flagAck, nil, b.local)
+	// Reboot sender: old packets and old ACKs cannot reactivate a retired session.
+	a2 := testProtocol(t)
+	establish(t, a2, b)
+	if _, _, _, _, err = b.open(oldAck); err == nil {
+		t.Fatal("retired peer session accepted")
+	}
+	if _, _, _, _, err = b.open(forward); err == nil {
+		t.Fatal("old data accepted after sender restart")
+	}
+	fresh := testSeal(t, a2, 0, []byte("after restart"), zeroSession)
+	if _, _, _, _, err = b.open(fresh); err != nil {
+		t.Fatal(err)
+	}
+	// Reboot receiver: even authentic recordings target its obsolete boot ID.
+	b2 := testProtocol(t)
+	if _, _, _, _, err = b2.open(fresh); err == nil {
+		t.Fatal("old data accepted after receiver restart")
+	}
+	establish(t, a2, b2)
+	if _, _, _, _, err = b2.open(testSeal(t, a2, 0, []byte("new receiver"), zeroSession)); err != nil {
+		t.Fatal(err)
+	}
+}
+func TestHelloDoesNotReplaceSession(t *testing.T) {
+	a, b := testProtocol(t), testProtocol(t)
+	establish(t, a, b)
+	stranger := testProtocol(t)
+	_, _, _, _, err := b.open(testSeal(t, stranger, flagHello, nil, zeroSession))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if b.remote != a.local {
+		t.Fatal("HELLO replaced established session")
+	}
+	wrong, err := newProtocol("a completely different shared secret")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, _, _, _, err = b.open(testSeal(t, wrong, flagHello, nil, zeroSession)); err == nil {
+		t.Fatal("wrong PSK accepted")
+	}
+}
+func TestReplayWindow(t *testing.T) {
+	var w replayWindow
+	for _, n := range []uint64{1, 3, 2, 66, 65} {
+		if !w.accept(n) {
+			t.Fatalf("valid counter %d rejected", n)
+		}
+	}
+	for _, n := range []uint64{0, 1, 2, 3, 65, 66} {
+		if w.accept(n) {
+			t.Fatalf("invalid counter %d accepted", n)
+		}
+	}
+}
+func TestHandshakeConfirmationRetry(t *testing.T) {
+	a, b := testProtocol(t), testProtocol(t)
+	hello := testSeal(t, a, flagHello, nil, zeroSession)
+	_, _, sender, _, err := b.open(hello)
+	if err != nil {
+		t.Fatal(err)
+	}
+	ack := testSeal(t, b, flagAck, nil, sender)
+	_, _, _, _, err = a.open(ack)
+	if err != nil {
+		t.Fatal(err)
+	}
+	// First confirmation is lost. Repeated HELLO -> ACK -> CONFIRM still works.
+	establish(t, a, b)
+	if _, _, _, _, err = b.open(testSeal(t, a, 0, []byte("data"), zeroSession)); err != nil {
+		t.Fatal(err)
+	}
+}

+ 88 - 0
internal/aerotun/routes.go

@@ -0,0 +1,88 @@
+package aerotun
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"os/exec"
+)
+
+func runIP(args ...string) ([]byte, error) {
+	out, err := exec.Command("ip", args...).CombinedOutput()
+	if err != nil {
+		return out, fmt.Errorf("ip %v: %s: %w", args, out, err)
+	}
+	return out, nil
+}
+
+type routeManager struct {
+	run   func(...string) ([]byte, error)
+	owned [][]string
+}
+
+func (r *routeManager) add(args ...string) error {
+	if _, err := r.run(append([]string{"-4", "route", "add"}, args...)...); err != nil {
+		return err
+	}
+	r.owned = append(r.owned, append([]string(nil), args...))
+	return nil
+}
+func (r *routeManager) cleanup() {
+	for i := len(r.owned) - 1; i >= 0; i-- {
+		_, _ = r.run(append([]string{"-4", "route", "del"}, r.owned[i]...)...)
+	}
+	r.owned = nil
+}
+func (r *routeManager) install(name string, endpoint net.IP) (err error) {
+	if endpoint.To4() == nil {
+		return fmt.Errorf("default route currently requires an IPv4 remote endpoint")
+	}
+	defer func() {
+		if err != nil {
+			r.cleanup()
+		}
+	}()
+	out, err := r.run("-j", "-4", "route", "get", endpoint.String())
+	if err != nil {
+		return err
+	}
+	var paths []struct {
+		Dev     string `json:"dev"`
+		Gateway string `json:"gateway"`
+	}
+	if err = json.Unmarshal(out, &paths); err != nil {
+		return err
+	}
+	if len(paths) != 1 || paths[0].Dev == "" || paths[0].Dev == name {
+		return fmt.Errorf("cannot find independent underlay route to remote endpoint")
+	}
+	host := endpoint.String() + "/32"
+	// Preserve a pre-existing host route. Never replace routes owned by others.
+	out, err = r.run("-j", "-4", "route", "show", "exact", host)
+	if err != nil {
+		return err
+	}
+	var existing []json.RawMessage
+	if err = json.Unmarshal(out, &existing); err != nil {
+		return err
+	}
+	if len(existing) == 0 {
+		args := []string{host}
+		if paths[0].Gateway != "" {
+			args = append(args, "via", paths[0].Gateway)
+		}
+		args = append(args, "dev", paths[0].Dev)
+		if err = r.add(args...); err != nil {
+			return err
+		}
+	}
+	// Split defaults leave the original default intact, so stopping/rolling back
+	// removes only our routes. A pinned endpoint stays outside the tunnel.
+	if err = r.add("0.0.0.0/1", "dev", name); err != nil {
+		return err
+	}
+	if err = r.add("128.0.0.0/1", "dev", name); err != nil {
+		return err
+	}
+	return nil
+}

+ 86 - 0
internal/aerotun/routes_test.go

@@ -0,0 +1,86 @@
+package aerotun
+
+import (
+	"fmt"
+	"net"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+func TestDefaultRoutePreservesUnderlayAndRollsBack(t *testing.T) {
+	for _, fail := range []bool{false, true} {
+		t.Run(fmt.Sprint(fail), func(t *testing.T) {
+			var calls []string
+			r := routeManager{run: func(a ...string) ([]byte, error) {
+				cmd := strings.Join(a, " ")
+				calls = append(calls, cmd)
+				if strings.Contains(cmd, "route get") {
+					return []byte(`[{"dev":"eth0","gateway":"192.0.2.1"}]`), nil
+				}
+				if strings.Contains(cmd, "show exact") {
+					return []byte(`[]`), nil
+				}
+				if fail && strings.Contains(cmd, "add 128.") {
+					return nil, fmt.Errorf("route conflict")
+				}
+				return nil, nil
+			}}
+			err := r.install("aerotun0", net.ParseIP("203.0.113.10"))
+			if (err != nil) != fail {
+				t.Fatalf("unexpected result: %v", err)
+			}
+			if !fail {
+				r.cleanup()
+			}
+			want := []string{"-j -4 route get 203.0.113.10", "-j -4 route show exact 203.0.113.10/32", "-4 route add 203.0.113.10/32 via 192.0.2.1 dev eth0", "-4 route add 0.0.0.0/1 dev aerotun0", "-4 route add 128.0.0.0/1 dev aerotun0"}
+			if !fail {
+				want = append(want, "-4 route del 128.0.0.0/1 dev aerotun0")
+			}
+			want = append(want, "-4 route del 0.0.0.0/1 dev aerotun0", "-4 route del 203.0.113.10/32 via 192.0.2.1 dev eth0")
+			if !reflect.DeepEqual(calls, want) {
+				t.Fatalf("route operations:\n%v\nwant:\n%v", calls, want)
+			}
+		})
+	}
+}
+func TestValidateConfig(t *testing.T) {
+	base := TunnelInstance{Name: "aerotun0", Mode: "client", LocalAddress: "10.99.0.1/24", PeerAddress: "10.99.0.2/24", RemoteEndpoint: "192.0.2.1:51830", PSK: testPSK}
+	if err := validateConfig(&base); err != nil {
+		t.Fatal(err)
+	}
+	for _, mutate := range []func(*TunnelInstance){func(c *TunnelInstance) { c.Name = "lo"; c.Mode = "invalid" }, func(c *TunnelInstance) { c.PSK = "short" }, func(c *TunnelInstance) { c.MTU = 65535 }, func(c *TunnelInstance) { c.RemoteEndpoint = "" }, func(c *TunnelInstance) { c.LocalAddress = "bad" }, func(c *TunnelInstance) { c.PeerAddress = c.LocalAddress }, func(c *TunnelInstance) { c.Mode = "server"; c.DefaultRoute = true }} {
+		c := base
+		mutate(&c)
+		if err := validateConfig(&c); err == nil {
+			t.Fatalf("invalid config accepted: %+v", c)
+		}
+	}
+}
+
+func TestDefaultRouteLeavesExistingEndpointRoute(t *testing.T) {
+	var changes []string
+	r := routeManager{run: func(a ...string) ([]byte, error) {
+		cmd := strings.Join(a, " ")
+		if strings.Contains(cmd, "route get") {
+			return []byte(`[{"dev":"eth0","gateway":"192.0.2.1"}]`), nil
+		}
+		if strings.Contains(cmd, "show exact") {
+			return []byte(`[{"dst":"203.0.113.10/32","dev":"eth0"}]`), nil
+		}
+		changes = append(changes, cmd)
+		return nil, nil
+	}}
+	if err := r.install("aerotun0", net.ParseIP("203.0.113.10")); err != nil {
+		t.Fatal(err)
+	}
+	r.cleanup()
+	for _, cmd := range changes {
+		if strings.Contains(cmd, "203.0.113.10") {
+			t.Fatalf("modified pre-existing endpoint route: %s", cmd)
+		}
+	}
+	if len(changes) != 4 {
+		t.Fatalf("expected two additions and two deletions: %v", changes)
+	}
+}

+ 372 - 0
internal/aerotun/secure_io.go

@@ -0,0 +1,372 @@
+package aerotun
+
+import (
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"net"
+	"net/netip"
+	"sync/atomic"
+	"syscall"
+	"time"
+
+	"github.com/pierrec/lz4/v4"
+	"golang.org/x/net/ipv4"
+	"golang.org/x/net/ipv6"
+)
+
+const secureBatchSize = 16
+
+type batchSocket interface {
+	ReadBatch([]ipv4.Message, int) (int, error)
+	WriteBatch([]ipv4.Message, int) (int, error)
+}
+
+func newBatchSocket(c *net.UDPConn) batchSocket {
+	if c.LocalAddr().(*net.UDPAddr).IP.To4() != nil {
+		return ipv4.NewPacketConn(c)
+	}
+	return ipv6.NewPacketConn(c)
+}
+
+type securePacket struct {
+	buf        []byte
+	n, wire    int
+	compressed bool
+}
+
+func (t *TunnelRunner) txLoopSecure() {
+	size := t.cfg.MTU + secureHeaderSize + 16
+	pool := make(chan *securePacket, secureBatchSize*2)
+	queue := make(chan *securePacket, secureBatchSize)
+	for range cap(pool) {
+		pool <- &securePacket{buf: make([]byte, size)}
+	}
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		defer close(queue)
+		for {
+			var p *securePacket
+			select {
+			case <-t.stopChan:
+				return
+			case p = <-pool:
+			}
+			n, err := t.dev.Read(p.buf[secureHeaderSize : secureHeaderSize+t.cfg.MTU])
+			if err != nil {
+				select {
+				case <-t.stopChan:
+					return
+				default:
+				}
+				t.setError(fmt.Errorf("secure interface read: %w", err))
+				pool <- p
+				time.Sleep(10 * time.Millisecond)
+				continue
+			}
+			if n == 0 || n > int(t.effectiveMTU.Load()) || !validIPPacket(p.buf[secureHeaderSize:secureHeaderSize+n]) {
+				atomic.AddUint64(&t.droppedPackets, 1)
+				pool <- p
+				continue
+			}
+			p.n = n
+			select {
+			case <-t.stopChan:
+				return
+			case queue <- p:
+			}
+		}
+	}()
+	defer func() { <-done }()
+	conn := newBatchSocket(t.udpConn)
+	messages := make([]ipv4.Message, secureBatchSize)
+	var buffers [secureBatchSize][1][]byte
+	for i := range messages {
+		messages[i].Buffers = buffers[i][:]
+	}
+	var packets [secureBatchSize]*securePacket
+	var nonce [24]byte
+	var comp []byte
+	if t.cfg.Compression {
+		comp = make([]byte, lz4.CompressBlockBound(t.cfg.MTU))
+	}
+	for {
+		var first *securePacket
+		select {
+		case <-t.stopChan:
+			return
+		case p, ok := <-queue:
+			if !ok {
+				return
+			}
+			first = p
+		}
+		count := 1
+		packets[0] = first
+	drain:
+		for count < secureBatchSize {
+			select {
+			case p, ok := <-queue:
+				if !ok {
+					break drain
+				}
+				packets[count] = p
+				count++
+			default:
+				break drain
+			}
+		}
+		target := t.target()
+		ready := 0
+		for i := 0; i < count; i++ {
+			p := packets[i]
+			payload := p.buf[secureHeaderSize : secureHeaderSize+p.n]
+			flags := byte(0)
+			p.compressed = false
+			p.wire = p.n
+			if target != nil && t.secure.compressionEnabled() && len(comp) > 0 && p.n > 40 {
+				if n, err := lz4.CompressBlock(payload, comp, nil); err == nil && n > 0 && n < p.n-16 {
+					payload = comp[:n]
+					flags = FlagCompressedLZ4
+					p.compressed = true
+					p.wire = n
+				}
+			}
+			if target == nil {
+				atomic.AddUint64(&t.droppedPackets, 1)
+				pool <- p
+				continue
+			}
+			out, err := t.secure.sealInto(p.buf[:0], &nonce, flags, payload)
+			if err != nil {
+				atomic.AddUint64(&t.droppedPackets, 1)
+				pool <- p
+				continue
+			}
+			packets[ready] = p
+			messages[ready].Buffers[0] = out
+			messages[ready].Addr = target
+			ready++
+		}
+		sent := 0
+		for sent < ready {
+			n, err := conn.WriteBatch(messages[sent:ready], 0)
+			for j := sent; j < sent+n; j++ {
+				p := packets[j]
+				atomic.AddUint64(&t.txBytes, uint64(p.n))
+				atomic.AddUint64(&t.rawBytesOut, uint64(p.n))
+				atomic.AddUint64(&t.packetsTx, 1)
+				atomic.AddUint64(&t.wireBytesOut, uint64(len(messages[j].Buffers[0])))
+				if p.compressed {
+					atomic.AddUint64(&t.compressedPackets, 1)
+					atomic.AddUint64(&t.savedBytes, uint64(p.n-p.wire))
+				} else {
+					atomic.AddUint64(&t.rawPackets, 1)
+				}
+			}
+			sent += n
+			if err != nil || n == 0 {
+				if err != nil {
+					t.setError(err)
+				}
+				atomic.AddUint64(&t.droppedPackets, uint64(ready-sent))
+				break
+			}
+		}
+		for i := 0; i < ready; i++ {
+			pool <- packets[i]
+		}
+	}
+}
+func (t *TunnelRunner) allowedSource(b []byte) bool {
+	var src netip.Addr
+	if b[0]>>4 == 4 {
+		var ip [4]byte
+		copy(ip[:], b[12:16])
+		src = netip.AddrFrom4(ip)
+	} else {
+		var ip [16]byte
+		copy(ip[:], b[8:24])
+		src = netip.AddrFrom16(ip)
+	}
+	for _, prefix := range t.allowed {
+		if prefix.Contains(src) {
+			return true
+		}
+	}
+	return false
+}
+func (t *TunnelRunner) rxLoopSecure() {
+	conn := newBatchSocket(t.udpConn)
+	size := max(t.cfg.MTU+secureHeaderSize+16, handshakeMaxSize)
+	messages := make([]ipv4.Message, secureBatchSize)
+	buffers := make([][]byte, secureBatchSize)
+	var vectors [secureBatchSize][1][]byte
+	for i := range messages {
+		buffers[i] = make([]byte, size)
+		vectors[i][0] = buffers[i]
+		messages[i].Buffers = vectors[i][:]
+	}
+	plain := make([]byte, t.cfg.MTU)
+	var decomp []byte
+	if t.cfg.Compression {
+		decomp = make([]byte, t.cfg.MTU)
+	}
+	var nonce [24]byte
+	for {
+		n, err := conn.ReadBatch(messages, 0)
+		if err != nil {
+			select {
+			case <-t.stopChan:
+				return
+			default:
+			}
+			t.setError(err)
+			continue
+		}
+		for i := 0; i < n; i++ {
+			m := &messages[i]
+			src, ok := m.Addr.(*net.UDPAddr)
+			if !ok || m.Flags&syscall.MSG_TRUNC != 0 || m.N < secureHeaderSize+16 {
+				atomic.AddUint64(&t.droppedPackets, 1)
+				continue
+			}
+			if t.cfg.Mode == "client" && !udpAddrsEqual(t.target(), src) {
+				continue
+			}
+			b := m.Buffers[0][:m.N]
+			if b[2] != 3 {
+				atomic.AddUint64(&t.authFailures, 1)
+				continue
+			}
+			if b[3] == secureInit || b[3] == secureResponse || b[3] == secureCookie {
+				response, peerMTU, err := t.secure.handleHandshake(b, int(t.effectiveMTU.Load()), src.AddrPort())
+				if err != nil {
+					if !errors.Is(err, errHandshakeRate) {
+						atomic.AddUint64(&t.authFailures, 1)
+					}
+					continue
+				}
+				atomic.AddUint64(&t.wireBytesIn, uint64(m.N))
+				if response != nil {
+					if n, err := t.udpConn.WriteToUDP(response, src); err == nil {
+						atomic.AddUint64(&t.wireBytesOut, uint64(n))
+					} else {
+						t.setError(err)
+					}
+				}
+				if peerMTU > 0 {
+					if err := t.adjustMTU(src.IP, peerMTU); err != nil {
+						t.setError(err)
+						continue
+					}
+					t.markSecurePeer(src)
+					if err := t.sendControl(FlagKeepalive, src, zeroSession); err != nil {
+						t.setError(err)
+					}
+				}
+				continue
+			}
+			payload, flags, peerMTU, err := t.secure.openInto(plain, &nonce, b)
+			if err != nil {
+				if errors.Is(err, errReplay) {
+					atomic.AddUint64(&t.replayDrops, 1)
+				} else {
+					atomic.AddUint64(&t.authFailures, 1)
+				}
+				continue
+			}
+			atomic.AddUint64(&t.wireBytesIn, uint64(m.N))
+			oldMTU := t.effectiveMTU.Load()
+			if flags == FlagKeepalive {
+				peerMTU = int(binary.BigEndian.Uint16(payload))
+			}
+			if peerMTU > 0 {
+				if err := t.adjustMTU(src.IP, peerMTU); err != nil {
+					t.setError(err)
+					continue
+				}
+			}
+			t.markSecurePeer(src)
+			if flags == FlagKeepalive {
+				if t.effectiveMTU.Load() < oldMTU {
+					if err := t.sendControl(FlagKeepalive, src, zeroSession); err != nil {
+						t.setError(err)
+					}
+				}
+				continue
+			}
+			if flags == FlagCompressedLZ4 {
+				if len(decomp) == 0 {
+					atomic.AddUint64(&t.droppedPackets, 1)
+					continue
+				}
+				size, err := lz4.UncompressBlock(payload, decomp[:int(t.effectiveMTU.Load())])
+				if err != nil || size == 0 {
+					atomic.AddUint64(&t.droppedPackets, 1)
+					continue
+				}
+				payload = decomp[:size]
+			}
+			if len(payload) > int(t.effectiveMTU.Load()) || !validIPPacket(payload) || !t.allowedSource(payload) {
+				atomic.AddUint64(&t.droppedPackets, 1)
+				continue
+			}
+			wrote, err := t.dev.Write(payload)
+			if err != nil || wrote != len(payload) {
+				atomic.AddUint64(&t.droppedPackets, 1)
+				if err != nil {
+					t.setError(err)
+				}
+				continue
+			}
+			atomic.AddUint64(&t.rxBytes, uint64(wrote))
+			atomic.AddUint64(&t.rawBytesIn, uint64(wrote))
+			atomic.AddUint64(&t.packetsRx, 1)
+		}
+	}
+}
+func (t *TunnelRunner) markSecurePeer(src *net.UDPAddr) {
+	t.mu.Lock()
+	if !udpAddrsEqual(t.remoteAddr, src) {
+		t.remoteAddr = src
+	}
+	t.lastHandshake = time.Now()
+	t.mu.Unlock()
+}
+func (t *TunnelRunner) keepaliveSecure() {
+	ticker := time.NewTicker(time.Second)
+	defer ticker.Stop()
+	lastSent := time.Time{}
+	for {
+		t.secure.expire()
+		target := t.target()
+		t.mu.RLock()
+		connected := !t.lastHandshake.IsZero() && time.Since(t.lastHandshake) < peerTimeout
+		t.mu.RUnlock()
+		if target != nil {
+			b, err := t.secure.initiation(int(t.effectiveMTU.Load()), !connected)
+			if err != nil {
+				t.setError(err)
+			} else if b != nil {
+				if n, err := t.udpConn.WriteToUDP(b, target); err == nil {
+					atomic.AddUint64(&t.wireBytesOut, uint64(n))
+				} else {
+					t.setError(err)
+				}
+			}
+			if connected && time.Since(lastSent) >= 20*time.Second {
+				if err := t.sendControl(FlagKeepalive, target, zeroSession); err != nil {
+					t.setError(err)
+				}
+				lastSent = time.Now()
+			}
+		}
+		select {
+		case <-t.stopChan:
+			return
+		case <-ticker.C:
+		}
+	}
+}

+ 540 - 0
internal/aerotun/secure_protocol.go

@@ -0,0 +1,540 @@
+package aerotun
+
+import (
+	"crypto/cipher"
+	"crypto/hmac"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/binary"
+	"errors"
+	"io"
+	"net/netip"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/flynn/noise"
+	"golang.org/x/crypto/chacha20poly1305"
+	"golang.org/x/crypto/hkdf"
+)
+
+const (
+	secureCookie        byte = 3
+	secureHeaderSize         = 16
+	secureVersion            = 3
+	secureInit          byte = 1
+	secureResponse      byte = 2
+	secureRaw           byte = 16
+	secureCompressed    byte = 17
+	secureKeepalive     byte = 18
+	secureRekeyAfter         = 90 * time.Second
+	secureRejectAfter        = 180 * time.Second
+	securePreviousGrace      = 30 * time.Second
+	handshakeMaxSize         = 512
+)
+
+var errPacket = errors.New("invalid authenticated tunnel packet")
+var errHandshakeRate = errors.New("handshake rate limited")
+
+// A 1024-packet window permits bounded reordering using a fixed 128-byte bitmap.
+type secureReplay struct {
+	max  uint64
+	bits [16]uint64
+}
+
+func (r *secureReplay) valid(n uint64) bool {
+	if n == 0 {
+		return false
+	}
+	if n > r.max {
+		return true
+	}
+	if r.max-n >= 1024 {
+		return false
+	}
+	pos := n % 1024
+	return r.bits[pos/64]&(uint64(1)<<(pos%64)) == 0
+}
+func (r *secureReplay) accept(n uint64) bool {
+	if !r.valid(n) {
+		return false
+	}
+	if n > r.max {
+		if n-r.max >= 1024 {
+			clear(r.bits[:])
+		} else {
+			for missing := r.max + 1; missing < n; missing++ {
+				pos := missing % 1024
+				r.bits[pos/64] &^= uint64(1) << (pos % 64)
+			}
+		}
+		r.max = n
+	}
+	pos := n % 1024
+	r.bits[pos/64] |= uint64(1) << (pos % 64)
+	return true
+}
+
+type secureSession struct {
+	local, remote      uint32
+	tx, rx             cipher.AEAD
+	txPrefix, rxPrefix [16]byte
+	counter            atomic.Uint64
+	rxMu               sync.Mutex
+	replay             secureReplay
+	born               time.Time
+	peerMTU            int
+	compression        bool
+}
+type secureSessions struct {
+	current, previous, pending *secureSession
+	previousUntil              time.Time
+}
+type noiseAttempt struct {
+	state             *noise.HandshakeState
+	packet            []byte
+	header            [16]byte
+	started, lastSent time.Time
+}
+type secureProtocol struct {
+	cookieSecret   [32]byte
+	mu             sync.Mutex // Only handshakes/publication, never bulk encryption/decryption.
+	cfg            TunnelInstance
+	psk            []byte
+	identity       noise.DHKey
+	peer           []byte
+	sessions       atomic.Pointer[secureSessions]
+	attempt        *noiseAttempt
+	lastRequest    time.Time
+	cachedInit     [32]byte
+	cachedResponse []byte
+	cachedAt       time.Time
+	handshakes     atomic.Uint64
+}
+
+func newSecureProtocol(cfg TunnelInstance) (*secureProtocol, error) {
+	psk, err := decodeKey(cfg.PSK)
+	if err != nil {
+		return nil, err
+	}
+	priv, err := decodeKey(cfg.PrivateKey)
+	if err != nil {
+		return nil, err
+	}
+	pub, err := decodeKey(cfg.PublicKey)
+	if err != nil {
+		return nil, err
+	}
+	peer, err := decodeKey(cfg.PeerPublicKey)
+	if err != nil {
+		return nil, err
+	}
+	p := &secureProtocol{cfg: cfg, psk: psk, identity: noise.DHKey{Private: priv, Public: pub}, peer: peer}
+	if _, err := rand.Read(p.cookieSecret[:]); err != nil {
+		return nil, err
+	}
+	p.sessions.Store(&secureSessions{})
+	return p, nil
+}
+func randomIndex() (uint32, error) {
+	var b [4]byte
+	for {
+		if _, err := rand.Read(b[:]); err != nil {
+			return 0, err
+		}
+		if n := binary.LittleEndian.Uint32(b[:]); n != 0 {
+			return n, nil
+		}
+	}
+}
+func secureHeader(dst []byte, kind byte, index uint32, n uint64) {
+	binary.BigEndian.PutUint16(dst, MagicHeader)
+	dst[2] = 3
+	dst[3] = kind
+	binary.BigEndian.PutUint32(dst[4:8], index)
+	binary.BigEndian.PutUint64(dst[8:16], n)
+}
+func secureMetadata(index uint32, mtu int, compression bool) []byte {
+	b := make([]byte, 8)
+	binary.BigEndian.PutUint32(b, index)
+	binary.BigEndian.PutUint16(b[4:], uint16(mtu))
+	if compression {
+		b[6] = 1
+	}
+	return b
+}
+func parseSecureMetadata(b []byte) (index uint32, mtu int, comp bool, err error) {
+	if len(b) != 8 || b[7] != 0 || b[6] > 1 {
+		return 0, 0, false, errPacket
+	}
+	index = binary.BigEndian.Uint32(b)
+	mtu = int(binary.BigEndian.Uint16(b[4:]))
+	comp = b[6] == 1
+	if index == 0 || mtu < 576 || mtu > 9000 {
+		err = errPacket
+	}
+	return
+}
+func (p *secureProtocol) newHandshake(initiator bool, header []byte) (*noise.HandshakeState, error) {
+	prologue := append([]byte("AeroTUN-v3/Noise-IKpsk2/XChaCha-data-v1\x00"), header...)
+	cfg := noise.Config{CipherSuite: noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256), Pattern: noise.HandshakeIK, Initiator: initiator, Prologue: prologue, PresharedKey: p.psk, PresharedKeyPlacement: 2, StaticKeypair: p.identity}
+	if initiator {
+		cfg.PeerStatic = p.peer
+	}
+	return noise.NewHandshakeState(cfg)
+}
+func (p *secureProtocol) authenticateHandshake(b []byte) bool {
+	if len(b) < 32 || len(b) > handshakeMaxSize {
+		return false
+	}
+	mac := hmac.New(sha256.New, p.psk)
+	mac.Write([]byte("AeroTUN-v3-handshake-MAC"))
+	mac.Write(b[:len(b)-16])
+	return hmac.Equal(mac.Sum(nil)[:16], b[len(b)-16:])
+}
+func (p *secureProtocol) finishHandshake(b []byte) []byte {
+	mac := hmac.New(sha256.New, p.psk)
+	mac.Write([]byte("AeroTUN-v3-handshake-MAC"))
+	mac.Write(b)
+	return append(b, mac.Sum(nil)[:16]...)
+}
+func (p *secureProtocol) initiation(mtu int, force bool) ([]byte, error) {
+	if p.cfg.Mode != "client" {
+		return nil, nil
+	}
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	now := time.Now()
+	if p.attempt != nil && now.Sub(p.attempt.started) < 5*time.Second {
+		if now.Sub(p.attempt.lastSent) < time.Second {
+			return nil, nil
+		}
+		p.attempt.lastSent = now
+		return p.attempt.packet, nil
+	}
+	if s := p.sessions.Load().current; s != nil && !force && now.Sub(s.born) < secureRekeyAfter && s.counter.Load() < 1<<60 {
+		return nil, nil
+	}
+	index, err := randomIndex()
+	if err != nil {
+		return nil, err
+	}
+	var header [16]byte
+	var nonce [8]byte
+	if _, err = rand.Read(nonce[:]); err != nil {
+		return nil, err
+	}
+	secureHeader(header[:], secureInit, index, binary.BigEndian.Uint64(nonce[:]))
+	hs, err := p.newHandshake(true, header[:])
+	if err != nil {
+		return nil, err
+	}
+	b, _, _, err := hs.WriteMessage(append([]byte(nil), header[:]...), secureMetadata(index, mtu, p.cfg.Compression))
+	if err != nil {
+		return nil, err
+	}
+	b = append(b, make([]byte, 16)...) // stateless source-address cookie
+	b = p.finishHandshake(b)
+	p.attempt = &noiseAttempt{state: hs, packet: b, header: header, started: now, lastSent: now}
+	return b, nil
+}
+func deriveDataState(c *noise.CipherState, binding []byte) (cipher.AEAD, [16]byte, error) {
+	// Export Noise split keys only for domain-separated data-key derivation. Noise
+	// CipherStates are never used for transport, so there are no competing nonces.
+	key := c.UnsafeKey()
+	var material [48]byte
+	_, err := io.ReadFull(hkdf.New(sha256.New, key[:], binding, []byte("AeroTUN-v3-XChaCha20-Poly1305-data")), material[:])
+	if err != nil {
+		return nil, [16]byte{}, err
+	}
+	a, err := chacha20poly1305.NewX(material[:32])
+	var prefix [16]byte
+	copy(prefix[:], material[32:])
+	clear(material[:])
+	clear(key[:])
+	return a, prefix, err
+}
+func makeSecureSession(local, remote uint32, mtu int, comp bool, tx, rx *noise.CipherState, binding []byte) (*secureSession, error) {
+	s := &secureSession{local: local, remote: remote, peerMTU: mtu, compression: comp, born: time.Now()}
+	var err error
+	s.tx, s.txPrefix, err = deriveDataState(tx, binding)
+	if err != nil {
+		return nil, err
+	}
+	s.rx, s.rxPrefix, err = deriveDataState(rx, binding)
+	return s, err
+}
+
+// A responder publishes only a pending receive session until authenticated data
+// proves the peer also possesses the PSK and completed the fresh handshake.
+func (p *secureProtocol) handleHandshake(b []byte, mtu int, source ...netip.AddrPort) (response []byte, peerMTU int, err error) {
+	if len(b) < 32 || len(b) > handshakeMaxSize || binary.BigEndian.Uint16(b) != MagicHeader || b[2] != 3 || !p.authenticateHandshake(b) {
+		return nil, 0, errPacket
+	}
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	now := time.Now()
+	switch b[3] {
+	case secureInit:
+		if p.cfg.Mode != "server" {
+			return nil, 0, errPacket
+		}
+		if len(b) < 48 {
+			return nil, 0, errPacket
+		}
+		var address netip.AddrPort
+		if len(source) > 0 {
+			address = source[0]
+		}
+		current := p.addressCookie(b[:16], address, now.Unix()/30)
+		previous := p.addressCookie(b[:16], address, now.Unix()/30-1)
+		supplied := b[len(b)-32 : len(b)-16]
+		if !hmac.Equal(current[:], supplied) && !hmac.Equal(previous[:], supplied) {
+			out := append([]byte(nil), b[:16]...)
+			out[3] = secureCookie
+			out = append(out, current[:]...)
+			return p.finishHandshake(out), 0, nil
+		}
+
+		fingerprint := sha256.Sum256(b)
+		if now.Sub(p.lastRequest) < time.Second {
+			return nil, 0, errHandshakeRate
+		}
+		p.lastRequest = now
+		if fingerprint == p.cachedInit && now.Sub(p.cachedAt) < 5*time.Second {
+			return p.cachedResponse, 0, nil
+		}
+		hs, e := p.newHandshake(false, b[:16])
+		if e != nil {
+			return nil, 0, e
+		}
+		meta, _, _, e := hs.ReadMessage(nil, b[16:len(b)-32])
+		if e != nil || !hmac.Equal(hs.PeerStatic(), p.peer) {
+			return nil, 0, errPacket
+		}
+		remote, remoteMTU, comp, e := parseSecureMetadata(meta)
+		if e != nil || remote != binary.BigEndian.Uint32(b[4:8]) {
+			return nil, 0, errPacket
+		}
+		local, e := randomIndex()
+		if e != nil {
+			return nil, 0, e
+		}
+		old := p.sessions.Load()
+		for local == remote || (old.current != nil && local == old.current.local) || (old.previous != nil && local == old.previous.local) {
+			local, e = randomIndex()
+			if e != nil {
+				return nil, 0, e
+			}
+		}
+		var h [16]byte
+		copy(h[:], b[:16])
+		h[3] = secureResponse
+		out, c1, c2, e := hs.WriteMessage(append([]byte(nil), h[:]...), secureMetadata(local, mtu, p.cfg.Compression))
+		if e != nil {
+			return nil, 0, e
+		}
+		if c1 == nil || c2 == nil {
+			return nil, 0, errPacket
+		}
+		session, e := makeSecureSession(local, remote, remoteMTU, comp && p.cfg.Compression, c2, c1, hs.ChannelBinding())
+		if e != nil {
+			return nil, 0, e
+		}
+		p.sessions.Store(&secureSessions{current: old.current, previous: old.previous, previousUntil: old.previousUntil, pending: session})
+		out = p.finishHandshake(out)
+		p.cachedInit = fingerprint
+		p.cachedResponse = out
+		p.cachedAt = now
+		return out, 0, nil
+	case secureCookie:
+		if p.cfg.Mode != "client" || len(b) != 48 || p.attempt == nil || now.Sub(p.attempt.started) > 5*time.Second || !hmac.Equal(b[4:16], p.attempt.header[4:16]) {
+			return nil, 0, errPacket
+		}
+		packet := append([]byte(nil), p.attempt.packet...)
+		copy(packet[len(packet)-32:len(packet)-16], b[16:32])
+		packet = p.finishHandshake(packet[:len(packet)-16])
+		p.attempt.packet = packet
+		p.attempt.lastSent = now
+		return packet, 0, nil
+	case secureResponse:
+		if p.cfg.Mode != "client" || p.attempt == nil || now.Sub(p.attempt.started) > 5*time.Second || !hmac.Equal(b[4:16], p.attempt.header[4:16]) {
+			return nil, 0, errPacket
+		}
+		hs := p.attempt.state
+		meta, c1, c2, e := hs.ReadMessage(nil, b[16:len(b)-16])
+		if e != nil {
+			p.attempt = nil
+			return nil, 0, e
+		}
+		remote, remoteMTU, comp, e := parseSecureMetadata(meta)
+		if e != nil {
+			return nil, 0, e
+		}
+		if c1 == nil || c2 == nil {
+			return nil, 0, errPacket
+		}
+		local := binary.BigEndian.Uint32(b[4:8])
+		if local == remote {
+			return nil, 0, errPacket
+		}
+		session, e := makeSecureSession(local, remote, remoteMTU, comp && p.cfg.Compression, c1, c2, hs.ChannelBinding())
+		if e != nil {
+			return nil, 0, e
+		}
+		old := p.sessions.Load()
+		p.sessions.Store(&secureSessions{current: session, previous: old.current, previousUntil: now.Add(securePreviousGrace)})
+		p.attempt = nil
+		p.handshakes.Add(1)
+		return nil, remoteMTU, nil
+	}
+	return nil, 0, errPacket
+}
+func (p *secureProtocol) sealInto(dst []byte, nonce *[24]byte, flags byte, payload []byte) ([]byte, error) {
+	s := p.sessions.Load().current
+	if s == nil || time.Since(s.born) > secureRejectAfter {
+		return nil, errSession
+	}
+	kind := secureRaw
+	switch flags {
+	case 0:
+	case FlagCompressedLZ4:
+		if !s.compression {
+			return nil, errPacket
+		}
+		kind = secureCompressed
+	case FlagKeepalive:
+		kind = secureKeepalive
+	default:
+		return nil, errPacket
+	}
+	if cap(dst) < 16+len(payload)+16 {
+		return nil, errPacket
+	}
+	n := s.counter.Add(1)
+	if n == 0 || n >= 1<<60 {
+		return nil, errSession
+	}
+	dst = dst[:16]
+	secureHeader(dst, kind, s.remote, n)
+	copy(nonce[:16], s.txPrefix[:])
+	binary.BigEndian.PutUint64(nonce[16:], n)
+	return s.tx.Seal(dst, nonce[:], payload, dst), nil
+}
+func (p *secureProtocol) compressionEnabled() bool {
+	s := p.sessions.Load().current
+	return s != nil && s.compression
+}
+func (p *secureProtocol) openInto(dst []byte, nonce *[24]byte, b []byte) (payload []byte, flags byte, peerMTU int, err error) {
+	if len(b) < 32 || cap(dst) < len(b)-32 || binary.BigEndian.Uint16(b) != MagicHeader || b[2] != 3 {
+		return nil, 0, 0, errPacket
+	}
+	switch b[3] {
+	case secureRaw:
+	case secureCompressed:
+		flags = FlagCompressedLZ4
+	case secureKeepalive:
+		flags = FlagKeepalive
+	default:
+		return nil, 0, 0, errPacket
+	}
+	slots := p.sessions.Load()
+	index := binary.BigEndian.Uint32(b[4:8])
+	var s *secureSession
+	for _, candidate := range []*secureSession{slots.current, slots.pending, slots.previous} {
+		if candidate != nil && candidate.local == index {
+			s = candidate
+			break
+		}
+	}
+	if s == nil || time.Since(s.born) > secureRejectAfter || (s == slots.previous && time.Now().After(slots.previousUntil)) || (flags == FlagCompressedLZ4 && !s.compression) {
+		return nil, 0, 0, errSession
+	}
+	n := binary.BigEndian.Uint64(b[8:16])
+	if n >= 1<<60 {
+		return nil, 0, 0, errPacket
+	}
+	s.rxMu.Lock()
+	valid := s.replay.valid(n)
+	s.rxMu.Unlock()
+	if !valid {
+		return nil, 0, 0, errReplay
+	}
+	copy(nonce[:16], s.rxPrefix[:])
+	binary.BigEndian.PutUint64(nonce[16:], n)
+	payload, err = s.rx.Open(dst[:0], nonce[:], b[16:], b[:16])
+	if err != nil {
+		return nil, 0, 0, err
+	}
+	if flags == FlagKeepalive && (len(payload) != 2 || binary.BigEndian.Uint16(payload) < 576 || binary.BigEndian.Uint16(payload) > 9000) {
+		return nil, 0, 0, errPacket
+	}
+	s.rxMu.Lock()
+	valid = s.replay.accept(n)
+	s.rxMu.Unlock()
+	if !valid {
+		return nil, 0, 0, errReplay
+	}
+	if s == slots.pending {
+		p.mu.Lock()
+		nowSlots := p.sessions.Load()
+		if nowSlots.pending == s {
+			p.sessions.Store(&secureSessions{current: s, previous: nowSlots.current, previousUntil: time.Now().Add(securePreviousGrace)})
+			p.handshakes.Add(1)
+		}
+		p.mu.Unlock()
+		peerMTU = s.peerMTU
+	}
+	return
+}
+
+// Retain at most the current session, one bounded-grace predecessor, and one
+// short-lived pending handshake. Expired keys are no longer reachable by the
+// protocol; Go does not guarantee physical erasure of discarded heap objects.
+func (p *secureProtocol) expire() {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	old := p.sessions.Load()
+	next := *old
+	now := time.Now()
+	changed := false
+	if next.current != nil && now.Sub(next.current.born) > secureRejectAfter {
+		next.current = nil
+		changed = true
+	}
+	if next.previous != nil && now.After(next.previousUntil) {
+		next.previous = nil
+		changed = true
+	}
+	if next.pending != nil && now.Sub(next.pending.born) > 5*time.Second {
+		next.pending = nil
+		changed = true
+	}
+	if changed {
+		p.sessions.Store(&next)
+	}
+	if p.attempt != nil && now.Sub(p.attempt.started) > 5*time.Second {
+		p.attempt = nil
+	}
+	if len(p.cachedResponse) > 0 && now.Sub(p.cachedAt) > 5*time.Second {
+		p.cachedResponse = nil
+		p.cachedInit = [32]byte{}
+	}
+}
+
+// A short-lived stateless cookie binds expensive DH work to a reachable UDP
+// source. A captured initiation cannot be moved to another source without a new
+// PSK MAC. Current/previous 30-second buckets tolerate packet delay.
+func (p *secureProtocol) addressCookie(header []byte, address netip.AddrPort, bucket int64) (out [16]byte) {
+	h := hmac.New(sha256.New, p.cookieSecret[:])
+	h.Write([]byte("AeroTUN-v3-source-cookie"))
+	h.Write(header[4:16])
+	var encoded [64]byte
+	b := address.AppendTo(encoded[:0])
+	h.Write(b)
+	var clock [8]byte
+	binary.BigEndian.PutUint64(clock[:], uint64(bucket))
+	h.Write(clock[:])
+	copy(out[:], h.Sum(nil)[:16])
+	return
+}

+ 376 - 0
internal/aerotun/secure_protocol_test.go

@@ -0,0 +1,376 @@
+package aerotun
+
+import (
+	"bytes"
+	"encoding/binary"
+	"errors"
+	"net/netip"
+	"sync"
+	"testing"
+	"time"
+)
+
+func secureConfigs(t testing.TB) (TunnelInstance, TunnelInstance) {
+	t.Helper()
+	privA, pubA, err := GenerateIdentity()
+	if err != nil {
+		t.Fatal(err)
+	}
+	privB, pubB, err := GenerateIdentity()
+	if err != nil {
+		t.Fatal(err)
+	}
+	a := TunnelInstance{Protocol: 3, Mode: "client", PSK: testPSK, PrivateKey: privA, PublicKey: pubA, PeerPublicKey: pubB, Name: "atest0", LocalAddress: "10.99.0.2/32", PeerAddress: "10.99.0.1/32", AllowedIPs: []string{"10.99.0.1/32"}, RemoteEndpoint: "192.0.2.1:51830", MTU: 1420}
+	b := a
+	b.Mode = "server"
+	b.PrivateKey = privB
+	b.PublicKey = pubB
+	b.PeerPublicKey = pubA
+	b.LocalAddress, b.PeerAddress = a.PeerAddress, a.LocalAddress
+	b.AllowedIPs = []string{"10.99.0.2/32"}
+	b.RemoteEndpoint = ""
+	return a, b
+}
+func securePair(t testing.TB) (*secureProtocol, *secureProtocol) {
+	t.Helper()
+	a, b := secureConfigs(t)
+	pa, err := newSecureProtocol(a)
+	if err != nil {
+		t.Fatal(err)
+	}
+	pb, err := newSecureProtocol(b)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return pa, pb
+}
+func secureEstablish(t testing.TB, a, b *secureProtocol) {
+	t.Helper()
+	b.mu.Lock()
+	b.lastRequest = time.Time{}
+	b.mu.Unlock()
+	hello, err := a.initiation(1420, true)
+	if err != nil || len(hello) == 0 {
+		t.Fatalf("initiation: %v", err)
+	}
+	challenge, _, err := b.handleHandshake(hello, 1420)
+	if err != nil || challenge[3] != secureCookie {
+		t.Fatal("cookie challenge failed", err)
+	}
+	request, _, err := a.handleHandshake(challenge, 1420)
+	if err != nil {
+		t.Fatal(err)
+	}
+	response, _, err := b.handleHandshake(request, 1420)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, mtu, err := a.handleHandshake(response, 1420); err != nil || mtu != 1420 {
+		t.Fatalf("response %v %d", err, mtu)
+	}
+	var buf [128]byte
+	var plain [128]byte
+	var txNonce, rxNonce [24]byte
+	packet, err := a.sealInto(buf[:0], &txNonce, FlagKeepalive, []byte{5, 140})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, _, mtu, err := b.openInto(plain[:0], &rxNonce, packet); err != nil || (mtu != 1420 && mtu != 0) {
+		t.Fatalf("confirmation: %v %d", err, mtu)
+	}
+}
+func TestSecureHandshakeReplayPinsAndRekey(t *testing.T) {
+	a, b := securePair(t)
+	secureEstablish(t, a, b)
+	var txBuf, rxBuf [2048]byte
+	var tn, rn [24]byte
+	raw := []byte("authenticated application payload")
+	packet, err := a.sealInto(txBuf[:0], &tn, 0, raw)
+	if err != nil {
+		t.Fatal(err)
+	}
+	oldPacket := append([]byte(nil), packet...)
+	forged := append([]byte(nil), packet...)
+	binary.BigEndian.PutUint64(forged[8:16], 1<<40)
+	if _, _, _, err = b.openInto(rxBuf[:0], &rn, forged); err == nil {
+		t.Fatal("forged high counter accepted")
+	}
+	got, _, _, err := b.openInto(rxBuf[:0], &rn, packet)
+	if err != nil || !bytes.Equal(got, raw) {
+		t.Fatal("valid packet lost after forgery", err)
+	}
+	if _, _, _, err = b.openInto(rxBuf[:0], &rn, packet); !errors.Is(err, errReplay) {
+		t.Fatalf("replay accepted: %v", err)
+	}
+	if _, _, _, err = a.openInto(rxBuf[:0], &rn, packet); err == nil {
+		t.Fatal("reflected packet accepted")
+	}
+	oldSession := a.sessions.Load().current
+	secureEstablish(t, a, b)
+	if a.sessions.Load().current == oldSession || a.sessions.Load().current.txPrefix == oldSession.txPrefix {
+		t.Fatal("rekey reused data key/nonce context")
+	}
+	if _, _, _, err = b.openInto(rxBuf[:0], &rn, oldPacket); !errors.Is(err, errReplay) {
+		t.Fatal("old duplicate accepted during grace", err)
+	}
+	oldB := b.sessions.Load()
+	b.sessions.Store(&secureSessions{current: oldB.current, previous: oldB.previous, previousUntil: time.Now().Add(-time.Second)})
+	if _, _, _, err = b.openInto(rxBuf[:0], &rn, oldPacket); err == nil {
+		t.Fatal("expired session accepted")
+	}
+	// An independent boot has fresh ephemeral keys and rejects old recordings.
+	restarted, err := newSecureProtocol(b.cfg)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, _, _, err = restarted.openInto(rxBuf[:0], &rn, oldPacket); err == nil {
+		t.Fatal("old packet survived receiver restart")
+	}
+	secureEstablish(t, a, restarted)
+}
+func TestSecureWrongPeerAndWrongPSK(t *testing.T) {
+	a, b := securePair(t)
+	_, wrongPub, err := GenerateIdentity()
+	if err != nil {
+		t.Fatal(err)
+	}
+	b.peer, _ = decodeKey(wrongPub)
+	packet, err := a.initiation(1420, true)
+	if err != nil {
+		t.Fatal(err)
+	}
+	challenge, _, err := b.handleHandshake(packet, 1420)
+	if err != nil {
+		t.Fatal(err)
+	}
+	request, _, err := a.handleHandshake(challenge, 1420)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, _, err = b.handleHandshake(request, 1420); err == nil {
+		t.Fatal("unpinned peer accepted with correct PSK")
+	}
+	a, b = securePair(t)
+	b.psk = bytes.Repeat([]byte{42}, 32)
+	packet, _ = a.initiation(1420, true)
+	if _, _, err = b.handleHandshake(packet, 1420); err == nil {
+		t.Fatal("wrong PSK accepted")
+	}
+}
+func TestSecureDuplicateInitiationDoesNotActivateSession(t *testing.T) {
+	a, b := securePair(t)
+	init, _ := a.initiation(1420, true)
+	challenge, _, err := b.handleHandshake(init, 1420)
+	if err != nil {
+		t.Fatal(err)
+	}
+	request, _, err := a.handleHandshake(challenge, 1420)
+	if err != nil {
+		t.Fatal(err)
+	}
+	response, _, err := b.handleHandshake(request, 1420)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if b.sessions.Load().current != nil {
+		t.Fatal("responder activated before key confirmation")
+	}
+	if _, _, err = b.handleHandshake(request, 1420); !errors.Is(err, errHandshakeRate) {
+		t.Fatal("no handshake rate limit")
+	}
+	b.lastRequest = time.Now().Add(-2 * time.Second)
+	again, _, err := b.handleHandshake(request, 1420)
+	if err != nil || !bytes.Equal(response, again) {
+		t.Fatal("retry regenerated keys", err)
+	}
+}
+
+func TestSecureConcurrentCounters(t *testing.T) {
+	a, b := securePair(t)
+	secureEstablish(t, a, b)
+	var wg sync.WaitGroup
+	counts := make(chan uint64, 400)
+	for range 4 {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			var buf [128]byte
+			var nonce [24]byte
+			for range 100 {
+				out, err := a.sealInto(buf[:0], &nonce, 0, []byte("data"))
+				if err != nil {
+					t.Error(err)
+					return
+				}
+				counts <- binary.BigEndian.Uint64(out[8:16])
+			}
+		}()
+	}
+	wg.Wait()
+	close(counts)
+	seen := map[uint64]bool{}
+	for n := range counts {
+		if seen[n] {
+			t.Fatal("nonce counter reused")
+		}
+		seen[n] = true
+	}
+}
+func TestSecureReplayReordering(t *testing.T) {
+	var r secureReplay
+	for _, n := range []uint64{1, 1024, 2, 1023, 2048, 2047} {
+		if !r.accept(n) {
+			t.Fatal("valid reordered packet rejected", n)
+		}
+	}
+	for _, n := range []uint64{0, 1, 2, 1024, 2047, 2048} {
+		if r.accept(n) {
+			t.Fatal("old/duplicate accepted", n)
+		}
+	}
+}
+func BenchmarkSecureProtocolRoundTrip(b *testing.B) {
+	a, peer := securePair(b)
+	secureEstablish(b, a, peer)
+	tx := make([]byte, 1500)
+	rx := make([]byte, 1500)
+	payload := make([]byte, 1200)
+	var tn, rn [24]byte
+	b.SetBytes(1200)
+	b.ReportAllocs()
+	b.ResetTimer()
+	for range b.N {
+		packet, err := a.sealInto(tx[:0], &tn, 0, payload)
+		if err != nil {
+			b.Fatal(err)
+		}
+		if _, _, _, err = peer.openInto(rx[:0], &rn, packet); err != nil {
+			b.Fatal(err)
+		}
+	}
+}
+func FuzzSecurePacket(f *testing.F) {
+	a, b := securePair(f)
+	secureEstablish(f, a, b)
+	f.Add([]byte("invalid"))
+	var wire [128]byte
+	var nonce [24]byte
+	valid, _ := a.sealInto(wire[:0], &nonce, 0, []byte("payload"))
+	f.Add(append([]byte(nil), valid...))
+	f.Fuzz(func(t *testing.T, packet []byte) {
+		var dst [2048]byte
+		var n [24]byte
+		_, _, _, _ = b.openInto(dst[:0], &n, packet)
+		_, _, _ = b.handleHandshake(packet, 1420)
+	})
+}
+
+func TestSecureInPlaceSealAndSourcePolicy(t *testing.T) {
+	a, b := securePair(t)
+	secureEstablish(t, a, b)
+	wire := make([]byte, 1500)
+	payload := wire[16:1216]
+	for i := range payload {
+		payload[i] = byte(i)
+	}
+	expected := append([]byte(nil), payload...)
+	var tn, rn [24]byte
+	var plain [1500]byte
+	packet, err := a.sealInto(wire[:0], &tn, 0, payload)
+	if err != nil {
+		t.Fatal(err)
+	}
+	got, _, _, err := b.openInto(plain[:0], &rn, packet)
+	if err != nil || !bytes.Equal(got, expected) {
+		t.Fatal("in-place encryption corrupted packet", err)
+	}
+}
+func TestSecureRekeyUnderTraffic(t *testing.T) {
+	a, b := securePair(t)
+	secureEstablish(t, a, b)
+	var wg sync.WaitGroup
+	started := make(chan struct{})
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		var wire, plain [512]byte
+		var tn, rn [24]byte
+		close(started)
+		for range 1000 {
+			out, err := a.sealInto(wire[:0], &tn, 0, []byte("traffic during key rotation"))
+			if err != nil {
+				t.Error(err)
+				return
+			}
+			if _, _, _, err = b.openInto(plain[:0], &rn, out); err != nil {
+				t.Error(err)
+				return
+			}
+		}
+	}()
+	<-started
+	secureEstablish(t, a, b)
+	wg.Wait()
+	if a.handshakes.Load() != 2 || b.handshakes.Load() != 2 {
+		t.Fatal("rotation not completed")
+	}
+}
+
+func TestSecureCookieBindsSourceAndExpires(t *testing.T) {
+	a, b := securePair(t)
+	init, _ := a.initiation(1420, true)
+	address := netip.MustParseAddrPort("192.0.2.10:51830")
+	other := netip.MustParseAddrPort("192.0.2.11:51830")
+	challenge, _, err := b.handleHandshake(init, 1420, address)
+	if err != nil || challenge[3] != secureCookie {
+		t.Fatal("missing source cookie", err)
+	}
+	request, _, err := a.handleHandshake(challenge, 1420)
+	if err != nil {
+		t.Fatal(err)
+	}
+	reply, _, err := b.handleHandshake(request, 1420, other)
+	if err != nil || reply[3] != secureCookie || b.sessions.Load().pending != nil {
+		t.Fatal("cookie accepted from another source", err)
+	}
+	expired := append([]byte(nil), request...)
+	old := b.addressCookie(expired[:16], address, time.Now().Unix()/30-3)
+	copy(expired[len(expired)-32:len(expired)-16], old[:])
+	expired = a.finishHandshake(expired[:len(expired)-16])
+	reply, _, err = b.handleHandshake(expired, 1420, address)
+	if err != nil || reply[3] != secureCookie || b.sessions.Load().pending != nil {
+		t.Fatal("expired cookie accepted", err)
+	}
+	if reply, _, err = b.handleHandshake(request, 1420, address); err != nil || reply[3] != secureResponse {
+		t.Fatal("valid source cookie rejected", err)
+	}
+}
+
+func TestSecureAllowedSourcePolicy(t *testing.T) {
+	runner := TunnelRunner{allowed: []netip.Prefix{netip.MustParsePrefix("172.16.233.2/32"), netip.MustParsePrefix("2001:db8:42::/64")}}
+	packet := make([]byte, 20)
+	packet[0] = 0x45
+	packet[3] = 20
+	for _, tc := range []struct {
+		source string
+		want   bool
+	}{{"172.16.233.2", true}, {"172.16.233.1", false}, {"192.168.1.1", false}} {
+		source := netip.MustParseAddr(tc.source).As4()
+		copy(packet[12:16], source[:])
+		if runner.allowedSource(packet) != tc.want {
+			t.Fatalf("incorrect IPv4 source policy for %s", tc.source)
+		}
+	}
+	v6 := make([]byte, 40)
+	v6[0] = 0x60
+	for _, tc := range []struct {
+		source string
+		want   bool
+	}{{"2001:db8:42::1", true}, {"2001:db8:43::1", false}} {
+		source := netip.MustParseAddr(tc.source).As16()
+		copy(v6[8:24], source[:])
+		if runner.allowedSource(v6) != tc.want {
+			t.Fatalf("incorrect IPv6 source policy for %s", tc.source)
+		}
+	}
+}

+ 285 - 283
internal/aerotun/tunnel.go

@@ -1,113 +1,119 @@
 package aerotun
 
 import (
-	"crypto/cipher"
 	"crypto/rand"
-	"crypto/sha256"
 	"encoding/binary"
+	"errors"
 	"fmt"
 	"log"
 	"net"
-	"os/exec"
+	"net/netip"
 	"sync"
 	"sync/atomic"
 	"time"
 
 	"github.com/pierrec/lz4/v4"
-	"golang.org/x/crypto/chacha20poly1305"
 )
 
-type TunnelRunner struct {
-	mu           sync.RWMutex
-	cfg          TunnelInstance
-	dev          VirtualDevice
-	udpConn      *net.UDPConn
-	aead         cipher.AEAD
-	remoteAddr   *net.UDPAddr
-	txCounter    uint64
-	rxMaxCounter uint64
-	replayWindow uint64
-	stopChan     chan struct{}
-	closed       bool
+const peerTimeout = 65 * time.Second
 
-	// Metrics
-	rxBytes           uint64
-	txBytes           uint64
-	rawBytesIn        uint64
-	rawBytesOut       uint64
-	wireBytesIn       uint64
-	wireBytesOut      uint64
-	savedBytes        uint64
-	packetsRx         uint64
-	packetsTx         uint64
-	compressedPackets uint64
-	rawPackets        uint64
-	lastHandshake     time.Time
+type TunnelRunner struct {
+	secure                                                               *secureProtocol
+	allowed                                                              []netip.Prefix
+	effectiveMTU                                                         atomic.Int64
+	mu                                                                   sync.RWMutex
+	cfg                                                                  TunnelInstance
+	dev                                                                  VirtualDevice
+	udpConn                                                              *net.UDPConn
+	protocol                                                             *protocolState
+	remoteAddr                                                           *net.UDPAddr
+	stopChan                                                             chan struct{}
+	closed                                                               bool
+	wg                                                                   sync.WaitGroup
+	routes                                                               routeManager
+	lastHandshake                                                        time.Time
+	lastError                                                            string
+	rxBytes, txBytes, rawBytesIn, rawBytesOut, wireBytesIn, wireBytesOut uint64
+	savedBytes, packetsRx, packetsTx, compressedPackets, rawPackets      uint64
+	droppedPackets, authFailures, replayDrops                            uint64
 }
 
 func NewTunnelRunner(cfg TunnelInstance) (*TunnelRunner, error) {
-	// 1. Derive 32-byte ChaCha20-Poly1305 key from PSK
-	if cfg.PSK == "" {
-		return nil, fmt.Errorf("pre-shared key (PSK) is required")
+	if err := validateConfig(&cfg); err != nil {
+		return nil, err
+	}
+	var remote *net.UDPAddr
+	var err error
+	if cfg.RemoteEndpoint != "" {
+		remote, err = net.ResolveUDPAddr("udp", cfg.RemoteEndpoint)
+		if err != nil {
+			return nil, fmt.Errorf("resolve remote endpoint: %w", err)
+		}
+		if remote.IP == nil || remote.IP.IsUnspecified() || remote.IP.IsMulticast() {
+			return nil, fmt.Errorf("remote endpoint must be a unicast IP")
+		}
+	}
+	var p *protocolState
+	var secure *secureProtocol
+	if cfg.Protocol == 3 {
+		secure, err = newSecureProtocol(cfg)
+	} else {
+		p, err = newProtocol(cfg.PSK)
 	}
-	key := sha256.Sum256([]byte(cfg.PSK))
-
-	aead, err := chacha20poly1305.New(key[:])
 	if err != nil {
-		return nil, fmt.Errorf("failed to initialize ChaCha20-Poly1305: %w", err)
+		return nil, err
 	}
 
-	// 2. Create Virtual Network Device (TUN or VETH fallback)
+	if remote != nil {
+		cfg.MTU, err = underlayMTUForHeader(cfg.MTU, remote.IP, packetHeaderSize(cfg.Protocol))
+		if err != nil {
+			return nil, err
+		}
+	}
+	if local, _, _ := net.ParseCIDR(cfg.LocalAddress); local.To4() == nil && cfg.MTU < 1280 {
+		return nil, fmt.Errorf("underlay cannot carry IPv6 tunnel MTU 1280")
+	}
 	dev, err := CreateDevice(cfg.Name, cfg.MTU, cfg.LocalAddress)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create virtual network device %s: %w", cfg.Name, err)
+		return nil, fmt.Errorf("create interface: %w", err)
 	}
-
-	// 3. Resolve UDP socket / remote address
-	var localUDPAddr *net.UDPAddr
-	if cfg.ListenPort > 0 {
-		localUDPAddr = &net.UDPAddr{Port: cfg.ListenPort}
-	} else if stringsEqual(cfg.Mode, "server") {
-		localUDPAddr = &net.UDPAddr{Port: DefaultPort}
+	if err = configurePeer(dev, cfg); err != nil {
+		_ = dev.Close()
+		return nil, err
 	}
-
-	udpConn, err := net.ListenUDP("udp", localUDPAddr)
+	conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: cfg.ListenPort})
 	if err != nil {
 		_ = dev.Close()
-		return nil, fmt.Errorf("failed to bind UDP socket on port %d: %w", cfg.ListenPort, err)
+		return nil, fmt.Errorf("bind UDP: %w", err)
 	}
-
-	var remoteAddr *net.UDPAddr
-	if cfg.RemoteEndpoint != "" {
-		resolved, err := net.ResolveUDPAddr("udp", cfg.RemoteEndpoint)
-		if err == nil {
-			remoteAddr = resolved
-		}
+	bufferSize := 4 << 20
+	if cfg.Protocol == 3 {
+		bufferSize = 1 << 20
 	}
-
-	runner := &TunnelRunner{
-		cfg:        cfg,
-		dev:        dev,
-		udpConn:    udpConn,
-		aead:       aead,
-		remoteAddr: remoteAddr,
-		stopChan:   make(chan struct{}),
+	_ = conn.SetReadBuffer(bufferSize)
+	_ = conn.SetWriteBuffer(bufferSize)
+	runner := &TunnelRunner{secure: secure, cfg: cfg, dev: dev, udpConn: conn, protocol: p, remoteAddr: remote, stopChan: make(chan struct{}), routes: routeManager{run: runIP}}
+	for _, p := range cfg.AllowedIPs {
+		prefix, _ := netip.ParsePrefix(p)
+		runner.allowed = append(runner.allowed, prefix.Masked())
 	}
-
+	runner.effectiveMTU.Store(int64(cfg.MTU))
 	return runner, nil
 }
 
-func (t *TunnelRunner) Start() {
-	go t.txLoop()
-	go t.rxLoop()
-	go t.keepaliveLoop()
-
+func (t *TunnelRunner) Start() error {
 	if t.cfg.DefaultRoute {
-		_ = exec.Command("ip", "route", "replace", "default", "dev", t.cfg.Name).Run()
+		if err := t.routes.install(t.cfg.Name, t.remoteAddr.IP); err != nil {
+			return err
+		}
 	}
-	log.Printf("[AeroTUN] Tunnel %s started in %s mode (listen port %d, MTU %d)", t.cfg.Name, t.cfg.Mode, t.cfg.ListenPort, t.cfg.MTU)
+	t.wg.Add(3)
+	go func() { defer t.wg.Done(); t.txLoop() }()
+	go func() { defer t.wg.Done(); t.rxLoop() }()
+	go func() { defer t.wg.Done(); t.keepaliveLoop() }()
+	log.Printf("[AeroTUN] %s started: protocol v%d, UDP %s, MTU %d", t.cfg.Name, t.cfg.Protocol, t.udpConn.LocalAddr(), t.cfg.MTU)
+	return nil
 }
-
 func (t *TunnelRunner) Stop() {
 	t.mu.Lock()
 	if t.closed {
@@ -117,263 +123,275 @@ func (t *TunnelRunner) Stop() {
 	t.closed = true
 	close(t.stopChan)
 	t.mu.Unlock()
-
 	_ = t.udpConn.Close()
 	_ = t.dev.Close()
-	log.Printf("[AeroTUN] Tunnel %s stopped", t.cfg.Name)
+	t.wg.Wait()
+	t.routes.cleanup()
+}
+func (t *TunnelRunner) setError(err error) {
+	t.mu.Lock()
+	t.lastError = err.Error()
+	t.mu.Unlock()
+}
+func (t *TunnelRunner) target() *net.UDPAddr {
+	t.mu.RLock()
+	defer t.mu.RUnlock()
+	return t.remoteAddr
+}
+func (t *TunnelRunner) send(flags byte, payload []byte, target *net.UDPAddr, session sessionID) error {
+	if t.secure != nil {
+		var packet [64]byte
+		var nonce [24]byte
+		if target == nil {
+			return errSession
+		}
+		b, err := t.secure.sealInto(packet[:0], &nonce, flags, payload)
+		if err != nil {
+			return err
+		}
+		n, err := t.udpConn.WriteToUDP(b, target)
+		if err == nil {
+			atomic.AddUint64(&t.wireBytesOut, uint64(n))
+		}
+		return err
+	}
+	if target == nil {
+		return errSession
+	}
+	b, err := t.protocol.seal(flags, payload, session)
+	if err != nil {
+		return err
+	}
+	n, err := t.udpConn.WriteToUDP(b, target)
+	if err == nil {
+		atomic.AddUint64(&t.wireBytesOut, uint64(n))
+	}
+	return err
+}
+func (t *TunnelRunner) sendControl(flags byte, target *net.UDPAddr, session sessionID) error {
+	payload := make([]byte, 2)
+	binary.BigEndian.PutUint16(payload, uint16(t.effectiveMTU.Load()))
+	return t.send(flags, payload, target, session)
 }
-
-// txLoop: Reads raw IP packets from TUN, compresses with LZ4, encrypts with ChaCha20-Poly1305, sends via UDP
 func (t *TunnelRunner) txLoop() {
-	tunBuf := make([]byte, 2048)
-	lz4Buf := make([]byte, 2048)
-	udpPacket := make([]byte, 2048)
-
+	if t.secure != nil {
+		t.txLoopSecure()
+		return
+	}
+	buf := make([]byte, maxPacketSize)
+	comp := make([]byte, lz4.CompressBlockBound(maxPacketSize))
 	for {
-		select {
-		case <-t.stopChan:
-			return
-		default:
-		}
-
-		n, err := t.dev.Read(tunBuf)
+		n, err := t.dev.Read(buf)
 		if err != nil {
 			select {
 			case <-t.stopChan:
 				return
 			default:
-				time.Sleep(10 * time.Millisecond)
-				continue
 			}
+			t.setError(fmt.Errorf("interface read: %w", err))
+			time.Sleep(10 * time.Millisecond)
+			continue
 		}
 		if n == 0 {
 			continue
 		}
-
-		rawIP := tunBuf[:n]
-		atomic.AddUint64(&t.rawBytesOut, uint64(n))
-		atomic.AddUint64(&t.txBytes, uint64(n))
-		atomic.AddUint64(&t.packetsTx, 1)
-
-		// 1. Adaptive LZ4 Compression
-		var flags byte = 0
-		payload := rawIP
-
+		if n > int(t.effectiveMTU.Load()) || !validIPPacket(buf[:n]) {
+			atomic.AddUint64(&t.droppedPackets, 1)
+			t.setError(fmt.Errorf("invalid or oversized interface packet (%d bytes, MTU %d)", n, t.effectiveMTU.Load()))
+			continue
+		}
+		payload := buf[:n]
+		flags := byte(0)
 		if t.cfg.Compression && n > 40 {
-			compressedSize, err := lz4.CompressBlock(rawIP, lz4Buf, nil)
-			// Only compress if saving at least 16 bytes
-			if err == nil && compressedSize > 0 && compressedSize < n-16 {
-				flags |= FlagCompressedLZ4
-				payload = lz4Buf[:compressedSize]
-				saved := uint64(n - compressedSize)
-				atomic.AddUint64(&t.savedBytes, saved)
-				atomic.AddUint64(&t.compressedPackets, 1)
-			} else {
-				atomic.AddUint64(&t.rawPackets, 1)
+			size, err := lz4.CompressBlock(payload, comp, nil)
+			if err == nil && size > 0 && size < n-16 {
+				payload = comp[:size]
+				flags = FlagCompressedLZ4
+			}
+		}
+		if err := t.send(flags, payload, t.target(), zeroSession); err != nil {
+			atomic.AddUint64(&t.droppedPackets, 1)
+			if !errors.Is(err, errSession) {
+				t.setError(fmt.Errorf("UDP send: %w", err))
 			}
+			continue
+		}
+		atomic.AddUint64(&t.txBytes, uint64(n))
+		atomic.AddUint64(&t.rawBytesOut, uint64(n))
+		atomic.AddUint64(&t.packetsTx, 1)
+		if flags == FlagCompressedLZ4 {
+			atomic.AddUint64(&t.compressedPackets, 1)
+			atomic.AddUint64(&t.savedBytes, uint64(n-len(payload)))
 		} else {
 			atomic.AddUint64(&t.rawPackets, 1)
 		}
-
-		// 2. Build 12-byte Authenticated Header:
-		// [Magic (2B)][Version (1B)][Flags (1B)][Counter (8B)]
-		counter := atomic.AddUint64(&t.txCounter, 1)
-		binary.BigEndian.PutUint16(udpPacket[0:2], MagicHeader)
-		udpPacket[2] = ProtocolVersion
-		udpPacket[3] = flags
-		binary.BigEndian.PutUint64(udpPacket[4:12], counter)
-
-		// 3. Construct 12-byte Nonce
-		var nonce [12]byte
-		copy(nonce[0:4], udpPacket[0:4])
-		binary.BigEndian.PutUint64(nonce[4:12], counter)
-
-		// 4. AEAD Seal: authenticate header [0:12] as AAD, encrypt payload
-		encrypted := t.aead.Seal(udpPacket[:12], nonce[:], payload, udpPacket[:12])
-
-		// 5. Transmit UDP packet
-		t.mu.RLock()
-		target := t.remoteAddr
-		t.mu.RUnlock()
-
-		if target != nil {
-			_, _ = t.udpConn.WriteToUDP(encrypted, target)
-			atomic.AddUint64(&t.wireBytesOut, uint64(len(encrypted)))
-		}
 	}
 }
-
-// rxLoop: Reads UDP datagrams, verifies AEAD & anti-replay, decompresses LZ4, writes raw IP to TUN
 func (t *TunnelRunner) rxLoop() {
-	udpBuf := make([]byte, 2048)
-	decompBuf := make([]byte, 2048)
-
+	if t.secure != nil {
+		t.rxLoopSecure()
+		return
+	}
+	buf := make([]byte, maxPacketSize)
+	decomp := make([]byte, maxPacketSize)
 	for {
-		select {
-		case <-t.stopChan:
-			return
-		default:
-		}
-
-		n, srcAddr, err := t.udpConn.ReadFromUDP(udpBuf)
+		n, src, err := t.udpConn.ReadFromUDP(buf)
 		if err != nil {
 			select {
 			case <-t.stopChan:
 				return
 			default:
-				time.Sleep(10 * time.Millisecond)
-				continue
 			}
+			t.setError(err)
+			continue
 		}
-		if n < 28 { // 12-byte header + 16-byte Poly1305 tag minimum
+		// A client pins its configured endpoint; servers support authenticated roaming.
+		if t.cfg.Mode == "client" && !udpAddrsEqual(t.target(), src) {
 			continue
 		}
-
-		packet := udpBuf[:n]
-		atomic.AddUint64(&t.wireBytesIn, uint64(n))
-
-		// 1. Verify Magic Header & Protocol Version
-		magic := binary.BigEndian.Uint16(packet[0:2])
-		if magic != MagicHeader || packet[2] != ProtocolVersion {
+		payload, flags, sender, _, err := t.protocol.open(buf[:n])
+		if err != nil {
+			if errors.Is(err, errReplay) {
+				atomic.AddUint64(&t.replayDrops, 1)
+			} else {
+				atomic.AddUint64(&t.authFailures, 1)
+			}
 			continue
 		}
-
-		flags := packet[3]
-		counter := binary.BigEndian.Uint64(packet[4:12])
-
-		// 2. Anti-Replay Sliding Window Check
-		if !t.checkAndUpdateReplay(counter) {
-			continue // Drop replayed packet
+		atomic.AddUint64(&t.wireBytesIn, uint64(n))
+		if flags == flagHello {
+			if err := t.sendControl(flagAck, src, sender); err != nil {
+				t.setError(err)
+			}
+			continue
 		}
-
-		// 3. Construct 12-byte Nonce & Verify AEAD
-		var nonce [12]byte
-		copy(nonce[0:4], packet[0:4])
-		binary.BigEndian.PutUint64(nonce[4:12], counter)
-
-		decrypted, err := t.aead.Open(nil, nonce[:], packet[12:], packet[:12])
-		if err != nil {
-			continue // Authentication failed
+		// A replayable HELLO must not change MTU or connection state. Only
+		// packets bound to our current boot/session may change the dataplane.
+		previousMTU := t.effectiveMTU.Load()
+		if flags == flagAck || flags == flagConfirm || flags == FlagKeepalive {
+			if err := t.adjustMTU(src.IP, int(binary.BigEndian.Uint16(payload))); err != nil {
+				t.setError(err)
+				continue
+			}
 		}
-
-		// 4. Dynamic Roaming: Learn remote peer address in server mode
 		t.mu.Lock()
-		if t.remoteAddr == nil || (t.cfg.Mode == "server" && !udpAddrsEqual(t.remoteAddr, srcAddr)) {
-			t.remoteAddr = srcAddr
-			t.lastHandshake = time.Now()
-		}
+		t.remoteAddr = src
 		t.lastHandshake = time.Now()
 		t.mu.Unlock()
-
-		// 5. Handle Keepalive
-		if flags&FlagKeepalive != 0 {
+		if flags == flagAck {
+			if err := t.sendControl(flagConfirm, src, sender); err != nil {
+				t.setError(err)
+			}
 			continue
 		}
-
-		// 6. LZ4 Decompress if flagged
-		var ipPayload []byte
-		if flags&FlagCompressedLZ4 != 0 {
-			uncompressedSize, err := lz4.UncompressBlock(decrypted, decompBuf)
-			if err != nil || uncompressedSize == 0 {
+		if flags == FlagKeepalive || flags == flagConfirm {
+			// Propagate a lower underlay/path MTU immediately, including when
+			// it was discovered only after receiving the final confirmation.
+			if t.effectiveMTU.Load() < previousMTU {
+				if err := t.sendControl(FlagKeepalive, src, sender); err != nil {
+					t.setError(err)
+				}
+			}
+			continue
+		}
+		if flags == FlagCompressedLZ4 {
+			size, err := lz4.UncompressBlock(payload, decomp[:int(t.effectiveMTU.Load())])
+			if err != nil || size == 0 {
+				atomic.AddUint64(&t.droppedPackets, 1)
 				continue
 			}
-			ipPayload = decompBuf[:uncompressedSize]
-			atomic.AddUint64(&t.rawBytesIn, uint64(uncompressedSize))
-		} else {
-			ipPayload = decrypted
-			atomic.AddUint64(&t.rawBytesIn, uint64(len(decrypted)))
+			payload = decomp[:size]
 		}
-
-		// 7. Inject IP packet into TUN device
-		_, _ = t.dev.Write(ipPayload)
-		atomic.AddUint64(&t.rxBytes, uint64(len(ipPayload)))
+		if len(payload) > int(t.effectiveMTU.Load()) || !validIPPacket(payload) {
+			atomic.AddUint64(&t.droppedPackets, 1)
+			continue
+		}
+		wrote, err := t.dev.Write(payload)
+		if err != nil || wrote != len(payload) {
+			atomic.AddUint64(&t.droppedPackets, 1)
+			t.setError(fmt.Errorf("interface write: %d/%d bytes, %v", wrote, len(payload), err))
+			continue
+		}
+		atomic.AddUint64(&t.rxBytes, uint64(wrote))
+		atomic.AddUint64(&t.rawBytesIn, uint64(wrote))
 		atomic.AddUint64(&t.packetsRx, 1)
 	}
 }
-
 func (t *TunnelRunner) keepaliveLoop() {
-	ticker := time.NewTicker(20 * time.Second)
+	if t.secure != nil {
+		t.keepaliveSecure()
+		return
+	}
+	// Initiate immediately, then retry quickly until the peer is authenticated.
+	ticker := time.NewTicker(2 * time.Second)
 	defer ticker.Stop()
-
-	keepaliveBuf := make([]byte, 32)
+	lastSent := time.Time{}
 	for {
+		target := t.target()
+		t.mu.RLock()
+		connected := !t.lastHandshake.IsZero() && time.Since(t.lastHandshake) < peerTimeout
+		t.mu.RUnlock()
+		if target != nil && (!connected || time.Since(lastSent) >= 20*time.Second) {
+			flags := byte(flagHello)
+			if connected {
+				flags = FlagKeepalive
+			}
+			if err := t.sendControl(flags, target, zeroSession); err != nil {
+				t.setError(err)
+			}
+			lastSent = time.Now()
+		}
 		select {
 		case <-t.stopChan:
 			return
 		case <-ticker.C:
-			t.mu.RLock()
-			target := t.remoteAddr
-			t.mu.RUnlock()
-
-			if target == nil {
-				continue
-			}
-
-			counter := atomic.AddUint64(&t.txCounter, 1)
-			binary.BigEndian.PutUint16(keepaliveBuf[0:2], MagicHeader)
-			keepaliveBuf[2] = ProtocolVersion
-			keepaliveBuf[3] = FlagKeepalive
-			binary.BigEndian.PutUint64(keepaliveBuf[4:12], counter)
-
-			var nonce [12]byte
-			copy(nonce[0:4], keepaliveBuf[0:4])
-			binary.BigEndian.PutUint64(nonce[4:12], counter)
-
-			encrypted := t.aead.Seal(keepaliveBuf[:12], nonce[:], nil, keepaliveBuf[:12])
-			_, _ = t.udpConn.WriteToUDP(encrypted, target)
 		}
 	}
 }
-
-// 64-packet Sliding Window Anti-Replay algorithm
-func (t *TunnelRunner) checkAndUpdateReplay(counter uint64) bool {
-	t.mu.Lock()
-	defer t.mu.Unlock()
-
-	if counter == 0 {
+func validIPPacket(b []byte) bool {
+	if len(b) < 20 {
 		return false
 	}
-	if counter > t.rxMaxCounter {
-		diff := counter - t.rxMaxCounter
-		if diff >= 64 {
-			t.replayWindow = 1
-		} else {
-			t.replayWindow = (t.replayWindow << diff) | 1
-		}
-		t.rxMaxCounter = counter
-		return true
-	}
-
-	diff := t.rxMaxCounter - counter
-	if diff >= 64 {
-		return false // Packet is too old
+	if b[0]>>4 == 4 {
+		ihl := int(b[0]&15) * 4
+		return ihl >= 20 && len(b) >= ihl && int(b[2])<<8|int(b[3]) == len(b)
 	}
-
-	mask := uint64(1) << diff
-	if (t.replayWindow & mask) != 0 {
-		return false // Duplicate packet!
+	if b[0]>>4 == 6 {
+		return len(b) >= 40 && (int(b[4])<<8|int(b[5]))+40 == len(b)
 	}
-
-	t.replayWindow |= mask
-	return true
+	return false
 }
-
 func (t *TunnelRunner) GetTelemetry() TunnelInstance {
 	t.mu.RLock()
-	peerStr := ""
-	if t.remoteAddr != nil {
-		peerStr = t.remoteAddr.String()
+	inst := t.cfg
+	if _, ok := t.dev.(*nativeTun); ok {
+		inst.Backend = "tun"
+	} else {
+		inst.Backend = "veth"
+	}
+	if t.secure != nil {
+		inst.Handshakes = t.secure.handshakes.Load()
+	}
+	inst.MTU = int(t.effectiveMTU.Load())
+	inst.Running = !t.closed
+	inst.Connected = inst.Running && !t.lastHandshake.IsZero() && time.Since(t.lastHandshake) < peerTimeout
+	inst.ActivePeerEndpoint = ""
+	if inst.Connected && t.remoteAddr != nil {
+		inst.ActivePeerEndpoint = t.remoteAddr.String()
 	}
-	lastHandshakeStr := "Never"
+	inst.LastHandshake = "Never"
 	if !t.lastHandshake.IsZero() {
-		lastHandshakeStr = time.Since(t.lastHandshake).Truncate(time.Second).String() + " ago"
+		inst.LastHandshake = time.Since(t.lastHandshake).Truncate(time.Second).String() + " ago"
 	}
+	if t.secure != nil {
+		session := t.secure.sessions.Load().current
+		if session == nil || time.Since(session.born) > secureRejectAfter {
+			inst.Connected = false
+			inst.ActivePeerEndpoint = ""
+		}
+	}
+	inst.LastError = t.lastError
 	t.mu.RUnlock()
-
-	inst := t.cfg
-	inst.Running = !t.closed
-	inst.ActivePeerEndpoint = peerStr
-	inst.LastHandshake = lastHandshakeStr
 	inst.RxBytes = atomic.LoadUint64(&t.rxBytes)
 	inst.TxBytes = atomic.LoadUint64(&t.txBytes)
 	inst.RawBytesIn = atomic.LoadUint64(&t.rawBytesIn)
@@ -385,23 +403,15 @@ func (t *TunnelRunner) GetTelemetry() TunnelInstance {
 	inst.PacketsTx = atomic.LoadUint64(&t.packetsTx)
 	inst.CompressedPackets = atomic.LoadUint64(&t.compressedPackets)
 	inst.RawPackets = atomic.LoadUint64(&t.rawPackets)
-
-	// Calculate compression ratio
-	if inst.RawBytesOut > 0 && inst.WireBytesOut > 0 {
-		ratio := float64(inst.RawBytesOut) / float64(inst.WireBytesOut)
-		if ratio > 1.0 {
-			savedPct := (1.0 - (float64(inst.WireBytesOut) / float64(inst.RawBytesOut))) * 100.0
-			inst.CompressionRatio = fmt.Sprintf("%.2fx (%.1f%% saved)", ratio, savedPct)
-		} else {
-			inst.CompressionRatio = "1.00x (0% saved)"
-		}
-	} else {
-		inst.CompressionRatio = "1.00x (0% saved)"
+	inst.DroppedPackets = atomic.LoadUint64(&t.droppedPackets)
+	inst.AuthFailures = atomic.LoadUint64(&t.authFailures)
+	inst.ReplayDrops = atomic.LoadUint64(&t.replayDrops)
+	inst.CompressionRatio = "1.00x (0% saved)"
+	if inst.RawBytesOut > inst.WireBytesOut && inst.WireBytesOut > 0 {
+		inst.CompressionRatio = fmt.Sprintf("%.2fx (%.1f%% saved)", float64(inst.RawBytesOut)/float64(inst.WireBytesOut), (1-float64(inst.WireBytesOut)/float64(inst.RawBytesOut))*100)
 	}
-
 	return inst
 }
-
 func GenerateSecureKey() (string, error) {
 	key := make([]byte, 32)
 	if _, err := rand.Read(key); err != nil {
@@ -409,14 +419,6 @@ func GenerateSecureKey() (string, error) {
 	}
 	return fmt.Sprintf("%x", key), nil
 }
-
 func udpAddrsEqual(a, b *net.UDPAddr) bool {
-	if a == nil || b == nil {
-		return a == b
-	}
-	return a.IP.Equal(b.IP) && a.Port == b.Port
-}
-
-func stringsEqual(a, b string) bool {
-	return len(a) == len(b) && (a == b || (len(a) > 0 && (a[0]|0x20) == (b[0]|0x20)))
+	return a != nil && b != nil && a.IP.Equal(b.IP) && a.Port == b.Port && a.Zone == b.Zone
 }

+ 38 - 24
internal/aerotun/types.go

@@ -4,7 +4,7 @@ import "time"
 
 const (
 	ProtocolName      = "AeroTUN"
-	ProtocolVersion   = 1
+	ProtocolVersion   = 2
 	DefaultMTU        = 1420
 	DefaultPort       = 51830
 	MagicHeader       = 0xAE70 // "AE"
@@ -15,6 +15,15 @@ const (
 
 // TunnelInstance represents a configured AeroTUN interface and its live status
 type TunnelInstance struct {
+	Backend        string    `json:"backend,omitempty"`
+	Handshakes     uint64    `json:"handshakes,omitempty"`
+	Protocol       int       `json:"protocol_version,omitempty"`
+	PrivateKey     string    `json:"private_key,omitempty"`
+	PublicKey      string    `json:"public_key,omitempty"`
+	PeerPublicKey  string    `json:"peer_public_key,omitempty"`
+	AllowedIPs     []string  `json:"allowed_ips,omitempty"`
+	HasPSK         bool      `json:"has_psk,omitempty"`
+	HasPrivateKey  bool      `json:"has_private_key,omitempty"`
 	ID             string    `json:"id"`
 	Name           string    `json:"name"`            // e.g. "aerotun0"
 	Mode           string    `json:"mode"`            // "server" (listen) or "client" (dial)
@@ -22,7 +31,7 @@ type TunnelInstance struct {
 	RemoteEndpoint string    `json:"remote_endpoint"` // "host:port" (used if client or static peer)
 	LocalAddress   string    `json:"local_address"`   // CIDR e.g. "10.99.0.1/24"
 	PeerAddress    string    `json:"peer_address"`    // Peer CIDR e.g. "10.99.0.2/24"
-	PSK            string    `json:"psk"`             // 32-byte hex/base64 pre-shared key
+	PSK            string    `json:"psk,omitempty"`   // 32-byte hex/base64 pre-shared key
 	Compression    bool      `json:"compression"`     // Enable adaptive LZ4
 	MTU            int       `json:"mtu"`             // e.g. 1420
 	DefaultRoute   bool      `json:"default_route"`   // Route 0.0.0.0/0 via this tunnel
@@ -31,31 +40,36 @@ type TunnelInstance struct {
 	CreatedAt      time.Time `json:"created_at"`
 
 	// Runtime state & telemetry
-	Running            bool    `json:"running"`
-	ActivePeerEndpoint string  `json:"active_peer_endpoint,omitempty"`
-	LastHandshake      string  `json:"last_handshake,omitempty"`
-	RxBytes            uint64  `json:"rx_bytes"`
-	TxBytes            uint64  `json:"tx_bytes"`
-	RawBytesIn         uint64  `json:"raw_bytes_in"`  // uncompressed RX payload
-	RawBytesOut        uint64  `json:"raw_bytes_out"` // uncompressed TX payload
-	WireBytesIn        uint64  `json:"wire_bytes_in"` // actual wire RX
-	WireBytesOut       uint64  `json:"wire_bytes_out"`// actual wire TX
-	SavedBytes         uint64  `json:"saved_bytes"`   // Bandwidth saved through compression
-	CompressionRatio   string  `json:"compression_ratio,omitempty"`
-	PacketsRx          uint64  `json:"packets_rx"`
-	PacketsTx          uint64  `json:"packets_tx"`
-	CompressedPackets  uint64  `json:"compressed_packets"`
-	RawPackets         uint64  `json:"raw_packets"`
+	Connected          bool   `json:"connected"`
+	LastError          string `json:"last_error,omitempty"`
+	DroppedPackets     uint64 `json:"dropped_packets"`
+	AuthFailures       uint64 `json:"auth_failures"`
+	ReplayDrops        uint64 `json:"replay_drops"`
+	Running            bool   `json:"running"`
+	ActivePeerEndpoint string `json:"active_peer_endpoint,omitempty"`
+	LastHandshake      string `json:"last_handshake,omitempty"`
+	RxBytes            uint64 `json:"rx_bytes"`
+	TxBytes            uint64 `json:"tx_bytes"`
+	RawBytesIn         uint64 `json:"raw_bytes_in"`   // uncompressed RX payload
+	RawBytesOut        uint64 `json:"raw_bytes_out"`  // uncompressed TX payload
+	WireBytesIn        uint64 `json:"wire_bytes_in"`  // actual wire RX
+	WireBytesOut       uint64 `json:"wire_bytes_out"` // actual wire TX
+	SavedBytes         uint64 `json:"saved_bytes"`    // Bandwidth saved through compression
+	CompressionRatio   string `json:"compression_ratio,omitempty"`
+	PacketsRx          uint64 `json:"packets_rx"`
+	PacketsTx          uint64 `json:"packets_tx"`
+	CompressedPackets  uint64 `json:"compressed_packets"`
+	RawPackets         uint64 `json:"raw_packets"`
 }
 
 // SummaryStats aggregates performance across all AeroTUN tunnels
 type SummaryStats struct {
-	TotalTunnels     int    `json:"total_tunnels"`
-	ActiveTunnels    int    `json:"active_tunnels"`
-	TotalRxBytes     uint64 `json:"total_rx_bytes"`
-	TotalTxBytes     uint64 `json:"total_tx_bytes"`
-	TotalSavedBytes  uint64 `json:"total_saved_bytes"`
-	AverageRatio     string `json:"average_ratio"`
-	AlgorithmCrypto  string `json:"algorithm_crypto"`
+	TotalTunnels      int    `json:"total_tunnels"`
+	ActiveTunnels     int    `json:"active_tunnels"`
+	TotalRxBytes      uint64 `json:"total_rx_bytes"`
+	TotalTxBytes      uint64 `json:"total_tx_bytes"`
+	TotalSavedBytes   uint64 `json:"total_saved_bytes"`
+	AverageRatio      string `json:"average_ratio"`
+	AlgorithmCrypto   string `json:"algorithm_crypto"`
 	AlgorithmCompress string `json:"algorithm_compress"`
 }

+ 32 - 1
internal/api/aerotun_handlers.go

@@ -4,10 +4,14 @@ import (
 	"encoding/json"
 	"net/http"
 	"proxmox-router/internal/aerotun"
+	"proxmox-router/internal/auth"
 )
 
 func (s *Server) handleListAeroTUNTunnels(w http.ResponseWriter, r *http.Request) {
 	tunnels := s.vpnMgr.AeroTUN.List()
+	for i := range tunnels {
+		tunnels[i] = tunnels[i].PublicView()
+	}
 	w.Header().Set("Content-Type", "application/json")
 	_ = json.NewEncoder(w).Encode(tunnels)
 }
@@ -18,7 +22,19 @@ func (s *Server) handleGetAeroTUNStats(w http.ResponseWriter, r *http.Request) {
 	_ = json.NewEncoder(w).Encode(stats)
 }
 
+func requireAeroTUNAdmin(w http.ResponseWriter, r *http.Request) bool {
+	sess := getSessionFromContext(r.Context())
+	if sess == nil || sess.Role != auth.RoleFull {
+		http.Error(w, "AeroTUN administration requires full privileges", http.StatusForbidden)
+		return false
+	}
+	return true
+}
 func (s *Server) handleGenerateAeroTUNKey(w http.ResponseWriter, r *http.Request) {
+	if !requireAeroTUNAdmin(w, r) {
+		return
+	}
+	w.Header().Set("Cache-Control", "no-store")
 	key, err := aerotun.GenerateSecureKey()
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -29,6 +45,9 @@ func (s *Server) handleGenerateAeroTUNKey(w http.ResponseWriter, r *http.Request
 }
 
 func (s *Server) handleSaveAeroTUNTunnel(w http.ResponseWriter, r *http.Request) {
+	if !requireAeroTUNAdmin(w, r) {
+		return
+	}
 	var inst aerotun.TunnelInstance
 	if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
 		http.Error(w, "invalid request body", http.StatusBadRequest)
@@ -42,10 +61,13 @@ func (s *Server) handleSaveAeroTUNTunnel(w http.ResponseWriter, r *http.Request)
 	}
 
 	w.Header().Set("Content-Type", "application/json")
-	_ = json.NewEncoder(w).Encode(saved)
+	_ = json.NewEncoder(w).Encode(saved.PublicView())
 }
 
 func (s *Server) handleDeleteAeroTUNTunnel(w http.ResponseWriter, r *http.Request) {
+	if !requireAeroTUNAdmin(w, r) {
+		return
+	}
 	id := r.PathValue("id")
 	if id == "" {
 		http.Error(w, "missing tunnel ID", http.StatusBadRequest)
@@ -62,6 +84,9 @@ func (s *Server) handleDeleteAeroTUNTunnel(w http.ResponseWriter, r *http.Reques
 }
 
 func (s *Server) handleSetAeroTUNTunnelState(w http.ResponseWriter, r *http.Request) {
+	if !requireAeroTUNAdmin(w, r) {
+		return
+	}
 	id := r.PathValue("id")
 	if id == "" {
 		http.Error(w, "missing tunnel ID", http.StatusBadRequest)
@@ -86,6 +111,9 @@ func (s *Server) handleSetAeroTUNTunnelState(w http.ResponseWriter, r *http.Requ
 }
 
 func (s *Server) handleStartAeroTUNTunnel(w http.ResponseWriter, r *http.Request) {
+	if !requireAeroTUNAdmin(w, r) {
+		return
+	}
 	id := r.PathValue("id")
 	if id == "" {
 		http.Error(w, "missing tunnel ID", http.StatusBadRequest)
@@ -102,6 +130,9 @@ func (s *Server) handleStartAeroTUNTunnel(w http.ResponseWriter, r *http.Request
 }
 
 func (s *Server) handleStopAeroTUNTunnel(w http.ResponseWriter, r *http.Request) {
+	if !requireAeroTUNAdmin(w, r) {
+		return
+	}
 	id := r.PathValue("id")
 	if id == "" {
 		http.Error(w, "missing tunnel ID", http.StatusBadRequest)

+ 74 - 0
internal/api/aerotun_handlers_test.go

@@ -0,0 +1,74 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"proxmox-router/internal/aerotun"
+	"proxmox-router/internal/auth"
+	"proxmox-router/internal/vpn"
+)
+
+func aeroRequest(method, path, body string, role auth.Role) *http.Request {
+	r := httptest.NewRequest(method, path, strings.NewReader(body))
+	return r.WithContext(context.WithValue(r.Context(), sessionContextKey, &auth.Session{Role: role}))
+}
+func TestAeroTUNSecretRedactionAndEdit(t *testing.T) {
+	m := aerotun.NewManagerWithPath(filepath.Join(t.TempDir(), "aerotun.json"))
+	defer m.StopAll()
+	key := strings.Repeat("12", 32)
+	saved, err := m.Save(aerotun.TunnelInstance{Name: "atest", Mode: "server", LocalAddress: "10.99.0.1/24", PSK: key, Disabled: true})
+	if err != nil {
+		t.Fatal(err)
+	}
+	s := &Server{vpnMgr: &vpn.Manager{AeroTUN: m}}
+	w := httptest.NewRecorder()
+	s.handleListAeroTUNTunnels(w, aeroRequest("GET", "/api/vpn/aerotun/tunnels", "", auth.RoleRead))
+	if strings.Contains(w.Body.String(), key) || strings.Contains(w.Body.String(), `"psk":`) {
+		t.Fatal("list disclosed PSK")
+	}
+	var views []aerotun.TunnelInstance
+	if err = json.Unmarshal(w.Body.Bytes(), &views); err != nil || !views[0].HasPSK {
+		t.Fatal("missing safe key presence indicator", err)
+	}
+	saved.PSK = ""
+	saved.Comment = "edited without revealing secret"
+	body, _ := json.Marshal(saved)
+	w = httptest.NewRecorder()
+	s.handleSaveAeroTUNTunnel(w, aeroRequest("POST", "/api/vpn/aerotun/tunnels", string(body), auth.RoleFull))
+	if w.Code != 200 || strings.Contains(w.Body.String(), key) {
+		t.Fatalf("save failed or disclosed PSK: %d", w.Code)
+	}
+	got, _ := m.Get(saved.ID)
+	if got.PSK != key {
+		t.Fatal("redacted edit erased the stored key")
+	}
+	for _, role := range []auth.Role{auth.RoleRead, auth.RoleWrite} {
+		w = httptest.NewRecorder()
+		s.handleSaveAeroTUNTunnel(w, aeroRequest("POST", "/api/vpn/aerotun/tunnels", string(body), role))
+		if w.Code != 403 {
+			t.Fatal("non-administrator modified tunnel")
+		}
+		w = httptest.NewRecorder()
+		s.handleGenerateAeroTUNKey(w, aeroRequest("GET", "/api/vpn/aerotun/genkey", "", role))
+		if w.Code != 403 {
+			t.Fatal("non-administrator generated key")
+		}
+	}
+}
+func TestAeroTUNPrivateIdentityRedacted(t *testing.T) {
+	private, public, err := aerotun.GenerateIdentity()
+	if err != nil {
+		t.Fatal(err)
+	}
+	view := aerotun.TunnelInstance{PSK: strings.Repeat("ab", 32), PrivateKey: private, PublicKey: public}.PublicView()
+	encoded, _ := json.Marshal(view)
+	if strings.Contains(string(encoded), private) || !view.HasPrivateKey || view.PublicKey != public {
+		t.Fatal("private identity exposed or public identity lost")
+	}
+}

+ 11 - 3
internal/api/api.go

@@ -74,6 +74,10 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 }
 
 func (s *Server) registerRoutes() {
+	s.mux.HandleFunc("GET /api/update/status", s.handleUpdateStatus)
+	s.mux.HandleFunc("POST /api/update/config", s.handleUpdateConfig)
+	s.mux.HandleFunc("POST /api/update/check", s.handleUpdateCheck)
+	s.mux.HandleFunc("POST /api/update/{action}", s.handleUpdateAction)
 	// API Endpoints
 	s.mux.HandleFunc("GET /api/system/status", s.handleSystemStatus)
 
@@ -463,12 +467,16 @@ func (s *Server) registerRoutes() {
 }
 
 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// If www service is disabled, reject HTTP requests with 503
+	// HTTP and HTTPS have independent management service settings.
 	if s.servicesMgr != nil {
-		if www, ok := s.servicesMgr.GetService("www"); ok && www.Disabled {
+		service := "www"
+		if r.TLS != nil {
+			service = "www-ssl"
+		}
+		if www, ok := s.servicesMgr.GetService(service); ok && www.Disabled {
 			w.Header().Set("Content-Type", "text/plain; charset=utf-8")
 			w.WriteHeader(http.StatusServiceUnavailable)
-			_, _ = w.Write([]byte("503 Service Unavailable: Web service is disabled via /ip service disable www\n"))
+			_, _ = fmt.Fprintf(w, "503 Service Unavailable: %s management service is disabled\n", service)
 			return
 		}
 	}

+ 2 - 1
internal/api/bwtest_handlers.go

@@ -79,7 +79,8 @@ func (s *Server) handleStreamBwTest(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Connection", "keep-alive")
 	w.Header().Set("X-Accel-Buffering", "no")
 
-	subCh := s.bwtestMgr.SubscribeClient()
+	subCh, unsubscribe := s.bwtestMgr.SubscribeClient()
+	defer unsubscribe()
 	ticker := time.NewTicker(500 * time.Millisecond)
 	defer ticker.Stop()
 

+ 41 - 0
internal/api/management_services_test.go

@@ -0,0 +1,41 @@
+package api
+
+import (
+	"crypto/tls"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+
+	"proxmox-router/internal/services"
+)
+
+func TestHTTPAndHTTPSServiceGatesAreIndependent(t *testing.T) {
+	m := services.NewManager(filepath.Join(t.TempDir(), "services.json"))
+	mux := http.NewServeMux()
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })
+	s := &Server{servicesMgr: m, mux: mux}
+	for _, tc := range []struct{ httpDisabled, httpsDisabled bool }{{true, false}, {false, true}, {false, false}} {
+		if err := m.SetServiceState("www", tc.httpDisabled); err != nil {
+			t.Fatal(err)
+		}
+		if err := m.SetServiceState("www-ssl", tc.httpsDisabled); err != nil {
+			t.Fatal(err)
+		}
+		for _, secure := range []bool{false, true} {
+			r := httptest.NewRequest("GET", "/", nil)
+			if secure {
+				r.TLS = &tls.ConnectionState{}
+			}
+			w := httptest.NewRecorder()
+			s.ServeHTTP(w, r)
+			want := 204
+			if (!secure && tc.httpDisabled) || (secure && tc.httpsDisabled) {
+				want = 503
+			}
+			if w.Code != want {
+				t.Fatalf("tls=%v httpDisabled=%v httpsDisabled=%v: got %d want %d", secure, tc.httpDisabled, tc.httpsDisabled, w.Code, want)
+			}
+		}
+	}
+}

+ 172 - 0
internal/api/update_handlers.go

@@ -0,0 +1,172 @@
+package api
+
+import (
+	"encoding/json"
+	"errors"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"proxmox-router/internal/auth"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+)
+
+var BuildCommit = "unknown"
+
+const updateDir = "/var/lib/debtik/update"
+
+var branchPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
+var pinPattern = regexp.MustCompile(`^sha256//[A-Za-z0-9+/]{43}=$`)
+
+type updateConfig struct {
+	Repository string `json:"repository"`
+	Branch     string `json:"branch"`
+	TLSKeyPin  string `json:"tls_key_pin,omitempty"`
+}
+
+func validUpdateConfig(c updateConfig) bool {
+	u, err := url.Parse(c.Repository)
+	if err != nil {
+		return false
+	}
+	if u.User != nil {
+		if u.Scheme != "ssh" || u.User.Username() == "" {
+			return false
+		}
+		if _, hasPassword := u.User.Password(); hasPassword {
+			return false
+		}
+	}
+	return (c.TLSKeyPin == "" || pinPattern.MatchString(c.TLSKeyPin)) && u.Host != "" && (u.Scheme == "http" || u.Scheme == "https" || u.Scheme == "ssh") && branchPattern.MatchString(c.Branch) && !strings.Contains(c.Branch, "..") && !strings.HasSuffix(c.Branch, "/")
+}
+func readUpdateConfig() updateConfig {
+	var c updateConfig
+	data, err := os.ReadFile(filepath.Join(updateDir, "config.json"))
+	if err == nil {
+		_ = json.Unmarshal(data, &c)
+	}
+	return c
+}
+func requireUpdateAdmin(w http.ResponseWriter, r *http.Request) bool {
+	session := getSessionFromContext(r.Context())
+	if session == nil || session.Role != auth.RoleFull {
+		http.Error(w, "Administrator required", http.StatusForbidden)
+		return false
+	}
+	return true
+}
+func (s *Server) handleUpdateStatus(w http.ResponseWriter, r *http.Request) {
+	c := readUpdateConfig()
+	state := "idle"
+	if data, err := os.ReadFile(filepath.Join(updateDir, "status")); err == nil {
+		state = strings.TrimSpace(string(data))
+	}
+	logData, _ := os.ReadFile(filepath.Join(updateDir, "update.log"))
+	if len(logData) > 12000 {
+		logData = logData[len(logData)-12000:]
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"repository": c.Repository, "branch": c.Branch, "tls_key_pin": c.TLSKeyPin, "installed_commit": BuildCommit, "state": state, "log": string(logData)})
+}
+func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
+	if !requireUpdateAdmin(w, r) {
+		return
+	}
+	var c updateConfig
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&c); err != nil || !validUpdateConfig(c) {
+		http.Error(w, "Valid HTTP(S)/SSH repository and branch required", http.StatusBadRequest)
+		return
+	}
+	if err := os.MkdirAll(updateDir, 0700); err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	data, _ := json.Marshal(c)
+	tmp, err := os.CreateTemp(updateDir, ".config-*")
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	defer os.Remove(tmp.Name())
+	if _, err = tmp.Write(data); err == nil {
+		err = tmp.Close()
+	} else {
+		tmp.Close()
+	}
+	if err == nil {
+		err = os.Rename(tmp.Name(), filepath.Join(updateDir, "config.json"))
+	}
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	writeJSON(w, 200, c)
+}
+func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
+	if !requireUpdateAdmin(w, r) {
+		return
+	}
+	c := readUpdateConfig()
+	if !validUpdateConfig(c) {
+		http.Error(w, "Configure repository first", 400)
+		return
+	}
+	args := []string{}
+	if c.TLSKeyPin != "" {
+		args = append(args, "-c", "http.sslVerify=false", "-c", "http.pinnedpubkey="+c.TLSKeyPin)
+	}
+	args = append(args, "ls-remote", "--exit-code", "--heads", c.Repository, "refs/heads/"+c.Branch)
+	cmd := exec.CommandContext(r.Context(), "git", args...)
+	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
+	data, err := cmd.Output()
+	if err != nil {
+		http.Error(w, "Cannot read repository branch", 502)
+		return
+	}
+	fields := strings.Fields(string(data))
+	if len(fields) != 2 || fields[1] != "refs/heads/"+c.Branch {
+		http.Error(w, "Unexpected Git response", 502)
+		return
+	}
+	writeJSON(w, 200, map[string]any{"installed_commit": BuildCommit, "available_commit": fields[0], "update_available": BuildCommit != fields[0]})
+}
+func (s *Server) handleUpdateAction(w http.ResponseWriter, r *http.Request) {
+	if !requireUpdateAdmin(w, r) {
+		return
+	}
+	action := r.PathValue("action")
+	if action != "install" && action != "rollback" {
+		http.Error(w, "Unknown action", 400)
+		return
+	}
+	c := readUpdateConfig()
+	if action == "install" && !validUpdateConfig(c) {
+		http.Error(w, "Configure repository first", 400)
+		return
+	}
+	if _, err := os.Stat("/usr/local/libexec/debtik-update"); err != nil {
+		http.Error(w, "Update helper is not installed", 503)
+		return
+	}
+	if err := os.MkdirAll(updateDir, 0700); err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	args := []string{"--unit=debtik-update-" + action + "-" + strconv.FormatInt(time.Now().UnixNano(), 10), "--collect", "/usr/local/libexec/debtik-update", action}
+	if action == "install" {
+		args = append(args, c.Repository, c.Branch, c.TLSKeyPin)
+	}
+	cmd := exec.CommandContext(r.Context(), "systemd-run", args...)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		if errors.Is(err, exec.ErrNotFound) {
+			http.Error(w, "systemd-run unavailable", 503)
+		} else {
+			http.Error(w, string(out), 409)
+		}
+		return
+	}
+	writeJSON(w, http.StatusAccepted, map[string]string{"state": "starting", "action": action})
+}

+ 39 - 0
internal/api/update_handlers_test.go

@@ -0,0 +1,39 @@
+package api
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"proxmox-router/internal/auth"
+	"strings"
+	"testing"
+)
+
+func TestUpdateConfigValidationAndPrivileges(t *testing.T) {
+	for _, tc := range []struct {
+		repo, branch string
+		valid        bool
+	}{
+		{"http://gogs.example/team/router.git", "master", true},
+		{"ssh://git@example.com/team/router.git", "release/1", true},
+		{"file:///etc/passwd", "main", false},
+		{"https://user:password@example.com/router.git", "main", false},
+		{"https://example.com/router.git", "../bad", false},
+		{"https://example.com/router.git", "-option", false},
+	} {
+		if got := validUpdateConfig(updateConfig{Repository: tc.repo, Branch: tc.branch}); got != tc.valid {
+			t.Fatalf("%s %s: got %v", tc.repo, tc.branch, got)
+		}
+	}
+	s := &Server{}
+	body := `{"repository":"https://example.com/router.git","branch":"main"}`
+	for _, role := range []auth.Role{auth.RoleRead, auth.RoleWrite} {
+		r := httptest.NewRequest("POST", "/api/update/config", strings.NewReader(body))
+		r = r.WithContext(context.WithValue(r.Context(), sessionContextKey, &auth.Session{Role: role}))
+		w := httptest.NewRecorder()
+		s.handleUpdateConfig(w, r)
+		if w.Code != http.StatusForbidden {
+			t.Fatalf("%s changed update config: %d", role, w.Code)
+		}
+	}
+}

+ 200 - 108
internal/bwtest/client.go

@@ -3,6 +3,7 @@ package bwtest
 import (
 	"bufio"
 	"context"
+	crand "crypto/rand"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -27,13 +28,15 @@ type BwTestClient struct {
 	status  TestStatus
 	samples []*LiveSample
 
-	sampleCh chan LiveSample
+	subscribers               map[chan LiveSample]struct{}
+	sampleCount               uint64
+	jitterSum, peakTx, peakRx float64
 }
 
 // NewClient creates a new BwTestClient instance
 func NewClient() *BwTestClient {
 	return &BwTestClient{
-		sampleCh: make(chan LiveSample, 100),
+		subscribers: make(map[chan LiveSample]struct{}),
 	}
 }
 
@@ -45,14 +48,39 @@ func (c *BwTestClient) GetStatus() TestStatus {
 	st := c.status
 	if len(c.samples) > 0 {
 		st.CurrentSample = c.samples[len(c.samples)-1]
-		st.RecentSamples = c.samples
+		st.RecentSamples = append([]*LiveSample(nil), c.samples...)
 	}
 	return st
 }
 
-// Subscribe returns a channel of live telemetry samples
-func (c *BwTestClient) Subscribe() <-chan LiveSample {
-	return c.sampleCh
+// Subscribe gives each observer its own bounded queue. Slow observers never
+// block the data path; they can resynchronize using GetStatus.
+func (c *BwTestClient) Subscribe() (<-chan LiveSample, func()) {
+	c.mu.Lock()
+	ch := make(chan LiveSample, 16)
+	c.subscribers[ch] = struct{}{}
+	c.mu.Unlock()
+	var once sync.Once
+	return ch, func() { once.Do(func() { c.mu.Lock(); delete(c.subscribers, ch); close(ch); c.mu.Unlock() }) }
+}
+
+func (c *BwTestClient) recordSampleLocked(sample *LiveSample) {
+	c.sampleCount++
+	c.jitterSum += sample.JitterMs
+	c.peakTx = max(c.peakTx, sample.TxMbps)
+	c.peakRx = max(c.peakRx, sample.RxMbps)
+	c.samples = append(c.samples, sample)
+	if len(c.samples) > 200 {
+		c.samples = c.samples[len(c.samples)-200:]
+	}
+	c.status.ElapsedSec = sample.ElapsedSec
+	c.status.CurrentSample = sample
+	for ch := range c.subscribers {
+		select {
+		case ch <- *sample:
+		default:
+		}
+	}
 }
 
 // Stop terminates any currently active bandwidth test
@@ -87,20 +115,25 @@ func (c *BwTestClient) Run(ctx context.Context, req SessionRequest) error {
 	if req.PacketSize <= 0 {
 		req.PacketSize = 1472
 	}
+	if req.PacketSize < 64 || req.PacketSize > 65507 {
+		c.mu.Unlock()
+		return fmt.Errorf("packet size must be between 64 and 65507 bytes")
+	}
 	if req.Streams <= 0 {
 		req.Streams = 1
 	}
 	if req.Streams > 8 {
 		req.Streams = 8
 	}
-	if req.Duration <= 0 {
+	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)
+	c.samples = make([]*LiveSample, 0, 200)
+	c.sampleCount, c.jitterSum, c.peakTx, c.peakRx = 0, 0, 0, 0
 
 	sessionID := fmt.Sprintf("bw-%d-%d", time.Now().Unix(), rand.Intn(10000))
 	c.status = TestStatus{
@@ -142,12 +175,17 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 		return
 	}
 	defer controlConn.Close()
+	stopClose := context.AfterFunc(ctx, func() { controlConn.Close() })
+	defer stopClose()
+	_ = controlConn.SetDeadline(time.Now().Add(5 * time.Second))
 
 	// If UDP, bind a local UDP socket
 	var udpConn *net.UDPConn
 	var clientUDPPort int
 	if req.Protocol == "udp" {
-		uaddr, err := net.ResolveUDPAddr("udp", ":0")
+		local := controlConn.LocalAddr().(*net.TCPAddr)
+		uaddr := &net.UDPAddr{IP: local.IP, Zone: local.Zone}
+		var err error
 		if err == nil {
 			udpConn, err = net.ListenUDP("udp", uaddr)
 		}
@@ -164,6 +202,16 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 		}
 	}
 
+	if req.Protocol == "udp" {
+		size, err := udpPayloadSize(req.PacketSize, controlConn.RemoteAddr().(*net.TCPAddr).IP)
+		if err != nil {
+			c.mu.Lock()
+			c.status.Error = err.Error()
+			c.mu.Unlock()
+			return
+		}
+		req.PacketSize = size
+	}
 	// Send handshake request
 	hs := SessionHandshake{
 		Type:           "request",
@@ -176,6 +224,11 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 		Duration:       req.Duration,
 		UDPPort:        clientUDPPort,
 	}
+	if req.Protocol == "tcp" {
+		hs.DataFraming = tcpFramingVersion
+	} else {
+		hs.UDPFeedback = udpFeedbackVersion
+	}
 	b, _ := json.Marshal(hs)
 	if _, err := controlConn.Write(append(b, '\n')); err != nil {
 		c.mu.Lock()
@@ -202,6 +255,29 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 		return
 	}
 
+	if req.Protocol == "tcp" && acceptResp.DataFraming != tcpFramingVersion {
+		c.mu.Lock()
+		c.status.Error = "Server does not support framed TCP bandwidth tests; upgrade both peers"
+		c.mu.Unlock()
+		return
+	}
+	if req.Protocol == "udp" {
+		if req.BandwidthLimit <= 0 && acceptResp.UDPFeedback != udpFeedbackVersion {
+			c.mu.Lock()
+			c.status.Error = "Automatic UDP tests require receiver-feedback support on both peers"
+			c.mu.Unlock()
+			return
+		}
+		if acceptResp.PacketSize >= 64 {
+			req.PacketSize = min(req.PacketSize, acceptResp.PacketSize)
+		}
+		c.mu.Lock()
+		c.status.PacketSize = req.PacketSize
+		c.mu.Unlock()
+	}
+	_ = controlConn.SetDeadline(time.Time{})
+	tcpWriter := &tcpFrameWriter{conn: controlConn}
+
 	serverUDPPort := acceptResp.UDPPort
 	if serverUDPPort <= 0 {
 		serverUDPPort = req.Port
@@ -209,35 +285,55 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 
 	// Statistics counters
 	var (
-		localTxBytes     atomic.Int64
-		localTxPackets   atomic.Int64
-		localRxBytes     atomic.Int64
-		localRxPackets   atomic.Int64
-		localLostPackets atomic.Int64
-		serverRxBytes    atomic.Int64
-		serverRxPackets  atomic.Int64
+		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
+		rxTracker   udpTracker
 	)
 
 	sessionHash := HashSessionID(sessionID)
+	udpPacing := newUDPPacer(req.BandwidthLimit)
+	var serverUDPAddr *net.UDPAddr
 
-	// Background worker for server feedback reports over TCP control socket
+	// One reader owns the buffered connection, including bytes prefetched
+	// during the handshake. It dispatches data frames and feedback reports.
 	go func() {
+		buf := make([]byte, tcpFrameMax)
 		for {
-			line, err := reader.ReadBytes('\n')
-			if err != nil {
-				return
+			var line []byte
+			if req.Protocol == "tcp" {
+				kind, payload, err := readTCPFrame(reader, buf)
+				if err != nil {
+					return
+				}
+				if kind == tcpFrameData {
+					localRxBytes.Add(int64(len(payload)))
+					localRxPackets.Add(1)
+					continue
+				}
+				line = payload
+			} else {
+				var err error
+				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)
+				udpPacing.observe(rep.RxBytes, time.Now())
 			}
 		}
 	}()
@@ -246,7 +342,7 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 
 	// UDP Data path
 	if req.Protocol == "udp" {
-		serverUDPAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", req.Target, serverUDPPort))
+		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)
@@ -254,6 +350,13 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 			return
 		}
 
+		// RX-only also sends a probe, so the server learns the real UDP tuple.
+		if _, err := udpConn.WriteToUDP(udpProbe(sessionHash), serverUDPAddr); err != nil {
+			c.mu.Lock()
+			c.status.Error = err.Error()
+			c.mu.Unlock()
+			return
+		}
 		// Transmitter (TX or Both)
 		if req.Direction == "tx" || req.Direction == "both" {
 			for s := 0; s < req.Streams; s++ {
@@ -261,8 +364,11 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 				streamID := uint8(s)
 				go func() {
 					defer wg.Done()
-					limiter := NewRateLimiter(req.BandwidthLimit / int64(req.Streams))
+
 					buf := make([]byte, req.PacketSize)
+					if _, err := crand.Read(buf); err != nil {
+						return
+					}
 					var seq uint64
 
 					hdr := PacketHeader{
@@ -281,8 +387,8 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 						default:
 						}
 
-						if limiter != nil {
-							limiter.Wait(req.PacketSize)
+						if !udpPacing.wait(ctx, req.PacketSize) {
+							return
 						}
 
 						seq++
@@ -330,7 +436,12 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 					}
 
 					hdr, err := DecodeHeader(rxBuf[:n])
-					if err != nil || hdr.SessionHash != sessionHash {
+					if err != nil || hdr.SessionHash != sessionHash || hdr.Version != ProtoVer || hdr.PacketType != PacketTypeData || int(hdr.PayloadLength) != n-HeaderSize {
+						continue
+					}
+					accepted, lost := rxTracker.accept(hdr.StreamID, hdr.Sequence)
+					localLostPackets.Store(lost)
+					if !accepted {
 						continue
 					}
 
@@ -344,15 +455,6 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 					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)
-					}
 				}
 			}()
 		}
@@ -363,6 +465,9 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 			go func() {
 				defer wg.Done()
 				chunk := make([]byte, 64*1024)
+				if _, err := crand.Read(chunk); err != nil {
+					return
+				}
 				limiter := NewRateLimiter(req.BandwidthLimit)
 
 				for {
@@ -373,45 +478,28 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 					}
 
 					if limiter != nil {
-						limiter.Wait(len(chunk))
+						if !limiter.WaitContext(ctx, len(chunk)) {
+							return
+						}
 					}
 
-					n, err := controlConn.Write(chunk)
+					n, err := tcpWriter.write(tcpFrameData, chunk)
 					if err != nil {
 						return
 					}
 					localTxBytes.Add(int64(n))
+					localTxPackets.Add(1)
 				}
 			}()
 		}
 
-		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
+	var lastTxBytes, lastRxBytes, lastOfferedBytes int64
 	lastTime := time.Now()
 	startTime := lastTime
 
@@ -425,6 +513,9 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 	for {
 		select {
 		case <-ctx.Done():
+			if req.Protocol == "tcp" {
+				return
+			}
 			// Notify remote server to stop
 			stopMsg := SessionHandshake{Type: "stop", SessionID: sessionID}
 			sb, _ := json.Marshal(stopMsg)
@@ -432,6 +523,9 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 			return
 
 		case <-timeoutChan:
+			if req.Protocol == "tcp" {
+				return
+			}
 			stopMsg := SessionHandshake{Type: "stop", SessionID: sessionID}
 			sb, _ := json.Marshal(stopMsg)
 			_, _ = controlConn.Write(append(sb, '\n'))
@@ -448,17 +542,31 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 			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()
+			if req.Direction != "rx" {
+				curTxBytes = serverRxBytes.Load()
+				curLostPackets += serverLostPackets.Load()
+			}
+			if req.Direction != "tx" {
 				curRxBytes = localRxBytes.Load()
-				curLostPackets = localLostPackets.Load() + serverLostPackets.Load()
+				curLostPackets += localLostPackets.Load()
+			}
+			offered := localTxBytes.Load()
+			offeredMbps := float64((offered-lastOfferedBytes)*8) / intervalSec / 1e6
+			lastOfferedBytes = offered
+			if req.Protocol == "udp" && req.Direction != "tx" {
+				rep := SessionHandshake{Type: "report", SessionID: sessionID, RxBytes: localRxBytes.Load(), RxPackets: localRxPackets.Load(), LostPackets: localLostPackets.Load()}
+				data, _ := json.Marshal(rep)
+				_ = controlConn.SetWriteDeadline(time.Now().Add(2 * time.Second))
+				if _, err := controlConn.Write(append(data, '\n')); err != nil {
+					c.mu.Lock()
+					c.status.Error = fmt.Sprintf("Receiver feedback failed: %v", err)
+					c.mu.Unlock()
+					return
+				}
+				// Retry discovery while no reverse packet has arrived.
+				if localRxPackets.Load() == 0 {
+					_, _ = udpConn.WriteToUDP(udpProbe(sessionHash), serverUDPAddr)
+				}
 			}
 
 			deltaTx := curTxBytes - lastTxBytes
@@ -473,7 +581,13 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 			rxMbps := float64(rxBps) / 1e6
 
 			// Total packets & loss %
-			totalExpectedPkts := localRxPackets.Load() + localLostPackets.Load()
+			totalExpectedPkts := int64(0)
+			if req.Direction != "tx" {
+				totalExpectedPkts += localRxPackets.Load() + localLostPackets.Load()
+			}
+			if req.Direction != "rx" {
+				totalExpectedPkts += serverRxPackets.Load() + serverLostPackets.Load()
+			}
 			var lossPct float64
 			if totalExpectedPkts > 0 {
 				lossPct = (float64(curLostPackets) / float64(totalExpectedPkts)) * 100.0
@@ -485,13 +599,15 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 			jitter := float64(curJitterMs.Load()) / 1000.0
 
 			sample := &LiveSample{
+				PacketSize:    req.PacketSize,
+				OfferedTxMbps: offeredMbps, OfferedTxBytes: offered,
 				Timestamp:   now,
 				ElapsedSec:  elapsedSec,
 				TxBps:       txBps,
 				RxBps:       rxBps,
 				TxMbps:      txMbps,
 				RxMbps:      rxMbps,
-				TxPackets:   localTxPackets.Load(),
+				TxPackets:   serverRxPackets.Load(),
 				RxPackets:   localRxPackets.Load(),
 				TxBytes:     curTxBytes,
 				RxBytes:     curRxBytes,
@@ -501,19 +617,8 @@ func (c *BwTestClient) executeTest(ctx context.Context, cancel context.CancelFun
 			}
 
 			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.recordSampleLocked(sample)
 			c.mu.Unlock()
-
-			// Non-blocking publish to subscriber channel
-			select {
-			case c.sampleCh <- *sample:
-			default:
-			}
 		}
 	}
 }
@@ -523,32 +628,20 @@ func (c *BwTestClient) finalizeSummary() {
 		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
-		}
+	var avgTx, avgRx, avgLoss, avgJitter float64
+	if duration > 0 {
+		avgTx = float64(last.TxBytes) * 8 / duration / 1e6
+		avgRx = float64(last.RxBytes) * 8 / duration / 1e6
+	}
+	expected := last.TxPackets + last.RxPackets + last.LostPackets
+	if expected > 0 {
+		avgLoss = float64(last.LostPackets) * 100 / float64(expected)
+	}
+	if c.sampleCount > 0 {
+		avgJitter = c.jitterSum / float64(c.sampleCount)
 	}
-
-	count := float64(len(c.samples))
-	avgTx := sumTxMbps / count
-	avgRx := sumRxMbps / count
-	avgLoss := sumLossPct / count
-	avgJitter := sumJitter / count
 
 	c.status.Summary = &TestSummary{
 		DurationSec:      duration,
@@ -558,13 +651,12 @@ func (c *BwTestClient) finalizeSummary() {
 		TotalRxBytes:     last.RxBytes,
 		AvgTxMbps:        avgTx,
 		AvgRxMbps:        avgRx,
-		PeakTxMbps:       peakTxMbps,
-		PeakRxMbps:       peakRxMbps,
+		PeakTxMbps:       c.peakTx,
+		PeakRxMbps:       c.peakRx,
 		TotalTxPackets:   last.TxPackets,
 		TotalRxPackets:   last.RxPackets,
 		TotalLostPackets: last.LostPackets,
 		AvgLossPct:       avgLoss,
 		AvgJitterMs:      avgJitter,
 	}
-	_ = first
 }

+ 1 - 1
internal/bwtest/manager.go

@@ -149,7 +149,7 @@ func (m *Manager) GetTestStatus() TestStatus {
 }
 
 // SubscribeClient returns a stream of real-time telemetry events
-func (m *Manager) SubscribeClient() <-chan LiveSample {
+func (m *Manager) SubscribeClient() (<-chan LiveSample, func()) {
 	return m.client.Subscribe()
 }
 

+ 30 - 19
internal/bwtest/protocol.go

@@ -1,6 +1,7 @@
 package bwtest
 
 import (
+	"context"
 	"encoding/binary"
 	"errors"
 	"hash/fnv"
@@ -123,34 +124,44 @@ func NewRateLimiter(targetBps int64) *RateLimiter {
 	}
 }
 
-// Wait paces the sender goroutine to ensure bandwidth limit is respected
-func (rl *RateLimiter) Wait(bytes int) {
+// Wait preserves the original API; cancellable workers use WaitContext.
+func (rl *RateLimiter) Wait(bytes int) { rl.WaitContext(context.Background(), bytes) }
+
+// A frame can exceed the nominal 100ms bucket capacity. Grow that bucket to at
+// least one frame; otherwise low-rate TCP tests wait forever for unreachable tokens.
+func (rl *RateLimiter) WaitContext(ctx context.Context, bytes int) bool {
 	if rl == nil || rl.bps <= 0 {
-		return
+		return ctx.Err() == nil
 	}
-	neededTokens := float64(bytes)
-	bytesPerSec := float64(rl.bps) / 8.0
-
+	needed := float64(bytes)
+	if rl.maxTokens < needed {
+		rl.maxTokens = needed
+	}
+	rate := float64(rl.bps) / 8
 	for {
+		if ctx.Err() != nil {
+			return false
+		}
 		now := time.Now()
-		elapsed := now.Sub(rl.lastCheckTime).Seconds()
+		rl.tokens += now.Sub(rl.lastCheckTime).Seconds() * rate
 		rl.lastCheckTime = now
-
-		rl.tokens += elapsed * bytesPerSec
 		if rl.tokens > rl.maxTokens {
 			rl.tokens = rl.maxTokens
 		}
-
-		if rl.tokens >= neededTokens {
-			rl.tokens -= neededTokens
-			return
+		if rl.tokens >= needed {
+			rl.tokens -= needed
+			return true
 		}
-
-		deficit := neededTokens - rl.tokens
-		sleepSec := deficit / bytesPerSec
-		if sleepSec > 0.05 {
-			sleepSec = 0.05
+		wait := time.Duration((needed - rl.tokens) / rate * float64(time.Second))
+		if wait > 50*time.Millisecond {
+			wait = 50 * time.Millisecond
+		}
+		timer := time.NewTimer(wait)
+		select {
+		case <-ctx.Done():
+			timer.Stop()
+			return false
+		case <-timer.C:
 		}
-		time.Sleep(time.Duration(sleepSec * float64(time.Second)))
 	}
 }

+ 124 - 64
internal/bwtest/server.go

@@ -3,6 +3,7 @@ package bwtest
 import (
 	"bufio"
 	"context"
+	crand "crypto/rand"
 	"encoding/json"
 	"fmt"
 	"log"
@@ -18,8 +19,13 @@ type serverSession struct {
 	id          string
 	sessionHash uint64
 	clientTCP   net.Conn
+	tcpWriter   *tcpFrameWriter
 	clientAddr  net.IP
 	clientPort  int
+	peerReady   chan struct{}
+	peerOnce    sync.Once
+	udpPacing   *udpPacer
+	rxTracker   udpTracker
 	handshake   SessionHandshake
 	cancel      context.CancelFunc
 	ctx         context.Context
@@ -32,9 +38,6 @@ type serverSession struct {
 	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
 }
@@ -200,6 +203,7 @@ func (s *BwTestServer) acceptLoop(ctx context.Context) {
 
 func (s *BwTestServer) handleClientControl(serverCtx context.Context, conn net.Conn) {
 	defer conn.Close()
+	_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
 
 	remoteTCPAddr, _ := conn.RemoteAddr().(*net.TCPAddr)
 	clientIP := net.IPv4(127, 0, 0, 1)
@@ -225,6 +229,29 @@ func (s *BwTestServer) handleClientControl(serverCtx context.Context, conn net.C
 		return
 	}
 
+	if req.Protocol == "tcp" && req.DataFraming != tcpFramingVersion {
+		b, _ := json.Marshal(SessionHandshake{Type: "reject", Message: "TCP test requires framed protocol; upgrade both peers"})
+		_, _ = conn.Write(append(b, '\n'))
+		return
+	}
+	if req.Protocol == "udp" && req.BandwidthLimit <= 0 && req.UDPFeedback != udpFeedbackVersion {
+		b, _ := json.Marshal(SessionHandshake{Type: "reject", Message: "Automatic UDP requires receiver feedback; upgrade both peers"})
+		_, _ = conn.Write(append(b, '\n'))
+		return
+	}
+	if req.Protocol == "udp" {
+		if req.PacketSize <= 0 {
+			req.PacketSize = 1472
+		}
+		size, err := udpPayloadSize(req.PacketSize, clientIP)
+		if err != nil {
+			return
+		}
+		req.PacketSize = size
+	}
+
+	_ = conn.SetReadDeadline(time.Time{})
+
 	// Session parameter sanitization
 	if req.SessionID == "" {
 		req.SessionID = fmt.Sprintf("bw-%d-%d", time.Now().UnixNano(), rand.Intn(10000))
@@ -248,18 +275,26 @@ func (s *BwTestServer) handleClientControl(serverCtx context.Context, conn net.C
 		req.Duration = 10
 	}
 
-	sessCtx, sessCancel := context.WithCancel(serverCtx)
+	var sessCtx context.Context
+	var sessCancel context.CancelFunc
 	if req.Duration > 0 {
 		sessCtx, sessCancel = context.WithTimeout(serverCtx, time.Duration(req.Duration+2)*time.Second)
+	} else {
+		sessCtx, sessCancel = context.WithCancel(serverCtx)
 	}
 	defer sessCancel()
+	stopClose := context.AfterFunc(sessCtx, func() { conn.Close() })
+	defer stopClose()
 
 	sess := &serverSession{
 		id:          req.SessionID,
 		sessionHash: HashSessionID(req.SessionID),
 		clientTCP:   conn,
+		tcpWriter:   &tcpFrameWriter{conn: conn},
 		clientAddr:  clientIP,
 		clientPort:  req.UDPPort,
+		peerReady:   make(chan struct{}),
+		udpPacing:   newUDPPacer(req.BandwidthLimit),
 		handshake:   req,
 		ctx:         sessCtx,
 		cancel:      sessCancel,
@@ -290,6 +325,12 @@ func (s *BwTestServer) handleClientControl(serverCtx context.Context, conn net.C
 		Direction: req.Direction,
 		Message:   "Ready",
 	}
+	if req.Protocol == "tcp" {
+		acceptResp.DataFraming = tcpFramingVersion
+	} else {
+		acceptResp.UDPFeedback = udpFeedbackVersion
+		acceptResp.PacketSize = req.PacketSize
+	}
 	acceptBytes, _ := json.Marshal(acceptResp)
 	if _, err := conn.Write(append(acceptBytes, '\n')); err != nil {
 		return
@@ -305,26 +346,45 @@ func (s *BwTestServer) handleClientControl(serverCtx context.Context, conn net.C
 		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)
 
+	frameBuf := make([]byte, tcpFrameMax)
 	for {
-		line, err := reader.ReadBytes('\n')
-		if err != nil {
-			break
+		var line []byte
+		if req.Protocol == "tcp" {
+			kind, payload, err := readTCPFrame(reader, frameBuf)
+			if err != nil {
+				break
+			}
+			if kind == tcpFrameData {
+				sess.rxBytes.Add(int64(len(payload)))
+				sess.rxPackets.Add(1)
+				s.totalBytes.Add(int64(len(payload)))
+				continue
+			}
+			line = payload
+		} else {
+			var err error
+			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
 			}
+			if msg.Type == "report" && msg.SessionID == sess.id {
+				sess.udpPacing.observe(msg.RxBytes, time.Now())
+			}
 		}
 	}
+
 }
 
 // udpReceiverLoop processes incoming UDP test packets across all active sessions
@@ -373,11 +433,33 @@ func (s *BwTestServer) udpReceiverLoop(ctx context.Context) {
 			continue
 		}
 
-		// Update remote client UDP address for reverse transmission
-		if matched.clientPort == 0 || matched.clientAddr == nil {
-			matched.clientAddr = remoteAddr.IP
+		// Learn the actual source port even for RX-only clients behind NAT.
+		// Restrict discovery to the host that owns this TCP control session.
+		if !remoteAddr.IP.Equal(matched.clientAddr) {
+			continue
+		}
+		if hdr.Version != ProtoVer {
+			continue
+		}
+		if hdr.PacketType == udpProbeType {
+			matched.mu.Lock()
 			matched.clientPort = remoteAddr.Port
+			matched.mu.Unlock()
+			matched.peerOnce.Do(func() { close(matched.peerReady) })
+			continue
+		}
+		if hdr.PacketType != PacketTypeData || int(hdr.PayloadLength) != n-HeaderSize {
+			continue
+		}
+		accepted, lost := matched.rxTracker.accept(hdr.StreamID, hdr.Sequence)
+		matched.lostPackets.Store(lost)
+		if !accepted {
+			continue
 		}
+		matched.mu.Lock()
+		matched.clientPort = remoteAddr.Port
+		matched.mu.Unlock()
+		matched.peerOnce.Do(func() { close(matched.peerReady) })
 
 		nowNano := time.Now().UnixNano()
 		matched.rxBytes.Add(int64(n))
@@ -389,49 +471,36 @@ func (s *BwTestServer) udpReceiverLoop(ctx context.Context) {
 		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 sess.handshake.UDPFeedback == udpFeedbackVersion {
+		select {
+		case <-sess.peerReady:
+		case <-ctx.Done():
+			return
 		}
 	}
-	if targetPort == 0 {
-		targetPort = s.config.Port
-	}
-
-	dstUDPAddr := &net.UDPAddr{
-		IP:   sess.clientAddr,
-		Port: targetPort,
+	sess.mu.Lock()
+	port := sess.clientPort
+	sess.mu.Unlock()
+	if port <= 0 {
+		return
 	}
+	dstUDPAddr := &net.UDPAddr{IP: sess.clientAddr, Port: port}
 
 	pktSize := sess.handshake.PacketSize
 	if pktSize < HeaderSize {
 		pktSize = HeaderSize
 	}
 	buf := make([]byte, pktSize)
+	if _, err := crand.Read(buf); err != nil {
+		return
+	}
 
-	limiter := NewRateLimiter(sess.handshake.BandwidthLimit)
 	var seq uint64
 
 	hdr := PacketHeader{
@@ -451,8 +520,8 @@ func (s *BwTestServer) runServerUDPTx(ctx context.Context, sess *serverSession)
 		default:
 		}
 
-		if limiter != nil {
-			limiter.Wait(pktSize)
+		if !sess.udpPacing.wait(ctx, pktSize) {
+			return
 		}
 
 		seq++
@@ -472,6 +541,9 @@ func (s *BwTestServer) runServerUDPTx(ctx context.Context, sess *serverSession)
 // runServerTCPTx generates continuous TCP stream data to client
 func (s *BwTestServer) runServerTCPTx(ctx context.Context, sess *serverSession) {
 	chunk := make([]byte, 64*1024)
+	if _, err := crand.Read(chunk); err != nil {
+		return
+	}
 	limiter := NewRateLimiter(sess.handshake.BandwidthLimit)
 
 	for {
@@ -482,33 +554,17 @@ func (s *BwTestServer) runServerTCPTx(ctx context.Context, sess *serverSession)
 		}
 
 		if limiter != nil {
-			limiter.Wait(len(chunk))
+			if !limiter.WaitContext(ctx, len(chunk)) {
+				return
+			}
 		}
 
-		n, err := sess.clientTCP.Write(chunk)
+		n, err := sess.tcpWriter.write(tcpFrameData, 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))
+		sess.txPackets.Add(1)
 		s.totalBytes.Add(int64(n))
 	}
 }
@@ -532,7 +588,11 @@ func (s *BwTestServer) reportLoop(ctx context.Context, sess *serverSession) {
 			}
 			b, err := json.Marshal(report)
 			if err == nil {
-				_, _ = sess.clientTCP.Write(append(b, '\n'))
+				if sess.handshake.Protocol == "tcp" {
+					_, _ = sess.tcpWriter.write(tcpFrameControl, b)
+				} else {
+					_, _ = sess.clientTCP.Write(append(b, '\n'))
+				}
 			}
 		}
 	}

+ 52 - 0
internal/bwtest/tcp_frames.go

@@ -0,0 +1,52 @@
+package bwtest
+
+import (
+	"encoding/binary"
+	"fmt"
+	"io"
+	"net"
+	"sync"
+)
+
+// A single framed stream multiplexes data and reports without competing readers
+// or interleaved writes. The newline JSON handshake negotiates this format.
+const tcpFramingVersion = "frames-v1"
+const (
+	tcpFrameData    byte = 1
+	tcpFrameControl byte = 2
+	tcpFrameMax          = 64 * 1024
+)
+
+type tcpFrameWriter struct {
+	conn net.Conn
+	mu   sync.Mutex
+}
+
+func (w *tcpFrameWriter) write(kind byte, payload []byte) (int, error) {
+	if len(payload) == 0 || len(payload) > tcpFrameMax {
+		return 0, fmt.Errorf("invalid TCP bandwidth frame size")
+	}
+	w.mu.Lock()
+	defer w.mu.Unlock()
+	var h [5]byte
+	h[0] = kind
+	binary.BigEndian.PutUint32(h[1:], uint32(len(payload)))
+	buffers := net.Buffers{h[:], payload}
+	n, err := buffers.WriteTo(w.conn)
+	if n < 5 {
+		return 0, err
+	}
+	return int(n) - 5, err
+}
+func readTCPFrame(r io.Reader, buf []byte) (byte, []byte, error) {
+	var h [5]byte
+	if _, err := io.ReadFull(r, h[:]); err != nil {
+		return 0, nil, err
+	}
+	n := int(binary.BigEndian.Uint32(h[1:]))
+	if (h[0] != tcpFrameData && h[0] != tcpFrameControl) || n == 0 || n > tcpFrameMax || n > len(buf) {
+		return 0, nil, fmt.Errorf("invalid TCP bandwidth frame")
+	}
+	_, err := io.ReadFull(r, buf[:n])
+	return h[0], buf[:n], err
+}

+ 149 - 0
internal/bwtest/tcp_frames_test.go

@@ -0,0 +1,149 @@
+package bwtest
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"encoding/binary"
+	"encoding/json"
+	"net"
+	"sync"
+	"testing"
+	"time"
+)
+
+func TestConcurrentTCPFramesRemainDistinct(t *testing.T) {
+	a, b := net.Pipe()
+	defer a.Close()
+	defer b.Close()
+	w := tcpFrameWriter{conn: a}
+	var wg sync.WaitGroup
+	for _, kind := range []byte{tcpFrameData, tcpFrameControl} {
+		wg.Add(1)
+		go func(kind byte) {
+			defer wg.Done()
+			for range 20 {
+				if _, err := w.write(kind, bytes.Repeat([]byte{kind}, 1024)); err != nil {
+					return
+				}
+			}
+		}(kind)
+	}
+	counts := map[byte]int{}
+	buf := make([]byte, tcpFrameMax)
+	_ = b.SetReadDeadline(time.Now().Add(3 * time.Second))
+	for range 40 {
+		kind, payload, err := readTCPFrame(b, buf)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !bytes.Equal(payload, bytes.Repeat([]byte{kind}, 1024)) {
+			t.Fatal("interleaved control and data")
+		}
+		counts[kind]++
+	}
+	wg.Wait()
+	if counts[tcpFrameData] != 20 || counts[tcpFrameControl] != 20 {
+		t.Fatal(counts)
+	}
+}
+func TestTCPFrameBounds(t *testing.T) {
+	for _, n := range []uint32{0, tcpFrameMax + 1} {
+		h := make([]byte, 5)
+		h[0] = tcpFrameData
+		binary.BigEndian.PutUint32(h[1:], n)
+		if _, _, err := readTCPFrame(bytes.NewReader(h), make([]byte, tcpFrameMax)); err == nil {
+			t.Fatal("invalid frame length accepted")
+		}
+	}
+}
+func testServer(t *testing.T) *BwTestServer {
+	t.Helper()
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	port := ln.Addr().(*net.TCPAddr).Port
+	ln.Close()
+	s := NewServer(ServerConfig{Enabled: true, ListenAddr: "127.0.0.1", Port: port})
+	if err = s.Start(); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(s.Stop)
+	return s
+}
+func TestBandwidthTCPAllDirections(t *testing.T) {
+	s := testServer(t)
+	for _, direction := range []string{"tx", "rx", "both"} {
+		t.Run(direction, func(t *testing.T) {
+			c := NewClient()
+			t.Cleanup(c.Stop)
+			err := c.Run(context.Background(), SessionRequest{Target: "127.0.0.1", Port: s.config.Port, Protocol: "tcp", Direction: direction, BandwidthLimit: 2_000_000, Duration: 2})
+			if err != nil {
+				t.Fatal(err)
+			}
+			deadline := time.Now().Add(5 * time.Second)
+			for c.GetStatus().Running && time.Now().Before(deadline) {
+				time.Sleep(10 * time.Millisecond)
+			}
+			st := c.GetStatus()
+			if st.Running || st.Error != "" || st.Summary == nil {
+				t.Fatalf("test did not finish: %+v", st)
+			}
+			if direction != "rx" && st.Summary.TotalTxBytes < 65536 {
+				t.Fatalf("no transmitted data: %+v", st.Summary)
+			}
+			if direction != "tx" && st.Summary.TotalRxBytes < 65536 {
+				t.Fatalf("no received data/reports: %+v", st.Summary)
+			}
+		})
+	}
+}
+func TestBandwidthStopInterruptsThrottledWriter(t *testing.T) {
+	s := testServer(t)
+	c := NewClient()
+	if err := c.Run(context.Background(), SessionRequest{Target: "127.0.0.1", Port: s.config.Port, Protocol: "tcp", Direction: "both", BandwidthLimit: 1000, Duration: 30}); err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(150 * time.Millisecond)
+	c.Stop()
+	deadline := time.Now().Add(time.Second)
+	for c.GetStatus().Running && time.Now().Before(deadline) {
+		time.Sleep(10 * time.Millisecond)
+	}
+	if c.GetStatus().Running {
+		t.Fatal("Stop blocked behind rate limiter")
+	}
+}
+func TestBandwidthRejectsUnframedTCP(t *testing.T) {
+	s := testServer(t)
+	conn, err := net.Dial("tcp", s.tcpListener.Addr().String())
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer conn.Close()
+	_ = conn.SetDeadline(time.Now().Add(time.Second))
+	b, _ := json.Marshal(SessionHandshake{Type: "request", Protocol: "tcp"})
+	conn.Write(append(b, '\n'))
+	line, err := bufio.NewReader(conn).ReadBytes('\n')
+	if err != nil {
+		t.Fatal(err)
+	}
+	var response SessionHandshake
+	if err = json.Unmarshal(line, &response); err != nil || response.Type != "reject" {
+		t.Fatalf("legacy TCP silently accepted: %s %v", line, err)
+	}
+}
+func TestRateLimiterCanSendFrameLargerThanBucket(t *testing.T) {
+	limiter := NewRateLimiter(4_000_000)
+	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+	defer cancel()
+	if !limiter.WaitContext(ctx, 65536) {
+		t.Fatal("64KB frame can never fit nominal 50KB bucket")
+	}
+	ctx2, cancel2 := context.WithCancel(context.Background())
+	cancel2()
+	if limiter.WaitContext(ctx2, 65536) {
+		t.Fatal("ignored cancellation")
+	}
+}

+ 96 - 0
internal/bwtest/telemetry_test.go

@@ -0,0 +1,96 @@
+package bwtest
+
+import (
+	"context"
+	"net"
+	"testing"
+	"time"
+)
+
+func TestSummaryCoversSamplesBeyondRetentionWindow(t *testing.T) {
+	c := NewClient()
+	for i := 1; i <= 300; i++ {
+		rate := 1.0
+		if i == 1 {
+			rate = 50
+		}
+		c.recordSampleLocked(&LiveSample{ElapsedSec: float64(i), TxBytes: int64(i) * 125000, RxBytes: int64(i) * 250000, TxMbps: rate, RxMbps: 2, JitterMs: float64(i), TxPackets: int64(i) * 10, RxPackets: int64(i) * 10, LostPackets: int64(i)})
+	}
+	if len(c.samples) != 200 {
+		t.Fatal("unbounded history")
+	}
+	c.finalizeSummary()
+	s := c.status.Summary
+	if s.AvgTxMbps != 1 || s.AvgRxMbps != 2 || s.PeakTxMbps != 50 || s.AvgJitterMs != 150.5 {
+		t.Fatalf("summary lost early samples: %+v", s)
+	}
+	if s.AvgLossPct < 4.76 || s.AvgLossPct > 4.77 {
+		t.Fatal("loss not computed from full totals")
+	}
+}
+
+func TestObserversReceiveBroadcastAndCanUnsubscribe(t *testing.T) {
+	c := NewClient()
+	a, stopA := c.Subscribe()
+	b, stopB := c.Subscribe()
+	defer stopB()
+	c.recordSampleLocked(&LiveSample{ElapsedSec: 1})
+	if (<-a).ElapsedSec != 1 || (<-b).ElapsedSec != 1 {
+		t.Fatal("observers competed for samples")
+	}
+	stopA()
+	stopA()
+	if _, ok := <-a; ok {
+		t.Fatal("subscription was not closed")
+	}
+	c.recordSampleLocked(&LiveSample{ElapsedSec: 2})
+	if (<-b).ElapsedSec != 2 {
+		t.Fatal("other observer stopped")
+	}
+}
+
+func TestZeroDurationRemainsContinuous(t *testing.T) {
+	// A closed local port makes the background connection end immediately; this
+	// checks normalization without sending traffic or waiting ten seconds.
+	l, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	port := l.Addr().(*net.TCPAddr).Port
+	l.Close()
+	c := NewClient()
+	if err := c.Run(context.Background(), SessionRequest{Target: "127.0.0.1", Port: port, Duration: 0}); err != nil {
+		t.Fatal(err)
+	}
+	defer c.Stop()
+	if c.GetStatus().Duration != 0 {
+		t.Fatal("continuous duration was replaced")
+	}
+}
+
+func TestContinuousSessionSurvivesTenSecondsAndStops(t *testing.T) {
+	if testing.Short() {
+		t.Skip("continuous session integration test")
+	}
+	s := testServer(t)
+	c := NewClient()
+	defer c.Stop()
+	if err := c.Run(context.Background(), SessionRequest{Target: "127.0.0.1", Port: s.config.Port, Protocol: "udp", Direction: "both", Duration: 0, BandwidthLimit: 100000}); err != nil {
+		t.Fatal(err)
+	}
+	timer := time.NewTimer(11 * time.Second)
+	defer timer.Stop()
+	<-timer.C
+	if st := c.GetStatus(); !st.Running || st.Error != "" || st.ElapsedSec < 10 {
+		t.Fatalf("continuous session ended: %+v", st)
+	}
+	c.Stop()
+	deadline := time.Now().Add(2 * time.Second)
+	for c.GetStatus().Running && time.Now().Before(deadline) {
+		time.Sleep(10 * time.Millisecond)
+	}
+	st := c.GetStatus()
+	if st.Running || st.Summary == nil || st.Summary.TotalTxBytes == 0 || st.Summary.TotalRxBytes == 0 {
+		t.Fatal("continuous session failed to stop or exchange data")
+	}
+}

+ 21 - 16
internal/bwtest/types.go

@@ -18,7 +18,7 @@ type SessionRequest struct {
 	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)
+	BandwidthLimit int64  `json:"bandwidth_limit"` // Target bits/sec (0 = automatic receiver-feedback pacing for UDP)
 	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)
@@ -26,7 +26,9 @@ type SessionRequest struct {
 
 // SessionHandshake defines the control plane message exchanged between client and server
 type SessionHandshake struct {
-	Type           string  `json:"type"`            // "request", "accept", "reject", "report", "stop"
+	UDPFeedback    string  `json:"udp_feedback,omitempty"`
+	DataFraming    string  `json:"data_framing,omitempty"`
+	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"
@@ -44,20 +46,23 @@ type SessionHandshake struct {
 
 // 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"`
+	PacketSize     int       `json:"packet_size"`
+	OfferedTxMbps  float64   `json:"offered_tx_mbps"`
+	OfferedTxBytes int64     `json:"offered_tx_bytes"`
+	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

+ 162 - 0
internal/bwtest/udp_control.go

@@ -0,0 +1,162 @@
+package bwtest
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"sync"
+	"time"
+
+	"github.com/vishvananda/netlink"
+)
+
+const udpFeedbackVersion = "receiver-v1"
+const udpProbeType uint8 = 4
+
+// UDP without a requested limit probes capacity using receiver feedback instead
+// of enqueuing an unbounded amount of traffic ahead of the reverse direction.
+type udpPacer struct {
+	mu                                sync.Mutex
+	limiter                           *RateLimiter
+	automatic                         bool
+	rate                              int64
+	started, lastFeedback, lastChange time.Time
+	delivered                         int64
+}
+
+func newUDPPacer(limit int64) *udpPacer {
+	now := time.Now()
+	automatic := limit <= 0
+	if automatic {
+		limit = 2_000_000
+	}
+	return &udpPacer{limiter: NewRateLimiter(limit), automatic: automatic, rate: limit, started: now, lastFeedback: now, lastChange: now}
+}
+func (p *udpPacer) wait(ctx context.Context, n int) bool {
+	// Take only a small token-accounting lock; feedback and other streams can
+	// update the rate while this stream waits for its next datagram.
+	for {
+		p.mu.Lock()
+		if p.automatic && time.Since(p.lastFeedback) > 2*time.Second && time.Since(p.lastChange) > time.Second {
+			p.setRate(max(int64(128000), p.rate/2))
+			p.lastChange = time.Now()
+		}
+		rl := p.limiter
+		now := time.Now()
+		rl.tokens += now.Sub(rl.lastCheckTime).Seconds() * float64(p.rate) / 8
+		rl.lastCheckTime = now
+		cap := max(float64(n), float64(p.rate)/8*.02)
+		if rl.tokens > cap {
+			rl.tokens = cap
+		}
+		if rl.tokens >= float64(n) {
+			rl.tokens -= float64(n)
+			p.mu.Unlock()
+			return ctx.Err() == nil
+		}
+		delay := time.Duration((float64(n) - rl.tokens) / (float64(p.rate) / 8) * float64(time.Second))
+		p.mu.Unlock()
+		if delay > 20*time.Millisecond {
+			delay = 20 * time.Millisecond
+		}
+		timer := time.NewTimer(delay)
+		select {
+		case <-ctx.Done():
+			timer.Stop()
+			return false
+		case <-timer.C:
+		}
+	}
+}
+func (p *udpPacer) setRate(rate int64) { p.rate = rate; p.limiter.bps = rate }
+func (p *udpPacer) observe(bytes int64, now time.Time) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	dt := now.Sub(p.lastFeedback).Seconds()
+	delta := bytes - p.delivered
+	if dt <= 0 || delta < 0 {
+		return
+	}
+	p.lastFeedback = now
+	p.delivered = bytes
+	if !p.automatic || now.Sub(p.started) < time.Second || now.Sub(p.lastChange) < 450*time.Millisecond {
+		return
+	}
+	deliveredRate := int64(float64(delta) * 8 / dt)
+	switch {
+	case deliveredRate < p.rate*7/10:
+		p.setRate(max(int64(128000), deliveredRate*11/10))
+	case deliveredRate >= p.rate*85/100:
+		p.setRate(min(int64(10_000_000_000), p.rate*5/4))
+	}
+	p.lastChange = now
+}
+func udpPayloadSize(requested int, ip net.IP) (int, error) {
+	routes, err := netlink.RouteGet(ip)
+	if err != nil {
+		return 0, fmt.Errorf("bandwidth target route: %w", err)
+	}
+	if len(routes) == 0 {
+		return 0, fmt.Errorf("no route to bandwidth target")
+	}
+	link, err := netlink.LinkByIndex(routes[0].LinkIndex)
+	if err != nil {
+		return 0, err
+	}
+	mtu := link.Attrs().MTU
+	if routes[0].MTU > 0 {
+		mtu = min(mtu, routes[0].MTU)
+	}
+	overhead := 28
+	if ip.To4() == nil {
+		overhead = 48
+	}
+	size := min(requested, mtu-overhead)
+	if size < 64 {
+		return 0, fmt.Errorf("target MTU is too small for bandwidth datagrams")
+	}
+	return size, nil
+}
+func udpProbe(hash uint64) []byte {
+	b := make([]byte, HeaderSize)
+	EncodeHeader(b, PacketHeader{Magic: MagicBytes, Version: ProtoVer, PacketType: udpProbeType, SessionHash: hash})
+	return b
+}
+
+// Each UDP stream has its own sequence space. Late packets within the bounded
+// reorder window undo inferred losses, and duplicate datagrams are not counted.
+type udpSequenceState struct {
+	max  uint64
+	seen [1024]uint64
+}
+type udpTracker struct {
+	streams map[uint8]*udpSequenceState
+	lost    int64
+}
+
+func (t *udpTracker) accept(stream uint8, seq uint64) (bool, int64) {
+	if seq == 0 {
+		return false, t.lost
+	}
+	if t.streams == nil {
+		t.streams = make(map[uint8]*udpSequenceState)
+	}
+	s := t.streams[stream]
+	if s == nil {
+		s = &udpSequenceState{}
+		t.streams[stream] = s
+	}
+	if seq > s.max {
+		t.lost += int64(seq - s.max - 1)
+		s.max = seq
+	} else {
+		if s.max-seq >= 1024 || s.seen[seq%1024] == seq {
+			return false, t.lost
+		}
+		if t.lost > 0 {
+			t.lost--
+		}
+	}
+	s.seen[seq%1024] = seq
+	return true, t.lost
+}

+ 86 - 0
internal/bwtest/udp_control_test.go

@@ -0,0 +1,86 @@
+package bwtest
+
+import (
+	"context"
+	"testing"
+	"time"
+)
+
+func TestUDPPacerUsesDeliveryFeedback(t *testing.T) {
+	p := newUDPPacer(0)
+	initial := p.rate
+	now := p.started.Add(2 * time.Second)
+	p.observe(10000, now)
+	if p.rate >= initial {
+		t.Fatal("automatic rate did not fall after severe loss")
+	}
+	lower := p.rate
+	p.observe(10000+lower/8, now.Add(time.Second))
+	if p.rate <= lower {
+		t.Fatal("automatic rate did not grow after successful delivery")
+	}
+	fixed := newUDPPacer(1234567)
+	fixed.observe(0, fixed.started.Add(2*time.Second))
+	if fixed.rate != 1234567 {
+		t.Fatal("receiver feedback changed explicit rate limit")
+	}
+}
+func TestUDPPacerCancellation(t *testing.T) {
+	p := newUDPPacer(1)
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
+	defer cancel()
+	if p.wait(ctx, 1472) {
+		t.Fatal("rate limiter ignored cancellation")
+	}
+}
+func TestUDPTrackerStreamsReorderingDuplicates(t *testing.T) {
+	var tracker udpTracker
+	for _, v := range []struct {
+		stream uint8
+		seq    uint64
+		lost   int64
+	}{{0, 1, 0}, {1, 1, 0}, {0, 3, 1}, {1, 2, 1}, {0, 2, 0}, {0, 4, 0}} {
+		ok, lost := tracker.accept(v.stream, v.seq)
+		if !ok || lost != v.lost {
+			t.Fatalf("stream %d seq %d: accepted=%v lost=%d want=%d", v.stream, v.seq, ok, lost, v.lost)
+		}
+	}
+	if ok, lost := tracker.accept(0, 3); ok || lost != 0 {
+		t.Fatal("duplicate counted as data or loss")
+	}
+}
+func TestBandwidthUDPDirectionsAndAutomaticPacing(t *testing.T) {
+	s := testServer(t)
+	for _, direction := range []string{"rx", "both", "tx"} {
+		t.Run(direction, func(t *testing.T) {
+			c := NewClient()
+			t.Cleanup(c.Stop)
+			if err := c.Run(context.Background(), SessionRequest{Target: "127.0.0.1", Port: s.config.Port, Protocol: "udp", Direction: direction, Streams: 4, PacketSize: 1472, Duration: 2}); err != nil {
+				t.Fatal(err)
+			}
+			deadline := time.Now().Add(5 * time.Second)
+			for c.GetStatus().Running && time.Now().Before(deadline) {
+				time.Sleep(10 * time.Millisecond)
+			}
+			st := c.GetStatus()
+			if st.Running || st.Error != "" || st.Summary == nil {
+				t.Fatalf("UDP test failed: %+v", st)
+			}
+			if direction != "rx" && st.Summary.TotalTxBytes < 100000 {
+				t.Fatalf("no confirmed TX: %+v", st.Summary)
+			}
+			if direction != "tx" && st.Summary.TotalRxBytes < 100000 {
+				t.Fatalf("no reverse UDP: %+v", st.Summary)
+			}
+			if st.Summary.AvgLossPct != 0 {
+				t.Fatalf("streams or reordered packets falsely counted as loss: %+v", st.Summary)
+			}
+			if st.Summary.AvgTxMbps > 20 || st.Summary.AvgRxMbps > 20 {
+				t.Fatal("automatic UDP started with an unbounded flood")
+			}
+			if direction == "tx" && st.Summary.TotalRxBytes != 0 {
+				t.Fatal("TX delivery reports mislabeled as downloaded data")
+			}
+		})
+	}
+}

+ 180 - 0
internal/cli/aerotun.go

@@ -0,0 +1,180 @@
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"proxmox-router/internal/aerotun"
+	"proxmox-router/internal/auth"
+)
+
+func (e *Executor) handleInterfaceAeroTUN(parts []string, user *auth.PublicUser) string {
+	if e.vpnMgr == nil || e.vpnMgr.AeroTUN == nil {
+		return ColorRed + "AeroTUN subsystem not available.\n" + ColorReset
+	}
+	mgr := e.vpnMgr.AeroTUN
+	action := "print"
+	if len(parts) > 0 {
+		action = strings.ToLower(parts[0])
+		parts = parts[1:]
+	}
+	pos, params := parseArgs(parts)
+	if action == "print" || action == "show" || action == "pr" {
+		list := mgr.List()
+		for i := range list {
+			list[i] = list[i].PublicView()
+		}
+		if params["detail"] == "yes" {
+			data, _ := json.MarshalIndent(list, "", "  ")
+			return string(data) + "\n"
+		}
+		tbl := NewTable("NAME", "VERSION", "STATE", "BACKEND", "LOCAL", "PEER", "ERROR")
+		for _, inst := range list {
+			tbl.AddRow(inst.Name, strconv.Itoa(inst.Protocol), aeroTUNState(inst), inst.Backend, inst.LocalAddress, inst.PeerAddress, inst.LastError)
+		}
+		return tbl.String()
+	}
+	if user == nil || user.Role != auth.RoleFull {
+		return ColorRed + "Permission denied: admin role required to manage AeroTUN\n" + ColorReset
+	}
+	if action == "genkey" {
+		key, err := aerotun.GenerateSecureKey()
+		if err != nil {
+			return cliError(err)
+		}
+		return key + "\n"
+	}
+	if action != "add" && action != "set" && action != "remove" && action != "enable" && action != "disable" && action != "start" && action != "stop" {
+		return ColorRed + "Usage: /interface aerotun [print [detail=yes]|add|set <name>|remove <name>|enable <name>|disable <name>|start <name>|stop <name>|genkey]\n" + ColorReset
+	}
+	inst := aerotun.TunnelInstance{Protocol: 3, Mode: "server", Disabled: true, MTU: aerotun.DefaultMTU}
+	if action != "add" {
+		if len(pos) != 1 {
+			return cliError(fmt.Errorf("%s requires one tunnel name or ID", action))
+		}
+		found := false
+		for _, candidate := range mgr.List() {
+			if candidate.Name == pos[0] || candidate.ID == pos[0] {
+				inst, found = candidate, true
+				break
+			}
+		}
+		if !found {
+			return cliError(fmt.Errorf("AeroTUN tunnel not found: %s", pos[0]))
+		}
+	}
+	var err error
+	switch action {
+	case "remove":
+		err = mgr.Delete(inst.ID)
+	case "enable":
+		err = mgr.ToggleState(inst.ID, false)
+	case "disable":
+		err = mgr.ToggleState(inst.ID, true)
+	case "start":
+		err = mgr.StartTunnel(inst.ID)
+	case "stop":
+		err = mgr.StopTunnel(inst.ID)
+	case "add", "set":
+		for key, value := range params {
+			switch key {
+			case "name":
+				inst.Name = value
+			case "mode":
+				inst.Mode = value
+			case "endpoint":
+				inst.RemoteEndpoint = value
+			case "local-address":
+				inst.LocalAddress = value
+			case "peer-address":
+				inst.PeerAddress = value
+			case "peer-public-key":
+				inst.PeerPublicKey = value
+			case "psk":
+				inst.PSK = value
+			case "comment":
+				inst.Comment = value
+			case "allowed-ips":
+				inst.AllowedIPs = nil
+				for _, prefix := range strings.Split(value, ",") {
+					if s := strings.TrimSpace(prefix); s != "" {
+						inst.AllowedIPs = append(inst.AllowedIPs, s)
+					}
+				}
+			case "protocol", "listen-port", "mtu":
+				var number int
+				number, err = strconv.Atoi(value)
+				if err == nil {
+					switch key {
+					case "protocol":
+						inst.Protocol = number
+					case "listen-port":
+						inst.ListenPort = number
+					case "mtu":
+						inst.MTU = number
+					}
+				}
+			case "disabled", "compression", "default-route":
+				var enabled bool
+				switch value {
+				case "yes", "true":
+					enabled = true
+				case "no", "false":
+					enabled = false
+				default:
+					err = fmt.Errorf("%s must be yes or no", key)
+				}
+				if err == nil {
+					switch key {
+					case "disabled":
+						inst.Disabled = enabled
+					case "compression":
+						inst.Compression = enabled
+					case "default-route":
+						inst.DefaultRoute = enabled
+					}
+				}
+			default:
+				err = fmt.Errorf("unknown AeroTUN parameter: %s", key)
+			}
+			if err != nil {
+				return cliError(err)
+			}
+		}
+		if action == "add" {
+			if inst.Name == "" {
+				return cliError(fmt.Errorf("name is required"))
+			}
+			if inst.PSK == "" {
+				return cliError(fmt.Errorf("psk is required; use /interface aerotun genkey and share it with the peer"))
+			}
+		}
+		inst, err = mgr.Save(inst)
+	}
+	if err != nil {
+		return cliError(err)
+	}
+	if current, ok := mgr.Get(inst.ID); ok {
+		return ColorGreen + fmt.Sprintf("AeroTUN %s: %s\nPublic key: %s\n", current.Name, aeroTUNState(current), current.PublicKey) + ColorReset
+	}
+	return ColorGreen + "AeroTUN tunnel removed.\n" + ColorReset
+}
+
+func cliError(err error) string { return ColorRed + "Error: " + err.Error() + "\n" + ColorReset }
+
+func aeroTUNState(inst aerotun.TunnelInstance) string {
+	switch {
+	case inst.LastError != "":
+		return "failed"
+	case inst.Connected:
+		return "connected"
+	case inst.Running:
+		return "connecting"
+	case inst.Disabled:
+		return "saved/disabled"
+	default:
+		return "stopped"
+	}
+}

+ 58 - 0
internal/cli/command.go

@@ -0,0 +1,58 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+	"unicode"
+)
+
+// splitCommand supports quoted arguments without invoking a system shell.
+// Backslashes outside quotes remain literal, preserving passwords and keys.
+func splitCommand(input string) ([]string, error) {
+	var args []string
+	var word strings.Builder
+	var quote rune
+	started := false
+	runes := []rune(input)
+	for i := 0; i < len(runes); i++ {
+		ch := runes[i]
+		if quote != 0 {
+			if ch == quote {
+				quote = 0
+			} else if ch == '\\' && quote == '"' && i+1 < len(runes) && (runes[i+1] == '"' || runes[i+1] == '\\') {
+				i++
+				word.WriteRune(runes[i])
+			} else {
+				word.WriteRune(ch)
+			}
+		} else if ch == '\'' || ch == '"' {
+			quote = ch
+			started = true
+		} else if unicode.IsSpace(ch) {
+			if started {
+				args = append(args, word.String())
+				word.Reset()
+				started = false
+			}
+		} else {
+			word.WriteRune(ch)
+			started = true
+		}
+	}
+	if quote != 0 {
+		return nil, fmt.Errorf("unterminated quote")
+	}
+	if started {
+		args = append(args, word.String())
+	}
+	return args, nil
+}
+
+// Existing command handlers mark failures red. Keep interactive rendering while
+// supplying an exit status for SSH automation.
+func commandExitStatus(output string) uint32 {
+	if strings.HasPrefix(strings.TrimSpace(output), ColorRed) {
+		return 1
+	}
+	return 0
+}

+ 83 - 0
internal/cli/command_test.go

@@ -0,0 +1,83 @@
+package cli
+
+import (
+	"path/filepath"
+	"reflect"
+	"strings"
+	"testing"
+
+	"proxmox-router/internal/aerotun"
+	"proxmox-router/internal/auth"
+	"proxmox-router/internal/vpn"
+)
+
+func TestQuotedCommandArguments(t *testing.T) {
+	got, err := splitCommand(`/interface aerotun set test comment="office uplink" psk='literal $\\key' name=""`)
+	want := []string{"/interface", "aerotun", "set", "test", "comment=office uplink", `psk=literal $\\key`, "name="}
+	if err != nil || !reflect.DeepEqual(got, want) {
+		t.Fatalf("%q %v", got, err)
+	}
+	if _, err := splitCommand(`comment="unfinished`); err == nil {
+		t.Fatal("accepted unterminated quote")
+	}
+}
+
+func TestBareLogAndHandlerRecovery(t *testing.T) {
+	e := &Executor{}
+	user := &auth.PublicUser{Role: auth.RoleFull}
+	output, _ := e.Execute("/log", user)
+	if commandExitStatus(output) != 0 {
+		t.Fatal(output)
+	}
+	output, _ = e.Execute("/interface print", user) // absent manager simulates a handler failure
+	if commandExitStatus(output) == 0 || !strings.Contains(output, "still running") {
+		t.Fatal(output)
+	}
+	output, _ = e.Execute("nonsense", user)
+	if commandExitStatus(output) == 0 {
+		t.Fatal("unknown command returned success")
+	}
+	output, _ = e.Execute("/help", user)
+	if commandExitStatus(output) != 0 {
+		t.Fatal("help returned failure")
+	}
+}
+
+func TestAeroTUNCLIConfigurationAndPermissions(t *testing.T) {
+	m := aerotun.NewManagerWithPath(filepath.Join(t.TempDir(), "aerotun.json"))
+	defer m.StopAll()
+	e := &Executor{vpnMgr: &vpn.Manager{AeroTUN: m}}
+	admin := &auth.PublicUser{Role: auth.RoleFull}
+	psk := strings.Repeat("12", 32)
+	command := `/interface aerotun add name=atest mode=server local-address=10.90.0.1/30 peer-address=10.90.0.2/32 allowed-ips=10.90.0.2/32 psk=` + psk + ` comment="office uplink"`
+	output, _ := e.Execute(command, admin)
+	if commandExitStatus(output) != 0 {
+		t.Fatal(output)
+	}
+	inst := m.List()[0]
+	if !inst.Disabled || inst.PublicKey == "" || inst.Comment != "office uplink" {
+		t.Fatal("draft configuration not retained")
+	}
+	for _, role := range []auth.Role{auth.RoleRead, auth.RoleWrite} {
+		output, _ = e.Execute("/interface aerotun remove atest", &auth.PublicUser{Role: role})
+		if commandExitStatus(output) == 0 {
+			t.Fatal("non-admin removed tunnel")
+		}
+	}
+	output, _ = e.Execute("/interface aerotun print detail=yes", admin)
+	if strings.Contains(output, psk) || strings.Contains(output, inst.PrivateKey) || !strings.Contains(output, inst.PublicKey) {
+		t.Fatal("secret disclosure or missing public identity")
+	}
+	output, _ = e.Execute(`/interface aerotun set atest comment="updated uplink"`, admin)
+	if commandExitStatus(output) != 0 {
+		t.Fatal(output)
+	}
+	updated, _ := m.Get(inst.ID)
+	if updated.PSK != psk || updated.PublicKey != inst.PublicKey {
+		t.Fatal("edit changed identity")
+	}
+	output, _ = e.Execute("/interface aerotun remove atest", admin)
+	if commandExitStatus(output) != 0 || len(m.List()) != 0 {
+		t.Fatal(output)
+	}
+}

+ 23 - 0
internal/cli/completer.go

@@ -509,6 +509,29 @@ func (c *Completer) buildCommandTree() *Node {
 	wgPeersAdd.AddChild(&Node{Name: "endpoint-address="})
 	wgPeersAdd.AddChild(&Node{Name: "endpoint-port="})
 
+	// /interface aerotun
+	aero := ifaceNode.AddChild(&Node{Name: "aerotun"})
+	names := func() []string {
+		if c.executor.vpnMgr == nil || c.executor.vpnMgr.AeroTUN == nil {
+			return nil
+		}
+		var out []string
+		for _, inst := range c.executor.vpnMgr.AeroTUN.List() {
+			out = append(out, inst.Name)
+		}
+		return out
+	}
+	aero.AddChild(&Node{Name: "print"}).AddChild(&Node{Name: "detail="})
+	aero.AddChild(&Node{Name: "genkey"})
+	for _, action := range []string{"add", "set", "remove", "enable", "disable", "start", "stop"} {
+		n := aero.AddChild(&Node{Name: action, Dynamic: names})
+		if action == "add" || action == "set" {
+			for _, param := range []string{"name=", "protocol=", "mode=", "listen-port=", "endpoint=", "local-address=", "peer-address=", "peer-public-key=", "allowed-ips=", "psk=", "mtu=", "disabled=", "compression=", "default-route=", "comment="} {
+				n.AddChild(&Node{Name: param})
+			}
+		}
+	}
+
 	// /interface eoip
 	eoipNode := ifaceNode.AddChild(&Node{Name: "eoip"})
 	addPrint(eoipNode)

+ 53 - 7
internal/cli/executor.go

@@ -78,14 +78,25 @@ func NewExecutor(
 	}
 }
 
-func (e *Executor) Execute(rawCmd string, user *auth.PublicUser) (string, bool) {
+func (e *Executor) Execute(rawCmd string, user *auth.PublicUser) (output string, shouldExit bool) {
+	// A malformed command must never take down the router or its tunnels.
+	defer func() {
+		if recover() != nil {
+			logging.Log("cli,error", "Command handler panicked")
+			output = ColorRed + "Internal command error; the router is still running.\n" + ColorReset
+			shouldExit = false
+		}
+	}()
 	cmd := strings.TrimSpace(rawCmd)
 	if cmd == "" {
 		return "", false
 	}
 
 	cmd = strings.TrimPrefix(cmd, "/")
-	parts := strings.Fields(cmd)
+	parts, err := splitCommand(cmd)
+	if err != nil {
+		return ColorRed + "Syntax error: " + err.Error() + "\n" + ColorReset, false
+	}
 	if len(parts) == 0 {
 		return "", false
 	}
@@ -124,6 +135,9 @@ func (e *Executor) Execute(rawCmd string, user *auth.PublicUser) (string, bool)
 		return e.handleRouting(parts[1:], user), false
 
 	case "tool":
+		if user == nil || user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied: tools require write privileges\n" + ColorReset, false
+		}
 		return e.handleTool(parts[1:]), false
 
 	case "user":
@@ -159,7 +173,11 @@ func (e *Executor) handleLog(parts []string) string {
 		limit := 50
 		filter := ""
 
-		_, params := parseArgs(parts[1:])
+		args := parts
+		if len(args) > 0 {
+			args = args[1:]
+		}
+		_, params := parseArgs(args)
 		if lStr, ok := params["limit"]; ok {
 			if lVal, err := strconv.Atoi(lStr); err == nil && lVal > 0 {
 				limit = lVal
@@ -212,6 +230,8 @@ func (e *Executor) handleInterface(parts []string, user *auth.PublicUser) string
 
 	case "wireguard":
 		return e.handleInterfaceWireGuard(parts[1:], user)
+	case "aerotun":
+		return e.handleInterfaceAeroTUN(parts[1:], user)
 
 	case "eoip":
 		return e.handleInterfaceEoIP(parts[1:], user)
@@ -2054,14 +2074,28 @@ func (e *Executor) handleBandwidthTest(args []string) string {
 	if d, ok := params["direction"]; ok {
 		dir = d
 	}
+	switch dir {
+	case "transmit":
+		dir = "tx"
+	case "receive":
+		dir = "rx"
+	case "tx", "rx", "both":
+	default:
+		return cliError(fmt.Errorf("direction must be both, tx/transmit, or rx/receive"))
+	}
 	proto := "udp"
 	if p, ok := params["protocol"]; ok {
 		proto = p
 	}
+	if proto != "udp" && proto != "tcp" {
+		return cliError(fmt.Errorf("protocol must be udp or tcp"))
+	}
 	dur := 5 * time.Second
 	if dStr, ok := params["duration"]; ok {
 		if dVal, err := time.ParseDuration(dStr); err == nil && dVal >= time.Second {
 			dur = dVal
+		} else {
+			return cliError(fmt.Errorf("duration must be at least 1s"))
 		}
 	}
 
@@ -2072,15 +2106,28 @@ func (e *Executor) handleBandwidthTest(args []string) string {
 		Duration:  int(dur.Seconds()),
 	}
 
-	ctx, cancel := context.WithTimeout(context.Background(), dur+3*time.Second)
+	ctx, cancel := context.WithTimeout(context.Background(), dur+8*time.Second)
 	defer cancel()
 
 	if err := e.bwtestMgr.StartTest(ctx, req); err != nil {
 		return ColorRed + "Failed to initiate bandwidth test: " + err.Error() + ColorReset + "\n"
 	}
 
-	time.Sleep(dur + 500*time.Millisecond)
 	st := e.bwtestMgr.GetTestStatus()
+	ticker := time.NewTicker(100 * time.Millisecond)
+	defer ticker.Stop()
+	for st.Running {
+		select {
+		case <-ctx.Done():
+			e.bwtestMgr.StopTest()
+			return cliError(fmt.Errorf("bandwidth test did not finish before the deadline; stop requested"))
+		case <-ticker.C:
+			st = e.bwtestMgr.GetTestStatus()
+		}
+	}
+	if st.Error != "" {
+		return cliError(fmt.Errorf("bandwidth test failed: %s", st.Error))
+	}
 
 	out := fmt.Sprintf("Bandwidth test to %s completed:\n", target)
 	if st.Summary != nil {
@@ -2091,8 +2138,6 @@ func (e *Executor) handleBandwidthTest(args []string) string {
 	} else if st.CurrentSample != nil {
 		out += fmt.Sprintf("  Tx Rate  : %.2f Mbps\n", st.CurrentSample.TxMbps)
 		out += fmt.Sprintf("  Rx Rate  : %.2f Mbps\n", st.CurrentSample.RxMbps)
-	} else if st.Error != "" {
-		out += fmt.Sprintf("  Error    : %s\n", st.Error)
 	} else {
 		out += "  Test finished.\n"
 	}
@@ -2283,6 +2328,7 @@ func (e *Executor) showHelp() string {
 	b.WriteString("  /interface                     - Network interface manager (print, enable, disable, set)\n")
 	b.WriteString("    /interface vlan              - VLAN virtual interfaces (print, add, remove)\n")
 	b.WriteString("    /interface bridge            - L2 Bridge and ports (print, add, remove, port)\n")
+	b.WriteString("    /interface aerotun           - Encrypted tunnels (print detail=yes, add, set, enable, disable, start, stop, remove, genkey)\n")
 	b.WriteString("    /interface wireguard         - WireGuard tunnels & peers (print, add, peers)\n")
 	b.WriteString("    /interface eoip              - Ethernet over IP GRE tunnels (print, add, remove)\n")
 	b.WriteString("    /interface vrrp              - Virtual Router Redundancy Protocol (print, add, remove)\n")

+ 18 - 18
internal/cli/ssh.go

@@ -3,6 +3,7 @@ package cli
 import (
 	"crypto/ed25519"
 	"crypto/rand"
+	"encoding/binary"
 	"encoding/pem"
 	"errors"
 	"fmt"
@@ -184,10 +185,14 @@ func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Requ
 		case "pty-req":
 			termWidth := 120
 			termHeight := 40
-			if len(req.Payload) >= 8 {
-				// uint32 cols, uint32 rows
-				cols := int(req.Payload[3]) | int(req.Payload[2])<<8
-				rows := int(req.Payload[7]) | int(req.Payload[6])<<8
+			var pty struct {
+				Term                      string
+				Cols, Rows, Width, Height uint32
+				Modes                     string
+			}
+			if ssh.Unmarshal(req.Payload, &pty) == nil {
+				cols := int(pty.Cols)
+				rows := int(pty.Rows)
 				if cols > 20 && rows > 5 {
 					termWidth = cols
 					termHeight = rows
@@ -198,8 +203,8 @@ func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Requ
 
 		case "window-change":
 			if len(req.Payload) >= 8 {
-				cols := int(req.Payload[3]) | int(req.Payload[2])<<8
-				rows := int(req.Payload[7]) | int(req.Payload[6])<<8
+				cols := int(binary.BigEndian.Uint32(req.Payload[:4]))
+				rows := int(binary.BigEndian.Uint32(req.Payload[4:8]))
 				if cols > 20 && rows > 5 {
 					term.SetSize(cols, rows)
 				}
@@ -212,20 +217,15 @@ func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Requ
 			return
 
 		case "exec":
-			_ = req.Reply(true, nil)
-			cmdStr := ""
-			if len(req.Payload) > 4 {
-				cmdLen := int(req.Payload[3]) | int(req.Payload[2])<<8
-				if 4+cmdLen <= len(req.Payload) {
-					cmdStr = string(req.Payload[4 : 4+cmdLen])
-				} else {
-					cmdStr = string(req.Payload[4:])
-				}
+			var command struct{ Command string }
+			if ssh.Unmarshal(req.Payload, &command) != nil {
+				_ = req.Reply(false, nil)
+				return
 			}
-			output, _ := s.executor.Execute(cmdStr, user)
+			_ = req.Reply(true, nil)
+			output, _ := s.executor.Execute(command.Command, user)
 			_, _ = channel.Write([]byte(output))
-			// Send exit status 0
-			_, _ = channel.SendRequest("exit-status", false, []byte{0, 0, 0, 0})
+			_, _ = channel.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{commandExitStatus(output)}))
 			return
 
 		default:

+ 80 - 0
internal/cli/ssh_test.go

@@ -0,0 +1,80 @@
+package cli
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"errors"
+	"net"
+	"strings"
+	"testing"
+	"time"
+
+	"golang.org/x/crypto/ssh"
+	"proxmox-router/internal/auth"
+)
+
+func TestSSHExecReportsFailureAndPreservesLongCommands(t *testing.T) {
+	_, private, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	signer, err := ssh.NewSignerFromKey(private)
+	if err != nil {
+		t.Fatal(err)
+	}
+	config := &ssh.ServerConfig{NoClientAuth: true}
+	config.AddHostKey(signer)
+	l, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer l.Close()
+	server := &SSHServer{executor: &Executor{}}
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		raw, err := l.Accept()
+		if err != nil {
+			return
+		}
+		defer raw.Close()
+		conn, chans, reqs, err := ssh.NewServerConn(raw, config)
+		if err != nil {
+			return
+		}
+		defer conn.Close()
+		go ssh.DiscardRequests(reqs)
+		for ch := range chans {
+			channel, requests, err := ch.Accept()
+			if err == nil {
+				go server.handleSession(channel, requests, &auth.PublicUser{Role: auth.RoleFull})
+			}
+		}
+	}()
+	client, err := ssh.Dial("tcp", l.Addr().String(), &ssh.ClientConfig{User: "test", HostKeyCallback: ssh.FixedHostKey(signer.PublicKey()), Timeout: 3 * time.Second})
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() { client.Close(); <-done }()
+	for _, command := range []string{"/log", "/help", "unknown", "/interface print", strings.Repeat("x", 70000)} {
+		session, err := client.NewSession()
+		if err != nil {
+			t.Fatal(err)
+		}
+		output, err := session.CombinedOutput(command)
+		session.Close()
+		if command == "/log" || command == "/help" {
+			if err != nil {
+				t.Fatalf("valid command failed: %v", err)
+			}
+		} else {
+			var exit *ssh.ExitError
+			if !errors.As(err, &exit) || exit.ExitStatus() != 1 {
+				t.Fatalf("failure reported success: %v", err)
+			}
+			if len(command) > 65535 && !strings.Contains(string(output), command) {
+				t.Fatal("long command truncated")
+			}
+		}
+	}
+}

+ 325 - 138
internal/web/static/app.js

@@ -1,54 +1,94 @@
 // DebTik - Debian Linux Router Frontend Application Logic
 
 // ================= Interceptor: Automatic Auth Bearer & 401 Handling =================
-const originalFetch = window.fetch;
-window.fetch = async function(...args) {
-  let [resource, config] = args;
-  config = config || {};
-  
-  // Clone or init headers
-  let headers = config.headers;
-  if (!headers) {
-    headers = {};
-    config.headers = headers;
-  }
+const originalFetch = window.fetch.bind(window);
+const pendingAPIReads = new Map();
+const telemetryHealth = { lastSuccess: 0, failures: new Map() };
+let coreRefreshPromise = null;
+let lastSummaryRefresh = 0;
+
+function renderTelemetryHealth() {
+  const el = document.getElementById('telemetry-health');
+  if (!el) return;
+  const failures = [...telemetryHealth.failures.entries()];
+  const seconds = telemetryHealth.lastSuccess ? Math.floor((Date.now() - telemetryHealth.lastSuccess) / 1000) : null;
+  const stale = seconds === null || seconds > 15;
+  el.textContent = failures.length ? `Data unavailable / incomplete${seconds === null ? '' : ` · updated ${seconds}s ago`}` : stale ? 'Waiting for fresh data' : `Updated ${seconds}s ago`;
+  el.className = `text-[10px] font-mono ${failures.length || stale ? 'text-amber-400' : 'text-emerald-400'}`;
+  el.title = failures.length ? failures.map(([path, error]) => `${path}: ${error}`).join('\n') : telemetryHealth.lastSuccess ? `Last successful update: ${new Date(telemetryHealth.lastSuccess).toLocaleTimeString()}` : 'No successful update yet';
+}
+
+function stopSessionPolling() {
+  if (window._telemetryInterval) clearInterval(window._telemetryInterval);
+  window._telemetryInterval = null;
+  if (trafficEventSource) trafficEventSource.close();
+  trafficEventSource = null;
+  if (trafficPollFallback) clearInterval(trafficPollFallback);
+  trafficPollFallback = null;
+  if (bwtestState.pollTimer) clearInterval(bwtestState.pollTimer);
+  bwtestState.pollTimer = null;
+  if (logsTimer) clearInterval(logsTimer);
+  logsTimer = null;
+  stopFwStatPolling();
+  stopNATStatPolling();
+  stopMangleStatPolling();
+  telemetryHealth.failures.clear();
+  telemetryHealth.lastSuccess = 0;
+  lastSummaryRefresh = 0;
+}
 
+window.fetch = async function(resource, options = {}) {
+  const url = new URL(resource instanceof Request ? resource.url : resource, window.location.href);
+  const isAPI = url.origin === window.location.origin && url.pathname.startsWith('/api/');
+  if (!isAPI) return originalFetch(resource, options);
+  const config = { ...options, headers: new Headers(options.headers || (resource instanceof Request ? resource.headers : undefined)) };
   const token = localStorage.getItem('debtik_token');
-  if (token) {
-    if (headers instanceof Headers) {
-      if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`);
-    } else if (Array.isArray(headers)) {
-      if (!headers.some(([k]) => k.toLowerCase() === 'authorization')) headers.push(['Authorization', `Bearer ${token}`]);
-    } else {
-      if (!headers['Authorization']) headers['Authorization'] = `Bearer ${token}`;
+  if (token && !config.headers.has('Authorization')) config.headers.set('Authorization', `Bearer ${token}`);
+  const method = (config.method || (resource instanceof Request ? resource.method : 'GET')).toUpperCase();
+  const isRead = method === 'GET' && !url.pathname.includes('/stream') && !url.pathname.startsWith('/api/auth/');
+  const key = `${url.href}|${JSON.stringify([...config.headers])}`;
+  if (isRead && pendingAPIReads.has(key)) return (await pendingAPIReads.get(key)).clone();
+  const perform = async () => {
+    let timeout;
+    if (isRead && !config.signal) {
+      const controller = new AbortController();
+      config.signal = controller.signal;
+      timeout = setTimeout(() => controller.abort(), 15000);
     }
-  }
-
-  const response = await originalFetch(resource, config);
-
-  // If 401 Unauthorized on protected API endpoint, prompt login immediately
-  if (response.status === 401 && typeof resource === 'string' && resource.includes('/api/') && !resource.includes('/api/auth/login')) {
-    if (state.currentUser) {
-      if (window._telemetryInterval) {
-        clearInterval(window._telemetryInterval);
-        window._telemetryInterval = null;
+    try {
+      const response = await originalFetch(resource, config);
+      if (isRead) {
+        if (response.ok) {
+          telemetryHealth.lastSuccess = Date.now();
+          telemetryHealth.failures.delete(url.pathname);
+        } else {
+          telemetryHealth.failures.set(url.pathname, `HTTP ${response.status}; displayed values may be old`);
+        }
+        renderTelemetryHealth();
       }
-      if (trafficEventSource) {
-        try { trafficEventSource.close(); } catch(e) {}
-        trafficEventSource = null;
+      if (response.status === 401 && url.pathname !== '/api/auth/login' && state.currentUser && token === localStorage.getItem('debtik_token')) {
+        stopSessionPolling();
+        state.isPolling = false;
+        state.currentUser = null;
+        localStorage.removeItem('debtik_token');
+        showLoginScreen(true);
       }
-      if (trafficPollFallback) {
-        clearInterval(trafficPollFallback);
-        trafficPollFallback = null;
+      return response;
+    } catch (err) {
+      if (isRead) {
+        telemetryHealth.failures.set(url.pathname, err.name === 'AbortError' ? 'Request timed out' : 'Connection failed; displayed values may be old');
+        renderTelemetryHealth();
       }
-      state.isPolling = false;
-      state.currentUser = null;
-      localStorage.removeItem('debtik_token');
-      showLoginScreen(true);
+      throw err;
+    } finally {
+      if (timeout) clearTimeout(timeout);
     }
-  }
-
-  return response;
+  };
+  if (!isRead) return perform();
+  const request = perform();
+  pendingAPIReads.set(key, request);
+  try { return (await request).clone(); }
+  finally { if (pendingAPIReads.get(key) === request) pendingAPIReads.delete(key); }
 };
 
 let state = {
@@ -141,7 +181,7 @@ document.addEventListener('DOMContentLoaded', async () => {
     // Full state telemetry polling every 5 seconds (smooth & flicker-free)
     if (!window._telemetryInterval) {
       window._telemetryInterval = setInterval(() => {
-        if (state.isPolling && state.currentUser) {
+        if (state.isPolling && state.currentUser && !document.hidden) {
           fetchData(false);
         }
       }, 5000);
@@ -293,6 +333,9 @@ function navigate(tabName) {
     return;
   }
 
+  stopFwStatPolling();
+  stopNATStatPolling();
+  stopMangleStatPolling();
   state.activeTab = tabName;
   window.location.hash = tabName;
 
@@ -322,7 +365,7 @@ function navigate(tabName) {
     'dynamic-routing': { title: 'BGP Dynamic Routing', sub: 'Dynamic routing engine and peering sessions' },
     ppp: { title: 'PPP Servers & Secrets', sub: 'Subscriber authentication, dynamic IP pools and VPN access concentrators' },
     eoip: { title: 'EoIP Tunnels', sub: 'Ethernet over IP (EoIP / GRETAP) Layer 2 encapsulation & transparent network bridging' },
-    aerotun: { title: 'AeroTUN High-Speed VPN', sub: 'Ultra-fast Layer 3 encrypted tunnel with ChaCha20-Poly1305 and live Adaptive LZ4 compression' },
+    aerotun: { title: 'AeroTUN High-Speed VPN', sub: 'Layer 3 encrypted tunnel with XChaCha20-Poly1305 and adaptive LZ4 compression' },
     pppoe: { title: 'PPPoE Client WAN', sub: 'Point-to-Point Protocol over Ethernet WAN access' },
     wireguard: { title: 'WireGuard VPN', sub: 'High-performance cryptographic Layer 3 tunnels with ChaCha20-Poly1305 key exchange' },
     ipsec: { title: 'IPSec (IKEv2) strongSwan', sub: 'Enterprise Site-to-Site & Roadwarrior IPSec controller' },
@@ -335,6 +378,7 @@ function navigate(tabName) {
     users: { title: 'Local User Manager', sub: 'Manage local system administrator credentials and access permissions' },
     certificates: { title: 'Certificates & SSL/TLS', sub: 'Root Certificate Authorities (CA), Server/Client certificates and automated Let\'s Encrypt' },
     services: { title: 'Management Services', sub: 'Enable or disable Web, SSH, and Telnet management listeners and listening ports' },
+    updates: { title: 'Git Updates', sub: 'Check, install and restore appliance software' },
     logs: { title: 'System Activity Logs', sub: 'Real-time circular buffer log viewer and topic-based event monitoring' }
   };
 
@@ -388,30 +432,41 @@ function navigate(tabName) {
   if (tabName === 'users') loadUsers();
   if (tabName === 'certificates') loadCertificates();
   if (tabName === 'services') loadServices();
+  if (tabName === 'updates') loadUpdateStatus();
   if (tabName === 'logs') loadLogs();
   setTimeout(initTableSorting, 50);
 }
 
 // Fetch all core system, network and VPN telemetry
-async function fetchData(isManual = false) {
+function fetchData(isManual = false) {
+  if (coreRefreshPromise) return coreRefreshPromise;
+  coreRefreshPromise = refreshCoreData(isManual).finally(() => { coreRefreshPromise = null; });
+  return coreRefreshPromise;
+}
+
+async function refreshCoreData(isManual = false) {
   const spinner = document.getElementById('refresh-spinner');
   if (isManual && spinner) spinner.classList.add('animate-spin');
 
   try {
+    const refreshSummaries = isManual || Date.now() - lastSummaryRefresh > 30000;
+    const summaryFetch = path => (refreshSummaries || (state.activeTab === 'interfaces' && ['/vlans', '/roles'].includes(path))) ? fetch(`${API_BASE}${path}`).catch(() => ({ ok: false })) : Promise.resolve({ ok: false });
     const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes, vrrpSumRes, rolesRes] = await Promise.all([
-      fetch(`${API_BASE}/system/status`),
-      fetch(`${API_BASE}/interfaces`),
-      fetch(`${API_BASE}/vlans`),
-      fetch(`${API_BASE}/vpn/status`),
-      fetch(`${API_BASE}/firewall/summary`),
-      fetch(`${API_BASE}/routing/dynamic/summary`),
-      fetch(`${API_BASE}/bridge/summary`),
-      fetch(`${API_BASE}/dhcp/clients/summary`),
-      fetch(`${API_BASE}/dhcp/clients`),
-      fetch(`${API_BASE}/vrrp/summary`).catch(() => null),
-      fetch(`${API_BASE}/roles`).catch(() => null)
+      fetch(`${API_BASE}/system/status`).catch(() => ({ ok: false })),
+      fetch(`${API_BASE}/interfaces`).catch(() => ({ ok: false })),
+      summaryFetch('/vlans'),
+      summaryFetch('/vpn/status'),
+      summaryFetch('/firewall/summary'),
+      summaryFetch('/routing/dynamic/summary'),
+      summaryFetch('/bridge/summary'),
+      summaryFetch('/dhcp/clients/summary'),
+      summaryFetch('/dhcp/clients'),
+      summaryFetch('/vrrp/summary').catch(() => null),
+      summaryFetch('/roles').catch(() => null)
     ]);
 
+    if (refreshSummaries && [vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes, vrrpSumRes, rolesRes].every(r => r && r.ok)) lastSummaryRefresh = Date.now();
+
     if (rolesRes && rolesRes.ok) {
       state.roles = await rolesRes.json();
       const countEl = document.getElementById('badge-roles-count');
@@ -472,27 +527,26 @@ async function fetchData(isManual = false) {
       else renderInterfacesList();
     }
     if (state.activeTab === 'vlans') renderVlansList();
-    if (state.activeTab === 'firewall') loadFirewall(false);
-    if (state.activeTab === 'routes') loadRoutes(false);
-    if (state.activeTab === 'dynamic-routing' || state.activeTab === 'bgp' || state.activeTab === 'ospf') loadDynamicRouting(false);
-    if (state.activeTab === 'ppp') loadPPP(false);
-    if (state.activeTab === 'eoip') loadEoIP(false);
-    if (state.activeTab === 'aerotun') loadAeroTUN(false);
-    if (state.activeTab === 'pppoe') loadPPPoE(false);
-    if (state.activeTab === 'wireguard') loadWireGuard(false);
-    if (state.activeTab === 'ipsec') loadIPSec(false);
-    if (state.activeTab === 'l2tp') loadL2TP(false);
-    if (state.activeTab === 'pptp') loadPPTP(false);
-    if (state.activeTab === 'dhcp-client') loadDHCPClients(false);
-    if (state.activeTab === 'dhcp-server') loadDHCPServer(false);
-    if (state.activeTab === 'vrrp') loadVRRP(false);
-    if (state.activeTab === 'settings') loadSettingsData(false);
-    if (state.activeTab === 'users') loadUsers(false);
-    if (state.activeTab === 'certificates') loadCertificates(false);
-    if (state.activeTab === 'services') loadServices(false);
-    loadCertificates(false).catch(() => {});
-    loadServices(false).catch(() => {});
-    fetchBwTestServerStatus();
+    if (state.activeTab === 'firewall') await loadFirewall(false);
+    if (state.activeTab === 'routes') await loadRoutes(false);
+    if (state.activeTab === 'dynamic-routing' || state.activeTab === 'bgp' || state.activeTab === 'ospf') await loadDynamicRouting(false);
+    if (state.activeTab === 'ppp') await loadPPP(false);
+    if (state.activeTab === 'eoip') await loadEoIP(false);
+    if (state.activeTab === 'aerotun') await loadAeroTUN(false);
+    if (state.activeTab === 'pppoe') await loadPPPoE(false);
+    if (state.activeTab === 'wireguard') await loadWireGuard(false);
+    if (state.activeTab === 'ipsec') await loadIPSec(false);
+    if (state.activeTab === 'l2tp') await loadL2TP(false);
+    if (state.activeTab === 'pptp') await loadPPTP(false);
+    if (state.activeTab === 'dhcp-client') await loadDHCPClients(false);
+    if (state.activeTab === 'dhcp-server') await loadDHCPServer(false);
+    if (state.activeTab === 'vrrp') await loadVRRP(false);
+    if (state.activeTab === 'settings') await loadSettingsData(false);
+    if (state.activeTab === 'users') await loadUsers(false);
+    if (state.activeTab === 'certificates') await loadCertificates(false);
+    if (state.activeTab === 'services') await loadServices(false);
+    if (state.activeTab === 'updates') await loadUpdateStatus(false);
+    if (state.activeTab === 'bwtest') await fetchBwTestServerStatus();
 
   } catch (err) {
     console.error('Failed to fetch router state:', err);
@@ -7557,7 +7611,7 @@ function renderAeroTUN() {
 
   const statActive = document.getElementById('aerotun-stat-active');
   if (statActive) {
-    const activeCount = tunnels.filter(t => t.running).length;
+    const activeCount = tunnels.filter(t => t.connected).length;
     statActive.innerText = `${activeCount} / ${tunnels.length}`;
   }
 
@@ -7601,9 +7655,9 @@ function renderAeroTUN() {
       ? `<span class="px-1.5 py-0.5 rounded text-[10px] font-mono bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-semibold" title="Adaptive LZ4 active">${t.compression_ratio || 'LZ4'}</span>`
       : `<span class="text-slate-500 text-[10px]">Off</span>`;
 
-    const activePeer = t.active_peer_endpoint
-      ? `<span class="text-emerald-400 font-mono text-[11px]" title="Last seen: ${t.last_handshake || 'just now'}">● ${escapeHtml(t.active_peer_endpoint)}</span>`
-      : `<span class="text-slate-500 text-[11px]">${t.mode === 'server' ? 'Waiting for peer...' : 'Connecting...'}</span>`;
+    const activePeer = t.connected && t.active_peer_endpoint
+      ? `<span class="text-emerald-400 font-mono text-[11px]" title="Last authenticated packet: ${escapeHtml(t.last_handshake || 'just now')}; dropped: ${t.dropped_packets || 0}; auth failures: ${t.auth_failures || 0}; replay drops: ${t.replay_drops || 0}${t.last_error ? '; last error: ' + escapeHtml(t.last_error) : ''}">● ${escapeHtml(t.active_peer_endpoint)}</span>`
+      : `<span class="text-slate-500 text-[11px]">${t.last_error ? 'Failed: ' + escapeHtml(t.last_error) : (!t.running ? (t.disabled ? 'Saved / disabled' : 'Stopped') : (t.mode === 'server' ? 'Waiting for peer...' : 'Connecting...'))}</span>`;
 
     const rawRx = formatBytes(t.rx_bytes || 0);
     const rawTx = formatBytes(t.tx_bytes || 0);
@@ -7611,10 +7665,11 @@ function renderAeroTUN() {
     const wireTx = formatBytes(t.wire_bytes_out || 0);
 
     tr.innerHTML = `
-      <td class="py-2.5 px-3 text-center">${renderMikroTikFlag(t.running, t.disabled)}</td>
+      <td class="py-2.5 px-3 text-center">${renderMikroTikFlag(t.connected, t.disabled)}</td>
       <td class="py-2.5 px-3 font-bold text-white font-mono flex items-center gap-1.5">
         <span>${escapeHtml(t.name)}</span>
         ${t.default_route ? '<span class="text-[9px] px-1 rounded bg-amber-500/20 text-amber-300 border border-amber-500/30">DEF</span>' : ''}
+        <span class="text-slate-400 text-[10px]">v${t.protocol_version || 2}</span>
       </td>
       <td class="py-2.5 px-3">${modeBadge}</td>
       <td class="py-2.5 px-3 text-xs font-mono">${endpointInfo}</td>
@@ -7661,16 +7716,33 @@ function onAeroTUNModeChange() {
 async function generateAeroTUNKey() {
   try {
     const res = await fetch(`${API_BASE}/vpn/aerotun/genkey`);
-    if (res.ok) {
-      const data = await res.json();
-      document.getElementById('aerotun-modal-psk').value = data.key;
-      showToast('Key Generated', 'Cryptographic 256-bit PSK generated', 'success');
-    }
+    if (!res.ok) throw new Error(await responseError(res));
+    const data = await res.json();
+    document.getElementById('aerotun-modal-psk').value = data.key;
+    showToast('Key Generated', 'Share this PSK securely with the peer before enabling', 'success');
   } catch (err) {
-    const arr = new Uint8Array(32);
-    crypto.getRandomValues(arr);
-    const hex = Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
-    document.getElementById('aerotun-modal-psk').value = hex;
+    showToast('Key Generation Failed', err.message, 'error');
+  }
+}
+
+function updateAeroTUNSetupHint() {
+  const version = document.getElementById('aerotun-modal-protocol').value;
+  const key = document.getElementById('aerotun-modal-public').value;
+  const peer = document.getElementById('aerotun-modal-peerkey').value.trim();
+  const hint = document.getElementById('aerotun-setup-hint');
+  if (!hint) return;
+  hint.innerText = version !== '3' ? 'Legacy v2 uses a shared PSK. Both endpoints must use the same protocol.' : !key ? '1. Save disabled to create the local identity. 2. Exchange public keys and share the same PSK. 3. Set allowed peer prefixes, then enable.' : !peer ? 'Local identity saved. Copy the public key to the peer and paste its public key here. Use the same PSK on both endpoints.' : 'Peer identity configured. Check reciprocal addresses, allowed peer source prefixes and the shared PSK before enabling. Allowed prefixes do not create LAN routes.';
+}
+
+async function copyAeroTUNPublicKey() {
+  const input = document.getElementById('aerotun-modal-public');
+  if (!input.value) { showToast('Save First', 'Save the disabled tunnel to create its public key', 'info'); return; }
+  try {
+    await navigator.clipboard.writeText(input.value);
+    showToast('Public Key Copied', 'Paste it into the other endpoint’s pinned peer key', 'success');
+  } catch {
+    input.focus(); input.select();
+    showToast('Copy Public Key', 'Key selected; press Ctrl+C to copy', 'info');
   }
 }
 
@@ -7678,6 +7750,10 @@ function openAddAeroTUNModal() {
   const tunnels = state.aerotunTunnels || [];
   document.getElementById('aerotun-modal-title').innerText = 'Add AeroTUN Interface';
   document.getElementById('aerotun-modal-id').value = '';
+ document.getElementById('aerotun-modal-protocol').value='3';
+ document.getElementById('aerotun-modal-public').value='';
+ document.getElementById('aerotun-modal-peerkey').value='';
+ document.getElementById('aerotun-modal-allowed').value=`10.99.${tunnels.length}.2/32`;
   document.getElementById('aerotun-modal-name').value = `aerotun${tunnels.length}`;
   document.getElementById('aerotun-modal-mode').value = 'server';
   document.getElementById('aerotun-modal-port').value = 51830 + tunnels.length;
@@ -7687,12 +7763,13 @@ function openAddAeroTUNModal() {
   document.getElementById('aerotun-modal-psk').value = '';
   document.getElementById('aerotun-modal-mtu').value = '1420';
   document.getElementById('aerotun-modal-comment').value = '';
-  document.getElementById('aerotun-modal-compression').checked = true;
+  document.getElementById('aerotun-modal-compression').checked = false;
   document.getElementById('aerotun-modal-defroute').checked = false;
-  document.getElementById('aerotun-modal-disabled').checked = false;
+  document.getElementById('aerotun-modal-disabled').checked = true;
 
   generateAeroTUNKey();
   onAeroTUNModeChange();
+  updateAeroTUNSetupHint();
   document.getElementById('aerotun-modal').classList.remove('hidden');
 }
 
@@ -7702,13 +7779,18 @@ function openEditAeroTUNModal(id) {
 
   document.getElementById('aerotun-modal-title').innerText = `Edit AeroTUN Interface (${t.name})`;
   document.getElementById('aerotun-modal-id').value = t.id;
+ document.getElementById('aerotun-modal-protocol').value=String(t.protocol_version || 2);
+ document.getElementById('aerotun-modal-public').value=t.public_key || '';
+ document.getElementById('aerotun-modal-peerkey').value=t.peer_public_key || '';
+ document.getElementById('aerotun-modal-allowed').value=(t.allowed_ips || []).join(', ');
   document.getElementById('aerotun-modal-name').value = t.name;
   document.getElementById('aerotun-modal-mode').value = t.mode || 'server';
   document.getElementById('aerotun-modal-port').value = t.listen_port || '';
   document.getElementById('aerotun-modal-endpoint').value = t.remote_endpoint || '';
   document.getElementById('aerotun-modal-local').value = t.local_address || '';
   document.getElementById('aerotun-modal-peer').value = t.peer_address || '';
-  document.getElementById('aerotun-modal-psk').value = t.psk || '';
+  document.getElementById('aerotun-modal-psk').value = '';
+ document.getElementById('aerotun-modal-psk').placeholder = t.has_psk ? 'Leave blank to keep the existing key' : 'New random 256-bit PSK';
   document.getElementById('aerotun-modal-mtu').value = t.mtu || 1420;
   document.getElementById('aerotun-modal-comment').value = t.comment || '';
   document.getElementById('aerotun-modal-compression').checked = !!t.compression;
@@ -7716,6 +7798,7 @@ function openEditAeroTUNModal(id) {
   document.getElementById('aerotun-modal-disabled').checked = !!t.disabled;
 
   onAeroTUNModeChange();
+  updateAeroTUNSetupHint();
   document.getElementById('aerotun-modal').classList.remove('hidden');
 }
 
@@ -7743,7 +7826,12 @@ async function submitSaveAeroTUN(e) {
     showToast('Validation Error', 'Remote endpoint (host:port) is required in client mode', 'error');
     return;
   }
-  if (!psk) {
+  const version = parseInt(document.getElementById('aerotun-modal-protocol').value, 10);
+  if (version === 3 && !disabled && !document.getElementById('aerotun-modal-peerkey').value.trim()) {
+    showToast('Peer Key Required', 'Save disabled first, exchange public keys, then enable', 'error');
+    return;
+  }
+  if (!psk && !id) {
     showToast('Validation Error', 'Pre-shared key (PSK) is required', 'error');
     return;
   }
@@ -7753,6 +7841,9 @@ async function submitSaveAeroTUN(e) {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({
+        protocol_version:parseInt(document.getElementById('aerotun-modal-protocol').value,10),
+        peer_public_key:document.getElementById('aerotun-modal-peerkey').value.trim(),
+        allowed_ips:document.getElementById('aerotun-modal-allowed').value.split(',').map(s=>s.trim()).filter(Boolean),
         id,
         name,
         mode,
@@ -7770,9 +7861,15 @@ async function submitSaveAeroTUN(e) {
     });
 
     if (res.ok) {
-      showToast('AeroTUN Saved', `Tunnel ${name} configured successfully`, 'success');
-      closeAeroTUNModal();
-      loadAeroTUN(false);
+      const saved = await res.json();
+      await loadAeroTUN(false);
+      if (!id && version === 3) {
+        openEditAeroTUNModal(saved.id);
+        showToast('Identity Saved', 'Copy the local public key and exchange keys with the peer', 'success');
+      } else {
+        closeAeroTUNModal();
+        showToast('AeroTUN Saved', disabled ? 'Configuration saved; tunnel is disabled' : 'Configuration saved; waiting for authenticated peer traffic', 'success');
+      }
       fetchData(false);
     } else {
       const err = await res.text();
@@ -7787,7 +7884,7 @@ async function startAeroTUNTunnel(id) {
   try {
     const res = await fetch(`${API_BASE}/vpn/aerotun/tunnels/${encodeURIComponent(id)}/start`, { method: 'POST' });
     if (res.ok) {
-      showToast('AeroTUN Started', 'Tunnel interface is active', 'success');
+      showToast('AeroTUN Started', 'Interface started; connection is confirmed by authenticated peer traffic', 'info');
       loadAeroTUN(false);
       fetchData(false);
     } else {
@@ -8689,19 +8786,20 @@ async function loadOspfAll() {
 }
 
 function renderBgpStatusPill() {
-  const pill = document.getElementById('bgp-live-status-pill');
-  if (!pill) return;
+  const pills = document.querySelectorAll('.bgp-live-status-pill');
+  pills.forEach(pill => {
   const inst = state.bgpInstance;
   if (!inst || !inst.enabled) {
-    pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700';
+    pill.className = 'bgp-live-status-pill text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700';
     pill.innerText = 'BGP DISABLED';
   } else if (inst.running) {
-    pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-purple-950 text-purple-300 border border-purple-800/60 font-semibold';
+    pill.className = 'bgp-live-status-pill text-[10px] px-2 py-0.5 rounded font-mono bg-purple-950 text-purple-300 border border-purple-800/60 font-semibold';
     pill.innerText = `BGP ACTIVE (AS ${inst.as})`;
   } else {
-    pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-amber-950 text-amber-300 border border-amber-800/60 font-semibold';
+    pill.className = 'bgp-live-status-pill text-[10px] px-2 py-0.5 rounded font-mono bg-amber-950 text-amber-300 border border-amber-800/60 font-semibold';
     pill.innerText = 'BGP STARTING...';
   }
+  });
 }
 
 function renderBgpPeers() {
@@ -14894,14 +14992,13 @@ const bwtestState = {
   peakTx: 0,
   peakRx: 0,
   targetDuration: 10,
-  lastSampleIndex: 0
+  lastSampleElapsed: -1,
+  sessionID: null
 };
 
 async function loadBwTestView() {
-  fetchBwTestServerStatus();
-  loadBwTestHistory();
   resizeBwTestCanvas();
-  checkActiveBwTest();
+  await Promise.all([fetchBwTestServerStatus(), loadBwTestHistory(), checkActiveBwTest()]);
 }
 
 async function fetchBwTestServerStatus() {
@@ -14940,9 +15037,11 @@ async function fetchBwTestServerStatus() {
 
     // Modal fields
     const toggle = document.getElementById('bwtest-server-enable-toggle');
-    if (toggle) toggle.checked = !!s.enabled;
+    const serverModal = document.getElementById('modal-bwtest-server');
+    const editing = serverModal && !serverModal.classList.contains('hidden');
+    if (toggle && !editing) toggle.checked = !!s.enabled;
     const portInp = document.getElementById('bwtest-server-port-input');
-    if (portInp) portInp.value = s.port || 2000;
+    if (portInp && !editing) 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');
@@ -14956,6 +15055,11 @@ async function fetchBwTestServerStatus() {
 }
 
 function openBwTestServerModal() {
+  const cfg = bwtestState.serverConfig;
+  if (cfg) {
+    document.getElementById('bwtest-server-enable-toggle').checked = !!cfg.enabled;
+    document.getElementById('bwtest-server-port-input').value = cfg.port || 2000;
+  }
   fetchBwTestServerStatus();
   document.getElementById('modal-bwtest-server')?.classList.remove('hidden');
 }
@@ -15068,7 +15172,7 @@ async function checkActiveBwTest() {
     const st = await res.json();
     if (st.running) {
       setBwTestRunningUI(true);
-      if (st.current_sample) handleBwTestSample(st.current_sample);
+      processBwTestStatus(st);
       startBwTestTelemetry();
     }
   } catch (err) {
@@ -15092,7 +15196,8 @@ async function startBwTest(e) {
   bwtestState.chartPoints = [];
   bwtestState.peakTx = 0;
   bwtestState.peakRx = 0;
-  bwtestState.lastSampleIndex = 0;
+  bwtestState.lastSampleElapsed = -1;
+  bwtestState.sessionID = null;
 
   // Clear UI elements
   document.getElementById('bwtest-stat-tx-mbps').innerText = '0.00';
@@ -15181,44 +15286,64 @@ function setBwTestRunningUI(running) {
 
 async function stopBwTest() {
   try {
-    await fetch(`${API_BASE}/tools/bwtest/stop`, { method: 'POST' });
-    showToast('Test Stopped', 'Bandwidth test terminated by user', 'info');
+    const res = await fetch(`${API_BASE}/tools/bwtest/stop`, { method: 'POST' });
+    if (!res.ok) throw new Error(await responseError(res));
+    showToast('Stop Requested', 'Waiting for the bandwidth test to finish', 'info');
+    if (!bwtestState.pollTimer) startBwTestTelemetry();
   } catch (err) {
-    console.error('Failed to stop test:', err);
+    showToast('Stop Failed', err.message, 'error');
+  }
+}
+
+async function responseError(res) {
+  const text = await res.text();
+  try { const data = JSON.parse(text); return data.message || (typeof data.error === 'string' ? data.error : null) || `HTTP ${res.status}`; }
+  catch { return text || `HTTP ${res.status}`; }
+}
+
+function processBwTestStatus(st) {
+  if (st.session_id && bwtestState.sessionID !== st.session_id) {
+    bwtestState.sessionID = st.session_id;
+    bwtestState.lastSampleElapsed = -1;
+  }
+  for (const sample of st.recent_samples || []) {
+    if (sample.elapsed_sec > (bwtestState.lastSampleElapsed ?? -1)) {
+      handleBwTestSample(sample);
+      bwtestState.lastSampleElapsed = sample.elapsed_sec;
+    }
+  }
+  if (!st.running) {
+    if (bwtestState.pollTimer) clearInterval(bwtestState.pollTimer);
+    bwtestState.pollTimer = null;
+    setBwTestRunningUI(false);
+    handleBwTestFinish(st.summary, st.error);
   }
 }
 
 function startBwTestTelemetry() {
+  if (!state.currentUser) return;
   if (bwtestState.pollTimer) clearInterval(bwtestState.pollTimer);
-
-  bwtestState.pollTimer = setInterval(async () => {
+  let busy = false;
+  const poll = async () => {
+    if (busy || !state.currentUser || document.hidden) return;
+    busy = true;
     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);
-      }
+      if (!res.ok) throw new Error(await responseError(res));
+      processBwTestStatus(await res.json());
     } catch (err) {
       console.error('BwTest polling error:', err);
-    }
-  }, 400);
+    } finally { busy = false; }
+  };
+  bwtestState.pollTimer = setInterval(poll, 400);
+  poll();
 }
 
 function handleBwTestSample(s) {
   if (!s) return;
 
+  const payloadHint = document.getElementById('bwtest-effective-payload');
+  if (payloadHint && s.packet_size) payloadHint.innerText = `Effective datagram: ${s.packet_size} bytes`;
   const txMbps = s.tx_mbps || 0;
   const rxMbps = s.rx_mbps || 0;
 
@@ -15227,7 +15352,10 @@ function handleBwTestSample(s) {
 
   // KPI updates
   const elTx = document.getElementById('bwtest-stat-tx-mbps');
-  if (elTx) elTx.innerText = txMbps.toFixed(2);
+  if (elTx) {
+ elTx.innerText = txMbps.toFixed(2);
+ elTx.title = `Confirmed by peer; locally offered: ${(s.offered_tx_mbps || 0).toFixed(2)} Mbps`;
+ }
   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');
@@ -15974,7 +16102,7 @@ async function handleLoginSubmit(event) {
       // Ensure background telemetry polling is active
       if (!window._telemetryInterval) {
         window._telemetryInterval = setInterval(() => {
-          if (state.isPolling && state.currentUser) {
+          if (state.isPolling && state.currentUser && !document.hidden) {
             fetchData(false);
           }
         }, 5000);
@@ -16003,6 +16131,7 @@ async function handleLogoutClick() {
     });
   } catch (e) {}
 
+  stopSessionPolling();
   localStorage.removeItem('debtik_token');
   state.currentUser = null;
   state.isPolling = false;
@@ -16665,9 +16794,9 @@ async function loadLogs(manual = false) {
   }
 
   // Setup auto-refresh if enabled and on logs tab
-  if (!logsTimer) {
+  if (!logsTimer && state.currentUser) {
     logsTimer = setInterval(() => {
-      if (state.activeTab === 'logs') {
+      if (state.currentUser && !document.hidden && state.activeTab === 'logs') {
         const auto = document.getElementById('logs-auto-refresh');
         if (auto && auto.checked) {
           loadLogs(false);
@@ -17094,3 +17223,61 @@ async function deleteCertificate(name) {
 
 
 
+
+// Refresh immediately when returning to a backgrounded controller tab.
+document.addEventListener('visibilitychange', () => {
+  if (!state.currentUser) return;
+  if (document.hidden) {
+    if (trafficEventSource) trafficEventSource.close();
+    trafficEventSource = null;
+    if (trafficPollFallback) clearInterval(trafficPollFallback);
+    trafficPollFallback = null;
+  } else {
+    fetchData(false);
+    initLiveTrafficStream();
+    if (state.activeTab === 'bwtest') loadBwTestView();
+  }
+});
+
+async function loadUpdateStatus(populate = true) {
+  try {
+    const res = await fetch(`${API_BASE}/update/status`);
+    if (!res.ok) throw new Error(await responseError(res));
+    const data = await res.json();
+    if (populate && document.activeElement?.id !== 'update-repository') document.getElementById('update-repository').value = data.repository || '';
+    if (populate && document.activeElement?.id !== 'update-branch') document.getElementById('update-branch').value = data.branch || 'master';
+    if (populate && document.activeElement?.id !== 'update-tls-pin') document.getElementById('update-tls-pin').value = data.tls_key_pin || '';
+    document.getElementById('update-installed').textContent = data.installed_commit || 'unknown';
+    document.getElementById('update-state').textContent = data.state || 'idle';
+    document.getElementById('update-log').textContent = data.log || '';
+  } catch (err) { showToast('Update Status', err.message, 'error'); }
+}
+async function saveUpdateConfig() {
+  try {
+    const repository = document.getElementById('update-repository').value.trim();
+    const branch = document.getElementById('update-branch').value.trim();
+    const tls_key_pin = document.getElementById('update-tls-pin').value.trim();
+    const res = await fetch(`${API_BASE}/update/config`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({repository,branch,tls_key_pin})});
+    if (!res.ok) throw new Error(await responseError(res));
+    showToast('Update Source Saved', 'Repository and branch saved', 'success');
+    await loadUpdateStatus(false);
+  } catch (err) { showToast('Update Source', err.message, 'error'); }
+}
+async function checkUpdate() {
+  try {
+    const res = await fetch(`${API_BASE}/update/check`, {method:'POST'});
+    if (!res.ok) throw new Error(await responseError(res));
+    const data = await res.json();
+    document.getElementById('update-available').textContent = data.available_commit;
+    showToast('Git Check', data.update_available ? 'An update is available' : 'Already at the selected commit', 'info');
+  } catch (err) { showToast('Git Check Failed', err.message, 'error'); }
+}
+async function startUpdate(action) {
+  if (!confirm(action === 'rollback' ? 'Restore the previous version and restart DebTik?' : 'Install the selected Git branch and restart DebTik?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/update/${action}`, {method:'POST'});
+    if (!res.ok) throw new Error(await responseError(res));
+    showToast('Update Started', 'The service will restart; reconnect after a few seconds', 'info');
+    await loadUpdateStatus(false);
+  } catch (err) { showToast('Update Failed', err.message, 'error'); }
+}

+ 57 - 30
internal/web/static/index.html

@@ -457,6 +457,8 @@
           <span id="badge-services-status" class="w-2 h-2 rounded-full bg-emerald-500"></span>
         </a>
 
+        <a href="#updates" onclick="navigate('updates')" id="nav-updates" class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium text-slate-300 hover:bg-slate-800/80 hover:text-white"><span>↻ Updates</span></a>
+
         <a href="#logs" onclick="navigate('logs')" id="nav-logs"
            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">
@@ -514,6 +516,7 @@
             <img src="debtik-emblem.svg" alt="DebTik" class="w-7 h-7 sm:hidden" />
             <div>
               <h1 id="page-title" class="text-lg font-bold text-white tracking-tight">Dashboard Overview</h1>
+              <div id="telemetry-health" role="status" aria-live="polite" class="text-[10px] text-amber-400">Waiting for fresh data</div>
               <p id="page-subtitle" class="text-xs text-slate-400">DebTik - Debian Linux Router appliance management</p>
             </div>
           </div>
@@ -1959,10 +1962,10 @@
                 <span class="px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-sky-500/20 text-sky-300 border border-sky-500/30 tracking-wide uppercase">Encrypted Tunnel</span>
                 <h2 class="text-base font-bold text-white flex items-center gap-2">
                   <span>AeroTUN High-Speed VPN</span>
-                  <span class="px-2 py-0.5 text-[10px] rounded-full bg-cyan-500/20 text-cyan-300 border border-cyan-500/40 font-mono font-normal">ChaCha20-Poly1305 + LZ4</span>
+                  <span class="px-2 py-0.5 text-[10px] rounded-full bg-cyan-500/20 text-cyan-300 border border-cyan-500/40 font-mono font-normal">XChaCha20-Poly1305 + LZ4</span>
                 </h2>
               </div>
-              <p class="text-xs text-slate-400">Custom ultra-lightweight Layer 3 IP tunnel over UDP with dynamic adaptive LZ4 compression, sliding window anti-replay protection, and roaming NAT traversal.</p>
+              <p class="text-xs text-slate-400">IP tunnels over UDP. V3 adds pinned peer identities, forward secrecy and automatic key rotation. Compression is optional.</p>
             </div>
             <div class="flex items-center gap-2.5">
               <button onclick="loadAeroTUN(true)" class="text-xs font-semibold px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition flex items-center gap-1.5" title="Refresh AeroTUN Status">
@@ -1981,7 +1984,7 @@
               <div>
                 <div class="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">Active Tunnels</div>
                 <div id="aerotun-stat-active" class="text-xl font-bold text-white font-mono mt-1">0 / 0</div>
-                <div class="text-[10px] text-slate-500 mt-0.5">Instances online</div>
+                <div class="text-[10px] text-slate-500 mt-0.5">Peers authenticated recently</div>
               </div>
               <div class="w-10 h-10 rounded-xl bg-sky-500/10 border border-sky-500/20 flex items-center justify-center text-sky-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="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
@@ -2038,7 +2041,7 @@
               <table class="w-full text-left text-xs font-mono">
                 <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
                   <tr>
-                    <th class="py-2.5 px-3 w-12 text-center" title="Flags: R=Running, X=Disabled">Flag</th>
+                    <th class="py-2.5 px-3 w-12 text-center" title="R=Authenticated peer seen within 65 seconds, X=Disabled">Flag</th>
                     <th class="py-2.5 px-3">Name</th>
                     <th class="py-2.5 px-3">Mode</th>
                     <th class="py-2.5 px-3">Port / Remote Endpoint</th>
@@ -2653,7 +2656,7 @@
               <div class="flex items-center gap-2 mb-1">
                 <span class="px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-purple-500/20 text-purple-300 border border-purple-500/30 tracking-wide uppercase">BGP Routing</span>
                 <h2 class="text-base font-bold text-white">BGP Dynamic Routing</h2>
-                <span id="bgp-live-status-pill" class="text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700">Checking...</span>
+                <span class="bgp-live-status-pill text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700">Checking...</span>
               </div>
               <p class="text-xs text-slate-400">High-Performance BIRD 2 BGP Engine: Multi-Instance / Multi-AS peering sessions, programmable route filters, prefix announcements, and BGP routing table (RIB).</p>
             </div>
@@ -2696,7 +2699,7 @@
               <div class="flex items-center gap-3">
                 <h3 class="text-sm font-bold text-white">Configured BGP Peers</h3>
                 <span id="bgp-peers-count-badge" class="text-xs font-mono text-slate-400">0 Peers</span>
-                <span id="bgp-live-status-pill" class="text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700">Checking...</span>
+                <span class="bgp-live-status-pill text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700">Checking...</span>
               </div>
               <div class="flex items-center gap-2">
                 <button onclick="loadDynamicRouting()" class="text-xs text-purple-400 hover:text-purple-300 font-semibold px-2.5 py-1 bg-slate-800 rounded border border-slate-700">Refresh</button>
@@ -3976,7 +3979,7 @@
                   <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 id="bwtest-stat-tx-mbps" title="Throughput confirmed by the receiving peer" 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">
@@ -4116,10 +4119,11 @@
                 <!-- Protocol Options: Packet Size (UDP) & Parallel Streams (TCP) -->
                 <div class="grid grid-cols-2 gap-3">
                   <div id="bwtest-field-pktsize">
-                    <label class="block text-xs font-semibold text-slate-300 mb-1">Packet Payload Size (UDP)</label>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1">Maximum UDP Datagram Size</label>
+                    <div id="bwtest-effective-payload" class="text-[10px] text-slate-400 mb-1">Negotiated to fit the route MTU</div>
                     <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="1472" selected>1472 bytes (1500-byte IP packet)</option>
+                      <option value="8972">8972 bytes (9000-byte IP packet)</option>
                       <option value="512">512 (Small Payload)</option>
                       <option value="64">64 (Min VoIP / Small)</option>
                     </select>
@@ -4140,14 +4144,14 @@
                   <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(0)" class="px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded transition border border-slate-700">Auto</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="0" selected>Auto (UDP uses receiver feedback)</option>
                     <option value="10000000">10 Mbps</option>
                     <option value="50000000">50 Mbps</option>
                     <option value="100000000">100 Mbps</option>
@@ -4157,17 +4161,8 @@
                   </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>
+                <!-- Duration -->
+                <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">
@@ -4668,6 +4663,24 @@
         </section>
 
         <!-- ================= SECTION: SYSTEM LOGS ================= -->
+        <section id="section-updates" class="hidden space-y-6">
+          <div class="bg-slate-900 border border-slate-800 rounded-2xl p-6 space-y-4 max-w-3xl">
+            <h2 class="text-lg font-bold text-white">Git Updates</h2>
+            <p class="text-xs text-slate-400">Set the source repository and branch for this appliance. Installation rebuilds DebTik, restarts the service and keeps the previous binaries for rollback.</p>
+            <label class="block text-xs text-slate-300">Repository URL<input id="update-repository" type="url" placeholder="https://git.example.com/team/DebTik.git" class="mt-1 w-full bg-slate-950 border border-slate-700 rounded-lg p-2 text-white font-mono"></label>
+            <label class="block text-xs text-slate-300">Branch<input id="update-branch" value="master" class="mt-1 w-full bg-slate-950 border border-slate-700 rounded-lg p-2 text-white font-mono"></label>
+            <label class="block text-xs text-slate-300">TLS public key pin (optional for private Git servers)<input id="update-tls-pin" placeholder="sha256//..." class="mt-1 w-full bg-slate-950 border border-slate-700 rounded-lg p-2 text-white font-mono"></label>
+            <div class="flex flex-wrap gap-2">
+              <button onclick="saveUpdateConfig()" class="bg-sky-700 text-white px-3 py-2 rounded-lg text-xs">Save source</button>
+              <button onclick="checkUpdate()" class="bg-slate-700 text-white px-3 py-2 rounded-lg text-xs">Check for updates</button>
+              <button onclick="startUpdate('install')" class="bg-emerald-700 text-white px-3 py-2 rounded-lg text-xs">Install update</button>
+              <button onclick="startUpdate('rollback')" class="bg-amber-700 text-white px-3 py-2 rounded-lg text-xs">Restore previous version</button>
+            </div>
+            <div class="text-xs font-mono text-slate-300">Installed: <span id="update-installed">—</span><br>Available: <span id="update-available">—</span><br>Status: <span id="update-state">idle</span></div>
+            <pre id="update-log" class="bg-slate-950 rounded-lg p-3 text-[11px] text-slate-300 whitespace-pre-wrap max-h-72 overflow-auto"></pre>
+          </div>
+        </section>
+
         <section id="section-logs" class="hidden space-y-6">
           <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 shadow-lg flex flex-wrap items-center justify-between gap-4">
             <div class="flex items-center gap-3">
@@ -9158,13 +9171,26 @@
       <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
         <div>
           <h3 id="aerotun-modal-title" class="text-sm font-bold text-white">Add AeroTUN Interface</h3>
-          <p class="text-[11px] text-slate-400">ChaCha20-Poly1305 AEAD + Adaptive LZ4 Tunnel</p>
+          <p class="text-[11px] text-slate-400">Noise identity handshake + XChaCha20-Poly1305</p>
         </div>
         <button onclick="closeAeroTUNModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
       </div>
-      <form onsubmit="submitSaveAeroTUN(event)" class="p-6 space-y-4 text-xs font-sans">
+      <form onsubmit="submitSaveAeroTUN(event)" class="p-6 space-y-4 text-xs font-sans max-h-[80vh] overflow-y-auto">
+        <p id="aerotun-setup-hint" role="status" class="p-3 rounded-lg bg-sky-950/40 text-sky-200"></p>
         <input type="hidden" id="aerotun-modal-id">
 
+        <div class="space-y-2">
+          <label class="block text-slate-300">Protocol</label>
+          <select id="aerotun-modal-protocol" onchange="updateAeroTUNSetupHint()" class="w-full bg-slate-800 rounded p-2"><option value="3">v3 — Noise, pinned identities and forward secrecy</option><option value="2">v2 — legacy PSK protocol</option></select>
+          <label class="block text-slate-300">Local public key</label>
+          <input id="aerotun-modal-public" readonly class="w-full bg-slate-800 rounded p-2 font-mono" placeholder="Created when you save this tunnel">
+          <button type="button" onclick="copyAeroTUNPublicKey()" class="text-sky-400 underline">Copy local public key</button>
+          <label class="block text-slate-300">Pinned peer public key</label>
+          <input id="aerotun-modal-peerkey" oninput="updateAeroTUNSetupHint()" class="w-full bg-slate-800 rounded p-2 font-mono" placeholder="Public key from the other endpoint">
+          <label class="block text-slate-300">Allowed peer source prefixes (comma separated)</label>
+          <input id="aerotun-modal-allowed" class="w-full bg-slate-800 rounded p-2 font-mono" placeholder="10.99.0.2/32, 192.168.20.0/24">
+          <p class="text-xs text-slate-400">For v3, save disabled first to create a local identity. Exchange public keys through a trusted channel, then enable both endpoints. An empty PSK on edit keeps the existing key.</p>
+        </div>
         <div class="grid grid-cols-2 gap-4">
           <div>
             <label class="block font-semibold text-slate-300 mb-1.5">Interface Name *</label>
@@ -9201,24 +9227,25 @@
           <div>
             <label class="block font-semibold text-slate-300 mb-1.5">Peer Tunnel IP (Optional CIDR)</label>
             <input type="text" id="aerotun-modal-peer" placeholder="10.99.0.2/24" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-sky-500">
-            <span class="text-[10px] text-slate-400">Expected peer overlay IP</span>
+            <span class="text-[10px] text-slate-400">Adds a host route to the peer overlay IP</span>
           </div>
         </div>
 
         <div>
           <div class="flex items-center justify-between mb-1.5">
             <label class="block font-semibold text-slate-300">Pre-Shared Key (PSK) *</label>
+            <button type="button" onclick="togglePasswordVisibility('aerotun-modal-psk')" class="text-[11px] text-slate-300 underline">Show / hide PSK</button>
             <button type="button" onclick="generateAeroTUNKey()" class="text-[11px] text-sky-400 hover:text-sky-300 underline font-mono">⚡ Generate Key</button>
           </div>
-          <input type="text" id="aerotun-modal-psk" placeholder="32-byte hexadecimal pre-shared secret" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-sky-500">
-          <span class="text-[10px] text-slate-400">Both ends must use identical 256-bit symmetric key for ChaCha20-Poly1305</span>
+          <input type="password" autocomplete="new-password" id="aerotun-modal-psk" placeholder="Leave blank when editing to retain the existing key" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-sky-500">
+          <span class="text-[10px] text-slate-400">Both ends must use the same random 256-bit PSK and protocol version. V3 also requires pinned peer public keys.</span>
         </div>
 
         <div class="grid grid-cols-2 gap-4">
           <div>
             <label class="block font-semibold text-slate-300 mb-1.5">MTU</label>
             <input type="number" id="aerotun-modal-mtu" value="1420" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-sky-500">
-            <span class="text-[10px] text-slate-400">Default: 1420 bytes</span>
+            <span class="text-[10px] text-slate-400">Maximum requested MTU; reduced to fit the local underlay</span>
           </div>
           <div>
             <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
@@ -9228,7 +9255,7 @@
 
         <div class="flex flex-wrap items-center justify-between pt-2 border-t border-slate-800/80 gap-3">
           <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
-            <input type="checkbox" id="aerotun-modal-compression" checked class="rounded bg-slate-800 border-slate-700 text-sky-600">
+            <input type="checkbox" id="aerotun-modal-compression" class="rounded bg-slate-800 border-slate-700 text-sky-600">
             <span class="font-medium">Adaptive LZ4 Compression</span>
           </label>
           <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">

+ 79 - 0
scripts/debtik-update

@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+umask 077
+base=/var/lib/debtik/update
+mkdir -p "$base"
+exec 9>"$base/lock"
+flock -n 9 || { echo 'An update is already running' >&2; exit 1; }
+exec >>"$base/update.log" 2>&1
+state() { printf '%s\n' "$1" >"$base/status"; printf '[%s] %s\n' "$(date -u +%FT%TZ)" "$1"; }
+cleanup_veth() {
+  python3 - <<'PY'
+import json,subprocess,pathlib
+p=pathlib.Path('/etc/proxmox-router/vpn/aerotun_instances.json')
+if p.exists():
+ for tunnel in json.loads(p.read_text()):
+  name=tunnel['name']
+  result=subprocess.run(['ip','-j','-d','link','show','dev',name],capture_output=True,text=True)
+  if result.returncode:
+   continue
+  links=json.loads(result.stdout)
+  if len(links)!=1:raise RuntimeError('Ambiguous AeroTUN interface')
+  link=links[0]
+  if link.get('linkinfo',{}).get('info_kind')=='veth':
+   if link.get('link')!=name+'_ep':raise RuntimeError('Unexpected veth peer')
+   subprocess.run(['ip','link','delete',name],check=True)
+PY
+}
+restore() {
+  state rolling_back
+  systemctl stop proxmox-router.service || true
+  cleanup_veth
+  for name in proxmox-router debtik-cli; do
+    test -f "$base/previous/$name"
+    cp -p "$base/previous/$name" "/usr/local/bin/$name.rollback"
+    mv -f "/usr/local/bin/$name.rollback" "/usr/local/bin/$name"
+  done
+  systemctl start proxmox-router.service
+  systemctl is-active --quiet proxmox-router.service
+  state rolled_back
+}
+if [[ ${1:-} == rollback ]]; then restore; exit; fi
+[[ $# == 4 && $1 == install ]] || { echo 'Usage: debtik-update install URL BRANCH | rollback' >&2; exit 2; }
+repo=$2 branch=$3 pin=$4
+git_opts=()
+if [[ -n $pin ]]; then
+  [[ $pin =~ ^sha256//[A-Za-z0-9+/]{43}=$ ]] || exit 2
+  git_opts=(-c http.sslVerify=false -c "http.pinnedpubkey=$pin")
+fi
+[[ $repo == http://* || $repo == https://* || $repo == ssh://* ]] || exit 2
+[[ $branch =~ ^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$ && $branch != *..* ]] || exit 2
+work=$(mktemp -d "$base/work.XXXXXXXX")
+trap 'rm -rf "$work"' EXIT
+trap 'state failed' ERR
+state fetching
+GIT_TERMINAL_PROMPT=0 git -c protocol.file.allow=never "${git_opts[@]}" clone --depth 1 --single-branch --branch "$branch" -- "$repo" "$work/source"
+cd "$work/source"
+commit=$(git rev-parse HEAD)
+state building
+go_cmd=$(command -v go || true)
+if [[ -x /opt/debtik/go/bin/go ]]; then go_cmd=/opt/debtik/go/bin/go; fi
+[[ -n $go_cmd ]] || { state failed; echo 'Go is not installed'; exit 1; }
+CGO_ENABLED=0 "$go_cmd" build -trimpath -ldflags "-X proxmox-router/internal/api.BuildCommit=$commit" -o "$work/proxmox-router" ./cmd/server
+CGO_ENABLED=0 "$go_cmd" build -trimpath -o "$work/debtik-cli" ./cmd/debtik-cli
+install -d -m 0700 "$base/previous"
+for name in proxmox-router debtik-cli; do cp -p "/usr/local/bin/$name" "$base/previous/$name"; done
+printf '%s\n' "$commit" > "$base/candidate_commit"
+state installing
+rollback_on_error() { trap - ERR; restore || state failed; exit 1; }
+trap rollback_on_error ERR
+systemctl stop proxmox-router.service
+cleanup_veth
+for name in proxmox-router debtik-cli; do
+  install -m 0755 "$work/$name" "/usr/local/bin/$name.next"
+  mv -f "/usr/local/bin/$name.next" "/usr/local/bin/$name"
+done
+systemctl start proxmox-router.service
+sleep 3
+systemctl is-active --quiet proxmox-router.service
+state complete

+ 49 - 0
scripts/ui-regression.test.cjs

@@ -0,0 +1,49 @@
+const {test} = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+const source = fs.readFileSync(path.join(__dirname,'../internal/web/static/app.js'),'utf8');
+function functionSource(name,next) {
+  return source.slice(source.indexOf('function '+name+'('),source.indexOf('\nfunction '+next+'('));
+}
+
+test('bandwidth cursor follows elapsed time across a rolling history and a new session',()=>{
+  const seen=[];
+  const context={bwtestState:{lastSampleElapsed:-1},handleBwTestSample:s=>seen.push(s.elapsed_sec),clearInterval(){},setBwTestRunningUI(){},handleBwTestFinish(){}};
+  vm.createContext(context);
+  vm.runInContext(functionSource('processBwTestStatus','startBwTestTelemetry'),context);
+  const window=offset=>Array.from({length:200},(_,i)=>({elapsed_sec:i+offset}));
+  context.processBwTestStatus({session_id:'one',running:true,recent_samples:window(1)});
+  context.processBwTestStatus({session_id:'one',running:true,recent_samples:window(2)});
+  assert.equal(seen.length,201);
+  assert.equal(seen.at(-1),201);
+  context.processBwTestStatus({session_id:'two',running:true,recent_samples:[{elapsed_sec:0.5}]});
+  assert.equal(seen.at(-1),0.5);
+});
+
+test('fetch coalesces API reads, copies responses, and keeps bearer tokens on this origin',async()=>{
+  const requests=[];
+  const localStorage={getItem:()=> 'test-token',removeItem(){}};
+  const context={URL,Headers,Request,Response,AbortController,setTimeout,clearTimeout,console,localStorage,document:{getElementById:()=>null}};
+  context.window={location:{href:'http://router/',origin:'http://router'},fetch:async(resource,options)=>{
+    requests.push({resource,options});
+    await new Promise(resolve=>setTimeout(resolve,10));
+    return new Response('{"ok":true}',{headers:{'content-type':'application/json'}});
+  }};
+  vm.createContext(context);
+  vm.runInContext(source.slice(source.indexOf('const originalFetch'),source.indexOf('\nlet state ='))+'\nlet state = {currentUser:null};',context);
+  const responses=await Promise.all([context.window.fetch('/api/status'),context.window.fetch('/api/status')]);
+  assert.equal(requests.length,1);
+  assert.deepEqual(await responses[0].json(),{ok:true});
+  assert.deepEqual(await responses[1].json(),{ok:true});
+  assert.equal(requests[0].options.headers.get('Authorization'),'Bearer test-token');
+  await context.window.fetch('https://another-origin.example/path');
+  assert.equal(requests[1].options.headers,undefined);
+});
+
+test('HTML contains no duplicate IDs',()=>{
+  const html=fs.readFileSync(path.join(__dirname,'../internal/web/static/index.html'),'utf8');
+  const ids=[...html.matchAll(/\bid="([^"]+)"/g)].map(match=>match[1]);
+  assert.equal(new Set(ids).size,ids.length);
+});

+ 104 - 0
scripts/ui-smoke.cjs

@@ -0,0 +1,104 @@
+const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const root = process.env.UI_TEST_ARTIFACTS || '/tmp/debtik-ui-tests';
+fs.mkdirSync(root,{recursive:true});
+(async () => {
+  const browser = await chromium.launch({headless:true,args:['--no-sandbox'],env:process.env});
+  const context = await browser.newContext({viewport:{width:1440,height:1000}});
+  const page = await context.newPage();
+  const errors = [], consoleErrors = [], badResponses = [];
+  page.on('pageerror', e => errors.push(e.message));
+  page.on('console', m => { if(m.type()==='error') consoleErrors.push(m.text()); });
+  page.on('response', r => { if(r.status()>=400) badResponses.push({path:new URL(r.url()).pathname,status:r.status()}); });
+  const results = [];
+  try {
+    await page.goto(process.env.DEBTIK_TEST_URL || 'http://127.0.0.1:8080', {waitUntil:'domcontentloaded'});
+    await page.locator('#login-username').fill(process.env.DEBTIK_TEST_USER || 'admin');
+    await page.locator('#login-password').fill(process.env.DEBTIK_TEST_PASSWORD || 'admin');
+    await page.locator('#login-submit-btn').click();
+    await page.waitForFunction(() => state.currentUser !== null);
+    await page.evaluate(() => fetchData(false));
+    const tabs = ['dashboard','interfaces','bridge','firewall','routes','bgp','ospf','routing-filters','ppp','eoip','aerotun','pppoe','wireguard','ipsec','l2tp','pptp','dhcp-client','dhcp-server','vrrp','tools','bwtest','settings','users','certificates','services','logs'];
+    for(const tab of tabs) {
+      await page.evaluate(async tab => { navigate(tab); await fetchData(false); }, tab);
+      await page.waitForTimeout(200);
+      assert.equal(await page.locator('#section-'+tab).isVisible(),true,tab+' not visible');
+      results.push('view '+tab);
+    }
+    await page.evaluate(() => {
+      state.bgpInstance={enabled:true,running:true,as:64512};renderBgpStatusPill();
+    });
+    const pills=await page.locator('.bgp-live-status-pill').allTextContents();
+    assert.equal(pills.length,2);assert.ok(pills.every(t=>t==='BGP ACTIVE (AS 64512)'));
+    results.push('both BGP badges update');
+    await page.evaluate(() => {
+      const sample = i => ({elapsed_sec:i/2,tx_mbps:1,rx_mbps:2,tx_bytes:i*100,rx_bytes:i*200,tx_packets:i,rx_packets:i,loss_pct:0,jitter_ms:0,timestamp:new Date().toISOString()});
+      processBwTestStatus({session_id:'audit',running:true,recent_samples:Array.from({length:200},(_,i)=>sample(i+1))});
+      processBwTestStatus({session_id:'audit',running:true,recent_samples:Array.from({length:200},(_,i)=>sample(i+2))});
+    });
+    assert.equal(await page.evaluate(()=>bwtestState.lastSampleElapsed),100.5);
+    results.push('rolling sample window continues after 200 samples');
+    let count=0;
+    await page.route('**/api/review/dedup', async route => {count++;await new Promise(r=>setTimeout(r,100));await route.fulfill({json:{ok:true}})});
+    const values=await page.evaluate(async()=>Promise.all([fetch('/api/review/dedup').then(r=>r.json()),fetch('/api/review/dedup').then(r=>r.json())]));
+    assert.equal(count,1);assert.deepEqual(values,[{ok:true},{ok:true}]);
+    results.push('concurrent GET deduplication and response copies');
+    await page.route('**/api/tools/bwtest/stop',route=>route.fulfill({status:403,json:{message:'Denied for review'}}));
+    await page.evaluate(async()=>{document.getElementById('toast-container').innerHTML='';await stopBwTest()});
+    assert.match(await page.locator('#toast-container').innerText(),/Stop Failed/);
+    assert.doesNotMatch(await page.locator('#toast-container').innerText(),/Stop Requested/);
+    results.push('rejected Stop is reported as failure');
+    await page.route('**/api/review/failure',route=>route.fulfill({status:503,body:'unavailable'}));
+    await page.evaluate(()=>fetch('/api/review/failure'));
+    assert.match(await page.locator('#telemetry-health').innerText(),/unavailable/);
+    await page.unroute('**/api/review/failure');
+    await page.route('**/api/review/failure',route=>route.fulfill({json:{ok:true}}));
+    await page.evaluate(()=>fetch('/api/review/failure'));
+    assert.doesNotMatch(await page.locator('#telemetry-health').getAttribute('title'),/review\/failure/);
+    results.push('failed telemetry visible and cleared after recovery');
+    await page.evaluate(()=>{navigate('aerotun');openAddAeroTUNModal()});
+    await page.waitForFunction(()=>document.getElementById('aerotun-modal-psk').value.length>0);
+    assert.match(await page.locator('#aerotun-setup-hint').innerText(),/Save disabled/);
+    assert.equal(await page.locator('#aerotun-modal-disabled').isChecked(),true);
+    const existing = await page.evaluate(() => state.aerotunTunnels || []);
+    let draft;
+    await page.route('**/api/vpn/aerotun/tunnels', async route => {
+      if (route.request().method() === 'POST') {
+        const request = route.request().postDataJSON();
+        assert.equal(request.disabled,true);
+        assert.equal(request.protocol_version,3);
+        assert.equal('private_key' in request,false);
+        draft = {...request,id:'audit-draft',public_key:'audit-public-key',has_psk:true};
+        delete draft.psk;
+        await route.fulfill({json:draft});
+      } else await route.fulfill({json:[...existing,...(draft?[draft]:[])]});
+    });
+    await page.evaluate(()=>submitSaveAeroTUN({preventDefault(){}}));
+    assert.equal(await page.locator('#aerotun-modal-id').inputValue(),'audit-draft');
+    assert.equal(await page.locator('#aerotun-modal-public').inputValue(),'audit-public-key');
+    assert.equal(await page.locator('#aerotun-modal-psk').inputValue(),'');
+    assert.equal(await page.locator('#aerotun-modal').isVisible(),true);
+    results.push('saved AeroTUN draft stays open and presents generated identity');
+    await page.screenshot({path:root+'/aerotun-setup.png'});
+    await page.evaluate(()=>closeAeroTUNModal());
+    results.push('AeroTUN guided setup and disabled default');
+    await page.evaluate(()=>{navigate('bwtest');openBwTestServerModal()});
+    await page.locator('#bwtest-server-port-input').fill('34567');
+    await page.evaluate(()=>fetchBwTestServerStatus());
+    assert.equal(await page.locator('#bwtest-server-port-input').inputValue(),'34567');
+    await page.evaluate(()=>closeBwTestServerModal());
+    results.push('telemetry preserves unsaved server modal values');
+    await page.evaluate(()=>handleLogoutClick());
+    assert.equal(await page.evaluate(()=>!!window._telemetryInterval || !!trafficEventSource || !!trafficPollFallback || !!bwtestState.pollTimer || !!logsTimer || !!state.fwStatInterval || !!state.natStatInterval || !!state.mangleStatInterval),false);
+    results.push('logout stops all session pollers');
+    assert.deepEqual(errors,[],'browser runtime errors');
+    assert.ok(consoleErrors.every(e => e.startsWith('Failed to load resource:')), 'Unexpected application console error');
+    assert.ok(badResponses.every(r => ['/api/auth/me','/api/tools/bwtest/stop','/api/review/failure'].includes(r.path)), 'Unexpected failed API request');
+    console.log(JSON.stringify({passed:results,errors,consoleErrors,badResponses},null,2));
+  } catch(error) {
+    await page.screenshot({path:root+'/browser-failure.png'}).catch(()=>{});
+    console.log(JSON.stringify({passed:results,errors,consoleErrors,badResponses},null,2));
+    throw error;
+  } finally { await browser.close(); }
+})().catch(error=>{console.error(error);process.exitCode=1});