352 lines
9.8 KiB
Python
352 lines
9.8 KiB
Python
"""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
|