├── testdata ├── llmnr_request.bin ├── mdns_request.bin ├── dns_reply.bin ├── dns_request.bin ├── llmnr_reply.bin ├── mdns_reply.bin ├── dhcpv6_renew.bin ├── netbios_reply.bin ├── dhcpv6_advertise.bin ├── dhcpv6_request.bin ├── dhcpv6_solicit.bin ├── netbios_request.bin ├── dhcpv6_renew_reply.bin └── dhcpv6_request_reply.bin ├── .gitignore ├── hostinfo ├── testdata │ ├── unified │ │ ├── mac-vendors.txt │ │ ├── linux_proc_net_arp │ │ ├── linux_ip_neighbor │ │ ├── windows_arp │ │ └── windows_netsh_show_neighbors │ ├── linux_proc_net_arp │ ├── linux_ip_neighbor │ ├── windows_arp │ └── windows_netsh_show_neighbors ├── ip_neighbor.go ├── ip_neighbor_test.go ├── netsh_neighbors.go ├── netsh_neighbors_test.go ├── arp_test.go ├── generate_mac_vendors.py ├── arp.go ├── host_info_test.go └── host_info.go ├── go.mod ├── workspace.code-workspace ├── .github └── workflows │ ├── release.yml │ └── check.yml ├── .goreleaser.yaml ├── LICENSE ├── multicast.go ├── router_advertisement.go ├── main.go ├── version.go ├── .golangci.yml ├── dhcpv6_test.go ├── defaults.go ├── local_name_resolution_test.go ├── filter.go ├── README.md ├── log.go ├── dns_test.go ├── go.sum ├── dns.go ├── filter_test.go ├── local_name_resolution.go ├── dhcpv6.go └── cli.go /testdata/llmnr_request.bin: -------------------------------------------------------------------------------- 1 | ߼test -------------------------------------------------------------------------------- /testdata/mdns_request.bin: -------------------------------------------------------------------------------- 1 | testlocal -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pretender 2 | pretender.exe 3 | Vagrantfile 4 | .vagrant 5 | .sshcfg 6 | dist/ 7 | -------------------------------------------------------------------------------- /testdata/dns_reply.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e1abrador/pretender/main/testdata/dns_reply.bin -------------------------------------------------------------------------------- /testdata/dns_request.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e1abrador/pretender/main/testdata/dns_request.bin -------------------------------------------------------------------------------- /testdata/llmnr_reply.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e1abrador/pretender/main/testdata/llmnr_reply.bin -------------------------------------------------------------------------------- /testdata/mdns_reply.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e1abrador/pretender/main/testdata/mdns_reply.bin -------------------------------------------------------------------------------- /testdata/dhcpv6_renew.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e1abrador/pretender/main/testdata/dhcpv6_renew.bin -------------------------------------------------------------------------------- /testdata/netbios_reply.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e1abrador/pretender/main/testdata/netbios_reply.bin -------------------------------------------------------------------------------- /testdata/dhcpv6_advertise.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e1abrador/pretender/main/testdata/dhcpv6_advertise.bin -------------------------------------------------------------------------------- /testdata/dhcpv6_request.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e1abrador/pretender/main/testdata/dhcpv6_request.bin -------------------------------------------------------------------------------- /testdata/dhcpv6_solicit.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e1abrador/pretender/main/testdata/dhcpv6_solicit.bin -------------------------------------------------------------------------------- /testdata/netbios_request.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e1abrador/pretender/main/testdata/netbios_request.bin -------------------------------------------------------------------------------- /testdata/dhcpv6_renew_reply.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e1abrador/pretender/main/testdata/dhcpv6_renew_reply.bin -------------------------------------------------------------------------------- /testdata/dhcpv6_request_reply.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e1abrador/pretender/main/testdata/dhcpv6_request_reply.bin -------------------------------------------------------------------------------- /hostinfo/testdata/unified/mac-vendors.txt: -------------------------------------------------------------------------------- 1 | # comment 2 | 08:00:27 A 3 | 08:00:27:FF specific 4 | 0a:00:27 test 5 | 52:54:00 B Test Comment 6 | ff:ff:ff C 7 | 8 | -------------------------------------------------------------------------------- /hostinfo/testdata/linux_proc_net_arp: -------------------------------------------------------------------------------- 1 | IP address HW type Flags HW address Mask Device 2 | 192.168.56.101 0x1 0x2 08:00:27:56:c2:4c * eth1 3 | 10.0.2.3 0x1 0x2 52:54:00:12:35:03 * eth0 4 | 192.168.56.100 0x1 0x2 0a:00:27:00:00:00 * eth1 5 | 10.0.2.2 0x1 0x2 52:54:00:12:35:02 * eth0 6 | -------------------------------------------------------------------------------- /hostinfo/testdata/linux_ip_neighbor: -------------------------------------------------------------------------------- 1 | 192.168.56.101 dev eth1 lladdr 08:00:27:56:c2:4c STALE 2 | 192.168.56.2 dev eth1 lladdr 08:00:27:c2:e0:27 STALE 3 | 10.0.2.3 dev eth0 lladdr 52:54:00:12:35:03 STALE 4 | 192.168.56.100 dev eth1 lladdr 0a:00:27:00:00:00 STALE 5 | 10.0.2.2 dev eth0 lladdr 52:54:00:12:35:02 REACHABLE 6 | fe80::a00:27ff:fe7e:ca64 dev eth1 lladdr 08:00:27:7e:ca:64 router STALE 7 | fe80::d422:2ab:8bf4:7381 dev eth1 lladdr 08:00:27:56:c2:4c STALE 8 | fe80::800:27ff:fe00:0 dev eth1 lladdr 0a:00:27:00:00:00 STALE 9 | -------------------------------------------------------------------------------- /hostinfo/testdata/unified/linux_proc_net_arp: -------------------------------------------------------------------------------- 1 | IP address HW type Flags HW address Mask Device 2 | 192.168.56.101 0x1 0x2 08:00:27:56:c2:4c * eth1 3 | 192.168.56.2 0x1 0x2 08:00:27:c2:e0:27 * eth1 4 | 10.0.2.3 0x1 0x2 52:54:00:12:35:03 * eth0 5 | 192.168.56.100 0x1 0x2 0a:00:27:00:00:00 * eth1 6 | 10.0.2.2 0x1 0x2 52:54:00:12:35:02 * eth0 7 | -------------------------------------------------------------------------------- /hostinfo/testdata/unified/linux_ip_neighbor: -------------------------------------------------------------------------------- 1 | 192.168.56.101 dev eth1 lladdr 08:00:27:56:c2:4c STALE 2 | 192.168.56.2 dev eth1 lladdr 08:00:27:c2:e0:27 STALE 3 | 10.0.2.3 dev eth0 lladdr 52:54:00:12:35:03 STALE 4 | 192.168.56.100 dev eth1 lladdr 0a:00:27:00:00:00 STALE 5 | 10.0.2.2 dev eth0 lladdr 52:54:00:12:35:02 REACHABLE 6 | fe80::a00:27ff:fe7e:ca64 dev eth1 lladdr 08:00:27:7e:ca:64 router STALE 7 | fe80::d422:2ab:8bf4:7381 dev eth1 lladdr 08:00:27:56:c2:4c STALE 8 | fe80::800:27ff:fe00:0 dev eth1 lladdr 0a:00:27:00:00:00 STALE 9 | -------------------------------------------------------------------------------- /hostinfo/testdata/unified/windows_arp: -------------------------------------------------------------------------------- 1 | 2 | Interface: 10.0.2.15 --- 0xc 3 | Internet Address Physical Address Type 4 | 10.0.2.2 52-54-00-12-35-02 dynamic 5 | 10.0.2.3 52-54-00-12-35-03 dynamic 6 | 7 | Interface: 192.168.56.9 --- 0xd 8 | Internet Address Physical Address Type 9 | 192.168.56.101 08-00-27-56-c2-4c dynamic 10 | 192.168.56.2 08-00-27-c2-e0-27 dynamic 11 | 192.168.56.100 0a-00-27-00-00-00 dynamic 12 | 13 | 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/RedTeamPentesting/pretender 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/insomniacslk/dhcp v0.0.0-20221001123530-5308ebe5334c 7 | github.com/mdlayher/ndp v1.0.0 8 | github.com/miekg/dns v1.1.50 9 | github.com/spf13/pflag v1.0.5 10 | golang.org/x/net v0.1.0 11 | golang.org/x/sync v0.1.0 12 | ) 13 | 14 | require ( 15 | github.com/u-root/uio v0.0.0-20220204230159-dac05f7d2cb4 // indirect 16 | golang.org/x/mod v0.6.0 // indirect 17 | golang.org/x/sys v0.2.0 // indirect 18 | golang.org/x/text v0.4.0 // indirect 19 | golang.org/x/tools v0.2.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /workspace.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "go.useLanguageServer": true, 9 | "go.lintTool": "golangci-lint", 10 | "gopls": { 11 | "gofumpt": true, 12 | }, 13 | "go.formatTool": "gofumports", 14 | "[go]": { 15 | "editor.formatOnSave": true, 16 | "editor.codeActionsOnSave": { 17 | "source.organizeImports": true, 18 | }, 19 | }, 20 | "[go.mod]": { 21 | "editor.formatOnSave": true, 22 | "editor.codeActionsOnSave": { 23 | "source.organizeImports": true, 24 | }, 25 | } 26 | }, 27 | "extensions": { 28 | "recommendations": [ 29 | "golang.go" 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v3 19 | with: 20 | go-version: 1.19.x 21 | 22 | - name: Run GoReleaser 23 | uses: goreleaser/goreleaser-action@v3 24 | with: 25 | distribution: goreleaser 26 | version: latest 27 | args: release --rm-dist 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | 2 | before: 3 | hooks: 4 | - go mod tidy 5 | 6 | builds: 7 | - 8 | ldflags: 9 | - -s -w -X main.version=v{{.Version}} 10 | env: 11 | - CGO_ENABLED=0 12 | targets: 13 | - linux_amd64 14 | - linux_arm64 15 | - linux_arm 16 | - darwin_amd64 17 | - darwin_arm64 18 | - windows_amd64 19 | 20 | archives: 21 | - 22 | replacements: 23 | darwin: macOS 24 | linux: Linux 25 | windows: Windows 26 | amd64: x86_64 27 | format_overrides: 28 | - goos: windows 29 | format: zip 30 | 31 | checksum: 32 | name_template: 'checksums.txt' 33 | 34 | snapshot: 35 | name_template: "{{ incpatch .Version }}-dev" 36 | 37 | changelog: 38 | sort: asc 39 | filters: 40 | exclude: 41 | - '^docs:' 42 | - '^test:' 43 | -------------------------------------------------------------------------------- /hostinfo/testdata/windows_arp: -------------------------------------------------------------------------------- 1 | 2 | Interface: 10.0.2.15 --- 0xc 3 | Internet Address Physical Address Type 4 | 10.0.2.2 52-54-00-12-35-02 dynamic 5 | 10.0.2.3 52-54-00-12-35-03 dynamic 6 | 10.0.2.255 ff-ff-ff-ff-ff-ff static 7 | 224.0.0.22 01-00-5e-00-00-16 static 8 | 224.0.0.251 01-00-5e-00-00-fb static 9 | 224.0.0.252 01-00-5e-00-00-fc static 10 | 239.255.255.250 01-00-5e-7f-ff-fa static 11 | 255.255.255.255 ff-ff-ff-ff-ff-ff static 12 | 13 | Interface: 192.168.56.101 --- 0xd 14 | Internet Address Physical Address Type 15 | 192.168.56.9 08-00-27-7e-ca-64 dynamic 16 | 192.168.56.255 ff-ff-ff-ff-ff-ff static 17 | 224.0.0.22 01-00-5e-00-00-16 static 18 | 224.0.0.251 01-00-5e-00-00-fb static 19 | 239.255.255.250 01-00-5e-7f-ff-fa static 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 RedTeam Pentesting GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /multicast.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "runtime" 7 | 8 | "golang.org/x/net/ipv6" 9 | ) 10 | 11 | const osWindows = "windows" 12 | 13 | // ListenUDPMulticast listens on a multicast group in a way that is supported on 14 | // Unix and Windows for both IPv4 and IPv6. 15 | func ListenUDPMulticast(iface *net.Interface, multicastGroup *net.UDPAddr) (net.PacketConn, error) { 16 | if multicastGroup.IP.To4() != nil { 17 | return net.ListenMulticastUDP("udp", iface, multicastGroup) 18 | } 19 | 20 | if runtime.GOOS != osWindows { 21 | return net.ListenMulticastUDP("udp6", iface, multicastGroup) 22 | } 23 | 24 | listenAddr := &net.UDPAddr{IP: multicastGroup.IP, Port: multicastGroup.Port} 25 | 26 | conn, err := net.ListenPacket("udp6", listenAddr.String()) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | packetConn := ipv6.NewPacketConn(conn) 32 | 33 | err = packetConn.JoinGroup(iface, listenAddr) 34 | if err != nil { 35 | return nil, fmt.Errorf("join multicast group %s: %w", listenAddr.IP, err) 36 | } 37 | 38 | return conn, nil 39 | } 40 | -------------------------------------------------------------------------------- /hostinfo/ip_neighbor.go: -------------------------------------------------------------------------------- 1 | package hostinfo 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "net" 7 | "runtime" 8 | "strings" 9 | ) 10 | 11 | var linuxIPCommandAvailable = commandAvailable("ip", osLinux) 12 | 13 | func getMACFromLinuxIPNeighbor(ip net.IP) net.HardwareAddr { 14 | if !linuxIPCommandAvailable || runtime.GOOS != osLinux { 15 | return nil 16 | } 17 | 18 | if testMode { 19 | return getMacFromLinuxIPNeighborOutput(ip, readFileIfPossible("testdata/unified/linux_ip_neighbor")) 20 | } 21 | 22 | return getMacFromLinuxIPNeighborOutput(ip, readOutput(execTimeoutLinux, "ip", "neighbor")) 23 | } 24 | 25 | func getMacFromLinuxIPNeighborOutput(ip net.IP, ipNeighOutput []byte) net.HardwareAddr { 26 | if ip == nil || ipNeighOutput == nil { 27 | return nil 28 | } 29 | 30 | scanner := bufio.NewScanner(bytes.NewReader(ipNeighOutput)) 31 | for scanner.Scan() { 32 | parts := strings.SplitN(scanner.Text(), " ", 6) //nolint:gomnd 33 | if len(parts) < 6 { //nolint:gomnd 34 | continue 35 | } 36 | 37 | if !ip.Equal(net.ParseIP(parts[0])) { 38 | continue 39 | } 40 | 41 | mac, err := net.ParseMAC(parts[4]) 42 | if err == nil { 43 | return mac 44 | } 45 | } 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /hostinfo/testdata/unified/windows_netsh_show_neighbors: -------------------------------------------------------------------------------- 1 | 2 | Interface 1: Loopback Pseudo-Interface 1 3 | 4 | 5 | Internet Address Physical Address Type 6 | -------------------------------------------- ----------------- ----------- 7 | ff02::c Permanent 8 | ff02::16 Permanent 9 | ff02::1:2 Permanent 10 | 11 | Interface 12: Ethernet 12 | 13 | 14 | Internet Address Physical Address Type 15 | -------------------------------------------- ----------------- ----------- 16 | 17 | 18 | 19 | Interface 13: Ethernet 2 20 | 21 | 22 | Internet Address Physical Address Type 23 | -------------------------------------------- ----------------- ----------- 24 | fe80::a00:27ff:fe7e:ca64 08-00-27-7e-ca-64 Stale 25 | fe80::a00:27ff:fe7e:ca64 08-00-27-7e-ca-64 Stale 26 | fe80::a00:27ff:fe7e:ca64 08-00-27-7e-ca-64 Stale 27 | fe80::d422:2ab:8bf4:7381 08-00-27-56-c2-4c Stale 28 | fe80::800:27ff:fe00:0 0a-00-27-00-00-00 Stale 29 | -------------------------------------------------------------------------------- /hostinfo/ip_neighbor_test.go: -------------------------------------------------------------------------------- 1 | package hostinfo 2 | 3 | import "testing" 4 | 5 | func TestGetMacFromLinuxIPNeighborOutput(t *testing.T) { 6 | testGetMac(t, "testdata/linux_ip_neighbor", getMacFromLinuxIPNeighborOutput, []macIPTestCase{ 7 | {ip: "", mac: ""}, 8 | {ip: "127.0.0.1", mac: ""}, 9 | {ip: "192.168.56.2", mac: "08:00:27:c2:e0:27"}, 10 | {ip: "10.0.2.3", mac: "52:54:00:12:35:03"}, 11 | {ip: "192.168.56.100", mac: "0a:00:27:00:00:00"}, 12 | {ip: "10.0.2.2", mac: "52:54:00:12:35:02"}, 13 | {ip: "fe80::a00:27ff:fe7e:ca64", mac: "08:00:27:7e:ca:64"}, 14 | {ip: "fe80::d422:2ab:8bf4:7381", mac: "08:00:27:56:c2:4c"}, 15 | {ip: "fe80::800:27ff:fe00:0", mac: "0a:00:27:00:00:00"}, 16 | }) 17 | 18 | testGetMac(t, "testdata/unified/linux_ip_neighbor", getMacFromLinuxIPNeighborOutput, []macIPTestCase{ 19 | {ip: "", mac: ""}, 20 | {ip: "127.0.0.1", mac: ""}, 21 | {ip: "192.168.56.2", mac: "08:00:27:c2:e0:27"}, 22 | {ip: "10.0.2.3", mac: "52:54:00:12:35:03"}, 23 | {ip: "192.168.56.100", mac: "0a:00:27:00:00:00"}, 24 | {ip: "10.0.2.2", mac: "52:54:00:12:35:02"}, 25 | {ip: "fe80::a00:27ff:fe7e:ca64", mac: "08:00:27:7e:ca:64"}, 26 | {ip: "fe80::d422:2ab:8bf4:7381", mac: "08:00:27:56:c2:4c"}, 27 | {ip: "fe80::800:27ff:fe00:0", mac: "0a:00:27:00:00:00"}, 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /hostinfo/netsh_neighbors.go: -------------------------------------------------------------------------------- 1 | package hostinfo 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "net" 7 | "runtime" 8 | "strings" 9 | ) 10 | 11 | var windowsNetshCommandAvailable = commandAvailable("netsh", osWindows) 12 | 13 | func getMACFromWindowsNetshShowNeighbors(ip net.IP) net.HardwareAddr { 14 | if !windowsNetshCommandAvailable || runtime.GOOS != osWindows { 15 | return nil 16 | } 17 | 18 | if testMode { 19 | return getMacFromLinuxIPNeighborOutput(ip, readFileIfPossible("testdata/unified/windows_netsh_show_neighbors")) 20 | } 21 | 22 | return getMACFromWindowsNetshShowNeighborsOutput(ip, readOutput(execTimeoutWindows, 23 | "netsh", "interface", "ipv6", "show", "neighbors")) 24 | } 25 | 26 | func getMACFromWindowsNetshShowNeighborsOutput(ip net.IP, netshOutput []byte) net.HardwareAddr { 27 | if ip == nil || netshOutput == nil { 28 | return nil 29 | } 30 | 31 | scanner := bufio.NewScanner(bytes.NewReader(netshOutput)) 32 | for scanner.Scan() { 33 | parts := strings.Fields(scanner.Text()) 34 | if len(parts) < 3 { //nolint:gomnd 35 | continue 36 | } 37 | 38 | if !ip.Equal(net.ParseIP(parts[0])) { 39 | continue 40 | } 41 | 42 | if parts[2] == "Unreachable" { 43 | continue 44 | } 45 | 46 | mac, err := net.ParseMAC(parts[1]) 47 | if err == nil { 48 | return mac 49 | } 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /hostinfo/netsh_neighbors_test.go: -------------------------------------------------------------------------------- 1 | package hostinfo 2 | 3 | import "testing" 4 | 5 | func TestGetMacFromWindowsNetshShowNeighborsOutput(t *testing.T) { 6 | testGetMac(t, "testdata/windows_netsh_show_neighbors", getMACFromWindowsNetshShowNeighborsOutput, []macIPTestCase{ 7 | {ip: "", mac: ""}, 8 | {ip: "127.0.0.1", mac: ""}, 9 | {ip: "ff02::1", mac: "33-33-00-00-00-01"}, 10 | {ip: "ff02::2", mac: "33-33-00-00-00-02"}, 11 | {ip: "ff02::c", mac: "33-33-00-00-00-0c"}, 12 | {ip: "ff02::16", mac: "33-33-00-00-00-16"}, 13 | {ip: "ff02::fb", mac: "33-33-00-00-00-fb"}, 14 | {ip: "ff02::1:2", mac: "33-33-00-01-00-02"}, 15 | {ip: "ff02::1:3", mac: "33-33-00-01-00-03"}, 16 | {ip: "ff02::1:ff49:f89a", mac: "33-33-ff-49-f8-9a"}, 17 | {ip: "fe80::a00:27ff:fe7e:ca64", mac: "08-00-27-7e-ca-64"}, 18 | {ip: "ff02::1:ff04:792b", mac: "33-33-ff-04-79-2b"}, 19 | {ip: "ff02::1:ff7e:ca64", mac: "33-33-ff-7e-ca-64"}, 20 | {ip: "ff02::1:fff4:7381", mac: "33-33-ff-f4-73-81"}, 21 | }) 22 | 23 | testGetMac(t, "testdata/unified/windows_netsh_show_neighbors", 24 | getMACFromWindowsNetshShowNeighborsOutput, []macIPTestCase{ 25 | {ip: "", mac: ""}, 26 | {ip: "127.0.0.1", mac: ""}, 27 | {ip: "fe80::a00:27ff:fe7e:ca64", mac: "08-00-27-7e-ca-64"}, 28 | {ip: "fe80::a00:27ff:fe7e:ca64", mac: "08-00-27-7e-ca-64"}, 29 | {ip: "fe80::a00:27ff:fe7e:ca64", mac: "08-00-27-7e-ca-64"}, 30 | {ip: "fe80::d422:2ab:8bf4:7381", mac: "08-00-27-56-c2-4c"}, 31 | {ip: "fe80::800:27ff:fe00:0", mac: "0a-00-27-00-00-00"}, 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | on: 3 | # run tests on push to main, but not when other branches are pushed to 4 | push: 5 | branches: 6 | - main 7 | 8 | # run tests for all pull requests 9 | pull_request: 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Set up Go 17 | uses: actions/setup-go@v3 18 | with: 19 | go-version: 1.19.x 20 | id: go 21 | 22 | - name: Checkout code 23 | uses: actions/checkout@v3 24 | 25 | - name: golangci-lint 26 | uses: golangci/golangci-lint-action@v3 27 | with: 28 | version: v1.50 29 | args: --verbose --timeout 5m 30 | 31 | - name: Check go.mod/go.sum 32 | run: | 33 | echo "check if go.mod and go.sum are up to date" 34 | go mod tidy 35 | git diff --exit-code go.mod go.sum 36 | 37 | test: 38 | name: Test 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Set up Go 42 | uses: actions/setup-go@v3 43 | with: 44 | go-version: 1.19.x 45 | id: go 46 | 47 | - name: Checkout code 48 | uses: actions/checkout@v3 49 | 50 | - name: Run tests 51 | run: | 52 | go test ./... 53 | 54 | build: 55 | strategy: 56 | matrix: 57 | go-version: 58 | - 1.19.x 59 | runs-on: ubuntu-latest 60 | name: Build with Go ${{ matrix.go-version }} 61 | env: 62 | GOPROXY: https://proxy.golang.org 63 | 64 | steps: 65 | - name: Set up Go ${{ matrix.go-version }} 66 | uses: actions/setup-go@v3 67 | with: 68 | go-version: ${{ matrix.go-version }} 69 | id: go 70 | 71 | - name: Checkout code 72 | uses: actions/checkout@v3 73 | 74 | - name: Build 75 | run: | 76 | GOOS=linux GOARCH=amd64 go build -o pretender_linux_amd64 77 | GOOS=linux GOARCH=arm64 go build -o pretender_linux_arm64 78 | GOOS=windows GOARCH=amd64 go build -o pretender_windows 79 | GOOS=darwin GOARCH=amd64 go build -o pretender_macos_amd64 80 | GOOS=darwin GOARCH=arm64 go build -o pretender_linux_arm64 81 | -------------------------------------------------------------------------------- /hostinfo/testdata/windows_netsh_show_neighbors: -------------------------------------------------------------------------------- 1 | 2 | Interface 1: Loopback Pseudo-Interface 1 3 | 4 | 5 | Internet Address Physical Address Type 6 | -------------------------------------------- ----------------- ----------- 7 | ff02::c Permanent 8 | ff02::16 Permanent 9 | ff02::1:2 Permanent 10 | 11 | Interface 12: Ethernet 12 | 13 | 14 | Internet Address Physical Address Type 15 | -------------------------------------------- ----------------- ----------- 16 | ff02::1 33-33-00-00-00-01 Permanent 17 | ff02::2 33-33-00-00-00-02 Permanent 18 | ff02::c 33-33-00-00-00-0c Permanent 19 | ff02::16 33-33-00-00-00-16 Permanent 20 | ff02::fb 33-33-00-00-00-fb Permanent 21 | ff02::1:2 33-33-00-01-00-02 Permanent 22 | ff02::1:3 33-33-00-01-00-03 Permanent 23 | ff02::1:ff49:f89a 33-33-ff-49-f8-9a Permanent 24 | 25 | Interface 13: Ethernet 2 26 | 27 | 28 | Internet Address Physical Address Type 29 | -------------------------------------------- ----------------- ----------- 30 | fe80::a00:27ff:fe7e:ca64 08-00-27-7e-ca-64 Stale 31 | ff02::1 33-33-00-00-00-01 Permanent 32 | ff02::2 33-33-00-00-00-02 Permanent 33 | ff02::c 33-33-00-00-00-0c Permanent 34 | ff02::16 33-33-00-00-00-16 Permanent 35 | ff02::fb 33-33-00-00-00-fb Permanent 36 | ff02::1:2 33-33-00-01-00-02 Permanent 37 | ff02::1:3 33-33-00-01-00-03 Permanent 38 | ff02::1:ff04:792b 33-33-ff-04-79-2b Permanent 39 | ff02::1:ff7e:ca64 33-33-ff-7e-ca-64 Permanent 40 | ff02::1:fff4:7381 33-33-ff-f4-73-81 Permanent 41 | 42 | -------------------------------------------------------------------------------- /router_advertisement.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/netip" 8 | "time" 9 | 10 | "github.com/mdlayher/ndp" 11 | ) 12 | 13 | const ( 14 | raHopLimit = 0 15 | raDefaultRouterLifetime = 180 * time.Second 16 | raDelay = 500 * time.Millisecond 17 | raDefaultPeriod = 3 * time.Minute 18 | ) 19 | 20 | var ( 21 | ipv6LinkLocalAllRouters = netip.MustParseAddr(net.IPv6linklocalallrouters.String()) 22 | ipv6LinkLocalAllNodes = netip.MustParseAddr(net.IPv6linklocalallnodes.String()) 23 | ) 24 | 25 | // SendPeriodicRouterAdvertisements sends periodic router advertisement messages. 26 | func SendPeriodicRouterAdvertisements(ctx context.Context, logger *Logger, config Config) error { 27 | iface, err := net.InterfaceByName(config.Interface.Name) 28 | if err != nil { 29 | return fmt.Errorf("selecting interface %q: %w", config.Interface.Name, err) 30 | } 31 | 32 | conn, _, err := ndp.Listen(config.Interface, ndp.LinkLocal) 33 | if err != nil { 34 | return fmt.Errorf("dialing (%s): %w", config.Interface.Name, err) 35 | } 36 | 37 | defer func() { _ = conn.Close() }() 38 | 39 | err = conn.JoinGroup(ipv6LinkLocalAllRouters) 40 | if err != nil { 41 | return fmt.Errorf("joining multicast group: %w", err) 42 | } 43 | 44 | time.Sleep(raDelay) // time for DHCPv6 server to start 45 | 46 | for { 47 | logger.Infof("sending router advertisement on %s", iface.Name) 48 | 49 | err := sendRouterAdvertisement(conn, iface.HardwareAddr, config.RouterLifetime) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | timer := time.NewTimer(config.RAPeriod) 55 | 56 | select { 57 | case <-ctx.Done(): 58 | if !timer.Stop() { 59 | <-timer.C 60 | } 61 | 62 | if config.RouterLifetime > 0 { 63 | logger.Infof("sending router de-advertisement on %s", iface.Name) 64 | 65 | return sendRouterAdvertisement(conn, iface.HardwareAddr, 0) 66 | } 67 | 68 | return nil 69 | case <-timer.C: 70 | continue 71 | } 72 | } 73 | } 74 | 75 | func sendRouterAdvertisement(c *ndp.Conn, routerMAC net.HardwareAddr, routerLifetime time.Duration) error { 76 | m := &ndp.RouterAdvertisement{ 77 | CurrentHopLimit: raHopLimit, 78 | ManagedConfiguration: true, 79 | OtherConfiguration: true, 80 | 81 | RouterSelectionPreference: ndp.High, 82 | RouterLifetime: routerLifetime, 83 | Options: []ndp.Option{ 84 | &ndp.LinkLayerAddress{ 85 | Direction: ndp.Source, 86 | Addr: routerMAC, 87 | }, 88 | }, 89 | } 90 | 91 | err := c.WriteTo(m, nil, ipv6LinkLocalAllNodes) 92 | if err != nil { 93 | return fmt.Errorf("sending router advertisement: %w", err) 94 | } 95 | 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /**************************************** 2 | * * 3 | * RedTeam Pentesting GmbH * 4 | * kontakt@redteam-pentesting.de * 5 | * https://www.redteam-pentesting.de/ * 6 | * * 7 | ****************************************/ 8 | package main 9 | 10 | import ( 11 | "context" 12 | "errors" 13 | "os" 14 | "os/signal" 15 | "sync" 16 | "syscall" 17 | ) 18 | 19 | func main() { 20 | config, logger, err := configFromCLI() 21 | if err != nil { 22 | logger.Errorf("Error: " + err.Error()) 23 | logger.Flush() 24 | 25 | if errors.As(err, &interfaceError{}) { 26 | logger.Errorf("Try specifying one of the following interfaces:") 27 | logger.Flush() 28 | 29 | _ = listInterfaces(stdErr, config.NoColor) 30 | } 31 | 32 | logger.Close() 33 | 34 | os.Exit(1) 35 | } 36 | 37 | runListeners(config, logger) 38 | 39 | logger.Close() 40 | } 41 | 42 | func runListeners(config Config, logger *Logger) { //nolint:cyclop 43 | ctx, cancel := signal.NotifyContext(context.Background(), 44 | os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGABRT) 45 | defer cancel() 46 | 47 | if config.StopAfter > 0 { 48 | ctx, cancel = context.WithTimeout(ctx, config.StopAfter) 49 | defer cancel() 50 | } 51 | 52 | wg := newServiceWaitGroup(ctx) 53 | 54 | if !config.NoNetBIOS && !config.NoLocalNameResolution { 55 | wg.Run(RunNetBIOSResponder, logger.WithPrefix("NetBIOS"), config) 56 | } 57 | 58 | if !config.NoLLMNR && !config.NoLocalNameResolution { 59 | wg.Run(RunLLMNRResponder, logger.WithPrefix("LLMNR"), config) 60 | } 61 | 62 | if !config.NoMDNS && !config.NoLocalNameResolution { 63 | wg.Run(RunMDNSResponder, logger.WithPrefix("mDNS"), config) 64 | } 65 | 66 | if !config.NoDHCPv6DNSTakeover && !config.NoDNS { 67 | wg.Run(RunDNSResponder, logger.WithPrefix("DNS"), config) 68 | } 69 | 70 | if !config.NoDHCPv6DNSTakeover && !config.NoDHCPv6 { 71 | wg.Run(RunDHCPv6Server, logger.WithPrefix("DHCPv6"), config) 72 | } 73 | 74 | if !config.NoRA && !config.NoDHCPv6DNSTakeover && !config.NoDHCPv6 { 75 | wg.Run(SendPeriodicRouterAdvertisements, logger.WithPrefix("RA"), config) 76 | } 77 | 78 | wg.Wait() 79 | } 80 | 81 | type serviceFunc func(context.Context, *Logger, Config) error 82 | 83 | type serviceWaitGroup struct { 84 | ctx context.Context //nolint:containedctx 85 | sync.WaitGroup 86 | } 87 | 88 | func newServiceWaitGroup(ctx context.Context) *serviceWaitGroup { 89 | return &serviceWaitGroup{ctx: ctx} 90 | } 91 | 92 | func (wg *serviceWaitGroup) Run(service serviceFunc, logger *Logger, config Config) { 93 | wg.WaitGroup.Add(1) 94 | 95 | go func() { 96 | err := service(wg.ctx, logger, config) 97 | if err != nil { 98 | logger.Errorf(escapeFormatString(err.Error())) 99 | } else if wg.ctx.Err() != nil { 100 | logger.Debugf("shutdown") 101 | } 102 | 103 | wg.WaitGroup.Done() 104 | }() 105 | } 106 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "runtime/debug" 6 | "time" 7 | ) 8 | 9 | const ( 10 | banner = "Pretender by RedTeam Pentesting" 11 | shortCommitSize = 10 12 | ) 13 | 14 | var version = "" // this variable can be set during compilation 15 | 16 | func buildSettingReader() func(string) (string, bool) { 17 | buildInfo, ok := debug.ReadBuildInfo() 18 | if !ok { 19 | return func(string) (string, bool) { return "", false } 20 | } 21 | 22 | return func(key string) (string, bool) { 23 | for _, setting := range buildInfo.Settings { 24 | if setting.Key == key { 25 | return setting.Value, true 26 | } 27 | } 28 | 29 | return "", false 30 | } 31 | } 32 | 33 | func shortVersion() string { 34 | fallback := version 35 | if fallback == "" { 36 | fallback = "(unknown version)" 37 | } 38 | 39 | readBuildSetting := buildSettingReader() 40 | 41 | commit, ok := readBuildSetting("vcs.revision") 42 | if !ok { 43 | return fmt.Sprintf("%s %s", banner, fallback) 44 | } 45 | 46 | if len(commit) > shortCommitSize { 47 | commit = commit[:shortCommitSize] 48 | } 49 | 50 | dirty, _ := readBuildSetting("vcs.modified") 51 | 52 | if version == "" { 53 | vcs, ok := readBuildSetting("vcs") 54 | if ok { 55 | vcs = " " + vcs 56 | } 57 | 58 | dirtySuffix := "" 59 | if dirty == "true" { 60 | dirtySuffix = " (dirty)" 61 | } 62 | 63 | return fmt.Sprintf("%s built from%s commit %s%s", banner, vcs, commit, dirtySuffix) 64 | } 65 | 66 | if dirty == "true" { 67 | return fmt.Sprintf("%s %s", banner, version) 68 | } 69 | 70 | return fmt.Sprintf("%s %s-%s", banner, version, commit) 71 | } 72 | 73 | func longVersion() string { //nolint:cyclop 74 | fallback := version 75 | if fallback == "" { 76 | fallback = "(unknown version)" 77 | } 78 | 79 | banner := banner 80 | if version != "" { 81 | banner = fmt.Sprintf("%s %s", banner, version) 82 | } 83 | 84 | readBuildSetting := buildSettingReader() 85 | 86 | version = "built" 87 | 88 | cgo, ok := readBuildSetting("CGO_ENABLED") 89 | if ok { 90 | if cgo == "1" { 91 | version += " with CGO" 92 | } else { 93 | version += " without CGO" 94 | } 95 | } 96 | 97 | vcs, ok := readBuildSetting("vcs") 98 | if !ok { 99 | return fmt.Sprintf("%s %s", banner, fallback) 100 | } 101 | 102 | version = fmt.Sprintf("%s from %s", version, vcs) 103 | 104 | commit, ok := readBuildSetting("vcs.revision") 105 | if !ok { 106 | return fmt.Sprintf("%s %s", banner, fallback) 107 | } 108 | 109 | version = fmt.Sprintf("%s commit %s", version, commit) 110 | 111 | timeStamp, ok := readBuildSetting("vcs.time") 112 | if ok { 113 | t, err := time.Parse(time.RFC3339, timeStamp) 114 | if err == nil { 115 | version = fmt.Sprintf("%s#%v", version, t.Format("2006-01-02")) 116 | } 117 | } 118 | 119 | dirty, ok := readBuildSetting("vcs.modified") 120 | if ok && dirty == "true" { 121 | version = fmt.Sprintf("%s (dirty)", version) 122 | } 123 | 124 | return fmt.Sprintf("%s %s", banner, version) 125 | } 126 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - asciicheck 4 | - bidichk 5 | - bodyclose 6 | - containedctx 7 | - cyclop 8 | - decorder 9 | - dogsled 10 | - dupl 11 | - durationcheck 12 | - errcheck 13 | - errchkjson 14 | - errname 15 | - errorlint 16 | - exhaustive 17 | - exportloopref 18 | - forbidigo 19 | - forcetypeassert 20 | - gci 21 | - gochecknoinits 22 | - goconst 23 | - gocritic 24 | - gocyclo 25 | - godot 26 | - godox 27 | - gofmt 28 | - gofumpt 29 | - goheader 30 | - goimports 31 | - gomnd 32 | - gomodguard 33 | - goprintffuncname 34 | - gosec 35 | - gosimple 36 | - govet 37 | - grouper 38 | - importas 39 | - ineffassign 40 | - ireturn 41 | - lll 42 | - maintidx 43 | - makezero 44 | - misspell 45 | - nakedret 46 | - nestif 47 | - nilerr 48 | - nilnil 49 | - nlreturn 50 | - noctx 51 | - nolintlint 52 | - nosprintfhostport 53 | - prealloc 54 | - predeclared 55 | - promlinter 56 | - revive 57 | - sqlclosecheck 58 | - staticcheck 59 | - stylecheck 60 | - tagliatelle 61 | - tenv 62 | - thelper 63 | - tparallel 64 | - typecheck 65 | - unconvert 66 | - unparam 67 | - unused 68 | - wastedassign 69 | - whitespace 70 | - wsl 71 | disable: 72 | - golint # deprecated 73 | - interfacer # deprecated 74 | - scopelint # deprecated 75 | - structcheck # deprecated 76 | - deadcode # deprecated 77 | - varcheck # deprecated 78 | - gocognit # detects complex functions 79 | - maligned # checks if structs can be reordered for more efficient packing 80 | - rowserrcheck # checks if errors in DB queries are checked 81 | - funlen # detects long functions 82 | - depguard # checks imports against an allow-list 83 | - goerr113 # forbids dynamic errors like ad-hoc wrapping with fmt.Errorf 84 | - exhaustivestruct # requires struct initializations to contain all fields 85 | - testpackage # requires tests to be in a separate package 86 | - gochecknoglobals # forbids global variables 87 | - wrapcheck # requires errors from external packages to be wrapped 88 | - paralleltest # requires all test cases to run t.Parallel() 89 | - ifshort # requires expressions to be pulled into if statements if the result is only used there 90 | - gomoddirectives # forbids replacements in go.mod 91 | - varnamelen # forbids short variable names in longer scopes 92 | - contextcheck # checks if functions use a non-inherited context 93 | - execinquery # checks SQL queries 94 | - exhaustruct # checks if all structure fields are initialized 95 | - nonamedreturns # forbids named returns 96 | linters-settings: 97 | godox: 98 | keywords: 99 | - FIXME # FIXME generates a linter warning 100 | goconst: 101 | min-occurrences: 5 102 | tagliatelle: 103 | # check the struck tag name case 104 | case: 105 | rules: 106 | json: snake 107 | yaml: snake 108 | exhaustive: 109 | default-signifies-exhaustive: true 110 | gomnd: 111 | settings: 112 | mnd: 113 | ignored-numbers: 0o400,0o600,0o660,0o640,0o644,0o700,0o750 114 | ignored-functions: os.WriteFile,os.MkdirAll 115 | issues: 116 | exclude-use-default: false 117 | -------------------------------------------------------------------------------- /hostinfo/arp_test.go: -------------------------------------------------------------------------------- 1 | package hostinfo 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | type macIPTestCase struct { 8 | ip string 9 | mac string 10 | } 11 | 12 | func TestGetMACFromLinuxArpContent(t *testing.T) { 13 | testGetMac(t, "testdata/linux_proc_net_arp", getMACFromLinuxARPContent, []macIPTestCase{ 14 | {ip: "", mac: ""}, 15 | {ip: "127.0.0.1", mac: ""}, 16 | {ip: "192.168.56.101", mac: "08:00:27:56:c2:4c"}, 17 | {ip: "10.0.2.3", mac: "52:54:00:12:35:03"}, 18 | {ip: "192.168.56.100", mac: "0a:00:27:00:00:00"}, 19 | {ip: "10.0.2.2", mac: "52:54:00:12:35:02"}, 20 | }) 21 | 22 | testGetMac(t, "testdata/unified/linux_proc_net_arp", getMACFromLinuxARPContent, []macIPTestCase{ 23 | {ip: "", mac: ""}, 24 | {ip: "127.0.0.1", mac: ""}, 25 | {ip: "192.168.56.101", mac: "08:00:27:56:c2:4c"}, 26 | {ip: "192.168.56.2", mac: "08:00:27:c2:e0:27"}, 27 | {ip: "10.0.2.3", mac: "52:54:00:12:35:03"}, 28 | {ip: "192.168.56.100", mac: "0a:00:27:00:00:00"}, 29 | {ip: "10.0.2.2", mac: "52:54:00:12:35:02"}, 30 | }) 31 | } 32 | 33 | func TestGetIPFromLinuxARPContent(t *testing.T) { 34 | testGetIP(t, "testdata/linux_proc_net_arp", getIPFromLinuxARPContent, []macIPTestCase{ 35 | {ip: "", mac: ""}, 36 | {ip: "", mac: "01:01:01:01:01:01"}, 37 | {ip: "192.168.56.101", mac: "08:00:27:56:c2:4c"}, 38 | {ip: "10.0.2.3", mac: "52:54:00:12:35:03"}, 39 | {ip: "192.168.56.100", mac: "0a:00:27:00:00:00"}, 40 | {ip: "10.0.2.2", mac: "52:54:00:12:35:02"}, 41 | }) 42 | 43 | testGetIP(t, "testdata/unified/linux_proc_net_arp", getIPFromLinuxARPContent, []macIPTestCase{ 44 | {ip: "", mac: ""}, 45 | {ip: "", mac: "01:01:01:01:01:01"}, 46 | {ip: "192.168.56.101", mac: "08:00:27:56:c2:4c"}, 47 | {ip: "192.168.56.2", mac: "08:00:27:c2:e0:27"}, 48 | {ip: "10.0.2.3", mac: "52:54:00:12:35:03"}, 49 | {ip: "192.168.56.100", mac: "0a:00:27:00:00:00"}, 50 | {ip: "10.0.2.2", mac: "52:54:00:12:35:02"}, 51 | }) 52 | } 53 | 54 | func TestGetMACFromWindowsARPOutput(t *testing.T) { 55 | testGetMac(t, "testdata/windows_arp", getMACFromWindowsARPOutput, []macIPTestCase{ 56 | {ip: "", mac: ""}, 57 | {ip: "127.0.0.1", mac: ""}, 58 | {ip: "10.0.2.15", mac: ""}, 59 | {ip: "192.168.56.101", mac: ""}, 60 | {ip: "10.0.2.2", mac: "52:54:00:12:35:02"}, 61 | {ip: "10.0.2.255", mac: "ff:ff:ff:ff:ff:ff"}, 62 | {ip: "192.168.56.9", mac: "08:00:27:7e:ca:64"}, 63 | {ip: "239.255.255.250", mac: "01:00:5e:7f:ff:fa"}, 64 | }) 65 | 66 | testGetMac(t, "testdata/unified/windows_arp", getMACFromWindowsARPOutput, []macIPTestCase{ 67 | {ip: "", mac: ""}, 68 | {ip: "127.0.0.1", mac: ""}, 69 | {ip: "10.0.2.15", mac: ""}, 70 | {ip: "192.168.56.9", mac: ""}, 71 | {ip: "10.0.2.2", mac: "52:54:00:12:35:02"}, 72 | {ip: "10.0.2.3", mac: "52:54:00:12:35:03"}, 73 | {ip: "192.168.56.101", mac: "08:00:27:56:c2:4c"}, 74 | {ip: "192.168.56.2", mac: "08:00:27:c2:e0:27"}, 75 | {ip: "192.168.56.100", mac: "0a:00:27:00:00:00"}, 76 | }) 77 | } 78 | 79 | func TestGetIPFromWindowsARPOutput(t *testing.T) { 80 | testGetIP(t, "testdata/windows_arp", getIPFromWindowsARPOutput, []macIPTestCase{ 81 | {ip: "", mac: ""}, 82 | {ip: "", mac: "01:01:01:01:01:01"}, 83 | {ip: "10.0.2.2", mac: "52:54:00:12:35:02"}, 84 | {ip: "10.0.2.255", mac: "ff:ff:ff:ff:ff:ff"}, 85 | {ip: "192.168.56.9", mac: "08:00:27:7e:ca:64"}, 86 | {ip: "239.255.255.250", mac: "01:00:5e:7f:ff:fa"}, 87 | }) 88 | 89 | testGetIP(t, "testdata/unified/windows_arp", getIPFromWindowsARPOutput, []macIPTestCase{ 90 | {ip: "", mac: ""}, 91 | {ip: "", mac: "01:01:01:01:01:01"}, 92 | {ip: "10.0.2.2", mac: "52:54:00:12:35:02"}, 93 | {ip: "10.0.2.3", mac: "52:54:00:12:35:03"}, 94 | {ip: "192.168.56.101", mac: "08:00:27:56:c2:4c"}, 95 | {ip: "192.168.56.2", mac: "08:00:27:c2:e0:27"}, 96 | {ip: "192.168.56.100", mac: "0a:00:27:00:00:00"}, 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /hostinfo/generate_mac_vendors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import csv 4 | from dataclasses import dataclass 5 | import datetime 6 | import os.path 7 | import re 8 | import sys 9 | from typing import Iterator, Sequence 10 | 11 | import requests 12 | 13 | 14 | OUTPUT_FILE = "mac-vendors.txt" 15 | 16 | SOURCES = [ 17 | "https://standards-oui.ieee.org/cid/cid.csv", 18 | "https://standards-oui.ieee.org/iab/iab.csv", 19 | "https://standards-oui.ieee.org/oui/oui.csv", 20 | "https://standards-oui.ieee.org/oui28/mam.csv", 21 | "https://standards-oui.ieee.org/oui36/oui36.csv", 22 | ] 23 | 24 | MAC_PREFIX_FIELD = 1 25 | ORG_FIELD = 2 26 | 27 | MAX_ORG_SIZE = 12 28 | IGNORED_TERMS = [ 29 | "gmbh", 30 | "inc", 31 | "llc", 32 | "a/s", 33 | "ag", 34 | "s.r.l", 35 | "ltd", 36 | "sa", 37 | "s.l", 38 | "co", 39 | "trading", 40 | "limited", 41 | "incorporated", 42 | "corporate", 43 | "corporation", 44 | "technologies", 45 | "technology", 46 | ] 47 | 48 | 49 | @dataclass 50 | class MacVendorEntry: 51 | mac_prefix: str 52 | vendor_short: str 53 | vendor: str 54 | 55 | @property 56 | def csv_line(self) -> tuple[str, str, str]: 57 | return (self.mac_prefix, self.vendor_short, self.vendor) 58 | 59 | 60 | def all_mac_vendor_entries(urls: Sequence[str]) -> Iterator[MacVendorEntry]: 61 | for url in urls: 62 | print(f"Downloading {url}", file=sys.stderr) 63 | csv_data = download(url) 64 | 65 | print(" Processing...", file=sys.stderr) 66 | yield from mac_vendor_entries_from_csv(csv_data) 67 | 68 | 69 | def mac_vendor_entries_from_csv(csv_content: str) -> Iterator[MacVendorEntry]: 70 | csv_reader = csv.reader(csv_content.splitlines(), delimiter=",", quotechar='"') 71 | next(csv_reader, None) # skip headers 72 | 73 | for row in csv_reader: 74 | mac = process_mac_prefix(row[MAC_PREFIX_FIELD]) 75 | short_org = shorten_org(row[ORG_FIELD]) 76 | 77 | yield MacVendorEntry( 78 | mac_prefix=mac, vendor_short=short_org, vendor=row[ORG_FIELD] 79 | ) 80 | 81 | 82 | def process_mac_prefix(raw_mac: str) -> str: 83 | return ":".join([raw_mac[i : i + 2].upper() for i in range(0, len(raw_mac), 2)]) 84 | 85 | 86 | def shorten_org(org: str) -> str: 87 | org = org.strip() 88 | 89 | for term in IGNORED_TERMS: 90 | org = re.sub( 91 | f"[\\s\\W]({re.escape(term)})(?:[\\s\\W]|\\Z)", "", org, flags=re.IGNORECASE 92 | ) 93 | 94 | return re.sub("[\\s\\W]", "", org)[:MAX_ORG_SIZE] 95 | 96 | 97 | def download(url: str) -> str: 98 | res = requests.get(url) 99 | res.raise_for_status() 100 | 101 | return res.text 102 | 103 | 104 | def main(): 105 | mac_prefixes: set[str] = set() 106 | 107 | with open(OUTPUT_FILE, "w") as mac_file: 108 | mac_file.write( 109 | f"# The file was automatically generated " 110 | f"on {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} " 111 | f"by {os.path.basename(__file__)} via `go generate`\n" 112 | ) 113 | 114 | csv_writer = csv.writer( 115 | mac_file, delimiter="\t", quotechar='"', quoting=csv.QUOTE_MINIMAL 116 | ) 117 | 118 | for mac_vendor_entry in all_mac_vendor_entries(SOURCES): 119 | if mac_vendor_entry.mac_prefix in mac_prefixes: 120 | print( 121 | f" Ignore duplicate entry: {mac_vendor_entry.mac_prefix} " 122 | f"({mac_vendor_entry.vendor})" 123 | ) 124 | continue 125 | 126 | mac_prefixes.add(mac_vendor_entry.mac_prefix) 127 | csv_writer.writerow(mac_vendor_entry.csv_line) 128 | 129 | print(f"Created {OUTPUT_FILE}", file=sys.stderr) 130 | 131 | 132 | if __name__ == "__main__": 133 | main() 134 | -------------------------------------------------------------------------------- /dhcpv6_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "math/rand" 7 | "net" 8 | "testing" 9 | 10 | "github.com/insomniacslk/dhcp/dhcpv6" 11 | ) 12 | 13 | func TestDHCPv6SolicitAdvertise(t *testing.T) { 14 | testDHCPv6Response(t, "testdata/dhcpv6_solicit.bin", "testdata/dhcpv6_advertise.bin") 15 | } 16 | 17 | func TestDHCPv6SolicitRequestReply(t *testing.T) { 18 | testDHCPv6Response(t, "testdata/dhcpv6_request.bin", "testdata/dhcpv6_request_reply.bin") 19 | } 20 | 21 | func TestDHCPv6SolicitRenewReply(t *testing.T) { 22 | testDHCPv6Response(t, "testdata/dhcpv6_renew.bin", "testdata/dhcpv6_renew_reply.bin") 23 | } 24 | 25 | func TestGenerateDeterministicRandomAddress(t *testing.T) { 26 | sampleSize := 500 27 | 28 | t.Run("same_input", func(t *testing.T) { 29 | inputIP := mustParseIP(t, "fe80::9c90:a097:867d:e039") 30 | reference, err := generateDeterministicRandomAddress(inputIP) 31 | if err != nil { 32 | t.Fatalf("generate deterministic random address: %v", err) 33 | } 34 | 35 | assertLinkLocalIPv6(t, reference) 36 | 37 | for i := 0; i < sampleSize; i++ { 38 | ip, err := generateDeterministicRandomAddress(inputIP) 39 | if err != nil { 40 | t.Fatalf("generate deterministic random address: %v", err) 41 | } 42 | 43 | assertLinkLocalIPv6(t, ip) 44 | 45 | if !ip.Equal(reference) { 46 | t.Errorf("a different address was generated in iteration %d", i) 47 | } 48 | } 49 | }) 50 | 51 | t.Run("different_input", func(t *testing.T) { 52 | seen := map[string]bool{} 53 | 54 | for i := 0; i < sampleSize; i++ { 55 | randomPart := make([]byte, net.IPv6len/2) 56 | 57 | binary.LittleEndian.PutUint64(randomPart, rand.Uint64()) //nolint:gosec 58 | 59 | inputIP := append([]byte{}, dhcpv6LinkLocalPrefix...) 60 | inputIP = append(inputIP, randomPart...) 61 | 62 | ip, err := generateDeterministicRandomAddress(net.IP(inputIP)) 63 | if err != nil { 64 | t.Fatalf("generate deterministic random address: %v", err) 65 | } 66 | 67 | assertLinkLocalIPv6(t, ip) 68 | 69 | if seen[ip.String()] { 70 | t.Errorf("a dublicate address was generated in iteration %d", i) 71 | } 72 | 73 | seen[ip.String()] = true 74 | } 75 | }) 76 | } 77 | 78 | func TestNewPeerInfo(t *testing.T) { 79 | solicit, err := readDHCPv6Message(t, "testdata/dhcpv6_solicit.bin").GetInnerMessage() 80 | if err != nil { 81 | t.Fatalf("get inner message: %v", err) 82 | } 83 | 84 | sourceAddr := &net.UDPAddr{IP: mustParseIP(t, "fe80::d422:2ab:8bf4:7381"), Port: 1234} 85 | expectedHostname := "win10vm" 86 | 87 | peerInfo := newPeerInfo(sourceAddr, solicit) 88 | 89 | if !peerInfo.IP.Equal(sourceAddr.IP) { 90 | t.Errorf("peer info contains IP %s instead of %s", peerInfo.IP, sourceAddr.IP) 91 | } 92 | 93 | if len(peerInfo.Hostnames) != 1 { 94 | t.Fatalf("peer info contains %d hostnames instead of one", len(peerInfo.Hostnames)) 95 | } 96 | 97 | if peerInfo.Hostnames[0] != expectedHostname { 98 | t.Errorf("hostname is %q instead of %q", peerInfo.Hostnames[0], expectedHostname) 99 | } 100 | } 101 | 102 | func testDHCPv6Response(tb testing.TB, requestFileName string, responseFileName string) { 103 | tb.Helper() 104 | 105 | clientAddr := &net.UDPAddr{IP: mustParseIP(tb, "fe80::2"), Port: 1234} 106 | solicit := readDHCPv6Message(tb, requestFileName) 107 | config := Config{ 108 | Interface: &net.Interface{HardwareAddr: mustParseMAC(tb, "08:00:27:7e:ca:64")}, 109 | LeaseLifetime: dhcpv6DefaultValidLifetime, 110 | LocalIPv6: mustParseIP(tb, "fe80::a00:27ff:fe7e:ca64"), 111 | } 112 | 113 | advertise, err := NewDHCPv6Handler(config, nil).createResponse(clientAddr, solicit) 114 | if err != nil { 115 | tb.Fatalf("create response: %v", err) 116 | } 117 | 118 | expectedAdvertise := readFile(tb, responseFileName) 119 | 120 | if !bytes.Equal(advertise.ToBytes(), expectedAdvertise) { 121 | tb.Fatalf("advertise bytes do not match") 122 | } 123 | } 124 | 125 | func readDHCPv6Message(tb testing.TB, fileName string) *dhcpv6.Message { 126 | tb.Helper() 127 | 128 | msg, err := dhcpv6.MessageFromBytes(readFile(tb, fileName)) 129 | if err != nil { 130 | tb.Fatalf("read DHCPv6 message from bytes: %v", err) 131 | } 132 | 133 | return msg 134 | } 135 | 136 | func mustParseMAC(tb testing.TB, mac string) net.HardwareAddr { 137 | tb.Helper() 138 | 139 | hwa, err := net.ParseMAC(mac) 140 | if err != nil { 141 | tb.Fatalf("parse MAC: %v", err) 142 | } 143 | 144 | return hwa 145 | } 146 | 147 | func assertLinkLocalIPv6(tb testing.TB, ip net.IP) { 148 | tb.Helper() 149 | 150 | if ip.To4() != nil { 151 | tb.Fatalf("IP %s is an IPv4 address instead of an IPv6 address", ip) 152 | } 153 | 154 | if len(ip) != net.IPv6len { 155 | tb.Fatalf("IP %s contains %d bytes instead of %d bytes (IPv6)", 156 | ip, len(ip), net.IPv6len) 157 | } 158 | 159 | if !bytes.Equal(ip[:net.IPv6len/2], dhcpv6LinkLocalPrefix) { 160 | tb.Fatalf("IP does not have link local prefix: %s", ip) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /defaults.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | const fallbackLogFileEnvironmentVariable = "PRETENDER_LOG_FILE" 12 | 13 | var ( 14 | // vendors can set the following values to tweak the default configuration 15 | // during compilation as with -ldflags "-X main.vendorInterface=eth1". 16 | 17 | vendorInterface = "" 18 | vendorRelayIPv4 = "" 19 | vendorRelayIPv6 = "" 20 | vendorSOAHostname = "" 21 | 22 | vendorNoDHCPv6DNSTakeover = "" 23 | vendorNoDHCPv6 = "" 24 | vendorNoDNS = "" 25 | vendorNoRA = "" 26 | vendorNoMDNS = "" 27 | vendorNoNetBIOS = "" 28 | vendorNoLLMNR = "" 29 | vendorNoLocalNameResolution = "" 30 | vendorNoIPv6LNR = "" 31 | 32 | vendorSpoof = "" 33 | vendorDontSpoof = "" 34 | vendorSpoofFor = "" 35 | vendorDontSpoofFor = "" 36 | vendorSpoofTypes = "" 37 | vendorIgnoreDHCPv6NoFQDN = "" 38 | vendorDryMode = "" 39 | 40 | vendorTTL = "" 41 | vendorLeaseLifetime = "" 42 | vendorRARouterLifetime = "" 43 | vendorRAPeriod = "" 44 | 45 | vendorStopAfter = "" 46 | vendorVerbose = "" 47 | vendorNoColor = "" 48 | vendorNoTimestamps = "" 49 | vendorLogFileName = "" 50 | vendorNoHostInfo = "" 51 | vendorHideIgnored = "" 52 | vendorRedirectStderr = "" 53 | vendorListInterfaces = "" 54 | ) 55 | 56 | var ( 57 | defaultInterface = vendorInterface 58 | defaultRelayIPv4 = forceIP(vendorRelayIPv4, nil) 59 | defaultRelayIPv6 = forceIP(vendorRelayIPv6, nil) 60 | defaultSOAHostname = vendorSOAHostname 61 | 62 | defaultNoDHCPv6DNSTakeover = forceBool(vendorNoDHCPv6DNSTakeover, false) 63 | defaultNoDHCPv6 = forceBool(vendorNoDHCPv6, false) 64 | defaultNoDNS = forceBool(vendorNoDNS, false) 65 | defaultNoRA = forceBool(vendorNoRA, false) 66 | defaultNoMDNS = forceBool(vendorNoMDNS, false) 67 | defaultNoNetBIOS = forceBool(vendorNoNetBIOS, false) 68 | defaultNoLLMNR = forceBool(vendorNoLLMNR, false) 69 | defaultNoLocalNameResolution = forceBool(vendorNoLocalNameResolution, false) 70 | defaultNoIPv6LNR = forceBool(vendorNoIPv6LNR, false) 71 | 72 | defaultSpoof = forceStrings(vendorSpoof) 73 | defaultDontSpoof = forceStrings(vendorDontSpoof) 74 | defaultSpoofFor = forceStrings(vendorSpoofFor) 75 | defaultDontSpoofFor = forceStrings(vendorDontSpoofFor) 76 | defaultSpoofTypes = forceStrings(vendorSpoofTypes) 77 | defaultIgnoreDHCPv6NoFQDN = forceBool(vendorIgnoreDHCPv6NoFQDN, false) 78 | defaultDryMode = forceBool(vendorDryMode, false) 79 | 80 | defaultTTL = forceDuration(vendorTTL, dnsDefaultTTL) 81 | defaultLeaseLifetime = forceDuration(vendorLeaseLifetime, dhcpv6DefaultValidLifetime) 82 | defaultRARouterLifetime = forceDuration(vendorRARouterLifetime, raDefaultRouterLifetime) 83 | defaultRAPeriod = forceDuration(vendorRAPeriod, raDefaultPeriod) 84 | 85 | defaultStopAfter = forceDuration(vendorStopAfter, 0) 86 | defaultVerbose = forceBool(vendorVerbose, false) 87 | defaultNoColor = forceBool(vendorNoColor, false) 88 | defaultNoTimestamps = forceBool(vendorNoTimestamps, false) 89 | defaultHideIgnored = forceBool(vendorHideIgnored, false) 90 | defaultLogFileName = fromEnvironmentIfEmpty(vendorLogFileName, fallbackLogFileEnvironmentVariable) 91 | defaultNoHostInfo = forceBool(vendorNoHostInfo, false) 92 | defaultRedirectStderr = forceBool(vendorRedirectStderr, false) 93 | defaultListInterfaces = forceBool(vendorListInterfaces, false) 94 | ) 95 | 96 | func forceIP(ipString string, fallbackIP net.IP) net.IP { 97 | if ipString == "" { 98 | return fallbackIP 99 | } 100 | 101 | ip := net.ParseIP(ipString) 102 | if ip == nil { 103 | panic(fmt.Sprintf("cannot parse IP %q", ipString)) 104 | } 105 | 106 | return ip 107 | } 108 | 109 | func forceStrings(input string) []string { 110 | if input == "" { 111 | return nil 112 | } 113 | 114 | res := make([]string, 0, len(input)) 115 | 116 | for _, s := range strings.Split(input, ",") { 117 | res = append(res, strings.TrimSpace(s)) 118 | } 119 | 120 | return res 121 | } 122 | 123 | func forceBool(boolString string, fallbackBool bool) bool { //nolint:unparam 124 | switch strings.ToLower(boolString) { 125 | case "": 126 | return fallbackBool 127 | case "true", "1", "yes": 128 | return true 129 | case "false", "0", "no": 130 | return false 131 | default: 132 | panic(fmt.Sprintf("cannot parse bool %q", boolString)) 133 | } 134 | } 135 | 136 | func forceDuration(durationString string, fallbackDuration time.Duration) time.Duration { 137 | if durationString == "" { 138 | return fallbackDuration 139 | } 140 | 141 | d, err := time.ParseDuration(durationString) 142 | if err != nil { 143 | panic(fmt.Sprintf("cannot parse duration %q", durationString)) 144 | } 145 | 146 | return d 147 | } 148 | 149 | func fromEnvironmentIfEmpty(primaryValue string, fallbackEnvVariable string) string { 150 | if primaryValue != "" { 151 | return primaryValue 152 | } 153 | 154 | return os.Getenv(fallbackEnvVariable) 155 | } 156 | -------------------------------------------------------------------------------- /hostinfo/arp.go: -------------------------------------------------------------------------------- 1 | package hostinfo 2 | 3 | import ( 4 | "net" 5 | "runtime" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | linuxArpPseudoFile = "/proc/net/arp" 11 | windowsArpCommandAvailable = commandAvailable("arp", osWindows) 12 | linuxArpPseudoFileAvailable = fileAvailable(linuxArpPseudoFile, osLinux) 13 | ) 14 | 15 | func getMACFromLinuxARP(ip net.IP) net.HardwareAddr { 16 | if !linuxArpPseudoFileAvailable || runtime.GOOS != osLinux { 17 | return nil 18 | } 19 | 20 | if testMode { 21 | return getMACFromLinuxARPContent(ip, readFileIfPossible("testdata/unified/linux_proc_net_arp")) 22 | } 23 | 24 | return getMACFromLinuxARPContent(ip, readFileIfPossible(linuxArpPseudoFile)) 25 | } 26 | 27 | func getMACFromLinuxARPContent(ip net.IP, arpContent []byte) net.HardwareAddr { 28 | if ip == nil || arpContent == nil { 29 | return nil 30 | } 31 | 32 | for _, line := range strings.Split(string(arpContent), "\n") { 33 | parts := strings.Fields(line) 34 | if len(parts) < 6 { //nolint:gomnd 35 | continue 36 | } 37 | 38 | if ip.Equal(net.ParseIP(parts[0])) { 39 | mac, err := net.ParseMAC(parts[3]) 40 | if err == nil { 41 | return mac 42 | } 43 | } 44 | } 45 | 46 | return nil 47 | } 48 | 49 | func getIPFromLinuxARP(mac net.HardwareAddr) net.IP { 50 | if !linuxArpPseudoFileAvailable || runtime.GOOS != osLinux { 51 | return nil 52 | } 53 | 54 | if testMode { 55 | return getIPFromLinuxARPContent(mac, readFileIfPossible("testdata/unified/linux_proc_net_arp")) 56 | } 57 | 58 | return getIPFromLinuxARPContent(mac, readFileIfPossible(linuxArpPseudoFile)) 59 | } 60 | 61 | func getIPFromLinuxARPContent(mac net.HardwareAddr, arpContent []byte) net.IP { 62 | if mac == nil || arpContent == nil { 63 | return nil 64 | } 65 | 66 | for _, line := range strings.Split(string(arpContent), "\n") { 67 | parts := strings.Fields(line) 68 | if len(parts) < 6 { //nolint:gomnd 69 | continue 70 | } 71 | 72 | if strings.EqualFold(mac.String(), parts[3]) { 73 | ip := net.ParseIP(parts[0]) 74 | if ip != nil { 75 | return ip 76 | } 77 | } 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func getMACFromWindowsARP(ip net.IP) net.HardwareAddr { 84 | if !windowsArpCommandAvailable || runtime.GOOS != osWindows { 85 | return nil 86 | } 87 | 88 | if testMode { 89 | return getMACFromWindowsARPOutput(ip, readFileIfPossible("testdata/unified/windows_arp")) 90 | } 91 | 92 | return getMACFromWindowsARPOutput(ip, readOutput(execTimeoutWindows, 93 | "arp", "-a")) 94 | } 95 | 96 | //nolint:gomnd 97 | func getMACFromWindowsARPOutput(ip net.IP, arpOutput []byte) net.HardwareAddr { 98 | if ip == nil || arpOutput == nil { 99 | return nil 100 | } 101 | 102 | skip := false 103 | 104 | for _, line := range strings.Split(string(arpOutput), "\n") { 105 | if len(line) == 0 { 106 | continue 107 | } 108 | 109 | // interface section header 110 | if line[0] != ' ' { 111 | // skip next line because it's a table header 112 | skip = true 113 | 114 | continue 115 | } 116 | 117 | if skip { 118 | // skip table header 119 | skip = false 120 | 121 | continue 122 | } 123 | 124 | fields := strings.Fields(line) 125 | if len(fields) < 2 { 126 | continue 127 | } 128 | 129 | lineIP := net.ParseIP(fields[0]) 130 | if !ip.Equal(lineIP) { 131 | continue 132 | } 133 | 134 | mac, err := net.ParseMAC(fields[1]) 135 | if err == nil { 136 | return mac 137 | } 138 | } 139 | 140 | return nil 141 | } 142 | 143 | func getIPFromWindowsARP(mac net.HardwareAddr) net.IP { 144 | if !windowsArpCommandAvailable || runtime.GOOS != osWindows { 145 | return nil 146 | } 147 | 148 | if testMode { 149 | return getIPFromWindowsARPOutput(mac, readFileIfPossible("testdata/unified/windows_arp")) 150 | } 151 | 152 | return getIPFromWindowsARPOutput(mac, readOutput(execTimeoutWindows, 153 | "arp", "-a")) 154 | } 155 | 156 | func getIPFromWindowsARPOutput(mac net.HardwareAddr, arpOutput []byte) net.IP { 157 | skip := false 158 | 159 | for _, line := range strings.Split(string(arpOutput), "\n") { 160 | if len(line) == 0 { 161 | continue 162 | } 163 | 164 | // interface section header 165 | if line[0] != ' ' { 166 | // skip next line because it's a table header 167 | skip = true 168 | 169 | continue 170 | } 171 | 172 | if skip { 173 | // skip table header 174 | skip = false 175 | 176 | continue 177 | } 178 | 179 | fields := strings.Fields(line) 180 | if len(fields) < 2 { //nolint:gomnd 181 | continue 182 | } 183 | 184 | lineMAC, err := net.ParseMAC(strings.ReplaceAll(fields[1], "-", ":")) 185 | if err != nil || !strings.EqualFold(lineMAC.String(), mac.String()) { 186 | continue 187 | } 188 | 189 | ip := net.ParseIP(fields[0]) 190 | if ip != nil { 191 | return ip 192 | } 193 | } 194 | 195 | return nil 196 | } 197 | 198 | func getIPFromARP(mac net.HardwareAddr) net.IP { 199 | if runtime.GOOS == osWindows { 200 | return getIPFromWindowsARP(mac) 201 | } 202 | 203 | return getIPFromLinuxARP(mac) 204 | } 205 | 206 | func getMACFromOS(ip net.IP) net.HardwareAddr { 207 | switch runtime.GOOS { 208 | case osLinux: 209 | if ip.To4() == nil { 210 | return getMACFromLinuxIPNeighbor(ip) 211 | } 212 | 213 | return getMACFromLinuxARP(ip) 214 | case osWindows: 215 | if ip.To4() == nil { 216 | return getMACFromWindowsNetshShowNeighbors(ip) 217 | } 218 | 219 | return getMACFromWindowsARP(ip) 220 | default: 221 | return nil 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /local_name_resolution_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "net" 6 | "testing" 7 | "time" 8 | 9 | "github.com/miekg/dns" 10 | ) 11 | 12 | func TestLLMNR(t *testing.T) { 13 | testReply(t, "testdata/llmnr_request.bin", "testdata/llmnr_reply.bin") 14 | } 15 | 16 | func TestMDNS(t *testing.T) { 17 | testReply(t, "testdata/mdns_request.bin", "testdata/mdns_reply.bin") 18 | } 19 | 20 | func TestNetBIOS(t *testing.T) { //nolint:cyclop 21 | relayIP := mustParseIP(t, "10.0.0.2") 22 | mockRW := mockResonseWriter{Remote: &net.UDPAddr{IP: mustParseIP(t, "10.0.0.1")}} 23 | request := readNameServiceMessage(t, "testdata/netbios_request.bin") 24 | expectedReply := readFile(t, "testdata/netbios_reply.bin") 25 | cfg := Config{RelayIPv4: relayIP, TTL: 60 * time.Second} 26 | 27 | reply := createDNSReplyFromRequest(mockRW, request, nil, cfg) 28 | if reply == nil { 29 | t.Fatalf("no message was created") 30 | } 31 | 32 | if !reply.Authoritative { 33 | t.Fatalf("reply is not authoritative such that it will not be considered by Windows") 34 | } 35 | 36 | if len(reply.Answer) != 1 { 37 | t.Fatalf("received %d answers instead of 1", len(reply.Answer)) 38 | } 39 | 40 | answer, ok := reply.Answer[0].(*dns.NIMLOC) 41 | if !ok { 42 | t.Fatalf("wrong answer type: %T", reply.Answer[0]) 43 | } 44 | 45 | expectedLocator := encodeNetBIOSLocator(relayIP) 46 | if answer.Locator != expectedLocator { 47 | t.Fatalf("unexpected locator: got %q instead of %q", answer.Locator, expectedLocator) 48 | } 49 | 50 | if answer.Hdr.Ttl != uint32(cfg.TTL.Seconds()) { 51 | t.Fatalf("unexpected TTL: got %ds instead of %ds", answer.Hdr.Ttl, uint32(cfg.TTL.Seconds())) 52 | } 53 | 54 | if answer.Hdr.Name != request.Question[0].Name { 55 | t.Fatalf("%q was echoed instead of question name %q", answer.Hdr.Name, reply.Question[0].Name) 56 | } 57 | 58 | if answer.Hdr.Rrtype != typeNetBios { 59 | t.Fatalf("unexpected type: got %s instead of NIMLOC/NetBIOS", dnsQueryType(answer.Hdr.Rrtype)) 60 | } 61 | 62 | if answer.Hdr.Class != dns.ClassINET { 63 | t.Fatalf("unexpected class: got %d instead of %d", answer.Hdr.Class, dns.ClassINET) 64 | } 65 | 66 | if reply.CheckingDisabled { 67 | t.Fatalf("checking disabled should be false in NetBIOS reply") 68 | } 69 | 70 | if reply.Question != nil { 71 | t.Fatalf("NetBIOS reply cannot include question") 72 | } 73 | 74 | replyBuffer, err := reply.Pack() 75 | if err != nil { 76 | t.Fatalf("pack reply: %v", err) 77 | } 78 | 79 | if !bytes.Equal(replyBuffer, expectedReply) { 80 | t.Fatalf("reply bytes do not match") 81 | } 82 | } 83 | 84 | func TestSubnetBroadcastListenIP(t *testing.T) { 85 | testCases := []struct { 86 | Net string 87 | BroadcastIP string 88 | }{ 89 | {"192.168.0.4/24", "192.168.0.255"}, 90 | {"10.0.0.10/23", "10.0.1.255"}, 91 | {"10.0.0.0/8", "10.255.255.255"}, 92 | {"192.168.5.16/30", "192.168.5.19"}, 93 | } 94 | 95 | for _, testCase := range testCases { 96 | testCase := testCase 97 | 98 | t.Run(testCase.Net, func(t *testing.T) { 99 | _, ipNet, err := net.ParseCIDR(testCase.Net) 100 | if err != nil { 101 | t.Fatalf("invalid net %q: %v", testCase.Net, err) 102 | } 103 | 104 | expected := net.ParseIP(testCase.BroadcastIP) 105 | if expected == nil { 106 | t.Fatalf("invalid broadcast IP: %s", testCase.BroadcastIP) 107 | } 108 | 109 | broadcastIP, err := subnetBroadcastListenIP(ipNet) 110 | if err != nil { 111 | t.Fatalf("calculating broadcast listen IP: %v", err) 112 | } 113 | 114 | if !broadcastIP.Equal(expected) { 115 | t.Fatalf("expected broadcast IP %s for net %s, got %s instead", 116 | expected, ipNet, broadcastIP) 117 | } 118 | }) 119 | } 120 | } 121 | 122 | func TestDecodeNetBIOSHostname(t *testing.T) { 123 | testCases := []struct { 124 | NetBIOSName string 125 | Expected string 126 | }{ 127 | {NetBIOSName: "FEEFFDFEEIEPFDFECACACACACACACACA.", Expected: "TESTHOST"}, 128 | 129 | // different NetBIOS Suffixes 130 | // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-brws/0c773bdd-78e2-4d8b-8b3d-b7506849847b 131 | {NetBIOSName: "FHEPFCELEHFCEPFFFACACACACACACAAA.", Expected: "WORKGROUP"}, // workstation name 132 | {NetBIOSName: "FHEPFCELEHFCEPFFFACACACACACACAAB.", Expected: "WORKGROUP"}, // messenger service 133 | {NetBIOSName: "FHEPFCELEHFCEPFFFACACACACACACABL.", Expected: "WORKGROUP"}, // domain master browser 134 | {NetBIOSName: "FHEPFCELEHFCEPFFFACACACACACACABN.", Expected: "WORKGROUP"}, // master browser 135 | {NetBIOSName: "FHEPFCELEHFCEPFFFACACACACACACABO.", Expected: "WORKGROUP"}, // domain service elections 136 | {NetBIOSName: "FHEPFCELEHFCEPFFFACACACACACACACA.", Expected: "WORKGROUP"}, // file service 137 | } 138 | 139 | for _, testCase := range testCases { 140 | testCase := testCase 141 | 142 | t.Run(testCase.Expected, func(t *testing.T) { 143 | decoded := decodeNetBIOSHostname(testCase.NetBIOSName) 144 | if decoded != testCase.Expected { 145 | t.Errorf("%s decoded to %s instead of %s", 146 | testCase.NetBIOSName, decoded, testCase.Expected) 147 | } 148 | }) 149 | } 150 | } 151 | 152 | func TestDecodeNetBIOSSuffix(t *testing.T) { 153 | testCases := []struct { 154 | NetBIOSName string 155 | Expected string 156 | }{ 157 | {NetBIOSName: "FHEPFCELEHFCEPFFFACACACACACACAAA.", Expected: NetBIOSSuffixWorkstationService}, 158 | {NetBIOSName: "FHEPFCELEHFCEPFFFACACACACACACAAB.", Expected: NetBIOSSuffixWindowsMessengerService}, 159 | {NetBIOSName: "FHEPFCELEHFCEPFFFACACACACACACABL.", Expected: NetBIOSSuffixDomainMasterBrowser}, 160 | {NetBIOSName: "FHEPFCELEHFCEPFFFACACACACACACABN.", Expected: NetBIOSSuffixMasterBrowser}, 161 | {NetBIOSName: "FHEPFCELEHFCEPFFFACACACACACACABO.", Expected: NetBIOSSuffixBrowserServiceElections}, 162 | {NetBIOSName: "FHEPFCELEHFCEPFFFACACACACACACACA.", Expected: NetBIOSSuffixFileService}, 163 | } 164 | 165 | for _, testCase := range testCases { 166 | testCase := testCase 167 | 168 | t.Run(testCase.Expected, func(t *testing.T) { 169 | decoded := decodeNetBIOSSuffix(testCase.NetBIOSName) 170 | if decoded != testCase.Expected { 171 | t.Errorf("%s suffix decoded to %q instead of %q", 172 | testCase.NetBIOSName, decoded, testCase.Expected) 173 | } 174 | }) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /filter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "strings" 7 | 8 | "github.com/miekg/dns" 9 | ) 10 | 11 | const isatapHostname = "isatap" 12 | 13 | func containsDomain(haystack []string, needle string) bool { 14 | needle = strings.ToLower(strings.TrimSuffix(strings.TrimRight(needle, "."), ".local")) 15 | 16 | for _, el := range haystack { 17 | el := strings.ToLower(strings.TrimRight(el, ".")) 18 | 19 | if strings.HasPrefix(el, ".") && strings.HasSuffix(needle, strings.TrimLeft(el, ".")) { 20 | return true 21 | } else if strings.EqualFold(el, needle) { 22 | return true 23 | } 24 | } 25 | 26 | return false 27 | } 28 | 29 | //nolint:cyclop 30 | func shouldRespondToNameResolutionQuery(config Config, host string, queryType uint16, 31 | from net.IP, fromHostnames []string, 32 | ) (bool, string) { 33 | if config.DryMode { 34 | return false, "dry mode" 35 | } 36 | 37 | if strings.HasPrefix(strings.ToLower(host), isatapHostname) { 38 | return false, "ISATAP is always ignored" 39 | } 40 | 41 | if len(config.SpoofFor) > 0 && !containsIP(config.SpoofFor, from) && 42 | !containsAnyHostname(config.SpoofFor, fromHostnames) { 43 | return false, "host address and name not in spoof-for list" 44 | } 45 | 46 | if len(config.DontSpoofFor) > 0 { 47 | if containsIP(config.DontSpoofFor, from) { 48 | return false, "host address included in dont-spoof-for list" 49 | } 50 | 51 | if containsAnyHostname(config.DontSpoofFor, fromHostnames) { 52 | return false, "hostname included in dont-spoof-for list" 53 | } 54 | } 55 | 56 | if len(config.Spoof) > 0 && !containsDomain(config.Spoof, host) { 57 | return false, "domain not in spoof list" 58 | } 59 | 60 | if len(config.DontSpoof) > 0 && containsDomain(config.DontSpoof, host) { 61 | return false, "domain included in dont-spoof list" 62 | } 63 | 64 | if !config.SpoofTypes.ShouldSpoof(queryType) { 65 | return false, fmt.Sprintf("type %s is not in spoof-types", dnsQueryType(queryType)) 66 | } 67 | 68 | switch { 69 | case queryType == dns.TypeA && config.RelayIPv4 == nil: 70 | return false, "no IPv4 relay address configured" 71 | case queryType == dns.TypeAAAA && config.RelayIPv6 == nil: 72 | return false, "no IPv6 relay address configured" 73 | } 74 | 75 | return true, "" 76 | } 77 | 78 | func shouldRespondToDHCP(config Config, from peerInfo) (bool, string) { 79 | if config.DryMode { 80 | return false, "dry mode" 81 | } 82 | 83 | if len(from.Hostnames) == 0 && config.IgnoreDHCPv6NoFQDN { 84 | return false, "no FQDN in DHCPv6 message" 85 | } 86 | 87 | if len(config.SpoofFor) > 0 && !containsPeer(config.SpoofFor, from) { 88 | return false, "host not in spoof-for list" 89 | } 90 | 91 | if len(config.DontSpoofFor) > 0 && containsPeer(config.DontSpoofFor, from) { 92 | return false, "host included in dont-spoof-for list" 93 | } 94 | 95 | return true, "" 96 | } 97 | 98 | type hostMatcher struct { 99 | IPs []net.IP 100 | Hostname string 101 | } 102 | 103 | var hostMatcherLookupFunction = net.LookupIP 104 | 105 | func newHostMatcher(hostnameOrIP string) *hostMatcher { 106 | ip := net.ParseIP(hostnameOrIP) 107 | if ip != nil { // hostnameOrIP is an IP 108 | return &hostMatcher{IPs: []net.IP{ip}} 109 | } 110 | 111 | // domain is a wildcard 112 | if strings.HasPrefix(hostnameOrIP, ".") { 113 | return &hostMatcher{Hostname: hostnameOrIP} 114 | } 115 | 116 | // hostnameOrIP is not an IP 117 | ips, _ := hostMatcherLookupFunction(hostnameOrIP) 118 | 119 | return &hostMatcher{ 120 | IPs: ips, 121 | Hostname: hostnameOrIP, 122 | } 123 | } 124 | 125 | func asHostMatchers(hostnamesOrIPs []string) []*hostMatcher { 126 | hosts := make([]*hostMatcher, 0, len(hostnamesOrIPs)) 127 | 128 | for _, hostnameOrIP := range hostnamesOrIPs { 129 | hosts = append(hosts, newHostMatcher(hostnameOrIP)) 130 | } 131 | 132 | return hosts 133 | } 134 | 135 | func normalizeHostname(hostname string) string { 136 | return strings.ToLower(strings.TrimRight(hostname, ".")) 137 | } 138 | 139 | // Matches determines whether or not the host matches any of the provided hostnames. 140 | func (h *hostMatcher) MatchesAnyHostname(hostnames ...string) bool { 141 | if h.Hostname == "" { 142 | return false 143 | } 144 | 145 | thisHostname := normalizeHostname(h.Hostname) 146 | 147 | for _, hostname := range hostnames { 148 | otherHostname := normalizeHostname(hostname) 149 | 150 | // hostname matches 151 | if thisHostname == otherHostname { 152 | return true 153 | } 154 | 155 | // subdomain matches 156 | if strings.HasPrefix(thisHostname, ".") && strings.HasSuffix(otherHostname, thisHostname) { 157 | return true 158 | } 159 | } 160 | 161 | return false 162 | } 163 | 164 | func (h *hostMatcher) String() string { 165 | if len(h.IPs) == 0 && h.Hostname == "" { 166 | return "no host" 167 | } 168 | 169 | ipStrings := make([]string, 0, len(h.IPs)) 170 | 171 | for _, ip := range h.IPs { 172 | ipStrings = append(ipStrings, ip.String()) 173 | } 174 | 175 | ipsString := strings.Join(ipStrings, ", ") 176 | 177 | if h.Hostname == "" { 178 | return ipsString 179 | } 180 | 181 | if len(h.IPs) == 0 { 182 | return h.Hostname 183 | } 184 | 185 | return fmt.Sprintf("%s (%s)", h.Hostname, ipsString) 186 | } 187 | 188 | type spoofTypes struct { 189 | A bool 190 | AAAA bool 191 | ANY bool 192 | SOA bool 193 | } 194 | 195 | func parseSpoofTypes(spoofTypesStrings []string) (*spoofTypes, error) { 196 | if len(spoofTypesStrings) == 0 { 197 | return nil, nil //nolint:nilnil 198 | } 199 | 200 | st := &spoofTypes{} 201 | 202 | for _, spoofType := range spoofTypesStrings { 203 | switch strings.ToLower(spoofType) { 204 | case "a": 205 | st.A = true 206 | case "aaaa": 207 | st.AAAA = true 208 | case "any": 209 | st.ANY = true 210 | case "soa": 211 | st.SOA = true 212 | default: 213 | return nil, fmt.Errorf("unknown query type: %s", spoofType) 214 | } 215 | } 216 | 217 | return st, nil 218 | } 219 | 220 | func (st *spoofTypes) ShouldSpoof(qType uint16) bool { //nolint:cyclop 221 | if st == nil { 222 | return true 223 | } 224 | 225 | switch { 226 | case qType == typeNetBios: 227 | return true 228 | case qType == dns.TypeA && st.A: 229 | return true 230 | case qType == dns.TypeAAAA && st.AAAA: 231 | return true 232 | case qType == dns.TypeANY && st.ANY: 233 | return true 234 | case qType == dns.TypeSOA && st.SOA: 235 | return true 236 | default: 237 | return false 238 | } 239 | } 240 | 241 | func containsPeer(hosts []*hostMatcher, peer peerInfo) bool { 242 | for _, host := range hosts { 243 | if host.MatchesAnyHostname(peer.Hostnames...) { 244 | return true 245 | } 246 | 247 | for _, ip := range host.IPs { 248 | if peer.IP.Equal(ip) { 249 | return true 250 | } 251 | } 252 | } 253 | 254 | return false 255 | } 256 | 257 | func containsIP(haystack []*hostMatcher, needle net.IP) bool { 258 | for _, el := range haystack { 259 | for _, ip := range el.IPs { 260 | if needle.Equal(ip) { 261 | return true 262 | } 263 | } 264 | } 265 | 266 | return false 267 | } 268 | 269 | func containsAnyHostname(haystack []*hostMatcher, needles []string) bool { 270 | for _, el := range haystack { 271 | if el.MatchesAnyHostname(needles...) { 272 | return true 273 | } 274 | } 275 | 276 | return false 277 | } 278 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

pretender

3 |

Your MitM sidekick for relaying attacks featuring DHCPv6 DNS takeover
as well as mDNS, LLMNR and NetBIOS-NS spoofing

4 |

5 | Release 6 | GitHub Action: Check 7 | Software License 8 | Go Report Card 9 |

10 |

11 | 12 | --- 13 | 14 | `pretender` is a tool developed by RedTeam Pentesting to obtain 15 | machine-in-the-middle positions via spoofed local name resolution and DHCPv6 DNS 16 | takeover attacks. `pretender` primarily targets Windows hosts, as it is intended 17 | to be used for relaying attacks but can be deployed on Linux, Windows and all 18 | other platforms Go supports. Name resolution queries can be answered with 19 | arbitrary IPs for situations where the relaying tool runs on a different host 20 | than `pretender`. It is designed to work with tools such as 21 | [Impacket's](https://github.com/SecureAuthCorp/impacket) `ntlmrelayx.py` and 22 | [krbrelayx](https://github.com/dirkjanm/krbrelayx) that handle the incoming 23 | connections for relaying attacks or hash dumping. 24 | 25 | Read our [blog 26 | post](https://blog.redteam-pentesting.de/2022/introducing-pretender/) for more 27 | information about DHCPv6 DNS takeover, local name resolution spoofing and relay 28 | attacks. 29 | 30 | --- 31 | 32 | ## Usage 33 | 34 | To get a feel for the situation in the local network, `pretender` can be started 35 | in `--dry` mode where it only logs incoming queries and does not answer any of 36 | them: 37 | 38 | ```sh 39 | pretender -i eth0 --dry 40 | pretender -i eth0 --dry --no-ra # without router advertisements 41 | ``` 42 | 43 | To perform local name resolution spoofing via mDNS, LLMNR and NetBIOS-NS as well 44 | as a DHCPv6 DNS takeover with router advertisements, simply run `pretender` like 45 | this: 46 | 47 | ```sh 48 | pretender -i eth0 49 | ``` 50 | 51 | You can disable certain attacks with `--no-dhcp-dns` (disabled DHCPv6, DNS and 52 | router advertisements), `--no-lnr` (disabled mDNS, LLMNR and NetBIOS-NS), 53 | `--no-mdns`, `--no-llmnr`, `--no-netbios` and `--no-ra`. 54 | 55 | If `ntlmrelayx.py` runs on a different host (say `10.0.0.10`/`fe80::5`), run 56 | `pretender` like this: 57 | 58 | ```sh 59 | pretender -i eth0 -4 10.0.0.10 -6 fe80::5 60 | ``` 61 | 62 | Pretender can be setup to only respond to queries for certain domains (or all 63 | _but_ certain domains) and it can perform the spoofing attacks only for certain 64 | hosts (or all _but_ certain hosts). Referencing hosts by hostname relies on the 65 | name resolution of the host that runs `pretender`. See the following example: 66 | 67 | ```sh 68 | pretender -i eth0 --spoof example.com --dont-spoof-for 10.0.0.3,host1.corp,fe80::f --ignore-nofqdn 69 | ``` 70 | 71 | For more information, run `pretender --help`. 72 | 73 | --- 74 | 75 | ## Tips 76 | 77 | - Make sure to enable IPv6 support in `ntlmrelayx.py` with the `-6` flag 78 | - Pretender can be configured to stop after a certain time period for situations 79 | where it cannot be aborted manually (`--stop-after` and 80 | `main.vendorStopAfter`) 81 | - Host info lookup (which relies on the ARP table, IP neighbours and reverse 82 | lookups) can be disabled with `--no-host-info` or `main.vendorNoHostInfo` 83 | - If you are not sure which interface to choose (especially on Windows), list 84 | all interfaces with names and addresses using `--interfaces` 85 | - If you want to exclude hosts from local name resolution spoofing, make sure to 86 | also exclude their IPv6 addresses or use `--no-ipv6-lnr`/`main.vendorNoIPv6LNR` 87 | - DHCPv6 messages usually contain a FQDN option (which can also sometimes 88 | contain a hostname which is not a FQDN). This option is used to filter out 89 | messages by hostname (`--spoof-for`/`--dont-spoof-for`). You can decide what 90 | to do with DHCPv6 messages without FQDN option by setting or omitting 91 | `--ignore-nofqdn` 92 | - Depending on the build configuration, either the operating system resolver 93 | (`CGO_ENABLED=1`) or a Go implementation (`CGO_ENABLED=0`) is used. This can 94 | be important for host info collection because the OS resolver may support 95 | local name resolution and the Go implementation does not, unless a stub 96 | resolver is used. 97 | - The host info functionality is currently only available for Windows and Linux. 98 | - A custom MAC address vendor list can be compiled into the binary by replacing 99 | the default list `hostinfo/mac-vendors.txt`. Only lines with MAC prefixes in 100 | the following format are recognized: `FF:FF:FFVendorIDVendor` (the 101 | MAC prefix length can be arbitrary). 102 | - If you only want to perform Kerberos relaying you can specify `--no-lnr` and 103 | `--spoof-types SOA` to ignore any queries that are unrelated to the attack. 104 | - When conducting a Kerberos relay attack where `krbrelayx.py` runs on a 105 | different host than pretender (relay IPv4 address points to different host 106 | that runs `krbrelayx.py`), the host running `krbrelayx.py` will also need to 107 | run pretender in order to receive and deny the Dynamic Update query sent to 108 | the relay IPv4 address. 109 | 110 | --- 111 | 112 | ## Building and Vendoring 113 | 114 | Pretender can be build as follows: 115 | 116 | ```sh 117 | go build 118 | ``` 119 | 120 | Pretender can also be compiled with pre-configured settings. For this, the 121 | `ldflags` have to be modified like this: 122 | 123 | ```sh 124 | -ldflags '-X main.vendorInterface=eth1' 125 | ``` 126 | 127 | For example, Pretender can be built for Windows with a specific default 128 | interface, without colored output and with a relay IPv4 address configured: 129 | 130 | ``` 131 | GOOS=windows go build -trimpath -ldflags '-X "main.vendorInterface=Ethernet 2" -X main.vendorNoColor=true -X main.vendorRelayIPv4=10.0.0.10' 132 | ``` 133 | 134 | Full list of vendoring options (see `defaults.go` or `pretender --help` for 135 | detailed information): 136 | 137 | ``` 138 | vendorInterface 139 | vendorRelayIPv4 140 | vendorRelayIPv6 141 | vendorSOAHostname 142 | vendorNoDHCPv6DNSTakeover 143 | vendorNoDHCPv6 144 | vendorNoDNS 145 | vendorNoMDNS 146 | vendorNoNetBIOS 147 | vendorNoLLMNR 148 | vendorNoLocalNameResolution 149 | vendorNoRA 150 | vendorNoIPv6LNR 151 | vendorSpoof 152 | vendorDontSpoof 153 | vendorSpoofFor 154 | vendorDontSpoofFor 155 | vendorSpoofTypes 156 | vendorIgnoreDHCPv6NoFQDN 157 | vendorDryMode 158 | vendorTTL 159 | vendorLeaseLifetime 160 | vendorRARouterLifetime 161 | vendorRAPeriod 162 | vendorStopAfter 163 | vendorVerbose 164 | vendorNoColor 165 | vendorNoTimestamps 166 | vendorLogFileName 167 | vendorNoHostInfo 168 | vendorHideIgnored 169 | vendorRedirectStderr 170 | vendorListInterfaces 171 | ``` 172 | -------------------------------------------------------------------------------- /hostinfo/host_info_test.go: -------------------------------------------------------------------------------- 1 | package hostinfo 2 | 3 | import ( 4 | "net" 5 | "os" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | //nolint:gochecknoinits 11 | func init() { 12 | testMode = true 13 | 14 | vendorsContent, err := os.ReadFile("testdata/unified/mac-vendors.txt") 15 | if err != nil { 16 | panic(err) 17 | } 18 | 19 | macVendorsFile = string(vendorsContent) 20 | } 21 | 22 | func TestCacheHostInfos(t *testing.T) { 23 | c := NewCache() 24 | 25 | ipv6 := mustParseIP(t, "fe80::a00:27ff:fe56:c24c") 26 | c.resolvedHostnames[ipv6.String()] = nil // unresolvable 27 | 28 | ipv4 := mustParseIP(t, "192.168.56.101") 29 | c.resolvedHostnames[ipv6.String()] = nil // unresolvable 30 | 31 | c.toMAC(ipv4) 32 | 33 | c.SaveMACFromIP(ipv6, nil) 34 | 35 | c.AddHostnamesForIP(ipv4, []string{"foo"}) 36 | c.AddHostnamesForIP(ipv6, []string{"bar"}) 37 | 38 | assertContainsExactly(t, c.HostInfos(ipv6), "foo", "bar", ipv4.String()) 39 | 40 | otherIPv6 := mustParseIP(t, "fe80::800:27ff:fe00:0") 41 | c.resolvedHostnames[otherIPv6.String()] = nil // unresolvable 42 | 43 | // this IPv4 has the same MAC as otherIPv6 44 | c.resolvedHostnames[mustParseIP(t, "192.168.56.100").String()] = nil // unresolvable 45 | 46 | assertContainsExactly(t, c.HostInfos(otherIPv6), 47 | "192.168.56.100", "test") 48 | } 49 | 50 | func TestCacheSaveMACFromIP(t *testing.T) { 51 | c := NewCache() 52 | 53 | ipv4 := mustParseIP(t, "192.168.56.101") 54 | c.toMAC(ipv4) 55 | 56 | // ipv6 contains the EUI-64 encoded MAC that matches the ipv4 address above 57 | ipv6 := mustParseIP(t, "fe80::a00:27ff:fe56:c24c") 58 | 59 | c.SaveMACFromIP(ipv6, nil) 60 | 61 | resolvedIPv4 := c.toIPv4(ipv6) 62 | if !resolvedIPv4.Equal(ipv4) { 63 | t.Errorf("IPv6 resolved to %s instead of %s", resolvedIPv4, ipv4) 64 | } 65 | } 66 | 67 | func TestCacheHostnames(t *testing.T) { 68 | c := NewCache() 69 | 70 | ipv4 := mustParseIP(t, "192.168.56.101") 71 | ipv6 := mustParseIP(t, "fe80::d422:2ab:8bf4:7381") 72 | 73 | // simulate previous lookup 74 | c.resolvedHostnames[ipv4.String()] = []string{"a"} 75 | c.resolvedHostnames[ipv6.String()] = []string{"b"} 76 | // lookup macs 77 | c.toMAC(ipv4) 78 | c.toMAC(ipv6) 79 | 80 | c.AddHostnamesForIP(ipv4, []string{"c"}) 81 | c.AddHostnamesForIP(ipv4, []string{"c"}) 82 | c.AddHostnamesForIP(ipv4, []string{"a"}) 83 | 84 | c.AddHostnamesForIP(ipv6, []string{"d"}) 85 | 86 | assertContainsExactly(t, c.Hostnames(ipv4), "a", "b", "c", "d") 87 | assertContainsExactly(t, c.Hostnames(ipv6), "a", "b", "c", "d") 88 | } 89 | 90 | func TestCacheToIPv4Ipv6(t *testing.T) { 91 | c := NewCache() 92 | 93 | ipv4 := mustParseIP(t, "192.168.56.101") 94 | ipv6 := mustParseIP(t, "fe80::d422:2ab:8bf4:7381") 95 | 96 | resolvedIPv4 := c.toIPv4(ipv6) 97 | if !resolvedIPv4.Equal(ipv4) { 98 | t.Errorf("resolved %s to %s instead of %s", ipv6, resolvedIPv4, ipv4) 99 | } 100 | 101 | resolvedIPv6 := c.toIPv6(ipv4) 102 | if !resolvedIPv6.Equal(ipv6) { 103 | t.Errorf("resolved %s to %s instead of %s", ipv4, resolvedIPv6, ipv6) 104 | } 105 | } 106 | 107 | func TestCacheMACResolution(t *testing.T) { 108 | c := NewCache() 109 | 110 | assertVendor(t, c, "08:00:27:00:00:01", "A") 111 | assertVendor(t, c, "08:00:27:c2:e0:27", "A") 112 | assertVendor(t, c, "08:00:27:ff:e0:27", "specific") 113 | assertVendor(t, c, "00:00:00:00:00:00", "") 114 | assertVendor(t, c, "52:54:00:52:54:00", "B") 115 | assertVendor(t, c, "0a:00:27:00:00:00", "test") 116 | assertVendor(t, c, "ff:ff:ff:ff:ff:ff", "C") 117 | assertVendor(t, c, "FF:FF:FF:FF:FF:FF", "C") 118 | } 119 | 120 | func TestLookupUsingExternalHostnames(t *testing.T) { 121 | c := NewCache() 122 | 123 | ipv4 := mustParseIP(t, "10.10.10.10") 124 | ipv6 := mustParseIP(t, "fe80::dead:beef") 125 | hostname := "testhost" 126 | 127 | // DHCPv6 message with hostname received 128 | c.AddHostnamesForIP(ipv6, []string{hostname}) 129 | 130 | // the hostname will resolve both IPs 131 | c.resolvedIPs[hostname] = []net.IP{ipv4, ipv6} 132 | 133 | // resolve IPv4 via the lookup of the hostname associated with IPv6 address via DHCPv6 134 | resolvedIPv4 := c.toIPv4(ipv6) 135 | if !resolvedIPv4.Equal(ipv4) { 136 | t.Errorf("resolved %s to %s instead of %s", ipv6, resolvedIPv4, ipv4) 137 | } 138 | } 139 | 140 | func assertVendor(tb testing.TB, c *Cache, macString string, vendor string) { 141 | tb.Helper() 142 | 143 | mac, err := net.ParseMAC(macString) 144 | if err != nil { 145 | tb.Errorf("parse MAC %q: %v", macString, err) 146 | 147 | return 148 | } 149 | 150 | v := c.vendorByMAC(mac) 151 | if v != vendor { 152 | tb.Errorf("resolved MAC %s to vendor %q instead of %q", macString, v, vendor) 153 | } 154 | } 155 | 156 | func testGetMac(tb testing.TB, testOutputFileName string, 157 | resolver func(net.IP, []byte) net.HardwareAddr, testCases []macIPTestCase, 158 | ) { 159 | tb.Helper() 160 | 161 | content, err := os.ReadFile(testOutputFileName) //nolint:gosec 162 | if err != nil { 163 | tb.Fatalf("read proc file: %v", err) 164 | } 165 | 166 | for i, testCase := range testCases { 167 | ip := net.ParseIP(testCase.ip) 168 | if ip == nil && testCase.ip != "" { 169 | tb.Errorf("invalid input IP in testcase %d: %s", i, testCase.ip) 170 | } 171 | 172 | expectedMAC, err := net.ParseMAC(testCase.mac) 173 | if err != nil && testCase.mac != "" { 174 | tb.Errorf("invalid exepcted output MAC in testcase %d: %s", i, testCase.mac) 175 | } 176 | 177 | mac := resolver(ip, content) 178 | if mac.String() != expectedMAC.String() { 179 | tb.Errorf("get MAC of IP %s (%s): got %s instead of %s", 180 | ip, strings.TrimPrefix(testOutputFileName, "testdata/"), mac, expectedMAC) 181 | } 182 | } 183 | } 184 | 185 | func testGetIP(tb testing.TB, testOutputFileName string, 186 | resolver func(net.HardwareAddr, []byte) net.IP, testCases []macIPTestCase, 187 | ) { 188 | tb.Helper() 189 | 190 | content, err := os.ReadFile(testOutputFileName) //nolint:gosec 191 | if err != nil { 192 | tb.Fatalf("read proc file: %v", err) 193 | } 194 | 195 | for i, testCase := range testCases { 196 | mac, err := net.ParseMAC(testCase.mac) 197 | if err != nil && testCase.mac != "" { 198 | tb.Errorf("invalid exepcted output MAC in testcase %d: %s", i, testCase.mac) 199 | } 200 | 201 | expectedIP := net.ParseIP(testCase.ip) 202 | if expectedIP == nil && testCase.ip != "" { 203 | tb.Errorf("invalid input IP in testcase %d: %s", i, testCase.ip) 204 | } 205 | 206 | ip := resolver(mac, content) 207 | if ip.String() != expectedIP.String() { 208 | tb.Errorf("get IP of MAC %s (%s): got %s instead of %s", 209 | mac, strings.TrimPrefix(testOutputFileName, "testdata/"), ip, expectedIP) 210 | } 211 | } 212 | } 213 | 214 | func assertContainsExactly(tb testing.TB, got []string, required ...string) { 215 | tb.Helper() 216 | 217 | if len(got) != len(required) { 218 | tb.Errorf("got %+v elements instead of %+v", got, required) 219 | } 220 | 221 | for _, needle := range required { 222 | if !containsString(got, needle) { 223 | tb.Errorf("missing entry: %s", needle) 224 | } 225 | } 226 | } 227 | 228 | func containsString(haystack []string, needle string) bool { 229 | for _, element := range haystack { 230 | if element == needle { 231 | return true 232 | } 233 | } 234 | 235 | return false 236 | } 237 | 238 | func mustParseIP(tb testing.TB, ip string) net.IP { 239 | tb.Helper() 240 | 241 | parsedIP := net.ParseIP(ip) 242 | if parsedIP == nil { 243 | tb.Fatalf("cannot parse IP %s", ip) 244 | } 245 | 246 | return parsedIP 247 | } 248 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net" 8 | "os" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/RedTeamPentesting/pretender/hostinfo" 15 | "github.com/insomniacslk/dhcp/dhcpv6" 16 | ) 17 | 18 | // escape is the ANSI escape sequence. 19 | const escape = "\x1b" 20 | 21 | // attribute represents a style. 22 | type attribute int 23 | 24 | // Base attributes. 25 | const ( 26 | reset attribute = iota 27 | bold 28 | faint 29 | ) 30 | 31 | // Foreground text colors. 32 | const ( 33 | fgRed attribute = iota + 31 34 | fgGreen 35 | ) 36 | 37 | type baseLogger struct { 38 | Verbose bool 39 | PrintTimestamps bool 40 | NoColor bool 41 | HostInfoCache *hostinfo.Cache 42 | HideIgnored bool 43 | NoHostInfo bool 44 | 45 | LogFile *os.File 46 | logFileMutex sync.Mutex 47 | 48 | wg sync.WaitGroup 49 | } 50 | 51 | func newBaseLogger() *baseLogger { 52 | return &baseLogger{ 53 | PrintTimestamps: true, 54 | HostInfoCache: hostinfo.NewCache(), 55 | } 56 | } 57 | 58 | // Logger provides logging functionality. 59 | type Logger struct { 60 | *baseLogger 61 | Prefix string 62 | } 63 | 64 | // NewLogger returns a Logger. 65 | func NewLogger() *Logger { 66 | return &Logger{ 67 | baseLogger: newBaseLogger(), 68 | } 69 | } 70 | 71 | func (l *Logger) style(attrs ...attribute) string { 72 | if l.NoColor { 73 | return "" 74 | } 75 | 76 | s := "" 77 | for _, a := range attrs { 78 | s += fmt.Sprintf("%s[%dm", escape, a) 79 | } 80 | 81 | return s 82 | } 83 | 84 | // WithPrefix returns a copy of the logger with a new prefix. 85 | func (l *Logger) WithPrefix(prefix string) *Logger { 86 | return &Logger{ 87 | baseLogger: l.baseLogger, 88 | Prefix: prefix, 89 | } 90 | } 91 | 92 | // Debugf prints debug information. 93 | func (l *Logger) Debugf(format string, a ...interface{}) { 94 | if l == nil || !l.Verbose { 95 | return 96 | } 97 | 98 | l.logf(os.Stdout, l.styleAndPrefix(faint)+format, a...) 99 | } 100 | 101 | // Infof prints info messages. 102 | func (l *Logger) Infof(format string, a ...interface{}) { 103 | if l == nil { 104 | return 105 | } 106 | 107 | l.logf(os.Stdout, l.styleAndPrefix()+format, a...) 108 | } 109 | 110 | // Query prints query information. 111 | func (l *Logger) Query(name string, queryType string, peer net.IP) { 112 | if l == nil { 113 | return 114 | } 115 | 116 | l.logWithHostInfo(peer, func(hostInfo string) string { 117 | return fmt.Sprintf(l.styleAndPrefix(fgGreen)+"%q (%s) queried by %s", name, queryType, hostInfo) 118 | }, logFileEntry{ 119 | Name: name, 120 | Type: l.Prefix, 121 | QueryType: queryType, 122 | Source: peer, 123 | }) 124 | } 125 | 126 | // RefuseDynamicUpdate prints information about refused DNS dynamic updates. 127 | func (l *Logger) RefuseDynamicUpdate(name string, queryType string, peer net.IP) { 128 | if l == nil { 129 | return 130 | } 131 | 132 | l.logWithHostInfo(peer, func(hostInfo string) string { 133 | return fmt.Sprintf(l.styleAndPrefix(fgGreen)+"refusing SOA dynamic update of %q from %s", name, hostInfo) 134 | }, logFileEntry{ 135 | Name: name, 136 | Type: l.Prefix, 137 | QueryType: queryType, 138 | Source: peer, 139 | }) 140 | } 141 | 142 | // IgnoreDNS prints information abound ignored DNS queries. 143 | func (l *Logger) IgnoreDNS(name string, queryType string, peer net.IP, reason string) { 144 | if l == nil { 145 | return 146 | } 147 | 148 | l.logWithHostInfo(peer, func(hostInfo string) string { 149 | if l.HideIgnored { 150 | return "" 151 | } 152 | 153 | reasonSuffix := reason 154 | if reasonSuffix != "" { 155 | reasonSuffix = ": " + reasonSuffix 156 | } 157 | 158 | return fmt.Sprintf(l.styleAndPrefix()+l.style(faint)+"ignoring query for %q (%s) from %s%s", 159 | name, queryType, hostInfo, reasonSuffix) 160 | }, logFileEntry{ 161 | Name: name, 162 | Type: l.Prefix, 163 | QueryType: queryType, 164 | Source: peer, 165 | Ignored: true, 166 | IgnoreReason: reason, 167 | }) 168 | } 169 | 170 | // IgnoreDHCP prints information abound ignored DHCP requests. 171 | func (l *Logger) IgnoreDHCP(dhcpType string, peer peerInfo, reason string) { 172 | if l == nil { 173 | return 174 | } 175 | 176 | l.HostInfoCache.AddHostnamesForIP(peer.IP, peer.Hostnames) 177 | 178 | l.logWithHostInfo(peer.IP, func(hostInfo string) string { 179 | if l.HideIgnored { 180 | return "" 181 | } 182 | 183 | reasonSuffix := reason 184 | if reasonSuffix != "" { 185 | reasonSuffix = ": " + reasonSuffix 186 | } 187 | 188 | return fmt.Sprintf(l.styleAndPrefix()+l.style(faint)+"ignoring DHCP %s request from %s%s", 189 | dhcpType, hostInfo, reasonSuffix) 190 | }, logFileEntry{ 191 | Source: peer.IP, 192 | Type: "DHCP", 193 | Ignored: true, 194 | IgnoreReason: reason, 195 | }) 196 | } 197 | 198 | // DHCP prints information abound answered DHCP requests in which an address is assined. 199 | func (l *Logger) DHCP(dhcpType dhcpv6.MessageType, peer peerInfo, assignedAddress net.IP) { 200 | if l == nil { 201 | return 202 | } 203 | 204 | l.HostInfoCache.AddHostnamesForIP(peer.IP, peer.Hostnames) 205 | 206 | message := "responding to %s from %s by assigning " 207 | if dhcpType != dhcpv6.MessageTypeSolicit { 208 | message += "DNS server and " 209 | } 210 | 211 | message += "IPv6 %q" 212 | 213 | l.logWithHostInfo(peer.IP, func(hostInfo string) string { 214 | return fmt.Sprintf(l.styleAndPrefix()+l.style(faint)+message, dhcpType, hostInfo, assignedAddress) 215 | }, logFileEntry{ 216 | AssignedAddress: assignedAddress, 217 | QueryType: dhcpType.String(), 218 | Source: peer.IP, 219 | Type: "DHCP", 220 | }) 221 | } 222 | 223 | // Errorf prints errors. 224 | func (l *Logger) Errorf(format string, a ...interface{}) { 225 | if l == nil { 226 | return 227 | } 228 | 229 | l.logf(stdErr, l.styleAndPrefix(bold, fgRed)+format, a...) 230 | } 231 | 232 | // Fatalf prints fatal errors and quits the application without shutdown. 233 | func (l *Logger) Fatalf(format string, a ...interface{}) { 234 | if l == nil { 235 | return 236 | } 237 | 238 | l.logf(stdErr, l.styleAndPrefix(bold, fgRed)+format, a...) 239 | os.Exit(1) 240 | } 241 | 242 | // Flush blocks until all log messages are printed. Flush does not nessarily 243 | // flush the log file. 244 | func (l *Logger) Flush() { 245 | l.baseLogger.wg.Wait() 246 | } 247 | 248 | // Close performs a Flush() and closes and thereby flushes the log file if configured. 249 | func (l *Logger) Close() { 250 | l.Flush() 251 | defer l.Flush() 252 | 253 | if l.LogFile != nil { 254 | l.logFileMutex.Lock() 255 | err := l.LogFile.Close() 256 | l.logFileMutex.Unlock() 257 | 258 | l.LogFile = nil 259 | 260 | if err != nil { 261 | l.Errorf("closing log file: %v", err) 262 | l.Flush() 263 | } 264 | } 265 | } 266 | 267 | func (l *Logger) logWithHostInfo(peer net.IP, logString func(hostInfo string) string, logEntry logFileEntry) { 268 | l.baseLogger.wg.Add(1) 269 | 270 | if logEntry.Time.IsZero() { 271 | logEntry.Time = time.Now() 272 | } 273 | 274 | log := func() { 275 | hostInfo := peer.String() 276 | 277 | if !l.NoHostInfo { 278 | infos := l.HostInfoCache.HostInfos(peer) 279 | if len(infos) != 0 { 280 | hostInfo = fmt.Sprintf("%s (%s)", peer, strings.Join(infos, ", ")) 281 | } 282 | 283 | logEntry.SourceInfo = infos 284 | } 285 | 286 | if l.LogFile != nil { 287 | fileLogLine, err := json.Marshal(logEntry) 288 | if err != nil { 289 | l.Errorf("marshalling file log entry: %v", err) 290 | } 291 | 292 | l.logFileMutex.Lock() 293 | _, err = l.LogFile.Write(append(fileLogLine, byte('\n'))) 294 | l.logFileMutex.Unlock() 295 | 296 | if err != nil { 297 | l.Errorf("logging to file: %w", err) 298 | l.LogFile = nil 299 | } 300 | } 301 | 302 | l.logf(os.Stdout, logString(hostInfo)) 303 | 304 | l.baseLogger.wg.Done() 305 | } 306 | 307 | if l.NoHostInfo { 308 | log() 309 | } else { 310 | go log() 311 | } 312 | } 313 | 314 | func (l *Logger) logf(w io.Writer, format string, a ...interface{}) { 315 | if l == nil || (format == "" && len(a) == 0) { 316 | return 317 | } 318 | 319 | if l.PrintTimestamps { 320 | format = fmt.Sprintf("%s%s%s %s", l.style(faint), time.Now().Format("15:04:05"), l.style(reset), format) 321 | } 322 | 323 | l.baseLogger.wg.Add(1) 324 | 325 | go func() { 326 | fmt.Fprintf(w, format+l.style(reset)+"\n", a...) 327 | l.baseLogger.wg.Done() 328 | }() 329 | } 330 | 331 | func (l *Logger) styleAndPrefix(attrs ...attribute) string { 332 | if l.Prefix == "" { 333 | return l.style(attrs...) 334 | } 335 | 336 | return fmt.Sprintf("%s%s[%s]%s%s ", l.style(attrs...), l.style(bold), l.Prefix, l.style(reset), l.style(attrs...)) 337 | } 338 | 339 | func styled(text string, disableStyle bool, styles ...attribute) string { 340 | if disableStyle { 341 | return text 342 | } 343 | 344 | strStyles := make([]string, 0, len(styles)) 345 | for _, s := range styles { 346 | strStyles = append(strStyles, strconv.Itoa(int(s))) 347 | } 348 | 349 | return fmt.Sprintf("%s[%sm%s%s[%dm", escape, strings.Join(strStyles, ";"), text, escape, reset) 350 | } 351 | 352 | type logFileEntry struct { 353 | Name string `json:"name,omitempty"` 354 | AssignedAddress net.IP `json:"assigned_addr,omitempty"` 355 | QueryType string `json:"query_type,omitempty"` 356 | Type string `json:"type"` 357 | Source net.IP `json:"source"` 358 | SourceInfo []string `json:"source_info"` 359 | Time time.Time `json:"time"` 360 | Ignored bool `json:"ignored"` 361 | IgnoreReason string `json:"ignore_reason,omitempty"` 362 | } 363 | 364 | func escapeFormatString(s string) string { 365 | return strings.ReplaceAll(s, "%", "%%") 366 | } 367 | -------------------------------------------------------------------------------- /dns_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "net" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/miekg/dns" 11 | ) 12 | 13 | func TestDNS(t *testing.T) { 14 | testReply(t, "testdata/dns_request.bin", "testdata/dns_reply.bin") 15 | } 16 | 17 | func TestDNSAny(t *testing.T) { 18 | request := &dns.Msg{} 19 | request.SetQuestion("host", dns.TypeANY) 20 | 21 | relayIPv4 := mustParseIP(t, "10.0.0.2") 22 | relayIPv6 := mustParseIP(t, "fe80::1") 23 | mockRW := mockResonseWriter{Remote: &net.UDPAddr{IP: mustParseIP(t, "10.0.0.1")}} 24 | cfgs := []Config{ 25 | {RelayIPv4: relayIPv4, RelayIPv6: nil, TTL: 60 * time.Second}, 26 | {RelayIPv4: nil, RelayIPv6: relayIPv6, TTL: 60 * time.Second}, 27 | {RelayIPv4: relayIPv4, RelayIPv6: relayIPv6, TTL: 60 * time.Second}, 28 | } 29 | 30 | for i, cfg := range cfgs { 31 | reply := createDNSReplyFromRequest(mockRW, request, nil, cfg) 32 | if reply == nil { 33 | t.Fatalf("config %d: no message was created", i) 34 | 35 | return // calm down staticcheck linter 36 | } 37 | 38 | expectedNumberOfAnswers := 0 39 | 40 | if cfg.RelayIPv4 != nil { 41 | expectedNumberOfAnswers++ 42 | 43 | aAnswer, ok := getAnswerByType(t, reply.Answer, dns.TypeA).(*dns.A) 44 | if !ok { 45 | t.Fatalf("config %d: unexpected type for A answer", i) 46 | } 47 | 48 | assertRRHeader(t, aAnswer.Hdr, "host", dns.TypeA, cfg.TTL) 49 | 50 | if !aAnswer.A.Equal(relayIPv4) { 51 | t.Fatalf("config %d: abnswer contains A address %s instead of %s", 52 | i, aAnswer.A, relayIPv4) 53 | } 54 | } 55 | 56 | if cfg.RelayIPv6 != nil { 57 | expectedNumberOfAnswers++ 58 | 59 | aaaaAnswer, ok := getAnswerByType(t, reply.Answer, dns.TypeAAAA).(*dns.AAAA) 60 | if !ok { 61 | t.Fatalf("config %d: unexpected type for A answer", i) 62 | } 63 | 64 | assertRRHeader(t, aaaaAnswer.Hdr, "host", dns.TypeAAAA, cfg.TTL) 65 | 66 | if !aaaaAnswer.AAAA.Equal(relayIPv6) { 67 | t.Fatalf("config %d: abnswer contains AAAA address %s instead of %s", 68 | i, aaaaAnswer.AAAA, relayIPv6) 69 | } 70 | } 71 | 72 | if len(reply.Answer) != expectedNumberOfAnswers { 73 | t.Fatalf("config %d: unexpected number of answers: %d instead of %d", 74 | i, len(reply.Answer), expectedNumberOfAnswers) 75 | } 76 | } 77 | } 78 | 79 | //nolint:cyclop 80 | func TestDNSSOA(t *testing.T) { 81 | soa := &dns.Msg{} 82 | soa.SetQuestion("host", dns.TypeSOA) 83 | 84 | relayIPv4 := mustParseIP(t, "10.0.0.2") 85 | relayIPv6 := mustParseIP(t, "fe80::1") 86 | mockRW := mockResonseWriter{Remote: &net.UDPAddr{IP: mustParseIP(t, "10.0.0.1")}} 87 | 88 | cfg := Config{RelayIPv4: relayIPv4, RelayIPv6: relayIPv6, TTL: 60 * time.Second} 89 | 90 | // don't respond to SOA when no SOA hostname is configured 91 | noReply := createDNSReplyFromRequest(mockRW, soa, nil, cfg) 92 | if noReply != nil { 93 | t.Fatalf("SOA rely was created without configuring SOA hostname") 94 | } 95 | 96 | cfg.SOAHostname = "hostname" 97 | 98 | reply := createDNSReplyFromRequest(mockRW, soa, nil, cfg) 99 | if reply == nil { 100 | t.Fatalf("no SOA reply was created") 101 | } 102 | 103 | soaAnswer, ok := getAnswerByType(t, reply.Answer, dns.TypeSOA).(*dns.SOA) 104 | if !ok { 105 | t.Fatalf("SOA answer has unexpected type") 106 | } 107 | 108 | assertRRHeader(t, soaAnswer.Hdr, "host", dns.TypeSOA, cfg.TTL) 109 | 110 | if soaAnswer.Ns != dns.Fqdn(cfg.SOAHostname) { 111 | t.Fatalf("unexpected SOA answer hostname: %q instead of %q", 112 | soaAnswer.Ns, dns.Fqdn(cfg.SOAHostname)) 113 | } 114 | 115 | soaNS, ok := getAnswerByType(t, reply.Ns, dns.TypeNS).(*dns.NS) 116 | if !ok { 117 | t.Fatalf("SOA NS has unexpected type") 118 | } 119 | 120 | assertRRHeader(t, soaNS.Hdr, "host", dns.TypeNS, cfg.TTL) 121 | 122 | if soaNS.Ns != dns.Fqdn(cfg.SOAHostname) { 123 | t.Fatalf("unexpected SOA NS hostname: %q instead of %q", 124 | soaNS.Ns, dns.Fqdn(cfg.SOAHostname)) 125 | } 126 | 127 | soaA, ok := getAnswerByType(t, reply.Extra, dns.TypeA).(*dns.A) 128 | if !ok { 129 | t.Fatalf("SOA A record has unexpected type") 130 | } 131 | 132 | assertRRHeader(t, soaA.Hdr, dns.Fqdn(cfg.SOAHostname), dns.TypeA, cfg.TTL) 133 | 134 | if !soaA.A.Equal(relayIPv4) { 135 | t.Fatalf("SOA extra A record contains address %s instead of %s", 136 | soaA.A, relayIPv4) 137 | } 138 | 139 | soaAAAA, ok := getAnswerByType(t, reply.Extra, dns.TypeAAAA).(*dns.AAAA) 140 | if !ok { 141 | t.Fatalf("SOA AAAA record has unexpected type") 142 | } 143 | 144 | assertRRHeader(t, soaAAAA.Hdr, dns.Fqdn(cfg.SOAHostname), dns.TypeAAAA, cfg.TTL) 145 | 146 | if !soaAAAA.AAAA.Equal(relayIPv6) { 147 | t.Fatalf("SOA extra AAAA record contains address %s instead of %s", 148 | soaAAAA.AAAA, relayIPv6) 149 | } 150 | } 151 | 152 | func TestDNSSOADynamicUpdate(t *testing.T) { 153 | soa := &dns.Msg{} 154 | soa.SetQuestion("host", dns.TypeSOA) 155 | soa.Opcode = dns.OpcodeUpdate 156 | 157 | relayIPv4 := mustParseIP(t, "10.0.0.2") 158 | relayIPv6 := mustParseIP(t, "fe80::1") 159 | mockRW := mockResonseWriter{Remote: &net.UDPAddr{IP: mustParseIP(t, "10.0.0.1")}} 160 | 161 | cfg := Config{ 162 | RelayIPv4: relayIPv4, 163 | RelayIPv6: relayIPv6, 164 | TTL: 60 * time.Second, 165 | SOAHostname: "hostname", 166 | } 167 | 168 | reply := createDNSReplyFromRequest(mockRW, soa, nil, cfg) 169 | if reply == nil { 170 | t.Fatalf("no SOA reply was created") 171 | } 172 | 173 | if reply.Rcode != dns.RcodeRefused { 174 | t.Fatalf("SOA dynamic update was not refused") 175 | } 176 | } 177 | 178 | //nolint:cyclop 179 | func testReply(tb testing.TB, requestFileName string, replyFileName string) { 180 | tb.Helper() 181 | 182 | relayIPv4 := mustParseIP(tb, "10.0.0.2") 183 | relayIPv6 := mustParseIP(tb, "fe80::1") 184 | mockRW := mockResonseWriter{Remote: &net.UDPAddr{IP: mustParseIP(tb, "10.0.0.1")}} 185 | request := readNameServiceMessage(tb, requestFileName) 186 | expectedReply := readFile(tb, replyFileName) 187 | cfg := Config{RelayIPv4: relayIPv4, RelayIPv6: relayIPv6, TTL: 60 * time.Second} 188 | 189 | reply := createDNSReplyFromRequest(mockRW, request, nil, cfg) 190 | if reply == nil { 191 | tb.Fatalf("no message was created") 192 | 193 | return // calm down staticcheck linter 194 | } 195 | 196 | if len(reply.Answer) == 0 { 197 | tb.Fatalf("reply contains no answers") 198 | } 199 | 200 | for _, answer := range reply.Answer { 201 | switch a := answer.(type) { 202 | case *dns.A: 203 | if !a.A.Equal(relayIPv4) { 204 | tb.Fatalf("reply contains A address %s instead of %s", a.A, relayIPv4) 205 | } 206 | 207 | if a.Hdr.Rrtype != dns.TypeA { 208 | tb.Fatalf("unexpected type: got %s instead of %s", dnsQueryType(a.Hdr.Rrtype), dnsQueryType(dns.TypeA)) 209 | } 210 | case *dns.AAAA: 211 | if !a.AAAA.Equal(relayIPv6) { 212 | tb.Fatalf("reply contains AAAA address %s instead of %s", a.AAAA, relayIPv6) 213 | } 214 | 215 | if a.Hdr.Rrtype != dns.TypeAAAA { 216 | tb.Fatalf("unexpected type: got %s instead of %s", dnsQueryType(a.Hdr.Rrtype), dnsQueryType(dns.TypeAAAA)) 217 | } 218 | default: 219 | tb.Fatalf("unexpected reply type %T", answer) 220 | } 221 | 222 | answerHeader := answer.Header() 223 | 224 | if answerHeader.Ttl != uint32(cfg.TTL.Seconds()) { 225 | tb.Fatalf("unexpected TTL: got %ds instead of %ds", answerHeader.Ttl, uint32(cfg.TTL.Seconds())) 226 | } 227 | 228 | if answerHeader.Name != request.Question[0].Name { 229 | tb.Fatalf("%q was echoed instead of question name %q", answerHeader.Name, reply.Question[0].Name) 230 | } 231 | 232 | if answerHeader.Class != dns.ClassINET { 233 | tb.Fatalf("unexpected class: got %d instead of %d", answerHeader.Class, dns.ClassINET) 234 | } 235 | } 236 | 237 | replyBuffer, err := reply.Pack() 238 | if err != nil { 239 | tb.Fatalf("pack reply: %v", err) 240 | } 241 | 242 | if !bytes.Equal(replyBuffer, expectedReply) { 243 | tb.Fatalf("reply bytes do not match") 244 | } 245 | } 246 | 247 | func getAnswerByType(tb testing.TB, answers []dns.RR, answerType uint16) dns.RR { //nolint:ireturn 248 | tb.Helper() 249 | 250 | for _, answer := range answers { 251 | if answer.Header().Rrtype == answerType { 252 | return answer 253 | } 254 | } 255 | 256 | tb.Fatalf("found no answer of type %d", answerType) 257 | 258 | return nil 259 | } 260 | 261 | func readNameServiceMessage(tb testing.TB, fileName string) *dns.Msg { 262 | tb.Helper() 263 | 264 | msg := &dns.Msg{} 265 | 266 | err := msg.Unpack(readFile(tb, fileName)) 267 | if err != nil { 268 | tb.Fatalf("parse NetBIOS test file: %v", err) 269 | } 270 | 271 | return msg 272 | } 273 | 274 | func readFile(tb testing.TB, fileName string) []byte { 275 | tb.Helper() 276 | 277 | content, err := os.ReadFile(fileName) //nolint:gosec 278 | if err != nil { 279 | tb.Fatalf("read file: %v", err) 280 | } 281 | 282 | return content 283 | } 284 | 285 | func assertRRHeader(tb testing.TB, hdr dns.RR_Header, name string, rtype uint16, ttl time.Duration) { 286 | tb.Helper() 287 | 288 | if hdr.Name != name { 289 | tb.Fatalf("unexpected name in header: %q instead of %q", hdr.Name, name) 290 | } 291 | 292 | if hdr.Rrtype != rtype { 293 | tb.Fatalf("unexpected type in header: %d instead of %d", hdr.Rrtype, rtype) 294 | } 295 | 296 | if hdr.Class != dns.ClassINET { 297 | tb.Fatalf("unexpected class in header: %d instead of %d", hdr.Class, dns.ClassINET) 298 | } 299 | 300 | if hdr.Ttl != uint32(ttl.Seconds()) { 301 | tb.Fatalf("unexpected TTL in header: %ds instead of %s", hdr.Ttl, ttl) 302 | } 303 | } 304 | 305 | type mockResonseWriter struct { 306 | Local net.Addr 307 | Remote net.Addr 308 | } 309 | 310 | var _ dns.ResponseWriter = mockResonseWriter{} 311 | 312 | func (m mockResonseWriter) LocalAddr() net.Addr { 313 | return m.Local 314 | } 315 | 316 | func (m mockResonseWriter) RemoteAddr() net.Addr { 317 | return m.Remote 318 | } 319 | 320 | func (mockResonseWriter) WriteMsg(*dns.Msg) error { 321 | return nil 322 | } 323 | 324 | func (mockResonseWriter) Write([]byte) (int, error) { 325 | return 0, nil 326 | } 327 | 328 | func (m mockResonseWriter) Close() error { 329 | return nil 330 | } 331 | 332 | func (m mockResonseWriter) TsigStatus() error { 333 | return nil 334 | } 335 | 336 | func (m mockResonseWriter) TsigTimersOnly(bool) {} 337 | 338 | func (m mockResonseWriter) Hijack() {} 339 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc= 4 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 5 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 6 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 7 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 8 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 9 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 10 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 11 | github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= 12 | github.com/insomniacslk/dhcp v0.0.0-20221001123530-5308ebe5334c h1:OCFM4+DXTWfNlyeoddrTwdup/ztkGSyAMR2UGcPckNQ= 13 | github.com/insomniacslk/dhcp v0.0.0-20221001123530-5308ebe5334c/go.mod h1:h+MxyHxRg9NH3terB1nfRIUaQEcI0XOVkdR9LNBlp8E= 14 | github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= 15 | github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ= 16 | github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok= 17 | github.com/jsimonetti/rtnetlink v0.0.0-20201110080708-d2c240429e6c/go.mod h1:huN4d1phzjhlOsNIjFsw2SVRbwIHj3fJDMEU2SDPTmg= 18 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 19 | github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y= 20 | github.com/mdlayher/ndp v1.0.0 h1:rcFaJVj04Rj47ZlV/t3iZcuKzlpwBuBsD3gR9AHDzcI= 21 | github.com/mdlayher/ndp v1.0.0/go.mod h1:+3vkk6YnlL8ZTRTjmQanCNQFqDKOpP2zNyHl2HqyoZs= 22 | github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= 23 | github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= 24 | github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY= 25 | github.com/mdlayher/netlink v1.1.1/go.mod h1:WTYpFb/WTvlRJAyKhZL5/uy69TDDpHHu2VZmb2XgV7o= 26 | github.com/mdlayher/raw v0.0.0-20190606142536-fef19f00fc18/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= 27 | github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= 28 | github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= 29 | github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 33 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 34 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 35 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 36 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 37 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 38 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 39 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 40 | github.com/u-root/uio v0.0.0-20210528114334-82958018845c/go.mod h1:LpEX5FO/cB+WF4TYGY1V5qktpaZLkKkSegbr0V4eYXA= 41 | github.com/u-root/uio v0.0.0-20220204230159-dac05f7d2cb4 h1:hl6sK6aFgTLISijk6xIzeqnPzQcsLqqvL6vEfTPinME= 42 | github.com/u-root/uio v0.0.0-20220204230159-dac05f7d2cb4/go.mod h1:LpEX5FO/cB+WF4TYGY1V5qktpaZLkKkSegbr0V4eYXA= 43 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 44 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 45 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 46 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 47 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 48 | golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I= 49 | golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 50 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 51 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 52 | golang.org/x/net v0.0.0-20190419010253-1f3472d942ba/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 53 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 54 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 55 | golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 56 | golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 57 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 58 | golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 59 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 60 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 61 | golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 62 | golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= 63 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 64 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 65 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 66 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 67 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 68 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 69 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 70 | golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 71 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 72 | golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 73 | golang.org/x/sys v0.0.0-20190606122018-79a91cf218c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 74 | golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 75 | golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 76 | golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 77 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 78 | golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 79 | golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 80 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 81 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 82 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 83 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 84 | golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 86 | golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= 87 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 88 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 89 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 90 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 91 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 92 | golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= 93 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 94 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 95 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 96 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 97 | golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 98 | golang.org/x/tools v0.2.0 h1:G6AHpWxTMGY1KyEYoAQ5WTtIekUUvDNjan3ugu60JvE= 99 | golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 100 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 101 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 102 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 103 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 104 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 105 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 106 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 107 | -------------------------------------------------------------------------------- /dns.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/miekg/dns" 12 | "golang.org/x/sync/errgroup" 13 | ) 14 | 15 | const ( 16 | dnsPort = 53 // default DNS port 17 | 18 | // DefaultTTL is the time to live specified in replies to name resolution 19 | // queries of any type. 20 | dnsDefaultTTL = 60 * time.Second 21 | 22 | // NetBIOS name resolution messages have the type set to 32 which means 23 | // NIMLOC in the DNS spec. In this case we don't expect actual NIMLOC 24 | // messages, so we assume type 32 messages to be NetBIOS requests. 25 | typeNetBios = dns.TypeNIMLOC 26 | ) 27 | 28 | //nolint:cyclop 29 | func createDNSReplyFromRequest(rw dns.ResponseWriter, request *dns.Msg, logger *Logger, config Config) *dns.Msg { 30 | reply := &dns.Msg{} 31 | reply.SetReply(request) 32 | reply.Authoritative = true // this has to be set for Windows to accept NetBIOS queries 33 | 34 | peer, err := toIP(rw.RemoteAddr()) 35 | if err != nil { 36 | logger.Errorf(err.Error()) 37 | 38 | return nil 39 | } 40 | 41 | var peerHostnames []string 42 | if logger != nil && logger.HostInfoCache != nil { 43 | peerHostnames = logger.HostInfoCache.Hostnames(peer) 44 | } 45 | 46 | for _, q := range request.Question { 47 | name := normalizedNameFromQuery(q) 48 | 49 | shouldRespond, reason := shouldRespondToNameResolutionQuery(config, name, q.Qtype, peer, peerHostnames) 50 | if !shouldRespond { 51 | logger.IgnoreDNS(name, queryType(q, request.Opcode), peer, reason) 52 | 53 | continue 54 | } 55 | 56 | switch q.Qtype { 57 | case dns.TypeA: 58 | reply.Answer = append(reply.Answer, rr(config.RelayIPv4, q.Name, config.TTL)) 59 | case dns.TypeAAAA: 60 | reply.Answer = append(reply.Answer, rr(config.RelayIPv6, q.Name, config.TTL)) 61 | case dns.TypeSOA: 62 | switch { 63 | case config.SOAHostname == "": 64 | logger.IgnoreDNS(name, queryType(q, request.Opcode), peer, "no SOA hostname configured") 65 | 66 | continue 67 | case request.Opcode == dns.OpcodeUpdate: 68 | // Refuse dynamic update to trigger authentication in TKEY query over TCP (not handled by pretender) 69 | reply.Rcode = dns.RcodeRefused 70 | reply.Ns = request.Ns 71 | reply.Answer = nil 72 | 73 | logger.RefuseDynamicUpdate(name, queryType(q, request.Opcode), peer) 74 | 75 | return reply // no need to react the other questions 76 | default: 77 | // Tell the client that a server with the hostname `SOAHostname` 78 | // is authoritative for the requested zone. And, btw: WE pretend 79 | // to be the host named `SOAHostname`. 80 | soaHostname := dns.Fqdn(config.SOAHostname) 81 | 82 | reply.Answer = append(reply.Answer, &dns.SOA{ 83 | Hdr: rrHeader(q.Name, dns.TypeSOA, config.TTL), 84 | Ns: soaHostname, 85 | Mbox: "pretender.invalid.", 86 | }) 87 | 88 | reply.Ns = append(reply.Ns, &dns.NS{ 89 | Hdr: rrHeader(q.Name, dns.TypeNS, config.TTL), 90 | Ns: soaHostname, 91 | }) 92 | 93 | if config.RelayIPv4 != nil { 94 | reply.Extra = append(reply.Extra, rr(config.RelayIPv4, soaHostname, config.TTL)) 95 | } 96 | 97 | if config.RelayIPv6 != nil { 98 | reply.Extra = append(reply.Extra, rr(config.RelayIPv6, soaHostname, config.TTL)) 99 | } 100 | } 101 | case dns.TypeANY: 102 | if config.RelayIPv4 != nil { 103 | reply.Answer = append(reply.Answer, rr(config.RelayIPv4, q.Name, config.TTL)) 104 | } 105 | 106 | if config.RelayIPv6 != nil { 107 | reply.Answer = append(reply.Answer, rr(config.RelayIPv6, q.Name, config.TTL)) 108 | } 109 | case typeNetBios: 110 | reply.CheckingDisabled = false 111 | reply.Question = nil 112 | reply.Answer = append(reply.Answer, &dns.NIMLOC{ 113 | Hdr: rrHeader(q.Name, dns.TypeNIMLOC, config.TTL), 114 | Locator: encodeNetBIOSLocator(config.RelayIPv4.To4()), 115 | }) 116 | default: 117 | logger.Debugf("%s query for name %s from %s is unhandled", 118 | dns.Type(q.Qtype).String(), name, rw.RemoteAddr().String()) 119 | 120 | continue 121 | } 122 | 123 | logger.Query(name, queryType(q, request.Opcode), peer) 124 | } 125 | 126 | // don't send a reply at all if we don't actually spoof anything 127 | if len(reply.Answer) == 0 && len(reply.Ns) == 0 && len(reply.Extra) == 0 { 128 | logger.Debugf("ignoring query from %s because no answers were configured", rw.RemoteAddr().String()) 129 | 130 | return nil 131 | } 132 | 133 | return reply 134 | } 135 | 136 | func rr(ip net.IP, name string, ttl time.Duration) dns.RR { //nolint:ireturn 137 | if ip.To4() == nil { 138 | return &dns.AAAA{Hdr: rrHeader(name, dns.TypeAAAA, ttl), AAAA: ip} 139 | } 140 | 141 | return &dns.A{Hdr: rrHeader(name, dns.TypeA, ttl), A: ip} 142 | } 143 | 144 | func rrHeader(name string, rtype uint16, ttl time.Duration) dns.RR_Header { 145 | return dns.RR_Header{Name: name, Rrtype: rtype, Class: dns.ClassINET, Ttl: uint32(ttl.Seconds())} 146 | } 147 | 148 | func toIP(addr net.Addr) (net.IP, error) { 149 | switch a := addr.(type) { 150 | case *net.TCPAddr: 151 | return a.IP, nil 152 | case *net.UDPAddr: 153 | return a.IP, nil 154 | default: 155 | return nil, fmt.Errorf("cannot extract IP from %T", addr) 156 | } 157 | } 158 | 159 | func normalizedNameFromQuery(q dns.Question) string { 160 | if q.Qtype == typeNetBios { 161 | return decodeNetBIOSHostname(q.Name) 162 | } 163 | 164 | name := normalizedName(q.Name) 165 | 166 | if name == "" { 167 | return q.Name 168 | } 169 | 170 | return name 171 | } 172 | 173 | func normalizedName(host string) string { 174 | return strings.TrimSuffix(strings.TrimSpace(host), ".") 175 | } 176 | 177 | func queryType(q dns.Question, opcode int) string { 178 | if q.Qtype == typeNetBios { 179 | return decodeNetBIOSSuffix(q.Name) 180 | } 181 | 182 | typeStr := dnsQueryType(q.Qtype) 183 | 184 | switch opcode { 185 | case dns.OpcodeStatus: 186 | return typeStr + " Status" 187 | case dns.OpcodeNotify: 188 | return typeStr + " Notify" 189 | case dns.OpcodeUpdate: 190 | return typeStr + " Dynamic Update" 191 | default: 192 | return typeStr 193 | } 194 | } 195 | 196 | func dnsQueryType(qtype uint16) string { 197 | return dns.Type(qtype).String() 198 | } 199 | 200 | // DNSHandler creates a dns.HandlerFunc based on the logic in 201 | // createReplyFromRequest. 202 | func DNSHandler(logger *Logger, config Config) dns.HandlerFunc { 203 | return func(rw dns.ResponseWriter, request *dns.Msg) { 204 | reply := createDNSReplyFromRequest(rw, request, logger, config) 205 | if reply == nil { 206 | _ = rw.Close() // early abort for TCP connections 207 | 208 | return 209 | } 210 | 211 | err := rw.WriteMsg(reply) 212 | if err != nil { 213 | logger.Errorf("writing reply: %v", err) 214 | } 215 | } 216 | } 217 | 218 | // UDPConnDNSHandler handles requests by creating a response using 219 | // createReplyFromRequest and sends it directly using the underlying UDP 220 | // connection on which the server operates. 221 | func UDPConnDNSHandler(conn net.PacketConn, logger *Logger, config Config) dns.HandlerFunc { 222 | return func(rw dns.ResponseWriter, request *dns.Msg) { 223 | reply := createDNSReplyFromRequest(rw, request, logger, config) 224 | if reply == nil { 225 | return 226 | } 227 | 228 | buf, err := reply.Pack() 229 | if err != nil { 230 | logger.Errorf("pack message: %v", err) 231 | 232 | return 233 | } 234 | 235 | _, err = conn.WriteTo(buf, rw.RemoteAddr()) 236 | if err != nil { 237 | logger.Errorf("write dns reply: %v", err) 238 | 239 | return 240 | } 241 | } 242 | } 243 | 244 | // RunDNSResponder starts a TCP and a UDP DNS server. 245 | func RunDNSResponder(ctx context.Context, logger *Logger, config Config) error { 246 | errGroup, ctx := errgroup.WithContext(ctx) 247 | 248 | ipv6Addr := net.IPAddr{IP: config.LocalIPv6, Zone: config.Interface.Name} 249 | fullAddr := net.JoinHostPort(ipv6Addr.String(), strconv.Itoa(dnsPort)) 250 | 251 | errGroup.Go(func() error { 252 | logger.Infof("listening via UDP on %s", fullAddr) 253 | 254 | return runDNSServerWithContext(ctx, &dns.Server{ 255 | Addr: fullAddr, 256 | Net: "udp6", 257 | Handler: DNSHandler(logger, config), 258 | MsgAcceptFunc: acceptAllQueries, 259 | }) 260 | }) 261 | 262 | errGroup.Go(func() error { 263 | logger.Infof("listening via TCP on %s", fullAddr) 264 | 265 | return runDNSServerWithContext(ctx, &dns.Server{ 266 | Addr: fullAddr, 267 | Net: "tcp6", 268 | Handler: DNSHandler(logger, config), 269 | MsgAcceptFunc: acceptAllQueries, 270 | }) 271 | }) 272 | 273 | // listen on IPv4 port only if we expect to receive a dynamic update to the 274 | // relay IPv4 address following an SOA query 275 | if config.SOAHostname != "" && hasSpecificIPv4Address(config.Interface, config.RelayIPv4) { 276 | fullAddr := net.JoinHostPort(config.RelayIPv4.String(), strconv.Itoa(dnsPort)) 277 | 278 | errGroup.Go(func() error { 279 | logger.Infof("listening via TCP on %s", fullAddr) 280 | 281 | return runDNSServerWithContext(ctx, &dns.Server{ 282 | Addr: fullAddr, 283 | Net: "udp4", 284 | Handler: DNSHandler(logger, config), 285 | MsgAcceptFunc: acceptAllQueries, 286 | }) 287 | }) 288 | } 289 | 290 | return errGroup.Wait() 291 | } 292 | 293 | func acceptAllQueries(dh dns.Header) dns.MsgAcceptAction { 294 | queryReplyBit := uint16(1 << 15) //nolint:gomnd 295 | 296 | if isReply := dh.Bits&queryReplyBit != 0; isReply { 297 | return dns.MsgIgnore 298 | } 299 | 300 | return dns.MsgAccept 301 | } 302 | 303 | func runDNSServerWithContext(ctx context.Context, server *dns.Server) error { 304 | go func() { 305 | <-ctx.Done() 306 | 307 | _ = server.Shutdown() 308 | }() 309 | 310 | err := server.ListenAndServe() 311 | if err != nil { 312 | return fmt.Errorf("activate and serve: %w", err) 313 | } 314 | 315 | return nil 316 | } 317 | 318 | // RunDNSHandlerOnUDPConnection runs the DNS handler on an arbitrary UDP 319 | // connection, such that the DNS handling logic can be used for LLMNR, mDNS and 320 | // NetBIOS name resolution. 321 | func RunDNSHandlerOnUDPConnection(ctx context.Context, conn net.PacketConn, logger *Logger, config Config) error { 322 | server := &dns.Server{ 323 | PacketConn: conn, 324 | Handler: UDPConnDNSHandler(conn, logger, config), 325 | } 326 | 327 | go func() { 328 | <-ctx.Done() 329 | 330 | _ = server.Shutdown() 331 | }() 332 | 333 | err := server.ActivateAndServe() 334 | if err != nil { 335 | return fmt.Errorf("activate and serve: %w", err) 336 | } 337 | 338 | return nil 339 | } 340 | 341 | func hasSpecificIPv4Address(iface *net.Interface, ip net.IP) bool { 342 | addrs, err := iface.Addrs() 343 | if err != nil { 344 | return false 345 | } 346 | 347 | for _, addr := range addrs { 348 | ifIP, ok := addr.(*net.IPNet) 349 | if !ok { 350 | continue 351 | } 352 | 353 | if ifIP.IP.Equal(ip) { 354 | return true 355 | } 356 | } 357 | 358 | return false 359 | } 360 | -------------------------------------------------------------------------------- /hostinfo/host_info.go: -------------------------------------------------------------------------------- 1 | // Package hostinfo can be used to correlate IPs, MACs and hostnames using 2 | // caches DNS lookups and ARP information. 3 | package hostinfo 4 | 5 | import ( 6 | "bufio" 7 | "bytes" 8 | "context" 9 | _ "embed" 10 | "net" 11 | "os" 12 | "os/exec" 13 | "runtime" 14 | "sort" 15 | "strings" 16 | "sync" 17 | "time" 18 | ) 19 | 20 | //go:generate python3 generate_mac_vendors.py 21 | 22 | const ( 23 | execTimeoutWindows = 800 * time.Millisecond 24 | execTimeoutLinux = 250 * time.Millisecond 25 | 26 | osWindows = "windows" 27 | osLinux = "linux" 28 | ) 29 | 30 | var testMode = false 31 | 32 | //go:embed mac-vendors.txt 33 | var macVendorsFile string 34 | 35 | type macIPPair struct { 36 | IP net.IP 37 | MAC net.HardwareAddr 38 | } 39 | 40 | // Cache caches information such as IPv4 addresses, MAC vendors and hostnames. 41 | type Cache struct { 42 | macIPPairs []macIPPair 43 | resolvedHostnames map[string][]string 44 | externalHostnames map[string][]string 45 | resolvedIPs map[string][]net.IP 46 | macVendors map[string]string 47 | macPrefixSizes []int 48 | 49 | sync.Mutex 50 | } 51 | 52 | // NewCache returns a new HostInfoCache and parses the embedded MAC vendors file. 53 | func NewCache() *Cache { 54 | cache := &Cache{ 55 | macIPPairs: []macIPPair{}, 56 | resolvedHostnames: map[string][]string{}, 57 | externalHostnames: map[string][]string{}, 58 | resolvedIPs: map[string][]net.IP{}, 59 | macVendors: map[string]string{}, 60 | } 61 | 62 | prefixSizes := map[int]struct{}{} 63 | 64 | scanner := bufio.NewScanner(strings.NewReader(macVendorsFile)) 65 | for scanner.Scan() { 66 | line := scanner.Text() 67 | if len(line) == 0 || line[0] == '#' { 68 | continue 69 | } 70 | 71 | parts := strings.SplitN(line, "\t", 3) //nolint:gomnd 72 | if len(parts) < 2 { //nolint:gomnd 73 | continue 74 | } 75 | 76 | macPrefix := strings.ToUpper(parts[0]) 77 | 78 | prefixSizes[len(macPrefix)] = struct{}{} 79 | 80 | cache.macVendors[macPrefix] = parts[1] 81 | } 82 | 83 | cache.macPrefixSizes = sortedHighToLow(prefixSizes) 84 | 85 | return cache 86 | } 87 | 88 | // SaveMACFromIP saves the MAC address extracted from the IP together with the 89 | // IP in the cache. If the IP did not contain an EUI-64 encoded MAC address a 90 | // MAC address with the first three bytes of the provided fallback address is 91 | // saved in order to be able to identify the vendor. 92 | func (c *Cache) SaveMACFromIP(ip net.IP, fallback net.HardwareAddr) { 93 | c.Lock() 94 | defer c.Unlock() 95 | 96 | for _, pair := range c.macIPPairs { 97 | if ip.Equal(pair.IP) { 98 | return 99 | } 100 | } 101 | 102 | mac := extractMAC(ip) 103 | if mac == nil { 104 | if fallback == nil { 105 | return 106 | } 107 | 108 | // use fallback only for vendor prefix 109 | mac = net.HardwareAddr{fallback[0], fallback[1], fallback[2], 0, 0, 0} 110 | } 111 | 112 | c.macIPPairs = append(c.macIPPairs, macIPPair{IP: ip, MAC: mac}) 113 | } 114 | 115 | // AddHostnamesForIP registers a set of hostnames for a given IP that were 116 | // obtained externally. Hostnames are only added if they are not already 117 | // present. 118 | func (c *Cache) AddHostnamesForIP(ip net.IP, hostnames []string) { 119 | c.Lock() 120 | defer c.Unlock() 121 | 122 | c.externalHostnames[ip.String()] = appendUnique(c.externalHostnames[ip.String()], hostnames...) 123 | } 124 | 125 | func (c *Cache) toMAC(ip net.IP) net.HardwareAddr { 126 | for _, pair := range c.macIPPairs { 127 | if ip.Equal(pair.IP) { 128 | return pair.MAC 129 | } 130 | } 131 | 132 | mac := getMACFromOS(ip) 133 | if mac != nil { 134 | c.macIPPairs = append(c.macIPPairs, macIPPair{IP: ip, MAC: mac}) 135 | } 136 | 137 | return mac 138 | } 139 | 140 | func (c *Cache) toIPv4(ip net.IP) net.IP { 141 | if ip.To4() != nil { 142 | return ip 143 | } 144 | 145 | mac := c.toMAC(ip) 146 | if mac == nil { 147 | return c.lookupUsingExternalHostnames(ip, mac) 148 | } 149 | 150 | for _, pair := range c.macIPPairs { 151 | if bytes.Equal(mac, pair.MAC) && pair.IP.To4() != nil { 152 | return pair.IP 153 | } 154 | } 155 | 156 | ipv4 := getIPFromARP(mac) 157 | if ipv4 != nil { 158 | c.macIPPairs = append(c.macIPPairs, macIPPair{MAC: mac, IP: ipv4}) 159 | 160 | return ipv4 161 | } 162 | 163 | if ipv4 == nil { 164 | return c.lookupUsingExternalHostnames(ip, mac) 165 | } 166 | 167 | return ipv4 168 | } 169 | 170 | func (c *Cache) lookupUsingExternalHostnames(ip net.IP, mac net.HardwareAddr) net.IP { 171 | externalHostnames := c.externalHostnames[ip.String()] 172 | if len(externalHostnames) == 0 { 173 | return nil 174 | } 175 | 176 | // for now, only consider the first to avoid a lot of DNS requests 177 | externalHostname := externalHostnames[0] 178 | 179 | resolvedIPs, ok := c.resolvedIPs[externalHostname] 180 | if !ok { 181 | resolvedIPs = lookup(externalHostname) 182 | 183 | c.resolvedIPs[externalHostname] = resolvedIPs 184 | } 185 | 186 | if mac != nil { 187 | for _, resolvedIP := range resolvedIPs { 188 | c.macIPPairs = append(c.macIPPairs, macIPPair{MAC: mac, IP: resolvedIP}) 189 | } 190 | } 191 | 192 | for _, resolvedIP := range resolvedIPs { 193 | if resolvedIP.To4() != nil { 194 | return resolvedIP 195 | } 196 | } 197 | 198 | return nil 199 | } 200 | 201 | func (c *Cache) toIPv6(ip net.IP) net.IP { 202 | if ip.To4() == nil { 203 | return ip 204 | } 205 | 206 | mac := c.toMAC(ip) 207 | 208 | for _, pair := range c.macIPPairs { 209 | if bytes.Equal(mac, pair.MAC) && pair.IP.To4() == nil { 210 | return pair.IP 211 | } 212 | } 213 | 214 | return nil 215 | } 216 | 217 | // Hostnames returns all known hostnames associated with the given IP. 218 | func (c *Cache) Hostnames(ip net.IP) []string { 219 | c.Lock() 220 | defer c.Unlock() 221 | 222 | return c.hostnames(ip) 223 | } 224 | 225 | func (c *Cache) hostnames(ip net.IP) []string { 226 | var results []string 227 | 228 | ipv4 := c.toIPv4(ip) 229 | if ipv4 != nil { 230 | hostnames, ok := c.resolvedHostnames[ipv4.String()] 231 | if !ok { 232 | hostnames = reverseLookup(ipv4.String()) 233 | 234 | c.resolvedHostnames[ipv4.String()] = hostnames 235 | } 236 | 237 | results = append(results, hostnames...) 238 | results = append(results, c.externalHostnames[ipv4.String()]...) 239 | } 240 | 241 | ipv6 := c.toIPv6(ip) 242 | if ipv6 != nil { 243 | hostnames, ok := c.resolvedHostnames[ipv6.String()] 244 | if !ok { 245 | hostnames = reverseLookup(ipv6.String()) 246 | 247 | c.resolvedHostnames[ipv6.String()] = hostnames 248 | } 249 | 250 | results = append(results, hostnames...) 251 | results = append(results, c.externalHostnames[ipv6.String()]...) 252 | } 253 | 254 | return uniqueLowercase(results) 255 | } 256 | 257 | func (c *Cache) vendorByIP(ip net.IP) string { 258 | if c.macVendors == nil { 259 | return "" 260 | } 261 | 262 | return c.vendorByMAC(c.toMAC(ip)) 263 | } 264 | 265 | func (c *Cache) vendorByMAC(mac net.HardwareAddr) string { 266 | if mac == nil { 267 | return "" 268 | } 269 | 270 | macStr := strings.ToUpper(mac.String()) 271 | 272 | for _, prefixSize := range c.macPrefixSizes { 273 | vendor, ok := c.macVendors[macStr[:prefixSize]] 274 | if ok { 275 | return vendor 276 | } 277 | } 278 | 279 | return "" 280 | } 281 | 282 | func uniqueLowercase(input []string) []string { 283 | present := map[string]bool{} 284 | 285 | unique := []string{} 286 | 287 | for _, element := range input { 288 | lowercaseElement := strings.ToLower(element) 289 | 290 | if !present[lowercaseElement] { 291 | present[lowercaseElement] = true 292 | 293 | unique = append(unique, lowercaseElement) 294 | } 295 | } 296 | 297 | return unique 298 | } 299 | 300 | func appendUnique(oldElements []string, newElements ...string) []string { 301 | present := map[string]bool{} 302 | 303 | for _, oldElement := range oldElements { 304 | present[oldElement] = true 305 | } 306 | 307 | for _, el := range newElements { 308 | _, ok := present[el] 309 | if !ok { 310 | oldElements = append(oldElements, el) 311 | present[el] = true 312 | } 313 | } 314 | 315 | return oldElements 316 | } 317 | 318 | const dnsTimeout = 200 * time.Millisecond 319 | 320 | func reverseLookup(addr string) []string { 321 | if addr == "" { 322 | return nil 323 | } 324 | 325 | resultChannel := make(chan []string) 326 | 327 | go func() { 328 | defer close(resultChannel) 329 | 330 | addrs, err := net.LookupAddr(addr) 331 | if err != nil { 332 | resultChannel <- nil 333 | } 334 | 335 | resultChannel <- addrs 336 | }() 337 | 338 | timer := time.NewTimer(dnsTimeout) 339 | 340 | select { 341 | case result := <-resultChannel: 342 | if !timer.Stop() { 343 | <-timer.C 344 | } 345 | 346 | return trimRightSlice(result, ".") 347 | case <-timer.C: 348 | return nil 349 | } 350 | } 351 | 352 | func lookup(hostname string) []net.IP { 353 | if hostname == "" { 354 | return nil 355 | } 356 | 357 | resultChannel := make(chan []net.IP) 358 | 359 | go func() { 360 | defer close(resultChannel) 361 | 362 | addrs, err := net.LookupIP(hostname) 363 | if err != nil { 364 | resultChannel <- nil 365 | } 366 | 367 | resultChannel <- addrs 368 | }() 369 | 370 | timer := time.NewTimer(dnsTimeout) 371 | 372 | select { 373 | case result := <-resultChannel: 374 | if !timer.Stop() { 375 | <-timer.C 376 | } 377 | 378 | return result 379 | case <-timer.C: 380 | return nil 381 | } 382 | } 383 | 384 | func trimRightSlice(stringSlice []string, cutset string) []string { 385 | result := make([]string, 0, len(stringSlice)) 386 | 387 | for _, element := range stringSlice { 388 | result = append(result, strings.TrimRight(element, cutset)) 389 | } 390 | 391 | return result 392 | } 393 | 394 | // HostInfos returns the string representations of all available infos for the 395 | // host. 396 | func (c *Cache) HostInfos(ip net.IP) []string { 397 | c.Lock() 398 | defer c.Unlock() 399 | 400 | var infos []string 401 | 402 | if ip.To4() == nil { 403 | ipv4 := c.toIPv4(ip) 404 | if ipv4 != nil { 405 | infos = append(infos, ipv4.String()) 406 | } 407 | } 408 | 409 | hostnames := c.hostnames(ip) 410 | 411 | if len(hostnames) == 0 { 412 | // add MAC vendor as replacement for missing hostnames 413 | vendor := c.vendorByIP(ip) 414 | if vendor != "" { 415 | infos = append(infos, vendor) 416 | } 417 | } 418 | 419 | return append(hostnames, infos...) 420 | } 421 | 422 | func extractMAC(ip net.IP) net.HardwareAddr { 423 | if ip[11] != 0xff || ip[12] != 0xfe { 424 | return getMACFromOS(ip) 425 | } 426 | 427 | mac := make([]byte, 0, 6) //nolint:gomnd 428 | 429 | // remove ff:fe from the middle 430 | mac = append(mac, ip[8:11]...) 431 | mac = append(mac, ip[13:]...) 432 | 433 | // invert bit in first octet 434 | mac[0] ^= 2 435 | 436 | return mac 437 | } 438 | 439 | func commandAvailable(executable string, goos string) bool { 440 | if goos != "" && runtime.GOOS != goos { 441 | return false 442 | } 443 | 444 | _, err := exec.LookPath(executable) 445 | 446 | return err == nil 447 | } 448 | 449 | func fileAvailable(filename string, goos string) bool { 450 | if goos != "" && runtime.GOOS != goos { 451 | return false 452 | } 453 | 454 | _, err := os.Stat(filename) 455 | 456 | return err == nil 457 | } 458 | 459 | func readOutput(timeout time.Duration, name string, args ...string) []byte { 460 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 461 | defer cancel() 462 | 463 | cmd := exec.CommandContext(ctx, name, args...) 464 | 465 | output, err := cmd.Output() 466 | if err != nil { 467 | return nil 468 | } 469 | 470 | return output 471 | } 472 | 473 | func readFileIfPossible(filename string) []byte { 474 | content, _ := os.ReadFile(filename) //nolint:gosec 475 | 476 | return content 477 | } 478 | 479 | func sortedHighToLow(m map[int]struct{}) []int { 480 | keys := make([]int, 0, len(m)) 481 | 482 | for key := range m { 483 | keys = append(keys, key) 484 | } 485 | 486 | sort.Sort(sort.Reverse(sort.IntSlice(keys))) 487 | 488 | return keys 489 | } 490 | -------------------------------------------------------------------------------- /filter_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/miekg/dns" 9 | ) 10 | 11 | func TestFilterNameResolutionQuery(t *testing.T) { //nolint:maintidx 12 | someIP := mustParseIP(t, "10.1.2.3") 13 | relayIPv4 := mustParseIP(t, "10.0.0.1") 14 | relayIPv6 := mustParseIP(t, "fe80::1") 15 | 16 | testCases := []struct { 17 | TestName string 18 | SpoofFor []string 19 | DontSpoofFor []string 20 | Spoof []string 21 | DontSpoof []string 22 | SpoofTypes []string 23 | DryMode bool 24 | NoRelayIPv4Configured bool 25 | NoRelayIPv6Configured bool 26 | 27 | Host string 28 | QueryType uint16 // defaults to A 29 | From net.IP 30 | FromHostnames []string 31 | 32 | ShouldRespond bool 33 | }{ 34 | { 35 | TestName: "regular", 36 | Host: "foo", 37 | From: someIP, 38 | ShouldRespond: true, 39 | }, 40 | { 41 | TestName: "istap", 42 | Host: isatapHostname, 43 | From: someIP, 44 | ShouldRespond: false, 45 | }, 46 | { 47 | TestName: "dry", 48 | Host: "foo", 49 | DryMode: true, 50 | From: someIP, 51 | ShouldRespond: false, 52 | }, 53 | { 54 | Host: "foo", 55 | Spoof: []string{"foo"}, 56 | SpoofFor: []string{someIP.String()}, 57 | DryMode: true, 58 | From: someIP, 59 | ShouldRespond: false, 60 | }, 61 | { 62 | Host: "foo", 63 | Spoof: []string{"foo", "oof"}, 64 | From: someIP, 65 | ShouldRespond: true, 66 | }, 67 | { 68 | Host: "oof", 69 | Spoof: []string{"foo", "oof"}, 70 | From: someIP, 71 | ShouldRespond: true, 72 | }, 73 | { 74 | Host: "test", 75 | SpoofFor: []string{"anotherhost"}, 76 | From: someIP, 77 | FromHostnames: []string{"anotherhost", "test"}, 78 | ShouldRespond: true, 79 | }, 80 | { 81 | Host: "test", 82 | DontSpoofFor: []string{"anotherhost"}, 83 | From: someIP, 84 | FromHostnames: []string{"anotherhost", "test"}, 85 | ShouldRespond: false, 86 | }, 87 | { 88 | Host: "test", 89 | SpoofFor: []string{".anotherhost"}, 90 | From: someIP, 91 | FromHostnames: []string{"foo.anotherhost", "test"}, 92 | ShouldRespond: true, 93 | }, 94 | { 95 | Host: "bar", 96 | Spoof: []string{"foo", "oof"}, 97 | From: someIP, 98 | ShouldRespond: false, 99 | }, 100 | { 101 | Host: "foo", 102 | DontSpoof: []string{"foo", "oof"}, 103 | From: someIP, 104 | ShouldRespond: false, 105 | }, 106 | { 107 | Host: "oof", 108 | DontSpoof: []string{"foo", "oof"}, 109 | From: someIP, 110 | ShouldRespond: false, 111 | }, 112 | { 113 | Host: "bar", 114 | SpoofFor: []string{"somehost"}, // resolves to 192.168.0.5 115 | From: mustParseIP(t, "192.168.0.5"), 116 | ShouldRespond: true, 117 | }, 118 | { 119 | Host: "bar", 120 | SpoofFor: []string{"192.168.0.5"}, 121 | From: mustParseIP(t, "192.168.0.5"), 122 | ShouldRespond: true, 123 | }, 124 | { 125 | Host: "bar", 126 | DontSpoofFor: []string{"somehost"}, // resolves to 192.168.0.5 127 | From: mustParseIP(t, "192.168.0.5"), 128 | ShouldRespond: false, 129 | }, 130 | { 131 | Host: "bar", 132 | SpoofFor: []string{"x"}, 133 | From: someIP, 134 | ShouldRespond: false, 135 | }, 136 | { 137 | Host: "foo.bar", 138 | Spoof: []string{"bar"}, 139 | From: someIP, 140 | ShouldRespond: false, 141 | }, 142 | { 143 | Host: "foo.bar", 144 | Spoof: []string{".bar"}, 145 | From: someIP, 146 | ShouldRespond: true, 147 | }, 148 | { 149 | Host: "foo.bar", 150 | DontSpoof: []string{"bar"}, 151 | From: someIP, 152 | ShouldRespond: true, 153 | }, 154 | { 155 | Host: "foo.bar", 156 | DontSpoof: []string{".bar"}, 157 | From: someIP, 158 | ShouldRespond: false, 159 | }, 160 | { 161 | Host: "foobar", 162 | DontSpoof: []string{"bar"}, 163 | From: someIP, 164 | ShouldRespond: true, 165 | }, 166 | { 167 | Host: "foobar", 168 | DontSpoof: []string{".bar"}, 169 | From: someIP, 170 | ShouldRespond: false, 171 | }, 172 | { 173 | Host: "bar", 174 | DontSpoof: []string{".bar"}, 175 | From: someIP, 176 | ShouldRespond: false, 177 | }, 178 | { 179 | Host: "bar", 180 | Spoof: []string{"bar."}, 181 | From: someIP, 182 | ShouldRespond: true, 183 | }, 184 | { 185 | Host: "bar.", 186 | Spoof: []string{"bar"}, 187 | From: someIP, 188 | ShouldRespond: true, 189 | }, 190 | { 191 | Host: "bar.", 192 | Spoof: []string{"bar."}, 193 | From: someIP, 194 | ShouldRespond: true, 195 | }, 196 | { 197 | Host: "test", 198 | QueryType: dns.TypeA, 199 | From: someIP, 200 | SpoofTypes: []string{"a"}, 201 | ShouldRespond: true, 202 | }, 203 | { 204 | Host: "test", 205 | QueryType: dns.TypeA, 206 | From: someIP, 207 | SpoofTypes: []string{"A"}, 208 | ShouldRespond: true, 209 | }, 210 | { 211 | Host: "test", 212 | QueryType: dns.TypeA, 213 | From: someIP, 214 | SpoofTypes: []string{"SOA"}, 215 | ShouldRespond: false, 216 | }, 217 | { 218 | Host: "test", 219 | QueryType: dns.TypeSOA, 220 | From: someIP, 221 | SpoofTypes: []string{"A", "AAAA", "SOA"}, 222 | ShouldRespond: true, 223 | }, 224 | { 225 | Host: "test", 226 | QueryType: dns.TypeSOA, 227 | From: someIP, 228 | SpoofTypes: []string{"A", "AAAA"}, 229 | ShouldRespond: false, 230 | }, 231 | { 232 | TestName: "NetBIOS unaffected by spoof-types", 233 | Host: "test", 234 | QueryType: typeNetBios, 235 | From: someIP, 236 | SpoofTypes: []string{"A", "AAAA"}, 237 | ShouldRespond: true, 238 | }, 239 | { 240 | TestName: "no relay IPv4", 241 | Host: "test", 242 | QueryType: dns.TypeA, 243 | From: someIP, 244 | NoRelayIPv4Configured: true, 245 | ShouldRespond: false, 246 | }, 247 | { 248 | TestName: "no relay IPv6", 249 | Host: "test", 250 | QueryType: dns.TypeAAAA, 251 | From: someIP, 252 | NoRelayIPv6Configured: true, 253 | ShouldRespond: false, 254 | }, 255 | } 256 | 257 | hostMatcherLookupFunction = func(host string) ([]net.IP, error) { 258 | switch host { 259 | case "somehost": 260 | return []net.IP{mustParseIP(t, "192.168.0.5")}, nil 261 | default: 262 | return nil, nil 263 | } 264 | } 265 | 266 | for i, testCase := range testCases { 267 | testCase := testCase 268 | 269 | testName := testCase.TestName 270 | if testName == "" { 271 | testName = strconv.Itoa(i) 272 | } 273 | 274 | t.Run("test_"+testName, func(t *testing.T) { 275 | types, err := parseSpoofTypes(testCase.SpoofTypes) 276 | if err != nil { 277 | t.Fatalf("parse spoof types: %v", err) 278 | } 279 | 280 | cfg := Config{ 281 | SpoofFor: asHostMatchers(testCase.SpoofFor), 282 | DontSpoofFor: asHostMatchers(testCase.DontSpoofFor), 283 | Spoof: testCase.Spoof, 284 | DontSpoof: testCase.DontSpoof, 285 | DryMode: testCase.DryMode, 286 | SpoofTypes: types, 287 | } 288 | 289 | switch { 290 | case !testCase.NoRelayIPv4Configured: 291 | cfg.RelayIPv4 = relayIPv4 292 | case !testCase.NoRelayIPv6Configured: 293 | cfg.RelayIPv6 = relayIPv6 294 | } 295 | 296 | if testCase.QueryType == 0 { 297 | testCase.QueryType = dns.TypeA 298 | } 299 | 300 | shouldRespond, _ := shouldRespondToNameResolutionQuery(cfg, 301 | normalizedName(testCase.Host), testCase.QueryType, testCase.From, testCase.FromHostnames) 302 | if shouldRespond != testCase.ShouldRespond { 303 | t.Errorf("shouldRespondToNameResolutionQuery returned %v instead of %v", 304 | shouldRespond, testCase.ShouldRespond) 305 | } 306 | }) 307 | } 308 | } 309 | 310 | func TestFilterDHCP(t *testing.T) { 311 | someIP := mustParseIP(t, "10.1.2.3") 312 | 313 | testCases := []struct { 314 | TestName string 315 | SpoofFor []string 316 | DontSpoofFor []string 317 | IgnoreDHCPv6NoFQDN bool 318 | DryMode bool 319 | 320 | PeerIP net.IP 321 | PeerHostnames []string 322 | 323 | ShouldRespond bool 324 | }{ 325 | { 326 | TestName: "regular", 327 | PeerIP: someIP, 328 | PeerHostnames: []string{"foo"}, 329 | ShouldRespond: true, 330 | }, 331 | { 332 | TestName: "dry", 333 | DryMode: true, 334 | PeerIP: someIP, 335 | PeerHostnames: []string{"foo"}, 336 | ShouldRespond: false, 337 | }, 338 | { 339 | TestName: "dry+spooffor", 340 | SpoofFor: []string{someIP.String(), "foo"}, 341 | DryMode: true, 342 | PeerIP: someIP, 343 | PeerHostnames: []string{"foo"}, 344 | ShouldRespond: false, 345 | }, 346 | { 347 | TestName: "fqdn", 348 | PeerIP: someIP, 349 | PeerHostnames: []string{"foo"}, 350 | IgnoreDHCPv6NoFQDN: true, 351 | ShouldRespond: true, 352 | }, 353 | { 354 | TestName: "ignore no fqdn", 355 | PeerIP: someIP, 356 | PeerHostnames: []string{}, 357 | IgnoreDHCPv6NoFQDN: true, 358 | ShouldRespond: false, 359 | }, 360 | { 361 | TestName: "spoof for ignore", 362 | SpoofFor: []string{"x"}, 363 | PeerIP: someIP, 364 | PeerHostnames: []string{"foo"}, 365 | ShouldRespond: false, 366 | }, 367 | { 368 | TestName: "dont spoof for", 369 | DontSpoofFor: []string{"somehost"}, // resolves to 192.168.0.5 370 | PeerIP: mustParseIP(t, "192.168.0.5"), 371 | PeerHostnames: []string{"foo"}, 372 | ShouldRespond: false, 373 | }, 374 | { 375 | TestName: "dont spoof for", 376 | DontSpoofFor: []string{"192.168.0.5"}, 377 | PeerIP: mustParseIP(t, "192.168.0.5"), 378 | PeerHostnames: []string{"foo"}, 379 | ShouldRespond: false, 380 | }, 381 | { 382 | TestName: "ignore whole domain", 383 | DontSpoofFor: []string{".domain"}, 384 | PeerIP: someIP, 385 | PeerHostnames: []string{"test.domain"}, 386 | ShouldRespond: false, 387 | }, 388 | } 389 | 390 | hostMatcherLookupFunction = func(host string) ([]net.IP, error) { 391 | switch host { 392 | case "somehost": 393 | return []net.IP{mustParseIP(t, "192.168.0.5")}, nil 394 | default: 395 | return nil, nil 396 | } 397 | } 398 | 399 | for i, testCase := range testCases { 400 | testCase := testCase 401 | 402 | testName := testCase.TestName 403 | if testName == "" { 404 | testName = strconv.Itoa(i) 405 | } 406 | 407 | t.Run("test_"+testName, func(t *testing.T) { 408 | cfg := Config{ 409 | SpoofFor: asHostMatchers(testCase.SpoofFor), 410 | DontSpoofFor: asHostMatchers(testCase.DontSpoofFor), 411 | DryMode: testCase.DryMode, 412 | IgnoreDHCPv6NoFQDN: testCase.IgnoreDHCPv6NoFQDN, 413 | } 414 | 415 | shouldRespond, _ := shouldRespondToDHCP(cfg, 416 | peerInfo{IP: testCase.PeerIP, Hostnames: testCase.PeerHostnames}) 417 | if shouldRespond != testCase.ShouldRespond { 418 | t.Errorf("shouldRespondToDHCP returned %v instead of %v", 419 | shouldRespond, testCase.ShouldRespond) 420 | } 421 | }) 422 | } 423 | } 424 | 425 | func mustParseIP(tb testing.TB, ip string) net.IP { 426 | tb.Helper() 427 | 428 | parsedIP := net.ParseIP(ip) 429 | if parsedIP == nil { 430 | tb.Fatalf("cannot parse IP %s", ip) 431 | } 432 | 433 | return parsedIP 434 | } 435 | -------------------------------------------------------------------------------- /local_name_resolution.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/binary" 6 | "encoding/hex" 7 | "fmt" 8 | "net" 9 | "runtime" 10 | "strings" 11 | "sync" 12 | "unicode" 13 | 14 | "golang.org/x/sync/errgroup" 15 | ) 16 | 17 | const ( 18 | netBIOSPort = 137 19 | 20 | mDNSMulticastIPv4 = "224.0.0.251" 21 | mDNSMulticastIPv6 = "ff02::fb" 22 | mDNSPort = 5353 23 | 24 | llmnrMulticastIPv4 = "224.0.0.252" 25 | llmnrMulticastIPv6 = "ff02::1:3" 26 | llmnrPort = 5355 27 | ) 28 | 29 | // RunNetBIOSResponder creates a listener for NetBIOS name resolution requests. 30 | func RunNetBIOSResponder(ctx context.Context, logger *Logger, config Config) error { //nolint:cyclop 31 | var wg sync.WaitGroup 32 | 33 | addrs, err := config.Interface.Addrs() 34 | if err != nil { 35 | return fmt.Errorf("listing addresses on interface %q: %w", config.Interface.Name, err) 36 | } 37 | 38 | activeListenAddresses := map[string]bool{} 39 | 40 | for _, addr := range addrs { 41 | ip, ok := addr.(*net.IPNet) 42 | if !ok { 43 | return fmt.Errorf("cannot extract IP address from network") 44 | } 45 | 46 | if ip.IP.To4() == nil { 47 | continue 48 | } 49 | 50 | listenIP, err := subnetBroadcastListenIP(ip) 51 | if err != nil { 52 | return fmt.Errorf("calculate subnet broadcast IP: %w", err) 53 | } 54 | 55 | if activeListenAddresses[listenIP.String()] { 56 | continue 57 | } 58 | 59 | listenAddr := &net.UDPAddr{IP: listenIP, Port: netBIOSPort} 60 | 61 | conn, err := net.ListenUDP("udp4", listenAddr) 62 | if err != nil { 63 | errStr := err.Error() 64 | if runtime.GOOS == osWindows && strings.Contains(err.Error(), "Only one usage of each socket address") { 65 | errStr += " (try disabling NetBIOS: Interface Status->" 66 | errStr += "Properties->TCP/IPv4->Advanced->WINS->Disable NetBIOS over TCP/IP)" 67 | } 68 | 69 | return fmt.Errorf(errStr) 70 | } 71 | 72 | activeListenAddresses[listenIP.String()] = true 73 | 74 | wg.Add(1) 75 | 76 | go func() { 77 | defer wg.Done() 78 | defer conn.Close() //nolint:errcheck 79 | 80 | logger.Infof("listening via UDP on %s", listenAddr) 81 | 82 | err = RunDNSHandlerOnUDPConnection(ctx, conn, logger, config) 83 | if err != nil { 84 | logger.Errorf(err.Error()) 85 | } 86 | }() 87 | } 88 | 89 | wg.Wait() 90 | 91 | return nil 92 | } 93 | 94 | // subnetBroadcastListenIP returns the IP to listen on for NetBIOS broadcasts 95 | // which is the highest IP in the subnet for Linux and the regular IP for 96 | // Windows. 97 | func subnetBroadcastListenIP(ip *net.IPNet) (net.IP, error) { 98 | ipv4 := ip.IP.To4() 99 | if ipv4 == nil { 100 | return nil, fmt.Errorf("invalid argument: IPv6 instead of IPv4") 101 | } 102 | 103 | if runtime.GOOS == osWindows { 104 | return ipv4, nil 105 | } 106 | 107 | rawIP := binary.BigEndian.Uint32(ipv4) 108 | rawMask := binary.BigEndian.Uint32(net.IP(ip.Mask).To4()) 109 | broadcastIP := make(net.IP, len(ipv4)) 110 | 111 | binary.BigEndian.PutUint32(broadcastIP, rawIP|^rawMask) 112 | 113 | return broadcastIP, nil 114 | } 115 | 116 | func decodeNetBIOSEncoding(netBIOSName string) string { 117 | netBIOSName = normalizedName(netBIOSName) 118 | 119 | if len(netBIOSName)%2 != 0 { 120 | return netBIOSName 121 | } 122 | 123 | decodedName := "" 124 | 125 | for i := 0; i < len(netBIOSName); i += 2 { 126 | higher := netBIOSName[i] - 'A' 127 | lower := netBIOSName[i+1] - 'A' 128 | 129 | full := higher<<4 | lower //nolint:gomnd 130 | decodedName += string(full) 131 | } 132 | 133 | return decodedName 134 | } 135 | 136 | func decodeNetBIOSHostname(netBIOSName string) string { 137 | decodedName := decodeNetBIOSEncoding(netBIOSName) 138 | if decodedName == "" { 139 | return "" 140 | } 141 | 142 | for { 143 | suffix := decodedName[len(decodedName)-1] 144 | 145 | if unicode.IsGraphic(rune(suffix)) { 146 | break 147 | } 148 | 149 | decodedName = strings.TrimRight(decodedName, string(suffix)) 150 | } 151 | 152 | return strings.TrimSpace(decodedName) 153 | } 154 | 155 | // The following constants hold the names of the NetBIOS suffixes 156 | // (https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-nbte/ 157 | // 6dbf0972-bb15-4f29-afeb-baaae98416ed#Appendix_A_2). 158 | const ( 159 | NetBIOSSuffixWorkstationService = "Workstation Name" 160 | NetBIOSSuffixWindowsMessengerService = "Messenger Service" 161 | NetBIOSSuffixRemoteAccessServer = "Remote Access Server" 162 | NetBIOSSuffixNetDDEService = "NetDDE Service" 163 | NetBIOSSuffixFileService = "File Service" 164 | NetBIOSSuffixRemoteAccessServiceClient = "Remote Access Client" 165 | NetBIOSSuffixMSExchangeServerInterchange = "MS Exchange Service Interchange" 166 | NetBIOSSuffixMSExchangeStore = "MS Exchange Store" 167 | NetBIOSSuffixMSExchangeDirectory = "MS Exchange Directory" 168 | NetBIOSSuffixLotusNotesServerService = "Lotus Notes Server" 169 | NetBIOSSuffixLotusNotes = "Lotus Notes" 170 | NetBIOSSuffixModemSharingServerService = "Modem Sharing Server" 171 | NetBIOSSuffixModemSharingClientService = "Modem Sharing Client" 172 | NetBIOSSuffixSMSClientsRemoteControl = "SMS Clients Remote Control" 173 | NetBIOSSuffixSMSAdministratorsRemoteControlTool = "SMS Admin Remote Control Tool" 174 | NetBIOSSuffixSMSClientsRemoteChat = "SMS Clients Remote Chat" 175 | NetBIOSSuffixSMSClientsRemoteTransfer = "SMS Clients Remote Transfer" 176 | NetBIOSSuffixDECPathworksTCPIPService = "DEC Pathworks TCPIP Service" 177 | NetBIOSSuffixMacAfeeAntivirus = "McAfee Antivirus" 178 | NetBIOSSuffixMSExchangeMTA = "MS Exchange MTA" 179 | NetBIOSSuffixMSExchangeIMC = "MS Exchange IMC" 180 | NetBIOSSuffixNetworkMonitorAgent = "Network Monitor Agent" 181 | NetBIOSSuffixNetworkMonitorApplication = "Network Monitor Application" 182 | NetBIOSSuffixDomainMasterBrowser = "Primary DC" 183 | NetBIOSSuffixMasterBrowser = "Master Browser" 184 | NetBIOSSuffixDomainControllers = "Domain Controllers" 185 | NetBIOSSuffixBrowserServiceElections = "Browser Server Elections" 186 | NetBIOSSuffixMSBrowse = "MSBROWSE Master Browser" 187 | ) 188 | 189 | func decodeNetBIOSSuffix(netBIOSName string) string { //nolint:gocyclo,cyclop 190 | const decodedBIOSNameSize = 16 191 | 192 | decodedName := decodeNetBIOSEncoding(netBIOSName) 193 | if len(decodedName) != decodedBIOSNameSize { 194 | return "No Suffix" 195 | } 196 | 197 | //nolint:gomnd 198 | switch suffix := decodedName[decodedBIOSNameSize-1]; suffix { 199 | case 0x00: 200 | return NetBIOSSuffixWorkstationService 201 | case 0x01: 202 | if decodedName[decodedBIOSNameSize-2] == 0x02 { 203 | return NetBIOSSuffixMSBrowse 204 | } 205 | 206 | return NetBIOSSuffixWindowsMessengerService 207 | case 0x03: 208 | return NetBIOSSuffixWindowsMessengerService 209 | case 0x06: 210 | return NetBIOSSuffixRemoteAccessServer 211 | case 0x1C: 212 | return NetBIOSSuffixDomainControllers 213 | case 0x1D: 214 | return NetBIOSSuffixMasterBrowser 215 | case 0x1E: 216 | return NetBIOSSuffixBrowserServiceElections 217 | case 0x1F: 218 | return NetBIOSSuffixNetDDEService 219 | case 0x20: 220 | return NetBIOSSuffixFileService 221 | case 0x21: 222 | return NetBIOSSuffixRemoteAccessServiceClient 223 | case 0x22: 224 | return NetBIOSSuffixMSExchangeServerInterchange 225 | case 0x23: 226 | return NetBIOSSuffixMSExchangeStore 227 | case 0x24: 228 | return NetBIOSSuffixMSExchangeDirectory 229 | case 0x2B: 230 | return NetBIOSSuffixLotusNotesServerService 231 | case 0x2F, 0x33: 232 | return NetBIOSSuffixLotusNotes 233 | case 0x30: 234 | return NetBIOSSuffixModemSharingServerService 235 | case 0x31: 236 | return NetBIOSSuffixModemSharingClientService 237 | case 0x1B: 238 | return NetBIOSSuffixDomainMasterBrowser 239 | case 0x42: 240 | return NetBIOSSuffixMacAfeeAntivirus 241 | case 0x43: 242 | return NetBIOSSuffixSMSClientsRemoteControl 243 | case 0x44: 244 | return NetBIOSSuffixSMSAdministratorsRemoteControlTool 245 | case 0x45: 246 | return NetBIOSSuffixSMSClientsRemoteChat 247 | case 0x46: 248 | return NetBIOSSuffixSMSClientsRemoteTransfer 249 | case 0x4C, 0x52: 250 | return NetBIOSSuffixDECPathworksTCPIPService 251 | case 0x87: 252 | return NetBIOSSuffixMSExchangeMTA 253 | case 0x6A: 254 | return NetBIOSSuffixMSExchangeIMC 255 | case 0xBE: 256 | return NetBIOSSuffixNetworkMonitorAgent 257 | case 0xBF: 258 | return NetBIOSSuffixNetworkMonitorApplication 259 | default: 260 | return fmt.Sprintf("Suffix 0x%02x", suffix) 261 | } 262 | } 263 | 264 | func encodeNetBIOSLocator(ip net.IP) string { 265 | return "0000" + hex.EncodeToString(ip.To4()) 266 | } 267 | 268 | // RunMDNSResponder creates a listener for mDNS requests. 269 | func RunMDNSResponder(ctx context.Context, logger *Logger, config Config) error { //nolint:dupl 270 | errGroup, ctx := errgroup.WithContext(ctx) 271 | 272 | if hasIPv4Address(config.Interface) { 273 | errGroup.Go(func() error { 274 | listenAddr := &net.UDPAddr{IP: net.ParseIP(mDNSMulticastIPv4), Port: mDNSPort} 275 | 276 | conn, err := ListenUDPMulticast(config.Interface, listenAddr) 277 | if err != nil { 278 | return fmt.Errorf("listen: %w", err) 279 | } 280 | 281 | defer conn.Close() //nolint:errcheck 282 | 283 | logger.Infof("listening via UDP on %s", listenAddr) 284 | 285 | err = RunDNSHandlerOnUDPConnection(ctx, conn, logger, config) 286 | if err != nil { 287 | return err 288 | } 289 | 290 | return nil 291 | }) 292 | } 293 | 294 | if hasIPv6Address(config.Interface) && !config.NoIPv6LNR { 295 | errGroup.Go(func() error { 296 | listenAddr := &net.UDPAddr{ 297 | IP: net.ParseIP(mDNSMulticastIPv6), 298 | Port: mDNSPort, 299 | Zone: config.Interface.Name, 300 | } 301 | 302 | conn, err := ListenUDPMulticast(config.Interface, listenAddr) 303 | if err != nil { 304 | return fmt.Errorf("listen: %w", err) 305 | } 306 | 307 | defer conn.Close() //nolint:errcheck 308 | 309 | logger.Infof("listening via UDP on %s", listenAddr) 310 | 311 | err = RunDNSHandlerOnUDPConnection(ctx, conn, logger, config) 312 | if err != nil { 313 | return err 314 | } 315 | 316 | return nil 317 | }) 318 | } 319 | 320 | return errGroup.Wait() 321 | } 322 | 323 | // RunLLMNRResponder creates a listener for LLMNR requests. 324 | func RunLLMNRResponder(ctx context.Context, logger *Logger, config Config) error { //nolint:dupl 325 | errGroup, ctx := errgroup.WithContext(ctx) 326 | 327 | if hasIPv4Address(config.Interface) { 328 | errGroup.Go(func() error { 329 | listenAddr := &net.UDPAddr{IP: net.ParseIP(llmnrMulticastIPv4), Port: llmnrPort} 330 | 331 | conn, err := ListenUDPMulticast(config.Interface, listenAddr) 332 | if err != nil { 333 | return fmt.Errorf("listen: %w", err) 334 | } 335 | 336 | defer conn.Close() //nolint:errcheck 337 | 338 | logger.Infof("listening via UDP on %s", listenAddr) 339 | 340 | err = RunDNSHandlerOnUDPConnection(ctx, conn, logger, config) 341 | if err != nil { 342 | return err 343 | } 344 | 345 | return nil 346 | }) 347 | } 348 | 349 | if hasIPv6Address(config.Interface) && !config.NoIPv6LNR { 350 | errGroup.Go(func() error { 351 | listenAddr := &net.UDPAddr{ 352 | IP: net.ParseIP(llmnrMulticastIPv6), 353 | Port: llmnrPort, 354 | Zone: config.Interface.Name, 355 | } 356 | 357 | conn, err := ListenUDPMulticast(config.Interface, listenAddr) 358 | if err != nil { 359 | return fmt.Errorf("listen: %w", err) 360 | } 361 | 362 | defer conn.Close() //nolint:errcheck 363 | 364 | logger.Infof("listening via UDP on %s", listenAddr) 365 | 366 | err = RunDNSHandlerOnUDPConnection(ctx, conn, logger, config) 367 | if err != nil { 368 | return err 369 | } 370 | 371 | return nil 372 | }) 373 | } 374 | 375 | return errGroup.Wait() 376 | } 377 | 378 | func hasIPv6Address(iface *net.Interface) bool { 379 | addrs, err := iface.Addrs() 380 | if err != nil { 381 | return false 382 | } 383 | 384 | for _, addr := range addrs { 385 | ip, ok := addr.(*net.IPNet) 386 | if !ok { 387 | continue 388 | } 389 | 390 | if ip.IP.To4() == nil { 391 | return true 392 | } 393 | } 394 | 395 | return false 396 | } 397 | 398 | func hasIPv4Address(iface *net.Interface) bool { 399 | addrs, err := iface.Addrs() 400 | if err != nil { 401 | return false 402 | } 403 | 404 | for _, addr := range addrs { 405 | ip, ok := addr.(*net.IPNet) 406 | if !ok { 407 | continue 408 | } 409 | 410 | if ip.IP.To4() != nil { 411 | return true 412 | } 413 | } 414 | 415 | return false 416 | } 417 | -------------------------------------------------------------------------------- /dhcpv6.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/binary" 6 | "errors" 7 | "fmt" 8 | "math/rand" 9 | "net" 10 | "strings" 11 | "time" 12 | 13 | "github.com/insomniacslk/dhcp/dhcpv6" 14 | "github.com/insomniacslk/dhcp/dhcpv6/server6" 15 | "github.com/insomniacslk/dhcp/iana" 16 | ) 17 | 18 | // DHCPv6 default values. 19 | const ( 20 | // The valid lifetime for the IPv6 prefix in the option, expressed in units 21 | // of seconds. A value of 0xFFFFFFFF represents infinity. 22 | dhcpv6DefaultValidLifetime = 60 * time.Second 23 | 24 | // The time at which the requesting router should contact the delegating 25 | // router from which the prefixes in the IA_PD were obtained to extend the 26 | // lifetimes of the prefixes delegated to the IA_PD; T1 is a time duration 27 | // relative to the current time expressed in units of seconds. 28 | dhcpv6T1 = 45 * time.Second 29 | 30 | // The time at which the requesting router should contact any available 31 | // delegating router to extend the lifetimes of the prefixes assigned to the 32 | // IA_PD; T2 is a time duration relative to the current time expressed in 33 | // units of seconds. 34 | dhcpv6T2 = 50 * time.Second 35 | ) 36 | 37 | // dhcpv6LinkLocalPrefix is the 64-bit link local IPv6 prefix. 38 | var dhcpv6LinkLocalPrefix = []byte{0xfe, 0x80, 0, 0, 0, 0, 0, 0} 39 | 40 | // DHCPv6Handler holds the state for of the DHCPv6 handler method Handler(). 41 | type DHCPv6Handler struct { 42 | logger *Logger 43 | serverID dhcpv6.Duid 44 | config Config 45 | } 46 | 47 | // NewDHCPv6Handler returns a DHCPv6Handler. 48 | func NewDHCPv6Handler(config Config, logger *Logger) *DHCPv6Handler { 49 | return &DHCPv6Handler{ 50 | logger: logger, 51 | config: config, 52 | serverID: dhcpv6.Duid{ 53 | Type: dhcpv6.DUID_LL, 54 | HwType: iana.HWTypeEthernet, 55 | LinkLayerAddr: config.Interface.HardwareAddr, 56 | }, 57 | } 58 | } 59 | 60 | // Handler implements a server6.Handler. 61 | func (h *DHCPv6Handler) Handler(conn net.PacketConn, peer net.Addr, m dhcpv6.DHCPv6) { 62 | err := h.handler(conn, peer, m) 63 | if err != nil { 64 | h.logger.Errorf(err.Error()) 65 | } 66 | } 67 | 68 | func (h *DHCPv6Handler) handler(conn net.PacketConn, peerAddr net.Addr, m dhcpv6.DHCPv6) error { 69 | answer, err := h.createResponse(peerAddr, m) 70 | if errors.Is(err, errNoResponse) { 71 | return nil 72 | } else if err != nil { 73 | return err 74 | } 75 | 76 | _, err = conn.WriteTo(answer.ToBytes(), peerAddr) 77 | if err != nil { 78 | return fmt.Errorf("write to %s: %w", peerAddr, err) 79 | } 80 | 81 | return nil 82 | } 83 | 84 | var errNoResponse = fmt.Errorf("no response") 85 | 86 | //nolint:cyclop 87 | func (h *DHCPv6Handler) createResponse(peerAddr net.Addr, m dhcpv6.DHCPv6) (*dhcpv6.Message, error) { 88 | msg, err := m.GetInnerMessage() 89 | if err != nil { 90 | return nil, fmt.Errorf("get inner message: %w", err) 91 | } 92 | 93 | peer := newPeerInfo(peerAddr, msg) 94 | 95 | shouldRespond, reason := shouldRespondToDHCP(h.config, peer) 96 | if !shouldRespond { 97 | h.logger.IgnoreDHCP(m.Type().String(), peer, reason) 98 | 99 | return nil, errNoResponse 100 | } 101 | 102 | var answer *dhcpv6.Message 103 | 104 | switch m.Type() { 105 | case dhcpv6.MessageTypeSolicit: 106 | answer, err = h.handleSolicit(msg, peer) 107 | case dhcpv6.MessageTypeRequest, dhcpv6.MessageTypeRebind, dhcpv6.MessageTypeRenew: 108 | answer, err = h.handleRequestRebindRenew(msg, peer) 109 | case dhcpv6.MessageTypeConfirm: 110 | answer, err = h.handleConfirm(msg, peer) 111 | case dhcpv6.MessageTypeRelease: 112 | answer, err = h.handleRelease(msg, peer) 113 | case dhcpv6.MessageTypeInformationRequest: 114 | h.logger.Debugf("ignoring %s from %s", msg.Type(), peer) 115 | 116 | return nil, errNoResponse 117 | default: 118 | h.logger.Debugf("unhandled DHCP message from %s:\n%s", peer, msg.Summary()) 119 | 120 | return nil, errNoResponse 121 | } 122 | 123 | if err != nil { 124 | return nil, fmt.Errorf("configure response to %T from %s: %w", msg.Type(), peer, err) 125 | } 126 | 127 | if answer == nil { 128 | return nil, fmt.Errorf("answer to %T from %s was not configured", msg.Type(), peer) 129 | } 130 | 131 | return answer, nil 132 | } 133 | 134 | func (h *DHCPv6Handler) handleSolicit(msg *dhcpv6.Message, peer peerInfo) (*dhcpv6.Message, error) { 135 | iaNA, err := extractIANA(msg) 136 | if err != nil { 137 | return nil, fmt.Errorf("extract IANA: %w", err) 138 | } 139 | 140 | ip, opts, err := h.configureResponseOpts(iaNA, msg, peer) 141 | if err != nil { 142 | return nil, fmt.Errorf("configure response options: %w", err) 143 | } 144 | 145 | answer, err := dhcpv6.NewAdvertiseFromSolicit(msg, opts...) 146 | if err != nil { 147 | return nil, fmt.Errorf("create ADVERTISE: %w", err) 148 | } 149 | 150 | h.logger.DHCP(msg.Type(), peer, ip) 151 | 152 | return answer, nil 153 | } 154 | 155 | func (h *DHCPv6Handler) handleRequestRebindRenew(msg *dhcpv6.Message, peer peerInfo) (*dhcpv6.Message, error) { 156 | iaNA, err := extractIANA(msg) 157 | if err != nil { 158 | return nil, fmt.Errorf("extract IANA: %w", err) 159 | } 160 | 161 | ip, opts, err := h.configureResponseOpts(iaNA, msg, peer) 162 | if err != nil { 163 | return nil, fmt.Errorf("configure response options: %w", err) 164 | } 165 | 166 | answer, err := dhcpv6.NewReplyFromMessage(msg, opts...) 167 | if err != nil { 168 | return nil, fmt.Errorf("create REPLY: %w", err) 169 | } 170 | 171 | h.logger.DHCP(msg.Type(), peer, ip) 172 | 173 | return answer, nil 174 | } 175 | 176 | func (h *DHCPv6Handler) handleConfirm(msg *dhcpv6.Message, peer peerInfo) (*dhcpv6.Message, error) { 177 | answer, err := dhcpv6.NewReplyFromMessage(msg, 178 | dhcpv6.WithServerID(h.serverID), 179 | dhcpv6.WithDNS(h.config.LocalIPv6), 180 | dhcpv6.WithOption(&dhcpv6.OptStatusCode{ 181 | StatusCode: iana.StatusNotOnLink, 182 | StatusMessage: iana.StatusNotOnLink.String(), 183 | })) 184 | if err != nil { 185 | return nil, fmt.Errorf("create REPLY: %w", err) 186 | } 187 | 188 | h.logger.Debugf("rejecting %s from %s", msg.Type().String(), peer) 189 | 190 | return answer, nil 191 | } 192 | 193 | func (h *DHCPv6Handler) handleRelease(msg *dhcpv6.Message, peer peerInfo) (*dhcpv6.Message, error) { 194 | iaNAs, err := extractIANAs(msg) 195 | if err != nil { 196 | return nil, err 197 | } 198 | 199 | opts := []dhcpv6.Modifier{ 200 | dhcpv6.WithOption(&dhcpv6.OptStatusCode{ 201 | StatusCode: iana.StatusSuccess, 202 | StatusMessage: iana.StatusSuccess.String(), 203 | }), 204 | dhcpv6.WithServerID(h.serverID), 205 | } 206 | 207 | // send status NoBinding for each address 208 | for _, iaNA := range iaNAs { 209 | opts = append(opts, dhcpv6.WithOption(&dhcpv6.OptIANA{ 210 | IaId: iaNA.IaId, 211 | Options: dhcpv6.IdentityOptions{ 212 | Options: []dhcpv6.Option{ 213 | &dhcpv6.OptStatusCode{ 214 | StatusCode: iana.StatusNoBinding, 215 | StatusMessage: iana.StatusNoBinding.String(), 216 | }, 217 | }, 218 | }, 219 | })) 220 | } 221 | 222 | answer, err := dhcpv6.NewReplyFromMessage(msg, opts...) 223 | if err != nil { 224 | return nil, fmt.Errorf("create REPLY: %w", err) 225 | } 226 | 227 | h.logger.Debugf("aggreeing to RELEASE from %s", peer) 228 | 229 | return answer, nil 230 | } 231 | 232 | // configureResponseOpts returns the IP that should be assigned based on the 233 | // request IA_NA and the modifiers to configure the response with that IP and 234 | // the DNS server configured in the DHCPv6Handler. 235 | func (h *DHCPv6Handler) configureResponseOpts(requestIANA *dhcpv6.OptIANA, 236 | msg *dhcpv6.Message, peer peerInfo, 237 | ) (net.IP, []dhcpv6.Modifier, error) { 238 | cid := msg.GetOneOption(dhcpv6.OptionClientID) 239 | if cid == nil { 240 | return nil, nil, fmt.Errorf("no client ID option from DHCPv6 message") 241 | } 242 | 243 | duid, err := dhcpv6.DuidFromBytes(cid.ToBytes()) 244 | if err != nil { 245 | return nil, nil, fmt.Errorf("deserialize DUI") 246 | } 247 | 248 | var leasedIP net.IP 249 | 250 | if duid.LinkLayerAddr == nil { 251 | h.logger.Debugf("DUID does not contain link layer address") 252 | 253 | randomIP, err := generateDeterministicRandomAddress(peer.IP) 254 | if err != nil { 255 | h.logger.Debugf("could not generate deterministic address (using SLAAC IP instead): %v", err) 256 | 257 | leasedIP = peer.IP 258 | } else { 259 | leasedIP = randomIP 260 | } 261 | } else { 262 | if h.logger != nil { 263 | go h.logger.HostInfoCache.SaveMACFromIP(peer.IP, duid.LinkLayerAddr) 264 | } 265 | 266 | leasedIP = append(leasedIP, dhcpv6LinkLocalPrefix...) 267 | leasedIP = append(leasedIP, 0, 0) 268 | leasedIP = append(leasedIP, duid.LinkLayerAddr...) 269 | } 270 | 271 | // if the IP has the first bit after the prefix set, Windows won't route 272 | // queries via this IP and use the regular self-generated link-local address 273 | // instead. 274 | leasedIP[8] |= 0b10000000 275 | 276 | return leasedIP, []dhcpv6.Modifier{ 277 | dhcpv6.WithServerID(h.serverID), 278 | dhcpv6.WithDNS(h.config.LocalIPv6), 279 | dhcpv6.WithOption(&dhcpv6.OptIANA{ 280 | IaId: requestIANA.IaId, 281 | T1: dhcpv6T1, 282 | T2: dhcpv6T2, 283 | Options: dhcpv6.IdentityOptions{ 284 | Options: []dhcpv6.Option{ 285 | &dhcpv6.OptIAAddress{ 286 | IPv6Addr: leasedIP, 287 | PreferredLifetime: h.config.LeaseLifetime, 288 | ValidLifetime: h.config.LeaseLifetime, 289 | }, 290 | }, 291 | }, 292 | }), 293 | }, nil 294 | } 295 | 296 | func generateDeterministicRandomAddress(peer net.IP) (net.IP, error) { 297 | if len(peer) != net.IPv6len { 298 | return nil, fmt.Errorf("invalid length of IPv6 address: %d bytes", len(peer)) 299 | } 300 | 301 | prefixLength := net.IPv6len / 2 //nolint:gomnd 302 | 303 | seed := binary.LittleEndian.Uint64(peer[prefixLength:]) 304 | 305 | deterministicAddress := make([]byte, prefixLength) 306 | 307 | n, err := rand.New(rand.NewSource(int64(seed))).Read(deterministicAddress) //nolint:gosec 308 | if err != nil { 309 | return nil, err 310 | } 311 | 312 | if n != prefixLength { 313 | return nil, fmt.Errorf("read %d random bytes instead of %d", n, prefixLength) 314 | } 315 | 316 | var newIP net.IP 317 | newIP = append(newIP, dhcpv6LinkLocalPrefix...) 318 | newIP = append(newIP, deterministicAddress...) 319 | 320 | return newIP, nil 321 | } 322 | 323 | func extractIANA(innerMessage *dhcpv6.Message) (*dhcpv6.OptIANA, error) { 324 | iaNAOpt := innerMessage.GetOneOption(dhcpv6.OptionIANA) 325 | if iaNAOpt == nil { 326 | return nil, fmt.Errorf("message does not contain IANA:\n%s", innerMessage.Summary()) 327 | } 328 | 329 | iaNA, ok := iaNAOpt.(*dhcpv6.OptIANA) 330 | if !ok { 331 | return nil, fmt.Errorf("unexpected type for IANA option: %T", iaNAOpt) 332 | } 333 | 334 | return iaNA, nil 335 | } 336 | 337 | func extractIANAs(innerMessage *dhcpv6.Message) ([]*dhcpv6.OptIANA, error) { 338 | iaNAOpts := innerMessage.GetOption(dhcpv6.OptionIANA) 339 | if iaNAOpts == nil { 340 | return nil, fmt.Errorf("message does not contain IANAs:\n%s", innerMessage.Summary()) 341 | } 342 | 343 | iaNAs := make([]*dhcpv6.OptIANA, 0, len(iaNAOpts)) 344 | 345 | for i, iaNAOpt := range iaNAOpts { 346 | iaNA, ok := iaNAOpt.(*dhcpv6.OptIANA) 347 | if !ok { 348 | return nil, fmt.Errorf("unexpected type for IANA option %d: %T", i, iaNAOpt) 349 | } 350 | 351 | iaNAs = append(iaNAs, iaNA) 352 | } 353 | 354 | return iaNAs, nil 355 | } 356 | 357 | // RunDHCPv6Server starts a DHCPv6 server which assigns a DNS server. 358 | func RunDHCPv6Server(ctx context.Context, logger *Logger, config Config) error { 359 | listenAddr := &net.UDPAddr{ 360 | IP: dhcpv6.AllDHCPRelayAgentsAndServers, 361 | Port: dhcpv6.DefaultServerPort, 362 | Zone: config.Interface.Name, 363 | } 364 | 365 | dhcvpv6Handler := NewDHCPv6Handler(config, logger) 366 | 367 | conn, err := ListenUDPMulticast(config.Interface, listenAddr) 368 | if err != nil { 369 | return err 370 | } 371 | 372 | server, err := server6.NewServer(config.Interface.Name, nil, dhcvpv6Handler.Handler, 373 | server6.WithConn(conn)) 374 | if err != nil { 375 | return fmt.Errorf("starting DHCPv6 server: %w", err) 376 | } 377 | 378 | go func() { 379 | <-ctx.Done() 380 | 381 | _ = server.Close() 382 | }() 383 | 384 | logger.Infof("listening via UDP on %s", listenAddr) 385 | 386 | err = server.Serve() 387 | 388 | // if the server is stopped via ctx, we suppress the resulting errors that 389 | // result from server.Close closing the connection. 390 | if ctx.Err() != nil { 391 | return nil //nolint:nilerr 392 | } 393 | 394 | return err 395 | } 396 | 397 | type peerInfo struct { 398 | IP net.IP 399 | Hostnames []string 400 | } 401 | 402 | func newPeerInfo(addr net.Addr, innerMessage *dhcpv6.Message) peerInfo { 403 | p := peerInfo{ 404 | IP: addrToIP(addr), 405 | } 406 | 407 | fqdnOpt := innerMessage.GetOneOption(dhcpv6.OptionFQDN) 408 | if fqdnOpt == nil { 409 | return p 410 | } 411 | 412 | fqdn, ok := fqdnOpt.(*dhcpv6.OptFQDN) 413 | if !ok { 414 | return p 415 | } 416 | 417 | p.Hostnames = fqdn.DomainName.Labels 418 | 419 | return p 420 | } 421 | 422 | // String returns the string representation of a peerInfo. 423 | func (p peerInfo) String() string { 424 | if len(p.Hostnames) > 0 { 425 | return p.IP.String() + " (" + strings.Join(p.Hostnames, ", ") + ")" 426 | } 427 | 428 | return p.IP.String() 429 | } 430 | 431 | func addrToIP(addr net.Addr) net.IP { 432 | udpAddr, ok := addr.(*net.UDPAddr) 433 | if ok { 434 | return udpAddr.IP 435 | } 436 | 437 | addrString := addr.String() 438 | 439 | for strings.Contains(addrString, "/") || strings.Contains(addrString, "%") { 440 | addrString = strings.SplitN(addrString, "/", 2)[0] //nolint:gomnd 441 | addrString = strings.SplitN(addrString, "%", 2)[0] //nolint:gomnd 442 | } 443 | 444 | splitAddr, _, err := net.SplitHostPort(addrString) 445 | if err == nil { 446 | addrString = splitAddr 447 | } 448 | 449 | return net.ParseIP(addrString) 450 | } 451 | -------------------------------------------------------------------------------- /cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/spf13/pflag" 12 | ) 13 | 14 | var stdErr = os.Stderr // this is used to make stderr redirectable without side effects 15 | 16 | // Config holds the configuration. 17 | type Config struct { 18 | RelayIPv4 net.IP 19 | RelayIPv6 net.IP 20 | SOAHostname string 21 | Interface *net.Interface 22 | TTL time.Duration 23 | LeaseLifetime time.Duration 24 | RouterLifetime time.Duration 25 | LocalIPv6 net.IP 26 | RAPeriod time.Duration 27 | 28 | NoDHCPv6DNSTakeover bool 29 | NoDHCPv6 bool 30 | NoDNS bool 31 | NoRA bool 32 | NoMDNS bool 33 | NoNetBIOS bool 34 | NoLLMNR bool 35 | NoLocalNameResolution bool 36 | NoIPv6LNR bool 37 | 38 | Spoof []string 39 | DontSpoof []string 40 | SpoofFor []*hostMatcher 41 | DontSpoofFor []*hostMatcher 42 | SpoofTypes *spoofTypes 43 | IgnoreDHCPv6NoFQDN bool 44 | DryMode bool 45 | 46 | StopAfter time.Duration 47 | Verbose bool 48 | NoColor bool 49 | NoTimestamps bool 50 | LogFileName string 51 | NoHostInfo bool 52 | HideIgnored bool 53 | RedirectStderr bool 54 | ListInterfaces bool 55 | 56 | spoofFor []string 57 | dontSpoofFor []string 58 | spoofTypes []string 59 | } 60 | 61 | // PrintSummary prints a summary of some important configuration parameters. 62 | // 63 | //nolint:forbidigo 64 | func (c Config) PrintSummary() { //nolint:cyclop 65 | fmt.Printf("Listening on interface: %s\n", c.Interface.Name) 66 | 67 | if c.LogFileName != "" { 68 | fmt.Printf("Logging to file: %s\n", c.LogFileName) 69 | } 70 | 71 | if c.RelayIPv4 != nil { 72 | fmt.Printf("IPv4 relayed to: %s\n", c.RelayIPv4) 73 | } 74 | 75 | if c.RelayIPv6 != nil { 76 | fmt.Printf("IPv6 relayed to: %s\n", c.RelayIPv6) 77 | } 78 | 79 | if c.SOAHostname != "" { 80 | fmt.Printf("SOA Requests answered with: %s\n", c.SOAHostname) 81 | } 82 | 83 | switch { 84 | case c.DryMode: 85 | var raNotice string 86 | 87 | if !c.NoRA && !c.NoDHCPv6DNSTakeover && !c.NoDHCPv6 { 88 | raNotice = " (RA is still enabled)" 89 | } 90 | 91 | fmt.Println("Dry Mode: DHCPv6 and name resolution queries will not be answered" + raNotice) 92 | default: 93 | if len(c.Spoof) != 0 { 94 | fmt.Println("Answering queries for: " + strings.Join(c.Spoof, ", ")) 95 | } 96 | 97 | if len(c.DontSpoof) != 0 { 98 | fmt.Println("Ignoring queries for: " + strings.Join(c.DontSpoof, ", ")) 99 | } 100 | 101 | if len(c.SpoofFor) != 0 { 102 | fmt.Println("Answering queries from: " + joinHosts(c.SpoofFor, ", ")) 103 | } 104 | 105 | if len(c.DontSpoofFor) != 0 { 106 | fmt.Println("Ignoring queries from: " + joinHosts(c.DontSpoofFor, ", ")) 107 | } 108 | 109 | if len(c.spoofTypes) != 0 { 110 | fmt.Println("Answering only queries of type: " + strings.Join(toUpper(c.spoofTypes), ", ")) 111 | } 112 | } 113 | 114 | if c.StopAfter > 0 { 115 | fmt.Printf("Pretender will automatically terminate after: %s\n", formatStopAfter(c.StopAfter)) 116 | } 117 | 118 | fmt.Println() 119 | } 120 | 121 | //nolint:forbidigo,cyclop 122 | func configFromCLI() (config Config, logger *Logger, err error) { 123 | var ( 124 | interfaceName string 125 | printVersion bool 126 | ) 127 | 128 | pflag.StringVarP(&interfaceName, "interface", "i", defaultInterface, 129 | "Interface to bind on, supports auto-detection by IPv4 or IPv6") 130 | pflag.IPVarP(&config.RelayIPv4, "ip4", "4", defaultRelayIPv4, 131 | "Relay IPv4 address with which queries are answered, supports auto-detection by interface or IPv6") 132 | pflag.IPVarP(&config.RelayIPv6, "ip6", "6", defaultRelayIPv6, 133 | "Relay IPv6 address with which queries are answered, supports auto-detection by interface or IPv4") 134 | pflag.StringVar(&config.SOAHostname, "soa-hostname", defaultSOAHostname, 135 | "Hostname for the SOA record (useful for Kerberos relaying)") 136 | 137 | pflag.BoolVar(&config.NoDHCPv6DNSTakeover, "no-dhcp-dns", defaultNoDHCPv6DNSTakeover, 138 | "Disable DHCPv6 DNS takeover attack (DHCPv6 and DNS)") 139 | pflag.BoolVar(&config.NoDHCPv6, "no-dhcp", defaultNoDHCPv6, "Disable DHCPv6 spoofing") 140 | pflag.BoolVar(&config.NoDNS, "no-dns", defaultNoDNS, "Disable DNS spoofing") 141 | pflag.BoolVar(&config.NoRA, "no-ra", defaultNoRA, "Disable router advertisements") 142 | pflag.BoolVar(&config.NoMDNS, "no-mdns", defaultNoMDNS, "Disable mDNS spoofing") 143 | pflag.BoolVar(&config.NoNetBIOS, "no-netbios", defaultNoNetBIOS, "Disable NetBIOS-NS spoofing") 144 | pflag.BoolVar(&config.NoLLMNR, "no-llmnr", defaultNoLLMNR, "Disable LLMNR spoofing") 145 | pflag.BoolVar(&config.NoLocalNameResolution, "no-lnr", defaultNoLocalNameResolution, 146 | "Disable local name resolution spoofing (mDNS, LLMNR, NetBIOS-NS)") 147 | pflag.BoolVar(&config.NoIPv6LNR, "no-ipv6-lnr", defaultNoIPv6LNR, 148 | "Disable mDNS and LLMNR via IPv6 (useful with allowlist or blocklist)") 149 | 150 | pflag.StringSliceVar(&config.Spoof, "spoof", defaultSpoof, 151 | "Only spoof these domains, if domain starts with a dot, all subdomains with match (allowlist)") 152 | pflag.StringSliceVar(&config.DontSpoof, "dont-spoof", defaultDontSpoof, 153 | "Do not spoof these domains, if domain starts with a dot, all subdomains with match (blocklist)") 154 | pflag.StringSliceVar(&config.spoofFor, "spoof-for", defaultSpoofFor, 155 | "Only spoof DHCPv6 and name resolution for these `hosts` (allowlist of IPs or hostnames)") 156 | pflag.StringSliceVar(&config.dontSpoofFor, "dont-spoof-for", defaultDontSpoofFor, 157 | "Do not spoof DHCPv6 and name resolution for these `hosts` (blocklist of IPs or hostnames)") 158 | pflag.StringSliceVar(&config.spoofTypes, "spoof-types", defaultSpoofTypes, 159 | "Only spoof these query `types` (A, AAA, ANY, SOA, all types are spoofed if empty)") 160 | pflag.BoolVar(&config.IgnoreDHCPv6NoFQDN, "ignore-nofqdn", defaultIgnoreDHCPv6NoFQDN, 161 | "Ignore DHCPv6 messages where the client did not include its FQDN (useful with allowlist or blocklists)") 162 | pflag.BoolVar(&config.DryMode, "dry", defaultDryMode, "Do not spoof name resolution at all, only log queries") 163 | 164 | pflag.DurationVarP(&config.TTL, "ttl", "t", defaultTTL, "Time to live for name resolution responses") 165 | pflag.DurationVar(&config.LeaseLifetime, "lease-lifetime", defaultLeaseLifetime, "DHCPv6 IP lease lifetime") 166 | pflag.DurationVar(&config.RouterLifetime, "router-lifetime", defaultRARouterLifetime, 167 | "Router lifetime specified in router advertisements") 168 | pflag.DurationVar(&config.RAPeriod, "ra-period", defaultRAPeriod, "Time period between router advertisements") 169 | 170 | pflag.DurationVar(&config.StopAfter, "stop-after", defaultStopAfter, "Stop running after this duration") 171 | pflag.BoolVarP(&config.Verbose, "verbose", "v", defaultVerbose, "Print debug information") 172 | pflag.BoolVar(&config.NoColor, "no-color", defaultNoColor, "Disables output styling") 173 | pflag.BoolVar(&config.NoTimestamps, "no-timestamps", defaultNoTimestamps, "Disables timestamps in the output") 174 | pflag.StringVarP(&config.LogFileName, "log", "l", defaultLogFileName, "Log `file` name") 175 | pflag.BoolVar(&printVersion, "version", false, "Print version information") 176 | pflag.BoolVar(&config.NoHostInfo, "no-host-info", defaultNoHostInfo, "Do not gather host information") 177 | pflag.BoolVar(&config.HideIgnored, "hide-ignored", defaultHideIgnored, "Do not log ignored queries") 178 | pflag.BoolVar(&config.RedirectStderr, "redirect-stderr", defaultRedirectStderr, "Redirect stderr to stdout") 179 | pflag.BoolVar(&config.ListInterfaces, "interfaces", defaultListInterfaces, 180 | "List interfaces and their addresses (the other options have no effect, except for --no-color)") 181 | 182 | pflag.CommandLine.SortFlags = false 183 | 184 | pflag.Parse() 185 | 186 | if pflag.NArg() > 0 { 187 | fmt.Printf("%s does not take positional arguments, only the following flags\n\n", os.Args[0]) 188 | pflag.PrintDefaults() 189 | 190 | os.Exit(1) 191 | } 192 | 193 | if config.RedirectStderr { 194 | stdErr = os.Stdout 195 | } 196 | 197 | if config.NoDNS && config.NoDHCPv6 { 198 | config.NoDHCPv6DNSTakeover = true 199 | } 200 | 201 | if config.NoMDNS && config.NoLLMNR && config.NoNetBIOS { 202 | config.NoLocalNameResolution = true 203 | } 204 | 205 | if config.NoLocalNameResolution { 206 | config.NoNetBIOS = true 207 | config.NoLLMNR = true 208 | config.NoMDNS = true 209 | } 210 | 211 | if printVersion { 212 | fmt.Println(longVersion()) 213 | } else { 214 | fmt.Println(shortVersion()) 215 | } 216 | 217 | if printVersion { 218 | os.Exit(0) 219 | } 220 | 221 | if config.ListInterfaces { 222 | err := listInterfaces(os.Stdout, config.NoColor) 223 | if err != nil { 224 | fmt.Fprintf(stdErr, "Error: %v", err) 225 | 226 | os.Exit(1) 227 | } 228 | 229 | os.Exit(0) 230 | } 231 | 232 | logger = NewLogger().WithPrefix("Setup") 233 | logger.Verbose = config.Verbose 234 | logger.NoColor = config.NoColor 235 | logger.PrintTimestamps = !config.NoTimestamps 236 | logger.HideIgnored = config.HideIgnored 237 | logger.NoHostInfo = config.NoHostInfo 238 | 239 | if config.LogFileName != "" { 240 | f, err := os.OpenFile(config.LogFileName, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600) 241 | if err != nil { 242 | return config, logger, fmt.Errorf("log file: %w", err) 243 | } 244 | 245 | logger.LogFile = f 246 | } 247 | 248 | config.Interface, err = chooseInterface(interfaceName, config.RelayIPv4, config.RelayIPv6) 249 | if err != nil { 250 | return config, logger, interfaceError{err} 251 | } 252 | 253 | var errIPv4, errIPv6 error 254 | 255 | config.RelayIPv4, errIPv4 = autoConfigureRelayIPv4(config.Interface, config.RelayIPv4, config.RelayIPv6) 256 | config.RelayIPv6, errIPv6 = autoConfigureRelayIPv6(config.Interface, config.RelayIPv4, config.RelayIPv6) 257 | 258 | if config.RelayIPv4 == nil && !config.NoNetBIOS { 259 | logger.Errorf("no relay IPv4 configured (required for NetBIOS-NS): %v", errIPv4) 260 | 261 | config.NoNetBIOS = true 262 | } 263 | 264 | if config.RelayIPv6 == nil && config.RelayIPv4 == nil { 265 | return config, logger, fmt.Errorf("no relay IP configured: %s and %s", errIPv4, errIPv6) //nolint:errorlint 266 | } 267 | 268 | config.LocalIPv6, err = getLinkLocalIPv6Address(config.Interface) 269 | if err != nil && !config.NoDHCPv6DNSTakeover { 270 | logger.Errorf("cannot detect link local IPv6 (required for DHCPv6 DNS takeover): %v", err) 271 | 272 | config.NoDHCPv6DNSTakeover = true 273 | } 274 | 275 | config.SpoofFor = asHostMatchers(config.spoofFor) 276 | config.DontSpoofFor = asHostMatchers(config.dontSpoofFor) 277 | 278 | config.SpoofTypes, err = parseSpoofTypes(config.spoofTypes) 279 | if err != nil { 280 | return config, logger, fmt.Errorf("parsing --spoof-types: %w", err) 281 | } 282 | 283 | config.PrintSummary() 284 | 285 | return config, logger, nil 286 | } 287 | 288 | func joinHosts(hosts []*hostMatcher, sep string) string { 289 | hostStrings := make([]string, 0, len(hosts)) 290 | 291 | for _, ip := range hosts { 292 | hostStrings = append(hostStrings, ip.String()) 293 | } 294 | 295 | return strings.Join(hostStrings, sep) 296 | } 297 | 298 | func isLocalIP(ip net.IP) bool { 299 | ifaces, err := net.Interfaces() 300 | if err != nil { 301 | return false 302 | } 303 | 304 | for _, iface := range ifaces { 305 | addrs, err := iface.Addrs() 306 | if err != nil { 307 | continue 308 | } 309 | 310 | for _, addr := range addrs { 311 | ifaceIP, ok := addr.(*net.IPNet) 312 | if !ok { 313 | continue 314 | } 315 | 316 | if net.IP.Equal(ifaceIP.IP, ip) { 317 | return true 318 | } 319 | } 320 | } 321 | 322 | return false 323 | } 324 | 325 | //nolint:cyclop 326 | func chooseInterface(interfaceName string, ipv4, ipv6 net.IP) (*net.Interface, error) { 327 | if interfaceName != "" { 328 | return net.InterfaceByName(interfaceName) 329 | } 330 | 331 | var candidateByIPv4, candidateByIPv6 *net.Interface 332 | 333 | var err error 334 | 335 | if ipv4 != nil { 336 | if ipv4.To4() == nil { 337 | return nil, fmt.Errorf("expected IPv4 address but got IPv6 address %s", ipv4) 338 | } 339 | 340 | candidateByIPv4, err = getInterfaceByIP(ipv4) 341 | if err != nil { 342 | return nil, fmt.Errorf("choose interface by IP: %w", err) 343 | } 344 | } 345 | 346 | if ipv6 != nil { 347 | if ipv6.To4() != nil { 348 | return nil, fmt.Errorf("expected IPv6 address but got IPv6 address %s", ipv6) 349 | } 350 | 351 | candidateByIPv6, err = getInterfaceByIP(ipv6) 352 | if err != nil { 353 | return nil, fmt.Errorf("choose interface by IP: %w", err) 354 | } 355 | } 356 | 357 | if ipv4 == nil && ipv6 == nil { 358 | return nil, fmt.Errorf("interface cannot be automatically detected when no relay addresses are provided") 359 | } 360 | 361 | if candidateByIPv4 != nil && candidateByIPv6 == nil { 362 | return candidateByIPv4, nil 363 | } 364 | 365 | if candidateByIPv6 != nil && candidateByIPv4 == nil { 366 | return candidateByIPv6, nil 367 | } 368 | 369 | if candidateByIPv4 == nil && candidateByIPv6 == nil { 370 | ifaces, err := net.Interfaces() 371 | if err != nil { 372 | return nil, fmt.Errorf("listing interfaces: %w", err) 373 | } 374 | 375 | ifaces = withoutLoopback(ifaces) 376 | if len(ifaces) != 0 { 377 | return nil, fmt.Errorf("no possible candidates to determine interface") 378 | } 379 | 380 | return &ifaces[0], nil 381 | } 382 | 383 | if candidateByIPv4.Name != candidateByIPv6.Name { 384 | return nil, fmt.Errorf("cannot determine interface: conflict between %s (by IPv4) and %s (by IPv6)", 385 | candidateByIPv4.Name, candidateByIPv6.Name) 386 | } 387 | 388 | return candidateByIPv4, nil 389 | } 390 | 391 | func withoutLoopback(ifaces []net.Interface) []net.Interface { 392 | filtered := []net.Interface{} 393 | 394 | for _, iface := range ifaces { 395 | if iface.Flags&net.FlagLoopback == 0 { 396 | filtered = append(filtered, iface) 397 | } 398 | } 399 | 400 | return filtered 401 | } 402 | 403 | func getInterfaceByIP(ip net.IP) (*net.Interface, error) { 404 | ifaces, err := net.Interfaces() 405 | if err != nil { 406 | return nil, fmt.Errorf("listing interfaces: %w", err) 407 | } 408 | 409 | for _, iface := range ifaces { 410 | addrs, err := iface.Addrs() 411 | if err != nil { 412 | continue 413 | } 414 | 415 | for _, addr := range addrs { 416 | ifaceIP, ok := addr.(*net.IPNet) 417 | if !ok { 418 | return nil, fmt.Errorf("unexpected IP type: %T", addr) 419 | } 420 | 421 | if net.IP.Equal(ifaceIP.IP, ip) { 422 | return &iface, nil 423 | } 424 | } 425 | } 426 | 427 | return nil, fmt.Errorf("cannot find interface with IP %s", ip) 428 | } 429 | 430 | func autoConfigureRelayIPv4(iface *net.Interface, ipv4, ipv6 net.IP) (net.IP, error) { 431 | if ipv4 == nil { 432 | if ipv6 != nil && !isLocalIP(ipv6) { 433 | return nil, fmt.Errorf("IPv4 auto detection disabled when remote IPv6 relay is configured") 434 | } 435 | 436 | ip, err := detectLocalIPv4(iface) 437 | if err != nil { 438 | return nil, fmt.Errorf("cannot auto-detect IPv4: %w", err) 439 | } 440 | 441 | return ip, nil 442 | } 443 | 444 | if ipv4.To4() == nil { 445 | return nil, fmt.Errorf("expected IPv4 address but got IPv6 address %s", ipv4) 446 | } 447 | 448 | return ipv4, nil 449 | } 450 | 451 | func detectLocalIPv4(iface *net.Interface) (net.IP, error) { 452 | addrs, err := iface.Addrs() 453 | if err != nil { 454 | return nil, fmt.Errorf("listing addresses of interface %q: %w", iface.Name, err) 455 | } 456 | 457 | candidates := []net.IP{} 458 | 459 | for _, addr := range addrs { 460 | ip, ok := addr.(*net.IPNet) 461 | if !ok { 462 | return nil, fmt.Errorf("unexpected IP type: %T", addr) 463 | } 464 | 465 | if ip.IP.To4() == nil { 466 | continue 467 | } 468 | 469 | candidates = append(candidates, ip.IP) 470 | } 471 | 472 | if len(candidates) > 1 { 473 | return nil, fmt.Errorf("multiple possible IPv4 addresses on interface %q", iface.Name) 474 | } 475 | 476 | if len(candidates) == 0 { 477 | return nil, fmt.Errorf("interface %q has no IPv4 addresses", iface.Name) 478 | } 479 | 480 | return candidates[0], nil 481 | } 482 | 483 | func autoConfigureRelayIPv6(iface *net.Interface, ipv4, ipv6 net.IP) (net.IP, error) { 484 | if ipv6 == nil { 485 | if ipv4 != nil && !isLocalIP(ipv4) { 486 | return nil, fmt.Errorf("IPv4 auto detection disabled when remote IPv4 relay is configured") 487 | } 488 | 489 | ip, err := detectLocalIPv6(iface) 490 | if err != nil { 491 | return nil, fmt.Errorf("cannot auto-detect IPv6: %w", err) 492 | } 493 | 494 | return ip, nil 495 | } 496 | 497 | if ipv6.To4() != nil { 498 | return nil, fmt.Errorf("expected IPv6 address but got IPv4 address %s", ipv6) 499 | } 500 | 501 | return ipv6, nil 502 | } 503 | 504 | func detectLocalIPv6(iface *net.Interface) (net.IP, error) { 505 | addrs, err := iface.Addrs() 506 | if err != nil { 507 | return nil, fmt.Errorf("listing addresses of interface %q: %w", iface.Name, err) 508 | } 509 | 510 | candidates := []net.IP{} 511 | 512 | for _, addr := range addrs { 513 | ip, ok := addr.(*net.IPNet) 514 | if !ok { 515 | return nil, fmt.Errorf("unexpected IP type: %T", addr) 516 | } 517 | 518 | if ip.IP.To4() != nil || ip.IP.IsLinkLocalMulticast() { 519 | continue 520 | } 521 | 522 | candidates = append(candidates, ip.IP) 523 | } 524 | 525 | if len(candidates) > 1 { 526 | return nil, fmt.Errorf("multiple possible IPv6 addresses on interface %q", iface.Name) 527 | } 528 | 529 | if len(candidates) == 0 { 530 | return nil, fmt.Errorf("interface %q has no IPv6 addresses", iface.Name) 531 | } 532 | 533 | return candidates[0], nil 534 | } 535 | 536 | func getLinkLocalIPv6Address(iface *net.Interface) (net.IP, error) { 537 | addrs, err := iface.Addrs() 538 | if err != nil { 539 | return nil, fmt.Errorf("gather addresses of interface %q: %w", iface.Name, err) 540 | } 541 | 542 | for _, addr := range addrs { 543 | ip, ok := addr.(*net.IPNet) 544 | if !ok { 545 | return nil, fmt.Errorf("unexpected IP type: %T", addr) 546 | } 547 | 548 | if ip.IP.IsLinkLocalUnicast() { 549 | return ip.IP, nil 550 | } 551 | } 552 | 553 | return nil, fmt.Errorf("interface %q has no link local IPv6 address", iface.Name) 554 | } 555 | 556 | func listInterfaces(w io.Writer, noColor bool) error { 557 | ifaces, err := net.Interfaces() 558 | if err != nil { 559 | return fmt.Errorf("listing interfaces: %w", err) 560 | } 561 | 562 | indent := " " 563 | 564 | for _, iface := range ifaces { 565 | fmt.Fprintf(w, "\n%2d: %s %s:\n", iface.Index, 566 | styled(iface.Name, noColor, bold, fgRed), styled("<"+iface.Flags.String()+">", noColor, faint)) 567 | 568 | if iface.HardwareAddr != nil { 569 | fmt.Fprintf(w, "%sMAC : %s\n", indent, styled(iface.HardwareAddr.String(), noColor, bold)) 570 | } 571 | 572 | addrs, err := iface.Addrs() 573 | if err != nil { 574 | return fmt.Errorf("gather addresses of interface %q: %w", iface.Name, err) 575 | } 576 | 577 | for _, addr := range addrs { 578 | ip, ok := addr.(*net.IPNet) 579 | if !ok { 580 | return fmt.Errorf("unexpected IP type: %T", addr) 581 | } 582 | 583 | ipType := "IPv6" 584 | if ip.IP.To4() != nil { 585 | ipType = "IPv4" 586 | } 587 | 588 | fmt.Fprintf(w, "%s%s: %s %s\n", indent, ipType, styled(addr.String(), noColor, bold), 589 | styled(ipProperties(ip.IP), noColor, faint)) 590 | } 591 | } 592 | 593 | return nil 594 | } 595 | 596 | func ipProperties(ip net.IP) string { 597 | properties := []string{} 598 | if ip.IsLoopback() { 599 | properties = append(properties, "loopback") 600 | } 601 | 602 | if ip.IsGlobalUnicast() { 603 | properties = append(properties, "global unicast") 604 | } 605 | 606 | if ip.IsLinkLocalUnicast() { 607 | properties = append(properties, "link local unicast") 608 | } 609 | 610 | if ip.IsMulticast() { 611 | properties = append(properties, "multicast") 612 | } 613 | 614 | return "<" + strings.Join(properties, "|") + ">" 615 | } 616 | 617 | type interfaceError struct { 618 | error 619 | } 620 | 621 | func (ie interfaceError) Error() string { 622 | return ie.error.Error() 623 | } 624 | 625 | func formatStopAfter(d time.Duration) string { 626 | stopDuration := strings.TrimSuffix(strings.TrimSuffix(d.String(), "0s"), "0m") 627 | if d < time.Minute { 628 | return stopDuration 629 | } 630 | 631 | now := time.Now() 632 | stopTime := now.Add(d) 633 | 634 | dateFormatter := "03:04pm" 635 | if stopTime.Day() != now.Day() { 636 | dateFormatter += " 02-Jan-06" 637 | } 638 | 639 | return fmt.Sprintf("%s (%s)", stopDuration, stopTime.Format(dateFormatter)) 640 | } 641 | 642 | func toUpper(elements []string) []string { 643 | upper := make([]string, 0, len(elements)) 644 | 645 | for _, el := range elements { 646 | upper = append(upper, strings.ToUpper(el)) 647 | } 648 | 649 | return upper 650 | } 651 | --------------------------------------------------------------------------------