commit fbb6138c28c2c947ffb899b9d036c25607865ae3 Author: Mathieu BOURBON Date: Sat Apr 18 16:24:44 2026 +0200 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..07c1bc1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +.next +.git +.env +.env.local +.env.*.local +npm-debug.log +Dockerfile +.dockerignore +.vscode +.idea +tests +coverage +*.md +!README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3ddf0ff --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# ---------------------------------------- +# IPAM — Variables d'environnement +# ---------------------------------------- + +# Environnement d'exécution +NODE_ENV=development + +# ---------------------------------------- +# Base de données +# ---------------------------------------- +# Choisir le provider : "sqlite" ou "postgresql" +DATABASE_PROVIDER=sqlite + +# SQLite (par défaut, aucun serveur à installer) +DATABASE_URL="file:./data/ipam.db" + +# PostgreSQL (décommenter et adapter) +# DATABASE_PROVIDER=postgresql +# DATABASE_URL="postgresql://ipam:ipam@localhost:5432/ipam?schema=public" + +# ---------------------------------------- +# Application +# ---------------------------------------- +PORT=3000 +APP_URL=http://localhost:3000 + +# ---------------------------------------- +# Découverte réseau +# ---------------------------------------- +# Plages CIDR scannées par défaut (séparées par des virgules) +DISCOVERY_DEFAULT_CIDRS=192.168.1.0/24 + +# Ports scannés par défaut (communs homelab) +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 + +# Timeout ping en ms +DISCOVERY_PING_TIMEOUT=1000 +# Timeout scan de port en ms +DISCOVERY_PORT_TIMEOUT=800 +# Concurrence des scans (nombre d'hôtes scannés en parallèle) +DISCOVERY_CONCURRENCY=32 + +# Activer/désactiver chaque scanner +DISCOVERY_ENABLE_PING=true +DISCOVERY_ENABLE_PORT_SCAN=true +DISCOVERY_ENABLE_ARP=true +DISCOVERY_ENABLE_MDNS=true + +# ---------------------------------------- +# Logs +# ---------------------------------------- +LOG_LEVEL=info diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..e8af991 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"], + "rules": { + "@typescript-eslint/no-unused-vars": [ + "warn", + { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } + ], + "@typescript-eslint/consistent-type-imports": "warn" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fc3b11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build +.next/ +out/ +dist/ +build/ + +# Next.js +next-env.d.ts + +# Env +.env +.env.local +.env.production +.env.development.local +.env.test.local + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Prisma +prisma/data/ +prisma/*.db +prisma/*.db-journal +data/ +*.db +*.db-journal + +# Docker +.docker/data/ + +# Tests / Coverage +coverage/ +.nyc_output/ + +# TypeScript +*.tsbuildinfo diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..fdad3f0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..68dfb4d --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,147 @@ +# Architecture + +Ce document décrit la structure du projet et les principes qui la gouvernent, +afin de garder l'application **évolutive** (facile à étendre, sans dette). + +## Principes + +1. **Modular monolith** — un seul projet Next.js, mais des modules métier isolés + sous `src/modules//`. Chaque module expose son `service` ; les routes + API et l'UI n'accèdent JAMAIS directement à Prisma pour un domaine. +2. **Validation à la frontière** — tout input externe (API, formulaire, scanner) + est validé via un schéma Zod avant d'atteindre le service. +3. **Registry pattern** pour les scanners — ajouter une capacité de découverte se + fait en implémentant une interface `Scanner` et en l'enregistrant dans le + registre (aucune autre partie du code n'est touchée). +4. **DB-agnostic** — Prisma lit `DATABASE_PROVIDER` et `DATABASE_URL` à l'exécution ; + SQLite pour commencer, PostgreSQL plus tard sans refactor. + +## Arborescence + +``` +IPAM/ +├── docker/ Dockerfile + docker-compose (profils sqlite/postgres) +├── prisma/ +│ ├── schema.prisma Modèle de données (Network, Host, Port, Application, Scan) +│ ├── migrations/ Migrations SQL (créées par `db:migrate`) +│ └── seed.ts Données d'exemple +├── public/ Assets statiques +├── scripts/ +│ └── run-discovery.ts CLI pour lancer une découverte hors UI +├── src/ +│ ├── app/ Next.js App Router +│ │ ├── (dashboard)/ Route group → layout UI commun (sidebar, header) +│ │ │ ├── hosts/ Pages de gestion des hôtes +│ │ │ ├── networks/ +│ │ │ ├── applications/ +│ │ │ ├── scans/ +│ │ │ └── settings/ +│ │ ├── api/ Route Handlers — FINS, délèguent aux services +│ │ │ ├── hosts/ +│ │ │ ├── networks/ +│ │ │ ├── applications/ +│ │ │ ├── ports/ +│ │ │ ├── scans/ +│ │ │ └── discovery/ +│ │ ├── layout.tsx +│ │ └── page.tsx +│ │ +│ ├── components/ Composants React +│ │ ├── ui/ Primitives (Button, Input, Dialog...) +│ │ ├── layout/ Sidebar, Header, Shell +│ │ ├── hosts/ networks/ ... Composants spécifiques par domaine +│ │ └── common/ Composants réutilisables (DataTable, Skeleton...) +│ │ +│ ├── modules/ ★ Cœur métier — UN DOSSIER PAR DOMAINE +│ │ ├── hosts/ +│ │ │ ├── hosts.schema.ts Validation Zod + types +│ │ │ ├── hosts.repository.ts Accès Prisma (seule frontière avec la DB) +│ │ │ ├── hosts.service.ts Logique métier +│ │ │ └── index.ts +│ │ ├── networks/ +│ │ ├── applications/ +│ │ ├── ports/ +│ │ ├── scans/ +│ │ └── discovery/ ★ Découverte réseau (pluggable) +│ │ ├── types.ts Interface `Scanner` + types résultats +│ │ ├── registry.ts Enregistrement des scanners +│ │ ├── discovery.service.ts Orchestrateur (persiste en DB) +│ │ ├── scanners/ +│ │ │ ├── ping.scanner.ts +│ │ │ ├── port.scanner.ts +│ │ │ ├── arp.scanner.ts +│ │ │ └── mdns.scanner.ts +│ │ └── utils/ +│ │ └── concurrency.ts Runner parallèle borné +│ │ +│ ├── lib/ Utilitaires transverses +│ │ ├── db/prisma.ts Client Prisma (singleton) +│ │ ├── api/response.ts Helpers NextResponse (ok/badRequest/...) +│ │ ├── utils/cn.ts Merge classes Tailwind +│ │ └── utils/logger.ts Pino logger configuré +│ │ +│ ├── config/env.ts Env vars typées via Zod (fail-fast) +│ ├── hooks/ Hooks React partagés (ex: useHosts) +│ ├── types/ Types globaux +│ └── styles/globals.css Tailwind + variables de thème +│ +├── tests/ Unitaires + intégration (vitest) +├── .env.example +├── next.config.mjs output: standalone + ext pkgs réseau +├── tsconfig.json Aliases @/modules, @/lib, @/components… +└── tailwind.config.ts +``` + +## Flux d'une requête + +``` +Client (RSC ou fetch) + → src/app/api//route.ts (validation + handleError) + → src/modules//service.ts (règles métier) + → src/modules//repository (Prisma) + → DB (SQLite | PostgreSQL) +``` + +## Ajouter un module métier (ex: VLAN) + +1. Créer `src/modules/vlans/` avec `vlans.schema.ts`, `vlans.service.ts`, `index.ts` +2. Ajouter le modèle Prisma dans `prisma/schema.prisma` → `npm run db:migrate` +3. Créer les routes `src/app/api/vlans/route.ts` (et `[id]/route.ts`) — copier le pattern des hôtes +4. Créer la page `src/app/(dashboard)/vlans/page.tsx` +5. (Optionnel) Ajouter des composants UI dans `src/components/vlans/` + +Aucun fichier existant ne doit être modifié en dehors de `schema.prisma`. + +## Ajouter un scanner de découverte (ex: SNMP) + +1. Créer `src/modules/discovery/scanners/snmp.scanner.ts` exportant une constante + conforme à l'interface `Scanner` (`kind`, `label`, `enabled`, `run`) +2. L'importer dans `src/modules/discovery/registry.ts` et l'ajouter au + `scannersRegistry` avec sa clé `ScannerKind` (mettre à jour le type union dans + `types.ts`) +3. Exposer la clé dans `bodySchema` de `src/app/api/discovery/route.ts` + +Il sera automatiquement disponible dans `runSingle()` et, si intégré à +`runFull()`, dans le scan complet. + +## Choix techniques clefs + +- **Prisma plutôt que Drizzle** : meilleures migrations pour un homelab, Studio + inclus. Drizzle serait envisageable plus tard si on a besoin de SQL brut. +- **App Router (pas Pages Router)** : server components, streaming, server + actions disponibles quand on voudra simplifier des formulaires. +- **Pas de framework "d'auth" pour l'instant** : ajouter NextAuth/Auth.js quand + on exposera l'IPAM au-delà du LAN. Un emplacement naturel : `src/modules/auth/`. +- **Pas de Redis/queue** : tous les scans tournent dans le process. À prévoir + (BullMQ + Redis) uniquement si on planifie des scans récurrents lourds. + +## Futures fonctionnalités envisagées + +- Scans planifiés (cron-like, via BullMQ ou `node-cron`) +- Notifications (nouvel hôte, port qui change d'état) +- Export CSV / JSON des inventaires +- Synchronisation avec un reverse-proxy (Traefik, Nginx Proxy Manager) pour + auto-remplir les applications +- API d'OUI lookup pour déduire le constructeur depuis la MAC +- Authentification multi-utilisateur + RBAC +- Topologie graphique (D3 ou React Flow) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6e470a --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# IPAM Homelab + +Application web **TypeScript / Next.js 15** pour gérer l'inventaire IP d'un homelab : hôtes, ports, applications, avec **découverte réseau automatique** (ping sweep, scan de ports, ARP, mDNS). + +## Stack + +- **Next.js 15** (App Router) + **React 18** + **TypeScript** +- **Prisma** ORM avec support **SQLite _ou_ PostgreSQL** (via variable d'env) +- **TailwindCSS** + design system inspiré shadcn/ui +- **Zod** pour la validation +- **Pino** pour les logs +- **Docker / Docker Compose** pour le déploiement + +## Démarrage rapide + +```bash +# 1. Installer les dépendances +npm install + +# 2. Copier la configuration d'exemple +cp .env.example .env + +# 3. Générer le client Prisma et créer la DB +npm run db:generate +npm run db:migrate + +# 4. (Optionnel) Charger des données d'exemple +npm run db:seed + +# 5. Lancer en développement +npm run dev +``` + +Accès : http://localhost:3000 + +## Passer de SQLite à PostgreSQL + +Dans `.env` : + +```env +DATABASE_PROVIDER=postgresql +DATABASE_URL="postgresql://ipam:ipam@localhost:5432/ipam?schema=public" +``` + +Puis : + +```bash +npm run db:generate +npm run db:migrate +``` + +## Déploiement Docker + +```bash +# Avec SQLite (simple, fichier local persisté dans un volume) +docker compose --profile sqlite -f docker/docker-compose.yml up -d + +# Avec PostgreSQL +docker compose --profile postgres -f docker/docker-compose.yml up -d +``` + +> Le conteneur utilise `network_mode: host` pour permettre les scans ARP / mDNS / +> ping sur le LAN. À désactiver si non nécessaire. + +## Découverte réseau + +| Scanner | Description | Requiert | +| ------- | ---------------------------------------------------- | ---------------------------------- | +| `ping` | Ping sweep ICMP sur un CIDR | `cap_net_raw` ou bin `ping` | +| `port` | Scan TCP connect sur une IP | — | +| `arp` | Lit la table ARP locale (`ip neigh` / `arp -a`) | Accès au LAN (host network) | +| `mdns` | Écoute Bonjour / DNS-SD | Multicast sur le LAN | + +Lancer un scan complet via CLI : + +```bash +npm run discovery:run -- --kind full --cidr 192.168.1.0/24 +``` + +Via l'API : + +```bash +curl -X POST http://localhost:3000/api/discovery \ + -H 'Content-Type: application/json' \ + -d '{"kind":"full","cidr":"192.168.1.0/24"}' +``` + +## Architecture + +Voir [`ARCHITECTURE.md`](./ARCHITECTURE.md) pour le détail des couches et la +procédure d'ajout d'un nouveau module métier ou d'un nouveau scanner. diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..a709ce2 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.6 +# ---------- Stage 1 : deps ---------- +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +# ---------- Stage 2 : build ---------- +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npx prisma generate +RUN npm run build + +# ---------- Stage 3 : runner ---------- +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 + +# Outils système utiles à la découverte réseau +# - 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 +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +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/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 + +USER nextjs + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..d1d8b08 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,61 @@ +# --------------------------------------------------------------- +# IPAM — Docker Compose +# --------------------------------------------------------------- +# Deux profils : +# - `sqlite` → ipam seul, base dans un volume +# - `postgres` → ipam + postgres +# +# Usage : +# docker compose --profile sqlite up -d +# docker compose --profile postgres up -d +# --------------------------------------------------------------- + +services: + ipam: + build: + context: .. + dockerfile: docker/Dockerfile + container_name: ipam + 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 + environment: + NODE_ENV: production + PORT: 3000 + env_file: + - ../.env + volumes: + - ipam-data:/app/data + # Capabilities requises pour le ping ICMP raw + cap_add: + - NET_RAW + - NET_ADMIN + profiles: [sqlite, postgres] + depends_on: + postgres: + condition: service_healthy + required: false + + postgres: + image: postgres:16-alpine + container_name: ipam-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ipam + POSTGRES_PASSWORD: ipam + POSTGRES_DB: ipam + volumes: + - ipam-postgres:/var/lib/postgresql/data + ports: + - '5432:5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ipam'] + interval: 5s + timeout: 3s + retries: 10 + profiles: [postgres] + +volumes: + ipam-data: + ipam-postgres: diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..b810137 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,28 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + output: 'standalone', // Optimisé pour Docker + experimental: { + // Permet l'utilisation de paquets natifs côté serveur (ping, bonjour, etc.) + serverComponentsExternalPackages: [ + 'ping', + 'bonjour-service', + '@prisma/client', + ], + }, + // Empêche le bundling des modules réseau côté client + webpack: (config, { isServer }) => { + if (!isServer) { + config.resolve.fallback = { + ...config.resolve.fallback, + net: false, + dns: false, + fs: false, + child_process: false, + }; + } + return config; + }, +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..7609b24 --- /dev/null +++ b/package.json @@ -0,0 +1,74 @@ +{ + "name": "ipam-homelab", + "version": "0.1.0", + "private": true, + "description": "IPAM (IP Address Management) pour homelab — inventaire, ports, applications et découverte réseau", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md}\"", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:deploy": "prisma migrate deploy", + "db:studio": "prisma studio", + "db:seed": "tsx prisma/seed.ts", + "db:reset": "prisma migrate reset", + "docker:up": "docker compose -f docker/docker-compose.yml up -d", + "docker:down": "docker compose -f docker/docker-compose.yml down", + "docker:logs": "docker compose -f docker/docker-compose.yml logs -f", + "discovery:run": "tsx scripts/run-discovery.ts" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.0", + "@prisma/client": "^5.22.0", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.2", + "@tanstack/react-query": "^5.59.0", + "bonjour-service": "^1.2.1", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "ip-address": "^9.0.5", + "ip-regex": "^5.0.0", + "lucide-react": "^0.454.0", + "netmask": "^2.0.2", + "next": "^15.0.0", + "ping": "^0.4.4", + "pino": "^9.5.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.53.0", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/netmask": "^2.0.5", + "@types/node": "^22.7.0", + "@types/ping": "^0.4.4", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.20", + "eslint": "^9.12.0", + "eslint-config-next": "^15.0.0", + "postcss": "^8.4.47", + "prettier": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.8", + "prisma": "^5.22.0", + "tailwindcss": "^3.4.13", + "tsx": "^4.19.1", + "typescript": "^5.6.3", + "vitest": "^2.1.2" + }, + "engines": { + "node": ">=20" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..f323da1 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,213 @@ +// IPAM — Schéma Prisma +// --------------------------------------------------------------------- +// Le `provider` est injecté via la variable d'environnement DATABASE_PROVIDER +// (SQLite par défaut, PostgreSQL en option). Les deux sont pris en charge. +// Lors d'un changement de provider, régénérer le client : `pnpm db:generate` +// et lancer une nouvelle migration : `pnpm db:migrate`. +// --------------------------------------------------------------------- + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = env("DATABASE_PROVIDER") + url = env("DATABASE_URL") +} + +// ===================================================================== +// Réseaux (subnets / VLAN) +// ===================================================================== +model Network { + id String @id @default(cuid()) + name String + cidr String @unique // ex: "192.168.1.0/24" + description String? + vlanId Int? + gateway String? + dnsServers String? // CSV — compat SQLite (pas de array natif) + color String? // Couleur d'affichage UI + + hosts Host[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([cidr]) +} + +// ===================================================================== +// Hôtes (une IP = un hôte) +// ===================================================================== +model Host { + id String @id @default(cuid()) + ipAddress String @unique // IPv4/IPv6 + hostname String? + macAddress String? + description String? + vendor String? // Issue du MAC (OUI) ou d'une fingerprint + osGuess String? // OS détecté / renseigné + + status HostStatus @default(UNKNOWN) + + // Origine de la fiche : saisie manuelle ou découverte + source HostSource @default(MANUAL) + + // Relations + networkId String? + network Network? @relation(fields: [networkId], references: [id], onDelete: SetNull) + + ports Port[] + applications HostApplication[] + scanResults ScanResult[] + + lastSeenAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([ipAddress]) + @@index([networkId]) + @@index([status]) +} + +enum HostStatus { + UP + DOWN + UNKNOWN +} + +enum HostSource { + MANUAL + DISCOVERED + IMPORTED +} + +// ===================================================================== +// Ports ouverts sur un hôte +// ===================================================================== +model Port { + id String @id @default(cuid()) + number Int + protocol Protocol @default(TCP) + serviceName String? // ex: "http", "ssh", renseigné ou deviné + banner String? // Banner applicatif collecté + state PortState @default(OPEN) + + hostId String + host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + + // Lien éventuel vers une application métier déclarée + applicationId String? + application Application? @relation(fields: [applicationId], references: [id], onDelete: SetNull) + + lastCheckedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([hostId, number, protocol]) + @@index([hostId]) + @@index([number]) +} + +enum Protocol { + TCP + UDP +} + +enum PortState { + OPEN + CLOSED + FILTERED + UNKNOWN +} + +// ===================================================================== +// Applications (Jellyfin, Home Assistant, Nextcloud, etc.) +// ===================================================================== +model Application { + id String @id @default(cuid()) + name String @unique + description String? + category String? // ex: "media", "monitoring", "storage" + icon String? // URL ou clé d'icône + url String? // URL principale d'accès + + hosts HostApplication[] + ports Port[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Table de jointure Hôte <-> Application (N:N) +model HostApplication { + hostId String + applicationId String + + host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade) + + notes String? + + createdAt DateTime @default(now()) + + @@id([hostId, applicationId]) +} + +// ===================================================================== +// Découverte réseau — historique des scans +// ===================================================================== +model Scan { + id String @id @default(cuid()) + type ScanType + status ScanStatus @default(PENDING) + target String // CIDR, IP, ou hôte + params String? // JSON (Zod validé côté app) + startedAt DateTime? + endedAt DateTime? + + hostsFound Int @default(0) + portsFound Int @default(0) + errorMessage String? + + results ScanResult[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) + @@index([type]) +} + +enum ScanType { + PING + PORT + ARP + MDNS + FULL +} + +enum ScanStatus { + PENDING + RUNNING + COMPLETED + FAILED + CANCELLED +} + +// Résultat individuel d'un scan (rattaché à un hôte quand possible) +model ScanResult { + id String @id @default(cuid()) + scanId String + scan Scan @relation(fields: [scanId], references: [id], onDelete: Cascade) + + ipAddress String + hostId String? + host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull) + + data String // JSON (ports trouvés, services mDNS, MAC, etc.) + createdAt DateTime @default(now()) + + @@index([scanId]) + @@index([ipAddress]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..14c321f --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,62 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + // Réseau d'exemple + const network = await prisma.network.upsert({ + where: { cidr: '192.168.1.0/24' }, + update: {}, + create: { + name: 'Homelab LAN', + cidr: '192.168.1.0/24', + description: 'Réseau principal du homelab', + gateway: '192.168.1.1', + dnsServers: '1.1.1.1,9.9.9.9', + color: '#3b82f6', + }, + }); + + // Applications usuelles en homelab + const apps = [ + { name: 'Jellyfin', category: 'media', description: 'Serveur multimédia' }, + { name: 'Home Assistant', category: 'automation', description: 'Domotique' }, + { name: 'Nextcloud', category: 'storage', description: 'Cloud personnel' }, + { name: 'Pi-hole', category: 'network', description: 'Bloqueur DNS' }, + { name: 'Portainer', category: 'management', description: 'Gestion Docker' }, + { name: 'Grafana', category: 'monitoring', description: 'Dashboards' }, + ]; + + for (const app of apps) { + await prisma.application.upsert({ + where: { name: app.name }, + update: {}, + create: app, + }); + } + + // Hôte d'exemple + await prisma.host.upsert({ + where: { ipAddress: '192.168.1.1' }, + update: {}, + create: { + ipAddress: '192.168.1.1', + hostname: 'router', + description: 'Passerelle / routeur du homelab', + status: 'UP', + source: 'MANUAL', + networkId: network.id, + }, + }); + + console.log('✅ Seed terminé'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/run-discovery.ts b/scripts/run-discovery.ts new file mode 100644 index 0000000..4af5d91 --- /dev/null +++ b/scripts/run-discovery.ts @@ -0,0 +1,37 @@ +/** + * CLI pour lancer une découverte sans passer par l'UI. + * Utilisation : `pnpm discovery:run -- --cidr 192.168.1.0/24` + */ +import { discoveryService } from '../src/modules/discovery'; + +function arg(name: string, fallback?: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + return idx > -1 ? process.argv[idx + 1] : fallback; +} + +async function main() { + const kind = (arg('kind', 'full') as 'full' | 'ping' | 'port' | 'arp' | 'mdns'); + const cidr = arg('cidr', '192.168.1.0/24'); + const ip = arg('ip'); + const portsRaw = arg('ports'); + const ports = portsRaw ? portsRaw.split(',').map(Number) : undefined; + + console.log(`▶ Discovery ${kind} sur ${cidr ?? ip}`); + + const scan = + kind === 'full' + ? await discoveryService.runFull(cidr!, ports) + : await discoveryService.runSingle(kind, { cidr, ip, ports }); + + console.log('✅ Scan terminé', { + id: scan.id, + status: scan.status, + hostsFound: scan.hostsFound, + portsFound: scan.portsFound, + }); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/app/(dashboard)/applications/page.tsx b/src/app/(dashboard)/applications/page.tsx new file mode 100644 index 0000000..e1df759 --- /dev/null +++ b/src/app/(dashboard)/applications/page.tsx @@ -0,0 +1,91 @@ +import { AppWindow, ExternalLink, Plus } from 'lucide-react'; +import { Header } from '@/components/layout/header'; +import { PageContainer } from '@/components/layout/page-container'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { EmptyState } from '@/components/ui/empty-state'; +import { prisma } from '@/lib/db/prisma'; + +async function getApplications() { + try { + return await prisma.application.findMany({ + include: { _count: { select: { hosts: true, ports: true } } }, + orderBy: { name: 'asc' }, + }); + } catch { + return []; + } +} + +export default async function ApplicationsPage() { + const apps = await getApplications(); + + return ( + <> +
+ + Nouvelle application + + } + /> + + {apps.length === 0 ? ( + Ajouter une application} + /> + ) : ( +
+ {apps.map((a) => ( + +
+
+ {a.icon ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+ {a.category && {a.category}} +
+
+

{a.name}

+ {a.description && ( +

+ {a.description} +

+ )} +
+
+ + {a._count.hosts} hôte{a._count.hosts > 1 ? 's' : ''} ·{' '} + {a._count.ports} port{a._count.ports > 1 ? 's' : ''} + + {a.url && ( + + Ouvrir + + + )} +
+
+ ))} +
+ )} +
+ + ); +} diff --git a/src/app/(dashboard)/hosts/page.tsx b/src/app/(dashboard)/hosts/page.tsx new file mode 100644 index 0000000..65449b0 --- /dev/null +++ b/src/app/(dashboard)/hosts/page.tsx @@ -0,0 +1,123 @@ +import { Plus, Server } from 'lucide-react'; +import { Header } from '@/components/layout/header'; +import { PageContainer } from '@/components/layout/page-container'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { EmptyState } from '@/components/ui/empty-state'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { prisma } from '@/lib/db/prisma'; + +async function getHosts() { + try { + return await prisma.host.findMany({ + include: { + network: true, + ports: { where: { state: 'OPEN' } }, + applications: { include: { application: true } }, + }, + orderBy: { ipAddress: 'asc' }, + }); + } catch { + return []; + } +} + +const statusVariant = { + UP: 'success', + DOWN: 'destructive', + UNKNOWN: 'default', +} as const; + +export default async function HostsPage() { + const hosts = await getHosts(); + + return ( + <> +
+ + Ajouter un hôte + + } + /> + + {hosts.length === 0 ? ( + Lancer une découverte} + /> + ) : ( + + + + + Adresse IP + Nom + Réseau + Statut + Ports + Applications + Dernière vue + + + + {hosts.map((h) => ( + + {h.ipAddress} + {h.hostname ?? '—'} + + {h.network?.name ?? '—'} + + + {h.status} + + + {h.ports.length > 0 ? ( + {h.ports.length} + ) : ( + '—' + )} + + +
+ {h.applications.slice(0, 3).map((ha) => ( + + {ha.application.name} + + ))} + {h.applications.length > 3 && ( + +{h.applications.length - 3} + )} + {h.applications.length === 0 && ( + + )} +
+
+ + {h.lastSeenAt + ? new Date(h.lastSeenAt).toLocaleString('fr-FR') + : '—'} + +
+ ))} +
+
+
+ )} +
+ + ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..908038c --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -0,0 +1,5 @@ +// Route group conservé pour organiser les pages du dashboard ; +// le sidebar est monté dans le layout racine pour éviter les conflits de routing. +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/src/app/(dashboard)/networks/page.tsx b/src/app/(dashboard)/networks/page.tsx new file mode 100644 index 0000000..9a126cc --- /dev/null +++ b/src/app/(dashboard)/networks/page.tsx @@ -0,0 +1,81 @@ +import { Network as NetworkIcon, Plus } from 'lucide-react'; +import { Header } from '@/components/layout/header'; +import { PageContainer } from '@/components/layout/page-container'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { EmptyState } from '@/components/ui/empty-state'; +import { prisma } from '@/lib/db/prisma'; + +async function getNetworks() { + try { + return await prisma.network.findMany({ + include: { _count: { select: { hosts: true } } }, + orderBy: { name: 'asc' }, + }); + } catch { + return []; + } +} + +export default async function NetworksPage() { + const networks = await getNetworks(); + + return ( + <> +
+ + Nouveau réseau + + } + /> + + {networks.length === 0 ? ( + Créer un réseau} + /> + ) : ( +
+ {networks.map((n) => ( + +
+
+ +
+ {n.vlanId !== null && n.vlanId !== undefined && ( + VLAN {n.vlanId} + )} +
+
+

{n.name}

+

{n.cidr}

+ {n.description && ( +

{n.description}

+ )} +
+
+ Hôtes + {n._count.hosts} +
+
+ ))} +
+ )} +
+ + ); +} diff --git a/src/app/(dashboard)/scans/page.tsx b/src/app/(dashboard)/scans/page.tsx new file mode 100644 index 0000000..e19f271 --- /dev/null +++ b/src/app/(dashboard)/scans/page.tsx @@ -0,0 +1,106 @@ +import { Radar } from 'lucide-react'; +import { Header } from '@/components/layout/header'; +import { PageContainer } from '@/components/layout/page-container'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { EmptyState } from '@/components/ui/empty-state'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { DiscoveryLauncher } from '@/components/scans/discovery-launcher'; +import { prisma } from '@/lib/db/prisma'; + +async function getScans() { + try { + return await prisma.scan.findMany({ + orderBy: { createdAt: 'desc' }, + take: 50, + }); + } catch { + return []; + } +} + +const statusVariant = { + COMPLETED: 'success', + RUNNING: 'info', + PENDING: 'default', + FAILED: 'destructive', + CANCELLED: 'warning', +} as const; + +function formatDuration(start: Date | null, end: Date | null) { + if (!start || !end) return '—'; + const diff = Math.max(0, end.getTime() - start.getTime()); + if (diff < 1000) return `${diff} ms`; + if (diff < 60_000) return `${(diff / 1000).toFixed(1)} s`; + return `${Math.floor(diff / 60_000)} min ${Math.floor((diff % 60_000) / 1000)} s`; +} + +export default async function ScansPage() { + const scans = await getScans(); + + return ( + <> +
+ + + +
+

+ Historique +

+ {scans.length === 0 ? ( + + ) : ( + + + + + Type + Cible + Statut + Hôtes + Ports + Durée + Date + + + + {scans.map((s) => ( + + + {s.type} + + {s.target} + + {s.status} + + {s.hostsFound} + {s.portsFound} + + {formatDuration(s.startedAt, s.endedAt)} + + + {new Date(s.createdAt).toLocaleString('fr-FR')} + + + ))} + +
+
+ )} +
+
+ + ); +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..40bfa5e --- /dev/null +++ b/src/app/(dashboard)/settings/page.tsx @@ -0,0 +1,70 @@ +import { Header } from '@/components/layout/header'; +import { PageContainer } from '@/components/layout/page-container'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { ThemeToggle } from '@/components/theme/theme-toggle'; + +export default function SettingsPage() { + return ( + <> +
+ + + + Apparence + Choisissez le thème de l’interface + + +
+
+

Thème

+

+ Automatique (système), clair ou sombre. +

+
+ +
+
+
+ + + + Découverte réseau + + Les valeurs par défaut sont lues depuis le fichier .env + (variables DISCOVERY_*). + + + + + + + + + + + + + + À propos + IPAM Homelab · version 0.1.0 + + + Projet open-source pour gérer l’inventaire IP de votre homelab. + + +
+ + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/src/app/api/discovery/route.ts b/src/app/api/discovery/route.ts new file mode 100644 index 0000000..20bb851 --- /dev/null +++ b/src/app/api/discovery/route.ts @@ -0,0 +1,32 @@ +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { discoveryService } from '@/modules/discovery'; +import { handleError, ok } from '@/lib/api/response'; + +const bodySchema = z.object({ + kind: z.enum(['ping', 'port', 'arp', 'mdns', 'full']), + cidr: z.string().optional(), + ip: z.string().optional(), + ports: z.array(z.number().int().min(1).max(65535)).optional(), +}); + +/** POST /api/discovery — lance un scan */ +export async function POST(req: NextRequest) { + try { + const { kind, cidr, ip, ports } = bodySchema.parse(await req.json()); + + const scan = + kind === 'full' + ? await discoveryService.runFull(cidr!, ports) + : await discoveryService.runSingle(kind, { cidr, ip, ports }); + + return ok(scan); + } catch (err) { + return handleError(err); + } +} + +/** GET /api/discovery — liste les scanners disponibles */ +export async function GET() { + return ok(discoveryService.listScanners()); +} diff --git a/src/app/api/hosts/[id]/route.ts b/src/app/api/hosts/[id]/route.ts new file mode 100644 index 0000000..6f3a50a --- /dev/null +++ b/src/app/api/hosts/[id]/route.ts @@ -0,0 +1,32 @@ +import type { NextRequest } from 'next/server'; +import { hostsService } from '@/modules/hosts'; +import { handleError, noContent, notFound, ok } from '@/lib/api/response'; + +export async function GET(_req: NextRequest, { params }: { params: { id: string } }) { + try { + const host = await hostsService.get(params.id); + if (!host) return notFound('Host not found'); + return ok(host); + } catch (err) { + return handleError(err); + } +} + +export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { + try { + const body = await req.json(); + const host = await hostsService.update(params.id, body); + return ok(host); + } catch (err) { + return handleError(err); + } +} + +export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) { + try { + await hostsService.delete(params.id); + return noContent(); + } catch (err) { + return handleError(err); + } +} diff --git a/src/app/api/hosts/route.ts b/src/app/api/hosts/route.ts new file mode 100644 index 0000000..baa900a --- /dev/null +++ b/src/app/api/hosts/route.ts @@ -0,0 +1,26 @@ +import type { NextRequest } from 'next/server'; +import { hostsService } from '@/modules/hosts'; +import { handleError, ok, created } from '@/lib/api/response'; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const hosts = await hostsService.list({ + networkId: searchParams.get('networkId') ?? undefined, + status: searchParams.get('status') ?? undefined, + }); + return ok(hosts); + } catch (err) { + return handleError(err); + } +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const host = await hostsService.create(body); + return created(host); + } catch (err) { + return handleError(err); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..2581fb4 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from 'next'; +import { ThemeProvider } from '@/components/theme/theme-provider'; +import { Sidebar } from '@/components/layout/sidebar'; +import '@/styles/globals.css'; + +export const metadata: Metadata = { + title: 'IPAM Homelab', + description: 'Gestion des IP, ports et applications de votre homelab', +}; + +/** + * Script inline exécuté AVANT l'hydratation React pour appliquer le bon thème + * et éviter le flash (FOUC) côté client. + */ +const themeInitScript = ` +(function() { + try { + var stored = localStorage.getItem('ipam-theme'); + var theme = stored || 'system'; + var resolved = theme === 'system' + ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : theme; + var root = document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(resolved); + root.style.colorScheme = resolved; + } catch (_) {} +})(); +`; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +