Compare commits

..

10 Commits

Author SHA1 Message Date
369a7e7edf fix(scans): rafraîchit l'historique et l'inventaire après un scan
L'historique des scans ne se mettait pas à jour après un POST sur
/api/discovery : le Server Component restait servi depuis le cache
route de Next.

- discovery-launcher : appelle router.refresh() après un scan
  réussi pour invalider le cache et re-rendre la page parent.
- scans/page.tsx + hosts/page.tsx : déclare `dynamic = 'force-dynamic'`
  et `revalidate = 0`. Sans ça, Next pouvait statifier ces routes au
  build (mode standalone) puisque la fonction getX() ne fait pas
  appel à un signal dynamique.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:40:25 +02:00
c1519bff2e docs: clarifie le déploiement Docker (host vs bridge)
- README racine : section Docker réécrite avec distinction Linux
  natif (host mode, découverte LAN active) vs macOS/Windows/Docker
  Desktop (override bridge, découverte désactivée). Mention de la
  contrainte Compose v2.24+.
- docker/README.md : pré-requis précisés, limitation Docker Desktop
  documentée explicitement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:33:24 +02:00
0685fcd2c5 fix(docker): garantit l'existence de /app/public au build
Le dossier public/ étant vide n'était pas tracké par git, donc
absent après clone sur une machine Linux. Le COPY runner échouait
sur 'failed to compute cache key: "/app/public": not found'.

- Ajoute public/.gitkeep pour versionner le dossier
- Ajoute `mkdir -p /app/public` dans le stage builder en filet
  de sécurité si le dossier disparaît à nouveau

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:33:16 +02:00
39f37ee2b8 fix(compose): retire network_mode hérité via !reset en bridge
Le parent docker-compose.yml fixe `network_mode: host` ; combiné
avec une déclaration `networks:` dans l'override, Compose refuse
le projet (mutuellement exclusifs). On utilise `!reset null` pour
supprimer la clé héritée et permettre l'usage du réseau bridge
personnalisé. Requiert Compose v2.24+.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:22:15 +02:00
2afb2e3bd2 fix(prisma): déclare les binaryTargets Alpine x64 et arm64
Sans binaryTargets explicites, Prisma ne génère l'engine que pour
la plateforme du builder, ce qui fait échouer le runtime sur les
containers Alpine arm64 (Raspberry Pi, Apple Silicon en émulation).
Ajout des cibles linux-musl-openssl-3.0.x et
linux-musl-arm64-openssl-3.0.x en plus de "native".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:22:09 +02:00
6278c3691f fix(docker): copie node_modules/.bin dans le stage runner
L'entrypoint utilise `npx prisma` et `npx tsx` mais les symlinks
correspondants vivent dans node_modules/.bin, qui n'était pas
copié depuis le builder. Résultat : `sh: prisma: not found` au
démarrage. On ajoute la copie de .bin ; les symlinks vers les
paquets non copiés deviendront dangling sans impact (on utilise
uniquement prisma et tsx au runtime).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:21:52 +02:00
f7ca7a0929 fix(mdns-scanner): browser par type au lieu d'une recherche globale
bonjour-service exige un `type` dans BrowserConfig ; il n'existe pas
d'option pour écouter "tous les services". Le scanner instancie
maintenant un browser par type mDNS courant en homelab (http, ssh,
smb, airplay, googlecast, homekit, ipp, etc.) et agrège les
annonces par IP via une fonction `ingest` partagée.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:21:45 +02:00
1273368c9a fix(arp-scanner): remplace map+filter null par flatMap typé
Le combo `map -> satisfies DiscoveryResult -> filter (r is …)` ne
passait pas le typecheck strict de Next : le type littéral produit
par `satisfies` n'était pas considéré sous-type valide du prédicat.
`flatMap<DiscoveryResult>` retourne [] quand la ligne ne matche pas,
ce qui évite la nullité intermédiaire et respecte la propriété
optionnelle `macAddress?: string`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:10:07 +02:00
dc8dbc3cd7 fix(prisma): remplace les enums par des String (compat SQLite)
SQLite ne supporte pas les enums Prisma. Les 6 enums (HostStatus,
HostSource, Protocol, PortState, ScanType, ScanStatus) sont
convertis en champs String avec valeurs par défaut littérales et
documentation inline des valeurs autorisées. La validation reste
assurée par Zod côté app.

Adapte aussi les mappings statusVariant des pages hosts/scans
en Record<string, BadgeVariant> avec fallback 'default'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:09:57 +02:00
6c020e7ee3 feat(docker): finalise le déploiement compose (sqlite + postgres)
- Compose nettoyé en deux profils isolés (sqlite, postgres) avec
  healthcheck HTTP et network_mode host pour la découverte LAN.
- Override docker-compose.bridge.yml pour les environnements où
  host mode n'est pas disponible (macOS/Windows).
- Entrypoint tolérant : fallback prisma db push quand aucune
  migration n'existe encore.
- Dockerfile robuste sans package-lock.json (npm install fallback).
- .env.docker.example et docker/README.md pour un démarrage en
  une commande.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:03:46 +02:00
15 changed files with 567 additions and 117 deletions

View File

@@ -1,15 +1,74 @@
# ---------------------------------------------------------------
# Docker build context — exclusions
# ---------------------------------------------------------------
# Dependencies (réinstallés dans l'image)
node_modules node_modules
.pnp
.pnp.js
# Build artifacts
.next .next
.git out
dist
build
*.tsbuildinfo
next-env.d.ts
# Env (les secrets ne doivent jamais être copiés dans l'image)
.env .env
.env.local .env.local
.env.*.local .env.*.local
npm-debug.log .env.production
Dockerfile .env.development.local
.dockerignore .env.test.local
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Git
.git
.gitignore
.gitattributes
# IDE
.vscode .vscode
.idea .idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Docker (évite la récursion). On NE PEUT PAS exclure docker/ car
# l'image a besoin de docker/entrypoint.sh ; on se contente d'exclure
# le compose et le dockerignore qui sont inutiles dans l'image.
docker/docker-compose.yml
docker/docker-compose.bridge.yml
.dockerignore
# Bases locales (SQLite dev)
data
*.db
*.db-journal
prisma/data
prisma/*.db
prisma/*.db-journal
# Tests & coverage
tests tests
coverage coverage
.nyc_output
__tests__
# Docs (sauf README)
*.md *.md
!README.md !README.md
# CI / Misc
.github
.husky

52
.env.docker.example Normal file
View File

@@ -0,0 +1,52 @@
# ---------------------------------------------------------------
# IPAM — Variables pour un déploiement Docker
# ---------------------------------------------------------------
# Copie ce fichier en `.env` à la racine du projet avant de lancer
# `docker compose`. Le compose lit `../.env` (env_file).
# ---------------------------------------------------------------
NODE_ENV=production
# ---------------------------------------------------------------
# Base de données
# ---------------------------------------------------------------
# Choix par défaut : SQLite — aucune dépendance externe.
# Pour basculer sur PostgreSQL :
# 1) Lance le profil postgres : `docker compose --profile postgres up -d`
# 2) Décommente la section PG ci-dessous et commente celle SQLite.
DATABASE_PROVIDER=sqlite
DATABASE_URL="file:/app/data/ipam.db"
# --- Variante PostgreSQL --------------------------------------
# DATABASE_PROVIDER=postgresql
# DATABASE_URL="postgresql://ipam:ipam@127.0.0.1:5432/ipam?schema=public"
# POSTGRES_PASSWORD=ipam
# ---------------------------------------------------------------
# Application
# ---------------------------------------------------------------
PORT=3000
APP_URL=http://localhost:3000
# Seed initial au premier démarrage (catégories d'apps, etc.)
RUN_SEED=true
# ---------------------------------------------------------------
# Découverte réseau
# ---------------------------------------------------------------
# Adapte la plage à ton LAN (séparées par des virgules)
DISCOVERY_DEFAULT_CIDRS=192.168.1.0/24
DISCOVERY_DEFAULT_PORTS=22,53,80,81,443,445,3000,3306,5432,6379,7878,8080,8081,8096,8123,8443,8989,9000,9090,9091,9117,9443,32400
DISCOVERY_PING_TIMEOUT=1000
DISCOVERY_PORT_TIMEOUT=800
DISCOVERY_CONCURRENCY=32
DISCOVERY_ENABLE_PING=true
DISCOVERY_ENABLE_PORT_SCAN=true
DISCOVERY_ENABLE_ARP=true
DISCOVERY_ENABLE_MDNS=true
# ---------------------------------------------------------------
# Logs
# ---------------------------------------------------------------
LOG_LEVEL=info

View File

@@ -51,16 +51,54 @@ npm run db:migrate
## Déploiement Docker ## Déploiement Docker
```bash > Détails complets dans [`docker/README.md`](./docker/README.md).
# Avec SQLite (simple, fichier local persisté dans un volume)
docker compose --profile sqlite -f docker/docker-compose.yml up -d
# Avec PostgreSQL ### Pré-requis
docker compose --profile postgres -f docker/docker-compose.yml up -d
- Docker Engine ≥ 24 + Docker Compose v2.24+ (`!reset` requis pour l'override bridge)
- Linux natif **recommandé en production** (seul environnement où la découverte LAN fonctionne pleinement)
### Configuration
```bash
cp .env.docker.example .env
# Édite .env pour ajuster DISCOVERY_DEFAULT_CIDRS à ton LAN (ex: 192.168.1.0/24)
``` ```
> Le conteneur utilise `network_mode: host` pour permettre les scans ARP / mDNS / ### Linux natif (production homelab — recommandé)
> ping sur le LAN. À désactiver si non nécessaire.
Mode `host` activé : la découverte ARP / mDNS / ping sweep voit l'ensemble du LAN.
```bash
# SQLite (autonome)
docker compose -f docker/docker-compose.yml --profile sqlite up -d --build
# OU PostgreSQL
docker compose -f docker/docker-compose.yml --profile postgres up -d --build
```
UI : `http://<ip-de-la-machine>:3000`
### macOS / Windows / Docker Desktop (dev local uniquement)
`network_mode: host` ne fonctionne pas dans la VM Docker Desktop : on ajoute l'override bridge qui mappe explicitement `3000:3000`. ⚠️ La découverte LAN (ARP / mDNS / ping) sera **désactivée** — seule la saisie manuelle reste fonctionnelle.
```bash
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.bridge.yml \
--profile sqlite up -d --build
```
UI : <http://localhost:3000>
### Commandes utiles
```bash
docker compose -f docker/docker-compose.yml logs -f ipam # logs
docker compose -f docker/docker-compose.yml down # stop
docker compose -f docker/docker-compose.yml down -v # stop + suppression données
```
## Découverte réseau ## Découverte réseau

View File

@@ -1,44 +1,103 @@
# syntax=docker/dockerfile:1.6 # syntax=docker/dockerfile:1.6
# ---------------------------------------------------------------
# IPAM — Dockerfile multi-stage
# ---------------------------------------------------------------
# Build arg :
# DATABASE_PROVIDER = sqlite (défaut) | postgresql
# Rappel : le provider Prisma est statique dans schema.prisma, on
# le bascule via scripts/switch-db-provider.mjs avant `prisma generate`.
# ---------------------------------------------------------------
ARG DATABASE_PROVIDER=sqlite
# ---------- Stage 1 : deps ---------- # ---------- Stage 1 : deps ----------
FROM node:20-alpine AS deps FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci # `npm ci` exige un package-lock.json ; sinon on retombe sur `npm install`
# qui en générera un. Cela permet un build « out of the box » même sans lock.
RUN if [ -f package-lock.json ]; then \
npm ci; \
else \
echo "package-lock.json absent -> npm install" && \
npm install --no-audit --no-fund; \
fi
# ---------- Stage 2 : build ---------- # ---------- Stage 2 : build ----------
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
ARG DATABASE_PROVIDER
ENV DATABASE_PROVIDER=${DATABASE_PROVIDER}
# Valeur factice pour que Zod ne rejette pas la config à la génération
ENV DATABASE_URL="file:./data/build-placeholder.db"
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npx prisma generate
RUN npm run build # Garantit l'existence de /app/public (Next.js l'exige même vide).
# Le dossier est parfois absent quand le repo est cloné sans assets statiques.
RUN mkdir -p /app/public
# Bascule le provider Prisma si nécessaire, puis génère le client et build
RUN node scripts/switch-db-provider.mjs "$DATABASE_PROVIDER" \
&& npx prisma generate \
&& npm run build
# ---------- Stage 3 : runner ---------- # ---------- Stage 3 : runner ----------
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
WORKDIR /app ARG DATABASE_PROVIDER
ENV DATABASE_PROVIDER=${DATABASE_PROVIDER}
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Outils système utiles à la découverte réseau WORKDIR /app
# - iputils : ping ICMP (privileged / cap_net_raw)
# - iproute2 : ip neigh (ARP)
# - nmap : fallback scan ports (optionnel, décommente si besoin)
RUN apk add --no-cache iputils iproute2
RUN addgroup --system --gid 1001 nodejs # Outils système utiles à la découverte et à l'entrypoint :
RUN adduser --system --uid 1001 nextjs # - iputils : ping ICMP (cap_net_raw requis côté compose)
# - iproute2 : `ip neigh` pour le scanner ARP
# - netcat-openbsd : attente de PostgreSQL (entrypoint)
# - openssl, tini, dumb-init : init + signaux propres
RUN apk add --no-cache iputils iproute2 netcat-openbsd tini openssl
COPY --from=builder /app/public ./public # User non-root pour la sécurité
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Binaires app (mode standalone Next)
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Prisma : schema + client généré + migrations (pour `migrate deploy` au boot)
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
# tsx + dépendances pour pouvoir exécuter prisma/seed.ts si RUN_SEED=true
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/tsx ./node_modules/tsx
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/esbuild ./node_modules/esbuild
# Binaires symlinkés (.bin) — nécessaires à `npx prisma` et `npx tsx`
# Les symlinks pointant vers des paquets non copiés seront dangling
# mais sans impact : on n'utilise que prisma et tsx au runtime.
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.bin ./node_modules/.bin
# Entrypoint
COPY --chown=nextjs:nodejs docker/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# Volume SQLite (inutile en PG mais inoffensif)
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
VOLUME ["/app/data"]
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
CMD ["node", "server.js"] # Healthcheck simple — retourne 200 dès que Next répond
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
# tini pour gérer SIGTERM proprement
ENTRYPOINT ["/sbin/tini", "--", "/app/entrypoint.sh"]

109
docker/README.md Normal file
View File

@@ -0,0 +1,109 @@
# IPAM — Déploiement Docker
Tout ce qu'il faut pour lancer l'application en une commande.
## Pré-requis
- Docker Engine ≥ 24
- Docker Compose **v2.24+** (le tag `!reset` utilisé par l'override bridge est requis)
- Linux natif **fortement conseillé** : `network_mode: host` ne fonctionne pas dans la VM de Docker Desktop (macOS / Windows). Sur ces plateformes, utilise l'override bridge — voir plus bas — mais la découverte LAN (ARP / mDNS / ping sweep) sera désactivée.
## Fichiers fournis
| Fichier | Rôle |
|---|---|
| `Dockerfile` | Image multi-stage (deps → build → runner) basée sur `node:20-alpine` |
| `entrypoint.sh` | Attente PostgreSQL, migrations Prisma, seed optionnel, lancement Next |
| `docker-compose.yml` | Compose principal — profils `sqlite` et `postgres`, mode host |
| `docker-compose.bridge.yml` | Override pour passer en mode bridge (sans découverte LAN) |
## Démarrage rapide — profil SQLite
```bash
# 1. Prépare la config
cp .env.docker.example .env
# (édite .env si besoin — surtout DISCOVERY_DEFAULT_CIDRS)
# 2. Build + run
docker compose -f docker/docker-compose.yml --profile sqlite up -d --build
# 3. Logs
docker compose -f docker/docker-compose.yml logs -f ipam
```
L'UI est disponible sur <http://localhost:3000>.
## Profil PostgreSQL
```bash
cp .env.docker.example .env
# Dans .env, bascule sur PostgreSQL :
# DATABASE_PROVIDER=postgresql
# DATABASE_URL="postgresql://ipam:ipam@127.0.0.1:5432/ipam?schema=public"
docker compose -f docker/docker-compose.yml --profile postgres up -d --build
```
Le container `ipam-postgres` expose le port `5432` sur l'hôte (utile pour `prisma studio` ou un client SQL externe).
## Bridge mode (sans découverte LAN)
Utile sur macOS / Windows ou pour un test rapide de l'UI :
```bash
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.bridge.yml \
--profile sqlite up -d --build
```
⚠️ En bridge, **ARP / mDNS / ping sweep ne fonctionneront pas** sur ton LAN — seule la saisie manuelle reste pleinement utilisable.
## Commandes utiles
```bash
# Arrêter
docker compose -f docker/docker-compose.yml down
# Tout supprimer (y compris les volumes — perte des données !)
docker compose -f docker/docker-compose.yml down -v
# Re-seeder manuellement
docker exec -it ipam npx tsx prisma/seed.ts
# Prisma studio (depuis l'hôte, profil postgres)
DATABASE_URL="postgresql://ipam:ipam@localhost:5432/ipam?schema=public" npx prisma studio
# Shell dans le container
docker exec -it ipam sh
```
## Variables d'environnement clés
Toutes les variables sont documentées dans [`.env.docker.example`](../.env.docker.example).
Les plus importantes pour Docker :
- `DATABASE_PROVIDER``sqlite` (défaut) ou `postgresql`
- `DATABASE_URL` — fichier `file:/app/data/ipam.db` ou DSN PostgreSQL
- `RUN_SEED``true` pour exécuter `prisma/seed.ts` au premier boot
- `DISCOVERY_DEFAULT_CIDRS` — plages scannées par défaut, à adapter à ton LAN
## Persistance
| Volume | Contenu |
|---|---|
| `ipam-data` | Base SQLite (`/app/data/ipam.db`) |
| `ipam-postgres` | Données PostgreSQL |
Les deux survivent à `docker compose down`, mais pas à `down -v`.
## Pourquoi `network_mode: host` ?
Pour qu'un container puisse :
- envoyer des paquets ICMP (ping sweep)
- lire la table ARP (`ip neigh`) du réseau local
- recevoir les annonces mDNS (port 5353 UDP multicast)
…il doit partager la pile réseau de l'hôte. Sans `host`, ces fonctionnalités sont aveugles au LAN.
Les `cap_add: [NET_RAW, NET_ADMIN]` permettent par ailleurs au binaire `ping` du container d'ouvrir un socket ICMP en non-root.

View File

@@ -0,0 +1,47 @@
# ---------------------------------------------------------------
# IPAM — Override : bridge network (sans host mode)
# ---------------------------------------------------------------
# À combiner avec docker-compose.yml :
# docker compose \
# -f docker/docker-compose.yml \
# -f docker/docker-compose.bridge.yml \
# --profile sqlite up -d
#
# ATTENTION : en mode bridge la découverte ARP / mDNS / ping sweep
# ne fonctionnera PAS sur le LAN (le container n'a pas accès direct
# au réseau de l'hôte). À utiliser uniquement pour la saisie manuelle
# ou un test rapide de l'UI sur macOS / Windows / Docker Desktop.
#
# `!reset null` retire `network_mode: host` hérité du compose parent.
# Nécessaire car `network_mode` et `networks` sont mutuellement
# exclusifs. Requiert Docker Compose v2.24+.
# ---------------------------------------------------------------
services:
ipam:
network_mode: !reset null
ports:
- "3000:3000"
networks:
- ipam-net
ipam-pg:
network_mode: !reset null
ports:
- "3000:3000"
environment:
# En bridge, on cible le service postgres par son nom DNS Docker
DATABASE_URL: "postgresql://ipam:${POSTGRES_PASSWORD:-ipam}@postgres:5432/ipam?schema=public"
networks:
- ipam-net
postgres:
network_mode: !reset null
ports:
- "5432:5432"
networks:
- ipam-net
networks:
ipam-net:
driver: bridge

View File

@@ -2,60 +2,115 @@
# IPAM — Docker Compose # IPAM — Docker Compose
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Deux profils : # Deux profils :
# - `sqlite` → ipam seul, base dans un volume # - `sqlite` → ipam seul, base SQLite dans un volume
# - `postgres` → ipam + postgres # - `postgres` → ipam + postgres
# #
# Usage : # Usage :
# docker compose --profile sqlite up -d # docker compose -f docker/docker-compose.yml --profile sqlite up -d
# docker compose --profile postgres up -d # docker compose -f docker/docker-compose.yml --profile postgres up -d
#
# Le mode `network_mode: host` est indispensable pour que la découverte
# (ARP, mDNS, ping sweep, port scan) atteigne le LAN. Sans lui, le
# container ne voit que le bridge Docker.
# --------------------------------------------------------------- # ---------------------------------------------------------------
services: services:
# =========================================================
# Service IPAM — variante SQLite (autonome, sans PostgreSQL)
# =========================================================
ipam: ipam:
build: build:
context: .. context: ..
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
args:
DATABASE_PROVIDER: sqlite
image: ipam-homelab:sqlite
container_name: ipam container_name: ipam
restart: unless-stopped restart: unless-stopped
# Network mode host : indispensable pour ARP / mDNS / ping sweep
# sur le réseau local. Commenter cette ligne si non nécessaire.
network_mode: host network_mode: host
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0
DATABASE_PROVIDER: sqlite
DATABASE_URL: "file:/app/data/ipam.db"
RUN_SEED: "${RUN_SEED:-false}"
env_file: env_file:
- ../.env - ../.env
volumes: volumes:
- ipam-data:/app/data - ipam-data:/app/data
# Capabilities requises pour le ping ICMP raw
cap_add: cap_add:
- NET_RAW - NET_RAW
- NET_ADMIN - NET_ADMIN
profiles: [sqlite, postgres] healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
profiles: ["sqlite"]
# =========================================================
# Service IPAM — variante PostgreSQL
# =========================================================
ipam-pg:
build:
context: ..
dockerfile: docker/Dockerfile
args:
DATABASE_PROVIDER: postgresql
image: ipam-homelab:postgres
container_name: ipam
restart: unless-stopped
network_mode: host
environment:
NODE_ENV: production
PORT: 3000
HOSTNAME: 0.0.0.0
DATABASE_PROVIDER: postgresql
DATABASE_URL: "postgresql://ipam:${POSTGRES_PASSWORD:-ipam}@127.0.0.1:5432/ipam?schema=public"
RUN_SEED: "${RUN_SEED:-false}"
env_file:
- ../.env
cap_add:
- NET_RAW
- NET_ADMIN
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
required: false healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
profiles: ["postgres"]
# =========================================================
# PostgreSQL (profil "postgres" uniquement)
# =========================================================
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: ipam-postgres container_name: ipam-postgres
restart: unless-stopped restart: unless-stopped
# network_mode host → expose le port 5432 directement sur l'hôte.
network_mode: host
environment: environment:
POSTGRES_USER: ipam POSTGRES_USER: ipam
POSTGRES_PASSWORD: ipam POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-ipam}"
POSTGRES_DB: ipam POSTGRES_DB: ipam
PGPORT: 5432
volumes: volumes:
- ipam-postgres:/var/lib/postgresql/data - ipam-postgres:/var/lib/postgresql/data
ports:
- '5432:5432'
healthcheck: healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ipam'] test: ["CMD-SHELL", "pg_isready -U ipam -d ipam"]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 10 retries: 10
profiles: [postgres] profiles: ["postgres"]
volumes: volumes:
ipam-data: ipam-data:
driver: local
ipam-postgres: ipam-postgres:
driver: local

View File

@@ -44,8 +44,15 @@ if [ "${DATABASE_PROVIDER:-sqlite}" = "sqlite" ]; then
fi fi
# --- 3. Migrations Prisma ----------------------------------------- # --- 3. Migrations Prisma -----------------------------------------
log "Application des migrations Prisma…" # Si des migrations existent → migrate deploy (production-safe)
npx prisma migrate deploy # Sinon (premier démarrage sans baseline) → db push pour initialiser le schéma
if [ -d /app/prisma/migrations ] && [ "$(find /app/prisma/migrations -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -n 1)" != "" ]; then
log "Application des migrations Prisma…"
npx prisma migrate deploy
else
log "Aucune migration trouvée — initialisation du schéma via 'prisma db push'…"
npx prisma db push --skip-generate --accept-data-loss
fi
# --- 4. Seed optionnel -------------------------------------------- # --- 4. Seed optionnel --------------------------------------------
if [ "${RUN_SEED:-false}" = "true" ]; then if [ "${RUN_SEED:-false}" = "true" ]; then

View File

@@ -8,7 +8,11 @@
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
// - native : dev local (macOS / Linux glibc)
// - linux-musl-openssl-3.0.x : runtime Alpine x86_64
// - linux-musl-arm64-openssl-3.0.x : runtime Alpine arm64 (Raspberry Pi, Apple Silicon en Docker, etc.)
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x"]
} }
datasource db { datasource db {
@@ -49,10 +53,11 @@ model Host {
vendor String? // Issue du MAC (OUI) ou d'une fingerprint vendor String? // Issue du MAC (OUI) ou d'une fingerprint
osGuess String? // OS détecté / renseigné osGuess String? // OS détecté / renseigné
status HostStatus @default(UNKNOWN) // Valeurs : "UP" | "DOWN" | "UNKNOWN" (validation Zod côté app)
status String @default("UNKNOWN")
// Origine de la fiche : saisie manuelle ou découverte // Origine de la fiche : "MANUAL" | "DISCOVERED" | "IMPORTED"
source HostSource @default(MANUAL) source String @default("MANUAL")
// Relations // Relations
networkId String? networkId String?
@@ -71,17 +76,10 @@ model Host {
@@index([status]) @@index([status])
} }
enum HostStatus { // NOTE : les enums Prisma ne sont pas supportés par SQLite. On stocke
UP // donc tous les statuts/types en `String` et on valide les valeurs
DOWN // possibles côté app via Zod. Constantes documentées au-dessus de
UNKNOWN // chaque champ.
}
enum HostSource {
MANUAL
DISCOVERED
IMPORTED
}
// ===================================================================== // =====================================================================
// Ports ouverts sur un hôte // Ports ouverts sur un hôte
@@ -89,10 +87,12 @@ enum HostSource {
model Port { model Port {
id String @id @default(cuid()) id String @id @default(cuid())
number Int number Int
protocol Protocol @default(TCP) // Valeurs : "TCP" | "UDP"
protocol String @default("TCP")
serviceName String? // ex: "http", "ssh", renseigné ou deviné serviceName String? // ex: "http", "ssh", renseigné ou deviné
banner String? // Banner applicatif collecté banner String? // Banner applicatif collecté
state PortState @default(OPEN) // Valeurs : "OPEN" | "CLOSED" | "FILTERED" | "UNKNOWN"
state String @default("OPEN")
hostId String hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
@@ -110,17 +110,8 @@ model Port {
@@index([number]) @@index([number])
} }
enum Protocol { // Protocol : "TCP" | "UDP" — voir Port.protocol
TCP // PortState : "OPEN" | "CLOSED" | "FILTERED" | "UNKNOWN" — voir Port.state
UDP
}
enum PortState {
OPEN
CLOSED
FILTERED
UNKNOWN
}
// ===================================================================== // =====================================================================
// Applications (Jellyfin, Home Assistant, Nextcloud, etc.) // Applications (Jellyfin, Home Assistant, Nextcloud, etc.)
@@ -159,9 +150,11 @@ model HostApplication {
// Découverte réseau — historique des scans // Découverte réseau — historique des scans
// ===================================================================== // =====================================================================
model Scan { model Scan {
id String @id @default(cuid()) id String @id @default(cuid())
type ScanType // Valeurs : "PING" | "PORT" | "ARP" | "MDNS" | "FULL"
status ScanStatus @default(PENDING) type String
// Valeurs : "PENDING" | "RUNNING" | "COMPLETED" | "FAILED" | "CANCELLED"
status String @default("PENDING")
target String // CIDR, IP, ou hôte target String // CIDR, IP, ou hôte
params String? // JSON (Zod validé côté app) params String? // JSON (Zod validé côté app)
startedAt DateTime? startedAt DateTime?
@@ -180,21 +173,8 @@ model Scan {
@@index([type]) @@index([type])
} }
enum ScanType { // ScanType : "PING" | "PORT" | "ARP" | "MDNS" | "FULL" — voir Scan.type
PING // ScanStatus : "PENDING" | "RUNNING" | "COMPLETED" | "FAILED" | "CANCELLED" — voir Scan.status
PORT
ARP
MDNS
FULL
}
enum ScanStatus {
PENDING
RUNNING
COMPLETED
FAILED
CANCELLED
}
// Résultat individuel d'un scan (rattaché à un hôte quand possible) // Résultat individuel d'un scan (rattaché à un hôte quand possible)
model ScanResult { model ScanResult {

0
public/.gitkeep Normal file
View File

View File

@@ -15,6 +15,10 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import { prisma } from '@/lib/db/prisma'; import { prisma } from '@/lib/db/prisma';
// L'inventaire évolue à chaque scan : pas de cache de route
export const dynamic = 'force-dynamic';
export const revalidate = 0;
async function getHosts() { async function getHosts() {
try { try {
return await prisma.host.findMany({ return await prisma.host.findMany({
@@ -30,11 +34,13 @@ async function getHosts() {
} }
} }
const statusVariant = { type BadgeVariant = 'default' | 'success' | 'warning' | 'destructive' | 'info' | 'outline';
const statusVariant: Record<string, BadgeVariant> = {
UP: 'success', UP: 'success',
DOWN: 'destructive', DOWN: 'destructive',
UNKNOWN: 'default', UNKNOWN: 'default',
} as const; };
export default async function HostsPage() { export default async function HostsPage() {
const hosts = await getHosts(); const hosts = await getHosts();
@@ -82,7 +88,7 @@ export default async function HostsPage() {
{h.network?.name ?? '—'} {h.network?.name ?? '—'}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={statusVariant[h.status]}>{h.status}</Badge> <Badge variant={statusVariant[h.status] ?? 'default'}>{h.status}</Badge>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{h.ports.length > 0 ? ( {h.ports.length > 0 ? (

View File

@@ -15,6 +15,10 @@ import {
import { DiscoveryLauncher } from '@/components/scans/discovery-launcher'; import { DiscoveryLauncher } from '@/components/scans/discovery-launcher';
import { prisma } from '@/lib/db/prisma'; import { prisma } from '@/lib/db/prisma';
// L'historique change à chaque scan : pas de cache de route
export const dynamic = 'force-dynamic';
export const revalidate = 0;
async function getScans() { async function getScans() {
try { try {
return await prisma.scan.findMany({ return await prisma.scan.findMany({
@@ -26,13 +30,15 @@ async function getScans() {
} }
} }
const statusVariant = { type BadgeVariant = 'default' | 'success' | 'warning' | 'destructive' | 'info' | 'outline';
const statusVariant: Record<string, BadgeVariant> = {
COMPLETED: 'success', COMPLETED: 'success',
RUNNING: 'info', RUNNING: 'info',
PENDING: 'default', PENDING: 'default',
FAILED: 'destructive', FAILED: 'destructive',
CANCELLED: 'warning', CANCELLED: 'warning',
} as const; };
function formatDuration(start: Date | null, end: Date | null) { function formatDuration(start: Date | null, end: Date | null) {
if (!start || !end) return '—'; if (!start || !end) return '—';
@@ -83,7 +89,7 @@ export default async function ScansPage() {
</TableCell> </TableCell>
<TableCell className="font-mono text-sm">{s.target}</TableCell> <TableCell className="font-mono text-sm">{s.target}</TableCell>
<TableCell> <TableCell>
<Badge variant={statusVariant[s.status]}>{s.status}</Badge> <Badge variant={statusVariant[s.status] ?? 'default'}>{s.status}</Badge>
</TableCell> </TableCell>
<TableCell className="tabular-nums">{s.hostsFound}</TableCell> <TableCell className="tabular-nums">{s.hostsFound}</TableCell>
<TableCell className="tabular-nums">{s.portsFound}</TableCell> <TableCell className="tabular-nums">{s.portsFound}</TableCell>

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Radar, Zap } from 'lucide-react'; import { Radar, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -10,6 +11,7 @@ import { Card } from '@/components/ui/card';
type Kind = 'full' | 'ping' | 'port' | 'arp' | 'mdns'; type Kind = 'full' | 'ping' | 'port' | 'arp' | 'mdns';
export function DiscoveryLauncher() { export function DiscoveryLauncher() {
const router = useRouter();
const [kind, setKind] = useState<Kind>('full'); const [kind, setKind] = useState<Kind>('full');
const [cidr, setCidr] = useState('192.168.1.0/24'); const [cidr, setCidr] = useState('192.168.1.0/24');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -37,6 +39,8 @@ export function DiscoveryLauncher() {
setMessage( setMessage(
`✓ Scan ${json.data.status}${json.data.hostsFound} hôtes, ${json.data.portsFound} ports`, `✓ Scan ${json.data.status}${json.data.hostsFound} hôtes, ${json.data.portsFound} ports`,
); );
// Invalide le cache route pour que l'historique se rafraîchisse
router.refresh();
} catch (e) { } catch (e) {
setMessage(`${(e as Error).message}`); setMessage(`${(e as Error).message}`);
} finally { } finally {

View File

@@ -30,16 +30,14 @@ function parseIpNeigh(output: string): DiscoveryResult[] {
.split('\n') .split('\n')
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean) .filter(Boolean)
.map((line) => { .flatMap<DiscoveryResult>((line) => {
const ipMatch = line.match(/^(\d{1,3}(?:\.\d{1,3}){3})/); const ipMatch = line.match(/^(\d{1,3}(?:\.\d{1,3}){3})/);
if (!ipMatch) return [];
const macMatch = line.match(/lladdr\s+([0-9a-fA-F:]{17})/); const macMatch = line.match(/lladdr\s+([0-9a-fA-F:]{17})/);
if (!ipMatch) return null; const result: DiscoveryResult = { ipAddress: ipMatch[1] };
return { if (macMatch) result.macAddress = macMatch[1];
ipAddress: ipMatch[1], return [result];
macAddress: macMatch?.[1], });
} satisfies DiscoveryResult;
})
.filter((r): r is DiscoveryResult => r !== null);
} }
function parseArpA(output: string): DiscoveryResult[] { function parseArpA(output: string): DiscoveryResult[] {
@@ -48,14 +46,12 @@ function parseArpA(output: string): DiscoveryResult[] {
.split('\n') .split('\n')
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean) .filter(Boolean)
.map((line) => { .flatMap<DiscoveryResult>((line) => {
const ipMatch = line.match(/\((\d{1,3}(?:\.\d{1,3}){3})\)/); const ipMatch = line.match(/\((\d{1,3}(?:\.\d{1,3}){3})\)/);
if (!ipMatch) return [];
const macMatch = line.match(/([0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5})/); const macMatch = line.match(/([0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5})/);
if (!ipMatch) return null; const result: DiscoveryResult = { ipAddress: ipMatch[1] };
return { if (macMatch) result.macAddress = macMatch[1];
ipAddress: ipMatch[1], return [result];
macAddress: macMatch?.[1], });
} satisfies DiscoveryResult;
})
.filter((r): r is DiscoveryResult => r !== null);
} }

View File

@@ -1,13 +1,36 @@
import { Bonjour } from 'bonjour-service'; import { Bonjour, type Service } from 'bonjour-service';
import { env } from '@/config/env'; import { env } from '@/config/env';
import type { DiscoveryResult, DiscoveryTarget, Scanner } from '../types'; import type { DiscoveryResult, DiscoveryTarget, Scanner } from '../types';
/** /**
* Découverte via mDNS / DNS-SD (_services._dns-sd._udp.local). * Découverte via mDNS / DNS-SD.
* Écoute pendant `timeout` ms et agrège les annonces reçues. * `bonjour-service` exige un `type` par browser : on en lance un par type
* courant en homelab et on agrège les annonces reçues pendant `timeout` ms.
*/ */
const DEFAULT_TIMEOUT_MS = 4000; const DEFAULT_TIMEOUT_MS = 4000;
// Types mDNS les plus fréquents en homelab (sans le préfixe `_` ni le suffixe `._tcp/._udp`)
const COMMON_SERVICE_TYPES: Array<{ type: string; protocol: 'tcp' | 'udp' }> = [
{ type: 'http', protocol: 'tcp' },
{ type: 'https', protocol: 'tcp' },
{ type: 'ssh', protocol: 'tcp' },
{ type: 'sftp-ssh', protocol: 'tcp' },
{ type: 'smb', protocol: 'tcp' },
{ type: 'nfs', protocol: 'tcp' },
{ type: 'afpovertcp', protocol: 'tcp' },
{ type: 'workstation', protocol: 'tcp' },
{ type: 'device-info', protocol: 'tcp' },
{ type: 'printer', protocol: 'tcp' },
{ type: 'ipp', protocol: 'tcp' },
{ type: 'ipps', protocol: 'tcp' },
{ type: 'airplay', protocol: 'tcp' },
{ type: 'raop', protocol: 'tcp' },
{ type: 'googlecast', protocol: 'tcp' },
{ type: 'homekit', protocol: 'tcp' },
{ type: 'hap', protocol: 'tcp' },
{ type: 'spotify-connect', protocol: 'tcp' },
];
export const mdnsScanner: Scanner = { export const mdnsScanner: Scanner = {
kind: 'mdns', kind: 'mdns',
label: 'mDNS / Bonjour', label: 'mDNS / Bonjour',
@@ -17,11 +40,14 @@ export const mdnsScanner: Scanner = {
const bonjour = new Bonjour(); const bonjour = new Bonjour();
const byIp = new Map<string, DiscoveryResult>(); const byIp = new Map<string, DiscoveryResult>();
const browser = bonjour.find({}); const ingest = (svc: Service) => {
browser.on('up', (svc) => {
const ips = (svc.addresses ?? []).filter((a) => a && !a.includes(':')); // IPv4 const ips = (svc.addresses ?? []).filter((a) => a && !a.includes(':')); // IPv4
for (const ip of ips) { for (const ip of ips) {
const existing = byIp.get(ip) ?? { ipAddress: ip, hostname: svc.host, services: [] }; const existing = byIp.get(ip) ?? {
ipAddress: ip,
hostname: svc.host,
services: [],
};
existing.services!.push({ existing.services!.push({
name: svc.name, name: svc.name,
type: `_${svc.type}._${svc.protocol}.local`, type: `_${svc.type}._${svc.protocol}.local`,
@@ -29,10 +55,16 @@ export const mdnsScanner: Scanner = {
}); });
byIp.set(ip, existing); byIp.set(ip, existing);
} }
};
const browsers = COMMON_SERVICE_TYPES.map(({ type, protocol }) => {
const browser = bonjour.find({ type, protocol });
browser.on('up', ingest);
return browser;
}); });
await new Promise((r) => setTimeout(r, DEFAULT_TIMEOUT_MS)); await new Promise((r) => setTimeout(r, DEFAULT_TIMEOUT_MS));
browser.stop(); for (const b of browsers) b.stop();
bonjour.destroy(); bonjour.destroy();
return [...byIp.values()]; return [...byIp.values()];