arblant
firefox-vpn-client / core / internal / proxy / failure.go
failure.go139 lines · 5.0 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139
package proxy

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"errors"
	"io"
	"net"
	"slices"
	"strings"
	"syscall"

	"github.com/armanlife/firefox-vpn-client/core/internal/accounts"
	"github.com/armanlife/firefox-vpn-client/core/internal/fxa"
	"github.com/armanlife/firefox-vpn-client/core/internal/guardian"
)

// Failure codes say why a connection through Mozilla failed, so the apps
// can tell the user in their language what is wrong instead of always
// blaming the internet; the error text stays for the details. The status
// view's wake_error_code and the connection test's error_code carry them.
const (
	// FailureNoNetwork: this device has no working network (no route, or
	// names do not resolve).
	FailureNoNetwork = "no_network"
	// FailureUnreachable: Mozilla's servers did not answer or the
	// connection was cut (timeouts, resets, failed TLS handshakes), as on
	// a network that blocks them.
	FailureUnreachable = "unreachable"
	// FailureRefused: a Mozilla proxy server refused the tunnel.
	FailureRefused = "refused"
	// FailureLocationRefused: the server of a location the user chose
	// kept refusing the tunnel (CONNECT 403 or 407) with a proxy pass
	// fetched after the first refusal: Mozilla does not let this account
	// use this location (LocationRefusedError).
	FailureLocationRefused = "location_refused"
	// FailureSignIn: the account must sign in again.
	FailureSignIn = "sign_in"
	// FailureQuota: the account's monthly data is used up.
	FailureQuota = "quota"
	// FailureNotEntitled: Mozilla does not let the account use the VPN.
	FailureNotEntitled = "not_entitled"
	// FailureRegion: Mozilla refuses requests from this region (HTTP 451).
	FailureRegion = "region"
	// FailureServer: Mozilla's service answered with a server error.
	FailureServer = "server"
	// FailureNoAccount: no account can be used now.
	FailureNoAccount = "no_account"
	// FailureTestSite: the tunnel opened, but the test site failed.
	FailureTestSite = "test_site"
	// FailureOther: anything else; the error text says what.
	FailureOther = "other"
)

// FailureCode classifies err, a failure to reach a site through Mozilla,
// into one of the Failure codes; "" for nil.
func FailureCode(err error) string {
	if err == nil {
		return ""
	}
	var noAccount *accounts.NoUsableAccountError
	var locationErr *LocationRefusedError
	var connectErr *proxyConnectHTTPError
	var dnsErr *net.DNSError
	var errno syscall.Errno
	var certErr *tls.CertificateVerificationError
	var unknownAuthority x509.UnknownAuthorityError
	var hostnameErr x509.HostnameError
	var netErr net.Error
	switch {
	// The account's problems first: their errors may wrap the network
	// error that a renewal also met.
	case errors.Is(err, fxa.ErrReauthRequired):
		return FailureSignIn
	case errors.Is(err, guardian.ErrQuotaExceeded):
		return FailureQuota
	// Guardian's 403 also reads as an invalid token.
	case errors.Is(err, guardian.ErrNotEntitled):
		return FailureNotEntitled
	case errors.Is(err, guardian.ErrTokenInvalid):
		return FailureSignIn
	case errors.Is(err, guardian.ErrRegionUnavailable):
		return FailureRegion
	case errors.As(err, &noAccount):
		return FailureNoAccount
	case errors.Is(err, guardian.ErrServer):
		return FailureServer
	case errors.As(err, &locationErr):
		return FailureLocationRefused
	// A rebuild that waits carries its cause, which decides.
	case errors.As(err, &connectErr):
		return FailureRefused
	case errors.As(err, &dnsErr):
		return FailureNoNetwork
	case errors.As(err, &errno) && slices.Contains(noNetworkErrnos, errno):
		return FailureNoNetwork
	case errors.As(err, &errno) && slices.Contains(cutErrnos, errno),
		errors.As(err, &certErr), errors.As(err, &unknownAuthority), errors.As(err, &hostnameErr),
		errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF),
		errors.Is(err, context.DeadlineExceeded),
		errors.As(err, &netErr) && netErr.Timeout():
		return FailureUnreachable
	}
	return FailureOther
}

// LocationRefusedError is a CONNECT refusal (403 or 407) by the server of a
// location the user chose, with a proxy pass fetched after the first
// refusal: a new pass did not help, so Mozilla most likely does not let
// this account use the location.
type LocationRefusedError struct {
	Err error
}

const locationRefusedText = "Mozilla does not let this account use this location"

func (e *LocationRefusedError) Error() string {
	return locationRefusedText + ": " + e.Err.Error()
}

func (e *LocationRefusedError) Unwrap() error { return e.Err }

// Redacted is the error without the response body, for logs that are not
// verbose.
func (e *LocationRefusedError) Redacted() string {
	var connectErr *proxyConnectHTTPError
	if errors.As(e.Err, &connectErr) {
		return locationRefusedText + ": " + connectErr.Redacted()
	}
	return e.Error()
}

// SessionReplaced reports an error text of a request whose upstream
// session was closed under it because another replaced it (Go's HTTP/2
// client says "client connection force closed").
func SessionReplaced(text string) bool {
	return strings.Contains(text, "client connection force closed") || strings.Contains(text, "use of closed network connection")
}