Files
shihaam 66073c7891
build-and-push / build (push) Failing after 35s
added multi user support, move api key to database
2026-08-01 13:16:54 +05:00

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