diff --git a/src/opnsense/mvc/app/models/OPNsense/Firewall/Alias.xml b/src/opnsense/mvc/app/models/OPNsense/Firewall/Alias.xml index cfcf58c44..201947adf 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Firewall/Alias.xml +++ b/src/opnsense/mvc/app/models/OPNsense/Firewall/Alias.xml @@ -36,6 +36,7 @@ GeoIP Network group MAC address + BGP ASN Dynamic IPv6 Host Internal (automatic) External (advanced) diff --git a/src/opnsense/mvc/app/models/OPNsense/Firewall/FieldTypes/AliasContentField.php b/src/opnsense/mvc/app/models/OPNsense/Firewall/FieldTypes/AliasContentField.php index b71ceadd5..d9386560a 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Firewall/FieldTypes/AliasContentField.php +++ b/src/opnsense/mvc/app/models/OPNsense/Firewall/FieldTypes/AliasContentField.php @@ -135,6 +135,24 @@ class AliasContentField extends BaseField return $messages; } + /** + * Validate asn alias options + * @param array $data to validate + * @return bool|Callback + * @throws \OPNsense\Base\ModelException + */ + private function validateASN($data) + { + $messages = []; + $filter_opts = ["min_range" => 1, "max_range" => 4294967296]; + foreach ($this->getItems($data) as $asn) { + if (filter_var($asn, FILTER_VALIDATE_INT, ["options"=> $filter_opts]) === false) { + $messages[] = sprintf(gettext('Entry "%s" is not a valid ASN.'), $asn); + } + } + return $messages; + } + /** * Validate host options * @param array $data to validate @@ -335,6 +353,12 @@ class AliasContentField extends BaseField } ]); break; + case "asn": + $validators[] = new CallbackValidator(["callback" => function ($data) { + return $this->validateASN($data); + } + ]); + break; default: break; } diff --git a/src/opnsense/mvc/app/views/OPNsense/Firewall/alias.volt b/src/opnsense/mvc/app/views/OPNsense/Firewall/alias.volt index b173ca8fd..0ebeee96c 100644 --- a/src/opnsense/mvc/app/views/OPNsense/Firewall/alias.volt +++ b/src/opnsense/mvc/app/views/OPNsense/Firewall/alias.volt @@ -234,6 +234,11 @@ $("#alias_type_geoip").show(); $("#alias\\.proto").selectpicker('show'); break; + case 'asn': + $("#alias_type_default").show(); + $("#alias\\.proto").selectpicker('show'); + $("#copy-paste").show(); + break; case 'external': break; case 'networkgroup': @@ -527,6 +532,7 @@ + diff --git a/src/opnsense/scripts/filter/lib/alias.py b/src/opnsense/scripts/filter/lib/alias.py index f50437eee..2b3a52d2a 100755 --- a/src/opnsense/scripts/filter/lib/alias.py +++ b/src/opnsense/scripts/filter/lib/alias.py @@ -39,6 +39,7 @@ from dns.exception import DNSException from . import geoip from . import net_wildcard_iterator, AsyncDNSResolver from .arpcache import ArpCache +from .bgpasn import BGPASN from .interface import InterfaceParser class Alias(object): @@ -279,6 +280,8 @@ class Alias(object): return InterfaceParser(self._interface).iter_dynipv6host elif self._type == 'mac': return ArpCache().iter_addresses + elif self._type == 'asn': + return BGPASN(self._proto).iter_addresses else: return None diff --git a/src/opnsense/scripts/filter/lib/bgpasn.py b/src/opnsense/scripts/filter/lib/bgpasn.py new file mode 100644 index 000000000..231d43a61 --- /dev/null +++ b/src/opnsense/scripts/filter/lib/bgpasn.py @@ -0,0 +1,104 @@ +""" + Copyright (c) 2022 Ad Schellevis + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + +""" +import csv +import fcntl +import time +import os +import sys +import syslog +import gzip +import requests + +class BGPASN: + _asn_source = 'https://rulesets.opnsense.org/alias/asn.gz' # source for ASN administration + _asn_filename = '/usr/local/share/bgp/asn.csv' # local copy + _asn_ttl = (86400 - 90) # validity in seconds of the local copy + _asn_fhandle = None # file handle to local copy + _asn_db = {} # cache + + @classmethod + def _update(cls): + do_update = True + if os.path.isfile(cls._asn_filename): + fstat = os.stat(cls._asn_filename) + if (time.time() - fstat.st_mtime) < cls._asn_ttl: + do_update = False + if do_update: + if not os.path.exists(os.path.dirname(cls._asn_filename)): + os.makedirs(os.path.dirname(cls._asn_filename)) + cls._asn_fhandle = open(cls._asn_filename, 'a+') + try: + fcntl.flock(cls._asn_fhandle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except IOError: + # other process is already creating the cache, wait, let the other process do it's work and return. + fcntl.flock(cls._asn_fhandle, fcntl.LOCK_EX) + fcntl.flock(cls._asn_fhandle, fcntl.LOCK_UN) + return + + req = requests.get(url=cls._asn_source, stream=True, timeout=20) + if req.status_code == 200: + gf = gzip.GzipFile(mode='r', fileobj=req.raw) + cls._asn_fhandle.seek(0) + cls._asn_fhandle.truncate() + count = 0 + for line in gf: + parts = line.decode().strip().split() + if len(parts) == 2: + cls._asn_fhandle.write("%s,%s\n" % tuple(parts)) + count += 1 + fcntl.flock(cls._asn_fhandle, fcntl.LOCK_UN) + syslog.syslog(syslog.LOG_NOTICE, 'dowloaded ASN list (%d entries)' % count) + else: + syslog.syslog( + syslog.LOG_ERR, + 'error fetching BGP ASN url %s [http_code:%s]' % (cls._asn_source, req.status_code) + ) + raise IOError('error fetching BGP ASN url %s' % cls._asn_source) + else: + cls._asn_fhandle = open(cls._asn_filename, 'rt') + + def __init__(self, proto='IPv4'): + self.proto = proto.split(',') + if self._asn_fhandle is None: + # update local asn list if needed, return a file pointer to a local csv file for reading + self._update() + + def iter_addresses(self, asn): + if len(self._asn_db) == 0: + self._asn_fhandle.seek(0) + for row in csv.reader(self._asn_fhandle, delimiter=',', quotechar='"'): + if len(row) == 2: + if row[1] not in self._asn_db: + self._asn_db[row[1]] = [] + self._asn_db[row[1]].append(row[0]) + + if asn in self._asn_db: + for address in self._asn_db[asn]: + if 'IPv4' in self.proto and address.find(':') == -1: + yield address + elif 'IPv6' in self.proto and address.find(':') > -1: + yield address diff --git a/src/opnsense/service/templates/OPNsense/Filter/filter_tables.conf b/src/opnsense/service/templates/OPNsense/Filter/filter_tables.conf index e7ad48ce6..c90e1b1ae 100644 --- a/src/opnsense/service/templates/OPNsense/Filter/filter_tables.conf +++ b/src/opnsense/service/templates/OPNsense/Filter/filter_tables.conf @@ -28,7 +28,7 @@ {{ alias.proto|e }} {% endif %}{% if alias.updatefreq %} {{ alias.updatefreq|float * 86400 }} -{% elif alias.type == 'geoip' %} +{% elif alias.type in ['geoip', 'asn'] %} 86400 {% elif alias.type == 'host' %} {{ system.aliasesresolveinterval|default('300') }}