Переглянути джерело

Prepare AeroTUN identity before saving

Proxmox Router 2 днів тому
батько
коміт
c0285cb68d

+ 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.4**. 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.5**. 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.4 https://gogs.eparmak.local/eparmak/DebTik.git
+  clone --depth 1 --branch v1.3.5 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.4` 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.5` 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.4
+1.3.5

+ 7 - 3
docs/aerotun-v3.md

@@ -108,9 +108,13 @@ Configuration and generated identity files are mode 0600. Existing management
 credentials and listeners are preserved; strong authentication and HTTPS/trusted
 management access are still required for appliance administration.
 
-New v3 tunnels can be saved disabled to generate a local identity. Copy its public
-key to the other endpoint over a trusted channel, set allowed prefixes, then enable
-both sides. Static private keys are never returned by the UI/API.
+Opening the new v3 tunnel form generates a temporary local identity immediately.
+Copy its public key to the other endpoint over a trusted channel and configure the
+peer key, shared PSK and allowed prefixes. Saving associates that same identity
+with the tunnel; save disabled if the peer is not ready. The unsaved private key
+stays in server memory, bound to the administrator session, and expires after two
+hours or a service restart. Closing the form discards the draft. Static private
+keys are never returned by the UI/API.
 
 For bootstrap on the owner host:
 

+ 88 - 2
internal/api/aerotun_handlers.go

@@ -1,12 +1,73 @@
 package api
 
 import (
+	"crypto/rand"
+	"encoding/hex"
 	"encoding/json"
 	"net/http"
 	"proxmox-router/internal/aerotun"
 	"proxmox-router/internal/auth"
+	"time"
 )
 
+type aeroIdentityDraft struct {
+	private, public, session string
+	expires                  time.Time
+}
+
+func (s *Server) handleCreateAeroIdentityDraft(w http.ResponseWriter, r *http.Request) {
+	if !requireAeroTUNAdmin(w, r) {
+		return
+	}
+	private, public, err := aerotun.GenerateIdentity()
+	if err != nil {
+		http.Error(w, "failed to generate identity", http.StatusInternalServerError)
+		return
+	}
+	b := make([]byte, 32)
+	if _, err = rand.Read(b); err != nil {
+		http.Error(w, "failed to generate identity token", http.StatusInternalServerError)
+		return
+	}
+	token := hex.EncodeToString(b)
+	session := getSessionFromContext(r.Context()).Token
+	s.aeroDraftMu.Lock()
+	if s.aeroDrafts == nil {
+		s.aeroDrafts = make(map[string]aeroIdentityDraft)
+	}
+	now := time.Now()
+	count := 0
+	for id, d := range s.aeroDrafts {
+		if now.After(d.expires) {
+			delete(s.aeroDrafts, id)
+		} else if d.session == session {
+			count++
+		}
+	}
+	if count >= 16 {
+		s.aeroDraftMu.Unlock()
+		http.Error(w, "too many pending identities; close an open form and try again", http.StatusTooManyRequests)
+		return
+	}
+	s.aeroDrafts[token] = aeroIdentityDraft{private: private, public: public, session: session, expires: now.Add(2 * time.Hour)}
+	s.aeroDraftMu.Unlock()
+	w.Header().Set("Cache-Control", "no-store")
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(map[string]string{"draft_id": token, "public_key": public})
+}
+
+func (s *Server) handleDeleteAeroIdentityDraft(w http.ResponseWriter, r *http.Request) {
+	if !requireAeroTUNAdmin(w, r) {
+		return
+	}
+	s.aeroDraftMu.Lock()
+	if d, ok := s.aeroDrafts[r.PathValue("id")]; ok && d.session == getSessionFromContext(r.Context()).Token {
+		delete(s.aeroDrafts, r.PathValue("id"))
+	}
+	s.aeroDraftMu.Unlock()
+	w.WriteHeader(http.StatusNoContent)
+}
+
 func (s *Server) handleListAeroTUNTunnels(w http.ResponseWriter, r *http.Request) {
 	tunnels := s.vpnMgr.AeroTUN.List()
 	for i := range tunnels {
@@ -48,13 +109,38 @@ func (s *Server) handleSaveAeroTUNTunnel(w http.ResponseWriter, r *http.Request)
 	if !requireAeroTUNAdmin(w, r) {
 		return
 	}
-	var inst aerotun.TunnelInstance
-	if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
+	var req struct {
+		aerotun.TunnelInstance
+		IdentityDraftID string `json:"identity_draft_id"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, "invalid request body", http.StatusBadRequest)
 		return
 	}
 
+	inst := req.TunnelInstance
+	if req.IdentityDraftID != "" {
+		if inst.ID != "" || inst.Protocol != 3 || inst.PrivateKey != "" {
+			http.Error(w, "identity draft is only valid for a new v3 tunnel", http.StatusBadRequest)
+			return
+		}
+		s.aeroDraftMu.Lock()
+		defer s.aeroDraftMu.Unlock()
+		d, ok := s.aeroDrafts[req.IdentityDraftID]
+		if !ok || d.session != getSessionFromContext(r.Context()).Token || time.Now().After(d.expires) {
+			http.Error(w, "identity draft expired or is unavailable; reopen the form to create a new key", http.StatusUnprocessableEntity)
+			return
+		}
+		inst.PrivateKey, inst.PublicKey = d.private, d.public
+	}
 	saved, err := s.vpnMgr.AeroTUN.Save(inst)
+	if req.IdentityDraftID != "" {
+		if err == nil {
+			delete(s.aeroDrafts, req.IdentityDraftID)
+		} else if existing, ok := s.vpnMgr.AeroTUN.Get(saved.ID); ok && existing.PublicKey == inst.PublicKey {
+			delete(s.aeroDrafts, req.IdentityDraftID)
+		}
+	}
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return

+ 57 - 0
internal/api/aerotun_handlers_test.go

@@ -72,3 +72,60 @@ func TestAeroTUNPrivateIdentityRedacted(t *testing.T) {
 		t.Fatal("private identity exposed or public identity lost")
 	}
 }
+
+func TestAeroTUNDraftIdentityIsSessionBoundAndSaved(t *testing.T) {
+	m := aerotun.NewManagerWithPath(filepath.Join(t.TempDir(), "aerotun.json"))
+	defer m.StopAll()
+	s := &Server{vpnMgr: &vpn.Manager{AeroTUN: m}}
+	request := func(method, path, body, session string, role auth.Role) *http.Request {
+		r := httptest.NewRequest(method, path, strings.NewReader(body))
+		return r.WithContext(context.WithValue(r.Context(), sessionContextKey, &auth.Session{Role: role, Token: session}))
+	}
+	w := httptest.NewRecorder()
+	s.handleCreateAeroIdentityDraft(w, request("POST", "/", "", "owner", auth.RoleRead))
+	if w.Code != http.StatusForbidden {
+		t.Fatal("read-only user generated an identity")
+	}
+	w = httptest.NewRecorder()
+	s.handleCreateAeroIdentityDraft(w, request("POST", "/", "", "owner", auth.RoleFull))
+	var draft struct {
+		ID     string `json:"draft_id"`
+		Public string `json:"public_key"`
+	}
+	if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &draft) != nil || draft.ID == "" || draft.Public == "" || strings.Contains(w.Body.String(), "private") || w.Header().Get("Cache-Control") != "no-store" {
+		t.Fatal("invalid or unsafe identity response")
+	}
+	payload := map[string]any{"identity_draft_id": draft.ID, "protocol_version": 3, "name": "atest", "mode": "server", "local_address": "10.99.0.1/24", "allowed_ips": []string{"10.99.0.2/32"}, "psk": strings.Repeat("12", 32), "disabled": true}
+	body, _ := json.Marshal(payload)
+	w = httptest.NewRecorder()
+	s.handleSaveAeroTUNTunnel(w, request("POST", "/", string(body), "other", auth.RoleFull))
+	if w.Code != 422 {
+		t.Fatalf("other session used identity: %d", w.Code)
+	}
+	bad := make(map[string]any)
+	for k, v := range payload {
+		bad[k] = v
+	}
+	bad["local_address"] = "invalid"
+	badBody, _ := json.Marshal(bad)
+	w = httptest.NewRecorder()
+	s.handleSaveAeroTUNTunnel(w, request("POST", "/", string(badBody), "owner", auth.RoleFull))
+	if w.Code == 200 {
+		t.Fatal("invalid draft configuration was saved")
+	}
+	w = httptest.NewRecorder()
+	s.handleSaveAeroTUNTunnel(w, request("POST", "/", string(body), "owner", auth.RoleFull))
+	var saved aerotun.TunnelInstance
+	if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &saved) != nil || saved.PublicKey != draft.Public || strings.Contains(w.Body.String(), `"private_key":`) {
+		t.Fatalf("identity not preserved on save: %d", w.Code)
+	}
+	stored, ok := m.Get(saved.ID)
+	if !ok || stored.PublicKey != draft.Public || stored.PrivateKey == "" {
+		t.Fatal("identity was not stored")
+	}
+	w = httptest.NewRecorder()
+	s.handleSaveAeroTUNTunnel(w, request("POST", "/", string(body), "owner", auth.RoleFull))
+	if w.Code != 422 {
+		t.Fatalf("identity draft reused: %d", w.Code)
+	}
+}

+ 5 - 0
internal/api/api.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"net/http"
 	"strings"
+	"sync"
 	"time"
 
 	"proxmox-router/internal/auth"
@@ -26,6 +27,8 @@ import (
 )
 
 type Server struct {
+	aeroDraftMu   sync.Mutex
+	aeroDrafts    map[string]aeroIdentityDraft
 	netMgr        *network.Manager
 	persistMgr    *network.PersistenceManager
 	metaMgr       *network.MetadataManager
@@ -252,6 +255,8 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("POST /api/vpn/aerotun/tunnels/{id}/start", s.handleStartAeroTUNTunnel)
 	s.mux.HandleFunc("POST /api/vpn/aerotun/tunnels/{id}/stop", s.handleStopAeroTUNTunnel)
 	s.mux.HandleFunc("GET /api/vpn/aerotun/genkey", s.handleGenerateAeroTUNKey)
+	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)
 
 	// PPP Profiles (/ppp profile)

+ 125 - 24
internal/web/static/app.js

@@ -7728,6 +7728,37 @@ function renderAeroTUN() {
   });
 }
 
+let aeroModalGeneration = 0;
+let aeroIdentityDraft = '';
+let aeroSaving = false;
+let aeroPSKRequest = 0;
+
+function discardAeroIdentityDraft(id) {
+  if (id) fetch(`${API_BASE}/vpn/aerotun/identity-drafts/${encodeURIComponent(id)}`, {method:'DELETE'}).catch(() => {});
+}
+
+async function generateAeroTUNIdentity() {
+  const generation = aeroModalGeneration;
+  const retry = document.getElementById('aerotun-retry-identity');
+  if (aeroIdentityDraft) { discardAeroIdentityDraft(aeroIdentityDraft); aeroIdentityDraft = ''; }
+  retry.classList.add('hidden');
+  document.getElementById('aerotun-modal-public').value = '';
+  try {
+    const res = await fetch(`${API_BASE}/vpn/aerotun/identity-drafts`, {method:'POST'});
+    if (!res.ok) throw new Error(await responseError(res));
+    const draft = await res.json();
+    if (generation !== aeroModalGeneration) { discardAeroIdentityDraft(draft.draft_id); return; }
+    aeroIdentityDraft = draft.draft_id;
+    document.getElementById('aerotun-modal-public').value = draft.public_key;
+    updateAeroTUNSetupHint();
+  } catch (err) {
+    if (generation !== aeroModalGeneration) return;
+    retry.classList.remove('hidden');
+    updateAeroTUNSetupHint();
+    showToast('Identity Generation Failed', err.message, 'error');
+  }
+}
+
 function onAeroTUNModeChange() {
   const mode = document.getElementById('aerotun-modal-mode').value;
   const endpointInput = document.getElementById('aerotun-modal-endpoint');
@@ -7742,15 +7773,28 @@ function onAeroTUNModeChange() {
   }
 }
 
-async function generateAeroTUNKey() {
+function onAeroTUNPeerChange() {
+  const peer = document.getElementById('aerotun-modal-peer');
+  const allowed = document.getElementById('aerotun-modal-allowed');
+  const previous = peer.dataset.allowedDefault || '';
+  const host = peer.value.trim().split('/')[0];
+  const next = host ? `${host}/${host.includes(':') ? 128 : 32}` : '';
+  if (allowed.value.trim() === previous) allowed.value = next;
+  peer.dataset.allowedDefault = next;
+}
+
+async function generateAeroTUNKey(automatic = false) {
+  const generation = aeroModalGeneration, request = ++aeroPSKRequest;
+  const input = document.getElementById('aerotun-modal-psk'), previous = input.value;
   try {
     const res = await fetch(`${API_BASE}/vpn/aerotun/genkey`);
     if (!res.ok) throw new Error(await responseError(res));
     const data = await res.json();
-    document.getElementById('aerotun-modal-psk').value = data.key;
-    showToast('Key Generated', 'Share this PSK securely with the peer before enabling', 'success');
+    if (generation !== aeroModalGeneration || request !== aeroPSKRequest || (automatic && input.value !== previous)) return;
+    input.value = data.key;
+    if (!automatic) showToast('Key Generated', 'Share this PSK securely with the peer before enabling', 'success');
   } catch (err) {
-    showToast('Key Generation Failed', err.message, 'error');
+    if (generation === aeroModalGeneration && request === aeroPSKRequest) showToast('Key Generation Failed', err.message, 'error');
   }
 }
 
@@ -7759,13 +7803,14 @@ function updateAeroTUNSetupHint() {
   const key = document.getElementById('aerotun-modal-public').value;
   const peer = document.getElementById('aerotun-modal-peerkey').value.trim();
   const hint = document.getElementById('aerotun-setup-hint');
+  document.getElementById('aerotun-v3-fields').classList.toggle('hidden', version !== '3');
   if (!hint) return;
-  hint.innerText = version !== '3' ? 'Legacy v2 uses a shared PSK. Both endpoints must use the same protocol.' : !key ? '1. Save disabled to create the local identity. 2. Exchange public keys and share the same PSK. 3. Set allowed peer prefixes, then enable.' : !peer ? 'Local identity saved. Copy the public key to the peer and paste its public key here. Use the same PSK on both endpoints.' : 'Peer identity configured. Check reciprocal addresses, allowed peer source prefixes and the shared PSK before enabling. Allowed prefixes do not create LAN routes.';
+  hint.innerText = version !== '3' ? 'Legacy v2 uses a shared PSK. Both endpoints must use the same protocol.' : !key ? (document.getElementById('aerotun-modal-id').value ? 'Save this existing tunnel disabled to create its v3 identity, then exchange public keys.' : 'Generating the local identity. You can fill in the other settings while you wait.') : !peer ? 'Copy the local public key to the peer and paste its public key here. Use the same PSK on both endpoints; save disabled if the peer key is not ready.' : 'Check reciprocal addresses, allowed peer source prefixes and the shared PSK before enabling. Allowed prefixes do not create LAN routes.';
 }
 
 async function copyAeroTUNPublicKey() {
   const input = document.getElementById('aerotun-modal-public');
-  if (!input.value) { showToast('Save First', 'Save the disabled tunnel to create its public key', 'info'); return; }
+  if (!input.value) { showToast('Key Not Ready', 'Wait for the local identity or retry key generation.', 'info'); return; }
   try {
     await navigator.clipboard.writeText(input.value);
     showToast('Public Key Copied', 'Paste it into the other endpoint’s pinned peer key', 'success');
@@ -7775,20 +7820,34 @@ async function copyAeroTUNPublicKey() {
   }
 }
 
+async function copyAeroTUNPSK() {
+  const input = document.getElementById('aerotun-modal-psk');
+  if (!input.value) { showToast('PSK Not Available', 'Generate or enter a PSK to copy it. Existing keys are not displayed.', 'info'); return; }
+  try { await navigator.clipboard.writeText(input.value); showToast('PSK Copied', 'Share it securely with the peer.', 'success'); }
+  catch { input.type = 'text'; input.focus(); input.select(); showToast('Copy PSK', 'Key selected; press Ctrl+C to copy.', 'info'); }
+}
+
 function openAddAeroTUNModal() {
+  if (aeroSaving) return;
+  aeroModalGeneration++; discardAeroIdentityDraft(aeroIdentityDraft); aeroIdentityDraft = '';
   const tunnels = state.aerotunTunnels || [];
+  const usedNames = new Set(tunnels.map(t => t.name));
+  const usedPorts = new Set(tunnels.map(t => Number(t.listen_port)));
+  let index = 0; while (index < 256 && (usedNames.has(`aerotun${index}`) || usedPorts.has(51830 + index) || tunnels.some(t => (t.local_address || '').startsWith(`10.99.${index}.`)))) index++;
+  if (index >= 256) index = 0;
   document.getElementById('aerotun-modal-title').innerText = 'Add AeroTUN Interface';
   document.getElementById('aerotun-modal-id').value = '';
  document.getElementById('aerotun-modal-protocol').value='3';
  document.getElementById('aerotun-modal-public').value='';
  document.getElementById('aerotun-modal-peerkey').value='';
- document.getElementById('aerotun-modal-allowed').value=`10.99.${tunnels.length}.2/32`;
-  document.getElementById('aerotun-modal-name').value = `aerotun${tunnels.length}`;
+ document.getElementById('aerotun-modal-allowed').value=`10.99.${index}.2/32`;
+  document.getElementById('aerotun-modal-name').value = `aerotun${index}`;
   document.getElementById('aerotun-modal-mode').value = 'server';
-  document.getElementById('aerotun-modal-port').value = 51830 + tunnels.length;
+  document.getElementById('aerotun-modal-port').value = 51830 + index;
   document.getElementById('aerotun-modal-endpoint').value = '';
-  document.getElementById('aerotun-modal-local').value = `10.99.${tunnels.length}.1/24`;
-  document.getElementById('aerotun-modal-peer').value = `10.99.${tunnels.length}.2/24`;
+  document.getElementById('aerotun-modal-local').value = `10.99.${index}.1/24`;
+  document.getElementById('aerotun-modal-peer').value = `10.99.${index}.2/24`;
+  document.getElementById('aerotun-modal-peer').dataset.allowedDefault = `10.99.${index}.2/32`;
   document.getElementById('aerotun-modal-psk').value = '';
   document.getElementById('aerotun-modal-mtu').value = '1420';
   document.getElementById('aerotun-modal-comment').value = '';
@@ -7796,15 +7855,21 @@ function openAddAeroTUNModal() {
   document.getElementById('aerotun-modal-defroute').checked = false;
   document.getElementById('aerotun-modal-disabled').checked = true;
 
-  generateAeroTUNKey();
+  document.getElementById('aerotun-modal-psk').type = 'password';
+  document.getElementById('aerotun-modal-psk').placeholder = 'Generate or enter the shared key';
   onAeroTUNModeChange();
   updateAeroTUNSetupHint();
   document.getElementById('aerotun-modal').classList.remove('hidden');
+  generateAeroTUNKey(true);
+  generateAeroTUNIdentity();
 }
 
 function openEditAeroTUNModal(id) {
+  if (aeroSaving) return;
   const t = (state.aerotunTunnels || []).find(tun => tun.id === id);
   if (!t) return;
+  aeroModalGeneration++; discardAeroIdentityDraft(aeroIdentityDraft); aeroIdentityDraft = '';
+  document.getElementById('aerotun-retry-identity').classList.add('hidden');
 
   document.getElementById('aerotun-modal-title').innerText = `Edit AeroTUN Interface (${t.name})`;
   document.getElementById('aerotun-modal-id').value = t.id;
@@ -7818,7 +7883,10 @@ function openEditAeroTUNModal(id) {
   document.getElementById('aerotun-modal-endpoint').value = t.remote_endpoint || '';
   document.getElementById('aerotun-modal-local').value = t.local_address || '';
   document.getElementById('aerotun-modal-peer').value = t.peer_address || '';
+  const peerHost = (t.peer_address || '').split('/')[0];
+  document.getElementById('aerotun-modal-peer').dataset.allowedDefault = peerHost ? `${peerHost}/${peerHost.includes(':') ? 128 : 32}` : '';
   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-comment').value = t.comment || '';
@@ -7832,11 +7900,15 @@ function openEditAeroTUNModal(id) {
 }
 
 function closeAeroTUNModal() {
+  if (aeroSaving) return;
+  aeroModalGeneration++; discardAeroIdentityDraft(aeroIdentityDraft); aeroIdentityDraft = '';
+  document.getElementById('aerotun-modal-psk').value = '';
   document.getElementById('aerotun-modal').classList.add('hidden');
 }
 
 async function submitSaveAeroTUN(e) {
   e.preventDefault();
+  if (aeroSaving) return;
   const id = document.getElementById('aerotun-modal-id').value;
   const name = document.getElementById('aerotun-modal-name').value.trim();
   const mode = document.getElementById('aerotun-modal-mode').value;
@@ -7845,7 +7917,8 @@ async function submitSaveAeroTUN(e) {
   const local = document.getElementById('aerotun-modal-local').value.trim();
   const peer = document.getElementById('aerotun-modal-peer').value.trim();
   const psk = document.getElementById('aerotun-modal-psk').value.trim();
-  const mtu = parseInt(document.getElementById('aerotun-modal-mtu').value) || 1420;
+  const mtuValue = document.getElementById('aerotun-modal-mtu').value;
+  const mtu = mtuValue === '' ? 1420 : Number(mtuValue);
   const comment = document.getElementById('aerotun-modal-comment').value.trim();
   const compression = document.getElementById('aerotun-modal-compression').checked;
   const defaultRoute = document.getElementById('aerotun-modal-defroute').checked;
@@ -7855,9 +7928,26 @@ async function submitSaveAeroTUN(e) {
     showToast('Validation Error', 'Remote endpoint (host:port) is required in client mode', 'error');
     return;
   }
+  if (defaultRoute && (mode !== 'client' || !endpoint)) {
+    showToast('Validation Error', 'Default route requires client mode and a fixed remote endpoint.', 'error');
+    return;
+  }
+  if (!Number.isInteger(mtu) || mtu < 576 || mtu > 9000) {
+    showToast('Validation Error', 'MTU must be between 576 and 9000.', 'error');
+    return;
+  }
   const version = parseInt(document.getElementById('aerotun-modal-protocol').value, 10);
+  if (version === 3 && !id && !aeroIdentityDraft) {
+    showToast('Local Key Not Ready', 'Wait for the local identity or retry key generation before saving.', 'error');
+    return;
+  }
+  if (version === 3 && !document.getElementById('aerotun-modal-allowed').value.trim()) {
+    showToast('Allowed Prefix Required', 'Enter at least one allowed peer source prefix, such as the peer tunnel IP /32.', 'error');
+    document.getElementById('aerotun-modal-allowed').focus();
+    return;
+  }
   if (version === 3 && !disabled && !document.getElementById('aerotun-modal-peerkey').value.trim()) {
-    showToast('Peer Key Required', 'Save disabled first, exchange public keys, then enable', 'error');
+    showToast('Peer Key Required', 'Enter the peer public key, or save this tunnel disabled until it is available.', 'error');
     return;
   }
   if (!psk && !id) {
@@ -7865,12 +7955,16 @@ async function submitSaveAeroTUN(e) {
     return;
   }
 
+  aeroSaving = true;
+  const submit = document.querySelector('#aerotun-modal button[type="submit"]');
+  if (submit) submit.disabled = true;
   try {
     const res = await fetch(`${API_BASE}/vpn/aerotun/tunnels`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({
         protocol_version:parseInt(document.getElementById('aerotun-modal-protocol').value,10),
+        identity_draft_id: version === 3 && !id ? aeroIdentityDraft : '',
         peer_public_key:document.getElementById('aerotun-modal-peerkey').value.trim(),
         allowed_ips:document.getElementById('aerotun-modal-allowed').value.split(',').map(s=>s.trim()).filter(Boolean),
         id,
@@ -7890,22 +7984,29 @@ async function submitSaveAeroTUN(e) {
     });
 
     if (res.ok) {
-      const saved = await res.json();
+      await res.json();
+      if (version !== 3) discardAeroIdentityDraft(aeroIdentityDraft);
+      aeroIdentityDraft = '';
+      aeroSaving = false;
+      closeAeroTUNModal();
       await loadAeroTUN(false);
-      if (!id && version === 3) {
-        openEditAeroTUNModal(saved.id);
-        showToast('Identity Saved', 'Copy the local public key and exchange keys with the peer', 'success');
-      } else {
-        closeAeroTUNModal();
-        showToast('AeroTUN Saved', disabled ? 'Configuration saved; tunnel is disabled' : 'Configuration saved; waiting for authenticated peer traffic', 'success');
-      }
+      showToast('AeroTUN Saved', disabled ? 'Configuration saved; tunnel is disabled' : 'Configuration saved; waiting for authenticated peer traffic', 'success');
       fetchData(false);
     } else {
-      const err = await res.text();
-      showToast('Error Saving Tunnel', err, 'error');
+      const err = await responseError(res);
+      if (err.includes('configuration saved but tunnel failed to start')) {
+        aeroIdentityDraft = '';
+        aeroSaving = false;
+        closeAeroTUNModal();
+        await loadAeroTUN(false);
+        showToast('Saved, but not started', err, 'error');
+      } else showToast('Error Saving Tunnel', err, 'error');
     }
   } catch (err) {
     showToast('Network Error', err.message, 'error');
+  } finally {
+    aeroSaving = false;
+    if (submit) submit.disabled = false;
   }
 }
 

+ 9 - 5
internal/web/static/index.html

@@ -8831,14 +8831,17 @@
         <div class="space-y-2">
           <label class="block text-slate-300">Protocol</label>
           <select id="aerotun-modal-protocol" onchange="updateAeroTUNSetupHint()" class="w-full bg-slate-800 rounded p-2"><option value="3">v3 — Noise, pinned identities and forward secrecy</option><option value="2">v2 — legacy PSK protocol</option></select>
+          <div id="aerotun-v3-fields" class="space-y-2">
           <label class="block text-slate-300">Local public key</label>
-          <input id="aerotun-modal-public" readonly class="w-full bg-slate-800 rounded p-2 font-mono" placeholder="Created when you save this tunnel">
+          <input id="aerotun-modal-public" readonly class="w-full bg-slate-800 rounded p-2 font-mono" placeholder="Generating local identity…">
           <button type="button" onclick="copyAeroTUNPublicKey()" class="text-sky-400 underline">Copy local public key</button>
+          <button type="button" id="aerotun-retry-identity" onclick="generateAeroTUNIdentity()" class="hidden text-sky-400 underline ml-3">Retry key generation</button>
           <label class="block text-slate-300">Pinned peer public key</label>
           <input id="aerotun-modal-peerkey" oninput="updateAeroTUNSetupHint()" class="w-full bg-slate-800 rounded p-2 font-mono" placeholder="Public key from the other endpoint">
           <label class="block text-slate-300">Allowed peer source prefixes (comma separated)</label>
           <input id="aerotun-modal-allowed" class="w-full bg-slate-800 rounded p-2 font-mono" placeholder="10.99.0.2/32, 192.168.20.0/24">
-          <p class="text-xs text-slate-400">For v3, save disabled first to create a local identity. Exchange public keys through a trusted channel, then enable both endpoints. An empty PSK on edit keeps the existing key.</p>
+          <p class="text-xs text-slate-400">Exchange public keys through a trusted channel. Allowed source prefixes do not create routes.</p>
+          </div>
         </div>
         <div class="grid grid-cols-2 gap-4">
           <div>
@@ -8857,7 +8860,7 @@
         <div class="grid grid-cols-2 gap-4">
           <div>
             <label class="block font-semibold text-slate-300 mb-1.5">Listen UDP Port</label>
-            <input type="number" id="aerotun-modal-port" placeholder="51830" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-sky-500">
+            <input type="number" id="aerotun-modal-port" min="0" max="65535" placeholder="51830" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-sky-500">
             <span class="text-[10px] text-slate-400">Default: 51830</span>
           </div>
           <div>
@@ -8875,7 +8878,7 @@
           </div>
           <div>
             <label class="block font-semibold text-slate-300 mb-1.5">Peer Tunnel IP (Optional CIDR)</label>
-            <input type="text" id="aerotun-modal-peer" placeholder="10.99.0.2/24" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-sky-500">
+            <input type="text" id="aerotun-modal-peer" oninput="onAeroTUNPeerChange()" placeholder="10.99.0.2/24" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-sky-500">
             <span class="text-[10px] text-slate-400">Adds a host route to the peer overlay IP</span>
           </div>
         </div>
@@ -8884,6 +8887,7 @@
           <div class="flex items-center justify-between mb-1.5">
             <label class="block font-semibold text-slate-300">Pre-Shared Key (PSK) *</label>
             <button type="button" onclick="togglePasswordVisibility('aerotun-modal-psk')" class="text-[11px] text-slate-300 underline">Show / hide PSK</button>
+            <button type="button" onclick="copyAeroTUNPSK()" class="text-[11px] text-sky-400 underline">Copy PSK</button>
             <button type="button" onclick="generateAeroTUNKey()" class="text-[11px] text-sky-400 hover:text-sky-300 underline font-mono">⚡ Generate Key</button>
           </div>
           <input type="password" autocomplete="new-password" id="aerotun-modal-psk" placeholder="Leave blank when editing to retain the existing key" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-sky-500">
@@ -8893,7 +8897,7 @@
         <div class="grid grid-cols-2 gap-4">
           <div>
             <label class="block font-semibold text-slate-300 mb-1.5">MTU</label>
-            <input type="number" id="aerotun-modal-mtu" value="1420" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-sky-500">
+            <input type="number" id="aerotun-modal-mtu" min="576" max="9000" value="1420" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-sky-500">
             <span class="text-[10px] text-slate-400">Maximum requested MTU; reduced to fit the local underlay</span>
           </div>
           <div>

+ 11 - 6
scripts/ui-smoke.cjs

@@ -57,10 +57,17 @@ fs.mkdirSync(root,{recursive:true});
     await page.evaluate(()=>fetch('/api/review/failure'));
     assert.doesNotMatch(await page.locator('#telemetry-health').getAttribute('title'),/review\/failure/);
     results.push('failed telemetry visible and cleared after recovery');
+    await page.route('**/api/vpn/aerotun/identity-drafts', route=>route.fulfill({json:{draft_id:'audit-identity',public_key:'audit-public-key'}}));
     await page.evaluate(()=>{navigate('aerotun');openAddAeroTUNModal()});
     await page.waitForFunction(()=>document.getElementById('aerotun-modal-psk').value.length>0);
-    assert.match(await page.locator('#aerotun-setup-hint').innerText(),/Save disabled/);
+    await page.waitForFunction(()=>document.getElementById('aerotun-modal-public').value==='audit-public-key');
+    assert.match(await page.locator('#aerotun-setup-hint').innerText(),/Copy the local public key/);
     assert.equal(await page.locator('#aerotun-modal-disabled').isChecked(),true);
+    await page.locator('#aerotun-modal-peer').fill('10.12.0.9/24');
+    assert.equal(await page.locator('#aerotun-modal-allowed').inputValue(),'10.12.0.9/32');
+    await page.locator('#aerotun-modal-allowed').fill('192.0.2.0/24');
+    await page.locator('#aerotun-modal-peer').fill('10.12.0.10/24');
+    assert.equal(await page.locator('#aerotun-modal-allowed').inputValue(),'192.0.2.0/24');
     const existing = await page.evaluate(() => state.aerotunTunnels || []);
     let draft;
     await page.route('**/api/vpn/aerotun/tunnels', async route => {
@@ -68,6 +75,7 @@ fs.mkdirSync(root,{recursive:true});
         const request = route.request().postDataJSON();
         assert.equal(request.disabled,true);
         assert.equal(request.protocol_version,3);
+        assert.equal(request.identity_draft_id,'audit-identity');
         assert.equal('private_key' in request,false);
         draft = {...request,id:'audit-draft',public_key:'audit-public-key',has_psk:true};
         delete draft.psk;
@@ -75,13 +83,10 @@ fs.mkdirSync(root,{recursive:true});
       } else await route.fulfill({json:[...existing,...(draft?[draft]:[])]});
     });
     await page.evaluate(()=>submitSaveAeroTUN({preventDefault(){}}));
-    assert.equal(await page.locator('#aerotun-modal-id').inputValue(),'audit-draft');
-    assert.equal(await page.locator('#aerotun-modal-public').inputValue(),'audit-public-key');
     assert.equal(await page.locator('#aerotun-modal-psk').inputValue(),'');
-    assert.equal(await page.locator('#aerotun-modal').isVisible(),true);
-    results.push('saved AeroTUN draft stays open and presents generated identity');
+    assert.equal(await page.locator('#aerotun-modal').isVisible(),false);
+    results.push('AeroTUN public key available before save and draft identity submitted');
     await page.screenshot({path:root+'/aerotun-setup.png'});
-    await page.evaluate(()=>closeAeroTUNModal());
     results.push('AeroTUN guided setup and disabled default');
     await page.evaluate(()=>{navigate('bwtest');openBwTestServerModal()});
     await page.locator('#bwtest-server-port-input').fill('34567');