From d0ac99fc69f84ab433624663c746a9d085b0012d Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 10 Feb 2025 20:21:25 +0100 Subject: [PATCH 01/84] 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 7836f2e47f8895e49d1e3fcbc96b9ff638c88600 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 11 Mar 2025 01:50:41 +0100 Subject: [PATCH 02/84] check h265 --- www/video-rtc.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/www/video-rtc.js b/www/video-rtc.js index fb872b45..518e242f 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -583,7 +583,7 @@ export class VideoRTC extends HTMLElement { if (stream.getVideoTracks().length > 0) rtcPriority += 0x220; if (stream.getAudioTracks().length > 0) rtcPriority += 0x102; - if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230; + if (this.mseCodecs.indexOf('hvc1.') >= 0 && !VideoRTC.isH265Supported()) msePriority += 0x230; if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210; if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101; @@ -664,6 +664,23 @@ export class VideoRTC extends HTMLElement { this.send({type: 'mp4', value: this.codecs(this.video.canPlayType)}); } + static isH265Supported() { + try { + const videoCodecs = RTCRtpSender?.getCapabilities('video')?.codecs; + + if (!videoCodecs) { + return false; + } + + return videoCodecs.some(codec => + codec.mimeType.toLowerCase().includes('h265') || + codec.mimeType.toLowerCase().includes('hevc') + ); + } catch { + return false; + } + } + static btoa(buffer) { const bytes = new Uint8Array(buffer); const len = bytes.byteLength; From b28ffa9543e5d71bcfd1e45f012af96a41ae99c8 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 11 Mar 2025 01:52:16 +0100 Subject: [PATCH 03/84] indentation --- www/video-rtc.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/video-rtc.js b/www/video-rtc.js index 518e242f..143ee5a1 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -673,8 +673,8 @@ export class VideoRTC extends HTMLElement { } return videoCodecs.some(codec => - codec.mimeType.toLowerCase().includes('h265') || - codec.mimeType.toLowerCase().includes('hevc') + codec.mimeType.toLowerCase().includes('h265') || + codec.mimeType.toLowerCase().includes('hevc') ); } catch { return false; From ac96b64c64d04df8d0882bbd31f6978310d60f56 Mon Sep 17 00:00:00 2001 From: seydx Date: Sun, 16 Mar 2025 14:16:01 +0100 Subject: [PATCH 04/84] change codec priority handling for h265 --- www/video-rtc.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/www/video-rtc.js b/www/video-rtc.js index 143ee5a1..7efa0a57 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -583,7 +583,10 @@ export class VideoRTC extends HTMLElement { if (stream.getVideoTracks().length > 0) rtcPriority += 0x220; if (stream.getAudioTracks().length > 0) rtcPriority += 0x102; - if (this.mseCodecs.indexOf('hvc1.') >= 0 && !VideoRTC.isH265Supported()) msePriority += 0x230; + if (this.mseCodecs.indexOf('hvc1.')) { + if (VideoRTC.isH265Supported()) rtcPriority += 0x230; + else msePriority += 0x230; + } if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210; if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101; From 124556f4db2f13e53a6c759cd1ab0761dce94997 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 8 May 2025 16:09:04 +0200 Subject: [PATCH 05/84] ring: skip refetching cameras to increase loading speed and refactor ring url --- internal/ring/ring.go | 3 +++ pkg/ring/api.go | 2 +- pkg/ring/client.go | 34 ++++++++++++---------------------- pkg/ring/snapshot.go | 8 ++++---- 4 files changed, 20 insertions(+), 27 deletions(-) diff --git a/internal/ring/ring.go b/internal/ring/ring.go index 673ea480..e1615151 100644 --- a/internal/ring/ring.go +++ b/internal/ring/ring.go @@ -5,6 +5,8 @@ import ( "net/http" "net/url" + "fmt" + "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" @@ -83,6 +85,7 @@ func apiRing(w http.ResponseWriter, r *http.Request) { var items []*api.Source for _, camera := range devices.AllCameras { + cleanQuery.Set("camera_id", fmt.Sprint(camera.ID)) cleanQuery.Set("device_id", camera.DeviceID) // Stream source diff --git a/pkg/ring/api.go b/pkg/ring/api.go index ed69465f..3b67173d 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -70,7 +70,7 @@ type CameraKind string // CameraData contains common fields for all camera types type CameraData struct { - ID float64 `json:"id"` + ID int `json:"id"` Description string `json:"description"` DeviceID string `json:"device_id"` Kind string `json:"kind"` diff --git a/pkg/ring/client.go b/pkg/ring/client.go index 18244a39..ccd17743 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "strconv" "sync" "time" @@ -19,7 +20,7 @@ type Client struct { api *RingRestClient ws *websocket.Conn prod core.Producer - camera *CameraData + cameraID int dialogID string sessionID string wsMutex sync.Mutex @@ -109,6 +110,7 @@ func Dial(rawURL string) (*Client, error) { query := u.Query() encodedToken := query.Get("refresh_token") + cameraID := query.Get("camera_id") deviceID := query.Get("device_id") _, isSnapshot := query["snapshot"] @@ -116,6 +118,11 @@ func Dial(rawURL string) (*Client, error) { return nil, errors.New("ring: wrong query") } + camID, err := strconv.Atoi(cameraID) + if err != nil { + return nil, fmt.Errorf("ring: invalid camera_id: %w", err) + } + // URL-decode the refresh token refreshToken, err := url.QueryUnescape(encodedToken) if err != nil { @@ -128,34 +135,17 @@ func Dial(rawURL string) (*Client, error) { return nil, err } - // Get camera details - devices, err := ringAPI.FetchRingDevices() - if err != nil { - return nil, err - } - - var camera *CameraData - for _, cam := range devices.AllCameras { - if fmt.Sprint(cam.DeviceID) == deviceID { - camera = &cam - break - } - } - if camera == nil { - return nil, errors.New("ring: camera not found") - } - // Create base client client := &Client{ api: ringAPI, - camera: camera, + cameraID: camID, dialogID: uuid.NewString(), done: make(chan struct{}), } // Check if snapshot request if isSnapshot { - client.prod = NewSnapshotProducer(ringAPI, camera) + client.prod = NewSnapshotProducer(ringAPI, cameraID) return client, nil } @@ -365,7 +355,7 @@ func (c *Client) startMessageLoop(connState *core.Waiter) { // check if the message is from the correct doorbot doorbotID := res.Body["doorbot_id"].(float64) - if doorbotID != float64(c.camera.ID) { + if int(doorbotID) != c.cameraID { continue } @@ -459,7 +449,7 @@ func (c *Client) sendSessionMessage(method string, body map[string]interface{}) body = make(map[string]interface{}) } - body["doorbot_id"] = c.camera.ID + body["doorbot_id"] = c.cameraID if c.sessionID != "" { body["session_id"] = c.sessionID } diff --git a/pkg/ring/snapshot.go b/pkg/ring/snapshot.go index f64e4f79..6d1a97bf 100644 --- a/pkg/ring/snapshot.go +++ b/pkg/ring/snapshot.go @@ -11,10 +11,10 @@ type SnapshotProducer struct { core.Connection client *RingRestClient - camera *CameraData + cameraID string } -func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer { +func NewSnapshotProducer(client *RingRestClient, cameraID string) *SnapshotProducer { return &SnapshotProducer{ Connection: core.Connection{ ID: core.NewID(), @@ -36,13 +36,13 @@ func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotPr }, }, client: client, - camera: camera, + cameraID: cameraID, } } func (p *SnapshotProducer) Start() error { // Fetch snapshot - response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", int(p.camera.ID)), nil) + response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%s", p.cameraID), nil) if err != nil { return err } From 2eef7bdbd3b46c6c25529b315e95a1baa3f0fc71 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 8 May 2025 17:38:09 +0200 Subject: [PATCH 06/84] ring: implement session management and caching --- pkg/ring/api.go | 222 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 199 insertions(+), 23 deletions(-) diff --git a/pkg/ring/api.go b/pkg/ring/api.go index 3b67173d..2a905d50 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -11,9 +11,13 @@ import ( "net/http" "reflect" "strings" + "sync" "time" ) +var clientCache = map[string]*RingRestClient{} +var cacheMutex sync.Mutex + type RefreshTokenAuth struct { RefreshToken string } @@ -52,17 +56,35 @@ type SocketTicketResponse struct { ResponseTimestamp int64 `json:"response_timestamp"` } +// SessionResponse repesents the response from the session endpoint +type SessionResponse struct { + Profile struct { + ID int64 `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + } `json:"profile"` +} + // RingRestClient handles authentication and requests to Ring API type RingRestClient struct { httpClient *http.Client authConfig *AuthConfig hardwareID string authToken *AuthTokenResponse + tokenExpiry time.Time Using2FA bool PromptFor2FA string RefreshToken string auth interface{} // EmailAuth or RefreshTokenAuth onTokenRefresh func(string) + authMutex sync.Mutex + session *SessionResponse + sessionExpiry time.Time + sessionMutex sync.Mutex + + // Cache-Schlüssel für diese Instanz + cacheKey string } // CameraKind represents the different types of Ring cameras @@ -139,23 +161,50 @@ const ( apiVersion = 11 defaultTimeout = 20 * time.Second maxRetries = 3 + sessionValidTime = 12 * time.Hour ) -// NewRingRestClient creates a new Ring client instance +// NewRingRestClient creates a new Ring client instance with caching func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { - client := &RingRestClient{ - httpClient: &http.Client{Timeout: defaultTimeout}, - onTokenRefresh: onTokenRefresh, - hardwareID: generateHardwareID(), - auth: auth, - } - + var cacheKey string + + // Create cache key based on auth data switch a := auth.(type) { case RefreshTokenAuth: if a.RefreshToken == "" { return nil, fmt.Errorf("refresh token is required") } + cacheKey = "refresh:" + a.RefreshToken + case EmailAuth: + if a.Email == "" || a.Password == "" { + return nil, fmt.Errorf("email and password are required") + } + cacheKey = "email:" + a.Email + ":" + a.Password + default: + return nil, fmt.Errorf("invalid auth type") + } + + cacheMutex.Lock() + defer cacheMutex.Unlock() + + if cachedClient, ok := clientCache[cacheKey]; ok { + // Check if token is not nil and not expired + if cachedClient.authToken != nil && time.Now().Before(cachedClient.tokenExpiry) { + cachedClient.onTokenRefresh = onTokenRefresh + return cachedClient, nil + } + } + + client := &RingRestClient{ + httpClient: &http.Client{Timeout: defaultTimeout}, + onTokenRefresh: onTokenRefresh, + hardwareID: generateHardwareID(), + auth: auth, + cacheKey: cacheKey, + } + switch a := auth.(type) { + case RefreshTokenAuth: config, err := parseAuthConfig(a.RefreshToken) if err != nil { return nil, fmt.Errorf("failed to parse refresh token: %w", err) @@ -164,22 +213,18 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest client.authConfig = config client.hardwareID = config.HID client.RefreshToken = a.RefreshToken - case EmailAuth: - if a.Email == "" || a.Password == "" { - return nil, fmt.Errorf("email and password are required") - } - default: - return nil, fmt.Errorf("invalid auth type") } + clientCache[cacheKey] = client + return client, nil } // Request makes an authenticated request to the Ring API func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) { - // Ensure we have a valid auth token - if err := c.ensureAuth(); err != nil { - return nil, fmt.Errorf("authentication failed: %w", err) + // Ensure we have a valid session + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("session validation failed: %w", err) } var bodyReader io.Reader @@ -226,17 +271,54 @@ func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, // Handle 401 by refreshing auth and retrying if resp.StatusCode == http.StatusUnauthorized { - c.authToken = nil // Force token refresh + // Reset token to force refresh + c.authMutex.Lock() + c.authToken = nil + c.tokenExpiry = time.Time{} // Reset token expiry + c.authMutex.Unlock() + if attempt == maxRetries { return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) } - if err := c.ensureAuth(); err != nil { - return nil, fmt.Errorf("failed to refresh authentication: %w", err) + + // By 401 with Auth AND Session start over + c.sessionMutex.Lock() + c.session = nil + c.sessionExpiry = time.Time{} // Reset session expiry + c.sessionMutex.Unlock() + + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("failed to refresh session: %w", err) } + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) continue } + // Handle 404 error with hardware_id reference - session issue + if resp.StatusCode == 404 && strings.Contains(url, clientAPIBaseURL) { + var errorBody map[string]interface{} + if err := json.Unmarshal(responseBody, &errorBody); err == nil { + if errorStr, ok := errorBody["error"].(string); ok && strings.Contains(errorStr, c.hardwareID) { + // Session with hardware_id not found, refresh session + c.sessionMutex.Lock() + c.session = nil + c.sessionExpiry = time.Time{} // Reset session expiry + c.sessionMutex.Unlock() + + if attempt == maxRetries { + return nil, fmt.Errorf("session refresh failed after %d retries", maxRetries) + } + + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("failed to refresh session: %w", err) + } + + continue + } + } + } + // Handle other error status codes if resp.StatusCode >= 400 { return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody)) @@ -248,9 +330,82 @@ func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, return responseBody, nil } -// ensureAuth ensures we have a valid auth token +// ensureSession makes sure we have a valid session +func (c *RingRestClient) ensureSession() error { + c.sessionMutex.Lock() + defer c.sessionMutex.Unlock() + + // If session is still valid, use it + if c.session != nil && time.Now().Before(c.sessionExpiry) { + return nil + } + + // Make sure we have a valid auth token + if err := c.ensureAuth(); err != nil { + return fmt.Errorf("authentication failed while creating session: %w", err) + } + + sessionPayload := map[string]interface{}{ + "device": map[string]interface{}{ + "hardware_id": c.hardwareID, + "metadata": map[string]interface{}{ + "api_version": apiVersion, + "device_model": "ring-client-go", + }, + "os": "android", + }, + } + + body, err := json.Marshal(sessionPayload) + if err != nil { + return fmt.Errorf("failed to marshal session request: %w", err) + } + + req, err := http.NewRequest("POST", ClientAPI("session"), bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("session request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var sessionResp SessionResponse + if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil { + return fmt.Errorf("failed to decode session response: %w", err) + } + + c.session = &sessionResp + c.sessionExpiry = time.Now().Add(sessionValidTime) + + // Aktualisiere den gecachten Client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() + + return nil +} + +// ensureAuth ensures we have a valid auth token with expiration tracking func (c *RingRestClient) ensureAuth() error { - if c.authToken != nil { + c.authMutex.Lock() + defer c.authMutex.Unlock() + + // If token exists and is not expired, use it + if c.authToken != nil && time.Now().Before(c.tokenExpiry) { return nil } @@ -306,12 +461,24 @@ func (c *RingRestClient) ensureAuth() error { RT: authResp.RefreshToken, HID: c.hardwareID, } + + // Set token expiry (1 minute before actual expiry) + expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second + c.tokenExpiry = time.Now().Add(expiresIn) // Encode and notify about new refresh token if c.onTokenRefresh != nil { newRefreshToken := encodeAuthConfig(c.authConfig) c.onTokenRefresh(newRefreshToken) } + + // Refreshn the token in the client + c.RefreshToken = encodeAuthConfig(c.authConfig) + + // Refresh the cached client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() return nil } @@ -404,16 +571,25 @@ func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, return nil, fmt.Errorf("failed to decode auth response: %w", err) } + // Refresh token and expiry c.authToken = &authResp c.authConfig = &AuthConfig{ RT: authResp.RefreshToken, HID: c.hardwareID, } + // Set token expiry (1 minute before actual expiry) + expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second + c.tokenExpiry = time.Now().Add(expiresIn) c.RefreshToken = encodeAuthConfig(c.authConfig) if c.onTokenRefresh != nil { c.onTokenRefresh(c.RefreshToken) } + + // Refresh the cached client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() return c.authToken, nil } @@ -542,4 +718,4 @@ func interfaceSlice(slice interface{}) []CameraData { } } return ret -} +} \ No newline at end of file From edfa09bb9fd64a2308d5873099d4c6feba6d53d0 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 10 May 2025 19:04:47 +0200 Subject: [PATCH 07/84] ring: update peer connection state handling and pass sdo to producer --- pkg/ring/client.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/ring/client.go b/pkg/ring/client.go index ccd17743..da1b2ce8 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -237,7 +237,10 @@ func Dial(rawURL string) (*Client, error) { case pion.PeerConnectionState: switch msg { + case pion.PeerConnectionStateNew: + break case pion.PeerConnectionStateConnecting: + break case pion.PeerConnectionStateConnected: connState.Done(nil) default: @@ -391,6 +394,8 @@ func (c *Client) startMessageLoop(connState *core.Waiter) { c.Stop() return } + + prod.SDP = msg.Body.SDP } case "ice": From adb1b21e81b23628eb6ec80a4abc6dfdad32da29 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 May 2025 16:37:12 +0200 Subject: [PATCH 08/84] format --- pkg/ring/api.go | 54 ++++++++++++++++++++++---------------------- pkg/ring/snapshot.go | 4 ++-- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/pkg/ring/api.go b/pkg/ring/api.go index 2a905d50..62ac7827 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -59,10 +59,10 @@ type SocketTicketResponse struct { // SessionResponse repesents the response from the session endpoint type SessionResponse struct { Profile struct { - ID int64 `json:"id"` - Email string `json:"email"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` + ID int64 `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` } `json:"profile"` } @@ -82,9 +82,9 @@ type RingRestClient struct { session *SessionResponse sessionExpiry time.Time sessionMutex sync.Mutex - + // Cache-Schlüssel für diese Instanz - cacheKey string + cacheKey string } // CameraKind represents the different types of Ring cameras @@ -92,11 +92,11 @@ type CameraKind string // CameraData contains common fields for all camera types type CameraData struct { - ID int `json:"id"` - Description string `json:"description"` - DeviceID string `json:"device_id"` - Kind string `json:"kind"` - LocationID string `json:"location_id"` + ID int `json:"id"` + Description string `json:"description"` + DeviceID string `json:"device_id"` + Kind string `json:"kind"` + LocationID string `json:"location_id"` } // RingDeviceType represents different types of Ring devices @@ -167,7 +167,7 @@ const ( // NewRingRestClient creates a new Ring client instance with caching func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { var cacheKey string - + // Create cache key based on auth data switch a := auth.(type) { case RefreshTokenAuth: @@ -183,10 +183,10 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest default: return nil, fmt.Errorf("invalid auth type") } - + cacheMutex.Lock() defer cacheMutex.Unlock() - + if cachedClient, ok := clientCache[cacheKey]; ok { // Check if token is not nil and not expired if cachedClient.authToken != nil && time.Now().Before(cachedClient.tokenExpiry) { @@ -194,7 +194,7 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest return cachedClient, nil } } - + client := &RingRestClient{ httpClient: &http.Client{Timeout: defaultTimeout}, onTokenRefresh: onTokenRefresh, @@ -216,7 +216,7 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest } clientCache[cacheKey] = client - + return client, nil } @@ -276,21 +276,21 @@ func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, c.authToken = nil c.tokenExpiry = time.Time{} // Reset token expiry c.authMutex.Unlock() - + if attempt == maxRetries { return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) } - + // By 401 with Auth AND Session start over c.sessionMutex.Lock() c.session = nil c.sessionExpiry = time.Time{} // Reset session expiry c.sessionMutex.Unlock() - + if err := c.ensureSession(); err != nil { return nil, fmt.Errorf("failed to refresh session: %w", err) } - + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) continue } @@ -305,15 +305,15 @@ func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, c.session = nil c.sessionExpiry = time.Time{} // Reset session expiry c.sessionMutex.Unlock() - + if attempt == maxRetries { return nil, fmt.Errorf("session refresh failed after %d retries", maxRetries) } - + if err := c.ensureSession(); err != nil { return nil, fmt.Errorf("failed to refresh session: %w", err) } - + continue } } @@ -461,7 +461,7 @@ func (c *RingRestClient) ensureAuth() error { RT: authResp.RefreshToken, HID: c.hardwareID, } - + // Set token expiry (1 minute before actual expiry) expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second c.tokenExpiry = time.Now().Add(expiresIn) @@ -471,7 +471,7 @@ func (c *RingRestClient) ensureAuth() error { newRefreshToken := encodeAuthConfig(c.authConfig) c.onTokenRefresh(newRefreshToken) } - + // Refreshn the token in the client c.RefreshToken = encodeAuthConfig(c.authConfig) @@ -585,7 +585,7 @@ func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, if c.onTokenRefresh != nil { c.onTokenRefresh(c.RefreshToken) } - + // Refresh the cached client cacheMutex.Lock() clientCache[c.cacheKey] = c @@ -718,4 +718,4 @@ func interfaceSlice(slice interface{}) []CameraData { } } return ret -} \ No newline at end of file +} diff --git a/pkg/ring/snapshot.go b/pkg/ring/snapshot.go index 6d1a97bf..727a5245 100644 --- a/pkg/ring/snapshot.go +++ b/pkg/ring/snapshot.go @@ -10,7 +10,7 @@ import ( type SnapshotProducer struct { core.Connection - client *RingRestClient + client *RingRestClient cameraID string } @@ -35,7 +35,7 @@ func NewSnapshotProducer(client *RingRestClient, cameraID string) *SnapshotProdu }, }, }, - client: client, + client: client, cameraID: cameraID, } } From 0830d8342ecdd5c8abd35726aa9e20dd81ed691c Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 20 May 2025 12:07:46 +0200 Subject: [PATCH 09/84] 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 10/84] 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 11/84] 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 12/84] 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 13/84] 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 14/84] 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 15/84] 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 16/84] 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 17/84] 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 c90fcd1ce16e613fc520980f4aadc8ab644be419 Mon Sep 17 00:00:00 2001 From: seydx Date: Wed, 21 May 2025 13:16:49 +0200 Subject: [PATCH 18/84] refactor --- internal/ring/ring.go | 21 +- pkg/ring/api.go | 615 ++++++++++++++++++++---------------------- pkg/ring/client.go | 405 ++++++++-------------------- pkg/ring/snapshot.go | 9 +- pkg/ring/ws.go | 265 ++++++++++++++++++ www/add.html | 19 +- 6 files changed, 702 insertions(+), 632 deletions(-) create mode 100644 pkg/ring/ws.go diff --git a/internal/ring/ring.go b/internal/ring/ring.go index e1615151..7fdb284f 100644 --- a/internal/ring/ring.go +++ b/internal/ring/ring.go @@ -1,7 +1,6 @@ package ring import ( - "encoding/json" "net/http" "net/url" @@ -23,8 +22,7 @@ func Init() { func apiRing(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() - var ringAPI *ring.RingRestClient - var err error + var ringAPI *ring.RingApi // Check auth method if email := query.Get("email"); email != "" { @@ -32,7 +30,8 @@ func apiRing(w http.ResponseWriter, r *http.Request) { password := query.Get("password") code := query.Get("code") - ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{ + var err error + ringAPI, err = ring.NewRestClient(ring.EmailAuth{ Email: email, Password: password, }, nil) @@ -46,7 +45,7 @@ func apiRing(w http.ResponseWriter, r *http.Request) { if _, err = ringAPI.GetAuth(code); err != nil { if ringAPI.Using2FA { // Return 2FA prompt - json.NewEncoder(w).Encode(map[string]interface{}{ + api.ResponseJSON(w, map[string]interface{}{ "needs_2fa": true, "prompt": ringAPI.PromptFor2FA, }) @@ -55,31 +54,33 @@ func apiRing(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - } else { + } else if refreshToken := query.Get("refresh_token"); refreshToken != "" { // Refresh Token Flow - refreshToken := query.Get("refresh_token") if refreshToken == "" { http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest) return } - ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{ + var err error + ringAPI, err = ring.NewRestClient(ring.RefreshTokenAuth{ RefreshToken: refreshToken, }, nil) + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + } else { + http.Error(w, "either email/password or refresh token is required", http.StatusBadRequest) + return } - // Fetch devices devices, err := ringAPI.FetchRingDevices() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - // Create clean query with only required parameters cleanQuery := url.Values{} cleanQuery.Set("refresh_token", ringAPI.RefreshToken) diff --git a/pkg/ring/api.go b/pkg/ring/api.go index 62ac7827..ea7c95ad 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -15,7 +15,7 @@ import ( "time" ) -var clientCache = map[string]*RingRestClient{} +var clientCache = map[string]*RingApi{} var cacheMutex sync.Mutex type RefreshTokenAuth struct { @@ -27,13 +27,11 @@ type EmailAuth struct { Password string } -// AuthConfig represents the decoded refresh token data type AuthConfig struct { RT string `json:"rt"` // Refresh Token HID string `json:"hid"` // Hardware ID } -// AuthTokenResponse represents the response from the authentication endpoint type AuthTokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` @@ -50,13 +48,11 @@ type Auth2faResponse struct { NextTimeInSecs int `json:"next_time_in_secs"` } -// SocketTicketRequest represents the request to get a socket ticket type SocketTicketResponse struct { Ticket string `json:"ticket"` ResponseTimestamp int64 `json:"response_timestamp"` } -// SessionResponse repesents the response from the session endpoint type SessionResponse struct { Profile struct { ID int64 `json:"id"` @@ -66,8 +62,7 @@ type SessionResponse struct { } `json:"profile"` } -// RingRestClient handles authentication and requests to Ring API -type RingRestClient struct { +type RingApi struct { httpClient *http.Client authConfig *AuthConfig hardwareID string @@ -82,15 +77,11 @@ type RingRestClient struct { session *SessionResponse sessionExpiry time.Time sessionMutex sync.Mutex - - // Cache-Schlüssel für diese Instanz - cacheKey string + cacheKey string } -// CameraKind represents the different types of Ring cameras type CameraKind string -// CameraData contains common fields for all camera types type CameraData struct { ID int `json:"id"` Description string `json:"description"` @@ -99,10 +90,8 @@ type CameraData struct { LocationID string `json:"location_id"` } -// RingDeviceType represents different types of Ring devices type RingDeviceType string -// RingDevicesResponse represents the response from the Ring API type RingDevicesResponse struct { Doorbots []CameraData `json:"doorbots"` AuthorizedDoorbots []CameraData `json:"authorized_doorbots"` @@ -164,8 +153,7 @@ const ( sessionValidTime = 12 * time.Hour ) -// NewRingRestClient creates a new Ring client instance with caching -func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { +func NewRestClient(auth interface{}, onTokenRefresh func(string)) (*RingApi, error) { var cacheKey string // Create cache key based on auth data @@ -195,7 +183,7 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest } } - client := &RingRestClient{ + client := &RingApi{ httpClient: &http.Client{Timeout: defaultTimeout}, onTokenRefresh: onTokenRefresh, hardwareID: generateHardwareID(), @@ -220,271 +208,23 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest return client, nil } -// Request makes an authenticated request to the Ring API -func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) { - // Ensure we have a valid session - if err := c.ensureSession(); err != nil { - return nil, fmt.Errorf("session validation failed: %w", err) - } - - var bodyReader io.Reader - if body != nil { - jsonBody, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) - } - bodyReader = bytes.NewReader(jsonBody) - } - - // Create request - req, err := http.NewRequest(method, url, bodyReader) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Set headers - req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("hardware_id", c.hardwareID) - req.Header.Set("User-Agent", "android:com.ringapp") - - // Make request with retries - var resp *http.Response - var responseBody []byte - - for attempt := 0; attempt <= maxRetries; attempt++ { - resp, err = c.httpClient.Do(req) - if err != nil { - if attempt == maxRetries { - return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err) - } - time.Sleep(5 * time.Second) - continue - } - defer resp.Body.Close() - - responseBody, err = io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - // Handle 401 by refreshing auth and retrying - if resp.StatusCode == http.StatusUnauthorized { - // Reset token to force refresh - c.authMutex.Lock() - c.authToken = nil - c.tokenExpiry = time.Time{} // Reset token expiry - c.authMutex.Unlock() - - if attempt == maxRetries { - return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) - } - - // By 401 with Auth AND Session start over - c.sessionMutex.Lock() - c.session = nil - c.sessionExpiry = time.Time{} // Reset session expiry - c.sessionMutex.Unlock() - - if err := c.ensureSession(); err != nil { - return nil, fmt.Errorf("failed to refresh session: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) - continue - } - - // Handle 404 error with hardware_id reference - session issue - if resp.StatusCode == 404 && strings.Contains(url, clientAPIBaseURL) { - var errorBody map[string]interface{} - if err := json.Unmarshal(responseBody, &errorBody); err == nil { - if errorStr, ok := errorBody["error"].(string); ok && strings.Contains(errorStr, c.hardwareID) { - // Session with hardware_id not found, refresh session - c.sessionMutex.Lock() - c.session = nil - c.sessionExpiry = time.Time{} // Reset session expiry - c.sessionMutex.Unlock() - - if attempt == maxRetries { - return nil, fmt.Errorf("session refresh failed after %d retries", maxRetries) - } - - if err := c.ensureSession(); err != nil { - return nil, fmt.Errorf("failed to refresh session: %w", err) - } - - continue - } - } - } - - // Handle other error status codes - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody)) - } - - break - } - - return responseBody, nil +func ClientAPI(path string) string { + return clientAPIBaseURL + path } -// ensureSession makes sure we have a valid session -func (c *RingRestClient) ensureSession() error { - c.sessionMutex.Lock() - defer c.sessionMutex.Unlock() - - // If session is still valid, use it - if c.session != nil && time.Now().Before(c.sessionExpiry) { - return nil - } - - // Make sure we have a valid auth token - if err := c.ensureAuth(); err != nil { - return fmt.Errorf("authentication failed while creating session: %w", err) - } - - sessionPayload := map[string]interface{}{ - "device": map[string]interface{}{ - "hardware_id": c.hardwareID, - "metadata": map[string]interface{}{ - "api_version": apiVersion, - "device_model": "ring-client-go", - }, - "os": "android", - }, - } - - body, err := json.Marshal(sessionPayload) - if err != nil { - return fmt.Errorf("failed to marshal session request: %w", err) - } - - req, err := http.NewRequest("POST", ClientAPI("session"), bytes.NewReader(body)) - if err != nil { - return err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) - req.Header.Set("hardware_id", c.hardwareID) - req.Header.Set("User-Agent", "android:com.ringapp") - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("session request failed with status %d: %s", resp.StatusCode, string(respBody)) - } - - var sessionResp SessionResponse - if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil { - return fmt.Errorf("failed to decode session response: %w", err) - } - - c.session = &sessionResp - c.sessionExpiry = time.Now().Add(sessionValidTime) - - // Aktualisiere den gecachten Client - cacheMutex.Lock() - clientCache[c.cacheKey] = c - cacheMutex.Unlock() - - return nil +func DeviceAPI(path string) string { + return deviceAPIBaseURL + path } -// ensureAuth ensures we have a valid auth token with expiration tracking -func (c *RingRestClient) ensureAuth() error { - c.authMutex.Lock() - defer c.authMutex.Unlock() - - // If token exists and is not expired, use it - if c.authToken != nil && time.Now().Before(c.tokenExpiry) { - return nil - } - - var grantData = map[string]string{ - "grant_type": "refresh_token", - "refresh_token": c.authConfig.RT, - } - - // Add common fields - grantData["client_id"] = "ring_official_android" - grantData["scope"] = "client" - - // Make auth request - body, err := json.Marshal(grantData) - if err != nil { - return fmt.Errorf("failed to marshal auth request: %w", err) - } - - req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("failed to create auth request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("hardware_id", c.hardwareID) - req.Header.Set("User-Agent", "android:com.ringapp") - req.Header.Set("2fa-support", "true") - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("auth request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusPreconditionFailed { - return fmt.Errorf("2FA required. Please see documentation for handling 2FA") - } - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) - } - - var authResp AuthTokenResponse - if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { - return fmt.Errorf("failed to decode auth response: %w", err) - } - - // Update auth config and refresh token - c.authToken = &authResp - c.authConfig = &AuthConfig{ - RT: authResp.RefreshToken, - HID: c.hardwareID, - } - - // Set token expiry (1 minute before actual expiry) - expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second - c.tokenExpiry = time.Now().Add(expiresIn) - - // Encode and notify about new refresh token - if c.onTokenRefresh != nil { - newRefreshToken := encodeAuthConfig(c.authConfig) - c.onTokenRefresh(newRefreshToken) - } - - // Refreshn the token in the client - c.RefreshToken = encodeAuthConfig(c.authConfig) - - // Refresh the cached client - cacheMutex.Lock() - clientCache[c.cacheKey] = c - cacheMutex.Unlock() - - return nil +func CommandsAPI(path string) string { + return commandsAPIBaseURL + path } -// getAuth makes an authentication request to the Ring API -func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { +func AppAPI(path string) string { + return appAPIBaseURL + path +} + +func (c *RingApi) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { var grantData map[string]string if c.authConfig != nil && twoFactorAuthCode == "" { @@ -594,46 +334,7 @@ func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, return c.authToken, nil } -// Helper functions for auth config encoding/decoding -func parseAuthConfig(refreshToken string) (*AuthConfig, error) { - decoded, err := base64.StdEncoding.DecodeString(refreshToken) - if err != nil { - return nil, err - } - - var config AuthConfig - if err := json.Unmarshal(decoded, &config); err != nil { - // Handle legacy format where refresh token is the raw token - return &AuthConfig{RT: refreshToken}, nil - } - - return &config, nil -} - -func encodeAuthConfig(config *AuthConfig) string { - jsonBytes, _ := json.Marshal(config) - return base64.StdEncoding.EncodeToString(jsonBytes) -} - -// API URL helpers -func ClientAPI(path string) string { - return clientAPIBaseURL + path -} - -func DeviceAPI(path string) string { - return deviceAPIBaseURL + path -} - -func CommandsAPI(path string) string { - return commandsAPIBaseURL + path -} - -func AppAPI(path string) string { - return appAPIBaseURL + path -} - -// FetchRingDevices gets all Ring devices and categorizes them -func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) { +func (c *RingApi) FetchRingDevices() (*RingDevicesResponse, error) { response, err := c.Request("GET", ClientAPI("ring_devices"), nil) if err != nil { return nil, fmt.Errorf("failed to fetch ring devices: %w", err) @@ -685,7 +386,7 @@ func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) { return &devices, nil } -func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) { +func (c *RingApi) GetSocketTicket() (*SocketTicketResponse, error) { response, err := c.Request("POST", AppAPI("clap/ticket/request/signalsocket"), nil) if err != nil { return nil, fmt.Errorf("failed to fetch socket ticket: %w", err) @@ -699,6 +400,286 @@ func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) { return &ticket, nil } +func (c *RingApi) Request(method, url string, body interface{}) ([]byte, error) { + // Ensure we have a valid session + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("session validation failed: %w", err) + } + + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(jsonBody) + } + + // Create request + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + + // Make request with retries + var resp *http.Response + var responseBody []byte + + for attempt := 0; attempt <= maxRetries; attempt++ { + resp, err = c.httpClient.Do(req) + if err != nil { + if attempt == maxRetries { + return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err) + } + time.Sleep(5 * time.Second) + continue + } + defer resp.Body.Close() + + responseBody, err = io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Handle 401 by refreshing auth and retrying + if resp.StatusCode == http.StatusUnauthorized { + // Reset token to force refresh + c.authMutex.Lock() + c.authToken = nil + c.tokenExpiry = time.Time{} // Reset token expiry + c.authMutex.Unlock() + + if attempt == maxRetries { + return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) + } + + // By 401 with Auth AND Session start over + c.sessionMutex.Lock() + c.session = nil + c.sessionExpiry = time.Time{} // Reset session expiry + c.sessionMutex.Unlock() + + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("failed to refresh session: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + continue + } + + // Handle 404 error with hardware_id reference - session issue + if resp.StatusCode == 404 && strings.Contains(url, clientAPIBaseURL) { + var errorBody map[string]interface{} + if err := json.Unmarshal(responseBody, &errorBody); err == nil { + if errorStr, ok := errorBody["error"].(string); ok && strings.Contains(errorStr, c.hardwareID) { + // Session with hardware_id not found, refresh session + c.sessionMutex.Lock() + c.session = nil + c.sessionExpiry = time.Time{} // Reset session expiry + c.sessionMutex.Unlock() + + if attempt == maxRetries { + return nil, fmt.Errorf("session refresh failed after %d retries", maxRetries) + } + + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("failed to refresh session: %w", err) + } + + continue + } + } + } + + // Handle other error status codes + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody)) + } + + break + } + + return responseBody, nil +} + +func (c *RingApi) ensureSession() error { + c.sessionMutex.Lock() + defer c.sessionMutex.Unlock() + + // If session is still valid, use it + if c.session != nil && time.Now().Before(c.sessionExpiry) { + return nil + } + + // Make sure we have a valid auth token + if err := c.ensureAuth(); err != nil { + return fmt.Errorf("authentication failed while creating session: %w", err) + } + + sessionPayload := map[string]interface{}{ + "device": map[string]interface{}{ + "hardware_id": c.hardwareID, + "metadata": map[string]interface{}{ + "api_version": apiVersion, + "device_model": "ring-client-go", + }, + "os": "android", + }, + } + + body, err := json.Marshal(sessionPayload) + if err != nil { + return fmt.Errorf("failed to marshal session request: %w", err) + } + + req, err := http.NewRequest("POST", ClientAPI("session"), bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("session request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var sessionResp SessionResponse + if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil { + return fmt.Errorf("failed to decode session response: %w", err) + } + + c.session = &sessionResp + c.sessionExpiry = time.Now().Add(sessionValidTime) + + // Aktualisiere den gecachten Client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() + + return nil +} + +func (c *RingApi) ensureAuth() error { + c.authMutex.Lock() + defer c.authMutex.Unlock() + + // If token exists and is not expired, use it + if c.authToken != nil && time.Now().Before(c.tokenExpiry) { + return nil + } + + var grantData = map[string]string{ + "grant_type": "refresh_token", + "refresh_token": c.authConfig.RT, + } + + // Add common fields + grantData["client_id"] = "ring_official_android" + grantData["scope"] = "client" + + // Make auth request + body, err := json.Marshal(grantData) + if err != nil { + return fmt.Errorf("failed to marshal auth request: %w", err) + } + + req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create auth request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + req.Header.Set("2fa-support", "true") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("auth request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusPreconditionFailed { + return fmt.Errorf("2FA required. Please see documentation for handling 2FA") + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var authResp AuthTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return fmt.Errorf("failed to decode auth response: %w", err) + } + + // Update auth config and refresh token + c.authToken = &authResp + c.authConfig = &AuthConfig{ + RT: authResp.RefreshToken, + HID: c.hardwareID, + } + + // Set token expiry (1 minute before actual expiry) + expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second + c.tokenExpiry = time.Now().Add(expiresIn) + + // Encode and notify about new refresh token + if c.onTokenRefresh != nil { + newRefreshToken := encodeAuthConfig(c.authConfig) + c.onTokenRefresh(newRefreshToken) + } + + // Refreshn the token in the client + c.RefreshToken = encodeAuthConfig(c.authConfig) + + // Refresh the cached client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() + + return nil +} + +func parseAuthConfig(refreshToken string) (*AuthConfig, error) { + decoded, err := base64.StdEncoding.DecodeString(refreshToken) + if err != nil { + return nil, err + } + + var config AuthConfig + if err := json.Unmarshal(decoded, &config); err != nil { + // Handle legacy format where refresh token is the raw token + return &AuthConfig{RT: refreshToken}, nil + } + + return &config, nil +} + +func encodeAuthConfig(config *AuthConfig) string { + jsonBytes, _ := json.Marshal(config) + return base64.StdEncoding.EncodeToString(jsonBytes) +} + func generateHardwareID() string { h := sha256.New() h.Write([]byte("ring-client-go2rtc")) diff --git a/pkg/ring/client.go b/pkg/ring/client.go index da1b2ce8..fb77e198 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -6,103 +6,24 @@ import ( "fmt" "net/url" "strconv" - "sync" - "time" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/google/uuid" - "github.com/gorilla/websocket" pion "github.com/pion/webrtc/v4" ) type Client struct { - api *RingRestClient - ws *websocket.Conn + api *RingApi + wsClient *WSClient prod core.Producer cameraID int dialogID string - sessionID string - wsMutex sync.Mutex - done chan struct{} + connected core.Waiter + closed bool } -type SessionBody struct { - DoorbotID int `json:"doorbot_id"` - SessionID string `json:"session_id"` -} - -type AnswerMessage struct { - Method string `json:"method"` // "sdp" - Body struct { - SessionBody - SDP string `json:"sdp"` - Type string `json:"type"` // "answer" - } `json:"body"` -} - -type IceCandidateMessage struct { - Method string `json:"method"` // "ice" - Body struct { - SessionBody - Ice string `json:"ice"` - MLineIndex int `json:"mlineindex"` - } `json:"body"` -} - -type SessionMessage struct { - Method string `json:"method"` // "session_created" or "session_started" - Body SessionBody `json:"body"` -} - -type PongMessage struct { - Method string `json:"method"` // "pong" - Body SessionBody `json:"body"` -} - -type NotificationMessage struct { - Method string `json:"method"` // "notification" - Body struct { - SessionBody - IsOK bool `json:"is_ok"` - Text string `json:"text"` - } `json:"body"` -} - -type StreamInfoMessage struct { - Method string `json:"method"` // "stream_info" - Body struct { - SessionBody - Transcoding bool `json:"transcoding"` - TranscodingReason string `json:"transcoding_reason"` - } `json:"body"` -} - -type CloseMessage struct { - Method string `json:"method"` // "close" - Body struct { - SessionBody - Reason struct { - Code int `json:"code"` - Text string `json:"text"` - } `json:"reason"` - } `json:"body"` -} - -type BaseMessage struct { - Method string `json:"method"` - Body map[string]any `json:"body"` -} - -// Close reason codes -const ( - CloseReasonNormalClose = 0 - CloseReasonAuthenticationFailed = 5 - CloseReasonTimeout = 6 -) - func Dial(rawURL string) (*Client, error) { - // 1. Parse URL and validate basic params u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -114,55 +35,38 @@ func Dial(rawURL string) (*Client, error) { deviceID := query.Get("device_id") _, isSnapshot := query["snapshot"] - if encodedToken == "" || deviceID == "" { + if encodedToken == "" || deviceID == "" || cameraID == "" { return nil, errors.New("ring: wrong query") } - camID, err := strconv.Atoi(cameraID) + client := &Client{ + dialogID: uuid.NewString(), + } + + client.cameraID, err = strconv.Atoi(cameraID) if err != nil { return nil, fmt.Errorf("ring: invalid camera_id: %w", err) } - // URL-decode the refresh token refreshToken, err := url.QueryUnescape(encodedToken) if err != nil { return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err) } - // Initialize Ring API client - ringAPI, err := NewRingRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil) + client.api, err = NewRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil) if err != nil { return nil, err } - // Create base client - client := &Client{ - api: ringAPI, - cameraID: camID, - dialogID: uuid.NewString(), - done: make(chan struct{}), - } - - // Check if snapshot request + // Snapshot Flow if isSnapshot { - client.prod = NewSnapshotProducer(ringAPI, cameraID) + client.prod = NewSnapshotProducer(client.api, client.cameraID) return client, nil } - // If not snapshot, continue with WebRTC setup - ticket, err := ringAPI.GetSocketTicket() - if err != nil { - return nil, err - } - - // Create WebSocket connection - wsURL := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s", - uuid.NewString(), url.QueryEscape(ticket.Ticket)) - - client.ws, _, err = websocket.DefaultDialer.Dial(wsURL, map[string][]string{ - "User-Agent": {"android:com.ringapp"}, - }) + client.wsClient, err = StartWebsocket(client.cameraID, client.api) if err != nil { + client.Stop() return nil, err } @@ -186,13 +90,13 @@ func Dial(rawURL string) (*Client, error) { api, err := webrtc.NewAPI() if err != nil { - client.ws.Close() + client.Stop() return nil, err } pc, err := api.NewPeerConnection(conf) if err != nil { - client.ws.Close() + client.Stop() return nil, err } @@ -202,16 +106,27 @@ func Dial(rawURL string) (*Client, error) { // protect from blocking on errors defer sendOffer.Done(nil) - // waiter will wait PC error or WS error or nil (connection OK) - var connState core.Waiter - prod := webrtc.NewConn(pc) prod.FormatName = "ring/webrtc" prod.Mode = core.ModeActiveProducer prod.Protocol = "ws" prod.URL = rawURL - client.prod = prod + client.wsClient.onMessage = func(msg WSMessage) { + client.onWSMessage(msg) + } + + client.wsClient.onError = func(err error) { + // fmt.Printf("ring: error: %s\n", err.Error()) + client.Stop() + client.connected.Done(err) + } + + client.wsClient.onClose = func() { + // fmt.Println("ring: disconnect") + client.Stop() + client.connected.Done(errors.New("ring: disconnect")) + } prod.Listen(func(msg any) { switch msg := msg.(type) { @@ -230,8 +145,8 @@ func Dial(rawURL string) (*Client, error) { "mlineindex": iceCandidate.SDPMLineIndex, } - if err = client.sendSessionMessage("ice", icePayload); err != nil { - connState.Done(err) + if err = client.wsClient.sendSessionMessage("ice", icePayload); err != nil { + client.connected.Done(err) return } @@ -242,13 +157,16 @@ func Dial(rawURL string) (*Client, error) { case pion.PeerConnectionStateConnecting: break case pion.PeerConnectionStateConnected: - connState.Done(nil) + client.connected.Done(nil) default: - connState.Done(errors.New("ring: " + msg.String())) + client.Stop() + client.connected.Done(errors.New("ring: " + msg.String())) } } }) + client.prod = prod + // Setup media configuration medias := []*core.Media{ { @@ -290,188 +208,103 @@ func Dial(rawURL string) (*Client, error) { "sdp": offer, } - if err = client.sendSessionMessage("live_view", offerPayload); err != nil { + if err = client.wsClient.sendSessionMessage("live_view", offerPayload); err != nil { client.Stop() return nil, err } sendOffer.Done(nil) - // Ring expects a ping message every 5 seconds - go client.startPingLoop(pc) - go client.startMessageLoop(&connState) - - if err = connState.Wait(); err != nil { + if err = client.connected.Wait(); err != nil { return nil, err } return client, nil } -func (c *Client) startPingLoop(pc *pion.PeerConnection) { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() +func (c *Client) onWSMessage(msg WSMessage) { + rawMsg, _ := json.Marshal(msg) - for { - select { - case <-c.done: - return - case <-ticker.C: - if pc.ConnectionState() == pion.PeerConnectionStateConnected { - if err := c.sendSessionMessage("ping", nil); err != nil { - return - } - } + // fmt.Printf("ring: onWSMessage: %s\n", string(rawMsg)) + + // check if "doorbot_id" is present + if _, ok := msg.Body["doorbot_id"]; !ok { + return + } + + // check if the message is from the correct doorbot + doorbotID := msg.Body["doorbot_id"].(float64) + if int(doorbotID) != c.cameraID { + return + } + + if msg.Method == "session_created" || msg.Method == "session_started" { + if _, ok := msg.Body["session_id"]; ok && c.wsClient.sessionID == "" { + c.wsClient.sessionID = msg.Body["session_id"].(string) } } -} -func (c *Client) startMessageLoop(connState *core.Waiter) { - var err error - - // will be closed when conn will be closed - defer func() { - connState.Done(err) - }() - - for { - select { - case <-c.done: + // check if the message is from the correct session + if _, ok := msg.Body["session_id"]; ok { + if msg.Body["session_id"].(string) != c.wsClient.sessionID { return - default: - var res BaseMessage - if err = c.ws.ReadJSON(&res); err != nil { - select { - case <-c.done: - return - default: - } + } + } + switch msg.Method { + case "sdp": + if prod, ok := c.prod.(*webrtc.Conn); ok { + // Get answer + var msg AnswerMessage + if err := json.Unmarshal(rawMsg, &msg); err != nil { c.Stop() + c.connected.Done(err) return } - // check if "doorbot_id" is present - if _, ok := res.Body["doorbot_id"]; !ok { - continue - } - - // check if the message is from the correct doorbot - doorbotID := res.Body["doorbot_id"].(float64) - if int(doorbotID) != c.cameraID { - continue - } - - // check if the message is from the correct session - if res.Method == "session_created" || res.Method == "session_started" { - if _, ok := res.Body["session_id"]; ok && c.sessionID == "" { - c.sessionID = res.Body["session_id"].(string) - } - } - - if _, ok := res.Body["session_id"]; ok { - if res.Body["session_id"].(string) != c.sessionID { - continue - } - } - - rawMsg, _ := json.Marshal(res) - - switch res.Method { - case "sdp": - if prod, ok := c.prod.(*webrtc.Conn); ok { - // Get answer - var msg AnswerMessage - if err = json.Unmarshal(rawMsg, &msg); err != nil { - c.Stop() - return - } - if err = prod.SetAnswer(msg.Body.SDP); err != nil { - c.Stop() - return - } - if err = c.activateSession(); err != nil { - c.Stop() - return - } - - prod.SDP = msg.Body.SDP - } - - case "ice": - if prod, ok := c.prod.(*webrtc.Conn); ok { - // Continue to receiving candidates - var msg IceCandidateMessage - if err = json.Unmarshal(rawMsg, &msg); err != nil { - break - } - - // check for empty ICE candidate - if msg.Body.Ice == "" { - break - } - - if err = prod.AddCandidate(msg.Body.Ice); err != nil { - c.Stop() - return - } - } - - case "close": + if err := prod.SetAnswer(msg.Body.SDP); err != nil { c.Stop() + c.connected.Done(err) return + } - case "pong": - // Ignore - continue + if err := c.wsClient.activateSession(); err != nil { + c.Stop() + c.connected.Done(err) + return + } + + prod.SDP = msg.Body.SDP + } + + case "ice": + if prod, ok := c.prod.(*webrtc.Conn); ok { + var msg IceCandidateMessage + if err := json.Unmarshal(rawMsg, &msg); err != nil { + break + } + + // Skip empty candidates + if msg.Body.Ice == "" { + break + } + + if err := prod.AddCandidate(msg.Body.Ice); err != nil { + c.Stop() + c.connected.Done(err) + return } } + + case "close": + c.Stop() + c.connected.Done(errors.New("ring: close")) + + case "pong": + // Ignore } } -func (c *Client) activateSession() error { - if err := c.sendSessionMessage("activate_session", nil); err != nil { - return err - } - - streamPayload := map[string]interface{}{ - "audio_enabled": true, - "video_enabled": true, - } - - if err := c.sendSessionMessage("stream_options", streamPayload); err != nil { - return err - } - - return nil -} - -func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error { - c.wsMutex.Lock() - defer c.wsMutex.Unlock() - - if body == nil { - body = make(map[string]interface{}) - } - - body["doorbot_id"] = c.cameraID - if c.sessionID != "" { - body["session_id"] = c.sessionID - } - - msg := map[string]interface{}{ - "method": method, - "dialog_id": c.dialogID, - "body": body, - } - - if err := c.ws.WriteJSON(msg); err != nil { - return err - } - - return nil -} - func (c *Client) GetMedias() []*core.Media { return c.prod.GetMedias() } @@ -487,7 +320,7 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece speakerPayload := map[string]interface{}{ "stealth_mode": false, } - _ = c.sendSessionMessage("camera_options", speakerPayload) + _ = c.wsClient.sendSessionMessage("camera_options", speakerPayload) } return webrtcProd.AddTrack(media, codec, track) } @@ -500,37 +333,23 @@ func (c *Client) Start() error { } func (c *Client) Stop() error { - select { - case <-c.done: + if c.closed { return nil - default: - close(c.done) } + c.closed = true + if c.prod != nil { _ = c.prod.Stop() } - if c.ws != nil { - closePayload := map[string]interface{}{ - "reason": map[string]interface{}{ - "code": CloseReasonNormalClose, - "text": "", - }, - } - - _ = c.sendSessionMessage("close", closePayload) - _ = c.ws.Close() - c.ws = nil + if c.wsClient != nil { + _ = c.wsClient.Close() } return nil } func (c *Client) MarshalJSON() ([]byte, error) { - if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { - return webrtcProd.MarshalJSON() - } - return json.Marshal(c.prod) } diff --git a/pkg/ring/snapshot.go b/pkg/ring/snapshot.go index 727a5245..b52eadac 100644 --- a/pkg/ring/snapshot.go +++ b/pkg/ring/snapshot.go @@ -10,11 +10,11 @@ import ( type SnapshotProducer struct { core.Connection - client *RingRestClient - cameraID string + client *RingApi + cameraID int } -func NewSnapshotProducer(client *RingRestClient, cameraID string) *SnapshotProducer { +func NewSnapshotProducer(client *RingApi, cameraID int) *SnapshotProducer { return &SnapshotProducer{ Connection: core.Connection{ ID: core.NewID(), @@ -41,8 +41,7 @@ func NewSnapshotProducer(client *RingRestClient, cameraID string) *SnapshotProdu } func (p *SnapshotProducer) Start() error { - // Fetch snapshot - response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%s", p.cameraID), nil) + response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", p.cameraID), nil) if err != nil { return err } diff --git a/pkg/ring/ws.go b/pkg/ring/ws.go new file mode 100644 index 00000000..51e72fe6 --- /dev/null +++ b/pkg/ring/ws.go @@ -0,0 +1,265 @@ +package ring + +import ( + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type SessionBody struct { + DoorbotID int `json:"doorbot_id"` + SessionID string `json:"session_id"` +} + +type AnswerMessage struct { + Method string `json:"method"` // "sdp" + Body struct { + SessionBody + SDP string `json:"sdp"` + Type string `json:"type"` // "answer" + } `json:"body"` +} + +type IceCandidateMessage struct { + Method string `json:"method"` // "ice" + Body struct { + SessionBody + Ice string `json:"ice"` + MLineIndex int `json:"mlineindex"` + } `json:"body"` +} + +type SessionMessage struct { + Method string `json:"method"` // "session_created" or "session_started" + Body SessionBody `json:"body"` +} + +type PongMessage struct { + Method string `json:"method"` // "pong" + Body SessionBody `json:"body"` +} + +type NotificationMessage struct { + Method string `json:"method"` // "notification" + Body struct { + SessionBody + IsOK bool `json:"is_ok"` + Text string `json:"text"` + } `json:"body"` +} + +type StreamInfoMessage struct { + Method string `json:"method"` // "stream_info" + Body struct { + SessionBody + Transcoding bool `json:"transcoding"` + TranscodingReason string `json:"transcoding_reason"` + } `json:"body"` +} + +type CloseRequest struct { + Method string `json:"method"` // "close" + Body struct { + SessionBody + Reason struct { + Code int `json:"code"` + Text string `json:"text"` + } `json:"reason"` + } `json:"body"` +} + +type WSMessage struct { + Method string `json:"method"` + Body map[string]any `json:"body"` +} + +type WSClient struct { + ws *websocket.Conn + api *RingApi + wsMutex sync.Mutex + cameraID int + dialogID string + sessionID string + + onMessage func(msg WSMessage) + onError func(err error) + onClose func() + + closed chan struct{} +} + +const ( + CloseReasonNormalClose = 0 + CloseReasonAuthenticationFailed = 5 + CloseReasonTimeout = 6 +) + +func StartWebsocket(cameraID int, api *RingApi) (*WSClient, error) { + client := &WSClient{ + api: api, + cameraID: cameraID, + dialogID: uuid.NewString(), + closed: make(chan struct{}), + } + + ticket, err := client.api.GetSocketTicket() + if err != nil { + return nil, err + } + + url := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s", + uuid.NewString(), url.QueryEscape(ticket.Ticket)) + + httpHeader := http.Header{} + httpHeader.Set("User-Agent", "android:com.ringapp") + + client.ws, _, err = websocket.DefaultDialer.Dial(url, httpHeader) + if err != nil { + return nil, err + } + + client.ws.SetCloseHandler(func(code int, text string) error { + client.onWsClose() + return nil + }) + + go client.startPingLoop() + go client.startMessageLoop() + + return client, nil +} + +func (c *WSClient) Close() error { + select { + case <-c.closed: + return nil + default: + close(c.closed) + } + + closePayload := map[string]interface{}{ + "reason": map[string]interface{}{ + "code": CloseReasonNormalClose, + "text": "", + }, + } + + _ = c.sendSessionMessage("close", closePayload) + + return c.ws.Close() +} + +func (c *WSClient) startPingLoop() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-c.closed: + return + case <-ticker.C: + if err := c.sendSessionMessage("ping", nil); err != nil { + return + } + } + } +} + +func (c *WSClient) startMessageLoop() { + for { + select { + case <-c.closed: + return + default: + var res WSMessage + if err := c.ws.ReadJSON(&res); err != nil { + select { + case <-c.closed: + // Ignore error if closed + default: + c.onWsError(err) + } + + return + } + + c.onWsMessage(res) + } + } +} + +func (c *WSClient) activateSession() error { + if err := c.sendSessionMessage("activate_session", nil); err != nil { + return err + } + + streamPayload := map[string]interface{}{ + "audio_enabled": true, + "video_enabled": true, + } + + if err := c.sendSessionMessage("stream_options", streamPayload); err != nil { + return err + } + + return nil +} + +func (c *WSClient) sendSessionMessage(method string, payload map[string]interface{}) error { + select { + case <-c.closed: + return nil + default: + // continue + } + + c.wsMutex.Lock() + defer c.wsMutex.Unlock() + + if payload == nil { + payload = make(map[string]interface{}) + } + + payload["doorbot_id"] = c.cameraID + if c.sessionID != "" { + payload["session_id"] = c.sessionID + } + + msg := map[string]interface{}{ + "method": method, + "dialog_id": c.dialogID, + "body": payload, + } + + // rawMsg, _ := json.Marshal(msg) + // fmt.Printf("ring: sendSessionMessage: %s: %s\n", method, string(rawMsg)) + + if err := c.ws.WriteJSON(msg); err != nil { + return err + } + + return nil +} + +func (c *WSClient) onWsMessage(msg WSMessage) { + if c.onMessage != nil { + c.onMessage(msg) + } +} + +func (c *WSClient) onWsError(err error) { + if c.onError != nil { + c.onError(err) + } +} + +func (c *WSClient) onWsClose() { + if c.onClose != nil { + c.onClose() + } +} diff --git a/www/add.html b/www/add.html index c8808736..53d6b3dc 100644 --- a/www/add.html +++ b/www/add.html @@ -254,25 +254,30 @@ async function handleRingAuth(ev) { ev.preventDefault(); + + const table = document.getElementById('ring-table'); + table.innerText = 'loading...'; + const query = new URLSearchParams(new FormData(ev.target)); const url = new URL('api/ring?' + query.toString(), location.href); const r = await fetch(url, {cache: 'no-cache'}); + + if (!r.ok) { + table.innerText = (await r.text()) || 'Unknown error'; + return; + } + const data = await r.json(); + table.innerText = ''; + if (data.needs_2fa) { document.getElementById('tfa-field').style.display = 'block'; document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code'; return; } - if (!r.ok) { - const table = document.getElementById('ring-table'); - table.innerText = data.error || 'Unknown error'; - return; - } - - const table = document.getElementById('ring-table'); drawTable(table, data); } From bf45f64a7e3f9eaab0c977e2555c4541da240f72 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 26 May 2025 21:56:45 +0200 Subject: [PATCH 19/84] - 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 20/84] 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 21/84] 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 79656d13447dfcd6cc1fe6a2da589243586751f8 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 26 May 2025 23:10:55 +0200 Subject: [PATCH 22/84] update readme --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 90a2537f..8a1e020a 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg * [Source: Hass](#source-hass) * [Source: ISAPI](#source-isapi) * [Source: Nest](#source-nest) + * [Source: Ring](#source-ring) * [Source: Roborock](#source-roborock) * [Source: WebRTC](#source-webrtc) * [Source: WebTorrent](#source-webtorrent) @@ -220,6 +221,7 @@ Supported for sources: - [Hikvision ISAPI](#source-isapi) cameras - [Roborock vacuums](#source-roborock) models with cameras - [Exec](#source-exec) audio on server +- [Ring](#source-ring) cameras - [Any Browser](#incoming-browser) as IP-camera Two way audio can be used in browser with [WebRTC](#module-webrtc) technology. The browser will give access to the microphone only for HTTPS sites ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)). @@ -642,6 +644,16 @@ streams: nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=*** ``` +#### Source: Ring + +This source type support Ring cameras with [two way audio](#two-way-audio) support. If you have a `refresh_token` and `device_id` - you can use it in `go2rtc.yaml` config file. Otherwise, you can use the go2rtc interface and add your ring account (WebUI > Add > Ring). Once added, it will list all your Ring cameras. + +```yaml +streams: + ring: ring:?device_id=XXX&refresh_token=XXX + ring_snapshot: ring:?device_id=XXX&refresh_token=XXX&snapshot +``` + #### Source: Roborock *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* From 859cd1cbe63cf7acb445ce27bde47b5d72a748a6 Mon Sep 17 00:00:00 2001 From: seydx Date: Sun, 1 Jun 2025 01:44:01 +0300 Subject: [PATCH 23/84] 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 24/84] 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 25/84] 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 dfc1f45f974f8b182b1f9574dc8c10d2e0817cab Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 2 Jun 2025 22:06:47 +0300 Subject: [PATCH 26/84] support preloading streams --- internal/streams/preload.go | 30 ++++++++++++ internal/streams/streams.go | 17 +++++-- pkg/preload/producer.go | 92 +++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 internal/streams/preload.go create mode 100644 pkg/preload/producer.go diff --git a/internal/streams/preload.go b/internal/streams/preload.go new file mode 100644 index 00000000..c811cc5c --- /dev/null +++ b/internal/streams/preload.go @@ -0,0 +1,30 @@ +package streams + +import ( + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/preload" +) + +func (s *Stream) Preload(query url.Values) error { + cons := preload.NewPreload(query) + + if err := s.AddConsumer(cons); err != nil { + return err + } + + return nil +} + +func Preload(src string) { + name, rawQuery, _ := strings.Cut(src, "#") + query := ParseQuery(rawQuery) + + if stream := Get(name); stream != nil { + if err := stream.Preload(query); err != nil { + log.Error().Err(err).Caller().Send() + } + return + } +} diff --git a/internal/streams/streams.go b/internal/streams/streams.go index dcbaba28..7bbccace 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -16,6 +16,7 @@ func Init() { var cfg struct { Streams map[string]any `yaml:"streams"` Publish map[string]any `yaml:"publish"` + Preload []string `yaml:"preload"` } app.LoadConfig(&cfg) @@ -29,14 +30,22 @@ func Init() { api.HandleFunc("api/streams", apiStreams) api.HandleFunc("api/streams.dot", apiStreamsDOT) - if cfg.Publish == nil { + if cfg.Publish == nil && cfg.Preload == nil { return } time.AfterFunc(time.Second, func() { - for name, dst := range cfg.Publish { - if stream := Get(name); stream != nil { - Publish(stream, dst) + if cfg.Publish != nil { + for name, dst := range cfg.Publish { + if stream := Get(name); stream != nil { + Publish(stream, dst) + } + } + } + + if cfg.Preload != nil { + for _, src := range cfg.Preload { + Preload(src) } } }) diff --git a/pkg/preload/producer.go b/pkg/preload/producer.go new file mode 100644 index 00000000..811cf2e4 --- /dev/null +++ b/pkg/preload/producer.go @@ -0,0 +1,92 @@ +package preload + +import ( + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Preload struct { + core.Connection + + Closed core.Waiter +} + +func NewPreload(query url.Values) *Preload { + medias := core.ParseQuery(query) + + for _, value := range query["microphone"] { + media := &core.Media{Kind: core.KindAudio, Direction: core.DirectionRecvonly} + + for _, name := range strings.Split(value, ",") { + name = strings.ToUpper(name) + switch name { + case "", "COPY": + name = core.CodecAny + } + media.Codecs = append(media.Codecs, &core.Codec{Name: name}) + } + + medias = append(medias, media) + } + + if len(medias) == 0 { + medias = []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{{Name: core.CodecAny}}, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{{Name: core.CodecAny}}, + }, + } + } + + return &Preload{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "preload", + Medias: medias, + Protocol: "native", + RemoteAddr: "localhost", + UserAgent: "go2rtc", + }, + } +} + +func (p *Preload) GetMedias() []*core.Media { + return p.Medias +} + +func (p *Preload) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + sender.Bind(track) + p.Senders = append(p.Senders, sender) + return nil +} + +func (p *Preload) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + receiver := core.NewReceiver(media, codec) + p.Receivers = append(p.Receivers, receiver) + return receiver, nil +} + +func (p *Preload) Start() error { + p.Closed.Wait() + return nil +} + +func (p *Preload) Stop() error { + for _, receiver := range p.Receivers { + receiver.Close() + } + for _, sender := range p.Senders { + sender.Close() + } + p.Closed.Done(nil) + return nil +} From 020549ef60103724575aee52e20096e817cb694b Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 2 Jun 2025 22:16:43 +0300 Subject: [PATCH 27/84] readme --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 90a2537f..09cafaeb 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg * [Incoming sources](#incoming-sources) * [Stream to camera](#stream-to-camera) * [Publish stream](#publish-stream) + * [Preload stream](#preload-stream) * [Module: API](#module-api) * [Module: RTSP](#module-rtsp) * [Module: RTMP](#module-rtmp) @@ -818,6 +819,25 @@ streams: - **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming. - **YouTube** > Create > Go live > Stream latency: Ultra low-latency > Copy: Stream URL + Stream key. +### Preload stream + +You can preload any stream on go2rtc start. This is useful for cameras that take a long time to start up. + +```yaml +preload: + - my_stream1 + - my_stream2#video#audio#microphone + - my_stream3#video=h265#audio=opus +streams: + my_stream1: + - rtsp://129.168.3.1:554/stream1 + my_stream2: + - rtsp://129.168.3.1:554/stream1 + my_stream3: + - rtsp://129.168.3.1:554/stream1 + - ffmpeg:my_stream3#video=copy#audio=opus +```` + ### Module: API The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`. From 493fa1ef6a1478e39c6a18f075c1434e43007242 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 5 Jun 2025 11:33:03 +0300 Subject: [PATCH 28/84] add api endpoints and change config syntax --- internal/streams/api.go | 62 +++++++++++++++++++++++++++++++++++++ internal/streams/preload.go | 26 +++++++++------- internal/streams/streams.go | 11 ++++--- pkg/preload/producer.go | 6 ++-- 4 files changed, 85 insertions(+), 20 deletions(-) diff --git a/internal/streams/api.go b/internal/streams/api.go index 061e61c2..c0c6744b 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -122,3 +122,65 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { api.Response(w, dot, "text/vnd.graphviz") } + +func apiPreload(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + src := query.Get("src") + query.Del("src") + + videoQuery := query.Get("video") + audioQuery := query.Get("audio") + micQuery := query.Get("microphone") + + if src == "" { + http.Error(w, "no source", http.StatusBadRequest) + return + } + + switch r.Method { + case "PUT": + // check if stream exists + if stream := Get(src); stream == nil { + http.Error(w, "stream not found", http.StatusNotFound) + return + } + + // check if consumer exists + if cons, ok := preloads[src]; ok { + cons.Stop() + delete(preloads, src) + } + + var rawQuery string + if videoQuery != "" { + rawQuery += "video=" + videoQuery + "#" + } + if audioQuery != "" { + rawQuery += "audio=" + audioQuery + "#" + } + if micQuery != "" { + rawQuery += "microphone=" + micQuery + } + + if err := app.PatchConfig([]string{"preload", src}, rawQuery); err != nil { + log.Error().Err(err).Str("src", src).Msg("Failed to patch config for PUT") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + Preload(src, rawQuery) + + case "DELETE": + if cons, ok := preloads[src]; ok { + cons.Stop() + delete(preloads, src) + } + + if err := app.PatchConfig([]string{"preload", src}, nil); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + default: + http.Error(w, "", http.StatusMethodNotAllowed) + } +} diff --git a/internal/streams/preload.go b/internal/streams/preload.go index c811cc5c..7314df55 100644 --- a/internal/streams/preload.go +++ b/internal/streams/preload.go @@ -2,13 +2,15 @@ package streams import ( "net/url" - "strings" "github.com/AlexxIT/go2rtc/pkg/preload" ) -func (s *Stream) Preload(query url.Values) error { - cons := preload.NewPreload(query) +var preloads = map[string]*preload.Preload{} + +func (s *Stream) Preload(name string, query url.Values) error { + cons := preload.NewPreload(name, query) + preloads[name] = cons if err := s.AddConsumer(cons); err != nil { return err @@ -17,14 +19,16 @@ func (s *Stream) Preload(query url.Values) error { return nil } -func Preload(src string) { - name, rawQuery, _ := strings.Cut(src, "#") - query := ParseQuery(rawQuery) - - if stream := Get(name); stream != nil { - if err := stream.Preload(query); err != nil { - log.Error().Err(err).Caller().Send() - } +func Preload(src string, rawQuery string) { + // skip if exists + if _, ok := preloads[src]; ok { return } + + if stream := Get(src); stream != nil { + query := ParseQuery(rawQuery) + if err := stream.Preload(src, query); err != nil { + log.Error().Err(err).Caller().Send() + } + } } diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 7bbccace..8f07ea12 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -14,9 +14,9 @@ import ( func Init() { var cfg struct { - Streams map[string]any `yaml:"streams"` - Publish map[string]any `yaml:"publish"` - Preload []string `yaml:"preload"` + Streams map[string]any `yaml:"streams"` + Publish map[string]any `yaml:"publish"` + Preload map[string]string `yaml:"preload"` } app.LoadConfig(&cfg) @@ -29,6 +29,7 @@ func Init() { api.HandleFunc("api/streams", apiStreams) api.HandleFunc("api/streams.dot", apiStreamsDOT) + api.HandleFunc("api/preload", apiPreload) if cfg.Publish == nil && cfg.Preload == nil { return @@ -44,8 +45,8 @@ func Init() { } if cfg.Preload != nil { - for _, src := range cfg.Preload { - Preload(src) + for name, rawQuery := range cfg.Preload { + Preload(name, rawQuery) } } }) diff --git a/pkg/preload/producer.go b/pkg/preload/producer.go index 811cf2e4..05a50d52 100644 --- a/pkg/preload/producer.go +++ b/pkg/preload/producer.go @@ -9,11 +9,10 @@ import ( type Preload struct { core.Connection - Closed core.Waiter } -func NewPreload(query url.Values) *Preload { +func NewPreload(name string, query url.Values) *Preload { medias := core.ParseQuery(query) for _, value := range query["microphone"] { @@ -49,11 +48,10 @@ func NewPreload(query url.Values) *Preload { return &Preload{ Connection: core.Connection{ ID: core.NewID(), - FormatName: "preload", Medias: medias, Protocol: "native", RemoteAddr: "localhost", - UserAgent: "go2rtc", + UserAgent: "go2rtc/preload", }, } } From 8ab7aeb8b25995199980e2411793b302ee9ab126 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 5 Jun 2025 15:51:14 +0300 Subject: [PATCH 29/84] update readme --- README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 09cafaeb..627d9bb9 100644 --- a/README.md +++ b/README.md @@ -825,18 +825,19 @@ You can preload any stream on go2rtc start. This is useful for cameras that take ```yaml preload: - - my_stream1 - - my_stream2#video#audio#microphone - - my_stream3#video=h265#audio=opus + camera1: # default: video&audio = ANY + camera2: "video" # preload only video track + camera3: "video=h264#audio=opus" # initialize transcoding pipeline + streams: - my_stream1: - - rtsp://129.168.3.1:554/stream1 - my_stream2: - - rtsp://129.168.3.1:554/stream1 - my_stream3: - - rtsp://129.168.3.1:554/stream1 - - ffmpeg:my_stream3#video=copy#audio=opus -```` + camera1: + - rtsp://192.168.1.100/stream + camera2: + - rtsp://192.168.1.101/stream + camera3: + - rtsp://192.168.1.102/h265stream + - ffmpeg:camera3#video=h264#audio=opus#hardware +``` ### Module: API From 91eeefec68405539d6d15996dbbb4d0d951b82fb Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 5 Jun 2025 16:01:49 +0300 Subject: [PATCH 30/84] openapi: add preload endpoints --- api/openapi.yaml | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/api/openapi.yaml b/api/openapi.yaml index 618acb48..a2d66a87 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -237,6 +237,54 @@ paths: + /api/preload: + put: + summary: Preload new stream + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream source (name) + required: true + schema: { type: string } + example: "camera1" + - name: video + in: query + description: Video codecs filter + required: false + schema: { type: string } + example: all,h264,h265,... + - name: audio + in: query + description: Audio codecs filter + required: false + schema: { type: string } + example: all,aac,opus,... + - name: microphone + in: query + description: Microphone codecs filter + required: false + schema: { type: string } + example: all,aac,opus,... + responses: + default: + description: Default response + delete: + summary: Delete preloaded stream + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream source (name) + required: true + schema: { type: string } + example: "camera1" + responses: + default: + description: Default response + + + /api/streams?src={src}: get: summary: Get stream info in JSON format From 42a67f8ad5ecb684b2d530c2b884a299604d6588 Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 6 Jun 2025 02:18:00 +0200 Subject: [PATCH 31/84] 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 b6579122d1037934bccf33e1007a15e76bd2f166 Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 6 Jun 2025 03:11:28 +0200 Subject: [PATCH 32/84] fix --- internal/streams/api.go | 12 +++++++++--- pkg/preload/producer.go | 6 +++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/internal/streams/api.go b/internal/streams/api.go index c0c6744b..47febeb4 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -140,14 +140,15 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { switch r.Method { case "PUT": // check if stream exists - if stream := Get(src); stream == nil { + stream := Get(src) + if stream == nil { http.Error(w, "stream not found", http.StatusNotFound) return } // check if consumer exists if cons, ok := preloads[src]; ok { - cons.Stop() + stream.RemoveConsumer(cons) delete(preloads, src) } @@ -172,7 +173,12 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { case "DELETE": if cons, ok := preloads[src]; ok { - cons.Stop() + if stream := Get(src); stream != nil { + stream.RemoveConsumer(cons) + } else { + cons.Stop() + } + delete(preloads, src) } diff --git a/pkg/preload/producer.go b/pkg/preload/producer.go index 05a50d52..932f5e29 100644 --- a/pkg/preload/producer.go +++ b/pkg/preload/producer.go @@ -9,7 +9,7 @@ import ( type Preload struct { core.Connection - Closed core.Waiter + closed core.Waiter } func NewPreload(name string, query url.Values) *Preload { @@ -74,7 +74,7 @@ func (p *Preload) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver } func (p *Preload) Start() error { - p.Closed.Wait() + p.closed.Wait() return nil } @@ -85,6 +85,6 @@ func (p *Preload) Stop() error { for _, sender := range p.Senders { sender.Close() } - p.Closed.Done(nil) + p.closed.Done(nil) return nil } From a4d7fd0d9540ee19b14db4ee21b51a3316487517 Mon Sep 17 00:00:00 2001 From: Alex X Date: Thu, 12 Jun 2025 16:52:05 +0300 Subject: [PATCH 33/84] Add support yandex source --- internal/yandex/README.md | 22 +++++ internal/yandex/goloom.go | 152 ++++++++++++++++++++++++++++ internal/yandex/yandex.go | 44 +++++++++ main.go | 2 + pkg/webrtc/server.go | 3 +- pkg/yandex/session.go | 203 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 internal/yandex/README.md create mode 100644 internal/yandex/goloom.go create mode 100644 internal/yandex/yandex.go create mode 100644 pkg/yandex/session.go diff --git a/internal/yandex/README.md b/internal/yandex/README.md new file mode 100644 index 00000000..951e1e99 --- /dev/null +++ b/internal/yandex/README.md @@ -0,0 +1,22 @@ +# Yandex + +Source for receiving stream from new [Yandex IP camera](https://alice.yandex.ru/smart-home/security/ipcamera). + +## Get Yandex token + +1. Install HomeAssistant integration [YandexStation](https://github.com/AlexxIT/YandexStation). +2. Copy token from HomeAssistant config folder: `/config/.storage/core.config_entries`, key: `"x_token"`. + +## Get device ID + +1. Open this link in any browser: https://iot.quasar.yandex.ru/m/v3/user/devices +2. Copy ID of your camera, key: `"id"`. + +## Config examples + +```yaml +streams: + yandex_stream: yandex:?x_token=XXXX&device_id=XXXX + yandex_snapshot: yandex:?x_token=XXXX&device_id=XXXX&snapshot + yandex_snapshot_custom_size: yandex:?x_token=XXXX&device_id=XXXX&snapshot=h=540 +``` diff --git a/internal/yandex/goloom.go b/internal/yandex/goloom.go new file mode 100644 index 00000000..6bccb756 --- /dev/null +++ b/internal/yandex/goloom.go @@ -0,0 +1,152 @@ +package yandex + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/AlexxIT/go2rtc/internal/webrtc" + "github.com/AlexxIT/go2rtc/pkg/core" + xwebrtc "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/google/uuid" + "github.com/gorilla/websocket" + pion "github.com/pion/webrtc/v4" +) + +func goloomClient(serviceURL, serviceName, roomId, participantId, credentials string) (core.Producer, error) { + conn, _, err := websocket.DefaultDialer.Dial(serviceURL, nil) + if err != nil { + return nil, err + } + defer func() { + time.Sleep(time.Second) + _ = conn.Close() + }() + + s := fmt.Sprintf(`{"hello": { +"credentials":"%s","participantId":"%s","roomId":"%s","serviceName":"%s","sdkInitializationId":"%s", +"capabilitiesOffer":{},"sendAudio":false,"sendSharing":false,"sendVideo":false, +"sdkInfo":{"hwConcurrency":4,"implementation":"browser","version":"5.4.0"}, +"participantAttributes":{"description":"","name":"mike","role":"SPEAKER"}, +"participantMeta":{"description":"","name":"mike","role":"SPEAKER","sendAudio":false,"sendVideo":false} +},"uid":"%s"}`, + credentials, participantId, roomId, serviceName, + uuid.NewString(), uuid.NewString(), + ) + + err = conn.WriteMessage(websocket.TextMessage, []byte(s)) + if err != nil { + return nil, err + } + + if _, _, err = conn.ReadMessage(); err != nil { + return nil, err + } + + pc, err := webrtc.PeerConnection(true) + if err != nil { + return nil, err + } + + prod := xwebrtc.NewConn(pc) + prod.FormatName = "yandex" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "wss" + + var connState core.Waiter + + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateConnecting: + case pion.PeerConnectionStateConnected: + connState.Done(nil) + default: + connState.Done(errors.New("webrtc: " + msg.String())) + } + } + }) + + go func() { + for { + var msg map[string]json.RawMessage + if err = conn.ReadJSON(&msg); err != nil { + return + } + + for k, v := range msg { + switch k { + case "uid": + continue + case "serverHello": + case "subscriberSdpOffer": + var sdp subscriberSdp + if err = json.Unmarshal(v, &sdp); err != nil { + return + } + //log.Trace().Msgf("offer:\n%s", sdp.Sdp) + if err = prod.SetOffer(sdp.Sdp); err != nil { + return + } + if sdp.Sdp, err = prod.GetAnswer(); err != nil { + return + } + //log.Trace().Msgf("answer:\n%s", sdp.Sdp) + + var raw []byte + if raw, err = json.Marshal(sdp); err != nil { + return + } + s = fmt.Sprintf(`{"uid":"%s","subscriberSdpAnswer":%s}`, uuid.NewString(), raw) + if err = conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return + } + case "webrtcIceCandidate": + var candidate webrtcIceCandidate + if err = json.Unmarshal(v, &candidate); err != nil { + return + } + if err = prod.AddCandidate(candidate.Candidate); err != nil { + return + } + } + //log.Trace().Msgf("%s : %s", k, v) + } + + if msg["ack"] != nil { + continue + } + + s = fmt.Sprintf(`{"uid":%s,"ack":{"status":{"code":"OK"}}}`, msg["uid"]) + if err = conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return + } + } + }() + + if err = connState.Wait(); err != nil { + return nil, err + } + + s = fmt.Sprintf(`{"uid":"%s","setSlots":{"slots":[{"width":0,"height":0}],"audioSlotsCount":0,"key":1,"shutdownAllVideo":false,"withSelfView":false,"selfViewVisibility":"ON_LOADING_THEN_HIDE","gridConfig":{}}}`, uuid.NewString()) + if err = conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return nil, err + } + + return prod, nil +} + +type subscriberSdp struct { + PcSeq int `json:"pcSeq"` + Sdp string `json:"sdp"` +} + +type webrtcIceCandidate struct { + PcSeq int `json:"pcSeq"` + Target string `json:"target"` + Candidate string `json:"candidate"` + SdpMid string `json:"sdpMid"` + SdpMlineIndex int `json:"sdpMlineIndex"` +} diff --git a/internal/yandex/yandex.go b/internal/yandex/yandex.go new file mode 100644 index 00000000..05680b30 --- /dev/null +++ b/internal/yandex/yandex.go @@ -0,0 +1,44 @@ +package yandex + +import ( + "net/url" + + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/yandex" +) + +func Init() { + streams.HandleFunc("yandex", func(source string) (core.Producer, error) { + u, err := url.Parse(source) + if err != nil { + return nil, err + } + + query := u.Query() + token := query.Get("x_token") + + session, err := yandex.GetSession(token) + if err != nil { + return nil, err + } + + deviceID := query.Get("device_id") + + if query.Has("snapshot") { + rawURL, err := session.GetSnapshotURL(deviceID) + if err != nil { + return nil, err + } + rawURL += "/current.jpg?" + query.Get("snapshot") + "#header=Cookie:" + session.GetCookieString(rawURL) + return streams.GetProducer(rawURL) + } + + room, err := session.WebrtcCreateRoom(deviceID) + if err != nil { + return nil, err + } + + return goloomClient(room.ServiceUrl, room.ServiceName, room.RoomId, room.ParticipantId, room.Credentials) + }) +} diff --git a/main.go b/main.go index 295de219..e85c5900 100644 --- a/main.go +++ b/main.go @@ -39,6 +39,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/webrtc" "github.com/AlexxIT/go2rtc/internal/webtorrent" "github.com/AlexxIT/go2rtc/internal/wyoming" + "github.com/AlexxIT/go2rtc/internal/yandex" "github.com/AlexxIT/go2rtc/pkg/shell" ) @@ -96,6 +97,7 @@ func main() { alsa.Init() // alsa source flussonic.Init() eseecloud.Init() + yandex.Init() // 6. Helper modules diff --git a/pkg/webrtc/server.go b/pkg/webrtc/server.go index f8abc70a..4714a6a4 100644 --- a/pkg/webrtc/server.go +++ b/pkg/webrtc/server.go @@ -65,7 +65,8 @@ transeivers: switch tr.Direction() { case webrtc.RTPTransceiverDirectionSendrecv: - _ = tr.Sender().Stop() + _ = tr.Sender().Stop() // don't know if necessary + _ = tr.SetSender(tr.Sender(), nil) // set direction to recvonly case webrtc.RTPTransceiverDirectionSendonly: _ = tr.Stop() } diff --git a/pkg/yandex/session.go b/pkg/yandex/session.go new file mode 100644 index 00000000..bd0e3a2b --- /dev/null +++ b/pkg/yandex/session.go @@ -0,0 +1,203 @@ +package yandex + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/cookiejar" + "strings" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Session struct { + token string + client *http.Client +} + +var sessions = map[string]*Session{} +var sessionsMu sync.Mutex + +func GetSession(token string) (*Session, error) { + sessionsMu.Lock() + defer sessionsMu.Unlock() + + if session, ok := sessions[token]; ok { + return session, nil + } + + session := &Session{token: token} + if err := session.Login(); err != nil { + return nil, err + } + + sessions[token] = session + + return session, nil +} + +func (s *Session) Login() error { + req, err := http.NewRequest( + "POST", "https://mobileproxy.passport.yandex.net/1/bundle/auth/x_token/", + strings.NewReader("type=x-token&retpath=https%3A%2F%2Fwww.yandex.ru"), + ) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Ya-Consumer-Authorization", "OAuth "+s.token) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + + var auth struct { + PassportHost string `json:"passport_host"` + Status string `json:"status"` + TrackId string `json:"track_id"` + } + if err = json.NewDecoder(res.Body).Decode(&auth); err != nil { + return err + } + + if auth.Status != "ok" { + return errors.New("yandex: login error: " + auth.Status) + } + + s.client = &http.Client{Timeout: 15 * time.Second} + s.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + s.client.Jar, _ = cookiejar.New(nil) + + res, err = s.client.Get(auth.PassportHost + "/auth/session/?track_id=" + auth.TrackId) + if err != nil { + return err + } + + s.client.CheckRedirect = nil + + return nil +} + +func (s *Session) Get(url string) (*http.Response, error) { + return s.client.Get(url) +} + +func (s *Session) GetCSRF() (string, error) { + res, err := s.Get("https://yandex.ru/quasar") + if err != nil { + return "", err + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + token := core.Between(string(body), `"csrfToken2":"`, `"`) + return token, nil +} + +func (s *Session) GetCookieString(url string) string { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "" + } + for _, cookie := range s.client.Jar.Cookies(req.URL) { + req.AddCookie(cookie) + } + return req.Header.Get("Cookie") +} + +func (s *Session) GetDevices() ([]Device, error) { + res, err := s.Get("https://iot.quasar.yandex.ru/m/v3/user/devices") + if err != nil { + return nil, err + } + + var data struct { + Households []struct { + All []Device `json:"all"` + } `json:"households"` + } + + if err = json.NewDecoder(res.Body).Decode(&data); err != nil { + return nil, err + } + + var devices []Device + for _, household := range data.Households { + devices = append(devices, household.All...) + } + return devices, nil +} + +func (s *Session) GetSnapshotURL(deviceID string) (string, error) { + devices, err := s.GetDevices() + if err != nil { + return "", err + } + + for _, device := range devices { + if device.Id == deviceID { + return device.Parameters.SnapshotUrl, nil + } + } + + return "", errors.New("yandex: can't get snapshot url for device: " + deviceID) +} + +func (s *Session) WebrtcCreateRoom(deviceID string) (*Room, error) { + csrf, err := s.GetCSRF() + if err != nil { + return nil, err + } + + req, err := http.NewRequest( + "POST", "https://iot.quasar.yandex.ru/m/v3/user/devices/"+deviceID+"/webrtc/create-room", + strings.NewReader(`{"protocol":"whip"}`), + ) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("X-CSRF-Token", csrf) + + res, err := s.client.Do(req) + if err != nil { + return nil, err + } + + var data struct { + Result Room `json:"result"` + } + if err = json.NewDecoder(res.Body).Decode(&data); err != nil { + return nil, err + } + + return &data.Result, nil +} + +type Device struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Parameters struct { + SnapshotUrl string `json:"snapshot_url,omitempty"` + } `json:"parameters"` +} + +type Room struct { + ServiceUrl string `json:"service_url"` + ServiceName string `json:"service_name"` + RoomId string `json:"room_id"` + ParticipantId string `json:"participant_id"` + Credentials string `json:"jwt"` +} From 230c80c70ec9efbf37006a579e87c89ae4baad44 Mon Sep 17 00:00:00 2001 From: Andreas Ehn Date: Sat, 14 Jun 2025 11:24:12 +0800 Subject: [PATCH 34/84] Improve spelling and grammar in README.md --- README.md | 308 +++++++++++++++++++++++++++--------------------------- 1 file changed, 154 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index 90a2537f..9712bbde 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![goreport](https://goreportcard.com/badge/github.com/AlexxIT/go2rtc)](https://goreportcard.com/report/github.com/AlexxIT/go2rtc) -Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg, RTMP, etc. +Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FFmpeg, RTMP, etc. ![](assets/go2rtc.png) @@ -20,11 +20,11 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg - [publish](#publish-stream) any source to popular streaming services (YouTube, Telegram, etc.) - first project in the World with support streaming from [HomeKit Cameras](#source-homekit) - support H265 for WebRTC in browser (Safari only, [read more](https://github.com/AlexxIT/Blog/issues/5)) -- on the fly transcoding for unsupported codecs via [FFmpeg](#source-ffmpeg) +- on-the-fly transcoding for unsupported codecs via [FFmpeg](#source-ffmpeg) - play audio files and live streams on some cameras with [speaker](#stream-to-camera) - multi-source 2-way [codecs negotiation](#codecs-negotiation) - mixing tracks from different sources to single stream - - auto match client supported codecs + - auto-match client-supported codecs - [2-way audio](#two-way-audio) for some cameras - streaming from private networks via [ngrok](#module-ngrok) - can be [integrated to](#module-api) any smart home platform or be used as [standalone app](#go2rtc-binary) @@ -182,11 +182,11 @@ Available modules: ### Module: Streams -**go2rtc** support different stream source types. You can config one or multiple links of any type as stream source. +**go2rtc** supports different stream source types. You can config one or multiple links of any type as a stream source. Available source types: -- [rtsp](#source-rtsp) - `RTSP` and `RTSPS` cameras with [two way audio](#two-way-audio) support +- [rtsp](#source-rtsp) - `RTSP` and `RTSPS` cameras with [two-way audio](#two-way-audio) support - [rtmp](#source-rtmp) - `RTMP` streams - [http](#source-http) - `HTTP-FLV`, `MPEG-TS`, `JPEG` (snapshots), `MJPEG` streams - [onvif](#source-onvif) - get camera `RTSP` link and snapshot link using `ONVIF` protocol @@ -203,16 +203,16 @@ Available source types: - [gopro](#source-gopro) - GoPro cameras - [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service - [hass](#source-hass) - Home Assistant integration -- [isapi](#source-isapi) - two way audio for Hikvision (ISAPI) cameras +- [isapi](#source-isapi) - two-way audio for Hikvision (ISAPI) cameras - [roborock](#source-roborock) - Roborock vacuums with cameras - [webrtc](#source-webrtc) - WebRTC/WHEP sources - [webtorrent](#source-webtorrent) - WebTorrent source from another go2rtc Read more about [incoming sources](#incoming-sources) -#### Two way audio +#### Two-way audio -Supported for sources: +Supported sources: - [RTSP cameras](#source-rtsp) with [ONVIF Profile T](https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec.pdf) (back channel connection) - [DVRIP](#source-dvrip) cameras @@ -222,9 +222,9 @@ Supported for sources: - [Exec](#source-exec) audio on server - [Any Browser](#incoming-browser) as IP-camera -Two way audio can be used in browser with [WebRTC](#module-webrtc) technology. The browser will give access to the microphone only for HTTPS sites ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)). +Two-way audio can be used in browser with [WebRTC](#module-webrtc) technology. The browser will give access to the microphone only for HTTPS sites ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)). -go2rtc also support [play audio](#stream-to-camera) files and live streams on this cameras. +go2rtc also supports [play audio](#stream-to-camera) files and live streams on this cameras. #### Source: RTSP @@ -242,13 +242,13 @@ streams: **Recommendations** -- **Amcrest Doorbell** users may want to disable two way audio, because with an active stream you won't have a call button working. You need to add `#backchannel=0` to the end of your RTSP link in YAML config file +- **Amcrest Doorbell** users may want to disable two-way audio, because with an active stream, you won't have a working call button. You need to add `#backchannel=0` to the end of your RTSP link in YAML config file - **Dahua Doorbell** users may want to change [audio codec](https://github.com/AlexxIT/go2rtc/issues/49#issuecomment-2127107379) for proper 2-way audio. Make sure not to request backchannel multiple times by adding `#backchannel=0` to other stream sources of the same doorbell. The `unicast=true&proto=Onvif` is preferred for 2-way audio as this makes the doorbell accept multiple codecs for the incoming audio -- **Reolink** users may want NOT to use RTSP protocol at all, some camera models have a very awful unusable stream implementation +- **Reolink** users may want NOT to use RTSP protocol at all, some camera models have a very awful, unusable stream implementation - **Ubiquiti UniFi** users may want to disable HTTPS verification. Use `rtspx://` prefix instead of `rtsps://`. And don't use `?enableSrtp` [suffix](https://github.com/AlexxIT/go2rtc/issues/81) - **TP-Link Tapo** users may skip login and password, because go2rtc support login [without them](https://drmnsamoliu.github.io/video.html) -- If your camera has two RTSP links - you can add both of them as sources. This is useful when streams has different codecs, as example AAC audio with main stream and PCMU/PCMA audio with second stream -- If the stream from your camera is glitchy, try using [ffmpeg source](#source-ffmpeg). It will not add CPU load if you won't use transcoding +- If your camera has two RTSP links, you can add both as sources. This is useful when streams have different codecs, for example AAC audio with main stream and PCMU/PCMA audio with second stream +- If the stream from your camera is glitchy, try using [ffmpeg source](#source-ffmpeg). It will not add CPU load if you don't use transcoding - If the stream from your camera is very glitchy, try to use transcoding with [ffmpeg source](#source-ffmpeg) **Other options** @@ -257,7 +257,7 @@ Format: `rtsp...#{param1}#{param2}#{param3}` - Add custom timeout `#timeout=30` (in seconds) - Ignore audio - `#media=video` or ignore video - `#media=audio` -- Ignore two way audio API `#backchannel=0` - important for some glitchy cameras +- Ignore two-way audio API `#backchannel=0` - important for some glitchy cameras - Use WebSocket transport `#transport=ws...` **RTSP over WebSocket** @@ -272,7 +272,7 @@ streams: #### Source: RTMP -You can get stream from RTMP server, for example [Nginx with nginx-rtmp-module](https://github.com/arut/nginx-rtmp-module). +You can get a stream from an RTMP server, for example [Nginx with nginx-rtmp-module](https://github.com/arut/nginx-rtmp-module). ```yaml streams: @@ -288,7 +288,7 @@ Support Content-Type: - **HTTP-MJPEG** (`multipart/x`) - simple MJPEG stream over HTTP - **MPEG-TS** (`video/mpeg`) - legacy [streaming format](https://en.wikipedia.org/wiki/MPEG_transport_stream) -Source also support HTTP and TCP streams with autodetection for different formats: **MJPEG**, **H.264/H.265 bitstream**, **MPEG-TS**. +Source also supports HTTP and TCP streams with autodetection for different formats: **MJPEG**, **H.264/H.265 bitstream**, **MPEG-TS**. ```yaml streams: @@ -308,7 +308,7 @@ streams: custom_header: "https://mjpeg.sanford.io/count.mjpeg#header=Authorization: Bearer XXX" ``` -**PS.** Dahua camera has bug: if you select MJPEG codec for RTSP second stream - snapshot won't work. +**PS.** Dahua camera has a bug: if you select MJPEG codec for RTSP second stream, snapshot won't work. #### Source: ONVIF @@ -316,7 +316,7 @@ streams: The source is not very useful if you already know RTSP and snapshot links for your camera. But it can be useful if you don't. -**WebUI > Add** webpage support ONVIF autodiscovery. Your server must be on the same subnet as the camera. If you use docker, you must use "network host". +**WebUI > Add** webpage support ONVIF autodiscovery. Your server must be on the same subnet as the camera. If you use Docker, you must use "network host". ```yaml streams: @@ -327,7 +327,7 @@ streams: #### Source: FFmpeg -You can get any stream or file or device via FFmpeg and push it to go2rtc. The app will automatically start FFmpeg with the proper arguments when someone starts watching the stream. +You can get any stream, file or device via FFmpeg and push it to go2rtc. The app will automatically start FFmpeg with the proper arguments when someone starts watching the stream. - FFmpeg preistalled for **Docker** and **Hass Add-on** users - **Hass Add-on** users can target files from [/media](https://www.home-assistant.io/more-info/local-media/setup-media/) folder @@ -342,7 +342,7 @@ streams: # [FILE] video will be transcoded to H264, audio will be skipped file2: ffmpeg:/media/BigBuckBunny.mp4#video=h264 - # [FILE] video will be copied, audio will be transcoded to pcmu + # [FILE] video will be copied, audio will be transcoded to PCMU file3: ffmpeg:/media/BigBuckBunny.mp4#video=copy#audio=pcmu # [HLS] video will be copied, audio will be skipped @@ -355,9 +355,9 @@ streams: rotate: ffmpeg:rtsp://12345678@192.168.1.123/av_stream/ch0#video=h264#rotate=90 ``` -All trascoding formats has [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. +All transcoding formats have [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. -But you can override them via YAML config. You can also add your own formats to config and use them with source params. +But you can override them via YAML config. You can also add your own formats to the config and use them with source params. ```yaml ffmpeg: @@ -385,12 +385,12 @@ Read more about [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/H #### Source: FFmpeg Device -You can get video from any USB-camera or Webcam as RTSP or WebRTC stream. This is part of FFmpeg integration. +You can get video from any USB camera or Webcam as RTSP or WebRTC stream. This is part of FFmpeg integration. -- check available devices in Web interface +- check available devices in web interface - `video_size` and `framerate` must be supported by your camera! - for Linux supported only video for now -- for macOS you can stream Facetime camera or whole Desktop! +- for macOS you can stream FaceTime camera or whole desktop! - for macOS important to set right framerate Format: `ffmpeg:device?{input-params}#{param1}#{param2}#{param3}` @@ -408,7 +408,7 @@ streams: Exec source can run any external application and expect data from it. Two transports are supported - **pipe** (*from [v1.5.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.5.0)*) and **RTSP**. -If you want to use **RTSP** transport - the command must contain the `{output}` argument in any place. On launch, it will be replaced by the local address of the RTSP server. +If you want to use **RTSP** transport, the command must contain the `{output}` argument in any place. On launch, it will be replaced by the local address of the RTSP server. **pipe** reads data from app stdout in different formats: **MJPEG**, **H.264/H.265 bitstream**, **MPEG-TS**. Also pipe can write data to app stdin in two formats: **PCMA** and **PCM/48000**. @@ -418,11 +418,11 @@ The source can be used with: - [FFplay](https://ffmpeg.org/ffplay.html) - play audio on your server - [GStreamer](https://gstreamer.freedesktop.org/) - [Raspberry Pi Cameras](https://www.raspberrypi.com/documentation/computers/camera_software.html) -- any your own software +- any of your own software Pipe commands support parameters (format: `exec:{command}#{param1}#{param2}`): -- `killsignal` - signal which will be send to stop the process (numeric form) +- `killsignal` - signal which will be sent to stop the process (numeric form) - `killtimeout` - time in seconds for forced termination with sigkill - `backchannel` - enable backchannel for two-way audio @@ -439,7 +439,7 @@ streams: #### Source: Echo -Some sources may have a dynamic link. And you will need to get it using a bash or python script. Your script should echo a link to the source. RTSP, FFmpeg or any of the [supported sources](#module-streams). +Some sources may have a dynamic link. And you will need to get it using a Bash or Python script. Your script should echo a link to the source. RTSP, FFmpeg or any of the [supported sources](#module-streams). **Docker** and **Hass Add-on** users has preinstalled `python3`, `curl`, `jq`. @@ -461,20 +461,20 @@ Like `echo` source, but uses the built-in [expr](https://github.com/antonmedv/ex **Important:** - You can use HomeKit Cameras **without Apple devices** (iPhone, iPad, etc.), it's just a yet another protocol -- HomeKit device can be paired with only one ecosystem. So, if you have paired it to an iPhone (Apple Home) - you can't pair it with Home Assistant or go2rtc. Or if you have paired it to go2rtc - you can't pair it with iPhone -- HomeKit device should be in same network with working [mDNS](https://en.wikipedia.org/wiki/Multicast_DNS) between device and go2rtc +- HomeKit device can be paired with only one ecosystem. So, if you have paired it to an iPhone (Apple Home), you can't pair it with Home Assistant or go2rtc. Or if you have paired it to go2rtc, you can't pair it with an iPhone +- HomeKit device should be on the same network with working [mDNS](https://en.wikipedia.org/wiki/Multicast_DNS) between the device and go2rtc -go2rtc support import paired HomeKit devices from [Home Assistant](#source-hass). So you can use HomeKit camera with Hass and go2rtc simultaneously. If you using Hass, I recommend pairing devices with it, it will give you more options. +go2rtc supports importing paired HomeKit devices from [Home Assistant](#source-hass). So you can use HomeKit camera with Hass and go2rtc simultaneously. If you are using Hass, I recommend pairing devices with it; it will give you more options. -You can pair device with go2rtc on the HomeKit page. If you can't see your devices - reload the page. Also try reboot your HomeKit device (power off). If you still can't see it - you have a problems with mDNS. +You can pair device with go2rtc on the HomeKit page. If you can't see your devices, reload the page. Also, try rebooting your HomeKit device (power off). If you still can't see it, you have a problem with mDNS. -If you see a device but it does not have a pair button - it is paired to some ecosystem (Apple Home, Home Assistant, HomeBridge etc). You need to delete device from that ecosystem, and it will be available for pairing. If you cannot unpair device, you will have to reset it. +If you see a device but it does not have a pairing button, it is paired to some ecosystem (Apple Home, Home Assistant, HomeBridge etc). You need to delete the device from that ecosystem, and it will be available for pairing. If you cannot unpair the device, you will have to reset it. **Important:** -- HomeKit audio uses very non-standard **AAC-ELD** codec with very non-standard params and specification violation +- HomeKit audio uses very non-standard **AAC-ELD** codec with very non-standard params and specification violations - Audio can't be played in `VLC` and probably any other player -- Audio should be transcoded for using with MSE, WebRTC, etc. +- Audio should be transcoded for use with MSE, WebRTC, etc. Recommended settings for using HomeKit Camera with WebRTC, MSE, MP4, RTSP: @@ -496,7 +496,7 @@ RTSP link with "normal" audio for any player: `rtsp://192.168.1.123:8554/aqara_g Other names: [ESeeCloud](http://www.eseecloud.com/), [dvr163](http://help.dvr163.com/). - you can skip `username`, `password`, `port`, `ch` and `stream` if they are default -- setup separate streams for different channels and streams +- set up separate streams for different channels and streams ```yaml streams: @@ -510,7 +510,7 @@ streams: Other names: DVR-IP, NetSurveillance, Sofia protocol (NETsurveillance ActiveX plugin XMeye SDK). - you can skip `username`, `password`, `port`, `channel` and `subtype` if they are default -- setup separate streams for different channels +- set up separate streams for different channels - use `subtype=0` for Main stream, and `subtype=1` for Extra1 stream - only the TCP protocol is supported @@ -532,7 +532,7 @@ streams: - stream quality is the same as [RTSP protocol](https://www.tapo.com/en/faq/34/) - use the **cloud password**, this is not the RTSP password! you do not need to add a login! - you can also use UPPERCASE MD5 hash from your cloud password with `admin` username -- some new camera firmwares requires SHA256 instead of MD5 +- some new camera firmwares require SHA256 instead of MD5 ```yaml streams: @@ -573,7 +573,7 @@ Support streaming from [GoPro](https://gopro.com/) cameras, connected via USB or #### Source: Ivideon -Support public cameras from service [Ivideon](https://tv.ivideon.com/). +Support public cameras from the service [Ivideon](https://tv.ivideon.com/). ```yaml streams: @@ -591,7 +591,7 @@ Support import camera links from [Home Assistant](https://www.home-assistant.io/ ```yaml hass: - config: "/config" # skip this setting if you Hass Add-on user + config: "/config" # skip this setting if you Hass add-on user streams: generic_camera: hass:Camera1 # Settings > Integrations > Integration Name @@ -600,9 +600,9 @@ streams: **WebRTC Cameras** (*from [v1.6.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.0)*) -Any cameras in WebRTC format are supported. But at the moment Home Assistant only supports some [Nest](https://www.home-assistant.io/integrations/nest/) cameras in this fomat. +Any cameras in WebRTC format are supported. But at the moment Home Assistant only supports some [Nest](https://www.home-assistant.io/integrations/nest/) cameras in this format. -**Important.** The Nest API only allows you to get a link to a stream for 5 minutes. Do not use this with Frigate! If the stream expires, Frigate will consume all available ram on your machine within seconds. It's recommended to use [Nest source](#source-nest) - it supports extending the stream. +**Important.** The Nest API only allows you to get a link to a stream for 5 minutes. Do not use this with Frigate! If the stream expires, Frigate will consume all available RAM on your machine within seconds. It's recommended to use [Nest source](#source-nest) - it supports extending the stream. ```yaml streams: @@ -614,13 +614,13 @@ streams: **RTSP Cameras** -By default, the Home Assistant API does not allow you to get dynamic RTSP link to a camera stream. So more cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), and possibly others can also be imported by using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate). +By default, the Home Assistant API does not allow you to get a dynamic RTSP link to a camera stream. So more cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), and possibly others, can also be imported using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate). #### Source: ISAPI *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* -This source type support only backchannel audio for Hikvision ISAPI protocol. So it should be used as second source in addition to the RTSP protocol. +This source type supports only backchannel audio for the Hikvision ISAPI protocol. So it should be used as a second source in addition to the RTSP protocol. ```yaml streams: @@ -633,9 +633,9 @@ streams: *[New in v1.6.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.0)* -Currently only WebRTC cameras are supported. +Currently, only WebRTC cameras are supported. -For simplicity, it is recommended to connect the Nest/WebRTC camera to the [Home Assistant](#source-hass). But if you can somehow get the below parameters - Nest/WebRTC source will work without Hass. +For simplicity, it is recommended to connect the Nest/WebRTC camera to the [Home Assistant](#source-hass). But if you can somehow get the below parameters, Nest/WebRTC source will work without Hass. ```yaml streams: @@ -646,29 +646,29 @@ streams: *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* -This source type support Roborock vacuums with cameras. Known working models: +This source type supports Roborock vacuums with cameras. Known working models: - Roborock S6 MaxV - only video (the vacuum has no microphone) -- Roborock S7 MaxV - video and two way audio -- Roborock Qrevo MaxV - video and two way audio +- Roborock S7 MaxV - video and two-way audio +- Roborock Qrevo MaxV - video and two-way audio -Source support load Roborock credentials from Home Assistant [custom integration](https://github.com/humbertogontijo/homeassistant-roborock) or the [core integration](https://www.home-assistant.io/integrations/roborock). Otherwise, you need to log in to your Roborock account (MiHome account is not supported). Go to: go2rtc WebUI > Add webpage. Copy `roborock://...` source for your vacuum and paste it to `go2rtc.yaml` config. +Source supports loading Roborock credentials from Home Assistant [custom integration](https://github.com/humbertogontijo/homeassistant-roborock) or the [core integration](https://www.home-assistant.io/integrations/roborock). Otherwise, you need to log in to your Roborock account (MiHome account is not supported). Go to: go2rtc WebUI > Add webpage. Copy `roborock://...` source for your vacuum and paste it to `go2rtc.yaml` config. -If you have graphic pin for your vacuum - add it as numeric pin (lines: 123, 456, 789) to the end of the roborock-link. +If you have a graphic PIN for your vacuum, add it as a numeric PIN (lines: 123, 456, 789) to the end of the `roborock` link. #### Source: WebRTC *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* -This source type support four connection formats. +This source type supports four connection formats. **whep** -[WebRTC/WHEP](https://datatracker.ietf.org/doc/draft-murillo-whep/) - is replaced by [WebRTC/WISH](https://datatracker.ietf.org/doc/charter-ietf-wish/02/) standard for WebRTC video/audio viewers. But it may already be supported in some third-party software. It is supported in go2rtc. +[WebRTC/WHEP](https://datatracker.ietf.org/doc/draft-murillo-whep/) is replaced by [WebRTC/WISH](https://datatracker.ietf.org/doc/charter-ietf-wish/02/) standard for WebRTC video/audio viewers. But it may already be supported in some third-party software. It is supported in go2rtc. **go2rtc** -This format is only supported in go2rtc. Unlike WHEP it supports asynchronous WebRTC connection and two way audio. +This format is only supported in go2rtc. Unlike WHEP, it supports asynchronous WebRTC connections and two-way audio. **openipc** (*from [v1.7.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0)*) @@ -676,11 +676,11 @@ Support connection to [OpenIPC](https://openipc.org/) cameras. **wyze** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) -Supports connection to [Wyze](https://www.wyze.com/) cameras, using WebRTC protocol. You can use [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge) project to get connection credentials. +Supports connection to [Wyze](https://www.wyze.com/) cameras, using WebRTC protocol. You can use the [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge) project to get connection credentials. **kinesis** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) -Supports [Amazon Kinesis Video Streams](https://aws.amazon.com/kinesis/video-streams/), using WebRTC protocol. You need to specify signalling WebSocket URL with all credentials in query params, `client_id` and `ice_servers` list in [JSON format](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer). +Supports [Amazon Kinesis Video Streams](https://aws.amazon.com/kinesis/video-streams/), using WebRTC protocol. You need to specify the signalling WebSocket URL with all credentials in query params, `client_id` and `ice_servers` list in [JSON format](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer). **switchbot** @@ -696,7 +696,7 @@ streams: webrtc-switchbot: webrtc:wss://...amazonaws.com/?...#format=switchbot#resolution=hd#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. +**PS.** For `kinesis` sources, you can use [echo](#source-echo) to get connection params using `bash`, `python` or any other script language. #### Source: WebTorrent @@ -715,9 +715,9 @@ By default, go2rtc establishes a connection to the source when any client reques - Go2rtc also can accepts incoming sources in [RTSP](#module-rtsp), [RTMP](#module-rtmp), [HTTP](#source-http) and **WebRTC/WHIP** formats - Go2rtc won't stop such a source if it has no clients -- You can push data only to existing stream (create stream with empty source in config) -- You can push multiple incoming sources to same stream -- You can push data to non empty stream, so it will have additional codecs inside +- You can push data only to an existing stream (create a stream with empty source in config) +- You can push multiple incoming sources to the same stream +- You can push data to a non-empty stream, so it will have additional codecs inside **Examples** @@ -742,11 +742,11 @@ By default, go2rtc establishes a connection to the source when any client reques *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* -You can turn the browser of any PC or mobile into an IP-camera with support video and two way audio. Or even broadcast your PC screen: +You can turn the browser of any PC or mobile into an IP camera with support for video and two-way audio. Or even broadcast your PC screen: 1. Create empty stream in the `go2rtc.yaml` 2. Go to go2rtc WebUI -3. Open `links` page for you stream +3. Open `links` page for your stream 4. Select `camera+microphone` or `display+speaker` option 5. Open `webrtc` local page (your go2rtc **should work over HTTPS!**) or `share link` via [WebTorrent](#module-webtorrent) technology (work over HTTPS by default) @@ -762,7 +762,7 @@ You can use **OBS Studio** or any other broadcast software with [WHIP](https://w *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* -go2rtc support play audio files (ex. music or [TTS](https://www.home-assistant.io/integrations/#text-to-speech)) and live streams (ex. radio) on cameras with [two way audio](#two-way-audio) support (RTSP/ONVIF cameras, TP-Link Tapo, Hikvision ISAPI, Roborock vacuums, any Browser). +go2rtc supports playing audio files (ex. music or [TTS](https://www.home-assistant.io/integrations/#text-to-speech)) and live streams (ex. radio) on cameras with [two-way audio](#two-way-audio) support (RTSP/ONVIF cameras, TP-Link Tapo, Hikvision ISAPI, Roborock vacuums, any Browser). API example: @@ -775,7 +775,7 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com - you can check camera codecs on the go2rtc WebUI info page when the stream is active - some cameras support only low quality `PCMA/8000` codec (ex. [Tapo](#source-tapo)) - it is recommended to choose higher quality formats if your camera supports them (ex. `PCMA/48000` for some Dahua cameras) -- if you play files over http-link, you need to add `#input=file` params for transcoding, so file will be transcoded and played in real time +- if you play files over `http` link, you need to add `#input=file` params for transcoding, so the file will be transcoded and played in real time - if you play live streams, you should skip `#input` param, because it is already in real time - you can stop active playback by calling the API with the empty `src` parameter - you will see one active producer and one active consumer in go2rtc WebUI info page during streaming @@ -787,10 +787,10 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com You can publish any stream to streaming services (YouTube, Telegram, etc.) via RTMP/RTMPS. Important: - Supported codecs: H264 for video and AAC for audio -- AAC audio is required for YouTube, videos without audio will not work +- AAC audio is required for YouTube; videos without audio will not work - You don't need to enable [RTMP module](#module-rtmp) listening for this task -You can use API: +You can use the API: ``` POST http://localhost:1984/api/streams?src=camera1&dst=rtmps://... @@ -822,7 +822,7 @@ streams: The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`. -**Important!** go2rtc passes requests from localhost and from unix socket without HTTP authorisation, even if you have it configured! It is your responsibility to set up secure external access to API. If not properly configured, an attacker can gain access to your cameras and even your server. +**Important!** go2rtc passes requests from localhost and from Unix sockets without HTTP authorisation, even if you have it configured! It is your responsibility to set up secure external access to the API. If not properly configured, an attacker can gain access to your cameras and even your server. [API description](https://github.com/AlexxIT/go2rtc/tree/master/api). @@ -830,7 +830,7 @@ The HTTP API is the main part for interacting with the application. Default addr - you can disable HTTP API with `listen: ""` and use, for example, only RTSP client/server protocol - you can enable HTTP API only on localhost with `listen: "127.0.0.1:1984"` setting -- you can change API `base_path` and host go2rtc on your main app webserver suburl +- you can change the API `base_path` and host go2rtc on your main app webserver suburl - all files from `static_dir` hosted on root path: `/` - you can use raw TLS cert/key content or path to files @@ -839,7 +839,7 @@ api: listen: ":1984" # default ":1984", HTTP API port ("" - disabled) username: "admin" # default "", Basic auth for WebUI password: "pass" # default "", Basic auth for WebUI - base_path: "/rtc" # default "", API prefix for serve on suburl (/api => /rtc/api) + base_path: "/rtc" # default "", API prefix for serving on suburl (/api => /rtc/api) static_dir: "www" # default "", folder for static files (custom web interface) origin: "*" # default "", allow CORS requests (only * supported) tls_listen: ":443" # default "", enable HTTPS server @@ -863,7 +863,7 @@ api: You can get any stream as RTSP-stream: `rtsp://192.168.1.123:8554/{stream_name}` -You can enable external password protection for your RTSP streams. Password protection always disabled for localhost calls (ex. FFmpeg or Hass on same server). +You can enable external password protection for your RTSP streams. Password protection is always disabled for localhost calls (ex. FFmpeg or Hass on the same server). ```yaml rtsp: @@ -888,7 +888,7 @@ Read more about [codecs filters](#codecs-filters). You can get any stream as RTMP-stream: `rtmp://192.168.1.123/{stream_name}`. Only H264/AAC codecs supported right now. -[Incoming stream](#incoming-sources) in RTMP-format tested only with [OBS Studio](https://obsproject.com/) and Dahua camera. Different FFmpeg versions has different problems with this format. +[Incoming stream](#incoming-sources) in RTMP format tested only with [OBS Studio](https://obsproject.com/) and a Dahua camera. Different FFmpeg versions have different problems with this format. ```yaml rtmp: @@ -897,12 +897,12 @@ rtmp: ### Module: WebRTC -In most cases [WebRTC](https://en.wikipedia.org/wiki/WebRTC) uses direct peer-to-peer connection from your browser to go2rtc and sends media data via UDP. +In most cases, [WebRTC](https://en.wikipedia.org/wiki/WebRTC) uses a direct peer-to-peer connection from your browser to go2rtc and sends media data via UDP. It **can't pass** media data through your Nginx or Cloudflare or [Nabu Casa](https://www.nabucasa.com/) HTTP TCP connection! -It can automatically detects your external IP via public [STUN](https://en.wikipedia.org/wiki/STUN) server. -It can establish a external direct connection via [UDP hole punching](https://en.wikipedia.org/wiki/UDP_hole_punching) technology even if you not open your server to the World. +It can automatically detect your external IP via a public [STUN](https://en.wikipedia.org/wiki/STUN) server. +It can establish an external direct connection via [UDP hole punching](https://en.wikipedia.org/wiki/UDP_hole_punching) technology even if you do not open your server to the World. -But about 10-20% of users may need to configure additional settings for external access if **mobile phone** or **go2rtc server** behing [Symmetric NAT](https://tomchen.github.io/symmetric-nat-test/). +But about 10-20% of users may need to configure additional settings for external access if **mobile phone** or **go2rtc server** is behind [Symmetric NAT](https://tomchen.github.io/symmetric-nat-test/). - by default, WebRTC uses both TCP and UDP on port 8555 for connections - you can use this port for external access @@ -915,25 +915,25 @@ webrtc: **Static public IP** -- forward the port 8555 on your router (you can use same 8555 port or any other as external port) -- add your external IP-address and external port to YAML config +- forward the port 8555 on your router (you can use the same 8555 port or any other as external port) +- add your external IP address and external port to the YAML config ```yaml webrtc: candidates: - - 216.58.210.174:8555 # if you have static public IP-address + - 216.58.210.174:8555 # if you have a static public IP address ``` **Dynamic public IP** -- forward the port 8555 on your router (you can use same 8555 port or any other as the external port) +- forward the port 8555 on your router (you can use the same 8555 port or any other as the external port) - add `stun` word and external port to YAML config - - go2rtc automatically detects your external address with STUN-server + - go2rtc automatically detects your external address with STUN server ```yaml webrtc: candidates: - - stun:8555 # if you have dynamic public IP-address + - stun:8555 # if you have a dynamic public IP address ``` **Private IP** @@ -947,7 +947,7 @@ ngrok: **Hard tech way 1. Own TCP-tunnel** -If you have personal [VPS](https://en.wikipedia.org/wiki/Virtual_private_server), you can create TCP-tunnel and setup in the same way as "Static public IP". But use your VPS IP-address in YAML config. +If you have a personal [VPS](https://en.wikipedia.org/wiki/Virtual_private_server), you can create a TCP tunnel and setup in the same way as "Static public IP". But use your VPS IP address in the YAML config. **Hard tech way 2. Using TURN-server** @@ -973,7 +973,7 @@ HomeKit module can work in two modes: **Important** -- HomeKit cameras supports only H264 video and OPUS audio +- HomeKit cameras support only H264 video and OPUS audio **Minimal config** @@ -1020,17 +1020,17 @@ homekit: *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* -This module support: +This module supports: - Share any local stream via [WebTorrent](https://webtorrent.io/) technology - Get any [incoming stream](#incoming-browser) from PC or mobile via [WebTorrent](https://webtorrent.io/) technology - Get any remote [go2rtc source](#source-webtorrent) via [WebTorrent](https://webtorrent.io/) technology -Securely and free. You do not need to open a public access to the go2rtc server. But in some cases (Symmetric NAT) you may need to set up external access to [WebRTC module](#module-webrtc). +Securely and freely. You do not need to open a public access to the go2rtc server. But in some cases (Symmetric NAT), you may need to set up external access to [WebRTC module](#module-webrtc). -To generate sharing link or incoming link - goto go2rtc WebUI (stream links page). This link is **temporary** and will stop working after go2rtc is restarted! +To generate a sharing link or incoming link, go to the go2rtc WebUI (stream links page). This link is **temporary** and will stop working after go2rtc is restarted! -You can create permanent external links in go2rtc config: +You can create permanent external links in the go2rtc config: ```yaml webtorrent: @@ -1042,22 +1042,22 @@ webtorrent: Link example: https://alexxit.github.io/go2rtc/#share=02SNtgjKXY&pwd=wznEQqznxW&media=video+audio -TODO: article how it works... +TODO: article on how it works... ### Module: ngrok -With ngrok integration you can get external access to your streams in situations when you have Internet with private IP-address. +With ngrok integration, you can get external access to your streams in situations when you have Internet with a private IP address. -- ngrok is pre-installed for **Docker** and **Hass Add-on** users +- ngrok is pre-installed for **Docker** and **Hass add-on** users - you may need external access for two different things: - - WebRTC stream, so you need tunnel WebRTC TCP port (ex. 8555) - - go2rtc web interface, so you need tunnel API HTTP port (ex. 1984) -- ngrok support authorization for your web interface + - WebRTC stream, so you need a tunnel WebRTC TCP port (ex. 8555) + - go2rtc web interface, so you need a tunnel API HTTP port (ex. 1984) +- ngrok supports authorization for your web interface - ngrok automatically adds HTTPS to your web interface The ngrok free subscription has the following limitations: -- You can reserve a free domain for serving the web interface, but the TCP address you get will always be random and change with each restart of the ngrok agent (not a problem for webrtc stream) +- You can reserve a free domain for serving the web interface, but the TCP address you get will always be random and change with each restart of the ngrok agent (not a problem for WebRTC stream) - You can forward multiple ports from a single agent, but you can only run one ngrok agent on the free plan go2rtc will automatically get your external TCP address (if you enable it in ngrok config) and use it with WebRTC connection (if you enable it in webrtc config). @@ -1075,7 +1075,7 @@ ngrok: **Tunnel for WebRTC and Web interface** -You need to create `ngrok.yaml` config file and add it to go2rtc config: +You need to create `ngrok.yaml` config file and add it to the go2rtc config: ```yaml ngrok: @@ -1089,12 +1089,12 @@ version: "2" authtoken: eW91IHNoYWxsIG5vdCBwYXNzCnlvdSBzaGFsbCBub3QgcGFzcw tunnels: api: - addr: 1984 # use the same port as in go2rtc config + addr: 1984 # use the same port as in the go2rtc config proto: http basic_auth: - admin:password # you can set login/pass for your web interface webrtc: - addr: 8555 # use the same port as in go2rtc config + addr: 8555 # use the same port as in the go2rtc config proto: tcp ``` @@ -1102,9 +1102,9 @@ See the [ngrok agent documentation](https://ngrok.com/docs/agent/config/) for mo ### Module: Hass -The best and easiest way to use go2rtc inside the Home Assistant is to install the custom integration [WebRTC Camera](#go2rtc-home-assistant-integration) and custom lovelace card. +The best and easiest way to use go2rtc inside Home Assistant is to install the custom integration [WebRTC Camera](#go2rtc-home-assistant-integration) and custom Lovelace card. -But go2rtc is also compatible and can be used with [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) built-in integration. +But go2rtc is also compatible and can be used with the [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) built-in integration. You have several options on how to add a camera to Home Assistant: @@ -1122,10 +1122,10 @@ You have several options on how to watch the stream from the cameras in Home Ass - Install any [go2rtc](#fast-start) - Hass > Settings > Integrations > Add Integration > [RTSPtoWebRTC](https://my.home-assistant.io/redirect/config_flow_start/?domain=rtsp_to_webrtc) > `http://127.0.0.1:1984/` - RTSPtoWebRTC > Configure > STUN server: `stun.l.google.com:19302` - - Use Picture Entity or Picture Glance lovelace card + - Use Picture Entity or Picture Glance Lovelace card 3. `Camera Entity` or `Camera URL` => [WebRTC Camera](https://github.com/AlexxIT/WebRTC) => Technology: `WebRTC/MSE/MP4/MJPEG`, codecs: `H264/H265/AAC/PCMU/PCMA/OPUS`, best latency, best compatibility. - Install and add [WebRTC Camera](https://github.com/AlexxIT/WebRTC) custom integration - - Use WebRTC Camera custom lovelace card + - Use WebRTC Camera custom Lovelace card You can add camera `entity_id` to [go2rtc config](#configuration) if you need transcoding: @@ -1134,7 +1134,7 @@ streams: "camera.hall": ffmpeg:{input}#video=copy#audio=opus ``` -**PS.** Default Home Assistant lovelace cards don't support 2-way audio. You can use 2-way audio from [Add-on Web UI](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons). But you need use HTTPS to access the microphone. This is a browser restriction and cannot be avoided. +**PS.** Default Home Assistant lovelace cards don't support two-way audio. You can use 2-way audio from [Add-on Web UI](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons), but you need to use HTTPS to access the microphone. This is a browser restriction and cannot be avoided. **PS.** There is also another nice card with go2rtc support - [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card). @@ -1144,7 +1144,7 @@ Provides several features: 1. MSE stream (fMP4 over WebSocket) 2. Camera snapshots in MP4 format (single frame), can be sent to [Telegram](https://github.com/AlexxIT/go2rtc/wiki/Snapshot-to-Telegram) -3. HTTP progressive streaming (MP4 file stream) - bad format for streaming because of high start delay. This format doesn't work in all Safari browsers, but go2rtc will automatically redirect it to HLS/fMP4 it this case. +3. HTTP progressive streaming (MP4 file stream) - bad format for streaming because of high start delay. This format doesn't work in all Safari browsers, but go2rtc will automatically redirect it to HLS/fMP4 in this case. API examples: @@ -1178,13 +1178,13 @@ Read more about [codecs filters](#codecs-filters). ### Module: MJPEG -**Important.** For stream as MJPEG format, your source MUST contain the MJPEG codec. If your stream has a MJPEG codec - you can receive **MJPEG stream** or **JPEG snapshots** via API. +**Important.** For stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has an MJPEG codec, you can receive **MJPEG stream** or **JPEG snapshots** via API. You can receive an MJPEG stream in several ways: - some cameras support MJPEG codec inside [RTSP stream](#source-rtsp) (ex. second stream for Dahua cameras) -- some cameras has HTTP link with [MJPEG stream](#source-http) -- some cameras has HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](#source-http) +- some cameras have an HTTP link with [MJPEG stream](#source-http) +- some cameras have an HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](#source-http) - you can convert H264/H265 stream from your camera via [FFmpeg integraion](#source-ffmpeg) With this example, your stream will have both H264 and MJPEG codecs: @@ -1225,7 +1225,7 @@ log: ## Security -By default `go2rtc` starts the Web interface on port `1984` and RTSP on port `8554`, as well as use port `8555` for WebRTC connections. The three ports are accessible from your local network. So anyone on your local network can watch video from your cameras without authorization. The same rule applies to the Home Assistant Add-on. +By default, `go2rtc` starts the Web interface on port `1984` and RTSP on port `8554`, as well as uses port `8555` for WebRTC connections. The three ports are accessible from your local network. So anyone on your local network can watch video from your cameras without authorization. The same rule applies to the Home Assistant Add-on. This is not a problem if you trust your local network as much as I do. But you can change this behaviour with a `go2rtc.yaml` config: @@ -1241,13 +1241,13 @@ webrtc: ``` - local access to RTSP is not a problem for [FFmpeg](#source-ffmpeg) integration, because it runs locally on your server -- local access to API is not a problem for [Home Assistant Add-on](#go2rtc-home-assistant-add-on), because Hass runs locally on same server and Add-on Web UI protected with Hass authorization ([Ingress feature](https://www.home-assistant.io/blog/2019/04/15/hassio-ingress/)) -- external access to WebRTC TCP port is not a problem, because it used only for transmit encrypted media data - - anyway you need to open this port to your local network and to the Internet in order for WebRTC to work +- local access to API is not a problem for the [Home Assistant add-on](#go2rtc-home-assistant-add-on), because Hass runs locally on the same server, and the add-on web UI is protected with Hass authorization ([Ingress feature](https://www.home-assistant.io/blog/2019/04/15/hassio-ingress/)) +- external access to WebRTC TCP port is not a problem, because it is used only for transmitting encrypted media data + - anyway you need to open this port to your local network and to the Internet for WebRTC to work -If you need Web interface protection without Home Assistant Add-on - you need to use reverse proxy, like [Nginx](https://nginx.org/), [Caddy](https://caddyserver.com/), [ngrok](https://ngrok.com/), etc. +If you need web interface protection without the Home Assistant add-on, you need to use a reverse proxy, like [Nginx](https://nginx.org/), [Caddy](https://caddyserver.com/), [ngrok](https://ngrok.com/), etc. -PS. Additionally WebRTC will try to use the 8555 UDP port for transmit encrypted media. It works without problems on the local network. And sometimes also works for external access, even if you haven't opened this port on your router ([read more](https://en.wikipedia.org/wiki/UDP_hole_punching)). But for stable external WebRTC access, you need to open the 8555 port on your router for both TCP and UDP. +PS. Additionally, WebRTC will try to use the 8555 UDP port to transmit encrypted media. It works without problems on the local network, and sometimes also works for external access, even if you haven't opened this port on your router ([read more](https://en.wikipedia.org/wiki/UDP_hole_punching)). But for stable external WebRTC access, you need to open the 8555 port on your router for both TCP and UDP. ## Codecs filters @@ -1270,11 +1270,11 @@ Some examples: - `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4` - HLS stream with MP4 compatible codecs (HLS/fMP4) - `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4=flac` - HLS stream with PCMA/PCMU/PCM audio support (HLS/fMP4), won't work on old devices - `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=flac` - MP4 file with PCMA/PCMU/PCM audio support, won't work on old devices (ex. iOS 12) -- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=all` - MP4 file with non standard audio codecs, won't work on some players +- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=all` - MP4 file with non-standard audio codecs, won't work on some players ## Codecs madness -`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has a lot of limitations in supporting with different devices and browsers. It's all about patents and money, you can't do anything about it. +`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has many limitations in supporting different devices and browsers. It's all about patents and money; you can't do anything about it. | Device | WebRTC | MSE | HTTP* | HLS | |--------------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------| @@ -1287,7 +1287,7 @@ Some examples: [1]: https://apps.apple.com/app/home-assistant/id1099568401 -`HTTP*` - HTTP Progressive Streaming, not related with [Progressive download](https://en.wikipedia.org/wiki/Progressive_download), because the file has no size and no end +`HTTP*` - HTTP Progressive Streaming, not related to [progressive download](https://en.wikipedia.org/wiki/Progressive_download), because the file has no size and no end - Chrome H265: [read this](https://chromestatus.com/feature/5186511939567616) and [read this](https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding) - Edge H265: [read this](https://www.reddit.com/r/MicrosoftEdge/comments/v9iw8k/enable_hevc_support_in_edge/) @@ -1298,7 +1298,7 @@ Some examples: - Go2rtc support [automatic repack](#built-in-transcoding) `PCMA/PCMU/PCM` codecs to `FLAC` for MSE/MP4/HLS so they will work almost anywhere - **WebRTC** audio codecs: `PCMU/8000`, `PCMA/8000`, `OPUS/48000/2` -- `OPUS` and `MP3` inside **MP4** is part of the standard, but some players do not support them anyway (especially Apple) +- `OPUS` and `MP3` inside **MP4** are part of the standard, but some players do not support them anyway (especially Apple) **Apple devices** @@ -1320,7 +1320,7 @@ Some examples: There are no plans to embed complex transcoding algorithms inside go2rtc. [FFmpeg source](#source-ffmpeg) does a great job with this. Including [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) support. -But go2rtc has some simple algorithms. They are turned on automatically, you do not need to set them up additionally. +But go2rtc has some simple algorithms. They are turned on automatically; you do not need to set them up additionally. **PCM for MSE/MP4/HLS** @@ -1332,7 +1332,7 @@ PCMA/PCMU => PCM => FLAC => MSE/MP4/HLS **Resample PCMA/PCMU for WebRTC** -By default WebRTC support only `PCMA/8000` and `PCMU/8000`. But go2rtc can automatically resample PCMA and PCMU codec with with a different sample rate. Also go2rtc can transcode `PCM` codec to `PCMA/8000`, so WebRTC can play it: +By default WebRTC supports only `PCMA/8000` and `PCMU/8000`. But go2rtc can automatically resample PCMA and PCMU codecs with a different sample rate. Also, go2rtc can transcode `PCM` codec to `PCMA/8000`, so WebRTC can play it: ``` PCM/xxx => PCMA/8000 => WebRTC @@ -1342,24 +1342,24 @@ PCMU/xxx => PCMU/8000 => WebRTC **Important** -- FLAC codec not supported in a RTSP stream. If you using Frigate or Hass for recording MP4 files with PCMA/PCMU/PCM audio - you should setup transcoding to AAC codec. -- PCMA and PCMU are VERY low quality codecs. Them support only 256! different sounds. Use them only when you have no other options. +- FLAC codec not supported in an RTSP stream. If you are using Frigate or Hass for recording MP4 files with PCMA/PCMU/PCM audio, you should set up transcoding to the AAC codec. +- PCMA and PCMU are VERY low-quality codecs. They support only 256! different sounds. Use them only when you have no other options. ## Codecs negotiation For example, you want to watch RTSP-stream from [Dahua IPC-K42](https://www.dahuasecurity.com/fr/products/All-Products/Network-Cameras/Wireless-Series/Wi-Fi-Series/4MP/IPC-K42) camera in your Chrome browser. -- this camera support 2-way audio standard **ONVIF Profile T** -- this camera support codecs **H264, H265** for send video, and you select `H264` in camera settings -- this camera support codecs **AAC, PCMU, PCMA** for send audio (from mic), and you select `AAC/16000` in camera settings -- this camera support codecs **AAC, PCMU, PCMA** for receive audio (to speaker), you don't need to select them -- your browser support codecs **H264, VP8, VP9, AV1** for receive video, you don't need to select them -- your browser support codecs **OPUS, PCMU, PCMA** for send and receive audio, you don't need to select them -- you can't get camera audio directly, because its audio codecs doesn't match with your browser codecs - - so you decide to use transcoding via FFmpeg and add this setting to config YAML file +- this camera supports two-way audio standard **ONVIF Profile T** +- this camera supports codecs **H264, H265** for send video, and you select `H264` in camera settings +- this camera supports codecs **AAC, PCMU, PCMA** for sending audio (from mic), and you select `AAC/16000` in camera settings +- this camera supports codecs **AAC, PCMU, PCMA** for receiving audio (to speaker), you don't need to select them +- your browser supports codecs **H264, VP8, VP9, AV1** for receiving video, you don't need to select them +- your browser supports codecs **OPUS, PCMU, PCMA** for sending and receiving audio, you don't need to select them +- you can't get camera audio directly, because its audio codecs don't match with your browser codecs + - so you decide to use transcoding via FFmpeg and add this setting to the config YAML file - you have chosen `OPUS/48000/2` codec, because it is higher quality than the `PCMU/8000` or `PCMA/8000` -Now you have stream with two sources - **RTSP and FFmpeg**: +Now you have a stream with two sources - **RTSP and FFmpeg**: ```yaml streams: @@ -1368,22 +1368,22 @@ streams: - ffmpeg:rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0#audio=opus ``` -**go2rtc** automatically match codecs for you browser and all your stream sources. This called **multi-source 2-way codecs negotiation**. And this is one of the main features of this app. +**go2rtc** automatically matches codecs for your browser and all your stream sources. This is called **multi-source two-way codec negotiation**. And this is one of the main features of this app. ![](assets/codecs.svg) -**PS.** You can select `PCMU` or `PCMA` codec in camera setting and don't use transcoding at all. Or you can select `AAC` codec for main stream and `PCMU` codec for second stream and add both RTSP to YAML config, this also will work fine. +**PS.** You can select `PCMU` or `PCMA` codec in camera settings and not use transcoding at all. Or you can select `AAC` codec for main stream and `PCMU` codec for second stream and add both RTSP to YAML config, this also will work fine. ## Projects using go2rtc -- [Frigate 12+](https://frigate.video/) - open source NVR built around real-time AI object detection +- [Frigate](https://frigate.video/) 0.12+ - open-source NVR built around real-time AI object detection - [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card) - custom card for Home Assistant -- [OpenIPC](https://github.com/OpenIPC/firmware/tree/master/general/package/go2rtc) - Alternative IP Camera firmware from an open community -- [wz_mini_hacks](https://github.com/gtxaspec/wz_mini_hacks) - Custom firmware for Wyze cameras -- [EufyP2PStream](https://github.com/oischinger/eufyp2pstream) - A small project that provides a Video/Audio Stream from Eufy cameras that don't directly support RTSP -- [ioBroker.euSec](https://github.com/bropat/ioBroker.eusec) - [ioBroker](https://www.iobroker.net/) adapter for control Eufy security devices -- [MMM-go2rtc](https://github.com/Anonym-tsk/MMM-go2rtc) - MagicMirror² Module -- [ring-mqtt](https://github.com/tsightler/ring-mqtt) - Ring devices to MQTT Bridge +- [OpenIPC](https://github.com/OpenIPC/firmware/tree/master/general/package/go2rtc) - alternative IP camera firmware from an open community +- [wz_mini_hacks](https://github.com/gtxaspec/wz_mini_hacks) - custom firmware for Wyze cameras +- [EufyP2PStream](https://github.com/oischinger/eufyp2pstream) - a small project that provides a video/audio stream from Eufy cameras that don't directly support RTSP +- [ioBroker.euSec](https://github.com/bropat/ioBroker.eusec) - [ioBroker](https://www.iobroker.net/) adapter for controlling Eufy security devices +- [MMM-go2rtc](https://github.com/Anonym-tsk/MMM-go2rtc) - MagicMirror² module +- [ring-mqtt](https://github.com/tsightler/ring-mqtt) - Ring-to-MQTT bridge **Distributions** @@ -1396,15 +1396,15 @@ streams: - [Synology NAS](https://synocommunity.com/package/go2rtc) - [Unraid](https://unraid.net/community/apps?q=go2rtc) -## Cameras experience +## Camera experience - [Dahua](https://www.dahuasecurity.com/) - reference implementation streaming protocols, a lot of settings, high stream quality, multiple streaming clients -- [EZVIZ](https://www.ezviz.com/) - awful RTSP protocol realisation, many bugs in SDP +- [EZVIZ](https://www.ezviz.com/) - awful RTSP protocol implementation, many bugs in SDP - [Hikvision](https://www.hikvision.com/) - a lot of proprietary streaming technologies -- [Reolink](https://reolink.com/) - some models has awful unusable RTSP realisation and not best RTMP alternative (I recommend that you contact Reolink support for new firmware), few settings -- [Sonoff](https://sonoff.tech/) - very low stream quality, no settings, not best protocol implementation +- [Reolink](https://reolink.com/) - some models have an awful, unusable RTSP implementation and not the best RTMP alternative (I recommend that you contact Reolink support for new firmware), few settings +- [Sonoff](https://sonoff.tech/) - very low stream quality, no settings, not the best protocol implementation - [TP-Link](https://www.tp-link.com/) - few streaming clients, packet loss? -- Chinese cheap noname cameras, Wyze Cams, Xiaomi cameras with hacks (usual has `/live/ch00_1` in RTSP URL) - awful but usable RTSP protocol realisation, low stream quality, few settings, packet loss? +- Chinese cheap noname cameras, Wyze Cams, Xiaomi cameras with hacks (usually have `/live/ch00_1` in RTSP URL) - awful but usable RTSP protocol implementation, low stream quality, few settings, packet loss? ## TIPS @@ -1421,22 +1421,22 @@ streams: **Q. What's the difference between go2rtc, WebRTC Camera and RTSPtoWebRTC?** -**go2rtc** is a new version of the server-side [WebRTC Camera](https://github.com/AlexxIT/WebRTC) integration, completely rewritten from scratch, with a number of fixes and a huge number of new features. It is compatible with native Home Assistant [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration. So you [can use](#module-hass) default lovelace Picture Entity or Picture Glance. +**go2rtc** is a new version of the server-side [WebRTC Camera](https://github.com/AlexxIT/WebRTC) integration, completely rewritten from scratch, with a number of fixes and a huge number of new features. It is compatible with native Home Assistant [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration. So you [can use](#module-hass) default Lovelace Picture Entity or Picture Glance. -**Q. Should I use go2rtc addon or WebRTC Camera integration?** +**Q. Should I use the go2rtc add-on or WebRTC Camera integration?** -**go2rtc** is more than just viewing your stream online with WebRTC/MSE/HLS/etc. You can use it all the time for your various tasks. But every time the Hass is rebooted - all integrations are also rebooted. So your streams may be interrupted if you use them in additional tasks. +**go2rtc** is more than just viewing your stream online with WebRTC/MSE/HLS/etc. You can use it all the time for your various tasks. But every time Hass is rebooted, all integrations are also rebooted. So your streams may be interrupted if you use them in additional tasks. -Basic users can use **WebRTC Camera** integration. Advanced users can use go2rtc addon or Frigate 12+ addon. +Basic users can use the **WebRTC Camera** integration. Advanced users can use the go2rtc add-on or the Frigate 0.12+ add-on. **Q. Which RTSP link should I use inside Hass?** -You can use direct link to your cameras there (as you always do). **go2rtc** support zero-config feature. You may leave `streams` config section empty. And your streams will be created on the fly on first start from Hass. And your cameras will have multiple connections. Some from Hass directly and one from **go2rtc**. +You can use a direct link to your cameras there (as you always do). **go2rtc** supports zero-config feature. You may leave `streams` config section empty. And your streams will be created on the fly on first start from Hass. And your cameras will have multiple connections. Some from Hass directly and one from **go2rtc**. -Also you can specify your streams in **go2rtc** [config file](#configuration) and use RTSP links to this addon. With additional features: multi-source [codecs negotiation](#codecs-negotiation) or FFmpeg [transcoding](#source-ffmpeg) for unsupported codecs. Or use them as source for Frigate. And your cameras will have one connection from **go2rtc**. And **go2rtc** will have multiple connection - some from Hass via RTSP protocol, some from your browser via WebRTC/MSE/HLS protocols. +Also, you can specify your streams in **go2rtc** [config file](#configuration) and use RTSP links to this add-on with additional features: multi-source [codecs negotiation](#codecs-negotiation) or FFmpeg [transcoding](#source-ffmpeg) for unsupported codecs. Or use them as a source for Frigate. And your cameras will have one connection from **go2rtc**. And **go2rtc** will have multiple connections - some from Hass via RTSP protocol, some from your browser via WebRTC/MSE/HLS protocols. -Use any config what you like. +Use any config that you like. -**Q. What about lovelace card with support 2-way audio?** +**Q. What about Lovelace card with support for two-way audio?** -At this moment I am focused on improving stability and adding new features to **go2rtc**. Maybe someone could write such a card themselves. It's not difficult, I have [some sketches](https://github.com/AlexxIT/go2rtc/blob/master/www/webrtc.html). +At this moment, I am focused on improving stability and adding new features to **go2rtc**. Maybe someone could write such a card themselves. It's not difficult, I have [some sketches](https://github.com/AlexxIT/go2rtc/blob/master/www/webrtc.html). From 6732e726d51b8433a413f81973e6ccd4a9c2848b Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 16 Jun 2025 00:33:16 +0200 Subject: [PATCH 35/84] update preload consumer to handle RTP packets --- pkg/preload/producer.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/preload/producer.go b/pkg/preload/producer.go index 932f5e29..8eb6aec2 100644 --- a/pkg/preload/producer.go +++ b/pkg/preload/producer.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" ) type Preload struct { @@ -62,7 +63,10 @@ func (p *Preload) GetMedias() []*core.Media { func (p *Preload) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { sender := core.NewSender(media, track.Codec) - sender.Bind(track) + sender.Handler = func(pkt *rtp.Packet) { + p.Send += pkt.MarshalSize() + } + sender.HandleRTP(track) p.Senders = append(p.Senders, sender) return nil } From 57714544004b19da46771baccc0c6296bde8ae51 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 16 Jun 2025 00:50:48 +0200 Subject: [PATCH 36/84] use preload as format name --- pkg/preload/producer.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/preload/producer.go b/pkg/preload/producer.go index 8eb6aec2..748dff16 100644 --- a/pkg/preload/producer.go +++ b/pkg/preload/producer.go @@ -49,10 +49,9 @@ func NewPreload(name string, query url.Values) *Preload { return &Preload{ Connection: core.Connection{ ID: core.NewID(), + FormatName: "preload", Medias: medias, - Protocol: "native", RemoteAddr: "localhost", - UserAgent: "go2rtc/preload", }, } } From ef318f663e854d3ba5e8af3641947edfec43df4d Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 16 Jun 2025 09:32:07 +0200 Subject: [PATCH 37/84] fix preload queries --- internal/streams/api.go | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/internal/streams/api.go b/internal/streams/api.go index 47febeb4..1b91f906 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -128,10 +128,6 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { src := query.Get("src") query.Del("src") - videoQuery := query.Get("video") - audioQuery := query.Get("audio") - micQuery := query.Get("microphone") - if src == "" { http.Error(w, "no source", http.StatusBadRequest) return @@ -152,15 +148,28 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { delete(preloads, src) } + // parse query parameters var rawQuery string - if videoQuery != "" { - rawQuery += "video=" + videoQuery + "#" + if query.Has("video") { + if videoQuery := query.Get("video"); videoQuery != "" { + rawQuery += "video=" + videoQuery + "#" + } else { + rawQuery += "video#" + } } - if audioQuery != "" { - rawQuery += "audio=" + audioQuery + "#" + if query.Has("audio") { + if audioQuery := query.Get("audio"); audioQuery != "" { + rawQuery += "audio=" + audioQuery + "#" + } else { + rawQuery += "audio#" + } } - if micQuery != "" { - rawQuery += "microphone=" + micQuery + if query.Has("microphone") { + if micQuery := query.Get("microphone"); micQuery != "" { + rawQuery += "microphone=" + micQuery + "#" + } else { + rawQuery += "microphone#" + } } if err := app.PatchConfig([]string{"preload", src}, rawQuery); err != nil { From 647b2acf487a36b9d478c8659a6c5f43880f840a Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 16 Jun 2025 09:58:55 +0200 Subject: [PATCH 38/84] cleanup --- pkg/preload/{producer.go => consumer.go} | 11 ----------- 1 file changed, 11 deletions(-) rename pkg/preload/{producer.go => consumer.go} (84%) diff --git a/pkg/preload/producer.go b/pkg/preload/consumer.go similarity index 84% rename from pkg/preload/producer.go rename to pkg/preload/consumer.go index 748dff16..4d3735a8 100644 --- a/pkg/preload/producer.go +++ b/pkg/preload/consumer.go @@ -51,15 +51,10 @@ func NewPreload(name string, query url.Values) *Preload { ID: core.NewID(), FormatName: "preload", Medias: medias, - RemoteAddr: "localhost", }, } } -func (p *Preload) GetMedias() []*core.Media { - return p.Medias -} - func (p *Preload) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { sender := core.NewSender(media, track.Codec) sender.Handler = func(pkt *rtp.Packet) { @@ -70,12 +65,6 @@ func (p *Preload) AddTrack(media *core.Media, codec *core.Codec, track *core.Rec return nil } -func (p *Preload) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - receiver := core.NewReceiver(media, codec) - p.Receivers = append(p.Receivers, receiver) - return receiver, nil -} - func (p *Preload) Start() error { p.closed.Wait() return nil From 7bb0f0d2e660979be9e5a0693a1911cb17948ec1 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 19 Jun 2025 10:29:55 +0200 Subject: [PATCH 39/84] readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8a1e020a..af9a99f9 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ Available source types: - [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR - [dvrip](#source-dvrip) - streaming from DVR-IP NVR - [tapo](#source-tapo) - TP-Link Tapo cameras with [two way audio](#two-way-audio) support +- [ring](#source-ring) - Ring cameras with [two way audio](#two-way-audio) support - [kasa](#source-tapo) - TP-Link Kasa cameras - [gopro](#source-gopro) - GoPro cameras - [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service From 5a344835139ab880d3a5aac62bca00bd68d0d5bf Mon Sep 17 00:00:00 2001 From: Volker Thiel <568497+riker09@users.noreply.github.com> Date: Mon, 23 Jun 2025 20:08:54 +0200 Subject: [PATCH 40/84] Update schema.json Add missing letter `r` in one of the examples --- website/schema.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/schema.json b/website/schema.json index d5e19436..530616c6 100644 --- a/website/schema.json +++ b/website/schema.json @@ -334,7 +334,7 @@ "rtmp://192.168.1.123/bcs/channel0_main.bcs?channel=0&stream=0&user=username&password=password", "http://192.168.1.123/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password", "http://username:password@192.168.1.123/cgi-bin/snapshot.cgi?channel=1", - "ffmpeg:media.mp4#video=h264#hadware#width=1920#height=1080#rotate=180#audio=copy", + "ffmpeg:media.mp4#video=h264#hardware#width=1920#height=1080#rotate=180#audio=copy", "ffmpeg:virtual?video=testsrc&size=4K#video=h264#hardware#bitrate=50M", "bubble://username:password@192.168.1.123:34567/bubble/live?ch=0&stream=0", "dvrip://username:password@192.168.1.123:34567?channel=0&subtype=0", @@ -483,4 +483,4 @@ } } } -} \ No newline at end of file +} From c68e3cafe4e385270080c1b0ede0132bdd79657a Mon Sep 17 00:00:00 2001 From: Oliver Eiber Date: Thu, 3 Jul 2025 23:35:58 +0200 Subject: [PATCH 41/84] 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 42/84] 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 34b103bbcba512a07fb1ea8a3c219ae31d2201bf Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 8 Jul 2025 12:43:14 +0300 Subject: [PATCH 43/84] Update all dependencies and min go version to 1.23 --- .github/workflows/build.yml | 4 ++-- go.mod | 38 ++++++++++++++++++------------------- go.sum | 36 +++++++++++++++++++++++++++++++++++ scripts/README.md | 2 ++ scripts/build.cmd | 5 ----- 5 files changed, 59 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7950004d..ac4d758d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,7 +29,7 @@ jobs: with: { name: go2rtc_win64, path: go2rtc.exe } - name: Build go2rtc_win32 - env: { GOOS: windows, GOARCH: 386, GOTOOLCHAIN: go1.20.14 } + env: { GOOS: windows, GOARCH: 386 } run: go build -ldflags "-s -w" -trimpath - name: Upload go2rtc_win32 uses: actions/upload-artifact@v4 @@ -85,7 +85,7 @@ jobs: with: { name: go2rtc_linux_mipsel, path: go2rtc } - name: Build go2rtc_mac_amd64 - env: { GOOS: darwin, GOARCH: amd64, GOTOOLCHAIN: go1.20.14 } + env: { GOOS: darwin, GOARCH: amd64 } run: go build -ldflags "-s -w" -trimpath - name: Upload go2rtc_mac_amd64 uses: actions/upload-artifact@v4 diff --git a/go.mod b/go.mod index 997737cf..7abf1edd 100644 --- a/go.mod +++ b/go.mod @@ -1,49 +1,49 @@ module github.com/AlexxIT/go2rtc -go 1.20 +go 1.23.0 require ( github.com/asticode/go-astits v1.13.0 - github.com/expr-lang/expr v1.17.2 + github.com/expr-lang/expr v1.17.5 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/mattn/go-isatty v0.0.20 - github.com/miekg/dns v1.1.63 - github.com/pion/ice/v4 v4.0.9 - github.com/pion/interceptor v0.1.37 + github.com/miekg/dns v1.1.66 + github.com/pion/ice/v4 v4.0.10 + github.com/pion/interceptor v0.1.40 github.com/pion/rtcp v1.2.15 - github.com/pion/rtp v1.8.13 - github.com/pion/sdp/v3 v3.0.11 - github.com/pion/srtp/v3 v3.0.4 + github.com/pion/rtp v1.8.20 + github.com/pion/sdp/v3 v3.0.14 + github.com/pion/srtp/v3 v3.0.6 github.com/pion/stun/v3 v3.0.0 - github.com/pion/webrtc/v4 v4.0.14 + github.com/pion/webrtc/v4 v4.1.3 github.com/rs/zerolog v1.34.0 github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f github.com/stretchr/testify v1.10.0 github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 - golang.org/x/crypto v0.33.0 + golang.org/x/crypto v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/asticode/go-astikit v0.54.0 // indirect + github.com/asticode/go-astikit v0.56.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v3 v3.0.6 // indirect - github.com/pion/logging v0.2.3 // indirect + github.com/pion/logging v0.2.4 // indirect github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/sctp v1.8.37 // indirect + github.com/pion/sctp v1.8.39 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/turn/v4 v4.0.0 // indirect + github.com/pion/turn/v4 v4.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/mod v0.20.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/tools v0.24.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/tools v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index c5a92c73..7e1b0cee 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= github.com/asticode/go-astikit v0.54.0 h1:uq9eurgisdkYwJU9vSWIQaPH4MH0cac82sQH00kmSNQ= github.com/asticode/go-astikit v0.54.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= +github.com/asticode/go-astikit v0.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw= +github.com/asticode/go-astikit v0.56.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c= github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -10,6 +12,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/expr-lang/expr v1.17.2 h1:o0A99O/Px+/DTjEnQiodAgOIK9PPxL8DtXhBRKC+Iso= github.com/expr-lang/expr v1.17.2/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= +github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -28,16 +32,24 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= +github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= +github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= github.com/pion/ice/v4 v4.0.9 h1:VKgU4MwA2LUDVLq+WBkpEHTcAb8c5iCvFMECeuPOZNk= github.com/pion/ice/v4 v4.0.9/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= +github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= +github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= +github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= @@ -46,20 +58,32 @@ github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= github.com/pion/rtp v1.8.13 h1:8uSUPpjSL4OlwZI8Ygqu7+h2p9NPFB+yAZ461Xn5sNg= github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= +github.com/pion/rtp v1.8.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI= +github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs= github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= +github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI= github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= +github.com/pion/sdp/v3 v3.0.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI= +github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= +github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= +github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= +github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps= +github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= github.com/pion/webrtc/v4 v4.0.14 h1:nyds/sFRR+HvmWoBa6wrL46sSfpArE0qR883MBW96lg= github.com/pion/webrtc/v4 v4.0.14/go.mod h1:R3+qTnQTS03UzwDarYecgioNf7DYgTsldxnCXB821Kk= +github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c= +github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= @@ -84,19 +108,31 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/scripts/README.md b/scripts/README.md index 669fe2b2..9c7f4544 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -16,6 +16,8 @@ golang.org/x/sys v0.30.0 // indirect golang.org/x/tools v0.24.0 // indirect ``` +**PS.** Unfortunately, due to the dependency on `pion/webrtc/v4 v4.1.3`, had to upgrade go to `1.23`. + ## Build - UPX-3.96 pack broken bin for `linux_mipsel` diff --git a/scripts/build.cmd b/scripts/build.cmd index a543ea80..37ccd441 100644 --- a/scripts/build.cmd +++ b/scripts/build.cmd @@ -1,18 +1,15 @@ @ECHO OFF -@SET GOTOOLCHAIN= @SET GOOS=windows @SET GOARCH=amd64 @SET FILENAME=go2rtc_win64.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe -@SET GOTOOLCHAIN=go1.20.14 @SET GOOS=windows @SET GOARCH=386 @SET FILENAME=go2rtc_win32.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe -@SET GOTOOLCHAIN= @SET GOOS=windows @SET GOARCH=arm64 @SET FILENAME=go2rtc_win_arm64.zip @@ -50,13 +47,11 @@ go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME @SET FILENAME=go2rtc_linux_mipsel go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% -@SET GOTOOLCHAIN=go1.20.14 @SET GOOS=darwin @SET GOARCH=amd64 @SET FILENAME=go2rtc_mac_amd64.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc -@SET GOTOOLCHAIN= @SET GOOS=darwin @SET GOARCH=arm64 @SET FILENAME=go2rtc_mac_arm64.zip From 56e61a85ee7be5c87e0313ec8e0dafc29a7c5d96 Mon Sep 17 00:00:00 2001 From: Oliver Eiber Date: Wed, 16 Jul 2025 21:07:34 +0200 Subject: [PATCH 44/84] 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 45/84] 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 46/84] 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 47/84] 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 48/84] 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 49/84] 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 50/84] 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 4577390130b351e56dd0d031ef3a7effd04d82cc Mon Sep 17 00:00:00 2001 From: Hugo Aboud Date: Tue, 19 Aug 2025 16:16:01 -0300 Subject: [PATCH 51/84] Sanitize credentials on websocket error messages --- internal/api/ws/ws.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/api/ws/ws.go b/internal/api/ws/ws.go index 1d945bfe..5a7d34be 100644 --- a/internal/api/ws/ws.go +++ b/internal/api/ws/ws.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "time" + "regexp" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" @@ -132,7 +133,11 @@ func apiWS(w http.ResponseWriter, r *http.Request) { if handler := wsHandlers[msg.Type]; handler != nil { go func() { if err = handler(tr, msg); err != nil { - tr.Write(&Message{Type: "error", Value: msg.Type + ": " + err.Error()}) + // Some streams such as ffmpeg might return credentials on error messages + errMsg := err.Error() + sanitizer := regexp.MustCompile(`(\w+)://(.*)@`) + errMsg = sanitizer.ReplaceAllString(errMsg, "$1://******@") + tr.Write(&Message{Type: "error", Value: msg.Type + ": " + errMsg}) } }() } From 8f7cbdf60a6227d90a8e3a7bd57b484bb0bf0d73 Mon Sep 17 00:00:00 2001 From: Ragnar Petursson <74888286+kvikindi@users.noreply.github.com> Date: Sat, 23 Aug 2025 18:09:28 +0100 Subject: [PATCH 52/84] Update Proxmox Helper Scripts link in README.md Changed link to current link, as previous repository is inactive as of November 2nd 2024. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9712bbde..12342a03 100644 --- a/README.md +++ b/README.md @@ -1391,7 +1391,7 @@ streams: - [Arch User Repository](https://linux-packages.com/aur/package/go2rtc) - [Gentoo](https://github.com/inode64/inode64-overlay/tree/main/media-video/go2rtc) - [NixOS](https://search.nixos.org/packages?query=go2rtc) -- [Proxmox Helper Scripts](https://tteck.github.io/Proxmox/) +- [Proxmox Helper Scripts](https://github.com/community-scripts/ProxmoxVE/) - [QNAP](https://www.myqnap.org/product/go2rtc/) - [Synology NAS](https://synocommunity.com/package/go2rtc) - [Unraid](https://unraid.net/community/apps?q=go2rtc) From 33f0fd5fe6b6d1baf0fe742ce7ffa237a58dfddf Mon Sep 17 00:00:00 2001 From: Alex X Date: Thu, 10 Jul 2025 16:47:52 +0300 Subject: [PATCH 53/84] Add lightNVR project to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 12342a03..13943770 100644 --- a/README.md +++ b/README.md @@ -1384,6 +1384,7 @@ streams: - [ioBroker.euSec](https://github.com/bropat/ioBroker.eusec) - [ioBroker](https://www.iobroker.net/) adapter for controlling Eufy security devices - [MMM-go2rtc](https://github.com/Anonym-tsk/MMM-go2rtc) - MagicMirror² module - [ring-mqtt](https://github.com/tsightler/ring-mqtt) - Ring-to-MQTT bridge +- [lightNVR](https://github.com/opensensor/lightNVR) **Distributions** From beb82045ffd31dca4ef0046cf7c7da05f0fa19a1 Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 29 Aug 2025 16:45:19 +0300 Subject: [PATCH 54/84] Fix yet another broken Content-Base for RTSP #1852 --- pkg/rtsp/helpers.go | 27 +++++++++++++++++++++++---- pkg/rtsp/rtsp_test.go | 10 ++++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/pkg/rtsp/helpers.go b/pkg/rtsp/helpers.go index d8ed1685..c73bd0a2 100644 --- a/pkg/rtsp/helpers.go +++ b/pkg/rtsp/helpers.go @@ -116,20 +116,39 @@ func findFmtpLine(payloadType uint8, descriptions []*sdp.MediaDescription) strin // urlParse fix bugs: // 1. Content-Base: rtsp://::ffff:192.168.1.123/onvif/profile.1/ // 2. Content-Base: rtsp://rtsp://turret2-cam.lan:554/stream1/ +// 3. Content-Base: 192.168.253.220:1935/ func urlParse(rawURL string) (*url.URL, error) { // fix https://github.com/AlexxIT/go2rtc/issues/830 if strings.HasPrefix(rawURL, "rtsp://rtsp://") { rawURL = rawURL[7:] } + // fix https://github.com/AlexxIT/go2rtc/issues/1852 + if !strings.Contains(rawURL, "://") { + rawURL = "rtsp://" + rawURL + } + u, err := url.Parse(rawURL) if err != nil && strings.HasSuffix(err.Error(), "after host") { - if i1 := strings.Index(rawURL, "://"); i1 > 0 { - if i2 := strings.IndexByte(rawURL[i1+3:], '/'); i2 > 0 { - return urlParse(rawURL[:i1+3+i2] + ":" + rawURL[i1+3+i2:]) - } + if i := indexN(rawURL, '/', 3); i > 0 { + return urlParse(rawURL[:i] + ":" + rawURL[i:]) } } return u, err } + +func indexN(s string, c byte, n int) int { + var offset int + for { + i := strings.IndexByte(s[offset:], c) + if i < 0 { + break + } + if n--; n == 0 { + return offset + i + } + offset += i + 1 + } + return -1 +} diff --git a/pkg/rtsp/rtsp_test.go b/pkg/rtsp/rtsp_test.go index 14c99803..282c04f8 100644 --- a/pkg/rtsp/rtsp_test.go +++ b/pkg/rtsp/rtsp_test.go @@ -11,14 +11,20 @@ func TestURLParse(t *testing.T) { // https://github.com/AlexxIT/WebRTC/issues/395 base := "rtsp://::ffff:192.168.1.123/onvif/profile.1/" u, err := urlParse(base) - assert.Empty(t, err) + assert.NoError(t, err) assert.Equal(t, "::ffff:192.168.1.123:", u.Host) // https://github.com/AlexxIT/go2rtc/issues/208 base = "rtsp://rtsp://turret2-cam.lan:554/stream1/" u, err = urlParse(base) - assert.Empty(t, err) + assert.NoError(t, err) assert.Equal(t, "turret2-cam.lan:554", u.Host) + + // https://github.com/AlexxIT/go2rtc/issues/1852 + base = "192.168.253.220:1935/" + u, err = urlParse(base) + assert.NoError(t, err) + assert.Equal(t, "192.168.253.220:1935", u.Host) } func TestBugSDP1(t *testing.T) { From cd7fa5d09cc020d6ce697f619d02c91eaf146ac1 Mon Sep 17 00:00:00 2001 From: Alex X Date: Wed, 10 Sep 2025 12:27:13 +0300 Subject: [PATCH 55/84] Fix RepairAVCC in some cases --- pkg/h264/avcc.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/h264/avcc.go b/pkg/h264/avcc.go index d21e3ea3..dd3a5687 100644 --- a/pkg/h264/avcc.go +++ b/pkg/h264/avcc.go @@ -16,6 +16,11 @@ func RepairAVCC(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { ps := JoinNALU(sps, pps) return func(packet *rtp.Packet) { + // this can happen for FLV from FFmpeg + if NALUType(packet.Payload) == NALUTypeSEI { + size := int(binary.BigEndian.Uint32(packet.Payload)) + 4 + packet.Payload = packet.Payload[size:] + } if NALUType(packet.Payload) == NALUTypeIFrame { packet.Payload = Join(ps, packet.Payload) } From 788afb7189979db3533cae1f7a1ed0c3c859121d Mon Sep 17 00:00:00 2001 From: Alex X Date: Thu, 18 Sep 2025 23:08:33 +0300 Subject: [PATCH 56/84] Fix HomeKit server support on iOS 26 #1843 --- pkg/hap/camera/accessory.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/hap/camera/accessory.go b/pkg/hap/camera/accessory.go index 42037d96..973983ec 100644 --- a/pkg/hap/camera/accessory.go +++ b/pkg/hap/camera/accessory.go @@ -12,7 +12,7 @@ func NewAccessory(manuf, model, name, serial, firmware string) *hap.Accessory { hap.ServiceAccessoryInformation(manuf, model, name, serial, firmware), ServiceCameraRTPStreamManagement(), //hap.ServiceHAPProtocolInformation(), - //ServiceMicrophone(), + ServiceMicrophone(), }, } acc.InitIID() @@ -30,17 +30,17 @@ func ServiceMicrophone() *hap.Service { Perms: hap.EVPRPW, //Descr: "Mute", }, - { - Type: "119", - Format: hap.FormatUInt8, - Value: 100, - Perms: hap.EVPRPW, - //Descr: "Volume", - //Unit: hap.UnitPercentage, - //MinValue: 0, - //MaxValue: 100, - //MinStep: 1, - }, + //{ + // Type: "119", + // Format: hap.FormatUInt8, + // Value: 100, + // Perms: hap.EVPRPW, + // //Descr: "Volume", + // //Unit: hap.UnitPercentage, + // //MinValue: 0, + // //MaxValue: 100, + // //MinStep: 1, + //}, }, } } @@ -62,7 +62,7 @@ func ServiceCameraRTPStreamManagement() *hap.Service { VideoAttrs: []VideoAttrs{ {Width: 1920, Height: 1080, Framerate: 30}, {Width: 1280, Height: 720, Framerate: 30}, // important for iPhones - {Width: 320, Height: 240, Framerate: 15}, // apple watch + {Width: 320, Height: 240, Framerate: 15}, // apple watch }, }, }, From 8b4df5f02c7882256799f8c769e5c1e999d8bd6b Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 19 Sep 2025 15:21:02 +0300 Subject: [PATCH 57/84] Code refactoring for #1841 --- internal/api/ws/ws.go | 7 ++----- pkg/core/core_test.go | 14 ++++++++++++++ pkg/core/helpers.go | 12 ++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/internal/api/ws/ws.go b/internal/api/ws/ws.go index 5a7d34be..981d1b41 100644 --- a/internal/api/ws/ws.go +++ b/internal/api/ws/ws.go @@ -8,10 +8,10 @@ import ( "strings" "sync" "time" - "regexp" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/gorilla/websocket" "github.com/rs/zerolog" ) @@ -133,10 +133,7 @@ func apiWS(w http.ResponseWriter, r *http.Request) { if handler := wsHandlers[msg.Type]; handler != nil { go func() { if err = handler(tr, msg); err != nil { - // Some streams such as ffmpeg might return credentials on error messages - errMsg := err.Error() - sanitizer := regexp.MustCompile(`(\w+)://(.*)@`) - errMsg = sanitizer.ReplaceAllString(errMsg, "$1://******@") + errMsg := core.StripUserinfo(err.Error()) tr.Write(&Message{Type: "error", Value: msg.Type + ": " + errMsg}) } }() diff --git a/pkg/core/core_test.go b/pkg/core/core_test.go index 4a05380a..e7845ca7 100644 --- a/pkg/core/core_test.go +++ b/pkg/core/core_test.go @@ -118,3 +118,17 @@ func TestName(t *testing.T) { // stage3 _ = prod2.Stop() } + +func TestStripUserinfo(t *testing.T) { + s := `streams: + test: + - ffmpeg:rtsp://username:password@10.1.2.3:554/stream1 + - ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy +` + s = StripUserinfo(s) + require.Equal(t, `streams: + test: + - ffmpeg:rtsp://***@10.1.2.3:554/stream1 + - ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy +`, s) +} diff --git a/pkg/core/helpers.go b/pkg/core/helpers.go index 72afe897..161a5504 100644 --- a/pkg/core/helpers.go +++ b/pkg/core/helpers.go @@ -2,6 +2,7 @@ package core import ( "crypto/rand" + "regexp" "runtime" "strconv" "strings" @@ -77,3 +78,14 @@ func Caller() string { _, file, line, _ := runtime.Caller(1) return file + ":" + strconv.Itoa(line) } + +const ( + unreserved = `A-Za-z0-9-._~` + subdelims = `!$&'()*+,;=` + userinfo = unreserved + subdelims + `%:` +) + +func StripUserinfo(s string) string { + sanitizer := regexp.MustCompile(`://[` + userinfo + `]+@`) + return sanitizer.ReplaceAllString(s, `://***@`) +} From 45cbbaf1cfa399d79081d7a55708b0019ac77dc1 Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 19 Sep 2025 15:26:54 +0300 Subject: [PATCH 58/84] Fixed a race condition when changing the config file --- internal/app/config.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/app/config.go b/internal/app/config.go index 9d4480b7..f0eb36e0 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/AlexxIT/go2rtc/pkg/yaml" @@ -18,11 +19,16 @@ func LoadConfig(v any) { } } +var configMu sync.Mutex + func PatchConfig(path []string, value any) error { if ConfigPath == "" { return errors.New("config file disabled") } + configMu.Lock() + defer configMu.Unlock() + // empty config is OK b, _ := os.ReadFile(ConfigPath) From 40269328fb28eb79413a097e4f3e94b051ff477c Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 19 Sep 2025 15:27:58 +0300 Subject: [PATCH 59/84] Fix insecure PINs for HomeKit server --- pkg/hap/helpers.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/hap/helpers.go b/pkg/hap/helpers.go index d1400b84..3900f935 100644 --- a/pkg/hap/helpers.go +++ b/pkg/hap/helpers.go @@ -71,11 +71,17 @@ type JSONCharacter struct { Event any `json:"ev,omitempty"` } +// 4.2.1.2 Invalid Setup Codes +const insecurePINs = "00000000 11111111 22222222 33333333 44444444 55555555 66666666 77777777 88888888 99999999 12345678 87654321" + func SanitizePin(pin string) (string, error) { s := strings.ReplaceAll(pin, "-", "") if len(s) != 8 { return "", errors.New("hap: wrong PIN format: " + pin) } + if strings.Contains(insecurePINs, s) { + return "", errors.New("hap: insecure PIN: " + pin) + } // 123-45-678 return s[:3] + "-" + s[3:5] + "-" + s[5:], nil } From 3b976c68122228e84fbf41c4c280edbe3e10ecbb Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 19 Sep 2025 15:29:24 +0300 Subject: [PATCH 60/84] Improve HomeKit TLV format parser --- pkg/hap/tlv8/tlv8.go | 131 +++++++++++++++++++++++++------------- pkg/hap/tlv8/tlv8_test.go | 47 ++++++++++++++ 2 files changed, 133 insertions(+), 45 deletions(-) diff --git a/pkg/hap/tlv8/tlv8.go b/pkg/hap/tlv8/tlv8.go index 068f21c3..7af27ea4 100644 --- a/pkg/hap/tlv8/tlv8.go +++ b/pkg/hap/tlv8/tlv8.go @@ -46,6 +46,8 @@ func Marshal(v any) ([]byte, error) { } switch kind { + case reflect.Slice: + return appendSlice(nil, value) case reflect.Struct: return appendStruct(nil, value) } @@ -53,6 +55,23 @@ func Marshal(v any) ([]byte, error) { return nil, errors.New("tlv8: not implemented: " + kind.String()) } +// separator the most confusing meaning in the documentation. +// It can have a value of 0x00 or 0xFF or even 0x05. +const separator = 0xFF + +func appendSlice(b []byte, value reflect.Value) ([]byte, error) { + for i := 0; i < value.Len(); i++ { + if i > 0 { + b = append(b, separator, 0) + } + var err error + if b, err = appendStruct(b, value.Index(i)); err != nil { + return nil, err + } + } + return b, nil +} + func appendStruct(b []byte, value reflect.Value) ([]byte, error) { valueType := value.Type() @@ -121,7 +140,7 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) { case reflect.Slice: for i := 0; i < value.Len(); i++ { if i > 0 { - b = append(b, 0, 0) + b = append(b, separator, 0) } if b, err = appendValue(b, tag, value.Index(i)); err != nil { return nil, err @@ -179,64 +198,86 @@ func Unmarshal(data []byte, v any) error { kind = value.Kind() } - if kind != reflect.Struct { - return errors.New("tlv8: not implemented: " + kind.String()) + switch kind { + case reflect.Slice: + return unmarshalSlice(data, value) + case reflect.Struct: + return unmarshalStruct(data, value) } - return unmarshalStruct(data, value) + return errors.New("tlv8: not implemented: " + kind.String()) } -func unmarshalStruct(b []byte, value reflect.Value) error { - var waitSlice bool +// unmarshalTLV can return two types of errors: +// - critical and then the value of []byte will be nil +// - not critical and then []byte will contain the value +func unmarshalTLV(b []byte, value reflect.Value) ([]byte, error) { + if len(b) < 2 { + return nil, errors.New("tlv8: wrong size: " + value.Type().Name()) + } - for len(b) >= 2 { - t := b[0] - l := int(b[1]) + t := b[0] + l := int(b[1]) - // array item divider - if t == 0 && l == 0 { - b = b[2:] - waitSlice = true - continue + // array item divider (t == 0x00 || t == 0xFF) + if l == 0 { + return b[2:], errors.New("tlv8: zero item") + } + + var v []byte + + for { + if len(b) < 2+l { + return nil, errors.New("tlv8: wrong size: " + value.Type().Name()) } - var v []byte + v = append(v, b[2:2+l]...) + b = b[2+l:] - for { - if len(b) < 2+l { - return errors.New("tlv8: wrong size: " + value.Type().Name()) + // if size == 255 and same tag - continue read big payload + if l < 255 || len(b) < 2 || b[0] != t { + break + } + + l = int(b[1]) + } + + tag := strconv.Itoa(int(t)) + + valueField, ok := getStructField(value, tag) + if !ok { + return b, fmt.Errorf("tlv8: can't find T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) + } + + if err := unmarshalValue(v, valueField); err != nil { + return nil, err + } + + return b, nil +} + +func unmarshalSlice(b []byte, value reflect.Value) error { + valueIndex := value.Index(growSlice(value)) + for len(b) > 0 { + var err error + if b, err = unmarshalTLV(b, valueIndex); err != nil { + if b != nil { + valueIndex = value.Index(growSlice(value)) + continue } - - v = append(v, b[2:2+l]...) - b = b[2+l:] - - // if size == 255 and same tag - continue read big payload - if l < 255 || len(b) < 2 || b[0] != t { - break - } - - l = int(b[1]) - } - - tag := strconv.Itoa(int(t)) - - valueField, ok := getStructField(value, tag) - if !ok { - return fmt.Errorf("tlv8: can't find T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) - } - - if waitSlice { - if valueField.Kind() != reflect.Slice { - return fmt.Errorf("tlv8: should be slice T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) - } - waitSlice = false - } - - if err := unmarshalValue(v, valueField); err != nil { return err } } + return nil +} +func unmarshalStruct(b []byte, value reflect.Value) error { + for len(b) > 0 { + var err error + if b, err = unmarshalTLV(b, value); b == nil && err != nil { + return err + } + } return nil } diff --git a/pkg/hap/tlv8/tlv8_test.go b/pkg/hap/tlv8/tlv8_test.go index 5ac41fec..bb44c981 100644 --- a/pkg/hap/tlv8/tlv8_test.go +++ b/pkg/hap/tlv8/tlv8_test.go @@ -2,6 +2,7 @@ package tlv8 import ( "encoding/hex" + "strings" "testing" "github.com/stretchr/testify/require" @@ -107,3 +108,49 @@ func TestInterface(t *testing.T) { require.Equal(t, src, dst) } + +func TestSlice1(t *testing.T) { + var v struct { + VideoAttrs []struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` + Framerate uint8 `tlv8:"3"` + } `tlv8:"3"` + } + + s := `030b010280070202380403011e ff00 030b010200050202d00203011e` + b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + require.NoError(t, err) + + err = Unmarshal(b1, &v) + require.NoError(t, err) + + require.Len(t, v.VideoAttrs, 2) + + b2, err := Marshal(v) + require.NoError(t, err) + + require.Equal(t, b1, b2) +} + +func TestSlice2(t *testing.T) { + var v []struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` + Framerate uint8 `tlv8:"3"` + } + + s := `010280070202380403011e ff00 010200050202d00203011e` + b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + require.NoError(t, err) + + err = Unmarshal(b1, &v) + require.NoError(t, err) + + require.Len(t, v, 2) + + b2, err := Marshal(v) + require.NoError(t, err) + + require.Equal(t, b1, b2) +} From e072842b7a6ccd69cd2ddb8c9df9855df467a042 Mon Sep 17 00:00:00 2001 From: mihailstoynov Date: Sun, 21 Sep 2025 22:56:59 +0300 Subject: [PATCH 61/84] Update README.md example for VGA and HD streams for ease of use --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 13943770..c9b884f3 100644 --- a/README.md +++ b/README.md @@ -531,7 +531,7 @@ streams: - stream quality is the same as [RTSP protocol](https://www.tapo.com/en/faq/34/) - use the **cloud password**, this is not the RTSP password! you do not need to add a login! -- you can also use UPPERCASE MD5 hash from your cloud password with `admin` username +- you can also use **UPPERCASE** MD5 hash from your cloud password with `admin` username - some new camera firmwares require SHA256 instead of MD5 ```yaml @@ -542,6 +542,10 @@ streams: camera2: tapo://admin:UPPERCASE-MD5@192.168.1.123 # admin username and UPPERCASE SHA256 cloud-password hash camera3: tapo://admin:UPPERCASE-SHA256@192.168.1.123 + # VGA stream (the so called substream, the lower resolution one) + camera4: tapo://cloud-password@192.168.1.123?subtype=1 + # HD stream (default) + camera5: tapo://cloud-password@192.168.1.123?subtype=0 ``` ```bash From fd682306e7a236962b3e4ecbf9ce7358a3673551 Mon Sep 17 00:00:00 2001 From: Alex X Date: Mon, 22 Sep 2025 18:11:47 +0300 Subject: [PATCH 62/84] Fix MultiUDPMuxDefault panic #1646 --- pkg/webrtc/api.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/webrtc/api.go b/pkg/webrtc/api.go index fe49ef1e..79cf6d3c 100644 --- a/pkg/webrtc/api.go +++ b/pkg/webrtc/api.go @@ -125,13 +125,20 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error networks = append(networks, ice.NetworkType(ntype)) } - udpMux, _ = ice.NewMultiUDPMuxFromPort( + var err error + if udpMux, err = ice.NewMultiUDPMuxFromPort( port, ice.UDPMuxFromPortWithInterfaceFilter(interfaceFilter), ice.UDPMuxFromPortWithIPFilter(ipFilter), ice.UDPMuxFromPortWithNetworks(networks...), - ) - } else if ln, err := net.ListenPacket("udp", address); err == nil { + ); err != nil { + return nil, err + } + } else { + ln, err := net.ListenPacket("udp", address) + if err != nil { + return nil, err + } udpMux = ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: ln}) } s.SetICEUDPMux(udpMux) From 26f16e392f9bd3733b131efe477a7e7052e487f4 Mon Sep 17 00:00:00 2001 From: Alex X Date: Wed, 24 Sep 2025 16:18:18 +0300 Subject: [PATCH 63/84] Update go (build) version to 1.25 and related readme --- .github/workflows/build.yml | 2 +- README.md | 4 ++-- docker/Dockerfile | 2 +- docker/hardware.Dockerfile | 2 +- docker/rockchip.Dockerfile | 2 +- scripts/README.md | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ac4d758d..c802df63 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 - with: { go-version: '1.24' } + with: { go-version: '1.25' } - name: Build go2rtc_win64 env: { GOOS: windows, GOARCH: amd64 } diff --git a/README.md b/README.md index c9b884f3..edaa1159 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/): - `go2rtc_win64.zip` - Windows 10+ 64-bit -- `go2rtc_win32.zip` - Windows 7+ 32-bit +- `go2rtc_win32.zip` - Windows 10+ 32-bit - `go2rtc_win_arm64.zip` - Windows ARM 64-bit - `go2rtc_linux_amd64` - Linux 64-bit - `go2rtc_linux_i386` - Linux 32-bit @@ -124,7 +124,7 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2 - `go2rtc_linux_arm` - Linux ARM 32-bit (ex. Raspberry 32-bit OS) - `go2rtc_linux_armv6` - Linux ARMv6 (for old Raspberry 1 and Zero) - `go2rtc_linux_mipsel` - Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3), [Wyze cameras](https://github.com/gtxaspec/wz_mini_hacks)) -- `go2rtc_mac_amd64.zip` - macOS 10.13+ Intel 64-bit +- `go2rtc_mac_amd64.zip` - macOS 11+ Intel 64-bit - `go2rtc_mac_arm64.zip` - macOS ARM 64-bit - `go2rtc_freebsd_amd64.zip` - FreeBSD 64-bit - `go2rtc_freebsd_arm64.zip` - FreeBSD ARM 64-bit diff --git a/docker/Dockerfile b/docker/Dockerfile index 34a96757..854ea6c9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,7 +2,7 @@ # 0. Prepare images ARG PYTHON_VERSION="3.11" -ARG GO_VERSION="1.24" +ARG GO_VERSION="1.25" # 1. Build go2rtc binary diff --git a/docker/hardware.Dockerfile b/docker/hardware.Dockerfile index 03b7d496..a80d08d7 100644 --- a/docker/hardware.Dockerfile +++ b/docker/hardware.Dockerfile @@ -4,7 +4,7 @@ # only debian 13 (trixie) has latest ffmpeg # https://packages.debian.org/trixie/ffmpeg ARG DEBIAN_VERSION="trixie-slim" -ARG GO_VERSION="1.24-bookworm" +ARG GO_VERSION="1.25-bookworm" # 1. Build go2rtc binary diff --git a/docker/rockchip.Dockerfile b/docker/rockchip.Dockerfile index a7a1b450..949db83b 100644 --- a/docker/rockchip.Dockerfile +++ b/docker/rockchip.Dockerfile @@ -2,7 +2,7 @@ # 0. Prepare images ARG PYTHON_VERSION="3.13-slim-bookworm" -ARG GO_VERSION="1.24-bookworm" +ARG GO_VERSION="1.25-bookworm" # 1. Build go2rtc binary diff --git a/scripts/README.md b/scripts/README.md index 9c7f4544..5594915d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ ## Versions +**PS.** Unfortunately, due to the dependency on `pion/webrtc/v4 v4.1.3`, had to upgrade go to `1.23`. Everything described below is not relevant. + [Go 1.20](https://go.dev/doc/go1.20) is last version with support Windows 7 and macOS 10.13. Go 1.21 support only Windows 10 and macOS 10.15. @@ -16,8 +18,6 @@ golang.org/x/sys v0.30.0 // indirect golang.org/x/tools v0.24.0 // indirect ``` -**PS.** Unfortunately, due to the dependency on `pion/webrtc/v4 v4.1.3`, had to upgrade go to `1.23`. - ## Build - UPX-3.96 pack broken bin for `linux_mipsel` From df95ce39d08f4eae0544f7dc340f8d8ee27a5752 Mon Sep 17 00:00:00 2001 From: Alex X Date: Wed, 24 Sep 2025 16:34:54 +0300 Subject: [PATCH 64/84] Update version to 1.9.10 --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index e85c5900..0cfc31fb 100644 --- a/main.go +++ b/main.go @@ -44,7 +44,7 @@ import ( ) func main() { - app.Version = "1.9.9" + app.Version = "1.9.10" // 1. Core modules: app, api/ws, streams From d697bdcf059c303fcf08edb6303dc3cbc3882b8e Mon Sep 17 00:00:00 2001 From: Alex X Date: Mon, 29 Sep 2025 18:21:36 +0300 Subject: [PATCH 65/84] Code refactoring for #1644 --- www/video-rtc.js | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/www/video-rtc.js b/www/video-rtc.js index 7efa0a57..a239c679 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -580,13 +580,14 @@ export class VideoRTC extends HTMLElement { /** @type {MediaStream} */ const stream = video2.srcObject; - if (stream.getVideoTracks().length > 0) rtcPriority += 0x220; + if (stream.getVideoTracks().length > 0) { + // not the best, but a pretty simple way to check a codec + const isH265Supported = this.pc.remoteDescription.sdp.includes('H265/90000'); + rtcPriority += isH265Supported ? 0x240 : 0x220; + } if (stream.getAudioTracks().length > 0) rtcPriority += 0x102; - if (this.mseCodecs.indexOf('hvc1.')) { - if (VideoRTC.isH265Supported()) rtcPriority += 0x230; - else msePriority += 0x230; - } + if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230; if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210; if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101; @@ -667,23 +668,6 @@ export class VideoRTC extends HTMLElement { this.send({type: 'mp4', value: this.codecs(this.video.canPlayType)}); } - static isH265Supported() { - try { - const videoCodecs = RTCRtpSender?.getCapabilities('video')?.codecs; - - if (!videoCodecs) { - return false; - } - - return videoCodecs.some(codec => - codec.mimeType.toLowerCase().includes('h265') || - codec.mimeType.toLowerCase().includes('hevc') - ); - } catch { - return false; - } - } - static btoa(buffer) { const bytes = new Uint8Array(buffer); const len = bytes.byteLength; From 7d9862202a9c87439bdcffd2d547e8586cdc02a8 Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 30 Sep 2025 12:12:29 +0300 Subject: [PATCH 66/84] Code refactoring for video-rtc.js --- www/video-rtc.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/www/video-rtc.js b/www/video-rtc.js index a239c679..8ecbce72 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -185,7 +185,7 @@ export class VideoRTC extends HTMLElement { /** @param {Function} isSupported */ codecs(isSupported) { return this.CODECS - .filter(codec => this.media.indexOf(codec.indexOf('vc1') > 0 ? 'video' : 'audio') >= 0) + .filter(codec => this.media.includes(codec.includes('vc1') ? 'video' : 'audio')) .filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join(); } @@ -350,23 +350,23 @@ export class VideoRTC extends HTMLElement { const modes = []; - if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) { + if (this.mode.includes('mse') && ('MediaSource' in window || 'ManagedMediaSource' in window)) { modes.push('mse'); this.onmse(); - } else if (this.mode.indexOf('hls') >= 0 && this.video.canPlayType('application/vnd.apple.mpegurl')) { + } else if (this.mode.includes('hls') && this.video.canPlayType('application/vnd.apple.mpegurl')) { modes.push('hls'); this.onhls(); - } else if (this.mode.indexOf('mp4') >= 0) { + } else if (this.mode.includes('mp4')) { modes.push('mp4'); this.onmp4(); } - if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) { + if (this.mode.includes('webrtc') && 'RTCPeerConnection' in window) { modes.push('webrtc'); this.onwebrtc(); } - if (this.mode.indexOf('mjpeg') >= 0) { + if (this.mode.includes('mjpeg')) { if (modes.length) { this.onmessage['mjpeg'] = msg => { if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return; @@ -490,7 +490,7 @@ export class VideoRTC extends HTMLElement { const pc = new RTCPeerConnection(this.pcConfig); pc.addEventListener('icecandidate', ev => { - if (ev.candidate && this.mode.indexOf('webrtc/tcp') >= 0 && ev.candidate.protocol === 'udp') return; + if (ev.candidate && this.mode.includes('webrtc/tcp') && ev.candidate.protocol === 'udp') return; const candidate = ev.candidate ? ev.candidate.toJSON().candidate : ''; this.send({type: 'webrtc/candidate', value: candidate}); @@ -518,7 +518,7 @@ export class VideoRTC extends HTMLElement { this.onmessage['webrtc'] = msg => { switch (msg.type) { case 'webrtc/candidate': - if (this.mode.indexOf('webrtc/tcp') >= 0 && msg.value.indexOf(' udp ') > 0) return; + if (this.mode.includes('webrtc/tcp') && msg.value.includes(' udp ')) return; pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => { console.warn(er); @@ -530,7 +530,7 @@ export class VideoRTC extends HTMLElement { }); break; case 'error': - if (msg.value.indexOf('webrtc/offer') < 0) return; + if (!msg.value.includes('webrtc/offer')) return; pc.close(); } }; @@ -549,7 +549,7 @@ export class VideoRTC extends HTMLElement { */ async createOffer(pc) { try { - if (this.media.indexOf('microphone') >= 0) { + if (this.media.includes('microphone')) { const media = await navigator.mediaDevices.getUserMedia({audio: true}); media.getTracks().forEach(track => { pc.addTransceiver(track, {direction: 'sendonly'}); @@ -560,7 +560,7 @@ export class VideoRTC extends HTMLElement { } for (const kind of ['video', 'audio']) { - if (this.media.indexOf(kind) >= 0) { + if (this.media.includes(kind)) { pc.addTransceiver(kind, {direction: 'recvonly'}); } } @@ -587,9 +587,9 @@ export class VideoRTC extends HTMLElement { } if (stream.getAudioTracks().length > 0) rtcPriority += 0x102; - if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230; - if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210; - if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101; + if (this.mseCodecs.includes('hvc1.')) msePriority += 0x230; + if (this.mseCodecs.includes('avc1.')) msePriority += 0x210; + if (this.mseCodecs.includes('mp4a.')) msePriority += 0x101; if (rtcPriority >= msePriority) { this.video.srcObject = stream; From c7119f44032fae050b0a7747bf555d17d4ac697e Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 30 Sep 2025 12:14:41 +0300 Subject: [PATCH 67/84] Fix RTP processing for H265 codec (restore VPS,SPS,PPS) --- pkg/h265/rtp.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/h265/rtp.go b/pkg/h265/rtp.go index 7a55b408..72d2c02f 100644 --- a/pkg/h265/rtp.go +++ b/pkg/h265/rtp.go @@ -9,8 +9,8 @@ import ( ) func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { - //vps, sps, pps := GetParameterSet(codec.FmtpLine) - //ps := h264.EncodeAVC(vps, sps, pps) + vps, sps, pps := GetParameterSet(codec.FmtpLine) + ps := h264.JoinNALU(vps, sps, pps) buf := make([]byte, 0, 512*1024) // 512K var nuStart int @@ -40,9 +40,9 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { nuType = data[2] & 0x3F // push PS data before keyframe - //if len(buf) == 0 && nuType >= 19 && nuType <= 21 { - // buf = append(buf, ps...) - //} + if len(buf) == 0 && nuType >= 19 && nuType <= 21 { + buf = append(buf, ps...) + } nuStart = len(buf) buf = append(buf, 0, 0, 0, 0) // NAL unit size From 2b5f9429a8fec87ec1150b9379a563b9d9969e10 Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 30 Sep 2025 12:17:41 +0300 Subject: [PATCH 68/84] Update FFmpeg command for encoding H265 (fix profile and level) --- internal/ffmpeg/ffmpeg.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go index e3b0c161..242c151d 100644 --- a/internal/ffmpeg/ffmpeg.go +++ b/internal/ffmpeg/ffmpeg.go @@ -80,7 +80,7 @@ var defaults = map[string]string{ // `-profile high -level 4.1` - most used streaming profile // `-pix_fmt:v yuv420p` - important for Telegram "h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", - "h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", + "h265": "-c:v libx265 -g 50 -profile:v main -x265-params level=5.1:high-tier=0 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", "mjpeg": "-c:v mjpeg", //"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p", From 3a0e4078fdb63cb9d496b7376f008c4fd183943e Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 30 Sep 2025 14:48:58 +0200 Subject: [PATCH 69/84] 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 70/84] 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 71/84] 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 72/84] 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 22cc8ac2c49b636423ee785844a71c761634d736 Mon Sep 17 00:00:00 2001 From: Alex X Date: Wed, 1 Oct 2025 16:57:39 +0300 Subject: [PATCH 73/84] Code refactoring for #1762 --- internal/streams/api.go | 80 ++++++++----------------- internal/streams/preload.go | 56 +++++++++++++----- internal/streams/streams.go | 16 +++-- pkg/preload/consumer.go | 82 -------------------------- pkg/probe/{producer.go => consumer.go} | 33 ++--------- 5 files changed, 79 insertions(+), 188 deletions(-) delete mode 100644 pkg/preload/consumer.go rename pkg/probe/{producer.go => consumer.go} (60%) diff --git a/internal/streams/api.go b/internal/streams/api.go index 1b91f906..d162cdf9 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -5,6 +5,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/probe" ) @@ -27,7 +28,7 @@ func apiStreams(w http.ResponseWriter, r *http.Request) { return } - cons := probe.NewProbe(query) + cons := probe.Create("probe", query) if len(cons.Medias) != 0 { cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { @@ -126,73 +127,44 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { func apiPreload(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() src := query.Get("src") - query.Del("src") - if src == "" { - http.Error(w, "no source", http.StatusBadRequest) + // check if stream exists + stream := Get(src) + if stream == nil { + http.Error(w, "", http.StatusNotFound) return } switch r.Method { case "PUT": - // check if stream exists - stream := Get(src) - if stream == nil { - http.Error(w, "stream not found", http.StatusNotFound) + // it's safe to delete from map while iterating + for k := range query { + switch k { + case core.KindVideo, core.KindAudio, "microphone": + default: + delete(query, k) + } + } + + rawQuery := query.Encode() + + if err := AddPreload(stream, rawQuery); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) return } - // check if consumer exists - if cons, ok := preloads[src]; ok { - stream.RemoveConsumer(cons) - delete(preloads, src) - } - - // parse query parameters - var rawQuery string - if query.Has("video") { - if videoQuery := query.Get("video"); videoQuery != "" { - rawQuery += "video=" + videoQuery + "#" - } else { - rawQuery += "video#" - } - } - if query.Has("audio") { - if audioQuery := query.Get("audio"); audioQuery != "" { - rawQuery += "audio=" + audioQuery + "#" - } else { - rawQuery += "audio#" - } - } - if query.Has("microphone") { - if micQuery := query.Get("microphone"); micQuery != "" { - rawQuery += "microphone=" + micQuery + "#" - } else { - rawQuery += "microphone#" - } - } - if err := app.PatchConfig([]string{"preload", src}, rawQuery); err != nil { - log.Error().Err(err).Str("src", src).Msg("Failed to patch config for PUT") - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + case "DELETE": + if err := DelPreload(stream); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) return } - Preload(src, rawQuery) - - case "DELETE": - if cons, ok := preloads[src]; ok { - if stream := Get(src); stream != nil { - stream.RemoveConsumer(cons) - } else { - cons.Stop() - } - - delete(preloads, src) - } - if err := app.PatchConfig([]string{"preload", src}, nil); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusInternalServerError) } default: diff --git a/internal/streams/preload.go b/internal/streams/preload.go index 7314df55..527746ac 100644 --- a/internal/streams/preload.go +++ b/internal/streams/preload.go @@ -1,34 +1,58 @@ package streams import ( + "errors" "net/url" + "sync" - "github.com/AlexxIT/go2rtc/pkg/preload" + "github.com/AlexxIT/go2rtc/pkg/probe" ) -var preloads = map[string]*preload.Preload{} +var preloads = map[*Stream]*probe.Probe{} +var preloadsMu sync.Mutex -func (s *Stream) Preload(name string, query url.Values) error { - cons := preload.NewPreload(name, query) - preloads[name] = cons +func Preload(stream *Stream, rawQuery string) { + if err := AddPreload(stream, rawQuery); err != nil { + log.Error().Err(err).Caller().Send() + } +} - if err := s.AddConsumer(cons); err != nil { +func AddPreload(stream *Stream, rawQuery string) error { + if rawQuery == "" { + rawQuery = "video&audio" + } + + query, err := url.ParseQuery(rawQuery) + if err != nil { return err } + preloadsMu.Lock() + defer preloadsMu.Unlock() + + if cons := preloads[stream]; cons != nil { + stream.RemoveConsumer(cons) + } + + cons := probe.Create("preload", query) + + if err = stream.AddConsumer(cons); err != nil { + return err + } + + preloads[stream] = cons return nil } -func Preload(src string, rawQuery string) { - // skip if exists - if _, ok := preloads[src]; ok { - return +func DelPreload(stream *Stream) error { + preloadsMu.Lock() + defer preloadsMu.Unlock() + + if cons := preloads[stream]; cons != nil { + stream.RemoveConsumer(cons) + delete(preloads, stream) + return nil } - if stream := Get(src); stream != nil { - query := ParseQuery(rawQuery) - if err := stream.Preload(src, query); err != nil { - log.Error().Err(err).Caller().Send() - } - } + return errors.New("streams: preload not found") } diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 8f07ea12..a0b1ed68 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -36,17 +36,15 @@ func Init() { } time.AfterFunc(time.Second, func() { - if cfg.Publish != nil { - for name, dst := range cfg.Publish { - if stream := Get(name); stream != nil { - Publish(stream, dst) - } + // range for nil map is OK + for name, dst := range cfg.Publish { + if stream := Get(name); stream != nil { + Publish(stream, dst) } } - - if cfg.Preload != nil { - for name, rawQuery := range cfg.Preload { - Preload(name, rawQuery) + for name, rawQuery := range cfg.Preload { + if stream := Get(name); stream != nil { + Preload(stream, rawQuery) } } }) diff --git a/pkg/preload/consumer.go b/pkg/preload/consumer.go deleted file mode 100644 index 4d3735a8..00000000 --- a/pkg/preload/consumer.go +++ /dev/null @@ -1,82 +0,0 @@ -package preload - -import ( - "net/url" - "strings" - - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/pion/rtp" -) - -type Preload struct { - core.Connection - closed core.Waiter -} - -func NewPreload(name string, query url.Values) *Preload { - medias := core.ParseQuery(query) - - for _, value := range query["microphone"] { - media := &core.Media{Kind: core.KindAudio, Direction: core.DirectionRecvonly} - - for _, name := range strings.Split(value, ",") { - name = strings.ToUpper(name) - switch name { - case "", "COPY": - name = core.CodecAny - } - media.Codecs = append(media.Codecs, &core.Codec{Name: name}) - } - - medias = append(medias, media) - } - - if len(medias) == 0 { - medias = []*core.Media{ - { - Kind: core.KindVideo, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{{Name: core.CodecAny}}, - }, - { - Kind: core.KindAudio, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{{Name: core.CodecAny}}, - }, - } - } - - return &Preload{ - Connection: core.Connection{ - ID: core.NewID(), - FormatName: "preload", - Medias: medias, - }, - } -} - -func (p *Preload) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { - sender := core.NewSender(media, track.Codec) - sender.Handler = func(pkt *rtp.Packet) { - p.Send += pkt.MarshalSize() - } - sender.HandleRTP(track) - p.Senders = append(p.Senders, sender) - return nil -} - -func (p *Preload) Start() error { - p.closed.Wait() - return nil -} - -func (p *Preload) Stop() error { - for _, receiver := range p.Receivers { - receiver.Close() - } - for _, sender := range p.Senders { - sender.Close() - } - p.closed.Done(nil) - return nil -} diff --git a/pkg/probe/producer.go b/pkg/probe/consumer.go similarity index 60% rename from pkg/probe/producer.go rename to pkg/probe/consumer.go index 1fbd3efb..c6aa4478 100644 --- a/pkg/probe/producer.go +++ b/pkg/probe/consumer.go @@ -11,7 +11,7 @@ type Probe struct { core.Connection } -func NewProbe(query url.Values) *Probe { +func Create(name string, query url.Values) *Probe { medias := core.ParseQuery(query) for _, value := range query["microphone"] { @@ -32,39 +32,18 @@ func NewProbe(query url.Values) *Probe { return &Probe{ Connection: core.Connection{ ID: core.NewID(), - FormatName: "probe", + FormatName: name, Medias: medias, }, } } -func (p *Probe) GetMedias() []*core.Media { - return p.Medias -} - func (p *Probe) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { sender := core.NewSender(media, track.Codec) - sender.Bind(track) + sender.Handler = func(pkt *core.Packet) { + p.Send += len(pkt.Payload) + } + sender.HandleRTP(track) p.Senders = append(p.Senders, sender) return nil } - -func (p *Probe) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - receiver := core.NewReceiver(media, codec) - p.Receivers = append(p.Receivers, receiver) - return receiver, nil -} - -func (p *Probe) Start() error { - return nil -} - -func (p *Probe) Stop() error { - for _, receiver := range p.Receivers { - receiver.Close() - } - for _, sender := range p.Senders { - sender.Close() - } - return nil -} From 4dd1f73a189865051c49d73cc6dbbc28b0185018 Mon Sep 17 00:00:00 2001 From: Alex X Date: Wed, 1 Oct 2025 17:03:32 +0300 Subject: [PATCH 74/84] Update readme for #1762 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dfbe79b0..5b3754c9 100644 --- a/README.md +++ b/README.md @@ -844,7 +844,7 @@ You can preload any stream on go2rtc start. This is useful for cameras that take preload: camera1: # default: video&audio = ANY camera2: "video" # preload only video track - camera3: "video=h264#audio=opus" # initialize transcoding pipeline + camera3: "video=h264&audio=opus" # preload H264 video and OPUS audio streams: camera1: From 54b95dced4ddee1a2e862f94365b7ad50ab1bff8 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sat, 4 Oct 2025 19:18:36 +0300 Subject: [PATCH 75/84] 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 76/84] 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 77/84] 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 78/84] 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 79/84] 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 80/84] 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 81/84] 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 82/84] 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 83/84] 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 84/84] 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 })