Full web : fichiers restants

This commit is contained in:
cedric committed 2026-10-23 17:43:20 +02:00
1 parent ec3b7c9a01
commit 4826fa5b82
26 files changed
+2537 -793

No files matched your search

+92 -106
View File
@@ -1,27 +1,35 @@
# pve-confsync # pve-confsync
Synchronisation d'une partie de la configuration d'un cluster Proxmox VE 9.x Réplication d'une partie de la configuration d'un cluster Proxmox VE 9.x **primaire**
« référence » vers N clusters « clients », via l'API PVE et des tokens API. vers une ou plusieurs **cibles**. Sens unique primaire → cibles. Pas de VM, pas de disques.
Sens unique référence → clients. Pas de VM, pas de disques. Tout se fait depuis l'interface web : plus de CLI ni de fichier YAML.
**État : phases 0 à 2.** CLI (export, diff et apply en YAML) et interface web **État : phases 0 à 2, version « full web ».** Ajout des clusters (un par un ou en lot),
(écarts par client, dry-run et synchronisation par objet, par client ou global, journal, choix du primaire, éléments répliqués et cibles choisis dans des menus, écarts par cible,
audit) pour les options datacenter et le SDN (zones VLAN/VXLAN, VNets, subnets). dry-run et réplication par objet, par cible ou global, journal, audit.
Testé contre un faux serveur PVE ; **pas encore testé sur les clusters de labo.** Testé contre un faux serveur PVE (API + SSH) ; **pas encore testé sur un vrai cluster.**
## Périmètre ## Périmètre
| Type (`kind`) | API | Politique par défaut | | Type (`kind`) | Accès | Politique par défaut |
|---|---|---| |---|---|---|
| `datacenter-options` | `/cluster/options` | `tag-style`, `registered-tags`, `user-tag-access`, `next-id` en `sync`, le reste ignoré ; `webauthn`, `u2f`, `http_proxy` jamais synchronisés | | `datacenter-options` | API `/cluster/options` | `tag-style`, `registered-tags`, `user-tag-access`, `next-id` en `sync`, le reste ignoré ; `webauthn`, `u2f`, `http_proxy` jamais répliqués |
| `sdn-zone` (vlan, vxlan) | `/cluster/sdn/zones` | `nodes`, `bridge`, `peers`, `fabric`, `dns`, `reversedns`, `dnszone` en `override` (mapping client) ; le reste en `sync` | | `pve-group` | API `/access/groups` | `sync` |
| `sdn-vnet` | `/cluster/sdn/vnets` | `sync` | | `pve-role` | API `/access/roles` | `sync` (rôles intégrés ignorés) |
| `sdn-subnet` | `/cluster/sdn/vnets/{vnet}/subnets` | `sync` | | `pve-user` | API `/access/users` | `sync`, comptes du royaume `pve` uniquement |
| `pve-password` | SSH `/etc/pve/priv/shadow.cfg` | `sync` (haché recopié tel quel, jamais affiché) |
| `sdn-zone` (vlan, vxlan) | API `/cluster/sdn/zones` | `nodes`, `bridge`, `peers`, `fabric`, `dns`, `reversedns`, `dnszone` en `override` (mapping de la cible) ; le reste en `sync` |
| `sdn-vnet` | API `/cluster/sdn/vnets` | `sync` |
| `sdn-subnet` | API `/cluster/sdn/vnets/{vnet}/subnets` | `sync` |
Hors périmètre v1 : EVPN (et les zones simple, QinQ, faucet, avec leurs VNets, ignorées Dépendances : un mot de passe exige son compte, un compte exige ses groupes ; le menu
avec un avertissement), firewall, storage, pools/ACL, jobs, secrets. « Éléments répliqués » les coche ensemble. Un parent absent de la cible et non répliqué
est une erreur bloquante.
Détail des routes, schémas et droits, avec les sources : [docs/api-pve.md](docs/api-pve.md). Hors périmètre : EVPN (et les zones simple, QinQ, faucet, avec leurs VNets, ignorées
avec un avertissement), firewall, storage, pools, ACL, jobs, comptes `@pam` et autres royaumes.
Détail des routes, schémas et accès, avec les sources : [docs/api-pve.md](docs/api-pve.md).
## Fonctionnement ## Fonctionnement
@@ -29,77 +37,65 @@ Détail des routes, schémas et droits, avec les sources : [docs/api-pve.md](doc
lecture source -> normalisation -> diff -> plan -> apply -> vérification lecture source -> normalisation -> diff -> plan -> apply -> vérification
``` ```
- **Lecture** : la référence est lue en configuration SDN *appliquée* (`running=1`) ; les - **Lecture** : le primaire est lu en configuration SDN *appliquée* (`running=1`) ; les
clients en configuration courante. Chaque cluster est refusé si un nœud en ligne n'est cibles en configuration courante. Chaque cluster est refusé si un nœud en ligne n'est
pas en PVE 9.x (`/nodes/{node}/version`). pas en PVE 9.x (`/nodes/{node}/version`).
- **Normalisation** : objets JSON génériques → champs canoniques (chaînes, listes triées, - **Normalisation** : objets JSON génériques → champs canoniques (chaînes, listes triées,
property strings à clés triées). Les champs calculés par PVE sont retirés. property strings à clés triées). Les champs calculés par PVE sont retirés.
- **Politiques** : `sync` (copie de la référence), `ignore`, `override` (valeur fournie - **Politiques** : `sync` (copie du primaire), `ignore`, `override` (valeur fournie
uniquement par le `mapping` du client). Le mapping d'un client est prioritaire sur la uniquement par le `mapping` de la cible). Le mapping d'une cible est prioritaire sur le
référence. Résolution : politique du client → politique globale → défaut de l'adaptateur. primaire. Résolution : politique de la cible → politique globale → défaut de l'adaptateur.
- **Plan** : création / mise à jour (champs modifiés, champs à supprimer) ; suppression - **Plan** : création / mise à jour (champs modifiés, champs à supprimer) ; suppression
d'objets uniquement avec `prune` (option explicite), dépendants d'abord. Erreurs bloquantes : d'objets uniquement avec `prune` (option explicite), dépendants d'abord. Erreurs bloquantes :
champ obligatoire absent à la création (ex. `bridge` d'une zone VLAN sans mapping), champ obligatoire absent à la création (ex. `bridge` d'une zone VLAN sans mapping),
champ immuable modifié (type de zone, vnet d'un subnet). champ immuable modifié (type de zone, vnet d'un subnet).
- **Apply SDN** : `POST /cluster/sdn/lock` → écritures avec `lock-token` → `PUT /cluster/sdn` - **Apply SDN** : `POST /cluster/sdn/lock` → écritures avec `lock-token` → `PUT /cluster/sdn`
(commit + rechargement réseau, suivi de la tâche). Toute erreur avant le commit déclenche (commit + rechargement réseau, suivi de la tâche). Toute erreur avant le commit déclenche
`POST /cluster/sdn/rollback`. Le verrou est refusé si le client a déjà des changements SDN `POST /cluster/sdn/rollback`. Le verrou est refusé si la cible a déjà des changements SDN
en attente : on ne mélange pas nos modifications avec celles de quelqu'un d'autre. en attente : on ne mélange pas nos modifications avec celles de quelqu'un d'autre.
- **Vérification** : relecture du client, nouveau plan qui doit être vide, et aucune zone en - **Apply comptes** : API pour groupes, rôles et comptes ; mots de passe par SSH (script
perl sous `lock_shadow_config`, comme `pveum passwd`).
- **Vérification** : relecture de la cible, nouveau plan qui doit être vide, et aucune zone en
`error` sur les nœuds (`/nodes/{node}/sdn/zones`). `error` sur les nœuds (`/nodes/{node}/sdn/zones`).
## Utilisation ## Démarrage
```sh ```sh
go build -o pve-confsync ./cmd/pve-confsync go run ./cmd/pve-confsync -demo # démo : faux clusters PVE, http://localhost:8080
cp examples/confsync.example.yaml confsync.yaml # puis adapter docker compose -f docker-compose.demo.yml up -d --build # même démo en conteneur
docker compose up -d # production : données dans le volume /data
./pve-confsync check # accès, version 9.x, droits des tokens
./pve-confsync export -o ref.yaml # état normalisé de la référence (versionnable dans Git)
./pve-confsync diff # écarts par client ; code retour 2 s'il y en a
./pve-confsync diff -format json
./pve-confsync apply # dry-run (défaut) : affiche le plan, n'écrit rien
./pve-confsync apply -client site-a -confirm # applique ; SDN : saisir le nom du client
./pve-confsync apply -source ref.yaml -confirm -yes # depuis un export Git, sans question
``` ```
Avec docker compose (images et utilisateur non privilégié alignés sur Logstream) : Le mode démo démarre un primaire PVE 9.1, deux cibles avec des écarts (dont un mapping
```sh
mkdir -p secrets && printf '%s' 'xxxxxxxx-...' > secrets/pve_ref
chmod 600 secrets/* && sudo chown 10001 secrets/* # lisibles par l'utilisateur du conteneur uniquement
docker compose run --rm confsync check
docker compose run --rm confsync apply -confirm
```
## Interface web
```sh
go run ./cmd/pve-confsync serve -demo # démo : faux clusters PVE, http://localhost:8080
docker compose -f docker-compose.demo.yml up -d --build # même démo en conteneur, sans secret à créer
docker compose up -d confsync-web # production : données dans le volume /data
```
Le mode démo démarre une référence PVE 9.1, deux clients avec des écarts (dont un mapping
manquant, bloquant) et un cluster PVE 8 refusé. Comptes `admin`, `operateur` et `lecteur`, manquant, bloquant) et un cluster PVE 8 refusé. Comptes `admin`, `operateur` et `lecteur`,
mot de passe `confsync-demo`. mot de passe `confsync-demo`.
- **Écarts** : une carte par cluster (version, compteurs à créer / modifier / erreurs / **Base vide** : la page de connexion propose de créer le premier administrateur
hors référence), puis les objets du client choisi ; le détail d'un objet montre pour (12 caractères minimum). Tant que ce n'est pas fait, **le premier qui atteint l'instance
chaque champ la politique, la valeur de référence, le mapping, la valeur actuelle et devient administrateur** : ne pas l'exposer avant. Alternative : `CONFSYNC_ADMIN_USER` et
la valeur après synchro. `CONFSYNC_ADMIN_PASSWORD` / `_FILE` créent ce compte au démarrage.
- **Dry-run / Synchroniser** par objet (avec ses dépendances : zone et vnet parents à
créer), par client ou global. La synchronisation passe toujours par un dry-run : le
serveur recalcule le plan et refuse s'il a changé depuis (empreinte du plan). Avec des
changements SDN, il faut taper le nom du client (celui de la référence en global).
Une seule synchronisation à la fois, suivie étape par étape.
- **Journal** des dry-runs et synchronisations (plans, étapes, résultats) et **Audit**
(connexions, actions, modifications des paramètres ; admin).
- **Paramètres** : clusters (test d'accès et des droits, TLS, mapping et politiques par
client), types synchronisés, sélection, politiques, suppression (désactivée par défaut),
utilisateurs locaux, import / export YAML (format de la CLI), compte, apparence.
Rôles : `admin` (tout), `operator` (dry-run et synchronisation), `viewer` (lecture). ## Interface web
- **Clusters** : ajout d'un cluster (URL `https://…:8006`, mot de passe root) ou d'une
liste (« nom URL » par ligne, même mot de passe), test (API, version 9.x, SSH), rôle
primaire / cible / désactivé. Un seul primaire, jamais écrit.
- **Éléments répliqués** et **Cibles** : menus de la vue Écarts ; les cibles décochées
sont exclues du global (choix mémorisé dans le navigateur).
- **Écarts** : une carte par cluster (version, compteurs à créer / modifier / erreurs /
hors primaire), puis les objets de la cible choisie ; le détail d'un objet montre pour
chaque champ la politique, la valeur du primaire, le mapping, la valeur actuelle et
la valeur après réplication. Les hachés sont masqués (`<masqué xxxxxx>`).
- **Dry-run / Répliquer** par objet (avec ses parents à créer), par cible ou global.
La réplication passe toujours par un dry-run : le serveur recalcule le plan et refuse
s'il a changé depuis (empreinte du plan). Avec des changements SDN, il faut taper le
nom de la cible (celui du primaire en global). Une seule réplication à la fois.
- **Journal** des dry-runs et réplications et **Audit** (connexions, actions,
modifications ; admin).
- **Paramètres** : sélection, politiques, mapping par cible, suppression (désactivée par
défaut), utilisateurs locaux, compte, apparence.
Rôles : `admin` (tout), `operator` (dry-run et réplication), `viewer` (lecture).
Les comptes locaux restent disponibles même avec OIDC. Toutes les variables sont reprises, Les comptes locaux restent disponibles même avec OIDC. Toutes les variables sont reprises,
commentées, dans [`.env.example`](.env.example) (`cp .env.example .env`). commentées, dans [`.env.example`](.env.example) (`cp .env.example .env`).
@@ -107,9 +103,8 @@ commentées, dans [`.env.example`](.env.example) (`cp .env.example .env`).
|---|---|---| |---|---|---|
| `LISTEN` | `:8080` | adresse d'écoute | | `LISTEN` | `:8080` | adresse d'écoute |
| `DATA_DIR` | `/data` | base SQLite, `secret.key`, `session.key` | | `DATA_DIR` | `/data` | base SQLite, `secret.key`, `session.key` |
| `CONFSYNC_SECRET_KEY` / `_FILE` | `DATA_DIR/secret.key` (créée) | clé AES-256 (hex ou base64) des secrets en base | | `CONFSYNC_SECRET_KEY` / `_FILE` | `DATA_DIR/secret.key` (créée) | clé AES-256 (hex ou base64) des mots de passe en base |
| `CONFSYNC_ADMIN_USER`, `CONFSYNC_ADMIN_PASSWORD` / `_FILE` | `admin`, aléatoire (journalisé une fois) | premier administrateur, base vide | | `CONFSYNC_ADMIN_USER`, `CONFSYNC_ADMIN_PASSWORD` / `_FILE` | `admin`, vide | premier administrateur sans passer par la page web |
| `CONFSYNC_IMPORT` | | YAML de la CLI importé si la base n'a aucun cluster |
| `AUTH_LOCAL` | `true` | comptes locaux | | `AUTH_LOCAL` | `true` | comptes locaux |
| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` / `_FILE`, `OIDC_REDIRECT_URL` | | OIDC (code + PKCE) ; `OIDC_REDIRECT_URL` complète, par ex. `https://confsync.example.org/auth/callback` | | `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` / `_FILE`, `OIDC_REDIRECT_URL` | | OIDC (code + PKCE) ; `OIDC_REDIRECT_URL` complète, par ex. `https://confsync.example.org/auth/callback` |
| `OIDC_SCOPES`, `OIDC_GROUPS_CLAIM` | `openid profile email`, `groups` | ajouter le scope qui fournit les groupes chez l'IdP | | `OIDC_SCOPES`, `OIDC_GROUPS_CLAIM` | `openid profile email`, `groups` | ajouter le scope qui fournit les groupes chez l'IdP |
@@ -119,68 +114,59 @@ commentées, dans [`.env.example`](.env.example) (`cp .env.example .env`).
## Sécurité ## Sécurité
- Secrets des tokens : jamais dans le YAML (`token_secret` en clair est refusé) ; lus depuis - **Accès PVE en `root@pam`** (API par ticket, SSH avec le même mot de passe) : nécessaire
une variable d'environnement (`token_secret_env`) ou un fichier (`token_secret_file`, pour les mots de passe du royaume `pve`, absents de l'API. Le compte root ne doit pas
secret docker). Ils ne sont ni affichés ni journalisés. Dans l'interface, ils sont chiffrés en base avoir de TFA (refusé). Le primaire n'est jamais écrit.
(AES-256-GCM, clé hors base) et jamais renvoyés au navigateur ; l'export YAML les remplace - **Épinglage au premier test** : empreinte SHA-256 du certificat TLS (mode par défaut,
par `token_secret_env`. adapté aux certificats auto-signés PVE) et clé d'hôte SSH. Une clé SSH différente
ensuite bloque l'accès ; l'accepter demande une action explicite (« faire confiance à la
nouvelle clé »). Changer l'URL ou le nœud SSH oublie les empreintes. Autres modes TLS :
CA (PEM), CA système, `insecure` (labo).
- **Mots de passe root** chiffrés en base (AES-256-GCM, clé hors base), jamais renvoyés au
navigateur ni journalisés. Les hachés du shadow sont masqués dans l'UI, le journal et
les réponses JSON.
- Interface : mots de passe PBKDF2-SHA256 (600 000 itérations), cookie de session signé - Interface : mots de passe PBKDF2-SHA256 (600 000 itérations), cookie de session signé
(HttpOnly, SameSite=Lax, Secure derrière HTTPS) invalidé au changement de mot de passe, (HttpOnly, SameSite=Lax, Secure derrière HTTPS) invalidé au changement de mot de passe,
en-tête `X-Confsync` exigé sur les écritures (CSRF), CSP sans script ni style en ligne. en-tête `X-Confsync` exigé sur les écritures (CSRF), CSP sans script ni style en ligne.
- TLS : `fingerprint` (pinning SHA-256 du certificat, adapté aux certificats auto-signés PVE),
`ca_file`, ou `insecure: true` réservé au labo.
- Tokens (privilege separation activée, l'ACL doit être posée sur l'utilisateur et le token) :
```sh
# référence : lecture seule
pveum role add ConfSyncRO --privs "Sys.Audit SDN.Audit"
pveum user add confsync@pve
pveum user token add confsync@pve ro --privsep 1
pveum acl modify / --users confsync@pve --roles ConfSyncRO
pveum acl modify / --tokens 'confsync@pve!ro' --roles ConfSyncRO
# clients : Sys.Modify uniquement si les options datacenter sont synchronisées
pveum role add ConfSyncRW --privs "Sys.Audit Sys.Modify SDN.Audit SDN.Allocate"
```
`check` signale un token de référence capable d'écrire et un token client à qui il manque
un droit.
## Arborescence ## Arborescence
``` ```
cmd/pve-confsync/ CLI (check, export, diff, apply, serve) cmd/pve-confsync/ serveur web (seul point d'entrée)
internal/service/ analyse pour l'UI : lecture parallèle, plans, lignes de diff par champ internal/service/ analyse pour l'UI : lecture parallèle, plans, lignes de diff par champ
internal/store/ SQLite : clusters (secrets chiffrés), paramètres, utilisateurs, journal, audit internal/store/ SQLite : clusters (mots de passe chiffrés, rôles, empreintes), utilisateurs, journal, audit
internal/server/ HTTP : auth locale + OIDC, rôles, API, jobs de synchronisation internal/server/ HTTP : auth locale + OIDC, rôles, API, jobs de synchronisation
internal/demo/ faux clusters du mode démo internal/demo/ faux clusters du mode démo
web/ UI embarquée (index.html, app.js, style.css, login.html) web/ UI embarquée (index.html, app.js, style.css, login.html)
internal/pve/ client API : token, TLS, form-encoding, version, tâches internal/pve/ client API : ticket root@pam, TLS épinglé, form-encoding, version, tâches
internal/pve/pvetest/ faux serveur PVE 9.x pour les tests internal/sshx/ SSH root, clé d'hôte épinglée
internal/model/ objets génériques, snapshot YAML, valeurs canoniques internal/pve/pvetest/ faux serveur PVE 9.x (API + SSH) pour les tests
internal/model/ objets génériques, valeurs canoniques
internal/adapter/ un adaptateur par type : routes, champs calculés, locaux, immuables internal/adapter/ un adaptateur par type : routes, champs calculés, locaux, immuables
internal/config/ configuration YAML : clusters, politiques, mappings, sélection internal/config/ configuration en mémoire : clusters, politiques, mappings, sélection
internal/engine/ lecture, plan, apply (verrou SDN, rollback), vérification internal/engine/ lecture, plan, apply (verrou SDN, rollback), vérification
docs/api-pve.md routes et schémas vérifiés dans les sources PVE docs/api-pve.md routes et schémas vérifiés dans les sources PVE
``` ```
Tests : `go test ./...` (bout en bout contre le faux serveur : synchro complète, mapping Tests : `go test ./...` (bout en bout contre le faux serveur : réplication SDN et comptes
manquant, rollback sur erreur, refus de PVE 8, prune et ordre de suppression, forme avec mots de passe par SSH, mapping manquant, parent absent, rollback sur erreur, refus de
canonique des options datacenter, CLI dry-run/apply/export ; sélection d'objets et dépendances ; chiffrement, utilisateurs ; PVE 8, prune et ordre de suppression ; sélection et dépendances ; chiffrement, migration
serveur de bout en bout : rôles, CSRF, dry-run, empreinte périmée, confirmation SDN, apply). de schéma, premier administrateur ; serveur depuis une base vide : création de l'admin,
ajout des clusters, épinglage, primaire, réplication globale, primaire jamais écrit).
## Phasage ## Phasage
0. **CLI** export / diff / apply YAML (options datacenter, tags, SDN). 0. Moteur export / diff / apply (options datacenter, tags, SDN) ; la CLI a été retirée.
1. UI lecture seule : clusters, diff visuel par client. Ergonomie reprise de Logstream 1. UI lecture seule : clusters, diff visuel par cible. Ergonomie reprise de Logstream
(`web/index.html`, `style.css`, `app.js`, `login.html`, auth locale + OIDC, audit) ; (`web/index.html`, `style.css`, `app.js`, `login.html`, auth locale + OIDC, audit) ;
SQLite (clusters, profils, mappings, historique, audit), secrets chiffrés. SQLite (clusters, profils, mappings, historique, audit), secrets chiffrés.
2. UI d'action : Dry-run / Synchroniser (par objet, par client, global), journal des exécutions. ← ici 2. UI d'action : Dry-run / Répliquer (par objet, par cible, global), journal ; gestion
des clusters en web, primaire / cibles, comptes et mots de passe du royaume pve. ← ici
3. Planification, détection de dérive en lecture seule, notifications. 3. Planification, détection de dérive en lecture seule, notifications.
## Reste à faire ## Reste à faire
- Valider sur les deux clusters de labo : forme réelle des réponses (`nodes`, `dhcp-range`, - Valider sur un vrai cluster : écriture du shadow par SSH, forme de `/access/users?full=1`, forme réelle des réponses (`nodes`, `dhcp-range`,
booléens), comportement de `PUT /cluster/options` avec `delete`. booléens), comportement de `PUT /cluster/options` avec `delete`.
- Exploiter `GET /cluster/sdn/dry-run` (diff frr / interfaces, PVE ≥ 9.2 environ) pour - Exploiter `GET /cluster/sdn/dry-run` (diff frr / interfaces, PVE ≥ 9.2 environ) pour
enrichir le dry-run SDN. enrichir le dry-run SDN.
+34 -9
View File
@@ -1,12 +1,18 @@
# API Proxmox VE 9.x utilisée par pve-confsync # API Proxmox VE 9.x utilisée par pve-confsync
Vérifiée le 2026-10-13 dans les sources Proxmox (miroirs GitHub officiels), faute d'accès Vérifiée le 2026-10-13 (comptes : 2026-10-23) dans les sources Proxmox (miroirs GitHub officiels), faute d'accès
à l'API viewer (https://pve.proxmox.com/pve-docs/api-viewer/) depuis l'environnement de à l'API viewer (https://pve.proxmox.com/pve-docs/api-viewer/) depuis l'environnement de
développement. Versions lues : pve-manager 9.2.21, libpve-network-perl 1.6.7, pve-cluster 9.1.6. développement. Versions lues : pve-manager 9.2.21, libpve-network-perl 1.6.7, pve-cluster 9.1.6.
**À recouper avec l'API viewer et les deux clusters de labo avant la phase 1.** **À recouper avec l'API viewer et les deux clusters de labo avant la phase 1.**
Toutes les routes sont relatives à `/api2/json`. Authentification : en-tête Toutes les routes sont relatives à `/api2/json`. Authentification par ticket du compte
`Authorization: PVEAPIToken=USER@REALM!TOKENID=SECRET`. Écritures en `root@pam` : `POST /access/ticket` (`username`, `password`) renvoie `ticket` et
`CSRFPreventionToken` ([AccessControl.pm L198](https://github.com/proxmox/pve-access-control/blob/dff26b458651b4e1141ff0a41a296d2904b16228/src/PVE/API2/AccessControl.pm#L198)) ; le ticket
est envoyé dans le cookie `PVEAuthCookie`, le jeton CSRF dans l'en-tête
`CSRFPreventionToken` sur les écritures. Durée de vie du ticket : 2 h
([AccessControl.pm L58](https://github.com/proxmox/pve-access-control/blob/dff26b458651b4e1141ff0a41a296d2904b16228/src/PVE/AccessControl.pm#L58)) ; pve-confsync se reconnecte après 90 min
ou sur un 401. Un compte root avec TFA renvoie `NeedTFA`
([L167](https://github.com/proxmox/pve-access-control/blob/dff26b458651b4e1141ff0a41a296d2904b16228/src/PVE/API2/AccessControl.pm#L167)) : refusé. Écritures en
`application/x-www-form-urlencoded` (tableaux = paramètre répété), suppression de champs `application/x-www-form-urlencoded` (tableaux = paramètre répété), suppression de champs
via le paramètre `delete=a,b`. via le paramètre `delete=a,b`.
@@ -17,7 +23,6 @@ via le paramètre `delete=a,b`.
| GET | `/cluster/status` | nœuds en ligne | Sys.Audit sur / | | GET | `/cluster/status` | nœuds en ligne | Sys.Audit sur / |
| GET | `/nodes/{node}/version` | `version` (9.2.21), `release` (9.2) : refus si majeur ≠ 9 | — | | GET | `/nodes/{node}/version` | `version` (9.2.21), `release` (9.2) : refus si majeur ≠ 9 | — |
| GET | `/version` | repli pour un nœud isolé ([API2.pm](https://github.com/proxmox/pve-manager/blob/9c3f5e5258f73f31e273493232a79d615fd9e072/PVE/API2.pm#L86)) | — | | GET | `/version` | repli pour un nœud isolé ([API2.pm](https://github.com/proxmox/pve-manager/blob/9c3f5e5258f73f31e273493232a79d615fd9e072/PVE/API2.pm#L86)) | — |
| GET | `/access/permissions` | droits effectifs du token (`check`) ([AccessControl.pm](https://github.com/proxmox/pve-access-control/blob/master/src/PVE/API2/AccessControl.pm#L510)) | — |
| GET | `/nodes/{node}/tasks/{upid}/status` | suivi de la tâche de rechargement SDN | — | | GET | `/nodes/{node}/tasks/{upid}/status` | suivi de la tâche de rechargement SDN | — |
## Options datacenter (`datacenter.cfg`) ## Options datacenter (`datacenter.cfg`)
@@ -77,9 +82,29 @@ Champs (schémas : [VlanPlugin.pm](https://github.com/proxmox/pve-network/blob/c
- subnet : `vnet`, `gateway`, `snat`, `dnszoneprefix`, `dhcp-range` (tableau), `dhcp-dns-server` - subnet : `vnet`, `gateway`, `snat`, `dnszoneprefix`, `dhcp-range` (tableau), `dhcp-dns-server`
- calculés par PVE, ignorés : `digest`, `state`, `pending`, et pour les subnets `cidr`, `network`, `mask`, `zone` - calculés par PVE, ignorés : `digest`, `state`, `pending`, et pour les subnets `cidr`, `network`, `mask`, `zone`
## Droits minimaux ## Comptes du royaume pve (vérifié le 2026-10-23, pve-access-control dff26b45, pve-cluster 86d4bb9e)
- **Référence** (lecture seule) : rôle avec `Sys.Audit` + `SDN.Audit` sur `/`. | Objet | Liste / création | Élément | Notes |
- **Clients** : `Sys.Audit` + `SDN.Audit` + `SDN.Allocate` sur `/` (ou `/sdn`) ; |---|---|---|---|
`Sys.Modify` sur `/` uniquement si les options datacenter sont synchronisées | groupe | `/access/groups` ([Group.pm L26](https://github.com/proxmox/pve-access-control/blob/dff26b458651b4e1141ff0a41a296d2904b16228/src/PVE/API2/Group.pm#L26)) | `/access/groups/{groupid}` | champ `comment` ; `users` calculé, ignoré |
(`PUT /cluster/options` n'a pas de droit plus fin). | rôle | `/access/roles` ([Role.pm L31](https://github.com/proxmox/pve-access-control/blob/dff26b458651b4e1141ff0a41a296d2904b16228/src/PVE/API2/Role.pm#L31)) | `/access/roles/{roleid}` (PUT) | rôles `special` (intégrés) ignorés, non modifiables ([L146](https://github.com/proxmox/pve-access-control/blob/dff26b458651b4e1141ff0a41a296d2904b16228/src/PVE/API2/Role.pm#L146)) ; `privs` trié |
| utilisateur | `/access/users?full=1` ([User.pm L156](https://github.com/proxmox/pve-access-control/blob/dff26b458651b4e1141ff0a41a296d2904b16228/src/PVE/API2/User.pm#L156)) | `/access/users/{userid}` | seuls les `@pve` ; `tokens`, `realm-type`, champs de verrou TFA ignorés ; `groups` liste triée ; `password` jamais envoyé par l'API ; `root@pam` jamais supprimé |
**Mots de passe.** L'API ne renvoie pas les hachés : ils sont dans
`/etc/pve/priv/shadow.cfg` (lignes `user:hash:`, utilisateur sans royaume,
[Auth/PVE.pm L13-L50](https://github.com/proxmox/pve-access-control/blob/dff26b458651b4e1141ff0a41a296d2904b16228/src/PVE/Auth/PVE.pm#L13)). Lecture et écriture par SSH en root :
- lecture : `cat /etc/pve/priv/shadow.cfg` ;
- écriture : script perl sous `PVE::Auth::PVE::lock_shadow_config`
([L51](https://github.com/proxmox/pve-access-control/blob/dff26b458651b4e1141ff0a41a296d2904b16228/src/PVE/Auth/PVE.pm#L51)) avec `cfs_read_file` / `cfs_write_file`, comme
`store_password` ([L95-L98](https://github.com/proxmox/pve-access-control/blob/dff26b458651b4e1141ff0a41a296d2904b16228/src/PVE/Auth/PVE.pm#L95)) : le haché est recopié tel quel, sans
le recalculer. `cfs_read_file` sans version relit toujours le fichier
([Cluster.pm](https://github.com/proxmox/pve-cluster/blob/86d4bb9efc0956311c01dbf59a38fa0583b60a19/src/PVE/Cluster.pm)).
**À valider sur un vrai cluster** : script d'écriture du shadow, forme réelle de
`/access/users?full=1`, mise à jour d'un utilisateur avec champs vidés.
## Droits
Accès en `root@pam` (API + SSH, même mot de passe), sans TFA. La référence (primaire)
n'est jamais écrite. Le certificat TLS et la clé d'hôte SSH sont épinglés au premier test.
+36 -12
View File
@@ -1,7 +1,7 @@
// Package demo starts fake PVE clusters (pvetest) and fills a database with // Package demo starts fake PVE clusters (pvetest: API and SSH) and fills a
// them, to try the web UI without a Proxmox cluster: a reference, two clients // database with them, to try the web UI without a Proxmox cluster: a primary,
// with differences (missing objects, different fields, a missing mapping) and // two targets with differences (missing objects, different fields, accounts
// a PVE 8 cluster that is refused. // and passwords, a missing mapping) and a PVE 8 cluster that is refused.
package demo package demo
import ( import (
@@ -11,6 +11,7 @@ import (
"pve-confsync/internal/adapter" "pve-confsync/internal/adapter"
"pve-confsync/internal/model" "pve-confsync/internal/model"
"pve-confsync/internal/pve"
"pve-confsync/internal/pve/pvetest" "pve-confsync/internal/pve/pvetest"
"pve-confsync/internal/store" "pve-confsync/internal/store"
) )
@@ -50,6 +51,14 @@ func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
"dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}} "dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}}
ref.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"} ref.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"}
ref.Commit() ref.Commit()
ref.Groups["admins"] = map[string]any{"comment": "Administrateurs"}
ref.Groups["ops"] = map[string]any{"comment": "Exploitation"}
ref.Roles["ConfSyncOps"] = "Sys.Audit,VM.Audit,VM.PowerMgmt"
ref.Users["alice@pve"] = map[string]any{"enable": 1, "firstname": "Alice", "email": "alice@example.org", "groups": "admins"}
ref.Users["bob@pve"] = map[string]any{"enable": 1, "firstname": "Bob", "email": "bob@example.org", "groups": "ops", "expire": 1893456000}
ref.Users["carol@pam"] = map[string]any{"enable": 1, "comment": "compte Linux, non répliqué"}
ref.Shadow["alice"] = "$5$Kd1Xa9$Q8yJdemoAliceHashOnlyForTheDemo0123456789AB"
ref.Shadow["bob"] = "$5$Pq7Zr2$W3vLdemoBobHashOnlyForTheDemo0123456789ABCDE"
// site-a: tags and an MTU behind, the VXLAN zone and the dev network missing // site-a: tags and an MTU behind, the VXLAN zone and the dev network missing
a := pvetest.New("9.0.10") a := pvetest.New("9.0.10")
@@ -65,6 +74,9 @@ func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
a.Pending.VNets["ceph"] = map[string]any{"zone": "stor", "tag": 50, "alias": "ceph public"} a.Pending.VNets["ceph"] = map[string]any{"zone": "stor", "tag": 50, "alias": "ceph public"}
a.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.254"} a.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.254"}
a.Commit() a.Commit()
a.Groups["admins"] = map[string]any{"comment": "Admins"}
a.Users["alice@pve"] = map[string]any{"enable": 1, "firstname": "Alice", "email": "alice@old.example.org", "groups": "admins"}
a.Shadow["alice"] = "$5$OldOld$oldAliceHashOnSiteA0123456789ABCDEFGHIJKLMNOP"
// site-b: nearly in line, but no mapping for the bridge of the "stor" zone // site-b: nearly in line, but no mapping for the bridge of the "stor" zone
// (blocking error) and a local zone absent from the reference (kept, no prune) // (blocking error) and a local zone absent from the reference (kept, no prune)
@@ -84,21 +96,33 @@ func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
"dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}} "dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}}
b.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"} b.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"}
b.Commit() b.Commit()
b.Groups["admins"] = map[string]any{"comment": "Administrateurs"}
b.Groups["ops"] = map[string]any{"comment": "Exploitation"}
b.Roles["ConfSyncOps"] = "Sys.Audit,VM.Audit,VM.PowerMgmt"
b.Users["alice@pve"] = map[string]any{"enable": 1, "firstname": "Alice", "email": "alice@example.org", "groups": "admins"}
b.Users["bob@pve"] = map[string]any{"enable": 1, "firstname": "Bob", "email": "bob@example.org", "groups": "ops", "expire": 1893456000}
b.Shadow["alice"] = ref.Shadow["alice"]
b.Shadow["bob"] = ref.Shadow["bob"]
// legacy: PVE 8, refused by the version check // legacy: PVE 8, refused by the version check
old := pvetest.New("8.4.1") old := pvetest.New("8.4.1")
c := &Clusters{servers: []*pvetest.Server{ref, a, b, old}} c := &Clusters{servers: []*pvetest.Server{ref, a, b, old}}
save := func(s *pvetest.Server, name, token string, refer bool, pos int, save := func(s *pvetest.Server, name, role string, pos int,
mapping map[model.Kind]map[string]map[string]any, pol map[model.Kind]map[string]adapter.Policy) error { mapping map[model.Kind]map[string]map[string]any, pol map[model.Kind]map[string]adapter.Policy) error {
sum := sha256.Sum256(s.Certificate().Raw) sum := sha256.Sum256(s.Certificate().Raw)
return st.SaveCluster(ctx, "", store.Cluster{Name: name, Reference: refer, Position: pos, URL: s.URL, host, port := s.SSHAddr()
TokenID: token, Secret: "demo-" + name, Fingerprint: hex.EncodeToString(sum[:]), err := st.SaveCluster(ctx, "", store.Cluster{Name: name, Role: role, Position: pos, URL: s.URL,
Mapping: mapping, Policies: pol}) User: "root@pam", Password: pvetest.Password, TLS: store.TLSPin, Fingerprint: pve.FormatFingerprint(hex.EncodeToString(sum[:])),
SSHHost: host, SSHPort: port, Mapping: mapping, Policies: pol})
if err != nil {
return err
}
return st.Pin(ctx, name, "", s.SSHHostKey(), nil)
} }
steps := []error{ steps := []error{
save(ref, "ref-rennes", "confsync@pve!ro", true, 0, nil, nil), save(ref, "pve-rennes", store.ClusterPrimary, 0, nil, nil),
save(a, "site-a", "confsync@pve!rw", false, 1, map[model.Kind]map[string]map[string]any{ save(a, "site-a", store.ClusterTarget, 1, map[model.Kind]map[string]map[string]any{
model.KindDatacenter: {"datacenter": {"next-id": "lower=2000,upper=2999"}}, model.KindDatacenter: {"datacenter": {"next-id": "lower=2000,upper=2999"}},
model.KindZone: { model.KindZone: {
"vlanz": {"bridge": "vmbr1"}, "vlanz": {"bridge": "vmbr1"},
@@ -106,13 +130,13 @@ func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
"vxz": {"peers": "192.168.10.1,192.168.10.2"}, "vxz": {"peers": "192.168.10.1,192.168.10.2"},
}, },
}, nil), }, nil),
save(b, "site-b", "confsync@pve!rw", false, 2, map[model.Kind]map[string]map[string]any{ save(b, "site-b", store.ClusterTarget, 2, map[model.Kind]map[string]map[string]any{
model.KindZone: { model.KindZone: {
"vlanz": {"bridge": "vmbr0"}, "vlanz": {"bridge": "vmbr0"},
"vxz": {"peers": "172.16.0.1,172.16.0.2"}, "vxz": {"peers": "172.16.0.1,172.16.0.2"},
}, },
}, map[model.Kind]map[string]adapter.Policy{model.KindZone: {"mtu": adapter.Ignore}}), }, map[model.Kind]map[string]adapter.Policy{model.KindZone: {"mtu": adapter.Ignore}}),
save(old, "legacy-pve8", "confsync@pve!rw", false, 3, nil, nil), save(old, "legacy-pve8", store.ClusterTarget, 3, nil, nil),
st.SaveSettings(ctx, store.Settings{Kinds: model.Kinds, st.SaveSettings(ctx, store.Settings{Kinds: model.Kinds,
Policies: map[model.Kind]map[string]adapter.Policy{model.KindDatacenter: {"keyboard": adapter.Sync}}}), Policies: map[model.Kind]map[string]adapter.Policy{model.KindDatacenter: {"keyboard": adapter.Sync}}}),
} }
+137
View File
@@ -0,0 +1,137 @@
package engine
import (
"context"
"encoding/json"
"strings"
"testing"
"pve-confsync/internal/adapter"
"pve-confsync/internal/config"
"pve-confsync/internal/model"
"pve-confsync/internal/pve/pvetest"
)
const aliceHash = "$5$salt$aliceHashValue"
func accessRef(t *testing.T) *pvetest.Server {
ref := pvetest.New("9.1.4")
t.Cleanup(ref.Close)
ref.Groups["admins"] = map[string]any{"comment": "Admins"}
ref.Groups["ops"] = map[string]any{"comment": "Ops"}
ref.Roles["Ops"] = "VM.PowerMgmt,Sys.Audit"
ref.Users["alice@pve"] = map[string]any{"enable": 1, "email": "alice@example.org", "groups": "ops,admins"}
ref.Users["bob@pve"] = map[string]any{"enable": 1, "groups": "ops", "expire": 0}
ref.Users["carol@pam"] = map[string]any{"enable": 1}
ref.Shadow["alice"] = aliceHash
ref.Shadow["bob"] = "$5$salt$bobHash"
ref.Shadow["orphan"] = "$5$salt$orphan" // no account: left aside
return ref
}
var accessKinds = []model.Kind{model.KindGroup, model.KindRole, model.KindUser, model.KindPassword}
func TestAccountsAndPasswords(t *testing.T) {
ctx := context.Background()
ref := accessRef(t)
cli := pvetest.New("9.0.10")
defer cli.Close()
cli.Users["alice@pve"] = map[string]any{"enable": 1, "email": "old@example.org", "firstname": "Al"}
cli.Shadow["alice"] = "$5$salt$oldHash"
cfg := &config.Config{Kinds: accessKinds, Reference: config.Cluster{Name: "ref"}, Clients: []config.Client{{Cluster: config.Cluster{Name: "site"}}}}
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
cl := &cfg.Clients[0]
src, err := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning)
if err != nil {
t.Fatal(err)
}
for _, o := range src.Objects {
if o.ID == "carol@pam" || o.ID == "orphan@pve" || o.ID == "Administrator" || o.ID == "root@pam" {
t.Fatalf("%s must not be read", o.Key())
}
}
cc := client(t, cli, "site")
cur, err := Read(ctx, cc, cfg.Kinds, adapter.ReadCurrent)
if err != nil {
t.Fatal(err)
}
plan := MakePlan(cfg, cl, src.Objects, cur.Objects)
if len(plan.Errors) > 0 {
t.Fatal(plan.Errors)
}
// 2 groups + 1 role + bob created, alice updated + 2 passwords
if len(plan.Changes) != 7 {
t.Fatalf("changes = %+v", plan.Changes)
}
b, _ := json.Marshal(plan)
if strings.Contains(string(b), "aliceHash") || strings.Contains(string(b), "oldHash") {
t.Fatalf("password hash in the plan JSON: %s", b)
}
res := Apply(ctx, cc, plan)
if res.Err != nil {
t.Fatalf("apply: %v (%+v)", res.Err, res.Steps)
}
if cli.Shadow["alice"] != aliceHash || cli.Shadow["bob"] != "$5$salt$bobHash" {
t.Fatalf("shadow = %v", cli.Shadow)
}
a := cli.Users["alice@pve"]
if a["email"] != "alice@example.org" || a["groups"] != "admins,ops" || a["firstname"] != nil {
t.Fatalf("alice = %v", a)
}
if cli.Roles["Ops"] != "Sys.Audit,VM.PowerMgmt" {
t.Fatalf("roles = %v", cli.Roles)
}
replan := func(actual []model.Object) Plan { return MakePlan(cfg, cl, src.Objects, actual) }
if err := Verify(ctx, cc, replan, cfg.Kinds); err != nil {
t.Fatalf("verify: %v", err)
}
}
func TestPasswordSelectionPullsAccountAndGroups(t *testing.T) {
ctx := context.Background()
ref := accessRef(t)
cli := pvetest.New("9.1.0")
defer cli.Close()
cfg := &config.Config{Kinds: accessKinds, Clients: []config.Client{{Cluster: config.Cluster{Name: "site"}}}}
src, _ := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning)
cur, _ := Read(ctx, client(t, cli, "site"), cfg.Kinds, adapter.ReadCurrent)
p := Filter(MakePlan(cfg, &cfg.Clients[0], src.Objects, cur.Objects), []string{"pve-password/bob@pve"})
var keys []string
for _, c := range p.Changes {
keys = append(keys, c.Key())
}
if got := strings.Join(keys, " "); got != "pve-group/ops pve-user/bob@pve pve-password/bob@pve" {
t.Fatalf("selection = %s", got)
}
}
func TestMissingParentIsAnError(t *testing.T) {
ctx := context.Background()
ref := accessRef(t)
cli := pvetest.New("9.1.0")
defer cli.Close()
// groups excluded from the selection: alice cannot be created in them
cfg := &config.Config{Kinds: accessKinds, Clients: []config.Client{{Cluster: config.Cluster{Name: "site"}}},
Objects: map[model.Kind]config.Selection{model.KindGroup: {Exclude: []string{"*"}}}}
src, _ := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning)
cur, _ := Read(ctx, client(t, cli, "site"), cfg.Kinds, adapter.ReadCurrent)
plan := MakePlan(cfg, &cfg.Clients[0], src.Objects, cur.Objects)
if len(plan.Errors) != 3 || !strings.Contains(strings.Join(plan.Errors, "\n"), "pve-user/alice@pve: pve-group/admins absent") {
t.Fatalf("errors = %v", plan.Errors)
}
}
func TestPasswordsNeedSSH(t *testing.T) {
s := pvetest.New("9.1.0")
defer s.Close()
c, err := config.Cluster{Name: "c", URL: s.URL, Password: pvetest.Password, Insecure: true}.PVE()
if err != nil {
t.Fatal(err)
}
if _, err := Read(context.Background(), c, accessKinds, adapter.ReadCurrent); err == nil || !strings.Contains(err.Error(), "SSH") {
t.Fatalf("err = %v", err)
}
}
+78 -11
View File
@@ -4,6 +4,7 @@ package engine
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"sort" "sort"
"time" "time"
@@ -42,6 +43,9 @@ func Read(ctx context.Context, c *pve.Client, kinds []model.Kind, mode adapter.R
if need[model.KindVNet] { if need[model.KindVNet] {
need[model.KindZone] = true need[model.KindZone] = true
} }
if need[model.KindPassword] {
need[model.KindUser] = true
}
var all []model.Object var all []model.Object
for _, a := range adapter.All() { for _, a := range adapter.All() {
if !need[a.Kind()] { if !need[a.Kind()] {
@@ -74,21 +78,43 @@ const (
// Change is one planned action. Set holds the new values, Old the current ones. // Change is one planned action. Set holds the new values, Old the current ones.
type Change struct { type Change struct {
Kind model.Kind `json:"kind" yaml:"kind"` Kind model.Kind `json:"kind"`
ID string `json:"id" yaml:"id"` ID string `json:"id"`
Op Op `json:"op" yaml:"op"` Op Op `json:"op"`
Set map[string]any `json:"set,omitempty" yaml:"set,omitempty"` Set map[string]any `json:"set,omitempty"`
Unset []string `json:"unset,omitempty" yaml:"unset,omitempty"` Unset []string `json:"unset,omitempty"`
Old map[string]any `json:"old,omitempty" yaml:"old,omitempty"` Old map[string]any `json:"old,omitempty"`
Target model.Object `json:"-" yaml:"-"` Target model.Object `json:"-"`
}
// MarshalJSON hides the secret fields (password hashes): plans are shown in the
// UI and kept in the journal, the real values only live in memory until applied.
func (c Change) MarshalJSON() ([]byte, error) {
type plain Change
red := func(m map[string]any) map[string]any {
if m == nil {
return nil
}
out := make(map[string]any, len(m))
for k, v := range m {
if adapter.IsSensitive(c.Kind, k) {
v = adapter.Redact(v)
}
out[k] = v
}
return out
}
p := plain(c)
p.Set, p.Old = red(c.Set), red(c.Old)
return json.Marshal(p)
} }
// Plan is the list of changes for one client, in execution order. // Plan is the list of changes for one client, in execution order.
type Plan struct { type Plan struct {
Client string `json:"client" yaml:"client"` Client string `json:"client"`
Changes []Change `json:"changes" yaml:"changes"` Changes []Change `json:"changes"`
Errors []string `json:"errors,omitempty" yaml:"errors,omitempty"` Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty" yaml:"warnings,omitempty"` Warnings []string `json:"warnings,omitempty"`
} }
// HasSDN tells whether the plan writes SDN objects (lock + reload needed). // HasSDN tells whether the plan writes SDN objects (lock + reload needed).
@@ -187,6 +213,7 @@ func MakePlan(cfg *config.Config, cl *config.Client, ref, actual []model.Object)
} }
} }
} }
checkParents(&plan, cl.Name, actual)
// deletions last, dependents first (subnets, vnets, zones) // deletions last, dependents first (subnets, vnets, zones)
sort.SliceStable(deletes, func(i, j int) bool { sort.SliceStable(deletes, func(i, j int) bool {
if deletes[i].Kind != deletes[j].Kind { if deletes[i].Kind != deletes[j].Kind {
@@ -198,6 +225,46 @@ func MakePlan(cfg *config.Config, cl *config.Client, ref, actual []model.Object)
return plan return plan
} }
// checkParents blocks the objects whose parents (groups of an account, account
// of a password) are neither on the target nor created by the plan.
func checkParents(plan *Plan, client string, actual []model.Object) {
have := map[string]bool{}
for _, o := range actual {
have[o.Key()] = true
}
for _, c := range plan.Changes {
if c.Op == OpCreate {
have[c.Key()] = true
}
}
for _, c := range plan.Changes {
if c.Op == OpDelete {
continue
}
for _, p := range adapter.Parents(Desired(c)) {
if !have[p] {
plan.Errors = append(plan.Errors, fmt.Sprintf("%s: %s absent on target %s and not replicated", c.Key(), p, client))
}
}
}
}
// Desired is the object as it will be after the change (fields of the target
// with the new values).
func Desired(c Change) model.Object {
f := map[string]any{}
for k, v := range c.Target.Fields {
f[k] = v
}
for _, k := range c.Unset {
delete(f, k)
}
for k, v := range c.Set {
f[k] = v
}
return model.Object{Kind: c.Kind, ID: c.ID, Fields: f}
}
func kindRank(k model.Kind) int { func kindRank(k model.Kind) int {
for i, x := range model.Kinds { for i, x := range model.Kinds {
if x == k { if x == k {
+9 -3
View File
@@ -14,17 +14,23 @@ import (
"pve-confsync/internal/pve/pvetest" "pve-confsync/internal/pve/pvetest"
) )
// client connects to a fake cluster like the web UI does: API as root@pam,
// pinned certificate, SSH with the pinned host key.
func client(t *testing.T, s *pvetest.Server, name string) *pve.Client { func client(t *testing.T, s *pvetest.Server, name string) *pve.Client {
t.Helper() t.Helper()
sum := sha256.Sum256(s.Certificate().Raw) sum := sha256.Sum256(s.Certificate().Raw)
c, err := pve.New(pve.Options{Name: name, BaseURL: s.URL, TokenID: "confsync@pve!t", TokenSecret: "secret", host, port := s.SSHAddr()
Fingerprint: hex.EncodeToString(sum[:])}) c, err := config.Cluster{Name: name, URL: s.URL, User: "root@pam", Password: pvetest.Password,
Fingerprint: hex.EncodeToString(sum[:]), SSHHost: host, SSHPort: port, SSHHostKey: s.SSHHostKey()}.PVE()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
return c return c
} }
// sdnKinds: the kinds of the SDN and datacenter tests.
var sdnKinds = []model.Kind{model.KindDatacenter, model.KindZone, model.KindVNet, model.KindSubnet}
// reference: two zones (vlan, vxlan), one unsupported (evpn), vnets, subnets, tags. // reference: two zones (vlan, vxlan), one unsupported (evpn), vnets, subnets, tags.
func reference(t *testing.T) *pvetest.Server { func reference(t *testing.T) *pvetest.Server {
ref := pvetest.New("9.1.4") ref := pvetest.New("9.1.4")
@@ -49,7 +55,7 @@ func reference(t *testing.T) *pvetest.Server {
func testConfig() *config.Config { func testConfig() *config.Config {
return &config.Config{ return &config.Config{
Kinds: model.Kinds, Kinds: sdnKinds,
Clients: []config.Client{{ Clients: []config.Client{{
Cluster: config.Cluster{Name: "site-a"}, Cluster: config.Cluster{Name: "site-a"},
Mapping: map[model.Kind]map[string]map[string]any{ Mapping: map[model.Kind]map[string]map[string]any{
+9
View File
@@ -7,6 +7,7 @@ import (
"sort" "sort"
"strings" "strings"
"pve-confsync/internal/adapter"
"pve-confsync/internal/model" "pve-confsync/internal/model"
) )
@@ -50,6 +51,11 @@ func Filter(p Plan, keys []string) Plan {
if v, ok := c.Set["vnet"].(string); ok && byKey[string(model.KindVNet)+"/"+v].Op == OpCreate { if v, ok := c.Set["vnet"].(string); ok && byKey[string(model.KindVNet)+"/"+v].Op == OpCreate {
add(string(model.KindVNet) + "/" + v) add(string(model.KindVNet) + "/" + v)
} }
for _, p := range adapter.Parents(Desired(c)) {
if byKey[p].Op == OpCreate {
add(p)
}
}
case OpDelete: case OpDelete:
// children deleted in the same plan must go first // children deleted in the same plan must go first
for _, d := range p.Changes { for _, d := range p.Changes {
@@ -79,6 +85,9 @@ func Filter(p Plan, keys []string) Plan {
if v, ok := c.Set["vnet"].(string); ok { if v, ok := c.Set["vnet"].(string); ok {
blocking[string(model.KindVNet)+"/"+v] = true blocking[string(model.KindVNet)+"/"+v] = true
} }
for _, p := range adapter.Parents(Desired(c)) {
blocking[p] = true
}
} }
out := Plan{Client: p.Client, Warnings: p.Warnings} out := Plan{Client: p.Client, Warnings: p.Warnings}
// errors about other objects ("kind/id: ...") do not block this selection // errors about other objects ("kind/id: ...") do not block this selection
+180 -43
View File
@@ -1,6 +1,8 @@
// Package pve is a minimal Proxmox VE API client: API token auth, generic JSON, // Package pve is a minimal Proxmox VE API client: ticket auth with the root@pam
// form-encoded writes, task polling and version check. No typed SDK on purpose: // credentials, generic JSON, form-encoded writes, task polling and version check.
// objects stay generic (map[string]any) and are interpreted by the adapters. // No typed SDK on purpose: objects stay generic (map[string]any) and are
// interpreted by the adapters. What the API does not cover (the password hashes
// of the pve realm) goes through a Shell, an SSH session on a node.
package pve package pve
import ( import (
@@ -15,10 +17,10 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -26,23 +28,42 @@ import (
type Options struct { type Options struct {
Name string // cluster name in the config (for messages only) Name string // cluster name in the config (for messages only)
BaseURL string // https://host:8006 BaseURL string // https://host:8006
TokenID string // user@realm!tokenname User string // user@realm, root@pam by default
TokenSecret string // never logged Password string // never logged
CAFile string // PEM bundle to trust (optional) CAPEM []byte // PEM bundle to trust (optional)
CAPEM []byte // PEM bundle given inline (web UI); CAFile wins when both are set
Fingerprint string // SHA-256 of the leaf certificate, "AA:BB:..." (optional, pinning) Fingerprint string // SHA-256 of the leaf certificate, "AA:BB:..." (optional, pinning)
Insecure bool // skip TLS verification (lab only) Insecure bool // skip TLS verification (lab only)
Timeout time.Duration Timeout time.Duration
Shell Shell // SSH access to a node (optional)
} }
// Shell runs a command on a node of the cluster (SSH), stdin fed with in.
type Shell interface {
Run(ctx context.Context, cmd string, in []byte) ([]byte, error)
}
// ticketTTL: PVE tickets live 2 hours (pve-access-control AccessControl.pm,
// $ticket_lifetime); a new one is requested well before.
const ticketTTL = 90 * time.Minute
// Client talks to the PVE API of one cluster. // Client talks to the PVE API of one cluster.
type Client struct { type Client struct {
name string name string
base *url.URL base *url.URL
auth string user string
http *http.Client password string
http *http.Client
shell Shell
mu sync.Mutex
ticket string
csrf string
ticketAt time.Time
} }
// ErrNoShell: the operation needs an SSH access to a node.
var ErrNoShell = errors.New("SSH access required")
// APIError is a non-2xx answer of the API. // APIError is a non-2xx answer of the API.
type APIError struct { type APIError struct {
Method, Path string Method, Path string
@@ -75,13 +96,40 @@ func New(o Options) (*Client, error) {
if err != nil || u.Scheme == "" || u.Host == "" { if err != nil || u.Scheme == "" || u.Host == "" {
return nil, fmt.Errorf("cluster %s: invalid url %q", o.Name, o.BaseURL) return nil, fmt.Errorf("cluster %s: invalid url %q", o.Name, o.BaseURL)
} }
if o.TokenID == "" || o.TokenSecret == "" { if o.User == "" {
return nil, fmt.Errorf("cluster %s: token id and secret are required", o.Name) o.User = "root@pam"
} }
if o.Password == "" {
return nil, fmt.Errorf("cluster %s: the password of %s is required", o.Name, o.User)
}
tlsCfg, err := TLSConfig(o.Name, o.CAPEM, o.Fingerprint, o.Insecure)
if err != nil {
return nil, err
}
timeout := o.Timeout
if timeout == 0 {
timeout = 30 * time.Second
}
return &Client{
name: o.Name,
base: u,
user: o.User,
password: o.Password,
shell: o.Shell,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{TLSClientConfig: tlsCfg, Proxy: http.ProxyFromEnvironment},
},
}, nil
}
// TLSConfig builds the TLS settings of a cluster: certificate pinning (self-signed
// PVE certificates), a CA bundle, the system authorities, or no verification.
func TLSConfig(name string, caPEM []byte, fingerprint string, insecure bool) (*tls.Config, error) {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
switch { switch {
case o.Fingerprint != "": case fingerprint != "":
want := normalizeFingerprint(o.Fingerprint) want := normalizeFingerprint(fingerprint)
// Self-signed PVE certificates: pin the leaf certificate instead of a CA. // Self-signed PVE certificates: pin the leaf certificate instead of a CA.
tlsCfg.InsecureSkipVerify = true tlsCfg.InsecureSkipVerify = true
tlsCfg.VerifyPeerCertificate = func(raw [][]byte, _ [][]*x509.Certificate) error { tlsCfg.VerifyPeerCertificate = func(raw [][]byte, _ [][]*x509.Certificate) error {
@@ -90,49 +138,112 @@ func New(o Options) (*Client, error) {
} }
sum := sha256.Sum256(raw[0]) sum := sha256.Sum256(raw[0])
if got := hex.EncodeToString(sum[:]); got != want { if got := hex.EncodeToString(sum[:]); got != want {
return fmt.Errorf("certificate fingerprint mismatch (got %s)", got) return fmt.Errorf("certificate fingerprint mismatch (got %s)", FormatFingerprint(got))
} }
return nil return nil
} }
case o.CAFile != "" || len(o.CAPEM) > 0: case len(caPEM) > 0:
pem, src := o.CAPEM, "the CA certificate"
if o.CAFile != "" {
var err error
if pem, err = os.ReadFile(o.CAFile); err != nil {
return nil, fmt.Errorf("cluster %s: %w", o.Name, err)
}
src = o.CAFile
}
pool := x509.NewCertPool() pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) { if !pool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("cluster %s: no certificate in %s", o.Name, src) return nil, fmt.Errorf("cluster %s: no certificate in the CA bundle", name)
} }
tlsCfg.RootCAs = pool tlsCfg.RootCAs = pool
case o.Insecure: case insecure:
tlsCfg.InsecureSkipVerify = true tlsCfg.InsecureSkipVerify = true
} }
timeout := o.Timeout return tlsCfg, nil
if timeout == 0 { }
timeout = 30 * time.Second
// PeerFingerprint connects to the API and returns the SHA-256 fingerprint of the
// certificate it presents, without verifying it: the administrator pins it on
// the first connection (trust on first use) and sees it in the cluster list.
func PeerFingerprint(ctx context.Context, baseURL string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil || u.Host == "" {
return "", fmt.Errorf("invalid url %q", baseURL)
} }
return &Client{ host := u.Host
name: o.Name, if u.Port() == "" {
base: u, host += ":8006"
auth: "PVEAPIToken=" + o.TokenID + "=" + o.TokenSecret, }
http: &http.Client{ d := tls.Dialer{Config: &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true}}
Timeout: timeout, conn, err := d.DialContext(ctx, "tcp", host)
Transport: &http.Transport{TLSClientConfig: tlsCfg, Proxy: http.ProxyFromEnvironment}, if err != nil {
}, return "", err
}, nil }
defer conn.Close()
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
if len(certs) == 0 {
return "", errors.New("no peer certificate")
}
sum := sha256.Sum256(certs[0].Raw)
return FormatFingerprint(hex.EncodeToString(sum[:])), nil
}
// FormatFingerprint prints a SHA-256 fingerprint like PVE does ("AA:BB:...").
func FormatFingerprint(fp string) string {
fp = strings.ToUpper(normalizeFingerprint(fp))
var b strings.Builder
for i := 0; i < len(fp); i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(fp[i:min(i+2, len(fp))])
}
return b.String()
} }
// Name returns the cluster name given in the options. // Name returns the cluster name given in the options.
func (c *Client) Name() string { return c.name } func (c *Client) Name() string { return c.name }
// Shell returns the SSH access to a node, or ErrNoShell.
func (c *Client) Shell() (Shell, error) {
if c.shell == nil {
return nil, fmt.Errorf("cluster %s: %w", c.name, ErrNoShell)
}
return c.shell, nil
}
func normalizeFingerprint(s string) string { func normalizeFingerprint(s string) string {
return strings.ToLower(strings.NewReplacer(":", "", " ", "").Replace(s)) return strings.ToLower(strings.NewReplacer(":", "", " ", "").Replace(s))
} }
// login gets a ticket and a CSRF token (POST /access/ticket). Accounts with a
// second factor are refused: the tool cannot answer the TFA challenge.
func (c *Client) login(ctx context.Context) error {
var res struct {
Ticket string `json:"ticket"`
CSRF string `json:"CSRFPreventionToken"`
NeedTFA any `json:"NeedTFA"`
}
f := url.Values{"username": {c.user}, "password": {c.password}}
if err := c.send(ctx, http.MethodPost, "access/ticket", nil, f, &res, false); err != nil {
var ae *APIError
if errors.As(err, &ae) && ae.Status == http.StatusUnauthorized {
return fmt.Errorf("cluster %s: authentication of %s failed", c.name, c.user)
}
return err
}
if Str(res.NeedTFA) == "1" {
return fmt.Errorf("cluster %s: %s has a second factor (TFA), not supported", c.name, c.user)
}
if res.Ticket == "" {
return fmt.Errorf("cluster %s: no ticket in the login answer", c.name)
}
c.ticket, c.csrf, c.ticketAt = res.Ticket, res.CSRF, time.Now()
return nil
}
// Login checks the credentials (and renews the ticket when needed).
func (c *Client) Login(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.ticket != "" && time.Since(c.ticketAt) < ticketTTL {
return nil
}
return c.login(ctx)
}
// Get reads path (relative to /api2/json) and decodes "data" into out. // Get reads path (relative to /api2/json) and decodes "data" into out.
func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error { func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error {
return c.do(ctx, http.MethodGet, path, query, nil, out) return c.do(ctx, http.MethodGet, path, query, nil, out)
@@ -153,6 +264,25 @@ func (c *Client) Delete(ctx context.Context, path string, params url.Values, out
} }
func (c *Client) do(ctx context.Context, method, path string, query, form url.Values, out any) error { func (c *Client) do(ctx context.Context, method, path string, query, form url.Values, out any) error {
if err := c.Login(ctx); err != nil {
return err
}
err := c.send(ctx, method, path, query, form, out, true)
var ae *APIError
if errors.As(err, &ae) && ae.Status == http.StatusUnauthorized {
// ticket expired early (key rotation on the cluster): log in again once
c.mu.Lock()
c.ticket = ""
c.mu.Unlock()
if err := c.Login(ctx); err != nil {
return err
}
err = c.send(ctx, method, path, query, form, out, true)
}
return err
}
func (c *Client) send(ctx context.Context, method, path string, query, form url.Values, out any, auth bool) error {
u := *c.base u := *c.base
u.Path = "/api2/json/" + strings.TrimLeft(path, "/") u.Path = "/api2/json/" + strings.TrimLeft(path, "/")
if len(query) > 0 { if len(query) > 0 {
@@ -166,14 +296,21 @@ func (c *Client) do(ctx context.Context, method, path string, query, form url.Va
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Authorization", c.auth) if auth {
c.mu.Lock()
req.AddCookie(&http.Cookie{Name: "PVEAuthCookie", Value: c.ticket})
if method != http.MethodGet {
req.Header.Set("CSRFPreventionToken", c.csrf)
}
c.mu.Unlock()
}
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
if form != nil { if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} }
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
// url.Error contains the URL only, never the Authorization header. // url.Error contains the URL only, never the ticket nor the password.
return fmt.Errorf("cluster %s: %w", c.name, err) return fmt.Errorf("cluster %s: %w", c.name, err)
} }
defer resp.Body.Close() defer resp.Body.Close()
+187
View File
@@ -0,0 +1,187 @@
package pvetest
import (
"net/http"
"sort"
"strings"
)
// access fakes /access/groups, /access/roles and /access/users (user.cfg).
func (s *Server) access(w http.ResponseWriter, r *http.Request, method string, seg []string) {
f := r.PostForm
if method == http.MethodDelete {
f = r.URL.Query()
}
switch {
case len(seg) == 2 && seg[1] == "groups" && method == "GET":
var out []any
for _, id := range sortedKeys(s.Groups) {
o := map[string]any{"groupid": id, "users": strings.Join(s.members(id), ",")}
for k, v := range s.Groups[id] {
o[k] = v
}
out = append(out, o)
}
reply(w, out)
case len(seg) == 2 && seg[1] == "groups" && method == "POST":
id := f.Get("groupid")
if s.Groups[id] != nil {
fail(w, 500, "create group failed: group '"+id+"' already exists")
return
}
s.Groups[id] = setFields(map[string]any{}, f, "groupid")
reply(w, nil)
case len(seg) == 3 && seg[1] == "groups":
g := s.Groups[seg[2]]
if g == nil {
fail(w, 500, "group '"+seg[2]+"' does not exist")
return
}
switch method {
case "PUT":
setFields(g, f)
case "DELETE":
delete(s.Groups, seg[2])
for _, u := range s.Users {
u["groups"] = strings.Join(remove(split(u["groups"]), seg[2]), ",")
}
}
reply(w, nil)
case len(seg) == 2 && seg[1] == "roles" && method == "GET":
out := []any{
map[string]any{"roleid": "Administrator", "privs": "Sys.Audit,Sys.Modify", "special": 1},
map[string]any{"roleid": "PVEAuditor", "privs": "Sys.Audit", "special": 1},
}
for _, id := range sortedKeys(s.Roles) {
out = append(out, map[string]any{"roleid": id, "privs": s.Roles[id], "special": 0})
}
reply(w, out)
case len(seg) == 2 && seg[1] == "roles" && method == "POST":
s.Roles[f.Get("roleid")] = f.Get("privs")
reply(w, nil)
case len(seg) == 3 && seg[1] == "roles":
if _, ok := s.Roles[seg[2]]; !ok {
fail(w, 500, "role '"+seg[2]+"' does not exist")
return
}
if method == "DELETE" {
delete(s.Roles, seg[2])
} else {
s.Roles[seg[2]] = f.Get("privs")
}
reply(w, nil)
case len(seg) == 2 && seg[1] == "users" && method == "GET":
var out []any
for _, id := range sortedKeys(s.Users) {
realm := id[strings.LastIndex(id, "@")+1:]
o := map[string]any{"userid": id, "realm-type": realm, "tokens": []any{}}
for k, v := range s.Users[id] {
o[k] = v
}
out = append(out, o)
}
reply(w, out)
case len(seg) == 2 && seg[1] == "users" && method == "POST":
id := f.Get("userid")
if s.Users[id] != nil {
fail(w, 500, "create user failed: user '"+id+"' already exists")
return
}
if !s.groupsExist(w, f.Get("groups")) {
return
}
s.Users[id] = setFields(map[string]any{"enable": 1}, f, "userid", "password")
reply(w, nil)
case len(seg) == 3 && seg[1] == "users":
u := s.Users[seg[2]]
if u == nil {
fail(w, 500, "user '"+seg[2]+"' does not exist")
return
}
switch method {
case "PUT":
if !s.groupsExist(w, f.Get("groups")) {
return
}
setFields(u, f)
case "DELETE":
delete(s.Users, seg[2])
delete(s.Shadow, strings.TrimSuffix(seg[2], "@pve"))
}
reply(w, nil)
default:
fail(w, 501, "not implemented in fake: "+method+" "+strings.Join(seg, "/"))
}
}
func (s *Server) groupsExist(w http.ResponseWriter, groups string) bool {
for _, g := range split(groups) {
if s.Groups[g] == nil {
fail(w, 500, "no such group '"+g+"'")
return false
}
}
return true
}
func (s *Server) members(group string) []string {
var out []string
for id, u := range s.Users {
for _, g := range split(u["groups"]) {
if g == group {
out = append(out, id)
}
}
}
sort.Strings(out)
return out
}
// setFields copies form values; an empty value removes the field (like user.cfg).
func setFields(m map[string]any, f map[string][]string, skip ...string) map[string]any {
sk := map[string]bool{}
for _, k := range skip {
sk[k] = true
}
for k, v := range f {
if sk[k] || len(v) == 0 {
continue
}
if v[0] == "" {
delete(m, k)
continue
}
m[k] = v[0]
}
return m
}
func split(v any) []string {
s, _ := v.(string)
var out []string
for _, x := range strings.Split(s, ",") {
if x = strings.TrimSpace(x); x != "" {
out = append(out, x)
}
}
return out
}
func remove(l []string, x string) []string {
out := l[:0]
for _, e := range l {
if e != x {
out = append(out, e)
}
}
return out
}
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
+53 -21
View File
@@ -1,7 +1,9 @@
// Package pvetest is an in-memory fake of the subset of the PVE 9.x API used by // Package pvetest is an in-memory fake of the subset of the PVE 9.x API used by
// pve-confsync, for tests. It mimics the behaviours that matter for the engine: // pve-confsync, for tests and the demo. It mimics the behaviours that matter for
// parsed datacenter options on GET, property strings on PUT, SDN pending/running // the engine: ticket login of root@pam, parsed datacenter options on GET,
// configurations, global SDN lock with token, rollback and reload task. // property strings on PUT, SDN pending/running configurations, global SDN lock
// with token, rollback and reload task, groups, roles and accounts of user.cfg,
// and an SSH server serving /etc/pve/priv/shadow.cfg (see ssh.go).
package pvetest package pvetest
import ( import (
@@ -43,29 +45,47 @@ func (s SDN) clone() SDN {
return c return c
} }
// Password of root@pam on the fake clusters (API and SSH).
const Password = "root-secret"
// Server is a fake PVE cluster. // Server is a fake PVE cluster.
type Server struct { type Server struct {
*httptest.Server *httptest.Server
mu sync.Mutex mu sync.Mutex
Version string // pve-manager version, e.g. 9.1.4 Version string // pve-manager version, e.g. 9.1.4
Options map[string]string // datacenter.cfg as written (property strings) Password string // root@pam password
Pending SDN Options map[string]string // datacenter.cfg as written (property strings)
Running SDN Pending SDN
Lock string Running SDN
Perms map[string]map[string]any Lock string
FailOn string // "METHOD path" answering 500, to test rollbacks Groups map[string]map[string]any // groupid -> fields (comment)
Calls []string // "METHOD path" of every write Roles map[string]string // custom roles: roleid -> privs
locks int Users map[string]map[string]any // userid -> fields (groups as "a,b")
Shadow map[string]string // user without realm -> hash (priv/shadow.cfg)
FailOn string // "METHOD path" answering 500, to test rollbacks
Calls []string // "METHOD path" of every write, "SSH write" for shadow.cfg
locks int
ssh *sshServer
} }
// New starts a fake cluster. const ticket, csrf = "PVE:root@pam:FAKE::sig", "fake-csrf"
// New starts a fake cluster (API in HTTPS and SSH).
func New(version string) *Server { func New(version string) *Server {
s := &Server{Version: version, Options: map[string]string{}, Pending: newSDN(), Running: newSDN(), s := &Server{Version: version, Password: Password, Options: map[string]string{}, Pending: newSDN(), Running: newSDN(),
Perms: map[string]map[string]any{}} Groups: map[string]map[string]any{}, Roles: map[string]string{}, Shadow: map[string]string{},
Users: map[string]map[string]any{"root@pam": {"enable": 1, "email": "root@localhost"}}}
s.Server = httptest.NewTLSServer(http.HandlerFunc(s.handle)) s.Server = httptest.NewTLSServer(http.HandlerFunc(s.handle))
s.ssh = startSSH(s)
return s return s
} }
// Close stops the API and the SSH server.
func (s *Server) Close() {
s.ssh.close()
s.Server.Close()
}
// Commit makes the pending SDN configuration the running one (like an apply). // Commit makes the pending SDN configuration the running one (like an apply).
func (s *Server) Commit() { func (s *Server) Commit() {
s.mu.Lock() s.mu.Lock()
@@ -87,12 +107,24 @@ func fail(w http.ResponseWriter, code int, msg string) {
func (s *Server) handle(w http.ResponseWriter, r *http.Request) { func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if !strings.HasPrefix(r.Header.Get("Authorization"), "PVEAPIToken=") { _ = r.ParseForm()
p := strings.TrimPrefix(r.URL.Path, "/api2/json/")
if r.Method == http.MethodPost && p == "access/ticket" {
if r.PostForm.Get("username") != "root@pam" || r.PostForm.Get("password") != s.Password {
fail(w, 401, "authentication failure")
return
}
reply(w, map[string]any{"ticket": ticket, "CSRFPreventionToken": csrf, "username": "root@pam"})
return
}
if c, err := r.Cookie("PVEAuthCookie"); err != nil || c.Value != ticket {
fail(w, 401, "no ticket") fail(w, 401, "no ticket")
return return
} }
_ = r.ParseForm() if r.Method != http.MethodGet && r.Header.Get("CSRFPreventionToken") != csrf {
p := strings.TrimPrefix(r.URL.Path, "/api2/json/") fail(w, 401, "Permission denied - invalid csrf token")
return
}
seg := strings.Split(p, "/") seg := strings.Split(p, "/")
key := r.Method + " " + p key := r.Method + " " + p
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
@@ -130,8 +162,8 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
reply(w, out) reply(w, out)
case r.Method == "GET" && len(seg) == 5 && seg[0] == "nodes" && seg[2] == "tasks" && seg[4] == "status": case r.Method == "GET" && len(seg) == 5 && seg[0] == "nodes" && seg[2] == "tasks" && seg[4] == "status":
reply(w, map[string]any{"status": "stopped", "exitstatus": "OK"}) reply(w, map[string]any{"status": "stopped", "exitstatus": "OK"})
case key == "GET access/permissions": case strings.HasPrefix(p, "access/"):
reply(w, s.Perms) s.access(w, r, r.Method, seg)
case key == "GET cluster/options": case key == "GET cluster/options":
reply(w, s.parsedOptions()) reply(w, s.parsedOptions())
case key == "PUT cluster/options": case key == "PUT cluster/options":
+143
View File
@@ -0,0 +1,143 @@
package pvetest
import (
"bufio"
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/binary"
"fmt"
"io"
"net"
"strings"
"golang.org/x/crypto/ssh"
)
// sshServer answers the commands pve-confsync runs on a node: pveversion, the
// read of priv/shadow.cfg and its locked write (adapter password).
type sshServer struct {
ln net.Listener
key ssh.PublicKey
}
func startSSH(s *Server) *sshServer {
_, priv, _ := ed25519.GenerateKey(rand.Reader)
signer, _ := ssh.NewSignerFromKey(priv)
cfg := &ssh.ServerConfig{
PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
s.mu.Lock()
defer s.mu.Unlock()
if c.User() == "root" && string(pass) == s.Password {
return nil, nil
}
return nil, fmt.Errorf("denied")
},
}
cfg.AddHostKey(signer)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
srv := &sshServer{ln: ln, key: signer.PublicKey()}
go func() {
for {
nc, err := ln.Accept()
if err != nil {
return
}
go s.serveSSH(nc, cfg)
}
}()
return srv
}
func (x *sshServer) close() { x.ln.Close() }
// SSHAddr returns the host and port of the fake SSH server.
func (s *Server) SSHAddr() (string, int) {
a := s.ssh.ln.Addr().(*net.TCPAddr)
return a.IP.String(), a.Port
}
// SSHHostKey returns the host key of the fake node (authorized_keys format).
func (s *Server) SSHHostKey() string {
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(s.ssh.key)))
}
func (s *Server) serveSSH(nc net.Conn, cfg *ssh.ServerConfig) {
_, chans, reqs, err := ssh.NewServerConn(nc, cfg)
if err != nil {
nc.Close()
return
}
go ssh.DiscardRequests(reqs)
for nch := range chans {
if nch.ChannelType() != "session" {
_ = nch.Reject(ssh.UnknownChannelType, "session only")
continue
}
ch, reqs, err := nch.Accept()
if err != nil {
continue
}
go func() {
defer ch.Close()
for req := range reqs {
if req.Type != "exec" {
_ = req.Reply(false, nil)
continue
}
var p struct{ Cmd string }
_ = ssh.Unmarshal(req.Payload, &p)
_ = req.Reply(true, nil)
status := s.exec(p.Cmd, ch, ch, ch.Stderr())
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, uint32(status))
_, _ = ch.SendRequest("exit-status", false, b)
return
}
}()
}
}
func (s *Server) exec(cmd string, in io.Reader, out, errw io.Writer) int {
switch {
case strings.HasPrefix(cmd, "pveversion"):
fmt.Fprintf(out, "pve-manager/%s/abcdef (running kernel: 6.14.8-2-pve)\n", s.Version)
return 0
case strings.HasPrefix(cmd, "cat /etc/pve/priv/shadow.cfg"):
s.mu.Lock()
defer s.mu.Unlock()
for _, u := range sortedKeys(s.Shadow) {
fmt.Fprintf(out, "%s:%s:\n", u, s.Shadow[u])
}
return 0
case strings.Contains(cmd, "confsync-shadow-write"):
data, _ := io.ReadAll(in)
s.mu.Lock()
defer s.mu.Unlock()
s.Calls = append(s.Calls, "SSH write shadow.cfg")
if s.FailOn == "SSH write shadow.cfg" {
fmt.Fprintln(errw, "injected failure")
return 1
}
sc := bufio.NewScanner(bytes.NewReader(data))
for sc.Scan() {
p := strings.SplitN(sc.Text(), "\t", 3)
switch {
case len(p) == 3 && p[0] == "set":
s.Shadow[p[1]] = p[2]
case len(p) >= 2 && p[0] == "del":
delete(s.Shadow, p[1])
default:
fmt.Fprintln(errw, "invalid operation")
return 1
}
}
fmt.Fprintln(out, "ok")
return 0
}
fmt.Fprintf(errw, "fake: unknown command %q\n", cmd)
return 127
}
+138 -109
View File
@@ -7,13 +7,13 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"gopkg.in/yaml.v3" "pve-confsync/internal/adapter"
"pve-confsync/internal/config" "pve-confsync/internal/config"
"pve-confsync/internal/engine" "pve-confsync/internal/engine"
"pve-confsync/internal/model" "pve-confsync/internal/model"
@@ -33,7 +33,13 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
if s.state == nil || r.URL.Query().Get("refresh") == "1" { if s.state == nil || r.URL.Query().Get("refresh") == "1" {
cfg, err := s.store.Config(r.Context()) cfg, err := s.store.Config(r.Context())
if errors.Is(err, store.ErrNoReference) { if errors.Is(err, store.ErrNoReference) {
writeJSON(w, http.StatusOK, map[string]any{"setup": true}) // nothing to compare yet: no cluster at all, or no primary chosen
cls, _ := s.store.Clusters(r.Context())
setup := "clusters"
if len(cls) > 0 {
setup = "primary"
}
writeJSON(w, http.StatusOK, map[string]any{"setup": setup})
return return
} }
if err != nil { if err != nil {
@@ -78,11 +84,12 @@ func (s *Server) remember(st *service.State, all bool) {
// ---- dry-run and apply (phase 2) ---- // ---- dry-run and apply (phase 2) ----
type selection struct { type selection struct {
Scope string `json:"scope"` // global, client or object Scope string `json:"scope"` // global (several targets), client (one target) or object
Targets []string `json:"targets"` // global scope: the targets (empty: all of them)
Client string `json:"client"` // client and object scopes Client string `json:"client"` // client and object scopes
Objects []string `json:"objects"` // object scope: "kind/id" Objects []string `json:"objects"` // object scope: "kind/id"
Digests map[string]string `json:"digests"` // apply: digest of each reviewed plan Digests map[string]string `json:"digests"` // apply: digest of each reviewed plan
Confirm string `json:"confirm"` // apply with SDN changes: client name (reference name for global) Confirm string `json:"confirm"` // apply with SDN changes: target name (primary name for global)
} }
func (sel *selection) check() error { func (sel *selection) check() error {
@@ -90,11 +97,12 @@ func (sel *selection) check() error {
case "global": case "global":
sel.Client, sel.Objects = "", nil sel.Client, sel.Objects = "", nil
case "client": case "client":
sel.Objects = nil sel.Objects, sel.Targets = nil, nil
if sel.Client == "" { if sel.Client == "" {
return errors.New("client required") return errors.New("client required")
} }
case "object": case "object":
sel.Targets = nil
if sel.Client == "" || len(sel.Objects) == 0 { if sel.Client == "" || len(sel.Objects) == 0 {
return errors.New("client and objects required") return errors.New("client and objects required")
} }
@@ -106,26 +114,29 @@ func (sel *selection) check() error {
func (sel selection) clients() []string { func (sel selection) clients() []string {
if sel.Client == "" { if sel.Client == "" {
return nil return sel.Targets
} }
return []string{sel.Client} return []string{sel.Client}
} }
// all tells whether the selection reads every target (the cached comparison is replaced).
func (sel selection) all() bool { return sel.Scope == "global" && len(sel.Targets) == 0 }
// plan reads the clusters and returns the plans of the selection. // plan reads the clusters and returns the plans of the selection.
func (s *Server) plan(ctx context.Context, sel selection) (*config.Config, *service.State, []service.ClientPlan, error) { func (s *Server) plan(ctx context.Context, sel selection) (*config.Config, *service.State, []service.ClientPlan, error) {
cfg, err := s.store.Config(ctx) cfg, err := s.store.Config(ctx)
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
if sel.Client != "" { for _, c := range sel.clients() {
if _, ok := cfg.Client(sel.Client); !ok { if _, ok := cfg.Client(c); !ok {
return nil, nil, nil, fmt.Errorf("unknown client %q", sel.Client) return nil, nil, nil, fmt.Errorf("unknown target %q", c)
} }
} }
rctx, cancel := context.WithTimeout(ctx, readTimeout) rctx, cancel := context.WithTimeout(ctx, readTimeout)
defer cancel() defer cancel()
st := service.Analyze(rctx, cfg, sel.clients()) st := service.Analyze(rctx, cfg, sel.clients())
s.remember(st, sel.Scope == "global") s.remember(st, sel.all())
return cfg, st, st.Plans(sel.Objects), nil return cfg, st, st.Plans(sel.Objects), nil
} }
@@ -185,7 +196,10 @@ func target(sel selection) string {
case "object": case "object":
return sel.Client + ": " + strings.Join(sel.Objects, ", ") return sel.Client + ": " + strings.Join(sel.Objects, ", ")
} }
return "global" if len(sel.Targets) > 0 {
return "cibles " + strings.Join(sel.Targets, ", ")
}
return "toutes les cibles"
} }
// dryRunSummary counts the changes: "3 changes on 2 clients", or the errors. // dryRunSummary counts the changes: "3 changes on 2 clients", or the errors.
@@ -203,11 +217,11 @@ func dryRunSummary(plans []service.ClientPlan) (string, string) {
} }
switch { switch {
case blocked > 0: case blocked > 0:
return store.RunBlocked, fmt.Sprintf("%d changement(s) sur %d client(s), %d client(s) en erreur", changes, clients, blocked) return store.RunBlocked, fmt.Sprintf("%d changement(s) sur %d cible(s), %d cible(s) en erreur", changes, clients, blocked)
case changes == 0: case changes == 0:
return store.RunNoop, "aucun écart" return store.RunNoop, "aucun écart"
} }
return store.RunOK, fmt.Sprintf("%d changement(s) sur %d client(s)", changes, clients) return store.RunOK, fmt.Sprintf("%d changement(s) sur %d cible(s)", changes, clients)
} }
// handleApply recomputes the plans, checks that they are the ones reviewed in the // handleApply recomputes the plans, checks that they are the ones reviewed in the
@@ -337,10 +351,10 @@ func (s *Server) runApply(r *http.Request, id Identity, runID int64, sel selecti
} }
} }
status := store.RunOK status := store.RunOK
summary := fmt.Sprintf("%d client(s) synchronisé(s) et vérifié(s)", done) summary := fmt.Sprintf("%d cible(s) synchronisée(s) et vérifiée(s)", done)
if failed > 0 { if failed > 0 {
status = store.RunError status = store.RunError
summary = fmt.Sprintf("%d client(s) en échec, %d synchronisé(s)", failed, done) summary = fmt.Sprintf("%d cible(s) en échec, %d synchronisée(s)", failed, done)
} }
save(status, summary) save(status, summary)
s.invalidate() s.invalidate()
@@ -387,8 +401,8 @@ func (s *Server) handleClusters(w http.ResponseWriter, r *http.Request) {
} }
// connection details are for the administrators // connection details are for the administrators
if identity(r).Role != store.RoleAdmin { if identity(r).Role != store.RoleAdmin {
for i := range cls { for i, c := range cls {
cls[i] = store.Cluster{Name: cls[i].Name, Reference: cls[i].Reference, URL: cls[i].URL, Mapping: cls[i].Mapping, Policies: cls[i].Policies} cls[i] = store.Cluster{Name: c.Name, Role: c.Role, URL: c.URL, Mapping: c.Mapping, Policies: c.Policies, LastCheck: c.LastCheck}
} }
} }
writeJSON(w, http.StatusOK, map[string]any{"clusters": cls}) writeJSON(w, http.StatusOK, map[string]any{"clusters": cls})
@@ -409,9 +423,9 @@ func (s *Server) handleSaveCluster(w http.ResponseWriter, r *http.Request) {
return return
} }
err := s.store.SaveCluster(r.Context(), old, c) err := s.store.SaveCluster(r.Context(), old, c)
detail := fmt.Sprintf("url=%s token=%s reference=%t", c.URL, c.TokenID, c.Reference) detail := fmt.Sprintf("url=%s rôle=%s tls=%s ssh=%s:%d", c.URL, c.Role, c.TLS, c.SSHHost, c.SSHPort)
if c.Secret != "" { if c.Password != "" {
detail += " secret=modifié" detail += " mot de passe=modifié"
} }
if old != "" && old != c.Name { if old != "" && old != c.Name {
detail += " renommé depuis " + old detail += " renommé depuis " + old
@@ -425,19 +439,25 @@ func (s *Server) handleSaveCluster(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
} }
// validateCluster checks the mapping and policies of a cluster with the rules of // validateCluster checks the address and the mapping and policies of a cluster.
// the YAML configuration.
func validateCluster(c store.Cluster) error { func validateCluster(c store.Cluster) error {
if c.Fingerprint == "" && c.CAPEM == "" && !c.Insecure && !strings.HasPrefix(c.URL, "https://") { u, err := url.Parse(strings.TrimSpace(c.URL))
return errors.New("url: https:// expected") if err != nil || u.Scheme != "https" || u.Host == "" {
return errors.New("url: https://host:8006 expected")
}
if c.User != "" && c.User != "root@pam" {
return errors.New("user: root@pam expected (the same account is used over SSH)")
} }
if c.Timeout != "" { if c.Timeout != "" {
if _, err := time.ParseDuration(c.Timeout); err != nil { if _, err := time.ParseDuration(c.Timeout); err != nil {
return fmt.Errorf("timeout: %w", err) return fmt.Errorf("timeout: %w", err)
} }
} }
cfg := &config.Config{Kinds: model.Kinds, Reference: config.Cluster{Name: "\x00ref", Secret: "x"}, if c.SSHPort < 0 || c.SSHPort > 65535 {
Clients: []config.Client{{Cluster: config.Cluster{Name: c.Name, Secret: "x"}, Mapping: c.Mapping, Policies: c.Policies}}} return errors.New("ssh port: 1-65535 expected")
}
cfg := &config.Config{Kinds: model.Kinds, Reference: config.Cluster{Name: "\x00ref"},
Clients: []config.Client{{Cluster: config.Cluster{Name: c.Name}, Mapping: c.Mapping, Policies: c.Policies}}}
return cfg.Validate() return cfg.Validate()
} }
@@ -460,51 +480,116 @@ func (s *Server) handleDeleteCluster(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
} }
// handleCheckCluster tests the access, the version and the token privileges. // handleRole makes a cluster the primary, a target or disabled.
func (s *Server) handleCheckCluster(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRole(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name") var req struct {
cfg, err := s.store.Config(r.Context()) Role string `json:"role"`
if err != nil && !errors.Is(err, store.ErrNoReference) { }
writeError(w, http.StatusInternalServerError, err.Error(), "config") if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return return
} }
var cl config.Cluster name := r.PathValue("name")
write := true err := s.store.SetRole(r.Context(), name, req.Role)
s.audit(r, Identity{}, "cluster-role", name, err == nil, errText(err, req.Role))
switch { switch {
case cfg != nil && cfg.Reference.Name == name: case errors.Is(err, store.ErrNotFound):
cl, write = cfg.Reference, false writeError(w, http.StatusNotFound, err.Error(), "notfound")
case cfg != nil: return
c, ok := cfg.Client(name) case err != nil:
if !ok { writeError(w, http.StatusBadRequest, err.Error(), "invalid")
writeError(w, http.StatusNotFound, "unknown cluster", "notfound") return
}
s.invalidate()
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleCheckCluster tests the API (root@pam, PVE 9.x) and SSH. On the first
// connection the TLS certificate (mode "fp") and the SSH host key are recorded
// (trust on first use); trust=true accepts a new SSH host key.
func (s *Server) handleCheckCluster(w http.ResponseWriter, r *http.Request) {
var req struct {
Trust bool `json:"trust"`
}
if r.ContentLength > 0 {
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return return
} }
cl = c.Cluster }
default: name := r.PathValue("name")
writeError(w, http.StatusBadRequest, "define a reference cluster first", "config") c, cc, err := s.store.Cluster(r.Context(), name)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "unknown cluster", "notfound")
return return
} }
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel() defer cancel()
res := service.CheckCluster(ctx, cl, write, cfg.HasKind(model.KindDatacenter)) res := service.CheckCluster(ctx, cc, c.TLS == store.TLSPin, req.Trust)
s.audit(r, Identity{}, "cluster-check", name, res.Error == "", res.Error) chk := &store.Check{At: time.Now().UTC(), OK: res.OK, Error: res.Error, Warnings: res.Warnings,
Version: res.Version, API: res.API, SSH: res.SSH}
for _, n := range res.Nodes {
chk.Nodes = append(chk.Nodes, n.Name)
}
// a TLS certificate is pinned only once the API answered with it
fp := ""
if res.API {
fp = res.TLSFingerprint
}
if err := s.store.Pin(r.Context(), name, fp, res.SSHHostKey, chk); err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
detail := res.Error
if fp != "" {
detail += " certificat TLS enregistré " + fp
}
if res.SSHHostKey != "" {
detail += " clé SSH enregistrée " + res.SSHFingerprint
}
s.audit(r, Identity{}, "cluster-check", name, res.OK, strings.TrimSpace(detail))
if fp != "" || res.SSHHostKey != "" {
s.invalidate()
}
writeJSON(w, http.StatusOK, res) writeJSON(w, http.StatusOK, res)
} }
// handleForget clears the pinned certificate and SSH host key of a cluster
// (node reinstalled, new certificate): they are recorded again on the next test.
func (s *Server) handleForget(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
err := s.store.Forget(r.Context(), name)
s.audit(r, Identity{}, "cluster-forget", name, err == nil, errText(err, "empreinte TLS et clé SSH oubliées"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error(), "notfound")
return
}
s.invalidate()
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
st, err := s.store.Settings(r.Context()) st, err := s.store.Settings(r.Context())
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store") writeError(w, http.StatusInternalServerError, err.Error(), "store")
return return
} }
writeJSON(w, http.StatusOK, map[string]any{"settings": st, "allKinds": model.Kinds, "defaults": defaultPolicies()}) writeJSON(w, http.StatusOK, map[string]any{"settings": st, "allKinds": model.Kinds, "deps": adapter.KindDeps, "defaults": defaultPolicies()})
} }
// defaultPolicies describes the built-in policies, shown in the settings. // defaultPolicies describes the built-in policies, shown in the settings.
func defaultPolicies() map[model.Kind]string { func defaultPolicies() map[model.Kind]string {
return map[model.Kind]string{ return map[model.Kind]string{
model.KindDatacenter: "tag-style, registered-tags, user-tag-access, next-id : sync ; le reste : ignore ; webauthn, u2f, http_proxy : jamais", model.KindDatacenter: "tag-style, registered-tags, user-tag-access, next-id : sync ; le reste : ignore ; webauthn, u2f, http_proxy : jamais",
model.KindZone: "nodes, bridge, peers, fabric, dns, reversedns, dnszone : override (mapping du client) ; le reste : sync", model.KindGroup: "sync (commentaire ; les membres viennent des comptes)",
model.KindRole: "sync (rôles personnalisés ; les rôles intégrés ne sont pas touchés)",
model.KindUser: "sync (comptes du realm pve : nom, e-mail, groupes, expiration, activation…)",
model.KindPassword: "sync (hachage copié par SSH dans /etc/pve/priv/shadow.cfg, jamais affiché)",
model.KindZone: "nodes, bridge, peers, fabric, dns, reversedns, dnszone : override (mapping de la cible) ; le reste : sync",
model.KindVNet: "sync", model.KindVNet: "sync",
model.KindSubnet: "sync", model.KindSubnet: "sync",
} }
@@ -517,11 +602,11 @@ func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
return return
} }
cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune, cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune,
Reference: config.Cluster{Name: "\x00ref", Secret: "x"}} Reference: config.Cluster{Name: "\x00ref"}}
if len(cfg.Kinds) == 0 {
cfg.Kinds = model.Kinds
}
err := cfg.Validate() err := cfg.Validate()
if err == nil && len(st.Kinds) == 0 {
err = errors.New("select at least one kind")
}
if err == nil { if err == nil {
err = s.store.SaveSettings(r.Context(), st) err = s.store.SaveSettings(r.Context(), st)
} }
@@ -535,62 +620,6 @@ func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
} }
// handleExport returns the configuration as a CLI YAML file (no secret).
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
cfg, err := s.store.ExportConfig(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "store")
return
}
b, err := yaml.Marshal(cfg)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "yaml")
return
}
s.audit(r, Identity{}, "config-export", "", true, "")
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="confsync.yaml"`)
_, _ = fmt.Fprintf(w, "# pve-confsync : configuration exportée le %s par %s\n# Secrets des tokens non exportés : renseigner les variables token_secret_env (ou token_secret_file).\n\n",
time.Now().Format("2006-01-02 15:04"), identity(r).User)
_, _ = w.Write(b)
}
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
var req struct {
YAML string `json:"yaml"`
Replace bool `json:"replace"`
}
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "request")
return
}
var cfg config.Config
dec := yaml.NewDecoder(strings.NewReader(req.YAML))
dec.KnownFields(true)
err := dec.Decode(&cfg)
if err == nil {
if len(cfg.Kinds) == 0 {
cfg.Kinds = append(cfg.Kinds, model.Kinds...)
}
err = cfg.Validate()
}
var warns []string
if err == nil {
warns, err = s.store.ImportConfig(r.Context(), &cfg, req.Replace)
}
s.audit(r, Identity{}, "config-import", fmt.Sprintf("%d cluster(s), remplacement=%t", len(cfg.Clients)+1, req.Replace),
err == nil, errText(err, strings.Join(warns, " ; ")))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
return
}
s.invalidate()
if warns == nil {
warns = []string{}
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "warnings": warns})
}
// ---- users and audit ---- // ---- users and audit ----
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
+52 -2
View File
@@ -183,7 +183,7 @@ func sessionKey(dir string) []byte {
// public paths: reachable without a session. // public paths: reachable without a session.
func public(p string) bool { func public(p string) bool {
switch p { switch p {
case "/healthz", loginPage, "/style.css", "/boot.js", "/login.js", "/favicon.svg", "/auth/config", "/auth/login", "/auth/logout", "/auth/oidc": case "/healthz", loginPage, "/style.css", "/boot.js", "/login.js", "/favicon.svg", "/auth/config", "/auth/login", "/auth/logout", "/auth/oidc", "/auth/setup":
return true return true
} }
return false return false
@@ -197,6 +197,9 @@ func (a *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case r.URL.Path == "/auth/logout": case r.URL.Path == "/auth/logout":
a.handleLogout(w, r) a.handleLogout(w, r)
return return
case r.URL.Path == "/auth/setup":
a.handleSetup(w, r)
return
case r.URL.Path == "/auth/oidc" && a.cfg.OIDCEnabled(): case r.URL.Path == "/auth/oidc" && a.cfg.OIDCEnabled():
a.startLogin(w, r) a.startLogin(w, r)
return return
@@ -204,7 +207,8 @@ func (a *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.handleCallback(w, r) a.handleCallback(w, r)
return return
case r.URL.Path == "/auth/config": case r.URL.Path == "/auth/config":
writeJSON(w, http.StatusOK, map[string]any{"local": a.cfg.Local, "oidc": a.cfg.OIDCEnabled(), "demo": a.cfg.Demo}) writeJSON(w, http.StatusOK, map[string]any{"local": a.cfg.Local, "oidc": a.cfg.OIDCEnabled(), "demo": a.cfg.Demo,
"setup": a.needSetup(r.Context())})
return return
} }
id, ok := a.sessionIdentity(r) id, ok := a.sessionIdentity(r)
@@ -299,6 +303,52 @@ func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, ret, http.StatusSeeOther) http.Redirect(w, r, ret, http.StatusSeeOther)
} }
// needSetup: local accounts enabled and none yet, the login page asks for the
// first administrator.
func (a *Auth) needSetup(ctx context.Context) bool {
if !a.cfg.Local {
return false
}
n, err := a.store.CountUsers(ctx)
return err == nil && n == 0
}
// handleSetup creates the first administrator on an empty database, then opens
// its session. Refused as soon as an account exists.
func (a *Auth) handleSetup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !a.needSetup(r.Context()) {
http.Redirect(w, r, loginPage, http.StatusFound)
return
}
user, pass := strings.TrimSpace(r.PostFormValue("user")), r.PostFormValue("pass")
fail := func(code string) {
http.Redirect(w, r, loginPage+"?"+url.Values{"setup": {code}}.Encode(), http.StatusSeeOther)
}
if pass != r.PostFormValue("pass2") {
fail("mismatch")
return
}
if err := a.store.CreateFirstAdmin(r.Context(), user, pass); err != nil {
log.Printf("auth: first administrator: %v", err)
if errors.Is(err, store.ErrSetupDone) {
http.Redirect(w, r, loginPage, http.StatusSeeOther)
return
}
fail("invalid")
return
}
u, err := a.store.User(r.Context(), user)
if err != nil {
fail("invalid")
return
}
id := Identity{User: u.Name, Role: u.Role, Mode: "local"}
log.Printf("auth: first administrator %q created from %s", u.Name, clientIP(r))
a.audit(r, id, "setup", u.Name, true, "premier administrateur créé")
a.setSession(w, r, session{User: u.Name, Role: u.Role, Mode: "local", Epoch: u.Epoch})
http.Redirect(w, r, "/#clusters", http.StatusSeeOther)
}
// handleLogout ends the session; with OIDC the provider's session ends on its logout page if it has one. // handleLogout ends the session; with OIDC the provider's session ends on its logout page if it has one.
func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) { func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
id, ok := a.sessionIdentity(r) id, ok := a.sessionIdentity(r)
+7 -6
View File
@@ -1,7 +1,8 @@
// Package server is the web UI of pve-confsync (phases 1 and 2): cluster list, // Package server is the web UI of pve-confsync, the only way to use it: first
// visual diff per client, dry-run and synchronization per object, per client or // administrator, clusters (root credentials, primary and targets), replicated
// global, execution journal and audit trail. The pages (web/) are embedded in // kinds, visual diff per target, dry-run and synchronization per object, per
// the binary; the API is JSON under /api/. // target or towards several targets, execution journal and audit trail. The
// pages (web/) are embedded in the binary; the API is JSON under /api/.
package server package server
import ( import (
@@ -77,10 +78,10 @@ func (s *Server) Handler() http.Handler {
mux.Handle("PUT /api/clusters/{name}", s.role(admin, s.handleSaveCluster)) mux.Handle("PUT /api/clusters/{name}", s.role(admin, s.handleSaveCluster))
mux.Handle("DELETE /api/clusters/{name}", s.role(admin, s.handleDeleteCluster)) mux.Handle("DELETE /api/clusters/{name}", s.role(admin, s.handleDeleteCluster))
mux.Handle("POST /api/clusters/{name}/check", s.role(admin, s.handleCheckCluster)) mux.Handle("POST /api/clusters/{name}/check", s.role(admin, s.handleCheckCluster))
mux.Handle("POST /api/clusters/{name}/role", s.role(admin, s.handleRole))
mux.Handle("POST /api/clusters/{name}/forget", s.role(admin, s.handleForget))
mux.Handle("GET /api/settings", s.role(viewer, s.handleSettings)) mux.Handle("GET /api/settings", s.role(viewer, s.handleSettings))
mux.Handle("PUT /api/settings", s.role(admin, s.handleSaveSettings)) mux.Handle("PUT /api/settings", s.role(admin, s.handleSaveSettings))
mux.Handle("GET /api/config/export", s.role(admin, s.handleExport))
mux.Handle("POST /api/config/import", s.role(admin, s.handleImport))
mux.Handle("GET /api/users", s.role(admin, s.handleUsers)) mux.Handle("GET /api/users", s.role(admin, s.handleUsers))
mux.Handle("PUT /api/users/{name}", s.role(admin, s.handleSaveUser)) mux.Handle("PUT /api/users/{name}", s.role(admin, s.handleSaveUser))
+109 -2
View File
@@ -10,11 +10,13 @@ import (
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"testing/fstest" "testing/fstest"
"time" "time"
"pve-confsync/internal/demo" "pve-confsync/internal/demo"
"pve-confsync/internal/pve/pvetest"
"pve-confsync/internal/store" "pve-confsync/internal/store"
) )
@@ -117,8 +119,8 @@ func TestAuthAndRoles(t *testing.T) {
t.Fatalf("clusters: %d", code) t.Fatalf("clusters: %d", code)
} }
for _, c := range d["clusters"].([]any) { for _, c := range d["clusters"].([]any) {
if s, ok := c.(map[string]any)["secret"]; ok && s != "" { if s, ok := c.(map[string]any)["password"]; ok && s != "" {
t.Fatalf("secret returned: %v", c) t.Fatalf("password returned: %v", c)
} }
} }
} }
@@ -147,6 +149,9 @@ func TestDryRunAndApply(t *testing.T) {
if code != http.StatusOK { if code != http.StatusOK {
t.Fatalf("dry-run site-a: %d %v", code, d) t.Fatalf("dry-run site-a: %d %v", code, d)
} }
if b, _ := json.Marshal(d); strings.Contains(string(b), "HashOnlyForTheDemo") || !strings.Contains(string(b), "pve-password") {
t.Fatalf("password hashes must be planned but never shown: %s", b)
}
sel["digests"] = digests(d) sel["digests"] = digests(d)
if code, d := op.do("POST", "/api/apply", sel, true); code != http.StatusBadRequest || d["code"] != "confirm" { if code, d := op.do("POST", "/api/apply", sel, true); code != http.StatusBadRequest || d["code"] != "confirm" {
t.Fatalf("SDN apply without confirmation: %d %v", code, d) t.Fatalf("SDN apply without confirmation: %d %v", code, d)
@@ -188,4 +193,106 @@ func TestDryRunAndApply(t *testing.T) {
} }
} }
// TestFromScratch: empty database, first administrator from the web, clusters
// added with their root password, certificate and SSH host key pinned by the
// first test, primary chosen, replication of the accounts to one target.
func TestFromScratch(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "t.db"), bytes.Repeat([]byte{7}, 32))
if err != nil {
t.Fatal(err)
}
srv, err := New(Options{Store: st, Version: "test",
Static: fstest.MapFS{"index.html": {Data: []byte("<html>")}, "login.html": {Data: []byte("<html>")}},
Auth: AuthConfig{Local: true, SessionTTL: time.Hour, DataDir: dir}})
if err != nil {
t.Fatal(err)
}
hs := httptest.NewServer(srv.Handler())
ref, tgt := pvetest.New("9.1.4"), pvetest.New("9.0.10")
t.Cleanup(func() { hs.Close(); srv.Wait(); ref.Close(); tgt.Close(); st.Close() })
ref.Groups["ops"] = map[string]any{"comment": "Ops"}
ref.Users["alice@pve"] = map[string]any{"enable": 1, "groups": "ops"}
ref.Shadow["alice"] = "$5$s$aliceSecretHash"
anon := &client{t: t, url: hs.URL, c: http.DefaultClient}
if _, d := anon.do("GET", "/auth/config", nil, false); d["setup"] != true {
t.Fatalf("setup expected: %v", d)
}
jar, _ := cookiejar.New(nil)
adm := &client{t: t, url: hs.URL, c: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
r, err := adm.c.PostForm(hs.URL+"/auth/setup", url.Values{"user": {"cedric"}, "pass": {"a-long-password"}, "pass2": {"a-long-password"}})
if err != nil {
t.Fatal(err)
}
r.Body.Close()
if r.StatusCode != http.StatusSeeOther || r.Header.Get("Location") != "/#clusters" {
t.Fatalf("setup: %d %s", r.StatusCode, r.Header.Get("Location"))
}
if r, _ := anon.c.PostForm(hs.URL+"/auth/setup", url.Values{"user": {"intrus"}, "pass": {"a-long-password"}, "pass2": {"a-long-password"}}); r != nil {
r.Body.Close()
}
if n, _ := st.CountUsers(ctx); n != 1 {
t.Fatal("a second setup must be refused")
}
if _, d := adm.do("GET", "/api/state", nil, false); d["setup"] != "clusters" {
t.Fatalf("state on an empty database: %v", d)
}
for _, c := range []struct {
name string
s *pvetest.Server
}{{"ref", ref}, {"tgt", tgt}} {
_, port := c.s.SSHAddr()
code, d := adm.do("PUT", "/api/clusters/-", map[string]any{"name": c.name, "url": c.s.URL, "password": pvetest.Password,
"tls": "fp", "sshPort": port}, true)
if code != http.StatusOK {
t.Fatalf("save %s: %d %v", c.name, code, d)
}
code, d = adm.do("POST", "/api/clusters/"+c.name+"/check", nil, true)
if code != http.StatusOK || d["ok"] != true || d["tlsFingerprint"] == "" || d["sshFingerprint"] == "" {
t.Fatalf("check %s: %d %v", c.name, code, d)
}
if code, d = adm.do("POST", "/api/clusters/"+c.name+"/check", nil, true); d["ok"] != true || d["sshFingerprint"] != nil {
t.Fatalf("second check %s (pinned): %d %v", c.name, code, d)
}
}
if _, d := adm.do("GET", "/api/state", nil, false); d["setup"] != "primary" {
t.Fatalf("state without primary: %v", d)
}
if code, d := adm.do("POST", "/api/clusters/ref/role", map[string]any{"role": "primary"}, true); code != http.StatusOK {
t.Fatalf("role: %d %v", code, d)
}
sel := map[string]any{"scope": "global", "targets": []string{"tgt"}}
code, d := adm.do("POST", "/api/dryrun", sel, true)
if code != http.StatusOK {
t.Fatalf("dry-run: %d %v", code, d)
}
sel["digests"] = digests(d)
if code, d = adm.do("POST", "/api/apply", sel, true); code != http.StatusAccepted {
t.Fatalf("apply: %d %v", code, d)
}
id := int(d["run"].(float64))
var run map[string]any
for i := 0; i < 100; i++ {
_, run = adm.do("GET", "/api/runs/"+itoa(id), nil, false)
if run["status"] != "running" {
break
}
time.Sleep(50 * time.Millisecond)
}
if run["status"] != "ok" {
t.Fatalf("run: %v", run)
}
if tgt.Shadow["alice"] != "$5$s$aliceSecretHash" || tgt.Users["alice@pve"]["groups"] != "ops" {
t.Fatalf("target: %v %v", tgt.Shadow, tgt.Users)
}
if b, _ := json.Marshal(run); strings.Contains(string(b), "aliceSecretHash") {
t.Fatal("hash in the journal")
}
if ref.Calls != nil {
t.Fatalf("the primary must never be written: %v", ref.Calls)
}
}
func itoa(i int) string { b, _ := json.Marshal(i); return string(b) } func itoa(i int) string { b, _ := json.Marshal(i); return string(b) }
+67 -41
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"fmt" "fmt"
"sort" "sort"
"strings"
"sync" "sync"
"time" "time"
@@ -15,6 +16,7 @@ import (
"pve-confsync/internal/engine" "pve-confsync/internal/engine"
"pve-confsync/internal/model" "pve-confsync/internal/model"
"pve-confsync/internal/pve" "pve-confsync/internal/pve"
"pve-confsync/internal/sshx"
) )
// Node is the PVE version of one node. // Node is the PVE version of one node.
@@ -255,6 +257,10 @@ func rows(cfg *config.Config, cl *config.Client, ref, actual []model.Object, pla
case fd.Policy != adapter.Ignore && fd.Current != nil: case fd.Policy != adapter.Ignore && fd.Current != nil:
fd.Target = fd.Current fd.Target = fd.Current
} }
if adapter.IsSensitive(k, f) { // password hashes: a digest only
fd.Ref, fd.Mapping = adapter.Redact(fd.Ref), adapter.Redact(fd.Mapping)
fd.Current, fd.Target = adapter.Redact(fd.Current), adapter.Redact(fd.Target)
}
row.Fields = append(row.Fields, fd) row.Fields = append(row.Fields, fd)
} }
out = append(out, row) out = append(out, row)
@@ -355,67 +361,87 @@ func (st *State) Apply(ctx context.Context, cp ClientPlan, keys []string, log fu
return err return err
} }
// Check tests the access to a cluster: versions (9.x only) and token privileges. // Check is the result of a connection test: API with the root credentials
// (login, PVE 9.x on every node) and SSH as root on the node.
type Check struct { type Check struct {
Nodes []Node `json:"nodes,omitempty"` OK bool `json:"ok"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"` Warnings []string `json:"warnings,omitempty"`
API bool `json:"api"`
SSH bool `json:"ssh"`
Version string `json:"version,omitempty"`
Nodes []Node `json:"nodes,omitempty"`
// set when something is seen for the first time and must be recorded
TLSFingerprint string `json:"tlsFingerprint,omitempty"`
SSHHostKey string `json:"-"`
SSHFingerprint string `json:"sshFingerprint,omitempty"`
SSHMismatch bool `json:"sshMismatch,omitempty"` // the node presents another key than the recorded one
} }
// CheckCluster runs the same checks as the "check" command. // CheckCluster tests a cluster. pinTLS: the TLS mode pins the certificate and
func CheckCluster(ctx context.Context, cl config.Cluster, write, dcOptions bool) Check { // none is recorded yet (trust on first use). trust accepts a new SSH host key.
func CheckCluster(ctx context.Context, cl config.Cluster, pinTLS, trust bool) Check {
var res Check var res Check
c, err := cl.PVE() fail := func(err error) Check {
if err != nil {
res.Error = err.Error() res.Error = err.Error()
return res return res
} }
if pinTLS && cl.Fingerprint == "" {
fp, err := pve.PeerFingerprint(ctx, cl.URL)
if err != nil {
return fail(fmt.Errorf("TLS: %w", err))
}
cl.Fingerprint, res.TLSFingerprint = fp, fp
}
if cl.Insecure {
res.Warnings = append(res.Warnings, "certificat TLS non vérifié (labo uniquement)")
}
key := cl.SSHHostKey
cl.SSHHostKey = "" // API only for now
c, err := cl.PVE()
if err != nil {
return fail(err)
}
if err := c.Login(ctx); err != nil {
return fail(fmt.Errorf("API: %w", err))
}
vs, err := c.CheckVersion(ctx) vs, err := c.CheckVersion(ctx)
res.Nodes = nodes(vs) res.Nodes = nodes(vs)
if err != nil { if len(vs) > 0 {
res.Error = err.Error() res.Version = vs[0].Version
return res
} }
perms, err := c.Permissions(ctx)
if err != nil { if err != nil {
res.Warnings = append(res.Warnings, fmt.Sprintf("token privileges unreadable (%v)", err)) return fail(err)
return res
} }
res.Warnings = PrivilegeWarnings(perms, write, dcOptions) res.API = true
return res
}
// PrivilegeWarnings flags a reference token able to write, and a client token o := cl.SSHOptions()
// missing the privileges needed by the synchronized kinds. if key == "" || trust {
func PrivilegeWarnings(perms map[string]map[string]any, write, dcOptions bool) []string { seen, fp, err := sshx.Scan(ctx, o)
has := func(path, priv string) bool { _, ok := perms[path][priv]; return ok } if err != nil {
anyPath := func(priv string) bool { return fail(fmt.Errorf("SSH: %w", err))
for p := range perms {
if has(p, priv) {
return true
}
} }
return false key, res.SSHHostKey, res.SSHFingerprint = seen, seen, fp
} }
var w []string o.HostKey = key
if !write { sh, err := sshx.New(o)
for _, p := range []string{"Sys.Modify", "SDN.Allocate", "Permissions.Modify"} { if err != nil {
if anyPath(p) { return fail(fmt.Errorf("SSH: %w", err))
w = append(w, "le token de référence possède "+p+" : un token en lecture seule est attendu") }
} out, err := sh.Run(ctx, "pveversion", nil)
if err != nil {
if strings.Contains(err.Error(), "host key mismatch") {
_, fp, _ := sshx.Scan(ctx, o)
res.SSHMismatch, res.SSHFingerprint = true, fp
return fail(fmt.Errorf("SSH: le nœud %s présente la clé d'hôte %s au lieu de %s enregistrée", o.Host, fp, sshx.Fingerprint(key)))
} }
return w return fail(fmt.Errorf("SSH: %w", err))
} }
if !has("/sdn", "SDN.Allocate") && !has("/sdn/zones", "SDN.Allocate") { if !strings.HasPrefix(strings.TrimSpace(string(out)), "pve-manager/") {
w = append(w, "SDN.Allocate absent sur /sdn : la synchro SDN échouera") return fail(fmt.Errorf("SSH: %s is not a PVE node (pveversion: %q)", o.Host, strings.TrimSpace(string(out))))
} }
if dcOptions && !has("/", "Sys.Modify") { res.SSH, res.OK = true, true
w = append(w, "Sys.Modify absent sur / : les options datacenter ne pourront pas être écrites") return res
}
if !has("/", "Sys.Audit") {
w = append(w, "Sys.Audit absent sur / : options datacenter et versions illisibles")
}
return w
} }
// nodes converts the versions read by the PVE client. // nodes converts the versions read by the PVE client.
+176
View File
@@ -0,0 +1,176 @@
// Package sshx runs commands on a PVE node over SSH as root, for what the API
// does not cover (the password hashes of the pve realm in /etc/pve/priv/shadow.cfg).
// The host key is pinned: the first connection records it (trust on first use),
// the next ones refuse any other key.
package sshx
import (
"bytes"
"context"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
// Options of an SSH access.
type Options struct {
Host string // node address
Port int // 22 by default
User string // root by default
Password string // never logged
HostKey string // pinned host key, authorized_keys format; empty: refused (see Scan)
Timeout time.Duration
}
// Client runs commands on one node. Each Run opens its own connection: the
// synchronizations are rare, a connection pool would only hold sessions open.
type Client struct{ o Options }
// New checks the options.
func New(o Options) (*Client, error) {
if o.Host == "" {
return nil, errors.New("ssh: host required")
}
if o.Password == "" {
return nil, errors.New("ssh: password required")
}
if o.HostKey == "" {
return nil, errors.New("ssh: host key not recorded yet, test the cluster first")
}
if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(o.HostKey)); err != nil {
return nil, fmt.Errorf("ssh: recorded host key: %w", err)
}
if o.Port == 0 {
o.Port = 22
}
if o.User == "" {
o.User = "root"
}
if o.Timeout == 0 {
o.Timeout = 15 * time.Second
}
return &Client{o}, nil
}
func (c *Client) addr() string { return net.JoinHostPort(c.o.Host, strconv.Itoa(c.o.Port)) }
// Run executes cmd and returns its standard output. in is written on the
// standard input (secrets are never put on the command line).
func (c *Client) Run(ctx context.Context, cmd string, in []byte) ([]byte, error) {
want, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(c.o.HostKey))
cfg := &ssh.ClientConfig{
User: c.o.User,
Auth: auth(c.o.Password),
HostKeyCallback: ssh.FixedHostKey(want),
Timeout: c.o.Timeout,
}
conn, err := dial(ctx, c.addr(), cfg)
if err != nil {
return nil, err
}
defer conn.Close()
sess, err := conn.NewSession()
if err != nil {
return nil, fmt.Errorf("ssh %s: %w", c.addr(), err)
}
defer sess.Close()
var out, stderr bytes.Buffer
sess.Stdout, sess.Stderr = &out, &stderr
if in != nil {
sess.Stdin = bytes.NewReader(in)
}
done := make(chan error, 1)
go func() { done <- sess.Run(cmd) }()
select {
case err = <-done:
case <-ctx.Done():
conn.Close()
return nil, ctx.Err()
}
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return out.Bytes(), fmt.Errorf("ssh %s: %s", c.addr(), msg)
}
return out.Bytes(), nil
}
// auth: password, and keyboard-interactive answering the password (PAM).
func auth(password string) []ssh.AuthMethod {
return []ssh.AuthMethod{
ssh.Password(password),
ssh.KeyboardInteractive(func(_, _ string, qs []string, _ []bool) ([]string, error) {
a := make([]string, len(qs))
for i := range a {
a[i] = password
}
return a, nil
}),
}
}
func dial(ctx context.Context, addr string, cfg *ssh.ClientConfig) (*ssh.Client, error) {
d := net.Dialer{Timeout: cfg.Timeout}
nc, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("ssh %s: %w", addr, err)
}
cc, chans, reqs, err := ssh.NewClientConn(nc, addr, cfg)
if err != nil {
nc.Close()
return nil, fmt.Errorf("ssh %s: %w", addr, err)
}
return ssh.NewClient(cc, chans, reqs), nil
}
// Scan connects without a pinned key and returns the key the node presents
// (authorized_keys format) and its SHA256 fingerprint, after checking that the
// password is accepted. The caller records the key (trust on first use).
func Scan(ctx context.Context, o Options) (key, fingerprint string, err error) {
if o.Port == 0 {
o.Port = 22
}
if o.User == "" {
o.User = "root"
}
if o.Timeout == 0 {
o.Timeout = 15 * time.Second
}
var seen ssh.PublicKey
cfg := &ssh.ClientConfig{
User: o.User,
Auth: auth(o.Password),
HostKeyCallback: func(_ string, _ net.Addr, k ssh.PublicKey) error {
seen = k
return nil
},
Timeout: o.Timeout,
}
addr := net.JoinHostPort(o.Host, strconv.Itoa(o.Port))
conn, err := dial(ctx, addr, cfg)
if seen != nil {
key = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(seen)))
fingerprint = ssh.FingerprintSHA256(seen)
}
if err != nil {
return key, fingerprint, err
}
conn.Close()
return key, fingerprint, nil
}
// Fingerprint returns the SHA256 fingerprint of a recorded key ("" if invalid).
func Fingerprint(key string) string {
k, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
if err != nil {
return ""
}
return ssh.FingerprintSHA256(k)
}
+3 -10
View File
@@ -18,7 +18,7 @@ import (
"strings" "strings"
) )
// Box encrypts the token secrets at rest (AES-256-GCM, random nonce, version byte). // Box encrypts the cluster passwords at rest (AES-256-GCM, random nonce, version byte).
type Box struct{ aead cipher.AEAD } type Box struct{ aead cipher.AEAD }
const boxVersion = 1 const boxVersion = 1
@@ -65,7 +65,7 @@ func (b *Box) Open(blob []byte) ([]byte, error) {
return plain, nil return plain, nil
} }
// LoadKey returns the encryption key of the token secrets, in this order: // LoadKey returns the encryption key of the cluster passwords, in this order:
// CONFSYNC_SECRET_KEY (64 hex characters or 44 base64 characters), // CONFSYNC_SECRET_KEY (64 hex characters or 44 base64 characters),
// CONFSYNC_SECRET_KEY_FILE (same content, docker secret), or <dataDir>/secret.key, // CONFSYNC_SECRET_KEY_FILE (same content, docker secret), or <dataDir>/secret.key,
// created on first start. The last case keeps the key next to the database: fine for // created on first start. The last case keeps the key next to the database: fine for
@@ -96,7 +96,7 @@ func LoadKey(dataDir string) ([]byte, string, error) {
if err := os.WriteFile(path, []byte(hex.EncodeToString(k)+"\n"), 0o600); err != nil { if err := os.WriteFile(path, []byte(hex.EncodeToString(k)+"\n"), 0o600); err != nil {
return nil, "", fmt.Errorf("cannot save the encryption key: %w", err) return nil, "", fmt.Errorf("cannot save the encryption key: %w", err)
} }
log.Printf("encryption key created in %s: keep a copy, the token secrets cannot be read without it", path) log.Printf("encryption key created in %s: keep a copy, the cluster passwords cannot be read without it", path)
return k, path, nil return k, path, nil
} }
@@ -149,10 +149,3 @@ func CheckPassword(hash, pass string) bool {
got, err := pbkdf2.Key(sha256.New, pass, salt, iter, len(want)) got, err := pbkdf2.Key(sha256.New, pass, salt, iter, len(want))
return err == nil && subtle.ConstantTimeCompare(got, want) == 1 return err == nil && subtle.ConstantTimeCompare(got, want) == 1
} }
// RandomPassword returns a password for the first admin account.
func RandomPassword() string {
b := make([]byte, 12)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
+301 -176
View File
@@ -1,6 +1,7 @@
// Package store keeps the state of the web UI in SQLite: clusters (token // Package store keeps the state of the web UI in SQLite: clusters (root
// secrets encrypted with AES-256-GCM), synchronization settings, per client // passwords encrypted with AES-256-GCM, pinned TLS certificates and SSH host
// mappings and policies, users, execution journal and audit trail. // keys), synchronization settings, per target mappings and policies, users,
// execution journal and audit trail.
package store package store
import ( import (
@@ -9,6 +10,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"strings" "strings"
"time" "time"
@@ -17,6 +19,7 @@ import (
"pve-confsync/internal/adapter" "pve-confsync/internal/adapter"
"pve-confsync/internal/config" "pve-confsync/internal/config"
"pve-confsync/internal/model" "pve-confsync/internal/model"
"pve-confsync/internal/sshx"
) )
// ErrNotFound is returned for an unknown cluster, user or run. // ErrNotFound is returned for an unknown cluster, user or run.
@@ -28,20 +31,29 @@ type Store struct {
box *Box box *Box
} }
// schemaVersion 2: clusters reached with the root credentials (API + SSH),
// roles primary / target / disabled. Version 1 (API tokens) is not migrated:
// its clusters table is dropped, the clusters are added again from the UI.
const schemaVersion = 2
const schema = ` const schema = `
CREATE TABLE IF NOT EXISTS clusters ( CREATE TABLE IF NOT EXISTS clusters (
name TEXT PRIMARY KEY, name TEXT PRIMARY KEY,
reference INTEGER NOT NULL DEFAULT 0, role TEXT NOT NULL DEFAULT 'target',
position INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
url TEXT NOT NULL, url TEXT NOT NULL,
token_id TEXT NOT NULL, api_user TEXT NOT NULL DEFAULT 'root@pam',
secret BLOB, password BLOB,
tls TEXT NOT NULL DEFAULT 'fp',
fingerprint TEXT NOT NULL DEFAULT '', fingerprint TEXT NOT NULL DEFAULT '',
ca_pem TEXT NOT NULL DEFAULT '', ca_pem TEXT NOT NULL DEFAULT '',
insecure INTEGER NOT NULL DEFAULT 0,
timeout TEXT NOT NULL DEFAULT '', timeout TEXT NOT NULL DEFAULT '',
ssh_host TEXT NOT NULL DEFAULT '',
ssh_port INTEGER NOT NULL DEFAULT 22,
ssh_hostkey TEXT NOT NULL DEFAULT '',
mapping TEXT NOT NULL DEFAULT '{}', mapping TEXT NOT NULL DEFAULT '{}',
policies TEXT NOT NULL DEFAULT '{}', policies TEXT NOT NULL DEFAULT '{}',
last_check TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
@@ -96,6 +108,10 @@ func Open(path string, key []byte) (*Store, error) {
return nil, err return nil, err
} }
db.SetMaxOpenConns(1) // one writer; SQLite serializes anyway db.SetMaxOpenConns(1) // one writer; SQLite serializes anyway
if err := migrate(db); err != nil {
db.Close()
return nil, fmt.Errorf("%s: %w", path, err)
}
if _, err := db.Exec(schema); err != nil { if _, err := db.Exec(schema); err != nil {
db.Close() db.Close()
return nil, fmt.Errorf("%s: %w", path, err) return nil, fmt.Errorf("%s: %w", path, err)
@@ -108,6 +124,29 @@ func Open(path string, key []byte) (*Store, error) {
return s, nil return s, nil
} }
// migrate drops the clusters of the first schema (API tokens, not reusable).
func migrate(db *sql.DB) error {
var v int
if err := db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {
return err
}
if v >= schemaVersion {
return nil
}
var old int
if err := db.QueryRow(`SELECT count(*) FROM pragma_table_info('clusters') WHERE name = 'token_id'`).Scan(&old); err != nil {
return err
}
if old > 0 {
log.Printf("database: clusters of the previous version (API tokens) dropped, add them again with their root credentials")
if _, err := db.Exec(`DROP TABLE clusters; DROP TABLE IF EXISTS settings`); err != nil {
return err
}
}
_, err := db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
return err
}
// Close closes the database. // Close closes the database.
func (s *Store) Close() error { return s.db.Close() } func (s *Store) Close() error { return s.db.Close() }
@@ -115,7 +154,7 @@ func (s *Store) Close() error { return s.db.Close() }
// (lost or changed CONFSYNC_SECRET_KEY), instead of failing later on each cluster. // (lost or changed CONFSYNC_SECRET_KEY), instead of failing later on each cluster.
func (s *Store) checkKey() error { func (s *Store) checkKey() error {
var blob []byte var blob []byte
err := s.db.QueryRow(`SELECT secret FROM clusters WHERE secret IS NOT NULL AND length(secret) > 0 LIMIT 1`).Scan(&blob) err := s.db.QueryRow(`SELECT password FROM clusters WHERE password IS NOT NULL AND length(password) > 0 LIMIT 1`).Scan(&blob)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil return nil
} }
@@ -123,67 +162,176 @@ func (s *Store) checkKey() error {
return err return err
} }
if _, err := s.box.Open(blob); err != nil { if _, err := s.box.Open(blob); err != nil {
return errors.New("the encryption key does not decrypt the stored token secrets (CONFSYNC_SECRET_KEY changed?)") return errors.New("the encryption key does not decrypt the stored passwords (CONFSYNC_SECRET_KEY changed?)")
} }
return nil return nil
} }
// ---- clusters ---- // ---- clusters ----
// Cluster is a cluster as stored. The secret is never returned to the UI: // Cluster roles: one primary (the source, never written), targets (replicated)
// HasSecret only says whether one is set. // and disabled clusters (kept, not read).
type Cluster struct { const (
Name string `json:"name"` ClusterPrimary = "primary"
Reference bool `json:"reference"` ClusterTarget = "target"
Position int `json:"position"` ClusterDisabled = "disabled"
URL string `json:"url"` )
TokenID string `json:"tokenId"`
Secret string `json:"secret,omitempty"` // write only // TLS modes of a cluster.
HasSecret bool `json:"hasSecret"` const (
Fingerprint string `json:"fingerprint"` TLSPin = "fp" // pinned certificate (recorded on the first connection when empty)
CAPEM string `json:"caPem"` TLSCA = "ca" // CA bundle
Insecure bool `json:"insecure"` TLSSystem = "sys" // system authorities
Timeout string `json:"timeout"` TLSInsecure = "insecure" // no verification (lab only)
Mapping map[model.Kind]map[string]map[string]any `json:"mapping"` )
Policies map[model.Kind]map[string]adapter.Policy `json:"policies"`
UpdatedAt time.Time `json:"updatedAt"` // Check is the result of the last connection test of a cluster.
type Check struct {
At time.Time `json:"at"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Version string `json:"version,omitempty"`
Nodes []string `json:"nodes,omitempty"`
API bool `json:"api"`
SSH bool `json:"ssh"`
} }
// Clusters returns every cluster, reference first, without secrets. // Cluster is a cluster as stored. The password is never returned to the UI:
// HasPassword only says whether one is set.
type Cluster struct {
Name string `json:"name"`
Role string `json:"role"`
Position int `json:"position"`
URL string `json:"url"`
User string `json:"user"`
Password string `json:"password,omitempty"` // write only
HasPassword bool `json:"hasPassword"`
TLS string `json:"tls"`
Fingerprint string `json:"fingerprint"`
CAPEM string `json:"caPem"`
Timeout string `json:"timeout"`
SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"`
SSHHostKey string `json:"sshHostKey"` // read only (pinned by the connection test)
SSHFingerprint string `json:"sshFingerprint"` // read only
Mapping map[model.Kind]map[string]map[string]any `json:"mapping"`
Policies map[model.Kind]map[string]adapter.Policy `json:"policies"`
LastCheck *Check `json:"lastCheck,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
const clusterCols = `name, role, position, url, api_user, password, tls, fingerprint, ca_pem, timeout,
ssh_host, ssh_port, ssh_hostkey, mapping, policies, last_check, updated_at`
func scanCluster(scan func(...any) error) (Cluster, []byte, error) {
var c Cluster
var blob []byte
var mapping, policies, check string
var updated int64
err := scan(&c.Name, &c.Role, &c.Position, &c.URL, &c.User, &blob, &c.TLS, &c.Fingerprint, &c.CAPEM, &c.Timeout,
&c.SSHHost, &c.SSHPort, &c.SSHHostKey, &mapping, &policies, &check, &updated)
if err != nil {
return c, nil, err
}
c.HasPassword = len(blob) > 0
c.SSHFingerprint = sshx.Fingerprint(c.SSHHostKey)
c.UpdatedAt = time.Unix(updated, 0).UTC()
_ = json.Unmarshal([]byte(mapping), &c.Mapping)
_ = json.Unmarshal([]byte(policies), &c.Policies)
if check != "" {
c.LastCheck = &Check{}
_ = json.Unmarshal([]byte(check), c.LastCheck)
}
return c, blob, nil
}
// Clusters returns every cluster, primary first, without passwords.
func (s *Store) Clusters(ctx context.Context) ([]Cluster, error) { func (s *Store) Clusters(ctx context.Context) ([]Cluster, error) {
rows, err := s.db.QueryContext(ctx, `SELECT name, reference, position, url, token_id, length(secret) > 0, rows, err := s.db.QueryContext(ctx, `SELECT `+clusterCols+` FROM clusters
fingerprint, ca_pem, insecure, timeout, mapping, policies, updated_at ORDER BY role = 'primary' DESC, position, name`)
FROM clusters ORDER BY reference DESC, position, name`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var out []Cluster var out []Cluster
for rows.Next() { for rows.Next() {
var c Cluster c, _, err := scanCluster(rows.Scan)
var hasSecret sql.NullBool if err != nil {
var mapping, policies string
var updated int64
if err := rows.Scan(&c.Name, &c.Reference, &c.Position, &c.URL, &c.TokenID, &hasSecret,
&c.Fingerprint, &c.CAPEM, &c.Insecure, &c.Timeout, &mapping, &policies, &updated); err != nil {
return nil, err return nil, err
} }
c.HasSecret = hasSecret.Bool
c.UpdatedAt = time.Unix(updated, 0).UTC()
_ = json.Unmarshal([]byte(mapping), &c.Mapping)
_ = json.Unmarshal([]byte(policies), &c.Policies)
out = append(out, c) out = append(out, c)
} }
return out, rows.Err() return out, rows.Err()
} }
// SaveCluster creates or updates a cluster. An empty Secret keeps the stored one. // Cluster returns one cluster with its decrypted connection (for a test).
// oldName renames a cluster (empty: same name). Setting Reference on a cluster func (s *Store) Cluster(ctx context.Context, name string) (Cluster, config.Cluster, error) {
// makes it the only reference. c, blob, err := scanCluster(s.db.QueryRowContext(ctx, `SELECT `+clusterCols+` FROM clusters WHERE name = ?`, name).Scan)
if errors.Is(err, sql.ErrNoRows) {
return c, config.Cluster{}, ErrNotFound
}
if err != nil {
return c, config.Cluster{}, err
}
cc, err := s.connection(c, blob)
return c, cc, err
}
func (s *Store) connection(c Cluster, blob []byte) (config.Cluster, error) {
pass, err := s.box.Open(blob)
if err != nil {
return config.Cluster{}, fmt.Errorf("cluster %s: password: %w", c.Name, err)
}
cc := config.Cluster{Name: c.Name, URL: c.URL, User: c.User, Password: string(pass), Timeout: c.Timeout,
SSHHost: c.SSHHost, SSHPort: c.SSHPort, SSHHostKey: c.SSHHostKey}
switch c.TLS {
case TLSPin:
cc.Fingerprint = c.Fingerprint
case TLSCA:
cc.CAPEM = c.CAPEM
case TLSInsecure:
cc.Insecure = true
}
return cc, nil
}
// ValidClusterRole tells whether r is a cluster role.
func ValidClusterRole(r string) bool {
return r == ClusterPrimary || r == ClusterTarget || r == ClusterDisabled
}
// SaveCluster creates or updates a cluster. An empty Password keeps the stored
// one. oldName renames a cluster (empty: same name). A primary cluster makes
// the previous primary a target. The pinned TLS certificate and SSH host key
// are forgotten when the address they belong to changes.
func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) error { func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) error {
c.Name = strings.TrimSpace(c.Name) c.Name = strings.TrimSpace(c.Name)
if c.Name == "" || c.URL == "" || c.TokenID == "" { c.URL = strings.TrimRight(strings.TrimSpace(c.URL), "/")
return errors.New("name, url and token id are required") if c.Name == "" || c.URL == "" {
return errors.New("name and url are required")
}
if c.User == "" {
c.User = "root@pam"
}
if c.Role == "" {
c.Role = ClusterTarget
}
if !ValidClusterRole(c.Role) {
return fmt.Errorf("role %q: primary, target or disabled expected", c.Role)
}
switch c.TLS {
case "":
c.TLS = TLSPin
case TLSPin, TLSCA, TLSSystem, TLSInsecure:
default:
return fmt.Errorf("tls %q: fp, ca, sys or insecure expected", c.TLS)
}
if c.TLS == TLSCA && strings.TrimSpace(c.CAPEM) == "" {
return errors.New("the CA certificate (PEM) is required")
}
if c.SSHPort == 0 {
c.SSHPort = 22
} }
if oldName == "" { if oldName == "" {
oldName = c.Name oldName = c.Name
@@ -195,36 +343,47 @@ func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) erro
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
var exists bool old, blob, err := scanCluster(tx.QueryRowContext(ctx, `SELECT `+clusterCols+` FROM clusters WHERE name = ?`, oldName).Scan)
var secret []byte exists := err == nil
err = tx.QueryRowContext(ctx, `SELECT 1, secret FROM clusters WHERE name = ?`, oldName).Scan(&exists, &secret)
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err return err
} }
if c.Secret != "" { if c.Password != "" {
if secret, err = s.box.Seal([]byte(c.Secret)); err != nil { if blob, err = s.box.Seal([]byte(c.Password)); err != nil {
return err return err
} }
} }
if len(secret) == 0 { if len(blob) == 0 {
return errors.New("the token secret is required") return errors.New("the root password is required")
} }
if c.Reference { hostKey := ""
if _, err := tx.ExecContext(ctx, `UPDATE clusters SET reference = 0`); err != nil { if exists {
hostKey = old.SSHHostKey
if old.URL != c.URL && c.Fingerprint == old.Fingerprint {
c.Fingerprint = "" // another server: pinned again on the next test
}
if sshTarget(old) != sshTarget(c) {
hostKey = ""
}
}
if c.Role == ClusterPrimary {
if _, err := tx.ExecContext(ctx, `UPDATE clusters SET role = 'target' WHERE role = 'primary'`); err != nil {
return err return err
} }
} }
now := time.Now().Unix() now := time.Now().Unix()
if exists { if exists {
_, err = tx.ExecContext(ctx, `UPDATE clusters SET name=?, reference=?, position=?, url=?, token_id=?, secret=?, _, err = tx.ExecContext(ctx, `UPDATE clusters SET name=?, role=?, position=?, url=?, api_user=?, password=?, tls=?,
fingerprint=?, ca_pem=?, insecure=?, timeout=?, mapping=?, policies=?, updated_at=? WHERE name=?`, fingerprint=?, ca_pem=?, timeout=?, ssh_host=?, ssh_port=?, ssh_hostkey=?, mapping=?, policies=?, updated_at=?
c.Name, c.Reference, c.Position, c.URL, c.TokenID, secret, c.Fingerprint, c.CAPEM, c.Insecure, c.Timeout, WHERE name=?`,
string(mapping), string(policies), now, oldName) c.Name, c.Role, c.Position, c.URL, c.User, blob, c.TLS, c.Fingerprint, c.CAPEM, c.Timeout,
c.SSHHost, c.SSHPort, hostKey, string(mapping), string(policies), now, oldName)
} else { } else {
_, err = tx.ExecContext(ctx, `INSERT INTO clusters (name, reference, position, url, token_id, secret, fingerprint, _, err = tx.ExecContext(ctx, `INSERT INTO clusters (name, role, position, url, api_user, password, tls, fingerprint,
ca_pem, insecure, timeout, mapping, policies, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, ca_pem, timeout, ssh_host, ssh_port, ssh_hostkey, mapping, policies, updated_at)
c.Name, c.Reference, c.Position, c.URL, c.TokenID, secret, c.Fingerprint, c.CAPEM, c.Insecure, c.Timeout, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
string(mapping), string(policies), now) c.Name, c.Role, c.Position, c.URL, c.User, blob, c.TLS, c.Fingerprint, c.CAPEM, c.Timeout,
c.SSHHost, c.SSHPort, "", string(mapping), string(policies), now)
} }
if err != nil { if err != nil {
if strings.Contains(err.Error(), "UNIQUE") { if strings.Contains(err.Error(), "UNIQUE") {
@@ -235,6 +394,68 @@ func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) erro
return tx.Commit() return tx.Commit()
} }
// sshTarget is the SSH endpoint of a cluster, to detect a change of node.
func sshTarget(c Cluster) string {
host := c.SSHHost
if host == "" {
host = c.URL
}
return fmt.Sprintf("%s|%d", host, c.SSHPort)
}
// SetRole changes the role of a cluster (a new primary demotes the previous one).
func (s *Store) SetRole(ctx context.Context, name, role string) error {
if !ValidClusterRole(role) {
return fmt.Errorf("role %q: primary, target or disabled expected", role)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if role == ClusterPrimary {
if _, err := tx.ExecContext(ctx, `UPDATE clusters SET role = 'target' WHERE role = 'primary' AND name <> ?`, name); err != nil {
return err
}
}
res, err := tx.ExecContext(ctx, `UPDATE clusters SET role = ?, updated_at = ? WHERE name = ?`, role, time.Now().Unix(), name)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return tx.Commit()
}
// Pin records the TLS fingerprint and/or the SSH host key seen on the first
// connection (empty values are left unchanged), and the result of the test.
func (s *Store) Pin(ctx context.Context, name, fingerprint, hostKey string, check *Check) error {
var chk []byte
if check != nil {
chk, _ = json.Marshal(check)
}
_, err := s.db.ExecContext(ctx, `UPDATE clusters SET
fingerprint = CASE WHEN ? <> '' THEN ? ELSE fingerprint END,
ssh_hostkey = CASE WHEN ? <> '' THEN ? ELSE ssh_hostkey END,
last_check = CASE WHEN ? <> '' THEN ? ELSE last_check END
WHERE name = ?`, fingerprint, fingerprint, hostKey, hostKey, string(chk), string(chk), name)
return err
}
// Forget clears the pinned TLS fingerprint and SSH host key (pinned again on the next test).
func (s *Store) Forget(ctx context.Context, name string) error {
res, err := s.db.ExecContext(ctx, `UPDATE clusters SET fingerprint = CASE WHEN tls = 'fp' THEN '' ELSE fingerprint END,
ssh_hostkey = '' WHERE name = ?`, name)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteCluster removes a cluster. // DeleteCluster removes a cluster.
func (s *Store) DeleteCluster(ctx context.Context, name string) error { func (s *Store) DeleteCluster(ctx context.Context, name string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM clusters WHERE name = ?`, name) res, err := s.db.ExecContext(ctx, `DELETE FROM clusters WHERE name = ?`, name)
@@ -304,43 +525,38 @@ func (s *Store) SaveSettings(ctx context.Context, st Settings) error {
// ---- configuration for the engine ---- // ---- configuration for the engine ----
// Config builds the engine configuration, secrets decrypted. It fails when no // Config builds the engine configuration, passwords decrypted: the primary
// reference is defined. // cluster and the targets (disabled clusters are left aside). It fails when no
// primary is defined.
func (s *Store) Config(ctx context.Context) (*config.Config, error) { func (s *Store) Config(ctx context.Context) (*config.Config, error) {
st, err := s.Settings(ctx) st, err := s.Settings(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune} cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune}
rows, err := s.db.QueryContext(ctx, `SELECT name, reference, url, token_id, secret, fingerprint, ca_pem, rows, err := s.db.QueryContext(ctx, `SELECT `+clusterCols+` FROM clusters ORDER BY position, name`)
insecure, timeout, mapping, policies FROM clusters ORDER BY reference DESC, position, name`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
hasRef := false hasRef := false
for rows.Next() { for rows.Next() {
var ref bool c, blob, err := scanCluster(rows.Scan)
var blob []byte if err != nil {
var mapping, policies string
var cl config.Cluster
if err := rows.Scan(&cl.Name, &ref, &cl.URL, &cl.TokenID, &blob, &cl.Fingerprint, &cl.CAPEM,
&cl.Insecure, &cl.Timeout, &mapping, &policies); err != nil {
return nil, err return nil, err
} }
secret, err := s.box.Open(blob) if c.Role == ClusterDisabled {
if err != nil {
return nil, fmt.Errorf("cluster %s: token secret: %w", cl.Name, err)
}
cl.Secret = string(secret)
if ref {
cfg.Reference, hasRef = cl, true
continue continue
} }
c := config.Client{Cluster: cl} cc, err := s.connection(c, blob)
_ = json.Unmarshal([]byte(mapping), &c.Mapping) if err != nil {
_ = json.Unmarshal([]byte(policies), &c.Policies) return nil, err
cfg.Clients = append(cfg.Clients, c) }
if c.Role == ClusterPrimary {
cfg.Reference, hasRef = cc, true
continue
}
cfg.Clients = append(cfg.Clients, config.Client{Cluster: cc, Mapping: c.Mapping, Policies: c.Policies})
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err return nil, err
@@ -351,96 +567,5 @@ func (s *Store) Config(ctx context.Context) (*config.Config, error) {
return cfg, cfg.Validate() return cfg, cfg.Validate()
} }
// ErrNoReference: nothing can be compared before a reference cluster is defined. // ErrNoReference: nothing can be compared before a primary cluster is chosen.
var ErrNoReference = errors.New("no reference cluster defined") var ErrNoReference = errors.New("no primary cluster defined")
// ExportConfig returns the configuration as the CLI YAML file, without secrets:
// each cluster gets a token_secret_env placeholder to fill.
func (s *Store) ExportConfig(ctx context.Context) (*config.Config, error) {
st, err := s.Settings(ctx)
if err != nil {
return nil, err
}
cls, err := s.Clusters(ctx)
if err != nil {
return nil, err
}
cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune}
for _, c := range cls {
cl := config.Cluster{Name: c.Name, URL: c.URL, TokenID: c.TokenID, Fingerprint: c.Fingerprint,
Insecure: c.Insecure, Timeout: c.Timeout, TokenSecretEnv: EnvName(c.Name)}
if c.Reference {
cfg.Reference = cl
continue
}
cfg.Clients = append(cfg.Clients, config.Client{Cluster: cl, Mapping: c.Mapping, Policies: c.Policies})
}
return cfg, nil
}
// EnvName is the environment variable proposed for the secret of a cluster in an export.
func EnvName(cluster string) string {
var b strings.Builder
b.WriteString("PVE_")
for _, r := range strings.ToUpper(cluster) {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
b.WriteString("_SECRET")
return b.String()
}
// ImportConfig loads a CLI configuration: settings are replaced, clusters are
// created or updated (the ones absent from the file are kept unless replace is
// set). Secrets are read from token_secret_env / token_secret_file when they
// are reachable from the server; otherwise the stored secret is kept.
// It returns warnings for the clusters left without a secret.
func (s *Store) ImportConfig(ctx context.Context, cfg *config.Config, replace bool) ([]string, error) {
var warns []string
st := Settings{Kinds: cfg.Kinds, Objects: cfg.Objects, Policies: cfg.Policies, Prune: cfg.Prune}
existing, err := s.Clusters(ctx)
if err != nil {
return nil, err
}
have := map[string]Cluster{}
for _, c := range existing {
have[c.Name] = c
}
save := func(cl config.Cluster, ref bool, pos int, mapping map[model.Kind]map[string]map[string]any, pol map[model.Kind]map[string]adapter.Policy) error {
c := Cluster{Name: cl.Name, Reference: ref, Position: pos, URL: cl.URL, TokenID: cl.TokenID,
Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: cl.Timeout, Mapping: mapping, Policies: pol}
if cl.CAFile != "" {
warns = append(warns, fmt.Sprintf("%s: ca_file %s not imported, paste the CA certificate in the cluster settings", cl.Name, cl.CAFile))
}
if sec, err := cl.ResolveSecret(); err == nil && sec != "" {
c.Secret = sec
} else if !have[cl.Name].HasSecret {
warns = append(warns, fmt.Sprintf("%s: token secret not readable from the server, cluster not imported (%v)", cl.Name, err))
return nil
}
return s.SaveCluster(ctx, "", c)
}
if err := save(cfg.Reference, true, 0, nil, nil); err != nil {
return warns, err
}
keep := map[string]bool{cfg.Reference.Name: true}
for i, c := range cfg.Clients {
keep[c.Name] = true
if err := save(c.Cluster, false, i+1, c.Mapping, c.Policies); err != nil {
return warns, err
}
}
if replace {
for name := range have {
if !keep[name] {
if err := s.DeleteCluster(ctx, name); err != nil {
return warns, err
}
}
}
}
return warns, s.SaveSettings(ctx, st)
}
+106 -14
View File
@@ -3,6 +3,7 @@ package store
import ( import (
"bytes" "bytes"
"context" "context"
"database/sql"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -10,30 +11,30 @@ import (
func testKey(b byte) []byte { return bytes.Repeat([]byte{b}, 32) } func testKey(b byte) []byte { return bytes.Repeat([]byte{b}, 32) }
func TestSecretsEncryptedAndKeyChecked(t *testing.T) { func TestPasswordsEncryptedAndKeyChecked(t *testing.T) {
ctx := context.Background() ctx := context.Background()
path := filepath.Join(t.TempDir(), "t.db") path := filepath.Join(t.TempDir(), "t.db")
st, err := Open(path, testKey(1)) st, err := Open(path, testKey(1))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := st.SaveCluster(ctx, "", Cluster{Name: "ref", Reference: true, URL: "https://ref:8006", TokenID: "a@pve!ro", Secret: "s3cr3t-uuid"}); err != nil { if err := st.SaveCluster(ctx, "", Cluster{Name: "ref", Role: ClusterPrimary, URL: "https://ref:8006", Password: "s3cr3t-root"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
var blob []byte var blob []byte
if err := st.db.QueryRowContext(ctx, `SELECT secret FROM clusters WHERE name = 'ref'`).Scan(&blob); err != nil { if err := st.db.QueryRowContext(ctx, `SELECT password FROM clusters WHERE name = 'ref'`).Scan(&blob); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if bytes.Contains(blob, []byte("s3cr3t")) { if bytes.Contains(blob, []byte("s3cr3t")) {
t.Fatal("secret stored in clear") t.Fatal("password stored in clear")
} }
cls, _ := st.Clusters(ctx) cls, _ := st.Clusters(ctx)
if len(cls) != 1 || cls[0].Secret != "" || !cls[0].HasSecret { if len(cls) != 1 || cls[0].Password != "" || !cls[0].HasPassword || cls[0].User != "root@pam" || cls[0].TLS != TLSPin {
t.Fatalf("listing must hide the secret: %+v", cls) t.Fatalf("listing must hide the password: %+v", cls)
} }
// an empty secret on update keeps the stored one // an empty password on update keeps the stored one
c := cls[0] c := cls[0]
c.URL = "https://ref2:8006" c.Timeout = "20s"
if err := st.SaveCluster(ctx, "ref", c); err != nil { if err := st.SaveCluster(ctx, "ref", c); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -41,12 +42,8 @@ func TestSecretsEncryptedAndKeyChecked(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if s, _ := cfg.Reference.ResolveSecret(); s != "s3cr3t-uuid" { if cfg.Reference.Password != "s3cr3t-root" || cfg.Reference.User != "root@pam" {
t.Fatalf("secret after update: %q", s) t.Fatalf("password after update: %+v", cfg.Reference)
}
exp, _ := st.ExportConfig(ctx)
if exp.Reference.Secret != "" || exp.Reference.TokenSecretEnv != "PVE_REF_SECRET" {
t.Fatalf("export: %+v", exp.Reference)
} }
st.Close() st.Close()
if _, err := Open(path, testKey(2)); err == nil { if _, err := Open(path, testKey(2)); err == nil {
@@ -54,6 +51,74 @@ func TestSecretsEncryptedAndKeyChecked(t *testing.T) {
} }
} }
func TestRolesAndPins(t *testing.T) {
ctx := context.Background()
st, err := Open(filepath.Join(t.TempDir(), "t.db"), testKey(1))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, err := st.Config(ctx); err != ErrNoReference {
t.Fatalf("empty database: %v", err)
}
for _, n := range []string{"a", "b", "c"} {
if err := st.SaveCluster(ctx, "", Cluster{Name: n, URL: "https://" + n + ":8006", Password: "pw"}); err != nil {
t.Fatal(err)
}
}
if err := st.SetRole(ctx, "a", ClusterPrimary); err != nil {
t.Fatal(err)
}
if err := st.SetRole(ctx, "c", ClusterDisabled); err != nil {
t.Fatal(err)
}
if err := st.SetRole(ctx, "b", ClusterPrimary); err != nil { // a becomes a target
t.Fatal(err)
}
cfg, err := st.Config(ctx)
if err != nil {
t.Fatal(err)
}
if cfg.Reference.Name != "b" || len(cfg.Clients) != 1 || cfg.Clients[0].Name != "a" {
t.Fatalf("primary %s, targets %+v", cfg.Reference.Name, cfg.Clients)
}
if err := st.Pin(ctx, "a", "AA:BB", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl", &Check{OK: true}); err != nil {
t.Fatal(err)
}
_, cc, _ := st.Cluster(ctx, "a")
if cc.Fingerprint != "AA:BB" || cc.SSHHostKey == "" {
t.Fatalf("pins = %+v", cc)
}
// another address: the pins are forgotten
c, _, _ := st.Cluster(ctx, "a")
c.URL = "https://a2:8006"
if err := st.SaveCluster(ctx, "a", c); err != nil {
t.Fatal(err)
}
_, cc, _ = st.Cluster(ctx, "a")
if cc.Fingerprint != "" || cc.SSHHostKey != "" {
t.Fatalf("pins kept after an address change: %+v", cc)
}
}
func TestFirstAdmin(t *testing.T) {
ctx := context.Background()
st, err := Open(filepath.Join(t.TempDir(), "t.db"), testKey(1))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.CreateFirstAdmin(ctx, "admin", "too-short"); err == nil {
t.Fatal("short password accepted")
}
if err := st.CreateFirstAdmin(ctx, "admin", "a-long-password"); err != nil {
t.Fatal(err)
}
if err := st.CreateFirstAdmin(ctx, "other", "a-long-password"); err != ErrSetupDone {
t.Fatalf("second setup: %v", err)
}
}
func TestUsers(t *testing.T) { func TestUsers(t *testing.T) {
ctx := context.Background() ctx := context.Background()
st, err := Open(filepath.Join(t.TempDir(), "t.db"), testKey(1)) st, err := Open(filepath.Join(t.TempDir(), "t.db"), testKey(1))
@@ -99,3 +164,30 @@ func TestPasswordHash(t *testing.T) {
t.Fatal("hash check") t.Fatal("hash check")
} }
} }
func TestMigrateFromTokens(t *testing.T) {
path := filepath.Join(t.TempDir(), "t.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`CREATE TABLE clusters (name TEXT PRIMARY KEY, reference INTEGER, url TEXT, token_id TEXT, secret BLOB, updated_at INTEGER);
INSERT INTO clusters VALUES ('old', 1, 'https://x:8006', 'a@pve!t', x'01', 0);
CREATE TABLE users (name TEXT PRIMARY KEY, role TEXT NOT NULL, password TEXT NOT NULL, epoch INTEGER NOT NULL, created_at INTEGER NOT NULL, last_login INTEGER NOT NULL DEFAULT 0);
INSERT INTO users VALUES ('admin', 'admin', 'h', 1, 1, 0)`); err != nil {
t.Fatal(err)
}
db.Close()
st, err := Open(path, testKey(1))
if err != nil {
t.Fatal(err)
}
defer st.Close()
cls, err := st.Clusters(context.Background())
if err != nil || len(cls) != 0 {
t.Fatalf("clusters after migration: %v %v", cls, err)
}
if n, _ := st.CountUsers(context.Background()); n != 1 {
t.Fatal("users must be kept")
}
}
+30
View File
@@ -180,3 +180,33 @@ func (s *Store) keepAnAdmin(ctx context.Context, name, newRole string) error {
} }
return nil return nil
} }
// ErrSetupDone: the first administrator already exists.
var ErrSetupDone = errors.New("the first administrator already exists")
// CreateFirstAdmin creates the first administrator from the web page shown on an
// empty database. The insert only happens while the table is empty: two
// concurrent requests cannot both succeed.
func (s *Store) CreateFirstAdmin(ctx context.Context, name, pass string) error {
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, " \t\r\n") {
return errors.New("invalid user name")
}
if len(pass) < 12 {
return errors.New("password: 12 characters minimum")
}
hash, err := HashPassword(pass)
if err != nil {
return err
}
now := time.Now()
res, err := s.db.ExecContext(ctx, `INSERT INTO users (name, role, password, epoch, created_at)
SELECT ?, 'admin', ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM users)`, name, hash, now.UnixNano(), now.Unix())
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrSetupDone
}
return nil
}
+415 -159
View File
@@ -1,5 +1,6 @@
// pve-confsync web UI: clusters, differences per client, dry-run and synchronization // pve-confsync web UI: clusters (root credentials, primary and targets), replicated
// (per object, per client, global), execution journal, audit trail and settings. // items, differences per target, dry-run and synchronization (per object, per
// target, towards several targets), execution journal, audit trail and settings.
// No framework and no build step, like Logstream. The DOM is built with h(): // No framework and no build step, like Logstream. The DOM is built with h():
// data coming from the clusters is always set as text, never as HTML. // data coming from the clusters is always set as text, never as HTML.
(function () { (function () {
@@ -9,73 +10,90 @@
const I18N = { const I18N = {
fr: { fr: {
demo: 'DÉMO', searchPh: 'Filtrer les objets (nom, champ, valeur)', refresh: 'Actualiser', demo: 'DÉMO', searchPh: 'Filtrer les objets (nom, champ, valeur)', refresh: 'Actualiser',
refreshTitle: 'Relire la référence et les clients', settings: 'Paramètres', theme: 'Thème clair / sombre', refreshTitle: 'Relire le primaire et les cibles', settings: 'Paramètres', theme: 'Thème clair / sombre',
logout: 'Se déconnecter', viewAria: 'Affichage', viewDiff: 'Écarts', viewRuns: 'Journal', viewAudit: 'Audit', logout: 'Se déconnecter', viewAria: 'Affichage', viewDiff: 'Écarts', viewClusters: 'Clusters', viewRuns: 'Journal', viewAudit: 'Audit',
kindAria: "Type d'objet", statusAria: 'Statut', modeAria: 'Mode', dryAll: 'Dry-run global', syncAll: 'Tout synchroniser', kindAria: "Type d'objet", statusAria: 'Statut', modeAria: 'Mode', dryAll: 'Dry-run', syncAll: 'Répliquer',
setupText: "Aucun cluster de référence n'est défini : ajoutez la référence puis les clients.", setupBtn: 'Ajouter les clusters', kindMenu: 'Éléments répliqués', targetMenu: 'Cibles', allTargets: 'Toutes les cibles', noTarget: 'Aucune cible sélectionnée.',
kindMenuRO: 'Seuls les administrateurs changent les éléments répliqués.',
kindMenuHelp: 'Un compte a besoin de ses groupes, un mot de passe de son compte : ils sont cochés ensemble.',
kindGroups: { dc: 'Datacenter', access: 'Comptes et droits (realm pve)', sdn: 'SDN' },
setupClusters: 'Base vide : ajoutez vos clusters (URL et mot de passe root), puis choisissez le primaire.', setupClustersBtn: 'Ajouter des clusters',
setupPrimary: 'Aucun cluster primaire : choisissez la source de la réplication dans la vue Clusters.', setupPrimaryBtn: 'Choisir le primaire',
stripAria: 'Clusters', colKind: 'Type', colObject: 'Objet', colStatus: 'Statut', colFields: 'Champs', stripAria: 'Clusters', colKind: 'Type', colObject: 'Objet', colStatus: 'Statut', colFields: 'Champs',
colDate: 'Date', colUser: 'Utilisateur', colMode: 'Mode', colScope: 'Portée', colClients: 'Clients', colSummary: 'Résumé', colDate: 'Date', colUser: 'Utilisateur', colMode: 'Mode', colScope: 'Portée', colClients: 'Cibles', colSummary: 'Résumé',
colIP: 'Adresse', colAction: 'Action', colTarget: 'Cible', colResult: 'Résultat', colDetail: 'Détail', colIP: 'Adresse', colAction: 'Action', colTarget: 'Cible', colResult: 'Résultat', colDetail: 'Détail',
colField: 'Champ', colValue: 'Valeur', colPolicy: 'Politique', colInclude: 'Inclure (motifs)', colExclude: 'Exclure (motifs)', colField: 'Champ', colValue: 'Valeur', colPolicy: 'Politique', colInclude: 'Inclure (motifs)', colExclude: 'Exclure (motifs)',
colRole: 'Rôle', colPassword: 'Nouveau mot de passe', colLastLogin: 'Dernière connexion', colRole: 'Rôle', colPassword: 'Nouveau mot de passe', colLastLogin: 'Dernière connexion',
more: 'Plus', toTop: 'Haut de page', close: 'Fermer', cancel: 'Annuler', save: 'Enregistrer', delete: 'Supprimer', more: 'Plus', toTop: 'Haut de page', close: 'Fermer', cancel: 'Annuler', save: 'Enregistrer', delete: 'Supprimer',
applyBtn: 'Synchroniser', addRow: '+ Ajouter', applyBtn: 'Synchroniser', addRow: '+ Ajouter',
kinds: { 'datacenter-options': 'Options datacenter', 'sdn-zone': 'Zones SDN', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets' }, kinds: {
kindShort: { 'datacenter-options': 'options', 'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet' }, 'datacenter-options': 'Options datacenter', 'pve-group': 'Groupes', 'pve-role': 'Rôles personnalisés',
'pve-user': 'Comptes du realm pve', 'pve-password': 'Mots de passe (SSH)',
'sdn-zone': 'Zones SDN (VLAN, VXLAN)', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets',
},
kindShort: {
'datacenter-options': 'options', 'pve-group': 'groupe', 'pve-role': 'rôle', 'pve-user': 'compte', 'pve-password': 'mot de passe',
'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet',
},
allKinds: 'Tous les types', allKinds: 'Tous les types',
st: { same: 'Conforme', create: 'À créer', update: 'À modifier', delete: 'À supprimer', extra: 'Hors référence', error: 'Erreur' }, st: { same: 'Conforme', create: 'À créer', update: 'À modifier', delete: 'À supprimer', extra: 'Hors primaire', error: 'Erreur' },
filterDiff: 'Écarts et erreurs', filterAll: 'Tous les objets', filterSame: 'Conformes', filterError: 'Erreurs', filterDiff: 'Écarts et erreurs', filterAll: 'Tous les objets', filterSame: 'Conformes', filterError: 'Erreurs',
reference: 'RÉF', unreadable: 'illisible', inLine: 'conforme', nodes: '{n} nœud(s)', reference: 'PRIMAIRE', unreadable: 'illisible', inLine: 'conforme', nodes: '{n} nœud(s)',
readAt: 'lu à {t} ({ms} ms)', dryClient: 'Dry-run du client', syncClient: 'Synchroniser le client', readAt: 'lu à {t} ({ms} ms)', dryClient: 'Dry-run de la cible', syncClient: 'Synchroniser la cible',
dryObj: 'Dry-run de cet objet', syncObj: 'Synchroniser cet objet', dryObj: 'Dry-run de cet objet', syncObj: 'Synchroniser cet objet',
refError: 'Référence illisible', clientError: 'Client illisible', planError: 'Bloquant', warning: 'Avertissement', refError: 'Primaire illisible', clientError: 'Cible illisible', planError: 'Bloquant', warning: 'Avertissement',
fieldsN: '{n} champ(s)', noFields: 'aucun champ', fieldsN: '{n} champ(s)', objectsN: '{n} objet(s)', noFields: 'aucun champ',
thField: 'Champ', thPolicy: 'Politique', thRef: 'Référence', thMapping: 'Mapping client', thCurrent: 'Client actuel', thTarget: 'Après synchro', thField: 'Champ', thPolicy: 'Politique', thRef: 'Primaire', thMapping: 'Mapping cible', thCurrent: 'Cible actuelle', thTarget: 'Après synchro',
emptyOk: 'Aucun écart pour ce client.', emptyOkHint: 'Le client est conforme à la référence pour les types synchronisés.', emptyOk: 'Aucun écart pour cette cible.', emptyOkHint: 'La cible est conforme au primaire pour les éléments répliqués.',
emptyFilter: 'Aucun objet ne correspond aux filtres.', emptyFilterHint: 'Changez le type, le statut ou la recherche.', emptyFilter: 'Aucun objet ne correspond aux filtres.', emptyFilterHint: 'Changez le type, le statut ou la recherche.',
noClient: 'Aucun client.', noClientHint: 'Ajoutez des clusters clients dans les paramètres.', noClient: 'Aucune cible.', noClientHint: 'Ajoutez des clusters cibles dans la vue Clusters.',
connOk: 'Connecté', connKo: 'Hors ligne', statsLine: 'Référence {ref} · {n} client(s) · {d} avec écarts · lu à {t}', connOk: 'Connecté', connKo: 'Hors ligne', statsLine: 'Primaire {ref} · {n} cible(s) · {d} avec écarts · lu à {t}',
dryTitle: 'Dry-run : {what}', whatGlobal: 'tous les clients', whatClient: 'client {c}', whatObject: '{o} sur {c}', dryTitle: 'Dry-run : {what}', whatGlobal: 'toutes les cibles', whatTargets: '{l}', whatClient: 'cible {c}', whatObject: '{o} sur {c}',
reading: 'Lecture des clusters…', planEmpty: 'Rien à faire : conforme.', planErrors: 'Erreurs bloquantes : rien ne sera appliqué sur ce client.', reading: 'Lecture des clusters…', planEmpty: 'Rien à faire : conforme.', planErrors: 'Erreurs bloquantes : rien ne sera appliqué sur cette cible.',
planSaved: 'Dry-run enregistré dans le journal (n° {id}).', changesN: '{n} changement(s)', sdnBadge: 'SDN', planSaved: 'Dry-run enregistré dans le journal (n° {id}).', changesN: '{n} changement(s)', sdnBadge: 'SDN',
sdnConfirm: "Les changements SDN sont appliqués sous verrou puis validés : le réseau de tous les nœuds de {c} est rechargé. Tapez « {w} » pour confirmer.", sdnConfirm: "Les changements SDN sont appliqués sous verrou puis validés : le réseau de tous les nœuds de {c} est rechargé. Tapez « {w} » pour confirmer.",
sdnConfirmGlobal: "Des changements SDN rechargent le réseau des nœuds de plusieurs clients. Tapez le nom du cluster de référence « {w} » pour confirmer.", sdnConfirmGlobal: "Des changements SDN rechargent le réseau des nœuds de plusieurs cibles. Tapez le nom du cluster primaire « {w} » pour confirmer.",
applyNow: 'Appliquer', applying: 'Synchronisation en cours…', applyDone: 'Synchronisation terminée', stale: "L'état a changé depuis le dry-run : relancez-le.", applyNow: 'Appliquer', applying: 'Synchronisation en cours…', applyDone: 'Synchronisation terminée', stale: "L'état a changé depuis le dry-run : relancez-le.",
relaunch: 'Relancer le dry-run', runN: 'Journal n° {id}', was: 'était', relaunch: 'Relancer le dry-run', runN: 'Journal n° {id}', was: 'était',
res: { pending: 'en attente', running: 'en cours', ok: 'OK', error: 'échec', noop: 'rien à faire', blocked: 'bloqué' }, res: { pending: 'en attente', running: 'en cours', ok: 'OK', error: 'échec', noop: 'rien à faire', blocked: 'bloqué' },
mode: { 'dry-run': 'dry-run', apply: 'synchro' }, scope: { global: 'global', client: 'client', object: 'objet' }, mode: { 'dry-run': 'dry-run', apply: 'synchro' }, scope: { global: 'cibles', client: 'cible', object: 'objet' },
allModes: 'Tous les modes', onlyApply: 'Synchronisations', onlyDry: 'Dry-runs', allModes: 'Tous les modes', onlyApply: 'Synchronisations', onlyDry: 'Dry-runs',
noRuns: 'Aucune exécution.', noAudit: 'Aucun évènement.', noRuns: 'Aucune exécution.', noAudit: 'Aucun évènement.',
action: { action: {
login: 'connexion', logout: 'déconnexion', 'dry-run': 'dry-run', 'apply-start': 'synchro lancée', apply: 'synchro', setup: 'premier administrateur', login: 'connexion', logout: 'déconnexion', 'dry-run': 'dry-run', 'apply-start': 'synchro lancée', apply: 'synchro',
'cluster-save': 'cluster enregistré', 'cluster-delete': 'cluster supprimé', 'cluster-check': 'test du cluster', 'cluster-save': 'cluster enregistré', 'cluster-delete': 'cluster supprimé', 'cluster-check': 'test du cluster',
'settings-save': 'paramètres', 'config-export': 'export YAML', 'config-import': 'import YAML', 'cluster-role': 'rôle du cluster', 'cluster-forget': 'empreintes oubliées', 'settings-save': 'paramètres',
'user-save': 'utilisateur enregistré', 'user-delete': 'utilisateur supprimé', 'password-change': 'mot de passe', 'user-save': 'utilisateur enregistré', 'user-delete': 'utilisateur supprimé', 'password-change': 'mot de passe',
}, },
tabClusters: 'Clusters', tabSync: 'Synchronisation', tabUsers: 'Utilisateurs', tabConfig: 'Import / export', tabAccount: 'Compte', tabAppearance: 'Apparence', tabSync: 'Synchronisation', tabUsers: 'Utilisateurs', tabAccount: 'Compte', tabAppearance: 'Apparence',
roNote: 'Lecture seule : seuls les administrateurs modifient ces paramètres.', roNote: 'Lecture seule : seuls les administrateurs modifient ces paramètres.',
addCluster: '+ Ajouter un cluster', addCluster: '+ Ajouter un cluster', addBulk: '+ Ajouter une liste', testAll: 'Tout tester',
clustersHelp: "Un cluster de référence (token en lecture seule : Sys.Audit, SDN.Audit) et des clients (Sys.Audit, Sys.Modify si les options datacenter sont synchronisées, SDN.Audit, SDN.Allocate). Les secrets des tokens sont chiffrés en base et ne sont jamais réaffichés.", clustersHelp: "Chaque cluster est joint avec root@pam : l'API pour la configuration, SSH (root, même mot de passe) pour ce que l'API ne couvre pas (mots de passe du realm pve). Le certificat TLS et la clé d'hôte SSH sont enregistrés au premier test, puis vérifiés. Le primaire n'est jamais modifié. Les mots de passe sont chiffrés en base (AES-256-GCM) et jamais réaffichés.",
test: 'Tester', edit: 'Modifier', testing: 'Test en cours…', testOk: 'Accès OK', role: { admin: 'admin', operator: 'opérateur', viewer: 'lecture seule' }, test: 'Tester', edit: 'Modifier', testing: 'Test en cours…', testOk: 'Accès OK', role: { admin: 'admin', operator: 'opérateur', viewer: 'lecture seule' },
newCluster: 'Nouveau cluster', editCluster: 'Cluster {c}', cName: 'Nom', cRole: 'Rôle', roleClient: 'Client', roleRef: 'Référence', crole: { primary: 'primaire', target: 'cible', disabled: 'inactif' }, makePrimary: 'Définir comme primaire',
cToken: 'Token', cSecret: 'Secret', cTLS: 'TLS', cFp: 'Empreinte', cTimeout: 'Délai', neverTested: 'jamais testé', testedAt: 'testé le {t}', pinned: 'enregistré : {l}', trustKey: 'Faire confiance à la nouvelle clé',
tlsFp: 'Empreinte SHA-256 du certificat', tlsCa: "Certificat d'autorité (PEM)", tlsSys: 'Autorités du système', tlsInsecure: 'Sans vérification (labo uniquement)', confirmTrust: "La clé d'hôte SSH de {c} a changé ({fp}). Ne l'acceptez que si le nœud a été réinstallé ou remplacé. Faire confiance à cette clé ?",
secretKeep: 'Laisser vide pour conserver le secret enregistré.', secretNew: 'Secret du token API (UUID), chiffré en base.', newCluster: 'Nouveau cluster', editCluster: 'Cluster {c}', cName: 'Nom', cRole: 'Rôle',
cMapping: 'Mapping (valeurs propres au client)', cMappingHelp: "Valeurs prioritaires sur la référence pour ce client : bridge, nodes, peers d'une zone, next-id… Les champs en politique « override » ne sont écrits qu'à partir d'ici.", rolePrimary: 'Primaire', roleTarget: 'Cible', roleDisabled: 'Inactif',
cPolicies: 'Politiques propres au client', cPoliciesHelp: 'Prioritaires sur les politiques globales pour ce client.', cUser: 'Utilisateur', cPassword: 'Mot de passe root', cTLS: 'TLS', cFp: 'Empreinte', cTimeout: 'Délai',
confirmDelete: 'Supprimer le cluster {c} ? Ses mappings sont perdus.', confirmDeleteUser: "Supprimer l'utilisateur {u} ?", cSSHHost: 'Nœud SSH', cSSHPort: 'Port SSH', cSSHKey: "Clé d'hôte SSH", sshKeyNone: 'enregistrée au premier test',
kindsTitle: 'Types synchronisés', kindsHelp: 'Hors périmètre v1 : EVPN, firewall, storage, pools/ACL, jobs, secrets.', fpPh: 'enregistrée au premier test', sshHostPh: "hôte de l'URL",
pruneTitle: 'Suppression', pruneLabel: 'Supprimer chez les clients les objets absents de la référence', tlsFp: 'Empreinte du certificat (enregistrée au premier test)', tlsCa: "Certificat d'autorité (PEM)", tlsSys: 'Autorités du système', tlsInsecure: 'Sans vérification (labo uniquement)',
passwordKeep: 'Laisser vide pour conserver le mot de passe enregistré.', passwordNew: 'Mot de passe de root (API root@pam et SSH), chiffré en base.',
forget: 'Oublier les empreintes', confirmForget: "Oublier le certificat TLS et la clé SSH enregistrés pour {c} ? Ils seront enregistrés de nouveau au prochain test.",
cMapping: 'Mapping (valeurs propres à la cible)', cMappingHelp: "Valeurs prioritaires sur le primaire pour cette cible : bridge, nodes, peers d'une zone, next-id… Les champs en politique « override » ne sont écrits qu'à partir d'ici.",
cPolicies: 'Politiques propres à la cible', cPoliciesHelp: 'Prioritaires sur les politiques globales pour cette cible.',
confirmDelete: 'Supprimer le cluster {c} ? Son mapping est perdu.', confirmDeleteUser: "Supprimer l'utilisateur {u} ?",
bulkTitle: 'Ajouter une liste de clusters', bulkHelp: "Un cluster par ligne : « nom URL », ou l'URL seule (le nom est alors l'hôte). Tous sont ajoutés comme cibles avec le même mot de passe root, puis testés ; choisissez ensuite le primaire.",
bulkPassword: 'Mot de passe root', bulkGo: 'Ajouter et tester', bulkBadLine: 'ligne illisible : {l}',
kindsTitle: 'Éléments répliqués', kindsHelp: 'Un compte a besoin de ses groupes, un mot de passe de son compte. Hors périmètre : EVPN, firewall, storage, ACL, jobs, tokens API, comptes pam et des autres realms.',
pruneTitle: 'Suppression', pruneLabel: 'Supprimer sur les cibles les objets absents du primaire',
pruneHelp: 'Désactivé par défaut (mode additif + mise à jour). Activé, les dry-runs listent aussi les suppressions.', pruneHelp: 'Désactivé par défaut (mode additif + mise à jour). Activé, les dry-runs listent aussi les suppressions.',
selTitle: 'Sélection des objets', selHelp: "Motifs glob sur l'identifiant, séparés par des virgules (ex. lab*, test-?). Vide : tout.", selTitle: 'Sélection des objets', selHelp: "Motifs glob sur l'identifiant, séparés par des virgules (ex. lab*, test-?, *@pve). Vide : tout.",
polTitle: 'Politiques par champ', polHelp: 'sync : copie de la référence ; ignore : jamais lu ni écrit ; override : valeur fournie par le mapping du client. Défauts :', polTitle: 'Politiques par champ', polHelp: 'sync : copie du primaire ; ignore : jamais lu ni écrit ; override : valeur fournie par le mapping de la cible. Défauts :',
unsaved: 'Modifications non enregistrées', saved: 'Enregistré', saveFailed: 'Échec : {e}', unsaved: 'Modifications non enregistrées', saved: 'Enregistré', saveFailed: 'Échec : {e}',
usersHelp: "Comptes locaux (toujours disponibles, même si le fournisseur OIDC est indisponible). Rôles : admin (tout), opérateur (dry-run et synchronisation), lecture seule. Les utilisateurs OIDC reçoivent leur rôle d'après leurs groupes.", usersHelp: "Comptes locaux de pve-confsync (toujours disponibles, même si le fournisseur OIDC est indisponible). Rôles : admin (tout), opérateur (dry-run et synchronisation), lecture seule. Les utilisateurs OIDC reçoivent leur rôle d'après leurs groupes.",
addUser: '+ Ajouter un utilisateur', never: 'jamais', pwdKeep: 'inchangé', pwdRequired: 'obligatoire', addUser: '+ Ajouter un utilisateur', never: 'jamais', pwdKeep: 'inchangé', pwdRequired: 'obligatoire',
exportTitle: 'Exporter', exportHelp: 'Configuration au format YAML de la CLI, versionnable dans Git. Les secrets ne sont pas exportés : chaque cluster reçoit une variable token_secret_env à renseigner.',
exportBtn: 'Télécharger confsync.yaml', importTitle: 'Importer',
importHelp: "Fichier YAML de la CLI : remplace les paramètres de synchronisation, crée ou met à jour les clusters. Les secrets sont lus depuis token_secret_env / token_secret_file s'ils sont accessibles au serveur, sinon le secret déjà enregistré est conservé.",
importPick: 'Choisir un fichier…', importReplace: 'Supprimer les clusters absents du fichier', importBtn: 'Importer', importOk: 'Configuration importée.',
accountTitle: 'Mon compte', pwdTitle: 'Changer de mot de passe', pwdOld: 'Mot de passe actuel', pwdNew: 'Nouveau', pwdNew2: 'Confirmation', accountTitle: 'Mon compte', pwdTitle: 'Changer de mot de passe', pwdOld: 'Mot de passe actuel', pwdNew: 'Nouveau', pwdNew2: 'Confirmation',
pwdBtn: 'Changer le mot de passe', pwdMismatch: 'Les deux mots de passe diffèrent.', pwdDone: 'Mot de passe changé.', pwdOidc: 'Mot de passe géré par le fournisseur d\'identité.', pwdBtn: 'Changer le mot de passe', pwdMismatch: 'Les deux mots de passe diffèrent.', pwdDone: 'Mot de passe changé.', pwdOidc: 'Mot de passe géré par le fournisseur d\'identité.',
accUser: 'Utilisateur', accRole: 'Rôle', accMode: 'Connexion', modeLocal: 'compte local', modeOidc: 'OIDC', accUser: 'Utilisateur', accRole: 'Rôle', accMode: 'Connexion', modeLocal: 'compte local', modeOidc: 'OIDC',
@@ -85,73 +103,90 @@
}, },
en: { en: {
demo: 'DEMO', searchPh: 'Filter objects (name, field, value)', refresh: 'Refresh', demo: 'DEMO', searchPh: 'Filter objects (name, field, value)', refresh: 'Refresh',
refreshTitle: 'Read the reference and the clients again', settings: 'Settings', theme: 'Light / dark theme', refreshTitle: 'Read the primary and the targets again', settings: 'Settings', theme: 'Light / dark theme',
logout: 'Sign out', viewAria: 'View', viewDiff: 'Differences', viewRuns: 'Journal', viewAudit: 'Audit', logout: 'Sign out', viewAria: 'View', viewDiff: 'Differences', viewClusters: 'Clusters', viewRuns: 'Journal', viewAudit: 'Audit',
kindAria: 'Object kind', statusAria: 'Status', modeAria: 'Mode', dryAll: 'Global dry-run', syncAll: 'Sync everything', kindAria: 'Object kind', statusAria: 'Status', modeAria: 'Mode', dryAll: 'Dry-run', syncAll: 'Replicate',
setupText: 'No reference cluster yet: add the reference, then the clients.', setupBtn: 'Add clusters', kindMenu: 'Replicated items', targetMenu: 'Targets', allTargets: 'All targets', noTarget: 'No target selected.',
kindMenuRO: 'Only administrators change the replicated items.',
kindMenuHelp: 'An account needs its groups, a password its account: they are checked together.',
kindGroups: { dc: 'Datacenter', access: 'Accounts and permissions (pve realm)', sdn: 'SDN' },
setupClusters: 'Empty database: add your clusters (URL and root password), then choose the primary.', setupClustersBtn: 'Add clusters',
setupPrimary: 'No primary cluster: choose the source of the replication in the Clusters view.', setupPrimaryBtn: 'Choose the primary',
stripAria: 'Clusters', colKind: 'Kind', colObject: 'Object', colStatus: 'Status', colFields: 'Fields', stripAria: 'Clusters', colKind: 'Kind', colObject: 'Object', colStatus: 'Status', colFields: 'Fields',
colDate: 'Date', colUser: 'User', colMode: 'Mode', colScope: 'Scope', colClients: 'Clients', colSummary: 'Summary', colDate: 'Date', colUser: 'User', colMode: 'Mode', colScope: 'Scope', colClients: 'Targets', colSummary: 'Summary',
colIP: 'Address', colAction: 'Action', colTarget: 'Target', colResult: 'Result', colDetail: 'Detail', colIP: 'Address', colAction: 'Action', colTarget: 'Target', colResult: 'Result', colDetail: 'Detail',
colField: 'Field', colValue: 'Value', colPolicy: 'Policy', colInclude: 'Include (patterns)', colExclude: 'Exclude (patterns)', colField: 'Field', colValue: 'Value', colPolicy: 'Policy', colInclude: 'Include (patterns)', colExclude: 'Exclude (patterns)',
colRole: 'Role', colPassword: 'New password', colLastLogin: 'Last login', colRole: 'Role', colPassword: 'New password', colLastLogin: 'Last login',
more: 'More', toTop: 'Back to top', close: 'Close', cancel: 'Cancel', save: 'Save', delete: 'Delete', more: 'More', toTop: 'Back to top', close: 'Close', cancel: 'Cancel', save: 'Save', delete: 'Delete',
applyBtn: 'Synchronize', addRow: '+ Add', applyBtn: 'Synchronize', addRow: '+ Add',
kinds: { 'datacenter-options': 'Datacenter options', 'sdn-zone': 'SDN zones', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets' }, kinds: {
kindShort: { 'datacenter-options': 'options', 'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet' }, 'datacenter-options': 'Datacenter options', 'pve-group': 'Groups', 'pve-role': 'Custom roles',
'pve-user': 'pve realm accounts', 'pve-password': 'Passwords (SSH)',
'sdn-zone': 'SDN zones (VLAN, VXLAN)', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets',
},
kindShort: {
'datacenter-options': 'options', 'pve-group': 'group', 'pve-role': 'role', 'pve-user': 'account', 'pve-password': 'password',
'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet',
},
allKinds: 'All kinds', allKinds: 'All kinds',
st: { same: 'In sync', create: 'Create', update: 'Update', delete: 'Delete', extra: 'Not in ref.', error: 'Error' }, st: { same: 'In sync', create: 'Create', update: 'Update', delete: 'Delete', extra: 'Not on primary', error: 'Error' },
filterDiff: 'Differences and errors', filterAll: 'All objects', filterSame: 'In sync', filterError: 'Errors', filterDiff: 'Differences and errors', filterAll: 'All objects', filterSame: 'In sync', filterError: 'Errors',
reference: 'REF', unreadable: 'unreadable', inLine: 'in sync', nodes: '{n} node(s)', reference: 'PRIMARY', unreadable: 'unreadable', inLine: 'in sync', nodes: '{n} node(s)',
readAt: 'read at {t} ({ms} ms)', dryClient: 'Client dry-run', syncClient: 'Synchronize client', readAt: 'read at {t} ({ms} ms)', dryClient: 'Target dry-run', syncClient: 'Synchronize target',
dryObj: 'Dry-run this object', syncObj: 'Synchronize this object', dryObj: 'Dry-run this object', syncObj: 'Synchronize this object',
refError: 'Reference unreadable', clientError: 'Client unreadable', planError: 'Blocking', warning: 'Warning', refError: 'Primary unreadable', clientError: 'Target unreadable', planError: 'Blocking', warning: 'Warning',
fieldsN: '{n} field(s)', noFields: 'no field', fieldsN: '{n} field(s)', objectsN: '{n} object(s)', noFields: 'no field',
thField: 'Field', thPolicy: 'Policy', thRef: 'Reference', thMapping: 'Client mapping', thCurrent: 'Client now', thTarget: 'After sync', thField: 'Field', thPolicy: 'Policy', thRef: 'Primary', thMapping: 'Target mapping', thCurrent: 'Target now', thTarget: 'After sync',
emptyOk: 'No difference for this client.', emptyOkHint: 'The client is in line with the reference for the synchronized kinds.', emptyOk: 'No difference for this target.', emptyOkHint: 'The target is in line with the primary for the replicated items.',
emptyFilter: 'No object matches the filters.', emptyFilterHint: 'Change the kind, the status or the search.', emptyFilter: 'No object matches the filters.', emptyFilterHint: 'Change the kind, the status or the search.',
noClient: 'No client.', noClientHint: 'Add client clusters in the settings.', noClient: 'No target.', noClientHint: 'Add target clusters in the Clusters view.',
connOk: 'Connected', connKo: 'Offline', statsLine: 'Reference {ref} · {n} client(s) · {d} with differences · read at {t}', connOk: 'Connected', connKo: 'Offline', statsLine: 'Primary {ref} · {n} target(s) · {d} with differences · read at {t}',
dryTitle: 'Dry-run: {what}', whatGlobal: 'all clients', whatClient: 'client {c}', whatObject: '{o} on {c}', dryTitle: 'Dry-run: {what}', whatGlobal: 'all targets', whatTargets: '{l}', whatClient: 'target {c}', whatObject: '{o} on {c}',
reading: 'Reading the clusters…', planEmpty: 'Nothing to do: in sync.', planErrors: 'Blocking errors: nothing will be applied on this client.', reading: 'Reading the clusters…', planEmpty: 'Nothing to do: in sync.', planErrors: 'Blocking errors: nothing will be applied on this target.',
planSaved: 'Dry-run saved in the journal (#{id}).', changesN: '{n} change(s)', sdnBadge: 'SDN', planSaved: 'Dry-run saved in the journal (#{id}).', changesN: '{n} change(s)', sdnBadge: 'SDN',
sdnConfirm: 'SDN changes are written under lock then committed: the network of every node of {c} is reloaded. Type "{w}" to confirm.', sdnConfirm: 'SDN changes are written under lock then committed: the network of every node of {c} is reloaded. Type "{w}" to confirm.',
sdnConfirmGlobal: 'SDN changes reload the network of the nodes of several clients. Type the name of the reference cluster "{w}" to confirm.', sdnConfirmGlobal: 'SDN changes reload the network of the nodes of several targets. Type the name of the primary cluster "{w}" to confirm.',
applyNow: 'Apply', applying: 'Synchronization in progress…', applyDone: 'Synchronization finished', stale: 'The state changed since the dry-run: run it again.', applyNow: 'Apply', applying: 'Synchronization in progress…', applyDone: 'Synchronization finished', stale: 'The state changed since the dry-run: run it again.',
relaunch: 'Run the dry-run again', runN: 'Journal #{id}', was: 'was', relaunch: 'Run the dry-run again', runN: 'Journal #{id}', was: 'was',
res: { pending: 'pending', running: 'running', ok: 'OK', error: 'failed', noop: 'nothing to do', blocked: 'blocked' }, res: { pending: 'pending', running: 'running', ok: 'OK', error: 'failed', noop: 'nothing to do', blocked: 'blocked' },
mode: { 'dry-run': 'dry-run', apply: 'sync' }, scope: { global: 'global', client: 'client', object: 'object' }, mode: { 'dry-run': 'dry-run', apply: 'sync' }, scope: { global: 'targets', client: 'target', object: 'object' },
allModes: 'All modes', onlyApply: 'Synchronizations', onlyDry: 'Dry-runs', allModes: 'All modes', onlyApply: 'Synchronizations', onlyDry: 'Dry-runs',
noRuns: 'No run yet.', noAudit: 'No event.', noRuns: 'No run yet.', noAudit: 'No event.',
action: { action: {
login: 'login', logout: 'logout', 'dry-run': 'dry-run', 'apply-start': 'sync started', apply: 'sync', setup: 'first administrator', login: 'login', logout: 'logout', 'dry-run': 'dry-run', 'apply-start': 'sync started', apply: 'sync',
'cluster-save': 'cluster saved', 'cluster-delete': 'cluster deleted', 'cluster-check': 'cluster test', 'cluster-save': 'cluster saved', 'cluster-delete': 'cluster deleted', 'cluster-check': 'cluster test',
'settings-save': 'settings', 'config-export': 'YAML export', 'config-import': 'YAML import', 'cluster-role': 'cluster role', 'cluster-forget': 'fingerprints forgotten', 'settings-save': 'settings',
'user-save': 'user saved', 'user-delete': 'user deleted', 'password-change': 'password', 'user-save': 'user saved', 'user-delete': 'user deleted', 'password-change': 'password',
}, },
tabClusters: 'Clusters', tabSync: 'Synchronization', tabUsers: 'Users', tabConfig: 'Import / export', tabAccount: 'Account', tabAppearance: 'Appearance', tabSync: 'Synchronization', tabUsers: 'Users', tabAccount: 'Account', tabAppearance: 'Appearance',
roNote: 'Read only: only administrators change these settings.', roNote: 'Read only: only administrators change these settings.',
addCluster: '+ Add a cluster', addCluster: '+ Add a cluster', addBulk: '+ Add a list', testAll: 'Test all',
clustersHelp: 'One reference cluster (read-only token: Sys.Audit, SDN.Audit) and clients (Sys.Audit, Sys.Modify when datacenter options are synchronized, SDN.Audit, SDN.Allocate). Token secrets are encrypted in the database and never shown again.', clustersHelp: 'Each cluster is reached as root@pam: the API for the configuration, SSH (root, same password) for what the API does not cover (passwords of the pve realm). The TLS certificate and the SSH host key are recorded on the first test, then checked. The primary is never written. Passwords are encrypted in the database (AES-256-GCM) and never shown again.',
test: 'Test', edit: 'Edit', testing: 'Testing…', testOk: 'Access OK', role: { admin: 'admin', operator: 'operator', viewer: 'read only' }, test: 'Test', edit: 'Edit', testing: 'Testing…', testOk: 'Access OK', role: { admin: 'admin', operator: 'operator', viewer: 'read only' },
newCluster: 'New cluster', editCluster: 'Cluster {c}', cName: 'Name', cRole: 'Role', roleClient: 'Client', roleRef: 'Reference', crole: { primary: 'primary', target: 'target', disabled: 'disabled' }, makePrimary: 'Make primary',
cToken: 'Token', cSecret: 'Secret', cTLS: 'TLS', cFp: 'Fingerprint', cTimeout: 'Timeout', neverTested: 'never tested', testedAt: 'tested on {t}', pinned: 'recorded: {l}', trustKey: 'Trust the new key',
tlsFp: 'SHA-256 fingerprint of the certificate', tlsCa: 'Certificate authority (PEM)', tlsSys: 'System authorities', tlsInsecure: 'No verification (lab only)', confirmTrust: 'The SSH host key of {c} changed ({fp}). Only accept it if the node was reinstalled or replaced. Trust this key?',
secretKeep: 'Leave empty to keep the stored secret.', secretNew: 'API token secret (UUID), encrypted in the database.', newCluster: 'New cluster', editCluster: 'Cluster {c}', cName: 'Name', cRole: 'Role',
cMapping: 'Mapping (client specific values)', cMappingHelp: 'Values taking precedence over the reference for this client: bridge, nodes, peers of a zone, next-id… Fields with the "override" policy are only written from here.', rolePrimary: 'Primary', roleTarget: 'Target', roleDisabled: 'Disabled',
cPolicies: 'Client policies', cPoliciesHelp: 'Take precedence over the global policies for this client.', cUser: 'User', cPassword: 'Root password', cTLS: 'TLS', cFp: 'Fingerprint', cTimeout: 'Timeout',
confirmDelete: 'Delete cluster {c}? Its mappings are lost.', confirmDeleteUser: 'Delete user {u}?', cSSHHost: 'SSH node', cSSHPort: 'SSH port', cSSHKey: 'SSH host key', sshKeyNone: 'recorded on the first test',
kindsTitle: 'Synchronized kinds', kindsHelp: 'Out of scope in v1: EVPN, firewall, storage, pools/ACL, jobs, secrets.', fpPh: 'recorded on the first test', sshHostPh: 'host of the URL',
pruneTitle: 'Deletion', pruneLabel: 'Delete on the clients the objects absent from the reference', tlsFp: 'Certificate fingerprint (recorded on the first test)', tlsCa: 'Certificate authority (PEM)', tlsSys: 'System authorities', tlsInsecure: 'No verification (lab only)',
passwordKeep: 'Leave empty to keep the stored password.', passwordNew: 'Password of root (API root@pam and SSH), encrypted in the database.',
forget: 'Forget fingerprints', confirmForget: 'Forget the TLS certificate and SSH key recorded for {c}? They are recorded again on the next test.',
cMapping: 'Mapping (target specific values)', cMappingHelp: 'Values taking precedence over the primary for this target: bridge, nodes, peers of a zone, next-id… Fields with the "override" policy are only written from here.',
cPolicies: 'Target policies', cPoliciesHelp: 'Take precedence over the global policies for this target.',
confirmDelete: 'Delete cluster {c}? Its mapping is lost.', confirmDeleteUser: 'Delete user {u}?',
bulkTitle: 'Add a list of clusters', bulkHelp: 'One cluster per line: "name URL", or the URL alone (the name is then the host). All are added as targets with the same root password, then tested; choose the primary afterwards.',
bulkPassword: 'Root password', bulkGo: 'Add and test', bulkBadLine: 'unreadable line: {l}',
kindsTitle: 'Replicated items', kindsHelp: 'An account needs its groups, a password its account. Out of scope: EVPN, firewall, storage, ACL, jobs, API tokens, accounts of pam and the other realms.',
pruneTitle: 'Deletion', pruneLabel: 'Delete on the targets the objects absent from the primary',
pruneHelp: 'Off by default (add + update). On, dry-runs also list the deletions.', pruneHelp: 'Off by default (add + update). On, dry-runs also list the deletions.',
selTitle: 'Object selection', selHelp: 'Glob patterns on the id, comma separated (e.g. lab*, test-?). Empty: everything.', selTitle: 'Object selection', selHelp: 'Glob patterns on the id, comma separated (e.g. lab*, test-?, *@pve). Empty: everything.',
polTitle: 'Field policies', polHelp: 'sync: copied from the reference; ignore: never read nor written; override: value from the client mapping. Defaults:', polTitle: 'Field policies', polHelp: 'sync: copied from the primary; ignore: never read nor written; override: value from the target mapping. Defaults:',
unsaved: 'Unsaved changes', saved: 'Saved', saveFailed: 'Failed: {e}', unsaved: 'Unsaved changes', saved: 'Saved', saveFailed: 'Failed: {e}',
usersHelp: 'Local accounts (always available, even when the OIDC provider is down). Roles: admin (everything), operator (dry-run and synchronization), read only. OIDC users get their role from their groups.', usersHelp: 'Local accounts of pve-confsync (always available, even when the OIDC provider is down). Roles: admin (everything), operator (dry-run and synchronization), read only. OIDC users get their role from their groups.',
addUser: '+ Add a user', never: 'never', pwdKeep: 'unchanged', pwdRequired: 'required', addUser: '+ Add a user', never: 'never', pwdKeep: 'unchanged', pwdRequired: 'required',
exportTitle: 'Export', exportHelp: 'Configuration in the YAML format of the CLI, versionable in Git. Secrets are not exported: each cluster gets a token_secret_env variable to fill.',
exportBtn: 'Download confsync.yaml', importTitle: 'Import',
importHelp: 'CLI YAML file: replaces the synchronization settings, creates or updates the clusters. Secrets are read from token_secret_env / token_secret_file when the server can reach them, otherwise the stored secret is kept.',
importPick: 'Choose a file…', importReplace: 'Delete the clusters absent from the file', importBtn: 'Import', importOk: 'Configuration imported.',
accountTitle: 'My account', pwdTitle: 'Change password', pwdOld: 'Current password', pwdNew: 'New', pwdNew2: 'Confirm', accountTitle: 'My account', pwdTitle: 'Change password', pwdOld: 'Current password', pwdNew: 'New', pwdNew2: 'Confirm',
pwdBtn: 'Change password', pwdMismatch: 'The two passwords differ.', pwdDone: 'Password changed.', pwdOidc: 'Password managed by the identity provider.', pwdBtn: 'Change password', pwdMismatch: 'The two passwords differ.', pwdDone: 'Password changed.', pwdOidc: 'Password managed by the identity provider.',
accUser: 'User', accRole: 'Role', accMode: 'Login', modeLocal: 'local account', modeOidc: 'OIDC', accUser: 'User', accRole: 'Role', accMode: 'Login', modeLocal: 'local account', modeOidc: 'OIDC',
@@ -160,7 +195,8 @@
noRights: 'Your role cannot synchronize.', errorPrefix: 'Error: ', noRights: 'Your role cannot synchronize.', errorPrefix: 'Error: ',
}, },
}; };
const KINDS = ['datacenter-options', 'sdn-zone', 'sdn-vnet', 'sdn-subnet']; const KINDS = ['datacenter-options', 'pve-group', 'pve-role', 'pve-user', 'pve-password', 'sdn-zone', 'sdn-vnet', 'sdn-subnet'];
const KIND_GROUPS = [['dc', ['datacenter-options']], ['access', ['pve-group', 'pve-role', 'pve-user', 'pve-password']], ['sdn', ['sdn-zone', 'sdn-vnet', 'sdn-subnet']]];
const POLICIES = ['sync', 'ignore', 'override']; const POLICIES = ['sync', 'ignore', 'override'];
const ROLES = ['admin', 'operator', 'viewer']; const ROLES = ['admin', 'operator', 'viewer'];
@@ -299,7 +335,11 @@
const S = { const S = {
me: null, me: null,
state: null, // last comparison state: null, // last comparison
setup: false, // no reference yet setup: '', // 'clusters' (empty database) or 'primary' (no primary chosen)
clusters: [], // clusters view
settings: null, // synchronization settings (replicated kinds)
deps: {}, // kind -> kinds it needs
skip: new Set(JSON.parse(store.get('skipTargets') || '[]')), // targets left out of the global actions
view: 'diff', view: 'diff',
client: null, // selected client client: null, // selected client
open: new Set(), // expanded object rows (client|key) open: new Set(), // expanded object rows (client|key)
@@ -353,10 +393,10 @@
$$('#densitySwitch [data-density]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.density === (v || 'normal')))); $$('#densitySwitch [data-density]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.density === (v || 'normal'))));
} }
// ---------- routing (#diff/<client>, #runs, #audit) ---------- // ---------- routing (#diff/<target>, #clusters, #runs, #audit) ----------
function readHash() { function readHash() {
const [v, c] = decodeURIComponent(location.hash.slice(1)).split('/'); const [v, c] = decodeURIComponent(location.hash.slice(1)).split('/');
S.view = ['diff', 'runs', 'audit'].includes(v) ? v : 'diff'; S.view = ['diff', 'clusters', 'runs', 'audit'].includes(v) ? v : 'diff';
if (S.view === 'audit' && !can.admin()) S.view = 'diff'; if (S.view === 'audit' && !can.admin()) S.view = 'diff';
if (c) S.client = c; if (c) S.client = c;
} }
@@ -370,6 +410,7 @@
renderView(); renderView();
if (v === 'runs') loadRuns(true); if (v === 'runs') loadRuns(true);
if (v === 'audit') loadAudit(true); if (v === 'audit') loadAudit(true);
if (v === 'clusters') loadClusters();
} }
// ---------- loading ---------- // ---------- loading ----------
@@ -378,7 +419,7 @@
$('#refreshBtn').classList.add('spin'); $('#refreshBtn').classList.add('spin');
try { try {
const d = await api('GET', 'api/state' + (refresh ? '?refresh=1' : '')); const d = await api('GET', 'api/state' + (refresh ? '?refresh=1' : ''));
S.setup = !!d.setup; S.setup = d.setup || '';
S.state = d.state || null; S.state = d.state || null;
$('#error').hidden = true; $('#error').hidden = true;
if (S.state) { if (S.state) {
@@ -412,13 +453,20 @@
function renderView() { function renderView() {
$$('#viewMode [data-view]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.view === S.view))); $$('#viewMode [data-view]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.view === S.view)));
$('#diffView').hidden = S.view !== 'diff'; $('#diffView').hidden = S.view !== 'diff';
$('#clustersView').hidden = S.view !== 'clusters';
$('#runsView').hidden = S.view !== 'runs'; $('#runsView').hidden = S.view !== 'runs';
$('#auditView').hidden = S.view !== 'audit'; $('#auditView').hidden = S.view !== 'audit';
$$('[data-diff]').forEach((e) => { e.hidden = S.view !== 'diff'; }); $$('[data-diff]').forEach((e) => { e.hidden = S.view !== 'diff'; });
$$('[data-runs]').forEach((e) => { e.hidden = S.view !== 'runs'; }); $$('[data-runs]').forEach((e) => { e.hidden = S.view !== 'runs'; });
$('#globalActs').hidden = S.view !== 'diff' || !S.state || !can.sync(); $('#globalActs').hidden = S.view !== 'diff' || !S.state || !can.sync();
$('#setup').hidden = !S.setup; $('#setup').hidden = !S.setup || S.view === 'clusters';
if (S.view === 'diff') renderDiff(); if (S.setup) {
$('#setupText').textContent = t(S.setup === 'clusters' ? 'setupClusters' : 'setupPrimary');
$('#setupBtn').textContent = t(S.setup === 'clusters' ? 'setupClustersBtn' : 'setupPrimaryBtn');
}
$$('[data-admin-only]').forEach((e) => { e.hidden = !can.admin(); });
if (S.view === 'diff') { renderDiff(); renderKindMenu(); renderTargetMenu(); }
if (S.view === 'clusters') renderClusters();
if (S.view === 'runs') renderRuns(); if (S.view === 'runs') renderRuns();
if (S.view === 'audit') renderAudit(); if (S.view === 'audit') renderAudit();
} }
@@ -450,7 +498,7 @@
h('span', { class: 'c-ver', text: r.version ? 'PVE ' + r.version : '' })), h('span', { class: 'c-ver', text: r.version ? 'PVE ' + r.version : '' })),
h('div', { class: 'c-url', text: r.url }), h('div', { class: 'c-url', text: r.url }),
r.error ? h('div', { class: 'c-err', text: r.error, title: r.error }) r.error ? h('div', { class: 'c-err', text: r.error, title: r.error })
: h('div', { class: 'c-counts' }, h('span', { class: 'muted small', text: t('fieldsN', { n: r.objects }).replace(/champ\(s\)|field\(s\)/, lang === 'fr' ? 'objet(s)' : 'object(s)') }), : h('div', { class: 'c-counts' }, h('span', { class: 'muted small', text: t('objectsN', { n: r.objects }) }),
r.nodes && r.nodes.length ? h('span', { class: 'muted small', text: '· ' + t('nodes', { n: r.nodes.length }) }) : null))); r.nodes && r.nodes.length ? h('span', { class: 'muted small', text: '· ' + t('nodes', { n: r.nodes.length }) }) : null)));
for (const c of st.clients) { for (const c of st.clients) {
const cs = clientStatus(c); const cs = clientStatus(c);
@@ -632,7 +680,7 @@
const plan = { sel: null, plans: null, run: 0, poll: 0 }; const plan = { sel: null, plans: null, run: 0, poll: 0 };
function whatLabel(sel) { function whatLabel(sel) {
if (sel.scope === 'global') return t('whatGlobal'); if (sel.scope === 'global') return (sel.targets || []).length ? t('whatTargets', { l: sel.targets.join(', ') }) : t('whatGlobal');
if (sel.scope === 'client') return t('whatClient', { c: sel.client }); if (sel.scope === 'client') return t('whatClient', { c: sel.client });
return t('whatObject', { o: sel.objects.join(', '), c: sel.client }); return t('whatObject', { o: sel.objects.join(', '), c: sel.client });
} }
@@ -898,7 +946,7 @@
} }
// ---------- settings ---------- // ---------- settings ----------
const set = { tab: 'clusters', clusters: [], settings: null, defaults: {}, dirty: false }; const set = { tab: 'sync', settings: null, defaults: {}, dirty: false };
function openSettings(tab) { function openSettings(tab) {
const dlg = $('#settingsDlg'); const dlg = $('#settingsDlg');
@@ -912,12 +960,11 @@
showTab(tab || set.tab); showTab(tab || set.tab);
} }
function showTab(tab) { function showTab(tab) {
if ((tab === 'users' || tab === 'config') && !can.admin()) tab = 'clusters'; if (tab === 'users' && !can.admin()) tab = 'sync';
set.tab = tab; set.tab = tab;
$$('#setNav [data-tab]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.tab === tab))); $$('#setNav [data-tab]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.tab === tab)));
$$('#settingsDlg [data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; }); $$('#settingsDlg [data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
$('#setActions').hidden = tab !== 'sync' || !can.admin(); $('#setActions').hidden = tab !== 'sync' || !can.admin();
if (tab === 'clusters') loadClusters();
if (tab === 'sync') loadSettings(); if (tab === 'sync') loadSettings();
if (tab === 'users') loadUsers(); if (tab === 'users') loadUsers();
if (tab === 'account') renderAccount(); if (tab === 'account') renderAccount();
@@ -928,63 +975,129 @@
} }
} }
// clusters // ---------- clusters view ----------
async function loadClusters() { async function loadClusters() {
try { try {
const d = await api('GET', 'api/clusters'); S.clusters = (await api('GET', 'api/clusters')).clusters;
set.clusters = d.clusters;
} catch (e) { } catch (e) {
if (e.message !== 'auth') toast(e.message, true); if (e.message !== 'auth') toast(e.message, true);
return; return;
} }
renderClusters();
}
function checkLine(c) {
const k = c.lastCheck;
if (!k) return h('span', { class: 'muted', text: t('neverTested') });
const parts = [t('testedAt', { t: fmtTime(k.at, true) })];
if (k.version) parts.push('PVE ' + k.version);
if ((k.nodes || []).length) parts.push(t('nodes', { n: k.nodes.length }) + ' (' + k.nodes.join(', ') + ')');
return h('span', { class: k.ok ? '' : 'ko', text: (k.ok ? '✓ ' : '✗ ') + parts.join(' · ') + (k.error ? ' — ' + k.error : '') });
}
function renderClusters() {
const list = $('#clusterList'); const list = $('#clusterList');
list.replaceChildren(); list.replaceChildren();
if (!set.clusters.length) list.appendChild(h('p', { class: 'muted', text: t('setupText') })); if (!S.clusters.length) list.appendChild(h('p', { class: 'muted', text: t('setupClusters') }));
for (const c of set.clusters) { for (const c of S.clusters) {
const chk = h('div', { class: 'chk', hidden: true }); const chk = h('div', { class: 'chk', hidden: true });
list.appendChild(h('div', { class: 'citem' + (c.reference ? ' ref' : '') }, const admin = can.admin();
h('div', { class: 'n' }, c.reference ? h('span', { class: 'c-role', text: t('reference') }) : null, h('span', { text: c.name })), let role = null;
h('div', { class: 'u', text: c.url + (c.tokenId ? ' · ' + c.tokenId : ''), title: c.url }), if (admin) {
can.admin() ? h('div', { class: 'acts' }, role = h('select', { class: 'field', 'aria-label': t('cRole'), onchange: () => setRole(c.name, role.value) },
...['primary', 'target', 'disabled'].map((r) => h('option', { value: r, text: t('crole.' + r) })));
role.value = c.role;
}
const meta = h('div', { class: 'meta' });
meta.appendChild(checkLine(c));
if (admin && c.tls === 'fp' && c.fingerprint) meta.appendChild(h('span', { class: 'mono', title: c.fingerprint, text: 'TLS ' + c.fingerprint.slice(0, 23) + '…' }));
if (admin && c.tls && c.tls !== 'fp') meta.appendChild(h('span', { text: 'TLS : ' + t({ ca: 'tlsCa', sys: 'tlsSys', insecure: 'tlsInsecure' }[c.tls]) }));
if (admin && c.sshFingerprint) meta.appendChild(h('span', { class: 'mono', title: c.sshFingerprint, text: 'SSH ' + c.sshFingerprint }));
list.appendChild(h('div', { class: 'citem cl ' + c.role },
h('div', { class: 'n' }, h('span', { class: 'rbadge ' + c.role, text: t('crole.' + c.role) }), h('span', { text: c.name })),
h('div', { class: 'u', text: c.url + (c.sshHost || (c.sshPort && c.sshPort !== 22) ? ' · ssh ' + (c.sshHost || '') + (c.sshPort && c.sshPort !== 22 ? ':' + c.sshPort : '') : ''), title: c.url }),
admin ? h('div', { class: 'acts' }, role,
h('button', { type: 'button', class: 'btn tool', onclick: () => checkCluster(c.name, chk) }, icon('plug'), t('test')), h('button', { type: 'button', class: 'btn tool', onclick: () => checkCluster(c.name, chk) }, icon('plug'), t('test')),
h('button', { type: 'button', class: 'btn tool', onclick: () => editCluster(c) }, icon('pencil'), t('edit'))) : h('span'), h('button', { type: 'button', class: 'btn tool', onclick: () => editCluster(c) }, icon('pencil'), t('edit'))) : h('span'),
chk)); meta, chk));
} }
} }
async function checkCluster(name, el) { async function setRole(name, role) {
el.hidden = false;
el.className = 'chk muted';
el.textContent = t('testing');
try { try {
const r = await api('POST', 'api/clusters/' + encodeURIComponent(name) + '/check'); await api('POST', 'api/clusters/' + encodeURIComponent(name) + '/role', { role });
const nodes = (r.nodes || []).map((n) => (n.name ? n.name + ' ' : '') + 'PVE ' + n.version).join(', '); toast(t('saved'));
if (r.error) { el.className = 'chk ko'; el.textContent = '✗ ' + r.error; return; }
el.className = 'chk ' + ((r.warnings || []).length ? 'warn' : 'ok');
el.textContent = '✓ ' + t('testOk') + (nodes ? ' — ' + nodes : '') + ((r.warnings || []).length ? ' — ⚠ ' + r.warnings.join(' ; ') : '');
} catch (e) { } catch (e) {
el.className = 'chk ko'; if (e.message !== 'auth') toast(e.message, true);
el.textContent = '✗ ' + e.message;
} }
await loadClusters();
loadState(true);
}
// checkCluster tests the API and SSH; the first test records the certificate
// and the host key. A changed host key is only accepted on request (trust).
async function checkCluster(name, el, trust) {
if (el) {
el.hidden = false;
el.className = 'chk muted';
el.textContent = t('testing');
}
let r;
try {
r = await api('POST', 'api/clusters/' + encodeURIComponent(name) + '/check', trust ? { trust: true } : undefined);
} catch (e) {
if (el) { el.className = 'chk ko'; el.textContent = '✗ ' + e.message; }
return false;
}
await loadClusters();
// the list was redrawn: show the result under the new row
const row = $$('#clusterList .citem').find((x) => $('.n span:last-child', x).textContent === name);
el = row ? $('.chk', row) : null;
if (!el) return r.ok;
el.hidden = false;
const pins = [];
if (r.tlsFingerprint) pins.push('TLS ' + r.tlsFingerprint);
if (r.sshFingerprint && !r.sshMismatch) pins.push('SSH ' + r.sshFingerprint);
if (!r.ok) {
el.className = 'chk ko';
el.replaceChildren('✗ ' + r.error);
if (r.sshMismatch) {
el.append(' ', h('button', { type: 'button', class: 'link-btn', text: t('trustKey'),
onclick: () => { if (confirm(t('confirmTrust', { c: name, fp: r.sshFingerprint }))) checkCluster(name, el, true); } }));
}
return false;
}
el.className = 'chk ' + ((r.warnings || []).length ? 'warn' : 'ok');
el.textContent = '✓ ' + t('testOk') + (pins.length ? ' — ' + t('pinned', { l: pins.join(' · ') }) : '') +
((r.warnings || []).length ? ' — ⚠ ' + r.warnings.join(' ; ') : '');
return true;
}
async function testAll() {
for (const c of S.clusters) {
const row = $$('#clusterList .citem').find((x) => $('.n span:last-child', x).textContent === c.name);
await checkCluster(c.name, row ? $('.chk', row) : null);
}
loadState(true);
} }
let editing = null; let editing = null;
function editCluster(c) { function editCluster(c) {
editing = c || null; editing = c || null;
const isNew = !c; const isNew = !c;
c = c || { name: '', reference: !set.clusters.some((x) => x.reference), url: '', tokenId: '', fingerprint: '', caPem: '', insecure: false, timeout: '', mapping: {}, policies: {} }; c = c || { name: '', role: S.clusters.some((x) => x.role === 'primary') ? 'target' : 'primary', url: '', tls: 'fp', fingerprint: '', caPem: '',
timeout: '', sshHost: '', sshPort: 22, mapping: {}, policies: {} };
$('#clusterTitle').textContent = isNew ? t('newCluster') : t('editCluster', { c: c.name }); $('#clusterTitle').textContent = isNew ? t('newCluster') : t('editCluster', { c: c.name });
$('#cName').value = c.name; $('#cName').value = c.name;
setSeg('#cRole', c.reference ? 'ref' : 'client'); setSeg('#cRole', c.role);
$('#cURL').value = c.url; $('#cURL').value = c.url;
$('#cToken').value = c.tokenId; $('#cPassword').value = '';
$('#cSecret').value = ''; $('#cPassword').required = isNew || !c.hasPassword;
$('#cSecret').required = isNew || !c.hasSecret; $('#cPasswordHint').textContent = isNew || !c.hasPassword ? t('passwordNew') : t('passwordKeep');
$('#cSecretHint').textContent = isNew || !c.hasSecret ? t('secretNew') : t('secretKeep'); $('#cTLS').value = c.tls || 'fp';
$('#cTLS').value = c.fingerprint ? 'fp' : c.caPem ? 'ca' : c.insecure ? 'insecure' : (isNew ? 'fp' : 'sys');
$('#cFp').value = c.fingerprint || ''; $('#cFp').value = c.fingerprint || '';
$('#cCA').value = c.caPem || ''; $('#cCA').value = c.caPem || '';
$('#cSSHHost').value = c.sshHost || '';
$('#cSSHPort').value = c.sshPort && c.sshPort !== 22 ? c.sshPort : '';
$('#cSSHKey').textContent = c.sshFingerprint || t('sshKeyNone');
$('#cTimeout').value = c.timeout || ''; $('#cTimeout').value = c.timeout || '';
$('#cDelete').hidden = isNew; $('#cDelete').hidden = isNew;
$('#cForget').hidden = isNew || (!c.sshHostKey && !(c.tls === 'fp' && c.fingerprint));
$('#cErr').hidden = true; $('#cErr').hidden = true;
tlsFields(); tlsFields();
roleFields(); roleFields();
@@ -1009,11 +1122,11 @@
const v = $('#cTLS').value; const v = $('#cTLS').value;
$$('#clusterDlg [data-tls]').forEach((e) => { e.hidden = e.dataset.tls !== v; }); $$('#clusterDlg [data-tls]').forEach((e) => { e.hidden = e.dataset.tls !== v; });
} }
function roleFields() { $('#cClientOnly').hidden = segVal('#cRole') === 'ref'; } function roleFields() { $('#cClientOnly').hidden = segVal('#cRole') === 'primary'; }
function kindSelect(v) { function kindSelect(v) {
const s = h('select', { class: 'field' }, ...KINDS.map((k) => h('option', { value: k, text: t('kindShort.' + k) }))); const s = h('select', { class: 'field' }, ...KINDS.map((k) => h('option', { value: k, text: t('kindShort.' + k) })));
s.value = v || KINDS[1]; s.value = v || 'sdn-zone';
return s; return s;
} }
function refIds(kind) { function refIds(kind) {
@@ -1088,21 +1201,25 @@
const tls = $('#cTLS').value; const tls = $('#cTLS').value;
const c = { const c = {
name: $('#cName').value.trim(), name: $('#cName').value.trim(),
reference: segVal('#cRole') === 'ref', role: segVal('#cRole') || 'target',
position: editing ? editing.position : set.clusters.length, position: editing ? editing.position : S.clusters.length,
url: $('#cURL').value.trim(), url: $('#cURL').value.trim(),
tokenId: $('#cToken').value.trim(), user: 'root@pam',
secret: $('#cSecret').value.trim(), password: $('#cPassword').value,
hasSecret: false, hasPassword: false,
tls,
fingerprint: tls === 'fp' ? $('#cFp').value.trim() : '', fingerprint: tls === 'fp' ? $('#cFp').value.trim() : '',
caPem: tls === 'ca' ? $('#cCA').value.trim() : '', caPem: tls === 'ca' ? $('#cCA').value.trim() : '',
insecure: tls === 'insecure',
timeout: $('#cTimeout').value.trim(), timeout: $('#cTimeout').value.trim(),
sshHost: $('#cSSHHost').value.trim(),
sshPort: Number($('#cSSHPort').value) || 22,
sshHostKey: '',
sshFingerprint: '',
mapping: {}, mapping: {},
policies: {}, policies: {},
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
if (!c.reference) { if (c.role !== 'primary') {
for (const row of $$('#cMapRows .erow')) { for (const row of $$('#cMapRows .erow')) {
const k = $('select', row).value; const k = $('select', row).value;
const id = $('[data-k="id"]', row).value.trim(); const id = $('[data-k="id"]', row).value.trim();
@@ -1112,12 +1229,14 @@
} }
c.policies = readPolicies($('#cPolRows')); c.policies = readPolicies($('#cPolRows'));
} }
if (!c.secret) delete c.secret; if (!c.password) delete c.password;
try { try {
await api('PUT', 'api/clusters/' + encodeURIComponent(editing ? editing.name : '-'), c); await api('PUT', 'api/clusters/' + encodeURIComponent(editing ? editing.name : '-'), c);
$('#clusterDlg').close(); $('#clusterDlg').close();
toast(t('saved')); toast(t('saved'));
loadClusters(); await loadClusters();
// test at once: records the certificate and the host key of a new cluster
await checkCluster(c.name, null);
loadState(true); loadState(true);
} catch (e) { } catch (e) {
$('#cErr').textContent = e.message; $('#cErr').textContent = e.message;
@@ -1136,6 +1255,67 @@
$('#cErr').hidden = false; $('#cErr').hidden = false;
} }
} }
async function forgetPins() {
if (!editing || !confirm(t('confirmForget', { c: editing.name }))) return;
try {
await api('POST', 'api/clusters/' + encodeURIComponent(editing.name) + '/forget');
$('#clusterDlg').close();
loadClusters();
} catch (e) {
$('#cErr').textContent = e.message;
$('#cErr').hidden = false;
}
}
// several clusters at once: "name URL" or "URL" per line, same root password
function openBulk() {
$('#bulkRes').replaceChildren();
$('#bulkGo').disabled = false;
$('#bulkDlg').showModal();
$('#bulkText').focus();
}
async function addBulk(ev) {
ev.preventDefault();
const res = $('#bulkRes');
res.replaceChildren();
const line = (cls, text) => res.appendChild(h('li', { class: cls, text }));
const items = [];
for (const raw of $('#bulkText').value.split('\n')) {
const l = raw.trim();
if (!l || l.startsWith('#')) continue;
const p = l.split(/\s+/);
let name = p.length > 1 ? p[0] : '';
let url = p.length > 1 ? p[1] : p[0];
if (!/^https:\/\//.test(url)) url = 'https://' + url;
try {
const u = new URL(url);
if (!u.port) u.port = '8006';
url = u.origin;
name = name || u.hostname;
} catch (e) {
line('ko', t('bulkBadLine', { l }));
continue;
}
items.push({ name, url });
}
$('#bulkGo').disabled = true;
let pos = S.clusters.length;
for (const it of items) {
try {
await api('PUT', 'api/clusters/-', { name: it.name, role: 'target', position: pos++, url: it.url, user: 'root@pam',
password: $('#bulkPassword').value, hasPassword: false, tls: $('#bulkTLS').value, fingerprint: '', caPem: '', timeout: '',
sshHost: '', sshPort: 22, sshHostKey: '', sshFingerprint: '', mapping: {}, policies: {}, updatedAt: new Date().toISOString() });
const r = await api('POST', 'api/clusters/' + encodeURIComponent(it.name) + '/check');
line(r.ok ? 'ok' : 'ko', (r.ok ? '✓ ' : '✗ ') + it.name + ' — ' + (r.ok ? 'PVE ' + r.version + (r.sshFingerprint ? ' · SSH ' + r.sshFingerprint : '') : r.error));
} catch (e) {
if (e.message === 'auth') return;
line('ko', '✗ ' + it.name + ' — ' + e.message);
}
}
$('#bulkGo').disabled = false;
loadClusters();
loadState(true);
}
// synchronization settings (Save / Cancel like Logstream) // synchronization settings (Save / Cancel like Logstream)
async function loadSettings() { async function loadSettings() {
@@ -1143,6 +1323,8 @@
const d = await api('GET', 'api/settings'); const d = await api('GET', 'api/settings');
set.settings = d.settings; set.settings = d.settings;
set.defaults = d.defaults || {}; set.defaults = d.defaults || {};
S.settings = d.settings;
S.deps = d.deps || {};
} catch (e) { } catch (e) {
if (e.message !== 'auth') toast(e.message, true); if (e.message !== 'auth') toast(e.message, true);
return; return;
@@ -1155,7 +1337,11 @@
const chips = $('#kindChips'); const chips = $('#kindChips');
chips.replaceChildren(...KINDS.map((k) => h('button', { chips.replaceChildren(...KINDS.map((k) => h('button', {
type: 'button', class: 'chip' + ((st.kinds || []).includes(k) ? ' on' : ''), dataset: { kind: k }, 'aria-pressed': String((st.kinds || []).includes(k)), type: 'button', class: 'chip' + ((st.kinds || []).includes(k) ? ' on' : ''), dataset: { kind: k }, 'aria-pressed': String((st.kinds || []).includes(k)),
onclick: (ev) => { ev.currentTarget.classList.toggle('on'); ev.currentTarget.setAttribute('aria-pressed', String(ev.currentTarget.classList.contains('on'))); setDirty(true); }, onclick: (ev) => {
const on = withDeps($$('#kindChips .chip.on').map((c) => c.dataset.kind), k, !ev.currentTarget.classList.contains('on'));
$$('#kindChips .chip').forEach((c) => { c.classList.toggle('on', on.includes(c.dataset.kind)); c.setAttribute('aria-pressed', String(on.includes(c.dataset.kind))); });
setDirty(true);
},
}, t('kinds.' + k)))); }, t('kinds.' + k))));
$('#pruneSw').checked = !!st.prune; $('#pruneSw').checked = !!st.prune;
const sel = $('#selRows'); const sel = $('#selRows');
@@ -1198,12 +1384,85 @@
await api('PUT', 'api/settings', body); await api('PUT', 'api/settings', body);
setDirty(false); setDirty(false);
$('#setState').textContent = t('saved'); $('#setState').textContent = t('saved');
S.settings = Object.assign({}, S.settings, body);
loadState(true); loadState(true);
} catch (e) { } catch (e) {
$('#setState').textContent = t('saveFailed', { e: e.message }); $('#setState').textContent = t('saveFailed', { e: e.message });
} }
} }
// ---------- menus of the filter bar: replicated items, targets ----------
// withDeps checks or unchecks a kind with the kinds it needs (or that need it).
function withDeps(kinds, k, on) {
const out = new Set(kinds);
const need = (x) => { out.add(x); (S.deps[x] || []).forEach(need); };
const drop = (x) => { out.delete(x); Object.entries(S.deps).forEach(([y, ds]) => { if (ds.includes(x) && out.has(y)) drop(y); }); };
if (on) need(k); else drop(k);
return KINDS.filter((x) => out.has(x));
}
function renderKindMenu() {
const pop = $('#kindMenuPop');
const kinds = (S.settings && S.settings.kinds) || KINDS;
$('#kindMenuN').textContent = kinds.length + '/' + KINDS.length;
const admin = can.admin();
pop.replaceChildren();
for (const [g, ks] of KIND_GROUPS) {
pop.appendChild(h('div', { class: 'grp', text: t('kindGroups.' + g) }));
for (const k of ks) {
pop.appendChild(h('label', { class: 'opt' },
h('input', { type: 'checkbox', checked: kinds.includes(k), disabled: !admin || !S.settings,
onchange: (ev) => saveKinds(withDeps(kinds, k, ev.target.checked)) }),
h('span', { class: 'kind ' + k, text: t('kindShort.' + k) }), h('span', { text: t('kinds.' + k) })));
}
}
pop.appendChild(h('div', { class: 'foot' }, h('span', { class: 'note-s', text: admin ? t('kindMenuHelp') : t('kindMenuRO') })));
}
async function saveKinds(kinds) {
if (!kinds.length) { renderKindMenu(); return; }
const body = Object.assign({}, S.settings, { kinds });
try {
await api('PUT', 'api/settings', body);
S.settings = body;
renderKindMenu();
loadState(true);
} catch (e) {
if (e.message !== 'auth') toast(e.message, true);
renderKindMenu();
}
}
// targets of the global dry-run / replication (all by default; the ones
// unchecked are remembered in this browser)
function selectedTargets() {
return S.state ? S.state.clients.map((c) => c.name).filter((n) => !S.skip.has(n)) : [];
}
function renderTargetMenu() {
const pop = $('#targetMenuPop');
const names = S.state ? S.state.clients.map((c) => c.name) : [];
const sel = selectedTargets();
$('#targetMenuN').textContent = sel.length + '/' + names.length;
$('#dryAllBtn').disabled = $('#syncAllBtn').disabled = sel.length === 0;
const all = h('input', { type: 'checkbox', checked: sel.length === names.length,
onchange: (ev) => { S.skip = ev.target.checked ? new Set() : new Set(names); saveSkip(); } });
pop.replaceChildren(h('label', { class: 'opt' }, all, h('strong', { text: t('allTargets') })));
for (const c of S.state ? S.state.clients : []) {
const st = clientStatus(c);
pop.appendChild(h('label', { class: 'opt' },
h('input', { type: 'checkbox', checked: !S.skip.has(c.name),
onchange: (ev) => { if (ev.target.checked) S.skip.delete(c.name); else S.skip.add(c.name); saveSkip(); } }),
h('span', { text: c.name }),
h('span', { class: 'h', text: st === 'ok' ? '✓' : st === 'error' ? '✗' : '~' })));
}
}
function saveSkip() {
store.set('skipTargets', S.skip.size ? JSON.stringify(Array.from(S.skip)) : null);
renderTargetMenu();
}
function globalSel() {
const names = S.state ? S.state.clients.map((c) => c.name) : [];
const sel = selectedTargets();
return { scope: 'global', targets: sel.length === names.length ? [] : sel };
}
// users // users
async function loadUsers() { async function loadUsers() {
let users; let users;
@@ -1239,20 +1498,6 @@
return row; return row;
} }
// import
async function importConfig() {
const res = $('#importRes');
res.replaceChildren();
try {
const d = await api('POST', 'api/config/import', { yaml: $('#importText').value, replace: $('#importReplace').checked });
res.appendChild(note('info', 'OK', t('importOk')));
for (const w of d.warnings || []) res.appendChild(note('warn', t('warning'), w));
loadState(true);
} catch (e) {
res.appendChild(note('err', t('st.error'), e.message));
}
}
// account // account
function renderAccount() { function renderAccount() {
const me = S.me; const me = S.me;
@@ -1278,7 +1523,10 @@
function bind() { function bind() {
$('#refreshBtn').addEventListener('click', () => { if (S.view === 'diff') loadState(true); else if (S.view === 'runs') loadRuns(true); else loadAudit(true); }); $('#refreshBtn').addEventListener('click', () => { if (S.view === 'diff') loadState(true); else if (S.view === 'runs') loadRuns(true); else loadAudit(true); });
$('#settingsBtn').addEventListener('click', () => openSettings()); $('#settingsBtn').addEventListener('click', () => openSettings());
$('#setupBtn').addEventListener('click', () => openSettings('clusters')); $('#setupBtn').addEventListener('click', () => {
setView('clusters');
if (S.setup === 'clusters' && can.admin()) editCluster(null);
});
$('#themeBtn').addEventListener('click', () => { $('#themeBtn').addEventListener('click', () => {
const root = document.documentElement; const root = document.documentElement;
const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches; const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
@@ -1293,8 +1541,12 @@
clearTimeout(qTimer); clearTimeout(qTimer);
qTimer = setTimeout(() => { S.q = e.target.value.trim(); renderView(); }, 120); qTimer = setTimeout(() => { S.q = e.target.value.trim(); renderView(); }, 120);
}); });
$('#dryAllBtn').addEventListener('click', () => openPlan({ scope: 'global' })); $('#dryAllBtn').addEventListener('click', () => openPlan(globalSel()));
$('#syncAllBtn').addEventListener('click', () => openPlan({ scope: 'global' }, true)); $('#syncAllBtn').addEventListener('click', () => openPlan(globalSel(), true));
// one drop-down menu open at a time, closed by a click outside
document.addEventListener('click', (e) => {
$$('details.menu[open]').forEach((d) => { if (!d.contains(e.target)) d.open = false; });
});
$('#planApply').addEventListener('click', applyPlan); $('#planApply').addEventListener('click', applyPlan);
$('#runMore').addEventListener('click', () => loadRuns(false)); $('#runMore').addEventListener('click', () => loadRuns(false));
$('#auditMore').addEventListener('click', () => loadAudit(false)); $('#auditMore').addEventListener('click', () => loadAudit(false));
@@ -1304,6 +1556,10 @@
// settings // settings
$$('#setNav [data-tab]').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab))); $$('#setNav [data-tab]').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab)));
$('#addCluster').addEventListener('click', () => editCluster(null)); $('#addCluster').addEventListener('click', () => editCluster(null));
$('#addBulk').addEventListener('click', openBulk);
$('#bulkForm').addEventListener('submit', addBulk);
$('#testAll').addEventListener('click', testAll);
$('#cForget').addEventListener('click', forgetPins);
$('#clusterForm').addEventListener('submit', saveCluster); $('#clusterForm').addEventListener('submit', saveCluster);
$('#cDelete').addEventListener('click', deleteCluster); $('#cDelete').addEventListener('click', deleteCluster);
$('#cTLS').addEventListener('change', tlsFields); $('#cTLS').addEventListener('change', tlsFields);
@@ -1315,12 +1571,6 @@
$('#setSave').addEventListener('click', saveSettings); $('#setSave').addEventListener('click', saveSettings);
$('#setCancel').addEventListener('click', () => { renderSettings(); setDirty(false); }); $('#setCancel').addEventListener('click', () => { renderSettings(); setDirty(false); });
$('#userAdd').addEventListener('click', () => { const r = userRow(null); $('#userRows').appendChild(r); $('input', r).focus(); }); $('#userAdd').addEventListener('click', () => { const r = userRow(null); $('#userRows').appendChild(r); $('input', r).focus(); });
$('#importPick').addEventListener('click', () => $('#importFile').click());
$('#importFile').addEventListener('change', async (e) => {
const f = e.target.files[0];
if (f) $('#importText').value = await f.text();
});
$('#importBtn').addEventListener('click', importConfig);
$('#pwdForm').addEventListener('submit', changePassword); $('#pwdForm').addEventListener('submit', changePassword);
$$('#langSwitch [data-lang]').forEach((b) => b.addEventListener('click', () => { lang = b.dataset.lang; store.set('lang', lang); applyLang(); })); $$('#langSwitch [data-lang]').forEach((b) => b.addEventListener('click', () => { lang = b.dataset.lang; store.set('lang', lang); applyLang(); }));
$$('#themeSwitch [data-theme-opt]').forEach((b) => b.addEventListener('click', () => setTheme(b.dataset.themeOpt))); $$('#themeSwitch [data-theme-opt]').forEach((b) => b.addEventListener('click', () => setTheme(b.dataset.themeOpt)));
@@ -1358,6 +1608,12 @@
$('#ver').textContent = 'pve-confsync ' + S.me.version; $('#ver').textContent = 'pve-confsync ' + S.me.version;
readHash(); readHash();
applyLang(); applyLang();
try {
const d = await api('GET', 'api/settings');
S.settings = d.settings;
S.deps = d.deps || {};
} catch (e) { /* the menu stays read only */ }
if (S.view === 'clusters') loadClusters();
await loadState(false); await loadState(false);
if (S.view === 'runs') loadRuns(true); if (S.view === 'runs') loadRuns(true);
if (S.view === 'audit') loadAudit(true); if (S.view === 'audit') loadAudit(true);
+87 -65
View File
@@ -51,6 +51,11 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>
<span data-i18n="viewDiff">Écarts</span> <span data-i18n="viewDiff">Écarts</span>
</button> </button>
<button type="button" role="radio" data-view="clusters">
<!-- Lucide "server" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01M6 18h.01"/></svg>
<span data-i18n="viewClusters">Clusters</span>
</button>
<button type="button" role="radio" data-view="runs"> <button type="button" role="radio" data-view="runs">
<!-- Lucide "history" (ISC license) --> <!-- Lucide "history" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
@@ -62,12 +67,28 @@
<span data-i18n="viewAudit">Audit</span> <span data-i18n="viewAudit">Audit</span>
</button> </button>
</div> </div>
<details id="kindMenu" class="menu" data-diff>
<summary>
<!-- Lucide "list-checks" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m3 17 2 2 4-4M3 7l2 2 4-4M13 6h8M13 12h8M13 18h8"/></svg>
<span data-i18n="kindMenu">Éléments répliqués</span><span class="n" id="kindMenuN"></span>
</summary>
<div class="pop" id="kindMenuPop"></div>
</details>
<select id="kindSel" data-diff data-i18n-aria="kindAria"></select> <select id="kindSel" data-diff data-i18n-aria="kindAria"></select>
<select id="statusSel" data-diff data-i18n-aria="statusAria"></select> <select id="statusSel" data-diff data-i18n-aria="statusAria"></select>
<select id="runModeSel" data-runs data-i18n-aria="modeAria" hidden></select> <select id="runModeSel" data-runs data-i18n-aria="modeAria" hidden></select>
<span class="spacer"></span> <span class="spacer"></span>
<span id="count" class="muted"></span> <span id="count" class="muted"></span>
<div id="globalActs" class="global-acts" data-diff hidden> <div id="globalActs" class="global-acts" data-diff hidden>
<details id="targetMenu" class="menu right">
<summary>
<!-- Lucide "target" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
<span data-i18n="targetMenu">Cibles</span><span class="n" id="targetMenuN"></span>
</summary>
<div class="pop" id="targetMenuPop"></div>
</details>
<button id="dryAllBtn" class="btn tool" type="button"> <button id="dryAllBtn" class="btn tool" type="button">
<!-- Lucide "scan-search" (ISC license) --> <!-- Lucide "scan-search" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="3"/><path d="m16 16-1.9-1.9"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="3"/><path d="m16 16-1.9-1.9"/></svg>
@@ -82,8 +103,8 @@
<div id="error" class="error-banner" hidden></div> <div id="error" class="error-banner" hidden></div>
<div id="setup" class="setup" hidden> <div id="setup" class="setup" hidden>
<p data-i18n="setupText">Aucun cluster de référence n'est défini.</p> <p id="setupText"></p>
<button id="setupBtn" class="btn primary" type="button" data-i18n="setupBtn">Ajouter les clusters</button> <button id="setupBtn" class="btn primary" type="button"></button>
</div> </div>
<!-- Differences: clusters, then the objects of the selected client --> <!-- Differences: clusters, then the objects of the selected client -->
@@ -108,6 +129,17 @@
</div> </div>
</section> </section>
<!-- Clusters: connection (root), primary / targets, connection tests -->
<section id="clustersView" class="cl-view" hidden>
<div class="cl-tools">
<p class="muted small" data-i18n="clustersHelp"></p>
<button id="addCluster" class="btn tool primary" type="button" data-admin-only data-i18n="addCluster">+ Ajouter un cluster</button>
<button id="addBulk" class="btn tool" type="button" data-admin-only data-i18n="addBulk">+ Ajouter une liste</button>
<button id="testAll" class="btn tool" type="button" data-admin-only data-i18n="testAll">Tout tester</button>
</div>
<div id="clusterList" class="clist"></div>
</section>
<!-- Execution journal --> <!-- Execution journal -->
<section id="runsView" hidden> <section id="runsView" hidden>
<div class="table runs"> <div class="table runs">
@@ -185,39 +217,46 @@
<input id="cName" class="inp" required autocomplete="off" spellcheck="false"> <input id="cName" class="inp" required autocomplete="off" spellcheck="false">
<span class="lbl" data-i18n="cRole">Rôle</span> <span class="lbl" data-i18n="cRole">Rôle</span>
<div id="cRole" class="seg" role="radiogroup"> <div id="cRole" class="seg" role="radiogroup">
<button type="button" role="radio" data-v="client" data-i18n="roleClient">Client</button> <button type="button" role="radio" data-v="primary" data-i18n="rolePrimary">Primaire</button>
<button type="button" role="radio" data-v="ref" data-i18n="roleRef">Référence</button> <button type="button" role="radio" data-v="target" data-i18n="roleTarget">Cible</button>
<button type="button" role="radio" data-v="disabled" data-i18n="roleDisabled">Inactif</button>
</div> </div>
<label for="cURL">URL</label> <label for="cURL">URL</label>
<input id="cURL" class="inp mono" required placeholder="https://pve.example.org:8006" autocomplete="off" spellcheck="false"> <input id="cURL" class="inp mono" required placeholder="https://pve1.example.org:8006" autocomplete="off" spellcheck="false">
<label for="cToken" data-i18n="cToken">Token</label> <label for="cUser" data-i18n="cUser">Utilisateur</label>
<input id="cToken" class="inp mono" required placeholder="confsync@pve!rw" autocomplete="off" spellcheck="false"> <input id="cUser" class="inp mono" value="root@pam" readonly tabindex="-1">
<label for="cSecret" data-i18n="cSecret">Secret</label> <label for="cPassword" data-i18n="cPassword">Mot de passe root</label>
<input id="cSecret" class="inp mono" type="password" autocomplete="new-password" spellcheck="false"> <input id="cPassword" class="inp mono" type="password" autocomplete="new-password" spellcheck="false">
<p class="muted small hint" id="cSecretHint"></p> <p class="muted small hint" id="cPasswordHint"></p>
<span class="lbl" data-i18n="cTLS">TLS</span> <span class="lbl" data-i18n="cTLS">TLS</span>
<select id="cTLS" class="field"> <select id="cTLS" class="field">
<option value="fp" data-i18n="tlsFp">Empreinte SHA-256 du certificat</option> <option value="fp" data-i18n="tlsFp">Empreinte du certificat (enregistrée au premier test)</option>
<option value="ca" data-i18n="tlsCa">Certificat d'autorité (PEM)</option> <option value="ca" data-i18n="tlsCa">Certificat d'autorité (PEM)</option>
<option value="sys" data-i18n="tlsSys">Autorités du système</option> <option value="sys" data-i18n="tlsSys">Autorités du système</option>
<option value="insecure" data-i18n="tlsInsecure">Sans vérification (labo uniquement)</option> <option value="insecure" data-i18n="tlsInsecure">Sans vérification (labo uniquement)</option>
</select> </select>
<label for="cFp" data-tls="fp" data-i18n="cFp">Empreinte</label> <label for="cFp" data-tls="fp" data-i18n="cFp">Empreinte</label>
<input id="cFp" class="inp mono" data-tls="fp" placeholder="AA:BB:…" autocomplete="off" spellcheck="false"> <input id="cFp" class="inp mono" data-tls="fp" data-i18n-ph="fpPh" autocomplete="off" spellcheck="false">
<label for="cCA" data-tls="ca">PEM</label> <label for="cCA" data-tls="ca">PEM</label>
<textarea id="cCA" class="inp" data-tls="ca" spellcheck="false" placeholder="-----BEGIN CERTIFICATE-----"></textarea> <textarea id="cCA" class="inp" data-tls="ca" spellcheck="false" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
<label for="cSSHHost" data-i18n="cSSHHost">Nœud SSH</label>
<input id="cSSHHost" class="inp mono" data-i18n-ph="sshHostPh" autocomplete="off" spellcheck="false">
<label for="cSSHPort" data-i18n="cSSHPort">Port SSH</label>
<input id="cSSHPort" class="inp mono" type="number" min="1" max="65535" placeholder="22">
<span class="lbl" data-i18n="cSSHKey">Clé d'hôte SSH</span>
<span id="cSSHKey" class="mono small muted"></span>
<label for="cTimeout" data-i18n="cTimeout">Délai</label> <label for="cTimeout" data-i18n="cTimeout">Délai</label>
<input id="cTimeout" class="inp mono" placeholder="30s" autocomplete="off"> <input id="cTimeout" class="inp mono" placeholder="30s" autocomplete="off">
</div> </div>
<div id="cClientOnly"> <div id="cClientOnly">
<div class="sec-head mt"><h3 class="lbl" data-i18n="cMapping">Mapping (valeurs propres au client)</h3></div> <div class="sec-head mt"><h3 class="lbl" data-i18n="cMapping">Mapping (valeurs propres à la cible)</h3></div>
<p class="muted small" data-i18n="cMappingHelp"></p> <p class="muted small" data-i18n="cMappingHelp"></p>
<div class="erow-head map"><span data-i18n="colKind">Type</span><span data-i18n="colObject">Objet</span><span data-i18n="colField">Champ</span><span data-i18n="colValue">Valeur</span><span></span></div> <div class="erow-head map"><span data-i18n="colKind">Type</span><span data-i18n="colObject">Objet</span><span data-i18n="colField">Champ</span><span data-i18n="colValue">Valeur</span><span></span></div>
<div id="cMapRows" class="erows"></div> <div id="cMapRows" class="erows"></div>
<div class="toolbar"><button id="cMapAdd" class="btn tool" type="button" data-i18n="addRow">+ Ajouter</button></div> <div class="toolbar"><button id="cMapAdd" class="btn tool" type="button" data-i18n="addRow">+ Ajouter</button></div>
<div class="sec-head mt"><h3 class="lbl" data-i18n="cPolicies">Politiques propres au client</h3></div> <div class="sec-head mt"><h3 class="lbl" data-i18n="cPolicies">Politiques propres à la cible</h3></div>
<p class="muted small" data-i18n="cPoliciesHelp"></p> <p class="muted small" data-i18n="cPoliciesHelp"></p>
<div class="erow-head pol"><span data-i18n="colKind">Type</span><span data-i18n="colField">Champ</span><span data-i18n="colPolicy">Politique</span><span></span></div> <div class="erow-head pol"><span data-i18n="colKind">Type</span><span data-i18n="colField">Champ</span><span data-i18n="colPolicy">Politique</span><span></span></div>
<div id="cPolRows" class="erows"></div> <div id="cPolRows" class="erows"></div>
@@ -227,6 +266,7 @@
</div> </div>
<footer> <footer>
<button id="cDelete" class="btn danger" type="button" data-i18n="delete">Supprimer</button> <button id="cDelete" class="btn danger" type="button" data-i18n="delete">Supprimer</button>
<button id="cForget" class="btn ghost" type="button" data-i18n="forget">Oublier les empreintes</button>
<span class="sp"></span> <span class="sp"></span>
<button class="btn ghost" type="button" data-close data-i18n="cancel">Annuler</button> <button class="btn ghost" type="button" data-close data-i18n="cancel">Annuler</button>
<button class="btn primary" type="submit" data-i18n="save">Enregistrer</button> <button class="btn primary" type="submit" data-i18n="save">Enregistrer</button>
@@ -234,6 +274,36 @@
</form> </form>
</dialog> </dialog>
<!-- Several clusters at once (administrators) -->
<dialog id="bulkDlg" aria-labelledby="bulkTitle">
<form class="dlg" id="bulkForm" method="dialog">
<header>
<h2 id="bulkTitle" data-i18n="bulkTitle">Ajouter une liste de clusters</h2>
<button class="icon-btn" type="button" data-close data-i18n-aria="close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
</header>
<div class="dlg-body">
<p class="muted small" data-i18n="bulkHelp"></p>
<textarea id="bulkText" class="inp" spellcheck="false" required placeholder="pve-rennes https://10.0.0.11:8006&#10;pve-nantes https://10.0.1.11:8006&#10;https://pve-brest.lab:8006"></textarea>
<div class="field-grid mt-s">
<label for="bulkPassword" data-i18n="bulkPassword">Mot de passe root</label>
<input id="bulkPassword" class="inp mono" type="password" autocomplete="new-password" required>
<span class="lbl" data-i18n="cTLS">TLS</span>
<select id="bulkTLS" class="field">
<option value="fp" data-i18n="tlsFp">Empreinte du certificat (enregistrée au premier test)</option>
<option value="sys" data-i18n="tlsSys">Autorités du système</option>
<option value="insecure" data-i18n="tlsInsecure">Sans vérification (labo uniquement)</option>
</select>
</div>
<ul id="bulkRes" class="bulk-res"></ul>
</div>
<footer>
<span class="sp"></span>
<button class="btn ghost" type="button" data-close data-i18n="close">Fermer</button>
<button id="bulkGo" class="btn primary" type="submit" data-i18n="bulkGo">Ajouter et tester</button>
</footer>
</form>
</dialog>
<dialog id="settingsDlg" class="settings" aria-labelledby="settingsTitle"> <dialog id="settingsDlg" class="settings" aria-labelledby="settingsTitle">
<div class="dlg"> <div class="dlg">
<header> <header>
@@ -243,11 +313,6 @@
<div class="set-body"> <div class="set-body">
<div class="set-side"> <div class="set-side">
<nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical"> <nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical">
<button type="button" role="tab" data-tab="clusters">
<!-- Lucide "server" (ISC license) -->
<svg viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01M6 18h.01"/></svg>
<span data-i18n="tabClusters">Clusters</span>
</button>
<button type="button" role="tab" data-tab="sync"> <button type="button" role="tab" data-tab="sync">
<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg> <svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
<span data-i18n="tabSync">Synchronisation</span> <span data-i18n="tabSync">Synchronisation</span>
@@ -257,11 +322,6 @@
<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg> <svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span data-i18n="tabUsers">Utilisateurs</span> <span data-i18n="tabUsers">Utilisateurs</span>
</button> </button>
<button type="button" role="tab" data-tab="config" data-admin>
<!-- Lucide "file-code" (ISC license) -->
<svg viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4M10 12l-2 2 2 2M14 12l2 2-2 2"/></svg>
<span data-i18n="tabConfig">Import / export</span>
</button>
<button type="button" role="tab" data-tab="account"> <button type="button" role="tab" data-tab="account">
<!-- Lucide "key-round" (ISC license) --> <!-- Lucide "key-round" (ISC license) -->
<svg viewBox="0 0 24 24"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></svg> <svg viewBox="0 0 24 24"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></svg>
@@ -283,30 +343,17 @@
</div> </div>
<div class="set-panels"> <div class="set-panels">
<!-- Clusters -->
<div class="set-panel wide" role="tabpanel" data-panel="clusters">
<p class="ro-note" data-ro data-i18n="roNote" hidden></p>
<section class="set-section">
<div class="sec-head">
<h3 data-i18n="tabClusters">Clusters</h3>
<button id="addCluster" class="btn tool primary" type="button" data-admin-only data-i18n="addCluster">+ Ajouter un cluster</button>
</div>
<p class="muted small" data-i18n="clustersHelp"></p>
<div id="clusterList" class="clist"></div>
</section>
</div>
<!-- Synchronization settings --> <!-- Synchronization settings -->
<div class="set-panel" role="tabpanel" data-panel="sync" hidden> <div class="set-panel" role="tabpanel" data-panel="sync">
<p class="ro-note" data-ro data-i18n="roNote" hidden></p> <p class="ro-note" data-ro data-i18n="roNote" hidden></p>
<section class="set-section" data-edit> <section class="set-section" data-edit>
<h3 data-i18n="kindsTitle">Types synchronisés</h3> <h3 data-i18n="kindsTitle">Éléments répliqués</h3>
<div id="kindChips" class="chip-field"></div> <div id="kindChips" class="chip-field"></div>
<p class="muted small mt-s" data-i18n="kindsHelp"></p> <p class="muted small mt-s" data-i18n="kindsHelp"></p>
</section> </section>
<section class="set-section danger" data-edit> <section class="set-section danger" data-edit>
<h3 data-i18n="pruneTitle">Suppression</h3> <h3 data-i18n="pruneTitle">Suppression</h3>
<label class="switch-row"><input id="pruneSw" type="checkbox" class="switch"><span data-i18n="pruneLabel">Supprimer chez les clients les objets absents de la référence</span></label> <label class="switch-row"><input id="pruneSw" type="checkbox" class="switch"><span data-i18n="pruneLabel">Supprimer sur les cibles les objets absents du primaire</span></label>
<p class="muted small mt-s" data-i18n="pruneHelp"></p> <p class="muted small mt-s" data-i18n="pruneHelp"></p>
</section> </section>
<section class="set-section span" data-edit> <section class="set-section span" data-edit>
@@ -336,31 +383,6 @@
</section> </section>
</div> </div>
<!-- Import / export -->
<div class="set-panel" role="tabpanel" data-panel="config" hidden>
<section class="set-section">
<h3 data-i18n="exportTitle">Exporter</h3>
<p class="muted small" data-i18n="exportHelp"></p>
<a id="exportBtn" class="btn" href="api/config/export" download="confsync.yaml">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M4 17v3h16v-3"/></svg>
<span data-i18n="exportBtn">Télécharger confsync.yaml</span>
</a>
</section>
<section class="set-section">
<h3 data-i18n="importTitle">Importer</h3>
<p class="muted small" data-i18n="importHelp"></p>
<textarea id="importText" class="inp" spellcheck="false" placeholder="reference:&#10; name: ref&#10; …"></textarea>
<div class="toolbar">
<input id="importFile" type="file" accept=".yaml,.yml,text/yaml" hidden>
<button id="importPick" class="btn tool" type="button" data-i18n="importPick">Choisir un fichier…</button>
<label class="switch-row"><input id="importReplace" type="checkbox" class="switch"><span data-i18n="importReplace">Supprimer les clusters absents du fichier</span></label>
<span class="spacer"></span>
<button id="importBtn" class="btn primary" type="button" data-i18n="importBtn">Importer</button>
</div>
<div id="importRes" class="notes import-res"></div>
</section>
</div>
<!-- Account --> <!-- Account -->
<div class="set-panel" role="tabpanel" data-panel="account" hidden> <div class="set-panel" role="tabpanel" data-panel="account" hidden>
<section class="set-section"> <section class="set-section">
+13
View File
@@ -27,6 +27,19 @@
</div> </div>
<p class="muted" data-i18n="intro">Synchronisation de configuration Proxmox VE</p> <p class="muted" data-i18n="intro">Synchronisation de configuration Proxmox VE</p>
<form id="setupForm" class="setup-form" method="post" action="auth/setup" hidden>
<h2 data-i18n="setupTitle">Premier démarrage</h2>
<p data-i18n="setupIntro">La base est vide : créez le compte administrateur de pve-confsync.</p>
<label for="sUser" data-i18n="user">Utilisateur</label>
<input id="sUser" name="user" type="text" value="admin" autocomplete="username" autocapitalize="none" spellcheck="false" required>
<label for="sPass" data-i18n="pass">Mot de passe</label>
<input id="sPass" name="pass" type="password" autocomplete="new-password" minlength="12" required>
<label for="sPass2" data-i18n="pass2">Confirmation</label>
<input id="sPass2" name="pass2" type="password" autocomplete="new-password" minlength="12" required>
<p id="sErr" class="login-err" role="alert" hidden></p>
<button class="btn primary" type="submit" data-i18n="setupBtn">Créer le compte et continuer</button>
</form>
<a id="ssoBtn" class="btn primary" href="auth/oidc" hidden data-i18n="sso">Se connecter avec le SSO</a> <a id="ssoBtn" class="btn primary" href="auth/oidc" hidden data-i18n="sso">Se connecter avec le SSO</a>
<div id="sep" class="login-sep" hidden data-i18n="or">ou</div> <div id="sep" class="login-sep" hidden data-i18n="or">ou</div>
+22 -4
View File
@@ -4,17 +4,23 @@
fr: { intro: 'Synchronisation de configuration Proxmox VE', user: 'Utilisateur', pass: 'Mot de passe', fr: { intro: 'Synchronisation de configuration Proxmox VE', user: 'Utilisateur', pass: 'Mot de passe',
submit: 'Se connecter', sso: 'Se connecter avec le SSO', or: 'ou', submit: 'Se connecter', sso: 'Se connecter avec le SSO', or: 'ou',
error: 'Utilisateur ou mot de passe incorrect.', theme: 'Thème clair / sombre', error: 'Utilisateur ou mot de passe incorrect.', theme: 'Thème clair / sombre',
demo: 'Mode démo : comptes {a}, {o} et {l}, mot de passe {p}.' }, demo: 'Mode démo : comptes {a}, {o} et {l}, mot de passe {p}.',
setupTitle: 'Premier démarrage', setupIntro: 'La base est vide : créez le compte administrateur de pve-confsync (12 caractères minimum).',
pass2: 'Confirmation', setupBtn: 'Créer le compte et continuer',
mismatch: 'Les deux mots de passe diffèrent.', invalid: 'Nom ou mot de passe refusé (12 caractères minimum).' },
en: { intro: 'Proxmox VE configuration synchronization', user: 'User', pass: 'Password', en: { intro: 'Proxmox VE configuration synchronization', user: 'User', pass: 'Password',
submit: 'Sign in', sso: 'Sign in with SSO', or: 'or', submit: 'Sign in', sso: 'Sign in with SSO', or: 'or',
error: 'Wrong user or password.', theme: 'Light / dark theme', error: 'Wrong user or password.', theme: 'Light / dark theme',
demo: 'Demo mode: accounts {a}, {o} and {l}, password {p}.' }, demo: 'Demo mode: accounts {a}, {o} and {l}, password {p}.',
setupTitle: 'First start', setupIntro: 'The database is empty: create the administrator account of pve-confsync (12 characters minimum).',
pass2: 'Confirm', setupBtn: 'Create the account and continue',
mismatch: 'The two passwords differ.', invalid: 'Name or password refused (12 characters minimum).' },
}; };
function get(k) { try { return localStorage.getItem('confsync.' + k); } catch (e) { return null; } } function get(k) { try { return localStorage.getItem('confsync.' + k); } catch (e) { return null; } }
function set(k, v) { try { localStorage.setItem('confsync.' + k, v); } catch (e) {} } function set(k, v) { try { localStorage.setItem('confsync.' + k, v); } catch (e) {} }
var lang = get('lang'); var lang = get('lang');
if (!I18N[lang]) lang = /^en\b/i.test(navigator.language || '') ? 'en' : 'fr'; if (!I18N[lang]) lang = /^en\b/i.test(navigator.language || '') ? 'en' : 'fr';
var cfg = { local: true, oidc: false, demo: false }; var cfg = { local: true, oidc: false, demo: false, setup: false };
function el(id) { return document.getElementById(id); } function el(id) { return document.getElementById(id); }
function code(s) { var c = document.createElement('code'); c.textContent = s; return c; } function code(s) { var c = document.createElement('code'); c.textContent = s; return c; }
@@ -25,6 +31,9 @@
document.querySelectorAll('[data-i18n-title]').forEach(function (e) { e.title = d[e.dataset.i18nTitle]; }); document.querySelectorAll('[data-i18n-title]').forEach(function (e) { e.title = d[e.dataset.i18nTitle]; });
document.querySelectorAll('[data-i18n-aria]').forEach(function (e) { e.setAttribute('aria-label', d[e.dataset.i18nAria]); }); document.querySelectorAll('[data-i18n-aria]').forEach(function (e) { e.setAttribute('aria-label', d[e.dataset.i18nAria]); });
document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) { b.setAttribute('aria-checked', String(b.dataset.lang === lang)); }); document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) { b.setAttribute('aria-checked', String(b.dataset.lang === lang)); });
var se = q.get('setup');
el('sErr').hidden = !se;
if (se) el('sErr').textContent = d[se === 'mismatch' ? 'mismatch' : 'invalid'];
var h = el('demoHint'); var h = el('demoHint');
h.textContent = ''; h.textContent = '';
if (cfg.demo) { if (cfg.demo) {
@@ -50,9 +59,18 @@
el('ret').value = ret; el('ret').value = ret;
el('err').hidden = q.get('e') !== '1'; el('err').hidden = q.get('e') !== '1';
el('ssoBtn').href = 'auth/oidc?r=' + encodeURIComponent(ret); el('ssoBtn').href = 'auth/oidc?r=' + encodeURIComponent(ret);
el('setupForm').addEventListener('submit', function (e) {
if (el('sPass').value !== el('sPass2').value) {
e.preventDefault();
el('sErr').textContent = I18N[lang].mismatch;
el('sErr').hidden = false;
}
});
function show() { function show() {
el('localForm').hidden = !cfg.local; // empty database: the first administrator is created here
el('setupForm').hidden = !cfg.setup;
el('localForm').hidden = !cfg.local || cfg.setup;
el('ssoBtn').hidden = !cfg.oidc; el('ssoBtn').hidden = !cfg.oidc;
el('sep').hidden = !(cfg.local && cfg.oidc); el('sep').hidden = !(cfg.local && cfg.oidc);
el('submitBtn').className = cfg.oidc ? 'btn' : 'btn primary'; el('submitBtn').className = cfg.oidc ? 'btn' : 'btn primary';
+53
View File
@@ -32,6 +32,7 @@
--k-zone: #1d6fd6; --k-zone: #1d6fd6;
--k-vnet: #1f9aa3; --k-vnet: #1f9aa3;
--k-subnet: #5f8a1f; --k-subnet: #5f8a1f;
--k-acc: #b45309;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@@ -65,6 +66,7 @@
--k-zone: #5aa2f5; --k-zone: #5aa2f5;
--k-vnet: #3cc3cc; --k-vnet: #3cc3cc;
--k-subnet: #9ccc54; --k-subnet: #9ccc54;
--k-acc: #f0a050;
color-scheme: dark; color-scheme: dark;
} }
@@ -94,6 +96,7 @@
--k-zone: #5aa2f5; --k-zone: #5aa2f5;
--k-vnet: #3cc3cc; --k-vnet: #3cc3cc;
--k-subnet: #9ccc54; --k-subnet: #9ccc54;
--k-acc: #f0a050;
color-scheme: dark; color-scheme: dark;
} }
} }
@@ -362,6 +365,7 @@ button.ccard:hover { background: var(--row-hover); }
.kind.sdn-zone { --k: var(--k-zone); } .kind.sdn-zone { --k: var(--k-zone); }
.kind.sdn-vnet { --k: var(--k-vnet); } .kind.sdn-vnet { --k: var(--k-vnet); }
.kind.sdn-subnet { --k: var(--k-subnet); } .kind.sdn-subnet { --k: var(--k-subnet); }
.kind.pve-group, .kind.pve-role, .kind.pve-user, .kind.pve-password { --k: var(--k-acc); }
/* status / mode badges */ /* status / mode badges */
.badge { .badge {
@@ -688,3 +692,52 @@ body.login-page { min-height: 100vh; padding: 16px; display: grid; place-items:
.mt-s { margin-top: 8px !important; } .mt-s { margin-top: 8px !important; }
.erow .acts { display: flex; gap: 2px; } .erow .acts { display: flex; gap: 2px; }
.import-res { margin: 10px 0 0; } .import-res { margin: 10px 0 0; }
/* ---------- drop-down menus of the filter bar (replicated kinds, targets) ---------- */
.menu { position: relative; }
.menu > summary {
list-style: none; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 10px;
border: 1px solid var(--border); border-radius: 8px; background: var(--panel); font-size: 13px; font-weight: 550; cursor: pointer; white-space: nowrap;
}
.menu > summary::-webkit-details-marker { display: none; }
.menu > summary svg { width: 15px; height: 15px; }
.menu > summary .n { font: 600 11px/1 var(--mono); padding: 3px 6px; border-radius: 10px; background: var(--accent-soft); color: var(--accent); }
.menu[open] > summary { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.menu .pop {
position: absolute; z-index: 30; top: calc(100% + 6px); left: 0; min-width: 18rem; max-height: 70vh; overflow-y: auto;
padding: 8px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); box-shadow: var(--shadow);
}
.menu.right .pop { left: auto; right: 0; }
.menu .grp { margin: 6px 4px 2px; font-size: 10.5px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.menu .grp:first-child { margin-top: 0; }
.menu label.opt { display: flex; align-items: center; gap: 8px; padding: 5px 6px; border-radius: 6px; font-size: 13px; cursor: pointer; }
.menu label.opt:hover { background: var(--row-hover); }
.menu label.opt input { accent-color: var(--accent); }
.menu label.opt .h { margin-left: auto; font-size: 11.5px; color: var(--muted); }
.menu .pop .foot { display: flex; gap: 6px; margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border); }
.menu .pop .foot .note-s { font-size: 11.5px; color: var(--muted); }
/* ---------- clusters view ---------- */
.cl-view { padding: 12px 20px 20px; }
.cl-tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; }
.cl-tools p { margin: 0; flex: 1 1 320px; }
.citem.cl { grid-template-columns: minmax(9rem, max-content) minmax(0, 1fr) max-content; }
.citem.cl.primary { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 4%, var(--panel)); }
.citem.cl.disabled { opacity: .7; }
.citem .meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--muted); }
.citem .meta span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.citem .meta b { font-weight: 600; color: var(--text); }
.citem .meta .mono { font-size: 11.5px; }
.rbadge { font: 700 10px/1.5 var(--mono); padding: 0 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: .03em;
color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 40%, transparent); }
.rbadge.primary { --c: var(--accent); }
.rbadge.target { --c: var(--ok); }
.rbadge.disabled { --c: var(--op-same); }
.citem select.field { height: 30px; width: auto; }
.bulk-res { list-style: none; margin: 10px 0 0; padding: 0; font: 12px var(--mono); }
.bulk-res li { padding: 3px 0; border-bottom: 1px dashed var(--border); }
.bulk-res li.ok { color: var(--ok); }
.bulk-res li.ko { color: var(--err); }
.setup-form { width: 100%; display: flex; flex-direction: column; gap: 6px; }
.setup-form h2 { margin: 0 0 4px; font-size: 15px; text-align: center; }
.setup-form p { margin: 0 0 6px; font-size: 12.5px; color: var(--muted); }