├── examples ├── bye │ ├── .norelease │ └── bye.go ├── alive │ ├── .norelease │ └── alive.go ├── monitor │ ├── .norelease │ └── monitor.go ├── search │ ├── .norelease │ └── search.go └── advertise │ ├── .norelease │ └── advertise.go ├── .gitignore ├── doc.go ├── internal ├── multicast │ ├── doc.go │ ├── interface_test.go │ ├── udp_test.go │ ├── udp.go │ ├── interface.go │ └── multicast.go └── ssdplog │ ├── ssdplog.go │ └── ssdplog_test.go ├── go.mod ├── staticcheck.conf ├── .circleci └── config.yml ├── go.sum ├── monitor_test.go ├── .github ├── dependabot.yml └── workflows │ ├── go.yml │ └── release.yml ├── ssdp.go ├── LICENSE ├── location.go ├── option.go ├── Makefile ├── README.md ├── announce_test.go ├── announce.go ├── search.go ├── advertise_test.go ├── advertise.go ├── monitor.go └── search_test.go /examples/bye/.norelease: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/alive/.norelease: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/monitor/.norelease: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/search/.norelease: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/advertise/.norelease: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *~ 3 | default.pgo 4 | tags 5 | tmp/ 6 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package ssdp provides SSDP advertiser or so. 3 | */ 4 | package ssdp 5 | -------------------------------------------------------------------------------- /internal/multicast/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package multicast provides utilities for network multicast. 3 | */ 4 | package multicast 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/koron/go-ssdp 2 | 3 | go 1.24.0 4 | 5 | require golang.org/x/net v0.44.0 6 | 7 | require golang.org/x/sys v0.36.0 // indirect 8 | -------------------------------------------------------------------------------- /staticcheck.conf: -------------------------------------------------------------------------------- 1 | # vim:set ft=toml: 2 | 3 | checks = ["all"] 4 | 5 | # based on: github.com/koron-go/_skeleton/staticcheck.conf 6 | # $Hash:cd6871e83e780f6a2ce05386c0551034badf78b2ad40a76a8f6f5510$ 7 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/golang:latest 6 | working_directory: /go/src/github.com/koron/go-ssdp 7 | steps: 8 | - checkout 9 | - run: go get -v -t -d ./... 10 | - run: go test -v ./... 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= 2 | golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= 3 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 4 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 5 | -------------------------------------------------------------------------------- /internal/ssdplog/ssdplog.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package ssdplog provides log mechanism for ssdp. 3 | */ 4 | package ssdplog 5 | 6 | import "log" 7 | 8 | var LoggerProvider = func() *log.Logger { return nil } 9 | 10 | func Printf(s string, a ...any) { 11 | if p := LoggerProvider; p != nil { 12 | if l := p(); l != nil { 13 | l.Printf(s, a...) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /monitor_test.go: -------------------------------------------------------------------------------- 1 | package ssdp 2 | 3 | func newTestMonitor(typ string, alive AliveHandler, bye ByeHandler, search SearchHandler) *Monitor { 4 | m := &Monitor{} 5 | if alive != nil { 6 | m.Alive = func(am *AliveMessage) { 7 | if am.Type == typ { 8 | alive(am) 9 | } 10 | } 11 | } 12 | if bye != nil { 13 | m.Bye = func(bm *ByeMessage) { 14 | if bm.Type == typ { 15 | bye(bm) 16 | } 17 | } 18 | } 19 | if search != nil { 20 | m.Search = func(sm *SearchMessage) { 21 | if sm.Type == typ { 22 | search(sm) 23 | } 24 | } 25 | } 26 | return m 27 | } 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | 13 | # $Hash:c5d3212bc9191230f44684f4d30f030b6c70d515e68cbc9c3467c4d9$ 14 | -------------------------------------------------------------------------------- /internal/multicast/interface_test.go: -------------------------------------------------------------------------------- 1 | package multicast 2 | 3 | import ( 4 | "net" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestInterfaces(t *testing.T) { 10 | list, err := interfaces() 11 | if err != nil { 12 | t.Fatalf("interfaces() failed: %s", err) 13 | } 14 | if len(list) == 0 { 15 | t.Error("interfaces() returns no interfaces") 16 | } 17 | } 18 | 19 | func TestInterafceProviders(t *testing.T) { 20 | want := []net.Interface{ 21 | {Index: 123, Name: "Test#1"}, 22 | {Index: 456, Name: "Test#2"}, 23 | {Index: 789, Name: "Test#3"}, 24 | } 25 | InterfacesProvider = func() []net.Interface { 26 | return want 27 | } 28 | defer func() { InterfacesProvider = nil }() 29 | got, err := interfaces() 30 | if err != nil { 31 | t.Fatalf("interfaces() failed: %s", err) 32 | } 33 | if !reflect.DeepEqual(got, want) { 34 | t.Errorf("unexpected interfaces:\nwant=%+v\ngot=%+v", want, got) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/ssdplog/ssdplog_test.go: -------------------------------------------------------------------------------- 1 | package ssdplog_test 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "testing" 7 | 8 | "github.com/koron/go-ssdp/internal/ssdplog" 9 | ) 10 | 11 | func TestLoggerProvider(t *testing.T) { 12 | b := &bytes.Buffer{} 13 | logger := log.New(b, "", 0) 14 | 15 | ssdplog.Printf("never output") 16 | if s := b.String(); s != "" { 17 | t.Errorf("unexpected log #1:\nwant=(empty)\n got=%q", s) 18 | } 19 | 20 | // provide LoggerProvider 21 | ssdplog.LoggerProvider = func() *log.Logger { return logger } 22 | ssdplog.Printf("foo") 23 | if s := b.String(); s != "foo\n" { 24 | t.Errorf("unexpected log #1:\nwant=%q\n got=%q", "foo\n", s) 25 | } 26 | 27 | // disable LoggerProvider, output buffer never changed 28 | ssdplog.LoggerProvider = func() *log.Logger { return nil } 29 | ssdplog.Printf("bar") 30 | if s := b.String(); s != "foo\n" { 31 | t.Errorf("unexpected log #1:\nwant=%q\n got=%q", "foo\n", s) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/bye/bye.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | 8 | "github.com/koron/go-ssdp" 9 | ) 10 | 11 | func main() { 12 | nt := flag.String("nt", "my:device", "NT: Type") 13 | usn := flag.String("usn", "unique:id", "USN: ID") 14 | laddr := flag.String("laddr", "", "local address to listen") 15 | ttl := flag.Int("ttl", 0, "TTL for outgoing multicast packets") 16 | sysIf := flag.Bool("sysif", false, "use system assigned multicast interface") 17 | v := flag.Bool("v", false, "verbose mode") 18 | h := flag.Bool("h", false, "show help") 19 | flag.Parse() 20 | 21 | if *h { 22 | flag.Usage() 23 | return 24 | } 25 | if *v { 26 | ssdp.Logger = log.New(os.Stderr, "[SSDP] ", log.LstdFlags) 27 | } 28 | 29 | var opts []ssdp.Option 30 | if *ttl > 0 { 31 | opts = append(opts, ssdp.TTL(*ttl)) 32 | } 33 | if *sysIf { 34 | opts = append(opts, ssdp.OnlySystemInterface()) 35 | } 36 | 37 | err := ssdp.AnnounceBye(*nt, *usn, *laddr, opts...) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ssdp.go: -------------------------------------------------------------------------------- 1 | package ssdp 2 | 3 | import ( 4 | "log" 5 | "net" 6 | 7 | "github.com/koron/go-ssdp/internal/multicast" 8 | "github.com/koron/go-ssdp/internal/ssdplog" 9 | ) 10 | 11 | func init() { 12 | multicast.InterfacesProvider = func() []net.Interface { 13 | return Interfaces 14 | } 15 | ssdplog.LoggerProvider = func() *log.Logger { 16 | return Logger 17 | } 18 | } 19 | 20 | // Interfaces specify target interfaces to multicast. If no interfaces are 21 | // specified, all interfaces will be used. 22 | var Interfaces []net.Interface 23 | 24 | // Logger is default logger for SSDP module. 25 | var Logger *log.Logger 26 | 27 | // SetMulticastRecvAddrIPv4 updates multicast address where to receive packets. 28 | // This never fail now. 29 | func SetMulticastRecvAddrIPv4(addr string) error { 30 | return multicast.SetRecvAddrIPv4(addr) 31 | } 32 | 33 | // SetMulticastSendAddrIPv4 updates a UDP address to send multicast packets. 34 | // This never fail now. 35 | func SetMulticastSendAddrIPv4(addr string) error { 36 | return multicast.SetSendAddrIPv4(addr) 37 | } 38 | -------------------------------------------------------------------------------- /examples/search/search.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/koron/go-ssdp" 10 | ) 11 | 12 | func main() { 13 | t := flag.String("t", ssdp.All, "search type") 14 | w := flag.Int("w", 1, "wait time") 15 | l := flag.String("l", "", "local address to listen") 16 | ttl := flag.Int("ttl", 0, "TTL for outgoing multicast packets") 17 | sysIf := flag.Bool("sysif", false, "use system assigned multicast interface") 18 | v := flag.Bool("v", false, "verbose mode") 19 | h := flag.Bool("h", false, "show help") 20 | flag.Parse() 21 | if *h { 22 | flag.Usage() 23 | return 24 | } 25 | if *v { 26 | ssdp.Logger = log.New(os.Stderr, "[SSDP] ", log.LstdFlags) 27 | } 28 | 29 | var opts []ssdp.Option 30 | if *ttl > 0 { 31 | opts = append(opts, ssdp.TTL(*ttl)) 32 | } 33 | if *sysIf { 34 | opts = append(opts, ssdp.OnlySystemInterface()) 35 | } 36 | 37 | list, err := ssdp.Search(*t, *w, *l, opts...) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | for i, srv := range list { 42 | //fmt.Printf("%d: %#v\n", i, srv) 43 | fmt.Printf("%d: %s %s\n", i, srv.Type, srv.Location) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 MURAOKA Taro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /location.go: -------------------------------------------------------------------------------- 1 | package ssdp 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | ) 7 | 8 | // LocationProvider provides address for Location header which can be reached from 9 | // "from" address network. 10 | type LocationProvider interface { 11 | // Location provides an address be reachable from the network located 12 | // by "from" address or "ifi" interface. 13 | // One of "from" or "ifi" must not be nil. 14 | Location(from net.Addr, ifi *net.Interface) string 15 | } 16 | 17 | // LocationProviderFunc type is an adapter to allow the use of ordinary 18 | // functions are location providers. 19 | type LocationProviderFunc func(net.Addr, *net.Interface) string 20 | 21 | func (f LocationProviderFunc) Location(from net.Addr, ifi *net.Interface) string { 22 | return f(from, ifi) 23 | } 24 | 25 | type fixedLocation string 26 | 27 | func (s fixedLocation) Location(net.Addr, *net.Interface) string { 28 | return string(s) 29 | } 30 | 31 | func toLocationProvider(v any) (LocationProvider, error) { 32 | switch w := v.(type) { 33 | case string: 34 | return fixedLocation(w), nil 35 | case LocationProvider: 36 | return w, nil 37 | default: 38 | return nil, fmt.Errorf("location should be a string or a ssdp.LocationProvider but got %T", w) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/alive/alive.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | 8 | "github.com/koron/go-ssdp" 9 | ) 10 | 11 | func main() { 12 | nt := flag.String("nt", "my:device", "NT: Type") 13 | usn := flag.String("usn", "unique:id", "USN: ID") 14 | loc := flag.String("loc", "", "LOCATION: location header") 15 | srv := flag.String("srv", "", "SERVER: server header") 16 | maxAge := flag.Int("maxage", 1800, "cache control, max-age") 17 | laddr := flag.String("laddr", "", "local address to listen") 18 | ttl := flag.Int("ttl", 0, "TTL for outgoing multicast packets") 19 | sysIf := flag.Bool("sysif", false, "use system assigned multicast interface") 20 | v := flag.Bool("v", false, "verbose mode") 21 | h := flag.Bool("h", false, "show help") 22 | flag.Parse() 23 | 24 | if *h { 25 | flag.Usage() 26 | return 27 | } 28 | if *v { 29 | ssdp.Logger = log.New(os.Stderr, "[SSDP] ", log.LstdFlags) 30 | } 31 | 32 | var opts []ssdp.Option 33 | if *ttl > 0 { 34 | opts = append(opts, ssdp.TTL(*ttl)) 35 | } 36 | if *sysIf { 37 | opts = append(opts, ssdp.OnlySystemInterface()) 38 | } 39 | 40 | err := ssdp.AnnounceAlive(*nt, *usn, *loc, *srv, *maxAge, *laddr, opts...) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [push] 4 | 5 | env: 6 | GO_VERSION: '>=1.24.0' 7 | 8 | jobs: 9 | 10 | build: 11 | name: Build 12 | runs-on: ${{ matrix.os }} 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: 18 | - ubuntu-latest 19 | - ubuntu-24.04-arm 20 | # some of tests are failed with macos-latest (macos-14). 21 | # see also: https://github.com/koron/go-ssdp/issues/42 22 | - macos-13 23 | - windows-latest 24 | 25 | steps: 26 | 27 | - uses: actions/checkout@v6 28 | 29 | - uses: actions/setup-go@v6 30 | with: 31 | go-version: ${{ env.GO_VERSION }} 32 | 33 | - run: go test ./... 34 | 35 | - name: Build all "main" packages 36 | shell: bash 37 | run: | 38 | go list -f '{{if (eq .Name "main")}}{{.ImportPath}} .{{slice .ImportPath (len .Module.Path)}}{{end}}' ./... | while IFS= read -r line ; do 39 | read -a e <<< "$line" 40 | path="${e[0]}" 41 | dir="${e[1]}" 42 | name=$(basename $path) 43 | printf "building %s\t(%s)\n" $name $dir 44 | ( cd "$dir" && go build ) 45 | done 46 | 47 | # based on: github.com/koron-go/_skeleton/.github/workflows/go.yml 48 | # $Hash:5c453adb7ee86afc50e6bbb626326f86d1e9b7ebc92cc12d74227105$ 49 | -------------------------------------------------------------------------------- /internal/multicast/udp_test.go: -------------------------------------------------------------------------------- 1 | package multicast 2 | 3 | import "testing" 4 | 5 | func TestSetMulticastRecvAddrIPv4(t *testing.T) { 6 | // resolve with default 7 | _, err := RecvAddrResolver.resolve() 8 | if err != nil { 9 | t.Errorf("resolve #1 failed: %s", err) 10 | } 11 | 12 | // resolve after override 13 | SetRecvAddrIPv4("224.0.0.1:1900") 14 | if RecvAddrResolver.Addr != "224.0.0.1:1900" { 15 | t.Errorf("unexpected RecvAddrResolver.Addr:\nwant=%q got=%q", "224.0.0.1:1900", RecvAddrResolver.Addr) 16 | } 17 | _, err = RecvAddrResolver.resolve() 18 | if err != nil { 19 | t.Errorf("resolve #2 failed: %s", err) 20 | } 21 | } 22 | 23 | func TestSetMulticastSendAddrIPv4(t *testing.T) { 24 | // resolve with default 25 | _, err := SendAddr() 26 | if err != nil { 27 | t.Errorf("resolve #1 failed: %s", err) 28 | } 29 | 30 | // resolve after override 31 | SetSendAddrIPv4("239.255.255.250:1900") 32 | if sendAddrResolver.Addr != "239.255.255.250:1900" { 33 | t.Errorf("unexpected sendAddrResolver.Addr:\nwant=%q got=%q", "239.255.255.250:1900", sendAddrResolver.Addr) 34 | } 35 | first, err := SendAddr() 36 | if err != nil { 37 | t.Errorf("resolve #2 failed: %s", err) 38 | } 39 | 40 | // resolve by cache 41 | second, err := SendAddr() 42 | if err != nil { 43 | t.Errorf("resolve #3 failed: %s", err) 44 | } 45 | if second != first { 46 | t.Errorf("cache mismatch: first=%p second=%p", first, second) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/multicast/udp.go: -------------------------------------------------------------------------------- 1 | package multicast 2 | 3 | import ( 4 | "net" 5 | "sync" 6 | ) 7 | 8 | type PacketHandler func(net.Addr, []byte) error 9 | 10 | type AddrResolver struct { 11 | Addr string 12 | 13 | mu sync.Mutex 14 | udp *net.UDPAddr 15 | err error 16 | } 17 | 18 | func (r *AddrResolver) setAddress(addr string) { 19 | r.mu.Lock() 20 | r.Addr = addr 21 | r.udp = nil 22 | r.err = nil 23 | r.mu.Unlock() 24 | } 25 | 26 | func (r *AddrResolver) resolve() (*net.UDPAddr, error) { 27 | r.mu.Lock() 28 | defer r.mu.Unlock() 29 | 30 | if err := r.err; err != nil { 31 | return nil, err 32 | } 33 | if udp := r.udp; udp != nil { 34 | return udp, nil 35 | } 36 | 37 | r.udp, r.err = net.ResolveUDPAddr("udp4", r.Addr) 38 | return r.udp, r.err 39 | } 40 | 41 | var RecvAddrResolver = &AddrResolver{Addr: "224.0.0.1:1900"} 42 | 43 | // SetRecvAddrIPv4 updates multicast address where to receive packets. 44 | // This never fail now. 45 | func SetRecvAddrIPv4(addr string) error { 46 | RecvAddrResolver.setAddress(addr) 47 | return nil 48 | } 49 | 50 | var sendAddrResolver = &AddrResolver{Addr: "239.255.255.250:1900"} 51 | 52 | // SendAddr returns an address to send multicast UDP package. 53 | func SendAddr() (*net.UDPAddr, error) { 54 | return sendAddrResolver.resolve() 55 | } 56 | 57 | // SetSendAddrIPv4 updates a UDP address to send multicast packets. 58 | // This never fail now. 59 | func SetSendAddrIPv4(addr string) error { 60 | sendAddrResolver.setAddress(addr) 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /examples/advertise/advertise.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "time" 9 | 10 | "github.com/koron/go-ssdp" 11 | ) 12 | 13 | func main() { 14 | st := flag.String("st", "my:device", "ST: Type") 15 | usn := flag.String("usn", "unique:id", "USN: ID") 16 | loc := flag.String("loc", "", "LOCATION: location header") 17 | srv := flag.String("srv", "", "SERVER: server header") 18 | maxAge := flag.Int("maxage", 1800, "cache control, max-age") 19 | ai := flag.Int("ai", 10, "alive interval") 20 | ttl := flag.Int("ttl", 0, "TTL for outgoing multicast packets") 21 | sysIf := flag.Bool("sysif", false, "use system assigned multicast interface") 22 | v := flag.Bool("v", false, "verbose mode") 23 | h := flag.Bool("h", false, "show help") 24 | flag.Parse() 25 | if *h { 26 | flag.Usage() 27 | return 28 | } 29 | if *v { 30 | ssdp.Logger = log.New(os.Stderr, "[SSDP] ", log.LstdFlags) 31 | } 32 | 33 | var opts []ssdp.Option 34 | if *ttl > 0 { 35 | opts = append(opts, ssdp.TTL(*ttl)) 36 | } 37 | if *sysIf { 38 | opts = append(opts, ssdp.OnlySystemInterface()) 39 | } 40 | 41 | ad, err := ssdp.Advertise(*st, *usn, *loc, *srv, *maxAge, opts...) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | var aliveTick <-chan time.Time 46 | if *ai > 0 { 47 | aliveTick = time.Tick(time.Duration(*ai) * time.Second) 48 | } else { 49 | aliveTick = make(chan time.Time) 50 | } 51 | quit := make(chan os.Signal, 1) 52 | signal.Notify(quit, os.Interrupt) 53 | 54 | loop: 55 | for { 56 | select { 57 | case <-aliveTick: 58 | ad.Alive() 59 | case <-quit: 60 | break loop 61 | } 62 | } 63 | ad.Bye() 64 | ad.Close() 65 | } 66 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package ssdp 2 | 3 | import "github.com/koron/go-ssdp/internal/multicast" 4 | 5 | type config struct { 6 | multicastConfig 7 | advertiseConfig 8 | } 9 | 10 | func opts2config(opts []Option) (cfg config, err error) { 11 | for _, o := range opts { 12 | err := o.apply(&cfg) 13 | if err != nil { 14 | return config{}, err 15 | } 16 | } 17 | return cfg, nil 18 | } 19 | 20 | type multicastConfig struct { 21 | ttl int 22 | sysIf bool 23 | } 24 | 25 | func (mc multicastConfig) options() (opts []multicast.ConnOption) { 26 | if mc.ttl > 0 { 27 | opts = append(opts, multicast.ConnTTL(mc.ttl)) 28 | } 29 | if mc.sysIf { 30 | opts = append(opts, multicast.ConnSystemAssginedInterface()) 31 | } 32 | return opts 33 | } 34 | 35 | type advertiseConfig struct { 36 | addHost bool 37 | } 38 | 39 | // Option is option set for SSDP API. 40 | type Option interface { 41 | apply(c *config) error 42 | } 43 | 44 | type optionFunc func(*config) error 45 | 46 | func (of optionFunc) apply(c *config) error { 47 | return of(c) 48 | } 49 | 50 | // TTL returns as Option that set TTL for multicast packets. 51 | func TTL(ttl int) Option { 52 | return optionFunc(func(c *config) error { 53 | c.ttl = ttl 54 | return nil 55 | }) 56 | } 57 | 58 | // OnlySystemInterface returns as Option that using only a system assigned 59 | // multicast interface. 60 | func OnlySystemInterface() Option { 61 | return optionFunc(func(c *config) error { 62 | c.sysIf = true 63 | return nil 64 | }) 65 | } 66 | 67 | // AdvertiseHost returns as Option that add HOST header to response for 68 | // M-SEARCH requests. 69 | // This option works with Advertise() function only. 70 | // This is added to support SmartThings. 71 | // See https://github.com/koron/go-ssdp/issues/30 for details. 72 | func AdvertiseHost() Option { 73 | return optionFunc(func(c *config) error { 74 | c.addHost = true 75 | return nil 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /examples/monitor/monitor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | 8 | "github.com/koron/go-ssdp" 9 | ) 10 | 11 | var filterType string 12 | 13 | func main() { 14 | v := flag.Bool("v", false, "verbose mode") 15 | h := flag.Bool("h", false, "show help") 16 | flag.StringVar(&filterType, "filter_type", "", "print only a specified type (ST or NT). default is print all types.") 17 | ttl := flag.Int("ttl", 0, "TTL for outgoing multicast packets") 18 | sysIf := flag.Bool("sysif", false, "use system assigned multicast interface") 19 | flag.Parse() 20 | 21 | if *h { 22 | flag.Usage() 23 | return 24 | } 25 | if *v { 26 | ssdp.Logger = log.New(os.Stderr, "[SSDP] ", log.LstdFlags) 27 | } 28 | 29 | var opts []ssdp.Option 30 | if *ttl > 0 { 31 | opts = append(opts, ssdp.TTL(*ttl)) 32 | } 33 | if *sysIf { 34 | opts = append(opts, ssdp.OnlySystemInterface()) 35 | } 36 | 37 | m := &ssdp.Monitor{ 38 | Alive: onAlive, 39 | Bye: onBye, 40 | Search: onSearch, 41 | Options: opts, 42 | } 43 | if err := m.Start(); err != nil { 44 | log.Fatal(err) 45 | } 46 | // wait infinitely 47 | ch := make(chan struct{}) 48 | <-ch 49 | } 50 | 51 | // filterByType returns true when given type must be hidden. 52 | func filterByType(typ string) bool { 53 | if filterType != "" && filterType != typ { 54 | return true 55 | } 56 | return false 57 | } 58 | 59 | func onAlive(m *ssdp.AliveMessage) { 60 | if filterByType(m.Type) { 61 | return 62 | } 63 | 64 | log.Printf("Alive: From=%s Type=%s USN=%s Location=%s Server=%s MaxAge=%d", 65 | m.From.String(), m.Type, m.USN, m.Location, m.Server, m.MaxAge()) 66 | } 67 | 68 | func onBye(m *ssdp.ByeMessage) { 69 | if filterByType(m.Type) { 70 | return 71 | } 72 | 73 | log.Printf("Bye: From=%s Type=%s USN=%s", m.From.String(), m.Type, m.USN) 74 | } 75 | 76 | func onSearch(m *ssdp.SearchMessage) { 77 | if filterByType(m.Type) { 78 | return 79 | } 80 | 81 | log.Printf("Search: From=%s Type=%s", m.From.String(), m.Type) 82 | } 83 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Get relative paths of all "main" packages 2 | MAIN_PACKAGE ?= $(shell go list -f '{{if (eq .Name "main")}}.{{slice .ImportPath (len .Module.Path)}}{{end}}' ./...) 3 | 4 | TEST_PACKAGE ?= ./... 5 | 6 | .PHONY: build 7 | build: 8 | go build -gcflags '-e' ./... 9 | 10 | .PHONY: test 11 | test: 12 | go test $(TEST_PACKAGE) 13 | 14 | .PHONY: race 15 | race: 16 | go test -race $(TEST_PACKAGE) 17 | 18 | .PHONY: bench 19 | bench: 20 | go test -bench $(TEST_PACKAGE) 21 | 22 | .PHONY: tags 23 | tags: 24 | gotags -f tags -R . 25 | 26 | .PHONY: cover 27 | cover: 28 | mkdir -p tmp 29 | go test -coverprofile tmp/_cover.out $(TEST_PACKAGE) 30 | go tool cover -html tmp/_cover.out -o tmp/cover.html 31 | 32 | .PHONY: checkall 33 | checkall: vet staticcheck 34 | 35 | .PHONY: vet 36 | vet: 37 | go vet $(TEST_PACKAGE) 38 | 39 | .PHONY: staticcheck 40 | staticcheck: 41 | staticcheck $(TEST_PACKAGE) 42 | 43 | .PHONY: clean 44 | clean: 45 | go clean 46 | rm -f tags 47 | rm -f tmp/_cover.out tmp/cover.html 48 | 49 | .PHONY: upgradable 50 | upgradable: 51 | @go list -m -mod=readonly -u -f='{{if and (not .Indirect) (not .Main)}}{{if .Update}}{{.Path}}@{{.Update.Version}} [{{.Version}}]{{else if .Replace}}{{if .Replace.Update}}{{.Path}}@{{.Replace.Update.Version}} [replaced:{{.Replace.Version}} {{.Version}}]{{end}}{{end}}{{end}}' all 52 | 53 | .PHONY: upgradable-all 54 | upgradable-all: 55 | @go list -m -u -f '{{if .Update}}{{.Path}} {{.Version}} [{{.Update.Version}}]{{end}}' all 56 | 57 | # Build all "main" packages 58 | .PHONY: main-build 59 | main-build: 60 | @for d in $(MAIN_PACKAGE) ; do \ 61 | echo "cd $$d && go build -gcflags '-e'" ; \ 62 | ( cd $$d && go build -gcflags '-e' ) ; \ 63 | done 64 | 65 | # Clean all "main" packages 66 | .PHONY: main-clean 67 | main-clean: 68 | @for d in $(MAIN_PACKAGE) ; do \ 69 | echo "cd $$d && go clean" ; \ 70 | ( cd $$d && go clean ) ; \ 71 | done 72 | 73 | # based on: github.com/koron-go/_skeleton/Makefile 74 | # $Hash:93a5966a0297543bcdd82a4dd9c2d60232a1b02c49cfa0b4341fdb71$ 75 | -------------------------------------------------------------------------------- /internal/multicast/interface.go: -------------------------------------------------------------------------------- 1 | package multicast 2 | 3 | import ( 4 | "net" 5 | ) 6 | 7 | type InterfacesProviderFunc func() []net.Interface 8 | 9 | // InterfacesProvider specify a function to list all interfaces to multicast. 10 | // If no provider are given, all possible interfaces will be used. 11 | var InterfacesProvider InterfacesProviderFunc 12 | 13 | // SystemAssignedInterface indicates use the system assigned multicast interface or not. 14 | // InterfacesProvider will be ignored when this is true. 15 | var SystemAssignedInterface bool = false 16 | 17 | // interfaces gets list of net.Interface to multicast UDP packet. 18 | func interfaces() ([]net.Interface, error) { 19 | if p := InterfacesProvider; p != nil { 20 | if list := p(); len(list) > 0 { 21 | return list, nil 22 | } 23 | } 24 | return interfacesIPv4() 25 | } 26 | 27 | // interfacesIPv4 lists net.Interface on IPv4. 28 | func interfacesIPv4() ([]net.Interface, error) { 29 | iflist, err := net.Interfaces() 30 | if err != nil { 31 | return nil, err 32 | } 33 | list := make([]net.Interface, 0, len(iflist)) 34 | for _, ifi := range iflist { 35 | if !hasLinkUp(&ifi) || !hasMulticast(&ifi) || !hasIPv4Address(&ifi) { 36 | continue 37 | } 38 | list = append(list, ifi) 39 | } 40 | return list, nil 41 | } 42 | 43 | // hasLinkUp checks an I/F have link-up or not. 44 | func hasLinkUp(ifi *net.Interface) bool { 45 | return ifi.Flags&net.FlagUp != 0 46 | } 47 | 48 | // hasMulticast checks an I/F supports multicast or not. 49 | func hasMulticast(ifi *net.Interface) bool { 50 | return ifi.Flags&net.FlagMulticast != 0 51 | } 52 | 53 | // hasIPv4Address checks an I/F have IPv4 address. 54 | func hasIPv4Address(ifi *net.Interface) bool { 55 | addrs, err := ifi.Addrs() 56 | if err != nil { 57 | return false 58 | } 59 | for _, a := range addrs { 60 | ip, _, err := net.ParseCIDR(a.String()) 61 | if err != nil { 62 | continue 63 | } 64 | if len(ip.To4()) == net.IPv4len && !ip.IsUnspecified() { 65 | return true 66 | } 67 | } 68 | return false 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SSDP library 2 | 3 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/koron/go-ssdp)](https://pkg.go.dev/github.com/koron/go-ssdp) 4 | [![Actions/Go](https://github.com/koron/go-ssdp/workflows/Go/badge.svg)](https://github.com/koron/go-ssdp/actions?query=workflow%3AGo) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/koron/go-ssdp)](https://goreportcard.com/report/github.com/koron/go-ssdp) 6 | 7 | Based on . 8 | 9 | ## Examples 10 | 11 | There are tiny snippets for example. See also examples/ directory for working 12 | examples. 13 | 14 | ### Respond to search 15 | 16 | ```go 17 | import "github.com/koron/go-ssdp" 18 | 19 | ad, err := ssdp.Advertise( 20 | "my:device", // send as "ST" 21 | "unique:id", // send as "USN" 22 | "http://192.168.0.1:57086/foo.xml", // send as "LOCATION" 23 | "go-ssdp sample", // send as "SERVER" 24 | 1800) // send as "maxAge" in "CACHE-CONTROL" 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | // run Advertiser infinitely. 30 | quit := make(chan bool) 31 | <-quit 32 | ``` 33 | 34 | ### Send alive periodically 35 | 36 | ```go 37 | import "time" 38 | 39 | aliveTick := time.Tick(300 * time.Second) 40 | 41 | for { 42 | select { 43 | case <-aliveTick: 44 | ad.Alive() 45 | } 46 | } 47 | ``` 48 | 49 | ### Send bye when quiting 50 | 51 | ```go 52 | import ( 53 | "os" 54 | "os/signal" 55 | ) 56 | 57 | // to detect CTRL-C is pressed. 58 | quit := make(chan os.Signal, 1) 59 | signal.Notify(quit, os.Interrupt) 60 | 61 | loop: 62 | for { 63 | select { 64 | case <-aliveTick: 65 | ad.Alive() 66 | case <-quit: 67 | break loop 68 | } 69 | } 70 | 71 | // send/multicast "byebye" message. 72 | ad.Bye() 73 | // teminate Advertiser. 74 | ad.Close() 75 | ``` 76 | 77 | ### Limitate interfaces to multicast 78 | 79 | go-ssdp will send multicast messages to all IPv4 interfaces as default. 80 | When you want to limitate interfaces, see below snippet. 81 | 82 | ```go 83 | import ( 84 | "github.com/koron/go-ssdp" 85 | "net" 86 | ) 87 | 88 | en0, err := net.InterfaceByName("en0") 89 | if err != nil { 90 | panic(err) 91 | } 92 | ssdp.Interfaces = []net.Interface{*en0} 93 | ``` 94 | 95 | go-ssdp will send multicast message only "en0" after this. 96 | -------------------------------------------------------------------------------- /announce_test.go: -------------------------------------------------------------------------------- 1 | package ssdp 2 | 3 | import ( 4 | "net" 5 | "strings" 6 | "sync" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestAnnounceAlive(t *testing.T) { 12 | var mu sync.Mutex 13 | var mm []*AliveMessage 14 | m := newTestMonitor("test:announce+alive", func(m *AliveMessage) { 15 | mu.Lock() 16 | mm = append(mm, m) 17 | mu.Unlock() 18 | }, nil, nil) 19 | err := m.Start() 20 | if err != nil { 21 | t.Fatalf("failed to start Monitor: %s", err) 22 | } 23 | defer m.Close() 24 | 25 | err = AnnounceAlive("test:announce+alive", "usn:announce+alive", "location:announce+alive", "server:announce+alive", 600, "") 26 | if err != nil { 27 | t.Fatalf("failed to announce alive: %s", err) 28 | } 29 | time.Sleep(500 * time.Millisecond) 30 | 31 | mu.Lock() 32 | defer mu.Unlock() 33 | 34 | if len(mm) < 1 { 35 | t.Fatal("no alives detected") 36 | } 37 | //t.Logf("found %d alives", len(mm)) 38 | _, port, err := net.SplitHostPort(mm[0].From.String()) 39 | if err != nil { 40 | t.Fatalf("failed to split host and port: %s", err) 41 | } 42 | port = ":" + port 43 | for i, m := range mm { 44 | if strings.HasSuffix(port, m.From.String()) { 45 | t.Errorf("unmatch port#%d:\nwant=%q\n got=%q", i, port, m.From.String()) 46 | } 47 | if m.Type != "test:announce+alive" { 48 | t.Errorf("unexpected alive#%d type: want=%q got=%q", i, "test:announce+alive", m.Type) 49 | } 50 | if m.USN != "usn:announce+alive" { 51 | t.Errorf("unexpected alive#%d usn: want=%q got=%q", i, "usn:announce+alive", m.USN) 52 | } 53 | if m.Location != "location:announce+alive" { 54 | t.Errorf("unexpected alive#%d location: want=%q got=%q", i, "location:announce+alive", m.Location) 55 | } 56 | if m.Server != "server:announce+alive" { 57 | t.Errorf("unexpected alive#%d server: want=%q got=%q", i, "server:announce+alive", m.Server) 58 | } 59 | } 60 | } 61 | 62 | func TestAnnounceBye(t *testing.T) { 63 | var mu sync.Mutex 64 | var mm []*ByeMessage 65 | m := newTestMonitor("test:announce+bye", nil, func(m *ByeMessage) { 66 | mu.Lock() 67 | mm = append(mm, m) 68 | mu.Unlock() 69 | }, nil) 70 | err := m.Start() 71 | if err != nil { 72 | t.Fatalf("failed to start Monitor: %s", err) 73 | } 74 | defer m.Close() 75 | 76 | err = AnnounceBye("test:announce+bye", "usn:announce+bye", "") 77 | if err != nil { 78 | t.Fatalf("failed to announce bye: %s", err) 79 | } 80 | time.Sleep(500 * time.Millisecond) 81 | 82 | mu.Lock() 83 | defer mu.Unlock() 84 | 85 | if len(mm) < 1 { 86 | t.Fatal("no byes detected") 87 | } 88 | //t.Logf("found %d byes", len(mm)) 89 | _, port, err := net.SplitHostPort(mm[0].From.String()) 90 | if err != nil { 91 | t.Fatalf("failed to split host and port: %s", err) 92 | } 93 | port = ":" + port 94 | for i, m := range mm { 95 | if strings.HasSuffix(port, m.From.String()) { 96 | t.Errorf("unmatch port#%d:\nwant=%q\n got=%q", i, port, m.From.String()) 97 | } 98 | if m.Type != "test:announce+bye" { 99 | t.Errorf("unexpected bye#%d type: want=%q got=%q", i, "test:announce+bye", m.Type) 100 | } 101 | if m.USN != "usn:announce+bye" { 102 | t.Errorf("unexpected bye#%d usn: want=%q got=%q", i, "usn:announce+bye", m.USN) 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /announce.go: -------------------------------------------------------------------------------- 1 | package ssdp 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/koron/go-ssdp/internal/multicast" 9 | ) 10 | 11 | // AnnounceAlive sends ssdp:alive message. 12 | // location should be a string or a ssdp.LocationProvider. 13 | func AnnounceAlive(nt, usn string, location any, server string, maxAge int, localAddr string, opts ...Option) error { 14 | locProv, err := toLocationProvider(location) 15 | if err != nil { 16 | return err 17 | } 18 | cfg, err := opts2config(opts) 19 | if err != nil { 20 | return err 21 | } 22 | // dial multicast UDP packet. 23 | conn, err := multicast.Listen(&multicast.AddrResolver{Addr: localAddr}, cfg.multicastConfig.options()...) 24 | if err != nil { 25 | return err 26 | } 27 | defer conn.Close() 28 | // build and send message. 29 | addr, err := multicast.SendAddr() 30 | if err != nil { 31 | return err 32 | } 33 | msg := &aliveDataProvider{ 34 | host: addr, 35 | nt: nt, 36 | usn: usn, 37 | location: locProv, 38 | server: server, 39 | maxAge: maxAge, 40 | } 41 | if _, err := conn.WriteTo(msg, addr); err != nil { 42 | return err 43 | } 44 | return nil 45 | } 46 | 47 | type aliveDataProvider struct { 48 | host net.Addr 49 | nt string 50 | usn string 51 | location LocationProvider 52 | server string 53 | maxAge int 54 | } 55 | 56 | func (p *aliveDataProvider) Bytes(ifi *net.Interface) []byte { 57 | return buildAlive(p.host, p.nt, p.usn, p.location.Location(nil, ifi), p.server, p.maxAge) 58 | } 59 | 60 | var _ multicast.DataProvider = (*aliveDataProvider)(nil) 61 | 62 | func buildAlive(raddr net.Addr, nt, usn, location, server string, maxAge int) []byte { 63 | // bytes.Buffer#Write() is never fail, so we can omit error checks. 64 | b := new(bytes.Buffer) 65 | b.WriteString("NOTIFY * HTTP/1.1\r\n") 66 | fmt.Fprintf(b, "HOST: %s\r\n", raddr.String()) 67 | fmt.Fprintf(b, "NT: %s\r\n", nt) 68 | fmt.Fprintf(b, "NTS: %s\r\n", "ssdp:alive") 69 | fmt.Fprintf(b, "USN: %s\r\n", usn) 70 | if location != "" { 71 | fmt.Fprintf(b, "LOCATION: %s\r\n", location) 72 | } 73 | if server != "" { 74 | fmt.Fprintf(b, "SERVER: %s\r\n", server) 75 | } 76 | fmt.Fprintf(b, "CACHE-CONTROL: max-age=%d\r\n", maxAge) 77 | b.WriteString("\r\n") 78 | return b.Bytes() 79 | } 80 | 81 | // AnnounceBye sends ssdp:byebye message. 82 | func AnnounceBye(nt, usn, localAddr string, opts ...Option) error { 83 | cfg, err := opts2config(opts) 84 | if err != nil { 85 | return err 86 | } 87 | // dial multicast UDP packet. 88 | conn, err := multicast.Listen(&multicast.AddrResolver{Addr: localAddr}, cfg.multicastConfig.options()...) 89 | if err != nil { 90 | return err 91 | } 92 | defer conn.Close() 93 | // build and send message. 94 | addr, err := multicast.SendAddr() 95 | if err != nil { 96 | return err 97 | } 98 | msg, err := buildBye(addr, nt, usn) 99 | if err != nil { 100 | return err 101 | } 102 | if _, err := conn.WriteTo(multicast.BytesDataProvider(msg), addr); err != nil { 103 | return err 104 | } 105 | return nil 106 | } 107 | 108 | func buildBye(raddr net.Addr, nt, usn string) ([]byte, error) { 109 | b := new(bytes.Buffer) 110 | // FIXME: error should be checked. 111 | b.WriteString("NOTIFY * HTTP/1.1\r\n") 112 | fmt.Fprintf(b, "HOST: %s\r\n", raddr.String()) 113 | fmt.Fprintf(b, "NT: %s\r\n", nt) 114 | fmt.Fprintf(b, "NTS: %s\r\n", "ssdp:byebye") 115 | fmt.Fprintf(b, "USN: %s\r\n", usn) 116 | b.WriteString("\r\n") 117 | return b.Bytes(), nil 118 | } 119 | -------------------------------------------------------------------------------- /search.go: -------------------------------------------------------------------------------- 1 | package ssdp 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "net" 9 | "net/http" 10 | "regexp" 11 | "strconv" 12 | "time" 13 | 14 | "github.com/koron/go-ssdp/internal/multicast" 15 | "github.com/koron/go-ssdp/internal/ssdplog" 16 | ) 17 | 18 | // Service is discovered service. 19 | type Service struct { 20 | // Type is a property of "ST" 21 | Type string 22 | 23 | // USN is a property of "USN" 24 | USN string 25 | 26 | // Location is a property of "LOCATION" 27 | Location string 28 | 29 | // Server is a property of "SERVER" 30 | Server string 31 | 32 | rawHeader http.Header 33 | maxAge *int 34 | } 35 | 36 | var rxMaxAge = regexp.MustCompile(`\bmax-age\s*=\s*(\d+)\b`) 37 | 38 | func extractMaxAge(s string, value int) int { 39 | if m := rxMaxAge.FindStringSubmatch(s); m != nil { 40 | i64, err := strconv.ParseInt(m[1], 10, 32) 41 | if err == nil { 42 | return int(i64) 43 | } 44 | } 45 | return value 46 | } 47 | 48 | // MaxAge extracts "max-age" value from "CACHE-CONTROL" property. 49 | func (s *Service) MaxAge() int { 50 | if s.maxAge == nil { 51 | s.maxAge = new(int) 52 | *s.maxAge = extractMaxAge(s.rawHeader.Get("CACHE-CONTROL"), -1) 53 | } 54 | return *s.maxAge 55 | } 56 | 57 | // Header returns all properties in response of search. 58 | func (s *Service) Header() http.Header { 59 | return s.rawHeader 60 | } 61 | 62 | const ( 63 | // All is a search type to search all services and devices. 64 | All = "ssdp:all" 65 | 66 | // RootDevice is a search type to search UPnP root devices. 67 | RootDevice = "upnp:rootdevice" 68 | ) 69 | 70 | // Search searches services by SSDP. 71 | func Search(searchType string, waitSec int, localAddr string, opts ...Option) ([]Service, error) { 72 | cfg, err := opts2config(opts) 73 | if err != nil { 74 | return nil, err 75 | } 76 | // dial multicast UDP packet. 77 | conn, err := multicast.Listen(&multicast.AddrResolver{Addr: localAddr}, cfg.multicastConfig.options()...) 78 | if err != nil { 79 | return nil, err 80 | } 81 | defer conn.Close() 82 | ssdplog.Printf("search on %s", conn.LocalAddr().String()) 83 | 84 | // send request. 85 | addr, err := multicast.SendAddr() 86 | if err != nil { 87 | return nil, err 88 | } 89 | msg, err := buildSearch(addr, searchType, waitSec) 90 | if err != nil { 91 | return nil, err 92 | } 93 | if _, err := conn.WriteTo(multicast.BytesDataProvider(msg), addr); err != nil { 94 | return nil, err 95 | } 96 | 97 | // wait response. 98 | var list []Service 99 | h := func(a net.Addr, d []byte) error { 100 | srv, err := parseService(d) 101 | if err != nil { 102 | ssdplog.Printf("invalid search response from %s: %s", a.String(), err) 103 | return nil 104 | } 105 | list = append(list, *srv) 106 | ssdplog.Printf("search response from %s: %s", a.String(), srv.USN) 107 | return nil 108 | } 109 | d := time.Second * time.Duration(waitSec) 110 | if err := conn.ReadPackets(d, h); err != nil { 111 | return nil, err 112 | } 113 | 114 | return list, err 115 | } 116 | 117 | func buildSearch(raddr net.Addr, searchType string, waitSec int) ([]byte, error) { 118 | b := new(bytes.Buffer) 119 | // FIXME: error should be checked. 120 | b.WriteString("M-SEARCH * HTTP/1.1\r\n") 121 | fmt.Fprintf(b, "HOST: %s\r\n", raddr.String()) 122 | fmt.Fprintf(b, "MAN: %q\r\n", "ssdp:discover") 123 | fmt.Fprintf(b, "MX: %d\r\n", waitSec) 124 | fmt.Fprintf(b, "ST: %s\r\n", searchType) 125 | b.WriteString("\r\n") 126 | return b.Bytes(), nil 127 | } 128 | 129 | var ( 130 | errWithoutHTTPPrefix = errors.New("without HTTP prefix") 131 | ) 132 | 133 | var endOfHeader = []byte{'\r', '\n', '\r', '\n'} 134 | 135 | func parseService(data []byte) (*Service, error) { 136 | if !bytes.HasPrefix(data, []byte("HTTP")) { 137 | return nil, errWithoutHTTPPrefix 138 | } 139 | // Complement newlines on tail of header for buggy SSDP responses. 140 | if !bytes.HasSuffix(data, endOfHeader) { 141 | // why we should't use append() for this purpose: 142 | // https://play.golang.org/p/IM1pONW9lqm 143 | data = bytes.Join([][]byte{data, endOfHeader}, nil) 144 | } 145 | resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(data)), nil) 146 | if err != nil { 147 | return nil, err 148 | } 149 | defer resp.Body.Close() 150 | return &Service{ 151 | Type: resp.Header.Get("ST"), 152 | USN: resp.Header.Get("USN"), 153 | Location: resp.Header.Get("LOCATION"), 154 | Server: resp.Header.Get("SERVER"), 155 | rawHeader: resp.Header, 156 | }, nil 157 | } 158 | -------------------------------------------------------------------------------- /advertise_test.go: -------------------------------------------------------------------------------- 1 | package ssdp 2 | 3 | import ( 4 | "net" 5 | "strings" 6 | "sync" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestAdvertise_Alive(t *testing.T) { 12 | var mu sync.Mutex 13 | var mm []*AliveMessage 14 | m := newTestMonitor("test:advertise+alive", func(m *AliveMessage) { 15 | mu.Lock() 16 | mm = append(mm, m) 17 | mu.Unlock() 18 | }, nil, nil) 19 | err := m.Start() 20 | if err != nil { 21 | t.Fatalf("failed to start Monitor: %s", err) 22 | } 23 | defer m.Close() 24 | 25 | a, err := Advertise("test:advertise+alive", "usn:advertise+alive", "location:advertise+alive", "server:advertise+alive", 600) 26 | if err != nil { 27 | t.Fatalf("failed to Advertise: %s", err) 28 | } 29 | err = a.Alive() 30 | if err != nil { 31 | a.Close() 32 | t.Fatalf("failed to send alive: %s", err) 33 | } 34 | a.Close() 35 | time.Sleep(500 * time.Millisecond) 36 | 37 | mu.Lock() 38 | defer mu.Unlock() 39 | 40 | if len(mm) < 1 { 41 | t.Fatal("no alives detected") 42 | } 43 | //t.Logf("found %d alives", len(mm)) 44 | _, port, err := net.SplitHostPort(mm[0].From.String()) 45 | if err != nil { 46 | t.Fatalf("failed to split host and port: %s", err) 47 | } 48 | port = ":" + port 49 | 50 | expHdr := map[string]string{ 51 | "Nts": "ssdp:alive", 52 | "Nt": "test:advertise+alive", 53 | "Usn": "usn:advertise+alive", 54 | "Location": "location:advertise+alive", 55 | "Server": "server:advertise+alive", 56 | "Cache-Control": "max-age=600", 57 | } 58 | for i, m := range mm { 59 | if strings.HasSuffix(port, m.From.String()) { 60 | t.Errorf("unmatch port#%d:\nwant=%q\n got=%q", i, port, m.From.String()) 61 | } 62 | if m.Type != "test:advertise+alive" { 63 | t.Errorf("unexpected alive#%d type: want=%q got=%q", i, "test:advertise+alive", m.Type) 64 | } 65 | if m.USN != "usn:advertise+alive" { 66 | t.Errorf("unexpected alive#%d usn: want=%q got=%q", i, "usn:advertise+alive", m.USN) 67 | } 68 | if m.Location != "location:advertise+alive" { 69 | t.Errorf("unexpected alive#%d location: want=%q got=%q", i, "location:advertise+alive", m.Location) 70 | } 71 | if m.Server != "server:advertise+alive" { 72 | t.Errorf("unexpected alive#%d server: want=%q got=%q", i, "server:advertise+alive", m.Server) 73 | } 74 | if m.MaxAge() != 600 { 75 | t.Errorf("unexpected max-age: want=%d got=%d", 600, m.MaxAge()) 76 | } 77 | 78 | h := m.Header() 79 | for k := range h { 80 | exp, ok := expHdr[k] 81 | if !ok { 82 | t.Errorf("unexpected header #%d %q=%q", i, k, h.Get(k)) 83 | } else if act := h.Get(k); act != exp { 84 | t.Errorf("header #%d %q value mismatch:\nwant=%q\n got=%q", i, k, exp, act) 85 | } 86 | } 87 | } 88 | } 89 | 90 | func TestAdvertise_Bye(t *testing.T) { 91 | var mu sync.Mutex 92 | var mm []*ByeMessage 93 | m := newTestMonitor("test:advertise+bye", nil, func(m *ByeMessage) { 94 | mu.Lock() 95 | mm = append(mm, m) 96 | mu.Unlock() 97 | }, nil) 98 | err := m.Start() 99 | if err != nil { 100 | t.Fatalf("failed to start Monitor: %s", err) 101 | } 102 | defer m.Close() 103 | 104 | a, err := Advertise("test:advertise+bye", "usn:advertise+bye", "location:advertise+bye", "server:advertise+bye", 600) 105 | if err != nil { 106 | t.Fatalf("failed to Advertise: %s", err) 107 | } 108 | err = a.Bye() 109 | if err != nil { 110 | a.Close() 111 | t.Fatalf("failed to send bye: %s", err) 112 | } 113 | a.Close() 114 | time.Sleep(500 * time.Millisecond) 115 | 116 | mu.Lock() 117 | defer mu.Unlock() 118 | 119 | if len(mm) < 1 { 120 | t.Fatal("no byes detected") 121 | } 122 | //t.Logf("found %d byes", len(mm)) 123 | _, port, err := net.SplitHostPort(mm[0].From.String()) 124 | if err != nil { 125 | t.Fatalf("failed to split host and port: %s", err) 126 | } 127 | port = ":" + port 128 | 129 | expHdr := map[string]string{ 130 | "Nts": "ssdp:byebye", 131 | "Nt": "test:advertise+bye", 132 | "Usn": "usn:advertise+bye", 133 | } 134 | for i, m := range mm { 135 | if strings.HasSuffix(port, m.From.String()) { 136 | t.Errorf("unmatch port#%d:\nwant=%q\n got=%q", i, port, m.From.String()) 137 | } 138 | if m.Type != "test:advertise+bye" { 139 | t.Errorf("unexpected bye#%d type: want=%q got=%q", i, "test:advertise+bye", m.Type) 140 | } 141 | if m.USN != "usn:advertise+bye" { 142 | t.Errorf("unexpected bye#%d usn: want=%q got=%q", i, "usn:advertise+bye", m.USN) 143 | } 144 | 145 | h := m.Header() 146 | for k := range h { 147 | exp, ok := expHdr[k] 148 | if !ok { 149 | t.Errorf("unexpected header #%d %q=%q", i, k, h.Get(k)) 150 | } else if act := h.Get(k); act != exp { 151 | t.Errorf("header #%d %q value mismatch:\nwant=%q\n got=%q", i, k, exp, act) 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /advertise.go: -------------------------------------------------------------------------------- 1 | package ssdp 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "sync" 11 | 12 | "github.com/koron/go-ssdp/internal/multicast" 13 | "github.com/koron/go-ssdp/internal/ssdplog" 14 | ) 15 | 16 | type message struct { 17 | to net.Addr 18 | data multicast.DataProvider 19 | } 20 | 21 | // Advertiser is a server to advertise a service. 22 | type Advertiser struct { 23 | st string 24 | usn string 25 | locProv LocationProvider 26 | server string 27 | maxAge int 28 | 29 | conn *multicast.Conn 30 | ch chan *message 31 | wg sync.WaitGroup 32 | wgS sync.WaitGroup 33 | 34 | // addHost is an optional flag to add HOST header for M-SEARCH response. 35 | // It is to support SmartThings. 36 | // See https://github.com/koron/go-ssdp/issues/30 for details 37 | addHost bool 38 | } 39 | 40 | // Advertise starts advertisement of service. 41 | // location should be a string or a ssdp.LocationProvider. 42 | func Advertise(st, usn string, location any, server string, maxAge int, opts ...Option) (*Advertiser, error) { 43 | locProv, err := toLocationProvider(location) 44 | if err != nil { 45 | return nil, err 46 | } 47 | cfg, err := opts2config(opts) 48 | if err != nil { 49 | return nil, err 50 | } 51 | conn, err := multicast.Listen(multicast.RecvAddrResolver, cfg.multicastConfig.options()...) 52 | if err != nil { 53 | return nil, err 54 | } 55 | ssdplog.Printf("SSDP advertise on: %s", conn.LocalAddr().String()) 56 | a := &Advertiser{ 57 | st: st, 58 | usn: usn, 59 | locProv: locProv, 60 | server: server, 61 | maxAge: maxAge, 62 | conn: conn, 63 | ch: make(chan *message), 64 | addHost: cfg.advertiseConfig.addHost, 65 | } 66 | a.wg.Add(2) 67 | a.wgS.Add(1) 68 | go func() { 69 | a.sendMain() 70 | a.wgS.Done() 71 | a.wg.Done() 72 | }() 73 | go func() { 74 | a.recvMain() 75 | a.wg.Done() 76 | }() 77 | return a, nil 78 | } 79 | 80 | func (a *Advertiser) recvMain() error { 81 | // TODO: update listening interfaces of a.conn 82 | err := a.conn.ReadPackets(0, func(addr net.Addr, data []byte) error { 83 | if err := a.handleRaw(addr, data); err != nil { 84 | ssdplog.Printf("failed to handle message: %s", err) 85 | } 86 | return nil 87 | }) 88 | if err != nil && err != io.EOF { 89 | return err 90 | } 91 | return nil 92 | } 93 | 94 | func (a *Advertiser) sendMain() { 95 | for msg := range a.ch { 96 | _, err := a.conn.WriteTo(msg.data, msg.to) 97 | if err != nil { 98 | ssdplog.Printf("failed to send: %s", err) 99 | } 100 | } 101 | } 102 | 103 | func (a *Advertiser) handleRaw(from net.Addr, raw []byte) error { 104 | if !bytes.HasPrefix(raw, []byte("M-SEARCH ")) { 105 | // unexpected method. 106 | return nil 107 | } 108 | req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(raw))) 109 | if err != nil { 110 | return err 111 | } 112 | var ( 113 | man = req.Header.Get("MAN") 114 | st = req.Header.Get("ST") 115 | ) 116 | if man != `"ssdp:discover"` { 117 | return fmt.Errorf("unexpected MAN: %s", man) 118 | } 119 | if st != All && st != RootDevice && st != a.st { 120 | // skip when ST is not matched/expected. 121 | return nil 122 | } 123 | ssdplog.Printf("received M-SEARCH MAN=%s ST=%s from %s", man, st, from.String()) 124 | // build and send a response. 125 | var host string 126 | if a.addHost { 127 | addr, err := multicast.SendAddr() 128 | if err != nil { 129 | return err 130 | } 131 | host = addr.String() 132 | } 133 | msg := buildOK(a.st, a.usn, a.locProv.Location(from, nil), a.server, a.maxAge, host) 134 | a.ch <- &message{to: from, data: multicast.BytesDataProvider(msg)} 135 | return nil 136 | } 137 | 138 | func buildOK(st, usn, location, server string, maxAge int, host string) []byte { 139 | // bytes.Buffer#Write() is never fail, so we can omit error checks. 140 | b := new(bytes.Buffer) 141 | b.WriteString("HTTP/1.1 200 OK\r\n") 142 | fmt.Fprintf(b, "EXT: \r\n") 143 | fmt.Fprintf(b, "ST: %s\r\n", st) 144 | fmt.Fprintf(b, "USN: %s\r\n", usn) 145 | if location != "" { 146 | fmt.Fprintf(b, "LOCATION: %s\r\n", location) 147 | } 148 | if server != "" { 149 | fmt.Fprintf(b, "SERVER: %s\r\n", server) 150 | } 151 | fmt.Fprintf(b, "CACHE-CONTROL: max-age=%d\r\n", maxAge) 152 | if host != "" { 153 | fmt.Fprintf(b, "HOST: %s\r\n", host) 154 | } 155 | b.WriteString("\r\n") 156 | return b.Bytes() 157 | } 158 | 159 | // Close stops advertisement. 160 | func (a *Advertiser) Close() error { 161 | if a.conn != nil { 162 | // closing order is very important. be careful to change: 163 | // stop sending loop by closing the channel and wait it. 164 | close(a.ch) 165 | a.wgS.Wait() 166 | // stop receiving loop by closing the connection. 167 | a.conn.Close() 168 | a.wg.Wait() 169 | a.conn = nil 170 | } 171 | return nil 172 | } 173 | 174 | // Alive announces ssdp:alive message. 175 | func (a *Advertiser) Alive() error { 176 | addr, err := multicast.SendAddr() 177 | if err != nil { 178 | return err 179 | } 180 | msg := &aliveDataProvider{ 181 | host: addr, 182 | nt: a.st, 183 | usn: a.usn, 184 | location: a.locProv, 185 | server: a.server, 186 | maxAge: a.maxAge, 187 | } 188 | a.ch <- &message{to: addr, data: msg} 189 | ssdplog.Printf("sent alive") 190 | return nil 191 | } 192 | 193 | // Bye announces ssdp:byebye message. 194 | func (a *Advertiser) Bye() error { 195 | addr, err := multicast.SendAddr() 196 | if err != nil { 197 | return err 198 | } 199 | msg, err := buildBye(addr, a.st, a.usn) 200 | if err != nil { 201 | return err 202 | } 203 | a.ch <- &message{to: addr, data: multicast.BytesDataProvider(msg)} 204 | ssdplog.Printf("sent bye") 205 | return nil 206 | } 207 | -------------------------------------------------------------------------------- /monitor.go: -------------------------------------------------------------------------------- 1 | package ssdp 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/http" 11 | "sync" 12 | 13 | "github.com/koron/go-ssdp/internal/multicast" 14 | "github.com/koron/go-ssdp/internal/ssdplog" 15 | ) 16 | 17 | // Monitor monitors SSDP's alive and byebye messages. 18 | type Monitor struct { 19 | Alive AliveHandler 20 | Bye ByeHandler 21 | Search SearchHandler 22 | 23 | Options []Option 24 | 25 | conn *multicast.Conn 26 | wg sync.WaitGroup 27 | } 28 | 29 | // Start starts to monitor SSDP messages. 30 | func (m *Monitor) Start() error { 31 | cfg, err := opts2config(m.Options) 32 | if err != nil { 33 | return err 34 | } 35 | conn, err := multicast.Listen(multicast.RecvAddrResolver, cfg.multicastConfig.options()...) 36 | if err != nil { 37 | return err 38 | } 39 | ssdplog.Printf("monitoring on %s", conn.LocalAddr().String()) 40 | m.conn = conn 41 | m.wg.Add(1) 42 | go func() { 43 | m.serve() 44 | m.wg.Done() 45 | }() 46 | return nil 47 | } 48 | 49 | func (m *Monitor) serve() error { 50 | // TODO: update listening interfaces of m.conn 51 | err := m.conn.ReadPackets(0, func(addr net.Addr, data []byte) error { 52 | msg := make([]byte, len(data)) 53 | copy(msg, data) 54 | go m.handleRaw(addr, msg) 55 | return nil 56 | }) 57 | if err != nil && !errors.Is(err, io.EOF) { 58 | return err 59 | } 60 | return nil 61 | } 62 | 63 | func (m *Monitor) handleRaw(addr net.Addr, raw []byte) error { 64 | // Add newline to workaround buggy SSDP responses 65 | if !bytes.HasSuffix(raw, endOfHeader) { 66 | raw = bytes.Join([][]byte{raw, endOfHeader}, nil) 67 | } 68 | if bytes.HasPrefix(raw, []byte("M-SEARCH ")) { 69 | return m.handleSearch(addr, raw) 70 | } 71 | if bytes.HasPrefix(raw, []byte("NOTIFY ")) { 72 | return m.handleNotify(addr, raw) 73 | } 74 | n := bytes.Index(raw, []byte("\r\n")) 75 | ssdplog.Printf("unexpected method: %q", string(raw[:n])) 76 | return nil 77 | } 78 | 79 | func (m *Monitor) handleNotify(addr net.Addr, raw []byte) error { 80 | req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(raw))) 81 | if err != nil { 82 | return err 83 | } 84 | switch nts := req.Header.Get("NTS"); nts { 85 | case "ssdp:alive": 86 | if req.Method != "NOTIFY" { 87 | return fmt.Errorf("unexpected method for %q: %s", "ssdp:alive", req.Method) 88 | } 89 | if h := m.Alive; h != nil { 90 | h(&AliveMessage{ 91 | From: addr, 92 | Type: req.Header.Get("NT"), 93 | USN: req.Header.Get("USN"), 94 | Location: req.Header.Get("LOCATION"), 95 | Server: req.Header.Get("SERVER"), 96 | rawHeader: req.Header, 97 | }) 98 | } 99 | case "ssdp:byebye": 100 | if req.Method != "NOTIFY" { 101 | return fmt.Errorf("unexpected method for %q: %s", "ssdp:byebye", req.Method) 102 | } 103 | if h := m.Bye; h != nil { 104 | h(&ByeMessage{ 105 | From: addr, 106 | Type: req.Header.Get("NT"), 107 | USN: req.Header.Get("USN"), 108 | rawHeader: req.Header, 109 | }) 110 | } 111 | default: 112 | return fmt.Errorf("unknown NTS: %s", nts) 113 | } 114 | return nil 115 | } 116 | 117 | func (m *Monitor) handleSearch(addr net.Addr, raw []byte) error { 118 | req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(raw))) 119 | if err != nil { 120 | return err 121 | } 122 | man := req.Header.Get("MAN") 123 | if man != `"ssdp:discover"` { 124 | return fmt.Errorf("unexpected MAN: %s", man) 125 | } 126 | if h := m.Search; h != nil { 127 | h(&SearchMessage{ 128 | From: addr, 129 | Type: req.Header.Get("ST"), 130 | rawHeader: req.Header, 131 | }) 132 | } 133 | return nil 134 | } 135 | 136 | // Close closes monitoring. 137 | func (m *Monitor) Close() error { 138 | if m.conn != nil { 139 | m.conn.Close() 140 | m.conn = nil 141 | m.wg.Wait() 142 | } 143 | return nil 144 | } 145 | 146 | // AliveMessage represents SSDP's ssdp:alive message. 147 | type AliveMessage struct { 148 | // From is a sender of this message 149 | From net.Addr 150 | 151 | // Type is a property of "NT" 152 | Type string 153 | 154 | // USN is a property of "USN" 155 | USN string 156 | 157 | // Location is a property of "LOCATION" 158 | Location string 159 | 160 | // Server is a property of "SERVER" 161 | Server string 162 | 163 | rawHeader http.Header 164 | maxAge *int 165 | } 166 | 167 | // Header returns all properties in alive message. 168 | func (m *AliveMessage) Header() http.Header { 169 | return m.rawHeader 170 | } 171 | 172 | // MaxAge extracts "max-age" value from "CACHE-CONTROL" property. 173 | func (m *AliveMessage) MaxAge() int { 174 | if m.maxAge == nil { 175 | m.maxAge = new(int) 176 | *m.maxAge = extractMaxAge(m.rawHeader.Get("CACHE-CONTROL"), -1) 177 | } 178 | return *m.maxAge 179 | } 180 | 181 | // AliveHandler is handler of Alive message. 182 | type AliveHandler func(*AliveMessage) 183 | 184 | // ByeMessage represents SSDP's ssdp:byebye message. 185 | type ByeMessage struct { 186 | // From is a sender of this message 187 | From net.Addr 188 | 189 | // Type is a property of "NT" 190 | Type string 191 | 192 | // USN is a property of "USN" 193 | USN string 194 | 195 | rawHeader http.Header 196 | } 197 | 198 | // Header returns all properties in bye message. 199 | func (m *ByeMessage) Header() http.Header { 200 | return m.rawHeader 201 | } 202 | 203 | // ByeHandler is handler of Bye message. 204 | type ByeHandler func(*ByeMessage) 205 | 206 | // SearchMessage represents SSDP's ssdp:discover message. 207 | type SearchMessage struct { 208 | From net.Addr 209 | Type string 210 | 211 | rawHeader http.Header 212 | } 213 | 214 | // Header returns all properties in search message. 215 | func (s *SearchMessage) Header() http.Header { 216 | return s.rawHeader 217 | } 218 | 219 | // SearchHandler is handler of Search message. 220 | type SearchHandler func(*SearchMessage) 221 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: [push] 4 | 5 | permissions: 6 | contents: write 7 | 8 | env: 9 | GO_VERSION: '>=1.24.0' 10 | 11 | jobs: 12 | 13 | check: 14 | name: Check if the main package exists 15 | outputs: 16 | targets: ${{ steps.list.outputs.targets }} 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | 22 | - uses: actions/checkout@v6 23 | - uses: actions/setup-go@v6 24 | with: 25 | go-version: ${{ env.GO_VERSION }} 26 | 27 | - name: List "main" packages to be released 28 | id: list 29 | run: | 30 | found=0 31 | echo "targets<<__END__" >> $GITHUB_OUTPUT 32 | go list -f '{{if (eq .Name "main")}}{{.ImportPath}} .{{slice .ImportPath (len .Module.Path)}}{{end}}' ./... | while IFS= read -r line ; do 33 | read -a e <<< "$line" 34 | path="${e[0]}" 35 | dir="${e[1]}" 36 | name=$(basename $path) 37 | if [ -f "$dir/.norelease" ] ; then 38 | echo -e "Skipped $name\t($dir), due to $dir/.norelease found" 39 | else 40 | found=1 41 | echo "$name $dir $path" >> $GITHUB_OUTPUT 42 | echo -e "Added $name\t($dir) to the release" 43 | fi 44 | done 45 | echo "__END__" >> $GITHUB_OUTPUT 46 | if [[ $found == 0 ]] ; then 47 | echo "⛔ No packages found to release" 48 | fi 49 | 50 | build: 51 | name: Build releases 52 | 53 | needs: check 54 | if: needs.check.outputs.targets != '' 55 | 56 | env: 57 | RELEASE_TARGETS: ${{needs.check.outputs.targets}} 58 | 59 | strategy: 60 | fail-fast: false 61 | matrix: 62 | os: 63 | - ubuntu-latest 64 | - ubuntu-24.04-arm 65 | - macos-latest 66 | - windows-latest 67 | arch: 68 | - amd64 69 | - arm64 70 | exclude: 71 | - os: windows-latest 72 | arch: arm64 73 | - os: ubuntu-latest 74 | arch: arm64 75 | - os: ubuntu-24.04-arm 76 | arch: amd64 77 | 78 | runs-on: ${{ matrix.os }} 79 | 80 | steps: 81 | 82 | - uses: actions/checkout@v6 83 | - uses: actions/setup-go@v6 84 | with: 85 | go-version: ${{ env.GO_VERSION }} 86 | 87 | - name: Setup env 88 | id: setup 89 | shell: bash 90 | run: | 91 | export NAME="${GITHUB_REPOSITORY#${GITHUB_REPOSITORY_OWNER}/}" 92 | if [[ ${GITHUB_REF} =~ ^refs/tags/v[0-9]+\.[0-9]+ ]] ; then 93 | export VERSION=${GITHUB_REF_NAME} 94 | else 95 | export VERSION=SNAPSHOT 96 | fi 97 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 98 | case ${{ matrix.os }} in 99 | ubuntu-*) 100 | export GOOS=linux 101 | export PKGEXT=.tar.gz 102 | ;; 103 | macos-*) 104 | export GOOS=darwin 105 | export PKGEXT=.zip 106 | ;; 107 | windows-*) 108 | choco install zip 109 | export GOOS=windows 110 | export PKGEXT=.zip 111 | ;; 112 | esac 113 | export GOARCH=${{ matrix.arch }} 114 | echo "GOOS=${GOOS}" >> $GITHUB_ENV 115 | echo "GOARCH=${GOARCH}" >> $GITHUB_ENV 116 | echo "CGO_ENABLED=1" >> $GITHUB_ENV 117 | echo "PKGNAME=${NAME}_${VERSION}_${GOOS}_${GOARCH}" >> $GITHUB_ENV 118 | echo "PKGEXT=${PKGEXT}" >> $GITHUB_ENV 119 | 120 | - name: Build all "main" packages 121 | shell: bash 122 | run: | 123 | echo "$RELEASE_TARGETS" | while IFS= read -r line ; do 124 | read -a entry <<< "$line" 125 | printf "building %s\t(%s)\n" "${entry[0]}" "${entry[1]}" 126 | ( cd "${entry[1]}" && go build ) 127 | done 128 | 129 | - name: Archive 130 | shell: bash 131 | run: | 132 | mkdir -p _build/${PKGNAME} 133 | 134 | echo "$RELEASE_TARGETS" | while IFS= read -r line ; do 135 | read -a entry <<< "$line" 136 | cp "${entry[1]}/${entry[0]}" _build/${PKGNAME} 137 | done 138 | 139 | cp -p LICENSE _build/${PKGNAME} 140 | cp -p README.md _build/${PKGNAME} 141 | 142 | case "${PKGEXT}" in 143 | ".tar.gz") 144 | tar caf _build/${PKGNAME}${PKGEXT} -C _build ${PKGNAME} 145 | ;; 146 | ".zip") 147 | (cd _build && zip -r9q ${PKGNAME}${PKGEXT} ${PKGNAME}) 148 | ;; 149 | esac 150 | ls -laFR _build 151 | 152 | - name: Artifact upload 153 | uses: actions/upload-artifact@v5 154 | with: 155 | name: ${{ env.GOOS }}_${{ env.GOARCH }} 156 | path: _build/${{ env.PKGNAME }}${{ env.PKGEXT }} 157 | 158 | create-release: 159 | name: Create release 160 | runs-on: ubuntu-latest 161 | if: startsWith(github.ref, 'refs/tags/') 162 | needs: 163 | - build 164 | steps: 165 | - uses: actions/download-artifact@v6 166 | with: { name: darwin_amd64 } 167 | - uses: actions/download-artifact@v6 168 | with: { name: darwin_arm64 } 169 | - uses: actions/download-artifact@v6 170 | with: { name: linux_amd64 } 171 | - uses: actions/download-artifact@v6 172 | with: { name: linux_arm64 } 173 | - uses: actions/download-artifact@v6 174 | with: { name: windows_amd64 } 175 | - run: ls -lafR 176 | - name: Release 177 | uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 178 | with: 179 | draft: true 180 | prerelease: ${{ contains(github.ref_name, '-alpha.') || contains(github.ref_name, '-beta.') }} 181 | files: | 182 | *.tar.gz 183 | *.zip 184 | fail_on_unmatched_files: true 185 | generate_release_notes: true 186 | append_body: true 187 | 188 | # based on: github.com/koron-go/_skeleton/.github/workflows/release.yml 189 | # $Hash:a9354833cf3f815f347887d0b11c95c61a362262f9715036ff827975$ 190 | -------------------------------------------------------------------------------- /internal/multicast/multicast.go: -------------------------------------------------------------------------------- 1 | package multicast 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net" 7 | "strings" 8 | "time" 9 | 10 | "github.com/koron/go-ssdp/internal/ssdplog" 11 | "golang.org/x/net/ipv4" 12 | ) 13 | 14 | // Conn is multicast connection. 15 | type Conn struct { 16 | laddr *net.UDPAddr 17 | pconn *ipv4.PacketConn 18 | 19 | // ifps stores pointers of multicast interface. 20 | ifps []*net.Interface 21 | } 22 | 23 | type connConfig struct { 24 | ttl int 25 | sysIf bool 26 | } 27 | 28 | // Listen starts to receiving multicast messages. 29 | func Listen(r *AddrResolver, opts ...ConnOption) (*Conn, error) { 30 | // prepare parameters. 31 | laddr, err := r.resolve() 32 | if err != nil { 33 | return nil, err 34 | } 35 | // configure connection 36 | var cfg connConfig 37 | for _, o := range opts { 38 | o.apply(&cfg) 39 | } 40 | // connect. 41 | conn, err := net.ListenUDP("udp4", laddr) 42 | if err != nil { 43 | return nil, err 44 | } 45 | // configure socket to use with multicast. 46 | pconn, ifplist, err := newIPv4MulticastConn(conn, cfg.sysIf) 47 | if err != nil { 48 | conn.Close() 49 | return nil, err 50 | } 51 | // set TTL 52 | if cfg.ttl > 0 { 53 | err := pconn.SetTTL(cfg.ttl) 54 | if err != nil { 55 | pconn.Close() 56 | return nil, err 57 | } 58 | } 59 | return &Conn{ 60 | laddr: laddr, 61 | pconn: pconn, 62 | ifps: ifplist, 63 | }, nil 64 | } 65 | 66 | // newIPv4MulticastConn create a new multicast connection. 67 | // 2nd return parameter will be nil when sysIf is true. 68 | func newIPv4MulticastConn(conn *net.UDPConn, sysIf bool) (*ipv4.PacketConn, []*net.Interface, error) { 69 | // sysIf: use system assigned multicast interface. 70 | // the empty iflist indicate it. 71 | var ifplist []*net.Interface 72 | if !sysIf { 73 | list, err := interfaces() 74 | if err != nil { 75 | return nil, nil, err 76 | } 77 | ifplist = make([]*net.Interface, 0, len(list)) 78 | for i := range list { 79 | ifplist = append(ifplist, &list[i]) 80 | } 81 | } 82 | addr, err := SendAddr() 83 | if err != nil { 84 | return nil, nil, err 85 | } 86 | pconn, err := joinGroupIPv4(conn, ifplist, addr) 87 | if err != nil { 88 | return nil, nil, err 89 | } 90 | return pconn, ifplist, nil 91 | } 92 | 93 | // joinGroupIPv4 makes the connection join to a group on interfaces. 94 | // This trys to use system assigned when iflist is nil or empty. 95 | func joinGroupIPv4(conn *net.UDPConn, ifplist []*net.Interface, gaddr net.Addr) (*ipv4.PacketConn, error) { 96 | wrap := ipv4.NewPacketConn(conn) 97 | wrap.SetMulticastLoopback(true) 98 | 99 | // try to use the system assigned multicast interface when iflist is empty. 100 | if len(ifplist) == 0 { 101 | if err := wrap.JoinGroup(nil, gaddr); err != nil { 102 | ssdplog.Printf("failed to join group %s on system assigned multicast interface: %s", gaddr.String(), err) 103 | return nil, errors.New("no system assigned multicast interfaces had joined to group") 104 | } 105 | ssdplog.Printf("joined group %s on system assigned multicast interface", gaddr.String()) 106 | return wrap, nil 107 | } 108 | 109 | // add interfaces to multicast group. 110 | joined := 0 111 | for _, ifi := range ifplist { 112 | if err := wrap.JoinGroup(ifi, gaddr); err != nil { 113 | ssdplog.Printf("failed to join group %s on %s: %s", gaddr.String(), ifi.Name, err) 114 | continue 115 | } 116 | joined++ 117 | ssdplog.Printf("joined group %s on %s (#%d)", gaddr.String(), ifi.Name, ifi.Index) 118 | } 119 | if joined == 0 { 120 | return nil, errors.New("no interfaces had joined to group") 121 | } 122 | return wrap, nil 123 | } 124 | 125 | // Close closes a multicast connection. 126 | func (mc *Conn) Close() error { 127 | // based net.UDPConn will be closed by mc.pconn.Close() 128 | return mc.pconn.Close() 129 | } 130 | 131 | // DataProvider provides a body of multicast message to send. 132 | type DataProvider interface { 133 | Bytes(*net.Interface) []byte 134 | } 135 | 136 | type BytesDataProvider []byte 137 | 138 | func (b BytesDataProvider) Bytes(ifi *net.Interface) []byte { 139 | return []byte(b) 140 | } 141 | 142 | // WriteTo sends a multicast message to interfaces. 143 | func (mc *Conn) WriteTo(dataProv DataProvider, to net.Addr) (int, error) { 144 | // Send a multicast message directory when recipient "to" address is not multicast. 145 | if uaddr, ok := to.(*net.UDPAddr); ok && !uaddr.IP.IsMulticast() { 146 | return mc.writeToIfi(dataProv, to, nil) 147 | } 148 | // Send a multicast message to all interfaces (iflist). 149 | sum := 0 150 | for _, ifi := range mc.ifps { 151 | ssdplog.Printf("WriteTo: ifi=%+v", ifi) 152 | n, err := mc.writeToIfi(dataProv, to, ifi) 153 | if err != nil { 154 | return 0, err 155 | } 156 | sum += n 157 | } 158 | return sum, nil 159 | } 160 | 161 | func (mc *Conn) writeToIfi(dataProv DataProvider, to net.Addr, ifi *net.Interface) (int, error) { 162 | if err := mc.pconn.SetMulticastInterface(ifi); err != nil { 163 | return 0, err 164 | } 165 | return mc.pconn.WriteTo(dataProv.Bytes(ifi), nil, to) 166 | } 167 | 168 | // LocalAddr returns local address to listen multicast packets. 169 | func (mc *Conn) LocalAddr() net.Addr { 170 | return mc.laddr 171 | } 172 | 173 | // ReadPackets reads multicast packets. 174 | func (mc *Conn) ReadPackets(timeout time.Duration, h PacketHandler) error { 175 | buf := make([]byte, 65535) 176 | if timeout > 0 { 177 | mc.pconn.SetReadDeadline(time.Now().Add(timeout)) 178 | } 179 | for { 180 | n, _, addr, err := mc.pconn.ReadFrom(buf) 181 | if err != nil { 182 | if nerr, ok := err.(net.Error); ok && nerr.Timeout() { 183 | return nil 184 | } 185 | if strings.Contains(err.Error(), "use of closed network connection") { 186 | return io.EOF 187 | } 188 | return err 189 | } 190 | if err := h(addr, buf[:n]); err != nil { 191 | return err 192 | } 193 | } 194 | } 195 | 196 | // ConnOption is option for Listen() 197 | type ConnOption interface { 198 | apply(cfg *connConfig) 199 | } 200 | 201 | type connOptFunc func(*connConfig) 202 | 203 | func (f connOptFunc) apply(cfg *connConfig) { 204 | f(cfg) 205 | } 206 | 207 | // ConnTTL returns as ConnOption that set default TTL to the connection. 208 | func ConnTTL(ttl int) ConnOption { 209 | return connOptFunc(func(cfg *connConfig) { 210 | cfg.ttl = ttl 211 | }) 212 | } 213 | 214 | func ConnSystemAssginedInterface() ConnOption { 215 | return connOptFunc(func(cfg *connConfig) { 216 | cfg.sysIf = true 217 | }) 218 | } 219 | -------------------------------------------------------------------------------- /search_test.go: -------------------------------------------------------------------------------- 1 | package ssdp 2 | 3 | import ( 4 | "net" 5 | "strings" 6 | "sync" 7 | "testing" 8 | ) 9 | 10 | func testMaxAge(t *testing.T, s string, expect int) { 11 | act := extractMaxAge(s, -1) 12 | if act != expect { 13 | t.Errorf("max-age for %q should be %d but actually %d", s, expect, act) 14 | } 15 | } 16 | 17 | func TestExtractMaxAge(t *testing.T) { 18 | // empty 19 | testMaxAge(t, "", -1) 20 | // spaces around `=` 21 | testMaxAge(t, "max-age=100", 100) 22 | testMaxAge(t, "max-age = 200", 200) 23 | testMaxAge(t, "max-age= 300", 300) 24 | testMaxAge(t, "max-age =400", 400) 25 | // minus 26 | testMaxAge(t, "max-age=-100", -1) 27 | // invalid name 28 | testMaxAge(t, "foo=100", -1) 29 | // contained valid name 30 | testMaxAge(t, "foomax-age=100", -1) 31 | // surrounded 32 | testMaxAge(t, ";max-age=500;", 500) 33 | testMaxAge(t, ";max-age=600", 600) 34 | testMaxAge(t, "max-age=700;", 700) 35 | } 36 | 37 | func TestSearch_Request(t *testing.T) { 38 | searchType := "test:search+request" 39 | 40 | var mu sync.Mutex 41 | var mm []*SearchMessage 42 | m := newTestMonitor(searchType, nil, nil, func(m *SearchMessage) { 43 | mu.Lock() 44 | mm = append(mm, m) 45 | mu.Unlock() 46 | }) 47 | err := m.Start() 48 | if err != nil { 49 | t.Fatalf("failed to start Monitor: %s", err) 50 | } 51 | defer m.Close() 52 | 53 | srvs, err := Search(searchType, 1, "") 54 | if err != nil { 55 | t.Fatalf("failed to Search: %s", err) 56 | } 57 | if len(srvs) > 0 { 58 | t.Errorf("unexpected services: %+v", srvs) 59 | } 60 | 61 | mu.Lock() 62 | defer mu.Unlock() 63 | 64 | if len(mm) < 1 { 65 | t.Fatal("no search detected") 66 | } 67 | _, port, err := net.SplitHostPort(mm[0].From.String()) 68 | if err != nil { 69 | t.Fatalf("failed to split host and port: %s", err) 70 | } 71 | port = ":" + port 72 | 73 | expHdr := map[string]string{ 74 | "Man": `"ssdp:discover"`, 75 | "Mx": "1", 76 | "St": "test:search+request", 77 | } 78 | for i, m := range mm { 79 | if m.Type != "test:search+request" { 80 | t.Errorf("unmatch type#%d:\nwant=%q\n got=%q", i, "test:search+request", m.Type) 81 | } 82 | if strings.HasSuffix(port, m.From.String()) { 83 | t.Errorf("unmatch port#%d:\nwant=%q\n got=%q", i, port, m.From.String()) 84 | } 85 | 86 | h := m.Header() 87 | for k := range h { 88 | exp, ok := expHdr[k] 89 | if !ok { 90 | t.Errorf("unexpected header #%d %q=%q", i, k, h.Get(k)) 91 | } else if act := h.Get(k); act != exp { 92 | t.Errorf("header #%d %q value mismatch:\nwant=%q\n got=%q", i, k, exp, act) 93 | } 94 | } 95 | } 96 | } 97 | 98 | func TestSearch_Response(t *testing.T) { 99 | a, err := Advertise("test:search+response", "usn:search+response", "location:search+response", "server:search+response", 600) 100 | if err != nil { 101 | t.Fatalf("failed to Advertise: %s", err) 102 | } 103 | defer a.Close() 104 | 105 | srvs, err := Search("test:search+response", 1, "") 106 | if err != nil { 107 | t.Fatalf("failed to Search: %s", err) 108 | } 109 | if len(srvs) == 0 { 110 | t.Errorf("no services found") 111 | } 112 | 113 | //t.Logf("found %d services", len(srvs)) 114 | for i, s := range srvs { 115 | if s.Type != "test:search+response" { 116 | t.Errorf("unexpected service#%d type: want=%q got=%q", i, "test:search+response", s.Type) 117 | } 118 | if s.USN != "usn:search+response" { 119 | t.Errorf("unexpected service#%d usn: want=%q got=%q", i, "usn:search+response", s.USN) 120 | } 121 | if s.Location != "location:search+response" { 122 | t.Errorf("unexpected service#%d location: want=%q got=%q", i, "location:search+response", s.Location) 123 | } 124 | if s.Server != "server:search+response" { 125 | t.Errorf("unexpected service#%d server: want=%q got=%q", i, "server:search+response", s.Server) 126 | } 127 | } 128 | } 129 | 130 | func TestSearch_ServiceRawHeader(t *testing.T) { 131 | a, err := Advertise("test:search+servicerawheader", "usn:search+servicerawheader", "location:search+servicerawheader", "server:search+servicerawheader", 600) 132 | if err != nil { 133 | t.Fatalf("failed to Advertise: %s", err) 134 | } 135 | defer a.Close() 136 | 137 | srvs, err := Search("test:search+servicerawheader", 1, "") 138 | if err != nil { 139 | t.Fatalf("failed to Search: %s", err) 140 | } 141 | if len(srvs) == 0 { 142 | t.Fatal("no services found") 143 | } 144 | 145 | expHdr := map[string]string{ 146 | "St": "test:search+servicerawheader", 147 | "Usn": "usn:search+servicerawheader", 148 | "Location": "location:search+servicerawheader", 149 | "Server": "server:search+servicerawheader", 150 | "Cache-Control": "max-age=600", 151 | "Ext": "", 152 | } 153 | for i, s := range srvs { 154 | if s.Type != "test:search+servicerawheader" { 155 | t.Errorf("unmatch type#%d:\nwant=%q\n got=%q", i, "test:search+request", s.Type) 156 | } 157 | if s.USN != "usn:search+servicerawheader" { 158 | t.Errorf("unexpected alive#%d usn: want=%q got=%q", i, "usn:search+servicerawheader", s.USN) 159 | } 160 | if s.Location != "location:search+servicerawheader" { 161 | t.Errorf("unexpected alive#%d location: want=%q got=%q", i, "location:search+servicerawheader", s.Location) 162 | } 163 | if s.Server != "server:search+servicerawheader" { 164 | t.Errorf("unexpected alive#%d server: want=%q got=%q", i, "server:search+servicerawheader", s.Server) 165 | } 166 | if s.MaxAge() != 600 { 167 | t.Errorf("unexpected max-age: want=%d got=%d", 600, s.MaxAge()) 168 | } 169 | 170 | h := s.Header() 171 | for k := range h { 172 | exp, ok := expHdr[k] 173 | if !ok { 174 | t.Errorf("unexpected header #%d %q=%q", i, k, h.Get(k)) 175 | } else if act := h.Get(k); act != exp { 176 | t.Errorf("header #%d %q value mismatch:\nwant=%q\n got=%q", i, k, exp, act) 177 | } 178 | } 179 | } 180 | } 181 | 182 | func TestSearch_AdvetiserWithHost(t *testing.T) { 183 | a, err := Advertise("test:search+advertiserwithhost", "usn:search+advertiserwithhost", "location:search+advertiserwithhost", "server:search+advertiserwithhost", 600, AdvertiseHost()) 184 | if err != nil { 185 | t.Fatalf("failed to Advertise: %s", err) 186 | } 187 | defer a.Close() 188 | 189 | srvs, err := Search("test:search+advertiserwithhost", 1, "") 190 | if err != nil { 191 | t.Fatalf("failed to Search: %s", err) 192 | } 193 | if len(srvs) == 0 { 194 | t.Fatal("no services found") 195 | } 196 | 197 | expHdr := map[string]string{ 198 | "St": "test:search+advertiserwithhost", 199 | "Usn": "usn:search+advertiserwithhost", 200 | "Location": "location:search+advertiserwithhost", 201 | "Server": "server:search+advertiserwithhost", 202 | "Cache-Control": "max-age=600", 203 | "Ext": "", 204 | "Host": "239.255.255.250:1900", 205 | } 206 | for i, s := range srvs { 207 | if s.Type != "test:search+advertiserwithhost" { 208 | t.Errorf("unmatch type#%d:\nwant=%q\n got=%q", i, "test:search+request", s.Type) 209 | } 210 | if s.USN != "usn:search+advertiserwithhost" { 211 | t.Errorf("unexpected alive#%d usn: want=%q got=%q", i, "usn:search+advertiserwithhost", s.USN) 212 | } 213 | if s.Location != "location:search+advertiserwithhost" { 214 | t.Errorf("unexpected alive#%d location: want=%q got=%q", i, "location:search+advertiserwithhost", s.Location) 215 | } 216 | if s.Server != "server:search+advertiserwithhost" { 217 | t.Errorf("unexpected alive#%d server: want=%q got=%q", i, "server:search+advertiserwithhost", s.Server) 218 | } 219 | if s.MaxAge() != 600 { 220 | t.Errorf("unexpected max-age: want=%d got=%d", 600, s.MaxAge()) 221 | } 222 | 223 | h := s.Header() 224 | for k := range h { 225 | exp, ok := expHdr[k] 226 | if !ok { 227 | t.Errorf("unexpected header #%d %q=%q", i, k, h.Get(k)) 228 | } else if act := h.Get(k); act != exp { 229 | t.Errorf("header #%d %q value mismatch:\nwant=%q\n got=%q", i, k, exp, act) 230 | } 231 | } 232 | } 233 | } 234 | --------------------------------------------------------------------------------