Merge pull request #102 from safing/feature/serverless-captive-portals

Update captive portal detection to work without server
This commit is contained in:
Daniel 2020-07-21 15:44:56 +02:00 committed by GitHub
commit 021ae850c4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 128 additions and 148 deletions

View file

@ -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 {

View file

@ -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",

View file

@ -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")
} }

View file

@ -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()