Improve captive portal handling

This commit is contained in:
Daniel 2020-07-15 08:53:55 +02:00
parent ec637bdce8
commit e369a9484a
13 changed files with 323 additions and 31 deletions

View file

@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"github.com/safing/portmaster/netenv"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/network"
"github.com/safing/portmaster/network/netutils"
@ -50,6 +52,7 @@ func DecideOnConnection(ctx context.Context, conn *network.Connection, pkt packe
checkSelfCommunication,
checkProfileExists,
checkConnectionType,
checkCaptivePortal,
checkConnectionScope,
checkEndpointLists,
checkBypassPrevention,
@ -178,6 +181,16 @@ func checkConnectionType(ctx context.Context, conn *network.Connection, _ packet
return false
}
func checkCaptivePortal(_ context.Context, conn *network.Connection, _ packet.Packet) bool {
if netenv.GetOnlineStatus() == netenv.StatusPortal &&
conn.Entity.Domain == netenv.GetCaptivePortal().Domain {
conn.Accept("captive portal access permitted")
return true
}
return false
}
func checkConnectionScope(_ context.Context, conn *network.Connection, _ packet.Packet) bool {
p := conn.Process().Profile()

View file

@ -115,7 +115,7 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, query *dns.Msg) er
// return with server failure if offline
if netenv.GetOnlineStatus() == netenv.StatusOffline &&
!netenv.IsOnlineStatusTestDomain(q.FQDN) {
!netenv.IsConnectivityDomain(q.FQDN) {
log.Tracer(ctx).Debugf("resolver: not resolving %s, device is offline", q.FQDN)
returnServerFailure(w, query)
return nil

View file

@ -6,7 +6,7 @@ func Nameservers() []Nameserver {
return nil
}
func Gateways() []*net.IP {
func Gateways() []net.IP {
return nil
}

View file

@ -21,7 +21,7 @@ const (
)
var (
gateways = make([]*net.IP, 0)
gateways = make([]net.IP, 0)
gatewaysLock sync.Mutex
gatewaysExpires = time.Now()
@ -31,7 +31,7 @@ var (
)
// Gateways returns the currently active gateways.
func Gateways() []*net.IP {
func Gateways() []net.IP {
// locking
gatewaysLock.Lock()
defer gatewaysLock.Unlock()
@ -45,7 +45,7 @@ func Gateways() []*net.IP {
}()
// logic
newGateways := make([]*net.IP, 0)
newGateways := make([]net.IP, 0)
var decoded []byte
// open file
@ -77,7 +77,7 @@ func Gateways() []*net.IP {
continue
}
gate := net.IPv4(decoded[3], decoded[2], decoded[1], decoded[0])
newGateways = append(newGateways, &gate)
newGateways = append(newGateways, gate)
}
}
@ -110,7 +110,7 @@ func Gateways() []*net.IP {
continue
}
gate := net.IP(decoded)
newGateways = append(newGateways, &gate)
newGateways = append(newGateways, gate)
}
}

View file

@ -1,13 +1,95 @@
package netenv
import "net"
import (
"bufio"
"bytes"
"context"
"net"
"os/exec"
"strings"
"sync"
"time"
"github.com/safing/portbase/log"
)
const (
nameserversRecheck = 2 * time.Second
)
var (
nameservers = make([]Nameserver, 0)
nameserversLock sync.Mutex
nameserversExpires = time.Now()
)
// Nameservers returns the currently active nameservers.
func Nameservers() []Nameserver {
return nil
// locking
nameserversLock.Lock()
defer nameserversLock.Unlock()
// cache
if nameserversExpires.After(time.Now()) {
return nameservers
}
// update cache expiry when finished
defer func() {
nameserversExpires = time.Now().Add(nameserversRecheck)
}()
// reset
nameservers = make([]Nameserver, 0)
// This is a preliminary workaround until we have more time for proper interface using iphlpapi.dll
// TODO: make nice implementation
var output = make(chan []byte, 1)
module.StartWorker("get assigned nameservers", func(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "nslookup", "localhost")
data, err := cmd.CombinedOutput()
if err != nil {
log.Debugf("netenv: failed to get assigned nameserves: %s", err)
output <- nil
} else {
output <- data
}
return nil
})
select {
case data := <-output:
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
// check if we found the correct line
if !strings.HasPrefix(scanner.Text(), "Address:") {
continue
}
// split into fields, check if we have enough fields
fields := strings.Fields(scanner.Text())
if len(fields) < 2 {
continue
}
// parse nameserver, return if valid IP found
ns := net.ParseIP(fields[1])
if ns != nil {
nameservers = append(nameservers, Nameserver{
IP: ns,
})
return nameservers
}
}
log.Debug("netenv: could not find assigned nameserver")
return nameservers
case <-time.After(5 * time.Second):
log.Debug("netenv: timed out while getting assigned nameserves")
}
return nameservers
}
// Gateways returns the currently active gateways.
func Gateways() []*net.IP {
func Gateways() []net.IP {
return nil
}

View file

@ -2,6 +2,7 @@ package netenv
import (
"context"
"errors"
"io/ioutil"
"net"
"net/http"
@ -41,6 +42,8 @@ const (
ResolverTestFqdn = "one.one.one.one."
ResolverTestRRType = dns.TypeA
ResolverTestExpectedResponse = "1.1.1.1"
SpecialCaptivePortalDomain = "captiveportal.local."
)
var (
@ -62,12 +65,12 @@ func init() {
}
}
// IsOnlineStatusTestDomain checks whether the given fqdn is used for testing online status.
func IsOnlineStatusTestDomain(domain string) bool {
// 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 {
switch domain {
case "detectportal.firefox.com.":
return true
case "one.one.one.one.":
case "detectportal.firefox.com.",
"one.one.one.one.",
GetCaptivePortal().Domain:
return true
}
@ -104,10 +107,34 @@ var (
onlineStatusInvestigationInProgress = abool.NewBool(false)
onlineStatusInvestigationWg sync.WaitGroup
captivePortalURL string
captivePortal = &CaptivePortal{}
captivePortalLock sync.Mutex
)
type CaptivePortal struct {
URL string
Domain string
IP net.IP
}
// IPasRR returns the captive portal IP as a DNS resource record.
func (p *CaptivePortal) IPasRR() (rr dns.RR, err error) {
switch {
case p.IP == nil:
return nil, errors.New("no portal IP present")
case p.Domain == "":
return nil, errors.New("no portal domain present")
case p.IP.To4() != nil:
rr, err = dns.NewRR(p.Domain + " 17 IN A " + p.IP.String())
default:
rr, err = dns.NewRR(p.Domain + " 17 IN AAAA " + p.IP.String())
}
if err != nil {
return nil, err
}
return rr, nil
}
func init() {
var onlineStatusValue int32
onlineStatus = &onlineStatusValue
@ -147,18 +174,16 @@ func updateOnlineStatus(status OnlineStatus, portalURL, comment string) {
}
// captive portal
captivePortalLock.Lock()
defer captivePortalLock.Unlock()
if portalURL != captivePortalURL {
captivePortalURL = portalURL
changed = true
// delete if offline, update only if there is a new value
if status == StatusOffline || portalURL != "" {
setCaptivePortal(portalURL)
}
// trigger event
if changed {
module.TriggerEvent(OnlineStatusChangedEvent, nil)
if status == StatusPortal {
log.Infof(`network: setting online status to %s at "%s" (%s)`, status, captivePortalURL, comment)
log.Infof(`network: setting online status to %s at "%s" (%s)`, status, portalURL, comment)
} else {
log.Infof("network: setting online status to %s (%s)", status, comment)
}
@ -166,12 +191,56 @@ func updateOnlineStatus(status OnlineStatus, portalURL, comment string) {
}
}
// GetCaptivePortalURL returns the current captive portal url as a string.
func GetCaptivePortalURL() string {
func setCaptivePortal(portalURL string) {
captivePortalLock.Lock()
defer captivePortalLock.Unlock()
return captivePortalURL
// delete
if portalURL == "" {
captivePortal = &CaptivePortal{}
return
}
// 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.
func GetCaptivePortal() *CaptivePortal {
captivePortalLock.Lock()
defer captivePortalLock.Unlock()
return captivePortal
}
// ReportSuccessfulConnection hints the online status monitoring system that a connection attempt was successful.

View file

@ -8,5 +8,5 @@ import (
func TestCheckOnlineStatus(t *testing.T) {
checkOnlineStatus(context.Background())
t.Logf("online status: %s", GetOnlineStatus())
t.Logf("captive portal: %s", GetCaptivePortalURL())
t.Logf("captive portal: %+v", GetCaptivePortal())
}

View file

@ -8,6 +8,8 @@ import (
"sync"
"time"
"github.com/safing/portmaster/netenv"
"github.com/safing/portbase/database/record"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/intel"
@ -148,6 +150,13 @@ func NewConnectionFromFirstPacket(pkt packet.Packet) *Connection {
}
}
// check if destination IP is the captive portal's IP
portal := netenv.GetCaptivePortal()
if portal.IP != nil && pkt.Info().Dst.Equal(portal.IP) {
scope = portal.Domain
entity.Domain = portal.Domain
}
if scope == "" {
// outbound direct (possibly P2P) connection

View file

@ -19,7 +19,7 @@ var (
// basic errors
// ErrNotFound is a basic error that will match all "not found" errors
ErrNotFound = errors.New("record does not exist")
ErrNotFound = errors.New("record could not be found")
// ErrBlocked is basic error that will match all "blocked" errors
ErrBlocked = errors.New("query was blocked")
// ErrLocalhost is returned to *.localhost queries
@ -30,6 +30,8 @@ var (
ErrOffline = errors.New("device is offine")
// ErrFailure is returned when the type of failure is unclear
ErrFailure = errors.New("query failed")
// ErrContinue is returned when the resolver has no answer, and the next resolver should be asked
ErrContinue = errors.New("resolver has no answer")
// detailed errors
@ -228,7 +230,7 @@ func resolveAndCache(ctx context.Context, q *Query) (rrCache *RRCache, err error
// check if we are online
if netenv.GetOnlineStatus() == netenv.StatusOffline {
if !netenv.IsOnlineStatusTestDomain(q.FQDN) {
if !netenv.IsConnectivityDomain(q.FQDN) {
log.Tracer(ctx).Debugf("resolver: not resolving %s, device is offline", q.FQDN)
// we are offline and this is not an online check query
return nil, ErrOffline
@ -260,7 +262,7 @@ resolveLoop:
// some resolvers might also block
return nil, err
case netenv.GetOnlineStatus() == netenv.StatusOffline &&
!netenv.IsOnlineStatusTestDomain(q.FQDN):
!netenv.IsConnectivityDomain(q.FQDN):
log.Tracer(ctx).Debugf("resolver: not resolving %s, device is offline", q.FQDN)
// we are offline and this is not an online check query
return nil, ErrOffline

95
resolver/resolver-env.go Normal file
View file

@ -0,0 +1,95 @@
package resolver
import (
"context"
"fmt"
"net"
"github.com/miekg/dns"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/netenv"
"github.com/safing/portmaster/network/netutils"
)
var (
envResolver = &Resolver{
Server: ServerSourceEnv,
ServerType: ServerTypeEnv,
ServerIPScope: netutils.SiteLocal,
Source: ServerSourceEnv,
Conn: &envResolverConn{},
}
)
type envResolverConn struct{}
func (er *envResolverConn) Query(ctx context.Context, q *Query) (*RRCache, error) {
// prepping
portal := netenv.GetCaptivePortal()
// check for matching name
switch q.FQDN {
case netenv.SpecialCaptivePortalDomain:
if portal.IP != nil {
rr, err := portal.IPasRR()
if err != nil {
log.Warningf("nameserver: failed to create captive portal response to %s: %s", q.FQDN, err)
return nil, ErrNotFound
}
return er.makeRRCache(q, []dns.RR{rr}), nil
}
return nil, ErrNotFound
case "router.local.":
routers := netenv.Gateways()
if len(routers) == 0 {
return nil, ErrNotFound
}
records, err := ipsToRRs(q.FQDN, routers)
if err != nil {
log.Warningf("nameserver: failed to create gateway response to %s: %s", q.FQDN, err)
return nil, ErrNotFound
}
return er.makeRRCache(q, records), nil
}
// no match
return nil, ErrContinue // continue with next resolver
}
func (er *envResolverConn) makeRRCache(q *Query, answers []dns.RR) *RRCache {
q.NoCaching = true // disable caching, as the env always has the data available and more up to date.
return &RRCache{
Domain: q.FQDN,
Question: q.QType,
Answer: answers,
Server: envResolver.Server,
ServerScope: envResolver.ServerIPScope,
}
}
func (er *envResolverConn) ReportFailure() {}
func (er *envResolverConn) IsFailing() bool {
return false
}
func ipsToRRs(domain string, ips []net.IP) ([]dns.RR, error) {
var records []dns.RR
var rr dns.RR
var err error
for _, ip := range ips {
if ip.To4() != nil {
rr, err = dns.NewRR(domain + " 17 IN A " + ip.String())
} else {
rr, err = dns.NewRR(domain + " 17 IN AAAA " + ip.String())
}
if err != nil {
return nil, fmt.Errorf("failed to create record for %s: %w", ip, err)
}
records = append(records, rr)
}
return records, nil
}

View file

@ -18,10 +18,12 @@ const (
ServerTypeTCP = "tcp"
ServerTypeDoT = "dot"
ServerTypeDoH = "doh"
ServerTypeEnv = "env"
ServerSourceConfigured = "config"
ServerSourceAssigned = "dhcp"
ServerSourceMDNS = "mdns"
ServerSourceEnv = "env"
)
var (

View file

@ -24,6 +24,7 @@ type Scope struct {
var (
globalResolvers []*Resolver // all (global) resolvers
localResolvers []*Resolver // all resolvers that are in site-local or link-local IP ranges
systemResolvers []*Resolver // all resolvers that were assigned by the system
localScopes []*Scope // list of scopes with a list of local resolvers that can resolve the scope
activeResolvers map[string]*Resolver // lookup map of all resolvers
resolversLock sync.RWMutex
@ -231,7 +232,7 @@ func loadResolvers() {
globalResolvers = newResolvers
// assing resolvers to scopes
setLocalAndScopeResolvers(globalResolvers)
setScopedResolvers(globalResolvers)
// set active resolvers (for cache validation)
// reset
@ -241,6 +242,7 @@ func loadResolvers() {
activeResolvers[resolver.Server] = resolver
}
activeResolvers[mDNSResolver.Server] = mDNSResolver
activeResolvers[envResolver.Server] = envResolver
// log global resolvers
if len(globalResolvers) > 0 {
@ -282,9 +284,10 @@ func loadResolvers() {
}
}
func setLocalAndScopeResolvers(resolvers []*Resolver) {
func setScopedResolvers(resolvers []*Resolver) {
// make list with local resolvers
localResolvers = make([]*Resolver, 0)
systemResolvers = make([]*Resolver, 0)
localScopes = make([]*Scope, 0)
for _, resolver := range resolvers {
@ -292,6 +295,10 @@ func setLocalAndScopeResolvers(resolvers []*Resolver) {
localResolvers = append(localResolvers, resolver)
}
if resolver.Source == "dhcp" {
systemResolvers = append(systemResolvers, resolver)
}
if resolver.Search != nil {
// add resolver to custom searches
for _, search := range resolver.Search {

View file

@ -5,6 +5,8 @@ import (
"errors"
"strings"
"github.com/safing/portmaster/netenv"
"github.com/miekg/dns"
"github.com/safing/portbase/log"
)
@ -124,6 +126,13 @@ func GetResolversInScope(ctx context.Context, q *Query) (selected []*Resolver) {
// global -> local scopes, global
// special -> local scopes, local
// special connectivity domains
if netenv.IsConnectivityDomain(q.FQDN) && len(systemResolvers) > 0 {
selected = append(selected, envResolver)
selected = append(selected, systemResolvers...) // dhcp assigned resolvers
return selected
}
// check local scopes
for _, scope := range localScopes {
if strings.HasSuffix(q.dotPrefixedFQDN, scope.Domain) {
@ -169,6 +178,8 @@ func GetResolversInScope(ctx context.Context, q *Query) (selected []*Resolver) {
// check for .local mdns
if strings.HasSuffix(q.dotPrefixedFQDN, local) {
// add env resolver
selected = append(selected, envResolver)
// add mdns
if err := mDNSResolver.checkCompliance(ctx, q); err == nil {
selected = append(selected, mDNSResolver)
@ -255,6 +266,8 @@ func (resolver *Resolver) checkCompliance(_ context.Context, q *Query) error {
// compliant
case ServerTypeDoH:
// compliant
case ServerTypeEnv:
// compliant (data is sources from local network only and is highly limited)
default:
return errInsecureProtocol
}