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" }