safing-portmaster/service/netenv/online-status.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

565 lines
16 KiB
Go

package netenv
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"sync"
"sync/atomic"
"time"
"github.com/tevino/abool"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/notifications"
"github.com/safing/portmaster/service/mgr"
"github.com/safing/portmaster/service/network/netutils"
"github.com/safing/portmaster/service/updates"
)
// 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.
var (
PortalTestIP = net.IPv4(192, 0, 2, 1)
PortalTestURL = fmt.Sprintf("http://%s/", PortalTestIP)
// IP address -> 100.127.247.245 is a special ip used by the android VPN service. Must be ignored during online check.
IgnoreIPsInOnlineStatusCheck = []net.IP{net.IPv4(100, 127, 247, 245)}
DNSTestDomain = "online-check.safing.io."
DNSTestExpectedIP = net.IPv4(0, 65, 67, 75) // Ascii: \0ACK
DNSTestQueryFunc func(ctx context.Context, fdqn string) (ips []net.IP, ok bool, err error)
ConnectedToSPN = abool.New()
ConnectedToDNS = abool.New()
// SpecialCaptivePortalDomain is the domain name used to point to the detected captive portal IP
// or the captive portal test IP. The default value should be overridden by the resolver package,
// which defines the custom internal domain name to use.
SpecialCaptivePortalDomain = "captiveportal.invalid."
// ConnectivityDomains holds all connectivity domains. This slice must not be modified.
ConnectivityDomains = []string{
SpecialCaptivePortalDomain,
// Windows
"dns.msftncsi.com.", // DNS Check
"msftncsi.com.", // Older
"www.msftncsi.com.",
"microsoftconnecttest.com.", // Newer
"www.microsoftconnecttest.com.",
"ipv6.microsoftconnecttest.com.",
// https://de.wikipedia.org/wiki/Captive_Portal
// https://docs.microsoft.com/en-us/windows-hardware/drivers/mobilebroadband/captive-portals
// TODO: read value from registry: HKLM:\SYSTEM\CurrentControlSet\Services\NlaSvc\Parameters\Internet
// Apple
"captive.apple.com.",
// https://de.wikipedia.org/wiki/Captive_Portal
// Linux
"connectivity-check.ubuntu.com.", // Ubuntu
"nmcheck.gnome.org.", // Gnome DE
"network-test.debian.org.", // Debian
"204.pop-os.org.", // Pop OS
"conncheck.opensuse.org.", // OpenSUSE
"ping.archlinux.org", // Arch
// There are probably a lot more domains for all the Linux Distro/DE Variants. Please raise issues and/or submit PRs!
// https://github.com/solus-project/budgie-desktop/issues/807
// https://www.lguruprasad.in/blog/2015/07/21/enabling-captive-portal-detection-in-gnome-3-14-on-debian-jessie/
// TODO: read value from NetworkManager config: /etc/NetworkManager/conf.d/*.conf
// Android
"connectivitycheck.gstatic.com.",
// https://de.wikipedia.org/wiki/Captive_Portal
// Other
"neverssl.com.", // Common Community Service
"detectportal.firefox.com.", // Firefox
}
parsedPortalTestURL *url.URL
)
func prepOnlineStatus() (err error) {
parsedPortalTestURL, err = url.Parse(PortalTestURL)
return err
}
// IsConnectivityDomain checks whether the given domain (fqdn) is used for any
// connectivity related network connections and should always be resolved using
// the network assigned DNS server.
func IsConnectivityDomain(domain string) bool {
if domain == "" {
return false
}
for _, connectivityDomain := range ConnectivityDomains {
if domain == connectivityDomain {
return true
}
}
// Check for captive portal domain.
captivePortal := GetCaptivePortal()
if captivePortal.Domain != "" &&
domain == captivePortal.Domain {
return true
}
return false
}
func (os OnlineStatus) String() string {
switch os {
case StatusOffline:
return "Offline"
case StatusLimited:
return "Limited"
case StatusPortal:
return "Portal"
case StatusSemiOnline:
return "SemiOnline"
case StatusOnline:
return "Online"
case StatusUnknown:
fallthrough
default:
return "Unknown"
}
}
var (
onlineStatus *int32
onlineStatusQuickCheck = abool.NewBool(false)
onlineStatusInvestigationTrigger = make(chan struct{}, 1)
onlineStatusInvestigationInProgress = abool.NewBool(false)
onlineStatusInvestigationWg sync.WaitGroup
onlineStatusNotification *notifications.Notification
captivePortal = &CaptivePortal{}
captivePortalLock sync.Mutex
captivePortalNotification *notifications.Notification
)
// CaptivePortal holds information about a detected captive portal.
type CaptivePortal struct {
URL string
Domain string
IP net.IP
}
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 *url.URL, comment string) {
changed := false
// Update online 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
}
// Update captive portal.
setCaptivePortal(portalURL)
// Trigger events.
if changed {
module.EventOnlineStatusChange.Submit(status)
if status == StatusPortal {
log.Infof(`netenv: setting online status to %s at "%s" (%s)`, status, portalURL, comment)
} else {
log.Infof("netenv: setting online status to %s (%s)", status, comment)
}
TriggerNetworkChangeCheck()
// Notify user.
notifyOnlineStatus(status)
// Trigger update check when coming (semi) online.
if Online() {
_ = updates.TriggerUpdate(false, false)
}
}
}
func notifyOnlineStatus(status OnlineStatus) {
var eventID, title, message string
// Check if status is worth notifying.
switch status { //nolint:exhaustive // Checking for selection only.
case StatusOffline:
eventID = "netenv:online-status:offline"
title = "Device is Offline"
message = "Portmaster did not detect any network connectivity."
case StatusLimited:
eventID = "netenv:online-status:limited"
title = "Limited network connectivity."
message = "Portmaster did detect local network connectivity, but could not detect connectivity to the Internet."
default:
// Delete notification, if present.
if onlineStatusNotification != nil {
onlineStatusNotification.Delete()
onlineStatusNotification = nil
}
return
}
// Update notification if not present or online status changed.
switch {
case onlineStatusNotification == nil:
// Continue creating new notification.
case onlineStatusNotification.EventID == eventID:
// Notification stays the same, stick with the old one.
return
default:
// Delete old notification before triggering updated one.
onlineStatusNotification.Delete()
}
// Create update status notification.
onlineStatusNotification = notifications.Notify(&notifications.Notification{
EventID: eventID,
Type: notifications.Info,
Title: title,
Message: message,
})
}
func setCaptivePortal(portalURL *url.URL) {
captivePortalLock.Lock()
defer captivePortalLock.Unlock()
// Delete captive portal if no url is supplied.
if portalURL == nil {
captivePortal = &CaptivePortal{}
if captivePortalNotification != nil {
captivePortalNotification.Delete()
captivePortalNotification = nil
}
return
}
// Only set captive portal once per detection.
if captivePortal.URL != "" {
return
}
// Compile captive portal data.
captivePortal = &CaptivePortal{
URL: portalURL.String(),
}
portalIP := net.ParseIP(portalURL.Hostname())
if portalIP != nil {
captivePortal.IP = portalIP
captivePortal.Domain = SpecialCaptivePortalDomain
} else {
captivePortal.Domain = portalURL.Hostname()
}
// Notify user about portal.
captivePortalNotification = notifications.Notify(&notifications.Notification{
EventID: "netenv:captive-portal",
Type: notifications.Info,
Title: "Captive Portal Detected",
Message: "The Portmaster detected a captive portal. You might experience limited network connectivity until the portal is handled.",
ShowOnSystem: true,
EventData: captivePortal,
AvailableActions: []*notifications.Action{
{
Text: "Open Portal",
Type: notifications.ActionTypeOpenURL,
Payload: captivePortal.URL,
},
{
ID: "ack",
Text: "Ignore",
},
},
})
}
// GetCaptivePortal returns the current captive portal. The returned struct must not be edited.
func GetCaptivePortal() *CaptivePortal {
captivePortalLock.Lock()
defer captivePortalLock.Unlock()
return captivePortal
}
// 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()
}
}
// TriggerOnlineStatusInvestigation manually triggers the online status check.
// It will not trigger it again, if it is already in progress.
func TriggerOnlineStatusInvestigation() {
if onlineStatusInvestigationInProgress.SetToIf(false, true) {
onlineStatusInvestigationWg.Add(1)
}
select {
case onlineStatusInvestigationTrigger <- struct{}{}:
default:
}
}
func monitorOnlineStatus(ctx *mgr.WorkerCtx) error {
TriggerOnlineStatusInvestigation()
for {
// wait for trigger
select {
case <-ctx.Done():
return nil
case <-onlineStatusInvestigationTrigger:
case <-getDynamicStatusTrigger():
}
// enable waiting
if onlineStatusInvestigationInProgress.SetToIf(false, true) {
onlineStatusInvestigationWg.Add(1)
}
checkOnlineStatus(ctx.Ctx())
// finished!
onlineStatusInvestigationWg.Done()
onlineStatusInvestigationInProgress.UnSet()
}
}
func getDynamicStatusTrigger() <-chan time.Time {
switch GetOnlineStatus() {
case StatusOffline:
// Will also be triggered by network change.
return time.After(10 * time.Second)
case StatusLimited, StatusPortal:
// Change will not be detected otherwise, but impact is minor.
return time.After(5 * time.Second)
case StatusSemiOnline:
// Very small impact.
return time.After(60 * time.Second)
case StatusOnline:
// Don't check until resolver reports problems.
return nil
case StatusUnknown:
fallthrough
default:
return time.After(5 * time.Minute)
}
}
func ipInList(list []net.IP, ip net.IP) bool {
for _, ignoreIP := range list {
if ignoreIP.Equal(ip) {
return true
}
}
return false
}
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
}*/
// 0) check if connected to SPN and/or DNS.
if ConnectedToSPN.IsSet() {
updateOnlineStatus(StatusOnline, nil, "connected to SPN")
return
}
if ConnectedToDNS.IsSet() {
updateOnlineStatus(StatusOnline, nil, "connected to DNS")
return
}
// 1) check for addresses
ipv4, ipv6, err := GetAssignedAddresses()
if err != nil {
log.Warningf("netenv: failed to get assigned network addresses: %s", err)
} else {
var lan bool
for _, ip := range ipv4 {
// Ignore IP if it is in the online check ignore list.
if ipInList(IgnoreIPsInOnlineStatusCheck, ip) {
continue
}
switch netutils.GetIPScope(ip) { //nolint:exhaustive // Checking to specific values only.
case netutils.SiteLocal:
lan = true
case netutils.Global:
// we _are_ the Internet ;)
updateOnlineStatus(StatusOnline, nil, "global IPv4 interface detected")
return
}
}
for _, ip := range ipv6 {
// Ignore IP if it is in the online check ignore list.
if ipInList(IgnoreIPsInOnlineStatusCheck, ip) {
continue
}
switch netutils.GetIPScope(ip) { //nolint:exhaustive // Checking to specific values only.
case netutils.SiteLocal, netutils.Global:
// IPv6 global addresses are also used in local networks
lan = true
}
}
if !lan {
updateOnlineStatus(StatusOffline, nil, "no local or global interfaces detected")
return
}
}
// 2) try a http request
dialer := &net.Dialer{
Timeout: 5 * time.Second,
LocalAddr: getLocalAddr("tcp"),
}
client := &http.Client{
Transport: &http.Transport{
DialContext: dialer.DialContext,
DisableKeepAlives: true,
DisableCompression: true,
WriteBufferSize: 1024,
ReadBufferSize: 1024,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 1 * time.Second,
}
request := (&http.Request{
Method: http.MethodGet,
URL: parsedPortalTestURL,
Close: true,
}).WithContext(ctx)
response, err := client.Do(request)
if err != nil {
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
// Timeout is the expected error when there is no portal
log.Debugf("netenv: http portal test failed: %s", err)
// TODO: discern between errors to detect StatusLimited
}
} else {
defer func() {
_ = response.Body.Close()
}()
// Got a response, something is messing with the request
// check location
portalURL, err := response.Location()
if err == nil {
updateOnlineStatus(StatusPortal, portalURL, "portal test request succeeded with redirect")
return
}
// direct response
if response.StatusCode == http.StatusOK {
updateOnlineStatus(StatusPortal, &url.URL{
Scheme: "http",
Host: SpecialCaptivePortalDomain,
Path: "/",
}, "portal test request succeeded")
return
}
log.Debugf("netenv: unexpected http portal test response code: %d", response.StatusCode)
// other responses are undefined, continue with next test
}
// 3) resolve a query
// Check if we can resolve the dns check domain.
if DNSTestQueryFunc == nil {
updateOnlineStatus(StatusOnline, nil, "all checks passed, dns query check disabled")
return
}
ips, ok, err := DNSTestQueryFunc(ctx, DNSTestDomain)
switch {
case ok && err != nil:
updateOnlineStatus(StatusOnline, nil, fmt.Sprintf(
"all checks passed, acceptable result for dns query check: %s",
err,
))
case ok && len(ips) >= 1 && ips[0].Equal(DNSTestExpectedIP):
updateOnlineStatus(StatusOnline, nil, "all checks passed")
case ok && len(ips) >= 1:
log.Warningf("netenv: dns query check response mismatched: got %s", ips[0])
updateOnlineStatus(StatusOnline, nil, "all checks passed, dns query check response mismatched")
case ok:
log.Warningf("netenv: dns query check response mismatched: empty response")
updateOnlineStatus(StatusOnline, nil, "all checks passed, dns query check response was empty")
default:
log.Warningf("netenv: dns query check failed: %s", err)
updateOnlineStatus(StatusOffline, nil, "dns query check failed")
}
}