فهرست منبع

feat(wireguard): enable full editing of WireGuard interfaces and peers, auto-derived pubkey, peer rename/state, and validation

Proxmox Router 1 هفته پیش
والد
کامیت
a9435510e0
6فایلهای تغییر یافته به همراه620 افزوده شده و 143 حذف شده
  1. 5 1
      internal/api/api.go
  2. 75 10
      internal/api/vpn_handlers.go
  3. 13 0
      internal/vpn/types.go
  4. 186 47
      internal/vpn/wireguard.go
  5. 218 35
      internal/web/static/app.js
  6. 123 50
      internal/web/static/index.html

+ 5 - 1
internal/api/api.go

@@ -169,8 +169,12 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("POST /api/vpn/wireguard/interfaces/{name}/stop", s.handleStopWireGuardInterface)
 	s.mux.HandleFunc("GET /api/vpn/wireguard/peers", s.handleListWireGuardPeers)
 	s.mux.HandleFunc("POST /api/vpn/wireguard/peers", s.handleSaveWireGuardPeer)
-	s.mux.HandleFunc("DELETE /api/vpn/wireguard/peers/{iface}/{pubkey}", s.handleDeleteWireGuardPeer)
+	s.mux.HandleFunc("POST /api/vpn/wireguard/peers/state", s.handleSetWireGuardPeerState)
+	s.mux.HandleFunc("POST /api/vpn/wireguard/peers/delete", s.handleDeleteWireGuardPeer)
+	s.mux.HandleFunc("DELETE /api/vpn/wireguard/peers", s.handleDeleteWireGuardPeer)
+	s.mux.HandleFunc("DELETE /api/vpn/wireguard/peers/{iface}/{pubkey...}", s.handleDeleteWireGuardPeer)
 	s.mux.HandleFunc("POST /api/vpn/wireguard/genkeys", s.handleWireGuardGenKeys)
+	s.mux.HandleFunc("POST /api/vpn/wireguard/calc-pubkey", s.handleWireGuardCalcPubKey)
 
 	// PPTP Multi-Instance
 	s.mux.HandleFunc("GET /api/vpn/pptp/clients", s.handleListPPTP)

+ 75 - 10
internal/api/vpn_handlers.go

@@ -82,17 +82,17 @@ func (s *Server) handleListWireGuardInterfaces(w http.ResponseWriter, r *http.Re
 }
 
 func (s *Server) handleSaveWireGuardInterface(w http.ResponseWriter, r *http.Request) {
-	var iface vpn.WireGuardInterface
-	if err := json.NewDecoder(r.Body).Decode(&iface); err != nil {
+	var req vpn.SaveWireGuardInterfaceRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body")
 		return
 	}
 
-	if err := s.vpnMgr.WireGuard.SaveInterface(iface); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+	if err := s.vpnMgr.WireGuard.SaveInterface(req); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
 		return
 	}
-	writeJSON(w, http.StatusOK, map[string]any{"success": true, "name": iface.Name})
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "name": req.Name})
 }
 
 func (s *Server) handleDeleteWireGuardInterface(w http.ResponseWriter, r *http.Request) {
@@ -143,23 +143,46 @@ func (s *Server) handleListWireGuardPeers(w http.ResponseWriter, r *http.Request
 }
 
 func (s *Server) handleSaveWireGuardPeer(w http.ResponseWriter, r *http.Request) {
-	var peer vpn.WireGuardPeer
-	if err := json.NewDecoder(r.Body).Decode(&peer); err != nil || peer.PublicKey == "" || peer.Interface == "" {
+	var req vpn.SaveWireGuardPeerRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.PublicKey == "" || req.Interface == "" {
 		writeError(w, http.StatusBadRequest, "interface and public_key are required")
 		return
 	}
 
-	if err := s.vpnMgr.WireGuard.SavePeer(peer); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+	if err := s.vpnMgr.WireGuard.SavePeer(req); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
 		return
 	}
-	writeJSON(w, http.StatusOK, map[string]any{"success": true, "interface": peer.Interface, "public_key": peer.PublicKey})
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "interface": req.Interface, "public_key": req.PublicKey})
 }
 
 func (s *Server) handleDeleteWireGuardPeer(w http.ResponseWriter, r *http.Request) {
 	iface := r.PathValue("iface")
 	pubkey := r.PathValue("pubkey")
 
+	if iface == "" || pubkey == "" {
+		iface = r.URL.Query().Get("interface")
+		pubkey = r.URL.Query().Get("public_key")
+	}
+	if iface == "" || pubkey == "" {
+		var body struct {
+			Interface string `json:"interface"`
+			PublicKey string `json:"public_key"`
+		}
+		_ = json.NewDecoder(r.Body).Decode(&body)
+		if body.Interface != "" {
+			iface = body.Interface
+		}
+		if body.PublicKey != "" {
+			pubkey = body.PublicKey
+		}
+	}
+
+	if iface == "" || pubkey == "" {
+		writeError(w, http.StatusBadRequest, "interface and public_key are required")
+		return
+	}
+
 	if err := s.vpnMgr.WireGuard.DeletePeer(iface, pubkey); err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())
 		return
@@ -167,6 +190,32 @@ func (s *Server) handleDeleteWireGuardPeer(w http.ResponseWriter, r *http.Reques
 	writeJSON(w, http.StatusOK, map[string]any{"success": true, "interface": iface, "public_key": pubkey})
 }
 
+func (s *Server) handleSetWireGuardPeerState(w http.ResponseWriter, r *http.Request) {
+	var req struct {
+		Interface string `json:"interface"`
+		PublicKey string `json:"public_key"`
+		Enabled   bool   `json:"enabled"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if req.Interface == "" {
+		req.Interface = r.PathValue("iface")
+	}
+	if req.PublicKey == "" {
+		req.PublicKey = r.PathValue("pubkey")
+	}
+
+	if req.Interface == "" || req.PublicKey == "" {
+		writeError(w, http.StatusBadRequest, "interface and public_key are required")
+		return
+	}
+
+	if err := s.vpnMgr.WireGuard.SetPeerEnabled(req.Interface, req.PublicKey, req.Enabled); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "interface": req.Interface, "public_key": req.PublicKey, "enabled": req.Enabled})
+}
+
 func (s *Server) handleWireGuardGenKeys(w http.ResponseWriter, r *http.Request) {
 	priv, pub, err := s.vpnMgr.WireGuard.GenerateKeyPair()
 	if err != nil {
@@ -179,6 +228,22 @@ func (s *Server) handleWireGuardGenKeys(w http.ResponseWriter, r *http.Request)
 	})
 }
 
+func (s *Server) handleWireGuardCalcPubKey(w http.ResponseWriter, r *http.Request) {
+	var req struct {
+		PrivateKey string `json:"private_key"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.PrivateKey == "" {
+		writeError(w, http.StatusBadRequest, "private_key is required")
+		return
+	}
+	pub, err := s.vpnMgr.WireGuard.CalcPublicKey(req.PrivateKey)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"public_key": pub})
+}
+
 // ======================== PPTP Multi-Instance Handlers ========================
 
 func (s *Server) handleListPPTP(w http.ResponseWriter, r *http.Request) {

+ 13 - 0
internal/vpn/types.go

@@ -45,6 +45,7 @@ type PPPoEInstance struct {
 type WireGuardPeer struct {
 	Interface           string   `json:"interface"`            // Name of parent WG interface (e.g. "wg0")
 	Name                string   `json:"name"`                 // Friendly label (e.g. "Peer-Office", "Laptop")
+	Comment             string   `json:"comment,omitempty"`
 	PublicKey           string   `json:"public_key"`
 	AllowedIPs          []string `json:"allowed_ips"`
 	EndpointAddress     string   `json:"endpoint_address,omitempty"`
@@ -66,6 +67,7 @@ type WireGuardInterface struct {
 	PublicKey   string           `json:"public_key"`
 	Address     string           `json:"address"`     // e.g. "10.10.0.1/24"
 	MTU         int              `json:"mtu"`         // default 1420
+	Comment     string           `json:"comment,omitempty"`
 	Disabled    bool             `json:"disabled"`
 	Running     bool             `json:"running"`     // MikroTik 'R' flag
 	PeersCount  int              `json:"peers_count"`
@@ -73,6 +75,17 @@ type WireGuardInterface struct {
 	LastError   string           `json:"last_error,omitempty"`
 }
 
+type SaveWireGuardInterfaceRequest struct {
+	WireGuardInterface
+	OrigName string `json:"orig_name,omitempty"`
+}
+
+type SaveWireGuardPeerRequest struct {
+	WireGuardPeer
+	OrigInterface string `json:"orig_interface,omitempty"`
+	OrigPublicKey string `json:"orig_public_key,omitempty"`
+}
+
 // ======================== PPTP Client (MikroTik style) ========================
 type PPTPInstance struct {
 	Name          string `json:"name"`           // e.g. "pptp-out1"

+ 186 - 47
internal/vpn/wireguard.go

@@ -68,17 +68,24 @@ func (m *WireGuardManager) GenerateKeyPair() (string, string, error) {
 	}
 	privKey := strings.TrimSpace(string(privOut))
 
-	pubCmd := exec.Command("wg", "pubkey")
-	pubCmd.Stdin = strings.NewReader(privKey)
-	pubOut, err := pubCmd.Output()
+	pubKey, err := m.CalcPublicKey(privKey)
 	if err != nil {
-		return "", "", fmt.Errorf("failed to generate public key: %w", err)
+		return "", "", err
 	}
-	pubKey := strings.TrimSpace(string(pubOut))
 
 	return privKey, pubKey, nil
 }
 
+func (m *WireGuardManager) CalcPublicKey(privateKey string) (string, error) {
+	pubCmd := exec.Command("wg", "pubkey")
+	pubCmd.Stdin = strings.NewReader(strings.TrimSpace(privateKey))
+	pubOut, err := pubCmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("failed to derive public key: %w", err)
+	}
+	return strings.TrimSpace(string(pubOut)), nil
+}
+
 // ListInterfaces returns all WireGuard interfaces with MikroTik running flags
 func (m *WireGuardManager) ListInterfaces() []WireGuardInterface {
 	m.mu.Lock()
@@ -106,51 +113,75 @@ func (m *WireGuardManager) ListInterfaces() []WireGuardInterface {
 	return res
 }
 
-func (m *WireGuardManager) SaveInterface(iface WireGuardInterface) error {
+func (m *WireGuardManager) SaveInterface(req SaveWireGuardInterfaceRequest) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
-	if iface.Name == "" {
+	if req.Name == "" {
 		return fmt.Errorf("interface name is required (e.g. wg0)")
 	}
-	if iface.ListenPort <= 0 {
-		iface.ListenPort = 51820
-	}
-	if iface.Address == "" {
-		iface.Address = "10.10.0.1/24"
+	if req.ListenPort <= 0 {
+		req.ListenPort = 51820
 	}
-	if iface.MTU <= 0 {
-		iface.MTU = 1420
+	if req.MTU <= 0 {
+		req.MTU = 1420
 	}
 
-	// Auto generate keys if not provided
-	if iface.PrivateKey == "" {
+	// Auto generate or derive public key
+	if req.PrivateKey != "" {
+		pub, err := m.CalcPublicKey(req.PrivateKey)
+		if err == nil && pub != "" {
+			req.PublicKey = pub
+		}
+	} else {
 		priv, pub, err := m.GenerateKeyPair()
 		if err == nil {
-			iface.PrivateKey = priv
-			iface.PublicKey = pub
+			req.PrivateKey = priv
+			req.PublicKey = pub
+		}
+	}
+
+	// Support interface renaming: clean up old interface if renamed
+	if req.OrigName != "" && req.OrigName != req.Name {
+		_ = m.stopWGInterface(req.OrigName)
+		_ = os.Remove(fmt.Sprintf("/etc/wireguard/%s.conf", req.OrigName))
+
+		// Update all peers associated with OrigName
+		for i := range m.peers {
+			if m.peers[i].Interface == req.OrigName {
+				m.peers[i].Interface = req.Name
+			}
+		}
+
+		// Remove old interface from slice
+		updated := make([]WireGuardInterface, 0)
+		for _, iface := range m.interfaces {
+			if iface.Name != req.OrigName {
+				updated = append(updated, iface)
+			}
 		}
+		m.interfaces = updated
 	}
 
 	found := false
 	for i, existing := range m.interfaces {
-		if existing.Name == iface.Name {
-			m.interfaces[i] = iface
+		if existing.Name == req.Name {
+			m.interfaces[i] = req.WireGuardInterface
 			found = true
 			break
 		}
 	}
 	if !found {
-		m.interfaces = append(m.interfaces, iface)
+		m.interfaces = append(m.interfaces, req.WireGuardInterface)
 	}
 
 	_ = m.save()
-	_ = m.generateWGConfigFile(iface.Name)
+	_ = m.generateWGConfigFile(req.Name)
 
-	if !iface.Disabled {
-		go m.startWGInterface(iface.Name)
+	if !req.Disabled {
+		go m.startWGInterface(req.Name)
 	} else {
-		go m.stopWGInterface(iface.Name)
+		go m.stopWGInterface(req.Name)
 	}
 
 	return nil
@@ -303,51 +334,154 @@ func (m *WireGuardManager) ListPeers(ifaceFilter string) []WireGuardPeer {
 	return result
 }
 
-func (m *WireGuardManager) SavePeer(peer WireGuardPeer) error {
+func (m *WireGuardManager) SavePeer(req SaveWireGuardPeerRequest) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
-	if peer.Interface == "" {
+	if req.Interface == "" {
 		return fmt.Errorf("target WireGuard interface is required")
 	}
-	if peer.PublicKey == "" {
-		return fmt.Errorf("public key is required")
+	if strings.TrimSpace(req.PublicKey) == "" {
+		return fmt.Errorf("peer public key is required")
+	}
+	req.PublicKey = strings.TrimSpace(req.PublicKey)
+
+	// Validate that peer public key is not the same as the interface's own public key
+	for _, iface := range m.interfaces {
+		if iface.Name == req.Interface && iface.PublicKey == req.PublicKey {
+			return fmt.Errorf("peer public key cannot be identical to interface %s public key", iface.Name)
+		}
+	}
+
+	// Handle modifying an existing peer's public key or moving to a different interface
+	if req.OrigPublicKey != "" && (req.OrigPublicKey != req.PublicKey || (req.OrigInterface != "" && req.OrigInterface != req.Interface)) {
+		oldIface := req.OrigInterface
+		if oldIface == "" {
+			oldIface = req.Interface
+		}
+		// Remove old peer from runtime
+		if _, err := netlink.LinkByName(oldIface); err == nil {
+			_ = exec.Command("wg", "set", oldIface, "peer", req.OrigPublicKey, "remove").Run()
+		}
+
+		// Remove old peer entry
+		updated := make([]WireGuardPeer, 0)
+		for _, p := range m.peers {
+			if !(p.Interface == oldIface && p.PublicKey == req.OrigPublicKey) {
+				updated = append(updated, p)
+			}
+		}
+		m.peers = updated
+
+		if oldIface != req.Interface {
+			_ = m.generateWGConfigFile(oldIface)
+		}
 	}
 
 	found := false
 	for i, existing := range m.peers {
-		if existing.PublicKey == peer.PublicKey && existing.Interface == peer.Interface {
-			m.peers[i] = peer
+		if existing.PublicKey == req.PublicKey && existing.Interface == req.Interface {
+			m.peers[i] = req.WireGuardPeer
 			found = true
 			break
 		}
 	}
 	if !found {
-		m.peers = append(m.peers, peer)
+		m.peers = append(m.peers, req.WireGuardPeer)
 	}
 
 	_ = m.save()
-	_ = m.generateWGConfigFile(peer.Interface)
+	_ = m.generateWGConfigFile(req.Interface)
 
 	// Apply dynamically to kernel if running
-	if _, err := netlink.LinkByName(peer.Interface); err == nil && !peer.Disabled {
-		args := []string{"set", peer.Interface, "peer", peer.PublicKey}
-		if len(peer.AllowedIPs) > 0 {
-			args = append(args, "allowed-ips", strings.Join(peer.AllowedIPs, ","))
-		}
-		if peer.EndpointAddress != "" {
-			ep := peer.EndpointAddress
-			if peer.EndpointPort > 0 {
-				ep = fmt.Sprintf("%s:%d", peer.EndpointAddress, peer.EndpointPort)
+	if _, err := netlink.LinkByName(req.Interface); err == nil {
+		if req.Disabled {
+			_ = exec.Command("wg", "set", req.Interface, "peer", req.PublicKey, "remove").Run()
+		} else {
+			args := []string{"set", req.Interface, "peer", req.PublicKey}
+			if len(req.AllowedIPs) > 0 {
+				args = append(args, "allowed-ips", strings.Join(req.AllowedIPs, ","))
 			}
-			args = append(args, "endpoint", ep)
+			if req.EndpointAddress != "" {
+				ep := req.EndpointAddress
+				if req.EndpointPort > 0 {
+					ep = fmt.Sprintf("%s:%d", req.EndpointAddress, req.EndpointPort)
+				}
+				args = append(args, "endpoint", ep)
+			}
+			if req.PersistentKeepalive > 0 {
+				args = append(args, "persistent-keepalive", strconv.Itoa(req.PersistentKeepalive))
+			}
+			if req.PresharedKey != "" {
+				pskFile, err := os.CreateTemp("", "wg-psk-*")
+				if err == nil {
+					_, _ = pskFile.WriteString(req.PresharedKey)
+					_ = pskFile.Close()
+					args = append(args, "preshared-key", pskFile.Name())
+					defer os.Remove(pskFile.Name())
+				}
+			}
+			_ = exec.Command("wg", args...).Run()
 		}
-		if peer.PersistentKeepalive > 0 {
-			args = append(args, "persistent-keepalive", strconv.Itoa(peer.PersistentKeepalive))
+	}
+
+	return nil
+}
+
+func (m *WireGuardManager) SetPeerEnabled(ifaceName, pubKey string, enabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	found := false
+	for i, p := range m.peers {
+		if p.Interface == ifaceName && p.PublicKey == pubKey {
+			m.peers[i].Disabled = !enabled
+			found = true
+			break
 		}
-		_ = exec.Command("wg", args...).Run()
+	}
+	if !found {
+		return fmt.Errorf("peer with public key %s not found on interface %s", pubKey, ifaceName)
 	}
 
+	_ = m.save()
+	_ = m.generateWGConfigFile(ifaceName)
+
+	if _, err := netlink.LinkByName(ifaceName); err == nil {
+		if !enabled {
+			_ = exec.Command("wg", "set", ifaceName, "peer", pubKey, "remove").Run()
+		} else {
+			for _, p := range m.peers {
+				if p.Interface == ifaceName && p.PublicKey == pubKey {
+					args := []string{"set", ifaceName, "peer", pubKey}
+					if len(p.AllowedIPs) > 0 {
+						args = append(args, "allowed-ips", strings.Join(p.AllowedIPs, ","))
+					}
+					if p.EndpointAddress != "" {
+						ep := p.EndpointAddress
+						if p.EndpointPort > 0 {
+							ep = fmt.Sprintf("%s:%d", p.EndpointAddress, p.EndpointPort)
+						}
+						args = append(args, "endpoint", ep)
+					}
+					if p.PersistentKeepalive > 0 {
+						args = append(args, "persistent-keepalive", strconv.Itoa(p.PersistentKeepalive))
+					}
+					if p.PresharedKey != "" {
+						pskFile, err := os.CreateTemp("", "wg-psk-*")
+						if err == nil {
+							_, _ = pskFile.WriteString(p.PresharedKey)
+							_ = pskFile.Close()
+							args = append(args, "preshared-key", pskFile.Name())
+							defer os.Remove(pskFile.Name())
+						}
+					}
+					_ = exec.Command("wg", args...).Run()
+					break
+				}
+			}
+		}
+	}
 	return nil
 }
 
@@ -390,9 +524,14 @@ func (m *WireGuardManager) generateWGConfigFile(ifaceName string) error {
 
 	var sb strings.Builder
 	sb.WriteString(fmt.Sprintf("# MikroTik-Style WireGuard Interface: %s\n", targetIface.Name))
+	if targetIface.Comment != "" {
+		sb.WriteString(fmt.Sprintf("# Comment: %s\n", targetIface.Comment))
+	}
 	sb.WriteString("[Interface]\n")
 	sb.WriteString(fmt.Sprintf("PrivateKey = %s\n", targetIface.PrivateKey))
-	sb.WriteString(fmt.Sprintf("Address = %s\n", targetIface.Address))
+	if targetIface.Address != "" && targetIface.Address != "0.0.0.0/0" {
+		sb.WriteString(fmt.Sprintf("Address = %s\n", targetIface.Address))
+	}
 	sb.WriteString(fmt.Sprintf("ListenPort = %d\n", targetIface.ListenPort))
 	if targetIface.MTU > 0 {
 		sb.WriteString(fmt.Sprintf("MTU = %d\n", targetIface.MTU))

+ 218 - 35
internal/web/static/app.js

@@ -1094,9 +1094,14 @@ async function loadWireGuard(populateInputs = true) {
           tr.className = 'hover:bg-slate-800/40 transition text-xs';
           tr.innerHTML = `
             <td class="py-2.5 px-3 text-center">${renderMikroTikFlag(iface.running, iface.disabled)}</td>
-            <td class="py-2.5 px-3 font-bold text-white">${iface.name}</td>
+            <td class="py-2.5 px-3">
+              <div class="flex items-center gap-1.5">
+                <span class="font-bold text-white">${escapeHtml(iface.name)}</span>
+                ${iface.comment ? `<span class="text-[10px] text-slate-400 font-sans italic">(${escapeHtml(iface.comment)})</span>` : ''}
+              </div>
+            </td>
             <td class="py-2.5 px-3 text-amber-400 font-mono">${iface.listen_port}</td>
-            <td class="py-2.5 px-3 text-emerald-400 font-mono">${iface.address || '-'}</td>
+            <td class="py-2.5 px-3 text-emerald-400 font-mono">${iface.address || '<span class="text-slate-600 font-sans">--</span>'}</td>
             <td class="py-2.5 px-3 text-slate-300 font-mono">
               <span class="cursor-pointer hover:text-white" onclick="copyToClipboard('${iface.public_key}', 'Public Key')" title="Click to copy: ${iface.public_key}">
                 ${(iface.public_key || '').substring(0, 10)}... 📋
@@ -1106,17 +1111,17 @@ async function loadWireGuard(populateInputs = true) {
             <td class="py-2.5 px-3 text-center">
               <span class="px-2 py-0.5 rounded text-[10px] font-sans bg-slate-800 text-slate-300">${peersOnIface} peer${peersOnIface !== 1 ? 's' : ''}</span>
             </td>
-            <td class="py-2.5 px-3 text-right space-x-2 font-sans">
+            <td class="py-2.5 px-3 text-right space-x-2 font-sans whitespace-nowrap">
               ${iface.running ? `
-                <button onclick="stopWGInterface('${iface.name}')" class="text-xs text-rose-400 hover:text-rose-300">Stop</button>
+                <button onclick="stopWGInterface('${iface.name}')" class="text-xs text-rose-400 hover:text-rose-300 font-medium">Stop</button>
               ` : `
-                <button onclick="startWGInterface('${iface.name}')" class="text-xs bg-rose-600/30 hover:bg-rose-600/50 text-rose-300 px-2 py-0.5 rounded border border-rose-500/30">Start</button>
+                <button onclick="startWGInterface('${iface.name}')" class="text-xs bg-rose-600/30 hover:bg-rose-600/50 text-rose-300 px-2 py-0.5 rounded border border-rose-500/30 font-medium">Start</button>
               `}
-              <button onclick="toggleWGInterfaceState('${iface.name}', ${iface.disabled ? 'true' : 'false'})" class="text-xs text-slate-400 hover:text-white">
+              <button onclick="toggleWGInterfaceState('${iface.name}', ${iface.disabled ? 'true' : 'false'})" class="text-xs text-slate-400 hover:text-white font-medium">
                 ${iface.disabled ? 'Enable' : 'Disable'}
               </button>
-              <button onclick="openEditWGInterfaceModal('${iface.name}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
-              <button onclick="deleteWGInterface('${iface.name}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
+              <button onclick="openEditWGInterfaceModal('${iface.name}')" class="text-xs text-amber-400 hover:text-amber-300 font-medium">Edit</button>
+              <button onclick="deleteWGInterface('${iface.name}')" class="text-xs text-slate-500 hover:text-rose-400 font-medium">Delete</button>
             </td>
           `;
           ifacesTbody.appendChild(tr);
@@ -1165,23 +1170,27 @@ function renderWGPeers() {
       tr.className = 'hover:bg-slate-800/40 transition text-xs';
       tr.innerHTML = `
         <td class="py-2.5 px-3 text-center">${renderMikroTikFlag(isOnline, p.disabled)}</td>
-        <td class="py-2.5 px-3 font-mono font-bold text-rose-400">${p.interface}</td>
-        <td class="py-2.5 px-3 font-bold text-white font-sans">${p.name || 'Unnamed'}</td>
+        <td class="py-2.5 px-3 font-mono font-bold text-rose-400">${escapeHtml(p.interface)}</td>
+        <td class="py-2.5 px-3 font-bold text-white font-sans">${escapeHtml(p.name || 'Unnamed')}</td>
         <td class="py-2.5 px-3 text-slate-300 font-mono">
           <span class="cursor-pointer hover:text-white" onclick="copyToClipboard('${p.public_key}', 'Peer Public Key')" title="${p.public_key}">
-            ${p.public_key.substring(0, 10)}... 📋
+            ${(p.public_key || '').substring(0, 10)}... 📋
           </span>
         </td>
         <td class="py-2.5 px-3 text-emerald-400 font-mono font-semibold">${(p.allowed_ips || []).join(', ')}</td>
-        <td class="py-2.5 px-3 text-slate-400 font-mono">${p.endpoint_address ? (p.endpoint_address + (p.endpoint_port ? ':' + p.endpoint_port : '')) : '-'}</td>
+        <td class="py-2.5 px-3 text-slate-400 font-mono">${p.endpoint_address ? (p.endpoint_address + (p.endpoint_port ? ':' + p.endpoint_port : '')) : '--'}</td>
         <td class="py-2.5 px-3 text-slate-300 font-sans">${p.latest_handshake || 'Never'}</td>
         <td class="py-2.5 px-3 font-mono text-[11px]">
           <span class="text-emerald-400">↓ ${p.transfer_rx_human || '0 B'}</span>
           <span class="text-cyan-400 ml-1">↑ ${p.transfer_tx_human || '0 B'}</span>
         </td>
-        <td class="py-2.5 px-3 text-right font-sans space-x-2">
-          <button onclick="openWGClientConfModal('${p.interface}', '${(p.allowed_ips || [])[0] || '10.10.0.2/32'}')" class="text-xs text-cyan-400 hover:text-cyan-300 font-semibold" title="Generate WireGuard Client Profile (.conf)">Config (.conf)</button>
-          <button onclick="deleteWGPeer('${p.interface}', '${p.public_key}')" class="text-xs text-rose-400 hover:text-rose-300">Delete</button>
+        <td class="py-2.5 px-3 text-right font-sans space-x-2 whitespace-nowrap">
+          <button onclick="toggleWGPeerState('${escapeHtml(p.interface)}', '${escapeHtml(p.public_key)}', ${p.disabled ? 'true' : 'false'})" class="text-xs ${p.disabled ? 'text-emerald-400 hover:text-emerald-300' : 'text-slate-400 hover:text-white'} font-medium">
+            ${p.disabled ? 'Enable' : 'Disable'}
+          </button>
+          <button onclick="openEditWGPeerModal('${escapeHtml(p.interface)}', '${escapeHtml(p.public_key)}')" class="text-xs text-amber-400 hover:text-amber-300 font-medium">Edit</button>
+          <button onclick="openWGClientConfModal('${escapeHtml(p.interface)}', '${(p.allowed_ips || [])[0] || '10.10.0.2/32'}')" class="text-xs text-cyan-400 hover:text-cyan-300 font-semibold" title="Generate WireGuard Client Profile (.conf)">Config (.conf)</button>
+          <button onclick="deleteWGPeer('${escapeHtml(p.interface)}', '${escapeHtml(p.public_key)}')" class="text-xs text-rose-400 hover:text-rose-300 font-medium">Delete</button>
         </td>
       `;
       tbody.appendChild(tr);
@@ -1192,11 +1201,13 @@ function renderWGPeers() {
 function openAddWGInterfaceModal() {
   document.getElementById('wg-iface-modal-title').innerText = 'Add WireGuard Interface';
   document.getElementById('wg-iface-modal-edit-orig').value = '';
-  document.getElementById('wg-iface-modal-name').value = `wg${(state.wgInterfaces || []).length}`;
-  document.getElementById('wg-iface-modal-name').readOnly = false;
+  const nameEl = document.getElementById('wg-iface-modal-name');
+  nameEl.value = `wg${(state.wgInterfaces || []).length}`;
+  nameEl.readOnly = false;
   document.getElementById('wg-iface-modal-port').value = 51820 + (state.wgInterfaces || []).length;
   document.getElementById('wg-iface-modal-addr').value = `10.${10 + (state.wgInterfaces || []).length}.0.1/24`;
   document.getElementById('wg-iface-modal-mtu').value = '1420';
+  document.getElementById('wg-iface-modal-comment').value = '';
   document.getElementById('wg-iface-modal-pubkey').value = '';
   document.getElementById('wg-iface-modal-privkey').value = '';
   document.getElementById('wg-iface-modal-disabled').checked = false;
@@ -1211,11 +1222,13 @@ function openEditWGInterfaceModal(name) {
 
   document.getElementById('wg-iface-modal-title').innerText = `Edit WireGuard Interface: ${name}`;
   document.getElementById('wg-iface-modal-edit-orig').value = name;
-  document.getElementById('wg-iface-modal-name').value = iface.name;
-  document.getElementById('wg-iface-modal-name').readOnly = true;
+  const nameEl = document.getElementById('wg-iface-modal-name');
+  nameEl.value = iface.name;
+  nameEl.readOnly = false; // ALLOW RENAMING
   document.getElementById('wg-iface-modal-port').value = iface.listen_port || 51820;
   document.getElementById('wg-iface-modal-addr').value = iface.address || '';
   document.getElementById('wg-iface-modal-mtu').value = iface.mtu || 1420;
+  document.getElementById('wg-iface-modal-comment').value = iface.comment || '';
   document.getElementById('wg-iface-modal-pubkey').value = iface.public_key || '';
   document.getElementById('wg-iface-modal-privkey').value = iface.private_key || '';
   document.getElementById('wg-iface-modal-disabled').checked = !!iface.disabled;
@@ -1240,12 +1253,51 @@ async function generateWGInterfaceKeys() {
   }
 }
 
+async function onWGInterfacePrivKeyInput() {
+  const privKey = document.getElementById('wg-iface-modal-privkey').value.trim();
+  if (!privKey) return;
+  try {
+    const res = await fetch(`${API_BASE}/vpn/wireguard/calc-pubkey`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ private_key: privKey })
+    });
+    if (res.ok) {
+      const data = await res.json();
+      if (data.public_key) {
+        document.getElementById('wg-iface-modal-pubkey').value = data.public_key;
+      }
+    }
+  } catch (err) {
+    // Ignore while typing
+  }
+}
+
+function toggleWGPrivKeyVisibility() {
+  const el = document.getElementById('wg-iface-modal-privkey');
+  const btn = document.getElementById('btn-toggle-wg-privkey');
+  if (el.type === 'password') {
+    el.type = 'text';
+    btn.innerText = '🙈 Hide';
+  } else {
+    el.type = 'password';
+    btn.innerText = '👁️ Show';
+  }
+}
+
+function copyWGInterfacePubKey() {
+  const pub = document.getElementById('wg-iface-modal-pubkey').value;
+  if (pub) copyToClipboard(pub, 'Interface Public Key');
+}
+
 async function submitSaveWGInterface(e) {
   e.preventDefault();
+  const origName = document.getElementById('wg-iface-modal-edit-orig').value.trim();
   const name = document.getElementById('wg-iface-modal-name').value.trim();
   const port = parseInt(document.getElementById('wg-iface-modal-port').value, 10) || 51820;
   const addr = document.getElementById('wg-iface-modal-addr').value.trim();
   const mtu = parseInt(document.getElementById('wg-iface-modal-mtu').value, 10) || 1420;
+  const comment = document.getElementById('wg-iface-modal-comment').value.trim();
   const pub = document.getElementById('wg-iface-modal-pubkey').value.trim();
   const priv = document.getElementById('wg-iface-modal-privkey').value.trim();
   const disabled = document.getElementById('wg-iface-modal-disabled').checked;
@@ -1255,20 +1307,25 @@ async function submitSaveWGInterface(e) {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({
+        orig_name: origName,
         name,
         listen_port: port,
         address: addr,
         mtu,
+        comment,
         public_key: pub,
         private_key: priv,
         disabled
       })
     });
+    const data = await res.json();
     if (res.ok) {
-      showToast('WireGuard Saved', `Interface ${name} configured`, 'success');
+      showToast('WireGuard Saved', `Interface ${name} configured successfully`, 'success');
       closeAddWGInterfaceModal();
       loadWireGuard(false);
       fetchData(false);
+    } else {
+      showToast('Error', data.error || 'Failed to save interface', 'error');
     }
   } catch (err) {
     showToast('Error', err.message, 'error');
@@ -1311,6 +1368,7 @@ async function toggleWGInterfaceState(name, enable) {
     if (res.ok) {
       showToast(enable ? 'Enabled' : 'Disabled', `${name} state updated`, 'info');
       loadWireGuard(false);
+      fetchData(false);
     }
   } catch (err) {
     showToast('Error', err.message, 'error');
@@ -1318,7 +1376,7 @@ async function toggleWGInterfaceState(name, enable) {
 }
 
 async function deleteWGInterface(name) {
-  if (!confirm(`Are you sure you want to delete WireGuard interface ${name}?`)) return;
+  if (!confirm(`Are you sure you want to delete WireGuard interface ${name}? All associated peers will also be removed.`)) return;
   try {
     const res = await fetch(`${API_BASE}/vpn/wireguard/interfaces/${encodeURIComponent(name)}`, { method: 'DELETE' });
     if (res.ok) {
@@ -1331,30 +1389,72 @@ async function deleteWGInterface(name) {
   }
 }
 
-function openAddWGPeerModal() {
+function populateWGPeerIfaceSelect(selectedIface = '') {
   const sel = document.getElementById('wg-peer-modal-iface');
+  if (!sel) return;
   sel.innerHTML = '';
   (state.wgInterfaces || []).forEach(i => {
     const opt = document.createElement('option');
     opt.value = i.name;
     opt.innerText = `${i.name} (${i.address || 'No IP'})`;
+    if (i.name === selectedIface) opt.selected = true;
     sel.appendChild(opt);
   });
+}
 
+function openAddWGPeerModal() {
   if ((state.wgInterfaces || []).length === 0) {
     showToast('No Interfaces', 'Please add a WireGuard interface first before adding peers', 'warning');
     openAddWGInterfaceModal();
     return;
   }
 
+  document.getElementById('wg-peer-modal-title').innerText = 'Add WireGuard Peer';
+  document.getElementById('wg-peer-modal-edit-orig-iface').value = '';
+  document.getElementById('wg-peer-modal-edit-orig-pubkey').value = '';
+
+  populateWGPeerIfaceSelect();
+
   document.getElementById('wg-peer-modal-name').value = '';
   document.getElementById('wg-peer-modal-pubkey').value = '';
   document.getElementById('wg-peer-modal-allowedips').value = '10.10.0.2/32';
   document.getElementById('wg-peer-modal-endpoint').value = '';
+  document.getElementById('wg-peer-modal-port').value = '';
   document.getElementById('wg-peer-modal-keepalive').value = '25';
   document.getElementById('wg-peer-modal-preshared').value = '';
   document.getElementById('wg-peer-modal-disabled').checked = false;
 
+  document.getElementById('wg-peer-key-warning').classList.add('hidden');
+  document.getElementById('wg-peer-gen-key-box').classList.add('hidden');
+
+  document.getElementById('wg-peer-modal').classList.remove('hidden');
+}
+
+function openEditWGPeerModal(ifaceName, pubKey) {
+  const peer = (state.wgPeers || []).find(p => p.interface === ifaceName && p.public_key === pubKey);
+  if (!peer) {
+    showToast('Not Found', 'Peer not found', 'warning');
+    return;
+  }
+
+  document.getElementById('wg-peer-modal-title').innerText = `Edit WireGuard Peer (${peer.name || ifaceName})`;
+  document.getElementById('wg-peer-modal-edit-orig-iface').value = peer.interface;
+  document.getElementById('wg-peer-modal-edit-orig-pubkey').value = peer.public_key;
+
+  populateWGPeerIfaceSelect(peer.interface);
+
+  document.getElementById('wg-peer-modal-name').value = peer.name || '';
+  document.getElementById('wg-peer-modal-pubkey').value = peer.public_key || '';
+  document.getElementById('wg-peer-modal-allowedips').value = (peer.allowed_ips || []).join(', ');
+  document.getElementById('wg-peer-modal-endpoint').value = peer.endpoint_address || '';
+  document.getElementById('wg-peer-modal-port').value = peer.endpoint_port || '';
+  document.getElementById('wg-peer-modal-keepalive').value = peer.persistent_keepalive ?? 25;
+  document.getElementById('wg-peer-modal-preshared').value = peer.preshared_key || '';
+  document.getElementById('wg-peer-modal-disabled').checked = !!peer.disabled;
+
+  document.getElementById('wg-peer-key-warning').classList.add('hidden');
+  document.getElementById('wg-peer-gen-key-box').classList.add('hidden');
+
   document.getElementById('wg-peer-modal').classList.remove('hidden');
 }
 
@@ -1362,23 +1462,77 @@ function closeAddWGPeerModal() {
   document.getElementById('wg-peer-modal').classList.add('hidden');
 }
 
+async function generatePeerKeypairHelper() {
+  try {
+    const res = await fetch(`${API_BASE}/vpn/wireguard/genkeys`, { method: 'POST' });
+    const keys = await res.json();
+    if (res.ok) {
+      document.getElementById('wg-peer-modal-pubkey').value = keys.public_key;
+      document.getElementById('wg-peer-gen-privkey').value = keys.private_key;
+      document.getElementById('wg-peer-gen-key-box').classList.remove('hidden');
+      checkWGPeerKeyValidity();
+      showToast('Keypair Generated', 'Public key inserted and client private key ready to copy', 'info');
+    }
+  } catch (err) {
+    showToast('Error', 'Failed to generate keypair: ' + err.message, 'error');
+  }
+}
+
+function copyGeneratedPeerPrivKey() {
+  const priv = document.getElementById('wg-peer-gen-privkey').value;
+  if (priv) copyToClipboard(priv, 'Client Private Key');
+}
+
+async function generatePeerPSK() {
+  try {
+    const res = await fetch(`${API_BASE}/vpn/wireguard/genkeys`, { method: 'POST' });
+    const keys = await res.json();
+    if (res.ok && keys.private_key) {
+      document.getElementById('wg-peer-modal-preshared').value = keys.private_key;
+      showToast('PSK Generated', 'Generated 256-bit preshared key', 'info');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+function checkWGPeerKeyValidity() {
+  const ifaceName = document.getElementById('wg-peer-modal-iface').value;
+  const peerPubKey = document.getElementById('wg-peer-modal-pubkey').value.trim();
+  const warningEl = document.getElementById('wg-peer-key-warning');
+  if (!warningEl) return;
+
+  const targetIface = (state.wgInterfaces || []).find(i => i.name === ifaceName);
+  if (targetIface && targetIface.public_key && peerPubKey && targetIface.public_key === peerPubKey) {
+    warningEl.classList.remove('hidden');
+  } else {
+    warningEl.classList.add('hidden');
+  }
+}
+
 async function submitSaveWGPeer(e) {
   e.preventDefault();
+  const origIface = document.getElementById('wg-peer-modal-edit-orig-iface').value.trim();
+  const origPubKey = document.getElementById('wg-peer-modal-edit-orig-pubkey').value.trim();
+
   const iface = document.getElementById('wg-peer-modal-iface').value;
   const name = document.getElementById('wg-peer-modal-name').value.trim();
   const pub = document.getElementById('wg-peer-modal-pubkey').value.trim();
-  const allowed = document.getElementById('wg-peer-modal-allowedips').value.split(',').map(s => s.trim());
-  const ep = document.getElementById('wg-peer-modal-endpoint').value.trim();
-  const keepalive = parseInt(document.getElementById('wg-peer-modal-keepalive').value, 10) || 25;
+  const allowed = document.getElementById('wg-peer-modal-allowedips').value
+    .split(',')
+    .map(s => s.trim())
+    .filter(s => s.length > 0);
+  const epHost = document.getElementById('wg-peer-modal-endpoint').value.trim();
+  const epPort = parseInt(document.getElementById('wg-peer-modal-port').value, 10) || 0;
+  const keepalive = parseInt(document.getElementById('wg-peer-modal-keepalive').value, 10) || 0;
   const psk = document.getElementById('wg-peer-modal-preshared').value.trim();
   const disabled = document.getElementById('wg-peer-modal-disabled').checked;
 
-  let epAddr = ep;
-  let epPort = 0;
-  if (ep.includes(':')) {
-    const parts = ep.split(':');
-    epAddr = parts[0];
-    epPort = parseInt(parts[1], 10) || 0;
+  // Validation
+  const targetIface = (state.wgInterfaces || []).find(i => i.name === iface);
+  if (targetIface && targetIface.public_key === pub) {
+    showToast('Invalid Peer Key', 'Peer public key cannot be the same as the interface public key!', 'error');
+    return;
   }
 
   try {
@@ -1386,21 +1540,45 @@ async function submitSaveWGPeer(e) {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({
+        orig_interface: origIface,
+        orig_public_key: origPubKey,
         interface: iface,
         name,
         public_key: pub,
         allowed_ips: allowed,
-        endpoint_address: epAddr,
+        endpoint_address: epHost,
         endpoint_port: epPort,
         persistent_keepalive: keepalive,
         preshared_key: psk,
         disabled
       })
     });
+    const data = await res.json();
     if (res.ok) {
-      showToast('Peer Saved', `Peer ${name} added to ${iface}`, 'success');
+      showToast('Peer Saved', `Peer ${name} saved on ${iface}`, 'success');
       closeAddWGPeerModal();
       loadWireGuard(false);
+    } else {
+      showToast('Error', data.error || 'Failed to save peer', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleWGPeerState(iface, pubkey, enable) {
+  try {
+    const res = await fetch(`${API_BASE}/vpn/wireguard/peers/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ interface: iface, public_key: pubkey, enabled: enable })
+    });
+    const data = await res.json();
+    if (res.ok) {
+      showToast(enable ? 'Peer Enabled' : 'Peer Disabled', `Peer state updated on ${iface}`, 'info');
+      loadWireGuard(false);
+    } else {
+      showToast('Error', data.error || 'Failed to update peer state', 'error');
     }
   } catch (err) {
     showToast('Error', err.message, 'error');
@@ -1410,12 +1588,17 @@ async function submitSaveWGPeer(e) {
 async function deleteWGPeer(iface, pubkey) {
   if (!confirm(`Are you sure you want to remove peer from ${iface}?`)) return;
   try {
-    const res = await fetch(`${API_BASE}/vpn/wireguard/peers/${encodeURIComponent(iface)}/${encodeURIComponent(pubkey)}`, {
-      method: 'DELETE'
+    const res = await fetch(`${API_BASE}/vpn/wireguard/peers/delete`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ interface: iface, public_key: pubkey })
     });
+    const data = await res.json();
     if (res.ok) {
       showToast('Peer Deleted', 'WireGuard peer removed', 'success');
       loadWireGuard(false);
+    } else {
+      showToast('Error', data.error || 'Failed to remove peer', 'error');
     }
   } catch (err) {
     showToast('Error', err.message, 'error');

+ 123 - 50
internal/web/static/index.html

@@ -3041,108 +3041,181 @@
   </div>
 
   <!-- ================= MODAL: WIREGUARD INTERFACE ================= -->
+  <!-- ================= MODAL: ADD / EDIT WIREGUARD INTERFACE ================= -->
   <div id="wg-iface-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
     <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">
       <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
-        <h3 id="wg-iface-modal-title" class="text-sm font-bold text-white">Add WireGuard Interface</h3>
+        <div class="flex items-center gap-2">
+          <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-rose-500/20 text-rose-300 border border-rose-500/30 font-semibold">/interface wireguard</span>
+          <h3 id="wg-iface-modal-title" class="text-sm font-bold text-white">Add WireGuard Interface</h3>
+        </div>
         <button onclick="closeAddWGInterfaceModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
       </div>
-      <form onsubmit="submitSaveWGInterface(event)" class="p-6 space-y-4">
+      <form onsubmit="submitSaveWGInterface(event)" class="p-6 space-y-4 text-xs">
         <input type="hidden" id="wg-iface-modal-edit-orig">
         <div class="grid grid-cols-2 gap-4">
           <div>
-            <label class="block text-xs font-semibold text-slate-300 mb-1.5">Interface Name *</label>
-            <input type="text" id="wg-iface-modal-name" placeholder="wg0" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-rose-500">
+            <label class="block font-semibold text-slate-300 mb-1.5">Interface Name *</label>
+            <input type="text" id="wg-iface-modal-name" placeholder="wg0" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-rose-500">
+            <p class="text-[10px] text-slate-500 mt-1">e.g. wg0, wg-office, wgx-Space</p>
           </div>
           <div>
-            <label class="block text-xs font-semibold text-slate-300 mb-1.5">Listen Port *</label>
-            <input type="number" id="wg-iface-modal-port" value="51820" min="1" max="65535" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-rose-500">
+            <label class="block font-semibold text-slate-300 mb-1.5">Listen Port *</label>
+            <input type="number" id="wg-iface-modal-port" value="51820" min="1" max="65535" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-rose-500">
           </div>
         </div>
+
         <div class="grid grid-cols-2 gap-4">
           <div>
-            <label class="block text-xs font-semibold text-slate-300 mb-1.5">Tunnel IP (CIDR) *</label>
-            <input type="text" id="wg-iface-modal-addr" placeholder="10.10.0.1/24" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-rose-500">
+            <label class="block font-semibold text-slate-300 mb-1.5">Tunnel IP (CIDR)</label>
+            <input type="text" id="wg-iface-modal-addr" placeholder="10.10.0.1/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-rose-500">
+            <p class="text-[10px] text-slate-500 mt-1">Assigned IP/mask, or leave blank</p>
           </div>
           <div>
-            <label class="block text-xs font-semibold text-slate-300 mb-1.5">MTU</label>
-            <input type="number" id="wg-iface-modal-mtu" value="1420" min="1280" max="1500" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+            <label class="block font-semibold text-slate-300 mb-1.5">MTU</label>
+            <input type="number" id="wg-iface-modal-mtu" value="1420" min="1280" max="1500" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
           </div>
         </div>
+
         <div>
-          <div class="flex items-center justify-between mb-1.5">
-            <label class="text-xs font-semibold text-slate-300">Public Key</label>
-            <button type="button" onclick="generateWGInterfaceKeys()" class="text-[10px] text-cyan-400 hover:text-cyan-300 font-semibold">Generate New Keypair</button>
-          </div>
-          <input type="text" id="wg-iface-modal-pubkey" readonly class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-slate-300 font-mono">
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment / Alias</label>
+          <input type="text" id="wg-iface-modal-comment" placeholder="e.g. Main Roadwarrior Server, Site-to-Site tunnel" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-rose-500">
         </div>
-        <div>
-          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Private Key (Secret) *</label>
-          <input type="password" id="wg-iface-modal-privkey" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-slate-300 font-mono focus:outline-none focus:border-rose-500">
+
+        <div class="bg-slate-950/60 border border-slate-800/80 rounded-xl p-3.5 space-y-3">
+          <div class="flex items-center justify-between">
+            <span class="text-[11px] font-bold text-slate-400 uppercase tracking-wider font-mono">Cryptographic Keypair</span>
+            <button type="button" onclick="generateWGInterfaceKeys()" class="text-[11px] text-rose-400 hover:text-rose-300 font-semibold flex items-center gap-1">
+              <span>⚡ Generate New Keypair</span>
+            </button>
+          </div>
+
+          <div>
+            <div class="flex items-center justify-between mb-1">
+              <label class="text-[11px] font-semibold text-slate-300">Public Key</label>
+              <button type="button" onclick="copyWGInterfacePubKey()" class="text-[10px] text-cyan-400 hover:text-cyan-300">📋 Copy Public Key</button>
+            </div>
+            <input type="text" id="wg-iface-modal-pubkey" readonly class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-slate-300 font-mono text-[11px]">
+          </div>
+
+          <div>
+            <div class="flex items-center justify-between mb-1">
+              <label class="text-[11px] font-semibold text-slate-300">Private Key (Secret) *</label>
+              <button type="button" onclick="toggleWGPrivKeyVisibility()" id="btn-toggle-wg-privkey" class="text-[10px] text-slate-400 hover:text-white">👁️ Show</button>
+            </div>
+            <input type="password" id="wg-iface-modal-privkey" required oninput="onWGInterfacePrivKeyInput()" class="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono text-[11px] focus:outline-none focus:border-rose-500">
+            <p class="text-[10px] text-slate-500 mt-1">You can paste any existing base64 private key; the public key will update automatically.</p>
+          </div>
         </div>
-        <div class="pt-2">
-          <label class="flex items-center space-x-2 cursor-pointer">
-            <input type="checkbox" id="wg-iface-modal-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500">
-            <span class="text-xs text-slate-300 font-medium">Disabled (X)</span>
+
+        <div class="pt-1">
+          <label class="flex items-center space-x-2.5 cursor-pointer">
+            <input type="checkbox" id="wg-iface-modal-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500 focus:ring-rose-500">
+            <span class="font-medium text-slate-300">Disabled (X)</span>
           </label>
         </div>
+
         <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
-          <button type="button" onclick="closeAddWGInterfaceModal()" class="px-4 py-2 rounded-lg text-xs font-medium text-slate-400 hover:bg-slate-800">Cancel</button>
-          <button type="submit" class="px-5 py-2 rounded-lg text-xs font-semibold bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-500/20">Save Interface</button>
+          <button type="button" onclick="closeAddWGInterfaceModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800 transition">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-500/20 transition">Save Interface</button>
         </div>
       </form>
     </div>
   </div>
 
-  <!-- ================= MODAL: ADD WIREGUARD PEER ================= -->
+  <!-- ================= MODAL: ADD / EDIT WIREGUARD PEER ================= -->
   <div id="wg-peer-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
     <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">
       <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
-        <h3 id="wg-peer-modal-title" class="text-sm font-bold text-white">Add WireGuard Peer</h3>
+        <div class="flex items-center gap-2">
+          <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-rose-500/20 text-rose-300 border border-rose-500/30 font-semibold">/interface wireguard peers</span>
+          <h3 id="wg-peer-modal-title" class="text-sm font-bold text-white">Add WireGuard Peer</h3>
+        </div>
         <button onclick="closeAddWGPeerModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
       </div>
-      <form onsubmit="submitSaveWGPeer(event)" class="p-6 space-y-4">
+
+      <form onsubmit="submitSaveWGPeer(event)" class="p-6 space-y-4 text-xs">
+        <input type="hidden" id="wg-peer-modal-edit-orig-iface">
+        <input type="hidden" id="wg-peer-modal-edit-orig-pubkey">
+
         <div class="grid grid-cols-2 gap-4">
           <div>
-            <label class="block text-xs font-semibold text-slate-300 mb-1.5">WireGuard Interface *</label>
-            <select id="wg-peer-modal-iface" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-rose-500"></select>
+            <label class="block font-semibold text-slate-300 mb-1.5">WireGuard Interface *</label>
+            <select id="wg-peer-modal-iface" onchange="checkWGPeerKeyValidity()" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-rose-500"></select>
           </div>
           <div>
-            <label class="block text-xs font-semibold text-slate-300 mb-1.5">Peer Name / Alias *</label>
-            <input type="text" id="wg-peer-modal-name" placeholder="Laptop, iPhone, Branch" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white">
+            <label class="block font-semibold text-slate-300 mb-1.5">Peer Name / Alias *</label>
+            <input type="text" id="wg-peer-modal-name" placeholder="Laptop, iPhone, Branch-Athens" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-rose-500">
           </div>
         </div>
+
         <div>
-          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Peer Public Key *</label>
-          <input type="text" id="wg-peer-modal-pubkey" placeholder="Base64 44-character public key" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+          <div class="flex items-center justify-between mb-1.5">
+            <label class="font-semibold text-slate-300">Peer Public Key *</label>
+            <button type="button" onclick="generatePeerKeypairHelper()" class="text-[11px] text-cyan-400 hover:text-cyan-300 font-semibold flex items-center gap-1">
+              <span>⚡ Generate Client Keypair</span>
+            </button>
+          </div>
+          <input type="text" id="wg-peer-modal-pubkey" oninput="checkWGPeerKeyValidity()" placeholder="Base64 44-character public key" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-rose-500">
+          
+          <!-- Warning if user tries to paste interface's own public key -->
+          <div id="wg-peer-key-warning" class="hidden text-rose-400 text-[11px] font-sans mt-1.5 bg-rose-950/50 p-2.5 rounded-lg border border-rose-800/70">
+            ⚠️ <strong>Invalid Key:</strong> The peer public key cannot be the same as the interface's own public key. A WireGuard node cannot connect to itself!
+          </div>
+
+          <!-- Box showing generated client secret key if generated here -->
+          <div id="wg-peer-gen-key-box" class="hidden mt-2.5 bg-slate-950/80 p-3 rounded-xl border border-cyan-800/60 text-xs font-mono space-y-1.5">
+            <div class="flex items-center justify-between text-[11px] text-cyan-300 font-bold font-sans">
+              <span>Generated Client Private Key (Secret):</span>
+              <button type="button" onclick="copyGeneratedPeerPrivKey()" class="text-white hover:text-cyan-300 font-semibold text-[10px] bg-slate-800 px-2 py-0.5 rounded border border-slate-700">📋 Copy Secret</button>
+            </div>
+            <input type="text" id="wg-peer-gen-privkey" readonly class="w-full bg-slate-900 border border-slate-800 rounded px-2.5 py-1.5 text-slate-300 text-xs font-mono">
+            <p class="text-[10px] text-slate-400 font-sans">Copy and save this private key to configure the client device (e.g. phone or remote router).</p>
+          </div>
         </div>
+
         <div>
-          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Allowed IPs (CIDR) *</label>
-          <input type="text" id="wg-peer-modal-allowedips" placeholder="e.g. 10.10.0.2/32, 192.168.20.0/24" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+          <label class="block font-semibold text-slate-300 mb-1.5">Allowed IPs (CIDR) *</label>
+          <input type="text" id="wg-peer-modal-allowedips" placeholder="e.g. 10.10.0.2/32, 192.168.20.0/24 or 0.0.0.0/0" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-rose-500">
+          <p class="text-[10px] text-slate-500 mt-1">IP ranges routed to/from this peer (comma-separated)</p>
         </div>
+
+        <div class="grid grid-cols-3 gap-3">
+          <div class="col-span-2">
+            <label class="block font-semibold text-slate-300 mb-1.5">Endpoint Host/IP (Optional)</label>
+            <input type="text" id="wg-peer-modal-endpoint" placeholder="e.g. 10.2.145.253 or vpn.example.com" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-rose-500">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Endpoint Port</label>
+            <input type="number" id="wg-peer-modal-port" placeholder="51820" min="1" max="65535" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-rose-500">
+          </div>
+        </div>
+
         <div class="grid grid-cols-2 gap-4">
           <div>
-            <label class="block text-xs font-semibold text-slate-300 mb-1.5">Endpoint (Optional)</label>
-            <input type="text" id="wg-peer-modal-endpoint" placeholder="host:port" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+            <label class="block font-semibold text-slate-300 mb-1.5">Keepalive (seconds)</label>
+            <input type="number" id="wg-peer-modal-keepalive" value="25" min="0" max="65535" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-rose-500">
+            <p class="text-[10px] text-slate-500 mt-1">25s recommended for NAT traversal</p>
           </div>
           <div>
-            <label class="block text-xs font-semibold text-slate-300 mb-1.5">Keepalive (sec)</label>
-            <input type="number" id="wg-peer-modal-keepalive" value="25" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+            <div class="flex items-center justify-between mb-1.5">
+              <label class="font-semibold text-slate-300">Preshared Key</label>
+              <button type="button" onclick="generatePeerPSK()" class="text-[10px] text-purple-400 hover:text-purple-300 font-semibold">Generate</button>
+            </div>
+            <input type="password" id="wg-peer-modal-preshared" placeholder="Optional PSK" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono text-xs focus:outline-none focus:border-rose-500">
           </div>
         </div>
-        <div>
-          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Preshared Key (Optional)</label>
-          <input type="password" id="wg-peer-modal-preshared" placeholder="Optional PSK for extra post-quantum security" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
-        </div>
-        <div class="pt-2">
-          <label class="flex items-center space-x-2 cursor-pointer">
-            <input type="checkbox" id="wg-peer-modal-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500">
-            <span class="text-xs text-slate-300 font-medium">Disabled (X)</span>
+
+        <div class="pt-1">
+          <label class="flex items-center space-x-2.5 cursor-pointer">
+            <input type="checkbox" id="wg-peer-modal-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500 focus:ring-rose-500">
+            <span class="font-medium text-slate-300">Disabled (X)</span>
           </label>
         </div>
+
         <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
-          <button type="button" onclick="closeAddWGPeerModal()" class="px-4 py-2 rounded-lg text-xs font-medium text-slate-400 hover:bg-slate-800">Cancel</button>
-          <button type="submit" class="px-5 py-2 rounded-lg text-xs font-semibold bg-rose-600 hover:bg-rose-500 text-white">Save Peer</button>
+          <button type="button" onclick="closeAddWGPeerModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800 transition">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-500/20 transition">Save Peer</button>
         </div>
       </form>
     </div>
@@ -5127,6 +5200,6 @@
     </div>
   </div>
 
-  <script src="app.js?v=4.0"></script>
+  <script src="app.js?v=4.1"></script>
 </body>
 </html>