From 26671ded24c80fbc4815a9e12e39ee8697a6b69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustavo=20I=C3=B1iguez=20Goia?= Date: Thu, 25 Feb 2021 13:38:48 +0100 Subject: [PATCH] added support for list of domains Initial support to filter connections using lists of domains. The lists must be in hosts format: - 0.0.0.0 www.domain.com - 127.0.0.1 www.domain.com From the rules editor, create a new rule, and select [x] To this lists of domains Select a directory with files in hosts format, select [x] Priority rule, select [x] Deny and click on Apply. An example of a list in hosts format: https://www.github.developerdan.com/hosts/lists/ads-and-tracking-extended.txt Note: you can also add a list of domains to allow, not only domains to block. TODOs: - support for URLs besides directories (local lists). - support for scheduled updates of the above URLs. related #298 --- daemon/rule/loader.go | 16 +- daemon/rule/operator.go | 25 ++ ui/opensnitch/config.py | 2 +- ui/opensnitch/dialogs/ruleseditor.py | 45 ++- ui/opensnitch/res/ruleseditor.ui | 408 +++++++++++++++------------ ui/opensnitch/utils.py | 24 +- 6 files changed, 325 insertions(+), 195 deletions(-) diff --git a/daemon/rule/loader.go b/daemon/rule/loader.go index 27e4d32d..ee3ca963 100644 --- a/daemon/rule/loader.go +++ b/daemon/rule/loader.go @@ -93,7 +93,9 @@ func (l *Loader) Load(path string) error { r.Operator.Compile() if r.Operator.Type == List { for i := 0; i < len(r.Operator.List); i++ { - r.Operator.List[i].Compile() + if err := r.Operator.List[i].Compile(); err != nil { + log.Warning("Operator.Compile() error: %s: ", err) + } } } diskRules[r.Name] = r.Name @@ -211,14 +213,24 @@ func (l *Loader) replaceUserRule(rule *Rule) (err error) { l.rules[rule.Name] = rule l.sortRules() l.Unlock() + + rule.Operator.isCompiled = false + if err := rule.Operator.Compile(); err != nil { + log.Warning("Operator.Compile() error: %s: ", err, rule.Operator.Data) + } + if rule.Operator.Type == List { // TODO: use List protobuf object instead of un/marshalling to/from json if err = json.Unmarshal([]byte(rule.Operator.Data), &rule.Operator.List); err != nil { return fmt.Errorf("Error loading rule of type list: %s", err) } + + // force re-Compile() changed rule for i := 0; i < len(rule.Operator.List); i++ { rule.Operator.List[i].isCompiled = false - rule.Operator.List[i].Compile() + if err := rule.Operator.Compile(); err != nil { + log.Warning("Operator.Compile() error: %s: ", err) + } } } diff --git a/daemon/rule/operator.go b/daemon/rule/operator.go index 1510bd8c..76d24639 100644 --- a/daemon/rule/operator.go +++ b/daemon/rule/operator.go @@ -29,6 +29,7 @@ const ( Complex = Type("complex") // for future use List = Type("list") Network = Type("network") + Lists = Type("lists") ) // Available operands @@ -46,6 +47,7 @@ const ( OpDstNetwork = Operand("dest.network") OpProto = Operand("protocol") OpList = Operand("list") + OpDomainsLists = Operand("lists.domains") ) type opCallback func(value interface{}) bool @@ -62,6 +64,7 @@ type Operator struct { re *regexp.Regexp netMask *net.IPNet isCompiled bool + lists map[string]string } // NewOperator returns a new operator object @@ -97,6 +100,14 @@ func (o *Operator) Compile() error { return err } o.re = re + } else if o.Type == Lists && o.Operand == OpDomainsLists { + if o.Data == "" { + return fmt.Errorf("Operand lists is empty, nothing to load: %s", o) + } + if err := o.loadLists(); err != nil { + return err + } + o.cb = o.domainsListCmp } else if o.Type == List { o.Operand = OpList } else if o.Type == Network { @@ -148,6 +159,18 @@ func (o *Operator) cmpNetwork(destIP interface{}) bool { return o.netMask.Contains(destIP.(net.IP)) } +func (o *Operator) domainsListCmp(v interface{}) bool { + dstHost := v.(string) + if dstHost == "" { + return false + } + if _, found := o.lists[dstHost]; found { + log.Debug("%s: %s, %s", log.Red("domain list match"), dstHost, o.lists[dstHost]) + return true + } + return false +} + func (o *Operator) listMatch(con interface{}) bool { res := true for i := 0; i < len(o.List); i++ { @@ -188,6 +211,8 @@ func (o *Operator) Match(con *conman.Connection) bool { return o.cb(con.DstIP) } else if o.Operand == OpList { return o.listMatch(con) + } else if o.Operand == OpDomainsLists { + return o.cb(con.DstHost) } return false diff --git a/ui/opensnitch/config.py b/ui/opensnitch/config.py index 479c3191..5bb40790 100644 --- a/ui/opensnitch/config.py +++ b/ui/opensnitch/config.py @@ -7,7 +7,7 @@ class Config: HELP_URL = "https://github.com/gustavo-iniguez-goya/opensnitch/wiki/Configurations" - RulesTypes = ("list", "simple", "regexp", "network") + RulesTypes = ("list", "lists", "simple", "regexp", "network") DEFAULT_DURATION_IDX = 6 # until restart DEFAULT_TARGET_PROCESS = 0 diff --git a/ui/opensnitch/dialogs/ruleseditor.py b/ui/opensnitch/dialogs/ruleseditor.py index 684aedef..b8ee13b5 100644 --- a/ui/opensnitch/dialogs/ruleseditor.py +++ b/ui/opensnitch/dialogs/ruleseditor.py @@ -15,7 +15,7 @@ from config import Config from nodes import Nodes from database import Database from version import version -from utils import Message +from utils import Message, FileDialog DIALOG_UI_PATH = "%s/../res/ruleseditor.ui" % os.path.dirname(sys.modules[__name__].__file__) class RulesEditorDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]): @@ -46,6 +46,7 @@ class RulesEditorDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]): self.buttonBox.button(QtWidgets.QDialogButtonBox.Close).clicked.connect(self._cb_close_clicked) self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).clicked.connect(self._cb_apply_clicked) self.buttonBox.button(QtWidgets.QDialogButtonBox.Help).clicked.connect(self._cb_help_clicked) + self.selectListButton.clicked.connect(self._cb_select_list_button_clicked) self.protoCheck.toggled.connect(self._cb_proto_check_toggled) self.procCheck.toggled.connect(self._cb_proc_check_toggled) self.cmdlineCheck.toggled.connect(self._cb_cmdline_check_toggled) @@ -53,6 +54,7 @@ class RulesEditorDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]): self.uidCheck.toggled.connect(self._cb_uid_check_toggled) self.dstIPCheck.toggled.connect(self._cb_dstip_check_toggled) self.dstHostCheck.toggled.connect(self._cb_dsthost_check_toggled) + self.dstListsCheck.toggled.connect(self._cb_dstlists_check_toggled) if QtGui.QIcon.hasThemeIcon("emblem-default") == False: self.actionAllowRadio.setIcon(self.style().standardIcon(getattr(QtWidgets.QStyle, "SP_DialogApplyButton"))) @@ -76,6 +78,11 @@ class RulesEditorDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]): def _cb_help_clicked(self): QtGui.QDesktopServices.openUrl(QtCore.QUrl(Config.HELP_URL)) + def _cb_select_list_button_clicked(self): + dirName = FileDialog.select_dir(self, self.dstListsLine.text()) + if dirName != None and dirName != "": + self.dstListsLine.setText(dirName) + def _cb_proto_check_toggled(self, state): self.protoCombo.setEnabled(state) @@ -97,6 +104,10 @@ class RulesEditorDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]): def _cb_dsthost_check_toggled(self, state): self.dstHostLine.setEnabled(state) + def _cb_dstlists_check_toggled(self, state): + self.dstListsLine.setEnabled(state) + self.selectListButton.setEnabled(state) + def _set_status_error(self, msg): self.statusLabel.setStyleSheet('color: red') self.statusLabel.setText(msg) @@ -184,6 +195,10 @@ class RulesEditorDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]): self.dstHostCheck.setChecked(False) self.dstHostLine.setText("") + self.selectListButton.setEnabled(False) + self.dstListsCheck.setChecked(False) + self.dstListsLine.setText("") + def _load_rule(self, addr=None, rule=None): self._load_nodes(addr) @@ -255,6 +270,12 @@ class RulesEditorDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]): self.dstHostLine.setEnabled(True) self.dstHostLine.setText(operator.data) + if operator.operand == "lists.domains": + self.dstListsCheck.setChecked(True) + self.dstListsCheck.setEnabled(True) + self.dstListsLine.setText(operator.data) + self.selectListButton.setEnabled(True) + def _load_nodes(self, addr=None): try: self.nodesCombo.clear() @@ -332,7 +353,7 @@ class RulesEditorDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]): Ensure that some constraints are met: - Determine if a field can be a regexp. - Validate regexp. - - Fields cam not be empty. + - Fields cannot be empty. - If the user has not provided a rule name, auto assign one. """ self.rule = ui_pb2.Rule() @@ -497,11 +518,29 @@ class RulesEditorDialog(QtWidgets.QDialog, uic.loadUiType(DIALOG_UI_PATH)[0]): if self._is_valid_regex(self.uidLine.text()) == False: return False, QC.translate("rules", "User ID regexp error") + if self.dstListsCheck.isChecked(): + if self.dstListsLine.text() == "": + return False, QC.translate("rules", "Lists field cannot be empty") + if os.path.isdir(self.dstListsLine.text()) == False: + return False, QC.translate("rules", "Lists field must be a directory") + + self.rule.operator.type = "lists" + self.rule.operator.operand = "lists.domains" + rule_data.append( + { + 'type': 'lists', + 'operand': 'lists.domains', + 'data': self.dstListsLine.text(), + 'sensitive': self.sensitiveCheck.isChecked() + }) + self.rule.operator.data = json.dumps(rule_data) + + if len(rule_data) > 1: self.rule.operator.type = "list" self.rule.operator.operand = "" self.rule.operator.data = json.dumps(rule_data) - elif len(rule_data) == 1: + else: self.rule.operator.operand = rule_data[0]['operand'] self.rule.operator.data = rule_data[0]['data'] if self._is_regex(self.rule.operator.data): diff --git a/ui/opensnitch/res/ruleseditor.ui b/ui/opensnitch/res/ruleseditor.ui index 1c47aad6..2d4fb9c1 100644 --- a/ui/opensnitch/res/ruleseditor.ui +++ b/ui/opensnitch/res/ruleseditor.ui @@ -7,7 +7,7 @@ 0 0 651 - 526 + 543 @@ -96,7 +96,7 @@ false - + QLayout::SetDefaultConstraint @@ -109,22 +109,15 @@ 12 - - + + - From this command line + To this IP / Network - - - - From this executable - - - - - + + Qt::Horizontal @@ -136,8 +129,18 @@ - - + + + + false + + + /path/to/executable, .*/bin/executable[0-9\.]+$, ... + + + + + Qt::Horizontal @@ -156,87 +159,6 @@ - - - - false - - - /path/to/executable, .*/bin/executable[0-9\.]+$, ... - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - To this IP / Network - - - - - - - false - - - - - - - - once - - - - - 30s - - - - - 5m - - - - - 15m - - - - - 30m - - - - - 1h - - - - - until reboot - - - - - always - - - - @@ -244,36 +166,15 @@ - - + + - From this user ID + To this list of domains - - - - false - - - Commas or spaces are not allowed to specify multiple domains. - -Use regular expressions instead: -.*(opensnitch|duckduckgo).com -.*\.google.com - -or a single domain: -www.gnu.org - it'll only match www.gnu.org, nor ftp.gnu.org, nor www2.gnu.org, ... -gnu.org - it'll only match gnu.org, nor www.gnu.org, nor ftp.gnu.org, ... - - - www.domain.org, .*\.domain.org - - - - - + + Qt::Horizontal @@ -285,53 +186,7 @@ gnu.org - it'll only match gnu.org, nor www.gnu.org, nor ftp.gnu.org, .. - - - - false - - - <html><head/><body><p>Only TCP, UDP or UDPLITE are allowed</p><p>You can use regexp, i.e.: ^(TCP|UDP)$</p></body></html> - - - true - - - TCP - - - - TCP - - - - - UDP - - - - - UDPLITE - - - - - TCP6 - - - - - UDP6 - - - - - UDPLITE6 - - - - - + false @@ -426,17 +281,98 @@ Note: Commas or spaces are not allowed to separate IPs or networks. - - - - Duration + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + false - - - - Protocol + + + + false + + + <html><head/><body><p>You can specify multiple ports using regular expressions:</p><p><br/></p><p>- 53, 80 or 443:</p><p>^(53|80|443)$</p><p><br/></p><p>- 53, 443 or 5551, 5552, 5553, etc:</p><p>^(53|443|555[0-9])$</p></body></html> + + + + + + + + once + + + + + 30s + + + + + 5m + + + + + 15m + + + + + 30m + + + + + 1h + + + + + until reboot + + + + + always + + + + + + + + false + + + Commas or spaces are not allowed to specify multiple domains. + +Use regular expressions instead: +.*(opensnitch|duckduckgo).com +.*\.google.com + +or a single domain: +www.gnu.org - it'll only match www.gnu.org, nor ftp.gnu.org, nor www2.gnu.org, ... +gnu.org - it'll only match gnu.org, nor www.gnu.org, nor ftp.gnu.org, ... + + + www.domain.org, .*\.domain.org @@ -447,14 +383,81 @@ Note: Commas or spaces are not allowed to separate IPs or networks. - + false - + + + + Duration + + + + + + + false + + + <html><head/><body><p>Only TCP, UDP or UDPLITE are allowed</p><p>You can use regexp, i.e.: ^(TCP|UDP)$</p></body></html> + + + true + + + TCP + + + + TCP + + + + + UDP + + + + + UDPLITE + + + + + TCP6 + + + + + UDP6 + + + + + UDPLITE6 + + + + + + + + Protocol + + + + + + + From this executable + + + + @@ -498,16 +501,47 @@ Note: Commas or spaces are not allowed to separate IPs or networks. - - - - false - - - <html><head/><body><p>You can specify multiple ports using regular expressions:</p><p><br/></p><p>- 53, 80 or 443:</p><p>^(53|80|443)$</p><p><br/></p><p>- 53, 443 or 5551, 5552, 5553, etc:</p><p>^(53|443|555[0-9])$</p></body></html> + + + + From this command line + + + + From this user ID + + + + + + + + + false + + + + + + + + + + + + + false + + + <html><head/><body><p>Select a directory with lists of domains to block or allow.</p><p>Put inside that directory files with any extension containing lists of domains.</p><p><br/>The format of each entry of a list is as follow (hosts format):</p><p>127.0.0.1 www.domain.com</p><p>or </p><p>0.0.0.0 www.domain.com</p></body></html> + + + + + diff --git a/ui/opensnitch/utils.py b/ui/opensnitch/utils.py index 076446fb..b4f5bab5 100644 --- a/ui/opensnitch/utils.py +++ b/ui/opensnitch/utils.py @@ -6,9 +6,8 @@ class Message(): @staticmethod def ok(title, message, icon): msgBox = QtWidgets.QMessageBox() - msgBox.setText(title) + msgBox.setText("{0}

{1}".format(title, message)) msgBox.setIcon(icon) - msgBox.setInformativeText(message) msgBox.setStandardButtons(QtWidgets.QMessageBox.Ok) msgBox.exec_() @@ -21,3 +20,24 @@ class Message(): msgBox.setStandardButtons(QtWidgets.QMessageBox.Cancel | QtWidgets.QMessageBox.Yes) msgBox.setDefaultButton(QtWidgets.QMessageBox.Cancel) return msgBox.exec_() + +class FileDialog(): + + @staticmethod + def save(parent): + options = QtWidgets.QFileDialog.Options() + fileName, _ = QtWidgets.QFileDialog.getSaveFileName(parent, "", "","All Files (*)", options=options) + return fileName + + @staticmethod + def select(parent): + options = QtWidgets.QFileDialog.Options() + fileName, _ = QtWidgets.QFileDialog.getOpenFileName(parent, "", "","All Files (*)", options=options) + return fileName + + @staticmethod + def select_dir(parent, current_dir): + options = QtWidgets.QFileDialog.Options() + fileName = QtWidgets.QFileDialog.getExistingDirectory(parent, "", current_dir, options) + return fileName +