From d0ac99fc69f84ab433624663c746a9d085b0012d Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 10 Feb 2025 20:21:25 +0100 Subject: [PATCH 01/41] fix onvif client --- pkg/onvif/client.go | 82 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/pkg/onvif/client.go b/pkg/onvif/client.go index cb6221e1..3b1f065d 100644 --- a/pkg/onvif/client.go +++ b/pkg/onvif/client.go @@ -3,9 +3,10 @@ package onvif import ( "bytes" "errors" + "fmt" "html" "io" - "net/http" + "net" "net/url" "regexp" "strings" @@ -37,13 +38,22 @@ func NewClient(rawURL string) (*Client, error) { client.deviceURL = baseURL + u.Path } + // Set default media URL before trying to get capabilities + client.mediaURL = baseURL + "/onvif/media_service" + client.imaginURL = baseURL + "/onvif/imaging_service" + b, err := client.DeviceRequest(DeviceGetCapabilities) if err != nil { return nil, err } - client.mediaURL = FindTagValue(b, "Media.+?XAddr") - client.imaginURL = FindTagValue(b, "Imaging.+?XAddr") + // Update URLs if found in capabilities + if mediaAddr := FindTagValue(b, "Media.+?XAddr"); mediaAddr != "" { + client.mediaURL = mediaAddr + } + if imagingAddr := FindTagValue(b, "Imaging.+?XAddr"); imagingAddr != "" { + client.imaginURL = imagingAddr + } return client, nil } @@ -172,26 +182,70 @@ func (c *Client) MediaRequest(operation string) ([]byte, error) { return c.Request(c.mediaURL, operation) } -func (c *Client) Request(url, body string) ([]byte, error) { - if url == "" { +func (c *Client) Request(rawUrl, body string) ([]byte, error) { + if rawUrl == "" { return nil, errors.New("onvif: unsupported service") } e := NewEnvelopeWithUser(c.url.User) e.Append(body) - client := &http.Client{Timeout: time.Second * 5000} - res, err := client.Post(url, `application/soap+xml;charset=utf-8`, bytes.NewReader(e.Bytes())) + u, err := url.Parse(rawUrl) 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("onvif: " + res.Status + " for " + url) + // Ensure we have a port + host := u.Host + if !strings.Contains(host, ":") { + host = host + ":80" } - return b, err -} + // Connect with timeout + conn, err := net.DialTimeout("tcp", host, 5*time.Second) + if err != nil { + return nil, err + } + defer conn.Close() + + // Send request + httpReq := fmt.Sprintf("POST %s HTTP/1.1\r\n"+ + "Host: %s\r\n"+ + "Content-Type: application/soap+xml;charset=utf-8\r\n"+ + "Content-Length: %d\r\n"+ + "Connection: close\r\n"+ + "\r\n%s", u.Path, u.Host, len(e.Bytes()), e.Bytes()) + + if _, err = conn.Write([]byte(httpReq)); err != nil { + return nil, err + } + + // Read full response first + var fullResponse []byte + buf := make([]byte, 4096) + for { + n, err := conn.Read(buf) + if n > 0 { + fullResponse = append(fullResponse, buf[:n]...) + } + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + } + + // Look for XML in complete response + if idx := bytes.Index(fullResponse, []byte("= 0 { + return fullResponse[idx:], nil + } + + // No XML found - might be an error response + if idx := bytes.Index(fullResponse, []byte("\r\n\r\n")); idx >= 0 { + // Return body after headers + return fullResponse[idx+4:], nil + } + + return fullResponse, nil +} \ No newline at end of file From 0830d8342ecdd5c8abd35726aa9e20dd81ed691c Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 20 May 2025 12:07:46 +0200 Subject: [PATCH 02/41] add secret management functions --- internal/app/app.go | 9 ++++++++ internal/app/config.go | 47 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/internal/app/app.go b/internal/app/app.go index eb803584..02b8d68c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,6 +13,7 @@ var ( Version string UserAgent string ConfigPath string + SecretPath string Info = make(map[string]any) ) @@ -25,11 +26,14 @@ const usage = `Usage of go2rtc: func Init() { var config flagConfig + var secret string var daemon bool var version bool flag.Var(&config, "config", "") flag.Var(&config, "c", "") + flag.StringVar(&secret, "secret", "go2rtc.secret", "") + flag.StringVar(&secret, "s", "go2rtc.secret", "") flag.BoolVar(&daemon, "daemon", false, "") flag.BoolVar(&daemon, "d", false, "") flag.BoolVar(&version, "version", false, "") @@ -67,6 +71,7 @@ func Init() { Info["revision"] = revision initConfig(config) + initSecret(secret) initLogger() platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) @@ -76,6 +81,10 @@ func Init() { if ConfigPath != "" { Logger.Info().Str("path", ConfigPath).Msg("config") } + + if SecretPath != "" { + Logger.Info().Str("path", SecretPath).Msg("secrets") + } } func readRevisionTime() (revision, vcsTime string) { diff --git a/internal/app/config.go b/internal/app/config.go index 9d4480b7..16cf53b5 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -18,6 +18,14 @@ func LoadConfig(v any) { } } +func LoadSecret(v any) { + for _, data := range secrets { + if err := yaml.Unmarshal(data, v); err != nil { + Logger.Warn().Err(err).Send() + } + } +} + func PatchConfig(path []string, value any) error { if ConfigPath == "" { return errors.New("config file disabled") @@ -34,6 +42,27 @@ func PatchConfig(path []string, value any) error { return os.WriteFile(ConfigPath, b, 0644) } +func PatchSecret(path []string, value any) error { + if SecretPath == "" { + return errors.New("secret file disabled") + } + + // empty config is OK + b, _ := os.ReadFile(SecretPath) + + b, err := yaml.Patch(b, path, value) + if err != nil { + return err + } + + if err := os.WriteFile(SecretPath, b, 0644); err == nil { + secrets = [][]byte{b} + } + + return err +} + + type flagConfig []string func (c *flagConfig) String() string { @@ -46,6 +75,7 @@ func (c *flagConfig) Set(value string) error { } var configs [][]byte +var secrets [][]byte func initConfig(confs flagConfig) { if confs == nil { @@ -86,6 +116,23 @@ func initConfig(confs flagConfig) { } } +func initSecret(secret string) { + if secret == "" { + secret = "go2rtc.secrets" + } + + SecretPath = secret + + if SecretPath != "" { + if !filepath.IsAbs(SecretPath) { + if cwd, err := os.Getwd(); err == nil { + SecretPath = filepath.Join(cwd, SecretPath) + } + } + Info["secret_path"] = SecretPath + } +} + func parseConfString(s string) []byte { i := strings.IndexByte(s, '=') if i < 0 { From e5e55b7a50e51911a53d69c82aaa3dae29ef555a Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 20 May 2025 13:05:11 +0200 Subject: [PATCH 03/41] improve secret vars and parse url with secrets --- internal/app/config.go | 47 ------------- internal/app/secrets.go | 129 +++++++++++++++++++++++++++++++++++ internal/streams/handlers.go | 8 ++- 3 files changed, 136 insertions(+), 48 deletions(-) create mode 100644 internal/app/secrets.go diff --git a/internal/app/config.go b/internal/app/config.go index 16cf53b5..9d4480b7 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -18,14 +18,6 @@ func LoadConfig(v any) { } } -func LoadSecret(v any) { - for _, data := range secrets { - if err := yaml.Unmarshal(data, v); err != nil { - Logger.Warn().Err(err).Send() - } - } -} - func PatchConfig(path []string, value any) error { if ConfigPath == "" { return errors.New("config file disabled") @@ -42,27 +34,6 @@ func PatchConfig(path []string, value any) error { return os.WriteFile(ConfigPath, b, 0644) } -func PatchSecret(path []string, value any) error { - if SecretPath == "" { - return errors.New("secret file disabled") - } - - // empty config is OK - b, _ := os.ReadFile(SecretPath) - - b, err := yaml.Patch(b, path, value) - if err != nil { - return err - } - - if err := os.WriteFile(SecretPath, b, 0644); err == nil { - secrets = [][]byte{b} - } - - return err -} - - type flagConfig []string func (c *flagConfig) String() string { @@ -75,7 +46,6 @@ func (c *flagConfig) Set(value string) error { } var configs [][]byte -var secrets [][]byte func initConfig(confs flagConfig) { if confs == nil { @@ -116,23 +86,6 @@ func initConfig(confs flagConfig) { } } -func initSecret(secret string) { - if secret == "" { - secret = "go2rtc.secrets" - } - - SecretPath = secret - - if SecretPath != "" { - if !filepath.IsAbs(SecretPath) { - if cwd, err := os.Getwd(); err == nil { - SecretPath = filepath.Join(cwd, SecretPath) - } - } - Info["secret_path"] = SecretPath - } -} - func parseConfString(s string) []byte { i := strings.IndexByte(s, '=') if i < 0 { diff --git a/internal/app/secrets.go b/internal/app/secrets.go new file mode 100644 index 00000000..12fce1ef --- /dev/null +++ b/internal/app/secrets.go @@ -0,0 +1,129 @@ +package app + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/yaml" +) + +var secrets [][]byte + +var templateRegex = regexp.MustCompile(`\{\{\s*([^\}]+)\s*\}\}`) + +func ResolveSecrets(template string) string { + if !templateRegex.MatchString(template) { + return template + } + + var secretsMap map[string]interface{} + LoadSecret(&secretsMap) + + // ex template: rtsp://{{ my_camera.username }}:{{ my_camera.password }}@192.168.178.1:554/stream + result := templateRegex.ReplaceAllStringFunc(template, func(match string) string { + varName := strings.TrimSpace(templateRegex.FindStringSubmatch(match)[1]) + pathParts := strings.Split(varName, ".") + value := getNestedValue(secretsMap, pathParts) + + if value != nil { + return stringify(value) + } + + return "" + }) + + return result +} + +func LoadSecret(v any) { + for _, data := range secrets { + if err := yaml.Unmarshal(data, v); err != nil { + Logger.Warn().Err(err).Send() + } + } +} + +func PatchSecret(path []string, value any) error { + if SecretPath == "" { + return errors.New("secret file disabled") + } + + // empty config is OK + b, _ := os.ReadFile(SecretPath) + + b, err := yaml.Patch(b, path, value) + if err != nil { + return err + } + + if err := os.WriteFile(SecretPath, b, 0644); err == nil { + secrets = [][]byte{b} + } + + return err +} + +func initSecret(secret string) { + if secret == "" { + secret = "go2rtc.secrets" + } + + SecretPath = secret + + if SecretPath != "" { + if !filepath.IsAbs(SecretPath) { + if cwd, err := os.Getwd(); err == nil { + SecretPath = filepath.Join(cwd, SecretPath) + } + } + Info["secret_path"] = SecretPath + } +} + +func getNestedValue(m map[string]interface{}, path []string) interface{} { + if len(path) == 0 || m == nil { + return nil + } + + key := path[0] + value, exists := m[key] + if !exists { + return nil + } + + if len(path) == 1 { + return value + } + + // Für verschachtelte Maps + switch nextMap := value.(type) { + case map[string]interface{}: + return getNestedValue(nextMap, path[1:]) + case map[interface{}]interface{}: + // Konvertiere map[interface{}]interface{} zu map[string]interface{} + stringMap := make(map[string]interface{}) + for k, v := range nextMap { + if keyStr, ok := k.(string); ok { + stringMap[keyStr] = v + } + } + return getNestedValue(stringMap, path[1:]) + default: + return nil + } +} + +func stringify(value interface{}) string { + switch v := value.(type) { + case string: + return v + case int, int64, float64, bool: + return fmt.Sprintf("%v", v) + default: + return "" + } +} \ No newline at end of file diff --git a/internal/streams/handlers.go b/internal/streams/handlers.go index 3240abb5..bd394fc8 100644 --- a/internal/streams/handlers.go +++ b/internal/streams/handlers.go @@ -4,6 +4,7 @@ import ( "errors" "strings" + "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/pkg/core" ) @@ -46,7 +47,8 @@ func GetProducer(url string) (core.Producer, error) { } if handler, ok := handlers[scheme]; ok { - return handler(url) + parsedURL := ParseURL(url) + return handler(parsedURL) } } @@ -95,3 +97,7 @@ func GetConsumer(url string) (core.Consumer, func(), error) { return nil, nil, errors.New("streams: unsupported scheme: " + url) } + +func ParseURL(url string) string { + return app.ResolveSecrets(url) +} \ No newline at end of file From a2beea1bbd6ecb8ae83803bcf3f41ee0fc65c87b Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 20 May 2025 13:59:46 +0200 Subject: [PATCH 04/41] refactor --- internal/streams/handlers.go | 9 +++------ pkg/core/listener.go | 10 ++++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/internal/streams/handlers.go b/internal/streams/handlers.go index bd394fc8..d2b99d65 100644 --- a/internal/streams/handlers.go +++ b/internal/streams/handlers.go @@ -47,7 +47,7 @@ func GetProducer(url string) (core.Producer, error) { } if handler, ok := handlers[scheme]; ok { - parsedURL := ParseURL(url) + parsedURL := app.ResolveSecrets(url) return handler(parsedURL) } } @@ -91,13 +91,10 @@ func GetConsumer(url string) (core.Consumer, func(), error) { scheme := url[:i] if handler, ok := consumerHandlers[scheme]; ok { - return handler(url) + parsedURL := app.ResolveSecrets(url) + return handler(parsedURL) } } return nil, nil, errors.New("streams: unsupported scheme: " + url) } - -func ParseURL(url string) string { - return app.ResolveSecrets(url) -} \ No newline at end of file diff --git a/pkg/core/listener.go b/pkg/core/listener.go index 75d9202a..2b7e198b 100644 --- a/pkg/core/listener.go +++ b/pkg/core/listener.go @@ -1,5 +1,7 @@ package core +import "github.com/AlexxIT/go2rtc/internal/app" + type EventFunc func(msg any) // Listener base struct for all classes with support feedback @@ -16,3 +18,11 @@ func (l *Listener) Fire(msg any) { f(msg) } } + +func (l *Listener) ParseSource(url string) string { + return app.ResolveSecrets(url) +} + +func (l *Listener) SaveSource(path []string, value any) error { + return app.PatchSecret(path, value) +} \ No newline at end of file From 2fcbb1d836153c7208e135de206566cfeb4f912d Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 20 May 2025 15:51:15 +0200 Subject: [PATCH 05/41] refactor --- internal/app/secrets.go | 54 +++++++++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/internal/app/secrets.go b/internal/app/secrets.go index 12fce1ef..2e279278 100644 --- a/internal/app/secrets.go +++ b/internal/app/secrets.go @@ -16,9 +16,9 @@ var secrets [][]byte var templateRegex = regexp.MustCompile(`\{\{\s*([^\}]+)\s*\}\}`) func ResolveSecrets(template string) string { - if !templateRegex.MatchString(template) { - return template - } + if !templateRegex.MatchString(template) { + return template + } var secretsMap map[string]interface{} LoadSecret(&secretsMap) @@ -28,23 +28,38 @@ func ResolveSecrets(template string) string { varName := strings.TrimSpace(templateRegex.FindStringSubmatch(match)[1]) pathParts := strings.Split(varName, ".") value := getNestedValue(secretsMap, pathParts) - + if value != nil { return stringify(value) } - + return "" }) - + return result } func LoadSecret(v any) { - for _, data := range secrets { - if err := yaml.Unmarshal(data, v); err != nil { - Logger.Warn().Err(err).Send() - } - } + for _, data := range secrets { + var tempData map[string]interface{} + + if err := yaml.Unmarshal(data, &tempData); err != nil { + Logger.Warn().Err(err).Send() + continue + } + + if secretData, exists := tempData["secret"]; exists { + secretBytes, err := yaml.Encode(secretData, 2) + if err != nil { + Logger.Warn().Err(err).Send() + continue + } + + if err := yaml.Unmarshal(secretBytes, v); err != nil { + Logger.Warn().Err(err).Send() + } + } + } } func PatchSecret(path []string, value any) error { @@ -55,7 +70,7 @@ func PatchSecret(path []string, value any) error { // empty config is OK b, _ := os.ReadFile(SecretPath) - b, err := yaml.Patch(b, path, value) + b, err := yaml.Patch(b, append([]string{"secret"}, path...), value) if err != nil { return err } @@ -82,29 +97,32 @@ func initSecret(secret string) { } Info["secret_path"] = SecretPath } + + if data, err := os.ReadFile(SecretPath); err == nil { + secrets = append(secrets, data) + } } func getNestedValue(m map[string]interface{}, path []string) interface{} { if len(path) == 0 || m == nil { return nil } - + key := path[0] value, exists := m[key] if !exists { return nil } - + if len(path) == 1 { return value } - - // Für verschachtelte Maps + + // Check nested maps switch nextMap := value.(type) { case map[string]interface{}: return getNestedValue(nextMap, path[1:]) case map[interface{}]interface{}: - // Konvertiere map[interface{}]interface{} zu map[string]interface{} stringMap := make(map[string]interface{}) for k, v := range nextMap { if keyStr, ok := k.(string); ok { @@ -126,4 +144,4 @@ func stringify(value interface{}) string { default: return "" } -} \ No newline at end of file +} From a0145b4b241dc187e83925c7aa702876eb87e650 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 20 May 2025 15:52:26 +0200 Subject: [PATCH 06/41] revert handlers --- internal/streams/handlers.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/internal/streams/handlers.go b/internal/streams/handlers.go index d2b99d65..3240abb5 100644 --- a/internal/streams/handlers.go +++ b/internal/streams/handlers.go @@ -4,7 +4,6 @@ import ( "errors" "strings" - "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/pkg/core" ) @@ -47,8 +46,7 @@ func GetProducer(url string) (core.Producer, error) { } if handler, ok := handlers[scheme]; ok { - parsedURL := app.ResolveSecrets(url) - return handler(parsedURL) + return handler(url) } } @@ -91,8 +89,7 @@ func GetConsumer(url string) (core.Consumer, func(), error) { scheme := url[:i] if handler, ok := consumerHandlers[scheme]; ok { - parsedURL := app.ResolveSecrets(url) - return handler(parsedURL) + return handler(url) } } From 7f87c6e478c26ba7277b7155013ebd19be05ae33 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 20 May 2025 21:40:33 +0200 Subject: [PATCH 07/41] refactor --- internal/app/app.go | 5 +- internal/app/secrets.go | 195 +++++++++++++++++++++++++++------------- pkg/core/listener.go | 8 +- 3 files changed, 137 insertions(+), 71 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 02b8d68c..706841ec 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -26,14 +26,11 @@ const usage = `Usage of go2rtc: func Init() { var config flagConfig - var secret string var daemon bool var version bool flag.Var(&config, "config", "") flag.Var(&config, "c", "") - flag.StringVar(&secret, "secret", "go2rtc.secret", "") - flag.StringVar(&secret, "s", "go2rtc.secret", "") flag.BoolVar(&daemon, "daemon", false, "") flag.BoolVar(&daemon, "d", false, "") flag.BoolVar(&version, "version", false, "") @@ -71,7 +68,7 @@ func Init() { Info["revision"] = revision initConfig(config) - initSecret(secret) + initSecrets() initLogger() platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) diff --git a/internal/app/secrets.go b/internal/app/secrets.go index 2e279278..b7773b65 100644 --- a/internal/app/secrets.go +++ b/internal/app/secrets.go @@ -1,33 +1,97 @@ package app import ( - "errors" "fmt" - "os" - "path/filepath" "regexp" "strings" + "sync" "github.com/AlexxIT/go2rtc/pkg/yaml" ) -var secrets [][]byte +var secrets = make(map[string]*Secret) +var secretsMu sync.Mutex var templateRegex = regexp.MustCompile(`\{\{\s*([^\}]+)\s*\}\}`) -func ResolveSecrets(template string) string { +type Secrets interface { + Get(key string) any + Set(key string, value any) + Parse(template string) string + Marshal(v any) ([]byte, error) + Unmarshal(v any) error + Save() error +} + +type Secret struct { + Secrets + + Name string + Values map[string]any +} + +func NewSecret(name string, values interface{}) *Secret { + secretsMu.Lock() + defer secretsMu.Unlock() + + if s, exists := secrets[name]; exists { + return s + } + + s := &Secret{Name: name, Values: make(map[string]any)} + + switch v := values.(type) { + case map[string]any: + s.Values = v + default: + data, err := yaml.Encode(values, 2) + if err == nil { + var mapValues map[string]any + if err := yaml.Unmarshal(data, &mapValues); err == nil { + s.Values = mapValues + } + } + } + + secrets[name] = s + return s +} + +func (s *Secret) Get(key string) any { + secretsMu.Lock() + defer secretsMu.Unlock() + + return s.Values[key] +} + +func (s *Secret) Set(key string, value any) { + secretsMu.Lock() + defer secretsMu.Unlock() + + if s.Values == nil { + s.Values = make(map[string]any) + } + + s.Values[key] = value + secrets[s.Name] = s +} + +func (s *Secret) Parse(template string) string { if !templateRegex.MatchString(template) { return template } - var secretsMap map[string]interface{} - LoadSecret(&secretsMap) + secretsMu.Lock() + defer secretsMu.Unlock() + + if _, exists := secrets[s.Name]; !exists { + return template + } - // ex template: rtsp://{{ my_camera.username }}:{{ my_camera.password }}@192.168.178.1:554/stream result := templateRegex.ReplaceAllStringFunc(template, func(match string) string { varName := strings.TrimSpace(templateRegex.FindStringSubmatch(match)[1]) pathParts := strings.Split(varName, ".") - value := getNestedValue(secretsMap, pathParts) + value := getNestedValue(s.Values, pathParts) if value != nil { return stringify(value) @@ -39,71 +103,80 @@ func ResolveSecrets(template string) string { return result } -func LoadSecret(v any) { - for _, data := range secrets { - var tempData map[string]interface{} +func (s *Secret) Marshal(v any) ([]byte, error) { + secretsMu.Lock() + defer secretsMu.Unlock() - if err := yaml.Unmarshal(data, &tempData); err != nil { - Logger.Warn().Err(err).Send() - continue - } - - if secretData, exists := tempData["secret"]; exists { - secretBytes, err := yaml.Encode(secretData, 2) - if err != nil { - Logger.Warn().Err(err).Send() - continue - } - - if err := yaml.Unmarshal(secretBytes, v); err != nil { - Logger.Warn().Err(err).Send() - } - } - } -} - -func PatchSecret(path []string, value any) error { - if SecretPath == "" { - return errors.New("secret file disabled") + if s.Values == nil { + return nil, fmt.Errorf("no values in secret %s", s.Name) } - // empty config is OK - b, _ := os.ReadFile(SecretPath) - - b, err := yaml.Patch(b, append([]string{"secret"}, path...), value) + data, err := yaml.Encode(s.Values, 2) if err != nil { - return err + return nil, fmt.Errorf("error encoding secret values: %w", err) } - if err := os.WriteFile(SecretPath, b, 0644); err == nil { - secrets = [][]byte{b} - } - - return err + return data, nil } -func initSecret(secret string) { - if secret == "" { - secret = "go2rtc.secrets" +func (s *Secret) Unmarshal(v any) error { + secretsMu.Lock() + defer secretsMu.Unlock() + + if s.Values == nil { + return fmt.Errorf("no values in secret %s", s.Name) } - SecretPath = secret - - if SecretPath != "" { - if !filepath.IsAbs(SecretPath) { - if cwd, err := os.Getwd(); err == nil { - SecretPath = filepath.Join(cwd, SecretPath) - } - } - Info["secret_path"] = SecretPath + data, err := yaml.Encode(s.Values, 2) + if err != nil { + return fmt.Errorf("error encoding secret values: %w", err) } - if data, err := os.ReadFile(SecretPath); err == nil { - secrets = append(secrets, data) + if err := yaml.Unmarshal(data, v); err != nil { + return fmt.Errorf("error unmarshaling secret values: %w", err) + } + + return nil +} + +func (s *Secret) Save() error { + secretsMu.Lock() + defer secretsMu.Unlock() + return saveSecret(s.Name, s.Values) +} + +func initSecrets() { + var cfg struct { + Secrets map[string]map[string]any `yaml:"secrets"` + } + + /* + Example config: + secrets: + test_camera: + username: test + password: test + */ + + LoadConfig(&cfg) + + if cfg.Secrets == nil { + return + } + + secretsMu.Lock() + defer secretsMu.Unlock() + + for name, values := range cfg.Secrets { + secrets[name] = &Secret{Name: name, Values: values} } } -func getNestedValue(m map[string]interface{}, path []string) interface{} { +func saveSecret(name string, secret map[string]any) error { + return PatchConfig([]string{"secrets", name}, secret) +} + +func getNestedValue(m map[string]any, path []string) interface{} { if len(path) == 0 || m == nil { return nil } @@ -120,10 +193,10 @@ func getNestedValue(m map[string]interface{}, path []string) interface{} { // Check nested maps switch nextMap := value.(type) { - case map[string]interface{}: + case map[string]any: return getNestedValue(nextMap, path[1:]) case map[interface{}]interface{}: - stringMap := make(map[string]interface{}) + stringMap := make(map[string]any) for k, v := range nextMap { if keyStr, ok := k.(string); ok { stringMap[keyStr] = v diff --git a/pkg/core/listener.go b/pkg/core/listener.go index 2b7e198b..2840880d 100644 --- a/pkg/core/listener.go +++ b/pkg/core/listener.go @@ -19,10 +19,6 @@ func (l *Listener) Fire(msg any) { } } -func (l *Listener) ParseSource(url string) string { - return app.ResolveSecrets(url) -} - -func (l *Listener) SaveSource(path []string, value any) error { - return app.PatchSecret(path, value) +func (l *Listener) NewSecret(name string, defaultValues interface{}) *app.Secret { + return app.NewSecret(name, defaultValues) } \ No newline at end of file From a1f0b86ab3e0e0f00752c4aafe15bb35e2f37ddf Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 20 May 2025 22:29:27 +0200 Subject: [PATCH 08/41] format --- internal/app/app.go | 5 ----- internal/app/secrets.go | 44 ++++++++++++++++++++--------------------- pkg/core/listener.go | 2 +- 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 706841ec..4b89daa9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,7 +13,6 @@ var ( Version string UserAgent string ConfigPath string - SecretPath string Info = make(map[string]any) ) @@ -78,10 +77,6 @@ func Init() { if ConfigPath != "" { Logger.Info().Str("path", ConfigPath).Msg("config") } - - if SecretPath != "" { - Logger.Info().Str("path", SecretPath).Msg("secrets") - } } func readRevisionTime() (revision, vcsTime string) { diff --git a/internal/app/secrets.go b/internal/app/secrets.go index b7773b65..3c3cbbfe 100644 --- a/internal/app/secrets.go +++ b/internal/app/secrets.go @@ -26,35 +26,35 @@ type Secrets interface { type Secret struct { Secrets - Name string + Name string Values map[string]any } func NewSecret(name string, values interface{}) *Secret { - secretsMu.Lock() - defer secretsMu.Unlock() + secretsMu.Lock() + defer secretsMu.Unlock() - if s, exists := secrets[name]; exists { - return s - } + if s, exists := secrets[name]; exists { + return s + } - s := &Secret{Name: name, Values: make(map[string]any)} - - switch v := values.(type) { - case map[string]any: - s.Values = v - default: - data, err := yaml.Encode(values, 2) - if err == nil { - var mapValues map[string]any - if err := yaml.Unmarshal(data, &mapValues); err == nil { - s.Values = mapValues - } - } - } + s := &Secret{Name: name, Values: make(map[string]any)} - secrets[name] = s - return s + switch v := values.(type) { + case map[string]any: + s.Values = v + default: + data, err := yaml.Encode(values, 2) + if err == nil { + var mapValues map[string]any + if err := yaml.Unmarshal(data, &mapValues); err == nil { + s.Values = mapValues + } + } + } + + secrets[name] = s + return s } func (s *Secret) Get(key string) any { diff --git a/pkg/core/listener.go b/pkg/core/listener.go index 2840880d..7a512741 100644 --- a/pkg/core/listener.go +++ b/pkg/core/listener.go @@ -21,4 +21,4 @@ func (l *Listener) Fire(msg any) { func (l *Listener) NewSecret(name string, defaultValues interface{}) *app.Secret { return app.NewSecret(name, defaultValues) -} \ No newline at end of file +} From 24310e2f7a6d6ae8d731f72024b1d89278533d93 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 20 May 2025 22:44:07 +0200 Subject: [PATCH 09/41] remove parse --- internal/app/secrets.go | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/internal/app/secrets.go b/internal/app/secrets.go index 3c3cbbfe..e0acc901 100644 --- a/internal/app/secrets.go +++ b/internal/app/secrets.go @@ -2,8 +2,6 @@ package app import ( "fmt" - "regexp" - "strings" "sync" "github.com/AlexxIT/go2rtc/pkg/yaml" @@ -12,8 +10,6 @@ import ( var secrets = make(map[string]*Secret) var secretsMu sync.Mutex -var templateRegex = regexp.MustCompile(`\{\{\s*([^\}]+)\s*\}\}`) - type Secrets interface { Get(key string) any Set(key string, value any) @@ -76,33 +72,6 @@ func (s *Secret) Set(key string, value any) { secrets[s.Name] = s } -func (s *Secret) Parse(template string) string { - if !templateRegex.MatchString(template) { - return template - } - - secretsMu.Lock() - defer secretsMu.Unlock() - - if _, exists := secrets[s.Name]; !exists { - return template - } - - result := templateRegex.ReplaceAllStringFunc(template, func(match string) string { - varName := strings.TrimSpace(templateRegex.FindStringSubmatch(match)[1]) - pathParts := strings.Split(varName, ".") - value := getNestedValue(s.Values, pathParts) - - if value != nil { - return stringify(value) - } - - return "" - }) - - return result -} - func (s *Secret) Marshal(v any) ([]byte, error) { secretsMu.Lock() defer secretsMu.Unlock() From e0687db9e2178c49fa62f922589b820720b9ea8e Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 20 May 2025 23:07:04 +0200 Subject: [PATCH 10/41] add template parsing --- internal/app/secrets.go | 35 +++++++++++++++++++++++++++++++++++ internal/streams/handlers.go | 13 +++++++++++++ 2 files changed, 48 insertions(+) diff --git a/internal/app/secrets.go b/internal/app/secrets.go index e0acc901..c3c4f95e 100644 --- a/internal/app/secrets.go +++ b/internal/app/secrets.go @@ -2,6 +2,8 @@ package app import ( "fmt" + "regexp" + "strings" "sync" "github.com/AlexxIT/go2rtc/pkg/yaml" @@ -10,6 +12,8 @@ import ( var secrets = make(map[string]*Secret) var secretsMu sync.Mutex +var templateRegex = regexp.MustCompile(`\{\{\s*([^\}]+)\s*\}\}`) + type Secrets interface { Get(key string) any Set(key string, value any) @@ -53,6 +57,10 @@ func NewSecret(name string, values interface{}) *Secret { return s } +func GetSecret(name string) *Secret { + return secrets[name] +} + func (s *Secret) Get(key string) any { secretsMu.Lock() defer secretsMu.Unlock() @@ -72,6 +80,33 @@ func (s *Secret) Set(key string, value any) { secrets[s.Name] = s } +func (s *Secret) Parse(template string) string { + if !templateRegex.MatchString(template) { + return template + } + + secretsMu.Lock() + defer secretsMu.Unlock() + + if _, exists := secrets[s.Name]; !exists { + return template + } + + result := templateRegex.ReplaceAllStringFunc(template, func(match string) string { + varName := strings.TrimSpace(templateRegex.FindStringSubmatch(match)[1]) + pathParts := strings.Split(varName, ".") + value := getNestedValue(s.Values, pathParts) + + if value != nil { + return stringify(value) + } + + return "" + }) + + return result +} + func (s *Secret) Marshal(v any) ([]byte, error) { secretsMu.Lock() defer secretsMu.Unlock() diff --git a/internal/streams/handlers.go b/internal/streams/handlers.go index 3240abb5..991b69c9 100644 --- a/internal/streams/handlers.go +++ b/internal/streams/handlers.go @@ -4,6 +4,7 @@ import ( "errors" "strings" + "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/pkg/core" ) @@ -46,6 +47,18 @@ func GetProducer(url string) (core.Producer, error) { } if handler, ok := handlers[scheme]; ok { + index := strings.IndexByte(url, '#') + if index > 0 { + _, query := url[:index], ParseQuery(url[index+1:]) + secretsName := query.Get("secrets") + if secretsName != "" { + secrets := app.GetSecret(secretsName) + if secrets != nil { + url = secrets.Parse(url) + } + } + } + return handler(url) } } From 72890d59839b30d9dc7c0f2f45c4036f65bf3a1d Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Mon, 26 May 2025 14:01:26 -0300 Subject: [PATCH 11/41] Update Python to 3.13 in docker image --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 34a96757..23efbc22 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:labs # 0. Prepare images -ARG PYTHON_VERSION="3.11" +ARG PYTHON_VERSION="3.13" ARG GO_VERSION="1.24" From bf45f64a7e3f9eaab0c977e2555c4541da240f72 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 26 May 2025 21:56:45 +0200 Subject: [PATCH 12/41] - refactor secrets - add support for env in config - redact sensitive information in logs/responses --- internal/api/api.go | 20 ++++- internal/app/secrets.go | 162 +++++++++-------------------------- internal/echo/echo.go | 2 +- internal/expr/expr.go | 3 +- internal/hass/hass.go | 3 +- internal/hls/hls.go | 7 +- internal/onvif/onvif.go | 5 +- internal/streams/dot.go | 4 +- internal/streams/handlers.go | 13 --- internal/streams/producer.go | 11 +-- internal/streams/streams.go | 3 +- pkg/core/helpers.go | 6 ++ pkg/core/listener.go | 6 -- pkg/shell/shell.go | 117 +++++++++++++++++++++++++ 14 files changed, 202 insertions(+), 160 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 419e2bdf..241bedf3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "crypto/tls" "encoding/json" "fmt" @@ -14,6 +15,7 @@ import ( "time" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/rs/zerolog" ) @@ -164,10 +166,20 @@ func ResponseJSON(w http.ResponseWriter, v any) { } func ResponsePrettyJSON(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", MimeJSON) - enc := json.NewEncoder(w) + w.Header().Set("Content-Type", "application/json") + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) enc.SetIndent("", " ") - _ = enc.Encode(v) + err := enc.Encode(v) + + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + redactedJSON := shell.Redact(buf.String()) + w.Write([]byte(redactedJSON)) } func Response(w http.ResponseWriter, body any, contentType string) { @@ -190,7 +202,7 @@ var log zerolog.Logger func middlewareLog(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Trace().Msgf("[api] %s %s %s", r.Method, r.URL, r.RemoteAddr) + log.Trace().Msgf("[api] %s %s %s", r.Method, shell.Redact(r.URL.String()), r.RemoteAddr) next.ServeHTTP(w, r) }) } diff --git a/internal/app/secrets.go b/internal/app/secrets.go index c3c4f95e..4735c27c 100644 --- a/internal/app/secrets.go +++ b/internal/app/secrets.go @@ -1,23 +1,19 @@ package app import ( - "fmt" - "regexp" - "strings" "sync" "github.com/AlexxIT/go2rtc/pkg/yaml" ) -var secrets = make(map[string]*Secret) -var secretsMu sync.Mutex - -var templateRegex = regexp.MustCompile(`\{\{\s*([^\}]+)\s*\}\}`) +var ( + secrets = make(map[string]*Secret) + secretsMu sync.Mutex +) type Secrets interface { Get(key string) any Set(key string, value any) - Parse(template string) string Marshal(v any) ([]byte, error) Unmarshal(v any) error Save() error @@ -27,37 +23,36 @@ type Secret struct { Secrets Name string - Values map[string]any + Values map[string]string } -func NewSecret(name string, values interface{}) *Secret { +func NewSecret(name string, values interface{}) (*Secret, error) { secretsMu.Lock() defer secretsMu.Unlock() if s, exists := secrets[name]; exists { - return s + return s, nil } - s := &Secret{Name: name, Values: make(map[string]any)} + s := &Secret{Name: name, Values: make(map[string]string)} - switch v := values.(type) { - case map[string]any: - s.Values = v - default: - data, err := yaml.Encode(values, 2) - if err == nil { - var mapValues map[string]any - if err := yaml.Unmarshal(data, &mapValues); err == nil { - s.Values = mapValues - } - } + data, err := yaml.Encode(values, 2) + if err != nil { + return nil, err + } + + if err := yaml.Unmarshal(data, &s.Values); err != nil { + return nil, err } secrets[name] = s - return s + + return s, nil } func GetSecret(name string) *Secret { + secretsMu.Lock() + defer secretsMu.Unlock() return secrets[name] } @@ -65,79 +60,50 @@ func (s *Secret) Get(key string) any { secretsMu.Lock() defer secretsMu.Unlock() + if s.Values == nil { + return nil + } + return s.Values[key] } -func (s *Secret) Set(key string, value any) { +func (s *Secret) Set(key string, value string) { secretsMu.Lock() defer secretsMu.Unlock() if s.Values == nil { - s.Values = make(map[string]any) + s.Values = make(map[string]string) } s.Values[key] = value - secrets[s.Name] = s } -func (s *Secret) Parse(template string) string { - if !templateRegex.MatchString(template) { - return template - } - - secretsMu.Lock() - defer secretsMu.Unlock() - - if _, exists := secrets[s.Name]; !exists { - return template - } - - result := templateRegex.ReplaceAllStringFunc(template, func(match string) string { - varName := strings.TrimSpace(templateRegex.FindStringSubmatch(match)[1]) - pathParts := strings.Split(varName, ".") - value := getNestedValue(s.Values, pathParts) - - if value != nil { - return stringify(value) - } - - return "" - }) - - return result -} - -func (s *Secret) Marshal(v any) ([]byte, error) { +func (s *Secret) Marshal() (interface{}, error) { secretsMu.Lock() defer secretsMu.Unlock() if s.Values == nil { - return nil, fmt.Errorf("no values in secret %s", s.Name) + return make(map[string]any), nil } - data, err := yaml.Encode(s.Values, 2) - if err != nil { - return nil, fmt.Errorf("error encoding secret values: %w", err) - } - - return data, nil + return s.Values, nil } -func (s *Secret) Unmarshal(v any) error { +func (s *Secret) Unmarshal(value any) error { secretsMu.Lock() defer secretsMu.Unlock() if s.Values == nil { - return fmt.Errorf("no values in secret %s", s.Name) + s.Values = make(map[string]string) } - data, err := yaml.Encode(s.Values, 2) + data, err := yaml.Encode(value, 2) if err != nil { - return fmt.Errorf("error encoding secret values: %w", err) + return err } - if err := yaml.Unmarshal(data, v); err != nil { - return fmt.Errorf("error unmarshaling secret values: %w", err) + if err := yaml.Unmarshal(data, value); err != nil { + return err } return nil @@ -151,17 +117,9 @@ func (s *Secret) Save() error { func initSecrets() { var cfg struct { - Secrets map[string]map[string]any `yaml:"secrets"` + Secrets map[string]map[string]string `yaml:"secrets"` } - /* - Example config: - secrets: - test_camera: - username: test - password: test - */ - LoadConfig(&cfg) if cfg.Secrets == nil { @@ -172,53 +130,13 @@ func initSecrets() { defer secretsMu.Unlock() for name, values := range cfg.Secrets { - secrets[name] = &Secret{Name: name, Values: values} - } -} - -func saveSecret(name string, secret map[string]any) error { - return PatchConfig([]string{"secrets", name}, secret) -} - -func getNestedValue(m map[string]any, path []string) interface{} { - if len(path) == 0 || m == nil { - return nil - } - - key := path[0] - value, exists := m[key] - if !exists { - return nil - } - - if len(path) == 1 { - return value - } - - // Check nested maps - switch nextMap := value.(type) { - case map[string]any: - return getNestedValue(nextMap, path[1:]) - case map[interface{}]interface{}: - stringMap := make(map[string]any) - for k, v := range nextMap { - if keyStr, ok := k.(string); ok { - stringMap[keyStr] = v - } + secrets[name] = &Secret{ + Name: name, + Values: values, } - return getNestedValue(stringMap, path[1:]) - default: - return nil } } -func stringify(value interface{}) string { - switch v := value.(type) { - case string: - return v - case int, int64, float64, bool: - return fmt.Sprintf("%v", v) - default: - return "" - } +func saveSecret(name string, secretValues map[string]string) error { + return PatchConfig([]string{"secrets", name}, secretValues) } diff --git a/internal/echo/echo.go b/internal/echo/echo.go index fb105cec..df88dd64 100644 --- a/internal/echo/echo.go +++ b/internal/echo/echo.go @@ -22,7 +22,7 @@ func Init() { b = bytes.TrimSpace(b) - log.Debug().Str("url", url).Msgf("[echo] %s", b) + log.Debug().Str("url", shell.Redact(url)).Msgf("[echo] %s", b) return string(b), nil }) diff --git a/internal/expr/expr.go b/internal/expr/expr.go index 8fd6c9c2..4d8aa5ce 100644 --- a/internal/expr/expr.go +++ b/internal/expr/expr.go @@ -6,6 +6,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/expr" + "github.com/AlexxIT/go2rtc/pkg/shell" ) func Init() { @@ -17,7 +18,7 @@ func Init() { return "", err } - log.Debug().Msgf("[expr] url=%s", url) + log.Debug().Msgf("[expr] url=%s", shell.Redact(url)) if url = v.(string); url == "" { return "", errors.New("expr: result is empty") diff --git a/internal/hass/hass.go b/internal/hass/hass.go index ea172b02..e2132ad6 100644 --- a/internal/hass/hass.go +++ b/internal/hass/hass.go @@ -15,6 +15,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/hass" + "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/rs/zerolog" ) @@ -177,7 +178,7 @@ func importConfig(config string) error { continue } - log.Debug().Str("url", "hass:"+entrie.Title).Msg("[hass] load config") + log.Debug().Str("url", "hass:"+shell.Redact(entrie.Title)).Msg("[hass] load config") //streams.Get("hass:" + entrie.Title) } diff --git a/internal/hls/hls.go b/internal/hls/hls.go index 5c136450..2344b62e 100644 --- a/internal/hls/hls.go +++ b/internal/hls/hls.go @@ -12,6 +12,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/mpegts" + "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/rs/zerolog" ) @@ -142,7 +143,7 @@ func handlerSegmentTS(w http.ResponseWriter, r *http.Request) { data := session.Segment() if data == nil { - log.Warn().Msgf("[hls] can't get segment %s", r.URL.RawQuery) + log.Warn().Msgf("[hls] can't get segment %s", shell.Redact(r.URL.RawQuery)) http.NotFound(w, r) return } @@ -172,7 +173,7 @@ func handlerInit(w http.ResponseWriter, r *http.Request) { data := session.Init() if data == nil { - log.Warn().Msgf("[hls] can't get init %s", r.URL.RawQuery) + log.Warn().Msgf("[hls] can't get init %s", shell.Redact(r.URL.RawQuery)) http.NotFound(w, r) return } @@ -206,7 +207,7 @@ func handlerSegmentMP4(w http.ResponseWriter, r *http.Request) { data := session.Segment() if data == nil { - log.Warn().Msgf("[hls] can't get segment %s", r.URL.RawQuery) + log.Warn().Msgf("[hls] can't get segment %s", shell.Redact(r.URL.RawQuery)) http.NotFound(w, r) return } diff --git a/internal/onvif/onvif.go b/internal/onvif/onvif.go index 6dfa633a..36b3843c 100644 --- a/internal/onvif/onvif.go +++ b/internal/onvif/onvif.go @@ -15,6 +15,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/onvif" + "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/rs/zerolog" ) @@ -165,12 +166,12 @@ func apiOnvif(w http.ResponseWriter, r *http.Request) { for _, rawURL := range urls { u, err := url.Parse(rawURL) if err != nil { - log.Warn().Str("url", rawURL).Msg("[onvif] broken") + log.Warn().Str("url", shell.Redact(rawURL)).Msg("[onvif] broken") continue } if u.Scheme != "http" { - log.Warn().Str("url", rawURL).Msg("[onvif] unsupported") + log.Warn().Str("url", shell.Redact(rawURL)).Msg("[onvif] unsupported") continue } diff --git a/internal/streams/dot.go b/internal/streams/dot.go index e0417972..2c357f77 100644 --- a/internal/streams/dot.go +++ b/internal/streams/dot.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/AlexxIT/go2rtc/pkg/shell" ) func AppendDOT(dot []byte, stream *Stream) []byte { @@ -166,7 +168,7 @@ func (c *conn) label() string { sb.WriteString("\nsource=" + c.Source) } if c.URL != "" { - sb.WriteString("\nurl=" + c.URL) + sb.WriteString("\nurl=" + shell.Redact(c.URL)) } if c.UserAgent != "" { sb.WriteString("\nuser_agent=" + c.UserAgent) diff --git a/internal/streams/handlers.go b/internal/streams/handlers.go index 991b69c9..3240abb5 100644 --- a/internal/streams/handlers.go +++ b/internal/streams/handlers.go @@ -4,7 +4,6 @@ import ( "errors" "strings" - "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/pkg/core" ) @@ -47,18 +46,6 @@ func GetProducer(url string) (core.Producer, error) { } if handler, ok := handlers[scheme]; ok { - index := strings.IndexByte(url, '#') - if index > 0 { - _, query := url[:index], ParseQuery(url[index+1:]) - secretsName := query.Get("secrets") - if secretsName != "" { - secrets := app.GetSecret(secretsName) - if secrets != nil { - url = secrets.Parse(url) - } - } - } - return handler(url) } } diff --git a/internal/streams/producer.go b/internal/streams/producer.go index 09e2dcc5..4260198e 100644 --- a/internal/streams/producer.go +++ b/internal/streams/producer.go @@ -8,6 +8,7 @@ import ( "time" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/shell" ) type state byte @@ -149,7 +150,7 @@ func (p *Producer) start() { return } - log.Debug().Msgf("[streams] start producer url=%s", p.url) + log.Debug().Msgf("[streams] start producer url=%s", shell.Redact(p.url)) p.state = stateStart p.workerID++ @@ -167,7 +168,7 @@ func (p *Producer) worker(conn core.Producer, workerID int) { return } - log.Warn().Err(err).Str("url", p.url).Caller().Send() + log.Warn().Err(err).Str("url", shell.Redact(p.url)).Caller().Send() } p.reconnect(workerID, 0) @@ -178,11 +179,11 @@ func (p *Producer) reconnect(workerID, retry int) { defer p.mu.Unlock() if p.workerID != workerID { - log.Trace().Msgf("[streams] stop reconnect url=%s", p.url) + log.Trace().Msgf("[streams] stop reconnect url=%s", shell.Redact(p.url)) return } - log.Debug().Msgf("[streams] retry=%d to url=%s", retry, p.url) + log.Debug().Msgf("[streams] retry=%d to url=%s", retry, shell.Redact(p.url)) conn, err := GetProducer(p.url) if err != nil { @@ -257,7 +258,7 @@ func (p *Producer) stop() { p.workerID++ } - log.Debug().Msgf("[streams] stop producer url=%s", p.url) + log.Debug().Msgf("[streams] stop producer url=%s", shell.Redact(p.url)) if p.conn != nil { _ = p.conn.Stop() diff --git a/internal/streams/streams.go b/internal/streams/streams.go index dcbaba28..8731ae68 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -9,6 +9,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/rs/zerolog" ) @@ -127,7 +128,7 @@ func GetOrPatch(query url.Values) *Stream { // check if name param provided if name := query.Get("name"); name != "" { - log.Info().Msgf("[streams] create new stream url=%s", source) + log.Info().Msgf("[streams] create new stream url=%s", shell.Redact(source)) return Patch(name, source) } diff --git a/pkg/core/helpers.go b/pkg/core/helpers.go index 72afe897..f39f53b4 100644 --- a/pkg/core/helpers.go +++ b/pkg/core/helpers.go @@ -6,6 +6,8 @@ import ( "strconv" "strings" "time" + + "github.com/AlexxIT/go2rtc/internal/app" ) const ( @@ -77,3 +79,7 @@ func Caller() string { _, file, line, _ := runtime.Caller(1) return file + ":" + strconv.Itoa(line) } + +func NewSecret(name string, defaultValues interface{}) (*app.Secret, error) { + return app.NewSecret(name, defaultValues) +} diff --git a/pkg/core/listener.go b/pkg/core/listener.go index 7a512741..75d9202a 100644 --- a/pkg/core/listener.go +++ b/pkg/core/listener.go @@ -1,7 +1,5 @@ package core -import "github.com/AlexxIT/go2rtc/internal/app" - type EventFunc func(msg any) // Listener base struct for all classes with support feedback @@ -18,7 +16,3 @@ func (l *Listener) Fire(msg any) { f(msg) } } - -func (l *Listener) NewSecret(name string, defaultValues interface{}) *app.Secret { - return app.NewSecret(name, defaultValues) -} diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index 75df671f..5388503a 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -1,12 +1,22 @@ package shell import ( + "fmt" "os" "os/signal" "path/filepath" "regexp" "strings" + "sync" "syscall" + + "github.com/AlexxIT/go2rtc/pkg/yaml" +) + +var ( + secretReplacer *strings.Replacer + secretValues map[string]bool // Tracker für alle bekannten Secret-Werte + secretMutex sync.RWMutex ) func QuoteSplit(s string) []string { @@ -40,6 +50,15 @@ func QuoteSplit(s string) []string { // ReplaceEnvVars - support format ${CAMERA_PASSWORD} and ${RTSP_USER:admin} func ReplaceEnvVars(text string) string { + var cfg struct { + Env map[string]string `yaml:"env"` + Secrets map[string]map[string]string `yaml:"secrets"` + } + + yaml.Unmarshal([]byte(text), &cfg) + + buildSecretReplacer(cfg) + re := regexp.MustCompile(`\${([^}{]+)}`) return re.ReplaceAllStringFunc(text, func(match string) string { key := match[2 : len(match)-1] @@ -63,6 +82,23 @@ func ReplaceEnvVars(text string) string { return value } + if cfg.Env != nil { + if value, ok := cfg.Env[key]; ok { + return value + } + } + + if cfg.Secrets != nil { + for secretName, secretValues := range cfg.Secrets { + for k, v := range secretValues { + name := fmt.Sprintf("%s_%s", secretName, k) + if key == name { + return v + } + } + } + } + if dok { return def } @@ -76,3 +112,84 @@ func RunUntilSignal() { signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) println("exit with signal:", (<-sigs).String()) } + +func Redact(text string) string { + secretMutex.RLock() + defer secretMutex.RUnlock() + + if secretReplacer == nil { + return text + } + + return secretReplacer.Replace(text) +} + +func buildSecretReplacer(cfg struct { + Env map[string]string `yaml:"env"` + Secrets map[string]map[string]string `yaml:"secrets"` +}) { + secretMutex.Lock() + defer secretMutex.Unlock() + + if secretValues == nil { + secretValues = make(map[string]bool) + } + + var newSecrets []string + + if dir, ok := os.LookupEnv("CREDENTIALS_DIRECTORY"); ok { + entries, err := os.ReadDir(dir) + if err == nil { + for _, entry := range entries { + if !entry.IsDir() { + value, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err == nil { + cleanValue := strings.TrimSpace(string(value)) + if len(cleanValue) > 0 && !secretValues[cleanValue] { + secretValues[cleanValue] = true + newSecrets = append(newSecrets, cleanValue) + } + } + } + } + } + } + + if cfg.Env != nil { + for _, value := range cfg.Env { + if len(value) > 0 && !secretValues[value] { + secretValues[value] = true + newSecrets = append(newSecrets, value) + } + } + } + + if cfg.Secrets != nil { + for _, secretMap := range cfg.Secrets { + for _, value := range secretMap { + if len(value) > 0 && !secretValues[value] { + secretValues[value] = true + newSecrets = append(newSecrets, value) + } + } + } + } + + if len(newSecrets) > 0 { + rebuildReplacer() + } +} + +func rebuildReplacer() { + var replacements []string + + for secret := range secretValues { + replacements = append(replacements, secret, "*****") + } + + if len(replacements) > 0 { + secretReplacer = strings.NewReplacer(replacements...) + } else { + secretReplacer = nil + } +} \ No newline at end of file From 7c17e64090ff6ceec8fd8165d671ace6eccfd393 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 26 May 2025 22:21:33 +0200 Subject: [PATCH 13/41] format --- internal/api/api.go | 6 +++--- pkg/shell/shell.go | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 241bedf3..f66f59f4 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -167,17 +167,17 @@ func ResponseJSON(w http.ResponseWriter, v any) { func ResponsePrettyJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") - + var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetIndent("", " ") err := enc.Encode(v) - + if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - + redactedJSON := shell.Redact(buf.String()) w.Write([]byte(redactedJSON)) } diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index 5388503a..c478b435 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -116,11 +116,11 @@ func RunUntilSignal() { func Redact(text string) string { secretMutex.RLock() defer secretMutex.RUnlock() - + if secretReplacer == nil { return text } - + return secretReplacer.Replace(text) } @@ -130,13 +130,13 @@ func buildSecretReplacer(cfg struct { }) { secretMutex.Lock() defer secretMutex.Unlock() - + if secretValues == nil { secretValues = make(map[string]bool) } - + var newSecrets []string - + if dir, ok := os.LookupEnv("CREDENTIALS_DIRECTORY"); ok { entries, err := os.ReadDir(dir) if err == nil { @@ -154,7 +154,7 @@ func buildSecretReplacer(cfg struct { } } } - + if cfg.Env != nil { for _, value := range cfg.Env { if len(value) > 0 && !secretValues[value] { @@ -163,7 +163,7 @@ func buildSecretReplacer(cfg struct { } } } - + if cfg.Secrets != nil { for _, secretMap := range cfg.Secrets { for _, value := range secretMap { @@ -174,7 +174,7 @@ func buildSecretReplacer(cfg struct { } } } - + if len(newSecrets) > 0 { rebuildReplacer() } @@ -182,14 +182,14 @@ func buildSecretReplacer(cfg struct { func rebuildReplacer() { var replacements []string - + for secret := range secretValues { replacements = append(replacements, secret, "*****") } - + if len(replacements) > 0 { secretReplacer = strings.NewReplacer(replacements...) } else { secretReplacer = nil } -} \ No newline at end of file +} From 759f979182d248f14ccd7d2350817cc61471eb50 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 26 May 2025 22:23:24 +0200 Subject: [PATCH 14/41] dont redact config.env values --- pkg/shell/shell.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index c478b435..49fb432a 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -155,15 +155,6 @@ func buildSecretReplacer(cfg struct { } } - if cfg.Env != nil { - for _, value := range cfg.Env { - if len(value) > 0 && !secretValues[value] { - secretValues[value] = true - newSecrets = append(newSecrets, value) - } - } - } - if cfg.Secrets != nil { for _, secretMap := range cfg.Secrets { for _, value := range secretMap { From 859cd1cbe63cf7acb445ce27bde47b5d72a748a6 Mon Sep 17 00:00:00 2001 From: seydx Date: Sun, 1 Jun 2025 01:44:01 +0300 Subject: [PATCH 15/41] support rtsp udp transport --- README.md | 2 + pkg/rtsp/client.go | 289 +++++++++++++++++++++++++++++--- pkg/rtsp/conn.go | 391 ++++++++++++++++++++++++++++++------------- pkg/rtsp/consumer.go | 19 ++- pkg/rtsp/ports.go | 75 +++++++++ 5 files changed, 626 insertions(+), 150 deletions(-) create mode 100644 pkg/rtsp/ports.go diff --git a/README.md b/README.md index 90a2537f..d1461206 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,7 @@ Format: `rtsp...#{param1}#{param2}#{param3}` - Ignore audio - `#media=video` or ignore video - `#media=audio` - Ignore two way audio API `#backchannel=0` - important for some glitchy cameras - Use WebSocket transport `#transport=ws...` +- Use UDP transport `#transport=udp` **RTSP over WebSocket** @@ -268,6 +269,7 @@ streams: axis-rtsp-ws: rtsp://192.168.1.123:4567/axis-media/media.amp?overview=0&camera=1&resolution=1280x720&videoframeskipmode=empty&Axis-Orig-Sw=true#transport=ws://user:pass@192.168.1.123:4567/rtsp-over-websocket # WebSocket without authorization, RTSP - with dahua-rtsp-ws: rtsp://user:pass@192.168.1.123/cam/realmonitor?channel=1&subtype=1&proto=Private3#transport=ws://192.168.1.123/rtspoverwebsocket + udp_camera: rtsp://user:pass@192.168.1.345:554/stream1#transport=udp ``` #### Source: RTMP diff --git a/pkg/rtsp/client.go b/pkg/rtsp/client.go index 7fc134fc..ef5ebbfe 100644 --- a/pkg/rtsp/client.go +++ b/pkg/rtsp/client.go @@ -2,6 +2,7 @@ package rtsp import ( "bufio" + "encoding/binary" "errors" "fmt" "net" @@ -25,7 +26,13 @@ func NewClient(uri string) *Conn { ID: core.NewID(), FormatName: "rtsp", }, - uri: uri, + uri: uri, + udpRtpConns: make(map[byte]*UDPConnection), + udpRtcpConns: make(map[byte]*UDPConnection), + udpRtpListeners: make(map[byte]*UDPConnection), + udpRtcpListeners: make(map[byte]*UDPConnection), + portToChannel: make(map[int]byte), + channelCounter: 0, } } @@ -36,13 +43,20 @@ func (c *Conn) Dial() (err error) { var conn net.Conn - if c.Transport == "" { + if c.Transport == "" || c.Transport == "tcp" || c.Transport == "udp" { timeout := core.ConnDialTimeout if c.Timeout != 0 { timeout = time.Second * time.Duration(c.Timeout) } conn, err = tcp.Dial(c.URL, timeout) - c.Protocol = "rtsp+tcp" + + if c.Transport != "udp" { + c.Protocol = "rtsp+tcp" + c.transportMode = TransportTCP + } else { + c.Protocol = "rtsp+udp" + c.transportMode = TransportUDP + } } else { conn, err = websocket.Dial(c.Transport) c.Protocol = "ws" @@ -217,23 +231,64 @@ func (c *Conn) Record() (err error) { func (c *Conn) SetupMedia(media *core.Media) (byte, error) { var transport string + var mediaIndex int = -1 // try to use media position as channel number for i, m := range c.Medias { if m.Equal(media) { - transport = fmt.Sprintf( - // i - RTP (data channel) - // i+1 - RTCP (control channel) - "RTP/AVP/TCP;unicast;interleaved=%d-%d", i*2, i*2+1, - ) + mediaIndex = i break } } - if transport == "" { + if mediaIndex == -1 { return 0, fmt.Errorf("wrong media: %v", media) } + if c.transportMode == TransportUDP { + transport, err := c.setupUDPTransport() + if err == nil { + return c.sendSetupRequest(media, transport) + } + // Fall back to TCP if UDP fails + c.closeUDP() + c.transportMode = TransportTCP + } + + transport = c.setupTCPTransport(mediaIndex) + return c.sendSetupRequest(media, transport) +} + +func (c *Conn) setupTCPTransport(mediaIndex int) string { + channel := byte(mediaIndex * 2) + transport := fmt.Sprintf("RTP/AVP/TCP;unicast;interleaved=%d-%d", channel, channel+1) + return transport +} + +func (c *Conn) setupUDPTransport() (string, error) { + portPair, err := GetUDPPorts(nil, 10) + if err != nil { + return "", err + } + + rtpChannel := c.getChannelForPort(portPair.RTPPort) + rtcpChannel := c.getChannelForPort(portPair.RTCPPort) + + c.udpRtpListeners[rtpChannel] = &UDPConnection{ + Conn: *portPair.RTPListener, + Channel: rtpChannel, + } + + c.udpRtcpListeners[rtcpChannel] = &UDPConnection{ + Conn: *portPair.RTCPListener, + Channel: rtcpChannel, + } + + transport := fmt.Sprintf("RTP/AVP;unicast;client_port=%d-%d", portPair.RTPPort, portPair.RTCPPort) + return transport, nil +} + +func (c *Conn) sendSetupRequest(media *core.Media, transport string) (byte, error) { rawURL := media.ID // control if !strings.Contains(rawURL, "://") { rawURL = c.URL.String() @@ -286,27 +341,114 @@ func (c *Conn) SetupMedia(media *core.Media) (byte, error) { } } - // we send our `interleaved`, but camera can answer with another + // Parse server response + responseTransport := res.Header.Get("Transport") - // Transport: RTP/AVP/TCP;unicast;interleaved=10-11;ssrc=10117CB7 - // Transport: RTP/AVP/TCP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0 - // Transport: RTP/AVP/TCP;ssrc=22345682;interleaved=0-1 - transport = res.Header.Get("Transport") - if !strings.HasPrefix(transport, "RTP/AVP/TCP;") { - // Escam Q6 has a bug: - // Transport: RTP/AVP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0-1 - if !strings.Contains(transport, ";interleaved=") { - return 0, fmt.Errorf("wrong transport: %s", transport) + if c.transportMode == TransportUDP { + // Parse UDP response: client_ports=1234-1235;server_port=1234-1235 + var clientPorts []int + var serverPorts []int + + if strings.Contains(transport, "client_port=") { + parts := strings.Split(responseTransport, "client_port=") + if len(parts) > 1 { + portPart := strings.Split(strings.Split(parts[1], ";")[0], "-") + for _, p := range portPart { + if port, err := strconv.Atoi(p); err == nil { + clientPorts = append(clientPorts, port) + } + } + } } - } - channel := core.Between(transport, "interleaved=", "-") - i, err := strconv.Atoi(channel) - if err != nil { - return 0, err - } + if strings.Contains(responseTransport, "server_port=") { + parts := strings.Split(responseTransport, "server_port=") + if len(parts) > 1 { + portPart := strings.Split(strings.Split(parts[1], ";")[0], "-") + for _, p := range portPart { + if port, err := strconv.Atoi(p); err == nil { + serverPorts = append(serverPorts, port) + } + } + } + } - return byte(i), nil + // Create UDP connections for RTP and RTCP if we have both server ports + if len(serverPorts) >= 2 { + if host, _, err := net.SplitHostPort(c.Connection.RemoteAddr); err == nil { + rtpServerPort := serverPorts[0] + rtcpServerPort := serverPorts[1] + + cleanHost := host + if strings.Contains(cleanHost, ":") { + cleanHost = fmt.Sprintf("[%s]", host) + } + + remoteRtpAddr := fmt.Sprintf("%s:%d", cleanHost, rtpServerPort) + remoteRtcpAddr := fmt.Sprintf("%s:%d", cleanHost, rtcpServerPort) + + if rtpAddr, err := net.ResolveUDPAddr("udp", remoteRtpAddr); err == nil { + if rtpConn, err := net.DialUDP("udp", nil, rtpAddr); err == nil { + channel := c.getChannelForPort(rtpServerPort) + c.udpRtpConns[channel] = &UDPConnection{ + Conn: *rtpConn, + Channel: channel, + } + } + } + + if rtcpAddr, err := net.ResolveUDPAddr("udp", remoteRtcpAddr); err == nil { + if rtcpConn, err := net.DialUDP("udp", nil, rtcpAddr); err == nil { + channel := c.getChannelForPort(rtcpServerPort) + c.udpRtcpConns[channel] = &UDPConnection{ + Conn: *rtcpConn, + Channel: channel, + } + } + } + } + } + + // Try to open a hole in the NAT router (to allow incoming UDP packets) + // by send a UDP packet for RTP and RTCP to the remote RTSP server. + go c.tryHolePunching(clientPorts, serverPorts) + + var rtpPort string + if media.Direction == core.DirectionRecvonly { + rtpPort = core.Between(transport, "client_port=", "-") + } else { + rtpPort = core.Between(responseTransport, "server_port=", "-") + } + + i, err := strconv.Atoi(rtpPort) + if err != nil { + return 0, err + } + + return c.getChannelForPort(i), nil + + } else { + // we send our `interleaved`, but camera can answer with another + + // Transport: RTP/AVP/TCP;unicast;interleaved=10-11;ssrc=10117CB7 + // Transport: RTP/AVP/TCP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0 + // Transport: RTP/AVP/TCP;ssrc=22345682;interleaved=0-1 + if !strings.HasPrefix(responseTransport, "RTP/AVP/TCP;") { + // Escam Q6 has a bug: + // Transport: RTP/AVP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0-1 + if !strings.Contains(responseTransport, ";interleaved=") { + return 0, fmt.Errorf("wrong transport: %s", responseTransport) + } + } + + channel := core.Between(responseTransport, "interleaved=", "-") + i, err := strconv.Atoi(channel) + if err != nil { + return 0, err + } + + return byte(i), nil + } } func (c *Conn) Play() (err error) { @@ -321,11 +463,106 @@ func (c *Conn) Teardown() (err error) { } func (c *Conn) Close() error { + c.closeUDP() + if c.mode == core.ModeActiveProducer { _ = c.Teardown() } + if c.OnClose != nil { _ = c.OnClose() } + return c.conn.Close() } + +func (c *Conn) closeUDP() { + for _, listener := range c.udpRtpListeners { + _ = listener.Conn.Close() + } + for _, listener := range c.udpRtcpListeners { + _ = listener.Conn.Close() + } + for _, conn := range c.udpRtpConns { + _ = conn.Conn.Close() + } + for _, conn := range c.udpRtcpConns { + _ = conn.Conn.Close() + } + + c.udpRtpListeners = make(map[byte]*UDPConnection) + c.udpRtcpListeners = make(map[byte]*UDPConnection) + c.udpRtpConns = make(map[byte]*UDPConnection) + c.udpRtcpConns = make(map[byte]*UDPConnection) + c.portToChannel = make(map[int]byte) + c.channelCounter = 0 +} + +func (c *Conn) sendUDPRtpPacket(data []byte) error { + for len(data) >= 4 && data[0] == '$' { + channel := data[1] + size := binary.BigEndian.Uint16(data[2:4]) + + if len(data) < 4+int(size) { + return fmt.Errorf("incomplete RTP packet: %d < %d", len(data), 4+size) + } + + // Send RTP data without interleaved header + rtpData := data[4 : 4+size] + + if conn, ok := c.udpRtpConns[channel]; ok { + if err := conn.Conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { + return nil + } + + if _, err := conn.Conn.Write(rtpData); err != nil { + return err + } + } + + data = data[4+size:] // Move to next packet + } + + return nil +} + +func (c *Conn) tryHolePunching(clientPorts, serverPorts []int) { + if len(clientPorts) < 2 || len(serverPorts) < 2 { + return + } + + host, _, _ := net.SplitHostPort(c.Connection.RemoteAddr) + if strings.Contains(host, ":") { + host = fmt.Sprintf("[%s]", host) + } + + // RTP hole punch + if rtpListener, ok := c.udpRtpListeners[c.getChannelForPort(clientPorts[0])]; ok { + if addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, serverPorts[0])); err == nil { + rtpListener.Conn.WriteToUDP([]byte{0x80, 0x00, 0x00, 0x00}, addr) + } + } + + // RTCP hole punch + if rtcpListener, ok := c.udpRtcpListeners[c.getChannelForPort(clientPorts[1])]; ok { + if addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, serverPorts[1])); err == nil { + rtcpListener.Conn.WriteToUDP([]byte{0x80, 0xC8, 0x00, 0x01}, addr) + } + } +} + +func (c *Conn) getChannelForPort(port int) byte { + if channel, exists := c.portToChannel[port]; exists { + return channel + } + + c.channelCounter++ + if c.channelCounter == 0 { + c.channelCounter = 1 + } + + channel := c.channelCounter + c.portToChannel[port] = channel + + return channel +} diff --git a/pkg/rtsp/conn.go b/pkg/rtsp/conn.go index 0c2009d7..a5f001c2 100644 --- a/pkg/rtsp/conn.go +++ b/pkg/rtsp/conn.go @@ -40,6 +40,7 @@ type Conn struct { keepalive int mode core.Mode playOK bool + playErr error reader *bufio.Reader sequence int session string @@ -47,8 +48,32 @@ type Conn struct { state State stateMu sync.Mutex + + transportMode TransportMode + + // UDP + + udpRtpConns map[byte]*UDPConnection + udpRtcpConns map[byte]*UDPConnection + udpRtpListeners map[byte]*UDPConnection + udpRtcpListeners map[byte]*UDPConnection + portToChannel map[int]byte + channelCounter byte } +type UDPConnection struct { + Conn net.UDPConn + Channel byte +} + +type TransportMode int + +const ( + TransportTCP TransportMode = iota + TransportUDP + ReceiveMTU = 1500 +) + const ( ProtoRTSP = "RTSP/1.0" MethodOptions = "OPTIONS" @@ -68,7 +93,6 @@ func (s State) String() string { case StateNone: return "NONE" case StateConn: - return "CONN" case StateSetup: return MethodSetup @@ -131,133 +155,22 @@ func (c *Conn) Handle() (err error) { for c.state != StateNone { ts := time.Now() + time := ts.Add(timeout) - if err = c.conn.SetReadDeadline(ts.Add(timeout)); err != nil { + if err = c.conn.SetReadDeadline(time); err != nil { return } - // we can read: - // 1. RTP interleaved: `$` + 1B channel number + 2B size - // 2. RTSP response: RTSP/1.0 200 OK - // 3. RTSP request: OPTIONS ... - var buf4 []byte // `$` + 1B channel number + 2B size - buf4, err = c.reader.Peek(4) - if err != nil { - return - } - - var channelID byte - var size uint16 - - if buf4[0] != '$' { - switch string(buf4) { - case "RTSP": - var res *tcp.Response - if res, err = c.ReadResponse(); err != nil { - return - } - c.Fire(res) - // for playing backchannel only after OK response on play - c.playOK = true - continue - - case "OPTI", "TEAR", "DESC", "SETU", "PLAY", "PAUS", "RECO", "ANNO", "GET_", "SET_": - var req *tcp.Request - if req, err = c.ReadRequest(); err != nil { - return - } - c.Fire(req) - if req.Method == MethodOptions { - res := &tcp.Response{Request: req} - if err = c.WriteResponse(res); err != nil { - return - } - } - continue - - default: - c.Fire("RTSP wrong input") - - for i := 0; ; i++ { - // search next start symbol - if _, err = c.reader.ReadBytes('$'); err != nil { - return err - } - - if channelID, err = c.reader.ReadByte(); err != nil { - return err - } - - // TODO: better check maximum good channel ID - if channelID >= 20 { - continue - } - - buf4 = make([]byte, 2) - if _, err = io.ReadFull(c.reader, buf4); err != nil { - return err - } - - // check if size good for RTP - size = binary.BigEndian.Uint16(buf4) - if size <= 1500 { - break - } - - // 10 tries to find good packet - if i >= 10 { - return fmt.Errorf("RTSP wrong input") - } - } + if c.transportMode == TransportUDP { + if err = c.handleUDPClientData(time); err != nil { + return err } } else { - // hope that the odd channels are always RTCP - channelID = buf4[1] - - // get data size - size = binary.BigEndian.Uint16(buf4[2:]) - - // skip 4 bytes from c.reader.Peek - if _, err = c.reader.Discard(4); err != nil { - return + if err = c.handleTCPClientData(); err != nil { + return err } } - // init memory for data - buf := make([]byte, size) - if _, err = io.ReadFull(c.reader, buf); err != nil { - return - } - - c.Recv += int(size) - - if channelID&1 == 0 { - packet := &rtp.Packet{} - if err = packet.Unmarshal(buf); err != nil { - return - } - - for _, receiver := range c.Receivers { - if receiver.ID == channelID { - receiver.WriteRTP(packet) - break - } - } - } else { - msg := &RTCP{Channel: channelID} - - if err = msg.Header.Unmarshal(buf); err != nil { - continue - } - - msg.Packets, err = rtcp.Unmarshal(buf) - if err != nil { - continue - } - - c.Fire(msg) - } - if keepaliveDT != 0 && ts.After(keepaliveTS) { req := &tcp.Request{Method: MethodOptions, URL: c.URL} if err = c.WriteRequest(req); err != nil { @@ -271,6 +184,246 @@ func (c *Conn) Handle() (err error) { return } +func (c *Conn) handleUDPClientData(time time.Time) error { + if c.playErr != nil { + return c.playErr + } + + if c.state == StatePlay && c.playOK { + return nil + } + + var buf4 []byte + + buf4, err := c.reader.Peek(4) + if err != nil { + return err + } + + switch string(buf4) { + case "RTSP": + var res *tcp.Response + if res, err = c.ReadResponse(); err != nil { + return err + } + + c.Fire(res) + c.playOK = true + + for _, listener := range c.udpRtpListeners { + go func(listener *UDPConnection) { + defer listener.Conn.Close() + + for c.state != StateNone { + if err := listener.Conn.SetReadDeadline(time); err != nil { + c.playErr = err + return + } + + buffer := make([]byte, ReceiveMTU) + n, _, err := listener.Conn.ReadFromUDP(buffer) + if err != nil { + c.playErr = err + break + } + + packet := &rtp.Packet{} + if err := packet.Unmarshal(buffer[:n]); err != nil { + c.playErr = err + return + } + + for _, receiver := range c.Receivers { + if receiver.ID == listener.Channel { + receiver.WriteRTP(packet) + break + } + } + + c.Recv += len(buffer[:n]) + } + }(listener) + } + + for _, listener := range c.udpRtcpListeners { + go func(listener *UDPConnection) { + defer listener.Conn.Close() + + for c.state != StateNone { + if err := listener.Conn.SetReadDeadline(time); err != nil { + return + } + + buffer := make([]byte, ReceiveMTU) + n, _, err := listener.Conn.ReadFromUDP(buffer) + if err != nil { + break + } + + msg := &RTCP{Channel: listener.Channel} + + if err := msg.Header.Unmarshal(buffer[:n]); err != nil { + continue + } + + msg.Packets, err = rtcp.Unmarshal(buffer[:n]) + if err != nil { + continue + } + + c.Fire(msg) + } + }(listener) + } + + case "OPTI", "TEAR", "DESC", "SETU", "PLAY", "PAUS", "RECO", "ANNO", "GET_", "SET_": + var req *tcp.Request + if req, err = c.ReadRequest(); err != nil { + return err + } + c.Fire(req) + if req.Method == MethodOptions { + res := &tcp.Response{Request: req} + if err = c.WriteResponse(res); err != nil { + return err + } + } + + default: + return fmt.Errorf("RTSP wrong input") + } + + return nil +} + +func (c *Conn) handleTCPClientData() error { + // we can read: + // 1. RTP interleaved: `$` + 1B channel number + 2B size + // 2. RTSP response: RTSP/1.0 200 OK + // 3. RTSP request: OPTIONS ... + var buf4 []byte // `$` + 1B channel number + 2B size + var err error + + buf4, err = c.reader.Peek(4) + if err != nil { + return err + } + + var channel byte + var size uint16 + + if buf4[0] != '$' { + switch string(buf4) { + case "RTSP": + var res *tcp.Response + if res, err = c.ReadResponse(); err != nil { + return err + } + c.Fire(res) + // for playing backchannel only after OK response on play + c.playOK = true + return nil + + case "OPTI", "TEAR", "DESC", "SETU", "PLAY", "PAUS", "RECO", "ANNO", "GET_", "SET_": + var req *tcp.Request + if req, err = c.ReadRequest(); err != nil { + return err + } + c.Fire(req) + if req.Method == MethodOptions { + res := &tcp.Response{Request: req} + if err = c.WriteResponse(res); err != nil { + return err + } + } + return nil + + default: + c.Fire("RTSP wrong input") + + for i := 0; ; i++ { + // search next start symbol + if _, err = c.reader.ReadBytes('$'); err != nil { + return err + } + + if channel, err = c.reader.ReadByte(); err != nil { + return err + } + + // TODO: better check maximum good channel ID + if channel >= 20 { + continue + } + + buf4 = make([]byte, 2) + if _, err = io.ReadFull(c.reader, buf4); err != nil { + return err + } + + // check if size good for RTP + size = binary.BigEndian.Uint16(buf4) + if size <= 1500 { + break + } + + // 10 tries to find good packet + if i >= 10 { + return fmt.Errorf("RTSP wrong input") + } + } + } + } else { + // hope that the odd channels are always RTCP + channel = buf4[1] + + // get data size + size = binary.BigEndian.Uint16(buf4[2:]) + + // skip 4 bytes from c.reader.Peek + if _, err = c.reader.Discard(4); err != nil { + return err + } + } + + // init memory for data + buf := make([]byte, size) + if _, err = io.ReadFull(c.reader, buf); err != nil { + return err + } + + c.Recv += int(size) + + if channel&1 == 0 { + packet := &rtp.Packet{} + if err = packet.Unmarshal(buf); err != nil { + return err + } + + for _, receiver := range c.Receivers { + if receiver.ID == channel { + receiver.WriteRTP(packet) + break + } + } + } else { + msg := &RTCP{Channel: channel} + + if err = msg.Header.Unmarshal(buf); err != nil { + return nil + } + + msg.Packets, err = rtcp.Unmarshal(buf) + if err != nil { + return nil + } + + c.Fire(msg) + } + + return nil +} + func (c *Conn) WriteRequest(req *tcp.Request) error { if req.Proto == "" { req.Proto = ProtoRTSP diff --git a/pkg/rtsp/consumer.go b/pkg/rtsp/consumer.go index 860ed113..b5827436 100644 --- a/pkg/rtsp/consumer.go +++ b/pkg/rtsp/consumer.go @@ -85,13 +85,22 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core. } flushBuf := func() { - if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { - return - } //log.Printf("[rtsp] channel:%2d write_size:%6d buffer_size:%6d", channel, n, len(buf)) - if _, err := c.conn.Write(buf[:n]); err == nil { - c.Send += n + + if c.transportMode == TransportUDP { + if err := c.sendUDPRtpPacket(buf[:n]); err == nil { + c.Send += n + } + } else { + if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { + return + } + + if _, err := c.conn.Write(buf[:n]); err == nil { + c.Send += n + } } + n = 0 } diff --git a/pkg/rtsp/ports.go b/pkg/rtsp/ports.go new file mode 100644 index 00000000..d280ac6d --- /dev/null +++ b/pkg/rtsp/ports.go @@ -0,0 +1,75 @@ +package rtsp + +import ( + "fmt" + "net" + "sync" +) + +var mu sync.Mutex + +type UDPPortPair struct { + RTPListener *net.UDPConn + RTCPListener *net.UDPConn + RTPPort int + RTCPPort int +} + +func (p *UDPPortPair) Close() { + if p.RTPListener != nil { + _ = p.RTPListener.Close() + } + if p.RTCPListener != nil { + _ = p.RTCPListener.Close() + } +} + +func GetUDPPorts(ip net.IP, maxAttempts int) (*UDPPortPair, error) { + mu.Lock() + defer mu.Unlock() + + if ip == nil { + ip = net.IPv4(0, 0, 0, 0) + } + + for i := 0; i < maxAttempts; i++ { + // Get a random even port from the OS + tempListener, err := net.ListenUDP("udp", &net.UDPAddr{IP: ip, Port: 0}) + if err != nil { + continue + } + + addr := tempListener.LocalAddr().(*net.UDPAddr) + basePort := addr.Port + tempListener.Close() + + // 11. RTP over Network and Transport Protocols (https://www.ietf.org/rfc/rfc3550.txt) + // For UDP and similar protocols, + // RTP SHOULD use an even destination port number and the corresponding + // RTCP stream SHOULD use the next higher (odd) destination port number + if basePort%2 == 1 { + basePort-- + } + + // Try to bind both ports + rtpListener, err := net.ListenUDP("udp", &net.UDPAddr{IP: ip, Port: basePort}) + if err != nil { + continue + } + + rtcpListener, err := net.ListenUDP("udp", &net.UDPAddr{IP: ip, Port: basePort + 1}) + if err != nil { + rtpListener.Close() + continue + } + + return &UDPPortPair{ + RTPListener: rtpListener, + RTCPListener: rtcpListener, + RTPPort: basePort, + RTCPPort: basePort + 1, + }, nil + } + + return nil, fmt.Errorf("failed to allocate consecutive UDP ports after %d attempts", maxAttempts) +} From 24ca87e00d4d5be424de97ebd7a1f1eead8adb87 Mon Sep 17 00:00:00 2001 From: seydx Date: Sun, 1 Jun 2025 18:40:53 +0300 Subject: [PATCH 16/41] dont fallback to tcp if udp failes --- pkg/rtsp/client.go | 15 ++++++--------- pkg/rtsp/conn.go | 6 +----- pkg/rtsp/consumer.go | 2 +- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/pkg/rtsp/client.go b/pkg/rtsp/client.go index ef5ebbfe..3d1bb0df 100644 --- a/pkg/rtsp/client.go +++ b/pkg/rtsp/client.go @@ -52,10 +52,8 @@ func (c *Conn) Dial() (err error) { if c.Transport != "udp" { c.Protocol = "rtsp+tcp" - c.transportMode = TransportTCP } else { c.Protocol = "rtsp+udp" - c.transportMode = TransportUDP } } else { conn, err = websocket.Dial(c.Transport) @@ -245,14 +243,13 @@ func (c *Conn) SetupMedia(media *core.Media) (byte, error) { return 0, fmt.Errorf("wrong media: %v", media) } - if c.transportMode == TransportUDP { + if c.Transport == "udp" { transport, err := c.setupUDPTransport() - if err == nil { - return c.sendSetupRequest(media, transport) + if err != nil { + return 0, err } - // Fall back to TCP if UDP fails - c.closeUDP() - c.transportMode = TransportTCP + + return c.sendSetupRequest(media, transport) } transport = c.setupTCPTransport(mediaIndex) @@ -344,7 +341,7 @@ func (c *Conn) sendSetupRequest(media *core.Media, transport string) (byte, erro // Parse server response responseTransport := res.Header.Get("Transport") - if c.transportMode == TransportUDP { + if c.Transport == "udp" { // Parse UDP response: client_ports=1234-1235;server_port=1234-1235 var clientPorts []int var serverPorts []int diff --git a/pkg/rtsp/conn.go b/pkg/rtsp/conn.go index a5f001c2..ddb15a74 100644 --- a/pkg/rtsp/conn.go +++ b/pkg/rtsp/conn.go @@ -49,8 +49,6 @@ type Conn struct { state State stateMu sync.Mutex - transportMode TransportMode - // UDP udpRtpConns map[byte]*UDPConnection @@ -69,8 +67,6 @@ type UDPConnection struct { type TransportMode int const ( - TransportTCP TransportMode = iota - TransportUDP ReceiveMTU = 1500 ) @@ -161,7 +157,7 @@ func (c *Conn) Handle() (err error) { return } - if c.transportMode == TransportUDP { + if c.Transport == "udp" { if err = c.handleUDPClientData(time); err != nil { return err } diff --git a/pkg/rtsp/consumer.go b/pkg/rtsp/consumer.go index b5827436..fde2684c 100644 --- a/pkg/rtsp/consumer.go +++ b/pkg/rtsp/consumer.go @@ -87,7 +87,7 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core. flushBuf := func() { //log.Printf("[rtsp] channel:%2d write_size:%6d buffer_size:%6d", channel, n, len(buf)) - if c.transportMode == TransportUDP { + if c.Transport == "udp" { if err := c.sendUDPRtpPacket(buf[:n]); err == nil { c.Send += n } From 641e65ee953080be4b332bc52bbe84ac4dd6af17 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Mon, 2 Jun 2025 12:55:20 -0300 Subject: [PATCH 17/41] Fix docker build and push job when running from a fork --- .github/workflows/build.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7950004d..390dd5c3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -124,7 +124,7 @@ jobs: uses: docker/metadata-action@v5 with: images: | - ${{ github.repository }} + name=${{ github.repository }},enable=${{ github.event.repository.fork == false }} ghcr.io/${{ github.repository }} tags: | type=ref,event=branch @@ -138,14 +138,14 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to DockerHub - if: github.event_name != 'pull_request' + if: github.event_name == 'push' && github.event.repository.fork == false uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - if: github.event_name != 'pull_request' + if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io @@ -181,7 +181,7 @@ jobs: uses: docker/metadata-action@v5 with: images: | - ${{ github.repository }} + name=${{ github.repository }},enable=${{ github.event.repository.fork == false }} ghcr.io/${{ github.repository }} flavor: | suffix=-hardware,onlatest=true @@ -198,14 +198,14 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to DockerHub - if: github.event_name != 'pull_request' + if: github.event_name == 'push' && github.event.repository.fork == false uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - + - name: Login to GitHub Container Registry - if: github.event_name != 'pull_request' + if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io @@ -236,7 +236,7 @@ jobs: uses: docker/metadata-action@v5 with: images: | - ${{ github.repository }} + name=${{ github.repository }},enable=${{ github.event.repository.fork == false }} ghcr.io/${{ github.repository }} flavor: | suffix=-rockchip,onlatest=true @@ -253,14 +253,14 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to DockerHub - if: github.event_name != 'pull_request' + if: github.event_name == 'push' && github.event.repository.fork == false uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - if: github.event_name != 'pull_request' + if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io From 42a67f8ad5ecb684b2d530c2b884a299604d6588 Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 6 Jun 2025 02:18:00 +0200 Subject: [PATCH 18/41] comments --- pkg/shell/shell.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index 49fb432a..64931a91 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -15,7 +15,7 @@ import ( var ( secretReplacer *strings.Replacer - secretValues map[string]bool // Tracker für alle bekannten Secret-Werte + secretValues map[string]bool secretMutex sync.RWMutex ) From c68e3cafe4e385270080c1b0ede0132bdd79657a Mon Sep 17 00:00:00 2001 From: Oliver Eiber Date: Thu, 3 Jul 2025 23:35:58 +0200 Subject: [PATCH 19/41] fixes doorbird backchannel audio: - proper session handling - honor http status codes - prevent device from being flooded by limiting concurrent audio channels --- pkg/doorbird/backchannel.go | 64 +++++++++++++++++++++++++++----- pkg/doorbird/backchannel_lock.go | 5 +++ 2 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 pkg/doorbird/backchannel_lock.go diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go index 82379383..82ea31b4 100644 --- a/pkg/doorbird/backchannel.go +++ b/pkg/doorbird/backchannel.go @@ -1,21 +1,32 @@ package doorbird import ( + "bufio" "fmt" "net" "net/url" + "strconv" + "strings" "time" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/pion/rtp" ) +var ( + clt Client +) + type Client struct { core.Connection conn net.Conn } func Dial(rawURL string) (*Client, error) { + if clt.conn != nil { + return &clt, nil + } + u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -45,6 +56,23 @@ func Dial(rawURL string) (*Client, error) { return nil, err } + reader := bufio.NewReader(conn) + statusLine, _ := reader.ReadString('\n') + parts := strings.SplitN(statusLine, " ", 3) + if len(parts) >= 2 { + statusCode, err := strconv.Atoi(parts[1]) + if err == nil { + if statusCode == 204 { + conn.Close() + return nil, fmt.Errorf("DoorBird user has no api permission: %d", statusCode) + } + if statusCode == 503 { + conn.Close() + return nil, fmt.Errorf("DoorBird device is busy: %d", statusCode) + } + } + } + medias := []*core.Media{ { Kind: core.KindAudio, @@ -55,17 +83,19 @@ func Dial(rawURL string) (*Client, error) { }, } - return &Client{ + clt = Client{ core.Connection{ ID: core.NewID(), FormatName: "doorbird", Protocol: "http", URL: rawURL, Medias: medias, - Transport: conn, + // Transport: conn, }, conn, - }, nil + } + + return &clt, nil } func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { @@ -73,12 +103,18 @@ func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, } func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + if len(c.Senders) > 0 { + return fmt.Errorf("DoorBird backchannel already in use") + } + sender := core.NewSender(media, track.Codec) sender.Handler = func(pkt *rtp.Packet) { - _ = c.conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) - if n, err := c.conn.Write(pkt.Payload); err == nil { - c.Send += n + if c.conn != nil { + _ = c.conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) + if n, err := c.conn.Write(pkt.Payload); err == nil { + c.Send += n + } } } @@ -87,7 +123,17 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece return nil } -func (c *Client) Start() (err error) { - _, err = c.conn.Read(nil) - return +func (c *Client) Start() error { + if c.conn == nil { + return nil + } + buf := make([]byte, 1) + for { + _, err := c.conn.Read(buf) + if err != nil { + c.conn.Close() + c.conn = nil + return err + } + } } diff --git a/pkg/doorbird/backchannel_lock.go b/pkg/doorbird/backchannel_lock.go new file mode 100644 index 00000000..758320dc --- /dev/null +++ b/pkg/doorbird/backchannel_lock.go @@ -0,0 +1,5 @@ +package doorbird + +import "sync" + +var backchannelMu sync.Mutex From e00d211619c437d1cbe920d7f08cbc558ef71c56 Mon Sep 17 00:00:00 2001 From: Oliver Eiber Date: Sun, 6 Jul 2025 22:33:25 +0200 Subject: [PATCH 20/41] ensure that doorbird errors where shown in logs --- pkg/doorbird/backchannel.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go index 82ea31b4..d338a445 100644 --- a/pkg/doorbird/backchannel.go +++ b/pkg/doorbird/backchannel.go @@ -2,6 +2,7 @@ package doorbird import ( "bufio" + "errors" "fmt" "net" "net/url" @@ -64,11 +65,11 @@ func Dial(rawURL string) (*Client, error) { if err == nil { if statusCode == 204 { conn.Close() - return nil, fmt.Errorf("DoorBird user has no api permission: %d", statusCode) + return nil, errors.New("DoorBird user has no api permission") } if statusCode == 503 { conn.Close() - return nil, fmt.Errorf("DoorBird device is busy: %d", statusCode) + return nil, errors.New("DoorBird device is busy") } } } @@ -104,7 +105,7 @@ func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { if len(c.Senders) > 0 { - return fmt.Errorf("DoorBird backchannel already in use") + return errors.New("DoorBird backchannel already in use") } sender := core.NewSender(media, track.Codec) From 56e61a85ee7be5c87e0313ec8e0dafc29a7c5d96 Mon Sep 17 00:00:00 2001 From: Oliver Eiber Date: Wed, 16 Jul 2025 21:07:34 +0200 Subject: [PATCH 21/41] proper error handling cleanup files --- pkg/doorbird/backchannel.go | 26 ++++++++++---------------- pkg/doorbird/backchannel_lock.go | 5 ----- 2 files changed, 10 insertions(+), 21 deletions(-) delete mode 100644 pkg/doorbird/backchannel_lock.go diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go index d338a445..8a9a25d9 100644 --- a/pkg/doorbird/backchannel.go +++ b/pkg/doorbird/backchannel.go @@ -5,9 +5,8 @@ import ( "errors" "fmt" "net" + "net/http" "net/url" - "strconv" - "strings" "time" "github.com/AlexxIT/go2rtc/pkg/core" @@ -57,20 +56,15 @@ func Dial(rawURL string) (*Client, error) { return nil, err } - reader := bufio.NewReader(conn) - statusLine, _ := reader.ReadString('\n') - parts := strings.SplitN(statusLine, " ", 3) - if len(parts) >= 2 { - statusCode, err := strconv.Atoi(parts[1]) - if err == nil { - if statusCode == 204 { - conn.Close() - return nil, errors.New("DoorBird user has no api permission") - } - if statusCode == 503 { - conn.Close() - return nil, errors.New("DoorBird device is busy") - } + resp, _ := http.ReadResponse(bufio.NewReader(conn), nil) + if resp != nil { + switch resp.StatusCode { + case 204: + conn.Close() + return nil, errors.New("DoorBird user has no api permission") + case 503: + conn.Close() + return nil, errors.New("DoorBird device is busy") } } diff --git a/pkg/doorbird/backchannel_lock.go b/pkg/doorbird/backchannel_lock.go deleted file mode 100644 index 758320dc..00000000 --- a/pkg/doorbird/backchannel_lock.go +++ /dev/null @@ -1,5 +0,0 @@ -package doorbird - -import "sync" - -var backchannelMu sync.Mutex From a92e04b6e0885bec723b222f5d16d1ffa35a22a1 Mon Sep 17 00:00:00 2001 From: Oliver Eiber Date: Tue, 22 Jul 2025 20:54:24 +0200 Subject: [PATCH 22/41] added audio mixing capability to avoid device overload when multiple backchannel audio streams are connected --- pkg/doorbird/backchannel.go | 195 +++++++++++++++++++++++++++++++++--- 1 file changed, 179 insertions(+), 16 deletions(-) diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go index 8a9a25d9..51b4c194 100644 --- a/pkg/doorbird/backchannel.go +++ b/pkg/doorbird/backchannel.go @@ -7,19 +7,140 @@ import ( "net" "net/http" "net/url" + "sync" "time" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" "github.com/pion/rtp" ) -var ( - clt Client -) +var clt Client + +type AudioMixer struct { + mu sync.Mutex + streams map[string]chan []byte + output chan []byte + running bool +} + +func NewAudioMixer() *AudioMixer { + return &AudioMixer{ + streams: make(map[string]chan []byte), + output: make(chan []byte, 100), + } +} + +func (m *AudioMixer) AddStream(id string) chan []byte { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.running { + m.running = true + go m.mixLoop() + } + + stream := make(chan []byte, 100) + m.streams[id] = stream + return stream +} + +func (m *AudioMixer) RemoveStream(id string) { + m.mu.Lock() + defer m.mu.Unlock() + + if stream, exists := m.streams[id]; exists { + close(stream) + delete(m.streams, id) + } +} + +func (m *AudioMixer) mixLoop() { + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + + for range ticker.C { + m.mu.Lock() + if len(m.streams) == 0 { + m.mu.Unlock() + continue + } + + var pcmSamples [][]int16 + activeStreams := 0 + + for _, stream := range m.streams { + select { + case data := <-stream: + if len(data) > 0 { + samples := make([]int16, len(data)) + for i, sample := range data { + samples[i] = pcm.PCMUtoPCM(sample) + } + pcmSamples = append(pcmSamples, samples) + activeStreams++ + } + default: + } + } + m.mu.Unlock() + + if activeStreams == 0 { + continue + } + + var mixedLength int + for _, samples := range pcmSamples { + if len(samples) > mixedLength { + mixedLength = len(samples) + } + } + + if mixedLength == 0 { + continue + } + + mixed := make([]int16, mixedLength) + for i := 0; i < mixedLength; i++ { + var sum int32 + var count int32 + + for _, samples := range pcmSamples { + if i < len(samples) { + sum += int32(samples[i]) + count++ + } + } + + if count > 0 { + averaged := sum / count + if averaged > 32767 { + mixed[i] = 32767 + } else if averaged < -32768 { + mixed[i] = -32768 + } else { + mixed[i] = int16(averaged) + } + } + } + + output := make([]byte, len(mixed)) + for i, sample := range mixed { + output[i] = pcm.PCMtoPCMU(sample) + } + + select { + case m.output <- output: + default: + } + } +} type Client struct { core.Connection - conn net.Conn + conn net.Conn + mixer *AudioMixer + trackMap map[*core.Sender]string } func Dial(rawURL string) (*Client, error) { @@ -85,9 +206,10 @@ func Dial(rawURL string) (*Client, error) { Protocol: "http", URL: rawURL, Medias: medias, - // Transport: conn, }, conn, + NewAudioMixer(), + make(map[*core.Sender]string), } return &clt, nil @@ -98,22 +220,35 @@ func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, } func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { - if len(c.Senders) > 0 { - return errors.New("DoorBird backchannel already in use") - } - sender := core.NewSender(media, track.Codec) + trackID := fmt.Sprintf("%d", core.NewID()) + streamChan := c.mixer.AddStream(trackID) sender.Handler = func(pkt *rtp.Packet) { - if c.conn != nil { - _ = c.conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) - if n, err := c.conn.Write(pkt.Payload); err == nil { - c.Send += n + if c.conn != nil && len(pkt.Payload) > 0 { + select { + case streamChan <- pkt.Payload: + default: } } } - sender.HandleRTP(track) + c.trackMap[sender] = trackID + + if len(c.Senders) == 0 { + go func() { + for mixedData := range c.mixer.output { + if c.conn != nil { + _ = c.conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) + if n, err := c.conn.Write(mixedData); err == nil { + c.Send += n + } + } + } + }() + } + + sender.WithParent(track).Start() c.Senders = append(c.Senders, sender) return nil } @@ -126,9 +261,37 @@ func (c *Client) Start() error { for { _, err := c.conn.Read(buf) if err != nil { - c.conn.Close() - c.conn = nil + c.cleanup() return err } } } + +func (c *Client) cleanup() { + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + + if c.mixer != nil { + c.mixer.mu.Lock() + for id := range c.mixer.streams { + if stream, exists := c.mixer.streams[id]; exists { + close(stream) + } + } + c.mixer.streams = make(map[string]chan []byte) + close(c.mixer.output) + c.mixer.running = false + c.mixer.mu.Unlock() + } + + c.trackMap = make(map[*core.Sender]string) +} + +func (c *Client) RemoveTrack(sender *core.Sender) { + if trackID, exists := c.trackMap[sender]; exists { + c.mixer.RemoveStream(trackID) + delete(c.trackMap, sender) + } +} From b82023bc32c2e15ac8cf68d4730a9ce2dba513ec Mon Sep 17 00:00:00 2001 From: hsakoh <20980395+hsakoh@users.noreply.github.com> Date: Sat, 26 Jul 2025 01:29:54 +0900 Subject: [PATCH 23/41] Add Support for SwitchBot VideoDoorbell --- README.md | 4 ++-- internal/webrtc/switchbot.go | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9712bbde..ad215aa7 100644 --- a/README.md +++ b/README.md @@ -684,7 +684,7 @@ Supports [Amazon Kinesis Video Streams](https://aws.amazon.com/kinesis/video-str **switchbot** -Support connection to [SwitchBot](https://us.switch-bot.com/) cameras that are based on Kinesis Video Streams. Specifically, this includes [Pan/Tilt Cam Plus 2K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-2k) and [Pan/Tilt Cam Plus 3K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-3k). `Outdoor Spotlight Cam 1080P`, `Outdoor Spotlight Cam 2K`, `Pan/Tilt Cam`, `Pan/Tilt Cam 2K`, `Indoor Cam` are based on Tuya, so this feature is not available. +Support connection to [SwitchBot](https://us.switch-bot.com/) cameras that are based on Kinesis Video Streams. Specifically, this includes [Pan/Tilt Cam Plus 2K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-2k) and [Pan/Tilt Cam Plus 3K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-3k) and [Smart Video Doorbell](https://www.switchbot.jp/products/switchbot-smart-video-doorbell). `Outdoor Spotlight Cam 1080P`, `Outdoor Spotlight Cam 2K`, `Pan/Tilt Cam`, `Pan/Tilt Cam 2K`, `Indoor Cam` are based on Tuya, so this feature is not available. ```yaml streams: @@ -693,7 +693,7 @@ streams: webrtc-openipc: webrtc:ws://192.168.1.123/webrtc_ws#format=openipc#ice_servers=[{"urls":"stun:stun.kinesisvideo.eu-north-1.amazonaws.com:443"}] webrtc-wyze: webrtc:http://192.168.1.123:5000/signaling/camera1?kvs#format=wyze webrtc-kinesis: webrtc:wss://...amazonaws.com/?...#format=kinesis#client_id=...#ice_servers=[{...},{...}] - webrtc-switchbot: webrtc:wss://...amazonaws.com/?...#format=switchbot#resolution=hd#client_id=...#ice_servers=[{...},{...}] + webrtc-switchbot: webrtc:wss://...amazonaws.com/?...#format=switchbot#resolution=hd#play_type=0#client_id=...#ice_servers=[{...},{...}] ``` **PS.** For `kinesis` sources, you can use [echo](#source-echo) to get connection params using `bash`, `python` or any other script language. diff --git a/internal/webrtc/switchbot.go b/internal/webrtc/switchbot.go index 5ece88ae..7d2b290a 100644 --- a/internal/webrtc/switchbot.go +++ b/internal/webrtc/switchbot.go @@ -3,6 +3,8 @@ package webrtc import ( "net/url" + "strconv" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" ) @@ -33,6 +35,13 @@ func switchbotClient(rawURL string, query url.Values) (core.Producer, error) { v.Resolution = 0 case "sd": v.Resolution = 1 + case "auto": + v.Resolution = 2 + } + + playtype, err := strconv.Atoi(query.Get("play_type")) + if err == nil { + v.PlayType = playtype } return v, nil From 7d2ad92c4b4c426062cb48f22c38110a6cc4ce30 Mon Sep 17 00:00:00 2001 From: Oliver Eiber Date: Mon, 28 Jul 2025 22:27:38 +0200 Subject: [PATCH 24/41] fix app crashes remove orphaned streams --- pkg/doorbird/backchannel.go | 107 +++++++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 15 deletions(-) diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go index 51b4c194..8cdd0136 100644 --- a/pkg/doorbird/backchannel.go +++ b/pkg/doorbird/backchannel.go @@ -22,6 +22,7 @@ type AudioMixer struct { streams map[string]chan []byte output chan []byte running bool + closed bool } func NewAudioMixer() *AudioMixer { @@ -35,6 +36,12 @@ func (m *AudioMixer) AddStream(id string) chan []byte { m.mu.Lock() defer m.mu.Unlock() + if m.closed { + ch := make(chan []byte) + close(ch) + return ch + } + if !m.running { m.running = true go m.mixLoop() @@ -138,9 +145,11 @@ func (m *AudioMixer) mixLoop() { type Client struct { core.Connection - conn net.Conn - mixer *AudioMixer - trackMap map[*core.Sender]string + conn net.Conn + mixer *AudioMixer + trackMap map[*core.Sender]string + senderStats map[*core.Sender]time.Time + mu sync.RWMutex } func Dial(rawURL string) (*Client, error) { @@ -200,16 +209,17 @@ func Dial(rawURL string) (*Client, error) { } clt = Client{ - core.Connection{ + Connection: core.Connection{ ID: core.NewID(), FormatName: "doorbird", Protocol: "http", URL: rawURL, Medias: medias, }, - conn, - NewAudioMixer(), - make(map[*core.Sender]string), + conn: conn, + mixer: NewAudioMixer(), + trackMap: make(map[*core.Sender]string), + senderStats: make(map[*core.Sender]time.Time), } return &clt, nil @@ -220,20 +230,31 @@ func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, } func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + c.mu.Lock() + defer c.mu.Unlock() + sender := core.NewSender(media, track.Codec) trackID := fmt.Sprintf("%d", core.NewID()) streamChan := c.mixer.AddStream(trackID) sender.Handler = func(pkt *rtp.Packet) { - if c.conn != nil && len(pkt.Payload) > 0 { + c.mu.RLock() + conn := c.conn + c.mu.RUnlock() + + if conn != nil && len(pkt.Payload) > 0 { select { case streamChan <- pkt.Payload: + c.mu.Lock() + c.senderStats[sender] = time.Now() + c.mu.Unlock() default: } } } c.trackMap[sender] = trackID + c.senderStats[sender] = time.Now() if len(c.Senders) == 0 { go func() { @@ -257,6 +278,15 @@ func (c *Client) Start() error { if c.conn == nil { return nil } + + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for range ticker.C { + c.cleanupOrphanedSenders() + } + }() + buf := make([]byte, 1) for { _, err := c.conn.Read(buf) @@ -268,6 +298,9 @@ func (c *Client) Start() error { } func (c *Client) cleanup() { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn != nil { c.conn.Close() c.conn = nil @@ -275,23 +308,67 @@ func (c *Client) cleanup() { if c.mixer != nil { c.mixer.mu.Lock() - for id := range c.mixer.streams { - if stream, exists := c.mixer.streams[id]; exists { - close(stream) - } + c.mixer.closed = true + for id, stream := range c.mixer.streams { + close(stream) + delete(c.mixer.streams, id) + } + if c.mixer.running { + close(c.mixer.output) + c.mixer.running = false } - c.mixer.streams = make(map[string]chan []byte) - close(c.mixer.output) - c.mixer.running = false c.mixer.mu.Unlock() } c.trackMap = make(map[*core.Sender]string) + c.senderStats = make(map[*core.Sender]time.Time) +} + +func (c *Client) cleanupOrphanedSenders() { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + removedCount := 0 + validIndex := 0 + + for i, sender := range c.Senders { + lastActivity, exists := c.senderStats[sender] + if sender.State() == "closed" || !exists || now.Sub(lastActivity) >= 5*time.Second { + if trackID, exists := c.trackMap[sender]; exists { + c.mixer.RemoveStream(trackID) + delete(c.trackMap, sender) + } + delete(c.senderStats, sender) + sender.Close() + removedCount++ + } else { + c.Senders[validIndex] = c.Senders[i] + validIndex++ + } + } + + c.Senders = c.Senders[:validIndex] + + if removedCount > 0 { + fmt.Printf("DoorBird: Cleaned up %d orphaned senders, %d remain active\n", removedCount, validIndex) + } } func (c *Client) RemoveTrack(sender *core.Sender) { + c.mu.Lock() + defer c.mu.Unlock() + if trackID, exists := c.trackMap[sender]; exists { c.mixer.RemoveStream(trackID) delete(c.trackMap, sender) } + delete(c.senderStats, sender) + + for i, s := range c.Senders { + if s == sender { + c.Senders = append(c.Senders[:i], c.Senders[i+1:]...) + break + } + } } From 3d38e5e567329d24d5ad87baf0729d841eaf3ad5 Mon Sep 17 00:00:00 2001 From: Oliver Eiber Date: Wed, 30 Jul 2025 23:37:06 +0200 Subject: [PATCH 25/41] fix unexpected close of backchannel streams --- pkg/doorbird/backchannel.go | 69 ++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go index 8cdd0136..5e5e8834 100644 --- a/pkg/doorbird/backchannel.go +++ b/pkg/doorbird/backchannel.go @@ -15,7 +15,10 @@ import ( "github.com/pion/rtp" ) -var clt Client +var ( + cltMu sync.Mutex + cltMap = make(map[string]*Client) +) type AudioMixer struct { mu sync.Mutex @@ -68,6 +71,11 @@ func (m *AudioMixer) mixLoop() { for range ticker.C { m.mu.Lock() + if m.closed { + m.mu.Unlock() + return + } + if len(m.streams) == 0 { m.mu.Unlock() continue @@ -153,8 +161,12 @@ type Client struct { } func Dial(rawURL string) (*Client, error) { - if clt.conn != nil { - return &clt, nil + cltMu.Lock() + defer cltMu.Unlock() + + // Check if we already have a client for this URL + if existingClient, exists := cltMap[rawURL]; exists && existingClient.conn != nil { + return existingClient, nil } u, err := url.Parse(rawURL) @@ -183,6 +195,7 @@ func Dial(rawURL string) (*Client, error) { _ = conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) if _, err = conn.Write([]byte(s)); err != nil { + conn.Close() return nil, err } @@ -208,7 +221,7 @@ func Dial(rawURL string) (*Client, error) { }, } - clt = Client{ + client := &Client{ Connection: core.Connection{ ID: core.NewID(), FormatName: "doorbird", @@ -222,7 +235,10 @@ func Dial(rawURL string) (*Client, error) { senderStats: make(map[*core.Sender]time.Time), } - return &clt, nil + // Store the client in the map + cltMap[rawURL] = client + + return client, nil } func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { @@ -238,17 +254,22 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece streamChan := c.mixer.AddStream(trackID) sender.Handler = func(pkt *rtp.Packet) { + if len(pkt.Payload) == 0 { + return + } + c.mu.RLock() conn := c.conn c.mu.RUnlock() - if conn != nil && len(pkt.Payload) > 0 { + if conn != nil { select { case streamChan <- pkt.Payload: c.mu.Lock() c.senderStats[sender] = time.Now() c.mu.Unlock() default: + // Channel is full, skip this packet } } } @@ -258,11 +279,24 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece if len(c.Senders) == 0 { go func() { + defer func() { + if r := recover(); r != nil { + // Recover from any panics when mixer is closed + } + }() + for mixedData := range c.mixer.output { - if c.conn != nil { - _ = c.conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) - if n, err := c.conn.Write(mixedData); err == nil { + c.mu.RLock() + conn := c.conn + c.mu.RUnlock() + + if conn != nil && len(mixedData) > 0 { + _ = conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) + if n, err := conn.Write(mixedData); err == nil { c.Send += n + } else { + // Connection failed, break out of loop + break } } } @@ -289,9 +323,15 @@ func (c *Client) Start() error { buf := make([]byte, 1) for { + // Set read deadline to detect connection issues + _ = c.conn.SetReadDeadline(time.Now().Add(30 * time.Second)) _, err := c.conn.Read(buf) if err != nil { c.cleanup() + // Remove this client from the global map + cltMu.Lock() + delete(cltMap, c.URL) + cltMu.Unlock() return err } } @@ -320,8 +360,19 @@ func (c *Client) cleanup() { c.mixer.mu.Unlock() } + // Close all senders + for _, sender := range c.Senders { + sender.Close() + } + c.Senders = nil + c.trackMap = make(map[*core.Sender]string) c.senderStats = make(map[*core.Sender]time.Time) + + // Remove from global map + cltMu.Lock() + delete(cltMap, c.URL) + cltMu.Unlock() } func (c *Client) cleanupOrphanedSenders() { From 975a43d39276bd61ef3ed0f9c2eb1adb66b5e60d Mon Sep 17 00:00:00 2001 From: Oliver Eiber Date: Thu, 31 Jul 2025 21:07:45 +0200 Subject: [PATCH 26/41] reduce audio delay by lowering buffer size --- pkg/doorbird/backchannel.go | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go index 5e5e8834..dc66ee0e 100644 --- a/pkg/doorbird/backchannel.go +++ b/pkg/doorbird/backchannel.go @@ -15,6 +15,14 @@ import ( "github.com/pion/rtp" ) +const ( + AudioMixerInterval = 10 * time.Millisecond + AudioChannelBuffer = 10 + OutputChannelBuffer = 10 + SenderCleanupInterval = 5 * time.Second + SenderTimeoutDuration = 5 * time.Second +) + var ( cltMu sync.Mutex cltMap = make(map[string]*Client) @@ -31,7 +39,7 @@ type AudioMixer struct { func NewAudioMixer() *AudioMixer { return &AudioMixer{ streams: make(map[string]chan []byte), - output: make(chan []byte, 100), + output: make(chan []byte, OutputChannelBuffer), } } @@ -50,7 +58,7 @@ func (m *AudioMixer) AddStream(id string) chan []byte { go m.mixLoop() } - stream := make(chan []byte, 100) + stream := make(chan []byte, AudioChannelBuffer) m.streams[id] = stream return stream } @@ -66,7 +74,7 @@ func (m *AudioMixer) RemoveStream(id string) { } func (m *AudioMixer) mixLoop() { - ticker := time.NewTicker(20 * time.Millisecond) + ticker := time.NewTicker(AudioMixerInterval) defer ticker.Stop() for range ticker.C { @@ -164,7 +172,6 @@ func Dial(rawURL string) (*Client, error) { cltMu.Lock() defer cltMu.Unlock() - // Check if we already have a client for this URL if existingClient, exists := cltMap[rawURL]; exists && existingClient.conn != nil { return existingClient, nil } @@ -235,7 +242,6 @@ func Dial(rawURL string) (*Client, error) { senderStats: make(map[*core.Sender]time.Time), } - // Store the client in the map cltMap[rawURL] = client return client, nil @@ -269,7 +275,6 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece c.senderStats[sender] = time.Now() c.mu.Unlock() default: - // Channel is full, skip this packet } } } @@ -281,7 +286,6 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece go func() { defer func() { if r := recover(); r != nil { - // Recover from any panics when mixer is closed } }() @@ -295,7 +299,6 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece if n, err := conn.Write(mixedData); err == nil { c.Send += n } else { - // Connection failed, break out of loop break } } @@ -314,7 +317,7 @@ func (c *Client) Start() error { } go func() { - ticker := time.NewTicker(5 * time.Second) + ticker := time.NewTicker(SenderCleanupInterval) defer ticker.Stop() for range ticker.C { c.cleanupOrphanedSenders() @@ -323,12 +326,10 @@ func (c *Client) Start() error { buf := make([]byte, 1) for { - // Set read deadline to detect connection issues _ = c.conn.SetReadDeadline(time.Now().Add(30 * time.Second)) _, err := c.conn.Read(buf) if err != nil { c.cleanup() - // Remove this client from the global map cltMu.Lock() delete(cltMap, c.URL) cltMu.Unlock() @@ -360,7 +361,6 @@ func (c *Client) cleanup() { c.mixer.mu.Unlock() } - // Close all senders for _, sender := range c.Senders { sender.Close() } @@ -369,7 +369,6 @@ func (c *Client) cleanup() { c.trackMap = make(map[*core.Sender]string) c.senderStats = make(map[*core.Sender]time.Time) - // Remove from global map cltMu.Lock() delete(cltMap, c.URL) cltMu.Unlock() @@ -385,7 +384,7 @@ func (c *Client) cleanupOrphanedSenders() { for i, sender := range c.Senders { lastActivity, exists := c.senderStats[sender] - if sender.State() == "closed" || !exists || now.Sub(lastActivity) >= 5*time.Second { + if sender.State() == "closed" || !exists || now.Sub(lastActivity) >= SenderTimeoutDuration { if trackID, exists := c.trackMap[sender]; exists { c.mixer.RemoveStream(trackID) delete(c.trackMap, sender) From f2242e31c8d3757e589b8a7dff0e4b1ae8ab66fe Mon Sep 17 00:00:00 2001 From: Oliver Eiber Date: Tue, 19 Aug 2025 07:53:10 +0200 Subject: [PATCH 27/41] impove connection timeout to prevent reconnections after 30 seconds --- pkg/doorbird/backchannel.go | 41 +++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go index dc66ee0e..a49130e5 100644 --- a/pkg/doorbird/backchannel.go +++ b/pkg/doorbird/backchannel.go @@ -21,6 +21,8 @@ const ( OutputChannelBuffer = 10 SenderCleanupInterval = 5 * time.Second SenderTimeoutDuration = 5 * time.Second + ConnectionReadTimeout = 5 * time.Minute + HeartbeatInterval = 30 * time.Second ) var ( @@ -244,6 +246,8 @@ func Dial(rawURL string) (*Client, error) { cltMap[rawURL] = client + fmt.Printf("DoorBird: New connection established to %s\n", rawURL) + return client, nil } @@ -299,6 +303,7 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece if n, err := conn.Write(mixedData); err == nil { c.Send += n } else { + fmt.Printf("DoorBird: Write error, breaking audio loop: %v\n", err) break } } @@ -324,17 +329,47 @@ func (c *Client) Start() error { } }() + // Start a heartbeat goroutine to periodically check connection health + go func() { + heartbeat := time.NewTicker(HeartbeatInterval) + defer heartbeat.Stop() + + for range heartbeat.C { + c.mu.RLock() + conn := c.conn + c.mu.RUnlock() + + if conn != nil { + // Try to write a small amount of silence to keep connection alive + silence := make([]byte, 160) // 20ms of silence at 8kHz + _ = conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) + if _, err := conn.Write(silence); err != nil { + fmt.Printf("DoorBird: Heartbeat write failed: %v\n", err) + // Don't break here, let the main read loop handle it + } + } + } + }() + + // The main loop now just monitors for any unexpected data or connection errors + // DoorBird typically doesn't send data back, so we use a very long timeout buf := make([]byte, 1) + connectionStart := time.Now() for { - _ = c.conn.SetReadDeadline(time.Now().Add(30 * time.Second)) - _, err := c.conn.Read(buf) + _ = c.conn.SetReadDeadline(time.Now().Add(ConnectionReadTimeout)) + n, err := c.conn.Read(buf) if err != nil { + elapsed := time.Since(connectionStart) + fmt.Printf("DoorBird: Connection failed after %v, error: %v\n", elapsed, err) c.cleanup() cltMu.Lock() delete(cltMap, c.URL) cltMu.Unlock() return err } + if n > 0 { + fmt.Printf("DoorBird: Unexpected data received: %v\n", buf[:n]) + } } } @@ -342,6 +377,8 @@ func (c *Client) cleanup() { c.mu.Lock() defer c.mu.Unlock() + fmt.Printf("DoorBird: Starting cleanup for connection %s\n", c.URL) + if c.conn != nil { c.conn.Close() c.conn = nil From 3a0e4078fdb63cb9d496b7376f008c4fd183943e Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 30 Sep 2025 14:48:58 +0200 Subject: [PATCH 28/41] Dont redact hass entry title --- internal/hass/hass.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/hass/hass.go b/internal/hass/hass.go index e2132ad6..ea172b02 100644 --- a/internal/hass/hass.go +++ b/internal/hass/hass.go @@ -15,7 +15,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/hass" - "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/rs/zerolog" ) @@ -178,7 +177,7 @@ func importConfig(config string) error { continue } - log.Debug().Str("url", "hass:"+shell.Redact(entrie.Title)).Msg("[hass] load config") + log.Debug().Str("url", "hass:"+entrie.Title).Msg("[hass] load config") //streams.Get("hass:" + entrie.Title) } From 50d5fa93b6d01cfaec0579052c5026f1f7303984 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 30 Sep 2025 14:50:57 +0200 Subject: [PATCH 29/41] Set Content-Type header to MimeJSON in ResponsePrettyJSON function --- internal/api/api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/api.go b/internal/api/api.go index f66f59f4..3dd16d3d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -166,7 +166,7 @@ func ResponseJSON(w http.ResponseWriter, v any) { } func ResponsePrettyJSON(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Type", MimeJSON) var buf bytes.Buffer enc := json.NewEncoder(&buf) From 0c5a2bf02ba8b9a4cd48bc0b49c5b8cbb11d2ba6 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 30 Sep 2025 15:21:30 +0200 Subject: [PATCH 30/41] Remove NewSecret function and related import from helpers.go --- pkg/core/helpers.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pkg/core/helpers.go b/pkg/core/helpers.go index c39a1eee..161a5504 100644 --- a/pkg/core/helpers.go +++ b/pkg/core/helpers.go @@ -7,8 +7,6 @@ import ( "strconv" "strings" "time" - - "github.com/AlexxIT/go2rtc/internal/app" ) const ( @@ -91,7 +89,3 @@ func StripUserinfo(s string) string { sanitizer := regexp.MustCompile(`://[` + userinfo + `]+@`) return sanitizer.ReplaceAllString(s, `://***@`) } - -func NewSecret(name string, defaultValues interface{}) (*app.Secret, error) { - return app.NewSecret(name, defaultValues) -} From 670370056cfd986ea1632af05e9b6c1d1252f08d Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 30 Sep 2025 15:35:32 +0200 Subject: [PATCH 31/41] Refactor secrets management --- internal/app/secrets.go | 46 ++++++++++++++++++----------------------- pkg/secrets/secrets.go | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 26 deletions(-) create mode 100644 pkg/secrets/secrets.go diff --git a/internal/app/secrets.go b/internal/app/secrets.go index 4735c27c..e1ce7509 100644 --- a/internal/app/secrets.go +++ b/internal/app/secrets.go @@ -3,34 +3,23 @@ package app import ( "sync" + "github.com/AlexxIT/go2rtc/pkg/secrets" "github.com/AlexxIT/go2rtc/pkg/yaml" ) var ( - secrets = make(map[string]*Secret) - secretsMu sync.Mutex + secretsMap = make(map[string]*Secret) + secretsMu sync.Mutex ) -type Secrets interface { - Get(key string) any - Set(key string, value any) - Marshal(v any) ([]byte, error) - Unmarshal(v any) error - Save() error -} +// SecretsManager implements secrets.SecretsManager interface +type SecretsManager struct{} -type Secret struct { - Secrets - - Name string - Values map[string]string -} - -func NewSecret(name string, values interface{}) (*Secret, error) { +func (m *SecretsManager) NewSecret(name string, values interface{}) (secrets.Secret, error) { secretsMu.Lock() defer secretsMu.Unlock() - if s, exists := secrets[name]; exists { + if s, exists := secretsMap[name]; exists { return s, nil } @@ -45,15 +34,21 @@ func NewSecret(name string, values interface{}) (*Secret, error) { return nil, err } - secrets[name] = s + secretsMap[name] = s return s, nil } -func GetSecret(name string) *Secret { +func (m *SecretsManager) GetSecret(name string) secrets.Secret { secretsMu.Lock() defer secretsMu.Unlock() - return secrets[name] + return secretsMap[name] +} + +// Secret implements secrets.Secret interface +type Secret struct { + Name string + Values map[string]string } func (s *Secret) Get(key string) any { @@ -112,7 +107,7 @@ func (s *Secret) Unmarshal(value any) error { func (s *Secret) Save() error { secretsMu.Lock() defer secretsMu.Unlock() - return saveSecret(s.Name, s.Values) + return PatchConfig([]string{"secrets", s.Name}, s.Values) } func initSecrets() { @@ -130,13 +125,12 @@ func initSecrets() { defer secretsMu.Unlock() for name, values := range cfg.Secrets { - secrets[name] = &Secret{ + secretsMap[name] = &Secret{ Name: name, Values: values, } } -} -func saveSecret(name string, secretValues map[string]string) error { - return PatchConfig([]string{"secrets", name}, secretValues) + // Register + secrets.SetManager(&SecretsManager{}) } diff --git a/pkg/secrets/secrets.go b/pkg/secrets/secrets.go new file mode 100644 index 00000000..071d9526 --- /dev/null +++ b/pkg/secrets/secrets.go @@ -0,0 +1,44 @@ +package secrets + +import ( + "errors" + "sync" +) + +type SecretsManager interface { + NewSecret(name string, defaultValues interface{}) (Secret, error) + GetSecret(name string) Secret +} + +type Secret interface { + Get(key string) any + Set(key string, value string) + Marshal() (interface{}, error) + Unmarshal(value any) error + Save() error +} + +var manager SecretsManager +var once sync.Once + +func SetManager(m SecretsManager) { + once.Do(func() { + manager = m + }) +} + +// NewSecret creates or retrieves a secret +func NewSecret(name string, defaultValues interface{}) (Secret, error) { + if manager == nil { + return nil, errors.New("secrets manager not initialized") + } + return manager.NewSecret(name, defaultValues) +} + +// GetSecret retrieves an existing secret +func GetSecret(name string) Secret { + if manager == nil { + return nil + } + return manager.GetSecret(name) +} From 54b95dced4ddee1a2e862f94365b7ad50ab1bff8 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sat, 4 Oct 2025 19:18:36 +0300 Subject: [PATCH 32/41] Fix probing after #1762 --- pkg/probe/consumer.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/probe/consumer.go b/pkg/probe/consumer.go index c6aa4478..a1ca7ca5 100644 --- a/pkg/probe/consumer.go +++ b/pkg/probe/consumer.go @@ -47,3 +47,7 @@ func (p *Probe) AddTrack(media *core.Media, codec *core.Codec, track *core.Recei p.Senders = append(p.Senders, sender) return nil } + +func (p *Probe) Start() error { + return nil +} From ec08dfee9c0d4c0c6ae620ca1cc64c8d44be223d Mon Sep 17 00:00:00 2001 From: Alex X Date: Sat, 4 Oct 2025 19:19:01 +0300 Subject: [PATCH 33/41] Fix stack API for new pion version --- internal/debug/stack.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/debug/stack.go b/internal/debug/stack.go index f8d62772..6bc735ad 100644 --- a/internal/debug/stack.go +++ b/internal/debug/stack.go @@ -29,8 +29,8 @@ var stackSkip = [][]byte{ []byte("created by github.com/AlexxIT/go2rtc/internal/homekit.Init"), // webrtc/api.go - []byte("created by github.com/pion/ice/v2.NewTCPMuxDefault"), - []byte("created by github.com/pion/ice/v2.NewUDPMuxDefault"), + []byte("created by github.com/pion/ice/v4.NewTCPMuxDefault"), + []byte("created by github.com/pion/ice/v4.NewUDPMuxDefault"), } func stackHandler(w http.ResponseWriter, r *http.Request) { From 887f0f48905459d1ad6f2e94bbe33b2450980a8d Mon Sep 17 00:00:00 2001 From: Oliver Eiber Date: Sat, 4 Oct 2025 21:37:19 +0200 Subject: [PATCH 34/41] fix connection handling in conjunction with doorbird backchannel --- pkg/doorbird/backchannel.go | 397 ++---------------------------------- 1 file changed, 16 insertions(+), 381 deletions(-) diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go index a49130e5..28eb5b69 100644 --- a/pkg/doorbird/backchannel.go +++ b/pkg/doorbird/backchannel.go @@ -1,183 +1,21 @@ package doorbird import ( - "bufio" - "errors" "fmt" "net" - "net/http" "net/url" - "sync" "time" "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/pcm" "github.com/pion/rtp" ) -const ( - AudioMixerInterval = 10 * time.Millisecond - AudioChannelBuffer = 10 - OutputChannelBuffer = 10 - SenderCleanupInterval = 5 * time.Second - SenderTimeoutDuration = 5 * time.Second - ConnectionReadTimeout = 5 * time.Minute - HeartbeatInterval = 30 * time.Second -) - -var ( - cltMu sync.Mutex - cltMap = make(map[string]*Client) -) - -type AudioMixer struct { - mu sync.Mutex - streams map[string]chan []byte - output chan []byte - running bool - closed bool -} - -func NewAudioMixer() *AudioMixer { - return &AudioMixer{ - streams: make(map[string]chan []byte), - output: make(chan []byte, OutputChannelBuffer), - } -} - -func (m *AudioMixer) AddStream(id string) chan []byte { - m.mu.Lock() - defer m.mu.Unlock() - - if m.closed { - ch := make(chan []byte) - close(ch) - return ch - } - - if !m.running { - m.running = true - go m.mixLoop() - } - - stream := make(chan []byte, AudioChannelBuffer) - m.streams[id] = stream - return stream -} - -func (m *AudioMixer) RemoveStream(id string) { - m.mu.Lock() - defer m.mu.Unlock() - - if stream, exists := m.streams[id]; exists { - close(stream) - delete(m.streams, id) - } -} - -func (m *AudioMixer) mixLoop() { - ticker := time.NewTicker(AudioMixerInterval) - defer ticker.Stop() - - for range ticker.C { - m.mu.Lock() - if m.closed { - m.mu.Unlock() - return - } - - if len(m.streams) == 0 { - m.mu.Unlock() - continue - } - - var pcmSamples [][]int16 - activeStreams := 0 - - for _, stream := range m.streams { - select { - case data := <-stream: - if len(data) > 0 { - samples := make([]int16, len(data)) - for i, sample := range data { - samples[i] = pcm.PCMUtoPCM(sample) - } - pcmSamples = append(pcmSamples, samples) - activeStreams++ - } - default: - } - } - m.mu.Unlock() - - if activeStreams == 0 { - continue - } - - var mixedLength int - for _, samples := range pcmSamples { - if len(samples) > mixedLength { - mixedLength = len(samples) - } - } - - if mixedLength == 0 { - continue - } - - mixed := make([]int16, mixedLength) - for i := 0; i < mixedLength; i++ { - var sum int32 - var count int32 - - for _, samples := range pcmSamples { - if i < len(samples) { - sum += int32(samples[i]) - count++ - } - } - - if count > 0 { - averaged := sum / count - if averaged > 32767 { - mixed[i] = 32767 - } else if averaged < -32768 { - mixed[i] = -32768 - } else { - mixed[i] = int16(averaged) - } - } - } - - output := make([]byte, len(mixed)) - for i, sample := range mixed { - output[i] = pcm.PCMtoPCMU(sample) - } - - select { - case m.output <- output: - default: - } - } -} - type Client struct { core.Connection - conn net.Conn - mixer *AudioMixer - trackMap map[*core.Sender]string - senderStats map[*core.Sender]time.Time - mu sync.RWMutex + conn net.Conn } func Dial(rawURL string) (*Client, error) { - cltMu.Lock() - defer cltMu.Unlock() - - if existingClient, exists := cltMap[rawURL]; exists && existingClient.conn != nil { - return existingClient, nil - } - u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -204,22 +42,9 @@ func Dial(rawURL string) (*Client, error) { _ = conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) if _, err = conn.Write([]byte(s)); err != nil { - conn.Close() return nil, err } - resp, _ := http.ReadResponse(bufio.NewReader(conn), nil) - if resp != nil { - switch resp.StatusCode { - case 204: - conn.Close() - return nil, errors.New("DoorBird user has no api permission") - case 503: - conn.Close() - return nil, errors.New("DoorBird device is busy") - } - } - medias := []*core.Media{ { Kind: core.KindAudio, @@ -230,25 +55,17 @@ func Dial(rawURL string) (*Client, error) { }, } - client := &Client{ - Connection: core.Connection{ + return &Client{ + core.Connection{ ID: core.NewID(), FormatName: "doorbird", Protocol: "http", URL: rawURL, Medias: medias, + Transport: conn, }, - conn: conn, - mixer: NewAudioMixer(), - trackMap: make(map[*core.Sender]string), - senderStats: make(map[*core.Sender]time.Time), - } - - cltMap[rawURL] = client - - fmt.Printf("DoorBird: New connection established to %s\n", rawURL) - - return client, nil + conn, + }, nil } func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { @@ -256,206 +73,24 @@ func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, } func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { - c.mu.Lock() - defer c.mu.Unlock() - sender := core.NewSender(media, track.Codec) - trackID := fmt.Sprintf("%d", core.NewID()) - streamChan := c.mixer.AddStream(trackID) sender.Handler = func(pkt *rtp.Packet) { - if len(pkt.Payload) == 0 { - return - } - - c.mu.RLock() - conn := c.conn - c.mu.RUnlock() - - if conn != nil { - select { - case streamChan <- pkt.Payload: - c.mu.Lock() - c.senderStats[sender] = time.Now() - c.mu.Unlock() - default: - } + _ = c.conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) + if n, err := c.conn.Write(pkt.Payload); err == nil { + c.Send += n } } - c.trackMap[sender] = trackID - c.senderStats[sender] = time.Now() - - if len(c.Senders) == 0 { - go func() { - defer func() { - if r := recover(); r != nil { - } - }() - - for mixedData := range c.mixer.output { - c.mu.RLock() - conn := c.conn - c.mu.RUnlock() - - if conn != nil && len(mixedData) > 0 { - _ = conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) - if n, err := conn.Write(mixedData); err == nil { - c.Send += n - } else { - fmt.Printf("DoorBird: Write error, breaking audio loop: %v\n", err) - break - } - } - } - }() - } - - sender.WithParent(track).Start() + sender.HandleRTP(track) c.Senders = append(c.Senders, sender) return nil } -func (c *Client) Start() error { - if c.conn == nil { - return nil - } - - go func() { - ticker := time.NewTicker(SenderCleanupInterval) - defer ticker.Stop() - for range ticker.C { - c.cleanupOrphanedSenders() - } - }() - - // Start a heartbeat goroutine to periodically check connection health - go func() { - heartbeat := time.NewTicker(HeartbeatInterval) - defer heartbeat.Stop() - - for range heartbeat.C { - c.mu.RLock() - conn := c.conn - c.mu.RUnlock() - - if conn != nil { - // Try to write a small amount of silence to keep connection alive - silence := make([]byte, 160) // 20ms of silence at 8kHz - _ = conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) - if _, err := conn.Write(silence); err != nil { - fmt.Printf("DoorBird: Heartbeat write failed: %v\n", err) - // Don't break here, let the main read loop handle it - } - } - } - }() - - // The main loop now just monitors for any unexpected data or connection errors - // DoorBird typically doesn't send data back, so we use a very long timeout - buf := make([]byte, 1) - connectionStart := time.Now() - for { - _ = c.conn.SetReadDeadline(time.Now().Add(ConnectionReadTimeout)) - n, err := c.conn.Read(buf) - if err != nil { - elapsed := time.Since(connectionStart) - fmt.Printf("DoorBird: Connection failed after %v, error: %v\n", elapsed, err) - c.cleanup() - cltMu.Lock() - delete(cltMap, c.URL) - cltMu.Unlock() - return err - } - if n > 0 { - fmt.Printf("DoorBird: Unexpected data received: %v\n", buf[:n]) - } - } -} - -func (c *Client) cleanup() { - c.mu.Lock() - defer c.mu.Unlock() - - fmt.Printf("DoorBird: Starting cleanup for connection %s\n", c.URL) - - if c.conn != nil { - c.conn.Close() - c.conn = nil - } - - if c.mixer != nil { - c.mixer.mu.Lock() - c.mixer.closed = true - for id, stream := range c.mixer.streams { - close(stream) - delete(c.mixer.streams, id) - } - if c.mixer.running { - close(c.mixer.output) - c.mixer.running = false - } - c.mixer.mu.Unlock() - } - - for _, sender := range c.Senders { - sender.Close() - } - c.Senders = nil - - c.trackMap = make(map[*core.Sender]string) - c.senderStats = make(map[*core.Sender]time.Time) - - cltMu.Lock() - delete(cltMap, c.URL) - cltMu.Unlock() -} - -func (c *Client) cleanupOrphanedSenders() { - c.mu.Lock() - defer c.mu.Unlock() - - now := time.Now() - removedCount := 0 - validIndex := 0 - - for i, sender := range c.Senders { - lastActivity, exists := c.senderStats[sender] - if sender.State() == "closed" || !exists || now.Sub(lastActivity) >= SenderTimeoutDuration { - if trackID, exists := c.trackMap[sender]; exists { - c.mixer.RemoveStream(trackID) - delete(c.trackMap, sender) - } - delete(c.senderStats, sender) - sender.Close() - removedCount++ - } else { - c.Senders[validIndex] = c.Senders[i] - validIndex++ - } - } - - c.Senders = c.Senders[:validIndex] - - if removedCount > 0 { - fmt.Printf("DoorBird: Cleaned up %d orphaned senders, %d remain active\n", removedCount, validIndex) - } -} - -func (c *Client) RemoveTrack(sender *core.Sender) { - c.mu.Lock() - defer c.mu.Unlock() - - if trackID, exists := c.trackMap[sender]; exists { - c.mixer.RemoveStream(trackID) - delete(c.trackMap, sender) - } - delete(c.senderStats, sender) - - for i, s := range c.Senders { - if s == sender { - c.Senders = append(c.Senders[:i], c.Senders[i+1:]...) - break - } - } +func (c *Client) Start() (err error) { + _, err = c.conn.Read(nil) + // just block until c.conn closed + b := make([]byte, 1) + _, _ = c.conn.Read(b) + return } From 94b7c33485ec29052c6521e4646de9ea6162a438 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 5 Oct 2025 16:00:58 +0300 Subject: [PATCH 35/41] Update backchannel.go Code refactoring for #1895 --- pkg/doorbird/backchannel.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go index 28eb5b69..4d252228 100644 --- a/pkg/doorbird/backchannel.go +++ b/pkg/doorbird/backchannel.go @@ -88,9 +88,8 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece } func (c *Client) Start() (err error) { - _, err = c.conn.Read(nil) // just block until c.conn closed b := make([]byte, 1) - _, _ = c.conn.Read(b) + _, err = c.conn.Read(b) return } From fe2cc4b525ba50812a0f1d419de989e3fc365cdf Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 7 Oct 2025 13:25:42 +0300 Subject: [PATCH 36/41] Code refactoring for #1744 --- internal/api/api.go | 18 +---- internal/app/app.go | 1 - internal/app/config.go | 6 +- internal/app/log.go | 3 + internal/app/secrets.go | 136 --------------------------------- internal/app/storage.go | 56 ++++++++++++++ internal/echo/echo.go | 2 +- internal/expr/expr.go | 3 +- internal/hls/hls.go | 7 +- internal/onvif/onvif.go | 5 +- internal/streams/api.go | 5 ++ internal/streams/dot.go | 4 +- internal/streams/producer.go | 11 ++- internal/streams/streams.go | 3 +- pkg/creds/README.md | 7 ++ pkg/creds/creds.go | 79 +++++++++++++++++++ pkg/creds/secrets.go | 83 ++++++++++++++++++++ pkg/creds/secrets_test.go | 15 ++++ pkg/secrets/secrets.go | 44 ----------- pkg/shell/shell.go | 143 ----------------------------------- 20 files changed, 269 insertions(+), 362 deletions(-) delete mode 100644 internal/app/secrets.go create mode 100644 internal/app/storage.go create mode 100644 pkg/creds/README.md create mode 100644 pkg/creds/creds.go create mode 100644 pkg/creds/secrets.go create mode 100644 pkg/creds/secrets_test.go delete mode 100644 pkg/secrets/secrets.go diff --git a/internal/api/api.go b/internal/api/api.go index 3dd16d3d..419e2bdf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,7 +1,6 @@ package api import ( - "bytes" "crypto/tls" "encoding/json" "fmt" @@ -15,7 +14,6 @@ import ( "time" "github.com/AlexxIT/go2rtc/internal/app" - "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/rs/zerolog" ) @@ -167,19 +165,9 @@ func ResponseJSON(w http.ResponseWriter, v any) { func ResponsePrettyJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", MimeJSON) - - var buf bytes.Buffer - enc := json.NewEncoder(&buf) + enc := json.NewEncoder(w) enc.SetIndent("", " ") - err := enc.Encode(v) - - if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - redactedJSON := shell.Redact(buf.String()) - w.Write([]byte(redactedJSON)) + _ = enc.Encode(v) } func Response(w http.ResponseWriter, body any, contentType string) { @@ -202,7 +190,7 @@ var log zerolog.Logger func middlewareLog(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Trace().Msgf("[api] %s %s %s", r.Method, shell.Redact(r.URL.String()), r.RemoteAddr) + log.Trace().Msgf("[api] %s %s %s", r.Method, r.URL, r.RemoteAddr) next.ServeHTTP(w, r) }) } diff --git a/internal/app/app.go b/internal/app/app.go index 4b89daa9..eb803584 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -67,7 +67,6 @@ func Init() { Info["revision"] = revision initConfig(config) - initSecrets() initLogger() platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) diff --git a/internal/app/config.go b/internal/app/config.go index f0eb36e0..0f95894a 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -7,7 +7,7 @@ import ( "strings" "sync" - "github.com/AlexxIT/go2rtc/pkg/shell" + "github.com/AlexxIT/go2rtc/pkg/creds" "github.com/AlexxIT/go2rtc/pkg/yaml" ) @@ -71,13 +71,15 @@ func initConfig(confs flagConfig) { // config as file if ConfigPath == "" { ConfigPath = conf + initStorage() } if data, _ = os.ReadFile(conf); data == nil { continue } - data = []byte(shell.ReplaceEnvVars(string(data))) + loadEnv(data) + data = creds.ReplaceVars(data) configs = append(configs, data) } } diff --git a/internal/app/log.go b/internal/app/log.go index b8ca4aa5..9ec89a2c 100644 --- a/internal/app/log.go +++ b/internal/app/log.go @@ -6,6 +6,7 @@ import ( "strings" "sync" + "github.com/AlexxIT/go2rtc/pkg/creds" "github.com/mattn/go-isatty" "github.com/rs/zerolog" ) @@ -88,6 +89,8 @@ func initLogger() { writer = MemoryLog } + writer = creds.SecretWriter(writer) + lvl, _ := zerolog.ParseLevel(modules["level"]) Logger = zerolog.New(writer).Level(lvl) diff --git a/internal/app/secrets.go b/internal/app/secrets.go deleted file mode 100644 index e1ce7509..00000000 --- a/internal/app/secrets.go +++ /dev/null @@ -1,136 +0,0 @@ -package app - -import ( - "sync" - - "github.com/AlexxIT/go2rtc/pkg/secrets" - "github.com/AlexxIT/go2rtc/pkg/yaml" -) - -var ( - secretsMap = make(map[string]*Secret) - secretsMu sync.Mutex -) - -// SecretsManager implements secrets.SecretsManager interface -type SecretsManager struct{} - -func (m *SecretsManager) NewSecret(name string, values interface{}) (secrets.Secret, error) { - secretsMu.Lock() - defer secretsMu.Unlock() - - if s, exists := secretsMap[name]; exists { - return s, nil - } - - s := &Secret{Name: name, Values: make(map[string]string)} - - data, err := yaml.Encode(values, 2) - if err != nil { - return nil, err - } - - if err := yaml.Unmarshal(data, &s.Values); err != nil { - return nil, err - } - - secretsMap[name] = s - - return s, nil -} - -func (m *SecretsManager) GetSecret(name string) secrets.Secret { - secretsMu.Lock() - defer secretsMu.Unlock() - return secretsMap[name] -} - -// Secret implements secrets.Secret interface -type Secret struct { - Name string - Values map[string]string -} - -func (s *Secret) Get(key string) any { - secretsMu.Lock() - defer secretsMu.Unlock() - - if s.Values == nil { - return nil - } - - return s.Values[key] -} - -func (s *Secret) Set(key string, value string) { - secretsMu.Lock() - defer secretsMu.Unlock() - - if s.Values == nil { - s.Values = make(map[string]string) - } - - s.Values[key] = value -} - -func (s *Secret) Marshal() (interface{}, error) { - secretsMu.Lock() - defer secretsMu.Unlock() - - if s.Values == nil { - return make(map[string]any), nil - } - - return s.Values, nil -} - -func (s *Secret) Unmarshal(value any) error { - secretsMu.Lock() - defer secretsMu.Unlock() - - if s.Values == nil { - s.Values = make(map[string]string) - } - - data, err := yaml.Encode(value, 2) - if err != nil { - return err - } - - if err := yaml.Unmarshal(data, value); err != nil { - return err - } - - return nil -} - -func (s *Secret) Save() error { - secretsMu.Lock() - defer secretsMu.Unlock() - return PatchConfig([]string{"secrets", s.Name}, s.Values) -} - -func initSecrets() { - var cfg struct { - Secrets map[string]map[string]string `yaml:"secrets"` - } - - LoadConfig(&cfg) - - if cfg.Secrets == nil { - return - } - - secretsMu.Lock() - defer secretsMu.Unlock() - - for name, values := range cfg.Secrets { - secretsMap[name] = &Secret{ - Name: name, - Values: values, - } - } - - // Register - secrets.SetManager(&SecretsManager{}) -} diff --git a/internal/app/storage.go b/internal/app/storage.go new file mode 100644 index 00000000..cfa1ca91 --- /dev/null +++ b/internal/app/storage.go @@ -0,0 +1,56 @@ +package app + +import ( + "sync" + + "github.com/AlexxIT/go2rtc/pkg/creds" + "github.com/AlexxIT/go2rtc/pkg/yaml" +) + +func initStorage() { + storage = &envStorage{data: make(map[string]string)} + creds.SetStorage(storage) +} + +func loadEnv(data []byte) { + var cfg struct { + Env map[string]string `yaml:"env"` + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return + } + + storage.mu.Lock() + for name, value := range cfg.Env { + storage.data[name] = value + creds.AddSecret(value) + } + storage.mu.Unlock() +} + +var storage *envStorage + +type envStorage struct { + data map[string]string + mu sync.Mutex +} + +func (s *envStorage) SetValue(name, value string) error { + if err := PatchConfig([]string{"env", name}, value); err != nil { + return err + } + + s.mu.Lock() + s.data[name] = value + s.mu.Unlock() + + return nil +} + +func (s *envStorage) GetValue(name string) (value string, ok bool) { + s.mu.Lock() + value, ok = s.data[name] + s.mu.Unlock() + return +} diff --git a/internal/echo/echo.go b/internal/echo/echo.go index df88dd64..fb105cec 100644 --- a/internal/echo/echo.go +++ b/internal/echo/echo.go @@ -22,7 +22,7 @@ func Init() { b = bytes.TrimSpace(b) - log.Debug().Str("url", shell.Redact(url)).Msgf("[echo] %s", b) + log.Debug().Str("url", url).Msgf("[echo] %s", b) return string(b), nil }) diff --git a/internal/expr/expr.go b/internal/expr/expr.go index 4d8aa5ce..8fd6c9c2 100644 --- a/internal/expr/expr.go +++ b/internal/expr/expr.go @@ -6,7 +6,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/expr" - "github.com/AlexxIT/go2rtc/pkg/shell" ) func Init() { @@ -18,7 +17,7 @@ func Init() { return "", err } - log.Debug().Msgf("[expr] url=%s", shell.Redact(url)) + log.Debug().Msgf("[expr] url=%s", url) if url = v.(string); url == "" { return "", errors.New("expr: result is empty") diff --git a/internal/hls/hls.go b/internal/hls/hls.go index 2344b62e..5c136450 100644 --- a/internal/hls/hls.go +++ b/internal/hls/hls.go @@ -12,7 +12,6 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/mpegts" - "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/rs/zerolog" ) @@ -143,7 +142,7 @@ func handlerSegmentTS(w http.ResponseWriter, r *http.Request) { data := session.Segment() if data == nil { - log.Warn().Msgf("[hls] can't get segment %s", shell.Redact(r.URL.RawQuery)) + log.Warn().Msgf("[hls] can't get segment %s", r.URL.RawQuery) http.NotFound(w, r) return } @@ -173,7 +172,7 @@ func handlerInit(w http.ResponseWriter, r *http.Request) { data := session.Init() if data == nil { - log.Warn().Msgf("[hls] can't get init %s", shell.Redact(r.URL.RawQuery)) + log.Warn().Msgf("[hls] can't get init %s", r.URL.RawQuery) http.NotFound(w, r) return } @@ -207,7 +206,7 @@ func handlerSegmentMP4(w http.ResponseWriter, r *http.Request) { data := session.Segment() if data == nil { - log.Warn().Msgf("[hls] can't get segment %s", shell.Redact(r.URL.RawQuery)) + log.Warn().Msgf("[hls] can't get segment %s", r.URL.RawQuery) http.NotFound(w, r) return } diff --git a/internal/onvif/onvif.go b/internal/onvif/onvif.go index 36b3843c..6dfa633a 100644 --- a/internal/onvif/onvif.go +++ b/internal/onvif/onvif.go @@ -15,7 +15,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/onvif" - "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/rs/zerolog" ) @@ -166,12 +165,12 @@ func apiOnvif(w http.ResponseWriter, r *http.Request) { for _, rawURL := range urls { u, err := url.Parse(rawURL) if err != nil { - log.Warn().Str("url", shell.Redact(rawURL)).Msg("[onvif] broken") + log.Warn().Str("url", rawURL).Msg("[onvif] broken") continue } if u.Scheme != "http" { - log.Warn().Str("url", shell.Redact(rawURL)).Msg("[onvif] unsupported") + log.Warn().Str("url", rawURL).Msg("[onvif] unsupported") continue } diff --git a/internal/streams/api.go b/internal/streams/api.go index 061e61c2..189178b6 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -5,10 +5,13 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/creds" "github.com/AlexxIT/go2rtc/pkg/probe" ) func apiStreams(w http.ResponseWriter, r *http.Request) { + w = creds.SecretResponse(w) + query := r.URL.Query() src := query.Get("src") @@ -120,5 +123,7 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { } dot = append(dot, '}') + dot = []byte(creds.SecretString(string(dot))) + api.Response(w, dot, "text/vnd.graphviz") } diff --git a/internal/streams/dot.go b/internal/streams/dot.go index 2c357f77..e0417972 100644 --- a/internal/streams/dot.go +++ b/internal/streams/dot.go @@ -4,8 +4,6 @@ import ( "encoding/json" "fmt" "strings" - - "github.com/AlexxIT/go2rtc/pkg/shell" ) func AppendDOT(dot []byte, stream *Stream) []byte { @@ -168,7 +166,7 @@ func (c *conn) label() string { sb.WriteString("\nsource=" + c.Source) } if c.URL != "" { - sb.WriteString("\nurl=" + shell.Redact(c.URL)) + sb.WriteString("\nurl=" + c.URL) } if c.UserAgent != "" { sb.WriteString("\nuser_agent=" + c.UserAgent) diff --git a/internal/streams/producer.go b/internal/streams/producer.go index 4260198e..09e2dcc5 100644 --- a/internal/streams/producer.go +++ b/internal/streams/producer.go @@ -8,7 +8,6 @@ import ( "time" "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/shell" ) type state byte @@ -150,7 +149,7 @@ func (p *Producer) start() { return } - log.Debug().Msgf("[streams] start producer url=%s", shell.Redact(p.url)) + log.Debug().Msgf("[streams] start producer url=%s", p.url) p.state = stateStart p.workerID++ @@ -168,7 +167,7 @@ func (p *Producer) worker(conn core.Producer, workerID int) { return } - log.Warn().Err(err).Str("url", shell.Redact(p.url)).Caller().Send() + log.Warn().Err(err).Str("url", p.url).Caller().Send() } p.reconnect(workerID, 0) @@ -179,11 +178,11 @@ func (p *Producer) reconnect(workerID, retry int) { defer p.mu.Unlock() if p.workerID != workerID { - log.Trace().Msgf("[streams] stop reconnect url=%s", shell.Redact(p.url)) + log.Trace().Msgf("[streams] stop reconnect url=%s", p.url) return } - log.Debug().Msgf("[streams] retry=%d to url=%s", retry, shell.Redact(p.url)) + log.Debug().Msgf("[streams] retry=%d to url=%s", retry, p.url) conn, err := GetProducer(p.url) if err != nil { @@ -258,7 +257,7 @@ func (p *Producer) stop() { p.workerID++ } - log.Debug().Msgf("[streams] stop producer url=%s", shell.Redact(p.url)) + log.Debug().Msgf("[streams] stop producer url=%s", p.url) if p.conn != nil { _ = p.conn.Stop() diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 8731ae68..dcbaba28 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -9,7 +9,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" - "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/rs/zerolog" ) @@ -128,7 +127,7 @@ func GetOrPatch(query url.Values) *Stream { // check if name param provided if name := query.Get("name"); name != "" { - log.Info().Msgf("[streams] create new stream url=%s", shell.Redact(source)) + log.Info().Msgf("[streams] create new stream url=%s", source) return Patch(name, source) } diff --git a/pkg/creds/README.md b/pkg/creds/README.md new file mode 100644 index 00000000..1909a206 --- /dev/null +++ b/pkg/creds/README.md @@ -0,0 +1,7 @@ +# Credentials + +This module allows you to get variables: + +- from custom storage (ex. config file) +- from [credential files](https://systemd.io/CREDENTIALS/) +- from environment variables diff --git a/pkg/creds/creds.go b/pkg/creds/creds.go new file mode 100644 index 00000000..84bc275a --- /dev/null +++ b/pkg/creds/creds.go @@ -0,0 +1,79 @@ +package creds + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "strings" +) + +type Storage interface { + SetValue(name, value string) error + GetValue(name string) (string, bool) +} + +var storage Storage + +func SetStorage(s Storage) { + storage = s +} + +func SetValue(name, value string) error { + if storage == nil { + return errors.New("credentials: storage not initialized") + } + if err := storage.SetValue(name, value); err != nil { + return err + } + AddSecret(value) + return nil +} + +func GetValue(name string) (value string, ok bool) { + value, ok = getValue(name) + AddSecret(value) + return +} + +func getValue(name string) (string, bool) { + if storage != nil { + if value, ok := storage.GetValue(name); ok { + return value, true + } + } + + if dir, ok := os.LookupEnv("CREDENTIALS_DIRECTORY"); ok { + if value, _ := os.ReadFile(filepath.Join(dir, name)); value != nil { + return strings.TrimSpace(string(value)), true + } + } + + return os.LookupEnv(name) +} + +// ReplaceVars - support format ${CAMERA_PASSWORD} and ${RTSP_USER:admin} +func ReplaceVars(data []byte) []byte { + re := regexp.MustCompile(`\${([^}{]+)}`) + return re.ReplaceAllFunc(data, func(match []byte) []byte { + key := string(match[2 : len(match)-1]) + + var def string + var defok bool + + if i := strings.IndexByte(key, ':'); i > 0 { + key, def = key[:i], key[i+1:] + defok = true + } + + if value, ok := GetValue(key); ok { + return []byte(value) + } + + if defok { + return []byte(def) + } + + return match + }) +} diff --git a/pkg/creds/secrets.go b/pkg/creds/secrets.go new file mode 100644 index 00000000..a9a0094e --- /dev/null +++ b/pkg/creds/secrets.go @@ -0,0 +1,83 @@ +package creds + +import ( + "io" + "net/http" + "slices" + "strings" + "sync" +) + +func AddSecret(value string) { + if value == "" { + return + } + + secretsMu.Lock() + defer secretsMu.Unlock() + + if slices.Contains(secrets, value) { + return + } + + secrets = append(secrets, value) + secretsReplacer = nil +} + +var secrets []string +var secretsMu sync.Mutex +var secretsReplacer *strings.Replacer + +func getReplacer() *strings.Replacer { + secretsMu.Lock() + defer secretsMu.Unlock() + + if secretsReplacer == nil { + oldnew := make([]string, 0, 2*len(secrets)) + for _, s := range secrets { + oldnew = append(oldnew, s, "***") + } + secretsReplacer = strings.NewReplacer(oldnew...) + } + + return secretsReplacer +} + +func SecretString(s string) string { + re := getReplacer() + return re.Replace(s) +} + +func SecretWriter(w io.Writer) io.Writer { + return &secretWriter{w} +} + +type secretWriter struct { + w io.Writer +} + +func (s *secretWriter) Write(b []byte) (int, error) { + re := getReplacer() + return re.WriteString(s.w, string(b)) +} + +type secretResponse struct { + w http.ResponseWriter +} + +func (s *secretResponse) Header() http.Header { + return s.w.Header() +} + +func (s *secretResponse) Write(b []byte) (int, error) { + re := getReplacer() + return re.WriteString(s.w, string(b)) +} + +func (s *secretResponse) WriteHeader(statusCode int) { + s.w.WriteHeader(statusCode) +} + +func SecretResponse(w http.ResponseWriter) http.ResponseWriter { + return &secretResponse{w} +} diff --git a/pkg/creds/secrets_test.go b/pkg/creds/secrets_test.go new file mode 100644 index 00000000..83f1908a --- /dev/null +++ b/pkg/creds/secrets_test.go @@ -0,0 +1,15 @@ +package creds + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestString(t *testing.T) { + AddSecret("admin") + AddSecret("pa$$word") + + s := SecretString("rtsp://admin:pa$$word@192.168.1.123/stream1") + require.Equal(t, "rtsp://***:***@192.168.1.123/stream1", s) +} diff --git a/pkg/secrets/secrets.go b/pkg/secrets/secrets.go deleted file mode 100644 index 071d9526..00000000 --- a/pkg/secrets/secrets.go +++ /dev/null @@ -1,44 +0,0 @@ -package secrets - -import ( - "errors" - "sync" -) - -type SecretsManager interface { - NewSecret(name string, defaultValues interface{}) (Secret, error) - GetSecret(name string) Secret -} - -type Secret interface { - Get(key string) any - Set(key string, value string) - Marshal() (interface{}, error) - Unmarshal(value any) error - Save() error -} - -var manager SecretsManager -var once sync.Once - -func SetManager(m SecretsManager) { - once.Do(func() { - manager = m - }) -} - -// NewSecret creates or retrieves a secret -func NewSecret(name string, defaultValues interface{}) (Secret, error) { - if manager == nil { - return nil, errors.New("secrets manager not initialized") - } - return manager.NewSecret(name, defaultValues) -} - -// GetSecret retrieves an existing secret -func GetSecret(name string) Secret { - if manager == nil { - return nil - } - return manager.GetSecret(name) -} diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index 64931a91..e04a58c4 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -1,22 +1,10 @@ package shell import ( - "fmt" "os" "os/signal" - "path/filepath" - "regexp" "strings" - "sync" "syscall" - - "github.com/AlexxIT/go2rtc/pkg/yaml" -) - -var ( - secretReplacer *strings.Replacer - secretValues map[string]bool - secretMutex sync.RWMutex ) func QuoteSplit(s string) []string { @@ -48,139 +36,8 @@ func QuoteSplit(s string) []string { return a } -// ReplaceEnvVars - support format ${CAMERA_PASSWORD} and ${RTSP_USER:admin} -func ReplaceEnvVars(text string) string { - var cfg struct { - Env map[string]string `yaml:"env"` - Secrets map[string]map[string]string `yaml:"secrets"` - } - - yaml.Unmarshal([]byte(text), &cfg) - - buildSecretReplacer(cfg) - - re := regexp.MustCompile(`\${([^}{]+)}`) - return re.ReplaceAllStringFunc(text, func(match string) string { - key := match[2 : len(match)-1] - - var def string - var dok bool - - if i := strings.IndexByte(key, ':'); i > 0 { - key, def = key[:i], key[i+1:] - dok = true - } - - if dir, vok := os.LookupEnv("CREDENTIALS_DIRECTORY"); vok { - value, err := os.ReadFile(filepath.Join(dir, key)) - if err == nil { - return strings.TrimSpace(string(value)) - } - } - - if value, vok := os.LookupEnv(key); vok { - return value - } - - if cfg.Env != nil { - if value, ok := cfg.Env[key]; ok { - return value - } - } - - if cfg.Secrets != nil { - for secretName, secretValues := range cfg.Secrets { - for k, v := range secretValues { - name := fmt.Sprintf("%s_%s", secretName, k) - if key == name { - return v - } - } - } - } - - if dok { - return def - } - - return match - }) -} - func RunUntilSignal() { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) println("exit with signal:", (<-sigs).String()) } - -func Redact(text string) string { - secretMutex.RLock() - defer secretMutex.RUnlock() - - if secretReplacer == nil { - return text - } - - return secretReplacer.Replace(text) -} - -func buildSecretReplacer(cfg struct { - Env map[string]string `yaml:"env"` - Secrets map[string]map[string]string `yaml:"secrets"` -}) { - secretMutex.Lock() - defer secretMutex.Unlock() - - if secretValues == nil { - secretValues = make(map[string]bool) - } - - var newSecrets []string - - if dir, ok := os.LookupEnv("CREDENTIALS_DIRECTORY"); ok { - entries, err := os.ReadDir(dir) - if err == nil { - for _, entry := range entries { - if !entry.IsDir() { - value, err := os.ReadFile(filepath.Join(dir, entry.Name())) - if err == nil { - cleanValue := strings.TrimSpace(string(value)) - if len(cleanValue) > 0 && !secretValues[cleanValue] { - secretValues[cleanValue] = true - newSecrets = append(newSecrets, cleanValue) - } - } - } - } - } - } - - if cfg.Secrets != nil { - for _, secretMap := range cfg.Secrets { - for _, value := range secretMap { - if len(value) > 0 && !secretValues[value] { - secretValues[value] = true - newSecrets = append(newSecrets, value) - } - } - } - } - - if len(newSecrets) > 0 { - rebuildReplacer() - } -} - -func rebuildReplacer() { - var replacements []string - - for secret := range secretValues { - replacements = append(replacements, secret, "*****") - } - - if len(replacements) > 0 { - secretReplacer = strings.NewReplacer(replacements...) - } else { - secretReplacer = nil - } -} From fde1fdc592832dcdfa360eb8762a0baba0b4436f Mon Sep 17 00:00:00 2001 From: Alex X Date: Thu, 9 Oct 2025 21:07:20 +0300 Subject: [PATCH 37/41] Code refactoring for #1758 --- pkg/rtsp/client.go | 325 +++++++++++++------------------------------ pkg/rtsp/conn.go | 218 ++++++++--------------------- pkg/rtsp/consumer.go | 38 +++-- pkg/rtsp/ports.go | 75 ---------- 4 files changed, 178 insertions(+), 478 deletions(-) delete mode 100644 pkg/rtsp/ports.go diff --git a/pkg/rtsp/client.go b/pkg/rtsp/client.go index 3d1bb0df..4e891213 100644 --- a/pkg/rtsp/client.go +++ b/pkg/rtsp/client.go @@ -2,7 +2,6 @@ package rtsp import ( "bufio" - "encoding/binary" "errors" "fmt" "net" @@ -10,6 +9,7 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "github.com/AlexxIT/go2rtc/pkg/tcp/websocket" @@ -26,13 +26,7 @@ func NewClient(uri string) *Conn { ID: core.NewID(), FormatName: "rtsp", }, - uri: uri, - udpRtpConns: make(map[byte]*UDPConnection), - udpRtcpConns: make(map[byte]*UDPConnection), - udpRtpListeners: make(map[byte]*UDPConnection), - udpRtcpListeners: make(map[byte]*UDPConnection), - portToChannel: make(map[int]byte), - channelCounter: 0, + uri: uri, } } @@ -43,10 +37,13 @@ func (c *Conn) Dial() (err error) { var conn net.Conn - if c.Transport == "" || c.Transport == "tcp" || c.Transport == "udp" { - timeout := core.ConnDialTimeout + switch c.Transport { + case "", "tcp", "udp": + var timeout time.Duration if c.Timeout != 0 { timeout = time.Second * time.Duration(c.Timeout) + } else { + timeout = core.ConnDialTimeout } conn, err = tcp.Dial(c.URL, timeout) @@ -55,7 +52,7 @@ func (c *Conn) Dial() (err error) { } else { c.Protocol = "rtsp+udp" } - } else { + default: conn, err = websocket.Dial(c.Transport) c.Protocol = "ws" } @@ -73,6 +70,9 @@ func (c *Conn) Dial() (err error) { c.sequence = 0 c.state = StateConn + c.udpConn = nil + c.udpAddr = nil + c.Connection.RemoteAddr = conn.RemoteAddr().String() c.Connection.Transport = conn c.Connection.URL = c.uri @@ -229,63 +229,35 @@ func (c *Conn) Record() (err error) { func (c *Conn) SetupMedia(media *core.Media) (byte, error) { var transport string - var mediaIndex int = -1 - - // try to use media position as channel number - for i, m := range c.Medias { - if m.Equal(media) { - mediaIndex = i - break - } - } - - if mediaIndex == -1 { - return 0, fmt.Errorf("wrong media: %v", media) - } if c.Transport == "udp" { - transport, err := c.setupUDPTransport() + conn1, conn2, err := ListenUDPPair() if err != nil { return 0, err } - return c.sendSetupRequest(media, transport) + c.udpConn = append(c.udpConn, conn1, conn2) + + port := conn1.LocalAddr().(*net.UDPAddr).Port + transport = fmt.Sprintf("RTP/AVP;unicast;client_port=%d-%d", port, port+1) + } else { + // try to use media position as channel number + for i, m := range c.Medias { + if m.Equal(media) { + transport = fmt.Sprintf( + // i - RTP (data channel) + // i+1 - RTCP (control channel) + "RTP/AVP/TCP;unicast;interleaved=%d-%d", i*2, i*2+1, + ) + break + } + } } - transport = c.setupTCPTransport(mediaIndex) - return c.sendSetupRequest(media, transport) -} - -func (c *Conn) setupTCPTransport(mediaIndex int) string { - channel := byte(mediaIndex * 2) - transport := fmt.Sprintf("RTP/AVP/TCP;unicast;interleaved=%d-%d", channel, channel+1) - return transport -} - -func (c *Conn) setupUDPTransport() (string, error) { - portPair, err := GetUDPPorts(nil, 10) - if err != nil { - return "", err + if transport == "" { + return 0, fmt.Errorf("wrong media: %v", media) } - rtpChannel := c.getChannelForPort(portPair.RTPPort) - rtcpChannel := c.getChannelForPort(portPair.RTCPPort) - - c.udpRtpListeners[rtpChannel] = &UDPConnection{ - Conn: *portPair.RTPListener, - Channel: rtpChannel, - } - - c.udpRtcpListeners[rtcpChannel] = &UDPConnection{ - Conn: *portPair.RTCPListener, - Channel: rtcpChannel, - } - - transport := fmt.Sprintf("RTP/AVP;unicast;client_port=%d-%d", portPair.RTPPort, portPair.RTCPPort) - return transport, nil -} - -func (c *Conn) sendSetupRequest(media *core.Media, transport string) (byte, error) { rawURL := media.ID // control if !strings.Contains(rawURL, "://") { rawURL = c.URL.String() @@ -339,109 +311,48 @@ func (c *Conn) sendSetupRequest(media *core.Media, transport string) (byte, erro } // Parse server response - responseTransport := res.Header.Get("Transport") + transport = res.Header.Get("Transport") if c.Transport == "udp" { - // Parse UDP response: client_ports=1234-1235;server_port=1234-1235 - var clientPorts []int - var serverPorts []int + channel := byte(len(c.udpConn) - 2) - if strings.Contains(transport, "client_port=") { - parts := strings.Split(responseTransport, "client_port=") - if len(parts) > 1 { - portPart := strings.Split(strings.Split(parts[1], ";")[0], "-") - for _, p := range portPart { - if port, err := strconv.Atoi(p); err == nil { - clientPorts = append(clientPorts, port) - } - } + // Dahua: RTP/AVP/UDP;unicast;client_port=49292-49293;server_port=43670-43671;ssrc=7CB694B4 + // OpenIPC: RTP/AVP/UDP;unicast;client_port=59612-59613 + if s := core.Between(transport, "server_port=", ";"); s != "" { + s1, s2, _ := strings.Cut(s, "-") + port1 := core.Atoi(s1) + port2 := core.Atoi(s2) + // TODO: more smart handling empty server ports + if port1 > 0 && port2 > 0 { + remoteIP := c.conn.RemoteAddr().(*net.TCPAddr).IP + c.udpAddr = append(c.udpAddr, + &net.UDPAddr{IP: remoteIP, Port: port1}, + &net.UDPAddr{IP: remoteIP, Port: port2}, + ) + + go func() { + // Try to open a hole in the NAT router (to allow incoming UDP packets) + // by send a UDP packet for RTP and RTCP to the remote RTSP server. + // https://github.com/FFmpeg/FFmpeg/blob/aa91ae25b88e195e6af4248e0ab30605735ca1cd/libavformat/rtpdec.c#L416-L438 + _, _ = c.WriteToUDP([]byte{0x80, 0x00, 0x00, 0x00}, channel) + _, _ = c.WriteToUDP([]byte{0x80, 0xC8, 0x00, 0x01}, channel+1) + }() } } - if strings.Contains(responseTransport, "server_port=") { - parts := strings.Split(responseTransport, "server_port=") - if len(parts) > 1 { - portPart := strings.Split(strings.Split(parts[1], ";")[0], "-") - for _, p := range portPart { - if port, err := strconv.Atoi(p); err == nil { - serverPorts = append(serverPorts, port) - } - } - } - } - - // Create UDP connections for RTP and RTCP if we have both server ports - if len(serverPorts) >= 2 { - if host, _, err := net.SplitHostPort(c.Connection.RemoteAddr); err == nil { - rtpServerPort := serverPorts[0] - rtcpServerPort := serverPorts[1] - - cleanHost := host - if strings.Contains(cleanHost, ":") { - cleanHost = fmt.Sprintf("[%s]", host) - } - - remoteRtpAddr := fmt.Sprintf("%s:%d", cleanHost, rtpServerPort) - remoteRtcpAddr := fmt.Sprintf("%s:%d", cleanHost, rtcpServerPort) - - if rtpAddr, err := net.ResolveUDPAddr("udp", remoteRtpAddr); err == nil { - if rtpConn, err := net.DialUDP("udp", nil, rtpAddr); err == nil { - channel := c.getChannelForPort(rtpServerPort) - c.udpRtpConns[channel] = &UDPConnection{ - Conn: *rtpConn, - Channel: channel, - } - } - } - - if rtcpAddr, err := net.ResolveUDPAddr("udp", remoteRtcpAddr); err == nil { - if rtcpConn, err := net.DialUDP("udp", nil, rtcpAddr); err == nil { - channel := c.getChannelForPort(rtcpServerPort) - c.udpRtcpConns[channel] = &UDPConnection{ - Conn: *rtcpConn, - Channel: channel, - } - } - } - } - } - - // Try to open a hole in the NAT router (to allow incoming UDP packets) - // by send a UDP packet for RTP and RTCP to the remote RTSP server. - go c.tryHolePunching(clientPorts, serverPorts) - - var rtpPort string - if media.Direction == core.DirectionRecvonly { - rtpPort = core.Between(transport, "client_port=", "-") - } else { - rtpPort = core.Between(responseTransport, "server_port=", "-") - } - - i, err := strconv.Atoi(rtpPort) - if err != nil { - return 0, err - } - - return c.getChannelForPort(i), nil - + return channel, nil } else { // we send our `interleaved`, but camera can answer with another // Transport: RTP/AVP/TCP;unicast;interleaved=10-11;ssrc=10117CB7 // Transport: RTP/AVP/TCP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0 // Transport: RTP/AVP/TCP;ssrc=22345682;interleaved=0-1 - if !strings.HasPrefix(responseTransport, "RTP/AVP/TCP;") { - // Escam Q6 has a bug: - // Transport: RTP/AVP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0-1 - if !strings.Contains(responseTransport, ";interleaved=") { - return 0, fmt.Errorf("wrong transport: %s", responseTransport) - } - } - - channel := core.Between(responseTransport, "interleaved=", "-") - i, err := strconv.Atoi(channel) + // Escam Q6 has a bug: + // Transport: RTP/AVP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0-1 + s := core.Between(transport, "interleaved=", "-") + i, err := strconv.Atoi(s) if err != nil { - return 0, err + return 0, fmt.Errorf("wrong transport: %s", transport) } return byte(i), nil @@ -460,106 +371,62 @@ func (c *Conn) Teardown() (err error) { } func (c *Conn) Close() error { - c.closeUDP() - if c.mode == core.ModeActiveProducer { _ = c.Teardown() } - if c.OnClose != nil { _ = c.OnClose() } - + for _, conn := range c.udpConn { + _ = conn.Close() + } return c.conn.Close() } -func (c *Conn) closeUDP() { - for _, listener := range c.udpRtpListeners { - _ = listener.Conn.Close() - } - for _, listener := range c.udpRtcpListeners { - _ = listener.Conn.Close() - } - for _, conn := range c.udpRtpConns { - _ = conn.Conn.Close() - } - for _, conn := range c.udpRtcpConns { - _ = conn.Conn.Close() - } - - c.udpRtpListeners = make(map[byte]*UDPConnection) - c.udpRtcpListeners = make(map[byte]*UDPConnection) - c.udpRtpConns = make(map[byte]*UDPConnection) - c.udpRtcpConns = make(map[byte]*UDPConnection) - c.portToChannel = make(map[int]byte) - c.channelCounter = 0 +func (c *Conn) WriteToUDP(b []byte, channel byte) (int, error) { + return c.udpConn[channel].WriteToUDP(b, c.udpAddr[channel]) } -func (c *Conn) sendUDPRtpPacket(data []byte) error { - for len(data) >= 4 && data[0] == '$' { - channel := data[1] - size := binary.BigEndian.Uint16(data[2:4]) +const listenUDPAttemps = 10 - if len(data) < 4+int(size) { - return fmt.Errorf("incomplete RTP packet: %d < %d", len(data), 4+size) +var listenUDPMu sync.Mutex + +func ListenUDPPair() (*net.UDPConn, *net.UDPConn, error) { + listenUDPMu.Lock() + defer listenUDPMu.Unlock() + + for i := 0; i < listenUDPAttemps; i++ { + // Get a random even port from the OS + ln1, err := net.ListenUDP("udp", &net.UDPAddr{IP: nil, Port: 0}) + if err != nil { + continue } - // Send RTP data without interleaved header - rtpData := data[4 : 4+size] + var port1 = ln1.LocalAddr().(*net.UDPAddr).Port + var port2 int - if conn, ok := c.udpRtpConns[channel]; ok { - if err := conn.Conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { - return nil - } - - if _, err := conn.Conn.Write(rtpData); err != nil { - return err - } + // 11. RTP over Network and Transport Protocols (https://www.ietf.org/rfc/rfc3550.txt) + // For UDP and similar protocols, + // RTP SHOULD use an even destination port number and the corresponding + // RTCP stream SHOULD use the next higher (odd) destination port number + if port1&1 > 0 { + port2 = port1 - 1 + } else { + port2 = port1 + 1 } - data = data[4+size:] // Move to next packet - } + ln2, err := net.ListenUDP("udp", &net.UDPAddr{IP: nil, Port: port2}) + if err != nil { + _ = ln1.Close() + continue + } - return nil -} - -func (c *Conn) tryHolePunching(clientPorts, serverPorts []int) { - if len(clientPorts) < 2 || len(serverPorts) < 2 { - return - } - - host, _, _ := net.SplitHostPort(c.Connection.RemoteAddr) - if strings.Contains(host, ":") { - host = fmt.Sprintf("[%s]", host) - } - - // RTP hole punch - if rtpListener, ok := c.udpRtpListeners[c.getChannelForPort(clientPorts[0])]; ok { - if addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, serverPorts[0])); err == nil { - rtpListener.Conn.WriteToUDP([]byte{0x80, 0x00, 0x00, 0x00}, addr) + if port1 < port2 { + return ln1, ln2, nil + } else { + return ln2, ln1, nil } } - // RTCP hole punch - if rtcpListener, ok := c.udpRtcpListeners[c.getChannelForPort(clientPorts[1])]; ok { - if addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, serverPorts[1])); err == nil { - rtcpListener.Conn.WriteToUDP([]byte{0x80, 0xC8, 0x00, 0x01}, addr) - } - } -} - -func (c *Conn) getChannelForPort(port int) byte { - if channel, exists := c.portToChannel[port]; exists { - return channel - } - - c.channelCounter++ - if c.channelCounter == 0 { - c.channelCounter = 1 - } - - channel := c.channelCounter - c.portToChannel[port] = channel - - return channel + return nil, nil, fmt.Errorf("can't open two UDP ports") } diff --git a/pkg/rtsp/conn.go b/pkg/rtsp/conn.go index ddb15a74..2984c781 100644 --- a/pkg/rtsp/conn.go +++ b/pkg/rtsp/conn.go @@ -2,6 +2,7 @@ package rtsp import ( "bufio" + "context" "encoding/binary" "fmt" "io" @@ -13,7 +14,6 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/tcp" - "github.com/pion/rtcp" "github.com/pion/rtp" ) @@ -49,27 +49,10 @@ type Conn struct { state State stateMu sync.Mutex - // UDP - - udpRtpConns map[byte]*UDPConnection - udpRtcpConns map[byte]*UDPConnection - udpRtpListeners map[byte]*UDPConnection - udpRtcpListeners map[byte]*UDPConnection - portToChannel map[int]byte - channelCounter byte + udpConn []*net.UDPConn + udpAddr []*net.UDPAddr } -type UDPConnection struct { - Conn net.UDPConn - Channel byte -} - -type TransportMode int - -const ( - ReceiveMTU = 1500 -) - const ( ProtoRTSP = "RTSP/1.0" MethodOptions = "OPTIONS" @@ -108,23 +91,25 @@ const ( func (c *Conn) Handle() (err error) { var timeout time.Duration - var keepaliveDT time.Duration - var keepaliveTS time.Time - switch c.mode { case core.ModeActiveProducer: + var keepaliveDT time.Duration + if c.keepalive > 5 { keepaliveDT = time.Duration(c.keepalive-5) * time.Second } else { keepaliveDT = 25 * time.Second } - keepaliveTS = time.Now().Add(keepaliveDT) + + ctx, cancel := context.WithCancel(context.Background()) + go c.handleKeepalive(ctx, keepaliveDT) + defer cancel() if c.Timeout == 0 { // polling frames from remote RTSP Server (ex Camera) timeout = time.Second * 5 - if len(c.Receivers) == 0 { + if len(c.Receivers) == 0 || c.Transport == "udp" { // if we only send audio to camera // https://github.com/AlexxIT/go2rtc/issues/659 timeout += keepaliveDT @@ -149,150 +134,58 @@ func (c *Conn) Handle() (err error) { return fmt.Errorf("wrong RTSP conn mode: %d", c.mode) } + for i := 0; i < len(c.udpConn); i++ { + go c.handleUDPData(byte(i)) + } + for c.state != StateNone { ts := time.Now() - time := ts.Add(timeout) - if err = c.conn.SetReadDeadline(time); err != nil { + _ = c.conn.SetReadDeadline(ts.Add(timeout)) + + if err = c.handleTCPData(); err != nil { return } - - if c.Transport == "udp" { - if err = c.handleUDPClientData(time); err != nil { - return err - } - } else { - if err = c.handleTCPClientData(); err != nil { - return err - } - } - - if keepaliveDT != 0 && ts.After(keepaliveTS) { - req := &tcp.Request{Method: MethodOptions, URL: c.URL} - if err = c.WriteRequest(req); err != nil { - return - } - - keepaliveTS = ts.Add(keepaliveDT) - } } return } -func (c *Conn) handleUDPClientData(time time.Time) error { - if c.playErr != nil { - return c.playErr - } - - if c.state == StatePlay && c.playOK { - return nil - } - - var buf4 []byte - - buf4, err := c.reader.Peek(4) - if err != nil { - return err - } - - switch string(buf4) { - case "RTSP": - var res *tcp.Response - if res, err = c.ReadResponse(); err != nil { - return err - } - - c.Fire(res) - c.playOK = true - - for _, listener := range c.udpRtpListeners { - go func(listener *UDPConnection) { - defer listener.Conn.Close() - - for c.state != StateNone { - if err := listener.Conn.SetReadDeadline(time); err != nil { - c.playErr = err - return - } - - buffer := make([]byte, ReceiveMTU) - n, _, err := listener.Conn.ReadFromUDP(buffer) - if err != nil { - c.playErr = err - break - } - - packet := &rtp.Packet{} - if err := packet.Unmarshal(buffer[:n]); err != nil { - c.playErr = err - return - } - - for _, receiver := range c.Receivers { - if receiver.ID == listener.Channel { - receiver.WriteRTP(packet) - break - } - } - - c.Recv += len(buffer[:n]) - } - }(listener) - } - - for _, listener := range c.udpRtcpListeners { - go func(listener *UDPConnection) { - defer listener.Conn.Close() - - for c.state != StateNone { - if err := listener.Conn.SetReadDeadline(time); err != nil { - return - } - - buffer := make([]byte, ReceiveMTU) - n, _, err := listener.Conn.ReadFromUDP(buffer) - if err != nil { - break - } - - msg := &RTCP{Channel: listener.Channel} - - if err := msg.Header.Unmarshal(buffer[:n]); err != nil { - continue - } - - msg.Packets, err = rtcp.Unmarshal(buffer[:n]) - if err != nil { - continue - } - - c.Fire(msg) - } - }(listener) - } - - case "OPTI", "TEAR", "DESC", "SETU", "PLAY", "PAUS", "RECO", "ANNO", "GET_", "SET_": - var req *tcp.Request - if req, err = c.ReadRequest(); err != nil { - return err - } - c.Fire(req) - if req.Method == MethodOptions { - res := &tcp.Response{Request: req} - if err = c.WriteResponse(res); err != nil { - return err +func (c *Conn) handleKeepalive(ctx context.Context, d time.Duration) { + ticker := time.NewTicker(d) + for { + select { + case <-ticker.C: + req := &tcp.Request{Method: MethodOptions, URL: c.URL} + if err := c.WriteRequest(req); err != nil { + return } + case <-ctx.Done(): + return } - - default: - return fmt.Errorf("RTSP wrong input") } - - return nil } -func (c *Conn) handleTCPClientData() error { +func (c *Conn) handleUDPData(channel byte) { + // TODO: handle timeouts and drop TCP connection after any error + conn := c.udpConn[channel] + + for { + // TP-Link Tapo camera has crazy 10000 bytes packet size + buf := make([]byte, 10240) + + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + return + } + + if err = c.handleRawPacket(channel, buf[:n]); err != nil { + return + } + } +} + +func (c *Conn) handleTCPData() error { // we can read: // 1. RTP interleaved: `$` + 1B channel number + 2B size // 2. RTSP response: RTSP/1.0 200 OK @@ -390,9 +283,13 @@ func (c *Conn) handleTCPClientData() error { c.Recv += int(size) + return c.handleRawPacket(channel, buf) +} + +func (c *Conn) handleRawPacket(channel byte, buf []byte) error { if channel&1 == 0 { packet := &rtp.Packet{} - if err = packet.Unmarshal(buf); err != nil { + if err := packet.Unmarshal(buf); err != nil { return err } @@ -405,14 +302,15 @@ func (c *Conn) handleTCPClientData() error { } else { msg := &RTCP{Channel: channel} - if err = msg.Header.Unmarshal(buf); err != nil { + if err := msg.Header.Unmarshal(buf); err != nil { return nil } - msg.Packets, err = rtcp.Unmarshal(buf) - if err != nil { - return nil - } + //var err error + //msg.Packets, err = rtcp.Unmarshal(buf) + //if err != nil { + // return nil + //} c.Fire(msg) } diff --git a/pkg/rtsp/consumer.go b/pkg/rtsp/consumer.go index fde2684c..e6525d96 100644 --- a/pkg/rtsp/consumer.go +++ b/pkg/rtsp/consumer.go @@ -86,21 +86,9 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core. flushBuf := func() { //log.Printf("[rtsp] channel:%2d write_size:%6d buffer_size:%6d", channel, n, len(buf)) - - if c.Transport == "udp" { - if err := c.sendUDPRtpPacket(buf[:n]); err == nil { - c.Send += n - } - } else { - if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { - return - } - - if _, err := c.conn.Write(buf[:n]); err == nil { - c.Send += n - } + if err := c.writeInterleavedData(buf[:n]); err != nil { + c.Send += n } - n = 0 } @@ -186,3 +174,25 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core. return handlerFunc } + +func (c *Conn) writeInterleavedData(data []byte) error { + if c.Transport != "udp" { + _ = c.conn.SetWriteDeadline(time.Now().Add(Timeout)) + _, err := c.conn.Write(data) + return err + } + + for len(data) >= 4 && data[0] == '$' { + channel := data[1] + size := uint16(data[2])<<8 | uint16(data[3]) + rtpData := data[4 : 4+size] + + if _, err := c.WriteToUDP(rtpData, channel); err != nil { + return err + } + + data = data[4+size:] + } + + return nil +} diff --git a/pkg/rtsp/ports.go b/pkg/rtsp/ports.go deleted file mode 100644 index d280ac6d..00000000 --- a/pkg/rtsp/ports.go +++ /dev/null @@ -1,75 +0,0 @@ -package rtsp - -import ( - "fmt" - "net" - "sync" -) - -var mu sync.Mutex - -type UDPPortPair struct { - RTPListener *net.UDPConn - RTCPListener *net.UDPConn - RTPPort int - RTCPPort int -} - -func (p *UDPPortPair) Close() { - if p.RTPListener != nil { - _ = p.RTPListener.Close() - } - if p.RTCPListener != nil { - _ = p.RTCPListener.Close() - } -} - -func GetUDPPorts(ip net.IP, maxAttempts int) (*UDPPortPair, error) { - mu.Lock() - defer mu.Unlock() - - if ip == nil { - ip = net.IPv4(0, 0, 0, 0) - } - - for i := 0; i < maxAttempts; i++ { - // Get a random even port from the OS - tempListener, err := net.ListenUDP("udp", &net.UDPAddr{IP: ip, Port: 0}) - if err != nil { - continue - } - - addr := tempListener.LocalAddr().(*net.UDPAddr) - basePort := addr.Port - tempListener.Close() - - // 11. RTP over Network and Transport Protocols (https://www.ietf.org/rfc/rfc3550.txt) - // For UDP and similar protocols, - // RTP SHOULD use an even destination port number and the corresponding - // RTCP stream SHOULD use the next higher (odd) destination port number - if basePort%2 == 1 { - basePort-- - } - - // Try to bind both ports - rtpListener, err := net.ListenUDP("udp", &net.UDPAddr{IP: ip, Port: basePort}) - if err != nil { - continue - } - - rtcpListener, err := net.ListenUDP("udp", &net.UDPAddr{IP: ip, Port: basePort + 1}) - if err != nil { - rtpListener.Close() - continue - } - - return &UDPPortPair{ - RTPListener: rtpListener, - RTCPListener: rtcpListener, - RTPPort: basePort, - RTCPPort: basePort + 1, - }, nil - } - - return nil, fmt.Errorf("failed to allocate consecutive UDP ports after %d attempts", maxAttempts) -} From 98f88d037e4f1662d8324e10cd07f0bd7b504b7c Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 10 Oct 2025 11:11:29 +0300 Subject: [PATCH 38/41] Remove UDP example from readme --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index d002de3a..cb3d6c8b 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,6 @@ Format: `rtsp...#{param1}#{param2}#{param3}` - Ignore audio - `#media=video` or ignore video - `#media=audio` - Ignore two-way audio API `#backchannel=0` - important for some glitchy cameras - Use WebSocket transport `#transport=ws...` -- Use UDP transport `#transport=udp` **RTSP over WebSocket** @@ -272,7 +271,6 @@ streams: axis-rtsp-ws: rtsp://192.168.1.123:4567/axis-media/media.amp?overview=0&camera=1&resolution=1280x720&videoframeskipmode=empty&Axis-Orig-Sw=true#transport=ws://user:pass@192.168.1.123:4567/rtsp-over-websocket # WebSocket without authorization, RTSP - with dahua-rtsp-ws: rtsp://user:pass@192.168.1.123/cam/realmonitor?channel=1&subtype=1&proto=Private3#transport=ws://192.168.1.123/rtspoverwebsocket - udp_camera: rtsp://user:pass@192.168.1.345:554/stream1#transport=udp ``` #### Source: RTMP From fdb3116027208b41cb4133bbccfa3fb82873fa18 Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 10 Oct 2025 11:08:05 +0300 Subject: [PATCH 39/41] Added checks for corrupted data to the H265 handler --- pkg/h265/rtp.go | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/pkg/h265/rtp.go b/pkg/h265/rtp.go index 72d2c02f..9c571ec5 100644 --- a/pkg/h265/rtp.go +++ b/pkg/h265/rtp.go @@ -14,6 +14,7 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { buf := make([]byte, 0, 512*1024) // 512K var nuStart int + var seqNum uint16 return func(packet *rtp.Packet) { data := packet.Payload @@ -34,9 +35,19 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { } } + // when we collect data into one buffer, we need to make sure + // that all of it falls into the same sequence + if len(buf) > 0 && packet.SequenceNumber-seqNum != 1 { + //log.Printf("broken H265 sequence") + buf = buf[:0] // drop data + return + } + + seqNum = packet.SequenceNumber + if nuType == NALUTypeFU { switch data[2] >> 6 { - case 2: // begin + case 0b10: // begin nuType = data[2] & 0x3F // push PS data before keyframe @@ -49,13 +60,30 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { buf = append(buf, (data[0]&0x81)|(nuType<<1), data[1]) buf = append(buf, data[3:]...) return - case 0: // continue + case 0b00: // continue + if len(buf) == 0 { + //log.Printf("broken H265 fragment") + return + } + buf = append(buf, data[3:]...) return - case 1: // end + case 0b01: // end + if len(buf) == 0 { + //log.Printf("broken H265 fragment") + return + } + buf = append(buf, data[3:]...) + + if nuStart > len(buf)+4 { + //log.Printf("broken H265 fragment") + buf = buf[:0] // drop data + return + } + binary.BigEndian.PutUint32(buf[nuStart:], uint32(len(buf)-nuStart-4)) - case 3: // wrong RFC 7798 realisation from OpenIPC project + case 0b11: // wrong RFC 7798 realisation from OpenIPC project // A non-fragmented NAL unit MUST NOT be transmitted in one FU; i.e., // the Start bit and End bit must not both be set to 1 in the same FU // header. @@ -65,10 +93,8 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { buf = append(buf, data[3:]...) } } else { - nuStart = len(buf) - buf = append(buf, 0, 0, 0, 0) // NAL unit size + buf = binary.BigEndian.AppendUint32(buf, uint32(len(data))) // NAL unit size buf = append(buf, data...) - binary.BigEndian.PutUint32(buf[nuStart:], uint32(len(data))) } // collect all NAL Units for Access Unit From 7291c03cea72f283a2eed92738b54692480fa8ba Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 10 Oct 2025 12:38:20 +0300 Subject: [PATCH 40/41] Code refactoring for #1589 --- pkg/onvif/client.go | 68 +++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 39 deletions(-) diff --git a/pkg/onvif/client.go b/pkg/onvif/client.go index 2d48e653..77bbe0ff 100644 --- a/pkg/onvif/client.go +++ b/pkg/onvif/client.go @@ -38,22 +38,20 @@ func NewClient(rawURL string) (*Client, error) { client.deviceURL = baseURL + u.Path } - // Set default media URL before trying to get capabilities - client.mediaURL = baseURL + "/onvif/media_service" - client.imaginURL = baseURL + "/onvif/imaging_service" - b, err := client.DeviceRequest(DeviceGetCapabilities) if err != nil { return nil, err } - // Update URLs if found in capabilities - if mediaAddr := FindTagValue(b, "Media.+?XAddr"); mediaAddr != "" { - client.mediaURL = mediaAddr - } - if imagingAddr := FindTagValue(b, "Imaging.+?XAddr"); imagingAddr != "" { - client.imaginURL = imagingAddr - } + client.mediaURL = FindTagValue(b, "Media.+?XAddr") + if client.mediaURL == "" { + client.mediaURL = baseURL + "/onvif/media_service" + } + + client.imaginURL = FindTagValue(b, "Imaging.+?XAddr") + if client.imaginURL == "" { + client.imaginURL = baseURL + "/onvif/imaging_service" + } return client, nil } @@ -198,57 +196,49 @@ func (c *Client) Request(rawUrl, body string) ([]byte, error) { return nil, err } - // Ensure we have a port host := u.Host - if !strings.Contains(host, ":") { - host = host + ":80" + if u.Port() == "" { + host += ":80" } - // Connect with timeout conn, err := net.DialTimeout("tcp", host, 5*time.Second) if err != nil { return nil, err } defer conn.Close() - // Send request - httpReq := fmt.Sprintf("POST %s HTTP/1.1\r\n"+ + reqBody := e.Bytes() + rawReq := fmt.Appendf(nil, "POST %s HTTP/1.1\r\n"+ "Host: %s\r\n"+ "Content-Type: application/soap+xml;charset=utf-8\r\n"+ "Content-Length: %d\r\n"+ "Connection: close\r\n"+ - "\r\n%s", u.Path, u.Host, len(e.Bytes()), e.Bytes()) + "\r\n", u.Path, u.Host, len(reqBody)) + rawReq = append(rawReq, reqBody...) - if _, err = conn.Write([]byte(httpReq)); err != nil { + if _, err = conn.Write(rawReq); err != nil { return nil, err } - // Read full response first - var fullResponse []byte - buf := make([]byte, 4096) - for { - n, err := conn.Read(buf) - if n > 0 { - fullResponse = append(fullResponse, buf[:n]...) - } - if err == io.EOF { - break - } - if err != nil { - return nil, err - } + rawRes, err := io.ReadAll(conn) + if err != nil { + return nil, err } // Look for XML in complete response - if idx := bytes.Index(fullResponse, []byte("= 0 { - return fullResponse[idx:], nil + if i := bytes.Index(rawRes, []byte(" 0 { + return rawRes[i:], nil } // No XML found - might be an error response - if idx := bytes.Index(fullResponse, []byte("\r\n\r\n")); idx >= 0 { + if i := bytes.Index(rawRes, []byte("\r\n\r\n")); i > 0 { + if bytes.Contains(rawRes[:i], []byte("chunked")) { + return nil, errors.New("onvif: TODO: support chunked encoding") + } + // Return body after headers - return fullResponse[idx+4:], nil + return rawRes[i+4:], nil } - return fullResponse, nil -} \ No newline at end of file + return rawRes, nil +} From ea23957f2a105bef4a9ad84f6c908e5bd3fb3dab Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 10 Oct 2025 17:22:18 +0300 Subject: [PATCH 41/41] Code refactoring for #1823 --- internal/webrtc/switchbot.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/internal/webrtc/switchbot.go b/internal/webrtc/switchbot.go index 7d2b290a..6f72e55d 100644 --- a/internal/webrtc/switchbot.go +++ b/internal/webrtc/switchbot.go @@ -3,8 +3,6 @@ package webrtc import ( "net/url" - "strconv" - "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" ) @@ -39,10 +37,7 @@ func switchbotClient(rawURL string, query url.Values) (core.Producer, error) { v.Resolution = 2 } - playtype, err := strconv.Atoi(query.Get("play_type")) - if err == nil { - v.PlayType = playtype - } + v.PlayType = core.Atoi(query.Get("play_type")) // zero by default return v, nil })