mirror of
https://github.com/opnsense/core.git
synced 2026-05-28 04:34:51 -04:00
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 <alex@goodkind.io>
This commit is contained in:
parent
770480715b
commit
369630dbd3
19 changed files with 716 additions and 280 deletions
|
|
@ -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,
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,16 @@
|
|||
<formatter>boolean</formatter>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>zone.roaming</id>
|
||||
<type>checkbox</type>
|
||||
<label>Client roaming</label>
|
||||
<help>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.</help>
|
||||
<grid_view>
|
||||
<type>boolean</type>
|
||||
<formatter>boolean</formatter>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>zone.authservers</id>
|
||||
<label>Authenticate using</label>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2026 Deciso B.V.
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
namespace OPNsense\System\Status;
|
||||
|
||||
use OPNsense\System\AbstractStatus;
|
||||
use OPNsense\System\SystemStatusCode;
|
||||
use OPNsense\Hostdiscovery\Hostwatch;
|
||||
|
||||
class CaptivePortalStatus extends AbstractStatus
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<model>
|
||||
<mount>//OPNsense/captiveportal</mount>
|
||||
<version>1.0.4</version>
|
||||
<version>1.0.5</version>
|
||||
<description>Captive portal application model</description>
|
||||
<items>
|
||||
<zones>
|
||||
|
|
@ -33,6 +33,10 @@
|
|||
<Multiple>Y</Multiple>
|
||||
<Service>CaptivePortal</Service>
|
||||
</authservers>
|
||||
<roaming type="BooleanField">
|
||||
<Default>1</Default>
|
||||
<Required>Y</Required>
|
||||
</roaming>
|
||||
<alwaysSendAccountingReqs type="BooleanField">
|
||||
<Default>0</Default>
|
||||
<Required>Y</Required>
|
||||
|
|
|
|||
|
|
@ -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 $('<span>', { class: 'text-muted', text: '-' })[0].outerHTML;
|
||||
}
|
||||
|
||||
return $('<span>', {
|
||||
'data-toggle': 'tooltip',
|
||||
'data-placement': 'top',
|
||||
title: ips.join('\n')
|
||||
}).append(ips.map(ip => $('<div>').text(ip).html()).join('<br>'))[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 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
[data-column-id="ipAddress"] {
|
||||
white-space: normal !important;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ul class="nav nav-tabs" data-tabs="tabs" id="maintabs"></ul>
|
||||
<div class="tab-content content-box col-xs-12 __mb">
|
||||
<div class="btn-group" id="zone-selection-wrapper">
|
||||
|
|
@ -91,14 +125,14 @@
|
|||
<tr>
|
||||
<th data-column-id="sessionId" data-type="string" data-identifier="true" data-visible="false">{{ lang._('Session') }}</th>
|
||||
<th data-column-id="zoneid" data-width="7em" data-type="string" data-visible="false">{{ lang._('Zoneid') }}</th>
|
||||
<th data-column-id="userName" data-type="string">{{ lang._('Username') }}</th>
|
||||
<th data-column-id="macAddress" data-type="string" data-width="12em" data-css-class="hidden-xs hidden-sm" data-header-css-class="hidden-xs hidden-sm">{{ lang._('MAC address') }}</th>
|
||||
<th data-column-id="ipAddress" data-type="string" data-width="12em" data-css-class="hidden-xs hidden-sm" data-header-css-class="hidden-xs hidden-sm">{{ lang._('IP address') }}</th>
|
||||
<th data-column-id="bytes_in" data-type="string" data-width="8em" data-formatter="bytes" data-css-class="hidden-xs hidden-sm" data-header-css-class="hidden-xs hidden-sm">{{ lang._('Bytes (in)') }}</th>
|
||||
<th data-column-id="bytes_out" data-type="string" data-width="8em" data-formatter="bytes" data-css-class="hidden-xs hidden-sm" data-header-css-class="hidden-xs hidden-sm">{{ lang._('Bytes (out)') }}</th>
|
||||
<th data-column-id="startTime" data-type="datetime">{{ lang._('Connected since') }}</th>
|
||||
<th data-column-id="last_accessed" data-type="datetime" data-css-class="hidden-xs hidden-sm" data-header-css-class="hidden-xs hidden-sm">{{ lang._('Last accessed') }}</th>
|
||||
<th data-column-id="commands" data-searchable="false" data-width="7em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
|
||||
<th data-column-id="userName" data-type="string" data-width="10em" data-formatter="userName">{{ lang._('Username') }}</th>
|
||||
<th data-column-id="macAddress" data-type="string" data-width="10em" data-css-class="hidden-xs hidden-sm" data-header-css-class="hidden-xs hidden-sm">{{ lang._('MAC address') }}</th>
|
||||
<th data-column-id="ipAddress" data-type="string" data-width="9em" data-formatter="ipAddress" data-css-class="hidden-xs hidden-sm" data-header-css-class="hidden-xs hidden-sm">{{ lang._('IP Address') }}</th>
|
||||
<th data-column-id="bytes_in" data-type="string" data-width="7em" data-formatter="bytes" data-css-class="hidden-xs hidden-sm" data-header-css-class="hidden-xs hidden-sm">{{ lang._('Bytes (in)') }}</th>
|
||||
<th data-column-id="bytes_out" data-type="string" data-width="7em" data-formatter="bytes" data-css-class="hidden-xs hidden-sm" data-header-css-class="hidden-xs hidden-sm">{{ lang._('Bytes (out)') }}</th>
|
||||
<th data-column-id="startTime" data-type="datetime" data-width="10em">{{ lang._('Connected since') }}</th>
|
||||
<th data-column-id="last_accessed" data-type="datetime" data-width="10em" data-css-class="hidden-xs hidden-sm" data-header-css-class="hidden-xs hidden-sm">{{ lang._('Last accessed') }}</th>
|
||||
<th data-column-id="commands" data-searchable="false" data-width="5em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/local/bin/python3
|
||||
|
||||
"""
|
||||
Copyright (c) 2015-2024 Ad Schellevis <ad@opnsense.org>
|
||||
Copyright (c) 2015-2025 Ad Schellevis <ad@opnsense.org>
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""
|
||||
Copyright (c) 2015-2019 Ad Schellevis <ad@opnsense.org>
|
||||
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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue