Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Spec couche 1 — air-config : schémas .capnp v1 + BNF du format de source (design)

Spécification technique — Version 0.2 (décisions validées BDFL 2026-06-25 ; v1 = réseau/ services/comptes + mounts/fstab). Couche 1. Dernier pré-requis avant implémentation d’air-config (cf. air-config.md §« pré-implémentation » et air-config-source-format.md).

Objet

Définit (A) les schémas Cap’n Proto des domaines de config v1 (alignés sur les backends validés : resolv.conf+hosts, systemd, passwd/shadow — décision #2) et (B) la BNF gelée du format de source. Ensemble = la matière prête pour l’implémentation.

Conventions de schéma (communes à tous les domaines)

  • Annotations Air (fichier air-config-annotations.capnp) :
    @0xada1ada1ada1ada1;
    annotation secret @0xacf0...01 (field) :Void;   # champ = secret → zeroize, jamais loggé
    annotation domain @0xacf0...02 (struct) :Text;  # nom de domaine ("air.network", "air.accounts"…)
    annotation backend @0xacf0...03 (struct) :Text; # backend /etc cible ("resolv.conf"…)
    
  • Enveloppe versionnée (en-tête commun, ADR-040 §version+checksum) :
    struct ConfigEnvelope {
      schemaDomain  @0 :Text;     # ex. "air.network"
      schemaVersion @1 :UInt32;   # version du schéma de domaine (évolution)
      # checksum : calculé sur l'artefact, porté HORS payload (en-tête de fichier), pas ici
      payload       @2 :AnyPointer;  # le struct racine du domaine
    }
    
  • Optionnalité : Cap’n Proto n’a pas d’Option natif → convention « champ absent = valeur par défaut du schéma » ; pour distinguer non-posé de valeur par défaut quand c’est sémantiquement requis, on utilise un groupe union unset | set :T.
  • Types réutilisés :
    struct IpAddress { union { v4 @0 :UInt32; v6 @1 :Data; } }   # v6 = 16 octets
    struct Duration  { millis @0 :UInt64; }                       # source : sucre 5s/250ms → millis
    

(A) Schémas v1

A.1 — Domaine air.network (backends resolv.conf + hosts)

@0x0001...;  # file id (à générer)
using Air = import "/air-config-annotations.capnp";

struct NetworkConfig $Air.domain("air.network") {
  resolver @0 :ResolverConfig;
  hosts    @1 :HostsConfig;
}

struct ResolverConfig $Air.backend("resolv.conf") {
  nameservers   @0 :List(IpAddress);   # ordonné (priorité)
  searchDomains @1 :List(Text);
  ndots         @2 :UInt8;             # option resolv.conf
  timeout       @3 :Duration;
  attempts      @4 :UInt8;
  ipv6First     @5 :Bool;
}

struct HostsConfig $Air.backend("hosts") {
  entries @0 :List(HostEntry);
}
struct HostEntry {
  address @0 :IpAddress;
  names   @1 :List(Text);   # canonique d'abord, puis alias
}

A.2 — Domaine air.services (backend systemd)

@0x0002...;
struct ServicesConfig $Air.domain("air.services") $Air.backend("systemd") {
  units         @0 :List(UnitConfig);
  defaultTarget @1 :Text;              # → /etc/systemd/system/default.target (symlink généré)
}
struct UnitConfig {
  name    @0 :Text;                    # ex. "sshd.service"
  state   @1 :UnitState;               # enabled → *.wants/ ; masked → symlink /dev/null
  dropIns @2 :List(DropIn);            # → /etc/systemd/system/<unit>.d/*.conf
}
enum UnitState { disabled @0; enabled @1; masked @2; }
struct DropIn { name @0 :Text; section @1 :Text; key @2 :Text; value @3 :Text; }

systemd : air-config ÉMET ces fichiers (*.wants, drop-ins) ; l’humain ne fait plus systemctl enable (ADR-041). systemd ne fait que lire.

A.3 — Domaine air.accounts (backends passwd + shadow) — LE PLUS SENSIBLE

@0x0003...;
struct AccountsConfig $Air.domain("air.accounts") {
  users  @0 :List(User);
  groups @1 :List(Group);
}
struct User $Air.backend("passwd") {
  name    @0 :Text;
  uid     @1 :UInt32;
  gid     @2 :UInt32;
  gecos   @3 :Text;
  home    @4 :Text;
  shell   @5 :Text;
  # --- champs shadow ---
  passwordHash @6 :Text $Air.secret;   # SECRET : zeroize, jamais loggé, backend `shadow` (0600)
  lastChange   @7 :UInt32;             # jours depuis epoch
  minAge       @8 :UInt32;
  maxAge       @9 :UInt32;
  # … warn/inactive/expire selon besoin
}
struct Group $Air.backend("passwd") {
  name    @0 :Text;
  gid     @1 :UInt32;
  members @2 :List(Text);
}

$Air.secret sur passwordHash ⇒ le compilateur zeroize ce champ dans ses tampons après usage, l’élide des diagnostics, et le backend l’écrit dans /etc/shadow (mode 0600), jamais dans passwd. Domaine livré en dernier (décision #2).

A.4 — Domaine air.mounts (backend fstab)

@0x0004...;
struct MountsConfig $Air.domain("air.mounts") $Air.backend("fstab") {
  entries @0 :List(MountEntry);
}
struct MountEntry {
  source     @0 :Text;        # device / UUID= / LABEL= / partage réseau
  target     @1 :Text;        # point de montage
  fsType     @2 :Text;        # ext4, btrfs, nfs, tmpfs…
  options    @3 :List(Text);  # rw, noatime, nofail…
  dumpFreq   @4 :UInt8;       # champ 5 de fstab
  passNumber @5 :UInt8;       # champ 6 (ordre fsck)
}

fstab = table de montage STATIQUE (config). /etc/mtab (mnttab) est EXCLU : c’est de l’état runtime (symlink kernel vers /proc/self/mounts), pas de la config → air-config n’y touche pas. ⚠️ Un fstab erroné peut bloquer le boot : les générations + rollback + entrée de secours au boot (ADR-033 §6) sont ici critiques.

Backends candidats (périmètre PROGRESSIF, ADR-041 — hors v1 strict)

À ajouter ensuite, par ordre de pertinence : nsswitch.conf (lie résolution réseau ↔ comptes), locale.conf/hostname/localtime(tz)/vconsole.conf (domaine air.locale/ air.hostname), sysctl.d, crypttab (pair de fstab). À DIFFÉRER (très sensibles) : sudoers, pam.d (escalade/auth — prudence façon passwd). Hors air-config-cœur : sshd_config (→ config du service air-ssh, couche réseau).

(B) BNF gelée du format de source

Formalise air-config-source-format.md (schema-directed).

document     = header , { line } ;
header       = "schema" , ws , "=" , ws , string , newline ;   (* obligatoire, en tête *)
line         = ( comment | section | array_table | assignment | blank ) , newline ;
comment      = "#" , { any_char_no_nl } ;
blank        = { ws } ;
section      = "[" , path , "]" ;
array_table  = "[[" , path , "]]" ;
path         = ident , { "." , ident } ;
assignment   = ws , key , ws , "=" , ws , value ;
key          = ident ;
ident        = alpha , { alpha | digit | "_" | "-" } ;
value        = bool | integer | float | string | bytes | enum_val | list ;
bool         = "true" | "false" ;
integer      = [ "-" ] , ( dec_int | hex_int ) ;          (* checked vs type schéma *)
dec_int      = digit , { digit | "_" } ;
hex_int      = "0x" , hexdigit , { hexdigit | "_" } ;
float        = [ "-" ] , digit , { digit } , [ "." , digit , { digit } ] , [ exp ] ;
exp          = ("e"|"E") , [ "+" | "-" ] , digit , { digit } ;
string       = '"' , { str_char | escape } , '"' ;
escape       = "\" , ( "n" | "t" | "r" | '"' | "\" | "u{" , hexdigit{1,6} , "}" ) ;
bytes        = ( "hex:" , string ) | ( "b64:" , string ) ;
enum_val     = ident ;                                    (* validé vs enum du schéma *)
list         = "[" , ws , [ value , { ws , "," , ws , value } , [ "," ] ] , ws , "]" ;
duration     = dec_int , ( "ms" | "s" | "m" | "h" | "d" ) ;   (* sucre, si champ Duration *)
size         = dec_int , ( "B" | "KiB" | "MiB" | "GiB" ) ;    (* sucre, si champ taille *)

(Bornes anti-DoS : taille document/ligne, profondeur de path, longueur de list — appliquées par le lexer, cf. air-config-source-format §1. Clés dupliquées = erreur.)

Mapping lexème → type Cap’n Proto (schema-directed)

Champ schémaLexèmes acceptés
Booltrue/false
UInt8..64/Int8..64integer (checked_* vs largeur+signe)
Float32/64float (+ integer promu)
Textstring
Databytes (hex:/b64:)
enumenum_val (∈ valeurs schéma ; sinon did-you-mean)
List(T)list (homogène T) ; ou [[table]] pour List(Struct)
struct[section]
Duration/tailleduration/size (sucre) ou integer
$secretstringzeroizé, élidé des diagnostics

Décisions validées (BDFL 2026-06-25)

  1. Extension de fichier = .airconf.
  2. Sucre duration/size (5s, 10MiB) inclus v1. ✓ — unités explicites ; la conversion vers l’entier cible est checked_* → tout overflow rejeté (diagnostic).
  3. Texte multi-lignes """…""" inclus (drop-ins, gecos). ✓
  4. IP = struct typé IpAddress (union v4/v6). ✓
  5. File ids .capnp générés (capnp id) au câblage. ✓
  6. fstab ajouté (domaine air.mounts, A.4). /etc/mtab exclu (état runtime). ✓ Autres backends → périmètre progressif (cf. « Backends candidats »).

Travail à reprendre (→ implémentation)

  • Générer les file ids + figer les .capnp dans air-config-schema/ ; committer le .rs généré (ADR-040).
  • Implémenter air-config-compile (lexer/parseur BNF ci-dessus + validateur schema-directed) — fuzzé ; puis le lecteur runtime + générations + backends (resolv.conf/hosts d’abord).

Licence du document : MPL 2.0 Statut : v0.2 — schémas .capnp v1 (réseau/services/comptes/mounts) + BNF gelée du format de source. Décisions validées (extension .airconf, sucre durée/taille checked, multi-lignes, IP struct, fstab ; mtab exclu). Prêt pour implémentation : câbler air-config-schema (file ids + code généré committé) puis air-config-compile (fuzzé). Bases : ADR-033 / ADR-040 / ADR-041 + specs air-config + air-config-source-format.