diff --git a/internal/servicediscovery/service.go b/internal/servicediscovery/service.go index a21ec81bb..3c26e12fb 100644 --- a/internal/servicediscovery/service.go +++ b/internal/servicediscovery/service.go @@ -1793,6 +1793,18 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (* CurrentStep: "Identified from resource name (fast path)", PercentComplete: 90, }) + } else if identity, evidence, ok := inferSurfaceIdentityFromPorts(analysisReq.CommandOutputs["listening_ports"]); ok { + // Second fast path: an un-named workload often exposes a distinctive + // listening port that identifies it (8123→HA, 32400→Plex). Skip the + // model here too — a distinctive port is a high-confidence signal, and + // the configured model may be slow enough to time out otherwise. + result = surfaceIdentityResponse(identity, evidence) + s.broadcastProgress(&DiscoveryProgress{ + ResourceID: resourceID, + Status: DiscoveryStatusRunning, + CurrentStep: "Identified from listening port (fast path)", + PercentComplete: 90, + }) } else if metadataOnly { result = metadataOnlyDiscoveryAbstention() } else { diff --git a/internal/servicediscovery/service_identity.go b/internal/servicediscovery/service_identity.go index ee168daae..2214b6e15 100644 --- a/internal/servicediscovery/service_identity.go +++ b/internal/servicediscovery/service_identity.go @@ -2,6 +2,8 @@ package servicediscovery import ( "fmt" + "regexp" + "strconv" "strings" ) @@ -10,29 +12,37 @@ type knownServiceIdentity struct { ServiceName string Category ServiceCategory Aliases []string - Confidence float64 + // Ports lists DISTINCTIVE listening ports that belong to essentially one + // service (8123→HA, 32400→Plex). The port fast-path uses these to identify + // an un-named workload without the model. Ambiguous ports (80, 443, 8080, + // 3000, 5000) are deliberately omitted — a bad port guess is worse than + // letting the model decide. + Ports []int + Confidence float64 } -// knownServiceIdentities drives both the deterministic surface fast-path -// (inferSurfaceIdentity, name-based, runs before the model) and the post-model -// identity improver (applyKnownServiceIdentity). Common homelab services are -// usually named after themselves, so the resource name alone identifies them -// instantly — no model call. Aliases are matched against the normalized name. +// knownServiceIdentities drives the deterministic surface fast-paths +// (inferSurfaceIdentity by name and inferSurfaceIdentityFromPorts by distinctive +// listening port, both run before the model) and the post-model identity +// improver (applyKnownServiceIdentity). Common homelab services are usually +// named after themselves and/or listen on a distinctive port, so they identify +// instantly — no model call. Aliases match the normalized name; Ports match +// exact listening ports. var knownServiceIdentities = []knownServiceIdentity{ - {ServiceType: "home-assistant", ServiceName: "Home Assistant", Category: CategoryHomeAuto, Aliases: []string{"home assistant", "homeassistant", "hassio", "hass"}, Confidence: 0.9}, - {ServiceType: "esphome", ServiceName: "ESPHome", Category: CategoryHomeAuto, Aliases: []string{"esphome", "esp-home", "esp home"}, Confidence: 0.85}, + {ServiceType: "home-assistant", ServiceName: "Home Assistant", Category: CategoryHomeAuto, Aliases: []string{"home assistant", "homeassistant", "hassio", "hass"}, Ports: []int{8123}, Confidence: 0.9}, + {ServiceType: "esphome", ServiceName: "ESPHome", Category: CategoryHomeAuto, Aliases: []string{"esphome", "esp-home", "esp home"}, Ports: []int{6052}, Confidence: 0.85}, {ServiceType: "zigbee2mqtt", ServiceName: "Zigbee2MQTT", Category: CategoryHomeAuto, Aliases: []string{"zigbee2mqtt", "zigbee 2 mqtt"}, Confidence: 0.9}, {ServiceType: "frigate", ServiceName: "Frigate NVR", Category: CategoryNVR, Aliases: []string{"frigate"}, Confidence: 0.9}, - {ServiceType: "mosquitto", ServiceName: "Mosquitto MQTT", Category: CategoryNetwork, Aliases: []string{"mosquitto", "mqtt"}, Confidence: 0.85}, - {ServiceType: "postgresql", ServiceName: "PostgreSQL", Category: CategoryDatabase, Aliases: []string{"postgresql", "postgres"}, Confidence: 0.9}, - {ServiceType: "mariadb", ServiceName: "MariaDB", Category: CategoryDatabase, Aliases: []string{"mariadb", "mysqld", "mysql"}, Confidence: 0.85}, - {ServiceType: "redis", ServiceName: "Redis", Category: CategoryCache, Aliases: []string{"redis", "redis server"}, Confidence: 0.9}, - {ServiceType: "influxdb", ServiceName: "InfluxDB", Category: CategoryDatabase, Aliases: []string{"influxdb", "influx"}, Confidence: 0.85}, - {ServiceType: "plex", ServiceName: "Plex Media Server", Category: CategoryMedia, Aliases: []string{"plex media server", "plexmediaserver", "plex"}, Confidence: 0.9}, - {ServiceType: "jellyfin", ServiceName: "Jellyfin", Category: CategoryMedia, Aliases: []string{"jellyfin"}, Confidence: 0.9}, + {ServiceType: "mosquitto", ServiceName: "Mosquitto MQTT", Category: CategoryNetwork, Aliases: []string{"mosquitto", "mqtt"}, Ports: []int{1883, 8883}, Confidence: 0.85}, + {ServiceType: "postgresql", ServiceName: "PostgreSQL", Category: CategoryDatabase, Aliases: []string{"postgresql", "postgres"}, Ports: []int{5432}, Confidence: 0.9}, + {ServiceType: "mariadb", ServiceName: "MariaDB", Category: CategoryDatabase, Aliases: []string{"mariadb", "mysqld", "mysql"}, Ports: []int{3306}, Confidence: 0.85}, + {ServiceType: "redis", ServiceName: "Redis", Category: CategoryCache, Aliases: []string{"redis", "redis server"}, Ports: []int{6379}, Confidence: 0.9}, + {ServiceType: "influxdb", ServiceName: "InfluxDB", Category: CategoryDatabase, Aliases: []string{"influxdb", "influx"}, Ports: []int{8086}, Confidence: 0.85}, + {ServiceType: "plex", ServiceName: "Plex Media Server", Category: CategoryMedia, Aliases: []string{"plex media server", "plexmediaserver", "plex"}, Ports: []int{32400}, Confidence: 0.9}, + {ServiceType: "jellyfin", ServiceName: "Jellyfin", Category: CategoryMedia, Aliases: []string{"jellyfin"}, Ports: []int{8096}, Confidence: 0.9}, {ServiceType: "nginx", ServiceName: "Nginx", Category: CategoryWebServer, Aliases: []string{"nginx"}, Confidence: 0.8}, {ServiceType: "grafana", ServiceName: "Grafana", Category: CategoryMonitoring, Aliases: []string{"grafana"}, Confidence: 0.9}, - {ServiceType: "prometheus", ServiceName: "Prometheus", Category: CategoryMonitoring, Aliases: []string{"prometheus"}, Confidence: 0.9}, + {ServiceType: "prometheus", ServiceName: "Prometheus", Category: CategoryMonitoring, Aliases: []string{"prometheus"}, Ports: []int{9090}, Confidence: 0.9}, {ServiceType: "uptime-kuma", ServiceName: "Uptime Kuma", Category: CategoryMonitoring, Aliases: []string{"uptime kuma", "uptimekuma"}, Confidence: 0.9}, {ServiceType: "pihole", ServiceName: "Pi-hole", Category: CategoryNetwork, Aliases: []string{"pi hole", "pihole"}, Confidence: 0.9}, {ServiceType: "adguard", ServiceName: "AdGuard Home", Category: CategoryNetwork, Aliases: []string{"adguard home", "adguardhome", "adguard"}, Confidence: 0.9}, @@ -182,6 +192,47 @@ func inferSurfaceIdentity(req DiscoveryRequest, metadata map[string]any) (knownS return knownServiceIdentity{}, "", false } +// listeningPortPattern extracts port numbers that appear after a colon in +// ss/netstat output (the local-address column, e.g. "0.0.0.0:8123"). Only +// distinctive ports are matched downstream, so any stray match is harmless. +var listeningPortPattern = regexp.MustCompile(`:(\d{2,5})\b`) + +// parseListeningPorts returns the set of port numbers present in ss/netstat +// output. +func parseListeningPorts(output string) map[int]bool { + ports := map[int]bool{} + for _, match := range listeningPortPattern.FindAllStringSubmatch(output, -1) { + if p, err := strconv.Atoi(match[1]); err == nil { + ports[p] = true + } + } + return ports +} + +// inferSurfaceIdentityFromPorts is the second deterministic fast-path: when the +// name doesn't identify the workload, a distinctive listening port often does +// (8123→Home Assistant, 32400→Plex). Only ports that belong to essentially one +// service are in the table, so a match is high-confidence and the model can be +// skipped — important when the configured model is slow and would otherwise time +// out on an un-named workload. +func inferSurfaceIdentityFromPorts(listeningPortsOutput string) (knownServiceIdentity, string, bool) { + if strings.TrimSpace(listeningPortsOutput) == "" { + return knownServiceIdentity{}, "", false + } + ports := parseListeningPorts(listeningPortsOutput) + if len(ports) == 0 { + return knownServiceIdentity{}, "", false + } + for _, identity := range knownServiceIdentities { + for _, p := range identity.Ports { + if ports[p] { + return identity, fmt.Sprintf("listening port %d", p), true + } + } + } + return knownServiceIdentity{}, "", false +} + // surfaceIdentityResponse builds a no-model discovery result from a fast-path // match. Identity only: config/data/log paths are intentionally left empty — // the Assistant knows standard service layouts and fetches specifics on demand, diff --git a/internal/servicediscovery/service_identity_test.go b/internal/servicediscovery/service_identity_test.go index 293d2564e..9d5a71f3a 100644 --- a/internal/servicediscovery/service_identity_test.go +++ b/internal/servicediscovery/service_identity_test.go @@ -92,3 +92,33 @@ func TestWithNestedDockerAccess(t *testing.T) { t.Fatalf("expected layered docker exec path, got %q", out) } } + +func TestInferSurfaceIdentityFromPorts(t *testing.T) { + haSS := "State Recv-Q Send-Q Local-Address:Port Peer:Port Process\n" + + "LISTEN 0 4096 0.0.0.0:8123 0.0.0.0:* users:((\"python3\",pid=1234,fd=10))\n" + + "LISTEN 0 128 127.0.0.1:22 0.0.0.0:*" + cases := []struct { + name string + output string + wantType string + wantOK bool + }{ + {"HA by distinctive port 8123", haSS, "home-assistant", true}, + {"postgres by 5432", "LISTEN 0 244 0.0.0.0:5432 0.0.0.0:*", "postgresql", true}, + {"plex by 32400", "LISTEN 0 128 *:32400 *:*", "plex", true}, + {"ambiguous ports only do not match", "LISTEN 0 128 0.0.0.0:80\nLISTEN 0 128 0.0.0.0:443\nLISTEN 0 128 0.0.0.0:22", "", false}, + {"empty output", "", "", false}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + id, _, ok := inferSurfaceIdentityFromPorts(tc.output) + if ok != tc.wantOK { + t.Fatalf("inferSurfaceIdentityFromPorts ok=%v, want %v", ok, tc.wantOK) + } + if ok && id.ServiceType != tc.wantType { + t.Fatalf("inferSurfaceIdentityFromPorts type=%q, want %q", id.ServiceType, tc.wantType) + } + }) + } +}