{"sha":"c102de2936d92a7bc7647978411f11f3004bb935","node_id":"C_kwDOAaq2ztoAKGMxMDJkZTI5MzZkOTJhN2JjNzY0Nzk3ODQxMWYxMWYzMDA0YmI5MzU","commit":{"author":{"name":"Franco Fichtner","email":"franco@opnsense.org","date":"2026-03-24T11:23:24Z"},"committer":{"name":"Franco Fichtner","email":"franco@opnsense.org","date":"2026-03-24T11:23:24Z"},"message":"Revert \"Captive portal: IPv6 support (#9745)\"\n\nThis reverts commit 497ed54fe18c26e6005665ddc2887819dba87f80.\n\nRevert for the time being since 26.1.5 doesn't force a reboot.","tree":{"sha":"2b6514771cfdd592293ce14b3666d9d2b4f7166a","url":"https://api.github.com/repos/opnsense/core/git/trees/2b6514771cfdd592293ce14b3666d9d2b4f7166a"},"url":"https://api.github.com/repos/opnsense/core/git/commits/c102de2936d92a7bc7647978411f11f3004bb935","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null,"verified_at":null}},"url":"https://api.github.com/repos/opnsense/core/commits/c102de2936d92a7bc7647978411f11f3004bb935","html_url":"https://github.com/opnsense/core/commit/c102de2936d92a7bc7647978411f11f3004bb935","comments_url":"https://api.github.com/repos/opnsense/core/commits/c102de2936d92a7bc7647978411f11f3004bb935/comments","author":{"login":"fichtner","id":1915288,"node_id":"MDQ6VXNlcjE5MTUyODg=","avatar_url":"https://avatars.githubusercontent.com/u/1915288?v=4","gravatar_id":"","url":"https://api.github.com/users/fichtner","html_url":"https://github.com/fichtner","followers_url":"https://api.github.com/users/fichtner/followers","following_url":"https://api.github.com/users/fichtner/following{/other_user}","gists_url":"https://api.github.com/users/fichtner/gists{/gist_id}","starred_url":"https://api.github.com/users/fichtner/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/fichtner/subscriptions","organizations_url":"https://api.github.com/users/fichtner/orgs","repos_url":"https://api.github.com/users/fichtner/repos","events_url":"https://api.github.com/users/fichtner/events{/privacy}","received_events_url":"https://api.github.com/users/fichtner/received_events","type":"User","user_view_type":"public","site_admin":false},"committer":{"login":"fichtner","id":1915288,"node_id":"MDQ6VXNlcjE5MTUyODg=","avatar_url":"https://avatars.githubusercontent.com/u/1915288?v=4","gravatar_id":"","url":"https://api.github.com/users/fichtner","html_url":"https://github.com/fichtner","followers_url":"https://api.github.com/users/fichtner/followers","following_url":"https://api.github.com/users/fichtner/following{/other_user}","gists_url":"https://api.github.com/users/fichtner/gists{/gist_id}","starred_url":"https://api.github.com/users/fichtner/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/fichtner/subscriptions","organizations_url":"https://api.github.com/users/fichtner/orgs","repos_url":"https://api.github.com/users/fichtner/repos","events_url":"https://api.github.com/users/fichtner/events{/privacy}","received_events_url":"https://api.github.com/users/fichtner/received_events","type":"User","user_view_type":"public","site_admin":false},"parents":[{"sha":"533ba0ce55570ba23ea1775174503333bf96d77c","url":"https://api.github.com/repos/opnsense/core/commits/533ba0ce55570ba23ea1775174503333bf96d77c","html_url":"https://github.com/opnsense/core/commit/533ba0ce55570ba23ea1775174503333bf96d77c"}],"stats":{"total":1016,"additions":290,"deletions":726},"files":[{"sha":"67b8912cb4af2ee82eda5c4a007f269fdbbfbb7e","filename":"plist","status":"modified","additions":0,"deletions":1,"changes":1,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/plist","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/plist","contents_url":"https://api.github.com/repos/opnsense/core/contents/plist?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -574,7 +574,6 @@\n /usr/local/opnsense/mvc/app/library/OPNsense/OpenVPN/PlainOpenVPN.php\n /usr/local/opnsense/mvc/app/library/OPNsense/OpenVPN/ViscosityVisz.php\n /usr/local/opnsense/mvc/app/library/OPNsense/System/AbstractStatus.php\n-/usr/local/opnsense/mvc/app/library/OPNsense/System/Status/CaptivePortalStatus.php\n /usr/local/opnsense/mvc/app/library/OPNsense/System/Status/ConfigdProxyOverrideStatus.php\n /usr/local/opnsense/mvc/app/library/OPNsense/System/Status/CrashReporterStatus.php\n /usr/local/opnsense/mvc/app/library/OPNsense/System/Status/DiskSpaceStatus.php"},{"sha":"0d24f4496ec0f543b8f9adec6aff9b96571f9414","filename":"src/etc/inc/plugins.inc.d/captiveportal.inc","status":"modified","additions":4,"deletions":24,"changes":28,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fetc%2Finc%2Fplugins.inc.d%2Fcaptiveportal.inc","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fetc%2Finc%2Fplugins.inc.d%2Fcaptiveportal.inc","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fetc%2Finc%2Fplugins.inc.d%2Fcaptiveportal.inc?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -91,6 +91,7 @@ function captiveportal_firewall($fw)\n             }\n \n             foreach ($zone->interfaces->getValues() as $intf) {\n+                // allow DNS\n                 $fw->registerFilterRule(\n                     1,\n                     [\n@@ -110,7 +111,7 @@ function captiveportal_firewall($fw)\n                 foreach (['80', '443'] as $to_port) {\n                     $rdr_port = $to_port === '443' ? (8000 + (int)$zoneid) : (9000 + (int)$zoneid);\n \n-                    // forward to localhost if not authenticated (IPv4)\n+                    // forward to localhost if not authenticated\n                     $fw->registerForwardRule(\n                         2,\n                         [\n@@ -132,29 +133,7 @@ function captiveportal_firewall($fw)\n                         ]\n                     );\n \n-                    // forward to localhost if not authenticated (IPv6)\n-                    $fw->registerForwardRule(\n-                        2,\n-                        [\n-                            'interface' => $intf,\n-                            'pass' => true,\n-                            'nordr' => false,\n-                            'ipprotocol' => 'inet6',\n-                            'protocol' => 'tcp',\n-                            'from' => \"<__captiveportal_zone_{$zoneid}>\",\n-                            'from_not' => true,\n-                            'to' => \"<__captiveportal_zone_{$zoneid}>\",\n-                            'to_not' => true,\n-                            'to_port' => $to_port,\n-                            'target' => $intf . 'ip',\n-                            'localport' => $rdr_port,\n-                            'natreflection' => 'disable',\n-                            'log' => true,\n-                            'descr' => \"Redirect to Captive Portal (zone {$zoneid})\",\n-                            '#ref' => \"ui/captiveportal#edit={$uuid}\"\n-                        ]\n-                    );\n-\n+                    // Allow access to the captive portal\n                     $proto = $to_port === '443' ? 'https' : 'http';\n                     $fw->registerFilterRule(\n                         2,\n@@ -173,6 +152,7 @@ function captiveportal_firewall($fw)\n                     );\n                 }\n \n+                // block all non-authenticated users\n                 $fw->registerFilterRule(\n                     3,\n                     ["},{"sha":"29338d5f92413b081715286eb1f5e4af8b9dc459","filename":"src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/AccessController.php","status":"modified","additions":8,"deletions":11,"changes":19,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fmvc%2Fapp%2Fcontrollers%2FOPNsense%2FCaptivePortal%2FApi%2FAccessController.php","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fmvc%2Fapp%2Fcontrollers%2FOPNsense%2FCaptivePortal%2FApi%2FAccessController.php","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fmvc%2Fapp%2Fcontrollers%2FOPNsense%2FCaptivePortal%2FApi%2FAccessController.php?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -50,13 +50,12 @@ class AccessController extends ApiControllerBase\n     protected function clientSession(string $zoneid)\n     {\n         $backend = new Backend();\n-        $allClients = json_decode($backend->configdpRun(\"captiveportal list_clients\", [$zoneid]), true);\n-        $clientIp = $this->getClientIp();\n-\n+        $allClientsRaw = $backend->configdpRun(\"captiveportal list_clients\", [$zoneid]);\n+        $allClients = json_decode($allClientsRaw, true);\n         if ($allClients != null) {\n             // search for client by ip address\n             foreach ($allClients as $connectedClient) {\n-                if (in_array($clientIp, $connectedClient['ipAddresses'])) {\n+                if ($connectedClient['ipAddress'] == $this->getClientIp()) {\n                     // client is authorized in this zone according to our administration\n                     $connectedClient['clientState'] = 'AUTHORIZED';\n                     return $connectedClient;\n@@ -65,7 +64,7 @@ protected function clientSession(string $zoneid)\n         }\n \n         // return Unauthorized including authentication requirements\n-        $result = ['clientState' => \"NOT_AUTHORIZED\", \"ipAddress\" => $clientIp];\n+        $result = ['clientState' => \"NOT_AUTHORIZED\", \"ipAddress\" => $this->getClientIp()];\n         $mdlCP = new CaptivePortal();\n         $cpZone = $mdlCP->getByZoneID($zoneid);\n         if ($cpZone != null && (string)$cpZone->extendedPreAuthData == '1') {\n@@ -104,15 +103,14 @@ protected function getClientIp()\n     protected function getClientMac($ip)\n     {\n         if (empty($this->arp)) {\n+            /* currently this only matches ipv4 properly, for ipv6 we need to unpack both rows and offered parameter */\n             $data = json_decode((new Backend())->configdRun('hostwatch dump'), true) ?? [];\n             if (!empty($data['rows'])) {\n                 foreach ($data['rows'] as $row) {\n-                    // remove scope from IPv6 address if present (e.g., fe80::1%em0 -> fe80::1)\n-                    $this->arp[$row[2]] = explode('%', $row[1])[0];\n+                    $this->arp[$row[2]] = $row[1];\n                 }\n             }\n         }\n-\n         return $this->arp[$ip] ?? null;\n     }\n \n@@ -308,11 +306,10 @@ public function logonAction($zoneid = 0)\n                             if (array_key_exists('session_timeout', $authProps) || $cpZone->alwaysSendAccountingReqs == '1') {\n                                 $backend->configdpRun(\n                                     \"captiveportal set session_restrictions\",\n-                                    [\n-                                        (string)$cpZone->zoneid,\n+                                    array((string)$cpZone->zoneid,\n                                         $CPsession['sessionId'],\n                                         $authProps['session_timeout'] ?? null,\n-                                    ]\n+                                        )\n                                 );\n                             }\n                         }"},{"sha":"c2d8c680728c42edcd4da510237a5b2ceddeacb0","filename":"src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/forms/dialogZone.xml","status":"modified","additions":0,"deletions":10,"changes":10,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fmvc%2Fapp%2Fcontrollers%2FOPNsense%2FCaptivePortal%2Fforms%2FdialogZone.xml","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fmvc%2Fapp%2Fcontrollers%2FOPNsense%2FCaptivePortal%2Fforms%2FdialogZone.xml","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fmvc%2Fapp%2Fcontrollers%2FOPNsense%2FCaptivePortal%2Fforms%2FdialogZone.xml?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -36,16 +36,6 @@\n             <formatter>boolean</formatter>\n         </grid_view>\n     </field>\n-    <field>\n-        <id>zone.roaming</id>\n-        <type>checkbox</type>\n-        <label>Client roaming</label>\n-        <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>\n-        <grid_view>\n-            <type>boolean</type>\n-            <formatter>boolean</formatter>\n-        </grid_view>\n-    </field>\n     <field>\n         <id>zone.authservers</id>\n         <label>Authenticate using</label>"},{"sha":"4620d50e6d0d937bf3d662561f123925f5be604f","filename":"src/opnsense/mvc/app/library/OPNsense/System/Status/CaptivePortalStatus.php","status":"removed","additions":0,"deletions":56,"changes":56,"blob_url":"https://github.com/opnsense/core/blob/533ba0ce55570ba23ea1775174503333bf96d77c/src%2Fopnsense%2Fmvc%2Fapp%2Flibrary%2FOPNsense%2FSystem%2FStatus%2FCaptivePortalStatus.php","raw_url":"https://github.com/opnsense/core/raw/533ba0ce55570ba23ea1775174503333bf96d77c/src%2Fopnsense%2Fmvc%2Fapp%2Flibrary%2FOPNsense%2FSystem%2FStatus%2FCaptivePortalStatus.php","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fmvc%2Fapp%2Flibrary%2FOPNsense%2FSystem%2FStatus%2FCaptivePortalStatus.php?ref=533ba0ce55570ba23ea1775174503333bf96d77c","patch":"@@ -1,56 +0,0 @@\n-<?php\n-\n-/*\n- * Copyright (C) 2026 Deciso B.V.\n- * All rights reserved.\n- *\n- * Redistribution and use in source and binary forms, with or without\n- * modification, are permitted provided that the following conditions are met:\n- *\n- * 1. Redistributions of source code must retain the above copyright notice,\n- *    this list of conditions and the following disclaimer.\n- *\n- * 2. Redistributions in binary form must reproduce the above copyright\n- *    notice, this list of conditions and the following disclaimer in the\n- *    documentation and/or other materials provided with the distribution.\n- *\n- * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,\n- * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY\n- * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE\n- * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,\n- * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n- * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n- * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n- * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n- * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n- * POSSIBILITY OF SUCH DAMAGE.\n- */\n-\n-namespace OPNsense\\System\\Status;\n-\n-use OPNsense\\System\\AbstractStatus;\n-use OPNsense\\System\\SystemStatusCode;\n-use OPNsense\\Hostdiscovery\\Hostwatch;\n-\n-class CaptivePortalStatus extends AbstractStatus\n-{\n-    public function __construct()\n-    {\n-        $this->internalPriority = 2;\n-        $this->internalPersistent = true;\n-        $this->internalTitle = gettext('Captive Portal IPv6 support');\n-        $this->internalIsBanner = true;\n-        $this->internalScope[] = '/ui/captiveportal*';\n-    }\n-\n-    public function collectStatus()\n-    {\n-        if ((new Hostwatch())->general->enabled->isEmpty()) {\n-            $this->internalMessage = gettext(\n-                'The host discovery service is disabled, which is required for Captive Portal IPv6 compatibility. ' .\n-                'You can enable it under Interfaces -> Neighbors -> Automatic Discovery.'\n-            );\n-            $this->internalStatus = SystemStatusCode::WARNING;\n-        }\n-    }\n-}"},{"sha":"0b5bb0d9b4eaf0674b3ab994e933fd8a850b6118","filename":"src/opnsense/mvc/app/models/OPNsense/CaptivePortal/CaptivePortal.xml","status":"modified","additions":1,"deletions":5,"changes":6,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fmvc%2Fapp%2Fmodels%2FOPNsense%2FCaptivePortal%2FCaptivePortal.xml","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fmvc%2Fapp%2Fmodels%2FOPNsense%2FCaptivePortal%2FCaptivePortal.xml","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fmvc%2Fapp%2Fmodels%2FOPNsense%2FCaptivePortal%2FCaptivePortal.xml?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -1,6 +1,6 @@\n <model>\n     <mount>//OPNsense/captiveportal</mount>\n-    <version>1.0.5</version>\n+    <version>1.0.4</version>\n     <description>Captive portal application model</description>\n     <items>\n         <zones>\n@@ -33,10 +33,6 @@\n                     <Multiple>Y</Multiple>\n                     <Service>CaptivePortal</Service>\n                 </authservers>\n-                <roaming type=\"BooleanField\">\n-                    <Default>1</Default>\n-                    <Required>Y</Required>\n-                </roaming>\n                 <alwaysSendAccountingReqs type=\"BooleanField\">\n                     <Default>0</Default>\n                     <Required>Y</Required>"},{"sha":"c0902233df3f3b34c8af53b7382d7e87935583f7","filename":"src/opnsense/mvc/app/views/OPNsense/CaptivePortal/clients.volt","status":"modified","additions":8,"deletions":42,"changes":50,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fmvc%2Fapp%2Fviews%2FOPNsense%2FCaptivePortal%2Fclients.volt","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fmvc%2Fapp%2Fviews%2FOPNsense%2FCaptivePortal%2Fclients.volt","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fmvc%2Fapp%2Fviews%2FOPNsense%2FCaptivePortal%2Fclients.volt?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -72,29 +72,6 @@\n                 requestHandler: function(request) {\n                     request['selected_zones'] = $(\"#zone-selection\").val();\n                     return request;\n-                },\n-                formatters: {\n-                    ipAddress: function(column, row) {\n-                        const ips = row.ipAddresses || [];\n-\n-                        if (!ips.length) {\n-                            return $('<span>', { class: 'text-muted', text: '-' })[0].outerHTML;\n-                        }\n-\n-                        return $('<span>', {\n-                            'data-toggle': 'tooltip',\n-                            'data-placement': 'top',\n-                            title: ips.join('\\n')\n-                        }).append(ips.map(ip => $('<div>').text(ip).html()).join('<br>'))[0].outerHTML;\n-                    },\n-                    userName: function(column, row) {\n-                        // Extract IP from username@ip format and show just username\n-                        let userName = row.userName || '';\n-                        if (userName && userName.indexOf('@') >= 0) {\n-                            return userName.split('@')[0] || userName;\n-                        }\n-                        return userName;\n-                    }\n                 }\n             }\n         });\n@@ -103,17 +80,6 @@\n     });\n </script>\n \n-<style>\n-    [data-column-id=\"ipAddress\"] {\n-        white-space: normal !important;\n-        word-break: break-all;\n-        line-height: 1.5;\n-        max-height: 80px;\n-        overflow: hidden;\n-        text-overflow: ellipsis;\n-    }\n-</style>\n-\n <ul class=\"nav nav-tabs\" data-tabs=\"tabs\" id=\"maintabs\"></ul>\n <div class=\"tab-content content-box col-xs-12 __mb\">\n     <div class=\"btn-group\" id=\"zone-selection-wrapper\">\n@@ -124,15 +90,15 @@\n         <thead>\n             <tr>\n                 <th data-column-id=\"sessionId\" data-type=\"string\" data-identifier=\"true\" data-visible=\"false\">{{ lang._('Session') }}</th>\n-                <th data-column-id=\"zoneid\" data-type=\"string\" data-visible=\"false\">{{ lang._('Zoneid') }}</th>\n-                <th data-column-id=\"userName\" data-type=\"string\" data-formatter=\"userName\">{{ lang._('Username') }}</th>\n-                <th data-column-id=\"macAddress\" data-type=\"string\">{{ lang._('MAC address') }}</th>\n-                <th data-column-id=\"ipAddress\" data-type=\"string\" data-formatter=\"ipAddress\">{{ lang._('IP Address') }}</th>\n-                <th data-column-id=\"bytes_in\" data-type=\"string\" data-formatter=\"bytes\">{{ lang._('Bytes (in)') }}</th>\n-                <th data-column-id=\"bytes_out\" data-type=\"string\" data-formatter=\"bytes\">{{ lang._('Bytes (out)') }}</th>\n+                <th data-column-id=\"zoneid\" data-width=\"7em\"  data-type=\"string\" data-visible=\"false\">{{ lang._('Zoneid') }}</th>\n+                <th data-column-id=\"userName\" data-type=\"string\">{{ lang._('Username') }}</th>\n+                <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>\n+                <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>\n+                <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>\n+                <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>\n                 <th data-column-id=\"startTime\" data-type=\"datetime\">{{ lang._('Connected since') }}</th>\n-                <th data-column-id=\"last_accessed\" data-type=\"datetime\">{{ lang._('Last accessed') }}</th>\n-                <th data-column-id=\"commands\" data-searchable=\"false\" data-formatter=\"commands\" data-sortable=\"false\">{{ lang._('Commands') }}</th>\n+                <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>\n+                <th data-column-id=\"commands\" data-searchable=\"false\" data-width=\"7em\" data-formatter=\"commands\" data-sortable=\"false\">{{ lang._('Commands') }}</th>\n             </tr>\n         </thead>\n         <tbody>"},{"sha":"add1492dad12c7bfe9a8f504ab044ccc1748b26f","filename":"src/opnsense/scripts/captiveportal/allow.py","status":"modified","additions":1,"deletions":4,"changes":5,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Fallow.py","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Fallow.py","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Fallow.py?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -1,7 +1,7 @@\n #!/usr/local/bin/python3\n \n \"\"\"\n-    Copyright (c) 2015-2025 Ad Schellevis <ad@opnsense.org>\n+    Copyright (c) 2015-2024 Ad Schellevis <ad@opnsense.org>\n     All rights reserved.\n \n     Redistribution and use in source and binary forms, with or without\n@@ -43,7 +43,6 @@\n parser.add_argument('--ip_address', help='source ip address', type=str)\n args = parser.parse_args()\n \n-\n arp_entry = ARP().get_by_ipaddress(args.ip_address)\n response = DB().add_client(\n     zoneid=args.zoneid,\n@@ -53,7 +52,5 @@\n     mac_address=arp_entry['mac'] if arp_entry is not None else None\n )\n PF.add_to_table(zoneid=args.zoneid, address=args.ip_address)\n-IPFW.add_accounting(args.ip_address)\n-\n response['clientState'] = 'AUTHORIZED'\n print(ujson.dumps(response))"},{"sha":"f55bf58896bada202fd5a8a956235f43cb962dce","filename":"src/opnsense/scripts/captiveportal/cp-background-process.py","status":"modified","additions":32,"deletions":42,"changes":74,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Fcp-background-process.py","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Fcp-background-process.py","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Fcp-background-process.py?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -56,7 +56,6 @@ def __init__(self):\n         self.arp = ARP()\n         self.cnf = Config()\n         self.db = DB()\n-        self.db.create()\n         self._conf_zone_info = self.cnf.get_zones()\n \n     def list_zone_ids(self):\n@@ -119,16 +118,14 @@ def sync_zone(self, zoneid, registered_addr_accounting):\n         if zoneid in self._conf_zone_info:\n             # fetch data for this zone\n             cpzone_info = self._conf_zone_info[zoneid]\n-            registered_addresses_pf = set(PF.list_table(zoneid))\n-            registered_addresses_ipfw = set(registered_addr_accounting.keys())\n+            registered_addresses = list(PF.list_table(zoneid))\n             expected_clients = self.db.list_clients(zoneid)\n             concurrent_users = self.db.find_concurrent_user_sessions(zoneid)\n-            allow_roaming = bool(int(cpzone_info['roaming']))\n \n             # handle connected clients, timeouts, address changes, etc.\n             for db_client in expected_clients:\n                 # fetch ip address (or network) from database\n-                session_ips = self.db.list_session_ips(zoneid, db_client['sessionId'])\n+                cpnet = db_client['ipAddress'].strip()\n \n                 # there are different reasons why a session should be removed, check for all reasons and\n                 # use the same method for the actual removal\n@@ -161,8 +158,8 @@ def sync_zone(self, zoneid, registered_addr_accounting):\n                             drop_session_reason = \"remove concurrent session %s\" % db_client['sessionId']\n                             delete_reason = \"User-Request\"\n \n-                    # if mac address changes, drop session. it's not the same client. Use the \"primary IP\" to determine this\n-                    current_arp = self.arp.get_by_ipaddress(db_client['ipAddress'])\n+                    # if mac address changes, drop session. it's not the same client\n+                    current_arp = self.arp.get_by_ipaddress(cpnet)\n                     if current_arp is not None and current_arp['mac'] != db_client['macAddress']:\n                         drop_session_reason = \"mac address changed for session %s\" % db_client['sessionId']\n                         delete_reason = \"Admin-Reset\"\n@@ -173,46 +170,39 @@ def sync_zone(self, zoneid, registered_addr_accounting):\n                             and time.time() - float(db_client['startTime']) > db_client['acc_session_timeout']:\n                             drop_session_reason = \"accounting limit reached for session %s\" % db_client['sessionId']\n                             delete_reason = \"Session-Timeout\"\n-\n-                if drop_session_reason is not None:\n-                    # remove session\n-                    syslog.syslog(syslog.LOG_NOTICE, drop_session_reason)\n-                    for ip in session_ips:\n-                        self._remove_client(zoneid, ip)\n-                    self.db.del_client(zoneid, db_client['sessionId'], delete_reason)\n-                    continue\n-\n-                # if primary IP changed, update db accordingly. Not relevant for static IP-authenticated clients\n-                if db_client['authenticated_via'] != '---ip---':\n-                    current_ips = self.arp.get_all_addresses_by_mac(db_client['macAddress'])\n-                    if len(current_ips) > 0 and db_client['ipAddress'] != current_ips[0]:\n+                elif db_client['authenticated_via'] == '---mac---':\n+                    # detect mac changes\n+                    current_ip = self.arp.get_address_by_mac(db_client['macAddress'])\n+                    if current_ip is not None and db_client['ipAddress'] != current_ip:\n                         if db_client['ipAddress'] != '':\n                             # remove old ip\n                             self._remove_client(zoneid, db_client['ipAddress'])\n-                        self.db.update_client_ip(zoneid, db_client['sessionId'], current_ips[0])\n-                        self._add_client(zoneid, current_ips[0])\n-                        db_client['ipAddress'] = current_ips[0]\n-\n-                # session should be active, validate its properties\n-                if allow_roaming:\n-                    # this will add the \"primary\" IP as well, but both list_session_ips and update_roaming_ips will return a deduplicated set\n-                    session_ips = self.db.update_roaming_ips(zoneid, db_client['sessionId'], self.arp.get_all_addresses_by_mac(db_client['macAddress']))\n+                        self.db.update_client_ip(zoneid, db_client['sessionId'], current_ip)\n+                        self._add_client(zoneid, current_ip)\n+\n+                # check session, if it should be active, validate its properties\n+                if drop_session_reason is None:\n+                    # registered client, but not active in pf or missing accounting according to ipfw (after reboot)\n+                    if cpnet and (\n+                        cpnet not in registered_addresses or\n+                        cpnet not in registered_addr_accounting\n+                    ):\n+                        self._add_client(zoneid, cpnet)\n                 else:\n-                    # may have been updated if primary IP changed\n-                    session_ips = {db_client['ipAddress']}\n-\n-                to_add = (session_ips - registered_addresses_pf) | (session_ips - registered_addresses_ipfw)\n-                if session_ips and to_add:\n-                    for ip in to_add:\n-                        self._add_client(zoneid, ip)\n-\n-            # remove any address from pf that isn't expected\n-            expected_addresses = set()\n-            for db_client in expected_clients:\n-                expected_addresses.update(self.db.list_session_ips(zoneid, db_client['sessionId']))\n+                    # remove session\n+                    syslog.syslog(syslog.LOG_NOTICE, drop_session_reason)\n+                    self._remove_client(zoneid, cpnet)\n+                    self.db.del_client(zoneid, db_client['sessionId'], delete_reason)\n \n-            for registered_address in registered_addresses_pf:\n-                if registered_address not in expected_addresses:\n+            # if there are addresses/networks in the underlying pf table which are not in our administration,\n+            # remove them from pf.\n+            for registered_address in registered_addresses:\n+                address_active = False\n+                for db_client in expected_clients:\n+                    if registered_address == db_client['ipAddress']:\n+                        address_active = True\n+                        break\n+                if not address_active:\n                     self._remove_client(zoneid, registered_address)\n \n def main():"},{"sha":"3214847d941e33b18eb05d4d5b8a5cfb1cef8f7c","filename":"src/opnsense/scripts/captiveportal/lib/arp.py","status":"modified","additions":61,"deletions":45,"changes":106,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Flib%2Farp.py","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Flib%2Farp.py","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Flib%2Farp.py?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -1,6 +1,5 @@\n \"\"\"\n     Copyright (c) 2015-2019 Ad Schellevis <ad@opnsense.org>\n-    Copyright (c) 2026 Deciso B.V.\n     All rights reserved.\n \n     Redistribution and use in source and binary forms, with or without\n@@ -26,60 +25,77 @@\n \n \"\"\"\n import subprocess\n-import ujson\n-from datetime import datetime\n+\n \n class ARP(object):\n     def __init__(self):\n         \"\"\" construct new arp helper\n         :return: None\n         \"\"\"\n-        self._table = {}\n-        self.reload()\n+        self._arp_table = dict()\n+        self._fetch_arp_table()\n \n     def reload(self):\n-        \"\"\" reload / parse arp and ndp tables\n+        \"\"\" reload / parse arp table\n         \"\"\"\n-        self._table.clear()\n-\n-        # fetch addresses, no IPv6 if hostwatch disabled\n-        out = ujson.loads(subprocess.run(\n-            ['/usr/local/opnsense/scripts/interfaces/list_hosts.py', '--last-seen-window', '86400', '-v'],\n-            capture_output=True,\n-            text=True\n-        ).stdout)\n-\n-        source = out.get(\"source\")\n-        rows = out.get(\"rows\", [])\n-\n-        if source == \"discovery\":\n-            rows_iter = sorted(\n-                rows,\n-                key=lambda row: datetime.strptime(row[5], \"%Y-%m-%d %H:%M:%S\"),\n-                reverse=True\n-            )\n-        else:\n-            rows_iter = rows\n-\n-        for row in rows_iter:\n-            ip = row[2]\n+        self._fetch_arp_table()\n \n-            entry = {\n-                \"intf\": row[0],\n-                \"mac\": row[1],\n-            }\n-\n-            if source == \"discovery\":\n-                entry[\"first_seen\"] = datetime.strptime(row[4], \"%Y-%m-%d %H:%M:%S\")\n-                entry[\"last_seen\"]  = datetime.strptime(row[5], \"%Y-%m-%d %H:%M:%S\")\n-\n-            self._table[ip] = entry\n+    def _fetch_arp_table(self):\n+        \"\"\" parse system arp table and store result in this object\n+        :return: None\n+        \"\"\"\n+        # parse arp table\n+        self._arp_table = dict()\n+        sp = subprocess.run(['/usr/sbin/arp', '-an'], capture_output=True, text=True)\n+        for line in sp.stdout.split(\"\\n\"):\n+            line_parts = line.split()\n+\n+            if len(line_parts) < 6 or line_parts[2] != 'at' or line_parts[4] != 'on':\n+                continue\n+            elif len(line_parts[1]) < 2 or line_parts[1][0] != '(' or line_parts[1][-1] != ')':\n+                continue\n+\n+            address = line_parts[1][1:-1]\n+            physical_intf = line_parts[5]\n+            mac = line_parts[3]\n+            expires = -1\n+\n+            for index in range(len(line_parts) - 3):\n+                if line_parts[index] == 'expires' and line_parts[index + 1] == 'in':\n+                    if line_parts[index + 2].isdigit():\n+                        expires = int(line_parts[index + 2])\n+\n+            if address in self._arp_table:\n+                self._arp_table[address]['intf'].append(physical_intf)\n+            elif mac.find('incomplete') == -1:\n+                self._arp_table[address] = {'mac': mac, 'intf': [physical_intf], 'expires': expires}\n+\n+    def list_items(self):\n+        \"\"\" return parsed arp list\n+        :return: dict\n+        \"\"\"\n+        return self._arp_table\n \n     def get_by_ipaddress(self, address):\n-        return self._table.get(address, None)\n+        \"\"\" search arp entry by ip address\n+        :param address: ip address\n+        :return: dict or None (if not found)\n+        \"\"\"\n+        if address in self._arp_table:\n+            return self._arp_table[address]\n+        else:\n+            return None\n \n-    def get_all_addresses_by_mac(self, mac_address):\n-        return [\n-            ip for ip, data in self._table.items()\n-            if data['mac'] == mac_address\n-        ]\n+    def get_address_by_mac(self, address):\n+        \"\"\" search arp entry by mac address, most recent arp entry\n+        :param address: ip address\n+        :return: dict or None (if not found)\n+        \"\"\"\n+        result = None\n+        for item in self._arp_table:\n+            if self._arp_table[item]['mac'] == address:\n+                if result is None:\n+                    result = item\n+                elif self._arp_table[result]['expires'] < self._arp_table[item]['expires']:\n+                    result = item\n+        return result"},{"sha":"9d480a37fae0500ac531c0aab1e2d3b3b0e413b0","filename":"src/opnsense/scripts/captiveportal/lib/db.py","status":"modified","additions":145,"deletions":388,"changes":533,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Flib%2Fdb.py","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Flib%2Fdb.py","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Flib%2Fdb.py?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -39,6 +39,7 @@ def __init__(self):\n         \"\"\"\n         self._connection = None\n         self.open()\n+        self.create()\n \n     def __del__(self):\n         \"\"\" destruct, close database handle\n@@ -69,10 +70,11 @@ def create(self, force_recreate=False):\n             self._connection = sqlite3.connect(self.database_filename)\n \n         cur = self._connection.cursor()\n-\n-        # initialize database\n-        init_script_filename = '%s/../sql/init.sql' % os.path.dirname(os.path.abspath(__file__))\n-        cur.executescript(open(init_script_filename, 'r').read())\n+        cur.execute(\"SELECT count(*) FROM sqlite_master where tbl_name = 'cp_clients'\")\n+        if cur.fetchall()[0][0] == 0:\n+            # empty database, initialize database\n+            init_script_filename = '%s/../sql/init.sql' % os.path.dirname(os.path.abspath(__file__))\n+            cur.executescript(open(init_script_filename, 'r').read())\n \n         # migration: add \"delete_reason\" column to cp_clients\n         cur.execute(\"PRAGMA table_info(cp_clients)\")\n@@ -100,93 +102,69 @@ def create(self, force_recreate=False):\n \n         cur.close()\n \n-    def sessions_per_address(self, zoneid, ip_address='', mac_address=''):\n-        \"\"\" fetch session(s) per (ip/mac) address\n-        Primary IP is stored in cp_clients.ip_address; roaming IPs in cp_client_ips.\n+    def sessions_per_address(self, zoneid, ip_address=None, mac_address=None):\n+        \"\"\" fetch session(s) per (mac) address\n+        :param zoneid: cp zone number\n+        :param ip_address: ip address\n+        :return: active status (boolean)\n         \"\"\"\n-        # Nothing to match on\n-        if ip_address == '' and mac_address == '':\n-            return []\n-\n         cur = self._connection.cursor()\n         request = {\n             'zoneid': zoneid,\n             'ip_address': ip_address,\n             'mac_address': mac_address\n         }\n+        cur.execute(\"\"\"select   cc.sessionid         sessionId\n+                        ,       cc.authenticated_via authenticated_via\n+                        , cc.ip_address\n+                       from     cp_clients cc\n+                       where    cc.deleted = 0\n+                       and      cc.zoneid = :zoneid\n+                       and   (\n+                                cc.ip_address = :ip_address\n+                                or\n+                                cc.mac_address = :mac_address\n+                             )\"\"\", request)\n \n-        clauses = []\n-        if ip_address != '':\n-            # Match either primary IP or any roaming IP\n-            clauses.append(\"(cc.ip_address = :ip_address OR ci.ip_address = :ip_address)\")\n-        if mac_address != '':\n-            clauses.append(\"cc.mac_address = :mac_address\")\n-\n-        where_or = \" OR \".join(clauses)\n-\n-        cur.execute(f\"\"\"\n-            SELECT DISTINCT\n-                cc.sessionid         AS sessionId,\n-                cc.authenticated_via AS authenticated_via\n-            FROM cp_clients cc\n-            LEFT JOIN cp_client_ips ci\n-                ON ci.zoneid = cc.zoneid\n-                AND ci.sessionid = cc.sessionid\n-            WHERE cc.deleted = 0\n-            AND cc.zoneid = :zoneid\n-            AND ({where_or})\n-        \"\"\", request)\n-\n-        return [{'sessionId': sessionId, 'authenticated_via': authenticated_via}\n-                for sessionId, authenticated_via in cur.fetchall()]\n+        result = []\n+        for row in cur.fetchall():\n+            result.append({'sessionId': row[0], 'authenticated_via': row[1]})\n+        return result\n \n     def add_client(self, zoneid, authenticated_via, username, ip_address, mac_address):\n-        response = {\n-            'zoneid': zoneid,\n-            'authenticated_via': authenticated_via,\n-            'userName': username,\n-            'ipAddress': ip_address,\n-            'macAddress': mac_address,\n-            'startTime': time.time(),\n-            'sessionId': base64.b64encode(os.urandom(16)).decode()\n-        }\n+        \"\"\" add a new client to the captive portal administration\n+        :param zoneid: cp zone number\n+        :param authenticated_via: name/id of the authenticator or ---ip--- / ---mac--- for authentication by address\n+        :param username: username, maybe empty\n+        :param ip_address: ip address (to unlock)\n+        :param mac_address: physical address of this ip\n+        :return: dictionary with session info\n+        \"\"\"\n+        response = dict()\n+        response['zoneid'] = zoneid\n+        response['authenticated_via'] = authenticated_via\n+        response['userName'] = username\n+        response['ipAddress'] = ip_address\n+        response['macAddress'] = mac_address\n+        response['startTime'] = time.time()  # record creation = sign-in time\n+        response['sessionId'] = base64.b64encode(os.urandom(16)).decode()  # generate a new random session id\n \n         cur = self._connection.cursor()\n-        try:\n-            cur.execute(\"BEGIN\")\n-\n-            # set cp_client as deleted in case there's already a user logged-in at this ip address\n-            # (match both primary IP and roaming IPs)\n-            if ip_address != '':\n-                cur.execute(\"\"\"\n-                    UPDATE cp_clients\n-                    SET    deleted = 1\n-                    WHERE  zoneid = :zoneid\n-                    AND  deleted = 0\n-                    AND (\n-                            ip_address = :ipAddress\n-                            OR sessionid IN (\n-                                SELECT sessionid\n-                                FROM   cp_client_ips\n-                                WHERE  zoneid = :zoneid\n-                                AND  ip_address = :ipAddress\n-                            )\n-                    )\n-                \"\"\", response)\n-\n-            # add new session (primary IP lives here)\n-            cur.execute(\"\"\"\n-                INSERT INTO cp_clients(zoneid, authenticated_via, sessionid, username, ip_address, mac_address, created)\n-                VALUES (:zoneid, :authenticated_via, :sessionId, :userName, :ipAddress, :macAddress, :startTime)\n-            \"\"\", response)\n+        # set cp_client as deleted in case there's already a user logged-in at this ip address.\n+        if ip_address is not None and ip_address != '':\n+            cur.execute(\"\"\"UPDATE cp_clients\n+                           SET    deleted = 1\n+                           WHERE  zoneid = :zoneid\n+                           AND    ip_address = :ipAddress\n+                        \"\"\", response)\n+\n+        # add new session\n+        cur.execute(\"\"\"INSERT INTO cp_clients(zoneid, authenticated_via, sessionid, username,  ip_address, mac_address, created)\n+                       VALUES (:zoneid, :authenticated_via, :sessionId, :userName, :ipAddress, :macAddress, :startTime)\n+                    \"\"\", response)\n \n-            self._connection.commit()\n-            return response\n-        except Exception:\n-            self._connection.rollback()\n-            raise\n-        finally:\n-            cur.close()\n+        self._connection.commit()\n+        return response\n \n     def update_client_ip(self, zoneid, sessionid, ip_address):\n         \"\"\" change client ip address\n@@ -202,70 +180,6 @@ def update_client_ip(self, zoneid, sessionid, ip_address):\n                     \"\"\", {'zoneid': zoneid, 'sessionid': sessionid, 'ip_address': ip_address})\n         self._connection.commit()\n \n-    def update_roaming_ips(self, zoneid, sessionid, ip_addresses=[]):\n-        \"\"\"Update roaming IP addresses for a session to exactly match the provided list.\n-        Returns the current set of IP addresses stored in the database.\n-        \"\"\"\n-\n-        # clean + deduplicate + remove empty values\n-        new_ips = {ip for ip in ip_addresses if ip != ''}\n-\n-        cur = self._connection.cursor()\n-        try:\n-            # fetch existing IPs\n-            cur.execute(\"\"\"\n-                SELECT ip_address\n-                FROM cp_client_ips\n-                WHERE zoneid = :zoneid\n-                AND sessionid = :sessionid\n-            \"\"\", {\n-                'zoneid': zoneid,\n-                'sessionid': sessionid\n-            })\n-\n-            current_ips = {row[0] for row in cur.fetchall()}\n-\n-            # if identical (order-independent), do nothing\n-            if current_ips == new_ips:\n-                return current_ips\n-\n-            # remove IPs no longer present\n-            ips_to_delete = current_ips - new_ips\n-            if ips_to_delete:\n-                cur.executemany(\"\"\"\n-                    DELETE FROM cp_client_ips\n-                    WHERE zoneid = :zoneid\n-                    AND sessionid = :sessionid\n-                    AND ip_address = :ip_address\n-                \"\"\", [\n-                    {\n-                        'zoneid': zoneid,\n-                        'sessionid': sessionid,\n-                        'ip_address': ip\n-                    }\n-                    for ip in ips_to_delete\n-                ])\n-\n-            # add new IPs\n-            ips_to_add = new_ips - current_ips\n-            if ips_to_add:\n-                cur.executemany(\"\"\"\n-                    INSERT INTO cp_client_ips(zoneid, sessionid, ip_address)\n-                    VALUES (:zoneid, :sessionid, :ip_address)\n-                \"\"\", [\n-                    {\n-                        'zoneid': zoneid,\n-                        'sessionid': sessionid,\n-                        'ip_address': ip\n-                    }\n-                    for ip in ips_to_add\n-                ])\n-\n-            self._connection.commit()\n-            return new_ips\n-        finally:\n-            cur.close()\n-\n     def del_client(self, zoneid, sessionid, reason=None):\n         \"\"\" mark (administrative) client for removal\n         :param zoneid: zone id\n@@ -297,16 +211,15 @@ def del_client(self, zoneid, sessionid, reason=None):\n         else:\n             return None\n \n-    def list_clients(self, zoneid=None, include_ips=False):\n+    def list_clients(self, zoneid=None):\n         \"\"\" return list of (administrative) connected clients and usage statistics\n         :param zoneid: zone id\n-        :param include_ips: if True, include all IPs (primary + roaming) as a list in record['ipAddresses']\n         :return: list of clients\n         \"\"\"\n-        result = []\n-        fieldnames = []\n+        result = list()\n+        fieldnames = list()\n         cur = self._connection.cursor()\n-\n+        # rename fields for API\n         cur.execute(\"\"\" select  cc.zoneid\n                         ,       cc.sessionid   sessionId\n                         ,       cc.authenticated_via authenticated_via\n@@ -330,91 +243,24 @@ def list_clients(self, zoneid=None, include_ips=False):\n                         and     cc.deleted = 0\n                         order by case when cc.username is not null then cc.username else cc.ip_address end\n                         ,        cc.created desc\n-                    \"\"\", {'zoneid': zoneid})\n-\n-        ip_map = {}\n-        if include_ips:\n-            cur_ips = self._connection.cursor()\n-            cur_ips.execute(\"\"\"\n-                SELECT cc.zoneid, cc.sessionid, cc.ip_address\n-                FROM cp_clients cc\n-                WHERE (cc.zoneid = :zoneid OR :zoneid IS NULL)\n-                AND cc.deleted = 0\n-                AND cc.ip_address IS NOT NULL\n-                AND TRIM(cc.ip_address) <> ''\n-                UNION ALL\n-                SELECT ci.zoneid, ci.sessionid, ci.ip_address\n-                FROM cp_client_ips ci\n-                JOIN cp_clients cc\n-                ON cc.zoneid = ci.zoneid AND cc.sessionid = ci.sessionid\n-                WHERE (ci.zoneid = :zoneid OR :zoneid IS NULL)\n-                AND cc.deleted = 0\n-                AND ci.ip_address IS NOT NULL\n-                AND TRIM(ci.ip_address) <> ''\n-            \"\"\", {'zoneid': zoneid})\n-\n-            for z, sid, ip in cur_ips.fetchall():\n-                ip_map.setdefault((z, sid), set()).add(ip)\n-            ip_map = {k: sorted(v) for k, v in ip_map.items()}\n-            cur_ips.close()\n+                        \"\"\", {'zoneid': zoneid})\n \n         while True:\n-            if not fieldnames:\n+            # fetch field names\n+            if len(fieldnames) == 0:\n                 for fields in cur.description:\n                     fieldnames.append(fields[0])\n \n             row = cur.fetchone()\n             if row is None:\n                 break\n-\n-            record = {fieldnames[idx]: row[idx] for idx in range(len(row))}\n-            if include_ips:\n-                record['ipAddresses'] = ip_map.get((record['zoneid'], record['sessionId']), [])\n-            result.append(record)\n-\n-        cur.close()\n+            else:\n+                record = dict()\n+                for idx in range(len(row)):\n+                    record[fieldnames[idx]] = row[idx]\n+                result.append(record)\n         return result\n \n-\n-    def list_session_ips(self, zoneid, sessionid):\n-        \"\"\"\n-        Return primary + roaming IPs for a session.\n-        - Primary IP is cp_clients.ip_address\n-        - roaming IPs are cp_client_ips.ip_address\n-        Returns a de-duplicated set[str] (order not guaranteed).\n-        \"\"\"\n-        cur = self._connection.cursor()\n-        try:\n-            params = {'zoneid': zoneid, 'sessionid': sessionid}\n-\n-            cur.execute(f\"\"\"\n-                SELECT cc.ip_address\n-                FROM cp_clients cc\n-                WHERE cc.zoneid = :zoneid\n-                AND cc.sessionid = :sessionid\n-                AND cc.deleted = 0\n-            \"\"\", params)\n-            row = cur.fetchone()\n-            ips = set()\n-            if row and row[0] and str(row[0]):\n-                ips.add(str(row[0]))\n-\n-            cur.execute(\"\"\"\n-                SELECT ci.ip_address\n-                FROM cp_client_ips ci\n-                WHERE ci.zoneid = :zoneid\n-                AND ci.sessionid = :sessionid\n-                AND ci.ip_address IS NOT NULL\n-                AND TRIM(ci.ip_address) <> ''\n-            \"\"\", params)\n-            for (ip,) in cur.fetchall():\n-                if ip and str(ip):\n-                    ips.add(str(ip))\n-\n-            return ips\n-        finally:\n-            cur.close()\n-\n     def find_concurrent_user_sessions(self, zoneid):\n         \"\"\" query zone database for concurrent user sessions\n         :param zoneid: zone id\n@@ -446,173 +292,84 @@ def find_concurrent_user_sessions(self, zoneid):\n \n     def update_accounting_info(self, details):\n         \"\"\" update internal accounting database with given ipfw info (not per zone)\n-        :param details: ipfw accounting details dict keyed by ip:\n-                        details[ip] = {'in_pkts','out_pkts','in_bytes','out_bytes','last_accessed'}\n-        \"\"\"\n-        if type(details) != dict:\n-            return\n-\n-        cur = self._connection.cursor()\n-        cur2 = self._connection.cursor()\n-\n-        # Load sessions + existing session_info\n-        cur.execute(\"\"\"\n-            SELECT  cc.zoneid,\n-                    cc.sessionid,\n-                    cc.ip_address AS primary_ip,\n-                    cc.created    AS created,\n-                    si.rowid      AS si_rowid,\n-                    COALESCE(si.prev_packets_in, 0)  AS prev_packets_in,\n-                    COALESCE(si.prev_bytes_in, 0)    AS prev_bytes_in,\n-                    COALESCE(si.prev_packets_out, 0) AS prev_packets_out,\n-                    COALESCE(si.prev_bytes_out, 0)   AS prev_bytes_out,\n-                    COALESCE(si.last_accessed, 0)    AS last_accessed\n-            FROM cp_clients cc\n-            LEFT JOIN session_info si\n-                ON si.zoneid = cc.zoneid AND si.sessionid = cc.sessionid\n-        \"\"\")\n-\n-        sessions = {}  # (zoneid, sessionid) -> record\n-        for row in cur.fetchall():\n-            rec = {\n-                'zoneid': row[0],\n-                'sessionid': row[1],\n-                'primary_ip': row[2],\n-                'created': row[3],\n-                'si_rowid': row[4],\n-                'prev_packets_in': row[5],\n-                'prev_bytes_in': row[6],\n-                'prev_packets_out': row[7],\n-                'prev_bytes_out': row[8],\n-                'last_accessed': row[9],\n-                'ips': set()\n-            }\n-            sessions[(rec['zoneid'], rec['sessionid'])] = rec\n-\n-        # Add roaming IPs\n-        cur.execute(\"\"\"\n-            SELECT zoneid, sessionid, ip_address\n-            FROM cp_client_ips\n-            WHERE ip_address IS NOT NULL AND TRIM(ip_address) <> ''\n-        \"\"\")\n-        for zoneid, sessionid, ip in cur.fetchall():\n-            key = (zoneid, sessionid)\n-            if key in sessions:\n-                sessions[key]['ips'].add(ip)\n-\n-        # Ensure primary IP is included for each session\n-        for rec in sessions.values():\n-            pip = rec.get('primary_ip')\n-            if pip is not None and pip != '':\n-                rec['ips'].add(pip)\n-\n-        sql_new = \"\"\"\n-            INSERT INTO session_info(\n-                zoneid, sessionid,\n-                prev_packets_in, prev_bytes_in,\n-                prev_packets_out, prev_bytes_out,\n-                packets_in, packets_out,\n-                bytes_in, bytes_out,\n-                last_accessed\n-            )\n-            VALUES (\n-                :zoneid, :sessionid,\n-                :prev_packets_in, :prev_bytes_in,\n-                :prev_packets_out, :prev_bytes_out,\n-                :packets_in, :packets_out,\n-                :bytes_in, :bytes_out,\n-                :last_accessed\n-            )\n-        \"\"\"\n-\n-        sql_update = \"\"\"\n-            UPDATE session_info\n-            SET    last_accessed    = :last_accessed,\n-                prev_packets_in  = :prev_packets_in,\n-                prev_packets_out = :prev_packets_out,\n-                prev_bytes_in    = :prev_bytes_in,\n-                prev_bytes_out   = :prev_bytes_out,\n-                packets_in       = packets_in + :packets_in,\n-                packets_out      = packets_out + :packets_out,\n-                bytes_in         = bytes_in + :bytes_in,\n-                bytes_out        = bytes_out + :bytes_out\n-            WHERE  rowid = :si_rowid\n+        :param details: ipfw accounting details\n         \"\"\"\n-\n-        # Update accounting per session by summing over all of its IPs\n-        for rec in sessions.values():\n-            cur_pkts_in = 0\n-            cur_pkts_out = 0\n-            cur_bytes_in = 0\n-            cur_bytes_out = 0\n-            cur_last_accessed = 0\n-\n-            any_hit = False\n-            for ip in rec['ips']:\n-                d = details.get(ip)\n-                if not d:\n-                    continue\n-                any_hit = True\n-                cur_pkts_in += int(d.get('in_pkts', 0))\n-                cur_pkts_out += int(d.get('out_pkts', 0))\n-                cur_bytes_in += int(d.get('in_bytes', 0))\n-                cur_bytes_out += int(d.get('out_bytes', 0))\n-                cur_last_accessed = max(cur_last_accessed, int(d.get('last_accessed', 0)))\n-\n-            if not any_hit:\n-                continue\n-\n-            last_accessed = cur_last_accessed if cur_last_accessed else int(rec['created'] or 0)\n-\n-            if rec['si_rowid'] is None:\n-                payload = {\n-                    'zoneid': rec['zoneid'],\n-                    'sessionid': rec['sessionid'],\n-                    'prev_packets_in': cur_pkts_in,\n-                    'prev_bytes_in': cur_bytes_in,\n-                    'prev_packets_out': cur_pkts_out,\n-                    'prev_bytes_out': cur_bytes_out,\n-                    'packets_in': cur_pkts_in,\n-                    'packets_out': cur_pkts_out,\n-                    'bytes_in': cur_bytes_in,\n-                    'bytes_out': cur_bytes_out,\n-                    'last_accessed': last_accessed\n-                }\n-                cur2.execute(sql_new, payload)\n-            else:\n-                prev_pi = int(rec['prev_packets_in'])\n-                prev_po = int(rec['prev_packets_out'])\n-                prev_bi = int(rec['prev_bytes_in'])\n-                prev_bo = int(rec['prev_bytes_out'])\n-\n-                # If totals decreased, treat as reset and add full totals\n-                if (cur_pkts_in >= prev_pi and cur_pkts_out >= prev_po and\n-                    cur_bytes_in >= prev_bi and cur_bytes_out >= prev_bo):\n-                    add_pi = cur_pkts_in - prev_pi\n-                    add_po = cur_pkts_out - prev_po\n-                    add_bi = cur_bytes_in - prev_bi\n-                    add_bo = cur_bytes_out - prev_bo\n-                else:\n-                    add_pi = cur_pkts_in\n-                    add_po = cur_pkts_out\n-                    add_bi = cur_bytes_in\n-                    add_bo = cur_bytes_out\n-\n-                payload = {\n-                    'si_rowid': rec['si_rowid'],\n-                    'last_accessed': last_accessed,\n-                    'packets_in': add_pi,\n-                    'packets_out': add_po,\n-                    'bytes_in': add_bi,\n-                    'bytes_out': add_bo,\n-                    'prev_packets_in': cur_pkts_in,\n-                    'prev_packets_out': cur_pkts_out,\n-                    'prev_bytes_in': cur_bytes_in,\n-                    'prev_bytes_out': cur_bytes_out\n-                }\n-                cur2.execute(sql_update, payload)\n-\n-        self._connection.commit()\n+        if type(details) == dict:\n+            # query registered data\n+            sql = \"\"\" select    cc.ip_address, cc.zoneid, cc.sessionid\n+                      ,         si.rowid si_rowid, si.prev_packets_in, si.prev_bytes_in\n+                      ,         si.prev_packets_out, si.prev_bytes_out, si.last_accessed\n+                      from      cp_clients cc\n+                      left join session_info si on si.zoneid = cc.zoneid and si.sessionid = cc.sessionid\n+                      order by  cc.ip_address, cc.deleted\n+                  \"\"\"\n+            cur = self._connection.cursor()\n+            cur2 = self._connection.cursor()\n+            cur.execute(sql)\n+            prev_record = {'ip_address': None}\n+            for row in cur.fetchall():\n+                # map fieldnumbers to names\n+                record = {}\n+                for fieldId in range(len(row)):\n+                    record[cur.description[fieldId][0]] = row[fieldId]\n+                # search unique hosts from dataset, both disabled and enabled.\n+                if prev_record['ip_address'] != record['ip_address'] and record['ip_address'] in details:\n+                    if record['si_rowid'] is None:\n+                        # new session, add info object\n+                        sql_new = \"\"\" insert into session_info(zoneid, sessionid, prev_packets_in, prev_bytes_in,\n+                                                               prev_packets_out, prev_bytes_out,\n+                                                               packets_in, packets_out, bytes_in, bytes_out,\n+                                                               last_accessed)\n+                                      values (:zoneid, :sessionid, :packets_in, :bytes_in, :packets_out, :bytes_out,\n+                                              :packets_in, :packets_out, :bytes_in, :bytes_out, :last_accessed)\n+                        \"\"\"\n+                        record['packets_in'] = details[record['ip_address']]['in_pkts']\n+                        record['bytes_in'] = details[record['ip_address']]['in_bytes']\n+                        record['packets_out'] = details[record['ip_address']]['out_pkts']\n+                        record['bytes_out'] = details[record['ip_address']]['out_bytes']\n+                        record['last_accessed'] = details[record['ip_address']]['last_accessed']\n+                        cur2.execute(sql_new, record)\n+                    else:\n+                        # update session\n+                        sql_update = \"\"\" update session_info\n+                                         set    last_accessed = :last_accessed\n+                                         ,      prev_packets_in = :prev_packets_in\n+                                         ,      prev_packets_out = :prev_packets_out\n+                                         ,      prev_bytes_in = :prev_bytes_in\n+                                         ,      prev_bytes_out = :prev_bytes_out\n+                                         ,      packets_in = packets_in + :packets_in\n+                                         ,      packets_out = packets_out + :packets_out\n+                                         ,      bytes_in = bytes_in + :bytes_in\n+                                         ,      bytes_out = bytes_out + :bytes_out\n+                                         where  rowid = :si_rowid\n+                        \"\"\"\n+                        # add usage to session\n+                        record['last_accessed'] = details[record['ip_address']]['last_accessed']\n+                        if record['prev_packets_in'] <= details[record['ip_address']]['in_pkts'] and \\\n+                           record['prev_packets_out'] <= details[record['ip_address']]['out_pkts']:\n+                            # ipfw data is still valid, add difference to use\n+                            record['packets_in'] = (\n+                                details[record['ip_address']]['in_pkts'] - record['prev_packets_in'])\n+                            record['packets_out'] = (\n+                                details[record['ip_address']]['out_pkts'] - record['prev_packets_out'])\n+                            record['bytes_in'] = (details[record['ip_address']]['in_bytes'] - record['prev_bytes_in'])\n+                            record['bytes_out'] = (\n+                                details[record['ip_address']]['out_bytes'] - record['prev_bytes_out'])\n+                        else:\n+                            # the data has been reset (reloading rules), add current packet count\n+                            record['packets_in'] = details[record['ip_address']]['in_pkts']\n+                            record['packets_out'] = details[record['ip_address']]['out_pkts']\n+                            record['bytes_in'] = details[record['ip_address']]['in_bytes']\n+                            record['bytes_out'] = details[record['ip_address']]['out_bytes']\n+\n+                        record['prev_packets_in'] = details[record['ip_address']]['in_pkts']\n+                        record['prev_packets_out'] = details[record['ip_address']]['out_pkts']\n+                        record['prev_bytes_in'] = details[record['ip_address']]['in_bytes']\n+                        record['prev_bytes_out'] = details[record['ip_address']]['out_bytes']\n+                        cur2.execute(sql_update, record)\n+\n+                prev_record = record\n+            self._connection.commit()\n \n     def update_session_restrictions(self, zoneid, sessionid, session_timeout):\n         \"\"\" upsert session restrictions"},{"sha":"f4410734e6d9c02a4d6a03c2946665230306d962","filename":"src/opnsense/scripts/captiveportal/lib/ipfw.py","status":"modified","additions":2,"deletions":12,"changes":14,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Flib%2Fipfw.py","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Flib%2Fipfw.py","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Flib%2Fipfw.py?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -26,17 +26,8 @@\n \"\"\"\n import os\n import subprocess\n-import ipaddress\n \n class IPFW(object):\n-    @staticmethod\n-    def _is_ipv6(address):\n-        try:\n-            ipaddress.IPv6Address(address)\n-            return True\n-        except (ValueError, AttributeError):\n-            return False\n-\n     @staticmethod\n     def list_accounting_info():\n         \"\"\" list accounting info per ip address, addresses can't overlap in zone's so we just output all we know here\n@@ -96,10 +87,9 @@ def add_accounting(address):\n \n             # add accounting rule\n             if new_rule_id != -1:\n-                proto = 'ip6' if IPFW._is_ipv6(address) else 'ip'\n-                subprocess.run(['/sbin/ipfw', 'add', str(new_rule_id), 'count', proto, 'from', address, 'to', 'any'],\n+                subprocess.run(['/sbin/ipfw', 'add', str(new_rule_id), 'count', 'ip', 'from', address, 'to', 'any'],\n                                capture_output=True)\n-                subprocess.run(['/sbin/ipfw', 'add', str(new_rule_id), 'count', proto, 'from', 'any', 'to', address],\n+                subprocess.run(['/sbin/ipfw', 'add', str(new_rule_id), 'count', 'ip', 'from', 'any', 'to', address],\n                                capture_output=True)\n \n                 return new_rule_id"},{"sha":"4b2f0d46657a6089b793e905c5514f4daa8db7dd","filename":"src/opnsense/scripts/captiveportal/lib/pf.py","status":"modified","additions":3,"deletions":12,"changes":15,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Flib%2Fpf.py","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Flib%2Fpf.py","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Flib%2Fpf.py?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -25,20 +25,13 @@\n \n \"\"\"\n import subprocess\n-import ipaddress\n+import tempfile\n+import time\n \n class PF(object):\n     def __init__(self):\n         pass\n \n-    @staticmethod\n-    def _is_ipv6(address):\n-        try:\n-            ipaddress.IPv6Address(address)\n-            return True\n-        except (ValueError, AttributeError):\n-            return False\n-\n     @staticmethod\n     def list_table(zoneid):\n         pfctl_cmd = ['/sbin/pfctl', '-t', f'__captiveportal_zone_{zoneid}', '-T', 'show']\n@@ -57,6 +50,4 @@ def remove_from_table(zoneid, address):\n         subprocess.run(['/sbin/pfctl', '-t', f'__captiveportal_zone_{zoneid}', '-T', 'del', address], capture_output=True)\n         # kill associated states to and from this host\n         subprocess.run(['/sbin/pfctl', '-k', f'{address}'], capture_output=True)\n-        # Use appropriate wildcard based on IP version\n-        wildcard = '::/0' if PF._is_ipv6(address) else '0.0.0.0/0'\n-        subprocess.run(['/sbin/pfctl', '-k', wildcard, '-k', f'{address}'], capture_output=True)\n+        subprocess.run(['/sbin/pfctl', '-k', '0.0.0.0/0', '-k', f'{address}'], capture_output=True)"},{"sha":"2c8c7336f727f5da7634cc7e7c603a3360642456","filename":"src/opnsense/scripts/captiveportal/listClients.py","status":"modified","additions":1,"deletions":1,"changes":2,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2FlistClients.py","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2FlistClients.py","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2FlistClients.py?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -37,5 +37,5 @@\n parser.add_argument('-z', help='optional zoneid to filter on', type=str)\n args = parser.parse_args()\n \n-response = DB().list_clients(int(args.z) if str(args.z).isdigit() else None, True)\n+response = DB().list_clients(int(args.z) if str(args.z).isdigit() else None)\n print(ujson.dumps(response))"},{"sha":"d8e53c34f549a8dd15f5c9dcb886d0a377d42650","filename":"src/opnsense/scripts/captiveportal/sql/init.sql","status":"modified","additions":6,"deletions":20,"changes":26,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Fsql%2Finit.sql","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Fsql%2Finit.sql","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fscripts%2Fcaptiveportal%2Fsql%2Finit.sql?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -3,7 +3,7 @@\n --\n \n -- connected clients\n-create table if not exists cp_clients (\n+create table cp_clients (\n       zoneid int\n ,     sessionid varchar\n ,     authenticated_via varchar\n@@ -15,25 +15,11 @@ create table if not exists cp_clients (\n ,     primary key (zoneid, sessionid)\n );\n \n-create index if not exists cp_clients_ip ON cp_clients (ip_address);\n-create index if not exists  cp_clients_zone ON cp_clients (zoneid);\n-\n--- multiple IPs per session\n-create table if not exists cp_client_ips (\n-      zoneid     int not null\n-,     sessionid  varchar not null\n-,     ip_address varchar not null\n-,     primary key (zoneid, sessionid, ip_address)\n-,     foreign key (zoneid, sessionid)\n-        references cp_clients(zoneid, sessionid)\n-        on delete cascade\n-);\n-\n-create index if not exists cp_client_ips_ip   on cp_client_ips (ip_address);\n-create index if not exists cp_client_ips_zone on cp_client_ips (zoneid);\n+create index cp_clients_ip ON cp_clients (ip_address);\n+create index cp_clients_zone ON cp_clients (zoneid);\n \n -- session (accounting) info\n-create table if not exists session_info (\n+create table session_info (\n       zoneid int\n ,     sessionid varchar\n ,     prev_packets_in integer default (0)\n@@ -49,15 +35,15 @@ create table if not exists session_info (\n );\n \n -- session (accounting) restrictions\n-create table if not exists session_restrictions (\n+create table session_restrictions (\n       zoneid int\n ,     sessionid varchar\n ,     session_timeout int\n ,     primary key (zoneid, sessionid)\n ) ;\n \n --  accounting state, record the state of (radius) accounting messages\n-create table if not exists accounting_state (\n+create table accounting_state (\n       zoneid int\n ,     sessionid varchar\n ,     state varchar"},{"sha":"5f1d545d003b846d73a0c363e8671f73f9c65086","filename":"src/opnsense/scripts/interfaces/list_hosts.py","status":"modified","additions":4,"deletions":25,"changes":29,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Finterfaces%2Flist_hosts.py","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fscripts%2Finterfaces%2Flist_hosts.py","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fscripts%2Finterfaces%2Flist_hosts.py?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -55,12 +55,6 @@ def is_hostwatch_enabled(rc_file):\n     parser.add_argument(\n         '-v', '--verbose', help='Verbose output (including vendors)', action=\"store_true\", default=False\n     )\n-    parser.add_argument(\n-        \"--last-seen-window\",\n-        type=int,\n-        default=None,\n-        help=\"Only return hosts seen in the last N seconds\"\n-    )\n     parser.add_argument('--rc_file', help='hostwatch rc(8) config filename', default='/etc/rc.conf.d/hostwatch')\n     parser.add_argument('--db_file', help='hostwatch sqlite3 database', default='/var/db/hostwatch/hosts.db')\n     inputargs = parser.parse_args()\n@@ -71,27 +65,12 @@ def is_hostwatch_enabled(rc_file):\n         result['source'] = 'discovery'\n         con = sqlite3.connect(\"file:%s?mode=ro\" % inputargs.db_file, uri=True)\n         con.row_factory = sqlite3.Row\n-        query = \"\"\"\n-            SELECT *\n-            FROM v_hosts\n-            WHERE protocol IN (?, ?)\n-        \"\"\"\n-        params = [inputargs.proto[0], inputargs.proto[-1]]\n-\n-        if inputargs.last_seen_window is not None:\n-            query += \"\"\"\n-                AND last_seen >= datetime('now', '-' || ? || ' seconds')\n-            \"\"\"\n-            params.append(inputargs.last_seen_window)\n-\n-        for row in con.execute(query, params):\n+        for row in con.execute(\n+            'select * from v_hosts where protocol in (?, ?)', (inputargs.proto[0], inputargs.proto[-1])\n+        ):\n             record = [row['interface_name'], row['ether_address'], row['ip_address']]\n             if inputargs.verbose:\n-                record += [\n-                    row['organization_name'],\n-                    row['first_seen'],\n-                    row['last_seen']\n-                ]\n+                record = record + [row['organization_name'], row['first_seen'], row['last_seen']]\n             result['rows'].append(record)\n     else:\n         result['source'] = 'arp-ndp'"},{"sha":"538df9ffd09a5c9d4d0f92b8c029e67531ce5326","filename":"src/opnsense/service/conf/actions.d/actions_hostwatch.conf","status":"modified","additions":1,"deletions":1,"changes":2,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fservice%2Fconf%2Factions.d%2Factions_hostwatch.conf","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fservice%2Fconf%2Factions.d%2Factions_hostwatch.conf","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fservice%2Fconf%2Factions.d%2Factions_hostwatch.conf?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -32,7 +32,7 @@ type:script_output\n message:fetch detailed host information\n \n [dump]\n-command:/usr/local/opnsense/scripts/interfaces/list_hosts.py -n\n+command:/usr/local/opnsense/scripts/interfaces/list_hosts.py\n parameters:\n errors:no\n cache_ttl:30"},{"sha":"fc61dae3261a6bcf753184e938f6bf5d211ae9b2","filename":"src/opnsense/service/templates/OPNsense/Captiveportal/captiveportal.conf","status":"modified","additions":0,"deletions":1,"changes":1,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fservice%2Ftemplates%2FOPNsense%2FCaptiveportal%2Fcaptiveportal.conf","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fservice%2Ftemplates%2FOPNsense%2FCaptiveportal%2Fcaptiveportal.conf","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fservice%2Ftemplates%2FOPNsense%2FCaptiveportal%2Fcaptiveportal.conf?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -8,7 +8,6 @@ hardtimeout={{ cpZone.hardtimeout|default(\"0\") }}\n concurrentlogins={{cpZone.concurrentlogins|default(\"0\")}}\n allowedAddresses={{cpZone.allowedAddresses|default(\"\")}}\n allowedMACAddresses={{cpZone.allowedMACAddresses|default(\"\")}}\n-roaming={{cpZone.roaming|default(\"0\")}}\n \n [template_for_zone_{{cpZone.zoneid}}]\n content={{helpers.getUUID(cpZone.template).content}}"},{"sha":"2285ffe5af1bb3eb63ae4b8723f1fb10480d9997","filename":"src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-api-dispatcher.conf","status":"modified","additions":2,"deletions":2,"changes":4,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fservice%2Ftemplates%2FOPNsense%2FCaptiveportal%2Flighttpd-api-dispatcher.conf","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fservice%2Ftemplates%2FOPNsense%2FCaptiveportal%2Flighttpd-api-dispatcher.conf","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fservice%2Ftemplates%2FOPNsense%2FCaptiveportal%2Flighttpd-api-dispatcher.conf?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -39,8 +39,8 @@ url.access-deny             = ( \"~\", \".inc\" )\n ######### Options that are good to be but not necessary to be changed #######\n \n ## bind to port (default: 80)\n-server.bind               = \"127.0.0.1\"\n-server.port               = 8999\n+server.bind  = \"127.0.0.1\"\n+server.port  = 8999\n \n ## to help the rc.scripts\n server.pid-file            = \"/var/run/lighttpd-api-dispatcher.pid\""},{"sha":"ca54e9ee8594bbaf2fe3ff1060e54fca31dfb7f9","filename":"src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-zone.conf","status":"modified","additions":11,"deletions":24,"changes":35,"blob_url":"https://github.com/opnsense/core/blob/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fservice%2Ftemplates%2FOPNsense%2FCaptiveportal%2Flighttpd-zone.conf","raw_url":"https://github.com/opnsense/core/raw/c102de2936d92a7bc7647978411f11f3004bb935/src%2Fopnsense%2Fservice%2Ftemplates%2FOPNsense%2FCaptiveportal%2Flighttpd-zone.conf","contents_url":"https://api.github.com/repos/opnsense/core/contents/src%2Fopnsense%2Fservice%2Ftemplates%2FOPNsense%2FCaptiveportal%2Flighttpd-zone.conf?ref=c102de2936d92a7bc7647978411f11f3004bb935","patch":"@@ -6,7 +6,7 @@\n         {% for intf_tag in item.interfaces.split(',') %}\n             {% for conf_key, conf_inf in interfaces.items() %}\n                 {% if conf_key == intf_tag and conf_inf.ipaddr and conf_inf.ipaddr != 'dhcp' %}\n-                    {% do item.update({'interface_hostaddr': conf_inf.ipaddr}) %}\n+                    {% do item.update({'interface_hostaddr':conf_inf.ipaddr}) %}\n                 {% endif %}\n             {% endfor %}\n         {% endfor %}\n@@ -22,16 +22,15 @@\n \n     {# generate zone redirect address #}\n     {% if not cp_zone_item.interface_hostaddr %}\n-      # interface address / servername not found\n-    {% elif cp_zone_item.certificate|default(\"\") != \"\" %}\n+      # interface address not found    {% elif cp_zone_item.certificate|default(\"\") != \"\" %}\n       # ssl enabled, redirect to https\n-      {% do cp_zone_item.update({'redirect_host': 'https://' ~ cp_zone_item.interface_hostaddr ~ ':' ~ (cp_zone_item.zoneid|int + 8000) ~ '/index.html'}) %}\n+      {% do cp_zone_item.update({'redirect_host':'https://'+cp_zone_item.interface_hostaddr + ':'  ~ (cp_zone_item.zoneid|int + 8000) ~ '/index.html'}) %}\n     {% else %}\n       # ssl disabled, redirect to http\n-      {% do cp_zone_item.update({'redirect_host': 'http://' ~ cp_zone_item.interface_hostaddr ~ ':' ~ (cp_zone_item.zoneid|int + 8000) ~ '/index.html'}) %}\n+      {% do cp_zone_item.update({'redirect_host':'http://'+cp_zone_item.interface_hostaddr + ':'  ~ (cp_zone_item.zoneid|int + 8000) ~ '/index.html'}) %}\n     {% endif %}\n     {% if cp_zone_item.interface_hostaddr %}\n-        {% do cp_zone_item.update({'redirect_host_match':cp_zone_item.interface_hostaddr.replace('.','\\.') ~ ':' ~ (cp_zone_item.zoneid|int + 8000) }) %}\n+      {% do cp_zone_item.update({'redirect_host_match':cp_zone_item.interface_hostaddr.replace('.','\\.') ~ ':' ~ (cp_zone_item.zoneid|int + 8000) }) %}\n     {% endif %}\n \n #############################################################################################\n@@ -84,35 +83,23 @@ server.port              = {{ cp_zone_item.zoneid|int + 8000  }}\n ## Redirect response code to use\n url.redirect-code        = 302\n \n-$HTTP[\"host\"] !~ \"(.*{{ cp_zone_item.redirect_host_match }}.*)\" {\n+##\n+$HTTP[\"host\"] !~ \"(.*{{cp_zone_item.redirect_host_match}}.*)\" {\n \t$HTTP[\"host\"] =~ \"([^:/]+)\" {\n-\t\turl.redirect = ( \"^(.*)$\" => \"{{ cp_zone_item.redirect_host }}?redirurl=%1$1\")\n+\t\turl.redirect = ( \"^(.*)$\" => \"{{cp_zone_item.redirect_host}}?redirurl=%1$1\")\n \t}\n }\n \n-$SERVER[\"socket\"] == \"[::]:{{ cp_zone_item.zoneid|int + 8000 }}\" {\n-\t$HTTP[\"host\"] !~ \"(.*{{cp_zone_item.redirect_host_match}}.*)\" {\n-\t\t$HTTP[\"host\"] =~ \"(\\[[^\\]]+\\]|[^:/]+)\" {\n-\t\t\turl.redirect = ( \"^(.*)$\" => \"{{cp_zone_item.redirect_host}}?redirurl=%1$1\")\n-\t\t}\n-    }\n-    {% if cp_zone_item.certificate|default(\"\") != \"\" %}\n-    ssl.engine = \"enable\"\n-    {% else %}\n-    ssl.engine = \"disable\"\n-    {% endif %}\n-}\n-\n ## redirect http traffic to http(s) main target\n $SERVER[\"socket\"] == \":{{ cp_zone_item.zoneid|int + 9000 }}\" {\n \t$HTTP[\"host\"] =~ \"([^:/]+)\" {\n-\t\turl.redirect = ( \"^(.*)$\" => \"{{ cp_zone_item.redirect_host }}?redirurl=%1$1\")\n+\t\turl.redirect = ( \"^(.*)$\" => \"{{cp_zone_item.redirect_host}}?redirurl=%1$1\")\n \t}\n \tssl.engine = \"disable\"\n }\n $SERVER[\"socket\"] == \"[::]:{{ cp_zone_item.zoneid|int + 9000 }}\" {\n-\t$HTTP[\"host\"] =~ \"(\\[[^\\]]+\\]|[^:/]+)\" {\n-\t\turl.redirect = ( \"^(.*)$\" => \"{{ cp_zone_item.redirect_host }}?redirurl=%1$1\")\n+\t$HTTP[\"host\"] =~ \"([^:/]+)\" {\n+\t\turl.redirect = ( \"(.*)\" => \"{{cp_zone_item.redirect_host}}?redirurl=%1$1\")\n \t}\n \tssl.engine = \"disable\"\n }"}]}