From d08addc25c42b0d53e9fd229e2ea642438184b69 Mon Sep 17 00:00:00 2001 From: Stephan de Wit Date: Mon, 3 Mar 2025 10:48:57 +0100 Subject: [PATCH] Captive Portal: migrate to pf (#8368) * Captive Portal: WIP for migration to pf (https://github.com/opnsense/core/issues/8326) Captive Portal: cleanup references to ipfw Captive Portal: move accounting deletion to get action, update references and descriptions Captive Portal: remove note Captive Portal: move accounting to pf match rules Captive Portal: cleanup and shorten code Captive Portal: parser issue after refactor Captive Portal: update logo in default login page * Captive Portal: internal alias should not be editable * Captive Portal: move to periodic accounting sync * Captive Portal: update lighttpd zone config * Captive Portal: ether rules for accounting * Captive Portal: safe accounting fetch * Captive Portal: move counter calculation to bgprocess * Captive Portal: remove nested anchors, match anchors on interfaces as well * Captive Portal: move service logic to captiveportal.inc * Captive Portal: leftover test statement * Captive Portal: properly initialize accounting result * Captive Portal: cleanup sql * Captive Portal: Implement backend requirements for RFC 8908 While here, the zoneid is provided to the client, even though there there is no need to do so. Instead let lighttpd forward the request with an added header containing the zoneid of the client * Captive Portal: review feedback * Captive Portal: from_not case --- plist | 6 +- src/etc/inc/filter.inc | 1 + src/etc/inc/plugins.inc.d/captiveportal.inc | 172 ++++++++++++++++ src/etc/inc/plugins.inc.d/core.inc | 36 ---- src/etc/rc.d/captiveportal | 2 +- .../CaptivePortal/Api/AccessController.php | 68 ++++++- .../CaptivePortal/Api/ServiceController.php | 4 +- .../app/library/OPNsense/Firewall/Plugin.php | 18 +- .../DynamicAliases/CaptivePortalAliases.php | 60 ++++++ .../views/OPNsense/CaptivePortal/index.volt | 5 +- .../scripts/OPNsense/CaptivePortal/allow.py | 4 +- .../CaptivePortal/cp-background-process.py | 82 ++++++-- .../OPNsense/CaptivePortal/disconnect.py | 5 +- .../htdocs_default/images/default-logo.png | Bin 2070 -> 0 bytes .../htdocs_default/images/default-logo.svg | 41 ++++ .../htdocs_default/images/favicon.png | Bin 2938 -> 2100 bytes .../CaptivePortal/htdocs_default/index.html | 13 +- .../scripts/OPNsense/CaptivePortal/lib/db.py | 46 +---- .../OPNsense/CaptivePortal/lib/ipfw.py | 188 ------------------ .../scripts/OPNsense/CaptivePortal/lib/pf.py | 101 ++++++++++ .../CaptivePortal/overlay_template.py | 5 - .../OPNsense/Captiveportal/lighttpd-zone.conf | 4 +- .../service/templates/OPNsense/IPFW/ipfw.conf | 84 -------- 23 files changed, 547 insertions(+), 398 deletions(-) create mode 100644 src/etc/inc/plugins.inc.d/captiveportal.inc create mode 100644 src/opnsense/mvc/app/models/OPNsense/Firewall/DynamicAliases/CaptivePortalAliases.php delete mode 100644 src/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/images/default-logo.png create mode 100644 src/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/images/default-logo.svg delete mode 100755 src/opnsense/scripts/OPNsense/CaptivePortal/lib/ipfw.py create mode 100644 src/opnsense/scripts/OPNsense/CaptivePortal/lib/pf.py diff --git a/plist b/plist index 771946c82..dfa69ac84 100644 --- a/plist +++ b/plist @@ -18,6 +18,7 @@ /usr/local/etc/inc/interfaces.lib.inc /usr/local/etc/inc/legacy_bindings.inc /usr/local/etc/inc/plugins.inc +/usr/local/etc/inc/plugins.inc.d/captiveportal.inc /usr/local/etc/inc/plugins.inc.d/core.inc /usr/local/etc/inc/plugins.inc.d/dhcpd.inc /usr/local/etc/inc/plugins.inc.d/dhcrelay.inc @@ -738,6 +739,7 @@ /usr/local/opnsense/mvc/app/models/OPNsense/Firewall/Alias.xml /usr/local/opnsense/mvc/app/models/OPNsense/Firewall/Category.php /usr/local/opnsense/mvc/app/models/OPNsense/Firewall/Category.xml +/usr/local/opnsense/mvc/app/models/OPNsense/Firewall/DynamicAliases/CaptivePortalAliases.php /usr/local/opnsense/mvc/app/models/OPNsense/Firewall/DynamicAliases/InterfaceNetworkAliases.php /usr/local/opnsense/mvc/app/models/OPNsense/Firewall/DynamicAliases/README.md /usr/local/opnsense/mvc/app/models/OPNsense/Firewall/DynamicAliases/StaticAliases.php @@ -1054,7 +1056,7 @@ /usr/local/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/fonts/glyphicons-halflings-regular.ttf /usr/local/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/fonts/glyphicons-halflings-regular.woff /usr/local/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/fonts/glyphicons-halflings-regular.woff2 -/usr/local/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/images/default-logo.png +/usr/local/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/images/default-logo.svg /usr/local/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/images/favicon.png /usr/local/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/index.html /usr/local/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/js/bootstrap.min.js @@ -1063,7 +1065,7 @@ /usr/local/opnsense/scripts/OPNsense/CaptivePortal/lib/arp.py /usr/local/opnsense/scripts/OPNsense/CaptivePortal/lib/daemonize.py /usr/local/opnsense/scripts/OPNsense/CaptivePortal/lib/db.py -/usr/local/opnsense/scripts/OPNsense/CaptivePortal/lib/ipfw.py +/usr/local/opnsense/scripts/OPNsense/CaptivePortal/lib/pf.py /usr/local/opnsense/scripts/OPNsense/CaptivePortal/listClients.py /usr/local/opnsense/scripts/OPNsense/CaptivePortal/overlay_template.py /usr/local/opnsense/scripts/OPNsense/CaptivePortal/process_accounting_messages.php diff --git a/src/etc/inc/filter.inc b/src/etc/inc/filter.inc index c684c4690..882357eee 100644 --- a/src/etc/inc/filter.inc +++ b/src/etc/inc/filter.inc @@ -355,6 +355,7 @@ function filter_configure_sync($verbose = false, $load_aliases = true) $rules .= "set skip on lo0\n"; $rules .= "set skip on pfsync0\n"; $rules .= "\n"; + $rules .= $fw->anchorToText('ether', 'head'); $rules .= filter_generate_scrubbing($cnfint); $rules .= "\n"; $rules .= $fw->anchorToText('nat,binat,rdr', 'head'); diff --git a/src/etc/inc/plugins.inc.d/captiveportal.inc b/src/etc/inc/plugins.inc.d/captiveportal.inc new file mode 100644 index 000000000..92819a696 --- /dev/null +++ b/src/etc/inc/plugins.inc.d/captiveportal.inc @@ -0,0 +1,172 @@ +isEnabled()) { + $services[] = array( + 'pidfile' => '/var/run/lighttpd-api-dispatcher.pid', + 'description' => gettext('Captive Portal'), + 'configd' => array( + 'restart' => array('captiveportal restart'), + 'start' => array('captiveportal start'), + 'stop' => array('captiveportal stop'), + ), + 'name' => 'captiveportal', + ); + } + + return $services; +} + +function captiveportal_cron() +{ + global $config; + + $jobs = []; + + if (!empty($config['system']['captiveportalbackup']) && $config['system']['captiveportalbackup'] > 0) { + $jobs[]['autocron'] = array( + '/usr/local/etc/rc.syshook.d/backup/20-captiveportal', + '0', + '*/' . $config['system']['captiveportalbackup'] + ); + } + + return $jobs; +} + +function captiveportal_syslog() +{ + $logfacilities = []; + + $logfacilities['portalauth'] = ['facility' => ['captiveportal']]; + + return $logfacilities; +} + +function captiveportal_firewall($fw) +{ + global $config; + + $cp = new \OPNsense\CaptivePortal\CaptivePortal(); + if ($cp->isEnabled()) { + foreach ($cp->zones->zone->iterateItems() as $zone) { + if ($zone->enabled->isEmpty()) { + continue; + } + + $zoneid = (string)$zone->zoneid; + $uuid = $zone->getAttribute('uuid'); + // register anchor, to be filled with ether rules for accounting + $fw->registerAnchor("captiveportal_zone_{$zoneid}", "ether", 0, "head", false, (string)$zone->interfaces); + + foreach (explode(',', $zone->interfaces) as $intf) { + // allow DNS + $fw->registerFilterRule( + 1, + [ + 'type' => 'pass', + 'interface' => $intf, + 'protocol' => 'tcp/udp', + 'direction' => 'in', + 'from' => 'any', + 'to' => '(self)', + 'to_port' => 53, + 'descr' => "Allow DNS for Captive Portal (zone {$zoneid})", + 'log' => !isset($config['syslog']['nologdefaultpass']), + '#ref' => "ui/captiveportal#edit={$uuid}", + ] + ); + + foreach (['80', '443'] as $to_port) { + $rdr_port = $to_port === '443' ? (8000 + (int)$zoneid) : (9000 + (int)$zoneid); + + // forward to localhost if not authenticated + $fw->registerForwardRule( + 2, + [ + 'interface' => $intf, + 'pass' => true, + 'nordr' => false, + 'ipprotocol' => 'inet', + 'protocol' => 'tcp', + 'from' => "<__captiveportal_zone_{$zoneid}>", + 'from_not' => true, + 'to' => 'any', + 'to_port' => $to_port, + 'target' => '127.0.0.1', + 'localport' => $rdr_port, + 'descr' => "Redirect to Captive Portal (zone {$zoneid})", + '#ref' => "ui/captiveportal#edit={$uuid}" + ] + ); + + // Allow access to the captive portal + $proto = $to_port === '443' ? 'https': 'http'; + $fw->registerFilterRule( + 2, + [ + 'type' => 'pass', + 'interface' => $intf, + 'protocol' => 'tcp', + 'direction' => 'in', + 'from' => 'any', + 'to' => '(self)', + 'to_port' => $rdr_port, + 'descr' => "Allow access to Captive Portal ({$proto}, zone {$zoneid})", + 'log' => !isset($config['syslog']['nologdefaultpass']), + '#ref' => "ui/captiveportal#edit={$uuid}", + ] + ); + } + + // block all non-authenticated users + $fw->registerFilterRule( + 3, + [ + 'type' => 'block', + 'interface' => $intf, + 'direction' => 'in', + 'from' => "<__captiveportal_zone_{$zoneid}>", + 'from_not' => true, + 'to' => 'any', + 'descr' => "Default Captive Portal block rule (zone {$zoneid})", + 'log' => !isset($config['syslog']['nologdefaultblock']), + '#ref' => "ui/captiveportal#edit={$uuid}", + ] + ); + + // we do not create a pass rule for authenticated clients here, any user-defined pass rule will + // automatically apply to the authenticated clients of this zone. + } + } + } +} diff --git a/src/etc/inc/plugins.inc.d/core.inc b/src/etc/inc/plugins.inc.d/core.inc index d2115054f..2a9907de9 100644 --- a/src/etc/inc/plugins.inc.d/core.inc +++ b/src/etc/inc/plugins.inc.d/core.inc @@ -32,33 +32,6 @@ function core_services() $services = array(); - if (isset($config['OPNsense']['captiveportal']['zones']['zone'])) { - $enabled = false; - if (!empty($config['OPNsense']['captiveportal']['zones']['zone']['enabled'])) { - // single zone and enabled - $enabled = true; - } else { - // possible more zones, traverse items - foreach ($config['OPNsense']['captiveportal']['zones']['zone'] as $zone) { - if (!empty($zone['enabled'])) { - $enabled = true; - } - } - } - if ($enabled) { - $services[] = array( - 'pidfile' => '/var/run/lighttpd-api-dispatcher.pid', - 'description' => gettext('Captive Portal'), - 'configd' => array( - 'restart' => array('captiveportal restart'), - 'start' => array('captiveportal start'), - 'stop' => array('captiveportal stop'), - ), - 'name' => 'captiveportal', - ); - } - } - $services[] = array( 'description' => gettext('System Configuration Daemon'), 'pidfile' => '/var/run/configd.pid', @@ -330,14 +303,6 @@ function core_cron() ); } - if (!empty($config['system']['captiveportalbackup']) && $config['system']['captiveportalbackup'] > 0) { - $jobs[]['autocron'] = array( - '/usr/local/etc/rc.syshook.d/backup/20-captiveportal', - '0', - '*/' . $config['system']['captiveportalbackup'] - ); - } - foreach ((new OPNsense\Backup\BackupFactory())->listProviders() as $classname => $provider) { if ($provider['handle']->isEnabled()) { $jobs[]['autocron'] = array('/usr/local/sbin/configctl -d system remote backup 3600', 0, 1); @@ -361,7 +326,6 @@ function core_syslog() $logfacilities['dhcpd'] = ['facility' => ['dhcpd']]; $logfacilities['lighttpd'] = ['facility' => ['lighttpd']]; $logfacilities['pkg'] = ['facility' => ['pkg', 'pkg-static']]; - $logfacilities['portalauth'] = ['facility' => ['captiveportal']]; $logfacilities['ppps'] = ['facility' => ['ppp']]; $logfacilities['resolver'] = ['facility' => ['unbound']]; $logfacilities['routing'] = ['facility' => ['routed', 'olsrd', 'zebra', 'ospfd', 'bgpd', 'miniupnpd']]; diff --git a/src/etc/rc.d/captiveportal b/src/etc/rc.d/captiveportal index 26b5493cc..ad87c0d73 100755 --- a/src/etc/rc.d/captiveportal +++ b/src/etc/rc.d/captiveportal @@ -25,7 +25,7 @@ # POSSIBILITY OF SUCH DAMAGE. # PROVIDE: captiveportal -# REQUIRE: ipfw +# REQUIRE: pf # KEYWORD: shutdown . /etc/rc.subr diff --git a/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/AccessController.php b/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/AccessController.php index c8716fce1..db16d10d0 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/AccessController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/AccessController.php @@ -117,7 +117,7 @@ class AccessController extends ApiControllerBase /** * logon client to zone, must use post type of request - * @param int|string $zoneid zone id number + * @param int|string $zoneid zone id number, provided for backwards compatibility * @return array * @throws \OPNsense\Base\ModelException */ @@ -131,6 +131,7 @@ class AccessController extends ApiControllerBase // init variables for authserver object and name $authServer = null; $authServerName = ""; + $zoneid = $this->request->getHeader("zoneid"); // get username from post $userName = $this->request->getPost("user", "striptags", null); @@ -223,9 +224,10 @@ class AccessController extends ApiControllerBase /** * logoff client - * @param int|string $zoneid zone id number + * @param int|string $zoneid zone id number, provided for backwards compatibility * @return array * @throws \OPNsense\Base\ModelException + * */ public function logoffAction($zoneid = 0) { @@ -233,6 +235,7 @@ class AccessController extends ApiControllerBase // return empty result on CORS preflight return []; } else { + $zoneid = $this->request->getHeader("zoneid"); $clientSession = $this->clientSession((string)$zoneid); if ( $clientSession['clientState'] == 'AUTHORIZED' && @@ -256,9 +259,10 @@ class AccessController extends ApiControllerBase /** * retrieve session info - * @param int|string $zoneid zone id number + * @param int|string $zoneid zone id number, provided for backwards compatibility * @return array * @throws \OPNsense\Base\ModelException + * */ public function statusAction($zoneid = 0) { @@ -266,8 +270,64 @@ class AccessController extends ApiControllerBase // return empty result on CORS preflight return []; } elseif ($this->request->isPost() || $this->request->isGet()) { - $clientSession = $this->clientSession((string)$zoneid); + $clientSession = $this->clientSession($this->request->getHeader("zoneid")); return $clientSession; } } + + /** + * RFC 8908: Captive Portal API status object + * + * The URI for this endpoint can be provisioned to the client + * as defined by RFC 7710. + * + * Request and response must set media type as "application/captive+json". + * + * Response contains the following fields: + * - captive: boolean: client is currently in a state of captivity. + * - user-portal-url: string: URL to login web portal (must be HTTPS). + * - seconds-remaining: number: seconds until session expires, + * only relevant if hardtimeout set. + * + * Fields not implemented here but possible in the future: + * - venue-info-url: string: Information page (must be HTTPS) + * - can-extend-session: boolean: hint that client system can access + * user-portal-url to extend session. + * - bytes-remaining: number: no. of bytes after which session expires. + * + * Response must set Cache-Control to 'private' or 'no-store' + */ + public function apiAction() + { + if ($this->request->isGet() && + $this->request->getHeader("accept") == "application/captive+json") { + $result = []; + $zoneId = $this->request->getHeader("zoneid"); + $clientSession = $this->clientSession($zoneId); + $captive = $clientSession["clientState"] != "AUTHORIZED"; + $host = $this->request->getHeader('X-Forwarded-Host'); + + $zone = (new \OPNsense\CaptivePortal\CaptivePortal())->getByZoneId($zoneId); + + if ($zone != null && !empty((string)$zone->hardtimeout) && !empty($clientSession['startTime'])) { + if ((time() - (int)$clientSession['startTime']) < (string)$zone->hardtimeout * 60) { + $result['seconds-remaining'] = (string)$zone->hardtimeout * 60 - ((time() - (int)$clientSession['startTime'])); + } + } + + $this->response->setRawHeader("Cache-Control: private"); + $this->response->setContentType("application/captive+json"); + + $result["captive"] = $captive; + $result["user-portal-url"] = "https://{$host}/index.html"; + + $this->response->setContent($result); + + return; + } + + $this->response->setStatusCode(400); + $this->response->setContentType('application/json', 'UTF-8'); + $this->response->setContent(['status' => 400, 'message' => 'Bad request']); + } } diff --git a/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/ServiceController.php b/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/ServiceController.php index 96620e416..e2ce2e2c8 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/ServiceController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/ServiceController.php @@ -48,9 +48,7 @@ class ServiceController extends ApiControllerBase { if ($this->request->isPost()) { $backend = new Backend(); - // the ipfw rules need to know about all the zones, so we need to reload ipfw for the portal to work - $backend->configdRun('template reload OPNsense/IPFW'); - $bckresult = trim($backend->configdRun("ipfw reload")); + $bckresult = trim($backend->configdRun("filter reload")); if ($bckresult == "OK") { // generate captive portal config $bckresult = trim($backend->configdRun('template reload OPNsense/Captiveportal')); diff --git a/src/opnsense/mvc/app/library/OPNsense/Firewall/Plugin.php b/src/opnsense/mvc/app/library/OPNsense/Firewall/Plugin.php index f74ffb947..4d74a6edd 100644 --- a/src/opnsense/mvc/app/library/OPNsense/Firewall/Plugin.php +++ b/src/opnsense/mvc/app/library/OPNsense/Firewall/Plugin.php @@ -214,10 +214,10 @@ class Plugin * @param bool $quick * @return null */ - public function registerAnchor($name, $type = "fw", $priority = 0, $placement = "tail", $quick = false) + public function registerAnchor($name, $type = "fw", $priority = 0, $placement = "tail", $quick = false, $ifs = null) { $anchorKey = sprintf("%s.%s.%08d.%08d", $type, $placement, $priority, count($this->anchors)); - $this->anchors[$anchorKey] = array('name' => $name, 'quick' => $quick); + $this->anchors[$anchorKey] = ['name' => $name, 'quick' => $quick, 'ifs' => $ifs]; ksort($this->anchors); } @@ -233,11 +233,21 @@ class Plugin foreach (explode(',', $types) as $type) { foreach ($this->anchors as $anchorKey => $anchor) { if (strpos($anchorKey, "{$type}.{$placement}") === 0) { - $result .= $type == "fw" ? "" : "{$type}-"; - $result .= "anchor \"{$anchor['name']}\""; + $result .= ($type == "fw" || $type == "ether") ? "" : "{$type}-"; + $prefix = $type == "ether" ? "ether " : ""; + $result .= "{$prefix}anchor \"{$anchor['name']}\""; if ($anchor['quick']) { $result .= " quick"; } + if (!empty($anchor['ifs'])) { + $ifs = array_filter(array_map(function($if) { + return $this->interfaceMapping[$if]['if'] ?? null; + }, explode(',', $anchor['ifs']))); + + if (!empty($ifs)) { + $result .= " on {" . implode(', ', $ifs) . "}"; + } + } $result .= "\n"; } } diff --git a/src/opnsense/mvc/app/models/OPNsense/Firewall/DynamicAliases/CaptivePortalAliases.php b/src/opnsense/mvc/app/models/OPNsense/Firewall/DynamicAliases/CaptivePortalAliases.php new file mode 100644 index 000000000..41eb8f01b --- /dev/null +++ b/src/opnsense/mvc/app/models/OPNsense/Firewall/DynamicAliases/CaptivePortalAliases.php @@ -0,0 +1,60 @@ +isEnabled()) { + foreach ($cp->zones->zone->iterateItems() as $zone) { + if (empty((string)$zone->enabled)) { + continue; + } + + $zoneid = (string)$zone->zoneid; + $result["__captiveportal_zone_{$zoneid}"] = [ + "enabled" => "1", + "name" => "__captiveportal_zone_{$zoneid}", + "type" => "internal", + "description" => sprintf("%s %s", (string)$zone->descr, gettext("captiveportal")), + "content" => "", + ]; + } + } + + return $result; + } +} diff --git a/src/opnsense/mvc/app/views/OPNsense/CaptivePortal/index.volt b/src/opnsense/mvc/app/views/OPNsense/CaptivePortal/index.volt index 0f4e6fcae..eda83ab17 100644 --- a/src/opnsense/mvc/app/views/OPNsense/CaptivePortal/index.volt +++ b/src/opnsense/mvc/app/views/OPNsense/CaptivePortal/index.volt @@ -37,7 +37,10 @@ set:'/api/captiveportal/settings/setZone/', add:'/api/captiveportal/settings/addZone/', del:'/api/captiveportal/settings/delZone/', - toggle:'/api/captiveportal/settings/toggleZone/' + toggle:'/api/captiveportal/settings/toggleZone/', + options: { + triggerEditFor: getUrlHash('edit') + } } ); diff --git a/src/opnsense/scripts/OPNsense/CaptivePortal/allow.py b/src/opnsense/scripts/OPNsense/CaptivePortal/allow.py index 937bb6047..6b1fcfdd4 100755 --- a/src/opnsense/scripts/OPNsense/CaptivePortal/allow.py +++ b/src/opnsense/scripts/OPNsense/CaptivePortal/allow.py @@ -33,7 +33,7 @@ import sys import ujson from lib.db import DB from lib.arp import ARP -from lib.ipfw import IPFW +from lib.pf import PF parser = argparse.ArgumentParser() parser.add_argument('-username', help='username', type=str, required=True) @@ -50,6 +50,6 @@ response = DB().add_client( ip_address=args.ip_address, mac_address=arp_entry['mac'] if arp_entry is not None else None ) -IPFW().add_to_table(table_number=args.zoneid, address=args.ip_address) +PF.add_to_table(zoneid=args.zoneid, address=args.ip_address) response['clientState'] = 'AUTHORIZED' print(ujson.dumps(response)) diff --git a/src/opnsense/scripts/OPNsense/CaptivePortal/cp-background-process.py b/src/opnsense/scripts/OPNsense/CaptivePortal/cp-background-process.py index 4d536ca1a..a5809e30f 100755 --- a/src/opnsense/scripts/OPNsense/CaptivePortal/cp-background-process.py +++ b/src/opnsense/scripts/OPNsense/CaptivePortal/cp-background-process.py @@ -38,7 +38,7 @@ sys.path.insert(0, "/usr/local/opnsense/site-python") from lib import Config from lib.db import DB from lib.arp import ARP -from lib.ipfw import IPFW +from lib.pf import PF from lib.daemonize import Daemonize from sqlite3_helper import check_and_repair @@ -50,18 +50,45 @@ class CPBackgroundProcess(object): # open syslog and notice startup syslog.openlog('captiveportal', facility=syslog.LOG_LOCAL4) syslog.syslog(syslog.LOG_NOTICE, 'starting captiveportal background process') - # handles to ipfw, arp the config and the internal administration - self.ipfw = IPFW() + # handles to pf, arp, the config and the internal administration self.arp = ARP() self.cnf = Config() self.db = DB() self._conf_zone_info = self.cnf.get_zones() + self._accounting_info = {k: {'cur': {}, 'prev': {}, 'reset': False} for k in self.list_zone_ids()} + def list_zone_ids(self): """ return zone numbers """ return self._conf_zone_info.keys() + def get_accounting(self): + """ returns only how much the accounting should add in total + """ + result = {} + for zoneid in self.list_zone_ids(): + self._accounting_info[zoneid]['prev'] = self._accounting_info[zoneid]['cur'] + self._accounting_info[zoneid]['cur'] = PF.list_accounting_info(zoneid) + + if self._accounting_info[zoneid]['reset']: + # counters were reset to 0, simply add + result[zoneid] = self._accounting_info[zoneid]['cur'] + else: + # counters still valid, calculate difference + result[zoneid] = {} + for ip in self._accounting_info[zoneid]['cur']: + result[zoneid][ip] = {'last_accessed': self._accounting_info[zoneid]['cur'][ip]['last_accessed']} + for key in ['in_pkts', 'in_bytes', 'out_pkts', 'out_bytes']: + if ip not in self._accounting_info[zoneid]['prev']: + result[zoneid][ip][key] = self._accounting_info[zoneid]['cur'][ip][key] + else: + result[zoneid][ip][key] = self._accounting_info[zoneid]['cur'][ip][key] \ + - self._accounting_info[zoneid]['prev'][ip][key] + + # map to flat dict of IPs + return {k: v for subdict in result.values() for k, v in subdict.items()} + def initialize_fixed(self): """ initialize fixed ip / hosts per zone """ @@ -94,12 +121,12 @@ class CPBackgroundProcess(object): for dbclient in self.db.list_clients(zoneid): if dbclient['authenticated_via'] == '---ip---' \ and dbclient['ipAddress'] not in cpzones[zoneid]['allowedaddresses']: - self.ipfw.delete(zoneid, dbclient['ipAddress']) + PF.remove_from_table(zoneid, dbclient['ipAddress']) self.db.del_client(zoneid, dbclient['sessionId']) elif dbclient['authenticated_via'] == '---mac---' \ and dbclient['macAddress'] not in cpzones[zoneid]['allowedmacaddresses']: if dbclient['ipAddress'] != '': - self.ipfw.delete(zoneid, dbclient['ipAddress']) + PF.remove_from_table(zoneid, dbclient['ipAddress']) self.db.del_client(zoneid, dbclient['sessionId']) def sync_zone(self, zoneid): @@ -109,8 +136,7 @@ class CPBackgroundProcess(object): if zoneid in self._conf_zone_info: # fetch data for this zone cpzone_info = self._conf_zone_info[zoneid] - registered_addresses = self.ipfw.list_table(zoneid) - registered_addr_accounting = self.ipfw.list_accounting_info() + registered_addresses = PF.list_table(zoneid) expected_clients = self.db.list_clients(zoneid) concurrent_users = self.db.find_concurrent_user_sessions(zoneid) @@ -160,23 +186,22 @@ class CPBackgroundProcess(object): if current_ip is not None and db_client['ipAddress'] != current_ip: if db_client['ipAddress'] != '': # remove old ip - self.ipfw.delete(zoneid, db_client['ipAddress']) + PF.remove_from_table(zoneid, db_client['ipAddress']) self.db.update_client_ip(zoneid, db_client['sessionId'], current_ip) - self.ipfw.add_to_table(zoneid, current_ip) + PF.add_to_table(zoneid, current_ip) # check session, if it should be active, validate its properties if drop_session_reason is None: - # registered client, but not active or missing accounting according to ipfw (after reboot) - if cpnet not in registered_addresses or cpnet not in registered_addr_accounting: - self.ipfw.add_to_table(zoneid, cpnet) + # registered client, but not active or missing accounting according to pf (after reboot) + if cpnet not in registered_addresses: + PF.add_to_table(zoneid, cpnet) else: # remove session - syslog.syslog(syslog.LOG_NOTICE, drop_session_reason) - self.ipfw.delete(zoneid, cpnet) + PF.remove_from_table(zoneid, cpnet) self.db.del_client(zoneid, db_client['sessionId']) - # if there are addresses/networks in the underlying ipfw table which are not in our administration, - # remove them from ipfw. + # 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: @@ -184,12 +209,24 @@ class CPBackgroundProcess(object): address_active = True break if not address_active: - self.ipfw.delete(zoneid, registered_address) + PF.remove_from_table(zoneid, registered_address) + def sync_accounting(self): + for zoneid in self.list_zone_ids(): + pf_stats = self._accounting_info[zoneid]['cur'] + current_clients = self.db.list_clients(zoneid) + + pf_ips = set(pf_stats.keys()) + db_ips = {entry['ipAddress'] for entry in current_clients} + + self._accounting_info[zoneid]['reset'] = False + if pf_ips != db_ips: + self._accounting_info[zoneid]['reset'] = True + PF.sync_accounting(zoneid) def main(): """ Background process loop, runs as backend daemon for all zones. only one should be active at all times. - The main job of this procedure is to sync the administration with the actual situation in the ipfw firewall. + The main job of this procedure is to sync the administration with the actual situation in pf. """ # perform integrity check and repair database if needed check_and_repair('/var/captiveportal/captiveportal.sqlite') @@ -211,13 +248,16 @@ def main(): # reload cached arp table contents bgprocess.arp.reload() - # update accounting info, for all zones - bgprocess.db.update_accounting_info(bgprocess.ipfw.list_accounting_info()) - # process sessions per zone for zoneid in bgprocess.list_zone_ids(): bgprocess.sync_zone(zoneid) + # update accounting info, for all zones + bgprocess.db.update_accounting_info(bgprocess.get_accounting()) + + # sync accounting after db update, resets zone counters if sync is needed + bgprocess.sync_accounting() + # close the database handle while waiting for the next poll bgprocess.db.close() diff --git a/src/opnsense/scripts/OPNsense/CaptivePortal/disconnect.py b/src/opnsense/scripts/OPNsense/CaptivePortal/disconnect.py index 6721d5cf8..08a7cfd49 100755 --- a/src/opnsense/scripts/OPNsense/CaptivePortal/disconnect.py +++ b/src/opnsense/scripts/OPNsense/CaptivePortal/disconnect.py @@ -31,8 +31,7 @@ import argparse import ujson from lib.db import DB -from lib.ipfw import IPFW - +from lib.pf import PF parser = argparse.ArgumentParser() parser.add_argument('session', help='session id to delete', type=str) @@ -43,7 +42,7 @@ response = {'terminateCause': 'UNKNOWN'} client_session_info = DB().del_client(int(args.z) if str(args.z).isdigit() else None, args.session) if client_session_info is not None: if client_session_info['ip_address']: - IPFW().delete(client_session_info['zoneid'], client_session_info['ip_address']) + PF.remove_from_table(client_session_info['zoneid'], client_session_info['ip_address']) client_session_info['terminateCause'] = 'User-Request' response = client_session_info diff --git a/src/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/images/default-logo.png b/src/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/images/default-logo.png deleted file mode 100644 index cd389fc7d31e6209e49247827628138bec242cbe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2070 zcmV+x25(x8Z@+ZJO|)0&C*mIR}b!Gr`FliTf0atlrA zb?+@i8uZe|Dz$29mnPVxt(7sp`8E&!1M}1uL0G{TL4=>*-(e3NhS_uX+>_HZ=bj6n z+|yi;Y+Kc7JbcETKJpy@T!`(tIL$nm=do;0*2a-f;%MQ2X`)Lgjm ztaP??yazWaXrq?4o_;#dfre>D&;rU0FhBtX`3CEyE%gi(8<2wnnpR|t@xW)!oOYA` z%c2b{Z9xvyrupJ|3^r&x0Y65)FsSL6^+Yn&!FubhKl-0vyvWCo9jkyQ>D0-S_R^&n z8Exi35&aNw!$CO$4-6I1fD#yLs76o&N9KffgY{hhx~U(r-hJmC|L6DK<7-#1)^A_C zR-8I@D&4ntuhUxlxq}DCA~mEIZfz;rDbNN>Tk;twMmlkfFTV(b6$GIG>PUf)1S^RL zu9Ixj1Ph&=L4{DsNW!nZ_J1zT{BoUO5ruf0Zw;t};e1i*RU+I+^_LLe8C^ zo71?~$u0J`ul$Ck=9SBrt+W9&#H52&%!2g+2dmFHmtl0NBwTB2zhvo@b7#+Lpe``l z>E#O-?AqF;UZ~!9~>tvuBT!+n9_x2n?S* zal-xZ$dLr7MT!Z!lXGS10?QaI^Q#@7{et=YeP7c1zq>ETh?W2iGYi%&OFZ4nji1_x8+{wFk?UGQ*ZIOR`^r!)9Ver()2p1!yyyLD<^If02pZD*1 zkiUOme}Ar~r!rWxU?osM4hEDQ6w;~Lf%(Jk{7>fm`-?ky;hT!@+;W>IB8z}IgvBE? z8Bh#@l|B5>L;h2rx!Y||?z4Np{fK|{_yhcp^Pl7=k9^PXJ23CiMmm+jngvUPff6(n z;R*D&6Q4-_bABuHv)lMzXO(|{VVl7SrhvNm*`qrY!er#y|DC&o|Gl`i{`-?V6QC|; z!MY_G(2#?MlF;!=AOwn+N%0b?e=cm(IOc&H?!QZ0HPAY=oI7|O0;Z*=)Rs1y_NdJ) zSht?%iWky>P!*6+69gaVALD=qYN3GxzNNkaiukauW0^??3xDqf3a|qTNPq$cDzJ?b zQlN%{Vn7ZllwhHPv_s7dD3A@*5fx~l#g#SHyy{Xwax!<<9tbeN9ThhzSPqJgD&R&o zsbDpoTmwhckPQUJcIcY~cpWt;pml2IK#d%@MFljd2}G0&F1BIP18L+M2Rc$ay&1vU zpbF?l2Wx5NMFk`yK-ft^&|J$uTpPKoBrU|Ba-Qo@(?-6f&3c?Y?LwKx?91}?-Yz7o? zZg#;+( z2Zs+QU;fHhbNX1Ku{2>`Ire~)WechG+|fsJsr?~XAkBeBH;G{VT0tyCDHy04B@nm{ zAt+jcr8)-7Knm4>1d1?N@lh-uEd98iV{=IJOUJ*(_-}r2-yG|MIA)A2d=6b8HTc2m zJ7X=2vC__W-~Tn<|LAuloV}V-8#S|AXdNqV^0&eoOyub~HqG#31}g&y;LV0jTa zreeX$nyoJG8+#5~1i{OS`!h>Ry&VepEWyfvVh}3bKRjV&$?!Q!$k z`GwV0k3H%dw?PHeg)v59)Y5nn?NOt@ZN^G%pml7@V2P&lHTY;S+Mm{{0eS-?mI8vb zCEObFmarF4HHt7;vcXRFNjY?^Y;);tm%#|ca{I#M-Qu?!Vb`c@uJQ8XT53s6?8joX zV<7`-v1x*JZsivW`g1Jbs9M&m(w1Y{7`H+VS+FRcL_(Ce1WQ33T^j>Jm@KvgbF)2J zMwueAdvs)SEw;+Y=eop?)NZyVGukvAM=YAMU}gPa$x1bZ%K&xDttW*l9;|UD?-166 zNU%)Hh*h^f75?@5b$|KIH+%D;KfV2~0z#+^0&zp2mXLOow1Mz2J{LeS!R;a+K>>9T zpqm}63^Zh5zyX7Ao(f|j$|JWu6&W?kNLaEFgOzlssUu-?P(-e!KEfQz%TfZi6IVc! zgtab<3I2NPNg8+z9+IG@yGg zDqxulNx(4SmVZ@~QlO0*J4=VIhc%t$HmHn*N)+J00F6n30}mx7_sX9|N4&K6V + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/images/favicon.png b/src/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/images/favicon.png index 3e6dde97264d7a9305e8efb15b48002d2bb63484..9a8472bc4faa57ecc19539c58f42b5fc61099cb1 100644 GIT binary patch literal 2100 zcma)7c~BEq99|9uQIJ7Iv}m_WMGKQ`5(Lef7()Vta)=lt9HWF}lk7mUVY4v-k4meE zg13xXDLU9Xr~?-2eWEg6R76J|K`mMxKnsXei>X@sl5i>2=^vZyd*AoFk6j%f7wPUc z!3}~S_b7=t0sM2UkLz&oJ4sMb1wq5c5>gp0i;ck)q?RdHk{LMDtknY?f_MV6UarW* zX*dH{6FNTf?8F%aCX{?6DI}H^s~6!KLXu12^K#>)irh>ESBVH_x$(>xP@u(WIc(Nu z=?s{ekJ$8L;N5zSBCw4@XYvtQY&&kEH{`(DOFg4IHFe=ocM@_ru7(# znoK69DVRx8YLw08a#0osop`z8Z_ibMEc<&g1DI&h~YgmR%8gBCR87x zTah7jkn7mkPe-BEz88pr4qpTk(VNuw9O{ePAkER^Xaa5^jg$fpUxe#ue_JkqHB3a| zavB$dhmUYroM{X;hrwb?*)uQ>2jj4VSZo%npBzgn302O2l7lf$C?I2l*`Pg?a$5d> z`27jTtZchepfx|~(SVGI%pxh^9Ec&<=Ij6zB@)F`q>9J_m?0rD42}vHg|fM!p$ra_ zZR1+&hQ$+RJWD1fw7AXydW?^R*rOf*lRpC5Y|jIu1q|2~S}Ti1Y2|7>iBQs-eq96i zF}P0sAz-afmeJbai8=yQlw)(wCLbf;lh;41UP_QYH-!G=5OXLEVD(dWTxay3aixIwSy|jWB2r06F^|*e_sFq5Yc* z9Qt=1t^-{}flU^EvAG0-9P$Xz4@3F@f~IaXZmf$A6MC-_9BRX*-q*`kt~hjMs?*-s z4PHKyg7Gh!+O9@EctjoWx)G`g@)m1z8Bfu2Z3E`I1}V5*S=c1KSAVOffOo#c(lWi( zPgTD>q$J@+?tRve!Cc1U9?ZvQtDpPic3Io59eWDO_HRC$zWn>YmZ4P{N0`6N?>PER z1s<{|D4p;wjh8Z}@ECrwYkLEpxthMZd}&|T_O4w4`Y+4gtMr=LEbYx?Q<{gE%P7@e&<#Cwx9rQjb9a@)YBJR_+;OM+=>3zO&%h^XgkF+tfFO@r z>*G)y>)joO~->9Rb} zRVdjZ2+*Es?w%Rp(e2Nz&d#r@3{0xoYFsjVW_s$)4)vuv%el=td)yo7M2k;%wU={g zO}P;E-0hkN6$Lu0GPZoda@y_T;D5o%d6X~exvfU|cAfUguvfCIl*0=mOV^e{)s=yB zE_}}SHfDyS2}#9^i+txq|8aN~-ZIwFsYva5Fa4xw*Tq`M;ZCh%_Pe!d(VmCA#1+nS zgM>G~?t2Zq)Olt~m6-eUWXG7_7E;G9K8D!om7kw5#e^)W8aoN&{o>NYd zbu})oF&Y0<8|NNbTvu~_Y3(D<>qVj$`Ic;T&x-B5GV|GO^(m8BEu!6+>h_bQ@xiZs h53CvSx1g+m>lDw(Jl!a)_|E#Vj|z_ySB9m3`!C-M>Ky<8 literal 2938 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 diff --git a/src/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/index.html b/src/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/index.html index 663a05c1a..6ed74621f 100644 --- a/src/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/index.html +++ b/src/opnsense/scripts/OPNsense/CaptivePortal/htdocs_default/index.html @@ -18,9 +18,6 @@ - - -