mirror of
https://github.com/safing/portmaster
synced 2025-04-22 11:59:09 +00:00
618 lines
15 KiB
Go
618 lines
15 KiB
Go
package intel
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"golang.org/x/net/publicsuffix"
|
|
|
|
"github.com/safing/portmaster/base/log"
|
|
"github.com/safing/portmaster/service/intel/filterlists"
|
|
"github.com/safing/portmaster/service/intel/geoip"
|
|
"github.com/safing/portmaster/service/network/netutils"
|
|
)
|
|
|
|
// Entity describes a remote endpoint in many different ways.
|
|
// It embeddes a sync.Mutex but none of the endpoints own
|
|
// functions performs locking. The caller MUST ENSURE
|
|
// proper locking and synchronization when accessing
|
|
// any properties of Entity.
|
|
type Entity struct { //nolint:maligned
|
|
sync.Mutex
|
|
|
|
// lists exist for most entity information and
|
|
// we need to know which one we loaded
|
|
domainListLoaded bool
|
|
ipListLoaded bool
|
|
countryListLoaded bool
|
|
asnListLoaded bool
|
|
reverseResolveEnabled bool
|
|
resolveSubDomainLists bool
|
|
checkCNAMEs bool
|
|
|
|
// IP is the IP address of the connection. If domain is
|
|
// set, IP has been resolved by following all CNAMEs.
|
|
IP net.IP
|
|
|
|
// IPScope holds the network scope of the IP.
|
|
// For DNS requests, this signifies in which scope the DNS request was resolved.
|
|
IPScope netutils.IPScope
|
|
|
|
// Protocol is the protcol number used by the connection.
|
|
Protocol uint8
|
|
|
|
// Port is the remote port of the connection
|
|
Port uint16
|
|
|
|
// dstPort is the destination port of the connection
|
|
dstPort uint16
|
|
|
|
// Domain is the target domain of the connection.
|
|
Domain string
|
|
|
|
// ReverseDomain is the domain the IP address points to. This is only
|
|
// resolved and populated when needed.
|
|
ReverseDomain string
|
|
|
|
// CNAME is a list of domain names that have been
|
|
// resolved for Domain.
|
|
CNAME []string
|
|
|
|
// Country holds the country the IP address (ASN) is
|
|
// located in.
|
|
Country string
|
|
|
|
// Coordinates holds the approximate coordinates of the IP address.
|
|
Coordinates *geoip.Coordinates
|
|
|
|
// ASN holds the autonomous system number of the IP.
|
|
ASN uint
|
|
|
|
// ASOrg holds the owner's name of the autonomous system.
|
|
ASOrg string
|
|
|
|
// LocationError holds an error message if fetching the location failed.
|
|
LocationError string
|
|
|
|
location *geoip.Location
|
|
|
|
// BlockedByLists holds list source IDs that
|
|
// are used to block the entity.
|
|
BlockedByLists []string
|
|
|
|
// BlockedEntities holds a list of entities that
|
|
// have been blocked. Values can be used as a key
|
|
// for the ListOccurences map.
|
|
BlockedEntities []string
|
|
|
|
// ListOccurences is a map that matches an entity (Domain, IPs, ASN, Country, Sub-domain)
|
|
// to a list of sources where the entity has been observed in.
|
|
ListOccurences map[string][]string
|
|
|
|
// ListsError holds an error message if fetching the lists failed.
|
|
ListsError string
|
|
|
|
// we only load each data above at most once
|
|
fetchLocationOnce sync.Once
|
|
reverseResolveOnce sync.Once
|
|
loadDomainListOnce sync.Once
|
|
loadIPListOnce sync.Once
|
|
loadCountryListOnce sync.Once
|
|
loadAsnListOnce sync.Once
|
|
}
|
|
|
|
// Init initializes internal metadata about the entity.
|
|
// If the entity does not describe a destination, you can supply a different
|
|
// destination port for endpoint matching.
|
|
// It returns the entity itself for single line formatting.
|
|
func (e *Entity) Init(dstPort uint16) *Entity {
|
|
// Get IP scope.
|
|
if e.IP != nil {
|
|
e.IPScope = netutils.GetIPScope(e.IP)
|
|
} else {
|
|
e.IPScope = netutils.Undefined
|
|
}
|
|
|
|
// Set dst port to given value or fall back to entity.
|
|
if dstPort > 0 {
|
|
e.dstPort = dstPort
|
|
} else {
|
|
e.dstPort = e.Port
|
|
}
|
|
|
|
return e
|
|
}
|
|
|
|
// DstPort returns the destination port.
|
|
func (e *Entity) DstPort() uint16 {
|
|
return e.dstPort
|
|
}
|
|
|
|
// FetchData fetches additional information, meant to be called before persisting an entity record.
|
|
func (e *Entity) FetchData(ctx context.Context) {
|
|
e.getLocation(ctx)
|
|
e.getLists(ctx)
|
|
}
|
|
|
|
// ResetLists resets the current list data and forces
|
|
// all list sources to be re-acquired when calling GetLists().
|
|
func (e *Entity) ResetLists() {
|
|
// TODO(ppacher): our actual goal is to reset the domain
|
|
// list right now so we could be more efficient by keeping
|
|
// the other lists around.
|
|
|
|
e.BlockedByLists = nil
|
|
e.BlockedEntities = nil
|
|
e.ListOccurences = nil
|
|
|
|
e.domainListLoaded = false
|
|
e.ipListLoaded = false
|
|
e.countryListLoaded = false
|
|
e.asnListLoaded = false
|
|
e.resolveSubDomainLists = false
|
|
e.checkCNAMEs = false
|
|
e.loadDomainListOnce = sync.Once{}
|
|
e.loadIPListOnce = sync.Once{}
|
|
e.loadCountryListOnce = sync.Once{}
|
|
e.loadAsnListOnce = sync.Once{}
|
|
}
|
|
|
|
// ResolveSubDomainLists enables or disables list lookups for
|
|
// sub-domains.
|
|
func (e *Entity) ResolveSubDomainLists(ctx context.Context, enabled bool) {
|
|
if e.domainListLoaded && enabled != e.resolveSubDomainLists {
|
|
log.Tracer(ctx).Warningf("intel/filterlists: tried to change sub-domain resolving for %s but lists are already fetched", e.Domain)
|
|
}
|
|
e.resolveSubDomainLists = enabled
|
|
}
|
|
|
|
// EnableCNAMECheck enalbes or disables list lookups for
|
|
// entity CNAMEs.
|
|
func (e *Entity) EnableCNAMECheck(ctx context.Context, enabled bool) {
|
|
if e.domainListLoaded && enabled != e.checkCNAMEs {
|
|
log.Tracer(ctx).Warningf("intel/filterlists: tried to change CNAME resolving for %s but lists are already fetched", e.Domain)
|
|
}
|
|
e.checkCNAMEs = enabled
|
|
}
|
|
|
|
// CNAMECheckEnabled returns true if the entities CNAMEs should
|
|
// also be checked.
|
|
func (e *Entity) CNAMECheckEnabled() bool {
|
|
return e.checkCNAMEs
|
|
}
|
|
|
|
// Domain and IP
|
|
|
|
// EnableReverseResolving enables reverse resolving the domain from the IP on demand.
|
|
func (e *Entity) EnableReverseResolving() {
|
|
e.reverseResolveEnabled = true
|
|
}
|
|
|
|
func (e *Entity) reverseResolve(ctx context.Context) {
|
|
e.reverseResolveOnce.Do(func() {
|
|
// need IP!
|
|
if e.IP == nil {
|
|
return
|
|
}
|
|
|
|
// reverse resolve
|
|
if reverseResolver == nil {
|
|
return
|
|
}
|
|
// TODO: security level
|
|
domain, err := reverseResolver(ctx, e.IP.String())
|
|
if err != nil {
|
|
log.Tracer(ctx).Warningf("intel: failed to resolve IP %s: %s", e.IP, err)
|
|
return
|
|
}
|
|
e.ReverseDomain = domain
|
|
})
|
|
}
|
|
|
|
// GetDomain returns the domain and whether it is set.
|
|
func (e *Entity) GetDomain(ctx context.Context, mayUseReverseDomain bool) (string, bool) {
|
|
if mayUseReverseDomain && e.reverseResolveEnabled {
|
|
e.reverseResolve(ctx)
|
|
|
|
if e.ReverseDomain == "" {
|
|
return "", false
|
|
}
|
|
return e.ReverseDomain, true
|
|
}
|
|
|
|
if e.Domain == "" {
|
|
return "", false
|
|
}
|
|
return e.Domain, true
|
|
}
|
|
|
|
// GetIP returns the IP and whether it is set.
|
|
func (e *Entity) GetIP() (net.IP, bool) {
|
|
if e.IP == nil {
|
|
return nil, false
|
|
}
|
|
return e.IP, true
|
|
}
|
|
|
|
// Location
|
|
|
|
func (e *Entity) getLocation(ctx context.Context) {
|
|
e.fetchLocationOnce.Do(func() {
|
|
// Only check if we have a global IP address.
|
|
if e.IP == nil || !e.IPScope.IsGlobal() {
|
|
return
|
|
}
|
|
|
|
// get location data
|
|
loc, err := geoip.GetLocation(e.IP)
|
|
if err != nil {
|
|
log.Tracer(ctx).Warningf("intel: failed to get location data for %s: %s", e.IP, err)
|
|
e.LocationError = err.Error()
|
|
return
|
|
}
|
|
e.location = loc
|
|
e.Country = loc.Country.Code
|
|
e.Coordinates = &loc.Coordinates
|
|
e.ASN = loc.AutonomousSystemNumber
|
|
e.ASOrg = loc.AutonomousSystemOrganization
|
|
|
|
// Log result.
|
|
if log.GetLogLevel() == log.TraceLevel {
|
|
// Build flags
|
|
var flags string
|
|
if loc.IsAnycast {
|
|
flags += " anycast"
|
|
}
|
|
if loc.IsSatelliteProvider {
|
|
flags += " satellite"
|
|
}
|
|
if loc.IsAnonymousProxy {
|
|
flags += " anonymous"
|
|
}
|
|
|
|
// Log location
|
|
log.Tracer(ctx).Tracef(
|
|
"intel: located %s in %s (%s), as part of AS%d by %s%s",
|
|
e.IP,
|
|
loc.Country.Name,
|
|
loc.Country.Code,
|
|
loc.AutonomousSystemNumber,
|
|
loc.AutonomousSystemOrganization,
|
|
flags,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
// GetLocation returns the raw location data and whether it is set.
|
|
func (e *Entity) GetLocation(ctx context.Context) (*geoip.Location, bool) {
|
|
e.getLocation(ctx)
|
|
|
|
if e.location == nil {
|
|
return nil, false
|
|
}
|
|
return e.location, true
|
|
}
|
|
|
|
// GetCountry returns the two letter ISO country code and whether it is set.
|
|
func (e *Entity) GetCountry(ctx context.Context) (string, bool) {
|
|
e.getLocation(ctx)
|
|
|
|
if e.LocationError != "" {
|
|
return "", false
|
|
}
|
|
return e.Country, true
|
|
}
|
|
|
|
// GetCountryInfo returns the two letter ISO country code and whether it is set.
|
|
func (e *Entity) GetCountryInfo(ctx context.Context) *geoip.CountryInfo {
|
|
e.getLocation(ctx)
|
|
|
|
if e.LocationError != "" {
|
|
return nil
|
|
}
|
|
return &e.location.Country
|
|
}
|
|
|
|
// GetASN returns the AS number and whether it is set.
|
|
func (e *Entity) GetASN(ctx context.Context) (uint, bool) {
|
|
e.getLocation(ctx)
|
|
|
|
if e.LocationError != "" {
|
|
return 0, false
|
|
}
|
|
return e.ASN, true
|
|
}
|
|
|
|
// Lists
|
|
|
|
func (e *Entity) getLists(ctx context.Context) {
|
|
e.getDomainLists(ctx)
|
|
e.getASNLists(ctx)
|
|
e.getIPLists(ctx)
|
|
e.getCountryLists(ctx)
|
|
}
|
|
|
|
func (e *Entity) mergeList(key string, list []string) {
|
|
if len(list) == 0 {
|
|
return
|
|
}
|
|
|
|
if e.ListOccurences == nil {
|
|
e.ListOccurences = make(map[string][]string)
|
|
}
|
|
|
|
e.ListOccurences[key] = mergeStringList(e.ListOccurences[key], list)
|
|
}
|
|
|
|
func (e *Entity) getDomainLists(ctx context.Context) {
|
|
if e.domainListLoaded {
|
|
return
|
|
}
|
|
|
|
domain, ok := e.GetDomain(ctx, false /* mayUseReverseDomain */)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
e.loadDomainListOnce.Do(func() {
|
|
domainsToInspect := []string{domain}
|
|
|
|
if e.checkCNAMEs && len(e.CNAME) > 0 {
|
|
log.Tracer(ctx).Tracef("intel: CNAME filtering enabled, checking %v too", e.CNAME)
|
|
domainsToInspect = append(domainsToInspect, e.CNAME...)
|
|
}
|
|
|
|
var domains []string
|
|
if e.resolveSubDomainLists {
|
|
for _, domain := range domainsToInspect {
|
|
subdomains := splitDomain(domain)
|
|
domains = append(domains, subdomains...)
|
|
}
|
|
} else {
|
|
domains = domainsToInspect
|
|
}
|
|
|
|
domains = makeDistinct(domains)
|
|
|
|
for _, d := range domains {
|
|
list, err := filterlists.LookupDomain(d)
|
|
if err != nil {
|
|
log.Tracer(ctx).Errorf("intel: failed to get domain blocklists for %s: %s", d, err)
|
|
e.ListsError = err.Error()
|
|
return
|
|
}
|
|
|
|
if len(list) > 0 {
|
|
log.Tracer(ctx).Tracef("intel: loaded domain lists for %s: %s", d, strings.Join(list, ", "))
|
|
e.mergeList(d, list)
|
|
}
|
|
}
|
|
e.domainListLoaded = true
|
|
})
|
|
}
|
|
|
|
func splitDomain(domain string) []string {
|
|
// Get suffix.
|
|
d := strings.TrimSuffix(domain, ".")
|
|
suffix, icann := publicsuffix.PublicSuffix(d)
|
|
if suffix == d {
|
|
return []string{domain}
|
|
}
|
|
|
|
// Split all subdomain into labels.
|
|
labels := strings.FieldsFunc(d[:len(d)-len(suffix)], func(r rune) bool {
|
|
return r == '.'
|
|
})
|
|
|
|
// Build list of all domains up to the public suffix.
|
|
domains := make([]string, 0, len(labels)+1)
|
|
for idx := range labels {
|
|
domains = append(
|
|
domains,
|
|
strings.Join(labels[idx:], ".")+"."+suffix+".",
|
|
)
|
|
}
|
|
|
|
// If the suffix is not a real TLD, but a public suffix, add it to the list.
|
|
if !icann {
|
|
domains = append(domains, suffix+".")
|
|
}
|
|
|
|
return domains
|
|
}
|
|
|
|
func (e *Entity) getASNLists(ctx context.Context) {
|
|
if e.asnListLoaded {
|
|
return
|
|
}
|
|
|
|
asn, ok := e.GetASN(ctx)
|
|
if !ok || asn == 0 {
|
|
return
|
|
}
|
|
|
|
e.loadAsnListOnce.Do(func() {
|
|
asnStr := strconv.FormatUint(uint64(asn), 10)
|
|
list, err := filterlists.LookupASNString(asnStr)
|
|
if err != nil {
|
|
log.Tracer(ctx).Errorf("intel: failed to get ASN blocklist for %d: %s", asn, err)
|
|
e.ListsError = err.Error()
|
|
return
|
|
}
|
|
|
|
if len(list) > 0 {
|
|
log.Tracer(ctx).Tracef("intel: loaded ASN lists for %s: %s", asnStr, strings.Join(list, ", "))
|
|
e.mergeList(asnStr, list)
|
|
}
|
|
|
|
e.asnListLoaded = true
|
|
})
|
|
}
|
|
|
|
func (e *Entity) getCountryLists(ctx context.Context) {
|
|
if e.countryListLoaded {
|
|
return
|
|
}
|
|
|
|
country, ok := e.GetCountry(ctx)
|
|
if !ok || country == "" {
|
|
return
|
|
}
|
|
|
|
e.loadCountryListOnce.Do(func() {
|
|
list, err := filterlists.LookupCountry(country)
|
|
if err != nil {
|
|
log.Tracer(ctx).Errorf("intel: failed to load country blocklist for %s: %s", country, err)
|
|
e.ListsError = err.Error()
|
|
return
|
|
}
|
|
|
|
if len(list) > 0 {
|
|
log.Tracer(ctx).Tracef("intel: loaded country lists for %s: %s", country, strings.Join(list, ", "))
|
|
e.mergeList(country, list)
|
|
}
|
|
|
|
e.countryListLoaded = true
|
|
})
|
|
}
|
|
|
|
func (e *Entity) getIPLists(ctx context.Context) {
|
|
if e.ipListLoaded {
|
|
return
|
|
}
|
|
|
|
ip, ok := e.GetIP()
|
|
if !ok || ip == nil {
|
|
return
|
|
}
|
|
|
|
// only load lists for IP addresses that are classified as global.
|
|
if !e.IPScope.IsGlobal() {
|
|
return
|
|
}
|
|
|
|
e.loadIPListOnce.Do(func() {
|
|
list, err := filterlists.LookupIP(ip)
|
|
if err != nil {
|
|
log.Tracer(ctx).Errorf("intel: failed to get IP blocklist for %s: %s", ip.String(), err)
|
|
e.ListsError = err.Error()
|
|
return
|
|
}
|
|
|
|
if len(list) > 0 {
|
|
log.Tracer(ctx).Tracef("intel: loaded IP lists for %s: %s", ip.String(), strings.Join(list, ", "))
|
|
e.mergeList(ip.String(), list)
|
|
}
|
|
|
|
e.ipListLoaded = true
|
|
})
|
|
}
|
|
|
|
// LoadLists searches all filterlists for all occurrences of
|
|
// this entity.
|
|
func (e *Entity) LoadLists(ctx context.Context) {
|
|
e.getLists(ctx)
|
|
}
|
|
|
|
// MatchLists matches the entities lists against a slice
|
|
// of source IDs and updates various entity properties
|
|
// like BlockedByLists, ListOccurences and BlockedEntitites.
|
|
func (e *Entity) MatchLists(lists []string) bool {
|
|
if len(lists) == 0 {
|
|
return false
|
|
}
|
|
|
|
e.BlockedByLists = nil
|
|
e.BlockedEntities = nil
|
|
|
|
lm := makeMap(lists)
|
|
for key, keyLists := range e.ListOccurences {
|
|
for _, keyListID := range keyLists {
|
|
if _, ok := lm[keyListID]; ok {
|
|
e.BlockedByLists = append(e.BlockedByLists, keyListID)
|
|
e.BlockedEntities = append(e.BlockedEntities, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
e.BlockedByLists = makeDistinct(e.BlockedByLists)
|
|
e.BlockedEntities = makeDistinct(e.BlockedEntities)
|
|
|
|
return len(e.BlockedByLists) > 0
|
|
}
|
|
|
|
// ListBlockReason returns the block reason for this entity.
|
|
func (e *Entity) ListBlockReason() ListBlockReason {
|
|
blockedBy := make([]ListMatch, len(e.BlockedEntities))
|
|
|
|
lm := makeMap(e.BlockedByLists)
|
|
|
|
for idx, blockedEntity := range e.BlockedEntities {
|
|
if entityLists, ok := e.ListOccurences[blockedEntity]; ok {
|
|
var activeLists []string
|
|
var inactiveLists []string
|
|
|
|
for _, l := range entityLists {
|
|
if _, ok := lm[l]; ok {
|
|
activeLists = append(activeLists, l)
|
|
} else {
|
|
inactiveLists = append(inactiveLists, l)
|
|
}
|
|
}
|
|
|
|
blockedBy[idx] = ListMatch{
|
|
Entity: blockedEntity,
|
|
ActiveLists: activeLists,
|
|
InactiveLists: inactiveLists,
|
|
}
|
|
}
|
|
}
|
|
|
|
return blockedBy
|
|
}
|
|
|
|
func mergeStringList(a, b []string) []string {
|
|
listMap := make(map[string]struct{})
|
|
for _, s := range a {
|
|
listMap[s] = struct{}{}
|
|
}
|
|
for _, s := range b {
|
|
listMap[s] = struct{}{}
|
|
}
|
|
|
|
res := make([]string, 0, len(listMap))
|
|
for s := range listMap {
|
|
res = append(res, s)
|
|
}
|
|
sort.Strings(res)
|
|
return res
|
|
}
|
|
|
|
func makeDistinct(slice []string) []string {
|
|
m := make(map[string]struct{}, len(slice))
|
|
result := make([]string, 0, len(slice))
|
|
|
|
for _, v := range slice {
|
|
if _, ok := m[v]; ok {
|
|
continue
|
|
}
|
|
|
|
m[v] = struct{}{}
|
|
result = append(result, v)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func makeMap(slice []string) map[string]struct{} {
|
|
lm := make(map[string]struct{})
|
|
for _, v := range slice {
|
|
lm[v] = struct{}{}
|
|
}
|
|
return lm
|
|
}
|