...

Source file src/edge-infra.dev/pkg/sds/emergencyaccess/emulatorsvc/emulatorsvc.go

Documentation: edge-infra.dev/pkg/sds/emergencyaccess/emulatorsvc

     1  package emulatorsvc
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/url"
    11  	"os"
    12  	"time"
    13  
    14  	"github.com/go-logr/logr"
    15  	"github.com/shurcooL/graphql"
    16  	"golang.org/x/oauth2"
    17  
    18  	"edge-infra.dev/pkg/edge/client"
    19  	"edge-infra.dev/pkg/lib/fog"
    20  	"edge-infra.dev/pkg/lib/uuid"
    21  	"edge-infra.dev/pkg/sds/emergencyaccess/apierror"
    22  	apierrorhandler "edge-infra.dev/pkg/sds/emergencyaccess/apierror/handler"
    23  	"edge-infra.dev/pkg/sds/emergencyaccess/eaconst"
    24  	eamiddleware "edge-infra.dev/pkg/sds/emergencyaccess/middleware"
    25  	"edge-infra.dev/pkg/sds/emergencyaccess/msgdata"
    26  	"edge-infra.dev/pkg/sds/emergencyaccess/types"
    27  )
    28  
    29  type sessionIDType string
    30  
    31  const (
    32  	envGatewayHost                      = "RCLI_GATEWAY_HOST"
    33  	sessionID             sessionIDType = "X-SessionID"
    34  	edgeSessionCookieName               = "edge-session"
    35  )
    36  
    37  // contains the URLs for the gateway for the different queries
    38  type gatewayURLs struct {
    39  	start *url.URL // /startSession
    40  	send  *url.URL // /sendCommand
    41  	end   *url.URL //endSession
    42  }
    43  
    44  // contains all details set at connection
    45  type sessionDetails struct {
    46  	cancelFunc context.CancelFunc // cancel function to be called by End
    47  	context    context.Context    // context to be used by emulator routines
    48  	ID         string             // sessionID
    49  	target     types.Target       // target for the given sessionID
    50  }
    51  
    52  type EmulatorService struct {
    53  	config *Config
    54  	client *http.Client
    55  	// auth
    56  	idToken *oauth2.Token
    57  	userID  string
    58  	// connection specific structs
    59  	session     *sessionDetails
    60  	gatewayURLs *gatewayURLs
    61  
    62  	dispChan chan msgdata.CommandResponse
    63  
    64  	idleTime time.Time
    65  	darkmode bool
    66  }
    67  
    68  func New(ctx context.Context, config Config) (*EmulatorService, error) {
    69  	jar, err := newCookieJar()
    70  	if err != nil {
    71  		return nil, err
    72  	}
    73  
    74  	if config.Profile.SessionCookie != "" {
    75  		// If the profile includes a session cookie set that on the cookie jar
    76  		// This means we don't need to call RetreiveIdentity
    77  		cookies := parseCookie(config.Profile.SessionCookie)
    78  		url, err := url.Parse(config.Profile.API)
    79  		if err != nil {
    80  			return nil, err
    81  		}
    82  		jar.SetCookies(url, cookies)
    83  	}
    84  
    85  	client := http.Client{
    86  		Jar: jar,
    87  		Transport: &client.Transport{
    88  			T: http.DefaultTransport,
    89  			Headers: map[string]string{
    90  				eaconst.APIVersionKey: eaconst.APIVersion,
    91  			},
    92  		},
    93  	}
    94  
    95  	es := EmulatorService{config: &config, client: &client}
    96  	if err := es.setGatewayURLs(ctx); err != nil {
    97  		return nil, err
    98  	}
    99  	return &es, nil
   100  }
   101  
   102  func (es *EmulatorService) Env() []string {
   103  	return []string{
   104  		"RCLI_API_ENDPOINT" + "=" + es.config.Profile.API,
   105  		"RCLI_COOKIE" + "=" + es.config.Profile.SessionCookie,
   106  	}
   107  }
   108  
   109  // TODO: there's a much easier way of doing this in go 1.23.0, let's refactor then
   110  func parseCookie(cookieString string) []*http.Cookie {
   111  	resp := http.Response{
   112  		Header: http.Header{
   113  			"Set-Cookie": []string{cookieString},
   114  		},
   115  	}
   116  	return resp.Cookies()
   117  }
   118  
   119  // Connect establishes a connection to the EAGateway and sets up the go routine responsible for monitoring the request buffer returned from
   120  // startSession. Will cancel the session context on a bad connection.
   121  func (es *EmulatorService) Connect(ctx context.Context, projectID string, bannerID string, storeID string, terminalID string) error {
   122  	seshID := uuid.New().UUID
   123  	ctx = context.WithValue(ctx, sessionID, seshID)
   124  	log := fog.FromContext(ctx, "sessionID", seshID)
   125  	ctx = fog.IntoContext(ctx, log)
   126  	ctx, cancel := context.WithCancel(ctx)
   127  	es.session = &sessionDetails{
   128  		cancelFunc: cancel,
   129  		context:    ctx,
   130  		ID:         seshID,
   131  	}
   132  
   133  	target := types.Target{
   134  		Projectid:  projectID,
   135  		Bannerid:   bannerID,
   136  		Storeid:    storeID,
   137  		Terminalid: terminalID,
   138  	}
   139  
   140  	req, err := es.createStartSessionRequest(ctx, seshID, target)
   141  	if err != nil {
   142  		cancel()
   143  		return err
   144  	}
   145  
   146  	es.dispChan = make(chan msgdata.CommandResponse)
   147  	var resp *http.Response
   148  	// do the request and wait for the flush
   149  	resp, err = es.client.Do(req)
   150  	if err != nil {
   151  		cancel()
   152  		return err
   153  	}
   154  	correlationID := resp.Header.Get(eamiddleware.CorrelationIDKey)
   155  	log = log.WithValues("correlationID", correlationID)
   156  
   157  	if resp.StatusCode != 200 {
   158  		cancel()
   159  		err := apierrorhandler.ParseJSONAPIError(resp.Body)
   160  
   161  		if _, ok := err.(apierror.APIError); !ok {
   162  			err = fmt.Errorf("error calling startSession API (%s), status (%s)", req.URL.String(), resp.Status)
   163  		}
   164  
   165  		resp.Body.Close()
   166  		return err
   167  	}
   168  	log.Info("connection to eagateway established")
   169  
   170  	// Update stored target with the returned resolved UUID's
   171  	target.Projectid = resp.Header.Get("X-EA-ProjectID")
   172  	target.Bannerid = resp.Header.Get("X-EA-BannerID")
   173  	target.Storeid = resp.Header.Get("X-EA-StoreID")
   174  	target.Terminalid = resp.Header.Get("X-EA-TerminalID")
   175  
   176  	es.session.target = target
   177  
   178  	go es.postToDisplayChan(resp, log)
   179  	es.idleTime = time.Now()
   180  	return nil
   181  }
   182  
   183  // called at connect to post to the display channel returned by GetDisplayChannel.
   184  // closes display channel and response body on return
   185  func (es *EmulatorService) postToDisplayChan(resp *http.Response, log logr.Logger) {
   186  	defer func() {
   187  		// wait here so emulator can exit threads safely before closing the display channel when user exit is called.
   188  		time.Sleep(100 * time.Millisecond)
   189  		resp.Body.Close()
   190  		close(es.dispChan)
   191  	}()
   192  
   193  	dec := json.NewDecoder(resp.Body)
   194  	for {
   195  		select {
   196  		default:
   197  			var received types.ConnectionPayload
   198  			err := dec.Decode(&received)
   199  			if err == io.EOF {
   200  				log.Info("Response body closed. Exiting")
   201  				return
   202  			}
   203  			if err != nil {
   204  				log.Error(err, "error decoding connection payload")
   205  				return
   206  			}
   207  			es.dispChan <- received.Message
   208  		case <-resp.Request.Context().Done():
   209  			log.Info("Context Deadline exceeded. Exiting")
   210  			return
   211  		}
   212  	}
   213  }
   214  
   215  // Send generates a SendPayload and posts it to the /sendCommand end point, returns
   216  // the commandID of the generated command
   217  func (es *EmulatorService) Send(command string) (string, error) {
   218  	log := fog.FromContext(es.session.context)
   219  	es.idleTime = time.Now()
   220  
   221  	payload := types.SendPayload{
   222  		Target: es.session.target,
   223  		AuthDetails: types.AuthDetails{
   224  			DarkMode: es.darkmode,
   225  		},
   226  		Command:   command,
   227  		SessionID: es.session.ID,
   228  	}
   229  	message, err := json.Marshal(payload)
   230  	if err != nil {
   231  		return "", err
   232  	}
   233  	req, err := http.NewRequestWithContext(es.session.context, http.MethodPost, es.gatewayURLs.send.String(), bytes.NewReader(message))
   234  	if err != nil {
   235  		return "", err
   236  	}
   237  	es.setAuthHeader(req)
   238  
   239  	resp, err := es.client.Do(req)
   240  	if err != nil {
   241  		return "", err
   242  	}
   243  	correlationID := resp.Header.Get(eamiddleware.CorrelationIDKey)
   244  	log = log.WithValues("correlationID", correlationID)
   245  
   246  	if resp.StatusCode != 200 {
   247  		err = apierrorhandler.ParseJSONAPIError(resp.Body)
   248  		// logging error here despite returning the error as we may not be displaying the entire error message on the emulator
   249  		log.Error(err, "error from eagateway")
   250  		return correlationID, err
   251  	}
   252  	return correlationID, nil
   253  }
   254  
   255  func (es *EmulatorService) SetTopicTemplate(topicTemplate string) {
   256  	fmt.Println(topicTemplate)
   257  }
   258  
   259  func (es *EmulatorService) SetSubscriptionTemplate(subscriptionTemplate string) {
   260  	fmt.Println(subscriptionTemplate)
   261  }
   262  
   263  func (es *EmulatorService) RetrieveIdentity(ctx context.Context) error {
   264  	return es.retrieveBSLToken(ctx)
   265  }
   266  
   267  // type used for login mutation
   268  type banners struct {
   269  	BannerEdgeID string
   270  }
   271  
   272  func (es *EmulatorService) retrieveBSLToken(ctx context.Context) error {
   273  	log := fog.FromContext(ctx, "username", es.config.Profile.Username, "api", es.config.Profile.API, "organization", es.config.Profile.Organization)
   274  	ctx = fog.IntoContext(ctx, log)
   275  
   276  	gqlClient := graphql.NewClient(es.config.Profile.API, es.client)
   277  	var mutation struct {
   278  		Login struct {
   279  			Token   graphql.String
   280  			Banners []banners
   281  		} `graphql:"login(username: $username, password: $password, organization: $organization)"`
   282  	}
   283  
   284  	variables := map[string]interface{}{
   285  		"username":     graphql.String(es.config.Profile.Username),
   286  		"password":     graphql.String(es.config.Profile.Password),
   287  		"organization": graphql.String(es.config.Profile.Organization),
   288  	}
   289  
   290  	log.Info("querying Login")
   291  	err := gqlClient.Mutate(ctx, &mutation, variables)
   292  	if err != nil {
   293  		return fmt.Errorf("error calling Edge API: %w", err)
   294  	}
   295  	log.Info("Login Successful")
   296  
   297  	es.userID = es.config.Profile.Username
   298  	es.idToken = &oauth2.Token{
   299  		AccessToken: string(mutation.Login.Token),
   300  		TokenType:   "Bearer",
   301  	}
   302  
   303  	url, err := url.Parse(es.config.Profile.API)
   304  	if err != nil {
   305  		return err
   306  	}
   307  
   308  	// Save the session cookie back to the profile to give access to the cookie
   309  	cookies := es.client.Jar.Cookies(url)
   310  	for _, cookie := range cookies {
   311  		// TODO: Should we error if there is no session cookie?
   312  		if cookie.Name == "edge-session" {
   313  			es.config.Profile.SessionCookie = cookie.String()
   314  		}
   315  	}
   316  
   317  	return nil
   318  }
   319  
   320  func (es *EmulatorService) GetDisplayChannel() <-chan msgdata.CommandResponse {
   321  	return es.dispChan
   322  }
   323  
   324  func (es *EmulatorService) GetSessionContext() context.Context {
   325  	return es.session.context
   326  }
   327  
   328  func (es *EmulatorService) UserID() string {
   329  	return es.userID
   330  }
   331  
   332  func (es *EmulatorService) IdleTime() time.Duration {
   333  	return time.Since(es.idleTime)
   334  }
   335  
   336  // only terminates the session context
   337  func (es *EmulatorService) End() error {
   338  	// always end session context, even if the end session request fails
   339  	defer func() {
   340  		es.darkmode = false
   341  		es.session.cancelFunc()
   342  	}()
   343  	req, err := es.createEndSessionRequest(es.session.context, es.session.ID)
   344  	log := fog.FromContext(es.session.context)
   345  	if err != nil {
   346  		return err
   347  	}
   348  	resp, err := es.client.Do(req)
   349  	if err != nil {
   350  		return err
   351  	}
   352  	correlationID := resp.Header.Get(eamiddleware.CorrelationIDKey)
   353  	log = log.WithValues("correlationID", correlationID)
   354  	if resp.StatusCode != 200 {
   355  		err = apierrorhandler.ParseJSONAPIError(resp.Body)
   356  		log.Error(err, "error when ending session")
   357  		return err
   358  	}
   359  	log.Info("endSession request completed")
   360  	return nil
   361  }
   362  
   363  func (es EmulatorService) createEndSessionRequest(ctx context.Context, sessionID string) (*http.Request, error) {
   364  	payload := types.EndSessionPayload{
   365  		SessionID: sessionID,
   366  	}
   367  	msg, err := json.Marshal(payload)
   368  	if err != nil {
   369  		return nil, err
   370  	}
   371  	req, err := http.NewRequestWithContext(ctx, http.MethodPost, es.gatewayURLs.end.String(), bytes.NewReader(msg))
   372  	if err != nil {
   373  		return req, err
   374  	}
   375  	es.setAuthHeader(req)
   376  	return req, nil
   377  }
   378  
   379  func (es EmulatorService) createStartSessionRequest(ctx context.Context, sessionID string, target types.Target) (req *http.Request, err error) {
   380  	payload := types.StartSessionPayload{
   381  		SessionID: sessionID,
   382  		Target:    target,
   383  	}
   384  	message, err := json.Marshal(payload)
   385  	if err != nil {
   386  		return nil, err
   387  	}
   388  	req, err = http.NewRequestWithContext(ctx, http.MethodPost, es.gatewayURLs.start.String(), bytes.NewReader(message))
   389  	if err != nil {
   390  		return nil, err
   391  	}
   392  	es.setAuthHeader(req)
   393  	req.Header.Set("Cache-Control", "no-cache")
   394  	req.Header.Set("Accept", "text/event-stream")
   395  	req.Header.Set("Connection", "keep-alive")
   396  	return req, nil
   397  }
   398  
   399  func (es *EmulatorService) setAuthHeader(req *http.Request) {
   400  	// Only set Authorization header if token is present
   401  	if es.idToken != nil && es.idToken.AccessToken != "" {
   402  		es.idToken.SetAuthHeader(req)
   403  	}
   404  }
   405  
   406  func parseEnvVar(log logr.Logger, envName, defaultVal string) (val string) {
   407  	val = os.Getenv(envName)
   408  	if val == "" {
   409  		val = defaultVal
   410  		log.Info(envName+" not set, using default value", "default", val)
   411  	} else {
   412  		log.Info("using envar value", envName, val)
   413  	}
   414  	return val
   415  }
   416  
   417  func (es *EmulatorService) setGatewayURLs(ctx context.Context) error {
   418  	log := fog.FromContext(ctx)
   419  
   420  	// Use provided API endpoint as hostname
   421  	hostname := es.config.Profile.API
   422  	host, err := url.Parse(hostname)
   423  	if err != nil {
   424  		return fmt.Errorf("error parsing API hostname url in EmulatorService:setGatewayURLs: %v", err)
   425  	}
   426  
   427  	// Set correct path
   428  	host, err = host.Parse("/api/ea/")
   429  	if err != nil {
   430  		return fmt.Errorf("error parsing common path segment: %w", err)
   431  	}
   432  
   433  	// If env var is set overwrite host and path with env var, if empty this should be a noop
   434  	hostname = parseEnvVar(log, envGatewayHost, "")
   435  	host, err = host.Parse(hostname)
   436  	if err != nil {
   437  		return fmt.Errorf("error parsing RCLI_GATEWAY_HOST URL: %w", err)
   438  	}
   439  
   440  	start, err := host.Parse("startSession")
   441  	if err != nil {
   442  		return fmt.Errorf("error parsing startSession url in EmulatorService:setGatewayURLs: %v", err)
   443  	}
   444  	send, err := host.Parse("sendCommand")
   445  	if err != nil {
   446  		return fmt.Errorf("error parsing sendCommand url in EmulatorService:setGatewayURLs: %v", err)
   447  	}
   448  	end, err := host.Parse("endSession")
   449  	if err != nil {
   450  		return fmt.Errorf("error parsing endSession url in EmulatorService:setGatewayURLs: %v", err)
   451  	}
   452  	es.gatewayURLs = &gatewayURLs{start: start, send: send, end: end}
   453  
   454  	return nil
   455  }
   456  func (es *EmulatorService) SetDarkmode(val bool) {
   457  	es.darkmode = val
   458  }
   459  
   460  func (es *EmulatorService) Darkmode() bool {
   461  	return es.darkmode
   462  }
   463  
   464  func (es *EmulatorService) EnablePerSessionSubscription()  {}
   465  func (es *EmulatorService) DisablePerSessionSubscription() {}
   466  

View as plain text