Initial commit — Stupid Simple Network Inventory

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>
This commit is contained in:
2026-05-17 09:19:19 +02:00
commit 88cf6458d0
58 changed files with 10365 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
# 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`).
```python
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`.
```json
[{ "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.
```json
[{
"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.
```json
// 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.
```json
// 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`
```python
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
```python
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
```python
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
```python
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
```python
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 with `secrets.token_hex(32)`, permissions 0600) or `SECRET_KEY` env 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_user` rejects any token whose `ver` differs from the DB value → immediate invalidation of all old sessions on password change.
- **Password hashing**: `passlib.CryptContext` with `bcrypt`. `requirements.txt` pins `bcrypt==3.2.2` because `passlib 1.7.4` is incompatible with `bcrypt >= 4.0`.
- **`get_current_user`**: validates JWT + token version. Returns the `User` ORM object, or raises 401.
- **`require_password_changed`**: wraps `get_current_user`, raises 403 if `must_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:
```python
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:
1. Create `table_new` with the desired schema
2. `INSERT INTO table_new SELECT ... FROM table`
3. `DROP TABLE table`
4. `ALTER TABLE table_new RENAME TO table`
5. 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):
1. `_migrate_vlan_nullable()`
2. `_migrate_device_virt_type()`
3. `_migrate_device_url()`
4. `_migrate_users_must_change_password()`
5. `_migrate_users_token_version()`
6. `_migrate_force_admin_password_change()`
7. `_migrate_drop_links_table()` — drops the `links` table if it exists (feature removed in phase 3)
8. `_migrate_users()`
9. `Base.metadata.create_all(bind=engine)`