Skip to content

Commit a46de3d

Browse files
authored
wsl (#27)
can now scan windows interfaces from inside wsl. closes #26 windows interfaces marked win: better docker support, can now use docker sockets. docker interface scans are now much faster.
1 parent cf2efb3 commit a46de3d

10 files changed

Lines changed: 575 additions & 21 deletions

File tree

internal/scanner/ip4/arp.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import (
55
"runtime"
66
"strings"
77

8+
"github.com/backendsystems/nibble/internal/scanner/ip4/docker"
89
"github.com/backendsystems/nibble/internal/scanner/ip4/linux"
910
"github.com/backendsystems/nibble/internal/scanner/ip4/macos"
1011
"github.com/backendsystems/nibble/internal/scanner/ip4/windows"
12+
"github.com/backendsystems/nibble/internal/scanner/ip4/wsl"
1113
)
1214

1315
// lookupMacFromCache reads the OS ARP cache to find a MAC without needing root
@@ -19,6 +21,11 @@ func lookupMacFromCache(ip string) string {
1921
if runtime.GOOS == "darwin" {
2022
return macos.LookupMAC(ip)
2123
}
24+
if wsl.IsWSL() {
25+
if mac := wsl.LookupMAC(ip); mac != "" {
26+
return mac
27+
}
28+
}
2229
return linux.LookupMAC(ip)
2330
}
2431

@@ -30,7 +37,22 @@ type NeighborEntry struct {
3037

3138
// visibleNeighbors returns neighbors currently visible in the OS ARP
3239
// table for the selected interface and subnet
33-
func visibleNeighbors(ifaceName string, subnet *net.IPNet) []NeighborEntry {
40+
// visibleNeighbors returns neighbors from the OS ARP table or Docker daemon.
41+
// The second return value is true when the result is exhaustive — i.e. came
42+
// from the Docker socket — meaning no subnet sweep is needed.
43+
func (s *Scanner) visibleNeighbors(ifaceName string, subnet *net.IPNet) ([]NeighborEntry, bool) {
44+
// For Docker bridge interfaces, the daemon knows exactly which containers
45+
// are running — skip the ARP table entirely.
46+
if _, ok := s.dockerIfaces[ifaceName]; ok {
47+
if dockerNeighbors := docker.ContainerNeighbors(ifaceName, subnet); len(dockerNeighbors) > 0 {
48+
out := make([]NeighborEntry, len(dockerNeighbors))
49+
for i, n := range dockerNeighbors {
50+
out[i] = NeighborEntry{IP: n.IP, MAC: n.MAC}
51+
}
52+
return out, true
53+
}
54+
}
55+
3456
var rows []NeighborEntry
3557
switch runtime.GOOS {
3658
case "windows":
@@ -42,8 +64,14 @@ func visibleNeighbors(ifaceName string, subnet *net.IPNet) []NeighborEntry {
4264
rows = append(rows, NeighborEntry{IP: row.IP, MAC: row.MAC})
4365
}
4466
default:
45-
for _, row := range linux.Neighbors(ifaceName) {
46-
rows = append(rows, NeighborEntry{IP: row.IP, MAC: row.MAC})
67+
if strings.HasPrefix(ifaceName, "win:") {
68+
for _, row := range wsl.Neighbors(ifaceName) {
69+
rows = append(rows, NeighborEntry{IP: row.IP, MAC: row.MAC})
70+
}
71+
} else {
72+
for _, row := range linux.Neighbors(ifaceName) {
73+
rows = append(rows, NeighborEntry{IP: row.IP, MAC: row.MAC})
74+
}
4775
}
4876
}
4977

@@ -69,7 +97,7 @@ func visibleNeighbors(ifaceName string, subnet *net.IPNet) []NeighborEntry {
6997
seen[row.IP] = struct{}{}
7098
out = append(out, row)
7199
}
72-
return out
100+
return out, false
73101
}
74102

75103
func isSubnetBroadcastIpv4(ip net.IP, subnet *net.IPNet) bool {
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package docker
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net"
7+
"net/http"
8+
"os"
9+
"strings"
10+
"time"
11+
)
12+
13+
const socketPath = "/var/run/docker.sock"
14+
15+
// Neighbor is a container visible on a Docker bridge network.
16+
type Neighbor struct {
17+
IP string
18+
MAC string
19+
}
20+
21+
func client() *http.Client {
22+
return &http.Client{
23+
Timeout: 2 * time.Second,
24+
Transport: &http.Transport{
25+
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
26+
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
27+
},
28+
},
29+
}
30+
}
31+
32+
// Networks queries the Docker daemon and returns a map of kernel bridge interface
33+
// name (e.g. "br-09ff0748a767") to human-readable Docker network name
34+
// (e.g. "myproject_default"). Returns nil if the socket is unavailable.
35+
func Networks() map[string]string {
36+
resp, err := client().Get("http://localhost/networks")
37+
if err != nil {
38+
return nil
39+
}
40+
defer resp.Body.Close()
41+
42+
var networks []struct {
43+
Name string `json:"Name"`
44+
ID string `json:"Id"`
45+
}
46+
if err := json.NewDecoder(resp.Body).Decode(&networks); err != nil {
47+
return nil
48+
}
49+
50+
result := make(map[string]string, len(networks))
51+
for _, n := range networks {
52+
if len(n.ID) >= 12 {
53+
result["br-"+n.ID[:12]] = n.Name
54+
}
55+
}
56+
return result
57+
}
58+
59+
// IsBridgeIface returns true if the kernel interface name belongs to a Docker
60+
// network, using the map returned by Networks(). Falls back to name-prefix
61+
// heuristic (docker0, br-*) when the socket was unavailable and networks is nil.
62+
func IsBridgeIface(kernelName string, networks map[string]string) bool {
63+
if networks != nil {
64+
_, ok := networks[kernelName]
65+
return ok
66+
}
67+
return kernelName == "docker0" || strings.HasPrefix(kernelName, "br-")
68+
}
69+
70+
// BridgeHasPeers returns true if the bridge has at least one veth peer attached,
71+
// meaning at least one container is connected. Uses sysfs rather than ARP cache
72+
// so idle-but-running containers are not incorrectly filtered out.
73+
func BridgeHasPeers(name string) bool {
74+
entries, err := os.ReadDir("/sys/class/net/" + name + "/brif")
75+
return err == nil && len(entries) > 0
76+
}
77+
78+
// ApplyNetworkNames renames any Docker bridge entries in ifaces and addrsByIface
79+
// to their human-readable Docker network names. Returns the set of display names
80+
// that correspond to Docker networks, for use in scan-time lookups.
81+
func ApplyNetworkNames(ifaces []net.Interface, addrsByIface map[string][]net.Addr, networks map[string]string) map[string]struct{} {
82+
dockerDisplayNames := make(map[string]struct{}, len(networks))
83+
for i, iface := range ifaces {
84+
dockerName, ok := networks[iface.Name]
85+
if !ok {
86+
// No rename — track under the kernel name if it's a bridge.
87+
if strings.HasPrefix(iface.Name, "br-") || iface.Name == "docker0" {
88+
dockerDisplayNames[iface.Name] = struct{}{}
89+
}
90+
continue
91+
}
92+
addrs := addrsByIface[iface.Name]
93+
delete(addrsByIface, iface.Name)
94+
addrsByIface[dockerName] = addrs
95+
ifaces[i].Name = dockerName
96+
dockerDisplayNames[dockerName] = struct{}{}
97+
}
98+
return dockerDisplayNames
99+
}
100+
101+
// ContainerNeighbors queries the Docker daemon and returns the IP and MAC of
102+
// every running container attached to networkName that falls within subnet.
103+
// Returns nil if the socket is unavailable.
104+
func ContainerNeighbors(networkName string, subnet *net.IPNet) []Neighbor {
105+
resp, err := client().Get("http://localhost/containers/json")
106+
if err != nil {
107+
return nil
108+
}
109+
defer resp.Body.Close()
110+
111+
var containers []struct {
112+
NetworkSettings struct {
113+
Networks map[string]struct {
114+
IPAddress string `json:"IPAddress"`
115+
MacAddress string `json:"MacAddress"`
116+
} `json:"Networks"`
117+
} `json:"NetworkSettings"`
118+
}
119+
if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil {
120+
return nil
121+
}
122+
123+
var out []Neighbor
124+
for _, c := range containers {
125+
for name, ns := range c.NetworkSettings.Networks {
126+
if name != networkName {
127+
continue
128+
}
129+
ip := net.ParseIP(ns.IPAddress)
130+
if ip == nil || !subnet.Contains(ip) {
131+
continue
132+
}
133+
out = append(out, Neighbor{IP: ns.IPAddress, MAC: ns.MacAddress})
134+
}
135+
}
136+
return out
137+
}
138+
139+
// ClampToCIDR returns addrs with any IPv4 prefix longer than bits clamped to bits.
140+
func ClampToCIDR(addrs []net.Addr, bits int) []net.Addr {
141+
out := make([]net.Addr, 0, len(addrs))
142+
for _, addr := range addrs {
143+
ipnet, ok := addr.(*net.IPNet)
144+
if !ok {
145+
out = append(out, addr)
146+
continue
147+
}
148+
ones, total := ipnet.Mask.Size()
149+
if total == 32 && ones > bits {
150+
out = append(out, &net.IPNet{
151+
IP: ipnet.IP,
152+
Mask: net.CIDRMask(bits, 32),
153+
})
154+
} else {
155+
out = append(out, addr)
156+
}
157+
}
158+
return out
159+
}

internal/scanner/ip4/interfaces.go

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,28 @@ package ip4
33
import (
44
"net"
55
"net/netip"
6+
7+
"github.com/backendsystems/nibble/internal/scanner/ip4/docker"
8+
"github.com/backendsystems/nibble/internal/scanner/ip4/wsl"
69
)
710

811
// GetInterfaces returns active non-loopback interfaces with at least one IPv4 address.
12+
// When running inside WSL, Windows host interfaces are also included via interop.
913
func (s *Scanner) GetInterfaces() ([]net.Interface, map[string][]net.Addr, error) {
1014
sysIfaces, err := net.Interfaces()
1115
if err != nil {
1216
return nil, nil, err
1317
}
1418

19+
// Fetch Docker network info once — used for detection, clamping, and renaming.
20+
dockerNetworks := docker.Networks()
21+
1522
ifaces := make([]net.Interface, 0, len(sysIfaces))
1623
addrsByIface := make(map[string][]net.Addr, len(sysIfaces))
1724
for _, iface := range sysIfaces {
18-
// Skip loopback interfaces.
1925
if iface.Flags&net.FlagLoopback != 0 {
2026
continue
2127
}
22-
// Skip interfaces that are down.
2328
if iface.Flags&net.FlagUp == 0 {
2429
continue
2530
}
@@ -29,15 +34,92 @@ func (s *Scanner) GetInterfaces() ([]net.Interface, map[string][]net.Addr, error
2934
continue
3035
}
3136

32-
if hasIp4(addrs) {
33-
ifaces = append(ifaces, iface)
34-
addrsByIface[iface.Name] = addrs
37+
if !hasIp4(addrs) || wsl.IsWSLVirtualIface(iface.Name) {
38+
continue
39+
}
40+
41+
if docker.IsBridgeIface(iface.Name, dockerNetworks) {
42+
if !docker.BridgeHasPeers(iface.Name) {
43+
continue
44+
}
45+
addrs = docker.ClampToCIDR(addrs, 24)
3546
}
47+
48+
ifaces = append(ifaces, iface)
49+
addrsByIface[iface.Name] = addrs
3650
}
3751

52+
// In WSL, also include Windows host interfaces via interop.
53+
if wsl.IsWSL() {
54+
for _, wiface := range wsl.Interfaces() {
55+
ifaces = append(ifaces, net.Interface{
56+
Index: wiface.Index,
57+
Name: wiface.Name,
58+
HardwareAddr: wiface.HWAddr,
59+
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast,
60+
})
61+
addrsByIface[wiface.Name] = wiface.Addrs
62+
}
63+
}
64+
65+
ifaces, addrsByIface = deduplicateSubnets(ifaces, addrsByIface)
66+
s.dockerIfaces = docker.ApplyNetworkNames(ifaces, addrsByIface, dockerNetworks)
67+
3868
return ifaces, addrsByIface, nil
3969
}
4070

71+
// deduplicateSubnets removes interfaces whose IPv4 address is already contained
72+
// within another interface's subnet. This prevents showing redundant cards when
73+
// multiple interfaces share the same network (e.g. eth0/24 and services1/32 on
74+
// the same 192.168.65.0/24).
75+
func deduplicateSubnets(ifaces []net.Interface, addrsByIface map[string][]net.Addr) ([]net.Interface, map[string][]net.Addr) {
76+
// Collect all subnets with their prefix length so we prefer the broader one.
77+
type ifaceSubnet struct {
78+
ones int
79+
net *net.IPNet
80+
}
81+
var subnets []ifaceSubnet
82+
for _, iface := range ifaces {
83+
for _, addr := range addrsByIface[iface.Name] {
84+
ipnet, ok := addr.(*net.IPNet)
85+
if ok && ipnet.IP.To4() != nil {
86+
ones, _ := ipnet.Mask.Size()
87+
subnets = append(subnets, ifaceSubnet{ones: ones, net: ipnet})
88+
}
89+
}
90+
}
91+
92+
out := make([]net.Interface, 0, len(ifaces))
93+
outAddrs := make(map[string][]net.Addr, len(ifaces))
94+
for _, iface := range ifaces {
95+
covered := false
96+
for _, addr := range addrsByIface[iface.Name] {
97+
ipnet, ok := addr.(*net.IPNet)
98+
if !ok || ipnet.IP.To4() == nil {
99+
continue
100+
}
101+
ones, _ := ipnet.Mask.Size()
102+
for _, s := range subnets {
103+
if s.net == ipnet {
104+
continue // skip self
105+
}
106+
if s.ones < ones && s.net.Contains(ipnet.IP) {
107+
covered = true
108+
break
109+
}
110+
}
111+
if covered {
112+
break
113+
}
114+
}
115+
if !covered {
116+
out = append(out, iface)
117+
outAddrs[iface.Name] = addrsByIface[iface.Name]
118+
}
119+
}
120+
return out, outAddrs
121+
}
122+
41123
func hasIp4(addrs []net.Addr) bool {
42124
for _, addr := range addrs {
43125
ip, ok := parseAddr(addr.String())
@@ -49,17 +131,13 @@ func hasIp4(addrs []net.Addr) bool {
49131
}
50132

51133
func parseAddr(s string) (netip.Addr, bool) {
52-
// addresses may be CIDR "192.168.1.10/24"
53134
prefix, err := netip.ParsePrefix(s)
54135
if err == nil {
55136
return prefix.Addr(), true
56137
}
57-
58-
// or plain "192.168.1.10"
59138
ip, err := netip.ParseAddr(s)
60139
if err == nil {
61140
return ip, true
62141
}
63-
64142
return netip.Addr{}, false
65143
}

internal/scanner/ip4/neighbours.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"sync"
1010
"time"
1111

12+
"github.com/backendsystems/nibble/internal/scanner/ip4/wsl"
1213
"github.com/backendsystems/nibble/internal/scanner/shared"
1314
)
1415

@@ -22,6 +23,9 @@ var dialExtra = func() time.Duration {
2223
case "windows":
2324
return 50 * time.Millisecond
2425
default:
26+
if wsl.IsWSL() {
27+
return 50 * time.Millisecond
28+
}
2529
return 0
2630
}
2731
}()

0 commit comments

Comments
 (0)