Skip to content

Commit

Permalink
Feature/systemd query events (#1728)
Browse files Browse the repository at this point in the history
* [service] Subscribe to systemd-resolver events

* [service] Add disabled state to the resolver

* [service] Add ETW DNS event listener

* [service] DNS listener refactoring

* [service] Add windows core dll project

* [service] DNSListener refactoring, small bugfixes

* [service] Change dns bypass rule

* [service] Update gitignore

* [service] Remove shim from integration module

* [service] Add DNS packet analyzer

* [service] Add self-check in dns monitor

* [service] Fix go linter errors

* [CI] Add github workflow for the windows core dll

* [service] Minor fixes to the dns monitor
  • Loading branch information
vlabo authored Nov 27, 2024
1 parent 943b9b7 commit 1a1bc14
Show file tree
Hide file tree
Showing 41 changed files with 1,668 additions and 51 deletions.
41 changes: 41 additions & 0 deletions .github/workflows/windows-dll.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Windows Portmaster Core DLL

on:
push:
paths:
- 'windows_core_dll/**'
branches:
- master
- develop

pull_request:
paths:
- 'windows_core_dll/**'
branches:
- master
- develop
workflow_dispatch:

jobs:
build:
name: Build
runs-on: windows-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Add msbuild to PATH
uses: microsoft/setup-msbuild@v2
- name: Build DLL
run: msbuild windows_core_dll\windows_core_dll.sln -t:rebuild -property:Configuration=Release
- name: Verify DLL
shell: powershell
run: |
if (!(Test-Path "windows_core_dll/x64/Release/portmaster-core.dll")) {
Write-Error "DLL build failed: portmaster-core.dll not found"
exit 1
}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: portmaster-core-dll
path: windows_core_dll/x64/Release/portmaster-core.dll
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ go.work.sum

# Kext releases
windows_kext/release/kext_release_*.zip
windows_core_dll/.vs/windows_core_dll
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ require (
github.com/tidwall/gjson v1.18.0
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.11
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f
Expand Down Expand Up @@ -92,6 +93,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // 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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,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=
Expand Down
1 change: 1 addition & 0 deletions service/compat/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,5 @@ func New(instance instance) (*Compat, error) {

type instance interface {
NetEnv() *netenv.NetEnv
Resolver() *resolver.ResolverModule
}
6 changes: 6 additions & 0 deletions service/compat/selfcheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ func selfcheck(ctx context.Context) (issue *systemIssue, err error) {

// Step 3: Have the nameserver respond with random data in the answer section.

// Check if the resolver is enabled
if module.instance.Resolver().IsDisabled() {
// There is no control over the response, there is nothing more that can be checked.
return nil, nil
}

// Wait for the reply from the resolver.
select {
case err := <-dnsCheckLookupError:
Expand Down
30 changes: 30 additions & 0 deletions service/firewall/bypassing.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,24 @@ 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() {
// 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 looksLikeOutgoingDNSRequest(conn) && module.instance.Resolver().IsDisabled():
// Allow. Packet will be analyzed and blocked if its not a dns request, before sent.
conn.Inspecting = true
return endpoints.NoMatch, "", nil
case conn.Entity.Port == 53:
return endpoints.Denied,
"blocked DNS query, manual dns setup required",
Expand All @@ -62,3 +78,17 @@ func PreventBypassing(ctx context.Context, conn *network.Connection) (endpoints.

return endpoints.NoMatch, "", nil
}

func looksLikeOutgoingDNSRequest(conn *network.Connection) bool {
// Outbound on remote port 53, UDP.
if conn.Inbound {
return false
}
if conn.Entity.Port != 53 {
return false
}
if conn.IPProtocol != packet.UDP {
return false
}
return true
}
54 changes: 24 additions & 30 deletions service/firewall/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,38 +287,37 @@ func UpdateIPsAndCNAMEs(q *resolver.Query, rrCache *resolver.RRCache, conn *netw
}
}

// Create new record for this IP.
record := resolver.ResolvedDomain{
Domain: q.FQDN,
Resolver: rrCache.Resolver,
DNSRequestContext: rrCache.ToDNSRequestContext(),
Expires: rrCache.Expires,
}
// Process CNAMEs
record.AddCNAMEs(cnames)
// Link connection with cnames.
if conn.Type == network.DNSRequest {
conn.Entity.CNAME = record.CNAMEs
}

SaveIPsInCache(ips, profileID, record)
}

// formatRR is a friendlier alternative to miekg/dns.RR.String().
func formatRR(rr dns.RR) string {
return strings.ReplaceAll(rr.String(), "\t", " ")
}

// SaveIPsInCache saves the provided ips in the dns cashe assoseted with the record Domain and CNAMEs.
func SaveIPsInCache(ips []net.IP, profileID string, record resolver.ResolvedDomain) {
// Package IPs and CNAMEs into IPInfo structs.
for _, ip := range ips {
// Never save domain attributions for localhost IPs.
if netutils.GetIPScope(ip) == netutils.HostLocal {
continue
}

// Create new record for this IP.
record := resolver.ResolvedDomain{
Domain: q.FQDN,
Resolver: rrCache.Resolver,
DNSRequestContext: rrCache.ToDNSRequestContext(),
Expires: rrCache.Expires,
}

// Resolve all CNAMEs in the correct order and add the to the record - up to max 50 layers.
domain := q.FQDN
for range 50 {
nextDomain, isCNAME := cnames[domain]
if !isCNAME || nextDomain == domain {
break
}

record.CNAMEs = append(record.CNAMEs, nextDomain)
domain = nextDomain
}

// Update the entity to include the CNAMEs of the query response.
conn.Entity.CNAME = record.CNAMEs

// Check if there is an existing record for this DNS response.
// Else create a new one.
ipString := ip.String()
info, err := resolver.GetIPInfo(profileID, ipString)
if err != nil {
Expand All @@ -341,8 +340,3 @@ func UpdateIPsAndCNAMEs(q *resolver.Query, rrCache *resolver.RRCache, conn *netw
}
}
}

// formatRR is a friendlier alternative to miekg/dns.RR.String().
func formatRR(rr dns.RR) string {
return strings.ReplaceAll(rr.String(), "\t", " ")
}
99 changes: 99 additions & 0 deletions service/firewall/interception/dnsmonitor/etwlink_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//go:build windows
// +build windows

package dnsmonitor

import (
"fmt"
"runtime"
"sync"
"sync/atomic"

"github.com/safing/portmaster/service/integration"
"golang.org/x/sys/windows"
)

type ETWSession struct {
i integration.ETWFunctions

shutdownGuard atomic.Bool
shutdownMutex sync.Mutex

state uintptr
}

// NewSession creates new ETW event listener and initilizes it. This is a low level interface, make sure to call DestorySession when you are done using it.
func NewSession(etwInterface integration.ETWFunctions, callback func(domain string, result string)) (*ETWSession, error) {
etwSession := &ETWSession{
i: etwInterface,
}

// Make sure session from previous instances are not running.
_ = etwSession.i.StopOldSession()

// Initialize notification activated callback
win32Callback := windows.NewCallback(func(domain *uint16, result *uint16) uintptr {
callback(windows.UTF16PtrToString(domain), windows.UTF16PtrToString(result))
return 0
})
// The function only allocates memory it will not fail.
etwSession.state = etwSession.i.CreateState(win32Callback)

// Make sure DestroySession is called even if caller forgets to call it.
runtime.SetFinalizer(etwSession, func(s *ETWSession) {
_ = s.i.DestroySession(s.state)
})

// Initialize session.
err := etwSession.i.InitializeSession(etwSession.state)
if err != nil {
return nil, fmt.Errorf("failed to initialzie session: %q", err)
}

return etwSession, nil
}

// StartTrace starts the tracing session of dns events. This is a blocking call. It will not return until the trace is stopped.
func (l *ETWSession) StartTrace() error {
return l.i.StartTrace(l.state)
}

// IsRunning returns true if DestroySession has NOT been called.
func (l *ETWSession) IsRunning() bool {
return !l.shutdownGuard.Load()
}

// FlushTrace flushes the trace buffer.
func (l *ETWSession) FlushTrace() error {
l.shutdownMutex.Lock()
defer l.shutdownMutex.Unlock()

// Make sure session is still running.
if l.shutdownGuard.Load() {
return nil
}

return l.i.FlushTrace(l.state)
}

// StopTrace stopes the trace. This will cause StartTrace to return.
func (l *ETWSession) StopTrace() error {
return l.i.StopTrace(l.state)
}

// DestroySession closes the session and frees the allocated memory. Listener cannot be used after this function is called.
func (l *ETWSession) DestroySession() error {
l.shutdownMutex.Lock()
defer l.shutdownMutex.Unlock()

if l.shutdownGuard.Swap(true) {
return nil
}

err := l.i.DestroySession(l.state)
if err != nil {
return err
}
l.state = 0
return nil
}
19 changes: 19 additions & 0 deletions service/firewall/interception/dnsmonitor/eventlistener.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//go:build !linux && !windows
// +build !linux,!windows

package dnsmonitor

type Listener struct{}

func newListener(_ *DNSMonitor) (*Listener, error) {
return &Listener{}, nil
}

func (l *Listener) flush() error {
// Nothing to flush
return nil
}

func (l *Listener) stop() error {
return nil
}
Loading

0 comments on commit 1a1bc14

Please sign in to comment.