diff --git a/docs/superpowers/specs/2026-09-08-unifi-os-integration-design.md b/docs/superpowers/specs/2026-09-08-unifi-os-integration-design.md new file mode 100644 index 000000000..b6993f364 --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-unifi-os-integration-design.md @@ -0,0 +1,459 @@ +# Intégration UniFi OS pour Homarr — conception + +**Date :** 2026-09-08 +**Base :** homarr-labs/homarr `2b1a03849` (v1.77.0) +**Branche :** `feat/unifi-os-integration` +**Statut :** conception validée, prête pour le plan d'implémentation + +> ⚠️ Aucune clé d'API ne figure dans ce document. Les clés utilisées pendant la +> conception ont été fournies hors dépôt et doivent être révoquées en fin de projet. + +--- + +## 1. Objectif + +Ajouter à Homarr une intégration **UniFi OS**, en complément — et non en +remplacement — de l'intégration `unifiController` existante. + +L'intégration existante s'authentifie par identifiant/mot de passe et lit +`/api/s/{site}/stat/sites`, l'API interne de l'ancien Network Controller. +UniFi OS expose depuis la version 9 une **API d'intégration officielle**, +authentifiée par clé d'API, stable et documentée. C'est celle que cible ce +projet. + +L'intégration existante n'est pas touchée. Les deux coexistent. + +## 2. Décisions cadrées + +| # | Décision | Justification | +|---|---|---| +| D1 | **Fork personnel**, pas de PR upstream | Choix de l'auteur. L'issue upstream [#4703](https://github.com/homarr-labs/homarr/issues/4703) demandait cette fonctionnalité et a été fermée par triage automatique, pas par refus technique : la porte reste ouverte pour plus tard. | +| D2 | **Hybride local + cloud optionnel** | L'API locale ne fournit ni latence ni perte de paquets. L'API cloud Site Manager les fournit. Le cloud reste facultatif pour que l'intégration fonctionne sans compte UI ni accès Internet. | +| D3 | **Widget dédié**, pas de réutilisation des widgets `networkController*` | Le contrat `NetworkControllerSummary` est taillé pour l'ancienne API. S'y conformer obligerait à inventer des valeurs. Un contrat propre évite ça. | +| D4 | **Déploiement par rsync du build standalone** | Le LXC exécute déjà un build standalone piloté par systemd. C'est le changement le plus petit possible, donc le plus facile à annuler. | +| D5 | **Structure client / mapping / orchestration séparés** | Modèle TrueNAS et Synology déjà présents dans le dépôt. Isole le cloud, optionnel et lent, de la partie locale, obligatoire et rapide. | + +**Conséquence de D3 :** on ne modifie pas `NetworkControllerSummary`. Les champs +`latency` / `ping` restent `number` obligatoires pour `unifiController` et +`opnsense`. **Aucune régression possible sur les intégrations existantes.** + +## 3. Constatations vérifiées sur l'API + +Toutes les valeurs ci-dessous ont été relevées sur une console réelle +(UniFi Network **10.6.102**) le 2026-09-08, et non déduites de la documentation. + +### 3.1 API locale — `{url}/proxy/network/integration/v1` + +Authentification : en-tête `X-API-KEY`, clé générée **dans l'application Network** +(Paramètres → Control Plane → Integrations). Une clé du Site Manager cloud est +refusée avec un `401` sur cette API — les deux systèmes de clés sont distincts. + +| Endpoint | Renvoie | Relevé | +|---|---|---| +| `GET /info` | `applicationVersion` | `10.6.102` | +| `GET /sites` | `id`, `internalReference`, `name` — **aucune statistique** | 1 site | +| `GET /sites/{id}/clients` | `type`, `access.type`, `name`, `ipAddress`, `macAddress`, `uplinkDeviceId`, `connectedAt` | 51 clients | +| — | **`limit` est plafonné à 200.** Une demande à 1000 renvoie `limit: 200`. | mesuré | +| `GET /sites/{id}/devices` | `state`, `model`, `macAddress`, `firmwareVersion`, `firmwareUpdatable`, `features` | 6 équipements | +| `GET /sites/{id}/devices/{id}/statistics/latest` | `uptimeSec`, `cpuUtilizationPct`, `memoryUtilizationPct`, `uplink.{tx,rx}RateBps` | — | +| `GET /sites/{id}/vpn/servers` | `type`, `enabled` | 1 serveur WireGuard actif | +| `GET /sites/{id}/wans` | `id`, `name` **uniquement** — inexploitable pour l'état | 2 WAN | + +**Types de clients** (discriminant `type`) : `WIRED`, `WIRELESS`, `VPN`, +`TELEPORT`. Sous-objet `access.type` : `DEFAULT`, `GUEST`. + +**Filtrage.** Supporté, avec une syntaxe **préfixe** : + +``` +✅ and(type.eq('WIRED'),access.type.eq('DEFAULT')) +❌ type.eq('WIRED') and access.type.eq('DEFAULT') → 400 invalid-filter +❌ type.eq('WIRED') & access.type.eq('DEFAULT') → 400 invalid-filter +❌ type.eq('WIRED'),access.type.eq('DEFAULT') → 400 invalid-filter +``` + +Combiné à `limit=1`, il permet d'obtenir un `totalCount` sans rapatrier les +enregistrements. Utilisé uniquement au-delà de 200 clients (voir §7). + +**Écart avec la documentation.** Ubiquiti ne publie l'OpenAPI que pour +`v10.4.57`. La console en `10.6.102` renvoie des champs absents de ce spec +(`macAddress`, `uplinkDeviceId` sur les clients). **Corollaire : on code contre +les réponses réelles, validées par des schémas zod tolérants (`passthrough`), +et on ne génère pas de client depuis le spec publié.** + +### 3.2 API cloud Site Manager — `https://api.ui.com` + +Authentification : en-tête `X-API-KEY`, clé générée sur `unifi.ui.com`. + +| Endpoint | Utilité | +|---|---| +| `GET /v1/sites` | Corrélation (`meta.gatewayMac`), `ispInfo`, IP publique par WAN, `percentages.wanUptime` | +| `POST /v1/isp-metrics/5m/query` | `avgLatency`, `maxLatency`, `packetLoss`, `uptime`, débits | + +**Deux contraintes mesurées :** + +1. `GET /v1/isp-metrics/5m` sans filtre renvoie **133 Ko** (288 points sur 24 h). + Il faut passer par `POST .../query` borné dans le temps. +2. Une fenêtre de 15 min renvoie un tableau **vide**. Une fenêtre de **3 h** + fonctionne. Le point le plus récent accuse un retard mesuré de **~27 minutes**. + +Ces deux faits pilotent le design du cache (§7) et l'affichage de la fraîcheur (§8). + +### 3.3 Corrélation locale ↔ cloud + +Une clé Site Manager donne accès à **tous** les sites du compte UI, y compris des +consoles dont l'utilisateur n'est pas propriétaire. Le compte de référence en +exposait deux. Prendre les métriques cloud sans discriminer afficherait la +latence d'un autre réseau. + +**Clé de jointure retenue : le MAC du gateway.** + +``` +LOCAL devices[] → gateway.macAddress aa:bb:cc:11:22:33 +CLOUD sites[] → meta.gatewayMac aa:bb:cc:11:22:33 ✅ correspondance +CLOUD sites[] → meta.gatewayMac dd:ee:ff:44:55:66 ✗ autre console du compte +``` + +*(adresses anonymisées ; la correspondance a été vérifiée sur la console réelle)* + +Retenu plutôt que le nom du site : un nom se renomme, un MAC non. **En l'absence +de correspondance, `unifi-sm-api.ts` renvoie `null` et n'essaie aucune +approximation.** Un manque visible vaut mieux qu'une donnée silencieusement fausse. + +## 4. Contrat de données + +```ts +export interface UnifiOsSummary { + site: { + id: string; + name: string; + applicationVersion: string; + }; + + wan: { + /** Dérivé de l'état du gateway côté local. */ + state: "online" | "offline" | "unknown"; + /** Débit temps réel de l'uplink du gateway, en bits/s. Local. */ + throughput: { txBps: number; rxBps: number } | null; + /** Bloc cloud. `null` si la clé Site Manager n'est pas configurée + * ou si aucun site cloud ne correspond au MAC du gateway. */ + isp: { + name: string; + externalIp: string | null; + latencyMs: number; + maxLatencyMs: number; + packetLossPct: number; + uptimePct: number; + /** Horodatage de la mesure. Rendu obligatoire : toute donnée cloud + * voyage avec sa fraîcheur. */ + measuredAt: Date; + } | null; + }; + + clients: { + total: number; + wired: number; + wireless: number; + vpn: number; // VPN + TELEPORT + guests: number; // access.type === "GUEST", tous types confondus + }; + + devices: { + total: number; + online: number; + offline: number; + firmwareUpdatable: number; + }; + + gateway: { + name: string; + model: string; + macAddress: string; + uptimeSec: number | null; + cpuPct: number | null; + memoryPct: number | null; + } | null; +} +``` + +**`measuredAt` est obligatoire dans le bloc `isp`.** Le type impose que la +fraîcheur accompagne la donnée cloud : il devient impossible d'afficher une +latence sans savoir de quand elle date. + +## 5. Architecture + +### 5.1 Nouveaux fichiers + +``` +packages/integrations/src/unifi-os/ +├── unifi-os-integration.ts # extends Integration — orchestre, mappe, dégrade +├── unifi-os-api.ts # client HTTP local +├── unifi-sm-api.ts # client HTTP cloud, optionnel, cache interne +├── unifi-os-types.ts # schémas zod + mappers purs +└── test/ + ├── unifi-os-types.spec.ts + └── unifi-os-integration.spec.ts +``` + +Chaque unité répond à une question unique : + +- `unifi-os-api.ts` — *comment parler à la console locale ?* Endpoints, + pagination, résolution du `siteId`. Ne connaît pas Homarr. +- `unifi-sm-api.ts` — *comment parler au cloud ?* Fenêtre glissante de 3 h, + corrélation par MAC, cache interne. **Instancié uniquement si la clé cloud + est présente.** +- `unifi-os-types.ts` — *comment valider et transformer ?* Schémas zod et + fonctions pures. Aucun accès réseau, donc testable sans mock. +- `unifi-os-integration.ts` — *comment Homarr consomme tout ça ?* La seule + unité qui connaît la classe `Integration`. + +### 5.2 Points d'attache dans le monorepo + +| Fichier | Modification | +|---|---| +| `packages/definitions/src/integration.ts` | Ajouter le secret `siteManagerApiKey` à `integrationSecretKindObject` ; ajouter `"unifiOs"` à `integrationCategories` ; ajouter le kind `unifiOs` avec `secretKinds: [["apiKey"], ["apiKey", "siteManagerApiKey"]]`, `category: ["unifiOs"]`, `defaultPort: 443` | +| `packages/definitions/src/widget.ts` | Ajouter le `WidgetKind` `unifiOs` | +| `packages/integrations/src/base/creator.ts` | Enregistrer `unifiOs: UnifiOsIntegration` | +| `packages/integrations/src/types.ts` | Exporter `./unifi-os/unifi-os-types` | +| `packages/integrations/src/interfaces/unifi-os-summary/` | Interface `UnifiOsSummaryIntegration` | +| `packages/request-handler/src/unifi-os.ts` | `createIntegrationRequestHandler` | +| `packages/api/src/router/widgets/unifi-os.ts` + `index.ts` | Routeur tRPC | +| `packages/widgets/src/unifi-os/{index.ts,component.tsx}` | Définition + composant | +| `packages/widgets/src/{manifest.ts,index.tsx,refetch-intervals.ts}` | Enregistrement | +| `packages/translation/src/lang/en.json` | Libellés du widget + clé `integration.category.unifiOs` | + +### 5.3 Pourquoi une catégorie dédiée, et pas `networkController` + +`integrationCategories` n'est pas une étiquette de présentation, c'est un +mécanisme de câblage. `getIntegrationKindsByCategory("networkController")` +alimente trois endroits : + +``` +widget-integration-map.ts → intégrations proposées par les widgets networkController* +router/widgets/network-controller.ts → middleware tRPC +request-handler/network-controller.ts → typé IntegrationKindByCategory<"networkController">, + appelle getNetworkSummaryAsync() +``` + +Déclarer `unifiOs` dans cette catégorie la rendrait sélectionnable dans les +widgets de l'ancienne intégration **et** exigerait qu'elle implémente +`getNetworkSummaryAsync()` — l'interface que la décision D3 écarte +explicitement. Le typage `IntegrationKindByCategory` transforme cette +contradiction en erreur de compilation, pas en défaut silencieux. + +On ajoute donc une catégorie `unifiOs`. Le dépôt a déjà un précédent de +catégorie mono-intégration : `beszel`. Une catégorie sans traduction ne casse +rien — `beszel` n'en a pas — mais on ajoute quand même +`integration.category.unifiOs`. + +**Base d'URL.** L'utilisateur saisit l'origine de la console +(`https://console.exemple.tld`). Le client local préfixe systématiquement +`/proxy/network/integration/v1`. Ce préfixe appartient au client, pas à la +configuration : l'utilisateur n'a pas à le connaître. + +**Précédent de conception validé :** le champ `secretKinds` accepte plusieurs +jeux alternatifs, et la classe de base expose `hasSecretValue()`. L'intégration +`ntfy` utilise déjà exactement ce motif (`[["topic"], ["topic", "apiKey"]]`). +La clé cloud optionnelle ne demande donc aucun mécanisme nouveau. + +**Note TLS.** La classe de base `Integration` applique les certificats de +confiance de Homarr à l'URL de l'intégration uniquement. L'appel vers +`api.ui.com` sort de ce périmètre : c'est du HTTPS public à certificat valide, +un `fetch` standard suffit. Il ne doit **pas** passer par +`fetchWithTrustedCertificatesAsync`, pour ne pas laisser croire que les deux +appels partagent la même configuration. + +## 6. Séquence d'un rafraîchissement + +``` +1. GET /info → applicationVersion +2. GET /sites → siteId, name +3. Promise.allSettled([ + GET /sites/{id}/clients?limit=200 → comptage par type (voir §7) + GET /sites/{id}/devices → états, firmwares, gateway + GET /sites/{id}/vpn/servers → VPN actif + ]) +4. GET /sites/{id}/devices/{gatewayId}/statistics/latest (si gateway trouvé) +5. si clé cloud présente : + unifi-sm-api.getIspMetricsAsync(gatewayMac) → cache 5 min +``` + +**`Promise.allSettled`, pas `Promise.all`.** Un endpoint en échec dégrade son +bloc à `null` ; il ne fait pas tomber le widget entier. L'étape 4 dépend de +l'étape 3 : sans gateway identifié, `gateway` et `wan.throughput` valent `null` +et le reste s'affiche normalement. + +## 7. Cache et gestion d'erreurs + +**Deux caches de rythmes différents**, parce que les deux sources n'évoluent pas +à la même vitesse : + +| Couche | TTL | Motif | +|---|---|---| +| `createIntegrationRequestHandler` | 60 s | Les compteurs locaux sont vivants | +| Cache interne de `unifi-sm-api.ts`, clé = MAC gateway | 5 min | La donnée cloud ne change que toutes les 5 min et arrive avec ~27 min de retard | + +Sans le second cache, on émettrait ~120 appels/h vers `api.ui.com` pour ~12 +valeurs distinctes, avec un risque de limitation de débit. Le motif du cache +statique par instance est déjà utilisé par `TrueNasClient`. + +**`fallbackToStaleOnError: true`** sur le handler. Une console injoignable +pendant quelques secondes affiche la dernière valeur connue au lieu d'une tuile +d'erreur — à condition que l'interface signale que la donnée est périmée (§8). + +**Comptage des clients — règle explicite.** L'API plafonne `limit` à 200 +(mesuré). Le comportement est donc conditionné par `totalCount`, renvoyé dès la +première page : + +| Condition | Stratégie | Coût | +|---|---|---| +| `totalCount ≤ 200` | La première page contient tout. Comptage en mémoire par `type` et `access.type`. | 1 requête | +| `totalCount > 200` | Bascule sur des requêtes filtrées `limit=1`, dont on ne lit que `totalCount`. | 5 requêtes de taille fixe | + +La seconde branche utilise la syntaxe préfixe du §3.1, par exemple +`and(type.eq('WIRELESS'),access.type.eq('GUEST'))`. + +On ne pagine **pas** en boucle : au-delà de 200 clients, rapatrier tous les +enregistrements pour n'en garder que des compteurs coûte plus cher que cinq +requêtes bornées, et la charge croîtrait avec la taille du réseau. + +*Écrire les deux branches maintenant est justifié : ce n'est pas de +l'optimisation spéculative mais la gestion d'une limite d'API mesurée, qui +produirait sinon des compteurs silencieusement faux au-delà de 200 clients.* + +## 8. Widget + +**Disposition retenue : bandeau WAN dominant + lignes** (option B des maquettes). + +``` +┌────────────────────────────────────────────────┐ +│ ● Nom du site │ +│ ┌────────────────────────────────────────────┐ │ +│ │ ● Internet ↑14,1 ↓3,3 Mb/s │ │ +│ │ Nom FAI · 203.0.113.10 │ │ +│ │ 11 ms · 0 % perte [27min]│ │ +│ └────────────────────────────────────────────┘ │ +│ Clients 51 · 44 filaire · 7 Wi-Fi │ +│ Équipements 6 / 6 en ligne │ +│ ● Firmware 3 à mettre à jour │ +│ Network 10.6.102 Gateway up 11 j 16 h │ +└────────────────────────────────────────────────┘ +``` + +Justification : la hiérarchie visuelle suit l'ordre de consultation réel — on +regarde d'abord si Internet fonctionne, ensuite le détail. + +**Le badge de fraîcheur n'est pas cosmétique.** Il porte deux rôles distincts : +signaler le retard structurel des métriques cloud, et signaler une donnée servie +depuis le cache périmé après une erreur. Sans lui, le widget juxtaposerait des +données de fraîcheurs très différentes sans le dire. + +**Comportement dégradé :** + +| Situation | Rendu | +|---|---| +| Clé cloud absente | Bloc ISP masqué. Le bandeau garde l'état WAN et le débit. | +| Cloud injoignable | Idem, plus un indicateur discret. | +| Aucune correspondance de MAC | Bloc ISP masqué. Jamais la latence d'un autre site. | +| Gateway introuvable | `throughput`, `uptime`, CPU/RAM masqués ; clients et équipements affichés. | +| Console injoignable | Dernière valeur connue + badge périmé, puis tuile d'erreur au-delà du TTL. | + +**Taille.** `WidgetDefinition` n'expose aucune contrainte de taille minimale : +l'adaptation est une affaire de CSS dans le composant. Le bandeau passe en deux +lignes en tuile étroite, et les lignes de détail se réduisent à leurs chiffres. + +**Rafraîchissement.** Entrée dans `widgetQueryRefetchIntervals` avec +`intervalSeconds: 60`. Les widgets `networkController` sont à `null` — pas de +rafraîchissement automatique — mais les compteurs de clients ont une valeur +d'usage qui justifie l'écart. + +## 9. Tests + +| Niveau | Fichier | Couvre | Réseau | +|---|---|---|---| +| Unitaire | `unifi-os-types.spec.ts` | Comptage par type et par `access.type`, agrégation d'états, conversion bps, corrélation par MAC **y compris le cas sans correspondance**, tolérance aux champs inconnus | Non | +| Intégration | `unifi-os-integration.spec.ts` | Séquence complète sur fixtures capturées, dégradation par bloc, absence de clé cloud, endpoint en échec | Mocké | +| Manuel | script hors CI | Vérification contre une console réelle | Oui | + +Le niveau unitaire est le plus important : c'est là que vivent les erreurs de +comptage, et c'est le seul qui reste valable si Ubiquiti fait évoluer ses +endpoints. + +**Fixtures.** Capturées depuis la console réelle, puis **anonymisées** — +adresses MAC, IP publique, noms de clients et identifiants de site remplacés. +Un dépôt ne doit pas porter la cartographie d'un réseau privé. + +**Cas de régression explicite :** l'issue upstream +[#3222](https://github.com/homarr-labs/homarr/issues/3222) signalait des valeurs +nulles en multi-site sur l'ancienne intégration. Un test couvre l'agrégation +multi-site pour ne pas reproduire ce défaut. + +## 10. Phasage + +Chaque phase se vérifie seule. C'est ce qui permet de savoir de quel côté +chercher quand quelque chose casse. + +| Phase | Contenu | Critère de validation | +|---|---|---| +| 0 | Fork, `corepack enable`, `pnpm docker:dev:up`, `pnpm dev` | Homarr démarre en local | +| 1 | `unifi-os-api.ts`, `unifi-sm-api.ts`, types et mappers, tests unitaires | `pnpm test` vert, sans réseau | +| 2 | `UnifiOsIntegration` + enregistrement | Le test de connexion Homarr passe dans l'interface | +| 3 | request-handler + routeur tRPC | La query renvoie les vraies valeurs | +| 4 | Widget + traductions `en` | Le widget s'affiche sur un board local | +| 5 | Build standalone + rsync vers le LXC | Le widget s'affiche en production | + +À l'issue de la phase 2, toute la plomberie est prouvée. Une panne en phase 4 +est donc nécessairement côté interface. + +## 11. Déploiement + +Cible : LXC Ubuntu 24.04, `10.70.1.13`, 2 vCPU / 2 Go. Homarr y tourne en build +standalone sous systemd (`/opt/homarr`, `homarr.service`, +`EnvironmentFile=/etc/homarr/homarr.env`), avec nginx et Redis embarqués par +`run.sh`. + +**Le build se fait sur le poste de développement** (16 cœurs, 60 Go). Le LXC ne +peut pas compiler ce monorepo Next.js. + +Procédure : + +1. `tar` de `/opt/homarr` **et** dump de la base. +2. `systemctl stop homarr`. +3. `rsync` du build standalone. +4. `systemctl start homarr`, puis vérification des logs. + +Rollback : restauration de l'archive et redémarrage. + +**Contrôle préalable obligatoire :** comparer les migrations de +`packages/db/migrations/` avec `/opt/homarr/db/migrations/`. Elles sont +alignées à `0041_add_right_click_preference.sql` au 2026-09-08. Une divergence +rendrait le rollback nettement plus complexe qu'une restauration d'archive. + +**`SECRET_ENCRYPTION_KEY`** dans `/etc/homarr/homarr.env` chiffre les secrets +d'intégration en base. Le rsync ne touche pas ce fichier, mais toute +modification rendrait les intégrations existantes illisibles. + +## 12. Hors périmètre + +Volontairement exclus de cette spec : + +- **Widget cloud multi-site** (SD-WAN, fabric, vue consolidée de plusieurs + consoles). Idée retenue pour un projet ultérieur, qui réutilisera + `unifi-sm-api.ts`. Sa mention ici sert à ne pas le construire maintenant. +- **Actions d'écriture** (adoption d'équipement, redémarrage de port, gestion de + vouchers). L'API les expose ; l'intégration reste en lecture seule. +- **Traduction au-delà de l'anglais.** Conséquence de D1. +- **Documentation Docusaurus.** Conséquence de D1. +- **Pull request upstream.** Conséquence de D1 ; la structure du code ne + l'interdit pas plus tard. +- **Modification de l'intégration `unifiController`.** Elle reste en l'état. + +## 13. Risques + +| Risque | Portée | Traitement | +|---|---|---| +| L'OpenAPI publié (`10.4.57`) est en retard sur les consoles | Élevée — déjà constaté | Schémas zod tolérants, code écrit contre les réponses réelles | +| La clé Site Manager donne accès à tout le compte UI | Sécurité | Cloud optionnel ; corrélation stricte par MAC ; clé de conception à révoquer | +| Retard de ~27 min sur les métriques cloud | Interface | `measuredAt` obligatoire dans le contrat, badge de fraîcheur dans le widget | +| Un fork diverge à chaque version de Homarr | Maintenance | Modifications concentrées dans un dossier neuf ; seuls les points d'attache du §5.2 touchent l'existant | +| Limitation de débit sur `api.ui.com` | Fiabilité | Cache interne 5 min | +| Le déploiement casse l'instance de production | Exploitation | Sauvegarde avant rsync, contrôle des migrations, rollback par restauration |