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:
@@ -0,0 +1,47 @@
|
|||||||
|
# Stupid Simple Network Inventory — environment variables
|
||||||
|
# Copy this file to .env and fill in the values.
|
||||||
|
# NEVER commit .env to version control.
|
||||||
|
|
||||||
|
# ── JWT Secret ───────────────────────────────────────────────────────���──────
|
||||||
|
# Required in production. If unset, a random key is auto-generated and stored
|
||||||
|
# in db_data/secret_key.txt (0600 permissions). All sessions are invalidated
|
||||||
|
# when this key changes (key rotation).
|
||||||
|
#
|
||||||
|
# Generate a strong secret:
|
||||||
|
# python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
# Or use a Docker secret (recommended for production).
|
||||||
|
SECRET_KEY=
|
||||||
|
|
||||||
|
# ── Initial admin password ──────────────────────────────────────────────────
|
||||||
|
# Set this before the first run to bypass the admin/admin bootstrap.
|
||||||
|
# When set: admin is created with this password and must_change_password=0.
|
||||||
|
# When unset: admin is created with password "admin" and must_change_password=1
|
||||||
|
# (forced password change on first login).
|
||||||
|
#
|
||||||
|
# This variable is only read when the users table is empty (first run).
|
||||||
|
# It has no effect on subsequent starts.
|
||||||
|
INITIAL_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# ── CORS allowed origins ─────────────────────────────────────────────────────
|
||||||
|
# Comma-separated list of allowed origins, or "*" for all (default).
|
||||||
|
# The app is designed for same-origin access via the Nginx reverse proxy.
|
||||||
|
# Restrict this if you expose the API to multiple origins.
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ALLOWED_ORIGINS=* (default — permissive)
|
||||||
|
# ALLOWED_ORIGINS=https://inventory.example.com
|
||||||
|
# ALLOWED_ORIGINS=https://a.example.com,https://b.example.com
|
||||||
|
# ALLOWED_ORIGINS= (empty — disables CORS headers)
|
||||||
|
ALLOWED_ORIGINS=*
|
||||||
|
|
||||||
|
# ── Container user IDs ───────────────────────────────────────────────────────
|
||||||
|
# UID and GID used to run the backend process inside the container.
|
||||||
|
# Must match the host user owning ./db_data/ to allow read/write on the
|
||||||
|
# bind-mounted volume without root privileges.
|
||||||
|
#
|
||||||
|
# Get your values: id -u && id -g
|
||||||
|
# Then create the data directory before the first run:
|
||||||
|
# mkdir -p db_data
|
||||||
|
#
|
||||||
|
DOCKER_UID=1000
|
||||||
|
DOCKER_GID=1000
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
# ── Secrets & données runtime ─────────────────────────────────────────────────
|
||||||
|
# Ne JAMAIS committer : clés, mots de passe, base de données, tokens
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
data/
|
||||||
|
db_data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
secret_key.txt
|
||||||
|
|
||||||
|
# ── Outils IA / sessions de développement ─────────────────────────────────────
|
||||||
|
# Contiennent des IDs de session, permissions locales — pas de valeur pour le dépôt
|
||||||
|
.claude/
|
||||||
|
.agents/
|
||||||
|
.codex/
|
||||||
|
claude-session.txt
|
||||||
|
.aider*
|
||||||
|
|
||||||
|
# ── Python ────────────────────────────────────────────────────────────────────
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
|
||||||
|
# ── Node / Frontend ───────────────────────────────────────────────────────────
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# ── Logs ─────────────────────────────────────────────────────────────────────
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# ── Certificats & clés PKI ───────────────────────────────────────────────────
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
*.cer
|
||||||
|
|
||||||
|
# ── IDE & éditeurs ────────────────────────────────────────────────────────────
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
|
||||||
|
# ── OS ────────────────────────────────────────────────────────────────────────
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# Stupid Simple Network Inventory — Agent Notes
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Web application for manual server/network inventory and logical network topology display.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Backend: FastAPI, Python 3.11, SQLAlchemy, SQLite
|
||||||
|
- Frontend: Vue 3 + Vite
|
||||||
|
- Topology view: CSS cards, no Cytoscape
|
||||||
|
- Icons: `lucide-vue-next` for generic UI, `simple-icons` for brand logos
|
||||||
|
- Reverse proxy: Nginx serves the frontend and proxies `/api/` to the backend
|
||||||
|
- Runtime: Docker Compose
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Application URL: http://localhost:8080
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `backend/main.py`: FastAPI entry point + startup SQLite migrations (run before `create_all`)
|
||||||
|
- `backend/database.py`: SQLAlchemy engine and sessions
|
||||||
|
- `backend/models.py`: ORM models (User, Vlan, Device, DeviceInterface)
|
||||||
|
- `backend/routers/auth.py`: JWT login, account update, token refresh — exports `get_current_user` for main.py
|
||||||
|
- `backend/routers/vlans.py`: network CRUD
|
||||||
|
- `backend/routers/devices.py`: device CRUD — assign fields explicitly, do not use `model_dump()` on Device
|
||||||
|
- `backend/routers/discovery.py`: ICMP ping sweep + DNS PTR (`/scan`) + parallel ping (`/ping`)
|
||||||
|
- `frontend/src/App.vue`: global layout and state + auth guard (shows LoginPage if not authenticated)
|
||||||
|
- `frontend/src/api.js`: axios API calls (`vlansApi`, `devicesApi`, `discoveryApi`, `authApi`)
|
||||||
|
- `frontend/src/auth.js`: reactive auth state (`isAuthenticated`, `currentUsername`, `setAuth`, `clearAuth`, `getToken`) — persisted in localStorage
|
||||||
|
- `frontend/src/i18n.js`: i18n — `locale` (ref), `t(key)`, `tFmt(key, ...args)`, `setLocale(lang)`
|
||||||
|
- `frontend/src/theme.js`: theme — `theme` (ref), `toggleTheme()`, applies/removes `html.dark` class
|
||||||
|
- `frontend/src/brandIcons.js`: BRANDS array + `detectBrands(name, description)` — shared utility
|
||||||
|
- `frontend/src/components/TopologyGraph.vue`: topology card layout, ping feature
|
||||||
|
- `frontend/src/components/DeviceIcon.vue`: Lucide type icon (prop `typeOnly`) or brand logos
|
||||||
|
- `frontend/src/components/DeviceManager.vue`: device CRUD + filter bar + search
|
||||||
|
- `frontend/src/components/VlanManager.vue`: network CRUD (LANs + VLANs)
|
||||||
|
- `frontend/src/components/DiscoveryModal.vue`: automatic discovery UI
|
||||||
|
- `frontend/src/components/LoginPage.vue`: full-screen login form, emits `@login` with `{ token, username }`
|
||||||
|
- `frontend/src/components/AccountModal.vue`: change username/password, emits `@updated` with new token
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
- `User`: `id`, `username` (unique), `hashed_password` (bcrypt), `must_change_password` (bool, default 0), `token_version` (int, default 1)
|
||||||
|
- `Vlan`: `id`, `vlan_id` (nullable int — null = plain LAN), `name`, `cidr`, `color`
|
||||||
|
- `Device`: `id`, `name`, `type`, `description`, `is_gateway`, `is_livebox`, `virt_type` (nullable), `url` (nullable)
|
||||||
|
- `DeviceInterface`: `id`, `device_id`, `vlan_id` (nullable FK), `ip_address`, `name`, `is_upstream`
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
JWT HS256, **24-hour expiry**. Payload: `{ sub: username, ver: token_version, exp: ... }`. Secret key persisted in `data/secret_key.txt` (0600 permissions) or `SECRET_KEY` env var.
|
||||||
|
|
||||||
|
Default credentials: `admin` / `admin` — seeded with `must_change_password=1` by `_migrate_users()` if the `users` table is empty. Set `INITIAL_ADMIN_PASSWORD` env var to skip this bootstrap.
|
||||||
|
|
||||||
|
**Token invalidation**: password change increments `User.token_version`. `get_current_user` rejects tokens whose `ver` doesn't match → old sessions expire immediately.
|
||||||
|
|
||||||
|
**must_change_password**: when `True`, business routers return 403 `"Password change required"`. The frontend shows a forced `<AccountModal :forced="true">` that blocks the app. `_migrate_force_admin_password_change()` sets this flag at startup if admin's password is still the default.
|
||||||
|
|
||||||
|
**bcrypt compatibility**: `passlib 1.7.4` is incompatible with `bcrypt >= 4.0`. `requirements.txt` pins `bcrypt==3.2.2`. Do not upgrade `bcrypt` without also upgrading `passlib`.
|
||||||
|
|
||||||
|
Business route groups protected via `dependencies=[Depends(require_password_changed)]` (wraps `get_current_user`). Auth router has no protection.
|
||||||
|
|
||||||
|
Frontend auth guard in `App.vue`:
|
||||||
|
- `!isAuthenticated` → `<LoginPage>`
|
||||||
|
- `mustChangePassword` → `<AccountModal :forced="true">`
|
||||||
|
- else → full app
|
||||||
|
|
||||||
|
Template root is `<div class="app-root">` with `display: contents` — prevents Firefox autofill overlay `NotFoundError` on fragment comment nodes.
|
||||||
|
|
||||||
|
## Device Types (18)
|
||||||
|
|
||||||
|
`server`, `switch`, `router`, `nas`, `gateway`, `livebox`, `access_point`, `camera`, `temperature`, `sensor`, `hub`, `smart_plug`, `alarm`, `light`, `doorbell`, `desktop`, `laptop`, `other`
|
||||||
|
|
||||||
|
## Device Fields
|
||||||
|
|
||||||
|
### virt_type
|
||||||
|
Form label: "Type d'environnement d'exécution". Values: `null`, `baremetal`, `lxc`, `qemu`.
|
||||||
|
|
||||||
|
### url
|
||||||
|
Optional web UI URL. Hidden in the form for `desktop` and `laptop` types. Displayed as a clickable link in the device card, and as a green "Link" tag on topology chips.
|
||||||
|
|
||||||
|
When adding a new Device field: update `models.py`, add a startup migration in `main.py`, update `DeviceCreate` and `DeviceOut` in `routers/devices.py`, assign explicitly in `create_device` and `update_device`.
|
||||||
|
|
||||||
|
## Networks (VlanManager)
|
||||||
|
|
||||||
|
`vlan_id` is optional. Omitting it creates a plain LAN ("LAN" badge). Networks ordered: LANs first (NULL vlan_id), then VLANs by ascending ID.
|
||||||
|
|
||||||
|
## Topology View
|
||||||
|
|
||||||
|
`TopologyGraph.vue` uses CSS cards only.
|
||||||
|
|
||||||
|
- Toolbar: Ping button with animated dot indicator + up/down count
|
||||||
|
- WAN card: red, `is_livebox` devices
|
||||||
|
- Gateway card: yellow, `is_gateway` devices
|
||||||
|
- Network list: one full-width card per network, stacked vertically (flex-col); a device appears in every card matching its interfaces
|
||||||
|
- Unassigned card: devices with no interface, not livebox, not gateway
|
||||||
|
- Devices sorted by IP ascending; no-IP devices go last
|
||||||
|
|
||||||
|
### Card layout
|
||||||
|
|
||||||
|
All network cards are full width, stacked vertically. Inside each card, chips are arranged in a horizontal grid (`grid auto-fill, minmax(210px, 1fr)`), wrapping as needed. Card height grows with the number of chips. Single column below 600px.
|
||||||
|
|
||||||
|
### Device chip structure
|
||||||
|
|
||||||
|
```
|
||||||
|
[chip-icon (type, Lucide)] [chip-body] [chip-tags]
|
||||||
|
chip-name
|
||||||
|
chip-sub: IP + iface + brand SVGs (11px)
|
||||||
|
chip-tags: Link | GW | LXC | VM + ping dot
|
||||||
|
```
|
||||||
|
|
||||||
|
- `chip-icon`: always the Lucide type icon (`type-only` prop)
|
||||||
|
- Brand logos: small colored SVGs in `chip-sub`, no text label, `title` tooltip on hover
|
||||||
|
- `tag-link`: green, clickable `<a>`, only if `device.url` is set
|
||||||
|
- Ping dot: green (`ping-up`) or red (`ping-down`), shown after a ping run
|
||||||
|
|
||||||
|
## DeviceIcon.vue
|
||||||
|
|
||||||
|
Prop `typeOnly` (bool, default false):
|
||||||
|
- `false`: show brand logos if detected, else Lucide — used in TopologyGraph chips
|
||||||
|
- `true`: always show Lucide type icon — used in DeviceManager cards
|
||||||
|
|
||||||
|
## Brand Icons (brandIcons.js)
|
||||||
|
|
||||||
|
Central `detectBrands(name, description)` returns all matching brand icon objects from `simple-icons`.
|
||||||
|
|
||||||
|
To check if a brand exists: `node -e "const si = require('./node_modules/simple-icons'); console.log(Object.keys(si).filter(k => k.toLowerCase().includes('name')))"`
|
||||||
|
|
||||||
|
To add a brand: import `si<Name>` in `brandIcons.js`, add entry to `BRANDS` array.
|
||||||
|
|
||||||
|
Supported brands: Proxmox, Docker, Synology, TrueNAS, Ubiquiti/UniFi, MikroTik, Cisco, TP-Link, ASUS, Netgear, pfSense, OPNsense, OpenWrt, Apache/Apache2, Traefik, MariaDB, Kubernetes/k3s, Debian, Ubuntu, Ansible, Dell, HP, Raspberry Pi, Arduino, KDE/Plasma, Excalidraw, Nextcloud, Paperless-NGX, Uptime Kuma, MkDocs, Jellyfin, Home Assistant, Philips Hue, Xiaomi.
|
||||||
|
|
||||||
|
## DeviceManager Filter Bar
|
||||||
|
|
||||||
|
Compact 28px bar combining:
|
||||||
|
- Search input (name, description, IP — real-time, Escape to clear)
|
||||||
|
- Type dropdown (multi-select, only present types)
|
||||||
|
- Network dropdown (with color swatch)
|
||||||
|
- Brand dropdown (with SVG icon, sorted alphabetically)
|
||||||
|
- Virt dropdown (only if at least one device has a virt_type)
|
||||||
|
|
||||||
|
All filters combine (AND between categories, OR within). "Clear" button + "X / total" counter when active.
|
||||||
|
|
||||||
|
## Ping (discovery.py)
|
||||||
|
|
||||||
|
`POST /api/discovery/ping` — parallel ICMP ping, 50 workers.
|
||||||
|
- Body: `{ ips: ["1.2.3.4", ...] }`
|
||||||
|
- Returns: `[{ ip, alive }, ...]`
|
||||||
|
|
||||||
|
Device status: "up" if any of its IPs responds, "down" if all fail, no dot if no IPs.
|
||||||
|
|
||||||
|
## SQLite Migrations (main.py)
|
||||||
|
|
||||||
|
Idempotent startup migrations run before `create_all`:
|
||||||
|
- `_migrate_vlan_nullable()`: recreates `vlans` to allow NULL `vlan_id`
|
||||||
|
- `_migrate_device_virt_type()`: adds `virt_type VARCHAR` to `devices`
|
||||||
|
- `_migrate_device_url()`: adds `url VARCHAR` to `devices`
|
||||||
|
- `_migrate_users()`: creates the `users` table and seeds `admin`/`admin` if count is 0
|
||||||
|
|
||||||
|
Pattern for new nullable column: check if column exists via `PRAGMA table_info`, run `ALTER TABLE … ADD COLUMN` if missing.
|
||||||
|
|
||||||
|
## Discovery
|
||||||
|
|
||||||
|
`POST /api/discovery/scan`: ICMP ping sweep + DNS PTR lookup. Max 1024 hosts/network, 100 workers. Needs `cap_add: NET_RAW` (already configured).
|
||||||
|
|
||||||
|
## Theme (theme.js)
|
||||||
|
|
||||||
|
`theme.js` exports `theme` (ref, `'light'`/`'dark'`) and `toggleTheme()`. Persisted in `localStorage('theme')`. Applies/removes `html.dark` class on `document.documentElement`.
|
||||||
|
|
||||||
|
CSS variables are defined in `App.vue` global `<style>`:
|
||||||
|
- `:root` — light theme
|
||||||
|
- `html.dark` — dark theme
|
||||||
|
|
||||||
|
Available variables: `--bg-page`, `--bg-card`, `--bg-card-hover`, `--bg-input`, `--bg-thead`, `--bg-chip`, `--bg-chip-hover`, `--border`, `--border-strong`, `--text-primary`, `--text-secondary`, `--text-muted`, `--text-faint`, `--chip-ip-bg`, `--chip-ip-color`, `--modal-overlay`, `--shadow-card`, `--shadow-modal`, `--shadow-drop`.
|
||||||
|
|
||||||
|
**Rule**: all background/text/border colors in components must use these variables, not hardcoded hex. Exception: semantic fixed colors (type-specific chip icon colors, VLAN badge colors) stay as hex.
|
||||||
|
|
||||||
|
Dark-mode overrides for scoped components must use `:global(html.dark .selector)` — the full selector must be inside the parentheses. `:global(html.dark) .selector` does NOT work (Vue's scoped CSS transformer does not follow descendant selectors after `:global()`).
|
||||||
|
|
||||||
|
Toggle button (☾/☀) in sidebar footer.
|
||||||
|
|
||||||
|
## i18n (i18n.js)
|
||||||
|
|
||||||
|
`i18n.js` exports `locale` (ref), `t(key)`, `tFmt(key, ...args)`, `setLocale(lang)`. Persisted in `localStorage('locale')`, default `'fr'`.
|
||||||
|
|
||||||
|
Supported languages: `fr`, `en`, `es`.
|
||||||
|
|
||||||
|
**Rule**: all visible UI strings use `t('key')` — never hardcoded text in templates. `deviceTypes` in `DeviceManager` is a `computed` (not a const) so type labels react to locale changes.
|
||||||
|
|
||||||
|
Language switcher pills (fr / en / es) in sidebar footer.
|
||||||
|
|
||||||
|
## Favicon
|
||||||
|
|
||||||
|
`frontend/public/favicon.svg` — SVG network graph icon (indigo #6366f1), same shape as sidebar logo. Linked in `index.html` as `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />`.
|
||||||
|
|
||||||
|
## Action Buttons (btn-icon)
|
||||||
|
|
||||||
|
`.btn-icon` uses `background: var(--bg-chip)` and `color: var(--text-secondary)` for adequate contrast in both themes. Hover: `var(--border-strong)` / `var(--text-primary)`. Danger hover: `rgba(239,68,68,0.15)` (transparent red, theme-neutral).
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
Docker volume `db_data` → `/app/data/topology.db` and `/app/data/secret_key.txt`.
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# Stupid Simple Network Inventory
|
||||||
|
|
||||||
|
Application web d'inventaire manuel de serveurs et de génération de topologie réseau logique.
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
- **Backend** : FastAPI (Python 3.11) + SQLAlchemy + SQLite
|
||||||
|
- **Frontend** : Vue 3 + Vite (pas de Cytoscape — topologie en cards CSS)
|
||||||
|
- **Icônes** : `lucide-vue-next` (UI générique) + `simple-icons` (logos de marques)
|
||||||
|
- **Reverse proxy** : Nginx (sert le frontend et proxifie `/api/` vers le backend)
|
||||||
|
- **Conteneurisation** : Docker Compose
|
||||||
|
|
||||||
|
## Lancement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application est accessible sur **http://localhost:8080**
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
topologie/
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── backend/
|
||||||
|
│ ├── Dockerfile # Python 3.11-slim + iputils-ping (pour ICMP)
|
||||||
|
│ ├── requirements.txt # fastapi, sqlalchemy, dnspython, ...
|
||||||
|
│ ├── main.py # Point d'entrée FastAPI + migrations SQLite au démarrage
|
||||||
|
│ ├── database.py # SQLAlchemy engine + session
|
||||||
|
│ ├── models.py # Modèles ORM (Vlan, Device, DeviceInterface)
|
||||||
|
│ └── routers/
|
||||||
|
│ ├── vlans.py
|
||||||
|
│ ├── devices.py
|
||||||
|
│ └── discovery.py # POST /api/discovery/scan + POST /api/discovery/ping
|
||||||
|
└── frontend/
|
||||||
|
├── Dockerfile # Multi-stage: build Vite → Nginx
|
||||||
|
├── nginx.conf # Proxy /api/ → backend:8000
|
||||||
|
├── index.html
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.js
|
||||||
|
└── src/
|
||||||
|
├── main.js
|
||||||
|
├── App.vue # Layout + état global (vlans, devices) + garde auth
|
||||||
|
├── api.js # Appels axios (vlansApi, devicesApi, discoveryApi, authApi)
|
||||||
|
├── auth.js # État auth : isAuthenticated, currentUsername, setAuth(), clearAuth()
|
||||||
|
├── i18n.js # Internationalisation : locale (ref), t(key), tFmt(key, ...args), setLocale()
|
||||||
|
├── theme.js # Thème : theme (ref), toggleTheme() — classe html.dark
|
||||||
|
├── brandIcons.js # Tableau BRANDS + fonction detectBrands() — partagé entre composants
|
||||||
|
└── components/
|
||||||
|
├── TopologyGraph.vue # Vue cards par réseau + ping statut
|
||||||
|
├── DeviceIcon.vue # Icône Lucide par type (prop typeOnly) ou logos marques
|
||||||
|
├── DeviceManager.vue # CRUD équipements + barre filtres/recherche
|
||||||
|
├── VlanManager.vue # CRUD réseaux (VLANs + LANs classiques)
|
||||||
|
├── DiscoveryModal.vue # Découverte auto : ping sweep + DNS PTR
|
||||||
|
├── LoginPage.vue # Page de connexion plein écran
|
||||||
|
└── AccountModal.vue # Modale changement nom d'utilisateur / mot de passe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modèle de données
|
||||||
|
|
||||||
|
- **User** : id, username (unique), hashed_password (bcrypt)
|
||||||
|
- **Vlan** : id, vlan_id (int, nullable — null = LAN classique sans tag 802.1Q), name, cidr, color
|
||||||
|
- **Device** : id, name, type, description, is_gateway, is_livebox, virt_type (nullable), url (nullable)
|
||||||
|
- **DeviceInterface** : id, device_id, vlan_id (nullable), ip_address, name, is_upstream
|
||||||
|
|
||||||
|
## Types d'équipements (18)
|
||||||
|
|
||||||
|
`server`, `switch`, `router`, `nas`, `gateway`, `livebox`, `access_point`, `camera`, `temperature`, `sensor`, `hub`, `smart_plug`, `alarm`, `light`, `doorbell`, `desktop`, `laptop`, `other`
|
||||||
|
|
||||||
|
## Champ virt_type (Device)
|
||||||
|
|
||||||
|
Libellé dans le formulaire : "Type d'environnement d'exécution". Valeurs :
|
||||||
|
- `null` — non précisé (défaut)
|
||||||
|
- `baremetal` — serveur physique
|
||||||
|
- `lxc` — conteneur LXC (tag bleu "LXC" dans la topologie)
|
||||||
|
- `qemu` — VM QEMU/KVM (tag violet "VM" dans la topologie)
|
||||||
|
|
||||||
|
## Champ url (Device)
|
||||||
|
|
||||||
|
URL optionnelle d'accès à l'interface web de l'équipement. Non applicable aux types `desktop` et `laptop` (masqué dans le formulaire). Affiché comme lien cliquable dans la card équipement et comme tag "Link" vert sur les chips de topologie.
|
||||||
|
|
||||||
|
## Réseaux (VlanManager.vue)
|
||||||
|
|
||||||
|
`vlan_id` est optionnel. Si absent → réseau LAN classique (badge "LAN"). Si présent → VLAN 802.1Q (badge "VLAN X").
|
||||||
|
Les réseaux sont triés : LANs en premier (vlan_id NULL), puis VLANs par ID croissant.
|
||||||
|
|
||||||
|
## Topologie (TopologyGraph.vue)
|
||||||
|
|
||||||
|
Pas de graphe Cytoscape. Layout en cards CSS :
|
||||||
|
- **Barre d'outils** en haut : bouton Ping avec indicateur animé + compteur up/down
|
||||||
|
- **Carte WAN** (rouge) — devices `is_livebox`
|
||||||
|
- **Carte Passerelle** (jaune) — devices `is_gateway`
|
||||||
|
- **Grille réseaux** — une card par réseau (LAN ou VLAN), chaque device apparaît dans toutes les cards de ses réseaux (multi-réseau natif)
|
||||||
|
- **Zone non assigné** — devices sans interface réseau, ni livebox, ni gateway
|
||||||
|
- **Tri** : devices triés par IP croissante dans chaque card
|
||||||
|
|
||||||
|
### Layout des cards
|
||||||
|
|
||||||
|
- **Cards pleine largeur**, empilées verticalement (flex-col) — toutes de la même largeur, hauteur variable selon le nombre de devices
|
||||||
|
- Barre colorée en haut de chaque card (3px, couleur du réseau), bordures latérales neutres
|
||||||
|
- Header : badge VLAN/LAN (pill colorée) + nom + CIDR inline + compteur de devices (pill)
|
||||||
|
- **Chips en grille horizontale** (`grid auto-fill, minmax(210px, 1fr)`) à l'intérieur de chaque card — s'adaptent automatiquement au nombre de colonnes disponibles
|
||||||
|
- Responsive : sous 600px, une seule colonne de chips
|
||||||
|
|
||||||
|
### Device chip
|
||||||
|
|
||||||
|
- Icône de type (Lucide) toujours dans le carré arrondi (28px) avec fond coloré
|
||||||
|
- Nom (bold 12px) + IP (pill monospace) + nom d'interface (grisé) + logos de marques (SVG colorés 11px)
|
||||||
|
- Tags à droite en colonne : **Link** (vert, cliquable — tag URL de l'équipement) / **GW** / **LXC** / **VM**
|
||||||
|
- Dot de ping : vert (joignable) ou rouge (injoignable), affiché après un ping
|
||||||
|
|
||||||
|
## Icônes (DeviceIcon.vue + brandIcons.js)
|
||||||
|
|
||||||
|
La détection de marque est centralisée dans `brandIcons.js` (`detectBrands(name, description)`).
|
||||||
|
|
||||||
|
`DeviceIcon.vue` accepte une prop `typeOnly` (booléen) :
|
||||||
|
- `false` (défaut) : affiche les logos de marques si détectés, sinon icône Lucide — utilisé dans TopologyGraph
|
||||||
|
- `true` : toujours l'icône Lucide par type — utilisé dans DeviceManager
|
||||||
|
|
||||||
|
Dans **DeviceManager**, les logos de marques apparaissent comme badges colorés séparés (couleur officielle de la marque) dans les meta de la card.
|
||||||
|
|
||||||
|
Dans **TopologyGraph**, les logos de marques apparaissent comme petits SVG 11px colorés dans `chip-sub`.
|
||||||
|
|
||||||
|
Marques supportées via Simple Icons :
|
||||||
|
Proxmox, Docker, Synology, TrueNAS, Ubiquiti/UniFi, MikroTik, Cisco, TP-Link, ASUS, Netgear, pfSense, OPNsense, OpenWrt, Apache/Apache2, Traefik, MariaDB, Kubernetes/k3s, Debian, Ubuntu, Ansible, Dell, HP, Raspberry Pi, Arduino, KDE/Plasma, Excalidraw, Nextcloud, Paperless-NGX, Uptime Kuma, MkDocs, Jellyfin, Home Assistant, Philips Hue, Xiaomi
|
||||||
|
|
||||||
|
Pour ajouter une marque : vérifier avec `node -e "const si = require('./node_modules/simple-icons'); console.log(Object.keys(si).filter(k => k.toLowerCase().includes('nom')))"`, importer `si<Name>` dans `brandIcons.js`, ajouter une entrée dans BRANDS.
|
||||||
|
|
||||||
|
Fallback : Lucide (icône par type d'équipement).
|
||||||
|
|
||||||
|
## Vue Équipements (DeviceManager.vue)
|
||||||
|
|
||||||
|
### Barre de filtres + recherche
|
||||||
|
|
||||||
|
Barre compacte (28px) combinant :
|
||||||
|
- **Champ de recherche** : filtre par nom, description ou IP en temps réel (Échap pour vider)
|
||||||
|
- **Filtre Type** : dropdown multi-sélection, seuls les types présents dans les données
|
||||||
|
- **Filtre Réseau** : dropdown avec pastille colorée par VLAN/LAN
|
||||||
|
- **Filtre Marque** : dropdown avec icône SVG, trié alphabétiquement
|
||||||
|
- **Filtre Virt** : dropdown, seulement si au moins un équipement a un virt_type
|
||||||
|
|
||||||
|
Tous les filtres sont combinables (ET entre catégories, OU à l'intérieur). Bouton "Effacer" + compteur `X / total` si filtre actif.
|
||||||
|
|
||||||
|
## Ping (discovery.py + TopologyGraph.vue)
|
||||||
|
|
||||||
|
`POST /api/discovery/ping` — ping ICMP parallèle (50 workers).
|
||||||
|
- Body : `{ ips: ["1.2.3.4", ...] }`
|
||||||
|
- Retourne : `[{ ip, alive }, ...]`
|
||||||
|
|
||||||
|
Dans la topologie : bouton "Ping" déclenche le ping de toutes les IPs connues. Un équipement est "up" si au moins une de ses IPs répond. Dot vert/rouge sur chaque chip.
|
||||||
|
|
||||||
|
## Authentification (auth.py + auth.js)
|
||||||
|
|
||||||
|
JWT HS256, **expiration 24h**. Payload : `{ sub: username, ver: token_version, exp: ... }`. Clé secrète dans `data/secret_key.txt` (permissions 0600, auto-générée) ou `SECRET_KEY` env var.
|
||||||
|
|
||||||
|
**Compte par défaut** : `admin` / `admin` avec `must_change_password=1` — créé si la table `users` est vide. Définir `INITIAL_ADMIN_PASSWORD` pour un mot de passe personnalisé (sans changement forcé). La migration `_migrate_force_admin_password_change()` remet `must_change_password=1` si l'admin utilise encore le mot de passe bootstrap sur une base existante.
|
||||||
|
|
||||||
|
**Invalidation de token** : le changement de mot de passe incrémente `User.token_version`. `get_current_user` rejette tout token dont `ver` ne correspond pas → invalidation immédiate des sessions précédentes.
|
||||||
|
|
||||||
|
**Compatibilité bcrypt** : `passlib 1.7.4` est incompatible avec `bcrypt >= 4.0`. Le fichier `requirements.txt` épingle `bcrypt==3.2.2`. Ne pas mettre à jour `bcrypt` sans mettre à jour `passlib`.
|
||||||
|
|
||||||
|
### Endpoints auth
|
||||||
|
|
||||||
|
| Méthode | Chemin | Auth requise | Description |
|
||||||
|
|---------|--------|:------------:|-------------|
|
||||||
|
| POST | `/api/auth/login` | Non | Form → `{ access_token, token_type, username, must_change_password }` — 429 si rate-limited |
|
||||||
|
| PUT | `/api/auth/account` | Oui (même si must_change) | `{ current_password, new_username?, new_password? }` → nouveau token + `must_change_password` |
|
||||||
|
| GET | `/api/auth/me` | Oui | `{ username, must_change_password }` |
|
||||||
|
|
||||||
|
Routeurs métier protégés via `dependencies=[Depends(require_password_changed)]` (= `get_current_user` + rejet 403 si `must_change_password=True`).
|
||||||
|
|
||||||
|
### Côté frontend
|
||||||
|
|
||||||
|
`auth.js` expose `isAuthenticated`, `currentUsername`, `mustChangePassword` (refs) et `setAuth(token, username, mustChange)`, `clearAuth()`, `getToken()`. Persisté dans `localStorage`.
|
||||||
|
|
||||||
|
`App.vue` guard :
|
||||||
|
- `!isAuthenticated` → `<LoginPage>`
|
||||||
|
- `mustChangePassword` → `<AccountModal :forced="true">` (bloque l'application)
|
||||||
|
- sinon → application complète
|
||||||
|
|
||||||
|
Template enveloppé dans `<div class="app-root">` (un seul nœud racine) pour éviter le bug Firefox avec l'overlay d'autofill sur les fragments Vue (plusieurs nœuds racines). `.app-root` utilise `display: flex; flex-direction: column; min-height: 100vh` — ne pas revenir à `display: contents` (bug Firefox : les clics ne sont plus dispatché aux enfants après un remplacement du DOM à l'intérieur d'un élément `display: contents`).
|
||||||
|
|
||||||
|
## Migrations SQLite (main.py)
|
||||||
|
|
||||||
|
`main.py` exécute des migrations idempotentes au démarrage avant `create_all` :
|
||||||
|
- `_migrate_vlan_nullable()` — recrée `vlans` sans contrainte NOT NULL sur `vlan_id`
|
||||||
|
- `_migrate_device_virt_type()` — ajoute `virt_type` si absente
|
||||||
|
- `_migrate_device_url()` — ajoute `url` si absente
|
||||||
|
- `_migrate_users_must_change_password()` — ajoute `must_change_password` si absente
|
||||||
|
- `_migrate_users_token_version()` — ajoute `token_version` si absente
|
||||||
|
- `_migrate_force_admin_password_change()` — rattrapage : force must_change si admin utilise le mot de passe bootstrap
|
||||||
|
- `_migrate_drop_links_table()` — supprime la table `links` (fonctionnalité retirée en phase 3)
|
||||||
|
- `_migrate_users()` — crée `users` + compte admin si vide
|
||||||
|
|
||||||
|
Pour toute nouvelle colonne nullable sur une table existante, ajouter une fonction de migration du même type.
|
||||||
|
|
||||||
|
Quand on ajoute un champ à Device : mettre à jour `models.py`, ajouter la migration dans `main.py`, ajouter le champ dans `DeviceCreate` et `DeviceOut` dans `routers/devices.py`, l'assigner explicitement dans `create_device` et `update_device`.
|
||||||
|
|
||||||
|
## Découverte automatique (DiscoveryModal.vue)
|
||||||
|
|
||||||
|
`POST /api/discovery/scan` — ping sweep ICMP + lookup PTR DNS contre un serveur DNS configurable.
|
||||||
|
- Max 1024 hôtes par VLAN, 100 workers concurrents
|
||||||
|
- Retourne : ip, hostname, vlan_id, durée
|
||||||
|
- Nécessite `cap_add: NET_RAW` sur le service backend (déjà configuré)
|
||||||
|
|
||||||
|
## Thème (theme.js + App.vue)
|
||||||
|
|
||||||
|
`frontend/src/theme.js` exporte `theme` (ref) et `toggleTheme()`.
|
||||||
|
- Valeurs : `'light'` (défaut) / `'dark'`
|
||||||
|
- Persisté dans `localStorage('theme')`
|
||||||
|
- Applique / retire la classe `html.dark` via `watch`
|
||||||
|
|
||||||
|
Les variables CSS sont définies dans le `<style>` global de `App.vue` :
|
||||||
|
- `:root` — thème clair (`--bg-page: #F1F5F9`, `--bg-card: #ffffff`, `--text-primary: #0F172A`, ...)
|
||||||
|
- `html.dark` — thème sombre (`--bg-page: #0F172A`, `--bg-card: #1E293B`, `--text-primary: #F1F5F9`, ...)
|
||||||
|
|
||||||
|
Variables disponibles : `--bg-page`, `--bg-card`, `--bg-card-hover`, `--bg-input`, `--bg-thead`, `--bg-chip`, `--bg-chip-hover`, `--border`, `--border-strong`, `--text-primary`, `--text-secondary`, `--text-muted`, `--text-faint`, `--chip-ip-bg`, `--chip-ip-color`, `--modal-overlay`, `--shadow-card`, `--shadow-modal`, `--shadow-drop`.
|
||||||
|
|
||||||
|
Bouton toggle (☾/☀) dans le footer de la sidebar.
|
||||||
|
|
||||||
|
**Règle** : toutes les couleurs de fond/texte/bordure dans les composants doivent utiliser ces variables, pas des valeurs hex hardcodées.
|
||||||
|
|
||||||
|
Exception : les couleurs sémantiques fixes (couleur des icônes par type d'équipement dans les chips, couleurs des badges VLAN) restent en hex car elles ne dépendent pas du thème.
|
||||||
|
|
||||||
|
## Internationalisation (i18n.js)
|
||||||
|
|
||||||
|
`frontend/src/i18n.js` exporte :
|
||||||
|
- `locale` (ref) — langue active, persistée dans `localStorage('locale')`, défaut `'fr'`
|
||||||
|
- `setLocale(lang)` — change la langue
|
||||||
|
- `t(key)` — retourne la traduction, fallback `'fr'` puis la clé brute
|
||||||
|
- `tFmt(key, ...args)` — idem avec interpolation `{0}`, `{1}`, ...
|
||||||
|
|
||||||
|
Langues supportées : **français** (`fr`), **anglais** (`en`), **espagnol** (`es`).
|
||||||
|
|
||||||
|
Sélecteur (pills `fr / en / es`) dans le footer de la sidebar.
|
||||||
|
|
||||||
|
**Règle** : toutes les chaînes visibles dans l'UI passent par `t('key')` — jamais de texte hardcodé dans les templates. `deviceTypes` dans `DeviceManager` est un `computed` (pas une constante) pour réagir aux changements de locale.
|
||||||
|
|
||||||
|
## Favicon
|
||||||
|
|
||||||
|
`frontend/public/favicon.svg` — SVG indigo (#6366f1), même forme que le logo sidebar (graphe de nœuds réseau).
|
||||||
|
Référencé dans `frontend/index.html` : `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />`.
|
||||||
|
|
||||||
|
## Données persistées
|
||||||
|
|
||||||
|
Volume Docker `db_data` monté sur `/app/data/` → `topology.db` (SQLite)
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Stupid Simple Network Inventory
|
||||||
|
|
||||||
|
Self-hosted web application for manual network inventory and logical network topology visualisation.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Backend | FastAPI + SQLAlchemy + SQLite (Python 3.11) |
|
||||||
|
| Frontend | Vue 3 + Vite, served by Nginx |
|
||||||
|
| Auth | JWT HS256, 24-hour expiry |
|
||||||
|
| Runtime | Docker Compose |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and enter the project
|
||||||
|
git clone <repo> && cd topologie
|
||||||
|
|
||||||
|
# 2. Create the data directory owned by the current user
|
||||||
|
mkdir -p db_data
|
||||||
|
|
||||||
|
# 3. Configure environment (required for correct bind-mount ownership)
|
||||||
|
cp .env.example .env
|
||||||
|
# Set DOCKER_UID / DOCKER_GID to match your host user:
|
||||||
|
# id -u && id -g
|
||||||
|
# Set INITIAL_ADMIN_PASSWORD to avoid the admin/admin bootstrap.
|
||||||
|
|
||||||
|
# 4. Build and start
|
||||||
|
docker compose --env-file .env up --build -d
|
||||||
|
|
||||||
|
# 5. Open the app
|
||||||
|
open http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### First login
|
||||||
|
|
||||||
|
| Case | Credentials | Behaviour |
|
||||||
|
|------|------------|-----------|
|
||||||
|
| `INITIAL_ADMIN_PASSWORD` set | `admin` / `<your password>` | Normal login |
|
||||||
|
| `INITIAL_ADMIN_PASSWORD` unset | `admin` / `admin` | Forced password change before accessing the app |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration is via environment variables. See `.env.example` for the full list with descriptions.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SECRET_KEY` | auto-generated | JWT signing key. Set explicitly in production. |
|
||||||
|
| `INITIAL_ADMIN_PASSWORD` | _(empty)_ | Bootstrap admin password. If unset, `admin/admin` is used with forced change. |
|
||||||
|
| `ALLOWED_ORIGINS` | `*` | CORS allowed origins (comma-separated). Set to your domain in production. |
|
||||||
|
| `DOCKER_UID` / `DOCKER_GID` | `1000` | UID/GID for the backend process. Must match the host user owning `./db_data/`. |
|
||||||
|
|
||||||
|
### Using .env with Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env — at minimum set DOCKER_UID, DOCKER_GID, INITIAL_ADMIN_PASSWORD
|
||||||
|
docker compose --env-file .env up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Secret management
|
||||||
|
|
||||||
|
Two options depending on your security requirements.
|
||||||
|
|
||||||
|
#### Option A — Auto-generated secret (recommended for single-node)
|
||||||
|
|
||||||
|
Leave `SECRET_KEY` unset (or empty) in `.env`. On first start the backend generates a random 64-character hex key, writes it to `db_data/secret_key.txt` with permissions **0600**, and reuses it on every subsequent restart. The secret never appears in an environment variable, a compose file, or a log.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env — leave the line empty or remove it
|
||||||
|
SECRET_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
The only requirement is that `db_data/` is backed up (it already contains the database).
|
||||||
|
|
||||||
|
#### Option B — Docker Compose file secret
|
||||||
|
|
||||||
|
Stores the secret in a file on the host, outside version control, and mounts it into the container. The value never appears in an environment variable.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate and store the secret outside the project directory
|
||||||
|
mkdir -p ~/.secrets
|
||||||
|
python3 -c "import secrets; print(secrets.token_hex(32))" > ~/.secrets/topologie_secret_key
|
||||||
|
chmod 600 ~/.secrets/topologie_secret_key
|
||||||
|
```
|
||||||
|
|
||||||
|
Then uncomment the `secrets:` blocks in `docker-compose.yml` (see comments in that file) and remove `SECRET_KEY` from `.env`. Docker Compose merges the override automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Key rotation
|
||||||
|
|
||||||
|
To rotate the JWT secret (invalidates all active sessions):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option A — environment variable (recommended)
|
||||||
|
# Set a new SECRET_KEY in your deployment config and restart
|
||||||
|
|
||||||
|
# Option B — file rotation
|
||||||
|
docker compose stop backend
|
||||||
|
rm db_data/secret_key.txt
|
||||||
|
docker compose start backend
|
||||||
|
# All users will need to log in again
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTPS
|
||||||
|
|
||||||
|
This application does not terminate TLS. For production use, place it behind a reverse proxy that handles HTTPS:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Example nginx reverse-proxy (external, on the host or a dedicated container)
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name inventory.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/inventory.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/inventory.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For local-only use, bind to loopback to prevent accidental LAN exposure:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.override.yml
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container hardening
|
||||||
|
|
||||||
|
The containers run with reduced privileges:
|
||||||
|
|
||||||
|
| Measure | Backend | Frontend |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| Non-root user | `DOCKER_UID:DOCKER_GID` (host user) | `nginx` (UID 101) |
|
||||||
|
| `cap_drop: ALL` | ✓ | ✓ |
|
||||||
|
| `cap_add: NET_RAW` | ✓ (ping) | — |
|
||||||
|
| `no-new-privileges` | — ¹ | ✓ |
|
||||||
|
| Healthcheck | ✓ | ✓ |
|
||||||
|
|
||||||
|
¹ Omitted on the backend: ping uses file capabilities (`cap_net_raw=ep`); `no-new-privileges` suppresses the file effective bit and would prevent the subprocess from acquiring `CAP_NET_RAW` in its effective set even though the parent holds it in its permitted set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data persistence
|
||||||
|
|
||||||
|
All data is stored in `./db_data/`:
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `topology.db` | SQLite database |
|
||||||
|
| `secret_key.txt` | Auto-generated JWT secret (0600 permissions) |
|
||||||
|
|
||||||
|
**Backup**: `cp -r db_data/ db_data.bak/`
|
||||||
|
|
||||||
|
**Restore**: stop the stack, replace `db_data/`, restart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Backend tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt -r requirements-test.txt
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local dev (without Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn main:app --reload
|
||||||
|
|
||||||
|
# Frontend (separate terminal)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # Vite dev server on :5173, proxies /api/ to :8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
See [`docs/architecture.md`](docs/architecture.md) for the detailed request flow, Docker setup, and authentication model.
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
# Audit de securite de base
|
||||||
|
|
||||||
|
Projet audite: Stupid Simple Network Inventory
|
||||||
|
Date: 2026-05-05
|
||||||
|
Referentiels: OWASP ASVS niveau 1, OWASP Top 10 2021
|
||||||
|
Mode: revue statique locale, sans attaque reelle, sans exploitation, sans correction applicative.
|
||||||
|
|
||||||
|
## Synthese
|
||||||
|
|
||||||
|
L'application est une SPA Vue exposee par Nginx, avec API FastAPI protegee par JWT pour les routeurs metier. Le modele d'exploitation precise est local, derriere un reverse-proxy. Dans ce modele, certains controles comme TLS, filtrage d'exposition et en-tetes HTTP peuvent legitimement etre portes par le reverse-proxy, mais doivent etre documentes et verifiables.
|
||||||
|
|
||||||
|
Risque global estime: moyen en usage local derriere reverse-proxy correctement configure; eleve si le service est expose directement a un LAN non fiable ou a Internet. Le risque principal vient du bootstrap avec identifiants publics `admin/admin` si le changement n'est pas force avant exposition, de l'absence de limitation de tentatives, du stockage du JWT en `localStorage`, de la surface de scan reseau authentifiee et d'un manque de documentation du contrat de securite attendu cote reverse-proxy.
|
||||||
|
|
||||||
|
## Perimetre lu
|
||||||
|
|
||||||
|
- Documents: `CLAUDE.md`, `AGENTS.md`, `docs/*.md`
|
||||||
|
- README: `README.md` absent
|
||||||
|
- Config: `docker-compose.yml`, `backend/Dockerfile`, `frontend/Dockerfile`, `frontend/nginx.conf`, `frontend/vite.config.js`, `backend/requirements.txt`, `frontend/package.json`, `frontend/package-lock.json`
|
||||||
|
- Backend: `backend/main.py`, `backend/database.py`, `backend/models.py`, `backend/routers/*.py`
|
||||||
|
- Frontend: `frontend/src/**/*.js`, `frontend/src/**/*.vue`, `frontend/index.html`
|
||||||
|
- `.env.example`: aucun fichier trouve
|
||||||
|
|
||||||
|
## Points positifs
|
||||||
|
|
||||||
|
- Les routeurs metier `vlans`, `devices`, `links`, `discovery` sont proteges par `Depends(get_current_user)` dans `backend/main.py:105-108`.
|
||||||
|
- Les requetes SQL passent par SQLAlchemy ou par `text()` parametre pour les migrations; pas de concatenation SQL dangereuse observee dans les routes CRUD.
|
||||||
|
- Le ping utilise `subprocess.run([...])` sans `shell=True` dans `backend/routers/discovery.py:42-46`, ce qui limite fortement l'injection de commande shell.
|
||||||
|
- Les mots de passe sont haches avec bcrypt via passlib dans `backend/routers/auth.py:37`.
|
||||||
|
- La cle JWT est generee aleatoirement au premier demarrage si `SECRET_KEY` est absent dans `backend/routers/auth.py:20-30`.
|
||||||
|
- Les liens externes visibles utilisent `rel="noopener"` dans les composants principaux, ce qui reduit le risque de controle de la fenetre ouvrante.
|
||||||
|
|
||||||
|
## Constats principaux
|
||||||
|
|
||||||
|
### SEC-01 - Bootstrap administrateur `admin/admin` sans changement force
|
||||||
|
|
||||||
|
Priorite: P0
|
||||||
|
OWASP: Top 10 A07 Identification and Authentication Failures, A04 Insecure Design
|
||||||
|
ASVS L1: authentification, gestion des secrets initiaux
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `backend/main.py:63-86`
|
||||||
|
- `CLAUDE.md` / `AGENTS.md` section Authentication
|
||||||
|
- `docs/backend.md`, `docs/extending.md`
|
||||||
|
|
||||||
|
La migration `_migrate_users()` cree automatiquement un compte `admin` avec mot de passe `admin` quand la table `users` est vide. Les documents indiquent explicitement ces identifiants comme mot de passe de depart a changer. Ce comportement est donc intentionnel, mais reste un ecart ASVS si l'application peut etre atteinte avant changement manuel: toute instance nouvellement demarree est accessible avec des identifiants publics jusqu'a cette action.
|
||||||
|
|
||||||
|
Impact: compromission complete des donnees d'inventaire, utilisation de la fonctionnalite de scan reseau, modification/suppression d'equipements.
|
||||||
|
|
||||||
|
Recommandation: conserver ce bootstrap seulement si l'application force le changement au premier login ou si la documentation exige explicitement une premiere connexion depuis localhost/reseau de confiance avant toute exposition. Variante plus robuste: exiger un mot de passe initial via variable d'environnement, fichier secret Docker, ou assistant de premier demarrage.
|
||||||
|
|
||||||
|
### SEC-02 - Pas de limitation de tentatives de connexion
|
||||||
|
|
||||||
|
Priorite: P1
|
||||||
|
OWASP: A07
|
||||||
|
ASVS L1: protections anti-automatisation de l'authentification
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `backend/routers/auth.py:72-77`
|
||||||
|
- `frontend/nginx.conf:1-16`
|
||||||
|
|
||||||
|
`POST /api/auth/login` ne limite pas les tentatives par IP, par nom utilisateur ou par fenetre temporelle. Nginx ne definit pas non plus de `limit_req`. Une attaque par devinette de mot de passe devient praticable, surtout pendant la phase de bootstrap avant changement du mot de passe initial.
|
||||||
|
|
||||||
|
Recommandation: ajouter une limitation cote API ou Nginx, journaliser les echecs, retourner des erreurs generiques et prevoir un delai progressif ou verrouillage temporaire.
|
||||||
|
|
||||||
|
### SEC-03 - Politique de mot de passe insuffisante
|
||||||
|
|
||||||
|
Priorite: P1
|
||||||
|
OWASP: A07
|
||||||
|
ASVS L1: qualite des secrets d'authentification
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `backend/routers/auth.py:66-69`
|
||||||
|
- `backend/routers/auth.py:80-95`
|
||||||
|
- `frontend/src/components/AccountModal.vue`
|
||||||
|
|
||||||
|
`new_password` est accepte s'il est non vide. Aucun minimum de longueur, aucune verification de mot de passe courant compromis/faible, et aucune validation de `new_username` ne sont appliquees cote serveur.
|
||||||
|
|
||||||
|
Recommandation: definir une politique minimale cote serveur, par exemple longueur minimale robuste, refus des mots de passe evidents, normalisation/validation du nom utilisateur, messages d'erreur non enumerants.
|
||||||
|
|
||||||
|
### SEC-04 - JWT persiste dans `localStorage`
|
||||||
|
|
||||||
|
Priorite: P1
|
||||||
|
OWASP: A02 Cryptographic Failures, A07, A05 Security Misconfiguration
|
||||||
|
ASVS L1: protection des tokens de session
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `frontend/src/auth.js:3-13`
|
||||||
|
- `frontend/src/api.js:6-9`
|
||||||
|
- `backend/routers/auth.py:41-43`
|
||||||
|
|
||||||
|
Le token JWT est stocke dans `localStorage`. En cas de XSS ou d'extension navigateur compromise, le token est directement lisible. Le token dure 7 jours et ne contient pas de version de session permettant d'invalider les anciens tokens apres changement de mot de passe.
|
||||||
|
|
||||||
|
Recommandation: preferer un cookie `HttpOnly`, `Secure`, `SameSite`, et ajouter une strategie d'expiration courte + renouvellement ou une version de session cote base. Si le mode bearer est conserve, reduire la duree de vie et ajouter une invalidation apres changement de mot de passe.
|
||||||
|
|
||||||
|
### SEC-05 - CORS ouvert a toutes origines dans l'API interne
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
OWASP: A05
|
||||||
|
ASVS L1: configuration HTTP securisee
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `backend/main.py:97-102`
|
||||||
|
- `docs/backend.md` indique que ce choix est intentionnel pour un outil LAN
|
||||||
|
|
||||||
|
`allow_origins=["*"]`, `allow_methods=["*"]`, `allow_headers=["*"]` ouvrent l'API a toute origine. Dans le deploiement Compose actuel, le backend n'est pas publie directement et le navigateur passe par le Nginx frontend/reverse-proxy en meme origine, ce qui reduit fortement le risque pratique. Cela reste une configuration permissive a encadrer si un reverse-proxy externe expose `/api/` ou si l'auth evolue vers des cookies.
|
||||||
|
|
||||||
|
Recommandation: soit supprimer CORS si l'API reste strictement interne et servie en meme origine par le reverse-proxy, soit configurer une liste d'origines explicites via environnement. Documenter le contrat attendu: backend non expose directement, `/api/` publie uniquement via le reverse-proxy.
|
||||||
|
|
||||||
|
### SEC-06 - Surface de scan reseau et SSRF authentifiee
|
||||||
|
|
||||||
|
Priorite: P1
|
||||||
|
OWASP: A10 SSRF, A04 Insecure Design
|
||||||
|
ASVS L1: validation des cibles reseau et limitation d'abus
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `backend/routers/discovery.py:22-24`
|
||||||
|
- `backend/routers/discovery.py:52-60`
|
||||||
|
- `backend/routers/discovery.py:79-86`
|
||||||
|
- `backend/routers/discovery.py:89-129`
|
||||||
|
- `docker-compose.yml:4-5`
|
||||||
|
|
||||||
|
Les endpoints `/api/discovery/scan` et `/api/discovery/ping` permettent a tout utilisateur authentifie de provoquer des connexions ICMP et DNS depuis le conteneur backend vers des cibles fournies par le client. `scan` limite chaque reseau a 1024 hotes, mais ne limite pas le nombre total de targets. `ping` n'a pas de limite de taille de liste ni de validation IP. Le serveur DNS est librement fourni.
|
||||||
|
|
||||||
|
Impact: cartographie de reseaux accessibles depuis le conteneur, charge CPU/process, requetes DNS vers des serveurs arbitraires, usage comme outil de reconnaissance interne apres compromission d'un compte.
|
||||||
|
|
||||||
|
Recommandation: valider toutes les IP/CIDR, limiter le nombre total d'adresses par requete, limiter la frequence, autoriser uniquement des plages configurees dans l'inventaire, restreindre les DNS autorises ou utiliser le resolv.conf du conteneur.
|
||||||
|
|
||||||
|
### SEC-07 - Validation d'entree insuffisante sur les modeles metier
|
||||||
|
|
||||||
|
Priorite: P1
|
||||||
|
OWASP: A03 Injection, A04, A05
|
||||||
|
ASVS L1: validation cote serveur
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `backend/routers/devices.py:11-33`
|
||||||
|
- `backend/routers/vlans.py:11-15`
|
||||||
|
- `backend/routers/links.py:11-15`
|
||||||
|
- `frontend/src/components/DeviceManager.vue:137`
|
||||||
|
- `frontend/src/components/TopologyGraph.vue:35,56,104,142`
|
||||||
|
|
||||||
|
Les schemas Pydantic acceptent des chaines libres sans bornes de longueur ni enums serveur. Exemples: `Device.type`, `virt_type`, `url`, `Vlan.cidr`, `Vlan.color`, `Link.link_type`, noms/descriptions/interfaces. Vue echappe correctement les interpolations texte, mais les URL sont reinjectees en `href` et l'API peut recevoir des valeurs qui ne passent pas par le formulaire HTML `type="url"`.
|
||||||
|
|
||||||
|
Recommandation: ajouter des contraintes Pydantic strictes: longueurs maximales, enums pour types, validation IP/CIDR, validation couleur hex, URL seulement `http`/`https`, normalisation des chaines. Ajouter `rel="noreferrer noopener"` sur les liens externes.
|
||||||
|
|
||||||
|
### SEC-08 - En-tetes de securite HTTP absents de la configuration fournie
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
OWASP: A05
|
||||||
|
ASVS L1: durcissement navigateur
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `frontend/nginx.conf:1-16`
|
||||||
|
- `frontend/index.html`
|
||||||
|
|
||||||
|
Le Nginx embarque ne configure pas d'en-tetes de securite: CSP, `X-Frame-Options` ou `frame-ancestors`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`. Si un reverse-proxy frontal ajoute deja ces en-tetes, le risque est traite au niveau infrastructure; sinon, la configuration fournie reste incomplete.
|
||||||
|
|
||||||
|
Recommandation: fournir une configuration de reference pour le reverse-proxy frontal, ou ajouter ces en-tetes dans `frontend/nginx.conf`. La decision doit eviter les doublons contradictoires et etre documentee.
|
||||||
|
|
||||||
|
### SEC-09 - TLS delegue au reverse-proxy, contrat non documente
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
OWASP: A02, A05
|
||||||
|
ASVS L1: protection des identifiants en transit
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `docker-compose.yml:14-15`
|
||||||
|
- `frontend/nginx.conf:1-2`
|
||||||
|
- `docs/architecture.md`
|
||||||
|
|
||||||
|
La configuration applicative expose HTTP sur `localhost:8080`. C'est coherent avec un service local derriere reverse-proxy, a condition que le reverse-proxy frontal termine TLS pour les acces non strictement locaux et que le backend/frontend Compose ne soient pas exposes directement sur un reseau non fiable.
|
||||||
|
|
||||||
|
Recommandation: documenter clairement que TLS est une responsabilite du reverse-proxy frontal, fournir un exemple de configuration attendue et recommander un bind local (`127.0.0.1:8080:80`) lorsque l'application est consommee uniquement par le proxy local.
|
||||||
|
|
||||||
|
### SEC-10 - Secret JWT persiste dans un bind mount projet, permissions faibles
|
||||||
|
|
||||||
|
Priorite: P1
|
||||||
|
OWASP: A02, A05
|
||||||
|
ASVS L1: gestion des secrets
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `backend/routers/auth.py:17-30`
|
||||||
|
- `docker-compose.yml:6-7`
|
||||||
|
- `docs/architecture.md`
|
||||||
|
- `db_data/secret_key.txt` present localement, permissions observees `0644`
|
||||||
|
|
||||||
|
La cle JWT est stockee dans `./db_data/secret_key.txt`, dans l'arborescence projet, avec permissions lisibles par d'autres utilisateurs locaux. Elle n'a pas ete lue pendant l'audit. Si cette cle fuit, des JWT valides peuvent etre forges jusqu'a rotation.
|
||||||
|
|
||||||
|
Recommandation: utiliser `SECRET_KEY` depuis un secret Docker ou un fichier hors depot, fixer des permissions strictes, ajouter `db_data/` et fichiers secrets a l'exclusion VCS si necessaire, documenter la rotation.
|
||||||
|
|
||||||
|
### SEC-11 - Conteneurs peu durcis
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
OWASP: A05
|
||||||
|
ASVS L1: configuration de plateforme
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `backend/Dockerfile:1-8`
|
||||||
|
- `frontend/Dockerfile:8-11`
|
||||||
|
- `docker-compose.yml:1-26`
|
||||||
|
|
||||||
|
Les images ne definissent pas d'utilisateur non-root explicite. Compose n'ajoute pas `read_only`, `security_opt: no-new-privileges:true`, `cap_drop`, limites de ressources, healthchecks ou contraintes de filesystem. Le backend ajoute `NET_RAW`, necessaire au ping, mais sans reduction des autres capacites.
|
||||||
|
|
||||||
|
Recommandation: executer les services avec utilisateur non-root, ajouter `cap_drop: [ALL]` puis `cap_add: [NET_RAW]` seulement pour backend, activer `no-new-privileges`, limiter ressources et rendre les FS readonly avec volumes temporaires explicites.
|
||||||
|
|
||||||
|
### SEC-12 - Pas de journalisation securite/audit
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
OWASP: A09 Security Logging and Monitoring Failures
|
||||||
|
ASVS L1: evenements de securite
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `backend/routers/auth.py`
|
||||||
|
- `backend/routers/devices.py`
|
||||||
|
- `backend/routers/vlans.py`
|
||||||
|
- `backend/routers/discovery.py`
|
||||||
|
|
||||||
|
Les evenements sensibles ne sont pas journalises de maniere structuree: login reussi/echec, changement de compte, scans reseau, imports, suppressions. En cas d'incident, l'attribution et l'analyse seront limitees.
|
||||||
|
|
||||||
|
Recommandation: ajouter des logs structures sans secrets, incluant utilisateur, endpoint, action, resultat, compteurs et IP client issue du proxy de confiance.
|
||||||
|
|
||||||
|
### SEC-13 - Integrite relationnelle SQLite non enforcee
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
OWASP: A04, A08
|
||||||
|
ASVS L1: integrite des donnees
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `backend/database.py:6-9`
|
||||||
|
- `backend/models.py`
|
||||||
|
- `docs/data-model.md` signale explicitement que les FK SQLite ne sont pas enforcees
|
||||||
|
|
||||||
|
Les `ForeignKey` SQLAlchemy ne suffisent pas sous SQLite sans `PRAGMA foreign_keys=ON` par connexion. L'application compense partiellement dans les routeurs, mais des incoherences restent possibles, par exemple via evolutions futures ou migrations.
|
||||||
|
|
||||||
|
Recommandation: activer le pragma via event SQLAlchemy, verifier les comportements de suppression, ajouter des tests d'integrite.
|
||||||
|
|
||||||
|
### SEC-14 - Gestion des dependances et supply chain incomplete
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
OWASP: A06 Vulnerable and Outdated Components, A08 Software and Data Integrity Failures
|
||||||
|
ASVS L1: composants connus et maintenus
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `backend/requirements.txt`
|
||||||
|
- `frontend/package.json:9-19`
|
||||||
|
- `frontend/package-lock.json`
|
||||||
|
- `frontend/Dockerfile:3-6`
|
||||||
|
|
||||||
|
Les dependances sont epinglees cote Python, mais il n'y a pas de workflow SCA documente. Cote frontend, `package.json` utilise des ranges `^`, et le Dockerfile lance `npm install` au lieu de `npm ci`, ce qui reduit la reproductibilite. `cytoscape` reste declare alors que les notes disent que la topologie n'utilise plus Cytoscape.
|
||||||
|
|
||||||
|
Recommandation: utiliser `npm ci`, supprimer les dependances inutilisees, ajouter `pip-audit`/`npm audit` ou equivalent CI, documenter la procedure d'upgrade passlib/bcrypt.
|
||||||
|
|
||||||
|
### SEC-15 - Import JSON sans schema ni limite de taille
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
OWASP: A04, A05
|
||||||
|
ASVS L1: validation des donnees importees
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `frontend/src/App.vue:191-208`
|
||||||
|
- routes CRUD backend appelees ensuite
|
||||||
|
|
||||||
|
L'import JSON parse tout le fichier cote navigateur, puis cree des VLANs/devices en boucle sans validation de schema dedie ni limite de taille/quantite. Les erreurs de creation sont ignorees avec `.catch(() => {})`, ce qui peut masquer des imports partiels ou incoherents.
|
||||||
|
|
||||||
|
Recommandation: definir un schema d'import, limiter taille et nombre d'elements, afficher un bilan d'erreurs, et s'appuyer sur les validations serveur renforcees.
|
||||||
|
|
||||||
|
### SEC-16 - `v-html` inutile dans la navigation
|
||||||
|
|
||||||
|
Priorite: P3
|
||||||
|
OWASP: A03 XSS, defense in depth
|
||||||
|
ASVS L1: sortie encodee
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `frontend/src/App.vue:24`
|
||||||
|
- `frontend/src/App.vue` tableau `tabs`
|
||||||
|
|
||||||
|
`v-html` est utilise pour afficher des icones de navigation depuis des constantes locales. Le risque actuel est faible car les valeurs ne viennent pas de l'utilisateur, mais l'usage de HTML injecte est inutile et peut devenir dangereux lors d'une future evolution.
|
||||||
|
|
||||||
|
Recommandation: remplacer par du texte, des composants d'icones ou une interpolation normale.
|
||||||
|
|
||||||
|
### SEC-17 - Endpoint health public minimal
|
||||||
|
|
||||||
|
Priorite: P3
|
||||||
|
OWASP: A05
|
||||||
|
ASVS L1: exposition minimale
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `backend/main.py:111-113`
|
||||||
|
- `docs/backend.md`
|
||||||
|
|
||||||
|
`GET /api/health` est public. Dans un modele local derriere reverse-proxy, c'est acceptable pour un healthcheck minimal. Il ne divulgue que `{"status":"ok"}`; risque faible. Il doit simplement rester minimal et ne pas exposer version, configuration ou details d'erreur.
|
||||||
|
|
||||||
|
Recommandation: conserver public si necessaire au healthcheck, documenter cette decision, et s'assurer que le reverse-proxy ne l'expose pas avec plus d'information que necessaire.
|
||||||
|
|
||||||
|
### SEC-18 - Documentation de securite et configuration d'environnement incompletes
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
OWASP: A05, A04
|
||||||
|
ASVS L1: configuration securisee documentee
|
||||||
|
|
||||||
|
Fichiers:
|
||||||
|
- `README.md` absent
|
||||||
|
- aucun `.env.example`
|
||||||
|
- `CLAUDE.md`, `AGENTS.md`, `docs/*.md`
|
||||||
|
|
||||||
|
Les fichiers de notes de developpement sont riches, mais il manque un README utilisateur et un `.env.example` pour documenter `SECRET_KEY`, origines CORS si conservees, URL publique du reverse-proxy, modele TLS delegue, creation du compte initial et durcissement production/local.
|
||||||
|
|
||||||
|
Recommandation: ajouter une documentation securite minimale et un exemple d'environnement sans secrets reels.
|
||||||
|
|
||||||
|
## Ecarts avec CLAUDE.md / AGENTS.md
|
||||||
|
|
||||||
|
- `README.md` est absent alors que le perimetre demande sa revue. Les informations equivalentes sont dans `CLAUDE.md`, `AGENTS.md` et `docs/`.
|
||||||
|
- Aucun `.env.example` n'est present, alors que `SECRET_KEY` est supporte par le code.
|
||||||
|
- `CLAUDE.md` et `AGENTS.md` documentent le compte de bootstrap `admin/admin`; ce n'est pas un ecart fonctionnel, mais cela doit etre accompagne d'un changement force ou d'une consigne de non-exposition avant changement pour satisfaire une posture ASVS L1.
|
||||||
|
- `docs/backend.md` indique que CORS ouvert est intentionnel pour un outil LAN. Avec un backend interne derriere reverse-proxy, le risque est reduit; il faut surtout documenter que le backend ne doit pas etre publie directement.
|
||||||
|
- `AGENTS.md` indique que les routeurs sont proteges; c'est vrai pour les routeurs metier. `GET /api/health` reste public et doit rester explicitement assume.
|
||||||
|
- Les notes disent "no Cytoscape", mais `frontend/package.json` garde `cytoscape` dans les dependances.
|
||||||
|
|
||||||
|
## Couverture OWASP Top 10
|
||||||
|
|
||||||
|
| Categorie | Evaluation |
|
||||||
|
|---|---|
|
||||||
|
| A01 Broken Access Control | Pas de multi-utilisateur/role. Routeurs metier proteges. Risque residuel faible a moyen via compte compromis. |
|
||||||
|
| A02 Cryptographic Failures | JWT en localStorage, TLS delegue au reverse-proxy a documenter, secret en bind mount lisible localement. |
|
||||||
|
| A03 Injection | SQL/commande correctement limites; validation URL/CSS/strings a renforcer; `v-html` inutile. |
|
||||||
|
| A04 Insecure Design | Bootstrap `admin/admin` a encadrer, scan reseau authentifie, absence de politique session/password. |
|
||||||
|
| A05 Security Misconfiguration | CORS permissif pour API interne, headers HTTP a porter par le proxy ou Nginx embarque, conteneurs peu durcis, health public minimal. |
|
||||||
|
| A06 Vulnerable and Outdated Components | Pas de SCA ni politique d'upgrade; `npm install`; dependance inutilisee. |
|
||||||
|
| A07 Identification and Authentication Failures | Bootstrap `admin/admin` sans changement force, pas de rate limit, politique password faible. |
|
||||||
|
| A08 Software and Data Integrity Failures | Build frontend non reproductible strictement; FK SQLite non enforcees; imports JSON peu controles. |
|
||||||
|
| A09 Logging and Monitoring Failures | Logs securite absents. |
|
||||||
|
| A10 SSRF | Discovery permet ping/DNS vers cibles fournies par utilisateur authentifie. |
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- OWASP ASVS: https://owasp.org/www-project-application-security-verification-standard/
|
||||||
|
- OWASP Top 10 2021: https://owasp.org/Top10/2021/
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Security Decisions
|
||||||
|
|
||||||
|
Decisions, trade-offs, and documented limitations for Stupid Simple Network Inventory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JWT in localStorage vs HttpOnly cookie
|
||||||
|
|
||||||
|
**Decision**: keep JWT in `localStorage` + Bearer token.
|
||||||
|
|
||||||
|
**Rationale**: migrating to HttpOnly cookies requires changes to the Nginx config (cookie proxying, SameSite handling), the FastAPI auth flow, and all frontend API calls. The added complexity is disproportionate for a self-hosted LAN tool. The risk is mitigated by:
|
||||||
|
- 24-hour token expiry (reduced from 7 days)
|
||||||
|
- `token_version` invalidation: password change immediately revokes all prior tokens
|
||||||
|
- The application is designed for trusted LAN use, not public internet exposure
|
||||||
|
|
||||||
|
**Assumption**: the deployment is behind a trusted proxy or on a private network where XSS is the primary concern and is partially mitigated by the LAN context.
|
||||||
|
|
||||||
|
**Future**: if the app ever needs public internet exposure, migrating to `HttpOnly; Secure; SameSite=Strict` cookies should be the first priority.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token versioning instead of a session store
|
||||||
|
|
||||||
|
**Decision**: `token_version` integer column on `User`, incremented on password change.
|
||||||
|
|
||||||
|
**Rationale**: no Redis, no external session store. A single integer per user provides immediate token invalidation with zero dependencies. The version is included in the JWT payload (`ver` field) and validated on every request.
|
||||||
|
|
||||||
|
**Trade-off**: revocation is per-user, not per-token. Logging out one device still lets other devices use their tokens until the common password is changed. This is acceptable for a single-user or small-team tool.
|
||||||
|
|
||||||
|
**Limitation**: `ver` absent from old tokens is treated as `ver=1` for backward compatibility. Existing valid tokens (before `token_version` was added) will therefore continue to work until the user changes their password.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## no-new-privileges absent from backend container
|
||||||
|
|
||||||
|
**Decision**: omit `security_opt: no-new-privileges:true` for the backend service.
|
||||||
|
|
||||||
|
**Rationale**: the discovery feature uses `subprocess.run(["ping", ...])`. In `iputils-ping` on Debian, the ping binary has the file capability `cap_net_raw=ep`. For a non-root process to execute ping successfully, the `execve` call must be allowed to promote capabilities from the file's permitted set. `no-new-privileges` blocks this promotion, breaking the ping feature.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Ambient capabilities (Linux 4.3+): would require modifying the entrypoint to `prctl(PR_CAP_AMBIENT_RAISE, ...)` before exec. Adds complexity with no meaningful security gain for this threat model.
|
||||||
|
- SUID ping: `no-new-privileges` also blocks SUID.
|
||||||
|
- Replace subprocess ping with a Python ICMP implementation: significant refactor out of scope.
|
||||||
|
|
||||||
|
**Mitigations in place**: `cap_drop: ALL` + `cap_add: NET_RAW DAC_OVERRIDE` limits the container to only the capabilities needed. `DAC_OVERRIDE` is required because the `db_data/` bind-mount is owned by the host user; root without it cannot create SQLite journal files in that directory. Both capabilities are in Docker's default set, so this is a net reduction from a standard container.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CORS default to `*`
|
||||||
|
|
||||||
|
**Decision**: `ALLOWED_ORIGINS` defaults to `"*"` for backward compatibility.
|
||||||
|
|
||||||
|
**Rationale**: the application is designed for same-origin access via Nginx (browser → Nginx → FastAPI), so CORS headers are not required for normal operation. However, changing the default to empty (no CORS) would silently break integrations where users access the API from a different origin. The default `"*"` maintains existing behavior while making it configurable.
|
||||||
|
|
||||||
|
**Production recommendation**: set `ALLOWED_ORIGINS=` (empty) if the backend is only accessed through the Nginx proxy, or set it to the specific domain if cross-origin access is needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## In-memory rate limiting
|
||||||
|
|
||||||
|
**Decision**: Python `dict[str, list[float]]` protected by `threading.Lock`, no Redis.
|
||||||
|
|
||||||
|
**Rationale**: Redis adds operational complexity (another service, volume, potential failure mode). For a single-process Uvicorn deployment behind Nginx, in-memory rate limiting is sufficient. The Nginx `limit_req` module provides the primary protection at the network edge; the Python layer adds defense-in-depth.
|
||||||
|
|
||||||
|
**Limitations**:
|
||||||
|
- State is lost on container restart (acceptable: a restart already breaks all active sessions).
|
||||||
|
- Does not work across multiple workers. Uvicorn defaults to a single worker; if scaled, the Nginx layer remains effective.
|
||||||
|
- IP tracking sees the Nginx container's IP (Docker internal), not the real client IP, unless `X-Real-IP` is forwarded to the backend. The Nginx `limit_req` uses the real client IP correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## APP_UID / APP_GID build args
|
||||||
|
|
||||||
|
**Decision**: configurable container UID/GID via build args, defaulting to 1000.
|
||||||
|
|
||||||
|
**Rationale**: the backend bind-mounts `./db_data:/app/data`. For the non-root container user to write to this directory, the container UID must match the host directory owner. On most Linux systems, the first non-root user is UID 1000. The build args allow customisation without Dockerfile changes.
|
||||||
|
|
||||||
|
**Alternative**: use an entrypoint that `chown`s the directory at runtime (current implementation via `entrypoint.sh`). This ensures compatibility regardless of UID mismatch, at the cost of a brief root execution before dropping to `appuser`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## nginx-unprivileged for frontend container
|
||||||
|
|
||||||
|
**Decision**: use `nginxinc/nginx-unprivileged:alpine` as the frontend base image.
|
||||||
|
|
||||||
|
**Rationale**: the standard `nginx:alpine` image requires `CAP_CHOWN` and `CAP_SETUID` at startup — the master process (root) chowns temp directories and forks workers as `nginx` user (UID 101). With `cap_drop: ALL`, both syscalls fail. Two alternative approaches were tried and abandoned:
|
||||||
|
- `USER nginx` in the Dockerfile: BusyBox `sed` does not support `\s` in basic regex mode, so the PID path substitution silently failed; the entrypoint scripts also need root.
|
||||||
|
- `gosu appuser` in the backend: `CAP_SETUID` is dropped, so `gosu` cannot switch UIDs.
|
||||||
|
|
||||||
|
`nginxinc/nginx-unprivileged:alpine` is maintained by nginx Inc. and pre-configures nginx to run entirely as UID 101 without needing `CAP_CHOWN` or `CAP_SETUID`. The entire process tree (master + workers) runs as nginx, making `cap_drop: ALL` compatible with zero extra capabilities on the frontend.
|
||||||
|
|
||||||
|
**Trade-off**: adds a dependency on a third-party image (`nginxinc/nginx-unprivileged`) instead of the official `nginx` image. The image is maintained by nginx Inc. itself, so the trust model is equivalent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ping and NET_RAW capability
|
||||||
|
|
||||||
|
**Decision**: retain `cap_add: NET_RAW` on the backend container.
|
||||||
|
|
||||||
|
**Rationale**: the discovery feature (`/api/discovery/ping`, `/api/discovery/scan`) uses ICMP ping, which requires `CAP_NET_RAW`. This is the minimum capability needed and is explicitly documented. The capability is dropped from the frontend container which has no need for it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SQLite without foreign key enforcement
|
||||||
|
|
||||||
|
**Status**: known limitation, not addressed in this phase.
|
||||||
|
|
||||||
|
SQLite does not enforce foreign keys by default. Enabling `PRAGMA foreign_keys=ON` per connection via a SQLAlchemy event was out of scope for this phase. The application code manually handles cascades (explicit DELETE before device removal). This is documented in SEC-FIX-013 as a future fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future security improvements (not scheduled)
|
||||||
|
|
||||||
|
| ID | Topic |
|
||||||
|
|----|-------|
|
||||||
|
| SEC-FIX-006 | Scope discovery scan to inventory CIDRs, validate IPs |
|
||||||
|
| SEC-FIX-007 | Pydantic field constraints on business models |
|
||||||
|
| SEC-FIX-008 | HTTP security headers (CSP, X-Content-Type-Options, etc.) |
|
||||||
|
| SEC-FIX-009 | Document TLS / reverse-proxy contract more explicitly |
|
||||||
|
| SEC-FIX-012 | Structured audit logging |
|
||||||
|
| SEC-FIX-013 | SQLite foreign key enforcement |
|
||||||
|
| SEC-FIX-014 | `npm audit` / `pip-audit` in CI |
|
||||||
|
| SEC-FIX-015 | Import JSON schema validation and size limits |
|
||||||
|
| SEC-FIX-016 | Remove `v-html` from App.vue |
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Security Decisions — Phase 3
|
||||||
|
|
||||||
|
Date: 2026-05-06
|
||||||
|
|
||||||
|
Ce document enregistre les arbitrages pris lors de la phase 3, les alternatives écartées et les limitations acceptées.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-006 — Cap global discovery
|
||||||
|
|
||||||
|
**Décision** : `MAX_HOSTS_TOTAL = 4096` (4 × /22).
|
||||||
|
|
||||||
|
**Alternatives écartées** :
|
||||||
|
- Cap par nombre de targets : ne bloque pas 1 seul /11 découpé en CIDRs /22.
|
||||||
|
- Cap plus bas (1024 global) : trop restrictif pour les déploiements multi-VLAN légitimes.
|
||||||
|
|
||||||
|
**Limitation** : le cap s'applique à la requête, pas à l'utilisateur ou à la session. Un utilisateur authentifié peut lancer N requêtes consécutives. Mitigation acceptable : l'endpoint est protégé par authentification et nécessite `CAP_NET_RAW`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-007 — Validation Pydantic : périmètre choisi
|
||||||
|
|
||||||
|
**Décision** : validateurs sur les champs à domaine fini (type, virt_type, couleur, CIDR, IP, URL). Pas de validation sur `description` au-delà d'une longueur max.
|
||||||
|
|
||||||
|
**Alternatives écartées** :
|
||||||
|
- Liste noire de caractères dans `name`/`description` : SQLAlchemy paramétrise toutes les requêtes → pas de risque SQL injection. La liste noire ajouterait une friction sans gain de sécurité.
|
||||||
|
- Validation de `vlan_id` contre les VLANs existants dans l'interface (unicité déjà enforced en base).
|
||||||
|
|
||||||
|
**Limite acceptée** : `name` peut contenir des caractères Unicode arbitraires — ce n'est pas un vecteur d'injection dans ce contexte (pas de rendu HTML côté serveur, pas de shell).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-008 — Content-Security-Policy
|
||||||
|
|
||||||
|
**Décision** : `style-src 'self' 'unsafe-inline'` conservé.
|
||||||
|
|
||||||
|
**Pourquoi** : Vue 3 utilise des attributs `style=""` inline (ex: `style="display:none"` pour `v-show`, styles dynamiques sur les chips de couleur VLAN). Ces attributs sont contrôlés par `style-src`. Supprimer `'unsafe-inline'` casserait l'app sans migration vers CSS classes ou nonces.
|
||||||
|
|
||||||
|
**Alternative écartée** : nonces CSP pour les styles inline — Vite ne génère pas de nonces en production, nécessiterait un middleware serveur dynamique incompatible avec Nginx statique.
|
||||||
|
|
||||||
|
**Décision** : `frame-ancestors 'none'` + `X-Frame-Options: DENY` — redondant mais nécessaire pour les navigateurs qui n'implémentent pas CSP `frame-ancestors`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-012 — Logs d'audit : format et activation
|
||||||
|
|
||||||
|
**Décision** : JSON one-line via `logging.getLogger("audit")`, stdout.
|
||||||
|
|
||||||
|
**Pourquoi** : stdlib uniquement, pas de dépendance (structlog, loguru écartes). Docker collecte stdout. Le format JSON permet le parsing par Loki/ELK sans transformation.
|
||||||
|
|
||||||
|
**Activation** : uvicorn hérite de la config logging Python. Pour filtrer uniquement les événements d'audit : `--log-config` ou configuration Python logging dans main.py (non ajouté : hors périmètre de cette phase).
|
||||||
|
|
||||||
|
**Limitation** : les logs ne sont pas persistants entre redémarrages sans volume dédié ou système de log externe. Acceptable pour un déploiement self-hosted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-013 — PRAGMA foreign_keys=ON
|
||||||
|
|
||||||
|
**Décision** : listener sur l'événement `connect` de SQLAlchemy — s'applique à toutes les connexions, y compris les connexions de test.
|
||||||
|
|
||||||
|
**Attention** : la migration `_migrate_vlan_nullable()` dans `main.py` désactive temporairement `PRAGMA foreign_keys=OFF` pour recréer la table vlans. Elle le réactive ensuite. Ce pattern est correct car le listener ne s'applique qu'à la connexion DBAPI sous-jacente, pas aux connexions SQLAlchemy qui wrappent.
|
||||||
|
|
||||||
|
**Conséquence sur SEC-FIX-017** : avec `foreign_keys=ON`, la table `links` (qui référençait `devices`) aurait bloqué les `DELETE` sur les équipements. C'est la raison pour laquelle `_migrate_drop_links_table()` est exécutée au démarrage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-015 — Limite import JSON côté frontend uniquement
|
||||||
|
|
||||||
|
**Décision** : limite de taille appliquée uniquement côté frontend (JavaScript, avant `file.text()`).
|
||||||
|
|
||||||
|
**Limitation connue** : cette protection est bypassable par un appel direct à l'API. Cependant :
|
||||||
|
- L'endpoint d'import JSON (`/api/devices/`, `/api/vlans/`) est protégé par authentification.
|
||||||
|
- FastAPI/uvicorn a une limite de corps par défaut (1 Mo via Starlette). Pour les requêtes individuelles, la validation Pydantic rejette les données invalides.
|
||||||
|
- La protection frontend couvre 99% des cas d'usage (import via UI).
|
||||||
|
|
||||||
|
**Alternative écartée** : limite de taille côté Nginx (`client_max_body_size`) — le scan de découverte et l'import sont deux flux différents ; limiter globalement pourrait bloquer des scans légitimes. Une limite fine par endpoint nécessiterait une configuration Nginx complexe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-016 — v-html
|
||||||
|
|
||||||
|
**Décision** : les icônes de navigation (■ ◆ ▣) sont des caractères Unicode hardcodés dans le `computed`. Pas de source externe, pas de traduction, pas d'interpolation.
|
||||||
|
|
||||||
|
**Risque résiduel** : nul — les caractères Unicode passent par `{{ }}` qui échappe automatiquement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-017 — Table `links` : DROP vs conservation
|
||||||
|
|
||||||
|
**Décision** : DROP de la table `links` via `_migrate_drop_links_table()` au démarrage.
|
||||||
|
|
||||||
|
**Pourquoi** : avec `PRAGMA foreign_keys=ON` (SEC-FIX-013), la table `links` (FK → `devices.id` sans ON DELETE CASCADE) aurait bloqué toute suppression d'équipement ayant des liens. Conserver la table sans le code applicatif pour la maintenir est une dette technique et un risque opérationnel.
|
||||||
|
|
||||||
|
**Données perdues** : les liens existants en base sont supprimés de façon irréversible. Acceptable car la fonctionnalité est retirée de l'interface — les données ne sont plus accessibles ni maintenables.
|
||||||
|
|
||||||
|
**Alternative écartée** : ajouter ON DELETE CASCADE sur la table existante. Aurait préservé les données mais complexifié la migration SQLite (pas d'ALTER CONSTRAINT) et laissé une table orpheline indéfiniment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Points restants non traités en Phase 3
|
||||||
|
|
||||||
|
| ID | Raison du report |
|
||||||
|
|----|-----------------|
|
||||||
|
| SEC-FIX-009 | Couvert : documentation TLS/HTTPS dans README (phases 2/3) |
|
||||||
|
| SEC-FIX-014 | Couvert : cytoscape supprimé en phase 2/3 |
|
||||||
|
| Audit log configuration fine | Hors périmètre — nécessite une décision d'infrastructure (Loki, ELK, etc.) |
|
||||||
|
| `no-new-privileges` backend | Contrainte technique inhérente à `cap_net_raw=ep` pour ping (documenté) |
|
||||||
|
| Rate limiting multi-worker | Mitigation en place via Nginx `limit_req` |
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Security Fixes Applied
|
||||||
|
|
||||||
|
Date: 2026-05-06
|
||||||
|
|
||||||
|
## P0-001 — SEC-FIX-001 : Changement de mot de passe obligatoire au premier login
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
Le compte admin par défaut utilisait `admin`/`admin`. N'importe qui ayant accès au port 8080 pouvait se connecter.
|
||||||
|
|
||||||
|
### Changements
|
||||||
|
|
||||||
|
**`backend/models.py`**
|
||||||
|
- Colonne `must_change_password BOOLEAN NOT NULL DEFAULT 0` ajoutée sur `User`.
|
||||||
|
|
||||||
|
**`backend/main.py`**
|
||||||
|
- Nouvelle migration idempotente `_migrate_users_must_change_password()` — `ALTER TABLE users ADD COLUMN must_change_password BOOLEAN NOT NULL DEFAULT 0` si absente.
|
||||||
|
- `_migrate_users()` : support de la variable d'environnement `INITIAL_ADMIN_PASSWORD`.
|
||||||
|
- Si définie → mot de passe fourni, `must_change_password = 0`.
|
||||||
|
- Sinon → mot de passe `admin`, `must_change_password = 1` (changement forcé au premier login).
|
||||||
|
- Routeurs `vlans`, `devices`, `links`, `discovery` protégés par `require_password_changed` au lieu de `get_current_user`.
|
||||||
|
|
||||||
|
**`backend/routers/auth.py`**
|
||||||
|
- Dépendance `require_password_changed` : retourne 403 `"Password change required"` si `must_change_password = 1`.
|
||||||
|
- `TokenOut` enrichi du champ `must_change_password: bool`.
|
||||||
|
- `login()` retourne `must_change_password`.
|
||||||
|
- `update_account()` passe `must_change_password` à `False` après changement de mot de passe réussi.
|
||||||
|
- `GET /me` retourne `must_change_password`.
|
||||||
|
|
||||||
|
**`frontend/src/auth.js`**
|
||||||
|
- Ref `_mustChange` persistée dans `localStorage('auth_mustchange')`.
|
||||||
|
- `mustChangePassword` computed exporté.
|
||||||
|
- `setAuth(token, username, mustChange)` / `clearAuth()` mis à jour.
|
||||||
|
|
||||||
|
**`frontend/src/App.vue`**
|
||||||
|
- Branche `v-else-if="mustChangePassword"` : affiche `<AccountModal :forced="true">` avant l'application.
|
||||||
|
- `onLogin` et `onAccountUpdated` propagent le flag ; `loadAll` n'est appelé que si `mustChangePassword` est faux.
|
||||||
|
- `onMounted` conditionnel sur `!mustChangePassword.value`.
|
||||||
|
|
||||||
|
**`frontend/src/components/AccountModal.vue`**
|
||||||
|
- Prop `forced` (Boolean, défaut `false`) :
|
||||||
|
- Masque le bouton ✕ et le bouton Annuler.
|
||||||
|
- Empêche la fermeture par clic sur l'overlay.
|
||||||
|
- Affiche une bannière d'avertissement.
|
||||||
|
- Rend le champ "Nouveau mot de passe" obligatoire.
|
||||||
|
|
||||||
|
**`frontend/src/i18n.js`**
|
||||||
|
- Clés ajoutées (fr/en/es) : `mustChangePasswordWarning`, `newPasswordRequired`.
|
||||||
|
|
||||||
|
### Utilisation
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml — optionnel
|
||||||
|
environment:
|
||||||
|
- INITIAL_ADMIN_PASSWORD=MonMotDePasseSecurisé
|
||||||
|
```
|
||||||
|
|
||||||
|
Sans cette variable, le premier login avec `admin`/`admin` force immédiatement le changement de mot de passe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1-001 — SEC-FIX-002 : Rate limiting sur POST /api/auth/login
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
Aucune limitation du nombre de tentatives de connexion — brute-force possible.
|
||||||
|
|
||||||
|
### Changements
|
||||||
|
|
||||||
|
**`frontend/rate_limit.conf`** (nouveau)
|
||||||
|
- `limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;`
|
||||||
|
|
||||||
|
**`frontend/Dockerfile`**
|
||||||
|
- `COPY rate_limit.conf /etc/nginx/conf.d/00_rate_limit.conf` — chargé en premier (ordre alphabétique).
|
||||||
|
|
||||||
|
**`frontend/nginx.conf`**
|
||||||
|
- Location spécifique `= /api/auth/login` avec `limit_req zone=login burst=5 nodelay` et `limit_req_status 429`.
|
||||||
|
|
||||||
|
**`backend/routers/auth.py`**
|
||||||
|
- Rate limiting in-memory (sans dépendance externe) :
|
||||||
|
- **Par IP** : 20 tentatives / 60 s
|
||||||
|
- **Par username** : 10 tentatives / 900 s (15 min)
|
||||||
|
- Compteurs réinitialisés après login réussi.
|
||||||
|
- `login()` extrait l'IP via `Request.client.host`.
|
||||||
|
- Retourne HTTP 429 `"Too many attempts, try again later"`.
|
||||||
|
|
||||||
|
**`frontend/src/components/LoginPage.vue`**
|
||||||
|
- Gère le status 429 → affiche `t('tooManyAttempts')`.
|
||||||
|
|
||||||
|
**`frontend/src/i18n.js`**
|
||||||
|
- Clé ajoutée : `tooManyAttempts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1-002 — SEC-FIX-003 : Validation serveur des entrées sur PUT /api/auth/account
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
Aucune validation sur `new_username` et `new_password` — mots de passe vides ou noms d'utilisateurs invalides acceptés.
|
||||||
|
|
||||||
|
### Changements
|
||||||
|
|
||||||
|
**`backend/routers/auth.py`**
|
||||||
|
- `_validate_new_password(password)` : min 8 caractères, au moins une lettre et un chiffre. Lève 400 avec code `password_too_short` ou `password_too_weak`.
|
||||||
|
- `_validate_new_username(username)` : `[a-zA-Z0-9._-]{1,64}`. Lève 400 avec code `username_invalid`.
|
||||||
|
- `update_account()` appelle ces fonctions avant modification.
|
||||||
|
|
||||||
|
**`frontend/src/components/AccountModal.vue`**
|
||||||
|
- Validation client miroir (évite un aller-retour réseau pour les cas évidents).
|
||||||
|
- Mapping des codes d'erreur backend → clés i18n : `password_too_short`, `password_too_weak`, `username_invalid`.
|
||||||
|
|
||||||
|
**`frontend/src/i18n.js`**
|
||||||
|
- Clés ajoutées (fr/en/es) : `passwordTooShort`, `passwordTooWeak`, `usernameInvalid`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
| Fichier | Fixes |
|
||||||
|
|---------|-------|
|
||||||
|
| `backend/models.py` | P0-001 |
|
||||||
|
| `backend/main.py` | P0-001 |
|
||||||
|
| `backend/routers/auth.py` | P0-001, P1-001, P1-002 |
|
||||||
|
| `frontend/rate_limit.conf` | P1-001 (nouveau) |
|
||||||
|
| `frontend/Dockerfile` | P1-001 |
|
||||||
|
| `frontend/nginx.conf` | P1-001 |
|
||||||
|
| `frontend/src/auth.js` | P0-001 |
|
||||||
|
| `frontend/src/i18n.js` | P0-001, P1-001, P1-002 |
|
||||||
|
| `frontend/src/App.vue` | P0-001 |
|
||||||
|
| `frontend/src/components/LoginPage.vue` | P0-001, P1-001 |
|
||||||
|
| `frontend/src/components/AccountModal.vue` | P0-001, P1-002 |
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Security Fixes Applied — Phase 2
|
||||||
|
|
||||||
|
Date: 2026-05-06
|
||||||
|
|
||||||
|
## SEC-FIX-001 — Bootstrap admin et rattrapage des bases existantes
|
||||||
|
|
||||||
|
### Problème résiduel (après phase 1)
|
||||||
|
La migration `_migrate_users_must_change_password()` ajoutait la colonne avec `DEFAULT 0`. Un compte admin existant avec le mot de passe par défaut "admin" n'était donc pas forcé à changer.
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`backend/main.py`**
|
||||||
|
- Nouvelle migration `_migrate_users_token_version()` — ajout de `token_version INTEGER NOT NULL DEFAULT 1` si absente.
|
||||||
|
- Nouvelle migration `_migrate_force_admin_password_change()` : vérifie si l'admin a `must_change_password=0` et si son hash correspond au mot de passe "admin". Si oui, pose `must_change_password=1`. Idempotent, s'exécute à chaque démarrage.
|
||||||
|
- `_migrate_users()` : mise à jour pour inclure `token_version` dans le CREATE TABLE initial.
|
||||||
|
- Ordre de démarrage mis à jour : `_migrate_users_token_version()` puis `_migrate_force_admin_password_change()` avant `_migrate_users()`.
|
||||||
|
|
||||||
|
**`backend/tests/conftest.py`** (nouveau)
|
||||||
|
- Configure `DATABASE_URL` vers une base temporaire avant tout import applicatif.
|
||||||
|
- `SECRET_KEY` fixé pour les tests.
|
||||||
|
|
||||||
|
**`backend/tests/test_auth.py`** (nouveau)
|
||||||
|
- Tests SEC-FIX-001 : base vide (must_change=1), CRUD bloqué avant changement, CRUD autorisé après, rattrapage admin existant, admin avec mot de passe personnalisé non touché, `INITIAL_ADMIN_PASSWORD`.
|
||||||
|
- Tests SEC-FIX-004 : ancien token rejeté après changement, nouveau token valide, backward compat token sans `ver`, token avec mauvaise version.
|
||||||
|
- Tests SEC-FIX-003 : validation mot de passe (trop court, sans chiffre, sans lettre, valide), username (invalide, trop long, valide), mauvais mot de passe courant.
|
||||||
|
- Tests SEC-FIX-002 : rate limit IP (429), rate limit username (429), reset après login réussi.
|
||||||
|
|
||||||
|
**`backend/requirements-test.txt`** (nouveau)
|
||||||
|
- `pytest>=7.4`, `httpx>=0.25`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-004 — Invalidation de session après changement de mot de passe
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
JWT valable 7 jours, pas d'invalidation après changement de mot de passe.
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`backend/models.py`**
|
||||||
|
- Colonne `token_version = Column(Integer, nullable=False, default=1, server_default="1")` ajoutée à `User`.
|
||||||
|
|
||||||
|
**`backend/routers/auth.py`**
|
||||||
|
- `TOKEN_EXPIRE_HOURS = 24` (réduit de 7 jours à 24 heures).
|
||||||
|
- `create_token(username, version)` : payload inclut `{ ver: version }`.
|
||||||
|
- `get_current_user` : vérifie `payload["ver"] == user.token_version`. Rejette avec 401 si différent. Rétrocompatibilité : `ver` absent dans le payload est traité comme `ver=1`.
|
||||||
|
- `update_account` : incrémente `current_user.token_version` lors d'un changement de mot de passe.
|
||||||
|
- `login` : transmet `user.token_version` au `create_token`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-005 — CORS configurable
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
`allow_origins=["*"]` hardcodé.
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`backend/main.py`**
|
||||||
|
- Lecture de `ALLOWED_ORIGINS` env var (défaut `"*"` pour rétrocompatibilité).
|
||||||
|
- `""` désactive le middleware CORS (même origine via proxy).
|
||||||
|
- Valeur CSV : liste d'origines explicites.
|
||||||
|
|
||||||
|
**`.env.example`** (nouveau)
|
||||||
|
- Documentation de `ALLOWED_ORIGINS` avec exemples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-010 — Sécurisation du secret JWT
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
`secret_key.txt` créé sans permissions restrictives. Pas de `.gitignore`.
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`backend/routers/auth.py`**
|
||||||
|
- Création du fichier secret via `os.open(..., os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)` pour imposer des permissions 0600 dès la création (owner read/write uniquement).
|
||||||
|
|
||||||
|
**`.gitignore`** (nouveau)
|
||||||
|
- Ignore `db_data/`, `*.db`, `.env`, `__pycache__/`, `node_modules/`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-011 — Durcissement des conteneurs
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
Conteneurs lancés en root sans contraintes de capabilities ni healthchecks.
|
||||||
|
|
||||||
|
### Corrections appliquées (Phase 2 initiale)
|
||||||
|
|
||||||
|
**`frontend/Dockerfile`**
|
||||||
|
- `npm install` → `npm ci` (build reproductible, respecte le lockfile).
|
||||||
|
- Image de base finale : `nginxinc/nginx-unprivileged:alpine` — nginx s'exécute entièrement en UID 101 sans nécessiter `CAP_CHOWN` ni `CAP_SETUID`. Compatible avec `cap_drop: ALL`.
|
||||||
|
- `EXPOSE 8080` (port non-privilégié, correspondant à la config nginx).
|
||||||
|
|
||||||
|
**`frontend/nginx.conf`**
|
||||||
|
- `listen 80` → `listen 8080` (port non-privilégié, ne nécessite pas CAP_NET_BIND_SERVICE).
|
||||||
|
|
||||||
|
**`docker-compose.yml`**
|
||||||
|
- `frontend` : `cap_drop: ALL`, `security_opt: no-new-privileges:true`, port `8080:8080`, healthcheck wget, `depends_on: backend: condition: service_healthy`.
|
||||||
|
- Suppression de la section `volumes: db_data:` (bind mount, pas de volume nommé).
|
||||||
|
|
||||||
|
**Approches abandonnées (Phase 2 initiale)**
|
||||||
|
- `gosu` + `appuser` pour le backend : `cap_drop: ALL` supprime `CAP_SETUID` → `gosu` ne peut pas changer d'utilisateur depuis le processus uvicorn après démarrage.
|
||||||
|
|
||||||
|
### Correction complémentaire — backend non-root via `user:` Compose
|
||||||
|
|
||||||
|
**Problème résiduel** : le backend tournait en UID 0 (root) faute de mécanisme interne pour changer d'utilisateur. La capability `DAC_OVERRIDE` avait été ajoutée comme contournement (accès en écriture au bind-mount), mais les fichiers `db_data/` étaient créés root:root sur l'hôte.
|
||||||
|
|
||||||
|
**Solution** : la directive `user:` de Docker Compose démarre le processus directement sous l'UID/GID cible, sans nécessiter `CAP_SETUID` ni gosu.
|
||||||
|
|
||||||
|
**`docker-compose.yml`**
|
||||||
|
- `backend` : ajout de `user: "${DOCKER_UID:-1000}:${DOCKER_GID:-1000}"`.
|
||||||
|
- `cap_add` : suppression de `DAC_OVERRIDE` (inutile — le processus est propriétaire du bind-mount).
|
||||||
|
- `cap_add` conserve `NET_RAW` (ICMP ping).
|
||||||
|
- `no-new-privileges` reste omis : ping utilise les file capabilities (`cap_net_raw=ep`) ; `no-new-privileges` supprime le bit effective du fichier, empêchant le sous-processus d'acquérir `CAP_NET_RAW` dans son ensemble effectif même si le parent le détient dans son ensemble permis.
|
||||||
|
|
||||||
|
**`.env.example`**
|
||||||
|
- `DOCKER_UID` / `DOCKER_GID` documentés avec procédure `id -u && id -g` et instruction `mkdir -p db_data`.
|
||||||
|
|
||||||
|
**`README.md`**
|
||||||
|
- Quick start : `docker compose --env-file .env up --build -d`.
|
||||||
|
- Configuration : `APP_UID`/`APP_GID` remplacés par `DOCKER_UID`/`DOCKER_GID`.
|
||||||
|
- Container hardening : table mise à jour (`DOCKER_UID:DOCKER_GID`), note `no-new-privileges` précisée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-018 — Documentation
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`README.md`** (nouveau)
|
||||||
|
- Quick start, configuration, sécurité (HTTPS, rotation de clé, durcissement conteneurs), persistance des données, développement.
|
||||||
|
|
||||||
|
**`.env.example`** (nouveau)
|
||||||
|
- `SECRET_KEY`, `INITIAL_ADMIN_PASSWORD`, `ALLOWED_ORIGINS`, `APP_UID`/`APP_GID` documentés avec exemples.
|
||||||
|
|
||||||
|
**`docs/backend.md`**
|
||||||
|
- Routeurs protégés : `get_current_user` → `require_password_changed`.
|
||||||
|
- Contrat login : ajout `must_change_password`.
|
||||||
|
- Contrat account : codes d'erreur normalisés.
|
||||||
|
- Modèle `User` : ajout `must_change_password`, `token_version`.
|
||||||
|
- Section Auth : mise à jour expiry, payload, invalidation, rate limiting.
|
||||||
|
- Section Migrations : ordre complet, description `_migrate_force_admin_password_change`.
|
||||||
|
|
||||||
|
**`docs/architecture.md`**
|
||||||
|
- Section Auth : schéma mis à jour (`mustChangePassword`, `AccountModal :forced`).
|
||||||
|
- Section Docker Compose : non-root, cap_drop, suppression volume nommé.
|
||||||
|
|
||||||
|
**`CLAUDE.md`**
|
||||||
|
- Section Authentification : 24h, `token_version`, `must_change_password`, guard App.vue, `INITIAL_ADMIN_PASSWORD`.
|
||||||
|
- Section Migrations : liste complète des 7 migrations.
|
||||||
|
|
||||||
|
**`AGENTS.md`**
|
||||||
|
- Section Authentication : réécriture complète.
|
||||||
|
- Data Model : `User` mis à jour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
| Fichier | Fixes |
|
||||||
|
|---------|-------|
|
||||||
|
| `backend/database.py` | Support `DATABASE_URL` pour tests |
|
||||||
|
| `backend/models.py` | SEC-FIX-004 (`token_version`) |
|
||||||
|
| `backend/main.py` | SEC-FIX-001 (rattrapage), SEC-FIX-004 (migration), SEC-FIX-005 (CORS) |
|
||||||
|
| `backend/routers/auth.py` | SEC-FIX-004 (versioning), SEC-FIX-010 (0600), expiry 24h |
|
||||||
|
| `backend/Dockerfile` | SEC-FIX-011 (cap_drop, DAC_OVERRIDE) |
|
||||||
|
| `backend/requirements-test.txt` | Tests (nouveau) |
|
||||||
|
| `backend/tests/__init__.py` | Tests (nouveau) |
|
||||||
|
| `backend/tests/conftest.py` | Tests (nouveau) |
|
||||||
|
| `backend/tests/test_auth.py` | Tests SEC-FIX-001, 002, 003, 004 (nouveau) |
|
||||||
|
| `frontend/Dockerfile` | SEC-FIX-011 (nginxinc/nginx-unprivileged, npm ci) |
|
||||||
|
| `frontend/nginx.conf` | SEC-FIX-011 (port 8080) |
|
||||||
|
| `docker-compose.yml` | SEC-FIX-011 (cap_drop, healthchecks) |
|
||||||
|
| `.gitignore` | SEC-FIX-010 (nouveau) |
|
||||||
|
| `.env.example` | SEC-FIX-005, SEC-FIX-010, SEC-FIX-018 (nouveau) |
|
||||||
|
| `README.md` | SEC-FIX-018 (nouveau) |
|
||||||
|
| `docs/backend.md` | SEC-FIX-018 |
|
||||||
|
| `docs/architecture.md` | SEC-FIX-018 |
|
||||||
|
| `CLAUDE.md` | SEC-FIX-018 |
|
||||||
|
| `AGENTS.md` | SEC-FIX-018 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risques couverts
|
||||||
|
|
||||||
|
| Risque | Avant | Après |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| Bootstrap admin/admin sur instance existante | Non corrigé | `_migrate_force_admin_password_change()` force le changement au prochain démarrage |
|
||||||
|
| JWT valable 7 jours après compromission | Oui | 24h + invalidation immédiate par `token_version` |
|
||||||
|
| CORS permissif | Hardcodé `*` | Configurable via `ALLOWED_ORIGINS`, défaut `*` rétrocompatible |
|
||||||
|
| Secret JWT lisible (permissions) | Mode par défaut (0644 ou plus) | 0600 dès la création |
|
||||||
|
| Secrets dans le dépôt | Pas de `.gitignore` | `db_data/`, `.env`, `*.db` ignorés |
|
||||||
|
| Process frontend root | Oui (nginx master + workers en root) | `nginxinc/nginx-unprivileged` — tout le process tree en UID 101 |
|
||||||
|
| Process backend root | Oui | `user: DOCKER_UID:DOCKER_GID` — process = utilisateur hôte, fichiers `db_data/` non root-owned |
|
||||||
|
| Capabilities superflues | Aucun cap_drop | `cap_drop: ALL` sur les deux conteneurs ; backend ajoute `NET_RAW` uniquement |
|
||||||
|
| `DAC_OVERRIDE` exposée | Oui (contournement root) | Supprimée — inutile avec non-root propriétaire du bind-mount |
|
||||||
|
| Pas de healthchecks | Aucun | Ajoutés sur les deux services |
|
||||||
|
| Build frontend non reproductible | `npm install` | `npm ci` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Limitations restantes
|
||||||
|
|
||||||
|
- JWT stocké en `localStorage` (pas de cookie HttpOnly) — mitigation : expiry 24h + token versioning.
|
||||||
|
- Rate limiting backend en mémoire (perdu au redémarrage, ne se partage pas entre workers) — mitigation : Nginx `limit_req` couvre le cas multi-worker.
|
||||||
|
- `no-new-privileges` absent du backend : `no-new-privileges` supprime le bit effective des file capabilities ; le sous-processus ping ne peut pas acquérir `CAP_NET_RAW` dans son ensemble effectif même si le parent le détient en permis. Mitigation : le processus tourne désormais sous un UID non-privilégié.
|
||||||
|
- Politique de mot de passe minimale (`password1` passe) — pas de liste noire de mots de passe communs.
|
||||||
|
- Pas de logs structurés d'audit (SEC-FIX-012 non traité dans cette phase).
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# Security Fixes Applied — Phase 3
|
||||||
|
|
||||||
|
Date: 2026-05-06
|
||||||
|
|
||||||
|
## SEC-FIX-006 — Validation des entrées discovery
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
- `dns_server` acceptait n'importe quelle chaîne (risque d'injection dans le résolveur DNS)
|
||||||
|
- `/api/discovery/ping` acceptait n'importe quelle chaîne comme IP (transmise au sous-processus ping)
|
||||||
|
- Plusieurs targets pouvaient contourner le cap de 1024 : 5 × /22 = 5110 hôtes
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`backend/routers/discovery.py`**
|
||||||
|
- `ScanRequest.dns_server` : `field_validator` qui appelle `ipaddress.ip_address(v)` — rejette toute valeur non-IP (422)
|
||||||
|
- `PingRequest.ips` : `field_validator` qui valide chaque IP — rejette toute entrée malformée avant d'appeler le sous-processus (422)
|
||||||
|
- `MAX_HOSTS_TOTAL = 4096` : cap global sur la somme des hôtes de tous les targets — rejette si dépassé (400)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-007 — Validation Pydantic métier
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
Aucun validator sur les schémas métier : valeurs hors-domaine, CIDR invalides, couleurs arbitraires, URLs arbitraires, IPs invalides, types inconnus acceptés silencieusement.
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`backend/routers/vlans.py`**
|
||||||
|
- `vlan_id` : 1–4094 (norme 802.1Q)
|
||||||
|
- `name` : non vide, max 100 caractères (strip)
|
||||||
|
- `cidr` : `ipaddress.ip_network(strict=False)` si non vide
|
||||||
|
- `color` : regex `^#[0-9a-fA-F]{6}$`
|
||||||
|
|
||||||
|
**`backend/routers/devices.py`**
|
||||||
|
- `name` : non vide, max 100 caractères (strip)
|
||||||
|
- `description` : max 500 caractères
|
||||||
|
- `type` : enum des 18 types valides
|
||||||
|
- `virt_type` : enum `{null, baremetal, lxc, qemu}`
|
||||||
|
- `url` : `urlparse` — schéma `http`/`https` + netloc non vide
|
||||||
|
- `InterfaceCreate.name` : non vide, max 50 caractères
|
||||||
|
- `InterfaceCreate.ip_address` : `ipaddress.ip_address(v)` si non vide
|
||||||
|
|
||||||
|
Toutes les erreurs de validation retournent 422 (comportement standard FastAPI/Pydantic).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-008 — En-têtes HTTP sécurité Nginx
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
Aucun en-tête de sécurité HTTP : pas de CSP, pas de protection contre le MIME sniffing ou le framing.
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`frontend/nginx.conf`**
|
||||||
|
```
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
X-Frame-Options: DENY
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||||
|
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self';
|
||||||
|
```
|
||||||
|
|
||||||
|
Tous les en-têtes utilisent le flag `always` pour s'appliquer aussi aux réponses d'erreur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-009 — Documentation TLS/HTTPS
|
||||||
|
|
||||||
|
### Statut : CORRIGÉ EN PHASE 2/3
|
||||||
|
|
||||||
|
Documentation ajoutée au `README.md` lors de la session précédente :
|
||||||
|
- Section HTTPS avec exemple nginx reverse-proxy complet
|
||||||
|
- `docker-compose.override.yml` pour limiter l'exposition au loopback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-012 — Logs d'audit structurés
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
Aucune trace des événements d'authentification : connexions, échecs, changements de mot de passe.
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`backend/routers/auth.py`**
|
||||||
|
- Logger stdlib `logging.getLogger("audit")` — aucune dépendance supplémentaire
|
||||||
|
- Helper `_log_audit(event, **kw)` : émet une ligne JSON `{"event": ..., "ts": ..., ...}`
|
||||||
|
- Événements loggés :
|
||||||
|
- `auth.login.success` — username, ip
|
||||||
|
- `auth.login.failure` — username, ip
|
||||||
|
- `auth.login.rate_limited` — ip, username (si disponible), reason (`ip` | `username`)
|
||||||
|
- `auth.token_rejected` — username, reason (`user_not_found` | `version_mismatch`)
|
||||||
|
- `auth.account.password_changed` — username
|
||||||
|
- `auth.account.username_changed` — old_username, new_username
|
||||||
|
- `auth.account.bad_password` — username
|
||||||
|
|
||||||
|
Activation dans Docker via la config de logging uvicorn (stdout par défaut).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-013 — PRAGMA foreign_keys=ON
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
SQLite désactive les contraintes de clés étrangères par défaut. Les suppressions en cascade ne sont pas enforced.
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`backend/database.py`**
|
||||||
|
```python
|
||||||
|
@event.listens_for(Engine, "connect")
|
||||||
|
def _set_sqlite_pragma(dbapi_conn, _record):
|
||||||
|
if isinstance(dbapi_conn, sqlite3.Connection):
|
||||||
|
cursor = dbapi_conn.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
Le listener s'exécute sur chaque nouvelle connexion SQLite, y compris les connexions de test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-014 — Dépendance `cytoscape` non utilisée
|
||||||
|
|
||||||
|
### Statut : CORRIGÉ EN PHASE 2
|
||||||
|
|
||||||
|
`cytoscape` supprimé de `frontend/package.json` lors de la session précédente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-015 — Import JSON sans limite de taille ni validation de schéma
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
Un fichier JSON de plusieurs gigaoctets pouvait être chargé en mémoire. Aucune validation du schéma avant traitement.
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`frontend/src/App.vue`**
|
||||||
|
- Vérification `file.size > 5 * 1024 * 1024` avant `file.text()` — rejette avec message localisé
|
||||||
|
- Validation de schéma :
|
||||||
|
- Le JSON doit être un objet (pas un tableau, pas null)
|
||||||
|
- `vlans` et `devices`, si présents, doivent être des tableaux
|
||||||
|
|
||||||
|
**`frontend/src/i18n.js`**
|
||||||
|
- Clé `importTooLarge` ajoutée (fr, en, es)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-016 — `v-html` dans App.vue
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
`v-html` utilisé pour injecter des entités HTML (`■`, `◆`, `▣`) dans les boutons de navigation. Risque XSS si la source venait à être dynamique.
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`frontend/src/App.vue`**
|
||||||
|
- Entités HTML remplacées par leurs équivalents Unicode directs (`■`, `◆`, `▣`)
|
||||||
|
- `v-html="tab.icon"` remplacé par `{{ tab.icon }}` (interpolation texte — échappe automatiquement)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEC-FIX-017 — Nettoyage du code orphelin Links (backend)
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
La vue Liens avait été retirée du frontend en phase 2/3, mais le backend conservait :
|
||||||
|
- `backend/routers/links.py` — router toujours enregistré dans main.py
|
||||||
|
- `class Link` dans `models.py` — ORM orphelin
|
||||||
|
- Référence explicite à `models.Link` dans `delete_device` (devices.py)
|
||||||
|
- La table `links` en base — avec FK vers `devices`, ce qui bloquerait les suppressions d'équipements avec `PRAGMA foreign_keys=ON`
|
||||||
|
|
||||||
|
### Corrections appliquées
|
||||||
|
|
||||||
|
**`backend/routers/links.py`** — Supprimé
|
||||||
|
|
||||||
|
**`backend/models.py`** — Classe `Link` supprimée
|
||||||
|
|
||||||
|
**`backend/routers/devices.py`** — Suppression de la requête `db.query(models.Link).filter(...).delete()` dans `delete_device`
|
||||||
|
|
||||||
|
**`backend/main.py`**
|
||||||
|
- Import `links` retiré
|
||||||
|
- `app.include_router(links.router, ...)` retiré
|
||||||
|
- Nouvelle migration `_migrate_drop_links_table()` : DROP TABLE links si elle existe (avec `PRAGMA foreign_keys=OFF/ON` pour éviter les erreurs FK pendant la migration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
| Fichier | Fix(es) |
|
||||||
|
|---------|---------|
|
||||||
|
| `backend/database.py` | SEC-FIX-013 |
|
||||||
|
| `backend/models.py` | SEC-FIX-017 |
|
||||||
|
| `backend/main.py` | SEC-FIX-017 |
|
||||||
|
| `backend/routers/auth.py` | SEC-FIX-012 |
|
||||||
|
| `backend/routers/vlans.py` | SEC-FIX-007 |
|
||||||
|
| `backend/routers/devices.py` | SEC-FIX-007, SEC-FIX-017 |
|
||||||
|
| `backend/routers/discovery.py` | SEC-FIX-006 |
|
||||||
|
| `backend/routers/links.py` | SEC-FIX-017 (supprimé) |
|
||||||
|
| `backend/tests/test_validation.py` | Tests SEC-FIX-006, 007, 013, 017 (nouveau) |
|
||||||
|
| `frontend/nginx.conf` | SEC-FIX-008 |
|
||||||
|
| `frontend/src/App.vue` | SEC-FIX-015, SEC-FIX-016 |
|
||||||
|
| `frontend/src/i18n.js` | SEC-FIX-015 |
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Plan d'action securite pour Claude Code
|
||||||
|
|
||||||
|
Objectif: corriger les ecarts de securite de base identifies dans `SECURITY_AUDIT_BASE.md`, sans refonte fonctionnelle inutile. Chaque tache est atomique et doit etre livree avec tests.
|
||||||
|
|
||||||
|
## SEC-FIX-001
|
||||||
|
|
||||||
|
Priorite: P0
|
||||||
|
Fichier(s) a modifier: `backend/main.py`, `backend/routers/auth.py`, `docker-compose.yml`, `.env.example`, `CLAUDE.md`, `AGENTS.md`, `docs/backend.md`, `docs/extending.md`
|
||||||
|
Probleme: compte de bootstrap `admin/admin` cree automatiquement au premier demarrage, sans changement force.
|
||||||
|
Correction attendue: conserver le bootstrap seulement si un changement de mot de passe est force au premier login. Variante plus robuste: remplacer le mot de passe fixe par une initialisation sure via variable `INITIAL_ADMIN_PASSWORD` ou secret Docker obligatoire si aucun utilisateur n'existe.
|
||||||
|
Risque couvert: compromission immediate d'une instance neuve.
|
||||||
|
Critere d'acceptation: une base vide peut creer `admin/admin` uniquement avec un etat `must_change_password`; tant que le mot de passe n'est pas change, seuls les endpoints necessaires au changement de compte sont accessibles. Si l'option `INITIAL_ADMIN_PASSWORD` est retenue, `admin/admin` n'est plus cree.
|
||||||
|
Tests recommandes: test unitaire de `_migrate_users()`; test premier login `admin/admin` redirige vers changement obligatoire; test acces CRUD refuse avant changement; test acces CRUD autorise apres changement.
|
||||||
|
|
||||||
|
## SEC-FIX-002
|
||||||
|
|
||||||
|
Priorite: P1
|
||||||
|
Fichier(s) a modifier: `backend/routers/auth.py`, `backend/rate_limit.py` (nouveau si necessaire), `frontend/nginx.conf`, `backend/requirements.txt`
|
||||||
|
Probleme: aucune limitation de tentatives sur `/api/auth/login`.
|
||||||
|
Correction attendue: ajouter une limitation par IP et par username avec fenetre temporelle, ou configurer `limit_req` Nginx plus une protection API. Les erreurs doivent rester generiques.
|
||||||
|
Risque couvert: bruteforce et credential stuffing.
|
||||||
|
Critere d'acceptation: apres N echecs rapproches, les tentatives suivantes recoivent une reponse de limitation temporaire; une connexion legitime fonctionne apres expiration de la fenetre.
|
||||||
|
Tests recommandes: tests API simulant echecs successifs, reset apres fenetre, non-enumeration des comptes.
|
||||||
|
|
||||||
|
## SEC-FIX-003
|
||||||
|
|
||||||
|
Priorite: P1
|
||||||
|
Fichier(s) a modifier: `backend/routers/auth.py`, `frontend/src/components/AccountModal.vue`, `frontend/src/i18n.js`
|
||||||
|
Probleme: `new_password` et `new_username` ne sont pas valides robustement cote serveur.
|
||||||
|
Correction attendue: imposer une longueur minimale de mot de passe, refuser les mots de passe evidents, borner/normaliser le username, retourner des messages localisables.
|
||||||
|
Risque couvert: prise de compte par mot de passe faible et donnees auth incoherentes.
|
||||||
|
Critere d'acceptation: les mots de passe trop courts/faibles sont refuses cote API; le frontend affiche une erreur claire; les usernames invalides sont refuses.
|
||||||
|
Tests recommandes: tests API pour password vide, court, faible, valide; username vide, trop long, caracteres invalides, doublon.
|
||||||
|
|
||||||
|
## SEC-FIX-004
|
||||||
|
|
||||||
|
Priorite: P1
|
||||||
|
Fichier(s) a modifier: `backend/routers/auth.py`, `backend/models.py`, `backend/main.py`, `frontend/src/auth.js`, `frontend/src/api.js`, `frontend/src/components/LoginPage.vue`, `frontend/src/components/AccountModal.vue`, `frontend/nginx.conf`
|
||||||
|
Probleme: JWT stocke en `localStorage`, duree 7 jours, pas d'invalidation apres changement de mot de passe.
|
||||||
|
Correction attendue: migrer vers cookie `HttpOnly`, `Secure`, `SameSite=Lax` ou `Strict`; ajouter version de session/token dans la base; invalider les anciens tokens apres changement de mot de passe. Si cookie impossible immediatement, reduire l'expiration et ajouter une version de session.
|
||||||
|
Risque couvert: vol/rejeu de token apres XSS ou poste compromis.
|
||||||
|
Critere d'acceptation: le token n'est plus lisible par JavaScript dans le mode cible; un changement de mot de passe invalide les tokens precedents; logout supprime la session/cookie.
|
||||||
|
Tests recommandes: tests login/me/logout, changement de mot de passe puis ancien token refuse, verification flags `Set-Cookie`.
|
||||||
|
|
||||||
|
## SEC-FIX-005
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
Fichier(s) a modifier: `backend/main.py`, `.env.example`, `docker-compose.yml`, `docs/backend.md`
|
||||||
|
Probleme: CORS ouvert a toutes origines alors que l'application est prevue en local derriere reverse-proxy.
|
||||||
|
Correction attendue: documenter que le backend reste interne et que le navigateur accede a `/api/` en meme origine via reverse-proxy. Supprimer CORS si inutile, ou ajouter `ALLOWED_ORIGINS` configurable pour les cas ou une origine externe est necessaire.
|
||||||
|
Risque couvert: publication accidentelle de l'API interne ou configuration dangereuse si l'auth migre vers cookies.
|
||||||
|
Critere d'acceptation: le mode recommande n'expose pas directement le backend; la doc explique quand CORS est necessaire; si `ALLOWED_ORIGINS` est ajoute, une origine non autorisee ne recoit pas d'en-tetes CORS permissifs.
|
||||||
|
Tests recommandes: test API avec headers `Origin` autorisee/non autorisee si CORS conserve; verification de la configuration Compose/reverse-proxy documentee.
|
||||||
|
|
||||||
|
## SEC-FIX-006
|
||||||
|
|
||||||
|
Priorite: P1
|
||||||
|
Fichier(s) a modifier: `backend/routers/discovery.py`, `frontend/src/components/DiscoveryModal.vue`, `frontend/src/api.js`, `frontend/src/i18n.js`
|
||||||
|
Probleme: `/scan` et `/ping` acceptent des cibles arbitraires et insuffisamment limitees.
|
||||||
|
Correction attendue: valider `ips` avec `ipaddress.ip_address`, valider `dns_server`, limiter le nombre total d'IP par requete, limiter le nombre de targets, autoriser uniquement les CIDR presents en base ou explicitement configures, ajouter rate limit.
|
||||||
|
Risque couvert: SSRF/reconnaissance interne et deni de service par surcharge de scans.
|
||||||
|
Critere d'acceptation: IP/CIDR/DNS invalides refuses; targets hors perimetre refusees; total d'adresses plafonne; les scans legitimes de VLAN configures fonctionnent.
|
||||||
|
Tests recommandes: tests API `ping` liste vide, liste trop grande, IP invalide, IP valide; tests `scan` CIDR trop large, target hors inventaire, DNS invalide, cas nominal.
|
||||||
|
|
||||||
|
## SEC-FIX-007
|
||||||
|
|
||||||
|
Priorite: P1
|
||||||
|
Fichier(s) a modifier: `backend/routers/devices.py`, `backend/routers/vlans.py`, `backend/routers/links.py`, `frontend/src/components/DeviceManager.vue`, `frontend/src/components/VlanManager.vue`, `frontend/src/components/TopologyGraph.vue`, `frontend/src/i18n.js`
|
||||||
|
Probleme: schemas metier trop permissifs.
|
||||||
|
Correction attendue: ajouter contraintes Pydantic v2: `Field(max_length=...)`, enums/Literal pour device type, virt type et link type, validation IP/CIDR, couleur hex, URL `http/https` uniquement. Ajouter `rel="noreferrer noopener"` sur liens externes.
|
||||||
|
Risque couvert: XSS via URL dangereuse, donnees incoherentes, injection future, DoS par champs enormes.
|
||||||
|
Critere d'acceptation: l'API refuse les valeurs hors enum, URL non http/https, CIDR/IP/couleur invalides et champs trop longs; l'UI affiche les erreurs.
|
||||||
|
Tests recommandes: tests API par champ invalide/valide; tests frontend de rendu lien externe avec `noreferrer`.
|
||||||
|
|
||||||
|
## SEC-FIX-008
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
Fichier(s) a modifier: `frontend/nginx.conf`, `frontend/index.html`, `frontend/src/App.vue` si ajustements CSP necessaires
|
||||||
|
Probleme: absence d'en-tetes de securite navigateur dans la configuration fournie; ils peuvent etre portes par le reverse-proxy frontal.
|
||||||
|
Correction attendue: fournir une configuration de reference reverse-proxy ou ajouter dans `frontend/nginx.conf` une CSP minimale, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`, et protection anti-framing via CSP `frame-ancestors` ou `X-Frame-Options`.
|
||||||
|
Risque couvert: impact XSS, clickjacking, sniffing MIME, fuite referrer.
|
||||||
|
Critere d'acceptation: les en-tetes sont presents sur `/` et assets; l'application build fonctionne sans violation CSP critique.
|
||||||
|
Tests recommandes: `curl -I` sur `/`; test navigateur console sans erreurs CSP bloquantes; build Vite.
|
||||||
|
|
||||||
|
## SEC-FIX-009
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
Fichier(s) a modifier: `docker-compose.yml`, `frontend/nginx.conf`, `docs/architecture.md`, `.env.example`
|
||||||
|
Probleme: TLS est delegue au reverse-proxy, mais le contrat de deploiement n'est pas documente.
|
||||||
|
Correction attendue: documenter explicitement que le mode actuel est local derriere reverse-proxy; fournir un exemple de deploiement derriere reverse-proxy TLS; recommander le bind `127.0.0.1:8080:80` si seul le proxy local doit consommer l'application.
|
||||||
|
Risque couvert: interception d'identifiants et tokens sur reseau non fiable.
|
||||||
|
Critere d'acceptation: la documentation exige HTTPS au niveau reverse-proxy pour tout acces non local; un exemple utilisable indique les variables et headers proxy necessaires; Compose n'encourage pas une exposition directe involontaire.
|
||||||
|
Tests recommandes: revue doc; test login via reverse proxy TLS si un profil est ajoute.
|
||||||
|
|
||||||
|
## SEC-FIX-010
|
||||||
|
|
||||||
|
Priorite: P1
|
||||||
|
Fichier(s) a modifier: `backend/routers/auth.py`, `docker-compose.yml`, `.gitignore`, `.env.example`, `docs/architecture.md`
|
||||||
|
Probleme: `data/secret_key.txt` est dans un bind mount projet et peut etre lisible localement.
|
||||||
|
Correction attendue: charger `SECRET_KEY` depuis secret Docker ou env obligatoire en production; creer les fichiers generes avec permissions `0600`; ignorer `db_data/`; documenter rotation de cle.
|
||||||
|
Risque couvert: fuite de cle JWT et forge de tokens.
|
||||||
|
Critere d'acceptation: nouveau fichier secret cree en `0600`; `db_data/` ignore; production documentee avec secret externe; absence de secret reel dans exemples.
|
||||||
|
Tests recommandes: test unitaire ou integration verifiant permissions; verification `.gitignore`; demarrage avec `SECRET_KEY`.
|
||||||
|
|
||||||
|
## SEC-FIX-011
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
Fichier(s) a modifier: `backend/Dockerfile`, `frontend/Dockerfile`, `docker-compose.yml`
|
||||||
|
Probleme: conteneurs lances sans durcissement explicite.
|
||||||
|
Correction attendue: executer backend et frontend en non-root si compatible, ajouter `cap_drop: [ALL]`, garder `NET_RAW` uniquement backend, `security_opt: no-new-privileges:true`, healthchecks, limites ressources, filesystem readonly avec volumes temporaires necessaires.
|
||||||
|
Risque couvert: impact accru en cas de compromission de conteneur.
|
||||||
|
Critere d'acceptation: les services demarrent et fonctionnent avec utilisateur non-root et privileges reduits; ping reste fonctionnel si active.
|
||||||
|
Tests recommandes: `docker compose up --build -d`, test login/CRUD/ping, verification `docker compose ps`, inspection utilisateur/capabilities.
|
||||||
|
|
||||||
|
## SEC-FIX-012
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
Fichier(s) a modifier: `backend/routers/auth.py`, `backend/routers/devices.py`, `backend/routers/vlans.py`, `backend/routers/links.py`, `backend/routers/discovery.py`, `frontend/nginx.conf`
|
||||||
|
Probleme: absence de logs securite structures.
|
||||||
|
Correction attendue: journaliser login reussi/echec, changement username/password, scans, imports/creates/deletes, erreurs de validation et rate limits, sans jamais logger mots de passe/tokens.
|
||||||
|
Risque couvert: detection et investigation insuffisantes.
|
||||||
|
Critere d'acceptation: chaque evenement sensible produit un log structure avec action, resultat, username, IP client fiable, compteurs utiles.
|
||||||
|
Tests recommandes: tests API capturant logs; verification absence de secrets dans logs.
|
||||||
|
|
||||||
|
## SEC-FIX-013
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
Fichier(s) a modifier: `backend/database.py`, `backend/models.py`, `backend/routers/devices.py`, `backend/routers/vlans.py`, `backend/routers/links.py`
|
||||||
|
Probleme: SQLite n'enforce pas les foreign keys.
|
||||||
|
Correction attendue: activer `PRAGMA foreign_keys=ON` sur chaque connexion via event SQLAlchemy; ajuster cascades/ondelete si necessaire.
|
||||||
|
Risque couvert: incoherence et corruption logique des donnees.
|
||||||
|
Critere d'acceptation: creation d'une interface avec `device_id` inexistant echoue; suppression VLAN/device conserve le comportement fonctionnel attendu.
|
||||||
|
Tests recommandes: tests DB d'integrite FK; tests suppression VLAN/device/link.
|
||||||
|
|
||||||
|
## SEC-FIX-014
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
Fichier(s) a modifier: `frontend/Dockerfile`, `frontend/package.json`, `frontend/package-lock.json`, `backend/requirements.txt`, documentation CI
|
||||||
|
Probleme: supply chain incomplete, `npm install`, dependance `cytoscape` inutilisee.
|
||||||
|
Correction attendue: remplacer `npm install` par `npm ci`; supprimer `cytoscape` si vraiment inutilise; ajouter scripts/documentation pour `npm audit` et audit Python; conserver compatibilite passlib/bcrypt.
|
||||||
|
Risque couvert: composants vulnerables/obsoletes et builds non reproductibles.
|
||||||
|
Critere d'acceptation: build Docker frontend utilise le lockfile; dependances inutilisees supprimees; procedure SCA documentee.
|
||||||
|
Tests recommandes: `docker compose build frontend`; `npm run build`; audit dependances dans CI ou local.
|
||||||
|
|
||||||
|
## SEC-FIX-015
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
Fichier(s) a modifier: `frontend/src/App.vue`, `backend/routers/devices.py`, `backend/routers/vlans.py`, `frontend/src/i18n.js`
|
||||||
|
Probleme: import JSON sans schema, limites ni bilan d'erreurs.
|
||||||
|
Correction attendue: limiter taille fichier/nombre d'objets, valider structure avant import, afficher un resume des erreurs, ne pas ignorer silencieusement les echecs.
|
||||||
|
Risque couvert: deni de service local, import incoherent, donnees dangereuses passant par CRUD.
|
||||||
|
Critere d'acceptation: fichier trop gros refuse; schema invalide refuse; import partiel affiche les erreurs; import valide fonctionne.
|
||||||
|
Tests recommandes: tests frontend unitaires pour parsing/validation; tests manuels import valide/invalide/gros.
|
||||||
|
|
||||||
|
## SEC-FIX-016
|
||||||
|
|
||||||
|
Priorite: P3
|
||||||
|
Fichier(s) a modifier: `frontend/src/App.vue`
|
||||||
|
Probleme: `v-html` utilise pour des icones statiques de navigation.
|
||||||
|
Correction attendue: remplacer par interpolation texte ou composants d'icones sans HTML injecte.
|
||||||
|
Risque couvert: reduction defense-in-depth du risque XSS futur.
|
||||||
|
Critere d'acceptation: aucun `v-html` ne reste dans `App.vue`; les icones de navigation s'affichent toujours.
|
||||||
|
Tests recommandes: `rg "v-html" frontend/src`; test visuel rapide.
|
||||||
|
|
||||||
|
## SEC-FIX-017
|
||||||
|
|
||||||
|
Priorite: P3
|
||||||
|
Fichier(s) a modifier: `backend/main.py`, `frontend/nginx.conf`, `docker-compose.yml`, `docs/backend.md`
|
||||||
|
Probleme: `/api/health` est public; c'est acceptable pour un healthcheck local/reverse-proxy mais doit rester assume.
|
||||||
|
Correction attendue: documenter que l'endpoint est volontairement public et minimal, ou le restreindre au reverse-proxy si souhaite. Ne pas exposer d'informations de version.
|
||||||
|
Risque couvert: exposition inutile d'informations de disponibilite si le proxy publie trop largement les endpoints internes.
|
||||||
|
Critere d'acceptation: decision documentee; endpoint ne retourne toujours aucun detail sensible; si restreint, le healthcheck Compose/reverse-proxy fonctionne.
|
||||||
|
Tests recommandes: `curl /api/health` selon mode attendu; test healthcheck Docker.
|
||||||
|
|
||||||
|
## SEC-FIX-018
|
||||||
|
|
||||||
|
Priorite: P2
|
||||||
|
Fichier(s) a modifier: `README.md`, `.env.example`, `CLAUDE.md`, `AGENTS.md`, `docs/architecture.md`, `docs/backend.md`
|
||||||
|
Probleme: documentation securite et configuration d'environnement incompletes.
|
||||||
|
Correction attendue: ajouter README et `.env.example` sans secrets reels; documenter `SECRET_KEY`, `INITIAL_ADMIN_PASSWORD`, `ALLOWED_ORIGINS`, TLS, stockage des donnees, rotation de cle, sauvegarde/restauration et profil dev/prod.
|
||||||
|
Risque couvert: mauvaises configurations deployees par defaut.
|
||||||
|
Critere d'acceptation: un nouvel operateur peut deployer sans identifiants par defaut ni secret genere dans le depot; les differences dev/prod sont explicites.
|
||||||
|
Tests recommandes: revue doc; demarrage Compose avec variables de `.env.example` adaptees.
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
# Revue securite apres corrections
|
||||||
|
|
||||||
|
Date: 2026-05-06
|
||||||
|
Base de comparaison: `SECURITY_AUDIT_BASE.md`, `SECURITY_FIX_PLAN_FOR_CLAUDE.md`, `SECURITY_FIXES_APPLIED.md` et code actuel.
|
||||||
|
Mode: revue statique locale, sans exploitation. Aucun fichier applicatif n'a ete modifie.
|
||||||
|
|
||||||
|
## Synthese
|
||||||
|
|
||||||
|
Les corrections declarees dans `SECURITY_FIXES_APPLIED.md` sont presentes dans le code pour les trois premiers sujets: changement force au premier login, rate limiting du login, validation minimale du changement de compte. Elles reduisent le risque principal de bootstrap et de bruteforce.
|
||||||
|
|
||||||
|
La correction SEC-FIX-001 reste partielle pour les installations deja creees avant la migration: l'ajout de colonne met `must_change_password` a `0` par defaut. Dans la base locale observee, l'utilisateur `admin` a `must_change_password=0`. Si ce compte utilise encore l'ancien mot de passe public, il ne sera pas force a changer. La documentation est egalement en retard sur le code: plusieurs fichiers decrivent encore `get_current_user` comme dependance des routeurs metier et ne documentent pas le nouveau flux `must_change_password`.
|
||||||
|
|
||||||
|
Verification effectuee:
|
||||||
|
- Parsing Python par AST: OK, 9 fichiers parses.
|
||||||
|
- Build frontend Vite vers `/tmp/topologie-vite-build`: OK.
|
||||||
|
- Aucun test automatise dedie n'a ete trouve dans `backend` ou `frontend`.
|
||||||
|
|
||||||
|
## SEC-FIX-001 - Changement de mot de passe obligatoire au premier login
|
||||||
|
|
||||||
|
Statut: partiellement corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `backend/models.py:6-12` ajoute `User.must_change_password`.
|
||||||
|
- `backend/main.py:78-110` cree `admin` avec `must_change_password=1` si `INITIAL_ADMIN_PASSWORD` est absent, et `0` si la variable est fournie.
|
||||||
|
- `backend/main.py:130-133` protege les routeurs metier avec `Depends(require_password_changed)`.
|
||||||
|
- `backend/routers/auth.py:97-100` refuse les routeurs proteges avec 403 si le changement est requis.
|
||||||
|
- `backend/routers/auth.py:149-172` remet `must_change_password` a `False` apres changement de mot de passe.
|
||||||
|
- `frontend/src/App.vue:3-11` affiche `AccountModal` forcee avant l'application si `mustChangePassword` est vrai.
|
||||||
|
- `frontend/src/components/AccountModal.vue:93-97` exige un nouveau mot de passe cote client en mode force.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- `backend/main.py:63-75` ajoute la colonne aux bases existantes avec `DEFAULT 0`; les comptes deja presents ne sont donc pas forces a changer.
|
||||||
|
- La base locale contient `admin|0` pour `must_change_password`.
|
||||||
|
- `docker-compose.yml` ne montre pas `INITIAL_ADMIN_PASSWORD`; `.env.example` est absent.
|
||||||
|
- `AGENTS.md`, `CLAUDE.md` et `docs/backend.md` decrivent encore l'ancien modele `Depends(get_current_user)` ou l'ancien contrat de login.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Ajouter une migration de rattrapage explicite pour les anciennes bases, ou documenter une commande de remediation obligatoire.
|
||||||
|
- Documenter `INITIAL_ADMIN_PASSWORD`, `must_change_password` et `require_password_changed` dans `AGENTS.md`, `CLAUDE.md`, `docs/*` et `.env.example`.
|
||||||
|
- Ajouter un test couvrant base vide, base existante et acces CRUD avant/apres changement.
|
||||||
|
|
||||||
|
## SEC-FIX-002 - Rate limiting sur POST /api/auth/login
|
||||||
|
|
||||||
|
Statut: corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `frontend/rate_limit.conf:1` definit `limit_req_zone`.
|
||||||
|
- `frontend/Dockerfile:10-11` copie `nginx.conf` et `rate_limit.conf`.
|
||||||
|
- `frontend/nginx.conf:6-13` applique `limit_req zone=login burst=5 nodelay` et retourne 429 sur `/api/auth/login`.
|
||||||
|
- `backend/routers/auth.py:43-76` implemente un rate limit in-memory par username et par IP.
|
||||||
|
- `backend/routers/auth.py:132-146` applique ces controles avant verification du mot de passe.
|
||||||
|
- `frontend/src/components/LoginPage.vue:69-74` affiche un message dedie sur 429.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Le rate limit backend est en memoire: il est perdu au redemarrage et se fragmente avec plusieurs workers/process.
|
||||||
|
- `Request.client.host` voit souvent l'adresse du proxy Nginx, pas le client final; le controle IP backend peut donc devenir global derriere proxy. Le controle Nginx compense dans le deploiement Compose fourni.
|
||||||
|
- Les compteurs IP backend ne sont pas remis a zero apres login reussi.
|
||||||
|
- Aucun test automatise de fenetre, expiration ou non-enumeration n'a ete trouve.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Conserver le controle Nginx et soit documenter que le backend ne doit pas etre expose directement, soit gerer proprement `X-Forwarded-For` depuis un proxy de confiance.
|
||||||
|
- Ajouter des tests API pour echecs successifs, expiration de fenetre et succes apres expiration.
|
||||||
|
|
||||||
|
## SEC-FIX-003 - Validation serveur des entrees sur PUT /api/auth/account
|
||||||
|
|
||||||
|
Statut: partiellement corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `backend/routers/auth.py:103-117` valide username et mot de passe.
|
||||||
|
- `backend/routers/auth.py:157-165` applique ces validations avant modification.
|
||||||
|
- `frontend/src/components/AccountModal.vue:91-117` applique une validation miroir.
|
||||||
|
- `frontend/src/i18n.js:160-165`, `310-315`, `460-465` contient les messages localises.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- La politique mot de passe reste minimale: `password1` ou `admin123` respectent la regle lettre+chiffre et longueur 8.
|
||||||
|
- `AccountUpdate` accepte les champs optionnels sans contraintes Pydantic; la robustesse depend des validations manuelles.
|
||||||
|
- Les erreurs `Username already taken` et `Current password is incorrect` ne sont pas sous forme de codes stables et restent moins homogenes que les nouveaux codes.
|
||||||
|
- Aucun test API dedie n'a ete trouve.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Renforcer la politique avec une liste de mots de passe evidents/refuses et des contraintes Pydantic (`Field`, `StringConstraints`) en plus des helpers.
|
||||||
|
- Normaliser/trim le username avant comparaison et stockage.
|
||||||
|
- Ajouter des tests pour mot de passe faible evident, username invalide, doublon et cas valide.
|
||||||
|
|
||||||
|
## SEC-FIX-004 - JWT localStorage, expiration et invalidation
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `frontend/src/auth.js:3-17` stocke toujours le JWT dans `localStorage`.
|
||||||
|
- `frontend/src/api.js:6-9` continue d'envoyer un bearer token lu par JavaScript.
|
||||||
|
- `backend/routers/auth.py:38-80` conserve une expiration de 7 jours et un payload `{sub, exp}` sans version de session.
|
||||||
|
- `backend/routers/auth.py:162-168` change le mot de passe sans invalider les tokens deja emis.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Vol/rejeu de JWT en cas de XSS, extension compromise ou poste compromis.
|
||||||
|
- Ancien token toujours valide apres changement de mot de passe jusqu'a expiration.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Migrer vers cookie `HttpOnly`, `Secure`, `SameSite` ou, a minima, ajouter `session_version`/`token_version` en base et reduire l'expiration.
|
||||||
|
|
||||||
|
## SEC-FIX-005 - CORS configurable ou supprime
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `backend/main.py:122-127` conserve `allow_origins=["*"]`, `allow_methods=["*"]`, `allow_headers=["*"]`.
|
||||||
|
- `.env.example` est absent.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Configuration permissive si le backend est publie directement ou si l'auth migre vers cookies.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Supprimer CORS en mode meme origine ou ajouter `ALLOWED_ORIGINS` explicite.
|
||||||
|
- Documenter que le backend doit rester interne.
|
||||||
|
|
||||||
|
## SEC-FIX-006 - Encadrement discovery scan/ping
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `backend/routers/discovery.py:72-86` accepte `ips: list[str]` sans validation IP ni limite de taille.
|
||||||
|
- `backend/routers/discovery.py:89-129` valide seulement le format CIDR et limite chaque reseau a 1024 hotes, mais pas le total de targets ni le perimetre autorise.
|
||||||
|
- `backend/routers/discovery.py:52-60` accepte un serveur DNS fourni librement.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Reconnaissance reseau authentifiee, surcharge CPU/process et DNS arbitraire.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Valider IP/CIDR/DNS, plafonner le total par requete, autoriser seulement les CIDR d'inventaire ou une allowlist, et ajouter rate limit.
|
||||||
|
|
||||||
|
## SEC-FIX-007 - Validation stricte des modeles metier
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `backend/routers/devices.py:11-33`, `backend/routers/vlans.py:11-15`, `backend/routers/links.py:11-15` utilisent encore des `str` libres sans bornes, enums ni validation URL/IP/CIDR/couleur.
|
||||||
|
- Les liens externes ont `rel="noopener"` mais pas `noreferrer`: `frontend/src/components/DeviceManager.vue:137`, `frontend/src/components/TopologyGraph.vue:35`, `56`, `104`, `142`.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Donnees incoherentes, champs enormes, URL dangereuses et surface XSS future.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Ajouter contraintes Pydantic v2, enums serveur, validation URL `http/https`, IP/CIDR et couleur hex.
|
||||||
|
- Remplacer `rel="noopener"` par `rel="noreferrer noopener"`.
|
||||||
|
|
||||||
|
## SEC-FIX-008 - En-tetes de securite HTTP
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `frontend/nginx.conf:1-25` ne definit pas CSP, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `X-Frame-Options` ou `frame-ancestors`.
|
||||||
|
- La seule configuration ajoutee concerne `limit_req`: `frontend/nginx.conf:6-8`.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Durcissement navigateur absent dans la configuration fournie.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Ajouter les en-tetes dans Nginx ou fournir une configuration reverse-proxy de reference verifiee.
|
||||||
|
|
||||||
|
## SEC-FIX-009 - Contrat TLS/reverse-proxy
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `docker-compose.yml:14-15` expose toujours `"8080:80"` sur toutes les interfaces.
|
||||||
|
- Aucune `.env.example` ni documentation de bind local/reverse proxy TLS n'a ete ajoutee.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Exposition HTTP involontaire sur un LAN non fiable avec interception d'identifiants/tokens.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Documenter HTTPS obligatoire hors local et recommander `127.0.0.1:8080:80` pour un proxy local.
|
||||||
|
- Fournir un exemple reverse-proxy TLS.
|
||||||
|
|
||||||
|
## SEC-FIX-010 - Gestion du secret JWT
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `backend/routers/auth.py:20-33` cree toujours `data/secret_key.txt` via `open(..., "w")` sans permissions explicites.
|
||||||
|
- `docker-compose.yml:6-7` monte toujours `./db_data:/app/data`.
|
||||||
|
- `.gitignore` et `.env.example` sont absents.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Fuite locale de cle JWT et forge de tokens jusqu'a rotation.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Creer le fichier avec permissions `0600`, ignorer `db_data/`, documenter rotation et supporter un secret Docker/env obligatoire en production.
|
||||||
|
|
||||||
|
## SEC-FIX-011 - Durcissement conteneurs
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `backend/Dockerfile:1-8` et `frontend/Dockerfile:8-12` ne definissent pas d'utilisateur non-root.
|
||||||
|
- `docker-compose.yml:1-20` ne definit pas `cap_drop`, `security_opt`, `read_only`, limites ressources ni healthchecks.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Impact augmente en cas de compromission d'un conteneur.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Passer en non-root, ajouter `cap_drop: [ALL]`, garder `NET_RAW` seulement pour le backend, ajouter `no-new-privileges`, healthchecks et volumes temporaires explicites.
|
||||||
|
|
||||||
|
## SEC-FIX-012 - Logs securite structures
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- Aucun logging structure n'est present dans `backend/routers/auth.py`, `devices.py`, `vlans.py`, `links.py` ou `discovery.py`.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Investigation limitee en cas d'incident: login, scans, suppressions et rate limits ne sont pas tracables.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Ajouter logs structures sans secrets pour authentification, changements de compte, CRUD, scans et rate limits.
|
||||||
|
|
||||||
|
## SEC-FIX-013 - Foreign keys SQLite
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `backend/database.py:7-10` cree l'engine sans event SQLAlchemy pour `PRAGMA foreign_keys=ON`.
|
||||||
|
- Les `ForeignKey` restent declares dans `backend/models.py`, mais SQLite ne les enforce pas sans pragma par connexion.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Incoherences relationnelles possibles via evolutions futures, migrations ou erreurs applicatives.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Activer `PRAGMA foreign_keys=ON` via event SQLAlchemy et tester suppressions/creation d'interfaces/liens invalides.
|
||||||
|
|
||||||
|
## SEC-FIX-014 - Supply chain et builds reproductibles
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `frontend/Dockerfile:3-4` utilise toujours `npm install` au lieu de `npm ci`.
|
||||||
|
- `frontend/package.json` contient encore `cytoscape`.
|
||||||
|
- Aucune procedure d'audit dependances n'a ete ajoutee.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Builds moins reproductibles et dependances inutiles/vulnerables plus difficiles a suivre.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Utiliser `npm ci`, supprimer `cytoscape` s'il est inutilise, documenter `npm audit` et l'audit Python en conservant le pin `bcrypt==3.2.2`.
|
||||||
|
|
||||||
|
## SEC-FIX-015 - Import JSON borne et valide
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `frontend/src/App.vue:198-220` lit le fichier complet, parse sans schema, importe en boucle et ignore silencieusement les erreurs par `.catch(() => {})`.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Import incoherent, erreurs masquees et deni de service local par fichier volumineux.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Ajouter limite de taille, validation de schema, compteur d'erreurs et retour utilisateur detaille.
|
||||||
|
|
||||||
|
## SEC-FIX-016 - Suppression de v-html
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `frontend/src/App.vue:30` utilise encore `v-html="tab.icon"`.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Risque XSS defense-in-depth si une future icone devient dynamique ou non controlee.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Remplacer par texte, composants d'icones ou SVG statique sans `v-html`.
|
||||||
|
|
||||||
|
## SEC-FIX-017 - Endpoint health public documente ou restreint
|
||||||
|
|
||||||
|
Statut: partiellement corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `backend/main.py:136-138` expose toujours `/api/health` publiquement avec seulement `{"status": "ok"}`, sans detail sensible.
|
||||||
|
- La decision de securite n'est pas documentee dans les fichiers lus.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Exposition inutile d'information de disponibilite si le proxy publie trop largement l'API.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Documenter explicitement que l'endpoint est public et minimal, ou le restreindre au proxy/healthcheck.
|
||||||
|
|
||||||
|
## SEC-FIX-018 - Documentation securite et environnement
|
||||||
|
|
||||||
|
Statut: non corrige
|
||||||
|
|
||||||
|
Preuve dans le code:
|
||||||
|
- `README.md` et `.env.example` sont absents.
|
||||||
|
- `AGENTS.md:66`, `CLAUDE.md:171` et `docs/backend.md:5-12` decrivent encore les routeurs metier avec `get_current_user` au lieu de `require_password_changed`.
|
||||||
|
- `docs/backend.md:28-36` ne reflete pas le nouveau champ `must_change_password` ni les erreurs de validation.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
- Operateur guide par une documentation obsolete, avec configuration dev/prod et secrets insuffisamment explicites.
|
||||||
|
|
||||||
|
Recommandation finale:
|
||||||
|
- Ajouter `README.md` et `.env.example`, puis mettre a jour `AGENTS.md`, `CLAUDE.md` et `docs/*` pour `INITIAL_ADMIN_PASSWORD`, `SECRET_KEY`, CORS, TLS, rotation de cle, sauvegarde/restauration et profils dev/prod.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Les trois corrections declarees sont globalement integrees, mais seules SEC-FIX-002 peut etre consideree corrigee avec des risques residuels acceptables pour le mode Compose actuel. SEC-FIX-001 et SEC-FIX-003 restent partielles. Les autres taches du plan ne sont pas encore appliquees.
|
||||||
|
|
||||||
|
Priorite recommandee:
|
||||||
|
1. Corriger la migration/strategie de rattrapage pour les comptes existants `admin`.
|
||||||
|
2. Mettre a jour la documentation et ajouter `.env.example`.
|
||||||
|
3. Ajouter des tests API pour auth, rate limit et changement force.
|
||||||
|
4. Continuer les corrections P1 restantes: JWT/session, discovery, validation metier et secret JWT.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Revue securite Phase 2
|
||||||
|
|
||||||
|
Date: 2026-05-06
|
||||||
|
Base: `SECURITY_REVIEW_AFTER_FIXES.md`, `SECURITY_FIXES_APPLIED_PHASE2.md`, code actuel.
|
||||||
|
Mode: revue statique locale + builds + controles cibles en conteneur. Aucun fichier applicatif n'a ete modifie.
|
||||||
|
|
||||||
|
## Synthese
|
||||||
|
|
||||||
|
Les corrections Phase 2 principales sont bien presentes dans le code actuel: rattrapage admin utilisant encore `admin`, `token_version` JWT, expiration 24h, CORS configurable, creation des nouvelles cles JWT en `0600`, build frontend reproductible via `npm ci`, conteneurs Compose avec privileges reduits, et documentation globalement enrichie.
|
||||||
|
|
||||||
|
Deux points importants restent a corriger:
|
||||||
|
|
||||||
|
- La suite de tests Phase 2 n'est pas reproductible telle que declaree: `backend/requirements-test.txt` laisse installer `httpx>=0.28`, incompatible avec le `TestClient` de Starlette utilise par FastAPI 0.104.1.
|
||||||
|
- SEC-FIX-010 ne corrige que les nouvelles creations de `secret_key.txt`; un fichier existant cree avant Phase 2 avec des permissions trop larges est relu tel quel, sans `chmod 0600`.
|
||||||
|
|
||||||
|
Verification executee:
|
||||||
|
|
||||||
|
- `npm run build` dans `frontend`: OK.
|
||||||
|
- `docker compose build`: OK.
|
||||||
|
- Controles conteneur: backend sous UID/GID `1000:1000`, frontend sous UID `101`, ping OK avec `cap_drop: ALL` + `cap_add: NET_RAW`, secret auto-genere en `0600`, bootstrap admin OK, rattrapage admin existant OK.
|
||||||
|
- `pytest -q tests` avec `requirements-test.txt` actuel: ECHEC, incompatibilite `httpx`.
|
||||||
|
- `pytest -q tests` avec `pytest>=7.4` et `httpx<0.28`: OK, 21 tests passes.
|
||||||
|
|
||||||
|
## Verification des corrections Phase 2
|
||||||
|
|
||||||
|
### SEC-FIX-001 - Bootstrap admin et rattrapage des bases existantes
|
||||||
|
|
||||||
|
Statut: corrige
|
||||||
|
|
||||||
|
Preuves:
|
||||||
|
|
||||||
|
- `backend/main.py:65-77` ajoute `must_change_password` aux bases existantes.
|
||||||
|
- `backend/main.py:80-92` ajoute `token_version`.
|
||||||
|
- `backend/main.py:95-111` force `must_change_password=1` si `admin` utilise encore le mot de passe bootstrap `admin`.
|
||||||
|
- `backend/main.py:114-146` cree une base neuve avec `admin/admin` + `must_change_password=1`, ou avec `INITIAL_ADMIN_PASSWORD` + `must_change_password=0`.
|
||||||
|
- `backend/main.py:179-183` protege les routeurs metier via `require_password_changed`.
|
||||||
|
- Controle conteneur: base neuve -> `admin 1 1`; ancienne base admin/admin avec `must_change_password=0` -> `must_change_password=1`; admin avec mot de passe personnalise -> non modifie.
|
||||||
|
|
||||||
|
Point de vigilance:
|
||||||
|
|
||||||
|
- `SECURITY_FIXES_APPLIED_PHASE2.md` dit que `_migrate_force_admin_password_change()` s'execute avant `_migrate_users()`. C'est vrai dans le code (`backend/main.py:149-155`) et correct pour les bases existantes. Pour une base neuve, le rattrapage ne s'applique pas car la table n'existe pas encore; le cas est couvert par `_migrate_users()`.
|
||||||
|
|
||||||
|
### SEC-FIX-004 - Invalidation de session apres changement de mot de passe
|
||||||
|
|
||||||
|
Statut: corrige
|
||||||
|
|
||||||
|
Preuves:
|
||||||
|
|
||||||
|
- `backend/models.py:13` ajoute `User.token_version`.
|
||||||
|
- `backend/routers/auth.py:44` reduit l'expiration JWT a 24 heures.
|
||||||
|
- `backend/routers/auth.py:84-90` inclut `ver` dans le JWT.
|
||||||
|
- `backend/routers/auth.py:93-107` rejette un token dont `ver` differe de `user.token_version`.
|
||||||
|
- `backend/routers/auth.py:175-180` incremente `token_version` lors d'un changement de mot de passe.
|
||||||
|
- Les tests d'invalidation passent quand la dependance `httpx` est pinnee correctement.
|
||||||
|
|
||||||
|
Limite restante:
|
||||||
|
|
||||||
|
- Le JWT reste stocke dans `localStorage` (`frontend/src/auth.js:3-17`) et lu par JavaScript (`frontend/src/api.js:6-9`). Phase 2 reduit la duree et invalide les anciennes sessions, mais ne supprime pas le risque de vol de token en cas de XSS.
|
||||||
|
|
||||||
|
### SEC-FIX-005 - CORS configurable
|
||||||
|
|
||||||
|
Statut: corrige avec risque residuel de configuration
|
||||||
|
|
||||||
|
Preuves:
|
||||||
|
|
||||||
|
- `backend/main.py:163-177` lit `ALLOWED_ORIGINS`, supporte `*`, chaine vide, ou CSV d'origines.
|
||||||
|
- `.env.example:25-35` documente la variable.
|
||||||
|
|
||||||
|
Risque residuel:
|
||||||
|
|
||||||
|
- Le defaut reste permissif: `ALLOWED_ORIGINS=*` dans `.env.example:35` et `docker-compose.yml:18`. C'est retrocompatible, mais une installation exposee directement garde CORS ouvert si l'operateur ne change rien.
|
||||||
|
|
||||||
|
### SEC-FIX-010 - Secret JWT
|
||||||
|
|
||||||
|
Statut: partiellement corrige
|
||||||
|
|
||||||
|
Preuves:
|
||||||
|
|
||||||
|
- `backend/routers/auth.py:31-38` cree les nouveaux secrets via `os.open(..., 0o600)`.
|
||||||
|
- Controle conteneur: secret auto-genere avec mode `0o600`.
|
||||||
|
- `.gitignore:1-8` ignore `db_data/`, `*.db`, `.env`.
|
||||||
|
|
||||||
|
Risque restant:
|
||||||
|
|
||||||
|
- `backend/routers/auth.py:27-28` lit un fichier existant sans verifier ni corriger ses permissions. Une installation ayant deja cree `db_data/secret_key.txt` avant Phase 2 avec un mode trop large conserve ce mode apres mise a jour.
|
||||||
|
|
||||||
|
Recommandation:
|
||||||
|
|
||||||
|
- Sur chargement du fichier, appliquer ou au minimum verifier `chmod 0600`.
|
||||||
|
- Documenter une remediation explicite: `chmod 600 db_data/secret_key.txt`.
|
||||||
|
|
||||||
|
### SEC-FIX-011 - Durcissement conteneurs
|
||||||
|
|
||||||
|
Statut: corrige pour le chemin Docker Compose fourni
|
||||||
|
|
||||||
|
Preuves:
|
||||||
|
|
||||||
|
- `docker-compose.yml:4-8` lance le backend sous `DOCKER_UID:DOCKER_GID`, avec `cap_drop: ALL` et `cap_add: NET_RAW` uniquement.
|
||||||
|
- `docker-compose.yml:35-41` ajoute un healthcheck backend.
|
||||||
|
- `docker-compose.yml:45-62` lance le frontend sur `8080`, avec `cap_drop: ALL`, `no-new-privileges` et healthcheck.
|
||||||
|
- `frontend/Dockerfile:4` utilise `npm ci`.
|
||||||
|
- `frontend/Dockerfile:8-12` utilise `nginxinc/nginx-unprivileged:alpine`.
|
||||||
|
- `frontend/nginx.conf:2` ecoute sur `8080`.
|
||||||
|
- Controle conteneur: backend `uid=1000 gid=1000`; frontend `uid=101(nginx)`; ping `127.0.0.1` OK avec la configuration de capabilities.
|
||||||
|
|
||||||
|
Limites:
|
||||||
|
|
||||||
|
- `backend/Dockerfile` ne definit pas `USER`; le non-root depend de Compose. Un lancement direct de l'image backend sans `--user` tournera en root.
|
||||||
|
- Le backend n'a pas `no-new-privileges`, par choix documente pour conserver le fonctionnement de `ping`.
|
||||||
|
|
||||||
|
## Regressions et nouveaux risques
|
||||||
|
|
||||||
|
### R1 - Tests Phase 2 non reproductibles avec `requirements-test.txt`
|
||||||
|
|
||||||
|
Severite: moyenne
|
||||||
|
|
||||||
|
`backend/requirements-test.txt:1-2` contient:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pytest>=7.4
|
||||||
|
httpx>=0.25
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans le conteneur backend actuel, cela installe un `httpx` recent. Resultat: tous les tests utilisant `TestClient(app)` echouent avec:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TypeError: Client.__init__() got an unexpected keyword argument 'app'
|
||||||
|
```
|
||||||
|
|
||||||
|
Cause: incompatibilite entre Starlette/FastAPI du projet et `httpx>=0.28`, qui a retire le raccourci `app=`.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Avec `requirements-test.txt` actuel: 1 failed, 20 errors.
|
||||||
|
- Avec `httpx<0.28`: 21 passed.
|
||||||
|
|
||||||
|
Correction recommandee:
|
||||||
|
|
||||||
|
- Pinner `httpx<0.28` dans `backend/requirements-test.txt`, ou mettre a jour FastAPI/Starlette de maniere controlee.
|
||||||
|
|
||||||
|
### R2 - Option Docker secret probablement fragile avec backend non-root
|
||||||
|
|
||||||
|
Severite: moyenne
|
||||||
|
|
||||||
|
`docker-compose.yml:31-34` documente une option de secret montee vers `/app/data/secret_key.txt` avec `mode: 0400`, pendant que le backend tourne sous `DOCKER_UID:DOCKER_GID` (`docker-compose.yml:4`). Selon le comportement exact de Docker Compose et du proprietaire du fichier source, un secret root-owned en `0400` peut devenir illisible par le backend non-root au demarrage.
|
||||||
|
|
||||||
|
Correction recommandee:
|
||||||
|
|
||||||
|
- Tester explicitement cette variante.
|
||||||
|
- Si elle est conservee, specifier `uid`/`gid` compatibles quand Compose le permet, ou supporter une variable `SECRET_KEY_FILE` pointant vers un chemin de secret lisible par l'utilisateur applicatif.
|
||||||
|
|
||||||
|
### R3 - Nouvelles cles protegees, anciennes cles non remediées
|
||||||
|
|
||||||
|
Severite: moyenne
|
||||||
|
|
||||||
|
Voir SEC-FIX-010. Le risque est particulierement important pour les installations deja deployees avant Phase 2, c'est-a-dire precisement le cas traite par les migrations de rattrapage admin.
|
||||||
|
|
||||||
|
## Incoherences documentation / code
|
||||||
|
|
||||||
|
- `docs/extending.md:70-71` recommande encore `dependencies=[Depends(get_current_user)]` pour un nouveau routeur. Le code actuel doit utiliser `require_password_changed` pour les routeurs metier.
|
||||||
|
- `docs/backend.md:5-12` dit "four router groups" mais l'extrait montre `vlans`, `devices`, `discovery` et omet `links`, pourtant enregistre dans `backend/main.py:182`.
|
||||||
|
- `docs/frontend.md:48-55` documente `linksApi`, mais `frontend/src/api.js` n'exporte pas `linksApi`.
|
||||||
|
- `docs/frontend.md:31` documente `setAuth(token, username)` alors que le code actuel attend `setAuth(token, username, mustChange)` (`frontend/src/auth.js:11`).
|
||||||
|
- `docs/frontend.md:114-121` decrit un guard `v-else` direct et des callbacks sans `mustChangePassword`; le code actuel a la branche forcee `AccountModal` (`frontend/src/App.vue:3-11`) et propage `mustChangePassword` (`frontend/src/App.vue:145-153`).
|
||||||
|
- `docs/architecture.md:54-57` dit encore que le stage final frontend est `nginx:alpine`; le code utilise `nginxinc/nginx-unprivileged:alpine` (`frontend/Dockerfile:8`).
|
||||||
|
- `docs/architecture.md:62-67` liste une sequence de demarrage backend obsolete et omet les migrations `_migrate_users_must_change_password`, `_migrate_users_token_version`, `_migrate_force_admin_password_change`.
|
||||||
|
- `AGENTS.md:207` parle d'un "Docker volume `db_data`"; le Compose actuel utilise un bind mount `./db_data:/app/data` (`docker-compose.yml:10-11`).
|
||||||
|
- `.env.example:5` contient un caractere corrompu dans le commentaire de section. Impact faible, mais a nettoyer.
|
||||||
|
- `SECURITY_FIXES_APPLIED_PHASE2.md` garde des traces de l'approche abandonnee (`APP_UID`/`APP_GID`, `DAC_OVERRIDE`, mentions backend Dockerfile) alors que le code final utilise `DOCKER_UID`/`DOCKER_GID` via Compose et a supprime `DAC_OVERRIDE`.
|
||||||
|
|
||||||
|
## Points herites confirmes ouverts
|
||||||
|
|
||||||
|
Ces points etaient deja signales dans `SECURITY_REVIEW_AFTER_FIXES.md` et restent ouverts dans le code actuel:
|
||||||
|
|
||||||
|
| ID | Etat actuel |
|
||||||
|
|---|---|
|
||||||
|
| SEC-FIX-006 | Discovery: `PingRequest.ips` non borne (`backend/routers/discovery.py:72-85`), DNS libre (`backend/routers/discovery.py:52-60`), limite par reseau mais pas limite totale multi-target (`backend/routers/discovery.py:93-107`). |
|
||||||
|
| SEC-FIX-007 | Validation metier faible: champs libres dans `devices.py`, `vlans.py`, `links.py`; URL/IP/CIDR/couleur/type non contraints cote serveur. Les `rel="noreferrer noopener"` sont en revanche corriges dans les liens frontend. |
|
||||||
|
| SEC-FIX-008 | En-tetes HTTP de securite absents dans `frontend/nginx.conf`: pas de CSP, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, ni politique frame. |
|
||||||
|
| SEC-FIX-009 | Le Compose expose encore `8080:8080` sur toutes les interfaces par defaut (`docker-compose.yml:45-46`). README documente HTTPS et bind loopback, mais ce n'est pas le defaut. |
|
||||||
|
| SEC-FIX-012 | Pas de logs d'audit structures dans les routeurs backend. |
|
||||||
|
| SEC-FIX-013 | SQLite foreign keys non forcees par connexion: pas d'event SQLAlchemy `PRAGMA foreign_keys=ON` dans `backend/database.py`. |
|
||||||
|
| SEC-FIX-014 | `cytoscape` a ete supprime de `frontend/package.json`, mais reste dans `frontend/package-lock.json:12` et dans le `node_modules` local comme dependance extraneous. Le lockfile doit etre regenere, et aucun audit dependances automatise n'est documente/integre. |
|
||||||
|
| SEC-FIX-015 | Import JSON frontend sans limite de taille ni schema, erreurs ignorees par `.catch(() => {})` (`frontend/src/App.vue:190-207`). |
|
||||||
|
| SEC-FIX-016 | `v-html` reste present pour les icones d'onglet (`frontend/src/App.vue:30`). Les valeurs sont hardcodees localement (`frontend/src/App.vue:139-143`), donc risque XSS faible aujourd'hui, mais surface inutile. |
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Phase 2 corrige effectivement les risques prioritaires annonces autour du bootstrap admin, de l'invalidation JWT, du CORS configurable, du secret JWT pour nouvelles installations et du durcissement Compose.
|
||||||
|
|
||||||
|
Avant de considerer Phase 2 comme stabilisee, corriger en priorite:
|
||||||
|
|
||||||
|
1. `backend/requirements-test.txt`: pinner `httpx<0.28` ou mettre a jour FastAPI/Starlette.
|
||||||
|
2. Remediation des permissions des secrets existants (`chmod 0600` au chargement ou migration).
|
||||||
|
3. Clarification/test de l'option Docker secret avec backend non-root.
|
||||||
|
4. Mise a jour des incoherences docs/code listees ci-dessus.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends iputils-ping \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN mkdir -p data
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.makedirs("data", exist_ok=True)
|
||||||
|
|
||||||
|
_DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./data/topology.db")
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
_DATABASE_URL,
|
||||||
|
connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(Engine, "connect")
|
||||||
|
def _set_sqlite_pragma(dbapi_conn, _record):
|
||||||
|
if isinstance(dbapi_conn, sqlite3.Connection):
|
||||||
|
cursor = dbapi_conn.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from sqlalchemy import text
|
||||||
|
from database import engine, Base
|
||||||
|
from routers import vlans, devices, discovery
|
||||||
|
from routers.auth import router as auth_router, get_current_user, require_password_changed
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_vlan_nullable():
|
||||||
|
"""Make vlans.vlan_id nullable (SQLite can't ALTER COLUMN, so recreate)."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if not conn.execute(text(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='vlans'"
|
||||||
|
)).fetchone():
|
||||||
|
return
|
||||||
|
cols = conn.execute(text("PRAGMA table_info(vlans)")).fetchall()
|
||||||
|
if not any(row[1] == 'vlan_id' and row[3] == 1 for row in cols):
|
||||||
|
return
|
||||||
|
conn.execute(text("PRAGMA foreign_keys=OFF"))
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE TABLE vlans_new (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
vlan_id INTEGER UNIQUE,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
cidr VARCHAR,
|
||||||
|
color VARCHAR
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
conn.execute(text("INSERT INTO vlans_new SELECT id, vlan_id, name, cidr, color FROM vlans"))
|
||||||
|
conn.execute(text("DROP TABLE vlans"))
|
||||||
|
conn.execute(text("ALTER TABLE vlans_new RENAME TO vlans"))
|
||||||
|
conn.commit()
|
||||||
|
conn.execute(text("PRAGMA foreign_keys=ON"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_device_virt_type():
|
||||||
|
"""Ajoute la colonne virt_type sur devices si absente."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if not conn.execute(text(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='devices'"
|
||||||
|
)).fetchone():
|
||||||
|
return
|
||||||
|
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(devices)")).fetchall()]
|
||||||
|
if 'virt_type' not in cols:
|
||||||
|
conn.execute(text("ALTER TABLE devices ADD COLUMN virt_type VARCHAR"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_device_url():
|
||||||
|
"""Ajoute la colonne url sur devices si absente."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if not conn.execute(text(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='devices'"
|
||||||
|
)).fetchone():
|
||||||
|
return
|
||||||
|
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(devices)")).fetchall()]
|
||||||
|
if 'url' not in cols:
|
||||||
|
conn.execute(text("ALTER TABLE devices ADD COLUMN url VARCHAR"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_users_must_change_password():
|
||||||
|
"""Ajoute la colonne must_change_password sur users si absente."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if not conn.execute(text(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||||
|
)).fetchone():
|
||||||
|
return
|
||||||
|
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(users)")).fetchall()]
|
||||||
|
if 'must_change_password' not in cols:
|
||||||
|
conn.execute(text(
|
||||||
|
"ALTER TABLE users ADD COLUMN must_change_password BOOLEAN NOT NULL DEFAULT 0"
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_users_token_version():
|
||||||
|
"""Ajoute la colonne token_version sur users si absente."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if not conn.execute(text(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||||
|
)).fetchone():
|
||||||
|
return
|
||||||
|
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(users)")).fetchall()]
|
||||||
|
if 'token_version' not in cols:
|
||||||
|
conn.execute(text(
|
||||||
|
"ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 1"
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_force_admin_password_change():
|
||||||
|
"""Force must_change_password=1 pour admin utilisant encore le mot de passe bootstrap."""
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if not conn.execute(text(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||||
|
)).fetchone():
|
||||||
|
return
|
||||||
|
row = conn.execute(text(
|
||||||
|
"SELECT hashed_password FROM users WHERE username='admin' AND must_change_password=0"
|
||||||
|
)).fetchone()
|
||||||
|
if row and pwd_context.verify("admin", row[0]):
|
||||||
|
conn.execute(text(
|
||||||
|
"UPDATE users SET must_change_password=1 WHERE username='admin'"
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_users():
|
||||||
|
"""Crée la table users et le compte admin par défaut si absents."""
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
initial_password = os.environ.get("INITIAL_ADMIN_PASSWORD", "")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
table_exists = conn.execute(text(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||||
|
)).fetchone()
|
||||||
|
if not table_exists:
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username VARCHAR NOT NULL UNIQUE,
|
||||||
|
hashed_password VARCHAR NOT NULL,
|
||||||
|
must_change_password BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
token_version INTEGER NOT NULL DEFAULT 1
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
conn.commit()
|
||||||
|
count = conn.execute(text("SELECT COUNT(*) FROM users")).fetchone()[0]
|
||||||
|
if count == 0:
|
||||||
|
if initial_password:
|
||||||
|
hashed = pwd_context.hash(initial_password)
|
||||||
|
must_change = 0
|
||||||
|
else:
|
||||||
|
hashed = pwd_context.hash("admin")
|
||||||
|
must_change = 1
|
||||||
|
conn.execute(
|
||||||
|
text("INSERT INTO users (username, hashed_password, must_change_password) VALUES ('admin', :h, :m)"),
|
||||||
|
{"h": hashed, "m": must_change},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_drop_links_table():
|
||||||
|
"""Supprime la table links (fonctionnalité retirée en phase 3). Idempotent."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("PRAGMA foreign_keys=OFF"))
|
||||||
|
if conn.execute(text(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='links'"
|
||||||
|
)).fetchone():
|
||||||
|
conn.execute(text("DROP TABLE links"))
|
||||||
|
conn.commit()
|
||||||
|
conn.execute(text("PRAGMA foreign_keys=ON"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
_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()
|
||||||
|
_migrate_users()
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI(title="Network Topology Manager")
|
||||||
|
|
||||||
|
# CORS — configurable via ALLOWED_ORIGINS env var (comma-separated).
|
||||||
|
# Default "*" for backward compatibility in a behind-proxy deployment.
|
||||||
|
# Production: set ALLOWED_ORIGINS="" to disable, or "https://yourdomain.com".
|
||||||
|
_allowed_origins_env = os.environ.get("ALLOWED_ORIGINS", "*")
|
||||||
|
if _allowed_origins_env.strip() == "*":
|
||||||
|
_origins = ["*"]
|
||||||
|
elif _allowed_origins_env.strip() == "":
|
||||||
|
_origins = []
|
||||||
|
else:
|
||||||
|
_origins = [o.strip() for o in _allowed_origins_env.split(",") if o.strip()]
|
||||||
|
|
||||||
|
if _origins:
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=_origins,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||||
|
app.include_router(vlans.router, prefix="/api/vlans", tags=["vlans"], dependencies=[Depends(require_password_changed)])
|
||||||
|
app.include_router(devices.router, prefix="/api/devices", tags=["devices"], dependencies=[Depends(require_password_changed)])
|
||||||
|
app.include_router(discovery.router, prefix="/api/discovery", tags=["discovery"], dependencies=[Depends(require_password_changed)])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=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")
|
||||||
|
|
||||||
|
|
||||||
|
class Vlan(Base):
|
||||||
|
__tablename__ = "vlans"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
vlan_id = Column(Integer, unique=True, nullable=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
cidr = Column(String, nullable=True, default="")
|
||||||
|
color = Column(String, default="#4A90D9")
|
||||||
|
|
||||||
|
interfaces = relationship("DeviceInterface", back_populates="vlan")
|
||||||
|
|
||||||
|
|
||||||
|
class Device(Base):
|
||||||
|
__tablename__ = "devices"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=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)
|
||||||
|
url = Column(String, nullable=True)
|
||||||
|
|
||||||
|
interfaces = relationship(
|
||||||
|
"DeviceInterface", back_populates="device", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceInterface(Base):
|
||||||
|
__tablename__ = "device_interfaces"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=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)
|
||||||
|
|
||||||
|
device = relationship("Device", back_populates="interfaces")
|
||||||
|
vlan = relationship("Vlan", back_populates="interfaces")
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pytest>=7.4
|
||||||
|
httpx>=0.25
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
pydantic==2.5.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
dnspython==2.4.2
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==3.2.2
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_SECRET_KEY_FILE = "data/secret_key.txt"
|
||||||
|
_audit = logging.getLogger("audit")
|
||||||
|
|
||||||
|
|
||||||
|
def _log_audit(event: str, **kw) -> None:
|
||||||
|
_audit.info(json.dumps({"event": event, "ts": datetime.now(timezone.utc).isoformat(), **kw}))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_secret_key() -> str:
|
||||||
|
env = os.environ.get("SECRET_KEY")
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
if os.path.exists(_SECRET_KEY_FILE):
|
||||||
|
return open(_SECRET_KEY_FILE).read().strip()
|
||||||
|
key = secrets.token_hex(32)
|
||||||
|
os.makedirs(os.path.dirname(_SECRET_KEY_FILE), exist_ok=True)
|
||||||
|
# Create with owner-only permissions (0600) to prevent other users from reading the key
|
||||||
|
fd = os.open(_SECRET_KEY_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.write(key)
|
||||||
|
except Exception:
|
||||||
|
os.close(fd)
|
||||||
|
raise
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
SECRET_KEY = _load_secret_key()
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
TOKEN_EXPIRE_HOURS = 24
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
# --- Rate limiting ---
|
||||||
|
_login_attempts: dict[str, list[float]] = {} # username → timestamps
|
||||||
|
_ip_attempts: dict[str, list[float]] = {} # ip → timestamps
|
||||||
|
_rate_lock = threading.Lock()
|
||||||
|
_USERNAME_WINDOW = 900 # 15 min
|
||||||
|
_USERNAME_MAX = 10
|
||||||
|
_IP_WINDOW = 60 # 1 min
|
||||||
|
_IP_MAX = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _check_username_rate_limit(username: str) -> None:
|
||||||
|
now = time.time()
|
||||||
|
with _rate_lock:
|
||||||
|
attempts = [t for t in _login_attempts.get(username, []) if now - t < _USERNAME_WINDOW]
|
||||||
|
if len(attempts) >= _USERNAME_MAX:
|
||||||
|
raise HTTPException(status_code=429, detail="Too many attempts, try again later")
|
||||||
|
attempts.append(now)
|
||||||
|
_login_attempts[username] = attempts
|
||||||
|
|
||||||
|
|
||||||
|
def _check_ip_rate_limit(ip: str) -> None:
|
||||||
|
now = time.time()
|
||||||
|
with _rate_lock:
|
||||||
|
attempts = [t for t in _ip_attempts.get(ip, []) if now - t < _IP_WINDOW]
|
||||||
|
if len(attempts) >= _IP_MAX:
|
||||||
|
raise HTTPException(status_code=429, detail="Too many attempts, try again later")
|
||||||
|
attempts.append(now)
|
||||||
|
_ip_attempts[ip] = attempts
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_login_attempts(username: str) -> None:
|
||||||
|
with _rate_lock:
|
||||||
|
_login_attempts.pop(username, None)
|
||||||
|
|
||||||
|
|
||||||
|
def create_token(username: str, version: int) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRE_HOURS)
|
||||||
|
return jwt.encode(
|
||||||
|
{"sub": username, "ver": version, "exp": expire},
|
||||||
|
SECRET_KEY,
|
||||||
|
algorithm=ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
token_ver: int = payload.get("ver", 1)
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Bearer"})
|
||||||
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
if not user:
|
||||||
|
_log_audit("auth.token_rejected", username=username, reason="user_not_found")
|
||||||
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
|
if (user.token_version or 1) != token_ver:
|
||||||
|
_log_audit("auth.token_rejected", username=username, reason="version_mismatch")
|
||||||
|
raise HTTPException(status_code=401, detail="Session expired, please log in again")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_password_changed(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
if current_user.must_change_password:
|
||||||
|
raise HTTPException(status_code=403, detail="Password change required")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
# --- Validation helpers ---
|
||||||
|
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_new_password(password: str) -> None:
|
||||||
|
if len(password) < 8:
|
||||||
|
raise HTTPException(status_code=400, detail="password_too_short")
|
||||||
|
if not re.search(r"[a-zA-Z]", password) or not re.search(r"[0-9]", password):
|
||||||
|
raise HTTPException(status_code=400, detail="password_too_weak")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_new_username(username: str) -> None:
|
||||||
|
if not _USERNAME_RE.match(username):
|
||||||
|
raise HTTPException(status_code=400, detail="username_invalid")
|
||||||
|
|
||||||
|
|
||||||
|
class TokenOut(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
username: str
|
||||||
|
must_change_password: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AccountUpdate(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_username: str | None = None
|
||||||
|
new_password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenOut)
|
||||||
|
def login(request: Request, form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
try:
|
||||||
|
_check_ip_rate_limit(client_ip)
|
||||||
|
except HTTPException:
|
||||||
|
_log_audit("auth.login.rate_limited", ip=client_ip, reason="ip")
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
_check_username_rate_limit(form.username)
|
||||||
|
except HTTPException:
|
||||||
|
_log_audit("auth.login.rate_limited", ip=client_ip, username=form.username, reason="username")
|
||||||
|
raise
|
||||||
|
user = db.query(User).filter(User.username == form.username).first()
|
||||||
|
if not user or not pwd_context.verify(form.password, user.hashed_password):
|
||||||
|
_log_audit("auth.login.failure", username=form.username, ip=client_ip)
|
||||||
|
raise HTTPException(status_code=401, detail="Incorrect username or password")
|
||||||
|
_clear_login_attempts(form.username)
|
||||||
|
_log_audit("auth.login.success", username=user.username, ip=client_ip)
|
||||||
|
return {
|
||||||
|
"access_token": create_token(user.username, user.token_version or 1),
|
||||||
|
"token_type": "bearer",
|
||||||
|
"username": user.username,
|
||||||
|
"must_change_password": bool(user.must_change_password),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/account", response_model=TokenOut)
|
||||||
|
def update_account(
|
||||||
|
data: AccountUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not pwd_context.verify(data.current_password, current_user.hashed_password):
|
||||||
|
_log_audit("auth.account.bad_password", username=current_user.username)
|
||||||
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||||
|
if data.new_username and data.new_username != current_user.username:
|
||||||
|
_validate_new_username(data.new_username)
|
||||||
|
if db.query(User).filter(User.username == data.new_username).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Username already taken")
|
||||||
|
old_username = current_user.username
|
||||||
|
current_user.username = data.new_username
|
||||||
|
_log_audit("auth.account.username_changed", old_username=old_username, new_username=data.new_username)
|
||||||
|
if data.new_password:
|
||||||
|
_validate_new_password(data.new_password)
|
||||||
|
current_user.hashed_password = pwd_context.hash(data.new_password)
|
||||||
|
current_user.must_change_password = False
|
||||||
|
# Invalidate all previously issued tokens by bumping the version
|
||||||
|
current_user.token_version = (current_user.token_version or 1) + 1
|
||||||
|
_log_audit("auth.account.password_changed", username=current_user.username)
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"access_token": create_token(current_user.username, current_user.token_version or 1),
|
||||||
|
"token_type": "bearer",
|
||||||
|
"username": current_user.username,
|
||||||
|
"must_change_password": bool(current_user.must_change_password),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def get_me(current_user: User = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"username": current_user.username,
|
||||||
|
"must_change_password": bool(current_user.must_change_password),
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
from typing import Optional, List
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
import models
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_VALID_TYPES = {
|
||||||
|
"server", "switch", "router", "nas", "gateway", "livebox", "access_point",
|
||||||
|
"camera", "temperature", "sensor", "hub", "smart_plug", "alarm", "light",
|
||||||
|
"doorbell", "desktop", "laptop", "other",
|
||||||
|
}
|
||||||
|
_VALID_VIRT_TYPES = {None, "baremetal", "lxc", "qemu"}
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceCreate(BaseModel):
|
||||||
|
name: str = "eth0"
|
||||||
|
ip_address: Optional[str] = ""
|
||||||
|
vlan_id: Optional[int] = None
|
||||||
|
is_upstream: bool = False
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def _name(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("Interface name cannot be empty")
|
||||||
|
if len(v) > 50:
|
||||||
|
raise ValueError("Interface name too long (max 50 characters)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("ip_address")
|
||||||
|
@classmethod
|
||||||
|
def _ip(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(v)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid IP address: {v!r}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceOut(InterfaceCreate):
|
||||||
|
id: int
|
||||||
|
device_id: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: str = "other"
|
||||||
|
description: str = ""
|
||||||
|
is_gateway: bool = False
|
||||||
|
is_livebox: bool = False
|
||||||
|
virt_type: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
interfaces: List[InterfaceCreate] = []
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def _name(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("name cannot be empty")
|
||||||
|
if len(v) > 100:
|
||||||
|
raise ValueError("name too long (max 100 characters)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("description")
|
||||||
|
@classmethod
|
||||||
|
def _description(cls, v: str) -> str:
|
||||||
|
if len(v) > 500:
|
||||||
|
raise ValueError("description too long (max 500 characters)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("type")
|
||||||
|
@classmethod
|
||||||
|
def _type(cls, v: str) -> str:
|
||||||
|
if v not in _VALID_TYPES:
|
||||||
|
raise ValueError(f"Invalid type: {v!r}. Must be one of: {sorted(_VALID_TYPES)}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("virt_type")
|
||||||
|
@classmethod
|
||||||
|
def _virt_type(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v not in _VALID_VIRT_TYPES:
|
||||||
|
raise ValueError(f"Invalid virt_type: {v!r}. Must be one of: baremetal, lxc, qemu")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("url")
|
||||||
|
@classmethod
|
||||||
|
def _url(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v:
|
||||||
|
parsed = urlparse(v)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
raise ValueError("url must be a valid http or https URL")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
description: str
|
||||||
|
is_gateway: bool
|
||||||
|
is_livebox: bool
|
||||||
|
virt_type: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
interfaces: List[InterfaceOut] = []
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[DeviceOut])
|
||||||
|
def list_devices(db: Session = Depends(get_db)):
|
||||||
|
return db.query(models.Device).order_by(models.Device.name).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=DeviceOut)
|
||||||
|
def create_device(device: DeviceCreate, db: Session = Depends(get_db)):
|
||||||
|
db_device = models.Device(
|
||||||
|
name=device.name,
|
||||||
|
type=device.type,
|
||||||
|
description=device.description,
|
||||||
|
is_gateway=device.is_gateway,
|
||||||
|
is_livebox=device.is_livebox,
|
||||||
|
virt_type=device.virt_type,
|
||||||
|
url=device.url,
|
||||||
|
)
|
||||||
|
db.add(db_device)
|
||||||
|
db.flush()
|
||||||
|
for iface in device.interfaces:
|
||||||
|
db.add(models.DeviceInterface(device_id=db_device.id, **iface.model_dump()))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_device)
|
||||||
|
return db_device
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{device_id}", response_model=DeviceOut)
|
||||||
|
def update_device(device_id: int, device: DeviceCreate, db: Session = Depends(get_db)):
|
||||||
|
db_device = db.query(models.Device).filter(models.Device.id == device_id).first()
|
||||||
|
if not db_device:
|
||||||
|
raise HTTPException(status_code=404, detail="Équipement introuvable")
|
||||||
|
db_device.name = device.name
|
||||||
|
db_device.type = device.type
|
||||||
|
db_device.description = device.description
|
||||||
|
db_device.is_gateway = device.is_gateway
|
||||||
|
db_device.is_livebox = device.is_livebox
|
||||||
|
db_device.virt_type = device.virt_type
|
||||||
|
db_device.url = device.url
|
||||||
|
db.query(models.DeviceInterface).filter(
|
||||||
|
models.DeviceInterface.device_id == device_id
|
||||||
|
).delete()
|
||||||
|
for iface in device.interfaces:
|
||||||
|
db.add(models.DeviceInterface(device_id=device_id, **iface.model_dump()))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_device)
|
||||||
|
return db_device
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{device_id}")
|
||||||
|
def delete_device(device_id: int, db: Session = Depends(get_db)):
|
||||||
|
db_device = db.query(models.Device).filter(models.Device.id == device_id).first()
|
||||||
|
if not db_device:
|
||||||
|
raise HTTPException(status_code=404, detail="Équipement introuvable")
|
||||||
|
db.delete(db_device)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import ipaddress
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import dns.resolver
|
||||||
|
import dns.reversename
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
MAX_HOSTS_PER_TARGET = 1024 # refuse les /21 et plus larges
|
||||||
|
MAX_HOSTS_TOTAL = 4096 # cap global sur l'ensemble des targets
|
||||||
|
|
||||||
|
|
||||||
|
class ScanTarget(BaseModel):
|
||||||
|
vlan_id: int
|
||||||
|
cidr: str
|
||||||
|
|
||||||
|
|
||||||
|
class ScanRequest(BaseModel):
|
||||||
|
dns_server: str = "8.8.8.8"
|
||||||
|
targets: list[ScanTarget]
|
||||||
|
|
||||||
|
@field_validator("dns_server")
|
||||||
|
@classmethod
|
||||||
|
def _dns_server(cls, v: str) -> str:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(v)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"dns_server must be a valid IP address, got: {v!r}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoveredHost(BaseModel):
|
||||||
|
ip: str
|
||||||
|
hostname: Optional[str] = None
|
||||||
|
vlan_id: int
|
||||||
|
cidr: str
|
||||||
|
|
||||||
|
|
||||||
|
class ScanResponse(BaseModel):
|
||||||
|
hosts: list[DiscoveredHost]
|
||||||
|
total_scanned: int
|
||||||
|
duration_s: float
|
||||||
|
|
||||||
|
|
||||||
|
def _ping(ip: str) -> bool:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["ping", "-c", "1", "-W", "1", ip],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
return r.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _ptr_lookup(ip: str, nameserver: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
resolver = dns.resolver.Resolver(configure=False)
|
||||||
|
resolver.nameservers = [nameserver]
|
||||||
|
resolver.timeout = 1
|
||||||
|
resolver.lifetime = 2
|
||||||
|
rev = dns.reversename.from_address(ip)
|
||||||
|
ans = resolver.resolve(rev, "PTR")
|
||||||
|
return str(ans[0]).rstrip(".")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_one(ip: str, dns_server: str, vlan_id: int, cidr: str) -> Optional[DiscoveredHost]:
|
||||||
|
if not _ping(ip):
|
||||||
|
return None
|
||||||
|
hostname = _ptr_lookup(ip, dns_server)
|
||||||
|
return DiscoveredHost(ip=ip, hostname=hostname, vlan_id=vlan_id, cidr=cidr)
|
||||||
|
|
||||||
|
|
||||||
|
class PingRequest(BaseModel):
|
||||||
|
ips: list[str]
|
||||||
|
|
||||||
|
@field_validator("ips")
|
||||||
|
@classmethod
|
||||||
|
def _ips(cls, v: list[str]) -> list[str]:
|
||||||
|
for ip in v:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid IP address: {ip!r}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class PingResult(BaseModel):
|
||||||
|
ip: str
|
||||||
|
alive: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ping", response_model=list[PingResult])
|
||||||
|
def ping_many(req: PingRequest):
|
||||||
|
if not req.ips:
|
||||||
|
return []
|
||||||
|
with ThreadPoolExecutor(max_workers=50) as pool:
|
||||||
|
futures = {pool.submit(_ping, ip): ip for ip in req.ips}
|
||||||
|
results = [PingResult(ip=futures[f], alive=f.result()) for f in as_completed(futures)]
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan", response_model=ScanResponse)
|
||||||
|
def scan(req: ScanRequest):
|
||||||
|
tasks: list[tuple[str, str, int, str]] = []
|
||||||
|
|
||||||
|
for t in req.targets:
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(t.cidr, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f"CIDR invalide : {t.cidr}")
|
||||||
|
|
||||||
|
hosts = list(net.hosts())
|
||||||
|
if len(hosts) > MAX_HOSTS_PER_TARGET:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"Réseau {t.cidr} trop large ({len(hosts)} hôtes). "
|
||||||
|
f"Maximum par target : {MAX_HOSTS_PER_TARGET} hôtes (/22 ou plus petit).",
|
||||||
|
)
|
||||||
|
for ip in hosts:
|
||||||
|
tasks.append((str(ip), req.dns_server, t.vlan_id, t.cidr))
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
raise HTTPException(400, "Aucune cible à scanner.")
|
||||||
|
|
||||||
|
if len(tasks) > MAX_HOSTS_TOTAL:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"Trop d'hôtes au total ({len(tasks)}). Maximum global : {MAX_HOSTS_TOTAL}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
results: list[DiscoveredHost] = []
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=100) as pool:
|
||||||
|
futures = [pool.submit(_scan_one, *args) for args in tasks]
|
||||||
|
for f in as_completed(futures):
|
||||||
|
host = f.result()
|
||||||
|
if host:
|
||||||
|
results.append(host)
|
||||||
|
|
||||||
|
results.sort(key=lambda h: ipaddress.ip_address(h.ip))
|
||||||
|
|
||||||
|
return ScanResponse(
|
||||||
|
hosts=results,
|
||||||
|
total_scanned=len(tasks),
|
||||||
|
duration_s=round(time.time() - t0, 1),
|
||||||
|
)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
from sqlalchemy import nullsfirst
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
import models
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||||
|
|
||||||
|
|
||||||
|
class VlanCreate(BaseModel):
|
||||||
|
vlan_id: Optional[int] = None
|
||||||
|
name: str
|
||||||
|
cidr: Optional[str] = ""
|
||||||
|
color: str = "#4A90D9"
|
||||||
|
|
||||||
|
@field_validator("vlan_id")
|
||||||
|
@classmethod
|
||||||
|
def _vlan_id(cls, v: Optional[int]) -> Optional[int]:
|
||||||
|
if v is not None and not (1 <= v <= 4094):
|
||||||
|
raise ValueError("vlan_id must be between 1 and 4094")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def _name(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("name cannot be empty")
|
||||||
|
if len(v) > 100:
|
||||||
|
raise ValueError("name too long (max 100 characters)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("cidr")
|
||||||
|
@classmethod
|
||||||
|
def _cidr(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(v, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid CIDR notation: {v!r}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("color")
|
||||||
|
@classmethod
|
||||||
|
def _color(cls, v: str) -> str:
|
||||||
|
if not _COLOR_RE.match(v):
|
||||||
|
raise ValueError("color must be a 6-digit hex color (e.g. #4A90D9)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class VlanOut(VlanCreate):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[VlanOut])
|
||||||
|
def list_vlans(db: Session = Depends(get_db)):
|
||||||
|
return db.query(models.Vlan).order_by(nullsfirst(models.Vlan.vlan_id)).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=VlanOut)
|
||||||
|
def create_vlan(vlan: VlanCreate, db: Session = Depends(get_db)):
|
||||||
|
if vlan.vlan_id is not None:
|
||||||
|
existing = db.query(models.Vlan).filter(models.Vlan.vlan_id == vlan.vlan_id).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail=f"VLAN {vlan.vlan_id} existe déjà")
|
||||||
|
db_vlan = models.Vlan(**vlan.model_dump())
|
||||||
|
db.add(db_vlan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_vlan)
|
||||||
|
return db_vlan
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{vlan_pk}", response_model=VlanOut)
|
||||||
|
def update_vlan(vlan_pk: int, vlan: VlanCreate, db: Session = Depends(get_db)):
|
||||||
|
db_vlan = db.query(models.Vlan).filter(models.Vlan.id == vlan_pk).first()
|
||||||
|
if not db_vlan:
|
||||||
|
raise HTTPException(status_code=404, detail="VLAN introuvable")
|
||||||
|
for k, v in vlan.model_dump().items():
|
||||||
|
setattr(db_vlan, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_vlan)
|
||||||
|
return db_vlan
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{vlan_pk}")
|
||||||
|
def delete_vlan(vlan_pk: int, db: Session = Depends(get_db)):
|
||||||
|
db_vlan = db.query(models.Vlan).filter(models.Vlan.id == vlan_pk).first()
|
||||||
|
if not db_vlan:
|
||||||
|
raise HTTPException(status_code=404, detail="VLAN introuvable")
|
||||||
|
db.delete(db_vlan)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Configures a fresh in-memory SQLite database for every test session.
|
||||||
|
DATABASE_URL must be set before any app module is imported.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Must be set before importing database or main
|
||||||
|
_tmpdb = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
_tmpdb.close()
|
||||||
|
os.environ["DATABASE_URL"] = f"sqlite:///{_tmpdb.name}"
|
||||||
|
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""
|
||||||
|
Tests de sécurité pour l'authentification.
|
||||||
|
|
||||||
|
Couvre :
|
||||||
|
- SEC-FIX-001 : bootstrap admin, rattrapage admin existant, blocage CRUD avant changement
|
||||||
|
- SEC-FIX-002 : rate limiting login
|
||||||
|
- SEC-FIX-003 : validation mot de passe et username
|
||||||
|
- SEC-FIX-004 : invalidation de token après changement de mot de passe
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# conftest.py sets DATABASE_URL before this import
|
||||||
|
from database import engine, Base
|
||||||
|
from main import (
|
||||||
|
app,
|
||||||
|
_migrate_users_must_change_password,
|
||||||
|
_migrate_users_token_version,
|
||||||
|
_migrate_force_admin_password_change,
|
||||||
|
_migrate_users,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_db():
|
||||||
|
"""Fresh schema + seeded admin for each test."""
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_migrate_users_must_change_password()
|
||||||
|
_migrate_users_token_version()
|
||||||
|
_migrate_users()
|
||||||
|
yield
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_rate_limits():
|
||||||
|
"""Remet à zéro les compteurs de rate limiting entre chaque test."""
|
||||||
|
from routers.auth import _ip_attempts, _login_attempts, _rate_lock
|
||||||
|
with _rate_lock:
|
||||||
|
_ip_attempts.clear()
|
||||||
|
_login_attempts.clear()
|
||||||
|
yield
|
||||||
|
with _rate_lock:
|
||||||
|
_ip_attempts.clear()
|
||||||
|
_login_attempts.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app, raise_server_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, username="admin", password="admin"):
|
||||||
|
return client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
data={"username": username, "password": password},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(token):
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SEC-FIX-001 — Bootstrap et rattrapage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBootstrap:
|
||||||
|
def test_fresh_db_admin_must_change_password(self, client):
|
||||||
|
"""Nouvelle base : admin créé avec must_change_password=1."""
|
||||||
|
r = _login(client)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["must_change_password"] is True
|
||||||
|
assert data["username"] == "admin"
|
||||||
|
|
||||||
|
def test_crud_blocked_before_password_change(self, client):
|
||||||
|
"""CRUD refusé (403) tant que must_change_password est vrai."""
|
||||||
|
token = _login(client).json()["access_token"]
|
||||||
|
r = client.get("/api/vlans/", headers=_auth_headers(token))
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json()["detail"] == "Password change required"
|
||||||
|
|
||||||
|
def test_crud_allowed_after_password_change(self, client):
|
||||||
|
"""CRUD autorisé après changement de mot de passe."""
|
||||||
|
token = _login(client).json()["access_token"]
|
||||||
|
r = client.put(
|
||||||
|
"/api/auth/account",
|
||||||
|
json={"current_password": "admin", "new_password": "SecurePass1"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
new_token = r.json()["access_token"]
|
||||||
|
assert r.json()["must_change_password"] is False
|
||||||
|
r2 = client.get("/api/vlans/", headers=_auth_headers(new_token))
|
||||||
|
assert r2.status_code == 200
|
||||||
|
|
||||||
|
def test_migration_forces_existing_admin_with_default_password(self, client):
|
||||||
|
"""Rattrapage : admin existant avec must_change_password=0 et password 'admin' est forcé."""
|
||||||
|
# Simuler une ancienne base : admin avec must_change_password=0
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||||
|
conn.commit()
|
||||||
|
# La migration de rattrapage doit remettre must_change_password=1
|
||||||
|
_migrate_force_admin_password_change()
|
||||||
|
r = _login(client)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["must_change_password"] is True
|
||||||
|
|
||||||
|
def test_migration_does_not_touch_admin_with_custom_password(self, client):
|
||||||
|
"""Rattrapage : admin avec mot de passe personnalisé et must_change_password=0 n'est pas touché."""
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text(
|
||||||
|
"UPDATE users SET hashed_password=:h, must_change_password=0 WHERE username='admin'"
|
||||||
|
), {"h": pwd.hash("CustomPass9")})
|
||||||
|
conn.commit()
|
||||||
|
_migrate_force_admin_password_change()
|
||||||
|
r = client.post("/api/auth/login", data={"username": "admin", "password": "CustomPass9"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["must_change_password"] is False
|
||||||
|
|
||||||
|
def test_initial_admin_password_env_var(self, monkeypatch):
|
||||||
|
"""Avec INITIAL_ADMIN_PASSWORD, must_change_password=0."""
|
||||||
|
monkeypatch.setenv("INITIAL_ADMIN_PASSWORD", "EnvPass42")
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_migrate_users_must_change_password()
|
||||||
|
_migrate_users_token_version()
|
||||||
|
_migrate_users()
|
||||||
|
with TestClient(app) as c:
|
||||||
|
r = c.post("/api/auth/login", data={"username": "admin", "password": "EnvPass42"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["must_change_password"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SEC-FIX-004 — Invalidation de token après changement de mot de passe
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTokenInvalidation:
|
||||||
|
def test_old_token_rejected_after_password_change(self, client):
|
||||||
|
"""L'ancien token est invalide après changement de mot de passe."""
|
||||||
|
# Forcer must_change_password=0 pour pouvoir tester le CRUD
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||||
|
conn.commit()
|
||||||
|
old_token = _login(client).json()["access_token"]
|
||||||
|
# Changer le mot de passe → invalide old_token
|
||||||
|
client.put(
|
||||||
|
"/api/auth/account",
|
||||||
|
json={"current_password": "admin", "new_password": "NewPass99"},
|
||||||
|
headers=_auth_headers(old_token),
|
||||||
|
)
|
||||||
|
r = client.get("/api/vlans/", headers=_auth_headers(old_token))
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
def test_new_token_valid_after_password_change(self, client):
|
||||||
|
"""Le nouveau token fonctionne après changement de mot de passe."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||||
|
conn.commit()
|
||||||
|
old_token = _login(client).json()["access_token"]
|
||||||
|
r = client.put(
|
||||||
|
"/api/auth/account",
|
||||||
|
json={"current_password": "admin", "new_password": "NewPass99"},
|
||||||
|
headers=_auth_headers(old_token),
|
||||||
|
)
|
||||||
|
new_token = r.json()["access_token"]
|
||||||
|
r2 = client.get("/api/vlans/", headers=_auth_headers(new_token))
|
||||||
|
assert r2.status_code == 200
|
||||||
|
|
||||||
|
def test_token_without_version_accepted_for_backward_compat(self, client):
|
||||||
|
"""Token sans champ 'ver' (ancien format) est accepté : ver absent → ver=1 par défaut."""
|
||||||
|
from jose import jwt as jose_jwt
|
||||||
|
from routers.auth import SECRET_KEY, ALGORITHM
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||||
|
old_format_token = jose_jwt.encode(
|
||||||
|
{"sub": "admin", "exp": expire},
|
||||||
|
SECRET_KEY,
|
||||||
|
algorithm=ALGORITHM,
|
||||||
|
)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||||
|
conn.commit()
|
||||||
|
r = client.get("/api/auth/me", headers=_auth_headers(old_format_token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_token_with_wrong_version_rejected(self, client):
|
||||||
|
"""Token avec version incorrecte est rejeté."""
|
||||||
|
from jose import jwt as jose_jwt
|
||||||
|
from routers.auth import SECRET_KEY, ALGORITHM
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||||
|
bad_token = jose_jwt.encode(
|
||||||
|
{"sub": "admin", "ver": 999, "exp": expire},
|
||||||
|
SECRET_KEY,
|
||||||
|
algorithm=ALGORITHM,
|
||||||
|
)
|
||||||
|
r = client.get("/api/auth/me", headers=_auth_headers(bad_token))
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SEC-FIX-003 — Validation mot de passe et username
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestValidation:
|
||||||
|
def _get_valid_token(self, client):
|
||||||
|
"""Retourne un token valide (must_change_password forcé à 0)."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||||
|
conn.commit()
|
||||||
|
return _login(client).json()["access_token"]
|
||||||
|
|
||||||
|
def test_password_too_short_rejected(self, client):
|
||||||
|
token = self._get_valid_token(client)
|
||||||
|
r = client.put(
|
||||||
|
"/api/auth/account",
|
||||||
|
json={"current_password": "admin", "new_password": "Short1"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"] == "password_too_short"
|
||||||
|
|
||||||
|
def test_password_no_digit_rejected(self, client):
|
||||||
|
token = self._get_valid_token(client)
|
||||||
|
r = client.put(
|
||||||
|
"/api/auth/account",
|
||||||
|
json={"current_password": "admin", "new_password": "OnlyLetters"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"] == "password_too_weak"
|
||||||
|
|
||||||
|
def test_password_no_letter_rejected(self, client):
|
||||||
|
token = self._get_valid_token(client)
|
||||||
|
r = client.put(
|
||||||
|
"/api/auth/account",
|
||||||
|
json={"current_password": "admin", "new_password": "12345678"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"] == "password_too_weak"
|
||||||
|
|
||||||
|
def test_valid_password_accepted(self, client):
|
||||||
|
token = self._get_valid_token(client)
|
||||||
|
r = client.put(
|
||||||
|
"/api/auth/account",
|
||||||
|
json={"current_password": "admin", "new_password": "ValidPass1"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_username_invalid_chars_rejected(self, client):
|
||||||
|
token = self._get_valid_token(client)
|
||||||
|
r = client.put(
|
||||||
|
"/api/auth/account",
|
||||||
|
json={"current_password": "admin", "new_username": "bad user!"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"] == "username_invalid"
|
||||||
|
|
||||||
|
def test_username_too_long_rejected(self, client):
|
||||||
|
token = self._get_valid_token(client)
|
||||||
|
r = client.put(
|
||||||
|
"/api/auth/account",
|
||||||
|
json={"current_password": "admin", "new_username": "a" * 65},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"] == "username_invalid"
|
||||||
|
|
||||||
|
def test_valid_username_accepted(self, client):
|
||||||
|
token = self._get_valid_token(client)
|
||||||
|
r = client.put(
|
||||||
|
"/api/auth/account",
|
||||||
|
json={"current_password": "admin", "new_username": "admin_user.1"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_wrong_current_password_rejected(self, client):
|
||||||
|
token = self._get_valid_token(client)
|
||||||
|
r = client.put(
|
||||||
|
"/api/auth/account",
|
||||||
|
json={"current_password": "wrong", "new_password": "NewPass1!"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SEC-FIX-002 — Rate limiting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRateLimit:
|
||||||
|
def test_ip_rate_limit_triggers_429(self, client):
|
||||||
|
"""Après trop de tentatives par IP, le login retourne 429."""
|
||||||
|
from routers.auth import _ip_attempts, _rate_lock, _IP_MAX
|
||||||
|
with _rate_lock:
|
||||||
|
_ip_attempts["testclient"] = [__import__("time").time()] * _IP_MAX
|
||||||
|
r = _login(client)
|
||||||
|
assert r.status_code == 429
|
||||||
|
|
||||||
|
def test_username_rate_limit_triggers_429(self, client):
|
||||||
|
"""Après trop de tentatives par username, le login retourne 429."""
|
||||||
|
from routers.auth import _login_attempts, _rate_lock, _USERNAME_MAX
|
||||||
|
with _rate_lock:
|
||||||
|
_login_attempts["admin"] = [__import__("time").time()] * _USERNAME_MAX
|
||||||
|
r = _login(client)
|
||||||
|
assert r.status_code == 429
|
||||||
|
|
||||||
|
def test_successful_login_clears_username_attempts(self, client):
|
||||||
|
"""Login réussi remet à zéro le compteur username."""
|
||||||
|
from routers.auth import _login_attempts, _rate_lock
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||||
|
conn.commit()
|
||||||
|
r = _login(client)
|
||||||
|
assert r.status_code == 200
|
||||||
|
with _rate_lock:
|
||||||
|
assert "admin" not in _login_attempts
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
Tests de validation — Phase 3
|
||||||
|
|
||||||
|
Couvre :
|
||||||
|
- SEC-FIX-006 : validation des entrées discovery (dns_server, ips, cap global)
|
||||||
|
- SEC-FIX-007 : validators Pydantic sur VlanCreate et DeviceCreate
|
||||||
|
- SEC-FIX-013 : PRAGMA foreign_keys=ON (test indirect via FK constraint)
|
||||||
|
- SEC-FIX-017 : suppression code orphelin Links (endpoint /api/links inexistant)
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from database import engine, Base
|
||||||
|
from main import app, _migrate_users_must_change_password, _migrate_users_token_version, _migrate_users
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_db():
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_migrate_users_must_change_password()
|
||||||
|
_migrate_users_token_version()
|
||||||
|
_migrate_users()
|
||||||
|
yield
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_rate_limits():
|
||||||
|
from routers.auth import _ip_attempts, _login_attempts, _rate_lock
|
||||||
|
with _rate_lock:
|
||||||
|
_ip_attempts.clear()
|
||||||
|
_login_attempts.clear()
|
||||||
|
yield
|
||||||
|
with _rate_lock:
|
||||||
|
_ip_attempts.clear()
|
||||||
|
_login_attempts.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app, raise_server_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_token(client):
|
||||||
|
"""Retourne un token admin valide avec must_change_password=0."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||||
|
conn.commit()
|
||||||
|
r = client.post("/api/auth/login", data={"username": "admin", "password": "admin"})
|
||||||
|
return r.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(token):
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SEC-FIX-007 — Validation VlanCreate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestVlanValidation:
|
||||||
|
def test_vlan_id_out_of_range_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/vlans/", json={"name": "Test", "vlan_id": 0}, headers=_auth(token))
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
def test_vlan_id_max_boundary_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/vlans/", json={"name": "Test", "vlan_id": 4095}, headers=_auth(token))
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
def test_vlan_id_valid_accepted(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/vlans/", json={"name": "Test", "vlan_id": 100, "color": "#AABBCC"}, headers=_auth(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_vlan_empty_name_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/vlans/", json={"name": " "}, headers=_auth(token))
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
def test_vlan_invalid_cidr_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/vlans/", json={"name": "Test", "cidr": "not-a-cidr"}, headers=_auth(token))
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
def test_vlan_valid_cidr_accepted(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/vlans/", json={"name": "Test", "cidr": "192.168.1.0/24", "color": "#AABBCC"}, headers=_auth(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_vlan_invalid_color_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/vlans/", json={"name": "Test", "color": "blue"}, headers=_auth(token))
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
def test_vlan_valid_color_accepted(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/vlans/", json={"name": "Test", "color": "#1a2B3c"}, headers=_auth(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SEC-FIX-007 — Validation DeviceCreate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDeviceValidation:
|
||||||
|
def test_invalid_type_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/devices/", json={"name": "srv", "type": "supercomputer"}, headers=_auth(token))
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
def test_valid_type_accepted(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/devices/", json={"name": "srv", "type": "server"}, headers=_auth(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_invalid_virt_type_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/devices/", json={"name": "srv", "virt_type": "docker"}, headers=_auth(token))
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
def test_valid_virt_type_accepted(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/devices/", json={"name": "srv", "virt_type": "lxc"}, headers=_auth(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_invalid_url_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/devices/", json={"name": "srv", "url": "ftp://bad"}, headers=_auth(token))
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
def test_valid_url_accepted(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/devices/", json={"name": "srv", "url": "https://192.168.1.1:8006"}, headers=_auth(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_empty_name_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/devices/", json={"name": ""}, headers=_auth(token))
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
def test_invalid_interface_ip_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/devices/", json={
|
||||||
|
"name": "srv",
|
||||||
|
"interfaces": [{"name": "eth0", "ip_address": "not-an-ip"}]
|
||||||
|
}, headers=_auth(token))
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
def test_valid_interface_ip_accepted(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/devices/", json={
|
||||||
|
"name": "srv",
|
||||||
|
"interfaces": [{"name": "eth0", "ip_address": "10.0.0.1"}]
|
||||||
|
}, headers=_auth(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SEC-FIX-006 — Validation discovery
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDiscoveryValidation:
|
||||||
|
def test_invalid_dns_server_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/discovery/scan", json={
|
||||||
|
"dns_server": "not-an-ip",
|
||||||
|
"targets": [{"vlan_id": 1, "cidr": "192.168.1.0/24"}]
|
||||||
|
}, headers=_auth(token))
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
def test_invalid_ping_ip_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/discovery/ping", json={"ips": ["1.2.3.4", "bad-ip"]}, headers=_auth(token))
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
def test_valid_ping_ips_accepted(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/discovery/ping", json={"ips": []}, headers=_auth(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_scan_oversized_cidr_rejected(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/discovery/scan", json={
|
||||||
|
"dns_server": "8.8.8.8",
|
||||||
|
"targets": [{"vlan_id": 1, "cidr": "10.0.0.0/8"}]
|
||||||
|
}, headers=_auth(token))
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_scan_global_cap_rejected(self, client):
|
||||||
|
"""Plusieurs targets dont le total dépasse MAX_HOSTS_TOTAL."""
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/discovery/scan", json={
|
||||||
|
"dns_server": "8.8.8.8",
|
||||||
|
"targets": [
|
||||||
|
{"vlan_id": 1, "cidr": "10.0.0.0/22"}, # 1022 hôtes
|
||||||
|
{"vlan_id": 2, "cidr": "10.1.0.0/22"}, # 1022 hôtes
|
||||||
|
{"vlan_id": 3, "cidr": "10.2.0.0/22"}, # 1022 hôtes
|
||||||
|
{"vlan_id": 4, "cidr": "10.3.0.0/22"}, # 1022 hôtes
|
||||||
|
{"vlan_id": 5, "cidr": "10.4.0.0/22"}, # 1022 hôtes → total > 4096
|
||||||
|
]
|
||||||
|
}, headers=_auth(token))
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "total" in r.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SEC-FIX-017 — Endpoint /api/links absent
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLinksEndpointRemoved:
|
||||||
|
def test_links_list_returns_404(self, client):
|
||||||
|
"""Le routeur /api/links a été supprimé en phase 3."""
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.get("/api/links/", headers=_auth(token))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
def test_links_create_returns_404(self, client):
|
||||||
|
token = _get_token(client)
|
||||||
|
r = client.post("/api/links/", json={
|
||||||
|
"source_device_id": 1, "target_device_id": 2, "link_type": "trunk"
|
||||||
|
}, headers=_auth(token))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SEC-FIX-013 — PRAGMA foreign_keys=ON
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestForeignKeys:
|
||||||
|
def test_foreign_keys_pragma_is_on(self):
|
||||||
|
"""Vérifie que PRAGMA foreign_keys=ON est actif sur chaque connexion."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(text("PRAGMA foreign_keys")).fetchone()
|
||||||
|
assert result[0] == 1
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
user: "${DOCKER_UID:-1000}:${DOCKER_GID:-1000}"
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- NET_RAW # required for ICMP ping (discovery feature)
|
||||||
|
# no-new-privileges omitted: ping relies on file capabilities (cap_net_raw=ep).
|
||||||
|
volumes:
|
||||||
|
- ./db_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
environment:
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD}
|
||||||
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||||
|
# ── Docker secret alternative for SECRET_KEY ─────────────────────────────
|
||||||
|
# Instead of passing SECRET_KEY via environment, you can mount a secret file.
|
||||||
|
# The backend reads data/secret_key.txt when SECRET_KEY env var is unset.
|
||||||
|
#
|
||||||
|
# 1. Generate the secret:
|
||||||
|
# python3 -c "import secrets; print(secrets.token_hex(32))" \
|
||||||
|
# > ~/.secrets/topologie_secret_key
|
||||||
|
# chmod 600 ~/.secrets/topologie_secret_key
|
||||||
|
#
|
||||||
|
# 2. Uncomment the blocks below and the top-level `secrets:` section,
|
||||||
|
# then remove SECRET_KEY from .env.
|
||||||
|
#
|
||||||
|
#secrets:
|
||||||
|
# - source: secret_key
|
||||||
|
# target: /app/data/secret_key.txt
|
||||||
|
# mode: 0400
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c",
|
||||||
|
"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
# ── Docker secret (uncomment to use — see backend service above) ─────────────
|
||||||
|
#secrets:
|
||||||
|
# secret_key:
|
||||||
|
# file: ~/.secrets/topologie_secret_key
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Stupid Simple Network Inventory is a self-hosted web application for manual network inventory and logical topology visualisation. There is no auto-discovery of topology — only ICMP reachability scanning. All data is entered manually.
|
||||||
|
|
||||||
|
## Request Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser
|
||||||
|
│
|
||||||
|
│ HTTP :8080
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Nginx (frontend container) │
|
||||||
|
│ serves /usr/share/nginx/html │
|
||||||
|
│ │
|
||||||
|
│ location /api/ → proxy_pass │
|
||||||
|
│ http://backend:8000/api/ │
|
||||||
|
└──────────────────┬──────────────────┘
|
||||||
|
│ HTTP :8000 (Docker internal network)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ FastAPI (backend container) │
|
||||||
|
│ uvicorn 0.0.0.0:8000 │
|
||||||
|
│ │
|
||||||
|
│ /app/data/ (bind mount) │
|
||||||
|
│ topology.db ← SQLite │
|
||||||
|
│ secret_key.txt ← JWT secret │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
Two services defined in `docker-compose.yml`:
|
||||||
|
|
||||||
|
| Resource | Type | Notes |
|
||||||
|
|----------|------|-------|
|
||||||
|
| `backend` | service | Python 3.11-slim, runs as `DOCKER_UID:DOCKER_GID` (host user), `cap_drop: ALL`, `cap_add: NET_RAW` |
|
||||||
|
| `frontend` | service | Multi-stage (Vite → `nginxinc/nginx-unprivileged`), UID 101, `cap_drop: ALL`, `no-new-privileges`, exposes `:8080` |
|
||||||
|
|
||||||
|
`./db_data:/app/data` is a bind mount. The backend process runs with the same UID/GID as the host user owning `./db_data/` (set via `DOCKER_UID`/`DOCKER_GID` in `.env`). Pre-create the directory before the first run: `mkdir -p db_data`.
|
||||||
|
|
||||||
|
Both containers share an internal Docker bridge network (`internal`). The browser never communicates directly with the backend — all API traffic goes through Nginx.
|
||||||
|
|
||||||
|
## Build Pipeline
|
||||||
|
|
||||||
|
**Backend** (`backend/Dockerfile`):
|
||||||
|
1. `python:3.11-slim` base
|
||||||
|
2. Install `iputils-ping` (required for ICMP subprocess calls)
|
||||||
|
3. `pip install -r requirements.txt`
|
||||||
|
4. Start with `uvicorn main:app --host 0.0.0.0 --port 8000`
|
||||||
|
|
||||||
|
**Frontend** (`frontend/Dockerfile`):
|
||||||
|
1. Stage 1: `node:20-alpine` — runs `vite build`, outputs to `/app/dist`
|
||||||
|
2. Stage 2: `nginx:alpine` — copies `/app/dist` to `/usr/share/nginx/html`, copies `nginx.conf`
|
||||||
|
|
||||||
|
## Startup Sequence (backend)
|
||||||
|
|
||||||
|
`main.py` runs synchronously at import time before the ASGI app is created:
|
||||||
|
|
||||||
|
1. `_migrate_vlan_nullable()` — idempotent DDL fix for vlans table
|
||||||
|
2. `_migrate_device_virt_type()` — adds column if missing
|
||||||
|
3. `_migrate_device_url()` — adds column if missing
|
||||||
|
4. `_migrate_users()` — creates users table + seeds admin account
|
||||||
|
5. `Base.metadata.create_all(bind=engine)` — creates any missing tables
|
||||||
|
6. FastAPI app instance created, routers registered
|
||||||
|
|
||||||
|
This ordering ensures migrations never run against an uninitialised schema.
|
||||||
|
|
||||||
|
## Authentication Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
LoginPage.vue
|
||||||
|
│ POST /api/auth/login (form-urlencoded)
|
||||||
|
│ ← { access_token, token_type, username, must_change_password }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
auth.js (setAuth)
|
||||||
|
stores token + username + mustChangePassword in localStorage
|
||||||
|
─────────────────────────────────────────────────────────────
|
||||||
|
isAuthenticated (computed ref) ← App.vue reads this
|
||||||
|
mustChangePassword (computed ref) ← App.vue shows AccountModal :forced when true
|
||||||
|
getToken() ← api.js reads this per request
|
||||||
|
|
||||||
|
App.vue template guard:
|
||||||
|
!isAuthenticated → <LoginPage>
|
||||||
|
mustChangePassword → <AccountModal :forced="true"> (blocks the app)
|
||||||
|
else → full application
|
||||||
|
|
||||||
|
api.js (axios interceptor)
|
||||||
|
every request → Authorization: Bearer <token>
|
||||||
|
every 401 response (if token existed) → clearAuth() + reload
|
||||||
|
```
|
||||||
|
|
||||||
|
The JWT payload is `{ sub: username, ver: token_version, exp: <24h> }`, signed with HS256.
|
||||||
|
The secret is loaded from `data/secret_key.txt` (auto-generated on first run, permissions 0600) or `SECRET_KEY` env var.
|
||||||
|
|
||||||
|
Password change invalidates previous tokens immediately via `token_version` bump.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
All state is in SQLite at `./db_data/topology.db`. There is no caching layer, no background jobs, no message queue. The only writable files at runtime are `topology.db` and `secret_key.txt`, both in the bind-mounted `./db_data/` directory on the host.
|
||||||
+273
@@ -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)`
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Data Model
|
||||||
|
|
||||||
|
## Schema Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
users
|
||||||
|
id PK
|
||||||
|
username UNIQUE NOT NULL
|
||||||
|
hashed_password NOT NULL
|
||||||
|
|
||||||
|
vlans
|
||||||
|
id PK
|
||||||
|
vlan_id UNIQUE NULL ← NULL = plain LAN (no 802.1Q tag)
|
||||||
|
name NOT NULL
|
||||||
|
cidr NULL
|
||||||
|
color NULL
|
||||||
|
|
||||||
|
devices
|
||||||
|
id PK
|
||||||
|
name NOT NULL
|
||||||
|
type
|
||||||
|
description
|
||||||
|
is_gateway BOOL
|
||||||
|
is_livebox BOOL
|
||||||
|
virt_type NULL ← null | baremetal | lxc | qemu
|
||||||
|
url NULL
|
||||||
|
|
||||||
|
device_interfaces
|
||||||
|
id PK
|
||||||
|
device_id FK → devices.id NOT NULL
|
||||||
|
vlan_id FK → vlans.id NULL ← NULL = no network assignment
|
||||||
|
ip_address NULL
|
||||||
|
name
|
||||||
|
is_upstream BOOL
|
||||||
|
|
||||||
|
links
|
||||||
|
id PK
|
||||||
|
source_device_id FK → devices.id NOT NULL
|
||||||
|
target_device_id FK → devices.id NOT NULL
|
||||||
|
link_type
|
||||||
|
description
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- `Device` 1 → N `DeviceInterface` (cascade delete-orphan)
|
||||||
|
- `Vlan` 1 → N `DeviceInterface` (nullable FK — deleting a VLAN sets `vlan_id` to NULL on its interfaces)
|
||||||
|
- `Link` references `Device` twice (source, target) — links are deleted explicitly in `delete_device` before removing the device
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### `vlan_id` is nullable
|
||||||
|
|
||||||
|
A network with `vlan_id = NULL` is a plain LAN without 802.1Q tagging. This allows modelling both tagged and untagged networks in the same table. The UI distinguishes them with "LAN" vs "VLAN X" badges.
|
||||||
|
|
||||||
|
The `vlans` table was originally created with `vlan_id NOT NULL`. The migration `_migrate_vlan_nullable()` recreates it (SQLite cannot `ALTER COLUMN`).
|
||||||
|
|
||||||
|
### Interfaces as a separate table
|
||||||
|
|
||||||
|
A device can belong to multiple networks simultaneously. The `device_interfaces` table is a join table with extra attributes (IP, name, is_upstream). A device appears in every topology card that corresponds to one of its interfaces.
|
||||||
|
|
||||||
|
### `is_gateway` and `is_livebox`
|
||||||
|
|
||||||
|
These boolean flags drive the special WAN and Gateway cards in the topology view. They are independent of network membership — a gateway device can also have interfaces in various VLANs.
|
||||||
|
|
||||||
|
### No foreign key enforcement at the SQLite level
|
||||||
|
|
||||||
|
SQLAlchemy's `ForeignKey` declarations define the schema, but SQLite does not enforce FK constraints unless `PRAGMA foreign_keys=ON` is set per-connection. The application does not enable this pragma, so referential integrity is maintained by application logic (explicit deletes in the routers).
|
||||||
|
|
||||||
|
## SQLite Migrations
|
||||||
|
|
||||||
|
Migrations are idempotent Python functions that run at startup before `Base.metadata.create_all`. They check for the presence of columns or tables before acting, so they are safe to run on every container start.
|
||||||
|
|
||||||
|
Pattern for adding a nullable column:
|
||||||
|
```python
|
||||||
|
def _migrate_my_column():
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if not conn.execute(text(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='devices'"
|
||||||
|
)).fetchone():
|
||||||
|
return
|
||||||
|
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(devices)")).fetchall()]
|
||||||
|
if 'my_column' not in cols:
|
||||||
|
conn.execute(text("ALTER TABLE devices ADD COLUMN my_column VARCHAR"))
|
||||||
|
conn.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Device Types
|
||||||
|
|
||||||
|
18 valid values for `devices.type`:
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `server` | Physical or virtual server |
|
||||||
|
| `switch` | Network switch |
|
||||||
|
| `router` | Network router |
|
||||||
|
| `nas` | Network-attached storage |
|
||||||
|
| `gateway` | Internet gateway / firewall |
|
||||||
|
| `livebox` | ISP-provided box (WAN) |
|
||||||
|
| `access_point` | Wi-Fi access point |
|
||||||
|
| `camera` | IP camera |
|
||||||
|
| `temperature` | Temperature sensor |
|
||||||
|
| `sensor` | Generic sensor |
|
||||||
|
| `hub` | Network hub |
|
||||||
|
| `smart_plug` | Smart power plug |
|
||||||
|
| `alarm` | Alarm system |
|
||||||
|
| `light` | Smart light |
|
||||||
|
| `doorbell` | Smart doorbell |
|
||||||
|
| `desktop` | Desktop computer |
|
||||||
|
| `laptop` | Laptop computer |
|
||||||
|
| `other` | Anything else |
|
||||||
|
|
||||||
|
`url` is hidden in the device form for `desktop` and `laptop` — these types have no web UI.
|
||||||
|
|
||||||
|
## Virtualisation Types
|
||||||
|
|
||||||
|
`devices.virt_type` valid values:
|
||||||
|
|
||||||
|
| Value | Display | Badge |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| `null` | (not shown) | — |
|
||||||
|
| `baremetal` | Bare metal | — |
|
||||||
|
| `lxc` | LXC container | blue "LXC" |
|
||||||
|
| `qemu` | QEMU/KVM VM | purple "VM" |
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Extending the Application
|
||||||
|
|
||||||
|
## Add a Field to `Device`
|
||||||
|
|
||||||
|
Every new Device field requires changes in four places. Missing any one of them will cause data loss or silent failures.
|
||||||
|
|
||||||
|
### 1. `backend/models.py`
|
||||||
|
|
||||||
|
Add the column to the `Device` class:
|
||||||
|
|
||||||
|
```python
|
||||||
|
my_field = Column(String, nullable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `backend/main.py`
|
||||||
|
|
||||||
|
Add an idempotent migration function and call it before `Base.metadata.create_all`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _migrate_device_my_field():
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if not conn.execute(text(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='devices'"
|
||||||
|
)).fetchone():
|
||||||
|
return
|
||||||
|
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(devices)")).fetchall()]
|
||||||
|
if 'my_field' not in cols:
|
||||||
|
conn.execute(text("ALTER TABLE devices ADD COLUMN my_field VARCHAR"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Add the call at the bottom of the startup sequence:
|
||||||
|
_migrate_device_my_field()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `backend/routers/devices.py`
|
||||||
|
|
||||||
|
Add to both Pydantic models:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class DeviceCreate(BaseModel):
|
||||||
|
my_field: Optional[str] = None
|
||||||
|
|
||||||
|
class DeviceOut(BaseModel):
|
||||||
|
my_field: Optional[str] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
Then assign explicitly in both `create_device` and `update_device`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
db_device = models.Device(
|
||||||
|
...
|
||||||
|
my_field=device.my_field,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** use `**device.model_dump()` to populate the ORM object — it silently ignores any field not present in the constructor keyword args.
|
||||||
|
|
||||||
|
### 4. Frontend
|
||||||
|
|
||||||
|
Add the field to the device form in `DeviceManager.vue` and display it where needed (chip in `TopologyGraph.vue`, card in `DeviceManager.vue`). Add any new i18n keys to all three locales in `i18n.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add a New API Route Group
|
||||||
|
|
||||||
|
1. Create `backend/routers/myrouter.py` following the pattern of `vlans.py` or `devices.py`.
|
||||||
|
2. Import and register in `backend/main.py`:
|
||||||
|
```python
|
||||||
|
from routers import myrouter
|
||||||
|
app.include_router(myrouter.router, prefix="/api/myroutes", tags=["myroutes"],
|
||||||
|
dependencies=[Depends(get_current_user)])
|
||||||
|
```
|
||||||
|
3. Add the corresponding API object in `frontend/src/api.js`:
|
||||||
|
```javascript
|
||||||
|
export const myApi = {
|
||||||
|
list: () => http.get('/myroutes/'),
|
||||||
|
create: (data) => http.post('/myroutes/', data),
|
||||||
|
remove: (id) => http.delete(`/myroutes/${id}`),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add a New i18n Key
|
||||||
|
|
||||||
|
1. Open `frontend/src/i18n.js`.
|
||||||
|
2. Add the key to all three locale objects (`fr`, `en`, `es`).
|
||||||
|
3. Use `t('myKey')` in the template.
|
||||||
|
|
||||||
|
For interpolation:
|
||||||
|
```javascript
|
||||||
|
// i18n.js
|
||||||
|
fr: { myMsg: "Il y a {0} équipements dans {1}" }
|
||||||
|
|
||||||
|
// template
|
||||||
|
{{ tFmt('myMsg', devices.length, vlan.name) }}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add a Language
|
||||||
|
|
||||||
|
1. In `frontend/src/i18n.js`, add a new locale object with all existing keys translated.
|
||||||
|
2. Add the language code to the valid values type/comment.
|
||||||
|
3. In `App.vue`, add a pill button to the language switcher:
|
||||||
|
```html
|
||||||
|
<button :class="{ active: locale === 'de' }" @click="setLocale('de')">de</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add a Brand Icon
|
||||||
|
|
||||||
|
Supported brands are detected by keyword-matching device name and description.
|
||||||
|
|
||||||
|
1. Find the Simple Icons export name:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
node -e "const si = require('./node_modules/simple-icons'); console.log(Object.keys(si).filter(k => k.toLowerCase().includes('yourterm')))"
|
||||||
|
```
|
||||||
|
2. In `frontend/src/brandIcons.js`, import the icon:
|
||||||
|
```javascript
|
||||||
|
import { siYourbrand } from 'simple-icons'
|
||||||
|
```
|
||||||
|
3. Add an entry to the `BRANDS` array:
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: 'yourbrand',
|
||||||
|
name: 'YourBrand',
|
||||||
|
keywords: ['yourbrand', 'alternate-name'],
|
||||||
|
hex: siYourbrand.hex,
|
||||||
|
path: siYourbrand.path,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`keywords` are matched case-insensitively against the device `name` and `description` fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add a Vue Component
|
||||||
|
|
||||||
|
1. Create `frontend/src/components/MyComponent.vue` using `<script setup>` syntax.
|
||||||
|
2. Use CSS custom properties for all colors (see `docs/frontend.md` for the variable list).
|
||||||
|
3. Use `t('key')` for all visible strings — never hardcode text.
|
||||||
|
4. For dark-mode scoped overrides, put the full selector inside `:global()`:
|
||||||
|
```css
|
||||||
|
:global(html.dark .my-component) { background: #1E293B; }
|
||||||
|
```
|
||||||
|
5. Import and use in `App.vue` or the relevant parent component.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Change the Default Admin Password
|
||||||
|
|
||||||
|
The admin account is seeded by `_migrate_users()` only when the `users` table is empty. To reset credentials on a running instance, use the account settings modal in the UI (sidebar footer → account icon).
|
||||||
|
|
||||||
|
To reset programmatically (e.g., locked out):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec backend python3 -c "
|
||||||
|
from database import engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
pwd = CryptContext(schemes=['bcrypt'], deprecated='auto')
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text(\"UPDATE users SET hashed_password=:h WHERE username='admin'\"), {'h': pwd.hash('newpassword')})
|
||||||
|
conn.commit()
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade Dependencies
|
||||||
|
|
||||||
|
### Backend Python packages
|
||||||
|
|
||||||
|
Check for passlib/bcrypt compatibility before upgrading:
|
||||||
|
- `passlib 1.7.4` requires `bcrypt < 4.0` — see `requirements.txt` for the pin.
|
||||||
|
- If upgrading passlib, verify the new version supports the installed bcrypt before removing the pin.
|
||||||
|
|
||||||
|
### Frontend npm packages
|
||||||
|
|
||||||
|
`simple-icons` has breaking changes between major versions (icon names and SVG paths change). Pin the major version in `package.json` and test brand detection after upgrading.
|
||||||
|
|
||||||
|
`lucide-vue-next` is generally backwards-compatible but icons are occasionally renamed or removed.
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# Frontend Reference
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Vue 3** (Composition API, `<script setup>`)
|
||||||
|
- **Vite 5** (build + dev server)
|
||||||
|
- **Axios** (HTTP client)
|
||||||
|
- **lucide-vue-next** (generic UI icons)
|
||||||
|
- **simple-icons** (brand logos as inline SVG)
|
||||||
|
|
||||||
|
No Pinia, no Vue Router (single page, tab switching via reactive state in `App.vue`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Inventory
|
||||||
|
|
||||||
|
### `src/main.js`
|
||||||
|
Minimal entry point: creates the Vue app and mounts it to `#app`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/auth.js`
|
||||||
|
|
||||||
|
Reactive authentication state. All consuming components import from here — there is no Vuex/Pinia store.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Exports
|
||||||
|
isAuthenticated // computed ref<boolean> — true if token is present
|
||||||
|
currentUsername // computed ref<string | null>
|
||||||
|
|
||||||
|
setAuth(token, username) // stores in reactive refs + localStorage
|
||||||
|
clearAuth() // clears refs + localStorage
|
||||||
|
getToken() // returns raw token string (used by api.js interceptor)
|
||||||
|
```
|
||||||
|
|
||||||
|
State is initialised from `localStorage` at module load, so auth survives page refreshes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/api.js`
|
||||||
|
|
||||||
|
Thin axios wrapper. All requests go to `/api` (relative URL, proxied by Nginx).
|
||||||
|
|
||||||
|
**Request interceptor**: injects `Authorization: Bearer <token>` if `getToken()` is non-null.
|
||||||
|
|
||||||
|
**Response interceptor**: on 401, calls `clearAuth()` and reloads the page — but only if a token was present (avoids an infinite reload loop if the login endpoint itself returns 401).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Named exports
|
||||||
|
authApi // login, updateAccount, me
|
||||||
|
vlansApi // list, create, update, remove
|
||||||
|
devicesApi // list, create, update, remove
|
||||||
|
linksApi // list, create, remove
|
||||||
|
discoveryApi // scan, ping
|
||||||
|
```
|
||||||
|
|
||||||
|
`authApi.login` sends `application/x-www-form-urlencoded` (required by FastAPI's `OAuth2PasswordRequestForm`). All other calls use JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/i18n.js`
|
||||||
|
|
||||||
|
Simple key-based i18n, no external library.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Exports
|
||||||
|
locale // ref<'fr'|'en'|'es'> — persisted in localStorage, default 'fr'
|
||||||
|
setLocale(lang)
|
||||||
|
t(key) // returns translation, fallback to 'fr', then raw key
|
||||||
|
tFmt(key, ...args) // t() with {0}, {1}, ... interpolation
|
||||||
|
```
|
||||||
|
|
||||||
|
Translations are plain objects keyed by locale code. To add a key: add it to all three locale objects. To add a language: add a new locale object and add the code to the switcher pills in `App.vue`.
|
||||||
|
|
||||||
|
**Rule**: every visible string in a template must use `t('key')`. Never hardcode text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/theme.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Exports
|
||||||
|
theme // ref<'light'|'dark'> — persisted in localStorage, default 'light'
|
||||||
|
toggleTheme()
|
||||||
|
```
|
||||||
|
|
||||||
|
Applies/removes the `html.dark` class on `document.documentElement`. All dark-mode styling is driven by CSS custom properties defined in `App.vue`'s global `<style>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/brandIcons.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Exports
|
||||||
|
BRANDS // array of { id, name, keywords[], hex, path }
|
||||||
|
detectBrands(name, description) // returns BRANDS entries matching the device
|
||||||
|
```
|
||||||
|
|
||||||
|
`detectBrands` is a pure function — it takes name and description strings and returns all matching brand objects. It is called in both `DeviceIcon.vue` and `DeviceManager.vue`.
|
||||||
|
|
||||||
|
To add a brand:
|
||||||
|
1. Check the `simple-icons` export name: `node -e "const si = require('./node_modules/simple-icons'); console.log(Object.keys(si).filter(k => k.toLowerCase().includes('yourterm')))"`
|
||||||
|
2. Import `si<Name>` from `simple-icons`
|
||||||
|
3. Add an entry to `BRANDS`: `{ id: 'yourbrand', name: 'YourBrand', keywords: ['keyword1', 'keyword2'], hex: si<Name>.hex, path: si<Name>.path }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Inventory
|
||||||
|
|
||||||
|
### `App.vue`
|
||||||
|
|
||||||
|
Global layout and state. Single-file, no sub-layouts.
|
||||||
|
|
||||||
|
**Auth guard**: `v-if="!isAuthenticated"` renders `<LoginPage>`, `v-else` renders the main layout. The template is wrapped in `<div class="app-root">` (`display: flex; flex-direction: column; min-height: 100vh`) — single root node to prevent a Firefox autofill overlay bug on Vue fragment comment nodes. Do **not** use `display: contents` here: Firefox fails to update click hit-testing after a child replacement inside a `display: contents` element, causing buttons to be unclickable after login.
|
||||||
|
|
||||||
|
**Global state** (reactive refs at the top of `<script setup>`):
|
||||||
|
- `vlans` — array loaded from `GET /api/vlans/`
|
||||||
|
- `devices` — array loaded from `GET /api/devices/`
|
||||||
|
- `activeTab` — `'topology' | 'devices' | 'vlans'`
|
||||||
|
|
||||||
|
**Auth functions**: `onLogin({ token, username })`, `onAccountUpdated({ token, username })`, `logout()` — all call `setAuth`/`clearAuth` from `auth.js`.
|
||||||
|
|
||||||
|
**CSS variables** are defined here in the global (non-scoped) `<style>` block:
|
||||||
|
- `:root` — light theme
|
||||||
|
- `html.dark` — dark theme overrides
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `LoginPage.vue`
|
||||||
|
|
||||||
|
Full-screen centered login card. No router navigation — it is shown/hidden by `App.vue`'s auth guard.
|
||||||
|
|
||||||
|
**Props**: none
|
||||||
|
**Emits**: `login` with payload `{ token: string, username: string }`
|
||||||
|
|
||||||
|
Calls `authApi.login(username, password)`. On success, emits the token. On failure, shows a localised error message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `AccountModal.vue`
|
||||||
|
|
||||||
|
Modal for changing username and/or password.
|
||||||
|
|
||||||
|
**Props**: none (reads `currentUsername` directly from `auth.js`)
|
||||||
|
**Emits**: `close`, `updated` with payload `{ token: string, username: string }`
|
||||||
|
|
||||||
|
Fields: new username (optional), new password (optional), confirm password (shown only when new password is typed), current password (always required).
|
||||||
|
|
||||||
|
Calls `authApi.updateAccount({ current_password, new_username?, new_password? })`. On success, emits the new token so `App.vue` can refresh auth state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `TopologyGraph.vue`
|
||||||
|
|
||||||
|
Topology view. CSS cards only — no SVG, no Cytoscape.
|
||||||
|
|
||||||
|
**Props**: `vlans[]`, `devices[]`
|
||||||
|
**Emits**: none (read-only view)
|
||||||
|
|
||||||
|
**Layout**:
|
||||||
|
```
|
||||||
|
toolbar (Ping button + up/down counter)
|
||||||
|
├── WAN card (is_livebox devices)
|
||||||
|
├── Gateway card (is_gateway devices)
|
||||||
|
├── [one card per network, sorted: LANs first then VLANs ascending]
|
||||||
|
└── Unassigned card (no interface, not livebox, not gateway)
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside each card, devices are sorted by IP address ascending. Devices with no IP go last. A device appears in every card that matches one of its interfaces.
|
||||||
|
|
||||||
|
Each device is rendered as a **chip** — a compact tile with:
|
||||||
|
- Left: type icon (Lucide, always, `typeOnly` prop)
|
||||||
|
- Center: name + IP pill + interface name + brand SVG logos (11px)
|
||||||
|
- Right tags column: Link (green, `<a>`) | GW | LXC | VM | ping dot
|
||||||
|
|
||||||
|
**Ping**: `POST /api/discovery/ping` with all known IPs. Populates `pingResults` reactive map `{ ip → alive }`. A device is "up" if any of its IPs maps to `alive: true`.
|
||||||
|
|
||||||
|
**Dark mode CSS**: scoped styles use `:global(html.dark .special-wan)` etc. The full selector must be inside `:global()` — Vue's scoped CSS transformer does not resolve descendant selectors after the closing parenthesis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DeviceIcon.vue`
|
||||||
|
|
||||||
|
**Props**: `device` (object with `name`, `description`, `type`), `typeOnly` (boolean, default false)
|
||||||
|
|
||||||
|
- `typeOnly: false` — shows brand SVG logo(s) if `detectBrands()` finds a match, else falls back to the Lucide type icon. Used in topology chips.
|
||||||
|
- `typeOnly: true` — always shows the Lucide type icon. Used in DeviceManager cards.
|
||||||
|
|
||||||
|
Lucide icon is selected by a `type → component` map covering all 18 device types.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DeviceManager.vue`
|
||||||
|
|
||||||
|
CRUD view for devices. Presented as a card list.
|
||||||
|
|
||||||
|
**Props**: `vlans[]`, `devices[]`
|
||||||
|
**Emits**: `refresh` (triggers parent reload of both vlans and devices)
|
||||||
|
|
||||||
|
**Filter bar** (28px height):
|
||||||
|
- Free-text search (name, description, IP) — cleared by Escape
|
||||||
|
- Type multi-select (only types present in data)
|
||||||
|
- Network dropdown (color swatch + name)
|
||||||
|
- Brand dropdown (SVG icon + name, alphabetical)
|
||||||
|
- Virt dropdown (only shown if at least one device has a `virt_type`)
|
||||||
|
|
||||||
|
Filters combine with AND across categories, OR within. Active filter shows `X / total` counter and a "Clear" button.
|
||||||
|
|
||||||
|
`deviceTypes` is a `computed` (not a constant) so labels re-render on locale change.
|
||||||
|
|
||||||
|
**Form**: inline panel (not a modal). Supports add and edit. Interfaces are managed as a sub-list within the same form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `VlanManager.vue`
|
||||||
|
|
||||||
|
CRUD view for networks (VLANs and plain LANs).
|
||||||
|
|
||||||
|
**Props**: `vlans[]`
|
||||||
|
**Emits**: `refresh`
|
||||||
|
|
||||||
|
`vlan_id` is optional. If omitted, the network is a plain LAN (badge "LAN"). If provided, it is a VLAN (badge "VLAN X"). Color picker included. CIDR is a free-text field with no validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DiscoveryModal.vue`
|
||||||
|
|
||||||
|
Auto-discovery modal: ping sweep + DNS PTR lookup.
|
||||||
|
|
||||||
|
**Props**: `vlans[]`
|
||||||
|
**Emits**: `close`, `import(hosts[])` — parent handles inserting discovered hosts
|
||||||
|
|
||||||
|
Lets the user select VLANs to scan and configure the DNS server. Shows progress and results. Discovered hosts can be selectively imported.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS Conventions
|
||||||
|
|
||||||
|
All background, text, and border colors use CSS custom properties from `App.vue`:
|
||||||
|
|
||||||
|
```
|
||||||
|
--bg-page, --bg-card, --bg-card-hover, --bg-input
|
||||||
|
--bg-thead, --bg-chip, --bg-chip-hover
|
||||||
|
--border, --border-strong
|
||||||
|
--text-primary, --text-secondary, --text-muted, --text-faint
|
||||||
|
--chip-ip-bg, --chip-ip-color
|
||||||
|
--modal-overlay, --shadow-card, --shadow-modal, --shadow-drop
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exceptions** (hardcoded hex is acceptable):
|
||||||
|
- Device type icon colors in chips (semantic, theme-independent)
|
||||||
|
- VLAN badge background colors
|
||||||
|
- Auth feedback colors (`#EF4444` red, `#15803D` green) with dark overrides via `:global(html.dark .class)`
|
||||||
|
|
||||||
|
**Dark-mode scoped override syntax**:
|
||||||
|
```css
|
||||||
|
/* CORRECT */
|
||||||
|
:global(html.dark .my-component .child) { color: red; }
|
||||||
|
|
||||||
|
/* WRONG — Vue does not resolve descendants after :global() closing paren */
|
||||||
|
:global(html.dark) .my-component .child { color: red; }
|
||||||
|
```
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Documentation
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [architecture.md](architecture.md) — System overview, Docker setup, request flow, startup sequence
|
||||||
|
- [backend.md](backend.md) — All API endpoints, ORM models, auth system, migration patterns
|
||||||
|
- [frontend.md](frontend.md) — Vue 3 module inventory, component reference, CSS conventions
|
||||||
|
- [data-model.md](data-model.md) — Database schema, relationships, device types, migration patterns
|
||||||
|
- [extending.md](extending.md) — Cookbooks: add a device field, API route, i18n key, brand icon, component
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Task | File |
|
||||||
|
|------|------|
|
||||||
|
| Add a field to Device | [extending.md → Add a Field to Device](extending.md#add-a-field-to-device) |
|
||||||
|
| Add an API endpoint | [extending.md → Add a New API Route Group](extending.md#add-a-new-api-route-group) |
|
||||||
|
| Add a translation key | [extending.md → Add a New i18n Key](extending.md#add-a-new-i18n-key) |
|
||||||
|
| Add a brand icon | [extending.md → Add a Brand Icon](extending.md#add-a-brand-icon) |
|
||||||
|
| Reset admin password | [extending.md → Change the Default Admin Password](extending.md#change-the-default-admin-password) |
|
||||||
|
| API endpoint reference | [backend.md → API Endpoints](backend.md#api-endpoints) |
|
||||||
|
| CSS variables list | [frontend.md → CSS Conventions](frontend.md#css-conventions) |
|
||||||
|
| Database schema | [data-model.md → Schema Overview](data-model.md#schema-overview) |
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginxinc/nginx-unprivileged:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY rate_limit.conf /etc/nginx/conf.d/00_rate_limit.conf
|
||||||
|
EXPOSE 8080
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<title>Stupid Simple Network Inventory</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# ── Security headers ─────────────────────────────────────────────────────
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
|
# CSP for a Vue 3 / Vite SPA:
|
||||||
|
# - script-src 'self' + hash : Vite bundles (self) + one Vite-generated inline
|
||||||
|
# script (modulepreload fallback in index.html, stable hash across builds)
|
||||||
|
# - style-src 'self' 'unsafe-inline' : inline style="" attributes used by Vue
|
||||||
|
# - img-src 'self' data: : SVG/PNG assets + possible data URIs
|
||||||
|
# - connect-src 'self' : API calls to /api/ (same origin via proxy)
|
||||||
|
# - object-src 'none' : no plugins
|
||||||
|
# - frame-ancestors 'none' : prevents embedding in iframes (replaces X-Frame-Options for CSP-aware browsers)
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-ZswfTY7H35rbv8WC7NXBoiC7WNu86vSzCDChNWwZZDM='; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self';" always;
|
||||||
|
|
||||||
|
location = /api/auth/login {
|
||||||
|
limit_req zone=login burst=5 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
proxy_pass http://backend:8000/api/auth/login;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1524
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "network-topology",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.3.8",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"lucide-vue-next": "^0.460.0",
|
||||||
|
"simple-icons": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^4.5.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<circle cx="4" cy="6" r="2"/>
|
||||||
|
<circle cx="20" cy="6" r="2"/>
|
||||||
|
<circle cx="4" cy="18" r="2"/>
|
||||||
|
<circle cx="20" cy="18" r="2"/>
|
||||||
|
<line x1="6" y1="6" x2="10" y2="11"/>
|
||||||
|
<line x1="18" y1="6" x2="14" y2="11"/>
|
||||||
|
<line x1="6" y1="18" x2="10" y2="13"/>
|
||||||
|
<line x1="18" y1="18" x2="14" y2="13"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 491 B |
@@ -0,0 +1 @@
|
|||||||
|
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-root">
|
||||||
|
<LoginPage v-if="!isAuthenticated" @login="onLogin" />
|
||||||
|
|
||||||
|
<AccountModal
|
||||||
|
v-else-if="mustChangePassword"
|
||||||
|
:forced="true"
|
||||||
|
@updated="onAccountUpdated"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/>
|
||||||
|
<circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/>
|
||||||
|
<line x1="6" y1="6" x2="10" y2="11"/><line x1="18" y1="6" x2="14" y2="11"/>
|
||||||
|
<line x1="6" y1="18" x2="10" y2="13"/><line x1="18" y1="18" x2="14" y2="13"/>
|
||||||
|
</svg>
|
||||||
|
<span>Stupid Simple Network Inventory</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
:class="['nav-btn', { active: view === tab.id }]"
|
||||||
|
@click="view = tab.id"
|
||||||
|
>
|
||||||
|
<span class="nav-icon">{{ tab.icon }}</span>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="discovery-btn-wrap">
|
||||||
|
<button class="btn-discovery" @click="showDiscovery = true">
|
||||||
|
{{ t('discovery') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-val">{{ vlans.length }}</span>
|
||||||
|
<span class="stat-lbl">{{ t('statsNetworks') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-val">{{ devices.length }}</span>
|
||||||
|
<span class="stat-lbl">{{ t('statsDevices') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-export" @click="exportJson">{{ t('exportJson') }}</button>
|
||||||
|
<label class="btn-export">
|
||||||
|
{{ t('importJson') }}
|
||||||
|
<input type="file" accept=".json" @change="importJson" style="display:none" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Contrôles thème + langue -->
|
||||||
|
<div class="settings-row">
|
||||||
|
<button class="settings-btn" @click="toggleTheme" :title="theme === 'dark' ? t('lightTheme') : t('darkTheme')">
|
||||||
|
{{ theme === 'dark' ? '☀' : '☾' }}
|
||||||
|
</button>
|
||||||
|
<div class="lang-switcher">
|
||||||
|
<button
|
||||||
|
v-for="lang in langs"
|
||||||
|
:key="lang"
|
||||||
|
class="lang-btn"
|
||||||
|
:class="{ active: locale === lang }"
|
||||||
|
@click="setLocale(lang)"
|
||||||
|
>{{ lang }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compte utilisateur -->
|
||||||
|
<div class="user-row">
|
||||||
|
<button class="user-btn" @click="showAccount = true" :title="t('accountSettings')">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
||||||
|
</svg>
|
||||||
|
<span class="user-name">{{ currentUsername }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="logout-btn" @click="logout" :title="t('logout')">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
|
<polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<div v-if="error" class="toast-error">{{ error }}</div>
|
||||||
|
<DiscoveryModal
|
||||||
|
v-if="showDiscovery"
|
||||||
|
:vlans="vlans"
|
||||||
|
:devices="devices"
|
||||||
|
@close="showDiscovery = false"
|
||||||
|
@refresh="loadAll"
|
||||||
|
/>
|
||||||
|
<AccountModal
|
||||||
|
v-if="showAccount"
|
||||||
|
@close="showAccount = false"
|
||||||
|
@updated="onAccountUpdated"
|
||||||
|
/>
|
||||||
|
<TopologyGraph
|
||||||
|
v-if="view === 'topology'"
|
||||||
|
:devices="devices"
|
||||||
|
:vlans="vlans"
|
||||||
|
/>
|
||||||
|
<VlanManager v-if="view === 'vlans'" :vlans="vlans" @refresh="loadAll" />
|
||||||
|
<DeviceManager v-if="view === 'devices'" :devices="devices" :vlans="vlans" @refresh="loadAll" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { vlansApi, devicesApi } from './api.js'
|
||||||
|
import { t, locale, setLocale } from './i18n.js'
|
||||||
|
import { theme, toggleTheme } from './theme.js'
|
||||||
|
import { isAuthenticated, currentUsername, mustChangePassword, setAuth, clearAuth } from './auth.js'
|
||||||
|
import TopologyGraph from './components/TopologyGraph.vue'
|
||||||
|
import VlanManager from './components/VlanManager.vue'
|
||||||
|
import DeviceManager from './components/DeviceManager.vue'
|
||||||
|
import DiscoveryModal from './components/DiscoveryModal.vue'
|
||||||
|
import LoginPage from './components/LoginPage.vue'
|
||||||
|
import AccountModal from './components/AccountModal.vue'
|
||||||
|
|
||||||
|
const view = ref('topology')
|
||||||
|
const vlans = ref([])
|
||||||
|
const devices = ref([])
|
||||||
|
const error = ref('')
|
||||||
|
const showDiscovery = ref(false)
|
||||||
|
const showAccount = ref(false)
|
||||||
|
const langs = ['fr', 'en', 'es']
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{ id: 'topology', label: t('tabTopology'), icon: '■' },
|
||||||
|
{ id: 'vlans', label: t('tabNetworks'), icon: '◆' },
|
||||||
|
{ id: 'devices', label: t('tabDevices'), icon: '▣' },
|
||||||
|
])
|
||||||
|
|
||||||
|
function onLogin({ token, username, mustChangePassword: mcp }) {
|
||||||
|
setAuth(token, username, mcp)
|
||||||
|
if (!mcp) loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAccountUpdated({ token, username, mustChangePassword: mcp }) {
|
||||||
|
setAuth(token, username, mcp || false)
|
||||||
|
if (!mcp) loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
try {
|
||||||
|
const [v, d] = await Promise.all([
|
||||||
|
vlansApi.list(),
|
||||||
|
devicesApi.list(),
|
||||||
|
])
|
||||||
|
vlans.value = v.data
|
||||||
|
devices.value = d.data
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response?.status !== 401) {
|
||||||
|
showError(t('loadError') + (e.response?.data?.detail || e.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
error.value = msg
|
||||||
|
setTimeout(() => { error.value = '' }, 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportJson() {
|
||||||
|
const data = JSON.stringify({ vlans: vlans.value, devices: devices.value }, null, 2)
|
||||||
|
const blob = new Blob([data], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `topology-${new Date().toISOString().slice(0, 10)}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importJson(e) {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
showError(t('importTooLarge'))
|
||||||
|
e.target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
if (typeof data !== 'object' || Array.isArray(data) || data === null)
|
||||||
|
throw new Error('Invalid format: expected a JSON object')
|
||||||
|
if (data.vlans !== undefined && !Array.isArray(data.vlans))
|
||||||
|
throw new Error('Invalid format: vlans must be an array')
|
||||||
|
if (data.devices !== undefined && !Array.isArray(data.devices))
|
||||||
|
throw new Error('Invalid format: devices must be an array')
|
||||||
|
for (const vlan of (data.vlans || [])) {
|
||||||
|
await vlansApi.create({ vlan_id: vlan.vlan_id, name: vlan.name, cidr: vlan.cidr, color: vlan.color }).catch(() => {})
|
||||||
|
}
|
||||||
|
await loadAll()
|
||||||
|
const refreshedVlans = vlans.value
|
||||||
|
for (const device of (data.devices || [])) {
|
||||||
|
const ifaces = (device.interfaces || []).map(i => {
|
||||||
|
const matchedVlan = refreshedVlans.find(v => v.vlan_id === (data.vlans || []).find(ov => ov.id === i.vlan_id)?.vlan_id)
|
||||||
|
return { ...i, vlan_id: matchedVlan?.id || null }
|
||||||
|
})
|
||||||
|
await devicesApi.create({ ...device, interfaces: ifaces }).catch(() => {})
|
||||||
|
}
|
||||||
|
await loadAll()
|
||||||
|
e.target.value = ''
|
||||||
|
} catch (err) {
|
||||||
|
showError(t('importFailed') + err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isAuthenticated.value && !mustChangePassword.value) loadAll()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
button { cursor: pointer; font-family: inherit; }
|
||||||
|
input, select, textarea { font-family: inherit; }
|
||||||
|
|
||||||
|
/* ── CSS variables — light (default) ───────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--bg-page: #F1F5F9;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-card-hover: #F8FAFC;
|
||||||
|
--bg-input: #ffffff;
|
||||||
|
--bg-thead: #F8FAFC;
|
||||||
|
--bg-chip: #F8FAFC;
|
||||||
|
--bg-chip-hover: #F1F5F9;
|
||||||
|
--border: #E2E8F0;
|
||||||
|
--border-strong: #CBD5E1;
|
||||||
|
--text-primary: #0F172A;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-muted: #64748B;
|
||||||
|
--text-faint: #94A3B8;
|
||||||
|
--chip-ip-bg: #E2E8F0;
|
||||||
|
--chip-ip-color: #475569;
|
||||||
|
--modal-overlay: rgba(0,0,0,0.4);
|
||||||
|
--shadow-card: 0 1px 4px rgba(0,0,0,0.07);
|
||||||
|
--shadow-modal: 0 20px 60px rgba(0,0,0,0.2);
|
||||||
|
--shadow-drop: 0 8px 24px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CSS variables — dark ───────────────────────────────────────────────── */
|
||||||
|
html.dark {
|
||||||
|
--bg-page: #0F172A;
|
||||||
|
--bg-card: #1E293B;
|
||||||
|
--bg-card-hover: #263548;
|
||||||
|
--bg-input: #1E293B;
|
||||||
|
--bg-thead: #162032;
|
||||||
|
--bg-chip: #263548;
|
||||||
|
--bg-chip-hover: #2D3F55;
|
||||||
|
--border: #334155;
|
||||||
|
--border-strong: #475569;
|
||||||
|
--text-primary: #F1F5F9;
|
||||||
|
--text-secondary: #94A3B8;
|
||||||
|
--text-muted: #64748B;
|
||||||
|
--text-faint: #475569;
|
||||||
|
--chip-ip-bg: #334155;
|
||||||
|
--chip-ip-color: #94A3B8;
|
||||||
|
--modal-overlay: rgba(0,0,0,0.65);
|
||||||
|
--shadow-card: 0 1px 4px rgba(0,0,0,0.3);
|
||||||
|
--shadow-modal: 0 20px 60px rgba(0,0,0,0.5);
|
||||||
|
--shadow-drop: 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-page); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
background: #1E293B;
|
||||||
|
color: #CBD5E1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .sidebar { background: #020617; }
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px 16px;
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: #94A3B8;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.nav-btn:hover { background: #334155; color: #F1F5F9; }
|
||||||
|
.nav-btn.active { background: #3B82F6; color: #fff; font-weight: 600; }
|
||||||
|
.nav-icon { font-size: 12px; }
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.stat-val { font-size: 18px; font-weight: 700; color: #F8FAFC; }
|
||||||
|
.stat-lbl { font-size: 10px; color: #64748B; }
|
||||||
|
|
||||||
|
.discovery-btn-wrap { padding: 8px; }
|
||||||
|
.btn-discovery {
|
||||||
|
display: block; width: 100%;
|
||||||
|
padding: 10px 8px; background: #1D4ED8; color: #fff;
|
||||||
|
border: none; border-radius: 8px; font-size: 13px; font-weight: 600;
|
||||||
|
cursor: pointer; transition: background 0.15s; text-align: center;
|
||||||
|
}
|
||||||
|
.btn-discovery:hover { background: #2563EB; }
|
||||||
|
|
||||||
|
.btn-export {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
background: #334155;
|
||||||
|
color: #CBD5E1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-export:hover { background: #475569; color: #F8FAFC; }
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
background: #334155; color: #CBD5E1;
|
||||||
|
border: none; border-radius: 6px;
|
||||||
|
font-size: 14px; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.settings-btn:hover { background: #475569; color: #F8FAFC; }
|
||||||
|
|
||||||
|
.lang-switcher {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.lang-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: #334155; color: #94A3B8;
|
||||||
|
border: none; border-radius: 5px;
|
||||||
|
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||||
|
cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.lang-btn:hover { background: #475569; color: #F8FAFC; }
|
||||||
|
.lang-btn.active { background: #3B82F6; color: #fff; }
|
||||||
|
|
||||||
|
/* ── User row ───────────────────────────────────────────────────────────── */
|
||||||
|
.user-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-btn {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #334155; color: #94A3B8;
|
||||||
|
border: none; border-radius: 6px;
|
||||||
|
font-size: 12px; text-align: left;
|
||||||
|
cursor: pointer; transition: all 0.15s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.user-btn:hover { background: #475569; color: #F8FAFC; }
|
||||||
|
.user-name {
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
width: 28px; height: 28px; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: #334155; color: #94A3B8;
|
||||||
|
border: none; border-radius: 6px;
|
||||||
|
cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.logout-btn:hover { background: #7F1D1D; color: #FCA5A5; }
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #EF4444;
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 1000;
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { getToken, clearAuth } from './auth.js'
|
||||||
|
|
||||||
|
const http = axios.create({ baseURL: '/api' })
|
||||||
|
|
||||||
|
http.interceptors.request.use(config => {
|
||||||
|
const token = getToken()
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
http.interceptors.response.use(
|
||||||
|
res => res,
|
||||||
|
err => {
|
||||||
|
if (err.response?.status === 401 && getToken()) {
|
||||||
|
clearAuth()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (username, password) => {
|
||||||
|
const form = new URLSearchParams()
|
||||||
|
form.append('username', username)
|
||||||
|
form.append('password', password)
|
||||||
|
return http.post('/auth/login', form)
|
||||||
|
},
|
||||||
|
updateAccount: (data) => http.put('/auth/account', data),
|
||||||
|
me: () => http.get('/auth/me'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vlansApi = {
|
||||||
|
list: () => http.get('/vlans/'),
|
||||||
|
create: (data) => http.post('/vlans/', data),
|
||||||
|
update: (id, data) => http.put(`/vlans/${id}`, data),
|
||||||
|
remove: (id) => http.delete(`/vlans/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const devicesApi = {
|
||||||
|
list: () => http.get('/devices/'),
|
||||||
|
create: (data) => http.post('/devices/', data),
|
||||||
|
update: (id, data) => http.put(`/devices/${id}`, data),
|
||||||
|
remove: (id) => http.delete(`/devices/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const discoveryApi = {
|
||||||
|
scan: (data) => http.post('/discovery/scan', data),
|
||||||
|
ping: (ips) => http.post('/discovery/ping', { ips }),
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const _token = ref(localStorage.getItem('auth_token') || null)
|
||||||
|
const _username = ref(localStorage.getItem('auth_username') || null)
|
||||||
|
const _mustChange = ref(localStorage.getItem('auth_mustchange') === 'true')
|
||||||
|
|
||||||
|
export const isAuthenticated = computed(() => !!_token.value)
|
||||||
|
export const currentUsername = computed(() => _username.value)
|
||||||
|
export const mustChangePassword = computed(() => _mustChange.value)
|
||||||
|
|
||||||
|
export function setAuth(token, username, mustChange = false) {
|
||||||
|
_token.value = token
|
||||||
|
_username.value = username
|
||||||
|
_mustChange.value = mustChange
|
||||||
|
localStorage.setItem('auth_token', token)
|
||||||
|
localStorage.setItem('auth_username', username)
|
||||||
|
localStorage.setItem('auth_mustchange', String(mustChange))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuth() {
|
||||||
|
_token.value = null
|
||||||
|
_username.value = null
|
||||||
|
_mustChange.value = false
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_username')
|
||||||
|
localStorage.removeItem('auth_mustchange')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return _token.value
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
siProxmox, siDocker,
|
||||||
|
siSynology, siTruenas,
|
||||||
|
siUbiquiti, siMikrotik, siCisco, siTplink, siAsus, siNetgear, siPfsense, siOpnsense, siOpenwrt,
|
||||||
|
siApache, siTraefikproxy,
|
||||||
|
siMariadb,
|
||||||
|
siKubernetes,
|
||||||
|
siDebian, siUbuntu,
|
||||||
|
siAnsible,
|
||||||
|
siDell, siHp,
|
||||||
|
siRaspberrypi, siArduino,
|
||||||
|
siNextcloud, siPaperlessngx, siUptimekuma, siMaterialformkdocs,
|
||||||
|
siJellyfin, siHomeassistant, siPhilipshue, siXiaomi,
|
||||||
|
siExcalidraw,
|
||||||
|
siKde,
|
||||||
|
} from 'simple-icons'
|
||||||
|
|
||||||
|
// Ordre : du plus spécifique au plus générique pour éviter les faux positifs.
|
||||||
|
const BRANDS = [
|
||||||
|
// Hyperviseurs / virtualisation
|
||||||
|
{ kw: ['proxmox', 'pve'], icon: siProxmox },
|
||||||
|
{ kw: ['docker'], icon: siDocker },
|
||||||
|
|
||||||
|
// NAS
|
||||||
|
{ kw: ['synology', 'dsm'], icon: siSynology },
|
||||||
|
{ kw: ['truenas', 'freenas'], icon: siTruenas },
|
||||||
|
|
||||||
|
// Réseau
|
||||||
|
{ kw: ['ubiquiti', 'unifi', 'usg', 'udm'], icon: siUbiquiti },
|
||||||
|
{ kw: ['mikrotik', 'routeros'], icon: siMikrotik },
|
||||||
|
{ kw: ['cisco'], icon: siCisco },
|
||||||
|
{ kw: ['tp-link', 'tplink', 'tp link'], icon: siTplink },
|
||||||
|
{ kw: ['asus'], icon: siAsus },
|
||||||
|
{ kw: ['netgear'], icon: siNetgear },
|
||||||
|
{ kw: ['pfsense'], icon: siPfsense },
|
||||||
|
{ kw: ['opnsense'], icon: siOpnsense },
|
||||||
|
{ kw: ['openwrt'], icon: siOpenwrt },
|
||||||
|
|
||||||
|
// Serveurs web / proxy
|
||||||
|
{ kw: ['apache', 'apache2', 'httpd'], icon: siApache },
|
||||||
|
{ kw: ['traefik'], icon: siTraefikproxy },
|
||||||
|
|
||||||
|
// Bases de données
|
||||||
|
{ kw: ['mariadb', 'maria db'], icon: siMariadb },
|
||||||
|
|
||||||
|
// Orchestration
|
||||||
|
{ kw: ['kubernetes', 'k8s', 'kubectl', 'k3s'], icon: siKubernetes },
|
||||||
|
|
||||||
|
// OS / distros
|
||||||
|
{ kw: ['debian'], icon: siDebian },
|
||||||
|
{ kw: ['ubuntu'], icon: siUbuntu },
|
||||||
|
|
||||||
|
// Automatisation
|
||||||
|
{ kw: ['ansible'], icon: siAnsible },
|
||||||
|
|
||||||
|
// Serveurs
|
||||||
|
{ kw: ['dell', 'idrac', 'poweredge'], icon: siDell },
|
||||||
|
{ kw: ['proliant', 'ilo', 'hewlett'], icon: siHp },
|
||||||
|
|
||||||
|
// SBC / DIY
|
||||||
|
{ kw: ['raspberry', 'raspberrypi', 'rpi', 'raspi'], icon: siRaspberrypi },
|
||||||
|
{ kw: ['arduino'], icon: siArduino },
|
||||||
|
|
||||||
|
// Environnements de bureau
|
||||||
|
{ kw: ['kde', 'plasma', 'kde desktop'], icon: siKde },
|
||||||
|
|
||||||
|
// Outils
|
||||||
|
{ kw: ['excalidraw'], icon: siExcalidraw },
|
||||||
|
|
||||||
|
// Productivité / self-hosted
|
||||||
|
{ kw: ['nextcloud'], icon: siNextcloud },
|
||||||
|
{ kw: ['paperless', 'paperless-ng', 'paperless-ngx'], icon: siPaperlessngx },
|
||||||
|
{ kw: ['uptime-kuma', 'uptimekuma', 'uptime kuma'], icon: siUptimekuma },
|
||||||
|
{ kw: ['mkdocs', 'material for mkdocs'], icon: siMaterialformkdocs },
|
||||||
|
|
||||||
|
// Médias / domotique
|
||||||
|
{ kw: ['jellyfin'], icon: siJellyfin },
|
||||||
|
{ kw: ['homeassistant', 'home assistant', 'hassio', 'hass'], icon: siHomeassistant },
|
||||||
|
{ kw: ['philips hue', 'hue bridge', 'hue hub'], icon: siPhilipshue },
|
||||||
|
{ kw: ['xiaomi', 'mi home', 'yeelight'], icon: siXiaomi },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function detectBrands(name, description) {
|
||||||
|
const text = ((name || '') + ' ' + (description || '')).toLowerCase()
|
||||||
|
if (!text.trim()) return []
|
||||||
|
return BRANDS.filter(b => b.kw.some(kw => text.includes(kw))).map(b => b.icon)
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
<template>
|
||||||
|
<div class="modal-overlay" @click.self="forced ? null : $emit('close')">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{{ t('accountSettings') }}</h2>
|
||||||
|
<button v-if="!forced" class="close-btn" @click="$emit('close')">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div v-if="forced" class="forced-warning">
|
||||||
|
{{ t('mustChangePasswordWarning') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="current-user">{{ t('loginUsername') }} : <strong>{{ currentUsername }}</strong></p>
|
||||||
|
|
||||||
|
<div class="section-title">{{ t('newUsername') }}</div>
|
||||||
|
<div class="field">
|
||||||
|
<input
|
||||||
|
v-model="newUsername"
|
||||||
|
type="text"
|
||||||
|
:placeholder="currentUsername"
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">
|
||||||
|
{{ t('newPassword') }}
|
||||||
|
<span v-if="forced" class="required-star">*</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<input
|
||||||
|
v-model="newPassword"
|
||||||
|
type="password"
|
||||||
|
:placeholder="forced ? '' : t('leaveBlankToKeep')"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="newPassword || forced">
|
||||||
|
<div class="section-title">{{ t('confirmPassword') }}</div>
|
||||||
|
<div class="field">
|
||||||
|
<input
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="section-title">{{ t('currentPassword') }} *</div>
|
||||||
|
<div class="field">
|
||||||
|
<input
|
||||||
|
v-model="currentPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
||||||
|
<p v-if="successMsg" class="success-msg">{{ successMsg }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button v-if="!forced" class="btn-cancel" @click="$emit('close')">{{ t('cancel') }}</button>
|
||||||
|
<button class="btn-save" :disabled="saving" @click="save">
|
||||||
|
{{ saving ? '…' : t('save') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { authApi } from '../api.js'
|
||||||
|
import { t } from '../i18n.js'
|
||||||
|
import { currentUsername } from '../auth.js'
|
||||||
|
|
||||||
|
const props = defineProps({ forced: { type: Boolean, default: false } })
|
||||||
|
const emit = defineEmits(['close', 'updated'])
|
||||||
|
|
||||||
|
const newUsername = ref('')
|
||||||
|
const newPassword = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const currentPassword = ref('')
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const successMsg = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const _USERNAME_RE = /^[a-zA-Z0-9._-]{1,64}$/
|
||||||
|
|
||||||
|
function _validateClient() {
|
||||||
|
if (props.forced && !newPassword.value) {
|
||||||
|
errorMsg.value = t('newPasswordRequired')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (newUsername.value && !_USERNAME_RE.test(newUsername.value)) {
|
||||||
|
errorMsg.value = t('usernameInvalid')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (newPassword.value) {
|
||||||
|
if (newPassword.value.length < 8) {
|
||||||
|
errorMsg.value = t('passwordTooShort')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!/[a-zA-Z]/.test(newPassword.value) || !/[0-9]/.test(newPassword.value)) {
|
||||||
|
errorMsg.value = t('passwordTooWeak')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
|
errorMsg.value = t('passwordMismatch')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = ''
|
||||||
|
|
||||||
|
if (!currentPassword.value) {
|
||||||
|
errorMsg.value = t('currentPassword') + ' ' + t('loginPassword').toLowerCase()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!_validateClient()) return
|
||||||
|
if (!newUsername.value && !newPassword.value) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = { current_password: currentPassword.value }
|
||||||
|
if (newUsername.value) payload.new_username = newUsername.value
|
||||||
|
if (newPassword.value) payload.new_password = newPassword.value
|
||||||
|
const { data } = await authApi.updateAccount(payload)
|
||||||
|
successMsg.value = t('accountUpdated')
|
||||||
|
emit('updated', {
|
||||||
|
token: data.access_token,
|
||||||
|
username: data.username,
|
||||||
|
mustChangePassword: data.must_change_password || false,
|
||||||
|
})
|
||||||
|
newUsername.value = ''
|
||||||
|
newPassword.value = ''
|
||||||
|
confirmPassword.value = ''
|
||||||
|
currentPassword.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e.response?.data?.detail || ''
|
||||||
|
const MAP = {
|
||||||
|
'Current password is incorrect': 'wrongPassword',
|
||||||
|
'password_too_short': 'passwordTooShort',
|
||||||
|
'password_too_weak': 'passwordTooWeak',
|
||||||
|
'username_invalid': 'usernameInvalid',
|
||||||
|
}
|
||||||
|
errorMsg.value = t(MAP[detail] || 'saveError')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: var(--modal-overlay);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
box-shadow: var(--shadow-modal);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 18px 20px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modal-header h2 { font-size: 16px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none; border: none; font-size: 16px;
|
||||||
|
color: var(--text-muted); cursor: pointer; line-height: 1;
|
||||||
|
}
|
||||||
|
.close-btn:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forced-warning {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #92400E;
|
||||||
|
background: #FEF3C7;
|
||||||
|
border: 1px solid #FCD34D;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.dark .forced-warning) {
|
||||||
|
background: #2D2000;
|
||||||
|
border-color: #5C4200;
|
||||||
|
color: #FCD34D;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-star { color: #EF4444; margin-left: 2px; }
|
||||||
|
|
||||||
|
.current-user {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.current-user strong { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 11px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.field input:focus { border-color: #3B82F6; }
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
font-size: 12px; color: #EF4444;
|
||||||
|
background: #FEF2F2; border: 1px solid #FECACA;
|
||||||
|
border-radius: 6px; padding: 7px 10px;
|
||||||
|
}
|
||||||
|
.success-msg {
|
||||||
|
font-size: 12px; color: #15803D;
|
||||||
|
background: #F0FDF4; border: 1px solid #BBF7D0;
|
||||||
|
border-radius: 6px; padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.dark .error-msg) { background: #2A1515; border-color: #3D1F1F; }
|
||||||
|
:global(html.dark .success-msg) { background: #0F2D1A; border-color: #1A4D2A; color: #4ADE80; }
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 7px 16px; background: var(--bg-chip);
|
||||||
|
color: var(--text-secondary); border: 1.5px solid var(--border);
|
||||||
|
border-radius: 7px; font-size: 13px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-cancel:hover { background: var(--bg-chip-hover); }
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
padding: 7px 18px; background: #3B82F6;
|
||||||
|
color: #fff; border: none; border-radius: 7px;
|
||||||
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-save:hover:not(:disabled) { background: #2563EB; }
|
||||||
|
.btn-save:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Simple Icons : logos des marques détectées (chips de topologie) -->
|
||||||
|
<span v-if="!typeOnly && brandIcons.length > 0" class="brand-icons">
|
||||||
|
<svg
|
||||||
|
v-for="icon in brandIcons"
|
||||||
|
:key="icon.title"
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path :d="icon.path" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Lucide : icône générique par type -->
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
:is="lucideIcon"
|
||||||
|
:size="size"
|
||||||
|
:stroke-width="1.75"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import {
|
||||||
|
Server, Network, Wifi, Database, Globe, GitFork,
|
||||||
|
Camera, Thermometer, Gauge, House, PlugZap, ShieldAlert,
|
||||||
|
Lightbulb, BellRing, Antenna, Monitor, Laptop, Box,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { detectBrands } from '../brandIcons.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
deviceType: { type: String, default: 'other' },
|
||||||
|
isGateway: { type: Boolean, default: false },
|
||||||
|
isLivebox: { type: Boolean, default: false },
|
||||||
|
name: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
size: { type: Number, default: 18 },
|
||||||
|
typeOnly: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const brandIcons = computed(() => detectBrands(props.name, props.description))
|
||||||
|
|
||||||
|
const LUCIDE_MAP = {
|
||||||
|
server: Server,
|
||||||
|
switch: Network,
|
||||||
|
router: Wifi,
|
||||||
|
nas: Database,
|
||||||
|
gateway: GitFork,
|
||||||
|
livebox: Globe,
|
||||||
|
camera: Camera,
|
||||||
|
temperature: Thermometer,
|
||||||
|
sensor: Gauge,
|
||||||
|
hub: House,
|
||||||
|
smart_plug: PlugZap,
|
||||||
|
alarm: ShieldAlert,
|
||||||
|
light: Lightbulb,
|
||||||
|
doorbell: BellRing,
|
||||||
|
access_point: Antenna,
|
||||||
|
desktop: Monitor,
|
||||||
|
laptop: Laptop,
|
||||||
|
other: Box,
|
||||||
|
}
|
||||||
|
|
||||||
|
const lucideIcon = computed(() => {
|
||||||
|
if (props.isLivebox) return Globe
|
||||||
|
if (props.isGateway) return GitFork
|
||||||
|
return LUCIDE_MAP[props.deviceType] || Box
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.brand-icons {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,689 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>{{ t('devices') }}</h1>
|
||||||
|
<button class="btn-primary" @click="openAdd">{{ t('addDevice') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.devices.length === 0" class="empty">
|
||||||
|
{{ t('noDevicesConfigured') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Barre de filtres -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<span class="search-icon">⌕</span>
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
class="search-input"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('searchPlaceholder')"
|
||||||
|
@keydown.escape="search = ''"
|
||||||
|
/>
|
||||||
|
<button v-if="search" class="search-clear" @click="search = ''">✕</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="usedTypes.length > 1" class="filter-group" @mousedown.stop>
|
||||||
|
<button class="filter-btn" :class="{ active: filterTypes.length }" @click="toggleDrop('types')">
|
||||||
|
{{ t('filterType') }}
|
||||||
|
<span v-if="filterTypes.length" class="filter-count">{{ filterTypes.length }}</span>
|
||||||
|
<span class="filter-chevron">▾</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="openDrop === 'types'" class="filter-drop">
|
||||||
|
<label v-for="t2 in usedTypes" :key="t2.value" class="filter-opt">
|
||||||
|
<input type="checkbox" :value="t2.value" v-model="filterTypes" />
|
||||||
|
{{ t2.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="usedVlans.length > 0" class="filter-group" @mousedown.stop>
|
||||||
|
<button class="filter-btn" :class="{ active: filterVlans.length }" @click="toggleDrop('vlans')">
|
||||||
|
{{ t('filterNetwork') }}
|
||||||
|
<span v-if="filterVlans.length" class="filter-count">{{ filterVlans.length }}</span>
|
||||||
|
<span class="filter-chevron">▾</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="openDrop === 'vlans'" class="filter-drop">
|
||||||
|
<label v-for="v in usedVlans" :key="v.id" class="filter-opt">
|
||||||
|
<input type="checkbox" :value="v.id" v-model="filterVlans" />
|
||||||
|
<span class="vlan-dot" :style="{ background: v.color }"></span>
|
||||||
|
{{ v.vlan_id != null ? 'VLAN ' + v.vlan_id + ' — ' : 'LAN — ' }}{{ v.name }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="availableBrands.length > 0" class="filter-group" @mousedown.stop>
|
||||||
|
<button class="filter-btn" :class="{ active: filterBrands.length }" @click="toggleDrop('brands')">
|
||||||
|
{{ t('filterBrand') }}
|
||||||
|
<span v-if="filterBrands.length" class="filter-count">{{ filterBrands.length }}</span>
|
||||||
|
<span class="filter-chevron">▾</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="openDrop === 'brands'" class="filter-drop">
|
||||||
|
<label v-for="b in availableBrands" :key="b.title" class="filter-opt">
|
||||||
|
<input type="checkbox" :value="b.title" v-model="filterBrands" />
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path :d="b.path" />
|
||||||
|
</svg>
|
||||||
|
{{ b.title }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="usedVirts.length > 0" class="filter-group" @mousedown.stop>
|
||||||
|
<button class="filter-btn" :class="{ active: filterVirts.length }" @click="toggleDrop('virts')">
|
||||||
|
{{ t('filterVirt') }}
|
||||||
|
<span v-if="filterVirts.length" class="filter-count">{{ filterVirts.length }}</span>
|
||||||
|
<span class="filter-chevron">▾</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="openDrop === 'virts'" class="filter-drop">
|
||||||
|
<label v-for="vt in usedVirts" :key="vt" class="filter-opt">
|
||||||
|
<input type="checkbox" :value="vt" v-model="filterVirts" />
|
||||||
|
{{ virtLabelFor(vt) }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="hasActiveFilters">
|
||||||
|
<button class="filter-reset" @click="resetFilters">{{ t('clearFilters') }}</button>
|
||||||
|
<span class="filter-results">{{ sortedDevices.length }} / {{ props.devices.length }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="sortedDevices.length === 0" class="empty">
|
||||||
|
{{ t('noDevicesFiltered') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="devices-grid">
|
||||||
|
<div v-for="device in sortedDevices" :key="device.id" class="device-card">
|
||||||
|
<div class="device-header">
|
||||||
|
<div class="device-icon" :class="iconClass(device)">
|
||||||
|
<DeviceIcon
|
||||||
|
:device-type="device.type"
|
||||||
|
:is-gateway="device.is_gateway"
|
||||||
|
:is-livebox="device.is_livebox"
|
||||||
|
:name="device.name"
|
||||||
|
:description="device.description"
|
||||||
|
:size="20"
|
||||||
|
type-only
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="device-info">
|
||||||
|
<div class="device-name">{{ device.name }}</div>
|
||||||
|
<div class="device-meta">
|
||||||
|
<span class="badge" :class="'badge-' + device.type">{{ typeLabel(device.type) }}</span>
|
||||||
|
<span v-if="device.is_gateway" class="badge badge-gateway">{{ t('badgeGateway') }}</span>
|
||||||
|
<span v-if="device.is_livebox" class="badge badge-livebox">{{ t('badgeLivebox') }}</span>
|
||||||
|
<span v-if="device.virt_type" class="badge badge-virt" :class="'badge-virt-' + device.virt_type">{{ virtLabelFor(device.virt_type) }}</span>
|
||||||
|
<span
|
||||||
|
v-for="brand in detectBrands(device.name, device.description)"
|
||||||
|
:key="brand.title"
|
||||||
|
class="badge-brand"
|
||||||
|
:title="brand.title"
|
||||||
|
:style="{ background: '#' + brand.hex }"
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path :d="brand.path" />
|
||||||
|
</svg>
|
||||||
|
{{ brand.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-actions">
|
||||||
|
<button class="btn-icon" @click="openEdit(device)" :title="t('save')">✎</button>
|
||||||
|
<button class="btn-icon danger" @click="remove(device)" :title="t('clearFilters')">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="device.description" class="device-desc">{{ device.description }}</div>
|
||||||
|
<a v-if="device.url" :href="device.url" target="_blank" rel="noreferrer noopener" class="device-url">{{ device.url }}</a>
|
||||||
|
<div v-if="device.interfaces.length" class="interfaces">
|
||||||
|
<div v-for="iface in device.interfaces" :key="iface.id" class="iface">
|
||||||
|
<span class="iface-name">{{ iface.name }}</span>
|
||||||
|
<span v-if="iface.ip_address" class="iface-ip">{{ iface.ip_address }}</span>
|
||||||
|
<span v-if="iface.vlan_id" class="iface-vlan" :style="{ background: vlanColor(iface.vlan_id) }">
|
||||||
|
{{ vlanLabel(iface.vlan_id) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="iface.is_upstream" class="iface-upstream">↑ WAN</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="showForm" class="modal-overlay" @click.self="showForm = false">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>{{ editing ? t('editDevice') : t('newDevice') }}</h2>
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<div class="fields-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('fieldName') }}</label>
|
||||||
|
<input v-model="form.name" type="text" required placeholder="ex: srv-web-01" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('fieldType') }}</label>
|
||||||
|
<select v-model="form.type">
|
||||||
|
<option v-for="dtype in deviceTypes" :key="dtype.value" :value="dtype.value">{{ dtype.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('fieldDescription') }}</label>
|
||||||
|
<textarea v-model="form.description" rows="2" :placeholder="t('descPlaceholder')"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="checkboxes">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" v-model="form.is_gateway" />
|
||||||
|
<span>{{ t('isGateway') }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" v-model="form.is_livebox" />
|
||||||
|
<span>{{ t('isLivebox') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="!['desktop','laptop'].includes(form.type)" class="field">
|
||||||
|
<label>{{ t('accessUrl') }}</label>
|
||||||
|
<input v-model="form.url" type="url" placeholder="https://192.168.1.1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('runtimeType') }}</label>
|
||||||
|
<select v-model="form.virt_type">
|
||||||
|
<option :value="null">{{ t('notSpecified') }}</option>
|
||||||
|
<option value="baremetal">{{ t('baremetal') }}</option>
|
||||||
|
<option value="lxc">{{ t('lxcContainer') }}</option>
|
||||||
|
<option value="qemu">{{ t('vmQemu') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="interfaces-section">
|
||||||
|
<div class="interfaces-header">
|
||||||
|
<strong>{{ t('networkInterfaces') }}</strong>
|
||||||
|
<button type="button" class="btn-add-iface" @click="addInterface">{{ t('addInterface') }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="form.interfaces.length === 0" class="iface-empty">
|
||||||
|
{{ t('noInterface') }}
|
||||||
|
</div>
|
||||||
|
<div v-for="(iface, idx) in form.interfaces" :key="idx" class="iface-row">
|
||||||
|
<input v-model="iface.name" type="text" placeholder="eth0" class="iface-input" />
|
||||||
|
<input v-model="iface.ip_address" type="text" placeholder="IP" class="iface-input wide" />
|
||||||
|
<select v-model="iface.vlan_id" class="iface-select">
|
||||||
|
<option :value="null">— VLAN</option>
|
||||||
|
<option v-for="v in props.vlans" :key="v.id" :value="v.id">
|
||||||
|
{{ v.vlan_id != null ? 'VLAN ' + v.vlan_id : 'LAN' }} — {{ v.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label class="iface-upstream">
|
||||||
|
<input type="checkbox" v-model="iface.is_upstream" />
|
||||||
|
<span>WAN</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn-rm-iface" @click="removeInterface(idx)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" @click="showForm = false">{{ t('cancel') }}</button>
|
||||||
|
<button type="submit" class="btn-primary">{{ editing ? t('save') : t('create') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { devicesApi } from '../api.js'
|
||||||
|
import DeviceIcon from './DeviceIcon.vue'
|
||||||
|
import { detectBrands } from '../brandIcons.js'
|
||||||
|
import { t, tFmt } from '../i18n.js'
|
||||||
|
|
||||||
|
const props = defineProps({ devices: Array, vlans: Array })
|
||||||
|
|
||||||
|
function ipToInt(ip) {
|
||||||
|
if (!ip) return Infinity
|
||||||
|
const p = ip.split('.').map(Number)
|
||||||
|
return p.length === 4 ? (p[0] << 24 | p[1] << 16 | p[2] << 8 | p[3]) >>> 0 : Infinity
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceFirstIp(device) {
|
||||||
|
for (const iface of device.interfaces) {
|
||||||
|
if (iface.ip_address) return iface.ip_address
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recherche + Filtres ───────────────────────────────────────────────────────
|
||||||
|
const search = ref('')
|
||||||
|
const filterTypes = ref([])
|
||||||
|
const filterVlans = ref([])
|
||||||
|
const filterBrands = ref([])
|
||||||
|
const filterVirts = ref([])
|
||||||
|
const openDrop = ref(null)
|
||||||
|
|
||||||
|
function toggleDrop(name) {
|
||||||
|
openDrop.value = openDrop.value === name ? null : name
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() { openDrop.value = null }
|
||||||
|
onMounted(() => document.addEventListener('mousedown', closeDropdown))
|
||||||
|
onUnmounted(() => document.removeEventListener('mousedown', closeDropdown))
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(() =>
|
||||||
|
search.value.trim() ||
|
||||||
|
filterTypes.value.length + filterVlans.value.length +
|
||||||
|
filterBrands.value.length + filterVirts.value.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
search.value = ''
|
||||||
|
filterTypes.value = []
|
||||||
|
filterVlans.value = []
|
||||||
|
filterBrands.value = []
|
||||||
|
filterVirts.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceTypes = computed(() => [
|
||||||
|
{ value: 'server', label: t('typeServer') },
|
||||||
|
{ value: 'switch', label: t('typeSwitch') },
|
||||||
|
{ value: 'router', label: t('typeRouter') },
|
||||||
|
{ value: 'nas', label: t('typeNas') },
|
||||||
|
{ value: 'gateway', label: t('typeGateway') },
|
||||||
|
{ value: 'livebox', label: t('typeLivebox') },
|
||||||
|
{ value: 'camera', label: t('typeCamera') },
|
||||||
|
{ value: 'temperature', label: t('typeTemperature') },
|
||||||
|
{ value: 'sensor', label: t('typeSensor') },
|
||||||
|
{ value: 'hub', label: t('typeHub') },
|
||||||
|
{ value: 'smart_plug', label: t('typeSmartPlug') },
|
||||||
|
{ value: 'alarm', label: t('typeAlarm') },
|
||||||
|
{ value: 'light', label: t('typeLight') },
|
||||||
|
{ value: 'doorbell', label: t('typeDoorbell') },
|
||||||
|
{ value: 'access_point', label: t('typeAccessPoint') },
|
||||||
|
{ value: 'desktop', label: t('typeDesktop') },
|
||||||
|
{ value: 'laptop', label: t('typeLaptop') },
|
||||||
|
{ value: 'other', label: t('typeOther') },
|
||||||
|
])
|
||||||
|
|
||||||
|
const usedTypes = computed(() => {
|
||||||
|
const used = new Set(props.devices.map(d => d.type))
|
||||||
|
return deviceTypes.value.filter(t2 => used.has(t2.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const usedVlans = computed(() => {
|
||||||
|
const usedIds = new Set(
|
||||||
|
props.devices.flatMap(d => d.interfaces.map(i => i.vlan_id).filter(id => id != null))
|
||||||
|
)
|
||||||
|
return props.vlans.filter(v => usedIds.has(v.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableBrands = computed(() => {
|
||||||
|
const seen = new Map()
|
||||||
|
for (const d of props.devices) {
|
||||||
|
for (const b of detectBrands(d.name, d.description)) {
|
||||||
|
if (!seen.has(b.title)) seen.set(b.title, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.values()].sort((a, b) => a.title.localeCompare(b.title))
|
||||||
|
})
|
||||||
|
|
||||||
|
const usedVirts = computed(() => {
|
||||||
|
const vts = new Set(props.devices.map(d => d.virt_type).filter(Boolean))
|
||||||
|
return ['baremetal', 'lxc', 'qemu'].filter(v => vts.has(v))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredDevices = computed(() => {
|
||||||
|
const q = search.value.trim().toLowerCase()
|
||||||
|
return props.devices.filter(d => {
|
||||||
|
if (q) {
|
||||||
|
const inName = d.name.toLowerCase().includes(q)
|
||||||
|
const inDesc = (d.description || '').toLowerCase().includes(q)
|
||||||
|
const inIp = d.interfaces.some(i => (i.ip_address || '').includes(q))
|
||||||
|
if (!inName && !inDesc && !inIp) return false
|
||||||
|
}
|
||||||
|
if (filterTypes.value.length && !filterTypes.value.includes(d.type)) return false
|
||||||
|
if (filterVlans.value.length && !d.interfaces.some(i => filterVlans.value.includes(i.vlan_id))) return false
|
||||||
|
if (filterBrands.value.length) {
|
||||||
|
const dBrands = detectBrands(d.name, d.description).map(b => b.title)
|
||||||
|
if (!filterBrands.value.some(b => dBrands.includes(b))) return false
|
||||||
|
}
|
||||||
|
if (filterVirts.value.length && !filterVirts.value.includes(d.virt_type)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedDevices = computed(() =>
|
||||||
|
[...filteredDevices.value].sort((a, b) => ipToInt(deviceFirstIp(a)) - ipToInt(deviceFirstIp(b)))
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits(['refresh'])
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editing = ref(null)
|
||||||
|
const form = reactive({
|
||||||
|
name: '', type: 'server', description: '',
|
||||||
|
is_gateway: false, is_livebox: false, virt_type: null, url: null, interfaces: []
|
||||||
|
})
|
||||||
|
|
||||||
|
function typeLabel(type) {
|
||||||
|
return deviceTypes.value.find(d => d.value === type)?.label || type
|
||||||
|
}
|
||||||
|
|
||||||
|
function virtLabelFor(vt) {
|
||||||
|
const map = { baremetal: t('virtBaremetal'), lxc: t('virtLxc'), qemu: t('virtQemu') }
|
||||||
|
return map[vt] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconClass(device) {
|
||||||
|
if (device.is_livebox) return 'icon-livebox'
|
||||||
|
if (device.is_gateway) return 'icon-gateway'
|
||||||
|
return 'icon-' + device.type
|
||||||
|
}
|
||||||
|
|
||||||
|
function vlanColor(vlanId) {
|
||||||
|
return props.vlans.find(v => v.id === vlanId)?.color || '#94A3B8'
|
||||||
|
}
|
||||||
|
|
||||||
|
function vlanLabel(vlanId) {
|
||||||
|
const v = props.vlans.find(v => v.id === vlanId)
|
||||||
|
if (!v) return '?'
|
||||||
|
return v.vlan_id != null ? `VLAN ${v.vlan_id}` : 'LAN'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
editing.value = null
|
||||||
|
Object.assign(form, { name: '', type: 'server', description: '', is_gateway: false, is_livebox: false, virt_type: null, url: null, interfaces: [] })
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(device) {
|
||||||
|
editing.value = device
|
||||||
|
Object.assign(form, {
|
||||||
|
name: device.name,
|
||||||
|
type: device.type,
|
||||||
|
description: device.description,
|
||||||
|
is_gateway: device.is_gateway,
|
||||||
|
is_livebox: device.is_livebox,
|
||||||
|
virt_type: device.virt_type || null,
|
||||||
|
url: device.url || null,
|
||||||
|
interfaces: device.interfaces.map(i => ({ ...i }))
|
||||||
|
})
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInterface() {
|
||||||
|
form.interfaces.push({ name: 'eth' + form.interfaces.length, ip_address: '', vlan_id: null, is_upstream: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeInterface(idx) {
|
||||||
|
form.interfaces.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
if (editing.value) {
|
||||||
|
await devicesApi.update(editing.value.id, form)
|
||||||
|
} else {
|
||||||
|
await devicesApi.create(form)
|
||||||
|
}
|
||||||
|
showForm.value = false
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.response?.data?.detail || t('saveError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(device) {
|
||||||
|
if (!confirm(`Supprimer ${tFmt('confirmDeleteDevice', device.name)}`)) return
|
||||||
|
try {
|
||||||
|
await devicesApi.remove(device.id)
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.response?.data?.detail || t('deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { height: 100vh; overflow-y: auto; background: var(--bg-page); padding: 32px; }
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
|
h1 { font-size: 22px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 9px 18px; background: #3B82F6; color: #fff;
|
||||||
|
border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #2563EB; }
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 9px 18px; background: var(--border); color: var(--text-secondary);
|
||||||
|
border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: var(--border-strong); }
|
||||||
|
|
||||||
|
.empty { padding: 48px; text-align: center; color: var(--text-faint); font-size: 15px; }
|
||||||
|
|
||||||
|
/* ── Barre de filtres ─────────────────────────────────────────────────────── */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
margin-bottom: 16px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||||
|
.search-icon {
|
||||||
|
position: absolute; left: 8px; font-size: 15px; color: var(--text-faint);
|
||||||
|
pointer-events: none; line-height: 1;
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
height: 28px; padding: 0 28px 0 26px;
|
||||||
|
border: 1.5px solid var(--border); border-radius: 8px;
|
||||||
|
font-size: 12px; color: var(--text-primary); background: var(--bg-input);
|
||||||
|
width: 160px; outline: none;
|
||||||
|
}
|
||||||
|
.search-input:focus { border-color: #3B82F6; }
|
||||||
|
.search-input::placeholder { color: var(--text-faint); }
|
||||||
|
.search-clear {
|
||||||
|
position: absolute; right: 6px;
|
||||||
|
background: none; border: none; font-size: 11px; color: var(--text-faint);
|
||||||
|
cursor: pointer; padding: 0; line-height: 1;
|
||||||
|
}
|
||||||
|
.search-clear:hover { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.filter-group { position: relative; }
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 0 10px; height: 28px;
|
||||||
|
background: var(--bg-input); border: 1.5px solid var(--border);
|
||||||
|
border-radius: 8px; font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||||
|
cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.filter-btn:hover { border-color: var(--border-strong); background: var(--bg-card-hover); }
|
||||||
|
.filter-btn.active { background: #EFF6FF; border-color: #93C5FD; color: #1D4ED8; }
|
||||||
|
|
||||||
|
.filter-count {
|
||||||
|
background: #3B82F6; color: #fff;
|
||||||
|
border-radius: 10px; padding: 0 5px;
|
||||||
|
font-size: 10px; font-weight: 700; line-height: 16px;
|
||||||
|
}
|
||||||
|
.filter-chevron { font-size: 9px; opacity: 0.5; }
|
||||||
|
|
||||||
|
.filter-drop {
|
||||||
|
position: absolute; top: calc(100% + 4px); left: 0; z-index: 50;
|
||||||
|
background: var(--bg-card); border: 1.5px solid var(--border); border-radius: 10px;
|
||||||
|
padding: 6px; min-width: 190px; max-height: 260px; overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow-drop);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-opt {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 5px 8px; border-radius: 6px; cursor: pointer;
|
||||||
|
font-size: 12px; color: var(--text-primary); white-space: nowrap; user-select: none;
|
||||||
|
}
|
||||||
|
.filter-opt:hover { background: var(--bg-page); }
|
||||||
|
.filter-opt input[type="checkbox"] { cursor: pointer; width: 13px; height: 13px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.vlan-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.filter-reset {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
padding: 0 10px; height: 28px;
|
||||||
|
background: #FEE2E2; color: #B91C1C;
|
||||||
|
border: none; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.filter-reset:hover { background: #FECACA; }
|
||||||
|
|
||||||
|
.filter-results { font-size: 12px; color: var(--text-faint); font-weight: 500; }
|
||||||
|
|
||||||
|
/* ── Grille équipements ───────────────────────────────────────────────────── */
|
||||||
|
.devices-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card {
|
||||||
|
background: var(--bg-card); border-radius: 12px; padding: 16px;
|
||||||
|
box-shadow: var(--shadow-card); border: 1.5px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-header { display: flex; align-items: flex-start; gap: 12px; }
|
||||||
|
.device-icon {
|
||||||
|
width: 40px; height: 40px; border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 20px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.icon-livebox { background: #FEE2E2; color: #B91C1C; }
|
||||||
|
.icon-gateway { background: #FEF3C7; color: #92400E; }
|
||||||
|
.icon-server { background: #DBEAFE; color: #1D4ED8; }
|
||||||
|
.icon-switch { background: #D1FAE5; color: #065F46; }
|
||||||
|
.icon-router { background: #FFEDD5; color: #C2410C; }
|
||||||
|
.icon-nas { background: #EDE9FE; color: #6D28D9; }
|
||||||
|
.icon-camera { background: #E0F2FE; color: #0369A1; }
|
||||||
|
.icon-temperature { background: #F0F9FF; color: #0284C7; }
|
||||||
|
.icon-sensor { background: #ECFCCB; color: #3F6212; }
|
||||||
|
.icon-hub { background: #EEF2FF; color: #3730A3; }
|
||||||
|
.icon-smart_plug { background: #FFF7ED; color: #C2410C; }
|
||||||
|
.icon-alarm { background: #FEF2F2; color: #B91C1C; }
|
||||||
|
.icon-light { background: #FEFCE8; color: #854D0E; }
|
||||||
|
.icon-doorbell { background: #FDF4FF; color: #7E22CE; }
|
||||||
|
.icon-access_point { background: #CCFBF1; color: #0F766E; }
|
||||||
|
.icon-desktop { background: #E0E7FF; color: #4338CA; }
|
||||||
|
.icon-laptop { background: #DCFCE7; color: #15803D; }
|
||||||
|
.icon-other { background: #F1F5F9; color: #475569; }
|
||||||
|
|
||||||
|
.device-info { flex: 1; min-width: 0; }
|
||||||
|
.device-name { font-weight: 700; font-size: 15px; color: var(--text-primary); }
|
||||||
|
.device-meta { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 11px; font-weight: 600; padding: 2px 8px;
|
||||||
|
border-radius: 20px; color: #fff;
|
||||||
|
}
|
||||||
|
.badge-server { background: #3B82F6; }
|
||||||
|
.badge-switch { background: #10B981; }
|
||||||
|
.badge-router { background: #F97316; }
|
||||||
|
.badge-nas { background: #8B5CF6; }
|
||||||
|
.badge-gateway { background: #6366F1; }
|
||||||
|
.badge-livebox { background: #EF4444; }
|
||||||
|
.badge-camera { background: #0284C7; }
|
||||||
|
.badge-temperature { background: #0EA5E9; }
|
||||||
|
.badge-sensor { background: #65A30D; }
|
||||||
|
.badge-hub { background: #4F46E5; }
|
||||||
|
.badge-smart_plug { background: #EA580C; }
|
||||||
|
.badge-alarm { background: #DC2626; }
|
||||||
|
.badge-light { background: #CA8A04; }
|
||||||
|
.badge-doorbell { background: #A21CAF; }
|
||||||
|
.badge-access_point { background: #14B8A6; }
|
||||||
|
.badge-desktop { background: #4F46E5; }
|
||||||
|
.badge-laptop { background: #16A34A; }
|
||||||
|
.badge-other { background: #94A3B8; }
|
||||||
|
.badge-virt { background: #1E293B; }
|
||||||
|
.badge-virt-baremetal { background: #475569; }
|
||||||
|
.badge-virt-lxc { background: #0369A1; }
|
||||||
|
.badge-virt-qemu { background: #7C3AED; }
|
||||||
|
|
||||||
|
.badge-brand {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-size: 11px; font-weight: 600; padding: 2px 7px;
|
||||||
|
border-radius: 20px; color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||||
|
.btn-icon {
|
||||||
|
width: 28px; height: 28px; border: none; border-radius: 6px;
|
||||||
|
background: var(--bg-chip); color: var(--text-secondary); font-size: 14px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.btn-icon:hover { background: var(--border-strong); color: var(--text-primary); }
|
||||||
|
.btn-icon.danger:hover { background: rgba(239,68,68,0.15); color: #EF4444; }
|
||||||
|
|
||||||
|
.device-desc { font-size: 13px; color: var(--text-muted); margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
|
||||||
|
.device-url {
|
||||||
|
display: inline-block; margin-top: 6px;
|
||||||
|
font-size: 12px; color: #3B82F6; font-family: monospace;
|
||||||
|
text-decoration: none; word-break: break-all;
|
||||||
|
}
|
||||||
|
.device-url:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.interfaces { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.iface { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; }
|
||||||
|
.iface-name { font-weight: 600; color: var(--text-secondary); font-family: monospace; }
|
||||||
|
.iface-ip { color: var(--text-muted); font-family: monospace; }
|
||||||
|
.iface-vlan {
|
||||||
|
color: #fff; padding: 1px 7px; border-radius: 12px;
|
||||||
|
font-weight: 600; font-size: 11px;
|
||||||
|
}
|
||||||
|
.iface-upstream { color: #F97316; font-weight: 600; font-size: 11px; }
|
||||||
|
|
||||||
|
/* ── Modal ────────────────────────────────────────────────────────────────── */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: var(--modal-overlay);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-card); border-radius: 16px; padding: 28px;
|
||||||
|
width: 600px; max-width: 95vw; max-height: 90vh; overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow-modal);
|
||||||
|
}
|
||||||
|
.modal h2 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 20px; }
|
||||||
|
|
||||||
|
.fields-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.field { margin-bottom: 14px; }
|
||||||
|
.field label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 5px; }
|
||||||
|
.field input[type="text"],
|
||||||
|
.field input[type="url"],
|
||||||
|
.field input[type="number"],
|
||||||
|
.field select,
|
||||||
|
.field textarea {
|
||||||
|
width: 100%; padding: 9px 12px; border: 1.5px solid var(--border);
|
||||||
|
border-radius: 8px; font-size: 14px; color: var(--text-primary);
|
||||||
|
background: var(--bg-input); box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.field input:focus, .field select:focus, .field textarea:focus {
|
||||||
|
outline: none; border-color: #3B82F6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxes { display: flex; gap: 20px; margin-bottom: 16px; }
|
||||||
|
.checkbox { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; color: var(--text-secondary); }
|
||||||
|
.checkbox input { width: 16px; height: 16px; cursor: pointer; }
|
||||||
|
|
||||||
|
.interfaces-section { border: 1.5px solid var(--border); border-radius: 10px; padding: 14px; margin-bottom: 14px; }
|
||||||
|
.interfaces-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||||
|
.interfaces-header strong { font-size: 13px; color: var(--text-secondary); }
|
||||||
|
.btn-add-iface {
|
||||||
|
padding: 5px 12px; background: #EFF6FF; color: #3B82F6;
|
||||||
|
border: 1.5px solid #BFDBFE; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-add-iface:hover { background: #DBEAFE; }
|
||||||
|
.iface-empty { font-size: 13px; color: var(--text-faint); text-align: center; padding: 8px 0; }
|
||||||
|
|
||||||
|
.iface-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||||
|
.iface-input { padding: 7px 10px; border: 1.5px solid var(--border); border-radius: 6px; font-size: 13px; width: 80px; background: var(--bg-input); color: var(--text-primary); }
|
||||||
|
.iface-input.wide { width: 130px; }
|
||||||
|
.iface-select { padding: 7px 10px; border: 1.5px solid var(--border); border-radius: 6px; font-size: 13px; background: var(--bg-input); color: var(--text-primary); }
|
||||||
|
.iface-upstream { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-muted); cursor: pointer; }
|
||||||
|
.btn-rm-iface {
|
||||||
|
width: 26px; height: 26px; border: none; border-radius: 6px;
|
||||||
|
background: #FEE2E2; color: #EF4444; font-size: 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-rm-iface:hover { background: #FECACA; }
|
||||||
|
|
||||||
|
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
<template>
|
||||||
|
<div class="modal-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="modal">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="header-title">
|
||||||
|
<span class="header-icon">🔍</span>
|
||||||
|
<h2>{{ t('autoDiscovery') }}</h2>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" @click="$emit('close')">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Step 1 : Configuration ── -->
|
||||||
|
<template v-if="step === 'config'">
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('dnsServer') }}</label>
|
||||||
|
<div class="input-hint">{{ t('dnsHint') }}</div>
|
||||||
|
<input v-model="dnsServer" type="text" placeholder="192.168.1.16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('vlansToScan') }}</label>
|
||||||
|
<div class="input-hint">{{ t('vlansHint') }}</div>
|
||||||
|
|
||||||
|
<div v-if="scanableVlans.length === 0" class="warn-box">
|
||||||
|
{{ t('noCidrWarning') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vlan-checklist">
|
||||||
|
<label
|
||||||
|
v-for="vlan in props.vlans"
|
||||||
|
:key="vlan.id"
|
||||||
|
class="vlan-check"
|
||||||
|
:class="{ disabled: !vlan.cidr }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="vlan.id"
|
||||||
|
v-model="selectedVlanIds"
|
||||||
|
:disabled="!vlan.cidr"
|
||||||
|
/>
|
||||||
|
<div class="vlan-check-body">
|
||||||
|
<span class="vlan-badge" :style="{ background: vlan.color }">VLAN {{ vlan.vlan_id }}</span>
|
||||||
|
<span class="vlan-check-name">{{ vlan.name }}</span>
|
||||||
|
<code v-if="vlan.cidr" class="vlan-check-cidr">{{ vlan.cidr }}</code>
|
||||||
|
<span v-else class="no-cidr">{{ t('noCidr') }}</span>
|
||||||
|
<span v-if="vlan.cidr" class="host-count">
|
||||||
|
(~{{ hostCount(vlan.cidr) }} hôtes)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="configError" class="error-box">{{ configError }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" @click="$emit('close')">{{ t('cancel') }}</button>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="selectedVlanIds.length === 0"
|
||||||
|
@click="startScan"
|
||||||
|
>
|
||||||
|
{{ t('startDiscovery') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── Step 2 : Scan en cours ── -->
|
||||||
|
<template v-if="step === 'scanning'">
|
||||||
|
<div class="modal-body scanning-body">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p class="scan-msg">{{ t('scanning') }}</p>
|
||||||
|
<p class="scan-detail">
|
||||||
|
{{ totalToScan }} {{ t('scanAddresses') }} {{ selectedVlanIds.length }} {{ t('scanVlans') }}
|
||||||
|
</p>
|
||||||
|
<p class="scan-note">{{ t('scanNote') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── Step 3 : Résultats ── -->
|
||||||
|
<template v-if="step === 'results'">
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<div class="results-summary">
|
||||||
|
<span class="summary-found">{{ results.length }} {{ t('hostsFound') }}</span>
|
||||||
|
<span class="summary-meta">
|
||||||
|
{{ t('scanAddresses') }} {{ scanMeta.total_scanned }} {{ t('addressesScanned') }}
|
||||||
|
— {{ scanMeta.duration_s }}s
|
||||||
|
</span>
|
||||||
|
<span v-if="newCount > 0" class="summary-new">{{ newCount }} {{ t('newHosts') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="results.length === 0" class="empty-results">
|
||||||
|
{{ t('noHosts') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="results-table-wrap">
|
||||||
|
<table class="results-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<input type="checkbox" @change="toggleAll" :checked="allNewSelected" />
|
||||||
|
</th>
|
||||||
|
<th>{{ t('colIp') }}</th>
|
||||||
|
<th>{{ t('colDns') }}</th>
|
||||||
|
<th>VLAN</th>
|
||||||
|
<th>{{ t('colStatus') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="host in results"
|
||||||
|
:key="host.ip"
|
||||||
|
:class="{ 'row-existing': isExisting(host.ip) }"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="host.ip"
|
||||||
|
v-model="selectedIps"
|
||||||
|
:disabled="isExisting(host.ip)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td><code class="ip">{{ host.ip }}</code></td>
|
||||||
|
<td class="hostname">
|
||||||
|
<span v-if="host.hostname">{{ shortHostname(host.hostname) }}</span>
|
||||||
|
<span v-else class="no-dns">—</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="vlan-badge sm" :style="{ background: vlanColor(host.vlan_id) }">
|
||||||
|
VLAN {{ vlanNum(host.vlan_id) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="isExisting(host.ip)" class="status existing">{{ t('statusExisting') }}</span>
|
||||||
|
<span v-else class="status new">{{ t('statusNew') }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="importError" class="error-box">{{ importError }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" @click="step = 'config'">{{ t('newScan') }}</button>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="selectedIps.length === 0 || importing"
|
||||||
|
@click="doImport"
|
||||||
|
>
|
||||||
|
{{ importing ? t('importingBtn') : `${t('importBtn')} (${selectedIps.length})` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { devicesApi, discoveryApi } from '../api.js'
|
||||||
|
import { t } from '../i18n.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
vlans: { type: Array, default: () => [] },
|
||||||
|
devices: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['close', 'refresh'])
|
||||||
|
|
||||||
|
const step = ref('config')
|
||||||
|
const dnsServer = ref('192.168.1.16')
|
||||||
|
const selectedVlanIds = ref([])
|
||||||
|
const results = ref([])
|
||||||
|
const selectedIps = ref([])
|
||||||
|
const scanMeta = ref({ total_scanned: 0, duration_s: 0 })
|
||||||
|
const configError = ref('')
|
||||||
|
const importError = ref('')
|
||||||
|
const importing = ref(false)
|
||||||
|
|
||||||
|
const scanableVlans = computed(() => props.vlans.filter(v => v.cidr))
|
||||||
|
|
||||||
|
const existingIps = computed(() => {
|
||||||
|
const ips = new Set()
|
||||||
|
for (const d of props.devices)
|
||||||
|
for (const i of d.interfaces)
|
||||||
|
if (i.ip_address) ips.add(i.ip_address.trim())
|
||||||
|
return ips
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalToScan = computed(() => {
|
||||||
|
let n = 0
|
||||||
|
for (const id of selectedVlanIds.value) {
|
||||||
|
const vlan = props.vlans.find(v => v.id === id)
|
||||||
|
if (vlan?.cidr) n += hostCount(vlan.cidr)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
|
||||||
|
const newCount = computed(() =>
|
||||||
|
results.value.filter(h => !isExisting(h.ip)).length
|
||||||
|
)
|
||||||
|
|
||||||
|
const allNewSelected = computed(() => {
|
||||||
|
const newHosts = results.value.filter(h => !isExisting(h.ip))
|
||||||
|
return newHosts.length > 0 && newHosts.every(h => selectedIps.value.includes(h.ip))
|
||||||
|
})
|
||||||
|
|
||||||
|
function hostCount(cidr) {
|
||||||
|
try {
|
||||||
|
const parts = cidr.split('/')
|
||||||
|
const prefix = parseInt(parts[1])
|
||||||
|
if (prefix >= 31) return 0
|
||||||
|
return Math.pow(2, 32 - prefix) - 2
|
||||||
|
} catch { return 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExisting(ip) { return existingIps.value.has(ip) }
|
||||||
|
function vlanColor(vlanId) { return props.vlans.find(v => v.id === vlanId)?.color || '#94A3B8' }
|
||||||
|
function vlanNum(vlanId) { return props.vlans.find(v => v.id === vlanId)?.vlan_id || '?' }
|
||||||
|
function shortHostname(fqdn) { return fqdn.split('.')[0] }
|
||||||
|
|
||||||
|
function toggleAll(e) {
|
||||||
|
if (e.target.checked) {
|
||||||
|
selectedIps.value = results.value.filter(h => !isExisting(h.ip)).map(h => h.ip)
|
||||||
|
} else {
|
||||||
|
selectedIps.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScan() {
|
||||||
|
configError.value = ''
|
||||||
|
if (!dnsServer.value.trim()) {
|
||||||
|
configError.value = t('dnsRequired')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedVlanIds.value.length === 0) {
|
||||||
|
configError.value = t('selectVlan')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = selectedVlanIds.value.map(id => {
|
||||||
|
const vlan = props.vlans.find(v => v.id === id)
|
||||||
|
return { vlan_id: id, cidr: vlan.cidr }
|
||||||
|
})
|
||||||
|
|
||||||
|
step.value = 'scanning'
|
||||||
|
results.value = []
|
||||||
|
selectedIps.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await discoveryApi.scan({
|
||||||
|
dns_server: dnsServer.value.trim(),
|
||||||
|
targets,
|
||||||
|
})
|
||||||
|
results.value = resp.data.hosts
|
||||||
|
scanMeta.value = { total_scanned: resp.data.total_scanned, duration_s: resp.data.duration_s }
|
||||||
|
selectedIps.value = results.value.filter(h => !isExisting(h.ip)).map(h => h.ip)
|
||||||
|
step.value = 'results'
|
||||||
|
} catch (e) {
|
||||||
|
configError.value = e.response?.data?.detail || t('scanError')
|
||||||
|
step.value = 'config'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doImport() {
|
||||||
|
importing.value = true
|
||||||
|
importError.value = ''
|
||||||
|
const toImport = results.value.filter(h => selectedIps.value.includes(h.ip) && !isExisting(h.ip))
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const host of toImport) {
|
||||||
|
const name = host.hostname ? shortHostname(host.hostname) : host.ip
|
||||||
|
await devicesApi.create({
|
||||||
|
name,
|
||||||
|
type: 'server',
|
||||||
|
description: host.hostname || '',
|
||||||
|
is_gateway: false,
|
||||||
|
is_livebox: false,
|
||||||
|
interfaces: [{
|
||||||
|
name: 'eth0',
|
||||||
|
ip_address: host.ip,
|
||||||
|
vlan_id: host.vlan_id,
|
||||||
|
is_upstream: false,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
emit('refresh')
|
||||||
|
emit('close')
|
||||||
|
} catch (e) {
|
||||||
|
importError.value = e.response?.data?.detail || t('importError')
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: var(--modal-overlay);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-card); border-radius: 18px;
|
||||||
|
width: 680px; max-width: 96vw; max-height: 88vh;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
box-shadow: var(--shadow-modal);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 20px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header-title { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.header-icon { font-size: 22px; }
|
||||||
|
h2 { font-size: 18px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.close-btn {
|
||||||
|
width: 30px; height: 30px; border: none; border-radius: 8px;
|
||||||
|
background: var(--bg-page); color: var(--text-muted); font-size: 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.close-btn:hover { background: var(--border); }
|
||||||
|
|
||||||
|
.modal-body { padding: 24px; overflow-y: auto; flex: 1; }
|
||||||
|
|
||||||
|
.field { margin-bottom: 20px; }
|
||||||
|
.field label { display: block; font-size: 13px; font-weight: 700; color: var(--text-secondary); margin-bottom: 4px; }
|
||||||
|
.input-hint { font-size: 12px; color: var(--text-faint); margin-bottom: 8px; }
|
||||||
|
.field input[type="text"] {
|
||||||
|
width: 100%; padding: 9px 12px; border: 1.5px solid var(--border);
|
||||||
|
border-radius: 8px; font-size: 14px; color: var(--text-primary); background: var(--bg-input);
|
||||||
|
}
|
||||||
|
.field input:focus { outline: none; border-color: #3B82F6; }
|
||||||
|
|
||||||
|
.vlan-checklist { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.vlan-check {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 12px; border: 1.5px solid var(--border); border-radius: 10px;
|
||||||
|
cursor: pointer; transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.vlan-check:hover:not(.disabled) { border-color: #93C5FD; background: var(--bg-card-hover); }
|
||||||
|
.vlan-check.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.vlan-check input[type="checkbox"] { width: 16px; height: 16px; flex-shrink: 0; cursor: pointer; }
|
||||||
|
.vlan-check-body { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.vlan-badge {
|
||||||
|
color: #fff; font-size: 11px; font-weight: 800;
|
||||||
|
padding: 2px 9px; border-radius: 20px;
|
||||||
|
}
|
||||||
|
.vlan-badge.sm { font-size: 10px; padding: 1px 7px; }
|
||||||
|
.vlan-check-name { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.vlan-check-cidr { font-size: 12px; color: var(--text-muted); font-family: monospace; background: var(--bg-page); padding: 1px 5px; border-radius: 4px; }
|
||||||
|
.no-cidr { font-size: 12px; color: var(--text-faint); font-style: italic; }
|
||||||
|
.host-count { font-size: 12px; color: var(--text-faint); }
|
||||||
|
|
||||||
|
.scanning-body {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; padding: 60px 24px; gap: 16px;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 48px; height: 48px; border-radius: 50%;
|
||||||
|
border: 4px solid var(--border); border-top-color: #3B82F6;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.scan-msg { font-size: 18px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.scan-detail { font-size: 14px; color: var(--text-muted); }
|
||||||
|
.scan-note { font-size: 12px; color: var(--text-faint); }
|
||||||
|
|
||||||
|
.results-summary {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin-bottom: 16px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.summary-found { font-size: 16px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.summary-meta { font-size: 13px; color: var(--text-muted); }
|
||||||
|
.summary-new {
|
||||||
|
background: #D1FAE5; color: #065F46;
|
||||||
|
font-size: 12px; font-weight: 700; padding: 2px 10px; border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-results { text-align: center; padding: 32px; color: var(--text-faint); font-size: 15px; }
|
||||||
|
|
||||||
|
.results-table-wrap { overflow-x: auto; border: 1.5px solid var(--border); border-radius: 10px; }
|
||||||
|
.results-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.results-table thead { background: var(--bg-thead); }
|
||||||
|
.results-table th {
|
||||||
|
padding: 10px 12px; text-align: left;
|
||||||
|
font-size: 11px; font-weight: 700; color: var(--text-muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.results-table td { padding: 10px 12px; font-size: 13px; border-top: 1px solid var(--border); color: var(--text-primary); }
|
||||||
|
.results-table tr:hover td { background: var(--bg-card-hover); }
|
||||||
|
.row-existing td { opacity: 0.5; }
|
||||||
|
|
||||||
|
code.ip { font-family: monospace; font-size: 13px; color: var(--text-primary); }
|
||||||
|
.hostname { color: #1D4ED8; font-weight: 600; }
|
||||||
|
.no-dns { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 20px;
|
||||||
|
}
|
||||||
|
.status.new { background: #DBEAFE; color: #1D4ED8; }
|
||||||
|
.status.existing { background: var(--bg-page); color: var(--text-faint); }
|
||||||
|
|
||||||
|
.warn-box {
|
||||||
|
background: #FFFBEB; border: 1.5px solid #FCD34D; border-radius: 8px;
|
||||||
|
padding: 12px; font-size: 13px; color: #92400E;
|
||||||
|
}
|
||||||
|
.error-box {
|
||||||
|
background: #FEF2F2; border: 1.5px solid #FCA5A5; border-radius: 8px;
|
||||||
|
padding: 12px; font-size: 13px; color: #B91C1C; margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px 24px; border-top: 1px solid var(--border);
|
||||||
|
display: flex; justify-content: flex-end; gap: 10px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
padding: 9px 20px; background: #3B82F6; color: #fff;
|
||||||
|
border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { background: #2563EB; }
|
||||||
|
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 9px 20px; background: var(--bg-page); color: var(--text-secondary);
|
||||||
|
border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: var(--border); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-logo">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/>
|
||||||
|
<circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/>
|
||||||
|
<line x1="6" y1="6" x2="10" y2="11"/><line x1="18" y1="6" x2="14" y2="11"/>
|
||||||
|
<line x1="6" y1="18" x2="10" y2="13"/><line x1="18" y1="18" x2="14" y2="13"/>
|
||||||
|
</svg>
|
||||||
|
<span>Stupid Simple Network Inventory</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="login-title">{{ t('loginTitle') }}</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="submit" class="login-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('loginUsername') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
autofocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('loginPassword') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMsg" class="login-error">{{ errorMsg }}</p>
|
||||||
|
|
||||||
|
<button type="submit" class="login-btn" :disabled="loading">
|
||||||
|
{{ loading ? '…' : t('loginBtn') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { authApi } from '../api.js'
|
||||||
|
import { t } from '../i18n.js'
|
||||||
|
|
||||||
|
const emit = defineEmits(['login'])
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
errorMsg.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await authApi.login(username.value, password.value)
|
||||||
|
emit('login', {
|
||||||
|
token: data.access_token,
|
||||||
|
username: data.username,
|
||||||
|
mustChangePassword: data.must_change_password || false,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response?.status === 429) {
|
||||||
|
errorMsg.value = t('tooManyAttempts')
|
||||||
|
} else {
|
||||||
|
errorMsg.value = t('loginError')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px 36px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
box-shadow: var(--shadow-modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #3B82F6;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.field input:focus { border-color: #3B82F6; }
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #EF4444;
|
||||||
|
background: #FEF2F2;
|
||||||
|
border: 1px solid #FECACA;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.dark) .login-error {
|
||||||
|
background: #2A1515;
|
||||||
|
border-color: #3D1F1F;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #3B82F6;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.login-btn:hover:not(:disabled) { background: #2563EB; }
|
||||||
|
.login-btn:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
<template>
|
||||||
|
<div class="topology-page">
|
||||||
|
|
||||||
|
<!-- Barre d'outils -->
|
||||||
|
<div class="topo-toolbar">
|
||||||
|
<button class="ping-btn" :class="{ pinging }" :disabled="pinging" @click="pingAll">
|
||||||
|
<span class="ping-btn-dot" :class="pingBtnDotClass"></span>
|
||||||
|
{{ pinging ? t('pinging') : pingDone ? t('refreshPing') : t('ping') }}
|
||||||
|
</button>
|
||||||
|
<span v-if="pingDone && !pinging" class="ping-summary">
|
||||||
|
{{ pingUpCount }} ↑ {{ pingDownCount }} ↓
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cartes spéciales en haut : WAN et Passerelle -->
|
||||||
|
<div v-if="liveboxDevices.length || gatewayDevices.length" class="special-row">
|
||||||
|
|
||||||
|
<div v-if="liveboxDevices.length" class="special-card special-wan">
|
||||||
|
<div class="special-header">
|
||||||
|
<span class="special-icon">🌐</span>
|
||||||
|
<span class="special-title">{{ t('wan') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="special-body">
|
||||||
|
<div v-for="d in liveboxDevices" :key="d.id" class="device-chip chip-livebox">
|
||||||
|
<span class="chip-icon"><DeviceIcon :device-type="d.type" :is-livebox="d.is_livebox" :is-gateway="d.is_gateway" :name="d.name" :description="d.description" :size="16" type-only /></span>
|
||||||
|
<div class="chip-body">
|
||||||
|
<span class="chip-name">{{ d.name }}</span>
|
||||||
|
<div class="chip-sub">
|
||||||
|
<span v-for="iface in d.interfaces" :key="iface.id">
|
||||||
|
<code v-if="iface.ip_address" class="chip-ip">{{ iface.ip_address }}</code>
|
||||||
|
</span>
|
||||||
|
<svg v-for="b in detectBrands(d.name, d.description)" :key="b.title" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" class="chip-brand-icon" :title="b.title" :style="{ color: '#' + b.hex }"><path :d="b.path" /></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a v-if="d.url" :href="d.url" target="_blank" rel="noreferrer noopener" class="chip-tag tag-link" :title="t('openWebUI')">Link</a>
|
||||||
|
<span v-if="pingStatus[d.id]" class="ping-dot" :class="'ping-' + pingStatus[d.id]" :title="pingStatus[d.id] === 'up' ? t('reachable') : t('unreachable')"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="gatewayDevices.length" class="special-card special-gw">
|
||||||
|
<div class="special-header">
|
||||||
|
<span class="special-icon">⬡</span>
|
||||||
|
<span class="special-title">{{ t('gateway') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="special-body">
|
||||||
|
<div v-for="d in gatewayDevices" :key="d.id" class="device-chip chip-gateway">
|
||||||
|
<span class="chip-icon"><DeviceIcon :device-type="d.type" :is-livebox="d.is_livebox" :is-gateway="d.is_gateway" :name="d.name" :description="d.description" :size="16" type-only /></span>
|
||||||
|
<div class="chip-body">
|
||||||
|
<span class="chip-name">{{ d.name }}</span>
|
||||||
|
<div class="chip-sub">
|
||||||
|
<span v-if="d.description" class="chip-iface">{{ d.description }}</span>
|
||||||
|
<svg v-for="b in detectBrands(d.name, d.description)" :key="b.title" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" class="chip-brand-icon" :title="b.title" :style="{ color: '#' + b.hex }"><path :d="b.path" /></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a v-if="d.url" :href="d.url" target="_blank" rel="noreferrer noopener" class="chip-tag tag-link" :title="t('openWebUI')">Link</a>
|
||||||
|
<span v-if="pingStatus[d.id]" class="ping-dot" :class="'ping-' + pingStatus[d.id]" :title="pingStatus[d.id] === 'up' ? t('reachable') : t('unreachable')"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grille des réseaux -->
|
||||||
|
<div class="vlan-grid">
|
||||||
|
<div
|
||||||
|
v-for="vlan in props.vlans"
|
||||||
|
:key="vlan.id"
|
||||||
|
class="vlan-card"
|
||||||
|
:style="{ '--vlan-color': vlan.color }"
|
||||||
|
>
|
||||||
|
<div class="vlan-header">
|
||||||
|
<div class="vlan-id-badge">{{ vlan.vlan_id != null ? 'VLAN ' + vlan.vlan_id : 'LAN' }}</div>
|
||||||
|
<div class="vlan-meta">
|
||||||
|
<span class="vlan-name">{{ vlan.name }}</span>
|
||||||
|
<code v-if="vlan.cidr" class="vlan-cidr">{{ vlan.cidr }}</code>
|
||||||
|
</div>
|
||||||
|
<span class="vlan-count">{{ vlanMembers(vlan.id).length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vlan-body">
|
||||||
|
<div v-if="vlanMembers(vlan.id).length === 0" class="empty-vlan">
|
||||||
|
{{ t('noDevice') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="item in vlanMembers(vlan.id)"
|
||||||
|
:key="item.device.id"
|
||||||
|
class="device-chip"
|
||||||
|
:class="'chip-' + chipRole(item.device)"
|
||||||
|
>
|
||||||
|
<span class="chip-icon">
|
||||||
|
<DeviceIcon :device-type="item.device.type" :is-livebox="item.device.is_livebox" :is-gateway="item.device.is_gateway" :name="item.device.name" :description="item.device.description" :size="16" type-only />
|
||||||
|
</span>
|
||||||
|
<div class="chip-body">
|
||||||
|
<span class="chip-name">{{ item.device.name }}</span>
|
||||||
|
<div class="chip-sub">
|
||||||
|
<code v-if="item.ip" class="chip-ip">{{ item.ip }}</code>
|
||||||
|
<span v-if="item.ifaceName" class="chip-iface">{{ item.ifaceName }}</span>
|
||||||
|
<svg v-for="b in detectBrands(item.device.name, item.device.description)" :key="b.title" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" class="chip-brand-icon" :title="b.title" :style="{ color: '#' + b.hex }"><path :d="b.path" /></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chip-tags">
|
||||||
|
<span v-if="pingStatus[item.device.id]" class="ping-dot" :class="'ping-' + pingStatus[item.device.id]" :title="pingStatus[item.device.id] === 'up' ? t('reachable') : t('unreachable')"></span>
|
||||||
|
<a v-if="item.device.url" :href="item.device.url" target="_blank" rel="noreferrer noopener" class="chip-tag tag-link" :title="t('openWebUI')">Link</a>
|
||||||
|
<span v-if="item.device.is_gateway" class="chip-tag tag-gw">GW</span>
|
||||||
|
<span v-if="item.device.virt_type === 'lxc'" class="chip-tag tag-lxc">LXC</span>
|
||||||
|
<span v-if="item.device.virt_type === 'qemu'" class="chip-tag tag-vm">VM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zone non assigné -->
|
||||||
|
<div
|
||||||
|
v-if="untaggedDevices.length"
|
||||||
|
class="vlan-card untagged"
|
||||||
|
>
|
||||||
|
<div class="vlan-header">
|
||||||
|
<div class="vlan-id-badge untagged-badge">—</div>
|
||||||
|
<div class="vlan-meta">
|
||||||
|
<span class="vlan-name">{{ t('unassigned') }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="vlan-count">{{ untaggedDevices.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="vlan-body">
|
||||||
|
<div
|
||||||
|
v-for="d in untaggedDevices"
|
||||||
|
:key="d.id"
|
||||||
|
class="device-chip"
|
||||||
|
:class="'chip-' + chipRole(d)"
|
||||||
|
>
|
||||||
|
<span class="chip-icon">
|
||||||
|
<DeviceIcon :device-type="d.type" :is-livebox="d.is_livebox" :is-gateway="d.is_gateway" :name="d.name" :description="d.description" :size="16" type-only />
|
||||||
|
</span>
|
||||||
|
<div class="chip-body">
|
||||||
|
<span class="chip-name">{{ d.name }}</span>
|
||||||
|
<div class="chip-sub">
|
||||||
|
<span v-if="d.description" class="chip-iface">{{ d.description }}</span>
|
||||||
|
<svg v-for="b in detectBrands(d.name, d.description)" :key="b.title" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" class="chip-brand-icon" :title="b.title" :style="{ color: '#' + b.hex }"><path :d="b.path" /></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a v-if="d.url" :href="d.url" target="_blank" rel="noreferrer noopener" class="chip-tag tag-link" :title="t('openWebUI')">Link</a>
|
||||||
|
<span v-if="pingStatus[d.id]" class="ping-dot" :class="'ping-' + pingStatus[d.id]" :title="pingStatus[d.id] === 'up' ? t('reachable') : t('unreachable')"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.vlans.length === 0 && props.devices.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">⬡</div>
|
||||||
|
<p>{{ t('noDevices') }}</p>
|
||||||
|
<p class="empty-hint">{{ t('noDevicesHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import DeviceIcon from './DeviceIcon.vue'
|
||||||
|
import { detectBrands } from '../brandIcons.js'
|
||||||
|
import { discoveryApi } from '../api.js'
|
||||||
|
import { t } from '../i18n.js'
|
||||||
|
|
||||||
|
function ipToInt(ip) {
|
||||||
|
if (!ip) return Infinity
|
||||||
|
const p = ip.split('.').map(Number)
|
||||||
|
return p.length === 4 ? (p[0] << 24 | p[1] << 16 | p[2] << 8 | p[3]) >>> 0 : Infinity
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
devices: { type: Array, default: () => [] },
|
||||||
|
vlans: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const liveboxDevices = computed(() => props.devices.filter(d => d.is_livebox))
|
||||||
|
const gatewayDevices = computed(() => props.devices.filter(d => d.is_gateway))
|
||||||
|
|
||||||
|
function vlanMembers(vlanId) {
|
||||||
|
const result = []
|
||||||
|
for (const device of props.devices) {
|
||||||
|
const iface = device.interfaces.find(i => i.vlan_id === vlanId)
|
||||||
|
if (iface) {
|
||||||
|
result.push({
|
||||||
|
device,
|
||||||
|
ip: iface.ip_address || '',
|
||||||
|
ifaceName: iface.name || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => ipToInt(a.ip) - ipToInt(b.ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
const untaggedDevices = computed(() =>
|
||||||
|
props.devices.filter(d => {
|
||||||
|
if (d.is_livebox || d.is_gateway) return false
|
||||||
|
return !d.interfaces.some(i => i.vlan_id)
|
||||||
|
}).sort((a, b) => ipToInt(a.interfaces[0]?.ip_address) - ipToInt(b.interfaces[0]?.ip_address))
|
||||||
|
)
|
||||||
|
|
||||||
|
function chipRole(device) {
|
||||||
|
if (device.is_livebox) return 'livebox'
|
||||||
|
if (device.is_gateway) return 'gateway'
|
||||||
|
return device.type || 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ping ──────────────────────────────────────────────────────────────────────
|
||||||
|
const pingStatus = ref({})
|
||||||
|
const pinging = ref(false)
|
||||||
|
const pingDone = ref(false)
|
||||||
|
|
||||||
|
const pingUpCount = computed(() => Object.values(pingStatus.value).filter(s => s === 'up').length)
|
||||||
|
const pingDownCount = computed(() => Object.values(pingStatus.value).filter(s => s === 'down').length)
|
||||||
|
const pingBtnDotClass = computed(() => {
|
||||||
|
if (pinging.value) return 'dot-pinging'
|
||||||
|
if (!pingDone.value) return 'dot-idle'
|
||||||
|
return pingDownCount.value > 0 ? 'dot-warn' : 'dot-ok'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function pingAll() {
|
||||||
|
const ipToDevices = {}
|
||||||
|
for (const d of props.devices) {
|
||||||
|
for (const i of d.interfaces) {
|
||||||
|
if (i.ip_address) {
|
||||||
|
if (!ipToDevices[i.ip_address]) ipToDevices[i.ip_address] = []
|
||||||
|
ipToDevices[i.ip_address].push(d.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ips = Object.keys(ipToDevices)
|
||||||
|
if (!ips.length) return
|
||||||
|
|
||||||
|
pinging.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await discoveryApi.ping(ips)
|
||||||
|
const next = {}
|
||||||
|
for (const { ip, alive } of data) {
|
||||||
|
for (const id of (ipToDevices[ip] || [])) {
|
||||||
|
if (alive || next[id] !== 'up') next[id] = alive ? 'up' : 'down'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pingStatus.value = next
|
||||||
|
pingDone.value = true
|
||||||
|
} finally {
|
||||||
|
pinging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.topology-page {
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-page);
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
|
||||||
|
.topo-toolbar {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ping-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
padding: 5px 14px; height: 30px;
|
||||||
|
background: var(--bg-card); border: 1.5px solid var(--border);
|
||||||
|
border-radius: 8px; font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ping-btn:hover:not(:disabled) { border-color: var(--border-strong); background: var(--bg-card-hover); }
|
||||||
|
.ping-btn:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
.ping-btn.pinging { border-color: #93C5FD; color: #1D4ED8; }
|
||||||
|
|
||||||
|
.ping-btn-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dot-idle { background: #CBD5E1; }
|
||||||
|
.dot-pinging { background: #60A5FA; animation: pulse 1s infinite; }
|
||||||
|
.dot-ok { background: #22C55E; }
|
||||||
|
.dot-warn { background: #F97316; }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ping-summary { font-size: 12px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ── Dot ping ────────────────────────────────────────────────────────────── */
|
||||||
|
.ping-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ping-up { background: #22C55E; }
|
||||||
|
.ping-down { background: #EF4444; }
|
||||||
|
|
||||||
|
/* ── Cartes spéciales (WAN + Passerelle) ─────────────────────────────────── */
|
||||||
|
.special-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1.5px solid;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
min-width: 220px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.special-wan { border-color: #FCA5A5; }
|
||||||
|
.special-gw { border-color: #FCD34D; }
|
||||||
|
|
||||||
|
.special-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.special-wan .special-header { background: #FEF2F2; color: #B91C1C; border-bottom: 1px solid #FECACA; }
|
||||||
|
.special-gw .special-header { background: #FFFBEB; color: #92400E; border-bottom: 1px solid #FDE68A; }
|
||||||
|
|
||||||
|
:global(html.dark .special-wan) { border-color: #7F1D1D; }
|
||||||
|
:global(html.dark .special-gw) { border-color: #78350F; }
|
||||||
|
:global(html.dark .special-wan .special-header) { background: #2A1515; color: #FCA5A5; border-bottom-color: #3D1F1F; }
|
||||||
|
:global(html.dark .special-gw .special-header) { background: #2A200A; color: #FCD34D; border-bottom-color: #3D3010; }
|
||||||
|
|
||||||
|
.special-body {
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Grille des réseaux ──────────────────────────────────────────────────── */
|
||||||
|
.vlan-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card réseau ─────────────────────────────────────────────────────────── */
|
||||||
|
.vlan-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-top: 3px solid var(--vlan-color, #CBD5E1);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.vlan-card.untagged {
|
||||||
|
--vlan-color: #CBD5E1;
|
||||||
|
border-style: dashed;
|
||||||
|
border-top-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vlan-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-thead);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vlan-id-badge {
|
||||||
|
background: var(--vlan-color, #CBD5E1);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.untagged-badge { background: #94A3B8; }
|
||||||
|
|
||||||
|
.vlan-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.vlan-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.vlan-cidr {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vlan-count {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--vlan-color, #CBD5E1);
|
||||||
|
background: color-mix(in srgb, var(--vlan-color) 10%, transparent);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vlan-body {
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.vlan-body { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-vlan {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
font-style: italic;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Device chip ─────────────────────────────────────────────────────────── */
|
||||||
|
.device-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-chip);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: background 0.12s, box-shadow 0.12s;
|
||||||
|
}
|
||||||
|
.device-chip:hover {
|
||||||
|
background: var(--bg-chip-hover);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-icon {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 7px; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-server .chip-icon { background: #DBEAFE; color: #1D4ED8; }
|
||||||
|
.chip-nas .chip-icon { background: #EDE9FE; color: #6D28D9; }
|
||||||
|
.chip-switch .chip-icon { background: #D1FAE5; color: #065F46; }
|
||||||
|
.chip-router .chip-icon { background: #FFEDD5; color: #C2410C; }
|
||||||
|
.chip-access_point .chip-icon { background: #CCFBF1; color: #0F766E; }
|
||||||
|
.chip-gateway .chip-icon { background: #FEF3C7; color: #B45309; }
|
||||||
|
.chip-livebox .chip-icon { background: #FEE2E2; color: #B91C1C; }
|
||||||
|
.chip-camera .chip-icon { background: #E0F2FE; color: #0369A1; }
|
||||||
|
.chip-temperature .chip-icon { background: #F0F9FF; color: #0284C7; }
|
||||||
|
.chip-sensor .chip-icon { background: #ECFCCB; color: #3F6212; }
|
||||||
|
.chip-hub .chip-icon { background: #EEF2FF; color: #3730A3; }
|
||||||
|
.chip-smart_plug .chip-icon { background: #FFF7ED; color: #C2410C; }
|
||||||
|
.chip-alarm .chip-icon { background: #FEF2F2; color: #B91C1C; }
|
||||||
|
.chip-light .chip-icon { background: #FEFCE8; color: #854D0E; }
|
||||||
|
.chip-doorbell .chip-icon { background: #FDF4FF; color: #7E22CE; }
|
||||||
|
.chip-desktop .chip-icon { background: #E0E7FF; color: #4338CA; }
|
||||||
|
.chip-laptop .chip-icon { background: #DCFCE7; color: #15803D; }
|
||||||
|
.chip-other .chip-icon { background: #F1F5F9; color: #64748B; }
|
||||||
|
|
||||||
|
.chip-body { flex: 1; min-width: 0; }
|
||||||
|
.chip-name {
|
||||||
|
font-size: 12px; font-weight: 600; color: var(--text-primary);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;
|
||||||
|
}
|
||||||
|
.chip-sub {
|
||||||
|
display: flex; align-items: center; gap: 5px; margin-top: 1px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.chip-brand-icon { display: inline-block; flex-shrink: 0; opacity: 0.85; }
|
||||||
|
.chip-ip {
|
||||||
|
font-size: 11px; color: var(--chip-ip-color);
|
||||||
|
font-family: monospace; background: var(--chip-ip-bg);
|
||||||
|
padding: 0px 4px; border-radius: 3px;
|
||||||
|
}
|
||||||
|
.chip-iface {
|
||||||
|
font-size: 10px; color: var(--text-faint);
|
||||||
|
font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-tags { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; }
|
||||||
|
.chip-tag {
|
||||||
|
font-size: 9px; font-weight: 800; padding: 1px 5px;
|
||||||
|
border-radius: 4px; text-align: center; letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.tag-gw { background: #FEF3C7; color: #B45309; }
|
||||||
|
.tag-lxc { background: #DBEAFE; color: #1D4ED8; }
|
||||||
|
.tag-vm { background: #EDE9FE; color: #6D28D9; }
|
||||||
|
.tag-link { background: #F0FDF4; color: #15803D; text-decoration: none; }
|
||||||
|
.tag-link:hover { background: #DCFCE7; }
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.empty-icon { font-size: 64px; margin-bottom: 16px; opacity: 0.3; }
|
||||||
|
.empty-state p { font-size: 16px; margin-bottom: 6px; }
|
||||||
|
.empty-hint { font-size: 13px; color: var(--text-faint); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>{{ t('networks') }}</h1>
|
||||||
|
<button class="btn-primary" @click="openAdd">{{ t('addNetwork') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.vlans.length === 0" class="empty">
|
||||||
|
{{ t('noNetworksConfigured') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap" v-else>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('colType') }}</th>
|
||||||
|
<th>{{ t('colName') }}</th>
|
||||||
|
<th>{{ t('colSubnet') }}</th>
|
||||||
|
<th>{{ t('colColor') }}</th>
|
||||||
|
<th>{{ t('colActions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="vlan in props.vlans" :key="vlan.id">
|
||||||
|
<td>
|
||||||
|
<span class="badge-vlan" :style="{ background: vlan.color }">
|
||||||
|
{{ vlan.vlan_id != null ? vlan.vlan_id : 'LAN' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ vlan.name }}</td>
|
||||||
|
<td><code>{{ vlan.cidr || '—' }}</code></td>
|
||||||
|
<td>
|
||||||
|
<div class="color-preview" :style="{ background: vlan.color }"></div>
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn-icon" @click="openEdit(vlan)" title="✎">✎</button>
|
||||||
|
<button class="btn-icon danger" @click="remove(vlan)" title="✕">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showForm" class="modal-overlay" @click.self="showForm = false">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>{{ editing ? t('editNetwork') : t('newNetwork') }}</h2>
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('vlanId') }} <span class="optional">{{ t('vlanIdHint') }}</span></label>
|
||||||
|
<input v-model="form.vlan_id" type="number" placeholder="ex: 10" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('fieldName') }}</label>
|
||||||
|
<input v-model="form.name" type="text" required placeholder="ex: Serveurs" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('subnet') }}</label>
|
||||||
|
<input v-model="form.cidr" type="text" :placeholder="t('subnetPlaceholder')" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ t('color') }}</label>
|
||||||
|
<div class="color-row">
|
||||||
|
<input v-model="form.color" type="color" />
|
||||||
|
<span>{{ form.color }}</span>
|
||||||
|
<div class="color-presets">
|
||||||
|
<div
|
||||||
|
v-for="c in presetColors"
|
||||||
|
:key="c"
|
||||||
|
class="preset"
|
||||||
|
:style="{ background: c }"
|
||||||
|
@click="form.color = c"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" @click="showForm = false">{{ t('cancel') }}</button>
|
||||||
|
<button type="submit" class="btn-primary">{{ editing ? t('save') : t('create') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { vlansApi } from '../api.js'
|
||||||
|
import { t, tFmt } from '../i18n.js'
|
||||||
|
|
||||||
|
const props = defineProps({ vlans: Array })
|
||||||
|
const emit = defineEmits(['refresh'])
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editing = ref(null)
|
||||||
|
const form = reactive({ vlan_id: '', name: '', cidr: '', color: '#4A90D9' })
|
||||||
|
|
||||||
|
const presetColors = [
|
||||||
|
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
||||||
|
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16'
|
||||||
|
]
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
editing.value = null
|
||||||
|
Object.assign(form, { vlan_id: '', name: '', cidr: '', color: '#4A90D9' })
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(vlan) {
|
||||||
|
editing.value = vlan
|
||||||
|
Object.assign(form, {
|
||||||
|
vlan_id: vlan.vlan_id != null ? String(vlan.vlan_id) : '',
|
||||||
|
name: vlan.name,
|
||||||
|
cidr: vlan.cidr || '',
|
||||||
|
color: vlan.color
|
||||||
|
})
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
vlan_id: form.vlan_id !== '' ? Number(form.vlan_id) : null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editing.value) {
|
||||||
|
await vlansApi.update(editing.value.id, payload)
|
||||||
|
} else {
|
||||||
|
await vlansApi.create(payload)
|
||||||
|
}
|
||||||
|
showForm.value = false
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.response?.data?.detail || t('saveError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(vlan) {
|
||||||
|
const label = vlan.vlan_id != null ? `VLAN ${vlan.vlan_id} — ${vlan.name}` : `LAN ${vlan.name}`
|
||||||
|
if (!confirm(`Supprimer ${tFmt('confirmDeleteNetwork', label)}`)) return
|
||||||
|
try {
|
||||||
|
await vlansApi.remove(vlan.id)
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.response?.data?.detail || t('deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { height: 100vh; overflow-y: auto; background: var(--bg-page); padding: 32px; }
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||||
|
h1 { font-size: 22px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 9px 18px; background: #3B82F6; color: #fff;
|
||||||
|
border: none; border-radius: 8px; font-size: 14px; font-weight: 600; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #2563EB; }
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 9px 18px; background: var(--border); color: var(--text-secondary);
|
||||||
|
border: none; border-radius: 8px; font-size: 14px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: var(--border-strong); }
|
||||||
|
|
||||||
|
.empty { padding: 48px; text-align: center; color: var(--text-faint); font-size: 15px; }
|
||||||
|
|
||||||
|
.table-wrap { background: var(--bg-card); border-radius: 12px; box-shadow: var(--shadow-card); overflow: hidden; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
thead { background: var(--bg-thead); }
|
||||||
|
th { padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
td { padding: 14px 16px; font-size: 14px; color: var(--text-primary); border-top: 1px solid var(--border); }
|
||||||
|
tr:hover td { background: var(--bg-card-hover); }
|
||||||
|
|
||||||
|
.badge-vlan {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
min-width: 36px; padding: 2px 8px; border-radius: 20px;
|
||||||
|
color: #fff; font-weight: 700; font-size: 13px;
|
||||||
|
}
|
||||||
|
code { background: var(--bg-page); padding: 2px 6px; border-radius: 4px; font-size: 13px; color: var(--text-secondary); }
|
||||||
|
.color-preview { width: 24px; height: 24px; border-radius: 6px; border: 2px solid rgba(0,0,0,0.1); }
|
||||||
|
|
||||||
|
.actions { display: flex; gap: 8px; }
|
||||||
|
.btn-icon {
|
||||||
|
width: 30px; height: 30px; border: none; border-radius: 6px;
|
||||||
|
background: var(--bg-chip); color: var(--text-secondary); font-size: 15px;
|
||||||
|
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-icon:hover { background: var(--border-strong); color: var(--text-primary); }
|
||||||
|
.btn-icon.danger:hover { background: rgba(239,68,68,0.15); color: #EF4444; }
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: var(--modal-overlay);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-card); border-radius: 16px; padding: 28px;
|
||||||
|
width: 440px; max-width: 95vw; box-shadow: var(--shadow-modal);
|
||||||
|
}
|
||||||
|
.modal h2 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 20px; }
|
||||||
|
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
|
||||||
|
.optional { font-weight: 400; color: var(--text-faint); font-size: 12px; }
|
||||||
|
.field input[type="text"],
|
||||||
|
.field input[type="number"] {
|
||||||
|
width: 100%; padding: 9px 12px; border: 1.5px solid var(--border);
|
||||||
|
border-radius: 8px; font-size: 14px; color: var(--text-primary); background: var(--bg-input);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.field input:focus { outline: none; border-color: #3B82F6; }
|
||||||
|
|
||||||
|
.color-row { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.color-row input[type="color"] { width: 40px; height: 36px; border: none; border-radius: 6px; cursor: pointer; padding: 0; }
|
||||||
|
.color-row span { font-size: 13px; color: var(--text-muted); font-family: monospace; }
|
||||||
|
.color-presets { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.preset { width: 20px; height: 20px; border-radius: 4px; cursor: pointer; transition: transform 0.1s; }
|
||||||
|
.preset:hover { transform: scale(1.2); }
|
||||||
|
|
||||||
|
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const locale = ref(localStorage.getItem('locale') || 'fr')
|
||||||
|
|
||||||
|
export function setLocale(lang) {
|
||||||
|
locale.value = lang
|
||||||
|
localStorage.setItem('locale', lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANGS = {
|
||||||
|
fr: {
|
||||||
|
// Sidebar / App
|
||||||
|
tabTopology: 'Topologie',
|
||||||
|
tabNetworks: 'Réseaux',
|
||||||
|
tabDevices: 'Équipements',
|
||||||
|
discovery: '🔍 Découverte auto',
|
||||||
|
statsNetworks: 'Réseaux',
|
||||||
|
statsDevices: 'Équip.',
|
||||||
|
exportJson: '⬇ Export JSON',
|
||||||
|
importJson: '⬆ Import JSON',
|
||||||
|
loadError: 'Erreur de chargement : ',
|
||||||
|
importFailed: 'Import échoué : ',
|
||||||
|
importTooLarge: 'Fichier trop volumineux (max 5 Mo).',
|
||||||
|
// TopologyGraph
|
||||||
|
ping: 'Ping',
|
||||||
|
pinging: 'Ping en cours…',
|
||||||
|
refreshPing: 'Rafraîchir le ping',
|
||||||
|
wan: 'Internet / WAN',
|
||||||
|
gateway: 'Passerelle inter-VLAN',
|
||||||
|
unassigned: 'Non assigné',
|
||||||
|
noDevice: 'Aucun équipement',
|
||||||
|
noDevices: 'Aucun équipement à afficher.',
|
||||||
|
noDevicesHint: 'Commencez par créer des réseaux et des équipements.',
|
||||||
|
reachable: 'Joignable',
|
||||||
|
unreachable: 'Injoignable',
|
||||||
|
openWebUI: "Ouvrir l'interface web",
|
||||||
|
// DeviceManager
|
||||||
|
devices: 'Équipements',
|
||||||
|
addDevice: '+ Ajouter un équipement',
|
||||||
|
noDevicesConfigured: 'Aucun équipement configuré. Commencez par en ajouter un.',
|
||||||
|
searchPlaceholder: 'Nom, IP…',
|
||||||
|
filterType: 'Type',
|
||||||
|
filterNetwork: 'Réseau',
|
||||||
|
filterBrand: 'Marque',
|
||||||
|
filterVirt: 'Virt',
|
||||||
|
clearFilters: '✕ Effacer',
|
||||||
|
noDevicesFiltered: 'Aucun équipement ne correspond aux filtres sélectionnés.',
|
||||||
|
editDevice: "Modifier l'équipement",
|
||||||
|
newDevice: 'Nouvel équipement',
|
||||||
|
fieldName: 'Nom *',
|
||||||
|
fieldType: 'Type *',
|
||||||
|
fieldDescription: 'Description',
|
||||||
|
isGateway: 'Passerelle inter-VLAN',
|
||||||
|
isLivebox: 'Livebox / Box FAI',
|
||||||
|
accessUrl: "URL d'accès",
|
||||||
|
runtimeType: "Type d'environnement d'exécution",
|
||||||
|
notSpecified: '— Non précisé',
|
||||||
|
baremetal: 'Bare-metal',
|
||||||
|
lxcContainer: 'Conteneur LXC',
|
||||||
|
vmQemu: 'VM QEMU/KVM',
|
||||||
|
networkInterfaces: 'Interfaces réseau',
|
||||||
|
addInterface: '+ Interface',
|
||||||
|
noInterface: 'Aucune interface configurée',
|
||||||
|
cancel: 'Annuler',
|
||||||
|
save: 'Enregistrer',
|
||||||
|
create: 'Créer',
|
||||||
|
badgeGateway: 'Passerelle',
|
||||||
|
badgeLivebox: 'Livebox',
|
||||||
|
confirmDeleteDevice: '{0} et tous ses liens ?',
|
||||||
|
confirmDeleteNetwork: '{0} ?',
|
||||||
|
saveError: 'Erreur lors de la sauvegarde',
|
||||||
|
deleteError: 'Erreur lors de la suppression',
|
||||||
|
descPlaceholder: 'Rôle, OS, notes…',
|
||||||
|
// Device types
|
||||||
|
typeServer: 'Serveur',
|
||||||
|
typeSwitch: 'Switch',
|
||||||
|
typeRouter: 'Routeur',
|
||||||
|
typeNas: 'NAS',
|
||||||
|
typeGateway: 'Passerelle',
|
||||||
|
typeLivebox: 'Livebox',
|
||||||
|
typeCamera: 'Caméra IP',
|
||||||
|
typeTemperature: 'Sonde température/humidité',
|
||||||
|
typeSensor: 'Capteur (mouvement, ouverture…)',
|
||||||
|
typeHub: 'Hub domotique',
|
||||||
|
typeSmartPlug: 'Prise connectée',
|
||||||
|
typeAlarm: 'Alarme / Détecteur',
|
||||||
|
typeLight: 'Éclairage connecté',
|
||||||
|
typeDoorbell: 'Sonnette / Interphone',
|
||||||
|
typeAccessPoint: 'Borne WiFi / Access Point',
|
||||||
|
typeDesktop: 'Ordinateur fixe',
|
||||||
|
typeLaptop: 'Ordinateur portable',
|
||||||
|
typeOther: 'Autre',
|
||||||
|
virtBaremetal: 'Bare-metal',
|
||||||
|
virtLxc: 'LXC',
|
||||||
|
virtQemu: 'VM QEMU',
|
||||||
|
// VlanManager
|
||||||
|
networks: 'Réseaux',
|
||||||
|
addNetwork: '+ Ajouter un réseau',
|
||||||
|
noNetworksConfigured: 'Aucun réseau configuré. Commencez par en créer un.',
|
||||||
|
colType: 'Type',
|
||||||
|
colName: 'Nom',
|
||||||
|
colSubnet: 'Sous-réseau',
|
||||||
|
colColor: 'Couleur',
|
||||||
|
colActions: 'Actions',
|
||||||
|
editNetwork: 'Modifier le réseau',
|
||||||
|
newNetwork: 'Nouveau réseau',
|
||||||
|
vlanId: 'ID VLAN',
|
||||||
|
vlanIdHint: '(laisser vide pour LAN classique)',
|
||||||
|
subnet: 'Sous-réseau CIDR',
|
||||||
|
color: 'Couleur',
|
||||||
|
subnetPlaceholder: 'ex: 192.168.10.0/24',
|
||||||
|
// DiscoveryModal
|
||||||
|
autoDiscovery: 'Découverte automatique',
|
||||||
|
dnsServer: 'Serveur DNS',
|
||||||
|
dnsHint: 'Le reverse DNS sera interrogé sur ce serveur pour résoudre les noms.',
|
||||||
|
vlansToScan: 'VLANs à scanner',
|
||||||
|
vlansHint: 'Seuls les VLANs avec un sous-réseau CIDR configuré peuvent être scannés.',
|
||||||
|
noCidrWarning: "Aucun VLAN n'a de CIDR configuré. Renseignez-les dans l'onglet VLANs.",
|
||||||
|
noCidr: 'pas de CIDR',
|
||||||
|
startDiscovery: 'Lancer la découverte',
|
||||||
|
scanning: 'Scan en cours…',
|
||||||
|
scanAddresses: 'adresses sur',
|
||||||
|
scanVlans: 'VLAN(s)',
|
||||||
|
scanNote: 'Chaque hôte est pingé puis interrogé en DNS.',
|
||||||
|
hostsFound: 'hôte(s) découvert(s)',
|
||||||
|
addressesScanned: 'adresses scannées',
|
||||||
|
newHosts: 'nouveaux',
|
||||||
|
noHosts: 'Aucun hôte actif trouvé sur les plages sélectionnées.',
|
||||||
|
colIp: 'IP',
|
||||||
|
colDns: 'Nom (DNS)',
|
||||||
|
colStatus: 'Statut',
|
||||||
|
statusExisting: 'Déjà présent',
|
||||||
|
statusNew: 'Nouveau',
|
||||||
|
newScan: 'Nouveau scan',
|
||||||
|
importingBtn: 'Import…',
|
||||||
|
importBtn: 'Importer',
|
||||||
|
dnsRequired: 'Veuillez renseigner un serveur DNS.',
|
||||||
|
selectVlan: 'Sélectionnez au moins un VLAN.',
|
||||||
|
importError: "Erreur lors de l'import.",
|
||||||
|
scanError: 'Erreur lors du scan.',
|
||||||
|
// Theme / Lang
|
||||||
|
lightTheme: 'Thème clair',
|
||||||
|
darkTheme: 'Thème sombre',
|
||||||
|
// Auth
|
||||||
|
loginTitle: 'Connexion',
|
||||||
|
loginUsername: 'Nom d\'utilisateur',
|
||||||
|
loginPassword: 'Mot de passe',
|
||||||
|
loginBtn: 'Se connecter',
|
||||||
|
loginError: 'Identifiants incorrects.',
|
||||||
|
logout: 'Déconnexion',
|
||||||
|
accountSettings: 'Paramètres du compte',
|
||||||
|
currentPassword: 'Mot de passe actuel',
|
||||||
|
newUsername: 'Nouveau nom d\'utilisateur',
|
||||||
|
newPassword: 'Nouveau mot de passe',
|
||||||
|
confirmPassword: 'Confirmer le mot de passe',
|
||||||
|
leaveBlankToKeep: 'Laisser vide pour ne pas modifier',
|
||||||
|
passwordMismatch: 'Les mots de passe ne correspondent pas.',
|
||||||
|
accountUpdated: 'Compte mis à jour.',
|
||||||
|
wrongPassword: 'Mot de passe actuel incorrect.',
|
||||||
|
mustChangePasswordWarning: 'Pour des raisons de sécurité, vous devez changer votre mot de passe avant de continuer.',
|
||||||
|
newPasswordRequired: 'Un nouveau mot de passe est requis.',
|
||||||
|
passwordTooShort: 'Le mot de passe doit contenir au moins 8 caractères.',
|
||||||
|
passwordTooWeak: 'Le mot de passe doit contenir au moins une lettre et un chiffre.',
|
||||||
|
usernameInvalid: "Le nom d'utilisateur ne peut contenir que des lettres, chiffres, . _ - (1 à 64 caractères).",
|
||||||
|
tooManyAttempts: 'Trop de tentatives, réessayez plus tard.',
|
||||||
|
},
|
||||||
|
|
||||||
|
en: {
|
||||||
|
tabTopology: 'Topology',
|
||||||
|
tabNetworks: 'Networks',
|
||||||
|
tabDevices: 'Devices',
|
||||||
|
discovery: '🔍 Auto discovery',
|
||||||
|
statsNetworks: 'Networks',
|
||||||
|
statsDevices: 'Devices',
|
||||||
|
exportJson: '⬇ Export JSON',
|
||||||
|
importJson: '⬆ Import JSON',
|
||||||
|
loadError: 'Loading error: ',
|
||||||
|
importFailed: 'Import failed: ',
|
||||||
|
importTooLarge: 'File too large (max 5 MB).',
|
||||||
|
ping: 'Ping',
|
||||||
|
pinging: 'Pinging…',
|
||||||
|
refreshPing: 'Refresh ping',
|
||||||
|
wan: 'Internet / WAN',
|
||||||
|
gateway: 'Inter-VLAN Gateway',
|
||||||
|
unassigned: 'Unassigned',
|
||||||
|
noDevice: 'No devices',
|
||||||
|
noDevices: 'No devices to display.',
|
||||||
|
noDevicesHint: 'Start by creating networks and devices.',
|
||||||
|
reachable: 'Reachable',
|
||||||
|
unreachable: 'Unreachable',
|
||||||
|
openWebUI: 'Open web interface',
|
||||||
|
devices: 'Devices',
|
||||||
|
addDevice: '+ Add device',
|
||||||
|
noDevicesConfigured: 'No devices configured. Start by adding one.',
|
||||||
|
searchPlaceholder: 'Name, IP…',
|
||||||
|
filterType: 'Type',
|
||||||
|
filterNetwork: 'Network',
|
||||||
|
filterBrand: 'Brand',
|
||||||
|
filterVirt: 'Virt',
|
||||||
|
clearFilters: '✕ Clear',
|
||||||
|
noDevicesFiltered: 'No devices match the selected filters.',
|
||||||
|
editDevice: 'Edit device',
|
||||||
|
newDevice: 'New device',
|
||||||
|
fieldName: 'Name *',
|
||||||
|
fieldType: 'Type *',
|
||||||
|
fieldDescription: 'Description',
|
||||||
|
isGateway: 'Inter-VLAN gateway',
|
||||||
|
isLivebox: 'ISP Box / Router',
|
||||||
|
accessUrl: 'Access URL',
|
||||||
|
runtimeType: 'Runtime environment',
|
||||||
|
notSpecified: '— Not specified',
|
||||||
|
baremetal: 'Bare-metal',
|
||||||
|
lxcContainer: 'LXC container',
|
||||||
|
vmQemu: 'VM QEMU/KVM',
|
||||||
|
networkInterfaces: 'Network interfaces',
|
||||||
|
addInterface: '+ Interface',
|
||||||
|
noInterface: 'No interface configured',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
save: 'Save',
|
||||||
|
create: 'Create',
|
||||||
|
badgeGateway: 'Gateway',
|
||||||
|
badgeLivebox: 'ISP Box',
|
||||||
|
confirmDeleteDevice: '{0} and all its links?',
|
||||||
|
confirmDeleteNetwork: '{0}?',
|
||||||
|
saveError: 'Error while saving',
|
||||||
|
deleteError: 'Error while deleting',
|
||||||
|
descPlaceholder: 'Role, OS, notes…',
|
||||||
|
typeServer: 'Server',
|
||||||
|
typeSwitch: 'Switch',
|
||||||
|
typeRouter: 'Router',
|
||||||
|
typeNas: 'NAS',
|
||||||
|
typeGateway: 'Gateway',
|
||||||
|
typeLivebox: 'ISP Box',
|
||||||
|
typeCamera: 'IP Camera',
|
||||||
|
typeTemperature: 'Temperature/humidity sensor',
|
||||||
|
typeSensor: 'Sensor (motion, door…)',
|
||||||
|
typeHub: 'Home automation hub',
|
||||||
|
typeSmartPlug: 'Smart plug',
|
||||||
|
typeAlarm: 'Alarm / Detector',
|
||||||
|
typeLight: 'Smart light',
|
||||||
|
typeDoorbell: 'Doorbell / Intercom',
|
||||||
|
typeAccessPoint: 'WiFi Access Point',
|
||||||
|
typeDesktop: 'Desktop computer',
|
||||||
|
typeLaptop: 'Laptop',
|
||||||
|
typeOther: 'Other',
|
||||||
|
virtBaremetal: 'Bare-metal',
|
||||||
|
virtLxc: 'LXC',
|
||||||
|
virtQemu: 'VM QEMU',
|
||||||
|
networks: 'Networks',
|
||||||
|
addNetwork: '+ Add network',
|
||||||
|
noNetworksConfigured: 'No networks configured. Start by creating one.',
|
||||||
|
colType: 'Type',
|
||||||
|
colName: 'Name',
|
||||||
|
colSubnet: 'Subnet',
|
||||||
|
colColor: 'Color',
|
||||||
|
colActions: 'Actions',
|
||||||
|
editNetwork: 'Edit network',
|
||||||
|
newNetwork: 'New network',
|
||||||
|
vlanId: 'VLAN ID',
|
||||||
|
vlanIdHint: '(leave empty for plain LAN)',
|
||||||
|
subnet: 'CIDR subnet',
|
||||||
|
color: 'Color',
|
||||||
|
subnetPlaceholder: 'e.g. 192.168.10.0/24',
|
||||||
|
autoDiscovery: 'Auto discovery',
|
||||||
|
dnsServer: 'DNS server',
|
||||||
|
dnsHint: 'Reverse DNS will be queried on this server to resolve hostnames.',
|
||||||
|
vlansToScan: 'VLANs to scan',
|
||||||
|
vlansHint: 'Only VLANs with a configured CIDR subnet can be scanned.',
|
||||||
|
noCidrWarning: 'No VLAN has a CIDR configured. Set them in the Networks tab.',
|
||||||
|
noCidr: 'no CIDR',
|
||||||
|
startDiscovery: 'Start discovery',
|
||||||
|
scanning: 'Scanning…',
|
||||||
|
scanAddresses: 'addresses on',
|
||||||
|
scanVlans: 'VLAN(s)',
|
||||||
|
scanNote: 'Each host is pinged then queried via DNS.',
|
||||||
|
hostsFound: 'host(s) found',
|
||||||
|
addressesScanned: 'addresses scanned',
|
||||||
|
newHosts: 'new',
|
||||||
|
noHosts: 'No active hosts found on the selected ranges.',
|
||||||
|
colIp: 'IP',
|
||||||
|
colDns: 'Name (DNS)',
|
||||||
|
colStatus: 'Status',
|
||||||
|
statusExisting: 'Already present',
|
||||||
|
statusNew: 'New',
|
||||||
|
newScan: 'New scan',
|
||||||
|
importingBtn: 'Importing…',
|
||||||
|
importBtn: 'Import',
|
||||||
|
dnsRequired: 'Please enter a DNS server.',
|
||||||
|
selectVlan: 'Select at least one VLAN.',
|
||||||
|
importError: 'Error during import.',
|
||||||
|
scanError: 'Error during scan.',
|
||||||
|
lightTheme: 'Light theme',
|
||||||
|
darkTheme: 'Dark theme',
|
||||||
|
// Auth
|
||||||
|
loginTitle: 'Sign in',
|
||||||
|
loginUsername: 'Username',
|
||||||
|
loginPassword: 'Password',
|
||||||
|
loginBtn: 'Sign in',
|
||||||
|
loginError: 'Incorrect credentials.',
|
||||||
|
logout: 'Logout',
|
||||||
|
accountSettings: 'Account settings',
|
||||||
|
currentPassword: 'Current password',
|
||||||
|
newUsername: 'New username',
|
||||||
|
newPassword: 'New password',
|
||||||
|
confirmPassword: 'Confirm password',
|
||||||
|
leaveBlankToKeep: 'Leave blank to keep unchanged',
|
||||||
|
passwordMismatch: 'Passwords do not match.',
|
||||||
|
accountUpdated: 'Account updated.',
|
||||||
|
wrongPassword: 'Current password is incorrect.',
|
||||||
|
mustChangePasswordWarning: 'For security reasons, you must change your password before continuing.',
|
||||||
|
newPasswordRequired: 'A new password is required.',
|
||||||
|
passwordTooShort: 'Password must be at least 8 characters.',
|
||||||
|
passwordTooWeak: 'Password must contain at least one letter and one digit.',
|
||||||
|
usernameInvalid: 'Username may only contain letters, digits, . _ - (1 to 64 characters).',
|
||||||
|
tooManyAttempts: 'Too many attempts, please try again later.',
|
||||||
|
},
|
||||||
|
|
||||||
|
es: {
|
||||||
|
tabTopology: 'Topología',
|
||||||
|
tabNetworks: 'Redes',
|
||||||
|
tabDevices: 'Equipos',
|
||||||
|
discovery: '🔍 Descubrimiento auto',
|
||||||
|
statsNetworks: 'Redes',
|
||||||
|
statsDevices: 'Equipos',
|
||||||
|
exportJson: '⬇ Exportar JSON',
|
||||||
|
importJson: '⬆ Importar JSON',
|
||||||
|
loadError: 'Error de carga: ',
|
||||||
|
importFailed: 'Importación fallida: ',
|
||||||
|
importTooLarge: 'Archivo demasiado grande (máx 5 MB).',
|
||||||
|
ping: 'Ping',
|
||||||
|
pinging: 'Ping en curso…',
|
||||||
|
refreshPing: 'Actualizar ping',
|
||||||
|
wan: 'Internet / WAN',
|
||||||
|
gateway: 'Pasarela inter-VLAN',
|
||||||
|
unassigned: 'Sin asignar',
|
||||||
|
noDevice: 'Sin equipos',
|
||||||
|
noDevices: 'No hay equipos que mostrar.',
|
||||||
|
noDevicesHint: 'Empiece creando redes y equipos.',
|
||||||
|
reachable: 'Alcanzable',
|
||||||
|
unreachable: 'No alcanzable',
|
||||||
|
openWebUI: 'Abrir interfaz web',
|
||||||
|
devices: 'Equipos',
|
||||||
|
addDevice: '+ Añadir equipo',
|
||||||
|
noDevicesConfigured: 'No hay equipos configurados. Empiece añadiendo uno.',
|
||||||
|
searchPlaceholder: 'Nombre, IP…',
|
||||||
|
filterType: 'Tipo',
|
||||||
|
filterNetwork: 'Red',
|
||||||
|
filterBrand: 'Marca',
|
||||||
|
filterVirt: 'Virt',
|
||||||
|
clearFilters: '✕ Borrar',
|
||||||
|
noDevicesFiltered: 'Ningún equipo coincide con los filtros seleccionados.',
|
||||||
|
editDevice: 'Editar equipo',
|
||||||
|
newDevice: 'Nuevo equipo',
|
||||||
|
fieldName: 'Nombre *',
|
||||||
|
fieldType: 'Tipo *',
|
||||||
|
fieldDescription: 'Descripción',
|
||||||
|
isGateway: 'Pasarela inter-VLAN',
|
||||||
|
isLivebox: 'Router / Box ISP',
|
||||||
|
accessUrl: 'URL de acceso',
|
||||||
|
runtimeType: 'Entorno de ejecución',
|
||||||
|
notSpecified: '— No especificado',
|
||||||
|
baremetal: 'Bare-metal',
|
||||||
|
lxcContainer: 'Contenedor LXC',
|
||||||
|
vmQemu: 'VM QEMU/KVM',
|
||||||
|
networkInterfaces: 'Interfaces de red',
|
||||||
|
addInterface: '+ Interfaz',
|
||||||
|
noInterface: 'Sin interfaces configuradas',
|
||||||
|
cancel: 'Cancelar',
|
||||||
|
save: 'Guardar',
|
||||||
|
create: 'Crear',
|
||||||
|
badgeGateway: 'Pasarela',
|
||||||
|
badgeLivebox: 'Router ISP',
|
||||||
|
confirmDeleteDevice: '{0} y todos sus enlaces?',
|
||||||
|
confirmDeleteNetwork: '{0}?',
|
||||||
|
saveError: 'Error al guardar',
|
||||||
|
deleteError: 'Error al eliminar',
|
||||||
|
descPlaceholder: 'Rol, SO, notas…',
|
||||||
|
typeServer: 'Servidor',
|
||||||
|
typeSwitch: 'Switch',
|
||||||
|
typeRouter: 'Router',
|
||||||
|
typeNas: 'NAS',
|
||||||
|
typeGateway: 'Pasarela',
|
||||||
|
typeLivebox: 'Router ISP',
|
||||||
|
typeCamera: 'Cámara IP',
|
||||||
|
typeTemperature: 'Sonda de temperatura/humedad',
|
||||||
|
typeSensor: 'Sensor (movimiento, apertura…)',
|
||||||
|
typeHub: 'Hub domótico',
|
||||||
|
typeSmartPlug: 'Enchufe inteligente',
|
||||||
|
typeAlarm: 'Alarma / Detector',
|
||||||
|
typeLight: 'Iluminación inteligente',
|
||||||
|
typeDoorbell: 'Timbre / Portero',
|
||||||
|
typeAccessPoint: 'Punto de acceso WiFi',
|
||||||
|
typeDesktop: 'Ordenador de sobremesa',
|
||||||
|
typeLaptop: 'Portátil',
|
||||||
|
typeOther: 'Otro',
|
||||||
|
virtBaremetal: 'Bare-metal',
|
||||||
|
virtLxc: 'LXC',
|
||||||
|
virtQemu: 'VM QEMU',
|
||||||
|
networks: 'Redes',
|
||||||
|
addNetwork: '+ Añadir red',
|
||||||
|
noNetworksConfigured: 'No hay redes configuradas. Empiece creando una.',
|
||||||
|
colType: 'Tipo',
|
||||||
|
colName: 'Nombre',
|
||||||
|
colSubnet: 'Subred',
|
||||||
|
colColor: 'Color',
|
||||||
|
colActions: 'Acciones',
|
||||||
|
editNetwork: 'Editar red',
|
||||||
|
newNetwork: 'Nueva red',
|
||||||
|
vlanId: 'ID de VLAN',
|
||||||
|
vlanIdHint: '(dejar vacío para LAN simple)',
|
||||||
|
subnet: 'Subred CIDR',
|
||||||
|
color: 'Color',
|
||||||
|
subnetPlaceholder: 'ej: 192.168.10.0/24',
|
||||||
|
autoDiscovery: 'Descubrimiento automático',
|
||||||
|
dnsServer: 'Servidor DNS',
|
||||||
|
dnsHint: 'El DNS inverso será consultado en este servidor para resolver nombres.',
|
||||||
|
vlansToScan: 'VLANs a escanear',
|
||||||
|
vlansHint: 'Solo los VLANs con subred CIDR configurada pueden escanearse.',
|
||||||
|
noCidrWarning: 'Ninguna VLAN tiene CIDR configurado. Configúrelo en la pestaña Redes.',
|
||||||
|
noCidr: 'sin CIDR',
|
||||||
|
startDiscovery: 'Iniciar descubrimiento',
|
||||||
|
scanning: 'Escaneo en curso…',
|
||||||
|
scanAddresses: 'direcciones en',
|
||||||
|
scanVlans: 'VLAN(s)',
|
||||||
|
scanNote: 'Cada host es pingado y luego consultado en DNS.',
|
||||||
|
hostsFound: 'host(s) descubierto(s)',
|
||||||
|
addressesScanned: 'direcciones escaneadas',
|
||||||
|
newHosts: 'nuevos',
|
||||||
|
noHosts: 'No se encontraron hosts activos en los rangos seleccionados.',
|
||||||
|
colIp: 'IP',
|
||||||
|
colDns: 'Nombre (DNS)',
|
||||||
|
colStatus: 'Estado',
|
||||||
|
statusExisting: 'Ya presente',
|
||||||
|
statusNew: 'Nuevo',
|
||||||
|
newScan: 'Nuevo escaneo',
|
||||||
|
importingBtn: 'Importando…',
|
||||||
|
importBtn: 'Importar',
|
||||||
|
dnsRequired: 'Por favor ingrese un servidor DNS.',
|
||||||
|
selectVlan: 'Seleccione al menos una VLAN.',
|
||||||
|
importError: 'Error durante la importación.',
|
||||||
|
scanError: 'Error durante el escaneo.',
|
||||||
|
lightTheme: 'Tema claro',
|
||||||
|
darkTheme: 'Tema oscuro',
|
||||||
|
// Auth
|
||||||
|
loginTitle: 'Iniciar sesión',
|
||||||
|
loginUsername: 'Nombre de usuario',
|
||||||
|
loginPassword: 'Contraseña',
|
||||||
|
loginBtn: 'Iniciar sesión',
|
||||||
|
loginError: 'Credenciales incorrectas.',
|
||||||
|
logout: 'Cerrar sesión',
|
||||||
|
accountSettings: 'Configuración de cuenta',
|
||||||
|
currentPassword: 'Contraseña actual',
|
||||||
|
newUsername: 'Nuevo nombre de usuario',
|
||||||
|
newPassword: 'Nueva contraseña',
|
||||||
|
confirmPassword: 'Confirmar contraseña',
|
||||||
|
leaveBlankToKeep: 'Dejar en blanco para no cambiar',
|
||||||
|
passwordMismatch: 'Las contraseñas no coinciden.',
|
||||||
|
accountUpdated: 'Cuenta actualizada.',
|
||||||
|
wrongPassword: 'La contraseña actual es incorrecta.',
|
||||||
|
mustChangePasswordWarning: 'Por razones de seguridad, debe cambiar su contraseña antes de continuar.',
|
||||||
|
newPasswordRequired: 'Se requiere una nueva contraseña.',
|
||||||
|
passwordTooShort: 'La contraseña debe tener al menos 8 caracteres.',
|
||||||
|
passwordTooWeak: 'La contraseña debe contener al menos una letra y un número.',
|
||||||
|
usernameInvalid: 'El nombre de usuario solo puede contener letras, dígitos, . _ - (1 a 64 caracteres).',
|
||||||
|
tooManyAttempts: 'Demasiados intentos, inténtelo de nuevo más tarde.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key) {
|
||||||
|
return LANGS[locale.value]?.[key] ?? LANGS['fr'][key] ?? key
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tFmt(key, ...args) {
|
||||||
|
let str = LANGS[locale.value]?.[key] ?? LANGS['fr'][key] ?? key
|
||||||
|
args.forEach((arg, i) => { str = str.replace(`{${i}}`, arg) })
|
||||||
|
return str
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export const theme = ref(localStorage.getItem('theme') || 'light')
|
||||||
|
|
||||||
|
function apply(t) {
|
||||||
|
document.documentElement.classList.toggle('dark', t === 'dark')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(theme.value)
|
||||||
|
|
||||||
|
watch(theme, (t) => {
|
||||||
|
localStorage.setItem('theme', t)
|
||||||
|
apply(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function toggleTheme() {
|
||||||
|
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
modulePreload: { polyfill: false },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user