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:
Stephan de Wit 2026-03-16 09:46:52 +01:00 committed by GitHub
parent 770480715b
commit 369630dbd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 716 additions and 280 deletions

View file

@ -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,
[

View file

@ -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,
)
]
);
}
}

View file

@ -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>

View file

@ -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;
}
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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))

View file

@ -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():

View file

@ -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
]

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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))

View file

@ -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

View file

@ -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'

View file

@ -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

View file

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

View file

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

View file

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