acces d'administration : un tunnel WireGuard nominatif, pas le runner en rebond
Le runner detient la voute et les cles SSH : en faire la porte des humains reunirait deux pouvoirs que le depot separe. Instance `admins` a cote du tunnel site-a-site, un pair par personne et par appareil, cles publiques seules au plan. Le reseau du tunnel est un reseau d'administration : pare-feux d'hote, contrat des locataires et regles de bordure en derivent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ee386dca00
commit
d90513ca91
9 changed files with 511 additions and 14 deletions
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -1,5 +1,29 @@
|
|||
# CHANGELOG — Set-OPS
|
||||
|
||||
## 2026-09-17 (3) — L'administration entre par un tunnel nominatif, pas par le runner
|
||||
|
||||
Question posee : « le runner ne devrait-il pas etre le rebond SSH des admins ? » Non — il
|
||||
detient la cle de la voute du site et les cles SSH de toutes les machines. Une session humaine
|
||||
compromise deviendrait le plan de controle, et reconstruire le runner couperait l'acces.
|
||||
|
||||
- **Instance WireGuard `admins`** (port 51821, `10.37.29.0/24`), creee par
|
||||
`scripts/vpn_admin.py` — a COTE du tunnel site-a-site vers le site pair, jamais melee a lui.
|
||||
Perimetre strict : un pair attache a une autre instance n'est jamais touche.
|
||||
- **Un pair = une personne ET un appareil.** Le plan ne porte que des cles PUBLIQUES ;
|
||||
`pair-nouveau` tire la paire et affiche la privee une fois. `etat: absent` revoque, et tout
|
||||
pair de notre instance que le plan ne declare plus est RETIRE.
|
||||
- **Le reseau du tunnel est un reseau d'administration, et tout en derive** : pare-feux des
|
||||
machines du site (`resoudre_flux`), contrat vers les locataires (`site_intrants`, donc leurs
|
||||
machines aussi), regles de la frontiere (`devis_opnsense`).
|
||||
- **`devis_opnsense` connait une troisieme voie d'arrivee.** Un CIDR d'administration etait
|
||||
soit « gestion » soit « WAN » ; celui d'un tunnel n'est ni l'un ni l'autre, et sa regle
|
||||
aurait ete posee sur une patte que ce trafic n'emprunte jamais.
|
||||
- **Un seul port ouvert sur l'Internet** : 51821/udp vers l'adresse publique de la frontiere.
|
||||
|
||||
Mesure : `wg1` active en `10.37.29.1` ; `pfctl` porte la regle du port et les 15 acces du
|
||||
tunnel ; les 31 machines (site + deux locataires) acceptent `10.37.29.0/24` en SSH,
|
||||
0 failed. `make prouver` : CONFORME, 82 OK. Document : `docs/acces-administration.md`.
|
||||
|
||||
## 2026-09-17 (2) — La frontiere rejoint la supervision du materiel
|
||||
|
||||
- **Exportateur** : greffon officiel `os-node_exporter` installe et configure par l'API
|
||||
|
|
|
|||
7
Makefile
7
Makefile
|
|
@ -823,6 +823,13 @@ site-intrants: ## Les intrants que le SITE monte expose a ses locataires (derive
|
|||
site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture)
|
||||
python3 scripts/site_intrants.py --verifier
|
||||
|
||||
.PHONY: vpn-admin-plan vpn-admin-appliquer
|
||||
vpn-admin-plan: ## Acces WireGuard des admins : ce que la frontiere porte face au plan (aucune ecriture)
|
||||
python3 scripts/vpn_admin.py plan
|
||||
|
||||
vpn-admin-appliquer: ## Pose l'instance et les pairs declares, retire ceux qui ne le sont plus — CONFIRMER=true
|
||||
CONFIRMER=$(CONFIRMER) python3 scripts/vpn_admin.py appliquer
|
||||
|
||||
.PHONY: dnssec-ds dnssec-verifier
|
||||
dnssec-ds: ## DS a remettre au registraire, calcules depuis la voute du locataire (aucune ecriture)
|
||||
python3 scripts/dnssec.py ds $(ZONE)
|
||||
|
|
|
|||
71
docs/acces-administration.md
Normal file
71
docs/acces-administration.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# L'accès d'administration — un tunnel nominatif
|
||||
|
||||
> **Pour qui :** celui qui administre le site et ses écosystèmes, et celui qui se demande
|
||||
> par où un humain entre, avec quelle clé, et ce qu'il peut atteindre une fois entré.
|
||||
>
|
||||
> Décidé le 2026-09-17, en réponse à une question : *le runner ne devrait-il pas être le
|
||||
> rebond SSH des admins ?*
|
||||
|
||||
## Pourquoi pas le runner
|
||||
|
||||
Le runner du site détient **la clé de la voûte du site** et les clés SSH qui configurent
|
||||
toutes les machines. En faire la porte des humains réunirait deux pouvoirs que tout le reste
|
||||
du dépôt sépare :
|
||||
|
||||
- une session humaine compromise (un agent SSH transféré de trop) deviendrait le **plan de
|
||||
contrôle** — pas seulement un rebond ;
|
||||
- il **ne doit jamais entrer chez un locataire** (charte des responsabilités) ; en faire le
|
||||
passage obligé des admins créerait ce chemin en fait ;
|
||||
- il est **reconstructible par le code**, et c'est sa vertu. Un point d'entrée doit survivre à
|
||||
la reconstruction de ce qu'il sert ;
|
||||
- « qui est entré » et « qu'est-ce qui a été déployé » cesseraient de se raconter séparément.
|
||||
|
||||
## Ce qui a été construit
|
||||
|
||||
Un **second** tunnel WireGuard sur la frontière — l'instance `admins`, port 51821 — à côté du
|
||||
tunnel site-à-site vers le site pair (instance `chezlePro`, port 51820), jamais mêlé à lui.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| déclaré | `SITE-<nom>/plan/10-intrants.yml`, clé `acces_admin_vpn` |
|
||||
| réconcilié | `scripts/vpn_admin.py` (`make vpn-admin-plan`, `make vpn-admin-appliquer`) |
|
||||
| réseau | `10.37.29.0/24`, la frontière en `.1` |
|
||||
| un pair | **une personne ET un appareil** — révoquer l'appareil perdu ne coupe pas les autres |
|
||||
| clés | seule la clé **publique** est au plan ; la privée ne quitte jamais l'appareil |
|
||||
|
||||
**Le réseau du tunnel est un réseau d'administration, et tout en dérive** : les pare-feux des
|
||||
machines du site (`resoudre_flux`), le contrat vers les locataires (`site_intrants`, donc les
|
||||
pare-feux de leurs machines) et les règles de la frontière (`devis_opnsense`). Rien à recopier
|
||||
— une liste recopiée prend toujours du retard sur celle qu'elle suit.
|
||||
|
||||
## Ajouter un appareil
|
||||
|
||||
```bash
|
||||
python3 scripts/vpn_admin.py pair-nouveau --nom prenom-appareil # tire la paire de clés
|
||||
# coller le bloc `pairs:` rendu dans SITE-<nom>/plan/10-intrants.yml
|
||||
make vpn-admin-plan # lire ce qui changerait
|
||||
CONFIRMER=true make vpn-admin-appliquer # poser le pair
|
||||
python3 scripts/vpn_admin.py config --nom prenom-appareil # la config de l'appareil
|
||||
make flux && make deployer-groupe GROUPE=serveur_durci # les pare-feux des machines
|
||||
```
|
||||
|
||||
La clé privée ne s'affiche **qu'une fois**, au moment où elle est tirée : elle appartient à
|
||||
l'appareil. Perdue, on en tire une autre ; l'ancienne se révoque par `etat: absent`.
|
||||
|
||||
## Retirer un accès
|
||||
|
||||
`etat: absent` sur le pair, puis `CONFIRMER=true make vpn-admin-appliquer`. Le script retire
|
||||
aussi tout pair **attaché à notre instance que le plan ne déclare plus** : un accès qui
|
||||
survivrait à la décision de le retirer est exactement ce qu'on ne veut pas.
|
||||
|
||||
## Ce que le tunnel donne, et ce qu'il ne donne pas
|
||||
|
||||
`AllowedIPs` est **dérivé** de la carte : zones du site, fabric, lien de transit et supernets
|
||||
des locataires. Ce que la frontière laisse ensuite passer reste décidé par les flux déclarés —
|
||||
SSH partout, et les consoles d'administration (Grafana, Icinga Web, la forge, le runner).
|
||||
|
||||
**Le seul port ouvert sur l'Internet par cette voie est le 51821/udp**, vers l'adresse publique
|
||||
de la frontière. Tout le reste voyage dans le tunnel.
|
||||
|
||||
**Ce qui n'est pas fait** : l'enregistrement des sessions (un rebond dédié le permettrait, pas
|
||||
un tunnel), et la double authentification — la possession de l'appareil fait foi.
|
||||
|
|
@ -43,26 +43,26 @@
|
|||
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 5 pool(s) Proxmox, 44 VM placee(s), aucun nom ni VMID en collision. |
|
||||
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
||||
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
|
||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 71 scripts expliques et atteignables, 131 cibles make documentees, 69 roles avec README. |
|
||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 72 scripts expliques et atteignables, 133 cibles make documentees, 69 roles avec README. |
|
||||
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (147 cle(s) declaree(s) par l'instance). |
|
||||
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 42 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
|
||||
| P34 | Chaque document declare son lecteur | — | ✅ OK | 48 document(s) declarent leur lecteur (43 genere(s) exempte(s)). |
|
||||
| P34 | Chaque document declare son lecteur | — | ✅ OK | 49 document(s) declarent leur lecteur (43 genere(s) exempte(s)). |
|
||||
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). |
|
||||
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 8 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
|
||||
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
||||
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
|
||||
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 96 page(s) de wiki toutes atteignables. |
|
||||
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
||||
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 67 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 68 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 5 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
|
||||
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 192 regle(s) du site. |
|
||||
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 197 regle(s) du site. |
|
||||
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
|
||||
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
|
||||
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
|
||||
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
|
||||
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 99 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
|
||||
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 101 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
|
||||
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (138 lignes). |
|
||||
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 283 regles `pass`), tous non consignes et tous motives. |
|
||||
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 299 regles `pass`), tous non consignes et tous motives. |
|
||||
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
|
||||
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
|
||||
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ README de rôles). Cette page comble ces deux trous.
|
|||
|---|---|---|
|
||||
| rôles | 69 | `roles/*/` |
|
||||
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
||||
| documents | 44 | `docs/*.md` |
|
||||
| documents | 45 | `docs/*.md` |
|
||||
| pièces d'audit | 48 | `docs/audit/*` |
|
||||
| unités de wiki | 27 | `wiki/*.md` |
|
||||
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
||||
|
|
@ -81,6 +81,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis
|
|||
| Pools Proxmox | un pool par tenant : les noms courts de VM sont **volontairement identiques** d'un tenant à l'autre (même fonction, même nom), et seule la console Proxmox en souffrait | `scripts/devis_proxmox_pools.py` (`make devis-proxmox-pools`) ; nom dérivé de l'`index` ; garde de collision = preuve **P28** | `decisions-architecture.md` D-37 |
|
||||
| Routage | **aucun commutateur ne route** : la frontière est le seul équipement L3 ; les switches commutent | `passerelle` dit qui porte la passerelle, le SVI se dérive du rôle du porteur | `decisions-architecture.md` D-49/50 |
|
||||
| **Devis de service** | LIT le système en marche et le compare à ce que le plan dérive ; n'écrit rien (D-23/D-24 portés du réseau aux services). Le playbook **relève**, Python **compare** | `playbooks/maintenance/devis-*.yml` + `scripts/devis_*.py` ; cible `make <sujet>-plan` | `devis-services.md` |
|
||||
| Accès d'administration | un **tunnel WireGuard nominatif** (instance `admins`) : un pair par personne et par appareil, le réseau du tunnel devient un réseau d'administration dont les pare-feux d'hôte, le contrat vers les locataires et les règles de bordure **dérivent**. Le runner n'est pas le rebond, et le document dit pourquoi | `scripts/vpn_admin.py` (`make vpn-admin-plan`) ; déclaré dans `acces_admin_vpn` (plan du site) | `acces-administration.md` |
|
||||
| Frontière nord/sud | les flux `pair: externe` — **sautés** par le pare-feu d'hôte — sont la politique de bordure | `scripts/devis_opnsense.py` (`make devis-opnsense`) ; garde d'accès admin = preuve **P24** | `frontiere-opnsense.md` |
|
||||
|
||||
> ⚠️ **Deux directions de binding, assumées** : `app→app` côté app (instancier),
|
||||
|
|
|
|||
|
|
@ -468,19 +468,36 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
# frontiere (`10.0.0.0/24` ici) arrive par l'interface de GESTION ; un plan distant,
|
||||
# qui revient par l'adresse publique ou un VPN, arrive par le WAN.
|
||||
reseaux_gestion = reseaux_locaux_frontiere(underlay_mod.charger(), transit_underlay())
|
||||
# LE TUNNEL D'ADMINISTRATION (2026-09-17). Un CIDR d'administration qui tombe dedans
|
||||
# n'arrive NI par la gestion NI par le WAN : il arrive par l'interface WireGuard. Range
|
||||
# ailleurs, sa regle serait posee sur une patte que ce trafic n'emprunte jamais — la
|
||||
# panne la plus silencieuse de cette couche, et la troisieme fois qu'elle se presente.
|
||||
_vpn_admin = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("acces_admin_vpn") or {}
|
||||
_reseau_vpn = str(_vpn_admin.get("reseau") or "")
|
||||
if_vpn = _ou_marqueur(intrants_frontiere(), "opnsense_if_admin_vpn", "wireguard")
|
||||
|
||||
def _par_le_vpn(cidr: str) -> bool:
|
||||
if not _reseau_vpn:
|
||||
return False
|
||||
try:
|
||||
return ipaddress.ip_network(cidr, strict=False).subnet_of(
|
||||
ipaddress.ip_network(_reseau_vpn, strict=False))
|
||||
except ValueError:
|
||||
return False
|
||||
admin_par_tenant: dict[str, list[str]] = {}
|
||||
admin_par_if: dict[str, dict[str, list[str]]] = {}
|
||||
for nom, pfx, n in tenants:
|
||||
etiquette = f"{pfx}{n['index']}"
|
||||
cidrs = admin_de(nom)
|
||||
admin_par_tenant[etiquette] = cidrs
|
||||
locaux = [c for c in cidrs if _porte_par(c, reseaux_gestion)]
|
||||
distants = [c for c in cidrs if c not in locaux]
|
||||
admin_par_if[etiquette] = {"gestion": locaux, "wan": distants}
|
||||
par_vpn = [c for c in cidrs if _par_le_vpn(c)]
|
||||
locaux = [c for c in cidrs if c not in par_vpn and _porte_par(c, reseaux_gestion)]
|
||||
distants = [c for c in cidrs if c not in par_vpn and c not in locaux]
|
||||
admin_par_if[etiquette] = {"gestion": locaux, "wan": distants, "vpn": par_vpn}
|
||||
# UN ALIAS PAR INTERFACE, et le nom le dit. Un alias unique couvrant les deux
|
||||
# cotes forcerait a poser la meme regle deux fois avec des sources qui ne peuvent
|
||||
# pas arriver la : la moitie serait morte, sans que rien ne le signale.
|
||||
for cle, portee in (("gestion", "GESTION"), ("wan", "WAN")):
|
||||
for cle, portee in (("gestion", "GESTION"), ("wan", "WAN"), ("vpn", "VPN")):
|
||||
part = admin_par_if[etiquette][cle]
|
||||
if part:
|
||||
alias[f"SETOPS_ADMIN_{etiquette}_{portee}"] = {
|
||||
|
|
@ -580,6 +597,8 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion))
|
||||
if admin_par_if[etiquette]["wan"]:
|
||||
portees.append((f"SETOPS_ADMIN_{etiquette}_WAN", if_wan))
|
||||
if admin_par_if[etiquette]["vpn"]:
|
||||
portees.append((f"SETOPS_ADMIN_{etiquette}_VPN", if_vpn))
|
||||
elif entrant and "voisins_site" in _pairs(fl):
|
||||
# UN FLUX ENTRE TENANTS DU MEME SITE.
|
||||
#
|
||||
|
|
@ -624,6 +643,8 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
portees = [("any", if_wan)]
|
||||
if admin_par_if[etiquette]["gestion"] and fl.get("poste", True):
|
||||
portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion))
|
||||
if admin_par_if[etiquette]["vpn"] and fl.get("poste", True):
|
||||
portees.append((f"SETOPS_ADMIN_{etiquette}_VPN", if_vpn))
|
||||
elif "voisins_site" in _pairs(fl):
|
||||
# SORTANT VERS UN VOISIN : la destination est NOMMEE, pas « tout sauf
|
||||
# l'interne ». Rendre `!SETOPS_INTERNES` ici aurait ouvert le port vers
|
||||
|
|
@ -756,6 +777,13 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
"contenu": _admin_site,
|
||||
"description": "Plan d'administration — seule source du SSH vers le site",
|
||||
}
|
||||
if _reseau_vpn:
|
||||
alias["SETOPS_ADMIN_SITE_VPN"] = {
|
||||
"type": "network",
|
||||
"contenu": [_reseau_vpn],
|
||||
"description": "Tunnel WireGuard des administrateurs (plan du site, "
|
||||
"`acces_admin_vpn`) — arrive par l'interface WireGuard",
|
||||
}
|
||||
# Le socle vaut aussi pour le site : ses machines sont des Debian de la flotte,
|
||||
# et c'est lui qui porte leur SSH. `site_inventaire.py` les y range.
|
||||
# LE SITE RESOUT CHEZ SA PASSERELLE, ET IL FAUT LE DIRE (2026-08-25).
|
||||
|
|
@ -929,7 +957,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
_portees = [("SETOPS_FABRIC", _if_de(r)) for r in _res_fabric]
|
||||
if not _portees:
|
||||
continue
|
||||
elif _entrant and ("admin" in _pairs_fl or "22" in _ports(_fl)) and _admin_site:
|
||||
elif _entrant and ("admin" in _pairs_fl or "22" in _ports(_fl)) and (_admin_site or _reseau_vpn):
|
||||
# `admin` N'EST PAS QUE LE SSH (2026-09-12). Cette branche testait le
|
||||
# PORT — « si c'est du 22, ça vient de l'administration ». Juste tant
|
||||
# que le seul flux administratif etait SSH.
|
||||
|
|
@ -944,7 +972,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
# Le SSH de gestion arrive par la patte d'ADMINISTRATION de la
|
||||
# frontiere, pas par celle du site : c'est la que le paquet penetre
|
||||
# le pare-feu. Une regle posee ailleurs ne correspondrait jamais.
|
||||
_portees = [("SETOPS_ADMIN_SITE", if_gestion)]
|
||||
# DEUX CHEMINS, DEUX PATTES. Le plan d'administration entre par la
|
||||
# gestion ; le tunnel des admins par l'interface WireGuard. Une seule
|
||||
# regle pour les deux serait juste pour l'un et morte pour l'autre.
|
||||
_portees = ([("SETOPS_ADMIN_SITE", if_gestion)] if _admin_site else [])
|
||||
if _reseau_vpn:
|
||||
_portees.append(("SETOPS_ADMIN_SITE_VPN", if_vpn))
|
||||
elif _entrant and (set(_pairs_fl) & set(_roles_site)):
|
||||
# UN ROLE DU SITE QUI EN APPELLE UN AUTRE — ET CE N'EST PAS
|
||||
# « L'EXTERIEUR » (mesure du 2026-09-02).
|
||||
|
|
@ -1386,6 +1419,36 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
"raison": _fl_f.get("raison", ""),
|
||||
})
|
||||
|
||||
# --- LA PORTE DU TUNNEL D'ADMINISTRATION (2026-09-17) --------------------------
|
||||
#
|
||||
# Le tunnel ne peut pas s'etablir si son port n'est pas ouvert sur le WAN. C'est le SEUL
|
||||
# flux entrant depuis l'Internet que cette voie demande — tout le reste voyage dedans.
|
||||
#
|
||||
# LA DESTINATION EST L'ADRESSE PUBLIQUE, PAS `any`. WireGuard ne repond qu'a une poignee
|
||||
# de main correctement signee : ouvrir large ne l'affaiblirait pas, mais une frontiere
|
||||
# dont la lecture dit « ce port est ouvert vers le boitier » est plus juste qu'une qui
|
||||
# dit « vers n'importe quoi ».
|
||||
if _reseau_vpn and _vpn_admin.get("port") and str(intrants.get("opnsense_wan_ip") or ""):
|
||||
alias["SETOPS_FRONTIERE_WAN"] = {
|
||||
"type": "host",
|
||||
"contenu": [str(intrants["opnsense_wan_ip"])],
|
||||
"description": "Adresse publique de la frontiere — porte du tunnel d'administration",
|
||||
}
|
||||
regles.append({
|
||||
"sens": "in",
|
||||
"interface": if_wan,
|
||||
"protocole": "udp",
|
||||
"source": "any",
|
||||
"destination": "SETOPS_FRONTIERE_WAN",
|
||||
"ports": [str(_vpn_admin["port"])],
|
||||
"chiffrement": "wireguard",
|
||||
"role": "acces_admin_vpn",
|
||||
"tenant": "",
|
||||
"raison": ("Poignee de main WireGuard des administrateurs du site. Le reste de "
|
||||
"l'administration voyage DANS le tunnel : c'est le seul port que cette "
|
||||
"voie demande sur l'Internet."),
|
||||
})
|
||||
|
||||
# Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois :
|
||||
# elle sert au prochain saut des routes ET au commentaire qui l'explique.
|
||||
_ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit)
|
||||
|
|
|
|||
|
|
@ -616,6 +616,12 @@ def _sources_admin_ssh_site() -> list[str]:
|
|||
# deploie. Une passerelle seule ne suffirait pas a le laisser entrer.
|
||||
if str(r.get("nom", "")) == "site-pilotage" and r.get("sous_reseau"):
|
||||
srcs.append(str(r["sous_reseau"]))
|
||||
# LE TUNNEL D'ADMINISTRATION, DERIVE (2026-09-17). Le declarer une seconde fois dans
|
||||
# `nftables_admin_ssh` serait une liste qui en suit une autre : le jour ou le reseau du
|
||||
# tunnel change, la moitie des machines refuserait l'exploitant sans rien dire.
|
||||
_vpn = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("acces_admin_vpn") or {}
|
||||
if _vpn.get("reseau"):
|
||||
srcs.append(str(_vpn["reseau"]))
|
||||
src = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("nftables_admin_ssh")
|
||||
if src:
|
||||
srcs += [str(s) for s in src] if isinstance(src, list) else [str(src)]
|
||||
|
|
|
|||
|
|
@ -116,7 +116,11 @@ def contrat() -> dict:
|
|||
# SOUS QUEL NOM LE SITE FAIT AUTORITE — les NS et le SOA de chaque zone publique.
|
||||
"dns_public_nom": str(intrants.get("dns_public_nom") or ""),
|
||||
# QUI A LE DROIT D'ENTRER EN SSH
|
||||
"nftables_admin_ssh": [gestion] if gestion else [],
|
||||
# QUI A LE DROIT D'ENTRER EN SSH : le plan d'administration du site ET le tunnel
|
||||
# WireGuard des admins, derive de sa declaration (jamais recopie).
|
||||
"nftables_admin_ssh": ([gestion] if gestion else [])
|
||||
+ ([str((intrants.get("acces_admin_vpn") or {}).get("reseau"))]
|
||||
if (intrants.get("acces_admin_vpn") or {}).get("reseau") else []),
|
||||
# PAR OU SORTIR, ET A QUI DEMANDER L'HEURE
|
||||
"passerelle_sortie": str(transit.get("passerelle_sortie") or ""),
|
||||
}
|
||||
|
|
|
|||
321
scripts/vpn_admin.py
Normal file
321
scripts/vpn_admin.py
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
#!/usr/bin/env python3
|
||||
"""L'acces d'administration par WireGuard — le plan declare, la frontiere suit.
|
||||
|
||||
POURQUOI CETTE VOIE (2026-09-17). L'exploitant entrait par les pattes de la frontiere, une
|
||||
regle par zone. La question posee etait : « le runner ne devrait-il pas etre le rebond ? »
|
||||
Non — il detient la cle de la voute du site et les cles SSH de toutes les machines : une
|
||||
session humaine compromise deviendrait le plan de controle, et reconstruire le runner
|
||||
couperait l'acces. Un tunnel nominatif donne la meme porte unique sans reunir ces pouvoirs.
|
||||
|
||||
CE QUE CE SCRIPT RECONCILIE, et rien d'autre :
|
||||
- UNE instance WireGuard (celle que le plan nomme), distincte du tunnel site-a-site ;
|
||||
- les PAIRS qui lui sont attaches : un par personne ET par appareil.
|
||||
|
||||
PERIMETRE STRICT : un pair attache a une AUTRE instance n'est jamais touche — le tunnel vers
|
||||
le site pair vit a cote et ne doit rien craindre d'ici.
|
||||
|
||||
AUCUNE CLE PRIVEE D'APPAREIL N'ENTRE DANS LE DEPOT. Le plan ne porte que des cles PUBLIQUES.
|
||||
`pair-nouveau` tire une paire, affiche la privee UNE fois (a coller dans l'appareil) et rend
|
||||
la ligne de plan a ajouter.
|
||||
|
||||
Usage :
|
||||
python3 scripts/vpn_admin.py plan # aucune ecriture
|
||||
CONFIRMER=true python3 scripts/vpn_admin.py appliquer
|
||||
python3 scripts/vpn_admin.py pair-nouveau --nom daniel-portable
|
||||
python3 scripts/vpn_admin.py config --nom daniel-portable
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import ipaddress
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
|
||||
import appliquer_opnsense as appl # noqa: E402
|
||||
import devis_opnsense as devis_mod # noqa: E402
|
||||
import underlay as underlay_mod # noqa: E402
|
||||
|
||||
|
||||
def plan_vpn() -> dict:
|
||||
d = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("acces_admin_vpn") or {}
|
||||
if not d.get("reseau") or not d.get("port"):
|
||||
raise SystemExit("REFUS : le plan du site ne declare pas `acces_admin_vpn` "
|
||||
"(reseau, port). Rien a reconcilier.")
|
||||
return d
|
||||
|
||||
|
||||
def api_frontiere() -> appl.Frontiere:
|
||||
base = devis_mod.depot_hebergeur()
|
||||
if base is None:
|
||||
raise SystemExit("Aucun underlay ne designe d'hebergeur : pas de frontiere a piloter.")
|
||||
intr = devis_mod.intrants_frontiere()
|
||||
v = appl._voute(base)
|
||||
return appl.Frontiere(str(intr.get("opnsense_api_url") or ""),
|
||||
v["vault_opnsense_api_key"], v["vault_opnsense_api_secret"],
|
||||
bool(intr.get("opnsense_api_verifier_certs")))
|
||||
|
||||
|
||||
def _table(rep: dict, *chemin: str) -> dict:
|
||||
"""OPNsense emboite ses listes : {'server': {'servers': {'server': {uuid: {...}}}}}."""
|
||||
noeud = rep
|
||||
for c in chemin:
|
||||
noeud = (noeud or {}).get(c) or {}
|
||||
return noeud if isinstance(noeud, dict) else {}
|
||||
|
||||
|
||||
def _choisi(champ) -> list[str]:
|
||||
"""Un champ a choix d'OPNsense : rend les valeurs selectionnees."""
|
||||
if isinstance(champ, dict):
|
||||
return [k for k, v in champ.items() if isinstance(v, dict) and v.get("selected")]
|
||||
return [str(champ)] if champ else []
|
||||
|
||||
|
||||
def etat(api: appl.Frontiere) -> tuple[dict, dict]:
|
||||
"""(serveurs par uuid, pairs par uuid) tels que la frontiere les porte."""
|
||||
srv = _table(appl._lire(api, "/api/wireguard/server/get"), "server", "servers", "server")
|
||||
pairs = _table(appl._lire(api, "/api/wireguard/client/get"), "client", "clients", "client")
|
||||
return srv, pairs
|
||||
|
||||
|
||||
def reseaux_joignables() -> list[str]:
|
||||
"""Ce que le tunnel doit router : les zones du site, la fabric, les supernets locataires.
|
||||
|
||||
DERIVE, JAMAIS ECRIT. Une liste recopiee ici prendrait du retard sur la carte a la
|
||||
premiere zone ajoutee — et l'administrateur decouvrirait le trou au pire moment.
|
||||
"""
|
||||
u = underlay_mod.charger() or {}
|
||||
out = {str(r["sous_reseau"]) for r in underlay_mod.reseaux(u)
|
||||
if r.get("sous_reseau")
|
||||
and (str(r.get("nom", "")) in ("management", "grappe-controle", "transit-frontiere")
|
||||
or str(r.get("nom", "")).startswith("site-"))}
|
||||
out |= {str(s) for s in _supernets_locataires()}
|
||||
return sorted(out, key=lambda c: ipaddress.ip_network(c, strict=False))
|
||||
|
||||
|
||||
def _supernets_locataires() -> list[str]:
|
||||
"""Les supernets des locataires heberges — decouverts, jamais listes."""
|
||||
import devis_reseau
|
||||
out = []
|
||||
for nom_depot, _prefixe, _n in devis_reseau.decouvrir_du_site():
|
||||
try:
|
||||
nomencl = (RACINE.parent / nom_depot / "plan" / "nomenclature.yml")
|
||||
import yaml
|
||||
d = yaml.safe_load(nomencl.read_text(encoding="utf-8")) or {}
|
||||
idx = d.get("index")
|
||||
if idx is not None:
|
||||
out.append(f"10.{int(idx)}.0.0/16")
|
||||
except (OSError, ValueError, TypeError):
|
||||
continue
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def rapprocher(api: appl.Frontiere, vpn: dict) -> dict:
|
||||
srv, pairs = etat(api)
|
||||
nom = str(vpn.get("nom") or "admins")
|
||||
uuid_srv = next((u for u, s in srv.items() if str(s.get("name")) == nom), None)
|
||||
declares = {str(p["nom"]): p for p in (vpn.get("pairs") or [])
|
||||
if str(p.get("etat", "present")) == "present"}
|
||||
retires = {str(p["nom"]) for p in (vpn.get("pairs") or [])
|
||||
if str(p.get("etat", "present")) == "absent"}
|
||||
|
||||
poses = {}
|
||||
if uuid_srv:
|
||||
for u, p in pairs.items():
|
||||
if uuid_srv in _choisi(p.get("servers")):
|
||||
poses[str(p.get("name"))] = (u, p)
|
||||
|
||||
def different(nom_p: str) -> bool:
|
||||
_u, p = poses[nom_p]
|
||||
d = declares[nom_p]
|
||||
return (str(p.get("pubkey")) != str(d.get("cle_publique"))
|
||||
or _choisi(p.get("tunneladdress")) != [str(d.get("adresse"))])
|
||||
|
||||
return {
|
||||
"nom": nom,
|
||||
"uuid_serveur": uuid_srv,
|
||||
"serveur_a_creer": uuid_srv is None,
|
||||
"serveur": srv.get(uuid_srv or "", {}),
|
||||
"pairs_creer": {n: d for n, d in declares.items() if n not in poses},
|
||||
"pairs_majer": {n: d for n, d in declares.items() if n in poses and different(n)},
|
||||
"pairs_garder": {n for n in declares if n in poses and not different(n)},
|
||||
# UN PAIR ATTACHE A NOTRE INSTANCE QUE LE PLAN NE DECLARE PLUS : c'est un acces qui
|
||||
# survit a la decision de le retirer. On le nomme, et `appliquer` le retire.
|
||||
"pairs_retirer": {n: u for n, (u, _p) in poses.items()
|
||||
if n not in declares or n in retires},
|
||||
}
|
||||
|
||||
|
||||
def afficher(p: dict, vpn: dict) -> bool:
|
||||
if p["serveur_a_creer"]:
|
||||
print(f" + instance WireGuard {p['nom']} (port {vpn['port']}, "
|
||||
f"{vpn['adresse_frontiere']})")
|
||||
else:
|
||||
s = p["serveur"]
|
||||
print(f" = instance WireGuard {p['nom']} — port {s.get('port')}, "
|
||||
f"cle publique {str(s.get('pubkey'))[:12]}…")
|
||||
for n, d in sorted(p["pairs_creer"].items()):
|
||||
print(f" + pair {n:<24} {d.get('adresse')}")
|
||||
for n, d in sorted(p["pairs_majer"].items()):
|
||||
print(f" ~ pair MODIFIE {n:<24} {d.get('adresse')}")
|
||||
for n in sorted(p["pairs_retirer"]):
|
||||
print(f" - pair RETIRE {n}")
|
||||
print(f"\n a creer : {len(p['pairs_creer']) + (1 if p['serveur_a_creer'] else 0)}"
|
||||
f" | a retirer : {len(p['pairs_retirer'])}"
|
||||
f" | inchange : {len(p['pairs_garder'])}")
|
||||
return bool(p["serveur_a_creer"] or p["pairs_creer"] or p["pairs_majer"] or p["pairs_retirer"])
|
||||
|
||||
|
||||
def _cle_privee() -> tuple[str, str]:
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
k = X25519PrivateKey.generate()
|
||||
priv = k.private_bytes(encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PrivateFormat.Raw,
|
||||
encryption_algorithm=serialization.NoEncryption())
|
||||
pub = k.public_key().public_bytes(encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw)
|
||||
return base64.b64encode(priv).decode(), base64.b64encode(pub).decode()
|
||||
|
||||
|
||||
def appliquer(api: appl.Frontiere, p: dict, vpn: dict) -> int:
|
||||
echecs = 0
|
||||
|
||||
def fait(rep, quoi):
|
||||
nonlocal echecs
|
||||
if rep.get("result") in ("saved", "deleted") or rep.get("status") == "ok":
|
||||
return rep
|
||||
echecs += 1
|
||||
print(f" ! ECHEC {quoi} : {str(rep)[:200]}")
|
||||
return {}
|
||||
|
||||
uuid_srv = p["uuid_serveur"]
|
||||
if p["serveur_a_creer"]:
|
||||
# LA CLE DU SERVEUR NAIT SUR LE BOITIER ET Y RESTE. Elle n'ouvre rien toute seule :
|
||||
# c'est la cle de la FRONTIERE, pas celle d'un administrateur. Le jour ou le boitier
|
||||
# est refait, les appareils recoivent la nouvelle cle publique — et c'est tout.
|
||||
priv, pub = _cle_privee()
|
||||
rep = fait(api("/api/wireguard/server/add_server", {"server": {
|
||||
"enabled": "1", "name": p["nom"], "instance": str(vpn.get("instance", 1)),
|
||||
"pubkey": pub, "privkey": priv, "port": str(vpn["port"]),
|
||||
"mtu": str(vpn.get("mtu", 1412)),
|
||||
"tunneladdress": str(vpn["adresse_frontiere"]),
|
||||
"disableroutes": "0", "peers": "",
|
||||
}}), f"instance {p['nom']}")
|
||||
uuid_srv = rep.get("uuid") or None
|
||||
if uuid_srv is None:
|
||||
srv, _ = etat(api)
|
||||
uuid_srv = next((u for u, s in srv.items() if str(s.get("name")) == p["nom"]), None)
|
||||
|
||||
for nom_p, d in sorted({**p["pairs_creer"], **p["pairs_majer"]}.items()):
|
||||
corps = {"client": {
|
||||
"enabled": "1", "name": nom_p, "pubkey": str(d["cle_publique"]), "psk": "",
|
||||
"tunneladdress": str(d["adresse"]), "keepalive": "25",
|
||||
"servers": uuid_srv or "",
|
||||
}}
|
||||
if nom_p in p["pairs_majer"]:
|
||||
fait(api(f"/api/wireguard/client/set_client/{_uuid_pair(api, nom_p)}", corps),
|
||||
f"pair {nom_p}")
|
||||
else:
|
||||
fait(api("/api/wireguard/client/add_client", corps), f"pair {nom_p}")
|
||||
|
||||
for nom_p, u in sorted(p["pairs_retirer"].items()):
|
||||
fait(api(f"/api/wireguard/client/del_client/{u}", {}), f"retrait du pair {nom_p}")
|
||||
|
||||
if echecs:
|
||||
print(f"\n {echecs} echec(s) — RIEN N'EST RECHARGE : le tunnel garde son etat "
|
||||
f"precedent. Corriger, puis rejouer.")
|
||||
return 1
|
||||
print(" service :", api("/api/wireguard/service/reconfigure", {}).get("status", "?"))
|
||||
return 0
|
||||
|
||||
|
||||
def _uuid_pair(api: appl.Frontiere, nom_p: str) -> str:
|
||||
_srv, pairs = etat(api)
|
||||
return next((u for u, x in pairs.items() if str(x.get("name")) == nom_p), "")
|
||||
|
||||
|
||||
def cmd_plan(args) -> int:
|
||||
vpn = plan_vpn()
|
||||
api = api_frontiere()
|
||||
p = rapprocher(api, vpn)
|
||||
print(f"Acces d'administration WireGuard — instance « {p['nom'] } », "
|
||||
f"reseau {vpn['reseau']}, port {vpn['port']}\n")
|
||||
a_faire = afficher(p, vpn)
|
||||
if not a_faire:
|
||||
print("\n Rien a faire.")
|
||||
return 0
|
||||
if os.environ.get("CONFIRMER") != "true":
|
||||
print("\n PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.")
|
||||
return 0
|
||||
return appliquer(api, p, vpn)
|
||||
|
||||
|
||||
def cmd_pair(args) -> int:
|
||||
vpn = plan_vpn()
|
||||
priv, pub = _cle_privee()
|
||||
reseau = ipaddress.ip_network(str(vpn["reseau"]), strict=False)
|
||||
prises = {str(p.get("adresse", "")).split("/")[0] for p in (vpn.get("pairs") or [])}
|
||||
prises.add(str(vpn["adresse_frontiere"]).split("/")[0])
|
||||
libre = next((str(h) for h in reseau.hosts() if str(h) not in prises), None)
|
||||
print(f"# Pair « {args.nom} » — a AJOUTER au plan du site (plan/10-intrants.yml) :\n")
|
||||
print(" pairs:")
|
||||
print(f" - nom: {args.nom}")
|
||||
print(f" cle_publique: \"{pub}\"")
|
||||
print(f" adresse: {libre}/32")
|
||||
print(" etat: present\n")
|
||||
print("# La cle PRIVEE ci-dessous ne s'affiche qu'une fois : elle appartient a l'appareil,")
|
||||
print("# elle n'entre ni dans le depot ni dans une voute.\n")
|
||||
print(f"PrivateKey = {priv}\n")
|
||||
print(f"# Puis : python3 scripts/vpn_admin.py config --nom {args.nom}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_config(args) -> int:
|
||||
vpn = plan_vpn()
|
||||
d = next((p for p in (vpn.get("pairs") or []) if str(p.get("nom")) == args.nom), None)
|
||||
if d is None:
|
||||
raise SystemExit(f"REFUS : aucun pair « {args.nom} » au plan du site.")
|
||||
api = api_frontiere()
|
||||
srv, _pairs = etat(api)
|
||||
nom = str(vpn.get("nom") or "admins")
|
||||
s = next((x for x in srv.values() if str(x.get("name")) == nom), None)
|
||||
if s is None:
|
||||
raise SystemExit(f"REFUS : l'instance « {nom} » n'existe pas encore sur la frontiere "
|
||||
f"(CONFIRMER=true python3 scripts/vpn_admin.py appliquer).")
|
||||
intr = devis_mod.intrants_frontiere()
|
||||
print(f"[Interface]\n# PrivateKey : celle affichee par `pair-nouveau` pour {args.nom}\n"
|
||||
f"PrivateKey = <cle privee de l'appareil>\n"
|
||||
f"Address = {d['adresse']}\n"
|
||||
f"MTU = {vpn.get('mtu', 1412)}\n\n"
|
||||
f"[Peer]\n"
|
||||
f"PublicKey = {s.get('pubkey')}\n"
|
||||
f"Endpoint = {intr.get('opnsense_wan_ip')}:{vpn['port']}\n"
|
||||
f"AllowedIPs = {', '.join(reseaux_joignables())}\n"
|
||||
f"PersistentKeepalive = 25")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
sous = ap.add_subparsers(dest="commande", required=True)
|
||||
for nom_cmd in ("plan", "appliquer"):
|
||||
p = sous.add_parser(nom_cmd, help="rapproche le plan et la frontiere "
|
||||
"(ecriture seulement avec CONFIRMER=true)")
|
||||
p.set_defaults(fn=cmd_plan)
|
||||
p = sous.add_parser("pair-nouveau", help="tire une paire de cles et rend la ligne de plan")
|
||||
p.add_argument("--nom", required=True, help="personne-appareil, ex. daniel-portable")
|
||||
p.set_defaults(fn=cmd_pair)
|
||||
p = sous.add_parser("config", help="la configuration a coller dans l'appareil")
|
||||
p.add_argument("--nom", required=True)
|
||||
p.set_defaults(fn=cmd_config)
|
||||
args = ap.parse_args()
|
||||
return args.fn(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue