Skip to content

Commit a57e2e4

Browse files
mcuelenaereclaude
andcommitted
feat(mdns): advertise _jetkvm._tcp via Bonjour/DNS-SD
JetKVM devices now advertise themselves as the DNS-SD service type `_jetkvm._tcp` on the local network, so macOS/iOS clients can discover every device on the LAN via NWBrowser(for: .bonjour(type: "_jetkvm._tcp", domain: nil)), and `dns-sd -B _jetkvm._tcp` / `avahi-browse -r _jetkvm._tcp` work too. The advertised record carries: - instance: the device hostname (e.g. jetkvm-abc123) - host: <hostname>.local (existing A/AAAA resolution preserved) - port: 80, or 443 when TLS is enabled - TXT: version=<fw>, id=<deviceId>, setup=<true|false> Implementation uses pion/mdns/v2 (already in the tree transitively via pion/webrtc). pion takes the IPv4 and IPv6 multicast packet conns separately, so the existing MDNSMode=ipv4_only / ipv6_only config is honored by simply not binding the disabled family — no custom responder, no second mDNS library. We switch from the legacy Server() (A/AAAA only) to NewServer() so PTR/SRV/TXT are answered too. Lifecycle: - starts on the first networkStateChanged once the network is up - refreshes after device setup completes so the `setup` TXT flips - refreshes after a TLS mode change so the advertised port follows - Stop() closes the sockets on shutdown NOTE: DNS-SD TXT publication needs an exported TXTEntry API that is not yet in a tagged pion/mdns release; go.mod pins pion/mdns/v2 to a fork branch via `replace` until pion/mdns#277 merges and ships. This PR stays in draft until then. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 800c0cb commit a57e2e4

9 files changed

Lines changed: 156 additions & 10 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) s
1818
- **Ultra-low Latency** - 1080p@60FPS video with 30-60ms latency using H.264 encoding. Smooth mouse and keyboard interaction for responsive remote control.
1919
- **Free & Optional Remote Access** - Remote management via JetKVM Cloud using WebRTC.
2020
- **Optional Tailscale Networking** - Built-in Tailscale status and control-server configuration, including custom [Headscale](https://headscale.net/)-compatible endpoints.
21+
- **Bonjour / DNS-SD Discovery** - JetKVM devices advertise themselves as `_jetkvm._tcp` on the local network, with TXT records exposing the firmware version, device ID, and setup state.
2122
- **Open-source software** - Written in Golang on Linux. Easily customizable through SSH access to the JetKVM device.
2223

2324
## Contributing

go.mod

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/coder/websocket v1.8.14
1111
github.com/coreos/go-oidc/v3 v3.16.0
1212
github.com/creack/pty v1.1.24
13+
github.com/eclipse/paho.mqtt.golang v1.5.1
1314
github.com/erikdubbelboer/gspt v0.0.0-20210805194459-ce36a5128377
1415
github.com/fsnotify/fsnotify v1.9.0
1516
github.com/gin-contrib/logger v1.2.6
@@ -21,8 +22,10 @@ require (
2122
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e
2223
github.com/mdlayher/ndp v1.1.0
2324
github.com/pion/ice/v4 v4.1.0
25+
github.com/pion/interceptor v0.1.42
2426
github.com/pion/logging v0.2.4
2527
github.com/pion/mdns/v2 v2.1.0
28+
github.com/pion/rtp v1.8.27
2629
github.com/pion/webrtc/v4 v4.2.1
2730
github.com/pojntfx/go-nbd v0.3.2
2831
github.com/prometheus/client_golang v1.23.2
@@ -38,7 +41,7 @@ require (
3841
go.bug.st/serial v1.6.4
3942
golang.org/x/crypto v0.43.0
4043
golang.org/x/net v0.46.0
41-
golang.org/x/sys v0.37.0
44+
golang.org/x/sys v0.41.0
4245
google.golang.org/grpc v1.76.0
4346
google.golang.org/protobuf v1.36.10
4447
)
@@ -54,7 +57,6 @@ require (
5457
github.com/cloudwego/base64x v0.1.6 // indirect
5558
github.com/creack/goselect v0.1.2 // indirect
5659
github.com/davecgh/go-spew v1.1.1 // indirect
57-
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
5860
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
5961
github.com/gin-contrib/sse v1.1.0 // indirect
6062
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
@@ -80,10 +82,8 @@ require (
8082
github.com/pilebones/go-udev v0.9.1 // indirect
8183
github.com/pion/datachannel v1.5.10 // indirect
8284
github.com/pion/dtls/v3 v3.0.9 // indirect
83-
github.com/pion/interceptor v0.1.42 // indirect
8485
github.com/pion/randutil v0.1.0 // indirect
8586
github.com/pion/rtcp v1.2.16 // indirect
86-
github.com/pion/rtp v1.8.27 // indirect
8787
github.com/pion/sctp v1.9.0 // indirect
8888
github.com/pion/sdp/v3 v3.0.17 // indirect
8989
github.com/pion/srtp/v3 v3.0.9 // indirect
@@ -107,3 +107,11 @@ require (
107107
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
108108
gopkg.in/yaml.v3 v3.0.1 // indirect
109109
)
110+
111+
// TEMPORARY: pion/mdns does not yet expose a public API to set DNS-SD
112+
// TXT records on advertised services (the ServiceInstance.Text element
113+
// type is unexported). This points pion/mdns/v2 at a fork branch that
114+
// exports TXTEntry + NewTXTEntry/NewTXTFlag. Drop this replace and bump
115+
// pion/mdns/v2 to the release that includes it once
116+
// https://github.com/pion/mdns/pull/277 merges and ships.
117+
replace github.com/pion/mdns/v2 => github.com/mcuelenaere/mdns/v2 v2.0.0-20260529132740-a54bccfba4f7

go.sum

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
115115
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
116116
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
117117
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
118+
github.com/mcuelenaere/mdns/v2 v2.0.0-20260529132740-a54bccfba4f7 h1:BZTD31GcSgA4upxhFAgrXM6vaCNM/0rvvVljKWZEFI0=
119+
github.com/mcuelenaere/mdns/v2 v2.0.0-20260529132740-a54bccfba4f7/go.mod h1:Ouef+TvRd3g/vNEFH8EH6qkRqQbJ102AfE/7ONXF8Og=
118120
github.com/mdlayher/ndp v1.1.0 h1:QylGKGVtH60sKZUE88+IW5ila1Z/M9/OXhWdsVKuscs=
119121
github.com/mdlayher/ndp v1.1.0/go.mod h1:FmgESgemgjl38vuOIyAHWUUL6vQKA/pQNkvXdWsdQFM=
120122
github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY=
@@ -144,8 +146,6 @@ github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cm
144146
github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU=
145147
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
146148
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
147-
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
148-
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
149149
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
150150
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
151151
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
@@ -162,6 +162,8 @@ github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU=
162162
github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA=
163163
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
164164
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
165+
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
166+
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
165167
github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA=
166168
github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A=
167169
github.com/pion/webrtc/v4 v4.2.1 h1:QgIfJeXf9dg++35y4z8GK3oXHcxWf0y2tUstCry0/V8=
@@ -248,8 +250,8 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
248250
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
249251
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
250252
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
251-
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
252-
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
253+
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
254+
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
253255
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
254256
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
255257
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=

internal/mdns/mdns.go

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,37 @@ type MDNS struct {
2222

2323
localNames []string
2424
listenOptions *MDNSListenOptions
25+
service *MDNSService
2526
}
2627

2728
type MDNSListenOptions struct {
2829
IPv4 bool
2930
IPv6 bool
3031
}
3132

33+
// MDNSService describes a DNS-SD service to advertise alongside the
34+
// A/AAAA records, so Bonjour browsers (NWBrowser, dns-sd,
35+
// avahi-browse) can discover the device.
36+
type MDNSService struct {
37+
// Type is the DNS-SD service type, e.g. "_jetkvm._tcp".
38+
Type string
39+
// Instance is the user-visible instance name. If empty, the first
40+
// local name is used.
41+
Instance string
42+
// Port is the TCP/UDP port the service listens on.
43+
Port int
44+
// TXT is the list of "key=value" entries (or bare "key" flags) to
45+
// advertise.
46+
TXT []string
47+
}
48+
3249
type MDNSOptions struct {
3350
Logger *zerolog.Logger
3451
LocalNames []string
3552
ListenOptions *MDNSListenOptions
53+
// Service, if non-nil, is published as a DNS-SD service whose SRV
54+
// record points at the first local name.
55+
Service *MDNSService
3656
}
3757

3858
const (
@@ -57,6 +77,7 @@ func NewMDNS(opts *MDNSOptions) (*MDNS, error) {
5777
lock: sync.Mutex{},
5878
localNames: opts.LocalNames,
5979
listenOptions: opts.ListenOptions,
80+
service: opts.Service,
6081
}, nil
6182
}
6283

@@ -131,7 +152,12 @@ func (m *MDNS) start(allowRestart bool) error {
131152
}
132153
}
133154

134-
mDNSConn, err := pion_mdns.Server(p4, p6, &pion_mdns.Config{LocalNames: newLocalNames})
155+
opts := []pion_mdns.ServerOption{pion_mdns.WithLocalNames(newLocalNames...)}
156+
if svc := buildServiceInstance(m.service, newLocalNames); svc != nil {
157+
opts = append(opts, pion_mdns.WithService(*svc))
158+
}
159+
160+
mDNSConn, err := pion_mdns.NewServer(p4, p6, opts...)
135161

136162
if err != nil {
137163
scopeLogger.Warn().Err(err).Msg("failed to start mDNS server")
@@ -144,6 +170,37 @@ func (m *MDNS) start(allowRestart bool) error {
144170
return nil
145171
}
146172

173+
// buildServiceInstance converts the configured MDNSService into a pion
174+
// ServiceInstance, defaulting the instance name to the first local name
175+
// and translating the "key=value" TXT slice into pion TXTEntry values.
176+
// Returns nil when there is no service to advertise.
177+
func buildServiceInstance(service *MDNSService, localNames []string) *pion_mdns.ServiceInstance {
178+
if service == nil || service.Type == "" || service.Port <= 0 {
179+
return nil
180+
}
181+
182+
instance := service.Instance
183+
if instance == "" && len(localNames) > 0 {
184+
instance = strings.TrimSuffix(localNames[0], ".local")
185+
}
186+
187+
txt := make([]pion_mdns.TXTEntry, 0, len(service.TXT))
188+
for _, e := range service.TXT {
189+
if key, value, ok := strings.Cut(e, "="); ok {
190+
txt = append(txt, pion_mdns.NewTXTEntry(key, value))
191+
} else if e != "" {
192+
txt = append(txt, pion_mdns.NewTXTFlag(e))
193+
}
194+
}
195+
196+
return &pion_mdns.ServiceInstance{
197+
Instance: instance,
198+
Service: service.Type,
199+
Port: uint16(service.Port),
200+
Text: txt,
201+
}
202+
}
203+
147204
// Start starts the mDNS server
148205
func (m *MDNS) Start() error {
149206
return m.start(false)
@@ -190,6 +247,17 @@ func (m *MDNS) setListenOptions(listenOptions *MDNSListenOptions) {
190247
m.listenOptions = listenOptions
191248
}
192249

250+
func (m *MDNS) setService(service *MDNSService) {
251+
m.lock.Lock()
252+
defer m.lock.Unlock()
253+
254+
if reflect.DeepEqual(m.service, service) {
255+
return
256+
}
257+
258+
m.service = service
259+
}
260+
193261
// SetLocalNames sets the local names and restarts the mDNS server
194262
func (m *MDNS) SetLocalNames(localNames []string) error {
195263
m.setLocalNames(localNames)
@@ -202,9 +270,10 @@ func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error {
202270
return m.Restart()
203271
}
204272

205-
// SetOptions sets the local names and listen options and restarts the mDNS server
273+
// SetOptions sets the local names, listen options, and service and restarts the mDNS server
206274
func (m *MDNS) SetOptions(options *MDNSOptions) error {
207275
m.setLocalNames(options.LocalNames)
208276
m.setListenOptions(options.ListenOptions)
277+
m.setService(options.Service)
209278
return m.Restart()
210279
}

main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,14 @@ func Main() {
189189

190190
logger.Log().Msg("JetKVM Shutting Down")
191191

192+
if mDNS != nil {
193+
// Close the multicast sockets so we stop responding to
194+
// browses immediately. Browsers age the entry out by TTL.
195+
if err := mDNS.Stop(); err != nil {
196+
logger.Warn().Err(err).Msg("failed to stop mDNS server")
197+
}
198+
}
199+
192200
//if fuseServer != nil {
193201
// err := setMassStorageImage(" ")
194202
// if err != nil {

mdns.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import (
66
"github.com/jetkvm/kvm/internal/mdns"
77
)
88

9+
// JetkvmServiceType is the DNS-SD service type advertised by the
10+
// device. Bonjour clients on macOS/iOS can discover JetKVM devices
11+
// by browsing for this type, e.g. via
12+
// NWBrowser(for: .bonjour(type: "_jetkvm._tcp", domain: nil)).
13+
const JetkvmServiceType = "_jetkvm._tcp"
14+
915
var mDNS *mdns.MDNS
1016

1117
func initMdns() error {
@@ -18,6 +24,7 @@ func initMdns() error {
1824
Logger: logger,
1925
LocalNames: options.LocalNames,
2026
ListenOptions: options.ListenOptions,
27+
Service: options.Service,
2128
})
2229
if err != nil {
2330
return err
@@ -28,3 +35,45 @@ func initMdns() error {
2835

2936
return nil
3037
}
38+
39+
// getMdnsServicePort returns the user-facing web server port to
40+
// advertise via Bonjour. When TLS is enabled the HTTPS server on
41+
// port 443 is the primary entry point; otherwise it's plain HTTP on
42+
// port 80.
43+
func getMdnsServicePort() int {
44+
if config != nil && config.TLSMode != "" {
45+
return 443
46+
}
47+
return 80
48+
}
49+
50+
// buildMdnsService constructs the DNS-SD service registration for
51+
// the JetKVM web server. The TXT records expose firmware version,
52+
// device ID, and the setup state so clients can filter unprovisioned
53+
// devices.
54+
func buildMdnsService() *mdns.MDNSService {
55+
if networkManager == nil {
56+
return nil
57+
}
58+
59+
instance := networkManager.Hostname()
60+
if instance == "" {
61+
instance = GetDefaultHostname()
62+
}
63+
64+
setup := "false"
65+
if config != nil && config.LocalAuthMode != "" {
66+
setup = "true"
67+
}
68+
69+
return &mdns.MDNSService{
70+
Type: JetkvmServiceType,
71+
Instance: instance,
72+
Port: getMdnsServicePort(),
73+
TXT: []string{
74+
"version=" + GetBuiltAppVersion(),
75+
"id=" + GetDeviceID(),
76+
"setup=" + setup,
77+
},
78+
}
79+
}

network.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ func getMdnsOptions() *mdns.MDNSOptions {
7070
IPv4: ipv4,
7171
IPv6: ipv6,
7272
},
73+
Service: buildMdnsService(),
7374
}
7475
}
7576

web.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,10 @@ func handleSetup(c *gin.Context) {
914914
return
915915
}
916916

917+
// Refresh the mDNS advertisement so the `setup` TXT record flips
918+
// to true now that the device is provisioned.
919+
restartMdns()
920+
917921
c.JSON(http.StatusOK, gin.H{"message": "Device setup completed successfully"})
918922
}
919923

web_tls.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ func setTLSState(s TLSState) error {
138138
startWebSecureServer()
139139
}
140140

141+
// The advertised Bonjour port follows TLS state (443 when on,
142+
// 80 when off), so refresh the mDNS service when it flips.
143+
restartMdns()
144+
141145
return nil
142146
}
143147

0 commit comments

Comments
 (0)