diff --git a/internal/ring/init.go b/internal/ring/init.go deleted file mode 100644 index bc49178b..00000000 --- a/internal/ring/init.go +++ /dev/null @@ -1,102 +0,0 @@ -package ring - -import ( - "encoding/json" - "net/http" - "net/url" - - "github.com/AlexxIT/go2rtc/internal/api" - "github.com/AlexxIT/go2rtc/internal/streams" - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/ring" -) - -func Init() { - streams.HandleFunc("ring", func(source string) (core.Producer, error) { - return ring.Dial(source) - }) - - api.HandleFunc("api/ring", apiRing) -} - -func apiRing(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - var ringAPI *ring.RingRestClient - var err error - - // Check auth method - if email := query.Get("email"); email != "" { - // Email/Password Flow - password := query.Get("password") - code := query.Get("code") - - ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{ - Email: email, - Password: password, - }, nil) - - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // Try authentication (this will trigger 2FA if needed) - if _, err = ringAPI.GetAuth(code); err != nil { - if ringAPI.Using2FA { - // Return 2FA prompt - json.NewEncoder(w).Encode(map[string]interface{}{ - "needs_2fa": true, - "prompt": ringAPI.PromptFor2FA, - }) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } else { - // 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{ - RefreshToken: refreshToken, - }, nil) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - 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) - - var items []*api.Source - for _, camera := range devices.AllCameras { - cleanQuery.Set("device_id", camera.DeviceID) - - // Stream source - items = append(items, &api.Source{ - Name: camera.Description, - URL: "ring:?" + cleanQuery.Encode(), - }) - - // Snapshot source - items = append(items, &api.Source{ - Name: camera.Description + " Snapshot", - URL: "ring:?" + cleanQuery.Encode() + "&snapshot", - }) - } - - api.ResponseSources(w, items) -} diff --git a/internal/ring/ring.go b/internal/ring/ring.go new file mode 100644 index 00000000..673ea480 --- /dev/null +++ b/internal/ring/ring.go @@ -0,0 +1,102 @@ +package ring + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/ring" +) + +func Init() { + streams.HandleFunc("ring", func(source string) (core.Producer, error) { + return ring.Dial(source) + }) + + api.HandleFunc("api/ring", apiRing) +} + +func apiRing(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + var ringAPI *ring.RingRestClient + var err error + + // Check auth method + if email := query.Get("email"); email != "" { + // Email/Password Flow + password := query.Get("password") + code := query.Get("code") + + ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{ + Email: email, + Password: password, + }, nil) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Try authentication (this will trigger 2FA if needed) + if _, err = ringAPI.GetAuth(code); err != nil { + if ringAPI.Using2FA { + // Return 2FA prompt + json.NewEncoder(w).Encode(map[string]interface{}{ + "needs_2fa": true, + "prompt": ringAPI.PromptFor2FA, + }) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } else { + // 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{ + RefreshToken: refreshToken, + }, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + 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) + + var items []*api.Source + for _, camera := range devices.AllCameras { + cleanQuery.Set("device_id", camera.DeviceID) + + // Stream source + items = append(items, &api.Source{ + Name: camera.Description, + URL: "ring:?" + cleanQuery.Encode(), + }) + + // Snapshot source + items = append(items, &api.Source{ + Name: camera.Description + " Snapshot", + URL: "ring:?" + cleanQuery.Encode() + "&snapshot", + }) + } + + api.ResponseSources(w, items) +} diff --git a/pkg/ring/api.go b/pkg/ring/api.go index e025e031..ed69465f 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -19,8 +19,8 @@ type RefreshTokenAuth struct { } type EmailAuth struct { - Email string - Password string + Email string + Password string } // AuthConfig represents the decoded refresh token data @@ -31,38 +31,38 @@ type AuthConfig struct { // AuthTokenResponse represents the response from the authentication endpoint type AuthTokenResponse struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - RefreshToken string `json:"refresh_token"` - Scope string `json:"scope"` // Always "client" - TokenType string `json:"token_type"` // Always "Bearer" + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` // Always "client" + TokenType string `json:"token_type"` // Always "Bearer" } type Auth2faResponse struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description"` - TSVState string `json:"tsv_state"` - Phone string `json:"phone"` - NextTimeInSecs int `json:"next_time_in_secs"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + TSVState string `json:"tsv_state"` + Phone string `json:"phone"` + 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"` + Ticket string `json:"ticket"` + ResponseTimestamp int64 `json:"response_timestamp"` } // RingRestClient handles authentication and requests to Ring API type RingRestClient struct { - httpClient *http.Client - authConfig *AuthConfig - hardwareID string - authToken *AuthTokenResponse - Using2FA bool - PromptFor2FA string - RefreshToken string - auth interface{} // EmailAuth or RefreshTokenAuth - onTokenRefresh func(string) + httpClient *http.Client + authConfig *AuthConfig + hardwareID string + authToken *AuthTokenResponse + Using2FA bool + PromptFor2FA string + RefreshToken string + auth interface{} // EmailAuth or RefreshTokenAuth + onTokenRefresh func(string) } // CameraKind represents the different types of Ring cameras @@ -70,11 +70,11 @@ type CameraKind string // CameraData contains common fields for all camera types type CameraData struct { - ID float64 `json:"id"` - Description string `json:"description"` - DeviceID string `json:"device_id"` - Kind string `json:"kind"` - LocationID string `json:"location_id"` + ID float64 `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 @@ -82,12 +82,12 @@ type RingDeviceType string // RingDevicesResponse represents the response from the Ring API type RingDevicesResponse struct { - Doorbots []CameraData `json:"doorbots"` - AuthorizedDoorbots []CameraData `json:"authorized_doorbots"` - StickupCams []CameraData `json:"stickup_cams"` - AllCameras []CameraData `json:"all_cameras"` - Chimes []CameraData `json:"chimes"` - Other []map[string]interface{} `json:"other"` + Doorbots []CameraData `json:"doorbots"` + AuthorizedDoorbots []CameraData `json:"authorized_doorbots"` + StickupCams []CameraData `json:"stickup_cams"` + AllCameras []CameraData `json:"all_cameras"` + Chimes []CameraData `json:"chimes"` + Other []map[string]interface{} `json:"other"` } const ( @@ -131,48 +131,48 @@ const ( ) const ( - clientAPIBaseURL = "https://api.ring.com/clients_api/" - deviceAPIBaseURL = "https://api.ring.com/devices/v1/" - commandsAPIBaseURL = "https://api.ring.com/commands/v1/" - appAPIBaseURL = "https://prd-api-us.prd.rings.solutions/api/v1/" - oauthURL = "https://oauth.ring.com/oauth/token" - apiVersion = 11 - defaultTimeout = 20 * time.Second - maxRetries = 3 + clientAPIBaseURL = "https://api.ring.com/clients_api/" + deviceAPIBaseURL = "https://api.ring.com/devices/v1/" + commandsAPIBaseURL = "https://api.ring.com/commands/v1/" + appAPIBaseURL = "https://prd-api-us.prd.rings.solutions/api/v1/" + oauthURL = "https://oauth.ring.com/oauth/token" + apiVersion = 11 + defaultTimeout = 20 * time.Second + maxRetries = 3 ) // NewRingRestClient creates a new Ring client instance func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { - client := &RingRestClient{ - httpClient: &http.Client{Timeout: defaultTimeout}, - onTokenRefresh: onTokenRefresh, - hardwareID: generateHardwareID(), - auth: auth, - } - - switch a := auth.(type) { - case RefreshTokenAuth: - if a.RefreshToken == "" { - return nil, fmt.Errorf("refresh token is required") - } - + client := &RingRestClient{ + httpClient: &http.Client{Timeout: defaultTimeout}, + onTokenRefresh: onTokenRefresh, + hardwareID: generateHardwareID(), + auth: auth, + } + + switch a := auth.(type) { + case RefreshTokenAuth: + if a.RefreshToken == "" { + return nil, fmt.Errorf("refresh token is required") + } + config, err := parseAuthConfig(a.RefreshToken) - if err != nil { - return nil, fmt.Errorf("failed to parse refresh token: %w", err) - } + if err != nil { + return nil, fmt.Errorf("failed to parse refresh token: %w", err) + } client.authConfig = config - client.hardwareID = config.HID + 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") - } - - return client, nil + case EmailAuth: + if a.Email == "" || a.Password == "" { + return nil, fmt.Errorf("email and password are required") + } + default: + return nil, fmt.Errorf("invalid auth type") + } + + return client, nil } // Request makes an authenticated request to the Ring API @@ -207,7 +207,7 @@ func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, // 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 { @@ -318,104 +318,104 @@ func (c *RingRestClient) ensureAuth() error { // getAuth makes an authentication request to the Ring API func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { - var grantData map[string]string - - if c.authConfig != nil && twoFactorAuthCode == "" { - grantData = map[string]string{ - "grant_type": "refresh_token", - "refresh_token": c.authConfig.RT, - } - } else { - authEmail, ok := c.auth.(EmailAuth) - if !ok { - return nil, fmt.Errorf("invalid auth type for email authentication") - } - grantData = map[string]string{ - "grant_type": "password", - "username": authEmail.Email, - "password": authEmail.Password, - } - } - - grantData["client_id"] = "ring_official_android" - grantData["scope"] = "client" - - body, err := json.Marshal(grantData) - if err != nil { - return nil, fmt.Errorf("failed to marshal auth request: %w", err) - } - - req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) - if err != nil { - return nil, 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") - if twoFactorAuthCode != "" { - req.Header.Set("2fa-code", twoFactorAuthCode) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - // Handle 2FA Responses - if resp.StatusCode == http.StatusPreconditionFailed || - (resp.StatusCode == http.StatusBadRequest && strings.Contains(resp.Header.Get("WWW-Authenticate"), "Verification Code")) { - - var tfaResp Auth2faResponse - if err := json.NewDecoder(resp.Body).Decode(&tfaResp); err != nil { - return nil, err - } - - c.Using2FA = true - if resp.StatusCode == http.StatusBadRequest { - c.PromptFor2FA = "Invalid 2fa code entered. Please try again." - return nil, fmt.Errorf("invalid 2FA code") - } - - if tfaResp.TSVState != "" { - prompt := "from your authenticator app" - if tfaResp.TSVState != "totp" { - prompt = fmt.Sprintf("sent to %s via %s", tfaResp.Phone, tfaResp.TSVState) - } - c.PromptFor2FA = fmt.Sprintf("Please enter the code %s", prompt) - } else { - c.PromptFor2FA = "Please enter the code sent to your text/email" - } - - return nil, fmt.Errorf("2FA required") - } - - // Handle errors - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, 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 nil, fmt.Errorf("failed to decode auth response: %w", err) - } - - c.authToken = &authResp - c.authConfig = &AuthConfig{ - RT: authResp.RefreshToken, - HID: c.hardwareID, - } - - c.RefreshToken = encodeAuthConfig(c.authConfig) - if c.onTokenRefresh != nil { - c.onTokenRefresh(c.RefreshToken) - } - - return c.authToken, nil + var grantData map[string]string + + if c.authConfig != nil && twoFactorAuthCode == "" { + grantData = map[string]string{ + "grant_type": "refresh_token", + "refresh_token": c.authConfig.RT, + } + } else { + authEmail, ok := c.auth.(EmailAuth) + if !ok { + return nil, fmt.Errorf("invalid auth type for email authentication") + } + grantData = map[string]string{ + "grant_type": "password", + "username": authEmail.Email, + "password": authEmail.Password, + } + } + + grantData["client_id"] = "ring_official_android" + grantData["scope"] = "client" + + body, err := json.Marshal(grantData) + if err != nil { + return nil, fmt.Errorf("failed to marshal auth request: %w", err) + } + + req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) + if err != nil { + return nil, 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") + if twoFactorAuthCode != "" { + req.Header.Set("2fa-code", twoFactorAuthCode) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Handle 2FA Responses + if resp.StatusCode == http.StatusPreconditionFailed || + (resp.StatusCode == http.StatusBadRequest && strings.Contains(resp.Header.Get("WWW-Authenticate"), "Verification Code")) { + + var tfaResp Auth2faResponse + if err := json.NewDecoder(resp.Body).Decode(&tfaResp); err != nil { + return nil, err + } + + c.Using2FA = true + if resp.StatusCode == http.StatusBadRequest { + c.PromptFor2FA = "Invalid 2fa code entered. Please try again." + return nil, fmt.Errorf("invalid 2FA code") + } + + if tfaResp.TSVState != "" { + prompt := "from your authenticator app" + if tfaResp.TSVState != "totp" { + prompt = fmt.Sprintf("sent to %s via %s", tfaResp.Phone, tfaResp.TSVState) + } + c.PromptFor2FA = fmt.Sprintf("Please enter the code %s", prompt) + } else { + c.PromptFor2FA = "Please enter the code sent to your text/email" + } + + return nil, fmt.Errorf("2FA required") + } + + // Handle errors + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, 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 nil, fmt.Errorf("failed to decode auth response: %w", err) + } + + c.authToken = &authResp + c.authConfig = &AuthConfig{ + RT: authResp.RefreshToken, + HID: c.hardwareID, + } + + c.RefreshToken = encodeAuthConfig(c.authConfig) + if c.onTokenRefresh != nil { + c.onTokenRefresh(c.RefreshToken) + } + + return c.authToken, nil } // Helper functions for auth config encoding/decoding @@ -542,4 +542,4 @@ func interfaceSlice(slice interface{}) []CameraData { } } return ret -} \ No newline at end of file +} diff --git a/pkg/ring/client.go b/pkg/ring/client.go index c432ecf9..7014213d 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -16,422 +16,422 @@ import ( ) type Client struct { - api *RingRestClient - ws *websocket.Conn - prod core.Producer - camera *CameraData - dialogID string - sessionID string - wsMutex sync.Mutex - done chan struct{} + api *RingRestClient + ws *websocket.Conn + prod core.Producer + camera *CameraData + dialogID string + sessionID string + wsMutex sync.Mutex + done chan struct{} } type SessionBody struct { - DoorbotID int `json:"doorbot_id"` - SessionID string `json:"session_id"` + 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"` + 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"` + 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"` + 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"` + 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"` + 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"` + 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"` + 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"` + Method string `json:"method"` + Body map[string]any `json:"body"` } // Close reason codes const ( - CloseReasonNormalClose = 0 - CloseReasonAuthenticationFailed = 5 - CloseReasonTimeout = 6 + 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 - } - - query := u.Query() - encodedToken := query.Get("refresh_token") - deviceID := query.Get("device_id") + // 1. Parse URL and validate basic params + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + encodedToken := query.Get("refresh_token") + deviceID := query.Get("device_id") _, isSnapshot := query["snapshot"] - if encodedToken == "" || deviceID == "" { - return nil, errors.New("ring: wrong query") - } - - // 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) - if err != nil { - 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, - dialogID: uuid.NewString(), - done: make(chan struct{}), - } - - // Check if snapshot request - if isSnapshot { - client.prod = NewSnapshotProducer(ringAPI, camera) - 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"}, - }) - if err != nil { - return nil, err - } - - // Create Peer Connection - conf := pion.Configuration{ - ICEServers: []pion.ICEServer{ - {URLs: []string{ - "stun:stun.kinesisvideo.us-east-1.amazonaws.com:443", - "stun:stun.kinesisvideo.us-east-2.amazonaws.com:443", - "stun:stun.kinesisvideo.us-west-2.amazonaws.com:443", - "stun:stun.l.google.com:19302", - "stun:stun1.l.google.com:19302", - "stun:stun2.l.google.com:19302", - "stun:stun3.l.google.com:19302", - "stun:stun4.l.google.com:19302", - }}, - }, - ICETransportPolicy: pion.ICETransportPolicyAll, - BundlePolicy: pion.BundlePolicyBalanced, - } - - api, err := webrtc.NewAPI() - if err != nil { - client.ws.Close() - return nil, err - } - - pc, err := api.NewPeerConnection(conf) - if err != nil { - client.ws.Close() - return nil, err - } - - // protect from sending ICE candidate before Offer - var sendOffer core.Waiter - - // 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 - - prod.Listen(func(msg any) { - switch msg := msg.(type) { - case *pion.ICECandidate: - _ = sendOffer.Wait() - - iceCandidate := msg.ToJSON() - - // skip empty ICE candidates - if iceCandidate.Candidate == "" { - return - } - - icePayload := map[string]interface{}{ - "ice": iceCandidate.Candidate, - "mlineindex": iceCandidate.SDPMLineIndex, - } - - if err = client.sendSessionMessage("ice", icePayload); err != nil { - connState.Done(err) - return - } - - case pion.PeerConnectionState: - switch msg { - case pion.PeerConnectionStateConnecting: - case pion.PeerConnectionStateConnected: - connState.Done(nil) - default: - connState.Done(errors.New("ring: " + msg.String())) - } - } - }) - - // Setup media configuration - medias := []*core.Media{ - { - Kind: core.KindAudio, - Direction: core.DirectionSendRecv, - Codecs: []*core.Codec{ - { - Name: "opus", - ClockRate: 48000, - Channels: 2, - }, - }, - }, - { - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: "H264", - ClockRate: 90000, - }, - }, - }, - } - - // Create offer - offer, err := prod.CreateOffer(medias) - if err != nil { - client.Stop() - return nil, err - } - - // Send offer - offerPayload := map[string]interface{}{ - "stream_options": map[string]bool{ - "audio_enabled": true, - "video_enabled": true, - }, - "sdp": offer, - } - - if err = client.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 { - return nil, err - } - - return client, nil + if encodedToken == "" || deviceID == "" { + return nil, errors.New("ring: wrong query") + } + + // 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) + if err != nil { + 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, + dialogID: uuid.NewString(), + done: make(chan struct{}), + } + + // Check if snapshot request + if isSnapshot { + client.prod = NewSnapshotProducer(ringAPI, camera) + 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"}, + }) + if err != nil { + return nil, err + } + + // Create Peer Connection + conf := pion.Configuration{ + ICEServers: []pion.ICEServer{ + {URLs: []string{ + "stun:stun.kinesisvideo.us-east-1.amazonaws.com:443", + "stun:stun.kinesisvideo.us-east-2.amazonaws.com:443", + "stun:stun.kinesisvideo.us-west-2.amazonaws.com:443", + "stun:stun.l.google.com:19302", + "stun:stun1.l.google.com:19302", + "stun:stun2.l.google.com:19302", + "stun:stun3.l.google.com:19302", + "stun:stun4.l.google.com:19302", + }}, + }, + ICETransportPolicy: pion.ICETransportPolicyAll, + BundlePolicy: pion.BundlePolicyBalanced, + } + + api, err := webrtc.NewAPI() + if err != nil { + client.ws.Close() + return nil, err + } + + pc, err := api.NewPeerConnection(conf) + if err != nil { + client.ws.Close() + return nil, err + } + + // protect from sending ICE candidate before Offer + var sendOffer core.Waiter + + // 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 + + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() + + iceCandidate := msg.ToJSON() + + // skip empty ICE candidates + if iceCandidate.Candidate == "" { + return + } + + icePayload := map[string]interface{}{ + "ice": iceCandidate.Candidate, + "mlineindex": iceCandidate.SDPMLineIndex, + } + + if err = client.sendSessionMessage("ice", icePayload); err != nil { + connState.Done(err) + return + } + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateConnecting: + case pion.PeerConnectionStateConnected: + connState.Done(nil) + default: + connState.Done(errors.New("ring: " + msg.String())) + } + } + }) + + // Setup media configuration + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendRecv, + Codecs: []*core.Codec{ + { + Name: "opus", + ClockRate: 48000, + Channels: 2, + }, + }, + }, + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: "H264", + ClockRate: 90000, + }, + }, + }, + } + + // Create offer + offer, err := prod.CreateOffer(medias) + if err != nil { + client.Stop() + return nil, err + } + + // Send offer + offerPayload := map[string]interface{}{ + "stream_options": map[string]bool{ + "audio_enabled": true, + "video_enabled": true, + }, + "sdp": offer, + } + + if err = client.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 { + return nil, err + } + + return client, nil } func (c *Client) startPingLoop(pc *pion.PeerConnection) { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - for { - select { - case <-c.done: - return - case <-ticker.C: - if pc.ConnectionState() == pion.PeerConnectionStateConnected { - if err := c.sendSessionMessage("ping", nil); err != nil { - return - } - } - } - } + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-c.done: + return + case <-ticker.C: + if pc.ConnectionState() == pion.PeerConnectionStateConnected { + if err := c.sendSessionMessage("ping", nil); err != nil { + return + } + } + } + } } 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: - return - default: - var res BaseMessage - if err = c.ws.ReadJSON(&res); err != nil { - select { - case <-c.done: - return - default: - } - - c.Stop() - 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 doorbotID != float64(c.camera.ID) { - 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 - } - } - - 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": - c.Stop() - return - - case "pong": - // Ignore - continue - } - } - } + var err error + + // will be closed when conn will be closed + defer func() { + connState.Done(err) + }() + + for { + select { + case <-c.done: + return + default: + var res BaseMessage + if err = c.ws.ReadJSON(&res); err != nil { + select { + case <-c.done: + return + default: + } + + c.Stop() + 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 doorbotID != float64(c.camera.ID) { + 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 + } + } + + 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": + c.Stop() + return + + case "pong": + // Ignore + continue + } + } + } } func (c *Client) activateSession() error { @@ -453,7 +453,7 @@ func (c *Client) activateSession() error { func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error { c.wsMutex.Lock() - defer c.wsMutex.Unlock() + defer c.wsMutex.Unlock() if body == nil { body = make(map[string]interface{}) @@ -486,18 +486,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 webrtcProd, ok := c.prod.(*webrtc.Conn); ok { - if media.Kind == core.KindAudio { - // Enable speaker - speakerPayload := map[string]interface{}{ - "stealth_mode": false, - } - _ = c.sendSessionMessage("camera_options", speakerPayload) - } - return webrtcProd.AddTrack(media, codec, track) - } - - return fmt.Errorf("add track not supported for snapshot") + if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { + if media.Kind == core.KindAudio { + // Enable speaker + speakerPayload := map[string]interface{}{ + "stealth_mode": false, + } + _ = c.sendSessionMessage("camera_options", speakerPayload) + } + return webrtcProd.AddTrack(media, codec, track) + } + + return fmt.Errorf("add track not supported for snapshot") } func (c *Client) Start() error { @@ -534,9 +534,9 @@ func (c *Client) Stop() error { } func (c *Client) MarshalJSON() ([]byte, error) { - if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { - return webrtcProd.MarshalJSON() - } - + if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { + return webrtcProd.MarshalJSON() + } + return nil, errors.New("ring: can't marshal") -} \ No newline at end of file +} diff --git a/pkg/ring/snapshot.go b/pkg/ring/snapshot.go index bbf86e28..84da0fd3 100644 --- a/pkg/ring/snapshot.go +++ b/pkg/ring/snapshot.go @@ -8,57 +8,57 @@ import ( ) type SnapshotProducer struct { - core.Connection + core.Connection - client *RingRestClient - camera *CameraData + client *RingRestClient + camera *CameraData } func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer { - return &SnapshotProducer{ - Connection: core.Connection{ - ID: core.NewID(), - FormatName: "ring/snapshot", - Protocol: "https", - Medias: []*core.Media{ - { - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: core.CodecJPEG, - ClockRate: 90000, - PayloadType: core.PayloadTypeRAW, - }, - }, - }, - }, - }, - client: client, - camera: camera, - } + return &SnapshotProducer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "ring/snapshot", + Protocol: "https", + Medias: []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + }, + }, + client: client, + camera: camera, + } } 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) - if err != nil { - return fmt.Errorf("failed to get snapshot: %w", err) - } + // Fetch snapshot + response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", int(p.camera.ID)), nil) + if err != nil { + return fmt.Errorf("failed to get snapshot: %w", err) + } - pkt := &rtp.Packet{ - Header: rtp.Header{Timestamp: core.Now90000()}, - Payload: response, - } + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: response, + } - // Send to all receivers + // Send to all receivers for _, receiver := range p.Receivers { - receiver.WriteRTP(pkt) - } + receiver.WriteRTP(pkt) + } - return nil + return nil } func (p *SnapshotProducer) Stop() error { - return p.Connection.Stop() -} \ No newline at end of file + return p.Connection.Stop() +} diff --git a/www/add.html b/www/add.html index 7dae63d4..cec8ed36 100644 --- a/www/add.html +++ b/www/add.html @@ -247,7 +247,7 @@ const r = await fetch(url, {cache: 'no-cache'}); const data = await r.json(); - + if (data.needs_2fa) { document.getElementById('tfa-field').style.display = 'block'; document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code';