diff --git a/README.md b/README.md index 989113cd0..f48e518a3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) s - **Ultra-low Latency** - 1080p@60FPS video with 30-60ms latency using H.264 encoding. Smooth mouse and keyboard interaction for responsive remote control. - **Free & Optional Remote Access** - Remote management via JetKVM Cloud using WebRTC. - **Optional Tailscale Networking** - Built-in Tailscale status and control-server configuration, including custom [Headscale](https://headscale.net/)-compatible endpoints. +- **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. - **Open-source software** - Written in Golang on Linux. Easily customizable through SSH access to the JetKVM device. ## Contributing diff --git a/go.mod b/go.mod index f4388a4db..48ce32b5c 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/coder/websocket v1.8.14 github.com/coreos/go-oidc/v3 v3.16.0 github.com/creack/pty v1.1.24 + github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/erikdubbelboer/gspt v0.0.0-20210805194459-ce36a5128377 github.com/fsnotify/fsnotify v1.9.0 github.com/gin-contrib/logger v1.2.6 @@ -21,8 +22,10 @@ require ( github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e github.com/mdlayher/ndp v1.1.0 github.com/pion/ice/v4 v4.1.0 + github.com/pion/interceptor v0.1.42 github.com/pion/logging v0.2.4 github.com/pion/mdns/v2 v2.1.0 + github.com/pion/rtp v1.8.27 github.com/pion/webrtc/v4 v4.2.1 github.com/pojntfx/go-nbd v0.3.2 github.com/prometheus/client_golang v1.23.2 @@ -38,7 +41,7 @@ require ( go.bug.st/serial v1.6.4 golang.org/x/crypto v0.43.0 golang.org/x/net v0.46.0 - golang.org/x/sys v0.37.0 + golang.org/x/sys v0.41.0 google.golang.org/grpc v1.76.0 google.golang.org/protobuf v1.36.10 ) @@ -54,7 +57,6 @@ require ( github.com/cloudwego/base64x v0.1.6 // indirect github.com/creack/goselect v0.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect @@ -80,10 +82,8 @@ require ( github.com/pilebones/go-udev v0.9.1 // indirect github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v3 v3.0.9 // indirect - github.com/pion/interceptor v0.1.42 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.16 // indirect - github.com/pion/rtp v1.8.27 // indirect github.com/pion/sctp v1.9.0 // indirect github.com/pion/sdp/v3 v3.0.17 // indirect github.com/pion/srtp/v3 v3.0.9 // indirect @@ -107,3 +107,11 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +// TEMPORARY: pion/mdns does not yet expose a public API to set DNS-SD +// TXT records on advertised services (the ServiceInstance.Text element +// type is unexported). This points pion/mdns/v2 at a fork branch that +// exports TXTEntry + NewTXTEntry/NewTXTFlag. Drop this replace and bump +// pion/mdns/v2 to the release that includes it once +// https://github.com/pion/mdns/pull/277 merges and ships. +replace github.com/pion/mdns/v2 => github.com/mcuelenaere/mdns/v2 v2.0.0-20260529132740-a54bccfba4f7 diff --git a/go.sum b/go.sum index 4223acb59..cbb25380a 100644 --- a/go.sum +++ b/go.sum @@ -115,6 +115,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mcuelenaere/mdns/v2 v2.0.0-20260529132740-a54bccfba4f7 h1:BZTD31GcSgA4upxhFAgrXM6vaCNM/0rvvVljKWZEFI0= +github.com/mcuelenaere/mdns/v2 v2.0.0-20260529132740-a54bccfba4f7/go.mod h1:Ouef+TvRd3g/vNEFH8EH6qkRqQbJ102AfE/7ONXF8Og= github.com/mdlayher/ndp v1.1.0 h1:QylGKGVtH60sKZUE88+IW5ila1Z/M9/OXhWdsVKuscs= github.com/mdlayher/ndp v1.1.0/go.mod h1:FmgESgemgjl38vuOIyAHWUUL6vQKA/pQNkvXdWsdQFM= 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 github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= -github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= @@ -162,6 +162,8 @@ github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU= github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA= github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA= github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A= 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= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= diff --git a/internal/mdns/mdns.go b/internal/mdns/mdns.go index ce265210c..ce845da68 100644 --- a/internal/mdns/mdns.go +++ b/internal/mdns/mdns.go @@ -22,6 +22,7 @@ type MDNS struct { localNames []string listenOptions *MDNSListenOptions + service *MDNSService } type MDNSListenOptions struct { @@ -29,10 +30,29 @@ type MDNSListenOptions struct { IPv6 bool } +// MDNSService describes a DNS-SD service to advertise alongside the +// A/AAAA records, so Bonjour browsers (NWBrowser, dns-sd, +// avahi-browse) can discover the device. +type MDNSService struct { + // Type is the DNS-SD service type, e.g. "_jetkvm._tcp". + Type string + // Instance is the user-visible instance name. If empty, the first + // local name is used. + Instance string + // Port is the TCP/UDP port the service listens on. + Port int + // TXT is the list of "key=value" entries (or bare "key" flags) to + // advertise. + TXT []string +} + type MDNSOptions struct { Logger *zerolog.Logger LocalNames []string ListenOptions *MDNSListenOptions + // Service, if non-nil, is published as a DNS-SD service whose SRV + // record points at the first local name. + Service *MDNSService } const ( @@ -57,6 +77,7 @@ func NewMDNS(opts *MDNSOptions) (*MDNS, error) { lock: sync.Mutex{}, localNames: opts.LocalNames, listenOptions: opts.ListenOptions, + service: opts.Service, }, nil } @@ -131,7 +152,12 @@ func (m *MDNS) start(allowRestart bool) error { } } - mDNSConn, err := pion_mdns.Server(p4, p6, &pion_mdns.Config{LocalNames: newLocalNames}) + opts := []pion_mdns.ServerOption{pion_mdns.WithLocalNames(newLocalNames...)} + if svc := buildServiceInstance(m.service, newLocalNames); svc != nil { + opts = append(opts, pion_mdns.WithService(*svc)) + } + + mDNSConn, err := pion_mdns.NewServer(p4, p6, opts...) if err != nil { scopeLogger.Warn().Err(err).Msg("failed to start mDNS server") @@ -144,6 +170,37 @@ func (m *MDNS) start(allowRestart bool) error { return nil } +// buildServiceInstance converts the configured MDNSService into a pion +// ServiceInstance, defaulting the instance name to the first local name +// and translating the "key=value" TXT slice into pion TXTEntry values. +// Returns nil when there is no service to advertise. +func buildServiceInstance(service *MDNSService, localNames []string) *pion_mdns.ServiceInstance { + if service == nil || service.Type == "" || service.Port <= 0 { + return nil + } + + instance := service.Instance + if instance == "" && len(localNames) > 0 { + instance = strings.TrimSuffix(localNames[0], ".local") + } + + txt := make([]pion_mdns.TXTEntry, 0, len(service.TXT)) + for _, e := range service.TXT { + if key, value, ok := strings.Cut(e, "="); ok { + txt = append(txt, pion_mdns.NewTXTEntry(key, value)) + } else if e != "" { + txt = append(txt, pion_mdns.NewTXTFlag(e)) + } + } + + return &pion_mdns.ServiceInstance{ + Instance: instance, + Service: service.Type, + Port: uint16(service.Port), + Text: txt, + } +} + // Start starts the mDNS server func (m *MDNS) Start() error { return m.start(false) @@ -190,6 +247,17 @@ func (m *MDNS) setListenOptions(listenOptions *MDNSListenOptions) { m.listenOptions = listenOptions } +func (m *MDNS) setService(service *MDNSService) { + m.lock.Lock() + defer m.lock.Unlock() + + if reflect.DeepEqual(m.service, service) { + return + } + + m.service = service +} + // SetLocalNames sets the local names and restarts the mDNS server func (m *MDNS) SetLocalNames(localNames []string) error { m.setLocalNames(localNames) @@ -202,9 +270,10 @@ func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error { return m.Restart() } -// SetOptions sets the local names and listen options and restarts the mDNS server +// SetOptions sets the local names, listen options, and service and restarts the mDNS server func (m *MDNS) SetOptions(options *MDNSOptions) error { m.setLocalNames(options.LocalNames) m.setListenOptions(options.ListenOptions) + m.setService(options.Service) return m.Restart() } diff --git a/main.go b/main.go index 5ade107dc..4ea6d1e9d 100644 --- a/main.go +++ b/main.go @@ -189,6 +189,14 @@ func Main() { logger.Log().Msg("JetKVM Shutting Down") + if mDNS != nil { + // Close the multicast sockets so we stop responding to + // browses immediately. Browsers age the entry out by TTL. + if err := mDNS.Stop(); err != nil { + logger.Warn().Err(err).Msg("failed to stop mDNS server") + } + } + //if fuseServer != nil { // err := setMassStorageImage(" ") // if err != nil { diff --git a/mdns.go b/mdns.go index c197d16fa..4f3ae2e20 100644 --- a/mdns.go +++ b/mdns.go @@ -6,6 +6,12 @@ import ( "github.com/jetkvm/kvm/internal/mdns" ) +// JetkvmServiceType is the DNS-SD service type advertised by the +// device. Bonjour clients on macOS/iOS can discover JetKVM devices +// by browsing for this type, e.g. via +// NWBrowser(for: .bonjour(type: "_jetkvm._tcp", domain: nil)). +const JetkvmServiceType = "_jetkvm._tcp" + var mDNS *mdns.MDNS func initMdns() error { @@ -18,6 +24,7 @@ func initMdns() error { Logger: logger, LocalNames: options.LocalNames, ListenOptions: options.ListenOptions, + Service: options.Service, }) if err != nil { return err @@ -28,3 +35,45 @@ func initMdns() error { return nil } + +// getMdnsServicePort returns the user-facing web server port to +// advertise via Bonjour. When TLS is enabled the HTTPS server on +// port 443 is the primary entry point; otherwise it's plain HTTP on +// port 80. +func getMdnsServicePort() int { + if config != nil && config.TLSMode != "" { + return 443 + } + return 80 +} + +// buildMdnsService constructs the DNS-SD service registration for +// the JetKVM web server. The TXT records expose firmware version, +// device ID, and the setup state so clients can filter unprovisioned +// devices. +func buildMdnsService() *mdns.MDNSService { + if networkManager == nil { + return nil + } + + instance := networkManager.Hostname() + if instance == "" { + instance = GetDefaultHostname() + } + + setup := "false" + if config != nil && config.LocalAuthMode != "" { + setup = "true" + } + + return &mdns.MDNSService{ + Type: JetkvmServiceType, + Instance: instance, + Port: getMdnsServicePort(), + TXT: []string{ + "version=" + GetBuiltAppVersion(), + "id=" + GetDeviceID(), + "setup=" + setup, + }, + } +} diff --git a/network.go b/network.go index 9b3079bac..e1c60aaff 100644 --- a/network.go +++ b/network.go @@ -70,6 +70,7 @@ func getMdnsOptions() *mdns.MDNSOptions { IPv4: ipv4, IPv6: ipv6, }, + Service: buildMdnsService(), } } diff --git a/web.go b/web.go index 1e1bf796f..f1444730f 100644 --- a/web.go +++ b/web.go @@ -914,6 +914,10 @@ func handleSetup(c *gin.Context) { return } + // Refresh the mDNS advertisement so the `setup` TXT record flips + // to true now that the device is provisioned. + restartMdns() + c.JSON(http.StatusOK, gin.H{"message": "Device setup completed successfully"}) } diff --git a/web_tls.go b/web_tls.go index 8376ba9a2..49a3e63da 100644 --- a/web_tls.go +++ b/web_tls.go @@ -138,6 +138,10 @@ func setTLSState(s TLSState) error { startWebSecureServer() } + // The advertised Bonjour port follows TLS state (443 when on, + // 80 when off), so refresh the mDNS service when it flips. + restartMdns() + return nil }