Skip to content

Commit abfa516

Browse files
committed
feat: add mdns alias publishing
1 parent 0d75108 commit abfa516

10 files changed

Lines changed: 252 additions & 24 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ Local access chain:
3333
- `http://nullhub.localhost:19800`
3434
- `http://127.0.0.1:19800`
3535

36-
`nullhub` prefers the first address that resolves locally and falls back to the
37-
next one automatically.
36+
`nullhub` tries to publish `nullhub.local` through `dns-sd`/Bonjour or
37+
`avahi-publish` when those tools are available, and otherwise falls back to
38+
`nullhub.localhost` and finally `127.0.0.1`.
3839

3940
### Runtime Prerequisites
4041

src/access.zig

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@ pub const public_alias_host = "nullhub.local";
66
pub const canonical_local_host = "nullhub.localhost";
77
pub const fallback_local_host = "127.0.0.1";
88

9+
pub const Options = struct {
10+
public_alias_active: bool = false,
11+
public_alias_provider: []const u8 = "none",
12+
};
13+
914
pub const AccessUrls = struct {
1015
local_alias_chain: bool,
16+
public_alias_active: bool,
17+
public_alias_provider: []const u8,
1118
direct_url: []const u8,
1219
browser_open_url: []const u8,
1320
canonical_url: []const u8,
@@ -34,6 +41,10 @@ pub fn isLocalBindHost(host: []const u8) bool {
3441
}
3542

3643
pub fn buildAccessUrls(allocator: std.mem.Allocator, host: []const u8, port: u16) !AccessUrls {
44+
return buildAccessUrlsWithOptions(allocator, host, port, .{});
45+
}
46+
47+
pub fn buildAccessUrlsWithOptions(allocator: std.mem.Allocator, host: []const u8, port: u16, options: Options) !AccessUrls {
3748
if (isLocalBindHost(host)) {
3849
const public_alias_url = try buildUrl(allocator, public_alias_host, port);
3950
errdefer allocator.free(public_alias_url);
@@ -44,14 +55,17 @@ pub fn buildAccessUrls(allocator: std.mem.Allocator, host: []const u8, port: u16
4455
const fallback_url = try buildUrl(allocator, fallback_local_host, port);
4556
errdefer allocator.free(fallback_url);
4657

47-
const browser_open_url = try buildUrl(allocator, canonical_local_host, port);
58+
const browser_open_host = if (options.public_alias_active) public_alias_host else canonical_local_host;
59+
const browser_open_url = try buildUrl(allocator, browser_open_host, port);
4860
errdefer allocator.free(browser_open_url);
4961

5062
const direct_url = try buildUrl(allocator, fallback_local_host, port);
5163
errdefer allocator.free(direct_url);
5264

5365
return .{
5466
.local_alias_chain = true,
67+
.public_alias_active = options.public_alias_active,
68+
.public_alias_provider = options.public_alias_provider,
5569
.direct_url = direct_url,
5670
.browser_open_url = browser_open_url,
5771
.canonical_url = canonical_url,
@@ -74,6 +88,8 @@ pub fn buildAccessUrls(allocator: std.mem.Allocator, host: []const u8, port: u16
7488

7589
return .{
7690
.local_alias_chain = false,
91+
.public_alias_active = false,
92+
.public_alias_provider = "none",
7793
.direct_url = direct_url,
7894
.browser_open_url = browser_open_url,
7995
.canonical_url = canonical_url,
@@ -91,6 +107,8 @@ test "buildAccessUrls uses nullhub local chain for loopback binds" {
91107
defer urls.deinit(std.testing.allocator);
92108

93109
try std.testing.expect(urls.local_alias_chain);
110+
try std.testing.expect(!urls.public_alias_active);
111+
try std.testing.expectEqualStrings("none", urls.public_alias_provider);
94112
try std.testing.expectEqualStrings("http://nullhub.local:19800", urls.public_alias_url.?);
95113
try std.testing.expectEqualStrings("http://nullhub.localhost:19800", urls.browser_open_url);
96114
try std.testing.expectEqualStrings("http://nullhub.localhost:19800", urls.canonical_url);
@@ -107,3 +125,15 @@ test "buildAccessUrls keeps direct host for non-local binds" {
107125
try std.testing.expectEqualStrings("http://192.168.1.50:22000", urls.browser_open_url);
108126
try std.testing.expectEqualStrings("http://192.168.1.50:22000", urls.direct_url);
109127
}
128+
129+
test "buildAccessUrls prefers public alias when it is active" {
130+
var urls = try buildAccessUrlsWithOptions(std.testing.allocator, "127.0.0.1", default_port, .{
131+
.public_alias_active = true,
132+
.public_alias_provider = "dns-sd",
133+
});
134+
defer urls.deinit(std.testing.allocator);
135+
136+
try std.testing.expect(urls.public_alias_active);
137+
try std.testing.expectEqualStrings("dns-sd", urls.public_alias_provider);
138+
try std.testing.expectEqualStrings("http://nullhub.local:19800", urls.browser_open_url);
139+
}

src/api/settings.zig

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ const access = @import("../access.zig");
66

77
/// GET /api/settings — return hub configuration defaults.
88
/// Caller owns the returned memory.
9-
pub fn handleGetSettings(allocator: std.mem.Allocator, host: []const u8, port: u16) ![]const u8 {
10-
var urls = try access.buildAccessUrls(allocator, host, port);
9+
pub fn handleGetSettings(allocator: std.mem.Allocator, host: []const u8, port: u16, access_options: access.Options) ![]const u8 {
10+
var urls = try access.buildAccessUrlsWithOptions(allocator, host, port, access_options);
1111
defer urls.deinit(allocator);
1212

1313
var buf = std.array_list.Managed(u8).init(allocator);
@@ -105,6 +105,11 @@ fn appendAccessJson(buf: *std.array_list.Managed(u8), urls: access.AccessUrls) !
105105
try buf.appendSlice(urls.fallback_url);
106106
try buf.appendSlice("\",\"local_alias_chain\":");
107107
try buf.appendSlice(if (urls.local_alias_chain) "true" else "false");
108+
try buf.appendSlice(",\"public_alias_active\":");
109+
try buf.appendSlice(if (urls.public_alias_active) "true" else "false");
110+
try buf.appendSlice(",\"public_alias_provider\":\"");
111+
try buf.appendSlice(urls.public_alias_provider);
112+
try buf.append('"');
108113
try buf.appendSlice(",\"public_alias_url\":");
109114
if (urls.public_alias_url) |url| {
110115
try buf.append('"');
@@ -121,7 +126,7 @@ fn appendAccessJson(buf: *std.array_list.Managed(u8), urls: access.AccessUrls) !
121126
test "handleGetSettings returns valid JSON with defaults" {
122127
const allocator = std.testing.allocator;
123128

124-
const json = try handleGetSettings(allocator, access.default_bind_host, access.default_port);
129+
const json = try handleGetSettings(allocator, access.default_bind_host, access.default_port, .{});
125130
defer allocator.free(json);
126131

127132
const parsed = try std.json.parseFromSlice(
@@ -136,6 +141,8 @@ test "handleGetSettings returns valid JSON with defaults" {
136141
canonical_url: []const u8,
137142
fallback_url: []const u8,
138143
local_alias_chain: bool,
144+
public_alias_active: bool,
145+
public_alias_provider: []const u8,
139146
public_alias_url: ?[]const u8,
140147
},
141148
},
@@ -150,6 +157,8 @@ test "handleGetSettings returns valid JSON with defaults" {
150157
try std.testing.expect(parsed.value.auth_token == null);
151158
try std.testing.expect(parsed.value.auto_update_check == true);
152159
try std.testing.expect(parsed.value.access.local_alias_chain);
160+
try std.testing.expect(!parsed.value.access.public_alias_active);
161+
try std.testing.expectEqualStrings("none", parsed.value.access.public_alias_provider);
153162
try std.testing.expectEqualStrings("http://nullhub.localhost:19800", parsed.value.access.browser_open_url);
154163
try std.testing.expectEqualStrings("http://nullhub.local:19800", parsed.value.access.public_alias_url.?);
155164
}

src/api/status.zig

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,10 @@ fn appendInstanceJson(buf: *std.array_list.Managed(u8), entry: state_mod.Instanc
6363
// ─── Handlers ────────────────────────────────────────────────────────────────
6464

6565
/// GET /api/status — aggregated dashboard data.
66-
pub fn handleStatus(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, uptime_seconds: u64, host: []const u8, port: u16) ApiResponse {
66+
pub fn handleStatus(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, uptime_seconds: u64, host: []const u8, port: u16, access_options: access.Options) ApiResponse {
6767
var buf = std.array_list.Managed(u8).init(allocator);
6868

69-
buildStatusJson(&buf, s, manager, uptime_seconds, host, port) catch return .{
69+
buildStatusJson(&buf, s, manager, uptime_seconds, host, port, access_options) catch return .{
7070
.status = "500 Internal Server Error",
7171
.content_type = "application/json",
7272
.body = "{\"error\":\"internal error\"}",
@@ -75,8 +75,8 @@ pub fn handleStatus(allocator: std.mem.Allocator, s: *state_mod.State, manager:
7575
return .{ .status = "200 OK", .content_type = "application/json", .body = buf.items };
7676
}
7777

78-
fn buildStatusJson(buf: *std.array_list.Managed(u8), s: *state_mod.State, manager: *manager_mod.Manager, uptime_seconds: u64, host: []const u8, port: u16) !void {
79-
var urls = try access.buildAccessUrls(buf.allocator, host, port);
78+
fn buildStatusJson(buf: *std.array_list.Managed(u8), s: *state_mod.State, manager: *manager_mod.Manager, uptime_seconds: u64, host: []const u8, port: u16, access_options: access.Options) !void {
79+
var urls = try access.buildAccessUrlsWithOptions(buf.allocator, host, port, access_options);
8080
defer urls.deinit(buf.allocator);
8181

8282
// Hub info
@@ -100,6 +100,11 @@ fn buildStatusJson(buf: *std.array_list.Managed(u8), s: *state_mod.State, manage
100100
try buf.appendSlice(urls.fallback_url);
101101
try buf.appendSlice("\",\"local_alias_chain\":");
102102
try buf.appendSlice(if (urls.local_alias_chain) "true" else "false");
103+
try buf.appendSlice(",\"public_alias_active\":");
104+
try buf.appendSlice(if (urls.public_alias_active) "true" else "false");
105+
try buf.appendSlice(",\"public_alias_provider\":\"");
106+
try buf.appendSlice(urls.public_alias_provider);
107+
try buf.append('"');
103108
try buf.appendSlice(",\"public_alias_url\":");
104109
if (urls.public_alias_url) |url| {
105110
try buf.append('"');
@@ -161,7 +166,7 @@ test "handleStatus returns valid JSON with hub version" {
161166
var mgr = manager_mod.Manager.init(allocator, p);
162167
defer mgr.deinit();
163168

164-
const resp = handleStatus(allocator, &s, &mgr, 3600, access.default_bind_host, access.default_port);
169+
const resp = handleStatus(allocator, &s, &mgr, 3600, access.default_bind_host, access.default_port, .{});
165170
defer allocator.free(resp.body);
166171

167172
try std.testing.expectEqualStrings("200 OK", resp.status);
@@ -176,6 +181,8 @@ test "handleStatus returns valid JSON with hub version" {
176181
uptime_seconds: u64,
177182
access: struct {
178183
browser_open_url: []const u8,
184+
public_alias_active: bool,
185+
public_alias_provider: []const u8,
179186
},
180187
},
181188
instances: std.json.ArrayHashMap(std.json.ArrayHashMap(struct {
@@ -193,6 +200,8 @@ test "handleStatus returns valid JSON with hub version" {
193200
try std.testing.expectEqualStrings("2026.3.2", parsed.value.hub.version);
194201
try std.testing.expect(parsed.value.hub.platform.len > 0);
195202
try std.testing.expectEqual(@as(u64, 3600), parsed.value.hub.uptime_seconds);
203+
try std.testing.expect(!parsed.value.hub.access.public_alias_active);
204+
try std.testing.expectEqualStrings("none", parsed.value.hub.access.public_alias_provider);
196205
try std.testing.expectEqualStrings("http://nullhub.localhost:19800", parsed.value.hub.access.browser_open_url);
197206
}
198207

@@ -207,7 +216,7 @@ test "handleStatus includes instances" {
207216

208217
try s.addInstance("nullclaw", "my-agent", .{ .version = "2026.3.1", .auto_start = true });
209218

210-
const resp = handleStatus(allocator, &s, &mgr, 0, access.default_bind_host, access.default_port);
219+
const resp = handleStatus(allocator, &s, &mgr, 0, access.default_bind_host, access.default_port, .{});
211220
defer allocator.free(resp.body);
212221

213222
try std.testing.expectEqualStrings("200 OK", resp.status);
@@ -252,7 +261,7 @@ test "handleStatus includes launch_mode" {
252261

253262
try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.0", .launch_mode = "agent" });
254263

255-
const resp = handleStatus(allocator, &s, &mgr, 0, access.default_bind_host, access.default_port);
264+
const resp = handleStatus(allocator, &s, &mgr, 0, access.default_bind_host, access.default_port, .{});
256265
defer allocator.free(resp.body);
257266

258267
try std.testing.expectEqualStrings("200 OK", resp.status);
@@ -268,7 +277,7 @@ test "handleStatus with empty state returns empty instances" {
268277
var mgr = manager_mod.Manager.init(allocator, p);
269278
defer mgr.deinit();
270279

271-
const resp = handleStatus(allocator, &s, &mgr, 42, access.default_bind_host, access.default_port);
280+
const resp = handleStatus(allocator, &s, &mgr, 42, access.default_bind_host, access.default_port, .{});
272281
defer allocator.free(resp.body);
273282

274283
try std.testing.expectEqualStrings("200 OK", resp.status);

src/main.zig

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const server = root.server;
66
const paths_mod = root.paths;
77
const manager_mod = root.manager;
88
const access = root.access;
9+
const mdns_mod = root.mdns;
910

1011
const version = "2026.3.2";
1112

@@ -35,13 +36,16 @@ pub fn main() !void {
3536

3637
var srv = try server.Server.init(allocator, opts.host, opts.port, &mgr, &mutex);
3738
defer srv.deinit();
39+
var mdns = try mdns_mod.Publisher.init(allocator, paths, opts.host, opts.port);
40+
defer mdns.deinit();
41+
srv.setAccessOptions(mdns.accessOptions());
3842

3943
const sup_thread = try std.Thread.spawn(.{}, supervisorLoop, .{ &mgr, &mutex });
4044
sup_thread.detach();
4145

4246
srv.autoStartAll();
4347

44-
if (!opts.no_open) openBrowser(allocator, opts.host, opts.port);
48+
if (!opts.no_open) openBrowser(allocator, opts.host, opts.port, mdns.accessOptions());
4549

4650
try srv.run();
4751
},
@@ -99,8 +103,8 @@ fn supervisorLoop(manager: *manager_mod.Manager, mutex: *std.Thread.Mutex) void
99103
}
100104
}
101105

102-
fn openBrowser(allocator: std.mem.Allocator, host: []const u8, port: u16) void {
103-
var urls = access.buildAccessUrls(allocator, host, port) catch return;
106+
fn openBrowser(allocator: std.mem.Allocator, host: []const u8, port: u16, access_options: access.Options) void {
107+
var urls = access.buildAccessUrlsWithOptions(allocator, host, port, access_options) catch return;
104108
defer urls.deinit(allocator);
105109

106110
var child = switch (builtin.os.tag) {

0 commit comments

Comments
 (0)