Skip to content

Commit 14560fa

Browse files
committed
v0.4.25: Agent-side mDNS interface filtering, preferred IP TXT, --config flag, installer interface picker
1 parent 0e65d9a commit 14560fa

9 files changed

Lines changed: 362 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
All notable changes to SMART Sniffer are documented here.
44

5+
## v0.4.25 — 2026-03-20
6+
7+
### Added
8+
- **Agent: mDNS interface filtering** — auto-skips Docker, ZeroTier, Tailscale, WireGuard, and other virtual interfaces by default; only advertises on real LAN interfaces
9+
- **Agent: `advertise_interface` config option** — restrict mDNS to a specific interface (e.g., `advertise_interface: eth0`)
10+
- **Agent: `ip=` mDNS TXT record** — agent reports its preferred LAN IP so the HA integration doesn't have to guess
11+
- **Agent: `--config` flag** — specify a custom config file path (`smartha-agent --config /path/to/config.yaml`)
12+
- **Agent: `--interface` flag** — CLI override for mDNS interface (`smartha-agent --interface eth0`)
13+
- **Installer: interface picker** — during install, presents detected interfaces with labels (Docker, ZeroTier, etc.) and lets the user choose which to advertise on
14+
- **Integration: reads agent `ip=` TXT field** — trusts the agent's preferred IP over local scoring when available; falls back gracefully for older agents
15+
16+
### Fixed
17+
- Duplicate mDNS discoveries from Docker bridges, VPNs, and mDNS reflectors surfacing the same agent at multiple IPs
18+
- IPv6 addresses deprioritized in IP scoring (unreliable across VLANs in home networks)
19+
520
## v0.4.24 — 2026-03-20
621

722
### Fixed

agent/config.go

Lines changed: 192 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package main
33
import (
44
"flag"
55
"fmt"
6+
"net"
67
"os"
8+
"strings"
79
"time"
810

911
"gopkg.in/yaml.v3"
@@ -12,10 +14,11 @@ import (
1214
// Config holds all agent configuration. Values are resolved with this
1315
// precedence: CLI flags > config file > defaults.
1416
type Config struct {
15-
Port int `yaml:"port"`
16-
Token string `yaml:"token"`
17-
ScanInterval time.Duration `yaml:"scan_interval"`
18-
MDNS *bool `yaml:"mdns"` // pointer so we can detect "not set" vs "set to false"
17+
Port int `yaml:"port"`
18+
Token string `yaml:"token"`
19+
ScanInterval time.Duration `yaml:"scan_interval"`
20+
MDNS *bool `yaml:"mdns"` // pointer so we can detect "not set" vs "set to false"
21+
AdvertiseInterface string `yaml:"advertise_interface"` // restrict mDNS to this interface (e.g. "eth0")
1922
}
2023

2124
// defaultConfig returns sane defaults.
@@ -26,31 +29,61 @@ func defaultConfig() Config {
2629
}
2730
}
2831

32+
// defaultSkipPrefixes are interface name prefixes that are skipped when no
33+
// explicit advertise_interface is configured. These are almost never the
34+
// real LAN interface and cause duplicate/unreachable mDNS discoveries.
35+
var defaultSkipPrefixes = []string{
36+
"docker", // Docker bridge (docker0)
37+
"br-", // Docker custom networks
38+
"veth", // Docker/container veth pairs
39+
"zt", // ZeroTier VPN
40+
"tailscale", "ts", // Tailscale VPN
41+
"wg", // WireGuard VPN
42+
"virbr", // libvirt/KVM virtual bridge
43+
"vbox", // VirtualBox host-only
44+
"vmnet", // VMware host-only
45+
"lo", // Loopback
46+
}
47+
2948
// LoadConfig reads configuration from config.yaml (if present) then overlays
3049
// CLI flags. CLI flags always win.
3150
func LoadConfig() (*Config, error) {
3251
cfg := defaultConfig()
3352

53+
// --- Parse the --config flag first (before other flags) ---
54+
configPath := flag.String("config", "", "Path to config.yaml (default: auto-detect)")
55+
port := flag.Int("port", 0, "HTTP listen port (default 9099)")
56+
token := flag.String("token", "", "Bearer token for API auth (optional)")
57+
interval := flag.Duration("scan-interval", 0, "Drive rescan interval (e.g. 30s, 2m)")
58+
noMDNS := flag.Bool("no-mdns", false, "Disable mDNS/Zeroconf service advertisement")
59+
advIface := flag.String("interface", "", "Restrict mDNS advertisement to this network interface")
60+
flag.Parse()
61+
3462
// --- Attempt to load config.yaml ---
35-
// We look in the working directory first, then next to the binary.
36-
for _, path := range []string{"config.yaml", "/etc/smartha-agent/config.yaml"} {
37-
data, err := os.ReadFile(path)
63+
if *configPath != "" {
64+
// Explicit path — must exist.
65+
data, err := os.ReadFile(*configPath)
3866
if err != nil {
39-
continue // file not found — that's fine
67+
return nil, fmt.Errorf("reading config file %s: %w", *configPath, err)
4068
}
4169
if err := yaml.Unmarshal(data, &cfg); err != nil {
42-
return nil, fmt.Errorf("parsing %s: %w", path, err)
70+
return nil, fmt.Errorf("parsing %s: %w", *configPath, err)
71+
}
72+
} else {
73+
// Auto-detect: working directory first, then system path.
74+
for _, path := range []string{"config.yaml", "/etc/smartha-agent/config.yaml"} {
75+
data, err := os.ReadFile(path)
76+
if err != nil {
77+
continue // file not found — that's fine
78+
}
79+
if err := yaml.Unmarshal(data, &cfg); err != nil {
80+
return nil, fmt.Errorf("parsing %s: %w", path, err)
81+
}
82+
break
4383
}
44-
break
4584
}
4685

4786
// --- CLI flags (override file values) ---
48-
port := flag.Int("port", 0, "HTTP listen port (default 9099)")
49-
token := flag.String("token", "", "Bearer token for API auth (optional)")
50-
interval := flag.Duration("scan-interval", 0, "Drive rescan interval (e.g. 30s, 2m)")
51-
noMDNS := flag.Bool("no-mdns", false, "Disable mDNS/Zeroconf service advertisement")
52-
flag.Parse()
53-
5487
if *port != 0 {
5588
cfg.Port = *port
5689
}
@@ -64,6 +97,9 @@ func LoadConfig() (*Config, error) {
6497
f := false
6598
cfg.MDNS = &f
6699
}
100+
if *advIface != "" {
101+
cfg.AdvertiseInterface = *advIface
102+
}
67103

68104
// Sanity checks
69105
if cfg.Port < 1 || cfg.Port > 65535 {
@@ -83,3 +119,143 @@ func (c *Config) MDNSEnabled() bool {
83119
}
84120
return *c.MDNS
85121
}
122+
123+
// ResolveAdvertiseInterfaces returns the list of net.Interface to pass to
124+
// zeroconf.Register(). If advertise_interface is set, it returns just that
125+
// interface. Otherwise, it filters out known virtual/VPN interfaces.
126+
func (c *Config) ResolveAdvertiseInterfaces() ([]net.Interface, string) {
127+
// Explicit interface configured — use only that one.
128+
if c.AdvertiseInterface != "" {
129+
iface, err := net.InterfaceByName(c.AdvertiseInterface)
130+
if err != nil {
131+
return nil, fmt.Sprintf("WARNING: interface %q not found, advertising on all", c.AdvertiseInterface)
132+
}
133+
return []net.Interface{*iface}, fmt.Sprintf("interface %s", c.AdvertiseInterface)
134+
}
135+
136+
// No explicit interface — auto-filter known virtual interfaces.
137+
allIfaces, err := net.Interfaces()
138+
if err != nil {
139+
return nil, "all interfaces (could not enumerate)"
140+
}
141+
142+
var filtered []net.Interface
143+
var skipped []string
144+
for _, iface := range allIfaces {
145+
// Skip interfaces that are down.
146+
if iface.Flags&net.FlagUp == 0 {
147+
continue
148+
}
149+
// Skip loopback.
150+
if iface.Flags&net.FlagLoopback != 0 {
151+
continue
152+
}
153+
// Skip known virtual/VPN prefixes.
154+
nameLower := strings.ToLower(iface.Name)
155+
skip := false
156+
for _, prefix := range defaultSkipPrefixes {
157+
if strings.HasPrefix(nameLower, prefix) {
158+
skip = true
159+
skipped = append(skipped, iface.Name)
160+
break
161+
}
162+
}
163+
if !skip {
164+
filtered = append(filtered, iface)
165+
}
166+
}
167+
168+
if len(filtered) == 0 {
169+
// All interfaces were filtered — fall back to all.
170+
return nil, "all interfaces (auto-filter found none)"
171+
}
172+
173+
desc := interfaceNames(filtered)
174+
if len(skipped) > 0 {
175+
desc += " (skipped: " + strings.Join(skipped, ", ") + ")"
176+
}
177+
return filtered, desc
178+
}
179+
180+
// PreferredIP returns the best IP address from the given interfaces for
181+
// inclusion in the mDNS TXT record. Prefers 192.168.x / 10.x over other
182+
// ranges. Returns empty string if no suitable IP is found.
183+
func PreferredIP(ifaces []net.Interface) string {
184+
// If no interface filter, enumerate all.
185+
if len(ifaces) == 0 {
186+
var err error
187+
ifaces, err = net.Interfaces()
188+
if err != nil {
189+
return ""
190+
}
191+
}
192+
193+
type candidate struct {
194+
ip string
195+
score int
196+
}
197+
var candidates []candidate
198+
199+
for _, iface := range ifaces {
200+
// Skip known virtual interfaces.
201+
nameLower := strings.ToLower(iface.Name)
202+
isVirtual := false
203+
for _, prefix := range defaultSkipPrefixes {
204+
if strings.HasPrefix(nameLower, prefix) {
205+
isVirtual = true
206+
break
207+
}
208+
}
209+
210+
addrs, err := iface.Addrs()
211+
if err != nil {
212+
continue
213+
}
214+
for _, addr := range addrs {
215+
var ip net.IP
216+
switch v := addr.(type) {
217+
case *net.IPNet:
218+
ip = v.IP
219+
case *net.IPAddr:
220+
ip = v.IP
221+
}
222+
if ip == nil || ip.IsLoopback() || ip.To4() == nil {
223+
continue // skip IPv6 and loopback
224+
}
225+
ipStr := ip.String()
226+
score := 80
227+
if isVirtual {
228+
score = 90
229+
} else if strings.HasPrefix(ipStr, "192.168.") || strings.HasPrefix(ipStr, "10.") {
230+
score = 10
231+
} else if strings.HasPrefix(ipStr, "172.") {
232+
score = 50
233+
} else if strings.HasPrefix(ipStr, "100.") {
234+
score = 70
235+
}
236+
candidates = append(candidates, candidate{ipStr, score})
237+
}
238+
}
239+
240+
if len(candidates) == 0 {
241+
return ""
242+
}
243+
244+
// Find the best (lowest score).
245+
best := candidates[0]
246+
for _, c := range candidates[1:] {
247+
if c.score < best.score {
248+
best = c
249+
}
250+
}
251+
return best.ip
252+
}
253+
254+
// interfaceNames returns a comma-separated list of interface names.
255+
func interfaceNames(ifaces []net.Interface) string {
256+
names := make([]string, len(ifaces))
257+
for i, iface := range ifaces {
258+
names[i] = iface.Name
259+
}
260+
return strings.Join(names, ", ")
261+
}

agent/config.yaml.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,15 @@ scan_interval: 60s
1818
# Home Assistant can auto-discover it.
1919
# Set to false to disable (or use --no-mdns flag).
2020
mdns: true
21+
22+
# Restrict mDNS advertisement to a specific network interface.
23+
# When set, the agent only advertises on this interface and reports
24+
# its IP in the mDNS TXT record so Home Assistant connects to the
25+
# correct address.
26+
#
27+
# When not set (default), the agent auto-filters known virtual
28+
# interfaces (Docker, ZeroTier, Tailscale, WireGuard, etc.) and
29+
# advertises on all remaining physical interfaces.
30+
#
31+
# Examples: eth0, enp1s0, en0, bond0
32+
# advertise_interface: eth0

agent/main.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ func main() {
6767
mdnsLabel := "disabled"
6868
if cfg.MDNSEnabled() {
6969
mdnsLabel = "enabled"
70+
if cfg.AdvertiseInterface != "" {
71+
mdnsLabel += " (interface: " + cfg.AdvertiseInterface + ")"
72+
}
7073
}
7174
log.Printf("SMART Sniffer Agent v%s", version)
7275
log.Printf("smartctl version: %s", smartctlVer)
@@ -119,6 +122,15 @@ func main() {
119122
if cfg.Token != "" {
120123
authFlag = "1"
121124
}
125+
126+
// Resolve which interfaces to advertise on.
127+
ifaces, ifaceDesc := cfg.ResolveAdvertiseInterfaces()
128+
log.Printf("mDNS: interfaces: %s", ifaceDesc)
129+
130+
// Determine preferred IP for TXT record so the HA integration
131+
// doesn't have to guess which IP is the real LAN address.
132+
preferredIP := PreferredIP(ifaces)
133+
122134
txt := []string{
123135
"txtvers=1",
124136
"version=" + version,
@@ -127,8 +139,13 @@ func main() {
127139
"auth=" + authFlag,
128140
"drives=" + strconv.Itoa(len(drives)),
129141
}
142+
if preferredIP != "" {
143+
txt = append(txt, "ip="+preferredIP)
144+
log.Printf("mDNS: preferred IP: %s", preferredIP)
145+
}
146+
130147
instance := "smartha-" + hostname
131-
mdnsServer, err = zeroconf.Register(instance, "_smartha._tcp", "local.", cfg.Port, txt, nil)
148+
mdnsServer, err = zeroconf.Register(instance, "_smartha._tcp", "local.", cfg.Port, txt, ifaces)
132149
if err != nil {
133150
log.Printf("WARNING: mDNS registration failed: %v", err)
134151
} else {

custom_components/smart_sniffer/config_flow.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,20 @@ async def async_step_zeroconf(
174174
self, discovery_info: ZeroconfServiceInfo
175175
) -> ConfigFlowResult:
176176
"""Handle discovery via mDNS/Zeroconf."""
177-
host = self._pick_best_ip(discovery_info)
178177
port = discovery_info.port
179178
properties = discovery_info.properties
180-
hostname = properties.get("hostname", host)
179+
hostname = properties.get("hostname", "")
180+
181+
# Agent v0.4.25+ includes an "ip" TXT field with its preferred LAN
182+
# address. Trust it over our own scoring when present.
183+
agent_preferred_ip = properties.get("ip", "")
184+
if agent_preferred_ip:
185+
host = agent_preferred_ip
186+
else:
187+
host = self._pick_best_ip(discovery_info)
188+
189+
if not hostname:
190+
hostname = host
181191

182192
# Migrate any existing IP-based unique IDs to hostname-based.
183193
self._migrate_legacy_unique_ids(hostname, host, port)

custom_components/smart_sniffer/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
"iot_class": "local_polling",
1010
"issue_tracker": "https://github.com/DAB-LABS/smart-sniffer/issues",
1111
"requirements": [],
12-
"version": "0.4.24",
12+
"version": "0.4.25",
1313
"zeroconf": [{"type": "_smartha._tcp.local."}]
1414
}

0 commit comments

Comments
 (0)