devis PostgreSQL et courriel : la serie des devis de service est complete

PostgreSQL — rend visibles deux defauts deja vecus ici : un reseau ECRIT dans
pg_hba au lieu d'etre derive, et une ligne host en clair la ou il faut hostssl
(verrou qui saute sans bruit, les clients verify-full continuant de marcher).
Interroge pg_settings, jamais le fichier : un grep de postgresql.conf annonce
ssl_cert_file=snakeoil alors que le serveur sert le certificat de l'AC — la
valeur vient d'un conf.d que le grep ne voyait pas. L'instrument etait
incomplet, pas la configuration.

Courriel — chaque maillon interroge la ou il dit la verite : postmap -q pour
la resolution LDAP de Postfix, doveadm user pour celle de Dovecot (le maillon
exact ou la livraison avait bloque), vraies conversations SMTP/IMAP. Verifie
aussi qu'une adresse INEXISTANTE ne resout pas — sinon la boite est un
fourre-tout et le devis ne mesure plus rien.

Trouve immediatement une divergence reelle : Dovecot connait la boite de
sysadmin@chezlepro.internal, Postfix ne sait pas y router (query_filter
(mail=%s), et l'attribut mail porte sysadmin@chezlepro.ca). Collision de
roles : une identite ne porte qu'une adresse mail et on lui en demande deux —
notification joignable hors du systeme, et cle de routage local. Arbitrage a
rendre avant correction.

Tests negatifs : PostgreSQL 3 ecarts nommes, code 1. Une variable morte
trouvee dans une branche que le cas nominal n'emprunte jamais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-08 08:53:49 -04:00
parent 0cc04177fb
commit 4bfcf4944f
7 changed files with 471 additions and 0 deletions

View file

@ -1,5 +1,41 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-08 — Devis PostgreSQL et courriel : la série est complète
**PostgreSQL.** Il rend visibles deux défauts déjà vécus ici : un réseau *écrit* dans
`pg_hba.conf` au lieu d'être dérivé, et une ligne `host` en clair là où il faut `hostssl`
— un verrou qui saute sans bruit, puisque les clients en `verify-full` continuent de
marcher. État : conforme. Test négatif rejouant les deux défauts plus un certificat
snakeoil : trois écarts nommés, code 1.
Une leçon de méthode au passage : un `grep` de `postgresql.conf` annonce
`ssl_cert_file = snakeoil` alors que le serveur sert bien le certificat de l'AC — la valeur
vient d'un `conf.d/99-setops.conf` que le grep ne voyait pas. **C'était l'instrument qui
était incomplet, pas la configuration.** Le devis interroge `pg_settings`, jamais le
fichier.
Et une erreur trouvée par le test négatif, pas par la relecture : une variable morte dans
une branche que le cas nominal n'emprunte jamais. Troisième fois aujourd'hui.
**Courriel.** Chaque maillon interrogé là où il dit la vérité : `postmap -q` pour la
résolution LDAP de Postfix, `doveadm user` pour celle de Dovecot — précisément le maillon
où la livraison avait bloqué — et de vraies conversations SMTP/IMAP. Il vérifie aussi
qu'une adresse *inexistante* ne résout pas : sans ça, une boîte fourre-tout accepterait
n'importe quel nom et le devis ne mesurerait plus rien.
**Il a immédiatement trouvé une divergence réelle.** Dovecot connaît la boîte de
`sysadmin@chezlepro.internal` (`mail_path = /var/vmail/sysadmin/Maildir`), et Postfix ne
sait pas y router : son `query_filter` est `(mail=%s)`, et l'attribut `mail` de l'annuaire
porte désormais `sysadmin@chezlepro.ca`.
La cause n'est pas un réglage mais une **collision de rôles** : une identité ne porte
qu'une adresse `mail`, et on lui en demande deux — l'adresse de notification, qui doit être
joignable par la personne *hors* du système qu'on amorce, et la clé de routage local, qui
doit vivre dans un `virtual_mailbox_domains`. Les deux ne peuvent pas être la même valeur.
Ce n'est pas une régression (le compte n'avait aucun `mail` en début de journée, donc
n'était pas routable non plus) — le devis a rendu lisible un état qui l'était déjà.
Arbitrage à rendre avant correction.
## 2026-08-08 — Devis des expositions : « est-ce que mes services répondent ? » ## 2026-08-08 — Devis des expositions : « est-ce que mes services répondent ? »
Troisième devis de service. Il pose la seule question qui compte pour un utilisateur, et Troisième devis de service. Il pose la seule question qui compte pour un utilisateur, et

View file

@ -418,6 +418,16 @@ ca-empreinte: ansible-runtime _instance-requise ## Empreinte de la racine, lue S
| tail -1 | tr -d ' \r' | tail -1 | tr -d ' \r'
.PHONY: frontiere-plan frontiere-appliquer .PHONY: frontiere-plan frontiere-appliquer
courriel-plan: ansible-runtime ## Chaine Postfix -> LDAP -> Dovecot -> IMAP (aucune ecriture)
@rm -f $(SETOPS_INSTANCE)/devis-courriel.json.*
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-courriel.yml >/dev/null
@python3 scripts/devis_courriel.py
postgresql-plan: ansible-runtime ## Chiffrement impose et portee des acces PostgreSQL (aucune ecriture)
@rm -f $(SETOPS_INSTANCE)/devis-postgresql.json.*
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-postgresql.yml >/dev/null
@python3 scripts/devis_postgresql.py
expositions-plan: ansible-runtime ## Chaque exposition du plan repond-elle ? (edge et poste, aucune ecriture) expositions-plan: ansible-runtime ## Chaque exposition du plan repond-elle ? (edge et poste, aucune ecriture)
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-expositions.yml >/dev/null @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-expositions.yml >/dev/null
@python3 scripts/devis_expositions.py @python3 scripts/devis_expositions.py

View file

@ -7,6 +7,8 @@
make identite-plan # realm, fédération, mappeurs, politique, comptes make identite-plan # realm, fédération, mappeurs, politique, comptes
make certificats-plan # certificats sur disque contre certificats réellement servis make certificats-plan # certificats sur disque contre certificats réellement servis
make expositions-plan # chaque `expose:` du plan répond-il, depuis l'edge et depuis le poste make expositions-plan # chaque `expose:` du plan répond-il, depuis l'edge et depuis le poste
make postgresql-plan # chiffrement imposé, et à quels réseaux
make courriel-plan # Postfix → LDAP → LMTP → Dovecot → IMAP
``` ```
## Le trou qu'il comble ## Le trou qu'il comble
@ -89,6 +91,29 @@ passer un dorsal tombé — trouvé par le test négatif, pas par la relecture.
en revanche, est un service parfaitement vivant qui redirige vers l'IdP ou exige une en revanche, est un service parfaitement vivant qui redirige vers l'IdP ou exige une
authentification. authentification.
## Le devis PostgreSQL
Deux défauts déjà vécus dans ce dépôt, et qu'il rend visibles : un réseau **écrit** dans
`pg_hba.conf` au lieu d'être dérivé (`10.11.0.0/16` sous un commentaire « AJUSTER » que
personne n'a suivi), et une ligne `host` en clair là où il faut `hostssl` — le verrou saute
sans bruit, puisque les clients en `verify-full` continuent de fonctionner.
**Interroger l'état effectif, jamais le fichier.** Un `grep` de `postgresql.conf` annonce
`ssl_cert_file = snakeoil` sur `data-sql-01`, alors que le serveur sert bien le certificat
de l'AC : la valeur vient d'un `conf.d/99-setops.conf` que le grep ne voyait pas. C'était
l'instrument qui était incomplet, pas la configuration. Le devis lit `pg_settings`.
## Le devis du courriel
La chaîne est longue — Postfix → LDAP → LMTP → Dovecot → IMAP — et chaque maillon peut
casser sans que le suivant s'en aperçoive. Chacun est donc interrogé là où il dit la
vérité : `postmap -q` pour la résolution LDAP de Postfix, `doveadm user` pour celle de
Dovecot — précisément le maillon où la livraison avait bloqué — et une vraie conversation
SMTP/IMAP pour ce que les serveurs annoncent.
Il vérifie aussi qu'une adresse **inexistante** ne résout pas : sans ce contrôle, une boîte
fourre-tout accepterait n'importe quel nom, et le devis ne mesurerait plus rien.
## Un piège de construction, à connaître avant d'écrire le prochain ## Un piège de construction, à connaître avant d'écrire le prochain
`include_vars` au niveau du *play* **prime sur les `group_vars`**. Charger les défauts d'un `include_vars` au niveau du *play* **prime sur les `group_vars`**. Charger les défauts d'un

View file

@ -0,0 +1,118 @@
---
# Devis du courriel — RELEVE seul. Ne modifie rien (D-23).
#
# La chaine est longue — Postfix -> LDAP -> LMTP -> Dovecot -> IMAP — et chaque maillon
# peut casser sans que le suivant s'en apercoive. On interroge donc chaque maillon la
# ou il dit la verite : `postmap -q` pour la resolution LDAP de Postfix, `doveadm user`
# pour celle de Dovecot (c'est precisement la ou la livraison avait bloque), et une vraie
# conversation SMTP pour ce que le serveur accepte.
#
# make courriel-plan
- name: Devis du courriel — Postfix
hosts: serveur_postfix
become: true
gather_facts: false
tasks:
- name: Résoudre la connexion à l'annuaire
ansible.builtin.include_role:
name: resoudre_annuaire
- name: Choisir un compte réel comme sonde
community.general.ldap_search:
dn: "{{ resoudre_annuaire_users_dn }}"
scope: onelevel
filter: "(objectClass=inetOrgPerson)"
attrs: ["uid"]
server_uri: "{{ resoudre_annuaire_uri }}"
bind_dn: "{{ resoudre_annuaire_bind_dn }}"
bind_pw: "{{ resoudre_annuaire_bind_password }}"
register: devis_courriel_comptes
changed_when: false
no_log: true
delegate_to: "{{ groups['serveur_openldap'] | first }}"
- name: Retenir l'adresse sonde
ansible.builtin.set_fact:
devis_courriel_sonde: "{{ (devis_courriel_comptes.results | first).uid }}@{{ domaine_interne }}"
- name: Relever Postfix (résolution LDAP, portée du relais, dialecte SMTP)
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -uo pipefail
echo "SONDE={{ devis_courriel_sonde }}"
echo "TROUVE=$(postmap -q '{{ devis_courriel_sonde }}' \
ldap:/etc/postfix/ldap-mailboxes.cf 2>/dev/null)"
# Une adresse qui n'existe pas DOIT rester sans reponse : sinon la boite est
# un fourre-tout et n'importe quel nom serait accepte.
echo "FANTOME=$(postmap -q 'nexistepas-{{ 999 | random }}@{{ domaine_interne }}' \
ldap:/etc/postfix/ldap-mailboxes.cf 2>/dev/null)"
echo "MYNETWORKS=$(postconf -h mynetworks)"
for P in 25 587; do
echo "PORT=$P"
exec 3<>/dev/tcp/127.0.0.1/$P || continue
read -r -t 5 _ <&3
printf 'EHLO devis.local\r\n' >&3
while read -r -t 5 L <&3; do
echo " $L"
[ "${L:3:1}" = " " ] && break
done
printf 'QUIT\r\n' >&3
exec 3<&- 3>&-
done
register: devis_courriel_postfix
changed_when: false
- name: Déposer le relevé Postfix
ansible.builtin.copy:
dest: "{{ playbook_dir }}/../../instance/devis-courriel.json.postfix"
mode: "0600"
content: >-
{{ {'hote': inventory_hostname, 'role': 'postfix',
'supernet': setops_supernet | default(''),
'brut': devis_courriel_postfix.stdout} | to_nice_json }}
delegate_to: localhost
become: false
- name: Devis du courriel — Dovecot
hosts: serveur_dovecot
become: true
gather_facts: false
tasks:
- name: Relever Dovecot (résolution userdb et dialecte IMAP)
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -uo pipefail
SONDE="{{ hostvars[groups['serveur_postfix'] | first]['devis_courriel_sonde'] }}"
echo "SONDE=$SONDE"
# `doveadm user` est le maillon exact ou la livraison avait bloque : c'est lui
# qui dit si Dovecot sait ou ecrire pour cette adresse.
echo "USERDB<<"
doveadm user "$SONDE" 2>&1 | head -12
echo ">>"
echo "PORT=143"
exec 3<>/dev/tcp/127.0.0.1/143 || exit 0
read -r -t 5 BANNIERE <&3
echo " $BANNIERE"
printf 'a CAPABILITY\r\n' >&3
while read -r -t 5 L <&3; do
echo " $L"
case "$L" in a\ *) break ;; esac
done
printf 'b LOGOUT\r\n' >&3
register: devis_courriel_dovecot
changed_when: false
- name: Déposer le relevé Dovecot
ansible.builtin.copy:
dest: "{{ playbook_dir }}/../../instance/devis-courriel.json.dovecot"
mode: "0600"
content: >-
{{ {'hote': inventory_hostname, 'role': 'dovecot',
'brut': devis_courriel_dovecot.stdout} | to_nice_json }}
delegate_to: localhost
become: false

View file

@ -0,0 +1,62 @@
---
# Devis PostgreSQL — RELEVE seul. Ne modifie rien (D-23).
#
# On interroge l'ETAT EFFECTIF (`pg_settings`), jamais les fichiers. Un `grep` de
# `postgresql.conf` annonce `ssl_cert_file = snakeoil` sur cet hote, alors que le
# serveur sert bien le certificat de l'AC interne : la valeur vient d'un
# `conf.d/99-setops.conf` que le grep ne voyait pas. C'est l'instrument qui etait
# incomplet, pas la configuration. Constate le 2026-08-08.
#
# make postgresql-plan
- name: Devis PostgreSQL — chiffrement et portee des accès
hosts: serveur_postgresql
become: true
gather_facts: false
tasks:
- name: Relever les réglages TLS effectifs
ansible.builtin.command:
argv:
- sudo
- -u
- postgres
- psql
- -tAF|
- -c
- >-
select name, setting from pg_settings
where name in ('ssl','ssl_cert_file','ssl_key_file','ssl_ca_file')
register: devis_pg_tls
changed_when: false
- name: Relever les règles d'accès effectives
ansible.builtin.command:
argv:
- sudo
- -u
- postgres
- psql
- -tAF|
- -c
# `address` et `netmask` sont du TEXTE dans cette vue, pas des inet : on les
# rend tels quels et Python les recompose.
- >-
select type, coalesce(address,''), coalesce(netmask,''), auth_method
from pg_hba_file_rules where error is null order by rule_number
register: devis_pg_hba
changed_when: false
- name: Déposer le relevé sur le contrôleur
ansible.builtin.copy:
dest: "{{ playbook_dir }}/../../instance/devis-postgresql.json.{{ inventory_hostname }}"
mode: "0600"
content: >-
{{ {
'hote': inventory_hostname,
'supernet': setops_supernet | default(''),
'tls': devis_pg_tls.stdout_lines | default([]),
'hba': devis_pg_hba.stdout_lines | default([]),
} | to_nice_json }}
delegate_to: localhost
become: false

120
scripts/devis_courriel.py Executable file
View file

@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Devis du courriel : la chaîne Postfix → LDAP → LMTP → Dovecot → IMAP tient-elle ?
Lit les relevés de `playbooks/maintenance/devis-courriel.yml`. Sort en code 1 s'il y a un
écart.
La chaîne est longue et chaque maillon peut casser sans que le suivant s'en aperçoive. On
interroge donc chaque maillon il dit la vérité : `postmap -q` pour la résolution LDAP
de Postfix, `doveadm user` pour celle de Dovecot c'est exactement là que la livraison
avait bloqué et une vraie conversation SMTP/IMAP pour ce que les serveurs annoncent.
"""
from __future__ import annotations
import glob
import ipaddress
import json
import re
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
MOTIF = str(RACINE / "instance" / "devis-courriel.json.*")
def _valeur(brut: str, cle: str) -> str:
m = re.search(rf"^{cle}=(.*)$", brut, re.M)
return m.group(1).strip() if m else ""
def _dialecte(brut: str, port: str) -> str:
"""Ce que le serveur annonce sur un port donné."""
m = re.search(rf"^PORT={port}$(.*?)(?=^PORT=|\Z)", brut, re.M | re.S)
return m.group(1) if m else ""
def analyser(releves: list[dict]) -> list[str]:
ecarts = []
postfix = next((r for r in releves if r["role"] == "postfix"), None)
dovecot = next((r for r in releves if r["role"] == "dovecot"), None)
if postfix:
h, brut = postfix["hote"], postfix["brut"]
sonde = _valeur(brut, "SONDE")
if not _valeur(brut, "TROUVE"):
ecarts.append(
f"{h} : Postfix ne resout PAS « {sonde} » — rien ne peut lui etre livre "
f"localement. L'attribut `mail` de l'annuaire porte une autre adresse, et "
f"c'est lui qui sert de cle (`query_filter = (mail=%s)`)")
if _valeur(brut, "FANTOME"):
ecarts.append(f"{h} : une adresse inexistante resout quand meme — la boite est "
f"un fourre-tout, n'importe quel nom serait accepte")
for port in ("25", "587"):
d = _dialecte(brut, port)
if not d.strip():
ecarts.append(f"{h}:{port} : aucune reponse SMTP")
elif "STARTTLS" not in d:
ecarts.append(f"{h}:{port} : STARTTLS n'est pas annonce — le courriel "
f"sortirait en clair")
supernet = postfix.get("supernet")
if supernet:
reseau_attendu = ipaddress.ip_network(supernet)
for morceau in _valeur(brut, "MYNETWORKS").split():
morceau = morceau.strip("[]")
try:
res = ipaddress.ip_network(morceau, strict=False)
except ValueError:
continue
if res.is_loopback:
continue
if res.version != reseau_attendu.version or not res.subnet_of(reseau_attendu):
ecarts.append(f"{h} : `mynetworks` autorise {res}, hors du supernet "
f"derive {reseau_attendu} — relais plus large que le tenant")
if dovecot:
h, brut = dovecot["hote"], dovecot["brut"]
sonde = _valeur(brut, "SONDE")
userdb = re.search(r"USERDB<<(.*?)>>", brut, re.S)
corps = userdb.group(1) if userdb else ""
if "mail_path" not in corps and "home" not in corps:
ecarts.append(f"{h} : Dovecot ne sait pas ou ecrire pour « {sonde} » — "
f"la resolution userdb echoue, la livraison LMTP aussi")
d = _dialecte(brut, "143")
if not d.strip():
ecarts.append(f"{h}:143 : aucune reponse IMAP")
elif "STARTTLS" not in d:
ecarts.append(f"{h}:143 : STARTTLS n'est pas annonce")
if postfix and dovecot:
if not _valeur(postfix["brut"], "TROUVE") and "mail_path" in dovecot["brut"]:
ecarts.append(
"les deux moities divergent : Dovecot connait la boite, Postfix ne sait "
"pas y router. Une identite ne porte qu'une adresse `mail`, et elle a ici "
"deux roles incompatibles — la notification (joignable par la personne) et "
"la cle de routage local (dans un domaine local). A trancher.")
return ecarts
def main(argv: list[str] | None = None) -> int:
fichiers = sorted(glob.glob(argv[0] if argv else MOTIF))
if not fichiers:
print("Relevés absents. Lancer d'abord : make courriel-plan", file=sys.stderr)
return 2
releves = [json.loads(Path(f).read_text(encoding="utf-8")) for f in fichiers]
ecarts = analyser(releves)
print(f"Devis du courriel — {' + '.join(r['hote'] for r in releves)}\n")
for e in ecarts:
print(f" [écart] {e}")
if not ecarts:
print("CONFORME : la chaîne tient, de la résolution LDAP à la boîte.")
return 0
print(f"\nÉCART : {len(ecarts)} divergence(s).")
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

100
scripts/devis_postgresql.py Executable file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Devis PostgreSQL : le chiffrement est-il réellement imposé, et à qui ?
Lit les relevés de `playbooks/maintenance/devis-postgresql.yml`. Sort en code 1 s'il y a
un écart.
Deux défauts déjà vécus dans ce dépôt, et que ce devis rend visibles :
- un réseau **écrit** dans `pg_hba.conf` au lieu d'être dérivé (`10.11.0.0/16` sous un
commentaire « AJUSTER » que personne n'a suivi) : la base acceptait un supernet qui
n'était pas le sien ;
- une ligne `host` (en clair) il faut `hostssl` : le verrou saute sans bruit,
puisque les clients en `verify-full` continuent de fonctionner.
"""
from __future__ import annotations
import glob
import ipaddress
import json
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
MOTIF = str(RACINE / "instance" / "devis-postgresql.json.*")
LOCAL = (ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("::1/128"))
def analyser(releves: list[dict]) -> list[str]:
ecarts = []
for r in sorted(releves, key=lambda x: x["hote"]):
h = r["hote"]
tls = dict(l.split("|", 1) for l in r["tls"] if "|" in l)
if tls.get("ssl") != "on":
ecarts.append(f"{h} : `ssl` n'est pas actif — tout le trafic est en clair")
cert = tls.get("ssl_cert_file", "")
if "snakeoil" in cert or not cert:
ecarts.append(f"{h} : certificat serveur « {cert or 'aucun'} » — ce n'est pas "
f"celui de l'AC interne, la confiance est-ouest ne tient pas")
if not tls.get("ssl_ca_file"):
ecarts.append(f"{h} : aucun `ssl_ca_file` — le serveur ne peut pas verifier "
f"un certificat client")
supernet = ipaddress.ip_network(r["supernet"]) if r.get("supernet") else None
for ligne in r["hba"]:
champs = [c.strip() for c in ligne.split("|")]
if len(champs) < 4:
continue
type_, adresse, masque = champs[0], champs[1], champs[2]
if type_ == "local" or not adresse:
continue
# `address` peut valoir « all », « samehost », un nom d'hote, une adresse
# nue (`::1`) ou un CIDR ; `netmask` n'est renseigne que parfois. On essaie
# les deux recompositions avant de conclure que ce n'est pas un reseau.
res = None
for candidat in ((f"{adresse}/{masque}" if masque else None), adresse):
if not candidat:
continue
try:
res = ipaddress.ip_network(candidat, strict=False)
break
except ValueError:
res = None
if res is None:
if type_ != "hostssl":
ecarts.append(f"{h} : `{type_}` (et non `hostssl`) pour « {adresse} » "
f"— le chiffrement n'est pas IMPOSE, seulement possible")
continue
if any(res.subnet_of(l) for l in LOCAL if res.version == l.version):
continue
if type_ != "hostssl":
ecarts.append(f"{h} : `{type_}` (et non `hostssl`) pour {res} — le "
f"chiffrement n'est pas IMPOSE, seulement possible")
if supernet and res.version == supernet.version and not res.subnet_of(supernet):
ecarts.append(f"{h} : {res} est autorise mais n'appartient pas au "
f"supernet derive {supernet} — un reseau ECRIT au lieu "
f"d'etre derive")
return ecarts
def main(argv: list[str] | None = None) -> int:
fichiers = sorted(glob.glob(argv[0] if argv else MOTIF))
if not fichiers:
print("Relevés absents. Lancer d'abord : make postgresql-plan", file=sys.stderr)
return 2
releves = [json.loads(Path(f).read_text(encoding="utf-8")) for f in fichiers]
ecarts = analyser(releves)
print(f"Devis PostgreSQL — {len(releves)} instance(s)\n")
for e in ecarts:
print(f" [écart] {e}")
if not ecarts:
print("CONFORME : chiffrement imposé, et aucun réseau hors du supernet dérivé.")
return 0
print(f"\nÉCART : {len(ecarts)} divergence(s).")
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))