summaryrefslogtreecommitdiff
path: root/client
diff options
context:
space:
mode:
authorjwijenbergh <jeroenwijenbergh@protonmail.com>2022-10-18 18:29:10 +0200
committerjwijenbergh <jeroenwijenbergh@protonmail.com>2022-10-18 18:29:10 +0200
commit6aced56a28fa52e4796aa1aa139e4323b4154aca (patch)
tree56bf7af557317b553c6c30db2ec8d20090b6336d /client
parentcc057e07579f290eb1db8bdf348cb2e5ba760ab3 (diff)
Client: Move to its own package
Diffstat (limited to 'client')
-rw-r--r--client/client.go1144
-rw-r--r--client/client_test.go435
-rw-r--r--client/fsm.go248
3 files changed, 1827 insertions, 0 deletions
diff --git a/client/client.go b/client/client.go
new file mode 100644
index 0000000..4cf95cd
--- /dev/null
+++ b/client/client.go
@@ -0,0 +1,1144 @@
+package client
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/eduvpn/eduvpn-common/internal/config"
+ "github.com/eduvpn/eduvpn-common/internal/discovery"
+ "github.com/eduvpn/eduvpn-common/internal/fsm"
+ "github.com/eduvpn/eduvpn-common/internal/log"
+ "github.com/eduvpn/eduvpn-common/internal/oauth"
+ "github.com/eduvpn/eduvpn-common/internal/server"
+ "github.com/eduvpn/eduvpn-common/internal/util"
+ "github.com/eduvpn/eduvpn-common/types"
+)
+
+type (
+ // ServerBase is an alias to the internal ServerBase
+ // This contains the details for each server
+ ServerBase = server.ServerBase
+)
+
+func (client Client) isLetsConnect() bool {
+ // see https://git.sr.ht/~fkooman/vpn-user-portal/tree/v3/item/src/OAuth/ClientDb.php
+ return strings.HasPrefix(client.Name, "org.letsconnect-vpn.app")
+}
+
+// Client is the main struct for the VPN client
+type Client struct {
+ // The name of the client
+ Name string `json:"-"`
+
+ // The language used for language matching
+ Language string `json:"-"` // language should not be saved
+
+ // The chosen server
+ Servers server.Servers `json:"servers"`
+
+ // The list of servers and organizations from disco
+ Discovery discovery.Discovery `json:"discovery"`
+
+ // The fsm
+ FSM fsm.FSM `json:"-"`
+
+ // The logger
+ Logger log.FileLogger `json:"-"`
+
+ // The config
+ Config config.Config `json:"-"`
+
+ // Whether to enable debugging
+ Debug bool `json:"-"`
+}
+
+// Register initializes the clientwith the following parameters:
+// - name: the name of the client
+// - directory: the directory where the config files are stored. Absolute or relative
+// - stateCallback: the callback function for the FSM that takes two states (old and new) and the data as an interface
+// - debug: whether or not we want to enable debugging
+// It returns an error if initialization failed, for example when discovery cannot be obtained and when there are no servers.
+func (client *Client) Register(
+ name string,
+ directory string,
+ language string,
+ stateCallback func(FSMStateID, FSMStateID, interface{}),
+ debug bool,
+) error {
+ errorMessage := "failed to register with the GO library"
+ if !client.InFSMState(STATE_DEREGISTERED) {
+ return &types.WrappedErrorMessage{
+ Message: errorMessage,
+ Err: FSMDeregisteredError{}.CustomError(),
+ }
+ }
+ client.Name = name
+
+ // TODO: Verify language setting?
+ client.Language = language
+
+ // Initialize the logger
+ logLevel := log.LOG_WARNING
+ if debug {
+ logLevel = log.LOG_INFO
+ }
+
+ loggerErr := client.Logger.Init(logLevel, name, directory)
+ if loggerErr != nil {
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: loggerErr}
+ }
+
+ // Initialize the FSM
+ client.FSM = newFSM(stateCallback, directory, debug)
+ client.Debug = debug
+
+ // Initialize the Config
+ client.Config.Init(directory, "state")
+
+ // Try to load the previous configuration
+ if client.Config.Load(&client) != nil {
+ // This error can be safely ignored, as when the config does not load, the struct will not be filled
+ client.Logger.Info("Previous configuration not found")
+ }
+
+ // Go to the No Server state with the saved servers after we're done
+ defer client.FSM.GoTransitionWithData(STATE_NO_SERVER, client.Servers, true)
+
+ // Let's Connect! doesn't care about discovery
+ if client.isLetsConnect() {
+ return nil
+ }
+
+ // Check if we are able to fetch discovery, and log if something went wrong
+ _, discoServersErr := client.GetDiscoServers()
+ if discoServersErr != nil {
+ client.Logger.Warning(fmt.Sprintf("Failed to get discovery servers: %v", discoServersErr))
+ }
+ _, discoOrgsErr := client.GetDiscoOrganizations()
+ if discoOrgsErr != nil {
+ client.Logger.Warning(fmt.Sprintf("Failed to get discovery organizations: %v", discoOrgsErr))
+ }
+
+ return nil
+}
+
+// Deregister 'deregisters' the client, meaning saving the log file and the config and emptying out the client struct.
+func (client *Client) Deregister() {
+ // Close the log file
+ client.Logger.Close()
+
+ // Save the config
+ saveErr := client.Config.Save(&client)
+ if saveErr != nil {
+ client.Logger.Info(
+ fmt.Sprintf(
+ "Failed saving configuration, error: %s",
+ types.GetErrorTraceback(saveErr),
+ ),
+ )
+ }
+
+ // Empty out the state
+ *client = Client{}
+}
+
+// goBackInternal uses the public go back but logs an error if it happened.
+func (client *Client) goBackInternal() {
+ goBackErr := client.GoBack()
+ if goBackErr != nil {
+ client.Logger.Info(
+ fmt.Sprintf(
+ "Failed going back, error: %s",
+ types.GetErrorTraceback(goBackErr),
+ ),
+ )
+ }
+}
+
+// GoBack transitions the FSM back to the previous UI state, for now this is always the NO_SERVER state.
+func (client *Client) GoBack() error {
+ errorMessage := "failed to go back"
+ if client.InFSMState(STATE_DEREGISTERED) {
+ client.Logger.Error("Wrong state, cannot go back when deregistered")
+ return &types.WrappedErrorMessage{
+ Message: errorMessage,
+ Err: FSMDeregisteredError{}.CustomError(),
+ }
+ }
+
+ // FIXME: Abitrary back transitions don't work because we need the approriate data
+ client.FSM.GoTransitionWithData(STATE_NO_SERVER, client.Servers, false)
+ return nil
+}
+
+// ensureLogin logs the user back in if needed.
+// It runs the FSM transitions to ask for user input.
+func (client *Client) ensureLogin(chosenServer server.Server) error {
+ errorMessage := "failed ensuring login"
+ // Relogin with oauth
+ // This moves the state to authorized
+ if server.NeedsRelogin(chosenServer) {
+ url, urlErr := server.GetOAuthURL(chosenServer, client.Name)
+
+ client.FSM.GoTransitionWithData(STATE_OAUTH_STARTED, url, true)
+
+ if urlErr != nil {
+ client.goBackInternal()
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: urlErr}
+ }
+
+ exchangeErr := server.OAuthExchange(chosenServer)
+
+ if exchangeErr != nil {
+ client.goBackInternal()
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: exchangeErr}
+ }
+ }
+ // OAuth was valid, ensure we are in the authorized state
+ client.FSM.GoTransition(STATE_AUTHORIZED)
+ return nil
+}
+
+// getConfigAuth gets a config with authorization and authentication.
+// It also asks for a profile if no valid profile is found.
+func (client *Client) getConfigAuth(
+ chosenServer server.Server,
+ preferTCP bool,
+) (string, string, error) {
+ loginErr := client.ensureLogin(chosenServer)
+ if loginErr != nil {
+ return "", "", loginErr
+ }
+ client.FSM.GoTransition(STATE_REQUEST_CONFIG)
+
+ validProfile, profileErr := server.HasValidProfile(chosenServer)
+ if profileErr != nil {
+ return "", "", profileErr
+ }
+
+ // No valid profile, ask for one
+ if !validProfile {
+ askProfileErr := client.askProfile(chosenServer)
+ if askProfileErr != nil {
+ return "", "", askProfileErr
+ }
+ }
+
+ // We return the error otherwise we wrap it too much
+ return server.GetConfig(chosenServer, preferTCP)
+}
+
+// retryConfigAuth retries the getConfigAuth function if the tokens are invalid.
+// If OAuth is cancelled, it makes sure that we only forward the error as additional info.
+func (client *Client) retryConfigAuth(
+ chosenServer server.Server,
+ preferTCP bool,
+) (string, string, error) {
+ errorMessage := "failed authorized config retry"
+ config, configType, configErr := client.getConfigAuth(chosenServer, preferTCP)
+ if configErr != nil {
+ level := types.ERR_OTHER
+ var error *oauth.OAuthTokensInvalidError
+ var oauthCancelledError *oauth.OAuthCancelledCallbackError
+
+ // Only retry if the error is that the tokens are invalid
+ if errors.As(configErr, &error) {
+ config, configType, configErr = client.getConfigAuth(
+ chosenServer,
+ preferTCP,
+ )
+ if configErr == nil {
+ return config, configType, nil
+ }
+ }
+ if errors.As(configErr, &oauthCancelledError) {
+ level = types.ERR_INFO
+ }
+ client.goBackInternal()
+ return "", "", &types.WrappedErrorMessage{Level: level, Message: errorMessage, Err: configErr}
+ }
+ return config, configType, nil
+}
+
+// getConfig gets an OpenVPN/WireGuard configuration by contacting the server, moving the FSM towards the DISCONNECTED state and then saving the local configuration file.
+func (client *Client) getConfig(
+ chosenServer server.Server,
+ preferTCP bool,
+) (string, string, error) {
+ errorMessage := "failed to get a configuration for OpenVPN/Wireguard"
+ if client.InFSMState(STATE_DEREGISTERED) {
+ return "", "", &types.WrappedErrorMessage{
+ Message: errorMessage,
+ Err: FSMDeregisteredError{}.CustomError(),
+ }
+ }
+
+ config, configType, configErr := client.retryConfigAuth(chosenServer, preferTCP)
+
+ if configErr != nil {
+ return "", "", &types.WrappedErrorMessage{Level: types.GetErrorLevel(configErr), Message: errorMessage, Err: configErr}
+ }
+
+ currentServer, currentServerErr := client.Servers.GetCurrentServer()
+ if currentServerErr != nil {
+ return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr}
+ }
+
+ // Signal the server display info
+ client.FSM.GoTransitionWithData(STATE_DISCONNECTED, currentServer, false)
+
+ // Save the config
+ saveErr := client.Config.Save(&client)
+ if saveErr != nil {
+ client.Logger.Info(
+ fmt.Sprintf(
+ "Failed saving configuration after getting a server: %s",
+ types.GetErrorTraceback(saveErr),
+ ),
+ )
+ }
+
+ return config, configType, nil
+}
+
+// SetSecureLocation sets the location for the current secure location server. countryCode is the secure location to be chosen.
+// This function returns an error e.g. if the server cannot be found or the location is wrong.
+func (client *Client) SetSecureLocation(countryCode string) error {
+ errorMessage := "failed asking secure location"
+
+ // Not supported with Let's Connect!
+ if client.isLetsConnect() {
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: LetsConnectNotSupportedError{}}
+ }
+
+ server, serverErr := client.Discovery.GetServerByCountryCode(countryCode, "secure_internet")
+ if serverErr != nil {
+ client.Logger.Error(
+ fmt.Sprintf(
+ "Failed getting secure internet server by country code: %s with error: %s",
+ countryCode,
+ types.GetErrorTraceback(serverErr),
+ ),
+ )
+ client.goBackInternal()
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr}
+ }
+
+ setLocationErr := client.Servers.SetSecureLocation(server)
+ if setLocationErr != nil {
+ client.Logger.Error(
+ fmt.Sprintf(
+ "Failed setting secure internet server with error: %s",
+ types.GetErrorTraceback(setLocationErr),
+ ),
+ )
+ client.goBackInternal()
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: setLocationErr}
+ }
+ return nil
+}
+
+// askProfile asks the user for a profile by moving the FSM to the ASK_PROFILE state.
+func (client *Client) askProfile(chosenServer server.Server) error {
+ base, baseErr := chosenServer.GetBase()
+ if baseErr != nil {
+ return &types.WrappedErrorMessage{Message: "failed asking for profiles", Err: baseErr}
+ }
+ client.FSM.GoTransitionWithData(STATE_ASK_PROFILE, &base.Profiles, false)
+ return nil
+}
+
+// askSecureLocation asks the user to choose a Secure Internet location by moving the FSM to the STATE_ASK_LOCATION state.
+func (client *Client) askSecureLocation() error {
+ locations := client.Discovery.GetSecureLocationList()
+
+ // Ask for the location in the callback
+ client.FSM.GoTransitionWithData(STATE_ASK_LOCATION, locations, false)
+
+ // The state has changed, meaning setting the secure location was not successful
+ if client.FSM.Current != STATE_ASK_LOCATION {
+ // TODO: maybe a custom type for this errors.new?
+ return &types.WrappedErrorMessage{
+ Message: "failed setting secure location",
+ Err: errors.New("failed loading secure location"),
+ }
+ }
+ return nil
+}
+
+// RemoveSecureInternet removes the current secure internet server.
+// It returns an error if the server cannot be removed due to the state being DEREGISTERED.
+// Note that if the server does not exist, it returns nil as an error.
+func (client *Client) RemoveSecureInternet() error {
+ if client.InFSMState(STATE_DEREGISTERED) {
+ client.Logger.Error("Failed removing secure internet server due to deregistered")
+ return &types.WrappedErrorMessage{
+ Message: "failed to remove Secure Internet",
+ Err: FSMDeregisteredError{}.CustomError(),
+ }
+ }
+ // No error because we can only have one secure internet server and if there are no secure internet servers, this is a NO-OP
+ client.Servers.RemoveSecureInternet()
+ client.FSM.GoTransitionWithData(STATE_NO_SERVER, client.Servers, false)
+ // Save the config
+ saveErr := client.Config.Save(&client)
+ if saveErr != nil {
+ client.Logger.Info(
+ fmt.Sprintf(
+ "Failed saving configuration after removing a secure internet server: %s",
+ types.GetErrorTraceback(saveErr),
+ ),
+ )
+ }
+ return nil
+}
+
+// RemoveInstituteAccess removes the institute access server with `url`.
+// It returns an error if the server cannot be removed due to the state being DEREGISTERED.
+// Note that if the server does not exist, it returns nil as an error.
+func (client *Client) RemoveInstituteAccess(url string) error {
+ if client.InFSMState(STATE_DEREGISTERED) {
+ return &types.WrappedErrorMessage{
+ Message: "failed to remove Institute Access",
+ Err: FSMDeregisteredError{}.CustomError(),
+ }
+ }
+ // No error because this is a NO-OP if the server doesn't exist
+ client.Servers.RemoveInstituteAccess(url)
+ client.FSM.GoTransitionWithData(STATE_NO_SERVER, client.Servers, false)
+ // Save the config
+ saveErr := client.Config.Save(&client)
+ if saveErr != nil {
+ client.Logger.Info(
+ fmt.Sprintf(
+ "Failed saving configuration after removing an institute access server: %s",
+ types.GetErrorTraceback(saveErr),
+ ),
+ )
+ }
+ return nil
+}
+
+// RemoveCustomServer removes the custom server with `url`.
+// It returns an error if the server cannot be removed due to the state being DEREGISTERED.
+// Note that if the server does not exist, it returns nil as an error.
+func (client *Client) RemoveCustomServer(url string) error {
+ if client.InFSMState(STATE_DEREGISTERED) {
+ return &types.WrappedErrorMessage{
+ Message: "failed to remove Custom Server",
+ Err: FSMDeregisteredError{}.CustomError(),
+ }
+ }
+ // No error because this is a NO-OP if the server doesn't exist
+ client.Servers.RemoveCustomServer(url)
+ client.FSM.GoTransitionWithData(STATE_NO_SERVER, client.Servers, false)
+ // Save the config
+ saveErr := client.Config.Save(&client)
+ if saveErr != nil {
+ client.Logger.Info(
+ fmt.Sprintf(
+ "Failed saving configuration after removing a custom server: %s",
+ types.GetErrorTraceback(saveErr),
+ ),
+ )
+ }
+ return nil
+}
+
+// AddInstituteServer adds an Institute Access server by `url`.
+func (client *Client) AddInstituteServer(url string) (server.Server, error) {
+ errorMessage := fmt.Sprintf("failed adding Institute Access server with url %s", url)
+
+ // Not supported with Let's Connect!
+ if client.isLetsConnect() {
+ return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: LetsConnectNotSupportedError{}}
+ }
+
+ // Indicate that we're loading the server
+ client.FSM.GoTransition(STATE_LOADING_SERVER)
+
+ // FIXME: Do nothing with discovery here as the client already has it
+ // So pass a server as the parameter
+ instituteServer, discoErr := client.Discovery.GetServerByURL(url, "institute_access")
+ if discoErr != nil {
+ client.goBackInternal()
+ return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: discoErr}
+ }
+
+ // Add the secure internet server
+ server, serverErr := client.Servers.AddInstituteAccessServer(instituteServer)
+ if serverErr != nil {
+ client.goBackInternal()
+ return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr}
+ }
+
+ // Indicate that we want to authorize this server
+ client.FSM.GoTransition(STATE_CHOSEN_SERVER)
+
+ // Authorize it
+ loginErr := client.ensureLogin(server)
+ if loginErr != nil {
+ // Removing is best effort
+ _ = client.RemoveInstituteAccess(url)
+ return nil, &types.WrappedErrorMessage{Level: types.GetErrorLevel(loginErr), Message: errorMessage, Err: loginErr}
+ }
+
+ client.FSM.GoTransitionWithData(STATE_NO_SERVER, client.Servers, false)
+ return server, nil
+}
+
+// AddSecureInternetHomeServer adds a Secure Internet Home Server with `orgID` that was obtained from the Discovery file.
+// Because there is only one Secure Internet Home Server, it replaces the existing one.
+func (client *Client) AddSecureInternetHomeServer(orgID string) (server.Server, error) {
+ errorMessage := fmt.Sprintf(
+ "failed adding Secure Internet home server with organization ID %s",
+ orgID,
+ )
+
+ // Not supported with Let's Connect!
+ if client.isLetsConnect() {
+ return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: LetsConnectNotSupportedError{}}
+ }
+
+ // Indicate that we're loading the server
+ client.FSM.GoTransition(STATE_LOADING_SERVER)
+
+ // Get the secure internet URL from discovery
+ secureOrg, secureServer, discoErr := client.Discovery.GetSecureHomeArgs(orgID)
+ if discoErr != nil {
+ client.goBackInternal()
+ return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: discoErr}
+ }
+
+ // Add the secure internet server
+ server, serverErr := client.Servers.AddSecureInternet(secureOrg, secureServer)
+ if serverErr != nil {
+ client.goBackInternal()
+ return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr}
+ }
+
+ locationErr := client.askSecureLocation()
+ if locationErr != nil {
+ // Removing is best effort
+ _ = client.RemoveSecureInternet()
+ return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: locationErr}
+ }
+
+ // Server has been chosen for authentication
+ client.FSM.GoTransition(STATE_CHOSEN_SERVER)
+
+ // Authorize it
+ loginErr := client.ensureLogin(server)
+ if loginErr != nil {
+ // Removing is best effort
+ _ = client.RemoveSecureInternet()
+ return nil, &types.WrappedErrorMessage{Level: types.GetErrorLevel(loginErr), Message: errorMessage, Err: loginErr}
+ }
+ client.FSM.GoTransitionWithData(STATE_NO_SERVER, client.Servers, false)
+ return server, nil
+}
+
+// AddCustomServer adds a Custom Server by `url`
+func (client *Client) AddCustomServer(url string) (server.Server, error) {
+ errorMessage := fmt.Sprintf("failed adding Custom server with url %s", url)
+
+ url, urlErr := util.EnsureValidURL(url)
+ if urlErr != nil {
+ return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: urlErr}
+ }
+
+ // Indicate that we're loading the server
+ client.FSM.GoTransition(STATE_LOADING_SERVER)
+
+ customServer := &types.DiscoveryServer{
+ BaseURL: url,
+ DisplayName: map[string]string{"en": url},
+ Type: "custom_server",
+ }
+
+ // A custom server is just an institute access server under the hood
+ server, serverErr := client.Servers.AddCustomServer(customServer)
+ if serverErr != nil {
+ client.goBackInternal()
+ return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr}
+ }
+
+ // Server has been chosen for authentication
+ client.FSM.GoTransition(STATE_CHOSEN_SERVER)
+
+ // Authorize it
+ loginErr := client.ensureLogin(server)
+ if loginErr != nil {
+ // removing is best effort
+ _ = client.RemoveCustomServer(url)
+ return nil, &types.WrappedErrorMessage{Level: types.GetErrorLevel(loginErr), Message: errorMessage, Err: loginErr}
+ }
+
+ client.FSM.GoTransitionWithData(STATE_NO_SERVER, client.Servers, false)
+ return server, nil
+}
+
+// GetConfigInstituteAccess gets a configuration for an Institute Access Server.
+// It ensures that the Institute Access Server exists by creating or using an existing one with the url.
+// `preferTCP` indicates that the client wants to use TCP (through OpenVPN) to establish the VPN tunnel.
+func (client *Client) GetConfigInstituteAccess(url string, preferTCP bool) (string, string, error) {
+ errorMessage := fmt.Sprintf("failed getting a configuration for Institute Access %s", url)
+
+ // Not supported with Let's Connect!
+ if client.isLetsConnect() {
+ return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: LetsConnectNotSupportedError{}}
+ }
+
+ client.FSM.GoTransition(STATE_LOADING_SERVER)
+
+ // Get the server if it exists
+ server, serverErr := client.Servers.GetInstituteAccess(url)
+ if serverErr != nil {
+ client.Logger.Error(
+ fmt.Sprintf(
+ "Failed getting an institute access server configuration with error: %s",
+ types.GetErrorTraceback(serverErr),
+ ),
+ )
+ client.goBackInternal()
+ return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr}
+ }
+
+ // Set the server as the current
+ currentErr := client.Servers.SetInstituteAccess(server)
+ if currentErr != nil {
+ return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: currentErr}
+ }
+
+ // The server has now been chosen
+ client.FSM.GoTransition(STATE_CHOSEN_SERVER)
+
+ config, configType, configErr := client.getConfig(server, preferTCP)
+ if configErr != nil {
+ client.Logger.Inherit(configErr,
+ fmt.Sprintf(
+ "Failed getting an institute access server configuration with error: %s",
+ types.GetErrorTraceback(configErr),
+ ),
+ )
+ client.goBackInternal()
+ return "", "", &types.WrappedErrorMessage{Level: types.GetErrorLevel(configErr), Message: errorMessage, Err: configErr}
+ }
+ return config, configType, nil
+}
+
+// GetConfigSecureInternet gets a configuration for a Secure Internet Server.
+// It ensures that the Secure Internet Server exists by creating or using an existing one with the orgID.
+// `preferTCP` indicates that the client wants to use TCP (through OpenVPN) to establish the VPN tunnel.
+func (client *Client) GetConfigSecureInternet(
+ orgID string,
+ preferTCP bool,
+) (string, string, error) {
+ errorMessage := fmt.Sprintf(
+ "failed getting a configuration for Secure Internet organization %s",
+ orgID,
+ )
+
+ // Not supported with Let's Connect!
+ if client.isLetsConnect() {
+ return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: LetsConnectNotSupportedError{}}
+ }
+
+ client.FSM.GoTransition(STATE_LOADING_SERVER)
+
+ // Get the server if it exists
+ server, serverErr := client.Servers.GetSecureInternetHomeServer()
+ if serverErr != nil {
+ client.Logger.Error(
+ fmt.Sprintf(
+ "Failed getting a custom server configuration with error: %s",
+ types.GetErrorTraceback(serverErr),
+ ),
+ )
+ client.goBackInternal()
+ return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr}
+ }
+
+ // Set the server as the current
+ currentErr := client.Servers.SetSecureInternet(server)
+ if currentErr != nil {
+ return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: currentErr}
+ }
+
+ client.FSM.GoTransition(STATE_CHOSEN_SERVER)
+
+ config, configType, configErr := client.getConfig(server, preferTCP)
+ if configErr != nil {
+ client.Logger.Inherit(
+ configErr,
+ fmt.Sprintf(
+ "Failed getting a secure internet configuration with error: %s",
+ types.GetErrorTraceback(configErr),
+ ),
+ )
+ client.goBackInternal()
+ return "", "", &types.WrappedErrorMessage{Level: types.GetErrorLevel(configErr), Message: errorMessage, Err: configErr}
+ }
+ return config, configType, nil
+}
+
+// GetConfigCustomServer gets a configuration for a Custom Server.
+// It ensures that the Custom Server exists by creating or using an existing one with the url.
+// `preferTCP` indicates that the client wants to use TCP (through OpenVPN) to establish the VPN tunnel.
+func (client *Client) GetConfigCustomServer(url string, preferTCP bool) (string, string, error) {
+ errorMessage := fmt.Sprintf("failed getting a configuration for custom server %s", url)
+
+ url, urlErr := util.EnsureValidURL(url)
+ if urlErr != nil {
+ return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: urlErr}
+ }
+
+ client.FSM.GoTransition(STATE_LOADING_SERVER)
+
+ // Get the server if it exists
+ server, serverErr := client.Servers.GetCustomServer(url)
+ if serverErr != nil {
+ client.Logger.Error(
+ fmt.Sprintf(
+ "Failed getting a custom server configuration with error: %s",
+ types.GetErrorTraceback(serverErr),
+ ),
+ )
+ client.goBackInternal()
+ return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr}
+ }
+
+ // Set the server as the current
+ currentErr := client.Servers.SetCustomServer(server)
+ if currentErr != nil {
+ return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: currentErr}
+ }
+
+ client.FSM.GoTransition(STATE_CHOSEN_SERVER)
+
+ config, configType, configErr := client.getConfig(server, preferTCP)
+ if configErr != nil {
+ client.Logger.Inherit(
+ configErr,
+ fmt.Sprintf(
+ "Failed getting a custom server with error: %s",
+ types.GetErrorTraceback(configErr),
+ ),
+ )
+ client.goBackInternal()
+ return "", "", &types.WrappedErrorMessage{Level: types.GetErrorLevel(configErr), Message: errorMessage, Err: configErr}
+ }
+ return config, configType, nil
+}
+
+// CancelOAuth cancels OAuth if one is in progress.
+// If OAuth is not in progress, it returns an error.
+// An error is also returned if OAuth is in progress but it fails to cancel it.
+func (client *Client) CancelOAuth() error {
+ errorMessage := "failed to cancel OAuth"
+ if !client.InFSMState(STATE_OAUTH_STARTED) {
+ client.Logger.Error("Failed cancelling OAuth, not in the right state")
+ return &types.WrappedErrorMessage{
+ Message: errorMessage,
+ Err: FSMWrongStateError{
+ Got: client.FSM.Current,
+ Want: STATE_OAUTH_STARTED,
+ }.CustomError(),
+ }
+ }
+
+ currentServer, serverErr := client.Servers.GetCurrentServer()
+ if serverErr != nil {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed cancelling OAuth, no server configured to cancel OAuth for (err: %v)",
+ serverErr,
+ ),
+ )
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr}
+ }
+ server.CancelOAuth(currentServer)
+ return nil
+}
+
+// ChangeSecureLocation changes the location for an existing Secure Internet Server.
+// Changing a secure internet location is only possible when the user is in the main screen (STATE_NO_SERVER), otherwise it returns an error.
+// It also returns an error if something has gone wrong when selecting the new location
+func (client *Client) ChangeSecureLocation() error {
+ errorMessage := "failed to change location from the main screen"
+
+ if !client.InFSMState(STATE_NO_SERVER) {
+ client.Logger.Error("Failed changing secure internet location, not in the right state")
+ return &types.WrappedErrorMessage{
+ Message: errorMessage,
+ Err: FSMWrongStateError{
+ Got: client.FSM.Current,
+ Want: STATE_NO_SERVER,
+ }.CustomError(),
+ }
+ }
+
+ askLocationErr := client.askSecureLocation()
+ if askLocationErr != nil {
+ client.Logger.Error(
+ fmt.Sprintf(
+ "Failed changing secure internet location, err: %s",
+ types.GetErrorTraceback(askLocationErr),
+ ),
+ )
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: askLocationErr}
+ }
+
+ // Go back to the main screen
+ client.FSM.GoTransitionWithData(STATE_NO_SERVER, client.Servers, false)
+
+ return nil
+}
+
+// GetDiscoOrganizations gets the organizations list from the discovery server
+// If the list cannot be retrieved an error is returned.
+// If this is the case then a previous version of the list is returned if there is any.
+// This takes into account the frequency of updates, see: https://github.com/eduvpn/documentation/blob/v3/SERVER_DISCOVERY.md#organization-list.
+func (client *Client) GetDiscoOrganizations() (*types.DiscoveryOrganizations, error) {
+ errorMessage := "failed getting discovery organizations list"
+ // Not supported with Let's Connect!
+ if client.isLetsConnect() {
+ return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: LetsConnectNotSupportedError{}}
+ }
+
+ orgs, orgsErr := client.Discovery.GetOrganizationsList()
+ if orgsErr != nil {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed getting discovery organizations, Err: %s",
+ types.GetErrorTraceback(orgsErr),
+ ),
+ )
+ return nil, &types.WrappedErrorMessage{
+ Message: errorMessage,
+ Err: orgsErr,
+ }
+ }
+ return orgs, nil
+}
+
+// GetDiscoServers gets the servers list from the discovery server
+// If the list cannot be retrieved an error is returned.
+// If this is the case then a previous version of the list is returned if there is any.
+// This takes into account the frequency of updates, see: https://github.com/eduvpn/documentation/blob/v3/SERVER_DISCOVERY.md#server-list.
+func (client *Client) GetDiscoServers() (*types.DiscoveryServers, error) {
+ errorMessage := "failed getting discovery servers list"
+
+ // Not supported with Let's Connect!
+ if client.isLetsConnect() {
+ return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: LetsConnectNotSupportedError{}}
+ }
+
+ servers, serversErr := client.Discovery.GetServersList()
+ if serversErr != nil {
+ client.Logger.Warning(
+ fmt.Sprintf("Failed getting discovery servers, Err: %s", types.GetErrorTraceback(serversErr)),
+ )
+ return nil, &types.WrappedErrorMessage{
+ Message: errorMessage,
+ Err: serversErr,
+ }
+ }
+ return servers, nil
+}
+
+// SetProfileID sets a `profileID` for the current server.
+// An error is returned if this is not possible, for example when no server is configured.
+func (client *Client) SetProfileID(profileID string) error {
+ errorMessage := "failed to set the profile ID for the current server"
+ server, serverErr := client.Servers.GetCurrentServer()
+ if serverErr != nil {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed setting a profile ID because no server configured, Err: %s",
+ types.GetErrorTraceback(serverErr),
+ ),
+ )
+ client.goBackInternal()
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr}
+ }
+
+ base, baseErr := server.GetBase()
+ if baseErr != nil {
+ client.Logger.Error(
+ fmt.Sprintf("Failed setting a profile ID, Err: %s", types.GetErrorTraceback(serverErr)),
+ )
+ client.goBackInternal()
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: baseErr}
+ }
+ base.Profiles.Current = profileID
+ return nil
+}
+
+// SetSearchServer sets the FSM to the SEARCH_SERVER state.
+// This indicates that the user wants to search for a new server.
+// Returns an error if this state transition is not possible.
+func (client *Client) SetSearchServer() error {
+ if !client.FSM.HasTransition(STATE_SEARCH_SERVER) {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed setting search server, wrong state %s",
+ GetStateName(client.FSM.Current),
+ ),
+ )
+ return &types.WrappedErrorMessage{
+ Message: "failed to set search server",
+ Err: FSMWrongStateTransitionError{
+ Got: client.FSM.Current,
+ Want: STATE_SEARCH_SERVER,
+ }.CustomError(),
+ }
+ }
+
+ client.FSM.GoTransition(STATE_SEARCH_SERVER)
+ return nil
+}
+
+// SetConnected sets the FSM to the CONNECTED state.
+// This indicates that the VPN is connected to the server.
+// Returns an error if this state transition is not possible.
+func (client *Client) SetConnected() error {
+ errorMessage := "failed to set connected"
+ if client.InFSMState(STATE_CONNECTED) {
+ // already connected, show no error
+ client.Logger.Warning("Already connected")
+ return nil
+ }
+ if !client.FSM.HasTransition(STATE_CONNECTED) {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed setting connected, wrong state: %s",
+ GetStateName(client.FSM.Current),
+ ),
+ )
+ return &types.WrappedErrorMessage{
+ Message: errorMessage,
+ Err: FSMWrongStateTransitionError{
+ Got: client.FSM.Current,
+ Want: STATE_CONNECTED,
+ }.CustomError(),
+ }
+ }
+
+ currentServer, currentServerErr := client.Servers.GetCurrentServer()
+ if currentServerErr != nil {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed setting connected, cannot get current server with error: %s",
+ types.GetErrorTraceback(currentServerErr),
+ ),
+ )
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr}
+ }
+
+ client.FSM.GoTransitionWithData(STATE_CONNECTED, currentServer, false)
+ return nil
+}
+
+// SetConnecting sets the FSM to the CONNECTING state.
+// This indicates that the VPN is currently connecting to the server.
+// Returns an error if this state transition is not possible.
+func (client *Client) SetConnecting() error {
+ errorMessage := "failed to set connecting"
+ if client.InFSMState(STATE_CONNECTING) {
+ // already loading connection, show no error
+ client.Logger.Warning("Already connecting")
+ return nil
+ }
+ if !client.FSM.HasTransition(STATE_CONNECTING) {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed setting connecting, wrong state: %s",
+ GetStateName(client.FSM.Current),
+ ),
+ )
+ return &types.WrappedErrorMessage{
+ Message: errorMessage,
+ Err: FSMWrongStateTransitionError{
+ Got: client.FSM.Current,
+ Want: STATE_CONNECTING,
+ }.CustomError(),
+ }
+ }
+
+ currentServer, currentServerErr := client.Servers.GetCurrentServer()
+ if currentServerErr != nil {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed setting connecting, cannot get current server with error: %s",
+ types.GetErrorTraceback(currentServerErr),
+ ),
+ )
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr}
+ }
+
+ client.FSM.GoTransitionWithData(STATE_CONNECTING, currentServer, false)
+ return nil
+}
+
+// SetDisconnecting sets the FSM to the DISCONNECTING state.
+// This indicates that the VPN is currently disconnecting from the server.
+// Returns an error if this state transition is not possible.
+func (client *Client) SetDisconnecting() error {
+ errorMessage := "failed to set disconnecting"
+ if client.InFSMState(STATE_DISCONNECTING) {
+ // already disconnecting, show no error
+ client.Logger.Warning("Already disconnecting")
+ return nil
+ }
+ if !client.FSM.HasTransition(STATE_DISCONNECTING) {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed setting disconnecting, wrong state: %s",
+ GetStateName(client.FSM.Current),
+ ),
+ )
+ return &types.WrappedErrorMessage{
+ Message: errorMessage,
+ Err: FSMWrongStateTransitionError{
+ Got: client.FSM.Current,
+ Want: STATE_DISCONNECTING,
+ }.CustomError(),
+ }
+ }
+
+ currentServer, currentServerErr := client.Servers.GetCurrentServer()
+ if currentServerErr != nil {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed setting disconnected, cannot get current server with error: %s",
+ types.GetErrorTraceback(currentServerErr),
+ ),
+ )
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr}
+ }
+
+ client.FSM.GoTransitionWithData(STATE_DISCONNECTING, currentServer, false)
+ return nil
+}
+
+// SetDisconnected sets the FSM to the DISCONNECTED state.
+// This indicates that the VPN is currently disconnected from the server.
+// This also sends the /disconnect API call to the server.
+// Returns an error if this state transition is not possible.
+func (client *Client) SetDisconnected(cleanup bool) error {
+ errorMessage := "failed to set disconnected"
+ if client.InFSMState(STATE_DISCONNECTED) {
+ // already disconnected, show no error
+ client.Logger.Warning("Already disconnected")
+ return nil
+ }
+ if !client.FSM.HasTransition(STATE_DISCONNECTED) {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed setting disconnected, wrong state: %s",
+ GetStateName(client.FSM.Current),
+ ),
+ )
+ return &types.WrappedErrorMessage{
+ Message: errorMessage,
+ Err: FSMWrongStateTransitionError{
+ Got: client.FSM.Current,
+ Want: STATE_DISCONNECTED,
+ }.CustomError(),
+ }
+ }
+
+ currentServer, currentServerErr := client.Servers.GetCurrentServer()
+ if currentServerErr != nil {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed setting disconnect, failed getting current server with error: %s",
+ types.GetErrorTraceback(currentServerErr),
+ ),
+ )
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr}
+ }
+
+ if cleanup {
+ // Do the /disconnect API call and go to disconnected after...
+ server.Disconnect(currentServer)
+ }
+
+ client.FSM.GoTransitionWithData(STATE_DISCONNECTED, currentServer, false)
+
+ return nil
+}
+
+// RenewSession renews the session for the current VPN server.
+// This logs the user back in.
+func (client *Client) RenewSession() error {
+ errorMessage := "failed to renew session"
+
+ currentServer, currentServerErr := client.Servers.GetCurrentServer()
+ if currentServerErr != nil {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed getting current server to renew, error: %s",
+ types.GetErrorTraceback(currentServerErr),
+ ),
+ )
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr}
+ }
+
+ server.MarkTokensForRenew(currentServer)
+ loginErr := client.ensureLogin(currentServer)
+ if loginErr != nil {
+ client.Logger.Warning(
+ fmt.Sprintf(
+ "Failed logging in server for renew, error: %s",
+ types.GetErrorTraceback(loginErr),
+ ),
+ )
+ return &types.WrappedErrorMessage{Message: errorMessage, Err: loginErr}
+ }
+
+ return nil
+}
+
+// ShouldRenewButton returns true if the renew button should be shown
+// If there is no server then this returns false and logs with INFO if so
+// In other cases it simply checks the expiry time and calculates according to: https://github.com/eduvpn/documentation/blob/b93854dcdd22050d5f23e401619e0165cb8bc591/API.md#session-expiry.
+func (client *Client) ShouldRenewButton() bool {
+ if !client.InFSMState(STATE_CONNECTED) && !client.InFSMState(STATE_CONNECTING) &&
+ !client.InFSMState(STATE_DISCONNECTED) &&
+ !client.InFSMState(STATE_DISCONNECTING) {
+ return false
+ }
+
+ currentServer, currentServerErr := client.Servers.GetCurrentServer()
+
+ if currentServerErr != nil {
+ client.Logger.Info(
+ fmt.Sprintf(
+ "No server found to renew with err: %s",
+ types.GetErrorTraceback(currentServerErr),
+ ),
+ )
+ return false
+ }
+
+ return server.ShouldRenewButton(currentServer)
+}
+
+// InFSMState is a helper to check if the FSM is in state `checkState`.
+func (client *Client) InFSMState(checkState FSMStateID) bool {
+ return client.FSM.InState(checkState)
+}
+
+// GetTranslated gets the translation for `languages` using the current state language.
+func (client *Client) GetTranslated(languages map[string]string) string {
+ return util.GetLanguageMatched(languages, client.Language)
+}
+
+type LetsConnectNotSupportedError struct{}
+
+func (e LetsConnectNotSupportedError) Error() string {
+ return "Any operation that involves discovery is not allowed with the Let's Connect! client"
+}
diff --git a/client/client_test.go b/client/client_test.go
new file mode 100644
index 0000000..68a6be0
--- /dev/null
+++ b/client/client_test.go
@@ -0,0 +1,435 @@
+package client
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ httpw "github.com/eduvpn/eduvpn-common/internal/http"
+ "github.com/eduvpn/eduvpn-common/internal/oauth"
+ "github.com/eduvpn/eduvpn-common/internal/util"
+ "github.com/eduvpn/eduvpn-common/types"
+)
+
+func getServerURI(t *testing.T) string {
+ serverURI := os.Getenv("SERVER_URI")
+ if serverURI == "" {
+ t.Skip("Skipping server test as no SERVER_URI env var has been passed")
+ }
+ serverURI, parseErr := util.EnsureValidURL(serverURI)
+ if parseErr != nil {
+ t.Skip("Skipping server test as the server uri is not valid")
+ }
+ return serverURI
+}
+
+func runCommand(t *testing.T, errBuffer *strings.Builder, name string, args ...string) error {
+ cmd := exec.Command(name, args...)
+
+ cmd.Stderr = errBuffer
+ err := cmd.Start()
+ if err != nil {
+ return err
+ }
+
+ return cmd.Wait()
+}
+
+func loginOAuthSelenium(t *testing.T, url string, state *Client) {
+ // We could use the go selenium library
+ // But it does not support the latest selenium v4 just yet
+ var errBuffer strings.Builder
+ err := runCommand(t, &errBuffer, "python3", "selenium_eduvpn.py", url)
+ if err != nil {
+ t.Fatalf(
+ "Login OAuth with selenium script failed with error %v and stderr %s",
+ err,
+ errBuffer.String(),
+ )
+ _ = state.CancelOAuth()
+ }
+}
+
+func stateCallback(
+ t *testing.T,
+ oldState FSMStateID,
+ newState FSMStateID,
+ data interface{},
+ state *Client,
+) {
+ if newState == STATE_OAUTH_STARTED {
+ url, ok := data.(string)
+
+ if !ok {
+ t.Fatalf("data is not a string for OAuth URL")
+ }
+ loginOAuthSelenium(t, url, state)
+ }
+}
+
+func Test_server(t *testing.T) {
+ serverURI := getServerURI(t)
+ state := &Client{}
+
+ registerErr := state.Register(
+ "org.letsconnect-vpn.app.linux",
+ "configstest",
+ "en",
+ func(old FSMStateID, new FSMStateID, data interface{}) {
+ stateCallback(t, old, new, data, state)
+ },
+ false,
+ )
+ if registerErr != nil {
+ t.Fatalf("Register error: %v", registerErr)
+ }
+
+ _, addErr := state.AddCustomServer(serverURI)
+ if addErr != nil {
+ t.Fatalf("Add error: %v", addErr)
+ }
+ _, _, configErr := state.GetConfigCustomServer(serverURI, false)
+ if configErr != nil {
+ t.Fatalf("Connect error: %v", configErr)
+ }
+}
+
+func test_connect_oauth_parameter(
+ t *testing.T,
+ parameters httpw.URLParameters,
+ expectedErr interface{},
+) {
+ serverURI := getServerURI(t)
+ state := &Client{}
+ configDirectory := "test_oauth_parameters"
+
+ registerErr := state.Register(
+ "org.letsconnect-vpn.app.linux",
+ configDirectory,
+ "en",
+ func(oldState FSMStateID, newState FSMStateID, data interface{}) {
+ if newState == STATE_OAUTH_STARTED {
+ server, serverErr := state.Servers.GetCustomServer(serverURI)
+ if serverErr != nil {
+ t.Fatalf("No server with error: %v", serverErr)
+ }
+ port, portErr := server.GetOAuth().GetListenerPort()
+ if portErr != nil {
+ _ = state.CancelOAuth()
+ t.Fatalf("No port with error: %v", portErr)
+ }
+ baseURL := fmt.Sprintf("http://127.0.0.1:%d/callback", port)
+ url, err := httpw.HTTPConstructURL(baseURL, parameters)
+ if err != nil {
+ _ = state.CancelOAuth()
+ t.Fatalf(
+ "Error: Constructing url %s with parameters %s",
+ baseURL,
+ fmt.Sprint(parameters),
+ )
+ }
+ go func() {
+ _, getErr := http.Get(url)
+ if getErr != nil {
+ _ = state.CancelOAuth()
+ t.Logf("HTTP GET error: %v", getErr)
+ }
+ }()
+ }
+ },
+ false,
+ )
+ if registerErr != nil {
+ t.Fatalf("Register error: %v", registerErr)
+ }
+
+ _, addErr := state.AddCustomServer(serverURI)
+
+ var wrappedErr *types.WrappedErrorMessage
+
+ // We ensure the error is of a wrappedErrorMessage
+ if !errors.As(addErr, &wrappedErr) {
+ t.Fatalf("error %T = %v, wantErr %T", addErr, addErr, wrappedErr)
+ }
+
+ gotExpectedErr := wrappedErr.Cause()
+
+ // Then we check if the cause is correct
+ if !errors.As(gotExpectedErr, expectedErr) {
+ t.Fatalf("error %T = %v, wantErr %T", gotExpectedErr, gotExpectedErr, expectedErr)
+ }
+}
+
+func Test_connect_oauth_parameters(t *testing.T) {
+ var (
+ failedCallbackParameterError *oauth.OAuthCallbackParameterError
+ failedCallbackStateMatchError *oauth.OAuthCallbackStateMatchError
+ failedCallbackISSMatchError *oauth.OAuthCallbackISSMatchError
+ )
+
+
+ serverURI := getServerURI(t)
+ // serverURI already ends with a / due to using the util EnsureValidURL function
+ iss := serverURI
+ tests := []struct {
+ expectedErr interface{}
+ parameters httpw.URLParameters
+ }{
+ // missing state and code
+ {&failedCallbackParameterError, httpw.URLParameters{"iss": iss}},
+ // missing state
+ {&failedCallbackParameterError, httpw.URLParameters{"iss": iss, "code": "42"}},
+ // invalid state
+ {&failedCallbackStateMatchError, httpw.URLParameters{"iss": iss, "code": "42", "state": "21"}},
+ // invalid iss
+ {&failedCallbackISSMatchError, httpw.URLParameters{"iss": "37", "code": "42", "state": "21"}},
+ }
+
+ for _, test := range tests {
+ test_connect_oauth_parameter(t, test.parameters, test.expectedErr)
+ }
+}
+
+func Test_token_expired(t *testing.T) {
+ serverURI := getServerURI(t)
+ expiredTTL := os.Getenv("OAUTH_EXPIRED_TTL")
+ if expiredTTL == "" {
+ t.Log(
+ "No expired TTL present, skipping this test. Set OAUTH_EXPIRED_TTL env variable to run this test",
+ )
+ return
+ }
+
+ // Convert the env variable to an int and signal error if it is not possible
+ expiredInt, expiredErr := strconv.Atoi(expiredTTL)
+ if expiredErr != nil {
+ t.Fatalf("Cannot convert EXPIRED_TTL env variable to an int with error %v", expiredErr)
+ }
+
+ // Get a vpn state
+ state := &Client{}
+
+ registerErr := state.Register(
+ "org.letsconnect-vpn.app.linux",
+ "configsexpired",
+ "en",
+ func(old FSMStateID, new FSMStateID, data interface{}) {
+ stateCallback(t, old, new, data, state)
+ },
+ false,
+ )
+ if registerErr != nil {
+ t.Fatalf("Register error: %v", registerErr)
+ }
+
+ _, addErr := state.AddCustomServer(serverURI)
+ if addErr != nil {
+ t.Fatalf("Add error: %v", addErr)
+ }
+
+ _, _, configErr := state.GetConfigCustomServer(serverURI, false)
+
+ if configErr != nil {
+ t.Fatalf("Connect error before expired: %v", configErr)
+ }
+
+ currentServer, serverErr := state.Servers.GetCurrentServer()
+ if serverErr != nil {
+ t.Fatalf("No server found")
+ }
+
+ oauth := currentServer.GetOAuth()
+
+ accessToken := oauth.Token.Access
+ refreshToken := oauth.Token.Refresh
+
+ // Wait for TTL so that the tokens expire
+ time.Sleep(time.Duration(expiredInt) * time.Second)
+
+ _, _, configErr = state.GetConfigCustomServer(serverURI, false)
+
+ if configErr != nil {
+ t.Fatalf("Connect error after expiry: %v", configErr)
+ }
+
+ // Check if tokens have changed
+ accessTokenAfter := oauth.Token.Access
+ refreshTokenAfter := oauth.Token.Refresh
+
+ if accessToken == accessTokenAfter {
+ t.Errorf("Access token is the same after refresh")
+ }
+
+ if refreshToken == refreshTokenAfter {
+ t.Errorf("Refresh token is the same after refresh")
+ }
+}
+
+func Test_token_invalid(t *testing.T) {
+ serverURI := getServerURI(t)
+ state := &Client{}
+
+ registerErr := state.Register(
+ "org.letsconnect-vpn.app.linux",
+ "configsinvalid",
+ "en",
+ func(old FSMStateID, new FSMStateID, data interface{}) {
+ stateCallback(t, old, new, data, state)
+ },
+ false,
+ )
+ if registerErr != nil {
+ t.Fatalf("Register error: %v", registerErr)
+ }
+
+ _, addErr := state.AddCustomServer(serverURI)
+ if addErr != nil {
+ t.Fatalf("Add error: %v", addErr)
+ }
+
+ _, _, configErr := state.GetConfigCustomServer(serverURI, false)
+
+ if configErr != nil {
+ t.Fatalf("Connect error before invalid: %v", configErr)
+ }
+
+ dummy_value := "37"
+
+ currentServer, serverErr := state.Servers.GetCurrentServer()
+ if serverErr != nil {
+ t.Fatalf("No server found")
+ }
+
+ oauth := currentServer.GetOAuth()
+
+ // Override tokens with invalid values
+ oauth.Token.Access = dummy_value
+ oauth.Token.Refresh = dummy_value
+
+ _, _, configErr = state.GetConfigCustomServer(serverURI, false)
+
+ if configErr != nil {
+ t.Fatalf("Connect error after invalid: %v", configErr)
+ }
+
+ if oauth.Token.Access == dummy_value {
+ t.Errorf("Access token is equal to dummy value: %s", dummy_value)
+ }
+
+ if oauth.Token.Refresh == dummy_value {
+ t.Errorf("Refresh token is equal to dummy value: %s", dummy_value)
+ }
+}
+
+// Test if an invalid profile will be corrected
+func Test_invalid_profile_corrected(t *testing.T) {
+ serverURI := getServerURI(t)
+ state := &Client{}
+
+ registerErr := state.Register(
+ "org.letsconnect-vpn.app.linux",
+ "configscancelprofile",
+ "en",
+ func(old FSMStateID, new FSMStateID, data interface{}) {
+ stateCallback(t, old, new, data, state)
+ },
+ false,
+ )
+ if registerErr != nil {
+ t.Fatalf("Register error: %v", registerErr)
+ }
+
+ _, addErr := state.AddCustomServer(serverURI)
+ if addErr != nil {
+ t.Fatalf("Add error: %v", addErr)
+ }
+
+ _, _, configErr := state.GetConfigCustomServer(serverURI, false)
+
+ if configErr != nil {
+ t.Fatalf("First connect error: %v", configErr)
+ }
+
+ currentServer, serverErr := state.Servers.GetCurrentServer()
+ if serverErr != nil {
+ t.Fatalf("No server found")
+ }
+
+ base, baseErr := currentServer.GetBase()
+ if baseErr != nil {
+ t.Fatalf("No base found")
+ }
+
+ previousProfile := base.Profiles.Current
+ base.Profiles.Current = "IDONOTEXIST"
+
+ _, _, configErr = state.GetConfigCustomServer(serverURI, false)
+
+ if configErr != nil {
+ t.Fatalf("Second connect error: %v", configErr)
+ }
+
+ if base.Profiles.Current != previousProfile {
+ t.Fatalf(
+ "Profiles do no match: current %s and previous %s",
+ base.Profiles.Current,
+ previousProfile,
+ )
+ }
+}
+
+// Test if prefer tcp is handled correctly by checking the returned config and config type
+func Test_prefer_tcp(t *testing.T) {
+ serverURI := getServerURI(t)
+ state := &Client{}
+
+ registerErr := state.Register(
+ "org.letsconnect-vpn.app.linux",
+ "configsprefertcp",
+ "en",
+ func(old FSMStateID, new FSMStateID, data interface{}) {
+ stateCallback(t, old, new, data, state)
+ },
+ false,
+ )
+ if registerErr != nil {
+ t.Fatalf("Register error: %v", registerErr)
+ }
+
+ _, addErr := state.AddCustomServer(serverURI)
+ if addErr != nil {
+ t.Fatalf("Add error: %v", addErr)
+ }
+
+ // get a config with preferTCP set to true
+ config, configType, configErr := state.GetConfigCustomServer(serverURI, true)
+
+ // Test server should accept prefer TCP!
+ if configType != "openvpn" {
+ t.Fatalf("Invalid protocol for prefer TCP, got: WireGuard, want: OpenVPN")
+ }
+
+ if configErr != nil {
+ t.Fatalf("Config error: %v", configErr)
+ }
+
+ if !strings.HasSuffix(config, "remote eduvpnserver 1194 tcp\nremote eduvpnserver 1194 udp") {
+ t.Fatalf("Suffix for prefer TCP is not in the right order for config: %s", config)
+ }
+
+ // get a config with preferTCP set to false
+ config, configType, configErr = state.GetConfigCustomServer(serverURI, false)
+ if configErr != nil {
+ t.Fatalf("Config error: %v", configErr)
+ }
+
+ if configType == "openvpn" && !strings.HasSuffix(config, "remote eduvpnserver 1194 udp\nremote eduvpnserver 1194 tcp") {
+ t.Fatalf("Suffix for disable prefer TCP is not in the right order for config: %s", config)
+ }
+}
diff --git a/client/fsm.go b/client/fsm.go
new file mode 100644
index 0000000..f8d2a1c
--- /dev/null
+++ b/client/fsm.go
@@ -0,0 +1,248 @@
+package client
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/eduvpn/eduvpn-common/internal/fsm"
+ "github.com/eduvpn/eduvpn-common/types"
+)
+
+type (
+ FSMStateID = fsm.FSMStateID
+ FSMStates = fsm.FSMStates
+ FSMState = fsm.FSMState
+ FSMTransition = fsm.FSMTransition
+)
+
+const (
+ // Deregistered means the app is not registered with the wrapper
+ STATE_DEREGISTERED FSMStateID = iota
+
+ // No Server means the user has not chosen a server yet
+ STATE_NO_SERVER
+
+ // The user selected a Secure Internet server but needs to choose a location
+ STATE_ASK_LOCATION
+
+ // The user is currently selecting a server in the UI
+ STATE_SEARCH_SERVER
+
+ // We are loading the server details
+ STATE_LOADING_SERVER
+
+ // Chosen Server means the user has chosen a server to connect to
+ STATE_CHOSEN_SERVER
+
+ // OAuth Started means the OAuth process has started
+ STATE_OAUTH_STARTED
+
+ // Authorized means the OAuth process has finished and the user is now authorized with the server
+ STATE_AUTHORIZED
+
+ // Requested config means the user has requested a config for connecting
+ STATE_REQUEST_CONFIG
+
+ // Ask profile means the go code is asking for a profile selection from the UI
+ STATE_ASK_PROFILE
+
+ // Disconnected means the user has gotten a config for a server but is not connected yet
+ STATE_DISCONNECTED
+
+ // Disconnecting means the OS is disconnecting and the Go code is doing the /disconnect
+ STATE_DISCONNECTING
+
+ // Connecting means the OS is establishing a connection to the server
+ STATE_CONNECTING
+
+ // Connected means the user has been connected to the server
+ STATE_CONNECTED
+)
+
+func GetStateName(s FSMStateID) string {
+ switch s {
+ case STATE_DEREGISTERED:
+ return "Deregistered"
+ case STATE_NO_SERVER:
+ return "No_Server"
+ case STATE_ASK_LOCATION:
+ return "Ask_Location"
+ case STATE_SEARCH_SERVER:
+ return "Search_Server"
+ case STATE_LOADING_SERVER:
+ return "Loading_Server"
+ case STATE_CHOSEN_SERVER:
+ return "Chosen_Server"
+ case STATE_OAUTH_STARTED:
+ return "OAuth_Started"
+ case STATE_DISCONNECTED:
+ return "Disconnected"
+ case STATE_REQUEST_CONFIG:
+ return "Request_Config"
+ case STATE_ASK_PROFILE:
+ return "Ask_Profile"
+ case STATE_AUTHORIZED:
+ return "Authorized"
+ case STATE_DISCONNECTING:
+ return "Disconnecting"
+ case STATE_CONNECTING:
+ return "Connecting"
+ case STATE_CONNECTED:
+ return "Connected"
+ default:
+ panic("unknown conversion of state to string")
+ }
+}
+
+func newFSM(
+ callback func(FSMStateID, FSMStateID, interface{}),
+ directory string,
+ debug bool,
+) fsm.FSM {
+ states := FSMStates{
+ STATE_DEREGISTERED: FSMState{
+ Transitions: []FSMTransition{{To: STATE_NO_SERVER, Description: "Client registers"}},
+ },
+ STATE_NO_SERVER: FSMState{
+ Transitions: []FSMTransition{
+ {To: STATE_NO_SERVER, Description: "Reload list"},
+ {To: STATE_LOADING_SERVER, Description: "User clicks a server in the UI"},
+ {To: STATE_CHOSEN_SERVER, Description: "The server has been chosen"},
+ {To: STATE_SEARCH_SERVER, Description: "The user is trying to choose a new server in the UI"},
+ {To: STATE_CONNECTED, Description: "The user is already connected"},
+ {To: STATE_ASK_LOCATION, Description: "Change the location in the main screen"},
+ },
+ },
+ STATE_SEARCH_SERVER: FSMState{
+ Transitions: []FSMTransition{
+ {To: STATE_LOADING_SERVER, Description: "User clicks a server in the UI"},
+ {To: STATE_NO_SERVER, Description: "Cancel or Error"},
+ },
+ BackState: STATE_NO_SERVER,
+ },
+ STATE_ASK_LOCATION: FSMState{
+ Transitions: []FSMTransition{
+ {To: STATE_CHOSEN_SERVER, Description: "Location chosen"},
+ {To: STATE_NO_SERVER, Description: "Go back or Error"},
+ {To: STATE_SEARCH_SERVER, Description: "Cancel or Error"},
+ },
+ },
+ STATE_LOADING_SERVER: FSMState{
+ Transitions: []FSMTransition{
+ {To: STATE_CHOSEN_SERVER, Description: "Server info loaded"},
+ {
+ To: STATE_ASK_LOCATION,
+ Description: "User chooses a Secure Internet server but no location is configured",
+ },
+ {To: STATE_NO_SERVER, Description: "Go back or Error"},
+ },
+ BackState: STATE_NO_SERVER,
+ },
+ STATE_CHOSEN_SERVER: FSMState{
+ Transitions: []FSMTransition{
+ {To: STATE_AUTHORIZED, Description: "Found tokens in config"},
+ {To: STATE_OAUTH_STARTED, Description: "No tokens found in config"},
+ },
+ },
+ STATE_OAUTH_STARTED: FSMState{
+ Transitions: []FSMTransition{
+ {To: STATE_AUTHORIZED, Description: "User authorizes with browser"},
+ {To: STATE_NO_SERVER, Description: "Cancel or Error"},
+ {To: STATE_SEARCH_SERVER, Description: "Cancel or Error"},
+ },
+ BackState: STATE_NO_SERVER,
+ },
+ STATE_AUTHORIZED: FSMState{
+ Transitions: []FSMTransition{
+ {To: STATE_OAUTH_STARTED, Description: "Re-authorize with OAuth"},
+ {To: STATE_REQUEST_CONFIG, Description: "Client requests a config"},
+ {To: STATE_NO_SERVER, Description: "Client wants to go back to the main screen"},
+ },
+ },
+ STATE_REQUEST_CONFIG: FSMState{
+ Transitions: []FSMTransition{
+ {To: STATE_ASK_PROFILE, Description: "Multiple profiles found and no profile chosen"},
+ {To: STATE_DISCONNECTED, Description: "Only one profile or profile already chosen"},
+ {To: STATE_NO_SERVER, Description: "Cancel or Error"},
+ {To: STATE_OAUTH_STARTED, Description: "Re-authorize"},
+ },
+ },
+ STATE_ASK_PROFILE: FSMState{
+ Transitions: []FSMTransition{
+ {To: STATE_DISCONNECTED, Description: "User chooses profile"},
+ {To: STATE_NO_SERVER, Description: "Cancel or Error"},
+ {To: STATE_SEARCH_SERVER, Description: "Cancel or Error"},
+ },
+ },
+ STATE_DISCONNECTED: FSMState{
+ Transitions: []FSMTransition{
+ {To: STATE_CONNECTING, Description: "OS reports it is trying to connect"},
+ {To: STATE_REQUEST_CONFIG, Description: "User reconnects"},
+ {To: STATE_NO_SERVER, Description: "User wants to choose a new server"},
+ {To: STATE_OAUTH_STARTED, Description: "Re-authorize with OAuth"},
+ },
+ BackState: STATE_NO_SERVER,
+ },
+ STATE_DISCONNECTING: FSMState{
+ Transitions: []FSMTransition{
+ {To: STATE_DISCONNECTED, Description: "Cancel or Error"},
+ {To: STATE_DISCONNECTED, Description: "Done disconnecting"},
+ },
+ },
+ STATE_CONNECTING: FSMState{
+ Transitions: []FSMTransition{
+ {To: STATE_DISCONNECTED, Description: "Cancel or Error"},
+ {To: STATE_CONNECTED, Description: "Done connecting"},
+ },
+ },
+ STATE_CONNECTED: FSMState{
+ Transitions: []FSMTransition{{To: STATE_DISCONNECTING, Description: "App wants to disconnect"}},
+ },
+ }
+ returnedFSM := fsm.FSM{}
+ returnedFSM.Init(STATE_DEREGISTERED, states, callback, directory, GetStateName, debug)
+ return returnedFSM
+}
+
+type FSMDeregisteredError struct{}
+
+func (e FSMDeregisteredError) CustomError() *types.WrappedErrorMessage {
+ return &types.WrappedErrorMessage{
+ Message: "Client not registered with the GO library",
+ Err: errors.New(
+ "the current FSM state is deregistered, but the function needs a state that is not deregistered",
+ ),
+ }
+}
+
+type FSMWrongStateTransitionError struct {
+ Got FSMStateID
+ Want FSMStateID
+}
+
+func (e FSMWrongStateTransitionError) CustomError() *types.WrappedErrorMessage {
+ return &types.WrappedErrorMessage{
+ Message: "Wrong FSM transition",
+ Err: fmt.Errorf(
+ "wrong FSM state, got: %s, want: a state with a transition to: %s",
+ GetStateName(e.Got),
+ GetStateName(e.Want),
+ ),
+ }
+}
+
+type FSMWrongStateError struct {
+ Got FSMStateID
+ Want FSMStateID
+}
+
+func (e FSMWrongStateError) CustomError() *types.WrappedErrorMessage {
+ return &types.WrappedErrorMessage{
+ Message: "Wrong FSM State",
+ Err: fmt.Errorf(
+ "wrong FSM state, got: %s, want: %s",
+ GetStateName(e.Got),
+ GetStateName(e.Want),
+ ),
+ }
+}