docs: design for UniFi OS integration

Conception validée pour une intégration UniFi OS en complément de
unifiController, basée sur l'API d'intégration officielle (clé X-API-KEY)
plutôt que sur l'ancienne API du Network Controller.

Toutes les capacités et limites de l'API ont été mesurées sur une console
réelle en 10.6.102, pas déduites de la documentation publiée (qui s'arrête
à 10.4.57 et est déjà en retard sur les consoles).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zacharyandClaude Opus 5 committed 2026-09-13 22:21:43 +02:00
1 parent 7859faaa1d
commit c47be12e69
1 file changed
+459
@@ -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 |