Add distinctive-port fast-path for un-named workloads

The name fast-path identifies workloads named after their service, but a
workload with a generic name (ct101, db1) still fell through to the model —
which, with a slow reasoning model configured, times out rather than just
being slow. Many such workloads are still identifiable instantly by a
DISTINCTIVE listening port.

Add a Ports field to the identity table (only single-service ports: 8123
HA, 32400 Plex, 5432 postgres, 6379 redis, 1883/8883 mosquitto, 3306
mariadb, 8086 influxdb, 8096 jellyfin, 9090 prometheus, 6052 esphome) and
a second fast-path inferSurfaceIdentityFromPorts that runs after the name
path and before the model. Ambiguous ports (80, 443, 8080, 3000, 5000) are
deliberately excluded — a bad port guess is worse than asking the model.

Parser validated against real ss -tlnp output from LXC 101 (extracts 8123,
ignores ephemeral ports / PIDs / docker-proxy noise → home-assistant).
Unit tests cover distinctive-port matches and ambiguous-only non-matches.
This commit is contained in:
rcourtman 2026-06-07 21:52:41 +01:00
parent ddd9aaddba
commit ac1628f9b4
3 changed files with 109 additions and 16 deletions

View file

@ -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 {

View file

@ -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,

View file

@ -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)
}
})
}
}