From 24cc487fb82de5ec7889a07318d204b6c30cc228 Mon Sep 17 00:00:00 2001 From: VEQNORA Date: Sat, 25 Jul 2026 14:44:49 +0500 Subject: [PATCH] net/pppoe-server: add PPPoE Access Concentrator (BRAS) plugin New plugin providing a PPPoE server / Access Concentrator built on mpd5. Features: - PPPoE server instances on physical or VLAN/QinQ interfaces - PAP, CHAP and MS-CHAPv2 against a local user database or RADIUS - IPv4 address pools and per-user static addresses - RADIUS authentication and accounting (Start/Interim/Stop, gigawords) - RADIUS CoA / RFC 5176 Disconnect via a small listener daemon - Per-session rate limiting via the RADIUS mpd-limit VSA or a local ext-auth shaping backend (mpd5 ng_car) - Dual-stack IPv6: IPv6CP link-local, SLAAC via Router Advertisements, and static routed IPv6 prefixes per subscriber (Framed-IPv6-Route) - Live session table with per-session/user/AC disconnect - CARP high-availability interlock - Diagnostics: config validation, secret-masked config preview, secret-free support bundle and Prometheus-format metrics via configd Verified on OPNsense 26.7 / mpd5 5.9 with live PPPoE clients, a load generator and FreeRADIUS, including a two-node CARP handover. --- net/pppoe-server/Makefile | 9 + net/pppoe-server/pkg-descr | 13 + .../etc/inc/plugins.inc.d/pppoe_server.inc | 146 +++++++ net/pppoe-server/src/etc/rc.d/pppoe_server | 39 ++ .../src/etc/rc.d/pppoe_server_coa | 34 ++ .../src/etc/rc.syshook.d/carp/50-pppoe-server | 53 +++ .../PPPoEServer/Api/DiagnosticsController.php | 105 +++++ .../PPPoEServer/Api/ServiceController.php | 81 ++++ .../PPPoEServer/Api/SessionsController.php | 130 ++++++ .../PPPoEServer/Api/SettingsController.php | 263 ++++++++++++ .../OPNsense/PPPoEServer/IndexController.php | 46 +++ .../OPNsense/PPPoEServer/forms/dialogAC.xml | 75 ++++ .../OPNsense/PPPoEServer/forms/dialogPool.xml | 30 ++ .../PPPoEServer/forms/dialogRadius.xml | 42 ++ .../OPNsense/PPPoEServer/forms/dialogUser.xml | 54 +++ .../OPNsense/PPPoEServer/forms/general.xml | 176 ++++++++ .../models/OPNsense/PPPoEServer/ACL/ACL.xml | 24 ++ .../models/OPNsense/PPPoEServer/Menu/Menu.xml | 8 + .../OPNsense/PPPoEServer/PPPoEServer.php | 164 ++++++++ .../OPNsense/PPPoEServer/PPPoEServer.xml | 340 ++++++++++++++++ .../app/views/OPNsense/PPPoEServer/index.volt | 375 ++++++++++++++++++ .../OPNsense/PPPoEServer/coa_daemon.py | 264 ++++++++++++ .../OPNsense/PPPoEServer/diagnostics.py | 227 +++++++++++ .../OPNsense/PPPoEServer/expire_users.py | 146 +++++++ .../scripts/OPNsense/PPPoEServer/ext_auth.py | 125 ++++++ .../scripts/OPNsense/PPPoEServer/linkdown.sh | 10 + .../scripts/OPNsense/PPPoEServer/linkup.sh | 8 + .../OPNsense/PPPoEServer/pppoe_console.py | 286 +++++++++++++ .../OPNsense/PPPoEServer/radius_test.py | 173 ++++++++ .../conf/actions.d/actions_pppoe_server.conf | 126 ++++++ .../templates/OPNsense/PPPoEServer/+TARGETS | 8 + .../templates/OPNsense/PPPoEServer/coa.conf | 5 + .../OPNsense/PPPoEServer/console.auth | 3 + .../templates/OPNsense/PPPoEServer/mpd.conf | 117 ++++++ .../templates/OPNsense/PPPoEServer/mpd.secret | 7 + .../templates/OPNsense/PPPoEServer/rc.conf.d | 5 + .../OPNsense/PPPoEServer/rc.conf.d.coa | 7 + .../templates/OPNsense/PPPoEServer/users.ext | 6 + .../templates/OPNsense/PPPoEServer/users.meta | 6 + 39 files changed, 3736 insertions(+) create mode 100644 net/pppoe-server/Makefile create mode 100644 net/pppoe-server/pkg-descr create mode 100644 net/pppoe-server/src/etc/inc/plugins.inc.d/pppoe_server.inc create mode 100755 net/pppoe-server/src/etc/rc.d/pppoe_server create mode 100755 net/pppoe-server/src/etc/rc.d/pppoe_server_coa create mode 100755 net/pppoe-server/src/etc/rc.syshook.d/carp/50-pppoe-server create mode 100644 net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/DiagnosticsController.php create mode 100644 net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/ServiceController.php create mode 100644 net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/SessionsController.php create mode 100644 net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/SettingsController.php create mode 100644 net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/IndexController.php create mode 100644 net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogAC.xml create mode 100644 net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogPool.xml create mode 100644 net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogRadius.xml create mode 100644 net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogUser.xml create mode 100644 net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/general.xml create mode 100644 net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/ACL/ACL.xml create mode 100644 net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/Menu/Menu.xml create mode 100644 net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/PPPoEServer.php create mode 100644 net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/PPPoEServer.xml create mode 100644 net/pppoe-server/src/opnsense/mvc/app/views/OPNsense/PPPoEServer/index.volt create mode 100755 net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/coa_daemon.py create mode 100755 net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/diagnostics.py create mode 100755 net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/expire_users.py create mode 100755 net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/ext_auth.py create mode 100755 net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/linkdown.sh create mode 100755 net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/linkup.sh create mode 100755 net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/pppoe_console.py create mode 100755 net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/radius_test.py create mode 100644 net/pppoe-server/src/opnsense/service/conf/actions.d/actions_pppoe_server.conf create mode 100644 net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/+TARGETS create mode 100644 net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/coa.conf create mode 100644 net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/console.auth create mode 100644 net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/mpd.conf create mode 100644 net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/mpd.secret create mode 100644 net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/rc.conf.d create mode 100644 net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/rc.conf.d.coa create mode 100644 net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/users.ext create mode 100644 net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/users.meta diff --git a/net/pppoe-server/Makefile b/net/pppoe-server/Makefile new file mode 100644 index 0000000000..9cd98be6c9 --- /dev/null +++ b/net/pppoe-server/Makefile @@ -0,0 +1,9 @@ +PLUGIN_NAME= pppoe-server +PLUGIN_VERSION= 1.0 +PLUGIN_COMMENT= PPPoE Access Concentrator (BRAS) based on mpd5 +PLUGIN_DEPENDS= mpd5 +PLUGIN_MAINTAINER= dev@veqnora.com +PLUGIN_WWW= https://veqnora.com +PLUGIN_LICENSE= BSD2CLAUSE + +.include "../../Mk/plugins.mk" diff --git a/net/pppoe-server/pkg-descr b/net/pppoe-server/pkg-descr new file mode 100644 index 0000000000..72bc9d021c --- /dev/null +++ b/net/pppoe-server/pkg-descr @@ -0,0 +1,13 @@ +PPPoE Access Concentrator (BRAS/BNG) for OPNsense based on mpd5. + +Runs PPPoE server instances on physical or VLAN interfaces with PAP, CHAP +and MS-CHAPv2 authentication against a local user database or RADIUS, +IPv4 address pools and per-user static addresses, RADIUS accounting, a +live session table with disconnect, and service diagnostics. + +Also supports per-session rate limiting (via the RADIUS mpd-limit VSA or a +local shaping backend), RADIUS CoA / RFC 5176 Disconnect, dual-stack IPv6 +(IPv6CP link-local, SLAAC via Router Advertisements, and static routed IPv6 +prefixes per subscriber), and CARP high-availability interlock. + +Maintained by VEQNORA. diff --git a/net/pppoe-server/src/etc/inc/plugins.inc.d/pppoe_server.inc b/net/pppoe-server/src/etc/inc/plugins.inc.d/pppoe_server.inc new file mode 100644 index 0000000000..9e715be3c3 --- /dev/null +++ b/net/pppoe-server/src/etc/inc/plugins.inc.d/pppoe_server.inc @@ -0,0 +1,146 @@ +general->enabled == '1'; +} + +function pppoe_server_services() +{ + $services = []; + + if (pppoe_server_enabled()) { + $services[] = [ + 'description' => gettext('PPPoE Server'), + 'configd' => [ + 'restart' => ['pppoe_server restart'], + 'start' => ['pppoe_server start'], + 'stop' => ['pppoe_server stop'], + ], + 'name' => 'pppoe_server', + 'pidfile' => '/var/run/pppoe_server.pid', + ]; + + $model = new \OPNsense\PPPoEServer\PPPoEServer(); + if ((string)$model->radius->coa->enabled == '1') { + $services[] = [ + 'description' => gettext('PPPoE CoA listener'), + 'configd' => [ + 'restart' => ['pppoe_server coa_restart'], + 'start' => ['pppoe_server coa_start'], + 'stop' => ['pppoe_server coa_stop'], + ], + 'name' => 'pppoe_server_coa', + 'pidfile' => '/var/run/pppoe_server_coa.pid', + ]; + } + } + + return $services; +} + +/** + * expose the plugin configuration section for HA XMLRPC config sync + */ +function pppoe_server_xmlrpc_sync() +{ + $result = []; + + $result[] = [ + 'description' => gettext('PPPoE Server'), + 'section' => 'OPNsense.pppoeserver', + 'id' => 'pppoeserver', + 'services' => ['pppoe_server'], + ]; + + return $result; +} + +/** + * dedicated syslog target for session events emitted by link scripts + */ +function pppoe_server_syslog() +{ + $logfacilities = []; + + $logfacilities['pppoe'] = ['facility' => ['pppoe']]; + + return $logfacilities; +} + +/** + * expose the client networks as a virtual interface group so the + * administrator can attach firewall and NAT rules to it + */ +function pppoe_server_interfaces() +{ + $interfaces = []; + + if (!pppoe_server_enabled()) { + return $interfaces; + } + + $model = new \OPNsense\PPPoEServer\PPPoEServer(); + + $networks = []; + foreach ($model->pools->pool->iterateItems() as $pool) { + if ((string)$pool->enabled != '1') { + continue; + } + $start = ip2long((string)$pool->start); + $end = ip2long((string)$pool->end); + if ($start === false || $end === false) { + continue; + } + /* smallest common prefix covering the pool range */ + $mask = 32; + while ($mask > 0 && (($start & (-1 << (32 - $mask))) != ($end & (-1 << (32 - $mask))))) { + $mask--; + } + $networks[] = [ + 'network' => long2ip($start & (-1 << (32 - $mask))), + 'mask' => $mask, + ]; + } + + if (count($networks)) { + $interfaces['pppoe'] = [ + 'enable' => true, + 'virtual' => true, + 'networks' => $networks, + 'if' => 'pppoe', + 'descr' => 'PPPoE Clients', + 'type' => 'group', + ]; + } + + return $interfaces; +} diff --git a/net/pppoe-server/src/etc/rc.d/pppoe_server b/net/pppoe-server/src/etc/rc.d/pppoe_server new file mode 100755 index 0000000000..9c565c1e44 --- /dev/null +++ b/net/pppoe-server/src/etc/rc.d/pppoe_server @@ -0,0 +1,39 @@ +#!/bin/sh +# +# PROVIDE: pppoe_server +# REQUIRE: SERVERS +# KEYWORD: shutdown +# +# mpd5 wrapper for the PPPoE Access Concentrator plugin. +# Configuration is generated by configd templates into /usr/local/etc/pppoe_server. + +. /etc/rc.subr + +name=pppoe_server +rcvar=pppoe_server_enable + +load_rc_config ${name} + +: ${pppoe_server_enable:="NO"} + +confdir="/usr/local/etc/pppoe_server" +pidfile="/var/run/pppoe_server.pid" +command="/usr/local/sbin/mpd5" +command_args="-b -d ${confdir} -p ${pidfile} -s pppoe pppoe_server" +required_files="${confdir}/mpd.conf" +start_precmd="pppoe_server_precmd" + +pppoe_server_precmd() +{ + # both files carry secrets (user passwords, RADIUS shared secrets) + for f in mpd.secret mpd.conf console.auth users.meta users.ext; do + if [ -f "${confdir}/${f}" ]; then + chown root:wheel "${confdir}/${f}" + chmod 0600 "${confdir}/${f}" + fi + done + # drop expired accounts from mpd.secret before the daemon starts + /usr/local/opnsense/scripts/OPNsense/PPPoEServer/expire_users.py --prune-only > /dev/null 2>&1 || : +} + +run_rc_command "$1" diff --git a/net/pppoe-server/src/etc/rc.d/pppoe_server_coa b/net/pppoe-server/src/etc/rc.d/pppoe_server_coa new file mode 100755 index 0000000000..333a944c37 --- /dev/null +++ b/net/pppoe-server/src/etc/rc.d/pppoe_server_coa @@ -0,0 +1,34 @@ +#!/bin/sh +# +# PROVIDE: pppoe_server_coa +# REQUIRE: pppoe_server +# KEYWORD: shutdown +# +# RFC 5176 Disconnect/CoA adapter for the PPPoE Access Concentrator plugin. + +. /etc/rc.subr + +name=pppoe_server_coa +rcvar=pppoe_server_coa_enable + +load_rc_config ${name} + +: ${pppoe_server_coa_enable:="NO"} + +pidfile="/var/run/pppoe_server_coa.pid" +command="/usr/sbin/daemon" +coa_script="/usr/local/opnsense/scripts/OPNsense/PPPoEServer/coa_daemon.py" +command_args="-f -P ${pidfile} -r -t pppoe_server_coa ${coa_script}" +required_files="/usr/local/etc/pppoe_server/coa.conf" +start_precmd="pppoe_server_coa_precmd" + +pppoe_server_coa_precmd() +{ + # the config carries the CoA shared secret + if [ -f "/usr/local/etc/pppoe_server/coa.conf" ]; then + chown root:wheel "/usr/local/etc/pppoe_server/coa.conf" + chmod 0600 "/usr/local/etc/pppoe_server/coa.conf" + fi +} + +run_rc_command "$1" diff --git a/net/pppoe-server/src/etc/rc.syshook.d/carp/50-pppoe-server b/net/pppoe-server/src/etc/rc.syshook.d/carp/50-pppoe-server new file mode 100755 index 0000000000..dd950cd0a3 --- /dev/null +++ b/net/pppoe-server/src/etc/rc.syshook.d/carp/50-pppoe-server @@ -0,0 +1,53 @@ +#!/usr/local/bin/php +general->enabled != '1' || + (string)$model->general->carpdependent != '1' +) { + exit(0); +} + +$actions = [ + 'MASTER' => 'start', + 'BACKUP' => 'stop', +]; + +mwexecfm('/usr/local/etc/rc.d/pppoe_server ' . $actions[$type]); diff --git a/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/DiagnosticsController.php b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/DiagnosticsController.php new file mode 100644 index 0000000000..9256ca0728 --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/DiagnosticsController.php @@ -0,0 +1,105 @@ +configdRun('pppoe_server ' . $action); + $payload = json_decode((string)$response, true); + return is_array($payload) ? $payload : ['status' => 'failed']; + } + + public function versionsAction() + { + return $this->jsonAction('versions'); + } + + public function validateAction() + { + return $this->jsonAction('validate'); + } + + public function netgraphAction() + { + return $this->jsonAction('netgraph_status'); + } + + public function configPreviewAction() + { + return $this->jsonAction('config_preview'); + } + + public function poolStatusAction() + { + return $this->jsonAction('pool_status'); + } + + public function supportBundleAction() + { + return $this->jsonAction('support_bundle'); + } + + /** + * probe all configured RADIUS servers (POST: sends network traffic) + * @return array + */ + public function radiusTestAction() + { + if (!$this->request->isPost()) { + return ['status' => 'failed', 'message' => gettext('Invalid request.')]; + } + return $this->jsonAction('radius_test'); + } + + /** + * Prometheus text exposition + * @return string + */ + public function metricsAction() + { + $response = (new Backend())->configdRun('pppoe_server metrics'); + $this->response->setContentType('text/plain', 'UTF-8'); + $this->response->setContent((string)$response); + return $this->response; + } +} diff --git a/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/ServiceController.php b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/ServiceController.php new file mode 100644 index 0000000000..77a6716be2 --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/ServiceController.php @@ -0,0 +1,81 @@ +request->isPost()) { + $mdl = new PPPoEServer(); + if ((string)$mdl->general->consolepass == '') { + // mpd5 stores the console password in a 32-byte buffer + // (console.h: char password[32]); keep well under 31 chars + $mdl->general->consolepass = bin2hex(random_bytes(14)); + $mdl->serializeToConfig(false, true); + Config::getInstance()->save(); + } + } + $result = parent::reconfigureAction(); + if ($this->request->isPost()) { + // (de)register the dynamic 'pppoe' interface group so the + // administrator can target it with firewall rules immediately, + // without waiting for a reboot + $backend = new Backend(); + $backend->configdRun('interface invoke registration'); + // bring the optional CoA listener in line with its config + $mdl = new PPPoEServer(); + if ((string)$mdl->general->enabled == '1' && (string)$mdl->radius->coa->enabled == '1') { + $backend->configdRun('pppoe_server coa_restart'); + } else { + $backend->configdRun('pppoe_server coa_stop'); + } + } + return $result; + } +} diff --git a/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/SessionsController.php b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/SessionsController.php new file mode 100644 index 0000000000..3112f9cc1d --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/SessionsController.php @@ -0,0 +1,130 @@ +configdRun('pppoe_server sessions'); + $payload = json_decode((string)$response, true); + if (!is_array($payload) || ($payload['status'] ?? '') != 'ok') { + return null; + } + return $payload['sessions'] ?? []; + } + + /** + * bootgrid compatible list of active sessions + * @return array + */ + public function searchAction() + { + $sessions = $this->fetchSessions(); + if ($sessions === null) { + return ['rows' => [], 'rowCount' => 0, 'total' => 0, 'current' => 1, 'status' => 'failed']; + } + return [ + 'rows' => $sessions, + 'rowCount' => count($sessions), + 'total' => count($sessions), + 'current' => 1, + 'status' => 'ok', + ]; + } + + /** + * disconnect a single session by its session id + * @return array + */ + public function disconnectAction() + { + if (!$this->request->isPost() || !$this->request->hasPost('session_id')) { + return ['status' => 'failed', 'message' => gettext('Invalid request.')]; + } + $sessionId = (string)$this->request->getPost('session_id'); + if (!preg_match(self::SESSION_ID_PATTERN, $sessionId)) { + return ['status' => 'failed', 'message' => gettext('Invalid session id.')]; + } + $response = (new Backend())->configdpRun('pppoe_server disconnect', [$sessionId]); + $payload = json_decode((string)$response, true); + return is_array($payload) ? $payload : ['status' => 'failed']; + } + + /** + * disconnect all sessions on an access concentrator (bundle name) + * @return array + */ + public function disconnectAcAction() + { + if (!$this->request->isPost() || !$this->request->hasPost('bundle')) { + return ['status' => 'failed', 'message' => gettext('Invalid request.')]; + } + $bundle = (string)$this->request->getPost('bundle'); + if (!preg_match(self::BUNDLE_PATTERN, $bundle)) { + return ['status' => 'failed', 'message' => gettext('Invalid bundle name.')]; + } + $response = (new Backend())->configdpRun('pppoe_server disconnect_ac', [$bundle]); + $payload = json_decode((string)$response, true); + return is_array($payload) ? $payload : ['status' => 'failed']; + } + + /** + * disconnect all sessions belonging to a username + * @return array + */ + public function disconnectUserAction() + { + if (!$this->request->isPost() || !$this->request->hasPost('username')) { + return ['status' => 'failed', 'message' => gettext('Invalid request.')]; + } + $username = (string)$this->request->getPost('username'); + if (!preg_match(self::USERNAME_PATTERN, $username)) { + return ['status' => 'failed', 'message' => gettext('Invalid username.')]; + } + $response = (new Backend())->configdpRun('pppoe_server disconnect_user', [$username]); + $payload = json_decode((string)$response, true); + return is_array($payload) ? $payload : ['status' => 'failed']; + } +} diff --git a/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/SettingsController.php b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/SettingsController.php new file mode 100644 index 0000000000..60de045bfb --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/Api/SettingsController.php @@ -0,0 +1,263 @@ +request->getPost(static::$internalModelName); + if ( + is_array($post) && + isset($post['radius']['coa']['secret']) && + $post['radius']['coa']['secret'] === '' + ) { + $current = (string)$this->getModel()->radius->coa->secret; + if ($current !== '') { + $post['radius']['coa']['secret'] = $current; + $_POST[static::$internalModelName] = $post; + } + } + return parent::setAction(); + } + + /* access concentrators */ + + public function searchAcAction() + { + return $this->searchBase( + 'acs.ac', + ['enabled', 'description', 'acname', 'interface', 'gateway'], + 'description' + ); + } + + public function getAcAction($uuid = null) + { + return $this->getBase('ac', 'acs.ac', $uuid); + } + + public function addAcAction() + { + return $this->addBase('ac', 'acs.ac'); + } + + public function setAcAction($uuid) + { + return $this->setBase('ac', 'acs.ac', $uuid); + } + + public function delAcAction($uuid) + { + return $this->delBase('acs.ac', $uuid); + } + + public function toggleAcAction($uuid, $enabled = null) + { + return $this->toggleBase('acs.ac', $uuid, $enabled); + } + + /* address pools */ + + public function searchPoolAction() + { + return $this->searchBase( + 'pools.pool', + ['enabled', 'name', 'description', 'start', 'end'], + 'name' + ); + } + + public function getPoolAction($uuid = null) + { + return $this->getBase('pool', 'pools.pool', $uuid); + } + + public function addPoolAction() + { + return $this->addBase('pool', 'pools.pool'); + } + + public function setPoolAction($uuid) + { + return $this->setBase('pool', 'pools.pool', $uuid); + } + + public function delPoolAction($uuid) + { + return $this->delBase('pools.pool', $uuid); + } + + public function togglePoolAction($uuid, $enabled = null) + { + return $this->toggleBase('pools.pool', $uuid, $enabled); + } + + /* RADIUS servers -- shared secret is never returned by the API */ + + public function searchRadiusAction() + { + return $this->searchBase( + 'radius.server', + ['enabled', 'description', 'host', 'authport', 'acctport', 'priority'], + 'priority' + ); + } + + public function getRadiusAction($uuid = null) + { + $result = $this->getBase('server', 'radius.server', $uuid); + if (isset($result['server']['secret'])) { + $result['server']['secret'] = ''; + } + return $result; + } + + public function addRadiusAction() + { + return $this->addBase('server', 'radius.server'); + } + + public function setRadiusAction($uuid) + { + // an empty posted secret means "keep the stored one" + $post = $this->request->getPost('server'); + if (is_array($post) && isset($post['secret']) && $post['secret'] === '') { + $node = $this->getModel()->getNodeByReference('radius.server.' . $uuid); + if ($node != null) { + $post['secret'] = (string)$node->secret; + $_POST['server'] = $post; + } + } + return $this->setBase('server', 'radius.server', $uuid); + } + + public function delRadiusAction($uuid) + { + return $this->delBase('radius.server', $uuid); + } + + public function toggleRadiusAction($uuid, $enabled = null) + { + return $this->toggleBase('radius.server', $uuid, $enabled); + } + + /* local users -- password is never returned by the API */ + + public function searchUserAction() + { + return $this->searchBase( + 'users.user', + ['enabled', 'username', 'description', 'staticip'], + 'username' + ); + } + + public function getUserAction($uuid = null) + { + $result = $this->getBase('user', 'users.user', $uuid); + if (isset($result['user']['password'])) { + $result['user']['password'] = ''; + } + return $result; + } + + public function addUserAction() + { + return $this->addBase('user', 'users.user'); + } + + public function setUserAction($uuid) + { + // an empty posted password means "keep the stored one" + $post = $this->request->getPost('user'); + if (is_array($post) && isset($post['password']) && $post['password'] === '') { + $node = $this->getModel()->getNodeByReference('users.user.' . $uuid); + if ($node != null) { + $post['password'] = (string)$node->password; + $_POST['user'] = $post; + } + } + return $this->setBase('user', 'users.user', $uuid); + } + + public function delUserAction($uuid) + { + return $this->delBase('users.user', $uuid); + } + + public function toggleUserAction($uuid, $enabled = null) + { + return $this->toggleBase('users.user', $uuid, $enabled); + } +} diff --git a/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/IndexController.php b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/IndexController.php new file mode 100644 index 0000000000..46003a7911 --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/IndexController.php @@ -0,0 +1,46 @@ +view->generalForm = $this->getForm('general'); + $this->view->formDialogAC = $this->getForm('dialogAC'); + $this->view->formDialogPool = $this->getForm('dialogPool'); + $this->view->formDialogUser = $this->getForm('dialogUser'); + $this->view->formDialogRadius = $this->getForm('dialogRadius'); + $this->view->pick('OPNsense/PPPoEServer/index'); + } +} diff --git a/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogAC.xml b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogAC.xml new file mode 100644 index 0000000000..da5effeaf4 --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogAC.xml @@ -0,0 +1,75 @@ +
+ + ac.enabled + + checkbox + + + ac.description + + text + + + ac.interface + + dropdown + Interface (physical or VLAN) the concentrator listens on for PPPoE discovery. + + + ac.acname + + text + Access concentrator name announced in PADO. Empty uses the system default. + + + ac.servicename + + text + PPPoE service name for this concentrator. Empty accepts any. + + + ac.pool + + dropdown + IPv4 pool used for dynamic client addressing. + + + ac.gateway + + text + Local IPv4 address of the PPP links (must be outside the pool). + + + ac.mtu + + text + true + + + ac.mru + + text + true + + + ac.maxsessions + + text + true + Session cap for this concentrator. Empty uses the global maximum. + + + ac.idletimeout + + text + true + Disconnect after this many seconds without traffic; 0 disables. + + + ac.sessiontimeout + + text + true + Hard session lifetime limit; 0 disables. + +
diff --git a/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogPool.xml b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogPool.xml new file mode 100644 index 0000000000..74dbf67000 --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogPool.xml @@ -0,0 +1,30 @@ +
+ + pool.enabled + + checkbox + + + pool.name + + text + Unique pool identifier (letters, digits, dash, underscore). + + + pool.description + + text + + + pool.start + + text + First IPv4 address handed out to clients. + + + pool.end + + text + Last IPv4 address handed out to clients. + +
diff --git a/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogRadius.xml b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogRadius.xml new file mode 100644 index 0000000000..09d05ea8d0 --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogRadius.xml @@ -0,0 +1,42 @@ +
+ + server.enabled + + checkbox + + + server.description + + text + + + server.host + + text + IPv4 address of the RADIUS server. + + + server.secret + + password + Stored secrets are never shown. Leave empty when editing to keep the current secret. + + + server.authport + + text + UDP port for Access-Request (0 disables authentication against this server). + + + server.acctport + + text + UDP port for Accounting-Request (0 disables accounting against this server). + + + server.priority + + text + 0 is queried first; higher values act as fallback servers. + +
diff --git a/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogUser.xml b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogUser.xml new file mode 100644 index 0000000000..bc13a6fa54 --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/dialogUser.xml @@ -0,0 +1,54 @@ +
+ + user.enabled + + checkbox + + + user.username + + text + Allowed characters: letters, digits and . _ @ - + + + user.password + + password + Stored passwords are never shown. Leave empty when editing to keep the current password. + + + user.description + + text + + + user.staticip + + text + Optional fixed address for this user; must not overlap any dynamic pool. + + + user.downlimit + + text + Rate limit towards the subscriber; 0 = unlimited. Requires "Local user shaping" in General. + + + user.uplimit + + text + Rate limit from the subscriber; 0 = unlimited. Requires "Local user shaping" in General. + + + user.ip6route + + text + Optional static IPv6 prefix (e.g. 2001:db8:1234::/56) routed towards this subscriber. Requires IPv6 and "Local user shaping" in General. + + + user.expires + + text + Optional YYYY-MM-DD date. From this day the account no longer authenticates and its active sessions are disconnected by the scheduled expiry job (see documentation for enabling the cron job). + +
diff --git a/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/general.xml b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/general.xml new file mode 100644 index 0000000000..0b5d6c774b --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/controllers/OPNsense/PPPoEServer/forms/general.xml @@ -0,0 +1,176 @@ +
+ + pppoeserver.general.enabled + + checkbox + Enable the PPPoE Access Concentrator service. Configuration changes restart the daemon and drop active sessions (mpd5 cannot reload). + + + pppoeserver.general.authmethod + + dropdown + Authentication protocol offered to clients. + + + pppoeserver.general.servicename + + text + true + Default PPPoE service name. Empty accepts any service name requested by the client. + + + pppoeserver.general.mtu + + text + true + Default MTU for PPP links (1492 for standard PPPoE). + + + pppoeserver.general.mru + + text + true + Default MRU for PPP links. + + + pppoeserver.general.ipv6 + + checkbox + Negotiate IPv6CP so sessions get an IPv6 link-local address. Hand out a global prefix with Router Advertisements (Services → Router Advertisements) on the PPPoE interface group for SLAAC. + + + pppoeserver.general.dns1 + + text + Primary DNS server handed to clients via IPCP. + + + pppoeserver.general.dns2 + + text + Secondary DNS server handed to clients via IPCP. + + + pppoeserver.general.maxsessions + + text + true + Global cap on concurrent PPPoE sessions. + + + pppoeserver.general.maxsessionsperuser + + text + true + Maximum simultaneous logins per username. + + + pppoeserver.general.carpdependent + + checkbox + Run the PPPoE server only while this node is CARP MASTER: the service stops on transition to BACKUP and starts on transition to MASTER. PPPoE sessions are not synchronized between nodes — clients reconnect after a failover. + + + pppoeserver.general.radiusauth + + checkbox + Authenticate PPPoE clients against the configured RADIUS servers instead of only the local user database. + + + pppoeserver.general.radiusacct + + checkbox + Send Accounting-Start/Interim/Stop records to the configured RADIUS servers. + + + pppoeserver.general.acctinterval + + text + true + Interval for Accounting Interim-Update records; 0 disables interim updates. + + + pppoeserver.radius.options.timeout + + text + true + Timeout per RADIUS request. + + + pppoeserver.radius.options.retries + + text + true + Retries per RADIUS server before trying the next one. + + + pppoeserver.radius.options.srcaddr + + text + true + Source IPv4 address for RADIUS requests on multihomed systems. + + + pppoeserver.radius.options.nasip + + text + true + Value for the NAS-IP-Address attribute. Empty uses the system default. + + + pppoeserver.radius.options.nasid + + text + true + Value for the NAS-Identifier attribute. Empty uses the local hostname. + + + pppoeserver.radius.options.messageauth + + checkbox + true + Add the Message-Authenticator attribute to Access-Requests (recommended). + + + pppoeserver.general.localshaping + + checkbox + true + Authenticate local users through an external-auth backend instead of the secrets file, so per-user upload/download limits (set on each Local User) are applied via mpd5 rate limiters. Leave off if you do not shape local users; RADIUS shaping works independently. + + + pppoeserver.radius.coa.enabled + + checkbox + true + Enable the RFC 5176 listener so a RADIUS server or billing system can disconnect active subscribers. Requires a shared secret and an allowed-client list below. + + + pppoeserver.radius.coa.port + + text + true + UDP port for Disconnect/CoA requests (default 3799). + + + pppoeserver.radius.coa.secret + + password + true + Shared secret used to authenticate Disconnect requests. + + + pppoeserver.radius.coa.allowedclients + + text + true + Comma-separated list of source IPv4/IPv6 addresses or CIDRs allowed to send Disconnect requests. Requests from any other source are dropped. + + + pppoeserver.general.loglevel + + dropdown + true + Daemon log verbosity (debug enables per-protocol tracing). + +
diff --git a/net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/ACL/ACL.xml b/net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/ACL/ACL.xml new file mode 100644 index 0000000000..baf0e5eda1 --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/ACL/ACL.xml @@ -0,0 +1,24 @@ + + + Services: PPPoE Server + + ui/pppoeserver/* + api/pppoeserver/settings/* + api/pppoeserver/service/status + api/pppoeserver/sessions/search + api/pppoeserver/diagnostics/* + + + + Services: PPPoE Server: disconnect sessions + + api/pppoeserver/sessions/* + + + + Services: PPPoE Server: control service + + api/pppoeserver/service/* + + + diff --git a/net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/Menu/Menu.xml b/net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/Menu/Menu.xml new file mode 100644 index 0000000000..73ec958260 --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/Menu/Menu.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/PPPoEServer.php b/net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/PPPoEServer.php new file mode 100644 index 0000000000..04ee3afcc0 --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/PPPoEServer.php @@ -0,0 +1,164 @@ +pools->pool->iterateItems() as $uuid => $pool) { + $start = ip2long((string)$pool->start); + $end = ip2long((string)$pool->end); + if ($start === false || $end === false) { + continue; // base validators already flagged the field + } + if ($start > $end) { + $messages->appendMessage(new Message( + gettext('Pool start address is higher than the end address.'), + $pool->start->__reference + )); + continue; + } + $pools[$uuid] = [ + 'name' => (string)$pool->name, + 'enabled' => (string)$pool->enabled == '1', + 'start' => $start, + 'end' => $end, + 'ref' => $pool->start->__reference, + ]; + } + + // overlap detection between enabled pools + $seen = []; + foreach ($pools as $uuid => $pool) { + if (!$pool['enabled']) { + continue; + } + foreach ($seen as $other) { + if ($pool['start'] <= $other['end'] && $other['start'] <= $pool['end']) { + $messages->appendMessage(new Message( + sprintf(gettext('Pool range overlaps with pool "%s".'), $other['name']), + $pool['ref'] + )); + break; + } + } + $seen[] = $pool; + } + + // AC checks: gateway must not fall inside the attached pool + foreach ($this->acs->ac->iterateItems() as $ac) { + $gateway = ip2long((string)$ac->gateway); + $poolUuid = (string)$ac->pool; + if ($gateway !== false && isset($pools[$poolUuid])) { + if ($gateway >= $pools[$poolUuid]['start'] && $gateway <= $pools[$poolUuid]['end']) { + $messages->appendMessage(new Message( + gettext('Gateway address must be outside of the attached address pool.'), + $ac->gateway->__reference + )); + } + if ((string)$ac->enabled == '1' && !$pools[$poolUuid]['enabled']) { + $messages->appendMessage(new Message( + gettext('Attached address pool is disabled.'), + $ac->pool->__reference + )); + } + } + } + + // user checks: unique usernames, static address not inside any enabled pool + $usernames = []; + foreach ($this->users->user->iterateItems() as $user) { + $username = (string)$user->username; + if ($username != '') { + if (isset($usernames[$username])) { + $messages->appendMessage(new Message( + gettext('Username is already in use.'), + $user->username->__reference + )); + } + $usernames[$username] = true; + } + $staticip = ip2long((string)$user->staticip); + if ($staticip !== false) { + foreach ($pools as $pool) { + if ($pool['enabled'] && $staticip >= $pool['start'] && $staticip <= $pool['end']) { + $messages->appendMessage(new Message( + sprintf(gettext('Static address collides with dynamic pool "%s".'), $pool['name']), + $user->staticip->__reference + )); + break; + } + } + } + } + + // RADIUS CoA: a shared secret is required when the listener is enabled + if ((string)$this->radius->coa->enabled == '1' && (string)$this->radius->coa->secret == '') { + $messages->appendMessage(new Message( + gettext('A CoA shared secret is required when the CoA listener is enabled.'), + $this->radius->coa->secret->__reference + )); + } + + // RADIUS: when enabled at least one active server must exist + if ((string)$this->general->radiusauth == '1' || (string)$this->general->radiusacct == '1') { + $active = 0; + foreach ($this->radius->server->iterateItems() as $server) { + if ((string)$server->enabled == '1') { + $active++; + } + } + if ($active == 0) { + $messages->appendMessage(new Message( + gettext('RADIUS is enabled but no active RADIUS server is configured.'), + $this->general->radiusauth->__reference + )); + } + } + + return $messages; + } +} diff --git a/net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/PPPoEServer.xml b/net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/PPPoEServer.xml new file mode 100644 index 0000000000..b389c06df9 --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/models/OPNsense/PPPoEServer/PPPoEServer.xml @@ -0,0 +1,340 @@ + + //OPNsense/pppoeserver + 0.0.1 + PPPoE Access Concentrator settings + + + + 0 + Y + + + /^[0-9a-zA-Z._-]{0,64}$/ + Service name may only contain letters, digits, dots, dashes and underscores (max 64). + + + chap + Y + + CHAP + PAP + PAP + CHAP + + + + 1492 + Y + 576 + 9000 + MTU must be between 576 and 9000. + + + 1492 + Y + 576 + 9000 + MRU must be between 576 and 9000. + + + 0 + Y + + + N + ipv4 + + + N + ipv4 + + + 256 + Y + 1 + 4096 + Maximum sessions must be between 1 and 4096. + + + 1 + Y + 1 + 64 + Sessions per user must be between 1 and 64. + + + 0 + Y + + + 0 + Y + + + 0 + Y + 0 + 86400 + Interim accounting interval must be between 0 (off) and 86400 seconds. + + + 0 + Y + + + 0 + Y + + + 5005 + Y + 1024 + 65535 + Console port must be between 1024 and 65535. + + + /^[0-9a-f]{0,64}$/ + Console password is managed automatically. + + + info + Y + + Error + Warning + Info + Debug + + + + + + + 1 + Y + + + Y + /^[0-9a-zA-Z_-]{1,32}$/ + Pool name may only contain letters, digits, dashes and underscores (max 32). + + + + Y + N + ipv4 + + + Y + N + ipv4 + + + + + + + 1 + Y + + + + /^[0-9a-zA-Z._-]{0,32}$/ + AC name may only contain letters, digits, dots, dashes and underscores (max 32). + + + /^[0-9a-zA-Z._-]{0,64}$/ + Service name may only contain letters, digits, dots, dashes and underscores (max 64). + + + Y + + /^(?!0).*$/ + + + + Y + + + OPNsense.PPPoEServer.PPPoEServer + pools.pool + name + + + Related pool not found. + + + Y + N + ipv4 + + + 576 + 9000 + MTU must be between 576 and 9000. + + + 576 + 9000 + MRU must be between 576 and 9000. + + + 1 + 4096 + Maximum sessions must be between 1 and 4096. + + + 0 + Y + 0 + 86400 + Idle timeout must be between 0 (off) and 86400 seconds. + + + 0 + Y + 0 + 604800 + Session timeout must be between 0 (off) and 604800 seconds. + + + + + + + 5 + Y + 1 + 60 + Timeout must be between 1 and 60 seconds. + + + 3 + Y + 1 + 10 + Retries must be between 1 and 10. + + + N + ipv4 + + + N + ipv4 + + + /^[0-9a-zA-Z._-]{0,64}$/ + NAS-Identifier may only contain letters, digits, dots, dashes and underscores (max 64). + + + 1 + Y + + + + + 0 + Y + + + 3799 + Y + 1024 + 65535 + CoA port must be between 1024 and 65535. + + + /^[^"'\s]{0,64}$/ + CoA shared secret may not contain spaces or quotes (max 64). + + + /^[0-9a-fA-F:.,\/ ]{0,255}$/ + Allowed clients must be a comma-separated list of IPv4/IPv6 addresses or CIDRs. + + + + + 1 + Y + + + + Y + N + ipv4 + + + Y + /^[^"'\s]{1,64}$/ + Shared secret may not contain spaces or quotes (max 64 characters). + + + 1812 + Y + 0 + 65535 + Authentication port must be between 0 (disabled) and 65535. + + + 1813 + Y + 0 + 65535 + Accounting port must be between 0 (disabled) and 65535. + + + 0 + Y + 0 + 9 + Priority must be between 0 (primary) and 9. + + + + + + + 1 + Y + + + Y + /^[0-9a-zA-Z._@-]{1,64}$/ + Username may only contain letters, digits and . _ @ - (max 64 characters). + + + Y + /^[^"\r\n]{1,128}$/ + Password may not contain double quotes or line breaks (max 128 characters). + + + + N + ipv4 + + + 0 + Y + 0 + 10000000 + Download limit must be 0 (unlimited) or a value in kbit/s. + + + 0 + Y + 0 + 10000000 + Upload limit must be 0 (unlimited) or a value in kbit/s. + + + /^([0-9a-fA-F:]+\/[0-9]{1,3})?$/ + Routed IPv6 prefix must be a CIDR such as 2001:db8:1234::/56. + + + /^([0-9]{4}-[0-9]{2}-[0-9]{2})?$/ + Expiration date must use YYYY-MM-DD format. + + + + + diff --git a/net/pppoe-server/src/opnsense/mvc/app/views/OPNsense/PPPoEServer/index.volt b/net/pppoe-server/src/opnsense/mvc/app/views/OPNsense/PPPoEServer/index.volt new file mode 100644 index 0000000000..18f43a3e32 --- /dev/null +++ b/net/pppoe-server/src/opnsense/mvc/app/views/OPNsense/PPPoEServer/index.volt @@ -0,0 +1,375 @@ +{# + # Copyright (C) 2026 VEQNORA + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without modification, + # are permitted provided that the following conditions are met: + # + # 1. Redistributions of source code must retain the above copyright notice, + # this list of conditions and the following disclaimer. + # + # 2. Redistributions in binary form must reproduce the above copyright notice, + # this list of conditions and the following disclaimer in the documentation + # and/or other materials provided with the distribution. + # + # THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + # AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + # OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + # POSSIBILITY OF SUCH DAMAGE. + #} + + + +
+
+ {{ partial("layout_partials/base_form",['fields':generalForm,'id':'frm_general_settings']) }} +
+
+ + + + + + + + + + + + + + + + + + + +
{{ lang._('Enabled') }}{{ lang._('Description') }}{{ lang._('Interface') }}{{ lang._('AC name') }}{{ lang._('Gateway') }}{{ lang._('Commands') }}{{ lang._('ID') }}
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + +
{{ lang._('Enabled') }}{{ lang._('Name') }}{{ lang._('Start') }}{{ lang._('End') }}{{ lang._('Description') }}{{ lang._('Commands') }}{{ lang._('ID') }}
+ + +
+
+
+ + + + + + + + + + + + + + + + + + +
{{ lang._('Enabled') }}{{ lang._('Username') }}{{ lang._('Static IP') }}{{ lang._('Description') }}{{ lang._('Commands') }}{{ lang._('ID') }}
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
{{ lang._('Enabled') }}{{ lang._('Server') }}{{ lang._('Auth port') }}{{ lang._('Acct port') }}{{ lang._('Priority') }}{{ lang._('Description') }}{{ lang._('Commands') }}{{ lang._('ID') }}
+ + +
+
+
+ + + + + + + + + + + + + + + + +
{{ lang._('Username') }}{{ lang._('Session ID') }}{{ lang._('PPP interface') }}{{ lang._('Client IP') }}{{ lang._('Client MAC') }}{{ lang._('Bundle') }}{{ lang._('Uptime') }}{{ lang._('In') }}{{ lang._('Out') }}{{ lang._('Commands') }}
+
+ +
+
+
+
+

{{ lang._('Environment') }}

+
{{ lang._('loading...') }}
+

{{ lang._('Configuration check') }}

+
{{ lang._('loading...') }}
+

{{ lang._('Address pool usage') }}

+
{{ lang._('loading...') }}
+

{{ lang._('Netgraph nodes') }}

+
{{ lang._('loading...') }}
+

{{ lang._('Generated configuration (secrets masked)') }}

+
{{ lang._('loading...') }}
+

{{ lang._('RADIUS test') }}

+ + +

+ + {{ lang._('Prometheus metrics endpoint:') }} /api/pppoeserver/diagnostics/metrics · + {{ lang._('Support bundle (secret-free):') }} /api/pppoeserver/diagnostics/supportBundle + +

+
+
+
+ +
+ +

+
+
+ +{{ partial("layout_partials/base_dialog",['fields':formDialogAC,'id':'DialogAC','label':lang._('Edit access concentrator')]) }} +{{ partial("layout_partials/base_dialog",['fields':formDialogPool,'id':'DialogPool','label':lang._('Edit address pool')]) }} +{{ partial("layout_partials/base_dialog",['fields':formDialogUser,'id':'DialogUser','label':lang._('Edit local user')]) }} +{{ partial("layout_partials/base_dialog",['fields':formDialogRadius,'id':'DialogRadius','label':lang._('Edit RADIUS server')]) }} + + diff --git a/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/coa_daemon.py b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/coa_daemon.py new file mode 100755 index 0000000000..16ddc2c2b0 --- /dev/null +++ b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/coa_daemon.py @@ -0,0 +1,264 @@ +#!/usr/local/bin/python3 + +""" + Copyright (C) 2026 VEQNORA + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + RFC 5176 Disconnect-Message adapter for mpd5. mpd5 has no native CoA / + Disconnect listener, so this small daemon speaks the RADIUS Dynamic + Authorization protocol and translates a Disconnect-Request into an mpd5 + control-console session close (re-using the same console client the GUI + uses). It answers Disconnect-ACK / Disconnect-NAK with the correct + response authenticator and honours a source allowlist and shared secret. + + Sessions are matched (in RFC-preferred order) by Acct-Session-Id, then + User-Name, then Framed-IP-Address. CoA-Request (reauthorize) is answered + with CoA-NAK / Unsupported-Service because mpd5 cannot re-apply attributes + to a live session; only Disconnect is actioned. +""" + +import hashlib +import hmac +import ipaddress +import importlib.util +import os +import socket +import struct +import sys + +CONF = '/usr/local/etc/pppoe_server/coa.conf' + +_spec = importlib.util.spec_from_file_location( + 'pppoe_console', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pppoe_console.py')) +pppoe_console = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(pppoe_console) + +# RADIUS Dynamic Authorization codes (RFC 5176 / RFC 3576) +DISCONNECT_REQUEST = 40 +DISCONNECT_ACK = 41 +DISCONNECT_NAK = 42 +COA_REQUEST = 43 +COA_ACK = 44 +COA_NAK = 45 + +ATTR_USER_NAME = 1 +ATTR_NAS_IP_ADDRESS = 4 +ATTR_FRAMED_IP_ADDRESS = 8 +ATTR_ACCT_SESSION_ID = 44 +ATTR_MESSAGE_AUTHENTICATOR = 80 +ATTR_ERROR_CAUSE = 101 + +# Error-Cause values (RFC 5176 section 3.5) +CAUSE_SESSION_NOT_FOUND = 503 +CAUSE_UNSUPPORTED_SERVICE = 405 + + +def read_conf(path=CONF): + cfg = {'port': 3799, 'secret': b'', 'allowed': []} + with open(path, 'r', encoding='ascii') as fh: + for line in fh: + line = line.strip() + if not line or line.startswith('#') or '=' not in line: + continue + key, val = line.split('=', 1) + key, val = key.strip(), val.strip() + if key == 'port': + cfg['port'] = int(val) + elif key == 'secret': + cfg['secret'] = val.encode() + elif key == 'allowed': + for item in val.split(','): + item = item.strip() + if item: + cfg['allowed'].append(ipaddress.ip_network(item, strict=False)) + return cfg + + +def parse_attributes(data): + """return list of (type, value_bytes) from a RADIUS attribute blob""" + attrs = [] + i = 0 + while i + 2 <= len(data): + atype = data[i] + alen = data[i + 1] + if alen < 2 or i + alen > len(data): + break + attrs.append((atype, data[i + 2:i + alen])) + i += alen + return attrs + + +def verify_request(packet, secret): + """ + validate the Request Authenticator of a Disconnect/CoA request + (RFC 5176 2.3): MD5(Code+ID+Length+RequestAuth(as received) is wrong; + it is MD5(Code+ID+Length+16 zero octets replaced by request auth? )). + Correct: the Request Authenticator = MD5(Code+ID+Length+Attributes+Secret) + computed with the Authenticator field itself zeroed. + """ + if len(packet) < 20: + return False + code, ident, length = struct.unpack('!BBH', packet[:4]) + authenticator = packet[4:20] + attrs = packet[20:length] if length <= len(packet) else packet[20:] + zeroed = packet[:4] + (b'\x00' * 16) + attrs + expected = hashlib.md5(zeroed + secret).digest() + if not hmac.compare_digest(expected, authenticator): + return False + # optional Message-Authenticator + for atype, val in parse_attributes(attrs): + if atype == ATTR_MESSAGE_AUTHENTICATOR: + probe = bytearray(packet[:length]) + # zero the Message-Authenticator in place, then HMAC-MD5 + idx = probe.find(bytes([ATTR_MESSAGE_AUTHENTICATOR, 18])) + if idx >= 0: + for j in range(idx + 2, idx + 18): + probe[j] = 0 + mac = hmac.new(secret, bytes(probe), hashlib.md5).digest() + if not hmac.compare_digest(mac, val): + return False + return True + + +def build_response(code, ident, request_authenticator, secret, error_cause=None): + attrs = b'' + if error_cause is not None: + attrs += struct.pack('!BBI', ATTR_ERROR_CAUSE, 6, error_cause) + length = 20 + len(attrs) + header = struct.pack('!BBH', code, ident, length) + resp_auth = hashlib.md5(header + request_authenticator + attrs + secret).digest() + return header + resp_auth + attrs + + +def find_session(sessions, wanted): + if wanted.get('session_id'): + for s in sessions: + if s['session_id'] == wanted['session_id']: + return s + if wanted.get('username'): + for s in sessions: + if s['username'] == wanted['username']: + return s + if wanted.get('address'): + for s in sessions: + if s['address'] == wanted['address']: + return s + return None + + +def disconnect(session): + """close a session via the mpd console; return True on success""" + try: + host, port, user, pw = pppoe_console.read_auth() + client = pppoe_console.ConsoleClient(host, port, user, pw) + try: + client.run('session %s' % session['session_id']) + client.run('close') + finally: + client.close() + return True + except (OSError, ValueError, ConnectionError): + return False + + +def handle(packet, addr, cfg, sock): + src = ipaddress.ip_address(addr[0]) + if not any(src in net for net in cfg['allowed']): + return # silently drop unauthorised sources + if len(packet) < 20: + return + code, ident, length = struct.unpack('!BBH', packet[:4]) + request_authenticator = packet[4:20] + if not verify_request(packet, cfg['secret']): + return # bad secret / authenticator -> drop + + if code == COA_REQUEST: + # mpd5 cannot re-apply attributes to a live session + sock.sendto(build_response(COA_NAK, ident, request_authenticator, + cfg['secret'], CAUSE_UNSUPPORTED_SERVICE), addr) + return + if code != DISCONNECT_REQUEST: + return + + wanted = {} + for atype, val in parse_attributes(packet[20:length]): + if atype == ATTR_ACCT_SESSION_ID: + sid = val.decode('ascii', 'ignore') + if pppoe_console.SESSION_RE.match(sid): + wanted['session_id'] = sid + elif atype == ATTR_USER_NAME: + name = val.decode('ascii', 'ignore') + if pppoe_console.USERNAME_RE.match(name): + wanted['username'] = name + elif atype == ATTR_FRAMED_IP_ADDRESS and len(val) == 4: + wanted['address'] = socket.inet_ntoa(val) + + try: + host, port, user, pw = pppoe_console.read_auth() + client = pppoe_console.ConsoleClient(host, port, user, pw) + try: + sessions = pppoe_console.parse_sessions(client.run('show sessions')) + finally: + client.close() + except (OSError, ValueError, ConnectionError): + sock.sendto(build_response(DISCONNECT_NAK, ident, request_authenticator, + cfg['secret'], CAUSE_SESSION_NOT_FOUND), addr) + return + + session = find_session(sessions, wanted) + if session is not None and disconnect(session): + sock.sendto(build_response(DISCONNECT_ACK, ident, request_authenticator, + cfg['secret']), addr) + else: + sock.sendto(build_response(DISCONNECT_NAK, ident, request_authenticator, + cfg['secret'], CAUSE_SESSION_NOT_FOUND), addr) + + +def main(): + try: + cfg = read_conf() + except (OSError, ValueError) as exc: + sys.stderr.write('coa: bad config: %s\n' % exc) + return 1 + if not cfg['secret'] or not cfg['allowed']: + sys.stderr.write('coa: secret and at least one allowed client required\n') + return 1 + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('0.0.0.0', cfg['port'])) + sys.stderr.write('coa: listening on udp/%d\n' % cfg['port']) + while True: + try: + packet, addr = sock.recvfrom(4096) + except OSError: + continue + try: + handle(packet, addr, cfg, sock) + except Exception as exc: # never let one packet kill the daemon + sys.stderr.write('coa: error handling packet from %s: %s\n' % (addr, exc)) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/diagnostics.py b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/diagnostics.py new file mode 100755 index 0000000000..27abf12e7c --- /dev/null +++ b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/diagnostics.py @@ -0,0 +1,227 @@ +#!/usr/local/bin/python3 + +""" + Copyright (C) 2026 VEQNORA + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + diagnostics helpers for the PPPoE server plugin. every output path + masks secrets: mpd.secret is never shown, RADIUS shared secrets and the + console credential are replaced before anything leaves this script. +""" + +import argparse +import importlib.util +import json +import os +import re +import subprocess +import sys + +CONF_DIR = '/usr/local/etc/pppoe_server' +MPD_CONF = os.path.join(CONF_DIR, 'mpd.conf') + +_spec = importlib.util.spec_from_file_location( + 'pppoe_console', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pppoe_console.py')) +pppoe_console = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(pppoe_console) + +MASK_RULES = ( + # set radius server [ports] -> mask the secret column + (re.compile(r'^(\s*set radius server\s+\S+\s+)\S+', re.M), r'\1********'), + # set user [priv] -> mask the password column + (re.compile(r'^(\s*set user\s+\S+\s+)\S+', re.M), r'\1********'), +) + + +def mask_config(text): + for pattern, replacement in MASK_RULES: + text = pattern.sub(replacement, text) + return text + + +def run_cmd(cmd, timeout=15): + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) + return proc.returncode, proc.stdout.strip(), proc.stderr.strip() + except (OSError, subprocess.SubprocessError) as exc: + return 1, '', str(exc) + + +def config_preview(): + try: + with open(MPD_CONF, 'r', encoding='utf-8') as handle: + return {'status': 'ok', 'preview': mask_config(handle.read())} + except OSError as exc: + return {'status': 'failed', 'message': str(exc)} + + +def netgraph_status(): + code, out, err = run_cmd(['/usr/sbin/ngctl', 'list']) + if code != 0: + return {'status': 'failed', 'message': err or 'ngctl failed'} + nodes = [n for n in out.splitlines() if n.strip().startswith('Name:')] + pppoe_related = [n.strip() for n in nodes + if re.search(r'Type:\s*(pppoe|ppp|iface|ether)\b', n, re.I)] + return {'status': 'ok', 'nodes': len(nodes), 'listing': '\n'.join(pppoe_related)} + + +def versions(): + result = {'status': 'ok'} + code, out, _ = run_cmd(['/usr/sbin/pkg', 'query', '%v', 'mpd5']) + result['mpd5'] = out if code == 0 else 'not installed' + code, out, _ = run_cmd(['/usr/sbin/pkg', 'query', '%v', 'os-pppoe-server']) + result['plugin'] = out if code == 0 else 'development' + code, out, _ = run_cmd(['/usr/bin/uname', '-rs']) + result['os'] = out if code == 0 else '' + return result + + +def validate(): + problems = [] + if not os.path.isfile(MPD_CONF): + problems.append('mpd.conf is missing - apply the configuration first') + else: + with open(MPD_CONF, 'r', encoding='utf-8') as handle: + conf = handle.read() + for keyword in ('startup:', 'pppoe_server:'): + if keyword not in conf: + problems.append('mpd.conf lacks the %s section' % keyword) + ifaces = set(re.findall(r'^\s*set pppoe iface\s+(\S+)', conf, re.M)) + code, out, _ = run_cmd(['/sbin/ifconfig', '-l']) + present = set(out.split()) if code == 0 else set() + for iface in sorted(ifaces - present): + problems.append('interface %s not present on this system' % iface) + for name in ('mpd.secret', 'console.auth'): + path = os.path.join(CONF_DIR, name) + if os.path.isfile(path): + mode = os.stat(path).st_mode & 0o777 + if mode & 0o077: + problems.append('%s permissions too open (%o)' % (name, mode)) + status = 'ok' if not problems else 'failed' + return {'status': status, 'problems': problems} + + +def support_bundle(): + """masked, secret-free diagnostic snapshot""" + bundle = {'status': 'ok', 'sections': {}} + bundle['sections']['versions'] = versions() + bundle['sections']['validate'] = validate() + bundle['sections']['netgraph'] = netgraph_status() + bundle['sections']['config_preview'] = config_preview() + code, out, _ = run_cmd(['/usr/local/etc/rc.d/pppoe_server', 'status']) + bundle['sections']['service'] = {'status': 'ok', 'output': out} + try: + host, port, username, password = pppoe_console.read_auth() + client = pppoe_console.ConsoleClient(host, port, username, password) + try: + sessions = pppoe_console.parse_sessions(client.run('show sessions')) + pools = pppoe_console.parse_ippool(client.run('show ippool')) + finally: + client.close() + bundle['sections']['sessions'] = {'status': 'ok', 'count': len(sessions)} + bundle['sections']['pools'] = {'status': 'ok', 'pools': pools} + except (OSError, ValueError, ConnectionError) as exc: + bundle['sections']['sessions'] = {'status': 'failed', 'message': str(exc)} + return bundle + + +def metrics(): + """Prometheus text exposition (low cardinality: bundle label only)""" + lines = [] + + def emit(name, value, help_text, mtype='gauge', labels=''): + if help_text: + lines.append('# HELP %s %s' % (name, help_text)) + lines.append('# TYPE %s %s' % (name, mtype)) + lines.append('%s%s %s' % (name, labels, value)) + + service_up = 1 if run_cmd(['/usr/local/etc/rc.d/pppoe_server', 'status'])[0] == 0 else 0 + emit('pppoe_service_up', service_up, 'PPPoE server daemon running') + + try: + host, port, username, password = pppoe_console.read_auth() + client = pppoe_console.ConsoleClient(host, port, username, password) + try: + sessions = pppoe_console.parse_sessions(client.run('show sessions')) + pools = pppoe_console.parse_ippool(client.run('show ippool')) + finally: + client.close() + except (OSError, ValueError, ConnectionError): + sessions, pools = [], [] + + emit('pppoe_active_sessions', len(sessions), 'Active PPPoE sessions') + by_bundle = {} + for session in sessions: + by_bundle[session['bundle']] = by_bundle.get(session['bundle'], 0) + 1 + first = True + for bundle, count in sorted(by_bundle.items()): + emit('pppoe_sessions_by_ac', count, + 'Active sessions per access concentrator' if first else '', + labels='{bundle="%s"}' % bundle) + first = False + + counters = pppoe_console.get_counters() + total_in = sum(counters.get(s['iface'], {}).get('input_bytes', 0) for s in sessions) + total_out = sum(counters.get(s['iface'], {}).get('output_bytes', 0) for s in sessions) + emit('pppoe_input_bytes_total', total_in, 'Bytes received from clients', 'counter') + emit('pppoe_output_bytes_total', total_out, 'Bytes sent to clients', 'counter') + + first = True + for pool in pools: + labels = '{pool="%s"}' % pool['name'] + emit('pppoe_pool_addresses_used', pool['used'], + 'Addresses in use per pool' if first else '', labels=labels) + lines.append('pppoe_pool_addresses_total%s %s' % (labels, pool['total'])) + first = False + + return '\n'.join(lines) + '\n' + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('command', choices=[ + 'config_preview', 'netgraph_status', 'versions', 'validate', + 'support_bundle', 'metrics', + ]) + args = parser.parse_args() + + if args.command == 'metrics': + sys.stdout.write(metrics()) + return 0 + + dispatch = { + 'config_preview': config_preview, + 'netgraph_status': netgraph_status, + 'versions': versions, + 'validate': validate, + 'support_bundle': support_bundle, + } + result = dispatch[args.command]() + # exit 0 with JSON status; configd script_output drops output on failure exit + print(json.dumps(result)) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/expire_users.py b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/expire_users.py new file mode 100755 index 0000000000..da901deac5 --- /dev/null +++ b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/expire_users.py @@ -0,0 +1,146 @@ +#!/usr/local/bin/python3 + +""" + Copyright (C) 2026 VEQNORA + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + account expiry job: users listed in users.meta with an expiration date + in the past are removed from mpd.secret (mpd5 re-reads the file on + every authentication, so no restart is needed) and their active + sessions are disconnected. designed to run daily from cron and from + the rc.d start_precmd. +""" + +import argparse +import datetime +import importlib.util +import json +import os +import re +import sys +import tempfile + +CONF_DIR = '/usr/local/etc/pppoe_server' +META_FILE = os.path.join(CONF_DIR, 'users.meta') +SECRET_FILE = os.path.join(CONF_DIR, 'mpd.secret') +DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$') + +_spec = importlib.util.spec_from_file_location( + 'pppoe_console', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pppoe_console.py')) +pppoe_console = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(pppoe_console) + + +def expired_usernames(meta_path=META_FILE, today=None): + today = today or datetime.date.today() + expired = [] + try: + with open(meta_path, 'r', encoding='utf-8') as handle: + for line in handle: + parts = line.rstrip('\n').split('\t') + if len(parts) != 2: + continue + username, expires = parts + if not pppoe_console.USERNAME_RE.match(username) or not DATE_RE.match(expires): + continue + try: + expiry = datetime.date.fromisoformat(expires) + except ValueError: + continue + if expiry <= today: + expired.append(username) + except OSError: + pass + return expired + + +def prune_secret(usernames, secret_path=SECRET_FILE): + """remove secret lines of the given users; atomic replace, 0600 kept""" + if not usernames: + return 0 + try: + with open(secret_path, 'r', encoding='utf-8') as handle: + lines = handle.readlines() + except OSError: + return 0 + blocked = set(usernames) + kept, removed = [], 0 + for line in lines: + name = line.split(' ', 1)[0].strip() + if name in blocked: + removed += 1 + continue + kept.append(line) + if removed: + fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(secret_path)) + try: + with os.fdopen(fd, 'w', encoding='utf-8') as handle: + handle.writelines(kept) + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, secret_path) + except OSError: + os.unlink(tmp_path) + raise + return removed + + +def disconnect_users(usernames): + disconnected = 0 + try: + host, port, username, password = pppoe_console.read_auth() + client = pppoe_console.ConsoleClient(host, port, username, password) + try: + sessions = pppoe_console.parse_sessions(client.run('show sessions')) + for session in sessions: + if session['username'] in usernames: + client.run('session %s' % session['session_id']) + client.run('close') + disconnected += 1 + finally: + client.close() + except (OSError, ValueError, ConnectionError): + pass # service not running - nothing to disconnect + return disconnected + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--prune-only', action='store_true', + help='only rewrite mpd.secret, do not touch sessions') + args = parser.parse_args() + + expired = expired_usernames() + removed = prune_secret(expired) + disconnected = 0 if args.prune_only else disconnect_users(set(expired)) + print(json.dumps({ + 'status': 'ok', + 'expired': len(expired), + 'secrets_removed': removed, + 'sessions_disconnected': disconnected, + })) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/ext_auth.py b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/ext_auth.py new file mode 100755 index 0000000000..44c694c765 --- /dev/null +++ b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/ext_auth.py @@ -0,0 +1,125 @@ +#!/usr/local/bin/python3 + +""" + Copyright (C) 2026 VEQNORA + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + mpd5 external authentication backend for local users. This is used + instead of mpd.secret when "local shaping" is enabled, because mpd5 + applies per-session rate limits (ng_car) only from auth parameters + (RADIUS or ext-auth) — not from the secrets file. + + Protocol (mpd5 auth.c): mpd writes request lines to our stdin + (USER_NAME, AUTH_TYPE, ...) ending with a blank line; we write reply + attributes to stdout ending with a blank line. We return the user's + cleartext password (mpd verifies PAP/CHAP itself), an optional static + Framed-IP-Address, and MPD_LIMIT rules built from the per-user + upload/download limits. Passwords are read from a root-only data file + and never logged. +""" + +import re +import sys + +DATA = '/usr/local/etc/pppoe_server/users.ext' +USERNAME_RE = re.compile(r'^[0-9a-zA-Z._@-]{1,64}$') + + +def read_request(stream): + # read line-by-line (NOT `for line in stream`, whose block buffering would + # deadlock: mpd holds the pipe open waiting for our reply after the blank + # line, so the iterator's read-ahead would block forever) + req = {} + while True: + line = stream.readline() + if line == '': # EOF + break + line = line.rstrip('\n') + if line == '': # blank line = end of request + break + if ':' in line: + key, val = line.split(':', 1) + req[key] = val + return req + + +def lookup(username): + """return dict with password/up/down/staticip or None""" + try: + with open(DATA, 'r', encoding='utf-8') as fh: + for line in fh: + parts = line.rstrip('\n').split('\t') + if len(parts) >= 5 and parts[0] == username: + return { + 'password': parts[1], + 'uplimit': int(parts[2] or 0), + 'downlimit': int(parts[3] or 0), + 'staticip': parts[4], + 'ip6route': parts[5] if len(parts) >= 6 else '', + } + except (OSError, ValueError): + return None + return None + + +def limit_rule(direction, kbit): + """build an mpd MPD_LIMIT rule string for a kbit/s rate""" + bps = int(kbit) * 1000 + burst = max(3000, bps // 100) # ~10 ms of data, floor 3 kB + return '%s#1=all rate-limit %d %d' % (direction, bps, burst) + + +def main(): + req = read_request(sys.stdin) + username = req.get('USER_NAME', '') + out = sys.stdout + + if not USERNAME_RE.match(username): + out.write('RESULT:FAIL\n\n') + return 0 + + user = lookup(username) + if user is None: + out.write('RESULT:FAIL\n\n') + return 0 + + # hand mpd the cleartext password; it performs the PAP/CHAP check + out.write('USER_PASSWORD:%s\n' % user['password']) + out.write('RESULT:UNDEF\n') + if user['staticip']: + out.write('FRAMED_IP_ADDRESS:%s\n' % user['staticip']) + if user.get('ip6route'): + # route a static IPv6 prefix towards this subscriber (see docs/IPV6.md) + out.write('FRAMED_IPV6_ROUTE:%s\n' % user['ip6route']) + if user['downlimit'] > 0: + out.write('MPD_LIMIT:%s\n' % limit_rule('in', user['downlimit'])) + if user['uplimit'] > 0: + out.write('MPD_LIMIT:%s\n' % limit_rule('out', user['uplimit'])) + out.write('\n') + out.flush() + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/linkdown.sh b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/linkdown.sh new file mode 100755 index 0000000000..6752cb497c --- /dev/null +++ b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/linkdown.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# mpd5 iface down-script: +# flush states owned by the session so traffic stops immediately + +/usr/bin/logger -t pppoe -p local3.info "logout,${1},${4},${5}" + +/sbin/pfctl -i "${1}" -Fs 2>/dev/null +/sbin/pfctl -K "${4}/32" 2>/dev/null + +exit 0 diff --git a/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/linkup.sh b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/linkup.sh new file mode 100755 index 0000000000..7d27c25fe5 --- /dev/null +++ b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/linkup.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# mpd5 iface up-script: ... +# keep clients in a dedicated pf interface group and log the event + +/usr/bin/logger -t pppoe -p local3.info "login,${1},${4},${5}" +/sbin/ifconfig "${1}" group pppoe + +exit 0 diff --git a/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/pppoe_console.py b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/pppoe_console.py new file mode 100755 index 0000000000..916012b36b --- /dev/null +++ b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/pppoe_console.py @@ -0,0 +1,286 @@ +#!/usr/local/bin/python3 + +""" + Copyright (C) 2026 VEQNORA + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + mpd5 control console client: list active PPPoE sessions as JSON and + disconnect sessions by session id or username. + + "show sessions" emits one TAB separated line per link (mpd 5.9 + command.c:ShowSessions): ifname, peer-ip, bundle, msession-id, link, + link-id, session-id, username, peer-mac and, with the session-time + global option enabled, session uptime in seconds. +""" + +import argparse +import json +import re +import socket +import subprocess +import sys + +AUTH_FILE = '/usr/local/etc/pppoe_server/console.auth' +SESSION_RE = re.compile(r'^[0-9A-Za-z._-]{1,32}$') +USERNAME_RE = re.compile(r'^[0-9a-zA-Z._@-]{1,64}$') +IAC = 255 + +SESSION_FIELDS = ( + 'iface', 'address', 'bundle', 'msession_id', 'link', + 'link_id', 'session_id', 'username', 'peer_mac', 'uptime' +) + + +def parse_sessions(payload): + """parse raw 'show sessions' output into a list of dicts""" + sessions = [] + for line in payload.splitlines(): + parts = line.rstrip('\r').split('\t') + # 9 columns without, 10 with the session-time option + if len(parts) < 9 or not SESSION_RE.match(parts[6]): + continue + session = dict(zip(SESSION_FIELDS, parts[:10])) + session.setdefault('uptime', '') + sessions.append(session) + return sessions + + +IPPOOL_RE = re.compile(r'^\s*(?P[0-9A-Za-z_-]+):\s+used\s+(?P\d+)\s+of\s+(?P\d+)\s*$') + + +def parse_ippool(payload): + """parse mpd5 'show ippool' output: '\t:\tused N of M'""" + pools = [] + for line in payload.splitlines(): + matched = IPPOOL_RE.match(line) + if matched: + used = int(matched.group('used')) + total = int(matched.group('total')) + pools.append({ + 'name': matched.group('name'), + 'used': used, + 'total': total, + 'free': total - used, + }) + return pools + + +def parse_counters(payload): + """ + parse `netstat -ibn` link-level rows into per-interface counters. + ng interfaces have no link address, which shifts the columns, so the + trailing fixed fields are taken from the end of the line: + ... Ipkts Ierrs Idrop Ibytes Opkts Oerrs Obytes Coll + """ + counters = {} + for line in payload.splitlines(): + parts = line.split() + if len(parts) < 10 or ' + elif verb == 250: # SB ... SE + end = data.find(bytes((IAC, 240)), i) + i = len(data) if end < 0 else end + 2 + else: + i += 2 + else: + out.append(byte) + i += 1 + return bytes(out) + + def _expect(self, token): + while token not in self._buf: + self._buf += self._strip_telnet(self._read_chunk()) + pos = self._buf.find(token) + len(token) + consumed, self._buf = self._buf[:pos], self._buf[pos:] + return consumed + + def run(self, command): + """execute one command, return its output up to the next prompt""" + self._send(command) + # every reply ends with a prompt like "[] " or "[B1] " on its own line + output = [] + while True: + self._buf += self._strip_telnet(self._read_chunk()) + text = self._buf.decode('utf-8', 'replace') + lines = text.split('\n') + if lines and self.PROMPT_RE.match(lines[-1]): + self._buf = b'' + output = lines[:-1] + break + # first echoed line repeats the command itself + if output and command in output[0]: + output = output[1:] + return '\n'.join(output) + + def close(self): + try: + self._send('quit') + except OSError: + pass + self._sock.close() + + +def read_auth(): + with open(AUTH_FILE, 'r', encoding='ascii') as fh: + for line in fh: + parts = line.split() + if len(parts) == 4: + return parts[0], int(parts[1]), parts[2], parts[3] + raise ValueError('malformed console.auth') + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('command', choices=['sessions', 'disconnect', 'pool_status']) + parser.add_argument('--session', help='session id to disconnect') + parser.add_argument('--user', help='disconnect all sessions of a username') + parser.add_argument('--bundle', help='disconnect all sessions on a bundle (access concentrator)') + args = parser.parse_args() + + result = {'status': 'failed'} + try: + host, port, username, password = read_auth() + client = ConsoleClient(host, port, username, password) + try: + if args.command == 'pool_status': + pools = parse_ippool(client.run('show ippool')) + print(json.dumps({'status': 'ok', 'pools': pools})) + return 0 + sessions = parse_sessions(client.run('show sessions')) + if args.command == 'sessions': + counters = get_counters() + for session in sessions: + session.update(counters.get(session['iface'], { + 'input_packets': 0, 'input_bytes': 0, + 'output_packets': 0, 'output_bytes': 0, + })) + result = {'status': 'ok', 'sessions': sessions} + else: + targets = [] + if args.session: + if not SESSION_RE.match(args.session): + raise ValueError('invalid session id') + targets = [s for s in sessions if s['session_id'] == args.session] + elif args.user: + if not USERNAME_RE.match(args.user): + raise ValueError('invalid username') + targets = [s for s in sessions if s['username'] == args.user] + elif args.bundle: + if not SESSION_RE.match(args.bundle): + raise ValueError('invalid bundle name') + targets = [s for s in sessions if s['bundle'] == args.bundle] + else: + raise ValueError('nothing to disconnect') + for session in targets: + client.run('session %s' % session['session_id']) + client.run('close') + result = {'status': 'ok', 'disconnected': len(targets)} + finally: + client.close() + except (OSError, ValueError, ConnectionError) as exc: + result = {'status': 'failed', 'message': str(exc)} + + # always exit 0 with a JSON status payload: configd script_output + # discards output on a non-zero exit code, which would hide the error + print(json.dumps(result)) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/radius_test.py b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/radius_test.py new file mode 100755 index 0000000000..44424ad4ec --- /dev/null +++ b/net/pppoe-server/src/opnsense/scripts/OPNsense/PPPoEServer/radius_test.py @@ -0,0 +1,173 @@ +#!/usr/local/bin/python3 + +""" + Copyright (C) 2026 VEQNORA + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + RADIUS reachability test (RFC 2865): sends a PAP Access-Request for a + throwaway probe account with a random password. Any valid response + (Access-Reject included) proves the server is reachable and the shared + secret matches: a wrong secret makes real servers silently discard the + request or fail response authentication. No real credentials are used + and no secret ever appears on the command line - servers and secrets + are read from the generated mpd.conf (root only). +""" + +import argparse +import hashlib +import hmac +import json +import os +import re +import secrets +import socket +import struct +import sys + +MPD_CONF = '/usr/local/etc/pppoe_server/mpd.conf' +SERVER_RE = re.compile( + r'^\s*set radius server\s+(?P\S+)\s+(?P\S+)\s+(?P\d+)\s+(?P\d+)\s*$' +) +HOST_RE = re.compile(r'^(?:\d{1,3}\.){3}\d{1,3}$') + +ACCESS_REQUEST = 1 +ACCESS_ACCEPT = 2 +ACCESS_REJECT = 3 + +ATTR_USER_NAME = 1 +ATTR_USER_PASSWORD = 2 +ATTR_NAS_PORT_TYPE = 61 +ATTR_MESSAGE_AUTHENTICATOR = 80 + +CODE_NAMES = {ACCESS_ACCEPT: 'Access-Accept', ACCESS_REJECT: 'Access-Reject', 11: 'Access-Challenge'} + + +def read_servers(path=MPD_CONF): + servers = [] + with open(path, 'r', encoding='utf-8') as handle: + for line in handle: + matched = SERVER_RE.match(line) + if matched: + entry = matched.groupdict() + if entry not in servers: + servers.append(entry) + return servers + + +def attr(attr_type, value): + return struct.pack('BB', attr_type, len(value) + 2) + value + + +def pap_encrypt(password, secret, authenticator): + """RFC 2865 5.2 User-Password obfuscation""" + password = password.ljust(16, b'\x00') + digest = hashlib.md5(secret + authenticator).digest() + return bytes(p ^ d for p, d in zip(password, digest)) + + +def build_access_request(secret, authenticator, username, password): + attrs = attr(ATTR_USER_NAME, username.encode()) + attrs += attr(ATTR_USER_PASSWORD, pap_encrypt(password.encode(), secret, authenticator)) + attrs += attr(ATTR_NAS_PORT_TYPE, struct.pack('!I', 15)) # Ethernet + # Message-Authenticator: HMAC-MD5 over the packet with the attribute zeroed + attrs += attr(ATTR_MESSAGE_AUTHENTICATOR, b'\x00' * 16) + length = 20 + len(attrs) + header = struct.pack('!BBH', ACCESS_REQUEST, 1, length) + authenticator + mac = hmac.new(secret, header + attrs, hashlib.md5).digest() + attrs = attrs[:-16] + mac + return header + attrs + + +def verify_response(data, secret, request_authenticator): + if len(data) < 20: + return None + code, ident, length = struct.unpack('!BBH', data[:4]) + if length > len(data): + return None + expected = hashlib.md5( + data[:4] + request_authenticator + data[20:length] + secret + ).digest() + if not hmac.compare_digest(expected, data[4:20]): + return None + return code + + +def probe(server, timeout=5): + secret = server['secret'].encode() + authenticator = os.urandom(16) + packet = build_access_request( + secret, authenticator, + 'opnsense-probe', secrets.token_urlsafe(16) + ) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(timeout) + try: + sock.sendto(packet, (server['host'], int(server['authport']))) + data, _ = sock.recvfrom(4096) + except socket.timeout: + return {'host': server['host'], 'status': 'timeout', + 'detail': 'no response (server down, port blocked or shared secret mismatch)'} + except OSError as exc: + return {'host': server['host'], 'status': 'error', 'detail': str(exc)} + finally: + sock.close() + + code = verify_response(data, secret, authenticator) + if code is None: + return {'host': server['host'], 'status': 'invalid', + 'detail': 'response failed authenticator check (shared secret mismatch)'} + return {'host': server['host'], 'status': 'ok', + 'detail': 'received %s (server reachable, shared secret valid)' + % CODE_NAMES.get(code, 'code %d' % code)} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--host', help='test only this configured server') + args = parser.parse_args() + + # always exit 0 with JSON: configd script_output drops output on failure exit + try: + servers = read_servers() + except OSError as exc: + print(json.dumps({'status': 'failed', 'message': str(exc)})) + return 0 + + if args.host: + if not HOST_RE.match(args.host): + print(json.dumps({'status': 'failed', 'message': 'invalid host'})) + return 0 + servers = [s for s in servers if s['host'] == args.host] + + if not servers: + print(json.dumps({'status': 'failed', 'message': 'no RADIUS servers configured'})) + return 0 + + results = [probe(server) for server in servers] + print(json.dumps({'status': 'ok', 'results': results})) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/net/pppoe-server/src/opnsense/service/conf/actions.d/actions_pppoe_server.conf b/net/pppoe-server/src/opnsense/service/conf/actions.d/actions_pppoe_server.conf new file mode 100644 index 0000000000..ed2a55f4b5 --- /dev/null +++ b/net/pppoe-server/src/opnsense/service/conf/actions.d/actions_pppoe_server.conf @@ -0,0 +1,126 @@ +[start] +command:/usr/local/etc/rc.d/pppoe_server start +parameters: +type:script +message:starting PPPoE server + +[stop] +command:/usr/local/etc/rc.d/pppoe_server stop +parameters: +type:script +message:stopping PPPoE server + +[restart] +command:/usr/local/etc/rc.d/pppoe_server restart +parameters: +type:script +message:restarting PPPoE server + +[status] +command:/usr/local/etc/rc.d/pppoe_server status; exit 0 +parameters: +type:script_output +message:request PPPoE server status + +[coa_start] +command:/usr/local/etc/rc.d/pppoe_server_coa start +parameters: +type:script +message:starting PPPoE CoA listener + +[coa_stop] +command:/usr/local/etc/rc.d/pppoe_server_coa stop +parameters: +type:script +message:stopping PPPoE CoA listener + +[coa_restart] +command:/usr/local/etc/rc.d/pppoe_server_coa restart +parameters: +type:script +message:restarting PPPoE CoA listener + +[coa_status] +command:/usr/local/etc/rc.d/pppoe_server_coa status; exit 0 +parameters: +type:script_output +message:request PPPoE CoA listener status + +[sessions] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/pppoe_console.py sessions +parameters: +type:script_output +message:list active PPPoE sessions + +[disconnect] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/pppoe_console.py disconnect --session +parameters:%s +type:script_output +message:disconnect PPPoE session + +[disconnect_user] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/pppoe_console.py disconnect --user +parameters:%s +type:script_output +message:disconnect PPPoE sessions of user + +[disconnect_ac] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/pppoe_console.py disconnect --bundle +parameters:%s +type:script_output +message:disconnect PPPoE sessions on access concentrator + +[pool_status] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/pppoe_console.py pool_status +parameters: +type:script_output +message:request PPPoE address pool status + +[radius_test] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/radius_test.py +parameters: +type:script_output +message:test configured RADIUS servers + +[netgraph_status] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/diagnostics.py netgraph_status +parameters: +type:script_output +message:request Netgraph status + +[config_preview] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/diagnostics.py config_preview +parameters: +type:script_output +message:preview generated PPPoE configuration + +[versions] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/diagnostics.py versions +parameters: +type:script_output +message:show PPPoE server component versions + +[validate] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/diagnostics.py validate +parameters: +type:script_output +message:validate generated PPPoE configuration + +[support_bundle] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/diagnostics.py support_bundle +parameters: +type:script_output +message:generate PPPoE support bundle + +[metrics] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/diagnostics.py metrics +parameters: +type:script_output +message:collect PPPoE server metrics + +[expire] +command:/usr/local/opnsense/scripts/OPNsense/PPPoEServer/expire_users.py +parameters: +type:script_output +message:disconnect and disable expired PPPoE accounts +description:PPPoE server: expire user accounts diff --git a/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/+TARGETS b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/+TARGETS new file mode 100644 index 0000000000..3351a0cc0e --- /dev/null +++ b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/+TARGETS @@ -0,0 +1,8 @@ +mpd.conf:/usr/local/etc/pppoe_server/mpd.conf +mpd.secret:/usr/local/etc/pppoe_server/mpd.secret +console.auth:/usr/local/etc/pppoe_server/console.auth +users.meta:/usr/local/etc/pppoe_server/users.meta +users.ext:/usr/local/etc/pppoe_server/users.ext +coa.conf:/usr/local/etc/pppoe_server/coa.conf +rc.conf.d.coa:/etc/rc.conf.d/pppoe_server_coa +rc.conf.d:/etc/rc.conf.d/pppoe_server diff --git a/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/coa.conf b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/coa.conf new file mode 100644 index 0000000000..dc4fc3c1a2 --- /dev/null +++ b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/coa.conf @@ -0,0 +1,5 @@ +{# RADIUS CoA / Disconnect listener config - generated, root only (secret) #} +{% set coa = OPNsense.pppoeserver.radius.coa|default({}) %} +port={{ coa.port|default('3799') }} +secret={{ coa.secret|default('') }} +allowed={{ coa.allowedclients|default('') }} diff --git a/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/console.auth b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/console.auth new file mode 100644 index 0000000000..f77023d515 --- /dev/null +++ b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/console.auth @@ -0,0 +1,3 @@ +{# mpd5 control console credentials for management scripts - root only #} +{% set general = OPNsense.pppoeserver.general|default({}) %} +127.0.0.1 {{ general.consoleport|default('5005') }} svc {{ general.consolepass|default('') }} diff --git a/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/mpd.conf b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/mpd.conf new file mode 100644 index 0000000000..2c2bd54e94 --- /dev/null +++ b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/mpd.conf @@ -0,0 +1,117 @@ +{# PPPoE Access Concentrator configuration - generated, do not edit #} +{% from 'OPNsense/Macros/interface.macro' import physical_interface %} +{% set general = OPNsense.pppoeserver.general|default({}) %} +startup: +{% if general.enabled|default('0') == '1' and general.consolepass|default('') != '' %} + set global enable session-time + set user svc {{ general.consolepass }} admin + set console self 127.0.0.1 {{ general.consoleport|default('5005') }} + set console open +{% endif %} + +pppoe_server: +{% if general.enabled|default('0') == '1' %} +{% if general.loglevel|default('info') == 'debug' %} + log +auth +lcp +ipcp +link +bund +pppoe +{% endif %} +{% for pool in helpers.toList('OPNsense.pppoeserver.pools.pool') %} +{% if pool.enabled|default('0') == '1' %} + set ippool add pool_{{ pool.name }} {{ pool.start }} {{ pool.end }} +{% endif %} +{% endfor %} +{% for ac in helpers.toList('OPNsense.pppoeserver.acs.ac') %} +{% if ac.enabled|default('0') == '1' %} + create bundle template B{{ loop.index }} +{% if general.ipv6|default('0') == '1' %} + set bundle enable ipv6cp +{% else %} + set bundle disable ipv6cp +{% endif %} + set iface enable tcpmssfix + set iface disable on-demand + set iface disable proxy-arp + set iface idle {{ ac.idletimeout|default('0') }} + set iface session {{ ac.sessiontimeout|default('0') }} + set iface up-script /usr/local/opnsense/scripts/OPNsense/PPPoEServer/linkup.sh + set iface down-script /usr/local/opnsense/scripts/OPNsense/PPPoEServer/linkdown.sh + set ipcp no vjcomp +{% for pool in helpers.toList('OPNsense.pppoeserver.pools.pool') %} +{% if pool['@uuid']|default('') == ac.pool %} + set ipcp ranges {{ ac.gateway }}/32 ippool pool_{{ pool.name }} +{% endif %} +{% endfor %} +{% if general.dns1|default('') != '' %} + set ipcp dns {{ general.dns1 }}{% if general.dns2|default('') != '' %} {{ general.dns2 }}{% endif %} + +{% endif %} + create link template L{{ loop.index }} pppoe + set link action bundle B{{ loop.index }} + set link disable multilink + set link disable eap +{% if general.authmethod|default('chap') == 'chap' %} + set link disable pap + set link enable chap +{% elif general.authmethod|default('chap') == 'pap' %} + set link enable pap + set link disable chap +{% else %} + set link enable pap + set link enable chap +{% endif %} + set link keep-alive 10 60 + set link mtu {{ ac.mtu|default('') if ac.mtu|default('') != '' else general.mtu|default('1492') }} + set link mru {{ ac.mru|default('') if ac.mru|default('') != '' else general.mru|default('1492') }} + set link max-redial 0 + set link max-children {{ ac.maxsessions|default('') if ac.maxsessions|default('') != '' else general.maxsessions|default('256') }} + set auth max-logins {{ general.maxsessionsperuser|default('1') }} +{% if general.localshaping|default('0') == '1' %} + set auth disable internal + set auth enable ext-auth + set auth extauth-script "/usr/local/opnsense/scripts/OPNsense/PPPoEServer/ext_auth.py" +{% endif %} +{% if general.radiusauth|default('0') == '1' or general.radiusacct|default('0') == '1' %} +{% set ropts = OPNsense.pppoeserver.radius.options|default({}) %} +{% for srv in helpers.toList('OPNsense.pppoeserver.radius.server', 'priority') %} +{% if srv.enabled|default('0') == '1' %} + set radius server {{ srv.host }} {{ srv.secret }} {{ srv.authport|default('1812') }} {{ srv.acctport|default('1813') }} +{% endif %} +{% endfor %} + set radius timeout {{ ropts.timeout|default('5') }} + set radius retries {{ ropts.retries|default('3') }} +{% if ropts.srcaddr|default('') != '' %} + set radius src-addr {{ ropts.srcaddr }} +{% endif %} +{% if ropts.nasip|default('') != '' %} + set radius me {{ ropts.nasip }} +{% endif %} +{% if ropts.nasid|default('') != '' %} + set radius identifier {{ ropts.nasid }} +{% endif %} +{% if ropts.messageauth|default('1') == '1' %} + set radius enable message-authentic +{% endif %} +{% if general.radiusauth|default('0') == '1' %} + set auth enable radius-auth +{% endif %} +{% if general.radiusacct|default('0') == '1' %} + set auth enable radius-acct +{% if general.acctinterval|default('0') != '0' %} + set auth acct-update {{ general.acctinterval }} +{% endif %} +{% endif %} +{% endif %} +{% if ac.servicename|default('') != '' %} + set pppoe service "{{ ac.servicename }}" +{% elif general.servicename|default('') != '' %} + set pppoe service "{{ general.servicename }}" +{% else %} + set pppoe service "*" +{% endif %} +{% if ac.acname|default('') != '' %} + set pppoe acname "{{ ac.acname }}" +{% endif %} + set pppoe iface {{ physical_interface(ac.interface) }} + set link enable incoming +{% endif %} +{% endfor %} +{% endif %} diff --git a/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/mpd.secret b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/mpd.secret new file mode 100644 index 0000000000..21258e26d8 --- /dev/null +++ b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/mpd.secret @@ -0,0 +1,7 @@ +{# PPPoE server local user secrets - generated, do not edit. root:wheel 0600 enforced by rc.d #} +{% for user in helpers.toList('OPNsense.pppoeserver.users.user') %} +{% if user.enabled|default('0') == '1' %} +{{ user.username }} "{{ user.password }}"{% if user.staticip|default('') != '' %} {{ user.staticip }}{% endif %} + +{% endif %} +{% endfor %} diff --git a/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/rc.conf.d b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/rc.conf.d new file mode 100644 index 0000000000..967eaeeb92 --- /dev/null +++ b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/rc.conf.d @@ -0,0 +1,5 @@ +{% if helpers.exists('OPNsense.pppoeserver.general.enabled') and OPNsense.pppoeserver.general.enabled == '1' %} +pppoe_server_enable="YES" +{% else %} +pppoe_server_enable="NO" +{% endif %} diff --git a/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/rc.conf.d.coa b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/rc.conf.d.coa new file mode 100644 index 0000000000..fbb6628c8f --- /dev/null +++ b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/rc.conf.d.coa @@ -0,0 +1,7 @@ +{% set coa = OPNsense.pppoeserver.radius.coa|default({}) %} +{% set general = OPNsense.pppoeserver.general|default({}) %} +{% if general.enabled|default('0') == '1' and coa.enabled|default('0') == '1' and coa.secret|default('') != '' and coa.allowedclients|default('') != '' %} +pppoe_server_coa_enable="YES" +{% else %} +pppoe_server_coa_enable="NO" +{% endif %} diff --git a/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/users.ext b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/users.ext new file mode 100644 index 0000000000..af1a1b4ce5 --- /dev/null +++ b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/users.ext @@ -0,0 +1,6 @@ +{# ext-auth data: usernamepasswordupdownstaticipip6route. root 0600 #} +{% for user in helpers.toList('OPNsense.pppoeserver.users.user') %} +{% if user.enabled|default('0') == '1' %} +{{ user.username }} {{ user.password }} {{ user.uplimit|default('0') }} {{ user.downlimit|default('0') }} {{ user.staticip|default('') }} {{ user.ip6route|default('') }} +{% endif %} +{% endfor %} diff --git a/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/users.meta b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/users.meta new file mode 100644 index 0000000000..23c46161a8 --- /dev/null +++ b/net/pppoe-server/src/opnsense/service/templates/OPNsense/PPPoEServer/users.meta @@ -0,0 +1,6 @@ +{# usernameexpiration-date metadata for the expiry job - no secrets #} +{% for user in helpers.toList('OPNsense.pppoeserver.users.user') %} +{% if user.enabled|default('0') == '1' and user.expires|default('') != '' %} +{{ user.username }} {{ user.expires }} +{% endif %} +{% endfor %}