mirror of
https://github.com/d9pouces/id_configurator.git
synced 2026-11-09 01:47:21 +01:00
feat: initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from id_configurator.cli import app, main
|
||||
|
||||
__all__ = ["app", "main"]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from id_configurator.cli import main
|
||||
|
||||
main(__name__)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user