diff --git a/compat/api.go b/compat/api.go new file mode 100644 index 00000000..78365c05 --- /dev/null +++ b/compat/api.go @@ -0,0 +1,29 @@ +package compat + +import ( + "github.com/safing/portbase/api" +) + +func registerAPIEndpoints() error { + if err := api.RegisterEndpoint(api.Endpoint{ + Path: "compat/self-check", + Read: api.PermitUser, + BelongsTo: module, + ActionFunc: selfcheckViaAPI, + Name: "Run Integration Self-Check", + Description: "Runs a couple integration self-checks in order to see if the system integration works.", + }); err != nil { + return err + } + + return nil +} + +func selfcheckViaAPI(ar *api.Request) (msg string, err error) { + _, err = selfcheck(ar.Context()) + if err != nil { + return "", err + } + + return "self-check successful", nil +} diff --git a/compat/callbacks.go b/compat/callbacks.go new file mode 100644 index 00000000..87f85078 --- /dev/null +++ b/compat/callbacks.go @@ -0,0 +1,36 @@ +package compat + +import ( + "net" + + "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/process" +) + +func SubmitSystemIntegrationCheckPacket(p packet.Packet) { + select { + case systemIntegrationCheckPackets <- p: + default: + } +} + +func SubmitDNSCheckDomain(subdomain string) (respondWith net.IP) { + // Submit queried domain. + select { + case dnsCheckReceivedDomain <- subdomain: + default: + } + + // Return the answer. + dnsCheckAnswerLock.Lock() + defer dnsCheckAnswerLock.Unlock() + return dnsCheckAnswer +} + +func ReportSecureDNSBypassIssue(p *process.Process) { + secureDNSBypassIssue.notify(p) +} + +func ReportMultiPeerUDPTunnelIssue(p *process.Process) { + multiPeerUDPTunnelIssue.notify(p) +} diff --git a/compat/module.go b/compat/module.go new file mode 100644 index 00000000..e258555d --- /dev/null +++ b/compat/module.go @@ -0,0 +1,82 @@ +package compat + +import ( + "context" + "time" + + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/netenv" + "github.com/tevino/abool" +) + +var ( + module *modules.Module + + selfcheckTask *modules.Task + selfcheckTaskRetryAfter = 10 * time.Second + selfCheckIsFailing = abool.New() +) + +func init() { + module = modules.Register("compat", prep, start, stop, "base", "network", "interception", "netenv", "notifications") +} + +func prep() error { + return registerAPIEndpoints() +} + +func start() error { + selfcheckTask = module.NewTask("compatibility self-check", selfcheckTaskFunc). + Repeat(1 * time.Minute). + StartASAP() + + return module.RegisterEventHook( + netenv.ModuleName, + netenv.NetworkChangedEvent, + "trigger compat self-check", + func(_ context.Context, _ interface{}) error { + selfcheckTask.StartASAP() + return nil + }, + ) +} + +func stop() error { + selfcheckTask.Cancel() + selfcheckTask = nil + + return nil +} + +func selfcheckTaskFunc(ctx context.Context, task *modules.Task) error { + // Run selfcheck and return if successful. + issue, err := selfcheck(ctx) + if err == nil { + selfCheckIsFailing.UnSet() + resetSystemIssue() + return nil + } + + // Log result. + if issue != nil { + selfCheckIsFailing.Set() + + log.Errorf("compat: %s", err) + issue.notify(err) + + // Retry quicker when failed. + task.Schedule(time.Now().Add(selfcheckTaskRetryAfter)) + } else { + selfCheckIsFailing.UnSet() + + // Only log internal errors, but don't notify. + log.Warningf("compat: %s", err) + } + + return nil +} + +func SelfCheckIsFailing() bool { + return selfCheckIsFailing.IsSet() +} diff --git a/compat/notify.go b/compat/notify.go new file mode 100644 index 00000000..78f6994a --- /dev/null +++ b/compat/notify.go @@ -0,0 +1,166 @@ +package compat + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/safing/portmaster/profile" + + "github.com/safing/portbase/log" + "github.com/safing/portbase/notifications" + "github.com/safing/portmaster/process" +) + +type baseIssue struct { + id string + title string + message string + level notifications.Type +} + +type systemIssue baseIssue + +type appIssue baseIssue + +var ( + systemIssueNotification *notifications.Notification + systemIssueNotificationLock sync.Mutex + + systemIntegrationIssue = &systemIssue{ + id: "compat:system-integration-issue", + title: "Detected System Integration Issue", + message: "Portmaster detected a problem with its system integration. You can try to restart or reinstall the Portmaster. If that does not help, please report the issue via [GitHub](https://github.com/safing/portmaster/issues) or send a mail to [support@safing.io](mailto:support@safing.io) so we can help you out.", + level: notifications.Error, + } + systemCompatibilityIssue = &systemIssue{ + id: "compat:compatibility-issue", + title: "Detected Compatibility Issue", + message: "Portmaster detected that something is interfering with its operation. This could be a VPN, an Anti-Virus or another network protection software. Please check if you are running an incompatible [VPN client](https://docs.safing.io/portmaster/install/status/vpn-compatibility) or [software](https://docs.safing.io/portmaster/install/status/software-compatibility). Otherwise, please report the issue via [GitHub](https://github.com/safing/portmaster/issues) or send a mail to [support@safing.io](mailto:support@safing.io) so we can help you out.", + level: notifications.Error, + } + + secureDNSBypassIssue = &appIssue{ + id: "compat:secure-dns-bypass-%s", + title: "Detected %s Bypass Attempt", + message: "Portmaster detected that %s is trying to use a secure DNS resolver. While this is a good thing, the Portmaster already handles secure DNS for your whole device. Please disable the secure DNS resolver within the app.", + // TODO: Add this when the new docs page is finished: + // , or [find out about other options](link to new docs page) + level: notifications.Warning, + } + multiPeerUDPTunnelIssue = &appIssue{ + id: "compat:multi-peer-udp-tunnel-%s", + title: "Detected SPN Incompatibility in %s", + message: "Portmaster detected that %s is trying to connect to multiple servers via the SPN using a single UDP connection. This is common for technologies such as torrents. Unfortunately, the SPN does not support this feature currently. You can try to change this behavior within the affected app or you could exempt it from using the SPN.", + level: notifications.Warning, + } +) + +func (issue *systemIssue) notify(err error) { + systemIssueNotificationLock.Lock() + defer systemIssueNotificationLock.Unlock() + + if systemIssueNotification != nil { + // Ignore duplicate notification. + if issue.id == systemIssueNotification.EventID { + return + } + + // Remove old notification. + systemIssueNotification.Delete() + } + + // Create new notification. + n := ¬ifications.Notification{ + EventID: issue.id, + Type: issue.level, + Title: issue.title, + Message: issue.message, + ShowOnSystem: true, + } + notifications.Notify(n) + + systemIssueNotification = n + n.AttachToModule(module) + + // Report the raw error as module error. + module.NewErrorMessage("selfcheck", err).Report() +} + +func resetSystemIssue() { + systemIssueNotificationLock.Lock() + defer systemIssueNotificationLock.Unlock() + + if systemIssueNotification != nil { + systemIssueNotification.Delete() + } + systemIssueNotification = nil +} + +func (issue *appIssue) notify(proc *process.Process) { + // Get profile from process. + p := proc.Profile().LocalProfile() + if p == nil { + return + } + + // Ignore notifications for unidentified processes. + if p.ID == profile.UnidentifiedProfileID { + return + } + + // Log warning. + log.Warningf( + "compat: detected %s issue with %s", + strings.ReplaceAll( + strings.TrimPrefix( + strings.TrimSuffix(issue.id, "-%d"), + "compat:", + ), + "-", " ", + ), + proc.Path, + ) + + // Check if we already have this notification. + eventID := fmt.Sprintf(issue.id, p.ID) + n := notifications.Get(eventID) + if n != nil { + return + } + + // Otherwise, create a new one. + n = ¬ifications.Notification{ + EventID: eventID, + Type: issue.level, + Title: fmt.Sprintf(issue.title, p.Name), + Message: fmt.Sprintf(issue.message, p.Name), + ShowOnSystem: true, + AvailableActions: []*notifications.Action{ + { + ID: "ack", + Text: "OK", + }, + }, + } + notifications.Notify(n) + + // Set warning on profile. + module.StartWorker("set app compat warning", func(ctx context.Context) error { + func() { + p.Lock() + defer p.Unlock() + + p.Warning = fmt.Sprintf( + "%s \nThis was last detected at %s.", + fmt.Sprintf(issue.message, p.Name), + time.Now().Format("15:04 on 2.1.2006"), + ) + p.WarningLastUpdated = time.Now() + }() + + return p.Save() + }) +} diff --git a/compat/selfcheck.go b/compat/selfcheck.go new file mode 100644 index 00000000..71fec997 --- /dev/null +++ b/compat/selfcheck.go @@ -0,0 +1,193 @@ +package compat + +import ( + "context" + "encoding/hex" + "fmt" + "net" + "strings" + "sync" + "time" + + "github.com/safing/portbase/log" + "github.com/safing/portbase/rng" + "github.com/safing/portmaster/network/packet" +) + +var ( + selfcheckLock sync.Mutex + + SystemIntegrationCheckDstIP = net.IPv4(127, 65, 67, 75) + SystemIntegrationCheckProtocol = packet.AnyHostInternalProtocol61 + + systemIntegrationCheckDialNet = fmt.Sprintf("ip4:%d", uint8(SystemIntegrationCheckProtocol)) + systemIntegrationCheckDialIP = SystemIntegrationCheckDstIP.String() + systemIntegrationCheckPackets = make(chan packet.Packet, 1) + systemIntegrationCheckWaitDuration = 3 * time.Second + + DNSCheckInternalDomainScope string + dnsCheckReceivedDomain = make(chan string, 1) + dnsCheckWaitDuration = 3 * time.Second + dnsCheckAnswerLock sync.Mutex + dnsCheckAnswer net.IP + + DNSTestDomain = "one.one.one.one." + DNSTestExpectedIP = net.IPv4(1, 1, 1, 1) +) + +func selfcheck(ctx context.Context) (issue *systemIssue, err error) { + selfcheckLock.Lock() + defer selfcheckLock.Unlock() + + // Step 1: Check if the system integration sees a packet. + + // Empty recv channel. + select { + case <-systemIntegrationCheckPackets: + case <-ctx.Done(): + return nil, context.Canceled + default: + } + + // Send packet. + conn, err := net.DialTimeout( + systemIntegrationCheckDialNet, + systemIntegrationCheckDialIP, + time.Second, + ) + if err != nil { + return nil, fmt.Errorf("failed to create system integration conn: %w", err) + } + _, err = conn.Write([]byte("SELF-CHECK")) + if err != nil { + return nil, fmt.Errorf("failed to send system integration packet: %w", err) + } + + // Wait for packet. + select { + case <-systemIntegrationCheckPackets: + // Check passed! + log.Tracef("compat: self-check #1: system integration check passed") + case <-time.After(systemIntegrationCheckWaitDuration): + return systemIntegrationIssue, fmt.Errorf("self-check #1: system integration check failed: did not receive test packet after %s", systemIntegrationCheckWaitDuration) + case <-ctx.Done(): + return nil, context.Canceled + } + + // Step 2: Check if a DNS request arrives at the nameserver + // This step necessary also includes some setup for step 3. + + // Generate random subdomain. + randomSubdomainBytes, err := rng.Bytes(16) + if err != nil { + return nil, fmt.Errorf("self-check #2: failed to get random bytes for subdomain check: %w", err) + } + randomSubdomain := "a" + strings.ToLower(hex.EncodeToString(randomSubdomainBytes)) + "b" + + // Generate random answer. + var B, C, D uint64 + B, err = rng.Number(255) + if err == nil { + C, err = rng.Number(255) + } + if err == nil { + D, err = rng.Number(255) + } + if err != nil { + return nil, fmt.Errorf("self-check #2: failed to get random number for subdomain check response: %w", err) + } + randomAnswer := net.IPv4(127, byte(B), byte(C), byte(D)) + func() { + dnsCheckAnswerLock.Lock() + defer dnsCheckAnswerLock.Unlock() + dnsCheckAnswer = randomAnswer + }() + + // Setup variables for lookup worker. + var ( + dnsCheckReturnedIP net.IP + dnsCheckLookupError = make(chan error) + ) + + // Empty recv channel. + select { + case <-dnsCheckReceivedDomain: + case <-ctx.Done(): + return nil, context.Canceled + default: + } + + // Start worker for the DNS lookup. + module.StartWorker("dns check lookup", func(_ context.Context) error { + ips, err := net.LookupIP(randomSubdomain + DNSCheckInternalDomainScope) + if err == nil && len(ips) > 0 { + dnsCheckReturnedIP = ips[0] + } + select { + case dnsCheckLookupError <- err: + case <-time.After(dnsCheckWaitDuration * 2): + case <-ctx.Done(): + } + + return nil + }) + + // Wait for the resolver to receive the query. + select { + case receivedTestDomain := <-dnsCheckReceivedDomain: + if receivedTestDomain != randomSubdomain { + return systemCompatibilityIssue, fmt.Errorf("self-check #2: dns integration check failed: received unmatching subdomain %q", receivedTestDomain) + } + case <-time.After(dnsCheckWaitDuration): + return systemCompatibilityIssue, fmt.Errorf("self-check #2: dns integration check failed: did not receive test query after %s", dnsCheckWaitDuration) + } + log.Tracef("compat: self-check #2: dns integration query check passed") + + // Step 3: Have the nameserver respond with random data in the answer section. + + // Wait for the reply from the resolver. + select { + case err := <-dnsCheckLookupError: + if err != nil { + return systemCompatibilityIssue, fmt.Errorf("self-check #3: dns integration check failed: failed to receive test response: %w", err) + } + case <-time.After(dnsCheckWaitDuration): + return systemCompatibilityIssue, fmt.Errorf("self-check #3: dns integration check failed: did not receive test response after %s", dnsCheckWaitDuration) + case <-ctx.Done(): + return nil, context.Canceled + } + + // Check response. + if !dnsCheckReturnedIP.Equal(randomAnswer) { + return systemCompatibilityIssue, fmt.Errorf("self-check #3: dns integration check failed: received unmatching response %q", dnsCheckReturnedIP) + } + log.Tracef("compat: self-check #3: dns integration response check passed") + + return nil, nil +} + +/* + +* Check if the system integration sees a packet: + * Send raw IP packet with random content and protocol, report finding to compat module. + * use `Dial("ip4:61", "127.65.67.75")`. + * Firewall reports back the data seen on `ip4:61` to IP `127.65.67.75`. + * If this fails, the system integration is broken. -> Integration Issue +* Check if a DNS request arrives at the nameserver: + * Send A question for `[random-subdomain].self-check.portmaster.home.arpa.`. + * Nameserver reports back the data seen. + * If this fails, redirection to the nameserver fails. + * This means there is another software interfering with DNS. -> Compatibility Issue +* Have the nameserver respond with random data in the answer section. + * Compat provides nameserver with random response data. + * Compat module checks if the received data matches. + * If this fails, redirection to the nameserver fails. + * This means there is another software interfering with DNS on the return path. -> Compatibility Issue +* DROPPED: If resolvers are reported failing, but we are online: + * Send out plain DNS requests to one.one.one.one. and dns.quad9.net via the Go standard lookup and check if the responses are correct. + * If not, something is blocking the Portmaster -> Secure DNS Issue + * Discuss if this is necessary: + * Does this improve from only having a failed TCP connection to the resolver? + * Could another program block port 853, but fully leave requests for one.one.one.one. to port 53 alone? + +*/ diff --git a/core/core.go b/core/core.go index 23a48946..07ff9ec1 100644 --- a/core/core.go +++ b/core/core.go @@ -27,7 +27,7 @@ var ( ) func init() { - module = modules.Register("core", prep, start, nil, "base", "subsystems", "status", "updates", "api", "notifications", "ui", "netenv", "network", "interception") + module = modules.Register("core", prep, start, nil, "base", "subsystems", "status", "updates", "api", "notifications", "ui", "netenv", "network", "interception", "compat") subsystems.Register( "core", "Core", diff --git a/firewall/bypassing.go b/firewall/bypassing.go index 822a21ce..016c1cc7 100644 --- a/firewall/bypassing.go +++ b/firewall/bypassing.go @@ -4,6 +4,8 @@ import ( "context" "strings" + "github.com/safing/portmaster/compat" + "github.com/safing/portmaster/nameserver/nsutil" "github.com/safing/portmaster/network" "github.com/safing/portmaster/network/packet" @@ -30,6 +32,7 @@ func PreventBypassing(ctx context.Context, conn *network.Connection) (endpoints. // Make an exception for ICMP, as these IPs are also often used for debugging. default: if conn.Entity.MatchLists(resolverFilterLists) { + compat.ReportSecureDNSBypassIssue(conn.Process()) return endpoints.Denied, "blocked rogue connection to DNS resolver", nsutil.BlockIP() diff --git a/firewall/dns.go b/firewall/dns.go index 5aaeff26..72fb398f 100644 --- a/firewall/dns.go +++ b/firewall/dns.go @@ -170,6 +170,11 @@ func FilterResolvedDNS( return nil } + // Don't filter env responses. + if rrCache.Resolver.Type == resolver.ServerTypeEnv { + return rrCache + } + // special grant for connectivity domains if checkConnectivityDomain(ctx, conn, layeredProfile, nil) { // returns true if check triggered diff --git a/firewall/interception.go b/firewall/interception.go index 296a9110..b90f697f 100644 --- a/firewall/interception.go +++ b/firewall/interception.go @@ -9,6 +9,8 @@ import ( "sync/atomic" "time" + "github.com/safing/portmaster/compat" + "github.com/safing/spn/captain" "github.com/google/gopacket/layers" @@ -314,6 +316,13 @@ func fastTrackedPermit(pkt packet.Packet) (handled bool) { _ = pkt.PermanentAccept() return true } + + case compat.SystemIntegrationCheckProtocol: + if pkt.Info().Dst.Equal(compat.SystemIntegrationCheckDstIP) { + compat.SubmitSystemIntegrationCheckPacket(pkt) + _ = pkt.Drop() + } + return true } return false @@ -331,11 +340,19 @@ func initialHandler(conn *network.Connection, pkt packet.Packet) { // Set tunnel options. setCustomTunnelOptionsForPortmaster(conn) + // Redirect outbound DNS packests, case pkt.IsOutbound() && pkt.Info().DstPort == 53 && - conn.Process().Pid != ownPID && + // that don't match the address of our nameserver, nameserverIPMatcherReady.IsSet() && - !nameserverIPMatcher(pkt.Info().Dst): + !nameserverIPMatcher(pkt.Info().Dst) && + // and are not broadcast queries by us. + // Context: + // - Unicast queries by the resolver are pre-authenticated. + // - Unicast qeries by the compat self-check should be redirected. + !(conn.Process().Pid == ownPID && + conn.Entity.IPScope == netutils.LocalMulticast): + // Reroute rogue dns queries back to Portmaster. conn.Verdict = network.VerdictRerouteToNameserver conn.Reason.Msg = "redirecting rogue dns query" diff --git a/go.mod b/go.mod index 7be9c686..9f3533a7 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/klauspost/reedsolomon v1.9.13 // indirect github.com/mdlayher/socket v0.0.0-20211007213009-516dcbdf0267 // indirect github.com/miekg/dns v1.1.43 + github.com/mr-tron/base58 v1.2.0 github.com/oschwald/maxminddb-golang v1.8.0 github.com/safing/portbase v0.12.3 github.com/safing/spn v0.3.6 diff --git a/netenv/location.go b/netenv/location.go index a80c510c..d4c03604 100644 --- a/netenv/location.go +++ b/netenv/location.go @@ -241,7 +241,7 @@ func addLocation(dl *DeviceLocation) { func GetApproximateInternetLocation() (net.IP, error) { loc, ok := GetInternetLocation() if !ok || loc.Best() == nil { - return nil, errors.New("no location data available") + return nil, errors.New("no device location data available") } return loc.Best().IP, nil } @@ -259,7 +259,7 @@ func GetInternetLocation() (deviceLocations *DeviceLocations, ok bool) { // Get all assigned addresses. v4s, v6s, err := GetAssignedAddresses() if err != nil { - log.Warningf("netenv: failed to get assigned addresses: %s", err) + log.Warningf("netenv: failed to get assigned addresses for device location: %s", err) return nil, false } @@ -267,27 +267,24 @@ func GetInternetLocation() (deviceLocations *DeviceLocations, ok bool) { v4ok, v6ok := getLocationFromInterfaces() // Try other methods for missing locations. - if len(v4s) > 0 { - if !v4ok { - _, err = getLocationFromTraceroute() - if err != nil { - log.Warningf("netenv: failed to get IPv4 from traceroute: %s", err) - } else { - v4ok = true - } + if len(v4s) > 0 && !v4ok { + _, err = getLocationFromTraceroute() + if err != nil { + log.Warningf("netenv: failed to get IPv4 device location from traceroute: %s", err) + } else { + v4ok = true } + + // Get location from timezone as final fallback. if !v4ok { - v4ok = getLocationFromTimezone(packet.IPv4) + getLocationFromTimezone(packet.IPv4) } } if len(v6s) > 0 && !v6ok { - // TODO - log.Warningf("netenv: could not get IPv6 location") - } + // TODO: Find more ways to get IPv6 device location - // Check if we have any locations. - if !v4ok && !v6ok { - return nil, false + // Get location from timezone as final fallback. + getLocationFromTimezone(packet.IPv6) } // Return gathered locations. diff --git a/netenv/online-status.go b/netenv/online-status.go index db36a805..ddad55e2 100644 --- a/netenv/online-status.go +++ b/netenv/online-status.go @@ -145,6 +145,7 @@ var ( onlineStatusInvestigationTrigger = make(chan struct{}, 1) onlineStatusInvestigationInProgress = abool.NewBool(false) onlineStatusInvestigationWg sync.WaitGroup + onlineStatusNotification *notifications.Notification captivePortal = &CaptivePortal{} captivePortalLock sync.Mutex @@ -186,7 +187,7 @@ func CheckAndGetOnlineStatus() OnlineStatus { func updateOnlineStatus(status OnlineStatus, portalURL *url.URL, comment string) { changed := false - // status + // Update online status. currentStatus := atomic.LoadInt32(onlineStatus) if status != OnlineStatus(currentStatus) && atomic.CompareAndSwapInt32(onlineStatus, currentStatus, int32(status)) { // status changed! @@ -196,10 +197,10 @@ func updateOnlineStatus(status OnlineStatus, portalURL *url.URL, comment string) changed = true } - // captive portal + // Update captive portal. setCaptivePortal(portalURL) - // trigger event + // Trigger events. if changed { module.TriggerEvent(OnlineStatusChangedEvent, status) if status == StatusPortal { @@ -209,6 +210,9 @@ func updateOnlineStatus(status OnlineStatus, portalURL *url.URL, comment string) } triggerNetworkChangeCheck() + // Notify user. + notifyOnlineStatus(status) + // Trigger update check when coming (semi) online. if Online() { _ = updates.TriggerUpdate(false) @@ -216,11 +220,54 @@ func updateOnlineStatus(status OnlineStatus, portalURL *url.URL, comment string) } } +func notifyOnlineStatus(status OnlineStatus) { + var eventID, title, message string + + // Check if status is worth notifying. + switch status { + 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 + // Delete captive portal if no url is supplied. if portalURL == nil { captivePortal = &CaptivePortal{} if captivePortalNotification != nil { @@ -230,12 +277,12 @@ func setCaptivePortal(portalURL *url.URL) { return } - // return if unchanged - if portalURL.String() == captivePortal.URL { + // Only set captive portal once per detection. + if captivePortal.URL != "" { return } - // set + // Compile captive portal data. captivePortal = &CaptivePortal{ URL: portalURL.String(), } @@ -247,7 +294,7 @@ func setCaptivePortal(portalURL *url.URL) { captivePortal.Domain = portalURL.Hostname() } - // notify + // Notify user about portal. captivePortalNotification = notifications.Notify(¬ifications.Notification{ EventID: "netenv:captive-portal", Type: notifications.Info, diff --git a/network/packet/const.go b/network/packet/const.go index 374ef57a..69abd09c 100644 --- a/network/packet/const.go +++ b/network/packet/const.go @@ -30,6 +30,8 @@ const ( ICMPv6 = IPProtocol(58) UDPLite = IPProtocol(136) RAW = IPProtocol(255) + + AnyHostInternalProtocol61 = IPProtocol(61) ) // Verdicts diff --git a/network/packet/parse.go b/network/packet/parse.go index 2b978097..ed5a7817 100644 --- a/network/packet/parse.go +++ b/network/packet/parse.go @@ -94,7 +94,12 @@ func parseIGMP(packet gopacket.Packet, info *Info) error { return nil } -func checkError(packet gopacket.Packet, _ *Info) error { +func checkError(packet gopacket.Packet, info *Info) error { + // Check for known unparseable before checking the error layer. + if info.Protocol == AnyHostInternalProtocol61 { + return nil + } + if err := packet.ErrorLayer(); err != nil { return err.Error() } diff --git a/profile/profile.go b/profile/profile.go index bf7063ee..cf651ded 100644 --- a/profile/profile.go +++ b/profile/profile.go @@ -68,9 +68,16 @@ type Profile struct { //nolint:maligned // not worth the effort // Name is a human readable name of the profile. It // defaults to the basename of the application. Name string - // Description may holds an optional description of the + // Description may hold an optional description of the // profile or the purpose of the application. Description string + // Warning may hold an optional warning about this application. + // It may be static or be added later on when the Portmaster detected an + // issue with the application. + Warning string + // WarningLastUpdated holds the timestamp when the Warning field was last + // updated. + WarningLastUpdated time.Time // Homepage may refer the the website of the application // vendor. Homepage string diff --git a/profile/special.go b/profile/special.go index f4f6dadd..eec4452d 100644 --- a/profile/special.go +++ b/profile/special.go @@ -112,7 +112,7 @@ func getSpecialProfile(profileID, linkedPath string) *Profile { }, ) // Add description to tell users about the quirks of this profile. - systemResolverProfile.Description = `The System DNS Client is a system service that requires special handling. For regular network connections, the configured settings will apply as usual, but DNS requests coming from the System DNS Client are handled in a special way, as they could actually be coming from any other application on the system. + systemResolverProfile.Warning = `The System DNS Client is a system service that requires special handling. For regular network connections, the configured settings will apply as usual, but DNS requests coming from the System DNS Client are handled in a special way, as they could actually be coming from any other application on the system. In order to respect the app settings of the actual application, DNS requests from the System DNS Client are only subject to the following settings: @@ -179,7 +179,7 @@ func specialProfileNeedsReset(profile *Profile) bool { switch profile.ID { case SystemResolverProfileID: - return canBeUpgraded(profile, "1.6.2021") + return canBeUpgraded(profile, "18.11.2021") case PortmasterAppProfileID: return canBeUpgraded(profile, "8.9.2021") default: diff --git a/resolver/main.go b/resolver/main.go index 9d327be1..09c134a2 100644 --- a/resolver/main.go +++ b/resolver/main.go @@ -4,11 +4,14 @@ import ( "context" "net" "strings" + "sync" "time" "github.com/safing/portbase/log" "github.com/safing/portbase/modules" + "github.com/safing/portbase/notifications" "github.com/safing/portmaster/intel" + "github.com/tevino/abool" // module dependencies _ "github.com/safing/portmaster/core/base" @@ -105,3 +108,50 @@ func getLocalAddr(network string) net.Addr { } return nil } + +var ( + failingResolverNotification *notifications.Notification + failingResolverNotificationSet = abool.New() + failingResolverNotificationLock sync.Mutex +) + +func notifyAboutFailingResolvers(err error) { + failingResolverNotificationLock.Lock() + defer failingResolverNotificationLock.Unlock() + failingResolverNotificationSet.Set() + + // Check if already set. + if failingResolverNotification != nil { + return + } + + // Create new notification. + n := ¬ifications.Notification{ + EventID: "resolver:all-configured-resolvers-failed", + Type: notifications.Error, + Title: "Detected DNS Compatibility Issue", + Message: "Portmaster detected that something is interfering with its Secure DNS resolver. This could be a firewall or another secure DNS resolver software. Please check if you are running incompatible [software](https://docs.safing.io/portmaster/install/status/software-compatibility). Otherwise, please report the issue via [GitHub](https://github.com/safing/portmaster/issues) or send a mail to [support@safing.io](mailto:support@safing.io) so we can help you out.", + ShowOnSystem: true, + } + notifications.Notify(n) + + failingResolverNotification = n + n.AttachToModule(module) + + // Report the raw error as module error. + module.NewErrorMessage("resolving", err).Report() +} + +func resetFailingResolversNotification() { + if failingResolverNotificationSet.IsNotSet() { + return + } + + failingResolverNotificationLock.Lock() + defer failingResolverNotificationLock.Unlock() + + if failingResolverNotification != nil { + failingResolverNotification.Delete() + failingResolverNotification = nil + } +} diff --git a/resolver/resolve.go b/resolver/resolve.go index d4a123bd..9e9644a7 100644 --- a/resolver/resolve.go +++ b/resolver/resolve.go @@ -7,12 +7,12 @@ import ( "sync" "time" - "github.com/safing/portmaster/netenv" - "github.com/miekg/dns" "github.com/safing/portbase/database" "github.com/safing/portbase/log" + "github.com/safing/portmaster/compat" + "github.com/safing/portmaster/netenv" ) var ( @@ -313,13 +313,13 @@ retry: func resolveAndCache(ctx context.Context, q *Query, oldCache *RRCache) (rrCache *RRCache, err error) { //nolint:gocognit,gocyclo // get resolvers - resolvers, tryAll := GetResolversInScope(ctx, q) + resolvers, primarySource, tryAll := GetResolversInScope(ctx, q) if len(resolvers) == 0 { return nil, ErrNoCompliance } // check if we are online - if netenv.GetOnlineStatus() == netenv.StatusOffline { + if primarySource != ServerSourceEnv && netenv.GetOnlineStatus() == netenv.StatusOffline { if !netenv.IsConnectivityDomain(q.FQDN) { // we are offline and this is not an online check query return oldCache, ErrOffline @@ -391,6 +391,10 @@ resolveLoop: // Report a successful connection. resolver.Conn.ResetFailure() + // Reset failing resolvers notification, if querying in global scope. + if primarySource == ServerSourceConfigured { + resetFailingResolversNotification() + } break resolveLoop } @@ -401,6 +405,13 @@ resolveLoop: // tried all resolvers, possibly twice if i > 1 { err = fmt.Errorf("all %d query-compliant resolvers failed, last error: %s", len(resolvers), err) + + if primarySource == ServerSourceConfigured && + netenv.Online() && compat.SelfCheckIsFailing() { + notifyAboutFailingResolvers(err) + } else { + resetFailingResolversNotification() + } } } else if rrCache == nil /* defensive */ { err = ErrNotFound diff --git a/resolver/resolver-env.go b/resolver/resolver-env.go index d3d809ef..87d47d6e 100644 --- a/resolver/resolver-env.go +++ b/resolver/resolver-env.go @@ -4,15 +4,17 @@ import ( "context" "fmt" "net" + "strings" "github.com/miekg/dns" "github.com/safing/portbase/log" + "github.com/safing/portmaster/compat" "github.com/safing/portmaster/netenv" "github.com/safing/portmaster/network/netutils" ) const ( - internalSpecialUseDomain = "17.home.arpa." + internalSpecialUseDomain = "portmaster.home.arpa." routerDomain = "router.local." + internalSpecialUseDomain captivePortalDomain = "captiveportal.local." + internalSpecialUseDomain @@ -36,6 +38,7 @@ var ( func prepEnvResolver() (err error) { netenv.SpecialCaptivePortalDomain = captivePortalDomain + compat.DNSCheckInternalDomainScope = ".self-check." + internalSpecialUseDomain internalSpecialUseSOA, err = dns.NewRR(fmt.Sprintf( "%s 17 IN SOA localhost. none.localhost. 0 0 0 0 0", @@ -57,6 +60,7 @@ type envResolverConn struct{} func (er *envResolverConn) Query(ctx context.Context, q *Query) (*RRCache, error) { switch uint16(q.QType) { case dns.TypeA, dns.TypeAAAA: // We respond with all IPv4/6 addresses we can find. + // Check for exact matches. switch q.FQDN { case captivePortalDomain: // Get IP address of the captive portal. @@ -86,7 +90,23 @@ func (er *envResolverConn) Query(ctx context.Context, q *Query) (*RRCache, error return er.nxDomain(q), nil } return er.makeRRCache(q, records), nil + } + // Check for suffix matches. + switch { + case strings.HasSuffix(q.FQDN, compat.DNSCheckInternalDomainScope): + subdomain := strings.TrimSuffix(q.FQDN, compat.DNSCheckInternalDomainScope) + respondWith := compat.SubmitDNSCheckDomain(subdomain) + + // We'll get an A record. Only respond if it's an A question. + if respondWith != nil && uint16(q.QType) == dns.TypeA { + records, err := netutils.IPsToRRs(q.FQDN, []net.IP{respondWith}) + if err != nil { + log.Warningf("nameserver: failed to create dns check response to %s: %s", q.FQDN, err) + return er.nxDomain(q), nil + } + return er.makeRRCache(q, records), nil + } } case dns.TypeSOA: // Direct query for the SOA record. diff --git a/resolver/resolver-mdns.go b/resolver/resolver-mdns.go index 59f20884..48601386 100644 --- a/resolver/resolver-mdns.go +++ b/resolver/resolver-mdns.go @@ -9,11 +9,9 @@ import ( "sync" "time" - "github.com/safing/portmaster/network/netutils" - "github.com/miekg/dns" - "github.com/safing/portbase/log" + "github.com/safing/portmaster/network/netutils" ) // DNS Classes diff --git a/resolver/resolver-tcp.go b/resolver/resolver-tcp.go index 30ff20a2..78968da8 100644 --- a/resolver/resolver-tcp.go +++ b/resolver/resolver-tcp.go @@ -145,10 +145,16 @@ func (tr *TCPResolver) getOrCreateResolverConn(ctx context.Context) (*tcpResolve // Connect to server. conn, err := tr.dnsClient.Dial(tr.resolver.ServerAddress) if err != nil { - log.Debugf("resolver: failed to connect to %s", tr.resolver.Info.DescriptiveName()) + // Hint network environment at failed connection. + netenv.ReportFailedConnection() + + log.Debugf("resolver: failed to connect to %s: %s", tr.resolver.Info.DescriptiveName(), err) return nil, fmt.Errorf("%w: failed to connect to %s: %s", ErrFailure, tr.resolver.Info.DescriptiveName(), err) } + // Hint network environment at successful connection. + netenv.ReportSuccessfulConnection() + // Log that a connection to the resolver was established. log.Debugf( "resolver: connected to %s", @@ -174,9 +180,6 @@ func (tr *TCPResolver) getOrCreateResolverConn(ctx context.Context) (*tcpResolve // Set resolver conn for reuse. tr.resolverConn = resolverConn - // Hint network environment at successful connection. - netenv.ReportSuccessfulConnection() - return resolverConn, nil } diff --git a/resolver/resolvers.go b/resolver/resolvers.go index 4cbaf46d..e6fdb814 100644 --- a/resolver/resolvers.go +++ b/resolver/resolvers.go @@ -112,7 +112,8 @@ func createResolver(resolverURL, source string) (*Resolver, bool, error) { } scope := netutils.GetIPScope(ip) - if scope.IsLocalhost() { + // Skip localhost resolvers from the OS, but not if configured. + if scope.IsLocalhost() && source == ServerSourceOperatingSystem { return nil, true, nil // skip } diff --git a/resolver/scopes.go b/resolver/scopes.go index 54819358..a291db22 100644 --- a/resolver/scopes.go +++ b/resolver/scopes.go @@ -109,20 +109,20 @@ func domainInScope(dotPrefixedFQDN string, scopeList []string) bool { } // GetResolversInScope returns all resolvers that are in scope the resolve the given query and options. -func GetResolversInScope(ctx context.Context, q *Query) (selected []*Resolver, tryAll bool) { //nolint:gocognit // TODO +func GetResolversInScope(ctx context.Context, q *Query) (selected []*Resolver, primarySource string, tryAll bool) { //nolint:gocognit // TODO resolversLock.RLock() defer resolversLock.RUnlock() // Internal use domains if domainInScope(q.dotPrefixedFQDN, internalSpecialUseDomains) { - return envResolvers, false + return envResolvers, ServerSourceEnv, false } // Special connectivity domains if netenv.IsConnectivityDomain(q.FQDN) && len(systemResolvers) > 0 { // Do not do compliance checks for connectivity domains. selected = append(selected, systemResolvers...) // dhcp assigned resolvers - return selected, false + return selected, ServerSourceOperatingSystem, false } // Prioritize search scopes @@ -137,7 +137,7 @@ func GetResolversInScope(ctx context.Context, q *Query) (selected []*Resolver, t selected = addResolvers(ctx, q, selected, mDNSResolvers) selected = addResolvers(ctx, q, selected, localResolvers) selected = addResolvers(ctx, q, selected, systemResolvers) - return selected, true + return selected, ServerSourceMDNS, true } // Special use domains @@ -145,12 +145,12 @@ func GetResolversInScope(ctx context.Context, q *Query) (selected []*Resolver, t domainInScope(q.dotPrefixedFQDN, specialServiceDomains) { selected = addResolvers(ctx, q, selected, localResolvers) selected = addResolvers(ctx, q, selected, systemResolvers) - return selected, true + return selected, "special", true } // Global domains selected = addResolvers(ctx, q, selected, globalResolvers) - return selected, false + return selected, ServerSourceConfigured, false } func addResolvers(ctx context.Context, q *Query, selected []*Resolver, addResolvers []*Resolver) []*Resolver {