feat: initial commit

This commit is contained in:
d9pouces
2026-08-28 16:52:07 +02:00
parent 3e5b65659b
commit 48ec887808
20 changed files with 1911 additions and 2 deletions
+3
View File
@@ -0,0 +1,3 @@
from id_configurator.cli import app, main
__all__ = ["app", "main"]
+3
View File
@@ -0,0 +1,3 @@
from id_configurator.cli import main
main(__name__)
+120
View File
@@ -0,0 +1,120 @@
"""Typer CLI for id-configurator."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Annotated
import typer
from id_configurator import modules # noqa: F401 (registers the built-in modules)
from id_configurator.config import load_configuration
from id_configurator.context import Context
from id_configurator.modules.base import all_modules
app = typer.Typer(
name="id-configurator",
help="Configure a device in an air-gapped environment.",
no_args_is_help=True,
)
logger = logging.getLogger("id_configurator")
ConfigFileOption = Annotated[
Path | None,
typer.Option(
"--config",
exists=True,
dir_okay=False,
help="Path to a JSON or YAML configuration file.",
),
]
ConfigEnvOption = Annotated[
str | None,
typer.Option(
"--config-env",
envvar="ID_CONFIGURATOR_CONFIG",
help="Base64-encoded JSON/YAML configuration.",
),
]
SrcOption = Annotated[
Path | None,
typer.Option(
"--src",
exists=True,
file_okay=False,
help="Existing filesystem used to detect the current configuration.",
),
]
DstOption = Annotated[
Path,
typer.Option(
"--dst",
file_okay=False,
help="Destination directory receiving the modified files.",
),
]
VerboseOption = Annotated[
bool, typer.Option("--verbose", "-v", help="Enable verbose (debug) logging.")
]
def _configure_logging(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(levelname)s %(name)s: %(message)s",
)
def _load_configuration(config: Path | None, config_env: str | None):
if config is None and config_env is None:
raise typer.BadParameter(
"either --config or --config-env (or $ID_CONFIGURATOR_CONFIG) must be provided"
)
return load_configuration(config, config_env)
@app.command()
def apply(
dst: DstOption,
config: ConfigFileOption = None,
config_env: ConfigEnvOption = None,
src: SrcOption = None,
verbose: VerboseOption = False,
) -> None:
"""Apply a configuration to the destination directory."""
_configure_logging(verbose)
configuration = _load_configuration(config, config_env)
dst.mkdir(parents=True, exist_ok=True)
context = Context(config=configuration, dst=dst, src=src)
for module_cls in all_modules():
module = module_cls()
if module.is_relevant(context):
logger.info("applying module %s", module.name)
module.apply(context)
else:
logger.debug("skipping module %s (not relevant)", module.name)
@app.command()
def validate(
config: ConfigFileOption = None, config_env: ConfigEnvOption = None
) -> None:
"""Validate a configuration file without applying it."""
configuration = _load_configuration(config, config_env)
typer.echo(
f"configuration {configuration.uuid} is valid (organisation: {configuration.organisation})"
)
@app.command("list-modules")
def list_modules_command() -> None:
"""List the available configuration modules."""
for module_cls in all_modules():
typer.echo(module_cls.name)
def main(name: str = "__main__") -> None:
if name == "__main__":
app()
+165
View File
@@ -0,0 +1,165 @@
"""Configuration schema and loading helpers for id-configurator."""
from __future__ import annotations
import base64
import json
from pathlib import Path
from uuid import UUID
import yaml
from pydantic import BaseModel, ConfigDict, Field
class NamedContent(BaseModel):
"""A named piece of content, generally base64-encoded (a certificate, a GPG key, ...)."""
name: str
content: str
def _yes_no(value: bool) -> str:
return "yes" if value else "no"
class AptSource(BaseModel):
"""A single APT source, in the DEB822 format (see sources.list(5))."""
model_config = ConfigDict(populate_by_name=True)
name: str
types: list[str] = Field(default_factory=lambda: ["deb"])
uris: list[str]
suites: list[str]
components: list[str] = Field(default_factory=list)
architectures: list[str] = Field(default_factory=list)
languages: list[str] = Field(default_factory=list)
targets: list[str] = Field(default_factory=list)
enabled: bool = True
trusted: bool | None = None
signed_by: str | None = Field(default=None, alias="signed-by")
pdiffs: bool | None = None
by_hash: bool | None = Field(default=None, alias="by-hash")
allow_insecure: bool | None = Field(default=None, alias="allow-insecure")
allow_weak: bool | None = Field(default=None, alias="allow-weak")
allow_downgrade_to_insecure: bool | None = Field(
default=None, alias="allow-downgrade-to-insecure"
)
check_valid_until: bool | None = Field(default=None, alias="check-valid-until")
valid_until_min: str | None = Field(default=None, alias="valid-until-min")
valid_until_max: str | None = Field(default=None, alias="valid-until-max")
check_date: bool | None = Field(default=None, alias="check-date")
date_max_future: str | None = Field(default=None, alias="date-max-future")
description: str | None = None
def to_deb822(self) -> str:
"""Render this source as a DEB822 stanza (as used in /etc/apt/sources.list.d/*.sources)."""
fields = [
("Types", " ".join(self.types)),
("URIs", " ".join(self.uris)),
("Suites", " ".join(self.suites)),
]
if self.components:
fields.append(("Components", " ".join(self.components)))
if self.architectures:
fields.append(("Architectures", " ".join(self.architectures)))
if self.languages:
fields.append(("Languages", " ".join(self.languages)))
if self.targets:
fields.append(("Targets", " ".join(self.targets)))
if self.signed_by:
fields.append(("Signed-By", self.signed_by))
if self.trusted is not None:
fields.append(("Trusted", _yes_no(self.trusted)))
if self.pdiffs is not None:
fields.append(("PDiffs", _yes_no(self.pdiffs)))
if self.by_hash is not None:
fields.append(("By-Hash", _yes_no(self.by_hash)))
if self.allow_insecure is not None:
fields.append(("Allow-Insecure", _yes_no(self.allow_insecure)))
if self.allow_weak is not None:
fields.append(("Allow-Weak", _yes_no(self.allow_weak)))
if self.allow_downgrade_to_insecure is not None:
fields.append(
(
"Allow-Downgrade-To-Insecure",
_yes_no(self.allow_downgrade_to_insecure),
)
)
if self.check_valid_until is not None:
fields.append(("Check-Valid-Until", _yes_no(self.check_valid_until)))
if self.valid_until_min:
fields.append(("Valid-Until-Min", self.valid_until_min))
if self.valid_until_max:
fields.append(("Valid-Until-Max", self.valid_until_max))
if self.check_date is not None:
fields.append(("Check-Date", _yes_no(self.check_date)))
if self.date_max_future:
fields.append(("Date-Max-Future", self.date_max_future))
if self.description:
fields.append(("Description", self.description))
fields.append(("Enabled", _yes_no(self.enabled)))
return "\n".join(f"{key}: {value}" for key, value in fields) + "\n"
class AptConfig(BaseModel):
sources: list[AptSource] = Field(default_factory=list)
keys: list[NamedContent] = Field(default_factory=list)
class YumRepository(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str
index_url: str = Field(alias="index-url")
gpg_url: str | None = Field(default=None, alias="gpg-url")
class YumConfig(BaseModel):
repositories: list[YumRepository] = Field(default_factory=list)
class PythonRepository(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str
index_url: str = Field(alias="index-url")
class PythonConfig(BaseModel):
repositories: list[PythonRepository] = Field(default_factory=list)
class ConfigurationsBlock(BaseModel):
certificates: list[NamedContent] = Field(default_factory=list)
apt: AptConfig = Field(default_factory=AptConfig)
yum: YumConfig = Field(default_factory=YumConfig)
python: PythonConfig = Field(default_factory=PythonConfig)
class Configuration(BaseModel):
uuid: UUID
organisation: str
description: str = ""
configurations: ConfigurationsBlock = Field(default_factory=ConfigurationsBlock)
def parse_configuration(raw: str) -> Configuration:
"""Parse a configuration from a JSON or YAML string."""
try:
data = json.loads(raw)
except json.JSONDecodeError:
data = yaml.safe_load(raw)
return Configuration.model_validate(data)
def load_configuration(
config_file: Path | None, config_env: str | None
) -> Configuration:
"""Load a configuration from a file, or from a base64-encoded string (typically an env var)."""
if config_file is not None:
raw = config_file.read_text(encoding="utf-8")
elif config_env is not None:
raw = base64.b64decode(config_env).decode("utf-8")
else:
raise ValueError("either --config or --config-env must be provided")
return parse_configuration(raw)
+56
View File
@@ -0,0 +1,56 @@
"""Execution context shared by all modules.
The context is responsible for enforcing that modules never write outside of
the destination directory, and gives read access to the (optional) source
filesystem used to detect the existing configuration.
"""
from __future__ import annotations
from pathlib import Path
from id_configurator.config import Configuration
class Context:
def __init__(
self, config: Configuration, dst: Path, src: Path | None = None
) -> None:
self.config = config
self.dst = dst.resolve()
self.src = src.resolve() if src is not None else None
def dst_path(self, relative: str) -> Path:
"""Resolve a path relative to the destination directory, refusing to escape it."""
path = (self.dst / relative.lstrip("/")).resolve()
if path != self.dst and self.dst not in path.parents:
raise ValueError(
f"refusing to write outside of the destination directory: {relative}"
)
return path
def src_path(self, relative: str) -> Path | None:
"""Resolve a path relative to the source directory, or None if no source was given."""
if self.src is None:
return None
return (self.src / relative.lstrip("/")).resolve()
def write_file(self, relative: str, content: bytes, mode: int = 0o644) -> Path:
path = self.dst_path(relative)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
path.chmod(mode)
return path
def read_src_file(self, relative: str) -> bytes | None:
path = self.src_path(relative)
if path is None or not path.is_file():
return None
return path.read_bytes()
def iter_src_dir(self, relative: str) -> list[Path]:
"""List the files present in a directory relative to the source filesystem, if any."""
path = self.src_path(relative)
if path is None or not path.is_dir():
return []
return sorted(entry for entry in path.iterdir() if entry.is_file())
+8
View File
@@ -0,0 +1,8 @@
"""Configuration modules.
Importing this package registers every built-in module (see `base.register`).
"""
from id_configurator.modules import apt, certificates
__all__ = ["apt", "certificates"]
+100
View File
@@ -0,0 +1,100 @@
"""Detect existing APT sources and configure new ones, in the DEB822 format.
If `<src>/etc/apt` exists, any source file already present there (classic
one-line `.list` files or DEB822 `.sources` files, whether at the top level
or under `sources.list.d/`) is copied into `<dst>/etc/apt`, disabled so it no
longer takes effect, and the sources declared in the configuration are added
as new, enabled `.sources` files.
"""
from __future__ import annotations
import base64
import logging
from pathlib import Path
from id_configurator.context import Context
from id_configurator.modules.base import Module, register
logger = logging.getLogger(__name__)
_APT_DIR = "etc/apt"
_SOURCES_LIST = "etc/apt/sources.list"
_SOURCES_LIST_D = "etc/apt/sources.list.d"
_KEYRINGS_DIR = "etc/apt/keyrings"
def _disable_list_content(content: str) -> str:
lines = [
line if not line.strip() or line.strip().startswith("#") else f"# {line}"
for line in content.splitlines()
]
return "\n".join(lines) + "\n"
def _disable_deb822_content(content: str) -> str:
disabled_stanzas = []
for stanza in content.split("\n\n"):
if not stanza.strip():
continue
lines = stanza.splitlines()
new_lines = [line for line in lines if not line.lower().startswith("enabled:")]
new_lines.append("Enabled: no")
disabled_stanzas.append("\n".join(new_lines))
return "\n\n".join(disabled_stanzas) + "\n"
@register
class AptModule(Module):
name = "apt"
def is_relevant(self, context: Context) -> bool:
return self._src_apt_dir(context) is not None
@staticmethod
def _src_apt_dir(context: Context) -> Path | None:
path = context.src_path(_APT_DIR)
if path is not None and path.is_dir():
return path
return None
def apply(self, context: Context) -> None:
self._copy_existing_disabled(context)
self._write_configured_keys(context)
self._write_configured_sources(context)
@staticmethod
def _copy_existing_disabled(context: Context) -> None:
existing_top_level = context.read_src_file(_SOURCES_LIST)
if existing_top_level is not None:
disabled = _disable_list_content(existing_top_level.decode("utf-8"))
context.write_file(_SOURCES_LIST, disabled.encode("utf-8"))
logger.info("disabled existing apt source list %s", _SOURCES_LIST)
for entry in context.iter_src_dir(_SOURCES_LIST_D):
if entry.suffix == ".sources":
disabled = _disable_deb822_content(entry.read_text(encoding="utf-8"))
elif entry.suffix == ".list":
disabled = _disable_list_content(entry.read_text(encoding="utf-8"))
else:
continue
context.write_file(
f"{_SOURCES_LIST_D}/{entry.name}", disabled.encode("utf-8")
)
logger.info("disabled existing apt source %s", entry.name)
@staticmethod
def _write_configured_keys(context: Context) -> None:
for key in context.config.configurations.apt.keys:
content = base64.b64decode(key.content)
context.write_file(f"{_KEYRINGS_DIR}/{key.name}.asc", content)
logger.info("installed apt keyring %s", key.name)
@staticmethod
def _write_configured_sources(context: Context) -> None:
for source in context.config.configurations.apt.sources:
context.write_file(
f"{_SOURCES_LIST_D}/{source.name}.sources",
source.to_deb822().encode("utf-8"),
)
logger.info("added apt source %s", source.name)
+36
View File
@@ -0,0 +1,36 @@
"""Base class and registry for configuration modules.
Each module inspects the configuration and the source filesystem (via the
`Context`) to decide whether it applies, then writes the resulting files to
the destination directory.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from id_configurator.context import Context
_REGISTRY: list[type[Module]] = []
class Module(ABC):
name: str
@abstractmethod
def is_relevant(self, context: Context) -> bool:
"""Return True if this module has something to do for the given configuration."""
@abstractmethod
def apply(self, context: Context) -> None:
"""Write the module's changes to the destination directory."""
def register(module_cls: type[Module]) -> type[Module]:
"""Class decorator registering a module so it is picked up by the CLI."""
_REGISTRY.append(module_cls)
return module_cls
def all_modules() -> list[type[Module]]:
return list(_REGISTRY)
+81
View File
@@ -0,0 +1,81 @@
"""Install root certificates into the system's CA store.
Certificates from the configuration are dropped into the "local" CA
directory, then the real `update-ca-certificates` binary is invoked to
rebuild the trust store (bundle + hashed symlinks) exactly like it would on
a real Debian/Ubuntu system, instead of reimplementing that logic here.
"""
from __future__ import annotations
import base64
import logging
import subprocess
from id_configurator.context import Context
from id_configurator.modules.base import Module, register
logger = logging.getLogger(__name__)
_LOCAL_CERTS_DIR = "usr/local/share/ca-certificates"
_CERTSCONF = "etc/ca-certificates.conf"
_CERTSDIR = "usr/share/ca-certificates"
_ETCCERTSDIR = "etc/ssl/certs"
@register
class CertificatesModule(Module):
name = "certificates"
def is_relevant(self, context: Context) -> bool:
return bool(context.config.configurations.certificates)
def apply(self, context: Context) -> None:
configured_names = {
certificate.name
for certificate in context.config.configurations.certificates
}
for existing in context.iter_src_dir(_LOCAL_CERTS_DIR):
if existing.stem not in configured_names:
context.write_file(
f"{_LOCAL_CERTS_DIR}/{existing.name}", existing.read_bytes()
)
for certificate in context.config.configurations.certificates:
content = base64.b64decode(certificate.content)
context.write_file(f"{_LOCAL_CERTS_DIR}/{certificate.name}.crt", content)
logger.info("installed root certificate %s", certificate.name)
self._update_ca_certificates_conf(context)
@staticmethod
def _update_ca_certificates_conf(context: Context) -> None:
etccertsdir = context.dst_path(_ETCCERTSDIR)
localcertsdir = context.dst_path(_LOCAL_CERTS_DIR)
etccertsdir.mkdir(parents=True, exist_ok=True)
localcertsdir.mkdir(parents=True, exist_ok=True)
args = [
"update-ca-certificates",
"--etccertsdir",
str(etccertsdir),
"--localcertsdir",
str(localcertsdir),
]
certsconf = context.src_path(_CERTSCONF)
if certsconf is not None and certsconf.is_file():
args += ["--certsconf", str(certsconf)]
certsdir = context.src_path(_CERTSDIR)
if certsdir is not None and certsdir.is_dir():
args += ["--certsdir", str(certsdir)]
logger.debug("running: %s", " ".join(args))
try:
result = subprocess.run(args, check=True, capture_output=True, text=True)
except FileNotFoundError as exc:
raise RuntimeError(
"update-ca-certificates is not available; the ca-certificates package must be installed "
"in the environment running id-configurator"
) from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"update-ca-certificates failed:\n{exc.stderr}") from exc
if result.stderr:
logger.debug("%s", result.stderr.strip())