diff --git a/network/api.go b/network/api.go new file mode 100644 index 00000000..f9b2c66c --- /dev/null +++ b/network/api.go @@ -0,0 +1,284 @@ +package network + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/database/query" + "github.com/safing/portbase/utils/debug" + "github.com/safing/portmaster/status" +) + +func registerAPIEndpoints() error { + if err := api.RegisterEndpoint(api.Endpoint{ + Path: "debug/network", + Read: api.PermitUser, + DataFunc: debugInfo, + Name: "Get Network Debug Information", + Description: "Returns network debugging information, similar to debug/core, but with connection data.", + Parameters: []api.Parameter{ + { + Method: http.MethodGet, + Field: "style", + Value: "github", + Description: "Specify the formatting style. The default is simple markdown formatting.", + }, + { + Method: http.MethodGet, + Field: "profile", + Value: "/", + Description: "Specify a profile source and ID for which network connection should be reported.", + }, + { + Method: http.MethodGet, + Field: "where", + Value: "", + Description: "Specify a query to limit the connections included in the report. The default is to include all connections.", + }, + }, + }); err != nil { + return err + } + + return nil +} + +// debugInfo returns the debugging information for support requests. +func debugInfo(ar *api.Request) (data []byte, err error) { + // Create debug information helper. + di := new(debug.Info) + di.Style = ar.Request.URL.Query().Get("style") + + // Add debug information. + di.AddVersionInfo() + di.AddPlatformInfo(ar.Context()) + status.AddToDebugInfo(di) + AddNetworkDebugData( + di, + ar.Request.URL.Query().Get("profile"), + ar.Request.URL.Query().Get("where"), + ) + di.AddLastReportedModuleError() + di.AddLastUnexpectedLogs() + di.AddGoroutineStack() + + // Return data. + return di.Bytes(), nil +} + +func AddNetworkDebugData(di *debug.Info, profile, where string) { + // Prepend where prefix to query if necessary. + if where != "" && !strings.HasPrefix(where, "where ") { + where = "where " + where + } + + // Build query. + q, err := query.ParseQuery("query network: " + where) + if err != nil { + di.AddSection( + fmt.Sprintf("Network: Debug Failed"), + debug.NoFlags, + fmt.Sprintf("Failed to build query: %s", err), + ) + return + } + + // Get iterator. + it, err := dbController.Query(q, true, true) + if err != nil { + di.AddSection( + fmt.Sprintf("Network: Debug Failed"), + debug.NoFlags, + fmt.Sprintf("Failed to run query: %s", err), + ) + return + } + + // Collect matching connections. + var debugConns []*Connection + var accepted int + var total int + for maybeConn := range it.Next { + // Switch to correct type. + conn, ok := maybeConn.(*Connection) + if !ok { + continue + } + + // Check if the profile matches + if profile != "" { + found := false + + // Get layer IDs and search for a match. + layerIDs := conn.Process().Profile().LayerIDs + for _, layerID := range layerIDs { + if profile == layerID { + found = true + break + } + } + + // Skip if the profile does not match. + if !found { + continue + } + } + + // Count. + total++ + switch conn.Verdict { + case VerdictAccept, + VerdictRerouteToNameserver, + VerdictRerouteToTunnel: + accepted++ + } + + // Add to list. + debugConns = append(debugConns, conn) + } + + // Add it all. + di.AddSection( + fmt.Sprintf( + "Network: %d/%d Connections", + accepted, + total, + ), + debug.UseCodeSection|debug.AddContentLineBreaks, + buildNetworkDebugInfoData(debugConns), + ) +} + +func buildNetworkDebugInfoData(debugConns []*Connection) string { + // Sort + sort.Sort(connectionsByStarted(debugConns)) + + // Format lines + var buf strings.Builder + currentBinaryPath := "__" + for _, conn := range debugConns { + conn.Lock() + + // Add process infomration if it differs from previous connection. + if currentBinaryPath != conn.ProcessContext.BinaryPath { + if currentBinaryPath != "__" { + buf.WriteString("\n\n\n") + } + buf.WriteString("ProcessName: " + conn.ProcessContext.ProcessName) + buf.WriteString("\nProfileName: " + conn.ProcessContext.ProfileName) + buf.WriteString("\nBinaryPath: " + conn.ProcessContext.BinaryPath) + buf.WriteString("\nProfile: " + conn.ProcessContext.Profile) + buf.WriteString("\nSource: " + conn.ProcessContext.Source) + buf.WriteString("\n") + + // Set current path in order to not print the process information again. + currentBinaryPath = conn.ProcessContext.BinaryPath + } + + // Add connection. + buf.WriteString("\n") + buf.WriteString(conn.debugInfoLine()) + + conn.Unlock() + } + + return buf.String() +} + +func (conn *Connection) debugInfoLine() string { + var connectionData string + if conn.ID != "" { + // Format IP/Port pair for connections. + connectionData = fmt.Sprintf( + "% 15s:%- 5s %s % 15s:%- 5s", + conn.LocalIP, + strconv.Itoa(int(conn.LocalPort)), + conn.fmtProtocolAndDirectionComponent(conn.IPProtocol.String()), + conn.Entity.IP, + strconv.Itoa(int(conn.Entity.Port)), + ) + } else { + // Leave empty for DNS Requests. + connectionData = " " + } + + return fmt.Sprintf( + "% 14s %s%- 25s %s-%s P#%d [%s] %s - by %s @ %s", + conn.Verdict.Verb(), + connectionData, + conn.fmtDomainComponent(), + time.Unix(conn.Started, 0).Format("15:04:05"), + conn.fmtEndTimeComponent(), + conn.ProcessContext.PID, + conn.fmtFlagsComponent(), + conn.Reason.Msg, + conn.Reason.OptionKey, + conn.fmtReasonProfileComponent(), + ) +} + +func (conn *Connection) fmtDomainComponent() string { + if conn.Entity.Domain != "" { + return " to " + conn.Entity.Domain + } + return "" +} + +func (conn *Connection) fmtProtocolAndDirectionComponent(protocol string) string { + if conn.Inbound { + return "<" + protocol + } + return protocol + ">" +} + +func (conn *Connection) fmtFlagsComponent() string { + var f string + + if conn.Internal { + f += "I" + } + if conn.Encrypted { + f += "E" + } + if conn.Tunneled { + f += "T" + } + if len(conn.activeInspectors) > 0 { + f += "A" + } + if conn.addedToMetrics { + f += "M" + } + + return f +} + +func (conn *Connection) fmtEndTimeComponent() string { + if conn.Ended == 0 { + return " " // Use same width as a timestamp. + } + return time.Unix(conn.Ended, 0).Format("15:04:05") +} + +func (conn *Connection) fmtReasonProfileComponent() string { + if conn.Reason.Profile == "" { + return "global" + } + return conn.Reason.Profile +} + +type connectionsByStarted []*Connection + +func (a connectionsByStarted) Len() int { return len(a) } +func (a connectionsByStarted) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a connectionsByStarted) Less(i, j int) bool { + if a[i].ProcessContext.BinaryPath != a[j].ProcessContext.BinaryPath { + return a[i].ProcessContext.BinaryPath < a[j].ProcessContext.BinaryPath + } + return a[i].Started < a[j].Started +} diff --git a/network/api_test.go b/network/api_test.go new file mode 100644 index 00000000..ae3a1825 --- /dev/null +++ b/network/api_test.go @@ -0,0 +1,145 @@ +package network + +import ( + "fmt" + "net" + "testing" + + "github.com/safing/portmaster/intel" +) + +func TestDebugInfoLineFormatting(t *testing.T) { + for _, conn := range connectionTestData { + fmt.Println(conn.debugInfoLine()) + } +} + +func TestDebugInfoFormatting(t *testing.T) { + fmt.Println(buildNetworkDebugInfoData(connectionTestData)) +} + +var connectionTestData = []*Connection{ + { + ID: "17-255.255.255.255-29810-192.168.0.23-40672", + Scope: "IL", + IPVersion: 4, + Inbound: true, + IPProtocol: 17, + LocalIP: net.ParseIP("255.255.255.255"), + LocalPort: 29810, + Entity: &intel.Entity{ + Protocol: 17, + Port: 40672, + Domain: "", + ReverseDomain: "", + IP: net.ParseIP("192.168.0.23"), + Country: "", + ASN: 0, + }, + Verdict: 4, + Reason: Reason{ + Msg: "incoming connection blocked by default", + OptionKey: "filter/serviceEndpoints", + Profile: "", + }, + Started: 1614010349, + Ended: 1614010350, + VerdictPermanent: true, + Inspecting: false, + Tunneled: false, + Encrypted: false, + ProcessContext: ProcessContext{ + ProcessName: "Unidentified Processes", + ProfileName: "Unidentified Processes", + BinaryPath: "", + PID: -1, + Profile: "_unidentified", + Source: "local", + }, + Internal: false, + ProfileRevisionCounter: 1, + }, + { + ID: "6-192.168.0.176-55216-13.32.6.15-80", + Scope: "PI", + IPVersion: 4, + Inbound: false, + IPProtocol: 6, + LocalIP: net.ParseIP("192.168.0.176"), + LocalPort: 55216, + Entity: &intel.Entity{ + Protocol: 6, + Port: 80, + Domain: "", + ReverseDomain: "", + IP: net.ParseIP("13.32.6.15"), + Country: "DE", + ASN: 16509, + }, + Verdict: 2, + Reason: Reason{ + Msg: "default permit", + OptionKey: "filter/defaultAction", + Profile: "", + }, + Started: 1614010475, + Ended: 1614010565, + VerdictPermanent: true, + Inspecting: false, + Tunneled: false, + Encrypted: false, + ProcessContext: ProcessContext{ + ProcessName: "NetworkManager", + ProfileName: "Network Manager", + BinaryPath: "/usr/sbin/NetworkManager", + PID: 1273, + Profile: "3a9b0eb5-c7fe-4bc7-9b93-a90f4ff84b5b", + Source: "local", + }, + Internal: true, + ProfileRevisionCounter: 1, + }, + { + ID: "6-192.168.0.176-49982-142.250.74.211-443", + Scope: "pkg.go.dev.", + IPVersion: 4, + Inbound: false, + IPProtocol: 6, + LocalIP: net.ParseIP("192.168.0.176"), + LocalPort: 49982, + Entity: &intel.Entity{ + Protocol: 6, + Port: 443, + Domain: "pkg.go.dev.", + ReverseDomain: "", + CNAME: []string{ + "ghs.googlehosted.com.", + }, + IP: net.ParseIP("142.250.74.211"), + Country: "US", + ASN: 15169, + }, + Verdict: 2, + Reason: Reason{ + Msg: "default permit", + OptionKey: "filter/defaultAction", + Profile: "", + }, + Started: 1614010415, + Ended: 1614010745, + VerdictPermanent: true, + Inspecting: false, + Tunneled: false, + Encrypted: false, + ProcessContext: ProcessContext{ + ProcessName: "firefox", + ProfileName: "Firefox", + BinaryPath: "/usr/bin/firefox", + PID: 5710, + Profile: "74b30392-9e4d-4157-83a9-fffafd3e2bde", + Source: "local", + }, + Internal: false, + ProfileRevisionCounter: 1, + }, +} diff --git a/network/connection.go b/network/connection.go index f8b130e9..750de2a6 100644 --- a/network/connection.go +++ b/network/connection.go @@ -33,7 +33,7 @@ type ProcessContext struct { ProfileName string // BinaryPath is the path to the process binary. BinaryPath string - // PID i the process identifier. + // PID is the process identifier. PID int // Profile is the ID of the main profile that // is applied to the process. @@ -93,7 +93,7 @@ type Connection struct { //nolint:maligned // TODO: fix alignment 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 + // Started is only ever set when creating a new connection object // and is considered immutable afterwards. Started int64 // Ended is set to the number of seconds in UNIX epoch time at which diff --git a/network/module.go b/network/module.go index e3ed28b0..2d4f6936 100644 --- a/network/module.go +++ b/network/module.go @@ -31,6 +31,10 @@ func start() error { return err } + if err := registerAPIEndpoints(); err != nil { + return err + } + module.StartServiceWorker("clean connections", 0, connectionCleaner) module.StartServiceWorker("write open dns requests", 0, openDNSRequestWriter) diff --git a/network/status.go b/network/status.go index e0d1042b..75c7f5a8 100644 --- a/network/status.go +++ b/network/status.go @@ -54,9 +54,9 @@ func (v Verdict) Verb() string { case VerdictDrop: return "dropped" case VerdictRerouteToNameserver: - return "rerouted to nameserver" + return "to nameserver" case VerdictRerouteToTunnel: - return "rerouted to tunnel" + return "to tunnel" case VerdictFailed: return "failed" default: