diff --git a/.github/workflows/ci-code-quality.yaml b/.github/workflows/ci-code-quality.yaml new file mode 100644 index 0000000..2ea80d0 --- /dev/null +++ b/.github/workflows/ci-code-quality.yaml @@ -0,0 +1,32 @@ +name: 🔍 Check code quality + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + quality: + name: 🔍 Code Quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - run: uv sync --group dev + + - name: Check formatting + run: uv run ruff format --check . + + - name: Lint code + run: uv run ruff check . + + - name: Type check + run: uv run ty check diff --git a/.github/workflows/create-tag.yaml b/.github/workflows/create-tag.yaml new file mode 100644 index 0000000..3025aa0 --- /dev/null +++ b/.github/workflows/create-tag.yaml @@ -0,0 +1,95 @@ +name: 🚀 Tagged Release + +on: + push: + tags: + - "v*.*.*" # Semantic version tags: v1.2.3 + +permissions: + contents: read + id-token: write + attestations: write + +jobs: + build: + name: 📦 Build distribution + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: astral-sh/setup-uv@v7 + + - run: uv build + + - name: Upload distribution artifacts + uses: actions/upload-artifact@v7 + with: + name: python-package-distributions + path: dist/ + + attest-artifacts: + name: 🔐 Generate artifact attestations + needs: build + runs-on: ubuntu-latest + + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v8 + with: + name: python-package-distributions + path: dist/ + + - name: Generate artifact attestations + uses: actions/attest-build-provenance@v4 + with: + subject-path: dist/* + + publish-pypi: + name: 📤 Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/netloom/ + permissions: + id-token: write + + steps: + - uses: astral-sh/setup-uv@v7 + + - name: Download distribution artifacts + uses: actions/download-artifact@v8 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to PyPI + run: uv publish --trusted-publishing always + + create-release: + name: 📋 Create GitHub Release + needs: [publish-pypi, attest-artifacts] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v6 + + - name: Download distribution artifacts + uses: actions/download-artifact@v8 + with: + name: python-package-distributions + path: dist/ + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.ref_name }} + run: | + gh release create $TAG_NAME \ + --title "🚀 Release $TAG_NAME" \ + --verify-tag \ + --generate-notes \ + dist/* diff --git a/.gitignore b/.gitignore index 4bad9b6..6a23434 100644 --- a/.gitignore +++ b/.gitignore @@ -199,7 +199,7 @@ cython_debug/ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore # and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder -# .vscode/ +.vscode/ # Ruff stuff: .ruff_cache/ @@ -213,4 +213,9 @@ marimo/_lsp/ __marimo__/ # Streamlit -.streamlit/secrets.toml +.streamlit/secrets.toml + +# Repository specific (inventories) +nullforge/inventories/* +!nullforge/inventories/example.py +!nullforge/inventories/README.md diff --git a/nullforge/foundry/README.md b/nullforge/foundry/README.md new file mode 100644 index 0000000..e69de29 diff --git a/nullforge/foundry/full_cast.py b/nullforge/foundry/full_cast.py new file mode 100644 index 0000000..6932d79 --- /dev/null +++ b/nullforge/foundry/full_cast.py @@ -0,0 +1,51 @@ +from pyinfra import local +from pyinfra.context import host + +from nullforge.models.dns import DnsMode +from nullforge.molds.utils import ensure_features, ensure_system + + +def cast_full() -> None: + """Cast the full NullForge's deployment blueprint.""" + + host.data.features = ensure_features(getattr(host.data, "features", None)) + host.data.system = ensure_system(getattr(host.data, "system", None)) + + local.include("nullforge/runes/prepare.py") + + local.include("nullforge/runes/base.py") + + if host.data.features.users.manage: + local.include("nullforge/runes/users.py") + + local.include("nullforge/runes/netsec.py") + + if host.data.features.profiles.for_root or host.data.features.profiles.for_user: + local.include("nullforge/runes/profiles.py") + + if host.data.features.dns.mode != DnsMode.NONE: + local.include("nullforge/runes/dns.py") + + if host.data.features.warp.install: + local.include("nullforge/runes/warp.py") + + if host.data.features.zerotrust.install: + local.include("nullforge/runes/zerotrust.py") + + if host.data.features.haproxy.install: + local.include("nullforge/runes/haproxy.py") + + if host.data.features.containers.install: + local.include("nullforge/runes/containers.py") + + if host.data.features.tor.install: + local.include("nullforge/runes/tor.py") + + if host.data.features.xray.install: + local.include("nullforge/runes/xray.py") + + if host.data.features.mtproto.install: + local.include("nullforge/runes/mtproto.py") + + +cast_full() diff --git a/nullforge/inventories/README.md b/nullforge/inventories/README.md new file mode 100644 index 0000000..e69de29 diff --git a/nullforge/inventories/example.py b/nullforge/inventories/example.py new file mode 100644 index 0000000..b0a08f4 --- /dev/null +++ b/nullforge/inventories/example.py @@ -0,0 +1,56 @@ +from nullforge.models.dns import DnsMode +from nullforge.models.users import Shell +from nullforge.molds import DnsMold, UserMold, WarpMold +from nullforge.molds.base import BASE_FEATURES, BASE_SYSTEM +from nullforge.molds.utils import merge_features, merge_system + + +users = UserMold( + manage=True, + name="example", + shell=Shell.ZSH, +) +"""User configuration preset +with user management enabled and the user "example". +with shell set to ZSH (default behavior). +""" + +warp = WarpMold( + install=True, + iface="warp-example", +) +"""WARP configuration preset +setup Cloudflare WARP +with default MASQUE engine and interface "warp-example". +""" + +dns = DnsMold( + mode=DnsMode.DOH_RAW, +) +"""DNS configuration preset +with DNS over HTTPS raw mode. +""" + +overrides = ( + users, + warp, + dns, +) +"""Wrappers for the features to be merged with the base features.""" + +hosts = [ + ( + "203.0.113.10", + { + "system": merge_system(BASE_SYSTEM, {"hostname": "example-node1.local"}), + "features": merge_features(BASE_FEATURES, *overrides), + }, + ), + ( + "203.0.113.20", + { + "system": merge_system(BASE_SYSTEM, {"hostname": "example-node2.local"}), + "features": merge_features(BASE_FEATURES, *overrides), + }, + ), +] diff --git a/nullforge/models/__init__.py b/nullforge/models/__init__.py new file mode 100644 index 0000000..fcd1969 --- /dev/null +++ b/nullforge/models/__init__.py @@ -0,0 +1 @@ +"""Internal models for NullForge.""" diff --git a/nullforge/models/containers.py b/nullforge/models/containers.py new file mode 100644 index 0000000..7ed1493 --- /dev/null +++ b/nullforge/models/containers.py @@ -0,0 +1,58 @@ +"""Containers configuration models.""" + +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import BaseModel, Field + + +class ContainersBackendType(StrEnum): + DOCKER = "docker" + PODMAN = "podman" + CRIO = "crio" + + +class ContainersRuntimeType(StrEnum): + DEFAULT = "default" + CRUN = "crun" + GVISOR = "gvisor" + + +class _ContainersBackendBase(BaseModel): + """Base for a containers backend.""" + + type: ContainersBackendType = Field(description="The type of containers backend") + runtime: ContainersRuntimeType = Field(description="The type of containers runtime") + + +class DockerContainersBackend(_ContainersBackendBase): + type: Literal[ContainersBackendType.DOCKER] = ContainersBackendType.DOCKER + runtime: Literal[ContainersRuntimeType.GVISOR] = ContainersRuntimeType.GVISOR + + +class PodmanContainersBackend(_ContainersBackendBase): + type: Literal[ContainersBackendType.PODMAN] = ContainersBackendType.PODMAN + runtime: Literal[ContainersRuntimeType.CRUN] = ContainersRuntimeType.CRUN + + +class CrioContainersBackend(_ContainersBackendBase): + type: Literal[ContainersBackendType.CRIO] = ContainersBackendType.CRIO + runtime: Literal[ContainersRuntimeType.DEFAULT] = ContainersRuntimeType.DEFAULT + + +ContainersBackend = Annotated[ + DockerContainersBackend | PodmanContainersBackend | CrioContainersBackend, + Field(discriminator="type"), +] + + +def containers_backend_factory(type: ContainersBackendType) -> ContainersBackend: + """Factory function for containers backends.""" + + match type: + case ContainersBackendType.DOCKER: + return DockerContainersBackend() + case ContainersBackendType.PODMAN: + return PodmanContainersBackend() + case ContainersBackendType.CRIO: + return CrioContainersBackend() diff --git a/nullforge/models/dns.py b/nullforge/models/dns.py new file mode 100644 index 0000000..2a5a287 --- /dev/null +++ b/nullforge/models/dns.py @@ -0,0 +1,234 @@ +"""DNS configuration models.""" + +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import AnyHttpUrl, BaseModel, Field, IPvAnyAddress, field_validator + + +class DnsProtocol(StrEnum): + DOU = "dou" + DOT = "dot" + DOH = "doh" + + +class DnsMode(StrEnum): + DOU = "dou" + DOT_RESOLVED = "dot_resolved" + DOH_RESOLVED = "doh_resolved" + DOH_RAW = "doh_raw" + NONE = "none" + + +class DnsProvider(StrEnum): + CLOUDFLARE = "cloudflare" + GOOGLE = "google" + QUAD9 = "quad9" + + +class _DnsServerBase(BaseModel): + """Base for a DNS server.""" + + protocol: DnsProtocol + + +class DnsServerDoU(_DnsServerBase): + protocol: Literal[DnsProtocol.DOU] = DnsProtocol.DOU + host: IPvAnyAddress | str = Field(description="Resolver hostname or IP") + port: int = Field(default=53, ge=1, le=65535, description="UDP port") + + +class DnsServerDoH(_DnsServerBase): + protocol: Literal[DnsProtocol.DOH] = DnsProtocol.DOH + url: AnyHttpUrl = Field(description="HTTPS endpoint for DNS over HTTPS (RFC 8484).") + + @field_validator("url") + @classmethod + def _require_https(cls, v: AnyHttpUrl) -> AnyHttpUrl: + """Enforce HTTPS.""" + + if v.scheme != "https": + raise ValueError("DoH endpoint must use HTTPS") + return v + + +class DnsServerDoT(_DnsServerBase): + protocol: Literal[DnsProtocol.DOT] = DnsProtocol.DOT + host: IPvAnyAddress | str = Field(description="Resolver hostname or IP") + port: int = Field(default=853, ge=1, le=65535, description="TLS port") + sni: str | None = Field(default=None, description="Optional SNI/hostname for TLS verification") + + +DnsServer = Annotated[ + DnsServerDoH | DnsServerDoT | DnsServerDoU, + Field(discriminator="protocol"), +] + + +class DnsProviders: + """DNS providers.""" + + @staticmethod + def cloudflare_doh(ipv6: bool) -> list[DnsServer]: + """Cloudflare DoH upstreams.""" + + ups: list[DnsServer] = [ + DnsServerDoH(url="https://1.1.1.1/dns-query"), # ty:ignore[invalid-argument-type] + DnsServerDoH(url="https://1.0.0.1/dns-query"), # ty:ignore[invalid-argument-type] + ] + if ipv6: + ups.extend( + [ + DnsServerDoH(url="https://[2606:4700:4700::1111]/dns-query"), # ty:ignore[invalid-argument-type] + DnsServerDoH(url="https://[2606:4700:4700::1001]/dns-query"), # ty:ignore[invalid-argument-type] + ] + ) + return ups + + @staticmethod + def cloudflare_dot(ipv6: bool) -> list[DnsServer]: + """Cloudflare DoT upstreams.""" + + ups: list[DnsServer] = [ + DnsServerDoT(host="1.1.1.1", sni="cloudflare-dns.com"), + DnsServerDoT(host="1.0.0.1", sni="cloudflare-dns.com"), + ] + if ipv6: + ups.extend( + [ + DnsServerDoT(host="2606:4700:4700::1111", sni="cloudflare-dns.com"), + DnsServerDoT(host="2606:4700:4700::1001", sni="cloudflare-dns.com"), + ] + ) + return ups + + @staticmethod + def google_doh(ipv6: bool) -> list[DnsServer]: + """Google DoH upstreams.""" + + ups: list[DnsServer] = [ + DnsServerDoH(url="https://8.8.8.8/dns-query"), # ty:ignore[invalid-argument-type] + DnsServerDoH(url="https://8.8.4.4/dns-query"), # ty:ignore[invalid-argument-type] + ] + if ipv6: + ups.extend( + [ + DnsServerDoH(url="https://[2001:4860:4860::8888]/dns-query"), # ty:ignore[invalid-argument-type] + DnsServerDoH(url="https://[2001:4860:4860::8844]/dns-query"), # ty:ignore[invalid-argument-type] + ] + ) + return ups + + @staticmethod + def google_dot(ipv6: bool) -> list[DnsServer]: + """Google DoT upstreams.""" + + ups: list[DnsServer] = [ + DnsServerDoT(host="8.8.8.8", sni="dns.google"), + DnsServerDoT(host="8.8.4.4", sni="dns.google"), + ] + if ipv6: + ups.extend( + [ + DnsServerDoT(host="2001:4860:4860::8888", sni="dns.google"), + DnsServerDoT(host="2001:4860:4860::8844", sni="dns.google"), + ] + ) + return ups + + @staticmethod + def quad9_doh(ipv6: bool, ecs: bool = False) -> list[DnsServer]: + """Quad9 DoH upstreams.""" + + if ecs: + primary_ipv4 = "https://9.9.9.12/dns-query" + secondary_ipv4 = "https://149.112.112.12/dns-query" + primary_ipv6 = "https://[2620:fe::12]/dns-query" + secondary_ipv6 = "https://[2620:fe::fe:12]/dns-query" + else: + primary_ipv4 = "https://9.9.9.10/dns-query" + secondary_ipv4 = "https://149.112.112.10/dns-query" + primary_ipv6 = "https://[2620:fe::10]/dns-query" + secondary_ipv6 = "https://[2620:fe::fe:10]/dns-query" + + ups: list[DnsServer] = [ + DnsServerDoH(url=primary_ipv4), # ty:ignore[invalid-argument-type] + DnsServerDoH(url=secondary_ipv4), # ty:ignore[invalid-argument-type] + ] + if ipv6: + ups.extend( + [ + DnsServerDoH(url=primary_ipv6), # ty:ignore[invalid-argument-type] + DnsServerDoH(url=secondary_ipv6), # ty:ignore[invalid-argument-type] + ] + ) + + return ups + + @staticmethod + def quad9_dot(ipv6: bool, ecs: bool = False) -> list[DnsServer]: + """Quad9 DoT upstreams.""" + + if ecs: + sni = "dns11.quad9.net" + primary_ipv4 = "9.9.9.12" + secondary_ipv4 = "149.112.112.12" + primary_ipv6 = "2620:fe::12" + secondary_ipv6 = "2620:fe::fe:12" + else: + sni = "dns10.quad9.net" + primary_ipv4 = "9.9.9.10" + secondary_ipv4 = "149.112.112.10" + primary_ipv6 = "2620:fe::10" + secondary_ipv6 = "2620:fe::fe:10" + + ups: list[DnsServer] = [ + DnsServerDoT(host=primary_ipv4, sni=sni), + DnsServerDoT(host=secondary_ipv4, sni=sni), + ] + if ipv6: + ups.extend( + [ + DnsServerDoT(host=primary_ipv6, sni=sni), + DnsServerDoT(host=secondary_ipv6, sni=sni), + ] + ) + + return ups + + @staticmethod + def get_upstreams( + provider: DnsProvider, + protocol: DnsProtocol, + ipv6: bool, + ecs: bool = False, + ) -> list[DnsServer]: + """Get upstream DNS servers for a provider and protocol.""" + + match provider: + case DnsProvider.CLOUDFLARE: + if protocol == DnsProtocol.DOH: + return DnsProviders.cloudflare_doh(ipv6) + elif protocol == DnsProtocol.DOT: + return DnsProviders.cloudflare_dot(ipv6) + else: + raise ValueError(f"Unsupported protocol {protocol} for Cloudflare") + case DnsProvider.GOOGLE: + if protocol == DnsProtocol.DOH: + return DnsProviders.google_doh(ipv6) + elif protocol == DnsProtocol.DOT: + return DnsProviders.google_dot(ipv6) + else: + raise ValueError(f"Unsupported protocol {protocol} for Google") + case DnsProvider.QUAD9: + if protocol == DnsProtocol.DOH: + return DnsProviders.quad9_doh(ipv6, ecs) + elif protocol == DnsProtocol.DOT: + return DnsProviders.quad9_dot(ipv6, ecs) + else: + raise ValueError(f"Unsupported protocol {protocol} for Quad9") + case _: + raise ValueError(f"Unknown provider: {provider}") + + +dns_providers = DnsProviders() diff --git a/nullforge/models/mtproto.py b/nullforge/models/mtproto.py new file mode 100644 index 0000000..78b4e81 --- /dev/null +++ b/nullforge/models/mtproto.py @@ -0,0 +1,8 @@ +"""MTProto provider models.""" + +from enum import StrEnum + + +class MtprotoProvider(StrEnum): + MTG = "mtg" + TELEMT = "telemt" diff --git a/nullforge/models/system.py b/nullforge/models/system.py new file mode 100644 index 0000000..be67d43 --- /dev/null +++ b/nullforge/models/system.py @@ -0,0 +1,15 @@ +from enum import StrEnum + + +class SwapType(StrEnum): + """Type of swap configuration.""" + + BASIC = "basic" + ZRAM = "zram" + + +class SwapAlgo(StrEnum): + """Algorithm for ZRAM compression.""" + + ZSTD = "zstd" + LZO = "lzo" diff --git a/nullforge/models/users.py b/nullforge/models/users.py new file mode 100644 index 0000000..8b7a2c1 --- /dev/null +++ b/nullforge/models/users.py @@ -0,0 +1,9 @@ +"""User configuration models.""" + +from enum import StrEnum + + +class Shell(StrEnum): + BASH = "/bin/bash" + ZSH = "/bin/zsh" + ZSH_USER = "/usr/bin/zsh" diff --git a/nullforge/models/warp.py b/nullforge/models/warp.py new file mode 100644 index 0000000..e6024d5 --- /dev/null +++ b/nullforge/models/warp.py @@ -0,0 +1,66 @@ +"""WARP configuration models.""" + +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import BaseModel, Field + + +class WarpEngineType(StrEnum): + WIREGUARD = "wireguard" + MASQUE = "masque" + + +class _WarpEngineBase(BaseModel): + """Base for a WARP engine.""" + + type: WarpEngineType = Field(description="The type of WARP engine") + binary_path: str = Field(description="The path to the WARP binary") + config_dir: str = Field(description="The path to the WARP configuration directory") + systemd_service_name: str = Field(description="The name of the systemd service") + policy_script: str = Field(default="", description="The path to the WARP policy script") + health_check_script: str = Field(default="", description="The path to the WARP health check script") + + @property + def config_path(self) -> str: + return f"{self.config_dir}/config.json" + + @property + def account_path(self) -> str: + return f"{self.config_dir}/wgcf-account.toml" + + @property + def profile_path(self) -> str: + return f"{self.config_dir}/warp.conf" + + +class MasqueWarpEngine(_WarpEngineBase): + type: Literal[WarpEngineType.MASQUE] = WarpEngineType.MASQUE + binary_path: Literal["/usr/local/bin/usque"] = "/usr/local/bin/usque" + config_dir: Literal["/etc/usque"] = "/etc/usque" + systemd_service_name: Literal["cloudflare-warp"] = "cloudflare-warp" + policy_script: Literal["/etc/usque/warp-v6-policy.sh"] = "/etc/usque/warp-v6-policy.sh" + health_check_script: Literal["/etc/usque/warp-check.sh"] = "/etc/usque/warp-check.sh" + + +class WireguardWarpEngine(_WarpEngineBase): + type: Literal[WarpEngineType.WIREGUARD] = WarpEngineType.WIREGUARD + binary_path: Literal["/usr/local/bin/wgcf"] = "/usr/local/bin/wgcf" + config_dir: Literal["/etc/wgcf"] = "/etc/wgcf" + systemd_service_name: Literal["wg-quick@warp"] = "wg-quick@warp" + policy_script: Literal[""] = "" + health_check_script: Literal[""] = "" + + +WarpEngine = Annotated[ + MasqueWarpEngine | WireguardWarpEngine, + Field(discriminator="type"), +] + + +def warp_engine_factory(type: WarpEngineType) -> WarpEngine: + match type: + case WarpEngineType.MASQUE: + return MasqueWarpEngine() + case WarpEngineType.WIREGUARD: + return WireguardWarpEngine() diff --git a/nullforge/models/zerotrust.py b/nullforge/models/zerotrust.py new file mode 100644 index 0000000..6d7b1c3 --- /dev/null +++ b/nullforge/models/zerotrust.py @@ -0,0 +1,9 @@ +"""Zero Trust Tunnel configuration models.""" + +from enum import StrEnum + + +class ZeroTrustTunnelProtocol(StrEnum): + HTTP2 = "http2" + QUIC = "quic" + AUTO = "auto" diff --git a/nullforge/molds/__init__.py b/nullforge/molds/__init__.py new file mode 100644 index 0000000..8757089 --- /dev/null +++ b/nullforge/molds/__init__.py @@ -0,0 +1,34 @@ +"""Configuration molds for NullForge.""" + +from .cloudflare import WarpMold, ZeroTrustTunnelMold +from .containers import ContainersMold +from .dns import DnsMold +from .features import FeaturesMold +from .haproxy import HaproxyMold +from .mtproto import MtprotoMold +from .netsec import NetSecMold, UfwRule +from .profiles import ProfilesMold +from .serializable import BaseMold +from .system import SystemMold +from .tor import TorMold +from .user import UserMold +from .xray import XrayCoreMold + + +__all__ = [ + "BaseMold", + "WarpMold", + "ZeroTrustTunnelMold", + "ContainersMold", + "DnsMold", + "FeaturesMold", + "HaproxyMold", + "MtprotoMold", + "NetSecMold", + "UfwRule", + "ProfilesMold", + "SystemMold", + "TorMold", + "UserMold", + "XrayCoreMold", +] diff --git a/nullforge/molds/base.py b/nullforge/molds/base.py new file mode 100644 index 0000000..6badaea --- /dev/null +++ b/nullforge/molds/base.py @@ -0,0 +1,10 @@ +"""Base configuration presets.""" + +from nullforge.molds import FeaturesMold, SystemMold + + +BASE_FEATURES = FeaturesMold() +"""Default features configuration.""" + +BASE_SYSTEM = SystemMold() +"""Default system configuration.""" diff --git a/nullforge/molds/cloudflare.py b/nullforge/molds/cloudflare.py new file mode 100644 index 0000000..cd45946 --- /dev/null +++ b/nullforge/molds/cloudflare.py @@ -0,0 +1,103 @@ +"""Cloudflare WARP and Zero Trust configuration molds.""" + +from typing import TYPE_CHECKING + +from pydantic import Field, field_validator, model_validator + +from nullforge.models.warp import WarpEngineType, warp_engine_factory +from nullforge.models.zerotrust import ZeroTrustTunnelProtocol + +from .serializable import BaseMold + + +if TYPE_CHECKING: + from nullforge.models.warp import WarpEngine + + +class WarpMold(BaseMold): + """WARP configuration mold.""" + + install: bool = Field( + default=False, + description="Whether to deploy WARP", + ) + engine_type: WarpEngineType = Field( + default=WarpEngineType.MASQUE, + description="The WARP engine to use", + ) + iface: str = Field( + default="warp", + description="The name of the network interface for WARP", + ) + mtu: int = Field( + default=1280, + ge=1280, + le=9216, + description="The MTU for the WARP interface", + ) + zero_trust: bool = Field( + default=False, + description="Whether to enable ZeroTrust enrollment for WARP", + ) + + @field_validator("iface") + @classmethod + def _valid_iface(cls, v: str) -> str: + if not v or any(ch.isspace() for ch in v): + raise ValueError("iface must be a non-empty string without spaces") + return v + + # TODO: Implement ZeroTrust enrollment + @field_validator("zero_trust") + @classmethod + def _valid_zero_trust(cls, v: bool) -> bool: + if v: + raise ValueError("ZeroTrust enrollment is not implemented yet") + return v + + @property + def engine(self) -> "WarpEngine": + return warp_engine_factory(self.engine_type) + + +class ZeroTrustTunnelMold(BaseMold): + """Cloudflare Zero Trust Tunnel configuration mold.""" + + install: bool = Field( + default=False, + description="Whether to deploy Zero Trust Tunnel", + ) + token: str = Field( + default="", + description="Tunnel token for authentication", + ) + protocol: ZeroTrustTunnelProtocol = Field( + default=ZeroTrustTunnelProtocol.AUTO, + description="Tunnel protocol", + ) + post_quantum: bool = Field( + default=False, + description="Whether to use post-quantum protocol", + ) + ha_connections: int = Field( + default=2, + ge=1, + le=4, + description="Number of high-availability connections", + ) + route_through_warp: bool = Field( + default=False, + description="Whether to route tunnel traffic through WARP interface", + ) + + @model_validator(mode="after") + def validate_install_requires_token(self) -> "ZeroTrustTunnelMold": + if self.install and not self.token: + raise ValueError("token is required when install is True") + return self + + @model_validator(mode="after") + def validate_post_quantum_requires_protocol(self) -> "ZeroTrustTunnelMold": + if self.post_quantum and self.route_through_warp: + raise ValueError("post-quantum protocol is not supported when routing through WARP") + return self diff --git a/nullforge/molds/containers.py b/nullforge/molds/containers.py new file mode 100644 index 0000000..cab30af --- /dev/null +++ b/nullforge/molds/containers.py @@ -0,0 +1,34 @@ +"""Containers configuration mold.""" + +from typing import TYPE_CHECKING + +from pydantic import Field + +from nullforge.models.containers import ContainersBackendType, containers_backend_factory + +from .serializable import BaseMold + + +if TYPE_CHECKING: + from nullforge.models.containers import ContainersBackend + + +class ContainersMold(BaseMold): + """Containers configuration mold.""" + + install: bool = Field( + default=False, + description="Whether to install containers backend", + ) + backend_type: ContainersBackendType = Field( + default=ContainersBackendType.DOCKER, + description="Which containers backend to use", + ) + skopeo: bool = Field( + default=True, + description="Whether to install skopeo", + ) + + @property + def backend(self) -> "ContainersBackend": + return containers_backend_factory(self.backend_type) diff --git a/nullforge/molds/dns.py b/nullforge/molds/dns.py new file mode 100644 index 0000000..14f16e3 --- /dev/null +++ b/nullforge/molds/dns.py @@ -0,0 +1,36 @@ +"""DNS configuration mold.""" + +from pydantic import Field + +from nullforge.models.dns import DnsMode, DnsProtocol, DnsProvider, DnsServer + +from .serializable import BaseMold + + +class DnsMold(BaseMold): + """Full DNS configuration mold.""" + + mode: DnsMode = Field( + default=DnsMode.DOH_RESOLVED, + description="How DNS resolution should be performed", + ) + upstream_provider: DnsProvider = Field( + default=DnsProvider.CLOUDFLARE, + description="Provider for upstream servers.", + ) + ecs: bool = Field( + default=False, + description="Enable ECS (EDNS Client Subnet) for Quad9 provider.", + ) + + # Internal fields + upstreams: list[DnsServer] | None = Field( + default=None, + description="Primary upstream servers.", + ) + + @property + def upstream_dns(self) -> list[str]: + """List of upstream DNS servers urls.""" + + return [str(srv.url) for srv in self.upstreams or [] if srv.protocol == DnsProtocol.DOH] # ty:ignore[unresolved-attribute] diff --git a/nullforge/molds/features.py b/nullforge/molds/features.py new file mode 100644 index 0000000..a16e6e8 --- /dev/null +++ b/nullforge/molds/features.py @@ -0,0 +1,47 @@ +"""Features controller model.""" + +from pydantic import Field + +from .cloudflare import WarpMold, ZeroTrustTunnelMold +from .containers import ContainersMold +from .dns import DnsMold +from .haproxy import HaproxyMold +from .mtproto import MtprotoMold +from .netsec import NetSecMold +from .profiles import ProfilesMold +from .serializable import BaseMold +from .tor import TorMold +from .user import UserMold +from .xray import XrayCoreMold + + +ALLOWED_FEATURES_LAYERS = ( + ContainersMold, + DnsMold, + HaproxyMold, + MtprotoMold, + NetSecMold, + ProfilesMold, + TorMold, + UserMold, + WarpMold, + XrayCoreMold, + ZeroTrustTunnelMold, +) +"""Allowed features layers for the FeaturesMold.""" + + +class FeaturesMold(BaseMold): + """Features configuration mold.""" + + warp: WarpMold = Field(default=WarpMold()) + zerotrust: ZeroTrustTunnelMold = Field(default=ZeroTrustTunnelMold()) + containers: ContainersMold = Field(default=ContainersMold()) + dns: DnsMold = Field(default=DnsMold()) + haproxy: HaproxyMold = Field(default=HaproxyMold()) + mtproto: MtprotoMold = Field(default=MtprotoMold()) + netsec: NetSecMold = Field(default=NetSecMold()) + profiles: ProfilesMold = Field(default=ProfilesMold()) + tor: TorMold = Field(default=TorMold()) + users: UserMold = Field(default=UserMold()) + xray: XrayCoreMold = Field(default=XrayCoreMold()) diff --git a/nullforge/molds/haproxy.py b/nullforge/molds/haproxy.py new file mode 100644 index 0000000..8da760d --- /dev/null +++ b/nullforge/molds/haproxy.py @@ -0,0 +1,18 @@ +"""HAProxy configuration mold.""" + +from pydantic import Field + +from .serializable import BaseMold + + +class HaproxyMold(BaseMold): + """Full HAProxy configuration mold.""" + + install: bool = Field( + default=False, + description="Whether to install HAProxy proxy server", + ) + config: str = Field( + default="", + description="The configuration file for HAProxy proxy server", + ) diff --git a/nullforge/molds/mtproto.py b/nullforge/molds/mtproto.py new file mode 100644 index 0000000..1b50286 --- /dev/null +++ b/nullforge/molds/mtproto.py @@ -0,0 +1,38 @@ +"""MTProto proxy configuration mold.""" + +from pydantic import Field + +from nullforge.models.mtproto import MtprotoProvider + +from .serializable import BaseMold + + +class MtprotoMold(BaseMold): + """MTProto proxy configuration mold supporting multiple providers.""" + + install: bool = Field( + default=False, + description="Whether to install an MTProto proxy", + ) + provider: MtprotoProvider = Field( + default=MtprotoProvider.MTG, + description="MTProto proxy provider", + ) + port: int = Field( + default=443, + description="The port the proxy listens on", + ) + # mtg provider fields + secret: str = Field( + default="", + description="MTProto secret for mtg", + ) + # telemt provider fields + tls_domain: str = Field( + default="", + description="TLS masking domain for telemt", + ) + users: dict[str, str] = Field( + default_factory=dict, + description="Username→hex-secret mapping for telemt (32 hex chars each)", + ) diff --git a/nullforge/molds/netsec.py b/nullforge/molds/netsec.py new file mode 100644 index 0000000..8840e87 --- /dev/null +++ b/nullforge/molds/netsec.py @@ -0,0 +1,179 @@ +"""Network security and hardening configuration mold.""" + +from ipaddress import IPv6Network, ip_network +from typing import Literal + +from pydantic import Field + +from .serializable import BaseMold + + +SSH_PORT = 22 +"""Default SSH port.""" + + +class UfwRule(BaseMold): + """A single UFW firewall rule, to ``ufw`` CLI invocation. + + Fields map directly to UFW command: + ufw ACTION [DIRECTION] [on IFACE] [proto PROTO] + from FROM_IP to TO_IP [port PORT] [comment "COMMENT"] + """ + + port: int | str | None = Field( + default=None, + description="Port number (22), range string ('8080:8090'), or None for IP-only rules", + ) + proto: Literal["tcp", "udp", "any"] = Field( + default="any", + description="Protocol to match; 'any' omits the proto clause entirely", + ) + from_ip: str | None = Field( + default=None, + description="Source IP address or CIDR (IPv4 or IPv6). None resolves to 'any'", + ) + to_ip: str | None = Field( + default=None, + description="Destination IP address or CIDR. None resolves to 'any'", + ) + action: Literal["allow", "deny", "reject", "limit"] = Field( + default="allow", + description="UFW action to apply", + ) + direction: Literal["in", "out"] | None = Field( + default=None, + description="Traffic direction; None lets UFW apply its default (in)", + ) + interface: str | None = Field( + default=None, + description="Bind rule to a specific network interface, e.g. 'eth0'", + ) + comment: str | None = Field( + default=None, + description="Optional label stored in UFW rule metadata", + ) + + @property + def is_ipv6(self) -> bool: + """True when this rule references IPv6 address exclusively. + + Rules where from_ip and to_ip are None are considered protocol-agnostic + and are not classified as IPv6-only. Mixed rules that reference both IPv4 + and IPv6 address are also not IPv6-only. + """ + + candidates = [a for a in (self.from_ip, self.to_ip) if a and a not in ("any", "anywhere")] + if not candidates: + return False + try: + networks = [ip_network(addr, strict=False) for addr in candidates] + except ValueError: + return False + return all(isinstance(net, IPv6Network) for net in networks) + + +def _default_ufw_rules() -> list[UfwRule]: + """Default UFW ruleset — allow SSH from anywhere.""" + + return [UfwRule(port=SSH_PORT, comment="SSH")] + + +def _default_sysctl() -> dict[str, int]: + """Get the default sysctl parameters.""" + + sysctl = { + # --- SYSTEM RESOURCE LIMITS --- + "fs.nr_open": 3000000, + "fs.file-max": 2097152, + "vm.swappiness": 10, + } + + return sysctl + + +def _default_ipv4_sysctl() -> dict[str, str | int]: + """Get the default sysctl parameters for IPv4 stack.""" + + net_sysctl = { + # --- ROUTING & CORE --- + "net.ipv4.ip_forward": 1, + "net.core.default_qdisc": "fq", + "net.ipv4.tcp_congestion_control": "bbr", + # --- BACKLOGS --- + "net.core.somaxconn": 65535, + "net.core.netdev_max_backlog": 65535, + "net.ipv4.tcp_max_syn_backlog": 65535, + # --- PORT MANAGEMENT --- + "net.ipv4.ip_local_port_range": "15000 60999", + "net.ipv4.tcp_tw_reuse": 1, + "net.ipv4.tcp_fin_timeout": 30, + # --- MEMORY BUFFERS --- + "net.core.optmem_max": 65536, + "net.core.rmem_default": 262144, + "net.core.wmem_default": 262144, + "net.core.rmem_max": 67108864, + "net.core.wmem_max": 67108864, + "net.ipv4.tcp_rmem": "4096 87380 67108864", + "net.ipv4.tcp_wmem": "4096 65536 67108864", + "net.ipv4.udp_rmem_min": 16384, + "net.ipv4.udp_wmem_min": 16384, + # --- TCP FEATURES --- + "net.ipv4.tcp_mtu_probing": 1, + "net.ipv4.tcp_slow_start_after_idle": 0, + "net.ipv4.tcp_keepalive_time": 600, + "net.ipv4.tcp_keepalive_intvl": 30, + "net.ipv4.tcp_keepalive_probes": 3, + "net.ipv4.tcp_syncookies": 1, + "net.ipv4.tcp_fastopen": 3, + "net.ipv4.tcp_notsent_lowat": 16384, + } + + return net_sysctl + + +def _default_ipv6_sysctl() -> dict[str, int]: + """Get the default sysctl parameters for IPv6 stack.""" + + net_sysctl = { + # --- FORWARDING --- + "net.ipv6.conf.all.forwarding": 1, + "net.ipv6.conf.default.forwarding": 1, + # --- NEIGHBOR DISCOVERY --- + "net.ipv6.neigh.default.gc_thresh1": 128, + "net.ipv6.neigh.default.gc_thresh2": 512, + "net.ipv6.neigh.default.gc_thresh3": 4096, + # --- BINDING --- + "net.ipv6.bindv6only": 0, + } + + return net_sysctl + + +class NetSecMold(BaseMold): + """Full network configuration mold.""" + + ufw: bool = Field( + default=True, + description="Whether to enable UFW firewall", + ) + ufw_rules: list[UfwRule] = Field( + default_factory=_default_ufw_rules, + description="Ordered list of UFW rules to apply. Rules with IPv6 addresses " + "are automatically skipped on hosts without IPv6 connectivity.", + ) + system_sysctl: dict[str, int] | None = Field( + default_factory=_default_sysctl, + description="Sysctl parameters to apply", + ) + ipv4_sysctl: dict[str, str | int] | None = Field( + default_factory=_default_ipv4_sysctl, + description="IPv4 sysctl parameters to apply", + ) + ipv6_sysctl: dict[str, int] | None = Field( + default_factory=_default_ipv6_sysctl, + description="IPv6 sysctl parameters to apply", + ) + reinstall: bool = Field( + default=False, + description="Whether to force reconfigure UFW even if already active", + ) diff --git a/nullforge/molds/profiles.py b/nullforge/molds/profiles.py new file mode 100644 index 0000000..08353e7 --- /dev/null +++ b/nullforge/molds/profiles.py @@ -0,0 +1,26 @@ +"""Profiles configuration mold.""" + +from pydantic import Field + +from .serializable import BaseMold + + +class ProfilesMold(BaseMold): + """Full profiles configuration mold.""" + + install: bool = Field( + default=True, + description="Whether to install the profiles", + ) + for_root: bool = Field( + default=True, + description="Whether to install the profiles for the root user", + ) + for_user: bool = Field( + default=False, + description="Whether to install the profiles for the user", + ) + reinstall: bool = Field( + default=False, + description="Whether to reinstall profiles and tools even if already installed", + ) diff --git a/nullforge/molds/serializable.py b/nullforge/molds/serializable.py new file mode 100644 index 0000000..c36b672 --- /dev/null +++ b/nullforge/molds/serializable.py @@ -0,0 +1,12 @@ +from typing import Any + +from pydantic import BaseModel + + +class BaseMold(BaseModel): + """Base model class with JSON serialization support for pyinfra.""" + + def to_json(self) -> dict[str, Any]: + """Return JSON-serializable representation for pyinfra's debug-inventory.""" + + return self.model_dump() diff --git a/nullforge/molds/system.py b/nullforge/molds/system.py new file mode 100644 index 0000000..e5262f2 --- /dev/null +++ b/nullforge/molds/system.py @@ -0,0 +1,132 @@ +"""Base system config: locales, timezone, base packages.""" + +from typing import Annotated + +from pydantic import Field, conlist, field_validator + +from nullforge.models.system import SwapAlgo, SwapType + +from .serializable import BaseMold + + +def _default_packages_base() -> list[str]: + """Get the default base packages to install.""" + + return [ + "acl", + "aha", + "apt-transport-https", + "bat", + "bind9-host", + "bison", + "btop", + "build-essential", + "direnv", + "dnsutils", + "duf", + "file", + "fontconfig", + "g++", + "gcc", + "git", + "gnupg", + "ifupdown2", + "ipcalc", + "iputils-ping", + "jq", + "libevent-dev", + "locales", + "mtr-tiny", + "ncat", + "ncurses-dev", + "net-tools", + "nmap", + "pkg-config", + "unzip", + "wget", + "whois", + "xsel", + "zoxide", + "zsh", + ] + + +def _default_locales() -> list[str]: + """Get the default locales to generate.""" + + return ["en_US.UTF-8 UTF-8"] + + +def _default_timezone() -> str: + """Get the default timezone.""" + + return "UTC" + + +class SwapMold(BaseMold): + """System swap configuration.""" + + enabled: bool = Field( + default=False, + description="Whether swap should be enabled.", + ) + type: SwapType = Field( + default=SwapType.ZRAM, + description="Type of swap to use (basic file or zram).", + ) + algo: SwapAlgo = Field( + default=SwapAlgo.ZSTD, + description="Algorithm of ZRAM compression to use.", + ) + size: str = Field( + default="60%", + description="Swap size (e.g., '4G', '512M', '50%'). For zram, usually percentage of RAM.", + ) + swappiness: int = Field( + default=70, + description="Kernel swappiness value (0-100).", + ge=0, + le=100, + ) + + +class SystemMold(BaseMold): + """Full system configuration mold.""" + + packages_base: Annotated[list[str], conlist(str, min_length=1)] = Field( + default_factory=_default_packages_base, + description="System-wide base packages to install", + ) + locales: Annotated[list[str], conlist(str, min_length=1)] = Field( + default_factory=_default_locales, + description="Locales to generate", + ) + timezone: str = Field( + default_factory=_default_timezone, + description="System timezone (e.g. 'UTC' or 'Europe/Amsterdam')", + ) + hostname: str | None = Field( + default=None, + description="System hostname (FQDN). If None, hostname is not configured.", + ) + swap: SwapMold = Field( + default_factory=SwapMold, + description="Swap configuration.", + ) + + @field_validator("hostname") + @classmethod + def _validate_hostname(cls, v: str | None) -> str | None: + """Validate hostname format.""" + + if v is None: + return v + if not v or len(v) > 253: + raise ValueError("hostname must be between 1 and 253 characters") + if "." not in v: + raise ValueError("hostname should be a FQDN (contain a dot)") + if not all(c.isalnum() or c in ".-" for c in v): + raise ValueError("hostname contains invalid characters (allowed: alphanumeric, dots, hyphens)") + if v.startswith((".", "-")) or v.endswith((".", "-")): + raise ValueError("hostname cannot start or end with dot or hyphen") + return v diff --git a/nullforge/molds/tor.py b/nullforge/molds/tor.py new file mode 100644 index 0000000..4ddb444 --- /dev/null +++ b/nullforge/molds/tor.py @@ -0,0 +1,22 @@ +"""Tor proxy configuration mold.""" + +from pydantic import Field + +from .serializable import BaseMold + + +class TorMold(BaseMold): + """Full Tor proxy configuration mold.""" + + install: bool = Field( + default=False, + description="Whether to install Tor proxy", + ) + socks_port: int = Field( + default=9050, + description="The port to use for the Tor proxy", + ) + dns_port: int = Field( + default=5353, + description="The port to use for the Tor proxy DNS", + ) diff --git a/nullforge/molds/user.py b/nullforge/molds/user.py new file mode 100644 index 0000000..41bd405 --- /dev/null +++ b/nullforge/molds/user.py @@ -0,0 +1,60 @@ +"""User configuration mold.""" + +import re + +from pydantic import Field, field_validator + +from nullforge.models.users import Shell + +from .serializable import BaseMold + + +class UserMold(BaseMold): + """Full user configuration mold.""" + + manage: bool = Field( + default=True, + description="Whether to manage the user", + ) + name: str = Field( + default="core", + description="The username for the user", + ) + password: str | None = Field( + default=None, + description="The password for the user", + ) + sudo: bool = Field( + default=True, + description="Whether to add the user to the sudo group", + ) + shell: Shell = Field( + default=Shell.ZSH, + description="The shell for the user to use ", + ) + copy_root_keys: bool = Field( + default=True, + description="Whether to copy the root user's SSH keys to the user", + ) + set_root_shell_like_user: bool = Field( + default=True, + description="Whether to set the root user's shell to the user's shell", + ) + + @field_validator("name", mode="before") + @classmethod + def validate_name(cls, value: str) -> str: + if not value: + raise ValueError("Name cannot be empty") + + if value.startswith("-"): + raise ValueError("Name cannot start with a hyphen") + + if not re.match(r"^[A-Za-z0-9._-]+$", value): + raise ValueError("Name must only contain characters from the portable filename character set") + + return value + + @property + def shell_path(self) -> str: + return self.shell.value diff --git a/nullforge/molds/utils.py b/nullforge/molds/utils.py new file mode 100644 index 0000000..c9604e0 --- /dev/null +++ b/nullforge/molds/utils.py @@ -0,0 +1,141 @@ +"""Utility functions for merging and ensuring models.""" + +from collections.abc import Mapping +from typing import Any + +from .cloudflare import WarpMold, ZeroTrustTunnelMold +from .containers import ContainersMold +from .dns import DnsMold +from .features import ALLOWED_FEATURES_LAYERS, FeaturesMold +from .haproxy import HaproxyMold +from .mtproto import MtprotoMold +from .netsec import NetSecMold +from .profiles import ProfilesMold +from .system import SystemMold +from .tor import TorMold +from .user import UserMold +from .xray import XrayCoreMold + + +def _deep_merge_dicts(a: Mapping[str, Any], b: Mapping[str, Any]) -> dict[str, Any]: + """ + Recursively merge dict b into dict a without mutating a. + None/empty containers in b will overwrite a. + """ + + out = dict(a) + for k, v in b.items(): + if k in out and isinstance(out[k], dict) and isinstance(v, dict): + out[k] = _deep_merge_dicts(out[k], v) + else: + out[k] = v + return out + + +def _to_features_fragment(value: object) -> dict[str, Any]: + """ + Accept a full FeaturesMold, any sub-mold (UserMold, WarpMold, ...), a mapping, + or None; return a dict fragment suitable for deep-merge into FeaturesMold. + """ + + match value: + case None: + return {} + case FeaturesMold(): + return value.model_dump() + case UserMold(): + return {"users": value.model_dump()} + case ProfilesMold(): + return {"profiles": value.model_dump()} + case DnsMold(): + return {"dns": value.model_dump()} + case WarpMold(): + return {"warp": value.model_dump()} + case ZeroTrustTunnelMold(): + return {"zerotrust": value.model_dump()} + case ContainersMold(): + return {"containers": value.model_dump()} + case HaproxyMold(): + return {"haproxy": value.model_dump()} + case MtprotoMold(): + return {"mtproto": value.model_dump()} + case NetSecMold(): + return {"netsec": value.model_dump()} + case TorMold(): + return {"tor": value.model_dump()} + case XrayCoreMold(): + return {"xray": value.model_dump()} + case Mapping(): + prepared = {k: (v.model_dump() if isinstance(v, ALLOWED_FEATURES_LAYERS) else v) for k, v in value.items()} + return FeaturesMold.model_validate(prepared).model_dump() + case _: + raise TypeError(f"Unsupported features layer type: {type(value)!r}") + + +def merge_features(base: FeaturesMold, *layers: object | None) -> FeaturesMold: + """Start from base FeaturesMold, deep-merge each layer (full mold, sub-mold, dict, or None).""" + + acc = base.model_dump() + for layer in layers: + if layer is None: + continue + acc = _deep_merge_dicts(acc, _to_features_fragment(layer)) + return FeaturesMold.model_validate(acc) + + +def ensure_features(value: Any | None) -> FeaturesMold: + """ + Coerce whatever is in inventory (None/dict/Features) into a Features instance, + falling back to model defaults when absent. + """ + + match value: + case None: + return FeaturesMold() + case FeaturesMold(): + return value + case Mapping(): + return FeaturesMold.model_validate(value) + case _: + raise TypeError(f"Unsupported features layer type: {type(value)!r}") + + +def _to_system_dict(value: SystemMold | Mapping[str, Any]) -> dict[str, Any]: + match value: + case SystemMold(): + return value.model_dump() + case Mapping(): + return SystemMold.model_validate(value).model_dump() + case _: + raise TypeError(f"Unsupported system layer type: {type(value)!r}") + + +def merge_system(base: SystemMold, *layers: SystemMold | Mapping[str, Any] | None) -> SystemMold: + """ + - start from base SystemMold + - apply each layer (dict or SystemMold), deep-merging into the accumulated dict + """ + + acc = base.model_dump() + for layer in layers: + if layer is None: + continue + acc = _deep_merge_dicts(acc, _to_system_dict(layer)) + return SystemMold.model_validate(acc) + + +def ensure_system(value: Any | None) -> SystemMold: + """ + Coerce whatever is in inventory (None/dict/SystemMold) into a SystemMold instance, + falling back to model defaults when absent. + """ + + match value: + case None: + return SystemMold() + case SystemMold(): + return value + case Mapping(): + return SystemMold.model_validate(value) + case _: + raise TypeError(f"Unsupported system layer type: {type(value)!r}") diff --git a/nullforge/molds/xray.py b/nullforge/molds/xray.py new file mode 100644 index 0000000..d649380 --- /dev/null +++ b/nullforge/molds/xray.py @@ -0,0 +1,14 @@ +"""Xray-core configuration mold.""" + +from pydantic import Field + +from .serializable import BaseMold + + +class XrayCoreMold(BaseMold): + """Full Xray-core configuration mold.""" + + install: bool = Field( + default=False, + description="Whether to install Xray core", + ) diff --git a/nullforge/runes/__init__.py b/nullforge/runes/__init__.py new file mode 100644 index 0000000..3f5c4a7 --- /dev/null +++ b/nullforge/runes/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/nullforge/runes/base.py b/nullforge/runes/base.py new file mode 100644 index 0000000..744069a --- /dev/null +++ b/nullforge/runes/base.py @@ -0,0 +1,232 @@ +"""Base system configuration deployment module.""" + +from contextlib import suppress + +from pyinfra.context import host +from pyinfra.facts.files import File +from pyinfra.facts.server import Command, Hostname +from pyinfra.operations import files, server, systemd + +from nullforge.molds import SystemMold +from nullforge.runes.swap import configure_swap +from nullforge.smithy.http import CURL_ARGS_STR +from nullforge.smithy.packages import get_pm +from nullforge.smithy.system import detect_best_locale +from nullforge.smithy.versions import STATIC_URLS, Versions + + +def deploy_base_system() -> None: + """Deploy base system configuration.""" + + system: SystemMold = host.data.system + + if system.hostname: + _configure_hostname(system.hostname) + + _install_packages(system) + + _set_locale(system) + + _set_timezone(system) + + _configure_ntp() + + configure_swap(system) + + +def _configure_hostname(hostname: str) -> None: + """Configure system hostname.""" + + short_hostname = hostname.split(".")[0] + + with suppress(Exception): + ip_fact = host.get_fact(Command, "hostname -I | awk '{print $1}'") + ip = ip_fact.strip() if ip_fact and ip_fact.strip() else "127.0.1.1" + + if host.get_fact(Hostname) != hostname: + server.hostname( + name=f"Set system hostname to {hostname}", + hostname=hostname, + _sudo=True, + ) + + host_entry = f"{ip} {hostname} {short_hostname}" + files.line( + name=f"Ensure {hostname} entry exists in hosts file", + path="/etc/hosts", + line=host_entry, + _sudo=True, + ) + + +def _install_packages(system: SystemMold) -> None: + """Install base system packages.""" + + pm = get_pm() + + pm.update( + name="Update package repositories", + _sudo=True, + ) + + pm.upgrade( + name="Update packages", + _sudo=True, + ) + + pm.install( + name="Install base system packages", + packages=system.packages_base, + no_recommends=True, + _sudo=True, + ) + + _install_curl() + + _install_doggo() + + +def _install_curl() -> None: + """Install curl package.""" + + pm = get_pm() + curl_exec_path = "/usr/local/bin/curl" + if host.get_fact(File, curl_exec_path): + return + + try: + curl_url = Versions(host).curl_tar() + except ValueError: + pm.install( + name="Install curl package from repo", + packages=["curl"], + _sudo=True, + ) + return + + server.shell( + name="Download curl package from static-curl", + commands=[ + f"wget -c {curl_url} -O - | tar xJ -C /tmp/", + f"mv /tmp/curl {curl_exec_path}", + ], + _sudo=True, + ) + + files.file( + name="Set curl binary as executable", + path=curl_exec_path, + mode="0755", + _sudo=True, + ) + + pm.install( + name="Remove curl package", + packages=["curl"], + present=False, + _sudo=True, + ) + + pm.upgrade( + name="Clean up unused packages", + auto_remove=True, + _sudo=True, + ) + + +def _install_doggo() -> None: + """Install doggo package.""" + + if host.get_fact(File, "/usr/local/bin/doggo"): + return + + doggo_install_path = "/tmp/doggo.sh" + if not host.get_fact(File, doggo_install_path): + curl_cmd = f"curl -L {CURL_ARGS_STR} {STATIC_URLS['doggo_install']} -o {doggo_install_path}" + server.shell( + name="Download doggo installation script", + commands=[curl_cmd], + ) + files.file( + name="Set doggo installation script as executable", + path=doggo_install_path, + mode="0755", + ) + + server.shell( + name="Install doggo", + commands=[f"sh {doggo_install_path}"], + _sudo=True, + ) + + +def _set_locale(system: SystemMold) -> None: + """Set system locale.""" + + resolved_locales = set() + + for locale in system.locales: + best = detect_best_locale(host, preferred=[locale]) + if best: + resolved_locales.add(best) + else: + fallback = detect_best_locale(host) + if fallback: + resolved_locales.add(fallback) + + if not resolved_locales: + return + + for locale in resolved_locales: + server.locale( + name=f"Enable locale {locale}", + locale=locale, + present=True, + _sudo=True, + ) + + +def _configure_ntp() -> None: + """Install and enable systemd-timesyncd for NTP synchronization.""" + + pm = get_pm() + + pm.install( + name="Install systemd-timesyncd", + packages=["systemd-timesyncd"], + _sudo=True, + ) + + systemd.service( + name="Enable and start systemd-timesyncd", + service="systemd-timesyncd", + running=True, + enabled=True, + _sudo=True, + ) + + server.shell( + name="Enable NTP synchronization", + commands=["timedatectl set-ntp true"], + _sudo=True, + _retries=3, + _retry_delay=5, + ) + + +def _set_timezone(system: SystemMold) -> None: + """Set system timezone.""" + + cmd_get_timezone = "timedatectl show --property=Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null" + current_tz = host.get_fact(Command, cmd_get_timezone) + if current_tz and current_tz.strip() == system.timezone: + return + + server.shell( + name="Set system timezone", + commands=f"timedatectl set-timezone {system.timezone}", + _sudo=True, + ) + + +deploy_base_system() diff --git a/nullforge/runes/cloudflare.py b/nullforge/runes/cloudflare.py new file mode 100644 index 0000000..c6ef610 --- /dev/null +++ b/nullforge/runes/cloudflare.py @@ -0,0 +1,76 @@ +"""Cloudflare service management module.""" + +from pyinfra.context import host +from pyinfra.facts.files import File +from pyinfra.facts.server import Groups, Users +from pyinfra.operations import files, server + +from nullforge.smithy.http import CURL_ARGS_STR +from nullforge.smithy.versions import Versions + + +CLOUDFLARE_GROUP = "cloudflare" +CLOUDFLARE_USER = "cloudflare" + + +def ensure_cloudflare_user() -> None: + """Ensure cloudflare system user and group exist with proper permissions.""" + + if CLOUDFLARE_GROUP not in host.get_fact(Groups): + server.group( + name="Ensure cloudflare group exists", + group=CLOUDFLARE_GROUP, + system=True, + _sudo=True, + ) + + if CLOUDFLARE_USER not in host.get_fact(Users): + default_shell = "/bin/false" + server.user( + name="Ensure cloudflare system user exists", + user=CLOUDFLARE_USER, + system=True, + group=CLOUDFLARE_GROUP, + shell=default_shell, + create_home=False, + _sudo=True, + ) + + config_dir = "/etc/cloudflare" + files.directory( + name=f"Ensure {config_dir} configuration directory exists", + path=config_dir, + user=CLOUDFLARE_USER, + group=CLOUDFLARE_GROUP, + mode="0755", + _sudo=True, + ) + + +def ensure_cloudflared_binary() -> None: + """Ensure cloudflared binary is installed.""" + + if host.get_fact(File, "/usr/bin/cloudflared"): + host.noop("cloudflared binary is already installed") + return + + curl_cmd = f"curl -L {CURL_ARGS_STR} {Versions(host).cloudflared()} -o /tmp/cloudflared" + server.shell( + name="Download cloudflared binary", + commands=[curl_cmd], + ) + + server.shell( + name="Install cloudflared binary", + commands=["mv /tmp/cloudflared /usr/bin/cloudflared"], + _sudo=True, + ) + + files.file( + name="Set cloudflared binary as executable", + path="/usr/bin/cloudflared", + mode="0755", + user="root", + group=CLOUDFLARE_GROUP, + _sudo=True, + ) diff --git a/nullforge/runes/containers.py b/nullforge/runes/containers.py new file mode 100644 index 0000000..36f20ad --- /dev/null +++ b/nullforge/runes/containers.py @@ -0,0 +1,292 @@ +"""Containers deployment module.""" + +import io + +from pyinfra.context import host +from pyinfra.facts.files import File +from pyinfra.operations import files, git, server + +from nullforge.models.containers import ContainersBackendType +from nullforge.molds import ContainersMold, FeaturesMold +from nullforge.molds.user import UserMold +from nullforge.smithy.arch import deb_arch +from nullforge.smithy.http import CURL_ARGS_STR +from nullforge.smithy.packages import get_pm +from nullforge.smithy.versions import GPG_KEYS, STATIC_URLS, Versions + + +def deploy_containers() -> None: + """Deploy containers runtime and related tools.""" + + features: FeaturesMold = host.data.features + containers_opts: ContainersMold = features.containers + users_opts: UserMold = features.users + + match containers_opts.backend_type: + case ContainersBackendType.DOCKER: + _install_docker() + if users_opts.manage: + _add_user_to_docker_group(users_opts.name) + _install_gvisor() + case ContainersBackendType.PODMAN: + _install_crun() + _install_podman() + case ContainersBackendType.CRIO: + raise ValueError("CRIO is not supported yet") + + if containers_opts.skopeo: + _install_skopeo() + + +def _install_gvisor() -> None: + """Install gVisor runtime.""" + + pm = get_pm() + if pm.is_debian_based: + gvisor_key_path = "/usr/share/keyrings/gvisor-archive-keyring.gpg" + curl_cmd = f"curl -L {CURL_ARGS_STR} {GPG_KEYS['gvisor']} -o {gvisor_key_path}" + server.shell( + name="Download gVisor GPG key", + commands=[curl_cmd], + _sudo=True, + ) + + gvisor_sources_list = "/etc/apt/sources.list.d/gvisor.list" + arch = deb_arch(host) + source = ( + f"deb [arch={arch} signed-by={gvisor_key_path}] " + "https://storage.googleapis.com/gvisor/releases release main\n" + ) + files.put( + name="Write gVisor repository source", + src=io.StringIO(source), + dest=gvisor_sources_list, + create_remote_dir=True, + mode="0600", + user="root", + group="root", + _sudo=True, + ) + + pm.update( + name="Update package repositories after adding gVisor repository", + _sudo=True, + ) + else: + raise ValueError("Unsupported distribution for runsc installation") + + pm.install( + name="Install gVisor", + packages=["runsc"], + _sudo=True, + ) + + +def _install_docker() -> None: + """Install Docker using official installation script.""" + + get_docker_path = "/tmp/get-docker.sh" + curl_cmd = f"curl -L {CURL_ARGS_STR} {STATIC_URLS['docker_install']} -o {get_docker_path}" + server.shell( + name="Download Docker installation script", + commands=[curl_cmd], + ) + + files.file( + name="Set Docker installation script as executable", + path=get_docker_path, + mode="0755", + ) + + server.shell( + name="Install Docker", + commands=[f"bash {get_docker_path}"], + _sudo=True, + ) + + +def _add_user_to_docker_group(username: str) -> None: + """Add user to docker group.""" + + server.user( + name=f"Add user {username} to docker group", + user=username, + groups=["docker"], + append=True, + _sudo=True, + ) + + +def _install_skopeo() -> None: + """Install skopeo.""" + + pm = get_pm() + pm.install( + name="Install skopeo", + packages=["skopeo"], + _sudo=True, + ) + + +def _install_podman() -> None: + """Install Podman.""" + + pm = get_pm() + # TODO: Install latest Podman from source + # For now we just install the package from apt + # _build_podman(opts) + + pm.install( + name="Install Podman", + packages=[ + "podman", + "podman-compose", + ], + _sudo=True, + ) + + +def _build_podman(opts: ContainersMold) -> None: + """Build Podman from source.""" + + if host.get_fact(File, "/usr/bin/podman"): + return + + pm = get_pm() + build_deps = [ + "btrfs-progs", + "gcc", + "git", + "golang-go", + "go-md2man", + "iptables", + "libassuan-dev", + "libbtrfs-dev", + "libc6-dev", + "libdevmapper-dev", + "libglib2.0-dev", + "libgpgme-dev", + "libgpg-error-dev", + "libprotobuf-dev", + "libprotobuf-c-dev", + "libseccomp-dev", + "libselinux1-dev", + "libsystemd-dev", + "make", + "netavark", + "passt", + "pkg-config", + "runc", + "uidmap", + "fuse-overlayfs", + "libapparmor-dev", + ] + pm.install( + name="Install Podman build dependencies", + packages=build_deps, + _sudo=True, + ) + + podman_version = Versions(host).podman() + git.repo( + name="Clone Podman repository", + src="https://github.com/containers/podman.git", + dest="/tmp/podman", + _sudo=True, + ) + + if podman_version != "latest": + server.shell( + name=f"Checkout Podman version {podman_version}", + commands=[ + f"cd /tmp/podman && git checkout {podman_version}", + ], + _sudo=True, + ) + + build_tags = "selinux seccomp" + server.shell( + name="Build Podman", + commands=[ + f"cd /tmp/podman && make BUILDTAGS='{build_tags}' PREFIX=/usr", + ], + _sudo=True, + ) + + server.shell( + name="Install Podman", + commands=[ + "cd /tmp/podman && make install PREFIX=/usr", + ], + _sudo=True, + ) + + +def _install_crun() -> None: + """Install crun from source.""" + + pm = get_pm() + # TODO: Build crun from source + # For now we just install the package from apt + # _build_crun() + + pm.install( + name="Install crun", + packages=["crun"], + _sudo=True, + ) + + +def _build_crun() -> None: + if host.get_fact(File, "/usr/local/bin/crun"): + return + + pm = get_pm() + _packages = [ + "make", + "git", + "gcc", + "build-essential", + "pkgconf", + "libtool", + "libsystemd-dev", + "libprotobuf-c-dev", + "libcap-dev", + "libseccomp-dev", + "libyajl-dev", + "go-md2man", + "autoconf", + "python3", + "automake", + ] + pm.install( + name="Install crun build dependencies", + packages=_packages, + _sudo=True, + ) + + crun_version = Versions(host).crun() + git.repo( + name="Clone crun repository", + src="https://github.com/containers/crun.git", + dest="/tmp/crun", + _sudo=True, + ) + + if crun_version != "latest": + server.shell( + name=f"Checkout crun version {crun_version}", + commands=[ + f"cd /tmp/crun && git checkout {crun_version} || git checkout v{crun_version}", + ], + _sudo=True, + ) + + server.shell( + name="Build crun", + commands=["cd /tmp/crun && ./autogen.sh && ./configure && make -j$(nproc) && make install"], + _sudo=True, + ) + + +deploy_containers() diff --git a/nullforge/runes/dns.py b/nullforge/runes/dns.py new file mode 100644 index 0000000..e8de450 --- /dev/null +++ b/nullforge/runes/dns.py @@ -0,0 +1,248 @@ +"""DNS configuration deployment module.""" + +from pyinfra.context import host +from pyinfra.operations import files, server, systemd +from pyinfra.operations.util import any_changed + +from nullforge.models.dns import DnsMode, DnsProtocol, dns_providers +from nullforge.molds import DnsMold, FeaturesMold +from nullforge.runes.cloudflare import ( + CLOUDFLARE_GROUP, + CLOUDFLARE_USER, + ensure_cloudflare_user, + ensure_cloudflared_binary, +) +from nullforge.smithy.network import has_ipv6 +from nullforge.smithy.packages import get_pm +from nullforge.templates import get_dns_template, get_systemd_template + + +def deploy_dns_configuration() -> None: + """Deploy DNS configuration based on selected mode.""" + + ipv6_enabled = has_ipv6(host) + features: FeaturesMold = host.data.features + dns_opts: DnsMold = features.dns + + upstream_protocol = ( + DnsProtocol.DOH + if dns_opts.mode in {DnsMode.DOH_RESOLVED, DnsMode.DOH_RAW} + else DnsProtocol.DOT + if dns_opts.mode == DnsMode.DOT_RESOLVED + else None + ) + + if upstream_protocol: + dns_opts.upstreams = dns_providers.get_upstreams( + dns_opts.upstream_provider, + upstream_protocol, + ipv6_enabled, + dns_opts.ecs, + ) + + match dns_opts.mode: + # TODO: Implement DNS over UDP + case DnsMode.DOU: + raise ValueError("DNS over UDP is not supported yet.") + case DnsMode.DOT_RESOLVED: + _deploy_dot_resolved(dns_opts) + case DnsMode.DOH_RESOLVED | DnsMode.DOH_RAW: + _deploy_doh_configuration(dns_opts) + case DnsMode.NONE | _: + return + + +# TODO: Disable DoH service if DoT is used +def _deploy_dot_resolved(opts: DnsMold) -> None: + """Deploy DNS over TLS configuration.""" + + files.template( + name="Configure systemd-resolved for DoT", + src=get_dns_template("resolved.conf.j2"), + dest="/etc/systemd/resolved.conf", + mode="0644", + DOT=True, + DOH=False, + _sudo=True, + ) + + stub_resolv_conf = "/run/systemd/resolve/stub-resolv.conf" + resolv_conf = "/etc/resolv.conf" + files.link( + name="Create symlink to resolv.conf for DoT with systemd-resolved", + path=resolv_conf, + target=stub_resolv_conf, + force=True, + _sudo=True, + ) + + systemd.service( + name="Restart systemd-resolved for DoT", + service="systemd-resolved", + running=True, + restarted=True, + enabled=True, + _sudo=True, + ) + + server.shell( + name="Flush DNS cache", + commands=[ + "resolvectl flush-caches", + ], + _sudo=True, + ) + + +def _deploy_doh_configuration(opts: DnsMold) -> None: + """Deploy DNS over HTTPS configuration.""" + + ensure_cloudflare_user() + ensure_cloudflared_binary() + + mode_config = { + DnsMode.DOH_RESOLVED: (5053, _configure_doh_with_resolved), + DnsMode.DOH_RAW: (53, _configure_doh_raw), + } + + if opts.mode not in mode_config: + raise ValueError(f"Unsupported DNS mode: {opts.mode}") + + stub_port, configure = mode_config[opts.mode] + + config_dir = "/etc/cloudflare" + config_path = f"{config_dir}/dns.yaml" + config_template = files.template( + name="Configure cloudflared DNS YAML config", + src=get_dns_template("dns.yaml.j2"), + dest=config_path, + mode="0644", + user=CLOUDFLARE_USER, + group=CLOUDFLARE_GROUP, + UPSTREAMS=opts.upstream_dns, + PORT=stub_port, + _sudo=True, + ) + + service_path = "/etc/systemd/system/cloudflare-dns.service" + service_template = files.template( + name="Configure cloudflared DNS proxy service", + src=get_systemd_template("cloudflare-dns.service.j2"), + dest=service_path, + mode="0644", + CONFIG_PATH=config_path, + _sudo=True, + ) + + systemd.daemon_reload( + name="Reload systemd daemon for cloudflared DNS proxy service", + _sudo=True, + _if=service_template.did_change, + ) + + systemd.service( + name="Enable and start Cloudflare DNS", + service="cloudflare-dns", + running=True, + restarted=True, + enabled=True, + _sudo=True, + _if=any_changed(config_template, service_template), + ) + + configure(opts) + + +def _configure_doh_with_resolved(opts: DnsMold) -> None: + """Configure DoH with systemd-resolved.""" + + pm = get_pm() + pm.install( + name="Install libnss-resolve package", + packages=["libnss-resolve"], + _sudo=True, + ) + + service_template = files.template( + name="Configure systemd-resolved for DoH", + src=get_dns_template("resolved.conf.j2"), + dest="/etc/systemd/resolved.conf", + mode="0644", + DOT=False, + DOH=True, + _sudo=True, + ) + + stub_resolv_conf = "/run/systemd/resolve/stub-resolv.conf" + resolv_conf = "/etc/resolv.conf" + files.link( + name="Create symlink to resolv.conf for DoH with systemd-resolved", + path=resolv_conf, + target=stub_resolv_conf, + force=True, + _sudo=True, + ) + + systemd.daemon_reload( + name="Reload systemd daemon for DoH with systemd-resolved", + _sudo=True, + _if=service_template.did_change, + ) + + systemd.service( + name="Restart systemd-resolved for DoH", + service="systemd-resolved", + running=True, + restarted=True, + enabled=True, + _sudo=True, + ) + + server.shell( + name="Flush DNS cache", + commands=[ + "resolvectl flush-caches", + ], + _sudo=True, + ) + + +def _configure_doh_raw(opts: DnsMold) -> None: + """Configure DoH without systemd-resolved.""" + + pm = get_pm() + systemd.service( + name="Stop systemd-resolved for DoH", + service="systemd-resolved", + running=False, + enabled=False, + _sudo=True, + ) + + pm.install( + name="Uninstall libnss-resolve package", + packages=["libnss-resolve"], + present=False, + extra_uninstall_args="--purge", + _sudo=True, + ) + + resolv_conf = "/etc/resolv.conf" + # files.file( + # name="Remove existing resolv.conf", + # path=resolv_conf, + # present=False, + # force=True, + # _sudo=True, + # ) + + files.template( + name="Configure resolv.conf for DoH", + src=get_dns_template("resolv.conf.j2"), + dest=resolv_conf, + mode="0644", + _sudo=True, + ) + + +deploy_dns_configuration() diff --git a/nullforge/runes/haproxy.py b/nullforge/runes/haproxy.py new file mode 100644 index 0000000..59d2ee1 --- /dev/null +++ b/nullforge/runes/haproxy.py @@ -0,0 +1,162 @@ +"""HAProxy deployment module.""" + +import io + +from pyinfra.context import host +from pyinfra.facts.files import Directory, File +from pyinfra.facts.server import Command +from pyinfra.operations import files, server + +from nullforge.molds import FeaturesMold, HaproxyMold +from nullforge.smithy.http import CURL_ARGS_STR +from nullforge.smithy.packages import get_pm +from nullforge.smithy.versions import GPG_KEYS, KEYRING_DIR + + +def deploy_haproxy() -> None: + """Deploy HAProxy proxy server.""" + + features: FeaturesMold = host.data.features + haproxy_opts: HaproxyMold = features.haproxy + + if haproxy_opts.install: + _install_haproxy() + if features.users.manage: + _setup_acls(features.users.name) + + +def _install_haproxy() -> None: + """Install HAProxy proxy server.""" + + pm = get_pm() + + if pm.is_debian_based: + # TODO: Add support for configuring HAProxy version + haproxy_version = "3.2" + + if pm.is_ubuntu: + pm.install( + name="Install software-properties-common", + packages=["software-properties-common"], + no_recommends=True, + _sudo=True, + ) + + server.shell( + name="Add HAProxy PPA", + commands=[f"add-apt-repository -y ppa:vbernat/haproxy-{haproxy_version}"], + _sudo=True, + ) + + pm.update( + name="Update package repositories after adding HAProxy PPA", + _sudo=True, + ) + + pm.install( + name="Install HAProxy", + packages=[f"haproxy={haproxy_version}.*"], + _sudo=True, + ) + + else: + if pm.distro_major == 13: + repo_version = f"trixie-backports-{haproxy_version}" + elif pm.distro_major == 12: + repo_version = f"bookworm-backports-{haproxy_version}" + else: + raise ValueError("Unsupported distribution version") + + if not host.get_fact(Directory, KEYRING_DIR): + files.directory( + name="Create keyring directory", + path=KEYRING_DIR, + user="root", + group="root", + mode="0755", + _sudo=True, + ) + + gpg_key_path = f"{KEYRING_DIR}/haproxy-archive-keyring.gpg" + if not host.get_fact(File, gpg_key_path): + curl_cmd = f"curl -L {CURL_ARGS_STR} {GPG_KEYS['haproxy']} -o {gpg_key_path}" + server.shell( + name="Download HAProxy GPG key", + commands=[curl_cmd], + _sudo=True, + ) + + haproxy_sources_list = "/etc/apt/sources.list.d/haproxy.list" + source = f"deb [signed-by={gpg_key_path}] https://haproxy.debian.net {repo_version} main\n" + files.put( + name="Write HAProxy repository source", + src=io.StringIO(source), + dest=haproxy_sources_list, + create_remote_dir=True, + mode="0600", + user="root", + group="root", + _sudo=True, + ) + + pm.update( + name="Update package repositories after adding HAProxy repository", + _sudo=True, + ) + + pm.install( + name="Install HAProxy", + packages=[f"haproxy={haproxy_version}.*"], + _sudo=True, + ) + + elif pm.is_rhel_based: + pm.install( + name="Install HAProxy", + packages=["haproxy"], + _sudo=True, + ) + + +def _setup_acls(username: str) -> None: + """Set up ACLs on HAProxy directories for ACME certificate management.""" + + pki_dir = "/usr/local/etc/haproxy/pki" + haproxy_dir = "/etc/haproxy" + + if not host.get_fact(Directory, pki_dir): + files.directory( + name="Create HAProxy PKI directory", + path=pki_dir, + user="root", + group="root", + mode="0755", + _sudo=True, + ) + + cmd_get_facl = "getfacl {} 2>/dev/null || true" + acl_haproxy = host.get_fact(Command, cmd_get_facl.format(haproxy_dir), _sudo=True) or "" + if f"user:{username}:rwx" not in acl_haproxy: + server.shell( + name="Set ACLs on HAProxy config directory", + commands=[ + f"setfacl -m u:{username}:rwx {haproxy_dir}", + f"setfacl -d -m u:{username}:rwx {haproxy_dir}", + f"setfacl -m u:{username}:rw {haproxy_dir}/haproxy.cfg", + ], + _sudo=True, + ) + + acl_pki = host.get_fact(Command, cmd_get_facl.format(pki_dir), _sudo=True) or "" + if f"user:{username}:rwx" not in acl_pki: + server.shell( + name="Set ACLs on HAProxy PKI directory", + commands=[ + f"setfacl -m u:{username}:rwx {pki_dir}", + f"setfacl -d -m u:{username}:rwx {pki_dir}", + ], + _sudo=True, + ) + + +deploy_haproxy() diff --git a/nullforge/runes/mtproto.py b/nullforge/runes/mtproto.py new file mode 100644 index 0000000..11d01d9 --- /dev/null +++ b/nullforge/runes/mtproto.py @@ -0,0 +1,303 @@ +"""MTProto proxy deployment module.""" + +from pyinfra.context import host +from pyinfra.facts.files import File +from pyinfra.facts.server import Groups, Users +from pyinfra.operations import files, server, systemd + +from nullforge.models.mtproto import MtprotoProvider +from nullforge.molds import FeaturesMold, MtprotoMold +from nullforge.smithy.http import CURL_ARGS_STR +from nullforge.smithy.network import has_ipv6 +from nullforge.smithy.versions import Versions +from nullforge.templates import get_mtg_template, get_systemd_template, get_telemt_template + + +MTG_USER = "mtg" +MTG_GROUP = "mtg" +MTG_CONFIG_DIR = "/etc/mtg" +MTG_EXEC = "/usr/local/bin/mtg" + +TELEMT_USER = "telemt" +TELEMT_GROUP = "telemt" +TELEMT_CONFIG_DIR = "/etc/telemt" +TELEMT_EXEC = "/usr/local/bin/telemt" + + +def deploy_mtproto() -> None: + """Deploy MTProto proxy using the configured provider.""" + + features: FeaturesMold = host.data.features + opts: MtprotoMold = features.mtproto + + match opts.provider: + case MtprotoProvider.MTG: + _cleanup_telemt() + _ensure_user(MTG_USER, MTG_GROUP, MTG_CONFIG_DIR) + _install_mtg(opts) + _deploy_mtg_service(opts) + case MtprotoProvider.TELEMT: + _cleanup_mtg() + _ensure_user(TELEMT_USER, TELEMT_GROUP, TELEMT_CONFIG_DIR) + _install_telemt() + _deploy_telemt_service(opts) + + +def _cleanup_mtg() -> None: + """Stop and remove all MTG provider artifacts.""" + + systemd.service( + name="Stop and disable mtg service", + service="mtg", + running=False, + enabled=False, + _sudo=True, + ) + + files.file( + name="Remove mtg systemd unit", + path="/etc/systemd/system/mtg.service", + present=False, + _sudo=True, + ) + + systemd.daemon_reload( + name="Reload systemd after removing mtg unit", + _sudo=True, + ) + + files.file( + name="Remove mtg binary", + path=MTG_EXEC, + present=False, + _sudo=True, + ) + + files.directory( + name="Remove mtg config directory", + path=MTG_CONFIG_DIR, + present=False, + _sudo=True, + ) + + +def _cleanup_telemt() -> None: + """Stop and remove all Telemt provider artifacts.""" + + systemd.service( + name="Stop and disable telemt service", + service="telemt", + running=False, + enabled=False, + _sudo=True, + ) + + files.file( + name="Remove telemt systemd unit", + path="/etc/systemd/system/telemt.service", + present=False, + _sudo=True, + ) + + systemd.daemon_reload( + name="Reload systemd after removing telemt unit", + _sudo=True, + ) + + files.file( + name="Remove telemt binary", + path=TELEMT_EXEC, + present=False, + _sudo=True, + ) + + files.directory( + name="Remove telemt config directory", + path=TELEMT_CONFIG_DIR, + present=False, + _sudo=True, + ) + + +def _ensure_user(user: str, group: str, config_dir: str) -> None: + """Ensure a system user, group, and config directory exist.""" + + if group not in host.get_fact(Groups): + server.group( + name=f"Ensure {group} group exists", + group=group, + system=True, + _sudo=True, + ) + + if user not in host.get_fact(Users): + server.user( # noqa: S604 + name=f"Ensure {user} system user exists", + user=user, + system=True, + group=group, + shell="/bin/false", + create_home=False, + _sudo=True, + ) + + files.directory( + name=f"Ensure {config_dir} configuration directory exists", + path=config_dir, + user=user, + group=group, + mode="0755", + _sudo=True, + ) + + +def _install_mtg(opts: MtprotoMold) -> None: + """Download and install mtg binary from GitHub release tarball.""" + + if host.get_fact(File, MTG_EXEC): + return + + versions = Versions(host) + tar_url = versions.mtg_tar() + binary_in_tar = versions.mtg_binary_in_tar() + + tar_path = "/tmp/mtg.tar.gz" + server.shell( + name="Download mtg tarball", + commands=[f"curl -L {CURL_ARGS_STR} {tar_url} -o {tar_path}"], + ) + + server.shell( + name="Extract and install mtg binary", + commands=[ + f"tar -xzf {tar_path} -C /tmp {binary_in_tar}", + f"mv /tmp/{binary_in_tar} {MTG_EXEC}", + ], + _sudo=True, + ) + + files.file( + name="Set mtg binary as executable", + path=MTG_EXEC, + mode="0755", + _sudo=True, + ) + + +def _deploy_mtg_service(opts: MtprotoMold) -> None: + """Deploy and enable mtg systemd service.""" + + config_path = f"{MTG_CONFIG_DIR}/config.toml" + files.template( + name="Deploy mtg config", + src=get_mtg_template("config.toml.j2"), + dest=config_path, + user=MTG_USER, + group=MTG_GROUP, + mode="0640", + SECRET=opts.secret, + PORT=opts.port, + ENABLE_IPV6=has_ipv6(host), + _sudo=True, + ) + + service_path = "/etc/systemd/system/mtg.service" + service_template = files.template( + name="Deploy mtg systemd service", + src=get_systemd_template("mtg.service.j2"), + dest=service_path, + mode="0644", + CONFIG_PATH=config_path, + CONFIG_DIR=MTG_CONFIG_DIR, + PORT=opts.port, + _sudo=True, + ) + + systemd.daemon_reload( + name="Reload systemd daemon for mtg", + _sudo=True, + _if=service_template.did_change, + ) + + systemd.service( + name="Enable and start mtg service", + service="mtg", + running=True, + restarted=True, + enabled=True, + _sudo=True, + ) + + +def _install_telemt() -> None: + """Download and install telemt binary from the latest GitHub release.""" + + if host.get_fact(File, TELEMT_EXEC): + return + + versions = Versions(host) + tar_url = versions.telemt_tar() + + server.shell( + name="Download and extract telemt binary", + commands=[f"curl -L {CURL_ARGS_STR} {tar_url} | tar -xz -C /tmp telemt"], + ) + + server.shell( + name="Install telemt binary", + commands=[f"mv /tmp/telemt {TELEMT_EXEC}"], + _sudo=True, + ) + + files.file( + name="Set telemt binary as executable", + path=TELEMT_EXEC, + mode="0755", + _sudo=True, + ) + + +def _deploy_telemt_service(opts: MtprotoMold) -> None: + """Deploy and enable telemt systemd service.""" + + config_path = f"{TELEMT_CONFIG_DIR}/telemt.toml" + files.template( + name="Deploy telemt config", + src=get_telemt_template("telemt.toml.j2"), + dest=config_path, + user=TELEMT_USER, + group=TELEMT_GROUP, + mode="0640", + PORT=opts.port, + TLS_DOMAIN=opts.tls_domain, + USERS=opts.users, + _sudo=True, + ) + + service_path = "/etc/systemd/system/telemt.service" + service_template = files.template( + name="Deploy telemt systemd service", + src=get_systemd_template("telemt.service.j2"), + dest=service_path, + mode="0644", + PORT=opts.port, + _sudo=True, + ) + + systemd.daemon_reload( + name="Reload systemd daemon for telemt", + _sudo=True, + _if=service_template.did_change, + ) + + systemd.service( + name="Enable and start telemt service", + service="telemt", + running=True, + restarted=True, + enabled=True, + _sudo=True, + ) + + +deploy_mtproto() diff --git a/nullforge/runes/netsec.py b/nullforge/runes/netsec.py new file mode 100644 index 0000000..e4742d6 --- /dev/null +++ b/nullforge/runes/netsec.py @@ -0,0 +1,243 @@ +"""Network security and hardening deployment module.""" + +import hashlib +import json + +from pyinfra.context import host +from pyinfra.facts.files import File, FileContents +from pyinfra.facts.server import Command +from pyinfra.operations import server, systemd + +from nullforge.molds import FeaturesMold, NetSecMold, UserMold +from nullforge.molds.netsec import UfwRule +from nullforge.smithy.network import has_ipv6 +from nullforge.smithy.packages import get_pm + + +_UFW_CHECKSUM_FILE = "/etc/ufw/.nullforge_checksum" + + +def deploy_network_security() -> None: + """Deploy network security and hardening configuration.""" + + features: FeaturesMold = host.data.features + users: UserMold = features.users + netsec_opts: NetSecMold = features.netsec + + _enhance_ssh_daemon(users) + + if netsec_opts.ufw: + _configure_ufw_firewall(netsec_opts) + + _apply_sysctl_tuning(netsec_opts) + + +def _rules_checksum(rules: list[UfwRule]) -> str: + """Compute a deterministic SHA-256 of the desired active ruleset. + + Rules are serialised as sorted JSON objects and then sorted themselves so + order in inventory does not affect fingerprint. Any change to ruleset: + add rule, remove rule, modify field will produces different hash, + triggering a full UFW reset on next run. + """ + + fingerprints = sorted(json.dumps(r.model_dump(), sort_keys=True) for r in rules) + payload = "\n".join(fingerprints) + return hashlib.sha256(payload.encode()).hexdigest() + + +def _build_ufw_command(rule: UfwRule) -> str: + """Build complete `ufw` CLI command string from `UfwRule`. + + Produces the canonical long-form command: + ufw ACTION [DIRECTION] [on IFACE] [proto PROTO] + from ADDR to ADDR [port PORT] [comment "LABEL"] + """ + + parts: list[str] = ["ufw", rule.action] + + if rule.direction: + parts.append(rule.direction) + + if rule.interface: + parts += ["on", rule.interface] + + if rule.proto != "any": + parts += ["proto", rule.proto] + + parts += ["from", rule.from_ip or "any"] + parts += ["to", rule.to_ip or "any"] + + if rule.port is not None: + parts += ["port", str(rule.port)] + + if rule.comment: + parts += ["comment", f'"{rule.comment}"'] + + return " ".join(parts) + + +def _configure_ufw_firewall(opts: NetSecMold) -> None: + """Configure UFW firewall with the specified rule set. + + IPv6-targeted rules (those whose source/destination is an IPv6 address or + subnet) are silently dropped when the host has no IPv6 connectivity. + """ + + ipv6 = has_ipv6(host) + active_rules = [r for r in opts.ufw_rules if ipv6 or not r.is_ipv6] + checksum = _rules_checksum(active_rules) + + pm = get_pm() + pm.install( + name="Install UFW firewall", + packages=["ufw"], + _sudo=True, + ) + + cmd_get_ufw = "ufw status 2>/dev/null || true" + if not opts.reinstall: + ufw_status = host.get_fact(Command, cmd_get_ufw, _sudo=True) or "" + if "Status: active" in ufw_status: + stored = host.get_fact(Command, f"cat {_UFW_CHECKSUM_FILE} 2>/dev/null || true", _sudo=True) or "" + if stored.strip() == checksum: + return + + server.shell( + name="Reset UFW to clean state", + commands=[ + "ufw --force reset", + ], + _sudo=True, + ) + + ipv6_value = "yes" if ipv6 else "no" + server.shell( + name=f"Configure UFW IPv6={ipv6_value}", + commands=[ + f"sed -i 's/^IPV6=.*/IPV6={ipv6_value}/' /etc/default/ufw", + ], + _sudo=True, + ) + + server.shell( + name="Set UFW default policies", + commands=[ + "ufw default deny incoming", + "ufw default allow outgoing", + "ufw default allow forward", + ], + _sudo=True, + ) + + for rule in active_rules: + cmd = _build_ufw_command(rule) + label = f"UFW {rule.action}: {rule.comment or cmd}" + server.shell( + name=label, + commands=[cmd], + _sudo=True, + ) + + server.shell( + name="Enable UFW firewall", + commands=[ + "yes | ufw enable", + ], + _sudo=True, + ) + + server.shell( + name="Persist UFW ruleset checksum", + commands=[ + f"printf '%s' '{checksum}' > {_UFW_CHECKSUM_FILE}", + ], + _sudo=True, + ) + + +def _enhance_ssh_daemon(user_opts: UserMold) -> None: + """Enhance SSH daemon configuration.""" + + pm = get_pm() + ssh_config_path = "/etc/ssh/sshd_config" + sshd_config: list[str] = host.get_fact( + FileContents, + path=ssh_config_path, + _sudo=True, + ) + lines = [line.strip() for line in sshd_config if line.strip()] + + add_password_auth = user_opts.manage and "PasswordAuthentication no" not in lines + add_root_login = "PermitRootLogin no" not in lines + add_usedns = "UseDNS yes" not in lines + + if add_password_auth: + sed_modify = r"s/^[[:space:]]*#?[[:space:]]*PasswordAuthentication[[:space:]]+.*/PasswordAuthentication no/" + server.shell( + name="Modify SSH password authentication", + commands=[ + f"sed -i -E '{sed_modify}' {ssh_config_path}", + ], + _sudo=True, + ) + + if add_root_login: + sed_modify = r"s/^[[:space:]]*#?[[:space:]]*PermitRootLogin[[:space:]]+.*/PermitRootLogin no/" + server.shell( + name="Disable SSH root login", + commands=[ + f"sed -i -E '{sed_modify}' {ssh_config_path}", + ], + _sudo=True, + ) + + if add_usedns: + sed_modify = r"s/^[[:space:]]*#?[[:space:]]*UseDNS[[:space:]]+.*/UseDNS yes/" + server.shell( + name="Enable DNS resolution for SSH", + commands=[ + f"sed -i -E '{sed_modify}' {ssh_config_path}", + ], + _sudo=True, + ) + + if any([add_password_auth, add_root_login, add_usedns]): + service_name = "ssh" if pm.is_debian_based else "sshd" + systemd.service( + name="Restart SSH service with modified configuration", + service=service_name, + running=True, + restarted=True, + _sudo=True, + ) + + +def _apply_sysctl_tuning(opts: NetSecMold) -> None: + """Apply system kernel parameter tuning.""" + + persist_map = [ + ("system", opts.system_sysctl, "/etc/sysctl.d/99-system.conf"), + ("ipv4", opts.ipv4_sysctl, "/etc/sysctl.d/99-ipv4.conf"), + ("ipv6", opts.ipv6_sysctl if has_ipv6(host) else None, "/etc/sysctl.d/99-ipv6.conf"), + ] + + for desc, sysctls, persist_file in persist_map: + if not sysctls: + continue + + if host.get_fact(File, path=persist_file): + continue + + for key, value in sysctls.items(): + server.sysctl( + name=f"Set sysctl {key} ({desc})", + key=key, + value=str(value), + persist=True, + persist_file=persist_file, + _sudo=True, + ) + + +deploy_network_security() diff --git a/nullforge/runes/prepare.py b/nullforge/runes/prepare.py new file mode 100644 index 0000000..b8d11cc --- /dev/null +++ b/nullforge/runes/prepare.py @@ -0,0 +1,34 @@ +"""Prepare the system for deployment.""" + +from pyinfra.context import host + +from nullforge.smithy.admin import is_root +from nullforge.smithy.packages import get_pm + + +def prepare() -> None: + """Prepare the system for deployment.""" + + if is_root(host): + _prepare_sudo() + + +def _prepare_sudo() -> None: + """As some distros don't have sudo installed by default, we ensure to have it.""" + + pm = get_pm() + + pm.update( + name="Update package lists", + ) + + pm.install( + name="Install minimal packages", + packages=[ + "sudo", + "locales", + ], + ) + + +prepare() diff --git a/nullforge/runes/profiles.py b/nullforge/runes/profiles.py new file mode 100644 index 0000000..491c2c5 --- /dev/null +++ b/nullforge/runes/profiles.py @@ -0,0 +1,493 @@ +"""Shell profiles and tools deployment module.""" + +import io + +from pyinfra.context import host +from pyinfra.facts.files import Directory, File +from pyinfra.operations import files, git, server + +from nullforge.molds import FeaturesMold, ProfilesMold +from nullforge.molds.user import UserMold +from nullforge.smithy.http import CURL_ARGS_STR +from nullforge.smithy.packages import get_pm +from nullforge.smithy.versions import STATIC_URLS, ZSH_PLUGINS, Versions +from nullforge.templates import get_nvim_template, get_profile_template + + +def deploy_shell_profiles() -> None: + """Deploy shell profiles and tools configuration.""" + + features: FeaturesMold = host.data.features + profiles_opts: ProfilesMold = features.profiles + reinstall = profiles_opts.reinstall + + _install_eza(reinstall) + + _install_tmux(reinstall) + + _install_nvim(reinstall) + + for user, home_dir in _get_profile_targets(features): + _install_user_profiles(user, home_dir, reinstall) + + +def _get_profile_targets(features: FeaturesMold) -> list[tuple[str, str]]: + """Get list of users and home directories for profile installation.""" + + users_opts: UserMold = features.users + profiles_opts: ProfilesMold = features.profiles + targets = [] + if profiles_opts.for_root and users_opts.manage: + targets.append(("root", "/root")) + + if profiles_opts.for_user: + user = users_opts.name + targets.append((user, f"/home/{user}")) + + return targets + + +def _install_user_profiles(user: str, home_dir: str, reinstall: bool) -> None: + """Configure user profile.""" + + _configure_user_oh_my_zsh(user, home_dir, reinstall) + _configure_user_shell_profiles(user, home_dir) + _install_firacode_font(user, home_dir, reinstall) + _install_user_tmux(user, home_dir, reinstall) + _install_user_nvim(user, home_dir, reinstall) + _install_atuin(user, home_dir, reinstall) + _install_starship(reinstall) + + +def _configure_user_oh_my_zsh(user: str, home_dir: str, reinstall: bool) -> None: + """Install oh-my-zsh and its plugins for a specific user.""" + + oh_my_zsh_dir = f"{home_dir}/.oh-my-zsh" + plugins_dir = f"{oh_my_zsh_dir}/custom/plugins" + fact_kwargs = {"_sudo": True, "_sudo_user": user} + + if reinstall and host.get_fact(Directory, oh_my_zsh_dir, **fact_kwargs): + server.shell( + name=f"Remove existing oh-my-zsh for {user}", + commands=[f"rm -rf {oh_my_zsh_dir}"], + _sudo=True, + _sudo_user=user, + ) + + if not host.get_fact(Directory, oh_my_zsh_dir, **fact_kwargs): + git.repo( + name=f"Install oh-my-zsh for {user}", + src="https://github.com/ohmyzsh/ohmyzsh", + dest=oh_my_zsh_dir, + _sudo=True, + _sudo_user=user, + ) + + for plugin_name, plugin_src in ZSH_PLUGINS.items(): + plugin_dir = f"{plugins_dir}/{plugin_name}" + if reinstall and host.get_fact(Directory, plugin_dir, **fact_kwargs): + server.shell( + name=f"Remove existing {plugin_name} plugin for {user}", + commands=[f"rm -rf {plugin_dir}"], + _sudo=True, + _sudo_user=user, + ) + if not host.get_fact(Directory, plugin_dir, **fact_kwargs): + git.repo( + name=f"Install {plugin_name} plugin for {user}", + src=plugin_src, + dest=plugin_dir, + _sudo=True, + _sudo_user=user, + ) + + +def _configure_user_shell_profiles(user: str, home_dir: str) -> None: + """Configure shell profiles (.zshrc, starship, direnv) for a specific user.""" + + files.template( + name=f"Configure .zshrc for {user}", + src=get_profile_template("zshrc.j2"), + dest=f"{home_dir}/.zshrc", + mode="0644", + home=home_dir, + _sudo=True, + _sudo_user=user, + ) + + with open(get_profile_template("starship.toml")) as f: + starship_config = f.read() + + files.put( + name=f"Configure starship prompt for {user}", + src=io.StringIO(starship_config), + dest=f"{home_dir}/.config/starship.toml", + mode="0644", + _sudo=True, + _sudo_user=user, + ) + + with open(get_profile_template("direnv.toml")) as f: + direnv_config = f.read() + + files.put( + name=f"Configure direnv for {user}", + src=io.StringIO(direnv_config), + dest=f"{home_dir}/.config/direnv/direnv.toml", + mode="0644", + create_remote_dir=True, + _sudo=True, + _sudo_user=user, + ) + + +def _install_firacode_font(user: str, home_dir: str, reinstall: bool = False) -> None: + """Install FiraCode NerdFont for the user.""" + + font_path = f"{home_dir}/.fonts/FiraCode/FiraCodeNerdFont-Regular.ttf" + fact_kwargs = {"_sudo": True, "_sudo_user": user} + if not reinstall and host.get_fact(File, font_path, **fact_kwargs): + return + + nerd_fonts_installer_path = "/tmp/nerd-fonts-installer.sh" + if not host.get_fact(File, nerd_fonts_installer_path): + curl_cmd = f"curl -L {CURL_ARGS_STR} {STATIC_URLS['nerd_fonts_installer']} -o {nerd_fonts_installer_path}" + server.shell( + name="Download nerd fonts installer", + commands=[curl_cmd], + ) + + files.file( + name="Set nerd fonts installer as executable", + path=nerd_fonts_installer_path, + mode="0755", + ) + + find_num_cmd = ( + f"grep 'fons_list=' {nerd_fonts_installer_path} | grep -oP '\"[^\"]+\"' | grep -n '\"FiraCode\"' | cut -d: -f1" + ) + install_cmd = f'firacode_num=$({find_num_cmd}) && echo "$firacode_num" | bash {nerd_fonts_installer_path}' + server.shell( + name=f"Install FiraCode NerdFont for {user}", + commands=[install_cmd], + _sudo=True, + _sudo_user=user, + ) + + files.file( + name="Remove FiraCode NerdFont zip file", + path="FiraCode.zip", + present=False, + ) + + +def _install_user_tmux(user: str, home_dir: str, reinstall: bool = False) -> None: + """Install and configure tmux for a specific user.""" + + tpm_dir = f"{home_dir}/.tmux/plugins/tpm" + config_dir = f"{home_dir}/.config/tmux" + fact_kwargs = {"_sudo": True, "_sudo_user": user} + + if reinstall or not host.get_fact(Directory, tpm_dir, **fact_kwargs): + server.shell( + name=f"Remove existing tmux configs for {user}", + commands=[ + f"rm -rf {home_dir}/.tmux", + f"rm -rf {config_dir}", + ], + _sudo=True, + _sudo_user=user, + ) + + git.repo( + name=f"Install TPM (Tmux Plugin Manager) for {user}", + src="https://github.com/tmux-plugins/tpm", + dest=tpm_dir, + _sudo=True, + _sudo_user=user, + ) + + files.directory( + name=f"Create tmux config directory for {user}", + path=config_dir, + mode="0755", + _sudo=True, + _sudo_user=user, + ) + + with open(get_profile_template("tmux.conf")) as f: + tmux_conf = f.read() + + files.put( + name=f"Setup tmux config for {user}", + src=io.StringIO(tmux_conf), + dest=f"{config_dir}/tmux.conf", + mode="0644", + _sudo=True, + _sudo_user=user, + ) + + +def _install_user_nvim(user: str, home_dir: str, reinstall: bool = False) -> None: + """Install and configure nvim/NvChad for a specific user.""" + + fact_kwargs = {"_sudo": True, "_sudo_user": user} + if not reinstall: + if host.get_fact(File, f"{home_dir}/.config/nvim/lua/chadrc.lua", **fact_kwargs): + return + + server.shell( + name=f"Remove existing nvim for {user}", + commands=[ + f"rm -rf {home_dir}/.config/nvim", + f"rm -rf {home_dir}/.local/state/nvim", + f"rm -rf {home_dir}/.local/share/nvim", + ], + _sudo=True, + _sudo_user=user, + ) + + git.repo( + name=f"Install NvChad for {user}", + src="https://github.com/NvChad/starter", + dest=f"{home_dir}/.config/nvim", + _sudo=True, + _sudo_user=user, + ) + + with open(get_nvim_template("nvim_patch.lua.j2")) as f: + nvim_patch = f.read() + + files.block( + name=f"Apply nvim patch to init.lua for {user}", + path=f"{home_dir}/.config/nvim/init.lua", + content=nvim_patch, + marker="-- {mark} Change cursor to original after exiting vim", + _sudo=True, + _sudo_user=user, + ) + + server.shell( + name=f"Change theme to tokyo-night for {user}", + commands=[f'sed -i "s/onedark/tokyonight/" {home_dir}/.config/nvim/lua/chadrc.lua'], + _sudo=True, + _sudo_user=user, + ) + + +def _install_starship(reinstall: bool = False) -> None: + """Install starship prompt.""" + + pm = get_pm() + + if pm.is_debian_based and pm.distro_major in [13, 25]: + if reinstall or not host.get_fact(File, "/usr/bin/starship"): + _install_starship_from_repo() + else: + if reinstall or not host.get_fact(File, "/usr/local/bin/starship"): + _install_starship_from_script() + + +def _install_starship_from_repo() -> None: + """Install starship prompt from repo.""" + + pm = get_pm() + pm.install( + name="Install starship from repo", + packages=["starship"], + _sudo=True, + ) + + +def _install_starship_from_script() -> None: + """Install starship prompt from script.""" + + starship_install_path = "/tmp/starship.sh" + if not host.get_fact(File, starship_install_path): + curl_cmd = f"curl -L {CURL_ARGS_STR} {STATIC_URLS['starship_install']} -o {starship_install_path}" + server.shell( + name="Download starship installation script", + commands=[curl_cmd], + _sudo=True, + ) + + files.file( + name="Set starship installation script as executable", + path=starship_install_path, + mode="0755", + _sudo=True, + ) + + server.shell( + name="Install starship prompt", + commands=[f"sh {starship_install_path} --yes"], + _sudo=True, + ) + + +def _install_atuin(user: str, home_dir: str, reinstall: bool = False) -> None: + """Install atuin for better shell history.""" + + fact_kwargs = {"_sudo": True, "_sudo_user": user} + if not reinstall and host.get_fact(File, f"{home_dir}/.atuin/bin/atuin", **fact_kwargs): + return + + atuin_install_path = "/tmp/atuin.sh" + if not host.get_fact(File, atuin_install_path): + curl_cmd = f"curl -L {CURL_ARGS_STR} {STATIC_URLS['atuin_install']} -o {atuin_install_path}" + server.shell( + name=f"Download atuin installation script for {user}", + commands=[curl_cmd], + _sudo=True, + ) + files.file( + name="Set atuin installation script as executable", + path=atuin_install_path, + mode="0755", + _sudo=True, + ) + + server.shell( + name=f"Install atuin installation script for {user}", + commands=[f"sh {atuin_install_path} -- --non-interactive"], + _sudo=True, + _sudo_user=user, + ) + + +def _install_eza(reinstall: bool = False) -> None: + """Install eza binary for enhanced ls functionality.""" + + if not reinstall and host.get_fact(File, "/usr/local/bin/eza"): + return + + eza_tar_path = "/tmp/eza.tar.gz" + curl_cmd = f"curl -L {CURL_ARGS_STR} {Versions(host).eza_tar()} -o {eza_tar_path}" + server.shell( + name="Download eza binary", + commands=[curl_cmd], + _sudo=True, + ) + + server.shell( + name="Extract eza and install eza binary", + commands=[ + f"tar -xzvf {eza_tar_path} -C /tmp/ && rm -f {eza_tar_path}", + "mv /tmp/eza /usr/local/bin/eza", + ], + _sudo=True, + ) + + files.file( + name="Set eza binary as executable", + path="/usr/local/bin/eza", + mode="0755", + _sudo=True, + ) + + +def _install_tmux(reinstall: bool = False) -> None: + """Install tmux package.""" + + if not reinstall and host.get_fact(File, "/usr/local/bin/tmux"): + return + + tmux_tar_path = "/tmp/tmux.tar.gz" + curl_cmd = f"curl -L {CURL_ARGS_STR} {Versions(host).tmux_tar()} -o {tmux_tar_path}" + server.shell( + name="Download tmux source", + commands=[curl_cmd], + ) + + server.shell( + name="Extract and build tmux", + commands=[ + f"tar -zxf {tmux_tar_path} -C /tmp/ && rm -f {tmux_tar_path}", + "cd /tmp/tmux-*/ && ./configure", + "cd /tmp/tmux-*/ && make -j$(nproc) && sudo make install", + "rm -rf /tmp/tmux-*/", + ], + _sudo=True, + ) + + pm = get_pm() + pm.install( + name="Remove existing tmux package", + packages=["tmux"], + present=False, + _sudo=True, + ) + + +def _install_nvim(reinstall: bool = False) -> None: + """Install nvim package.""" + + if not reinstall and host.get_fact(File, "/usr/bin/nvim-source/AppRun"): + return + + if reinstall: + server.shell( + name="Remove existing nvim binary installation", + commands=[ + "rm -rf /usr/bin/nvim-source", + "rm -f /usr/bin/nvim", + ], + _sudo=True, + ) + + nvim_appimage_path = "/tmp/nvim.appimage" + curl_cmd = f"curl -L {CURL_ARGS_STR} {Versions(host).nvim_appimage()} -o {nvim_appimage_path}" + server.shell( + name="Download nvim appimage", + commands=[curl_cmd], + ) + + files.file( + name="Set nvim appimage as executable", + path="/tmp/nvim.appimage", + mode="0755", + _sudo=True, + ) + + files.directory( + name="Create nvim source directory", + path="/usr/bin/nvim-source", + mode="0755", + _sudo=True, + ) + + server.shell( + name="Extract nvim appimage to target directory", + commands=[f"cd /usr/bin && {nvim_appimage_path} --appimage-extract"], + _sudo=True, + ) + + server.shell( + name="Move extracted contents to nvim-source", + commands=["mv /usr/bin/squashfs-root/* /usr/bin/nvim-source/"], + _sudo=True, + ) + + files.directory( + name="Remove empty squashfs-root directory", + path="/usr/bin/squashfs-root", + present=False, + _sudo=True, + ) + + files.link( + name="Create symlink to nvim", + path="/usr/bin/nvim", + target="/usr/bin/nvim-source/AppRun", + _sudo=True, + ) + + files.file( + name="Remove nvim appimage file", + path=nvim_appimage_path, + present=False, + _sudo=True, + ) + + +deploy_shell_profiles() diff --git a/nullforge/runes/swap.py b/nullforge/runes/swap.py new file mode 100644 index 0000000..04935c0 --- /dev/null +++ b/nullforge/runes/swap.py @@ -0,0 +1,136 @@ +"""Swap configuration module.""" + +from pyinfra.context import host +from pyinfra.facts.files import File +from pyinfra.operations import files, server, systemd + +from nullforge.molds.system import SwapType, SystemMold +from nullforge.smithy.packages import get_pm +from nullforge.templates import get_etc_template + + +def configure_swap(system: SystemMold) -> None: + """Configure system swap.""" + + if not system.swap.enabled: + _disable_swap() + return + + _set_swappiness(system.swap.swappiness) + + if system.swap.type == SwapType.ZRAM: + _disable_basic_swap() + _configure_zram(system.swap.size) + else: + _disable_zram() + _configure_basic_swap(system.swap.size) + + +def _disable_swap() -> None: + """Disable all swap.""" + + _disable_basic_swap() + _disable_zram() + + +def _disable_basic_swap() -> None: + """Disable basic swap file.""" + + if host.get_fact(File, "/swapfile"): + server.shell( + name="Turn off swapfile", + commands=["swapoff /swapfile"], + _sudo=True, + ) + + files.file( + name="Remove swapfile", + path="/swapfile", + present=False, + _sudo=True, + ) + + files.line( + name="Remove swapfile from fstab", + path="/etc/fstab", + line="/swapfile none swap sw 0 0", + present=False, + _sudo=True, + ) + + +def _disable_zram() -> None: + """Disable ZRAM swap.""" + + pm = get_pm() + pm.install( + name="Remove zram-tools", + packages=["zram-tools"], + present=False, + _sudo=True, + ) + + +def _set_swappiness(value: int) -> None: + """Set system swappiness.""" + + server.sysctl( + name=f"Set swappiness to {value}", + key="vm.swappiness", + value=value, + persist=True, + persist_file="/etc/sysctl.d/99-swappiness.conf", + _sudo=True, + ) + + +def _configure_zram(size: str) -> None: + """Configure ZRAM swap.""" + + pm = get_pm() + pm.install( + name="Install zram-tools", + packages=["zram-tools"], + _sudo=True, + ) + + files.template( + name="Configure zram-tools", + src=get_etc_template("default/zramswap.j2"), + dest="/etc/default/zramswap", + mode="0644", + size=size, + _sudo=True, + ) + + systemd.service( + name="Restart zramswap service", + service="zramswap", + running=True, + enabled=True, + restarted=True, + _sudo=True, + ) + + +def _configure_basic_swap(size: str) -> None: + """Configure basic swap file.""" + + if not host.get_fact(File, "/swapfile"): + server.shell( + name=f"Create swapfile of size {size}", + commands=[ + f"fallocate -l {size} /swapfile", + "chmod 600 /swapfile", + "mkswap /swapfile", + "swapon /swapfile", + ], + _sudo=True, + ) + + files.line( + name="Add swapfile to fstab", + path="/etc/fstab", + line="/swapfile none swap sw 0 0", + _sudo=True, + ) diff --git a/nullforge/runes/tor.py b/nullforge/runes/tor.py new file mode 100644 index 0000000..51f3717 --- /dev/null +++ b/nullforge/runes/tor.py @@ -0,0 +1,50 @@ +"""Tor proxy deployment module.""" + +from pyinfra.context import host +from pyinfra.operations import files, systemd + +from nullforge.molds import FeaturesMold, TorMold +from nullforge.smithy.packages import get_pm +from nullforge.templates import get_tor_template + + +def deploy_tor() -> None: + """Deploy Tor proxy configuration.""" + + features: FeaturesMold = host.data.features + tor_opts: TorMold = features.tor + + _install_tor(tor_opts) + + +def _install_tor(opts: TorMold) -> None: + """Install Tor proxy.""" + + pm = get_pm() + pm.install( + name="Install Tor package", + packages=["tor"], + _sudo=True, + ) + + files.template( + name="Deploy Tor proxy configuration", + src=get_tor_template("torrc.j2"), + dest="/etc/tor/torrc", + mode="0644", + SOCKS_PORT=opts.socks_port, + DNS_PORT=opts.dns_port, + _sudo=True, + ) + + systemd.service( + name="Enable and start Tor", + service="tor", + running=True, + restarted=True, + enabled=True, + _sudo=True, + ) + + +deploy_tor() diff --git a/nullforge/runes/users.py b/nullforge/runes/users.py new file mode 100644 index 0000000..89eb2ca --- /dev/null +++ b/nullforge/runes/users.py @@ -0,0 +1,135 @@ +"""User management deployment module.""" + +import io + +from pyinfra.context import host +from pyinfra.facts.files import FileContents +from pyinfra.facts.server import Home +from pyinfra.operations import files, server + +from nullforge.molds import FeaturesMold, UserMold + + +def deploy_user_management() -> None: + """Deploy user management configuration.""" + + features: FeaturesMold = host.data.features + user_opts: UserMold = features.users + + if user_opts.sudo: + groups = ["sudo"] + operation_name = f"Create user {user_opts.name} with sudo access" + else: + groups = [] + operation_name = f"Create user {user_opts.name}" + + user_created = server.user( + name=operation_name, + user=user_opts.name, + shell=user_opts.shell_path, + groups=groups, + append=True, + create_home=True, + _sudo=True, + ) + + if user_created.did_succeed: + if user_opts.password: + _set_user_password(user_opts) + else: + _configure_passwordless_sudo(user_opts) + + if user_opts.copy_root_keys: + _copy_ssh_keys(user_opts) + + if user_opts.set_root_shell_like_user: + server.user( + name="Set root shell to user's shell", + user="root", + shell=user_opts.shell_path, + _sudo=True, + ) + + +def _set_user_password(opts: UserMold) -> None: + """Set user password.""" + + escaped_username = opts.name.replace("'", "'\\''") + escaped_password = opts.password.replace("'", "'\\''") # ty:ignore[unresolved-attribute] + server.shell( + name=f"Set password for user {opts.name}", + commands=[ + f"printf '%s:%s\\n' '{escaped_username}' '{escaped_password}' | chpasswd", + ], + _sudo=True, + ) + + +def _configure_passwordless_sudo(opts: UserMold) -> None: + """Configure passwordless sudo for user when no password is set.""" + + username = opts.name + sudoers_file = f"/etc/sudoers.d/{username}" + sudoers_line = f"{username} ALL=(ALL) NOPASSWD:ALL" + + files.put( + name=f"Configure passwordless sudo for {username}", + src=io.StringIO(f"{sudoers_line}\n"), + dest=sudoers_file, + mode="0440", + user="root", + group="root", + _sudo=True, + ) + + +def _copy_ssh_keys(opts: UserMold) -> None: + """Copy SSH keys from current user to new user.""" + + username = opts.name + current_user_home = host.get_fact(Home) + new_user_home = host.get_fact(Home, user=username) + + ssh_keys = host.get_fact(FileContents, f"{current_user_home}/.ssh/authorized_keys") or [] + + # Filter out empty lines and comments + valid_keys = [key for key in ssh_keys if key.strip() and not key.strip().startswith("#")] + + if valid_keys: + files.directory( + name=f"Ensure SSH directory for {username}", + path=f"{new_user_home}/.ssh", + user=username, + group=username, + mode="0700", + _sudo=True, + ) + + files.directory( + name=f"Ensure SSH sockets directory for {username}", + path=f"{new_user_home}/.ssh/sockets", + user=username, + group=username, + mode="0700", + _sudo=True, + ) + + server.user_authorized_keys( + name=f"Add SSH keys for {username}", + user=username, + group=username, + public_keys=valid_keys, + _sudo=True, + ) + + files.file( + name=f"Set SSH key permissions for {username}", + path=f"{new_user_home}/.ssh/authorized_keys", + user=username, + group=username, + mode="0600", + _sudo=True, + ) + + +deploy_user_management() diff --git a/nullforge/runes/warp.py b/nullforge/runes/warp.py new file mode 100644 index 0000000..acd7a2a --- /dev/null +++ b/nullforge/runes/warp.py @@ -0,0 +1,314 @@ +"""Cloudflare WARP deployment module.""" + +from pyinfra.context import host +from pyinfra.facts.files import File +from pyinfra.operations import files, server, systemd +from pyinfra.operations.util import any_changed + +from nullforge.models.warp import WarpEngineType +from nullforge.molds import FeaturesMold, WarpMold +from nullforge.runes.cloudflare import CLOUDFLARE_GROUP, CLOUDFLARE_USER, ensure_cloudflare_user +from nullforge.smithy.http import CURL_ARGS_STR +from nullforge.smithy.network import has_ipv6 +from nullforge.smithy.packages import get_pm +from nullforge.smithy.versions import Versions +from nullforge.templates import get_script_template, get_systemd_template + + +def succes_condition(): + return True + + +def deploy_warp() -> None: + """Deploy Cloudflare WARP configuration.""" + + features: FeaturesMold = host.data.features + warp_opts: WarpMold = features.warp + + ensure_cloudflare_user() + + files.directory( + name="Ensure WARP engine configuration directory exists", + path=warp_opts.engine.config_dir, + user=CLOUDFLARE_USER, + group=CLOUDFLARE_GROUP, + mode="0755", + _sudo=True, + ) + + match warp_opts.engine_type: + case WarpEngineType.WIREGUARD: + _deploy_wireguard_warp(warp_opts) + case WarpEngineType.MASQUE: + _deploy_masque_warp(warp_opts) + _deploy_warp_health_check(warp_opts) + + +def _install_wgcf(opts: WarpMold) -> None: + """Install wgcf binary.""" + + if host.get_fact(File, opts.engine.binary_path): + return + + wgcf_bin_path = "/tmp/wgcf" + curl_cmd = f"curl -L {CURL_ARGS_STR} {Versions(host).wgcf()} -o {wgcf_bin_path}" + server.shell( + name="Download wgcf binary", + commands=[curl_cmd], + ) + + files.move( + name="Move wgcf binary to /usr/local/bin", + src=wgcf_bin_path, + dest="/usr/local/bin", + _sudo=True, + ) + + files.file( + name="Set wgcf binary as executable", + path=opts.engine.binary_path, + mode="0755", + ) + + +def _deploy_wireguard_warp(opts: WarpMold) -> None: + """Deploy WARP using WireGuard.""" + + pm = get_pm() + pm.install( + name="Install WireGuard packages", + packages=["wireguard", "wireguard-tools"], + _sudo=True, + ) + + _install_wgcf(opts) + + wgcf_account_path = opts.engine.account_path + wgcf_profile_path = opts.engine.profile_path + if not host.get_fact(File, wgcf_account_path): + server.shell( + name="Register wgcf account", + commands=f"wgcf register --accept-tos --config {wgcf_account_path}", + ) + + if not host.get_fact(File, wgcf_profile_path): + server.shell( + name="Generate WireGuard profile", + commands=f"wgcf generate --config {wgcf_account_path} --profile {wgcf_profile_path}", + ) + + server.shell( + name="Post-process WireGuard profile", + commands=f"sed -i '/^DNS = /d' {wgcf_profile_path} " + rf"&& sed -i '/^\[Interface\]/a Table = off' {wgcf_profile_path}", + ) + + files.link( + name="Link WireGuard profile to /etc/wireguard/warp.conf", + path=wgcf_profile_path, + target="/etc/wireguard/warp.conf", + _sudo=True, + ) + + systemd.service( + name="Enable and start WireGuard WARP", + service=opts.engine.systemd_service_name, + running=True, + restarted=True, + enabled=True, + _sudo=True, + ) + + +def _install_usque(opts: WarpMold) -> None: + """Install usque binary.""" + + if host.get_fact(File, opts.engine.binary_path): + return + + usque_zip_path = "/tmp/usque.zip" + curl_cmd = f"curl -L {CURL_ARGS_STR} {Versions(host).usque_zip()} -o {usque_zip_path}" + server.shell( + name="Download usque zip", + commands=[curl_cmd], + ) + + server.shell( + name="Extract and install usque binary", + commands=[ + f"unzip -o {usque_zip_path} -d /tmp/usque", + f"mv /tmp/usque/usque {opts.engine.binary_path}", + ], + _sudo=True, + ) + + files.file( + name="Set usque binary as executable", + path=opts.engine.binary_path, + mode="0755", + user="root", + group=CLOUDFLARE_GROUP, + _sudo=True, + ) + + +def _deploy_masque_warp(opts: WarpMold) -> None: + """Deploy WARP using Masque.""" + + _install_usque(opts) + + usque_config_path = opts.engine.config_path + if not host.get_fact(File, usque_config_path): + server.shell( + name="Enroll device in Warp", + commands=f"usque enroll -c {usque_config_path}", + _sudo=True, + ) + + register_op = server.shell( + name="Register device in Warp", + commands=f"usque register -c {usque_config_path} --accept-tos", + _sudo=True, + _retries=3, + _retry_delay=10, + _ignore_errors=True, + ) + config_condition = register_op.did_succeed + else: + config_condition = succes_condition + + files.file( + name="Ensure WARP config file ownership to cloudflare", + path=usque_config_path, + user=CLOUDFLARE_USER, + group=CLOUDFLARE_GROUP, + mode="0640", + _sudo=True, + _if=config_condition, + ) + + if opts.zero_trust: + server.shell( + name="Enroll device in Warp after ZeroTrust registration", + commands=f"usque enroll -c {usque_config_path}", + _sudo=True, + _if=config_condition, + ) + + ipv6_enabled = has_ipv6(host) + if ipv6_enabled: + rt_tables_dir = "/etc/iproute2" + rt_tables_path = f"{rt_tables_dir}/rt_tables" + files.directory( + name="Create /etc/iproute2 directory", + path=rt_tables_dir, + user="root", + group="root", + mode="0755", + _sudo=True, + _if=config_condition, + ) + + files.file( + name=f"Set {rt_tables_path} group to cloudflare", + path=rt_tables_path, + group=CLOUDFLARE_GROUP, + mode="0664", + _sudo=True, + _if=config_condition, + ) + + files.put( + name="Deploy WARP v6 policy script", + src=get_script_template("warp-v6-policy.sh"), + dest=opts.engine.policy_script, + user=CLOUDFLARE_USER, + group=CLOUDFLARE_GROUP, + mode="0755", + _sudo=True, + _if=config_condition, + ) + + service_template = files.template( + name="Deploy Masque WARP service configuration", + src=get_systemd_template("cloudflare-warp.service.j2"), + dest=f"/etc/systemd/system/{opts.engine.systemd_service_name}.service", + mode="0644", + WORKDIR=opts.engine.config_dir, + CONFIG_PATH=opts.engine.config_path, + MTU=opts.mtu, + INET_NAME=opts.iface, + ENABLE_IPV6=ipv6_enabled, + _sudo=True, + _if=config_condition, + ) + + systemd.daemon_reload( + name="Reload systemd daemon for Masque WARP", + _sudo=True, + _if=service_template.did_change, + ) + + systemd.service( + name="Enable and start Masque WARP service", + service=opts.engine.systemd_service_name, + running=True, + restarted=True, + enabled=True, + _sudo=True, + _if=service_template.did_change, + ) + + +def _deploy_warp_health_check(opts: WarpMold) -> None: + """Deploy periodic health check for WARP with auto-restart on failure.""" + + if not host.get_fact(File, opts.engine.health_check_script): + files.put( + name="Deploy WARP health check script", + src=get_script_template("warp-check.sh"), + dest=opts.engine.health_check_script, + user=CLOUDFLARE_USER, + group=CLOUDFLARE_GROUP, + mode="0755", + _sudo=True, + ) + + service_template = files.template( + name="Deploy WARP health check service", + src=get_systemd_template(f"{opts.engine.systemd_service_name}-check.service.j2"), + dest=f"/etc/systemd/system/{opts.engine.systemd_service_name}-check.service", + mode="0644", + HEALTH_CHECK_SCRIPT=opts.engine.health_check_script, + IFACE=opts.iface, + SERVICE_NAME=opts.engine.systemd_service_name, + _sudo=True, + ) + + timer_template = files.template( + name="Deploy WARP health check timer", + src=get_systemd_template(f"{opts.engine.systemd_service_name}-check.timer.j2"), + dest=f"/etc/systemd/system/{opts.engine.systemd_service_name}-check.timer", + mode="0644", + SERVICE_NAME=opts.engine.systemd_service_name, + _sudo=True, + ) + + systemd.daemon_reload( + name="Reload systemd daemon for Masque WARP health check", + _sudo=True, + _if=any_changed(service_template, timer_template), + ) + + systemd.service( + name="Enable and start Masque WARP health check timer", + service="cloudflare-warp-check.timer", + running=True, + restarted=True, + enabled=True, + _sudo=True, + _if=any_changed(service_template, timer_template), + ) + + +deploy_warp() diff --git a/nullforge/runes/xray.py b/nullforge/runes/xray.py new file mode 100644 index 0000000..0e2c954 --- /dev/null +++ b/nullforge/runes/xray.py @@ -0,0 +1,106 @@ +"""Xray proxy deployment module.""" + +from pyinfra.context import host +from pyinfra.facts.files import File +from pyinfra.facts.server import Command +from pyinfra.operations import files, server, systemd + +from nullforge.molds import FeaturesMold, XrayCoreMold +from nullforge.smithy.http import CURL_ARGS +from nullforge.smithy.versions import STATIC_URLS + + +GEOIP_DAT_URL = "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" +GEOSITE_DAT_URL = "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" + +GEOIP_DAT = {"geoip.dat": GEOIP_DAT_URL} +GEOSITE_DAT = {"geosite.dat": GEOSITE_DAT_URL} + +BASE_DIR = "/usr/local/share/xray" +CONFIG_DIR = "/usr/local/etc/xray" + + +def deploy_xray() -> None: + """Deploy Xray proxy configuration.""" + + features: FeaturesMold = host.data.features + xray_opts: XrayCoreMold = features.xray + + _install_xray(xray_opts) + _download_geo_data(xray_opts) + + if features.users.manage: + _setup_acls(features.users.name) + + +def _install_xray(opts: XrayCoreMold) -> None: + """Install Xray using official installation script.""" + + if host.get_fact(File, "/usr/local/bin/xray"): + return + + server.shell( + name="Install Xray proxy", + commands=f'bash -lc "$(curl -L {STATIC_URLS["xray_install_script"]})" @ install --beta', + _sudo=True, + ) + + systemd.service( + name="Enable and start Xray service", + service="xray", + running=True, + restarted=True, + enabled=True, + _sudo=True, + ) + + +def _download_geo_data(opts: XrayCoreMold) -> None: + """Download GeoIP and GeoSite data files.""" + + for file, url in GEOIP_DAT.items(): + files.download( + name=f"Download {file}", + src=url, + dest=f"{BASE_DIR}/{file}", + extra_curl_args=CURL_ARGS, + ) + + for file, url in GEOSITE_DAT.items(): + files.download( + name=f"Download {file}", + src=url, + dest=f"{BASE_DIR}/{file}", + extra_curl_args=CURL_ARGS, + ) + + +def _setup_acls(username: str) -> None: + """Set up ACLs on Xray directories for config management.""" + + cmd_get_facl = "getfacl {} 2>/dev/null || true" + acl_base = host.get_fact(Command, cmd_get_facl.format(BASE_DIR), _sudo=True) or "" + if f"user:{username}:rwx" not in acl_base: + server.shell( + name="Set ACLs on Xray base directory", + commands=[ + f"setfacl -m u:{username}:rwx {BASE_DIR}", + f"setfacl -d -m u:{username}:rwx {BASE_DIR}", + ], + _sudo=True, + ) + + acl_config = host.get_fact(Command, cmd_get_facl.format(CONFIG_DIR), _sudo=True) or "" + if f"user:{username}:rwx" not in acl_config: + server.shell( + name="Set ACLs on Xray config directory", + commands=[ + f"setfacl -m u:{username}:rwx {CONFIG_DIR}", + f"setfacl -d -m u:{username}:rwx {CONFIG_DIR}", + f"setfacl -m u:{username}:rw {CONFIG_DIR}/config.json", + ], + _sudo=True, + ) + + +deploy_xray() diff --git a/nullforge/runes/zerotrust.py b/nullforge/runes/zerotrust.py new file mode 100644 index 0000000..9b22b17 --- /dev/null +++ b/nullforge/runes/zerotrust.py @@ -0,0 +1,88 @@ +"""Cloudflare Zero Trust Tunnel deployment module.""" + +from pyinfra.context import host +from pyinfra.operations import files, systemd +from pyinfra.operations.util import any_changed + +from nullforge.molds import FeaturesMold, ZeroTrustTunnelMold +from nullforge.runes.cloudflare import ( + CLOUDFLARE_GROUP, + CLOUDFLARE_USER, + ensure_cloudflare_user, + ensure_cloudflared_binary, +) +from nullforge.templates import get_cloudflared_template, get_script_template, get_systemd_template + + +def deploy_zerotrust_tunnel() -> None: + """Deploy Cloudflare Zero Trust Tunnel.""" + + features: FeaturesMold = host.data.features + tunnel_opts: ZeroTrustTunnelMold = features.zerotrust + + if not tunnel_opts.install: + return + + deploy_tunnel(tunnel_opts) + + +def deploy_tunnel(opts: ZeroTrustTunnelMold) -> None: + ensure_cloudflare_user() + ensure_cloudflared_binary() + + config_dir = "/etc/cloudflare" + config_path = f"{config_dir}/tunnel.yml" + config_template = files.template( + name="Deploy Zero Trust Tunnel configuration", + src=get_cloudflared_template("tunnel.yml.j2"), + dest=config_path, + mode="0600", + user=CLOUDFLARE_USER, + group=CLOUDFLARE_GROUP, + TOKEN=opts.token, + PROTOCOL=opts.protocol, + HA_CONNECTIONS=opts.ha_connections, + POST_QUANTUM=opts.post_quantum, + _sudo=True, + ) + + if opts.route_through_warp: + files.put( + name="Deploy Zero Trust tunnel WARP routing script", + src=get_script_template("zt-tunnel-warp.sh"), + dest=f"{config_dir}/zt-tunnel-warp.sh", + user=CLOUDFLARE_USER, + group=CLOUDFLARE_GROUP, + mode="0755", + _sudo=True, + ) + + service_template = files.template( + name="Deploy Zero Trust Tunnel systemd service", + src=get_systemd_template("cloudflare-tunnel.service.j2"), + dest="/etc/systemd/system/cloudflare-tunnel.service", + mode="0644", + CONFIG_PATH=config_path, + WORKDIR=config_dir, + ROUTE_THROUGH_WARP=opts.route_through_warp, + _sudo=True, + ) + + systemd.daemon_reload( + name="Reload systemd daemon for Zero Trust Tunnel", + _sudo=True, + _if=service_template.did_change, + ) + + systemd.service( + name="Enable and start Zero Trust Tunnel service", + service="cloudflare-tunnel", + running=True, + restarted=True, + enabled=True, + _sudo=True, + _if=any_changed(config_template, service_template), + ) + + +deploy_zerotrust_tunnel() diff --git a/nullforge/smithy/__init__.py b/nullforge/smithy/__init__.py new file mode 100644 index 0000000..c7f700b --- /dev/null +++ b/nullforge/smithy/__init__.py @@ -0,0 +1 @@ +"""Helpers for NullForge.""" diff --git a/nullforge/smithy/admin.py b/nullforge/smithy/admin.py new file mode 100644 index 0000000..6180914 --- /dev/null +++ b/nullforge/smithy/admin.py @@ -0,0 +1,22 @@ +"""Admin utilities for NullForge.""" + +from typing import TYPE_CHECKING + +from pyinfra.facts.server import Command + + +if TYPE_CHECKING: + from pyinfra.api.host import Host + + +def is_root(host: "Host") -> bool: + """Check if the current user is root.""" + + cache_key = "_nullforge_is_root" + if hasattr(host.data, cache_key): + return getattr(host.data, cache_key) + + result = host.get_fact(Command, command="whoami") == "root" + + setattr(host.data, cache_key, result) + return result diff --git a/nullforge/smithy/arch.py b/nullforge/smithy/arch.py new file mode 100644 index 0000000..8ebc3b1 --- /dev/null +++ b/nullforge/smithy/arch.py @@ -0,0 +1,37 @@ +"""Architecture utilities for NullForge.""" + +from typing import TYPE_CHECKING + +from pyinfra.facts.server import Arch + + +if TYPE_CHECKING: + from pyinfra.api.host import Host + + +def arch_id(host: "Host") -> str: + """Normalize arch id where vendors differ.""" + + cache_key = "_nullforge_arch_id" + if hasattr(host.data, cache_key): + return getattr(host.data, cache_key) + + a = host.get_fact(Arch) + result = { + "x86_64": "x86_64", + "amd64": "x86_64", + "aarch64": "arm64", + "arm64": "arm64", + }.get(a, a or "x86_64") + + setattr(host.data, cache_key, result) + return result + + +def deb_arch(host: "Host") -> str: + """Return Debian package architecture name for the host.""" + + return { + "x86_64": "amd64", + "arm64": "arm64", + }.get(arch_id(host), "amd64") diff --git a/nullforge/smithy/http.py b/nullforge/smithy/http.py new file mode 100644 index 0000000..773df89 --- /dev/null +++ b/nullforge/smithy/http.py @@ -0,0 +1,15 @@ +"""HTTP utilities for NullForge.""" + +CURL_ARGS = { + "--compressed": " ", + "--retry": "3", + "--retry-connrefused": " ", + "--connect-timeout": "10", + "--max-time": "120", + "--proto": "=https", + "--tlsv1.2": " ", +} +"""Robust curl options for reliable downloads.""" + +CURL_ARGS_STR = " ".join([f"{k} {v}" if v != " " else k for k, v in CURL_ARGS.items()]) +"""String representation of curl arguments.""" diff --git a/nullforge/smithy/network.py b/nullforge/smithy/network.py new file mode 100644 index 0000000..5db6353 --- /dev/null +++ b/nullforge/smithy/network.py @@ -0,0 +1,43 @@ +"""Network utilities for NullForge.""" + +from contextlib import suppress +from typing import TYPE_CHECKING + +from pyinfra.facts.files import FileContents +from pyinfra.facts.server import Command + + +if TYPE_CHECKING: + from pyinfra.api.host import Host + + +def has_ipv6(host: "Host") -> bool: + """Check if the system has IPv6 support enabled.""" + + cache_key = "_nullforge_ipv6_enabled" + if hasattr(host.data, cache_key): + return getattr(host.data, cache_key) + + # First check if IPv6 is disabled in GRUB + grub_default = "/etc/default/grub" + with suppress(Exception): + grub_contents = host.get_fact(FileContents, path=grub_default) + # GRUB_CMDLINE_LINUX_DEFAULT="ipv6.disable=1 quiet splash" + if grub_contents and "ipv6.disable=1" in grub_contents: + setattr(host.data, cache_key, False) + return False + + # Check if IPv6 is enabled by looking for global IPv6 addresses + ipv6_check = host.get_fact( + Command, + "ip -6 addr show scope global 2>/dev/null | grep -c 'inet6' | awk '{print $1}' || echo '0'", + ) + + result = False + if ipv6_check: + with suppress(Exception): + count = int(ipv6_check.strip()) + result = count > 0 + + setattr(host.data, cache_key, result) + return result diff --git a/nullforge/smithy/packages.py b/nullforge/smithy/packages.py new file mode 100644 index 0000000..101f160 --- /dev/null +++ b/nullforge/smithy/packages.py @@ -0,0 +1,171 @@ +"""Package management utilities for NullForge.""" + +from typing import TYPE_CHECKING, Any, Protocol, cast + +from pyinfra.context import host +from pyinfra.facts.server import LinuxDistribution +from pyinfra.operations import apt, dnf + + +if TYPE_CHECKING: + from pyinfra.api.host import Host + +UBUNTU_OVERRIDES = { + "ifupdown2": "ifupdown", +} +"""Specific overrides for Ubuntu""" + +RHEL_OVERRIDES = { + "aha": None, + "apt-transport-https": None, + "bat": "bat", + "bind9-host": "bind-utils", + "build-essential": "@development-tools", + "direnv": None, # requires manual installation + "dnsutils": "bind-utils", + "g++": "gcc-c++", + "gnupg": "gnupg2", + "ifupdown2": None, + "iputils-ping": "iputils", + "libevent-dev": "libevent-devel", + "libnss-resolve": "systemd-resolved", + "libssl-dev": "openssl-devel", + "locales": "glibc-langpack-en", + "mtr-tiny": "mtr", + "ncat": "nmap-ncat", + "ncurses-dev": "ncurses-devel", + "pkg-config": "pkgconfig", + "python3-dev": "python3-devel", + "software-properties-common": None, + "ufw": "firewalld", + "zoxide": None, # requires manual installation +} +"""Package overrides for RHEL/CentOS/Fedora families +Key: Canonical name (Debian/Ubuntu style) +Value: RHEL equivalent (or None to skip) +""" + + +class SystemPackageModule(Protocol): + """Protocol for pyinfra package manager modules (apt, dnf, etc).""" + + def packages(self, packages: list[str] | None = None, **kwargs: Any) -> Any: ... + def update(self, **kwargs: Any) -> Any: ... + + +class AptModule(SystemPackageModule, Protocol): + """Protocol for apt module which includes upgrade.""" + + def upgrade(self, **kwargs: Any) -> Any: ... + + +class PackageManager: + def __init__(self, host: "Host"): + self.host = host + self.distro_info = host.get_fact(LinuxDistribution) + self.distro_name = (self.distro_info.get("name") or "").lower() + self.distro_major = self.distro_info.get("major", 0) or 0 + + @property + def is_debian_based(self) -> bool: + return any(x in self.distro_name for x in ["debian", "ubuntu"]) + + @property + def is_rhel_based(self) -> bool: + return any(x in self.distro_name for x in ["centos", "redhat", "rhel", "fedora", "rocky", "alma"]) + + @property + def is_ubuntu(self) -> bool: + return "ubuntu" in self.distro_name + + @property + def is_debian(self) -> bool: + return "debian" in self.distro_name + + def _get_module(self) -> SystemPackageModule: + if self.is_debian_based: + return cast(SystemPackageModule, apt) + elif self.is_rhel_based: + return cast(SystemPackageModule, dnf) + else: + raise NotImplementedError(f"Unsupported distribution: {self.distro_name}") + + def map_package(self, package: str) -> str | None: + """Map a package name to the current distro's equivalent.""" + + if self.is_ubuntu and package in UBUNTU_OVERRIDES: + return UBUNTU_OVERRIDES[package] + + if self.is_debian_based: + return package + + if self.is_rhel_based and package in RHEL_OVERRIDES: + return RHEL_OVERRIDES[package] + + # TODO: Maybe make better fallback mechanism + return package + + def map_packages(self, packages: list[str]) -> list[str]: + """Map a list of packages.""" + + mapped = set() + for p in packages: + m = self.map_package(p) + if m: + mapped.add(m) + return list(mapped) + + def update(self, **kwargs: Any) -> Any: + """Update package repository cache.""" + + call_kwargs = kwargs.copy() + + mod = self._get_module() + if self.is_debian_based: + return mod.update(**call_kwargs) + return None + + def upgrade(self, **kwargs: Any) -> Any: + """Upgrade all packages.""" + + call_kwargs = kwargs.copy() + mod = self._get_module() + + if not self.is_debian_based: + call_kwargs.pop("auto_remove", None) + + if self.is_debian_based: + return cast(AptModule, mod).upgrade(**call_kwargs) + elif self.is_rhel_based: + return mod.update(**call_kwargs) + return None + + def install(self, packages: list[str], **kwargs: Any) -> Any: + """Install packages.""" + + mod = self._get_module() + mapped_packages = self.map_packages(packages) + if not mapped_packages: + return None + + call_kwargs = kwargs.copy() + + # Remove apt-specific kwargs if not apt + if not self.is_debian_based: + call_kwargs.pop("no_recommends", None) + call_kwargs.pop("force", None) + call_kwargs.pop("cache_time", None) + call_kwargs.pop("extra_uninstall_args", None) + + return mod.packages(packages=mapped_packages, **call_kwargs) + + +def get_pm() -> PackageManager: + """Get or create the PackageManager for the current host.""" + + if hasattr(host.data, "_nullforge_package_manager"): + return getattr(host.data, "_nullforge_package_manager") + + pm = PackageManager(host) + setattr(host.data, "_nullforge_package_manager", pm) + return pm diff --git a/nullforge/smithy/system.py b/nullforge/smithy/system.py new file mode 100644 index 0000000..4bb763f --- /dev/null +++ b/nullforge/smithy/system.py @@ -0,0 +1,64 @@ +"""System utilities for NullForge.""" + +from contextlib import suppress +from typing import TYPE_CHECKING + +from pyinfra.facts.files import FileContents + + +if TYPE_CHECKING: + from pyinfra.api.host import Host + + +def get_supported_locales(host: "Host") -> list[str]: + """Get list of supported locales from /etc/locale.gen.""" + + cache_key = "_nullforge_supported_locales" + if hasattr(host.data, cache_key): + return getattr(host.data, cache_key) + + locales = [] + with suppress(Exception): + content = host.get_fact(FileContents, path="/etc/locale.gen") + if content: + for line in content.splitlines(): + line = line.strip() + if not line: + continue + if line.startswith("#"): + line = line[1:].strip() + + parts = line.split() + if len(parts) >= 2: + locales.append(f"{parts[0]} {parts[1]}") + + setattr(host.data, cache_key, locales) + return locales + + +def detect_best_locale(host: "Host", preferred: list[str] | None = None) -> str | None: + """Detect the best available locale from the system.""" + + supported = get_supported_locales(host) + if not supported: + return None + + if preferred: + for pref in preferred: + if pref in supported: + return pref + pref_name = pref.split()[0] + for supp in supported: + if supp.startswith(pref_name): + return supp + + defaults = ["en_US.UTF-8 UTF-8", "C.UTF-8 UTF-8", "en_GB.UTF-8 UTF-8"] + for def_loc in defaults: + if def_loc in supported: + return def_loc + + for loc in supported: + if "UTF-8" in loc.upper(): + return loc + + return supported[0] if supported else None diff --git a/nullforge/smithy/versions.py b/nullforge/smithy/versions.py new file mode 100644 index 0000000..2fc291f --- /dev/null +++ b/nullforge/smithy/versions.py @@ -0,0 +1,188 @@ +"""Versions utilities for NullForge.""" + +from typing import TYPE_CHECKING + +from nullforge.smithy.arch import arch_id + + +if TYPE_CHECKING: + from pyinfra.api.host import Host + + +DEFAULT_VERSIONS = { + "mtg": "2.2.4", + "wgcf": "2.2.29", + "usque": "1.4.2", + "nvim": "v0.11.4", + "tmux": "3.5a", + "curl": "8.16.0", + "eza": "latest", + "cloudflared": "2025.11.1", + "podman": "v5.6.2", + "crun": "v1.24", +} +"""Version pins (override per-host via inventory if needed).""" + +STATIC_URLS = { + "starship_install": "https://starship.rs/install.sh", + "docker_install": "https://get.docker.com", + "xray_install_script": "https://github.com/XTLS/Xray-install/raw/main/install-release.sh", + "atuin_install": "https://setup.atuin.sh", + "nerd_fonts_installer": "https://raw.githubusercontent.com/officialrajdeepsingh/nerd-fonts-installer/main/install.sh", + "doggo_install": "https://raw.githubusercontent.com/mr-karan/doggo/main/install.sh", +} +"""Static endpoints.""" + +ZSH_PLUGINS: dict[str, str] = { + "zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions", + "zsh-syntax-highlighting": "https://github.com/zsh-users/zsh-syntax-highlighting", + "ohmyzsh-full-autoupdate": "https://github.com/Pilaton/OhMyZsh-full-autoupdate", +} +"""Oh-my-zsh plugins to install (name → git URL).""" + +GPG_KEYS = { + "haproxy": "https://haproxy.debian.net/haproxy-archive-keyring.gpg", + "gvisor": "https://gvisor.dev/archive.key", +} +"""GPG keys.""" + +KEYRING_DIR = "/etc/apt/keyrings" +"""Keyring directory.""" + + +class Versions: + def __init__(self, host: "Host"): + self.host = host + self.versions = {**DEFAULT_VERSIONS, **(host.data.get("versions", {}) or {})} + + def mtg_tar(self) -> str: + """mtg tarball URL.""" + + version = self.versions["mtg"] + arch = arch_id(self.host) + match arch: + case "x86_64": + return f"https://github.com/9seconds/mtg/releases/download/v{version}/mtg-{version}-linux-amd64.tar.gz" + case "arm64": + return f"https://github.com/9seconds/mtg/releases/download/v{version}/mtg-{version}-linux-arm64.tar.gz" + case _: + raise ValueError(f"Unsupported architecture: {arch}") + + def mtg_binary_in_tar(self) -> str: + """Path of the mtg binary inside its tarball.""" + + version = self.versions["mtg"] + arch = arch_id(self.host) + match arch: + case "x86_64": + return f"mtg-{version}-linux-amd64/mtg" + case "arm64": + return f"mtg-{version}-linux-arm64/mtg" + case _: + raise ValueError(f"Unsupported architecture: {arch}") + + def telemt_tar(self) -> str: + """telemt tarball URL (latest release from GitHub).""" + + arch = arch_id(self.host) + uname_arch = {"x86_64": "x86_64", "arm64": "aarch64"}.get(arch, arch) + + return f"https://github.com/telemt/telemt/releases/latest/download/telemt-{uname_arch}-linux-gnu.tar.gz" + + def cloudflared(self) -> str: + """Cloudflare's cloudflared binary URL.""" + + base_url = f"https://github.com/cloudflare/cloudflared/releases/download/{self.versions['cloudflared']}" + arch = arch_id(self.host) + match arch: + case "x86_64": + return f"{base_url}/cloudflared-linux-amd64" + case "arm64": + return f"{base_url}/cloudflared-linux-arm64" + case _: + raise ValueError(f"Unsupported architecture: {arch}") + + def eza_tar(self) -> str: + """eza tarball URL.""" + + base_url = "https://github.com/eza-community/eza/releases/latest/download" + arch = arch_id(self.host) + match arch: + case "x86_64": + return f"{base_url}/eza_x86_64-unknown-linux-gnu.tar.gz" + case "arm64": + return f"{base_url}/eza_aarch64-unknown-linux-gnu.tar.gz" + case _: + raise ValueError(f"Unsupported architecture: {arch}") + + def wgcf(self) -> str: + """wgcf binary URL.""" + + version = self.versions["wgcf"] + arch = arch_id(self.host) + match arch: + case "x86_64": + return f"https://github.com/ViRb3/wgcf/releases/download/v{version}/wgcf_{version}_linux_amd64" + case "arm64": + return f"https://github.com/ViRb3/wgcf/releases/download/v{version}/wgcf_{version}_linux_arm64" + case _: + raise ValueError(f"Unsupported architecture: {arch}") + + def usque_zip(self) -> str: + """usque zip URL.""" + + base_url = "https://github.com/Diniboy1123/usque/releases/download" + version = self.versions["usque"] + arch = arch_id(self.host) + match arch: + case "x86_64": + return f"{base_url}/v{version}/usque_{version}_linux_amd64.zip" + case "arm64": + return f"{base_url}/v{version}/usque_{version}_linux_arm64.zip" + case _: + raise ValueError(f"Unsupported architecture: {arch}") + + def nvim_appimage(self) -> str: + """nvim appimage URL.""" + + base_url = "https://github.com/neovim/neovim/releases/download" + version = self.versions["nvim"] + arch = arch_id(self.host) + match arch: + case "x86_64": + return f"{base_url}/{version}/nvim-linux-x86_64.appimage" + case "arm64": + return f"{base_url}/{version}/nvim-linux-arm64.tar.gz" + case _: + raise ValueError(f"Unsupported architecture: {arch}") + + def tmux_tar(self) -> str: + """tmux tarball URL.""" + + base_url = "https://github.com/tmux/tmux/releases/download" + version = self.versions["tmux"] + return f"{base_url}/{version}/tmux-{version}.tar.gz" + + def curl_tar(self) -> str: + """curl tarball URL.""" + + base_url = "https://github.com/stunnel/static-curl/releases/download" + version = self.versions["curl"] + arch = arch_id(self.host) + match arch: + case "x86_64": + return f"{base_url}/{version}/curl-linux-x86_64-glibc-{version}.tar.xz" + case "arm64": + return f"{base_url}/{version}/curl-linux-aarch64-glibc-{version}.tar.xz" + case _: + raise ValueError(f"Unsupported architecture: {arch}") + + def podman(self) -> str: + """Podman version.""" + + return self.versions["podman"] + + def crun(self) -> str: + """crun version.""" + + return self.versions["crun"] diff --git a/nullforge/templates/__init__.py b/nullforge/templates/__init__.py new file mode 100644 index 0000000..1389cca --- /dev/null +++ b/nullforge/templates/__init__.py @@ -0,0 +1,79 @@ +"""NullForge templates package. + +This package provides access to all template files used by NullForge. +Templates are organized by category (dns, profiles, systemd, etc.). +""" + +from pathlib import Path + + +def get_template_path(template_name: str) -> str: + """Get the full path to a template file.""" + + templates_dir = Path(__file__).parent + template_path = templates_dir / template_name + + if not template_path.exists(): + raise FileNotFoundError(f"Template not found: {template_name}") + + return str(template_path) + + +def get_dns_template(name: str) -> str: + """Get DNS template file.""" + + return get_template_path(f"dns/{name}") + + +def get_profile_template(name: str) -> str: + """Get profile template file.""" + + return get_template_path(f"profiles/{name}") + + +def get_systemd_template(name: str) -> str: + """Get systemd template file.""" + + return get_template_path(f"systemd/{name}") + + +def get_script_template(name: str) -> str: + """Get script template file.""" + + return get_template_path(f"scripts/{name}") + + +def get_nvim_template(name: str) -> str: + """Get nvim template file.""" + + return get_template_path(f"nvim/{name}") + + +def get_tor_template(name: str) -> str: + """Get tor template file.""" + + return get_template_path(f"tor/{name}") + + +def get_etc_template(name: str) -> str: + """Get etc template file.""" + + return get_template_path(f"etc/{name}") + + +def get_cloudflared_template(name: str) -> str: + """Get cloudflared template file.""" + + return get_template_path(f"cloudflared/{name}") + + +def get_mtg_template(name: str) -> str: + """Get mtg template file.""" + + return get_template_path(f"mtg/{name}") + + +def get_telemt_template(name: str) -> str: + """Get telemt template file.""" + + return get_template_path(f"telemt/{name}") diff --git a/nullforge/templates/cloudflared/tunnel.yml.j2 b/nullforge/templates/cloudflared/tunnel.yml.j2 new file mode 100644 index 0000000..c63f806 --- /dev/null +++ b/nullforge/templates/cloudflared/tunnel.yml.j2 @@ -0,0 +1,3 @@ +token: {{ TOKEN }} +protocol: {{ PROTOCOL }} +ha-connections: {{ HA_CONNECTIONS }} diff --git a/nullforge/templates/dns/dns.yaml.j2 b/nullforge/templates/dns/dns.yaml.j2 new file mode 100644 index 0000000..ceb9ed8 --- /dev/null +++ b/nullforge/templates/dns/dns.yaml.j2 @@ -0,0 +1,6 @@ +proxy-dns: true +proxy-dns-port: {{ PORT }} +proxy-dns-upstream: +{% for upstream in UPSTREAMS -%} + - {{ upstream }} +{% endfor -%} diff --git a/nullforge/templates/dns/resolv.conf.j2 b/nullforge/templates/dns/resolv.conf.j2 new file mode 100644 index 0000000..bbc8559 --- /dev/null +++ b/nullforge/templates/dns/resolv.conf.j2 @@ -0,0 +1 @@ +nameserver 127.0.0.1 diff --git a/nullforge/templates/dns/resolved.conf.j2 b/nullforge/templates/dns/resolved.conf.j2 new file mode 100644 index 0000000..f7d70df --- /dev/null +++ b/nullforge/templates/dns/resolved.conf.j2 @@ -0,0 +1,14 @@ +[Resolve] +{% if DOT -%} +# TODO: Make this dynamic based on the upstreams +DNS=1.1.1.1#cloudflare-dns.com 1.0.0.1#cloudflare-dns.com 2606:4700:4700::1111#cloudflare-dns.com 2606:4700:4700::1001#cloudflare-dns.com +DNSOverTLS=yes +{% elif DOH -%} +DNS=127.0.0.1:5053 +DNSOverTLS=no +{% endif -%} +DNSSEC=yes +LLMNR=no +Cache=yes +CacheFromLocalhost=yes +ReadEtcHosts=yes diff --git a/nullforge/templates/etc/default/zramswap.j2 b/nullforge/templates/etc/default/zramswap.j2 new file mode 100644 index 0000000..687e258 --- /dev/null +++ b/nullforge/templates/etc/default/zramswap.j2 @@ -0,0 +1,13 @@ +# Algorithm to use. +ALGO={{ algo }} + +# Specifies the amount of RAM to use as zram swap. +# If both PERCENT and SIZE are specified, SIZE takes precedence. +{% if size.endswith('%') %} +PERCENT={{ size.rstrip('%') }} +{% else %} +SIZE={{ size }} +{% endif %} + +# Priority for the zram swap devices. +PRIORITY=100 diff --git a/nullforge/templates/mtg/config.toml.j2 b/nullforge/templates/mtg/config.toml.j2 new file mode 100644 index 0000000..20a6880 --- /dev/null +++ b/nullforge/templates/mtg/config.toml.j2 @@ -0,0 +1,4 @@ +secret = "{{ SECRET }}" +bind-to = "{{ '0.0.0.0' if PORT == 443 else '127.0.0.1' }}:{{ PORT }}" +{% if ENABLE_IPV6 %}prefer-ip = "prefer-ipv6" +{% endif %} diff --git a/nullforge/templates/nvim/nvim_patch.lua.j2 b/nullforge/templates/nvim/nvim_patch.lua.j2 new file mode 100644 index 0000000..3cd60f5 --- /dev/null +++ b/nullforge/templates/nvim/nvim_patch.lua.j2 @@ -0,0 +1,5 @@ +vim.api.nvim_create_autocmd("VimLeave", { + callback = function() + vim.api.nvim_command('set guicursor= | call chansend(v:stderr, "\x1b[ q")') + end +}) diff --git a/nullforge/templates/profiles/direnv.toml b/nullforge/templates/profiles/direnv.toml new file mode 100644 index 0000000..89ac394 --- /dev/null +++ b/nullforge/templates/profiles/direnv.toml @@ -0,0 +1,2 @@ +[global] +load_dotenv = true diff --git a/nullforge/templates/profiles/starship.toml b/nullforge/templates/profiles/starship.toml new file mode 100644 index 0000000..11fbc3c --- /dev/null +++ b/nullforge/templates/profiles/starship.toml @@ -0,0 +1,186 @@ +add_newline = true + +[aws] +symbol = " " + +[buf] +symbol = " " + +[bun] +symbol = " " + +[c] +symbol = " " + +[cmake] +symbol = " " + +[conda] +symbol = " " + +[crystal] +symbol = " " + +[dart] +symbol = " " + +[deno] +symbol = " " + +[directory] +read_only = " 󰌾" + +[docker_context] +symbol = " " + +[elixir] +symbol = " " + +[elm] +symbol = " " + +[fennel] +symbol = " " + +[fossil_branch] +symbol = " " + +[gcloud] +symbol = " " + +[git_branch] +symbol = " " + +[git_commit] +tag_symbol = '  ' + +[golang] +symbol = " " + +[guix_shell] +symbol = " " + +[haskell] +symbol = " " + +[haxe] +symbol = " " + +[hg_branch] +symbol = " " + +[hostname] +ssh_symbol = " " + +[java] +symbol = " " + +[julia] +symbol = " " + +[kotlin] +symbol = " " + +[lua] +symbol = " " + +[memory_usage] +symbol = "󰍛 " + +[meson] +symbol = "󰔷 " + +[nim] +symbol = "󰆥 " + +[nix_shell] +symbol = " " + +[nodejs] +symbol = " " + +[ocaml] +symbol = " " + +[os.symbols] +Alpaquita = " " +Alpine = " " +AlmaLinux = " " +Amazon = " " +Android = " " +Arch = " " +Artix = " " +CachyOS = " " +CentOS = " " +Debian = " " +DragonFly = " " +Emscripten = " " +EndeavourOS = " " +Fedora = " " +FreeBSD = " " +Garuda = "󰛓 " +Gentoo = " " +HardenedBSD = "󰞌 " +Illumos = "󰈸 " +Kali = " " +Linux = " " +Mabox = " " +Macos = " " +Manjaro = " " +Mariner = " " +MidnightBSD = " " +Mint = " " +NetBSD = " " +NixOS = " " +Nobara = " " +OpenBSD = "󰈺 " +openSUSE = " " +OracleLinux = "󰌷 " +Pop = " " +Raspbian = " " +Redhat = " " +RedHatEnterprise = " " +RockyLinux = " " +Redox = "󰀘 " +Solus = "󰠳 " +SUSE = " " +Ubuntu = " " +Unknown = " " +Void = " " +Windows = "󰍲 " + +[package] +symbol = "󰏗 " + +[perl] +symbol = " " + +[php] +symbol = " " + +[pijul_channel] +symbol = " " + +[python] +symbol = " " + +[rlang] +symbol = "󰟔 " + +[ruby] +symbol = " " + +[rust] +symbol = "󱘗 " + +[scala] +symbol = " " + +[swift] +symbol = " " + +[zig] +symbol = " " + +[gradle] +symbol = " " diff --git a/nullforge/templates/profiles/tmux.conf b/nullforge/templates/profiles/tmux.conf new file mode 100644 index 0000000..f8bfee4 --- /dev/null +++ b/nullforge/templates/profiles/tmux.conf @@ -0,0 +1,32 @@ +# Support for colors +set-option -sa terminal-overrides \",xterm*:Tc\" + +# Toggle mouse on\off +set-option -g mouse on # Mouse support +bind-key m \ + if-shell -F "#{?mouse,1,0}" \ + "set-option -g mouse off; display-message 'Mouse: OFF'" \ + "set-option -g mouse on; display-message 'Mouse: ON'" + +# Start windows and panes at 1, not 0 +set -g base-index 1 +set -g pane-base-index 1 +set-window-option -g pane-base-index 1 +set-option -g renumber-windows on + +# Open pane in same directory +bind '\' split-window -v -c "#{pane_current_path}" +bind '%' split-window -h -c "#{pane_current_path}" + +# Additions to the status bar +set -g status-right '#{prefix_highlight} | %a %Y-%m-%d %H:%M' + +# Plugins +set -g @plugin 'tmux-plugins/tpm' +set -g @plugin 'tmux-plugins/tmux-yank' +set -g @plugin 'tmux-plugins/tmux-sensible' +set -g @plugin 'tmux-plugins/tmux-prefix-highlight' +set -g @plugin 'tmux-plugins/tmux-sessionist' +set -g @plugin 'tmux-plugins/tmux-resurrect' +set -g @plugin 'hoshiya4522/tokyo-night-tmux' +run '~/.tmux/plugins/tpm/tpm' diff --git a/nullforge/templates/profiles/zshrc.j2 b/nullforge/templates/profiles/zshrc.j2 new file mode 100644 index 0000000..121c771 --- /dev/null +++ b/nullforge/templates/profiles/zshrc.j2 @@ -0,0 +1,31 @@ +export ZSH="{{ home }}/.oh-my-zsh" +ZSH_THEME="" + +plugins=( + git + docker + zsh-autosuggestions + zsh-syntax-highlighting + ohmyzsh-full-autoupdate +) + +zstyle ':omz:update' mode auto + +source $ZSH/oh-my-zsh.sh + +. "$HOME/.atuin/bin/env" + +eval "$(atuin init zsh)" +eval "$(starship init zsh)" +eval "$(zoxide init zsh)" +eval "$(direnv hook zsh)" + +# Aliases +alias tailf='tail -f' +alias cd='z' +alias top='btop' +alias cat='bat --style=plain' +alias ls='eza' +alias vim='nvim' +alias df='duf' +alias dig='doggo' diff --git a/nullforge/templates/scripts/warp-check.sh b/nullforge/templates/scripts/warp-check.sh new file mode 100755 index 0000000..fda6008 --- /dev/null +++ b/nullforge/templates/scripts/warp-check.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IFACE=${1:-warp} +SERVICE_NAME=${2:-cloudflare-warp} + +# Endpoints to try +APPLE_URL="http://www.apple.com/library/test/success.html" +HICLOUD_URL="http://connectivitycheck.platform.hicloud.com/generate_204" +CLOUDFLARE_TRACE_URL="https://cloudflare.com/cdn-cgi/trace" + +bind_args=() +if [[ -n "$IFACE" ]]; then + bind_args+=("--interface" "$IFACE") +fi + +curl_cmd=(curl -fsSL --max-time 10) + +check_apple() { + "${curl_cmd[@]}" "${bind_args[@]}" "$APPLE_URL" | grep -q "Success" +} + +check_hicloud() { + code=$("${curl_cmd[@]}" "${bind_args[@]}" -o /dev/null -w "%{http_code}" "$HICLOUD_URL" || true) + [[ "$code" == "204" ]] +} + +check_cf_warp() { + "${curl_cmd[@]}" "${bind_args[@]}" "$CLOUDFLARE_TRACE_URL" | grep -q "warp=on" +} + +if check_cf_warp || check_apple || check_hicloud; then + exit 0 +fi + +systemctl restart "$SERVICE_NAME" +exit 1 diff --git a/nullforge/templates/scripts/warp-v6-policy.sh b/nullforge/templates/scripts/warp-v6-policy.sh new file mode 100755 index 0000000..b443255 --- /dev/null +++ b/nullforge/templates/scripts/warp-v6-policy.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cmd=${1:?up|down} +IFACE=${2:-warp} +CFG=${3:-/etc/usque/config.json} +TABLE=warp +TID=123 +PRIO=12300 +PRIO_OIF=$((PRIO+10)) +WAIT_SECS=15 + +log(){ logger -t warp-policy -- "$*"; } + +get_warp6(){ + ip -6 -o addr show dev "$IFACE" scope global 2>/dev/null \ + | awk '{print $4}' | head -n1 | cut -d/ -f1 +} + +get_ep6(){ + jq -r '.endpoint_v6 // empty' "$CFG" 2>/dev/null || true +} + +ensure_table(){ + grep -qE "^[[:space:]]*$TID[[:space:]]+$TABLE$" /etc/iproute2/rt_tables \ + || echo "$TID $TABLE" >> /etc/iproute2/rt_tables +} + +wait_for_warp6(){ + local i=0 + while [ $i -lt "$WAIT_SECS" ]; do + local a + a=$(get_warp6 || true) + if [ -n "$a" ]; then echo "$a"; return 0; fi + sleep 1; i=$((i+1)) + done + return 1 +} + +case "$cmd" in + up) + ensure_table + + if ! WARP6=$(wait_for_warp6); then + log "No IPv6 on $IFACE after ${WAIT_SECS}s; leaving without rules" + exit 0 + fi + + EP6=$(get_ep6) + if [ -n "$EP6" ]; then + GW6=$(ip -6 route show default | awk '/default/ {print $3; exit}') + DEV=$(ip -6 route show default | awk '/default/ {print $5; exit}') + if [ -n "${GW6}" ] && [ -n "${DEV}" ]; then + ip -6 route replace "${EP6}/128" via "$GW6" dev "$DEV" onlink + fi + fi + + ip -6 route replace default dev "$IFACE" table "$TABLE" + + ip -6 rule del pref "$PRIO" 2>/dev/null || true + ip -6 rule add pref "$PRIO" from "${WARP6}/128" lookup "$TABLE" + ip -6 rule del pref "$PRIO_OIF" 2>/dev/null || true + ip -6 rule add pref "$PRIO_OIF" oif "$IFACE" lookup "$TABLE" + + log "IPv6 policy up: src=${WARP6}/128 pref=$PRIO, oif=$IFACE pref=$PRIO_OIF table=$TABLE" + ;; + + down) + ip -6 rule del pref "$PRIO" 2>/dev/null || true + ip -6 rule del pref "$PRIO_OIF" 2>/dev/null || true + + EP6=$(get_ep6 || true) + if [ -n "${EP6:-}" ]; then + ip -6 route del "${EP6}/128" 2>/dev/null || true + fi + + ip -6 route flush table "$TABLE" 2>/dev/null || true + + log "IPv6 policy down: table=$TABLE prefs=$PRIO,$PRIO_OIF cleaned" + ;; + + *) + echo "usage: $0 {up|down} [iface] [/path/to/config.json]" >&2 + exit 1 + ;; +esac diff --git a/nullforge/templates/scripts/zt-tunnel-warp.sh b/nullforge/templates/scripts/zt-tunnel-warp.sh new file mode 100755 index 0000000..2504b48 --- /dev/null +++ b/nullforge/templates/scripts/zt-tunnel-warp.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cmd=${1:?up|down} +IFACE=${2:-warp} + +log(){ logger -t zt-tunnel-warp -- "$*"; } + +CIDRS="198.41.200.0/24 104.16.0.0/12" + +case "$cmd" in + up) + for cidr in $CIDRS; do + ip route replace "$cidr" dev "$IFACE" + log "Added route for $cidr via $IFACE" + done + ;; + down) + for cidr in $CIDRS; do + ip route del "$cidr" dev "$IFACE" + log "Removed route for $cidr via $IFACE" + done + ;; + *) + log "Invalid command: $cmd" + exit 1 +esac diff --git a/nullforge/templates/systemd/cloudflare-dns.service.j2 b/nullforge/templates/systemd/cloudflare-dns.service.j2 new file mode 100644 index 0000000..02ac09d --- /dev/null +++ b/nullforge/templates/systemd/cloudflare-dns.service.j2 @@ -0,0 +1,20 @@ +[Unit] +Description=Cloudflare DNS-over-HTTPS proxy +Wants=network-online.target nss-lookup.target +Before=nss-lookup.target + +[Service] +User=cloudflare +Group=cloudflare +Type=simple + +ExecStart=/usr/bin/cloudflared --config {{ CONFIG_PATH }} + +AmbientCapabilities=CAP_NET_BIND_SERVICE +StandardOutput=journal +StandardError=journal +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/nullforge/templates/systemd/cloudflare-tunnel.service.j2 b/nullforge/templates/systemd/cloudflare-tunnel.service.j2 new file mode 100644 index 0000000..4f40480 --- /dev/null +++ b/nullforge/templates/systemd/cloudflare-tunnel.service.j2 @@ -0,0 +1,23 @@ +[Unit] +Description=Cloudflare Zero Trust Tunnel +Wants=network-online.target +After=network-online.target + +[Service] +User=cloudflare +Group=cloudflare +Type=simple + +ExecStart=/usr/bin/cloudflared --config {{ CONFIG_PATH }} tunnel run +{% if ROUTE_THROUGH_WARP %} +ExecStartPost={{ WORKDIR }}/zt-tunnel-warp.sh up +ExecStopPost={{ WORKDIR }}/zt-tunnel-warp.sh down +{% endif %} +AmbientCapabilities=CAP_NET_ADMIN +StandardOutput=journal +StandardError=journal +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/nullforge/templates/systemd/cloudflare-warp-check.service.j2 b/nullforge/templates/systemd/cloudflare-warp-check.service.j2 new file mode 100644 index 0000000..b3d8faf --- /dev/null +++ b/nullforge/templates/systemd/cloudflare-warp-check.service.j2 @@ -0,0 +1,18 @@ +[Unit] +Description=Cloudflare WARP Tunnel health check +Wants=network-online.target +After=network-online.target + +[Service] +User=cloudflare +Group=cloudflare +Type=oneshot + +ExecStart={{ HEALTH_CHECK_SCRIPT }} {{ IFACE }} {{ SERVICE_NAME }} + +Nice=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/nullforge/templates/systemd/cloudflare-warp-check.timer.j2 b/nullforge/templates/systemd/cloudflare-warp-check.timer.j2 new file mode 100644 index 0000000..a8dc630 --- /dev/null +++ b/nullforge/templates/systemd/cloudflare-warp-check.timer.j2 @@ -0,0 +1,10 @@ +[Unit] +Description=Run Cloudflare WARP Tunnel health check every 5 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min +Unit={{ SERVICE_NAME }}-check.service + +[Install] +WantedBy=timers.target diff --git a/nullforge/templates/systemd/cloudflare-warp.service.j2 b/nullforge/templates/systemd/cloudflare-warp.service.j2 new file mode 100644 index 0000000..2bd0cce --- /dev/null +++ b/nullforge/templates/systemd/cloudflare-warp.service.j2 @@ -0,0 +1,23 @@ +[Unit] +Description=Cloudflare Warp Tunnel (Masque) +Wants=network-online.target +After=network-online.target + +[Service] +User=cloudflare +Group=cloudflare +Type=simple + +ExecStart=/usr/local/bin/usque nativetun -m {{ MTU }} -c {{ CONFIG_PATH }} -n {{ INET_NAME }} {% if ENABLE_IPV6 %}--ipv6{% else %}--no-tunnel-ipv6{% endif %} +{% if ENABLE_IPV6 %} +ExecStartPost={{ WORKDIR }}/warp-v6-policy.sh up {{ INET_NAME }} {{ CONFIG_PATH }} +ExecStopPost={{ WORKDIR }}/warp-v6-policy.sh down {{ INET_NAME }} {{ CONFIG_PATH }} +{% endif %} +AmbientCapabilities=CAP_NET_ADMIN +StandardOutput=journal +StandardError=journal +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/nullforge/templates/systemd/mtg.service.j2 b/nullforge/templates/systemd/mtg.service.j2 new file mode 100644 index 0000000..68538b0 --- /dev/null +++ b/nullforge/templates/systemd/mtg.service.j2 @@ -0,0 +1,25 @@ +[Unit] +Description=MTProto Proxy (mtg) +Wants=network-online.target +After=network-online.target + +[Service] +User=mtg +Group=mtg +Type=simple + +ExecStart=/usr/local/bin/mtg run {{ CONFIG_PATH }} + +{% if PORT <= 1024 %}AmbientCapabilities=CAP_NET_BIND_SERVICE{% endif %} +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths={{ CONFIG_DIR }} +StandardOutput=journal +StandardError=journal +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/nullforge/templates/systemd/telemt.service.j2 b/nullforge/templates/systemd/telemt.service.j2 new file mode 100644 index 0000000..e7feb92 --- /dev/null +++ b/nullforge/templates/systemd/telemt.service.j2 @@ -0,0 +1,20 @@ +[Unit] +Description=Telemt +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=telemt +Group=telemt +ExecStart=/usr/local/bin/telemt /etc/telemt/telemt.toml +Restart=on-failure +LimitNOFILE=65536 +{% if PORT <= 1024 %}AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE +{% endif %}NoNewPrivileges=true +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/nullforge/templates/telemt/telemt.toml.j2 b/nullforge/templates/telemt/telemt.toml.j2 new file mode 100644 index 0000000..29390d9 --- /dev/null +++ b/nullforge/templates/telemt/telemt.toml.j2 @@ -0,0 +1,47 @@ +[general] +fast_mode = true +use_middle_proxy = false +# TODO: Make log level modular +# log_level = "silent" + +[general.modes] +classic = false +secure = false +tls = true + +[server] +port = {{ PORT }} +# TODO: Change to global address is port is 443 +listen_addr_ipv4 = "127.0.0.1" +listen_unix_sock = "/dev/shm/mtproto.sock" +listen_tcp = true +proxy_protocol = true + +[server.api] +enabled = false + +# TODO: Make IPv6 support and prefer is host support it +[network] +ipv6 = true +prefer = 6 + +[censorship] +tls_domain = "{{ TLS_DOMAIN }}" + +[access.users] +{% for username, secret in USERS.items() %}{{ username }} = "{{ secret }}" +{% endfor %} + +# TODO: Add socks5 upstream if enabled in config +[[upstreams]] +type = "socks5" +address = "[::1]:2080" +weight = 5 +enabled = false + +# TODO: Add warp outbound if warp is deployed on host +[[upstreams]] +type = "direct" +interface = "warp" +weight = 10 +enabled = true diff --git a/nullforge/templates/tor/torrc.j2 b/nullforge/templates/tor/torrc.j2 new file mode 100644 index 0000000..16259b5 --- /dev/null +++ b/nullforge/templates/tor/torrc.j2 @@ -0,0 +1,5 @@ +SocksPort {{ SOCKS_PORT }} +AutomapHostsOnResolve 1 +DnsPort {{ DNS_PORT }} +SocksPolicy accept 127.0.0.1 +SocksPolicy reject * diff --git a/pyproject.toml b/pyproject.toml index ba08f79..701d46c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,32 +1,38 @@ -[build-system] -build-backend = "hatchling.build" -requires = [ "hatchling" ] - -[project] -name = "nullforge" -description = "Forge the server's baseline from null" -maintainers = [ { name = "Renat Mustafin", email = "mustafinrr.rm@gmail.com" } ] -authors = [ { name = "Renat Mustafin", email = "mustafinrr.rm@gmail.com" } ] -requires-python = ">=3.13,<3.14" -classifiers = [ - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.13", -] -dynamic = [ "version" ] -dependencies = [] - -[dependency-groups] -dev = [ "prek", "ruff", "ty" ] - -[tool.hatch] -build.targets.wheel.packages = [ "nullforge" ] -metadata.allow-direct-references = true -version.path = "nullforge/__init__.py" - -[tool.ruff] -line-length = 120 -lint.select = [ "E", "F", "I", "N", "S", "UP", "W", "YTT" ] -lint.isort.lines-after-imports = 2 - -[tool.ty] -environment.root = [ "./nullforge" ] +[build-system] +build-backend = "hatchling.build" +requires = [ "hatchling" ] + +[project] +name = "nullforge" +description = "Forge the server's baseline from null" +maintainers = [ { name = "Renat Mustafin", email = "mustafinrr.rm@gmail.com" } ] +authors = [ { name = "Renat Mustafin", email = "mustafinrr.rm@gmail.com" } ] +requires-python = ">=3.13,<3.14" +classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.13", +] +dynamic = [ "version" ] +dependencies = [ + "pydantic", + "pyinfra", +] + +[dependency-groups] +dev = [ "prek", "ruff", "ty" ] + +[tool.hatch] +build.targets.wheel.packages = [ "nullforge" ] +metadata.allow-direct-references = true +version.path = "nullforge/__init__.py" + +[tool.ruff] +line-length = 120 +lint.select = [ "E", "F", "I", "N", "S", "UP", "W", "YTT" ] +lint.ignore = [ + "S108", # Probable insecure usage of temporary file or directory +] +lint.isort.lines-after-imports = 2 + +[tool.ty] +environment.root = [ "./nullforge" ] diff --git a/uv.lock b/uv.lock index bb29b95..3b83f1f 100644 --- a/uv.lock +++ b/uv.lock @@ -2,9 +2,248 @@ version = 1 revision = 3 requires-python = "==3.13.*" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "gevent" +version = "25.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/77/b97f086388f87f8ad3e01364f845004aef0123d4430241c7c9b1f9bde742/gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed", size = 2973739, upload-time = "2025-09-17T14:53:30.279Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/9d5f204ead343e5b27bbb2fedaec7cd0009d50696b2266f590ae845d0331/gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245", size = 1809165, upload-time = "2025-09-17T15:41:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/10/3e/791d1bf1eb47748606d5f2c2aa66571f474d63e0176228b1f1fd7b77ab37/gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82", size = 1890638, upload-time = "2025-09-17T15:49:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5c/9ad0229b2b4d81249ca41e4f91dd8057deaa0da6d4fbe40bf13cdc5f7a47/gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48", size = 1857118, upload-time = "2025-09-17T15:49:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/49/2a/3010ed6c44179a3a5c5c152e6de43a30ff8bc2c8de3115ad8733533a018f/gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7", size = 2111598, upload-time = "2025-09-17T15:15:15.226Z" }, + { url = "https://files.pythonhosted.org/packages/08/75/6bbe57c19a7aa4527cc0f9afcdf5a5f2aed2603b08aadbccb5bf7f607ff4/gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47", size = 1829059, upload-time = "2025-09-17T15:52:42.596Z" }, + { url = "https://files.pythonhosted.org/packages/06/6e/19a9bee9092be45679cb69e4dd2e0bf5f897b7140b4b39c57cc123d24829/gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117", size = 2173529, upload-time = "2025-09-17T15:24:13.897Z" }, + { url = "https://files.pythonhosted.org/packages/ca/4f/50de9afd879440e25737e63f5ba6ee764b75a3abe17376496ab57f432546/gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa", size = 1681518, upload-time = "2025-09-17T19:39:47.488Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + [[package]] name = "nullforge" source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "pyinfra" }, +] [package.dev-dependencies] dev = [ @@ -14,6 +253,10 @@ dev = [ ] [package.metadata] +requires-dist = [ + { name = "pydantic" }, + { name = "pyinfra" }, +] [package.metadata.requires-dev] dev = [ @@ -22,75 +265,267 @@ dev = [ { name = "ty" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "paramiko" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/15/ad6ce226e8138315f2451c2aeea985bf35ee910afb477bae7477dc3a8f3b/paramiko-3.5.1.tar.gz", hash = "sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822", size = 1566110, upload-time = "2025-02-04T02:37:59.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/f8/c7bd0ef12954a81a1d3cea60a13946bd9a49a0036a5927770c461eade7ae/paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61", size = 227298, upload-time = "2025-02-04T02:37:57.672Z" }, +] + [[package]] name = "prek" -version = "0.3.5" +version = "0.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/ee/03e8180e3fda9de25b6480bd15cc2bde40d573868d50648b0e527b35562f/prek-0.3.8.tar.gz", hash = "sha256:434a214256516f187a3ab15f869d950243be66b94ad47987ee4281b69643a2d9", size = 400224, upload-time = "2026-03-23T08:23:35.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/84/40d2ddf362d12c4cd4a25a8c89a862edf87cdfbf1422aa41aac8e315d409/prek-0.3.8-py3-none-linux_armv6l.whl", hash = "sha256:6fb646ada60658fa6dd7771b2e0fb097f005151be222f869dada3eb26d79ed33", size = 5226646, upload-time = "2026-03-23T08:23:18.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/52/7308a033fa43b7e8e188797bd2b3b017c0f0adda70fa7af575b1f43ea888/prek-0.3.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3d7fdadb15efc19c09953c7a33cf2061a70f367d1e1957358d3ad5cc49d0616", size = 5620104, upload-time = "2026-03-23T08:23:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b1/f106ac000a91511a9cd80169868daf2f5b693480ef5232cec5517a38a512/prek-0.3.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:72728c3295e79ca443f8c1ec037d2a5b914ec73a358f69cf1bc1964511876bf8", size = 5199867, upload-time = "2026-03-23T08:23:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e9/970713f4b019f69de9844e1bab37b8ddb67558e410916f4eb5869a696165/prek-0.3.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:48efc28f2f53b5b8087efca9daaed91572d62df97d5f24a1c7a087fecb5017de", size = 5441801, upload-time = "2026-03-23T08:23:32.617Z" }, + { url = "https://files.pythonhosted.org/packages/12/a4/7ef44032b181753e19452ec3b09abb3a32607cf6b0a0508f0604becaaf2b/prek-0.3.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6ca9d63bacbc448a5c18e955c78d3ac5176c3a17c3baacdd949b1a623e08a36", size = 5155107, upload-time = "2026-03-23T08:23:31.021Z" }, + { url = "https://files.pythonhosted.org/packages/bd/77/4d9c8985dbba84149760785dfe07093ea1e29d710257dfb7c89615e2234c/prek-0.3.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1000f7029696b4fe712fb1fefd4c55b9c4de72b65509c8e50296370a06f9dc3f", size = 5566541, upload-time = "2026-03-23T08:23:45.694Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1a/81e6769ac1f7f8346d09ce2ab0b47cf06466acd9ff72e87e5d1f0d98cd32/prek-0.3.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ff0bed0e2c1286522987d982168a86cbbd0d069d840506a46c9fda983515517", size = 6552991, upload-time = "2026-03-23T08:23:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fa/ce2df0dd2dc75a9437a52463239d0782998943d7b04e191fb89b83016c34/prek-0.3.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fb087ac0ffda3ac65bbbae9a38326a7fd27ee007bb4a94323ce1eb539d8bbec", size = 5832972, upload-time = "2026-03-23T08:23:20.258Z" }, + { url = "https://files.pythonhosted.org/packages/18/6b/9d4269df9073216d296244595a21c253b6475dfc9076c0bd2906be7a436c/prek-0.3.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2e1e5e206ff7b31bd079cce525daddc96cd6bc544d20dc128921ad92f7a4c85d", size = 5448371, upload-time = "2026-03-23T08:23:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/60/1d/1e4d8a78abefa5b9d086e5a9f1638a74b5e540eec8a648d9946707701f29/prek-0.3.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dcea3fe23832a4481bccb7c45f55650cb233be7c805602e788bb7dba60f2d861", size = 5270546, upload-time = "2026-03-23T08:23:24.231Z" }, + { url = "https://files.pythonhosted.org/packages/77/07/34f36551a6319ae36e272bea63a42f59d41d2d47ab0d5fb00eb7b4e88e87/prek-0.3.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:4d25e647e9682f6818ab5c31e7a4b842993c14782a6ffcd128d22b784e0d677f", size = 5124032, upload-time = "2026-03-23T08:23:26.368Z" }, + { url = "https://files.pythonhosted.org/packages/e3/01/6d544009bb655e709993411796af77339f439526db4f3b3509c583ad8eb9/prek-0.3.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de528b82935e33074815acff3c7c86026754d1212136295bc88fe9c43b4231d5", size = 5432245, upload-time = "2026-03-23T08:23:47.877Z" }, + { url = "https://files.pythonhosted.org/packages/54/96/1237ee269e9bfa283ffadbcba1f401f48a47aed2b2563eb1002740d6079d/prek-0.3.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6d660f1c25a126e6d9f682fe61449441226514f412a4469f5d71f8f8cad56db2", size = 5950550, upload-time = "2026-03-23T08:23:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6b/a574411459049bc691047c9912f375deda10c44a707b6ce98df2b658f0b3/prek-0.3.8-py3-none-win32.whl", hash = "sha256:b0c291c577615d9f8450421dff0b32bfd77a6b0d223ee4115a1f820cb636fdf1", size = 4949501, upload-time = "2026-03-23T08:23:16.338Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b4/46b59fe49f635acd9f6530778ce577f9d8b49452835726a5311ffc902c67/prek-0.3.8-py3-none-win_amd64.whl", hash = "sha256:bc147fdbdd4ec33fc7a987b893ecb69b1413ac100d95c9889a70f3fd58c73d06", size = 5346551, upload-time = "2026-03-23T08:23:34.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/05/9cca1708bb8c65264124eb4b04251e0f65ce5bfc707080bb6b492d5a0df7/prek-0.3.8-py3-none-win_arm64.whl", hash = "sha256:a2614647aeafa817a5802ccb9561e92eedc20dcf840639a1b00826e2c2442515", size = 5190872, upload-time = "2026-03-23T08:23:29.463Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, +] + +[[package]] +name = "pyinfra" +version = "3.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "distro" }, + { name = "gevent" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "paramiko" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/2b/49eeb9000a66157f7cda8b5b4871e1e496f1fb42fd1b94290ee5efb9a3cb/pyinfra-3.7.tar.gz", hash = "sha256:2f8eeee84d4d90c5ee57300ae6673b58237117605cca6b0cffde29f0b2517d4b", size = 599054, upload-time = "2026-03-12T10:55:35.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/6d/246168fe781009d32199fb7553a9a462e298d17f6e49accedf3b16c65346/pyinfra-3.7-py3-none-any.whl", hash = "sha256:9208de3f4479ad80509db51da874445017987818dba8a36c808f56ce1451b34c", size = 267074, upload-time = "2026-03-12T10:55:34.518Z" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/d6/277e002e56eeab3a9d48f1ca4cc067d249d6326fc1783b770d70ad5ae2be/prek-0.3.5.tar.gz", hash = "sha256:ca40b6685a4192256bc807f32237af94bf9b8799c0d708b98735738250685642", size = 374806, upload-time = "2026-03-09T10:35:18.842Z" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/a9/16dd8d3a50362ebccffe58518af1f1f571c96f0695d7fcd8bbd386585f58/prek-0.3.5-py3-none-linux_armv6l.whl", hash = "sha256:44b3e12791805804f286d103682b42a84e0f98a2687faa37045e9d3375d3d73d", size = 5105604, upload-time = "2026-03-09T10:35:00.332Z" }, - { url = "https://files.pythonhosted.org/packages/e4/74/bc6036f5bf03860cda66ab040b32737e54802b71a81ec381839deb25df9e/prek-0.3.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3cb451cc51ac068974557491beb4c7d2d41dfde29ed559c1694c8ce23bf53e8", size = 5506155, upload-time = "2026-03-09T10:35:17.64Z" }, - { url = "https://files.pythonhosted.org/packages/02/d9/a3745c2a10509c63b6a118ada766614dd705efefd08f275804d5c807aa4a/prek-0.3.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ad8f5f0d8da53dc94d00b76979af312b3dacccc9dcbc6417756c5dca3633c052", size = 5100383, upload-time = "2026-03-09T10:35:13.302Z" }, - { url = "https://files.pythonhosted.org/packages/43/8e/de965fc515d39309a332789cd3778161f7bc80cde15070bedf17f9f8cb93/prek-0.3.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:4511e15d34072851ac88e4b2006868fbe13655059ad941d7a0ff9ee17138fd9f", size = 5334913, upload-time = "2026-03-09T10:35:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/44f07e8940256059cfd82520e3cbe0764ab06ddb4aa43148465db00b39ad/prek-0.3.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc0b63b8337e2046f51267facaac63ba755bc14aad53991840a5eccba3e5c28", size = 5033825, upload-time = "2026-03-09T10:35:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/94/85/3ff0f96881ff2360c212d310ff23c3cf5a15b223d34fcfa8cdcef203be69/prek-0.3.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5fc0d78c3896a674aeb8247a83bbda7efec85274dbdfbc978ceff8d37e4ed20", size = 5438586, upload-time = "2026-03-09T10:34:58.779Z" }, - { url = "https://files.pythonhosted.org/packages/79/a5/c6d08d31293400fcb5d427f8e7e6bacfc959988e868ad3a9d97b4d87c4b7/prek-0.3.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64cad21cb9072d985179495b77b312f6b81e7b45357d0c68dc1de66e0408eabc", size = 6359714, upload-time = "2026-03-09T10:34:57.454Z" }, - { url = "https://files.pythonhosted.org/packages/ba/18/321dcff9ece8065d42c8c1c7a53a23b45d2b4330aa70993be75dc5f2822f/prek-0.3.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45ee84199bb48e013bdfde0c84352c17a44cc42d5792681b86d94e9474aab6f8", size = 5717632, upload-time = "2026-03-09T10:35:08.634Z" }, - { url = "https://files.pythonhosted.org/packages/a3/7f/1288226aa381d0cea403157f4e6b64b356e1a745f2441c31dd9d8a1d63da/prek-0.3.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f43275e5d564e18e52133129ebeb5cb071af7ce4a547766c7f025aa0955dfbb6", size = 5339040, upload-time = "2026-03-09T10:35:03.665Z" }, - { url = "https://files.pythonhosted.org/packages/22/94/cfec83df9c2b8e7ed1608087bcf9538a6a77b4c2e7365123e9e0a3162cd1/prek-0.3.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:abcee520d31522bcbad9311f21326b447694cd5edba33618c25fd023fc9865ec", size = 5162586, upload-time = "2026-03-09T10:35:11.564Z" }, - { url = "https://files.pythonhosted.org/packages/13/b7/741d62132f37a5f7cc0fad1168bd31f20dea9628f482f077f569547e0436/prek-0.3.5-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:499c56a94a155790c75a973d351a33f8065579d9094c93f6d451ada5d1e469be", size = 5002933, upload-time = "2026-03-09T10:35:16.347Z" }, - { url = "https://files.pythonhosted.org/packages/6f/83/630a5671df6550fcfa67c54955e8a8174eb9b4d97ac38fb05a362029245b/prek-0.3.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de1065b59f194624adc9dea269d4ff6b50e98a1b5bb662374a9adaa496b3c1eb", size = 5304934, upload-time = "2026-03-09T10:35:09.975Z" }, - { url = "https://files.pythonhosted.org/packages/de/79/67a7afd0c0b6c436630b7dba6e586a42d21d5d6e5778fbd9eba7bbd3dd26/prek-0.3.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a1c4869e45ee341735d07179da3a79fa2afb5959cef8b3c8a71906eb52dc6933", size = 5829914, upload-time = "2026-03-09T10:35:05.39Z" }, - { url = "https://files.pythonhosted.org/packages/37/47/e2fe13b33e7b5fdd9dd1a312f5440208bfe1be6183e54c5c99c10f27d848/prek-0.3.5-py3-none-win32.whl", hash = "sha256:70b2152ecedc58f3f4f69adc884617b0cf44259f7414c44d6268ea6f107672eb", size = 4836910, upload-time = "2026-03-09T10:35:01.884Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ab/dc2a139fd4896d11f39631479ed385e86307af7f54059ebe9414bb0d00c6/prek-0.3.5-py3-none-win_amd64.whl", hash = "sha256:01d031b684f7e1546225393af1268d9b4451a44ef6cb9be4101c85c7862e08db", size = 5234234, upload-time = "2026-03-09T10:35:20.193Z" }, - { url = "https://files.pythonhosted.org/packages/ed/38/f7256b4b7581444f658e909c3b566f51bfabe56c03e80d107a6932d62040/prek-0.3.5-py3-none-win_arm64.whl", hash = "sha256:aa774168e3d868039ff79422bdef2df8d5a016ed804a9914607dcdd3d41da053", size = 5083330, upload-time = "2026-03-09T10:34:55.469Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "ruff" -version = "0.15.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, - { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, - { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, - { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, - { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, - { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, - { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, - { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, - { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "ty" -version = "0.0.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/ba/d3c998ff4cf6b5d75b39356db55fe1b7caceecc522b9586174e6a5dee6f7/ty-0.0.23.tar.gz", hash = "sha256:5fb05db58f202af366f80ef70f806e48f5237807fe424ec787c9f289e3f3a4ef", size = 5341461, upload-time = "2026-03-13T12:34:23.125Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/21/aab32603dfdfacd4819e52fa8c6074e7bd578218a5142729452fc6a62db6/ty-0.0.23-py3-none-linux_armv6l.whl", hash = "sha256:e810eef1a5f1cfc0731a58af8d2f334906a96835829767aed00026f1334a8dd7", size = 10329096, upload-time = "2026-03-13T12:34:09.432Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a9/dd3287a82dce3df546ec560296208d4905dcf06346b6e18c2f3c63523bd1/ty-0.0.23-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e43d36bd89a151ddcad01acaeff7dcc507cb73ff164c1878d2d11549d39a061c", size = 10156631, upload-time = "2026-03-13T12:34:53.122Z" }, - { url = "https://files.pythonhosted.org/packages/0f/01/3f25909b02fac29bb0a62b2251f8d62e65d697781ffa4cf6b47a4c075c85/ty-0.0.23-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd6a340969577b4645f231572c4e46012acba2d10d4c0c6570fe1ab74e76ae00", size = 9653211, upload-time = "2026-03-13T12:34:15.049Z" }, - { url = "https://files.pythonhosted.org/packages/d5/60/bfc0479572a6f4b90501c869635faf8d84c8c68ffc5dd87d04f049affabc/ty-0.0.23-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:341441783e626eeb7b1ec2160432956aed5734932ab2d1c26f94d0c98b229937", size = 10156143, upload-time = "2026-03-13T12:34:34.468Z" }, - { url = "https://files.pythonhosted.org/packages/3a/81/8a93e923535a340f54bea20ff196f6b2787782b2f2f399bd191c4bc132d6/ty-0.0.23-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ce1dc66c26d4167e2c78d12fa870ef5a7ec9cc344d2baaa6243297cfa88bd52", size = 10136632, upload-time = "2026-03-13T12:34:28.832Z" }, - { url = "https://files.pythonhosted.org/packages/da/cb/2ac81c850c58acc9f976814404d28389c9c1c939676e32287b9cff61381e/ty-0.0.23-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bae1e7a294bf8528836f7617dc5c360ea2dddb63789fc9471ae6753534adca05", size = 10655025, upload-time = "2026-03-13T12:34:37.105Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9b/bac771774c198c318ae699fc013d8cd99ed9caf993f661fba11238759244/ty-0.0.23-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b162768764d9dc177c83fb497a51532bb67cbebe57b8fa0f2668436bf53f3c", size = 11230107, upload-time = "2026-03-13T12:34:20.751Z" }, - { url = "https://files.pythonhosted.org/packages/14/09/7644fb0e297265e18243f878aca343593323b9bb19ed5278dcbc63781be0/ty-0.0.23-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d28384e48ca03b34e4e2beee0e230c39bbfb68994bb44927fec61ef3642900da", size = 10934177, upload-time = "2026-03-13T12:34:17.904Z" }, - { url = "https://files.pythonhosted.org/packages/18/14/69a25a0cad493fb6a947302471b579a03516a3b00e7bece77fdc6b4afb9b/ty-0.0.23-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:559d9a299df793cb7a7902caed5eda8a720ff69164c31c979673e928f02251ee", size = 10752487, upload-time = "2026-03-13T12:34:31.785Z" }, - { url = "https://files.pythonhosted.org/packages/9d/2a/42fc3cbccf95af0a62308ebed67e084798ab7a85ef073c9986ef18032743/ty-0.0.23-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:32a7b8a14a98e1d20a9d8d2af23637ed7efdb297ac1fa2450b8e465d05b94482", size = 10133007, upload-time = "2026-03-13T12:34:42.838Z" }, - { url = "https://files.pythonhosted.org/packages/e1/69/307833f1b52fa3670e0a1d496e43ef7df556ecde838192d3fcb9b35e360d/ty-0.0.23-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6f803b9b9cca87af793467973b9abdd4b83e6b96d9b5e749d662cff7ead70b6d", size = 10169698, upload-time = "2026-03-13T12:34:12.351Z" }, - { url = "https://files.pythonhosted.org/packages/89/ae/5dd379ec22d0b1cba410d7af31c366fcedff191d5b867145913a64889f66/ty-0.0.23-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4a0bf086ec8e2197b7ea7ebfcf4be36cb6a52b235f8be61647ef1b2d99d6ffd3", size = 10346080, upload-time = "2026-03-13T12:34:40.012Z" }, - { url = "https://files.pythonhosted.org/packages/98/c7/dfc83203d37998620bba9c4873a080c8850a784a8a46f56f8163c5b4e320/ty-0.0.23-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:252539c3fcd7aeb9b8d5c14e2040682c3e1d7ff640906d63fd2c4ce35865a4ba", size = 10848162, upload-time = "2026-03-13T12:34:45.421Z" }, - { url = "https://files.pythonhosted.org/packages/89/08/05481511cfbcc1fd834b6c67aaae090cb609a079189ddf2032139ccfc490/ty-0.0.23-py3-none-win32.whl", hash = "sha256:51b591d19eef23bbc3807aef77d38fa1f003c354e1da908aa80ea2dca0993f77", size = 9748283, upload-time = "2026-03-13T12:34:50.607Z" }, - { url = "https://files.pythonhosted.org/packages/31/2e/eaed4ff5c85e857a02415084c394e02c30476b65e158eec1938fdaa9a205/ty-0.0.23-py3-none-win_amd64.whl", hash = "sha256:1e137e955f05c501cfbb81dd2190c8fb7d01ec037c7e287024129c722a83c9ad", size = 10698355, upload-time = "2026-03-13T12:34:26.134Z" }, - { url = "https://files.pythonhosted.org/packages/91/29/b32cb7b4c7d56b9ed50117f8ad6e45834aec293e4cb14749daab4e9236d5/ty-0.0.23-py3-none-win_arm64.whl", hash = "sha256:a0399bd13fd2cd6683fd0a2d59b9355155d46546d8203e152c556ddbdeb20842", size = 10155890, upload-time = "2026-03-13T12:34:48.082Z" }, +version = "0.0.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/c2/a60543fb172ac7adaa3ae43b8db1d0dcd70aa67df254b70bf42f852a24f6/ty-0.0.28.tar.gz", hash = "sha256:1fbde7bc5d154d6f047b570d95665954fa83b75a0dce50d88cf081b40a27ea32", size = 5447781, upload-time = "2026-04-02T21:34:33.556Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/15/c2aa3d4633e6153a2e300d7dd0ebdedf904a60241d1922566f31c5f7f211/ty-0.0.28-py3-none-linux_armv6l.whl", hash = "sha256:6dbfb27524195ab1715163d7be065cc45037509fe529d9763aff6732c919f0d8", size = 10556282, upload-time = "2026-04-02T21:35:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/60/9c/f6183838df89e9692235a71a69a9d4e0f12481bbdf1883f47010075793b0/ty-0.0.28-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8c72a899ba94f7438bd07e897a84b36526b385aaf01d6f3eb6504e869232b3a6", size = 10425770, upload-time = "2026-04-02T21:34:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/68/82/e9208383412f8a320537ef4c44a768d2cb6c1330d9ab33087f0b932ccd1b/ty-0.0.28-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eef67f9cdfd31677bde801b611741dde779271ec6f471f818c7c6eccf515237f", size = 9899999, upload-time = "2026-04-02T21:34:40.297Z" }, + { url = "https://files.pythonhosted.org/packages/4d/26/0442f49589ba393fbd3b50751f8bb82137b036bc509762884f7b21c511d1/ty-0.0.28-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70e7b98a91d8245641be1e4b55af8bc9b1ae82ec189794d35e14e546f1e15e66", size = 10400725, upload-time = "2026-04-02T21:34:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/57/d9/64128f1a7ceba72e49f35dd562533f44d4c56d0cf62efb21692377819dbc/ty-0.0.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9bd83d4ad9f99078b830aabb47792fac6dc39368bb0f72f3cc14607173ed6e25", size = 10387410, upload-time = "2026-04-02T21:34:46.889Z" }, + { url = "https://files.pythonhosted.org/packages/cc/52/498b6bdd1d0a985fd14ce83c31186f3b838ad79efdf68ce928f441a6962b/ty-0.0.28-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0172984fc2fcd3e47ccd5da69f36f632cddc410f9a093144a05ad07d67cf06ed", size = 10880982, upload-time = "2026-04-02T21:34:53.687Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c8/fefd616f38a250b28f62ba73728cb6061715f03df0a610dce558a0fdfc0a/ty-0.0.28-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0bbf47d2bea82a09cab2ca4f48922d6c16a36608447acdc64163cd19beb28d3", size = 11459056, upload-time = "2026-04-02T21:34:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/16/15/9e18d763a5ef9c6a69396876586589fd5e0fd0acba35fae8a9a169680f48/ty-0.0.28-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1774c9a0fb071607e3bdfa0ce8365488ac46809fc04ad1706562a8709a023247", size = 11156341, upload-time = "2026-04-02T21:35:01.824Z" }, + { url = "https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2849d6d212af78175430e8cc51a962a53851458182eb44a981b0e3981163177", size = 11006089, upload-time = "2026-04-02T21:34:38.111Z" }, + { url = "https://files.pythonhosted.org/packages/dd/de/5b5fdbe3bdb5c6f4918b33f1c55cd975b3d606057089a822439d5151bf93/ty-0.0.28-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3c576c15b867b3913c4a1d9be30ade4682303e24a576d2cc99bfd8f25ae838e9", size = 10367739, upload-time = "2026-04-02T21:34:57.679Z" }, + { url = "https://files.pythonhosted.org/packages/80/82/abdfb27ab988e6bd09502a4573f64a7e72db3e83acd7886af54448703c97/ty-0.0.28-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e5f13d10b3436bee3ea35851e5af400123f6693bfae48294ddfbbf553fa51ef", size = 10399528, upload-time = "2026-04-02T21:34:51.398Z" }, + { url = "https://files.pythonhosted.org/packages/ba/74/3ccbe468e8480ba53f83a1e52481d3e11756415f0ca1297fb2da65e29612/ty-0.0.28-py3-none-musllinux_1_2_i686.whl", hash = "sha256:759db467e399faedc7d5f1ca4b383dd8ecc71d7d79b2ca6ea6db4ac8e643378a", size = 10586771, upload-time = "2026-04-02T21:34:35.912Z" }, + { url = "https://files.pythonhosted.org/packages/ee/79/545c76dcef0c3f89fb733ec46118aed2a700e79d4e22cb142e3b5a80286c/ty-0.0.28-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0cd44e3c857951cbf3f8647722ca87475614fac8ac0371eb1f200a942315a2c2", size = 11110550, upload-time = "2026-04-02T21:34:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e4/e3c6f71c95a2cbabd7d88fd698b00b8af48e39aa10e0b10b839410fc3c6d/ty-0.0.28-py3-none-win32.whl", hash = "sha256:88e2c784ec5e0e2fb01b137d92fd595cdc27b98a553f4bb34b8bf138bac1be1e", size = 9985411, upload-time = "2026-04-02T21:34:44.763Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/79dbab4856d3d15e5173314ff1846be65d58b31de6efe62ef1c25c663b32/ty-0.0.28-py3-none-win_amd64.whl", hash = "sha256:faaffbef127cb67560ad6dbc6a8f8845a4033b818bcc78ad7af923e02df199db", size = 10986548, upload-time = "2026-04-02T21:34:59.886Z" }, + { url = "https://files.pythonhosted.org/packages/01/b2/cc987aaf5babacc55caf0aeb751c83401e86e05e22ce82dace5a7e7e5354/ty-0.0.28-py3-none-win_arm64.whl", hash = "sha256:34a18ea09ee09612fb6555deccf1eed810e6f770b61a41243b494bcb7f624a1c", size = 10388573, upload-time = "2026-04-02T21:34:29.219Z" }, +] + +[[package]] +name = "typeguard" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/e8/66e25efcc18542d58706ce4e50415710593721aae26e794ab1dec34fb66f/typeguard-4.5.1.tar.gz", hash = "sha256:f6f8ecbbc819c9bc749983cc67c02391e16a9b43b8b27f15dc70ed7c4a007274", size = 80121, upload-time = "2026-02-19T16:09:03.392Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/88/b55b3117287a8540b76dbdd87733808d4d01c8067a3b339408c250bb3600/typeguard-4.5.1-py3-none-any.whl", hash = "sha256:44d2bf329d49a244110a090b55f5f91aa82d9a9834ebfd30bcc73651e4a8cc40", size = 36745, upload-time = "2026-02-19T16:09:01.6Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "zope-event" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/a4/77daa5ba398996d16bb43fc721599d27d03eae68fe3c799de1963c72e228/zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224", size = 254019, upload-time = "2026-01-09T07:51:07.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/47/45188fb101fa060b20e6090e500682398ab415e516a0c228fbb22bc7def2/zope_interface-8.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:6068322004a0158c80dfd4708dfb103a899635408c67c3b10e9acec4dbacefec", size = 209170, upload-time = "2026-01-09T08:05:26.616Z" }, + { url = "https://files.pythonhosted.org/packages/09/03/f6b9336c03c2b48403c4eb73a1ec961d94dc2fb5354c583dfb5fa05fd41f/zope_interface-8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2499de92e8275d0dd68f84425b3e19e9268cd1fa8507997900fa4175f157733c", size = 209229, upload-time = "2026-01-09T08:05:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/07/b1/65fe1dca708569f302ade02e6cdca309eab6752bc9f80105514f5b708651/zope_interface-8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f777e68c76208503609c83ca021a6864902b646530a1a39abb9ed310d1100664", size = 259393, upload-time = "2026-01-09T08:05:29.897Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a5/97b49cfceb6ed53d3dcfb3f3ebf24d83b5553194f0337fbbb3a9fec6cf78/zope_interface-8.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b05a919fdb0ed6ea942e5a7800e09a8b6cdae6f98fee1bef1c9d1a3fc43aaa0", size = 264863, upload-time = "2026-01-09T08:05:31.501Z" }, + { url = "https://files.pythonhosted.org/packages/cb/02/0b7a77292810efe3a0586a505b077ebafd5114e10c6e6e659f0c8e387e1f/zope_interface-8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccc62b5712dd7bd64cfba3ee63089fb11e840f5914b990033beeae3b2180b6cb", size = 264369, upload-time = "2026-01-09T08:05:32.941Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1d/0d1ff3846302ed1b5bbf659316d8084b30106770a5f346b7ff4e9f540f80/zope_interface-8.2-cp313-cp313-win_amd64.whl", hash = "sha256:34f877d1d3bb7565c494ed93828fa6417641ca26faf6e8f044e0d0d500807028", size = 212447, upload-time = "2026-01-09T08:05:35.064Z" }, ]