Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
10 changes: 6 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
73 changes: 71 additions & 2 deletions internal/mdns/mdns.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,37 @@ type MDNS struct {

localNames []string
listenOptions *MDNSListenOptions
service *MDNSService
}

type MDNSListenOptions struct {
IPv4 bool
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 (
Expand All @@ -57,6 +77,7 @@ func NewMDNS(opts *MDNSOptions) (*MDNS, error) {
lock: sync.Mutex{},
localNames: opts.LocalNames,
listenOptions: opts.ListenOptions,
service: opts.Service,
}, nil
}

Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}
8 changes: 8 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions mdns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,6 +24,7 @@ func initMdns() error {
Logger: logger,
LocalNames: options.LocalNames,
ListenOptions: options.ListenOptions,
Service: options.Service,
})
if err != nil {
return err
Expand All @@ -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"
}
Comment thread
cursor[bot] marked this conversation as resolved.

return &mdns.MDNSService{
Type: JetkvmServiceType,
Instance: instance,
Port: getMdnsServicePort(),
TXT: []string{
"version=" + GetBuiltAppVersion(),
"id=" + GetDeviceID(),
"setup=" + setup,
},
}
}
1 change: 1 addition & 0 deletions network.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func getMdnsOptions() *mdns.MDNSOptions {
IPv4: ipv4,
IPv6: ipv6,
},
Service: buildMdnsService(),
}
}

Expand Down
4 changes: 4 additions & 0 deletions web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
}

Expand Down
4 changes: 4 additions & 0 deletions web_tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading