mirror of
https://github.com/safing/portmaster
synced 2025-04-21 19:39:09 +00:00
* 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>
565 lines
16 KiB
Go
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(¬ifications.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(¬ifications.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")
|
|
}
|
|
}
|