Merge pull request #183 from safing/feature/ui-revamp-ingest
Revamp profile and process handling, add optionKey to reason
This commit is contained in:
@@ -29,7 +29,7 @@ func registerConfig() error {
|
||||
err := config.Register(&config.Option{
|
||||
Name: "Development Mode",
|
||||
Key: CfgDevModeKey,
|
||||
Description: "In Development Mode security restrictions are lifted/softened to enable easier access to Portmaster for debugging and testing purposes.",
|
||||
Description: "In Development Mode, security restrictions are lifted/softened to enable easier access to Portmaster for debugging and testing purposes.",
|
||||
OptType: config.OptTypeBool,
|
||||
ExpertiseLevel: config.ExpertiseLevelDeveloper,
|
||||
ReleaseLevel: config.ReleaseLevelStable,
|
||||
@@ -44,9 +44,9 @@ func registerConfig() error {
|
||||
}
|
||||
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Use System Notifications",
|
||||
Name: "Desktop Notifications",
|
||||
Key: CfgUseSystemNotificationsKey,
|
||||
Description: "Send notifications to your operating system's notification system. When this setting is turned off, notifications will only be visible in the Portmaster App. This affects both alerts from the Portmaster and questions from the Privacy Filter.",
|
||||
Description: "In addition to showing notifications in the Portmaster App, also send them to the Desktop. This requires the Portmaster Notifier to be running.",
|
||||
OptType: config.OptTypeBool,
|
||||
ExpertiseLevel: config.ExpertiseLevelUser,
|
||||
ReleaseLevel: config.ReleaseLevelStable,
|
||||
|
||||
@@ -45,9 +45,9 @@ func registerConfig() error {
|
||||
permanentVerdicts = config.Concurrent.GetAsBool(CfgOptionPermanentVerdictsKey, true)
|
||||
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Ask with System Notifications",
|
||||
Name: "Prompt Desktop Notifications",
|
||||
Key: CfgOptionAskWithSystemNotificationsKey,
|
||||
Description: `Ask about connections using your operating system's notification system. For this to be enabled, the setting "Use System Notifications" must enabled too. This only affects questions from the Privacy Filter, and does not affect alerts from the Portmaster.`,
|
||||
Description: `In addition to showing prompt notifications in the Portmaster App, also send them to the Desktop. This requires the Portmaster Notifier to be running. Requires Desktop Notifications to be enabled.`,
|
||||
OptType: config.OptTypeBool,
|
||||
ExpertiseLevel: config.ExpertiseLevelUser,
|
||||
ReleaseLevel: config.ReleaseLevelExperimental,
|
||||
@@ -66,9 +66,9 @@ func registerConfig() error {
|
||||
}
|
||||
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Timeout for Ask Notifications",
|
||||
Name: "Prompt Timeout",
|
||||
Key: CfgOptionAskTimeoutKey,
|
||||
Description: "Amount of time (in seconds) how long the Portmaster will wait for a response when prompting about a connection via a notification. Please note that system notifications might not respect this or have it's own limits.",
|
||||
Description: "How long the Portmaster will wait for a reply to a prompt notification. Please note that Desktop Notifications might not respect this or have it's own limits.",
|
||||
OptType: config.OptTypeInt,
|
||||
ExpertiseLevel: config.ExpertiseLevelUser,
|
||||
ReleaseLevel: config.ReleaseLevelExperimental,
|
||||
@@ -82,7 +82,7 @@ func registerConfig() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
askTimeout = config.Concurrent.GetAsInt(CfgOptionAskTimeoutKey, 60)
|
||||
askTimeout = config.Concurrent.GetAsInt(CfgOptionAskTimeoutKey, 15)
|
||||
|
||||
devMode = config.Concurrent.GetAsBool(core.CfgDevModeKey, false)
|
||||
apiListenAddress = config.GetAsString(api.CfgDefaultListenAddressKey, "")
|
||||
|
||||
@@ -17,13 +17,14 @@ import (
|
||||
"github.com/safing/portmaster/resolver"
|
||||
)
|
||||
|
||||
func filterDNSSection(entries []dns.RR, p *profile.LayeredProfile, scope int8) ([]dns.RR, []string, int) {
|
||||
func filterDNSSection(entries []dns.RR, p *profile.LayeredProfile, scope int8) ([]dns.RR, []string, int, string) {
|
||||
goodEntries := make([]dns.RR, 0, len(entries))
|
||||
filteredRecords := make([]string, 0, len(entries))
|
||||
|
||||
// keeps track of the number of valid and allowed
|
||||
// A and AAAA records.
|
||||
var allowedAddressRecords int
|
||||
var interveningOptionKey string
|
||||
|
||||
for _, rr := range entries {
|
||||
// get IP and classification
|
||||
@@ -45,10 +46,12 @@ func filterDNSSection(entries []dns.RR, p *profile.LayeredProfile, scope int8) (
|
||||
case classification == netutils.HostLocal:
|
||||
// No DNS should return localhost addresses
|
||||
filteredRecords = append(filteredRecords, rr.String())
|
||||
interveningOptionKey = profile.CfgOptionRemoveOutOfScopeDNSKey
|
||||
continue
|
||||
case scope == netutils.Global && (classification == netutils.SiteLocal || classification == netutils.LinkLocal):
|
||||
// No global DNS should return LAN addresses
|
||||
filteredRecords = append(filteredRecords, rr.String())
|
||||
interveningOptionKey = profile.CfgOptionRemoveOutOfScopeDNSKey
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -58,12 +61,15 @@ func filterDNSSection(entries []dns.RR, p *profile.LayeredProfile, scope int8) (
|
||||
switch {
|
||||
case p.BlockScopeInternet() && classification == netutils.Global:
|
||||
filteredRecords = append(filteredRecords, rr.String())
|
||||
interveningOptionKey = profile.CfgOptionBlockScopeInternetKey
|
||||
continue
|
||||
case p.BlockScopeLAN() && (classification == netutils.SiteLocal || classification == netutils.LinkLocal):
|
||||
filteredRecords = append(filteredRecords, rr.String())
|
||||
interveningOptionKey = profile.CfgOptionBlockScopeLANKey
|
||||
continue
|
||||
case p.BlockScopeLocal() && classification == netutils.HostLocal:
|
||||
filteredRecords = append(filteredRecords, rr.String())
|
||||
interveningOptionKey = profile.CfgOptionBlockScopeLocalKey
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -75,7 +81,7 @@ func filterDNSSection(entries []dns.RR, p *profile.LayeredProfile, scope int8) (
|
||||
goodEntries = append(goodEntries, rr)
|
||||
}
|
||||
|
||||
return goodEntries, filteredRecords, allowedAddressRecords
|
||||
return goodEntries, filteredRecords, allowedAddressRecords, interveningOptionKey
|
||||
}
|
||||
|
||||
func filterDNSResponse(conn *network.Connection, rrCache *resolver.RRCache) *resolver.RRCache {
|
||||
@@ -97,18 +103,19 @@ func filterDNSResponse(conn *network.Connection, rrCache *resolver.RRCache) *res
|
||||
|
||||
var filteredRecords []string
|
||||
var validIPs int
|
||||
var interveningOptionKey string
|
||||
|
||||
rrCache.Answer, filteredRecords, validIPs = filterDNSSection(rrCache.Answer, p, rrCache.ServerScope)
|
||||
rrCache.Answer, filteredRecords, validIPs, interveningOptionKey = filterDNSSection(rrCache.Answer, p, rrCache.ServerScope)
|
||||
rrCache.FilteredEntries = append(rrCache.FilteredEntries, filteredRecords...)
|
||||
|
||||
// we don't count the valid IPs in the extra section
|
||||
rrCache.Extra, filteredRecords, _ = filterDNSSection(rrCache.Extra, p, rrCache.ServerScope)
|
||||
rrCache.Extra, filteredRecords, _, _ = filterDNSSection(rrCache.Extra, p, rrCache.ServerScope)
|
||||
rrCache.FilteredEntries = append(rrCache.FilteredEntries, filteredRecords...)
|
||||
|
||||
if len(rrCache.FilteredEntries) > 0 {
|
||||
rrCache.Filtered = true
|
||||
if validIPs == 0 {
|
||||
conn.Block("no addresses returned for this domain are permitted")
|
||||
conn.Block("no addresses returned for this domain are permitted", interveningOptionKey)
|
||||
|
||||
// If all entries are filtered, this could mean that these are broken/bogus resource records.
|
||||
if rrCache.Expired() {
|
||||
@@ -151,12 +158,6 @@ func DecideOnResolvedDNS(
|
||||
rrCache *resolver.RRCache,
|
||||
) *resolver.RRCache {
|
||||
|
||||
// check profile
|
||||
if checkProfileExists(ctx, conn, nil) {
|
||||
// returns true if check triggered
|
||||
return nil
|
||||
}
|
||||
|
||||
// special grant for connectivity domains
|
||||
if checkConnectivityDomain(ctx, conn, nil) {
|
||||
// returns true if check triggered
|
||||
@@ -186,14 +187,14 @@ func mayBlockCNAMEs(ctx context.Context, conn *network.Connection) bool {
|
||||
|
||||
result, reason := conn.Process().Profile().MatchEndpoint(ctx, conn.Entity)
|
||||
if result == endpoints.Denied {
|
||||
conn.BlockWithContext(reason.String(), reason.Context())
|
||||
conn.BlockWithContext(reason.String(), profile.CfgOptionFilterCNAMEKey, reason.Context())
|
||||
return true
|
||||
}
|
||||
|
||||
if result == endpoints.NoMatch {
|
||||
result, reason = conn.Process().Profile().MatchFilterLists(ctx, conn.Entity)
|
||||
if result == endpoints.Denied {
|
||||
conn.BlockWithContext(reason.String(), reason.Context())
|
||||
conn.BlockWithContext(reason.String(), profile.CfgOptionFilterCNAMEKey, reason.Context())
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ func init() {
|
||||
filterModule,
|
||||
"config:filter/",
|
||||
&config.Option{
|
||||
Name: "Enable Privacy Filter",
|
||||
Name: "Privacy Filter",
|
||||
Key: CfgOptionEnableFilterKey,
|
||||
Description: "Enable the Privacy Filter Subsystem to filter DNS queries and network requests.",
|
||||
Description: "Enable the DNS and Network Filter.",
|
||||
OptType: config.OptTypeBool,
|
||||
ExpertiseLevel: config.ExpertiseLevelUser,
|
||||
ReleaseLevel: config.ReleaseLevelBeta,
|
||||
|
||||
@@ -85,11 +85,11 @@ func RunInspectors(conn *network.Connection, pkt packet.Packet) (network.Verdict
|
||||
verdict = network.VerdictDrop
|
||||
continueInspection = true
|
||||
case BLOCK_CONN:
|
||||
conn.SetVerdict(network.VerdictBlock, "", nil)
|
||||
conn.SetVerdict(network.VerdictBlock, "", "", nil)
|
||||
verdict = conn.Verdict
|
||||
activeInspectors[key] = true
|
||||
case DROP_CONN:
|
||||
conn.SetVerdict(network.VerdictDrop, "", nil)
|
||||
conn.SetVerdict(network.VerdictDrop, "", "", nil)
|
||||
verdict = conn.Verdict
|
||||
activeInspectors[key] = true
|
||||
case STOP_INSPECTING:
|
||||
|
||||
@@ -171,7 +171,7 @@ func initialHandler(conn *network.Connection, pkt packet.Packet) {
|
||||
ps := getPortStatusAndMarkUsed(pkt.Info().LocalPort())
|
||||
if ps.isMe {
|
||||
// approve
|
||||
conn.Accept("internally approved")
|
||||
conn.Accept("connection by Portmaster", noReasonOptionKey)
|
||||
conn.Internal = true
|
||||
// finish
|
||||
conn.StopFirewallHandler()
|
||||
@@ -191,7 +191,7 @@ func initialHandler(conn *network.Connection, pkt packet.Packet) {
|
||||
// check if filtering is enabled
|
||||
if !filterEnabled() {
|
||||
conn.Inspecting = false
|
||||
conn.SetVerdict(network.VerdictAccept, "privacy filter disabled", nil)
|
||||
conn.Accept("privacy filter disabled", noReasonOptionKey)
|
||||
conn.StopFirewallHandler()
|
||||
issueVerdict(conn, pkt, 0, true)
|
||||
return
|
||||
|
||||
@@ -70,7 +70,7 @@ func handleWindowsDNSCache() {
|
||||
|
||||
func notifyDisableDNSCache() {
|
||||
(¬ifications.Notification{
|
||||
ID: "windows-disable-dns-cache",
|
||||
EventID: "interception:windows-disable-dns-cache",
|
||||
Message: "The Portmaster needs the Windows Service \"DNS Client\" (dnscache) to be disabled for best effectiveness.",
|
||||
Type: notifications.Warning,
|
||||
}).Save()
|
||||
@@ -78,7 +78,7 @@ func notifyDisableDNSCache() {
|
||||
|
||||
func notifyRebootRequired() {
|
||||
(¬ifications.Notification{
|
||||
ID: "windows-dnscache-reboot-required",
|
||||
EventID: "interception:windows-dnscache-reboot-required",
|
||||
Message: "Please restart your system to complete Portmaster integration.",
|
||||
Type: notifications.Warning,
|
||||
}).Save()
|
||||
|
||||
@@ -36,44 +36,83 @@ import (
|
||||
// 3. DecideOnConnection
|
||||
// is called with the first packet of a network connection.
|
||||
|
||||
const noReasonOptionKey = ""
|
||||
|
||||
type deciderFn func(context.Context, *network.Connection, packet.Packet) bool
|
||||
|
||||
var deciders = []deciderFn{
|
||||
checkPortmasterConnection,
|
||||
checkSelfCommunication,
|
||||
checkConnectionType,
|
||||
checkConnectivityDomain,
|
||||
checkConnectionScope,
|
||||
checkEndpointLists,
|
||||
checkBypassPrevention,
|
||||
checkFilterLists,
|
||||
dropInbound,
|
||||
checkDomainHeuristics,
|
||||
checkAutoPermitRelated,
|
||||
}
|
||||
|
||||
// DecideOnConnection makes a decision about a connection.
|
||||
// When called, the connection and profile is already locked.
|
||||
func DecideOnConnection(ctx context.Context, conn *network.Connection, pkt packet.Packet) {
|
||||
// update profiles and check if communication needs reevaluation
|
||||
if conn.UpdateAndCheck() {
|
||||
// Check if we have a process and profile.
|
||||
layeredProfile := conn.Process().Profile()
|
||||
if layeredProfile == nil {
|
||||
conn.Deny("unknown process or profile", noReasonOptionKey)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the layered profile needs updating.
|
||||
if layeredProfile.NeedsUpdate() {
|
||||
// Update revision counter in connection.
|
||||
conn.ProfileRevisionCounter = layeredProfile.Update()
|
||||
conn.SaveWhenFinished()
|
||||
|
||||
// Reset verdict for connection.
|
||||
log.Tracer(ctx).Infof("filter: re-evaluating verdict on %s", conn)
|
||||
conn.Verdict = network.VerdictUndecided
|
||||
|
||||
// Reset entity if it exists.
|
||||
if conn.Entity != nil {
|
||||
conn.Entity.ResetLists()
|
||||
}
|
||||
}
|
||||
|
||||
var deciders = []func(context.Context, *network.Connection, packet.Packet) bool{
|
||||
checkPortmasterConnection,
|
||||
checkSelfCommunication,
|
||||
checkProfileExists,
|
||||
checkConnectionType,
|
||||
checkConnectivityDomain,
|
||||
checkConnectionScope,
|
||||
checkEndpointLists,
|
||||
checkBypassPrevention,
|
||||
checkFilterLists,
|
||||
checkInbound,
|
||||
checkDomainHeuristics,
|
||||
checkDefaultPermit,
|
||||
checkAutoPermitRelated,
|
||||
checkDefaultAction,
|
||||
// Run all deciders and return if they came to a conclusion.
|
||||
done, defaultAction := runDeciders(ctx, conn, pkt)
|
||||
if done {
|
||||
return
|
||||
}
|
||||
|
||||
// Deciders did not conclude, use default action.
|
||||
switch defaultAction {
|
||||
case profile.DefaultActionPermit:
|
||||
conn.Accept("default permit", profile.CfgOptionDefaultActionKey)
|
||||
case profile.DefaultActionAsk:
|
||||
prompt(ctx, conn, pkt)
|
||||
default:
|
||||
conn.Deny("default block", profile.CfgOptionDefaultActionKey)
|
||||
}
|
||||
}
|
||||
|
||||
func runDeciders(ctx context.Context, conn *network.Connection, pkt packet.Packet) (done bool, defaultAction uint8) {
|
||||
layeredProfile := conn.Process().Profile()
|
||||
|
||||
// Read-lock the all the profiles.
|
||||
layeredProfile.LockForUsage()
|
||||
defer layeredProfile.UnlockForUsage()
|
||||
|
||||
// Go though all deciders, return if one sets an action.
|
||||
for _, decider := range deciders {
|
||||
if decider(ctx, conn, pkt) {
|
||||
return
|
||||
return true, profile.DefaultActionNotSet
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultAction == DefaultActionBlock
|
||||
conn.Deny("endpoint is not allowed (default=block)")
|
||||
// Return the default action.
|
||||
return false, layeredProfile.DefaultAction()
|
||||
}
|
||||
|
||||
// checkPortmasterConnection allows all connection that originate from
|
||||
@@ -82,7 +121,7 @@ func checkPortmasterConnection(ctx context.Context, conn *network.Connection, pk
|
||||
// grant self
|
||||
if conn.Process().Pid == os.Getpid() {
|
||||
log.Tracer(ctx).Infof("filter: granting own connection %s", conn)
|
||||
conn.Verdict = network.VerdictAccept
|
||||
conn.Accept("connection by Portmaster", noReasonOptionKey)
|
||||
conn.Internal = true
|
||||
return true
|
||||
}
|
||||
@@ -115,7 +154,7 @@ func checkSelfCommunication(ctx context.Context, conn *network.Connection, pkt p
|
||||
if err != nil {
|
||||
log.Tracer(ctx).Warningf("filter: failed to find load local peer process with PID %d: %s", otherPid, err)
|
||||
} else if otherProcess.Pid == conn.Process().Pid {
|
||||
conn.Accept("connection to self")
|
||||
conn.Accept("process internal connection", noReasonOptionKey)
|
||||
conn.Internal = true
|
||||
return true
|
||||
}
|
||||
@@ -126,14 +165,6 @@ func checkSelfCommunication(ctx context.Context, conn *network.Connection, pkt p
|
||||
return false
|
||||
}
|
||||
|
||||
func checkProfileExists(_ context.Context, conn *network.Connection, _ packet.Packet) bool {
|
||||
if conn.Process().Profile() == nil {
|
||||
conn.Block("unknown process or profile")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkEndpointLists(ctx context.Context, conn *network.Connection, _ packet.Packet) bool {
|
||||
var result endpoints.EPResult
|
||||
var reason endpoints.Reason
|
||||
@@ -142,17 +173,20 @@ func checkEndpointLists(ctx context.Context, conn *network.Connection, _ packet.
|
||||
p := conn.Process().Profile()
|
||||
|
||||
// check endpoints list
|
||||
var optionKey string
|
||||
if conn.Inbound {
|
||||
result, reason = p.MatchServiceEndpoint(ctx, conn.Entity)
|
||||
optionKey = profile.CfgOptionServiceEndpointsKey
|
||||
} else {
|
||||
result, reason = p.MatchEndpoint(ctx, conn.Entity)
|
||||
optionKey = profile.CfgOptionEndpointsKey
|
||||
}
|
||||
switch result {
|
||||
case endpoints.Denied:
|
||||
conn.DenyWithContext(reason.String(), reason.Context())
|
||||
conn.DenyWithContext(reason.String(), optionKey, reason.Context())
|
||||
return true
|
||||
case endpoints.Permitted:
|
||||
conn.AcceptWithContext(reason.String(), reason.Context())
|
||||
conn.AcceptWithContext(reason.String(), optionKey, reason.Context())
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -167,16 +201,16 @@ func checkConnectionType(ctx context.Context, conn *network.Connection, _ packet
|
||||
case network.IncomingLAN, network.IncomingInternet, network.IncomingInvalid:
|
||||
if p.BlockInbound() {
|
||||
if conn.Scope == network.IncomingHost {
|
||||
conn.Block("inbound connections blocked")
|
||||
conn.Block("inbound connections blocked", profile.CfgOptionBlockInboundKey)
|
||||
} else {
|
||||
conn.Drop("inbound connections blocked")
|
||||
conn.Drop("inbound connections blocked", profile.CfgOptionBlockInboundKey)
|
||||
}
|
||||
return true
|
||||
}
|
||||
case network.PeerInternet:
|
||||
// BlockP2P only applies to connections to the Internet
|
||||
if p.BlockP2P() {
|
||||
conn.Block("direct connections (P2P) blocked")
|
||||
conn.Block("direct connections (P2P) blocked", profile.CfgOptionBlockP2PKey)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -202,7 +236,7 @@ func checkConnectivityDomain(_ context.Context, conn *network.Connection, _ pack
|
||||
|
||||
case netenv.IsConnectivityDomain(conn.Entity.Domain):
|
||||
// Special grant!
|
||||
conn.Accept("special grant for connectivity domain during network bootstrap")
|
||||
conn.Accept("special grant for connectivity domain during network bootstrap", noReasonOptionKey)
|
||||
return true
|
||||
|
||||
default:
|
||||
@@ -221,29 +255,29 @@ func checkConnectionScope(_ context.Context, conn *network.Connection, _ packet.
|
||||
switch classification {
|
||||
case netutils.Global, netutils.GlobalMulticast:
|
||||
if p.BlockScopeInternet() {
|
||||
conn.Deny("Internet access blocked") // Block Outbound / Drop Inbound
|
||||
conn.Deny("Internet access blocked", profile.CfgOptionBlockScopeInternetKey) // Block Outbound / Drop Inbound
|
||||
return true
|
||||
}
|
||||
case netutils.SiteLocal, netutils.LinkLocal, netutils.LocalMulticast:
|
||||
if p.BlockScopeLAN() {
|
||||
conn.Block("LAN access blocked") // Block Outbound / Drop Inbound
|
||||
conn.Block("LAN access blocked", profile.CfgOptionBlockScopeLANKey) // Block Outbound / Drop Inbound
|
||||
return true
|
||||
}
|
||||
case netutils.HostLocal:
|
||||
if p.BlockScopeLocal() {
|
||||
conn.Block("Localhost access blocked") // Block Outbound / Drop Inbound
|
||||
conn.Block("Localhost access blocked", profile.CfgOptionBlockScopeLocalKey) // Block Outbound / Drop Inbound
|
||||
return true
|
||||
}
|
||||
default: // netutils.Invalid
|
||||
conn.Deny("invalid IP") // Block Outbound / Drop Inbound
|
||||
conn.Deny("invalid IP", noReasonOptionKey) // Block Outbound / Drop Inbound
|
||||
return true
|
||||
}
|
||||
} else if conn.Entity.Domain != "" {
|
||||
// DNS Query
|
||||
// DNS is expected to resolve to LAN or Internet addresses
|
||||
// TODO: handle domains mapped to localhost
|
||||
// This is a DNS Request.
|
||||
// DNS is expected to resolve to LAN or Internet addresses.
|
||||
// Localhost queries are immediately responded to by the nameserver.
|
||||
if p.BlockScopeInternet() && p.BlockScopeLAN() {
|
||||
conn.Block("Internet and LAN access blocked")
|
||||
conn.Block("Internet and LAN access blocked", profile.CfgOptionBlockScopeInternetKey)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -256,10 +290,10 @@ func checkBypassPrevention(_ context.Context, conn *network.Connection, _ packet
|
||||
result, reason, reasonCtx := PreventBypassing(conn)
|
||||
switch result {
|
||||
case endpoints.Denied:
|
||||
conn.BlockWithContext("bypass prevention: "+reason, reasonCtx)
|
||||
conn.BlockWithContext("bypass prevention: "+reason, profile.CfgOptionPreventBypassingKey, reasonCtx)
|
||||
return true
|
||||
case endpoints.Permitted:
|
||||
conn.AcceptWithContext("bypass prevention: "+reason, reasonCtx)
|
||||
conn.AcceptWithContext("bypass prevention: "+reason, profile.CfgOptionPreventBypassingKey, reasonCtx)
|
||||
return true
|
||||
case endpoints.NoMatch:
|
||||
}
|
||||
@@ -274,7 +308,7 @@ func checkFilterLists(ctx context.Context, conn *network.Connection, pkt packet.
|
||||
result, reason := p.MatchFilterLists(ctx, conn.Entity)
|
||||
switch result {
|
||||
case endpoints.Denied:
|
||||
conn.DenyWithContext(reason.String(), reason.Context())
|
||||
conn.DenyWithContext(reason.String(), profile.CfgOptionFilterListsKey, reason.Context())
|
||||
return true
|
||||
case endpoints.NoMatch:
|
||||
// nothing to do
|
||||
@@ -315,7 +349,7 @@ func checkDomainHeuristics(ctx context.Context, conn *network.Connection, _ pack
|
||||
domainToCheck,
|
||||
score,
|
||||
)
|
||||
conn.Block("possible DGA domain commonly used by malware")
|
||||
conn.Block("possible DGA domain commonly used by malware", profile.CfgOptionDomainHeuristicsKey)
|
||||
return true
|
||||
}
|
||||
log.Tracer(ctx).Tracef("filter: LMS score of eTLD+1 %s is %.2f", etld1, score)
|
||||
@@ -335,7 +369,7 @@ func checkDomainHeuristics(ctx context.Context, conn *network.Connection, _ pack
|
||||
domainToCheck,
|
||||
score,
|
||||
)
|
||||
conn.Block("possible data tunnel for covert communication and protection bypassing")
|
||||
conn.Block("possible data tunnel for covert communication and protection bypassing", profile.CfgOptionDomainHeuristicsKey)
|
||||
return true
|
||||
}
|
||||
log.Tracer(ctx).Tracef("filter: LMS score of entire domain is %.2f", score)
|
||||
@@ -344,20 +378,10 @@ func checkDomainHeuristics(ctx context.Context, conn *network.Connection, _ pack
|
||||
return false
|
||||
}
|
||||
|
||||
func checkInbound(_ context.Context, conn *network.Connection, _ packet.Packet) bool {
|
||||
func dropInbound(_ context.Context, conn *network.Connection, _ packet.Packet) bool {
|
||||
// implicit default=block for inbound
|
||||
if conn.Inbound {
|
||||
conn.Drop("endpoint is not allowed (incoming is always default=block)")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkDefaultPermit(_ context.Context, conn *network.Connection, _ packet.Packet) bool {
|
||||
// check default action
|
||||
p := conn.Process().Profile()
|
||||
if p.DefaultAction() == profile.DefaultActionPermit {
|
||||
conn.Accept("endpoint is not blocked (default=permit)")
|
||||
conn.Drop("incoming connection blocked by default", profile.CfgOptionServiceEndpointsKey)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -365,22 +389,24 @@ func checkDefaultPermit(_ context.Context, conn *network.Connection, _ packet.Pa
|
||||
|
||||
func checkAutoPermitRelated(_ context.Context, conn *network.Connection, _ packet.Packet) bool {
|
||||
p := conn.Process().Profile()
|
||||
if !p.DisableAutoPermit() {
|
||||
related, reason := checkRelation(conn)
|
||||
if related {
|
||||
conn.Accept(reason)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkDefaultAction(_ context.Context, conn *network.Connection, pkt packet.Packet) bool {
|
||||
p := conn.Process().Profile()
|
||||
if p.DefaultAction() == profile.DefaultActionAsk {
|
||||
prompt(conn, pkt)
|
||||
// Auto permit is disabled for default action permit.
|
||||
if p.DefaultAction() == profile.DefaultActionPermit {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if auto permit is disabled.
|
||||
if p.DisableAutoPermit() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for relation to auto permit.
|
||||
related, reason := checkRelation(conn)
|
||||
if related {
|
||||
conn.Accept(reason, profile.CfgOptionDisableAutoPermitKey)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -426,7 +452,7 @@ matchLoop:
|
||||
}
|
||||
|
||||
if related {
|
||||
reason = fmt.Sprintf("domain is related to process: %s is related to %s", domainElement, processElement)
|
||||
reason = fmt.Sprintf("auto permitted: domain is related to process: %s is related to %s", domainElement, processElement)
|
||||
}
|
||||
return related, reason
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/safing/portmaster/profile/endpoints"
|
||||
|
||||
"github.com/safing/portbase/log"
|
||||
"github.com/safing/portbase/notifications"
|
||||
"github.com/safing/portmaster/intel"
|
||||
"github.com/safing/portmaster/network"
|
||||
"github.com/safing/portmaster/network/packet"
|
||||
"github.com/safing/portmaster/profile"
|
||||
"github.com/safing/portmaster/profile/endpoints"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,8 +28,47 @@ const (
|
||||
denyServingIP = "deny-serving-ip"
|
||||
)
|
||||
|
||||
func prompt(conn *network.Connection, pkt packet.Packet) { //nolint:gocognit // TODO
|
||||
nTTL := time.Duration(askTimeout()) * time.Second
|
||||
var (
|
||||
promptNotificationCreation sync.Mutex
|
||||
)
|
||||
|
||||
type promptData struct {
|
||||
Entity *intel.Entity
|
||||
Profile promptProfile
|
||||
}
|
||||
|
||||
type promptProfile struct {
|
||||
Source string
|
||||
ID string
|
||||
LinkedPath string
|
||||
}
|
||||
|
||||
func prompt(ctx context.Context, conn *network.Connection, pkt packet.Packet) { //nolint:gocognit // TODO
|
||||
// Create notification.
|
||||
n := createPrompt(ctx, conn, pkt)
|
||||
|
||||
// wait for response/timeout
|
||||
select {
|
||||
case promptResponse := <-n.Response():
|
||||
switch promptResponse {
|
||||
case permitDomainAll, permitDomainDistinct, permitIP, permitServingIP:
|
||||
conn.Accept("permitted via prompt", profile.CfgOptionEndpointsKey)
|
||||
default: // deny
|
||||
conn.Deny("blocked via prompt", profile.CfgOptionEndpointsKey)
|
||||
}
|
||||
|
||||
case <-time.After(1 * time.Second):
|
||||
log.Tracer(ctx).Debugf("filter: continuing prompting async")
|
||||
conn.Deny("prompting in progress", profile.CfgOptionDefaultActionKey)
|
||||
|
||||
case <-ctx.Done():
|
||||
log.Tracer(ctx).Debugf("filter: aborting prompting because of shutdown")
|
||||
conn.Drop("shutting down", noReasonOptionKey)
|
||||
}
|
||||
}
|
||||
|
||||
func createPrompt(ctx context.Context, conn *network.Connection, pkt packet.Packet) (n *notifications.Notification) {
|
||||
expires := time.Now().Add(time.Duration(askTimeout()) * time.Second).Unix()
|
||||
|
||||
// first check if there is an existing notification for this.
|
||||
// build notification ID
|
||||
@@ -37,134 +79,154 @@ func prompt(conn *network.Connection, pkt packet.Packet) { //nolint:gocognit //
|
||||
default: // connection to domain
|
||||
nID = fmt.Sprintf("filter:prompt-%d-%s", conn.Process().Pid, conn.Scope)
|
||||
}
|
||||
n := notifications.Get(nID)
|
||||
saveResponse := true
|
||||
|
||||
// Only handle one notification at a time.
|
||||
promptNotificationCreation.Lock()
|
||||
defer promptNotificationCreation.Unlock()
|
||||
|
||||
n = notifications.Get(nID)
|
||||
|
||||
// If there already is a notification, just update the expiry.
|
||||
if n != nil {
|
||||
// update with new expiry
|
||||
n.Update(time.Now().Add(nTTL).Unix())
|
||||
// do not save response to profile
|
||||
saveResponse = false
|
||||
} else {
|
||||
var (
|
||||
msg string
|
||||
actions []notifications.Action
|
||||
n.Update(expires)
|
||||
log.Tracer(ctx).Debugf("filter: updated existing prompt notification")
|
||||
return
|
||||
}
|
||||
|
||||
// Reference relevant data for save function
|
||||
localProfile := conn.Process().Profile().LocalProfile()
|
||||
entity := conn.Entity
|
||||
|
||||
// Create new notification.
|
||||
n = ¬ifications.Notification{
|
||||
EventID: nID,
|
||||
Type: notifications.Prompt,
|
||||
Title: "Connection Prompt",
|
||||
Category: "Privacy Filter",
|
||||
EventData: &promptData{
|
||||
Entity: entity,
|
||||
Profile: promptProfile{
|
||||
Source: string(localProfile.Source),
|
||||
ID: localProfile.ID,
|
||||
LinkedPath: localProfile.LinkedPath,
|
||||
},
|
||||
},
|
||||
Expires: expires,
|
||||
}
|
||||
|
||||
// Set action function.
|
||||
n.SetActionFunction(func(_ context.Context, n *notifications.Notification) error {
|
||||
return saveResponse(
|
||||
localProfile,
|
||||
entity,
|
||||
n.SelectedActionID,
|
||||
)
|
||||
})
|
||||
|
||||
// add message and actions
|
||||
switch {
|
||||
case conn.Inbound:
|
||||
msg = fmt.Sprintf("Application %s wants to accept connections from %s (%d/%d)", conn.Process(), conn.Entity.IP.String(), conn.Entity.Protocol, conn.Entity.Port)
|
||||
actions = []notifications.Action{
|
||||
{
|
||||
ID: permitServingIP,
|
||||
Text: "Permit",
|
||||
},
|
||||
{
|
||||
ID: denyServingIP,
|
||||
Text: "Deny",
|
||||
},
|
||||
}
|
||||
case conn.Entity.Domain == "": // direct connection
|
||||
msg = fmt.Sprintf("Application %s wants to connect to %s (%d/%d)", conn.Process(), conn.Entity.IP.String(), conn.Entity.Protocol, conn.Entity.Port)
|
||||
actions = []notifications.Action{
|
||||
{
|
||||
ID: permitIP,
|
||||
Text: "Permit",
|
||||
},
|
||||
{
|
||||
ID: denyIP,
|
||||
Text: "Deny",
|
||||
},
|
||||
}
|
||||
default: // connection to domain
|
||||
if pkt != nil {
|
||||
msg = fmt.Sprintf("Application %s wants to connect to %s (%s %d/%d)", conn.Process(), conn.Entity.Domain, conn.Entity.IP.String(), conn.Entity.Protocol, conn.Entity.Port)
|
||||
} else {
|
||||
msg = fmt.Sprintf("Application %s wants to connect to %s", conn.Process(), conn.Entity.Domain)
|
||||
}
|
||||
actions = []notifications.Action{
|
||||
{
|
||||
ID: permitDomainAll,
|
||||
Text: "Permit all",
|
||||
},
|
||||
{
|
||||
ID: permitDomainDistinct,
|
||||
Text: "Permit",
|
||||
},
|
||||
{
|
||||
ID: denyDomainDistinct,
|
||||
Text: "Deny",
|
||||
},
|
||||
}
|
||||
// add message and actions
|
||||
switch {
|
||||
case conn.Inbound:
|
||||
n.Message = fmt.Sprintf("Application %s wants to accept connections from %s (%d/%d)", conn.Process(), conn.Entity.IP.String(), conn.Entity.Protocol, conn.Entity.Port)
|
||||
n.AvailableActions = []*notifications.Action{
|
||||
{
|
||||
ID: permitServingIP,
|
||||
Text: "Permit",
|
||||
},
|
||||
{
|
||||
ID: denyServingIP,
|
||||
Text: "Deny",
|
||||
},
|
||||
}
|
||||
case conn.Entity.Domain == "": // direct connection
|
||||
n.Message = fmt.Sprintf("Application %s wants to connect to %s (%d/%d)", conn.Process(), conn.Entity.IP.String(), conn.Entity.Protocol, conn.Entity.Port)
|
||||
n.AvailableActions = []*notifications.Action{
|
||||
{
|
||||
ID: permitIP,
|
||||
Text: "Permit",
|
||||
},
|
||||
{
|
||||
ID: denyIP,
|
||||
Text: "Deny",
|
||||
},
|
||||
}
|
||||
default: // connection to domain
|
||||
n.Message = fmt.Sprintf("Application %s wants to connect to %s", conn.Process(), conn.Entity.Domain)
|
||||
n.AvailableActions = []*notifications.Action{
|
||||
{
|
||||
ID: permitDomainAll,
|
||||
Text: "Permit all",
|
||||
},
|
||||
{
|
||||
ID: permitDomainDistinct,
|
||||
Text: "Permit",
|
||||
},
|
||||
{
|
||||
ID: denyDomainDistinct,
|
||||
Text: "Deny",
|
||||
},
|
||||
}
|
||||
|
||||
n = notifications.NotifyPrompt(nID, msg, actions...)
|
||||
}
|
||||
|
||||
// wait for response/timeout
|
||||
select {
|
||||
case promptResponse := <-n.Response():
|
||||
switch promptResponse {
|
||||
case permitDomainAll, permitDomainDistinct, permitIP, permitServingIP:
|
||||
conn.Accept("permitted by user")
|
||||
default: // deny
|
||||
conn.Deny("denied by user")
|
||||
}
|
||||
n.Save()
|
||||
log.Tracer(ctx).Debugf("filter: sent prompt notification")
|
||||
|
||||
// end here if we won't save the response to the profile
|
||||
if !saveResponse {
|
||||
return
|
||||
}
|
||||
|
||||
// get profile
|
||||
p := conn.Process().Profile()
|
||||
|
||||
var ep endpoints.Endpoint
|
||||
switch promptResponse {
|
||||
case permitDomainAll:
|
||||
ep = &endpoints.EndpointDomain{
|
||||
EndpointBase: endpoints.EndpointBase{Permitted: true},
|
||||
Domain: "." + conn.Entity.Domain,
|
||||
}
|
||||
case permitDomainDistinct:
|
||||
ep = &endpoints.EndpointDomain{
|
||||
EndpointBase: endpoints.EndpointBase{Permitted: true},
|
||||
Domain: conn.Entity.Domain,
|
||||
}
|
||||
case denyDomainAll:
|
||||
ep = &endpoints.EndpointDomain{
|
||||
EndpointBase: endpoints.EndpointBase{Permitted: false},
|
||||
Domain: "." + conn.Entity.Domain,
|
||||
}
|
||||
case denyDomainDistinct:
|
||||
ep = &endpoints.EndpointDomain{
|
||||
EndpointBase: endpoints.EndpointBase{Permitted: false},
|
||||
Domain: conn.Entity.Domain,
|
||||
}
|
||||
case permitIP, permitServingIP:
|
||||
ep = &endpoints.EndpointIP{
|
||||
EndpointBase: endpoints.EndpointBase{Permitted: true},
|
||||
IP: conn.Entity.IP,
|
||||
}
|
||||
case denyIP, denyServingIP:
|
||||
ep = &endpoints.EndpointIP{
|
||||
EndpointBase: endpoints.EndpointBase{Permitted: false},
|
||||
IP: conn.Entity.IP,
|
||||
}
|
||||
default:
|
||||
log.Warningf("filter: unknown prompt response: %s", promptResponse)
|
||||
return
|
||||
}
|
||||
|
||||
switch promptResponse {
|
||||
case permitServingIP, denyServingIP:
|
||||
p.AddServiceEndpoint(ep.String())
|
||||
default:
|
||||
p.AddEndpoint(ep.String())
|
||||
}
|
||||
|
||||
case <-n.Expired():
|
||||
conn.Deny("no response to prompt")
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func saveResponse(p *profile.Profile, entity *intel.Entity, promptResponse string) error {
|
||||
// Update the profile if necessary.
|
||||
if p.IsOutdated() {
|
||||
var err error
|
||||
p, _, err = profile.GetProfile(p.Source, p.ID, p.LinkedPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var ep endpoints.Endpoint
|
||||
switch promptResponse {
|
||||
case permitDomainAll:
|
||||
ep = &endpoints.EndpointDomain{
|
||||
EndpointBase: endpoints.EndpointBase{Permitted: true},
|
||||
OriginalValue: "." + entity.Domain,
|
||||
}
|
||||
case permitDomainDistinct:
|
||||
ep = &endpoints.EndpointDomain{
|
||||
EndpointBase: endpoints.EndpointBase{Permitted: true},
|
||||
OriginalValue: entity.Domain,
|
||||
}
|
||||
case denyDomainAll:
|
||||
ep = &endpoints.EndpointDomain{
|
||||
EndpointBase: endpoints.EndpointBase{Permitted: false},
|
||||
OriginalValue: "." + entity.Domain,
|
||||
}
|
||||
case denyDomainDistinct:
|
||||
ep = &endpoints.EndpointDomain{
|
||||
EndpointBase: endpoints.EndpointBase{Permitted: false},
|
||||
OriginalValue: entity.Domain,
|
||||
}
|
||||
case permitIP, permitServingIP:
|
||||
ep = &endpoints.EndpointIP{
|
||||
EndpointBase: endpoints.EndpointBase{Permitted: true},
|
||||
IP: entity.IP,
|
||||
}
|
||||
case denyIP, denyServingIP:
|
||||
ep = &endpoints.EndpointIP{
|
||||
EndpointBase: endpoints.EndpointBase{Permitted: false},
|
||||
IP: entity.IP,
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown prompt response: %s", promptResponse)
|
||||
}
|
||||
|
||||
switch promptResponse {
|
||||
case permitServingIP, denyServingIP:
|
||||
p.AddServiceEndpoint(ep.String())
|
||||
log.Infof("filter: added incoming rule to profile %s: %q", p, ep.String())
|
||||
default:
|
||||
p.AddEndpoint(ep.String())
|
||||
log.Infof("filter: added outgoing rule to profile %s: %q", p, ep.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -197,11 +197,11 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg)
|
||||
// A reason for this might be that the request is sink-holed to a forced
|
||||
// IP address in which case we "accept" it, but let the firewall handle
|
||||
// the resolving as it wishes.
|
||||
if responder, ok := conn.ReasonContext.(nsutil.Responder); ok {
|
||||
if responder, ok := conn.Reason.Context.(nsutil.Responder); ok {
|
||||
// Save the request as open, as we don't know if there will be a connection or not.
|
||||
network.SaveOpenDNSRequest(conn)
|
||||
|
||||
tracer.Infof("nameserver: handing over request for %s to special filter responder: %s", q.ID(), conn.Reason)
|
||||
tracer.Infof("nameserver: handing over request for %s to special filter responder: %s", q.ID(), conn.Reason.Msg)
|
||||
return reply(responder)
|
||||
}
|
||||
|
||||
@@ -243,11 +243,11 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg)
|
||||
rrCache = firewall.DecideOnResolvedDNS(ctx, conn, q, rrCache)
|
||||
if rrCache == nil {
|
||||
// Check again if there is a responder from the firewall.
|
||||
if responder, ok := conn.ReasonContext.(nsutil.Responder); ok {
|
||||
if responder, ok := conn.Reason.Context.(nsutil.Responder); ok {
|
||||
// Save the request as open, as we don't know if there will be a connection or not.
|
||||
network.SaveOpenDNSRequest(conn)
|
||||
|
||||
tracer.Infof("nameserver: handing over request for %s to filter responder: %s", q.ID(), conn.Reason)
|
||||
tracer.Infof("nameserver: handing over request for %s to filter responder: %s", q.ID(), conn.Reason.Msg)
|
||||
return reply(responder)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,10 +47,16 @@ func checkForConflictingService() error {
|
||||
// wait for a short duration for the other service to shut down
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
notifications.NotifyInfo(
|
||||
"namserver-stopped-conflicting-service",
|
||||
fmt.Sprintf("Portmaster stopped a conflicting name service (pid %d) to gain required system integration.", pid),
|
||||
)
|
||||
notifications.Notify(¬ifications.Notification{
|
||||
EventID: "namserver:stopped-conflicting-service",
|
||||
Type: notifications.Info,
|
||||
Title: "Conflicting DNS Service",
|
||||
Category: "Secure DNS",
|
||||
Message: fmt.Sprintf(
|
||||
"The Portmaster stopped a conflicting name service (pid %d) to gain required system integration.",
|
||||
pid,
|
||||
),
|
||||
})
|
||||
|
||||
// restart via service-worker logic
|
||||
return fmt.Errorf("%w: stopped conflicting name service with pid %d", modules.ErrRestartNow, pid)
|
||||
|
||||
@@ -213,16 +213,6 @@ func setCaptivePortal(portalURL *url.URL) {
|
||||
return
|
||||
}
|
||||
|
||||
// notify
|
||||
cleanUpPortalNotification()
|
||||
defer func() {
|
||||
// TODO: add "open" button
|
||||
captivePortalNotification = notifications.NotifyInfo(
|
||||
"netenv:captive-portal:"+captivePortal.Domain,
|
||||
"Portmaster detected a captive portal at "+captivePortal.Domain,
|
||||
)
|
||||
}()
|
||||
|
||||
// set
|
||||
captivePortal = &CaptivePortal{
|
||||
URL: portalURL.String(),
|
||||
@@ -234,6 +224,20 @@ func setCaptivePortal(portalURL *url.URL) {
|
||||
} else {
|
||||
captivePortal.Domain = portalURL.Hostname()
|
||||
}
|
||||
|
||||
// notify
|
||||
cleanUpPortalNotification()
|
||||
captivePortalNotification = notifications.Notify(¬ifications.Notification{
|
||||
EventID: "netenv:captive-portal",
|
||||
Type: notifications.Info,
|
||||
Title: "Captive Portal",
|
||||
Category: "Core",
|
||||
Message: fmt.Sprintf(
|
||||
"Portmaster detected a captive portal at %s",
|
||||
captivePortal.Domain,
|
||||
),
|
||||
EventData: captivePortal,
|
||||
})
|
||||
}
|
||||
|
||||
func cleanUpPortalNotification() {
|
||||
|
||||
@@ -83,12 +83,10 @@ type Connection struct { //nolint:maligned // TODO: fix alignment
|
||||
// The verdict may change so any access to it must be guarded by the
|
||||
// connection lock.
|
||||
Verdict Verdict
|
||||
// Reason is a human readable description justifying the set verdict.
|
||||
// Reason holds information justifying the verdict, as well as additional
|
||||
// information about the reason.
|
||||
// Access to Reason must be guarded by the connection lock.
|
||||
Reason string
|
||||
// ReasonContext may holds additional reason-specific information and
|
||||
// any access must be guarded by the connection lock.
|
||||
ReasonContext interface{}
|
||||
Reason Reason
|
||||
// Started holds the number of seconds in UNIX epoch time at which
|
||||
// the connection has been initated and first seen by the portmaster.
|
||||
// Staretd is only every set when creating a new connection object
|
||||
@@ -96,7 +94,7 @@ type Connection struct { //nolint:maligned // TODO: fix alignment
|
||||
Started int64
|
||||
// Ended is set to the number of seconds in UNIX epoch time at which
|
||||
// the connection is considered terminated. Ended may be set at any
|
||||
// time so access must be guarded by the conneciton lock.
|
||||
// time so access must be guarded by the connection lock.
|
||||
Ended int64
|
||||
// VerdictPermanent is set to true if the final verdict is permanent
|
||||
// and the connection has been (or will be) handed back to the kernel.
|
||||
@@ -121,7 +119,7 @@ type Connection struct { //nolint:maligned // TODO: fix alignment
|
||||
// points and access to it must be guarded by the connection lock.
|
||||
Internal bool
|
||||
// process holds a reference to the actor process. That is, the
|
||||
// process instance that initated the conneciton.
|
||||
// process instance that initated the connection.
|
||||
process *process.Process
|
||||
// pkgQueue is used to serialize packet handling for a single
|
||||
// connection and is served by the connections packetHandler.
|
||||
@@ -141,10 +139,26 @@ type Connection struct { //nolint:maligned // TODO: fix alignment
|
||||
// inspectorData holds additional meta data for the inspectors.
|
||||
// using the inspectors index as a map key.
|
||||
inspectorData map[uint8]interface{}
|
||||
// profileRevisionCounter is used to track changes to the process
|
||||
// ProfileRevisionCounter is used to track changes to the process
|
||||
// profile and required for correct re-evaluation of a connections
|
||||
// verdict.
|
||||
profileRevisionCounter uint64
|
||||
ProfileRevisionCounter uint64
|
||||
}
|
||||
|
||||
// Reason holds information justifying a verdict, as well as additional
|
||||
// information about the reason.
|
||||
type Reason struct {
|
||||
// Msg is a human readable description of the reason.
|
||||
Msg string
|
||||
// OptionKey is the configuration option key of the setting that
|
||||
// was responsible for the verdict.
|
||||
OptionKey string
|
||||
// Profile is the database key of the profile that held the setting
|
||||
// that was responsible for the verdict.
|
||||
Profile string
|
||||
// ReasonContext may hold additional reason-specific information and
|
||||
// any access must be guarded by the connection lock.
|
||||
Context interface{}
|
||||
}
|
||||
|
||||
func getProcessContext(proc *process.Process) ProcessContext {
|
||||
@@ -290,7 +304,7 @@ func NewConnectionFromFirstPacket(pkt packet.Packet) *Connection {
|
||||
Entity: entity,
|
||||
// meta
|
||||
Started: time.Now().Unix(),
|
||||
profileRevisionCounter: proc.Profile().RevisionCnt(),
|
||||
ProfileRevisionCounter: proc.Profile().RevisionCnt(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,73 +314,77 @@ func GetConnection(id string) (*Connection, bool) {
|
||||
}
|
||||
|
||||
// AcceptWithContext accepts the connection.
|
||||
func (conn *Connection) AcceptWithContext(reason string, ctx interface{}) {
|
||||
if !conn.SetVerdict(VerdictAccept, reason, ctx) {
|
||||
func (conn *Connection) AcceptWithContext(reason, reasonOptionKey string, ctx interface{}) {
|
||||
if !conn.SetVerdict(VerdictAccept, reason, reasonOptionKey, ctx) {
|
||||
log.Warningf("filter: tried to accept %s, but current verdict is %s", conn, conn.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
// Accept is like AcceptWithContext but only accepts a reason.
|
||||
func (conn *Connection) Accept(reason string) {
|
||||
conn.AcceptWithContext(reason, nil)
|
||||
func (conn *Connection) Accept(reason, reasonOptionKey string) {
|
||||
conn.AcceptWithContext(reason, reasonOptionKey, nil)
|
||||
}
|
||||
|
||||
// BlockWithContext blocks the connection.
|
||||
func (conn *Connection) BlockWithContext(reason string, ctx interface{}) {
|
||||
if !conn.SetVerdict(VerdictBlock, reason, ctx) {
|
||||
func (conn *Connection) BlockWithContext(reason, reasonOptionKey string, ctx interface{}) {
|
||||
if !conn.SetVerdict(VerdictBlock, reason, reasonOptionKey, ctx) {
|
||||
log.Warningf("filter: tried to block %s, but current verdict is %s", conn, conn.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
// Block is like BlockWithContext but does only accepts a reason.
|
||||
func (conn *Connection) Block(reason string) {
|
||||
conn.BlockWithContext(reason, nil)
|
||||
func (conn *Connection) Block(reason, reasonOptionKey string) {
|
||||
conn.BlockWithContext(reason, reasonOptionKey, nil)
|
||||
}
|
||||
|
||||
// DropWithContext drops the connection.
|
||||
func (conn *Connection) DropWithContext(reason string, ctx interface{}) {
|
||||
if !conn.SetVerdict(VerdictDrop, reason, ctx) {
|
||||
func (conn *Connection) DropWithContext(reason, reasonOptionKey string, ctx interface{}) {
|
||||
if !conn.SetVerdict(VerdictDrop, reason, reasonOptionKey, ctx) {
|
||||
log.Warningf("filter: tried to drop %s, but current verdict is %s", conn, conn.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop is like DropWithContext but does only accepts a reason.
|
||||
func (conn *Connection) Drop(reason string) {
|
||||
conn.DropWithContext(reason, nil)
|
||||
func (conn *Connection) Drop(reason, reasonOptionKey string) {
|
||||
conn.DropWithContext(reason, reasonOptionKey, nil)
|
||||
}
|
||||
|
||||
// DenyWithContext blocks or drops the link depending on the connection direction.
|
||||
func (conn *Connection) DenyWithContext(reason string, ctx interface{}) {
|
||||
func (conn *Connection) DenyWithContext(reason, reasonOptionKey string, ctx interface{}) {
|
||||
if conn.Inbound {
|
||||
conn.DropWithContext(reason, ctx)
|
||||
conn.DropWithContext(reason, reasonOptionKey, ctx)
|
||||
} else {
|
||||
conn.BlockWithContext(reason, ctx)
|
||||
conn.BlockWithContext(reason, reasonOptionKey, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Deny is like DenyWithContext but only accepts a reason.
|
||||
func (conn *Connection) Deny(reason string) {
|
||||
conn.DenyWithContext(reason, nil)
|
||||
func (conn *Connection) Deny(reason, reasonOptionKey string) {
|
||||
conn.DenyWithContext(reason, reasonOptionKey, nil)
|
||||
}
|
||||
|
||||
// FailedWithContext marks the connection with VerdictFailed and stores the reason.
|
||||
func (conn *Connection) FailedWithContext(reason string, ctx interface{}) {
|
||||
if !conn.SetVerdict(VerdictFailed, reason, ctx) {
|
||||
func (conn *Connection) FailedWithContext(reason, reasonOptionKey string, ctx interface{}) {
|
||||
if !conn.SetVerdict(VerdictFailed, reason, reasonOptionKey, ctx) {
|
||||
log.Warningf("filter: tried to drop %s due to error but current verdict is %s", conn, conn.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
// Failed is like FailedWithContext but only accepts a string.
|
||||
func (conn *Connection) Failed(reason string) {
|
||||
conn.FailedWithContext(reason, nil)
|
||||
func (conn *Connection) Failed(reason, reasonOptionKey string) {
|
||||
conn.FailedWithContext(reason, reasonOptionKey, nil)
|
||||
}
|
||||
|
||||
// SetVerdict sets a new verdict for the connection, making sure it does not interfere with previous verdicts.
|
||||
func (conn *Connection) SetVerdict(newVerdict Verdict, reason string, reasonCtx interface{}) (ok bool) {
|
||||
func (conn *Connection) SetVerdict(newVerdict Verdict, reason, reasonOptionKey string, reasonCtx interface{}) (ok bool) {
|
||||
if newVerdict >= conn.Verdict {
|
||||
conn.Verdict = newVerdict
|
||||
conn.Reason = reason
|
||||
conn.ReasonContext = reasonCtx
|
||||
conn.Reason.Msg = reason
|
||||
conn.Reason.Context = reasonCtx
|
||||
if reasonOptionKey != "" && conn.Process() != nil {
|
||||
conn.Reason.OptionKey = reasonOptionKey
|
||||
conn.Reason.Profile = conn.Process().Profile().GetProfileSource(conn.Reason.OptionKey)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -424,21 +442,6 @@ func (conn *Connection) delete() {
|
||||
dbController.PushUpdate(conn)
|
||||
}
|
||||
|
||||
// UpdateAndCheck updates profiles and checks whether a reevaluation is needed.
|
||||
func (conn *Connection) UpdateAndCheck() (needsReevaluation bool) {
|
||||
p := conn.process.Profile()
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
revCnt := p.Update()
|
||||
|
||||
if conn.profileRevisionCounter != revCnt {
|
||||
conn.profileRevisionCounter = revCnt
|
||||
needsReevaluation = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SetFirewallHandler sets the firewall handler for this link, and starts a
|
||||
// worker to handle the packets.
|
||||
func (conn *Connection) SetFirewallHandler(handler FirewallHandler) {
|
||||
@@ -490,7 +493,7 @@ func (conn *Connection) packetHandler() {
|
||||
defaultFirewallHandler(conn, pkt)
|
||||
}
|
||||
// log verdict
|
||||
log.Tracer(pkt.Ctx()).Infof("filter: connection %s %s: %s", conn, conn.Verdict.Verb(), conn.Reason)
|
||||
log.Tracer(pkt.Ctx()).Infof("filter: connection %s %s: %s", conn, conn.Verdict.Verb(), conn.Reason.Msg)
|
||||
|
||||
// save does not touch any changing data
|
||||
// must not be locked, will deadlock with cleaner functions
|
||||
|
||||
@@ -124,15 +124,15 @@ func (conn *Connection) GetExtraRRs(ctx context.Context, request *dns.Msg) []dns
|
||||
}
|
||||
|
||||
// Create resource record with verdict and reason.
|
||||
rr, err := nsutil.MakeMessageRecord(level, fmt.Sprintf("%s: %s", conn.Verdict.Verb(), conn.Reason))
|
||||
rr, err := nsutil.MakeMessageRecord(level, fmt.Sprintf("%s: %s", conn.Verdict.Verb(), conn.Reason.Msg))
|
||||
if err != nil {
|
||||
log.Tracer(ctx).Warningf("filter: failed to add informational record to reply: %s", err)
|
||||
return nil
|
||||
}
|
||||
extra := []dns.RR{rr}
|
||||
|
||||
// Add additional records from ReasonContext.
|
||||
if rrProvider, ok := conn.ReasonContext.(nsutil.RRProvider); ok {
|
||||
// Add additional records from Reason.Context.
|
||||
if rrProvider, ok := conn.Reason.Context.(nsutil.RRProvider); ok {
|
||||
rrs := rrProvider.GetExtraRRs(ctx, request)
|
||||
extra = append(extra, rrs...)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func registerConfiguration() error {
|
||||
// Enable Process Detection
|
||||
// This should be always enabled. Provided as an option to disable in case there are severe problems on a system, or for debugging.
|
||||
err := config.Register(&config.Option{
|
||||
Name: "Enable Process Detection",
|
||||
Name: "Process Detection",
|
||||
Key: CfgOptionEnableProcessDetectionKey,
|
||||
Description: "This option enables the attribution of network traffic to processes. This should be always enabled, and effectively disables app profiles if disabled.",
|
||||
OptType: config.OptTypeBool,
|
||||
|
||||
@@ -106,32 +106,34 @@ func CleanProcessStorage(activePIDs map[int]struct{}) {
|
||||
|
||||
// clean primary processes
|
||||
for _, p := range processesCopy {
|
||||
p.Lock()
|
||||
// The PID of a process does not change.
|
||||
|
||||
_, active := activePIDs[p.Pid]
|
||||
switch {
|
||||
case p.Pid == UnidentifiedProcessID:
|
||||
// internal
|
||||
case p.Pid == SystemProcessID:
|
||||
// internal
|
||||
case active:
|
||||
// process in system process table or recently seen on the network
|
||||
default:
|
||||
// delete now or soon
|
||||
switch {
|
||||
case p.LastSeen == 0:
|
||||
// add last
|
||||
p.LastSeen = time.Now().Unix()
|
||||
case p.LastSeen > threshold:
|
||||
// within keep period
|
||||
default:
|
||||
// delete now
|
||||
log.Tracef("process.clean: deleted %s", p.DatabaseKey())
|
||||
go p.Delete()
|
||||
}
|
||||
// Check if this is a special process.
|
||||
if p.Pid == UnidentifiedProcessID || p.Pid == SystemProcessID {
|
||||
p.profile.MarkStillActive()
|
||||
continue
|
||||
}
|
||||
|
||||
p.Unlock()
|
||||
// Check if process is active.
|
||||
_, active := activePIDs[p.Pid]
|
||||
if active {
|
||||
p.profile.MarkStillActive()
|
||||
continue
|
||||
}
|
||||
|
||||
// Process is inactive, start deletion process
|
||||
lastSeen := p.GetLastSeen()
|
||||
switch {
|
||||
case lastSeen == 0:
|
||||
// add last seen timestamp
|
||||
p.SetLastSeen(time.Now().Unix())
|
||||
case lastSeen > threshold:
|
||||
// within keep period
|
||||
default:
|
||||
// delete now
|
||||
p.Delete()
|
||||
log.Tracef("process: cleaned %s", p.DatabaseKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,14 @@ func GetProcessByConnection(ctx context.Context, pktInfo *packet.Info) (process
|
||||
return nil, connInbound, err
|
||||
}
|
||||
|
||||
err = process.GetProfile(ctx)
|
||||
changed, err := process.GetProfile(ctx)
|
||||
if err != nil {
|
||||
log.Tracer(ctx).Errorf("process: failed to get profile for process %s: %s", process, err)
|
||||
}
|
||||
|
||||
if changed {
|
||||
process.Save()
|
||||
}
|
||||
|
||||
return process, connInbound, nil
|
||||
}
|
||||
|
||||
@@ -30,40 +30,56 @@ type Process struct {
|
||||
record.Base
|
||||
sync.Mutex
|
||||
|
||||
// Constant attributes.
|
||||
|
||||
Name string
|
||||
UserID int
|
||||
UserName string
|
||||
UserHome string
|
||||
Pid int
|
||||
ParentPid int
|
||||
Path string
|
||||
ExecName string
|
||||
Cwd string
|
||||
CmdLine string
|
||||
FirstArg string
|
||||
|
||||
ExecName string
|
||||
ExecHashes map[string]string
|
||||
// ExecOwner ...
|
||||
// ExecSignature ...
|
||||
|
||||
LocalProfileKey string
|
||||
profile *profile.LayeredProfile
|
||||
Name string
|
||||
Icon string
|
||||
// Icon is a path to the icon and is either prefixed "f:" for filepath, "d:" for database cache path or "c:"/"a:" for a the icon key to fetch it from a company / authoritative node and cache it in its own cache.
|
||||
|
||||
// Mutable attributes.
|
||||
|
||||
FirstSeen int64
|
||||
LastSeen int64
|
||||
Virtual bool // This process is either merged into another process or is not needed.
|
||||
Error string // Cache errors
|
||||
|
||||
Virtual bool // This process is either merged into another process or is not needed.
|
||||
Error string // Cache errors
|
||||
ExecHashes map[string]string
|
||||
}
|
||||
|
||||
// Profile returns the assigned layered profile.
|
||||
func (p *Process) Profile() *profile.LayeredProfile {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.profile
|
||||
}
|
||||
|
||||
// GetLastSeen returns the unix timestamp when the process was last seen.
|
||||
func (p *Process) GetLastSeen() int64 {
|
||||
p.Lock()
|
||||
defer p.Unlock()
|
||||
|
||||
return p.profile
|
||||
return p.LastSeen
|
||||
}
|
||||
|
||||
// SetLastSeen sets the unix timestamp when the process was last seen.
|
||||
func (p *Process) SetLastSeen(lastSeen int64) {
|
||||
p.Lock()
|
||||
defer p.Unlock()
|
||||
|
||||
p.LastSeen = lastSeen
|
||||
}
|
||||
|
||||
// Strings returns a string representation of process.
|
||||
@@ -72,8 +88,6 @@ func (p *Process) String() string {
|
||||
return "?"
|
||||
}
|
||||
|
||||
p.Lock()
|
||||
defer p.Unlock()
|
||||
return fmt.Sprintf("%s:%s:%d", p.UserName, p.Path, p.Pid)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,35 +8,58 @@ import (
|
||||
)
|
||||
|
||||
// GetProfile finds and assigns a profile set to the process.
|
||||
func (p *Process) GetProfile(ctx context.Context) error {
|
||||
func (p *Process) GetProfile(ctx context.Context) (changed bool, err error) {
|
||||
p.Lock()
|
||||
defer p.Unlock()
|
||||
|
||||
// only find profiles if not already done.
|
||||
if p.profile != nil {
|
||||
log.Tracer(ctx).Trace("process: profile already loaded")
|
||||
// mark profile as used
|
||||
// Mark profile as used.
|
||||
p.profile.MarkUsed()
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
log.Tracer(ctx).Trace("process: loading profile")
|
||||
|
||||
// get profile
|
||||
localProfile, new, err := profile.FindOrCreateLocalProfileByPath(p.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
// Check if we need a special profile.
|
||||
profileID := ""
|
||||
switch p.Pid {
|
||||
case UnidentifiedProcessID:
|
||||
profileID = profile.UnidentifiedProfileID
|
||||
case SystemProcessID:
|
||||
profileID = profile.SystemProfileID
|
||||
}
|
||||
// add more information if new
|
||||
|
||||
// Get the (linked) local profile.
|
||||
localProfile, new, err := profile.GetProfile(profile.SourceLocal, profileID, p.Path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// If the local profile is new, add some information from the process.
|
||||
if new {
|
||||
localProfile.Name = p.ExecName
|
||||
|
||||
// Special profiles will only have a name, but not an ExecName.
|
||||
if localProfile.Name == "" {
|
||||
localProfile.Name = p.Name
|
||||
}
|
||||
}
|
||||
|
||||
// mark profile as used
|
||||
localProfile.MarkUsed()
|
||||
// Mark profile as used.
|
||||
profileChanged := localProfile.MarkUsed()
|
||||
|
||||
// Save the profile if we changed something.
|
||||
if new || profileChanged {
|
||||
err := localProfile.Save()
|
||||
if err != nil {
|
||||
log.Warningf("process: failed to save profile %s: %s", localProfile.ScopedID(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Assign profile to process.
|
||||
p.LocalProfileKey = localProfile.Key()
|
||||
p.profile = profile.NewLayeredProfile(localProfile)
|
||||
p.profile = localProfile.LayeredProfile()
|
||||
|
||||
go p.Save()
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/safing/portbase/log"
|
||||
"github.com/safing/portmaster/profile"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// Special Process IDs
|
||||
@@ -32,53 +33,41 @@ var (
|
||||
ParentPid: SystemProcessID,
|
||||
Name: "Operating System",
|
||||
}
|
||||
|
||||
getSpecialProcessSingleInflight singleflight.Group
|
||||
)
|
||||
|
||||
// GetUnidentifiedProcess returns the special process assigned to unidentified processes.
|
||||
func GetUnidentifiedProcess(ctx context.Context) *Process {
|
||||
return getSpecialProcess(ctx, UnidentifiedProcessID, unidentifiedProcess, profile.GetUnidentifiedProfile)
|
||||
return getSpecialProcess(ctx, UnidentifiedProcessID, unidentifiedProcess)
|
||||
}
|
||||
|
||||
// GetSystemProcess returns the special process used for the Kernel.
|
||||
func GetSystemProcess(ctx context.Context) *Process {
|
||||
return getSpecialProcess(ctx, SystemProcessID, systemProcess, profile.GetSystemProfile)
|
||||
return getSpecialProcess(ctx, SystemProcessID, systemProcess)
|
||||
}
|
||||
|
||||
func getSpecialProcess(ctx context.Context, pid int, template *Process, getProfile func() *profile.Profile) *Process {
|
||||
// check storage
|
||||
p, ok := GetProcessFromStorage(pid)
|
||||
if ok {
|
||||
return p
|
||||
}
|
||||
func getSpecialProcess(ctx context.Context, pid int, template *Process) *Process {
|
||||
p, _, _ := getSpecialProcessSingleInflight.Do(strconv.Itoa(pid), func() (interface{}, error) {
|
||||
// Check if we have already loaded the special process.
|
||||
process, ok := GetProcessFromStorage(pid)
|
||||
if ok {
|
||||
return process, nil
|
||||
}
|
||||
|
||||
// assign template
|
||||
p = template
|
||||
// Create new process from template
|
||||
process = template
|
||||
process.FirstSeen = time.Now().Unix()
|
||||
|
||||
p.Lock()
|
||||
defer p.Unlock()
|
||||
// Get profile.
|
||||
_, err := process.GetProfile(ctx)
|
||||
if err != nil {
|
||||
log.Tracer(ctx).Errorf("process: failed to get profile for process %s: %s", process, err)
|
||||
}
|
||||
|
||||
if p.FirstSeen == 0 {
|
||||
p.FirstSeen = time.Now().Unix()
|
||||
}
|
||||
|
||||
// only find profiles if not already done.
|
||||
if p.profile != nil {
|
||||
log.Tracer(ctx).Trace("process: special profile already loaded")
|
||||
// mark profile as used
|
||||
p.profile.MarkUsed()
|
||||
return p
|
||||
}
|
||||
log.Tracer(ctx).Trace("process: loading special profile")
|
||||
|
||||
// get profile
|
||||
localProfile := getProfile()
|
||||
|
||||
// mark profile as used
|
||||
localProfile.MarkUsed()
|
||||
|
||||
p.LocalProfileKey = localProfile.Key()
|
||||
p.profile = profile.NewLayeredProfile(localProfile)
|
||||
|
||||
go p.Save()
|
||||
return p
|
||||
// Save process to storage.
|
||||
process.Save()
|
||||
return process, nil
|
||||
})
|
||||
return p.(*Process)
|
||||
}
|
||||
|
||||
@@ -7,46 +7,61 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
activeProfileCleanerTickDuration = 10 * time.Minute
|
||||
activeProfileCleanerThreshold = 1 * time.Hour
|
||||
activeProfileCleanerTickDuration = 1 * time.Minute
|
||||
activeProfileCleanerThreshold = 5 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
// TODO: periodically clean up inactive profiles
|
||||
activeProfiles = make(map[string]*Profile)
|
||||
activeProfilesLock sync.RWMutex
|
||||
)
|
||||
|
||||
// getActiveProfile returns a cached copy of an active profile and nil if it isn't found.
|
||||
func getActiveProfile(scopedID string) *Profile {
|
||||
activeProfilesLock.Lock()
|
||||
defer activeProfilesLock.Unlock()
|
||||
activeProfilesLock.RLock()
|
||||
defer activeProfilesLock.RUnlock()
|
||||
|
||||
profile, ok := activeProfiles[scopedID]
|
||||
activeProfile, ok := activeProfiles[scopedID]
|
||||
if ok {
|
||||
return profile
|
||||
activeProfile.MarkStillActive()
|
||||
return activeProfile
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// markProfileActive registers a profile as active.
|
||||
func markProfileActive(profile *Profile) {
|
||||
// findActiveProfile searched for an active local profile using the linked path.
|
||||
func findActiveProfile(linkedPath string) *Profile {
|
||||
activeProfilesLock.RLock()
|
||||
defer activeProfilesLock.RUnlock()
|
||||
|
||||
for _, activeProfile := range activeProfiles {
|
||||
if activeProfile.LinkedPath == linkedPath {
|
||||
activeProfile.MarkStillActive()
|
||||
return activeProfile
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addActiveProfile registers a active profile.
|
||||
func addActiveProfile(profile *Profile) {
|
||||
activeProfilesLock.Lock()
|
||||
defer activeProfilesLock.Unlock()
|
||||
|
||||
profile.MarkStillActive()
|
||||
activeProfiles[profile.ScopedID()] = profile
|
||||
}
|
||||
|
||||
// markActiveProfileAsOutdated marks an active profile as outdated, so that it will be refetched from the database.
|
||||
// markActiveProfileAsOutdated marks an active profile as outdated.
|
||||
func markActiveProfileAsOutdated(scopedID string) {
|
||||
activeProfilesLock.Lock()
|
||||
defer activeProfilesLock.Unlock()
|
||||
activeProfilesLock.RLock()
|
||||
defer activeProfilesLock.RUnlock()
|
||||
|
||||
profile, ok := activeProfiles[scopedID]
|
||||
if ok {
|
||||
profile.outdated.Set()
|
||||
delete(activeProfiles, scopedID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,16 +70,12 @@ func cleanActiveProfiles(ctx context.Context) error {
|
||||
select {
|
||||
case <-time.After(activeProfileCleanerTickDuration):
|
||||
|
||||
threshold := time.Now().Add(-activeProfileCleanerThreshold)
|
||||
threshold := time.Now().Add(-activeProfileCleanerThreshold).Unix()
|
||||
|
||||
activeProfilesLock.Lock()
|
||||
for id, profile := range activeProfiles {
|
||||
// get last used
|
||||
profile.Lock()
|
||||
lastUsed := profile.lastUsed
|
||||
profile.Unlock()
|
||||
// remove if not used for a while
|
||||
if lastUsed.Before(threshold) {
|
||||
// Remove profile if it hasn't been used for a while.
|
||||
if profile.LastActive() < threshold {
|
||||
profile.outdated.Set()
|
||||
delete(activeProfiles, id)
|
||||
}
|
||||
|
||||
@@ -71,13 +71,9 @@ func updateGlobalConfigProfile(ctx context.Context, data interface{}) error {
|
||||
}
|
||||
|
||||
// build global profile for reference
|
||||
profile := &Profile{
|
||||
ID: "global-config",
|
||||
Source: SourceSpecial,
|
||||
Name: "Global Configuration",
|
||||
Config: make(map[string]interface{}),
|
||||
internalSave: true,
|
||||
}
|
||||
profile := New(SourceSpecial, "global-config")
|
||||
profile.Name = "Global Configuration"
|
||||
profile.Internal = true
|
||||
|
||||
newConfig := make(map[string]interface{})
|
||||
// fill profile config options
|
||||
|
||||
@@ -69,10 +69,6 @@ var (
|
||||
cfgOptionDisableAutoPermit config.IntOption // security level option
|
||||
cfgOptionDisableAutoPermitOrder = 80
|
||||
|
||||
CfgOptionEnforceSPNKey = "filter/enforceSPN"
|
||||
cfgOptionEnforceSPN config.IntOption // security level option
|
||||
cfgOptionEnforceSPNOrder = 96
|
||||
|
||||
CfgOptionRemoveOutOfScopeDNSKey = "filter/removeOutOfScopeDNS"
|
||||
cfgOptionRemoveOutOfScopeDNS config.IntOption // security level option
|
||||
cfgOptionRemoveOutOfScopeDNSOrder = 112
|
||||
@@ -86,6 +82,10 @@ var (
|
||||
cfgOptionDomainHeuristicsOrder = 114
|
||||
|
||||
// Permanent Verdicts Order = 128
|
||||
|
||||
CfgOptionUseSPNKey = "spn/useSPN"
|
||||
cfgOptionUseSPN config.BoolOption
|
||||
cfgOptionUseSPNOrder = 128
|
||||
)
|
||||
|
||||
func registerConfiguration() error {
|
||||
@@ -94,11 +94,11 @@ func registerConfiguration() error {
|
||||
// ask - ask mode: if not verdict is found, the user is consulted
|
||||
// block - allowlist mode: everything is blocked unless permitted
|
||||
err := config.Register(&config.Option{
|
||||
Name: "Default Filter Action",
|
||||
Key: CfgOptionDefaultActionKey,
|
||||
Description: `The default filter action when nothing else permits or blocks a connection.`,
|
||||
Name: "Default Action",
|
||||
Key: CfgOptionDefaultActionKey,
|
||||
// TODO: Discuss "when nothing else"
|
||||
Description: `The default action when nothing else permits or blocks an outgoing connection. Inbound connections are always blocked by default.`,
|
||||
OptType: config.OptTypeString,
|
||||
ReleaseLevel: config.ReleaseLevelExperimental,
|
||||
DefaultValue: "permit",
|
||||
Annotations: config.Annotations{
|
||||
config.DisplayHintAnnotation: config.DisplayHintOneOf,
|
||||
@@ -112,7 +112,7 @@ func registerConfiguration() error {
|
||||
Description: "Permit all connections",
|
||||
},
|
||||
{
|
||||
Name: "Ask",
|
||||
Name: "Prompt",
|
||||
Value: "ask",
|
||||
Description: "Always ask for a decision",
|
||||
},
|
||||
@@ -131,10 +131,12 @@ func registerConfiguration() error {
|
||||
|
||||
// Disable Auto Permit
|
||||
err = config.Register(&config.Option{
|
||||
// TODO: Discuss
|
||||
Name: "Disable Auto Permit",
|
||||
Key: CfgOptionDisableAutoPermitKey,
|
||||
Description: "Auto Permit searches for a relation between an app and the destionation of a connection - if there is a correlation, the connection will be permitted. This setting is negated in order to provide a streamlined user experience, where higher settings are better.",
|
||||
Description: `Auto Permit searches for a relation between an app and the destination of a connection - if there is a correlation, the connection will be permitted. This setting is negated in order to provide a streamlined user experience, where "higher settings" provide more protection.`,
|
||||
OptType: config.OptTypeInt,
|
||||
ReleaseLevel: config.ReleaseLevelBeta,
|
||||
DefaultValue: status.SecurityLevelsAll,
|
||||
Annotations: config.Annotations{
|
||||
config.DisplayOrderAnnotation: cfgOptionDisableAutoPermitOrder,
|
||||
@@ -200,7 +202,7 @@ Examples:
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Incoming Rules",
|
||||
Key: CfgOptionServiceEndpointsKey,
|
||||
Description: "Rules that apply to incoming network connections. Network Scope restrictions and the inbound permission still apply. Also not that the implicit default action of this list is to always block.",
|
||||
Description: "Rules that apply to incoming network connections. Network Scope restrictions and the incoming permission still apply. Also note that the default action for incoming connections is to always block.",
|
||||
Help: filterListHelp,
|
||||
OptType: config.OptTypeStringArray,
|
||||
DefaultValue: []string{"+ Localhost"},
|
||||
@@ -236,9 +238,9 @@ Examples:
|
||||
|
||||
// Filter list IDs
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Filter List",
|
||||
Name: "Filter Lists",
|
||||
Key: CfgOptionFilterListsKey,
|
||||
Description: "Filter connections by matching the endpoint against configured filterlists",
|
||||
Description: "Block connections that match enabled filter lists.",
|
||||
OptType: config.OptTypeStringArray,
|
||||
DefaultValue: []string{"TRAC", "MAL"},
|
||||
Annotations: config.Annotations{
|
||||
@@ -256,9 +258,9 @@ Examples:
|
||||
|
||||
// Include CNAMEs
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Filter CNAMEs",
|
||||
Name: "Check Domain Aliases",
|
||||
Key: CfgOptionFilterCNAMEKey,
|
||||
Description: "Also filter requests where a CNAME would be blocked",
|
||||
Description: "In addition to checking a domain against rules and filter lists, also check it's resolved CNAMEs.",
|
||||
OptType: config.OptTypeInt,
|
||||
DefaultValue: status.SecurityLevelsAll,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
@@ -277,9 +279,9 @@ Examples:
|
||||
|
||||
// Include subdomains
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Filter Subdomains",
|
||||
Name: "Check Subdomains",
|
||||
Key: CfgOptionFilterSubDomainsKey,
|
||||
Description: "Also filter a domain if any parent domain is blocked by a filter list",
|
||||
Description: "Also block a domain if any parent domain is blocked by a filter list",
|
||||
OptType: config.OptTypeInt,
|
||||
DefaultValue: status.SecurityLevelsAll,
|
||||
PossibleValues: status.SecurityLevelValues,
|
||||
@@ -297,9 +299,9 @@ Examples:
|
||||
|
||||
// Block Scope Local
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Block Scope Local",
|
||||
Name: "Block Device-Local Connections",
|
||||
Key: CfgOptionBlockScopeLocalKey,
|
||||
Description: "Block internal connections on your own device, ie. localhost.",
|
||||
Description: "Block all internal connections on your own device, ie. localhost.",
|
||||
OptType: config.OptTypeInt,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
DefaultValue: status.SecurityLevelOff,
|
||||
@@ -318,9 +320,9 @@ Examples:
|
||||
|
||||
// Block Scope LAN
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Block Scope LAN",
|
||||
Name: "Block LAN",
|
||||
Key: CfgOptionBlockScopeLANKey,
|
||||
Description: "Block connections to the Local Area Network.",
|
||||
Description: "Block all connections from and to the Local Area Network.",
|
||||
OptType: config.OptTypeInt,
|
||||
DefaultValue: status.SecurityLevelsHighAndExtreme,
|
||||
PossibleValues: status.AllSecurityLevelValues,
|
||||
@@ -338,9 +340,9 @@ Examples:
|
||||
|
||||
// Block Scope Internet
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Block Scope Internet",
|
||||
Name: "Block Internet",
|
||||
Key: CfgOptionBlockScopeInternetKey,
|
||||
Description: "Block connections to the Internet.",
|
||||
Description: "Block connections from and to the Internet.",
|
||||
OptType: config.OptTypeInt,
|
||||
DefaultValue: status.SecurityLevelOff,
|
||||
PossibleValues: status.AllSecurityLevelValues,
|
||||
@@ -358,9 +360,9 @@ Examples:
|
||||
|
||||
// Block Peer to Peer Connections
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Block Peer to Peer Connections",
|
||||
Name: "Block P2P/Direct Connections",
|
||||
Key: CfgOptionBlockP2PKey,
|
||||
Description: "These are connections that are established directly to an IP address on the Internet without resolving a domain name via DNS first.",
|
||||
Description: "These are connections that are established directly to an IP address or peer on the Internet without resolving a domain name via DNS first.",
|
||||
OptType: config.OptTypeInt,
|
||||
DefaultValue: status.SecurityLevelExtreme,
|
||||
PossibleValues: status.SecurityLevelValues,
|
||||
@@ -378,7 +380,7 @@ Examples:
|
||||
|
||||
// Block Inbound Connections
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Block Inbound Connections",
|
||||
Name: "Block Incoming Connections",
|
||||
Key: CfgOptionBlockInboundKey,
|
||||
Description: "Connections initiated towards your device from the LAN or Internet. This will usually only be the case if you are running a network service or are using peer to peer software.",
|
||||
OptType: config.OptTypeInt,
|
||||
@@ -396,35 +398,13 @@ Examples:
|
||||
cfgOptionBlockInbound = config.Concurrent.GetAsInt(CfgOptionBlockInboundKey, int64(status.SecurityLevelsHighAndExtreme))
|
||||
cfgIntOptions[CfgOptionBlockInboundKey] = cfgOptionBlockInbound
|
||||
|
||||
// Enforce SPN
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Enforce SPN",
|
||||
Key: CfgOptionEnforceSPNKey,
|
||||
Description: "This setting enforces connections to be routed over the SPN. If this is not possible for any reason, connections will be blocked.",
|
||||
OptType: config.OptTypeInt,
|
||||
ReleaseLevel: config.ReleaseLevelExperimental,
|
||||
DefaultValue: status.SecurityLevelOff,
|
||||
PossibleValues: status.AllSecurityLevelValues,
|
||||
Annotations: config.Annotations{
|
||||
config.DisplayHintAnnotation: status.DisplayHintSecurityLevel,
|
||||
config.DisplayOrderAnnotation: cfgOptionEnforceSPNOrder,
|
||||
config.CategoryAnnotation: "Advanced",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfgOptionEnforceSPN = config.Concurrent.GetAsInt(CfgOptionEnforceSPNKey, int64(status.SecurityLevelOff))
|
||||
cfgIntOptions[CfgOptionEnforceSPNKey] = cfgOptionEnforceSPN
|
||||
|
||||
// Filter Out-of-Scope DNS Records
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Filter Out-of-Scope DNS Records",
|
||||
Name: "Enforce global/private split-view",
|
||||
Key: CfgOptionRemoveOutOfScopeDNSKey,
|
||||
Description: "Filter DNS answers that are outside of the scope of the server. A server on the public Internet may not respond with a private LAN address.",
|
||||
Description: "Remove private IP addresses from public DNS responses.",
|
||||
OptType: config.OptTypeInt,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
ReleaseLevel: config.ReleaseLevelBeta,
|
||||
DefaultValue: status.SecurityLevelsAll,
|
||||
PossibleValues: status.SecurityLevelValues,
|
||||
Annotations: config.Annotations{
|
||||
@@ -441,12 +421,11 @@ Examples:
|
||||
|
||||
// Filter DNS Records that would be blocked
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Filter DNS Records that would be blocked",
|
||||
Name: "Remove blocked records",
|
||||
Key: CfgOptionRemoveBlockedDNSKey,
|
||||
Description: "Pre-filter DNS answers that an application would not be allowed to connect to.",
|
||||
Description: "Remove blocked IP addresses from DNS responses.",
|
||||
OptType: config.OptTypeInt,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
ReleaseLevel: config.ReleaseLevelBeta,
|
||||
DefaultValue: status.SecurityLevelsAll,
|
||||
PossibleValues: status.SecurityLevelValues,
|
||||
Annotations: config.Annotations{
|
||||
@@ -463,9 +442,9 @@ Examples:
|
||||
|
||||
// Domain heuristics
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Enable Domain Heuristics",
|
||||
Name: "Domain Heuristics",
|
||||
Key: CfgOptionDomainHeuristicsKey,
|
||||
Description: "Domain Heuristics checks for suspicious looking domain names and blocks them. Ths option currently targets domains generated by malware and DNS data tunnels.",
|
||||
Description: "Domain Heuristics checks for suspicious domain names and blocks them. This option currently targets domain names generated by malware and DNS data exfiltration channels.",
|
||||
OptType: config.OptTypeInt,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
DefaultValue: status.SecurityLevelsAll,
|
||||
@@ -483,9 +462,10 @@ Examples:
|
||||
|
||||
// Bypass prevention
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Prevent Bypassing",
|
||||
Key: CfgOptionPreventBypassingKey,
|
||||
Description: "Prevent apps from bypassing the privacy filter: Firefox by disabling DNS-over-HTTPs",
|
||||
Name: "Prevent Bypassing",
|
||||
Key: CfgOptionPreventBypassingKey,
|
||||
Description: `Prevent apps from bypassing the privacy filter:
|
||||
- Disable Firefox' internal DNS-over-HTTPs resolver`,
|
||||
OptType: config.OptTypeInt,
|
||||
ExpertiseLevel: config.ExpertiseLevelUser,
|
||||
ReleaseLevel: config.ReleaseLevelBeta,
|
||||
@@ -503,5 +483,24 @@ Examples:
|
||||
cfgOptionPreventBypassing = config.Concurrent.GetAsInt((CfgOptionPreventBypassingKey), int64(status.SecurityLevelsAll))
|
||||
cfgIntOptions[CfgOptionPreventBypassingKey] = cfgOptionPreventBypassing
|
||||
|
||||
// Use SPN
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Use SPN",
|
||||
Key: CfgOptionUseSPNKey,
|
||||
Description: "Route connection through the Safing Privacy Network. If it is unavailable for any reason, connections will be blocked.",
|
||||
OptType: config.OptTypeBool,
|
||||
ReleaseLevel: config.ReleaseLevelExperimental,
|
||||
DefaultValue: true,
|
||||
Annotations: config.Annotations{
|
||||
config.DisplayOrderAnnotation: cfgOptionUseSPNOrder,
|
||||
config.CategoryAnnotation: "General",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfgOptionUseSPN = config.Concurrent.GetAsBool(CfgOptionUseSPNKey, true)
|
||||
cfgBoolOptions[CfgOptionUseSPNKey] = cfgOptionUseSPN
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"github.com/safing/portbase/database/query"
|
||||
"github.com/safing/portbase/log"
|
||||
)
|
||||
|
||||
// FindOrCreateLocalProfileByPath returns an existing or new profile for the given application path.
|
||||
func FindOrCreateLocalProfileByPath(fullPath string) (profile *Profile, new bool, err error) {
|
||||
// find local profile
|
||||
it, err := profileDB.Query(
|
||||
query.New(makeProfileKey(SourceLocal, "")).Where(
|
||||
query.Where("LinkedPath", query.SameAs, fullPath),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// get first result
|
||||
r := <-it.Next
|
||||
// cancel immediately
|
||||
it.Cancel()
|
||||
|
||||
// return new if none was found
|
||||
if r == nil {
|
||||
profile = New()
|
||||
profile.LinkedPath = fullPath
|
||||
return profile, true, nil
|
||||
}
|
||||
|
||||
// ensure its a profile
|
||||
profile, err = EnsureProfile(r)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// prepare config
|
||||
err = profile.prepConfig()
|
||||
if err != nil {
|
||||
log.Warningf("profiles: profile %s has (partly) invalid configuration: %s", profile.ID, err)
|
||||
}
|
||||
|
||||
// parse config
|
||||
err = profile.parseConfig()
|
||||
if err != nil {
|
||||
log.Warningf("profiles: profile %s has (partly) invalid configuration: %s", profile.ID, err)
|
||||
}
|
||||
|
||||
// mark active
|
||||
markProfileActive(profile)
|
||||
|
||||
// return parsed profile
|
||||
return profile, false, nil
|
||||
}
|
||||
206
profile/get.go
Normal file
206
profile/get.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/safing/portbase/database"
|
||||
|
||||
"github.com/safing/portbase/dataroot"
|
||||
|
||||
"github.com/safing/portbase/database/query"
|
||||
"github.com/safing/portbase/database/record"
|
||||
"github.com/safing/portbase/log"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
// UnidentifiedProfileID is the profile ID used for unidentified processes.
|
||||
UnidentifiedProfileID = "_unidentified"
|
||||
|
||||
// SystemProfileID is the profile ID used for the system/kernel.
|
||||
SystemProfileID = "_system"
|
||||
)
|
||||
|
||||
var getProfileSingleInflight singleflight.Group
|
||||
|
||||
// GetProfile fetches a profile. This function ensure that the profile loaded
|
||||
// is shared among all callers. You must always supply both the scopedID and
|
||||
// linkedPath parameters whenever available.
|
||||
func GetProfile(source profileSource, id, linkedPath string) ( //nolint:gocognit
|
||||
profile *Profile,
|
||||
newProfile bool,
|
||||
err error,
|
||||
) {
|
||||
// Select correct key for single in flight.
|
||||
singleInflightKey := linkedPath
|
||||
if singleInflightKey == "" {
|
||||
singleInflightKey = makeScopedID(source, id)
|
||||
}
|
||||
|
||||
p, err, _ := getProfileSingleInflight.Do(singleInflightKey, func() (interface{}, error) {
|
||||
var previousVersion *Profile
|
||||
|
||||
// Fetch profile depending on the available information.
|
||||
switch {
|
||||
case id != "":
|
||||
scopedID := makeScopedID(source, id)
|
||||
|
||||
// Get profile via the scoped ID.
|
||||
// Check if there already is an active and not outdated profile.
|
||||
profile = getActiveProfile(scopedID)
|
||||
if profile != nil {
|
||||
if profile.outdated.IsSet() {
|
||||
previousVersion = profile
|
||||
} else {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
// Get from database.
|
||||
profile, err = getProfile(scopedID)
|
||||
|
||||
// If we cannot find a profile, check if the request is for a special
|
||||
// profile we can create.
|
||||
if errors.Is(err, database.ErrNotFound) {
|
||||
switch id {
|
||||
case UnidentifiedProfileID:
|
||||
profile = New(SourceLocal, UnidentifiedProfileID)
|
||||
newProfile = true
|
||||
err = nil
|
||||
case SystemProfileID:
|
||||
profile = New(SourceLocal, SystemProfileID)
|
||||
newProfile = true
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
|
||||
case linkedPath != "":
|
||||
// Search for profile via a linked path.
|
||||
// Check if there already is an active and not outdated profile for
|
||||
// the linked path.
|
||||
profile = findActiveProfile(linkedPath)
|
||||
if profile != nil {
|
||||
if profile.outdated.IsSet() {
|
||||
previousVersion = profile
|
||||
} else {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
// Get from database.
|
||||
profile, newProfile, err = findProfile(linkedPath)
|
||||
|
||||
default:
|
||||
return nil, errors.New("cannot fetch profile without ID or path")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Process profiles coming directly from the database.
|
||||
// As we don't use any caching, these will be new objects.
|
||||
|
||||
// Mark the profile as being saved internally in order to not trigger an
|
||||
// update after saving it to the database.
|
||||
profile.internalSave = true
|
||||
|
||||
// Add a layeredProfile to local profiles.
|
||||
if profile.Source == SourceLocal {
|
||||
// If we are refetching, assign the layered profile from the previous version.
|
||||
if previousVersion != nil {
|
||||
profile.layeredProfile = previousVersion.layeredProfile
|
||||
}
|
||||
|
||||
// Local profiles must have a layered profile, create a new one if it
|
||||
// does not yet exist.
|
||||
if profile.layeredProfile == nil {
|
||||
profile.layeredProfile = NewLayeredProfile(profile)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the profile to the currently active profiles.
|
||||
addActiveProfile(profile)
|
||||
|
||||
return profile, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if p == nil {
|
||||
return nil, false, errors.New("profile getter returned nil")
|
||||
}
|
||||
|
||||
return p.(*Profile), newProfile, nil
|
||||
}
|
||||
|
||||
// getProfile fetches the profile for the given scoped ID.
|
||||
func getProfile(scopedID string) (profile *Profile, err error) {
|
||||
// Get profile from the database.
|
||||
r, err := profileDB.Get(profilesDBPath + scopedID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse and prepare the profile, return the result.
|
||||
return prepProfile(r)
|
||||
}
|
||||
|
||||
// findProfile searches for a profile with the given linked path. If it cannot
|
||||
// find one, it will create a new profile for the given linked path.
|
||||
func findProfile(linkedPath string) (profile *Profile, new bool, err error) {
|
||||
// Search the database for a matching profile.
|
||||
it, err := profileDB.Query(
|
||||
query.New(makeProfileKey(SourceLocal, "")).Where(
|
||||
query.Where("LinkedPath", query.SameAs, linkedPath),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// Only wait for the first result, or until the query ends.
|
||||
r := <-it.Next
|
||||
// Then cancel the query, should it still be running.
|
||||
it.Cancel()
|
||||
|
||||
// Prep and return an existing profile.
|
||||
if r != nil {
|
||||
profile, err = prepProfile(r)
|
||||
return profile, false, err
|
||||
}
|
||||
|
||||
// If there was no profile in the database, create a new one, and return it.
|
||||
profile = New(SourceLocal, "")
|
||||
profile.LinkedPath = linkedPath
|
||||
|
||||
// Check if the profile should be marked as internal.
|
||||
// This is the case whenever the binary resides within the data root dir.
|
||||
if strings.HasPrefix(linkedPath, dataroot.Root().Dir+string(os.PathSeparator)) {
|
||||
profile.Internal = true
|
||||
}
|
||||
|
||||
return profile, true, nil
|
||||
}
|
||||
|
||||
func prepProfile(r record.Record) (*Profile, error) {
|
||||
// ensure its a profile
|
||||
profile, err := EnsureProfile(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// prepare config
|
||||
err = profile.prepConfig()
|
||||
if err != nil {
|
||||
log.Warningf("profiles: profile %s has (partly) invalid configuration: %s", profile.ID, err)
|
||||
}
|
||||
|
||||
// parse config
|
||||
err = profile.parseConfig()
|
||||
if err != nil {
|
||||
log.Warningf("profiles: profile %s has (partly) invalid configuration: %s", profile.ID, err)
|
||||
}
|
||||
|
||||
// return parsed profile
|
||||
return profile, nil
|
||||
}
|
||||
@@ -38,6 +38,11 @@ func start() error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = registerRevisionProvider()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = startProfileUpdateChecker()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
49
profile/profile-layered-provider.go
Normal file
49
profile/profile-layered-provider.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/safing/portbase/database/record"
|
||||
"github.com/safing/portbase/runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
revisionProviderPrefix = "runtime:layeredProfile/"
|
||||
)
|
||||
|
||||
var (
|
||||
errProfileNotActive = errors.New("profile not active")
|
||||
errNoLayeredProfile = errors.New("profile has no layered profile")
|
||||
)
|
||||
|
||||
func registerRevisionProvider() error {
|
||||
_, err := runtime.DefaultRegistry.Register(
|
||||
revisionProviderPrefix,
|
||||
runtime.SimpleValueGetterFunc(getRevision),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func getRevision(key string) ([]record.Record, error) {
|
||||
key = strings.TrimPrefix(key, revisionProviderPrefix)
|
||||
|
||||
// Get active profile.
|
||||
profile := getActiveProfile(key)
|
||||
if profile == nil {
|
||||
return nil, errProfileNotActive
|
||||
}
|
||||
|
||||
// Get layered profile.
|
||||
layeredProfile := profile.LayeredProfile()
|
||||
if layeredProfile == nil {
|
||||
return nil, errNoLayeredProfile
|
||||
}
|
||||
|
||||
// Update profiles if necessary.
|
||||
if layeredProfile.NeedsUpdate() {
|
||||
layeredProfile.Update()
|
||||
}
|
||||
|
||||
return []record.Record{layeredProfile}, nil
|
||||
}
|
||||
@@ -5,48 +5,45 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/safing/portbase/database/record"
|
||||
"github.com/safing/portbase/log"
|
||||
|
||||
"github.com/safing/portmaster/status"
|
||||
|
||||
"github.com/tevino/abool"
|
||||
|
||||
"github.com/safing/portbase/config"
|
||||
"github.com/safing/portmaster/intel"
|
||||
"github.com/safing/portmaster/profile/endpoints"
|
||||
)
|
||||
|
||||
var (
|
||||
no = abool.NewBool(false)
|
||||
)
|
||||
|
||||
// LayeredProfile combines multiple Profiles.
|
||||
type LayeredProfile struct {
|
||||
lock sync.Mutex
|
||||
record.Base
|
||||
sync.RWMutex
|
||||
|
||||
localProfile *Profile
|
||||
layers []*Profile
|
||||
revisionCounter uint64
|
||||
localProfile *Profile
|
||||
layers []*Profile
|
||||
|
||||
validityFlag *abool.AtomicBool
|
||||
validityFlagLock sync.Mutex
|
||||
LayerIDs []string
|
||||
RevisionCounter uint64
|
||||
globalValidityFlag *config.ValidityFlag
|
||||
|
||||
securityLevel *uint32
|
||||
|
||||
// These functions give layered access to configuration options and require
|
||||
// the layered profile to be read locked.
|
||||
DisableAutoPermit config.BoolOption
|
||||
BlockScopeLocal config.BoolOption
|
||||
BlockScopeLAN config.BoolOption
|
||||
BlockScopeInternet config.BoolOption
|
||||
BlockP2P config.BoolOption
|
||||
BlockInbound config.BoolOption
|
||||
EnforceSPN config.BoolOption
|
||||
RemoveOutOfScopeDNS config.BoolOption
|
||||
RemoveBlockedDNS config.BoolOption
|
||||
FilterSubDomains config.BoolOption
|
||||
FilterCNAMEs config.BoolOption
|
||||
PreventBypassing config.BoolOption
|
||||
DomainHeuristics config.BoolOption
|
||||
UseSPN config.BoolOption
|
||||
}
|
||||
|
||||
// NewLayeredProfile returns a new layered profile based on the given local profile.
|
||||
@@ -56,8 +53,7 @@ func NewLayeredProfile(localProfile *Profile) *LayeredProfile {
|
||||
new := &LayeredProfile{
|
||||
localProfile: localProfile,
|
||||
layers: make([]*Profile, 0, len(localProfile.LinkedProfiles)+1),
|
||||
revisionCounter: 0,
|
||||
validityFlag: abool.NewBool(true),
|
||||
LayerIDs: make([]string, 0, len(localProfile.LinkedProfiles)+1),
|
||||
globalValidityFlag: config.NewValidityFlag(),
|
||||
securityLevel: &securityLevelVal,
|
||||
}
|
||||
@@ -86,10 +82,6 @@ func NewLayeredProfile(localProfile *Profile) *LayeredProfile {
|
||||
CfgOptionBlockInboundKey,
|
||||
cfgOptionBlockInbound,
|
||||
)
|
||||
new.EnforceSPN = new.wrapSecurityLevelOption(
|
||||
CfgOptionEnforceSPNKey,
|
||||
cfgOptionEnforceSPN,
|
||||
)
|
||||
new.RemoveOutOfScopeDNS = new.wrapSecurityLevelOption(
|
||||
CfgOptionRemoveOutOfScopeDNSKey,
|
||||
cfgOptionRemoveOutOfScopeDNS,
|
||||
@@ -114,22 +106,44 @@ func NewLayeredProfile(localProfile *Profile) *LayeredProfile {
|
||||
CfgOptionDomainHeuristicsKey,
|
||||
cfgOptionDomainHeuristics,
|
||||
)
|
||||
new.UseSPN = new.wrapBoolOption(
|
||||
CfgOptionUseSPNKey,
|
||||
cfgOptionUseSPN,
|
||||
)
|
||||
|
||||
// TODO: load linked profiles.
|
||||
|
||||
// FUTURE: load forced company profile
|
||||
new.LayerIDs = append(new.LayerIDs, localProfile.ScopedID())
|
||||
new.layers = append(new.layers, localProfile)
|
||||
// FUTURE: load company profile
|
||||
// FUTURE: load community profile
|
||||
|
||||
// TODO: Load additional profiles.
|
||||
|
||||
new.updateCaches()
|
||||
|
||||
new.SetKey(revisionProviderPrefix + localProfile.ID)
|
||||
return new
|
||||
}
|
||||
|
||||
func (lp *LayeredProfile) getValidityFlag() *abool.AtomicBool {
|
||||
lp.validityFlagLock.Lock()
|
||||
defer lp.validityFlagLock.Unlock()
|
||||
return lp.validityFlag
|
||||
// LockForUsage locks the layered profile, including all layers individually.
|
||||
func (lp *LayeredProfile) LockForUsage() {
|
||||
lp.RLock()
|
||||
for _, layer := range lp.layers {
|
||||
layer.RLock()
|
||||
}
|
||||
}
|
||||
|
||||
// UnlockForUsage unlocks the layered profile, including all layers individually.
|
||||
func (lp *LayeredProfile) UnlockForUsage() {
|
||||
lp.RUnlock()
|
||||
for _, layer := range lp.layers {
|
||||
layer.RUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
// LocalProfile returns the local profile associated with this layered profile.
|
||||
func (lp *LayeredProfile) LocalProfile() *Profile {
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
|
||||
return lp.localProfile
|
||||
}
|
||||
|
||||
// RevisionCnt returns the current profile revision counter.
|
||||
@@ -138,23 +152,57 @@ func (lp *LayeredProfile) RevisionCnt() (revisionCounter uint64) {
|
||||
return 0
|
||||
}
|
||||
|
||||
lp.lock.Lock()
|
||||
defer lp.lock.Unlock()
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
|
||||
return lp.revisionCounter
|
||||
return lp.RevisionCounter
|
||||
}
|
||||
|
||||
// Update checks for updated profiles and replaces any outdated profiles.
|
||||
// MarkStillActive marks all the layers as still active.
|
||||
func (lp *LayeredProfile) MarkStillActive() {
|
||||
if lp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
|
||||
for _, layer := range lp.layers {
|
||||
layer.MarkStillActive()
|
||||
}
|
||||
}
|
||||
|
||||
// NeedsUpdate checks for outdated profiles.
|
||||
func (lp *LayeredProfile) NeedsUpdate() (outdated bool) {
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
|
||||
// Check global config state.
|
||||
if !lp.globalValidityFlag.IsValid() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check config in layers.
|
||||
for _, layer := range lp.layers {
|
||||
if layer.outdated.IsSet() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Update checks for and replaces any outdated profiles.
|
||||
func (lp *LayeredProfile) Update() (revisionCounter uint64) {
|
||||
lp.lock.Lock()
|
||||
defer lp.lock.Unlock()
|
||||
lp.Lock()
|
||||
defer lp.Unlock()
|
||||
|
||||
var changed bool
|
||||
for i, layer := range lp.layers {
|
||||
if layer.outdated.IsSet() {
|
||||
changed = true
|
||||
// update layer
|
||||
newLayer, err := GetProfile(layer.Source, layer.ID)
|
||||
newLayer, _, err := GetProfile(layer.Source, layer.ID, layer.LinkedPath)
|
||||
if err != nil {
|
||||
log.Errorf("profiles: failed to update profile %s", layer.ScopedID())
|
||||
} else {
|
||||
@@ -167,11 +215,6 @@ func (lp *LayeredProfile) Update() (revisionCounter uint64) {
|
||||
}
|
||||
|
||||
if changed {
|
||||
// reset validity flag
|
||||
lp.validityFlagLock.Lock()
|
||||
lp.validityFlag.SetTo(false)
|
||||
lp.validityFlag = abool.NewBool(true)
|
||||
lp.validityFlagLock.Unlock()
|
||||
// get global config validity flag
|
||||
lp.globalValidityFlag.Refresh()
|
||||
|
||||
@@ -179,10 +222,10 @@ func (lp *LayeredProfile) Update() (revisionCounter uint64) {
|
||||
lp.updateCaches()
|
||||
|
||||
// bump revision counter
|
||||
lp.revisionCounter++
|
||||
lp.RevisionCounter++
|
||||
}
|
||||
|
||||
return lp.revisionCounter
|
||||
return lp.RevisionCounter
|
||||
}
|
||||
|
||||
func (lp *LayeredProfile) updateCaches() {
|
||||
@@ -194,8 +237,6 @@ func (lp *LayeredProfile) updateCaches() {
|
||||
}
|
||||
}
|
||||
atomic.StoreUint32(lp.securityLevel, uint32(newLevel))
|
||||
|
||||
// TODO: ignore community profiles
|
||||
}
|
||||
|
||||
// MarkUsed marks the localProfile as used.
|
||||
@@ -203,12 +244,12 @@ func (lp *LayeredProfile) MarkUsed() {
|
||||
lp.localProfile.MarkUsed()
|
||||
}
|
||||
|
||||
// SecurityLevel returns the highest security level of all layered profiles.
|
||||
// SecurityLevel returns the highest security level of all layered profiles. This function is atomic and does not require any locking.
|
||||
func (lp *LayeredProfile) SecurityLevel() uint8 {
|
||||
return uint8(atomic.LoadUint32(lp.securityLevel))
|
||||
}
|
||||
|
||||
// DefaultAction returns the active default action ID.
|
||||
// DefaultAction returns the active default action ID. This functions requires the layered profile to be read locked.
|
||||
func (lp *LayeredProfile) DefaultAction() uint8 {
|
||||
for _, layer := range lp.layers {
|
||||
if layer.defaultAction > 0 {
|
||||
@@ -221,7 +262,7 @@ func (lp *LayeredProfile) DefaultAction() uint8 {
|
||||
return cfgDefaultAction
|
||||
}
|
||||
|
||||
// MatchEndpoint checks if the given endpoint matches an entry in any of the profiles.
|
||||
// MatchEndpoint checks if the given endpoint matches an entry in any of the profiles. This functions requires the layered profile to be read locked.
|
||||
func (lp *LayeredProfile) MatchEndpoint(ctx context.Context, entity *intel.Entity) (endpoints.EPResult, endpoints.Reason) {
|
||||
for _, layer := range lp.layers {
|
||||
if layer.endpoints.IsSet() {
|
||||
@@ -237,7 +278,7 @@ func (lp *LayeredProfile) MatchEndpoint(ctx context.Context, entity *intel.Entit
|
||||
return cfgEndpoints.Match(ctx, entity)
|
||||
}
|
||||
|
||||
// MatchServiceEndpoint checks if the given endpoint of an inbound connection matches an entry in any of the profiles.
|
||||
// MatchServiceEndpoint checks if the given endpoint of an inbound connection matches an entry in any of the profiles. This functions requires the layered profile to be read locked.
|
||||
func (lp *LayeredProfile) MatchServiceEndpoint(ctx context.Context, entity *intel.Entity) (endpoints.EPResult, endpoints.Reason) {
|
||||
entity.EnableReverseResolving()
|
||||
|
||||
@@ -256,7 +297,7 @@ func (lp *LayeredProfile) MatchServiceEndpoint(ctx context.Context, entity *inte
|
||||
}
|
||||
|
||||
// MatchFilterLists matches the entity against the set of filter
|
||||
// lists.
|
||||
// lists. This functions requires the layered profile to be read locked.
|
||||
func (lp *LayeredProfile) MatchFilterLists(ctx context.Context, entity *intel.Entity) (endpoints.EPResult, endpoints.Reason) {
|
||||
entity.ResolveSubDomainLists(ctx, lp.FilterSubDomains())
|
||||
entity.EnableCNAMECheck(ctx, lp.FilterCNAMEs())
|
||||
@@ -287,16 +328,6 @@ func (lp *LayeredProfile) MatchFilterLists(ctx context.Context, entity *intel.En
|
||||
return endpoints.NoMatch, nil
|
||||
}
|
||||
|
||||
// AddEndpoint adds an endpoint to the local endpoint list, saves the local profile and reloads the configuration.
|
||||
func (lp *LayeredProfile) AddEndpoint(newEntry string) {
|
||||
lp.localProfile.AddEndpoint(newEntry)
|
||||
}
|
||||
|
||||
// AddServiceEndpoint adds a service endpoint to the local endpoint list, saves the local profile and reloads the configuration.
|
||||
func (lp *LayeredProfile) AddServiceEndpoint(newEntry string) {
|
||||
lp.localProfile.AddServiceEndpoint(newEntry)
|
||||
}
|
||||
|
||||
func (lp *LayeredProfile) wrapSecurityLevelOption(configKey string, globalConfig config.IntOption) config.BoolOption {
|
||||
activeAtLevels := lp.wrapIntOption(configKey, globalConfig)
|
||||
|
||||
@@ -308,22 +339,27 @@ func (lp *LayeredProfile) wrapSecurityLevelOption(configKey string, globalConfig
|
||||
}
|
||||
}
|
||||
|
||||
func (lp *LayeredProfile) wrapIntOption(configKey string, globalConfig config.IntOption) config.IntOption {
|
||||
valid := no
|
||||
var value int64
|
||||
func (lp *LayeredProfile) wrapBoolOption(configKey string, globalConfig config.BoolOption) config.BoolOption {
|
||||
revCnt := lp.RevisionCounter
|
||||
var value bool
|
||||
var refreshLock sync.Mutex
|
||||
|
||||
return func() int64 {
|
||||
if !valid.IsSet() {
|
||||
valid = lp.getValidityFlag()
|
||||
return func() bool {
|
||||
refreshLock.Lock()
|
||||
defer refreshLock.Unlock()
|
||||
|
||||
// Check if we need to refresh the value.
|
||||
if revCnt != lp.RevisionCounter {
|
||||
revCnt = lp.RevisionCounter
|
||||
|
||||
// Go through all layers to find an active value.
|
||||
found := false
|
||||
layerLoop:
|
||||
for _, layer := range lp.layers {
|
||||
layerValue, ok := layer.configPerspective.GetAsInt(configKey)
|
||||
layerValue, ok := layer.configPerspective.GetAsBool(configKey)
|
||||
if ok {
|
||||
found = true
|
||||
value = layerValue
|
||||
break layerLoop
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
@@ -335,25 +371,76 @@ func (lp *LayeredProfile) wrapIntOption(configKey string, globalConfig config.In
|
||||
}
|
||||
}
|
||||
|
||||
func (lp *LayeredProfile) wrapIntOption(configKey string, globalConfig config.IntOption) config.IntOption {
|
||||
revCnt := lp.RevisionCounter
|
||||
var value int64
|
||||
var refreshLock sync.Mutex
|
||||
|
||||
return func() int64 {
|
||||
refreshLock.Lock()
|
||||
defer refreshLock.Unlock()
|
||||
|
||||
// Check if we need to refresh the value.
|
||||
if revCnt != lp.RevisionCounter {
|
||||
revCnt = lp.RevisionCounter
|
||||
|
||||
// Go through all layers to find an active value.
|
||||
found := false
|
||||
for _, layer := range lp.layers {
|
||||
layerValue, ok := layer.configPerspective.GetAsInt(configKey)
|
||||
if ok {
|
||||
found = true
|
||||
value = layerValue
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
value = globalConfig()
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// GetProfileSource returns the database key of the first profile in the
|
||||
// layers that has the given configuration key set. If it returns an empty
|
||||
// string, the global profile can be assumed to have been effective.
|
||||
func (lp *LayeredProfile) GetProfileSource(configKey string) string {
|
||||
for _, layer := range lp.layers {
|
||||
if layer.configPerspective.Has(configKey) {
|
||||
return layer.Key()
|
||||
}
|
||||
}
|
||||
|
||||
// Global Profile
|
||||
return ""
|
||||
}
|
||||
|
||||
/*
|
||||
For later:
|
||||
|
||||
func (lp *LayeredProfile) wrapStringOption(configKey string, globalConfig config.StringOption) config.StringOption {
|
||||
valid := no
|
||||
revCnt := lp.RevisionCounter
|
||||
var value string
|
||||
var refreshLock sync.Mutex
|
||||
|
||||
return func() string {
|
||||
if !valid.IsSet() {
|
||||
valid = lp.getValidityFlag()
|
||||
refreshLock.Lock()
|
||||
defer refreshLock.Unlock()
|
||||
|
||||
// Check if we need to refresh the value.
|
||||
if revCnt != lp.RevisionCounter {
|
||||
revCnt = lp.RevisionCounter
|
||||
|
||||
// Go through all layers to find an active value.
|
||||
found := false
|
||||
layerLoop:
|
||||
for _, layer := range lp.layers {
|
||||
layerValue, ok := layer.configPerspective.GetAsString(configKey)
|
||||
if ok {
|
||||
found = true
|
||||
value = layerValue
|
||||
break layerLoop
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/tevino/abool"
|
||||
@@ -53,7 +54,8 @@ const (
|
||||
// Profile is used to predefine a security profile for applications.
|
||||
type Profile struct { //nolint:maligned // not worth the effort
|
||||
record.Base
|
||||
sync.Mutex
|
||||
sync.RWMutex
|
||||
|
||||
// ID is a unique identifier for the profile.
|
||||
ID string
|
||||
// Source describes the source of the profile.
|
||||
@@ -73,7 +75,6 @@ type Profile struct { //nolint:maligned // not worth the effort
|
||||
Icon string
|
||||
// IconType describes the type of the Icon property.
|
||||
IconType iconType
|
||||
// References - local profiles only
|
||||
// LinkedPath is a filesystem path to the executable this
|
||||
// profile was created for.
|
||||
LinkedPath string
|
||||
@@ -88,7 +89,7 @@ type Profile struct { //nolint:maligned // not worth the effort
|
||||
// Config holds profile specific setttings. It's a nested
|
||||
// object with keys defining the settings database path. All keys
|
||||
// until the actual settings value (which is everything that is not
|
||||
// an object) need to be concatinated for the settings database
|
||||
// an object) need to be concatenated for the settings database
|
||||
// path.
|
||||
Config map[string]interface{}
|
||||
// ApproxLastUsed holds a UTC timestamp in seconds of
|
||||
@@ -99,6 +100,17 @@ type Profile struct { //nolint:maligned // not worth the effort
|
||||
// profile has been created.
|
||||
Created int64
|
||||
|
||||
// Internal is set to true if the profile is attributed to a
|
||||
// Portmaster internal process. Internal is set during profile
|
||||
// creation and may be accessed without lock.
|
||||
Internal bool
|
||||
|
||||
// layeredProfile is a link to the layered profile with this profile as the
|
||||
// main profile.
|
||||
// All processes with the same binary should share the same instance of the
|
||||
// local profile and the associated layered profile.
|
||||
layeredProfile *LayeredProfile
|
||||
|
||||
// Interpreted Data
|
||||
configPerspective *config.Perspective
|
||||
dataParsed bool
|
||||
@@ -108,8 +120,8 @@ type Profile struct { //nolint:maligned // not worth the effort
|
||||
filterListIDs []string
|
||||
|
||||
// Lifecycle Management
|
||||
outdated *abool.AtomicBool
|
||||
lastUsed time.Time
|
||||
outdated *abool.AtomicBool
|
||||
lastActive *int64
|
||||
|
||||
internalSave bool
|
||||
}
|
||||
@@ -118,6 +130,7 @@ func (profile *Profile) prepConfig() (err error) {
|
||||
// prepare configuration
|
||||
profile.configPerspective, err = config.NewPerspective(profile.Config)
|
||||
profile.outdated = abool.New()
|
||||
profile.lastActive = new(int64)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -177,16 +190,24 @@ func (profile *Profile) parseConfig() error {
|
||||
}
|
||||
|
||||
// New returns a new Profile.
|
||||
func New() *Profile {
|
||||
func New(source profileSource, id string) *Profile {
|
||||
profile := &Profile{
|
||||
ID: utils.RandomUUID("").String(),
|
||||
Source: SourceLocal,
|
||||
ID: id,
|
||||
Source: source,
|
||||
Created: time.Now().Unix(),
|
||||
Config: make(map[string]interface{}),
|
||||
internalSave: true,
|
||||
}
|
||||
|
||||
// create placeholders
|
||||
// Generate random ID if none is given.
|
||||
if id == "" {
|
||||
profile.ID = utils.RandomUUID("").String()
|
||||
}
|
||||
|
||||
// Make key from ID and source.
|
||||
profile.makeKey()
|
||||
|
||||
// Prepare profile to create placeholders.
|
||||
_ = profile.prepConfig()
|
||||
_ = profile.parseConfig()
|
||||
|
||||
@@ -198,6 +219,11 @@ func (profile *Profile) ScopedID() string {
|
||||
return makeScopedID(profile.Source, profile.ID)
|
||||
}
|
||||
|
||||
// makeKey derives and sets the record Key from the profile attributes.
|
||||
func (profile *Profile) makeKey() {
|
||||
profile.SetKey(makeProfileKey(profile.Source, profile.ID))
|
||||
}
|
||||
|
||||
// Save saves the profile to the database
|
||||
func (profile *Profile) Save() error {
|
||||
if profile.ID == "" {
|
||||
@@ -207,38 +233,41 @@ func (profile *Profile) Save() error {
|
||||
return fmt.Errorf("profile: profile %s does not specify a source", profile.ID)
|
||||
}
|
||||
|
||||
if !profile.KeyIsSet() {
|
||||
profile.SetKey(makeProfileKey(profile.Source, profile.ID))
|
||||
}
|
||||
|
||||
return profileDB.Put(profile)
|
||||
}
|
||||
|
||||
// MarkUsed marks the profile as used and saves it when it has changed.
|
||||
func (profile *Profile) MarkUsed() {
|
||||
profile.Lock()
|
||||
// lastUsed
|
||||
profile.lastUsed = time.Now()
|
||||
// MarkStillActive marks the profile as still active.
|
||||
func (profile *Profile) MarkStillActive() {
|
||||
atomic.StoreInt64(profile.lastActive, time.Now().Unix())
|
||||
}
|
||||
|
||||
// LastActive returns the unix timestamp when the profile was last marked as
|
||||
// still active.
|
||||
func (profile *Profile) LastActive() int64 {
|
||||
return atomic.LoadInt64(profile.lastActive)
|
||||
}
|
||||
|
||||
// MarkUsed updates ApproxLastUsed when it's been a while and saves the profile if it was changed.
|
||||
func (profile *Profile) MarkUsed() (changed bool) {
|
||||
profile.Lock()
|
||||
defer profile.Unlock()
|
||||
|
||||
// ApproxLastUsed
|
||||
save := false
|
||||
if time.Now().Add(-lastUsedUpdateThreshold).Unix() > profile.ApproxLastUsed {
|
||||
profile.ApproxLastUsed = time.Now().Unix()
|
||||
save = true
|
||||
return true
|
||||
}
|
||||
profile.Unlock()
|
||||
|
||||
if save {
|
||||
err := profile.Save()
|
||||
if err != nil {
|
||||
log.Warningf("profiles: failed to save profile %s after marking as used: %s", profile.ScopedID(), err)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// String returns a string representation of the Profile.
|
||||
func (profile *Profile) String() string {
|
||||
return profile.Name
|
||||
return fmt.Sprintf("<%s %s/%s>", profile.Name, profile.Source, profile.ID)
|
||||
}
|
||||
|
||||
// IsOutdated returns whether the this instance of the profile is marked as outdated.
|
||||
func (profile *Profile) IsOutdated() bool {
|
||||
return profile.outdated.IsSet()
|
||||
}
|
||||
|
||||
// AddEndpoint adds an endpoint to the endpoint list, saves the profile and reloads the configuration.
|
||||
@@ -252,82 +281,50 @@ func (profile *Profile) AddServiceEndpoint(newEntry string) {
|
||||
}
|
||||
|
||||
func (profile *Profile) addEndpointyEntry(cfgKey, newEntry string) {
|
||||
// When finished, save the profile.
|
||||
defer func() {
|
||||
err := profile.Save()
|
||||
if err != nil {
|
||||
log.Warningf("profile: failed to save profile %s after add an endpoint rule: %s", profile.ScopedID(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
// When finished increase the revision counter of the layered profile.
|
||||
defer func() {
|
||||
if profile.layeredProfile != nil {
|
||||
profile.layeredProfile.Lock()
|
||||
defer profile.layeredProfile.Unlock()
|
||||
|
||||
profile.layeredProfile.RevisionCounter++
|
||||
}
|
||||
}()
|
||||
|
||||
// Lock the profile for editing.
|
||||
profile.Lock()
|
||||
// get, update, save endpoints list
|
||||
defer profile.Unlock()
|
||||
|
||||
// Get the endpoint list configuration value and add the new entry.
|
||||
endpointList, ok := profile.configPerspective.GetAsStringArray(cfgKey)
|
||||
if !ok {
|
||||
endpointList = make([]string, 0, 1)
|
||||
}
|
||||
endpointList = append(endpointList, newEntry)
|
||||
endpointList = append([]string{newEntry}, endpointList...)
|
||||
config.PutValueIntoHierarchicalConfig(profile.Config, cfgKey, endpointList)
|
||||
|
||||
profile.Unlock()
|
||||
err := profile.Save()
|
||||
if err != nil {
|
||||
log.Warningf("profile: failed to save profile after adding endpoint: %s", err)
|
||||
}
|
||||
|
||||
// reload manually
|
||||
profile.Lock()
|
||||
// Reload the profile manually in order to parse the newly added entry.
|
||||
profile.dataParsed = false
|
||||
err = profile.parseConfig()
|
||||
err := profile.parseConfig()
|
||||
if err != nil {
|
||||
log.Warningf("profile: failed to parse profile config after adding endpoint: %s", err)
|
||||
log.Warningf("profile: failed to parse %s config after adding endpoint: %s", profile, err)
|
||||
}
|
||||
profile.Unlock()
|
||||
}
|
||||
|
||||
// GetProfile loads a profile from the database.
|
||||
func GetProfile(source profileSource, id string) (*Profile, error) {
|
||||
return GetProfileByScopedID(makeScopedID(source, id))
|
||||
}
|
||||
|
||||
// GetProfileByScopedID loads a profile from the database using a scoped ID like "local/id" or "community/id".
|
||||
func GetProfileByScopedID(scopedID string) (*Profile, error) {
|
||||
// check cache
|
||||
profile := getActiveProfile(scopedID)
|
||||
if profile != nil {
|
||||
profile.MarkUsed()
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// get from database
|
||||
r, err := profileDB.Get(profilesDBPath + scopedID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// convert
|
||||
profile, err = EnsureProfile(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// lock for prepping
|
||||
// LayeredProfile returns the layered profile associated with this profile.
|
||||
func (profile *Profile) LayeredProfile() *LayeredProfile {
|
||||
profile.Lock()
|
||||
defer profile.Unlock()
|
||||
|
||||
// prepare config
|
||||
err = profile.prepConfig()
|
||||
if err != nil {
|
||||
log.Warningf("profiles: profile %s has (partly) invalid configuration: %s", profile.ID, err)
|
||||
}
|
||||
|
||||
// parse config
|
||||
err = profile.parseConfig()
|
||||
if err != nil {
|
||||
log.Warningf("profiles: profile %s has (partly) invalid configuration: %s", profile.ID, err)
|
||||
}
|
||||
|
||||
// mark as internal
|
||||
profile.internalSave = true
|
||||
|
||||
profile.Unlock()
|
||||
|
||||
// mark active
|
||||
profile.MarkUsed()
|
||||
markProfileActive(profile)
|
||||
|
||||
return profile, nil
|
||||
return profile.layeredProfile
|
||||
}
|
||||
|
||||
// EnsureProfile ensures that the given record is a *Profile, and returns it.
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"github.com/safing/portbase/log"
|
||||
)
|
||||
|
||||
const (
|
||||
unidentifiedProfileID = "_unidentified"
|
||||
systemProfileID = "_system"
|
||||
)
|
||||
|
||||
// GetUnidentifiedProfile returns the special profile assigned to unidentified processes.
|
||||
func GetUnidentifiedProfile() *Profile {
|
||||
// get profile
|
||||
profile, err := GetProfile(SourceLocal, unidentifiedProfileID)
|
||||
if err == nil {
|
||||
return profile
|
||||
}
|
||||
|
||||
// create if not available (or error)
|
||||
profile = New()
|
||||
profile.Name = "Unidentified Processes"
|
||||
profile.Source = SourceLocal
|
||||
profile.ID = unidentifiedProfileID
|
||||
|
||||
// save to db
|
||||
err = profile.Save()
|
||||
if err != nil {
|
||||
log.Warningf("profiles: failed to save %s: %s", profile.ScopedID(), err)
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
// GetSystemProfile returns the special profile used for the Kernel.
|
||||
func GetSystemProfile() *Profile {
|
||||
// get profile
|
||||
profile, err := GetProfile(SourceLocal, systemProfileID)
|
||||
if err == nil {
|
||||
return profile
|
||||
}
|
||||
|
||||
// create if not available (or error)
|
||||
profile = New()
|
||||
profile.Name = "Operating System"
|
||||
profile.Source = SourceLocal
|
||||
profile.ID = systemProfileID
|
||||
|
||||
// save to db
|
||||
err = profile.Save()
|
||||
if err != nil {
|
||||
log.Warningf("profiles: failed to save %s: %s", profile.ScopedID(), err)
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
@@ -82,30 +82,23 @@ func prepConfig() error {
|
||||
Name: "DNS Servers",
|
||||
Key: CfgOptionNameServersKey,
|
||||
Description: "DNS Servers to use for resolving DNS requests.",
|
||||
Help: `Format:
|
||||
Help: strings.ReplaceAll(`DNS Servers are configured in a URL format. This allows you to specify special settings for a resolver. If you just want to use a resolver at IP 10.2.3.4, please enter: "dns://10.2.3.4"
|
||||
The format is: "protocol://ip:port?parameter=value¶meter=value"
|
||||
|
||||
DNS Servers are configured in a URL format. This allows you to specify special settings for a resolver. If you just want to use a resolver at IP 10.2.3.4, please enter: dns://10.2.3.4:53
|
||||
The format is: protocol://ip:port?parameter=value¶meter=value
|
||||
|
||||
Protocols:
|
||||
dot: DNS-over-TLS (recommended)
|
||||
dns: plain old DNS
|
||||
tcp: plain old DNS over TCP
|
||||
|
||||
IP:
|
||||
always use the IP address and _not_ the domain name!
|
||||
|
||||
Port:
|
||||
optionally define a custom port
|
||||
|
||||
Parameters:
|
||||
name: give your DNS Server a name that is used for messages and logs
|
||||
verify: domain name to verify for "dot", required and only valid for "dot"
|
||||
blockedif: detect if the name server blocks a query, options:
|
||||
empty: server replies with NXDomain status, but without any other record in any section
|
||||
refused: server replies with Refused status
|
||||
zeroip: server replies with an IP address, but it is zero
|
||||
`,
|
||||
- Protocol
|
||||
- "dot": DNS-over-TLS (recommended)
|
||||
- "dns": plain old DNS
|
||||
- "tcp": plain old DNS over TCP
|
||||
- IP: always use the IP address and _not_ the domain name!
|
||||
- Port: optionally define a custom port
|
||||
- Parameters:
|
||||
- "name": give your DNS Server a name that is used for messages and logs
|
||||
- "verify": domain name to verify for "dot", required and only valid for protocol "dot"
|
||||
- "blockedif": detect if the name server blocks a query, options:
|
||||
- "empty": server replies with NXDomain status, but without any other record in any section
|
||||
- "refused": server replies with Refused status
|
||||
- "zeroip": server replies with an IP address, but it is zero
|
||||
`, `"`, "`"),
|
||||
OptType: config.OptTypeStringArray,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
ReleaseLevel: config.ReleaseLevelStable,
|
||||
@@ -157,13 +150,13 @@ Parameters:
|
||||
configuredNameServers = config.Concurrent.GetAsStringArray(CfgOptionNameServersKey, defaultNameServers)
|
||||
|
||||
err = config.Register(&config.Option{
|
||||
Name: "DNS Server Retry Rate",
|
||||
Name: "Retry Timeout",
|
||||
Key: CfgOptionNameserverRetryRateKey,
|
||||
Description: "Rate at which to retry failed DNS Servers, in seconds.",
|
||||
Description: "Timeout between retries when a resolver fails.",
|
||||
OptType: config.OptTypeInt,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
ReleaseLevel: config.ReleaseLevelStable,
|
||||
DefaultValue: 600,
|
||||
DefaultValue: 300,
|
||||
Annotations: config.Annotations{
|
||||
config.DisplayOrderAnnotation: cfgOptionNameserverRetryRateOrder,
|
||||
config.UnitAnnotation: "seconds",
|
||||
@@ -176,9 +169,9 @@ Parameters:
|
||||
nameserverRetryRate = config.Concurrent.GetAsInt(CfgOptionNameserverRetryRateKey, 600)
|
||||
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Do not use assigned Nameservers",
|
||||
Name: "Ignore system resolvers",
|
||||
Key: CfgOptionNoAssignedNameserversKey,
|
||||
Description: "that were acquired by the network (dhcp) or system",
|
||||
Description: "Ignore resolvers that were acquired from the operating system.",
|
||||
OptType: config.OptTypeInt,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
ReleaseLevel: config.ReleaseLevelStable,
|
||||
@@ -196,9 +189,9 @@ Parameters:
|
||||
noAssignedNameservers = status.SecurityLevelOption(CfgOptionNoAssignedNameserversKey)
|
||||
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Do not use Multicast DNS",
|
||||
Name: "Ignore Multicast DNS",
|
||||
Key: CfgOptionNoMulticastDNSKey,
|
||||
Description: "Multicast DNS queries other devices in the local network",
|
||||
Description: "Do not resolve using Multicast DNS. This may break certain Plug and Play devices or services.",
|
||||
OptType: config.OptTypeInt,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
ReleaseLevel: config.ReleaseLevelStable,
|
||||
@@ -216,9 +209,9 @@ Parameters:
|
||||
noMulticastDNS = status.SecurityLevelOption(CfgOptionNoMulticastDNSKey)
|
||||
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Do not resolve insecurely",
|
||||
Name: "Enforce secure DNS",
|
||||
Key: CfgOptionNoInsecureProtocolsKey,
|
||||
Description: "Do not resolve domains with insecure protocols, ie. plain DNS",
|
||||
Description: "Never resolve using insecure protocols, ie. plain DNS.",
|
||||
OptType: config.OptTypeInt,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
ReleaseLevel: config.ReleaseLevelStable,
|
||||
@@ -236,9 +229,9 @@ Parameters:
|
||||
noInsecureProtocols = status.SecurityLevelOption(CfgOptionNoInsecureProtocolsKey)
|
||||
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Do not resolve special domains",
|
||||
Name: "Block unofficial TLDs",
|
||||
Key: CfgOptionDontResolveSpecialDomainsKey,
|
||||
Description: fmt.Sprintf("Do not resolve the special top level domains %s", formatScopeList(specialServiceDomains)),
|
||||
Description: fmt.Sprintf("Block %s.", formatScopeList(specialServiceDomains)),
|
||||
OptType: config.OptTypeInt,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
ReleaseLevel: config.ReleaseLevelStable,
|
||||
|
||||
@@ -45,15 +45,17 @@ type ThreatPayload struct {
|
||||
// // Once you're done, delete the threat
|
||||
// threat.Delete().Publish()
|
||||
//
|
||||
func NewThreat(id, msg string) *Threat {
|
||||
func NewThreat(id, title, msg string) *Threat {
|
||||
t := &Threat{
|
||||
Notification: ¬ifications.Notification{
|
||||
EventID: id,
|
||||
Message: msg,
|
||||
Type: notifications.Warning,
|
||||
State: notifications.Active,
|
||||
EventID: id,
|
||||
Type: notifications.Warning,
|
||||
Title: title,
|
||||
Category: "Threat",
|
||||
Message: msg,
|
||||
},
|
||||
}
|
||||
|
||||
t.threatData().Started = time.Now().Unix()
|
||||
|
||||
return t
|
||||
|
||||
@@ -25,10 +25,10 @@ func registerConfig() error {
|
||||
err := config.Register(&config.Option{
|
||||
Name: "Release Channel",
|
||||
Key: releaseChannelKey,
|
||||
Description: "The Release Channel changes which updates are applied. When using beta, you will receive new features earlier and Portmaster will update more frequently. Some beta or experimental features are also available in the stable release channel.",
|
||||
Description: "Switch release channel.",
|
||||
OptType: config.OptTypeString,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
ReleaseLevel: config.ReleaseLevelBeta,
|
||||
ExpertiseLevel: config.ExpertiseLevelDeveloper,
|
||||
ReleaseLevel: config.ReleaseLevelExperimental,
|
||||
RequiresRestart: false,
|
||||
DefaultValue: releaseChannelStable,
|
||||
PossibleValues: []config.PossibleValue{
|
||||
@@ -54,7 +54,7 @@ func registerConfig() error {
|
||||
err = config.Register(&config.Option{
|
||||
Name: "Disable Updates",
|
||||
Key: disableUpdatesKey,
|
||||
Description: "Disable automatic updates.",
|
||||
Description: "Disable automatic updates. This affects all kinds of updates, including intelligence feeds and broadcast notifications.",
|
||||
OptType: config.OptTypeBool,
|
||||
ExpertiseLevel: config.ExpertiseLevelExpert,
|
||||
ReleaseLevel: config.ReleaseLevelStable,
|
||||
|
||||
@@ -99,18 +99,30 @@ func upgradeCoreNotify() error {
|
||||
|
||||
// check for new version
|
||||
if info.GetInfo().Version != pmCoreUpdate.Version() {
|
||||
n := notifications.NotifyInfo(
|
||||
"updates:core-update-available",
|
||||
fmt.Sprintf(":tada: Update to **Portmaster v%s** is available! Please restart the Portmaster to apply the update.", pmCoreUpdate.Version()),
|
||||
notifications.Action{
|
||||
ID: "restart",
|
||||
Text: "Restart",
|
||||
n := notifications.Notify(¬ifications.Notification{
|
||||
EventID: "updates:core-update-available",
|
||||
Type: notifications.Info,
|
||||
Title: fmt.Sprintf(
|
||||
"Portmaster Update v%s",
|
||||
pmCoreUpdate.Version(),
|
||||
),
|
||||
Category: "Core",
|
||||
Message: fmt.Sprintf(
|
||||
`:tada: Update to **Portmaster v%s** is available!
|
||||
Please restart the Portmaster to apply the update.`,
|
||||
pmCoreUpdate.Version(),
|
||||
),
|
||||
AvailableActions: []*notifications.Action{
|
||||
{
|
||||
ID: "restart",
|
||||
Text: "Restart",
|
||||
},
|
||||
{
|
||||
ID: "later",
|
||||
Text: "Not now",
|
||||
},
|
||||
},
|
||||
notifications.Action{
|
||||
ID: "later",
|
||||
Text: "Not now",
|
||||
},
|
||||
)
|
||||
})
|
||||
n.SetActionFunction(upgradeCoreNotifyActionHandler)
|
||||
|
||||
log.Debugf("updates: new portmaster version available, sending notification to user")
|
||||
@@ -119,7 +131,7 @@ func upgradeCoreNotify() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func upgradeCoreNotifyActionHandler(n *notifications.Notification) {
|
||||
func upgradeCoreNotifyActionHandler(_ context.Context, n *notifications.Notification) error {
|
||||
switch n.SelectedActionID {
|
||||
case "restart":
|
||||
// Cannot directly trigger due to import loop.
|
||||
@@ -130,11 +142,13 @@ func upgradeCoreNotifyActionHandler(n *notifications.Notification) {
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
log.Warningf("updates: failed to trigger restart via notification: %s", err)
|
||||
return fmt.Errorf("failed to trigger restart via notification: %s", err)
|
||||
}
|
||||
case "later":
|
||||
n.Expires = time.Now().Unix() // expire immediately
|
||||
return n.Delete()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func upgradeHub() error {
|
||||
@@ -244,10 +258,18 @@ func warnOnIncorrectParentPath() {
|
||||
if !strings.HasPrefix(absPath, root) {
|
||||
log.Warningf("detected unexpected path %s for portmaster-start", absPath)
|
||||
|
||||
notifications.NotifyWarn(
|
||||
"updates:unsupported-parent",
|
||||
fmt.Sprintf("The portmaster has been launched by an unexpected %s binary at %s. Please configure your system to use the binary at %s as this version will be kept up to date automatically.", expectedFileName, absPath, filepath.Join(root, expectedFileName)),
|
||||
)
|
||||
notifications.Notify(¬ifications.Notification{
|
||||
EventID: "updates:unsupported-parent",
|
||||
Type: notifications.Warning,
|
||||
Title: "Unsupported Launcher",
|
||||
Category: "Core",
|
||||
Message: fmt.Sprintf(
|
||||
"The portmaster has been launched by an unexpected %s binary at %s. Please configure your system to use the binary at %s as this version will be kept up to date automatically.",
|
||||
expectedFileName,
|
||||
absPath,
|
||||
filepath.Join(root, expectedFileName),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user