package emulatorsvc import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "time" "github.com/go-logr/logr" "github.com/shurcooL/graphql" "golang.org/x/oauth2" "edge-infra.dev/pkg/edge/client" "edge-infra.dev/pkg/lib/fog" "edge-infra.dev/pkg/lib/uuid" "edge-infra.dev/pkg/sds/emergencyaccess/apierror" apierrorhandler "edge-infra.dev/pkg/sds/emergencyaccess/apierror/handler" "edge-infra.dev/pkg/sds/emergencyaccess/eaconst" eamiddleware "edge-infra.dev/pkg/sds/emergencyaccess/middleware" "edge-infra.dev/pkg/sds/emergencyaccess/msgdata" "edge-infra.dev/pkg/sds/emergencyaccess/types" ) type sessionIDType string const ( envGatewayHost = "RCLI_GATEWAY_HOST" sessionID sessionIDType = "X-SessionID" edgeSessionCookieName = "edge-session" ) // contains the URLs for the gateway for the different queries type gatewayURLs struct { start *url.URL // /startSession send *url.URL // /sendCommand end *url.URL //endSession } // contains all details set at connection type sessionDetails struct { cancelFunc context.CancelFunc // cancel function to be called by End context context.Context // context to be used by emulator routines ID string // sessionID target types.Target // target for the given sessionID } type EmulatorService struct { config *Config client *http.Client // auth idToken *oauth2.Token userID string // connection specific structs session *sessionDetails gatewayURLs *gatewayURLs dispChan chan msgdata.CommandResponse idleTime time.Time darkmode bool } func New(ctx context.Context, config Config) (*EmulatorService, error) { jar, err := newCookieJar() if err != nil { return nil, err } if config.Profile.SessionCookie != "" { // If the profile includes a session cookie set that on the cookie jar // This means we don't need to call RetreiveIdentity cookies := parseCookie(config.Profile.SessionCookie) url, err := url.Parse(config.Profile.API) if err != nil { return nil, err } jar.SetCookies(url, cookies) } client := http.Client{ Jar: jar, Transport: &client.Transport{ T: http.DefaultTransport, Headers: map[string]string{ eaconst.APIVersionKey: eaconst.APIVersion, }, }, } es := EmulatorService{config: &config, client: &client} if err := es.setGatewayURLs(ctx); err != nil { return nil, err } return &es, nil } func (es *EmulatorService) Env() []string { return []string{ "RCLI_API_ENDPOINT" + "=" + es.config.Profile.API, "RCLI_COOKIE" + "=" + es.config.Profile.SessionCookie, } } // TODO: there's a much easier way of doing this in go 1.23.0, let's refactor then func parseCookie(cookieString string) []*http.Cookie { resp := http.Response{ Header: http.Header{ "Set-Cookie": []string{cookieString}, }, } return resp.Cookies() } // Connect establishes a connection to the EAGateway and sets up the go routine responsible for monitoring the request buffer returned from // startSession. Will cancel the session context on a bad connection. func (es *EmulatorService) Connect(ctx context.Context, projectID string, bannerID string, storeID string, terminalID string) error { seshID := uuid.New().UUID ctx = context.WithValue(ctx, sessionID, seshID) log := fog.FromContext(ctx, "sessionID", seshID) ctx = fog.IntoContext(ctx, log) ctx, cancel := context.WithCancel(ctx) es.session = &sessionDetails{ cancelFunc: cancel, context: ctx, ID: seshID, } target := types.Target{ Projectid: projectID, Bannerid: bannerID, Storeid: storeID, Terminalid: terminalID, } req, err := es.createStartSessionRequest(ctx, seshID, target) if err != nil { cancel() return err } es.dispChan = make(chan msgdata.CommandResponse) var resp *http.Response // do the request and wait for the flush resp, err = es.client.Do(req) if err != nil { cancel() return err } correlationID := resp.Header.Get(eamiddleware.CorrelationIDKey) log = log.WithValues("correlationID", correlationID) if resp.StatusCode != 200 { cancel() err := apierrorhandler.ParseJSONAPIError(resp.Body) if _, ok := err.(apierror.APIError); !ok { err = fmt.Errorf("error calling startSession API (%s), status (%s)", req.URL.String(), resp.Status) } resp.Body.Close() return err } log.Info("connection to eagateway established") // Update stored target with the returned resolved UUID's target.Projectid = resp.Header.Get("X-EA-ProjectID") target.Bannerid = resp.Header.Get("X-EA-BannerID") target.Storeid = resp.Header.Get("X-EA-StoreID") target.Terminalid = resp.Header.Get("X-EA-TerminalID") es.session.target = target go es.postToDisplayChan(resp, log) es.idleTime = time.Now() return nil } // called at connect to post to the display channel returned by GetDisplayChannel. // closes display channel and response body on return func (es *EmulatorService) postToDisplayChan(resp *http.Response, log logr.Logger) { defer func() { // wait here so emulator can exit threads safely before closing the display channel when user exit is called. time.Sleep(100 * time.Millisecond) resp.Body.Close() close(es.dispChan) }() dec := json.NewDecoder(resp.Body) for { select { default: var received types.ConnectionPayload err := dec.Decode(&received) if err == io.EOF { log.Info("Response body closed. Exiting") return } if err != nil { log.Error(err, "error decoding connection payload") return } es.dispChan <- received.Message case <-resp.Request.Context().Done(): log.Info("Context Deadline exceeded. Exiting") return } } } // Send generates a SendPayload and posts it to the /sendCommand end point, returns // the commandID of the generated command func (es *EmulatorService) Send(command string) (string, error) { log := fog.FromContext(es.session.context) es.idleTime = time.Now() payload := types.SendPayload{ Target: es.session.target, AuthDetails: types.AuthDetails{ DarkMode: es.darkmode, }, Command: command, SessionID: es.session.ID, } message, err := json.Marshal(payload) if err != nil { return "", err } req, err := http.NewRequestWithContext(es.session.context, http.MethodPost, es.gatewayURLs.send.String(), bytes.NewReader(message)) if err != nil { return "", err } es.setAuthHeader(req) resp, err := es.client.Do(req) if err != nil { return "", err } correlationID := resp.Header.Get(eamiddleware.CorrelationIDKey) log = log.WithValues("correlationID", correlationID) if resp.StatusCode != 200 { err = apierrorhandler.ParseJSONAPIError(resp.Body) // logging error here despite returning the error as we may not be displaying the entire error message on the emulator log.Error(err, "error from eagateway") return correlationID, err } return correlationID, nil } func (es *EmulatorService) SetTopicTemplate(topicTemplate string) { fmt.Println(topicTemplate) } func (es *EmulatorService) SetSubscriptionTemplate(subscriptionTemplate string) { fmt.Println(subscriptionTemplate) } func (es *EmulatorService) RetrieveIdentity(ctx context.Context) error { return es.retrieveBSLToken(ctx) } // type used for login mutation type banners struct { BannerEdgeID string } func (es *EmulatorService) retrieveBSLToken(ctx context.Context) error { log := fog.FromContext(ctx, "username", es.config.Profile.Username, "api", es.config.Profile.API, "organization", es.config.Profile.Organization) ctx = fog.IntoContext(ctx, log) gqlClient := graphql.NewClient(es.config.Profile.API, es.client) var mutation struct { Login struct { Token graphql.String Banners []banners } `graphql:"login(username: $username, password: $password, organization: $organization)"` } variables := map[string]interface{}{ "username": graphql.String(es.config.Profile.Username), "password": graphql.String(es.config.Profile.Password), "organization": graphql.String(es.config.Profile.Organization), } log.Info("querying Login") err := gqlClient.Mutate(ctx, &mutation, variables) if err != nil { return fmt.Errorf("error calling Edge API: %w", err) } log.Info("Login Successful") es.userID = es.config.Profile.Username es.idToken = &oauth2.Token{ AccessToken: string(mutation.Login.Token), TokenType: "Bearer", } url, err := url.Parse(es.config.Profile.API) if err != nil { return err } // Save the session cookie back to the profile to give access to the cookie cookies := es.client.Jar.Cookies(url) for _, cookie := range cookies { // TODO: Should we error if there is no session cookie? if cookie.Name == "edge-session" { es.config.Profile.SessionCookie = cookie.String() } } return nil } func (es *EmulatorService) GetDisplayChannel() <-chan msgdata.CommandResponse { return es.dispChan } func (es *EmulatorService) GetSessionContext() context.Context { return es.session.context } func (es *EmulatorService) UserID() string { return es.userID } func (es *EmulatorService) IdleTime() time.Duration { return time.Since(es.idleTime) } // only terminates the session context func (es *EmulatorService) End() error { // always end session context, even if the end session request fails defer func() { es.darkmode = false es.session.cancelFunc() }() req, err := es.createEndSessionRequest(es.session.context, es.session.ID) log := fog.FromContext(es.session.context) if err != nil { return err } resp, err := es.client.Do(req) if err != nil { return err } correlationID := resp.Header.Get(eamiddleware.CorrelationIDKey) log = log.WithValues("correlationID", correlationID) if resp.StatusCode != 200 { err = apierrorhandler.ParseJSONAPIError(resp.Body) log.Error(err, "error when ending session") return err } log.Info("endSession request completed") return nil } func (es EmulatorService) createEndSessionRequest(ctx context.Context, sessionID string) (*http.Request, error) { payload := types.EndSessionPayload{ SessionID: sessionID, } msg, err := json.Marshal(payload) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, es.gatewayURLs.end.String(), bytes.NewReader(msg)) if err != nil { return req, err } es.setAuthHeader(req) return req, nil } func (es EmulatorService) createStartSessionRequest(ctx context.Context, sessionID string, target types.Target) (req *http.Request, err error) { payload := types.StartSessionPayload{ SessionID: sessionID, Target: target, } message, err := json.Marshal(payload) if err != nil { return nil, err } req, err = http.NewRequestWithContext(ctx, http.MethodPost, es.gatewayURLs.start.String(), bytes.NewReader(message)) if err != nil { return nil, err } es.setAuthHeader(req) req.Header.Set("Cache-Control", "no-cache") req.Header.Set("Accept", "text/event-stream") req.Header.Set("Connection", "keep-alive") return req, nil } func (es *EmulatorService) setAuthHeader(req *http.Request) { // Only set Authorization header if token is present if es.idToken != nil && es.idToken.AccessToken != "" { es.idToken.SetAuthHeader(req) } } func parseEnvVar(log logr.Logger, envName, defaultVal string) (val string) { val = os.Getenv(envName) if val == "" { val = defaultVal log.Info(envName+" not set, using default value", "default", val) } else { log.Info("using envar value", envName, val) } return val } func (es *EmulatorService) setGatewayURLs(ctx context.Context) error { log := fog.FromContext(ctx) // Use provided API endpoint as hostname hostname := es.config.Profile.API host, err := url.Parse(hostname) if err != nil { return fmt.Errorf("error parsing API hostname url in EmulatorService:setGatewayURLs: %v", err) } // Set correct path host, err = host.Parse("/api/ea/") if err != nil { return fmt.Errorf("error parsing common path segment: %w", err) } // If env var is set overwrite host and path with env var, if empty this should be a noop hostname = parseEnvVar(log, envGatewayHost, "") host, err = host.Parse(hostname) if err != nil { return fmt.Errorf("error parsing RCLI_GATEWAY_HOST URL: %w", err) } start, err := host.Parse("startSession") if err != nil { return fmt.Errorf("error parsing startSession url in EmulatorService:setGatewayURLs: %v", err) } send, err := host.Parse("sendCommand") if err != nil { return fmt.Errorf("error parsing sendCommand url in EmulatorService:setGatewayURLs: %v", err) } end, err := host.Parse("endSession") if err != nil { return fmt.Errorf("error parsing endSession url in EmulatorService:setGatewayURLs: %v", err) } es.gatewayURLs = &gatewayURLs{start: start, send: send, end: end} return nil } func (es *EmulatorService) SetDarkmode(val bool) { es.darkmode = val } func (es *EmulatorService) Darkmode() bool { return es.darkmode } func (es *EmulatorService) EnablePerSessionSubscription() {} func (es *EmulatorService) DisablePerSessionSubscription() {}