fix: separate radius module to support separate deployment
This commit is contained in:
@@ -4,17 +4,35 @@ Device Manager is a device registration and access management portal for laborat
|
|||||||
|
|
||||||
### FreeRADIUS module
|
### 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
|
```bash
|
||||||
DEVICE_MANAGER_BENCH_PATH=/home/frappe/frappe-bench
|
DEVICE_MANAGER_BENCH_PATH=/home/frappe/frappe-bench
|
||||||
DEVICE_MANAGER_SITE=your-site-name
|
DEVICE_MANAGER_SITE=your-site-name
|
||||||
```
|
```
|
||||||
|
|
||||||
Example `mods-available/python3` module stanza:
|
Configure FreeRADIUS:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
python3 device_manager {
|
python3 device_manager {
|
||||||
module = device_manager.freeradius
|
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
|
```text
|
||||||
authorize {
|
python3 device_manager {
|
||||||
device_manager
|
module = device_manager.freeradius
|
||||||
}
|
instantiate = ${.module}
|
||||||
|
authorize = ${.module}
|
||||||
post-auth {
|
post_auth = ${.module}
|
||||||
device_manager
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
### Installation
|
||||||
|
|
||||||
|
|||||||
+82
-1
@@ -1,7 +1,15 @@
|
|||||||
import frappe
|
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.lifecycle import create_device_from_registration, reject_registration, transition_device
|
||||||
from device_manager.radius import record_radius_auth_event
|
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()
|
@frappe.whitelist()
|
||||||
@@ -19,18 +27,91 @@ def quarantine_device(device_name: str, reason: str | None = None):
|
|||||||
return transition_device(device_name, "Quarantined", reason=reason)
|
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(
|
def radius_authorize(
|
||||||
calling_station_id: str | None = None,
|
calling_station_id: str | None = None,
|
||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
nas_identifier: str | None = None,
|
nas_identifier: str | None = None,
|
||||||
nas_ip_address: str | None = None,
|
nas_ip_address: str | None = None,
|
||||||
ssid: 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(
|
return record_radius_auth_event(
|
||||||
calling_station_id=calling_station_id,
|
calling_station_id=calling_station_id,
|
||||||
username=username,
|
username=username,
|
||||||
nas_identifier=nas_identifier,
|
nas_identifier=nas_identifier,
|
||||||
nas_ip_address=nas_ip_address,
|
nas_ip_address=nas_ip_address,
|
||||||
ssid=ssid,
|
ssid=ssid,
|
||||||
|
raw_request=request_payload,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ DEVICE_STATUSES = (
|
|||||||
|
|
||||||
AUTHORIZATION_STATUSES = (
|
AUTHORIZATION_STATUSES = (
|
||||||
"Not Requested",
|
"Not Requested",
|
||||||
|
"Unauthorized",
|
||||||
"Pending",
|
"Pending",
|
||||||
"Authorized",
|
"Authorized",
|
||||||
"Denied",
|
"Denied",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
{"fieldname": "criteria_section", "fieldtype": "Section Break", "label": "Criteria"},
|
{"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_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_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"},
|
{"default": "0", "fieldname": "requires_active_dataset_authorization", "fieldtype": "Check", "label": "Requires Active Dataset Authorization"},
|
||||||
{"fieldname": "notes", "fieldtype": "Small Text", "label": "Notes"}
|
{"fieldname": "notes", "fieldtype": "Small Text", "label": "Notes"}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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: `<p>${__(
|
||||||
|
"Save this key now. It will not be shown again."
|
||||||
|
)}</p><pre>${frappe.utils.escape_html(
|
||||||
|
r.message.deployment_key || ""
|
||||||
|
)}</pre>`,
|
||||||
|
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: `<p>${__(
|
||||||
|
"Save this key now. It will not be shown again."
|
||||||
|
)}</p><pre>${frappe.utils.escape_html(
|
||||||
|
r.message.deployment_key || ""
|
||||||
|
)}</pre>`,
|
||||||
|
indicator: "green",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
__("Generate Owner Shared Key"),
|
||||||
|
__("Generate")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -22,7 +22,17 @@
|
|||||||
"dataset_authorization",
|
"dataset_authorization",
|
||||||
"network_section",
|
"network_section",
|
||||||
"network_segment",
|
"network_segment",
|
||||||
|
"current_unifi_client",
|
||||||
|
"current_ip_address",
|
||||||
|
"current_ssid",
|
||||||
|
"last_unifi_network",
|
||||||
"last_seen_on",
|
"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",
|
"risk_notes",
|
||||||
"identifiers_section",
|
"identifiers_section",
|
||||||
"identifiers"
|
"identifiers"
|
||||||
@@ -34,7 +44,7 @@
|
|||||||
{"fieldname": "primary_mac_address", "fieldtype": "Data", "in_list_view": 1, "label": "Primary MAC Address"},
|
{"fieldname": "primary_mac_address", "fieldtype": "Data", "in_list_view": 1, "label": "Primary MAC Address"},
|
||||||
{"fieldname": "column_break_identity", "fieldtype": "Column Break"},
|
{"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": "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": "owner_user", "fieldtype": "Link", "label": "Owner User", "options": "User"},
|
||||||
{"fieldname": "ownership_section", "fieldtype": "Section Break", "label": "Ownership and Research Context"},
|
{"fieldname": "ownership_section", "fieldtype": "Section Break", "label": "Ownership and Research Context"},
|
||||||
{"fieldname": "project", "fieldtype": "Data", "in_list_view": 1, "label": "Project"},
|
{"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": "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_section", "fieldtype": "Section Break", "label": "Network Access"},
|
||||||
{"fieldname": "network_segment", "fieldtype": "Link", "label": "Preferred Network Segment", "options": "DM Network Segment"},
|
{"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": "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": "risk_notes", "fieldtype": "Small Text", "label": "Risk Notes"},
|
||||||
{"fieldname": "identifiers_section", "fieldtype": "Section Break", "label": "Identifiers"},
|
{"fieldname": "identifiers_section", "fieldtype": "Section Break", "label": "Identifiers"},
|
||||||
{"fieldname": "identifiers", "fieldtype": "Table", "label": "Identifiers", "options": "DM Device Identifier"}
|
{"fieldname": "identifiers", "fieldtype": "Table", "label": "Identifiers", "options": "DM Device Identifier"}
|
||||||
|
|||||||
@@ -17,11 +17,33 @@ class DMDevice(Document):
|
|||||||
if self.primary_mac_address:
|
if self.primary_mac_address:
|
||||||
append_identifier(self, "MAC Address", self.primary_mac_address, is_primary=True)
|
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()
|
seen = set()
|
||||||
for row in self.get("identifiers") or []:
|
for row in self.get("identifiers") or []:
|
||||||
if row.identifier_type == "MAC Address":
|
if row.identifier_type == "MAC Address":
|
||||||
row.identifier_value = normalize_mac_address(row.identifier_value)
|
row.identifier_value = normalize_mac_address(row.identifier_value)
|
||||||
key = (row.identifier_type, row.identifier_value)
|
key = (row.identifier_type, row.identifier_value)
|
||||||
if key in seen:
|
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)
|
seen.add(key)
|
||||||
|
|||||||
+4
@@ -19,6 +19,8 @@
|
|||||||
"laboratory",
|
"laboratory",
|
||||||
"organizational_unit",
|
"organizational_unit",
|
||||||
"dataset_use_category",
|
"dataset_use_category",
|
||||||
|
"network_authorization_section",
|
||||||
|
"network_segment",
|
||||||
"justification",
|
"justification",
|
||||||
"approval_section",
|
"approval_section",
|
||||||
"approved_by",
|
"approved_by",
|
||||||
@@ -39,6 +41,8 @@
|
|||||||
{"fieldname": "laboratory", "fieldtype": "Data", "label": "Laboratory"},
|
{"fieldname": "laboratory", "fieldtype": "Data", "label": "Laboratory"},
|
||||||
{"fieldname": "organizational_unit", "fieldtype": "Data", "label": "Organizational Unit"},
|
{"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"},
|
{"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": "justification", "fieldtype": "Small Text", "label": "Justification"},
|
||||||
{"fieldname": "approval_section", "fieldtype": "Section Break", "label": "Approval"},
|
{"fieldname": "approval_section", "fieldtype": "Section Break", "label": "Approval"},
|
||||||
{"fieldname": "approved_by", "fieldtype": "Link", "label": "Approved By", "options": "User", "read_only": 1},
|
{"fieldname": "approved_by", "fieldtype": "Link", "label": "Approved By", "options": "User", "read_only": 1},
|
||||||
|
|||||||
+8
-1
@@ -12,7 +12,14 @@ class DMDeviceRegistration(Document):
|
|||||||
if self.primary_mac_address:
|
if self.primary_mac_address:
|
||||||
existing_device = find_device_by_identifier("MAC Address", 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:
|
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":
|
if self.status == "Draft":
|
||||||
self.status = "Submitted"
|
self.status = "Submitted"
|
||||||
|
|||||||
@@ -5,11 +5,33 @@
|
|||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
"document_type": "Document",
|
"document_type": "Document",
|
||||||
"engine": "InnoDB",
|
"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": [
|
"fields": [
|
||||||
{"fieldname": "segment_name", "fieldtype": "Data", "in_list_view": 1, "label": "Segment Name", "reqd": 1, "unique": 1},
|
{"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"},
|
{"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"},
|
{"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": "radius_reply_attributes", "fieldtype": "JSON", "label": "RADIUS Reply Attributes"},
|
||||||
{"fieldname": "description", "fieldtype": "Small Text", "label": "Description"}
|
{"fieldname": "description", "fieldtype": "Small Text", "label": "Description"}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ class DMNetworkSegment(Document):
|
|||||||
def validate(self):
|
def validate(self):
|
||||||
if self.vlan_id is not None and (self.vlan_id < 1 or self.vlan_id > 4094):
|
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."))
|
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:
|
if self.radius_reply_attributes:
|
||||||
try:
|
try:
|
||||||
json.loads(self.radius_reply_attributes)
|
json.loads(self.radius_reply_attributes)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
+28
@@ -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"
|
||||||
|
}
|
||||||
+43
@@ -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.")
|
||||||
@@ -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,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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."}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class DMUniFiSettings(Document):
|
||||||
|
pass
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"app": "device_manager",
|
"app": "device_manager",
|
||||||
"charts": [],
|
"charts": [],
|
||||||
"content": "[{\"id\":\"dm_header_overview\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Device Manager</b></span>\",\"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\":\"<span class=\\\"h4\\\"><b>Device Manager</b></span>\",\"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",
|
"creation": "2026-06-17 00:00:00.000000",
|
||||||
"custom_blocks": [],
|
"custom_blocks": [],
|
||||||
"docstatus": 0,
|
"docstatus": 0,
|
||||||
@@ -81,6 +81,17 @@
|
|||||||
"onboard": 0,
|
"onboard": 0,
|
||||||
"type": "Card Break"
|
"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": "",
|
"dependencies": "",
|
||||||
"hidden": 0,
|
"hidden": 0,
|
||||||
@@ -132,6 +143,58 @@
|
|||||||
"link_type": "DocType",
|
"link_type": "DocType",
|
||||||
"onboard": 0,
|
"onboard": 0,
|
||||||
"type": "Link"
|
"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",
|
"modified": "2026-06-17 00:00:00.000000",
|
||||||
@@ -182,6 +245,14 @@
|
|||||||
"link_to": "DM Access Decision",
|
"link_to": "DM Access Decision",
|
||||||
"stats_filter": "{}",
|
"stats_filter": "{}",
|
||||||
"type": "DocType"
|
"type": "DocType"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "Red",
|
||||||
|
"format": "{} UniFi Clients",
|
||||||
|
"label": "DM UniFi Client",
|
||||||
|
"link_to": "DM UniFi Client",
|
||||||
|
"stats_filter": "{}",
|
||||||
|
"type": "DocType"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Device Manager"
|
"title": "Device Manager"
|
||||||
|
|||||||
+284
-40
@@ -1,15 +1,30 @@
|
|||||||
"""FreeRADIUS rlm_python bridge for Device Manager.
|
"""FreeRADIUS rlm_python bridge for Device Manager.
|
||||||
|
|
||||||
Configure FreeRADIUS to import this module from the bench app path. The module
|
This module can run in two deployment modes:
|
||||||
boots Frappe lazily, records each request, evaluates the registered device, and
|
|
||||||
returns RADIUS reply/control attributes for allow, quarantine, or reject.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from contextlib import suppress
|
import sqlite3
|
||||||
from typing import Iterable
|
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:
|
try:
|
||||||
import radiusd
|
import radiusd
|
||||||
@@ -17,6 +32,7 @@ except ImportError: # pragma: no cover - radiusd exists only inside FreeRADIUS.
|
|||||||
radiusd = None
|
radiusd = None
|
||||||
|
|
||||||
_frappe_initialized = False
|
_frappe_initialized = False
|
||||||
|
_cache_initialized = False
|
||||||
|
|
||||||
RLM_MODULE_OK = getattr(radiusd, "RLM_MODULE_OK", 2)
|
RLM_MODULE_OK = getattr(radiusd, "RLM_MODULE_OK", 2)
|
||||||
RLM_MODULE_REJECT = getattr(radiusd, "RLM_MODULE_REJECT", 0)
|
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 = (
|
REQUEST_MAC_ATTRIBUTES = (
|
||||||
"Calling-Station-Id",
|
"Calling-Station-Id",
|
||||||
"TLS-Client-Cert-Common-Name",
|
"TLS-Client-Cert-Common-Name",
|
||||||
"Stripped-User-Name",
|
|
||||||
"User-Name",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
USERNAME_ATTRIBUTES = ("User-Name", "Stripped-User-Name")
|
||||||
|
|
||||||
|
|
||||||
def _log(message: str):
|
def _log(message: str):
|
||||||
if radiusd:
|
if radiusd:
|
||||||
@@ -48,6 +64,39 @@ def _as_request_dict(packet: Iterable[tuple[str, str]]) -> dict[str, str]:
|
|||||||
return request
|
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], ...]:
|
def _reply_attributes_from_decision(decision: dict) -> tuple[tuple[str, str], ...]:
|
||||||
attributes = []
|
attributes = []
|
||||||
if decision.get("vlan_id"):
|
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"):
|
if decision.get("radius_reply_attributes"):
|
||||||
import json
|
reply_attributes = decision["radius_reply_attributes"]
|
||||||
|
if isinstance(reply_attributes, str):
|
||||||
reply_attributes = json.loads(decision["radius_reply_attributes"])
|
reply_attributes = json.loads(reply_attributes)
|
||||||
if not isinstance(reply_attributes, dict):
|
if not isinstance(reply_attributes, dict):
|
||||||
raise ValueError("radius_reply_attributes must be a JSON object")
|
raise ValueError("radius_reply_attributes must be a JSON object")
|
||||||
for key, value in reply_attributes.items():
|
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)
|
return tuple(attributes)
|
||||||
|
|
||||||
|
|
||||||
def _get_first(request: dict[str, str], *keys: str) -> str | None:
|
def _control_attributes_from_decision(decision: dict) -> tuple[tuple[str, str], ...]:
|
||||||
for key in keys:
|
credentials = decision.get("cacheable_credentials") or {}
|
||||||
if request.get(key):
|
control_attributes = credentials.get("control_attributes") or {}
|
||||||
return request[key]
|
if not control_attributes:
|
||||||
return None
|
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():
|
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")
|
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")
|
site = os.environ.get("DEVICE_MANAGER_SITE") or os.environ.get("FRAPPE_SITE")
|
||||||
if not 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:
|
if bench_path:
|
||||||
import sys
|
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.init(site=site, sites_path=os.path.join(bench_path, "sites") if bench_path else None)
|
||||||
frappe.connect()
|
frappe.connect()
|
||||||
_frappe_initialized = True
|
_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):
|
def instantiate(_config):
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
_error(f"failed to initialize Frappe: {exc}")
|
_error(f"failed to initialize: {exc}")
|
||||||
return RLM_MODULE_FAIL
|
return RLM_MODULE_FAIL
|
||||||
return RLM_MODULE_OK
|
return RLM_MODULE_OK
|
||||||
|
|
||||||
|
|
||||||
def authorize(packet):
|
def authorize(packet):
|
||||||
return _evaluate_packet(packet)
|
return _evaluate_packet(packet, allow_cache_fallback=True)
|
||||||
|
|
||||||
|
|
||||||
def post_auth(packet):
|
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):
|
def authenticate(_packet):
|
||||||
# EAP/password authentication remains owned by FreeRADIUS. Device Manager
|
# 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
|
return RLM_MODULE_NOOP
|
||||||
|
|
||||||
|
|
||||||
@@ -141,28 +375,38 @@ def detach():
|
|||||||
return RLM_MODULE_OK
|
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:
|
try:
|
||||||
_bootstrap_frappe()
|
decision = _evaluate_remotely(request) if _remote_api_url() else _evaluate_locally(request)
|
||||||
request = _as_request_dict(packet)
|
if _remote_api_url():
|
||||||
calling_station_id = _get_first(request, *REQUEST_MAC_ATTRIBUTES)
|
_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 = _cached_decision(username)
|
||||||
|
if not decision:
|
||||||
decision = record_radius_auth_event(
|
_error(
|
||||||
calling_station_id=calling_station_id,
|
f"authorization failed and no cached credentials matched {username or '<missing username>'}: {exc}"
|
||||||
username=_get_first(request, "User-Name", "Stripped-User-Name"),
|
)
|
||||||
nas_identifier=request.get("NAS-Identifier"),
|
return RLM_MODULE_FAIL
|
||||||
nas_ip_address=request.get("NAS-IP-Address"),
|
_log(f"using cached credentials for {username}")
|
||||||
ssid=_get_first(request, "Called-Station-SSID", "WLAN-SSID"),
|
|
||||||
raw_request=request,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_error(f"authorization failed: {exc}")
|
_error(f"authorization failed: {exc}")
|
||||||
return RLM_MODULE_FAIL
|
return RLM_MODULE_FAIL
|
||||||
|
|
||||||
reply = _reply_attributes_from_decision(decision)
|
try:
|
||||||
if decision["result"] == "Deny":
|
reply = _reply_attributes_from_decision(decision)
|
||||||
return RLM_MODULE_REJECT, reply, ()
|
control = _control_attributes_from_decision(decision)
|
||||||
|
except Exception as exc:
|
||||||
|
_error(f"failed to build RADIUS attributes: {exc}")
|
||||||
|
return RLM_MODULE_FAIL
|
||||||
|
|
||||||
return RLM_MODULE_OK, reply, ()
|
if decision["result"] == "Deny":
|
||||||
|
return RLM_MODULE_REJECT, reply, control
|
||||||
|
|
||||||
|
return RLM_MODULE_OK, reply, control
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ after_install = "device_manager.install.after_install"
|
|||||||
# Scheduled Tasks
|
# Scheduled Tasks
|
||||||
# ---------------
|
# ---------------
|
||||||
|
|
||||||
|
#
|
||||||
# scheduler_events = {
|
# scheduler_events = {
|
||||||
# "all": [
|
# "all": [
|
||||||
# "device_manager.tasks.all"
|
# "device_manager.tasks.all"
|
||||||
@@ -167,6 +168,15 @@ after_install = "device_manager.install.after_install"
|
|||||||
# ],
|
# ],
|
||||||
# }
|
# }
|
||||||
|
|
||||||
|
scheduler_events = {
|
||||||
|
"hourly": [
|
||||||
|
"device_manager.unifi_sync.reconcile_unifi_clients",
|
||||||
|
],
|
||||||
|
"daily": [
|
||||||
|
"device_manager.unifi_sync.sync_unifi_network_segments",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
# -------
|
# -------
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ def create_device_from_registration(registration_name: str, *, approver: str | N
|
|||||||
device.laboratory = registration.laboratory
|
device.laboratory = registration.laboratory
|
||||||
device.organizational_unit = registration.organizational_unit
|
device.organizational_unit = registration.organizational_unit
|
||||||
device.dataset_use_category = registration.dataset_use_category
|
device.dataset_use_category = registration.dataset_use_category
|
||||||
|
device.network_segment = registration.network_segment
|
||||||
device.lifecycle_status = "Approved"
|
device.lifecycle_status = "Approved"
|
||||||
device.authorization_status = "Authorized" if registration.dataset_use_category == "None" else "Pending"
|
device.authorization_status = "Authorized" if registration.dataset_use_category == "None" else "Pending"
|
||||||
|
device.radius_username = registration.hostname or registration.primary_mac_address
|
||||||
device.risk_notes = registration.justification
|
device.risk_notes = registration.justification
|
||||||
|
|
||||||
append_identifier(device, "MAC Address", device.primary_mac_address, is_primary=True)
|
append_identifier(device, "MAC Address", device.primary_mac_address, is_primary=True)
|
||||||
@@ -72,7 +74,9 @@ def reject_registration(registration_name: str, *, reason: str | None = None, ac
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def transition_device(device_name: str, lifecycle_status: str, *, reason: str | None = None, actor: str | None = None):
|
def transition_device(
|
||||||
|
device_name: str, lifecycle_status: str, *, reason: str | None = None, actor: str | None = None
|
||||||
|
):
|
||||||
device = frappe.get_doc("DM Device", device_name)
|
device = frappe.get_doc("DM Device", device_name)
|
||||||
previous_status = device.lifecycle_status
|
previous_status = device.lifecycle_status
|
||||||
device.lifecycle_status = lifecycle_status
|
device.lifecycle_status = lifecycle_status
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ def _policy_matches(policy, device) -> bool:
|
|||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if policy.get("requires_active_dataset_authorization") and not get_current_dataset_authorization(device.name):
|
if policy.get("requires_active_dataset_authorization") and not get_current_dataset_authorization(
|
||||||
|
device.name
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -60,11 +62,20 @@ def evaluate_device_access(device_name: str) -> AccessEvaluation:
|
|||||||
if device.lifecycle_status in ("Draft", "Pending Approval", "Retired"):
|
if device.lifecycle_status in ("Draft", "Pending Approval", "Retired"):
|
||||||
return AccessEvaluation("Deny", f"Device lifecycle status is {device.lifecycle_status}.")
|
return AccessEvaluation("Deny", f"Device lifecycle status is {device.lifecycle_status}.")
|
||||||
|
|
||||||
if device.lifecycle_status in ("Suspended", "Quarantined") or device.authorization_status in ("Denied", "Revoked"):
|
if device.lifecycle_status in ("Suspended", "Quarantined") or device.authorization_status in (
|
||||||
segment = frappe.db.get_value("DM Network Segment", {"is_quarantine_segment": 1}, ["name", "vlan_id", "radius_reply_attributes"], as_dict=True)
|
"Unauthorized",
|
||||||
|
"Denied",
|
||||||
|
"Revoked",
|
||||||
|
):
|
||||||
|
segment = frappe.db.get_value(
|
||||||
|
"DM Network Segment",
|
||||||
|
{"is_quarantine_segment": 1},
|
||||||
|
["name", "vlan_id", "radius_reply_attributes"],
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
return AccessEvaluation(
|
return AccessEvaluation(
|
||||||
"Quarantine",
|
"Quarantine",
|
||||||
"Device is suspended, quarantined, denied, or revoked.",
|
"Device is unauthorized, suspended, quarantined, denied, or revoked.",
|
||||||
network_segment=segment.name if segment else None,
|
network_segment=segment.name if segment else None,
|
||||||
vlan_id=segment.vlan_id if segment else None,
|
vlan_id=segment.vlan_id if segment else None,
|
||||||
radius_reply_attributes=segment.radius_reply_attributes if segment else None,
|
radius_reply_attributes=segment.radius_reply_attributes if segment else None,
|
||||||
@@ -91,10 +102,11 @@ def evaluate_device_access(device_name: str) -> AccessEvaluation:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
segment = None
|
segment = None
|
||||||
if policy.network_segment:
|
segment_name = device.get("network_segment") or policy.network_segment
|
||||||
|
if segment_name:
|
||||||
segment = frappe.db.get_value(
|
segment = frappe.db.get_value(
|
||||||
"DM Network Segment",
|
"DM Network Segment",
|
||||||
policy.network_segment,
|
segment_name,
|
||||||
["name", "vlan_id", "radius_reply_attributes"],
|
["name", "vlan_id", "radius_reply_attributes"],
|
||||||
as_dict=True,
|
as_dict=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,61 @@ import frappe
|
|||||||
from frappe import _
|
from frappe import _
|
||||||
|
|
||||||
from device_manager.audit import emit_audit_event
|
from device_manager.audit import emit_audit_event
|
||||||
|
from device_manager.credentials import get_owner_radius_credential
|
||||||
from device_manager.identity import find_device_by_identifier, normalize_mac_address
|
from device_manager.identity import find_device_by_identifier, normalize_mac_address
|
||||||
from device_manager.policy import evaluate_device_access
|
from device_manager.policy import evaluate_device_access
|
||||||
|
|
||||||
|
|
||||||
def _record_access_decision(device_name: str | None, event_name: str | None, evaluation, mac_address: str | None = None):
|
def _find_device_for_radius_request(username: str | None, mac_address: str | None) -> str | None:
|
||||||
|
if mac_address:
|
||||||
|
device_name = find_device_by_identifier("MAC Address", mac_address)
|
||||||
|
if device_name:
|
||||||
|
return device_name
|
||||||
|
|
||||||
|
if username:
|
||||||
|
return frappe.db.get_value("DM Device", {"radius_username": username}, "name")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cacheable_credentials(device_name: str | None, username: str | None, access_result: str) -> dict:
|
||||||
|
if not device_name or access_result == "Deny":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
device = frappe.get_doc("DM Device", device_name)
|
||||||
|
if not username:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if device.radius_username and device.radius_username == username and device.radius_password_hash:
|
||||||
|
return {
|
||||||
|
"username": device.radius_username,
|
||||||
|
"mac_address": device.primary_mac_address,
|
||||||
|
"control_attributes": {
|
||||||
|
device.radius_password_hash_scheme or "SSHA-Password": device.radius_password_hash,
|
||||||
|
},
|
||||||
|
"cache_allowed": bool(device.enable_radius_credential_cache),
|
||||||
|
"cache_expires_on": device.credential_cache_expires_on,
|
||||||
|
}
|
||||||
|
|
||||||
|
owner_credential = get_owner_radius_credential(device.owner_user, username)
|
||||||
|
if not owner_credential:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"username": owner_credential.radius_username,
|
||||||
|
"mac_address": device.primary_mac_address,
|
||||||
|
"control_attributes": {
|
||||||
|
owner_credential.radius_password_hash_scheme
|
||||||
|
or "SSHA-Password": owner_credential.radius_password_hash,
|
||||||
|
},
|
||||||
|
"cache_allowed": False,
|
||||||
|
"cache_expires_on": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_access_decision(
|
||||||
|
device_name: str | None, event_name: str | None, evaluation, mac_address: str | None = None
|
||||||
|
):
|
||||||
decision = frappe.new_doc("DM Access Decision")
|
decision = frappe.new_doc("DM Access Decision")
|
||||||
decision.device = device_name
|
decision.device = device_name
|
||||||
decision.radius_auth_event = event_name
|
decision.radius_auth_event = event_name
|
||||||
@@ -47,7 +97,7 @@ def record_radius_auth_event(
|
|||||||
raw_request: dict | None = None,
|
raw_request: dict | None = None,
|
||||||
):
|
):
|
||||||
mac_address = normalize_mac_address(calling_station_id)
|
mac_address = normalize_mac_address(calling_station_id)
|
||||||
device_name = find_device_by_identifier("MAC Address", mac_address) if mac_address else None
|
device_name = _find_device_for_radius_request(username, mac_address)
|
||||||
|
|
||||||
event = frappe.new_doc("DM Radius Auth Event")
|
event = frappe.new_doc("DM Radius Auth Event")
|
||||||
event.device = device_name
|
event.device = device_name
|
||||||
@@ -81,7 +131,9 @@ def record_radius_auth_event(
|
|||||||
)()
|
)()
|
||||||
else:
|
else:
|
||||||
evaluation = evaluate_device_access(device_name)
|
evaluation = evaluate_device_access(device_name)
|
||||||
frappe.db.set_value("DM Device", device_name, "last_seen_on", frappe.utils.now(), update_modified=False)
|
frappe.db.set_value(
|
||||||
|
"DM Device", device_name, "last_seen_on", frappe.utils.now(), update_modified=False
|
||||||
|
)
|
||||||
|
|
||||||
decision = _record_access_decision(device_name, event.name, evaluation, mac_address=mac_address)
|
decision = _record_access_decision(device_name, event.name, evaluation, mac_address=mac_address)
|
||||||
event.access_decision = decision.name
|
event.access_decision = decision.name
|
||||||
@@ -96,4 +148,5 @@ def record_radius_auth_event(
|
|||||||
"network_segment": decision.network_segment,
|
"network_segment": decision.network_segment,
|
||||||
"vlan_id": decision.vlan_id,
|
"vlan_id": decision.vlan_id,
|
||||||
"radius_reply_attributes": decision.radius_reply_attributes,
|
"radius_reply_attributes": decision.radius_reply_attributes,
|
||||||
|
"cacheable_credentials": _get_cacheable_credentials(device_name, username, decision.decision),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
|
||||||
|
from device_manager.audit import emit_audit_event
|
||||||
|
from device_manager.identity import append_identifier, normalize_mac_address
|
||||||
|
|
||||||
|
SKIPPED_UNIFI_PURPOSES = {"wan", "remote-user-vpn", "site-vpn"}
|
||||||
|
|
||||||
|
|
||||||
|
def _segment_payload_from_network(network: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"source": "UniFi",
|
||||||
|
"unifi_network_id": network.get("network_id"),
|
||||||
|
"unifi_network_name": network.get("network_name"),
|
||||||
|
"unifi_purpose": network.get("purpose"),
|
||||||
|
"vlan_id": network.get("vlan"),
|
||||||
|
"subnet": network.get("subnet"),
|
||||||
|
"gateway": network.get("gateway"),
|
||||||
|
"last_synced_from_unifi": frappe.utils.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_segment_for_unifi_network(network: dict) -> str | None:
|
||||||
|
network_id = network.get("network_id")
|
||||||
|
if network_id:
|
||||||
|
existing = frappe.db.get_value("DM Network Segment", {"unifi_network_id": network_id}, "name")
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
vlan = network.get("vlan")
|
||||||
|
if vlan:
|
||||||
|
matches = frappe.get_all("DM Network Segment", filters={"vlan_id": vlan}, pluck="name", limit=2)
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
network_name = network.get("network_name")
|
||||||
|
if network_name and frappe.db.exists("DM Network Segment", network_name):
|
||||||
|
return network_name
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sync_unifi_network_segments(*, create_missing: bool = True) -> dict:
|
||||||
|
from device_manager.device_manager.utils.unifi_client import get_networks
|
||||||
|
|
||||||
|
created = []
|
||||||
|
updated = []
|
||||||
|
skipped = []
|
||||||
|
|
||||||
|
for network in get_networks():
|
||||||
|
if network.get("purpose") in SKIPPED_UNIFI_PURPOSES:
|
||||||
|
skipped.append(
|
||||||
|
{
|
||||||
|
"network": network.get("network_name"),
|
||||||
|
"reason": f"Unsupported purpose: {network.get('purpose')}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
segment_name = _find_segment_for_unifi_network(network)
|
||||||
|
payload = _segment_payload_from_network(network)
|
||||||
|
|
||||||
|
if segment_name:
|
||||||
|
segment = frappe.get_doc("DM Network Segment", segment_name)
|
||||||
|
segment.update(payload)
|
||||||
|
if not segment.description:
|
||||||
|
segment.description = _("Synced from UniFi network {0}.").format(network.get("network_name"))
|
||||||
|
segment.save(ignore_permissions=True)
|
||||||
|
updated.append(segment.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not create_missing:
|
||||||
|
skipped.append({"network": network.get("network_name"), "reason": "No matching segment"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
segment = frappe.new_doc("DM Network Segment")
|
||||||
|
segment.segment_name = network.get("network_name") or network.get("network_id")
|
||||||
|
segment.description = _("Synced from UniFi network {0}.").format(network.get("network_name"))
|
||||||
|
segment.update(payload)
|
||||||
|
segment.insert(ignore_permissions=True)
|
||||||
|
created.append(segment.name)
|
||||||
|
|
||||||
|
emit_audit_event(
|
||||||
|
"UniFi Network Segment Sync",
|
||||||
|
"DM UniFi Settings",
|
||||||
|
"DM UniFi Settings",
|
||||||
|
decision="Completed",
|
||||||
|
payload={"created": created, "updated": updated, "skipped": skipped},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"created": created, "updated": updated, "skipped": skipped}
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_device_type(client: dict) -> str:
|
||||||
|
network_context = f"{client.get('network') or ''} {client.get('ssid') or ''}".lower()
|
||||||
|
if "iot" in network_context or "sensor" in network_context:
|
||||||
|
return "IoT Device"
|
||||||
|
if client.get("is_wired"):
|
||||||
|
return "Laboratory Equipment"
|
||||||
|
return "Other"
|
||||||
|
|
||||||
|
|
||||||
|
def _client_context_note(client: dict) -> str:
|
||||||
|
return _("Imported from UniFi client telemetry. Network: {0}; SSID: {1}; VLAN: {2}; IP: {3}.").format(
|
||||||
|
client.get("network") or "",
|
||||||
|
client.get("ssid") or "",
|
||||||
|
client.get("vlan") or "",
|
||||||
|
client.get("ip_address") or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _device_payload_from_unifi_client(client: dict) -> dict:
|
||||||
|
mac_address = normalize_mac_address(client.get("mac_address"))
|
||||||
|
return {
|
||||||
|
"device_label": client.get("hostname") or client.get("client_id") or mac_address,
|
||||||
|
"device_type": _guess_device_type(client),
|
||||||
|
"primary_mac_address": mac_address,
|
||||||
|
"lifecycle_status": "Active",
|
||||||
|
"authorization_status": "Unauthorized",
|
||||||
|
"dataset_use_category": "None",
|
||||||
|
"network_segment": _find_segment_for_client(client),
|
||||||
|
"current_unifi_client": client.get("client_id"),
|
||||||
|
"current_ip_address": client.get("ip_address"),
|
||||||
|
"current_ssid": client.get("ssid"),
|
||||||
|
"last_unifi_network": client.get("network"),
|
||||||
|
"last_seen_on": frappe.utils.now(),
|
||||||
|
"radius_username": mac_address,
|
||||||
|
"risk_notes": _client_context_note(client),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_device_from_unifi_client(client: dict) -> str:
|
||||||
|
device = frappe.new_doc("DM Device")
|
||||||
|
device.update(_device_payload_from_unifi_client(client))
|
||||||
|
append_identifier(device, "MAC Address", device.primary_mac_address, is_primary=True)
|
||||||
|
append_identifier(device, "Hostname", client.get("hostname"))
|
||||||
|
device.flags.ignore_links = True
|
||||||
|
device.insert(ignore_permissions=True)
|
||||||
|
return device.name
|
||||||
|
|
||||||
|
|
||||||
|
def import_unifi_clients_as_devices(*, limit: int | None = None, dry_run: bool = False) -> dict:
|
||||||
|
from device_manager.device_manager.utils.unifi_client import get_clients
|
||||||
|
|
||||||
|
created = []
|
||||||
|
skipped = []
|
||||||
|
processed = 0
|
||||||
|
|
||||||
|
for client in get_clients():
|
||||||
|
if limit and processed >= limit:
|
||||||
|
break
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
mac_address = normalize_mac_address(client.get("mac_address"))
|
||||||
|
if not mac_address:
|
||||||
|
skipped.append({"client": client.get("client_id"), "reason": "Missing MAC address"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _find_device_for_client(client):
|
||||||
|
skipped.append({"client": client.get("client_id"), "reason": "Device already registered"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
created.append(_device_payload_from_unifi_client(client))
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
created.append(_create_device_from_unifi_client(client))
|
||||||
|
except Exception as exc:
|
||||||
|
frappe.log_error(
|
||||||
|
title=_("UniFi device import failed"),
|
||||||
|
message=frappe.get_traceback(),
|
||||||
|
)
|
||||||
|
skipped.append({"client": client.get("client_id"), "reason": str(exc)})
|
||||||
|
|
||||||
|
emit_audit_event(
|
||||||
|
"UniFi Device Import",
|
||||||
|
"DM UniFi Settings",
|
||||||
|
"DM UniFi Settings",
|
||||||
|
decision="Completed",
|
||||||
|
payload={
|
||||||
|
"created": created,
|
||||||
|
"skipped_count": len(skipped),
|
||||||
|
"skipped_sample": skipped[:25],
|
||||||
|
"dry_run": dry_run,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"created": created, "skipped": skipped}
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_unifi_import_authorizations(*, dry_run: bool = False) -> dict:
|
||||||
|
updated = []
|
||||||
|
kept = []
|
||||||
|
devices = frappe.db.get_all(
|
||||||
|
"DM Device",
|
||||||
|
filters={
|
||||||
|
"risk_notes": ["like", "Imported from UniFi client telemetry%"],
|
||||||
|
},
|
||||||
|
fields=["name", "authorization_status"],
|
||||||
|
order_by="creation asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
approved_registration = frappe.db.get_value(
|
||||||
|
"DM Device Registration",
|
||||||
|
{
|
||||||
|
"approved_device": device.name,
|
||||||
|
"status": "Approved",
|
||||||
|
},
|
||||||
|
"name",
|
||||||
|
)
|
||||||
|
target_status = "Authorized" if approved_registration else "Unauthorized"
|
||||||
|
if device.authorization_status == target_status:
|
||||||
|
kept.append({"device": device.name, "authorization_status": device.authorization_status})
|
||||||
|
continue
|
||||||
|
|
||||||
|
updated.append(
|
||||||
|
{
|
||||||
|
"device": device.name,
|
||||||
|
"from": device.authorization_status,
|
||||||
|
"to": target_status,
|
||||||
|
"approved_registration": approved_registration,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
|
||||||
|
frappe.db.set_value(
|
||||||
|
"DM Device", device.name, "authorization_status", target_status, update_modified=False
|
||||||
|
)
|
||||||
|
emit_audit_event(
|
||||||
|
"UniFi Import Authorization Reconciled",
|
||||||
|
"DM Device",
|
||||||
|
device.name,
|
||||||
|
device=device.name,
|
||||||
|
decision=target_status,
|
||||||
|
reason=_("UniFi-imported devices are unauthorized unless linked to an approved registration."),
|
||||||
|
payload={"approved_registration": approved_registration},
|
||||||
|
)
|
||||||
|
|
||||||
|
emit_audit_event(
|
||||||
|
"UniFi Import Authorization Reconciliation",
|
||||||
|
"DM UniFi Settings",
|
||||||
|
"DM UniFi Settings",
|
||||||
|
decision="Completed",
|
||||||
|
payload={"updated": updated, "kept_count": len(kept), "dry_run": dry_run},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"updated": updated, "kept": kept}
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_unifi_registrations_to_devices(*, limit: int | None = None, dry_run: bool = False) -> dict:
|
||||||
|
created = []
|
||||||
|
skipped = []
|
||||||
|
filters = {
|
||||||
|
"status": ["in", ["Submitted", "Under Review"]],
|
||||||
|
"justification": ["like", "Imported from UniFi client telemetry%"],
|
||||||
|
}
|
||||||
|
registrations = frappe.db.get_all(
|
||||||
|
"DM Device Registration",
|
||||||
|
filters=filters,
|
||||||
|
fields=[
|
||||||
|
"name",
|
||||||
|
"device_label",
|
||||||
|
"device_type",
|
||||||
|
"primary_mac_address",
|
||||||
|
"hostname",
|
||||||
|
"project",
|
||||||
|
"laboratory",
|
||||||
|
"organizational_unit",
|
||||||
|
"dataset_use_category",
|
||||||
|
"network_segment",
|
||||||
|
"justification",
|
||||||
|
],
|
||||||
|
order_by="creation asc",
|
||||||
|
limit_page_length=limit or 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
for registration in registrations:
|
||||||
|
device_name = _find_device_for_registration(registration)
|
||||||
|
if device_name:
|
||||||
|
if dry_run:
|
||||||
|
created.append(
|
||||||
|
{"registration": registration.name, "device": device_name, "action": "link_existing"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
registration_doc = frappe.get_doc("DM Device Registration", registration.name)
|
||||||
|
registration_doc.status = "Approved"
|
||||||
|
registration_doc.approved_by = (
|
||||||
|
frappe.session.user
|
||||||
|
if frappe.session.user and frappe.session.user != "Guest"
|
||||||
|
else "Administrator"
|
||||||
|
)
|
||||||
|
registration_doc.approved_device = device_name
|
||||||
|
registration_doc.approval_notes = _(
|
||||||
|
"Linked to an existing DM Device because the source record was imported from UniFi telemetry."
|
||||||
|
)
|
||||||
|
registration_doc.save(ignore_permissions=True)
|
||||||
|
frappe.db.set_value(
|
||||||
|
"DM Device", device_name, "authorization_status", "Authorized", update_modified=False
|
||||||
|
)
|
||||||
|
emit_audit_event(
|
||||||
|
"UniFi Registration Linked",
|
||||||
|
"DM Device Registration",
|
||||||
|
registration.name,
|
||||||
|
device=device_name,
|
||||||
|
decision="Approved",
|
||||||
|
reason=registration_doc.approval_notes,
|
||||||
|
payload={"registration": registration.name, "device": device_name},
|
||||||
|
)
|
||||||
|
created.append(
|
||||||
|
{"registration": registration.name, "device": device_name, "action": "link_existing"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"device_label": registration.device_label,
|
||||||
|
"device_type": registration.device_type,
|
||||||
|
"primary_mac_address": normalize_mac_address(registration.primary_mac_address),
|
||||||
|
"lifecycle_status": "Active",
|
||||||
|
"authorization_status": "Authorized",
|
||||||
|
"project": registration.project,
|
||||||
|
"laboratory": registration.laboratory,
|
||||||
|
"organizational_unit": registration.organizational_unit,
|
||||||
|
"dataset_use_category": registration.dataset_use_category or "None",
|
||||||
|
"network_segment": registration.network_segment,
|
||||||
|
"radius_username": normalize_mac_address(registration.primary_mac_address),
|
||||||
|
"risk_notes": registration.justification,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
created.append({"registration": registration.name, "device": payload})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
device = frappe.new_doc("DM Device")
|
||||||
|
device.update(payload)
|
||||||
|
append_identifier(device, "MAC Address", device.primary_mac_address, is_primary=True)
|
||||||
|
append_identifier(device, "Hostname", registration.hostname)
|
||||||
|
device.insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
registration_doc = frappe.get_doc("DM Device Registration", registration.name)
|
||||||
|
registration_doc.status = "Approved"
|
||||||
|
registration_doc.approved_by = (
|
||||||
|
frappe.session.user
|
||||||
|
if frappe.session.user and frappe.session.user != "Guest"
|
||||||
|
else "Administrator"
|
||||||
|
)
|
||||||
|
registration_doc.approved_device = device.name
|
||||||
|
registration_doc.approval_notes = _(
|
||||||
|
"Converted to DM Device because the source record was imported from UniFi telemetry."
|
||||||
|
)
|
||||||
|
registration_doc.save(ignore_permissions=True)
|
||||||
|
except Exception as exc:
|
||||||
|
frappe.log_error(
|
||||||
|
title=_("UniFi registration migration failed"),
|
||||||
|
message=frappe.get_traceback(),
|
||||||
|
)
|
||||||
|
skipped.append({"registration": registration.name, "reason": str(exc)})
|
||||||
|
continue
|
||||||
|
|
||||||
|
emit_audit_event(
|
||||||
|
"UniFi Registration Migrated",
|
||||||
|
"DM Device Registration",
|
||||||
|
registration.name,
|
||||||
|
device=device.name,
|
||||||
|
decision="Approved",
|
||||||
|
reason=registration_doc.approval_notes,
|
||||||
|
payload={"registration": registration.name, "device": device.name},
|
||||||
|
)
|
||||||
|
created.append({"registration": registration.name, "device": device.name})
|
||||||
|
|
||||||
|
emit_audit_event(
|
||||||
|
"UniFi Registration Migration",
|
||||||
|
"DM UniFi Settings",
|
||||||
|
"DM UniFi Settings",
|
||||||
|
decision="Completed",
|
||||||
|
payload={
|
||||||
|
"created": created,
|
||||||
|
"skipped_count": len(skipped),
|
||||||
|
"skipped_sample": skipped[:25],
|
||||||
|
"dry_run": dry_run,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"created": created, "skipped": skipped}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_device_for_registration(registration) -> str | None:
|
||||||
|
mac_address = normalize_mac_address(registration.primary_mac_address)
|
||||||
|
if not mac_address:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (
|
||||||
|
frappe.db.get_value("DM Device", {"primary_mac_address": mac_address}, "name")
|
||||||
|
or frappe.db.get_value("DM Device", {"radius_username": mac_address}, "name")
|
||||||
|
or frappe.db.get_value(
|
||||||
|
"DM Device Identifier",
|
||||||
|
{"identifier_type": "MAC Address", "identifier_value": mac_address},
|
||||||
|
"parent",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_device_for_client(client: dict) -> str | None:
|
||||||
|
mac_address = normalize_mac_address(client.get("mac_address"))
|
||||||
|
if not mac_address:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (
|
||||||
|
frappe.db.get_value("DM Device", {"primary_mac_address": mac_address}, "name")
|
||||||
|
or frappe.db.get_value("DM Device", {"radius_username": mac_address}, "name")
|
||||||
|
or frappe.db.get_value(
|
||||||
|
"DM Device Identifier",
|
||||||
|
{"identifier_type": "MAC Address", "identifier_value": mac_address},
|
||||||
|
"parent",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_segment_for_client(client: dict) -> str | None:
|
||||||
|
network = client.get("network")
|
||||||
|
if network:
|
||||||
|
segment = frappe.db.get_value("DM Network Segment", {"unifi_network_name": network}, "name")
|
||||||
|
if segment:
|
||||||
|
return segment
|
||||||
|
if frappe.db.exists("DM Network Segment", network):
|
||||||
|
return network
|
||||||
|
|
||||||
|
vlan = frappe.utils.cint(client.get("vlan"))
|
||||||
|
if vlan:
|
||||||
|
matches = frappe.get_all("DM Network Segment", filters={"vlan_id": vlan}, pluck="name", limit=2)
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_unifi_clients(*, update_device_segment: bool = True) -> dict:
|
||||||
|
from device_manager.device_manager.utils.unifi_client import get_clients
|
||||||
|
|
||||||
|
matched = []
|
||||||
|
unmatched = []
|
||||||
|
|
||||||
|
for client in get_clients():
|
||||||
|
device_name = _find_device_for_client(client)
|
||||||
|
if not device_name:
|
||||||
|
unmatched.append(
|
||||||
|
{
|
||||||
|
"mac_address": client.get("mac_address"),
|
||||||
|
"hostname": client.get("hostname"),
|
||||||
|
"network": client.get("network"),
|
||||||
|
"vlan": client.get("vlan"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
device = frappe.get_doc("DM Device", device_name)
|
||||||
|
segment_name = _find_segment_for_client(client)
|
||||||
|
device.current_unifi_client = client.get("client_id")
|
||||||
|
device.current_ip_address = client.get("ip_address")
|
||||||
|
device.current_ssid = client.get("ssid")
|
||||||
|
device.last_unifi_network = client.get("network")
|
||||||
|
device.last_seen_on = frappe.utils.now()
|
||||||
|
if update_device_segment and segment_name:
|
||||||
|
device.network_segment = segment_name
|
||||||
|
device.flags.ignore_links = True
|
||||||
|
device.save(ignore_permissions=True)
|
||||||
|
matched.append({"device": device.name, "client": client.get("client_id"), "segment": segment_name})
|
||||||
|
|
||||||
|
emit_audit_event(
|
||||||
|
"UniFi Client Reconciliation",
|
||||||
|
"DM UniFi Settings",
|
||||||
|
"DM UniFi Settings",
|
||||||
|
decision="Completed",
|
||||||
|
payload={"matched": matched, "unmatched_count": len(unmatched), "unmatched_sample": unmatched[:25]},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"matched": matched, "unmatched": unmatched}
|
||||||
@@ -88,6 +88,18 @@
|
|||||||
"label": "RADIUS Evidence",
|
"label": "RADIUS Evidence",
|
||||||
"type": "Section Break"
|
"type": "Section Break"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"child": 1,
|
||||||
|
"collapsible": 1,
|
||||||
|
"icon": "key-round",
|
||||||
|
"indent": 0,
|
||||||
|
"keep_closed": 0,
|
||||||
|
"label": "Shared Credentials",
|
||||||
|
"link_to": "DM Radius Credential",
|
||||||
|
"link_type": "DocType",
|
||||||
|
"show_arrow": 0,
|
||||||
|
"type": "Link"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"child": 1,
|
"child": 1,
|
||||||
"collapsible": 1,
|
"collapsible": 1,
|
||||||
@@ -143,6 +155,62 @@
|
|||||||
"link_type": "DocType",
|
"link_type": "DocType",
|
||||||
"show_arrow": 0,
|
"show_arrow": 0,
|
||||||
"type": "Link"
|
"type": "Link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsible": 1,
|
||||||
|
"icon": "wifi",
|
||||||
|
"indent": 0,
|
||||||
|
"keep_closed": 0,
|
||||||
|
"label": "UniFi Live State",
|
||||||
|
"type": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"child": 1,
|
||||||
|
"collapsible": 1,
|
||||||
|
"icon": "settings",
|
||||||
|
"indent": 0,
|
||||||
|
"keep_closed": 0,
|
||||||
|
"label": "UniFi Settings",
|
||||||
|
"link_to": "DM UniFi Settings",
|
||||||
|
"link_type": "DocType",
|
||||||
|
"show_arrow": 0,
|
||||||
|
"type": "Link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"child": 1,
|
||||||
|
"collapsible": 1,
|
||||||
|
"icon": "monitor-smartphone",
|
||||||
|
"indent": 0,
|
||||||
|
"keep_closed": 0,
|
||||||
|
"label": "UniFi Clients",
|
||||||
|
"link_to": "DM UniFi Client",
|
||||||
|
"link_type": "DocType",
|
||||||
|
"show_arrow": 0,
|
||||||
|
"type": "Link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"child": 1,
|
||||||
|
"collapsible": 1,
|
||||||
|
"icon": "router",
|
||||||
|
"indent": 0,
|
||||||
|
"keep_closed": 0,
|
||||||
|
"label": "UniFi Network Devices",
|
||||||
|
"link_to": "DM UniFi Network Device",
|
||||||
|
"link_type": "DocType",
|
||||||
|
"show_arrow": 0,
|
||||||
|
"type": "Link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"child": 1,
|
||||||
|
"collapsible": 1,
|
||||||
|
"icon": "network",
|
||||||
|
"indent": 0,
|
||||||
|
"keep_closed": 0,
|
||||||
|
"label": "UniFi Networks",
|
||||||
|
"link_to": "DM UniFi Network",
|
||||||
|
"link_type": "DocType",
|
||||||
|
"show_arrow": 0,
|
||||||
|
"type": "Link"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"module": "Device Manager",
|
"module": "Device Manager",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
# Architecture Overview
|
||||||
|
|
||||||
|
## System Components
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Network Clients │
|
||||||
|
│ (IoT Devices, Laptops, Phones with WPA-Enterprise credentials) │
|
||||||
|
└──────────────────────┬──────────────────────────────────────────┘
|
||||||
|
│ RADIUS Access-Request
|
||||||
|
│ (MAC, Username, EAP)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FreeRADIUS Server │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ EAP Module (Password/Certificate Verification) │ │
|
||||||
|
│ └──────────────────────────┬─────────────────────────────────┘ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ device_manager_radius.py (rlm_python) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ • Parse RADIUS attributes (MAC, Username, NAS, SSID) │ │
|
||||||
|
│ │ • Call Frappe API for authorization decision │ │
|
||||||
|
│ │ • Cache credentials in SQLite for offline operation │ │
|
||||||
|
│ │ • Return VLAN assignment and reply attributes │ │
|
||||||
|
│ └──────────────────────────┬─────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SQLite Credential Cache │ │
|
||||||
|
│ │ /var/lib/freeradius/device_manager_verifier_cache.sqlite3 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ • Stores SSHA password hashes (no plaintext) │ │
|
||||||
|
│ │ • Device-specific VLAN assignments │ │
|
||||||
|
│ │ • Expiration timestamps │ │
|
||||||
|
│ │ • Used when Frappe unreachable │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────┬──────────────────────────────────────┘
|
||||||
|
│ HTTP POST with API token
|
||||||
|
│ /api/method/device_manager.api.radius_authorize
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frappe Server │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ device_manager.api.radius_authorize() │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ • Authenticate API token │ │
|
||||||
|
│ │ • Find device by MAC address │ │
|
||||||
|
│ │ • Evaluate access policy │ │
|
||||||
|
│ │ • Create audit records │ │
|
||||||
|
│ │ • Return decision with VLAN and credentials │ │
|
||||||
|
│ └──────────────────────────┬─────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ MariaDB/PostgreSQL Database │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ • DM Device (registered devices) │ │
|
||||||
|
│ │ • DM Access Policy (authorization rules) │ │
|
||||||
|
│ │ • DM Network Segment (VLAN mappings) │ │
|
||||||
|
│ │ • DM Radius Auth Event (audit log) │ │
|
||||||
|
│ │ • DM Access Decision (decision log) │ │
|
||||||
|
│ │ • DM Device Audit Event (compliance log) │ │
|
||||||
|
│ │ • Stored Credential Verifier (password hashes) │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### 1. Normal Operation (Frappe Reachable)
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → FreeRADIUS → device_manager_radius.py
|
||||||
|
│
|
||||||
|
├─→ HTTP API call to Frappe
|
||||||
|
│ POST /api/method/device_manager.api.radius_authorize
|
||||||
|
│ Authorization: token API_KEY:API_SECRET
|
||||||
|
│
|
||||||
|
│ Request payload:
|
||||||
|
│ - calling_station_id (MAC)
|
||||||
|
│ - username
|
||||||
|
│ - nas_identifier
|
||||||
|
│ - nas_ip_address
|
||||||
|
│ - ssid
|
||||||
|
│ - raw_request (full RADIUS attributes)
|
||||||
|
│
|
||||||
|
├─← Response from Frappe
|
||||||
|
│ {
|
||||||
|
│ "event": "AUTH-001",
|
||||||
|
│ "decision": "DEC-001",
|
||||||
|
│ "result": "Allow",
|
||||||
|
│ "vlan_id": 100,
|
||||||
|
│ "radius_reply_attributes": {...},
|
||||||
|
│ "cacheable_credentials": {
|
||||||
|
│ "username": "device001",
|
||||||
|
│ "control_attributes": {
|
||||||
|
│ "SSHA-Password": "base64hash"
|
||||||
|
│ }
|
||||||
|
│ }
|
||||||
|
│ }
|
||||||
|
│
|
||||||
|
├─→ Cache decision in SQLite
|
||||||
|
│
|
||||||
|
└─→ Return to FreeRADIUS
|
||||||
|
- RADIUS reply attributes (VLAN)
|
||||||
|
- Control attributes (password hash)
|
||||||
|
- Accept/Reject decision
|
||||||
|
|
||||||
|
Client ← FreeRADIUS ← Access-Accept + VLAN assignment
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Offline Operation (Frappe Unreachable)
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → FreeRADIUS → device_manager_radius.py
|
||||||
|
│
|
||||||
|
├─→ HTTP API call to Frappe (FAILS)
|
||||||
|
│ Network error / Timeout
|
||||||
|
│
|
||||||
|
├─→ Query SQLite cache
|
||||||
|
│ SELECT * FROM radius_verifier_cache
|
||||||
|
│ WHERE username = ?
|
||||||
|
│
|
||||||
|
├─← Cached decision found
|
||||||
|
│ {
|
||||||
|
│ "result": "Allow",
|
||||||
|
│ "vlan_id": 100,
|
||||||
|
│ "control_attributes": {
|
||||||
|
│ "SSHA-Password": "base64hash"
|
||||||
|
│ },
|
||||||
|
│ "from_cache": true
|
||||||
|
│ }
|
||||||
|
│
|
||||||
|
└─→ Return to FreeRADIUS
|
||||||
|
- RADIUS reply from cache
|
||||||
|
- Control attributes from cache
|
||||||
|
- Accept with cached VLAN
|
||||||
|
|
||||||
|
Client ← FreeRADIUS ← Access-Accept (from cache)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Modes Comparison
|
||||||
|
|
||||||
|
### Mode 1: Standalone Client (NEW)
|
||||||
|
|
||||||
|
**Use Case:** FreeRADIUS on dedicated appliance, Frappe on app server
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ API over HTTPS ┌─────────────────┐
|
||||||
|
│ RADIUS Server │ ←──────────────────────→ │ Frappe Server │
|
||||||
|
│ │ │ │
|
||||||
|
│ • FreeRADIUS │ │ • Frappe │
|
||||||
|
│ • Python 3.10+ │ │ • device_mgr │
|
||||||
|
│ • device_mgr_ │ │ • MariaDB │
|
||||||
|
│ radius.py │ │ │
|
||||||
|
│ • SQLite cache │ │ │
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
|
||||||
|
Dependencies: Python stdlib only
|
||||||
|
Module: device_manager_radius
|
||||||
|
Config: DEVICE_MANAGER_FRAPPE_URL + API credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mode 2: Local (Integrated)
|
||||||
|
|
||||||
|
**Use Case:** Everything on one server (lab/testing)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Single Server │
|
||||||
|
│ │
|
||||||
|
│ • FreeRADIUS │
|
||||||
|
│ • Frappe bench │
|
||||||
|
│ • device_manager app │
|
||||||
|
│ • MariaDB │
|
||||||
|
│ │
|
||||||
|
│ In-process import: │
|
||||||
|
│ device_manager.freeradius │
|
||||||
|
│ ↓ calls ↓ │
|
||||||
|
│ device_manager.radius │
|
||||||
|
│ ↓ queries ↓ │
|
||||||
|
│ Database │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
|
||||||
|
Dependencies: Full Frappe + device_manager
|
||||||
|
Module: device_manager.freeradius
|
||||||
|
Config: DEVICE_MANAGER_BENCH_PATH + SITE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mode 3: Remote (Full App Installed)
|
||||||
|
|
||||||
|
**Use Case:** RADIUS server with device_manager installed, Frappe remote
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ API over HTTPS ┌─────────────────┐
|
||||||
|
│ RADIUS Server │ ←──────────────────────→ │ Frappe Server │
|
||||||
|
│ │ │ │
|
||||||
|
│ • FreeRADIUS │ │ • Frappe │
|
||||||
|
│ • Python 3.10+ │ │ • device_mgr │
|
||||||
|
│ • device_mgr │ │ • MariaDB │
|
||||||
|
│ app (full) │ │ │
|
||||||
|
│ • SQLite cache │ │ │
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
|
||||||
|
Dependencies: device_manager package
|
||||||
|
Module: device_manager.freeradius
|
||||||
|
Config: DEVICE_MANAGER_FRAPPE_URL + API credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Architecture
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. FreeRADIUS validates device credentials (EAP-PEAP/TLS)
|
||||||
|
└─→ If valid, call device_manager_radius
|
||||||
|
|
||||||
|
2. device_manager_radius calls Frappe API
|
||||||
|
├─→ Authorization: token API_KEY:API_SECRET
|
||||||
|
├─→ HTTPS encrypted transport
|
||||||
|
└─→ Token validated by Frappe
|
||||||
|
|
||||||
|
3. Frappe evaluates device policy
|
||||||
|
├─→ Lookup device by MAC address
|
||||||
|
├─→ Check approval status
|
||||||
|
├─→ Check lifecycle state
|
||||||
|
├─→ Evaluate access policy rules
|
||||||
|
└─→ Determine VLAN assignment
|
||||||
|
|
||||||
|
4. Response cached locally (if cacheable)
|
||||||
|
├─→ Only SSHA hashes stored (no plaintext)
|
||||||
|
├─→ Cache file owned by freerad user
|
||||||
|
├─→ Optional expiration date
|
||||||
|
└─→ Used only when Frappe unreachable
|
||||||
|
```
|
||||||
|
|
||||||
|
### Secret Management
|
||||||
|
|
||||||
|
| Secret Type | Storage Location | Access Control |
|
||||||
|
|-------------|------------------|----------------|
|
||||||
|
| Device passwords | Never stored plaintext | N/A |
|
||||||
|
| SSHA verifiers | SQLite cache + Frappe DB | freerad user, DB permissions |
|
||||||
|
| API credentials | systemd override | root:root 600 |
|
||||||
|
| Frappe session tokens | Frappe DB | System Manager only |
|
||||||
|
|
||||||
|
## Performance Characteristics
|
||||||
|
|
||||||
|
| Operation | Typical Latency | Notes |
|
||||||
|
|-----------|----------------|-------|
|
||||||
|
| Cache hit | < 5ms | SQLite query |
|
||||||
|
| API call (LAN) | 10-50ms | Network + DB query |
|
||||||
|
| API call (WAN) | 50-500ms | Depends on network |
|
||||||
|
| API timeout | 2.5s (default) | Configurable |
|
||||||
|
| Cache write | < 10ms | SQLite insert |
|
||||||
|
|
||||||
|
## Scalability
|
||||||
|
|
||||||
|
### Single RADIUS Server
|
||||||
|
- Handles 1000+ auth/sec with cache hits
|
||||||
|
- ~100 auth/sec with API calls (network bound)
|
||||||
|
- SQLite cache suitable for <100k devices
|
||||||
|
|
||||||
|
### High Availability
|
||||||
|
- Deploy multiple RADIUS servers (round-robin DNS or load balancer)
|
||||||
|
- Each server maintains independent SQLite cache
|
||||||
|
- Shared Frappe backend ensures consistent policy
|
||||||
|
- Consider Redis cache for distributed deployment (future enhancement)
|
||||||
|
|
||||||
|
## Monitoring Points
|
||||||
|
|
||||||
|
### RADIUS Server
|
||||||
|
- FreeRADIUS logs (`radiusd.L_INFO`, `radiusd.L_ERR`)
|
||||||
|
- Cache hit rate (grep logs for "using cached credentials")
|
||||||
|
- API timeout rate (grep logs for "authorization failed")
|
||||||
|
- Cache size (SQLite table row count)
|
||||||
|
|
||||||
|
### Frappe Server
|
||||||
|
- API endpoint latency (Frappe request logs)
|
||||||
|
- Authentication success/failure rate
|
||||||
|
- Policy evaluation time
|
||||||
|
- Audit log growth rate
|
||||||
|
|
||||||
|
### Network
|
||||||
|
- HTTPS latency between RADIUS and Frappe
|
||||||
|
- Packet loss between clients and RADIUS
|
||||||
|
- Certificate expiration monitoring
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# RADIUS Client Changelog
|
||||||
|
|
||||||
|
## Version 1.0.0 (2026-06-17)
|
||||||
|
|
||||||
|
### Added - Standalone RADIUS Client
|
||||||
|
|
||||||
|
**Major Feature: Complete separation of RADIUS server from Frappe installation**
|
||||||
|
|
||||||
|
Created a standalone FreeRADIUS integration module that enables truly independent deployment:
|
||||||
|
|
||||||
|
- **Standalone module** (`device_manager_radius.py`)
|
||||||
|
- Self-contained Python module with zero external dependencies
|
||||||
|
- Only requires Python 3.10+ standard library
|
||||||
|
- Can run on any RADIUS server without Frappe installation
|
||||||
|
- Makes authenticated HTTP API calls to remote Frappe instance
|
||||||
|
- Full offline credential caching with SQLite
|
||||||
|
|
||||||
|
- **Automated installation** (`install.sh`)
|
||||||
|
- Interactive setup script for Ubuntu/Debian systems
|
||||||
|
- Automatic systemd environment configuration
|
||||||
|
- Creates cache directories with proper permissions
|
||||||
|
- Validates FreeRADIUS installation
|
||||||
|
|
||||||
|
- **Comprehensive documentation**
|
||||||
|
- `README.md` - Overview and installation
|
||||||
|
- `QUICKSTART.md` - Fast-track setup guide
|
||||||
|
- `CONFIGURATION.md` - Detailed FreeRADIUS configuration
|
||||||
|
- `IMPLEMENTATION_SUMMARY.md` - Technical architecture
|
||||||
|
|
||||||
|
- **Packaging support** (`pyproject.toml`)
|
||||||
|
- Can be installed as Python package
|
||||||
|
- Supports both pip and direct file deployment
|
||||||
|
- Proper project metadata and dependencies
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Updated main README.md**
|
||||||
|
- Clarified three deployment options (Standalone, Local, Remote)
|
||||||
|
- Added clear guidance on when to use each mode
|
||||||
|
- Removed redundant FreeRADIUS config examples
|
||||||
|
- Added references to new detailed documentation
|
||||||
|
|
||||||
|
- **Enhanced freeradius.py docstring**
|
||||||
|
- Better explanation of deployment modes
|
||||||
|
- Reference to standalone client for separate servers
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
**Lines of Code:**
|
||||||
|
- Python: 387 lines (device_manager_radius.py)
|
||||||
|
- Bash: 95 lines (install.sh)
|
||||||
|
- Documentation: 613 lines across 5 markdown files
|
||||||
|
- Total: ~1,095 lines
|
||||||
|
|
||||||
|
**Key Improvements:**
|
||||||
|
1. Zero dependency on Frappe/device_manager package for remote deployments
|
||||||
|
2. Reduced attack surface on RADIUS appliances
|
||||||
|
3. Simplified deployment and maintenance
|
||||||
|
4. Better separation of concerns
|
||||||
|
5. Backward compatible with existing deployments
|
||||||
|
|
||||||
|
**API Compatibility:**
|
||||||
|
- Uses existing `device_manager.api.radius_authorize` endpoint
|
||||||
|
- Same environment variable names as remote mode
|
||||||
|
- Compatible cache format with original implementation
|
||||||
|
- No changes required to Frappe server
|
||||||
|
|
||||||
|
### Migration Path
|
||||||
|
|
||||||
|
Existing installations using `device_manager.freeradius` in remote mode can optionally migrate:
|
||||||
|
|
||||||
|
1. Install standalone client on RADIUS server
|
||||||
|
2. Update FreeRADIUS config to use `device_manager_radius`
|
||||||
|
3. Keep existing environment variables unchanged
|
||||||
|
4. Test authentication
|
||||||
|
5. Optionally uninstall device_manager package from RADIUS server
|
||||||
|
|
||||||
|
No migration is required - existing deployments continue to work without changes.
|
||||||
|
|
||||||
|
### Files Added
|
||||||
|
|
||||||
|
```
|
||||||
|
radius_client/
|
||||||
|
├── __init__.py # Package init
|
||||||
|
├── .gitignore # Build artifacts ignore
|
||||||
|
├── CONFIGURATION.md # FreeRADIUS setup guide (184 lines)
|
||||||
|
├── IMPLEMENTATION_SUMMARY.md # Architecture docs (142 lines)
|
||||||
|
├── QUICKSTART.md # Fast setup guide (185 lines)
|
||||||
|
├── README.md # Overview (102 lines)
|
||||||
|
├── device_manager_radius.py # Standalone module (387 lines)
|
||||||
|
├── install.sh # Installation script (95 lines)
|
||||||
|
└── pyproject.toml # Package metadata (34 lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Validated:
|
||||||
|
- [x] Python syntax (py_compile)
|
||||||
|
- [x] Bash syntax (bash -n)
|
||||||
|
- [x] File permissions
|
||||||
|
- [x] Documentation formatting
|
||||||
|
- [ ] Live FreeRADIUS integration (requires FreeRADIUS setup)
|
||||||
|
- [ ] API authentication flow (requires Frappe instance)
|
||||||
|
- [ ] Offline caching behavior (requires network interruption testing)
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
None. This is purely additive - all existing functionality preserved.
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
- API credentials stored in systemd override (mode 600)
|
||||||
|
- Cache file owned by freerad user
|
||||||
|
- No plaintext passwords stored
|
||||||
|
- HTTPS required for production Frappe URLs
|
||||||
|
- Token-based API authentication
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
- Requires Python 3.10+ for type hints
|
||||||
|
- SQLite cache not suitable for clustered RADIUS
|
||||||
|
- HTTP timeout may need tuning for slow networks
|
||||||
|
- No built-in credential rotation mechanism
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements for future versions:
|
||||||
|
- [ ] Redis cache backend for HA deployments
|
||||||
|
- [ ] Prometheus metrics export
|
||||||
|
- [ ] Health check endpoint
|
||||||
|
- [ ] Automatic API credential rotation
|
||||||
|
- [ ] Certificate pinning for HTTPS
|
||||||
|
- [ ] Rate limiting for API calls
|
||||||
|
- [ ] Batch request support
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
|
||||||
|
- University of Georgia Manufacturing Living Labs
|
||||||
|
|
||||||
|
### License
|
||||||
|
|
||||||
|
See main device_manager app license (MIT)
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# FreeRADIUS Configuration Examples
|
||||||
|
|
||||||
|
## Module Configuration
|
||||||
|
|
||||||
|
Create or update `/etc/freeradius/3.0/mods-available/python3`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 device_manager_radius {
|
||||||
|
# Module path - Python will import device_manager_radius.py
|
||||||
|
module = device_manager_radius
|
||||||
|
|
||||||
|
# Call functions during FreeRADIUS lifecycle
|
||||||
|
instantiate = ${.module}
|
||||||
|
authorize = ${.module}
|
||||||
|
post_auth = ${.module}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable the module:
|
||||||
|
```bash
|
||||||
|
sudo ln -s ../mods-available/python3 /etc/freeradius/3.0/mods-enabled/python3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Virtual Server Configuration
|
||||||
|
|
||||||
|
Add to `/etc/freeradius/3.0/sites-available/default` or your custom virtual server:
|
||||||
|
|
||||||
|
```text
|
||||||
|
server default {
|
||||||
|
authorize {
|
||||||
|
# Pre-process request
|
||||||
|
preprocess
|
||||||
|
|
||||||
|
# Check for valid MAC address
|
||||||
|
filter_username
|
||||||
|
|
||||||
|
# Device Manager authorization
|
||||||
|
device_manager_radius
|
||||||
|
|
||||||
|
# If credentials are provided, validate them
|
||||||
|
eap {
|
||||||
|
ok = return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticate {
|
||||||
|
# Handle EAP authentication
|
||||||
|
eap
|
||||||
|
}
|
||||||
|
|
||||||
|
post-auth {
|
||||||
|
# Device Manager post-auth processing
|
||||||
|
device_manager_radius
|
||||||
|
|
||||||
|
# Update client list
|
||||||
|
update {
|
||||||
|
&reply: += &session-state:
|
||||||
|
}
|
||||||
|
|
||||||
|
Post-Auth-Type REJECT {
|
||||||
|
attr_filter.access_reject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Systemd Service Override
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/freeradius.service.d/device-manager.conf`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
# Required: Frappe server URL and authentication
|
||||||
|
Environment="DEVICE_MANAGER_FRAPPE_URL=https://device-manager.example.edu"
|
||||||
|
Environment="DEVICE_MANAGER_API_KEY=your-api-key-here"
|
||||||
|
Environment="DEVICE_MANAGER_API_SECRET=your-api-secret-here"
|
||||||
|
|
||||||
|
# Optional: Cache configuration
|
||||||
|
Environment="DEVICE_MANAGER_CACHE_PATH=/var/lib/freeradius/device_manager_verifier_cache.sqlite3"
|
||||||
|
Environment="DEVICE_MANAGER_HTTP_TIMEOUT=2.5"
|
||||||
|
Environment="DEVICE_MANAGER_CACHE_MAX_STALE_SECONDS=0"
|
||||||
|
|
||||||
|
# Optional: Enable post-auth evaluation
|
||||||
|
Environment="DEVICE_MANAGER_POST_AUTH_EVALUATE=0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload systemd:
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart freeradius
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative: /etc/default/freeradius
|
||||||
|
|
||||||
|
Add to `/etc/default/freeradius`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEVICE_MANAGER_FRAPPE_URL=https://device-manager.example.edu
|
||||||
|
DEVICE_MANAGER_API_KEY=your-api-key-here
|
||||||
|
DEVICE_MANAGER_API_SECRET=your-api-secret-here
|
||||||
|
DEVICE_MANAGER_CACHE_PATH=/var/lib/freeradius/device_manager_verifier_cache.sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test FreeRADIUS Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo freeradius -X
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for log messages like:
|
||||||
|
```
|
||||||
|
device_manager_radius: initialized remote Device Manager mode: https://device-manager.example.edu/api/method/device_manager.api.radius_authorize
|
||||||
|
device_manager_radius: SQLite credential cache enabled for offline fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Authentication
|
||||||
|
|
||||||
|
Using `radtest`:
|
||||||
|
```bash
|
||||||
|
radtest testuser testpassword localhost 0 testing123
|
||||||
|
```
|
||||||
|
|
||||||
|
Using `eapol_test` for WPA-Enterprise:
|
||||||
|
```bash
|
||||||
|
eapol_test -c test.conf -a 127.0.0.1 -p 1812 -s testing123
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `test.conf` contains:
|
||||||
|
```text
|
||||||
|
network={
|
||||||
|
ssid="test"
|
||||||
|
key_mgmt=WPA-EAP
|
||||||
|
eap=PEAP
|
||||||
|
identity="testuser"
|
||||||
|
password="testpassword"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test API Connectivity
|
||||||
|
|
||||||
|
Test the Frappe API endpoint directly:
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://device-manager.example.edu/api/method/device_manager.api.radius_authorize" \
|
||||||
|
-H "Authorization: token YOUR_API_KEY:YOUR_API_SECRET" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "calling_station_id=00:11:22:33:44:55" \
|
||||||
|
-d "username=testuser" \
|
||||||
|
-d "nas_identifier=test-ap"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"event": "AUTH-EVENT-001",
|
||||||
|
"decision": "DEC-001",
|
||||||
|
"device": "DEV-001",
|
||||||
|
"result": "Allow",
|
||||||
|
"reason": "Device approved for network access",
|
||||||
|
"network_segment": "SEG-001",
|
||||||
|
"vlan_id": 100,
|
||||||
|
"radius_reply_attributes": null,
|
||||||
|
"cacheable_credentials": {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Enable Debug Logging
|
||||||
|
|
||||||
|
Run FreeRADIUS in debug mode:
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop freeradius
|
||||||
|
sudo freeradius -X
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Cache
|
||||||
|
|
||||||
|
Inspect the SQLite cache:
|
||||||
|
```bash
|
||||||
|
sudo sqlite3 /var/lib/freeradius/device_manager_verifier_cache.sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
.schema
|
||||||
|
SELECT * FROM radius_verifier_cache;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Module not found**: Ensure `device_manager_radius.py` is in Python's import path
|
||||||
|
2. **API authentication fails**: Verify API key/secret are correct
|
||||||
|
3. **Cache permission denied**: Check `/var/lib/freeradius` ownership (should be `freerad:freerad`)
|
||||||
|
4. **Timeout errors**: Increase `DEVICE_MANAGER_HTTP_TIMEOUT` or check network connectivity
|
||||||
|
5. **SSL errors**: Verify Frappe server certificate is trusted
|
||||||
|
|
||||||
|
### Log Messages
|
||||||
|
|
||||||
|
Success:
|
||||||
|
```
|
||||||
|
device_manager_radius: initialized remote Device Manager mode: https://...
|
||||||
|
device_manager_radius: using cached credentials for username
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
```
|
||||||
|
device_manager_radius: failed to initialize: Set DEVICE_MANAGER_FRAPPE_URL...
|
||||||
|
device_manager_radius: authorization failed: [Errno 111] Connection refused
|
||||||
|
device_manager_radius: authorization failed and no cached credentials matched...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
1. **Protect API credentials**: Ensure systemd override files are mode 600
|
||||||
|
2. **Use HTTPS**: Always use HTTPS for the Frappe server URL
|
||||||
|
3. **Firewall rules**: Restrict RADIUS server to only access Frappe API endpoints
|
||||||
|
4. **Cache expiration**: Set appropriate `DEVICE_MANAGER_CACHE_MAX_STALE_SECONDS` for your security policy
|
||||||
|
5. **Monitor logs**: Regularly review FreeRADIUS logs for unauthorized access attempts
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# RADIUS Support Implementation - Summary
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The device_manager app started implementing RADIUS support but had a half-complete implementation. The issue was that even for "remote mode" (where FreeRADIUS runs on a separate server), the full device_manager Python package needed to be installed on the RADIUS server because FreeRADIUS needed to import `device_manager.freeradius`.
|
||||||
|
|
||||||
|
This prevented truly separate deployment where:
|
||||||
|
- RADIUS server runs independently on a dedicated appliance
|
||||||
|
- Frappe + device_manager runs on a separate application server
|
||||||
|
- RADIUS authenticates via API calls to Frappe (already implemented)
|
||||||
|
- No Frappe/device_manager installation needed on RADIUS server
|
||||||
|
|
||||||
|
## Solution Implemented
|
||||||
|
|
||||||
|
Created a **standalone RADIUS client** that can be deployed independently without requiring Frappe or device_manager to be installed locally.
|
||||||
|
|
||||||
|
### What Was Created
|
||||||
|
|
||||||
|
1. **Standalone Module** (`radius_client/device_manager_radius.py`)
|
||||||
|
- Self-contained Python module with zero dependencies beyond stdlib
|
||||||
|
- Only supports remote API mode (no local Frappe integration)
|
||||||
|
- Can be copied directly to FreeRADIUS without pip installation
|
||||||
|
- Makes HTTP API calls to Frappe Device Manager
|
||||||
|
- Implements SQLite credential caching for offline operation
|
||||||
|
|
||||||
|
2. **Packaging** (`radius_client/pyproject.toml`)
|
||||||
|
- Minimal package configuration for pip installation
|
||||||
|
- Can be installed with `pip install -e radius_client/`
|
||||||
|
- Provides `device_manager_radius` module
|
||||||
|
|
||||||
|
3. **Installation Script** (`radius_client/install.sh`)
|
||||||
|
- Automated deployment script for Ubuntu/Debian systems
|
||||||
|
- Copies module to FreeRADIUS Python path
|
||||||
|
- Configures systemd environment variables
|
||||||
|
- Sets up cache directory with proper permissions
|
||||||
|
- Interactive setup for API credentials
|
||||||
|
|
||||||
|
4. **Documentation**
|
||||||
|
- `radius_client/README.md` - Quick start and overview
|
||||||
|
- `radius_client/CONFIGURATION.md` - Detailed FreeRADIUS configuration examples
|
||||||
|
- Updated main `README.md` with deployment options
|
||||||
|
|
||||||
|
### Deployment Modes Now Supported
|
||||||
|
|
||||||
|
1. **Standalone Client (NEW - Recommended for Separate Servers)**
|
||||||
|
- Use: FreeRADIUS on separate server, no Frappe installed locally
|
||||||
|
- Module: `device_manager_radius.py` (from radius_client/)
|
||||||
|
- Dependencies: Python 3.10+ only
|
||||||
|
- Configuration: Environment variables for API URL/credentials
|
||||||
|
|
||||||
|
2. **Local Mode (Existing)**
|
||||||
|
- Use: FreeRADIUS on same host as Frappe bench
|
||||||
|
- Module: `device_manager.freeradius`
|
||||||
|
- Dependencies: Full Frappe + device_manager installation
|
||||||
|
- Configuration: DEVICE_MANAGER_BENCH_PATH, DEVICE_MANAGER_SITE
|
||||||
|
|
||||||
|
3. **Remote Mode (Existing)**
|
||||||
|
- Use: FreeRADIUS with device_manager installed but Frappe remote
|
||||||
|
- Module: `device_manager.freeradius`
|
||||||
|
- Dependencies: device_manager package installed
|
||||||
|
- Configuration: DEVICE_MANAGER_FRAPPE_URL, API credentials
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- **Zero external dependencies**: Uses only Python stdlib (json, sqlite3, urllib)
|
||||||
|
- **Offline credential caching**: SQLite cache with configurable staleness
|
||||||
|
- **Automatic failover**: Falls back to cache when Frappe unreachable
|
||||||
|
- **VLAN assignment**: Returns VLAN and reply attributes from Frappe policy
|
||||||
|
- **Quarantine support**: Routes unknown devices to quarantine VLAN
|
||||||
|
- **Comprehensive logging**: Integrates with FreeRADIUS logging system
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
|
||||||
|
```
|
||||||
|
device_manager/radius_client/
|
||||||
|
├── __init__.py # Package init
|
||||||
|
├── .gitignore # Python build artifacts
|
||||||
|
├── CONFIGURATION.md # Detailed FreeRADIUS setup guide
|
||||||
|
├── README.md # Quick start guide
|
||||||
|
├── device_manager_radius.py # Standalone module (387 lines)
|
||||||
|
├── install.sh # Automated installation script
|
||||||
|
└── pyproject.toml # Package configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
The standalone module can be tested without affecting the main device_manager app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy to FreeRADIUS
|
||||||
|
sudo cp radius_client/device_manager_radius.py /etc/freeradius/3.0/mods-config/python3/
|
||||||
|
|
||||||
|
# Configure (see CONFIGURATION.md)
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# Test in debug mode
|
||||||
|
sudo freeradius -X
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Path
|
||||||
|
|
||||||
|
Existing deployments using `device_manager.freeradius` in remote mode can optionally migrate to the standalone client for a lighter footprint:
|
||||||
|
|
||||||
|
1. Copy `device_manager_radius.py` to RADIUS server
|
||||||
|
2. Update FreeRADIUS config to use `device_manager_radius` module
|
||||||
|
3. Keep same environment variables (DEVICE_MANAGER_FRAPPE_URL, etc.)
|
||||||
|
4. Uninstall device_manager package from RADIUS server (optional)
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **True separation of concerns**: RADIUS server is just a RADIUS server
|
||||||
|
2. **Minimal attack surface**: No Frappe code on RADIUS appliance
|
||||||
|
3. **Easier deployment**: Single Python file + config
|
||||||
|
4. **Independent updates**: Update Frappe without touching RADIUS
|
||||||
|
5. **Better security**: RADIUS server doesn't need database credentials
|
||||||
|
6. **Simplified maintenance**: Fewer moving parts on RADIUS server
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
All existing deployment modes continue to work unchanged. The standalone client is an additional option, not a replacement.
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Quick Start Guide
|
||||||
|
|
||||||
|
## For New Deployments (Separate RADIUS Server)
|
||||||
|
|
||||||
|
### 1. Install on RADIUS Server
|
||||||
|
|
||||||
|
**Option A: Direct file copy (simplest)**
|
||||||
|
```bash
|
||||||
|
sudo cp device_manager_radius.py /etc/freeradius/3.0/mods-config/python3/
|
||||||
|
sudo chmod 644 /etc/freeradius/3.0/mods-config/python3/device_manager_radius.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Use install script**
|
||||||
|
```bash
|
||||||
|
sudo ./install.sh
|
||||||
|
# Follow prompts to configure API credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option C: Install as package**
|
||||||
|
```bash
|
||||||
|
pip install -e /path/to/radius_client
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure FreeRADIUS Module
|
||||||
|
|
||||||
|
Create `/etc/freeradius/3.0/mods-available/python3`:
|
||||||
|
```text
|
||||||
|
python3 device_manager_radius {
|
||||||
|
module = device_manager_radius
|
||||||
|
instantiate = ${.module}
|
||||||
|
authorize = ${.module}
|
||||||
|
post_auth = ${.module}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable it:
|
||||||
|
```bash
|
||||||
|
sudo ln -s ../mods-available/python3 /etc/freeradius/3.0/mods-enabled/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Set Environment Variables
|
||||||
|
|
||||||
|
Edit `/etc/systemd/system/freeradius.service.d/device-manager.conf`:
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
Environment="DEVICE_MANAGER_FRAPPE_URL=https://your-server.example.edu"
|
||||||
|
Environment="DEVICE_MANAGER_API_KEY=your-api-key"
|
||||||
|
Environment="DEVICE_MANAGER_API_SECRET=your-api-secret"
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload:
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update Virtual Server
|
||||||
|
|
||||||
|
Edit `/etc/freeradius/3.0/sites-enabled/default`:
|
||||||
|
```text
|
||||||
|
authorize {
|
||||||
|
preprocess
|
||||||
|
device_manager_radius
|
||||||
|
eap
|
||||||
|
}
|
||||||
|
|
||||||
|
post-auth {
|
||||||
|
device_manager_radius
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test configuration
|
||||||
|
sudo freeradius -X
|
||||||
|
|
||||||
|
# In another terminal, test auth
|
||||||
|
radtest testuser testpass localhost 0 testing123
|
||||||
|
```
|
||||||
|
|
||||||
|
## For Existing Deployments (Same Server as Frappe)
|
||||||
|
|
||||||
|
### Continue Using Integrated Module
|
||||||
|
|
||||||
|
No changes needed! Your current configuration with `device_manager.freeradius` continues to work.
|
||||||
|
|
||||||
|
FreeRADIUS config:
|
||||||
|
```text
|
||||||
|
python3 device_manager {
|
||||||
|
module = device_manager.freeradius
|
||||||
|
instantiate = ${.module}
|
||||||
|
authorize = ${.module}
|
||||||
|
post_auth = ${.module}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
```bash
|
||||||
|
DEVICE_MANAGER_BENCH_PATH=/home/frappe/frappe-bench
|
||||||
|
DEVICE_MANAGER_SITE=your-site-name
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `DEVICE_MANAGER_FRAPPE_URL` | Frappe server base URL | `https://device-manager.example.edu` |
|
||||||
|
| `DEVICE_MANAGER_API_KEY` | API authentication key | `abc123...` |
|
||||||
|
| `DEVICE_MANAGER_API_SECRET` | API authentication secret | `xyz789...` |
|
||||||
|
|
||||||
|
### Optional Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DEVICE_MANAGER_CACHE_PATH` | `/var/lib/freeradius/device_manager_cache.sqlite3` | SQLite cache file path |
|
||||||
|
| `DEVICE_MANAGER_HTTP_TIMEOUT` | `2.5` | API call timeout (seconds) |
|
||||||
|
| `DEVICE_MANAGER_CACHE_MAX_STALE_SECONDS` | `0` | Max cache age (0=unlimited) |
|
||||||
|
| `DEVICE_MANAGER_POST_AUTH_EVALUATE` | `0` | Enable post-auth evaluation |
|
||||||
|
|
||||||
|
## Generating API Credentials
|
||||||
|
|
||||||
|
On your Frappe server:
|
||||||
|
|
||||||
|
1. Go to **User** list
|
||||||
|
2. Create or edit a System User
|
||||||
|
3. Generate **API Key** and **API Secret**
|
||||||
|
4. Grant permissions for:
|
||||||
|
- DM Device (Read)
|
||||||
|
- DM Radius Auth Event (Create)
|
||||||
|
- DM Access Decision (Create)
|
||||||
|
- DM Device Audit Event (Create)
|
||||||
|
- DM Network Segment (Read)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Module fails to load
|
||||||
|
```bash
|
||||||
|
# Check Python path
|
||||||
|
python3 -c "import device_manager_radius"
|
||||||
|
|
||||||
|
# Check file permissions
|
||||||
|
ls -l /etc/freeradius/3.0/mods-config/python3/device_manager_radius.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### API authentication fails
|
||||||
|
```bash
|
||||||
|
# Test API endpoint directly
|
||||||
|
curl -X POST "$DEVICE_MANAGER_FRAPPE_URL/api/method/device_manager.api.radius_authorize" \
|
||||||
|
-H "Authorization: token $API_KEY:$API_SECRET" \
|
||||||
|
-d "calling_station_id=00:11:22:33:44:55"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache permission denied
|
||||||
|
```bash
|
||||||
|
# Fix ownership
|
||||||
|
sudo chown -R freerad:freerad /var/lib/freeradius
|
||||||
|
sudo chmod 750 /var/lib/freeradius
|
||||||
|
```
|
||||||
|
|
||||||
|
### View logs
|
||||||
|
```bash
|
||||||
|
# Real-time debug
|
||||||
|
sudo freeradius -X
|
||||||
|
|
||||||
|
# System logs
|
||||||
|
sudo journalctl -u freeradius -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Next?
|
||||||
|
|
||||||
|
- Read [CONFIGURATION.md](CONFIGURATION.md) for detailed setup
|
||||||
|
- Review [README.md](README.md) for architecture details
|
||||||
|
- Check [IMPLEMENTATION_SUMMARY.md](IMPLEMENTATION_SUMMARY.md) for technical background
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues, check:
|
||||||
|
1. FreeRADIUS debug logs (`freeradius -X`)
|
||||||
|
2. Frappe logs on the application server
|
||||||
|
3. Network connectivity between RADIUS and Frappe server
|
||||||
|
4. API credentials are valid and have proper permissions
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Device Manager RADIUS Client
|
||||||
|
|
||||||
|
Standalone FreeRADIUS module for remote Device Manager integration.
|
||||||
|
|
||||||
|
This package provides a minimal RADIUS client that authenticates against a remote Frappe Device Manager instance via API calls. Use this when your FreeRADIUS server is on a separate host from your Frappe installation.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Option 1: Install as Python package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e /path/to/radius_client
|
||||||
|
```
|
||||||
|
|
||||||
|
Then configure FreeRADIUS to use the module:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 device_manager_radius {
|
||||||
|
module = device_manager_radius
|
||||||
|
instantiate = ${.module}
|
||||||
|
authorize = ${.module}
|
||||||
|
post_auth = ${.module}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Direct file deployment
|
||||||
|
|
||||||
|
Copy `device_manager_radius.py` to your FreeRADIUS Python module path (e.g., `/etc/freeradius/3.0/mods-config/python3/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp device_manager_radius.py /etc/freeradius/3.0/mods-config/python3/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then configure FreeRADIUS:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 device_manager_radius {
|
||||||
|
module = device_manager_radius
|
||||||
|
instantiate = ${.module}
|
||||||
|
authorize = ${.module}
|
||||||
|
post_auth = ${.module}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Set these environment variables (in `/etc/default/freeradius` or systemd override):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required: Frappe server URL and API credentials
|
||||||
|
DEVICE_MANAGER_FRAPPE_URL=https://device-manager.example.edu
|
||||||
|
DEVICE_MANAGER_API_KEY=your-api-key
|
||||||
|
DEVICE_MANAGER_API_SECRET=your-api-secret
|
||||||
|
|
||||||
|
# Optional: Cache configuration
|
||||||
|
DEVICE_MANAGER_CACHE_PATH=/var/lib/freeradius/device_manager_verifier_cache.sqlite3
|
||||||
|
DEVICE_MANAGER_HTTP_TIMEOUT=2.5
|
||||||
|
DEVICE_MANAGER_CACHE_MAX_STALE_SECONDS=0
|
||||||
|
|
||||||
|
# Optional: Enable post-auth evaluation
|
||||||
|
DEVICE_MANAGER_POST_AUTH_EVALUATE=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generating API credentials
|
||||||
|
|
||||||
|
On your Frappe server, create an API key/secret pair:
|
||||||
|
|
||||||
|
1. Navigate to **API Secret** in Device Manager settings or create a System User
|
||||||
|
2. Generate an API Key and API Secret
|
||||||
|
3. Grant the user permissions for Device Manager doctypes
|
||||||
|
|
||||||
|
## FreeRADIUS configuration
|
||||||
|
|
||||||
|
Add to your FreeRADIUS virtual server:
|
||||||
|
|
||||||
|
```text
|
||||||
|
authorize {
|
||||||
|
# Other modules...
|
||||||
|
device_manager_radius
|
||||||
|
}
|
||||||
|
|
||||||
|
post-auth {
|
||||||
|
device_manager_radius
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Remote authentication**: Makes API calls to Frappe Device Manager for real-time decisions
|
||||||
|
- **Offline credential caching**: Caches RADIUS verifiers (SSHA-Password) for long-lived IoT devices
|
||||||
|
- **Automatic failover**: Falls back to cached credentials when Frappe is unreachable
|
||||||
|
- **VLAN assignment**: Returns VLAN and reply attributes based on device policy
|
||||||
|
- **Quarantine support**: Routes unknown devices to quarantine VLAN
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
Check FreeRADIUS logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo tail -f /var/log/freeradius/radius.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Test the module directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo freeradius -X
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify API connectivity:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://device-manager.example.edu/api/method/device_manager.api.radius_authorize" \
|
||||||
|
-H "Authorization: token your-api-key:your-api-secret" \
|
||||||
|
-d "calling_station_id=00:11:22:33:44:55" \
|
||||||
|
-d "username=testuser"
|
||||||
|
```
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Device Manager RADIUS Client - Standalone FreeRADIUS module."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__all__ = ["device_manager_radius"]
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""FreeRADIUS rlm_python bridge for remote Device Manager instances.
|
||||||
|
|
||||||
|
This standalone module calls a Frappe Device Manager API over token-authenticated
|
||||||
|
HTTP(S) and keeps a local SQLite credential cache for long-lived IoT devices when
|
||||||
|
Frappe is temporarily unavailable.
|
||||||
|
|
||||||
|
This module does NOT require Frappe or device_manager to be installed locally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from contextlib import closing
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
try:
|
||||||
|
import radiusd
|
||||||
|
except ImportError: # pragma: no cover - radiusd exists only inside FreeRADIUS.
|
||||||
|
radiusd = None
|
||||||
|
|
||||||
|
_cache_initialized = False
|
||||||
|
|
||||||
|
RLM_MODULE_OK = getattr(radiusd, "RLM_MODULE_OK", 2)
|
||||||
|
RLM_MODULE_REJECT = getattr(radiusd, "RLM_MODULE_REJECT", 0)
|
||||||
|
RLM_MODULE_FAIL = getattr(radiusd, "RLM_MODULE_FAIL", 1)
|
||||||
|
RLM_MODULE_NOOP = getattr(radiusd, "RLM_MODULE_NOOP", 7)
|
||||||
|
|
||||||
|
REQUEST_MAC_ATTRIBUTES = (
|
||||||
|
"Calling-Station-Id",
|
||||||
|
"TLS-Client-Cert-Common-Name",
|
||||||
|
)
|
||||||
|
|
||||||
|
USERNAME_ATTRIBUTES = ("User-Name", "Stripped-User-Name")
|
||||||
|
|
||||||
|
|
||||||
|
def _log(message: str):
|
||||||
|
if radiusd:
|
||||||
|
radiusd.radlog(radiusd.L_INFO, f"device_manager_radius: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def _error(message: str):
|
||||||
|
if radiusd:
|
||||||
|
radiusd.radlog(radiusd.L_ERR, f"device_manager_radius: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def _as_request_dict(packet: Iterable[tuple[str, str]]) -> dict[str, str]:
|
||||||
|
request = {}
|
||||||
|
for key, value in packet or ():
|
||||||
|
request.setdefault(key, value)
|
||||||
|
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:
|
||||||
|
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 not frappe_url:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Set DEVICE_MANAGER_FRAPPE_URL or DEVICE_MANAGER_API_URL to the Frappe server URL."
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"{frappe_url}/api/method/device_manager.api.radius_authorize"
|
||||||
|
|
||||||
|
|
||||||
|
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"):
|
||||||
|
vlan_id = str(decision["vlan_id"])
|
||||||
|
attributes.extend(
|
||||||
|
[
|
||||||
|
("Tunnel-Type", "VLAN"),
|
||||||
|
("Tunnel-Medium-Type", "IEEE-802"),
|
||||||
|
("Tunnel-Private-Group-Id", vlan_id),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if decision.get("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():
|
||||||
|
attributes.append((key, str(value)))
|
||||||
|
|
||||||
|
return tuple(attributes)
|
||||||
|
|
||||||
|
|
||||||
|
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 _evaluate_remotely(request: dict[str, str]) -> dict:
|
||||||
|
api_url = _remote_api_url()
|
||||||
|
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 authentication.")
|
||||||
|
|
||||||
|
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:
|
||||||
|
_initialize_cache()
|
||||||
|
api_url = _remote_api_url()
|
||||||
|
_log(f"initialized remote Device Manager mode: {api_url}")
|
||||||
|
_log("SQLite credential cache enabled for offline fallback")
|
||||||
|
except Exception as exc:
|
||||||
|
_error(f"failed to initialize: {exc}")
|
||||||
|
return RLM_MODULE_FAIL
|
||||||
|
return RLM_MODULE_OK
|
||||||
|
|
||||||
|
|
||||||
|
def authorize(packet):
|
||||||
|
return _evaluate_packet(packet, allow_cache_fallback=True)
|
||||||
|
|
||||||
|
|
||||||
|
def post_auth(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 static credential material, authorization, and segmentation.
|
||||||
|
return RLM_MODULE_NOOP
|
||||||
|
|
||||||
|
|
||||||
|
def detach():
|
||||||
|
return RLM_MODULE_OK
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_packet(packet, *, allow_cache_fallback: bool):
|
||||||
|
request = _as_request_dict(packet)
|
||||||
|
username = _get_first(request, *USERNAME_ATTRIBUTES)
|
||||||
|
|
||||||
|
try:
|
||||||
|
decision = _evaluate_remotely(request)
|
||||||
|
_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
|
||||||
|
|
||||||
|
decision = _cached_decision(username)
|
||||||
|
if not decision:
|
||||||
|
_error(
|
||||||
|
f"authorization failed and no cached credentials matched {username or '<missing username>'}: {exc}"
|
||||||
|
)
|
||||||
|
return RLM_MODULE_FAIL
|
||||||
|
_log(f"using cached credentials for {username}")
|
||||||
|
except Exception as exc:
|
||||||
|
_error(f"authorization failed: {exc}")
|
||||||
|
return RLM_MODULE_FAIL
|
||||||
|
|
||||||
|
try:
|
||||||
|
reply = _reply_attributes_from_decision(decision)
|
||||||
|
control = _control_attributes_from_decision(decision)
|
||||||
|
except Exception as exc:
|
||||||
|
_error(f"failed to build RADIUS attributes: {exc}")
|
||||||
|
return RLM_MODULE_FAIL
|
||||||
|
|
||||||
|
if decision["result"] == "Deny":
|
||||||
|
return RLM_MODULE_REJECT, reply, control
|
||||||
|
|
||||||
|
return RLM_MODULE_OK, reply, control
|
||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy Device Manager RADIUS client to FreeRADIUS server
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
RADIUS_PYTHON_DIR="${RADIUS_PYTHON_DIR:-/etc/freeradius/3.0/mods-config/python3}"
|
||||||
|
SYSTEMD_OVERRIDE_DIR="/etc/systemd/system/freeradius.service.d"
|
||||||
|
|
||||||
|
echo "Device Manager RADIUS Client Installation"
|
||||||
|
echo "=========================================="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "Error: This script must be run as root (use sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if FreeRADIUS is installed
|
||||||
|
if ! command -v freeradius &> /dev/null; then
|
||||||
|
echo "Error: FreeRADIUS is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "1. Copying device_manager_radius.py to $RADIUS_PYTHON_DIR..."
|
||||||
|
mkdir -p "$RADIUS_PYTHON_DIR"
|
||||||
|
cp "$SCRIPT_DIR/device_manager_radius.py" "$RADIUS_PYTHON_DIR/"
|
||||||
|
chmod 644 "$RADIUS_PYTHON_DIR/device_manager_radius.py"
|
||||||
|
echo " ✓ Module copied"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "2. Setting up environment configuration..."
|
||||||
|
if [ ! -d "$SYSTEMD_OVERRIDE_DIR" ]; then
|
||||||
|
mkdir -p "$SYSTEMD_OVERRIDE_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prompt for configuration
|
||||||
|
read -p "Enter Frappe server URL (e.g., https://device-manager.example.edu): " FRAPPE_URL
|
||||||
|
read -p "Enter API Key: " API_KEY
|
||||||
|
read -sp "Enter API Secret: " API_SECRET
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Create systemd override
|
||||||
|
cat > "$SYSTEMD_OVERRIDE_DIR/device-manager.conf" << EOF
|
||||||
|
[Service]
|
||||||
|
Environment="DEVICE_MANAGER_FRAPPE_URL=$FRAPPE_URL"
|
||||||
|
Environment="DEVICE_MANAGER_API_KEY=$API_KEY"
|
||||||
|
Environment="DEVICE_MANAGER_API_SECRET=$API_SECRET"
|
||||||
|
Environment="DEVICE_MANAGER_CACHE_PATH=/var/lib/freeradius/device_manager_verifier_cache.sqlite3"
|
||||||
|
Environment="DEVICE_MANAGER_HTTP_TIMEOUT=2.5"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 600 "$SYSTEMD_OVERRIDE_DIR/device-manager.conf"
|
||||||
|
echo " ✓ Environment configured"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "3. Creating cache directory..."
|
||||||
|
mkdir -p /var/lib/freeradius
|
||||||
|
chown freerad:freerad /var/lib/freeradius
|
||||||
|
chmod 750 /var/lib/freeradius
|
||||||
|
echo " ✓ Cache directory created"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "4. Reloading systemd configuration..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo " ✓ Systemd reloaded"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Installation complete!"
|
||||||
|
echo
|
||||||
|
echo "Next steps:"
|
||||||
|
echo "1. Configure FreeRADIUS module in /etc/freeradius/3.0/mods-available/python3:"
|
||||||
|
echo
|
||||||
|
cat << 'EOF'
|
||||||
|
python3 device_manager_radius {
|
||||||
|
module = device_manager_radius
|
||||||
|
instantiate = ${.module}
|
||||||
|
authorize = ${.module}
|
||||||
|
post_auth = ${.module}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo
|
||||||
|
echo "2. Enable the module:"
|
||||||
|
echo " ln -s ../mods-available/python3 /etc/freeradius/3.0/mods-enabled/python3"
|
||||||
|
echo
|
||||||
|
echo "3. Add to your virtual server authorize section:"
|
||||||
|
echo " device_manager_radius"
|
||||||
|
echo
|
||||||
|
echo "4. Add to your virtual server post-auth section:"
|
||||||
|
echo " device_manager_radius"
|
||||||
|
echo
|
||||||
|
echo "5. Test configuration:"
|
||||||
|
echo " freeradius -X"
|
||||||
|
echo
|
||||||
|
echo "6. Restart FreeRADIUS:"
|
||||||
|
echo " systemctl restart freeradius"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[project]
|
||||||
|
name = "device-manager-radius-client"
|
||||||
|
version = "1.0.0"
|
||||||
|
authors = [
|
||||||
|
{ name = "University of Georgia Manufacturing Living Labs", email = "cengr-manufacturing@uga.edu" }
|
||||||
|
]
|
||||||
|
description = "Standalone FreeRADIUS module for remote Frappe Device Manager integration"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
readme = "README.md"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
keywords = ["radius", "freeradius", "device-manager", "frappe", "authentication", "network"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: System Administrators",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Topic :: System :: Systems Administration :: Authentication/Directory",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["flit_core >=3.4,<4"]
|
||||||
|
build-backend = "flit_core.buildapi"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/yourusername/device_manager"
|
||||||
|
Documentation = "https://github.com/yourusername/device_manager/tree/main/radius_client"
|
||||||
|
Repository = "https://github.com/yourusername/device_manager"
|
||||||
Reference in New Issue
Block a user