From 1e14dc9ab29a3a56dd5776a6c229ba24479656fc Mon Sep 17 00:00:00 2001 From: Alexey Khit Date: Sat, 29 Apr 2023 13:58:10 +0300 Subject: [PATCH] Add ONVIF client and server support --- cmd/hass/hass.go | 19 ++++ cmd/onvif/init.go | 173 ++++++++++++++++++++++++++++++++++ cmd/streams/init.go | 7 ++ main.go | 2 + pkg/onvif/client.go | 217 +++++++++++++++++++++++++++++++++++++++++++ pkg/onvif/helpers.go | 99 ++++++++++++++++++++ pkg/onvif/server.go | 204 ++++++++++++++++++++++++++++++++++++++++ www/add.html | 25 +++++ 8 files changed, 746 insertions(+) create mode 100644 cmd/onvif/init.go create mode 100644 pkg/onvif/client.go create mode 100644 pkg/onvif/helpers.go create mode 100644 pkg/onvif/server.go 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 @@ + +
+
+ + +
+
+
+ + +