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..54f96a4b9 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/mdlayher/ndp v1.1.0 github.com/pion/ice/v4 v4.1.0 github.com/pion/logging v0.2.4 - github.com/pion/mdns/v2 v2.1.0 + github.com/pion/mdns/v2 v2.1.1-0.20260609144526-3724545d31eb 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 +38,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 ) diff --git a/go.sum b/go.sum index 4223acb59..1e3e77f86 100644 --- a/go.sum +++ b/go.sum @@ -144,8 +144,8 @@ 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/mdns/v2 v2.1.1-0.20260609144526-3724545d31eb h1:9foh7K/Ktd48ct20j/esXgmy7LmSyrV+ZBJeoTvlhT0= +github.com/pion/mdns/v2 v2.1.1-0.20260609144526-3724545d31eb/go.mod h1:Ouef+TvRd3g/vNEFH8EH6qkRqQbJ102AfE/7ONXF8Og= 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..367ad0b35 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,40 @@ type MDNSListenOptions struct { IPv6 bool } +// TXTEntry is a single DNS-SD TXT record key/value pair. Build entries +// with the re-exported NewTXT* constructors below. +type TXTEntry = pion_mdns.TXTEntry + +// Re-exported TXT entry constructors (see pion/mdns for semantics), so +// callers can build typed TXT records without importing pion directly. +var ( + NewTXTString = pion_mdns.NewTXTString + NewTXTBinary = pion_mdns.NewTXTBinary + NewTXTFlag = pion_mdns.NewTXTFlag +) + +// 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 TXT record entries to advertise. + TXT []TXTEntry +} + 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 +88,7 @@ func NewMDNS(opts *MDNSOptions) (*MDNS, error) { lock: sync.Mutex{}, localNames: opts.LocalNames, listenOptions: opts.ListenOptions, + service: opts.Service, }, nil } @@ -131,7 +163,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 +181,27 @@ 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. +// 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") + } + + return &pion_mdns.ServiceInstance{ + Instance: instance, + Service: service.Type, + Port: uint16(service.Port), + Text: service.TXT, + } +} + // Start starts the mDNS server func (m *MDNS) Start() error { return m.start(false) @@ -190,6 +248,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 +271,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..2f6de479c 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: []mdns.TXTEntry{ + mdns.NewTXTString("version", GetBuiltAppVersion()), + mdns.NewTXTString("id", GetDeviceID()), + mdns.NewTXTString("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 }