wip: migrate to mono-repo. SPN has already been moved to spn/

This commit is contained in:
Patrick Pacher
2024-03-15 11:55:13 +01:00
parent b30fd00ccf
commit 8579430db9
577 changed files with 35981 additions and 818 deletions

View File

@@ -0,0 +1,13 @@
package endpoints
// DisplayHintEndpointList marks an option as an endpoint
// list option. It's meant to be used with DisplayHintAnnotation.
const DisplayHintEndpointList = "endpoint list"
// EndpointListVerdictNamesAnnotation is the annotation identifier used in
// configuration options to hint the UI on names to be used for endpoint list
// verdicts.
// If configured, it must be of type map[string]string, mapping the verdict
// symbol to a name to be displayed in the UI.
// May only used when config.DisplayHintAnnotation is set to DisplayHintEndpointList.
const EndpointListVerdictNamesAnnotation = "safing/portmaster:ui:endpoint-list:verdict-names"

View File

@@ -0,0 +1,29 @@
package endpoints
import (
"context"
"github.com/safing/portmaster/service/intel"
)
// EndpointAny matches anything.
type EndpointAny struct {
EndpointBase
}
// Matches checks whether the given entity matches this endpoint definition.
func (ep *EndpointAny) Matches(_ context.Context, entity *intel.Entity) (EPResult, Reason) {
return ep.match(ep, entity, "*", "matches")
}
func (ep *EndpointAny) String() string {
return ep.renderPPP("*")
}
func parseTypeAny(fields []string) (Endpoint, error) {
if fields[1] == "*" {
ep := &EndpointAny{}
return ep.parsePPP(ep, fields)
}
return nil, nil
}

View File

@@ -0,0 +1,65 @@
package endpoints
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/safing/portmaster/service/intel"
)
var asnRegex = regexp.MustCompile("^AS[0-9]+$")
// EndpointASN matches ASNs.
type EndpointASN struct {
EndpointBase
ASN uint
}
// Matches checks whether the given entity matches this endpoint definition.
func (ep *EndpointASN) Matches(ctx context.Context, entity *intel.Entity) (EPResult, Reason) {
if entity.IP == nil {
return NoMatch, nil
}
if !entity.IPScope.IsGlobal() {
return NoMatch, nil
}
asn, ok := entity.GetASN(ctx)
if !ok {
asnStr := strconv.Itoa(int(ep.ASN))
return MatchError, ep.makeReason(ep, asnStr, "ASN data not available to match")
}
if asn == ep.ASN {
asnStr := strconv.Itoa(int(ep.ASN))
return ep.match(ep, entity, asnStr, "IP is part of AS")
}
return NoMatch, nil
}
func (ep *EndpointASN) String() string {
return ep.renderPPP("AS" + strconv.FormatInt(int64(ep.ASN), 10))
}
func parseTypeASN(fields []string) (Endpoint, error) {
if asnRegex.MatchString(fields[1]) {
asnString := strings.TrimPrefix(fields[1], "AS")
asn, err := strconv.ParseUint(asnString, 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse AS number %s", asnString)
}
ep := &EndpointASN{
ASN: uint(asn),
}
return ep.parsePPP(ep, fields)
}
return nil, nil
}

View File

@@ -0,0 +1,63 @@
package endpoints
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/safing/portmaster/service/intel"
)
var (
continentCodePrefix = "C:"
continentRegex = regexp.MustCompile(`^C:[A-Z]{2}$`)
)
// EndpointContinent matches countries.
type EndpointContinent struct {
EndpointBase
ContinentCode string
}
// Matches checks whether the given entity matches this endpoint definition.
func (ep *EndpointContinent) Matches(ctx context.Context, entity *intel.Entity) (EPResult, Reason) {
if entity.IP == nil {
return NoMatch, nil
}
if !entity.IPScope.IsGlobal() {
return NoMatch, nil
}
countryInfo := entity.GetCountryInfo(ctx)
if countryInfo == nil {
return MatchError, ep.makeReason(ep, "", "country data not available to match")
}
if ep.ContinentCode == countryInfo.Continent.Code {
return ep.match(
ep, entity,
fmt.Sprintf("%s (%s)", countryInfo.Continent.Name, countryInfo.Continent.Code),
"IP is located in",
)
}
return NoMatch, nil
}
func (ep *EndpointContinent) String() string {
return ep.renderPPP(continentCodePrefix + ep.ContinentCode)
}
func parseTypeContinent(fields []string) (Endpoint, error) {
if continentRegex.MatchString(fields[1]) {
ep := &EndpointContinent{
ContinentCode: strings.TrimPrefix(strings.ToUpper(fields[1]), continentCodePrefix),
}
return ep.parsePPP(ep, fields)
}
return nil, nil
}

View File

@@ -0,0 +1,59 @@
package endpoints
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/safing/portmaster/service/intel"
)
var countryRegex = regexp.MustCompile(`^[A-Z]{2}$`)
// EndpointCountry matches countries.
type EndpointCountry struct {
EndpointBase
CountryCode string
}
// Matches checks whether the given entity matches this endpoint definition.
func (ep *EndpointCountry) Matches(ctx context.Context, entity *intel.Entity) (EPResult, Reason) {
if entity.IP == nil {
return NoMatch, nil
}
if !entity.IPScope.IsGlobal() {
return NoMatch, nil
}
countryInfo := entity.GetCountryInfo(ctx)
if countryInfo == nil {
return MatchError, ep.makeReason(ep, "", "country data not available to match")
}
if ep.CountryCode == countryInfo.Code {
return ep.match(
ep, entity,
fmt.Sprintf("%s (%s)", countryInfo.Name, countryInfo.Code),
"IP is located in",
)
}
return NoMatch, nil
}
func (ep *EndpointCountry) String() string {
return ep.renderPPP(ep.CountryCode)
}
func parseTypeCountry(fields []string) (Endpoint, error) {
if countryRegex.MatchString(fields[1]) {
ep := &EndpointCountry{
CountryCode: strings.ToUpper(fields[1]),
}
return ep.parsePPP(ep, fields)
}
return nil, nil
}

View File

@@ -0,0 +1,170 @@
package endpoints
import (
"context"
"errors"
"regexp"
"strings"
"github.com/safing/portmaster/service/intel"
"github.com/safing/portmaster/service/network/netutils"
)
const (
domainMatchTypeExact uint8 = iota
domainMatchTypeZone
domainMatchTypeSuffix
domainMatchTypePrefix
domainMatchTypeContains
)
var (
allowedDomainChars = regexp.MustCompile(`^[a-z0-9\.-]+$`)
// looksLikeAnIP matches domains that look like an IP address.
looksLikeAnIP = regexp.MustCompile(`^[0-9\.:]+$`)
)
// EndpointDomain matches domains.
type EndpointDomain struct {
EndpointBase
OriginalValue string
Domain string
DomainZone string
MatchType uint8
}
func (ep *EndpointDomain) check(entity *intel.Entity, domain string) (EPResult, Reason) {
result, reason := ep.match(ep, entity, ep.OriginalValue, "domain matches")
switch ep.MatchType {
case domainMatchTypeExact:
if domain == ep.Domain {
return result, reason
}
case domainMatchTypeZone:
if domain == ep.Domain {
return result, reason
}
if strings.HasSuffix(domain, ep.DomainZone) {
return result, reason
}
case domainMatchTypeSuffix:
if strings.HasSuffix(domain, ep.Domain) {
return result, reason
}
case domainMatchTypePrefix:
if strings.HasPrefix(domain, ep.Domain) {
return result, reason
}
case domainMatchTypeContains:
if strings.Contains(domain, ep.Domain) {
return result, reason
}
}
return NoMatch, nil
}
// Matches checks whether the given entity matches this endpoint definition.
func (ep *EndpointDomain) Matches(ctx context.Context, entity *intel.Entity) (EPResult, Reason) {
domain, ok := entity.GetDomain(ctx, true /* mayUseReverseDomain */)
if !ok {
return NoMatch, nil
}
result, reason := ep.check(entity, domain)
if result != NoMatch {
return result, reason
}
if entity.CNAMECheckEnabled() {
for _, cname := range entity.CNAME {
result, reason = ep.check(entity, cname)
if result == Denied {
return result, reason
}
}
}
return NoMatch, nil
}
func (ep *EndpointDomain) String() string {
return ep.renderPPP(ep.OriginalValue)
}
func parseTypeDomain(fields []string) (Endpoint, error) {
domain := fields[1]
ep := &EndpointDomain{
OriginalValue: domain,
}
// Fix domain ending.
switch domain[len(domain)-1] {
case '.', '*':
default:
domain += "."
}
// Check if this looks like an IP address.
// At least the TLDs has characters.
if looksLikeAnIP.MatchString(domain) {
return nil, nil
}
// Fix domain case.
domain = strings.ToLower(domain)
needValidFQDN := true
switch {
case strings.HasPrefix(domain, "*") && strings.HasSuffix(domain, "*"):
ep.MatchType = domainMatchTypeContains
ep.Domain = strings.TrimPrefix(domain, "*")
ep.Domain = strings.TrimSuffix(ep.Domain, "*")
needValidFQDN = false
case strings.HasSuffix(domain, "*"):
ep.MatchType = domainMatchTypePrefix
ep.Domain = strings.TrimSuffix(domain, "*")
needValidFQDN = false
// Prefix matching cannot be combined with zone matching
if strings.HasPrefix(ep.Domain, ".") {
return nil, nil
}
// Do not accept domains that look like an IP address and have a suffix wildcard.
// This is confusing, because it looks like an IP Netmask matching rule.
if looksLikeAnIP.MatchString(ep.Domain) {
return nil, errors.New("use CIDR notation (eg. 10.0.0.0/24) for matching ip address ranges")
}
case strings.HasPrefix(domain, "*"):
ep.MatchType = domainMatchTypeSuffix
ep.Domain = strings.TrimPrefix(domain, "*")
needValidFQDN = false
case strings.HasPrefix(domain, "."):
ep.MatchType = domainMatchTypeZone
ep.Domain = strings.TrimPrefix(domain, ".")
ep.DomainZone = "." + ep.Domain
default:
ep.MatchType = domainMatchTypeExact
ep.Domain = domain
}
// Validate domain "content".
switch {
case needValidFQDN && !netutils.IsValidFqdn(ep.Domain):
return nil, nil
case !needValidFQDN && !allowedDomainChars.MatchString(ep.Domain):
return nil, nil
case strings.Contains(ep.Domain, ".."):
// The above regex does not catch double dots.
return nil, nil
}
return ep.parsePPP(ep, fields)
}

View File

@@ -0,0 +1,42 @@
package endpoints
import (
"context"
"net"
"github.com/safing/portmaster/service/intel"
)
// EndpointIP matches IPs.
type EndpointIP struct {
EndpointBase
IP net.IP
}
// Matches checks whether the given entity matches this endpoint definition.
func (ep *EndpointIP) Matches(_ context.Context, entity *intel.Entity) (EPResult, Reason) {
if entity.IP == nil {
return NoMatch, nil
}
if ep.IP.Equal(entity.IP) {
return ep.match(ep, entity, ep.IP.String(), "IP matches")
}
return NoMatch, nil
}
func (ep *EndpointIP) String() string {
return ep.renderPPP(ep.IP.String())
}
func parseTypeIP(fields []string) (Endpoint, error) {
ip := net.ParseIP(fields[1])
if ip != nil {
ep := &EndpointIP{
IP: ip,
}
return ep.parsePPP(ep, fields)
}
return nil, nil
}

View File

@@ -0,0 +1,42 @@
package endpoints
import (
"context"
"net"
"github.com/safing/portmaster/service/intel"
)
// EndpointIPRange matches IP ranges.
type EndpointIPRange struct {
EndpointBase
Net *net.IPNet
}
// Matches checks whether the given entity matches this endpoint definition.
func (ep *EndpointIPRange) Matches(_ context.Context, entity *intel.Entity) (EPResult, Reason) {
if entity.IP == nil {
return NoMatch, nil
}
if ep.Net.Contains(entity.IP) {
return ep.match(ep, entity, ep.Net.String(), "IP is in")
}
return NoMatch, nil
}
func (ep *EndpointIPRange) String() string {
return ep.renderPPP(ep.Net.String())
}
func parseTypeIPRange(fields []string) (Endpoint, error) {
_, net, err := net.ParseCIDR(fields[1])
if err == nil {
ep := &EndpointIPRange{
Net: net,
}
return ep.parsePPP(ep, fields)
}
return nil, nil
}

View File

@@ -0,0 +1,41 @@
package endpoints
import (
"context"
"strings"
"github.com/safing/portmaster/service/intel"
)
// EndpointLists matches endpoint lists.
type EndpointLists struct {
EndpointBase
ListSet []string
Lists string
}
// Matches checks whether the given entity matches this endpoint definition.
func (ep *EndpointLists) Matches(ctx context.Context, entity *intel.Entity) (EPResult, Reason) {
if entity.MatchLists(ep.ListSet) {
return ep.match(ep, entity, ep.Lists, "filterlist contains", "filterlist", entity.ListBlockReason())
}
return NoMatch, nil
}
func (ep *EndpointLists) String() string {
return ep.renderPPP(ep.Lists)
}
func parseTypeList(fields []string) (Endpoint, error) {
if strings.HasPrefix(fields[1], "L:") {
lists := strings.Split(strings.TrimPrefix(fields[1], "L:"), ",")
ep := &EndpointLists{
ListSet: lists,
Lists: "L:" + strings.Join(lists, ","),
}
return ep.parsePPP(ep, fields)
}
return nil, nil
}

View File

@@ -0,0 +1,107 @@
package endpoints
import (
"context"
"strings"
"github.com/safing/portmaster/service/intel"
"github.com/safing/portmaster/service/network/netutils"
)
const (
scopeLocalhost = 1
scopeLocalhostName = "Localhost"
scopeLocalhostMatcher = "localhost"
scopeLAN = 2
scopeLANName = "LAN"
scopeLANMatcher = "lan"
scopeInternet = 4
scopeInternetName = "Internet"
scopeInternetMatcher = "internet"
)
// EndpointScope matches network scopes.
type EndpointScope struct {
EndpointBase
scopes uint8
}
// Matches checks whether the given entity matches this endpoint definition.
func (ep *EndpointScope) Matches(_ context.Context, entity *intel.Entity) (EPResult, Reason) {
if entity.IP == nil {
return NoMatch, nil
}
var scope uint8
switch entity.IPScope {
case netutils.HostLocal:
scope = scopeLocalhost
case netutils.LinkLocal:
scope = scopeLAN
case netutils.SiteLocal:
scope = scopeLAN
case netutils.Global:
scope = scopeInternet
case netutils.LocalMulticast:
scope = scopeLAN
case netutils.GlobalMulticast:
scope = scopeInternet
case netutils.Undefined, netutils.Invalid:
return NoMatch, nil
}
if ep.scopes&scope > 0 {
return ep.match(ep, entity, ep.Scopes(), "scope matches")
}
return NoMatch, nil
}
// Scopes returns the string representation of all scopes.
func (ep *EndpointScope) Scopes() string {
// single scope
switch ep.scopes {
case scopeLocalhost:
return scopeLocalhostName
case scopeLAN:
return scopeLANName
case scopeInternet:
return scopeInternetName
}
// multiple scopes
var s []string
if ep.scopes&scopeLocalhost > 0 {
s = append(s, scopeLocalhostName)
}
if ep.scopes&scopeLAN > 0 {
s = append(s, scopeLANName)
}
if ep.scopes&scopeInternet > 0 {
s = append(s, scopeInternetName)
}
return strings.Join(s, ",")
}
func (ep *EndpointScope) String() string {
return ep.renderPPP(ep.Scopes())
}
func parseTypeScope(fields []string) (Endpoint, error) {
ep := &EndpointScope{}
for _, val := range strings.Split(strings.ToLower(fields[1]), ",") {
switch val {
case scopeLocalhostMatcher:
ep.scopes ^= scopeLocalhost
case scopeLANMatcher:
ep.scopes ^= scopeLAN
case scopeInternetMatcher:
ep.scopes ^= scopeInternet
default:
return nil, nil
}
}
return ep.parsePPP(ep, fields)
}

View File

@@ -0,0 +1,258 @@
package endpoints
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/safing/portmaster/service/intel"
"github.com/safing/portmaster/service/network/reference"
)
// Endpoint describes an Endpoint Matcher.
type Endpoint interface {
Matches(ctx context.Context, entity *intel.Entity) (EPResult, Reason)
String() string
}
// EndpointBase provides general functions for implementing an Endpoint to reduce boilerplate.
type EndpointBase struct { //nolint:maligned // TODO
Protocol uint8
StartPort uint16
EndPort uint16
Permitted bool
}
func (ep *EndpointBase) match(s fmt.Stringer, entity *intel.Entity, value, desc string, keyval ...interface{}) (EPResult, Reason) {
result := ep.matchesPPP(entity)
if result == NoMatch {
return result, nil
}
return result, ep.makeReason(s, value, desc, keyval...)
}
func (ep *EndpointBase) makeReason(s fmt.Stringer, value, desc string, keyval ...interface{}) Reason {
r := &reason{
description: desc,
Filter: s.String(),
Permitted: ep.Permitted,
Value: value,
}
r.Extra = make(map[string]interface{})
for idx := 0; idx < len(keyval)/2; idx += 2 {
key := keyval[idx]
val := keyval[idx+1]
if keyName, ok := key.(string); ok {
r.Extra[keyName] = val
}
}
return r
}
func (ep *EndpointBase) matchesPPP(entity *intel.Entity) (result EPResult) {
// only check if protocol is defined
if ep.Protocol > 0 {
// if protocol does not match, return NoMatch
if entity.Protocol != ep.Protocol {
return NoMatch
}
}
// only check if port is defined
if ep.StartPort > 0 {
// if port does not match, return NoMatch
if entity.DstPort() < ep.StartPort || entity.DstPort() > ep.EndPort {
return NoMatch
}
}
// protocol and port matched or were defined as any
if ep.Permitted {
return Permitted
}
return Denied
}
func (ep *EndpointBase) renderPPP(s string) string {
var rendered string
if ep.Permitted {
rendered = "+ " + s
} else {
rendered = "- " + s
}
if ep.Protocol > 0 || ep.StartPort > 0 {
if ep.Protocol > 0 {
rendered += " " + reference.GetProtocolName(ep.Protocol)
} else {
rendered += " *"
}
if ep.StartPort > 0 {
if ep.StartPort == ep.EndPort {
rendered += "/" + reference.GetPortName(ep.StartPort)
} else {
rendered += "/" + strconv.Itoa(int(ep.StartPort)) + "-" + strconv.Itoa(int(ep.EndPort))
}
}
}
return rendered
}
func (ep *EndpointBase) parsePPP(typedEp Endpoint, fields []string) (Endpoint, error) { //nolint:gocognit // TODO
switch len(fields) {
case 2:
// nothing else to do here
case 3:
// parse protocol and port(s)
var ok bool
splitted := strings.Split(fields[2], "/")
if len(splitted) > 2 {
return nil, invalidDefinitionError(fields, "protocol and port must be in format <protocol>/<port>")
}
// protocol
switch splitted[0] {
case "":
return nil, invalidDefinitionError(fields, "protocol can't be empty")
case "*":
// any protocol that supports ports
default:
n, err := strconv.ParseUint(splitted[0], 10, 8)
n8 := uint8(n)
if err != nil {
// maybe it's a name?
n8, ok = reference.GetProtocolNumber(splitted[0])
if !ok {
return nil, invalidDefinitionError(fields, "protocol number parsing error")
}
}
ep.Protocol = n8
}
// port(s)
if len(splitted) > 1 {
switch splitted[1] {
case "", "*":
return nil, invalidDefinitionError(fields, "omit port if should match any")
default:
portSplitted := strings.Split(splitted[1], "-")
if len(portSplitted) > 2 {
return nil, invalidDefinitionError(fields, "ports must be in format from-to")
}
// parse start port
n, err := strconv.ParseUint(portSplitted[0], 10, 16)
n16 := uint16(n)
if err != nil {
// maybe it's a name?
n16, ok = reference.GetPortNumber(portSplitted[0])
if !ok {
return nil, invalidDefinitionError(fields, "port number parsing error")
}
}
if n16 == 0 {
return nil, invalidDefinitionError(fields, "port number cannot be 0")
}
ep.StartPort = n16
// parse end port
if len(portSplitted) > 1 {
n, err = strconv.ParseUint(portSplitted[1], 10, 16)
n16 = uint16(n)
if err != nil {
// maybe it's a name?
n16, ok = reference.GetPortNumber(portSplitted[1])
if !ok {
return nil, invalidDefinitionError(fields, "port number parsing error")
}
}
}
if n16 == 0 {
return nil, invalidDefinitionError(fields, "port number cannot be 0")
}
ep.EndPort = n16
}
}
// check if anything was parsed
if ep.Protocol == 0 && ep.StartPort == 0 {
return nil, invalidDefinitionError(fields, "omit protocol/port if should match any")
}
default:
return nil, invalidDefinitionError(fields, "there should be only 2 or 3 segments")
}
switch fields[0] {
case "+":
ep.Permitted = true
case "-":
ep.Permitted = false
default:
return nil, invalidDefinitionError(fields, "invalid permission prefix")
}
return typedEp, nil
}
func invalidDefinitionError(fields []string, msg string) error {
return fmt.Errorf(`invalid endpoint definition: "%s" - %s`, strings.Join(fields, " "), msg)
}
//nolint:gocognit,nakedret
func parseEndpoint(value string) (endpoint Endpoint, err error) {
fields := strings.Fields(value)
if len(fields) < 2 {
return nil, fmt.Errorf(`invalid endpoint definition: "%s"`, value)
}
// Remove comment.
for i, field := range fields {
if strings.HasPrefix(field, "#") {
fields = fields[:i]
break
}
}
// any
if endpoint, err = parseTypeAny(fields); endpoint != nil || err != nil {
return
}
// ip
if endpoint, err = parseTypeIP(fields); endpoint != nil || err != nil {
return
}
// ip range
if endpoint, err = parseTypeIPRange(fields); endpoint != nil || err != nil {
return
}
// country
if endpoint, err = parseTypeCountry(fields); endpoint != nil || err != nil {
return
}
// continent
if endpoint, err = parseTypeContinent(fields); endpoint != nil || err != nil {
return
}
// asn
if endpoint, err = parseTypeASN(fields); endpoint != nil || err != nil {
return
}
// scopes
if endpoint, err = parseTypeScope(fields); endpoint != nil || err != nil {
return
}
// lists
if endpoint, err = parseTypeList(fields); endpoint != nil || err != nil {
return
}
// domain
if endpoint, err = parseTypeDomain(fields); endpoint != nil || err != nil {
return
}
return nil, fmt.Errorf(`unknown endpoint definition: "%s"`, value)
}

View File

@@ -0,0 +1,99 @@
package endpoints
import (
"strings"
"testing"
)
func TestEndpointParsing(t *testing.T) {
t.Parallel()
// any (basics)
testParsing(t, "- *")
testParsing(t, "+ *")
// domain
testDomainParsing(t, "- *bad*", domainMatchTypeContains, "bad")
testDomainParsing(t, "- bad*", domainMatchTypePrefix, "bad")
testDomainParsing(t, "- *bad.com", domainMatchTypeSuffix, "bad.com.")
testDomainParsing(t, "- .bad.com", domainMatchTypeZone, "bad.com.")
testDomainParsing(t, "- bad.com", domainMatchTypeExact, "bad.com.")
testDomainParsing(t, "- www.bad.com.", domainMatchTypeExact, "www.bad.com.")
testDomainParsing(t, "- www.bad.com", domainMatchTypeExact, "www.bad.com.")
// ip
testParsing(t, "+ 127.0.0.1")
testParsing(t, "+ 192.168.0.1")
testParsing(t, "+ ::1")
testParsing(t, "+ 2606:4700:4700::1111")
// ip
testParsing(t, "+ 127.0.0.0/8")
testParsing(t, "+ 192.168.0.0/24")
testParsing(t, "+ 2606:4700:4700::/48")
// country
testParsing(t, "+ DE")
testParsing(t, "+ AT")
testParsing(t, "+ CH")
testParsing(t, "+ AS")
// asn
testParsing(t, "+ AS1")
testParsing(t, "+ AS12")
testParsing(t, "+ AS123")
testParsing(t, "+ AS1234")
testParsing(t, "+ AS12345")
// network scope
testParsing(t, "+ Localhost")
testParsing(t, "+ LAN")
testParsing(t, "+ Internet")
testParsing(t, "+ Localhost,LAN,Internet")
// protocol and ports
testParsing(t, "+ * TCP/1-1024")
testParsing(t, "+ * */DNS")
testParsing(t, "+ * ICMP")
testParsing(t, "+ * 127")
testParsing(t, "+ * UDP/1234")
testParsing(t, "+ * TCP/HTTP")
testParsing(t, "+ * TCP/80-443")
// TODO: Test fails:
// testParsing(t, "+ 1234")
}
func testParsing(t *testing.T, value string) {
t.Helper()
ep, err := parseEndpoint(value)
if err != nil {
t.Error(err)
return
}
// t.Logf("%T: %+v", ep, ep)
if value != ep.String() {
t.Errorf(`stringified endpoint mismatch: original was "%s", parsed is "%s"`, value, ep.String())
}
}
func testDomainParsing(t *testing.T, value string, matchType uint8, matchValue string) {
t.Helper()
testParsing(t, value)
epGeneric, err := parseTypeDomain(strings.Fields(value))
if err != nil {
t.Error(err)
return
}
ep := epGeneric.(*EndpointDomain) //nolint:forcetypeassert
if ep.MatchType != matchType {
t.Errorf(`error parsing domain endpoint "%s": match type should be %d, was %d`, value, matchType, ep.MatchType)
}
if ep.Domain != matchValue {
t.Errorf(`error parsing domain endpoint "%s": match domain value should be %s, was %s`, value, matchValue, ep.Domain)
}
}

View File

@@ -0,0 +1,149 @@
package endpoints
import (
"context"
"errors"
"fmt"
"strings"
"github.com/safing/portmaster/service/intel"
)
// Endpoints is a list of permitted or denied endpoints.
type Endpoints []Endpoint
// EPResult represents the result of a check against an EndpointPermission.
type EPResult uint8
// Endpoint matching return values.
const (
NoMatch EPResult = iota
MatchError
Denied
Permitted
)
// IsDecision returns true if result represents a decision
// and false if result is NoMatch or Undeterminable.
func IsDecision(result EPResult) bool {
return result == Denied || result == Permitted || result == MatchError
}
// ParseEndpoints parses a list of endpoints and returns a list of Endpoints for matching.
func ParseEndpoints(entries []string) (Endpoints, error) {
var firstErr error
var errCnt int
endpoints := make(Endpoints, 0, len(entries))
entriesLoop:
for _, entry := range entries {
ep, err := parseEndpoint(entry)
if err != nil {
errCnt++
if firstErr == nil {
firstErr = err
}
continue entriesLoop
}
endpoints = append(endpoints, ep)
}
if firstErr != nil {
if errCnt > 0 {
return endpoints, fmt.Errorf("encountered %d errors, first was: %w", errCnt, firstErr)
}
return endpoints, firstErr
}
return endpoints, nil
}
// ListEntryValidationRegex is a regex to bullshit check endpoint list entries.
var ListEntryValidationRegex = strings.Join([]string{
`^(\+|\-) `, // Rule verdict.
`(! +)?`, // Invert matching.
`[A-z0-9\.:\-*/]+`, // Entity matching.
`( `, // Start of optional matching.
`[A-z0-9*]+`, // Protocol matching.
`(/[A-z0-9]+(\-[A-z0-9]+)?)?`, // Port and port range matching.
`)?`, // End of optional matching.
`( +#.*)?`, // Optional comment.
}, "")
// ValidateEndpointListConfigOption validates the given value.
func ValidateEndpointListConfigOption(value interface{}) error {
list, ok := value.([]string)
if !ok {
return errors.New("invalid type")
}
_, err := ParseEndpoints(list)
return err
}
// IsSet returns whether the Endpoints object is "set".
func (e Endpoints) IsSet() bool {
return len(e) > 0
}
// Match checks whether the given entity matches any of the endpoint definitions in the list.
func (e Endpoints) Match(ctx context.Context, entity *intel.Entity) (result EPResult, reason Reason) {
for _, entry := range e {
if entry == nil {
continue
}
if result, reason = entry.Matches(ctx, entity); result != NoMatch {
return
}
}
return NoMatch, nil
}
// MatchMulti checks whether the given entities match any of the endpoint
// definitions in the list. Every rule is evaluated against all given entities
// and only if not match was registered, the next rule is evaluated.
func (e Endpoints) MatchMulti(ctx context.Context, entities ...*intel.Entity) (result EPResult, reason Reason) {
for _, entry := range e {
if entry == nil {
continue
}
for _, entity := range entities {
if entity == nil {
continue
}
if result, reason = entry.Matches(ctx, entity); result != NoMatch {
return
}
}
}
return NoMatch, nil
}
func (e Endpoints) String() string {
s := make([]string, 0, len(e))
for _, entry := range e {
s = append(s, entry.String())
}
return fmt.Sprintf("[%s]", strings.Join(s, ", "))
}
func (epr EPResult) String() string {
switch epr {
case NoMatch:
return "No Match"
case MatchError:
return "Match Error"
case Denied:
return "Denied"
case Permitted:
return "Permitted"
default:
return "Unknown"
}
}

View File

@@ -0,0 +1,432 @@
package endpoints
import (
"context"
"net"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/safing/portmaster/service/core/pmtesting"
"github.com/safing/portmaster/service/intel"
)
func TestMain(m *testing.M) {
pmtesting.TestMain(m, intel.Module)
}
func testEndpointMatch(t *testing.T, ep Endpoint, entity *intel.Entity, expectedResult EPResult) {
t.Helper()
result, _ := ep.Matches(context.TODO(), entity)
if result != expectedResult {
t.Errorf(
"line %d: unexpected result for endpoint %s and entity %+v: result=%s, expected=%s",
getLineNumberOfCaller(1),
ep,
entity,
result,
expectedResult,
)
}
}
func testFormat(t *testing.T, endpoint string, shouldSucceed bool) {
t.Helper()
_, err := parseEndpoint(endpoint)
if shouldSucceed {
assert.NoError(t, err)
} else {
assert.Error(t, err)
}
}
func TestEndpointFormat(t *testing.T) {
t.Parallel()
testFormat(t, "+ .", false)
testFormat(t, "+ .at", true)
testFormat(t, "+ .at.", true)
testFormat(t, "+ 1.at", true)
testFormat(t, "+ 1.at.", true)
testFormat(t, "+ 1.f.ix.de.", true)
testFormat(t, "+ *contains*", true)
testFormat(t, "+ *has.suffix", true)
testFormat(t, "+ *.has.suffix", true)
testFormat(t, "+ *has.prefix*", true)
testFormat(t, "+ *has.prefix.*", true)
testFormat(t, "+ .sub.and.prefix.*", false)
testFormat(t, "+ *.sub..and.prefix.*", false)
}
func TestEndpointMatching(t *testing.T) { //nolint:maintidx // TODO
t.Parallel()
// ANY
ep, err := parseEndpoint("+ *")
if err != nil {
t.Fatal(err)
}
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), Permitted)
// DOMAIN
// wildcard domains
ep, err = parseEndpoint("+ *example.com")
if err != nil {
t.Fatal(err)
}
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc.example.com.",
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc-example.com.",
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc.example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc-example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), Permitted)
ep, err = parseEndpoint("+ *.example.com")
if err != nil {
t.Fatal(err)
}
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
}).Init(0), NoMatch)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc.example.com.",
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc-example.com.",
}).Init(0), NoMatch)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), NoMatch)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc.example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc-example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), NoMatch)
ep, err = parseEndpoint("+ .example.com")
if err != nil {
t.Fatal(err)
}
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc.example.com.",
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc-example.com.",
}).Init(0), NoMatch)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc.example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc-example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), NoMatch)
ep, err = parseEndpoint("+ example.*")
if err != nil {
t.Fatal(err)
}
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc.example.com.",
}).Init(0), NoMatch)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc.example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), NoMatch)
ep, err = parseEndpoint("+ *.exampl*")
if err != nil {
t.Fatal(err)
}
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
}).Init(0), NoMatch)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc.example.com.",
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), NoMatch)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "abc.example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), Permitted)
ep, err = parseEndpoint("+ *.com.")
if err != nil {
t.Fatal(err)
}
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.org.",
}).Init(0), NoMatch)
// protocol
ep, err = parseEndpoint("+ example.com UDP")
if err != nil {
t.Fatal(err)
}
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), NoMatch)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 17,
Port: 443,
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
}).Init(0), NoMatch)
// ports
ep, err = parseEndpoint("+ example.com 17/442-444")
if err != nil {
t.Fatal(err)
}
entity := (&intel.Entity{
Domain: "example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 17,
Port: 441,
}).Init(0)
testEndpointMatch(t, ep, entity, NoMatch)
entity.Port = 442
entity.Init(0)
testEndpointMatch(t, ep, entity, Permitted)
entity.Port = 443
entity.Init(0)
testEndpointMatch(t, ep, entity, Permitted)
entity.Port = 444
entity.Init(0)
testEndpointMatch(t, ep, entity, Permitted)
entity.Port = 445
entity.Init(0)
testEndpointMatch(t, ep, entity, NoMatch)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
}).Init(0), NoMatch)
// IP
ep, err = parseEndpoint("+ 10.2.3.4")
if err != nil {
t.Fatal(err)
}
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
IP: net.ParseIP("10.2.3.4"),
Protocol: 17,
Port: 443,
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "",
IP: net.ParseIP("10.2.3.3"),
Protocol: 6,
Port: 443,
}).Init(0), NoMatch)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
IP: net.ParseIP("10.2.3.5"),
Protocol: 17,
Port: 443,
}).Init(0), NoMatch)
testEndpointMatch(t, ep, (&intel.Entity{
Domain: "example.com.",
}).Init(0), NoMatch)
// IP Range
ep, err = parseEndpoint("+ 10.2.3.0/24")
if err != nil {
t.Fatal(err)
}
testEndpointMatch(t, ep, (&intel.Entity{
IP: net.ParseIP("10.2.2.4"),
}).Init(0), NoMatch)
testEndpointMatch(t, ep, (&intel.Entity{
IP: net.ParseIP("10.2.3.4"),
}).Init(0), Permitted)
testEndpointMatch(t, ep, (&intel.Entity{
IP: net.ParseIP("10.2.4.4"),
}).Init(0), NoMatch)
// Skip test that need the geoip database in CI.
if !testing.Short() {
// ASN
ep, err = parseEndpoint("+ AS15169")
if err != nil {
t.Fatal(err)
}
entity = (&intel.Entity{IP: net.IPv4(8, 8, 8, 8)}).Init(0)
testEndpointMatch(t, ep, entity, Permitted)
entity = (&intel.Entity{IP: net.IPv4(1, 1, 1, 1)}).Init(0)
testEndpointMatch(t, ep, entity, NoMatch)
// Country
ep, err = parseEndpoint("+ AT")
if err != nil {
t.Fatal(err)
}
entity = (&intel.Entity{IP: net.IPv4(194, 232, 104, 1)}).Init(0) // orf.at
testEndpointMatch(t, ep, entity, Permitted)
entity = (&intel.Entity{IP: net.IPv4(151, 101, 1, 164)}).Init(0) // nytimes.com
testEndpointMatch(t, ep, entity, NoMatch)
}
// Scope
ep, err = parseEndpoint("+ Localhost,LAN")
if err != nil {
t.Fatal(err)
}
entity = (&intel.Entity{IP: net.IPv4(192, 168, 0, 1)}).Init(0)
testEndpointMatch(t, ep, entity, Permitted)
entity = (&intel.Entity{IP: net.IPv4(151, 101, 1, 164)}).Init(0) // nytimes.com
testEndpointMatch(t, ep, entity, NoMatch)
// Port with protocol wildcard
ep, err = parseEndpoint("+ * */443")
if err != nil {
t.Fatal(err)
}
entity = &intel.Entity{
Domain: "",
IP: net.ParseIP("10.2.3.4"),
Protocol: 6,
Port: 443,
}
entity.Init(0)
testEndpointMatch(t, ep, entity, Permitted)
// Lists
// Skip test that need the filter lists in CI.
if !testing.Short() {
_, err = parseEndpoint("+ L:A,B,C")
if err != nil {
t.Fatal(err)
}
}
// TODO: write test for lists matcher
}
func getLineNumberOfCaller(levels int) int {
_, _, line, _ := runtime.Caller(levels + 1) //nolint:dogsled
return line
}

View File

@@ -0,0 +1,34 @@
package endpoints
// Reason describes the reason why an endpoint has been
// permitted or blocked.
type Reason interface {
// String should return a human readable string
// describing the decision reason.
String() string
// Context returns the context that was used
// for the decision.
Context() interface{}
}
type reason struct {
description string
Filter string
Value string
Permitted bool
Extra map[string]interface{}
}
func (r *reason) String() string {
prefix := "denied by rule: "
if r.Permitted {
prefix = "allowed by rule: "
}
return prefix + r.description + " " + r.Filter[2:]
}
func (r *reason) Context() interface{} {
return r
}