From 485448cbc7c0a3d0fc25cae2b7e24fed6f2b58f2 Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 24 Jan 2025 12:38:45 +0100 Subject: [PATCH 1/6] initial ring implementation --- internal/ring/init.go | 47 ++++ main.go | 2 + pkg/ring/api.go | 416 +++++++++++++++++++++++++++++++ pkg/ring/client.go | 551 ++++++++++++++++++++++++++++++++++++++++++ www/add.html | 25 +- 5 files changed, 1040 insertions(+), 1 deletion(-) create mode 100644 internal/ring/init.go create mode 100644 pkg/ring/api.go create mode 100644 pkg/ring/client.go diff --git a/internal/ring/init.go b/internal/ring/init.go new file mode 100644 index 00000000..24a91ac6 --- /dev/null +++ b/internal/ring/init.go @@ -0,0 +1,47 @@ +package ring + +import ( + "net/http" + + "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() + refreshToken := query.Get("refresh_token") + + ringAPI, err := ring.NewRingRestClient(ring.RefreshTokenAuth{RefreshToken: refreshToken}, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + devices, err := ringAPI.FetchRingDevices() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var items []*api.Source + + for _, camera := range devices.AllCameras { + query.Set("device_id", camera.DeviceID) + + items = append(items, &api.Source{ + Name: camera.Description, URL: "ring:?" + query.Encode(), + }) + } + + api.ResponseSources(w, items) +} diff --git a/main.go b/main.go index db8de9f4..db3983cc 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/nest" "github.com/AlexxIT/go2rtc/internal/ngrok" "github.com/AlexxIT/go2rtc/internal/onvif" + "github.com/AlexxIT/go2rtc/internal/ring" "github.com/AlexxIT/go2rtc/internal/roborock" "github.com/AlexxIT/go2rtc/internal/rtmp" "github.com/AlexxIT/go2rtc/internal/rtsp" @@ -80,6 +81,7 @@ func main() { mpegts.Init() // mpegts passive source roborock.Init() // roborock source homekit.Init() // homekit source + ring.Init() // ring source nest.Init() // nest source bubble.Init() // bubble source expr.Init() // expr source diff --git a/pkg/ring/api.go b/pkg/ring/api.go new file mode 100644 index 00000000..faebf6b9 --- /dev/null +++ b/pkg/ring/api.go @@ -0,0 +1,416 @@ +package ring + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "time" +) + +type RefreshTokenAuth struct { + RefreshToken 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"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` // Always "client" + TokenType string `json:"token_type"` // Always "Bearer" +} + +// SocketTicketRequest represents the request to get a socket ticket +type SocketTicketResponse struct { + 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 + auth RefreshTokenAuth + onTokenRefresh func(string) // Callback when refresh token is updated +} + +// CameraKind represents the different types of Ring cameras +type CameraKind string + +const ( + Doorbot CameraKind = "doorbot" + Doorbell CameraKind = "doorbell" + DoorbellV3 CameraKind = "doorbell_v3" + DoorbellV4 CameraKind = "doorbell_v4" + DoorbellV5 CameraKind = "doorbell_v5" + DoorbellOyster CameraKind = "doorbell_oyster" + DoorbellPortal CameraKind = "doorbell_portal" + DoorbellScallop CameraKind = "doorbell_scallop" + DoorbellScallopLite CameraKind = "doorbell_scallop_lite" + DoorbellGraham CameraKind = "doorbell_graham_cracker" + LpdV1 CameraKind = "lpd_v1" + LpdV2 CameraKind = "lpd_v2" + LpdV4 CameraKind = "lpd_v4" + JboxV1 CameraKind = "jbox_v1" + StickupCam CameraKind = "stickup_cam" + StickupCamV3 CameraKind = "stickup_cam_v3" + StickupCamElite CameraKind = "stickup_cam_elite" + StickupCamLongfin CameraKind = "stickup_cam_longfin" + StickupCamLunar CameraKind = "stickup_cam_lunar" + SpotlightV2 CameraKind = "spotlightw_v2" + HpCamV1 CameraKind = "hp_cam_v1" + HpCamV2 CameraKind = "hp_cam_v2" + StickupCamV4 CameraKind = "stickup_cam_v4" + FloodlightV1 CameraKind = "floodlight_v1" + FloodlightV2 CameraKind = "floodlight_v2" + FloodlightPro CameraKind = "floodlight_pro" + CocoaCamera CameraKind = "cocoa_camera" + CocoaDoorbell CameraKind = "cocoa_doorbell" + CocoaFloodlight CameraKind = "cocoa_floodlight" + CocoaSpotlight CameraKind = "cocoa_spotlight" + StickupCamMini CameraKind = "stickup_cam_mini" + OnvifCamera CameraKind = "onvif_camera" +) + +// RingDeviceType represents different types of Ring devices +type RingDeviceType string + +const ( + IntercomHandsetAudio RingDeviceType = "intercom_handset_audio" + OnvifCameraType RingDeviceType = "onvif_camera" +) + +// 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"` +} + +// 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"` +} + +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 +) + +// NewRingRestClient creates a new Ring client instance +func NewRingRestClient(auth RefreshTokenAuth, onTokenRefresh func(string)) (*RingRestClient, error) { + client := &RingRestClient{ + httpClient: &http.Client{ + Timeout: defaultTimeout, + }, + auth: auth, + onTokenRefresh: onTokenRefresh, + hardwareID: generateHardwareID(), + } + + // check if refresh token is provided + if auth.RefreshToken == "" { + return nil, fmt.Errorf("refresh token is required") + } + + if config, err := parseAuthConfig(auth.RefreshToken); err == nil { + client.authConfig = config + client.hardwareID = config.HID + } + + 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) + } + + 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 { + c.authToken = nil // Force token refresh + 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) + } + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + 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 +} + +// ensureAuth ensures we have a valid auth token +func (c *RingRestClient) ensureAuth() error { + if c.authToken != nil { + 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, + } + + // Encode and notify about new refresh token + if c.onTokenRefresh != nil { + newRefreshToken := encodeAuthConfig(c.authConfig) + c.onTokenRefresh(newRefreshToken) + } + + return 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) { + response, err := c.Request("GET", ClientAPI("ring_devices"), nil) + if err != nil { + return nil, fmt.Errorf("failed to fetch ring devices: %w", err) + } + + var devices RingDevicesResponse + if err := json.Unmarshal(response, &devices); err != nil { + return nil, fmt.Errorf("failed to unmarshal devices response: %w", err) + } + + // Process "other" devices + var onvifCameras []CameraData + var intercoms []CameraData + + for _, device := range devices.Other { + kind, ok := device["kind"].(string) + if !ok { + continue + } + + switch RingDeviceType(kind) { + case OnvifCameraType: + var camera CameraData + if deviceJson, err := json.Marshal(device); err == nil { + if err := json.Unmarshal(deviceJson, &camera); err == nil { + onvifCameras = append(onvifCameras, camera) + } + } + case IntercomHandsetAudio: + var intercom CameraData + if deviceJson, err := json.Marshal(device); err == nil { + if err := json.Unmarshal(deviceJson, &intercom); err == nil { + intercoms = append(intercoms, intercom) + } + } + } + } + + // Combine all cameras into AllCameras slice + allCameras := make([]CameraData, 0) + allCameras = append(allCameras, interfaceSlice(devices.Doorbots)...) + allCameras = append(allCameras, interfaceSlice(devices.StickupCams)...) + allCameras = append(allCameras, interfaceSlice(devices.AuthorizedDoorbots)...) + allCameras = append(allCameras, interfaceSlice(onvifCameras)...) + allCameras = append(allCameras, interfaceSlice(intercoms)...) + + devices.AllCameras = allCameras + + return &devices, nil +} + +func (c *RingRestClient) 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) + } + + var ticket SocketTicketResponse + if err := json.Unmarshal(response, &ticket); err != nil { + return nil, fmt.Errorf("failed to unmarshal socket ticket response: %w", err) + } + + return &ticket, nil +} + +func generateHardwareID() string { + h := sha256.New() + h.Write([]byte("ring-client-go2rtc")) + return hex.EncodeToString(h.Sum(nil)[:16]) +} + +func interfaceSlice(slice interface{}) []CameraData { + s := reflect.ValueOf(slice) + if s.Kind() != reflect.Slice { + return nil + } + + ret := make([]CameraData, s.Len()) + for i := 0; i < s.Len(); i++ { + if camera, ok := s.Index(i).Interface().(CameraData); ok { + ret[i] = camera + } + } + return ret +} \ No newline at end of file diff --git a/pkg/ring/client.go b/pkg/ring/client.go new file mode 100644 index 00000000..db8e2eaa --- /dev/null +++ b/pkg/ring/client.go @@ -0,0 +1,551 @@ +package ring + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "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/v3" + "github.com/rs/zerolog/log" +) + +type Client struct { + conn *webrtc.Conn + ws *websocket.Conn + api *RingRestClient + camera *CameraData + dialogID string + sessionID string + done chan struct{} +} + +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. Create Ring Rest API client + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + encodedToken := query.Get("refresh_token") + deviceID := query.Get("device_id") + + 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) + } + + println("Connecting to Ring WebSocket") + println("Refresh Token: ", refreshToken) + println("Device ID: ", deviceID) + + // 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") + } + + // 2. Connect to signaling server + ticket, err := ringAPI.GetSocketTicket() + if err != nil { + return nil, err + } + + println("WebSocket Ticket: ", ticket.Ticket) + println("WebSocket ResponseTimestamp: ", ticket.ResponseTimestamp) + + // 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)) + + println("WebSocket URL: ", wsURL) + + conn, _, err := websocket.DefaultDialer.Dial(wsURL, map[string][]string{ + "User-Agent": {"android:com.ringapp"}, + }) + if err != nil { + return nil, err + } + + println("WebSocket handshake completed successfully") + + // 3. Create Peer Connection + println("Creating 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 { + println("Failed to create WebRTC API") + conn.Close() + return nil, err + } + + pc, err := api.NewPeerConnection(conf) + if err != nil { + println("Failed to create Peer Connection") + conn.Close() + return nil, err + } + + println("Peer Connection created") + + // 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 := &Client{ + ws: conn, + api: ringAPI, + camera: camera, + dialogID: uuid.NewString(), + conn: prod, + done: make(chan struct{}), + } + + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() + + iceCandidate := msg.ToJSON() + + 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, + }, + }, + }, + } + + // 4. Create offer + offer, err := prod.CreateOffer(medias) + if err != nil { + println("Failed to create offer") + client.Stop() + return nil, err + } + + println("Offer created") + println(offer) + + // 5. 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 { + println("Failed to send live_view message") + client.Stop() + return nil, err + } + + sendOffer.Done(nil) + + // Ring expects a ping message every 5 seconds + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-client.done: + return + case <-ticker.C: + if pc.ConnectionState() == pion.PeerConnectionStateConnected { + if err := client.sendSessionMessage("ping", nil); err != nil { + println("Failed to send ping:", err) + return + } + } + } + } + }() + + go func() { + var err error + + // will be closed when conn will be closed + defer func() { + connState.Done(err) + }() + + for { + select { + case <-client.done: + return + default: + var res BaseMessage + if err = conn.ReadJSON(&res); err != nil { + select { + case <-client.done: + return + default: + } + + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + println("WebSocket closed normally") + } else { + println("Failed to read JSON message:", err) + client.Stop() + } + return + } + + body, _ := json.Marshal(res.Body) + bodyStr := string(body) + + println("Received message:", res.Method) + println("Message body:", bodyStr) + + // check if "doorbot_id" is present and matches the camera ID + if _, ok := res.Body["doorbot_id"]; !ok { + println("Received message without doorbot_id") + continue + } + + doorbotID := res.Body["doorbot_id"].(float64) + if doorbotID != float64(client.camera.ID) { + println("Received message from unknown doorbot:", doorbotID) + continue + } + + if res.Method == "session_created" || res.Method == "session_started" { + if _, ok := res.Body["session_id"]; ok && client.sessionID == "" { + client.sessionID = res.Body["session_id"].(string) + println("Session established:", client.sessionID) + } + } + + if _, ok := res.Body["session_id"]; ok { + if res.Body["session_id"].(string) != client.sessionID { + println("Received message with wrong session ID") + continue + } + } + + rawMsg, _ := json.Marshal(res) + + switch res.Method { + case "sdp": + // 6. Get answer + var msg AnswerMessage + if err = json.Unmarshal(rawMsg, &msg); err != nil { + println("Failed to parse SDP message:", err) + client.Stop() + return + } + if err = prod.SetAnswer(msg.Body.SDP); err != nil { + println("Failed to set answer:", err) + client.Stop() + return + } + if err = client.activateSession(); err != nil { + println("Failed to activate session:", err) + client.Stop() + return + } + + case "ice": + // 7. Continue to receiving candidates + var msg IceCandidateMessage + if err = json.Unmarshal(rawMsg, &msg); err != nil { + println("Failed to parse ICE message:", err) + client.Stop() + return + } + + if err = prod.AddCandidate(msg.Body.Ice); err != nil { + client.Stop() + return + } + + case "close": + client.Stop() + return + + case "pong": + // Ignore + continue + } + } + } + }() + + if err = connState.Wait(); err != nil { + return nil, err + } + + return client, nil +} + +func (c *Client) activateSession() error { + println("Activating session") + + 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 + } + + println("Session activated") + + return nil +} + +func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error { + if body == nil { + body = make(map[string]interface{}) + } + + body["doorbot_id"] = c.camera.ID + if c.sessionID != "" { + body["session_id"] = c.sessionID + } + + msg := map[string]interface{}{ + "method": method, + "dialog_id": c.dialogID, + "body": body, + } + + println("Sending session message:", method) + + if err := c.ws.WriteJSON(msg); err != nil { + log.Error().Err(err).Msg("Failed to send JSON message") + return err + } + + return nil +} + +func (c *Client) GetMedias() []*core.Media { + println("Getting medias") + return c.conn.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + println("Getting track") + return c.conn.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + println("Adding track") + return c.conn.AddTrack(media, codec, track) +} + +func (c *Client) Start() error { + println("Starting client") + return c.conn.Start() +} + +func (c *Client) Stop() error { + select { + case <-c.done: + return nil + default: + println("Stopping client") + close(c.done) + } + + if c.conn != nil { + _ = c.conn.Stop() + c.conn = nil + } + + 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 + } + + return nil +} + +func (c *Client) MarshalJSON() ([]byte, error) { + return c.conn.MarshalJSON() +} \ No newline at end of file diff --git a/www/add.html b/www/add.html index 49e954d3..1190f07e 100644 --- a/www/add.html +++ b/www/add.html @@ -35,7 +35,7 @@ function drawTable(table, data) { const cols = ['id', 'name', 'info', 'url', 'location']; const th = (row) => cols.reduce((html, k) => k in row ? `${html}${k}` : html, '') + ''; - const td = (row) => cols.reduce((html, k) => k in row ? `${html}${row[k]}` : html, '') + ''; + const td = (row) => cols.reduce((html, k) => k in row ? `${html}${row[k]}` : html, '') + ''; const thead = th(data.sources[0]); const tbody = data.sources.reduce((html, source) => `${html}${td(source)}`, ''); @@ -218,6 +218,29 @@ }); + +
+
+ + +
+
+
+
From 17bba4d4a28644fb99bdffc5e6bef62d690ea3fd Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 24 Jan 2025 12:47:25 +0100 Subject: [PATCH 2/6] skip empty ICE candidates --- pkg/ring/client.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/ring/client.go b/pkg/ring/client.go index db8e2eaa..47790664 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -238,6 +238,11 @@ func Dial(rawURL string) (*Client, error) { iceCandidate := msg.ToJSON() + // skip empty ICE candidates + if iceCandidate.Candidate == "" { + return + } + icePayload := map[string]interface{}{ "ice": iceCandidate.Candidate, "mlineindex": iceCandidate.SDPMLineIndex, @@ -425,6 +430,12 @@ func Dial(rawURL string) (*Client, error) { return } + // check for empty ICE candidate + if msg.Body.Ice == "" { + println("Received empty ICE candidate") + continue + } + if err = prod.AddCandidate(msg.Body.Ice); err != nil { client.Stop() return From bceb024588813aa8f8a809ffcd99d9684b8dec57 Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 24 Jan 2025 17:37:50 +0100 Subject: [PATCH 3/6] enable speaker for two way audio --- pkg/ring/client.go | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/pkg/ring/client.go b/pkg/ring/client.go index 47790664..8fa27bd5 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -16,9 +16,9 @@ import ( ) type Client struct { - conn *webrtc.Conn - ws *websocket.Conn api *RingRestClient + ws *websocket.Conn + prod *webrtc.Conn camera *CameraData dialogID string sessionID string @@ -162,7 +162,7 @@ func Dial(rawURL string) (*Client, error) { println("WebSocket URL: ", wsURL) - conn, _, err := websocket.DefaultDialer.Dial(wsURL, map[string][]string{ + ws, _, err := websocket.DefaultDialer.Dial(wsURL, map[string][]string{ "User-Agent": {"android:com.ringapp"}, }) if err != nil { @@ -194,14 +194,14 @@ func Dial(rawURL string) (*Client, error) { api, err := webrtc.NewAPI() if err != nil { println("Failed to create WebRTC API") - conn.Close() + ws.Close() return nil, err } pc, err := api.NewPeerConnection(conf) if err != nil { println("Failed to create Peer Connection") - conn.Close() + ws.Close() return nil, err } @@ -223,11 +223,11 @@ func Dial(rawURL string) (*Client, error) { prod.URL = rawURL client := &Client{ - ws: conn, api: ringAPI, + ws: ws, + prod: prod, camera: camera, dialogID: uuid.NewString(), - conn: prod, done: make(chan struct{}), } @@ -351,7 +351,7 @@ func Dial(rawURL string) (*Client, error) { return default: var res BaseMessage - if err = conn.ReadJSON(&res); err != nil { + if err = ws.ReadJSON(&res); err != nil { select { case <-client.done: return @@ -509,22 +509,28 @@ func (c *Client) sendSessionMessage(method string, body map[string]interface{}) func (c *Client) GetMedias() []*core.Media { println("Getting medias") - return c.conn.GetMedias() + return c.prod.GetMedias() } func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { println("Getting track") - return c.conn.GetTrack(media, codec) + return c.prod.GetTrack(media, codec) } func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { - println("Adding track") - return c.conn.AddTrack(media, codec, track) + // Enable speaker + speakerPayload := map[string]interface{}{ + "stealth_mode": false, + } + + _ = c.sendSessionMessage("camera_options", speakerPayload); + + return c.prod.AddTrack(media, codec, track) } func (c *Client) Start() error { println("Starting client") - return c.conn.Start() + return c.prod.Start() } func (c *Client) Stop() error { @@ -536,9 +542,9 @@ func (c *Client) Stop() error { close(c.done) } - if c.conn != nil { - _ = c.conn.Stop() - c.conn = nil + if c.prod != nil { + _ = c.prod.Stop() + c.prod = nil } if c.ws != nil { @@ -558,5 +564,5 @@ func (c *Client) Stop() error { } func (c *Client) MarshalJSON() ([]byte, error) { - return c.conn.MarshalJSON() + return c.prod.MarshalJSON() } \ No newline at end of file From c9682ca64da755000a6c12b32db3df00cad5525c Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 24 Jan 2025 18:02:47 +0100 Subject: [PATCH 4/6] remove unnecessary prints and use mutex for ws --- pkg/ring/client.go | 83 ++++++++++------------------------------------ 1 file changed, 18 insertions(+), 65 deletions(-) diff --git a/pkg/ring/client.go b/pkg/ring/client.go index 8fa27bd5..b48727d9 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "sync" "time" "github.com/AlexxIT/go2rtc/pkg/core" @@ -12,7 +13,6 @@ import ( "github.com/google/uuid" "github.com/gorilla/websocket" pion "github.com/pion/webrtc/v3" - "github.com/rs/zerolog/log" ) type Client struct { @@ -22,6 +22,7 @@ type Client struct { camera *CameraData dialogID string sessionID string + wsMutex sync.Mutex done chan struct{} } @@ -120,10 +121,6 @@ func Dial(rawURL string) (*Client, error) { return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err) } - println("Connecting to Ring WebSocket") - println("Refresh Token: ", refreshToken) - println("Device ID: ", deviceID) - // Initialize Ring API client ringAPI, err := NewRingRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil) if err != nil { @@ -153,15 +150,10 @@ func Dial(rawURL string) (*Client, error) { return nil, err } - println("WebSocket Ticket: ", ticket.Ticket) - println("WebSocket ResponseTimestamp: ", ticket.ResponseTimestamp) - // 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)) - println("WebSocket URL: ", wsURL) - ws, _, err := websocket.DefaultDialer.Dial(wsURL, map[string][]string{ "User-Agent": {"android:com.ringapp"}, }) @@ -169,11 +161,7 @@ func Dial(rawURL string) (*Client, error) { return nil, err } - println("WebSocket handshake completed successfully") - // 3. Create Peer Connection - println("Creating Peer Connection") - conf := pion.Configuration{ ICEServers: []pion.ICEServer{ {URLs: []string{ @@ -193,20 +181,16 @@ func Dial(rawURL string) (*Client, error) { api, err := webrtc.NewAPI() if err != nil { - println("Failed to create WebRTC API") ws.Close() return nil, err } pc, err := api.NewPeerConnection(conf) if err != nil { - println("Failed to create Peer Connection") ws.Close() return nil, err } - println("Peer Connection created") - // protect from sending ICE candidate before Offer var sendOffer core.Waiter @@ -292,14 +276,10 @@ func Dial(rawURL string) (*Client, error) { // 4. Create offer offer, err := prod.CreateOffer(medias) if err != nil { - println("Failed to create offer") client.Stop() return nil, err } - println("Offer created") - println(offer) - // 5. Send offer offerPayload := map[string]interface{}{ "stream_options": map[string]bool{ @@ -310,7 +290,6 @@ func Dial(rawURL string) (*Client, error) { } if err = client.sendSessionMessage("live_view", offerPayload); err != nil { - println("Failed to send live_view message") client.Stop() return nil, err } @@ -329,7 +308,6 @@ func Dial(rawURL string) (*Client, error) { case <-ticker.C: if pc.ConnectionState() == pion.PeerConnectionStateConnected { if err := client.sendSessionMessage("ping", nil); err != nil { - println("Failed to send ping:", err) return } } @@ -358,43 +336,30 @@ func Dial(rawURL string) (*Client, error) { default: } - if websocket.IsCloseError(err, websocket.CloseNormalClosure) { - println("WebSocket closed normally") - } else { - println("Failed to read JSON message:", err) - client.Stop() - } + client.Stop() return } - body, _ := json.Marshal(res.Body) - bodyStr := string(body) - - println("Received message:", res.Method) - println("Message body:", bodyStr) - - // check if "doorbot_id" is present and matches the camera ID + // check if "doorbot_id" is present if _, ok := res.Body["doorbot_id"]; !ok { - println("Received message without doorbot_id") continue } + // check if the message is from the correct doorbot doorbotID := res.Body["doorbot_id"].(float64) if doorbotID != float64(client.camera.ID) { - println("Received message from unknown doorbot:", doorbotID) 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 && client.sessionID == "" { client.sessionID = res.Body["session_id"].(string) - println("Session established:", client.sessionID) } } if _, ok := res.Body["session_id"]; ok { if res.Body["session_id"].(string) != client.sessionID { - println("Received message with wrong session ID") continue } } @@ -406,17 +371,14 @@ func Dial(rawURL string) (*Client, error) { // 6. Get answer var msg AnswerMessage if err = json.Unmarshal(rawMsg, &msg); err != nil { - println("Failed to parse SDP message:", err) client.Stop() return } if err = prod.SetAnswer(msg.Body.SDP); err != nil { - println("Failed to set answer:", err) client.Stop() return } if err = client.activateSession(); err != nil { - println("Failed to activate session:", err) client.Stop() return } @@ -425,15 +387,12 @@ func Dial(rawURL string) (*Client, error) { // 7. Continue to receiving candidates var msg IceCandidateMessage if err = json.Unmarshal(rawMsg, &msg); err != nil { - println("Failed to parse ICE message:", err) - client.Stop() - return + break } // check for empty ICE candidate if msg.Body.Ice == "" { - println("Received empty ICE candidate") - continue + break } if err = prod.AddCandidate(msg.Body.Ice); err != nil { @@ -461,8 +420,6 @@ func Dial(rawURL string) (*Client, error) { } func (c *Client) activateSession() error { - println("Activating session") - if err := c.sendSessionMessage("activate_session", nil); err != nil { return err } @@ -476,12 +433,13 @@ func (c *Client) activateSession() error { return err } - println("Session activated") - 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{}) } @@ -497,10 +455,7 @@ func (c *Client) sendSessionMessage(method string, body map[string]interface{}) "body": body, } - println("Sending session message:", method) - if err := c.ws.WriteJSON(msg); err != nil { - log.Error().Err(err).Msg("Failed to send JSON message") return err } @@ -508,28 +463,27 @@ func (c *Client) sendSessionMessage(method string, body map[string]interface{}) } func (c *Client) GetMedias() []*core.Media { - println("Getting medias") return c.prod.GetMedias() } func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - println("Getting track") return c.prod.GetTrack(media, codec) } func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { - // Enable speaker - speakerPayload := map[string]interface{}{ - "stealth_mode": false, + if media.Kind == core.KindAudio { + // Enable speaker + speakerPayload := map[string]interface{}{ + "stealth_mode": false, + } + + _ = c.sendSessionMessage("camera_options", speakerPayload); } - _ = c.sendSessionMessage("camera_options", speakerPayload); - return c.prod.AddTrack(media, codec, track) } func (c *Client) Start() error { - println("Starting client") return c.prod.Start() } @@ -538,7 +492,6 @@ func (c *Client) Stop() error { case <-c.done: return nil default: - println("Stopping client") close(c.done) } From 2c5f1e0417b01d495a70b8d1f709afb9636a01bb Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 24 Jan 2025 19:37:17 +0100 Subject: [PATCH 5/6] add 2fa --- internal/ring/init.go | 87 +++++++++++++---- pkg/ring/api.go | 219 +++++++++++++++++++++++++++++++++--------- www/add.html | 36 ++++++- 3 files changed, 272 insertions(+), 70 deletions(-) diff --git a/internal/ring/init.go b/internal/ring/init.go index 24a91ac6..521c137a 100644 --- a/internal/ring/init.go +++ b/internal/ring/init.go @@ -1,7 +1,9 @@ package ring import ( + "encoding/json" "net/http" + "net/url" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" @@ -18,30 +20,75 @@ func Init() { } func apiRing(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - refreshToken := query.Get("refresh_token") + query := r.URL.Query() + var ringAPI *ring.RingRestClient + var err error - ringAPI, err := ring.NewRingRestClient(ring.RefreshTokenAuth{RefreshToken: refreshToken}, nil) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + // Check auth method + if email := query.Get("email"); email != "" { + // Email/Password Flow + password := query.Get("password") + code := query.Get("code") - devices, err := ringAPI.FetchRingDevices() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{ + Email: email, + Password: password, + }, nil) - var items []*api.Source + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - for _, camera := range devices.AllCameras { - query.Set("device_id", camera.DeviceID) + // 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 + } - items = append(items, &api.Source{ - Name: camera.Description, URL: "ring:?" + query.Encode(), - }) - } + ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{ + RefreshToken: refreshToken, + }, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } - api.ResponseSources(w, items) + // 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) + items = append(items, &api.Source{ + Name: camera.Description, + URL: "ring:?" + cleanQuery.Encode(), + }) + } + + api.ResponseSources(w, items) } diff --git a/pkg/ring/api.go b/pkg/ring/api.go index faebf6b9..e025e031 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "reflect" + "strings" "time" ) @@ -17,6 +18,11 @@ type RefreshTokenAuth struct { RefreshToken string } +type EmailAuth struct { + Email string + Password string +} + // AuthConfig represents the decoded refresh token data type AuthConfig struct { RT string `json:"rt"` // Refresh Token @@ -32,6 +38,14 @@ type AuthTokenResponse struct { 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"` +} + // SocketTicketRequest represents the request to get a socket ticket type SocketTicketResponse struct { Ticket string `json:"ticket"` @@ -40,17 +54,42 @@ type SocketTicketResponse struct { // RingRestClient handles authentication and requests to Ring API type RingRestClient struct { - httpClient *http.Client - authConfig *AuthConfig - hardwareID string - authToken *AuthTokenResponse - auth RefreshTokenAuth - onTokenRefresh func(string) // Callback when refresh token is updated + 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 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"` +} + +// 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"` + StickupCams []CameraData `json:"stickup_cams"` + AllCameras []CameraData `json:"all_cameras"` + Chimes []CameraData `json:"chimes"` + Other []map[string]interface{} `json:"other"` +} + const ( Doorbot CameraKind = "doorbot" Doorbell CameraKind = "doorbell" @@ -86,33 +125,11 @@ const ( OnvifCamera CameraKind = "onvif_camera" ) -// RingDeviceType represents different types of Ring devices -type RingDeviceType string - const ( IntercomHandsetAudio RingDeviceType = "intercom_handset_audio" OnvifCameraType RingDeviceType = "onvif_camera" ) -// 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"` -} - -// 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"` -} - const ( clientAPIBaseURL = "https://api.ring.com/clients_api/" deviceAPIBaseURL = "https://api.ring.com/devices/v1/" @@ -125,27 +142,37 @@ const ( ) // NewRingRestClient creates a new Ring client instance -func NewRingRestClient(auth RefreshTokenAuth, onTokenRefresh func(string)) (*RingRestClient, error) { - client := &RingRestClient{ - httpClient: &http.Client{ - Timeout: defaultTimeout, - }, - auth: auth, - onTokenRefresh: onTokenRefresh, - hardwareID: generateHardwareID(), - } +func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { + client := &RingRestClient{ + httpClient: &http.Client{Timeout: defaultTimeout}, + onTokenRefresh: onTokenRefresh, + hardwareID: generateHardwareID(), + auth: auth, + } - // check if refresh token is provided - if auth.RefreshToken == "" { - return nil, fmt.Errorf("refresh token is required") - } + 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 config, err := parseAuthConfig(auth.RefreshToken); err == nil { 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 + return client, nil } // Request makes an authenticated request to the Ring API @@ -289,6 +316,108 @@ func (c *RingRestClient) ensureAuth() error { return nil } +// 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 +} + // Helper functions for auth config encoding/decoding func parseAuthConfig(refreshToken string) (*AuthConfig, error) { decoded, err := base64.StdEncoding.DecodeString(refreshToken) diff --git a/www/add.html b/www/add.html index 1190f07e..7dae63d4 100644 --- a/www/add.html +++ b/www/add.html @@ -220,7 +220,16 @@
-
+ + + + + +
+
@@ -231,15 +240,32 @@ ev.target.nextElementSibling.style.display = 'block'; }); - document.getElementById('ring-form').addEventListener('submit', async ev => { + async function handleRingAuth(ev) { ev.preventDefault(); - 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'}); - await getSources('ring-table', r); - }); + 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'; + 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); + } + + document.getElementById('ring-credentials-form').addEventListener('submit', handleRingAuth); + document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth); From 0651a09a3c0f19250dcc2ff0845195450b6c8684 Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 24 Jan 2025 22:35:04 +0100 Subject: [PATCH 6/6] add snapshot producer --- internal/ring/init.go | 8 + pkg/ring/client.go | 587 ++++++++++++++++++++++-------------------- pkg/ring/snapshot.go | 64 +++++ 3 files changed, 376 insertions(+), 283 deletions(-) create mode 100644 pkg/ring/snapshot.go diff --git a/internal/ring/init.go b/internal/ring/init.go index 521c137a..bc49178b 100644 --- a/internal/ring/init.go +++ b/internal/ring/init.go @@ -84,10 +84,18 @@ func apiRing(w http.ResponseWriter, r *http.Request) { 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/client.go b/pkg/ring/client.go index b48727d9..c432ecf9 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -18,7 +18,7 @@ import ( type Client struct { api *RingRestClient ws *websocket.Conn - prod *webrtc.Conn + prod core.Producer camera *CameraData dialogID string sessionID string @@ -101,322 +101,337 @@ const ( ) func Dial(rawURL string) (*Client, error) { - // 1. Create Ring Rest API client - u, err := url.Parse(rawURL) - if err != nil { - return nil, err - } + // 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") + 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") - } + 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) - } + // 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 - } + // 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 - } + // 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") - } + 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") + } - // 2. Connect to signaling server - ticket, err := ringAPI.GetSocketTicket() - if err != nil { - return nil, err - } + // Create base client + client := &Client{ + api: ringAPI, + camera: camera, + dialogID: uuid.NewString(), + done: make(chan struct{}), + } - // 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)) + // Check if snapshot request + if isSnapshot { + client.prod = NewSnapshotProducer(ringAPI, camera) + return client, nil + } - ws, _, err := websocket.DefaultDialer.Dial(wsURL, map[string][]string{ - "User-Agent": {"android:com.ringapp"}, - }) - if err != nil { - return nil, err - } + // If not snapshot, continue with WebRTC setup + ticket, err := ringAPI.GetSocketTicket() + if err != nil { + return nil, err + } - // 3. 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, + // 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 { - ws.Close() - return nil, err - } + api, err := webrtc.NewAPI() + if err != nil { + client.ws.Close() + return nil, err + } - pc, err := api.NewPeerConnection(conf) - if err != nil { - 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 sending ICE candidate before Offer + var sendOffer core.Waiter - // protect from blocking on errors - defer sendOffer.Done(nil) + // 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 + // 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 + prod := webrtc.NewConn(pc) + prod.FormatName = "ring/webrtc" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = rawURL - client := &Client{ - api: ringAPI, - ws: ws, - prod: prod, - camera: camera, - dialogID: uuid.NewString(), - done: make(chan struct{}), - } + client.prod = prod - prod.Listen(func(msg any) { - switch msg := msg.(type) { - case *pion.ICECandidate: - _ = sendOffer.Wait() + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() - iceCandidate := msg.ToJSON() + iceCandidate := msg.ToJSON() - // skip empty ICE candidates - if iceCandidate.Candidate == "" { - return - } + // 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 - } + 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())) - } - } - }) + 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, - }, - }, - }, - } + // 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, + }, + }, + }, + } - // 4. Create offer - offer, err := prod.CreateOffer(medias) - if err != nil { - client.Stop() - return nil, err - } + // Create offer + offer, err := prod.CreateOffer(medias) + if err != nil { + client.Stop() + return nil, err + } - // 5. Send offer - offerPayload := map[string]interface{}{ - "stream_options": map[string]bool{ - "audio_enabled": true, - "video_enabled": true, - }, - "sdp": offer, - } + // 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 - } + if err = client.sendSessionMessage("live_view", offerPayload); err != nil { + client.Stop() + return nil, err + } - sendOffer.Done(nil) + sendOffer.Done(nil) - // Ring expects a ping message every 5 seconds - go func() { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() + // Ring expects a ping message every 5 seconds + go client.startPingLoop(pc) + go client.startMessageLoop(&connState) - for { - select { - case <-client.done: - return - case <-ticker.C: - if pc.ConnectionState() == pion.PeerConnectionStateConnected { - if err := client.sendSessionMessage("ping", nil); err != nil { - return - } - } - } - } - }() - - go func() { - var err error + if err = connState.Wait(); err != nil { + return nil, err + } - // will be closed when conn will be closed - defer func() { - connState.Done(err) - }() + return client, nil +} - for { - select { - case <-client.done: - return - default: - var res BaseMessage - if err = ws.ReadJSON(&res); err != nil { - select { - case <-client.done: - return - default: - } +func (c *Client) startPingLoop(pc *pion.PeerConnection) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() - client.Stop() - return - } + for { + select { + case <-c.done: + return + case <-ticker.C: + if pc.ConnectionState() == pion.PeerConnectionStateConnected { + if err := c.sendSessionMessage("ping", nil); err != nil { + 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(client.camera.ID) { - continue - } +func (c *Client) startMessageLoop(connState *core.Waiter) { + var err error - // 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 && client.sessionID == "" { - client.sessionID = res.Body["session_id"].(string) - } - } + // will be closed when conn will be closed + defer func() { + connState.Done(err) + }() - if _, ok := res.Body["session_id"]; ok { - if res.Body["session_id"].(string) != client.sessionID { - continue - } - } + for { + select { + case <-c.done: + return + default: + var res BaseMessage + if err = c.ws.ReadJSON(&res); err != nil { + select { + case <-c.done: + return + default: + } - rawMsg, _ := json.Marshal(res) + c.Stop() + return + } - switch res.Method { - case "sdp": - // 6. Get answer - var msg AnswerMessage - if err = json.Unmarshal(rawMsg, &msg); err != nil { - client.Stop() - return - } - if err = prod.SetAnswer(msg.Body.SDP); err != nil { - client.Stop() - return - } - if err = client.activateSession(); err != nil { - client.Stop() - return - } - - case "ice": - // 7. Continue to receiving candidates - var msg IceCandidateMessage - if err = json.Unmarshal(rawMsg, &msg); err != nil { - break - } + // 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 for empty ICE candidate - if msg.Body.Ice == "" { - break - } + // 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 err = prod.AddCandidate(msg.Body.Ice); err != nil { - client.Stop() - return - } + if _, ok := res.Body["session_id"]; ok { + if res.Body["session_id"].(string) != c.sessionID { + continue + } + } - case "close": - client.Stop() - return + rawMsg, _ := json.Marshal(res) - case "pong": - // Ignore - continue - } - } - } - }() + 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 + } - if err = connState.Wait(); err != nil { - return nil, err - } + // check for empty ICE candidate + if msg.Body.Ice == "" { + break + } - return client, nil + 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 { @@ -471,16 +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 media.Kind == core.KindAudio { - // Enable speaker - speakerPayload := map[string]interface{}{ - "stealth_mode": false, - } + 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) + } - _ = c.sendSessionMessage("camera_options", speakerPayload); - } - - return c.prod.AddTrack(media, codec, track) + return fmt.Errorf("add track not supported for snapshot") } func (c *Client) Start() error { @@ -517,5 +534,9 @@ func (c *Client) Stop() error { } func (c *Client) MarshalJSON() ([]byte, error) { - return c.prod.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 new file mode 100644 index 00000000..bbf86e28 --- /dev/null +++ b/pkg/ring/snapshot.go @@ -0,0 +1,64 @@ +package ring + +import ( + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type SnapshotProducer struct { + core.Connection + + 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, + } +} + +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) + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: response, + } + + // Send to all receivers + for _, receiver := range p.Receivers { + receiver.WriteRTP(pkt) + } + + return nil +} + +func (p *SnapshotProducer) Stop() error { + return p.Connection.Stop() +} \ No newline at end of file