Explorar el Código

Add AeroTUN health history and diagnostics

Proxmox Router hace 2 días
padre
commit
cf65c2f398

+ 3 - 3
README.md

@@ -2,7 +2,7 @@
 
 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.
 
-Current release: **1.3.8**. See [functionality corrections and verification](docs/functionality-fixes.md) for network persistence, firewall isolation/IPv6, service error handling and certificate renewal changes.
+Current release: **1.3.9**. See [functionality corrections and verification](docs/functionality-fixes.md) for network persistence, firewall isolation/IPv6, service error handling and certificate renewal changes.
 
 ## Install
 
@@ -21,10 +21,10 @@ The internal Git server uses a private CA. Trust that CA before cloning, or repl
 ```sh
 git -c http.sslVerify=false \
   -c 'http.pinnedpubkey=sha256//u4xyt0njDzn4BAQnS6OTGrXBxVE8vMg5BSNIuryUsMo=' \
-  clone --depth 1 --branch v1.3.8 https://gogs.eparmak.local/eparmak/DebTik.git
+  clone --depth 1 --branch v1.3.9 https://gogs.eparmak.local/eparmak/DebTik.git
 ```
 
-This verifies the server's pinned key for this command; it does not change global Git settings. Reverify the pin if the server's TLS key changes. For a reproducible fresh installation, `v1.3.8` contains the complete dependency installer and native Linux firewall corrections.
+This verifies the server's pinned key for this command; it does not change global Git settings. Reverify the pin if the server's TLS key changes. For a reproducible fresh installation, `v1.3.9` contains the complete dependency installer and native Linux firewall corrections.
 
 `install.sh` installs and verifies the dependencies listed in [Dependencies](docs/dependencies.md), including Go, WireGuard, firewall, BIRD, DHCP/DNS, PPP clients and servers, strongSwan plugins, diagnostics and the RSTP daemon. It builds `proxmox-router` and `debtik-cli`, installs the updater helpers, 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.
 

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.3.8
+1.3.9

+ 33 - 0
docs/aerotun-v3.md

@@ -126,6 +126,39 @@ 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.
 
+## Health, MTU and performance diagnostics (1.3.9)
+
+The health panel reports the configured and effective MTU, backend, authenticated
+peer status, last authenticated packet, TX/RX payload rates, packet rates,
+aggregate drops, v3 drop categories, authentication and replay counters. CPU
+is for the entire DebTik process, expressed as a percentage of one core; it is
+not a per-tunnel CPU measurement. Rates are sampled while the UI is open.
+
+The manager checks authenticated peer state every two seconds. State changes and
+on-demand checks are saved in `aerotun_health.json` next to the tunnel config,
+mode 0600, capped at the latest 500 events. Intervals while the service is down
+are not monitored. The history contains no key material. Health probes require
+full administrator privileges and send at most two ICMP echo packets through
+the overlay: a small echo for RTT and a do-not-fragment echo sized to the current
+MTU. A firewall may block ICMP; failed probes are inconclusive, not proof of a
+down tunnel. A successful overlay probe does not prove that the outer UDP path
+never fragments. The probe neither adjusts MTU nor changes routes; this is not
+full automatic path-MTU discovery.
+
+The optional TCP comparison runs four user-triggered tests: tunnel TX/RX and
+direct-address TX/RX. They can consume the available bandwidth. The direct
+address follows the system routing table; the operator must verify that both
+addresses reach the same test server over distinct routes. Comparisons currently
+support IPv4 targets, do not interrupt existing tests, and stop after the current
+test when requested. Previous tests are not automatically repeated.
+
+A CPU profile of the local XChaCha round-trip benchmark was dominated by the
+existing crypto implementation (about 80% in seal/open in that sample). The v3
+batch-send counters now aggregate each successful batch before atomic updates;
+this does not change the wire protocol. Short veth benchmarks vary with host
+load, and no production throughput gain is assumed. Native TUN could not be
+benchmarked in the current container because `/dev/net/tun` is unavailable.
+
 ## Validation and observed results
 
 Both full repository tests and `go vet` pass. API regression tests cover redaction,

+ 94 - 0
internal/aerotun/diagnostics.go

@@ -0,0 +1,94 @@
+package aerotun
+
+import (
+	"context"
+	"fmt"
+	"os/exec"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type HealthCheck struct {
+	TunnelID      string    `json:"tunnel_id"`
+	CheckedAt     time.Time `json:"checked_at"`
+	Reachability  string    `json:"reachability"`
+	RTTMillis     *float64  `json:"rtt_ms,omitempty"`
+	RTTUpperBound bool      `json:"rtt_upper_bound,omitempty"`
+	MTU           int       `json:"mtu"`
+	MTUProbe      string    `json:"mtu_probe"`
+	Explanation   string    `json:"explanation"`
+}
+
+var pingTime = regexp.MustCompile(`time([=<])([0-9]+(?:\.[0-9]+)?)\s*ms`)
+
+// CheckHealth sends at most two ICMP echoes over the configured overlay.
+// A failed echo is inconclusive: peer firewalls may block ICMP. It never
+// changes link MTU or routing and does not probe the public underlay.
+func (m *Manager) CheckHealth(ctx context.Context, id string) (HealthCheck, error) {
+	if m.health != nil {
+		if !m.health.checkMu.TryLock() {
+			return HealthCheck{}, fmt.Errorf("another health check is already running")
+		}
+		defer m.health.checkMu.Unlock()
+	}
+	t, ok := m.Get(id)
+	if !ok {
+		return HealthCheck{}, fmt.Errorf("tunnel not found")
+	}
+	if !t.Running || !t.Connected {
+		return HealthCheck{}, fmt.Errorf("tunnel is not connected")
+	}
+	ip, err := peerIP(t.PeerAddress)
+	if err != nil || ip == nil {
+		return HealthCheck{}, fmt.Errorf("configure the peer tunnel IP before running a health check")
+	}
+	if !interfaceName.MatchString(t.Name) {
+		return HealthCheck{}, fmt.Errorf("invalid interface name")
+	}
+	if _, err = exec.LookPath("ping"); err != nil {
+		return HealthCheck{}, fmt.Errorf("ping is not installed")
+	}
+	family := "-4"
+	overhead := 28
+	if ip.To4() == nil {
+		family = "-6"
+		overhead = 48
+	}
+	result := HealthCheck{TunnelID: id, CheckedAt: time.Now(), Reachability: "inconclusive", MTU: t.MTU, MTUProbe: "inconclusive", Explanation: "ICMP may be filtered; a failed probe does not prove the tunnel is down. A successful overlay probe does not prove the outer path avoids fragmentation."}
+	probe := func(size int) ([]byte, error) {
+		return exec.CommandContext(ctx, "ping", family, "-n", "-c", "1", "-W", "2", "-I", t.Name, "-M", "do", "-s", strconv.Itoa(size), ip.String()).CombinedOutput()
+	}
+	out, err := probe(56)
+	if err == nil {
+		result.Reachability = "reachable"
+		if match := pingTime.FindStringSubmatch(string(out)); match != nil {
+			v, e := strconv.ParseFloat(match[2], 64)
+			if e == nil {
+				result.RTTMillis = &v
+				result.RTTUpperBound = match[1] == "<"
+			}
+		}
+	}
+	if ctx.Err() != nil {
+		return HealthCheck{}, ctx.Err()
+	}
+	if t.MTU > overhead {
+		if _, err = probe(t.MTU - overhead); err == nil {
+			result.MTUProbe = "reachable"
+		}
+	}
+	if ctx.Err() != nil {
+		return HealthCheck{}, ctx.Err()
+	}
+	if m.health != nil {
+		m.health.mu.Lock()
+		m.health.addLocked(HealthEvent{Time: result.CheckedAt, TunnelID: id, Name: t.Name, Type: "check", Status: result.Reachability, Detail: strings.TrimSpace(fmt.Sprintf("Overlay echo: %s; MTU-size echo: %s", result.Reachability, result.MTUProbe))})
+		if err := m.health.saveLocked(); err != nil {
+			fmt.Printf("AeroTUN health history: %v\n", err)
+		}
+		m.health.mu.Unlock()
+	}
+	return result, nil
+}

+ 218 - 0
internal/aerotun/health.go

@@ -0,0 +1,218 @@
+package aerotun
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sync"
+	"time"
+
+	"golang.org/x/sys/unix"
+)
+
+const healthHistoryLimit = 500
+
+type HealthEvent struct {
+	Time                    time.Time `json:"time"`
+	TunnelID                string    `json:"tunnel_id"`
+	Name                    string    `json:"name"`
+	Type                    string    `json:"type"`
+	Status                  string    `json:"status"`
+	Previous                string    `json:"previous,omitempty"`
+	PreviousDurationSeconds float64   `json:"previous_duration_seconds,omitempty"`
+	Detail                  string    `json:"detail,omitempty"`
+}
+
+type healthState struct {
+	name, status string
+	since        time.Time
+}
+type healthMonitor struct {
+	mu             sync.Mutex
+	checkMu        sync.Mutex
+	path           string
+	events         []HealthEvent
+	states         map[string]healthState
+	done, stopped  chan struct{}
+	once           sync.Once
+	lastCPUSeconds float64
+	lastCPUAt      time.Time
+	processCPU     *float64
+}
+
+func newHealthMonitor(path string) *healthMonitor {
+	h := &healthMonitor{path: path, states: make(map[string]healthState), done: make(chan struct{}), stopped: make(chan struct{})}
+	if data, err := os.ReadFile(path); err == nil && len(data) <= 1024*1024 {
+		_ = json.Unmarshal(data, &h.events)
+	}
+	if len(h.events) > healthHistoryLimit {
+		h.events = h.events[len(h.events)-healthHistoryLimit:]
+	}
+	return h
+}
+func (h *healthMonitor) stop() { h.once.Do(func() { close(h.done) }); <-h.stopped }
+func tunnelHealthStatus(t TunnelInstance) string {
+	if t.Disabled {
+		return "disabled"
+	}
+	if !t.Running {
+		if t.LastError != "" {
+			return "error"
+		}
+		return "stopped"
+	}
+	if t.Connected {
+		return "connected"
+	}
+	return "waiting"
+}
+func (h *healthMonitor) addLocked(e HealthEvent) {
+	h.events = append(h.events, e)
+	if len(h.events) > healthHistoryLimit {
+		h.events = append([]HealthEvent(nil), h.events[len(h.events)-healthHistoryLimit:]...)
+	}
+}
+func (h *healthMonitor) saveLocked() error {
+	data, err := json.Marshal(h.events)
+	if err != nil {
+		return err
+	}
+	if err = os.MkdirAll(filepath.Dir(h.path), 0700); err != nil {
+		return err
+	}
+	f, err := os.CreateTemp(filepath.Dir(h.path), ".aerotun-health-*")
+	if err != nil {
+		return err
+	}
+	defer os.Remove(f.Name())
+	if err = f.Chmod(0600); err == nil {
+		_, err = f.Write(data)
+	}
+	if err == nil {
+		err = f.Sync()
+	}
+	if closeErr := f.Close(); err == nil {
+		err = closeErr
+	}
+	if err != nil {
+		return err
+	}
+	return os.Rename(f.Name(), h.path)
+}
+func (h *healthMonitor) observe(rows []TunnelInstance, now time.Time) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	var usage unix.Rusage
+	if unix.Getrusage(unix.RUSAGE_SELF, &usage) == nil {
+		seconds := float64(usage.Utime.Sec) + float64(usage.Utime.Usec)/1e6 + float64(usage.Stime.Sec) + float64(usage.Stime.Usec)/1e6
+		elapsed := now.Sub(h.lastCPUAt).Seconds()
+		if !h.lastCPUAt.IsZero() && elapsed > 0 && seconds >= h.lastCPUSeconds {
+			v := (seconds - h.lastCPUSeconds) / elapsed * 100
+			h.processCPU = &v
+		}
+		h.lastCPUAt = now
+		h.lastCPUSeconds = seconds
+	}
+	seen := make(map[string]bool)
+	changed := false
+	for _, row := range rows {
+		seen[row.ID] = true
+		status := tunnelHealthStatus(row)
+		old, ok := h.states[row.ID]
+		if !ok || old.status != status {
+			e := HealthEvent{Time: now, TunnelID: row.ID, Name: row.Name, Type: "state", Status: status, Detail: row.LastError}
+			if ok {
+				e.Previous = old.status
+				e.PreviousDurationSeconds = now.Sub(old.since).Seconds()
+			}
+			h.addLocked(e)
+			h.states[row.ID] = healthState{name: row.Name, status: status, since: now}
+			changed = true
+		} else if old.name != row.Name {
+			old.name = row.Name
+			h.states[row.ID] = old
+		}
+	}
+	for id, old := range h.states {
+		if !seen[id] {
+			h.addLocked(HealthEvent{Time: now, TunnelID: id, Name: old.name, Type: "state", Status: "removed", Previous: old.status, PreviousDurationSeconds: now.Sub(old.since).Seconds()})
+			delete(h.states, id)
+			changed = true
+		}
+	}
+	if changed {
+		if err := h.saveLocked(); err != nil {
+			fmt.Fprintf(os.Stderr, "AeroTUN health history: %v\n", err)
+		}
+	}
+}
+func (h *healthMonitor) history(id string) []HealthEvent {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	out := make([]HealthEvent, 0, len(h.events))
+	for i := len(h.events) - 1; i >= 0; i-- {
+		if id == "" || h.events[i].TunnelID == id {
+			out = append(out, h.events[i])
+		}
+	}
+	return out
+}
+func (h *healthMonitor) status(id string) (string, time.Time) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	s := h.states[id]
+	return s.status, s.since
+}
+func (m *Manager) monitorHealth() {
+	defer close(m.health.stopped)
+	tick := time.NewTicker(2 * time.Second)
+	defer tick.Stop()
+	for {
+		m.health.observe(m.List(), time.Now())
+		select {
+		case <-m.health.done:
+			return
+		case <-tick.C:
+		}
+	}
+}
+func (m *Manager) HealthHistory(id string) []HealthEvent {
+	if m.health == nil {
+		return []HealthEvent{}
+	}
+	return m.health.history(id)
+}
+func (m *Manager) ProcessCPUPercent() *float64 {
+	if m.health == nil {
+		return nil
+	}
+	m.health.mu.Lock()
+	defer m.health.mu.Unlock()
+	if m.health.processCPU == nil {
+		return nil
+	}
+	v := *m.health.processCPU
+	return &v
+}
+
+type HealthSummary struct {
+	TunnelID string    `json:"tunnel_id"`
+	Status   string    `json:"status"`
+	Since    time.Time `json:"since"`
+}
+
+func (m *Manager) HealthSummaries() []HealthSummary {
+	rows := m.List()
+	out := make([]HealthSummary, 0, len(rows))
+	for _, t := range rows {
+		status, since := tunnelHealthStatus(t), time.Time{}
+		if m.health != nil {
+			if recorded, at := m.health.status(t.ID); recorded == status {
+				since = at
+			}
+		}
+		out = append(out, HealthSummary{TunnelID: t.ID, Status: status, Since: since})
+	}
+	return out
+}

+ 59 - 0
internal/aerotun/health_test.go

@@ -0,0 +1,59 @@
+package aerotun
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestHealthHistoryTransitionsAndPersistence(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "history.json")
+	h := newHealthMonitor(path)
+	now := time.Now().UTC()
+	tunnel := TunnelInstance{ID: "id1", Name: "test0", Running: true, PSK: "private-psk", PrivateKey: "private-identity"}
+	h.observe([]TunnelInstance{tunnel}, now)
+	h.observe([]TunnelInstance{tunnel}, now.Add(time.Second))
+	tunnel.Connected = true
+	h.observe([]TunnelInstance{tunnel}, now.Add(3*time.Second))
+	h.observe(nil, now.Add(8*time.Second))
+	rows := h.history("id1")
+	if len(rows) != 3 || rows[0].Status != "removed" || rows[1].Status != "connected" || rows[1].Previous != "waiting" || rows[1].PreviousDurationSeconds != 3 {
+		t.Fatalf("bad transitions: %+v", rows)
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if strings.Contains(string(data), "private-") {
+		t.Fatal("history contains secret")
+	}
+	info, _ := os.Stat(path)
+	if info.Mode().Perm() != 0600 {
+		t.Fatalf("unsafe permissions: %v", info.Mode())
+	}
+	reloaded := newHealthMonitor(path)
+	if got := reloaded.history("id1"); len(got) != 3 {
+		t.Fatalf("history not restored: %+v", got)
+	}
+}
+func TestHealthStatusDistinguishesDisabledErrorWaitingAndConnected(t *testing.T) {
+	for _, tc := range []struct {
+		tunnel TunnelInstance
+		want   string
+	}{{TunnelInstance{Disabled: true}, "disabled"}, {TunnelInstance{LastError: "failed"}, "error"}, {TunnelInstance{}, "stopped"}, {TunnelInstance{Running: true}, "waiting"}, {TunnelInstance{Running: true, Connected: true}, "connected"}} {
+		if got := tunnelHealthStatus(tc.tunnel); got != tc.want {
+			t.Fatalf("got %s want %s", got, tc.want)
+		}
+	}
+}
+
+func TestDropBreakdownCountsOnlyReportedPackets(t *testing.T) {
+	r := &TunnelRunner{}
+	r.recordDrop(&r.dropSourcePolicy, 2)
+	r.recordDrop(&r.dropSendFailure, 3)
+	if r.droppedPackets != 5 || r.dropSourcePolicy != 2 || r.dropSendFailure != 3 || r.dropInvalidIncoming != 0 {
+		t.Fatalf("incorrect breakdown: %+v", r)
+	}
+}

+ 11 - 0
internal/aerotun/integration_test.go

@@ -105,9 +105,20 @@ func runNamespacePeer(t *testing.T) {
 	}
 	_ = os.WriteFile(filepath.Join(dir, role+".ready"), nil, 0600)
 	deadline := time.Now().Add(45 * time.Second)
+	checked := false
 	for time.Now().Before(deadline) {
 		if r.GetTelemetry().Connected && r.GetTelemetry().MTU == expectedMTU {
 			_ = os.WriteFile(filepath.Join(dir, role+".connected"), nil, 0600)
+			if !checked && role == "client" && os.Getenv("AEROTUN_HEALTH_CHECK") == "1" {
+				checked = true
+				m := &Manager{instances: map[string]TunnelInstance{"health-test": cfg}, runners: map[string]*TunnelRunner{"health-test": r}}
+				ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second)
+				check, e := m.CheckHealth(ctx, "health-test")
+				cancel()
+				if e != nil || check.Reachability != "reachable" || check.MTUProbe != "reachable" || check.RTTMillis == nil {
+					t.Fatalf("health probe failed: %+v %v", check, e)
+				}
+			}
 		}
 		if _, err := os.Stat(filepath.Join(dir, "stop")); err == nil {
 			stats := r.GetTelemetry()

+ 8 - 0
internal/aerotun/manager.go

@@ -17,6 +17,7 @@ type Manager struct {
 	runners   map[string]*TunnelRunner
 	errors    map[string]string
 	filePath  string
+	health    *healthMonitor
 }
 
 func NewManager() *Manager { return NewManagerWithPath(StoragePath) }
@@ -42,6 +43,8 @@ func NewManagerWithPath(path string) *Manager {
 			}
 		}
 	}
+	m.health = newHealthMonitor(filepath.Join(filepath.Dir(path), "aerotun_health.json"))
+	go m.monitorHealth()
 
 	return m
 }
@@ -77,6 +80,8 @@ func (m *Manager) saveLocked() error {
 		copyInst.Running = false
 		copyInst.Handshakes = 0
 		copyInst.Backend = ""
+		copyInst.ConfiguredMTU = 0
+		copyInst.DropBreakdown = nil
 		copyInst.HasPSK = false
 		copyInst.HasPrivateKey = false
 		copyInst.Connected = false
@@ -339,6 +344,9 @@ func (m *Manager) startRunnerLocked(id string) error {
 }
 
 func (m *Manager) StopAll() {
+	if m.health != nil {
+		m.health.stop()
+	}
 	m.mu.Lock()
 	defer m.mu.Unlock()
 

+ 29 - 17
internal/aerotun/secure_io.go

@@ -66,7 +66,7 @@ func (t *TunnelRunner) txLoopSecure() {
 				continue
 			}
 			if n == 0 || n > int(t.effectiveMTU.Load()) || !validIPPacket(p.buf[secureHeaderSize:secureHeaderSize+n]) {
-				atomic.AddUint64(&t.droppedPackets, 1)
+				t.recordDrop(&t.dropInvalidOutgoing, 1)
 				pool <- p
 				continue
 			}
@@ -134,13 +134,13 @@ func (t *TunnelRunner) txLoopSecure() {
 				}
 			}
 			if target == nil {
-				atomic.AddUint64(&t.droppedPackets, 1)
+				t.recordDrop(&t.dropMissingSession, 1)
 				pool <- p
 				continue
 			}
 			out, err := t.secure.sealInto(p.buf[:0], &nonce, flags, payload)
 			if err != nil {
-				atomic.AddUint64(&t.droppedPackets, 1)
+				t.recordDrop(&t.dropMissingSession, 1)
 				pool <- p
 				continue
 			}
@@ -152,25 +152,33 @@ func (t *TunnelRunner) txLoopSecure() {
 		sent := 0
 		for sent < ready {
 			n, err := conn.WriteBatch(messages[sent:ready], 0)
+			var payloadBytes, wireBytes, savedBytes, compressed, raw uint64
 			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])))
+				payloadBytes += uint64(p.n)
+				wireBytes += uint64(len(messages[j].Buffers[0]))
 				if p.compressed {
-					atomic.AddUint64(&t.compressedPackets, 1)
-					atomic.AddUint64(&t.savedBytes, uint64(p.n-p.wire))
+					compressed++
+					savedBytes += uint64(p.n - p.wire)
 				} else {
-					atomic.AddUint64(&t.rawPackets, 1)
+					raw++
 				}
 			}
+			if n > 0 {
+				atomic.AddUint64(&t.txBytes, payloadBytes)
+				atomic.AddUint64(&t.rawBytesOut, payloadBytes)
+				atomic.AddUint64(&t.packetsTx, uint64(n))
+				atomic.AddUint64(&t.wireBytesOut, wireBytes)
+				atomic.AddUint64(&t.compressedPackets, compressed)
+				atomic.AddUint64(&t.savedBytes, savedBytes)
+				atomic.AddUint64(&t.rawPackets, raw)
+			}
 			sent += n
 			if err != nil || n == 0 {
 				if err != nil {
 					t.setError(err)
 				}
-				atomic.AddUint64(&t.droppedPackets, uint64(ready-sent))
+				t.recordDrop(&t.dropSendFailure, uint64(ready-sent))
 				break
 			}
 		}
@@ -229,7 +237,7 @@ func (t *TunnelRunner) rxLoopSecure() {
 			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)
+				t.recordDrop(&t.dropInvalidIncoming, 1)
 				continue
 			}
 			if t.cfg.Mode == "client" && !udpAddrsEqual(t.target(), src) {
@@ -299,23 +307,27 @@ func (t *TunnelRunner) rxLoopSecure() {
 			}
 			if flags == FlagCompressedLZ4 {
 				if len(decomp) == 0 {
-					atomic.AddUint64(&t.droppedPackets, 1)
+					t.recordDrop(&t.dropInvalidIncoming, 1)
 					continue
 				}
 				size, err := lz4.UncompressBlock(payload, decomp[:int(t.effectiveMTU.Load())])
 				if err != nil || size == 0 {
-					atomic.AddUint64(&t.droppedPackets, 1)
+					t.recordDrop(&t.dropInvalidIncoming, 1)
 					continue
 				}
 				payload = decomp[:size]
 			}
-			if len(payload) > int(t.effectiveMTU.Load()) || !validIPPacket(payload) || !t.allowedSource(payload) {
-				atomic.AddUint64(&t.droppedPackets, 1)
+			if len(payload) > int(t.effectiveMTU.Load()) || !validIPPacket(payload) {
+				t.recordDrop(&t.dropInvalidIncoming, 1)
+				continue
+			}
+			if !t.allowedSource(payload) {
+				t.recordDrop(&t.dropSourcePolicy, 1)
 				continue
 			}
 			wrote, err := t.dev.Write(payload)
 			if err != nil || wrote != len(payload) {
-				atomic.AddUint64(&t.droppedPackets, 1)
+				t.recordDrop(&t.dropInterfaceWrite, 1)
 				if err != nil {
 					t.setError(err)
 				}

+ 14 - 1
internal/aerotun/tunnel.go

@@ -21,6 +21,7 @@ type TunnelRunner struct {
 	secure                                                               *secureProtocol
 	allowed                                                              []netip.Prefix
 	effectiveMTU                                                         atomic.Int64
+	configuredMTU                                                        int
 	mu                                                                   sync.RWMutex
 	cfg                                                                  TunnelInstance
 	dev                                                                  VirtualDevice
@@ -36,12 +37,20 @@ type TunnelRunner struct {
 	rxBytes, txBytes, rawBytesIn, rawBytesOut, wireBytesIn, wireBytesOut uint64
 	savedBytes, packetsRx, packetsTx, compressedPackets, rawPackets      uint64
 	droppedPackets, authFailures, replayDrops                            uint64
+	dropInvalidOutgoing, dropMissingSession, dropSendFailure             uint64
+	dropInvalidIncoming, dropSourcePolicy, dropInterfaceWrite            uint64
+}
+
+func (t *TunnelRunner) recordDrop(kind *uint64, count uint64) {
+	atomic.AddUint64(&t.droppedPackets, count)
+	atomic.AddUint64(kind, count)
 }
 
 func NewTunnelRunner(cfg TunnelInstance) (*TunnelRunner, error) {
 	if err := validateConfig(&cfg); err != nil {
 		return nil, err
 	}
+	configuredMTU := cfg.MTU
 	var remote *net.UDPAddr
 	var err error
 	if cfg.RemoteEndpoint != "" {
@@ -92,7 +101,7 @@ func NewTunnelRunner(cfg TunnelInstance) (*TunnelRunner, error) {
 	}
 	_ = 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}}
+	runner := &TunnelRunner{secure: secure, cfg: cfg, configuredMTU: configuredMTU, 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())
@@ -373,6 +382,7 @@ func (t *TunnelRunner) GetTelemetry() TunnelInstance {
 		inst.Handshakes = t.secure.handshakes.Load()
 	}
 	inst.MTU = int(t.effectiveMTU.Load())
+	inst.ConfiguredMTU = t.configuredMTU
 	inst.Running = !t.closed
 	inst.Connected = inst.Running && !t.lastHandshake.IsZero() && time.Since(t.lastHandshake) < peerTimeout
 	inst.ActivePeerEndpoint = ""
@@ -406,6 +416,9 @@ func (t *TunnelRunner) GetTelemetry() TunnelInstance {
 	inst.DroppedPackets = atomic.LoadUint64(&t.droppedPackets)
 	inst.AuthFailures = atomic.LoadUint64(&t.authFailures)
 	inst.ReplayDrops = atomic.LoadUint64(&t.replayDrops)
+	if t.secure != nil {
+		inst.DropBreakdown = &DropBreakdown{InvalidOutgoing: atomic.LoadUint64(&t.dropInvalidOutgoing), MissingSession: atomic.LoadUint64(&t.dropMissingSession), SendFailure: atomic.LoadUint64(&t.dropSendFailure), InvalidIncoming: atomic.LoadUint64(&t.dropInvalidIncoming), SourcePolicy: atomic.LoadUint64(&t.dropSourcePolicy), InterfaceWrite: atomic.LoadUint64(&t.dropInterfaceWrite)}
+	}
 	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)

+ 34 - 23
internal/aerotun/types.go

@@ -15,29 +15,31 @@ 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)
-	ListenPort     int       `json:"listen_port"`     // UDP port to bind
-	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,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
-	Disabled       bool      `json:"disabled"`        // MikroTik 'X' flag
-	Comment        string    `json:"comment,omitempty"`
-	CreatedAt      time.Time `json:"created_at"`
+	Backend        string         `json:"backend,omitempty"`
+	ConfiguredMTU  int            `json:"configured_mtu,omitempty"`
+	DropBreakdown  *DropBreakdown `json:"drop_breakdown,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)
+	ListenPort     int            `json:"listen_port"`     // UDP port to bind
+	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,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
+	Disabled       bool           `json:"disabled"`        // MikroTik 'X' flag
+	Comment        string         `json:"comment,omitempty"`
+	CreatedAt      time.Time      `json:"created_at"`
 
 	// Runtime state & telemetry
 	Connected          bool   `json:"connected"`
@@ -63,6 +65,15 @@ type TunnelInstance struct {
 }
 
 // SummaryStats aggregates performance across all AeroTUN tunnels
+type DropBreakdown struct {
+	InvalidOutgoing uint64 `json:"invalid_outgoing"`
+	MissingSession  uint64 `json:"missing_session"`
+	SendFailure     uint64 `json:"send_failure"`
+	InvalidIncoming uint64 `json:"invalid_incoming"`
+	SourcePolicy    uint64 `json:"source_policy"`
+	InterfaceWrite  uint64 `json:"interface_write"`
+}
+
 type SummaryStats struct {
 	TotalTunnels      int    `json:"total_tunnels"`
 	ActiveTunnels     int    `json:"active_tunnels"`

+ 20 - 0
internal/api/aerotun_handlers.go

@@ -1,6 +1,7 @@
 package api
 
 import (
+	"context"
 	"crypto/rand"
 	"encoding/hex"
 	"encoding/json"
@@ -10,6 +11,25 @@ import (
 	"time"
 )
 
+func (s *Server) handleAeroTUNHealth(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Cache-Control", "no-store")
+	writeJSON(w, http.StatusOK, map[string]any{"states": s.vpnMgr.AeroTUN.HealthSummaries(), "events": s.vpnMgr.AeroTUN.HealthHistory(""), "service_cpu_percent": s.vpnMgr.AeroTUN.ProcessCPUPercent()})
+}
+func (s *Server) handleAeroTUNHealthCheck(w http.ResponseWriter, r *http.Request) {
+	if !requireAeroTUNAdmin(w, r) {
+		return
+	}
+	ctx, cancel := context.WithTimeout(r.Context(), 7*time.Second)
+	defer cancel()
+	result, err := s.vpnMgr.AeroTUN.CheckHealth(ctx, r.PathValue("id"))
+	if err != nil {
+		writeError(w, http.StatusUnprocessableEntity, err.Error())
+		return
+	}
+	w.Header().Set("Cache-Control", "no-store")
+	writeJSON(w, http.StatusOK, result)
+}
+
 type aeroIdentityDraft struct {
 	private, public, session string
 	expires                  time.Time

+ 30 - 0
internal/api/aerotun_handlers_test.go

@@ -73,6 +73,36 @@ func TestAeroTUNPrivateIdentityRedacted(t *testing.T) {
 	}
 }
 
+func TestAeroTUNHealthAPIDoesNotDiscloseKeysAndRestrictsProbe(t *testing.T) {
+	m := aerotun.NewManagerWithPath(filepath.Join(t.TempDir(), "aerotun.json"))
+	defer m.StopAll()
+	key := strings.Repeat("ab", 32)
+	saved, err := m.Save(aerotun.TunnelInstance{Name: "health0", 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.handleAeroTUNHealth(w, aeroRequest("GET", "/", "", auth.RoleRead))
+	if w.Code != 200 || strings.Contains(w.Body.String(), key) || strings.Contains(w.Body.String(), `"private_key"`) {
+		t.Fatalf("unsafe health response: %d", w.Code)
+	}
+	r := aeroRequest("POST", "/", "", auth.RoleRead)
+	r.SetPathValue("id", saved.ID)
+	w = httptest.NewRecorder()
+	s.handleAeroTUNHealthCheck(w, r)
+	if w.Code != http.StatusForbidden {
+		t.Fatalf("read-only user triggered probe: %d", w.Code)
+	}
+	r = aeroRequest("POST", "/", "", auth.RoleFull)
+	r.SetPathValue("id", saved.ID)
+	w = httptest.NewRecorder()
+	s.handleAeroTUNHealthCheck(w, r)
+	if w.Code != http.StatusUnprocessableEntity {
+		t.Fatalf("disabled tunnel probe accepted: %d", w.Code)
+	}
+}
+
 func TestAeroTUNDraftIdentityIsSessionBoundAndSaved(t *testing.T) {
 	m := aerotun.NewManagerWithPath(filepath.Join(t.TempDir(), "aerotun.json"))
 	defer m.StopAll()

+ 2 - 0
internal/api/api.go

@@ -258,6 +258,8 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("POST /api/vpn/aerotun/identity-drafts", s.handleCreateAeroIdentityDraft)
 	s.mux.HandleFunc("DELETE /api/vpn/aerotun/identity-drafts/{id}", s.handleDeleteAeroIdentityDraft)
 	s.mux.HandleFunc("GET /api/vpn/aerotun/stats", s.handleGetAeroTUNStats)
+	s.mux.HandleFunc("GET /api/vpn/aerotun/health", s.handleAeroTUNHealth)
+	s.mux.HandleFunc("POST /api/vpn/aerotun/tunnels/{id}/health-check", s.handleAeroTUNHealthCheck)
 
 	// PPP Profiles (/ppp profile)
 	s.mux.HandleFunc("GET /api/vpn/ppp/profiles", s.handleListPPPProfiles)

+ 76 - 1
internal/web/static/app.js

@@ -7619,6 +7619,8 @@ async function deleteEoIP(id) {
 }
 
 // ======================== AeroTUN (Custom Encrypted VPN with Adaptive LZ4) ========================
+const aeroRateSamples = new Map();
+const aeroLiveRates = new Map();
 async function loadAeroTUN(populate = true) {
   try {
     const [tunnelsRes, statsRes] = await Promise.all([
@@ -7629,6 +7631,14 @@ async function loadAeroTUN(populate = true) {
     if (tunnelsRes.ok) {
       const data = await tunnelsRes.json();
       state.aerotunTunnels = Array.isArray(data) ? data : [];
+      const now = Date.now();
+      for (const t of state.aerotunTunnels) {
+        const old = aeroRateSamples.get(t.id), seconds = old ? (now-old.time)/1000 : 0;
+        if (seconds >= 0.5 && seconds <= 60 && t.rx_bytes >= old.rx && t.tx_bytes >= old.tx && t.packets_rx >= old.prx && t.packets_tx >= old.ptx) {
+          aeroLiveRates.set(t.id,{rx:(t.rx_bytes-old.rx)*8/seconds/1e6,tx:(t.tx_bytes-old.tx)*8/seconds/1e6,prx:(t.packets_rx-old.prx)/seconds,ptx:(t.packets_tx-old.ptx)/seconds});
+        } else if (!old || seconds > 60 || t.rx_bytes < old.rx || t.tx_bytes < old.tx) aeroLiveRates.delete(t.id);
+        if (!old || seconds >= 0.5) aeroRateSamples.set(t.id,{time:now,rx:t.rx_bytes||0,tx:t.tx_bytes||0,prx:t.packets_rx||0,ptx:t.packets_tx||0});
+      }
     }
 
     if (statsRes.ok) {
@@ -7642,6 +7652,7 @@ async function loadAeroTUN(populate = true) {
     if (sideBadge) sideBadge.innerText = (state.aerotunTunnels || []).length;
 
     renderAeroTUN();
+    if (state.aeroHealthSelected) await refreshAeroTUNHealth();
   } catch (err) {
     console.error('Failed to load AeroTUN:', err);
   }
@@ -7709,6 +7720,8 @@ function renderAeroTUN() {
     const rawTx = formatBytes(t.tx_bytes || 0);
     const wireRx = formatBytes(t.wire_bytes_in || 0);
     const wireTx = formatBytes(t.wire_bytes_out || 0);
+    const rate = aeroLiveRates.get(t.id);
+    const id = encodeURIComponent(String(t.id || '')).replace(/'/g, '%27');
 
     tr.innerHTML = `
       <td class="py-2.5 px-3 text-center">${renderMikroTikFlag(t.connected, t.disabled)}</td>
@@ -7725,6 +7738,7 @@ function renderAeroTUN() {
       <td class="py-2.5 px-3 font-mono text-[11px]">
         <span class="text-emerald-400">↓ ${rawRx}</span>
         <span class="text-cyan-400 ml-1">↑ ${rawTx}</span>
+        ${rate ? `<div class="text-slate-400">↓ ${rate.rx.toFixed(2)} / ↑ ${rate.tx.toFixed(2)} Mbit/s</div>` : ''}
       </td>
       <td class="py-2.5 px-3 font-mono text-[11px]">
         <span class="text-slate-400">↓ ${wireRx}</span>
@@ -7732,6 +7746,7 @@ function renderAeroTUN() {
       </td>
       <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${escapeHtml(t.comment || '')}">${escapeHtml(t.comment || '-')}</td>
       <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
+        <button onclick="openAeroTUNHealth('${id}')" class="text-xs text-sky-400 hover:text-sky-300 font-medium">Health</button>
         ${t.running
           ? `<button onclick="stopAeroTUNTunnel('${t.id}')" class="text-xs text-amber-400 hover:text-amber-300 font-medium">Stop</button>`
           : `<button onclick="startAeroTUNTunnel('${t.id}')" class="text-xs text-emerald-400 hover:text-emerald-300 font-medium">Start</button>`
@@ -7745,6 +7760,66 @@ function renderAeroTUN() {
   });
 }
 
+async function openAeroTUNHealth(encodedID) {
+  state.aeroHealthSelected = decodeURIComponent(encodedID);
+  const tunnel=(state.aerotunTunnels||[]).find(x=>x.id===state.aeroHealthSelected);
+  let underlay='';try {underlay=new URL(`http://${tunnel?.remote_endpoint||tunnel?.active_peer_endpoint||''}`).hostname.replace(/^\[|\]$/g,'')} catch {}
+  document.getElementById('aerotun-compare-host').value=underlay;
+  document.getElementById('aerotun-compare-result').textContent='';
+  document.getElementById('aerotun-health-result').textContent = '';
+  document.getElementById('aerotun-health-panel').classList.remove('hidden');
+  await refreshAeroTUNHealth();
+  document.getElementById('aerotun-health-panel').scrollIntoView({behavior:'smooth',block:'nearest'});
+}
+function closeAeroTUNHealth() { aeroCompareCancel=true; state.aeroHealthSelected = ''; document.getElementById('aerotun-health-panel').classList.add('hidden'); }
+async function refreshAeroTUNHealth() {
+  const id=state.aeroHealthSelected,t=(state.aerotunTunnels||[]).find(x=>x.id===id);if(!id)return;
+  if(!t){document.getElementById('aerotun-health-status').textContent='Tunnel no longer exists.';return}
+  document.getElementById('aerotun-health-title').textContent=`Health: ${t.name}`;
+  try {
+    const response=await fetch(`${API_BASE}/vpn/aerotun/health`);if(!response.ok)throw new Error(await responseError(response));const data=await response.json();if(state.aeroHealthSelected!==id)return;
+    const current=(data.states||[]).find(x=>x.tunnel_id===id);const status=current?.status||'unknown';const since=current?.since&&!current.since.startsWith('0001')?new Date(current.since).toLocaleString():'—';const rate=aeroLiveRates.get(id);
+    const d=t.drop_breakdown;
+    document.getElementById('aerotun-health-status').textContent=`Status: ${status} since ${since} · Backend: ${t.backend||'—'} · MTU: ${t.mtu||'—'} / configured ${t.configured_mtu||(!t.running?t.mtu:'—')||'—'} · Last authenticated packet: ${t.last_handshake||'never'} · Drops: ${t.dropped_packets||0}${d?` (outgoing invalid ${d.invalid_outgoing}, session ${d.missing_session}, send ${d.send_failure}, incoming invalid ${d.invalid_incoming}, source policy ${d.source_policy}, interface ${d.interface_write})`:''} · Authentication failures: ${t.auth_failures||0} · Replay drops: ${t.replay_drops||0}${Number.isFinite(data.service_cpu_percent)?` · Service CPU (all features): ${data.service_cpu_percent.toFixed(1)}% of one core`:''}${rate?` · RX/TX: ${rate.rx.toFixed(2)} / ${rate.tx.toFixed(2)} Mbit/s, ${rate.prx.toFixed(0)} / ${rate.ptx.toFixed(0)} packets/s`:''}${t.last_error?` · Last error: ${t.last_error}`:''}`;
+    const events=(data.events||[]).filter(e=>e.tunnel_id===id).slice(0,100),node=document.getElementById('aerotun-health-history');node.replaceChildren();
+    if(!events.length){node.textContent='No events recorded yet.';return}
+    for(const e of events){const line=document.createElement('div');line.textContent=`${new Date(e.time).toLocaleString()} · ${e.type==='check'?'Check':(e.previous?`${e.previous} → ${e.status}${e.previous_duration_seconds?` (after ${Math.round(e.previous_duration_seconds)} s)`:''}`:e.status)}${e.detail?` · ${e.detail}`:''}`;node.appendChild(line)}
+  } catch(e){document.getElementById('aerotun-health-history').textContent=`Health history unavailable: ${e.message}`}
+}
+async function runAeroTUNHealthCheck() {
+  const id=state.aeroHealthSelected;if(!id)return;const button=document.getElementById('aerotun-health-check-btn'),output=document.getElementById('aerotun-health-result');button.disabled=true;output.textContent='Checking…';
+  try {const response=await fetch(`${API_BASE}/vpn/aerotun/tunnels/${encodeURIComponent(id)}/health-check`,{method:'POST'});if(!response.ok)throw new Error(await responseError(response));const r=await response.json();if(state.aeroHealthSelected!==id)return;output.textContent=`Peer echo: ${r.reachability}${r.rtt_ms!=null?` · RTT ${r.rtt_upper_bound?'<':''}${r.rtt_ms} ms`:''} · ${r.mtu}-byte overlay probe: ${r.mtu_probe}. ${r.explanation}`;await refreshAeroTUNHealth()}
+  catch(e){if(state.aeroHealthSelected===id)output.textContent=`Health check failed: ${e.message}`}
+  finally {button.disabled=false}
+}
+let aeroCompareRunning=false, aeroCompareCancel=false;
+function stopAeroTUNComparison() { aeroCompareCancel=true;document.getElementById('aerotun-compare-result').textContent+='\nStopping after the current test.'; }
+async function runAeroTUNComparison() {
+  if(aeroCompareRunning)return;
+  const id=state.aeroHealthSelected,t=(state.aerotunTunnels||[]).find(x=>x.id===id),direct=document.getElementById('aerotun-compare-host').value.trim(),port=Number(document.getElementById('aerotun-compare-port').value),peer=(t?.peer_address||'').split('/')[0].trim();
+  const output=document.getElementById('aerotun-compare-result'),button=document.getElementById('aerotun-compare-btn'),stop=document.getElementById('aerotun-compare-stop');
+  if(!t?.connected||!peer||!direct||!Number.isInteger(port)||port<1||port>65535||peer===direct){output.textContent='A connected tunnel, peer IP, different direct server address and valid port are required.';return}
+  if(peer.includes(':')||direct.includes(':')){output.textContent='This comparison currently supports IPv4 addresses or IPv4-resolvable hostnames.';return}
+  aeroCompareRunning=true;aeroCompareCancel=false;button.disabled=true;stop.classList.remove('hidden');output.textContent='Starting. The direct address uses the system routing table.';
+  const get=async(path,body)=>{const r=await fetch(`${API_BASE}${path}`,body?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:{});if(!r.ok)throw new Error(await responseError(r));return r.json()};
+  const results=[];
+  try {
+    for(const [path,target,direction] of [['Tunnel',peer,'tx'],['Tunnel',peer,'rx'],['Direct',direct,'tx'],['Direct',direct,'rx']]) {
+      if(aeroCompareCancel)break;
+      if((await get('/tools/bwtest/status')).running)throw new Error('Another bandwidth test is running; comparison stopped.');
+      output.textContent+=`\n${path} ${direction.toUpperCase()}: testing…`;
+      const started=await get('/tools/bwtest/start',{target,port,protocol:'tcp',direction,bandwidth_limit:0,packet_size:1200,streams:2,duration:8}),session=started.session_id;let status,complete=false;
+      for(let i=0;i<30;i++){await new Promise(resolve=>setTimeout(resolve,1000));status=await get('/tools/bwtest/status');if(status.session_id!==session)throw new Error('Bandwidth test session changed; comparison stopped.');if(!status.running){complete=true;break}}
+      if(!complete)throw new Error('Timed out waiting for the bandwidth test.');if(status.error)throw new Error(`${path} ${direction.toUpperCase()}: ${status.error}`);
+      const value=status.summary?.[direction==='tx'?'avg_tx_mbps':'avg_rx_mbps'];if(!Number.isFinite(value)||value<=0)throw new Error(`${path} ${direction.toUpperCase()}: no delivered data.`);
+      results.push(`${path} ${direction.toUpperCase()}: ${value.toFixed(2)} Mbit/s`);output.textContent='Results:\n'+results.join('\n');
+    }
+    if(aeroCompareCancel)output.textContent+='\nComparison stopped.';
+    else output.textContent+='\nDirect results are meaningful only if both addresses reach the same server over different routes.';
+  } catch(e){output.textContent+=`\nComparison incomplete: ${e.message}`}
+  finally {aeroCompareRunning=false;button.disabled=false;stop.classList.add('hidden')}
+}
+
 let aeroModalGeneration = 0;
 let aeroIdentityDraft = '';
 let aeroSaving = false;
@@ -7905,7 +7980,7 @@ function openEditAeroTUNModal(id) {
   document.getElementById('aerotun-modal-psk').value = '';
   document.getElementById('aerotun-modal-psk').type = 'password';
  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-mtu').value = t.configured_mtu || t.mtu || 1420;
   document.getElementById('aerotun-modal-comment').value = t.comment || '';
   document.getElementById('aerotun-modal-compression').checked = !!t.compression;
   document.getElementById('aerotun-modal-defroute').checked = !!t.default_route;

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

@@ -2017,7 +2017,7 @@
               <div>
                 <div class="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">AEAD Cipher & Anti-Replay</div>
                 <div class="text-xs font-bold text-sky-300 font-mono mt-1">ChaCha20-Poly1305</div>
-                <div class="text-[10px] text-slate-400 mt-0.5">64-pkt Sliding Window Active</div>
+                <div class="text-[10px] text-slate-400 mt-0.5">Authenticated packets and replay protection</div>
               </div>
               <div class="w-10 h-10 rounded-xl bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center text-indigo-400">
                 <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
@@ -2062,6 +2062,19 @@
               No AeroTUN interfaces configured. Click "+ Add AeroTUN Tunnel" to create a fast encrypted tunnel.
             </div>
           </div>
+          <div id="aerotun-health-panel" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl p-5 space-y-4">
+            <div class="flex items-center justify-between gap-3"><h3 id="aerotun-health-title" class="font-bold text-sm text-white">Tunnel health</h3><button type="button" onclick="closeAeroTUNHealth()" class="text-slate-400 hover:text-white">Close</button></div>
+            <div id="aerotun-health-status" class="text-xs text-slate-300"></div>
+            <div class="flex gap-3 items-center"><button id="aerotun-health-check-btn" type="button" onclick="runAeroTUNHealthCheck()" class="px-3 py-2 rounded bg-sky-700 text-white text-xs">Check peer and MTU</button><span class="text-xs text-slate-400">Sends up to two ICMP probes through this tunnel.</span></div>
+            <div id="aerotun-health-result" role="status" class="text-xs text-slate-300"></div>
+            <div class="border-t border-slate-800 pt-3 space-y-2">
+              <div class="text-xs font-semibold text-slate-300">TCP bandwidth comparison</div>
+              <p class="text-xs text-slate-400">Runs four 8-second tests through the tunnel and directly. Both addresses must reach the same bandwidth test server. This can use the full available bandwidth.</p>
+              <div class="flex flex-wrap gap-2 items-center"><label class="text-xs text-slate-300">Direct server <input id="aerotun-compare-host" class="bg-slate-800 rounded px-2 py-1 w-44" placeholder="Server IP or hostname"></label><label class="text-xs text-slate-300">Port <input id="aerotun-compare-port" type="number" min="1" max="65535" value="2000" class="bg-slate-800 rounded px-2 py-1 w-20"></label><button id="aerotun-compare-btn" type="button" onclick="runAeroTUNComparison()" class="px-3 py-1.5 rounded bg-slate-700 text-white text-xs">Run comparison</button><button id="aerotun-compare-stop" type="button" onclick="stopAeroTUNComparison()" class="hidden px-3 py-1.5 rounded bg-slate-700 text-white text-xs">Stop after current test</button></div>
+              <div id="aerotun-compare-result" role="status" class="text-xs text-slate-300 whitespace-pre-line"></div>
+            </div>
+            <div><h4 class="text-xs font-semibold text-slate-300 mb-2">Health history</h4><div id="aerotun-health-history" class="text-xs text-slate-400 space-y-1 max-h-60 overflow-y-auto"></div></div>
+          </div>
         </section>
 
         <!-- ================= SECTION: FIREWALL ================= -->

+ 21 - 0
scripts/aerotun-health-smoke.cjs

@@ -0,0 +1,21 @@
+const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright');
+const assert=require('node:assert/strict');
+(async()=>{const browser=await chromium.launch({headless:true,args:['--no-sandbox'],env:process.env});const page=await browser.newPage();const errors=[],starts=[];let session=0;
+ page.on('pageerror',e=>errors.push(e.message));
+ await page.route('**/api/vpn/aerotun/health',route=>route.fulfill({json:{states:[{tunnel_id:'health-id',status:'connected',since:'2026-01-01T00:00:00Z'}],events:[{tunnel_id:'health-id',time:'2026-01-01T00:00:00Z',type:'state',status:'connected',previous:'waiting'}]}}));
+ await page.route('**/api/vpn/aerotun/tunnels/*/health-check',route=>route.fulfill({json:{reachability:'reachable',rtt_ms:4.2,mtu:1300,mtu_probe:'reachable',explanation:'Overlay result'}}));
+ await page.route('**/api/tools/bwtest/start',route=>{starts.push(route.request().postDataJSON());session++;return route.fulfill({json:{session_id:'test-'+session,running:true}})});
+ await page.route('**/api/tools/bwtest/status',route=>route.fulfill({json:{session_id:'test-'+session,running:false,summary:{avg_tx_mbps:42,avg_rx_mbps:43}}}));
+ 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(()=>{document.getElementById('section-aerotun').classList.remove('hidden');state.aerotunTunnels=[{id:'health-id',name:'aero-health',mode:'client',running:true,connected:true,backend:'veth',protocol_version:3,mtu:1300,configured_mtu:1420,local_address:'192.0.2.1/30',peer_address:'192.0.2.2/30',remote_endpoint:'198.51.100.1:51830',allowed_ips:['192.0.2.2/32'],rx_bytes:10,tx_bytes:20,packets_rx:1,packets_tx:2}];renderAeroTUN()});
+  await page.evaluate(()=>openEditAeroTUNModal('health-id'));assert.equal(await page.locator('#aerotun-modal-mtu').inputValue(),'1420');await page.evaluate(()=>closeAeroTUNModal());
+  await page.evaluate(()=>openAeroTUNHealth('health-id'));await page.waitForFunction(()=>document.getElementById('aerotun-health-history').textContent.includes('waiting'));
+  assert.match(await page.locator('#aerotun-health-status').innerText(),/MTU: 1300 \/ configured 1420/);
+  await page.locator('#aerotun-health-check-btn').click();assert.match(await page.locator('#aerotun-health-result').innerText(),/RTT 4.2 ms/);
+  assert.equal(await page.locator('#aerotun-compare-host').inputValue(),'198.51.100.1');await page.locator('#aerotun-compare-btn').click();await page.waitForFunction(()=>document.getElementById('aerotun-compare-result').textContent.includes('different routes'),null,{timeout:12000});
+  assert.deepEqual(starts.map(x=>[x.target,x.direction]),[['192.0.2.2','tx'],['192.0.2.2','rx'],['198.51.100.1','tx'],['198.51.100.1','rx']]);assert.deepEqual(errors,[]);
+  await page.route('**/api/tools/bwtest/status',route=>route.fulfill({json:{session_id:'external',running:true}}));await page.locator('#aerotun-compare-btn').click();await page.waitForFunction(()=>document.getElementById('aerotun-compare-result').textContent.includes('Another bandwidth test is running'));assert.equal(starts.length,4);
+  await page.evaluate(()=>handleLogoutClick());console.log('AeroTUN health, configured MTU, probe and four-step comparison passed.');
+ }finally{await browser.close()}
+})().catch(e=>{console.error(e);process.exitCode=1});