Skip to content

Commit 0e3b908

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 0e3b908

9 files changed

Lines changed: 154 additions & 7 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: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ require (
3838
go.bug.st/serial v1.6.4
3939
golang.org/x/crypto v0.43.0
4040
golang.org/x/net v0.46.0
41-
golang.org/x/sys v0.37.0
41+
golang.org/x/sys v0.41.0
4242
google.golang.org/grpc v1.76.0
4343
google.golang.org/protobuf v1.36.10
4444
)
@@ -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. This points pion/mdns/v2 at a
113+
// fork branch that exports TXTEntry + NewTXTString/NewTXTBinary/
114+
// NewTXTFlag. Drop this replace and bump pion/mdns/v2 to the release
115+
// that includes it once https://github.com/pion/mdns/pull/277 merges
116+
// and ships.
117+
replace github.com/pion/mdns/v2 => github.com/mcuelenaere/mdns/v2 v2.0.0-20260606193126-50dcf6722ea6

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-20260606193126-50dcf6722ea6 h1:Agr4RkH9dM/8hw7tqYbW/vnpKqHx9ifo2zWmvBQmHag=
119+
github.com/mcuelenaere/mdns/v2 v2.0.0-20260606193126-50dcf6722ea6/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: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,48 @@ 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+
// TXTEntry is a single DNS-SD TXT record key/value pair. Build entries
34+
// with the re-exported NewTXT* constructors below.
35+
type TXTEntry = pion_mdns.TXTEntry
36+
37+
// Re-exported TXT entry constructors (see pion/mdns for semantics), so
38+
// callers can build typed TXT records without importing pion directly.
39+
var (
40+
NewTXTString = pion_mdns.NewTXTString
41+
NewTXTBinary = pion_mdns.NewTXTBinary
42+
NewTXTFlag = pion_mdns.NewTXTFlag
43+
)
44+
45+
// MDNSService describes a DNS-SD service to advertise alongside the
46+
// A/AAAA records, so Bonjour browsers (NWBrowser, dns-sd,
47+
// avahi-browse) can discover the device.
48+
type MDNSService struct {
49+
// Type is the DNS-SD service type, e.g. "_jetkvm._tcp".
50+
Type string
51+
// Instance is the user-visible instance name. If empty, the first
52+
// local name is used.
53+
Instance string
54+
// Port is the TCP/UDP port the service listens on.
55+
Port int
56+
// TXT is the list of TXT record entries to advertise.
57+
TXT []TXTEntry
58+
}
59+
3260
type MDNSOptions struct {
3361
Logger *zerolog.Logger
3462
LocalNames []string
3563
ListenOptions *MDNSListenOptions
64+
// Service, if non-nil, is published as a DNS-SD service whose SRV
65+
// record points at the first local name.
66+
Service *MDNSService
3667
}
3768

3869
const (
@@ -57,6 +88,7 @@ func NewMDNS(opts *MDNSOptions) (*MDNS, error) {
5788
lock: sync.Mutex{},
5889
localNames: opts.LocalNames,
5990
listenOptions: opts.ListenOptions,
91+
service: opts.Service,
6092
}, nil
6193
}
6294

@@ -131,7 +163,12 @@ func (m *MDNS) start(allowRestart bool) error {
131163
}
132164
}
133165

134-
mDNSConn, err := pion_mdns.Server(p4, p6, &pion_mdns.Config{LocalNames: newLocalNames})
166+
opts := []pion_mdns.ServerOption{pion_mdns.WithLocalNames(newLocalNames...)}
167+
if svc := buildServiceInstance(m.service, newLocalNames); svc != nil {
168+
opts = append(opts, pion_mdns.WithService(*svc))
169+
}
170+
171+
mDNSConn, err := pion_mdns.NewServer(p4, p6, opts...)
135172

136173
if err != nil {
137174
scopeLogger.Warn().Err(err).Msg("failed to start mDNS server")
@@ -144,6 +181,27 @@ func (m *MDNS) start(allowRestart bool) error {
144181
return nil
145182
}
146183

184+
// buildServiceInstance converts the configured MDNSService into a pion
185+
// ServiceInstance, defaulting the instance name to the first local name.
186+
// Returns nil when there is no service to advertise.
187+
func buildServiceInstance(service *MDNSService, localNames []string) *pion_mdns.ServiceInstance {
188+
if service == nil || service.Type == "" || service.Port <= 0 {
189+
return nil
190+
}
191+
192+
instance := service.Instance
193+
if instance == "" && len(localNames) > 0 {
194+
instance = strings.TrimSuffix(localNames[0], ".local")
195+
}
196+
197+
return &pion_mdns.ServiceInstance{
198+
Instance: instance,
199+
Service: service.Type,
200+
Port: uint16(service.Port),
201+
Text: service.TXT,
202+
}
203+
}
204+
147205
// Start starts the mDNS server
148206
func (m *MDNS) Start() error {
149207
return m.start(false)
@@ -190,6 +248,17 @@ func (m *MDNS) setListenOptions(listenOptions *MDNSListenOptions) {
190248
m.listenOptions = listenOptions
191249
}
192250

251+
func (m *MDNS) setService(service *MDNSService) {
252+
m.lock.Lock()
253+
defer m.lock.Unlock()
254+
255+
if reflect.DeepEqual(m.service, service) {
256+
return
257+
}
258+
259+
m.service = service
260+
}
261+
193262
// SetLocalNames sets the local names and restarts the mDNS server
194263
func (m *MDNS) SetLocalNames(localNames []string) error {
195264
m.setLocalNames(localNames)
@@ -202,9 +271,10 @@ func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error {
202271
return m.Restart()
203272
}
204273

205-
// SetOptions sets the local names and listen options and restarts the mDNS server
274+
// SetOptions sets the local names, listen options, and service and restarts the mDNS server
206275
func (m *MDNS) SetOptions(options *MDNSOptions) error {
207276
m.setLocalNames(options.LocalNames)
208277
m.setListenOptions(options.ListenOptions)
278+
m.setService(options.Service)
209279
return m.Restart()
210280
}

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: []mdns.TXTEntry{
74+
mdns.NewTXTString("version", GetBuiltAppVersion()),
75+
mdns.NewTXTString("id", GetDeviceID()),
76+
mdns.NewTXTString("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)