Przeglądaj źródła

fix(eoip): implement native MikroTik RouterOS EoIP protocol (GRE 0x6400, keepalive handshake, veth L2 bridge injection)

Proxmox Router 1 tydzień temu
rodzic
commit
0a6ee06f08
2 zmienionych plików z 393 dodań i 89 usunięć
  1. 3 0
      internal/network/manager.go
  2. 390 89
      internal/vpn/eoip.go

+ 3 - 0
internal/network/manager.go

@@ -50,6 +50,9 @@ func (m *Manager) ListInterfaces() ([]InterfaceInfo, error) {
 
 	for _, link := range links {
 		attrs := link.Attrs()
+		if strings.HasPrefix(attrs.Name, "ep_") {
+			continue
+		}
 		info := InterfaceInfo{
 			Index:     attrs.Index,
 			Name:      attrs.Name,

+ 390 - 89
internal/vpn/eoip.go

@@ -1,27 +1,30 @@
 package vpn
 
 import (
-	"bufio"
 	"crypto/rand"
+	"encoding/binary"
 	"encoding/json"
 	"fmt"
+	"log"
+	"net"
 	"os"
 	"os/exec"
 	"path/filepath"
 	"strconv"
-	"strings"
 	"sync"
+	"sync/atomic"
+	"syscall"
 	"time"
 )
 
-// EoIPInstance represents a MikroTik Ethernet-over-IP (GRETAP) tunnel
+// EoIPInstance represents a MikroTik Ethernet-over-IP tunnel instance
 type EoIPInstance struct {
 	ID            string `json:"id"`
-	Name          string `json:"name"`           // e.g. "eoip-tunnel1"
+	Name          string `json:"name"`           // e.g. "Goldix" or "eoip-tunnel1"
 	TunnelID      int    `json:"tunnel_id"`      // 1 - 65535
-	RemoteAddress string `json:"remote_address"` // Remote router IPv4
+	RemoteAddress string `json:"remote_address"` // Remote MikroTik IPv4
 	LocalAddress  string `json:"local_address"`  // Local router IPv4 (optional)
-	MACAddress    string `json:"mac_address"`    // e.g. "02:00:00:00:00:01"
+	MACAddress    string `json:"mac_address"`    // e.g. "02:64:83:6b:46:44"
 	MTU           int    `json:"mtu"`            // default 1500
 	ClampMSS      bool   `json:"clamp_mss"`      // Clamp TCP MSS for PMTUD
 	Comment       string `json:"comment,omitempty"`
@@ -40,12 +43,14 @@ type EoIPManager struct {
 	mu        sync.RWMutex
 	storePath string
 	tunnels   map[string]*EoIPInstance
+	workers   map[string]*eoipTunnelWorker
 }
 
 func NewEoIPManager() *EoIPManager {
 	mgr := &EoIPManager{
 		storePath: "/etc/proxmox-router/vpn/eoip.json",
 		tunnels:   make(map[string]*EoIPInstance),
+		workers:   make(map[string]*eoipTunnelWorker),
 	}
 	mgr.load()
 	mgr.syncAll()
@@ -80,7 +85,7 @@ func (m *EoIPManager) List() []*EoIPInstance {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
-	list := make([]*EoIPInstance, 0)
+	list := make([]*EoIPInstance, 0, len(m.tunnels))
 	for _, t := range m.tunnels {
 		m.updateStats(t)
 		list = append(list, t)
@@ -122,15 +127,29 @@ func (m *EoIPManager) Save(inst *EoIPInstance) error {
 		inst.ID = fmt.Sprintf("eoip-%d", time.Now().UnixNano())
 	}
 
-	// Stop previous interface if name changed or existing
-	if prev, exists := m.tunnels[inst.ID]; exists && prev.Name != inst.Name {
-		m.destroyInterface(prev.Name)
+	// Stop previous worker if exists
+	if w, exists := m.workers[inst.ID]; exists {
+		w.stop()
+		delete(m.workers, inst.ID)
 	}
 
 	m.tunnels[inst.ID] = inst
 	_ = m.save()
 
-	return m.applyTunnel(inst)
+	if !inst.Disabled {
+		worker, err := newEoIPTunnelWorker(inst)
+		if err != nil {
+			inst.LastError = err.Error()
+			inst.Running = false
+			return err
+		}
+		m.workers[inst.ID] = worker
+		go worker.start()
+	} else {
+		inst.Running = false
+	}
+
+	return nil
 }
 
 func (m *EoIPManager) SetState(id string, disabled bool) error {
@@ -144,23 +163,40 @@ func (m *EoIPManager) SetState(id string, disabled bool) error {
 	t.Disabled = disabled
 	_ = m.save()
 
+	if w, exists := m.workers[id]; exists {
+		w.stop()
+		delete(m.workers, id)
+	}
+
 	if disabled {
-		m.destroyInterface(t.Name)
 		t.Running = false
 		return nil
 	}
-	return m.applyTunnel(t)
+
+	worker, err := newEoIPTunnelWorker(t)
+	if err != nil {
+		t.LastError = err.Error()
+		t.Running = false
+		return err
+	}
+	m.workers[id] = worker
+	go worker.start()
+	return nil
 }
 
 func (m *EoIPManager) Delete(id string) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
-	t, ok := m.tunnels[id]
-	if !ok {
+	if _, ok := m.tunnels[id]; !ok {
 		return fmt.Errorf("tunnel not found")
 	}
-	m.destroyInterface(t.Name)
+
+	if w, exists := m.workers[id]; exists {
+		w.stop()
+		delete(m.workers, id)
+	}
+
 	delete(m.tunnels, id)
 	return m.save()
 }
@@ -169,111 +205,376 @@ func (m *EoIPManager) syncAll() {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
-	for _, t := range m.tunnels {
+	for id, t := range m.tunnels {
 		if !t.Disabled {
-			_ = m.applyTunnel(t)
+			if w, exists := m.workers[id]; exists {
+				w.stop()
+				delete(m.workers, id)
+			}
+			worker, err := newEoIPTunnelWorker(t)
+			if err != nil {
+				t.LastError = err.Error()
+				t.Running = false
+				continue
+			}
+			m.workers[id] = worker
+			go worker.start()
 		} else {
-			m.destroyInterface(t.Name)
 			t.Running = false
 		}
 	}
 }
 
-func (m *EoIPManager) applyTunnel(t *EoIPInstance) error {
-	m.destroyInterface(t.Name)
-
-	if t.Disabled {
+func (m *EoIPManager) updateStats(t *EoIPInstance) {
+	if w, exists := m.workers[t.ID]; exists {
+		t.Running = w.isRunning()
+		t.RxBytes = atomic.LoadUint64(&w.rxBytes)
+		t.RxPackets = atomic.LoadUint64(&w.rxPackets)
+		t.TxBytes = atomic.LoadUint64(&w.txBytes)
+		t.TxPackets = atomic.LoadUint64(&w.txPackets)
+	} else {
 		t.Running = false
-		return nil
 	}
+	t.RxHuman = formatBytes(t.RxBytes)
+	t.TxHuman = formatBytes(t.TxBytes)
+}
+
+// -------------------------------------------------------------
+// Native MikroTik EoIP Engine Worker
+// -------------------------------------------------------------
+
+type eoipTunnelWorker struct {
+	inst           *EoIPInstance
+	peerIfName     string
+	peerIfIndex    int
+	greFd          int
+	pktFd          int
+	stopChan       chan struct{}
+	lastRx         int64 // unix nano
+	rxBytes        uint64
+	rxPackets      uint64
+	txBytes        uint64
+	txPackets      uint64
+	remoteAddrInet syscall.SockaddrInet4
+}
 
-	// Construct ip link add command:
-	// ip link add <name> type gretap remote <remote_ip> [local <local_ip>] key <tunnel_id>
-	args := []string{"link", "add", t.Name, "type", "gretap", "remote", t.RemoteAddress}
-	if t.LocalAddress != "" {
-		args = append(args, "local", t.LocalAddress)
+func newEoIPTunnelWorker(inst *EoIPInstance) (*eoipTunnelWorker, error) {
+	// Parse Remote IPv4
+	remoteIP := net.ParseIP(inst.RemoteAddress).To4()
+	if remoteIP == nil {
+		return nil, fmt.Errorf("invalid remote address: %s", inst.RemoteAddress)
 	}
-	args = append(args, "key", strconv.Itoa(t.TunnelID))
 
-	if out, err := exec.Command("ip", args...).CombinedOutput(); err != nil {
-		t.LastError = fmt.Sprintf("create failed: %s - %v", strings.TrimSpace(string(out)), err)
-		t.Running = false
-		return fmt.Errorf("%s", t.LastError)
+	// Internal peer interface name (max 15 chars for Linux ifnames)
+	peerName := fmt.Sprintf("ep_%s", inst.Name)
+	if len(peerName) > 15 {
+		peerName = peerName[:15]
 	}
 
-	// Set MAC address if provided
-	if t.MACAddress != "" {
-		_ = exec.Command("ip", "link", "set", t.Name, "address", t.MACAddress).Run()
+	// 1. Clean up existing links if present
+	_ = exec.Command("ip", "link", "del", inst.Name).Run()
+	_ = exec.Command("ip", "link", "del", peerName).Run()
+
+	// 2. Create veth pair: inst.Name <--> peerName
+	cmdAdd := exec.Command("ip", "link", "add", inst.Name, "type", "veth", "peer", "name", peerName)
+	if out, err := cmdAdd.CombinedOutput(); err != nil {
+		return nil, fmt.Errorf("failed to create veth pair: %s - %w", string(out), err)
 	}
 
-	// Set MTU
-	if t.MTU > 0 {
-		_ = exec.Command("ip", "link", "set", t.Name, "mtu", strconv.Itoa(t.MTU)).Run()
+	// 3. Set MAC address and MTU
+	if inst.MACAddress != "" {
+		_ = exec.Command("ip", "link", "set", inst.Name, "address", inst.MACAddress).Run()
 	}
+	mtuStr := strconv.Itoa(inst.MTU)
+	_ = exec.Command("ip", "link", "set", inst.Name, "mtu", mtuStr).Run()
+	_ = exec.Command("ip", "link", "set", peerName, "mtu", mtuStr).Run()
 
-	// Bring UP
-	if out, err := exec.Command("ip", "link", "set", t.Name, "up").CombinedOutput(); err != nil {
-		t.LastError = fmt.Sprintf("up failed: %s - %v", strings.TrimSpace(string(out)), err)
-		t.Running = false
-		return fmt.Errorf("%s", t.LastError)
+	// 4. Bring both interfaces UP
+	_ = exec.Command("ip", "link", "set", inst.Name, "up").Run()
+	_ = exec.Command("ip", "link", "set", peerName, "up").Run()
+
+	// 5. TCP MSS Clamping
+	if inst.ClampMSS {
+		_ = exec.Command("iptables", "-t", "mangle", "-A", "FORWARD", "-o", inst.Name, "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu").Run()
 	}
 
-	// TCP MSS Clamping if enabled
-	if t.ClampMSS {
-		_ = exec.Command("iptables", "-t", "mangle", "-D", "FORWARD", "-o", t.Name, "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu").Run()
-		_ = exec.Command("iptables", "-t", "mangle", "-A", "FORWARD", "-o", t.Name, "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu").Run()
+	// 6. Find interface index of peerName
+	peerIf, err := net.InterfaceByName(peerName)
+	if err != nil {
+		_ = exec.Command("ip", "link", "del", inst.Name).Run()
+		return nil, fmt.Errorf("cannot find peer interface %s: %w", peerName, err)
 	}
 
-	t.Running = true
-	t.LastError = ""
-	return nil
+	// 7. Open AF_PACKET raw socket on peer interface
+	pktFd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(htons(syscall.ETH_P_ALL)))
+	if err != nil {
+		_ = exec.Command("ip", "link", "del", inst.Name).Run()
+		return nil, fmt.Errorf("cannot create AF_PACKET socket: %w", err)
+	}
+	pktAddr := syscall.SockaddrLinklayer{
+		Protocol: htons(syscall.ETH_P_ALL),
+		Ifindex:  peerIf.Index,
+	}
+	if err := syscall.Bind(pktFd, &pktAddr); err != nil {
+		syscall.Close(pktFd)
+		_ = exec.Command("ip", "link", "del", inst.Name).Run()
+		return nil, fmt.Errorf("cannot bind AF_PACKET socket to %s: %w", peerName, err)
+	}
+
+	// 8. Open raw GRE socket (IPPROTO_GRE = 47)
+	greFd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_GRE)
+	if err != nil {
+		syscall.Close(pktFd)
+		_ = exec.Command("ip", "link", "del", inst.Name).Run()
+		return nil, fmt.Errorf("cannot create raw GRE socket: %w", err)
+	}
+
+	// Bind GRE socket to LocalAddress if specified
+	if inst.LocalAddress != "" {
+		localIP := net.ParseIP(inst.LocalAddress).To4()
+		if localIP != nil {
+			localAddr := &syscall.SockaddrInet4{
+				Addr: [4]byte{localIP[0], localIP[1], localIP[2], localIP[3]},
+			}
+			_ = syscall.Bind(greFd, localAddr)
+		}
+	}
+
+	w := &eoipTunnelWorker{
+		inst:        inst,
+		peerIfName:  peerName,
+		peerIfIndex: peerIf.Index,
+		greFd:       greFd,
+		pktFd:       pktFd,
+		stopChan:    make(chan struct{}),
+		remoteAddrInet: syscall.SockaddrInet4{
+			Addr: [4]byte{remoteIP[0], remoteIP[1], remoteIP[2], remoteIP[3]},
+		},
+		rxBytes:   inst.RxBytes,
+		rxPackets: inst.RxPackets,
+		txBytes:   inst.TxBytes,
+		txPackets: inst.TxPackets,
+	}
+
+	return w, nil
 }
 
-func (m *EoIPManager) destroyInterface(name string) {
-	if name == "" {
-		return
+func (w *eoipTunnelWorker) start() {
+	log.Printf("[EoIP] Started tunnel '%s' (ID: %d, Remote: %s)", w.inst.Name, w.inst.TunnelID, w.inst.RemoteAddress)
+
+	// Goroutine 1: Forward packets from Local veth -> Remote MikroTik via GRE
+	go w.txLoop()
+
+	// Goroutine 2: Receive packets from Remote MikroTik via GRE -> Inject to Local veth
+	go w.rxLoop()
+
+	// Goroutine 3: Periodic MikroTik EoIP Keepalive
+	go w.keepaliveLoop()
+}
+
+func (w *eoipTunnelWorker) isRunning() bool {
+	last := atomic.LoadInt64(&w.lastRx)
+	if last == 0 {
+		return false
 	}
-	_ = exec.Command("iptables", "-t", "mangle", "-D", "FORWARD", "-o", name, "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu").Run()
-	_ = exec.Command("ip", "link", "del", name).Run()
+	return time.Since(time.Unix(0, last)) < 15*time.Second
 }
 
-func (m *EoIPManager) updateStats(t *EoIPInstance) {
-	// Check if interface is up
-	out, err := exec.Command("ip", "link", "show", t.Name).Output()
-	if err != nil || !strings.Contains(string(out), "UP") {
-		t.Running = false
-	} else {
-		t.Running = true
+func (w *eoipTunnelWorker) sendKeepalive() {
+	// MikroTik EoIP Keepalive packet:
+	// GRE Flags: 0x2001
+	// Protocol:  0x6400
+	// Length:    0x0000
+	// Tunnel ID: Little-Endian
+	tid := uint16(w.inst.TunnelID)
+	pkt := []byte{
+		0x20, 0x01,
+		0x64, 0x00,
+		0x00, 0x00,
+		byte(tid & 0xff), byte(tid >> 8),
 	}
+	_ = syscall.Sendto(w.greFd, pkt, 0, &w.remoteAddrInet)
+}
 
-	// Read /proc/net/dev for RX/TX bytes
-	file, err := os.Open("/proc/net/dev")
-	if err != nil {
-		return
-	}
-	defer file.Close()
-
-	scanner := bufio.NewScanner(file)
-	for scanner.Scan() {
-		line := scanner.Text()
-		if strings.Contains(line, t.Name+":") {
-			parts := strings.Fields(line)
-			if len(parts) >= 10 {
-				rxBytes, _ := strconv.ParseUint(parts[1], 10, 64)
-				rxPackets, _ := strconv.ParseUint(parts[2], 10, 64)
-				txBytes, _ := strconv.ParseUint(parts[9], 10, 64)
-				txPackets, _ := strconv.ParseUint(parts[10], 10, 64)
-
-				t.RxBytes = rxBytes
-				t.RxPackets = rxPackets
-				t.TxBytes = txBytes
-				t.TxPackets = txPackets
-				t.RxHuman = formatBytes(rxBytes)
-				t.TxHuman = formatBytes(txBytes)
+func (w *eoipTunnelWorker) keepaliveLoop() {
+	ticker := time.NewTicker(5 * time.Second)
+	defer ticker.Stop()
+
+	// Send immediate initial keepalive
+	w.sendKeepalive()
+
+	for {
+		select {
+		case <-w.stopChan:
+			return
+		case <-ticker.C:
+			w.sendKeepalive()
+		}
+	}
+}
+
+func (w *eoipTunnelWorker) txLoop() {
+	buf := make([]byte, 2048)
+	tid := uint16(w.inst.TunnelID)
+
+	for {
+		select {
+		case <-w.stopChan:
+			return
+		default:
+		}
+
+		n, from, err := syscall.Recvfrom(w.pktFd, buf, 0)
+		if err != nil {
+			select {
+			case <-w.stopChan:
+				return
+			default:
+				time.Sleep(10 * time.Millisecond)
+				continue
+			}
+		}
+
+		// Filter out outgoing packets to prevent reflection
+		if fromLink, ok := from.(*syscall.SockaddrLinklayer); ok {
+			if fromLink.Pkttype == syscall.PACKET_OUTGOING {
+				continue
 			}
-			break
 		}
+
+		// Must have at least minimum Ethernet header length (14 bytes)
+		if n < 14 {
+			continue
+		}
+
+		// Construct MikroTik EoIP Header (8 bytes) + Ethernet Frame
+		// [0..1] = 0x2001 (Flags)
+		// [2..3] = 0x6400 (Protocol)
+		// [4..5] = payload length (Big-Endian)
+		// [6..7] = tunnel ID (Little-Endian)
+		grePkt := make([]byte, 8+n)
+		grePkt[0] = 0x20
+		grePkt[1] = 0x01
+		grePkt[2] = 0x64
+		grePkt[3] = 0x00
+		binary.BigEndian.PutUint16(grePkt[4:6], uint16(n))
+		binary.LittleEndian.PutUint16(grePkt[6:8], tid)
+		copy(grePkt[8:], buf[:n])
+
+		// Send over raw GRE socket
+		if err := syscall.Sendto(w.greFd, grePkt, 0, &w.remoteAddrInet); err == nil {
+			atomic.AddUint64(&w.txPackets, 1)
+			atomic.AddUint64(&w.txBytes, uint64(n))
+		}
+	}
+}
+
+func (w *eoipTunnelWorker) rxLoop() {
+	buf := make([]byte, 4096)
+	expectedTid := uint16(w.inst.TunnelID)
+	remoteIP := net.ParseIP(w.inst.RemoteAddress).To4()
+
+	injectAddr := syscall.SockaddrLinklayer{
+		Protocol: htons(syscall.ETH_P_ALL),
+		Ifindex:  w.peerIfIndex,
+		Halen:    6,
 	}
+
+	for {
+		select {
+		case <-w.stopChan:
+			return
+		default:
+		}
+
+		n, from, err := syscall.Recvfrom(w.greFd, buf, 0)
+		if err != nil {
+			select {
+			case <-w.stopChan:
+				return
+			default:
+				time.Sleep(10 * time.Millisecond)
+				continue
+			}
+		}
+
+		// RAW IPv4 socket Recvfrom returns [IPv4 Header (20+ bytes)] + [GRE Header (8 bytes)] + [Payload]
+		if n < 28 {
+			continue
+		}
+
+		// Verify remote sender IP
+		if fromIP, ok := from.(*syscall.SockaddrInet4); ok {
+			if fromIP.Addr[0] != remoteIP[0] || fromIP.Addr[1] != remoteIP[1] ||
+				fromIP.Addr[2] != remoteIP[2] || fromIP.Addr[3] != remoteIP[3] {
+				continue
+			}
+		}
+
+		// Calculate IPv4 Header Length
+		ipHdrLen := int(buf[0]&0x0f) * 4
+		if n < ipHdrLen+8 {
+			continue
+		}
+
+		gre := buf[ipHdrLen:n]
+		// Verify MikroTik EoIP GRE Header
+		// Flags: 0x2001 (or 0x2000)
+		// Proto: 0x6400
+		if gre[0] != 0x20 || (gre[1] != 0x01 && gre[1] != 0x00) {
+			continue
+		}
+		if gre[2] != 0x64 || gre[3] != 0x00 {
+			continue
+		}
+
+		frameLen := binary.BigEndian.Uint16(gre[4:6])
+		tunnelID := binary.LittleEndian.Uint16(gre[6:8])
+
+		if tunnelID != expectedTid {
+			continue
+		}
+
+		// Record successful packet reception
+		now := time.Now().UnixNano()
+		atomic.StoreInt64(&w.lastRx, now)
+
+		if frameLen == 0 {
+			// Keepalive probe from MikroTik: reply with keepalive
+			w.sendKeepalive()
+			continue
+		}
+
+		// Valid Ethernet frame received from MikroTik EoIP tunnel
+		ethFrame := gre[8:]
+		if len(ethFrame) < int(frameLen) {
+			continue
+		}
+		payload := ethFrame[:frameLen]
+
+		// Inject Ethernet frame into veth peer -> kernel delivers it to user interface (w.inst.Name)
+		if err := syscall.Sendto(w.pktFd, payload, 0, &injectAddr); err == nil {
+			atomic.AddUint64(&w.rxPackets, 1)
+			atomic.AddUint64(&w.rxBytes, uint64(frameLen))
+		}
+	}
+}
+
+func (w *eoipTunnelWorker) stop() {
+	close(w.stopChan)
+	_ = syscall.Close(w.pktFd)
+	_ = syscall.Close(w.greFd)
+
+	// Clean up interfaces & iptables
+	if w.inst.ClampMSS {
+		_ = exec.Command("iptables", "-t", "mangle", "-D", "FORWARD", "-o", w.inst.Name, "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu").Run()
+	}
+	_ = exec.Command("ip", "link", "del", w.inst.Name).Run()
+	log.Printf("[EoIP] Stopped tunnel '%s'", w.inst.Name)
+}
+
+func htons(n uint16) uint16 {
+	return (n<<8)&0xff00 | (n>>8)&0x00ff
 }
 
 func generateMAC() string {