From 4cdf709606972f9098166664b3bff1786a4187c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustavo=20I=C3=B1iguez=20Goia?= Date: Tue, 23 Apr 2024 11:59:11 +0200 Subject: [PATCH] collect and display bytes sent/recv per process New feature to collect and display bytes sent/received per process. - it only works with 'ebpf' monitor method. - the information is collected on kernel space and sent to the daemon: - when the connection socket is closed. - every 2s on large transfers. On this case the bytes are accumulated. The daemon sends the events to the server (GUI), where the information is added to the DB. The information is displayed on the GUI: - on the statusbar in real-time (based on the refresh interval defined). - on the Applications tab. By right clicking on the Applications tab headers, the user can reset Tx/Rx stats, and grouping bytes per unit (default) or not. Finally, the rx/tx stats are deleted based on the preferences options. --- daemon/procmon/details.go | 52 +++--- daemon/procmon/ebpf/ebpf.go | 82 +++++++-- daemon/procmon/ebpf/events.go | 100 +++++++++-- daemon/procmon/ebpf/find.go | 4 +- daemon/procmon/ebpf/utils.go | 4 +- daemon/procmon/process.go | 168 ++++++++++++------ daemon/rule/operator.go | 68 +++++++- daemon/ui/alerts.go | 8 + daemon/ui/client.go | 7 +- daemon/ui/config_utils.go | 3 + ebpf_prog/common.h | 63 ++++++- ebpf_prog/opensnitch-procs.c | 272 ++++++++++++++++++++++++++++- proto/ui.proto | 69 +++++++- ui/opensnitch/alerts/__init__.py | 0 ui/opensnitch/alerts/_utils.py | 29 +++ ui/opensnitch/alerts/alert.py | 69 ++++++++ ui/opensnitch/alerts/rxtx.py | 67 +++++++ ui/opensnitch/config.py | 1 + ui/opensnitch/database/__init__.py | 122 ++++++++++++- ui/opensnitch/dialogs/stats.py | 208 ++++++++++++++++++++-- ui/opensnitch/res/stats.ui | 110 ++++++------ ui/opensnitch/service.py | 64 +++---- 22 files changed, 1328 insertions(+), 242 deletions(-) create mode 100644 ui/opensnitch/alerts/__init__.py create mode 100644 ui/opensnitch/alerts/_utils.py create mode 100644 ui/opensnitch/alerts/alert.py create mode 100644 ui/opensnitch/alerts/rxtx.py diff --git a/daemon/procmon/details.go b/daemon/procmon/details.go index 3090e4c6b7..b17a216ebb 100644 --- a/daemon/procmon/details.go +++ b/daemon/procmon/details.go @@ -121,7 +121,7 @@ func (p *Process) GetExtraInfo() error { // ReadPPID obtains the pid of the parent process func (p *Process) ReadPPID() { // ReadFile + parse = ~40us - data, err := ioutil.ReadFile(p.pathStat) + data, err := ioutil.ReadFile(p.procPath[Stat]) if err != nil { p.PPID = 0 return @@ -143,7 +143,7 @@ func (p *Process) ReadComm() error { if p.Comm != "" { return nil } - data, err := ioutil.ReadFile(p.pathComm) + data, err := ioutil.ReadFile(p.procPath[Comm]) if err != nil { return err } @@ -156,7 +156,7 @@ func (p *Process) ReadCwd() error { if p.CWD != "" { return nil } - link, err := os.Readlink(p.pathCwd) + link, err := os.Readlink(p.procPath[Cwd]) if err != nil { return err } @@ -166,7 +166,7 @@ func (p *Process) ReadCwd() error { // ReadEnv reads and parses the environment variables of a process. func (p *Process) ReadEnv() { - data, err := ioutil.ReadFile(p.pathEnviron) + data, err := ioutil.ReadFile(p.procPath[Environ]) if err != nil { return } @@ -200,7 +200,7 @@ func (p *Process) ReadPath() error { defer func() { if p.Path == "" { // determine if this process might be of a kernel task. - if data, err := ioutil.ReadFile(p.pathMaps); err == nil && len(data) == 0 { + if data, err := ioutil.ReadFile(p.procPath[Maps]); err == nil && len(data) == 0 { p.Path = KernelConnection p.Args = append(p.Args, p.Comm) return @@ -209,12 +209,12 @@ func (p *Process) ReadPath() error { } }() - if _, err := os.Lstat(p.pathExe); err != nil { + if _, err := os.Lstat(p.procPath[Exe]); err != nil { return err } // FIXME: this reading can give error: file name too long - link, err := os.Readlink(p.pathExe) + link, err := os.Readlink(p.procPath[Exe]) if err != nil { return err } @@ -226,7 +226,7 @@ func (p *Process) ReadPath() error { func (p *Process) SetPath(path string) { p.Path = path p.CleanPath() - p.RealPath = core.ConcatStrings(p.pathRoot, "/", p.Path) + p.RealPath = core.ConcatStrings(p.procPath[Root], "/", p.Path) if core.Exists(p.RealPath) == false { p.RealPath = p.Path // p.CleanPath() ? @@ -236,13 +236,13 @@ func (p *Process) SetPath(path string) { // ReadCmdline reads the cmdline of the process from ProcFS /proc//cmdline // This file may be empty if the process is of a kernel task. // It can also be empty for short-lived processes. -func (p *Process) ReadCmdline() { +func (p *Process) ReadCmdline() error { if len(p.Args) > 0 { - return + return nil } - data, err := ioutil.ReadFile(p.pathCmdline) + data, err := ioutil.ReadFile(p.procPath[Cmdline]) if err != nil || len(data) == 0 { - return + return fmt.Errorf("%s empty", p.procPath[Cmdline]) } // XXX: remove this loop, and split by "\x00" for i, b := range data { @@ -259,6 +259,8 @@ func (p *Process) ReadCmdline() { } } p.CleanArgs() + + return nil } // CleanArgs applies fixes on the cmdline arguments. @@ -271,7 +273,7 @@ func (p *Process) CleanArgs() { } func (p *Process) readDescriptors() { - f, err := os.Open(p.pathFd) + f, err := os.Open(p.procPath[Fd]) if err != nil { return } @@ -283,7 +285,7 @@ func (p *Process) readDescriptors() { tempFd := &procDescriptors{ Name: fd.Name(), } - link, err := os.Readlink(core.ConcatStrings(p.pathFd, fd.Name())) + link, err := os.Readlink(core.ConcatStrings(p.procPath[Fd], fd.Name())) if err != nil { continue } @@ -311,7 +313,7 @@ func (p *Process) readDescriptors() { } func (p *Process) readIOStats() (err error) { - f, err := os.Open(p.pathIO) + f, err := os.Open(p.procPath[IO]) if err != nil { return err } @@ -342,19 +344,19 @@ func (p *Process) readIOStats() (err error) { } func (p *Process) readStatus() { - if data, err := ioutil.ReadFile(p.pathStatus); err == nil { + if data, err := ioutil.ReadFile(p.procPath[Status]); err == nil { p.Status = string(data) } - if data, err := ioutil.ReadFile(p.pathStat); err == nil { + if data, err := ioutil.ReadFile(p.procPath[Stat]); err == nil { p.Stat = string(data) } if data, err := ioutil.ReadFile(core.ConcatStrings("/proc/", strconv.Itoa(p.ID), "/stack")); err == nil { p.Stack = string(data) } - if data, err := ioutil.ReadFile(p.pathMaps); err == nil { + if data, err := ioutil.ReadFile(p.procPath[Maps]); err == nil { p.Maps = string(data) } - if data, err := ioutil.ReadFile(p.pathStatm); err == nil { + if data, err := ioutil.ReadFile(p.procPath[Statm]); err == nil { p.Statm = &procStatm{} fmt.Sscanf(string(data), "%d %d %d %d %d %d %d", &p.Statm.Size, &p.Statm.Resident, &p.Statm.Shared, &p.Statm.Text, &p.Statm.Lib, &p.Statm.Data, &p.Statm.Dt) } @@ -372,7 +374,7 @@ func (p *Process) CleanPath() { // to any process. // Therefore we cannot use /proc/self/exe directly, because it resolves to our own process. if strings.HasPrefix(p.Path, ProcSelf) { - if link, err := os.Readlink(p.pathExe); err == nil { + if link, err := os.Readlink(p.procPath[Exe]); err == nil { p.Path = link return } @@ -390,7 +392,7 @@ func (p *Process) CleanPath() { } // We may receive relative paths from kernel, but the path of a process must be absolute - if core.IsAbsPath(p.Path) == false { + if pathLen > 0 && core.IsAbsPath(p.Path) == false { if err := p.ReadPath(); err != nil { log.Debug("ClenPath() error reading process path%s", err) return @@ -401,7 +403,7 @@ func (p *Process) CleanPath() { // IsAlive checks if the process is still running func (p *Process) IsAlive() bool { - return core.Exists(p.pathProc) + return core.Exists(p.procPath[ProcID]) } // IsChild determines if this process is child of its parent @@ -459,7 +461,7 @@ func (p *Process) ComputeChecksum(algo string) { // Path cannot be trusted, because multiple processes with the same path // can coexist in different namespaces. // The real path is /proc//root/ - paths := []string{p.pathExe, p.RealPath, p.Path} + paths := []string{p.procPath[Exe], p.RealPath, p.Path} var h hash.Hash if algo == HashMD5 { @@ -530,7 +532,7 @@ func (p *Process) dumpFileImage(filePath string) ([]byte, error) { var mappings []MemoryMapping // read memory mappings - mapsFile, err := os.Open(p.pathMaps) + mapsFile, err := os.Open(p.procPath[Maps]) if err != nil { return nil, err } @@ -593,7 +595,7 @@ func (p *Process) dumpFileImage(filePath string) ([]byte, error) { // given a range of addrs, read it from mem and return the content func (p *Process) readMem(mappings []MemoryMapping) ([]byte, error) { var elfCode []byte - memFile, err := os.Open(p.pathMem) + memFile, err := os.Open(p.procPath[Mem]) if err != nil { return nil, err } diff --git a/daemon/procmon/ebpf/ebpf.go b/daemon/procmon/ebpf/ebpf.go index 5ad83f7106..45310f7518 100644 --- a/daemon/procmon/ebpf/ebpf.go +++ b/daemon/procmon/ebpf/ebpf.go @@ -6,25 +6,28 @@ import ( "fmt" "sync" "syscall" + "time" "unsafe" "github.com/evilsocket/opensnitch/daemon/core" "github.com/evilsocket/opensnitch/daemon/log" daemonNetlink "github.com/evilsocket/opensnitch/daemon/netlink" "github.com/evilsocket/opensnitch/daemon/procmon" + "github.com/evilsocket/opensnitch/daemon/ui/protocol" elf "github.com/iovisor/gobpf/elf" "github.com/vishvananda/netlink" + "golang.org/x/sys/unix" ) -//contains pointers to ebpf maps for a given protocol (tcp/udp/v6) +// contains pointers to ebpf maps for a given protocol (tcp/udp/v6) type ebpfMapsForProto struct { bpfmap *elf.Map } //Not in use, ~4usec faster lookup compared to m.LookupElement() -//mimics union bpf_attr's anonymous struct used by BPF_MAP_*_ELEM commands -//from /include/uapi/linux/bpf.h +// mimics union bpf_attr's anonymous struct used by BPF_MAP_*_ELEM commands +// from /include/uapi/linux/bpf.h type bpf_lookup_elem_t struct { map_fd uint64 //even though in bpf.h its type is __u32, we must make it 8 bytes long //because "key" is of type __aligned_u64, i.e. "key" must be aligned on an 8-byte boundary @@ -47,8 +50,8 @@ const ( // Error returns the error type and a message with the explanation type Error struct { - What int // 1 global error, 2 events error, 3 ... Msg error + What int // 1 global error, 2 events error, 3 ... } var ( @@ -76,22 +79,22 @@ var ( hostByteOrder binary.ByteOrder ) -//Start installs ebpf kprobes +// Start installs ebpf kprobes func Start(modPath string) *Error { modulesPath = modPath setRunning(false) if err := mountDebugFS(); err != nil { return &Error{ - NotAvailable, fmt.Errorf("ebpf.Start: mount debugfs error. Report on github please: %s", err), + NotAvailable, } } var err error m, err = core.LoadEbpfModule("opensnitch.o", modulesPath) if err != nil { dispatchErrorEvent(fmt.Sprint("[eBPF]: ", err.Error())) - return &Error{NotAvailable, fmt.Errorf("[eBPF] Error loading opensnitch.o: %s", err.Error())} + return &Error{fmt.Errorf("[eBPF] Error loading opensnitch.o: %s", err.Error()), NotAvailable} } m.EnableOptionCompatProbe() @@ -101,10 +104,10 @@ func Start(modPath string) *Error { if err := m.EnableKprobes(0); err != nil { m.Close() if err := m.Load(nil); err != nil { - return &Error{NotAvailable, fmt.Errorf("eBPF failed to load /etc/opensnitchd/opensnitch.o (2): %v", err)} + return &Error{fmt.Errorf("eBPF failed to load /etc/opensnitchd/opensnitch.o (2): %v", err), NotAvailable} } if err := m.EnableKprobes(0); err != nil { - return &Error{NotAvailable, fmt.Errorf("eBPF error when enabling kprobes: %v", err)} + return &Error{fmt.Errorf("eBPF error when enabling kprobes: %v", err), NotAvailable} } } determineHostByteOrder() @@ -121,7 +124,7 @@ func Start(modPath string) *Error { } for prot, mfp := range ebpfMaps { if mfp.bpfmap == nil { - return &Error{NotAvailable, fmt.Errorf("eBPF module opensnitch.o malformed, bpfmap[%s] nil", prot)} + return &Error{fmt.Errorf("eBPF module opensnitch.o malformed, bpfmap[%s] nil", prot), NotAvailable} } } @@ -200,7 +203,7 @@ func Stop() { } } -//make bpf() syscall with bpf_lookup prepared by the caller +// make bpf() syscall with bpf_lookup prepared by the caller func makeBpfSyscall(bpf_lookup *bpf_lookup_elem_t) uintptr { BPF_MAP_LOOKUP_ELEM := 1 //cmd number syscall_BPF := 321 //syscall number @@ -211,9 +214,64 @@ func makeBpfSyscall(bpf_lookup *bpf_lookup_elem_t) uintptr { return r1 } +// dispatch a rx/tx event to the server +func dispatchRxTxEvent(proc *procmon.Process, proto uint32, fam uint8, bsent, brecv uint64) { + protoProc := proc.Serialize() + protoStr := "tcp" + if proto == unix.IPPROTO_UDP { + protoStr = "udp" + } + family := "" + if fam == unix.AF_INET6 { + family = "6" + } + // send only the bytes received of the packet(s), not the totals of the process. + protoProc.BytesSent = map[string]uint64{protoStr + family: bsent} + protoProc.BytesRecv = map[string]uint64{protoStr + family: brecv} + log.Debug("dispatchProcExit, proto: %s, sent: %d, recv: %d", protoStr, bsent, brecv) + + dispatchEvent( + &protocol.Alert{ + Id: uint64(time.Now().UnixNano()), + Type: protocol.Alert_INFO, + Action: protocol.Alert_SAVE_TO_DB, + What: protocol.Alert_KERNEL_NET_RXTX, + // TODO: send a KernelEvent{ KernelNetEvent } + Data: &protocol.Alert_Proc{ + protoProc, + }, + }, + ) +} + +func dispatchProcExitEvent(proc *procmon.Process, proto uint32, fam uint8, bsent, brecv uint64) { + protoProc := proc.Serialize() + log.Debug("dispatchProcExit: %s", proc.Path) + + dispatchEvent( + &protocol.Alert{ + Id: uint64(time.Now().UnixNano()), + Type: protocol.Alert_INFO, + Action: protocol.Alert_SAVE_TO_DB, + What: protocol.Alert_KERNEL_PROC_EXIT, + Data: &protocol.Alert_Proc{ + protoProc, + }, + }, + ) +} + func dispatchErrorEvent(what string) { log.Error(what) - dispatchEvent(what) + dispatchEvent( + &protocol.Alert{ + Id: uint64(time.Now().UnixNano()), + Type: protocol.Alert_ERROR, + Action: protocol.Alert_SHOW_ALERT, + What: protocol.Alert_GENERIC, + Data: &protocol.Alert_Text{what}, + }, + ) } func dispatchEvent(data interface{}) { diff --git a/daemon/procmon/ebpf/events.go b/daemon/procmon/ebpf/events.go index e8bd281f83..4e0cc66284 100644 --- a/daemon/procmon/ebpf/events.go +++ b/daemon/procmon/ebpf/events.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/signal" + "unsafe" "github.com/evilsocket/opensnitch/daemon/core" "github.com/evilsocket/opensnitch/daemon/log" @@ -43,10 +44,31 @@ type execEvent struct { Pad2 uint32 } +type netEventT struct { + Type uint64 + SaddrV6 uint64 + DaddrV6 uint64 + Cookie uint64 + BytesSent uint64 + BytesRecv uint64 + LastSeen uint64 + PID uint32 + UID uint32 + PPID uint32 + Proto uint32 + + Saddr uint32 + Daddr uint32 + Sport uint16 + Dport uint16 + Fam uint8 +} + // Struct that holds the metadata of a connection. -// When we receive a new connection, we look for it on the eBPF maps, -// and if it's found, this information is returned. -type networkEventT struct { +// When we receive a new connection via nfqueue, we look for it on the +// eBPF maps by the key srcport+srcip+dstip+dstport. +// If it's found, the following struct/info is returned (defined in opensnitch.c). +type connEventT struct { Pid uint64 UID uint64 Comm [TaskCommLen]byte @@ -60,6 +82,10 @@ const ( EV_TYPE_EXECVEAT EV_TYPE_FORK EV_TYPE_SCHED_EXIT + EV_TYPE_TCP_CONN_DESTROYED + EV_TYPE_UDP_CONN_DESTROYED + EV_TYPE_RECV_BYTES + EV_TYPE_SENT_BYTES ) var ( @@ -69,7 +95,7 @@ var ( perfMapName = "proc-events" // default value is 8. - // Not enough to handle high loads such http downloads, torent traffic, etc. + // Not enough to handle "high loads" such http downloads, torrent traffic, etc. // (regular desktop usage) ringBuffSize = 64 // * PAGE_SIZE (4k usually) ) @@ -81,13 +107,13 @@ func initEventsStreamer() *Error { perfMod, err = core.LoadEbpfModule("opensnitch-procs.o", modulesPath) if err != nil { dispatchErrorEvent(fmt.Sprint("[eBPF events]: ", err)) - return &Error{EventsNotAvailable, err} + return &Error{err, EventsNotAvailable} } perfMod.EnableOptionCompatProbe() if err = perfMod.Load(elfOpts); err != nil { dispatchErrorEvent(fmt.Sprint("[eBPF events]: ", err)) - return &Error{EventsNotAvailable, err} + return &Error{err, EventsNotAvailable} } tracepoints := []string{ @@ -115,7 +141,7 @@ Verify that your kernel has support for tracepoints (opensnitchd -check-requirem perfMod.Close() if err = perfMod.Load(elfOpts); err != nil { dispatchErrorEvent(fmt.Sprintf("[eBPF events] failed to load /etc/opensnitchd/opensnitch-procs.o (2): %v", err)) - return &Error{EventsNotAvailable, err} + return &Error{err, EventsNotAvailable} } if err = perfMod.EnableKprobes(0); err != nil { dispatchErrorEvent(fmt.Sprintf("[eBPF events] error enabling kprobes: %v", err)) @@ -130,7 +156,7 @@ Verify that your kernel has support for tracepoints (opensnitchd -check-requirem eventWorkers = 0 if err := initPerfMap(perfMod); err != nil { - return &Error{EventsNotAvailable, err} + return &Error{err, EventsNotAvailable} } return nil @@ -158,19 +184,64 @@ func initPerfMap(mod *elf.Module) error { func streamEventsWorker(id int, chn chan []byte, lost chan uint64, kernelEvents chan interface{}) { var event execEvent + var netEvent netEventT + var buf bytes.Buffer + for { + event = execEvent{} + netEvent = netEventT{} + buf.Reset() + select { case <-ctxTasks.Done(): goto Exit case l := <-lost: log.Debug("Lost ebpf events: %d", l) - case d := <-chn: - if err := binary.Read(bytes.NewBuffer(d), hostByteOrder, &event); err != nil { - log.Debug("[eBPF events #%d] error: %s", id, err) - continue + case incomingEvent := <-chn: + switch incomingEvent[0] { + case EV_TYPE_SENT_BYTES, + EV_TYPE_RECV_BYTES, + EV_TYPE_TCP_CONN_DESTROYED, + EV_TYPE_UDP_CONN_DESTROYED: + + buf.Write(incomingEvent) + if err := binary.Read(&buf, hostByteOrder, &netEvent); err != nil { + log.Debug("[eBPF NET events #%d] netbytes error: %s", id, err) + continue + } + default: + buf.Write(incomingEvent) + if err := binary.Read(&buf, hostByteOrder, &event); err != nil { + log.Debug("[eBPF events #%d] error: %s, event: %d", id, err, incomingEvent[0]) + continue + } } - switch event.Type { + switch incomingEvent[0] { + case EV_TYPE_SENT_BYTES, EV_TYPE_RECV_BYTES: + //dstIP := make(net.IP, 4) + //srcIP := make(net.IP, 4) + //binary.BigEndian.PutUint32(srcIP, netEvent.Saddr) + log.Debug("[eBPF events recv/sent]: %d, pid: %d, proto: %d sport: %d -> dport: %d, bytes_sent: %d, bytes_recv: %d", netEvent.Type, netEvent.PID, netEvent.Proto, netEvent.Sport, netEvent.Dport, netEvent.BytesSent, netEvent.BytesRecv) + item, found := procmon.EventsCache.IsInStoreByPID(int(netEvent.PID)) + if found { + dispatchRxTxEvent(&item.Proc, netEvent.Proto, netEvent.Fam, netEvent.BytesSent, netEvent.BytesRecv) + // TODO: Proc.AddBytes? to apply quotas more rapidly? + procmon.EventsCache.UpdateItem(&item.Proc) + continue + } + + case EV_TYPE_TCP_CONN_DESTROYED, EV_TYPE_UDP_CONN_DESTROYED: + log.Debug("[eBPF events conn destroyed]: %d, pid: %d, proto: %d sport: %d -> dport: %d, bytes_sent: %d, bytes_recv: %d", netEvent.Type, netEvent.PID, netEvent.Proto, netEvent.Sport, netEvent.Dport, netEvent.BytesSent, netEvent.BytesRecv) + item, found := procmon.EventsCache.IsInStoreByPID(int(netEvent.PID)) + if found { + dispatchRxTxEvent(&item.Proc, netEvent.Proto, netEvent.Fam, netEvent.BytesSent, netEvent.BytesRecv) + item.Proc.AddBytes(netEvent.Fam, netEvent.Proto, netEvent.BytesSent, netEvent.BytesRecv) + procmon.EventsCache.UpdateItem(&item.Proc) + + continue + } + case EV_TYPE_EXEC, EV_TYPE_EXECVEAT: processExecEvent(&event) @@ -259,4 +330,7 @@ func getProcDetails(event *execEvent, proc *procmon.Process) { func processExitEvent(event *execEvent) { log.Debug("[eBPF exit event] pid: %d, ppid: %d", event.PID, event.PPID) procmon.EventsCache.Delete(int(event.PID)) + + m.DeleteElement(perfMod.Map("tcpBytesMap"), unsafe.Pointer(&event.PID)) + m.DeleteElement(perfMod.Map("udpBytesMap"), unsafe.Pointer(&event.PID)) } diff --git a/daemon/procmon/ebpf/find.go b/daemon/procmon/ebpf/find.go index 0890e1c354..9caf98e3c3 100644 --- a/daemon/procmon/ebpf/find.go +++ b/daemon/procmon/ebpf/find.go @@ -83,7 +83,7 @@ func getPidFromEbpf(proto string, srcPort uint, srcIP net.IP, dstIP net.IP, dstP return } - var value networkEventT + var value connEventT var key []byte var isIP4 bool = (proto == "tcp") || (proto == "udp") || (proto == "udplite") @@ -163,7 +163,7 @@ func getPidFromEbpf(proto string, srcPort uint, srcIP net.IP, dstIP net.IP, dstP // By default we only receive the PID of the process, so we need to get // the rest of the details. // TODO: get the details from kernel, with mm_struct (exe_file, fd_path, etc). -func findConnProcess(value *networkEventT, connKey string) (proc *procmon.Process) { +func findConnProcess(value *connEventT, connKey string) (proc *procmon.Process) { // Use socket's UID. A process may have dropped privileges. // This is the UID that we've always used. diff --git a/daemon/procmon/ebpf/utils.go b/daemon/procmon/ebpf/utils.go index 874e858642..b392bd467e 100644 --- a/daemon/procmon/ebpf/utils.go +++ b/daemon/procmon/ebpf/utils.go @@ -77,7 +77,7 @@ func getItems(proto string, isIPv6 bool) (items uint) { lookupKey = make([]byte, 36) nextKey = make([]byte, 36) } - var value networkEventT + var value connEventT firstrun := true for { @@ -122,7 +122,7 @@ func deleteOldItems(proto string, isIPv6 bool, maxToDelete uint) (deleted uint) lookupKey = make([]byte, 36) nextKey = make([]byte, 36) } - var value networkEventT + var value connEventT firstrun := true i := uint(0) diff --git a/daemon/procmon/process.go b/daemon/procmon/process.go index d1edc97aca..54971df1dd 100644 --- a/daemon/procmon/process.go +++ b/daemon/procmon/process.go @@ -8,6 +8,7 @@ import ( "github.com/evilsocket/opensnitch/daemon/core" "github.com/evilsocket/opensnitch/daemon/ui/protocol" + "golang.org/x/sys/unix" ) var ( @@ -29,6 +30,24 @@ const ( HashSHA1 = "process.hash.sha1" ) +// .. +const ( + ProcID = iota + Comm + Cmdline + Exe + Cwd + Environ + Root + Status + Statm + Stat + Mem + Maps + Fd + IO +) + // man 5 proc; man procfs type procIOstats struct { RChar int64 @@ -61,34 +80,30 @@ type procStatm struct { Dt int } +type procBytes struct { + sent uint64 + recv uint64 + proto uint8 + fam uint8 +} + // Process holds the details of a process. type Process struct { - mu *sync.RWMutex - Statm *procStatm - Parent *Process - IOStats *procIOstats - NetStats *procNetStats - Env map[string]string - Checksums map[string]string - Status string - Stat string - Stack string - Maps string - Comm string - pathProc string - pathComm string - pathExe string - pathCmdline string - pathCwd string - pathEnviron string - pathRoot string - pathFd string - pathStatus string - pathStatm string - pathStat string - pathMaps string - pathMem string - pathIO string + mu *sync.RWMutex + Statm *procStatm + Parent *Process + IOStats *procIOstats + NetStats *procNetStats + Env map[string]string + BytesSent map[string]uint64 + BytesRecv map[string]uint64 + Checksums map[string]string + CWD string + Status string + Stat string + Stack string + Maps string + Comm string // Path is the absolute path to the binary Path string @@ -96,8 +111,8 @@ type Process struct { // RealPath is the path to the binary taking into account its root fs. // The simplest form of accessing the RealPath is by prepending /proc//root/ to the path: // /usr/bin/curl -> /proc//root/usr/bin/curl - RealPath string - CWD string + RealPath string + Tree []*protocol.StringInt Descriptors []*procDescriptors // Args is the command that the user typed. It MAY contain the absolute path @@ -109,6 +124,7 @@ type Process struct { // -> Path: /usr/bin/curl // -> Args: /usr/bin/curl https://.... Args []string + procPath []string Starttime int64 ID int PPID int @@ -124,27 +140,31 @@ func NewProcessEmpty(pid int, comm string) *Process { PPID: 0, Comm: comm, Args: make([]string, 0), + procPath: make([]string, 14), Env: make(map[string]string), + BytesSent: make(map[string]uint64, 2), + BytesRecv: make(map[string]uint64, 2), Tree: make([]*protocol.StringInt, 0), IOStats: &procIOstats{}, NetStats: &procNetStats{}, Statm: &procStatm{}, Checksums: make(map[string]string), } - p.pathProc = core.ConcatStrings("/proc/", strconv.Itoa(p.ID)) - p.pathExe = core.ConcatStrings(p.pathProc, "/exe") - p.pathCwd = core.ConcatStrings(p.pathProc, "/cwd") - p.pathComm = core.ConcatStrings(p.pathProc, "/comm") - p.pathCmdline = core.ConcatStrings(p.pathProc, "/cmdline") - p.pathEnviron = core.ConcatStrings(p.pathProc, "/environ") - p.pathStatus = core.ConcatStrings(p.pathProc, "/status") - p.pathStatm = core.ConcatStrings(p.pathProc, "/statm") - p.pathRoot = core.ConcatStrings(p.pathProc, "/root") - p.pathMaps = core.ConcatStrings(p.pathProc, "/maps") - p.pathStat = core.ConcatStrings(p.pathProc, "/stat") - p.pathMem = core.ConcatStrings(p.pathProc, "/mem") - p.pathFd = core.ConcatStrings(p.pathProc, "/fd/") - p.pathIO = core.ConcatStrings(p.pathProc, "/io") + + p.procPath[ProcID] = core.ConcatStrings("/proc/", strconv.Itoa(p.ID)) + p.procPath[Exe] = core.ConcatStrings(p.procPath[ProcID], "/exe") + p.procPath[Cwd] = core.ConcatStrings(p.procPath[ProcID], "/cwd") + p.procPath[Comm] = core.ConcatStrings(p.procPath[ProcID], "/comm") + p.procPath[Cmdline] = core.ConcatStrings(p.procPath[ProcID], "/cmdline") + p.procPath[Environ] = core.ConcatStrings(p.procPath[ProcID], "/environ") + p.procPath[Status] = core.ConcatStrings(p.procPath[ProcID], "/status") + p.procPath[Statm] = core.ConcatStrings(p.procPath[ProcID], "/statm") + p.procPath[Root] = core.ConcatStrings(p.procPath[ProcID], "/root") + p.procPath[Maps] = core.ConcatStrings(p.procPath[ProcID], "/maps") + p.procPath[Stat] = core.ConcatStrings(p.procPath[ProcID], "/stat") + p.procPath[Mem] = core.ConcatStrings(p.procPath[ProcID], "/mem") + p.procPath[Fd] = core.ConcatStrings(p.procPath[ProcID], "/fd/") + p.procPath[IO] = core.ConcatStrings(p.procPath[ProcID], "/io") return p } @@ -195,8 +215,29 @@ func (p *Process) RUnlock() { p.mu.RUnlock() } -//Serialize transforms a Process object to gRPC protocol object +// AddBytes accumulates the bytes sent by this process +func (p *Process) AddBytes(fam uint8, proto uint32, sent, recv uint64) { + p.mu.Lock() + defer p.mu.Unlock() + + protoStr := "tcp" + if proto == unix.IPPROTO_UDP { + protoStr = "udp" + } + family := "" + if fam == unix.AF_INET6 { + family = "6" + } + + p.BytesSent[protoStr+family] += recv + p.BytesRecv[protoStr+family] += recv +} + +// Serialize transforms a Process object to gRPC protocol object func (p *Process) Serialize() *protocol.Process { + p.mu.RLock() + defer p.mu.RUnlock() + ioStats := p.IOStats netStats := p.NetStats if ioStats == nil { @@ -206,21 +247,34 @@ func (p *Process) Serialize() *protocol.Process { netStats = &procNetStats{} } + // maps are referenced data types, we cannot assign a map to another + // an expect to be a copy. + bsent := make(map[string]uint64, len(p.BytesSent)) + brecv := make(map[string]uint64, len(p.BytesRecv)) + for k, v := range p.BytesSent { + bsent[k] = v + } + for k, v := range p.BytesRecv { + brecv[k] = v + } + return &protocol.Process{ - Pid: uint64(p.ID), - Ppid: uint64(p.PPID), - Uid: uint64(p.UID), - Comm: p.Comm, - Path: p.Path, - Args: p.Args, - Env: p.Env, - Cwd: p.CWD, - Checksums: p.Checksums, - IoReads: uint64(ioStats.RChar), - IoWrites: uint64(ioStats.WChar), - NetReads: netStats.ReadBytes, - NetWrites: netStats.WriteBytes, - ProcessTree: p.Tree, + Pid: uint64(p.ID), + Ppid: uint64(p.PPID), + Uid: uint64(p.UID), + Comm: p.Comm, + Path: p.Path, + Args: p.Args, + Env: p.Env, + Cwd: p.CWD, + Checksums: p.Checksums, + IoReads: uint64(ioStats.RChar), + IoWrites: uint64(ioStats.WChar), + NetReads: netStats.ReadBytes, + NetWrites: netStats.WriteBytes, + BytesSent: bsent, + BytesRecv: brecv, + Tree: p.Tree, } } diff --git a/daemon/rule/operator.go b/daemon/rule/operator.go index 0634c226cd..3f6c53e405 100644 --- a/daemon/rule/operator.go +++ b/daemon/rule/operator.go @@ -32,6 +32,7 @@ const ( List = Type("list") Network = Type("network") Lists = Type("lists") + Quota = Type("Quota") ) // Available operands @@ -62,9 +63,9 @@ const ( OpNetLists = Operand("lists.nets") // TODO //OpHashMD5Lists = Operand("lists.hash.md5") - //OpQuota = Operand("quota") - //OpQuotaTxOver = Operand("quota.sent.over") // 1000b, 1kb, 1mb, 1gb, ... - //OpQuotaRxOver = Operand("quota.recv.over") // 1000b, 1kb, 1mb, 1gb, ... + OpQuota = Operand("quota") + OpQuotaTxOver = Operand("quota.sent.over") // 1000b, 1kb, 1mb, 1gb, ... + OpQuotaRxOver = Operand("quota.recv.over") // 1000b, 1kb, 1mb, 1gb, ... ) type opCallback func(value interface{}) bool @@ -154,6 +155,33 @@ func (o *Operator) Compile() error { o.cb = o.ipNetCmp } else if o.Operand == OpProcessHashMD5 || o.Operand == OpProcessHashSHA1 { o.cb = o.hashCmp + } else if o.Operand == OpQuotaTxOver { + if o.Data == "" { + return fmt.Errorf("Operand quota cannot be empty: %s", o) + } + o.cb = o.quotaCmp + if strings.HasSuffix(o.Data, "kb") { + if val, err := strconv.Atoi(o.Data[:len(o.Data)-2]); err == nil { + o.Data = fmt.Sprint(val * 1024) + } + } + if strings.HasSuffix(o.Data, "mb") { + if val, err := strconv.Atoi(o.Data[:len(o.Data)-2]); err == nil { + o.Data = fmt.Sprint((val * 1024) * 1024) + } + } + if strings.HasSuffix(o.Data, "gb") { + if val, err := strconv.Atoi(o.Data[:len(o.Data)-2]); err == nil { + o.Data = fmt.Sprint(((val * 1024) * 1024) * 1024) + } + } + if strings.HasSuffix(o.Data, "tb") { + if val, err := strconv.Atoi(o.Data[:len(o.Data)-2]); err == nil { + o.Data = fmt.Sprint((((val * 1024) * 1024) * 1024) * 1024) + } + } else { + o.Data = o.Data[:len(o.Data)-1] + } } log.Debug("Operator compiled: %s", o) o.isCompiled = true @@ -176,6 +204,38 @@ func (o *Operator) simpleCmp(v interface{}) bool { return v == o.Data } +// quotaCmp +// 1. compare sent/recv bytes +// 2. on bytes over quota: +// - reset proc bytes +// - send alert +// - change action to deny ¿? +// 3. if comparison matches, apply defined action: over quota? -> reject, until quota? -> allow +func (o *Operator) quotaCmp(v interface{}) bool { + + con, ok := v.(*conman.Connection) + if !ok { + return false + } + // XXX: get rid of this conversion on Compile()? + b, err := strconv.ParseUint(o.Data, 10, 64) + if err != nil { + return false + } + bsent, _ := con.Process.BytesSent[con.Protocol] + brecv, _ := con.Process.BytesRecv[con.Protocol] + result := false + // quota.over + result = bsent > b || brecv > b + + // quota.sent.over + //result = bsent > b + // quota.until.over + //result = bsent < b + + return result +} + func (o *Operator) reCmp(v interface{}) bool { if vt := reflect.ValueOf(v).Kind(); vt != reflect.String { log.Warning("Operator.reCmp() bad interface type: %T", v) @@ -341,6 +401,8 @@ func (o *Operator) Match(con *conman.Connection, hasChecksums bool) bool { return o.cb(strconv.FormatUint(uint64(con.SrcPort), 10)) } else if o.Operand == OpProcessID { return o.cb(strconv.Itoa(con.Process.ID)) + } else if o.Operand == OpQuotaTxOver { + return o.cb(con) } else if strings.HasPrefix(string(o.Operand), string(OpProcessEnvPrefix)) { envVarName := core.Trim(string(o.Operand[OpProcessEnvPrefixLen:])) envVarValue, _ := con.Process.Env[envVarName] diff --git a/daemon/ui/alerts.go b/daemon/ui/alerts.go index 1496979ae7..24d2d4e35f 100644 --- a/daemon/ui/alerts.go +++ b/daemon/ui/alerts.go @@ -33,6 +33,14 @@ func NewAlert(atype protocol.Alert_Type, what protocol.Alert_What, action protoc } switch what { + case protocol.Alert_KERNEL_PROC_EXIT: + switch data.(type) { + case procmon.Process: + a.Data = &protocol.Alert_Proc{ + data.(*procmon.Process).Serialize(), + } + } + case protocol.Alert_KERNEL_EVENT: switch data.(type) { diff --git a/daemon/ui/client.go b/daemon/ui/client.go index 68354c9919..70dadf465b 100644 --- a/daemon/ui/client.go +++ b/daemon/ui/client.go @@ -360,7 +360,12 @@ func (c *Client) PostAlert(atype protocol.Alert_Type, awhat protocol.Alert_What, if c.Connected() == false { log.Debug("UI not connected, queueing alert: %d", len(c.alertsChan)) } - c.alertsChan <- *NewAlert(atype, awhat, action, prio, data) + switch data.(type) { + case *protocol.Alert: + c.alertsChan <- *(data.(*protocol.Alert)) + default: + c.alertsChan <- *NewAlert(atype, awhat, action, prio, data) + } } func (c *Client) monitorConfigWorker() { diff --git a/daemon/ui/config_utils.go b/daemon/ui/config_utils.go index dadd2fe3da..c67e86b856 100644 --- a/daemon/ui/config_utils.go +++ b/daemon/ui/config_utils.go @@ -106,16 +106,19 @@ func (c *Client) loadConfiguration(rawConfig []byte) bool { } c.setSocketPath(tempSocketPath) } + if clientConfig.DefaultAction != "" { clientDisconnectedRule.Action = rule.Action(clientConfig.DefaultAction) clientErrorRule.Action = rule.Action(clientConfig.DefaultAction) // TODO: reconfigure connected rule if changed, but not save it to disk. //clientConnectedRule.Action = rule.Action(clientConfig.DefaultAction) } + if clientConfig.DefaultDuration != "" { clientDisconnectedRule.Duration = rule.Duration(clientConfig.DefaultDuration) clientErrorRule.Duration = rule.Duration(clientConfig.DefaultDuration) } + if clientConfig.ProcMonitorMethod != "" { err := monitor.ReconfigureMonitorMethod(clientConfig.ProcMonitorMethod, clientConfig.Ebpf.ModulesPath) if err != nil { diff --git a/ebpf_prog/common.h b/ebpf_prog/common.h index 70d3dea97a..bdbfa7b357 100644 --- a/ebpf_prog/common.h +++ b/ebpf_prog/common.h @@ -3,6 +3,7 @@ #include "common_defs.h" + //https://elixir.bootlin.com/linux/latest/source/include/uapi/linux/limits.h#L13 #ifndef MAX_PATH_LEN #define MAX_PATH_LEN 4096 @@ -32,6 +33,10 @@ enum events_type { EVENT_EXECVEAT, EVENT_FORK, EVENT_SCHED_EXIT, + EVENT_TCP_CONN_DESTROYED, + EVENT_UDP_CONN_DESTROYED, + EVENT_RECV_BYTES, + EVENT_SEND_BYTES }; struct trace_ev_common { @@ -41,6 +46,20 @@ struct trace_ev_common { int common_pid; }; +struct trace_tcp_destroy_sock { + struct trace_ev_common ext; + + const void * skaddr; + u16 sport; + u16 dport; + u16 family; + u8 saddr[4]; + u8 daddr[4]; + u8 saddr_v6[16]; + u8 daddr_v6[16]; + u64 cookie; +}; + struct trace_sys_enter_execve { struct trace_ev_common ext; @@ -84,14 +103,50 @@ struct data_t { u32 pad2; }; +struct network_event_t { + u64 type; + u64 saddr_v6; + u64 daddr_v6; + u64 cookie; + u64 bytes_sent; + u64 bytes_recv; + u64 last_sent; + u32 pid; + u32 uid; + u32 ppid; + u32 proto; + + u32 saddr; + u32 daddr; + u16 sport; + u16 dport; + u8 family; +}; + //----------------------------------------------------------------------------- // maps struct bpf_map_def SEC("maps/heapstore") heapstore = { - .type = BPF_MAP_TYPE_PERCPU_ARRAY, - .key_size = sizeof(u32), - .value_size = sizeof(struct data_t), - .max_entries = 1 + .type = BPF_MAP_TYPE_PERCPU_ARRAY, + .key_size = sizeof(u32), + .value_size = sizeof(struct data_t), + .max_entries = 1 +}; + +struct bpf_map_def SEC("maps/tcpBytesMap") tcpBytesMap = { + .type = BPF_MAP_TYPE_HASH, + .key_size = sizeof(u64), + .value_size = sizeof(struct network_event_t), + .max_entries = 13000, }; +struct bpf_map_def SEC("maps/udpBytesMap") udpBytesMap = { + .type = BPF_MAP_TYPE_HASH, + .key_size = sizeof(u64), + .value_size = sizeof(struct network_event_t), + .max_entries = 13001, +}; + + + #endif diff --git a/ebpf_prog/opensnitch-procs.c b/ebpf_prog/opensnitch-procs.c index 2da48f7c5b..3b33a5f905 100644 --- a/ebpf_prog/opensnitch-procs.c +++ b/ebpf_prog/opensnitch-procs.c @@ -1,6 +1,7 @@ #define KBUILD_MODNAME "opensnitch-procs" #include "common.h" +#include struct bpf_map_def SEC("maps/proc-events") events = { // Since kernel 4.4 @@ -11,10 +12,10 @@ struct bpf_map_def SEC("maps/proc-events") events = { }; struct bpf_map_def SEC("maps/execMap") execMap = { - .type = BPF_MAP_TYPE_HASH, - .key_size = sizeof(u32), - .value_size = sizeof(struct data_t), - .max_entries = 256, + .type = BPF_MAP_TYPE_HASH, + .key_size = sizeof(u64), + .value_size = sizeof(struct data_t), + .max_entries = 257, }; @@ -56,6 +57,149 @@ static __always_inline void __handle_exit_execve(struct trace_sys_exit_execve *c bpf_map_delete_elem(&execMap, &pid_tgid); } +static int __always_inline __handle_destroy_sock(struct pt_regs *ctx, short proto, short fam) +{ +#if defined(__i386__) + // On x86_32 platforms accessing arguments using PT_REGS_PARM1 seems to cause probles. + // That's why we are accessing registers directly. + struct sock *sk = (struct sock *)((ctx)->ax); +#else + struct sock *sk = (struct sock *)PT_REGS_PARM1(ctx); +#endif + bpf_probe_read(&fam, sizeof(fam), &sk->__sk_common.skc_family); + if (fam != AF_INET && fam != AF_INET6){ + return 0; + } + + struct network_event_t *net_event={0}; + u64 pid_tgid = bpf_get_current_pid_tgid(); + u32 pid = pid_tgid >> 32; + // invalid pid / unable to obtain it + if (pid == 0){ + return 0; + } + + if (proto == IPPROTO_UDP){ + net_event = (struct network_event_t *)bpf_map_lookup_elem(&udpBytesMap, &pid_tgid); + } else { + net_event = (struct network_event_t *)bpf_map_lookup_elem(&tcpBytesMap, &pid_tgid); + } + if (!net_event){ + return 0; + } + + net_event->proto = proto; + net_event->pid = pid; + net_event->family = fam; + bpf_probe_read(&net_event->proto, sizeof(u8), &sk->sk_protocol); + bpf_probe_read(&net_event->dport, sizeof(net_event->dport), &sk->__sk_common.skc_dport); + bpf_probe_read(&net_event->sport, sizeof(net_event->sport), &sk->__sk_common.skc_num); + bpf_probe_read(&net_event->daddr, sizeof(net_event->daddr), &sk->__sk_common.skc_daddr); + bpf_probe_read(&net_event->saddr, sizeof(net_event->saddr), &sk->__sk_common.skc_rcv_saddr); + bpf_probe_read(&net_event->cookie, sizeof(net_event->cookie), &sk->__sk_common.skc_cookie); + + net_event->type = EVENT_TCP_CONN_DESTROYED; + if (proto == IPPROTO_UDP){ + net_event->type = EVENT_UDP_CONN_DESTROYED; + } + + bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, net_event, sizeof(*net_event)); + if (proto == IPPROTO_UDP){ + bpf_map_delete_elem(&udpBytesMap, &pid_tgid); + } else { + bpf_map_delete_elem(&tcpBytesMap, &pid_tgid); + } + + return 0; +}; + +/** + * A common function to count bytes per protocol and type (recv/sent). + * Bytes are only sent to userspace every +-3 seconds, or on the first packet + * seen, otherwise they're accumulated. + */ +static int __always_inline __handle_transfer_bytes(struct pt_regs *ctx, short proto, short fam, short type) +{ + int slen = PT_REGS_RC(ctx); + if (slen < 0){ + return 0; + } + + u64 now = bpf_ktime_get_ns(); + u64 pid_tgid = bpf_get_current_pid_tgid(); + u32 pid = pid_tgid >> 32; + // TODO: check pid == 0? + + struct network_event_t *net_event=NULL; + if (proto == IPPROTO_TCP){ + net_event = (struct network_event_t *)bpf_map_lookup_elem(&tcpBytesMap, &pid_tgid); + } + else if (proto == IPPROTO_UDP){ + net_event = (struct network_event_t *)bpf_map_lookup_elem(&udpBytesMap, &pid_tgid); + } + + if (!net_event){ + struct network_event_t new_net_event; + __builtin_memset(&new_net_event, 0, sizeof(new_net_event)); + new_net_event.pid = pid; + new_net_event.last_sent = now; + new_net_event.proto = proto; + new_net_event.type = type; + new_net_event.family = fam; + + if (type == EVENT_SEND_BYTES){ + new_net_event.bytes_sent = slen; + } else { + new_net_event.bytes_recv = slen; + } + + int ret = 0; + if (proto == IPPROTO_TCP){ + ret = bpf_map_update_elem(&tcpBytesMap, &pid_tgid, &new_net_event, BPF_ANY); + } else if (proto == IPPROTO_UDP){ + ret = bpf_map_update_elem(&udpBytesMap, &pid_tgid, &new_net_event, BPF_ANY); + } + if (ret != 0){ + char x[] = "transfer bytes, unable to update map, proto: %d, error: %d\n"; + bpf_trace_printk(x, sizeof(x), proto, ret); + } + bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &new_net_event, sizeof(new_net_event)); + return 0; + } + u64 diff = now - net_event->last_sent; + + net_event->pid = pid; + net_event->family = fam; + if (type == EVENT_SEND_BYTES){ + __sync_fetch_and_add(&net_event->bytes_sent, slen); + } else { + __sync_fetch_and_add(&net_event->bytes_recv, slen); + } + + if (diff > 1e9 * 2) { + net_event->last_sent = now; + net_event->pid = pid; + net_event->proto = proto; + net_event->type = type; + bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, net_event, sizeof(*net_event)); + + // once sent to userspace, reset counters + if (type == EVENT_SEND_BYTES){ + net_event->bytes_sent = 0; + } else { + net_event->bytes_recv = 0; + } + } + + if (proto == IPPROTO_TCP){ + bpf_map_update_elem(&tcpBytesMap, &pid_tgid, net_event, BPF_ANY); + } else if (proto == IPPROTO_UDP){ + bpf_map_update_elem(&udpBytesMap, &pid_tgid, net_event, BPF_ANY); + } + + return 0; +}; + // https://0xax.gitbooks.io/linux-insides/content/SysCall/linux-syscall-4.html // bprm_execve REGS_PARM3 // https://elixir.bootlin.com/linux/latest/source/fs/exec.c#L1796 @@ -72,7 +216,21 @@ int tracepoint__sched_sched_process_exit(struct pt_regs *ctx) bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, data, sizeof(*data)); u64 pid_tgid = bpf_get_current_pid_tgid(); + u64 pid = pid_tgid >> 32; + struct network_event_t *tcp_net_event = (struct network_event_t *)bpf_map_lookup_elem(&tcpBytesMap, &pid_tgid); + struct network_event_t *udp_net_event = (struct network_event_t *)bpf_map_lookup_elem(&udpBytesMap, &pid_tgid); + if (tcp_net_event != NULL){ + bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, tcp_net_event, sizeof(*tcp_net_event)); + } + if (udp_net_event != NULL){ + bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, udp_net_event, sizeof(*udp_net_event)); + } + + + bpf_map_delete_elem(&tcpBytesMap, &pid); + bpf_map_delete_elem(&udpBytesMap, &pid); bpf_map_delete_elem(&execMap, &pid_tgid); + return 0; }; @@ -129,7 +287,7 @@ int tracepoint__syscalls_sys_enter_execve(struct trace_sys_enter_execve* ctx) #else // in case of failure adding the item to the map, send it directly u64 pid_tgid = bpf_get_current_pid_tgid(); - if (bpf_map_update_elem(&execMap, &pid_tgid, data, BPF_ANY) != 0) { + if (bpf_map_update_elem(&execMap, &pid_tgid, data, BPF_ANY) != 0) { // With some commands, this helper fails with error -28 (ENOSPC). Misleading error? cmd failed maybe? // BUG: after coming back from suspend state, this helper fails with error -95 (EOPNOTSUPP) @@ -180,7 +338,7 @@ int tracepoint__syscalls_sys_enter_execveat(struct trace_sys_enter_execveat* ctx #else // in case of failure adding the item to the map, send it directly u64 pid_tgid = bpf_get_current_pid_tgid(); - if (bpf_map_update_elem(&execMap, &pid_tgid, data, BPF_ANY) != 0) { + if (bpf_map_update_elem(&execMap, &pid_tgid, data, BPF_ANY) != 0) { // With some commands, this helper fails with error -28 (ENOSPC). Misleading error? cmd failed maybe? // BUG: after coming back from suspend state, this helper fails with error -95 (EOPNOTSUPP) @@ -193,6 +351,108 @@ int tracepoint__syscalls_sys_enter_execveat(struct trace_sys_enter_execveat* ctx }; +SEC("kretprobe/tcp_sendmsg") +int kretprobe__tcp_sendmsg(struct pt_regs *ctx) +{ + __handle_transfer_bytes(ctx, IPPROTO_TCP, 0x0, EVENT_SEND_BYTES); + return 0; +}; + +SEC("kretprobe/tcp_recvmsg") +int kretprobe__tcp_recvmsg(struct pt_regs *ctx) +{ + __handle_transfer_bytes(ctx, IPPROTO_TCP, 0x0, EVENT_RECV_BYTES); + return 0; +}; + +SEC("kretprobe/udp_sendmsg") +int kretprobe__udp_sendmsg(struct pt_regs *ctx) +{ + __handle_transfer_bytes(ctx, IPPROTO_UDP, AF_INET, EVENT_SEND_BYTES); + return 0; +}; + +SEC("kretprobe/udp_recvmsg") +int kretprobe__udp_recvmsg(struct pt_regs *ctx) +{ + __handle_transfer_bytes(ctx, IPPROTO_UDP, AF_INET, EVENT_RECV_BYTES); + return 0; +}; + +SEC("kretprobe/udpv6_sendmsg") +int kretprobe__udpv6_sendmsg(struct pt_regs *ctx) +{ + __handle_transfer_bytes(ctx, IPPROTO_UDP, 0xa, EVENT_SEND_BYTES); + return 0; +}; + +SEC("kretprobe/udpv6_recvmsg") +int kretprobe__udpv6_recvmsg(struct pt_regs *ctx) +{ + __handle_transfer_bytes(ctx, IPPROTO_UDP, 0xa, EVENT_RECV_BYTES); + return 0; +}; + +SEC("kprobe/tcp_close") +int kprobe__tcp_close(struct pt_regs *ctx) +{ + __handle_destroy_sock(ctx, IPPROTO_TCP, 0x0); + + return 0; +} + + +// SEC("kprobe/release_sock") +// SEC("kprobe/inet_sock_destruct") + +/** + * tcp_v4_destroy_sock also used for tcpv6? + * https://elixir.bootlin.com/linux/latest/source/net/ipv6/tcp_ipv6.c#L2150 + */ +SEC("kprobe/tcp_v4_destroy_sock") +int kprobe__tcp_v4_destroy_sock(struct pt_regs *ctx) +{ + __handle_destroy_sock(ctx, 0x0, 0x0); + + return 0; +} + +SEC("kprobe/udp_abort") +int kprobe__udp_abort(struct pt_regs *ctx) +{ + __handle_destroy_sock(ctx, IPPROTO_UDP, AF_INET); + + return 0; +} + +/** + * udp_disconnect common for ipv4 and ipv6 + * https://elixir.bootlin.com/linux/latest/source/net/ipv6/udp.c#L1761 + */ +SEC("kprobe/udp_disconnect") +int kprobe__udp_disconnect(struct pt_regs *ctx) +{ + __handle_destroy_sock(ctx, IPPROTO_UDP, 0x0); + + return 0; +} + +SEC("kprobe/udp_destruct_sock") +int kprobe__udp_destruct_sock(struct pt_regs *ctx) +{ + __handle_destroy_sock(ctx, IPPROTO_UDP, AF_INET); + + return 0; +} + +SEC("kprobe/udpv6_destruct_sock") +int kprobe__udpv6_destruct_sock(struct pt_regs *ctx) +{ + __handle_destroy_sock(ctx, IPPROTO_UDP, AF_INET6); + + return 0; +} + char _license[] SEC("license") = "GPL"; // this number will be interpreted by the elf loader diff --git a/proto/ui.proto b/proto/ui.proto index 3ba8e20296..e8ed25f844 100644 --- a/proto/ui.proto +++ b/proto/ui.proto @@ -25,9 +25,10 @@ message Alert { HIGH = 2; } enum Type { - ERROR = 0; - WARNING = 1; - INFO = 2; + NOT_DEFINED = 0; + ERROR = 1; + WARNING = 2; + INFO = 3; } enum Action { NONE = 0; @@ -44,6 +45,11 @@ message Alert { NETLINK = 5; // bind, exec, etc KERNEL_EVENT = 6; + KERNEL_PROC_EXEC = 7; + KERNEL_PROC_EXIT = 8; + KERNEL_NET_BIND = 9; + KERNEL_NET_RXTX = 10; + UNKNOWN = 999; } uint64 id = 1; @@ -62,6 +68,8 @@ message Alert { Connection conn = 9; Rule rule = 10; FwRule fwrule = 11; + KernelEvent kEvent = 12; + Message msg = 13; } } @@ -69,6 +77,42 @@ message MsgResponse { uint64 id = 1; } +message KernelProcEvent { + uint64 id = 1; + uint32 what = 2; + Process proc = 3; +} + +message KernelNetEvent { + uint64 id = 1; + // bind, connection, rxtx, others? + uint32 what = 2; + Connection conn = 3; +} + +message KernelEvent { + enum Generic { + GENERIC = 0; + PROC_EXEC = 1; + PROC_EXIT = 2; + // net events + NET_BIND = 100; + NET_RXTX = 101; + } + + uint64 id = 1; + // execve, proc (bytes/recv), bind + uint32 type = 2; + oneof data { + string text = 3; + KernelProcEvent procEvent = 4; + KernelNetEvent netEvent = 5; + } +} + + +// -------------------------------------------------------------------------- + message Event { string time = 1; Connection connection = 2; @@ -110,6 +154,11 @@ message StringInt { uint32 value = 2; } +message Message { + string key = 1; + string value = 2; +} + message Process { uint64 pid = 1; uint64 ppid = 2; @@ -124,7 +173,9 @@ message Process { uint64 io_writes = 11; uint64 net_reads = 12; uint64 net_writes = 13; - repeated StringInt process_tree = 14; + map bytes_sent = 15; + map bytes_recv = 16; + repeated StringInt tree = 17; } message Connection { @@ -138,10 +189,12 @@ message Connection { uint32 process_id = 8; string process_path = 9; string process_cwd = 10; - repeated string process_args = 11; - map process_env = 12; - map process_checksums = 13; - repeated StringInt process_tree = 14; + uint64 process_bytessent = 11; + uint64 process_bytesrecv = 12; + repeated string process_args = 13; + map process_env = 14; + map process_checksums = 15; + repeated StringInt process_tree = 16; } message Operator { diff --git a/ui/opensnitch/alerts/__init__.py b/ui/opensnitch/alerts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/alerts/_utils.py b/ui/opensnitch/alerts/_utils.py new file mode 100644 index 0000000000..06537b6270 --- /dev/null +++ b/ui/opensnitch/alerts/_utils.py @@ -0,0 +1,29 @@ +from PyQt5 import QtWidgets, QtGui, QtCore + +from opensnitch import ui_pb2 +from opensnitch.notifications import DesktopNotifications + +def get_urgency(alert): + urgency = DesktopNotifications.URGENCY_NORMAL + if alert.priority == ui_pb2.Alert.LOW: + urgency = DesktopNotifications.URGENCY_LOW + elif alert.priority == ui_pb2.Alert.HIGH: + urgency = DesktopNotifications.URGENCY_CRITICAL + + return urgency + +def get_icon(alert): + icon = QtWidgets.QSystemTrayIcon.Information + _title = QtCore.QCoreApplication.translate("messages", "Info") + atype = "INFO" + if alert.type == ui_pb2.Alert.ERROR: + atype = "ERROR" + _title = QtCore.QCoreApplication.translate("messages", "Error") + icon = QtWidgets.QSystemTrayIcon.Critical + if alert.type == ui_pb2.Alert.WARNING: + atype = "WARNING" + _title = QtCore.QCoreApplication.translate("messages", "Warning") + icon = QtWidgets.QSystemTrayIcon.Warning + + return icon + diff --git a/ui/opensnitch/alerts/alert.py b/ui/opensnitch/alerts/alert.py new file mode 100644 index 0000000000..057fccc44e --- /dev/null +++ b/ui/opensnitch/alerts/alert.py @@ -0,0 +1,69 @@ +from PyQt5 import QtWidgets, QtCore +from datetime import datetime + +from opensnitch import ui_pb2 +from opensnitch.database import Database +from opensnitch.notifications import DesktopNotifications +from opensnitch.alerts import _utils + +class Alert: + type = "INFO" + what = "GENERIC" + body = "generic alert" + title = "Info" + icon = QtWidgets.QSystemTrayIcon.Information + urgency = DesktopNotifications.URGENCY_NORMAL + pb_alert = None + + # flag to indicate if the alert was generated locally (same host) + is_local = True + + def __init__(self, proto, addr, is_local, pb_alert): + self._db = Database.instance() + self.proto = proto + self.addr = addr + self.is_local = is_local + self.pb_alert = pb_alert + self.alert_type = pb_alert.type + self.body = pb_alert.text + + def build(self): + self.title = QtCore.QCoreApplication.translate("messages", "Info") + + if self.what == ui_pb2.Alert.KERNEL_EVENT: + self.body = "%s\n%s" % (self.text, self.proc.path) + self.what = "KERNEL EVENT" + if self.what == ui_pb2.Alert.NET_EVENT: + self.body = "%s\n%s" % (self.text, self.proc.path) + self.what = "NETWORK EVENT" + if self.is_local is False: + self.body = "node: {0}:{1}\n\n{2}\n{3}".format(self.proto, self.addr, self.text, self.proc.path) + + self.icon = _utils.get_icon(self.pb_alert) + self.urgency = _utils.get_urgency(self.pb_alert) + + if self.type == ui_pb2.Alert.ERROR: + self.type = "ERROR" + self.title = QtCore.QCoreApplication.translate("messages", "Error") + self.icon = QtWidgets.QSystemTrayIcon.Critical + elif self.type == ui_pb2.Alert.WARNING: + self.type = "WARNING" + self.title = QtCore.QCoreApplication.translate("messages", "Warning") + self.icon = QtWidgets.QSystemTrayIcon.Warning + + if self.priority == ui_pb2.Alert.LOW: + urgency = DesktopNotifications.URGENCY_LOW + elif self.priority == ui_pb2.Alert.HIGH: + urgency = DesktopNotifications.URGENCY_CRITICAL + + return self.title, self.body, self.icon, urgency + + def save(self): + if self.type == ui_pb2.Alert.GENERIC: + self._db.insert( + "alerts", + "(time, node, type, action, priority, what, body, status)", + ( + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + self.proto+":"+self.addr, self.type, "", "", self.what, self.body, 0 + )) diff --git a/ui/opensnitch/alerts/rxtx.py b/ui/opensnitch/alerts/rxtx.py new file mode 100644 index 0000000000..58c34f8a08 --- /dev/null +++ b/ui/opensnitch/alerts/rxtx.py @@ -0,0 +1,67 @@ + +import json +from datetime import datetime + +from google.protobuf.json_format import MessageToJson +from opensnitch.database import Database + +class RxTx: + def __init__(self, proto, addr, pb_alert): + self._db = Database.instance() + self.proto = proto + self.addr = addr + self.proc = json.loads(MessageToJson(pb_alert.proc)) + + self.env = "" + if self.proc.get('env') != None: + self.env = json.dumps(self.proc['env']) + self.tree = "" + if self.proc.get('tree') != None: + self.tree = json.dumps(self.proc['tree']) + self.checksums="" + if self.proc.get('checksums') != None: + self.checksums = json.dumps(self.proc['checksums']) + + # totals + self.bytesSent = 0 + self.bytesRecv = 0 + self.proto = "" + if self.proc.get('bytesSent') != None: + for k in self.proc['bytesSent']: + self.bytesSent += int(self.proc['bytesSent'][k]) + self.proto = k + if self.proc.get('bytesRecv') != None: + for k in self.proc['bytesRecv']: + self.bytesRecv += int(self.proc['bytesRecv'][k]) + self.proto = k + self.cwd = "" + if self.proc.get('cwd') != None: + self.cwd = self.proc['cwd'] + + def save(self): + ret, lastId = self._db.insert("procs", "(what, hits)", (self.proc['path'], 0), action_on_conflict="IGNORE") + # TODO: path is not valid as primary key. We should use + # node+path as minimum. + ret, lastId = self._db.insert("rxtx", + "(time, what, proto, bytes_sent, bytes_recv, proc_path_fk)", + ( + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + 0, # process, conn, etc + self.proto, + self.bytesSent, + self.bytesRecv, + self.proc['path'] + )) + ret, lastId = self._db.insert("proc_details", + "(time, node, comm, path, cmdline, cwd, md5, tree, env)", + ( + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "{0}:{1}".format(self.proto, self.addr), + self.proc['comm'], + self.proc['path'], + " ".join(self.proc['args']), + self.cwd, + self.checksums, + self.tree, + self.env + ), action_on_conflict="IGNORE") diff --git a/ui/opensnitch/config.py b/ui/opensnitch/config.py index e57c778ec0..0767107b27 100644 --- a/ui/opensnitch/config.py +++ b/ui/opensnitch/config.py @@ -141,6 +141,7 @@ class Config: STATS_RULES_SPLITTER_POS = "statsDialog/rules_splitter_pos" STATS_VIEW_COL_STATE = "statsDialog/view_columns_state" STATS_VIEW_DETAILS_COL_STATE = "statsDialog/view_details_columns_state" + STATS_RXTX_UNITS_FORMAT = "statsDialog/rxtx_units_format" INFOWIN_GEOMETRY = "infoWindow/geometry" diff --git a/ui/opensnitch/database/__init__.py b/ui/opensnitch/database/__init__.py index c9deee7a90..345d0a193e 100644 --- a/ui/opensnitch/database/__init__.py +++ b/ui/opensnitch/database/__init__.py @@ -200,6 +200,47 @@ def _create_tables(self): ")", self.db) q.exec_() + q = QSqlQuery("create table if not exists rxtx (" \ + "id int primary key, " \ + "time time, " \ + "what int, " \ + "proto text, " \ + "bytes_sent int, " \ + "bytes_recv int, " \ + "proc_path_fk text, " \ + "FOREIGN KEY(proc_path_fk) REFERENCES procs(path)" \ + ")", self.db) + q.exec_() + q = QSqlQuery("CREATE INDEX idx_rxtx_what_path ON rxtx (what, proc_path_fk)", self.db) + q.exec_() + q = QSqlQuery("CREATE INDEX idx_rxtx_bytes_sent ON rxtx (bytes_sent)", self.db) + q.exec_() + q = QSqlQuery("CREATE INDEX idx_rxtx_bytes_recv ON rxtx (bytes_recv)", self.db) + q.exec_() + + q = QSqlQuery("create table if not exists proc_details (" \ + "time time, " \ + "node text, " \ + "path text, " \ + #"hostid text, " \ # container host/id ?? + "comm text, " \ + "cmdline text, " \ + "cwd text, " \ + "md5 text, " \ + "tree text, " \ + "env text, " \ + "proc_path_fk text, " \ + "FOREIGN KEY(proc_path_fk) REFERENCES procs(what)" \ + # what should we consider unique? + # node+path prevents from registering different executions of + # the same binary. A scenario where this could be useful is + # when a process is launched with different environment + # variables. + "UNIQUE(node, path)" \ + ")", self.db) + q.exec_() + + q = QSqlQuery("create index rules_index on rules (time)", self.db) q.exec_() @@ -300,9 +341,12 @@ def optimize(self): q = QSqlQuery("PRAGMA optimize;", self.db) q.exec_() - def clean(self, table): + def clean(self, table, where=None): + qstr = "DELETE FROM " + table + if where is not None: + qstr += " WHERE " + where with self._lock: - q = QSqlQuery("delete from " + table, self.db) + q = QSqlQuery(qstr, self.db) q.exec_() def vacuum(self): @@ -358,6 +402,7 @@ def purge_oldest(self, max_days_to_keep): if oldt == None or newt == None or oldt == 0 or newt == 0: return -1 + rows_deleted = 0 oldest = datetime.strptime(oldt, "%Y-%m-%d %H:%M:%S.%f") newest = datetime.strptime(newt, "%Y-%m-%d %H:%M:%S.%f") diff = newest - oldest @@ -368,8 +413,30 @@ def purge_oldest(self, max_days_to_keep): q.prepare("DELETE FROM connections WHERE time < ?") q.bindValue(0, str(date_to_purge)) if q.exec_(): - print("purge_oldest() {0} records deleted".format(q.numRowsAffected())) - return q.numRowsAffected() + print("purge_oldest() connections: {0} records deleted".format(q.numRowsAffected())) + rows_deleted += q.numRowsAffected() + else: + print(q.lastError().driverText()) + + # XXX: delete really the alerts? There shouldn't be that many + q = QSqlQuery(self.db) + q.prepare("DELETE FROM alerts WHERE time < ?") + q.bindValue(0, str(date_to_purge)) + if q.exec_(): + print("purge_oldest() alerts: {0} records deleted".format(q.numRowsAffected())) + rows_deleted += q.numRowsAffected() + + q = QSqlQuery(self.db) + q.prepare("DELETE FROM rxtx WHERE time < ?") + q.bindValue(0, str(date_to_purge)) + if q.exec_(): + print("purge_oldest() rxtx: {0} records deleted".format(q.numRowsAffected())) + rows_deleted += q.numRowsAffected() + + + + return rows_deleted + except Exception as e: print("db, purge_oldest() error:", e) @@ -405,7 +472,7 @@ def _insert(self, query_str, columns): for idx, v in enumerate(columns): q.bindValue(idx, v) if q.exec_(): - return True + return True, q.lastInsertId() else: print("_insert() ERROR", query_str) print(q.lastError().driverText()) @@ -415,7 +482,7 @@ def _insert(self, query_str, columns): finally: q.finish() - return False + return False, -1 def insert(self, table, fields, columns, update_field=None, update_values=None, action_on_conflict="REPLACE"): if update_field != None: @@ -684,7 +751,7 @@ def delete_alert(self, time, node_addr=None): def get_alert(self, alert_time, node_addr=None): """ - get alert, given the time of the alert and the node + get an alert, given the time of the alert and the node """ qstr = "SELECT * FROM alerts WHERE time=?" if node_addr != None: @@ -698,3 +765,44 @@ def get_alert(self, alert_time, node_addr=None): q.exec_() return q + + def get_process(self, path, node_addr=None): + qstr = "SELECT * FROM process WHERE path=?" + if node_addr != None: + qstr = qstr + " AND node=?" + + q = QSqlQuery(qstr, self.db) + q.prepare(qstr) + q.addBindValue(path) + if node_addr != None: + q.addBindValue(node_addr) + q.exec_() + + return q + + def get_process_bytes(self, path, get_totals=True, node_addr=None): + qstr = "SELECT sum(bytes_sent) as bytes_sent, sum(bytes_recv) as bytes_recv FROM process WHERE path=?" + if not get_totals: + qstr = "SELECT bytes_sent, bytes_recv FROM process WHERE path=?" + if node_addr != None: + qstr = qstr + " AND node=?" + + q = QSqlQuery(qstr, self.db) + q.prepare(qstr) + q.addBindValue(path) + if node_addr != None: + q.addBindValue(node_addr) + q.exec_() + + return q + + def reset_rxtx_stats(self, field, object): + """Reset rxtx stats to 0 (defined by the 'field' parameter). + The object will be used to specify what to reset (process, conns...) + """ + self.update( + table="rxtx", + fields="{0}=?".format(field), + values=[0], + condition="what == {0}".format(object), + action_on_conflict="") diff --git a/ui/opensnitch/dialogs/stats.py b/ui/opensnitch/dialogs/stats.py index 497ef7128c..ec9a30acf5 100644 --- a/ui/opensnitch/dialogs/stats.py +++ b/ui/opensnitch/dialogs/stats.py @@ -44,6 +44,23 @@ class StatsDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]): LIMITS = ["LIMIT 50", "LIMIT 100", "LIMIT 200", "LIMIT 300", ""] LAST_GROUP_BY = "" + RXTX_BYTES = "bytes" + RXTX_UNITS = "units" + RXTX_FILTER_BY_BYTES = "rt.total_sent as Tx, rt.total_recv as Rx" + RXTX_FILTER_BY_UNITS = """CASE + WHEN rt.total_sent >= 1024 AND rt.total_sent <= 1048576 THEN (ROUND(rt.total_sent / 1024, 2)) || ' KBytes' + WHEN rt.total_sent > 1048576 AND rt.total_sent < 1073741824 THEN (ROUND(rt.total_sent / 1048576, 2)) || ' MBytes' + WHEN rt.total_sent >= 1073741824 THEN (ROUND(rt.total_sent / 1073741824, 2)) || ' GBytes' + ELSE rt.total_sent + END AS Tx, + CASE + WHEN rt.total_recv >= 1024 AND rt.total_recv <= 1048576 THEN (ROUND(rt.total_recv / 1024, 2)) || ' KBytes' + WHEN rt.total_recv > 1048576 AND rt.total_recv < 1073741824 THEN (ROUND(rt.total_recv / 1048576, 2)) || ' MBytes' + WHEN rt.total_recv >= 1073741824 THEN (ROUND(rt.total_recv / 1073741824, 2)) || ' GBytes' + ELSE rt.total_recv + END AS Rx""" + + # general COL_TIME = 0 COL_NODE = 1 @@ -264,7 +281,26 @@ class StatsDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]): "filterLine": None, "model": None, "delegate": "commonDelegateConfig", - "display_fields": "*", + "display_fields": "", + "bytes_units": RXTX_FILTER_BY_UNITS, + "custom_query": """ +WITH rxtx_totals AS ( + SELECT + proc_path_fk, + what, + total(bytes_sent) AS total_sent, + total(bytes_recv) AS total_recv + FROM rxtx + WHERE what = 0 + GROUP BY proc_path_fk, what +) + +SELECT + procs.what, + procs.hits, + #UNITS# +FROM procs +JOIN rxtx_totals rt ON procs.what = rt.proc_path_fk""", "header_labels": [], "last_order_by": "2", "last_order_to": 1, @@ -371,6 +407,13 @@ def __init__(self, parent=None, address=None, db=None, dbname="db", appicon=None self._actions = Actions().instance() self._actions.loadAll() self._last_update = datetime.datetime.now() + self._last_rxtx_update = datetime.datetime.now() + self._last_rxtx = {'sent': 0, 'recv': 0} + # rxtx timer to reset statusbar stats after 3s if we haven't received + # new bytes stats. + self._rxtx_timer = QtCore.QTimer() + self._rxtx_timer.setInterval(2000) + self._rxtx_timer.timeout.connect(self._rxtx_timer_callback) # TODO: allow to display multiples dialogs self._proc_details_dialog = ProcessDetailsDialog(appicon=appicon) @@ -493,10 +536,15 @@ def __init__(self, parent=None, address=None, db=None, dbname="db", appicon=None ] self.TABLES[self.TAB_HOSTS]['header_labels'] = stats_headers - self.TABLES[self.TAB_PROCS]['header_labels'] = stats_headers self.TABLES[self.TAB_ADDRS]['header_labels'] = stats_headers self.TABLES[self.TAB_PORTS]['header_labels'] = stats_headers self.TABLES[self.TAB_USERS]['header_labels'] = stats_headers + self.TABLES[self.TAB_PROCS]['header_labels'] = [ + QC.translate("stats", "What", "This is a word, without spaces and symbols.").replace(" ", ""), + QC.translate("stats", "Hits", "This is a word, without spaces and symbols.").replace(" ", ""), + 'Tx', + 'Rx' + ] self.TABLES[self.TAB_MAIN]['view'] = self._setup_table(QtWidgets.QTableView, self.eventsTable, "connections", self.TABLES[self.TAB_MAIN]['display_fields'], @@ -557,11 +605,13 @@ def __init__(self, parent=None, address=None, db=None, dbname="db", appicon=None self.TABLES[self.TAB_PROCS]['view'] = self._setup_table(QtWidgets.QTableView, self.procsTable, "procs", model=GenericTableModel("procs", self.TABLES[self.TAB_PROCS]['header_labels']), + fields="", verticalScrollBar=self.procsScrollBar, resize_cols=(self.COL_WHAT,), delegate=self.TABLES[self.TAB_PROCS]['delegate'], order_by="2", - limit=self._get_limit() + limit=self._get_limit(), + custom_query=self.TABLES[self.TAB_PROCS]['custom_query'].replace("#UNITS#", self.TABLES[self.TAB_PROCS]['bytes_units']) ) self.TABLES[self.TAB_ADDRS]['view'] = self._setup_table(QtWidgets.QTableView, self.addrTable, "addrs", @@ -631,6 +681,7 @@ def __init__(self, parent=None, address=None, db=None, dbname="db", appicon=None self.TABLES[self.TAB_FIREWALL]['view'].customContextMenuRequested.connect(self._cb_table_context_menu) self.TABLES[self.TAB_ALERTS]['view'].setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.TABLES[self.TAB_ALERTS]['view'].customContextMenuRequested.connect(self._cb_table_context_menu) + self.TABLES[self.TAB_ALERTS]['view'].resizeRowsToContents() for idx in range(1,10): if self.TABLES[idx]['cmd'] != None: @@ -683,6 +734,18 @@ def __init__(self, parent=None, address=None, db=None, dbname="db", appicon=None self.fwTreeEdit.clicked.connect(self._cb_tree_edit_firewall_clicked) self._configure_buttons_icons() + def _rxtx_timer_callback(self): + """Reset rxtx stats if we haven't received new bytes stats in the latest + 2 seconds. + Whenever we receive a new bytes event, the timer is resetted. + """ + self._rxtx_timer.start() + self._last_rxtx = { + 'sent': 0, + 'recv': 0 + } + self.rxtxLabel.setText(" 🡅 0 🡇 0") + #Sometimes a maximized window which had been minimized earlier won't unminimize #To workaround, we explicitely maximize such windows when unminimizing happens def changeEvent(self, event): @@ -821,6 +884,12 @@ def _load_settings(self): if dialog_general_filter_text != None: self.filterLine.setText(dialog_general_filter_text) + rxtx_units = self._cfg.getSettings(Config.STATS_RXTX_UNITS_FORMAT) + if rxtx_units == self.RXTX_BYTES: + self.TABLES[self.TAB_PROCS]['bytes_units'] = self.RXTX_FILTER_BY_BYTES + else: + self.TABLES[self.TAB_PROCS]['bytes_units'] = self.RXTX_FILTER_BY_UNITS + def _save_settings(self): self._cfg.setSettings(Config.STATS_GEOMETRY, self.saveGeometry()) self._cfg.setSettings(Config.STATS_LAST_TAB, self.tabWidget.currentIndex()) @@ -927,6 +996,41 @@ def _configure_events_contextual_menu(self, pos): self._clear_rows_selection() return True + def _configure_headers_procs_contextual_menu(self, pos, cur_idx): + try: + table = self._get_active_table() + point = QtCore.QPoint(pos.x()+10, pos.y()+5) + + menu = QtWidgets.QMenu(self) + _menu_units_reset_rx = menu.addAction(QC.translate("stats", "Reset Rx stats")) + _menu_units_reset_tx = menu.addAction(QC.translate("stats", "Reset Tx stats")) + unitsMenu = QtWidgets.QMenu(QC.translate("stats", "Format")) + _menu_units_bytes = unitsMenu.addAction(QC.translate("stats", "Bytes")) + _menu_units_group = unitsMenu.addAction(QC.translate("stats", "Group by units")) + menu.addMenu(unitsMenu) + + action = menu.exec_(table.mapToGlobal(point)) + + if action == _menu_units_reset_rx: + self._db.reset_rxtx_stats("bytes_sent", 0) + elif action == _menu_units_reset_tx: + self._db.reset_rxtx_stats("bytes_recv", 0) + elif action == _menu_units_bytes: + self._cfg.setSettings(Config.STATS_RXTX_UNITS_FORMAT, self.RXTX_BYTES) + self.TABLES[cur_idx]['bytes_units'] = self.RXTX_FILTER_BY_BYTES + elif action == _menu_units_group: + self._cfg.setSettings(Config.STATS_RXTX_UNITS_FORMAT, self.RXTX_UNITS) + self.TABLES[cur_idx]['bytes_units'] = self.RXTX_FILTER_BY_UNITS + + view = self.TABLES[self.TAB_PROCS]['view'] + model = view.model() + qstr = self.TABLES[self.TAB_PROCS]['custom_query'].replace("#UNITS#", self.TABLES[cur_idx]['bytes_units']) \ + + self._get_order() + self._get_limit() + self.setQuery(model, qstr) + + except Exception as e: + print("config procs headers exception:", e) + def _configure_fwrules_contextual_menu(self, pos): try: cur_idx = self.tabWidget.currentIndex() @@ -1495,6 +1599,14 @@ def _cb_tab_changed(self, index): self._refresh_active_table() + def _cb_headers_context_menu(self, pos): + cur_idx = self.tabWidget.currentIndex() + + #if cur_idx == self.TAB_MAIN: + # self._configure_headers_main_contextual_menu(pos, cur_idx) + if cur_idx == self.TAB_PROCS: + self._configure_headers_procs_contextual_menu(pos, cur_idx) + def _cb_table_context_menu(self, pos): cur_idx = self.tabWidget.currentIndex() if cur_idx != self.TAB_RULES and cur_idx != self.TAB_MAIN: @@ -1560,8 +1672,14 @@ def _cb_events_filter_line_changed(self, text): else: where_clause = self._get_filter_line_clause(cur_idx, text) - qstr = self._db.get_query( self.TABLES[cur_idx]['name'], self.TABLES[cur_idx]['display_fields'] ) + \ - where_clause + self._get_order() + if self.TABLES[cur_idx].get('custom_query') != None: + qstr = self.TABLES[cur_idx]['custom_query'] + if cur_idx == StatsDialog.TAB_PROCS: + qstr = self.TABLES[self.TAB_PROCS]['custom_query'].replace("#UNITS#", self.TABLES[cur_idx]['bytes_units']) + else: + qstr = self._db.get_query( self.TABLES[cur_idx]['name'], self.TABLES[cur_idx]['display_fields'] ) + + qstr += where_clause + self._get_order() if text == "": qstr = qstr + self._get_limit() @@ -1594,6 +1712,10 @@ def _cb_clean_sql_clicked(self, idx): cur_idx = self.tabWidget.currentIndex() if cur_idx == StatsDialog.TAB_RULES: self._db.empty_rule(self.TABLES[cur_idx]['label'].text()) + elif cur_idx == StatsDialog.TAB_PROCS: + self._db.clean(self.TABLES[cur_idx]['name']) + self._db.clean("proc_details") + self._db.clean("rxtx", "what == 0") elif self.IN_DETAIL_VIEW[cur_idx]: self._del_by_field(cur_idx, self.TABLES[cur_idx]['name'], self.TABLES[cur_idx]['label'].text()) else: @@ -1618,11 +1740,18 @@ def _cb_cmd_back_clicked(self, idx): filter_text = self.TABLES[cur_idx]['filterLine'].text() where_clause = self._get_filter_line_clause(cur_idx, filter_text) - self.setQuery(model, - self._db.get_query( - self.TABLES[cur_idx]['name'], - self.TABLES[cur_idx]['display_fields']) + where_clause + " " + self._get_order() + self._get_limit() - ) + if self.TABLES[cur_idx].get('custom_query') != None: + qstr = self.TABLES[cur_idx]['custom_query'] + if cur_idx == StatsDialog.TAB_PROCS: + qstr = self.TABLES[self.TAB_PROCS]['custom_query'].replace("#UNITS#", self.TABLES[cur_idx]['bytes_units']) + else: + qstr = self._db.get_query( + self.TABLES[cur_idx]['name'], + self.TABLES[cur_idx]['display_fields']) + + qstr += where_clause + self._get_order() + self._get_limit() + self.setQuery(model, qstr) + finally: self._restore_details_view_columns( self.TABLES[cur_idx]['view'].horizontalHeader(), @@ -2547,6 +2676,10 @@ def _set_process_query(self, data): nrows = self._get_active_table().model().rowCount() self.cmdProcDetails.setVisible(nrows != 0) + records = self._db.get_process_bytes(data) + if records != None and records.next(): + labelText = "{0} - sent: {1}, recv: {2}".format(data, records.value(0), records.value(1)) + self.procsLabel.setText(labelText) def _set_addrs_query(self, data): model = self._get_active_table().model() @@ -2846,7 +2979,21 @@ def _on_menu_export_csv_clicked(self, triggered): values.append(table.model().index(row, col).data()) w.writerow(values) - def _setup_table(self, widget, tableWidget, table_name, fields="*", group_by="", order_by="2", sort_direction=SORT_ORDER[1], limit="", resize_cols=(), model=None, delegate=None, verticalScrollBar=None, tracking_column=COL_TIME): + def _setup_table(self, + widget, + tableWidget, + table_name, + fields="*", + group_by="", + order_by="2", + sort_direction=SORT_ORDER[1], + limit="", + resize_cols=(), + model=None, + delegate=None, + verticalScrollBar=None, + tracking_column=COL_TIME, + custom_query=None): tableWidget.setSortingEnabled(True) if model == None: model = self._db.get_new_qsql_model() @@ -2856,7 +3003,11 @@ def _setup_table(self, widget, tableWidget, table_name, fields="*", group_by="", tableWidget.verticalScrollBar().sliderReleased.connect(self._cb_scrollbar_released) tableWidget.setTrackingColumn(tracking_column) - self.setQuery(model, "SELECT " + fields + " FROM " + table_name + group_by + " ORDER BY " + order_by + " " + sort_direction + limit) + query_tail = group_by + " ORDER BY " + order_by + " " + sort_direction + limit + if custom_query != None: + self.setQuery(model, custom_query + query_tail) + else: + self.setQuery(model, "SELECT " + fields + " FROM " + table_name + query_tail) tableWidget.setModel(model) if delegate != None: @@ -2866,6 +3017,8 @@ def _setup_table(self, widget, tableWidget, table_name, fields="*", group_by="", header = tableWidget.horizontalHeader() if header != None: + header.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + header.customContextMenuRequested.connect(self._cb_headers_context_menu) header.sortIndicatorChanged.connect(self._cb_table_header_clicked) for _, col in enumerate(resize_cols): @@ -2936,6 +3089,37 @@ def _on_update_triggered(self, is_local, need_query_update=False): if need_query_update and not self._are_rows_selected(): self._refresh_active_table() + + @QtCore.pyqtSlot(int, int) + def on_bytes_updated(self, sent, recv): + """Display rxtx stats on the statusbar + """ + # start/reset the timer + self._rxtx_timer.start() + self._last_rxtx = { + 'sent': self._last_rxtx['sent'] + sent, + 'recv': self._last_rxtx['recv'] + recv + } + tx_units = "" + rx_units = "" + diff = datetime.datetime.now() - self._last_rxtx_update + if diff.seconds < self._ui_refresh_interval: + return + + if self._last_rxtx['sent'] > 1024: + tx_units = "KB" + sent = round(float(self._last_rxtx['sent'] / 1024), 2) + if self._last_rxtx['recv'] > 1024: + rx_units = "KB" + recv = round(float(self._last_rxtx['recv'] / 1024), 2) + + self.rxtxLabel.setText(" 🡅 {0} {1} 🡇 {2} {3}".format(sent, tx_units, recv, rx_units)) + self._last_rxtx = { + 'sent': 0, + 'recv': 0 + } + self._last_rxtx_update = datetime.datetime.now() + # prevent a click on the window's x # from quitting the whole application def closeEvent(self, e): diff --git a/ui/opensnitch/res/stats.ui b/ui/opensnitch/res/stats.ui index a32a315909..18376e821d 100644 --- a/ui/opensnitch/res/stats.ui +++ b/ui/opensnitch/res/stats.ui @@ -286,49 +286,51 @@ - - - 0 - - - - - QFrame::NoFrame - - - QFrame::Plain - - - false - - - QAbstractItemView::SelectRows - - - false - - - true - - - true - - - false - - - false - - - - - - - Qt::Vertical - - - - + + + + 0 + + + + + QFrame::NoFrame + + + QFrame::Plain + + + false + + + QAbstractItemView::SelectRows + + + false + + + true + + + true + + + false + + + false + + + + + + + Qt::Vertical + + + + + @@ -504,9 +506,6 @@ - - QAbstractItemView::AnyKeyPressed|QAbstractItemView::EditKeyPressed - QAbstractItemView::SelectRows @@ -1206,7 +1205,8 @@ - 8 + DejaVu Sans + 9 true @@ -1258,7 +1258,8 @@ - 8 + DejaVu Sans + 9 true @@ -1310,7 +1311,8 @@ - 8 + DejaVu Sans + 9 true @@ -1362,7 +1364,8 @@ - 8 + DejaVu Sans + 9 true @@ -1377,6 +1380,13 @@ + + + + - + + + diff --git a/ui/opensnitch/service.py b/ui/opensnitch/service.py index ca00b105d0..8f6a544161 100644 --- a/ui/opensnitch/service.py +++ b/ui/opensnitch/service.py @@ -18,6 +18,7 @@ from opensnitch.dialogs.prompt import PromptDialog from opensnitch.dialogs.stats import StatsDialog +from opensnitch.alerts import alert, rxtx from opensnitch.notifications import DesktopNotifications from opensnitch.firewall import Rules as FwRules from opensnitch.nodes import Nodes @@ -37,6 +38,7 @@ class UIService(ui_pb2_grpc.UIServicer, QtWidgets.QGraphicsObject): _status_change_trigger = QtCore.pyqtSignal(bool) _notification_callback = QtCore.pyqtSignal(ui_pb2.NotificationReply) _show_message_trigger = QtCore.pyqtSignal(str, str, int, int) + _bytes_updated_trigger = QtCore.pyqtSignal(int, int) # .desktop filename located under /usr/share/applications/ DESKTOP_FILENAME = "opensnitch_ui.desktop" @@ -150,6 +152,7 @@ def _setup_slots(self): self._stats_dialog._status_changed_trigger.connect(self._on_stats_status_changed) self._stats_dialog.settings_saved.connect(self._on_settings_saved) self._stats_dialog.close_trigger.connect(self._on_close) + self._bytes_updated_trigger.connect(self._stats_dialog.on_bytes_updated) self._show_message_trigger.connect(self._show_systray_message) def _setup_icons(self): @@ -301,48 +304,29 @@ def _on_update_stats(self, proto, addr, request): self._stats_dialog.update(is_local_request, request.stats, main_need_refresh or details_need_refresh) @QtCore.pyqtSlot(str, str, ui_pb2.Alert) - def _on_new_alert(self, proto, addr, alert): - # TODO: move to its own module + def _on_new_alert(self, proto, addr, pb_alert): + is_local_request = self._is_local_request(proto, addr) try: - is_local = self._is_local_request(proto, addr) - - what = "GENERIC" - body = alert.text - if alert.what == ui_pb2.Alert.KERNEL_EVENT: - body = "%s\n%s" % (alert.text, alert.proc.path) - what = "KERNEL EVENT" - if is_local is False: - body = "node: {0}:{1}\n\n{2}\n{3}".format(proto, addr, alert.text, alert.proc.path) - - if alert.action == ui_pb2.Alert.SHOW_ALERT: - icon = QtWidgets.QSystemTrayIcon.Information - _title = QtCore.QCoreApplication.translate("messages", "Info") - atype = "INFO" - if alert.type == ui_pb2.Alert.ERROR: - atype = "ERROR" - _title = QtCore.QCoreApplication.translate("messages", "Error") - icon = QtWidgets.QSystemTrayIcon.Critical - if alert.type == ui_pb2.Alert.WARNING: - atype = "WARNING" - _title = QtCore.QCoreApplication.translate("messages", "Warning") - icon = QtWidgets.QSystemTrayIcon.Warning - - urgency = DesktopNotifications.URGENCY_NORMAL - if alert.priority == ui_pb2.Alert.LOW: - urgency = DesktopNotifications.URGENCY_LOW - elif alert.priority == ui_pb2.Alert.HIGH: - urgency = DesktopNotifications.URGENCY_CRITICAL - - self._show_message_trigger.emit(_title, body, icon, urgency) - - self._db.insert("alerts", - "(time, node, type, action, priority, what, body, status)", - ( - datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - proto+":"+addr, atype, "", "", what, body, 0 - )) + if pb_alert.what == ui_pb2.Alert.GENERIC: + al = alerts.Alert(proto, addr, is_local_request, pb_alert) + if pb_alert.action == ui_pb2.Alert.SHOW_ALERT: + _title, body, icon, urgency = al.build() + self._show_message_trigger.emit(_title, body, icon, urgency) + + if pb_alert.action == ui_pb2.Alert.SAVE_TO_DB: + al.save() + + # proc_exit/rxtx events are not saved to the alerts table + if pb_alert.what == ui_pb2.Alert.KERNEL_NET_RXTX: + rxtxAlert = rxtx.RxTx(proto, addr, pb_alert) + if pb_alert.action == ui_pb2.Alert.SAVE_TO_DB: + rxtxAlert.save() + + self._bytes_updated_trigger.emit(rxtxAlert.bytesSent, rxtxAlert.bytesRecv) + self._stats_dialog.update(is_local_request, None, True) + else: - print("PostAlert() unknown alert action:", alert.action) + print("PostAlert() unknown alert action:", pb_alert.text) except Exception as e: