package check import ( "bytes" "context" "encoding/xml" "io" "log/slog" "net" "net/http" "net/url" "os/exec" "strings" "time" "github.com/aceberg/WatchYourLAN/internal/models" ) // DNS - returns DNS names of a host func DNS(host models.Host) (name, dns string) { names := lookupHostNames(host.IP) if len(names) > 0 { name = names[0] dns = strings.Join(names, " ") } return name, dns } type hostIdentity struct { names []string hardware string } // EnrichHosts fills host names from local DNS, mDNS and SSDP/UPnP discovery. func EnrichHosts(hosts []models.Host) []models.Host { ipSet := make(map[string]struct{}) for _, host := range hosts { if net.ParseIP(host.IP) != nil { ipSet[host.IP] = struct{}{} } } avahi := discoverAvahiBrowse(ipSet) ssdp := discoverSSDP(ipSet) for i := range hosts { names := lookupHostNames(hosts[i].IP) if identity, ok := avahi[hosts[i].IP]; ok { names = appendUnique(names, identity.names...) } if identity, ok := ssdp[hosts[i].IP]; ok { names = appendUnique(names, identity.names...) if isUnknownHardware(hosts[i].Hw) && identity.hardware != "" { hosts[i].Hw = identity.hardware } } if len(names) > 0 { hosts[i].Name = names[0] hosts[i].DNS = strings.Join(names, " ") } } return hosts } func lookupHostNames(ip string) []string { names := []string{} dnsNames, _ := net.LookupAddr(ip) names = appendUnique(names, dnsNames...) names = appendUnique(names, getentNames(ip)...) names = appendUnique(names, avahiNames(ip)...) return names } func getentNames(ip string) []string { out, ok := runOutput(700*time.Millisecond, "getent", "hosts", ip) if !ok { return nil } fields := strings.Fields(out) if len(fields) < 2 { return nil } return fields[1:] } func avahiNames(ip string) []string { out, ok := runOutput(900*time.Millisecond, "avahi-resolve-address", ip) if !ok { return nil } fields := strings.Fields(out) if len(fields) < 2 { return nil } return fields[1:] } func runOutput(timeout time.Duration, name string, args ...string) (string, bool) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() out, err := exec.CommandContext(ctx, name, args...).Output() if err != nil { return "", false } return string(out), true } func discoverAvahiBrowse(targetIPs map[string]struct{}) map[string]hostIdentity { identities := make(map[string]hostIdentity) if len(targetIPs) == 0 { return identities } out, ok := runOutput(8*time.Second, "avahi-browse", "-artp") if !ok { return identities } for _, line := range strings.Split(out, "\n") { if !strings.HasPrefix(line, "=") { continue } parts := strings.Split(line, ";") if len(parts) < 9 { continue } ip := strings.TrimSpace(parts[7]) if _, ok := targetIPs[ip]; !ok { continue } identity := identities[ip] identity.names = appendUnique(identity.names, parts[3], parts[6]) identities[ip] = identity } return identities } func discoverSSDP(targetIPs map[string]struct{}) map[string]hostIdentity { identities := make(map[string]hostIdentity) if len(targetIPs) == 0 { return identities } addr, err := net.ResolveUDPAddr("udp4", "239.255.255.250:1900") if err != nil { return identities } conn, err := net.ListenPacket("udp4", ":0") if err != nil { slog.Debug("SSDP listen failed", "err", err) return identities } defer conn.Close() message := strings.Join([]string{ "M-SEARCH * HTTP/1.1", "HOST:239.255.255.250:1900", `MAN:"ssdp:discover"`, "MX:1", "ST:ssdp:all", "", "", }, "\r\n") for i := 0; i < 2; i++ { _, _ = conn.WriteTo([]byte(message), addr) } _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) seenLocations := make(map[string]struct{}) for { buffer := make([]byte, 65535) n, remote, err := conn.ReadFrom(buffer) if err != nil { break } responseIP, _, err := net.SplitHostPort(remote.String()) if err != nil { responseIP = remote.String() } headers := parseSSDPHeaders(buffer[:n]) location := strings.TrimSpace(headers["location"]) if location == "" { continue } if _, seen := seenLocations[location]; seen { continue } seenLocations[location] = struct{}{} ip := matchingLocationIP(location, responseIP, targetIPs) if ip == "" { continue } identity := identities[ip] desc := fetchSSDPDescription(location) identity.names = appendUnique(identity.names, desc["friendlyName"]) identity.hardware = joinNonEmpty(desc["manufacturer"], desc["modelName"], desc["modelNumber"]) identities[ip] = identity } return identities } func parseSSDPHeaders(payload []byte) map[string]string { headers := make(map[string]string) for _, line := range strings.Split(string(payload), "\n") { key, value, ok := strings.Cut(line, ":") if !ok { continue } headers[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value) } return headers } func matchingLocationIP(location, responseIP string, targetIPs map[string]struct{}) string { if _, ok := targetIPs[responseIP]; ok { return responseIP } parsed, err := url.Parse(location) if err != nil || parsed.Hostname() == "" { return "" } locationIP := net.ParseIP(parsed.Hostname()) if locationIP != nil { ip := locationIP.String() if _, ok := targetIPs[ip]; ok { return ip } return "" } ips, err := net.LookupHost(parsed.Hostname()) if err != nil { return "" } for _, ip := range ips { if _, ok := targetIPs[ip]; ok { return ip } } return "" } func fetchSSDPDescription(location string) map[string]string { result := make(map[string]string) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil) if err != nil { return result } req.Header.Set("User-Agent", "WatchYourLAN/2.1.4") resp, err := http.DefaultClient.Do(req) if err != nil { return result } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) if err != nil { return result } return parseSSDPDescription(body) } func parseSSDPDescription(body []byte) map[string]string { result := make(map[string]string) wanted := map[string]struct{}{ "friendlyName": {}, "manufacturer": {}, "modelName": {}, "modelNumber": {}, } decoder := xml.NewDecoder(bytes.NewReader(body)) current := "" for { token, err := decoder.Token() if err != nil { break } switch item := token.(type) { case xml.StartElement: if _, ok := wanted[item.Name.Local]; ok { current = item.Name.Local } case xml.CharData: if current != "" { result[current] = cleanName(string(item)) } case xml.EndElement: if item.Name.Local == current { current = "" } } } return result } func appendUnique(items []string, more ...string) []string { seen := make(map[string]struct{}, len(items)+len(more)) out := make([]string, 0, len(items)+len(more)) for _, item := range append(items, more...) { item = cleanName(item) if item == "" { continue } if _, ok := seen[item]; ok { continue } seen[item] = struct{}{} out = append(out, item) } return out } func cleanName(value string) string { value = strings.TrimSpace(value) value = strings.TrimSuffix(value, ".") value = strings.Join(strings.Fields(value), " ") if value == "" || net.ParseIP(value) != nil { return "" } return value } func isUnknownHardware(value string) bool { value = strings.TrimSpace(value) return value == "" || strings.HasPrefix(value, "(Unknown") } func joinNonEmpty(parts ...string) string { out := []string{} for _, part := range parts { part = cleanName(part) if part != "" { out = append(out, part) } } return strings.Join(out, " ") }