feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
18
backend/app/schemas/__init__.py
Normal file
18
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Pydantic schemas for request/response validation."""
|
||||
|
||||
from app.schemas.auth import LoginRequest, TokenResponse, RefreshRequest, UserMeResponse
|
||||
from app.schemas.tenant import TenantCreate, TenantResponse, TenantUpdate
|
||||
from app.schemas.user import UserCreate, UserResponse, UserUpdate
|
||||
|
||||
__all__ = [
|
||||
"LoginRequest",
|
||||
"TokenResponse",
|
||||
"RefreshRequest",
|
||||
"UserMeResponse",
|
||||
"TenantCreate",
|
||||
"TenantResponse",
|
||||
"TenantUpdate",
|
||||
"UserCreate",
|
||||
"UserResponse",
|
||||
"UserUpdate",
|
||||
]
|
||||
123
backend/app/schemas/auth.py
Normal file
123
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Authentication request/response schemas."""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
auth_upgrade_required: bool = False # True when bcrypt user needs SRP registration
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UserMeResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
auth_version: int = 1
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
# SRP users must provide re-derived credentials
|
||||
new_srp_salt: Optional[str] = None
|
||||
new_srp_verifier: Optional[str] = None
|
||||
# Re-wrapped key bundle (SRP users re-encrypt with new AUK)
|
||||
encrypted_private_key: Optional[str] = None
|
||||
private_key_nonce: Optional[str] = None
|
||||
encrypted_vault_key: Optional[str] = None
|
||||
vault_key_nonce: Optional[str] = None
|
||||
public_key: Optional[str] = None
|
||||
pbkdf2_salt: Optional[str] = None
|
||||
hkdf_salt: Optional[str] = None
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
# --- SRP Zero-Knowledge Authentication Schemas ---
|
||||
|
||||
|
||||
class SRPInitRequest(BaseModel):
|
||||
"""Step 1 request: client sends email to begin SRP handshake."""
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class SRPInitResponse(BaseModel):
|
||||
"""Step 1 response: server returns ephemeral B and key derivation salts."""
|
||||
salt: str # hex-encoded SRP salt
|
||||
server_public: str # hex-encoded server ephemeral B
|
||||
session_id: str # Redis session key nonce
|
||||
pbkdf2_salt: str # base64-encoded, from user_key_sets (needed for 2SKD before SRP verify)
|
||||
hkdf_salt: str # base64-encoded, from user_key_sets (needed for 2SKD before SRP verify)
|
||||
|
||||
|
||||
class SRPVerifyRequest(BaseModel):
|
||||
"""Step 2 request: client sends proof M1 to complete handshake."""
|
||||
email: EmailStr
|
||||
session_id: str
|
||||
client_public: str # hex-encoded client ephemeral A
|
||||
client_proof: str # hex-encoded client proof M1
|
||||
|
||||
|
||||
class SRPVerifyResponse(BaseModel):
|
||||
"""Step 2 response: server returns tokens and proof M2."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
server_proof: str # hex-encoded server proof M2
|
||||
encrypted_key_set: Optional[dict] = None # Key bundle for client-side decryption
|
||||
|
||||
|
||||
class SRPRegisterRequest(BaseModel):
|
||||
"""Used during registration to store SRP verifier and key set."""
|
||||
srp_salt: str # hex-encoded
|
||||
srp_verifier: str # hex-encoded
|
||||
encrypted_private_key: str # base64-encoded
|
||||
private_key_nonce: str # base64-encoded
|
||||
encrypted_vault_key: str # base64-encoded
|
||||
vault_key_nonce: str # base64-encoded
|
||||
public_key: str # base64-encoded
|
||||
pbkdf2_salt: str # base64-encoded
|
||||
hkdf_salt: str # base64-encoded
|
||||
|
||||
|
||||
# --- Account Self-Service Schemas ---
|
||||
|
||||
|
||||
class DeleteAccountRequest(BaseModel):
|
||||
"""Request body for account self-deletion. User must type 'DELETE' to confirm."""
|
||||
confirmation: str # Must be "DELETE" to confirm
|
||||
|
||||
|
||||
class DeleteAccountResponse(BaseModel):
|
||||
"""Response after successful account deletion."""
|
||||
message: str
|
||||
deleted: bool
|
||||
78
backend/app/schemas/certificate.py
Normal file
78
backend/app/schemas/certificate.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Pydantic request/response schemas for the Internal Certificate Authority."""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CACreateRequest(BaseModel):
|
||||
"""Request to generate a new root CA for the tenant."""
|
||||
|
||||
common_name: str = "Portal Root CA"
|
||||
validity_years: int = 10 # Default 10 years for CA
|
||||
|
||||
|
||||
class CertSignRequest(BaseModel):
|
||||
"""Request to sign a per-device certificate using the tenant CA."""
|
||||
|
||||
device_id: UUID
|
||||
validity_days: int = 730 # Default 2 years for device certs
|
||||
|
||||
|
||||
class BulkCertDeployRequest(BaseModel):
|
||||
"""Request to deploy certificates to multiple devices."""
|
||||
|
||||
device_ids: list[UUID]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CAResponse(BaseModel):
|
||||
"""Public details of a tenant's Certificate Authority (no private key)."""
|
||||
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
common_name: str
|
||||
fingerprint_sha256: str
|
||||
serial_number: str
|
||||
not_valid_before: datetime
|
||||
not_valid_after: datetime
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DeviceCertResponse(BaseModel):
|
||||
"""Public details of a device certificate (no private key)."""
|
||||
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
device_id: UUID
|
||||
ca_id: UUID
|
||||
common_name: str
|
||||
fingerprint_sha256: str
|
||||
serial_number: str
|
||||
not_valid_before: datetime
|
||||
not_valid_after: datetime
|
||||
status: str
|
||||
deployed_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CertDeployResponse(BaseModel):
|
||||
"""Result of a single device certificate deployment attempt."""
|
||||
|
||||
success: bool
|
||||
device_id: UUID
|
||||
cert_name_on_device: str | None = None
|
||||
error: str | None = None
|
||||
271
backend/app/schemas/device.py
Normal file
271
backend/app/schemas/device.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""Pydantic schemas for Device, DeviceGroup, and DeviceTag endpoints."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceCreate(BaseModel):
|
||||
"""Schema for creating a new device."""
|
||||
|
||||
hostname: str
|
||||
ip_address: str
|
||||
api_port: int = 8728
|
||||
api_ssl_port: int = 8729
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
"""Schema for updating an existing device. All fields optional."""
|
||||
|
||||
hostname: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
api_port: Optional[int] = None
|
||||
api_ssl_port: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
tls_mode: Optional[str] = None
|
||||
|
||||
@field_validator("tls_mode")
|
||||
@classmethod
|
||||
def validate_tls_mode(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Validate tls_mode is one of the allowed values."""
|
||||
if v is None:
|
||||
return v
|
||||
allowed = {"auto", "insecure", "plain", "portal_ca"}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"tls_mode must be one of: {', '.join(sorted(allowed))}")
|
||||
return v
|
||||
|
||||
|
||||
class DeviceTagRef(BaseModel):
|
||||
"""Minimal tag info embedded in device responses."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DeviceGroupRef(BaseModel):
|
||||
"""Minimal group info embedded in device responses."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DeviceResponse(BaseModel):
|
||||
"""Device response schema. NEVER includes credential fields."""
|
||||
|
||||
id: uuid.UUID
|
||||
hostname: str
|
||||
ip_address: str
|
||||
api_port: int
|
||||
api_ssl_port: int
|
||||
model: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
firmware_version: Optional[str] = None
|
||||
routeros_version: Optional[str] = None
|
||||
routeros_major_version: Optional[int] = None
|
||||
uptime_seconds: Optional[int] = None
|
||||
last_seen: Optional[datetime] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
status: str
|
||||
tls_mode: str = "auto"
|
||||
tags: list[DeviceTagRef] = []
|
||||
groups: list[DeviceGroupRef] = []
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DeviceListResponse(BaseModel):
|
||||
"""Paginated device list response."""
|
||||
|
||||
items: list[DeviceResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subnet scan schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SubnetScanRequest(BaseModel):
|
||||
"""Request body for a subnet scan."""
|
||||
|
||||
cidr: str
|
||||
|
||||
@field_validator("cidr")
|
||||
@classmethod
|
||||
def validate_cidr(cls, v: str) -> str:
|
||||
"""Validate that the value is a valid CIDR notation and RFC 1918 private range."""
|
||||
import ipaddress
|
||||
try:
|
||||
network = ipaddress.ip_network(v, strict=False)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid CIDR notation: {e}") from e
|
||||
# Only allow private IP ranges (RFC 1918: 10/8, 172.16/12, 192.168/16)
|
||||
if not network.is_private:
|
||||
raise ValueError(
|
||||
"Only private IP ranges can be scanned (RFC 1918: "
|
||||
"10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)"
|
||||
)
|
||||
# Reject ranges larger than /20 (4096 IPs) to prevent abuse
|
||||
if network.num_addresses > 4096:
|
||||
raise ValueError(
|
||||
f"CIDR range too large ({network.num_addresses} addresses). "
|
||||
"Maximum allowed: /20 (4096 addresses)."
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class SubnetScanResult(BaseModel):
|
||||
"""A single discovered host from a subnet scan."""
|
||||
|
||||
ip_address: str
|
||||
hostname: Optional[str] = None
|
||||
api_port_open: bool = False
|
||||
api_ssl_port_open: bool = False
|
||||
|
||||
|
||||
class SubnetScanResponse(BaseModel):
|
||||
"""Response for a subnet scan operation."""
|
||||
|
||||
cidr: str
|
||||
discovered: list[SubnetScanResult]
|
||||
total_scanned: int
|
||||
total_discovered: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bulk add from scan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BulkDeviceAdd(BaseModel):
|
||||
"""One device entry within a bulk-add request."""
|
||||
|
||||
ip_address: str
|
||||
hostname: Optional[str] = None
|
||||
api_port: int = 8728
|
||||
api_ssl_port: int = 8729
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class BulkAddRequest(BaseModel):
|
||||
"""
|
||||
Bulk-add devices selected from a scan result.
|
||||
|
||||
shared_username / shared_password are used for all devices that do not
|
||||
provide their own credentials.
|
||||
"""
|
||||
|
||||
devices: list[BulkDeviceAdd]
|
||||
shared_username: Optional[str] = None
|
||||
shared_password: Optional[str] = None
|
||||
|
||||
|
||||
class BulkAddResult(BaseModel):
|
||||
"""Summary result of a bulk-add operation."""
|
||||
|
||||
added: list[DeviceResponse]
|
||||
failed: list[dict] # {ip_address, error}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeviceGroup schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceGroupCreate(BaseModel):
|
||||
"""Schema for creating a device group."""
|
||||
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class DeviceGroupUpdate(BaseModel):
|
||||
"""Schema for updating a device group."""
|
||||
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class DeviceGroupResponse(BaseModel):
|
||||
"""Device group response schema."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
device_count: int = 0
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeviceTag schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceTagCreate(BaseModel):
|
||||
"""Schema for creating a device tag."""
|
||||
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
|
||||
@field_validator("color")
|
||||
@classmethod
|
||||
def validate_color(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Validate hex color format if provided."""
|
||||
if v is None:
|
||||
return v
|
||||
import re
|
||||
if not re.match(r"^#[0-9A-Fa-f]{6}$", v):
|
||||
raise ValueError("Color must be a valid 6-digit hex color (e.g. #FF5733)")
|
||||
return v
|
||||
|
||||
|
||||
class DeviceTagUpdate(BaseModel):
|
||||
"""Schema for updating a device tag."""
|
||||
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
@field_validator("color")
|
||||
@classmethod
|
||||
def validate_color(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return v
|
||||
import re
|
||||
if not re.match(r"^#[0-9A-Fa-f]{6}$", v):
|
||||
raise ValueError("Color must be a valid 6-digit hex color (e.g. #FF5733)")
|
||||
return v
|
||||
|
||||
|
||||
class DeviceTagResponse(BaseModel):
|
||||
"""Device tag response schema."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
31
backend/app/schemas/tenant.py
Normal file
31
backend/app/schemas/tenant.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Tenant request/response schemas."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TenantCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
|
||||
|
||||
class TenantResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
user_count: int = 0
|
||||
device_count: int = 0
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
53
backend/app/schemas/user.py
Normal file
53
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""User request/response schemas."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
role: UserRole = UserRole.VIEWER
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
return v
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def validate_role(cls, v: UserRole) -> UserRole:
|
||||
"""Tenant admins can only create operator/viewer roles; super_admin via separate flow."""
|
||||
allowed_tenant_roles = {UserRole.TENANT_ADMIN, UserRole.OPERATOR, UserRole.VIEWER}
|
||||
if v not in allowed_tenant_roles:
|
||||
raise ValueError(
|
||||
f"Role must be one of: {', '.join(r.value for r in allowed_tenant_roles)}"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
email: str
|
||||
role: str
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
is_active: bool
|
||||
last_login: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
is_active: Optional[bool] = None
|
||||
91
backend/app/schemas/vpn.py
Normal file
91
backend/app/schemas/vpn.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Pydantic schemas for WireGuard VPN management."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ── VPN Config (server-side) ──
|
||||
|
||||
|
||||
class VpnSetupRequest(BaseModel):
|
||||
"""Request to enable VPN for a tenant."""
|
||||
endpoint: Optional[str] = None # public hostname:port — if blank, devices must be configured manually
|
||||
|
||||
|
||||
class VpnConfigResponse(BaseModel):
|
||||
"""VPN server configuration (never exposes private key)."""
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
server_public_key: str
|
||||
subnet: str
|
||||
server_port: int
|
||||
server_address: str
|
||||
endpoint: Optional[str]
|
||||
is_enabled: bool
|
||||
peer_count: int = 0
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class VpnConfigUpdate(BaseModel):
|
||||
"""Update VPN configuration."""
|
||||
endpoint: Optional[str] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
# ── VPN Peers ──
|
||||
|
||||
|
||||
class VpnPeerCreate(BaseModel):
|
||||
"""Add a device as a VPN peer."""
|
||||
device_id: uuid.UUID
|
||||
additional_allowed_ips: Optional[str] = None # comma-separated subnets for site-to-site routing
|
||||
|
||||
|
||||
class VpnPeerResponse(BaseModel):
|
||||
"""VPN peer info (never exposes private key)."""
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
device_id: uuid.UUID
|
||||
device_hostname: str = ""
|
||||
device_ip: str = ""
|
||||
peer_public_key: str
|
||||
assigned_ip: str
|
||||
is_enabled: bool
|
||||
last_handshake: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── VPN Onboarding (combined device + peer creation) ──
|
||||
|
||||
|
||||
class VpnOnboardRequest(BaseModel):
|
||||
"""Combined device creation + VPN peer onboarding."""
|
||||
hostname: str
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class VpnOnboardResponse(BaseModel):
|
||||
"""Response from onboarding — device, peer, and RouterOS commands."""
|
||||
device_id: uuid.UUID
|
||||
peer_id: uuid.UUID
|
||||
hostname: str
|
||||
assigned_ip: str
|
||||
routeros_commands: list[str]
|
||||
|
||||
|
||||
class VpnPeerConfig(BaseModel):
|
||||
"""Full peer config for display/export — includes private key for device setup."""
|
||||
peer_private_key: str
|
||||
peer_public_key: str
|
||||
assigned_ip: str
|
||||
server_public_key: str
|
||||
server_endpoint: str
|
||||
allowed_ips: str
|
||||
routeros_commands: list[str]
|
||||
Reference in New Issue
Block a user