mirror of
https://github.com/safing/portmaster
synced 2025-09-02 18:49:14 +00:00
Merge pull request #102 from safing/feature/serverless-captive-portals
Update captive portal detection to work without server
This commit is contained in:
commit
021ae850c4
4 changed files with 128 additions and 148 deletions
|
@ -184,18 +184,28 @@ func checkConnectionType(ctx context.Context, conn *network.Connection, _ packet
|
||||||
func checkConnectivityDomain(_ context.Context, conn *network.Connection, _ packet.Packet) bool {
|
func checkConnectivityDomain(_ context.Context, conn *network.Connection, _ packet.Packet) bool {
|
||||||
p := conn.Process().Profile()
|
p := conn.Process().Profile()
|
||||||
|
|
||||||
if !p.BlockScopeInternet() {
|
switch {
|
||||||
|
case netenv.GetOnlineStatus() > netenv.StatusPortal:
|
||||||
|
// Special grant only applies if network status is Portal (or even more limited).
|
||||||
|
return false
|
||||||
|
|
||||||
|
case conn.Inbound:
|
||||||
|
// Special grant only applies to outgoing connections.
|
||||||
|
return false
|
||||||
|
|
||||||
|
case p.BlockScopeInternet():
|
||||||
// Special grant only applies if application is allowed to connect to the Internet.
|
// Special grant only applies if application is allowed to connect to the Internet.
|
||||||
return false
|
return false
|
||||||
}
|
|
||||||
|
|
||||||
if netenv.GetOnlineStatus() <= netenv.StatusPortal &&
|
case netenv.IsConnectivityDomain(conn.Entity.Domain):
|
||||||
netenv.IsConnectivityDomain(conn.Entity.Domain) {
|
// Special grant!
|
||||||
conn.Accept("special grant for connectivity domain during network bootstrap")
|
conn.Accept("special grant for connectivity domain during network bootstrap")
|
||||||
return true
|
return true
|
||||||
}
|
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Not a special grant domain
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkConnectionScope(_ context.Context, conn *network.Connection, _ packet.Packet) bool {
|
func checkConnectionScope(_ context.Context, conn *network.Connection, _ packet.Packet) bool {
|
||||||
|
|
|
@ -15,11 +15,15 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
module = modules.Register("netenv", nil, start, nil)
|
module = modules.Register("netenv", prep, start, nil)
|
||||||
module.RegisterEvent(NetworkChangedEvent)
|
module.RegisterEvent(NetworkChangedEvent)
|
||||||
module.RegisterEvent(OnlineStatusChangedEvent)
|
module.RegisterEvent(OnlineStatusChangedEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prep() error {
|
||||||
|
return prepOnlineStatus()
|
||||||
|
}
|
||||||
|
|
||||||
func start() error {
|
func start() error {
|
||||||
module.StartServiceWorker(
|
module.StartServiceWorker(
|
||||||
"monitor network changes",
|
"monitor network changes",
|
||||||
|
|
|
@ -2,11 +2,10 @@ package netenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io/ioutil"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
@ -15,8 +14,6 @@ import (
|
||||||
|
|
||||||
"github.com/safing/portbase/notifications"
|
"github.com/safing/portbase/notifications"
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
|
||||||
|
|
||||||
"github.com/safing/portbase/log"
|
"github.com/safing/portbase/log"
|
||||||
"github.com/safing/portmaster/network/netutils"
|
"github.com/safing/portmaster/network/netutils"
|
||||||
|
|
||||||
|
@ -37,41 +34,30 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Online Status and Resolver
|
// Online Status and Resolver
|
||||||
const (
|
var (
|
||||||
HTTPTestURL = "http://detectportal.firefox.com/success.txt"
|
PortalTestIP = net.IPv4(255, 255, 255, 254)
|
||||||
HTTPExpectedContent = "success"
|
PortalTestURL = fmt.Sprintf("http://%s/", PortalTestIP)
|
||||||
HTTPSTestURL = "https://one.one.one.one/"
|
|
||||||
|
|
||||||
ResolverTestFqdn = "one.one.one.one."
|
DNSTestDomain = "one.one.one.one."
|
||||||
ResolverTestRRType = dns.TypeA
|
DNSTestExpectedIP = net.IPv4(1, 1, 1, 1)
|
||||||
ResolverTestExpectedResponse = "1.1.1.1"
|
|
||||||
|
|
||||||
SpecialCaptivePortalDomain = "captiveportal.local."
|
SpecialCaptivePortalDomain = "captiveportal.local."
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
parsedHTTPTestURL *url.URL
|
parsedPortalTestURL *url.URL
|
||||||
parsedHTTPSTestURL *url.URL
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func prepOnlineStatus() (err error) {
|
||||||
var err error
|
parsedPortalTestURL, err = url.Parse(PortalTestURL)
|
||||||
|
return err
|
||||||
parsedHTTPTestURL, err = url.Parse(HTTPTestURL)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedHTTPSTestURL, err = url.Parse(HTTPSTestURL)
|
|
||||||
if err != nil {
|
|
||||||
panic(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.
|
// 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 {
|
func IsConnectivityDomain(domain string) bool {
|
||||||
switch domain {
|
switch domain {
|
||||||
case "one.one.one.one.", // Internal DNS Check
|
case SpecialCaptivePortalDomain,
|
||||||
|
"one.one.one.one.", // Internal DNS Check
|
||||||
|
|
||||||
// Windows
|
// Windows
|
||||||
"dns.msftncsi.com.", // DNS Check
|
"dns.msftncsi.com.", // DNS Check
|
||||||
|
@ -113,11 +99,6 @@ func IsConnectivityDomain(domain string) bool {
|
||||||
return false
|
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 {
|
func (os OnlineStatus) String() string {
|
||||||
switch os {
|
switch os {
|
||||||
default:
|
default:
|
||||||
|
@ -180,7 +161,7 @@ func CheckAndGetOnlineStatus() OnlineStatus {
|
||||||
return GetOnlineStatus()
|
return GetOnlineStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateOnlineStatus(status OnlineStatus, portalURL, comment string) {
|
func updateOnlineStatus(status OnlineStatus, portalURL *url.URL, comment string) {
|
||||||
changed := false
|
changed := false
|
||||||
|
|
||||||
// status
|
// status
|
||||||
|
@ -195,29 +176,64 @@ func updateOnlineStatus(status OnlineStatus, portalURL, comment string) {
|
||||||
|
|
||||||
// captive portal
|
// captive portal
|
||||||
// delete if offline, update only if there is a new value
|
// delete if offline, update only if there is a new value
|
||||||
if status == StatusOffline || portalURL != "" {
|
if status == StatusOffline || portalURL != nil {
|
||||||
setCaptivePortal(portalURL)
|
setCaptivePortal(portalURL)
|
||||||
|
} else if status == StatusOnline {
|
||||||
|
cleanUpPortalNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
// trigger event
|
// trigger event
|
||||||
if changed {
|
if changed {
|
||||||
module.TriggerEvent(OnlineStatusChangedEvent, nil)
|
module.TriggerEvent(OnlineStatusChangedEvent, nil)
|
||||||
if status == StatusPortal {
|
if status == StatusPortal {
|
||||||
log.Infof(`network: setting online status to %s at "%s" (%s)`, status, portalURL, comment)
|
log.Infof(`netenv: setting online status to %s at "%s" (%s)`, status, portalURL, comment)
|
||||||
} else {
|
} else {
|
||||||
log.Infof("network: setting online status to %s (%s)", status, comment)
|
log.Infof("netenv: setting online status to %s (%s)", status, comment)
|
||||||
}
|
}
|
||||||
triggerNetworkChangeCheck()
|
triggerNetworkChangeCheck()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCaptivePortal(portalURL string) {
|
func setCaptivePortal(portalURL *url.URL) {
|
||||||
captivePortalLock.Lock()
|
captivePortalLock.Lock()
|
||||||
defer captivePortalLock.Unlock()
|
defer captivePortalLock.Unlock()
|
||||||
|
|
||||||
// delete
|
// delete
|
||||||
if portalURL == "" {
|
if portalURL == nil {
|
||||||
captivePortal = &CaptivePortal{}
|
captivePortal = &CaptivePortal{}
|
||||||
|
cleanUpPortalNotification()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// return if unchanged
|
||||||
|
if portalURL.String() == captivePortal.URL {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify
|
||||||
|
cleanUpPortalNotification()
|
||||||
|
defer func() {
|
||||||
|
// TODO: add "open" button
|
||||||
|
captivePortalNotification = notifications.NotifyInfo(
|
||||||
|
"netenv:captive-portal:"+captivePortal.Domain,
|
||||||
|
"Portmaster detected a captive portal at "+captivePortal.Domain,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// set
|
||||||
|
captivePortal = &CaptivePortal{
|
||||||
|
URL: portalURL.String(),
|
||||||
|
}
|
||||||
|
portalIP := net.ParseIP(portalURL.Hostname())
|
||||||
|
if portalIP != nil {
|
||||||
|
captivePortal.IP = portalIP
|
||||||
|
captivePortal.Domain = SpecialCaptivePortalDomain
|
||||||
|
} else {
|
||||||
|
captivePortal.Domain = portalURL.Hostname()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanUpPortalNotification() {
|
||||||
if captivePortalNotification != nil {
|
if captivePortalNotification != nil {
|
||||||
err := captivePortalNotification.Delete()
|
err := captivePortalNotification.Delete()
|
||||||
if err != nil && err != database.ErrNotFound {
|
if err != nil && err != database.ErrNotFound {
|
||||||
|
@ -225,52 +241,6 @@ func setCaptivePortal(portalURL string) {
|
||||||
}
|
}
|
||||||
captivePortalNotification = nil
|
captivePortalNotification = nil
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// return if unchanged
|
|
||||||
if portalURL == captivePortal.URL {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// notify
|
|
||||||
defer notifications.NotifyInfo(
|
|
||||||
"netenv:captive-portal:"+captivePortal.Domain,
|
|
||||||
"Portmaster detected a captive portal at "+captivePortal.Domain,
|
|
||||||
)
|
|
||||||
|
|
||||||
// set
|
|
||||||
captivePortal = &CaptivePortal{
|
|
||||||
URL: portalURL,
|
|
||||||
}
|
|
||||||
parsedURL, err := url.Parse(portalURL)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
log.Debugf(`netenv: failed to parse captive portal URL "%s": %s`, portalURL, err)
|
|
||||||
return
|
|
||||||
case parsedURL.Hostname() == "":
|
|
||||||
log.Debugf(`netenv: captive portal URL "%s" has no domain or IP`, portalURL)
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
// try to parse an IP
|
|
||||||
portalIP := net.ParseIP(parsedURL.Hostname())
|
|
||||||
if portalIP != nil {
|
|
||||||
captivePortal.IP = portalIP
|
|
||||||
captivePortal.Domain = SpecialCaptivePortalDomain
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// try to parse domain
|
|
||||||
// ensure fqdn format
|
|
||||||
domain := dns.Fqdn(parsedURL.Hostname())
|
|
||||||
// check validity
|
|
||||||
if !netutils.IsValidFqdn(domain) {
|
|
||||||
log.Debugf(`netenv: captive portal domain/IP "%s" is invalid`, parsedURL.Hostname())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// set domain
|
|
||||||
captivePortal.Domain = domain
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCaptivePortal returns the current captive portal. The returned struct must not be edited.
|
// GetCaptivePortal returns the current captive portal. The returned struct must not be edited.
|
||||||
|
@ -333,15 +303,17 @@ func monitorOnlineStatus(ctx context.Context) error {
|
||||||
func getDynamicStatusTrigger() <-chan time.Time {
|
func getDynamicStatusTrigger() <-chan time.Time {
|
||||||
switch GetOnlineStatus() {
|
switch GetOnlineStatus() {
|
||||||
case StatusOffline:
|
case StatusOffline:
|
||||||
return time.After(10 * time.Second)
|
return time.After(5 * time.Second)
|
||||||
case StatusLimited, StatusPortal:
|
case StatusLimited, StatusPortal:
|
||||||
return time.After(1 * time.Minute)
|
return time.After(10 * time.Second)
|
||||||
case StatusSemiOnline:
|
case StatusSemiOnline:
|
||||||
return time.After(5 * time.Minute)
|
return time.After(1 * time.Minute)
|
||||||
case StatusOnline:
|
case StatusOnline:
|
||||||
return nil
|
return nil
|
||||||
default: // unknown status
|
case StatusUnknown:
|
||||||
return time.After(5 * time.Minute)
|
return time.After(2 * time.Second)
|
||||||
|
default: // other unknown status
|
||||||
|
return time.After(1 * time.Minute)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -367,7 +339,7 @@ func checkOnlineStatus(ctx context.Context) {
|
||||||
lan = true
|
lan = true
|
||||||
case netutils.Global:
|
case netutils.Global:
|
||||||
// we _are_ the Internet ;)
|
// we _are_ the Internet ;)
|
||||||
updateOnlineStatus(StatusOnline, "", "global IPv4 interface detected")
|
updateOnlineStatus(StatusOnline, nil, "global IPv4 interface detected")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -379,20 +351,16 @@ func checkOnlineStatus(ctx context.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !lan {
|
if !lan {
|
||||||
updateOnlineStatus(StatusOffline, "", "no local or global interfaces detected")
|
updateOnlineStatus(StatusOffline, nil, "no local or global interfaces detected")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) try a http request
|
// 2) try a http request
|
||||||
|
|
||||||
// TODO: find (array of) alternatives to detectportal.firefox.com
|
|
||||||
// TODO: find something about usage terms of detectportal.firefox.com
|
|
||||||
|
|
||||||
dialer := &net.Dialer{
|
dialer := &net.Dialer{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
LocalAddr: getLocalAddr("tcp"),
|
LocalAddr: getLocalAddr("tcp"),
|
||||||
DualStack: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
|
@ -406,67 +374,63 @@ func checkOnlineStatus(ctx context.Context) {
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 1 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
request := (&http.Request{
|
request := (&http.Request{
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
URL: parsedHTTPTestURL,
|
URL: parsedPortalTestURL,
|
||||||
Close: true,
|
Close: true,
|
||||||
}).WithContext(ctx)
|
}).WithContext(ctx)
|
||||||
|
|
||||||
response, err := client.Do(request)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
updateOnlineStatus(StatusLimited, "", "http request failed")
|
nErr, ok := err.(net.Error)
|
||||||
return
|
if !ok || !nErr.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 response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
// Got a response, something is messing with the request
|
||||||
|
|
||||||
// check location
|
// check location
|
||||||
portalURL, err := response.Location()
|
portalURL, err := response.Location()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
updateOnlineStatus(StatusPortal, portalURL.String(), "http request succeeded with redirect")
|
updateOnlineStatus(StatusPortal, portalURL, "portal test request succeeded with redirect")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// read the body
|
// direct response
|
||||||
data, err := ioutil.ReadAll(response.Body)
|
if response.StatusCode == 200 {
|
||||||
|
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
|
||||||
|
|
||||||
|
// make DNS request
|
||||||
|
ips, err := net.LookupIP(DNSTestDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warningf("network: failed to read http body of captive portal testing response: %s", err)
|
updateOnlineStatus(StatusSemiOnline, nil, "dns check query failed")
|
||||||
// assume we are online nonetheless
|
|
||||||
// TODO: improve handling this case
|
|
||||||
updateOnlineStatus(StatusOnline, "", "http request succeeded, albeit failing later")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// check for expected response
|
||||||
// check body contents
|
for _, ip := range ips {
|
||||||
if strings.TrimSpace(string(data)) != HTTPExpectedContent {
|
if ip.Equal(DNSTestExpectedIP) {
|
||||||
// Something is interfering with the website content.
|
updateOnlineStatus(StatusOnline, nil, "all checks passed")
|
||||||
// This probably is a captive portal, just direct the user there.
|
|
||||||
updateOnlineStatus(StatusPortal, "detectportal.firefox.com", "http request succeeded, response content not as expected")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// close the body now as we plan to reuse the http.Client
|
|
||||||
response.Body.Close()
|
|
||||||
|
|
||||||
// 3) try a https request
|
|
||||||
dialer.LocalAddr = getLocalAddr("tcp")
|
|
||||||
|
|
||||||
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 to "+parsedHTTPSTestURL.String()+" with error "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
// unexpected response
|
||||||
|
updateOnlineStatus(StatusSemiOnline, nil, "dns check query response mismatched")
|
||||||
// finally
|
|
||||||
updateOnlineStatus(StatusOnline, "", "all checks successful")
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,15 +44,17 @@ func (er *envResolverConn) Query(ctx context.Context, q *Query) (*RRCache, error
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
|
|
||||||
case netenv.SpecialCaptivePortalDomain:
|
case netenv.SpecialCaptivePortalDomain:
|
||||||
if portal.IP != nil {
|
portalIP := portal.IP
|
||||||
records, err := netutils.IPsToRRs(q.FQDN, []net.IP{portal.IP})
|
if portal.IP == nil {
|
||||||
|
portalIP = netenv.PortalTestIP
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := netutils.IPsToRRs(q.FQDN, []net.IP{portalIP})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warningf("nameserver: failed to create captive portal response to %s: %s", q.FQDN, err)
|
log.Warningf("nameserver: failed to create captive portal response to %s: %s", q.FQDN, err)
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
return er.makeRRCache(q, records), nil
|
return er.makeRRCache(q, records), nil
|
||||||
}
|
|
||||||
return nil, ErrNotFound
|
|
||||||
|
|
||||||
case "router.local.":
|
case "router.local.":
|
||||||
routers := netenv.Gateways()
|
routers := netenv.Gateways()
|
||||||
|
|
Loading…
Add table
Reference in a new issue