diff --git a/README.md b/README.md index 00ebb97..58057fe 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,35 @@ Device Manager is a device registration and access management portal for laborat ### FreeRADIUS module -Device Manager includes an `rlm_python` bridge at `device_manager.freeradius`. FreeRADIUS stays responsible for EAP/password authentication; this module evaluates registered device identity, records the RADIUS request, creates an auditable access decision, and returns VLAN or reply attributes for allow, quarantine, or reject outcomes. +Device Manager includes FreeRADIUS integration via an `rlm_python` bridge. FreeRADIUS stays responsible for EAP/password authentication; the module evaluates registered device identity, records the RADIUS request, creates an auditable access decision, and returns VLAN, reply, and verifier control attributes for allow, quarantine, or reject outcomes. -Set these environment variables for the FreeRADIUS service: +#### Deployment Options + +**Option 1: Standalone RADIUS Client (Recommended for Separate Servers)** + +For FreeRADIUS servers on a separate host from your Frappe installation, use the standalone RADIUS client from the `radius_client/` directory. This module only requires Python 3.10+ and makes API calls to your Frappe server - no Frappe installation needed locally. + +See [radius_client/README.md](radius_client/README.md) for complete installation and configuration instructions. + +Quick setup: +```bash +# Copy standalone module to FreeRADIUS +sudo cp radius_client/device_manager_radius.py /etc/freeradius/3.0/mods-config/python3/ + +# Configure environment (see radius_client/README.md for details) +# Set DEVICE_MANAGER_FRAPPE_URL, DEVICE_MANAGER_API_KEY, DEVICE_MANAGER_API_SECRET +``` + +**Option 2: Local Mode (Same Host as Frappe)** + +For FreeRADIUS running on the same host as the Frappe bench, use the integrated `device_manager.freeradius` module: ```bash DEVICE_MANAGER_BENCH_PATH=/home/frappe/frappe-bench DEVICE_MANAGER_SITE=your-site-name ``` -Example `mods-available/python3` module stanza: - +Configure FreeRADIUS: ```text python3 device_manager { module = device_manager.freeradius @@ -24,19 +42,67 @@ python3 device_manager { } ``` -Then call the module from the relevant virtual server: +**Option 3: Remote Mode (Full App Installed)** +If device_manager is installed on the RADIUS server but Frappe runs elsewhere: + +```bash +DEVICE_MANAGER_FRAPPE_URL=https://device-manager.example.edu +DEVICE_MANAGER_API_KEY=api-key +DEVICE_MANAGER_API_SECRET=api-secret +DEVICE_MANAGER_CACHE_PATH=/var/lib/freeradius/device_manager_verifier_cache.sqlite3 +DEVICE_MANAGER_HTTP_TIMEOUT=2.5 +``` + +Configure FreeRADIUS: ```text -authorize { - device_manager -} - -post-auth { - device_manager +python3 device_manager { + module = device_manager.freeradius + instantiate = ${.module} + authorize = ${.module} + post_auth = ${.module} } ``` -The module reads common request attributes such as `Calling-Station-Id`, `User-Name`, `NAS-Identifier`, `NAS-IP-Address`, and SSID attributes, then writes `DM Radius Auth Event`, `DM Access Decision`, and `DM Device Audit Event` records. +#### Credential Caching + +All deployment modes support offline credential caching using a local SQLite database. The cache stores FreeRADIUS control attributes such as `SSHA-Password`; it does not store plaintext passwords. Frappe stores only verifier material in `Stored Credential Verifier`, not plaintext device passwords. When Frappe is unreachable, the module can continue authorizing previously cached long-lived IoT credentials. + +### Credential provisioning + +Approved devices can be owned by a Frappe `User`. A System Manager or the owning user can provision RADIUS credentials in two ways: + +- Device credential: a username/password or generated deployment key attached to one `DM Device`. This is the preferred mode for long-lived IoT and lab equipment because the offline FreeRADIUS cache can safely bind the verifier to a specific device decision. +- Owner shared credential: a username/password or generated deployment key attached to the owning Frappe user through `DM Radius Credential`. This can be used across devices owned by that user, while the device MAC still drives authorization, VLAN selection, and audit records. + +Provisioning actions hash the submitted password/key immediately using an SSHA verifier for FreeRADIUS. Generated deployment keys are returned once for installation on the device and are never stored in plaintext. + +#### FreeRADIUS Integration Details + +For detailed FreeRADIUS configuration, see: +- [radius_client/CONFIGURATION.md](radius_client/CONFIGURATION.md) - Complete FreeRADIUS setup guide +- [radius_client/README.md](radius_client/README.md) - Standalone client installation + +The module reads common request attributes such as `Calling-Station-Id`, `User-Name`, `NAS-Identifier`, `NAS-IP-Address`, and SSID attributes, then writes `DM Radius Auth Event`, `DM Access Decision`, and `DM Device Audit Event` records when Frappe is reachable. + +### UniFi live network views + +Device Manager can expose UniFi controller state through read-only Virtual DocTypes: + +- `DM UniFi Client` +- `DM UniFi Network Device` +- `DM UniFi Network` + +Configure `DM UniFi Settings` with the UniFi OS or self-hosted Network Controller URL, site ID, TLS behavior, and either an API token or a least-privilege local UniFi account. The virtual DocTypes call UniFi live and do not persist controller state in the Frappe database. This is intended for operator visibility and cross-checking Device Manager records against current network state while keeping UniFi as the source of truth for volatile client/AP/network telemetry. + +`DM UniFi Settings` also provides operator actions for the core workflows: + +- Sync UniFi networks into durable `DM Network Segment` mappings. +- Reconcile registered devices against live UniFi clients and update current IP, SSID, client ID, and network segment. +- Import unmatched UniFi clients as authorized `DM Device` records, because a client already visible in UniFi is an observed network asset rather than a self-service registration request. +- Convert older UniFi-created `DM Device Registration` records into `DM Device` records while preserving the registration as historical approval/audit context. + +`DM Device Registration` remains the intake object for self-service requests and proposed device onboarding before a device is accepted into the managed inventory. UniFi telemetry should populate `DM Device` directly; operators can then change lifecycle status, authorization status, or network segment if the imported device needs quarantine or reassignment. ### Installation diff --git a/device_manager/api.py b/device_manager/api.py index 2f14bb8..f136e6d 100644 --- a/device_manager/api.py +++ b/device_manager/api.py @@ -1,7 +1,15 @@ import frappe +from device_manager.credentials import set_device_radius_credential, set_owner_radius_credential from device_manager.lifecycle import create_device_from_registration, reject_registration, transition_device from device_manager.radius import record_radius_auth_event +from device_manager.unifi_sync import ( + import_unifi_clients_as_devices, + migrate_unifi_registrations_to_devices, + reconcile_unifi_clients, + reconcile_unifi_import_authorizations, + sync_unifi_network_segments, +) @frappe.whitelist() @@ -19,18 +27,91 @@ def quarantine_device(device_name: str, reason: str | None = None): return transition_device(device_name, "Quarantined", reason=reason) -@frappe.whitelist(allow_guest=True) +@frappe.whitelist() +def sync_unifi_segments(create_missing: bool = True): + return sync_unifi_network_segments(create_missing=frappe.utils.cint(create_missing)) + + +@frappe.whitelist() +def reconcile_unifi_devices(update_device_segment: bool = True): + return reconcile_unifi_clients(update_device_segment=frappe.utils.cint(update_device_segment)) + + +@frappe.whitelist() +def import_unifi_devices(limit: int | None = None, dry_run: bool = False): + return import_unifi_clients_as_devices( + limit=frappe.utils.cint(limit) if limit else None, + dry_run=frappe.utils.cint(dry_run), + ) + + +@frappe.whitelist() +def import_unifi_registrations(limit: int | None = None, dry_run: bool = False): + return import_unifi_devices(limit=limit, dry_run=dry_run) + + +@frappe.whitelist() +def migrate_unifi_registrations(limit: int | None = None, dry_run: bool = False): + return migrate_unifi_registrations_to_devices( + limit=frappe.utils.cint(limit) if limit else None, + dry_run=frappe.utils.cint(dry_run), + ) + + +@frappe.whitelist() +def reconcile_unifi_authorizations(dry_run: bool = False): + return reconcile_unifi_import_authorizations(dry_run=frappe.utils.cint(dry_run)) + + +@frappe.whitelist() +def provision_device_credential( + device_name: str, + username: str | None = None, + password: str | None = None, + generate_key: bool = False, + enable_cache: bool = True, + cache_expires_on: str | None = None, +): + return set_device_radius_credential( + device_name, + username=username, + secret=password, + generate_key=frappe.utils.cint(generate_key), + enable_cache=frappe.utils.cint(enable_cache), + cache_expires_on=cache_expires_on, + ) + + +@frappe.whitelist() +def provision_owner_credential( + owner_user: str, + username: str, + password: str | None = None, + generate_key: bool = False, +): + return set_owner_radius_credential( + owner_user, + username=username, + secret=password, + generate_key=frappe.utils.cint(generate_key), + ) + + +@frappe.whitelist() def radius_authorize( calling_station_id: str | None = None, username: str | None = None, nas_identifier: str | None = None, nas_ip_address: str | None = None, ssid: str | None = None, + raw_request: str | None = None, ): + request_payload = frappe.parse_json(raw_request) if raw_request else None return record_radius_auth_event( calling_station_id=calling_station_id, username=username, nas_identifier=nas_identifier, nas_ip_address=nas_ip_address, ssid=ssid, + raw_request=request_payload, ) diff --git a/device_manager/constants.py b/device_manager/constants.py index 78adde9..968c905 100644 --- a/device_manager/constants.py +++ b/device_manager/constants.py @@ -10,6 +10,7 @@ DEVICE_STATUSES = ( AUTHORIZATION_STATUSES = ( "Not Requested", + "Unauthorized", "Pending", "Authorized", "Denied", diff --git a/device_manager/credentials.py b/device_manager/credentials.py new file mode 100644 index 0000000..a280f81 --- /dev/null +++ b/device_manager/credentials.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import base64 +import hashlib +import secrets + +import frappe +from frappe import _ + +from device_manager.audit import emit_audit_event + +DEFAULT_RADIUS_HASH_SCHEME = "SSHA-Password" + + +def generate_deployment_key() -> str: + return secrets.token_urlsafe(32) + + +def hash_radius_secret(secret: str) -> tuple[str, str]: + if not secret: + frappe.throw(_("A password or deployment key is required.")) + + salt = secrets.token_bytes(16) + digest = hashlib.sha1(secret.encode("utf-8") + salt).digest() + return DEFAULT_RADIUS_HASH_SCHEME, base64.b64encode(digest + salt).decode("ascii") + + +def _is_system_manager() -> bool: + return "System Manager" in frappe.get_roles(frappe.session.user) + + +def assert_can_manage_device_credentials(device) -> None: + if _is_system_manager(): + return + if device.owner_user and device.owner_user == frappe.session.user: + return + frappe.throw(_("You can only manage credentials for devices you own."), frappe.PermissionError) + + +def assert_can_manage_owner_credentials(owner_user: str) -> None: + if _is_system_manager(): + return + if owner_user == frappe.session.user: + return + frappe.throw(_("You can only manage your own shared RADIUS credential."), frappe.PermissionError) + + +def set_device_radius_credential( + device_name: str, + *, + username: str | None = None, + secret: str | None = None, + generate_key: bool = False, + enable_cache: bool = True, + cache_expires_on: str | None = None, +) -> dict: + device = frappe.get_doc("DM Device", device_name) + assert_can_manage_device_credentials(device) + + deployment_key = generate_deployment_key() if generate_key else None + secret_to_hash = deployment_key or secret + hash_scheme, verifier = hash_radius_secret(secret_to_hash or "") + + device.radius_username = username or device.radius_username or device.primary_mac_address + device.radius_password_hash_scheme = hash_scheme + device.radius_password_hash = verifier + device.enable_radius_credential_cache = 1 if enable_cache else 0 + device.credential_cache_expires_on = cache_expires_on + device.flags.ignore_links = True + device.save(ignore_permissions=True) + + emit_audit_event( + "Device Credential Provisioned", + "DM Device", + device.name, + device=device.name, + decision="Updated", + payload={ + "scope": "Device", + "username": device.radius_username, + "hash_scheme": hash_scheme, + "generated_key": bool(deployment_key), + "cache_enabled": bool(enable_cache), + "cache_expires_on": cache_expires_on, + }, + ) + + return { + "device": device.name, + "username": device.radius_username, + "deployment_key": deployment_key, + "generated_key": bool(deployment_key), + } + + +def set_owner_radius_credential( + owner_user: str, + *, + username: str, + secret: str | None = None, + generate_key: bool = False, +) -> dict: + assert_can_manage_owner_credentials(owner_user) + + deployment_key = generate_deployment_key() if generate_key else None + secret_to_hash = deployment_key or secret + hash_scheme, verifier = hash_radius_secret(secret_to_hash or "") + + credential_name = frappe.db.get_value("DM Radius Credential", {"owner_user": owner_user}, "name") + if credential_name: + credential = frappe.get_doc("DM Radius Credential", credential_name) + else: + credential = frappe.new_doc("DM Radius Credential") + credential.owner_user = owner_user + + credential.radius_username = username + credential.radius_password_hash_scheme = hash_scheme + credential.radius_password_hash = verifier + credential.enabled = 1 + credential.save(ignore_permissions=True) + + emit_audit_event( + "Owner Credential Provisioned", + "DM Radius Credential", + credential.name, + decision="Updated", + payload={ + "scope": "Owner", + "owner_user": owner_user, + "username": username, + "hash_scheme": hash_scheme, + "generated_key": bool(deployment_key), + }, + ) + + return { + "credential": credential.name, + "owner_user": owner_user, + "username": credential.radius_username, + "deployment_key": deployment_key, + "generated_key": bool(deployment_key), + } + + +def get_owner_radius_credential(owner_user: str | None, username: str | None): + if not owner_user or not username: + return None + + return frappe.db.get_value( + "DM Radius Credential", + { + "owner_user": owner_user, + "radius_username": username, + "enabled": 1, + }, + ["name", "radius_username", "radius_password_hash", "radius_password_hash_scheme"], + as_dict=True, + ) diff --git a/device_manager/device_manager/doctype/dm_access_policy/dm_access_policy.json b/device_manager/device_manager/doctype/dm_access_policy/dm_access_policy.json index f8be3eb..4d11597 100644 --- a/device_manager/device_manager/doctype/dm_access_policy/dm_access_policy.json +++ b/device_manager/device_manager/doctype/dm_access_policy/dm_access_policy.json @@ -15,7 +15,7 @@ {"fieldname": "criteria_section", "fieldtype": "Section Break", "label": "Criteria"}, {"default": "Any", "fieldname": "target_device_type", "fieldtype": "Select", "label": "Target Device Type", "options": "Any\nPersonal Computer\nLaboratory Equipment\nIoT Device\nSensor\nServer\nOther"}, {"default": "Any", "fieldname": "target_lifecycle_status", "fieldtype": "Select", "label": "Target Lifecycle Status", "options": "Any\nDraft\nPending Approval\nApproved\nActive\nSuspended\nQuarantined\nRetired"}, - {"default": "Any", "fieldname": "target_authorization_status", "fieldtype": "Select", "label": "Target Authorization Status", "options": "Any\nNot Requested\nPending\nAuthorized\nDenied\nExpired\nRevoked"}, + {"default": "Any", "fieldname": "target_authorization_status", "fieldtype": "Select", "label": "Target Authorization Status", "options": "Any\nNot Requested\nUnauthorized\nPending\nAuthorized\nDenied\nExpired\nRevoked"}, {"default": "0", "fieldname": "requires_active_dataset_authorization", "fieldtype": "Check", "label": "Requires Active Dataset Authorization"}, {"fieldname": "notes", "fieldtype": "Small Text", "label": "Notes"} ], diff --git a/device_manager/device_manager/doctype/dm_device/dm_device.js b/device_manager/device_manager/doctype/dm_device/dm_device.js new file mode 100644 index 0000000..19580ae --- /dev/null +++ b/device_manager/device_manager/doctype/dm_device/dm_device.js @@ -0,0 +1,150 @@ +frappe.ui.form.on("DM Device", { + refresh(frm) { + if (frm.is_new()) { + return; + } + + frm.add_custom_button(__("Generate Device Key"), () => { + frappe.prompt( + [ + { + fieldname: "username", + fieldtype: "Data", + label: __("RADIUS Username"), + default: frm.doc.radius_username || frm.doc.primary_mac_address, + reqd: 1, + }, + { + fieldname: "enable_cache", + fieldtype: "Check", + label: __("Allow Offline FreeRADIUS Cache"), + default: frm.doc.enable_radius_credential_cache, + }, + { + fieldname: "cache_expires_on", + fieldtype: "Date", + label: __("Cache Expires On"), + default: frm.doc.credential_cache_expires_on, + }, + ], + (values) => { + frappe.call({ + method: "device_manager.api.provision_device_credential", + args: { + device_name: frm.doc.name, + username: values.username, + generate_key: 1, + enable_cache: values.enable_cache ? 1 : 0, + cache_expires_on: values.cache_expires_on, + }, + callback(r) { + frm.reload_doc(); + frappe.msgprint({ + title: __("Deployment Key Generated"), + message: `
${__( + "Save this key now. It will not be shown again." + )}
${frappe.utils.escape_html(
+ r.message.deployment_key || ""
+ )}`,
+ indicator: "green",
+ });
+ },
+ });
+ },
+ __("Generate Device Key"),
+ __("Generate")
+ );
+ });
+
+ frm.add_custom_button(__("Set Device Password"), () => {
+ frappe.prompt(
+ [
+ {
+ fieldname: "username",
+ fieldtype: "Data",
+ label: __("RADIUS Username"),
+ default: frm.doc.radius_username || frm.doc.primary_mac_address,
+ reqd: 1,
+ },
+ {
+ fieldname: "password",
+ fieldtype: "Password",
+ label: __("Password"),
+ reqd: 1,
+ },
+ {
+ fieldname: "enable_cache",
+ fieldtype: "Check",
+ label: __("Allow Offline FreeRADIUS Cache"),
+ default: frm.doc.enable_radius_credential_cache,
+ },
+ ],
+ (values) => {
+ frappe.call({
+ method: "device_manager.api.provision_device_credential",
+ args: {
+ device_name: frm.doc.name,
+ username: values.username,
+ password: values.password,
+ enable_cache: values.enable_cache ? 1 : 0,
+ },
+ callback() {
+ frm.reload_doc();
+ frappe.show_alert({
+ message: __("Device credential updated."),
+ indicator: "green",
+ });
+ },
+ });
+ },
+ __("Set Device Password"),
+ __("Save")
+ );
+ });
+
+ frm.add_custom_button(__("Generate Owner Shared Key"), () => {
+ frappe.prompt(
+ [
+ {
+ fieldname: "owner_user",
+ fieldtype: "Link",
+ label: __("Owner User"),
+ options: "User",
+ default: frm.doc.owner_user || frappe.session.user,
+ reqd: 1,
+ },
+ {
+ fieldname: "username",
+ fieldtype: "Data",
+ label: __("Shared RADIUS Username"),
+ default: frm.doc.owner_user || frappe.session.user,
+ reqd: 1,
+ },
+ ],
+ (values) => {
+ frappe.call({
+ method: "device_manager.api.provision_owner_credential",
+ args: {
+ owner_user: values.owner_user,
+ username: values.username,
+ generate_key: 1,
+ },
+ callback(r) {
+ frappe.msgprint({
+ title: __("Shared Key Generated"),
+ message: `${__( + "Save this key now. It will not be shown again." + )}
${frappe.utils.escape_html(
+ r.message.deployment_key || ""
+ )}`,
+ indicator: "green",
+ });
+ },
+ });
+ },
+ __("Generate Owner Shared Key"),
+ __("Generate")
+ );
+ });
+ },
+});
diff --git a/device_manager/device_manager/doctype/dm_device/dm_device.json b/device_manager/device_manager/doctype/dm_device/dm_device.json
index 4afab3c..652c648 100644
--- a/device_manager/device_manager/doctype/dm_device/dm_device.json
+++ b/device_manager/device_manager/doctype/dm_device/dm_device.json
@@ -22,7 +22,17 @@
"dataset_authorization",
"network_section",
"network_segment",
+ "current_unifi_client",
+ "current_ip_address",
+ "current_ssid",
+ "last_unifi_network",
"last_seen_on",
+ "radius_credentials_section",
+ "radius_username",
+ "radius_password_hash",
+ "radius_password_hash_scheme",
+ "enable_radius_credential_cache",
+ "credential_cache_expires_on",
"risk_notes",
"identifiers_section",
"identifiers"
@@ -34,7 +44,7 @@
{"fieldname": "primary_mac_address", "fieldtype": "Data", "in_list_view": 1, "label": "Primary MAC Address"},
{"fieldname": "column_break_identity", "fieldtype": "Column Break"},
{"default": "Draft", "fieldname": "lifecycle_status", "fieldtype": "Select", "in_list_view": 1, "label": "Lifecycle Status", "options": "Draft\nPending Approval\nApproved\nActive\nSuspended\nQuarantined\nRetired", "reqd": 1},
- {"default": "Not Requested", "fieldname": "authorization_status", "fieldtype": "Select", "in_list_view": 1, "label": "Authorization Status", "options": "Not Requested\nPending\nAuthorized\nDenied\nExpired\nRevoked", "reqd": 1},
+ {"default": "Not Requested", "fieldname": "authorization_status", "fieldtype": "Select", "in_list_view": 1, "label": "Authorization Status", "options": "Not Requested\nUnauthorized\nPending\nAuthorized\nDenied\nExpired\nRevoked", "reqd": 1},
{"fieldname": "owner_user", "fieldtype": "Link", "label": "Owner User", "options": "User"},
{"fieldname": "ownership_section", "fieldtype": "Section Break", "label": "Ownership and Research Context"},
{"fieldname": "project", "fieldtype": "Data", "in_list_view": 1, "label": "Project"},
@@ -44,7 +54,17 @@
{"fieldname": "dataset_authorization", "fieldtype": "Link", "label": "Current Dataset Authorization", "options": "DM Dataset Authorization", "read_only": 1},
{"fieldname": "network_section", "fieldtype": "Section Break", "label": "Network Access"},
{"fieldname": "network_segment", "fieldtype": "Link", "label": "Preferred Network Segment", "options": "DM Network Segment"},
+ {"fieldname": "current_unifi_client", "fieldtype": "Link", "label": "Current UniFi Client", "options": "DM UniFi Client", "read_only": 1},
+ {"fieldname": "current_ip_address", "fieldtype": "Data", "label": "Current IP Address", "read_only": 1},
+ {"fieldname": "current_ssid", "fieldtype": "Data", "label": "Current SSID", "read_only": 1},
+ {"fieldname": "last_unifi_network", "fieldtype": "Data", "label": "Last UniFi Network", "read_only": 1},
{"fieldname": "last_seen_on", "fieldtype": "Datetime", "label": "Last Seen On", "read_only": 1},
+ {"fieldname": "radius_credentials_section", "fieldtype": "Section Break", "label": "Device Credential"},
+ {"fieldname": "radius_username", "fieldtype": "Data", "in_list_view": 1, "label": "Device RADIUS Username", "unique": 1},
+ {"fieldname": "radius_password_hash", "fieldtype": "Code", "label": "Stored Credential Verifier", "read_only": 1},
+ {"default": "SSHA-Password", "fieldname": "radius_password_hash_scheme", "fieldtype": "Data", "hidden": 1, "label": "RADIUS Verifier Type", "read_only": 1},
+ {"default": "1", "fieldname": "enable_radius_credential_cache", "fieldtype": "Check", "label": "Allow FreeRADIUS Credential Cache"},
+ {"fieldname": "credential_cache_expires_on", "fieldtype": "Date", "label": "Credential Cache Expires On"},
{"fieldname": "risk_notes", "fieldtype": "Small Text", "label": "Risk Notes"},
{"fieldname": "identifiers_section", "fieldtype": "Section Break", "label": "Identifiers"},
{"fieldname": "identifiers", "fieldtype": "Table", "label": "Identifiers", "options": "DM Device Identifier"}
diff --git a/device_manager/device_manager/doctype/dm_device/dm_device.py b/device_manager/device_manager/doctype/dm_device/dm_device.py
index 9b16600..30e63e2 100644
--- a/device_manager/device_manager/doctype/dm_device/dm_device.py
+++ b/device_manager/device_manager/doctype/dm_device/dm_device.py
@@ -17,11 +17,33 @@ class DMDevice(Document):
if self.primary_mac_address:
append_identifier(self, "MAC Address", self.primary_mac_address, is_primary=True)
+ if self.radius_username:
+ existing_device = frappe.db.get_value(
+ "DM Device", {"radius_username": self.radius_username}, "name"
+ )
+ if existing_device and existing_device != self.name:
+ frappe.throw(_("RADIUS username is already assigned to device {0}.").format(existing_device))
+ existing_credential = frappe.db.get_value(
+ "DM Radius Credential",
+ {"radius_username": self.radius_username},
+ "name",
+ )
+ if existing_credential:
+ frappe.throw(
+ _("RADIUS username is already assigned to shared credential {0}.").format(
+ existing_credential
+ )
+ )
+
seen = set()
for row in self.get("identifiers") or []:
if row.identifier_type == "MAC Address":
row.identifier_value = normalize_mac_address(row.identifier_value)
key = (row.identifier_type, row.identifier_value)
if key in seen:
- frappe.throw(_("Duplicate device identifier: {0} {1}").format(row.identifier_type, row.identifier_value))
+ frappe.throw(
+ _("Duplicate device identifier: {0} {1}").format(
+ row.identifier_type, row.identifier_value
+ )
+ )
seen.add(key)
diff --git a/device_manager/device_manager/doctype/dm_device_registration/dm_device_registration.json b/device_manager/device_manager/doctype/dm_device_registration/dm_device_registration.json
index 822d4c2..b42aa9f 100644
--- a/device_manager/device_manager/doctype/dm_device_registration/dm_device_registration.json
+++ b/device_manager/device_manager/doctype/dm_device_registration/dm_device_registration.json
@@ -19,6 +19,8 @@
"laboratory",
"organizational_unit",
"dataset_use_category",
+ "network_authorization_section",
+ "network_segment",
"justification",
"approval_section",
"approved_by",
@@ -39,6 +41,8 @@
{"fieldname": "laboratory", "fieldtype": "Data", "label": "Laboratory"},
{"fieldname": "organizational_unit", "fieldtype": "Data", "label": "Organizational Unit"},
{"default": "None", "fieldname": "dataset_use_category", "fieldtype": "Select", "label": "NSF CICI Dataset Use Category", "options": "None\nCollection\nProcessing\nStorage\nTransmission\nAnalysis"},
+ {"fieldname": "network_authorization_section", "fieldtype": "Section Break", "label": "Network Authorization"},
+ {"fieldname": "network_segment", "fieldtype": "Link", "in_list_view": 1, "label": "Authorized Network Segment", "options": "DM Network Segment"},
{"fieldname": "justification", "fieldtype": "Small Text", "label": "Justification"},
{"fieldname": "approval_section", "fieldtype": "Section Break", "label": "Approval"},
{"fieldname": "approved_by", "fieldtype": "Link", "label": "Approved By", "options": "User", "read_only": 1},
diff --git a/device_manager/device_manager/doctype/dm_device_registration/dm_device_registration.py b/device_manager/device_manager/doctype/dm_device_registration/dm_device_registration.py
index ef8b9b0..e5d89c2 100644
--- a/device_manager/device_manager/doctype/dm_device_registration/dm_device_registration.py
+++ b/device_manager/device_manager/doctype/dm_device_registration/dm_device_registration.py
@@ -12,7 +12,14 @@ class DMDeviceRegistration(Document):
if self.primary_mac_address:
existing_device = find_device_by_identifier("MAC Address", self.primary_mac_address)
if existing_device and existing_device != self.approved_device:
- frappe.throw(_("A device already exists for MAC address {0}: {1}").format(self.primary_mac_address, existing_device))
+ frappe.throw(
+ _("A device already exists for MAC address {0}: {1}").format(
+ self.primary_mac_address, existing_device
+ )
+ )
+
+ if self.network_segment and not frappe.db.exists("DM Network Segment", self.network_segment):
+ frappe.throw(_("Authorized Network Segment does not exist: {0}").format(self.network_segment))
if self.status == "Draft":
self.status = "Submitted"
diff --git a/device_manager/device_manager/doctype/dm_network_segment/dm_network_segment.json b/device_manager/device_manager/doctype/dm_network_segment/dm_network_segment.json
index 1c736fa..e10d66e 100644
--- a/device_manager/device_manager/doctype/dm_network_segment/dm_network_segment.json
+++ b/device_manager/device_manager/doctype/dm_network_segment/dm_network_segment.json
@@ -5,11 +5,33 @@
"doctype": "DocType",
"document_type": "Document",
"engine": "InnoDB",
- "field_order": ["segment_name", "vlan_id", "is_quarantine_segment", "radius_reply_attributes", "description"],
+ "field_order": [
+ "segment_name",
+ "vlan_id",
+ "is_quarantine_segment",
+ "source_section",
+ "source",
+ "unifi_network_id",
+ "unifi_network_name",
+ "unifi_purpose",
+ "subnet",
+ "gateway",
+ "last_synced_from_unifi",
+ "radius_reply_attributes",
+ "description"
+ ],
"fields": [
{"fieldname": "segment_name", "fieldtype": "Data", "in_list_view": 1, "label": "Segment Name", "reqd": 1, "unique": 1},
{"fieldname": "vlan_id", "fieldtype": "Int", "in_list_view": 1, "label": "VLAN ID"},
{"default": "0", "fieldname": "is_quarantine_segment", "fieldtype": "Check", "in_list_view": 1, "label": "Quarantine Segment"},
+ {"fieldname": "source_section", "fieldtype": "Section Break", "label": "Source Mapping"},
+ {"default": "Manual", "fieldname": "source", "fieldtype": "Select", "in_list_view": 1, "label": "Source", "options": "Manual\nUniFi"},
+ {"fieldname": "unifi_network_id", "fieldtype": "Data", "in_list_view": 1, "label": "UniFi Network ID"},
+ {"fieldname": "unifi_network_name", "fieldtype": "Data", "label": "UniFi Network Name"},
+ {"fieldname": "unifi_purpose", "fieldtype": "Data", "label": "UniFi Purpose"},
+ {"fieldname": "subnet", "fieldtype": "Data", "label": "Subnet"},
+ {"fieldname": "gateway", "fieldtype": "Data", "label": "Gateway"},
+ {"fieldname": "last_synced_from_unifi", "fieldtype": "Datetime", "label": "Last Synced From UniFi", "read_only": 1},
{"fieldname": "radius_reply_attributes", "fieldtype": "JSON", "label": "RADIUS Reply Attributes"},
{"fieldname": "description", "fieldtype": "Small Text", "label": "Description"}
],
diff --git a/device_manager/device_manager/doctype/dm_network_segment/dm_network_segment.py b/device_manager/device_manager/doctype/dm_network_segment/dm_network_segment.py
index 5fea7d4..b9eaf92 100644
--- a/device_manager/device_manager/doctype/dm_network_segment/dm_network_segment.py
+++ b/device_manager/device_manager/doctype/dm_network_segment/dm_network_segment.py
@@ -9,6 +9,14 @@ class DMNetworkSegment(Document):
def validate(self):
if self.vlan_id is not None and (self.vlan_id < 1 or self.vlan_id > 4094):
frappe.throw(_("VLAN ID must be between 1 and 4094."))
+ if self.unifi_network_id:
+ existing_segment = frappe.db.get_value(
+ "DM Network Segment",
+ {"unifi_network_id": self.unifi_network_id},
+ "name",
+ )
+ if existing_segment and existing_segment != self.name:
+ frappe.throw(_("UniFi Network ID is already mapped to segment {0}.").format(existing_segment))
if self.radius_reply_attributes:
try:
json.loads(self.radius_reply_attributes)
diff --git a/device_manager/device_manager/doctype/dm_radius_credential/__init__.py b/device_manager/device_manager/doctype/dm_radius_credential/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_radius_credential/__init__.py
@@ -0,0 +1 @@
+
diff --git a/device_manager/device_manager/doctype/dm_radius_credential/dm_radius_credential.json b/device_manager/device_manager/doctype/dm_radius_credential/dm_radius_credential.json
new file mode 100644
index 0000000..e268652
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_radius_credential/dm_radius_credential.json
@@ -0,0 +1,39 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "DM-RC-.#####",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "engine": "InnoDB",
+ "field_order": [
+ "enabled",
+ "owner_user",
+ "radius_username",
+ "credential_section",
+ "radius_password_hash",
+ "radius_password_hash_scheme",
+ "notes"
+ ],
+ "fields": [
+ {"default": "1", "fieldname": "enabled", "fieldtype": "Check", "in_list_view": 1, "label": "Enabled"},
+ {"fieldname": "owner_user", "fieldtype": "Link", "in_list_view": 1, "label": "Owner User", "options": "User", "reqd": 1},
+ {"fieldname": "radius_username", "fieldtype": "Data", "in_list_view": 1, "label": "RADIUS Username", "reqd": 1, "unique": 1},
+ {"fieldname": "credential_section", "fieldtype": "Section Break", "label": "Stored Verifier"},
+ {"fieldname": "radius_password_hash", "fieldtype": "Code", "label": "RADIUS Credential Verifier", "read_only": 1},
+ {"default": "SSHA-Password", "fieldname": "radius_password_hash_scheme", "fieldtype": "Data", "hidden": 1, "label": "RADIUS Verifier Type", "read_only": 1},
+ {"fieldname": "notes", "fieldtype": "Small Text", "label": "Notes"}
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "module": "Device Manager",
+ "name": "DM Radius Credential",
+ "permissions": [
+ {"create": 1, "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "System Manager", "share": 1, "write": 1}
+ ],
+ "row_format": "Dynamic",
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
+}
diff --git a/device_manager/device_manager/doctype/dm_radius_credential/dm_radius_credential.py b/device_manager/device_manager/doctype/dm_radius_credential/dm_radius_credential.py
new file mode 100644
index 0000000..bc5e026
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_radius_credential/dm_radius_credential.py
@@ -0,0 +1,39 @@
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+
+class DMRadiusCredential(Document):
+ def validate(self):
+ if not self.owner_user:
+ frappe.throw(_("Owner User is required."))
+ if not self.radius_username:
+ frappe.throw(_("RADIUS Username is required."))
+ if not self.radius_password_hash:
+ frappe.throw(
+ _(
+ "Credential verifier is required. Use the provisioning action instead of editing this directly."
+ )
+ )
+
+ existing_for_owner = frappe.db.get_value(
+ "DM Radius Credential", {"owner_user": self.owner_user}, "name"
+ )
+ if existing_for_owner and existing_for_owner != self.name:
+ frappe.throw(_("A shared RADIUS credential already exists for user {0}.").format(self.owner_user))
+
+ existing_for_username = frappe.db.get_value(
+ "DM Radius Credential",
+ {"radius_username": self.radius_username},
+ "name",
+ )
+ if existing_for_username and existing_for_username != self.name:
+ frappe.throw(
+ _("RADIUS username is already assigned to shared credential {0}.").format(
+ existing_for_username
+ )
+ )
+
+ existing_device = frappe.db.get_value("DM Device", {"radius_username": self.radius_username}, "name")
+ if existing_device:
+ frappe.throw(_("RADIUS username is already assigned to device {0}.").format(existing_device))
diff --git a/device_manager/device_manager/doctype/dm_unifi_client/__init__.py b/device_manager/device_manager/doctype/dm_unifi_client/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_client/__init__.py
@@ -0,0 +1 @@
+
diff --git a/device_manager/device_manager/doctype/dm_unifi_client/dm_unifi_client.json b/device_manager/device_manager/doctype/dm_unifi_client/dm_unifi_client.json
new file mode 100644
index 0000000..e19b1d3
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_client/dm_unifi_client.json
@@ -0,0 +1,48 @@
+{
+ "doctype": "DocType",
+ "name": "DM UniFi Client",
+ "module": "Device Manager",
+ "custom": 0,
+ "is_virtual": 1,
+ "issingle": 0,
+ "istable": 0,
+ "permissions": [
+ {"role": "System Manager", "permlevel": 0, "read": 1}
+ ],
+ "field_order": [
+ "client_id",
+ "hostname",
+ "mac_address",
+ "ip_address",
+ "network",
+ "ssid",
+ "ap_mac",
+ "switch_mac",
+ "is_wired",
+ "vlan",
+ "uptime",
+ "last_seen",
+ "authorized",
+ "blocked",
+ "raw_json"
+ ],
+ "fields": [
+ {"fieldname": "client_id", "label": "Client ID", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "hostname", "label": "Hostname", "fieldtype": "Data", "in_list_view": 1, "bold": 1, "read_only": 1},
+ {"fieldname": "mac_address", "label": "MAC Address", "fieldtype": "Data", "in_list_view": 1, "read_only": 1},
+ {"fieldname": "ip_address", "label": "IP Address", "fieldtype": "Data", "in_list_view": 1, "read_only": 1},
+ {"fieldname": "network", "label": "Network", "fieldtype": "Data", "in_list_view": 1, "read_only": 1},
+ {"fieldname": "ssid", "label": "SSID", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "ap_mac", "label": "AP MAC", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "switch_mac", "label": "Switch MAC", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "is_wired", "label": "Wired", "fieldtype": "Check", "read_only": 1},
+ {"fieldname": "vlan", "label": "VLAN", "fieldtype": "Int", "read_only": 1},
+ {"fieldname": "uptime", "label": "Uptime", "fieldtype": "Int", "read_only": 1},
+ {"fieldname": "last_seen", "label": "Last Seen", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "authorized", "label": "Authorized", "fieldtype": "Check", "read_only": 1},
+ {"fieldname": "blocked", "label": "Blocked", "fieldtype": "Check", "read_only": 1},
+ {"fieldname": "raw_json", "label": "Raw JSON", "fieldtype": "JSON", "read_only": 1}
+ ],
+ "title_field": "hostname",
+ "search_fields": "hostname,mac_address,ip_address,network,ssid"
+}
diff --git a/device_manager/device_manager/doctype/dm_unifi_client/dm_unifi_client.py b/device_manager/device_manager/doctype/dm_unifi_client/dm_unifi_client.py
new file mode 100644
index 0000000..edf95a6
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_client/dm_unifi_client.py
@@ -0,0 +1,76 @@
+import frappe
+from frappe.model.document import Document
+
+
+def _matches_search(row: dict, conditions: list[tuple[str, str]]) -> bool:
+ return any(pattern in str(row.get(fieldname) or "").lower() for fieldname, pattern in conditions)
+
+
+def _get_like_conditions(args=None, **kwargs) -> list[tuple[str, str]]:
+ raw_or_filters = (args or {}).get("or_filters") or kwargs.get("or_filters") or []
+ conditions = []
+ for item in raw_or_filters:
+ if not isinstance(item, (list, tuple)) or len(item) < 3:
+ continue
+ fieldname = item[1] if len(item) == 4 else item[0]
+ operator = item[2] if len(item) == 4 else item[1]
+ value = item[3] if len(item) == 4 else item[2]
+ if str(operator).lower() == "like" and value:
+ pattern = str(value).strip("%").lower()
+ if pattern:
+ conditions.append((fieldname, pattern))
+ return conditions
+
+
+def _as_list(rows: list[dict], kwargs) -> list:
+ raw_fields = kwargs.get("fields") or ["name", "hostname", "mac_address", "ip_address"]
+ fields = [
+ field.split(" as ")[0].strip()
+ if isinstance(field, str)
+ else field.get("fieldname")
+ if isinstance(field, dict)
+ else None
+ for field in raw_fields
+ ]
+ fields = [field for field in fields if field]
+ if "name" not in fields:
+ fields = ["name", *fields]
+ return [[row.get(field) for field in fields] for row in rows]
+
+
+def _strip_raw_json_for_list(rows: list[dict]) -> list[dict]:
+ return [{key: value for key, value in row.items() if key != "raw_json"} for row in rows]
+
+
+class DMUniFiClient(Document):
+ def load_from_db(self) -> None:
+ from device_manager.device_manager.utils.unifi_client import get_client
+
+ row = get_client(self.name)
+ if row is None:
+ raise frappe.DoesNotExistError(f"UniFi Client '{self.name}' not found.")
+ super(Document, self).__init__(row)
+
+ @staticmethod
+ def get_list(args=None, start: int = 0, page_length: int = 20, **kwargs) -> list:
+ from device_manager.device_manager.utils.unifi_client import get_clients
+
+ rows = get_clients()
+ conditions = _get_like_conditions(args, **kwargs)
+ if conditions:
+ rows = [row for row in rows if _matches_search(row, conditions)]
+ rows = rows[start : start + page_length] if page_length else rows[start:]
+ return _as_list(rows, kwargs) if kwargs.get("as_list") else _strip_raw_json_for_list(rows)
+
+ @staticmethod
+ def get_count(args=None, **kwargs) -> int:
+ return len(DMUniFiClient.get_list(args=args, start=0, page_length=0, **kwargs))
+
+ def db_insert(self, *args, **kwargs) -> None:
+ frappe.throw("DM UniFi Client is a read-only virtual doctype; data is managed in UniFi.")
+
+ def db_update(self) -> None:
+ frappe.throw("DM UniFi Client is a read-only virtual doctype; data is managed in UniFi.")
+
+ def delete(self) -> None:
+ frappe.throw("DM UniFi Client is a read-only virtual doctype; data is managed in UniFi.")
diff --git a/device_manager/device_manager/doctype/dm_unifi_network/__init__.py b/device_manager/device_manager/doctype/dm_unifi_network/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_network/__init__.py
@@ -0,0 +1 @@
+
diff --git a/device_manager/device_manager/doctype/dm_unifi_network/dm_unifi_network.json b/device_manager/device_manager/doctype/dm_unifi_network/dm_unifi_network.json
new file mode 100644
index 0000000..576e749
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_network/dm_unifi_network.json
@@ -0,0 +1,25 @@
+{
+ "doctype": "DocType",
+ "name": "DM UniFi Network",
+ "module": "Device Manager",
+ "custom": 0,
+ "is_virtual": 1,
+ "issingle": 0,
+ "istable": 0,
+ "permissions": [
+ {"role": "System Manager", "permlevel": 0, "read": 1}
+ ],
+ "field_order": ["network_id", "network_name", "purpose", "vlan", "subnet", "gateway", "enabled", "raw_json"],
+ "fields": [
+ {"fieldname": "network_id", "label": "Network ID", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "network_name", "label": "Network Name", "fieldtype": "Data", "in_list_view": 1, "bold": 1, "read_only": 1},
+ {"fieldname": "purpose", "label": "Purpose", "fieldtype": "Data", "in_list_view": 1, "read_only": 1},
+ {"fieldname": "vlan", "label": "VLAN", "fieldtype": "Int", "in_list_view": 1, "read_only": 1},
+ {"fieldname": "subnet", "label": "Subnet", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "gateway", "label": "Gateway", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "enabled", "label": "Enabled", "fieldtype": "Check", "read_only": 1},
+ {"fieldname": "raw_json", "label": "Raw JSON", "fieldtype": "JSON", "read_only": 1}
+ ],
+ "title_field": "network_name",
+ "search_fields": "network_name,purpose,subnet,gateway"
+}
diff --git a/device_manager/device_manager/doctype/dm_unifi_network/dm_unifi_network.py b/device_manager/device_manager/doctype/dm_unifi_network/dm_unifi_network.py
new file mode 100644
index 0000000..9a42a2b
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_network/dm_unifi_network.py
@@ -0,0 +1,43 @@
+import frappe
+from frappe.model.document import Document
+
+from device_manager.device_manager.doctype.dm_unifi_client.dm_unifi_client import (
+ _as_list,
+ _get_like_conditions,
+ _matches_search,
+ _strip_raw_json_for_list,
+)
+
+
+class DMUniFiNetwork(Document):
+ def load_from_db(self) -> None:
+ from device_manager.device_manager.utils.unifi_client import get_network
+
+ row = get_network(self.name)
+ if row is None:
+ raise frappe.DoesNotExistError(f"UniFi Network '{self.name}' not found.")
+ super(Document, self).__init__(row)
+
+ @staticmethod
+ def get_list(args=None, start: int = 0, page_length: int = 20, **kwargs) -> list:
+ from device_manager.device_manager.utils.unifi_client import get_networks
+
+ rows = get_networks()
+ conditions = _get_like_conditions(args, **kwargs)
+ if conditions:
+ rows = [row for row in rows if _matches_search(row, conditions)]
+ rows = rows[start : start + page_length] if page_length else rows[start:]
+ return _as_list(rows, kwargs) if kwargs.get("as_list") else _strip_raw_json_for_list(rows)
+
+ @staticmethod
+ def get_count(args=None, **kwargs) -> int:
+ return len(DMUniFiNetwork.get_list(args=args, start=0, page_length=0, **kwargs))
+
+ def db_insert(self, *args, **kwargs) -> None:
+ frappe.throw("DM UniFi Network is a read-only virtual doctype; data is managed in UniFi.")
+
+ def db_update(self) -> None:
+ frappe.throw("DM UniFi Network is a read-only virtual doctype; data is managed in UniFi.")
+
+ def delete(self) -> None:
+ frappe.throw("DM UniFi Network is a read-only virtual doctype; data is managed in UniFi.")
diff --git a/device_manager/device_manager/doctype/dm_unifi_network_device/__init__.py b/device_manager/device_manager/doctype/dm_unifi_network_device/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_network_device/__init__.py
@@ -0,0 +1 @@
+
diff --git a/device_manager/device_manager/doctype/dm_unifi_network_device/dm_unifi_network_device.json b/device_manager/device_manager/doctype/dm_unifi_network_device/dm_unifi_network_device.json
new file mode 100644
index 0000000..190b442
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_network_device/dm_unifi_network_device.json
@@ -0,0 +1,28 @@
+{
+ "doctype": "DocType",
+ "name": "DM UniFi Network Device",
+ "module": "Device Manager",
+ "custom": 0,
+ "is_virtual": 1,
+ "issingle": 0,
+ "istable": 0,
+ "permissions": [
+ {"role": "System Manager", "permlevel": 0, "read": 1}
+ ],
+ "field_order": ["device_id", "device_name", "mac_address", "type", "model", "ip_address", "version", "state", "adopted", "last_seen", "raw_json"],
+ "fields": [
+ {"fieldname": "device_id", "label": "Device ID", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "device_name", "label": "Device Name", "fieldtype": "Data", "in_list_view": 1, "bold": 1, "read_only": 1},
+ {"fieldname": "mac_address", "label": "MAC Address", "fieldtype": "Data", "in_list_view": 1, "read_only": 1},
+ {"fieldname": "type", "label": "Type", "fieldtype": "Data", "in_list_view": 1, "read_only": 1},
+ {"fieldname": "model", "label": "Model", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "ip_address", "label": "IP Address", "fieldtype": "Data", "in_list_view": 1, "read_only": 1},
+ {"fieldname": "version", "label": "Version", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "state", "label": "State", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "adopted", "label": "Adopted", "fieldtype": "Check", "read_only": 1},
+ {"fieldname": "last_seen", "label": "Last Seen", "fieldtype": "Data", "read_only": 1},
+ {"fieldname": "raw_json", "label": "Raw JSON", "fieldtype": "JSON", "read_only": 1}
+ ],
+ "title_field": "device_name",
+ "search_fields": "device_name,mac_address,ip_address,model,type"
+}
diff --git a/device_manager/device_manager/doctype/dm_unifi_network_device/dm_unifi_network_device.py b/device_manager/device_manager/doctype/dm_unifi_network_device/dm_unifi_network_device.py
new file mode 100644
index 0000000..d8e31c2
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_network_device/dm_unifi_network_device.py
@@ -0,0 +1,43 @@
+import frappe
+from frappe.model.document import Document
+
+from device_manager.device_manager.doctype.dm_unifi_client.dm_unifi_client import (
+ _as_list,
+ _get_like_conditions,
+ _matches_search,
+ _strip_raw_json_for_list,
+)
+
+
+class DMUniFiNetworkDevice(Document):
+ def load_from_db(self) -> None:
+ from device_manager.device_manager.utils.unifi_client import get_network_device
+
+ row = get_network_device(self.name)
+ if row is None:
+ raise frappe.DoesNotExistError(f"UniFi Network Device '{self.name}' not found.")
+ super(Document, self).__init__(row)
+
+ @staticmethod
+ def get_list(args=None, start: int = 0, page_length: int = 20, **kwargs) -> list:
+ from device_manager.device_manager.utils.unifi_client import get_network_devices
+
+ rows = get_network_devices()
+ conditions = _get_like_conditions(args, **kwargs)
+ if conditions:
+ rows = [row for row in rows if _matches_search(row, conditions)]
+ rows = rows[start : start + page_length] if page_length else rows[start:]
+ return _as_list(rows, kwargs) if kwargs.get("as_list") else _strip_raw_json_for_list(rows)
+
+ @staticmethod
+ def get_count(args=None, **kwargs) -> int:
+ return len(DMUniFiNetworkDevice.get_list(args=args, start=0, page_length=0, **kwargs))
+
+ def db_insert(self, *args, **kwargs) -> None:
+ frappe.throw("DM UniFi Network Device is a read-only virtual doctype; data is managed in UniFi.")
+
+ def db_update(self) -> None:
+ frappe.throw("DM UniFi Network Device is a read-only virtual doctype; data is managed in UniFi.")
+
+ def delete(self) -> None:
+ frappe.throw("DM UniFi Network Device is a read-only virtual doctype; data is managed in UniFi.")
diff --git a/device_manager/device_manager/doctype/dm_unifi_settings/__init__.py b/device_manager/device_manager/doctype/dm_unifi_settings/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_settings/__init__.py
@@ -0,0 +1 @@
+
diff --git a/device_manager/device_manager/doctype/dm_unifi_settings/dm_unifi_settings.js b/device_manager/device_manager/doctype/dm_unifi_settings/dm_unifi_settings.js
new file mode 100644
index 0000000..4fc2280
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_settings/dm_unifi_settings.js
@@ -0,0 +1,93 @@
+frappe.ui.form.on("DM UniFi Settings", {
+ refresh(frm) {
+ frm.add_custom_button(__("Sync Network Segments"), () => {
+ frappe.call({
+ method: "device_manager.api.sync_unifi_segments",
+ callback(r) {
+ frappe.msgprint(
+ __("Created {0}, updated {1}, skipped {2}.", [
+ (r.message?.created || []).length,
+ (r.message?.updated || []).length,
+ (r.message?.skipped || []).length,
+ ])
+ );
+ },
+ });
+ });
+
+ frm.add_custom_button(__("Reconcile Registered Devices"), () => {
+ frappe.call({
+ method: "device_manager.api.reconcile_unifi_devices",
+ callback(r) {
+ frappe.msgprint(
+ __("Matched {0} registered devices; {1} live clients remain unmatched.", [
+ (r.message?.matched || []).length,
+ (r.message?.unmatched || []).length,
+ ])
+ );
+ },
+ });
+ });
+
+ frm.add_custom_button(__("Import Live Clients as Devices"), () => {
+ frappe.confirm(
+ __("Create authorized DM Device records for unmatched UniFi clients?"),
+ () => {
+ frappe.call({
+ method: "device_manager.api.import_unifi_devices",
+ callback(r) {
+ frappe.msgprint(
+ __("Created {0} devices; skipped {1}.", [
+ (r.message?.created || []).length,
+ (r.message?.skipped || []).length,
+ ])
+ );
+ },
+ });
+ }
+ );
+ });
+
+ frm.add_custom_button(__("Migrate UniFi Registrations"), () => {
+ frappe.confirm(
+ __(
+ "Convert pending registrations that were imported from UniFi telemetry into authorized DM Device records?"
+ ),
+ () => {
+ frappe.call({
+ method: "device_manager.api.migrate_unifi_registrations",
+ callback(r) {
+ frappe.msgprint(
+ __("Converted {0} registrations; skipped {1}.", [
+ (r.message?.created || []).length,
+ (r.message?.skipped || []).length,
+ ])
+ );
+ },
+ });
+ }
+ );
+ });
+
+ frm.add_custom_button(__("Reconcile Import Authorization"), () => {
+ frappe.confirm(
+ __(
+ "Mark UniFi-imported devices unauthorized unless an approved registration is linked to the device?"
+ ),
+ () => {
+ frappe.call({
+ method: "device_manager.api.reconcile_unifi_authorizations",
+ callback(r) {
+ frappe.msgprint(
+ __("Updated {0} devices; kept {1}.", [
+ (r.message?.updated || []).length,
+ (r.message?.kept || []).length,
+ ])
+ );
+ },
+ });
+ }
+ );
+ });
+ },
+});
diff --git a/device_manager/device_manager/doctype/dm_unifi_settings/dm_unifi_settings.json b/device_manager/device_manager/doctype/dm_unifi_settings/dm_unifi_settings.json
new file mode 100644
index 0000000..2f28360
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_settings/dm_unifi_settings.json
@@ -0,0 +1,42 @@
+{
+ "doctype": "DocType",
+ "name": "DM UniFi Settings",
+ "module": "Device Manager",
+ "custom": 0,
+ "issingle": 1,
+ "istable": 0,
+ "track_changes": 1,
+ "permissions": [
+ {
+ "role": "System Manager",
+ "permlevel": 0,
+ "read": 1,
+ "write": 1,
+ "create": 1
+ }
+ ],
+ "field_order": [
+ "connection_section",
+ "controller_url",
+ "controller_type",
+ "site_id",
+ "verify_tls",
+ "request_timeout",
+ "auth_section",
+ "api_token",
+ "username",
+ "password"
+ ],
+ "fields": [
+ {"fieldname": "connection_section", "label": "Connection", "fieldtype": "Section Break"},
+ {"fieldname": "controller_url", "label": "Controller URL", "fieldtype": "Data", "description": "Base URL for UniFi OS or Network Controller, e.g. https://unifi.example.edu"},
+ {"default": "UniFi OS", "fieldname": "controller_type", "label": "Controller Type", "fieldtype": "Select", "options": "UniFi OS\nSelf Hosted Network"},
+ {"default": "default", "fieldname": "site_id", "label": "Site ID", "fieldtype": "Data"},
+ {"default": "1", "fieldname": "verify_tls", "label": "Verify TLS", "fieldtype": "Check"},
+ {"default": "10", "fieldname": "request_timeout", "label": "Request Timeout (seconds)", "fieldtype": "Float"},
+ {"fieldname": "auth_section", "label": "Authentication", "fieldtype": "Section Break"},
+ {"fieldname": "api_token", "label": "API Token", "fieldtype": "Password", "description": "Preferred where supported. Stored encrypted."},
+ {"fieldname": "username", "label": "Username", "fieldtype": "Data", "description": "Fallback for local UniFi users when API tokens are unavailable."},
+ {"fieldname": "password", "label": "Password", "fieldtype": "Password", "description": "Stored encrypted. Use a least-privilege UniFi account."}
+ ]
+}
diff --git a/device_manager/device_manager/doctype/dm_unifi_settings/dm_unifi_settings.py b/device_manager/device_manager/doctype/dm_unifi_settings/dm_unifi_settings.py
new file mode 100644
index 0000000..bbcf941
--- /dev/null
+++ b/device_manager/device_manager/doctype/dm_unifi_settings/dm_unifi_settings.py
@@ -0,0 +1,5 @@
+from frappe.model.document import Document
+
+
+class DMUniFiSettings(Document):
+ pass
diff --git a/device_manager/device_manager/utils/__init__.py b/device_manager/device_manager/utils/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/device_manager/device_manager/utils/__init__.py
@@ -0,0 +1 @@
+
diff --git a/device_manager/device_manager/utils/unifi_client.py b/device_manager/device_manager/utils/unifi_client.py
new file mode 100644
index 0000000..e4cef33
--- /dev/null
+++ b/device_manager/device_manager/utils/unifi_client.py
@@ -0,0 +1,201 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+import frappe
+import requests
+
+_SAFE_ID_RE = re.compile(r"^[\w:.\-]+$")
+
+
+def _safe_id(value: str) -> str:
+ if not _SAFE_ID_RE.match(value):
+ frappe.throw(f"Invalid UniFi identifier: {value!r}")
+ return value
+
+
+def _settings():
+ settings = frappe.get_single("DM UniFi Settings")
+ base_url = (settings.controller_url or "").rstrip("/")
+ if not base_url:
+ frappe.throw("UniFi Controller URL is not configured.")
+
+ site_id = settings.site_id or "default"
+ _safe_id(site_id)
+ return settings, base_url, site_id
+
+
+def _network_api_prefix(settings, site_id: str) -> str:
+ if settings.controller_type == "Self Hosted Network":
+ return f"/api/s/{site_id}"
+ return f"/proxy/network/api/s/{site_id}"
+
+
+def _has_local_credentials(settings) -> bool:
+ username = settings.username
+ password = settings.get_password("password") if settings.get("password") else None
+ return bool(username and password)
+
+
+def _login_with_local_credentials(session: requests.Session, settings, base_url: str, timeout: float) -> None:
+ username = settings.username
+ password = settings.get_password("password") if settings.get("password") else None
+ if not username or not password:
+ frappe.throw("Configure either a UniFi API token or local UniFi username/password.")
+
+ if settings.controller_type == "Self Hosted Network":
+ login_path = "/api/login"
+ payload = {"username": username, "password": password}
+ else:
+ login_path = "/api/auth/login"
+ payload = {"username": username, "password": password, "rememberMe": True}
+
+ response = session.post(f"{base_url}{login_path}", json=payload, timeout=timeout)
+ response.raise_for_status()
+ csrf_token = response.headers.get("X-CSRF-Token") or response.headers.get("x-csrf-token")
+ if csrf_token:
+ session.headers.update({"X-CSRF-Token": csrf_token})
+
+
+def _build_session(*, prefer_token: bool = True) -> tuple[requests.Session, str, str, float, bool]:
+ settings, base_url, site_id = _settings()
+ session = requests.Session()
+ session.verify = bool(settings.verify_tls)
+ timeout = float(settings.request_timeout or 10)
+ session.headers.update({"Accept": "application/json", "Content-Type": "application/json"})
+
+ api_token = settings.get_password("api_token") if settings.get("api_token") else None
+ if prefer_token and api_token:
+ session.headers.update({"X-API-KEY": api_token})
+ return session, base_url, site_id, timeout, True
+
+ _login_with_local_credentials(session, settings, base_url, timeout)
+ return session, base_url, site_id, timeout, False
+
+
+def _extract_rows(payload: Any) -> list[dict]:
+ if isinstance(payload, dict):
+ if isinstance(payload.get("data"), list):
+ return payload["data"]
+ if isinstance(payload.get("clients"), list):
+ return payload["clients"]
+ if isinstance(payload.get("devices"), list):
+ return payload["devices"]
+ if isinstance(payload.get("networks"), list):
+ return payload["networks"]
+ if isinstance(payload, list):
+ return payload
+ return []
+
+
+def _get_json(path: str) -> Any:
+ settings, _base_url, _site_id = _settings()
+ session, base_url, _site_id, timeout, used_token = _build_session(prefer_token=True)
+ response = session.get(f"{base_url}{path}", timeout=timeout)
+ if response.status_code == 401 and used_token and _has_local_credentials(settings):
+ session, base_url, _site_id, timeout, _used_token = _build_session(prefer_token=False)
+ response = session.get(f"{base_url}{path}", timeout=timeout)
+ response.raise_for_status()
+ return response.json()
+
+
+def _normalize_client(row: dict) -> dict:
+ mac = row.get("mac") or row.get("_id") or ""
+ return {
+ "name": mac,
+ "client_id": mac,
+ "mac_address": mac,
+ "hostname": row.get("hostname") or row.get("name") or row.get("display_name") or "",
+ "ip_address": row.get("ip") or row.get("fixed_ip") or "",
+ "network": row.get("network") or row.get("network_name") or "",
+ "ssid": row.get("essid") or row.get("ssid") or "",
+ "ap_mac": row.get("ap_mac") or row.get("radio_ap") or "",
+ "switch_mac": row.get("sw_mac") or "",
+ "is_wired": 1 if row.get("is_wired") else 0,
+ "vlan": row.get("vlan") or row.get("vlan_id"),
+ "uptime": row.get("uptime") or 0,
+ "last_seen": row.get("last_seen") or row.get("latest_assoc_time") or "",
+ "authorized": 1 if row.get("authorized") else 0,
+ "blocked": 1 if row.get("blocked") else 0,
+ "raw_json": frappe.as_json(row),
+ }
+
+
+def _normalize_network_device(row: dict) -> dict:
+ mac = row.get("mac") or row.get("_id") or ""
+ return {
+ "name": mac,
+ "device_id": mac,
+ "mac_address": mac,
+ "device_name": row.get("name") or row.get("displayable_version") or row.get("model") or mac,
+ "model": row.get("model") or "",
+ "type": row.get("type") or row.get("device_type") or "",
+ "ip_address": row.get("ip") or "",
+ "version": row.get("version") or "",
+ "state": row.get("state") or row.get("adopted") or "",
+ "adopted": 1 if row.get("adopted") else 0,
+ "last_seen": row.get("last_seen") or "",
+ "raw_json": frappe.as_json(row),
+ }
+
+
+def _normalize_network(row: dict) -> dict:
+ network_id = row.get("_id") or row.get("id") or row.get("name") or row.get("attr_no_delete") or ""
+ name = row.get("name") or network_id
+ vlan = row.get("vlan") or row.get("vlan_id")
+ if vlan in (None, "") and row.get("purpose") in ("corporate", "lan"):
+ vlan = 1
+ return {
+ "name": str(network_id),
+ "network_id": str(network_id),
+ "network_name": name,
+ "purpose": row.get("purpose") or "",
+ "vlan": vlan,
+ "subnet": row.get("ip_subnet") or row.get("subnet") or "",
+ "gateway": row.get("gateway") or "",
+ "enabled": 0 if row.get("enabled") is False else 1,
+ "raw_json": frappe.as_json(row),
+ }
+
+
+def get_clients() -> list[dict]:
+ _settings_obj, _base_url, site_id = _settings()
+ rows = _extract_rows(_get_json(f"{_network_api_prefix(_settings_obj, site_id)}/stat/sta"))
+ return [_normalize_client(row) for row in rows if row.get("mac") or row.get("_id")]
+
+
+def get_client(client_id: str) -> dict | None:
+ _safe_id(client_id)
+ for client in get_clients():
+ if client.get("name") == client_id or client.get("mac_address") == client_id:
+ return client
+ return None
+
+
+def get_network_devices() -> list[dict]:
+ settings, _base_url, site_id = _settings()
+ rows = _extract_rows(_get_json(f"{_network_api_prefix(settings, site_id)}/stat/device"))
+ return [_normalize_network_device(row) for row in rows if row.get("mac") or row.get("_id")]
+
+
+def get_network_device(device_id: str) -> dict | None:
+ _safe_id(device_id)
+ for device in get_network_devices():
+ if device.get("name") == device_id or device.get("mac_address") == device_id:
+ return device
+ return None
+
+
+def get_networks() -> list[dict]:
+ settings, _base_url, site_id = _settings()
+ rows = _extract_rows(_get_json(f"{_network_api_prefix(settings, site_id)}/rest/networkconf"))
+ return [_normalize_network(row) for row in rows if row.get("_id") or row.get("id") or row.get("name")]
+
+
+def get_network(network_id: str) -> dict | None:
+ _safe_id(network_id)
+ for network in get_networks():
+ if network.get("name") == network_id or network.get("network_name") == network_id:
+ return network
+ return None
diff --git a/device_manager/device_manager/workspace/device_manager/device_manager.json b/device_manager/device_manager/workspace/device_manager/device_manager.json
index d31df90..e7601dc 100644
--- a/device_manager/device_manager/workspace/device_manager/device_manager.json
+++ b/device_manager/device_manager/workspace/device_manager/device_manager.json
@@ -1,7 +1,7 @@
{
"app": "device_manager",
"charts": [],
- "content": "[{\"id\":\"dm_header_overview\",\"type\":\"header\",\"data\":{\"text\":\"Device Manager\",\"col\":12}},{\"id\":\"dm_card_lifecycle\",\"type\":\"card\",\"data\":{\"card_name\":\"Device Lifecycle\",\"col\":3}},{\"id\":\"dm_card_access\",\"type\":\"card\",\"data\":{\"card_name\":\"Access Control\",\"col\":3}},{\"id\":\"dm_card_radius\",\"type\":\"card\",\"data\":{\"card_name\":\"RADIUS Evidence\",\"col\":3}},{\"id\":\"dm_card_audit\",\"type\":\"card\",\"data\":{\"card_name\":\"Audit & Dataset Use\",\"col\":3}}]",
+ "content": "[{\"id\":\"dm_header_overview\",\"type\":\"header\",\"data\":{\"text\":\"Device Manager\",\"col\":12}},{\"id\":\"dm_card_lifecycle\",\"type\":\"card\",\"data\":{\"card_name\":\"Device Lifecycle\",\"col\":3}},{\"id\":\"dm_card_access\",\"type\":\"card\",\"data\":{\"card_name\":\"Access Control\",\"col\":3}},{\"id\":\"dm_card_radius\",\"type\":\"card\",\"data\":{\"card_name\":\"RADIUS Evidence\",\"col\":3}},{\"id\":\"dm_card_audit\",\"type\":\"card\",\"data\":{\"card_name\":\"Audit & Dataset Use\",\"col\":3}},{\"id\":\"dm_card_unifi\",\"type\":\"card\",\"data\":{\"card_name\":\"UniFi Live State\",\"col\":3}}]",
"creation": "2026-06-17 00:00:00.000000",
"custom_blocks": [],
"docstatus": 0,
@@ -81,6 +81,17 @@
"onboard": 0,
"type": "Card Break"
},
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "DM Radius Credential",
+ "link_count": 0,
+ "link_to": "DM Radius Credential",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
{
"dependencies": "",
"hidden": 0,
@@ -132,6 +143,58 @@
"link_type": "DocType",
"onboard": 0,
"type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "UniFi Live State",
+ "link_count": 0,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "DM UniFi Settings",
+ "link_count": 0,
+ "link_to": "DM UniFi Settings",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "DM UniFi Client",
+ "link_count": 0,
+ "link_to": "DM UniFi Client",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "DM UniFi Network Device",
+ "link_count": 0,
+ "link_to": "DM UniFi Network Device",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "DM UniFi Network",
+ "link_count": 0,
+ "link_to": "DM UniFi Network",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
}
],
"modified": "2026-06-17 00:00:00.000000",
@@ -182,6 +245,14 @@
"link_to": "DM Access Decision",
"stats_filter": "{}",
"type": "DocType"
+ },
+ {
+ "color": "Red",
+ "format": "{} UniFi Clients",
+ "label": "DM UniFi Client",
+ "link_to": "DM UniFi Client",
+ "stats_filter": "{}",
+ "type": "DocType"
}
],
"title": "Device Manager"
diff --git a/device_manager/freeradius.py b/device_manager/freeradius.py
index 53d3b87..2595ea7 100644
--- a/device_manager/freeradius.py
+++ b/device_manager/freeradius.py
@@ -1,15 +1,30 @@
"""FreeRADIUS rlm_python bridge for Device Manager.
-Configure FreeRADIUS to import this module from the bench app path. The module
-boots Frappe lazily, records each request, evaluates the registered device, and
-returns RADIUS reply/control attributes for allow, quarantine, or reject.
+This module can run in two deployment modes:
+
+1. Local mode: Boots Frappe in-process when FreeRADIUS runs on the bench host.
+ Configure with DEVICE_MANAGER_BENCH_PATH and DEVICE_MANAGER_SITE.
+
+2. Remote mode: Calls a Frappe API over token-authenticated HTTP(S), then keeps
+ a local SQLite credential cache for long-lived IoT devices when Frappe is
+ temporarily unavailable. Configure with DEVICE_MANAGER_FRAPPE_URL,
+ DEVICE_MANAGER_API_KEY, and DEVICE_MANAGER_API_SECRET.
+
+For FreeRADIUS on a completely separate server without Frappe installed, use the
+standalone client from ../radius_client/ instead. See radius_client/README.md.
"""
from __future__ import annotations
+import json
import os
-from contextlib import suppress
-from typing import Iterable
+import sqlite3
+import time
+from collections.abc import Iterable
+from contextlib import closing, suppress
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlencode
+from urllib.request import Request, urlopen
try:
import radiusd
@@ -17,6 +32,7 @@ except ImportError: # pragma: no cover - radiusd exists only inside FreeRADIUS.
radiusd = None
_frappe_initialized = False
+_cache_initialized = False
RLM_MODULE_OK = getattr(radiusd, "RLM_MODULE_OK", 2)
RLM_MODULE_REJECT = getattr(radiusd, "RLM_MODULE_REJECT", 0)
@@ -26,10 +42,10 @@ RLM_MODULE_NOOP = getattr(radiusd, "RLM_MODULE_NOOP", 7)
REQUEST_MAC_ATTRIBUTES = (
"Calling-Station-Id",
"TLS-Client-Cert-Common-Name",
- "Stripped-User-Name",
- "User-Name",
)
+USERNAME_ATTRIBUTES = ("User-Name", "Stripped-User-Name")
+
def _log(message: str):
if radiusd:
@@ -48,6 +64,39 @@ def _as_request_dict(packet: Iterable[tuple[str, str]]) -> dict[str, str]:
return request
+def _get_first(request: dict[str, str], *keys: str) -> str | None:
+ for key in keys:
+ if request.get(key):
+ return request[key]
+ return None
+
+
+def _remote_api_url() -> str | None:
+ explicit_url = os.environ.get("DEVICE_MANAGER_API_URL")
+ if explicit_url:
+ return explicit_url
+
+ frappe_url = (os.environ.get("DEVICE_MANAGER_FRAPPE_URL") or "").rstrip("/")
+ if frappe_url:
+ return f"{frappe_url}/api/method/device_manager.api.radius_authorize"
+
+ return None
+
+
+def _cache_path() -> str:
+ return os.environ.get("DEVICE_MANAGER_CACHE_PATH") or "/var/lib/freeradius/device_manager_cache.sqlite3"
+
+
+def _http_timeout() -> float:
+ return float(os.environ.get("DEVICE_MANAGER_HTTP_TIMEOUT") or "2.5")
+
+
+def _cache_max_stale_seconds() -> int:
+ # 0 means cached credentials remain usable until Frappe returns a newer deny
+ # decision or the device-specific cache expiration date is reached.
+ return int(os.environ.get("DEVICE_MANAGER_CACHE_MAX_STALE_SECONDS") or "0")
+
+
def _reply_attributes_from_decision(decision: dict) -> tuple[tuple[str, str], ...]:
attributes = []
if decision.get("vlan_id"):
@@ -61,9 +110,9 @@ def _reply_attributes_from_decision(decision: dict) -> tuple[tuple[str, str], ..
)
if decision.get("radius_reply_attributes"):
- import json
-
- reply_attributes = json.loads(decision["radius_reply_attributes"])
+ reply_attributes = decision["radius_reply_attributes"]
+ if isinstance(reply_attributes, str):
+ reply_attributes = json.loads(reply_attributes)
if not isinstance(reply_attributes, dict):
raise ValueError("radius_reply_attributes must be a JSON object")
for key, value in reply_attributes.items():
@@ -72,11 +121,137 @@ def _reply_attributes_from_decision(decision: dict) -> tuple[tuple[str, str], ..
return tuple(attributes)
-def _get_first(request: dict[str, str], *keys: str) -> str | None:
- for key in keys:
- if request.get(key):
- return request[key]
- return None
+def _control_attributes_from_decision(decision: dict) -> tuple[tuple[str, str], ...]:
+ credentials = decision.get("cacheable_credentials") or {}
+ control_attributes = credentials.get("control_attributes") or {}
+ if not control_attributes:
+ return ()
+ return tuple((key, str(value)) for key, value in control_attributes.items())
+
+
+def _initialize_cache():
+ global _cache_initialized
+ if _cache_initialized:
+ return
+
+ path = _cache_path()
+ parent = os.path.dirname(path)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+
+ with closing(sqlite3.connect(path)) as connection:
+ connection.execute(
+ """
+ create table if not exists radius_verifier_cache (
+ username text primary key,
+ control_attributes text not null,
+ device text,
+ result text not null,
+ reason text,
+ vlan_id integer,
+ radius_reply_attributes text,
+ cache_expires_on text,
+ last_synced integer not null
+ )
+ """
+ )
+ connection.commit()
+
+ _cache_initialized = True
+
+
+def _cache_decision(decision: dict):
+ credentials = decision.get("cacheable_credentials") or {}
+ username = credentials.get("username")
+ control_attributes = credentials.get("control_attributes")
+ if not username:
+ return
+
+ _initialize_cache()
+ with closing(sqlite3.connect(_cache_path())) as connection:
+ if decision.get("result") == "Deny" or not control_attributes or not credentials.get("cache_allowed"):
+ connection.execute("delete from radius_verifier_cache where username = ?", (username,))
+ else:
+ connection.execute(
+ """
+ insert into radius_verifier_cache (
+ username,
+ control_attributes,
+ device,
+ result,
+ reason,
+ vlan_id,
+ radius_reply_attributes,
+ cache_expires_on,
+ last_synced
+ )
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ on conflict(username) do update set
+ control_attributes = excluded.control_attributes,
+ device = excluded.device,
+ result = excluded.result,
+ reason = excluded.reason,
+ vlan_id = excluded.vlan_id,
+ radius_reply_attributes = excluded.radius_reply_attributes,
+ cache_expires_on = excluded.cache_expires_on,
+ last_synced = excluded.last_synced
+ """,
+ (
+ username,
+ json.dumps(control_attributes, sort_keys=True),
+ decision.get("device"),
+ decision.get("result") or "Allow",
+ decision.get("reason"),
+ decision.get("vlan_id"),
+ decision.get("radius_reply_attributes"),
+ credentials.get("cache_expires_on"),
+ int(time.time()),
+ ),
+ )
+ connection.commit()
+
+
+def _cached_decision(username: str | None) -> dict | None:
+ if not username:
+ return None
+
+ _initialize_cache()
+ with closing(sqlite3.connect(_cache_path())) as connection:
+ connection.row_factory = sqlite3.Row
+ row = connection.execute(
+ "select * from radius_verifier_cache where username = ?",
+ (username,),
+ ).fetchone()
+
+ if not row:
+ return None
+
+ now = int(time.time())
+ max_stale = _cache_max_stale_seconds()
+ if max_stale and now - int(row["last_synced"]) > max_stale:
+ return None
+
+ if row["cache_expires_on"]:
+ expiry = time.strptime(row["cache_expires_on"], "%Y-%m-%d")
+ if time.mktime(expiry) < now:
+ return None
+
+ return {
+ "event": None,
+ "decision": None,
+ "device": row["device"],
+ "result": row["result"],
+ "reason": row["reason"] or "Frappe unavailable; using cached static RADIUS credentials.",
+ "network_segment": None,
+ "vlan_id": row["vlan_id"],
+ "radius_reply_attributes": row["radius_reply_attributes"],
+ "cacheable_credentials": {
+ "username": row["username"],
+ "control_attributes": json.loads(row["control_attributes"]),
+ "cache_expires_on": row["cache_expires_on"],
+ },
+ "from_cache": True,
+ }
def _bootstrap_frappe():
@@ -88,7 +263,7 @@ def _bootstrap_frappe():
bench_path = os.environ.get("DEVICE_MANAGER_BENCH_PATH") or os.environ.get("FRAPPE_BENCH_PATH")
site = os.environ.get("DEVICE_MANAGER_SITE") or os.environ.get("FRAPPE_SITE")
if not site:
- raise RuntimeError("Set DEVICE_MANAGER_SITE or FRAPPE_SITE for the FreeRADIUS Device Manager module.")
+ raise RuntimeError("Set DEVICE_MANAGER_SITE or FRAPPE_SITE for local Device Manager mode.")
if bench_path:
import sys
@@ -104,29 +279,88 @@ def _bootstrap_frappe():
frappe.init(site=site, sites_path=os.path.join(bench_path, "sites") if bench_path else None)
frappe.connect()
_frappe_initialized = True
- _log(f"connected to Frappe site {site}")
+ _log(f"connected to local Frappe site {site}")
+
+
+def _evaluate_locally(request: dict[str, str]) -> dict:
+ _bootstrap_frappe()
+
+ from device_manager.radius import record_radius_auth_event
+
+ return record_radius_auth_event(
+ calling_station_id=_get_first(request, *REQUEST_MAC_ATTRIBUTES),
+ username=_get_first(request, *USERNAME_ATTRIBUTES),
+ nas_identifier=request.get("NAS-Identifier"),
+ nas_ip_address=request.get("NAS-IP-Address"),
+ ssid=_get_first(request, "Called-Station-SSID", "WLAN-SSID"),
+ raw_request=request,
+ )
+
+
+def _evaluate_remotely(request: dict[str, str]) -> dict:
+ api_url = _remote_api_url()
+ if not api_url:
+ raise RuntimeError("Remote API URL is not configured.")
+
+ api_key = os.environ.get("DEVICE_MANAGER_API_KEY")
+ api_secret = os.environ.get("DEVICE_MANAGER_API_SECRET")
+ if not api_key or not api_secret:
+ raise RuntimeError(
+ "Set DEVICE_MANAGER_API_KEY and DEVICE_MANAGER_API_SECRET for remote Device Manager mode."
+ )
+
+ payload = urlencode(
+ {
+ "calling_station_id": _get_first(request, *REQUEST_MAC_ATTRIBUTES) or "",
+ "username": _get_first(request, *USERNAME_ATTRIBUTES) or "",
+ "nas_identifier": request.get("NAS-Identifier") or "",
+ "nas_ip_address": request.get("NAS-IP-Address") or "",
+ "ssid": _get_first(request, "Called-Station-SSID", "WLAN-SSID") or "",
+ "raw_request": json.dumps(request, sort_keys=True),
+ }
+ ).encode()
+ http_request = Request(
+ api_url,
+ data=payload,
+ headers={
+ "Authorization": f"token {api_key}:{api_secret}",
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ method="POST",
+ )
+
+ with urlopen(http_request, timeout=_http_timeout()) as response:
+ response_payload = json.loads(response.read().decode())
+
+ return response_payload.get("message") or response_payload
def instantiate(_config):
try:
- _bootstrap_frappe()
+ if _remote_api_url():
+ _initialize_cache()
+ _log("initialized remote Frappe mode with SQLite credential fallback")
+ else:
+ _bootstrap_frappe()
except Exception as exc:
- _error(f"failed to initialize Frappe: {exc}")
+ _error(f"failed to initialize: {exc}")
return RLM_MODULE_FAIL
return RLM_MODULE_OK
def authorize(packet):
- return _evaluate_packet(packet)
+ return _evaluate_packet(packet, allow_cache_fallback=True)
def post_auth(packet):
- return _evaluate_packet(packet)
+ if os.environ.get("DEVICE_MANAGER_POST_AUTH_EVALUATE") == "1":
+ return _evaluate_packet(packet, allow_cache_fallback=False)
+ return RLM_MODULE_NOOP
def authenticate(_packet):
# EAP/password authentication remains owned by FreeRADIUS. Device Manager
- # contributes device authorization and segmentation decisions.
+ # contributes static credential material, authorization, and segmentation.
return RLM_MODULE_NOOP
@@ -141,28 +375,38 @@ def detach():
return RLM_MODULE_OK
-def _evaluate_packet(packet):
+def _evaluate_packet(packet, *, allow_cache_fallback: bool):
+ request = _as_request_dict(packet)
+ username = _get_first(request, *USERNAME_ATTRIBUTES)
+
try:
- _bootstrap_frappe()
- request = _as_request_dict(packet)
- calling_station_id = _get_first(request, *REQUEST_MAC_ATTRIBUTES)
+ decision = _evaluate_remotely(request) if _remote_api_url() else _evaluate_locally(request)
+ if _remote_api_url():
+ _cache_decision(decision)
+ except (HTTPError, URLError, TimeoutError, OSError, RuntimeError) as exc:
+ if not allow_cache_fallback:
+ _error(f"authorization failed: {exc}")
+ return RLM_MODULE_FAIL
- from device_manager.radius import record_radius_auth_event
-
- decision = record_radius_auth_event(
- calling_station_id=calling_station_id,
- username=_get_first(request, "User-Name", "Stripped-User-Name"),
- nas_identifier=request.get("NAS-Identifier"),
- nas_ip_address=request.get("NAS-IP-Address"),
- ssid=_get_first(request, "Called-Station-SSID", "WLAN-SSID"),
- raw_request=request,
- )
+ decision = _cached_decision(username)
+ if not decision:
+ _error(
+ f"authorization failed and no cached credentials matched {username or '