package vpn import ( "crypto/rand" "encoding/binary" "encoding/json" "fmt" "log" "net" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" ) // EoIPInstance represents a MikroTik Ethernet-over-IP tunnel instance type EoIPInstance struct { ID string `json:"id"` Name string `json:"name"` // e.g. "Goldix" or "eoip-tunnel1" TunnelID int `json:"tunnel_id"` // 1 - 65535 RemoteAddress string `json:"remote_address"` // Remote MikroTik IPv4 LocalAddress string `json:"local_address"` // Local router IPv4 (optional) IPAddress string `json:"ip_address,omitempty"` // Overlay IP/CIDR (e.g. "172.16.33.254/30") 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"` Disabled bool `json:"disabled"` Running bool `json:"running"` // MikroTik 'R' flag 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"` } 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() return mgr } func (m *EoIPManager) load() { if data, err := os.ReadFile(m.storePath); err == nil { var list []*EoIPInstance if err := json.Unmarshal(data, &list); err == nil { for _, t := range list { m.tunnels[t.ID] = t } } } } func (m *EoIPManager) save() error { _ = os.MkdirAll(filepath.Dir(m.storePath), 0755) var list []*EoIPInstance 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) } func (m *EoIPManager) List() []*EoIPInstance { m.mu.Lock() defer m.mu.Unlock() list := make([]*EoIPInstance, 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].TunnelID != list[j].TunnelID { return list[i].TunnelID < list[j].TunnelID } return list[i].Name < list[j].Name }) return list } func (m *EoIPManager) Get(id string) (*EoIPInstance, bool) { m.mu.Lock() defer m.mu.Unlock() t, ok := m.tunnels[id] if ok { m.updateStats(t) } return t, ok } func (m *EoIPManager) Save(inst *EoIPInstance) error { m.mu.Lock() defer m.mu.Unlock() if inst.Name == "" { inst.Name = fmt.Sprintf("eoip-tunnel%d", inst.TunnelID) } if inst.TunnelID <= 0 || inst.TunnelID > 65535 { return fmt.Errorf("tunnel_id must be between 1 and 65535") } if inst.RemoteAddress == "" { return fmt.Errorf("remote_address is required") } if inst.MTU <= 0 { inst.MTU = 1500 } if inst.MACAddress == "" { inst.MACAddress = generateMAC() } if inst.ID == "" { inst.ID = fmt.Sprintf("eoip-%d", time.Now().UnixNano()) } // Smart detection: if user put CIDR into LocalAddress by mistake, move it to IPAddress if strings.Contains(inst.LocalAddress, "/") && inst.IPAddress == "" { inst.IPAddress = inst.LocalAddress inst.LocalAddress = "" } // 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() 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 { m.mu.Lock() defer m.mu.Unlock() t, ok := m.tunnels[id] if !ok { return fmt.Errorf("tunnel not found") } t.Disabled = disabled _ = m.save() if w, exists := m.workers[id]; exists { w.stop() delete(m.workers, id) } if disabled { t.Running = false return nil } 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() if _, ok := m.tunnels[id]; !ok { return fmt.Errorf("tunnel not found") } if w, exists := m.workers[id]; exists { w.stop() delete(m.workers, id) } delete(m.tunnels, id) return m.save() } func (m *EoIPManager) syncAll() { m.mu.Lock() defer m.mu.Unlock() for id, t := range m.tunnels { if !t.Disabled { 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 { t.Running = false } } } 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 } t.RxHuman = formatBytes(t.RxBytes) t.TxHuman = formatBytes(t.TxBytes) // Detect any bound IPv4 if not explicitly saved in JSON if t.IPAddress == "" { if ifi, err := net.InterfaceByName(t.Name); err == nil { if addrs, err := ifi.Addrs(); err == nil { for _, a := range addrs { if ipNet, ok := a.(*net.IPNet); ok && ipNet.IP.To4() != nil { t.IPAddress = ipNet.String() break } } } } } } // ------------------------------------------------------------- // 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 } 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) } // Internal peer interface name (max 15 chars for Linux ifnames) peerName := fmt.Sprintf("ep_%s", inst.Name) if len(peerName) > 15 { peerName = peerName[:15] } // 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) } // 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() // 4. Bring both interfaces UP _ = exec.Command("ip", "link", "set", inst.Name, "up").Run() _ = exec.Command("ip", "link", "set", peerName, "up").Run() // 4b. Assign Overlay IP address if specified if inst.IPAddress != "" { _ = exec.Command("ip", "addr", "add", inst.IPAddress, "dev", inst.Name).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() } // 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) } // 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 (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 } return time.Since(time.Unix(0, last)) < 15*time.Second } 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) } 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 } } // 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 { buf := make([]byte, 3) _, _ = rand.Read(buf) return fmt.Sprintf("02:64:%02x:%02x:%02x:%02x", buf[0], buf[1], buf[2], time.Now().UnixNano()%256) } func formatBytes(b uint64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := uint64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) }