diff --git a/.tasks/done/2026-02-18-fix-dotted-hostname-routing.md b/.tasks/done/2026-02-18-fix-dotted-hostname-routing.md new file mode 100644 index 00000000..0e04aab0 --- /dev/null +++ b/.tasks/done/2026-02-18-fix-dotted-hostname-routing.md @@ -0,0 +1,40 @@ +--- +title: Fix dotted hostname breaks NATS subject routing +status: done +created: 2026-02-18 +updated: 2026-02-18 +--- + +## Objective + +Hostnames containing dots (e.g., `Johns-MacBook-Pro-2.local`) break NATS +subject routing because dots are NATS subject delimiters. + +## Problem + +When targeting a host with dots in the name, `BuildSubjectFromTarget` produces +a subject like `jobs.query.host.Johns-MacBook-Pro-2.local` (6 tokens). The +worker subscribes using `SanitizeHostname`, which replaces dots with +underscores: `jobs.*.host.Johns-MacBook-Pro-2_local` (4 tokens). The subjects +never match, so the job is never delivered. + +## Affected Code + +- `internal/job/subjects.go` — `BuildSubjectFromTarget` (line 241) builds the + publish subject without sanitizing the hostname +- `internal/job/subjects.go` — `BuildWorkerSubscriptionPattern` (line 148) + sanitizes via `SanitizeHostname` +- `internal/job/subjects.go` — `ParseSubject` (line 113) would also need to + handle sanitized hostnames on the parse side + +## Suggested Fix + +Sanitize the hostname in `BuildSubjectFromTarget` for the `"host"` case so +the publish subject matches the worker subscription. Ensure `ParseSubject` +round-trips correctly with sanitized hostnames. Add test cases for dotted +hostnames. + +## Notes + +- Reproduced with: `go run main.go client network dns get --interface-name eth0 --target 'Johns-MacBook-Pro-2.local'` +- The job is created but no worker ever picks it up. diff --git a/internal/job/client/jobs.go b/internal/job/client/jobs.go index 681df623..f37a2079 100644 --- a/internal/job/client/jobs.go +++ b/internal/job/client/jobs.go @@ -50,21 +50,22 @@ func (c *Client) CreateJob( targetHostname = job.AnyHost } - // Build the notification subject using simplified routing based on operation semantics - var notificationSubject string - - // Route based on operation name - operations ending in common read patterns are queries + // Build the notification subject using semantic routing based on operation type. + // Route based on operation name - operations ending in common read patterns are queries. + var prefix string if strings.HasSuffix(operationType, ".get") || strings.HasSuffix(operationType, ".query") || strings.HasSuffix(operationType, ".read") || strings.HasSuffix(operationType, ".status") || strings.HasSuffix(operationType, ".do") || strings.HasPrefix(operationType, "system.") { - notificationSubject = job.BuildQuerySubject(targetHostname) + prefix = job.JobsQueryPrefix } else { - notificationSubject = job.BuildModifySubject(targetHostname) + prefix = job.JobsModifyPrefix } + notificationSubject := job.BuildSubjectFromTarget(prefix, targetHostname) + // Generate job ID jobID := uuid.New().String() diff --git a/internal/job/subjects.go b/internal/job/subjects.go index 7b2cfd7e..f032ec0c 100644 --- a/internal/job/subjects.go +++ b/internal/job/subjects.go @@ -156,9 +156,9 @@ func BuildWorkerSubscriptionPattern( patterns := make([]string, 0, 3+labelCount) patterns = append(patterns, - fmt.Sprintf("jobs.*.host.%s", hostname), // Direct messages to this host - fmt.Sprintf("jobs.*.%s", AnyHost), // Load-balanced messages - fmt.Sprintf("jobs.*.%s", BroadcastHost), // Broadcast messages + fmt.Sprintf("jobs.*.host.%s", SanitizeHostname(hostname)), // Direct messages to this host + fmt.Sprintf("jobs.*.%s", AnyHost), // Load-balanced messages + fmt.Sprintf("jobs.*.%s", BroadcastHost), // Broadcast messages ) for key, value := range labels { @@ -248,7 +248,7 @@ func BuildSubjectFromTarget( case "label": return fmt.Sprintf("%s.label.%s.%s", prefix, key, value) default: // "host" - return fmt.Sprintf("%s.host.%s", prefix, key) + return fmt.Sprintf("%s.host.%s", prefix, SanitizeHostname(key)) } } diff --git a/internal/job/subjects_public_test.go b/internal/job/subjects_public_test.go index 46c9fa2e..c2dcdbfb 100644 --- a/internal/job/subjects_public_test.go +++ b/internal/job/subjects_public_test.go @@ -299,7 +299,7 @@ func (suite *SubjectsPublicTestSuite) TestBuildWorkerSubscriptionPattern() { name: "when building subscription pattern for specific hostname", hostname: "web-server-01", want: []string{ - "jobs.*.host.web-server-01", + "jobs.*.host.web_server_01", "jobs.*._any", "jobs.*._all", }, @@ -314,10 +314,10 @@ func (suite *SubjectsPublicTestSuite) TestBuildWorkerSubscriptionPattern() { }, }, { - name: "when building subscription pattern with complex hostname", + name: "when building subscription pattern with dotted hostname", hostname: "api.example.com", want: []string{ - "jobs.*.host.api.example.com", + "jobs.*.host.api_example_com", "jobs.*._any", "jobs.*._all", }, @@ -327,7 +327,7 @@ func (suite *SubjectsPublicTestSuite) TestBuildWorkerSubscriptionPattern() { hostname: "web-01", labels: map[string]string{"group": "web.dev.us-east"}, want: []string{ - "jobs.*.host.web-01", + "jobs.*.host.web_01", "jobs.*._any", "jobs.*._all", "jobs.*.label.group.web", @@ -340,7 +340,7 @@ func (suite *SubjectsPublicTestSuite) TestBuildWorkerSubscriptionPattern() { hostname: "web-01", labels: map[string]string{"team": "platform"}, want: []string{ - "jobs.*.host.web-01", + "jobs.*.host.web_01", "jobs.*._any", "jobs.*._all", "jobs.*.label.team.platform", @@ -599,6 +599,12 @@ func (suite *SubjectsPublicTestSuite) TestBuildSubjectFromTarget() { target: "server1", want: "jobs.query.host.server1", }, + { + name: "when target is a dotted hostname", + prefix: "jobs.query", + target: "my-server.local", + want: "jobs.query.host.my_server_local", + }, { name: "when target is a flat label", prefix: "jobs.query", diff --git a/internal/job/worker/consumer.go b/internal/job/worker/consumer.go index c0e0c382..f565a38c 100644 --- a/internal/job/worker/consumer.go +++ b/internal/job/worker/consumer.go @@ -62,7 +62,7 @@ func (w *Worker) consumeQueryJobs( }, { name: "query_direct_" + sanitizedHostname, - filter: "jobs.query.host." + hostname, + filter: "jobs.query.host." + sanitizedHostname, }, } @@ -158,7 +158,7 @@ func (w *Worker) consumeModifyJobs( }, { name: "modify_direct_" + sanitizedHostname, - filter: "jobs.modify.host." + hostname, + filter: "jobs.modify.host." + sanitizedHostname, }, }