mirror of
https://github.com/d9pouces/id_configurator.git
synced 2026-11-09 01:47:21 +01:00
bf56cdfcd2
Signed-off-by: d9pouces <github@19pouces.net>
392 lines
14 KiB
Python
392 lines
14 KiB
Python
"""Configuration schema and loading helpers for id-configurator."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import ssl
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
import yaml
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
_CONFIG_URL_TIMEOUT_SECONDS = 30
|
|
|
|
|
|
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"
|
|
|
|
|
|
def _fold_deb822_multiline(field: str, value: str) -> str:
|
|
"""Render `field` as a DEB822 folded multi-line field (sources.list(5)):
|
|
each of `value`'s own lines indented by one space, with a truly blank
|
|
line represented as a lone `.` - exactly how an ASCII-armored GPG key
|
|
block is embedded directly in a `Signed-By` field, verified against a
|
|
real `apt-get update` run against a genuinely signed repository."""
|
|
folded = "\n".join(f" {line}" if line else " ." for line in value.splitlines())
|
|
return f"{field}:\n{folded}"
|
|
|
|
|
|
class AptSource(BaseModel):
|
|
"""A single APT source, in the DEB822 format (see sources.list(5)).
|
|
|
|
Two ways to tell APT which key to trust this source with:
|
|
- `signed_by`: the ASCII-armored (.asc) *content* of a GPG public key,
|
|
folded inline as a multi-line `Signed-By` field - no separate keyring
|
|
file, no `gpg` binary needed to apply this configuration.
|
|
- `signed_by_file`: the *path* to a keyring already expected to exist on
|
|
the target system, written to the rendered `Signed-By` field verbatim.
|
|
If both are set, `signed_by` takes precedence.
|
|
|
|
`uri_to_replace`, if set, is an fnmatch pattern: this source is only
|
|
applied if it matches at least one URI already present in the machine's
|
|
existing apt configuration; otherwise it is skipped entirely.
|
|
"""
|
|
|
|
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")
|
|
signed_by_file: str | None = Field(default=None, alias="signed-by-file")
|
|
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
|
|
uri_to_replace: str | None = Field(default=None, alias="uri-to-replace")
|
|
|
|
def to_deb822(
|
|
self,
|
|
signed_by_armored_key: str | None = None,
|
|
signed_by_path: str | None = None,
|
|
) -> str:
|
|
"""Render this source as a DEB822 stanza (as used in
|
|
/etc/apt/sources.list.d/*.sources).
|
|
|
|
Neither `self.signed_by` nor `self.signed_by_file` is ever rendered
|
|
directly - the caller (the `apt` module) resolves which one applies
|
|
and passes the result in: `signed_by_armored_key` is folded inline
|
|
as a multi-line `Signed-By` field (see `_fold_deb822_multiline`);
|
|
`signed_by_path` is written as a plain single-line `Signed-By`
|
|
field. If both are given, `signed_by_armored_key` takes precedence.
|
|
"""
|
|
lines = [
|
|
f"Types: {' '.join(self.types)}",
|
|
f"URIs: {' '.join(self.uris)}",
|
|
f"Suites: {' '.join(self.suites)}",
|
|
]
|
|
if self.components:
|
|
lines.append(f"Components: {' '.join(self.components)}")
|
|
if self.architectures:
|
|
lines.append(f"Architectures: {' '.join(self.architectures)}")
|
|
if self.languages:
|
|
lines.append(f"Languages: {' '.join(self.languages)}")
|
|
if self.targets:
|
|
lines.append(f"Targets: {' '.join(self.targets)}")
|
|
if signed_by_armored_key:
|
|
lines.append(_fold_deb822_multiline("Signed-By", signed_by_armored_key))
|
|
elif signed_by_path:
|
|
lines.append(f"Signed-By: {signed_by_path}")
|
|
if self.trusted is not None:
|
|
lines.append(f"Trusted: {_yes_no(self.trusted)}")
|
|
if self.pdiffs is not None:
|
|
lines.append(f"PDiffs: {_yes_no(self.pdiffs)}")
|
|
if self.by_hash is not None:
|
|
lines.append(f"By-Hash: {_yes_no(self.by_hash)}")
|
|
if self.allow_insecure is not None:
|
|
lines.append(f"Allow-Insecure: {_yes_no(self.allow_insecure)}")
|
|
if self.allow_weak is not None:
|
|
lines.append(f"Allow-Weak: {_yes_no(self.allow_weak)}")
|
|
if self.allow_downgrade_to_insecure is not None:
|
|
lines.append(
|
|
f"Allow-Downgrade-To-Insecure: {_yes_no(self.allow_downgrade_to_insecure)}"
|
|
)
|
|
if self.check_valid_until is not None:
|
|
lines.append(f"Check-Valid-Until: {_yes_no(self.check_valid_until)}")
|
|
if self.valid_until_min:
|
|
lines.append(f"Valid-Until-Min: {self.valid_until_min}")
|
|
if self.valid_until_max:
|
|
lines.append(f"Valid-Until-Max: {self.valid_until_max}")
|
|
if self.check_date is not None:
|
|
lines.append(f"Check-Date: {_yes_no(self.check_date)}")
|
|
if self.date_max_future:
|
|
lines.append(f"Date-Max-Future: {self.date_max_future}")
|
|
if self.description:
|
|
lines.append(f"Description: {self.description}")
|
|
lines.append(f"Enabled: {_yes_no(self.enabled)}")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
class AptConfig(BaseModel):
|
|
sources: list[AptSource] = Field(default_factory=list)
|
|
|
|
|
|
class ApkRepository(BaseModel):
|
|
"""An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community.
|
|
|
|
`url_to_replace`, if set, is an fnmatch pattern: this repository is only
|
|
applied if it matches at least one URL already present in the machine's
|
|
existing apk configuration; otherwise it is skipped entirely.
|
|
|
|
`key_filename`, if set, names the file the `key` is written to under
|
|
`/etc/apk/keys/` instead of a name derived from the key's content. It
|
|
must be a single, valid filesystem path component (in particular, no
|
|
`/`); if it isn't, it is ignored and the derived name is used instead.
|
|
"""
|
|
|
|
name: str
|
|
url: str
|
|
key: str | None = None
|
|
key_filename: str | None = None
|
|
url_to_replace: str | None = None
|
|
|
|
|
|
class ApkConfig(BaseModel):
|
|
repositories: list[ApkRepository] = Field(default_factory=list)
|
|
|
|
|
|
class YumRepository(BaseModel):
|
|
"""A YUM/DNF repository (see /etc/yum.repos.d/*.repo).
|
|
|
|
`index_url_to_replace`, if set, is an fnmatch pattern: this repository is
|
|
only applied if it matches at least one baseurl already present in the
|
|
machine's existing yum/dnf configuration; otherwise it is skipped
|
|
entirely.
|
|
"""
|
|
|
|
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")
|
|
index_url_to_replace: str | None = Field(default=None, alias="index-url-to-replace")
|
|
|
|
|
|
class YumConfig(BaseModel):
|
|
repositories: list[YumRepository] = Field(default_factory=list)
|
|
keys: list[NamedContent] = 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 JavaRepository(BaseModel):
|
|
"""A Maven-compatible repository, shared by Maven, Gradle, Ivy, SBT and Leiningen."""
|
|
|
|
name: str
|
|
url: str
|
|
|
|
|
|
class JavaConfig(BaseModel):
|
|
repositories: list[JavaRepository] = Field(default_factory=list)
|
|
|
|
|
|
class PhpRepository(BaseModel):
|
|
"""A Composer repository (see the "repositories" key in composer.json)."""
|
|
|
|
name: str
|
|
url: str
|
|
|
|
|
|
class PhpConfig(BaseModel):
|
|
repositories: list[PhpRepository] = Field(default_factory=list)
|
|
|
|
|
|
class NpmRepository(BaseModel):
|
|
"""An npm registry. The first entry replaces the default registry; any
|
|
further ones are scoped to `name` (`@name:registry`), e.g. for a private
|
|
org's packages alongside the default public registry."""
|
|
|
|
name: str
|
|
url: str
|
|
|
|
|
|
class NpmConfig(BaseModel):
|
|
repositories: list[NpmRepository] = Field(default_factory=list)
|
|
|
|
|
|
class RustRepository(BaseModel):
|
|
"""A cargo registry (see the "source" tables in .cargo/config.toml).
|
|
|
|
`is_sparse`, if true, marks the registry as served over the sparse
|
|
HTTP protocol (its index is then declared as `sparse+<url>`) rather
|
|
than a git-based index.
|
|
"""
|
|
|
|
name: str
|
|
url: str
|
|
is_sparse: bool = False
|
|
|
|
|
|
class RustConfig(BaseModel):
|
|
repositories: list[RustRepository] = Field(default_factory=list)
|
|
|
|
|
|
class GoRepository(BaseModel):
|
|
"""A Go module proxy (see GOPROXY), served over the standard Go module
|
|
proxy protocol."""
|
|
|
|
name: str
|
|
url: str
|
|
|
|
|
|
class GoConfig(BaseModel):
|
|
repositories: list[GoRepository] = Field(default_factory=list)
|
|
|
|
|
|
class RubyRepository(BaseModel):
|
|
"""A RubyGems source (see the ":sources:" key in gemrc)."""
|
|
|
|
name: str
|
|
url: str
|
|
|
|
|
|
class RubyConfig(BaseModel):
|
|
repositories: list[RubyRepository] = Field(default_factory=list)
|
|
|
|
|
|
class NuGetRepository(BaseModel):
|
|
"""A NuGet package source (see the "packageSources" key in NuGet.Config)."""
|
|
|
|
name: str
|
|
url: str
|
|
|
|
|
|
class NuGetConfig(BaseModel):
|
|
repositories: list[NuGetRepository] = Field(default_factory=list)
|
|
|
|
|
|
class ConfigurationsBlock(BaseModel):
|
|
certificates: list[NamedContent] = Field(default_factory=list)
|
|
apt: AptConfig = Field(default_factory=AptConfig)
|
|
apk: ApkConfig = Field(default_factory=ApkConfig)
|
|
yum: YumConfig = Field(default_factory=YumConfig)
|
|
python: PythonConfig = Field(default_factory=PythonConfig)
|
|
java: JavaConfig = Field(default_factory=JavaConfig)
|
|
php: PhpConfig = Field(default_factory=PhpConfig)
|
|
npm: NpmConfig = Field(default_factory=NpmConfig)
|
|
rust: RustConfig = Field(default_factory=RustConfig)
|
|
go: GoConfig = Field(default_factory=GoConfig)
|
|
ruby: RubyConfig = Field(default_factory=RubyConfig)
|
|
nuget: NuGetConfig = Field(default_factory=NuGetConfig)
|
|
|
|
|
|
class Configuration(BaseModel):
|
|
"""`version` is the configuration format's own schema version (currently
|
|
"1.0"), independent of the id-configurator package version - it lets a
|
|
future, incompatible format change be told apart from this one. Omitting
|
|
it defaults to "1.0", the only version there currently is."""
|
|
|
|
version: str = "1.0"
|
|
uuid: UUID | None = None
|
|
organisation: str | None = None
|
|
description: str | None = None
|
|
configurations: ConfigurationsBlock = Field(default_factory=ConfigurationsBlock)
|
|
|
|
@field_validator("version", mode="before")
|
|
@classmethod
|
|
def _coerce_version_to_str(cls, value: object) -> object:
|
|
"""Accept a bare YAML number (`version: 1.0`) as well as a quoted
|
|
string (`version: "1.0"`): YAML parses the unquoted, natural-looking
|
|
form as a float, which would otherwise fail validation with a
|
|
confusing type error."""
|
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
return str(value)
|
|
return value
|
|
|
|
|
|
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 _fetch_config_url(
|
|
url: str, cacert_content: str | None, insecure: bool = False
|
|
) -> str:
|
|
"""Download a JSON or YAML configuration from `url`.
|
|
|
|
If `cacert_content` is given, it - and only it, not the system's default
|
|
trust store - is used to verify an HTTPS connection: for an internal
|
|
endpoint signed by a private CA the running system doesn't otherwise
|
|
trust yet (e.g. before the `certificates` module has ever run).
|
|
|
|
If `insecure` is set instead, certificate verification (and hostname
|
|
checking) is disabled entirely - e.g. to bootstrap from a server whose
|
|
certificate isn't trusted, or trustable, at all yet. `cacert_content` is
|
|
then ignored: there would be nothing left to verify it against.
|
|
"""
|
|
if insecure:
|
|
# Explicitly requested by the operator via --config-url-insecure.
|
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
context.check_hostname = False
|
|
context.verify_mode = ssl.CERT_NONE
|
|
else:
|
|
context = ssl.create_default_context(
|
|
cadata=cacert_content if cacert_content is not None else None
|
|
)
|
|
with urllib.request.urlopen( # nosec B310 (url is an operator-provided CLI argument)
|
|
url, timeout=_CONFIG_URL_TIMEOUT_SECONDS, context=context
|
|
) as response:
|
|
return response.read().decode("utf-8")
|
|
|
|
|
|
def load_configuration(
|
|
config_file: Path | None,
|
|
config_env: str | None,
|
|
config_url: str | None = None,
|
|
config_url_cacert_content: str | None = None,
|
|
config_url_insecure: bool = False,
|
|
) -> Configuration:
|
|
"""Load a configuration from a file, from a base64-encoded string
|
|
(typically an env var), or by downloading it from a URL."""
|
|
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")
|
|
elif config_url is not None:
|
|
raw = _fetch_config_url(
|
|
config_url, config_url_cacert_content, config_url_insecure
|
|
)
|
|
else:
|
|
raise ValueError(
|
|
"either --config, --config-env or --config-url must be provided"
|
|
)
|
|
return parse_configuration(raw)
|