├── +POST_DEINSTALL.post ├── +POST_INSTALL.post ├── +POST_INSTALL.pre ├── +PRE_DEINSTALL.pre ├── .editorconfig ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── pkg-descr └── src ├── etc ├── cron.d │ └── oscrowdsec.cron ├── crowdsec │ └── acquis.d │ │ └── opnsense.yaml ├── inc │ └── plugins.inc.d │ │ └── crowdsec.inc ├── rc.d │ └── oscrowdsec └── rc.syshook.d │ └── start │ └── 50-crowdsec └── opnsense ├── mvc └── app │ ├── controllers │ └── OPNsense │ │ └── CrowdSec │ │ ├── Api │ │ ├── AlertsController.php │ │ ├── BouncersController.php │ │ ├── CollectionsController.php │ │ ├── DecisionsController.php │ │ ├── GeneralController.php │ │ ├── MachinesController.php │ │ ├── ParsersController.php │ │ ├── PostoverflowsController.php │ │ ├── ScenariosController.php │ │ ├── ServiceController.php │ │ └── VersionController.php │ │ ├── GeneralController.php │ │ ├── OverviewController.php │ │ └── forms │ │ └── general.xml │ ├── models │ └── OPNsense │ │ └── CrowdSec │ │ ├── ACL │ │ └── ACL.xml │ │ ├── General.php │ │ ├── General.xml │ │ └── Menu │ │ └── Menu.xml │ └── views │ └── OPNsense │ └── CrowdSec │ ├── general.volt │ └── overview.volt ├── scripts └── OPNsense │ └── CrowdSec │ ├── debug.sh │ ├── hub-upgrade.sh │ ├── reconfigure.py │ └── reconfigure.sh ├── service ├── conf │ └── actions.d │ │ └── actions_crowdsec.conf └── templates │ └── OPNsense │ └── CrowdSec │ ├── +TARGETS │ ├── crowdsec.rc.conf.d │ ├── crowdsec_firewall.rc.conf.d │ ├── oscrowdsec.rc.conf.d │ └── settings.json └── www └── js └── CrowdSec └── crowdsec.js /+POST_DEINSTALL.post: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Removing the plugin from the web interface will autoremove the dependencies 4 | # too, and here we have to delete the files in rc.conf.d (because they are 5 | # generated from templates when the configuration is saved, and the package 6 | # system did not keep track of them). 7 | 8 | # But.. If the plugin is removed from the command line (which does not happen 9 | # outside of testing conditions), the crowdsec and bouncer services will not be 10 | # removed. However, since we deleted the files that enabled these services, 11 | # they will be disabled at the next reboot. 12 | 13 | rm -f /etc/rc.conf.d/crowdsec \ 14 | /etc/rc.conf.d/crowdsec_firewall \ 15 | /etc/rc.conf.d/oscrowdsec 16 | 17 | 18 | # Remove aliases and with them, the rules. We don't have plugin files 19 | # anymore so we do that on the fly. 20 | 21 | /usr/local/bin/php <<'EOT' 22 | aliases->alias->iterateItems() as $index => $alias) { 35 | if (strval($alias->name) == $name) { 36 | if ($model->aliases->alias->del($index)) { 37 | $model->serializeToConfig(); 38 | Config::getInstance()->save(); 39 | } 40 | } 41 | } 42 | } 43 | 44 | removeAlias('crowdsec_blacklists'); 45 | removeAlias('crowdsec6_blacklists'); 46 | EOT 47 | 48 | 49 | # apply the configuration changes to the packet filter 50 | configctl filter reload 51 | -------------------------------------------------------------------------------- /+POST_INSTALL.post: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | configctl crowdsec reconfigure 4 | -------------------------------------------------------------------------------- /+POST_INSTALL.pre: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # The configuration file used in reconfigure (i.e. settings.json) may eventually 4 | # have credentials, so we need to restrict its permissions. We do so by pre-creating 5 | # the directory, and the template package will use its permissions while creating the file. 6 | # If we do that in setup.sh, the file already exists with bad permissions. 7 | 8 | # shellcheck disable=SC2174 9 | mkdir -p -m 0700 /usr/local/etc/crowdsec/opnsense 10 | -------------------------------------------------------------------------------- /+PRE_DEINSTALL.pre: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # need to temporarily stop the bouncer to remove all the rules 4 | service crowdsec_firewall stop >/dev/null 2>&1 | : 5 | 6 | # the rest of the cleanup is done in the post-deinstall script, otherwise 7 | # the plugin recreates the objects during "filter reload". 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | insert_final_newline = true 8 | 9 | [*.{js,py,php,inc,volt}] 10 | indent_size = 4 11 | trim_trailing_whitespace = true 12 | 13 | [*.xml] 14 | indent_size = 2 15 | trim_trailing_whitespace = false 16 | 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | work/ 2 | node_modules 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Crowdsec 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME= crowdsec 2 | PLUGIN_VERSION= 1.0 3 | PLUGIN_DEPENDS= crowdsec 4 | PLUGIN_COMMENT= Lightweight and collaborative security engine 5 | PLUGIN_MAINTAINER= marco@crowdsec.net 6 | PLUGIN_WWW= https://crowdsec.net/ 7 | 8 | .include "../../Mk/plugins.mk" 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Beware 3 | ------ 4 | 5 | * The code in this repository has been merged in [https://github.com/opnsense/plugins](https://github.com/opnsense/plugins) 6 | and new releases are done through the OPNsense channels. 7 | 8 | * The documentation is available at [docs.crowdsec.net](https://docs.crowdsec.net/docs/next/getting_started/install_crowdsec_opnsense). 9 | 10 | * Don't expect many features on the web interface, but feel free to give us a sense 11 | of your priorities. The command line should work the same as under Linux. 12 | 13 | * The exact same application under Linux and FreeBSD can have logs in 14 | different location and format. This means that supporting OPNsense plugins 15 | may require modifying the existing parsers or writing new ones. We did that 16 | for SSH and the web interface, let us know what else you want us to protect 17 | on your firewall. 18 | 19 | * For the changelog, see [pkg-descr](pkg-descr) 20 | 21 | -------------------------------------------------------------------------------- /pkg-descr: -------------------------------------------------------------------------------- 1 | Crowdsec is an open-source, lightweight software, detecting peers with 2 | aggressive behaviors to prevent them from accessing your systems. Its user 3 | friendly design and assistance offers a low technical barrier of entry and 4 | nevertheless a high security gain. 5 | 6 | WWW: https://crowdsec.net/ 7 | 8 | Plugin Changelog 9 | ================ 10 | 11 | 1.0 12 | 13 | * first non-devel release 14 | * changed service restart to reload on hub update; fixed "service oscrowdsec status" 15 | 16 | 0.2 17 | 18 | * first published release 19 | * added options `lapi_enabled`, `crowdsec_firewall_verbose` 20 | * removed options `crowdsec_flags`, `crowdsec_firewall_flags` 21 | * changed default for `agent_enabled`, `firewall_bouncer_enabled` to 1 22 | 23 | 0.1 24 | 25 | * fixed packet tags with ipv6 26 | * custom `crowdsec_flags`, `crosdsec_firewall_flags` 27 | 28 | 0.0.9 29 | 30 | * fixed the javascript, 0.0.8 had a syntax error 31 | * new option: rules_tag 32 | * new option: lapi_manual_configuration 33 | * ipv4/ipv6 validation with regexp 34 | 35 | 0.0.8 36 | 37 | * crowdsec update 1.3.2 38 | * configurable `rules_log` and LAPI address/port 39 | 40 | 0.0.7 41 | 42 | * automated removal of Alias objects when the plugin is uninstalled 43 | 44 | 0.0.6 45 | 46 | * crowdsec update 1.3.1.r1 47 | * bouncer update to 0.0.23.r1 48 | * automated creation of Alias and Rule objects 49 | 50 | 0.0.5 51 | 52 | * fixed an issue that prevented the bouncer from banning IPs on opnsense 53 | * fixed support for notification plugins 54 | -------------------------------------------------------------------------------- /src/etc/cron.d/oscrowdsec.cron: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE -- OPNsense auto-generated file 2 | # 3 | # User-defined crontab files can be loaded via /etc/cron.d 4 | # or /usr/local/etc/cron.d and follow the same format as 5 | # /etc/crontab, see the crontab(5) manual page. 6 | SHELL=/bin/sh 7 | PATH=/etc:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin 8 | #minute hour mday month wday who command 9 | 0 1,13 * * * root /usr/local/opnsense/scripts/OPNsense/CrowdSec/hub-upgrade.sh 10 | -------------------------------------------------------------------------------- /src/etc/crowdsec/acquis.d/opnsense.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Before 22.1, OPNsense used circular logs under /var/log/*.log that 3 | # can still be around. They are old, in binary format and are not needed by crowdsec. 4 | # 5 | # For this reason we don't scan /var/log/*.log, but some plugins can write 6 | # their (plaintext) logs in that location, in such case add their pathnames too. 7 | # 8 | 9 | filenames: 10 | # ssh 11 | - /var/log/audit/*.log 12 | # web admin 13 | - /var/log/lighttpd/*.log 14 | labels: 15 | type: syslog 16 | -------------------------------------------------------------------------------- /src/etc/inc/plugins.inc.d/crowdsec.inc: -------------------------------------------------------------------------------- 1 | 5 | 6 | use OPNsense\Core\Config; 7 | use OPNsense\Firewall\Alias; 8 | use OPNsense\Firewall\Plugin; 9 | 10 | function add_alias_if_not_exist($name, $description, $proto) 11 | { 12 | $model = new Alias(); 13 | 14 | if ($model->getByName($name) != null) { 15 | return; 16 | } 17 | 18 | $new_alias = $model->aliases->alias->Add(); 19 | $new_alias->name = $name; 20 | $new_alias->description = $description; 21 | $new_alias->proto = $proto; 22 | $new_alias->type = 'external'; 23 | $model->serializeToConfig(); 24 | Config::getInstance()->save(); 25 | } 26 | 27 | function crowdsec_firewall(Plugin $fw) 28 | { 29 | global $config; 30 | 31 | $general = $config['OPNsense']['crowdsec']['general']; 32 | 33 | $bouncer_enabled = isset($general['firewall_bouncer_enabled']) && $general['firewall_bouncer_enabled']; 34 | 35 | if (!$bouncer_enabled) { 36 | return; 37 | } 38 | 39 | $rules_log_enabled = isset($general['rules_log']) && $general['rules_log']; 40 | 41 | $rules_tag = ""; 42 | if (isset($general['rules_tag'])) { 43 | $rules_tag = $general['rules_tag']; 44 | } 45 | 46 | add_alias_if_not_exist('crowdsec_blacklists', 'CrowdSec (IPv4)', 'IPv4'); 47 | 48 | // https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/library/OPNsense/Firewall/FilterRule.php 49 | 50 | $fw->registerFilterRule( 51 | 1, /* priority */ 52 | array( 53 | 'ipprotocol' => 'inet', 54 | 'descr' => 'CrowdSec (IPv4)', 55 | 'from' => '$crowdsec_blacklists', # $ to reference an alias 56 | 'type' => 'block', 57 | 'log' => $rules_log_enabled, 58 | 'tag' => $rules_tag, 59 | 'label' => 'blocked by crowdsec', 60 | 'quick' => true 61 | ), 62 | null 63 | ); 64 | 65 | add_alias_if_not_exist('crowdsec6_blacklists', 'CrowdSec (IPv6)', 'IPv6'); 66 | 67 | $fw->registerFilterRule( 68 | 1, /* priority */ 69 | array( 70 | 'ipprotocol' => 'inet6', 71 | 'descr' => 'CrowdSec (IPv6)', 72 | 'from' => '$crowdsec6_blacklists', # $ to reference an alias 73 | 'type' => 'block', 74 | 'log' => $rules_log_enabled, 75 | 'tag' => $rules_tag, 76 | 'label' => 'blocked by crowdsec', 77 | 'quick' => true 78 | ), 79 | null 80 | ); 81 | } 82 | 83 | function crowdsec_services() 84 | { 85 | $services[] = array( 86 | 'description' => 'CrowdSec', 87 | 'configd' => array( 88 | 'restart' => array('crowdsec restart'), 89 | 'start' => array('crowdsec start'), 90 | 'stop' => array('crowdsec stop'), 91 | ), 92 | 'name' => 'crowdsec' 93 | ); 94 | 95 | return $services; 96 | } 97 | -------------------------------------------------------------------------------- /src/etc/rc.d/oscrowdsec: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # $FreeBSD$ 4 | # 5 | 6 | # PROVIDE: oscrowdsec 7 | # REQUIRE: NETWORKING syslogd 8 | # BEFORE: DAEMON 9 | # KEYWORD: shutdown 10 | 11 | # shellcheck disable=SC1091 12 | . /etc/rc.subr 13 | 14 | name="oscrowdsec" 15 | rcvar="oscrowdsec_enable" 16 | 17 | load_rc_config $name 18 | 19 | : "${oscrowdsec_enable="NO"}" 20 | 21 | 22 | oscrowdsec_start () { 23 | # 24 | # Start, or stop the services according to the plugin's configuration. 25 | # When starting -> error if the services are already running 26 | # When stopping -> no error 27 | # 28 | 29 | if service crowdsec enabled; then 30 | service crowdsec start 31 | else 32 | service crowdsec stop || : 33 | fi 34 | 35 | if service crowdsec_firewall enabled; then 36 | service crowdsec_firewall start 37 | else 38 | service crowdsec_firewall stop || : 39 | fi 40 | } 41 | 42 | oscrowdsec_stop () { 43 | # Always stop the services, enabled or not, running or not. No errors. 44 | 45 | service crowdsec stop || : 46 | service crowdsec_firewall stop || : 47 | } 48 | 49 | oscrowdsec_restart () { 50 | oscrowdsec_stop || : 51 | oscrowdsec_start 52 | } 53 | 54 | oscrowdsec_status () { 55 | # return error if at least one program is not running 56 | service crowdsec status 57 | ret=$? 58 | 59 | if ! service crowdsec_firewall status; then 60 | ret=1 61 | fi 62 | return $ret 63 | } 64 | 65 | oscrowdsec_reload () { 66 | if service crowdsec enabled; then 67 | if service crowdsec status >/dev/null 2>&1; then 68 | service crowdsec reload 69 | else 70 | service crowdsec restart 71 | fi 72 | fi 73 | 74 | if service crowdsec_firewall enabled; then 75 | # the bouncer does not support reload 76 | service crowdsec_firewall restart 77 | fi 78 | } 79 | 80 | case $1 in 81 | start) 82 | oscrowdsec_start 83 | exit $? 84 | ;; 85 | stop) 86 | oscrowdsec_stop 87 | exit $? 88 | ;; 89 | restart) 90 | oscrowdsec_restart 91 | exit $? 92 | ;; 93 | status) 94 | oscrowdsec_status 95 | exit $? 96 | ;; 97 | reload) 98 | oscrowdsec_reload 99 | exit $? 100 | ;; 101 | esac 102 | -------------------------------------------------------------------------------- /src/etc/rc.syshook.d/start/50-crowdsec: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # https://docs.opnsense.org/development/backend/autorun.html 4 | 5 | /usr/local/opnsense/scripts/OPNsense/CrowdSec/hub-upgrade.sh 6 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/Api/AlertsController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec\Api; 7 | 8 | use OPNsense\Base\ApiControllerBase; 9 | use OPNsense\CrowdSec\CrowdSec; 10 | use OPNsense\Core\Backend; 11 | 12 | /** 13 | * @package OPNsense\CrowdSec 14 | */ 15 | class AlertsController extends ApiControllerBase 16 | { 17 | /** 18 | * retrieve list of alerts 19 | * @return array of alerts 20 | * @throws \OPNsense\Base\ModelException 21 | * @throws \ReflectionException 22 | */ 23 | public function getAction() 24 | { 25 | $backend = new Backend(); 26 | $bckresult = json_decode(trim($backend->configdRun("crowdsec alerts-list")), true); 27 | if ($bckresult !== null) { 28 | // only return valid json type responses 29 | return $bckresult; 30 | } 31 | return array("message" => "unable to list alerts"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/Api/BouncersController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec\Api; 7 | 8 | use OPNsense\Base\ApiControllerBase; 9 | use OPNsense\CrowdSec\CrowdSec; 10 | use OPNsense\Core\Backend; 11 | 12 | /** 13 | * @package OPNsense\CrowdSec 14 | */ 15 | class BouncersController extends ApiControllerBase 16 | { 17 | /** 18 | * retrieve list of bouncers 19 | * @return array of bouncers 20 | * @throws \OPNsense\Base\ModelException 21 | * @throws \ReflectionException 22 | */ 23 | public function getAction() 24 | { 25 | $backend = new Backend(); 26 | $bckresult = json_decode(trim($backend->configdRun("crowdsec bouncers-list")), true); 27 | if ($bckresult !== null) { 28 | // only return valid json type responses 29 | return $bckresult; 30 | } 31 | return array("message" => "unable to list bouncers"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/Api/CollectionsController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec\Api; 7 | 8 | use OPNsense\Base\ApiControllerBase; 9 | use OPNsense\CrowdSec\CrowdSec; 10 | use OPNsense\Core\Backend; 11 | 12 | /** 13 | * @package OPNsense\CrowdSec 14 | */ 15 | class CollectionsController extends ApiControllerBase 16 | { 17 | /** 18 | * retrieve list of collections 19 | * @return array of collections 20 | * @throws \OPNsense\Base\ModelException 21 | * @throws \ReflectionException 22 | */ 23 | public function getAction() 24 | { 25 | $backend = new Backend(); 26 | $bckresult = json_decode(trim($backend->configdRun("crowdsec collections-list")), true); 27 | if ($bckresult !== null) { 28 | // only return valid json type responses 29 | return $bckresult; 30 | } 31 | return array("message" => "unable to list collections"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/Api/DecisionsController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec\Api; 7 | 8 | use OPNsense\Base\ApiControllerBase; 9 | use OPNsense\CrowdSec\CrowdSec; 10 | use OPNsense\Core\Backend; 11 | 12 | /** 13 | * @package OPNsense\CrowdSec 14 | */ 15 | class DecisionsController extends ApiControllerBase 16 | { 17 | /** 18 | * retrieve list of decisions 19 | * @return array of decisions 20 | * @throws \OPNsense\Base\ModelException 21 | * @throws \ReflectionException 22 | */ 23 | public function getAction() 24 | { 25 | $backend = new Backend(); 26 | $bckresult = json_decode(trim($backend->configdRun("crowdsec decisions-list")), true); 27 | if ($bckresult !== null) { 28 | // only return valid json type responses 29 | return $bckresult; 30 | } 31 | return array("message" => "unable to list decisions"); 32 | } 33 | 34 | public function deleteAction($decision_id) 35 | { 36 | if ($this->request->isDelete()) { 37 | $backend = new Backend(); 38 | $bckresult = $backend->configdRun("crowdsec decisions-delete ${decision_id}"); 39 | if ($bckresult !== null) { 40 | // why does the action return \n\n for empty output? 41 | if (trim($bckresult) === '') { 42 | return array("message" => "OK"); 43 | } 44 | // TODO handle error 45 | return array("message" => $bckresult); 46 | } 47 | return array("message" => "OK"); 48 | } else { 49 | $this->response->setStatusCode(405, "Method Not Allowed"); 50 | $this->response->setHeader("Allow", "DELETE"); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/Api/GeneralController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec\Api; 7 | 8 | use OPNsense\Base\ApiMutableModelControllerBase; 9 | 10 | /** 11 | * @package OPNsense\CrowdSec 12 | */ 13 | class GeneralController extends ApiMutableModelControllerBase 14 | { 15 | protected static $internalModelName = 'general'; 16 | protected static $internalModelClass = '\OPNsense\CrowdSec\General'; 17 | } 18 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/Api/MachinesController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec\Api; 7 | 8 | use OPNsense\Base\ApiControllerBase; 9 | use OPNsense\CrowdSec\CrowdSec; 10 | use OPNsense\Core\Backend; 11 | 12 | /** 13 | * @package OPNsense\CrowdSec 14 | */ 15 | class MachinesController extends ApiControllerBase 16 | { 17 | /** 18 | * retrieve list of registered machines 19 | * @return array of machines 20 | * @throws \OPNsense\Base\ModelException 21 | * @throws \ReflectionException 22 | */ 23 | public function getAction() 24 | { 25 | $backend = new Backend(); 26 | $bckresult = json_decode(trim($backend->configdRun("crowdsec machines-list")), true); 27 | if ($bckresult !== null) { 28 | // only return valid json type responses 29 | return $bckresult; 30 | } 31 | return array("message" => "unable to list machines"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/Api/ParsersController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec\Api; 7 | 8 | use OPNsense\Base\ApiControllerBase; 9 | use OPNsense\CrowdSec\CrowdSec; 10 | use OPNsense\Core\Backend; 11 | 12 | /** 13 | * @package OPNsense\CrowdSec 14 | */ 15 | class ParsersController extends ApiControllerBase 16 | { 17 | /** 18 | * retrieve list of registered parsers 19 | * @return array of parsers 20 | * @throws \OPNsense\Base\ModelException 21 | * @throws \ReflectionException 22 | */ 23 | public function getAction() 24 | { 25 | $backend = new Backend(); 26 | $bckresult = json_decode(trim($backend->configdRun("crowdsec parsers-list")), true); 27 | if ($bckresult !== null) { 28 | // only return valid json type responses 29 | return $bckresult; 30 | } 31 | return array("message" => "unable to list parsers"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/Api/PostoverflowsController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec\Api; 7 | 8 | use OPNsense\Base\ApiControllerBase; 9 | use OPNsense\CrowdSec\CrowdSec; 10 | use OPNsense\Core\Backend; 11 | 12 | /** 13 | * @package OPNsense\CrowdSec 14 | */ 15 | class PostoverflowsController extends ApiControllerBase 16 | { 17 | /** 18 | * retrieve list of registered postoverflows 19 | * @return array of postoverflows 20 | * @throws \OPNsense\Base\ModelException 21 | * @throws \ReflectionException 22 | */ 23 | public function getAction() 24 | { 25 | $backend = new Backend(); 26 | $bckresult = json_decode(trim($backend->configdRun("crowdsec postoverflows-list")), true); 27 | if ($bckresult !== null) { 28 | // only return valid json type responses 29 | return $bckresult; 30 | } 31 | return array("message" => "unable to list postoverflows"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/Api/ScenariosController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec\Api; 7 | 8 | use OPNsense\Base\ApiControllerBase; 9 | use OPNsense\CrowdSec\CrowdSec; 10 | use OPNsense\Core\Backend; 11 | 12 | /** 13 | * @package OPNsense\CrowdSec 14 | */ 15 | class ScenariosController extends ApiControllerBase 16 | { 17 | /** 18 | * retrieve list of registered scenarios 19 | * @return array of scenarios 20 | * @throws \OPNsense\Base\ModelException 21 | * @throws \ReflectionException 22 | */ 23 | public function getAction() 24 | { 25 | $backend = new Backend(); 26 | $bckresult = json_decode(trim($backend->configdRun("crowdsec scenarios-list")), true); 27 | if ($bckresult !== null) { 28 | // only return valid json type responses 29 | return $bckresult; 30 | } 31 | return array("message" => "unable to list scenarios"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/Api/ServiceController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec\Api; 7 | 8 | use OPNsense\Base\ApiControllerBase; 9 | use OPNsense\Core\Backend; 10 | 11 | /** 12 | * Class ServiceController 13 | * @package OPNsense\CrowdSec 14 | */ 15 | class ServiceController extends ApiControllerBase 16 | { 17 | /** 18 | * reconfigure CrowdSec 19 | */ 20 | public function reloadAction() 21 | { 22 | $status = "failed"; 23 | if ($this->request->isPost()) { 24 | $backend = new Backend(); 25 | $bckresult = trim($backend->configdRun('template reload OPNsense/CrowdSec')); 26 | if ($bckresult == "OK") { 27 | $bckresult = trim($backend->configdRun('crowdsec reconfigure')); 28 | if ($bckresult == "OK") { 29 | $status = "ok"; 30 | } 31 | } 32 | } 33 | return array("status" => $status); 34 | } 35 | 36 | /** 37 | * retrieve status of crowdsec 38 | * @return array 39 | * @throws \Exception 40 | */ 41 | public function statusAction() 42 | { 43 | $backend = new Backend(); 44 | $response = $backend->configdRun("crowdsec crowdsec-status"); 45 | 46 | $status = "unknown"; 47 | if (strpos($response, "not running") > 0) { 48 | $status = "stopped"; 49 | } elseif (strpos($response, "is running") > 0) { 50 | $status = "running"; 51 | } 52 | 53 | $response = $backend->configdRun("crowdsec crowdsec-firewall-status"); 54 | 55 | $firewall_status = "unknown"; 56 | if (strpos($response, "not running") > 0) { 57 | $firewall_status = "stopped"; 58 | } elseif (strpos($response, "is running") > 0) { 59 | $firewall_status = "running"; 60 | } 61 | 62 | return array( 63 | "crowdsec-status" => $status, 64 | "crowdsec-firewall-status" => $firewall_status, 65 | ); 66 | } 67 | 68 | /** 69 | * return debug information 70 | * @return array 71 | */ 72 | public function debugAction() 73 | { 74 | $backend = new Backend(); 75 | $response = $backend->configdRun("crowdsec debug"); 76 | return array("message" => $response); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/Api/VersionController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec\Api; 7 | 8 | use OPNsense\Base\ApiControllerBase; 9 | use OPNsense\CrowdSec\CrowdSec; 10 | use OPNsense\Core\Backend; 11 | 12 | /** 13 | * @package OPNsense\CrowdSec 14 | */ 15 | class VersionController extends ApiControllerBase 16 | { 17 | /** 18 | * retrieve version description 19 | * @return version description 20 | * @throws \OPNsense\Base\ModelException 21 | * @throws \ReflectionException 22 | */ 23 | public function getAction() 24 | { 25 | $backend = new Backend(); 26 | return $backend->configdRun("crowdsec version"); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/GeneralController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec; 7 | 8 | /** 9 | * Class GeneralController 10 | * @package OPNsense\CrowdSec 11 | */ 12 | class GeneralController extends \OPNsense\Base\IndexController 13 | { 14 | public function indexAction() 15 | { 16 | $this->view->pick('OPNsense/CrowdSec/general'); 17 | $this->view->generalForm = $this->getForm("general"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/OverviewController.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec; 7 | 8 | /** 9 | * Class OverviewController 10 | * @package OPNsense\CrowdSec 11 | */ 12 | class OverviewController extends \OPNsense\Base\IndexController 13 | { 14 | public function indexAction() 15 | { 16 | $this->view->pick('OPNsense/CrowdSec/overview'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/controllers/OPNsense/CrowdSec/forms/general.xml: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | general.agent_enabled 6 | 7 | checkbox 8 | Enable/disable the CrowdSec agent. Keep this enabled to detect 9 | attacks and receive alerts from the CrowSec central service. 10 | 11 | 12 | 13 | 14 | general.lapi_enabled 15 | 16 | checkbox 17 | Enable/disable the CrowdSec Local API. Keep this enabled unless you 18 | connect to a LAPI on another machine. 19 | 20 | 21 | 22 | 23 | general.firewall_bouncer_enabled 24 | 25 | checkbox 26 | Enable/disable the firewall bouncer. Keep this enabled to block 27 | packets from the attacking IP addresses. 28 | 29 | 30 | 31 | 32 | general.lapi_manual_configuration 33 | 34 | checkbox 35 | Avoid overwriting LAPI settings for config.yaml, 36 | local_api_credentials.yaml, crowdsec-firewall-bouncer.yaml. The next 37 | two configuration options (lapi_listen_address, lapi_listen_port) will 38 | be ignored. Allows unsupported configurations like linking together 39 | multiple opnsense instances or connecting to an existing crowdsec 40 | multi-server setup. 41 | 42 | 43 | 44 | 45 | general.lapi_listen_address 46 | 47 | text 48 | Where to listen for LAPI connections: IP address. The default value 49 | is 127.0.0.1. You can change it to a LAN address to connect from other 50 | agents/machines and bouncers. 51 | 52 | This is written in /usr/local/etc/crowdsec/config.yaml, 53 | local_api_credentials.yaml and bouncers/crowdsec-firewall-bouncer.yaml. 54 | To enable TLS, add the certificate information to config.yaml and change 55 | http to https in the other two files. Comments in YAML will not be 56 | preserved. 57 | 58 | 59 | 60 | 61 | general.lapi_listen_port 62 | 63 | text 64 | Where to listen for LAPI connections: port. The default value is 65 | 8080, but you can change it to avoid conflicts with existing 66 | services. 67 | 68 | 69 | 70 | 71 | general.rules_log 72 | 73 | checkbox 74 | Enable log collection for CrowdSec's block rules. 75 | 76 | 77 | 78 | 79 | general.rules_tag 80 | 81 | text 82 | Add a tag to packets that are dropped by CrowdSec rules for 83 | diagnostic purposes. 84 | 85 | 86 | 87 | 88 | general.crowdsec_firewall_verbose 89 | 90 | checkbox 91 | Verbose /var/log/crowdsec/crowdsec-firewall-bouncer.log. Enable this 92 | for debugging. 93 | 94 | 95 |
96 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/models/OPNsense/CrowdSec/ACL/ACL.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | CrowdSec 4 | 5 | ui/crowdsec/* 6 | api/crowdsec/* 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/models/OPNsense/CrowdSec/General.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | namespace OPNsense\CrowdSec; 7 | 8 | use OPNsense\Base\BaseModel; 9 | 10 | class General extends BaseModel 11 | { 12 | } 13 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/models/OPNsense/CrowdSec/General.xml: -------------------------------------------------------------------------------- 1 | 2 | //OPNsense/crowdsec/general 3 | CrowdSec general configuration 4 | 1.0 5 | 6 | 7 | 8 | 1 9 | Y 10 | 11 | 12 | 13 | 1 14 | Y 15 | 16 | 17 | 18 | 1 19 | Y 20 | 21 | 22 | 23 | 0 24 | Y 25 | 26 | 27 | 28 | 127.0.0.1 29 | Y 30 | ((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$)) 31 | 32 | 33 | 34 | 8080 35 | Y 36 | N 37 | N 38 | 39 | 40 | 41 | 0 42 | Y 43 | 44 | 45 | 46 | 47 | N 48 | 49 | 50 | 51 | 0 52 | Y 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/models/OPNsense/CrowdSec/Menu/Menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/views/OPNsense/CrowdSec/general.volt: -------------------------------------------------------------------------------- 1 | {# SPDX-License-Identifier: MIT #} 2 | {# SPDX-FileCopyrightText: © 2021 CrowdSec #} 3 | 4 | 5 | 33 | 34 | 44 | 45 | 49 | 50 |
51 |
52 |

Introduction

53 | 54 |

This plugin installs a CrowdSec agent/LAPI 55 | node, and a Firewall Bouncer.

56 | 57 |

Out of the box, by enabling them in the "Settings" tab, they can protect the OPNsense server 58 | by receiving thousands of IP addresses of active attackers, which are immediately banned at the 59 | firewall level. In addition, the logs of the ssh service and OPNsense administration interface are 60 | analyzed for possible brute-force attacks; any such scenario triggers a ban and is reported to the 61 | CrowdSec Central API 62 | (meaning timestamp, scenario, attacking IP).

63 | 64 |

Other attack behaviors can be recognized on the OPNsense server and its plugins, or 65 | any other agent 66 | connected to the same LAPI node. Other types of remediation are possible (ex. captcha test for scraping attempts).

67 | 68 | We recommend you to register to the Console. This helps you manage your instances, 69 | and us to have better overall metrics. 70 | 71 |

Please refer to the tutorials to explore 72 | the possibilities.

73 | 74 |

For the latest plugin documentation, including how to use it with an external LAPI, see Install 76 | CrowdSec (OPNsense)

77 | 78 |

A few remarks:

79 | 80 |
    81 |
  • 82 | If your OPNsense is <22.1, you must check "Disable circular logs" in the Settings menu for the 83 | ssh and web-auth parsers to work. If you upgrade to 22.1, it will be done automatically. 84 | See acquis.d/opnsense.yaml 85 |
  • 86 |
  • 87 | At the moment, the CrowdSec package for OPNsense is fully functional on the 88 | command line but its web interface is limited; you can only list the installed objects and revoke 89 | decisions. For anything else 90 | you need the shell. 91 |
  • 92 |
  • 93 | Do not enable/start the agent and bouncer services with sysrc or /etc/rc.conf 94 | like you would on vanilla freebsd, the plugin takes care of that. 95 |
  • 96 |
  • 97 | The parsers, scenarios and all plugins from the Hub are periodically upgraded. The 98 | crowdsecurity/freebsd and 99 | crowdsecurity/opnsense 100 | collections are installed by default. 101 |
  • 102 |
103 | 104 |
105 | 106 | Documentation 107 | 108 | 109 | Blog 110 | 111 | 112 | Console 113 | 114 | 115 | CrowdSec Hub 116 | 117 |
118 | 119 |

Installation

120 | 121 |

122 | On the Settings tab, you can expose CrowdSec to the LAN for other servers by changing `LAPI listen address`. 123 | Otherwise, leave the defualt value. 124 |

125 | 126 |

127 | Select the first three checkboxes: IDS, LAPI and IPS. Click Apply. If you need to restart, you can do so 128 | from the System > Diagnostics > Services page. 129 |

130 | 131 |

Test the plugin

132 | 133 |

134 | A quick way to test that everything is working correctly is to 135 | execute the following command. 136 |

137 | 138 |

139 | Your ssh session should freeze and you should be kicked out from 140 | the firewall. You will not be able to connect to it (from the same 141 | IP address) for two minutes. 142 |

143 | 144 |

145 | It might be a good idea to have a secondary IP from which you can 146 | connect, should anything go wrong. 147 |

148 | 149 |
[root@OPNsense ~]# cscli decisions add -t ban -d 2m -i 
150 | 151 |

152 | This is a more secure way to test than attempting to brute-force 153 | yourself: the default ban period is 4 hours, and Crowdsec reads the 154 | logs from the beginning, so it could ban you even if you failed ssh 155 | login 10 times in 30 seconds two hours before installing it. 156 |

157 | 158 |
159 | 160 | GitHub 161 | 162 | 163 | Discourse 164 | 165 | 166 | Discord 167 | 168 | 169 | Twitter 170 | 171 |
172 |
173 | 174 |
175 | 177 |
178 | {{ partial("layout_partials/base_form",['fields':generalForm,'id':'frm_GeneralSettings'])}} 179 |
180 | 181 |
182 | 183 |
184 |
185 |
186 | -------------------------------------------------------------------------------- /src/opnsense/mvc/app/views/OPNsense/CrowdSec/overview.volt: -------------------------------------------------------------------------------- 1 | {# SPDX-License-Identifier: MIT #} 2 | {# SPDX-FileCopyrightText: © 2021 CrowdSec #} 3 | 4 | 5 | 6 | 7 | 12 | 13 | 34 | 35 |
36 | Service status: crowdsec ... - firewall bouncer ... 37 |
38 | 39 | 50 | 51 |
52 | 53 |
54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 |
NameIP AddressLast UpdateValidated?Version
71 |
72 | 73 |
74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 |
NameIP AddressValidLast API PullTypeVersion
92 |
93 | 94 |
95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 |
NameStatusVersionLocal Path
111 |
112 | 113 |
114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 |
NameStatusVersionPathDescription
131 |
132 | 133 |
134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 |
NameStatusVersionLocal PathDescription
151 |
152 | 153 |
154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 |
NameStatusVersionLocal PathDescription
171 |
172 | 173 |
174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 |
IDValueReasonCountryASDecisionsCreated At
193 |
194 | 195 |
196 | Note: the decisions coming from the CAPI (signals collected by the CrowdSec users) do not appear here. 197 | To show them, use cscli decisions list -a in a shell. 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 |
IDSourceScope:ValueReasonActionCountryASEventsExpirationAlert ID
221 |
222 | 223 |
224 |
225 |         
226 |
227 | 228 | 229 | 248 | 249 |
250 | -------------------------------------------------------------------------------- /src/opnsense/scripts/OPNsense/CrowdSec/debug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | printf "service oscrowdsec enabled: " 4 | if service oscrowdsec enabled; then echo "YES"; else echo "NO"; fi 5 | echo 6 | 7 | printf "service crowdsec enabled: " 8 | if service oscrowdsec enabled; then echo "YES"; else echo "NO"; fi 9 | printf "service crowdsec status: "; service crowdsec status 10 | echo 11 | 12 | echo "crowdsec version:" 13 | crowdsec -version 2>&1 14 | echo 15 | 16 | printf "service crowdsec_firewall enabled: " 17 | if service crowdsec_firewall enabled; then echo "YES"; else echo "NO"; fi 18 | printf "service crowdsec_firewall status: "; service crowdsec_firewall status 19 | echo 20 | 21 | echo "crowdsec-firewall-bouncer version:" 22 | crowdsec-firewall-bouncer -V 23 | echo 24 | 25 | printf "pf anchor: " 26 | if ! pfctl -sa | grep crowdsec; then "NO"; fi 27 | 28 | -------------------------------------------------------------------------------- /src/opnsense/scripts/OPNsense/CrowdSec/hub-upgrade.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | /usr/local/bin/cscli --error hub update \ 4 | && /usr/local/bin/cscli --error hub upgrade 5 | 6 | if [ ! -e "/usr/local/etc/crowdsec/collections/opnsense.yaml" ]; then 7 | /usr/local/bin/cscli --error collections install crowdsecurity/opnsense 8 | fi 9 | 10 | if service crowdsec enabled; then 11 | if ! service crowdsec status >/dev/null 2>&1; then 12 | service crowdsec start >/dev/null 2>&1 || : 13 | else 14 | service crowdsec reload >/dev/null 2>&1 || : 15 | fi 16 | fi 17 | -------------------------------------------------------------------------------- /src/opnsense/scripts/OPNsense/CrowdSec/reconfigure.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import json 5 | import urllib.parse 6 | import yaml 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | 10 | 11 | def load_config(filename): 12 | with open(filename) as fin: 13 | return yaml.safe_load(fin) 14 | 15 | 16 | # only save if some value has changed 17 | def save_config(filename, new_config): 18 | old_config = load_config(filename) 19 | if old_config != new_config: 20 | with open(filename, 'w') as fout: 21 | yaml.dump(new_config, fout) 22 | 23 | 24 | def get_netloc(settings): 25 | # defaults if config has not been saved yet 26 | listen_address = settings.get('lapi_listen_address', '127.0.0.1') 27 | listen_port = settings.get('lapi_listen_port', '8080') 28 | return '{}:{}'.format(listen_address, listen_port) 29 | 30 | 31 | def get_new_url(old_url, settings): 32 | old_tuple = urllib.parse.urlsplit(old_url) 33 | new_tuple = old_tuple._replace(netloc=get_netloc(settings)) 34 | new_url = urllib.parse.urlunsplit(new_tuple) 35 | # client lapi requires a trailing slash for the path part 36 | # and no, query and fragment don't make much sense 37 | if not new_tuple.query and not new_tuple.fragment and not new_url.endswith('/'): 38 | new_url += '/' 39 | return new_url 40 | 41 | 42 | def configure_agent(settings): 43 | config_path = '/usr/local/etc/crowdsec/config.yaml' 44 | config = load_config(config_path) 45 | 46 | config['common']['log_dir'] = '/var/log/crowdsec' 47 | config['crowdsec_service']['acquisition_dir'] = '/usr/local/etc/crowdsec/acquis.d/' 48 | 49 | if not int(settings.get('lapi_manual_configuration', '0')): 50 | config['api']['server']['listen_uri'] = get_netloc(settings) 51 | 52 | save_config(config_path, config) 53 | 54 | 55 | def configure_lapi_credentials(settings): 56 | config_path = '/usr/local/etc/crowdsec/local_api_credentials.yaml' 57 | config = load_config(config_path) 58 | 59 | if not int(settings.get('lapi_manual_configuration', '0')): 60 | config['url'] = get_new_url(config['url'], settings) 61 | 62 | save_config(config_path, config) 63 | 64 | 65 | def configure_bouncer(settings): 66 | config_path = '/usr/local/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml' 67 | config = load_config(config_path) 68 | 69 | config['log_dir'] = '/var/log/crowdsec' 70 | config['blacklists_ipv4'] = 'crowdsec_blacklists' 71 | config['blacklists_ipv6'] = 'crowdsec6_blacklists' 72 | config['pf'] = {'anchor_name': ''} 73 | 74 | if not int(settings.get('lapi_manual_configuration', '0')): 75 | config['api_url'] = get_new_url(config['api_url'], settings) 76 | 77 | save_config(config_path, config) 78 | 79 | 80 | def main(): 81 | try: 82 | with open('/usr/local/etc/crowdsec/opnsense/settings.json') as f: 83 | settings = json.load(f) 84 | except FileNotFoundError: 85 | logging.info("settings.json not found, won't change crowdsec config") 86 | return 87 | 88 | configure_agent(settings) 89 | configure_lapi_credentials(settings) 90 | configure_bouncer(settings) 91 | 92 | 93 | if __name__ == '__main__': 94 | main() 95 | -------------------------------------------------------------------------------- /src/opnsense/scripts/OPNsense/CrowdSec/reconfigure.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This script is run 4 | # - when the plugin is installed (by +POST_INSTALL.post) 5 | # - when saving the "settings" form (which calls /api/crowdsec/service/reload) 6 | # - by hand, running "configctl crowdsec reconfigure" 7 | 8 | set -e 9 | 10 | # apply configuration options specific to opnsense 11 | /usr/local/opnsense/scripts/OPNsense/CrowdSec/reconfigure.py 12 | 13 | # enable pf anchor here - the tables and rules will be created by the bouncer 14 | /usr/local/sbin/configctl filter reload >/dev/null 15 | 16 | # the hub is upgraded by cron too 17 | /usr/local/opnsense/scripts/OPNsense/CrowdSec/hub-upgrade.sh 18 | 19 | # crowdsec was already restarted by hub-upgrade.sh 20 | if service crowdsec_firewall enabled; then 21 | # have to check status explicitly because "restart" can set $? = 0 even when failing 22 | if ! service crowdsec_firewall status >/dev/null 2>&1; then 23 | service crowdsec_firewall start >/dev/null 2>&1 || : 24 | else 25 | service crowdsec_firewall restart >/dev/null 2>&1 || : 26 | fi 27 | fi 28 | 29 | # left from v0.0.8 30 | rm -f /usr/local/etc/crowdsec/opnsense-settings.json 31 | 32 | echo "OK" 33 | -------------------------------------------------------------------------------- /src/opnsense/service/conf/actions.d/actions_crowdsec.conf: -------------------------------------------------------------------------------- 1 | 2 | # https://docs.opnsense.org/development/backend/configd.html 3 | 4 | [start] 5 | command:/usr/local/etc/rc.d/oscrowdsec start 6 | type: script 7 | message: starting crowdsec services 8 | 9 | [stop] 10 | command:/usr/local/etc/rc.d/oscrowdsec stop 11 | type: script 12 | message: stopping crowdsec services 13 | 14 | [status] 15 | command:/usr/local/etc/rc.d/oscrowdsec status; exit 0 16 | type: script_output 17 | message: oscrowdsec status 18 | 19 | [restart] 20 | command:/usr/local/etc/rc.d/oscrowdsec restart 21 | type: script 22 | message: stopping crowdsec services 23 | 24 | [reload] 25 | command:/usr/local/etc/rc.d/oscrowdsec reload 26 | type: script 27 | message: reload crowdsec configuration 28 | 29 | [crowdsec-status] 30 | command:/usr/local/etc/rc.d/crowdsec status;exit 0 31 | type:script_output 32 | message: request crowdsec status 33 | 34 | [crowdsec-firewall-status] 35 | command:/usr/local/etc/rc.d/crowdsec_firewall status;exit 0 36 | type:script_output 37 | message: request crowdsec_firewall status 38 | 39 | [alerts-list] 40 | command:/usr/local/bin/cscli alerts list -l 0 -o json | sed 's/^null$/\[\]/' 41 | type:script_output 42 | message:crowdsec alerts list 43 | 44 | [bouncers-list] 45 | command:/usr/local/bin/cscli bouncers list -o json | sed 's/^null$/\[\]/' 46 | type:script_output 47 | message:crowdsec bouncers list 48 | 49 | [collections-list] 50 | command:/usr/local/bin/cscli collections list -o json 51 | type:script_output 52 | message:crowdsec collections list 53 | 54 | [decisions-list] 55 | command:/usr/local/bin/cscli decisions list -l 0 -o json | sed 's/^null$/\[\]/' 56 | type:script_output 57 | message:crowdsec decisions list 58 | 59 | [decisions-delete] 60 | command:/usr/local/bin/cscli --error decisions delete 2>&1 61 | parameters:--id %s 62 | type:script_output 63 | message:crowdsec decisions delete 64 | 65 | [machines-list] 66 | command:/usr/local/bin/cscli machines list -o json | sed 's/^null$/\[\]/' 67 | type:script_output 68 | message:crowdsec machines list 69 | 70 | [parsers-list] 71 | command:/usr/local/bin/cscli parsers list -o json 72 | type:script_output 73 | message:crowdsec parsers list 74 | 75 | [postoverflows-list] 76 | command:/usr/local/bin/cscli postoverflows list -o json 77 | type:script_output 78 | message:crowdsec postoverflows list 79 | 80 | [scenarios-list] 81 | command:/usr/local/bin/cscli scenarios list -o json 82 | type:script_output 83 | message:crowdsec scenarios list 84 | 85 | [version] 86 | command:/usr/local/bin/cscli version 2>&1 87 | type:script_output 88 | message:crowdsec version 89 | 90 | [reconfigure] 91 | command:/usr/local/opnsense/scripts/OPNsense/CrowdSec/reconfigure.sh 92 | type:script_output 93 | message:crowdsec reconfigure 94 | -------------------------------------------------------------------------------- /src/opnsense/service/templates/OPNsense/CrowdSec/+TARGETS: -------------------------------------------------------------------------------- 1 | oscrowdsec.rc.conf.d:/etc/rc.conf.d/oscrowdsec 2 | crowdsec.rc.conf.d:/etc/rc.conf.d/crowdsec 3 | crowdsec_firewall.rc.conf.d:/etc/rc.conf.d/crowdsec_firewall 4 | settings.json:/usr/local/etc/crowdsec/opnsense/settings.json 5 | -------------------------------------------------------------------------------- /src/opnsense/service/templates/OPNsense/CrowdSec/crowdsec.rc.conf.d: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE -- OPNsense auto-generated file 2 | {% if helpers.exists('OPNsense.crowdsec.general.agent_enabled') and OPNsense.crowdsec.general.agent_enabled|default("1") == "1" %} 3 | crowdsec_enable="YES" 4 | {% else %} 5 | crowdsec_enable="NO" 6 | {% endif %} 7 | {% if helpers.exists('OPNsense.crowdsec.general.lapi_enabled') and OPNsense.crowdsec.general.lapi_enabled|default("1") == "1" %} 8 | crowdsec_flags="" 9 | {% else %} 10 | crowdsec_flags="-no-api" 11 | {% endif %} 12 | -------------------------------------------------------------------------------- /src/opnsense/service/templates/OPNsense/CrowdSec/crowdsec_firewall.rc.conf.d: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE -- OPNsense auto-generated file 2 | {% if helpers.exists('OPNsense.crowdsec.general.firewall_bouncer_enabled') and OPNsense.crowdsec.general.firewall_bouncer_enabled|default("1") == "1" %} 3 | crowdsec_firewall_enable="YES" 4 | {% else %} 5 | crowdsec_firewall_enable="NO" 6 | {% endif %} 7 | {% if helpers.exists('OPNsense.crowdsec.general.crowdsec_firewall_verbose') and OPNsense.crowdsec.general.crowdsec_firewall_verbose|default("0") == "1" %} 8 | crowdsec_firewall_flags="-v" 9 | {% else %} 10 | crowdsec_firewall_flags="" 11 | {% endif %} 12 | -------------------------------------------------------------------------------- /src/opnsense/service/templates/OPNsense/CrowdSec/oscrowdsec.rc.conf.d: -------------------------------------------------------------------------------- 1 | oscrowdsec_enable="YES" 2 | -------------------------------------------------------------------------------- /src/opnsense/service/templates/OPNsense/CrowdSec/settings.json: -------------------------------------------------------------------------------- 1 | {% if helpers.exists('OPNsense.crowdsec.general') -%} 2 | {{ OPNsense.crowdsec.general | tojson }} 3 | {%- endif %} 4 | -------------------------------------------------------------------------------- /src/opnsense/www/js/CrowdSec/crowdsec.js: -------------------------------------------------------------------------------- 1 | /*global moment, $ */ 2 | /*exported CrowdSec */ 3 | /*eslint no-undef: "error"*/ 4 | /*eslint semi: "error"*/ 5 | 6 | var CrowdSec = (function() { 7 | 'use strict'; 8 | 9 | var _refresh_template = ''; 10 | 11 | var _dataFormatters = { 12 | yesno: function(column, row) { 13 | return _yesno2html(row[column.id]); 14 | }, 15 | 16 | delete: function(column, row) { 17 | var val = row.id; 18 | if (isNaN(val)) { 19 | return ''; 20 | } 21 | return ''; 22 | }, 23 | 24 | duration: function(column, row) { 25 | var duration = row[column.id]; 26 | if (!duration) { 27 | return 'n/a'; 28 | } 29 | return $('
').attr({ 30 | 'data-toggle': 'tooltip', 31 | 'data-placement': 'left', 32 | 'title': duration 33 | }).text(_humanizeDuration(duration)).prop('outerHTML'); 34 | }, 35 | 36 | datetime: function(column, row) { 37 | var dt = row[column.id]; 38 | var parsed = moment(dt); 39 | if (!dt) { 40 | return ''; 41 | } 42 | if (!parsed.isValid()) { 43 | console.error("Cannot parse timestamp: %s", dt); 44 | return '???'; 45 | } 46 | return $('
').attr({ 47 | 'data-toggle': 'tooltip', 48 | 'data-placement': 'left', 49 | 'title': parsed.format() 50 | }).text(_humanizeDate(dt)).prop('outerHTML'); 51 | }, 52 | }; 53 | 54 | function _parseDuration(duration) { 55 | var re = /(-?)(?:(?:(\d+)h)?(\d+)m)?(\d+).\d+(m?)s/m; 56 | var matches = duration.match(re); 57 | var seconds = 0; 58 | 59 | if (!matches.length) { 60 | throw new Error("Unable to parse the following duration: " + duration + "."); 61 | } 62 | if (typeof matches[2] !== "undefined") { 63 | seconds += parseInt(matches[2], 10) * 3600; // hours 64 | } 65 | if (typeof matches[3] !== "undefined") { 66 | seconds += parseInt(matches[3], 10) * 60; // minutes 67 | } 68 | if (typeof matches[4] !== "undefined") { 69 | seconds += parseInt(matches[4], 10); // seconds 70 | } 71 | if ("m" === parseInt(matches[5], 10)) { 72 | // units in milliseconds 73 | seconds *= 0.001; 74 | } 75 | if ("-" === parseInt(matches[1], 10)) { 76 | // negative 77 | seconds = -seconds; 78 | } 79 | return seconds; 80 | } 81 | 82 | function _updateFreshness(selector, timestamp) { 83 | var $freshness = $(selector).find('.actionBar .freshness'); 84 | if (timestamp) { 85 | $freshness.data('refresh_timestamp', timestamp); 86 | } else { 87 | timestamp = $freshness.data('refresh_timestamp'); 88 | } 89 | var howlong_human = '???'; 90 | if (timestamp) { 91 | var howlong_ms = moment() - moment(timestamp); 92 | howlong_human = moment.duration(howlong_ms).humanize(); 93 | } 94 | $freshness.text(howlong_human + ' ago'); 95 | } 96 | 97 | function _addFreshness(selector) { 98 | // this creates one timer per tab 99 | var freshness_template = 'Last refresh: '; 100 | $(selector).find('.actionBar').prepend(freshness_template); 101 | setInterval(function() { 102 | _updateFreshness(selector); 103 | }, 5000); 104 | } 105 | 106 | function _humanizeDate(text) { 107 | return moment(text).fromNow(); 108 | } 109 | 110 | function _humanizeDuration(text) { 111 | return moment.duration(_parseDuration(text), 'seconds').humanize(); 112 | } 113 | 114 | function _yesno2html(val) { 115 | if (val) { 116 | return ''; 117 | } else { 118 | return ''; 119 | } 120 | } 121 | 122 | function _decisionsByType(decisions) { 123 | var dectypes = {}; 124 | if (!decisions) { 125 | return ''; 126 | } 127 | decisions.map(function(decision) { 128 | // TODO ignore negative expiration? 129 | dectypes[decision.type] = dectypes[decision.type] ? (dectypes[decision.type]+1) : 1; 130 | }); 131 | var ret = ''; 132 | for (var type in dectypes) { 133 | if (ret !== '') { 134 | ret += ' '; 135 | } 136 | ret += (type + ':' + dectypes[type]); 137 | } 138 | return ret; 139 | } 140 | 141 | function _initService() { 142 | $.ajax({ 143 | url: '/api/crowdsec/service/status', 144 | cache: false 145 | }).done(function(data) { 146 | // TODO handle errors 147 | var crowdsec_status = data['crowdsec-status']; 148 | if (crowdsec_status === 'unknown') { 149 | crowdsec_status = 'Unknown'; 150 | } else { 151 | crowdsec_status = _yesno2html(crowdsec_status === 'running'); 152 | } 153 | $('#crowdsec-status').html(crowdsec_status); 154 | 155 | var crowdsec_firewall_status = data['crowdsec-firewall-status']; 156 | if (crowdsec_firewall_status === 'unknown') { 157 | crowdsec_firewall_status = 'Unknown'; 158 | } else { 159 | crowdsec_firewall_status = _yesno2html(crowdsec_firewall_status === 'running'); 160 | } 161 | $('#crowdsec-firewall-status').html(crowdsec_firewall_status); 162 | }); 163 | } 164 | 165 | function _initDebug() { 166 | $.ajax({ 167 | url: '/api/crowdsec/service/debug', 168 | cache: false 169 | }).done(function(data) { 170 | $('#debug pre').text(data.message); 171 | }); 172 | } 173 | 174 | function _initTab(selector, url, dataCallback) { 175 | var $tab = $(selector); 176 | if ($tab.find('table.bootgrid-table').length) { 177 | return; 178 | } 179 | $tab.find('table'). 180 | on("initialized.rs.jquery.bootgrid", function() { 181 | $(_refresh_template).on('click', function() { 182 | _refreshTab(selector, url, dataCallback); 183 | }).insertBefore($tab.find('.actionBar .actions .dropdown:first')); 184 | _addFreshness(selector); 185 | _refreshTab(selector, url, dataCallback); 186 | }). 187 | bootgrid({ 188 | caseSensitive: false, 189 | formatters: _dataFormatters 190 | }); 191 | } 192 | 193 | function _refreshTab(selector, url, dataCallback) { 194 | $.ajax({ 195 | url: url, 196 | cache: false 197 | }).done(dataCallback); 198 | _updateFreshness(selector, moment()); 199 | } 200 | 201 | function _initMachines() { 202 | var url = '/api/crowdsec/machines/get'; 203 | var dataCallback = function(data) { 204 | var rows = []; 205 | data.map(function(row) { 206 | rows.push({ 207 | name: row.machineId, 208 | ip_address: row.ipAddress || ' ', 209 | last_update: row.updated_at || ' ', 210 | validated: row.isValidated, 211 | version: row.version || ' ' 212 | }); 213 | }); 214 | $('#machines table').bootgrid('clear').bootgrid('append', rows); 215 | }; 216 | _initTab('#machines', url, dataCallback); 217 | } 218 | 219 | function _initCollections() { 220 | var url = '/api/crowdsec/collections/get'; 221 | var dataCallback = function(data) { 222 | var rows = []; 223 | data.collections.map(function(row) { 224 | rows.push({ 225 | name: row.name, 226 | status: row.status, 227 | local_version: row.local_version || ' ', 228 | local_path: row.local_path || ' ' 229 | }); 230 | }); 231 | $('#collections table').bootgrid('clear').bootgrid('append', rows); 232 | }; 233 | _initTab('#collections', url, dataCallback); 234 | } 235 | 236 | function _initScenarios() { 237 | var url = '/api/crowdsec/scenarios/get'; 238 | var dataCallback = function(data) { 239 | var rows = []; 240 | data.scenarios.map(function(row) { 241 | rows.push({ 242 | name: row.name, 243 | status: row.status, 244 | local_version: row.local_version || ' ', 245 | local_path: row.local_path || ' ', 246 | description: row.description || ' ' 247 | }); 248 | }); 249 | $('#scenarios table').bootgrid('clear').bootgrid('append', rows); 250 | }; 251 | _initTab('#scenarios', url, dataCallback); 252 | } 253 | 254 | function _initParsers() { 255 | var url = '/api/crowdsec/parsers/get'; 256 | var dataCallback = function(data) { 257 | var rows = []; 258 | data.parsers.map(function(row) { 259 | rows.push({ 260 | name: row.name, 261 | status: row.status, 262 | local_version: row.local_version || ' ', 263 | local_path: row.local_path || ' ', 264 | description: row.description || ' ' 265 | }); 266 | }); 267 | $('#parsers table').bootgrid('clear').bootgrid('append', rows); 268 | }; 269 | _initTab('#parsers ', url, dataCallback); 270 | } 271 | 272 | function _initPostoverflows() { 273 | var url = '/api/crowdsec/postoverflows/get'; 274 | var dataCallback = function(data) { 275 | var rows = []; 276 | data.postoverflows.map(function(row) { 277 | rows.push({ 278 | name: row.name, 279 | status: row.status, 280 | local_version: row.local_version || ' ', 281 | local_path: row.local_path || ' ', 282 | description: row.description || ' ' 283 | }); 284 | }); 285 | $('#postoverflows table').bootgrid('clear').bootgrid('append', rows); 286 | }; 287 | _initTab('#postoverflows ', url, dataCallback); 288 | } 289 | 290 | function _initBouncers() { 291 | var url = '/api/crowdsec/bouncers/get'; 292 | var dataCallback = function(data) { 293 | var rows = []; 294 | data.map(function(row) { 295 | // TODO - remove || ' ' later, it was fixed for 1.3.3 296 | rows.push({ 297 | name: row.name, 298 | ip_address: row.ip_address || ' ', 299 | valid: row.revoked ? false : true, 300 | last_pull: row.last_pull, 301 | type: row.type || ' ', 302 | version: row.version || ' ' 303 | }); 304 | }); 305 | $('#bouncers table').bootgrid('clear').bootgrid('append', rows); 306 | }; 307 | _initTab('#bouncers ', url, dataCallback); 308 | } 309 | 310 | function _initAlerts() { 311 | var url = '/api/crowdsec/alerts/get'; 312 | var dataCallback = function(data) { 313 | var rows = []; 314 | data.map(function(row) { 315 | rows.push({ 316 | id: row.id, 317 | value: row.source.scope + (row.source.value?(':'+row.source.value):''), 318 | reason: row.scenario || ' ', 319 | country: row.source.cn || ' ', 320 | as: row.source.as_name || ' ', 321 | decisions: _decisionsByType(row.decisions) || ' ', 322 | created_at: row.created_at 323 | }); 324 | }); 325 | $('#alerts table').bootgrid('clear').bootgrid('append', rows); 326 | }; 327 | _initTab('#alerts ', url, dataCallback); 328 | } 329 | 330 | function _initDecisions() { 331 | var url = '/api/crowdsec/decisions/get'; 332 | var dataCallback = function(data) { 333 | var rows = []; 334 | data.map(function(row) { 335 | row.decisions.map(function(decision) { 336 | // ignore deleted decisions 337 | if (decision.duration.startsWith('-')) { 338 | return; 339 | } 340 | rows.push({ 341 | // search will break on empty values when using .append(). so we use spaces 342 | delete: '', 343 | id: decision.id, 344 | source: decision.origin || ' ', 345 | scope_value: decision.scope + (decision.value?(':'+decision.value):''), 346 | reason: decision.scenario || ' ', 347 | action: decision.type || ' ', 348 | country: row.source.cn || ' ', 349 | as: row.source.as_name || ' ', 350 | events_count: row.events_count, 351 | // XXX pre-parse duration to seconds, and integer type, for sorting 352 | expiration: decision.duration || ' ', 353 | alert_id: row.id || ' ' 354 | }); 355 | }); 356 | }); 357 | $('#decisions table').bootgrid('clear').bootgrid('append', rows); 358 | }; 359 | _initTab('#decisions ', url, dataCallback); 360 | } 361 | 362 | function deleteDecision(decisionId) { 363 | var $modal = $('#delete-decision-modal'); 364 | $modal.find('.modal-title').text('Delete decision #' + decisionId); 365 | $modal.find('.modal-body').text('Are you sure?'); 366 | $modal.find('#delete-decision-confirm').on('click', function() { 367 | $.ajax({ 368 | // XXX handle errors 369 | url: '/api/crowdsec/decisions/delete/' + decisionId, 370 | type: 'DELETE', 371 | success: function(result) { 372 | if (result && result.message === 'OK') { 373 | $('#decisions table').bootgrid('remove', [decisionId]); 374 | $modal.modal('hide'); 375 | } 376 | } 377 | }); 378 | }); 379 | $modal.modal('show'); 380 | } 381 | 382 | function init() { 383 | _initService(); 384 | 385 | $('#machines_tab').on('click', _initMachines); 386 | $('#collections_tab').on('click', _initCollections); 387 | $('#scenarios_tab').on('click', _initScenarios); 388 | $('#parsers_tab').on('click', _initParsers); 389 | $('#postoverflows_tab').on('click', _initPostoverflows); 390 | $('#bouncers_tab').on('click', _initBouncers); 391 | $('#alerts_tab').on('click', _initAlerts); 392 | $('#decisions_tab').on('click', _initDecisions); 393 | 394 | $('[data-toggle="tooltip"]').tooltip(); 395 | 396 | if (window.location.hash) { 397 | // activate a tab from the hash, if it exists 398 | $(window.location.hash+'_tab').click(); 399 | } else { 400 | // otherwise, machines 401 | $('#machines_tab').click(); 402 | } 403 | 404 | $(window).on('hashchange', function(e) { 405 | $(window.location.hash+'_tab').click(); 406 | }); 407 | 408 | if (new URLSearchParams(window.location.search).has('debug')) { 409 | $('#debug_tab').show().on('click', _initDebug); 410 | } 411 | 412 | // navigation 413 | if(window.location.hash != "") { 414 | $('a[href="' + window.location.hash + '"]').click() 415 | } 416 | $('.nav-tabs a').on('shown.bs.tab', function (e) { 417 | history.pushState(null, null, e.target.hash); 418 | }); 419 | } 420 | 421 | return { 422 | deleteDecision: deleteDecision, 423 | init: init 424 | }; 425 | 426 | }()); 427 | --------------------------------------------------------------------------------