From 369630dbd35c0acacc7384aa997f553fdc6ca91a Mon Sep 17 00:00:00 2001 From: Stephan de Wit Date: Mon, 16 Mar 2026 09:46:52 +0100 Subject: [PATCH] Captive portal: IPv6 support (#9745) This commit adds IPv6 support for Captive Portal by introducing a new "roaming" option, which is enabled by default. Roaming allows the synchronization of additional IPv4/IPv6 client address aliases, aggregating their accounting through ipfw and managing their state in the pf table. For IPv6, hostwatch is required to be enabled to prevent performance issues during client roaming IP synchronization. Furthermore, IPv6 can only work properly if a hostname is provided in the zone and proper AAAA records have been synthesized for the local DNS server - for a default setup, this requires the DNS64 option in Unbound to be set. Co-authored-by: Alex Goodkind --- src/etc/inc/plugins.inc.d/captiveportal.inc | 28 +- .../CaptivePortal/Api/AccessController.php | 19 +- .../CaptivePortal/forms/dialogZone.xml | 10 + .../System/Status/CaptivePortalStatus.php | 56 ++ .../OPNsense/CaptivePortal/CaptivePortal.xml | 6 +- .../views/OPNsense/CaptivePortal/clients.volt | 50 +- src/opnsense/scripts/captiveportal/allow.py | 5 +- .../captiveportal/cp-background-process.py | 73 +-- src/opnsense/scripts/captiveportal/lib/arp.py | 96 ++-- src/opnsense/scripts/captiveportal/lib/db.py | 527 +++++++++++++----- .../scripts/captiveportal/lib/ipfw.py | 14 +- src/opnsense/scripts/captiveportal/lib/pf.py | 15 +- .../scripts/captiveportal/listClients.py | 2 +- .../scripts/captiveportal/sql/init.sql | 26 +- src/opnsense/scripts/interfaces/list_hosts.py | 29 +- .../conf/actions.d/actions_hostwatch.conf | 2 +- .../OPNsense/Captiveportal/captiveportal.conf | 1 + .../lighttpd-api-dispatcher.conf | 4 +- .../OPNsense/Captiveportal/lighttpd-zone.conf | 33 +- 19 files changed, 716 insertions(+), 280 deletions(-) create mode 100644 src/opnsense/mvc/app/library/OPNsense/System/Status/CaptivePortalStatus.php diff --git a/src/etc/inc/plugins.inc.d/captiveportal.inc b/src/etc/inc/plugins.inc.d/captiveportal.inc index 0d24f4496e..b19940f15a 100644 --- a/src/etc/inc/plugins.inc.d/captiveportal.inc +++ b/src/etc/inc/plugins.inc.d/captiveportal.inc @@ -91,7 +91,6 @@ function captiveportal_firewall($fw) } foreach ($zone->interfaces->getValues() as $intf) { - // allow DNS $fw->registerFilterRule( 1, [ @@ -111,7 +110,7 @@ function captiveportal_firewall($fw) foreach (['80', '443'] as $to_port) { $rdr_port = $to_port === '443' ? (8000 + (int)$zoneid) : (9000 + (int)$zoneid); - // forward to localhost if not authenticated + // forward to localhost if not authenticated (IPv4) $fw->registerForwardRule( 2, [ @@ -133,7 +132,29 @@ function captiveportal_firewall($fw) ] ); - // Allow access to the captive portal + // forward to localhost if not authenticated (IPv6) + $fw->registerForwardRule( + 2, + [ + 'interface' => $intf, + 'pass' => true, + 'nordr' => false, + 'ipprotocol' => 'inet6', + 'protocol' => 'tcp', + 'from' => "<__captiveportal_zone_{$zoneid}>", + 'from_not' => true, + 'to' => "<__captiveportal_zone_{$zoneid}>", + 'to_not' => true, + 'to_port' => $to_port, + 'target' => $intf . 'ip', + 'localport' => $rdr_port, + 'natreflection' => 'disable', + 'log' => true, + 'descr' => "Redirect to Captive Portal (zone {$zoneid})", + '#ref' => "ui/captiveportal#edit={$uuid}" + ] + ); + $proto = $to_port === '443' ? 'https' : 'http'; $fw->registerFilterRule( 2, @@ -152,7 +173,6 @@ function captiveportal_firewall($fw) ); } - // block all non-authenticated users $fw->registerFilterRule( 3, [ diff --git a/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/AccessController.php b/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/AccessController.php index 3ffabfd8fa..2dff6be6e0 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/AccessController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/AccessController.php @@ -50,12 +50,13 @@ class AccessController extends ApiControllerBase protected function clientSession(string $zoneid) { $backend = new Backend(); - $allClientsRaw = $backend->configdpRun("captiveportal list_clients", [$zoneid]); - $allClients = json_decode($allClientsRaw, true); + $allClients = json_decode($backend->configdpRun("captiveportal list_clients", [$zoneid]), true); + $clientIp = $this->getClientIp(); + if ($allClients != null) { // search for client by ip address foreach ($allClients as $connectedClient) { - if ($connectedClient['ipAddress'] == $this->getClientIp()) { + if (in_array($clientIp, $connectedClient['ipAddresses'])) { // client is authorized in this zone according to our administration $connectedClient['clientState'] = 'AUTHORIZED'; return $connectedClient; @@ -64,7 +65,7 @@ class AccessController extends ApiControllerBase } // return Unauthorized including authentication requirements - $result = ['clientState' => "NOT_AUTHORIZED", "ipAddress" => $this->getClientIp()]; + $result = ['clientState' => "NOT_AUTHORIZED", "ipAddress" => $clientIp]; $mdlCP = new CaptivePortal(); $cpZone = $mdlCP->getByZoneID($zoneid); if ($cpZone != null && (string)$cpZone->extendedPreAuthData == '1') { @@ -103,14 +104,15 @@ class AccessController extends ApiControllerBase protected function getClientMac($ip) { if (empty($this->arp)) { - /* currently this only matches ipv4 properly, for ipv6 we need to unpack both rows and offered parameter */ $data = json_decode((new Backend())->configdRun('hostwatch dump'), true) ?? []; if (!empty($data['rows'])) { foreach ($data['rows'] as $row) { - $this->arp[$row[2]] = $row[1]; + // remove scope from IPv6 address if present (e.g., fe80::1%em0 -> fe80::1) + $this->arp[$row[2]] = explode('%', $row[1])[0]; } } } + return $this->arp[$ip] ?? null; } @@ -306,10 +308,11 @@ class AccessController extends ApiControllerBase if (array_key_exists('session_timeout', $authProps) || $cpZone->alwaysSendAccountingReqs == '1') { $backend->configdpRun( "captiveportal set session_restrictions", - array((string)$cpZone->zoneid, + [ + (string)$cpZone->zoneid, $CPsession['sessionId'], $authProps['session_timeout'] ?? null, - ) + ] ); } } diff --git a/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/forms/dialogZone.xml b/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/forms/dialogZone.xml index c2d8c68072..82506dbd03 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/forms/dialogZone.xml +++ b/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/forms/dialogZone.xml @@ -36,6 +36,16 @@ boolean + + zone.roaming + checkbox + + Allow a connecting client to use multiple IPs (bound to the same MAC) over the course of its session. This option is needed for maximum IPv6 compatibility and also affects IPv4 clients. + + boolean + boolean + + zone.authservers diff --git a/src/opnsense/mvc/app/library/OPNsense/System/Status/CaptivePortalStatus.php b/src/opnsense/mvc/app/library/OPNsense/System/Status/CaptivePortalStatus.php new file mode 100644 index 0000000000..4620d50e6d --- /dev/null +++ b/src/opnsense/mvc/app/library/OPNsense/System/Status/CaptivePortalStatus.php @@ -0,0 +1,56 @@ +internalPriority = 2; + $this->internalPersistent = true; + $this->internalTitle = gettext('Captive Portal IPv6 support'); + $this->internalIsBanner = true; + $this->internalScope[] = '/ui/captiveportal*'; + } + + public function collectStatus() + { + if ((new Hostwatch())->general->enabled->isEmpty()) { + $this->internalMessage = gettext( + 'The host discovery service is disabled, which is required for Captive Portal IPv6 compatibility. ' . + 'You can enable it under Interfaces -> Neighbors -> Automatic Discovery.' + ); + $this->internalStatus = SystemStatusCode::WARNING; + } + } +} diff --git a/src/opnsense/mvc/app/models/OPNsense/CaptivePortal/CaptivePortal.xml b/src/opnsense/mvc/app/models/OPNsense/CaptivePortal/CaptivePortal.xml index 0b5bb0d9b4..01af43b61a 100644 --- a/src/opnsense/mvc/app/models/OPNsense/CaptivePortal/CaptivePortal.xml +++ b/src/opnsense/mvc/app/models/OPNsense/CaptivePortal/CaptivePortal.xml @@ -1,6 +1,6 @@ //OPNsense/captiveportal - 1.0.4 + 1.0.5 Captive portal application model @@ -33,6 +33,10 @@ Y CaptivePortal + + 1 + Y + 0 Y diff --git a/src/opnsense/mvc/app/views/OPNsense/CaptivePortal/clients.volt b/src/opnsense/mvc/app/views/OPNsense/CaptivePortal/clients.volt index c0902233df..c3a7170625 100644 --- a/src/opnsense/mvc/app/views/OPNsense/CaptivePortal/clients.volt +++ b/src/opnsense/mvc/app/views/OPNsense/CaptivePortal/clients.volt @@ -72,6 +72,29 @@ requestHandler: function(request) { request['selected_zones'] = $("#zone-selection").val(); return request; + }, + formatters: { + ipAddress: function(column, row) { + const ips = row.ipAddresses || []; + + if (!ips.length) { + return $('', { class: 'text-muted', text: '-' })[0].outerHTML; + } + + return $('', { + 'data-toggle': 'tooltip', + 'data-placement': 'top', + title: ips.join('\n') + }).append(ips.map(ip => $('
').text(ip).html()).join('
'))[0].outerHTML; + }, + userName: function(column, row) { + // Extract IP from username@ip format and show just username + let userName = row.userName || ''; + if (userName && userName.indexOf('@') >= 0) { + return userName.split('@')[0] || userName; + } + return userName; + } } } }); @@ -80,6 +103,17 @@ }); + +
@@ -91,14 +125,14 @@ {{ lang._('Session') }} {{ lang._('Zoneid') }} - {{ lang._('Username') }} - {{ lang._('MAC address') }} - {{ lang._('IP address') }} - {{ lang._('Bytes (in)') }} - {{ lang._('Bytes (out)') }} - {{ lang._('Connected since') }} - {{ lang._('Last accessed') }} - {{ lang._('Commands') }} + {{ lang._('Username') }} + {{ lang._('MAC address') }} + {{ lang._('IP Address') }} + {{ lang._('Bytes (in)') }} + {{ lang._('Bytes (out)') }} + {{ lang._('Connected since') }} + {{ lang._('Last accessed') }} + {{ lang._('Commands') }} diff --git a/src/opnsense/scripts/captiveportal/allow.py b/src/opnsense/scripts/captiveportal/allow.py index add1492dad..055e6f1ad5 100755 --- a/src/opnsense/scripts/captiveportal/allow.py +++ b/src/opnsense/scripts/captiveportal/allow.py @@ -1,7 +1,7 @@ #!/usr/local/bin/python3 """ - Copyright (c) 2015-2024 Ad Schellevis + Copyright (c) 2015-2025 Ad Schellevis All rights reserved. Redistribution and use in source and binary forms, with or without @@ -43,6 +43,7 @@ parser.add_argument('--authenticated_via', help='authentication source', type=st parser.add_argument('--ip_address', help='source ip address', type=str) args = parser.parse_args() + arp_entry = ARP().get_by_ipaddress(args.ip_address) response = DB().add_client( zoneid=args.zoneid, @@ -52,5 +53,7 @@ response = DB().add_client( mac_address=arp_entry['mac'] if arp_entry is not None else None ) PF.add_to_table(zoneid=args.zoneid, address=args.ip_address) +IPFW.add_accounting(args.ip_address) + response['clientState'] = 'AUTHORIZED' print(ujson.dumps(response)) diff --git a/src/opnsense/scripts/captiveportal/cp-background-process.py b/src/opnsense/scripts/captiveportal/cp-background-process.py index f55bf58896..dcdb1bdcf7 100755 --- a/src/opnsense/scripts/captiveportal/cp-background-process.py +++ b/src/opnsense/scripts/captiveportal/cp-background-process.py @@ -56,6 +56,7 @@ class CPBackgroundProcess(object): self.arp = ARP() self.cnf = Config() self.db = DB() + self.db.create() self._conf_zone_info = self.cnf.get_zones() def list_zone_ids(self): @@ -118,14 +119,16 @@ class CPBackgroundProcess(object): if zoneid in self._conf_zone_info: # fetch data for this zone cpzone_info = self._conf_zone_info[zoneid] - registered_addresses = list(PF.list_table(zoneid)) + registered_addresses_pf = set(PF.list_table(zoneid)) + registered_addresses_ipfw = set(registered_addr_accounting.keys()) expected_clients = self.db.list_clients(zoneid) concurrent_users = self.db.find_concurrent_user_sessions(zoneid) + allow_roaming = bool(int(cpzone_info['roaming'])) # handle connected clients, timeouts, address changes, etc. for db_client in expected_clients: # fetch ip address (or network) from database - cpnet = db_client['ipAddress'].strip() + session_ips = self.db.list_session_ips(zoneid, db_client['sessionId']) # there are different reasons why a session should be removed, check for all reasons and # use the same method for the actual removal @@ -158,8 +161,8 @@ class CPBackgroundProcess(object): drop_session_reason = "remove concurrent session %s" % db_client['sessionId'] delete_reason = "User-Request" - # if mac address changes, drop session. it's not the same client - current_arp = self.arp.get_by_ipaddress(cpnet) + # if mac address changes, drop session. it's not the same client. Use the "primary IP" to determine this + current_arp = self.arp.get_by_ipaddress(db_client['ipAddress']) if current_arp is not None and current_arp['mac'] != db_client['macAddress']: drop_session_reason = "mac address changed for session %s" % db_client['sessionId'] delete_reason = "Admin-Reset" @@ -170,39 +173,47 @@ class CPBackgroundProcess(object): and time.time() - float(db_client['startTime']) > db_client['acc_session_timeout']: drop_session_reason = "accounting limit reached for session %s" % db_client['sessionId'] delete_reason = "Session-Timeout" - elif db_client['authenticated_via'] == '---mac---': - # detect mac changes - current_ip = self.arp.get_address_by_mac(db_client['macAddress']) - if current_ip is not None and db_client['ipAddress'] != current_ip: + + if drop_session_reason is not None: + # remove session + syslog.syslog(syslog.LOG_NOTICE, drop_session_reason) + for ip in session_ips: + self._remove_client(zoneid, ip) + self.db.del_client(zoneid, db_client['sessionId'], delete_reason) + continue + + # if primary IP changed, update db accordingly. Not relevant for static IP-authenticated clients + if db_client['authenticated_via'] != '---ip---': + current_ips = self.arp.get_all_addresses_by_mac(db_client['macAddress']) + if len(current_ips) > 0 and db_client['ipAddress'] != current_ips[0]: if db_client['ipAddress'] != '': # remove old ip self._remove_client(zoneid, db_client['ipAddress']) - self.db.update_client_ip(zoneid, db_client['sessionId'], current_ip) - self._add_client(zoneid, current_ip) + self.db.update_client_ip(zoneid, db_client['sessionId'], current_ips[0]) + self._add_client(zoneid, current_ips[0]) + db_client['ipAddress'] = current_ips[0] - # check session, if it should be active, validate its properties - if drop_session_reason is None: - # registered client, but not active in pf or missing accounting according to ipfw (after reboot) - if cpnet and ( - cpnet not in registered_addresses or - cpnet not in registered_addr_accounting - ): - self._add_client(zoneid, cpnet) + # session should be active, validate its properties + if allow_roaming: + # this will add the "primary" IP as well, but both list_session_ips and update_roaming_ips will return a deduplicated set + session_ips = self.db.update_roaming_ips(zoneid, db_client['sessionId'], self.arp.get_all_addresses_by_mac(db_client['macAddress'])) else: - # remove session - syslog.syslog(syslog.LOG_NOTICE, drop_session_reason) - self._remove_client(zoneid, cpnet) - self.db.del_client(zoneid, db_client['sessionId'], delete_reason) + # may have been updated if primary IP changed + session_ips = {db_client['ipAddress']} - # if there are addresses/networks in the underlying pf table which are not in our administration, - # remove them from pf. - for registered_address in registered_addresses: - address_active = False - for db_client in expected_clients: - if registered_address == db_client['ipAddress']: - address_active = True - break - if not address_active: + to_add = (session_ips - registered_addresses_pf) | (session_ips - registered_addresses_ipfw) + if session_ips and to_add: + for ip in to_add: + self._add_client(zoneid, ip) + + # remove any address from pf/ipfw that isn't expected + expected_addresses = set() + # need to query again as clients may have been updated + for db_client in self.db.list_clients(zoneid): + expected_addresses.update(self.db.list_session_ips(zoneid, db_client['sessionId'])) + + for registered_address in (registered_addresses_pf | registered_addresses_ipfw): + if registered_address not in expected_addresses: self._remove_client(zoneid, registered_address) def main(): diff --git a/src/opnsense/scripts/captiveportal/lib/arp.py b/src/opnsense/scripts/captiveportal/lib/arp.py index 3214847d94..1a940d68d3 100755 --- a/src/opnsense/scripts/captiveportal/lib/arp.py +++ b/src/opnsense/scripts/captiveportal/lib/arp.py @@ -1,5 +1,6 @@ """ Copyright (c) 2015-2019 Ad Schellevis + Copyright (c) 2026 Deciso B.V. All rights reserved. Redistribution and use in source and binary forms, with or without @@ -25,77 +26,60 @@ """ import subprocess - +import ujson +from datetime import datetime class ARP(object): def __init__(self): """ construct new arp helper :return: None """ - self._arp_table = dict() - self._fetch_arp_table() + self._table = {} + self.reload() def reload(self): - """ reload / parse arp table + """ reload / parse arp and ndp tables """ - self._fetch_arp_table() + self._table.clear() - def _fetch_arp_table(self): - """ parse system arp table and store result in this object - :return: None - """ - # parse arp table - self._arp_table = dict() - sp = subprocess.run(['/usr/sbin/arp', '-an'], capture_output=True, text=True) - for line in sp.stdout.split("\n"): - line_parts = line.split() + # fetch addresses, no IPv6 if hostwatch disabled + out = ujson.loads(subprocess.run( + ['/usr/local/opnsense/scripts/interfaces/list_hosts.py', '--last-seen-window', '86400', '-v'], + capture_output=True, + text=True + ).stdout) - if len(line_parts) < 6 or line_parts[2] != 'at' or line_parts[4] != 'on': - continue - elif len(line_parts[1]) < 2 or line_parts[1][0] != '(' or line_parts[1][-1] != ')': - continue + source = out.get("source") + rows = out.get("rows", []) - address = line_parts[1][1:-1] - physical_intf = line_parts[5] - mac = line_parts[3] - expires = -1 + if source == "discovery": + rows_iter = sorted( + rows, + key=lambda row: datetime.strptime(row[5], "%Y-%m-%d %H:%M:%S"), + reverse=True + ) + else: + rows_iter = rows - for index in range(len(line_parts) - 3): - if line_parts[index] == 'expires' and line_parts[index + 1] == 'in': - if line_parts[index + 2].isdigit(): - expires = int(line_parts[index + 2]) + for row in rows_iter: + ip = row[2] - if address in self._arp_table: - self._arp_table[address]['intf'].append(physical_intf) - elif mac.find('incomplete') == -1: - self._arp_table[address] = {'mac': mac, 'intf': [physical_intf], 'expires': expires} + entry = { + "intf": row[0], + "mac": row[1], + } - def list_items(self): - """ return parsed arp list - :return: dict - """ - return self._arp_table + if source == "discovery": + entry["first_seen"] = datetime.strptime(row[4], "%Y-%m-%d %H:%M:%S") + entry["last_seen"] = datetime.strptime(row[5], "%Y-%m-%d %H:%M:%S") + + self._table[ip] = entry def get_by_ipaddress(self, address): - """ search arp entry by ip address - :param address: ip address - :return: dict or None (if not found) - """ - if address in self._arp_table: - return self._arp_table[address] - else: - return None + return self._table.get(address, None) - def get_address_by_mac(self, address): - """ search arp entry by mac address, most recent arp entry - :param address: ip address - :return: dict or None (if not found) - """ - result = None - for item in self._arp_table: - if self._arp_table[item]['mac'] == address: - if result is None: - result = item - elif self._arp_table[result]['expires'] < self._arp_table[item]['expires']: - result = item - return result + def get_all_addresses_by_mac(self, mac_address): + return [ + ip for ip, data in self._table.items() + if data['mac'] == mac_address + ] diff --git a/src/opnsense/scripts/captiveportal/lib/db.py b/src/opnsense/scripts/captiveportal/lib/db.py index 9d480a37fa..d34cfd8a8f 100755 --- a/src/opnsense/scripts/captiveportal/lib/db.py +++ b/src/opnsense/scripts/captiveportal/lib/db.py @@ -39,7 +39,6 @@ class DB(object): """ self._connection = None self.open() - self.create() def __del__(self): """ destruct, close database handle @@ -70,11 +69,10 @@ class DB(object): self._connection = sqlite3.connect(self.database_filename) cur = self._connection.cursor() - cur.execute("SELECT count(*) FROM sqlite_master where tbl_name = 'cp_clients'") - if cur.fetchall()[0][0] == 0: - # empty database, initialize database - init_script_filename = '%s/../sql/init.sql' % os.path.dirname(os.path.abspath(__file__)) - cur.executescript(open(init_script_filename, 'r').read()) + + # initialize database + init_script_filename = '%s/../sql/init.sql' % os.path.dirname(os.path.abspath(__file__)) + cur.executescript(open(init_script_filename, 'r').read()) # migration: add "delete_reason" column to cp_clients cur.execute("PRAGMA table_info(cp_clients)") @@ -102,69 +100,93 @@ class DB(object): cur.close() - def sessions_per_address(self, zoneid, ip_address=None, mac_address=None): - """ fetch session(s) per (mac) address - :param zoneid: cp zone number - :param ip_address: ip address - :return: active status (boolean) + def sessions_per_address(self, zoneid, ip_address='', mac_address=''): + """ fetch session(s) per (ip/mac) address + Primary IP is stored in cp_clients.ip_address; roaming IPs in cp_client_ips. """ + # Nothing to match on + if ip_address == '' and mac_address == '': + return [] + cur = self._connection.cursor() request = { 'zoneid': zoneid, 'ip_address': ip_address, 'mac_address': mac_address } - cur.execute("""select cc.sessionid sessionId - , cc.authenticated_via authenticated_via - , cc.ip_address - from cp_clients cc - where cc.deleted = 0 - and cc.zoneid = :zoneid - and ( - cc.ip_address = :ip_address - or - cc.mac_address = :mac_address - )""", request) - result = [] - for row in cur.fetchall(): - result.append({'sessionId': row[0], 'authenticated_via': row[1]}) - return result + clauses = [] + if ip_address != '': + # Match either primary IP or any roaming IP + clauses.append("(cc.ip_address = :ip_address OR ci.ip_address = :ip_address)") + if mac_address != '': + clauses.append("cc.mac_address = :mac_address") + + where_or = " OR ".join(clauses) + + cur.execute(f""" + SELECT DISTINCT + cc.sessionid AS sessionId, + cc.authenticated_via AS authenticated_via + FROM cp_clients cc + LEFT JOIN cp_client_ips ci + ON ci.zoneid = cc.zoneid + AND ci.sessionid = cc.sessionid + WHERE cc.deleted = 0 + AND cc.zoneid = :zoneid + AND ({where_or}) + """, request) + + return [{'sessionId': sessionId, 'authenticated_via': authenticated_via} + for sessionId, authenticated_via in cur.fetchall()] def add_client(self, zoneid, authenticated_via, username, ip_address, mac_address): - """ add a new client to the captive portal administration - :param zoneid: cp zone number - :param authenticated_via: name/id of the authenticator or ---ip--- / ---mac--- for authentication by address - :param username: username, maybe empty - :param ip_address: ip address (to unlock) - :param mac_address: physical address of this ip - :return: dictionary with session info - """ - response = dict() - response['zoneid'] = zoneid - response['authenticated_via'] = authenticated_via - response['userName'] = username - response['ipAddress'] = ip_address - response['macAddress'] = mac_address - response['startTime'] = time.time() # record creation = sign-in time - response['sessionId'] = base64.b64encode(os.urandom(16)).decode() # generate a new random session id + response = { + 'zoneid': zoneid, + 'authenticated_via': authenticated_via, + 'userName': username, + 'ipAddress': ip_address, + 'macAddress': mac_address, + 'startTime': time.time(), + 'sessionId': base64.b64encode(os.urandom(16)).decode() + } cur = self._connection.cursor() - # set cp_client as deleted in case there's already a user logged-in at this ip address. - if ip_address is not None and ip_address != '': - cur.execute("""UPDATE cp_clients - SET deleted = 1 - WHERE zoneid = :zoneid - AND ip_address = :ipAddress - """, response) + try: + cur.execute("BEGIN") - # add new session - cur.execute("""INSERT INTO cp_clients(zoneid, authenticated_via, sessionid, username, ip_address, mac_address, created) - VALUES (:zoneid, :authenticated_via, :sessionId, :userName, :ipAddress, :macAddress, :startTime) - """, response) + # set cp_client as deleted in case there's already a user logged-in at this ip address + # (match both primary IP and roaming IPs) + if ip_address != '': + cur.execute(""" + UPDATE cp_clients + SET deleted = 1 + WHERE zoneid = :zoneid + AND deleted = 0 + AND ( + ip_address = :ipAddress + OR sessionid IN ( + SELECT sessionid + FROM cp_client_ips + WHERE zoneid = :zoneid + AND ip_address = :ipAddress + ) + ) + """, response) - self._connection.commit() - return response + # add new session (primary IP lives here) + cur.execute(""" + INSERT INTO cp_clients(zoneid, authenticated_via, sessionid, username, ip_address, mac_address, created) + VALUES (:zoneid, :authenticated_via, :sessionId, :userName, :ipAddress, :macAddress, :startTime) + """, response) + + self._connection.commit() + return response + except Exception: + self._connection.rollback() + raise + finally: + cur.close() def update_client_ip(self, zoneid, sessionid, ip_address): """ change client ip address @@ -180,6 +202,70 @@ class DB(object): """, {'zoneid': zoneid, 'sessionid': sessionid, 'ip_address': ip_address}) self._connection.commit() + def update_roaming_ips(self, zoneid, sessionid, ip_addresses=[]): + """Update roaming IP addresses for a session to exactly match the provided list. + Returns the current set of IP addresses stored in the database. + """ + + # clean + deduplicate + remove empty values + new_ips = {ip for ip in ip_addresses if ip != ''} + + cur = self._connection.cursor() + try: + # fetch existing IPs + cur.execute(""" + SELECT ip_address + FROM cp_client_ips + WHERE zoneid = :zoneid + AND sessionid = :sessionid + """, { + 'zoneid': zoneid, + 'sessionid': sessionid + }) + + current_ips = {row[0] for row in cur.fetchall()} + + # if identical (order-independent), do nothing + if current_ips == new_ips: + return current_ips + + # remove IPs no longer present + ips_to_delete = current_ips - new_ips + if ips_to_delete: + cur.executemany(""" + DELETE FROM cp_client_ips + WHERE zoneid = :zoneid + AND sessionid = :sessionid + AND ip_address = :ip_address + """, [ + { + 'zoneid': zoneid, + 'sessionid': sessionid, + 'ip_address': ip + } + for ip in ips_to_delete + ]) + + # add new IPs + ips_to_add = new_ips - current_ips + if ips_to_add: + cur.executemany(""" + INSERT INTO cp_client_ips(zoneid, sessionid, ip_address) + VALUES (:zoneid, :sessionid, :ip_address) + """, [ + { + 'zoneid': zoneid, + 'sessionid': sessionid, + 'ip_address': ip + } + for ip in ips_to_add + ]) + + self._connection.commit() + return new_ips + finally: + cur.close() + def del_client(self, zoneid, sessionid, reason=None): """ mark (administrative) client for removal :param zoneid: zone id @@ -211,15 +297,16 @@ class DB(object): else: return None - def list_clients(self, zoneid=None): + def list_clients(self, zoneid=None, include_ips=False): """ return list of (administrative) connected clients and usage statistics :param zoneid: zone id + :param include_ips: if True, include all IPs (primary + roaming) as a list in record['ipAddresses'] :return: list of clients """ - result = list() - fieldnames = list() + result = [] + fieldnames = [] cur = self._connection.cursor() - # rename fields for API + cur.execute(""" select cc.zoneid , cc.sessionid sessionId , cc.authenticated_via authenticated_via @@ -243,24 +330,91 @@ class DB(object): and cc.deleted = 0 order by case when cc.username is not null then cc.username else cc.ip_address end , cc.created desc - """, {'zoneid': zoneid}) + """, {'zoneid': zoneid}) + + ip_map = {} + if include_ips: + cur_ips = self._connection.cursor() + cur_ips.execute(""" + SELECT cc.zoneid, cc.sessionid, cc.ip_address + FROM cp_clients cc + WHERE (cc.zoneid = :zoneid OR :zoneid IS NULL) + AND cc.deleted = 0 + AND cc.ip_address IS NOT NULL + AND TRIM(cc.ip_address) <> '' + UNION ALL + SELECT ci.zoneid, ci.sessionid, ci.ip_address + FROM cp_client_ips ci + JOIN cp_clients cc + ON cc.zoneid = ci.zoneid AND cc.sessionid = ci.sessionid + WHERE (ci.zoneid = :zoneid OR :zoneid IS NULL) + AND cc.deleted = 0 + AND ci.ip_address IS NOT NULL + AND TRIM(ci.ip_address) <> '' + """, {'zoneid': zoneid}) + + for z, sid, ip in cur_ips.fetchall(): + ip_map.setdefault((z, sid), set()).add(ip) + ip_map = {k: sorted(v) for k, v in ip_map.items()} + cur_ips.close() while True: - # fetch field names - if len(fieldnames) == 0: + if not fieldnames: for fields in cur.description: fieldnames.append(fields[0]) row = cur.fetchone() if row is None: break - else: - record = dict() - for idx in range(len(row)): - record[fieldnames[idx]] = row[idx] - result.append(record) + + record = {fieldnames[idx]: row[idx] for idx in range(len(row))} + if include_ips: + record['ipAddresses'] = ip_map.get((record['zoneid'], record['sessionId']), []) + result.append(record) + + cur.close() return result + + def list_session_ips(self, zoneid, sessionid): + """ + Return primary + roaming IPs for a session. + - Primary IP is cp_clients.ip_address + - roaming IPs are cp_client_ips.ip_address + Returns a de-duplicated set[str] (order not guaranteed). + """ + cur = self._connection.cursor() + try: + params = {'zoneid': zoneid, 'sessionid': sessionid} + + cur.execute(f""" + SELECT cc.ip_address + FROM cp_clients cc + WHERE cc.zoneid = :zoneid + AND cc.sessionid = :sessionid + AND cc.deleted = 0 + """, params) + row = cur.fetchone() + ips = set() + if row and row[0] and str(row[0]): + ips.add(str(row[0])) + + cur.execute(""" + SELECT ci.ip_address + FROM cp_client_ips ci + WHERE ci.zoneid = :zoneid + AND ci.sessionid = :sessionid + AND ci.ip_address IS NOT NULL + AND TRIM(ci.ip_address) <> '' + """, params) + for (ip,) in cur.fetchall(): + if ip and str(ip): + ips.add(str(ip)) + + return ips + finally: + cur.close() + def find_concurrent_user_sessions(self, zoneid): """ query zone database for concurrent user sessions :param zoneid: zone id @@ -292,84 +446,173 @@ class DB(object): def update_accounting_info(self, details): """ update internal accounting database with given ipfw info (not per zone) - :param details: ipfw accounting details + :param details: ipfw accounting details dict keyed by ip: + details[ip] = {'in_pkts','out_pkts','in_bytes','out_bytes','last_accessed'} """ - if type(details) == dict: - # query registered data - sql = """ select cc.ip_address, cc.zoneid, cc.sessionid - , si.rowid si_rowid, si.prev_packets_in, si.prev_bytes_in - , si.prev_packets_out, si.prev_bytes_out, si.last_accessed - from cp_clients cc - left join session_info si on si.zoneid = cc.zoneid and si.sessionid = cc.sessionid - order by cc.ip_address, cc.deleted - """ - cur = self._connection.cursor() - cur2 = self._connection.cursor() - cur.execute(sql) - prev_record = {'ip_address': None} - for row in cur.fetchall(): - # map fieldnumbers to names - record = {} - for fieldId in range(len(row)): - record[cur.description[fieldId][0]] = row[fieldId] - # search unique hosts from dataset, both disabled and enabled. - if prev_record['ip_address'] != record['ip_address'] and record['ip_address'] in details: - if record['si_rowid'] is None: - # new session, add info object - sql_new = """ insert into session_info(zoneid, sessionid, prev_packets_in, prev_bytes_in, - prev_packets_out, prev_bytes_out, - packets_in, packets_out, bytes_in, bytes_out, - last_accessed) - values (:zoneid, :sessionid, :packets_in, :bytes_in, :packets_out, :bytes_out, - :packets_in, :packets_out, :bytes_in, :bytes_out, :last_accessed) - """ - record['packets_in'] = details[record['ip_address']]['in_pkts'] - record['bytes_in'] = details[record['ip_address']]['in_bytes'] - record['packets_out'] = details[record['ip_address']]['out_pkts'] - record['bytes_out'] = details[record['ip_address']]['out_bytes'] - record['last_accessed'] = details[record['ip_address']]['last_accessed'] - cur2.execute(sql_new, record) - else: - # update session - sql_update = """ update session_info - set last_accessed = :last_accessed - , prev_packets_in = :prev_packets_in - , prev_packets_out = :prev_packets_out - , prev_bytes_in = :prev_bytes_in - , prev_bytes_out = :prev_bytes_out - , packets_in = packets_in + :packets_in - , packets_out = packets_out + :packets_out - , bytes_in = bytes_in + :bytes_in - , bytes_out = bytes_out + :bytes_out - where rowid = :si_rowid - """ - # add usage to session - record['last_accessed'] = details[record['ip_address']]['last_accessed'] - if record['prev_packets_in'] <= details[record['ip_address']]['in_pkts'] and \ - record['prev_packets_out'] <= details[record['ip_address']]['out_pkts']: - # ipfw data is still valid, add difference to use - record['packets_in'] = ( - details[record['ip_address']]['in_pkts'] - record['prev_packets_in']) - record['packets_out'] = ( - details[record['ip_address']]['out_pkts'] - record['prev_packets_out']) - record['bytes_in'] = (details[record['ip_address']]['in_bytes'] - record['prev_bytes_in']) - record['bytes_out'] = ( - details[record['ip_address']]['out_bytes'] - record['prev_bytes_out']) - else: - # the data has been reset (reloading rules), add current packet count - record['packets_in'] = details[record['ip_address']]['in_pkts'] - record['packets_out'] = details[record['ip_address']]['out_pkts'] - record['bytes_in'] = details[record['ip_address']]['in_bytes'] - record['bytes_out'] = details[record['ip_address']]['out_bytes'] + if type(details) != dict: + return - record['prev_packets_in'] = details[record['ip_address']]['in_pkts'] - record['prev_packets_out'] = details[record['ip_address']]['out_pkts'] - record['prev_bytes_in'] = details[record['ip_address']]['in_bytes'] - record['prev_bytes_out'] = details[record['ip_address']]['out_bytes'] - cur2.execute(sql_update, record) + cur = self._connection.cursor() + cur2 = self._connection.cursor() - prev_record = record - self._connection.commit() + # Load sessions + existing session_info + cur.execute(""" + SELECT cc.zoneid, + cc.sessionid, + cc.ip_address AS primary_ip, + cc.created AS created, + si.rowid AS si_rowid, + COALESCE(si.prev_packets_in, 0) AS prev_packets_in, + COALESCE(si.prev_bytes_in, 0) AS prev_bytes_in, + COALESCE(si.prev_packets_out, 0) AS prev_packets_out, + COALESCE(si.prev_bytes_out, 0) AS prev_bytes_out, + COALESCE(si.last_accessed, 0) AS last_accessed + FROM cp_clients cc + LEFT JOIN session_info si + ON si.zoneid = cc.zoneid AND si.sessionid = cc.sessionid + """) + + sessions = {} # (zoneid, sessionid) -> record + for row in cur.fetchall(): + rec = { + 'zoneid': row[0], + 'sessionid': row[1], + 'primary_ip': row[2], + 'created': row[3], + 'si_rowid': row[4], + 'prev_packets_in': row[5], + 'prev_bytes_in': row[6], + 'prev_packets_out': row[7], + 'prev_bytes_out': row[8], + 'last_accessed': row[9], + 'ips': set() + } + sessions[(rec['zoneid'], rec['sessionid'])] = rec + + # Add roaming IPs + cur.execute(""" + SELECT zoneid, sessionid, ip_address + FROM cp_client_ips + WHERE ip_address IS NOT NULL AND TRIM(ip_address) <> '' + """) + for zoneid, sessionid, ip in cur.fetchall(): + key = (zoneid, sessionid) + if key in sessions: + sessions[key]['ips'].add(ip) + + # Ensure primary IP is included for each session + for rec in sessions.values(): + pip = rec.get('primary_ip') + if pip is not None and pip != '': + rec['ips'].add(pip) + + sql_new = """ + INSERT INTO session_info( + zoneid, sessionid, + prev_packets_in, prev_bytes_in, + prev_packets_out, prev_bytes_out, + packets_in, packets_out, + bytes_in, bytes_out, + last_accessed + ) + VALUES ( + :zoneid, :sessionid, + :prev_packets_in, :prev_bytes_in, + :prev_packets_out, :prev_bytes_out, + :packets_in, :packets_out, + :bytes_in, :bytes_out, + :last_accessed + ) + """ + + sql_update = """ + UPDATE session_info + SET last_accessed = :last_accessed, + prev_packets_in = :prev_packets_in, + prev_packets_out = :prev_packets_out, + prev_bytes_in = :prev_bytes_in, + prev_bytes_out = :prev_bytes_out, + packets_in = packets_in + :packets_in, + packets_out = packets_out + :packets_out, + bytes_in = bytes_in + :bytes_in, + bytes_out = bytes_out + :bytes_out + WHERE rowid = :si_rowid + """ + + # Update accounting per session by summing over all of its IPs + for rec in sessions.values(): + cur_pkts_in = 0 + cur_pkts_out = 0 + cur_bytes_in = 0 + cur_bytes_out = 0 + cur_last_accessed = 0 + + any_hit = False + for ip in rec['ips']: + d = details.get(ip) + if not d: + continue + any_hit = True + cur_pkts_in += int(d.get('in_pkts', 0)) + cur_pkts_out += int(d.get('out_pkts', 0)) + cur_bytes_in += int(d.get('in_bytes', 0)) + cur_bytes_out += int(d.get('out_bytes', 0)) + cur_last_accessed = max(cur_last_accessed, int(d.get('last_accessed', 0))) + + if not any_hit: + continue + + last_accessed = cur_last_accessed if cur_last_accessed else int(rec['created'] or 0) + + if rec['si_rowid'] is None: + payload = { + 'zoneid': rec['zoneid'], + 'sessionid': rec['sessionid'], + 'prev_packets_in': cur_pkts_in, + 'prev_bytes_in': cur_bytes_in, + 'prev_packets_out': cur_pkts_out, + 'prev_bytes_out': cur_bytes_out, + 'packets_in': cur_pkts_in, + 'packets_out': cur_pkts_out, + 'bytes_in': cur_bytes_in, + 'bytes_out': cur_bytes_out, + 'last_accessed': last_accessed + } + cur2.execute(sql_new, payload) + else: + prev_pi = int(rec['prev_packets_in']) + prev_po = int(rec['prev_packets_out']) + prev_bi = int(rec['prev_bytes_in']) + prev_bo = int(rec['prev_bytes_out']) + + # If totals decreased, treat as reset and add full totals + if (cur_pkts_in >= prev_pi and cur_pkts_out >= prev_po and + cur_bytes_in >= prev_bi and cur_bytes_out >= prev_bo): + add_pi = cur_pkts_in - prev_pi + add_po = cur_pkts_out - prev_po + add_bi = cur_bytes_in - prev_bi + add_bo = cur_bytes_out - prev_bo + else: + add_pi = cur_pkts_in + add_po = cur_pkts_out + add_bi = cur_bytes_in + add_bo = cur_bytes_out + + payload = { + 'si_rowid': rec['si_rowid'], + 'last_accessed': last_accessed, + 'packets_in': add_pi, + 'packets_out': add_po, + 'bytes_in': add_bi, + 'bytes_out': add_bo, + 'prev_packets_in': cur_pkts_in, + 'prev_packets_out': cur_pkts_out, + 'prev_bytes_in': cur_bytes_in, + 'prev_bytes_out': cur_bytes_out + } + cur2.execute(sql_update, payload) + + self._connection.commit() def update_session_restrictions(self, zoneid, sessionid, session_timeout): """ upsert session restrictions diff --git a/src/opnsense/scripts/captiveportal/lib/ipfw.py b/src/opnsense/scripts/captiveportal/lib/ipfw.py index f4410734e6..33f5139cfb 100755 --- a/src/opnsense/scripts/captiveportal/lib/ipfw.py +++ b/src/opnsense/scripts/captiveportal/lib/ipfw.py @@ -26,8 +26,17 @@ """ import os import subprocess +import ipaddress class IPFW(object): + @staticmethod + def _is_ipv6(address): + try: + ipaddress.IPv6Address(address) + return True + except (ValueError, AttributeError): + return False + @staticmethod def list_accounting_info(): """ list accounting info per ip address, addresses can't overlap in zone's so we just output all we know here @@ -87,9 +96,10 @@ class IPFW(object): # add accounting rule if new_rule_id != -1: - subprocess.run(['/sbin/ipfw', 'add', str(new_rule_id), 'count', 'ip', 'from', address, 'to', 'any'], + proto = 'ip6' if IPFW._is_ipv6(address) else 'ip' + subprocess.run(['/sbin/ipfw', 'add', str(new_rule_id), 'count', proto, 'from', address, 'to', 'any'], capture_output=True) - subprocess.run(['/sbin/ipfw', 'add', str(new_rule_id), 'count', 'ip', 'from', 'any', 'to', address], + subprocess.run(['/sbin/ipfw', 'add', str(new_rule_id), 'count', proto, 'from', 'any', 'to', address], capture_output=True) return new_rule_id diff --git a/src/opnsense/scripts/captiveportal/lib/pf.py b/src/opnsense/scripts/captiveportal/lib/pf.py index 4b2f0d4665..4cb3ca1014 100755 --- a/src/opnsense/scripts/captiveportal/lib/pf.py +++ b/src/opnsense/scripts/captiveportal/lib/pf.py @@ -25,13 +25,20 @@ """ import subprocess -import tempfile -import time +import ipaddress class PF(object): def __init__(self): pass + @staticmethod + def _is_ipv6(address): + try: + ipaddress.IPv6Address(address) + return True + except (ValueError, AttributeError): + return False + @staticmethod def list_table(zoneid): pfctl_cmd = ['/sbin/pfctl', '-t', f'__captiveportal_zone_{zoneid}', '-T', 'show'] @@ -50,4 +57,6 @@ class PF(object): subprocess.run(['/sbin/pfctl', '-t', f'__captiveportal_zone_{zoneid}', '-T', 'del', address], capture_output=True) # kill associated states to and from this host subprocess.run(['/sbin/pfctl', '-k', f'{address}'], capture_output=True) - subprocess.run(['/sbin/pfctl', '-k', '0.0.0.0/0', '-k', f'{address}'], capture_output=True) + # Use appropriate wildcard based on IP version + wildcard = '::/0' if PF._is_ipv6(address) else '0.0.0.0/0' + subprocess.run(['/sbin/pfctl', '-k', wildcard, '-k', f'{address}'], capture_output=True) diff --git a/src/opnsense/scripts/captiveportal/listClients.py b/src/opnsense/scripts/captiveportal/listClients.py index 2c8c7336f7..ab303fca63 100755 --- a/src/opnsense/scripts/captiveportal/listClients.py +++ b/src/opnsense/scripts/captiveportal/listClients.py @@ -37,5 +37,5 @@ parser = argparse.ArgumentParser() parser.add_argument('-z', help='optional zoneid to filter on', type=str) args = parser.parse_args() -response = DB().list_clients(int(args.z) if str(args.z).isdigit() else None) +response = DB().list_clients(int(args.z) if str(args.z).isdigit() else None, True) print(ujson.dumps(response)) diff --git a/src/opnsense/scripts/captiveportal/sql/init.sql b/src/opnsense/scripts/captiveportal/sql/init.sql index d8e53c34f5..5ff163ef62 100755 --- a/src/opnsense/scripts/captiveportal/sql/init.sql +++ b/src/opnsense/scripts/captiveportal/sql/init.sql @@ -3,7 +3,7 @@ -- -- connected clients -create table cp_clients ( +create table if not exists cp_clients ( zoneid int , sessionid varchar , authenticated_via varchar @@ -15,11 +15,25 @@ create table cp_clients ( , primary key (zoneid, sessionid) ); -create index cp_clients_ip ON cp_clients (ip_address); -create index cp_clients_zone ON cp_clients (zoneid); +create index if not exists cp_clients_ip ON cp_clients (ip_address); +create index if not exists cp_clients_zone ON cp_clients (zoneid); + +-- multiple IPs per session +create table if not exists cp_client_ips ( + zoneid int not null +, sessionid varchar not null +, ip_address varchar not null +, primary key (zoneid, sessionid, ip_address) +, foreign key (zoneid, sessionid) + references cp_clients(zoneid, sessionid) + on delete cascade +); + +create index if not exists cp_client_ips_ip on cp_client_ips (ip_address); +create index if not exists cp_client_ips_zone on cp_client_ips (zoneid); -- session (accounting) info -create table session_info ( +create table if not exists session_info ( zoneid int , sessionid varchar , prev_packets_in integer default (0) @@ -35,7 +49,7 @@ create table session_info ( ); -- session (accounting) restrictions -create table session_restrictions ( +create table if not exists session_restrictions ( zoneid int , sessionid varchar , session_timeout int @@ -43,7 +57,7 @@ create table session_restrictions ( ) ; -- accounting state, record the state of (radius) accounting messages -create table accounting_state ( +create table if not exists accounting_state ( zoneid int , sessionid varchar , state varchar diff --git a/src/opnsense/scripts/interfaces/list_hosts.py b/src/opnsense/scripts/interfaces/list_hosts.py index 5f1d545d00..3b6a98a206 100755 --- a/src/opnsense/scripts/interfaces/list_hosts.py +++ b/src/opnsense/scripts/interfaces/list_hosts.py @@ -55,6 +55,12 @@ if __name__ == '__main__': parser.add_argument( '-v', '--verbose', help='Verbose output (including vendors)', action="store_true", default=False ) + parser.add_argument( + "--last-seen-window", + type=int, + default=None, + help="Only return hosts seen in the last N seconds" + ) parser.add_argument('--rc_file', help='hostwatch rc(8) config filename', default='/etc/rc.conf.d/hostwatch') parser.add_argument('--db_file', help='hostwatch sqlite3 database', default='/var/db/hostwatch/hosts.db') inputargs = parser.parse_args() @@ -65,12 +71,27 @@ if __name__ == '__main__': result['source'] = 'discovery' con = sqlite3.connect("file:%s?mode=ro" % inputargs.db_file, uri=True) con.row_factory = sqlite3.Row - for row in con.execute( - 'select * from v_hosts where protocol in (?, ?)', (inputargs.proto[0], inputargs.proto[-1]) - ): + query = """ + SELECT * + FROM v_hosts + WHERE protocol IN (?, ?) + """ + params = [inputargs.proto[0], inputargs.proto[-1]] + + if inputargs.last_seen_window is not None: + query += """ + AND last_seen >= datetime('now', '-' || ? || ' seconds') + """ + params.append(inputargs.last_seen_window) + + for row in con.execute(query, params): record = [row['interface_name'], row['ether_address'], row['ip_address']] if inputargs.verbose: - record = record + [row['organization_name'], row['first_seen'], row['last_seen']] + record += [ + row['organization_name'], + row['first_seen'], + row['last_seen'] + ] result['rows'].append(record) else: result['source'] = 'arp-ndp' diff --git a/src/opnsense/service/conf/actions.d/actions_hostwatch.conf b/src/opnsense/service/conf/actions.d/actions_hostwatch.conf index 538df9ffd0..85893e5f0b 100644 --- a/src/opnsense/service/conf/actions.d/actions_hostwatch.conf +++ b/src/opnsense/service/conf/actions.d/actions_hostwatch.conf @@ -32,7 +32,7 @@ type:script_output message:fetch detailed host information [dump] -command:/usr/local/opnsense/scripts/interfaces/list_hosts.py +command:/usr/local/opnsense/scripts/interfaces/list_hosts.py -n parameters: errors:no cache_ttl:30 diff --git a/src/opnsense/service/templates/OPNsense/Captiveportal/captiveportal.conf b/src/opnsense/service/templates/OPNsense/Captiveportal/captiveportal.conf index fc61dae326..7d595331a1 100644 --- a/src/opnsense/service/templates/OPNsense/Captiveportal/captiveportal.conf +++ b/src/opnsense/service/templates/OPNsense/Captiveportal/captiveportal.conf @@ -8,6 +8,7 @@ hardtimeout={{ cpZone.hardtimeout|default("0") }} concurrentlogins={{cpZone.concurrentlogins|default("0")}} allowedAddresses={{cpZone.allowedAddresses|default("")}} allowedMACAddresses={{cpZone.allowedMACAddresses|default("")}} +roaming={{cpZone.roaming|default("0")}} [template_for_zone_{{cpZone.zoneid}}] content={{helpers.getUUID(cpZone.template).content}} diff --git a/src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-api-dispatcher.conf b/src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-api-dispatcher.conf index 2285ffe5af..c76f522d6f 100644 --- a/src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-api-dispatcher.conf +++ b/src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-api-dispatcher.conf @@ -39,8 +39,8 @@ url.access-deny = ( "~", ".inc" ) ######### Options that are good to be but not necessary to be changed ####### ## bind to port (default: 80) -server.bind = "127.0.0.1" -server.port = 8999 +server.bind = "127.0.0.1" +server.port = 8999 ## to help the rc.scripts server.pid-file = "/var/run/lighttpd-api-dispatcher.pid" diff --git a/src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-zone.conf b/src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-zone.conf index ca54e9ee85..b48a0abc6d 100644 --- a/src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-zone.conf +++ b/src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-zone.conf @@ -6,7 +6,7 @@ {% for intf_tag in item.interfaces.split(',') %} {% for conf_key, conf_inf in interfaces.items() %} {% if conf_key == intf_tag and conf_inf.ipaddr and conf_inf.ipaddr != 'dhcp' %} - {% do item.update({'interface_hostaddr':conf_inf.ipaddr}) %} + {% do item.update({'interface_hostaddr': conf_inf.ipaddr}) %} {% endif %} {% endfor %} {% endfor %} @@ -22,15 +22,16 @@ {# generate zone redirect address #} {% if not cp_zone_item.interface_hostaddr %} - # interface address not found {% elif cp_zone_item.certificate|default("") != "" %} + # interface address / servername not found + {% elif cp_zone_item.certificate|default("") != "" %} # ssl enabled, redirect to https - {% do cp_zone_item.update({'redirect_host':'https://'+cp_zone_item.interface_hostaddr + ':' ~ (cp_zone_item.zoneid|int + 8000) ~ '/index.html'}) %} + {% do cp_zone_item.update({'redirect_host': 'https://' ~ cp_zone_item.interface_hostaddr ~ ':' ~ (cp_zone_item.zoneid|int + 8000) ~ '/index.html'}) %} {% else %} # ssl disabled, redirect to http - {% do cp_zone_item.update({'redirect_host':'http://'+cp_zone_item.interface_hostaddr + ':' ~ (cp_zone_item.zoneid|int + 8000) ~ '/index.html'}) %} + {% do cp_zone_item.update({'redirect_host': 'http://' ~ cp_zone_item.interface_hostaddr ~ ':' ~ (cp_zone_item.zoneid|int + 8000) ~ '/index.html'}) %} {% endif %} {% if cp_zone_item.interface_hostaddr %} - {% do cp_zone_item.update({'redirect_host_match':cp_zone_item.interface_hostaddr.replace('.','\.') ~ ':' ~ (cp_zone_item.zoneid|int + 8000) }) %} + {% do cp_zone_item.update({'redirect_host_match':cp_zone_item.interface_hostaddr.replace('.','\.') ~ ':' ~ (cp_zone_item.zoneid|int + 8000) }) %} {% endif %} ############################################################################################# @@ -83,23 +84,35 @@ server.port = {{ cp_zone_item.zoneid|int + 8000 }} ## Redirect response code to use url.redirect-code = 302 -## -$HTTP["host"] !~ "(.*{{cp_zone_item.redirect_host_match}}.*)" { +$HTTP["host"] !~ "(.*{{ cp_zone_item.redirect_host_match }}.*)" { $HTTP["host"] =~ "([^:/]+)" { - url.redirect = ( "^(.*)$" => "{{cp_zone_item.redirect_host}}?redirurl=%1$1") + url.redirect = ( "^(.*)$" => "{{ cp_zone_item.redirect_host }}?redirurl=%1$1") } } +$SERVER["socket"] == "[::]:{{ cp_zone_item.zoneid|int + 8000 }}" { + $HTTP["host"] !~ "(.*{{cp_zone_item.redirect_host_match}}.*)" { + $HTTP["host"] =~ "([^:/]+)" { + url.redirect = ( "^(.*)$" => "{{cp_zone_item.redirect_host}}?redirurl=%1$1") + } + } + {% if cp_zone_item.certificate|default("") != "" %} + ssl.engine = "enable" + {% else %} + ssl.engine = "disable" + {% endif %} +} + ## redirect http traffic to http(s) main target $SERVER["socket"] == ":{{ cp_zone_item.zoneid|int + 9000 }}" { $HTTP["host"] =~ "([^:/]+)" { - url.redirect = ( "^(.*)$" => "{{cp_zone_item.redirect_host}}?redirurl=%1$1") + url.redirect = ( "^(.*)$" => "{{ cp_zone_item.redirect_host }}?redirurl=%1$1") } ssl.engine = "disable" } $SERVER["socket"] == "[::]:{{ cp_zone_item.zoneid|int + 9000 }}" { $HTTP["host"] =~ "([^:/]+)" { - url.redirect = ( "(.*)" => "{{cp_zone_item.redirect_host}}?redirurl=%1$1") + url.redirect = ( "^(.*)$" => "{{ cp_zone_item.redirect_host }}?redirurl=%1$1") } ssl.engine = "disable" }