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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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.
+ #}
+
+
+ {{ lang._('Configuration changed, apply to activate.') }}
+
+
+
+
+
+
+
+{{ 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 %}