From 9d26cd2adaaedda52d1544b2df5fb1acbcd616c7 Mon Sep 17 00:00:00 2001 From: Vladimir Stoilov Date: Mon, 4 Nov 2024 16:07:30 +0200 Subject: [PATCH 1/2] [service] Subscribe to systemd-resolver events --- .../interception/dnslistener/module.go | 171 ++++++++++++++++++ .../interception/dnslistener/varlinktypes.go | 79 ++++++++ service/instance.go | 12 ++ 3 files changed, 262 insertions(+) create mode 100644 service/firewall/interception/dnslistener/module.go create mode 100644 service/firewall/interception/dnslistener/varlinktypes.go diff --git a/service/firewall/interception/dnslistener/module.go b/service/firewall/interception/dnslistener/module.go new file mode 100644 index 000000000..64f36aea5 --- /dev/null +++ b/service/firewall/interception/dnslistener/module.go @@ -0,0 +1,171 @@ +package dnslistener + +import ( + "errors" + "fmt" + "net" + "sync/atomic" + + "github.com/miekg/dns" + "github.com/safing/portmaster/base/log" + "github.com/safing/portmaster/service/mgr" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/resolver" + "github.com/varlink/go/varlink" +) + +var ResolverInfo = resolver.ResolverInfo{ + Name: "SystemdResolver", + Type: "env", + Source: "System", +} + +type DNSListener struct { + instance instance + mgr *mgr.Manager + + varlinkConn *varlink.Connection +} + +func (dl *DNSListener) Manager() *mgr.Manager { + return dl.mgr +} + +func (dl *DNSListener) Start() error { + var err error + + // Create the varlink connection with the systemd resolver. + dl.varlinkConn, err = varlink.NewConnection(dl.mgr.Ctx(), "unix:/run/systemd/resolve/io.systemd.Resolve.Monitor") + if err != nil { + log.Errorf("dnslistener: failed to connect to systemd-resolver varlink service: %s", err) + return nil + } + + dl.mgr.Go("systemd-resolver-event-listener", func(w *mgr.WorkerCtx) error { + // Subscribe to the dns query events + receive, err := dl.varlinkConn.Send(dl.mgr.Ctx(), "io.systemd.Resolve.Monitor.SubscribeQueryResults", nil, varlink.More) + if err != nil { + if varlinkErr, ok := err.(*varlink.Error); ok { + return fmt.Errorf("failed to issue Varlink call: %+v", varlinkErr.Parameters) + } else { + return fmt.Errorf("failed to issue Varlink call: %v", err) + } + } + + for { + queryResult := QueryResult{} + // Receive the next event from the resolver. + flags, err := receive(w.Ctx(), &queryResult) + if err != nil { + if varlinkErr, ok := err.(*varlink.Error); ok { + return fmt.Errorf("failed to receive Varlink reply: %+v", varlinkErr.Parameters) + } else { + return fmt.Errorf("failed to receive Varlink reply: %v", err) + } + } + + // Check if the reply indicates the end of the stream + if flags&varlink.Continues == 0 { + break + } + + if queryResult.Rcode != nil { + continue // Ignore DNS errors + } + + dl.processAnswer(&queryResult) + + } + + return nil + }) + + return nil +} + +func (dl *DNSListener) processAnswer(queryResult *QueryResult) { + // Allocated data struct for the parsed result. + cnames := make(map[string]string) + ips := make([]net.IP, 0, 5) + + // Check if the query is valid + if queryResult.Question == nil || len(*queryResult.Question) == 0 || queryResult.Answer == nil { + return + } + + domain := (*queryResult.Question)[0].Name + + // Go trough each answer entry. + for _, a := range *queryResult.Answer { + if a.RR.Address != nil { + ip := net.IP(*a.RR.Address) + // Answer contains ip address. + ips = append(ips, ip) + + } else if a.RR.Name != nil { + // Answer is a CNAME. + cnames[domain] = *a.RR.Name + } + } + + for _, ip := range ips { + // Never save domain attributions for localhost IPs. + if netutils.GetIPScope(ip) == netutils.HostLocal { + continue + } + fqdn := dns.Fqdn(domain) + + // Create new record for this IP. + record := resolver.ResolvedDomain{ + Domain: fqdn, + Resolver: &ResolverInfo, + DNSRequestContext: &resolver.DNSRequestContext{}, + Expires: 0, + } + + for { + nextDomain, isCNAME := cnames[domain] + if !isCNAME { + break + } + + record.CNAMEs = append(record.CNAMEs, nextDomain) + domain = nextDomain + } + + info := resolver.IPInfo{ + IP: ip.String(), + } + + // Add the new record to the resolved domains for this IP and scope. + info.AddDomain(record) + + // Save if the record is new or has been updated. + if err := info.Save(); err != nil { + log.Errorf("nameserver: failed to save IP info record: %s", err) + } + } +} + +func (dl *DNSListener) Stop() error { + if dl.varlinkConn != nil { + _ = dl.varlinkConn.Close() + } + return nil +} + +var shimLoaded atomic.Bool + +func New(instance instance) (*DNSListener, error) { + if !shimLoaded.CompareAndSwap(false, true) { + return nil, errors.New("only one instance allowed") + } + m := mgr.New("DNSListener") + module := &DNSListener{ + mgr: m, + instance: instance, + } + return module, nil +} + +type instance interface{} diff --git a/service/firewall/interception/dnslistener/varlinktypes.go b/service/firewall/interception/dnslistener/varlinktypes.go new file mode 100644 index 000000000..39b2a369a --- /dev/null +++ b/service/firewall/interception/dnslistener/varlinktypes.go @@ -0,0 +1,79 @@ +package dnslistener + +// List of struct that define the systemd-resolver varlink dns event protocol. + +type ResourceKey struct { + Class int `json:"class"` + Type int `json:"type"` + Name string `json:"name"` +} + +type ResourceRecord struct { + Key ResourceKey `json:"key"` + Name *string `json:"name,omitempty"` + Address *[]byte `json:"address,omitempty"` + // Rest of the fields are not used. + // Priority *int `json:"priority,omitempty"` + // Weight *int `json:"weight,omitempty"` + // Port *int `json:"port,omitempty"` + // CPU *string `json:"cpu,omitempty"` + // OS *string `json:"os,omitempty"` + // Items *[]string `json:"items,omitempty"` + // MName *string `json:"mname,omitempty"` + // RName *string `json:"rname,omitempty"` + // Serial *int `json:"serial,omitempty"` + // Refresh *int `json:"refresh,omitempty"` + // Expire *int `json:"expire,omitempty"` + // Minimum *int `json:"minimum,omitempty"` + // Exchange *string `json:"exchange,omitempty"` + // Version *int `json:"version,omitempty"` + // Size *int `json:"size,omitempty"` + // HorizPre *int `json:"horiz_pre,omitempty"` + // VertPre *int `json:"vert_pre,omitempty"` + // Latitude *int `json:"latitude,omitempty"` + // Longitude *int `json:"longitude,omitempty"` + // Altitude *int `json:"altitude,omitempty"` + // KeyTag *int `json:"key_tag,omitempty"` + // Algorithm *int `json:"algorithm,omitempty"` + // DigestType *int `json:"digest_type,omitempty"` + // Digest *string `json:"digest,omitempty"` + // FPType *int `json:"fptype,omitempty"` + // Fingerprint *string `json:"fingerprint,omitempty"` + // Flags *int `json:"flags,omitempty"` + // Protocol *int `json:"protocol,omitempty"` + // DNSKey *string `json:"dnskey,omitempty"` + // Signer *string `json:"signer,omitempty"` + // TypeCovered *int `json:"type_covered,omitempty"` + // Labels *int `json:"labels,omitempty"` + // OriginalTTL *int `json:"original_ttl,omitempty"` + // Expiration *int `json:"expiration,omitempty"` + // Inception *int `json:"inception,omitempty"` + // Signature *string `json:"signature,omitempty"` + // NextDomain *string `json:"next_domain,omitempty"` + // Types *[]int `json:"types,omitempty"` + // Iterations *int `json:"iterations,omitempty"` + // Salt *string `json:"salt,omitempty"` + // Hash *string `json:"hash,omitempty"` + // CertUsage *int `json:"cert_usage,omitempty"` + // Selector *int `json:"selector,omitempty"` + // MatchingType *int `json:"matching_type,omitempty"` + // Data *string `json:"data,omitempty"` + // Tag *string `json:"tag,omitempty"` + // Value *string `json:"value,omitempty"` +} + +type Answer struct { + RR *ResourceRecord `json:"rr,omitempty"` + Raw string `json:"raw"` + IfIndex *int `json:"ifindex,omitempty"` +} + +type QueryResult struct { + Ready *bool `json:"ready,omitempty"` + State *string `json:"state,omitempty"` + Rcode *int `json:"rcode,omitempty"` + Errno *int `json:"errno,omitempty"` + Question *[]ResourceKey `json:"question,omitempty"` + CollectedQuestions *[]ResourceKey `json:"collectedQuestions,omitempty"` + Answer *[]Answer `json:"answer,omitempty"` +} diff --git a/service/instance.go b/service/instance.go index ad6e9dab9..01d72a8a4 100644 --- a/service/instance.go +++ b/service/instance.go @@ -19,6 +19,7 @@ import ( "github.com/safing/portmaster/service/core/base" "github.com/safing/portmaster/service/firewall" "github.com/safing/portmaster/service/firewall/interception" + "github.com/safing/portmaster/service/firewall/interception/dnslistener" "github.com/safing/portmaster/service/intel/customlists" "github.com/safing/portmaster/service/intel/filterlists" "github.com/safing/portmaster/service/intel/geoip" @@ -74,6 +75,7 @@ type Instance struct { firewall *firewall.Firewall filterLists *filterlists.FilterLists interception *interception.Interception + dnslistener *dnslistener.DNSListener customlist *customlists.CustomList status *status.Status broadcasts *broadcasts.Broadcasts @@ -187,6 +189,10 @@ func New(svcCfg *ServiceConfig) (*Instance, error) { //nolint:maintidx if err != nil { return instance, fmt.Errorf("create interception module: %w", err) } + instance.dnslistener, err = dnslistener.New(instance) + if err != nil { + return instance, fmt.Errorf("create dns-listener module: %w", err) + } instance.customlist, err = customlists.New(instance) if err != nil { return instance, fmt.Errorf("create customlist module: %w", err) @@ -288,6 +294,7 @@ func New(svcCfg *ServiceConfig) (*Instance, error) { //nolint:maintidx instance.filterLists, instance.customlist, instance.interception, + instance.dnslistener, instance.compat, instance.status, @@ -463,6 +470,11 @@ func (i *Instance) Interception() *interception.Interception { return i.interception } +// DNSListener returns the dns-listener module. +func (i *Instance) DNSListener() *dnslistener.DNSListener { + return i.dnslistener +} + // CustomList returns the customlist module. func (i *Instance) CustomList() *customlists.CustomList { return i.customlist From 811a3d94df2d096f9afbbfffabd033de882ce30c Mon Sep 17 00:00:00 2001 From: Vladimir Stoilov Date: Tue, 5 Nov 2024 13:13:24 +0200 Subject: [PATCH 2/2] [service] Add disabled state to the resolver --- go.mod | 3 ++- go.sum | 2 ++ service/compat/module.go | 9 +++++++++ service/firewall/bypassing.go | 12 ++++++++++++ service/firewall/module.go | 5 +++-- service/firewall/packet_handler.go | 3 ++- service/resolver/main.go | 7 +++++-- service/resolver/resolvers.go | 11 +++++++++-- 8 files changed, 44 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 436df0948..74baff29e 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/hashicorp/go-version v1.7.0 github.com/jackc/puddle/v2 v2.2.1 github.com/lmittmann/tint v1.0.5 + github.com/maruel/panicparse/v2 v2.3.1 github.com/mat/besticon v3.12.0+incompatible github.com/mattn/go-colorable v0.1.13 github.com/mattn/go-isatty v0.0.20 @@ -57,6 +58,7 @@ require ( github.com/tidwall/gjson v1.17.3 github.com/tidwall/sjson v1.2.5 github.com/umahmood/haversine v0.0.0-20151105152445-808ab04add26 + github.com/varlink/go v0.4.0 github.com/vincent-petithory/dataurl v1.0.0 go.etcd.io/bbolt v1.3.10 golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa @@ -90,7 +92,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/native v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect - github.com/maruel/panicparse/v2 v2.3.1 // indirect github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect diff --git a/go.sum b/go.sum index 50df69d64..d05bd31dc 100644 --- a/go.sum +++ b/go.sum @@ -311,6 +311,8 @@ github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ= github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY= +github.com/varlink/go v0.4.0 h1:+/BQoUO9eJK/+MTSHwFcJch7TMsb6N6Dqp6g0qaXXRo= +github.com/varlink/go v0.4.0/go.mod h1:DKg9Y2ctoNkesREGAEak58l+jOC6JU2aqZvUYs5DynU= github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI= github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U= github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= diff --git a/service/compat/module.go b/service/compat/module.go index 5ac97b511..62118368d 100644 --- a/service/compat/module.go +++ b/service/compat/module.go @@ -99,6 +99,12 @@ func stop() error { } func selfcheckTaskFunc(wc *mgr.WorkerCtx) error { + res := module.instance.Resolver() + if res.IsDisabled.IsSet() { + log.Debugf("compat: skipping self-check: resolver is disabled") + return nil + } + // Create tracing logger. ctx, tracer := log.AddTracer(wc.Ctx()) defer tracer.Submit() @@ -118,6 +124,8 @@ func selfcheckTaskFunc(wc *mgr.WorkerCtx) error { tracer.Warningf("compat: %s", err) case selfcheckNetworkChangedFlag.IsSet(): // The network changed, ignore the issue. + case res.IsDisabled.IsSet(): + // Portmaster resolver is disabled, ignore this issue. default: // The self-check failed. @@ -181,4 +189,5 @@ func New(instance instance) (*Compat, error) { type instance interface { NetEnv() *netenv.NetEnv + Resolver() *resolver.ResolverModule } diff --git a/service/firewall/bypassing.go b/service/firewall/bypassing.go index 415fc6c88..1c9077f02 100644 --- a/service/firewall/bypassing.go +++ b/service/firewall/bypassing.go @@ -43,6 +43,18 @@ func PreventBypassing(ctx context.Context, conn *network.Connection) (endpoints. return endpoints.NoMatch, "", nil } + // If Portmaster resolver is disabled allow requests going to system dns resolver. + // And allow all connections out of the System Resolver. + if module.instance.Resolver().IsDisabled.IsSet() { + // TODO(vladimir): Is there a more specific check that can be done? + if conn.Process().IsSystemResolver() { + return endpoints.NoMatch, "", nil + } + if conn.Entity.Port == 53 && conn.Entity.IPScope.IsLocalhost() { + return endpoints.NoMatch, "", nil + } + } + // Block bypass attempts using an (encrypted) DNS server. switch { case conn.Entity.Port == 53: diff --git a/service/firewall/module.go b/service/firewall/module.go index 131d4cacb..2ac87de2c 100644 --- a/service/firewall/module.go +++ b/service/firewall/module.go @@ -16,6 +16,7 @@ import ( "github.com/safing/portmaster/service/netquery" "github.com/safing/portmaster/service/network" "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/resolver" "github.com/safing/portmaster/spn/access" "github.com/safing/portmaster/spn/captain" ) @@ -34,8 +35,7 @@ func (ss *stringSliceFlag) Set(value string) error { var allowedClients stringSliceFlag type Firewall struct { - mgr *mgr.Manager - + mgr *mgr.Manager instance instance } @@ -165,4 +165,5 @@ type instance interface { Access() *access.Access Network() *network.Network NetQuery() *netquery.NetQuery + Resolver() *resolver.ResolverModule } diff --git a/service/firewall/packet_handler.go b/service/firewall/packet_handler.go index a290182f8..4c06c9682 100644 --- a/service/firewall/packet_handler.go +++ b/service/firewall/packet_handler.go @@ -444,8 +444,9 @@ func filterHandler(conn *network.Connection, pkt packet.Packet) { filterConnection = false log.Tracer(pkt.Ctx()).Infof("filter: granting own pre-authenticated connection %s", conn) - // Redirect outbound DNS packets if enabled, + // Redirect outbound DNS packets if enabled, case dnsQueryInterception() && + module.instance.Resolver().IsDisabled.IsNotSet() && pkt.IsOutbound() && pkt.Info().DstPort == 53 && // that don't match the address of our nameserver, diff --git a/service/resolver/main.go b/service/resolver/main.go index 8a43d12be..da22b064f 100644 --- a/service/resolver/main.go +++ b/service/resolver/main.go @@ -29,6 +29,8 @@ type ResolverModule struct { //nolint failingResolverWorkerMgr *mgr.WorkerMgr suggestUsingStaleCacheTask *mgr.WorkerMgr + IsDisabled abool.AtomicBool + states *mgr.StateMgr } @@ -267,8 +269,9 @@ func New(instance instance) (*ResolverModule, error) { } m := mgr.New("Resolver") module = &ResolverModule{ - mgr: m, - instance: instance, + mgr: m, + instance: instance, + IsDisabled: *abool.New(), states: mgr.NewStateMgr(m), } diff --git a/service/resolver/resolvers.go b/service/resolver/resolvers.go index c5609a017..59abc37ad 100644 --- a/service/resolver/resolvers.go +++ b/service/resolver/resolvers.go @@ -388,7 +388,6 @@ func loadResolvers() { // Resolve module error about missing resolvers. module.states.Remove(missingResolversErrorID) - // Check if settings were changed and clear name cache when they did. newResolverConfig := configuredNameServers() if len(currentResolverConfig) > 0 && @@ -399,6 +398,14 @@ func loadResolvers() { return err }) } + + // If no resolvers are configure set the disabled state. So other modules knows that the users does not want to use Portmaster resolver. + if len(newResolverConfig) == 0 { + module.IsDisabled.Set() + } else { + module.IsDisabled.UnSet() + } + currentResolverConfig = newResolverConfig newResolvers := append( @@ -431,7 +438,7 @@ func loadResolvers() { // save resolvers globalResolvers = newResolvers - // assing resolvers to scopes + // assign resolvers to scopes setScopedResolvers(globalResolvers) // set active resolvers (for cache validation)