diff --git a/internal/webrtc/milestone.go b/internal/webrtc/milestone.go new file mode 100644 index 00000000..f57b04ea --- /dev/null +++ b/internal/webrtc/milestone.go @@ -0,0 +1,265 @@ +package webrtc + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v3" +) + +// This package handles the Milestone WebRTC session lifecycle, including authentication, +// session creation, and session update with an SDP answer. It is designed to be used with +// a specific URL format that encodes session parameters. For example: +// webrtc:https://milestone-host/api#format=milestone#username=User#password=TestPassword#cameraId=a539f254-af05-4d67-a1bb-cd9b3c74d122 +// see: https://github.com/milestonesys/mipsdk-samples-protocol/tree/main/WebRTC_JavaScript + +// MilestoneClient manages the configurations of the server and client +type MilestoneClient struct { + ApiGatewayUrl string + Username string + Password string + ClientID string + Token string + GrantType string + PeerConnection *pion.PeerConnection +} + +// WebRTCSessionDetails structures the session details for the WebRTC connection. +type WebRTCSessionDetails struct { + CameraId string `json:"cameraId"` + StreamId *string `json:"streamId,omitempty"` + PlaybackTimeNode *PlaybackTimeDetails `json:"playbackTimeNode,omitempty"` + ICEServers []string `json:"iceServers"` + Resolution string `json:"resolution"` +} + +// PlaybackTimeDetails holds optional playback parameters +type PlaybackTimeDetails struct { + PlaybackTime string `json:"playbackTime"` + SkipGaps *bool `json:"skipGaps,omitempty"` + Speed *float64 `json:"speed,omitempty"` +} + +func setupMilestoneClient(rawURL string, query url.Values) *MilestoneClient { + return &MilestoneClient{ + ApiGatewayUrl: rawURL, + Username: query.Get("username"), + Password: query.Get("password"), + ClientID: "GrantValidatorClient", + GrantType: "password", + } +} + +func parseSessionDetails(query url.Values) WebRTCSessionDetails { + details := WebRTCSessionDetails{ + CameraId: query.Get("cameraId"), + Resolution: "notInUse", + ICEServers: []string{}, + } + + if streamId := query.Get("streamId"); streamId != "" { + details.StreamId = &streamId + } + + // Check for playback related details and construct PlaybackTimeNode if necessary + var playbackTimeNode PlaybackTimeDetails + hasPlaybackDetails := false + + if playbackTime := query.Get("playbackTime"); playbackTime != "" { + playbackTimeNode.PlaybackTime = playbackTime + hasPlaybackDetails = true + } + + if skipGaps := query.Get("skipGaps"); skipGaps != "" { + skipGapsBool, err := strconv.ParseBool(skipGaps) + if err == nil { + playbackTimeNode.SkipGaps = &skipGapsBool + hasPlaybackDetails = true + } + } + + if speed := query.Get("speed"); speed != "" { + speedFloat, err := strconv.ParseFloat(speed, 64) + if err == nil { + playbackTimeNode.Speed = &speedFloat + hasPlaybackDetails = true + } + } + + if hasPlaybackDetails { + details.PlaybackTimeNode = &playbackTimeNode + } + + return details +} + +func createWebRTCSession(mc *MilestoneClient, details WebRTCSessionDetails) (*http.Response, error) { + body, err := json.Marshal(details) + if err != nil { + return nil, err + } + + url := mc.ApiGatewayUrl + "/REST/v1/WebRTC/Session" + req, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+mc.Token) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }} + return client.Do(req) +} + +func updateWebRTCSession(mc *MilestoneClient, sessionID string, answer pion.SessionDescription) (*http.Response, error) { + sdpJSON, err := json.Marshal(answer) + if err != nil { + return nil, err + } + + payload := fmt.Sprintf(`{"answerSDP":%s}`, strconv.Quote(string(sdpJSON))) + url := fmt.Sprintf("%s/REST/v1/WebRTC/Session/%s", mc.ApiGatewayUrl, sessionID) + req, err := http.NewRequest("PATCH", url, bytes.NewBufferString(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+mc.Token) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }} + return client.Do(req) +} + +func (mc *MilestoneClient) Authenticate() error { + formData := url.Values{ + "grant_type": {mc.GrantType}, + "username": {mc.Username}, + "password": {mc.Password}, + "client_id": {mc.ClientID}, + } + + resp, err := http.PostForm(mc.ApiGatewayUrl+"/IDP/connect/token", formData) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("authentication failed: status code %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return err + } + + token, ok := result["access_token"].(string) + if !ok { + return errors.New("token not found in the response") + } + mc.Token = token + + return nil +} + +func milestoneClient(rawURL string, query url.Values, desc string) (core.Producer, error) { + mc := setupMilestoneClient(rawURL, query) + + if err := mc.Authenticate(); err != nil { + return nil, err + } + + details := parseSessionDetails(query) + + config := pion.Configuration{ + ICEServers: []pion.ICEServer{ + { + URLs: details.ICEServers, + }, + }, + } + + api, err := webrtc.NewAPI() + if err != nil { + return nil, err + } + + mc.PeerConnection, err = api.NewPeerConnection(config) + if err != nil { + return nil, err + } + + var sendOffer core.Waiter + defer sendOffer.Done(nil) + + prod := webrtc.NewConn(mc.PeerConnection) + prod.Desc = "WebRTC/OpenIPC" + prod.Mode = core.ModeActiveProducer + + resp, err := createWebRTCSession(mc, details) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var session struct { + SessionId string `json:"sessionId"` + OfferSDP string `json:"offerSDP"` + } + + if err := json.Unmarshal(responseBody, &session); err != nil { + return nil, fmt.Errorf("error parsing session response: %v", err) + } + + var offer pion.SessionDescription + if err := json.Unmarshal([]byte(session.OfferSDP), &offer); err != nil { + return nil, fmt.Errorf("failed to parse offer SDP: %v", err) + } + + if err = prod.SetOffer(string(offer.SDP)); err != nil { + return nil, err + } + + if err := mc.PeerConnection.SetRemoteDescription(offer); err != nil { + return nil, fmt.Errorf("failed to set remote description: %v", err) + } + + answer, err := mc.PeerConnection.CreateAnswer(nil) + if err != nil { + return nil, fmt.Errorf("failed to create answer: %v", err) + } + + if err := mc.PeerConnection.SetLocalDescription(answer); err != nil { + return nil, fmt.Errorf("failed to set local description: %v", err) + } + + resp, err = updateWebRTCSession(mc, session.SessionId, answer) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server responded with non-OK status: %d", resp.StatusCode) + } + + return prod, nil +}