').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"
}