Full web : fichiers restants
This commit is contained in:
1 parent
ec3b7c9a01
commit
4826fa5b82
26 files changed
+2518
-774
No files matched your search
@@ -1,27 +1,35 @@
|
||||
# pve-confsync
|
||||
|
||||
Synchronisation d'une partie de la configuration d'un cluster Proxmox VE 9.x
|
||||
« référence » vers N clusters « clients », via l'API PVE et des tokens API.
|
||||
Sens unique référence → clients. Pas de VM, pas de disques.
|
||||
Réplication d'une partie de la configuration d'un cluster Proxmox VE 9.x **primaire**
|
||||
vers une ou plusieurs **cibles**. Sens unique primaire → cibles. 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
|
||||
(écarts par client, dry-run et synchronisation par objet, par client ou global, journal,
|
||||
audit) pour les options datacenter et le SDN (zones VLAN/VXLAN, VNets, subnets).
|
||||
Testé contre un faux serveur PVE ; **pas encore testé sur les clusters de labo.**
|
||||
**État : phases 0 à 2, version « full web ».** Ajout des clusters (un par un ou en lot),
|
||||
choix du primaire, éléments répliqués et cibles choisis dans des menus, écarts par cible,
|
||||
dry-run et réplication par objet, par cible ou global, journal, audit.
|
||||
Testé contre un faux serveur PVE (API + SSH) ; **pas encore testé sur un vrai cluster.**
|
||||
|
||||
## 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 |
|
||||
| `sdn-zone` (vlan, vxlan) | `/cluster/sdn/zones` | `nodes`, `bridge`, `peers`, `fabric`, `dns`, `reversedns`, `dnszone` en `override` (mapping client) ; le reste en `sync` |
|
||||
| `sdn-vnet` | `/cluster/sdn/vnets` | `sync` |
|
||||
| `sdn-subnet` | `/cluster/sdn/vnets/{vnet}/subnets` | `sync` |
|
||||
| `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 |
|
||||
| `pve-group` | API `/access/groups` | `sync` |
|
||||
| `pve-role` | API `/access/roles` | `sync` (rôles intégrés ignorés) |
|
||||
| `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
|
||||
avec un avertissement), firewall, storage, pools/ACL, jobs, secrets.
|
||||
Dépendances : un mot de passe exige son compte, un compte exige ses groupes ; le menu
|
||||
« É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
|
||||
|
||||
@@ -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** : la référence est lue en configuration SDN *appliquée* (`running=1`) ; les
|
||||
clients en configuration courante. Chaque cluster est refusé si un nœud en ligne n'est
|
||||
- **Lecture** : le primaire est lu en configuration SDN *appliquée* (`running=1`) ; les
|
||||
cibles en configuration courante. Chaque cluster est refusé si un nœud en ligne n'est
|
||||
pas en PVE 9.x (`/nodes/{node}/version`).
|
||||
- **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.
|
||||
- **Politiques** : `sync` (copie de la référence), `ignore`, `override` (valeur fournie
|
||||
uniquement par le `mapping` du client). Le mapping d'un client est prioritaire sur la
|
||||
référence. Résolution : politique du client → politique globale → défaut de l'adaptateur.
|
||||
- **Politiques** : `sync` (copie du primaire), `ignore`, `override` (valeur fournie
|
||||
uniquement par le `mapping` de la cible). Le mapping d'une cible est prioritaire sur le
|
||||
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
|
||||
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 immuable modifié (type de zone, vnet d'un subnet).
|
||||
- **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
|
||||
`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.
|
||||
- **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`).
|
||||
|
||||
## Utilisation
|
||||
## Démarrage
|
||||
|
||||
```sh
|
||||
go build -o pve-confsync ./cmd/pve-confsync
|
||||
cp examples/confsync.example.yaml confsync.yaml # puis adapter
|
||||
|
||||
./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
|
||||
go run ./cmd/pve-confsync -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
|
||||
docker compose up -d # production : données dans le volume /data
|
||||
```
|
||||
|
||||
Avec docker compose (images et utilisateur non privilégié alignés sur Logstream) :
|
||||
|
||||
```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
|
||||
Le mode démo démarre un primaire PVE 9.1, deux cibles avec des écarts (dont un mapping
|
||||
manquant, bloquant) et un cluster PVE 8 refusé. Comptes `admin`, `operateur` et `lecteur`,
|
||||
mot de passe `confsync-demo`.
|
||||
|
||||
- **Écarts** : une carte par cluster (version, compteurs à créer / modifier / erreurs /
|
||||
hors référence), puis les objets du client choisi ; le détail d'un objet montre pour
|
||||
chaque champ la politique, la valeur de référence, le mapping, la valeur actuelle et
|
||||
la valeur après synchro.
|
||||
- **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.
|
||||
**Base vide** : la page de connexion propose de créer le premier administrateur
|
||||
(12 caractères minimum). Tant que ce n'est pas fait, **le premier qui atteint l'instance
|
||||
devient administrateur** : ne pas l'exposer avant. Alternative : `CONFSYNC_ADMIN_USER` et
|
||||
`CONFSYNC_ADMIN_PASSWORD` / `_FILE` créent ce compte au démarrage.
|
||||
|
||||
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,
|
||||
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 |
|
||||
| `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_ADMIN_USER`, `CONFSYNC_ADMIN_PASSWORD` / `_FILE` | `admin`, aléatoire (journalisé une fois) | premier administrateur, base vide |
|
||||
| `CONFSYNC_IMPORT` | | YAML de la CLI importé si la base n'a aucun cluster |
|
||||
| `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`, vide | premier administrateur sans passer par la page web |
|
||||
| `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_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é
|
||||
|
||||
- Secrets des tokens : jamais dans le YAML (`token_secret` en clair est refusé) ; lus depuis
|
||||
une variable d'environnement (`token_secret_env`) ou un fichier (`token_secret_file`,
|
||||
secret docker). Ils ne sont ni affichés ni journalisés. Dans l'interface, ils sont chiffrés en base
|
||||
(AES-256-GCM, clé hors base) et jamais renvoyés au navigateur ; l'export YAML les remplace
|
||||
par `token_secret_env`.
|
||||
- **Accès PVE en `root@pam`** (API par ticket, SSH avec le même mot de passe) : nécessaire
|
||||
pour les mots de passe du royaume `pve`, absents de l'API. Le compte root ne doit pas
|
||||
avoir de TFA (refusé). Le primaire n'est jamais écrit.
|
||||
- **Épinglage au premier test** : empreinte SHA-256 du certificat TLS (mode par défaut,
|
||||
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é
|
||||
(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.
|
||||
- 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
|
||||
|
||||
```
|
||||
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/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/demo/ faux clusters du mode démo
|
||||
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/pvetest/ faux serveur PVE 9.x pour les tests
|
||||
internal/model/ objets génériques, snapshot YAML, valeurs canoniques
|
||||
internal/pve/ client API : ticket root@pam, TLS épinglé, form-encoding, version, tâches
|
||||
internal/sshx/ SSH root, clé d'hôte épinglée
|
||||
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/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
|
||||
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
|
||||
manquant, rollback sur erreur, refus de PVE 8, prune et ordre de suppression, forme
|
||||
canonique des options datacenter, CLI dry-run/apply/export ; sélection d'objets et dépendances ; chiffrement, utilisateurs ;
|
||||
serveur de bout en bout : rôles, CSRF, dry-run, empreinte périmée, confirmation SDN, apply).
|
||||
Tests : `go test ./...` (bout en bout contre le faux serveur : réplication SDN et comptes
|
||||
avec mots de passe par SSH, mapping manquant, parent absent, rollback sur erreur, refus de
|
||||
PVE 8, prune et ordre de suppression ; sélection et dépendances ; chiffrement, migration
|
||||
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
|
||||
|
||||
0. **CLI** export / diff / apply YAML (options datacenter, tags, SDN).
|
||||
1. UI lecture seule : clusters, diff visuel par client. Ergonomie reprise de Logstream
|
||||
0. Moteur export / diff / apply (options datacenter, tags, SDN) ; la CLI a été retirée.
|
||||
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) ;
|
||||
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.
|
||||
|
||||
## 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`.
|
||||
- Exploiter `GET /cluster/sdn/dry-run` (diff frr / interfaces, PVE ≥ 9.2 environ) pour
|
||||
enrichir le dry-run SDN.
|
||||
|
||||
+34
-9
@@ -1,12 +1,18 @@
|
||||
# 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
|
||||
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.**
|
||||
|
||||
Toutes les routes sont relatives à `/api2/json`. Authentification : en-tête
|
||||
`Authorization: PVEAPIToken=USER@REALM!TOKENID=SECRET`. Écritures en
|
||||
Toutes les routes sont relatives à `/api2/json`. Authentification par ticket du compte
|
||||
`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
|
||||
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 | `/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 | `/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 | — |
|
||||
|
||||
## 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`
|
||||
- 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 `/`.
|
||||
- **Clients** : `Sys.Audit` + `SDN.Audit` + `SDN.Allocate` sur `/` (ou `/sdn`) ;
|
||||
`Sys.Modify` sur `/` uniquement si les options datacenter sont synchronisées
|
||||
(`PUT /cluster/options` n'a pas de droit plus fin).
|
||||
| Objet | Liste / création | Élément | Notes |
|
||||
|---|---|---|---|
|
||||
| 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é |
|
||||
| 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
@@ -1,7 +1,7 @@
|
||||
// Package demo starts fake PVE clusters (pvetest) and fills a database with
|
||||
// them, to try the web UI without a Proxmox cluster: a reference, two clients
|
||||
// with differences (missing objects, different fields, a missing mapping) and
|
||||
// a PVE 8 cluster that is refused.
|
||||
// Package demo starts fake PVE clusters (pvetest: API and SSH) and fills a
|
||||
// database with them, to try the web UI without a Proxmox cluster: a primary,
|
||||
// two targets with differences (missing objects, different fields, accounts
|
||||
// and passwords, a missing mapping) and a PVE 8 cluster that is refused.
|
||||
package demo
|
||||
|
||||
import (
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"pve-confsync/internal/adapter"
|
||||
"pve-confsync/internal/model"
|
||||
"pve-confsync/internal/pve"
|
||||
"pve-confsync/internal/pve/pvetest"
|
||||
"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"}}
|
||||
ref.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"}
|
||||
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
|
||||
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.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.254"}
|
||||
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
|
||||
// (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"}}
|
||||
b.Pending.Subnets["vlanz-10.2.0.0-24"] = map[string]any{"vnet": "v200", "gateway": "10.2.0.1"}
|
||||
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
|
||||
old := pvetest.New("8.4.1")
|
||||
|
||||
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 {
|
||||
sum := sha256.Sum256(s.Certificate().Raw)
|
||||
return st.SaveCluster(ctx, "", store.Cluster{Name: name, Reference: refer, Position: pos, URL: s.URL,
|
||||
TokenID: token, Secret: "demo-" + name, Fingerprint: hex.EncodeToString(sum[:]),
|
||||
Mapping: mapping, Policies: pol})
|
||||
host, port := s.SSHAddr()
|
||||
err := st.SaveCluster(ctx, "", store.Cluster{Name: name, Role: role, Position: pos, URL: s.URL,
|
||||
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{
|
||||
save(ref, "ref-rennes", "confsync@pve!ro", true, 0, nil, nil),
|
||||
save(a, "site-a", "confsync@pve!rw", false, 1, map[model.Kind]map[string]map[string]any{
|
||||
save(ref, "pve-rennes", store.ClusterPrimary, 0, nil, nil),
|
||||
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.KindZone: {
|
||||
"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"},
|
||||
},
|
||||
}, 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: {
|
||||
"vlanz": {"bridge": "vmbr0"},
|
||||
"vxz": {"peers": "172.16.0.1,172.16.0.2"},
|
||||
},
|
||||
}, 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,
|
||||
Policies: map[model.Kind]map[string]adapter.Policy{model.KindDatacenter: {"keyboard": adapter.Sync}}}),
|
||||
}
|
||||
|
||||
@@ -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
@@ -4,6 +4,7 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -42,6 +43,9 @@ func Read(ctx context.Context, c *pve.Client, kinds []model.Kind, mode adapter.R
|
||||
if need[model.KindVNet] {
|
||||
need[model.KindZone] = true
|
||||
}
|
||||
if need[model.KindPassword] {
|
||||
need[model.KindUser] = true
|
||||
}
|
||||
var all []model.Object
|
||||
for _, a := range adapter.All() {
|
||||
if !need[a.Kind()] {
|
||||
@@ -74,21 +78,43 @@ const (
|
||||
|
||||
// Change is one planned action. Set holds the new values, Old the current ones.
|
||||
type Change struct {
|
||||
Kind model.Kind `json:"kind" yaml:"kind"`
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Op Op `json:"op" yaml:"op"`
|
||||
Set map[string]any `json:"set,omitempty" yaml:"set,omitempty"`
|
||||
Unset []string `json:"unset,omitempty" yaml:"unset,omitempty"`
|
||||
Old map[string]any `json:"old,omitempty" yaml:"old,omitempty"`
|
||||
Target model.Object `json:"-" yaml:"-"`
|
||||
Kind model.Kind `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Op Op `json:"op"`
|
||||
Set map[string]any `json:"set,omitempty"`
|
||||
Unset []string `json:"unset,omitempty"`
|
||||
Old map[string]any `json:"old,omitempty"`
|
||||
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.
|
||||
type Plan struct {
|
||||
Client string `json:"client" yaml:"client"`
|
||||
Changes []Change `json:"changes" yaml:"changes"`
|
||||
Errors []string `json:"errors,omitempty" yaml:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty" yaml:"warnings,omitempty"`
|
||||
Client string `json:"client"`
|
||||
Changes []Change `json:"changes"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
sort.SliceStable(deletes, func(i, j int) bool {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
for i, x := range model.Kinds {
|
||||
if x == k {
|
||||
|
||||
@@ -14,17 +14,23 @@ import (
|
||||
"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 {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(s.Certificate().Raw)
|
||||
c, err := pve.New(pve.Options{Name: name, BaseURL: s.URL, TokenID: "confsync@pve!t", TokenSecret: "secret",
|
||||
Fingerprint: hex.EncodeToString(sum[:])})
|
||||
host, port := s.SSHAddr()
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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.
|
||||
func reference(t *testing.T) *pvetest.Server {
|
||||
ref := pvetest.New("9.1.4")
|
||||
@@ -49,7 +55,7 @@ func reference(t *testing.T) *pvetest.Server {
|
||||
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Kinds: model.Kinds,
|
||||
Kinds: sdnKinds,
|
||||
Clients: []config.Client{{
|
||||
Cluster: config.Cluster{Name: "site-a"},
|
||||
Mapping: map[model.Kind]map[string]map[string]any{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"pve-confsync/internal/adapter"
|
||||
"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 {
|
||||
add(string(model.KindVNet) + "/" + v)
|
||||
}
|
||||
for _, p := range adapter.Parents(Desired(c)) {
|
||||
if byKey[p].Op == OpCreate {
|
||||
add(p)
|
||||
}
|
||||
}
|
||||
case OpDelete:
|
||||
// children deleted in the same plan must go first
|
||||
for _, d := range p.Changes {
|
||||
@@ -79,6 +85,9 @@ func Filter(p Plan, keys []string) Plan {
|
||||
if v, ok := c.Set["vnet"].(string); ok {
|
||||
blocking[string(model.KindVNet)+"/"+v] = true
|
||||
}
|
||||
for _, p := range adapter.Parents(Desired(c)) {
|
||||
blocking[p] = true
|
||||
}
|
||||
}
|
||||
out := Plan{Client: p.Client, Warnings: p.Warnings}
|
||||
// errors about other objects ("kind/id: ...") do not block this selection
|
||||
|
||||
+182
-45
@@ -1,6 +1,8 @@
|
||||
// Package pve is a minimal Proxmox VE API client: API token auth, generic JSON,
|
||||
// form-encoded writes, task polling and version check. No typed SDK on purpose:
|
||||
// objects stay generic (map[string]any) and are interpreted by the adapters.
|
||||
// Package pve is a minimal Proxmox VE API client: ticket auth with the root@pam
|
||||
// credentials, generic JSON, form-encoded writes, task polling and version check.
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -15,10 +17,10 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -26,23 +28,42 @@ import (
|
||||
type Options struct {
|
||||
Name string // cluster name in the config (for messages only)
|
||||
BaseURL string // https://host:8006
|
||||
TokenID string // user@realm!tokenname
|
||||
TokenSecret string // never logged
|
||||
CAFile string // PEM bundle to trust (optional)
|
||||
CAPEM []byte // PEM bundle given inline (web UI); CAFile wins when both are set
|
||||
User string // user@realm, root@pam by default
|
||||
Password string // never logged
|
||||
CAPEM []byte // PEM bundle to trust (optional)
|
||||
Fingerprint string // SHA-256 of the leaf certificate, "AA:BB:..." (optional, pinning)
|
||||
Insecure bool // skip TLS verification (lab only)
|
||||
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.
|
||||
type Client struct {
|
||||
name string
|
||||
base *url.URL
|
||||
auth string
|
||||
user string
|
||||
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.
|
||||
type APIError struct {
|
||||
Method, Path string
|
||||
@@ -75,41 +96,15 @@ func New(o Options) (*Client, error) {
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("cluster %s: invalid url %q", o.Name, o.BaseURL)
|
||||
}
|
||||
if o.TokenID == "" || o.TokenSecret == "" {
|
||||
return nil, fmt.Errorf("cluster %s: token id and secret are required", o.Name)
|
||||
if o.User == "" {
|
||||
o.User = "root@pam"
|
||||
}
|
||||
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
switch {
|
||||
case o.Fingerprint != "":
|
||||
want := normalizeFingerprint(o.Fingerprint)
|
||||
// Self-signed PVE certificates: pin the leaf certificate instead of a CA.
|
||||
tlsCfg.InsecureSkipVerify = true
|
||||
tlsCfg.VerifyPeerCertificate = func(raw [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(raw) == 0 {
|
||||
return errors.New("no peer certificate")
|
||||
if o.Password == "" {
|
||||
return nil, fmt.Errorf("cluster %s: the password of %s is required", o.Name, o.User)
|
||||
}
|
||||
sum := sha256.Sum256(raw[0])
|
||||
if got := hex.EncodeToString(sum[:]); got != want {
|
||||
return fmt.Errorf("certificate fingerprint mismatch (got %s)", got)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
case o.CAFile != "" || len(o.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()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("cluster %s: no certificate in %s", o.Name, src)
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
case o.Insecure:
|
||||
tlsCfg.InsecureSkipVerify = true
|
||||
tlsCfg, err := TLSConfig(o.Name, o.CAPEM, o.Fingerprint, o.Insecure)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout := o.Timeout
|
||||
if timeout == 0 {
|
||||
@@ -118,7 +113,9 @@ func New(o Options) (*Client, error) {
|
||||
return &Client{
|
||||
name: o.Name,
|
||||
base: u,
|
||||
auth: "PVEAPIToken=" + o.TokenID + "=" + o.TokenSecret,
|
||||
user: o.User,
|
||||
password: o.Password,
|
||||
shell: o.Shell,
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{TLSClientConfig: tlsCfg, Proxy: http.ProxyFromEnvironment},
|
||||
@@ -126,13 +123,127 @@ func New(o Options) (*Client, error) {
|
||||
}, 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}
|
||||
switch {
|
||||
case fingerprint != "":
|
||||
want := normalizeFingerprint(fingerprint)
|
||||
// Self-signed PVE certificates: pin the leaf certificate instead of a CA.
|
||||
tlsCfg.InsecureSkipVerify = true
|
||||
tlsCfg.VerifyPeerCertificate = func(raw [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(raw) == 0 {
|
||||
return errors.New("no peer certificate")
|
||||
}
|
||||
sum := sha256.Sum256(raw[0])
|
||||
if got := hex.EncodeToString(sum[:]); got != want {
|
||||
return fmt.Errorf("certificate fingerprint mismatch (got %s)", FormatFingerprint(got))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
case len(caPEM) > 0:
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return nil, fmt.Errorf("cluster %s: no certificate in the CA bundle", name)
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
case insecure:
|
||||
tlsCfg.InsecureSkipVerify = true
|
||||
}
|
||||
return tlsCfg, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
host := u.Host
|
||||
if u.Port() == "" {
|
||||
host += ":8006"
|
||||
}
|
||||
d := tls.Dialer{Config: &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true}}
|
||||
conn, err := d.DialContext(ctx, "tcp", host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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.
|
||||
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 {
|
||||
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.
|
||||
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)
|
||||
@@ -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 {
|
||||
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.Path = "/api2/json/" + strings.TrimLeft(path, "/")
|
||||
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 {
|
||||
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")
|
||||
if form != nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
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)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// 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:
|
||||
// parsed datacenter options on GET, property strings on PUT, SDN pending/running
|
||||
// configurations, global SDN lock with token, rollback and reload task.
|
||||
// pve-confsync, for tests and the demo. It mimics the behaviours that matter for
|
||||
// the engine: ticket login of root@pam, parsed datacenter options on GET,
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -43,29 +45,47 @@ func (s SDN) clone() SDN {
|
||||
return c
|
||||
}
|
||||
|
||||
// Password of root@pam on the fake clusters (API and SSH).
|
||||
const Password = "root-secret"
|
||||
|
||||
// Server is a fake PVE cluster.
|
||||
type Server struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
Version string // pve-manager version, e.g. 9.1.4
|
||||
Password string // root@pam password
|
||||
Options map[string]string // datacenter.cfg as written (property strings)
|
||||
Pending SDN
|
||||
Running SDN
|
||||
Lock string
|
||||
Perms map[string]map[string]any
|
||||
Groups map[string]map[string]any // groupid -> fields (comment)
|
||||
Roles map[string]string // custom roles: roleid -> privs
|
||||
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
|
||||
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 {
|
||||
s := &Server{Version: version, Options: map[string]string{}, Pending: newSDN(), Running: newSDN(),
|
||||
Perms: map[string]map[string]any{}}
|
||||
s := &Server{Version: version, Password: Password, Options: map[string]string{}, Pending: newSDN(), Running: newSDN(),
|
||||
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.ssh = startSSH(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).
|
||||
func (s *Server) Commit() {
|
||||
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) {
|
||||
s.mu.Lock()
|
||||
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")
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
p := strings.TrimPrefix(r.URL.Path, "/api2/json/")
|
||||
if r.Method != http.MethodGet && r.Header.Get("CSRFPreventionToken") != csrf {
|
||||
fail(w, 401, "Permission denied - invalid csrf token")
|
||||
return
|
||||
}
|
||||
seg := strings.Split(p, "/")
|
||||
key := r.Method + " " + p
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -130,8 +162,8 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
reply(w, out)
|
||||
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"})
|
||||
case key == "GET access/permissions":
|
||||
reply(w, s.Perms)
|
||||
case strings.HasPrefix(p, "access/"):
|
||||
s.access(w, r, r.Method, seg)
|
||||
case key == "GET cluster/options":
|
||||
reply(w, s.parsedOptions())
|
||||
case key == "PUT cluster/options":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+137
-108
@@ -7,13 +7,13 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"pve-confsync/internal/adapter"
|
||||
"pve-confsync/internal/config"
|
||||
"pve-confsync/internal/engine"
|
||||
"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" {
|
||||
cfg, err := s.store.Config(r.Context())
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
@@ -78,11 +84,12 @@ func (s *Server) remember(st *service.State, all bool) {
|
||||
// ---- dry-run and apply (phase 2) ----
|
||||
|
||||
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
|
||||
Objects []string `json:"objects"` // object scope: "kind/id"
|
||||
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 {
|
||||
@@ -90,11 +97,12 @@ func (sel *selection) check() error {
|
||||
case "global":
|
||||
sel.Client, sel.Objects = "", nil
|
||||
case "client":
|
||||
sel.Objects = nil
|
||||
sel.Objects, sel.Targets = nil, nil
|
||||
if sel.Client == "" {
|
||||
return errors.New("client required")
|
||||
}
|
||||
case "object":
|
||||
sel.Targets = nil
|
||||
if sel.Client == "" || len(sel.Objects) == 0 {
|
||||
return errors.New("client and objects required")
|
||||
}
|
||||
@@ -106,26 +114,29 @@ func (sel *selection) check() error {
|
||||
|
||||
func (sel selection) clients() []string {
|
||||
if sel.Client == "" {
|
||||
return nil
|
||||
return sel.Targets
|
||||
}
|
||||
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.
|
||||
func (s *Server) plan(ctx context.Context, sel selection) (*config.Config, *service.State, []service.ClientPlan, error) {
|
||||
cfg, err := s.store.Config(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if sel.Client != "" {
|
||||
if _, ok := cfg.Client(sel.Client); !ok {
|
||||
return nil, nil, nil, fmt.Errorf("unknown client %q", sel.Client)
|
||||
for _, c := range sel.clients() {
|
||||
if _, ok := cfg.Client(c); !ok {
|
||||
return nil, nil, nil, fmt.Errorf("unknown target %q", c)
|
||||
}
|
||||
}
|
||||
rctx, cancel := context.WithTimeout(ctx, readTimeout)
|
||||
defer cancel()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -185,7 +196,10 @@ func target(sel selection) string {
|
||||
case "object":
|
||||
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.
|
||||
@@ -203,11 +217,11 @@ func dryRunSummary(plans []service.ClientPlan) (string, string) {
|
||||
}
|
||||
switch {
|
||||
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:
|
||||
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
|
||||
@@ -337,10 +351,10 @@ func (s *Server) runApply(r *http.Request, id Identity, runID int64, sel selecti
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
s.invalidate()
|
||||
@@ -387,8 +401,8 @@ func (s *Server) handleClusters(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// connection details are for the administrators
|
||||
if identity(r).Role != store.RoleAdmin {
|
||||
for i := 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}
|
||||
for i, c := range cls {
|
||||
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})
|
||||
@@ -409,9 +423,9 @@ func (s *Server) handleSaveCluster(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
err := s.store.SaveCluster(r.Context(), old, c)
|
||||
detail := fmt.Sprintf("url=%s token=%s reference=%t", c.URL, c.TokenID, c.Reference)
|
||||
if c.Secret != "" {
|
||||
detail += " secret=modifié"
|
||||
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.Password != "" {
|
||||
detail += " mot de passe=modifié"
|
||||
}
|
||||
if old != "" && old != c.Name {
|
||||
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"})
|
||||
}
|
||||
|
||||
// validateCluster checks the mapping and policies of a cluster with the rules of
|
||||
// the YAML configuration.
|
||||
// validateCluster checks the address and the mapping and policies of a cluster.
|
||||
func validateCluster(c store.Cluster) error {
|
||||
if c.Fingerprint == "" && c.CAPEM == "" && !c.Insecure && !strings.HasPrefix(c.URL, "https://") {
|
||||
return errors.New("url: https:// expected")
|
||||
u, err := url.Parse(strings.TrimSpace(c.URL))
|
||||
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 _, err := time.ParseDuration(c.Timeout); err != nil {
|
||||
return fmt.Errorf("timeout: %w", err)
|
||||
}
|
||||
}
|
||||
cfg := &config.Config{Kinds: model.Kinds, Reference: config.Cluster{Name: "\x00ref", Secret: "x"},
|
||||
Clients: []config.Client{{Cluster: config.Cluster{Name: c.Name, Secret: "x"}, Mapping: c.Mapping, Policies: c.Policies}}}
|
||||
if c.SSHPort < 0 || c.SSHPort > 65535 {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -460,51 +480,116 @@ func (s *Server) handleDeleteCluster(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleCheckCluster tests the access, the version and the token privileges.
|
||||
func (s *Server) handleCheckCluster(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
cfg, err := s.store.Config(r.Context())
|
||||
if err != nil && !errors.Is(err, store.ErrNoReference) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error(), "config")
|
||||
// handleRole makes a cluster the primary, a target or disabled.
|
||||
func (s *Server) handleRole(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||
return
|
||||
}
|
||||
var cl config.Cluster
|
||||
write := true
|
||||
name := r.PathValue("name")
|
||||
err := s.store.SetRole(r.Context(), name, req.Role)
|
||||
s.audit(r, Identity{}, "cluster-role", name, err == nil, errText(err, req.Role))
|
||||
switch {
|
||||
case cfg != nil && cfg.Reference.Name == name:
|
||||
cl, write = cfg.Reference, false
|
||||
case cfg != nil:
|
||||
c, ok := cfg.Client(name)
|
||||
if !ok {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, err.Error(), "notfound")
|
||||
return
|
||||
case err != nil:
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
|
||||
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
|
||||
}
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
c, cc, err := s.store.Cluster(r.Context(), name)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "unknown cluster", "notfound")
|
||||
return
|
||||
}
|
||||
cl = c.Cluster
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "define a reference cluster first", "config")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||
defer cancel()
|
||||
res := service.CheckCluster(ctx, cl, write, cfg.HasKind(model.KindDatacenter))
|
||||
s.audit(r, Identity{}, "cluster-check", name, res.Error == "", res.Error)
|
||||
res := service.CheckCluster(ctx, cc, c.TLS == store.TLSPin, req.Trust)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
st, err := s.store.Settings(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||
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.
|
||||
func defaultPolicies() 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.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.KindSubnet: "sync",
|
||||
}
|
||||
@@ -517,11 +602,11 @@ func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune,
|
||||
Reference: config.Cluster{Name: "\x00ref", Secret: "x"}}
|
||||
if len(cfg.Kinds) == 0 {
|
||||
cfg.Kinds = model.Kinds
|
||||
}
|
||||
Reference: config.Cluster{Name: "\x00ref"}}
|
||||
err := cfg.Validate()
|
||||
if err == nil && len(st.Kinds) == 0 {
|
||||
err = errors.New("select at least one kind")
|
||||
}
|
||||
if err == nil {
|
||||
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"})
|
||||
}
|
||||
|
||||
// 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 ----
|
||||
|
||||
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+52
-2
@@ -183,7 +183,7 @@ func sessionKey(dir string) []byte {
|
||||
// public paths: reachable without a session.
|
||||
func public(p string) bool {
|
||||
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 false
|
||||
@@ -197,6 +197,9 @@ func (a *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case r.URL.Path == "/auth/logout":
|
||||
a.handleLogout(w, r)
|
||||
return
|
||||
case r.URL.Path == "/auth/setup":
|
||||
a.handleSetup(w, r)
|
||||
return
|
||||
case r.URL.Path == "/auth/oidc" && a.cfg.OIDCEnabled():
|
||||
a.startLogin(w, r)
|
||||
return
|
||||
@@ -204,7 +207,8 @@ func (a *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
a.handleCallback(w, r)
|
||||
return
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := a.sessionIdentity(r)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Package server is the web UI of pve-confsync (phases 1 and 2): cluster list,
|
||||
// visual diff per client, dry-run and synchronization per object, per client or
|
||||
// global, execution journal and audit trail. The pages (web/) are embedded in
|
||||
// the binary; the API is JSON under /api/.
|
||||
// Package server is the web UI of pve-confsync, the only way to use it: first
|
||||
// administrator, clusters (root credentials, primary and targets), replicated
|
||||
// kinds, visual diff per target, dry-run and synchronization per object, per
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -77,10 +78,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("PUT /api/clusters/{name}", s.role(admin, s.handleSaveCluster))
|
||||
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}/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("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("PUT /api/users/{name}", s.role(admin, s.handleSaveUser))
|
||||
|
||||
@@ -10,11 +10,13 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"pve-confsync/internal/demo"
|
||||
"pve-confsync/internal/pve/pvetest"
|
||||
"pve-confsync/internal/store"
|
||||
)
|
||||
|
||||
@@ -117,8 +119,8 @@ func TestAuthAndRoles(t *testing.T) {
|
||||
t.Fatalf("clusters: %d", code)
|
||||
}
|
||||
for _, c := range d["clusters"].([]any) {
|
||||
if s, ok := c.(map[string]any)["secret"]; ok && s != "" {
|
||||
t.Fatalf("secret returned: %v", c)
|
||||
if s, ok := c.(map[string]any)["password"]; ok && s != "" {
|
||||
t.Fatalf("password returned: %v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,6 +149,9 @@ func TestDryRunAndApply(t *testing.T) {
|
||||
if code != http.StatusOK {
|
||||
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)
|
||||
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)
|
||||
@@ -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) }
|
||||
+66
-40
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"pve-confsync/internal/engine"
|
||||
"pve-confsync/internal/model"
|
||||
"pve-confsync/internal/pve"
|
||||
"pve-confsync/internal/sshx"
|
||||
)
|
||||
|
||||
// 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:
|
||||
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)
|
||||
}
|
||||
out = append(out, row)
|
||||
@@ -355,67 +361,87 @@ func (st *State) Apply(ctx context.Context, cp ClientPlan, keys []string, log fu
|
||||
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 {
|
||||
Nodes []Node `json:"nodes,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,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.
|
||||
func CheckCluster(ctx context.Context, cl config.Cluster, write, dcOptions bool) Check {
|
||||
// CheckCluster tests a cluster. pinTLS: the TLS mode pins the certificate and
|
||||
// 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
|
||||
c, err := cl.PVE()
|
||||
if err != nil {
|
||||
fail := func(err error) Check {
|
||||
res.Error = err.Error()
|
||||
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)
|
||||
res.Nodes = nodes(vs)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
if len(vs) > 0 {
|
||||
res.Version = vs[0].Version
|
||||
}
|
||||
perms, err := c.Permissions(ctx)
|
||||
if err != nil {
|
||||
res.Warnings = append(res.Warnings, fmt.Sprintf("token privileges unreadable (%v)", err))
|
||||
return res
|
||||
return fail(err)
|
||||
}
|
||||
res.Warnings = PrivilegeWarnings(perms, write, dcOptions)
|
||||
return res
|
||||
}
|
||||
res.API = true
|
||||
|
||||
// PrivilegeWarnings flags a reference token able to write, and a client token
|
||||
// missing the privileges needed by the synchronized kinds.
|
||||
func PrivilegeWarnings(perms map[string]map[string]any, write, dcOptions bool) []string {
|
||||
has := func(path, priv string) bool { _, ok := perms[path][priv]; return ok }
|
||||
anyPath := func(priv string) bool {
|
||||
for p := range perms {
|
||||
if has(p, priv) {
|
||||
return true
|
||||
o := cl.SSHOptions()
|
||||
if key == "" || trust {
|
||||
seen, fp, err := sshx.Scan(ctx, o)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("SSH: %w", err))
|
||||
}
|
||||
key, res.SSHHostKey, res.SSHFingerprint = seen, seen, fp
|
||||
}
|
||||
return false
|
||||
o.HostKey = key
|
||||
sh, err := sshx.New(o)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("SSH: %w", err))
|
||||
}
|
||||
var w []string
|
||||
if !write {
|
||||
for _, p := range []string{"Sys.Modify", "SDN.Allocate", "Permissions.Modify"} {
|
||||
if anyPath(p) {
|
||||
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 fail(fmt.Errorf("SSH: %w", err))
|
||||
}
|
||||
return w
|
||||
if !strings.HasPrefix(strings.TrimSpace(string(out)), "pve-manager/") {
|
||||
return fail(fmt.Errorf("SSH: %s is not a PVE node (pveversion: %q)", o.Host, strings.TrimSpace(string(out))))
|
||||
}
|
||||
if !has("/sdn", "SDN.Allocate") && !has("/sdn/zones", "SDN.Allocate") {
|
||||
w = append(w, "SDN.Allocate absent sur /sdn : la synchro SDN échouera")
|
||||
}
|
||||
if dcOptions && !has("/", "Sys.Modify") {
|
||||
w = append(w, "Sys.Modify absent sur / : les options datacenter ne pourront pas être écrites")
|
||||
}
|
||||
if !has("/", "Sys.Audit") {
|
||||
w = append(w, "Sys.Audit absent sur / : options datacenter et versions illisibles")
|
||||
}
|
||||
return w
|
||||
res.SSH, res.OK = true, true
|
||||
return res
|
||||
}
|
||||
|
||||
// nodes converts the versions read by the PVE client.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"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 }
|
||||
|
||||
const boxVersion = 1
|
||||
@@ -65,7 +65,7 @@ func (b *Box) Open(blob []byte) ([]byte, error) {
|
||||
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_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
|
||||
@@ -96,7 +96,7 @@ func LoadKey(dataDir string) ([]byte, string, error) {
|
||||
if err := os.WriteFile(path, []byte(hex.EncodeToString(k)+"\n"), 0o600); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -149,10 +149,3 @@ func CheckPassword(hash, pass string) bool {
|
||||
got, err := pbkdf2.Key(sha256.New, pass, salt, iter, len(want))
|
||||
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)
|
||||
}
|
||||
+291
-166
@@ -1,6 +1,7 @@
|
||||
// Package store keeps the state of the web UI in SQLite: clusters (token
|
||||
// secrets encrypted with AES-256-GCM), synchronization settings, per client
|
||||
// mappings and policies, users, execution journal and audit trail.
|
||||
// Package store keeps the state of the web UI in SQLite: clusters (root
|
||||
// passwords encrypted with AES-256-GCM, pinned TLS certificates and SSH host
|
||||
// keys), synchronization settings, per target mappings and policies, users,
|
||||
// execution journal and audit trail.
|
||||
package store
|
||||
|
||||
import (
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
"pve-confsync/internal/adapter"
|
||||
"pve-confsync/internal/config"
|
||||
"pve-confsync/internal/model"
|
||||
"pve-confsync/internal/sshx"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned for an unknown cluster, user or run.
|
||||
@@ -28,20 +31,29 @@ type Store struct {
|
||||
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 = `
|
||||
CREATE TABLE IF NOT EXISTS clusters (
|
||||
name TEXT PRIMARY KEY,
|
||||
reference INTEGER NOT NULL DEFAULT 0,
|
||||
role TEXT NOT NULL DEFAULT 'target',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
url TEXT NOT NULL,
|
||||
token_id TEXT NOT NULL,
|
||||
secret BLOB,
|
||||
api_user TEXT NOT NULL DEFAULT 'root@pam',
|
||||
password BLOB,
|
||||
tls TEXT NOT NULL DEFAULT 'fp',
|
||||
fingerprint TEXT NOT NULL DEFAULT '',
|
||||
ca_pem TEXT NOT NULL DEFAULT '',
|
||||
insecure INTEGER NOT NULL DEFAULT 0,
|
||||
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 '{}',
|
||||
policies TEXT NOT NULL DEFAULT '{}',
|
||||
last_check TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
@@ -96,6 +108,10 @@ func Open(path string, key []byte) (*Store, error) {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
@@ -108,6 +124,29 @@ func Open(path string, key []byte) (*Store, error) {
|
||||
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.
|
||||
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.
|
||||
func (s *Store) checkKey() error {
|
||||
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) {
|
||||
return nil
|
||||
}
|
||||
@@ -123,67 +162,176 @@ func (s *Store) checkKey() error {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ---- clusters ----
|
||||
|
||||
// Cluster is a cluster as stored. The secret is never returned to the UI:
|
||||
// HasSecret only says whether one is set.
|
||||
// Cluster roles: one primary (the source, never written), targets (replicated)
|
||||
// and disabled clusters (kept, not read).
|
||||
const (
|
||||
ClusterPrimary = "primary"
|
||||
ClusterTarget = "target"
|
||||
ClusterDisabled = "disabled"
|
||||
)
|
||||
|
||||
// TLS modes of a cluster.
|
||||
const (
|
||||
TLSPin = "fp" // pinned certificate (recorded on the first connection when empty)
|
||||
TLSCA = "ca" // CA bundle
|
||||
TLSSystem = "sys" // system authorities
|
||||
TLSInsecure = "insecure" // no verification (lab only)
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Reference bool `json:"reference"`
|
||||
Role string `json:"role"`
|
||||
Position int `json:"position"`
|
||||
URL string `json:"url"`
|
||||
TokenID string `json:"tokenId"`
|
||||
Secret string `json:"secret,omitempty"` // write only
|
||||
HasSecret bool `json:"hasSecret"`
|
||||
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"`
|
||||
Insecure bool `json:"insecure"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// Clusters returns every cluster, reference first, without secrets.
|
||||
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) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT name, reference, position, url, token_id, length(secret) > 0,
|
||||
fingerprint, ca_pem, insecure, timeout, mapping, policies, updated_at
|
||||
FROM clusters ORDER BY reference DESC, position, name`)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+clusterCols+` FROM clusters
|
||||
ORDER BY role = 'primary' DESC, position, name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Cluster
|
||||
for rows.Next() {
|
||||
var c Cluster
|
||||
var hasSecret sql.NullBool
|
||||
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 {
|
||||
c, _, err := scanCluster(rows.Scan)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SaveCluster creates or updates a cluster. An empty Secret keeps the stored one.
|
||||
// oldName renames a cluster (empty: same name). Setting Reference on a cluster
|
||||
// makes it the only reference.
|
||||
// Cluster returns one cluster with its decrypted connection (for a test).
|
||||
func (s *Store) Cluster(ctx context.Context, name string) (Cluster, config.Cluster, error) {
|
||||
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 {
|
||||
c.Name = strings.TrimSpace(c.Name)
|
||||
if c.Name == "" || c.URL == "" || c.TokenID == "" {
|
||||
return errors.New("name, url and token id are required")
|
||||
c.URL = strings.TrimRight(strings.TrimSpace(c.URL), "/")
|
||||
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 == "" {
|
||||
oldName = c.Name
|
||||
@@ -195,36 +343,47 @@ func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) erro
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var exists bool
|
||||
var secret []byte
|
||||
err = tx.QueryRowContext(ctx, `SELECT 1, secret FROM clusters WHERE name = ?`, oldName).Scan(&exists, &secret)
|
||||
old, blob, err := scanCluster(tx.QueryRowContext(ctx, `SELECT `+clusterCols+` FROM clusters WHERE name = ?`, oldName).Scan)
|
||||
exists := err == nil
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if c.Secret != "" {
|
||||
if secret, err = s.box.Seal([]byte(c.Secret)); err != nil {
|
||||
if c.Password != "" {
|
||||
if blob, err = s.box.Seal([]byte(c.Password)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(secret) == 0 {
|
||||
return errors.New("the token secret is required")
|
||||
if len(blob) == 0 {
|
||||
return errors.New("the root password is required")
|
||||
}
|
||||
if c.Reference {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE clusters SET reference = 0`); err != nil {
|
||||
hostKey := ""
|
||||
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
|
||||
}
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if exists {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE clusters SET name=?, reference=?, position=?, url=?, token_id=?, secret=?,
|
||||
fingerprint=?, ca_pem=?, insecure=?, timeout=?, mapping=?, policies=?, updated_at=? WHERE name=?`,
|
||||
c.Name, c.Reference, c.Position, c.URL, c.TokenID, secret, c.Fingerprint, c.CAPEM, c.Insecure, c.Timeout,
|
||||
string(mapping), string(policies), now, oldName)
|
||||
_, err = tx.ExecContext(ctx, `UPDATE clusters SET name=?, role=?, position=?, url=?, api_user=?, password=?, tls=?,
|
||||
fingerprint=?, ca_pem=?, timeout=?, ssh_host=?, ssh_port=?, ssh_hostkey=?, mapping=?, policies=?, updated_at=?
|
||||
WHERE name=?`,
|
||||
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 {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO clusters (name, reference, position, url, token_id, secret, fingerprint,
|
||||
ca_pem, insecure, timeout, mapping, policies, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
c.Name, c.Reference, c.Position, c.URL, c.TokenID, secret, c.Fingerprint, c.CAPEM, c.Insecure, c.Timeout,
|
||||
string(mapping), string(policies), now)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO clusters (name, role, position, url, api_user, password, tls, fingerprint,
|
||||
ca_pem, timeout, ssh_host, ssh_port, ssh_hostkey, mapping, policies, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
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 strings.Contains(err.Error(), "UNIQUE") {
|
||||
@@ -235,6 +394,68 @@ func (s *Store) SaveCluster(ctx context.Context, oldName string, c Cluster) erro
|
||||
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.
|
||||
func (s *Store) DeleteCluster(ctx context.Context, name string) error {
|
||||
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 ----
|
||||
|
||||
// Config builds the engine configuration, secrets decrypted. It fails when no
|
||||
// reference is defined.
|
||||
// Config builds the engine configuration, passwords decrypted: the primary
|
||||
// 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) {
|
||||
st, err := s.Settings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,
|
||||
insecure, timeout, mapping, policies FROM clusters ORDER BY reference DESC, position, name`)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+clusterCols+` FROM clusters ORDER BY position, name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
hasRef := false
|
||||
for rows.Next() {
|
||||
var ref bool
|
||||
var blob []byte
|
||||
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 {
|
||||
c, blob, err := scanCluster(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := s.box.Open(blob)
|
||||
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
|
||||
if c.Role == ClusterDisabled {
|
||||
continue
|
||||
}
|
||||
c := config.Client{Cluster: cl}
|
||||
_ = json.Unmarshal([]byte(mapping), &c.Mapping)
|
||||
_ = json.Unmarshal([]byte(policies), &c.Policies)
|
||||
cfg.Clients = append(cfg.Clients, c)
|
||||
cc, err := s.connection(c, blob)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return nil, err
|
||||
@@ -351,96 +567,5 @@ func (s *Store) Config(ctx context.Context) (*config.Config, error) {
|
||||
return cfg, cfg.Validate()
|
||||
}
|
||||
|
||||
// ErrNoReference: nothing can be compared before a reference cluster is defined.
|
||||
var ErrNoReference = errors.New("no reference 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)
|
||||
}
|
||||
// ErrNoReference: nothing can be compared before a primary cluster is chosen.
|
||||
var ErrNoReference = errors.New("no primary cluster defined")
|
||||
+106
-14
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -10,30 +11,30 @@ import (
|
||||
|
||||
func testKey(b byte) []byte { return bytes.Repeat([]byte{b}, 32) }
|
||||
|
||||
func TestSecretsEncryptedAndKeyChecked(t *testing.T) {
|
||||
func TestPasswordsEncryptedAndKeyChecked(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "t.db")
|
||||
st, err := Open(path, testKey(1))
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if bytes.Contains(blob, []byte("s3cr3t")) {
|
||||
t.Fatal("secret stored in clear")
|
||||
t.Fatal("password stored in clear")
|
||||
}
|
||||
cls, _ := st.Clusters(ctx)
|
||||
if len(cls) != 1 || cls[0].Secret != "" || !cls[0].HasSecret {
|
||||
t.Fatalf("listing must hide the secret: %+v", cls)
|
||||
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 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.URL = "https://ref2:8006"
|
||||
c.Timeout = "20s"
|
||||
if err := st.SaveCluster(ctx, "ref", c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -41,12 +42,8 @@ func TestSecretsEncryptedAndKeyChecked(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _ := cfg.Reference.ResolveSecret(); s != "s3cr3t-uuid" {
|
||||
t.Fatalf("secret after update: %q", s)
|
||||
}
|
||||
exp, _ := st.ExportConfig(ctx)
|
||||
if exp.Reference.Secret != "" || exp.Reference.TokenSecretEnv != "PVE_REF_SECRET" {
|
||||
t.Fatalf("export: %+v", exp.Reference)
|
||||
if cfg.Reference.Password != "s3cr3t-root" || cfg.Reference.User != "root@pam" {
|
||||
t.Fatalf("password after update: %+v", cfg.Reference)
|
||||
}
|
||||
st.Close()
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
st, err := Open(filepath.Join(t.TempDir(), "t.db"), testKey(1))
|
||||
@@ -99,3 +164,30 @@ func TestPasswordHash(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -180,3 +180,33 @@ func (s *Store) keepAnAdmin(ctx context.Context, name, newRole string) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
+414
-158
@@ -1,5 +1,6 @@
|
||||
// pve-confsync web UI: clusters, differences per client, dry-run and synchronization
|
||||
// (per object, per client, global), execution journal, audit trail and settings.
|
||||
// pve-confsync web UI: clusters (root credentials, primary and targets), replicated
|
||||
// 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():
|
||||
// data coming from the clusters is always set as text, never as HTML.
|
||||
(function () {
|
||||
@@ -9,73 +10,90 @@
|
||||
const I18N = {
|
||||
fr: {
|
||||
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',
|
||||
logout: 'Se déconnecter', viewAria: 'Affichage', viewDiff: 'Écarts', viewRuns: 'Journal', viewAudit: 'Audit',
|
||||
kindAria: "Type d'objet", statusAria: 'Statut', modeAria: 'Mode', dryAll: 'Dry-run global', syncAll: 'Tout synchroniser',
|
||||
setupText: "Aucun cluster de référence n'est défini : ajoutez la référence puis les clients.", setupBtn: 'Ajouter les clusters',
|
||||
refreshTitle: 'Relire le primaire et les cibles', settings: 'Paramètres', theme: 'Thème clair / sombre',
|
||||
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', syncAll: 'Répliquer',
|
||||
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',
|
||||
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',
|
||||
colField: 'Champ', colValue: 'Valeur', colPolicy: 'Politique', colInclude: 'Inclure (motifs)', colExclude: 'Exclure (motifs)',
|
||||
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',
|
||||
applyBtn: 'Synchroniser', addRow: '+ Ajouter',
|
||||
kinds: { 'datacenter-options': 'Options datacenter', 'sdn-zone': 'Zones SDN', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets' },
|
||||
kindShort: { 'datacenter-options': 'options', 'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet' },
|
||||
kinds: {
|
||||
'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',
|
||||
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',
|
||||
reference: 'RÉF', unreadable: 'illisible', inLine: 'conforme', nodes: '{n} nœud(s)',
|
||||
readAt: 'lu à {t} ({ms} ms)', dryClient: 'Dry-run du client', syncClient: 'Synchroniser le client',
|
||||
reference: 'PRIMAIRE', unreadable: 'illisible', inLine: 'conforme', nodes: '{n} nœud(s)',
|
||||
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',
|
||||
refError: 'Référence illisible', clientError: 'Client illisible', planError: 'Bloquant', warning: 'Avertissement',
|
||||
fieldsN: '{n} champ(s)', noFields: 'aucun champ',
|
||||
thField: 'Champ', thPolicy: 'Politique', thRef: 'Référence', thMapping: 'Mapping client', thCurrent: 'Client actuel', thTarget: 'Après synchro',
|
||||
emptyOk: 'Aucun écart pour ce client.', emptyOkHint: 'Le client est conforme à la référence pour les types synchronisés.',
|
||||
refError: 'Primaire illisible', clientError: 'Cible illisible', planError: 'Bloquant', warning: 'Avertissement',
|
||||
fieldsN: '{n} champ(s)', objectsN: '{n} objet(s)', noFields: 'aucun champ',
|
||||
thField: 'Champ', thPolicy: 'Politique', thRef: 'Primaire', thMapping: 'Mapping cible', thCurrent: 'Cible actuelle', thTarget: 'Après synchro',
|
||||
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.',
|
||||
noClient: 'Aucun client.', noClientHint: 'Ajoutez des clusters clients dans les paramètres.',
|
||||
connOk: 'Connecté', connKo: 'Hors ligne', statsLine: 'Référence {ref} · {n} client(s) · {d} avec écarts · lu à {t}',
|
||||
dryTitle: 'Dry-run : {what}', whatGlobal: 'tous les clients', whatClient: 'client {c}', whatObject: '{o} sur {c}',
|
||||
reading: 'Lecture des clusters…', planEmpty: 'Rien à faire : conforme.', planErrors: 'Erreurs bloquantes : rien ne sera appliqué sur ce client.',
|
||||
noClient: 'Aucune cible.', noClientHint: 'Ajoutez des clusters cibles dans la vue Clusters.',
|
||||
connOk: 'Connecté', connKo: 'Hors ligne', statsLine: 'Primaire {ref} · {n} cible(s) · {d} avec écarts · lu à {t}',
|
||||
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 cette cible.',
|
||||
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.",
|
||||
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.",
|
||||
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é' },
|
||||
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',
|
||||
noRuns: 'Aucune exécution.', noAudit: 'Aucun évènement.',
|
||||
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',
|
||||
'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',
|
||||
},
|
||||
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.',
|
||||
addCluster: '+ Ajouter un cluster',
|
||||
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.",
|
||||
addCluster: '+ Ajouter un cluster', addBulk: '+ Ajouter une liste', testAll: 'Tout tester',
|
||||
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' },
|
||||
newCluster: 'Nouveau cluster', editCluster: 'Cluster {c}', cName: 'Nom', cRole: 'Rôle', roleClient: 'Client', roleRef: 'Référence',
|
||||
cToken: 'Token', cSecret: 'Secret', cTLS: 'TLS', cFp: 'Empreinte', cTimeout: 'Délai',
|
||||
tlsFp: 'Empreinte SHA-256 du certificat', tlsCa: "Certificat d'autorité (PEM)", tlsSys: 'Autorités du système', tlsInsecure: 'Sans vérification (labo uniquement)',
|
||||
secretKeep: 'Laisser vide pour conserver le secret enregistré.', secretNew: 'Secret du token API (UUID), chiffré en base.',
|
||||
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.",
|
||||
cPolicies: 'Politiques propres au client', cPoliciesHelp: 'Prioritaires sur les politiques globales pour ce client.',
|
||||
confirmDelete: 'Supprimer le cluster {c} ? Ses mappings sont perdus.', confirmDeleteUser: "Supprimer l'utilisateur {u} ?",
|
||||
kindsTitle: 'Types synchronisés', kindsHelp: 'Hors périmètre v1 : EVPN, firewall, storage, pools/ACL, jobs, secrets.',
|
||||
pruneTitle: 'Suppression', pruneLabel: 'Supprimer chez les clients les objets absents de la référence',
|
||||
crole: { primary: 'primaire', target: 'cible', disabled: 'inactif' }, makePrimary: 'Définir comme primaire',
|
||||
neverTested: 'jamais testé', testedAt: 'testé le {t}', pinned: 'enregistré : {l}', trustKey: 'Faire confiance à la nouvelle clé',
|
||||
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é ?",
|
||||
newCluster: 'Nouveau cluster', editCluster: 'Cluster {c}', cName: 'Nom', cRole: 'Rôle',
|
||||
rolePrimary: 'Primaire', roleTarget: 'Cible', roleDisabled: 'Inactif',
|
||||
cUser: 'Utilisateur', cPassword: 'Mot de passe root', cTLS: 'TLS', cFp: 'Empreinte', cTimeout: 'Délai',
|
||||
cSSHHost: 'Nœud SSH', cSSHPort: 'Port SSH', cSSHKey: "Clé d'hôte SSH", sshKeyNone: 'enregistrée au premier test',
|
||||
fpPh: 'enregistrée au premier test', sshHostPh: "hôte de l'URL",
|
||||
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.',
|
||||
selTitle: 'Sélection des objets', selHelp: "Motifs glob sur l'identifiant, séparés par des virgules (ex. lab*, test-?). 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 :',
|
||||
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 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}',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
@@ -85,73 +103,90 @@
|
||||
},
|
||||
en: {
|
||||
demo: 'DEMO', searchPh: 'Filter objects (name, field, value)', refresh: 'Refresh',
|
||||
refreshTitle: 'Read the reference and the clients again', settings: 'Settings', theme: 'Light / dark theme',
|
||||
logout: 'Sign out', viewAria: 'View', viewDiff: 'Differences', viewRuns: 'Journal', viewAudit: 'Audit',
|
||||
kindAria: 'Object kind', statusAria: 'Status', modeAria: 'Mode', dryAll: 'Global dry-run', syncAll: 'Sync everything',
|
||||
setupText: 'No reference cluster yet: add the reference, then the clients.', setupBtn: 'Add clusters',
|
||||
refreshTitle: 'Read the primary and the targets again', settings: 'Settings', theme: 'Light / dark theme',
|
||||
logout: 'Sign out', viewAria: 'View', viewDiff: 'Differences', viewClusters: 'Clusters', viewRuns: 'Journal', viewAudit: 'Audit',
|
||||
kindAria: 'Object kind', statusAria: 'Status', modeAria: 'Mode', dryAll: 'Dry-run', syncAll: 'Replicate',
|
||||
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',
|
||||
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',
|
||||
colField: 'Field', colValue: 'Value', colPolicy: 'Policy', colInclude: 'Include (patterns)', colExclude: 'Exclude (patterns)',
|
||||
colRole: 'Role', colPassword: 'New password', colLastLogin: 'Last login',
|
||||
more: 'More', toTop: 'Back to top', close: 'Close', cancel: 'Cancel', save: 'Save', delete: 'Delete',
|
||||
applyBtn: 'Synchronize', addRow: '+ Add',
|
||||
kinds: { 'datacenter-options': 'Datacenter options', 'sdn-zone': 'SDN zones', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets' },
|
||||
kindShort: { 'datacenter-options': 'options', 'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet' },
|
||||
kinds: {
|
||||
'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',
|
||||
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',
|
||||
reference: 'REF', unreadable: 'unreadable', inLine: 'in sync', nodes: '{n} node(s)',
|
||||
readAt: 'read at {t} ({ms} ms)', dryClient: 'Client dry-run', syncClient: 'Synchronize client',
|
||||
reference: 'PRIMARY', unreadable: 'unreadable', inLine: 'in sync', nodes: '{n} node(s)',
|
||||
readAt: 'read at {t} ({ms} ms)', dryClient: 'Target dry-run', syncClient: 'Synchronize target',
|
||||
dryObj: 'Dry-run this object', syncObj: 'Synchronize this object',
|
||||
refError: 'Reference unreadable', clientError: 'Client unreadable', planError: 'Blocking', warning: 'Warning',
|
||||
fieldsN: '{n} field(s)', noFields: 'no field',
|
||||
thField: 'Field', thPolicy: 'Policy', thRef: 'Reference', thMapping: 'Client mapping', thCurrent: 'Client now', thTarget: 'After sync',
|
||||
emptyOk: 'No difference for this client.', emptyOkHint: 'The client is in line with the reference for the synchronized kinds.',
|
||||
refError: 'Primary unreadable', clientError: 'Target unreadable', planError: 'Blocking', warning: 'Warning',
|
||||
fieldsN: '{n} field(s)', objectsN: '{n} object(s)', noFields: 'no field',
|
||||
thField: 'Field', thPolicy: 'Policy', thRef: 'Primary', thMapping: 'Target mapping', thCurrent: 'Target now', thTarget: 'After sync',
|
||||
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.',
|
||||
noClient: 'No client.', noClientHint: 'Add client clusters in the settings.',
|
||||
connOk: 'Connected', connKo: 'Offline', statsLine: 'Reference {ref} · {n} client(s) · {d} with differences · read at {t}',
|
||||
dryTitle: 'Dry-run: {what}', whatGlobal: 'all clients', whatClient: 'client {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.',
|
||||
noClient: 'No target.', noClientHint: 'Add target clusters in the Clusters view.',
|
||||
connOk: 'Connected', connKo: 'Offline', statsLine: 'Primary {ref} · {n} target(s) · {d} with differences · read at {t}',
|
||||
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 target.',
|
||||
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.',
|
||||
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.',
|
||||
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' },
|
||||
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',
|
||||
noRuns: 'No run yet.', noAudit: 'No event.',
|
||||
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',
|
||||
'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',
|
||||
},
|
||||
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.',
|
||||
addCluster: '+ Add a cluster',
|
||||
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.',
|
||||
addCluster: '+ Add a cluster', addBulk: '+ Add a list', testAll: 'Test all',
|
||||
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' },
|
||||
newCluster: 'New cluster', editCluster: 'Cluster {c}', cName: 'Name', cRole: 'Role', roleClient: 'Client', roleRef: 'Reference',
|
||||
cToken: 'Token', cSecret: 'Secret', cTLS: 'TLS', cFp: 'Fingerprint', cTimeout: 'Timeout',
|
||||
tlsFp: 'SHA-256 fingerprint of the certificate', tlsCa: 'Certificate authority (PEM)', tlsSys: 'System authorities', tlsInsecure: 'No verification (lab only)',
|
||||
secretKeep: 'Leave empty to keep the stored secret.', secretNew: 'API token secret (UUID), encrypted in the database.',
|
||||
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.',
|
||||
cPolicies: 'Client policies', cPoliciesHelp: 'Take precedence over the global policies for this client.',
|
||||
confirmDelete: 'Delete cluster {c}? Its mappings are lost.', confirmDeleteUser: 'Delete user {u}?',
|
||||
kindsTitle: 'Synchronized kinds', kindsHelp: 'Out of scope in v1: EVPN, firewall, storage, pools/ACL, jobs, secrets.',
|
||||
pruneTitle: 'Deletion', pruneLabel: 'Delete on the clients the objects absent from the reference',
|
||||
crole: { primary: 'primary', target: 'target', disabled: 'disabled' }, makePrimary: 'Make primary',
|
||||
neverTested: 'never tested', testedAt: 'tested on {t}', pinned: 'recorded: {l}', trustKey: 'Trust the new key',
|
||||
confirmTrust: 'The SSH host key of {c} changed ({fp}). Only accept it if the node was reinstalled or replaced. Trust this key?',
|
||||
newCluster: 'New cluster', editCluster: 'Cluster {c}', cName: 'Name', cRole: 'Role',
|
||||
rolePrimary: 'Primary', roleTarget: 'Target', roleDisabled: 'Disabled',
|
||||
cUser: 'User', cPassword: 'Root password', cTLS: 'TLS', cFp: 'Fingerprint', cTimeout: 'Timeout',
|
||||
cSSHHost: 'SSH node', cSSHPort: 'SSH port', cSSHKey: 'SSH host key', sshKeyNone: 'recorded on the first test',
|
||||
fpPh: 'recorded on the first test', sshHostPh: 'host of the URL',
|
||||
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.',
|
||||
selTitle: 'Object selection', selHelp: 'Glob patterns on the id, comma separated (e.g. lab*, test-?). Empty: everything.',
|
||||
polTitle: 'Field policies', polHelp: 'sync: copied from the reference; ignore: never read nor written; override: value from the client mapping. Defaults:',
|
||||
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 primary; ignore: never read nor written; override: value from the target mapping. Defaults:',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
@@ -160,7 +195,8 @@
|
||||
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 ROLES = ['admin', 'operator', 'viewer'];
|
||||
|
||||
@@ -299,7 +335,11 @@
|
||||
const S = {
|
||||
me: null,
|
||||
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',
|
||||
client: null, // selected client
|
||||
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'))));
|
||||
}
|
||||
|
||||
// ---------- routing (#diff/<client>, #runs, #audit) ----------
|
||||
// ---------- routing (#diff/<target>, #clusters, #runs, #audit) ----------
|
||||
function readHash() {
|
||||
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 (c) S.client = c;
|
||||
}
|
||||
@@ -370,6 +410,7 @@
|
||||
renderView();
|
||||
if (v === 'runs') loadRuns(true);
|
||||
if (v === 'audit') loadAudit(true);
|
||||
if (v === 'clusters') loadClusters();
|
||||
}
|
||||
|
||||
// ---------- loading ----------
|
||||
@@ -378,7 +419,7 @@
|
||||
$('#refreshBtn').classList.add('spin');
|
||||
try {
|
||||
const d = await api('GET', 'api/state' + (refresh ? '?refresh=1' : ''));
|
||||
S.setup = !!d.setup;
|
||||
S.setup = d.setup || '';
|
||||
S.state = d.state || null;
|
||||
$('#error').hidden = true;
|
||||
if (S.state) {
|
||||
@@ -412,13 +453,20 @@
|
||||
function renderView() {
|
||||
$$('#viewMode [data-view]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.view === S.view)));
|
||||
$('#diffView').hidden = S.view !== 'diff';
|
||||
$('#clustersView').hidden = S.view !== 'clusters';
|
||||
$('#runsView').hidden = S.view !== 'runs';
|
||||
$('#auditView').hidden = S.view !== 'audit';
|
||||
$$('[data-diff]').forEach((e) => { e.hidden = S.view !== 'diff'; });
|
||||
$$('[data-runs]').forEach((e) => { e.hidden = S.view !== 'runs'; });
|
||||
$('#globalActs').hidden = S.view !== 'diff' || !S.state || !can.sync();
|
||||
$('#setup').hidden = !S.setup;
|
||||
if (S.view === 'diff') renderDiff();
|
||||
$('#setup').hidden = !S.setup || S.view === 'clusters';
|
||||
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 === 'audit') renderAudit();
|
||||
}
|
||||
@@ -450,7 +498,7 @@
|
||||
h('span', { class: 'c-ver', text: r.version ? 'PVE ' + r.version : '' })),
|
||||
h('div', { class: 'c-url', text: r.url }),
|
||||
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)));
|
||||
for (const c of st.clients) {
|
||||
const cs = clientStatus(c);
|
||||
@@ -632,7 +680,7 @@
|
||||
const plan = { sel: null, plans: null, run: 0, poll: 0 };
|
||||
|
||||
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 });
|
||||
return t('whatObject', { o: sel.objects.join(', '), c: sel.client });
|
||||
}
|
||||
@@ -898,7 +946,7 @@
|
||||
}
|
||||
|
||||
// ---------- settings ----------
|
||||
const set = { tab: 'clusters', clusters: [], settings: null, defaults: {}, dirty: false };
|
||||
const set = { tab: 'sync', settings: null, defaults: {}, dirty: false };
|
||||
|
||||
function openSettings(tab) {
|
||||
const dlg = $('#settingsDlg');
|
||||
@@ -912,12 +960,11 @@
|
||||
showTab(tab || set.tab);
|
||||
}
|
||||
function showTab(tab) {
|
||||
if ((tab === 'users' || tab === 'config') && !can.admin()) tab = 'clusters';
|
||||
if (tab === 'users' && !can.admin()) tab = 'sync';
|
||||
set.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; });
|
||||
$('#setActions').hidden = tab !== 'sync' || !can.admin();
|
||||
if (tab === 'clusters') loadClusters();
|
||||
if (tab === 'sync') loadSettings();
|
||||
if (tab === 'users') loadUsers();
|
||||
if (tab === 'account') renderAccount();
|
||||
@@ -928,63 +975,129 @@
|
||||
}
|
||||
}
|
||||
|
||||
// clusters
|
||||
// ---------- clusters view ----------
|
||||
async function loadClusters() {
|
||||
try {
|
||||
const d = await api('GET', 'api/clusters');
|
||||
set.clusters = d.clusters;
|
||||
S.clusters = (await api('GET', 'api/clusters')).clusters;
|
||||
} catch (e) {
|
||||
if (e.message !== 'auth') toast(e.message, true);
|
||||
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');
|
||||
list.replaceChildren();
|
||||
if (!set.clusters.length) list.appendChild(h('p', { class: 'muted', text: t('setupText') }));
|
||||
for (const c of set.clusters) {
|
||||
if (!S.clusters.length) list.appendChild(h('p', { class: 'muted', text: t('setupClusters') }));
|
||||
for (const c of S.clusters) {
|
||||
const chk = h('div', { class: 'chk', hidden: true });
|
||||
list.appendChild(h('div', { class: 'citem' + (c.reference ? ' ref' : '') },
|
||||
h('div', { class: 'n' }, c.reference ? h('span', { class: 'c-role', text: t('reference') }) : null, h('span', { text: c.name })),
|
||||
h('div', { class: 'u', text: c.url + (c.tokenId ? ' · ' + c.tokenId : ''), title: c.url }),
|
||||
can.admin() ? h('div', { class: 'acts' },
|
||||
const admin = can.admin();
|
||||
let role = null;
|
||||
if (admin) {
|
||||
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: () => editCluster(c) }, icon('pencil'), t('edit'))) : h('span'),
|
||||
chk));
|
||||
meta, chk));
|
||||
}
|
||||
}
|
||||
async function checkCluster(name, el) {
|
||||
async function setRole(name, role) {
|
||||
try {
|
||||
await api('POST', 'api/clusters/' + encodeURIComponent(name) + '/role', { role });
|
||||
toast(t('saved'));
|
||||
} catch (e) {
|
||||
if (e.message !== 'auth') toast(e.message, true);
|
||||
}
|
||||
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');
|
||||
try {
|
||||
const r = await api('POST', 'api/clusters/' + encodeURIComponent(name) + '/check');
|
||||
const nodes = (r.nodes || []).map((n) => (n.name ? n.name + ' ' : '') + 'PVE ' + n.version).join(', ');
|
||||
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) {
|
||||
el.className = 'chk ko';
|
||||
el.textContent = '✗ ' + e.message;
|
||||
}
|
||||
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;
|
||||
function editCluster(c) {
|
||||
editing = c || null;
|
||||
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 });
|
||||
$('#cName').value = c.name;
|
||||
setSeg('#cRole', c.reference ? 'ref' : 'client');
|
||||
setSeg('#cRole', c.role);
|
||||
$('#cURL').value = c.url;
|
||||
$('#cToken').value = c.tokenId;
|
||||
$('#cSecret').value = '';
|
||||
$('#cSecret').required = isNew || !c.hasSecret;
|
||||
$('#cSecretHint').textContent = isNew || !c.hasSecret ? t('secretNew') : t('secretKeep');
|
||||
$('#cTLS').value = c.fingerprint ? 'fp' : c.caPem ? 'ca' : c.insecure ? 'insecure' : (isNew ? 'fp' : 'sys');
|
||||
$('#cPassword').value = '';
|
||||
$('#cPassword').required = isNew || !c.hasPassword;
|
||||
$('#cPasswordHint').textContent = isNew || !c.hasPassword ? t('passwordNew') : t('passwordKeep');
|
||||
$('#cTLS').value = c.tls || 'fp';
|
||||
$('#cFp').value = c.fingerprint || '';
|
||||
$('#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 || '';
|
||||
$('#cDelete').hidden = isNew;
|
||||
$('#cForget').hidden = isNew || (!c.sshHostKey && !(c.tls === 'fp' && c.fingerprint));
|
||||
$('#cErr').hidden = true;
|
||||
tlsFields();
|
||||
roleFields();
|
||||
@@ -1009,11 +1122,11 @@
|
||||
const v = $('#cTLS').value;
|
||||
$$('#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) {
|
||||
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;
|
||||
}
|
||||
function refIds(kind) {
|
||||
@@ -1088,21 +1201,25 @@
|
||||
const tls = $('#cTLS').value;
|
||||
const c = {
|
||||
name: $('#cName').value.trim(),
|
||||
reference: segVal('#cRole') === 'ref',
|
||||
position: editing ? editing.position : set.clusters.length,
|
||||
role: segVal('#cRole') || 'target',
|
||||
position: editing ? editing.position : S.clusters.length,
|
||||
url: $('#cURL').value.trim(),
|
||||
tokenId: $('#cToken').value.trim(),
|
||||
secret: $('#cSecret').value.trim(),
|
||||
hasSecret: false,
|
||||
user: 'root@pam',
|
||||
password: $('#cPassword').value,
|
||||
hasPassword: false,
|
||||
tls,
|
||||
fingerprint: tls === 'fp' ? $('#cFp').value.trim() : '',
|
||||
caPem: tls === 'ca' ? $('#cCA').value.trim() : '',
|
||||
insecure: tls === 'insecure',
|
||||
timeout: $('#cTimeout').value.trim(),
|
||||
sshHost: $('#cSSHHost').value.trim(),
|
||||
sshPort: Number($('#cSSHPort').value) || 22,
|
||||
sshHostKey: '',
|
||||
sshFingerprint: '',
|
||||
mapping: {},
|
||||
policies: {},
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (!c.reference) {
|
||||
if (c.role !== 'primary') {
|
||||
for (const row of $$('#cMapRows .erow')) {
|
||||
const k = $('select', row).value;
|
||||
const id = $('[data-k="id"]', row).value.trim();
|
||||
@@ -1112,12 +1229,14 @@
|
||||
}
|
||||
c.policies = readPolicies($('#cPolRows'));
|
||||
}
|
||||
if (!c.secret) delete c.secret;
|
||||
if (!c.password) delete c.password;
|
||||
try {
|
||||
await api('PUT', 'api/clusters/' + encodeURIComponent(editing ? editing.name : '-'), c);
|
||||
$('#clusterDlg').close();
|
||||
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);
|
||||
} catch (e) {
|
||||
$('#cErr').textContent = e.message;
|
||||
@@ -1136,6 +1255,67 @@
|
||||
$('#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)
|
||||
async function loadSettings() {
|
||||
@@ -1143,6 +1323,8 @@
|
||||
const d = await api('GET', 'api/settings');
|
||||
set.settings = d.settings;
|
||||
set.defaults = d.defaults || {};
|
||||
S.settings = d.settings;
|
||||
S.deps = d.deps || {};
|
||||
} catch (e) {
|
||||
if (e.message !== 'auth') toast(e.message, true);
|
||||
return;
|
||||
@@ -1155,7 +1337,11 @@
|
||||
const chips = $('#kindChips');
|
||||
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)),
|
||||
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))));
|
||||
$('#pruneSw').checked = !!st.prune;
|
||||
const sel = $('#selRows');
|
||||
@@ -1198,12 +1384,85 @@
|
||||
await api('PUT', 'api/settings', body);
|
||||
setDirty(false);
|
||||
$('#setState').textContent = t('saved');
|
||||
S.settings = Object.assign({}, S.settings, body);
|
||||
loadState(true);
|
||||
} catch (e) {
|
||||
$('#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
|
||||
async function loadUsers() {
|
||||
let users;
|
||||
@@ -1239,20 +1498,6 @@
|
||||
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
|
||||
function renderAccount() {
|
||||
const me = S.me;
|
||||
@@ -1278,7 +1523,10 @@
|
||||
function bind() {
|
||||
$('#refreshBtn').addEventListener('click', () => { if (S.view === 'diff') loadState(true); else if (S.view === 'runs') loadRuns(true); else loadAudit(true); });
|
||||
$('#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', () => {
|
||||
const root = document.documentElement;
|
||||
const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
@@ -1293,8 +1541,12 @@
|
||||
clearTimeout(qTimer);
|
||||
qTimer = setTimeout(() => { S.q = e.target.value.trim(); renderView(); }, 120);
|
||||
});
|
||||
$('#dryAllBtn').addEventListener('click', () => openPlan({ scope: 'global' }));
|
||||
$('#syncAllBtn').addEventListener('click', () => openPlan({ scope: 'global' }, true));
|
||||
$('#dryAllBtn').addEventListener('click', () => openPlan(globalSel()));
|
||||
$('#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);
|
||||
$('#runMore').addEventListener('click', () => loadRuns(false));
|
||||
$('#auditMore').addEventListener('click', () => loadAudit(false));
|
||||
@@ -1304,6 +1556,10 @@
|
||||
// settings
|
||||
$$('#setNav [data-tab]').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab)));
|
||||
$('#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);
|
||||
$('#cDelete').addEventListener('click', deleteCluster);
|
||||
$('#cTLS').addEventListener('change', tlsFields);
|
||||
@@ -1315,12 +1571,6 @@
|
||||
$('#setSave').addEventListener('click', saveSettings);
|
||||
$('#setCancel').addEventListener('click', () => { renderSettings(); setDirty(false); });
|
||||
$('#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);
|
||||
$$('#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)));
|
||||
@@ -1358,6 +1608,12 @@
|
||||
$('#ver').textContent = 'pve-confsync ' + S.me.version;
|
||||
readHash();
|
||||
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);
|
||||
if (S.view === 'runs') loadRuns(true);
|
||||
if (S.view === 'audit') loadAudit(true);
|
||||
|
||||
+87
-65
@@ -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>
|
||||
<span data-i18n="viewDiff">Écarts</span>
|
||||
</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">
|
||||
<!-- 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>
|
||||
@@ -62,12 +67,28 @@
|
||||
<span data-i18n="viewAudit">Audit</span>
|
||||
</button>
|
||||
</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="statusSel" data-diff data-i18n-aria="statusAria"></select>
|
||||
<select id="runModeSel" data-runs data-i18n-aria="modeAria" hidden></select>
|
||||
<span class="spacer"></span>
|
||||
<span id="count" class="muted"></span>
|
||||
<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">
|
||||
<!-- 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>
|
||||
@@ -82,8 +103,8 @@
|
||||
|
||||
<div id="error" class="error-banner" hidden></div>
|
||||
<div id="setup" class="setup" hidden>
|
||||
<p data-i18n="setupText">Aucun cluster de référence n'est défini.</p>
|
||||
<button id="setupBtn" class="btn primary" type="button" data-i18n="setupBtn">Ajouter les clusters</button>
|
||||
<p id="setupText"></p>
|
||||
<button id="setupBtn" class="btn primary" type="button"></button>
|
||||
</div>
|
||||
|
||||
<!-- Differences: clusters, then the objects of the selected client -->
|
||||
@@ -108,6 +129,17 @@
|
||||
</div>
|
||||
</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 -->
|
||||
<section id="runsView" hidden>
|
||||
<div class="table runs">
|
||||
@@ -185,39 +217,46 @@
|
||||
<input id="cName" class="inp" required autocomplete="off" spellcheck="false">
|
||||
<span class="lbl" data-i18n="cRole">Rôle</span>
|
||||
<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="ref" data-i18n="roleRef">Référence</button>
|
||||
<button type="button" role="radio" data-v="primary" data-i18n="rolePrimary">Primaire</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>
|
||||
<label for="cURL">URL</label>
|
||||
<input id="cURL" class="inp mono" required placeholder="https://pve.example.org:8006" autocomplete="off" spellcheck="false">
|
||||
<label for="cToken" data-i18n="cToken">Token</label>
|
||||
<input id="cToken" class="inp mono" required placeholder="confsync@pve!rw" autocomplete="off" spellcheck="false">
|
||||
<label for="cSecret" data-i18n="cSecret">Secret</label>
|
||||
<input id="cSecret" class="inp mono" type="password" autocomplete="new-password" spellcheck="false">
|
||||
<p class="muted small hint" id="cSecretHint"></p>
|
||||
<input id="cURL" class="inp mono" required placeholder="https://pve1.example.org:8006" autocomplete="off" spellcheck="false">
|
||||
<label for="cUser" data-i18n="cUser">Utilisateur</label>
|
||||
<input id="cUser" class="inp mono" value="root@pam" readonly tabindex="-1">
|
||||
<label for="cPassword" data-i18n="cPassword">Mot de passe root</label>
|
||||
<input id="cPassword" class="inp mono" type="password" autocomplete="new-password" spellcheck="false">
|
||||
<p class="muted small hint" id="cPasswordHint"></p>
|
||||
<span class="lbl" data-i18n="cTLS">TLS</span>
|
||||
<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="sys" data-i18n="tlsSys">Autorités du système</option>
|
||||
<option value="insecure" data-i18n="tlsInsecure">Sans vérification (labo uniquement)</option>
|
||||
</select>
|
||||
<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>
|
||||
<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>
|
||||
<input id="cTimeout" class="inp mono" placeholder="30s" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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 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>
|
||||
<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>
|
||||
@@ -227,6 +266,7 @@
|
||||
</div>
|
||||
<footer>
|
||||
<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>
|
||||
<button class="btn ghost" type="button" data-close data-i18n="cancel">Annuler</button>
|
||||
<button class="btn primary" type="submit" data-i18n="save">Enregistrer</button>
|
||||
@@ -234,6 +274,36 @@
|
||||
</form>
|
||||
</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 pve-nantes https://10.0.1.11:8006 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">
|
||||
<div class="dlg">
|
||||
<header>
|
||||
@@ -243,11 +313,6 @@
|
||||
<div class="set-body">
|
||||
<div class="set-side">
|
||||
<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">
|
||||
<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>
|
||||
@@ -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>
|
||||
<span data-i18n="tabUsers">Utilisateurs</span>
|
||||
</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">
|
||||
<!-- 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>
|
||||
@@ -283,30 +343,17 @@
|
||||
</div>
|
||||
|
||||
<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 -->
|
||||
<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>
|
||||
<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>
|
||||
<p class="muted small mt-s" data-i18n="kindsHelp"></p>
|
||||
</section>
|
||||
<section class="set-section danger" data-edit>
|
||||
<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>
|
||||
</section>
|
||||
<section class="set-section span" data-edit>
|
||||
@@ -336,31 +383,6 @@
|
||||
</section>
|
||||
</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: name: ref …"></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 -->
|
||||
<div class="set-panel" role="tabpanel" data-panel="account" hidden>
|
||||
<section class="set-section">
|
||||
|
||||
@@ -27,6 +27,19 @@
|
||||
</div>
|
||||
<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>
|
||||
<div id="sep" class="login-sep" hidden data-i18n="or">ou</div>
|
||||
|
||||
|
||||
+22
-4
@@ -4,17 +4,23 @@
|
||||
fr: { intro: 'Synchronisation de configuration Proxmox VE', user: 'Utilisateur', pass: 'Mot de passe',
|
||||
submit: 'Se connecter', sso: 'Se connecter avec le SSO', or: 'ou',
|
||||
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',
|
||||
submit: 'Sign in', sso: 'Sign in with SSO', or: 'or',
|
||||
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 set(k, v) { try { localStorage.setItem('confsync.' + k, v); } catch (e) {} }
|
||||
var lang = get('lang');
|
||||
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 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-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)); });
|
||||
var se = q.get('setup');
|
||||
el('sErr').hidden = !se;
|
||||
if (se) el('sErr').textContent = d[se === 'mismatch' ? 'mismatch' : 'invalid'];
|
||||
var h = el('demoHint');
|
||||
h.textContent = '';
|
||||
if (cfg.demo) {
|
||||
@@ -50,9 +59,18 @@
|
||||
el('ret').value = ret;
|
||||
el('err').hidden = q.get('e') !== '1';
|
||||
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() {
|
||||
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('sep').hidden = !(cfg.local && cfg.oidc);
|
||||
el('submitBtn').className = cfg.oidc ? 'btn' : 'btn primary';
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
--k-zone: #1d6fd6;
|
||||
--k-vnet: #1f9aa3;
|
||||
--k-subnet: #5f8a1f;
|
||||
--k-acc: #b45309;
|
||||
|
||||
--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;
|
||||
@@ -65,6 +66,7 @@
|
||||
--k-zone: #5aa2f5;
|
||||
--k-vnet: #3cc3cc;
|
||||
--k-subnet: #9ccc54;
|
||||
--k-acc: #f0a050;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -94,6 +96,7 @@
|
||||
--k-zone: #5aa2f5;
|
||||
--k-vnet: #3cc3cc;
|
||||
--k-subnet: #9ccc54;
|
||||
--k-acc: #f0a050;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
@@ -362,6 +365,7 @@ button.ccard:hover { background: var(--row-hover); }
|
||||
.kind.sdn-zone { --k: var(--k-zone); }
|
||||
.kind.sdn-vnet { --k: var(--k-vnet); }
|
||||
.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 */
|
||||
.badge {
|
||||
@@ -688,3 +692,52 @@ body.login-page { min-height: 100vh; padding: 16px; display: grid; place-items:
|
||||
.mt-s { margin-top: 8px !important; }
|
||||
.erow .acts { display: flex; gap: 2px; }
|
||||
.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); }
|
||||
Reference in new issue
Block a user