Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .tasks/done/2026-02-18-fix-dotted-hostname-routing.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 7 additions & 6 deletions internal/job/client/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
8 changes: 4 additions & 4 deletions internal/job/subjects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
}
}

Expand Down
16 changes: 11 additions & 5 deletions internal/job/subjects_public_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand 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",
},
Expand 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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions internal/job/worker/consumer.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func (w *Worker) consumeQueryJobs(
},
{
name: "query_direct_" + sanitizedHostname,
filter: "jobs.query.host." + hostname,
filter: "jobs.query.host." + sanitizedHostname,
},
}

Expand Down Expand Up @@ -158,7 +158,7 @@ func (w *Worker) consumeModifyJobs(
},
{
name: "modify_direct_" + sanitizedHostname,
filter: "jobs.modify.host." + hostname,
filter: "jobs.modify.host." + sanitizedHostname,
},
}

Expand Down
Loading