Jelajahi Sumber

feat(vxlan): implement Virtual Extensible LAN (VXLAN) with static VTEPs, CLI, API and Web UI (v1.7.0)

Proxmox Router 2 hari lalu
induk
melakukan
322b8406da

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.6.0
+1.7.0

TEMPAT SAMPAH
bin/debtik


TEMPAT SAMPAH
bin/debtik-cli


+ 18 - 0
internal/api/api.go

@@ -266,6 +266,24 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("DELETE /api/vpn/eoip/{id}", s.handleDeleteEoIP)
 	s.mux.HandleFunc("POST /api/vpn/eoip/{id}/state", s.handleSetEoIPState)
 
+	// VXLAN (Virtual Extensible LAN overlay network)
+	s.mux.HandleFunc("GET /api/vpn/vxlan", s.handleListVXLAN)
+	s.mux.HandleFunc("POST /api/vpn/vxlan", s.handleSaveVXLAN)
+	s.mux.HandleFunc("DELETE /api/vpn/vxlan/{id}", s.handleDeleteVXLAN)
+	s.mux.HandleFunc("POST /api/vpn/vxlan/{id}/state", s.handleSetVXLANState)
+	s.mux.HandleFunc("GET /api/vpn/vxlan/vteps", s.handleListVXLANVteps)
+	s.mux.HandleFunc("POST /api/vpn/vxlan/vteps", s.handleSaveVXLANVtep)
+	s.mux.HandleFunc("DELETE /api/vpn/vxlan/{id}/vteps/{vtepid}", s.handleDeleteVXLANVtep)
+
+	// VXLAN aliases under /api/interface/vxlan
+	s.mux.HandleFunc("GET /api/interface/vxlan", s.handleListVXLAN)
+	s.mux.HandleFunc("POST /api/interface/vxlan", s.handleSaveVXLAN)
+	s.mux.HandleFunc("DELETE /api/interface/vxlan/{id}", s.handleDeleteVXLAN)
+	s.mux.HandleFunc("POST /api/interface/vxlan/{id}/state", s.handleSetVXLANState)
+	s.mux.HandleFunc("GET /api/interface/vxlan/vteps", s.handleListVXLANVteps)
+	s.mux.HandleFunc("POST /api/interface/vxlan/vteps", s.handleSaveVXLANVtep)
+	s.mux.HandleFunc("DELETE /api/interface/vxlan/{id}/vteps/{vtepid}", s.handleDeleteVXLANVtep)
+
 	// AeroTUN (High-speed ChaCha20-Poly1305 + LZ4 Encrypted UDP Tunnel)
 	s.mux.HandleFunc("GET /api/vpn/aerotun/tunnels", s.handleListAeroTUNTunnels)
 	s.mux.HandleFunc("POST /api/vpn/aerotun/tunnels", s.handleSaveAeroTUNTunnel)

+ 137 - 0
internal/api/vxlan_handlers.go

@@ -0,0 +1,137 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"debtik/internal/vpn"
+)
+
+// ======================== VXLAN Handlers ========================
+
+func (s *Server) handleListVXLAN(w http.ResponseWriter, r *http.Request) {
+	if s.vpnMgr == nil || s.vpnMgr.VXLAN == nil {
+		writeJSON(w, http.StatusOK, []*vpn.VXLANInstance{})
+		return
+	}
+	list := s.vpnMgr.VXLAN.List()
+	writeJSON(w, http.StatusOK, list)
+}
+
+func (s *Server) handleSaveVXLAN(w http.ResponseWriter, r *http.Request) {
+	if s.vpnMgr == nil || s.vpnMgr.VXLAN == nil {
+		writeError(w, http.StatusServiceUnavailable, "vxlan subsystem unavailable")
+		return
+	}
+	var inst vpn.VXLANInstance
+	if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	if err := s.vpnMgr.VXLAN.Save(&inst); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success": true,
+		"id":      inst.ID,
+		"name":    inst.Name,
+		"vni":     inst.VNI,
+		"port":    inst.Port,
+	})
+}
+
+func (s *Server) handleDeleteVXLAN(w http.ResponseWriter, r *http.Request) {
+	if s.vpnMgr == nil || s.vpnMgr.VXLAN == nil {
+		writeError(w, http.StatusServiceUnavailable, "vxlan subsystem unavailable")
+		return
+	}
+	id := r.PathValue("id")
+	if id == "" {
+		writeError(w, http.StatusBadRequest, "interface id required")
+		return
+	}
+	if err := s.vpnMgr.VXLAN.Delete(id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleSetVXLANState(w http.ResponseWriter, r *http.Request) {
+	if s.vpnMgr == nil || s.vpnMgr.VXLAN == nil {
+		writeError(w, http.StatusServiceUnavailable, "vxlan subsystem unavailable")
+		return
+	}
+	id := r.PathValue("id")
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := s.vpnMgr.VXLAN.SetState(id, req.Disabled); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "disabled": req.Disabled})
+}
+
+// ======================== VXLAN VTEP Handlers ========================
+
+func (s *Server) handleListVXLANVteps(w http.ResponseWriter, r *http.Request) {
+	if s.vpnMgr == nil || s.vpnMgr.VXLAN == nil {
+		writeJSON(w, http.StatusOK, []*vpn.VXLANVtep{})
+		return
+	}
+	filterIface := r.URL.Query().Get("interface")
+	list := s.vpnMgr.VXLAN.ListVteps(filterIface)
+	writeJSON(w, http.StatusOK, list)
+}
+
+func (s *Server) handleSaveVXLANVtep(w http.ResponseWriter, r *http.Request) {
+	if s.vpnMgr == nil || s.vpnMgr.VXLAN == nil {
+		writeError(w, http.StatusServiceUnavailable, "vxlan subsystem unavailable")
+		return
+	}
+	var vtep vpn.VXLANVtep
+	if err := json.NewDecoder(r.Body).Decode(&vtep); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	if vtep.Interface == "" {
+		writeError(w, http.StatusBadRequest, "interface is required")
+		return
+	}
+	if vtep.RemoteIP == "" {
+		writeError(w, http.StatusBadRequest, "remote_ip is required")
+		return
+	}
+	if err := s.vpnMgr.VXLAN.AddVtep(vtep.Interface, &vtep); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success":   true,
+		"id":        vtep.ID,
+		"interface": vtep.Interface,
+		"remote_ip": vtep.RemoteIP,
+		"port":      vtep.Port,
+	})
+}
+
+func (s *Server) handleDeleteVXLANVtep(w http.ResponseWriter, r *http.Request) {
+	if s.vpnMgr == nil || s.vpnMgr.VXLAN == nil {
+		writeError(w, http.StatusServiceUnavailable, "vxlan subsystem unavailable")
+		return
+	}
+	id := r.PathValue("id")
+	vtepid := r.PathValue("vtepid")
+	if id == "" || vtepid == "" {
+		writeError(w, http.StatusBadRequest, "interface id and vtepid required")
+		return
+	}
+	if err := s.vpnMgr.VXLAN.DeleteVtep(id, vtepid); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "vtepid": vtepid})
+}

+ 134 - 0
internal/api/vxlan_handlers_test.go

@@ -0,0 +1,134 @@
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+
+	"debtik/internal/auth"
+	"debtik/internal/vpn"
+)
+
+func setupTestVXLANServer(t *testing.T) (*Server, *vpn.Manager) {
+	tmpDir := t.TempDir()
+	authPath := filepath.Join(tmpDir, "users.json")
+	authMgr := auth.NewManager(authPath)
+
+	vpnMgr := &vpn.Manager{
+		VXLAN: vpn.NewVXLANManager(filepath.Join(tmpDir, "vxlan.json")),
+	}
+
+	srv := &Server{
+		vpnMgr:    vpnMgr,
+		authMgr:   authMgr,
+		mux:       http.NewServeMux(),
+		fsHandler: http.NotFoundHandler(),
+	}
+	srv.registerRoutes()
+	return srv, vpnMgr
+}
+
+func TestVXLANAPIEndpoints(t *testing.T) {
+	srv, _ := setupTestVXLANServer(t)
+
+	// 1. GET /api/vpn/vxlan (empty initially)
+	req := httptest.NewRequest(http.MethodGet, "/api/vpn/vxlan", nil)
+	rec := httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("GET /api/vpn/vxlan code = %d; want 200", rec.Code)
+	}
+
+	var list []*vpn.VXLANInstance
+	if err := json.NewDecoder(rec.Body).Decode(&list); err != nil {
+		t.Fatalf("decode failed: %v", err)
+	}
+	if len(list) != 0 {
+		t.Fatalf("expected 0 vxlan tunnels, got %d", len(list))
+	}
+
+	// 2. POST /api/vpn/vxlan (create disabled instance)
+	payload := vpn.VXLANInstance{
+		ID:       "vxlan-unit-1",
+		Name:     "vxlan-test",
+		VNI:      1050,
+		Port:     4789,
+		Disabled: true,
+		Comment:  "API test vxlan",
+	}
+	b, _ := json.Marshal(payload)
+	req = httptest.NewRequest(http.MethodPost, "/api/vpn/vxlan", bytes.NewReader(b))
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("POST /api/vpn/vxlan code = %d, body = %s; want 200", rec.Code, rec.Body.String())
+	}
+
+	// 3. GET /api/interface/vxlan alias
+	req = httptest.NewRequest(http.MethodGet, "/api/interface/vxlan", nil)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("GET /api/interface/vxlan code = %d; want 200", rec.Code)
+	}
+	list = nil
+	_ = json.NewDecoder(rec.Body).Decode(&list)
+	if len(list) != 1 || list[0].Name != "vxlan-test" {
+		t.Fatalf("expected 1 vxlan named vxlan-test, got %+v", list)
+	}
+
+	// 4. POST /api/vpn/vxlan/vteps
+	vtepPayload := vpn.VXLANVtep{
+		Interface: "vxlan-test",
+		RemoteIP:  "192.168.100.2",
+		Comment:   "Remote VTEP 1",
+	}
+	vb, _ := json.Marshal(vtepPayload)
+	req = httptest.NewRequest(http.MethodPost, "/api/vpn/vxlan/vteps", bytes.NewReader(vb))
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("POST /api/vpn/vxlan/vteps code = %d, body = %s; want 200", rec.Code, rec.Body.String())
+	}
+
+	// 5. GET /api/vpn/vxlan/vteps
+	req = httptest.NewRequest(http.MethodGet, "/api/vpn/vxlan/vteps?interface=vxlan-test", nil)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("GET /api/vpn/vxlan/vteps code = %d; want 200", rec.Code)
+	}
+	var vteps []*vpn.VXLANVtep
+	_ = json.NewDecoder(rec.Body).Decode(&vteps)
+	if len(vteps) != 1 || vteps[0].RemoteIP != "192.168.100.2" {
+		t.Fatalf("expected 1 vtep with remote-ip 192.168.100.2, got %+v", vteps)
+	}
+
+	// 6. DELETE /api/vpn/vxlan/{id}/vteps/{vtepid}
+	req = httptest.NewRequest(http.MethodDelete, "/api/vpn/vxlan/vxlan-unit-1/vteps/192.168.100.2", nil)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("DELETE vtep code = %d, body = %s; want 200", rec.Code, rec.Body.String())
+	}
+
+	// 7. DELETE /api/vpn/vxlan/{id}
+	req = httptest.NewRequest(http.MethodDelete, "/api/vpn/vxlan/vxlan-unit-1", nil)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("DELETE vxlan code = %d; want 200", rec.Code)
+	}
+
+	req = httptest.NewRequest(http.MethodGet, "/api/vpn/vxlan", nil)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	list = nil
+	_ = json.NewDecoder(rec.Body).Decode(&list)
+	if len(list) != 0 {
+		t.Fatalf("expected 0 vxlan tunnels after delete, got %d", len(list))
+	}
+}

+ 55 - 0
internal/cli/command_test.go

@@ -81,3 +81,58 @@ func TestAeroTUNCLIConfigurationAndPermissions(t *testing.T) {
 		t.Fatal(output)
 	}
 }
+
+func TestVXLANCLIConfigurationAndPermissions(t *testing.T) {
+	vxlanMgr := vpn.NewVXLANManager(filepath.Join(t.TempDir(), "vxlan.json"))
+	e := &Executor{vpnMgr: &vpn.Manager{VXLAN: vxlanMgr}}
+	admin := &auth.PublicUser{Role: auth.RoleFull}
+	viewer := &auth.PublicUser{Role: auth.RoleRead}
+
+	// 1. Add VXLAN
+	output, _ := e.Execute(`/interface vxlan add name=vxlan99 vni=99 port=4789 mtu=1500 comment="test overlay"`, admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("unexpected error adding vxlan: %s", output)
+	}
+
+	// 2. Print VXLAN
+	output, _ = e.Execute("/interface vxlan print", viewer)
+	if !strings.Contains(output, "vxlan99") || !strings.Contains(output, "99") {
+		t.Fatalf("vxlan99 not found in print output: %s", output)
+	}
+
+	// 3. Add VTEP
+	output, _ = e.Execute("/interface vxlan vteps add interface=vxlan99 remote-ip=192.168.1.50 comment=peera", admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("unexpected error adding vtep: %s", output)
+	}
+
+	// 4. Print VTEP
+	output, _ = e.Execute("/interface vxlan vteps print", viewer)
+	if !strings.Contains(output, "192.168.1.50") {
+		t.Fatalf("vtep not found in print output: %s", output)
+	}
+
+	// 5. Viewer cannot modify
+	output, _ = e.Execute("/interface vxlan remove vxlan99", viewer)
+	if !strings.Contains(output, "Permission denied") {
+		t.Fatalf("expected permission denied for viewer, got: %s", output)
+	}
+
+	// 6. Set comment
+	output, _ = e.Execute(`/interface vxlan set vxlan99 comment="updated vxlan"`, admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("unexpected error setting vxlan: %s", output)
+	}
+
+	// 7. Remove VTEP
+	output, _ = e.Execute("/interface vxlan vteps remove interface=vxlan99 remote-ip=192.168.1.50", admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("unexpected error removing vtep: %s", output)
+	}
+
+	// 8. Remove VXLAN
+	output, _ = e.Execute("/interface vxlan remove vxlan99", admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("unexpected error removing vxlan: %s", output)
+	}
+}

+ 23 - 1
internal/cli/completer.go

@@ -346,7 +346,7 @@ func colorizeCandidate(s string) string {
 	case "print", "add", "remove", "enable", "disable", "set", "reboot", "flush", "renew", "release", "connect", "disconnect":
 		return ColorGreen + s + ColorReset
 	case "interface", "ip", "routing", "tool", "log", "user", "system", "certificate", "certificates",
-		"vlan", "bridge", "wireguard", "eoip", "vrrp", "pppoe-client",
+		"vlan", "bridge", "wireguard", "eoip", "vxlan", "vrrp", "pppoe-client",
 		"address", "route", "dhcp-client", "dhcp-server", "dns", "firewall", "service",
 		"filter", "nat", "mangle", "address-list", "bgp", "ospf", "port", "peers", "lease", "network", "cache", "static", "rule", "table", "instance", "area", "neighbor", "resource", "identity":
 		return ColorCyan + s + ColorReset
@@ -542,6 +542,28 @@ func (c *Completer) buildCommandTree() *Node {
 	eoipAdd.AddChild(&Node{Name: "remote-ip="})
 	eoipAdd.AddChild(&Node{Name: "tunnel-id="})
 
+	// /interface vxlan
+	vxlanNode := ifaceNode.AddChild(&Node{Name: "vxlan"})
+	addPrint(vxlanNode)
+	addRemove(vxlanNode)
+	addEnableDisable(vxlanNode)
+	vxlanAdd := vxlanNode.AddChild(&Node{Name: "add"})
+	for _, p := range []string{"name=", "vni=", "port=", "mtu=", "mac-address=", "local-address=", "remote-address=", "group=", "interface=", "learning=", "comment="} {
+		vxlanAdd.AddChild(&Node{Name: p})
+	}
+	vxlanSet := vxlanNode.AddChild(&Node{Name: "set"})
+	for _, p := range []string{"name=", "vni=", "port=", "mtu=", "mac-address=", "local-address=", "remote-address=", "group=", "interface=", "comment="} {
+		vxlanSet.AddChild(&Node{Name: p})
+	}
+	vtepsNode := vxlanNode.AddChild(&Node{Name: "vteps"})
+	addPrint(vtepsNode)
+	addRemove(vtepsNode)
+	vtepsAdd := vtepsNode.AddChild(&Node{Name: "add"})
+	vtepsAdd.AddChild(&Node{Name: "interface="})
+	vtepsAdd.AddChild(&Node{Name: "remote-ip="})
+	vtepsAdd.AddChild(&Node{Name: "port="})
+	vtepsAdd.AddChild(&Node{Name: "comment="})
+
 	// /interface vrrp
 	vrrpNode := ifaceNode.AddChild(&Node{Name: "vrrp"})
 	addPrint(vrrpNode)

+ 272 - 0
internal/cli/executor.go

@@ -252,6 +252,9 @@ func (e *Executor) handleInterface(parts []string, user *auth.PublicUser) string
 	case "eoip":
 		return e.handleInterfaceEoIP(parts[1:], user)
 
+	case "vxlan":
+		return e.handleInterfaceVXLAN(parts[1:], user)
+
 	case "vrrp":
 		return e.handleInterfaceVRRP(parts[1:], user)
 
@@ -818,6 +821,274 @@ func (e *Executor) handleInterfaceEoIP(parts []string, user *auth.PublicUser) st
 	}
 }
 
+// --- VXLAN ---
+func (e *Executor) handleInterfaceVXLAN(parts []string, user *auth.PublicUser) string {
+	if e.vpnMgr == nil || e.vpnMgr.VXLAN == nil {
+		return ColorRed + "VXLAN subsystem not available.\n" + ColorReset
+	}
+
+	if len(parts) > 0 {
+		sub := strings.ToLower(parts[0])
+		if sub == "vteps" || sub == "vtep" {
+			return e.handleInterfaceVXLANVteps(parts[1:], user)
+		}
+	}
+
+	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
+		tunnels := e.vpnMgr.VXLAN.List()
+		tbl := NewTable("FLAGS", "NAME", "VNI", "PORT", "MTU", "MAC-ADDRESS", "STATUS")
+		for _, t := range tunnels {
+			flags := ""
+			if t.Disabled {
+				flags += "X"
+			}
+			if t.Running {
+				flags += "R"
+			}
+			st := "stopped"
+			if !t.Disabled {
+				if t.Running {
+					st = "running"
+				} else {
+					st = "down"
+				}
+			}
+			tbl.AddRow(flags, t.Name, fmt.Sprintf("%d", t.VNI), fmt.Sprintf("%d", t.Port), fmt.Sprintf("%d", t.MTU), t.MACAddress, st)
+		}
+		return tbl.String()
+	}
+
+	if user != nil && user.Role == auth.RoleRead {
+		return ColorRed + "Permission denied: viewer role cannot modify VXLAN\n" + ColorReset
+	}
+
+	sub := strings.ToLower(parts[0])
+	switch sub {
+	case "add":
+		_, params := parseArgs(parts[1:])
+		vniStr := params["vni"]
+		vni, err := strconv.Atoi(vniStr)
+		if err != nil || vni < 1 || vni > 16777215 {
+			return ColorRed + "Invalid or missing vni (1-16777215)\n" + ColorReset
+		}
+		name := params["name"]
+		if name == "" {
+			name = fmt.Sprintf("vxlan%d", vni)
+		}
+
+		port := 4789
+		if pStr := params["port"]; pStr != "" {
+			if p, err := strconv.Atoi(pStr); err == nil && p > 0 && p <= 65535 {
+				port = p
+			}
+		}
+
+		mtu := 1500
+		if mStr := params["mtu"]; mStr != "" {
+			if m, err := strconv.Atoi(mStr); err == nil && m >= 576 {
+				mtu = m
+			}
+		}
+
+		learning := true
+		if lStr := params["learning"]; lStr != "" {
+			lLower := strings.ToLower(lStr)
+			if lLower == "no" || lLower == "false" || lLower == "0" {
+				learning = false
+			}
+		}
+
+		inst := &vpn.VXLANInstance{
+			Name:            name,
+			VNI:             vni,
+			Port:            port,
+			MTU:             mtu,
+			MACAddress:      params["mac-address"],
+			IPAddress:       params["ip-address"],
+			LocalAddress:    params["local-address"],
+			RemoteAddress:   params["remote-address"],
+			Group:           params["group"],
+			ParentInterface: params["interface"],
+			Learning:        learning,
+			Comment:         params["comment"],
+			Disabled:        false,
+		}
+
+		if err := e.vpnMgr.VXLAN.Save(inst); err != nil {
+			return ColorRed + "Error adding VXLAN interface: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("VXLAN interface %s added.\n", name) + ColorReset
+
+	case "set":
+		pos, params := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /interface vxlan set <name|id> [name=...] [vni=...] [port=...] [mtu=...] [comment=...]\n" + ColorReset
+		}
+		inst, found := e.vpnMgr.VXLAN.Get(pos[0])
+		if !found {
+			return ColorRed + fmt.Sprintf("VXLAN interface %q not found\n", pos[0]) + ColorReset
+		}
+
+		if newName := params["name"]; newName != "" {
+			inst.Name = newName
+		}
+		if vniStr := params["vni"]; vniStr != "" {
+			if v, err := strconv.Atoi(vniStr); err == nil && v >= 1 && v <= 16777215 {
+				inst.VNI = v
+			}
+		}
+		if pStr := params["port"]; pStr != "" {
+			if p, err := strconv.Atoi(pStr); err == nil && p > 0 {
+				inst.Port = p
+			}
+		}
+		if mStr := params["mtu"]; mStr != "" {
+			if m, err := strconv.Atoi(mStr); err == nil && m >= 576 {
+				inst.MTU = m
+			}
+		}
+		if mac := params["mac-address"]; mac != "" {
+			inst.MACAddress = mac
+		}
+		if ip := params["ip-address"]; ip != "" {
+			inst.IPAddress = ip
+		}
+		if loc := params["local-address"]; loc != "" {
+			inst.LocalAddress = loc
+		}
+		if rem := params["remote-address"]; rem != "" {
+			inst.RemoteAddress = rem
+		}
+		if grp := params["group"]; grp != "" {
+			inst.Group = grp
+		}
+		if iface := params["interface"]; iface != "" {
+			inst.ParentInterface = iface
+		}
+		if cmt, ok := params["comment"]; ok {
+			inst.Comment = cmt
+		}
+
+		if err := e.vpnMgr.VXLAN.Save(inst); err != nil {
+			return ColorRed + "Error updating VXLAN interface: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("VXLAN interface %s updated.\n", inst.Name) + ColorReset
+
+	case "remove", "rem", "delete":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /interface vxlan remove <name|id>\n" + ColorReset
+		}
+		if err := e.vpnMgr.VXLAN.Delete(pos[0]); err != nil {
+			return ColorRed + "Error: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("VXLAN interface %s removed.\n", pos[0]) + ColorReset
+
+	case "enable":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /interface vxlan enable <name|id>\n" + ColorReset
+		}
+		if err := e.vpnMgr.VXLAN.SetState(pos[0], false); err != nil {
+			return ColorRed + "Error: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("VXLAN interface %s enabled.\n", pos[0]) + ColorReset
+
+	case "disable":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /interface vxlan disable <name|id>\n" + ColorReset
+		}
+		if err := e.vpnMgr.VXLAN.SetState(pos[0], true); err != nil {
+			return ColorRed + "Error: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("VXLAN interface %s disabled.\n", pos[0]) + ColorReset
+
+	default:
+		return ColorRed + "Usage: /interface vxlan [print|add|set|remove|enable|disable|vteps]\n" + ColorReset
+	}
+}
+
+// --- VXLAN VTEPs ---
+func (e *Executor) handleInterfaceVXLANVteps(parts []string, user *auth.PublicUser) string {
+	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
+		_, params := parseArgs(parts)
+		vteps := e.vpnMgr.VXLAN.ListVteps(params["interface"])
+		tbl := NewTable("INTERFACE", "REMOTE-IP", "PORT", "COMMENT")
+		for _, v := range vteps {
+			pStr := fmt.Sprintf("%d", v.Port)
+			if v.Port <= 0 {
+				pStr = "default"
+			}
+			tbl.AddRow(v.Interface, v.RemoteIP, pStr, v.Comment)
+		}
+		return tbl.String()
+	}
+
+	if user != nil && user.Role == auth.RoleRead {
+		return ColorRed + "Permission denied: viewer role cannot modify VXLAN VTEPs\n" + ColorReset
+	}
+
+	sub := strings.ToLower(parts[0])
+	switch sub {
+	case "add":
+		_, params := parseArgs(parts[1:])
+		iface := params["interface"]
+		remote := params["remote-ip"]
+		if remote == "" {
+			remote = params["remote-address"]
+		}
+		if iface == "" || remote == "" {
+			return ColorRed + "Usage: /interface vxlan vteps add interface=<iface> remote-ip=<ip> [port=4789] [comment=...]\n" + ColorReset
+		}
+
+		port := 0
+		if pStr := params["port"]; pStr != "" {
+			if p, err := strconv.Atoi(pStr); err == nil && p > 0 {
+				port = p
+			}
+		}
+
+		vtep := &vpn.VXLANVtep{
+			Interface: iface,
+			RemoteIP:  remote,
+			Port:      port,
+			Comment:   params["comment"],
+		}
+		if err := e.vpnMgr.VXLAN.AddVtep(iface, vtep); err != nil {
+			return ColorRed + "Error adding VTEP: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("VTEP %s added to interface %s.\n", remote, iface) + ColorReset
+
+	case "remove", "rem", "delete":
+		pos, params := parseArgs(parts[1:])
+		iface := params["interface"]
+		remote := params["remote-ip"]
+		if remote == "" {
+			remote = params["remote-address"]
+		}
+
+		if iface != "" && remote != "" {
+			if err := e.vpnMgr.VXLAN.DeleteVtep(iface, remote); err != nil {
+				return ColorRed + "Error: " + err.Error() + ColorReset + "\n"
+			}
+			return ColorGreen + fmt.Sprintf("VTEP %s removed from %s.\n", remote, iface) + ColorReset
+		}
+
+		if len(pos) >= 2 {
+			if err := e.vpnMgr.VXLAN.DeleteVtep(pos[0], pos[1]); err != nil {
+				return ColorRed + "Error: " + err.Error() + ColorReset + "\n"
+			}
+			return ColorGreen + fmt.Sprintf("VTEP %s removed from %s.\n", pos[1], pos[0]) + ColorReset
+		}
+
+		return ColorRed + "Usage: /interface vxlan vteps remove interface=<iface> remote-ip=<ip> OR <iface> <remote-ip>\n" + ColorReset
+
+	default:
+		return ColorRed + "Usage: /interface vxlan vteps [print|add|remove]\n" + ColorReset
+	}
+}
+
 // --- VRRP ---
 func (e *Executor) handleInterfaceVRRP(parts []string, user *auth.PublicUser) string {
 	if e.vrrpMgr == nil {
@@ -2437,6 +2708,7 @@ func (e *Executor) showHelp() string {
 	b.WriteString("    /interface aerotun           - Encrypted tunnels (print detail=yes, add, set, enable, disable, start, stop, remove, genkey)\n")
 	b.WriteString("    /interface wireguard         - WireGuard tunnels & peers (print, add, peers)\n")
 	b.WriteString("    /interface eoip              - Ethernet over IP GRE tunnels (print, add, remove)\n")
+	b.WriteString("    /interface vxlan             - VXLAN overlay tunnels & VTEPs (print, add, set, remove, enable, disable, vteps)\n")
 	b.WriteString("    /interface vrrp              - Virtual Router Redundancy Protocol (print, add, remove)\n")
 	b.WriteString("    /interface pppoe-client      - PPPoE WAN Client sessions (print, add, connect)\n")
 	b.WriteString("  /ip\n")

+ 16 - 1
internal/vpn/manager.go

@@ -9,6 +9,7 @@ type Manager struct {
 	L2TP      *L2TPManager
 	IPSec     *IPSecManager
 	EoIP      *EoIPManager
+	VXLAN     *VXLANManager
 	PPPServer *PPPServerManager
 	AeroTUN   *aerotun.Manager
 }
@@ -27,6 +28,7 @@ func NewManager() *Manager {
 		L2TP:      l2tpMgr,
 		IPSec:     NewIPSecManager(),
 		EoIP:      NewEoIPManager(),
+		VXLAN:     NewVXLANManager(),
 		PPPServer: pppServer,
 		AeroTUN:   aerotun.NewManager(),
 	}
@@ -41,6 +43,7 @@ func (m *Manager) GetOverallStatus() OverallVPNStatus {
 	l2tpList := m.L2TP.List()
 	ipsecList := m.IPSec.List()
 	eoipList := m.EoIP.List()
+	vxlanList := m.VXLAN.List()
 
 	activePPPoE := 0
 	for _, p := range pppoeList {
@@ -84,6 +87,13 @@ func (m *Manager) GetOverallStatus() OverallVPNStatus {
 		}
 	}
 
+	activeVXLAN := 0
+	for _, v := range vxlanList {
+		if v.Running {
+			activeVXLAN++
+		}
+	}
+
 	aeroStats := m.AeroTUN.GetSummaryStats()
 
 	pppoeSrv := m.PPPServer.GetPPPoEServer()
@@ -92,7 +102,7 @@ func (m *Manager) GetOverallStatus() OverallVPNStatus {
 	ipsecSrv := m.PPPServer.GetIPSecServer()
 	pppSessions := len(m.PPPServer.ListActiveSessions())
 
-	totalActive := activePPPoE + activeWG + activePPTP + activeL2TP + activeIPSec + activeEoIP + aeroStats.ActiveTunnels + pppSessions
+	totalActive := activePPPoE + activeWG + activePPTP + activeL2TP + activeIPSec + activeEoIP + activeVXLAN + aeroStats.ActiveTunnels + pppSessions
 
 	return OverallVPNStatus{
 		TotalPPPoE:          len(pppoeList),
@@ -108,6 +118,8 @@ func (m *Manager) GetOverallStatus() OverallVPNStatus {
 		ActiveIPSec:         activeIPSec,
 		TotalEoIP:           len(eoipList),
 		ActiveEoIP:          activeEoIP,
+		TotalVXLAN:          len(vxlanList),
+		ActiveVXLAN:         activeVXLAN,
 		TotalAeroTUN:        aeroStats.TotalTunnels,
 		ActiveAeroTUN:       aeroStats.ActiveTunnels,
 		AeroTUNSavedBytes:   aeroStats.TotalSavedBytes,
@@ -126,4 +138,7 @@ func (m *Manager) RenameInterface(oldName, newName string) {
 	if m.PPPoE != nil {
 		m.PPPoE.RenameInterface(oldName, newName)
 	}
+	if m.VXLAN != nil {
+		m.VXLAN.RenameInterface(oldName, newName)
+	}
 }

+ 2 - 0
internal/vpn/types.go

@@ -15,6 +15,8 @@ type OverallVPNStatus struct {
 	ActiveIPSec         int  `json:"active_ipsec"`
 	TotalEoIP           int    `json:"total_eoip"`
 	ActiveEoIP          int    `json:"active_eoip"`
+	TotalVXLAN          int    `json:"total_vxlan"`
+	ActiveVXLAN         int    `json:"active_vxlan"`
 	TotalAeroTUN        int    `json:"total_aerotun"`
 	ActiveAeroTUN       int    `json:"active_aerotun"`
 	AeroTUNSavedBytes   uint64 `json:"aerotun_saved_bytes"`

+ 490 - 0
internal/vpn/vxlan.go

@@ -0,0 +1,490 @@
+package vpn
+
+import (
+	"crypto/rand"
+	"encoding/json"
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+)
+
+const DefaultVXLANStorePath = "/etc/debtik/vpn/vxlan.json"
+
+// VXLANVtep represents a remote VTEP (Virtual Tunnel Endpoint) for a VXLAN interface.
+type VXLANVtep struct {
+	ID        string `json:"id"`
+	Interface string `json:"interface"` // Name of the parent VXLAN interface
+	RemoteIP  string `json:"remote_ip"`  // Unicast IPv4 address of remote VTEP
+	Port      int    `json:"port,omitempty"` // UDP port, defaults to parent VXLAN port
+	Comment   string `json:"comment,omitempty"`
+}
+
+// VXLANInstance represents a Linux/RouterOS VXLAN network interface.
+type VXLANInstance struct {
+	ID              string       `json:"id"`
+	Name            string       `json:"name"`                       // Interface name (e.g. "vxlan1", "vxlan100")
+	VNI             int          `json:"vni"`                        // VXLAN Network Identifier (1 - 16777215)
+	Port            int          `json:"port"`                       // UDP Destination Port (RFC 7348 standard: 4789)
+	MTU             int          `json:"mtu"`                        // Interface MTU (default 1500)
+	MACAddress      string       `json:"mac_address,omitempty"`      // Hardware MAC address
+	IPAddress       string       `json:"ip_address,omitempty"`       // Overlay IP/CIDR (e.g. "10.200.0.1/24")
+	ParentInterface string       `json:"parent_interface,omitempty"` // Underlay interface (e.g. "eth0")
+	LocalAddress    string       `json:"local_address,omitempty"`    // VTEP local source IP
+	Group           string       `json:"group,omitempty"`            // Multicast group (e.g. "239.1.1.1")
+	RemoteAddress   string       `json:"remote_address,omitempty"`   // Point-to-point remote unicast IP
+	Learning        bool         `json:"learning"`                   // MAC address learning (default: true)
+	Comment         string       `json:"comment,omitempty"`
+	Disabled        bool         `json:"disabled"`
+	Running         bool         `json:"running"` // True if link is created and active in kernel
+	VTEPs           []*VXLANVtep `json:"vteps,omitempty"`
+	RxPackets       uint64       `json:"rx_packets"`
+	TxPackets       uint64       `json:"tx_packets"`
+	RxBytes         uint64       `json:"rx_bytes"`
+	TxBytes         uint64       `json:"tx_bytes"`
+	RxHuman         string       `json:"rx_human,omitempty"`
+	TxHuman         string       `json:"tx_human,omitempty"`
+	LastError       string       `json:"last_error,omitempty"`
+}
+
+// VXLANManager manages persistent VXLAN interfaces and kernel links.
+type VXLANManager struct {
+	mu        sync.RWMutex
+	storePath string
+	tunnels   map[string]*VXLANInstance
+}
+
+// NewVXLANManager creates a new manager with default or customized storage path.
+func NewVXLANManager(customPath ...string) *VXLANManager {
+	store := DefaultVXLANStorePath
+	if len(customPath) > 0 && customPath[0] != "" {
+		store = customPath[0]
+	}
+	mgr := &VXLANManager{
+		storePath: store,
+		tunnels:   make(map[string]*VXLANInstance),
+	}
+	mgr.load()
+	mgr.SyncAll()
+	return mgr
+}
+
+func (m *VXLANManager) load() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	data, err := os.ReadFile(m.storePath)
+	if err != nil {
+		return
+	}
+	var list []*VXLANInstance
+	if err := json.Unmarshal(data, &list); err == nil {
+		for _, t := range list {
+			m.tunnels[t.ID] = t
+		}
+	}
+}
+
+func (m *VXLANManager) save() error {
+	_ = os.MkdirAll(filepath.Dir(m.storePath), 0755)
+
+	list := make([]*VXLANInstance, 0, len(m.tunnels))
+	for _, t := range m.tunnels {
+		list = append(list, t)
+	}
+
+	data, err := json.MarshalIndent(list, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(m.storePath, data, 0644)
+}
+
+// List returns all configured VXLAN tunnels with updated statistics.
+func (m *VXLANManager) List() []*VXLANInstance {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	list := make([]*VXLANInstance, 0, len(m.tunnels))
+	for _, t := range m.tunnels {
+		m.updateStats(t)
+		list = append(list, t)
+	}
+
+	sort.Slice(list, func(i, j int) bool {
+		if list[i].VNI != list[j].VNI {
+			return list[i].VNI < list[j].VNI
+		}
+		return list[i].Name < list[j].Name
+	})
+
+	return list
+}
+
+// Get returns a single VXLAN instance by ID or interface name.
+func (m *VXLANManager) Get(idOrName string) (*VXLANInstance, bool) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if t, ok := m.tunnels[idOrName]; ok {
+		m.updateStats(t)
+		return t, true
+	}
+	for _, t := range m.tunnels {
+		if t.Name == idOrName {
+			m.updateStats(t)
+			return t, true
+		}
+	}
+	return nil, false
+}
+
+// Save validates, stores, and synchronizes a VXLAN tunnel configuration into the kernel.
+func (m *VXLANManager) Save(inst *VXLANInstance) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if inst.VNI <= 0 || inst.VNI > 16777215 {
+		return fmt.Errorf("vni must be between 1 and 16777215")
+	}
+	if inst.Name == "" {
+		inst.Name = fmt.Sprintf("vxlan%d", inst.VNI)
+	}
+	if inst.Port <= 0 {
+		inst.Port = 4789 // RFC 7348 standard
+	}
+	if inst.MTU <= 0 {
+		inst.MTU = 1500
+	}
+	if inst.ID == "" {
+		inst.ID = fmt.Sprintf("vxlan-%d", time.Now().UnixNano())
+	}
+	if inst.MACAddress == "" {
+		inst.MACAddress = generateVXLANMAC()
+	}
+
+	// Check for name or VNI collisions with existing different instances
+	for _, existing := range m.tunnels {
+		if existing.ID != inst.ID {
+			if existing.Name == inst.Name {
+				return fmt.Errorf("interface name %q already in use", inst.Name)
+			}
+			if existing.VNI == inst.VNI && existing.Port == inst.Port {
+				return fmt.Errorf("vni %d on port %d already exists", inst.VNI, inst.Port)
+			}
+		}
+	}
+
+	// Sync link in kernel
+	if err := m.syncKernelLink(inst); err != nil {
+		return err
+	}
+
+	m.tunnels[inst.ID] = inst
+	return m.save()
+}
+
+// SetState enables or disables a VXLAN interface.
+func (m *VXLANManager) SetState(idOrName string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	inst := m.findLocked(idOrName)
+	if inst == nil {
+		return fmt.Errorf("vxlan interface %q not found", idOrName)
+	}
+
+	inst.Disabled = disabled
+	if err := m.syncKernelLink(inst); err != nil {
+		return err
+	}
+
+	return m.save()
+}
+
+// Delete removes a VXLAN interface from the kernel and persistent store.
+func (m *VXLANManager) Delete(idOrName string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	inst := m.findLocked(idOrName)
+	if inst == nil {
+		return fmt.Errorf("vxlan interface %q not found", idOrName)
+	}
+
+	// Delete kernel link
+	_ = exec.Command("ip", "link", "del", "dev", inst.Name).Run()
+
+	delete(m.tunnels, inst.ID)
+	return m.save()
+}
+
+// AddVtep adds a remote VTEP peer to a VXLAN interface.
+func (m *VXLANManager) AddVtep(vxlanIDOrName string, vtep *VXLANVtep) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	inst := m.findLocked(vxlanIDOrName)
+	if inst == nil {
+		return fmt.Errorf("vxlan interface %q not found", vxlanIDOrName)
+	}
+
+	if vtep.RemoteIP == "" {
+		return fmt.Errorf("remote_ip is required")
+	}
+	if net.ParseIP(vtep.RemoteIP) == nil {
+		return fmt.Errorf("invalid remote IP %q", vtep.RemoteIP)
+	}
+	if vtep.Port <= 0 {
+		vtep.Port = inst.Port
+	}
+	if vtep.ID == "" {
+		vtep.ID = fmt.Sprintf("vtep-%d", time.Now().UnixNano())
+	}
+	vtep.Interface = inst.Name
+
+	// Check duplicate remote IP
+	for _, existing := range inst.VTEPs {
+		if existing.RemoteIP == vtep.RemoteIP {
+			return fmt.Errorf("vtep with remote-ip %s already exists for %s", vtep.RemoteIP, inst.Name)
+		}
+	}
+
+	inst.VTEPs = append(inst.VTEPs, vtep)
+
+	// Apply bridge fdb flood entry in kernel
+	if !inst.Disabled {
+		m.applyVtepKernel(inst.Name, vtep)
+	}
+
+	return m.save()
+}
+
+// DeleteVtep removes a remote VTEP peer from a VXLAN interface.
+func (m *VXLANManager) DeleteVtep(vxlanIDOrName, vtepIDOrIP string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	inst := m.findLocked(vxlanIDOrName)
+	if inst == nil {
+		return fmt.Errorf("vxlan interface %q not found", vxlanIDOrName)
+	}
+
+	idx := -1
+	var targetVtep *VXLANVtep
+	for i, v := range inst.VTEPs {
+		if v.ID == vtepIDOrIP || v.RemoteIP == vtepIDOrIP {
+			idx = i
+			targetVtep = v
+			break
+		}
+	}
+
+	if idx == -1 {
+		return fmt.Errorf("vtep %q not found on interface %s", vtepIDOrIP, inst.Name)
+	}
+
+	if !inst.Disabled && targetVtep != nil {
+		m.removeVtepKernel(inst.Name, targetVtep)
+	}
+
+	inst.VTEPs = append(inst.VTEPs[:idx], inst.VTEPs[idx+1:]...)
+	return m.save()
+}
+
+// ListVteps returns a slice of all configured VTEPs across interfaces.
+func (m *VXLANManager) ListVteps(filterIface string) []*VXLANVtep {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	var list []*VXLANVtep
+	for _, inst := range m.tunnels {
+		if filterIface != "" && inst.Name != filterIface && inst.ID != filterIface {
+			continue
+		}
+		for _, v := range inst.VTEPs {
+			list = append(list, v)
+		}
+	}
+
+	sort.Slice(list, func(i, j int) bool {
+		if list[i].Interface != list[j].Interface {
+			return list[i].Interface < list[j].Interface
+		}
+		return list[i].RemoteIP < list[j].RemoteIP
+	})
+
+	return list
+}
+
+// SyncAll reconciles all stored VXLAN interfaces into the kernel.
+func (m *VXLANManager) SyncAll() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for _, inst := range m.tunnels {
+		_ = m.syncKernelLink(inst)
+	}
+}
+
+// RenameInterface updates parent underlay references when a link is renamed.
+func (m *VXLANManager) RenameInterface(oldName, newName string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	changed := false
+	for _, inst := range m.tunnels {
+		if inst.ParentInterface == oldName {
+			inst.ParentInterface = newName
+			changed = true
+		}
+	}
+	if changed {
+		_ = m.save()
+	}
+}
+
+func (m *VXLANManager) findLocked(idOrName string) *VXLANInstance {
+	if t, ok := m.tunnels[idOrName]; ok {
+		return t
+	}
+	for _, t := range m.tunnels {
+		if t.Name == idOrName {
+			return t
+		}
+	}
+	return nil
+}
+
+func (m *VXLANManager) syncKernelLink(inst *VXLANInstance) error {
+	// Remove existing link if present
+	_ = exec.Command("ip", "link", "del", "dev", inst.Name).Run()
+
+	if inst.Disabled {
+		inst.Running = false
+		return nil
+	}
+
+	args := []string{"link", "add", "dev", inst.Name, "type", "vxlan", "id", strconv.Itoa(inst.VNI)}
+	dstPort := inst.Port
+	if dstPort <= 0 {
+		dstPort = 4789
+	}
+	args = append(args, "dstport", strconv.Itoa(dstPort))
+
+	if inst.LocalAddress != "" {
+		args = append(args, "local", inst.LocalAddress)
+	}
+	if inst.RemoteAddress != "" {
+		args = append(args, "remote", inst.RemoteAddress)
+	}
+	if inst.Group != "" {
+		args = append(args, "group", inst.Group)
+	}
+	if inst.ParentInterface != "" {
+		args = append(args, "dev", inst.ParentInterface)
+	}
+	if !inst.Learning {
+		args = append(args, "nolearning")
+	}
+	args = append(args, "ttl", "auto", "ageing", "300")
+
+	out, err := exec.Command("ip", args...).CombinedOutput()
+	if err != nil {
+		inst.LastError = string(out)
+		inst.Running = false
+		return fmt.Errorf("failed to create vxlan %s: %s (%w)", inst.Name, strings.TrimSpace(string(out)), err)
+	}
+
+	if inst.MACAddress != "" {
+		_ = exec.Command("ip", "link", "set", "dev", inst.Name, "address", inst.MACAddress).Run()
+	}
+
+	mtu := inst.MTU
+	if mtu <= 0 {
+		mtu = 1500
+	}
+	_ = exec.Command("ip", "link", "set", "dev", inst.Name, "mtu", strconv.Itoa(mtu)).Run()
+
+	if out, err := exec.Command("ip", "link", "set", "dev", inst.Name, "up").CombinedOutput(); err != nil {
+		inst.LastError = string(out)
+	} else {
+		inst.Running = true
+		inst.LastError = ""
+	}
+
+	if inst.IPAddress != "" {
+		_ = exec.Command("ip", "addr", "add", inst.IPAddress, "dev", inst.Name).Run()
+	}
+
+	for _, vtep := range inst.VTEPs {
+		m.applyVtepKernel(inst.Name, vtep)
+	}
+
+	return nil
+}
+
+func (m *VXLANManager) applyVtepKernel(iface string, vtep *VXLANVtep) {
+	if vtep.RemoteIP == "" {
+		return
+	}
+	args := []string{"fdb", "append", "00:00:00:00:00:00", "dev", iface, "dst", vtep.RemoteIP}
+	if vtep.Port > 0 && vtep.Port != 4789 {
+		args = append(args, "port", strconv.Itoa(vtep.Port))
+	}
+	_ = exec.Command("bridge", args...).Run()
+}
+
+func (m *VXLANManager) removeVtepKernel(iface string, vtep *VXLANVtep) {
+	if vtep.RemoteIP == "" {
+		return
+	}
+	args := []string{"fdb", "del", "00:00:00:00:00:00", "dev", iface, "dst", vtep.RemoteIP}
+	_ = exec.Command("bridge", args...).Run()
+}
+
+func (m *VXLANManager) updateStats(inst *VXLANInstance) {
+	if inst.Name == "" {
+		return
+	}
+
+	rxBytes, _ := readUint64File(fmt.Sprintf("/sys/class/net/%s/statistics/rx_bytes", inst.Name))
+	txBytes, _ := readUint64File(fmt.Sprintf("/sys/class/net/%s/statistics/tx_bytes", inst.Name))
+	rxPackets, _ := readUint64File(fmt.Sprintf("/sys/class/net/%s/statistics/rx_packets", inst.Name))
+	txPackets, _ := readUint64File(fmt.Sprintf("/sys/class/net/%s/statistics/tx_packets", inst.Name))
+	operstate, _ := os.ReadFile(fmt.Sprintf("/sys/class/net/%s/operstate", inst.Name))
+
+	inst.RxBytes = rxBytes
+	inst.TxBytes = txBytes
+	inst.RxPackets = rxPackets
+	inst.TxPackets = txPackets
+	inst.RxHuman = formatBytes(rxBytes)
+	inst.TxHuman = formatBytes(txBytes)
+
+	if !inst.Disabled {
+		stateStr := strings.TrimSpace(string(operstate))
+		inst.Running = (stateStr == "up" || stateStr == "unknown")
+	} else {
+		inst.Running = false
+	}
+}
+
+func readUint64File(path string) (uint64, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return 0, err
+	}
+	return strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
+}
+
+func generateVXLANMAC() string {
+	buf := make([]byte, 3)
+	_, _ = rand.Read(buf)
+	return fmt.Sprintf("02:58:%02x:%02x:%02x:%02x", buf[0], buf[1], buf[2], time.Now().UnixNano()%256)
+}

+ 144 - 0
internal/vpn/vxlan_test.go

@@ -0,0 +1,144 @@
+package vpn
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestVXLANManagerBasics(t *testing.T) {
+	tmpDir := t.TempDir()
+	storePath := filepath.Join(tmpDir, "vxlan.json")
+
+	mgr := NewVXLANManager(storePath)
+	if mgr == nil {
+		t.Fatal("expected non-nil VXLANManager")
+	}
+
+	// 1. Validation checks
+	err := mgr.Save(&VXLANInstance{
+		VNI: 0,
+	})
+	if err == nil {
+		t.Fatal("expected error for VNI <= 0")
+	}
+
+	err = mgr.Save(&VXLANInstance{
+		VNI: 16777216,
+	})
+	if err == nil {
+		t.Fatal("expected error for VNI > 16777215")
+	}
+
+	// 2. Save a disabled VXLAN instance (does not invoke kernel netlink)
+	inst1 := &VXLANInstance{
+		ID:       "vxlan-test-1",
+		Name:     "vxlan-test1",
+		VNI:      100,
+		Disabled: true,
+		Comment:  "Test VXLAN 1",
+	}
+	if err := mgr.Save(inst1); err != nil {
+		t.Fatalf("failed to save inst1: %v", err)
+	}
+
+	// Validate auto-defaults
+	if inst1.Port != 4789 {
+		t.Errorf("expected default port 4789, got %d", inst1.Port)
+	}
+	if inst1.MTU != 1500 {
+		t.Errorf("expected default MTU 1500, got %d", inst1.MTU)
+	}
+	if inst1.MACAddress == "" {
+		t.Error("expected auto-generated MAC address")
+	}
+
+	// 3. Get and List
+	got, found := mgr.Get("vxlan-test-1")
+	if !found || got == nil {
+		t.Fatal("expected to find vxlan-test-1 by ID")
+	}
+	if got.Name != "vxlan-test1" {
+		t.Errorf("expected name vxlan-test1, got %s", got.Name)
+	}
+
+	gotByName, found := mgr.Get("vxlan-test1")
+	if !found || gotByName == nil {
+		t.Fatal("expected to find vxlan-test1 by Name")
+	}
+
+	list := mgr.List()
+	if len(list) != 1 {
+		t.Fatalf("expected 1 tunnel, got %d", len(list))
+	}
+
+	// 4. Duplicate checks
+	dupVNI := &VXLANInstance{
+		ID:       "vxlan-test-dup",
+		Name:     "vxlan-test-other",
+		VNI:      100,
+		Port:     4789,
+		Disabled: true,
+	}
+	if err := mgr.Save(dupVNI); err == nil {
+		t.Fatal("expected error on duplicate VNI+Port")
+	}
+
+	dupName := &VXLANInstance{
+		ID:       "vxlan-test-dup-name",
+		Name:     "vxlan-test1",
+		VNI:      200,
+		Disabled: true,
+	}
+	if err := mgr.Save(dupName); err == nil {
+		t.Fatal("expected error on duplicate Name")
+	}
+
+	// 5. VTEP management
+	vtep1 := &VXLANVtep{
+		RemoteIP: "192.168.1.10",
+		Comment:  "Remote Office VTEP",
+	}
+	if err := mgr.AddVtep("vxlan-test-1", vtep1); err != nil {
+		t.Fatalf("failed to add VTEP: %v", err)
+	}
+
+	// Duplicate VTEP remote IP check
+	if err := mgr.AddVtep("vxlan-test-1", &VXLANVtep{RemoteIP: "192.168.1.10"}); err == nil {
+		t.Fatal("expected error for duplicate remote IP VTEP")
+	}
+
+	// Invalid remote IP
+	if err := mgr.AddVtep("vxlan-test-1", &VXLANVtep{RemoteIP: "invalid-ip"}); err == nil {
+		t.Fatal("expected error for invalid remote IP")
+	}
+
+	vteps := mgr.ListVteps("vxlan-test1")
+	if len(vteps) != 1 {
+		t.Fatalf("expected 1 VTEP, got %d", len(vteps))
+	}
+	if vteps[0].RemoteIP != "192.168.1.10" {
+		t.Errorf("expected 192.168.1.10, got %s", vteps[0].RemoteIP)
+	}
+
+	// Delete VTEP
+	if err := mgr.DeleteVtep("vxlan-test1", "192.168.1.10"); err != nil {
+		t.Fatalf("failed to delete VTEP: %v", err)
+	}
+	if len(mgr.ListVteps("vxlan-test1")) != 0 {
+		t.Fatal("expected 0 VTEPs after deletion")
+	}
+
+	// 6. Delete tunnel
+	if err := mgr.Delete("vxlan-test-1"); err != nil {
+		t.Fatalf("failed to delete tunnel: %v", err)
+	}
+	if len(mgr.List()) != 0 {
+		t.Fatal("expected 0 tunnels after deletion")
+	}
+
+	// Verify persistence file exists and is valid
+	if _, err := os.Stat(storePath); err != nil {
+		t.Fatalf("expected store file to exist: %v", err)
+	}
+}

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

@@ -110,6 +110,8 @@ let state = {
   routesSubTab: 'routes',
   pppSubTab: 'secrets',
   eoipTunnels: [],
+  vxlanTunnels: [],
+  vxlanVteps: [],
   pppSecrets: [],
   pppProfiles: [],
   pppActiveSessions: [],
@@ -365,6 +367,7 @@ function navigate(tabName) {
     'dynamic-routing': { title: 'BGP Dynamic Routing', sub: 'Dynamic routing engine and peering sessions' },
     ppp: { title: 'PPP Servers & Secrets', sub: 'Subscriber authentication, dynamic IP pools and VPN access concentrators' },
     eoip: { title: 'EoIP Tunnels', sub: 'Ethernet over IP (EoIP / GRETAP) Layer 2 encapsulation & transparent network bridging' },
+    vxlan: { title: 'VXLAN Tunnels', sub: 'Virtual Extensible LAN (RFC 7348) Layer 2 overlay networks and static VTEPs' },
     aerotun: { title: 'AeroTUN High-Speed VPN', sub: 'Layer 3 encrypted tunnel with XChaCha20-Poly1305 and adaptive LZ4 compression' },
     pppoe: { title: 'PPPoE Client WAN', sub: 'Point-to-Point Protocol over Ethernet WAN access' },
     wireguard: { title: 'WireGuard VPN', sub: 'High-performance cryptographic Layer 3 tunnels with ChaCha20-Poly1305 key exchange' },
@@ -418,6 +421,7 @@ function navigate(tabName) {
   if (tabName === 'dynamic-routing') loadDynamicRouting();
   if (tabName === 'ppp') loadPPP();
   if (tabName === 'eoip') loadEoIP();
+  if (tabName === 'vxlan') loadVXLAN();
   if (tabName === 'aerotun') loadAeroTUN();
   if (tabName === 'pppoe') loadPPPoE();
   if (tabName === 'wireguard') loadWireGuard();
@@ -534,6 +538,7 @@ async function refreshCoreData(isManual = false) {
     if (state.activeTab === 'dynamic-routing' || state.activeTab === 'bgp' || state.activeTab === 'ospf') await loadDynamicRouting(false);
     if (state.activeTab === 'ppp') await loadPPP(false);
     if (state.activeTab === 'eoip') await loadEoIP(false);
+    if (state.activeTab === 'vxlan') await loadVXLAN(false);
     if (state.activeTab === 'aerotun') await loadAeroTUN(false);
     if (state.activeTab === 'pppoe') await loadPPPoE(false);
     if (state.activeTab === 'wireguard') await loadWireGuard(false);
@@ -754,6 +759,8 @@ function updateBadges() {
     const v = state.vpnStatus;
     const bEoIP = document.getElementById('badge-eoip-count');
     if (bEoIP) bEoIP.innerText = v.total_eoip || 0;
+    const bVXLAN = document.getElementById('badge-vxlan-count');
+    if (bVXLAN) bVXLAN.innerText = v.total_vxlan || 0;
     const bAero = document.getElementById('badge-aerotun-count');
     if (bAero) bAero.innerText = v.total_aerotun || 0;
     const bPPPSess = document.getElementById('badge-ppp-sessions');
@@ -8133,6 +8140,325 @@ async function deleteEoIP(id) {
   }
 }
 
+// ======================== VXLAN (Virtual Extensible LAN) ========================
+async function loadVXLAN(populate = true) {
+  try {
+    const [tunnelsRes, vtepsRes] = await Promise.all([
+      fetch(`${API_BASE}/vpn/vxlan`),
+      fetch(`${API_BASE}/vpn/vxlan/vteps`)
+    ]);
+
+    if (tunnelsRes.ok) {
+      state.vxlanTunnels = await tunnelsRes.json() || [];
+    }
+    if (vtepsRes.ok) {
+      state.vxlanVteps = await vtepsRes.json() || [];
+    }
+
+    renderVXLANTable();
+    renderVXLANVtepsTable();
+
+    const countBadge = document.getElementById('vxlan-count-badge');
+    if (countBadge) countBadge.innerText = `${(state.vxlanTunnels || []).length} Tunnels`;
+
+    const vtepsCountBadge = document.getElementById('vxlan-vteps-count-badge');
+    if (vtepsCountBadge) vtepsCountBadge.innerText = `${(state.vxlanVteps || []).length} VTEPs`;
+
+    const navBadge = document.getElementById('badge-vxlan-count');
+    if (navBadge) navBadge.innerText = (state.vxlanTunnels || []).length;
+  } catch (err) {
+    console.error('Error loading VXLAN:', err);
+  }
+}
+
+function renderVXLANTable() {
+  const tbody = document.getElementById('vxlan-table-body');
+  const empty = document.getElementById('vxlan-empty');
+  if (!tbody || !empty) return;
+
+  tbody.innerHTML = '';
+  const tunnels = state.vxlanTunnels || [];
+
+  if (tunnels.length === 0) {
+    empty.classList.remove('hidden');
+    return;
+  }
+  empty.classList.add('hidden');
+
+  tunnels.forEach(t => {
+    const tr = document.createElement('tr');
+    tr.className = 'hover:bg-slate-800/40 transition border-b border-slate-800/60';
+
+    let flag = '';
+    if (t.disabled) {
+      flag = '<span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 font-bold border border-slate-700" title="Disabled">X</span>';
+    } else if (t.running) {
+      flag = '<span class="px-1.5 py-0.5 rounded text-[10px] bg-amber-950 text-amber-300 font-bold border border-amber-800/50" title="Running">R</span>';
+    } else {
+      flag = '<span class="px-1.5 py-0.5 rounded text-[10px] bg-rose-950 text-rose-300 font-bold border border-rose-800/50" title="Down">!</span>';
+    }
+
+    const vtepCount = (t.vteps || []).length;
+    const underlayDesc = t.parent_interface || t.local_address ? `${t.parent_interface || '-'}${t.local_address ? ' / ' + t.local_address : ''}` : '(any)';
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 text-center">${flag}</td>
+      <td class="py-2.5 px-3 font-bold text-white font-mono">${escapeHtml(t.name)}</td>
+      <td class="py-2.5 px-3 font-bold text-amber-400 font-mono">${t.vni}</td>
+      <td class="py-2.5 px-3 text-slate-300 font-mono">${t.port || 4789}</td>
+      <td class="py-2.5 px-3 font-semibold text-emerald-400 font-mono">${escapeHtml(t.ip_address || '') || '<span class="text-slate-500 font-normal">Unassigned</span>'}</td>
+      <td class="py-2.5 px-3 text-slate-300 font-mono text-[11px]">${escapeHtml(underlayDesc)}</td>
+      <td class="py-2.5 px-3 text-slate-400 font-mono text-[11px]">${escapeHtml(t.mac_address || '-')}</td>
+      <td class="py-2.5 px-3 text-slate-400 font-mono">${t.mtu || 1500}</td>
+      <td class="py-2.5 px-3 font-mono">
+        <span class="px-2 py-0.5 rounded-full text-[10px] bg-slate-800 text-amber-300 border border-slate-700 cursor-pointer" onclick="openAddVXLANVtepModal('${escapeHtml(t.name)}')" title="Click to add VTEP peer">${vtepCount} VTEP${vtepCount === 1 ? '' : 's'}</span>
+      </td>
+      <td class="py-2.5 px-3 font-mono text-[11px]">
+        <span class="text-emerald-400">↓ ${t.rx_human || '0 B'}</span>
+        <span class="text-amber-400 ml-1">↑ ${t.tx_human || '0 B'}</span>
+      </td>
+      <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${escapeHtml(t.comment || '')}">${escapeHtml(t.comment || '-')}</td>
+      <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
+        <button onclick="toggleVXLANState('${t.id}', ${!t.disabled})" class="text-xs ${t.disabled ? 'text-amber-400' : 'text-slate-400'} hover:underline">${t.disabled ? 'Enable' : 'Disable'}</button>
+        <button onclick="openEditVXLANModal('${t.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
+        <button onclick="deleteVXLAN('${t.id}')" class="text-xs text-rose-400 hover:text-rose-300">Delete</button>
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function renderVXLANVtepsTable() {
+  const tbody = document.getElementById('vxlan-vteps-table-body');
+  const empty = document.getElementById('vxlan-vteps-empty');
+  if (!tbody || !empty) return;
+
+  tbody.innerHTML = '';
+  const vteps = state.vxlanVteps || [];
+
+  if (vteps.length === 0) {
+    empty.classList.remove('hidden');
+    return;
+  }
+  empty.classList.add('hidden');
+
+  vteps.forEach(v => {
+    const tr = document.createElement('tr');
+    tr.className = 'hover:bg-slate-800/40 transition border-b border-slate-800/60';
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 font-bold text-white font-mono">${escapeHtml(v.interface)}</td>
+      <td class="py-2.5 px-3 text-amber-400 font-mono font-semibold">${escapeHtml(v.remote_ip)}</td>
+      <td class="py-2.5 px-3 text-slate-300 font-mono">${v.port > 0 ? v.port : 'default'}</td>
+      <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[150px]" title="${escapeHtml(v.comment || '')}">${escapeHtml(v.comment || '-')}</td>
+      <td class="py-2.5 px-3 text-right font-sans whitespace-nowrap">
+        <button onclick="deleteVXLANVtep('${escapeHtml(v.interface)}', '${escapeHtml(v.id || v.remote_ip)}')" class="text-xs text-rose-400 hover:text-rose-300">Delete</button>
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function openAddVXLANModal() {
+  document.getElementById('vxlan-modal-title').innerText = 'Add VXLAN Interface';
+  document.getElementById('vxlan-modal-id').value = '';
+  document.getElementById('vxlan-modal-name').value = `vxlan${(state.vxlanTunnels || []).length + 100}`;
+  document.getElementById('vxlan-modal-vni').value = (state.vxlanTunnels || []).length + 100;
+  document.getElementById('vxlan-modal-port').value = '4789';
+  document.getElementById('vxlan-modal-ip').value = '';
+  document.getElementById('vxlan-modal-parent').value = '';
+  document.getElementById('vxlan-modal-local').value = '';
+  document.getElementById('vxlan-modal-remote').value = '';
+  document.getElementById('vxlan-modal-group').value = '';
+  document.getElementById('vxlan-modal-mac').value = '';
+  document.getElementById('vxlan-modal-mtu').value = '1500';
+  document.getElementById('vxlan-modal-comment').value = '';
+  document.getElementById('vxlan-modal-learning').checked = true;
+  document.getElementById('vxlan-modal-disabled').checked = false;
+  document.getElementById('vxlan-modal').classList.remove('hidden');
+}
+
+function openEditVXLANModal(id) {
+  const t = (state.vxlanTunnels || []).find(tun => tun.id === id);
+  if (!t) return;
+  document.getElementById('vxlan-modal-title').innerText = `Edit VXLAN Interface (${t.name})`;
+  document.getElementById('vxlan-modal-id').value = t.id;
+  document.getElementById('vxlan-modal-name').value = t.name;
+  document.getElementById('vxlan-modal-vni').value = t.vni;
+  document.getElementById('vxlan-modal-port').value = t.port || 4789;
+  document.getElementById('vxlan-modal-ip').value = t.ip_address || '';
+  document.getElementById('vxlan-modal-parent').value = t.parent_interface || '';
+  document.getElementById('vxlan-modal-local').value = t.local_address || '';
+  document.getElementById('vxlan-modal-remote').value = t.remote_address || '';
+  document.getElementById('vxlan-modal-group').value = t.group || '';
+  document.getElementById('vxlan-modal-mac').value = t.mac_address || '';
+  document.getElementById('vxlan-modal-mtu').value = t.mtu || 1500;
+  document.getElementById('vxlan-modal-comment').value = t.comment || '';
+  document.getElementById('vxlan-modal-learning').checked = t.learning !== false;
+  document.getElementById('vxlan-modal-disabled').checked = !!t.disabled;
+  document.getElementById('vxlan-modal').classList.remove('hidden');
+}
+
+function closeVXLANModal() {
+  document.getElementById('vxlan-modal').classList.add('hidden');
+}
+
+async function submitSaveVXLAN(e) {
+  e.preventDefault();
+  const id = document.getElementById('vxlan-modal-id').value;
+  const name = document.getElementById('vxlan-modal-name').value.trim();
+  const vni = parseInt(document.getElementById('vxlan-modal-vni').value);
+  const port = parseInt(document.getElementById('vxlan-modal-port').value) || 4789;
+  const ipAddress = document.getElementById('vxlan-modal-ip').value.trim();
+  const parentInterface = document.getElementById('vxlan-modal-parent').value.trim();
+  const local = document.getElementById('vxlan-modal-local').value.trim();
+  const remote = document.getElementById('vxlan-modal-remote').value.trim();
+  const group = document.getElementById('vxlan-modal-group').value.trim();
+  const mac = document.getElementById('vxlan-modal-mac').value.trim();
+  const mtu = parseInt(document.getElementById('vxlan-modal-mtu').value) || 1500;
+  const comment = document.getElementById('vxlan-modal-comment').value.trim();
+  const learning = document.getElementById('vxlan-modal-learning').checked;
+  const disabled = document.getElementById('vxlan-modal-disabled').checked;
+
+  try {
+    const res = await fetch(`${API_BASE}/vpn/vxlan`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        id,
+        name,
+        vni,
+        port,
+        ip_address: ipAddress,
+        parent_interface: parentInterface,
+        local_address: local,
+        remote_address: remote,
+        group,
+        mac_address: mac,
+        mtu,
+        learning,
+        comment,
+        disabled
+      })
+    });
+    if (res.ok) {
+      showToast('VXLAN Saved', `Interface ${name} configured successfully`, 'success');
+      closeVXLANModal();
+      loadVXLAN(false);
+      fetchData(false);
+    } else {
+      const err = await res.json();
+      showToast('Error', err.message, 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleVXLANState(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/vpn/vxlan/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) {
+      showToast(disabled ? 'Disabled' : 'Enabled', 'VXLAN interface state updated', 'info');
+      loadVXLAN(false);
+      fetchData(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteVXLAN(id) {
+  if (!confirm('Are you sure you want to delete this VXLAN interface?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/vpn/vxlan/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'VXLAN interface removed', 'success');
+      loadVXLAN(false);
+      fetchData(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+function openAddVXLANVtepModal(defaultIface = '') {
+  const sel = document.getElementById('vxlan-vtep-modal-interface');
+  if (sel) {
+    sel.innerHTML = '';
+    const tunnels = state.vxlanTunnels || [];
+    tunnels.forEach(t => {
+      const opt = document.createElement('option');
+      opt.value = t.name;
+      opt.innerText = `${t.name} (VNI ${t.vni})`;
+      if (defaultIface && t.name === defaultIface) {
+        opt.selected = true;
+      }
+      sel.appendChild(opt);
+    });
+  }
+  document.getElementById('vxlan-vtep-modal-remote').value = '';
+  document.getElementById('vxlan-vtep-modal-port').value = '';
+  document.getElementById('vxlan-vtep-modal-comment').value = '';
+  document.getElementById('vxlan-vtep-modal').classList.remove('hidden');
+}
+
+function closeVXLANVtepModal() {
+  document.getElementById('vxlan-vtep-modal').classList.add('hidden');
+}
+
+async function submitSaveVXLANVtep(e) {
+  e.preventDefault();
+  const iface = document.getElementById('vxlan-vtep-modal-interface').value;
+  const remote = document.getElementById('vxlan-vtep-modal-remote').value.trim();
+  const port = parseInt(document.getElementById('vxlan-vtep-modal-port').value) || 0;
+  const comment = document.getElementById('vxlan-vtep-modal-comment').value.trim();
+
+  try {
+    const res = await fetch(`${API_BASE}/vpn/vxlan/vteps`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        interface: iface,
+        remote_ip: remote,
+        port,
+        comment
+      })
+    });
+    if (res.ok) {
+      showToast('VTEP Added', `Peer ${remote} added to ${iface}`, 'success');
+      closeVXLANVtepModal();
+      loadVXLAN(false);
+      fetchData(false);
+    } else {
+      const err = await res.json();
+      showToast('Error', err.message, 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteVXLANVtep(iface, vtepIdOrIp) {
+  if (!confirm(`Are you sure you want to remove VTEP ${vtepIdOrIp} from ${iface}?`)) return;
+  try {
+    const res = await fetch(`${API_BASE}/vpn/vxlan/${encodeURIComponent(iface)}/vteps/${encodeURIComponent(vtepIdOrIp)}`, {
+      method: 'DELETE'
+    });
+    if (res.ok) {
+      showToast('Deleted', 'Remote VTEP removed', 'success');
+      loadVXLAN(false);
+      fetchData(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
 // ======================== AeroTUN (Custom Encrypted VPN with Adaptive LZ4) ========================
 const aeroRateSamples = new Map();
 const aeroLiveRates = new Map();
@@ -11359,12 +11685,13 @@ function openCurrentBridgeModal() {
 
 async function loadBridge() {
   try {
-    const [bridgesRes, portsRes, vlansRes, summaryRes, eoipRes, vlanIfacesRes] = await Promise.all([
+    const [bridgesRes, portsRes, vlansRes, summaryRes, eoipRes, vxlanRes, vlanIfacesRes] = await Promise.all([
       fetch(`${API_BASE}/bridge/bridges`),
       fetch(`${API_BASE}/bridge/ports`),
       fetch(`${API_BASE}/bridge/vlans`),
       fetch(`${API_BASE}/bridge/summary`),
       fetch(`${API_BASE}/vpn/eoip`),
+      fetch(`${API_BASE}/vpn/vxlan`),
       fetch(`${API_BASE}/vlans`)
     ]);
 
@@ -11373,6 +11700,7 @@ async function loadBridge() {
     if (vlansRes.ok) state.bridgeVLANs = await vlansRes.json();
     if (summaryRes.ok) state.bridgeSummary = await summaryRes.json();
     if (eoipRes && eoipRes.ok) state.eoipTunnels = await eoipRes.json();
+    if (vxlanRes && vxlanRes.ok) state.vxlanTunnels = await vxlanRes.json();
     if (vlanIfacesRes && vlanIfacesRes.ok) state.vlans = await vlanIfacesRes.json();
 
     // Badges & Stats
@@ -12160,6 +12488,18 @@ function populateBridgePortCandidateSelect(selectId, currentVal = '') {
     grpEoip.appendChild(opt);
   });
   if (grpEoip.children.length > 0) sel.appendChild(grpEoip);
+
+  // 4. VXLAN Tunnels (MikroTik VXLAN bridging!)
+  const grpVxlan = document.createElement('optgroup');
+  grpVxlan.label = 'VXLAN Tunnels (Layer 2 Overlay)';
+  (state.vxlanTunnels || []).forEach(t => {
+    const opt = document.createElement('option');
+    opt.value = t.name;
+    opt.innerText = `${t.name} (VNI: ${t.vni}, Port: ${t.port})`;
+    if (t.name === currentVal) opt.selected = true;
+    grpVxlan.appendChild(opt);
+  });
+  if (grpVxlan.children.length > 0) sel.appendChild(grpVxlan);
 }
 
 // -------------------------------------------------------------

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

@@ -366,6 +366,17 @@
           <span id="badge-eoip-count" class="text-[10px] bg-slate-800 text-slate-300 px-1.5 py-0.2 rounded-full font-mono">0</span>
         </a>
 
+        <a href="#vxlan" onclick="navigate('vxlan')" id="nav-vxlan"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
+            </svg>
+            <span>VXLAN Tunnels</span>
+          </div>
+          <span id="badge-vxlan-count" class="text-[10px] bg-slate-800 text-slate-300 px-1.5 py-0.2 rounded-full font-mono">0</span>
+        </a>
+
         <a href="#aerotun" onclick="navigate('aerotun')" id="nav-aerotun"
            class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
           <div class="flex items-center space-x-2.5">
@@ -1965,6 +1976,91 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: VXLAN TUNNELS ================= -->
+        <section id="section-vxlan" class="hidden space-y-6">
+          <div class="bg-gradient-to-r from-amber-950/40 via-slate-900 to-slate-900 border border-amber-900/30 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
+            <div>
+              <div class="flex items-center gap-2 mb-1">
+                <span class="px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30 tracking-wide uppercase">Layer 2 Overlay</span>
+                <h2 class="text-base font-bold text-white">VXLAN (Virtual Extensible LAN)</h2>
+              </div>
+              <p class="text-xs text-slate-400">RFC 7348 standard L2 overlay network encapsulated in UDP packets (port 4789). Supports 16M VNIs and static VTEPs for unicast flooding matching MikroTik RouterOS v7.</p>
+            </div>
+            <div class="flex items-center gap-2">
+              <button onclick="openAddVXLANVtepModal()" class="text-xs font-semibold px-3.5 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-amber-300 border border-amber-500/30 transition flex items-center gap-1.5">
+                <span>+ Add Remote VTEP</span>
+              </button>
+              <button onclick="openAddVXLANModal()" class="text-xs font-semibold px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-500/20 transition flex items-center gap-1.5">
+                <span>+ Add VXLAN Tunnel</span>
+              </button>
+            </div>
+          </div>
+
+          <!-- VXLAN Interfaces Table -->
+          <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <h3 class="text-sm font-bold text-white">Configured VXLAN Interfaces</h3>
+              <span id="vxlan-count-badge" class="text-xs font-mono bg-slate-800 text-slate-300 px-2.5 py-0.5 rounded-full">0 Tunnels</span>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3 w-12 text-center" title="Flags: R=Running, X=Disabled">Flag</th>
+                    <th class="py-2.5 px-3">Name</th>
+                    <th class="py-2.5 px-3">VNI</th>
+                    <th class="py-2.5 px-3">Port</th>
+                    <th class="py-2.5 px-3">Overlay IP</th>
+                    <th class="py-2.5 px-3">Underlay (Dev / Local)</th>
+                    <th class="py-2.5 px-3">MAC Address</th>
+                    <th class="py-2.5 px-3">MTU</th>
+                    <th class="py-2.5 px-3">VTEPs</th>
+                    <th class="py-2.5 px-3">RX / TX Traffic</th>
+                    <th class="py-2.5 px-3">Comment</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="vxlan-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+
+            <div id="vxlan-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No VXLAN interfaces configured. Click "+ Add VXLAN Tunnel" to create one.
+            </div>
+          </div>
+
+          <!-- Remote VTEPs Table -->
+          <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <div>
+                <h3 class="text-sm font-bold text-white">Static Remote VTEPs (Flooding Peers)</h3>
+                <p class="text-[11px] text-slate-400">Unicast peer IP endpoints for BUM (Broadcast, Unknown unicast, Multicast) packet replication.</p>
+              </div>
+              <span id="vxlan-vteps-count-badge" class="text-xs font-mono bg-slate-800 text-slate-300 px-2.5 py-0.5 rounded-full">0 VTEPs</span>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3">Parent Interface</th>
+                    <th class="py-2.5 px-3">Remote IP</th>
+                    <th class="py-2.5 px-3">UDP Port</th>
+                    <th class="py-2.5 px-3">Comment</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="vxlan-vteps-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+
+            <div id="vxlan-vteps-empty" class="hidden p-8 text-center text-slate-500 text-xs font-sans">
+              No static VTEPs configured. Click "+ Add Remote VTEP" to add a peer.
+            </div>
+          </div>
+        </section>
+
         <!-- ================= SECTION: AeroTUN ================= -->
         <section id="section-aerotun" class="hidden space-y-6">
           <div class="bg-gradient-to-r from-sky-950/50 via-slate-900 to-slate-900 border border-sky-800/40 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
@@ -9094,6 +9190,138 @@
     </div>
   </div>
 
+  <!-- ================= MODAL: ADD/EDIT VXLAN INTERFACE ================= -->
+  <div id="vxlan-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">
+        <div>
+          <h3 id="vxlan-modal-title" class="text-sm font-bold text-white">Add VXLAN Interface</h3>
+          <p class="text-[11px] text-slate-400">Virtual Extensible LAN (RFC 7348) L2 Overlay</p>
+        </div>
+        <button onclick="closeVXLANModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSaveVXLAN(event)" class="p-6 space-y-4 text-xs font-sans max-h-[85vh] overflow-y-auto">
+        <input type="hidden" id="vxlan-modal-id">
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Interface Name *</label>
+            <input type="text" id="vxlan-modal-name" placeholder="vxlan100" 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-amber-500">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">VNI (1 - 16777215) *</label>
+            <input type="number" id="vxlan-modal-vni" min="1" max="16777215" placeholder="100" 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-amber-500">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">UDP Port (Default: 4789)</label>
+            <input type="number" id="vxlan-modal-port" value="4789" 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-amber-500">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Overlay IP / CIDR</label>
+            <input type="text" id="vxlan-modal-ip" placeholder="e.g. 10.200.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-amber-500">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Underlay Interface</label>
+            <input type="text" id="vxlan-modal-parent" placeholder="e.g. eth0" 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-amber-500">
+            <span class="text-[10px] text-slate-400">Physical/VLAN egress device</span>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Local Source IP</label>
+            <input type="text" id="vxlan-modal-local" placeholder="e.g. 192.168.1.10" 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-amber-500">
+            <span class="text-[10px] text-slate-400">Source VTEP IP for outer UDP</span>
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Remote IP (Point-to-Point)</label>
+            <input type="text" id="vxlan-modal-remote" placeholder="e.g. 192.168.1.20" 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-amber-500">
+            <span class="text-[10px] text-slate-400">Leave empty if using VTEPs list</span>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Multicast Group (Optional)</label>
+            <input type="text" id="vxlan-modal-group" placeholder="e.g. 239.1.1.1" 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-amber-500">
+            <span class="text-[10px] text-slate-400">Optional IGMP multicast group</span>
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">MTU</label>
+            <input type="number" id="vxlan-modal-mtu" value="1500" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">MAC Address (Auto if empty)</label>
+            <input type="text" id="vxlan-modal-mac" placeholder="02:58:xx:xx:xx:xx" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="vxlan-modal-comment" placeholder="Data center overlay network" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+        </div>
+
+        <div class="flex items-center justify-between pt-1">
+          <label class="flex items-center space-x-2 text-slate-300">
+            <input type="checkbox" id="vxlan-modal-learning" checked class="rounded bg-slate-800 border-slate-700 text-amber-500">
+            <span>MAC Address Learning</span>
+          </label>
+          <label class="flex items-center space-x-2 text-slate-300">
+            <input type="checkbox" id="vxlan-modal-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-600">
+            <span>Disabled</span>
+          </label>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeVXLANModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-500/20">Save Interface</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: ADD REMOTE VTEP ================= -->
+  <div id="vxlan-vtep-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-md 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">
+        <div>
+          <h3 id="vxlan-vtep-modal-title" class="text-sm font-bold text-white">Add Remote VTEP</h3>
+          <p class="text-[11px] text-slate-400">Unicast peer for VXLAN flood replication</p>
+        </div>
+        <button onclick="closeVXLANVtepModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSaveVXLANVtep(event)" class="p-6 space-y-4 text-xs font-sans">
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Parent VXLAN Interface *</label>
+          <select id="vxlan-vtep-modal-interface" 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-amber-500">
+          </select>
+        </div>
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Remote IP Address *</label>
+          <input type="text" id="vxlan-vtep-modal-remote" placeholder="e.g. 192.168.10.50" 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-amber-500">
+          <span class="text-[10px] text-slate-400">Unicast transport IP of the remote VTEP</span>
+        </div>
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">UDP Port (Optional)</label>
+          <input type="number" id="vxlan-vtep-modal-port" placeholder="Default (interface port)" 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-amber-500">
+        </div>
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="vxlan-vtep-modal-comment" placeholder="Remote Data Center Host 2" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+        </div>
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeVXLANVtepModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-500/20">Add VTEP</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
   <!-- ================= MODAL: ADD/EDIT AeroTUN TUNNEL ================= -->
   <div id="aerotun-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">
@@ -11745,6 +11973,6 @@
   </div>
 
   <datalist id="fw-addrlist-datalist"></datalist>
-  <script src="app.js?v=21.0"></script>
+  <script src="app.js?v=22.0"></script>
 </body>
 </html>