diff --git a/cmd/hass/hass.go b/cmd/hass/hass.go
index ddbfe995..32d0771e 100644
--- a/cmd/hass/hass.go
+++ b/cmd/hass/hass.go
@@ -131,6 +131,25 @@ func importEntries(config string) map[string]string {
case "roborock":
_ = json.Unmarshal(entrie.Data, &roborock.Auth)
+ case "onvif":
+ var data struct {
+ Host string `json:"host" json:"host"`
+ Port uint16 `json:"port" json:"port"`
+ Username string `json:"username" json:"username"`
+ Password string `json:"password" json:"password"`
+ }
+ if err = json.Unmarshal(entrie.Data, &data); err != nil {
+ continue
+ }
+
+ if data.Username != "" && data.Password != "" {
+ urls[entrie.Title] = fmt.Sprintf(
+ "onvif://%s:%s@%s:%d", data.Username, data.Password, data.Host, data.Port,
+ )
+ } else {
+ urls[entrie.Title] = fmt.Sprintf("onvif://%s:%d", data.Host, data.Port)
+ }
+
default:
continue
}
diff --git a/cmd/onvif/init.go b/cmd/onvif/init.go
new file mode 100644
index 00000000..928b77d4
--- /dev/null
+++ b/cmd/onvif/init.go
@@ -0,0 +1,173 @@
+package onvif
+
+import (
+ "github.com/AlexxIT/go2rtc/cmd/api"
+ "github.com/AlexxIT/go2rtc/cmd/app"
+ "github.com/AlexxIT/go2rtc/cmd/rtsp"
+ "github.com/AlexxIT/go2rtc/cmd/streams"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/AlexxIT/go2rtc/pkg/onvif"
+ "github.com/rs/zerolog"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "strconv"
+ "time"
+)
+
+func Init() {
+ log = app.GetLogger("onvif")
+
+ streams.HandleFunc("onvif", streamOnvif)
+
+ // ONVIF server on all suburls
+ api.HandleFunc("/onvif/", onvifDeviceService)
+
+ // ONVIF client autodiscovery
+ api.HandleFunc("api/onvif", apiOnvif)
+}
+
+var log zerolog.Logger
+
+func streamOnvif(rawURL string) (core.Producer, error) {
+ client, err := onvif.NewClient(rawURL)
+ if err != nil {
+ return nil, err
+ }
+
+ uri, err := client.GetURI()
+ if err != nil {
+ return nil, err
+ }
+
+ log.Debug().Msgf("[onvif] new uri=%s", uri)
+
+ return streams.GetProducer(uri)
+}
+
+func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ action := onvif.GetRequestAction(b)
+ if action == "" {
+ http.Error(w, "malformed request body", http.StatusBadRequest)
+ return
+ }
+
+ log.Trace().Msgf("[onvif] %s", action)
+
+ var res string
+
+ switch action {
+ case onvif.ActionGetCapabilities:
+ // important for Hass: Media section
+ res = onvif.GetCapabilitiesResponse(r.Host)
+
+ case onvif.ActionGetSystemDateAndTime:
+ // important for Hass
+ res = onvif.GetSystemDateAndTimeResponse()
+
+ case onvif.ActionGetNetworkInterfaces:
+ // important for Hass: none
+ res = onvif.GetNetworkInterfacesResponse()
+
+ case onvif.ActionGetDeviceInformation:
+ // important for Hass: SerialNumber (unique server ID)
+ res = onvif.GetDeviceInformationResponse("", "go2rtc", app.Version, r.Host)
+
+ case onvif.ActionGetServiceCapabilities:
+ // important for Hass
+ res = onvif.GetServiceCapabilitiesResponse()
+
+ case onvif.ActionSystemReboot:
+ res = onvif.SystemRebootResponse()
+
+ time.AfterFunc(time.Second, func() {
+ os.Exit(0)
+ })
+
+ case onvif.ActionGetProfiles:
+ // important for Hass: H264 codec, width, height
+ res = onvif.GetProfilesResponse(streams.GetAll())
+
+ case onvif.ActionGetStreamUri:
+ host, _, err := net.SplitHostPort(r.Host)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ uri := "rtsp://" + host + ":" + rtsp.Port + "/" + onvif.FindTagValue(b, "ProfileToken")
+ res = onvif.GetStreamUriResponse(uri)
+
+ default:
+ http.Error(w, "unsupported action", http.StatusBadRequest)
+ log.Debug().Msgf("[onvif] unsupported request:\n%s", b)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/soap+xml; charset=utf-8")
+ if _, err = w.Write([]byte(res)); err != nil {
+ log.Error().Err(err).Caller().Send()
+ }
+}
+
+func apiOnvif(w http.ResponseWriter, r *http.Request) {
+ src := r.URL.Query().Get("src")
+
+ var items []api.Stream
+
+ if src == "" {
+ hosts, err := onvif.DiscoveryStreamingHosts()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ for _, host := range hosts {
+ items = append(items, api.Stream{
+ Name: host,
+ URL: "onvif://user:pass@" + host,
+ })
+ }
+ } else {
+ client, err := onvif.NewClient(src)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ name, err := client.GetName()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ tokens, err := client.GetProfilesTokens()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ for i, token := range tokens {
+ items = append(items, api.Stream{
+ Name: name + " stream" + strconv.Itoa(i),
+ URL: src + "?subtype=" + token,
+ })
+ }
+
+ if len(tokens) > 0 && client.HasSnapshots() {
+ items = append(items, api.Stream{
+ Name: name + " snapshot",
+ URL: src + "?subtype=" + tokens[0] + "&snapshot",
+ })
+ }
+ }
+
+ api.ResponseStreams(w, items)
+}
diff --git a/cmd/streams/init.go b/cmd/streams/init.go
index ca5ff58e..84b2f207 100644
--- a/cmd/streams/init.go
+++ b/cmd/streams/init.go
@@ -68,6 +68,13 @@ func GetOrNew(src string) *Stream {
return New(src, src)
}
+func GetAll() (names []string) {
+ for name := range streams {
+ names = append(names, name)
+ }
+ return
+}
+
func streamsHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
src := query.Get("src")
diff --git a/main.go b/main.go
index 4f91f4e6..6a50d407 100644
--- a/main.go
+++ b/main.go
@@ -18,6 +18,7 @@ import (
"github.com/AlexxIT/go2rtc/cmd/mp4"
"github.com/AlexxIT/go2rtc/cmd/mpegts"
"github.com/AlexxIT/go2rtc/cmd/ngrok"
+ "github.com/AlexxIT/go2rtc/cmd/onvif"
"github.com/AlexxIT/go2rtc/cmd/roborock"
"github.com/AlexxIT/go2rtc/cmd/rtmp"
"github.com/AlexxIT/go2rtc/cmd/rtsp"
@@ -36,6 +37,7 @@ func main() {
app.Init() // init config and logs
api.Init() // init HTTP API server
streams.Init() // load streams list
+ onvif.Init()
rtsp.Init() // add support RTSP client and RTSP server
rtmp.Init() // add support RTMP client
diff --git a/pkg/onvif/client.go b/pkg/onvif/client.go
new file mode 100644
index 00000000..77157d0b
--- /dev/null
+++ b/pkg/onvif/client.go
@@ -0,0 +1,217 @@
+package onvif
+
+import (
+ "bytes"
+ "crypto/sha1"
+ "encoding/base64"
+ "errors"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "html"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "time"
+)
+
+type Client struct {
+ url *url.URL
+}
+
+func NewClient(rawURL string) (*Client, error) {
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return nil, err
+ }
+ return &Client{url: u}, nil
+}
+
+func (c *Client) GetURI() (string, error) {
+ query := c.url.Query()
+
+ token := query.Get("subtype")
+
+ // support empty
+ if i := atoi(token); i >= 0 {
+ tokens, err := c.GetProfilesTokens()
+ if err != nil {
+ return "", err
+ }
+ if i >= len(tokens) {
+ return "", errors.New("wrong subtype")
+ }
+ token = tokens[i]
+ }
+
+ getUri := c.GetStreamUri
+ if query.Has("snapshot") {
+ getUri = c.GetSnapshotUri
+ }
+
+ b, err := getUri(token)
+ if err != nil {
+ return "", err
+ }
+
+ uri := FindTagValue(b, "Uri")
+ uri = html.UnescapeString(uri)
+
+ u, err := url.Parse(uri)
+ if err != nil {
+ return "", err
+ }
+
+ if u.User == nil && c.url.User != nil {
+ u.User = c.url.User
+ }
+
+ return u.String(), nil
+}
+
+func (c *Client) GetName() (string, error) {
+ b, err := c.GetDeviceInformation()
+ if err != nil {
+ return "", err
+ }
+
+ return FindTagValue(b, "Manufacturer") + " " + FindTagValue(b, "Model"), nil
+}
+
+func (c *Client) GetProfilesTokens() ([]string, error) {
+ b, err := c.GetProfiles()
+ if err != nil {
+ return nil, err
+ }
+
+ var tokens []string
+
+ re := regexp.MustCompile(`Profiles.+?token="([^"]+)`)
+ for _, s := range re.FindAllStringSubmatch(string(b), 10) {
+ tokens = append(tokens, s[1])
+ }
+
+ return tokens, nil
+}
+
+func (c *Client) HasSnapshots() bool {
+ b, err := c.GetServiceCapabilities()
+ if err != nil {
+ return false
+ }
+ return strings.Contains(string(b), `SnapshotUri="true"`)
+}
+
+func (c *Client) GetCapabilities() ([]byte, error) {
+ return c.Request(
+ `
+ All
+`,
+ )
+}
+
+func (c *Client) GetNetworkInterfaces() ([]byte, error) {
+ return c.Request(``)
+}
+
+func (c *Client) GetDeviceInformation() ([]byte, error) {
+ return c.Request(``)
+}
+
+func (c *Client) GetProfiles() ([]byte, error) {
+ return c.Request(``)
+}
+
+func (c *Client) GetStreamUri(token string) ([]byte, error) {
+ return c.Request(
+ `
+
+ RTP-Unicast
+ RTSP
+
+ ` + token + `
+`)
+}
+
+func (c *Client) GetSnapshotUri(token string) ([]byte, error) {
+ return c.Request(
+ `
+ ` + token + `
+`)
+}
+
+func (c *Client) GetSystemDateAndTime() ([]byte, error) {
+ return c.Request(
+ ``,
+ )
+}
+
+func (c *Client) GetServiceCapabilities() ([]byte, error) {
+ return c.Request(
+ ``,
+ )
+}
+
+func (c *Client) SystemReboot() ([]byte, error) {
+ return c.Request(
+ ``,
+ )
+}
+
+func (c *Client) GetServices() ([]byte, error) {
+ return c.Request(`
+ true
+`)
+}
+
+func (c *Client) GetScopes() ([]byte, error) {
+ return c.Request(``)
+}
+
+func (c *Client) Request(body string) ([]byte, error) {
+ buf := bytes.NewBuffer(nil)
+ buf.WriteString(
+ ``,
+ )
+
+ if user := c.url.User; user != nil {
+ nonce := core.RandString(16, 36)
+ created := time.Now().UTC().Format(time.RFC3339Nano)
+ pass, _ := user.Password()
+
+ h := sha1.New()
+ h.Write([]byte(nonce + created + pass))
+
+ buf.WriteString(`
+
+
+` + user.Username() + `
+` + base64.StdEncoding.EncodeToString(h.Sum(nil)) + `
+` + base64.StdEncoding.EncodeToString([]byte(nonce)) + `
+` + created + `
+
+
+`)
+ }
+
+ buf.WriteString(`` + body + ``)
+
+ client := &http.Client{Timeout: time.Second * 5000}
+ res, err := client.Post(
+ "http://"+c.url.Host+"/onvif/",
+ `application/soap+xml;charset=utf-8`,
+ buf,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ // need to close body with eny response status
+ b, err := io.ReadAll(res.Body)
+
+ if err == nil && res.StatusCode != http.StatusOK {
+ err = errors.New(res.Status)
+ }
+
+ return b, err
+}
diff --git a/pkg/onvif/helpers.go b/pkg/onvif/helpers.go
new file mode 100644
index 00000000..dd12e6bc
--- /dev/null
+++ b/pkg/onvif/helpers.go
@@ -0,0 +1,99 @@
+package onvif
+
+import (
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "net"
+ "net/url"
+ "regexp"
+ "strconv"
+ "time"
+)
+
+func FindTagValue(b []byte, tag string) string {
+ re := regexp.MustCompile(tag + `[^>]*>([^<]+)`)
+ m := re.FindSubmatch(b)
+ if len(m) != 2 {
+ return ""
+ }
+ return string(m[1])
+}
+
+// UUID - generate something like 44302cbf-0d18-4feb-79b3-33b575263da3
+func UUID() string {
+ s := core.RandString(32, 16)
+ return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:]
+}
+
+func DiscoveryStreamingHosts() ([]string, error) {
+ conn, err := net.ListenPacket("udp4", ":0")
+ if err != nil {
+ return nil, err
+ }
+
+ msg := `
+
+
+ http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe
+ uuid:` + UUID() + `
+ urn:schemas-xmlsoap-org:ws:2005:04:discovery
+
+
+
+ tds:Device
+ onvif://www.onvif.org/Profile/Streaming
+
+
+`
+
+ addr := &net.UDPAddr{
+ IP: net.IP{239, 255, 255, 250},
+ Port: 3702,
+ }
+
+ if _, err = conn.WriteTo([]byte(msg), addr); err != nil {
+ return nil, err
+ }
+
+ if err = conn.SetReadDeadline(time.Now().Add(time.Second * 3)); err != nil {
+ return nil, err
+ }
+
+ var hosts []string
+
+ b := make([]byte, 8192)
+ for {
+ n, _, err := conn.ReadFrom(b)
+ if err != nil {
+ break
+ }
+
+ rawURL := FindTagValue(b[:n], "XAddrs")
+ if rawURL == "" {
+ continue
+ }
+
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ continue
+ }
+
+ if u.Scheme != "http" {
+ continue
+ }
+
+ hosts = append(hosts, u.Host)
+ }
+
+ return hosts, nil
+}
+
+func atoi(s string) int {
+ if s == "" {
+ return 0
+ }
+ i, err := strconv.Atoi(s)
+ if err != nil {
+ return -1
+ }
+ return i
+}
diff --git a/pkg/onvif/server.go b/pkg/onvif/server.go
new file mode 100644
index 00000000..3003efd7
--- /dev/null
+++ b/pkg/onvif/server.go
@@ -0,0 +1,204 @@
+package onvif
+
+import (
+ "bytes"
+ "fmt"
+ "regexp"
+ "strconv"
+ "time"
+)
+
+const (
+ ActionGetCapabilities = "GetCapabilities"
+ ActionGetSystemDateAndTime = "GetSystemDateAndTime"
+ ActionGetNetworkInterfaces = "GetNetworkInterfaces"
+ ActionGetDeviceInformation = "GetDeviceInformation"
+ ActionGetServiceCapabilities = "GetServiceCapabilities"
+ ActionGetProfiles = "GetProfiles"
+ ActionGetStreamUri = "GetStreamUri"
+ ActionSystemReboot = "SystemReboot"
+
+ ActionGetServices = "GetServices"
+ ActionGetScopes = "GetScopes"
+ ActionGetVideoSources = "GetVideoSources"
+ ActionGetAudioSources = "GetAudioSources"
+ ActionGetVideoSourceConfigurations = "GetVideoSourceConfigurations"
+ ActionGetAudioSourceConfigurations = "GetAudioSourceConfigurations"
+ ActionGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations"
+ ActionGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations"
+)
+
+func GetRequestAction(b []byte) string {
+ //
+ //
+ re := regexp.MustCompile(`Body[^<]+<([^ />]+)`)
+ m := re.FindSubmatch(b)
+ if len(m) != 2 {
+ return ""
+ }
+ if i := bytes.IndexByte(m[1], ':'); i > 0 {
+ return string(m[1][i+1:])
+ }
+ return string(m[1])
+}
+
+func GetCapabilitiesResponse(host string) string {
+ return `
+
+
+
+
+
+ http://` + host + `/onvif/device_service
+
+
+ http://` + host + `/onvif/media_service
+
+ false
+ false
+ true
+
+
+
+
+
+`
+}
+
+func GetSystemDateAndTimeResponse() string {
+ loc := time.Now()
+ utc := loc.UTC()
+
+ return fmt.Sprintf(`
+
+
+
+
+ NTP
+ false
+
+ GMT%s
+
+
+
+ %d
+ %d
+ %d
+
+
+ %d
+ %d
+ %d
+
+
+
+
+ %d
+ %d
+ %d
+
+
+ %d
+ %d
+ %d
+
+
+
+
+
+`,
+ loc.Format("-07:00"),
+ utc.Hour(), utc.Minute(), utc.Second(), utc.Year(), utc.Month(), utc.Day(),
+ loc.Hour(), loc.Minute(), loc.Second(), loc.Year(), loc.Month(), loc.Day(),
+ )
+}
+
+func GetNetworkInterfacesResponse() string {
+ return `
+
+
+
+
+`
+}
+
+func GetDeviceInformationResponse(manuf, model, firmware, serial string) string {
+ return `
+
+
+
+ ` + manuf + `
+ ` + model + `
+ ` + firmware + `
+ ` + serial + `
+ 1.00
+
+
+`
+}
+
+func GetServiceCapabilitiesResponse() string {
+ return `
+
+
+
+
+
+
+
+
+`
+}
+
+func SystemRebootResponse() string {
+ return `
+
+
+
+ system reboot in 1 second...
+
+
+`
+}
+
+func GetProfilesResponse(names []string) string {
+ buf := bytes.NewBuffer(nil)
+ buf.WriteString(`
+
+
+ `)
+
+ for i, name := range names {
+ buf.WriteString(`
+
+ ` + name + `
+
+ H264
+
+ 1920
+ 1080
+
+
+ `)
+ }
+
+ buf.WriteString(`
+
+
+`)
+
+ return buf.String()
+}
+
+func GetStreamUriResponse(uri string) string {
+ return `
+
+
+
+
+ ` + uri + `
+
+
+
+`
+}
diff --git a/www/add.html b/www/add.html
index fc907b76..476c65c5 100644
--- a/www/add.html
+++ b/www/add.html
@@ -182,6 +182,31 @@
+
+
+
+
+