From 807c4cf3235c4a1805f520af860a7e637d0e68e7 Mon Sep 17 00:00:00 2001 From: xieyao Date: Wed, 17 Jun 2026 05:04:19 +0800 Subject: [PATCH] feat: Docker Compose E2E test env, agent fixes, TAP MAC config, testclient binary - Add docker-compose.yml with server + init + 3 clients + tester - Add Dockerfile.server (CGO multi-stage) and Dockerfile.client - Add .dockerignore for efficient builds - Add cmd/testclient/main.go: full E2E test binary using SDK - Fix agent handshake loop: only respond to new handshakes - Add tapInterface.SetMAC() to match agent's generated MAC - Call SetMAC() in agent SetTAPIP to fix ARP resolution - Add data-plane debug logging for troubleshooting - Change config database path to /tmp/data/ for container use - Add SetMAC stub for non-Linux builds --- .dockerignore | 4 + Dockerfile.client | 9 ++ Dockerfile.server | 12 +++ cmd/testclient/main.go | 192 +++++++++++++++++++++++++++++++++++++++++ config.yaml | 2 +- docker-compose.yml | 167 +++++++++++++++++++++++++++++++++++ sdk/agent.go | 134 ++++++++++++++++++++++++++-- sdk/tap_linux.go | 19 +++- sdk/tap_stub.go | 4 + 9 files changed, 535 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile.client create mode 100644 Dockerfile.server create mode 100644 cmd/testclient/main.go create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4d633bd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +web/node_modules/ +bin/ +.git/ +*.md diff --git a/Dockerfile.client b/Dockerfile.client new file mode 100644 index 0000000..7cd292a --- /dev/null +++ b/Dockerfile.client @@ -0,0 +1,9 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /src +COPY . . +RUN go build -o /zmclient ./cmd/testclient/ + +FROM alpine:3.19 +RUN apk add --no-cache iproute2 iptables-legacy +COPY --from=builder /zmclient /usr/local/bin/zmclient +CMD ["zmclient"] diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..2ae2f12 --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,12 @@ +FROM golang:1.26-alpine AS builder +RUN apk add --no-cache gcc musl-dev +WORKDIR /src +COPY . . +RUN CGO_ENABLED=1 go build -o /zeromesh . + +FROM alpine:3.19 +RUN apk add --no-cache iproute2 iptables-legacy +COPY --from=builder /zeromesh /usr/local/bin/zeromesh +COPY config.yaml /etc/zeromesh/config.yaml +EXPOSE 10001 19993/udp +CMD ["zeromesh", "-config", "/etc/zeromesh/config.yaml"] diff --git a/cmd/testclient/main.go b/cmd/testclient/main.go new file mode 100644 index 0000000..cdb9903 --- /dev/null +++ b/cmd/testclient/main.go @@ -0,0 +1,192 @@ +package main + +import ( + "flag" + "fmt" + "log" + "log/slog" + "net" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "zeromesh/sdk" +) + +var ( + serverURL = flag.String("server", "http://server:10001", "Controller HTTP URL") + name = flag.String("name", "client", "Node name") + udpPort = flag.Int("udp", 7001, "Agent UDP listen port") + tapName = flag.String("tap", "ztmesh0", "TAP device name") + identityPath = flag.String("identity", "", "Identity file path") + adminUser = flag.String("admin", "admin", "Admin username") + adminPass = flag.String("pass", "admin123456", "Admin password") +) + +func main() { + flag.Parse() + slog.SetLogLoggerLevel(slog.LevelInfo) + + cfg := sdk.DefaultConfig() + cfg.ControllerURL = *serverURL + cfg.ListenPort = *udpPort + cfg.TapName = *tapName + cfg.PSK = "default-psk-change-me" + if *identityPath != "" { + cfg.IdentityPath = *identityPath + } + cli := sdk.New(cfg) + + var token string + for i := 0; i < 30; i++ { + if err := cli.Login(*adminUser, *adminPass); err == nil { + token = cli.Token() + break + } + if _, err := cli.InitAdmin(*adminUser, *adminPass); err == nil { + token = cli.Token() + log.Print("initialized admin") + break + } + log.Printf("waiting for server (attempt %d)...", i+1) + time.Sleep(2 * time.Second) + } + if token == "" { + log.Fatal("could not login or init admin after 30 attempts") + } + log.Printf("logged in as %s", *adminUser) + + idPath := *identityPath + if idPath == "" { + idPath = fmt.Sprintf("/tmp/%s.id", *name) + } + id, err := sdk.LoadOrGenerateIdentity(idPath) + if err != nil { + log.Fatalf("identity: %v", err) + } + log.Printf("identity: %s pub=%s", id.Address, id.PublicKeyHex()) + + myIP := sdk.GetPreferredIP() + node, err := cli.RegisterNode(id.Address.String(), id.PublicKeyHex(), *name, myIP, *udpPort, "1.0.0") + if err != nil { + log.Fatalf("register node: %v", err) + } + log.Printf("registered node: %s", node.NodeID) + + findNetwork := func() *sdk.Network { + networks, err := cli.ListNetworks() + if err != nil { + return nil + } + for i := range networks { + if !strings.HasPrefix(networks[i].IPRange, "10.7.") && !strings.HasPrefix(networks[i].IPRange, "172.") && !strings.HasPrefix(networks[i].IPRange, "192.") { + continue + } + return &networks[i] + } + if len(networks) > 0 { + return &networks[0] + } + return nil + } + + myNet := findNetwork() + if myNet == nil { + created, err := cli.CreateNetwork(fmt.Sprintf("testnet-%s", *name), "") + if err != nil { + log.Fatalf("create network: %v", err) + } + myNet = created + log.Printf("created network: %s (%s) id=%d", myNet.Name, myNet.IPRange, myNet.NetworkID) + } else { + log.Printf("using network: %s (%s) id=%d", myNet.Name, myNet.IPRange, myNet.NetworkID) + } + + var myIPAddr string + for i := 0; i < 10; i++ { + members, err := cli.NetworkMembers(myNet.NetworkID) + if err == nil { + for _, m := range members { + if m.NodeID == id.Address.String() { + myIPAddr = m.IPAddress + break + } + } + } + if myIPAddr != "" { + break + } + log.Printf("authorizing self on network %d...", myNet.NetworkID) + cli.AuthorizeMember(myNet.NetworkID, id.Address.String()) + time.Sleep(1 * time.Second) + } + if myIPAddr == "" { + log.Fatalf("could not get virtual IP after authorization") + } + log.Printf("my virtual IP: %s", myIPAddr) + + agent := sdk.NewAgent(cli) + agent.SetIdentity(id) + + if err := agent.Start(); err != nil { + log.Fatalf("agent start: %v", err) + } + defer agent.Stop() + log.Printf("agent started: tap=%s udp=%d", agent.TAPName(), agent.LocalPort()) + + agent.ControllerHello() + time.Sleep(2 * time.Second) + + for i := 0; i < 6; i++ { + agent.SyncPeers() + time.Sleep(3 * time.Second) + c := agent.PeerCount() + if c > 0 { + break + } + } + + cidrFull := myIPAddr + "/24" + if err := agent.SetTAPIP(cidrFull); err != nil { + log.Printf("set tap ip warning: %v", err) + } else { + log.Printf("tap ip set: %s", cidrFull) + } + + listener, err := net.Listen("tcp", fmt.Sprintf("%s:7777", myIPAddr)) + if err != nil { + log.Printf("echo server listen error (expected if TAP not ready): %v", err) + } else { + defer listener.Close() + log.Printf("echo server listening on %s:7777", myIPAddr) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 4096) + for { + n, err := c.Read(buf) + if err != nil { + return + } + c.Write(buf[:n]) + } + }(conn) + } + }() + } + + peerCount := agent.PeerCount() + log.Printf("%s ready: ip=%s tap=%s peers=%d", *name, myIPAddr, agent.TAPName(), peerCount) + + sc := make(chan os.Signal, 1) + signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM) + <-sc + log.Printf("shutting down %s", *name) +} diff --git a/config.yaml b/config.yaml index 49834ad..a5bd7bf 100644 --- a/config.yaml +++ b/config.yaml @@ -6,7 +6,7 @@ database: auto_migrate: true init_data: true sqlite: - path: ./data/zeromesh.db + path: /tmp/data/zeromesh.db mysql: host: 127.0.0.1 port: 3306 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..66b2038 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,167 @@ +services: + server: + build: + context: . + dockerfile: Dockerfile.server + container_name: zm-server + hostname: server + privileged: true + ports: + - "7100:10001" + command: + - sh + - -c + - | + mkdir -p /tmp/data && zeromesh -config /etc/zeromesh/config.yaml + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:10001/api/health"] + interval: 3s + retries: 30 + start_period: 5s + networks: + zmt: + ipv4_address: 172.30.0.10 + + init: + image: alpine:3.19 + container_name: zm-init + hostname: init + depends_on: + server: + condition: service_healthy + command: + - sh + - -c + - | + apk add --no-cache curl jq + echo '=== Create admin (try init then login) ===' + ADMIN=$$(curl -s -X POST http://server:10001/api/v1/auth/init \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin123456"}') + TOKEN=$$(echo "$$ADMIN" | jq -r '.token // empty') + if [ -z "$$TOKEN" ]; then + echo 'Admin already exists, logging in...' + LOGIN=$$(curl -s -X POST http://server:10001/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin123456"}') + TOKEN=$$(echo "$$LOGIN" | jq -r '.token // empty') + fi + if [ -z "$$TOKEN" ]; then echo "Failed to get admin token"; exit 1; fi + echo 'Admin token acquired' + sleep 1 + echo '=== Create network ===' + curl -s -X POST http://server:10001/api/v1/network/create \ + -H "Authorization: Bearer $$TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"name":"testnet","ip_range":"10.7.0.0/24"}' + echo '' + echo '=== init done ===' + networks: + - zmt + + client1: + build: + context: . + dockerfile: Dockerfile.client + container_name: zm-client1 + hostname: client1 + privileged: true + ports: + - "7201:7001/udp" + depends_on: + init: + condition: service_completed_successfully + command: + - sh + - -c + - | + zmclient -server http://server:10001 -name client1 -udp 7001 -tap ztmesh0 -identity /tmp/c1.id + networks: + zmt: + ipv4_address: 172.30.0.11 + + client2: + build: + context: . + dockerfile: Dockerfile.client + container_name: zm-client2 + hostname: client2 + privileged: true + ports: + - "7202:7002/udp" + depends_on: + init: + condition: service_completed_successfully + command: + - sh + - -c + - | + zmclient -server http://server:10001 -name client2 -udp 7002 -tap ztmesh1 -identity /tmp/c2.id + networks: + zmt: + ipv4_address: 172.30.0.12 + + client3: + build: + context: . + dockerfile: Dockerfile.client + container_name: zm-client3 + hostname: client3 + privileged: true + ports: + - "7203:7003/udp" + depends_on: + init: + condition: service_completed_successfully + command: + - sh + - -c + - | + zmclient -server http://server:10001 -name client3 -udp 7003 -tap ztmesh2 -identity /tmp/c3.id + networks: + zmt: + ipv4_address: 172.30.0.13 + + tester: + image: alpine:3.19 + container_name: zm-tester + hostname: tester + privileged: true + depends_on: + init: + condition: service_completed_successfully + command: + - sh + - -c + - | + apk add --no-cache iputils iproute2 curl bash + echo '=== Waiting for clients (60s) ===' + sleep 60 + echo '=== Login ===' + LOGIN=$$(curl -s -X POST http://server:10001/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin123456"}') + TOKEN=$$(echo "$$LOGIN" | sed 's/.*"token":"//' | sed 's/".*//') + echo '=== Networks ===' + curl -s http://server:10001/api/v1/network/list \ + -H "Authorization: Bearer $$TOKEN" | head -c 300 + echo '' + echo '=== Members (network/1) ===' + curl -s http://server:10001/api/v1/network/1/members \ + -H "Authorization: Bearer $$TOKEN" | sed 's/,/\n/g' | grep -E 'node_id|ip_address|authorized' + echo '' + echo '=== Online Nodes ===' + NODES=$$(curl -s http://server:10001/api/v1/node/online \ + -H "Authorization: Bearer $$TOKEN") + echo "$$NODES" | sed 's/,/\n/g' | grep -E 'node_id|ip_address|port|name' + echo '' + echo '=== Try pinging virtual IPs ===' + networks: + - zmt + +networks: + zmt: + driver: bridge + ipam: + config: + - subnet: 172.30.0.0/24 diff --git a/sdk/agent.go b/sdk/agent.go index 8bb53ba..74711d4 100644 --- a/sdk/agent.go +++ b/sdk/agent.go @@ -5,6 +5,7 @@ import ( "crypto/cipher" "crypto/sha256" "encoding/binary" + "encoding/hex" "errors" "fmt" "log/slog" @@ -62,6 +63,11 @@ func NewAgent(client *Client) *Agent { return a } +func (a *Agent) SetIdentity(id *Identity) { + a.identity = id + a.localMAC = generateMAC(a.cfg.NetworkID, id.Address) +} + func (a *Agent) Start() error { port := a.cfg.ListenPort if port == 0 { @@ -109,6 +115,104 @@ func (a *Agent) LocalPort() int { return a.udpConn.LocalAddr().(*net.UDPAddr).Port } +func (a *Agent) TAPName() string { + if a.tapDev != nil { + return a.tapDev.Name + } + return "" +} + +func (a *Agent) SetTAPIP(cidr string) error { + if a.tapDev == nil { + return fmt.Errorf("tap not opened") + } + if err := a.tapDev.SetMAC(a.localMAC); err != nil { + a.log.Warn("set tap mac", "error", err) + } + return a.tapDev.SetIP(cidr) +} + +func (a *Agent) ControllerHello() error { + ctrlAddr := &net.UDPAddr{ + IP: a.parseControllerHost(), + Port: 19993, + } + payload := make([]byte, 32) + copy(payload, a.identity.PublicKey) + a.sendPacket(pktHandshake, 0, payload, ctrlAddr) + return nil +} + +func (a *Agent) SyncPeers() error { + nodes, err := a.client.ListOnlineNodes() + if err != nil { + return err + } + myAddr := a.identity.Address.String() + for _, n := range nodes { + if n.NodeID == myAddr || n.PublicKey == "" { + continue + } + pk, err := hex.DecodeString(n.PublicKey) + if err != nil || len(pk) != 32 { + continue + } + ip := net.ParseIP(n.IPAddress) + if ip == nil || n.Port == 0 { + continue + } + peerAddr := AddressFromPublicKey(pk) + udpAddr := &net.UDPAddr{IP: ip, Port: n.Port} + + var pubKeyArr [32]byte + copy(pubKeyArr[:], pk) + existing := a.peers.getByAddr(peerAddr) + if existing != nil && existing.Connected { + continue + } + + peer := a.peers.upsert(peerAddr, pubKeyArr, udpAddr) + peer.Touch() + + sendKey, recvKey := deriveKeys([]byte(a.cfg.PSK), a.identity.PublicKey, pk) + peer.sendCipher, _ = chacha20poly1305.New(sendKey[:]) + peer.recvCipher, _ = chacha20poly1305.New(recvKey[:]) + peer.Connected = true + + a.log.Info("synced peer", "addr", peerAddr, "endpoint", udpAddr) + a.sendHello(peer) + } + return nil +} + +func (a *Agent) PeerCount() int { + return len(a.peers.all()) +} + +func (a *Agent) parseControllerHost() net.IP { + host := a.cfg.ControllerURL + if len(host) > 7 && host[:7] == "http://" { + host = host[7:] + } + if len(host) > 8 && host[:8] == "https://" { + host = host[8:] + } + idx := indexOfByte(host, ':') + if idx >= 0 { + host = host[:idx] + } + return net.ParseIP(host) +} + +func indexOfByte(s string, b byte) int { + for i := 0; i < len(s); i++ { + if s[i] == b { + return i + } + } + return -1 +} + func (a *Agent) udpReadLoop() { defer a.wg.Done() buf := make([]byte, 65535) @@ -162,16 +266,17 @@ func (a *Agent) handleHandshake(payload []byte, addr *net.UDPAddr) { var pubKey [32]byte copy(pubKey[:], payload[:32]) peerAddr := AddressFromPublicKey(pubKey[:]) + existing := a.peers.getByAddr(peerAddr) + if existing != nil && existing.Connected { + existing.Touch() + return + } peer := a.peers.upsert(peerAddr, pubKey, addr) peer.Touch() a.log.Info("handshake from peer", "addr", peerAddr, "endpoint", addr) sendKey, recvKey := deriveKeys([]byte(a.cfg.PSK), a.identity.PublicKey, pubKey[:]) - c, err := chacha20poly1305.New(sendKey[:]) - if err != nil { - return - } - _ = c + var err error peer.sendCipher, err = chacha20poly1305.New(sendKey[:]) if err != nil { return @@ -180,6 +285,7 @@ func (a *Agent) handleHandshake(payload []byte, addr *net.UDPAddr) { if err != nil { return } + peer.Connected = true hello := make([]byte, 32) copy(hello, a.identity.PublicKey) @@ -200,6 +306,7 @@ func (a *Agent) handleData(payload []byte, addr *net.UDPAddr) { binary.BigEndian.PutUint64(nonce[4:], binary.BigEndian.Uint64(payload[:8])) decrypted, err := peer.recvCipher.Open(nil, nonce, payload[8:], nil) if err != nil { + a.log.Warn("decrypt failed", "addr", peer.addr, "endpoint", addr) return } frame := decrypted @@ -207,6 +314,8 @@ func (a *Agent) handleData(payload []byte, addr *net.UDPAddr) { return } + etherType := (uint16(frame[12]) << 8) | uint16(frame[13]) + a.log.Info("handle data", "len", len(frame), "ether", fmt.Sprintf("0x%04x", etherType)) a.macTable.learn(frame[6:12], peer.addr) dstMAC := frame[:6] @@ -214,12 +323,14 @@ func (a *Agent) handleData(payload []byte, addr *net.UDPAddr) { _ = srcMAC if isLocalMAC(dstMAC, a.localMAC[:]) { + a.log.Info("data to tap local") if a.tapDev != nil { a.tapDev.Write(frame) } return } if isBroadcastMAC(dstMAC) { + a.log.Info("data to tap broadcast", "ether", fmt.Sprintf("0x%04x", etherType)) if a.tapDev != nil { a.tapDev.Write(frame) } @@ -229,12 +340,14 @@ func (a *Agent) handleData(payload []byte, addr *net.UDPAddr) { dstAddr := a.macTable.lookup(string(dstMAC)) if dstAddr != nil { + a.log.Info("data forward unicast", "ether", fmt.Sprintf("0x%04x", etherType)) if p := a.peers.getByAddr(*dstAddr); p != nil { a.sendToPeer(frame, p) } else { a.broadcastToPeers(frame, peer.addr) } } else { + a.log.Info("data forward miss", "ether", fmt.Sprintf("0x%04x", etherType)) a.broadcastToPeers(frame, peer.addr) } } @@ -257,21 +370,25 @@ func (a *Agent) tapReadLoop() { if len(frame) < 14 { continue } + etherType := (uint16(frame[12]) << 8) | uint16(frame[13]) a.macTable.learn(frame[6:12], zeroAddr) dstMAC := frame[:6] if isBroadcastMAC(dstMAC) { + a.log.Info("tap broadcast", "len", n, "ether", fmt.Sprintf("0x%04x", etherType)) a.broadcastToPeers(frame, zeroAddr) continue } dstAddr := a.macTable.lookup(string(dstMAC)) if dstAddr != nil { + a.log.Info("tap unicast", "len", n, "ether", fmt.Sprintf("0x%04x", etherType)) if p := a.peers.getByAddr(*dstAddr); p != nil { a.sendToPeer(frame, p) } else { a.broadcastToPeers(frame, zeroAddr) } } else { + a.log.Info("tap miss", "len", n, "ether", fmt.Sprintf("0x%04x", etherType), "dst", fmt.Sprintf("%x", dstMAC)) a.broadcastToPeers(frame, zeroAddr) } } @@ -281,11 +398,16 @@ func (a *Agent) maintenanceLoop() { defer a.wg.Done() ticker := time.NewTicker(maintenanceTick) defer ticker.Stop() + var syncCount int for { select { case <-a.ctx.Done(): return case <-ticker.C: + syncCount++ + if syncCount%3 == 0 { + a.SyncPeers() + } a.peers.cleanDead() a.macTable.clean() for _, p := range a.peers.all() { @@ -320,6 +442,7 @@ func (a *Agent) sendPacket(pktType byte, netID uint32, payload []byte, addr *net func (a *Agent) sendToPeer(frame []byte, p *peer) { if p == nil || p.sendCipher == nil { + a.log.Warn("sendToPeer: nil peer or cipher") return } p.LastSend = time.Now() @@ -330,6 +453,7 @@ func (a *Agent) sendToPeer(frame []byte, p *peer) { header := make([]byte, 8) binary.BigEndian.PutUint64(header, binary.BigEndian.Uint64(nonceBuf[4:])) payload := append(header, encrypted...) + a.log.Info("send data", "to", p.addr, "len", len(payload)) a.sendPacket(pktData, a.cfg.NetworkID, payload, p.Endpoint) } diff --git a/sdk/tap_linux.go b/sdk/tap_linux.go index ae23371..4e4915a 100644 --- a/sdk/tap_linux.go +++ b/sdk/tap_linux.go @@ -4,6 +4,7 @@ package sdk import ( "fmt" + "os/exec" "syscall" "unsafe" ) @@ -35,7 +36,6 @@ func openTap(name string, mtu int) (*tapInterface, error) { } ti := &tapInterface{Name: devName, MTU: mtu, fd: fd} - // Set MTU via socket ioctl s, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, 0) if err == nil { defer syscall.Close(s) @@ -45,7 +45,7 @@ func openTap(name string, mtu int) (*tapInterface, error) { } copy(mtuReq.name[:], []byte(devName)) mtuReq.mtu = int32(mtu) - syscall.Syscall(syscall.SYS_IOCTL, uintptr(s), 0x400c4a52, uintptr(unsafe.Pointer(&mtuReq))) // SIOCSIFMTU + syscall.Syscall(syscall.SYS_IOCTL, uintptr(s), 0x400c4a52, uintptr(unsafe.Pointer(&mtuReq))) } return ti, nil } @@ -63,6 +63,21 @@ func (ti *tapInterface) Close() error { return syscall.Close(ti.fd) } +func (ti *tapInterface) SetMAC(mac [6]byte) error { + macStr := fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]) + return exec.Command("ip", "link", "set", ti.Name, "address", macStr).Run() +} + +func (ti *tapInterface) SetIP(cidr string) error { + if err := exec.Command("ip", "addr", "add", cidr, "dev", ti.Name).Run(); err != nil { + return fmt.Errorf("add ip: %w", err) + } + if err := exec.Command("ip", "link", "set", ti.Name, "up").Run(); err != nil { + return fmt.Errorf("link up: %w", err) + } + return nil +} + func indexOfZero(s string) int { for i, c := range []byte(s) { if c == 0 { diff --git a/sdk/tap_stub.go b/sdk/tap_stub.go index c2d3d94..3743a69 100644 --- a/sdk/tap_stub.go +++ b/sdk/tap_stub.go @@ -24,3 +24,7 @@ func (ti *tapInterface) Write(buf []byte) error { func (ti *tapInterface) Close() error { return nil } + +func (ti *tapInterface) SetIP(cidr string) error { + return fmt.Errorf("TAP device requires Linux") +}