Application web d'inventaire réseau manuel avec FastAPI, Vue 3 et Docker. Inclut l'authentification JWT, la découverte ICMP, et la topologie en cards CSS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
10 KiB
Backend Reference
Entry Point — main.py
FastAPI application. Four router groups protected by require_password_changed (which implies get_current_user + rejects users with must_change_password=True).
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(vlans.router, prefix="/api/vlans", dependencies=[Depends(require_password_changed)])
app.include_router(devices.router, prefix="/api/devices", dependencies=[Depends(require_password_changed)])
app.include_router(discovery.router, prefix="/api/discovery", dependencies=[Depends(require_password_changed)])
GET /api/health returns {"status": "ok"} — public, intentionally minimal (no sensitive detail).
CORS: controlled by the ALLOWED_ORIGINS environment variable (default "*"). Set to your domain in production or leave empty to disable CORS headers (same-origin via proxy).
API Endpoints
Auth — /api/auth
POST /api/auth/login
- Auth: None
- Content-Type:
application/x-www-form-urlencoded - Body:
username=<str>&password=<str> - Response:
{ access_token: str, token_type: "bearer", username: str, must_change_password: bool } - Errors: 401 if credentials invalid, 429 if rate-limited (IP or username)
PUT /api/auth/account
- Auth: Bearer token (works even when
must_change_password=True) - Body (JSON):
{ current_password: str, new_username?: str, new_password?: str } - Response:
{ access_token: str, token_type: "bearer", username: str, must_change_password: bool }(new token) - Errors: 400
"Current password is incorrect", 400"Username already taken", 400"password_too_short", 400"password_too_weak", 400"username_invalid" - Note: changing the password bumps
token_version, invalidating all previously issued tokens.
GET /api/auth/me
- Auth: Bearer token
- Response:
{ username: str, must_change_password: bool }
VLANs — /api/vlans
All endpoints require auth.
GET /api/vlans/
Returns all networks sorted: LANs first (NULL vlan_id), then VLANs ascending by vlan_id.
[{ "id": 1, "vlan_id": null, "name": "LAN", "cidr": "192.168.1.0/24", "color": "#4A90D9" }]
POST /api/vlans/
Creates a network. vlan_id is optional — omit for a plain LAN.
PUT /api/vlans/{id}
Full replacement of a network record.
DELETE /api/vlans/{id}
Deletes the network. SQLAlchemy cascades: associated DeviceInterface rows will lose their vlan_id FK (set to NULL, since the column is nullable).
Devices — /api/devices
All endpoints require auth.
GET /api/devices/
Returns all devices with their interfaces, ordered by name.
[{
"id": 1,
"name": "proxmox-01",
"type": "server",
"description": "Proxmox hypervisor",
"is_gateway": false,
"is_livebox": false,
"virt_type": "baremetal",
"url": "https://proxmox.local:8006",
"interfaces": [
{ "id": 1, "device_id": 1, "vlan_id": 2, "ip_address": "192.168.10.5", "name": "eth0", "is_upstream": false }
]
}]
POST /api/devices/
Creates device with its interfaces in a single transaction (flush + batch insert + commit).
PUT /api/devices/{id}
Replaces the device and its interfaces: existing interfaces are deleted, new ones inserted.
DELETE /api/devices/{id}
Deletes device and its interfaces (cascade via SQLAlchemy delete-orphan).
Important: when adding a new field to Device, update all four of: models.py, _migrate_* in main.py, DeviceCreate/DeviceOut in routers/devices.py, and the explicit assignments in create_device / update_device. Do not rely on **model_dump() to populate the ORM object — it will silently drop new fields.
Discovery — /api/discovery
All endpoints require auth.
POST /api/discovery/ping
Parallel ICMP ping of a list of IPs, 50 concurrent workers.
// Request
{ "ips": ["192.168.1.1", "192.168.1.2"] }
// Response
[{ "ip": "192.168.1.1", "alive": true }, { "ip": "192.168.1.2", "alive": false }]
Uses subprocess.run(["ping", "-c", "1", "-W", "1", ip]). Requires cap_add: NET_RAW on the container (already configured in docker-compose.yml).
POST /api/discovery/scan
Ping sweep + DNS PTR reverse lookup for one or more CIDR ranges.
// Request
{
"dns_server": "192.168.1.1",
"targets": [
{ "vlan_id": 1, "cidr": "192.168.1.0/24" }
]
}
// Response
{
"hosts": [{ "ip": "192.168.1.5", "hostname": "nas.local", "vlan_id": 1, "cidr": "192.168.1.0/24" }],
"total_scanned": 254,
"duration_s": 3.2
}
Maximum 1024 hosts per target (rejects /21 and wider). 100 concurrent workers. DNS queries use dnspython with a 1s timeout.
Database — database.py
engine = create_engine("sqlite:///./data/topology.db", connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db(): # FastAPI dependency
db = SessionLocal()
try:
yield db
finally:
db.close()
check_same_thread=False is required for SQLite under a multi-threaded ASGI server. The data/ directory is created at module import time.
Models — models.py
User
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String, unique=True, nullable=False)
hashed_password = Column(String, nullable=False)
must_change_password = Column(Boolean, nullable=False, default=False, server_default="0")
token_version = Column(Integer, nullable=False, default=1, server_default="1")
must_change_password: set to True when admin is bootstrapped with the default password. Clears to False on successful PUT /api/auth/account with a new password.
token_version: incremented on every password change. Included in JWT payload as ver. A token is rejected if its ver differs from the current token_version.
Vlan
class Vlan(Base):
__tablename__ = "vlans"
id = Column(Integer, primary_key=True)
vlan_id = Column(Integer, unique=True, nullable=True) # NULL = plain LAN
name = Column(String, nullable=False)
cidr = Column(String, nullable=True, default="")
color = Column(String, default="#4A90D9")
interfaces = relationship("DeviceInterface", back_populates="vlan")
Device
class Device(Base):
__tablename__ = "devices"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
type = Column(String, default="other")
description = Column(String, default="")
is_gateway = Column(Boolean, default=False)
is_livebox = Column(Boolean, default=False)
virt_type = Column(String, nullable=True) # null | baremetal | lxc | qemu
url = Column(String, nullable=True)
interfaces = relationship("DeviceInterface", back_populates="device", cascade="all, delete-orphan")
DeviceInterface
class DeviceInterface(Base):
__tablename__ = "device_interfaces"
id = Column(Integer, primary_key=True)
device_id = Column(Integer, ForeignKey("devices.id"), nullable=False)
vlan_id = Column(Integer, ForeignKey("vlans.id"), nullable=True)
ip_address = Column(String, nullable=True, default="")
name = Column(String, default="eth0")
is_upstream = Column(Boolean, default=False)
Auth — routers/auth.py
Key details for maintainers:
- Secret key: loaded from
data/secret_key.txt(auto-generated withsecrets.token_hex(32), permissions 0600) orSECRET_KEYenv var. Same Docker volume as the database — survives restarts. See README for rotation procedure. - Token: HS256 JWT, payload
{ sub: username, ver: token_version, exp: utcnow + 24h }. - Token invalidation: changing the password increments
User.token_version.get_current_userrejects any token whoseverdiffers from the DB value → immediate invalidation of all old sessions on password change. - Password hashing:
passlib.CryptContextwithbcrypt.requirements.txtpinsbcrypt==3.2.2becausepasslib 1.7.4is incompatible withbcrypt >= 4.0. get_current_user: validates JWT + token version. Returns theUserORM object, or raises 401.require_password_changed: wrapsget_current_user, raises 403 ifmust_change_password=True. Used as the dependency for all business routers.- Rate limiting: 20 attempts/IP/60s and 10 attempts/username/15min (in-memory). Complemented by Nginx
limit_req(10 req/min per IP, burst 5) on the login endpoint.
Migrations — main.py
All migration functions follow this idempotent pattern:
def _migrate_example():
with engine.connect() as conn:
# 1. Bail out if table doesn't exist yet
if not conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='table_name'")).fetchone():
return
# 2. Check if work is needed
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(table_name)")).fetchall()]
if 'new_column' not in cols:
conn.execute(text("ALTER TABLE table_name ADD COLUMN new_column VARCHAR"))
conn.commit()
For table recreation (when SQLite cannot ALTER COLUMN), the pattern is:
- Create
table_newwith the desired schema INSERT INTO table_new SELECT ... FROM tableDROP TABLE tableALTER TABLE table_new RENAME TO table- Wrap in
PRAGMA foreign_keys=OFF/ON
Always call conn.commit() explicitly — SQLAlchemy 2.0 does not auto-commit DDL on SQLite.
The _migrate_users() function has a two-phase check: table existence, then COUNT(*) = 0 for seeding. This handles the edge case where create_all created the table empty before the migration ran.
_migrate_force_admin_password_change() is the "catch-up" migration for existing installations: if the admin account uses the default bootstrap password ("admin") and must_change_password=0, it sets must_change_password=1. This runs at every startup and is idempotent.
Startup call order (as of phase 3):
_migrate_vlan_nullable()_migrate_device_virt_type()_migrate_device_url()_migrate_users_must_change_password()_migrate_users_token_version()_migrate_force_admin_password_change()_migrate_drop_links_table()— drops thelinkstable if it exists (feature removed in phase 3)_migrate_users()Base.metadata.create_all(bind=engine)