395 lines
12 KiB
Python
395 lines
12 KiB
Python
import re
|
|
from datetime import datetime
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
|
|
class ORMModel(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
# ---------- auth: login / session / current user ----------
|
|
class LoginRequest(BaseModel):
|
|
username: str = Field(max_length=64)
|
|
password: str = Field(min_length=1, max_length=256)
|
|
|
|
|
|
class LoginResponse(BaseModel):
|
|
token: str
|
|
username: str
|
|
is_admin: bool
|
|
must_change_password: bool
|
|
|
|
|
|
class CurrentUser(BaseModel):
|
|
id: int
|
|
username: str
|
|
is_admin: bool
|
|
must_change_password: bool
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
# Optional: a forced first-login change skips it (the user just authenticated
|
|
# with their current password to reach that screen). A normal self-service
|
|
# change from the account page still requires it.
|
|
current_password: str | None = Field(default=None, max_length=256)
|
|
new_password: str = Field(min_length=6, max_length=256)
|
|
|
|
|
|
# ---------- admin users (radadmin_admins) ----------
|
|
class AdminOut(ORMModel):
|
|
id: int
|
|
username: str
|
|
is_admin: bool
|
|
must_change_password: bool
|
|
created_at: datetime | None = None
|
|
|
|
|
|
class AdminCreate(BaseModel):
|
|
username: str = Field(min_length=1, max_length=64)
|
|
password: str = Field(min_length=6, max_length=256)
|
|
is_admin: bool = False
|
|
force_password_change: bool = True # require a reset on first login
|
|
|
|
|
|
class ResetPasswordRequest(BaseModel):
|
|
new_password: str = Field(min_length=6, max_length=256)
|
|
force_password_change: bool = True # make the user reset again on next login
|
|
|
|
|
|
# ---------- API keys (radadmin_api_keys) ----------
|
|
class ApiKeyOut(ORMModel):
|
|
id: int
|
|
name: str
|
|
key_prefix: str
|
|
created_by: str | None = None
|
|
created_at: datetime | None = None
|
|
last_used_at: datetime | None = None
|
|
revoked: bool
|
|
|
|
|
|
class ApiKeyCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=64)
|
|
|
|
|
|
class ApiKeyCreated(ApiKeyOut):
|
|
key: str # the raw key, shown only once at creation
|
|
|
|
|
|
# ---------- activity log (radadmin_logs) ----------
|
|
class LogOut(ORMModel):
|
|
id: int
|
|
username: str | None = None
|
|
action: str
|
|
detail: str | None = None
|
|
ip_address: str | None = None
|
|
created_at: datetime | None = None
|
|
|
|
|
|
# ---------- customers ----------
|
|
# Human metadata (name/phone/alias) lives in radadmin_clients now — customers holds
|
|
# only the RADIUS-adjacent identity + billing status.
|
|
class CustomerBase(BaseModel):
|
|
username: str = Field(max_length=64)
|
|
mac_address: str = Field(max_length=17)
|
|
status: Literal["new", "paid", "unpaid"] = "new"
|
|
|
|
|
|
class CustomerCreate(CustomerBase):
|
|
pass
|
|
|
|
|
|
class CustomerUpdate(BaseModel):
|
|
username: str | None = Field(default=None, max_length=64)
|
|
mac_address: str | None = Field(default=None, max_length=17)
|
|
status: Literal["new", "paid", "unpaid"] | None = None
|
|
|
|
|
|
class CustomerOut(ORMModel, CustomerBase):
|
|
id: int
|
|
created_at: datetime | None = None
|
|
|
|
|
|
# ---------- nas ----------
|
|
class NasBase(BaseModel):
|
|
nasname: str = Field(max_length=128)
|
|
shortname: str | None = Field(default=None, max_length=32)
|
|
type: str | None = Field(default="other", max_length=30)
|
|
ports: int | None = None
|
|
secret: str = Field(default="secret", max_length=60)
|
|
server: str | None = Field(default=None, max_length=64)
|
|
community: str | None = Field(default=None, max_length=50)
|
|
description: str | None = Field(default="RADIUS Client", max_length=200)
|
|
|
|
|
|
class NasCreate(NasBase):
|
|
pass
|
|
|
|
|
|
class NasUpdate(BaseModel):
|
|
nasname: str | None = Field(default=None, max_length=128)
|
|
shortname: str | None = Field(default=None, max_length=32)
|
|
type: str | None = Field(default=None, max_length=30)
|
|
ports: int | None = None
|
|
secret: str | None = Field(default=None, max_length=60)
|
|
server: str | None = Field(default=None, max_length=64)
|
|
community: str | None = Field(default=None, max_length=50)
|
|
description: str | None = Field(default=None, max_length=200)
|
|
|
|
|
|
class NasOut(ORMModel, NasBase):
|
|
id: int
|
|
|
|
|
|
# ---------- attribute pair tables (radcheck / radreply) ----------
|
|
class UserAttrBase(BaseModel):
|
|
username: str = Field(max_length=64)
|
|
attribute: str = Field(max_length=64)
|
|
op: str = Field(max_length=2)
|
|
value: str = Field(max_length=253)
|
|
|
|
|
|
class UserAttrCreate(UserAttrBase):
|
|
pass
|
|
|
|
|
|
class UserAttrUpdate(BaseModel):
|
|
username: str | None = Field(default=None, max_length=64)
|
|
attribute: str | None = Field(default=None, max_length=64)
|
|
op: str | None = Field(default=None, max_length=2)
|
|
value: str | None = Field(default=None, max_length=253)
|
|
|
|
|
|
class UserAttrOut(ORMModel, UserAttrBase):
|
|
id: int
|
|
|
|
|
|
# ---------- group attribute tables (radgroupcheck / radgroupreply / vlans) ----------
|
|
class GroupAttrBase(BaseModel):
|
|
groupname: str = Field(max_length=64)
|
|
attribute: str = Field(max_length=64)
|
|
op: str = Field(max_length=2)
|
|
value: str = Field(max_length=253)
|
|
|
|
|
|
class GroupAttrCreate(GroupAttrBase):
|
|
pass
|
|
|
|
|
|
class GroupAttrUpdate(BaseModel):
|
|
groupname: str | None = Field(default=None, max_length=64)
|
|
attribute: str | None = Field(default=None, max_length=64)
|
|
op: str | None = Field(default=None, max_length=2)
|
|
value: str | None = Field(default=None, max_length=253)
|
|
|
|
|
|
class GroupAttrOut(ORMModel, GroupAttrBase):
|
|
id: int
|
|
|
|
|
|
# ---------- vlans (logical view over radgroupreply) ----------
|
|
class VlanOut(BaseModel):
|
|
alias: str # radgroupreply.groupname
|
|
vlanid: int # value of the Tunnel-Private-Group-Id attribute
|
|
|
|
|
|
class VlanCreate(BaseModel):
|
|
vlanid: int = Field(ge=1, le=4094, description="802.1Q VLAN ID")
|
|
alias: str = Field(min_length=1, max_length=64, description="Group name for this VLAN")
|
|
|
|
|
|
class VlanEdit(BaseModel):
|
|
vlanid: int = Field(ge=1, le=4094, description="VLAN ID identifying the group to rename")
|
|
alias: str = Field(min_length=1, max_length=64, description="New alias (groupname)")
|
|
|
|
|
|
# ---------- clients (logical view over radcheck + radusergroup + customers) ----------
|
|
_MAC_RE = r"^[0-9A-Fa-f]{2}([-:][0-9A-Fa-f]{2}){5}$"
|
|
|
|
|
|
def _normalize_mac(mac: str) -> str:
|
|
"""Canonicalize a MAC to uppercase, hyphen-separated (AA-BB-CC-DD-EE-FF)."""
|
|
return mac.strip().upper().replace(":", "-")
|
|
|
|
|
|
_PHONE_ERROR = "Enter a valid phone number"
|
|
|
|
|
|
def _normalize_phone(raw: str) -> str:
|
|
"""Validate a Maldivian mobile number and return the 7-digit local form.
|
|
|
|
Rules: 7 digits, starts with 9 or 7. A 960/+960 country code is stripped ONLY
|
|
when the number is 10 digits (960 + 7) — a bare 7-digit number like 9601234 is
|
|
a valid local number and is never stripped.
|
|
"""
|
|
digits = re.sub(r"\D", "", raw or "")
|
|
if len(digits) == 10 and digits.startswith("960"):
|
|
digits = digits[3:]
|
|
if len(digits) == 7 and digits[0] in ("7", "9"):
|
|
return digits
|
|
raise ValueError(_PHONE_ERROR)
|
|
|
|
|
|
class ClientOut(BaseModel):
|
|
mac_address: str
|
|
group: str | None = None # radusergroup.groupname
|
|
status: str | None = None # customers.status
|
|
name: str | None = None # customers.name (human metadata)
|
|
phone: str | None = None # customers.phone (human metadata)
|
|
alias: str | None = None # radadmin_clients.alias (human metadata)
|
|
|
|
|
|
class ClientCreate(BaseModel):
|
|
mac_address: str = Field(pattern=_MAC_RE, max_length=17)
|
|
group: str = Field(min_length=1, max_length=64)
|
|
name: str = Field(min_length=1, max_length=128, description="Customer name (metadata, ignored by RADIUS)")
|
|
phone: str = Field(min_length=1, max_length=32, description="7-digit mobile (9/7…), 960/+960 optional")
|
|
alias: str | None = Field(default=None, max_length=64, description="Optional device alias (metadata)")
|
|
|
|
@field_validator("mac_address")
|
|
@classmethod
|
|
def _norm(cls, v: str) -> str:
|
|
return _normalize_mac(v)
|
|
|
|
@field_validator("phone")
|
|
@classmethod
|
|
def _norm_phone(cls, v: str) -> str:
|
|
return _normalize_phone(v)
|
|
|
|
|
|
class ClientEdit(BaseModel):
|
|
mac_address: str = Field(pattern=_MAC_RE, max_length=17)
|
|
group: str | None = Field(default=None, max_length=64)
|
|
status: Literal["new", "paid", "unpaid"] | None = None
|
|
name: str | None = Field(default=None, max_length=128)
|
|
phone: str | None = Field(default=None, max_length=32)
|
|
alias: str | None = Field(default=None, max_length=64)
|
|
|
|
@field_validator("mac_address")
|
|
@classmethod
|
|
def _norm(cls, v: str) -> str:
|
|
return _normalize_mac(v)
|
|
|
|
@field_validator("phone")
|
|
@classmethod
|
|
def _norm_phone(cls, v: str | None) -> str | None:
|
|
return None if v is None else _normalize_phone(v)
|
|
|
|
@model_validator(mode="after")
|
|
def _at_least_one(self):
|
|
if all(v is None for v in (self.group, self.status, self.name, self.phone, self.alias)):
|
|
raise ValueError("provide at least one of: group, status, name, phone, alias")
|
|
return self
|
|
|
|
|
|
# ---------- client CSV import ----------
|
|
class ClientImportRow(BaseModel):
|
|
"""A single CSV row — lenient so one bad row never 422s the whole batch.
|
|
|
|
Each row is re-validated with ``ClientCreate`` inside the router so failures
|
|
are collected per-row instead of rejecting the entire request.
|
|
"""
|
|
|
|
mac_address: str | None = None
|
|
group: str | None = None
|
|
name: str | None = None
|
|
phone: str | None = None
|
|
alias: str | None = None
|
|
|
|
|
|
class ClientImportRequest(BaseModel):
|
|
devices: list[ClientImportRow]
|
|
dry_run: bool = Field(default=False, description="Validate only; commit nothing")
|
|
|
|
|
|
class ClientImportError(BaseModel):
|
|
row: int # 1-based index within the submitted rows
|
|
mac: str | None = None
|
|
detail: str
|
|
|
|
|
|
class ClientImportResult(BaseModel):
|
|
total: int # rows submitted
|
|
valid: int # rows that passed validation
|
|
created: int # rows actually inserted (0 on dry_run)
|
|
dry_run: bool
|
|
errors: list[ClientImportError]
|
|
|
|
|
|
# ---------- devices (NAS/AP boxes seen in radacct, aliased via radadmin_devices) ----------
|
|
class DeviceOut(BaseModel):
|
|
ap_mac: str # calledstationid before ':' — the device key
|
|
nasipaddress: str | None = None # NAS IP(s) this AP reports from (comma-joined)
|
|
ssids: list[str] = [] # every SSID seen for this AP MAC
|
|
alias: str | None = None # radadmin_devices.alias (human label)
|
|
|
|
|
|
class DeviceAliasUpdate(BaseModel):
|
|
alias: str | None = Field(default=None, max_length=64)
|
|
|
|
|
|
# ---------- radusergroup ----------
|
|
class UserGroupBase(BaseModel):
|
|
username: str = Field(max_length=64)
|
|
groupname: str = Field(max_length=64)
|
|
priority: int = 1
|
|
|
|
|
|
class UserGroupCreate(UserGroupBase):
|
|
pass
|
|
|
|
|
|
class UserGroupUpdate(BaseModel):
|
|
username: str | None = Field(default=None, max_length=64)
|
|
groupname: str | None = Field(default=None, max_length=64)
|
|
priority: int | None = None
|
|
|
|
|
|
class UserGroupOut(ORMModel, UserGroupBase):
|
|
id: int
|
|
|
|
|
|
# ---------- read-only: radacct ----------
|
|
class RadAcctOut(ORMModel):
|
|
radacctid: int
|
|
acctsessionid: str
|
|
acctuniqueid: str
|
|
username: str
|
|
realm: str | None = None
|
|
nasipaddress: str
|
|
nasportid: str | None = None
|
|
nasporttype: str | None = None
|
|
acctstarttime: datetime | None = None
|
|
acctupdatetime: datetime | None = None
|
|
acctstoptime: datetime | None = None
|
|
acctinterval: int | None = None
|
|
acctsessiontime: int | None = None
|
|
acctauthentic: str | None = None
|
|
connectinfo_start: str | None = None
|
|
connectinfo_stop: str | None = None
|
|
acctinputoctets: int | None = None
|
|
acctoutputoctets: int | None = None
|
|
calledstationid: str
|
|
callingstationid: str
|
|
acctterminatecause: str
|
|
servicetype: str | None = None
|
|
framedprotocol: str | None = None
|
|
framedipaddress: str
|
|
class_: str | None = Field(default=None, alias="class")
|
|
|
|
|
|
# ---------- read-only: radpostauth ----------
|
|
class RadPostAuthOut(ORMModel):
|
|
id: int
|
|
username: str
|
|
reply: str
|
|
authdate: datetime | None = None
|
|
class_: str | None = Field(default=None, alias="class")
|
|
|
|
|
|
# ---------- read-only: nasreload ----------
|
|
class NasReloadOut(ORMModel):
|
|
nasipaddress: str
|
|
reloadtime: datetime
|