From 57890727c766d67477bde86156a1476c779e3b20 Mon Sep 17 00:00:00 2001 From: v-byte-cpu <65545655+v-byte-cpu@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:17:08 +0400 Subject: [PATCH] feat(macos): add macOS packet handling and release artifacts Add Darwin-specific IP and packet read/write handling so sx can run on macOS with the same VPN and loopback framing logic as Linux. Update CI to test and build on macOS runners, and replace the GoReleaser-based release flow with explicit platform artifacts and GitHub release publishing. --- .github/workflows/ci.yml | 41 +++- .github/workflows/release.yml | 230 +++++++++++++++--- .goreleaser.yml | 36 --- README.md | 8 +- pkg/ip/ip.go | 8 +- pkg/ip/ip_darwin.go | 97 ++++++++ pkg/ip/ip_darwin_test.go | 120 +++++++++ pkg/ip/ip_other.go | 3 +- pkg/packet/afpacket/readwriter_darwin.go | 144 +++++++++++ pkg/packet/afpacket/readwriter_darwin_test.go | 210 ++++++++++++++++ pkg/packet/afpacket/readwriter_other.go | 3 +- 11 files changed, 811 insertions(+), 89 deletions(-) delete mode 100644 .goreleaser.yml create mode 100644 pkg/ip/ip_darwin.go create mode 100644 pkg/ip/ip_darwin_test.go create mode 100644 pkg/packet/afpacket/readwriter_darwin.go create mode 100644 pkg/packet/afpacket/readwriter_darwin_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40b32e3..a12f9e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,9 +34,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - with: - fetch-depth: 100 - fetch-tags: true - name: Cache Go modules and build outputs uses: actions/cache@v5 @@ -63,14 +60,38 @@ jobs: - name: Run Go build run: go build -ldflags "-w -s" - - name: Run GoReleaser snapshot - uses: goreleaser/goreleaser-action@v7 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + go-macos: + name: go (macOS ${{ matrix.goarch }}) + runs-on: ${{ matrix.runner }} + env: + CGO_ENABLED: 1 + GOARCH: ${{ matrix.goarch }} + GOFLAGS: -buildvcs=false + strategy: + fail-fast: false + matrix: + include: + - runner: macos-26-intel + goarch: amd64 + - runner: macos-26 + goarch: arm64 + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 with: - distribution: goreleaser - version: v2.16.0 - args: release --snapshot --clean + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Run Go tests + run: go test ./... -v -cover + + - name: Run Go build + run: go build -ldflags "-w -s" -o "${RUNNER_TEMP}/sx" release-helper: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c828933..78860f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,23 +22,18 @@ concurrency: jobs: release: runs-on: ubuntu-latest - container: golang:1.25-alpine + outputs: + next_tag: ${{ steps.compute_release_version.outputs.next_tag }} + release_name: ${{ steps.compute_release_version.outputs.release_name }} + prerelease: ${{ steps.release_flags.outputs.prerelease }} + make_latest: ${{ steps.release_flags.outputs.make_latest }} env: RELEASE_KIND: ${{ inputs.release_kind }} - CGO_ENABLED: 1 - GOFLAGS: -buildvcs=false - GOMODCACHE: /go/pkg/mod - GOCACHE: /root/.cache/go-build defaults: run: shell: bash steps: - - name: Install system dependencies - shell: sh - run: | - apk add --no-cache bash ca-certificates gcc git libc-dev libpcap-dev linux-headers python3 tar - - name: Checkout repository uses: actions/checkout@v6 with: @@ -46,19 +41,6 @@ jobs: fetch-depth: 100 fetch-tags: true - - name: Cache Go modules and build outputs - uses: actions/cache@v5 - with: - path: | - /go/pkg/mod - /root/.cache/go-build - key: ${{ runner.os }}-go-1.25-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go-1.25- - - - name: Install git-cliff - uses: taiki-e/install-action@git-cliff - - name: Compute release tag id: compute_release_version run: | @@ -96,35 +78,213 @@ jobs: - Commit: \`${commit_sha}\` EOF - - name: Generate release notes - id: release_notes + - name: Determine release flags + id: release_flags env: NEXT_TAG: ${{ steps.compute_release_version.outputs.next_tag }} run: | set -euo pipefail - notes_path="${RUNNER_TEMP}/release-notes.md" + if [[ "${NEXT_TAG}" =~ -rc\.[0-9]+$ ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + echo "make_latest=true" >> "$GITHUB_OUTPUT" + fi + + linux-artifacts: + name: Linux artifact + needs: release + runs-on: ubuntu-latest + container: golang:1.25-alpine + env: + CGO_ENABLED: 1 + GOARCH: amd64 + GOFLAGS: -buildvcs=false + GOMODCACHE: /go/pkg/mod + GOCACHE: /root/.cache/go-build + NEXT_TAG: ${{ needs.release.outputs.next_tag }} + RELEASE_NAME: ${{ needs.release.outputs.release_name }} + defaults: + run: + shell: bash + + steps: + - name: Install system dependencies + shell: sh + run: | + apk add --no-cache bash ca-certificates gcc git libc-dev libpcap-dev linux-headers tar + + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ needs.release.outputs.next_tag }} + fetch-depth: 1 + + - name: Cache Go modules and build outputs + uses: actions/cache@v5 + with: + path: | + /go/pkg/mod + /root/.cache/go-build + key: ${{ runner.os }}-go-1.25-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-1.25- + + - name: Build Linux archive + id: package + shell: bash + run: | + set -euo pipefail + + commit_sha="$(git rev-parse HEAD)" + archive="sx_${RELEASE_NAME}_linux_${GOARCH}.tar.gz" + + mkdir -p dist/linux + go build \ + -trimpath \ + -ldflags "-linkmode external -extldflags '-static' -s -w -X main.version=${RELEASE_NAME} -X main.commit=${commit_sha}" \ + -o dist/linux/sx + + cp README.md LICENSE dist/linux/ + tar -C dist/linux -czf "${archive}" sx README.md LICENSE + + echo "archive=${archive}" >> "$GITHUB_OUTPUT" + + - name: Upload Linux archive + uses: actions/upload-artifact@v7 + with: + name: release-linux + path: ${{ steps.package.outputs.archive }} + retention-days: 1 + if-no-files-found: error + + macos-artifacts: + name: macOS artifacts (${{ matrix.goarch }}) + needs: release + runs-on: ${{ matrix.runner }} + env: + CGO_ENABLED: 1 + GOARCH: ${{ matrix.goarch }} + GOFLAGS: -buildvcs=false + NEXT_TAG: ${{ needs.release.outputs.next_tag }} + RELEASE_NAME: ${{ needs.release.outputs.release_name }} + strategy: + fail-fast: false + matrix: + include: + - runner: macos-26-intel + goarch: amd64 + - runner: macos-26 + goarch: arm64 + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ needs.release.outputs.next_tag }} + fetch-depth: 1 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Build macOS archive + id: package + shell: bash + run: | + set -euo pipefail + + commit_sha="$(git rev-parse HEAD)" + archive="sx_${RELEASE_NAME}_darwin_${GOARCH}.tar.gz" + + mkdir -p dist/macos + go build \ + -trimpath \ + -ldflags "-s -w -X main.version=${RELEASE_NAME} -X main.commit=${commit_sha}" \ + -o dist/macos/sx + + cp README.md LICENSE dist/macos/ + tar -C dist/macos -czf "${archive}" sx README.md LICENSE + + echo "archive=${archive}" >> "$GITHUB_OUTPUT" + + - name: Upload macOS archive + uses: actions/upload-artifact@v7 + with: + name: release-macos-${{ matrix.goarch }} + path: ${{ steps.package.outputs.archive }} + retention-days: 1 + if-no-files-found: error + + publish-release: + name: Publish release + needs: + - release + - linux-artifacts + - macos-artifacts + runs-on: ubuntu-latest + env: + NEXT_TAG: ${{ needs.release.outputs.next_tag }} + RELEASE_NAME: ${{ needs.release.outputs.release_name }} + PRERELEASE: ${{ needs.release.outputs.prerelease }} + MAKE_LATEST: ${{ needs.release.outputs.make_latest }} + NOTES_PATH: ${{ github.workspace }}/release-notes.md + defaults: + run: + shell: bash + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ needs.release.outputs.next_tag }} + fetch-depth: 100 + fetch-tags: true + + - name: Install git-cliff + uses: taiki-e/install-action@git-cliff + + - name: Generate release notes + run: | + set -euo pipefail + ignore_rc_tags='^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' if [[ "${NEXT_TAG}" =~ -rc\.[0-9]+$ ]]; then - git-cliff --current --output "${notes_path}" + git-cliff --current --output "${NOTES_PATH}" else - git-cliff --current --ignore-tags "${ignore_rc_tags}" --output "${notes_path}" + git-cliff --current --ignore-tags "${ignore_rc_tags}" --output "${NOTES_PATH}" fi cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_STEP_SUMMARY" + cat "${NOTES_PATH}" >> "$GITHUB_STEP_SUMMARY" - echo "notes_path=${notes_path}" >> "$GITHUB_OUTPUT" + - name: Download release artifacts + uses: actions/download-artifact@v8 + with: + pattern: release-* + path: release-assets + merge-multiple: true - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 + - name: Publish GitHub Release + uses: softprops/action-gh-release@v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - distribution: goreleaser - version: v2.16.0 - args: release --clean --release-notes "${{ steps.release_notes.outputs.notes_path }}" + tag_name: ${{ env.NEXT_TAG }} + name: ${{ env.RELEASE_NAME }} + body_path: ${{ env.NOTES_PATH }} + prerelease: ${{ env.PRERELEASE }} + make_latest: ${{ env.MAKE_LATEST }} + working_directory: release-assets + draft: true + files: | + *.tar.gz diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index 649b201..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -1,36 +0,0 @@ -version: 2 - -project_name: sx - -gomod: - proxy: true - -builds: - - id: sx - binary: sx - env: - - CGO_ENABLED=1 - goos: - - linux - goarch: - - amd64 - flags: - - -trimpath - ldflags: - - -linkmode external -extldflags '-static' -s -w -X main.version={{ .Version }} -X main.commit={{ .Commit }} - mod_timestamp: "{{ .CommitTimestamp }}" - -archives: - - id: sx - -checksum: - name_template: checksums.txt - algorithm: sha256 - -changelog: - disable: true - -release: - prerelease: auto - mode: replace - name_template: "{{ .Version }}" diff --git a/README.md b/README.md index 64f55c4..ecd3612 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/v-byte-cpu/sx/blob/master/LICENSE) [![Build Status](https://github.com/v-byte-cpu/sx/actions/workflows/ci.yml/badge.svg)](https://github.com/v-byte-cpu/sx/actions/workflows/ci.yml) [![GoReportCard Status](https://goreportcard.com/badge/github.com/v-byte-cpu/sx)](https://goreportcard.com/report/github.com/v-byte-cpu/sx) -![Platform](https://img.shields.io/badge/platform-linux%2Fdocker-blue) +![Platform](https://img.shields.io/badge/platform-linux%2FmacOS%2Fdocker-blue) @@ -54,6 +54,12 @@ Requirements: * [Go 1.25 or newer](https://golang.org/dl/) * [libpcap](https://www.tcpdump.org/) (already installed if you use **wireshark**) +On macOS, install the Xcode Command Line Tools first so CGO can link against the system libpcap: + +``` +xcode-select --install +``` + From the root of the source tree, run: ``` diff --git a/pkg/ip/ip.go b/pkg/ip/ip.go index 09766d2..ff0153a 100644 --- a/pkg/ip/ip.go +++ b/pkg/ip/ip.go @@ -26,10 +26,12 @@ func GetInterfaceIP(iface *net.Interface) (ifaceIP net.IP, err error) { if addrs, err = iface.Addrs(); err != nil || len(addrs) == 0 { return } - if ipnet, ok := addrs[0].(*net.IPNet); ok { - return ipnet.IP, nil + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil { + return ipnet.IP.To4(), nil + } } - return nil, fmt.Errorf("invalid IP address: %v", addrs[0]) + return nil, fmt.Errorf("interface has no IPv4 address: %s", iface.Name) } func GetLocalSubnetInterface(dstSubnet *net.IPNet) (iface *net.Interface, ifaceIP net.IP, err error) { diff --git a/pkg/ip/ip_darwin.go b/pkg/ip/ip_darwin.go new file mode 100644 index 0000000..42de767 --- /dev/null +++ b/pkg/ip/ip_darwin.go @@ -0,0 +1,97 @@ +package ip + +import ( + "net" + "syscall" + + "golang.org/x/net/route" +) + +type defaultRoute struct { + interfaceIndex int + gatewayIP net.IP +} + +func GetDefaultInterface() (iface *net.Interface, ifaceIP net.IP, err error) { + defaultRoute, err := findDefaultRoute(0) + if err != nil || defaultRoute == nil { + return nil, nil, err + } + if iface, err = net.InterfaceByIndex(defaultRoute.interfaceIndex); err != nil { + return nil, nil, err + } + if ifaceIP, err = GetInterfaceIP(iface); err != nil { + return nil, nil, err + } + return iface, ifaceIP, nil +} + +func GetDefaultGatewayIP(iface *net.Interface) (gatewayIP net.IP, err error) { + defaultRoute, err := findDefaultRoute(iface.Index) + if err != nil || defaultRoute == nil { + return nil, err + } + return defaultRoute.gatewayIP, nil +} + +func findDefaultRoute(interfaceIndex int) (*defaultRoute, error) { + rib, err := route.FetchRIB(syscall.AF_INET, route.RIBTypeRoute, 0) + if err != nil { + return nil, err + } + messages, err := route.ParseRIB(route.RIBTypeRoute, rib) + if err != nil { + return nil, err + } + for _, message := range messages { + routeMessage, ok := message.(*route.RouteMessage) + if !ok { + continue + } + defaultRoute, ok := parseDefaultRoute(routeMessage) + if ok && (interfaceIndex == 0 || interfaceIndex == defaultRoute.interfaceIndex) { + return &defaultRoute, nil + } + } + return nil, nil +} + +func parseDefaultRoute(message *route.RouteMessage) (defaultRoute, bool) { + if message.Err != nil || message.Index == 0 || message.Flags&syscall.RTF_GATEWAY == 0 { + return defaultRoute{}, false + } + dst, ok := routeAddr[*route.Inet4Addr](message.Addrs, syscall.RTAX_DST) + if !ok || !isZeroInet4Addr(dst) { + return defaultRoute{}, false + } + netmask, ok := routeAddr[*route.Inet4Addr](message.Addrs, syscall.RTAX_NETMASK) + if ok && !isZeroInet4Addr(netmask) { + return defaultRoute{}, false + } + gateway, ok := routeAddr[*route.Inet4Addr](message.Addrs, syscall.RTAX_GATEWAY) + if !ok || isZeroInet4Addr(gateway) { + return defaultRoute{}, false + } + return defaultRoute{ + interfaceIndex: message.Index, + gatewayIP: net.IP(gateway.IP[:]).To4(), + }, true +} + +func routeAddr[T route.Addr](addrs []route.Addr, index int) (T, bool) { + var zero T + if index >= len(addrs) { + return zero, false + } + addr, ok := addrs[index].(T) + return addr, ok +} + +func isZeroInet4Addr(addr *route.Inet4Addr) bool { + for _, octet := range addr.IP { + if octet != 0 { + return false + } + } + return true +} diff --git a/pkg/ip/ip_darwin_test.go b/pkg/ip/ip_darwin_test.go new file mode 100644 index 0000000..cafd1c6 --- /dev/null +++ b/pkg/ip/ip_darwin_test.go @@ -0,0 +1,120 @@ +package ip + +import ( + "errors" + "net" + "syscall" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/net/route" +) + +func TestParseDefaultRoute(t *testing.T) { + t.Parallel() + addrs := make([]route.Addr, syscall.RTAX_MAX) + addrs[syscall.RTAX_DST] = &route.Inet4Addr{} + addrs[syscall.RTAX_GATEWAY] = &route.Inet4Addr{IP: [4]byte{192, 168, 0, 1}} + addrs[syscall.RTAX_NETMASK] = &route.Inet4Addr{} + message := &route.RouteMessage{ + Flags: syscall.RTF_GATEWAY, + Index: 11, + Addrs: addrs, + } + + result, ok := parseDefaultRoute(message) + + require.True(t, ok) + require.Equal(t, 11, result.interfaceIndex) + require.Equal(t, net.IPv4(192, 168, 0, 1).To4(), result.gatewayIP) +} + +func TestParseDefaultRouteRejectsNonDefaultRoutes(t *testing.T) { + t.Parallel() + tests := []struct { + name string + message *route.RouteMessage + }{ + { + name: "MessageError", + message: &route.RouteMessage{ + Err: errors.New("route error"), + Index: 11, + }, + }, + { + name: "MissingGatewayFlag", + message: &route.RouteMessage{ + Index: 11, + Addrs: defaultRouteAddrs( + &route.Inet4Addr{}, + &route.Inet4Addr{IP: [4]byte{192, 168, 0, 1}}, + &route.Inet4Addr{}, + ), + }, + }, + { + name: "MissingInterfaceIndex", + message: &route.RouteMessage{ + Flags: syscall.RTF_GATEWAY, + Addrs: defaultRouteAddrs( + &route.Inet4Addr{}, + &route.Inet4Addr{IP: [4]byte{192, 168, 0, 1}}, + &route.Inet4Addr{}, + ), + }, + }, + { + name: "NonZeroDestination", + message: &route.RouteMessage{ + Flags: syscall.RTF_GATEWAY, + Index: 11, + Addrs: defaultRouteAddrs( + &route.Inet4Addr{IP: [4]byte{10, 0, 0, 0}}, + &route.Inet4Addr{IP: [4]byte{192, 168, 0, 1}}, + &route.Inet4Addr{}, + ), + }, + }, + { + name: "NonZeroNetmask", + message: &route.RouteMessage{ + Flags: syscall.RTF_GATEWAY, + Index: 11, + Addrs: defaultRouteAddrs( + &route.Inet4Addr{}, + &route.Inet4Addr{IP: [4]byte{192, 168, 0, 1}}, + &route.Inet4Addr{IP: [4]byte{255, 255, 255, 0}}, + ), + }, + }, + { + name: "MissingGateway", + message: &route.RouteMessage{ + Flags: syscall.RTF_GATEWAY, + Index: 11, + Addrs: defaultRouteAddrs( + &route.Inet4Addr{}, + nil, + &route.Inet4Addr{}, + ), + }, + }, + } + for _, vtt := range tests { + tt := vtt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, ok := parseDefaultRoute(tt.message) + require.False(t, ok) + }) + } +} + +func defaultRouteAddrs(dst, gateway, netmask route.Addr) []route.Addr { + addrs := make([]route.Addr, syscall.RTAX_MAX) + addrs[syscall.RTAX_DST] = dst + addrs[syscall.RTAX_GATEWAY] = gateway + addrs[syscall.RTAX_NETMASK] = netmask + return addrs +} diff --git a/pkg/ip/ip_other.go b/pkg/ip/ip_other.go index b4f4744..72f9544 100644 --- a/pkg/ip/ip_other.go +++ b/pkg/ip/ip_other.go @@ -1,5 +1,4 @@ -//go:build !linux -// +build !linux +//go:build !linux && !darwin package ip diff --git a/pkg/packet/afpacket/readwriter_darwin.go b/pkg/packet/afpacket/readwriter_darwin.go new file mode 100644 index 0000000..d80efa7 --- /dev/null +++ b/pkg/packet/afpacket/readwriter_darwin.go @@ -0,0 +1,144 @@ +package afpacket + +import ( + "encoding/binary" + "errors" + "fmt" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/google/gopacket/pcap" + "github.com/v-byte-cpu/sx/pkg/packet" +) + +const ( + defaultSnapLen = 262144 + loopbackLen = 4 +) + +var ( + ErrUnsupportedLinkType = errors.New("unsupported pcap link type") + errShortLoopbackPacket = errors.New("short loopback packet") +) + +type packetHandle interface { + Close() + LinkType() layers.LinkType + ReadPacketData() ([]byte, gopacket.CaptureInfo, error) + SetBPFFilter(string) error + WritePacketData([]byte) error +} + +type packetLinkMode int + +const ( + packetLinkEthernet packetLinkMode = iota + packetLinkRaw + packetLinkNull + packetLinkLoop +) + +type Source struct { + handle packetHandle + mode packetLinkMode +} + +// Assert that Source conforms to the packet.ReadWriter interface. +var _ packet.ReadWriter = (*Source)(nil) + +func NewPacketSource(iface string, vpnMode bool) (*Source, error) { + handle, err := pcap.OpenLive(iface, defaultSnapLen, false, pcap.BlockForever) + if err != nil { + return nil, err + } + if err = handle.SetDirection(pcap.DirectionIn); err != nil { + handle.Close() + return nil, err + } + return newSource(handle, vpnMode) +} + +func newSource(handle packetHandle, vpnMode bool) (*Source, error) { + mode, err := newPacketLinkMode(handle.LinkType(), vpnMode) + if err != nil { + handle.Close() + return nil, err + } + return &Source{handle: handle, mode: mode}, nil +} + +func newPacketLinkMode(linkType layers.LinkType, vpnMode bool) (packetLinkMode, error) { + if !vpnMode { + if linkType == layers.LinkTypeEthernet { + return packetLinkEthernet, nil + } + return 0, fmt.Errorf("%w: %s", ErrUnsupportedLinkType, linkType) + } + + switch linkType { + case layers.LinkTypeRaw, layers.LinkTypeIPv4: + return packetLinkRaw, nil + case layers.LinkTypeNull: + return packetLinkNull, nil + case layers.LinkTypeLoop: + return packetLinkLoop, nil + default: + return 0, fmt.Errorf("%w: %s", ErrUnsupportedLinkType, linkType) + } +} + +func (s *Source) SetBPFFilter(bpfFilter string, _ int) error { + return s.handle.SetBPFFilter(bpfFilter) +} + +func (s *Source) Close() { + s.handle.Close() +} + +func (s *Source) ReadPacketData() ([]byte, *gopacket.CaptureInfo, error) { + data, ci, err := s.handle.ReadPacketData() + if err != nil { + return nil, nil, err + } + data, err = s.decodePacket(data) + if err != nil { + return nil, nil, err + } + ci.CaptureLength = len(data) + ci.Length = len(data) + return data, &ci, nil +} + +func (s *Source) decodePacket(data []byte) ([]byte, error) { + switch s.mode { + case packetLinkNull, packetLinkLoop: + if len(data) < loopbackLen { + return nil, errShortLoopbackPacket + } + return data[loopbackLen:], nil + default: + return data, nil + } +} + +func (s *Source) WritePacketData(pkt []byte) error { + return s.handle.WritePacketData(s.encodePacket(pkt)) +} + +func (s *Source) encodePacket(pkt []byte) []byte { + switch s.mode { + case packetLinkNull: + return appendLoopbackHeader(pkt, binary.LittleEndian) + case packetLinkLoop: + return appendLoopbackHeader(pkt, binary.BigEndian) + default: + return pkt + } +} + +func appendLoopbackHeader(pkt []byte, byteOrder binary.ByteOrder) []byte { + result := make([]byte, loopbackLen+len(pkt)) + byteOrder.PutUint32(result[:loopbackLen], uint32(layers.ProtocolFamilyIPv4)) + copy(result[loopbackLen:], pkt) + return result +} diff --git a/pkg/packet/afpacket/readwriter_darwin_test.go b/pkg/packet/afpacket/readwriter_darwin_test.go new file mode 100644 index 0000000..6167f0d --- /dev/null +++ b/pkg/packet/afpacket/readwriter_darwin_test.go @@ -0,0 +1,210 @@ +package afpacket + +import ( + "errors" + "testing" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/require" +) + +type packetReadResult struct { + data []byte + ci gopacket.CaptureInfo + err error +} + +type fakePacketHandle struct { + linkType layers.LinkType + filter string + reads []packetReadResult + writes [][]byte + closed bool +} + +func (h *fakePacketHandle) Close() { + h.closed = true +} + +func (h *fakePacketHandle) LinkType() layers.LinkType { + return h.linkType +} + +func (h *fakePacketHandle) ReadPacketData() ([]byte, gopacket.CaptureInfo, error) { + if len(h.reads) == 0 { + return nil, gopacket.CaptureInfo{}, errors.New("unexpected read") + } + result := h.reads[0] + h.reads = h.reads[1:] + return result.data, result.ci, result.err +} + +func (h *fakePacketHandle) SetBPFFilter(filter string) error { + h.filter = filter + return nil +} + +func (h *fakePacketHandle) WritePacketData(data []byte) error { + copied := make([]byte, len(data)) + copy(copied, data) + h.writes = append(h.writes, copied) + return nil +} + +func TestDarwinNewSourceRejectsUnsupportedLinkType(t *testing.T) { + t.Parallel() + handle := &fakePacketHandle{linkType: layers.LinkTypePPP} + + _, err := newSource(handle, false) + + require.ErrorIs(t, err, ErrUnsupportedLinkType) + require.True(t, handle.closed) +} + +func TestDarwinSourceSetBPFFilter(t *testing.T) { + t.Parallel() + handle := &fakePacketHandle{linkType: layers.LinkTypeEthernet} + source, err := newSource(handle, false) + require.NoError(t, err) + + err = source.SetBPFFilter("tcp", 1518) + + require.NoError(t, err) + require.Equal(t, "tcp", handle.filter) +} + +func TestDarwinSourceReadPacketData(t *testing.T) { + t.Parallel() + tests := []struct { + name string + linkType layers.LinkType + vpnMode bool + input []byte + expected []byte + }{ + { + name: "Ethernet", + linkType: layers.LinkTypeEthernet, + input: []byte{0x01, 0x02}, + expected: []byte{0x01, 0x02}, + }, + { + name: "Raw", + linkType: layers.LinkTypeRaw, + vpnMode: true, + input: []byte{0x45, 0x00}, + expected: []byte{0x45, 0x00}, + }, + { + name: "IPv4", + linkType: layers.LinkTypeIPv4, + vpnMode: true, + input: []byte{0x45, 0x00}, + expected: []byte{0x45, 0x00}, + }, + { + name: "Null", + linkType: layers.LinkTypeNull, + vpnMode: true, + input: []byte{0x02, 0x00, 0x00, 0x00, 0x45, 0x00}, + expected: []byte{0x45, 0x00}, + }, + { + name: "Loop", + linkType: layers.LinkTypeLoop, + vpnMode: true, + input: []byte{0x00, 0x00, 0x00, 0x02, 0x45, 0x00}, + expected: []byte{0x45, 0x00}, + }, + } + for _, vtt := range tests { + tt := vtt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + handle := &fakePacketHandle{ + linkType: tt.linkType, + reads: []packetReadResult{{ + data: tt.input, + ci: gopacket.CaptureInfo{ + CaptureLength: len(tt.input), + Length: len(tt.input), + }, + }}, + } + source, err := newSource(handle, tt.vpnMode) + require.NoError(t, err) + + data, ci, err := source.ReadPacketData() + + require.NoError(t, err) + require.Equal(t, tt.expected, data) + require.Equal(t, len(tt.expected), ci.CaptureLength) + require.Equal(t, len(tt.expected), ci.Length) + }) + } +} + +func TestDarwinSourceReadShortLoopbackPacketReturnsError(t *testing.T) { + t.Parallel() + handle := &fakePacketHandle{ + linkType: layers.LinkTypeNull, + reads: []packetReadResult{{ + data: []byte{0x02, 0x00, 0x00}, + }}, + } + source, err := newSource(handle, true) + require.NoError(t, err) + + _, _, err = source.ReadPacketData() + + require.ErrorIs(t, err, errShortLoopbackPacket) +} + +func TestDarwinSourceWritePacketData(t *testing.T) { + t.Parallel() + tests := []struct { + name string + linkType layers.LinkType + vpnMode bool + expected []byte + }{ + { + name: "Ethernet", + linkType: layers.LinkTypeEthernet, + expected: []byte{0x45, 0x00}, + }, + { + name: "Raw", + linkType: layers.LinkTypeRaw, + vpnMode: true, + expected: []byte{0x45, 0x00}, + }, + { + name: "Null", + linkType: layers.LinkTypeNull, + vpnMode: true, + expected: []byte{0x02, 0x00, 0x00, 0x00, 0x45, 0x00}, + }, + { + name: "Loop", + linkType: layers.LinkTypeLoop, + vpnMode: true, + expected: []byte{0x00, 0x00, 0x00, 0x02, 0x45, 0x00}, + }, + } + for _, vtt := range tests { + tt := vtt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + handle := &fakePacketHandle{linkType: tt.linkType} + source, err := newSource(handle, tt.vpnMode) + require.NoError(t, err) + + err = source.WritePacketData([]byte{0x45, 0x00}) + + require.NoError(t, err) + require.Equal(t, [][]byte{tt.expected}, handle.writes) + }) + } +} diff --git a/pkg/packet/afpacket/readwriter_other.go b/pkg/packet/afpacket/readwriter_other.go index 9171c6a..f4227fa 100644 --- a/pkg/packet/afpacket/readwriter_other.go +++ b/pkg/packet/afpacket/readwriter_other.go @@ -1,5 +1,4 @@ -//go:build !linux -// +build !linux +//go:build !linux && !darwin package afpacket