D-71 : PKI et DNS debout avant tout, et la zone inverse manquait
Contrainte de l'exploitant apres avoir vu backup-01 et collab-01 crees avant l'AC et le DNS. Ma premiere reponse etait incomplete : un clone est bien inerte, mais un echec a la 12e VM coute 40 minutes sans rien deployer, et le journal donne l'impression que le moteur ignore ses couches. Mesure avant de coder : - enregistrements A : DEJA derives du plan (zone generee depuis hotes_actifs) - zone inverse / PTR : n'existe NULLE PART, aucun role ne touche in-addr.arpa - ordre d'amorcage : aucun, deployer-tout est par couches Le premier point a reduit le travail de moitie — j'allais ecrire un enrolement DNS par hote alors que la zone directe etait deja correcte. Zone inverse derivee du supernet (27.10.in-addr.arpa), PTR issus de la MEME source que les A : pas d'endroit ou elles puissent diverger. Vide si le supernet n'est pas un /16. _amorcer-socle monte l'AC puis le DNS completement avant deployer-tout ; les deux derives de applications.<app>.hote, dans un ordre causal et non alphabetique. Deux exceptions assumees : l'AC s'auto-signe, le DNS pose son propre enregistrement. P10 a attrape un handler que je venais d'inventer (Recharger PowerDNS au lieu de Validate and reload PowerDNS) avant tout deploiement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
872d590031
commit
1295eeaf4e
11 changed files with 227 additions and 2 deletions
42
CHANGELOG.md
42
CHANGELOG.md
|
|
@ -1,5 +1,47 @@
|
|||
# CHANGELOG — Set-OPS
|
||||
|
||||
## 2026-08-08 — D-71 : PKI et DNS debout avant tout le reste, et la zone inverse
|
||||
|
||||
Contrainte posée par l'exploitant en voyant `backup-01` et `collab-01` créés avant l'AC et
|
||||
le DNS : **une PKI et un DNS fonctionnels avant toute chose** ; puis par VM, socle →
|
||||
enrôlement PKI → enregistrement DNS (A et PTR).
|
||||
|
||||
Ma première réponse était incomplète. J'avais expliqué qu'un clone est inerte et que
|
||||
`site.yml` respecte bien les couches — c'est exact, mais ça esquivait deux points justes :
|
||||
un échec de création à la douzième VM coûte quarante minutes sans rien déployer, et un
|
||||
journal qui montre `backup-01` en tête donne l'impression que le moteur ignore ses propres
|
||||
couches.
|
||||
|
||||
**Ce qui manquait vraiment, mesuré avant de coder :**
|
||||
|
||||
```
|
||||
enregistrements A → DEJA derives du plan (zone generee depuis `hotes_actifs`)
|
||||
zone inverse / PTR → n'existe NULLE PART — aucun role ne touche `in-addr.arpa`
|
||||
ordre d'amorcage → aucun : `deployer-tout` est par couches, pas par hote
|
||||
```
|
||||
|
||||
Le premier point a réduit le travail de moitié : je m'apprêtais à écrire un enrôlement DNS
|
||||
par hôte alors que la zone directe se dérivait déjà correctement.
|
||||
|
||||
**La zone inverse** est dérivée du supernet, comme le reste : un `/16` `10.(10+index).0.0`
|
||||
donne `(10+index).10.in-addr.arpa` — `27.10.in-addr.arpa` ici. Les PTR viennent de la
|
||||
**même source** que les A (`hotes_actifs` et son `ansible_host`) : deux zones alimentées
|
||||
par une seule vérité, donc pas d'endroit où elles puissent diverger. Vide si le supernet
|
||||
n'est pas un `/16` — mieux vaut pas de zone inverse qu'une zone fausse.
|
||||
|
||||
**`_amorcer-socle`** monte l'AC puis le DNS **complètement**, hôte par hôte, avant
|
||||
`deployer-tout`. Les deux se dérivent de `applications.<app>.hote` ; l'ordre entre eux
|
||||
n'est pas alphabétique mais causal — le DNS a besoin d'un certificat, l'autorité n'a besoin
|
||||
de personne.
|
||||
|
||||
**Deux exceptions structurelles, nommées plutôt que découvertes à l'exécution** : l'AC
|
||||
s'auto-signe, et le DNS pose son propre enregistrement. La règle « socle → PKI → DNS » ne
|
||||
peut pas s'appliquer à ceux qui la rendent possible.
|
||||
|
||||
Au passage, le harnais a attrapé une faute que je venais d'introduire : j'avais inventé un
|
||||
handler `Recharger PowerDNS` qui n'existe pas — le rôle écoute `Validate and reload
|
||||
PowerDNS`. P10 l'a nommée avant tout déploiement.
|
||||
|
||||
## 2026-08-08 — Une question de l'exploitant trouve un trou dans P31, une heure après
|
||||
|
||||
« Pourquoi pas `make myDay` ? » — la cible existe, c'est un alias strict de
|
||||
|
|
|
|||
18
Makefile
18
Makefile
|
|
@ -594,11 +594,29 @@ reconstruire: _instance-requise ## Reconstruit un ecosysteme depuis zero : VM pu
|
|||
fi; \
|
||||
$(MAKE) flotte-creer CONFIRMER=true; \
|
||||
$(MAKE) _attendre-flotte; \
|
||||
$(MAKE) _amorcer-socle; \
|
||||
$(MAKE) deployer-tout CONFIRMER=true
|
||||
|
||||
# « Go ahead, make my day. » — LE bouton rouge : alias de reconstruire (Clint Eastwood).
|
||||
# Cree toutes les VM puis deploie tout, en une commande. Garde CONFIRMER=true.
|
||||
.PHONY: myDay
|
||||
# « Une PKI et un DNS fonctionnels avant toute chose » — contrainte d'architecture du
|
||||
# 2026-08-08. `deployer-tout` deroule par COUCHES : tous les hotes recoivent le socle,
|
||||
# puis tous recoivent la couche suivante. C'est correct, mais ca laisse chaque VM
|
||||
# reclamer un certificat a une autorite qui n'est pas encore debout — et l'echec se lit
|
||||
# alors comme un defaut du role, pas comme un defaut d'ordre.
|
||||
#
|
||||
# On monte donc l'AC puis le DNS COMPLETEMENT d'abord, hote par hote (`make deployer`
|
||||
# ordonne deja les groupes d'un hote par couches). Les deux se DERIVENT du plan :
|
||||
# deplacer l'autorite deplace l'amorcage avec elle.
|
||||
_amorcer-socle: ansible-runtime _instance-requise
|
||||
@set -e; \
|
||||
for h in $$(python3 scripts/socle_amorcage.py); do \
|
||||
printf '\n=== Amorcage du socle : %s ===\n' "$$h"; \
|
||||
$(MAKE) deployer HOTE="$$h"; \
|
||||
done; \
|
||||
printf '\nPKI et DNS debout : le reste de la flotte peut monter.\n'
|
||||
|
||||
myDay: reconstruire ## Alias strict de `reconstruire` (meme cible, memes gardes)
|
||||
|
||||
deployer-groupe: ## Deploie un seul groupe sur toute la flotte — GROUPE=<groupe>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. |
|
||||
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, 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, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
|
||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 36 scripts expliques et atteignables, 87 cibles make documentees, 54 roles avec README. |
|
||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 37 scripts expliques et atteignables, 87 cibles make documentees, 54 roles avec README. |
|
||||
|
||||
## Couverture des affirmations ✅ du registre
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ code + les README de rôles). Cette page comble ces deux trous.
|
|||
| **Ordre de déploiement** | `docs/couches-deploiement.yml` (couches) + `docs/dependances-groupes.yml` (graphe) → `playbooks/site.yml` (**généré**, `make site`) |
|
||||
| **Conformité du déployé** | `docs/devis-services.md` — les **cinq devis de service** (`make identite-plan`, `certificats-plan`, `expositions-plan`, `postgresql-plan`, `courriel-plan`). Répondent à ce que `make prouver` ne demande jamais : *ce qui tourne correspond-il à ce qui est déclaré ?* |
|
||||
| **Preuve / recette** | `docs/audit/affirmations.md` (registre), `make prouver` → `docs/audit/preuve-<date>.md` — **statique** : lit le dépôt, aucun appel réseau ; la conformité du déployé est l'affaire des devis de service (ligne au-dessus), `docs/audit/plan-de-recette.md` (**généré** du wiki), `docs/audit/protocole-operateur-independant.md` |
|
||||
| **Décisions d'architecture** | `docs/decisions-architecture.md` — **67 décisions en vigueur** (D-01 → D-70, 3 renversées), pourquoi, où lire le détail, et ce qui les garde ; plus les **décisions renversées** et leur cause |
|
||||
| **Décisions d'architecture** | `docs/decisions-architecture.md` — **68 décisions en vigueur** (D-01 → D-71, 3 renversées), pourquoi, où lire le détail, et ce qui les garde ; plus les **décisions renversées** et leur cause |
|
||||
| **SDN / routage** | `docs/sdn-evpn.md` — décision du 2026-08-02 : le routage inter-zone passe des commutateurs aux hyperviseurs (zones EVPN = VRF). **Non éprouvé** : spike avant génération |
|
||||
| **Migration de tenant** | `docs/migration-tenant.md` — recette en 8 étapes, machine à états, gardes ; le receveur se construit **avant** tout gel |
|
||||
| **Exploitation courante** | `docs/runbooks-exploitation.md`, `docs/intrants-communs.md`, `docs/intrants-base-gui-conception.md`, `docs/theme-forgejo-hors-flotte.md` |
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ sont les seules vérifiables.
|
|||
| **D-68** | On **écrit, puis on relit et on compare** — quelle que soit l'interface ; on choisit celle dont le chemin de **lecture** parle le même langage que le chemin d'**écriture** | « toujours préférer l'API » n'aurait prédit aucune des pannes du 2026-08-08 : sur six familles de défauts, deux venaient d'un CLI, une d'un module Ansible (`ldap_entry` crée sans jamais modifier), une d'un `grep` de fichier, une de la précédence Ansible, une de mon comparateur. Le facteur commun est d'avoir écrit sans relire. Et la plupart de la flotte n'a **pas** d'API — Postfix, Dovecot, nginx, slapd, nftables : `postconf -h` / `postconf -e` sont symétriques, c'est tout ce qu'on demande | `devis-services.md` | les 5 devis |
|
||||
| **D-69** | Sur Keycloak : **l'API pour toute map ou collection** (`smtpServer`, `attributes`, `config`), `kcadm` pour les scalaires et les créations | `kcadm -s` sur une map accepte la commande, **sort en succès et n'écrit rien** — mesuré deux fois le 2026-08-08 (`smtpServer` resté vide après deux déploiements verts, puis `post.logout.redirect.uris`). Le CLI reste préféré ailleurs : c'est le vocabulaire de la documentation du produit, donc lisible sans IA | `roles/serveur_keycloak/tasks/` | `make identite-plan` |
|
||||
| **D-70** | La documentation **dit et explique tout ce que le dépôt fait** — et l'exigence est **outillée**, pas seulement énoncée | une exigence qu'on n'outille pas pourrit en silence : la carte annonçait « 28 décisions » quand il y en avait 66, et disait les accès « non construits » alors qu'ils tournaient en production. **P31** garde le couvert — chaque script s'explique et reste atteignable, chaque cible `make` porte son aide (sauf les internes préfixées `_`), chaque rôle a son README. Elle ne garde **pas** la qualité du « pourquoi » : ça se juge en revue, et ça vit dans `CHANGELOG.md` et ici | `devis-services.md`, `CHANGELOG.md` | **P31** |
|
||||
| **D-71** | **Une PKI et un DNS fonctionnels avant toute chose** ; puis, par VM : socle → enrôlement PKI → enregistrement DNS (A **et** PTR) | `deployer-tout` déroule par COUCHES — correct, mais chaque VM réclame alors un certificat à une autorité pas encore debout, et l'échec se lit comme un défaut du rôle et non d'ordre. Les deux hôtes d'amorçage se **dérivent** de `applications.<step_ca\|powerdns>.hote` : déplacer l'autorité déplace l'amorçage. **Deux exceptions structurelles assumées** — l'AC s'auto-signe, le DNS pose son propre enregistrement | `Makefile` `_amorcer-socle`, `scripts/socle_amorcage.py` | — |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
serveur_powerdns_packages:
|
||||
- pdns-server
|
||||
- pdns-backend-bind
|
||||
- bind9-utils
|
||||
- dnsutils
|
||||
|
||||
serveur_powerdns_service_name: "pdns"
|
||||
serveur_powerdns_zone_directory: "/etc/powerdns/zones"
|
||||
serveur_powerdns_bind_config: "/etc/powerdns/bindbackend.conf"
|
||||
serveur_powerdns_zone: "{{ domaine_interne }}"
|
||||
serveur_powerdns_nameserver: "ns1"
|
||||
serveur_powerdns_contact: "hostmaster.{{ domaine_interne }}"
|
||||
serveur_powerdns_ttl: 3600
|
||||
serveur_powerdns_serial: 2026062201
|
||||
serveur_powerdns_soa_refresh: 3600
|
||||
serveur_powerdns_soa_retry: 900
|
||||
serveur_powerdns_soa_expire: 1209600
|
||||
serveur_powerdns_soa_minimum: 3600
|
||||
# L'adresse du tenant SEULEMENT, pas `0.0.0.0` : lier toutes les interfaces occupe aussi
|
||||
# `127.0.0.1:53`, ou un resolveur recursif local voudrait s'installer. Ce n'est pas un
|
||||
# probleme theorique — c'est la raison pour laquelle `client_unbound` exempte cet hote.
|
||||
# PowerDNS reste autoritatif pur : il repond pour la zone souveraine et REFUSE le reste.
|
||||
serveur_powerdns_listen_addresses:
|
||||
- "{{ ansible_host | default('0.0.0.0') }}"
|
||||
serveur_powerdns_allow_axfr_ips: []
|
||||
|
||||
# Enregistrements additionnels geres explicitement.
|
||||
serveur_powerdns_records:
|
||||
- name: "dns"
|
||||
type: "CNAME"
|
||||
value: "ns1.{{ serveur_powerdns_zone }}."
|
||||
|
||||
# Groupes dont les hotes actifs avec ansible_host seront ajoutes a la zone.
|
||||
serveur_powerdns_inventory_groups:
|
||||
- hotes_actifs
|
||||
|
||||
# Publier des A pour les FQDN d'exposition (champ 'expose' des applications) vers l'edge
|
||||
# qui les sert (domaines.edge). Ferme la boucle : declarer 'expose' -> vhost + cert + DNS.
|
||||
serveur_powerdns_publier_expositions: true
|
||||
serveur_powerdns_expositions: []
|
||||
|
||||
# Zone INVERSE — DERIVEE du supernet, jamais ecrite. Un /16 `10.(10+index).0.0` donne
|
||||
# `(10+index).10.in-addr.arpa`. Vide si le supernet n'est pas connu ou n'est pas un /16 :
|
||||
# mieux vaut pas de zone inverse qu'une zone fausse.
|
||||
#
|
||||
# Pourquoi elle manquait. Rien dans le moteur ne touchait `in-addr.arpa` — mesure du
|
||||
# 2026-08-08. Un hote pouvait donc etre resolu par son nom et rester anonyme a l'envers,
|
||||
# ce que la plupart des services de courriel et de journalisation reprochent en silence.
|
||||
serveur_powerdns_zone_inverse: >-
|
||||
{{ (setops_supernet | default('') | ansible.utils.ipaddr('prefix') | int == 16)
|
||||
| ternary(
|
||||
(setops_supernet | default('0.0.0.0/0')).split('.')[1] ~ '.'
|
||||
~ (setops_supernet | default('0.0.0.0/0')).split('.')[0] ~ '.in-addr.arpa',
|
||||
'') }}
|
||||
|
|
@ -39,3 +39,17 @@ serveur_powerdns_inventory_groups:
|
|||
# qui les sert (domaines.edge). Ferme la boucle : declarer 'expose' -> vhost + cert + DNS.
|
||||
serveur_powerdns_publier_expositions: true
|
||||
serveur_powerdns_expositions: []
|
||||
|
||||
# Zone INVERSE — DERIVEE du supernet, jamais ecrite. Un /16 `10.(10+index).0.0` donne
|
||||
# `(10+index).10.in-addr.arpa`. Vide si le supernet n'est pas connu ou n'est pas un /16 :
|
||||
# mieux vaut pas de zone inverse qu'une zone fausse.
|
||||
#
|
||||
# Pourquoi elle manquait. Rien dans le moteur ne touchait `in-addr.arpa` — mesure du
|
||||
# 2026-08-08. Un hote pouvait donc etre resolu par son nom et rester anonyme a l'envers,
|
||||
# ce que la plupart des services de courriel et de journalisation reprochent en silence.
|
||||
serveur_powerdns_zone_inverse: >-
|
||||
{{ (setops_supernet | default('') | ansible.utils.ipaddr('prefix') | int == 16)
|
||||
| ternary(
|
||||
(setops_supernet | default('0.0.0.0/0')).split('.')[1] ~ '.'
|
||||
~ (setops_supernet | default('0.0.0.0/0')).split('.')[0] ~ '.in-addr.arpa',
|
||||
'') }}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,18 @@
|
|||
mode: "0644"
|
||||
notify: Validate and reload PowerDNS
|
||||
|
||||
# Meme source que la zone directe : `hotes_actifs` et son `ansible_host`. Deux zones
|
||||
# alimentees par une seule verite — l'endroit ou elles divergeraient n'existe pas.
|
||||
- name: Générer la zone DNS inverse depuis l'inventaire
|
||||
ansible.builtin.template:
|
||||
src: zone-inverse.db.j2
|
||||
dest: "{{ serveur_powerdns_zone_directory }}/{{ serveur_powerdns_zone_inverse }}.zone"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: serveur_powerdns_zone_inverse | length > 0
|
||||
notify: Validate and reload PowerDNS
|
||||
|
||||
- name: Activer et démarrer PowerDNS
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ serveur_powerdns_service_name }}"
|
||||
|
|
|
|||
|
|
@ -2,3 +2,9 @@ zone "{{ serveur_powerdns_zone }}" {
|
|||
type master;
|
||||
file "{{ serveur_powerdns_zone_directory }}/{{ serveur_powerdns_zone }}.zone";
|
||||
};
|
||||
{% if serveur_powerdns_zone_inverse %}
|
||||
zone "{{ serveur_powerdns_zone_inverse }}" {
|
||||
type master;
|
||||
file "{{ serveur_powerdns_zone_directory }}/{{ serveur_powerdns_zone_inverse }}.zone";
|
||||
};
|
||||
{% endif %}
|
||||
|
|
|
|||
24
roles/serveur_powerdns/templates/zone-inverse.db.j2
Normal file
24
roles/serveur_powerdns/templates/zone-inverse.db.j2
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
; Zone INVERSE — generee par Set-OPS (role serveur_powerdns). Ne pas editer a la main.
|
||||
;
|
||||
; Le nom de la zone est DERIVE du supernet du tenant, comme tout le reste : un /16
|
||||
; 10.(10+index).0.0 donne (10+index).10.in-addr.arpa. Rien n'est ecrit.
|
||||
;
|
||||
; Les PTR viennent de la MEME source que les A — `hotes_actifs` et son `ansible_host`.
|
||||
; Deux zones alimentees par une seule verite : c'est ce qui empeche l'endroit ou elles
|
||||
; divergent d'exister.
|
||||
$TTL {{ serveur_powerdns_ttl }}
|
||||
@ IN SOA {{ serveur_powerdns_nameserver }}.{{ serveur_powerdns_zone }}. {{ serveur_powerdns_contact }}. (
|
||||
{{ serveur_powerdns_serial }} ; serial
|
||||
{{ serveur_powerdns_soa_refresh }} ; refresh
|
||||
{{ serveur_powerdns_soa_retry }} ; retry
|
||||
{{ serveur_powerdns_soa_expire }} ; expire
|
||||
{{ serveur_powerdns_soa_minimum }} ) ; minimum
|
||||
|
||||
@ IN NS {{ serveur_powerdns_nameserver }}.{{ serveur_powerdns_zone }}.
|
||||
|
||||
{% for host_name in groups.get('hotes_actifs', []) | sort %}
|
||||
{% set ip = hostvars[host_name].ansible_host | default('') %}
|
||||
{% if ip %}
|
||||
{{ ip.split('.')[3] }}.{{ ip.split('.')[2] }} IN PTR {{ host_name }}.{{ serveur_powerdns_zone }}.
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
53
scripts/socle_amorcage.py
Executable file
53
scripts/socle_amorcage.py
Executable file
|
|
@ -0,0 +1,53 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Nomme les hotes qui doivent etre DEBOUT avant tous les autres, derives du plan.
|
||||
|
||||
Contrainte d'architecture posee le 2026-08-08 : « une PKI et un DNS fonctionnels avant
|
||||
toute chose ». Sans eux, chaque VM qui monte reclame un certificat a une autorite absente
|
||||
et un nom a un annuaire muet — et l'echec se lit comme un defaut du role, pas comme un
|
||||
defaut d'ordre.
|
||||
|
||||
Les hotes ne sont PAS ecrits : ils se derivent de `applications.<app>.hote`. Deplacer
|
||||
l'autorite dans le plan deplace l'amorcage avec elle.
|
||||
|
||||
python3 scripts/socle_amorcage.py # un nom d'hote par ligne, dans l'ordre
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
RACINE = Path(__file__).resolve().parent.parent
|
||||
|
||||
# L'ordre compte et n'est pas alphabetique : le DNS a besoin d'un certificat, l'autorite
|
||||
# n'a besoin de personne. Elle passe donc devant — c'est la seule dependance reelle
|
||||
# entre les deux.
|
||||
APPLICATIONS = ["step_ca", "powerdns"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
base = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
|
||||
plan = base / "plan" / "applications.yml"
|
||||
if not plan.exists():
|
||||
print(f"Plan introuvable : {plan}", file=sys.stderr)
|
||||
return 2
|
||||
apps = (yaml.safe_load(plan.read_text(encoding="utf-8")) or {}).get("applications") or {}
|
||||
|
||||
vus, sortie = set(), []
|
||||
for nom in APPLICATIONS:
|
||||
hote = (apps.get(nom) or {}).get("hote")
|
||||
if not hote:
|
||||
print(f"L'application « {nom} » n'est pas au plan : impossible d'amorcer le "
|
||||
f"socle sans elle.", file=sys.stderr)
|
||||
return 2
|
||||
if hote not in vus: # PKI et DNS peuvent partager un hote
|
||||
vus.add(hote)
|
||||
sortie.append(hote)
|
||||
print("\n".join(sortie))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in a new issue