From 5dad968aa90ded83d57211426bc186f6d609ef06 Mon Sep 17 00:00:00 2001 From: jwijenbergh Date: Tue, 4 Oct 2022 15:29:11 +0200 Subject: State: Rename to client to avoid confusion with the FSM --- client.go | 1028 ++++++++++++++++++++++++++++++++++++++++++++++++++++ client_test.go | 378 +++++++++++++++++++ cmd/cli/main.go | 8 +- exports/disco.go | 8 +- exports/exports.go | 12 +- exports/servers.go | 10 +- state.go | 1028 ---------------------------------------------------- state_test.go | 379 ------------------- 8 files changed, 1425 insertions(+), 1426 deletions(-) create mode 100644 client.go create mode 100644 client_test.go delete mode 100644 state.go delete mode 100644 state_test.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..506d319 --- /dev/null +++ b/client.go @@ -0,0 +1,1028 @@ +package eduvpn + +import ( + "errors" + "fmt" + + "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/types" + "github.com/eduvpn/eduvpn-common/internal/util" +) + +type ( + // ServerBase is an alias to the internal ServerBase + // This contains the details for each server + ServerBase = server.ServerBase +) + +// Client is the main struct for the VPN client +type Client struct { + // 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, + 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(), + } + } + // Initialize the logger + logLevel := log.LOG_WARNING + client.Language = "en" + + 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(name, stateCallback, directory, debug) + client.Debug = debug + + // Initialize the Config + client.Config.Init(name, directory) + + // 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") + } + + discoServers, discoServersErr := client.GetDiscoServers() + + _, currentServerErr := client.Servers.GetCurrentServer() + // Only actually return the error if we have no disco servers and no current server + if discoServersErr != nil && (discoServers == nil || discoServers.Version == 0) && currentServerErr != nil { + client.Logger.Error( + fmt.Sprintf( + "No configured servers, discovery servers is empty and no servers with error: %s", + GetErrorTraceback(discoServersErr), + ), + ) + return &types.WrappedErrorMessage{Message: errorMessage, Err: discoServersErr} + } + discoOrgs, discoOrgsErr := client.GetDiscoOrganizations() + + // Only actually return the error if we have no disco organizations and no current server + if discoOrgsErr != nil && (discoOrgs == nil || discoOrgs.Version == 0) && currentServerErr != nil { + client.Logger.Error( + fmt.Sprintf( + "No configured organizations, discovery organizations empty and no servers with error: %s", + GetErrorTraceback(discoOrgsErr), + ), + ) + return &types.WrappedErrorMessage{Message: errorMessage, Err: discoOrgsErr} + } + // Go to the No Server state with the saved servers + client.FSM.GoTransitionWithData(STATE_NO_SERVER, client.Servers, true) + 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", + 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", + 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.FSM.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: 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", + 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" + + 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, + 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", + 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 +} + +// 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, + ) + // Get the secure internet URL from discovery + secureOrg, secureServer, discoErr := client.Discovery.GetSecureHomeArgs(orgID) + if discoErr != nil { + return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: discoErr} + } + + // Add the secure internet server + server, serverErr := client.Servers.AddSecureInternet(secureOrg, secureServer) + + if serverErr != nil { + return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} + } + + var locationErr error + + if !client.Servers.HasSecureLocation() { + locationErr = client.askSecureLocation() + } else { + // reinitialize + locationErr = client.SetSecureLocation(client.Servers.GetSecureLocation()) + } + + if locationErr != nil { + return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: locationErr} + } + + return server, 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", + 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", + 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", + GetErrorTraceback(saveErr), + ), + ) + } + return 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, + ) + client.FSM.GoTransition(STATE_LOADING_SERVER) + server, serverErr := client.addSecureInternetHomeServer(orgID) + if serverErr != nil { + client.Logger.Error( + fmt.Sprintf( + "Failed adding a secure internet server with error: %s", + GetErrorTraceback(serverErr), + ), + ) + client.goBackInternal() + return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} + } + + 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", + GetErrorTraceback(configErr), + ), + ) + return "", "", &types.WrappedErrorMessage{Level: GetErrorLevel(configErr), Message: errorMessage, Err: configErr} + } + return config, configType, 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) + instituteServer, discoErr := client.Discovery.GetServerByURL(url, "institute_access") + if discoErr != nil { + return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: discoErr} + } + // Add the secure internet server + server, serverErr := client.Servers.AddInstituteAccessServer(instituteServer) + if serverErr != nil { + return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} + } + + client.FSM.GoTransition(STATE_CHOSEN_SERVER) + + 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} + } + + 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 { + return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} + } + + client.FSM.GoTransition(STATE_CHOSEN_SERVER) + + 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) + client.FSM.GoTransition(STATE_LOADING_SERVER) + server, serverErr := client.addInstituteServer(url) + if serverErr != nil { + client.Logger.Error( + fmt.Sprintf( + "Failed adding an institute access server with error: %s", + GetErrorTraceback(serverErr), + ), + ) + client.goBackInternal() + return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} + } + + 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", + GetErrorTraceback(configErr), + ), + ) + return "", "", &types.WrappedErrorMessage{Level: 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) + client.FSM.GoTransition(STATE_LOADING_SERVER) + server, serverErr := client.addCustomServer(url) + + if serverErr != nil { + client.Logger.Error( + fmt.Sprintf( + "Failed adding a custom server with error: %s", + GetErrorTraceback(serverErr), + ), + ) + client.goBackInternal() + return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} + } + + config, configType, configErr := client.getConfig(server, preferTCP) + if configErr != nil { + client.Logger.Inherit( + configErr, + fmt.Sprintf( + "Failed getting a custom server with error: %s", + GetErrorTraceback(configErr), + ), + ) + return "", "", &types.WrappedErrorMessage{Level: 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", + 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) { + orgs, orgsErr := client.Discovery.GetOrganizationsList() + if orgsErr != nil { + client.Logger.Warning( + fmt.Sprintf( + "Failed getting discovery organizations, Err: %s", + GetErrorTraceback(orgsErr), + ), + ) + return nil, &types.WrappedErrorMessage{ + Message: "failed getting discovery organizations list", + Err: orgsErr, + } + } + return orgs, nil +} + +// GetDiscoDiscovers 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) { + servers, serversErr := client.Discovery.GetServersList() + if serversErr != nil { + client.Logger.Warning( + fmt.Sprintf("Failed getting discovery servers, Err: %s", GetErrorTraceback(serversErr)), + ) + return nil, &types.WrappedErrorMessage{ + Message: "failed getting discovery servers list", + 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", + 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", 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_CONNECTED, + }.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", + 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", + 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", + 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", + 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", + GetErrorTraceback(currentServerErr), + ), + ) + return &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr} + } + + loginErr := client.ensureLogin(currentServer) + if loginErr != nil { + client.Logger.Warning( + fmt.Sprintf( + "Failed logging in server for renew, error: %s", + 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", + 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) +} + +// GetErrorCause gets the cause for error `err`. +func GetErrorCause(err error) error { + return types.GetErrorCause(err) +} + +// GetErrorCause gets the level for error `err`. +func GetErrorLevel(err error) types.ErrorLevel { + return types.GetErrorLevel(err) +} + +// GetErrorCause gets the traceback for error `err`. +func GetErrorTraceback(err error) string { + return types.GetErrorTraceback(err) +} + +// 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) +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..74e951b --- /dev/null +++ b/client_test.go @@ -0,0 +1,378 @@ +package eduvpn + +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/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") + } + 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.eduvpn.app.linux", + "configstest", + func(old FSMStateID, new FSMStateID, data interface{}) { + stateCallback(t, old, new, data, state) + }, + false, + ) + if registerErr != nil { + t.Fatalf("Register error: %v", registerErr) + } + + _, _, 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.eduvpn.app.linux", + configDirectory, + func(oldState FSMStateID, newState FSMStateID, data interface{}) { + if newState == STATE_OAUTH_STARTED { + baseURL := "http://127.0.0.1:8000/callback" + url, err := httpw.HTTPConstructURL(baseURL, parameters) + if err != nil { + t.Fatalf( + "Error: Constructing url %s with parameters %s", + baseURL, + fmt.Sprint(parameters), + ) + } + go func() { + _, getErr := http.Get(url) + if getErr != nil { + t.Logf("HTTP GET error: %v", getErr) + } + }() + } + }, + false, + ) + if registerErr != nil { + t.Fatalf("Register error: %v", registerErr) + } + _, _, configErr := state.GetConfigCustomServer(serverURI, false) + + var wrappedErr *types.WrappedErrorMessage + + // We ensure the error is of a wrappedErrorMessage + if !errors.As(configErr, &wrappedErr) { + t.Fatalf("error %T = %v, wantErr %T", configErr, configErr, 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 + ) + + tests := []struct { + expectedErr interface{} + parameters httpw.URLParameters + }{ + {&failedCallbackParameterError, httpw.URLParameters{}}, + {&failedCallbackParameterError, httpw.URLParameters{"code": "42"}}, + {&failedCallbackStateMatchError, httpw.URLParameters{"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.eduvpn.app.linux", + "configsexpired", + func(old FSMStateID, new FSMStateID, data interface{}) { + stateCallback(t, old, new, data, state) + }, + false, + ) + if registerErr != nil { + t.Fatalf("Register error: %v", registerErr) + } + + _, _, 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.eduvpn.app.linux", + "configsinvalid", + func(old FSMStateID, new FSMStateID, data interface{}) { + stateCallback(t, old, new, data, state) + }, + false, + ) + if registerErr != nil { + t.Fatalf("Register error: %v", registerErr) + } + + _, _, 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.eduvpn.app.linux", + "configscancelprofile", + func(old FSMStateID, new FSMStateID, data interface{}) { + stateCallback(t, old, new, data, state) + }, + false, + ) + if registerErr != nil { + t.Fatalf("Register error: %v", registerErr) + } + + _, _, 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.eduvpn.app.linux", + "configsprefertcp", + func(old FSMStateID, new FSMStateID, data interface{}) { + stateCallback(t, old, new, data, state) + }, + false, + ) + if registerErr != nil { + t.Fatalf("Register error: %v", registerErr) + } + + // 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/cmd/cli/main.go b/cmd/cli/main.go index 8bc083d..b221c71 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -34,7 +34,7 @@ func openBrowser(url interface{}) { } // Ask for a profile in the command line -func sendProfile(state *eduvpn.VPNState, data interface{}) { +func sendProfile(state *eduvpn.Client, data interface{}) { fmt.Printf("Multiple VPN profiles found. Please select a profile by entering e.g. 1") serverProfiles, ok := data.(*server.ServerProfileInfo) @@ -76,7 +76,7 @@ func sendProfile(state *eduvpn.VPNState, data interface{}) { // If we ask for a profile, we send the profile using command line input // Note that this has an additional argument, the vpn state which was wrapped into this callback function below func stateCallback( - state *eduvpn.VPNState, + state *eduvpn.Client, oldState eduvpn.FSMStateID, newState eduvpn.FSMStateID, data interface{}, @@ -91,7 +91,7 @@ func stateCallback( } // Get a config for Institute Access or Secure Internet Server -func getConfig(state *eduvpn.VPNState, url string, serverType ServerTypes) (string, string, error) { +func getConfig(state *eduvpn.Client, url string, serverType ServerTypes) (string, string, error) { if !strings.HasPrefix(url, "http") { url = "https://" + url } @@ -106,7 +106,7 @@ func getConfig(state *eduvpn.VPNState, url string, serverType ServerTypes) (stri // Get a config for a single server, Institute Access or Secure Internet func printConfig(url string, serverType ServerTypes) { - state := &eduvpn.VPNState{} + state := &eduvpn.Client{} registerErr := state.Register( "org.eduvpn.app.linux", diff --git a/exports/disco.go b/exports/disco.go index ac7ac7d..3058334 100644 --- a/exports/disco.go +++ b/exports/disco.go @@ -47,7 +47,7 @@ import ( ) func getCPtrDiscoOrganization( - state *eduvpn.VPNState, + state *eduvpn.Client, organization *types.DiscoveryOrganization, ) *C.discoveryOrganization { returnedStruct := (*C.discoveryOrganization)( @@ -61,7 +61,7 @@ func getCPtrDiscoOrganization( } func getCPtrDiscoOrganizations( - state *eduvpn.VPNState, + state *eduvpn.Client, organizations *types.DiscoveryOrganizations, ) (C.size_t, **C.discoveryOrganization) { totalOrganizations := C.size_t(len(organizations.List)) @@ -82,7 +82,7 @@ func getCPtrDiscoOrganizations( } func getCPtrDiscoServer( - state *eduvpn.VPNState, + state *eduvpn.Client, server *types.DiscoveryServer, ) *C.discoveryServer { returnedStruct := (*C.discoveryServer)( @@ -104,7 +104,7 @@ func getCPtrDiscoServer( } func getCPtrDiscoServers( - state *eduvpn.VPNState, + state *eduvpn.Client, servers *types.DiscoveryServers, ) (C.size_t, **C.discoveryServer) { totalServers := C.size_t(len(servers.List)) diff --git a/exports/exports.go b/exports/exports.go index a5c008a..fdf3d94 100644 --- a/exports/exports.go +++ b/exports/exports.go @@ -22,10 +22,10 @@ import ( var P_StateCallbacks map[string]C.PythonCB -var VPNStates map[string]*eduvpn.VPNState +var VPNStates map[string]*eduvpn.Client func GetStateData( - state *eduvpn.VPNState, + state *eduvpn.Client, stateID eduvpn.FSMStateID, data interface{}, ) unsafe.Pointer { @@ -55,7 +55,7 @@ func GetStateData( } func StateCallback( - state *eduvpn.VPNState, + state *eduvpn.Client, name string, old_state eduvpn.FSMStateID, new_state eduvpn.FSMStateID, @@ -74,7 +74,7 @@ func StateCallback( // data_c gets freed by the wrapper } -func GetVPNState(name string) (*eduvpn.VPNState, error) { +func GetVPNState(name string) (*eduvpn.Client, error) { state, exists := VPNStates[name] if !exists || state == nil { @@ -94,10 +94,10 @@ func Register( nameStr := C.GoString(name) state, stateErr := GetVPNState(nameStr) if stateErr != nil { - state = &eduvpn.VPNState{} + state = &eduvpn.Client{} } if VPNStates == nil { - VPNStates = make(map[string]*eduvpn.VPNState) + VPNStates = make(map[string]*eduvpn.Client) } if P_StateCallbacks == nil { P_StateCallbacks = make(map[string]C.PythonCB) diff --git a/exports/servers.go b/exports/servers.go index a399db7..175c835 100644 --- a/exports/servers.go +++ b/exports/servers.go @@ -162,7 +162,7 @@ func freeCListStrings(allStrings **C.char, totalStrings C.size_t) { // Function for getting the server, // It gets the main state as a pointer as we need to convert some string maps to localized strings // It gets the base information for a server as well -func getCPtrServer(state *eduvpn.VPNState, base *eduvpn.VPNServerBase) *C.server { +func getCPtrServer(state *eduvpn.Client, base *eduvpn.ServerBase) *C.server { // Allocation using malloc and the size of the struct server := (*C.server)(C.malloc(C.size_t(unsafe.Sizeof(C.server{})))) // String allocation and translate the display name @@ -212,7 +212,7 @@ func FreeServer(info *C.server) { // Get the C ptr to the servers, returns the length in size_t and the double pointer to the struct func getCPtrServers( - state *eduvpn.VPNState, + state *eduvpn.Client, serverMap map[string]*server.InstituteAccessServer, ) (C.size_t, **C.server) { totalServers := C.size_t(len(serverMap)) @@ -263,7 +263,7 @@ func FreeServers(cServers *C.servers) { // Return the servers as a C struct pointer // It takes the state as a pointer as we need to translate some strings // It also takes the servers as a pointer that belongs to the main state or gathered from the callback -func getSavedServersWithOptions(state *eduvpn.VPNState, servers *server.Servers) *C.servers { +func getSavedServersWithOptions(state *eduvpn.Client, servers *server.Servers) *C.servers { // Allocate the struct that we will return // With the size of the c struct returnedStruct := (*C.servers)(C.malloc(C.size_t(unsafe.Sizeof(C.servers{})))) @@ -306,7 +306,7 @@ func GetSavedServers(name *C.char) (*C.servers, *C.error) { // This function takes the state as input which is the main state // It also takes the data as an interface and if it has the servers type gets the data as a c struct otherwise nil -func getTransitionDataServers(state *eduvpn.VPNState, data interface{}) *C.servers { +func getTransitionDataServers(state *eduvpn.Client, data interface{}) *C.servers { if converted, ok := data.(server.Servers); ok { return getSavedServersWithOptions(state, &converted) } @@ -335,7 +335,7 @@ func getTransitionProfiles(data interface{}) *C.serverProfiles { return nil } -func getTransitionServer(state *eduvpn.VPNState, data interface{}) *C.server { +func getTransitionServer(state *eduvpn.Client, data interface{}) *C.server { if server, ok := data.(server.Server); ok { base, baseErr := server.GetBase() if baseErr != nil { diff --git a/state.go b/state.go deleted file mode 100644 index 4812fab..0000000 --- a/state.go +++ /dev/null @@ -1,1028 +0,0 @@ -package eduvpn - -import ( - "errors" - "fmt" - - "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/types" - "github.com/eduvpn/eduvpn-common/internal/util" -) - -type ( - // VPNServerBase is an alias to the internal ServerBase - // This contains the details for each server - VPNServerBase = server.ServerBase -) - -// VPNState is the main struct for the library -type VPNState struct { - // 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 state with 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 (state *VPNState) Register( - name string, - directory string, - stateCallback func(FSMStateID, FSMStateID, interface{}), - debug bool, -) error { - errorMessage := "failed to register with the GO library" - if !state.InFSMState(STATE_DEREGISTERED) { - return &types.WrappedErrorMessage{ - Message: errorMessage, - Err: FSMDeregisteredError{}.CustomError(), - } - } - // Initialize the logger - logLevel := log.LOG_WARNING - state.Language = "en" - - if debug { - logLevel = log.LOG_INFO - } - - loggerErr := state.Logger.Init(logLevel, name, directory) - if loggerErr != nil { - return &types.WrappedErrorMessage{Message: errorMessage, Err: loggerErr} - } - - // Initialize the FSM - state.FSM = newFSM(name, stateCallback, directory, debug) - state.Debug = debug - - // Initialize the Config - state.Config.Init(name, directory) - - // Try to load the previous configuration - if state.Config.Load(&state) != nil { - // This error can be safely ignored, as when the config does not load, the struct will not be filled - state.Logger.Info("Previous configuration not found") - } - - discoServers, discoServersErr := state.GetDiscoServers() - - _, currentServerErr := state.Servers.GetCurrentServer() - // Only actually return the error if we have no disco servers and no current server - if discoServersErr != nil && (discoServers == nil || discoServers.Version == 0) && currentServerErr != nil { - state.Logger.Error( - fmt.Sprintf( - "No configured servers, discovery servers is empty and no servers with error: %s", - GetErrorTraceback(discoServersErr), - ), - ) - return &types.WrappedErrorMessage{Message: errorMessage, Err: discoServersErr} - } - discoOrgs, discoOrgsErr := state.GetDiscoOrganizations() - - // Only actually return the error if we have no disco organizations and no current server - if discoOrgsErr != nil && (discoOrgs == nil || discoOrgs.Version == 0) && currentServerErr != nil { - state.Logger.Error( - fmt.Sprintf( - "No configured organizations, discovery organizations empty and no servers with error: %s", - GetErrorTraceback(discoOrgsErr), - ), - ) - return &types.WrappedErrorMessage{Message: errorMessage, Err: discoOrgsErr} - } - // Go to the No Server state with the saved servers - state.FSM.GoTransitionWithData(STATE_NO_SERVER, state.Servers, true) - return nil -} - -// Deregister 'deregisters' the state, meaning saving the log file and the config and emptying out the state. -func (state *VPNState) Deregister() { - // Close the log file - state.Logger.Close() - - // Save the config - saveErr := state.Config.Save(&state) - if saveErr != nil { - state.Logger.Info( - fmt.Sprintf( - "Failed saving configuration, error: %s", - GetErrorTraceback(saveErr), - ), - ) - } - - // Empty out the state - *state = VPNState{} -} - -// goBackInternal uses the public go back but logs an error if it happened. -func (state *VPNState) goBackInternal() { - goBackErr := state.GoBack() - if goBackErr != nil { - state.Logger.Info( - fmt.Sprintf( - "Failed going back, error: %s", - GetErrorTraceback(goBackErr), - ), - ) - } -} - -// GoBack transitions the FSM back to the previous UI state, for now this is always the NO_SERVER state. -func (state *VPNState) GoBack() error { - errorMessage := "failed to go back" - if state.InFSMState(STATE_DEREGISTERED) { - state.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 - state.FSM.GoTransitionWithData(STATE_NO_SERVER, state.Servers, false) - return nil -} - -// ensureLogin logs the user back in if needed. -// It runs the FSM transitions to ask for user input. -func (state *VPNState) 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, state.FSM.Name) - - state.FSM.GoTransitionWithData(STATE_OAUTH_STARTED, url, true) - - if urlErr != nil { - state.goBackInternal() - return &types.WrappedErrorMessage{Message: errorMessage, Err: urlErr} - } - - exchangeErr := server.OAuthExchange(chosenServer) - - if exchangeErr != nil { - state.goBackInternal() - return &types.WrappedErrorMessage{Message: errorMessage, Err: exchangeErr} - } - } - // OAuth was valid, ensure we are in the authorized state - state.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 (state *VPNState) getConfigAuth( - chosenServer server.Server, - preferTCP bool, -) (string, string, error) { - loginErr := state.ensureLogin(chosenServer) - if loginErr != nil { - return "", "", loginErr - } - state.FSM.GoTransition(STATE_REQUEST_CONFIG) - - validProfile, profileErr := server.HasValidProfile(chosenServer) - if profileErr != nil { - return "", "", profileErr - } - - // No valid profile, ask for one - if !validProfile { - askProfileErr := state.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 (state *VPNState) retryConfigAuth( - chosenServer server.Server, - preferTCP bool, -) (string, string, error) { - errorMessage := "failed authorized config retry" - config, configType, configErr := state.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 = state.getConfigAuth( - chosenServer, - preferTCP, - ) - if configErr == nil { - return config, configType, nil - } - } - if errors.As(configErr, &oauthCancelledError) { - level = types.ERR_INFO - } - state.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 (state *VPNState) getConfig( - chosenServer server.Server, - preferTCP bool, -) (string, string, error) { - errorMessage := "failed to get a configuration for OpenVPN/Wireguard" - if state.InFSMState(STATE_DEREGISTERED) { - return "", "", &types.WrappedErrorMessage{ - Message: errorMessage, - Err: FSMDeregisteredError{}.CustomError(), - } - } - - config, configType, configErr := state.retryConfigAuth(chosenServer, preferTCP) - - if configErr != nil { - return "", "", &types.WrappedErrorMessage{Level: GetErrorLevel(configErr), Message: errorMessage, Err: configErr} - } - - currentServer, currentServerErr := state.Servers.GetCurrentServer() - if currentServerErr != nil { - return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr} - } - - // Signal the server display info - state.FSM.GoTransitionWithData(STATE_DISCONNECTED, currentServer, false) - - // Save the config - saveErr := state.Config.Save(&state) - if saveErr != nil { - state.Logger.Info( - fmt.Sprintf( - "Failed saving configuration after getting a server: %s", - 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 (state *VPNState) SetSecureLocation(countryCode string) error { - errorMessage := "failed asking secure location" - - server, serverErr := state.Discovery.GetServerByCountryCode(countryCode, "secure_internet") - if serverErr != nil { - state.Logger.Error( - fmt.Sprintf( - "Failed getting secure internet server by country code: %s with error: %s", - countryCode, - GetErrorTraceback(serverErr), - ), - ) - state.goBackInternal() - return &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} - } - - setLocationErr := state.Servers.SetSecureLocation(server) - if setLocationErr != nil { - state.Logger.Error( - fmt.Sprintf( - "Failed setting secure internet server with error: %s", - GetErrorTraceback(setLocationErr), - ), - ) - state.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 (state *VPNState) askProfile(chosenServer server.Server) error { - base, baseErr := chosenServer.GetBase() - if baseErr != nil { - return &types.WrappedErrorMessage{Message: "failed asking for profiles", Err: baseErr} - } - state.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 (state *VPNState) askSecureLocation() error { - locations := state.Discovery.GetSecureLocationList() - - // Ask for the location in the callback - state.FSM.GoTransitionWithData(STATE_ASK_LOCATION, locations, false) - - // The state has changed, meaning setting the secure location was not successful - if state.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 -} - -// 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 (state *VPNState) addSecureInternetHomeServer(orgID string) (server.Server, error) { - errorMessage := fmt.Sprintf( - "failed adding Secure Internet home server with organization ID %s", - orgID, - ) - // Get the secure internet URL from discovery - secureOrg, secureServer, discoErr := state.Discovery.GetSecureHomeArgs(orgID) - if discoErr != nil { - return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: discoErr} - } - - // Add the secure internet server - server, serverErr := state.Servers.AddSecureInternet(secureOrg, secureServer) - - if serverErr != nil { - return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} - } - - var locationErr error - - if !state.Servers.HasSecureLocation() { - locationErr = state.askSecureLocation() - } else { - // reinitialize - locationErr = state.SetSecureLocation(state.Servers.GetSecureLocation()) - } - - if locationErr != nil { - return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: locationErr} - } - - return server, 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 (state *VPNState) RemoveSecureInternet() error { - if state.InFSMState(STATE_DEREGISTERED) { - state.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 - state.Servers.RemoveSecureInternet() - state.FSM.GoTransitionWithData(STATE_NO_SERVER, state.Servers, false) - // Save the config - saveErr := state.Config.Save(&state) - if saveErr != nil { - state.Logger.Info( - fmt.Sprintf( - "Failed saving configuration after removing a secure internet server: %s", - 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 (state *VPNState) RemoveInstituteAccess(url string) error { - if state.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 - state.Servers.RemoveInstituteAccess(url) - state.FSM.GoTransitionWithData(STATE_NO_SERVER, state.Servers, false) - // Save the config - saveErr := state.Config.Save(&state) - if saveErr != nil { - state.Logger.Info( - fmt.Sprintf( - "Failed saving configuration after removing an institute access server: %s", - 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 (state *VPNState) RemoveCustomServer(url string) error { - if state.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 - state.Servers.RemoveCustomServer(url) - state.FSM.GoTransitionWithData(STATE_NO_SERVER, state.Servers, false) - // Save the config - saveErr := state.Config.Save(&state) - if saveErr != nil { - state.Logger.Info( - fmt.Sprintf( - "Failed saving configuration after removing a custom server: %s", - GetErrorTraceback(saveErr), - ), - ) - } - return 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 (state *VPNState) GetConfigSecureInternet( - orgID string, - preferTCP bool, -) (string, string, error) { - errorMessage := fmt.Sprintf( - "failed getting a configuration for Secure Internet organization %s", - orgID, - ) - state.FSM.GoTransition(STATE_LOADING_SERVER) - server, serverErr := state.addSecureInternetHomeServer(orgID) - if serverErr != nil { - state.Logger.Error( - fmt.Sprintf( - "Failed adding a secure internet server with error: %s", - GetErrorTraceback(serverErr), - ), - ) - state.goBackInternal() - return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} - } - - state.FSM.GoTransition(STATE_CHOSEN_SERVER) - - config, configType, configErr := state.getConfig(server, preferTCP) - if configErr != nil { - state.Logger.Inherit( - configErr, - fmt.Sprintf( - "Failed getting a secure internet configuration with error: %s", - GetErrorTraceback(configErr), - ), - ) - return "", "", &types.WrappedErrorMessage{Level: GetErrorLevel(configErr), Message: errorMessage, Err: configErr} - } - return config, configType, nil -} - -// addInstituteServer adds an Institute Access server by `url`. -func (state *VPNState) addInstituteServer(url string) (server.Server, error) { - errorMessage := fmt.Sprintf("failed adding Institute Access server with url %s", url) - instituteServer, discoErr := state.Discovery.GetServerByURL(url, "institute_access") - if discoErr != nil { - return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: discoErr} - } - // Add the secure internet server - server, serverErr := state.Servers.AddInstituteAccessServer(instituteServer) - if serverErr != nil { - return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} - } - - state.FSM.GoTransition(STATE_CHOSEN_SERVER) - - return server, nil -} - -// addCustomServer adds a Custom Server by `url` -func (state *VPNState) 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} - } - - 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 := state.Servers.AddCustomServer(customServer) - if serverErr != nil { - return nil, &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} - } - - state.FSM.GoTransition(STATE_CHOSEN_SERVER) - - 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 (state *VPNState) GetConfigInstituteAccess(url string, preferTCP bool) (string, string, error) { - errorMessage := fmt.Sprintf("failed getting a configuration for Institute Access %s", url) - state.FSM.GoTransition(STATE_LOADING_SERVER) - server, serverErr := state.addInstituteServer(url) - if serverErr != nil { - state.Logger.Error( - fmt.Sprintf( - "Failed adding an institute access server with error: %s", - GetErrorTraceback(serverErr), - ), - ) - state.goBackInternal() - return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} - } - - config, configType, configErr := state.getConfig(server, preferTCP) - if configErr != nil { - state.Logger.Inherit(configErr, - fmt.Sprintf( - "Failed getting an institute access server configuration with error: %s", - GetErrorTraceback(configErr), - ), - ) - return "", "", &types.WrappedErrorMessage{Level: 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 (state *VPNState) GetConfigCustomServer(url string, preferTCP bool) (string, string, error) { - errorMessage := fmt.Sprintf("failed getting a configuration for custom server %s", url) - state.FSM.GoTransition(STATE_LOADING_SERVER) - server, serverErr := state.addCustomServer(url) - - if serverErr != nil { - state.Logger.Error( - fmt.Sprintf( - "Failed adding a custom server with error: %s", - GetErrorTraceback(serverErr), - ), - ) - state.goBackInternal() - return "", "", &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} - } - - config, configType, configErr := state.getConfig(server, preferTCP) - if configErr != nil { - state.Logger.Inherit( - configErr, - fmt.Sprintf( - "Failed getting a custom server with error: %s", - GetErrorTraceback(configErr), - ), - ) - return "", "", &types.WrappedErrorMessage{Level: 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 (state *VPNState) CancelOAuth() error { - errorMessage := "failed to cancel OAuth" - if !state.InFSMState(STATE_OAUTH_STARTED) { - state.Logger.Error("Failed cancelling OAuth, not in the right state") - return &types.WrappedErrorMessage{ - Message: errorMessage, - Err: FSMWrongStateError{ - Got: state.FSM.Current, - Want: STATE_OAUTH_STARTED, - }.CustomError(), - } - } - - currentServer, serverErr := state.Servers.GetCurrentServer() - if serverErr != nil { - state.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 (state *VPNState) ChangeSecureLocation() error { - errorMessage := "failed to change location from the main screen" - - if !state.InFSMState(STATE_NO_SERVER) { - state.Logger.Error("Failed changing secure internet location, not in the right state") - return &types.WrappedErrorMessage{ - Message: errorMessage, - Err: FSMWrongStateError{ - Got: state.FSM.Current, - Want: STATE_NO_SERVER, - }.CustomError(), - } - } - - askLocationErr := state.askSecureLocation() - if askLocationErr != nil { - state.Logger.Error( - fmt.Sprintf( - "Failed changing secure internet location, err: %s", - GetErrorTraceback(askLocationErr), - ), - ) - return &types.WrappedErrorMessage{Message: errorMessage, Err: askLocationErr} - } - - // Go back to the main screen - state.FSM.GoTransitionWithData(STATE_NO_SERVER, state.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 (state *VPNState) GetDiscoOrganizations() (*types.DiscoveryOrganizations, error) { - orgs, orgsErr := state.Discovery.GetOrganizationsList() - if orgsErr != nil { - state.Logger.Warning( - fmt.Sprintf( - "Failed getting discovery organizations, Err: %s", - GetErrorTraceback(orgsErr), - ), - ) - return nil, &types.WrappedErrorMessage{ - Message: "failed getting discovery organizations list", - Err: orgsErr, - } - } - return orgs, nil -} - -// GetDiscoDiscovers 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 (state *VPNState) GetDiscoServers() (*types.DiscoveryServers, error) { - servers, serversErr := state.Discovery.GetServersList() - if serversErr != nil { - state.Logger.Warning( - fmt.Sprintf("Failed getting discovery servers, Err: %s", GetErrorTraceback(serversErr)), - ) - return nil, &types.WrappedErrorMessage{ - Message: "failed getting discovery servers list", - 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 (state *VPNState) SetProfileID(profileID string) error { - errorMessage := "failed to set the profile ID for the current server" - server, serverErr := state.Servers.GetCurrentServer() - if serverErr != nil { - state.Logger.Warning( - fmt.Sprintf( - "Failed setting a profile ID because no server configured, Err: %s", - GetErrorTraceback(serverErr), - ), - ) - state.goBackInternal() - return &types.WrappedErrorMessage{Message: errorMessage, Err: serverErr} - } - - base, baseErr := server.GetBase() - if baseErr != nil { - state.Logger.Error( - fmt.Sprintf("Failed setting a profile ID, Err: %s", GetErrorTraceback(serverErr)), - ) - state.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 (state *VPNState) SetSearchServer() error { - if !state.FSM.HasTransition(STATE_SEARCH_SERVER) { - state.Logger.Warning( - fmt.Sprintf( - "Failed setting search server, wrong state %s", - GetStateName(state.FSM.Current), - ), - ) - return &types.WrappedErrorMessage{ - Message: "failed to set search server", - Err: FSMWrongStateTransitionError{ - Got: state.FSM.Current, - Want: STATE_CONNECTED, - }.CustomError(), - } - } - - state.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 (state *VPNState) SetConnected() error { - errorMessage := "failed to set connected" - if state.InFSMState(STATE_CONNECTED) { - // already connected, show no error - state.Logger.Warning("Already connected") - return nil - } - if !state.FSM.HasTransition(STATE_CONNECTED) { - state.Logger.Warning( - fmt.Sprintf( - "Failed setting connected, wrong state: %s", - GetStateName(state.FSM.Current), - ), - ) - return &types.WrappedErrorMessage{ - Message: errorMessage, - Err: FSMWrongStateTransitionError{ - Got: state.FSM.Current, - Want: STATE_CONNECTED, - }.CustomError(), - } - } - - currentServer, currentServerErr := state.Servers.GetCurrentServer() - if currentServerErr != nil { - state.Logger.Warning( - fmt.Sprintf( - "Failed setting connected, cannot get current server with error: %s", - GetErrorTraceback(currentServerErr), - ), - ) - return &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr} - } - - state.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 (state *VPNState) SetConnecting() error { - errorMessage := "failed to set connecting" - if state.InFSMState(STATE_CONNECTING) { - // already loading connection, show no error - state.Logger.Warning("Already connecting") - return nil - } - if !state.FSM.HasTransition(STATE_CONNECTING) { - state.Logger.Warning( - fmt.Sprintf( - "Failed setting connecting, wrong state: %s", - GetStateName(state.FSM.Current), - ), - ) - return &types.WrappedErrorMessage{ - Message: errorMessage, - Err: FSMWrongStateTransitionError{ - Got: state.FSM.Current, - Want: STATE_CONNECTING, - }.CustomError(), - } - } - - currentServer, currentServerErr := state.Servers.GetCurrentServer() - if currentServerErr != nil { - state.Logger.Warning( - fmt.Sprintf( - "Failed setting connecting, cannot get current server with error: %s", - GetErrorTraceback(currentServerErr), - ), - ) - return &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr} - } - - state.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 (state *VPNState) SetDisconnecting() error { - errorMessage := "failed to set disconnecting" - if state.InFSMState(STATE_DISCONNECTING) { - // already disconnecting, show no error - state.Logger.Warning("Already disconnecting") - return nil - } - if !state.FSM.HasTransition(STATE_DISCONNECTING) { - state.Logger.Warning( - fmt.Sprintf( - "Failed setting disconnecting, wrong state: %s", - GetStateName(state.FSM.Current), - ), - ) - return &types.WrappedErrorMessage{ - Message: errorMessage, - Err: FSMWrongStateTransitionError{ - Got: state.FSM.Current, - Want: STATE_DISCONNECTING, - }.CustomError(), - } - } - - currentServer, currentServerErr := state.Servers.GetCurrentServer() - if currentServerErr != nil { - state.Logger.Warning( - fmt.Sprintf( - "Failed setting disconnected, cannot get current server with error: %s", - GetErrorTraceback(currentServerErr), - ), - ) - return &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr} - } - - state.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 (state *VPNState) SetDisconnected(cleanup bool) error { - errorMessage := "failed to set disconnected" - if state.InFSMState(STATE_DISCONNECTED) { - // already disconnected, show no error - state.Logger.Warning("Already disconnected") - return nil - } - if !state.FSM.HasTransition(STATE_DISCONNECTED) { - state.Logger.Warning( - fmt.Sprintf( - "Failed setting disconnected, wrong state: %s", - GetStateName(state.FSM.Current), - ), - ) - return &types.WrappedErrorMessage{ - Message: errorMessage, - Err: FSMWrongStateTransitionError{ - Got: state.FSM.Current, - Want: STATE_DISCONNECTED, - }.CustomError(), - } - } - - currentServer, currentServerErr := state.Servers.GetCurrentServer() - if currentServerErr != nil { - state.Logger.Warning( - fmt.Sprintf( - "Failed setting disconnect, failed getting current server with error: %s", - GetErrorTraceback(currentServerErr), - ), - ) - return &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr} - } - - if cleanup { - // Do the /disconnect API call and go to disconnected after... - server.Disconnect(currentServer) - } - - state.FSM.GoTransitionWithData(STATE_DISCONNECTED, currentServer, false) - - return nil -} - -// RenewSession renews the session for the current VPN server. -// This logs the user back in. -func (state *VPNState) RenewSession() error { - errorMessage := "failed to renew session" - - currentServer, currentServerErr := state.Servers.GetCurrentServer() - if currentServerErr != nil { - state.Logger.Warning( - fmt.Sprintf( - "Failed getting current server to renew, error: %s", - GetErrorTraceback(currentServerErr), - ), - ) - return &types.WrappedErrorMessage{Message: errorMessage, Err: currentServerErr} - } - - loginErr := state.ensureLogin(currentServer) - if loginErr != nil { - state.Logger.Warning( - fmt.Sprintf( - "Failed logging in server for renew, error: %s", - 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 (state *VPNState) ShouldRenewButton() bool { - if !state.InFSMState(STATE_CONNECTED) && !state.InFSMState(STATE_CONNECTING) && - !state.InFSMState(STATE_DISCONNECTED) && - !state.InFSMState(STATE_DISCONNECTING) { - return false - } - - currentServer, currentServerErr := state.Servers.GetCurrentServer() - - if currentServerErr != nil { - state.Logger.Info( - fmt.Sprintf( - "No server found to renew with err: %s", - GetErrorTraceback(currentServerErr), - ), - ) - return false - } - - return server.ShouldRenewButton(currentServer) -} - -// InFSMState is a helper to check if the FSM is in state `checkState`. -func (state *VPNState) InFSMState(checkState FSMStateID) bool { - return state.FSM.InState(checkState) -} - -// GetErrorCause gets the cause for error `err`. -func GetErrorCause(err error) error { - return types.GetErrorCause(err) -} - -// GetErrorCause gets the level for error `err`. -func GetErrorLevel(err error) types.ErrorLevel { - return types.GetErrorLevel(err) -} - -// GetErrorCause gets the traceback for error `err`. -func GetErrorTraceback(err error) string { - return types.GetErrorTraceback(err) -} - -// GetTranslated gets the translation for `languages` using the current state language. -func (state *VPNState) GetTranslated(languages map[string]string) string { - return util.GetLanguageMatched(languages, state.Language) -} diff --git a/state_test.go b/state_test.go deleted file mode 100644 index c665958..0000000 --- a/state_test.go +++ /dev/null @@ -1,379 +0,0 @@ -package eduvpn - -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/server" - "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") - } - 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 *VPNState) { - // 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 *VPNState, -) { - 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 := &VPNState{} - - registerErr := state.Register( - "org.eduvpn.app.linux", - "configstest", - func(old FSMStateID, new FSMStateID, data interface{}) { - stateCallback(t, old, new, data, state) - }, - false, - ) - if registerErr != nil { - t.Fatalf("Register error: %v", registerErr) - } - - _, _, 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 := &VPNState{} - configDirectory := "test_oauth_parameters" - - registerErr := state.Register( - "org.eduvpn.app.linux", - configDirectory, - func(oldState FSMStateID, newState FSMStateID, data interface{}) { - if newState == STATE_OAUTH_STARTED { - baseURL := "http://127.0.0.1:8000/callback" - url, err := httpw.HTTPConstructURL(baseURL, parameters) - if err != nil { - t.Fatalf( - "Error: Constructing url %s with parameters %s", - baseURL, - fmt.Sprint(parameters), - ) - } - go func() { - _, getErr := http.Get(url) - if getErr != nil { - t.Logf("HTTP GET error: %v", getErr) - } - }() - } - }, - false, - ) - if registerErr != nil { - t.Fatalf("Register error: %v", registerErr) - } - _, _, configErr := state.GetConfigCustomServer(serverURI, false) - - var wrappedErr *types.WrappedErrorMessage - - // We ensure the error is of a wrappedErrorMessage - if !errors.As(configErr, &wrappedErr) { - t.Fatalf("error %T = %v, wantErr %T", configErr, configErr, 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 - ) - - tests := []struct { - expectedErr interface{} - parameters httpw.URLParameters - }{ - {&failedCallbackParameterError, httpw.URLParameters{}}, - {&failedCallbackParameterError, httpw.URLParameters{"code": "42"}}, - {&failedCallbackStateMatchError, httpw.URLParameters{"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 := &VPNState{} - - registerErr := state.Register( - "org.eduvpn.app.linux", - "configsexpired", - func(old FSMStateID, new FSMStateID, data interface{}) { - stateCallback(t, old, new, data, state) - }, - false, - ) - if registerErr != nil { - t.Fatalf("Register error: %v", registerErr) - } - - _, _, 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 := &VPNState{} - - registerErr := state.Register( - "org.eduvpn.app.linux", - "configsinvalid", - func(old FSMStateID, new FSMStateID, data interface{}) { - stateCallback(t, old, new, data, state) - }, - false, - ) - if registerErr != nil { - t.Fatalf("Register error: %v", registerErr) - } - - _, _, 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 := &VPNState{} - - registerErr := state.Register( - "org.eduvpn.app.linux", - "configscancelprofile", - func(old FSMStateID, new FSMStateID, data interface{}) { - stateCallback(t, old, new, data, state) - }, - false, - ) - if registerErr != nil { - t.Fatalf("Register error: %v", registerErr) - } - - _, _, 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 := &VPNState{} - - registerErr := state.Register( - "org.eduvpn.app.linux", - "configsprefertcp", - func(old FSMStateID, new FSMStateID, data interface{}) { - stateCallback(t, old, new, data, state) - }, - false, - ) - if registerErr != nil { - t.Fatalf("Register error: %v", registerErr) - } - - // 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) - } -} -- cgit v1.2.3