safing-portmaster/netenv/online-status.go
2020-04-02 17:08:02 +02:00

353 lines
8.9 KiB
Go

package netenv
import (
"context"
"io/ioutil"
"net"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/network/netutils"
"github.com/tevino/abool"
)
// OnlineStatus represent a state of connectivity to the Internet.
type OnlineStatus uint8
// Online Status Values
const (
StatusUnknown OnlineStatus = 0
StatusOffline OnlineStatus = 1
StatusLimited OnlineStatus = 2 // local network only
StatusPortal OnlineStatus = 3 // there seems to be an internet connection, but we are being intercepted, possibly by a captive portal
StatusSemiOnline OnlineStatus = 4 // we seem to online, but without full connectivity
StatusOnline OnlineStatus = 5
)
// Online Status and Resolver
const (
HTTPTestURL = "http://detectportal.firefox.com/success.txt"
HTTPExpectedContent = "success"
HTTPSTestURL = "https://one.one.one.one/"
ResolverTestFqdn = "one.one.one.one."
ResolverTestRRType = dns.TypeA
ResolverTestExpectedResponse = "1.1.1.1"
)
var (
parsedHTTPTestURL *url.URL
parsedHTTPSTestURL *url.URL
)
func init() {
var err error
parsedHTTPTestURL, err = url.Parse(HTTPTestURL)
if err != nil {
panic(err)
}
parsedHTTPSTestURL, err = url.Parse(HTTPSTestURL)
if err != nil {
panic(err)
}
}
// IsOnlineStatusTestDomain checks whether the given fqdn is used for testing online status.
func IsOnlineStatusTestDomain(domain string) bool {
switch domain {
case "detectportal.firefox.com.":
return true
case "one.one.one.one.":
return true
}
return false
}
// GetResolverTestingRequestData returns request information that should be used to test DNS resolvers for availability and basic correct behaviour.
func GetResolverTestingRequestData() (fqdn string, rrType uint16, expectedResponse string) {
return ResolverTestFqdn, ResolverTestRRType, ResolverTestExpectedResponse
}
func (os OnlineStatus) String() string {
switch os {
default:
return "Unknown"
case StatusOffline:
return "Offline"
case StatusLimited:
return "Limited"
case StatusPortal:
return "Portal"
case StatusSemiOnline:
return "SemiOnline"
case StatusOnline:
return "Online"
}
}
var (
onlineStatus *int32
onlineStatusQuickCheck = abool.NewBool(false)
onlineStatusInvestigationTrigger = make(chan struct{}, 1)
onlineStatusInvestigationInProgress = abool.NewBool(false)
onlineStatusInvestigationWg sync.WaitGroup
captivePortalURL string
captivePortalLock sync.Mutex
)
func init() {
var onlineStatusValue int32
onlineStatus = &onlineStatusValue
}
// Online returns true if online status is either SemiOnline or Online.
func Online() bool {
return onlineStatusQuickCheck.IsSet()
}
// GetOnlineStatus returns the current online stats.
func GetOnlineStatus() OnlineStatus {
return OnlineStatus(atomic.LoadInt32(onlineStatus))
}
// CheckAndGetOnlineStatus triggers a new online status check and returns the result
func CheckAndGetOnlineStatus() OnlineStatus {
// trigger new investigation
triggerOnlineStatusInvestigation()
// wait for completion
onlineStatusInvestigationWg.Wait()
// return current status
return GetOnlineStatus()
}
func updateOnlineStatus(status OnlineStatus, portalURL, comment string) {
changed := false
// status
currentStatus := atomic.LoadInt32(onlineStatus)
if status != OnlineStatus(currentStatus) && atomic.CompareAndSwapInt32(onlineStatus, currentStatus, int32(status)) {
// status changed!
onlineStatusQuickCheck.SetTo(
status == StatusOnline || status == StatusSemiOnline,
)
changed = true
}
// captive portal
captivePortalLock.Lock()
defer captivePortalLock.Unlock()
if portalURL != captivePortalURL {
captivePortalURL = portalURL
changed = true
}
// trigger event
if changed {
module.TriggerEvent(onlineStatusChangedEvent, nil)
if status == StatusPortal {
log.Infof(`network: setting online status to %s at "%s" (%s)`, status, captivePortalURL, comment)
} else {
log.Infof("network: setting online status to %s (%s)", status, comment)
}
triggerNetworkChangeCheck()
}
}
// GetCaptivePortalURL returns the current captive portal url as a string.
func GetCaptivePortalURL() string {
captivePortalLock.Lock()
defer captivePortalLock.Unlock()
return captivePortalURL
}
// ReportSuccessfulConnection hints the online status monitoring system that a connection attempt was successful.
func ReportSuccessfulConnection() {
if !onlineStatusQuickCheck.IsSet() {
triggerOnlineStatusInvestigation()
}
}
// ReportFailedConnection hints the online status monitoring system that a connection attempt has failed. This function has extremely low overhead and may be called as much as wanted.
func ReportFailedConnection() {
if onlineStatusQuickCheck.IsSet() {
triggerOnlineStatusInvestigation()
}
}
func triggerOnlineStatusInvestigation() {
if onlineStatusInvestigationInProgress.SetToIf(false, true) {
onlineStatusInvestigationWg.Add(1)
}
select {
case onlineStatusInvestigationTrigger <- struct{}{}:
default:
}
}
func monitorOnlineStatus(ctx context.Context) error {
for {
// wait for trigger
if GetOnlineStatus() == StatusOnline {
select {
case <-ctx.Done():
return nil
case <-onlineStatusInvestigationTrigger:
case <-time.After(1 * time.Minute):
}
} else {
select {
case <-ctx.Done():
return nil
case <-onlineStatusInvestigationTrigger:
case <-time.After(1 * time.Second):
}
}
// enable waiting
if onlineStatusInvestigationInProgress.SetToIf(false, true) {
onlineStatusInvestigationWg.Add(1)
}
checkOnlineStatus(ctx)
// finished!
onlineStatusInvestigationWg.Done()
onlineStatusInvestigationInProgress.UnSet()
}
}
func checkOnlineStatus(ctx context.Context) {
// TODO: implement more methods
/*status, err := getConnectivityStateFromDbus()
if err != nil {
log.Warningf("environment: could not get connectivity: %s", err)
setConnectivity(StatusUnknown)
return StatusUnknown
}*/
// 1) check for addresses
ipv4, ipv6, err := GetAssignedAddresses()
if err != nil {
log.Warningf("network: failed to get assigned network addresses: %s", err)
} else {
var lan bool
for _, ip := range ipv4 {
switch netutils.ClassifyIP(ip) {
case netutils.SiteLocal:
lan = true
case netutils.Global:
// we _are_ the Internet ;)
updateOnlineStatus(StatusOnline, "", "global IPv4 interface detected")
return
}
}
for _, ip := range ipv6 {
switch netutils.ClassifyIP(ip) {
case netutils.SiteLocal, netutils.Global:
// IPv6 global addresses are also used in local networks
lan = true
}
}
if !lan {
updateOnlineStatus(StatusOffline, "", "no local or global interfaces detected")
return
}
}
// 2) try a http request
// TODO: find (array of) alternatives to detectportal.firefox.com
// TODO: find something about usage terms of detectportal.firefox.com
client := &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
LocalAddr: getLocalAddr("tcp"),
DualStack: true,
}).DialContext,
DisableKeepAlives: true,
DisableCompression: true,
WriteBufferSize: 1024,
ReadBufferSize: 1024,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 5 * time.Second,
}
request := (&http.Request{
Method: "GET",
URL: parsedHTTPTestURL,
Close: true,
}).WithContext(ctx)
response, err := client.Do(request)
if err != nil {
updateOnlineStatus(StatusLimited, "", "http request failed")
return
}
defer response.Body.Close()
// check location
portalURL, err := response.Location()
if err == nil {
updateOnlineStatus(StatusPortal, portalURL.String(), "http request succeeded with redirect")
return
}
// read the body
data, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Warningf("network: failed to read http body of captive portal testing response: %s", err)
// assume we are online nonetheless
updateOnlineStatus(StatusOnline, "", "http request succeeded, albeit failing later")
return
}
// check body contents
if strings.TrimSpace(string(data)) == HTTPExpectedContent {
updateOnlineStatus(StatusOnline, "", "http request succeeded")
} else {
// something is interfering with the website content
// this might be a weird captive portal, just direct the user there
updateOnlineStatus(StatusPortal, "detectportal.firefox.com", "http request succeeded, response content not as expected")
}
// 3) try a https request
request = (&http.Request{
Method: "HEAD",
URL: parsedHTTPSTestURL,
Close: true,
}).WithContext(ctx)
// only test if we can get the headers
response, err = client.Do(request)
if err != nil {
// if we fail, something is really weird
updateOnlineStatus(StatusSemiOnline, "", "http request failed")
return
}
defer response.Body.Close()
// finally
updateOnlineStatus(StatusOnline, "", "all checks successful")
}