@@ -3,7 +3,9 @@ package main
33import (
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.
1416type 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.
3150func 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+ }
0 commit comments