safing-portmaster/spn/access/client.go
Daniel Hååvi 80664d1a27
Restructure modules ()
* Move portbase into monorepo

* Add new simple module mgr

* [WIP] Switch to new simple module mgr

* Add StateMgr and more worker variants

* [WIP] Switch more modules

* [WIP] Switch more modules

* [WIP] swtich more modules

* [WIP] switch all SPN modules

* [WIP] switch all service modules

* [WIP] Convert all workers to the new module system

* [WIP] add new task system to module manager

* [WIP] Add second take for scheduling workers

* [WIP] Add FIXME for bugs in new scheduler

* [WIP] Add minor improvements to scheduler

* [WIP] Add new worker scheduler

* [WIP] Fix more bug related to new module system

* [WIP] Fix start handing of the new module system

* [WIP] Improve startup process

* [WIP] Fix minor issues

* [WIP] Fix missing subsystem in settings

* [WIP] Initialize managers in constructor

* [WIP] Move module event initialization to constrictors

* [WIP] Fix setting for enabling and disabling the SPN module

* [WIP] Move API registeration into module construction

* [WIP] Update states mgr for all modules

* [WIP] Add CmdLine operation support

* Add state helper methods to module group and instance

* Add notification and module status handling to status package

* Fix starting issues

* Remove pilot widget and update security lock to new status data

* Remove debug logs

* Improve http server shutdown

* Add workaround for cleanly shutting down firewall+netquery

* Improve logging

* Add syncing states with notifications for new module system

* Improve starting, stopping, shutdown; resolve FIXMEs/TODOs

* [WIP] Fix most unit tests

* Review new module system and fix minor issues

* Push shutdown and restart events again via API

* Set sleep mode via interface

* Update example/template module

* [WIP] Fix spn/cabin unit test

* Remove deprecated UI elements

* Make log output more similar for the logging transition phase

* Switch spn hub and observer cmds to new module system

* Fix log sources

* Make worker mgr less error prone

* Fix tests and minor issues

* Fix observation hub

* Improve shutdown and restart handling

* Split up big connection.go source file

* Move varint and dsd packages to structures repo

* Improve expansion test

* Fix linter warnings

* Fix interception module on windows

* Fix linter errors

---------

Co-authored-by: Vladimir Stoilov <vladimir@safing.io>
2024-08-09 18:15:48 +03:00

543 lines
14 KiB
Go

package access
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/safing/portmaster/base/database"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/spn/access/account"
"github.com/safing/portmaster/spn/access/token"
"github.com/safing/structures/dsd"
)
// Client URLs.
const (
AccountServer = "https://api.account.safing.io"
LoginPath = "/api/v1/authenticate"
UserProfilePath = "/api/v1/user/profile"
TokenRequestSetupPath = "/api/v1/token/request/setup" //nolint:gosec
TokenRequestIssuePath = "/api/v1/token/request/issue" //nolint:gosec
HealthCheckPath = "/api/v1/health"
defaultDataFormat = dsd.CBOR
defaultRequestTimeout = 30 * time.Second
)
var (
accountClient = &http.Client{}
clientRequestLock sync.Mutex
// EnableAfterLogin automatically enables the SPN subsystem/module after login.
EnableAfterLogin = true
)
type clientRequestOptions struct {
method string
url string
send interface{}
recv interface{}
requestTimeout time.Duration
dataFormat uint8
setAuthToken bool
requireNextAuthToken bool
logoutOnAuthError bool
requestSetupFunc func(*http.Request) error
}
func makeClientRequest(opts *clientRequestOptions) (resp *http.Response, err error) {
// Get request timeout.
if opts.requestTimeout == 0 {
opts.requestTimeout = defaultRequestTimeout
}
// Get context for request.
var ctx context.Context
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(module.mgr.Ctx(), opts.requestTimeout)
defer cancel()
// Create new request.
request, err := http.NewRequestWithContext(ctx, opts.method, opts.url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request structure: %w", err)
}
// Prepare body and content type.
if opts.dataFormat == dsd.AUTO {
opts.dataFormat = defaultDataFormat
}
if opts.send != nil {
// Add data to body.
err = dsd.DumpToHTTPRequest(request, opts.send, opts.dataFormat)
if err != nil {
return nil, fmt.Errorf("failed to add request body: %w", err)
}
} else {
// Set requested HTTP response format.
_, err = dsd.RequestHTTPResponseFormat(request, opts.dataFormat)
if err != nil {
return nil, fmt.Errorf("failed to set requested response format: %w", err)
}
}
// Get auth token to apply to request.
var authToken *AuthTokenRecord
if opts.setAuthToken {
authToken, err = GetAuthToken()
if err != nil {
return nil, ErrNotLoggedIn
}
authToken.Token.ApplyTo(request)
}
// Do any additional custom request setup.
if opts.requestSetupFunc != nil {
err = opts.requestSetupFunc(request)
if err != nil {
return nil, err
}
}
// Make request.
resp, err = accountClient.Do(request)
if err != nil {
updateUserWithFailedRequest(account.StatusConnectionError, false)
tokenIssuerFailed()
return nil, fmt.Errorf("http request failed: %w", err)
}
log.Debugf("spn/access: request to %s returned %s", request.URL, resp.Status)
defer func() {
_ = resp.Body.Close()
}()
// Handle request error.
switch resp.StatusCode {
case http.StatusOK, http.StatusCreated:
// All good!
case account.StatusInvalidAuth, account.StatusInvalidDevice:
// Wrong username / password.
updateUserWithFailedRequest(resp.StatusCode, true)
return resp, ErrInvalidCredentials
case account.StatusReachedDeviceLimit:
// Device limit is reached.
updateUserWithFailedRequest(resp.StatusCode, true)
return resp, ErrDeviceLimitReached
case account.StatusDeviceInactive:
// Device is locked.
updateUserWithFailedRequest(resp.StatusCode, true)
return resp, ErrDeviceIsLocked
default:
updateUserWithFailedRequest(account.StatusUnknownError, false)
tokenIssuerFailed()
return resp, fmt.Errorf("unexpected reply: [%d] %s", resp.StatusCode, resp.Status)
}
// Save next auth token.
if authToken != nil {
err = authToken.Update(resp)
if err != nil {
if errors.Is(err, account.ErrMissingToken) {
if opts.requireNextAuthToken {
return resp, fmt.Errorf("failed to save next auth token: %w", err)
}
} else {
return resp, fmt.Errorf("failed to save next auth token: %w", err)
}
}
} else if opts.requireNextAuthToken {
return resp, fmt.Errorf("failed to save next auth token: %w", account.ErrMissingToken)
}
// Load response data.
if opts.recv != nil {
_, err = dsd.LoadFromHTTPResponse(resp, opts.recv)
if err != nil {
return resp, fmt.Errorf("failed to parse response: %w", err)
}
}
tokenIssuerIsFailing.UnSet()
return resp, nil
}
func updateUserWithFailedRequest(statusCode int, disableSubscription bool) {
// Get user from database.
user, err := GetUser()
if err != nil {
if !errors.Is(err, ErrNotLoggedIn) {
log.Warningf("spn/access: failed to get user to update with failed request: %s", err)
}
return
}
func() {
user.Lock()
defer user.Unlock()
// Ignore update if user state is undefined or logged out.
if user.State == "" || user.State == account.UserStateLoggedOut {
return
}
// Disable the subscription if desired.
if disableSubscription && user.Subscription != nil {
user.Subscription.EndsAt = nil
}
// Update view with the status code and save user.
user.UpdateView(statusCode)
}()
err = user.Save()
if err != nil {
log.Warningf("spn/access: failed to save user after update with failed request: %s", err)
}
}
// Login logs the user into the SPN account with the given username and password.
func Login(username, password string) (user *UserRecord, code int, err error) {
clientRequestLock.Lock()
defer clientRequestLock.Unlock()
// Trigger account update when done.
defer module.EventAccountUpdate.Submit(struct{}{})
// Get previous user.
previousUser, err := GetUser()
if err != nil {
if !errors.Is(err, ErrNotLoggedIn) {
log.Warningf("spn/access: failed to get previous for re-login: %s", err)
}
previousUser = nil
}
// Create request options.
userAccount := &account.User{}
requestOptions := &clientRequestOptions{
method: http.MethodPost,
url: AccountServer + LoginPath,
recv: userAccount,
dataFormat: dsd.JSON,
requestSetupFunc: func(request *http.Request) error {
// Add username and password.
request.SetBasicAuth(username, password)
// Try to reuse the device ID, if the username matches the previous user.
if previousUser != nil && username == previousUser.Username {
request.Header.Set(account.AuthHeaderDevice, previousUser.Device.ID)
}
return nil
},
}
// Make request.
resp, err := makeClientRequest(requestOptions) //nolint:bodyclose // Body is closed in function.
if err != nil {
if resp != nil && resp.StatusCode == account.StatusInvalidDevice {
// Try again without the previous device ID.
previousUser = nil
log.Info("spn/access: retrying log in without re-using previous device ID")
resp, err = makeClientRequest(requestOptions) //nolint:bodyclose // Body is closed in function.
}
if err != nil {
if resp != nil {
return nil, resp.StatusCode, err
}
return nil, 0, err
}
}
// Save new user.
now := time.Now()
user = &UserRecord{
User: userAccount,
LoggedInAt: &now,
}
user.UpdateView(0)
err = user.Save()
if err != nil {
return user, resp.StatusCode, fmt.Errorf("failed to save new user profile: %w", err)
}
// Save initial auth token.
err = SaveNewAuthToken(user.Device.ID, resp)
if err != nil {
return user, resp.StatusCode, fmt.Errorf("failed to save initial auth token: %w", err)
}
// Enable the SPN right after login.
if user.MayUseSPN() && EnableAfterLogin {
enableSPN()
}
log.Infof("spn/access: logged in as %q on device %q", user.Username, user.Device.Name)
return user, resp.StatusCode, nil
}
// Logout logs the user out of the SPN account.
// Specify "shallow" to keep user data in order to display data in the
// UI - preferably when logged out be the server.
// Specify "purge" in order to fully delete all user account data, even
// the device ID so that logging in again will create a new device.
func Logout(shallow, purge bool) error {
clientRequestLock.Lock()
defer clientRequestLock.Unlock()
// Trigger account update when done.
defer module.EventAccountUpdate.Submit(struct{}{})
// Clear caches.
clearUserCaches()
// Clear tokens.
clearTokens()
// Delete auth token.
err := db.Delete(authTokenRecordKey)
if err != nil && !errors.Is(err, database.ErrNotFound) {
return fmt.Errorf("failed to delete auth token: %w", err)
}
// Delete all user data if purging.
if purge {
err := db.Delete(userRecordKey)
if err != nil && !errors.Is(err, database.ErrNotFound) {
return fmt.Errorf("failed to delete user: %w", err)
}
// Disable SPN when the user logs out directly.
disableSPN()
log.Info("spn/access: logged out and purged data")
return nil
}
// Else, just update the user.
user, err := GetUser()
if err != nil {
if errors.Is(err, ErrNotLoggedIn) {
return nil
}
return fmt.Errorf("failed to load user for logout: %w", err)
}
func() {
user.Lock()
defer user.Unlock()
if shallow {
// Shallow logout: User stays logged in the UI to display status when
// logged out from the Portmaster or Customer Hub.
user.User.State = account.UserStateLoggedOut
} else {
// Proper logout: User is logged out from UI.
// Reset all user data, except for username and device ID in order to log
// into the same device again.
user.User = &account.User{
Username: user.Username,
Device: &account.Device{
ID: user.Device.ID,
},
}
user.LoggedInAt = &time.Time{}
}
user.UpdateView(0)
}()
err = user.Save()
if err != nil {
return fmt.Errorf("failed to save user for logout: %w", err)
}
if shallow {
log.Info("spn/access: logged out shallow")
} else {
log.Info("spn/access: logged out")
// Disable SPN when the user logs out directly.
disableSPN()
}
return nil
}
// UpdateUser fetches the current user information from the server.
func UpdateUser() (user *UserRecord, statusCode int, err error) {
clientRequestLock.Lock()
defer clientRequestLock.Unlock()
// Trigger account update when done.
defer module.EventAccountUpdate.Submit(struct{}{})
// Create request options.
userData := &account.User{}
requestOptions := &clientRequestOptions{
method: http.MethodGet,
url: AccountServer + UserProfilePath,
recv: userData,
dataFormat: dsd.JSON,
setAuthToken: true,
requireNextAuthToken: true,
logoutOnAuthError: true,
}
// Make request.
resp, err := makeClientRequest(requestOptions) //nolint:bodyclose // Body is closed in function.
if err != nil {
if resp != nil {
return nil, resp.StatusCode, err
}
return nil, 0, err
}
// Save to previous user, if exists.
previousUser, err := GetUser()
if err == nil {
func() {
previousUser.Lock()
defer previousUser.Unlock()
previousUser.User = userData
previousUser.UpdateView(resp.StatusCode)
}()
err := previousUser.Save()
if err != nil {
log.Warningf("spn/access: failed to save updated user profile: %s", err)
}
// Notify user of nearing end of package.
notifyOfPackageEnd(previousUser)
log.Infof("spn/access: got user profile, updated existing")
return previousUser, resp.StatusCode, nil
}
// Else, save as new user.
now := time.Now()
newUser := &UserRecord{
User: userData,
LoggedInAt: &now,
}
newUser.UpdateView(resp.StatusCode)
err = newUser.Save()
if err != nil {
log.Warningf("spn/access: failed to save new user profile: %s", err)
}
// Notify user of nearing end of package.
notifyOfPackageEnd(newUser)
log.Infof("spn/access: got user profile, saved as new")
return newUser, resp.StatusCode, nil
}
// UpdateTokens fetches more tokens for handlers that need it.
func UpdateTokens() error {
clientRequestLock.Lock()
defer clientRequestLock.Unlock()
// Check if the user may request tokens.
user, err := GetUser()
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
if !user.MayUseTheSPN() {
return ErrMayNotUseSPN
}
// Create setup request, return if not required.
setupRequest, setupRequired := token.CreateSetupRequest()
var setupResponse *token.SetupResponse
if setupRequired {
// Request setup data.
setupResponse = &token.SetupResponse{}
_, err := makeClientRequest(&clientRequestOptions{ //nolint:bodyclose // Body is closed in function.
method: http.MethodPost,
url: AccountServer + TokenRequestSetupPath,
send: setupRequest,
recv: setupResponse,
dataFormat: dsd.MsgPack,
setAuthToken: true,
logoutOnAuthError: true,
})
if err != nil {
return fmt.Errorf("failed to request setup data: %w", err)
}
}
// Create request for issuing new tokens.
tokenRequest, requestRequired, err := token.CreateTokenRequest(setupResponse)
if err != nil {
return fmt.Errorf("failed to create token request: %w", err)
}
if !requestRequired {
return nil
}
// Request issuing new tokens.
issuedTokens := &token.IssuedTokens{}
_, err = makeClientRequest(&clientRequestOptions{ //nolint:bodyclose // Body is closed in function.
method: http.MethodPost,
url: AccountServer + TokenRequestIssuePath,
send: tokenRequest,
recv: issuedTokens,
dataFormat: dsd.MsgPack,
setAuthToken: true,
logoutOnAuthError: true,
})
if err != nil {
return fmt.Errorf("failed to request tokens: %w", err)
}
// Save tokens to handlers.
err = token.ProcessIssuedTokens(issuedTokens)
if err != nil {
return fmt.Errorf("failed to process issued tokens: %w", err)
}
// Log new status.
regular, fallback := GetTokenAmount(ExpandAndConnectZones)
log.Infof(
"spn/access: got new tokens, now at %d regular and %d fallback tokens for expand and connect",
regular,
fallback,
)
return nil
}
var (
lastHealthCheckExpires time.Time
lastHealthCheckLock sync.Mutex
lastHealthCheckValidityDuration = 30 * time.Second
)
func healthCheck() (ok bool) {
lastHealthCheckLock.Lock()
defer lastHealthCheckLock.Unlock()
// Return current value if recently checked.
if time.Now().Before(lastHealthCheckExpires) {
return tokenIssuerIsFailing.IsNotSet()
}
// Check health.
_, err := makeClientRequest(&clientRequestOptions{ //nolint:bodyclose // Body is closed in function.
method: http.MethodGet,
url: AccountServer + HealthCheckPath,
})
if err != nil {
log.Warningf("spn/access: token issuer health check failed: %s", err)
}
// Update health check expiry.
lastHealthCheckExpires = time.Now().Add(lastHealthCheckValidityDuration)
return tokenIssuerIsFailing.IsNotSet()
}