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