Просмотр исходного кода

Add AeroTUN health history and diagnostics

Proxmox Router 2 дней назад
Родитель
Сommit
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.
 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
 ## Install
 
 
@@ -21,10 +21,10 @@ The internal Git server uses a private CA. Trust that CA before cloning, or repl
 ```sh
 ```sh
 git -c http.sslVerify=false \
 git -c http.sslVerify=false \
   -c 'http.pinnedpubkey=sha256//u4xyt0njDzn4BAQnS6OTGrXBxVE8vMg5BSNIuryUsMo=' \
   -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.
 `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
 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.
 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
 ## Validation and observed results
 
 
 Both full repository tests and `go vet` pass. API regression tests cover redaction,
 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)
 	_ = os.WriteFile(filepath.Join(dir, role+".ready"), nil, 0600)
 	deadline := time.Now().Add(45 * time.Second)
 	deadline := time.Now().Add(45 * time.Second)
+	checked := false
 	for time.Now().Before(deadline) {
 	for time.Now().Before(deadline) {
 		if r.GetTelemetry().Connected && r.GetTelemetry().MTU == expectedMTU {
 		if r.GetTelemetry().Connected && r.GetTelemetry().MTU == expectedMTU {
 			_ = os.WriteFile(filepath.Join(dir, role+".connected"), nil, 0600)
 			_ = 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 {
 		if _, err := os.Stat(filepath.Join(dir, "stop")); err == nil {
 			stats := r.GetTelemetry()
 			stats := r.GetTelemetry()

+ 8 - 0
internal/aerotun/manager.go

@@ -17,6 +17,7 @@ type Manager struct {
 	runners   map[string]*TunnelRunner
 	runners   map[string]*TunnelRunner
 	errors    map[string]string
 	errors    map[string]string
 	filePath  string
 	filePath  string
+	health    *healthMonitor
 }
 }
 
 
 func NewManager() *Manager { return NewManagerWithPath(StoragePath) }
 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
 	return m
 }
 }
@@ -77,6 +80,8 @@ func (m *Manager) saveLocked() error {
 		copyInst.Running = false
 		copyInst.Running = false
 		copyInst.Handshakes = 0
 		copyInst.Handshakes = 0
 		copyInst.Backend = ""
 		copyInst.Backend = ""
+		copyInst.ConfiguredMTU = 0
+		copyInst.DropBreakdown = nil
 		copyInst.HasPSK = false
 		copyInst.HasPSK = false
 		copyInst.HasPrivateKey = false
 		copyInst.HasPrivateKey = false
 		copyInst.Connected = false
 		copyInst.Connected = false
@@ -339,6 +344,9 @@ func (m *Manager) startRunnerLocked(id string) error {
 }
 }
 
 
 func (m *Manager) StopAll() {
 func (m *Manager) StopAll() {
+	if m.health != nil {
+		m.health.stop()
+	}
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()
 
 

+ 29 - 17
internal/aerotun/secure_io.go

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

+ 14 - 1
internal/aerotun/tunnel.go

@@ -21,6 +21,7 @@ type TunnelRunner struct {
 	secure                                                               *secureProtocol
 	secure                                                               *secureProtocol
 	allowed                                                              []netip.Prefix
 	allowed                                                              []netip.Prefix
 	effectiveMTU                                                         atomic.Int64
 	effectiveMTU                                                         atomic.Int64
+	configuredMTU                                                        int
 	mu                                                                   sync.RWMutex
 	mu                                                                   sync.RWMutex
 	cfg                                                                  TunnelInstance
 	cfg                                                                  TunnelInstance
 	dev                                                                  VirtualDevice
 	dev                                                                  VirtualDevice
@@ -36,12 +37,20 @@ type TunnelRunner struct {
 	rxBytes, txBytes, rawBytesIn, rawBytesOut, wireBytesIn, wireBytesOut uint64
 	rxBytes, txBytes, rawBytesIn, rawBytesOut, wireBytesIn, wireBytesOut uint64
 	savedBytes, packetsRx, packetsTx, compressedPackets, rawPackets      uint64
 	savedBytes, packetsRx, packetsTx, compressedPackets, rawPackets      uint64
 	droppedPackets, authFailures, replayDrops                            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) {
 func NewTunnelRunner(cfg TunnelInstance) (*TunnelRunner, error) {
 	if err := validateConfig(&cfg); err != nil {
 	if err := validateConfig(&cfg); err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
+	configuredMTU := cfg.MTU
 	var remote *net.UDPAddr
 	var remote *net.UDPAddr
 	var err error
 	var err error
 	if cfg.RemoteEndpoint != "" {
 	if cfg.RemoteEndpoint != "" {
@@ -92,7 +101,7 @@ func NewTunnelRunner(cfg TunnelInstance) (*TunnelRunner, error) {
 	}
 	}
 	_ = conn.SetReadBuffer(bufferSize)
 	_ = conn.SetReadBuffer(bufferSize)
 	_ = conn.SetWriteBuffer(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 {
 	for _, p := range cfg.AllowedIPs {
 		prefix, _ := netip.ParsePrefix(p)
 		prefix, _ := netip.ParsePrefix(p)
 		runner.allowed = append(runner.allowed, prefix.Masked())
 		runner.allowed = append(runner.allowed, prefix.Masked())
@@ -373,6 +382,7 @@ func (t *TunnelRunner) GetTelemetry() TunnelInstance {
 		inst.Handshakes = t.secure.handshakes.Load()
 		inst.Handshakes = t.secure.handshakes.Load()
 	}
 	}
 	inst.MTU = int(t.effectiveMTU.Load())
 	inst.MTU = int(t.effectiveMTU.Load())
+	inst.ConfiguredMTU = t.configuredMTU
 	inst.Running = !t.closed
 	inst.Running = !t.closed
 	inst.Connected = inst.Running && !t.lastHandshake.IsZero() && time.Since(t.lastHandshake) < peerTimeout
 	inst.Connected = inst.Running && !t.lastHandshake.IsZero() && time.Since(t.lastHandshake) < peerTimeout
 	inst.ActivePeerEndpoint = ""
 	inst.ActivePeerEndpoint = ""
@@ -406,6 +416,9 @@ func (t *TunnelRunner) GetTelemetry() TunnelInstance {
 	inst.DroppedPackets = atomic.LoadUint64(&t.droppedPackets)
 	inst.DroppedPackets = atomic.LoadUint64(&t.droppedPackets)
 	inst.AuthFailures = atomic.LoadUint64(&t.authFailures)
 	inst.AuthFailures = atomic.LoadUint64(&t.authFailures)
 	inst.ReplayDrops = atomic.LoadUint64(&t.replayDrops)
 	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)"
 	inst.CompressionRatio = "1.00x (0% saved)"
 	if inst.RawBytesOut > inst.WireBytesOut && inst.WireBytesOut > 0 {
 	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)
 		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
 // TunnelInstance represents a configured AeroTUN interface and its live status
 type TunnelInstance struct {
 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
 	// Runtime state & telemetry
 	Connected          bool   `json:"connected"`
 	Connected          bool   `json:"connected"`
@@ -63,6 +65,15 @@ type TunnelInstance struct {
 }
 }
 
 
 // SummaryStats aggregates performance across all AeroTUN tunnels
 // 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 {
 type SummaryStats struct {
 	TotalTunnels      int    `json:"total_tunnels"`
 	TotalTunnels      int    `json:"total_tunnels"`
 	ActiveTunnels     int    `json:"active_tunnels"`
 	ActiveTunnels     int    `json:"active_tunnels"`

+ 20 - 0
internal/api/aerotun_handlers.go

@@ -1,6 +1,7 @@
 package api
 package api
 
 
 import (
 import (
+	"context"
 	"crypto/rand"
 	"crypto/rand"
 	"encoding/hex"
 	"encoding/hex"
 	"encoding/json"
 	"encoding/json"
@@ -10,6 +11,25 @@ import (
 	"time"
 	"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 {
 type aeroIdentityDraft struct {
 	private, public, session string
 	private, public, session string
 	expires                  time.Time
 	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) {
 func TestAeroTUNDraftIdentityIsSessionBoundAndSaved(t *testing.T) {
 	m := aerotun.NewManagerWithPath(filepath.Join(t.TempDir(), "aerotun.json"))
 	m := aerotun.NewManagerWithPath(filepath.Join(t.TempDir(), "aerotun.json"))
 	defer m.StopAll()
 	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("POST /api/vpn/aerotun/identity-drafts", s.handleCreateAeroIdentityDraft)
 	s.mux.HandleFunc("DELETE /api/vpn/aerotun/identity-drafts/{id}", s.handleDeleteAeroIdentityDraft)
 	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/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)
 	// PPP Profiles (/ppp profile)
 	s.mux.HandleFunc("GET /api/vpn/ppp/profiles", s.handleListPPPProfiles)
 	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) ========================
 // ======================== AeroTUN (Custom Encrypted VPN with Adaptive LZ4) ========================
+const aeroRateSamples = new Map();
+const aeroLiveRates = new Map();
 async function loadAeroTUN(populate = true) {
 async function loadAeroTUN(populate = true) {
   try {
   try {
     const [tunnelsRes, statsRes] = await Promise.all([
     const [tunnelsRes, statsRes] = await Promise.all([
@@ -7629,6 +7631,14 @@ async function loadAeroTUN(populate = true) {
     if (tunnelsRes.ok) {
     if (tunnelsRes.ok) {
       const data = await tunnelsRes.json();
       const data = await tunnelsRes.json();
       state.aerotunTunnels = Array.isArray(data) ? data : [];
       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) {
     if (statsRes.ok) {
@@ -7642,6 +7652,7 @@ async function loadAeroTUN(populate = true) {
     if (sideBadge) sideBadge.innerText = (state.aerotunTunnels || []).length;
     if (sideBadge) sideBadge.innerText = (state.aerotunTunnels || []).length;
 
 
     renderAeroTUN();
     renderAeroTUN();
+    if (state.aeroHealthSelected) await refreshAeroTUNHealth();
   } catch (err) {
   } catch (err) {
     console.error('Failed to load AeroTUN:', err);
     console.error('Failed to load AeroTUN:', err);
   }
   }
@@ -7709,6 +7720,8 @@ function renderAeroTUN() {
     const rawTx = formatBytes(t.tx_bytes || 0);
     const rawTx = formatBytes(t.tx_bytes || 0);
     const wireRx = formatBytes(t.wire_bytes_in || 0);
     const wireRx = formatBytes(t.wire_bytes_in || 0);
     const wireTx = formatBytes(t.wire_bytes_out || 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 = `
     tr.innerHTML = `
       <td class="py-2.5 px-3 text-center">${renderMikroTikFlag(t.connected, t.disabled)}</td>
       <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]">
       <td class="py-2.5 px-3 font-mono text-[11px]">
         <span class="text-emerald-400">↓ ${rawRx}</span>
         <span class="text-emerald-400">↓ ${rawRx}</span>
         <span class="text-cyan-400 ml-1">↑ ${rawTx}</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>
       <td class="py-2.5 px-3 font-mono text-[11px]">
       <td class="py-2.5 px-3 font-mono text-[11px]">
         <span class="text-slate-400">↓ ${wireRx}</span>
         <span class="text-slate-400">↓ ${wireRx}</span>
@@ -7732,6 +7746,7 @@ function renderAeroTUN() {
       </td>
       </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-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">
       <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
         ${t.running
           ? `<button onclick="stopAeroTUNTunnel('${t.id}')" class="text-xs text-amber-400 hover:text-amber-300 font-medium">Stop</button>`
           ? `<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>`
           : `<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 aeroModalGeneration = 0;
 let aeroIdentityDraft = '';
 let aeroIdentityDraft = '';
 let aeroSaving = false;
 let aeroSaving = false;
@@ -7905,7 +7980,7 @@ function openEditAeroTUNModal(id) {
   document.getElementById('aerotun-modal-psk').value = '';
   document.getElementById('aerotun-modal-psk').value = '';
   document.getElementById('aerotun-modal-psk').type = 'password';
   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-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-comment').value = t.comment || '';
   document.getElementById('aerotun-modal-compression').checked = !!t.compression;
   document.getElementById('aerotun-modal-compression').checked = !!t.compression;
   document.getElementById('aerotun-modal-defroute').checked = !!t.default_route;
   document.getElementById('aerotun-modal-defroute').checked = !!t.default_route;

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

@@ -2017,7 +2017,7 @@
               <div>
               <div>
                 <div class="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">AEAD Cipher & Anti-Replay</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-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>
               <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">
               <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>
                 <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.
               No AeroTUN interfaces configured. Click "+ Add AeroTUN Tunnel" to create a fast encrypted tunnel.
             </div>
             </div>
           </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>
 
 
         <!-- ================= SECTION: FIREWALL ================= -->
         <!-- ================= 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});