diff --git a/Makefile b/Makefile index 3845d55688..b743565ca4 100644 --- a/Makefile +++ b/Makefile @@ -808,7 +808,7 @@ swarm-zombie: install-act ACT_WORKFLOW=.github/workflows/call-test-deploy.yml \ ACT_JOB=deploy \ ACT_MATRIX="apps:$(app);variant:$(or $(variant),0);mode:swarm" \ - ACT_INPUTS="whitelist=$(app) distros=$(SWARM_DISTROS) index=0 sweep=0 modes=swarm disable=$(disable)" \ + ACT_INPUTS="whitelist=$(app)#$(or $(variant),0)@swarm distros=$(SWARM_DISTROS) index=0 sweep=0 modes=swarm disable=$(disable)" \ bash scripts/tests/deploy/act/workflow.sh .PHONY: system-purge diff --git a/README.md b/README.md index 306cd45a83..873f7bf390 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Every invokable role in the tested lifecycle envelope, with its upstream homepag | [Fediwall](roles/web-app-fediwall/) | beta | Self-hosted Fediverse wall for showing recent public posts by hashtag or account in a live masonry grid | [๐ŸŒ](https://github.com/defnull/fediwall) [๐Ÿ› ๏ธ](roles/web-app-fediwall/README.md#quick-setup) | [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Friendica](roles/web-app-friendica/), [Mastodon](roles/web-app-mastodon/), [Matomo](roles/web-app-matomo/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/) | | [Fider](roles/web-app-fider/) | beta | Open-source community feedback platform for collecting, voting on, and prioritizing feature requests and ideas | [๐ŸŒ](https://www.fider.io/) [๐Ÿ› ๏ธ](roles/web-app-fider/README.md#quick-setup) | [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Hermes Agent](roles/web-app-hermes/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [Open WebUI](roles/web-app-openwebui/), [OpenClaw](roles/web-app-openclaw/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [SeaweedFS](roles/web-app-seaweedfs/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | | [File Server](roles/web-svc-file/) | beta | Static file server over HTTPS with directory listing, .well-known handling, and automatic TLS certificates | [๐Ÿ› ๏ธ](roles/web-svc-file/README.md#quick-setup) | [Corporate Design](roles/web-svc-css/), [Matomo](roles/web-app-matomo/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/) | +| [Firewall ruleset](roles/svc-net-firewall/) | beta | nftables packet filter whose rules each role owns as its own table, replaced atomically and reloaded at boot | [๐ŸŒ](https://www.netfilter.org/projects/nftables/) [๐ŸŽฌ](https://www.youtube.com/watch?v=EGKhIljDPCw) [๐Ÿ› ๏ธ](roles/svc-net-firewall/README.md#quick-setup) | | | [Flowise](roles/web-app-flowise/) | beta | Low-code visual builder for creating, testing, and publishing LLM and RAG workflows as HTTP endpoints | [๐ŸŒ](https://flowiseai.com/) [๐ŸŽฌ](https://www.youtube.com/watch?v=h_0TJTY6Ulg) [๐Ÿ› ๏ธ](roles/web-app-flowise/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [LiteLLM Gateway](roles/svc-ai-litellm/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [Ollama](roles/svc-ai-ollama/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [Qdrant](roles/svc-db-qdrant/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | | [Friendica](roles/web-app-friendica/) | beta | Decentralized, federated social-networking platform for peer-to-peer communication and community building | [๐ŸŒ](https://friendi.ca/) [๐Ÿ› ๏ธ](roles/web-app-friendica/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [MariaDB](roles/svc-db-mariadb/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | | [Funkwhale](roles/web-app-funkwhale/) | beta | Self-hosted, federated platform for sharing, hosting, and streaming your own music and audio library | [๐ŸŒ](https://www.funkwhale.audio/) [๐Ÿ› ๏ธ](roles/web-app-funkwhale/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [Redis](roles/svc-db-redis/), [SeaweedFS](roles/web-app-seaweedfs/), [Tor Onion Service](roles/svc-net-tor/), [Typesense](roles/svc-db-typesense/), [Universal Logout](roles/web-svc-logout/) | @@ -175,7 +176,7 @@ Every invokable role in the tested lifecycle envelope, with its upstream homepag | [SuiteCRM](roles/web-app-suitecrm/) | beta | Open-source CRM platform for sales, service, cases, and campaign management with workflow automation | [๐ŸŒ](https://suitecrm.com/) [๐ŸŽฌ](https://www.youtube.com/watch?v=pOAs_C3Fp_E) [๐Ÿ› ๏ธ](roles/web-app-suitecrm/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [MariaDB](roles/svc-db-mariadb/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | | [Swapfile](roles/svc-opt-swapfile/) | beta | On-target automation for creating and activating swap space via a cloned swapfile script | [๐Ÿ› ๏ธ](roles/svc-opt-swapfile/README.md#quick-setup) | | | [Taiga](roles/web-app-taiga/) | beta | Open-source agile project management platform for Scrum, Kanban, and hybrid team workflows | [๐ŸŒ](https://taiga.io/) [๐ŸŽฌ](https://www.youtube.com/watch?v=TYPX8nQEU5I) [๐Ÿ› ๏ธ](roles/web-app-taiga/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [SeaweedFS](roles/web-app-seaweedfs/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | -| [Tor Onion Service](roles/svc-net-tor/) | alpha | Tor daemon publishing the node as a v3 onion service | [๐Ÿ› ๏ธ](roles/svc-net-tor/README.md#quick-setup) | [Asset Mirror (Privacy Proxy)](roles/web-svc-mirror/) | +| [Tor Onion Service](roles/svc-net-tor/) | beta | Tor daemon publishing the node as a v3 onion service | [๐Ÿ› ๏ธ](roles/svc-net-tor/README.md#quick-setup) | [Asset Mirror (Privacy Proxy)](roles/web-svc-mirror/) | | [Typesense](roles/svc-db-typesense/) | beta | Central Dockerized search engine shared by multiple apps with per-consumer scoped API keys | [๐ŸŒ](https://typesense.org/) [๐Ÿ› ๏ธ](roles/svc-db-typesense/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Tor Onion Service](roles/svc-net-tor/) | | [Unbound](roles/svc-dns-unbound/) | beta | Central recursive/forwarding DNSSEC resolver on a static IP for consumer stacks to target as their DNS | [๐ŸŒ](https://nlnetlabs.nl/projects/unbound/about/) [๐Ÿ› ๏ธ](roles/svc-dns-unbound/README.md#quick-setup) | | | [Universal Logout](roles/web-svc-logout/) | beta | Central logout proxy for single-action sign-out across all OIDC-connected apps, with Nginx snippets and a live status UI | [๐Ÿ› ๏ธ](roles/web-svc-logout/README.md#quick-setup) | [Corporate Design](roles/web-svc-css/), [Matomo](roles/web-app-matomo/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/) | diff --git a/cli/administration/deploy/development/init.py b/cli/administration/deploy/development/init.py index 6b69dd919b..de711ef911 100644 --- a/cli/administration/deploy/development/init.py +++ b/cli/administration/deploy/development/init.py @@ -175,9 +175,9 @@ def handler(args: argparse.Namespace) -> int: if len(plan) == 1: _, inv_dir, round_variants, include_R, _purge_set = plan[0] - non_zero = {a: i for a, i in round_variants.items() if i} - suffix = f" variants={non_zero}" if non_zero else "" shown = built_includes.get(inv_dir, include_R) + non_zero = {a: i for a, i in round_variants.items() if i and a in shown} + suffix = f" variants={non_zero}" if non_zero else "" print( f">>> Inventory initialized at {inv_dir} " f"(include={','.join(shown)} " @@ -189,8 +189,8 @@ def handler(args: argparse.Namespace) -> int: f"(primary_apps={','.join(primary_apps)}):" ) for round_index, inv_dir, round_variants, include_R, _purge_set in plan: - non_zero = {a: i for a, i in round_variants.items() if i} shown = built_includes.get(inv_dir, include_R) + non_zero = {a: i for a, i in round_variants.items() if i and a in shown} print( f" [round {round_index}] {inv_dir} " f"include={','.join(shown)} " diff --git a/cli/administration/deploy/development/inventory/planner.py b/cli/administration/deploy/development/inventory/planner.py index 8dcfdff18d..99e64a74c4 100644 --- a/cli/administration/deploy/development/inventory/planner.py +++ b/cli/administration/deploy/development/inventory/planner.py @@ -103,11 +103,10 @@ def plan_dev_inventory_matrix( round_include = per_variant_includes[round_index] round_variants = dict(primary_round_variants) - for dep in round_include: + for dep, dep_variants in variants_per_app.items(): if dep in round_variants: continue - dep_variants = variants_per_app.get(dep) or [{}] - dep_count = max(1, len(dep_variants)) + dep_count = max(1, len(dep_variants or [{}])) round_variants[dep] = round_index if round_index < dep_count else 0 inv_dir = f"{base}-{round_index}" if total_rounds > 1 else base diff --git a/cli/build/docs/readme/generate.py b/cli/build/docs/readme/generate.py index 40a76041ab..201362567f 100644 --- a/cli/build/docs/readme/generate.py +++ b/cli/build/docs/readme/generate.py @@ -70,6 +70,19 @@ def _app_name(preamble: str, role_name: str) -> str: return entity.replace("-", " ").title() +def _needs_clearnet_resolver(role_dir) -> bool: + """Whether the role refuses to deploy without ``networks.internet.dns``. + + Only the inventories under ``inventories/development/`` set that value, so a + role asserting it has to name it in the block an operator copies, or the + documented deploy aborts on the assert. + """ + return any( + "networks.internet.dns" in read_text(str(path)) + for path in sorted((role_dir / "tasks").rglob("*.yml")) + ) + + def _base_context(role_dir, role_name: str, app_name: str, *, invokable: bool) -> dict: return { "application_id": role_name, @@ -84,6 +97,7 @@ def _base_context(role_dir, role_name: str, app_name: str, *, invokable: bool) - "application_author": _role_author(role_dir), "application_author_url": author_urls().get(_role_author(role_dir)), "application_is_host": not role_has_stack(role_dir), + "application_needs_clearnet_resolver": _needs_clearnet_resolver(role_dir), "cosmos_mermaid": derive_cosmos_mermaid(role_dir, role_name), "cosmos_legend": _cosmos_legend(), } diff --git a/docs/contributing/actions/testing/suppression.md b/docs/contributing/actions/testing/suppression.md index 8746107ca3..48661f5814 100644 --- a/docs/contributing/actions/testing/suppression.md +++ b/docs/contributing/actions/testing/suppression.md @@ -44,6 +44,7 @@ The placement rule is per check. The catalog "Position" column uses these labels | Rule | Position | Affected test | Effect | | --------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `url` | same or above | [test_urls_reachable.py](../../../../tests/external/repository/test_urls_reachable.py) | Skips probing the literal HTTP(S) URL. Use for CDN roots and API base URLs that 4xx without a path. | +| `onion-flag` | same or above | [test_onion_port_flag.py](../../../../tests/lint/ansible/services/test_onion_port_flag.py) | Accepts a `false` under `ports.onion`. The marker MUST carry a reason naming why that port stays off the node onion; a bare marker is rejected. | | `docker-version` | same or above | [test_image_versions.py](../../../../tests/external/update/docker/test_image_versions.py), [test_semver_pinning.py](../../../../tests/lint/ansible/services/test_semver_pinning.py) | Skips both the live version-update warning and the non-semver-pin lint for that image's `version:` key. | | `repository-version` | same or above | [test_repository_versions.py](../../../../tests/external/update/repository/test_repository_versions.py), [test_semver_pinning.py](../../../../tests/lint/ansible/services/test_semver_pinning.py) | Skips both the live ref-update warning and the non-semver-pin lint for that entity's `ref:` key. | | `dockerfile-arg-default` | same or above | [test_arg_without_default.py](../../../../tests/lint/docker/dockerfile/test_arg_without_default.py) | Allows the marked `ARG` to carry a default. The caller normally supplies every build argument so a missing one fails the build instead of selecting a base image nobody chose. | @@ -107,6 +108,11 @@ The placement rule is per check. The catalog "Position" column uses these labels | `csp-unsafe` | same line | [test_csp_unsafe_requires_justification.py](../../../../tests/lint/ansible/roles/meta/test_csp_unsafe_requires_justification.py) | Documents an `unsafe-*: true` flag in `roles/*/meta/csp.yml`. MUST be paired with a `Reason:` on the same line naming what breaks without the relaxation. `unsafe-inline` re-admits the inline execution an XSS needs and `unsafe-eval` re-admits string-to-code, so the flag has to be verified against the running app, not copied from another role. | | `min-storage-headroom` | same line OR comment block above key | [test_min_storage_headroom.py](../../../../tests/external/update/docker/test_min_storage_headroom.py) | Allows a `roles/*/meta/services.yml` `min_storage` to exceed the measured compressed size of its own `image:`/`version:` by more than 1 GB. MUST be paired with a `Reason:` on the same comment line naming what occupies the space. Reserved for services whose data volume genuinely dominates the footprint; the default path is to lower the value, because an over-declared budget makes a host look too small and prunes a cache the deploy did not need to lose. | | `galaxy-git-parity` | same or above | [test_galaxy_entries_have_git_source.py](../../../../tests/lint/repository/dependencies/test_galaxy_entries_have_git_source.py) | Exempts a `requirements/requirements.galaxy.yml` collection from the requirement to also carry a `type: git` entry in `requirements/requirements.git.yml`. Reserved for collections that publish no usable git remote; without the git entry a `galaxy.ansible.com` outage fails `make install`, because the fallback in `scripts/install/ansible.sh` cannot resolve the collection either. | +| `loop-var-shadow` | same or above | [test_loop_var_shadowing.py](../../../../tests/lint/ansible/tasks/test_loop_var_shadowing.py) | Allows a `loop_var` to take the name of a variable declared in `group_vars/**` or in a role's `vars`/`defaults`. Inside the loop that name carries the item, so every lookup, template and nested include reading it sees the item instead, and ansible only warns. Reserved for a loop whose body reads the name as the item on purpose, typically because the item IS that value: state which meaning the body relies on. | +| `firewall-fragment-accept` | same or above | [test_firewall_fragment_verdicts.py](../../../../tests/lint/ansible/jinja/test_firewall_fragment_verdicts.py) | Allows a rule in a `roles/*/templates/nftables.conf.j2` fragment to end in an `accept` verdict. Default path: express the intent as a `drop` of what must not pass, or put the rule in the chain carrying the policy it has to overrule, because an `accept` terminates only its own base chain and every other chain at that hook still runs, docker's `drop` policy on forwarded packets included. Reserved for an accept whose only job is to skip the fragment's own later rules. | +| `shell-template` | head (first 30 lines) | [test_no_shell_templates.py](../../../../tests/lint/filesystem/shell/test_no_shell_templates.py) | Allows a `*.sh.j2` to exist. Default path: ship the script as a plain `.sh` under `files/shell/` and pass its values as `ExecStart` arguments or `Environment=` entries, because shellcheck, the repository's `.sh` guards and every test skip a Jinja template, so nothing reads what actually runs on the host. Reserved for a script whose shape is generated, not just its values. Place the marker BELOW the shebang, never above it, and prefer the Jinja comment form so it does not reach the rendered script. | +| `systemd-via-sys-service` | same or above | [test_systemd_units_via_sys_service.py](../../../../tests/lint/ansible/modules/test_systemd_units_via_sys_service.py) | Allows a role task to render a `*.service.j2` itself instead of handing the unit to `sys-service`. Default path: name the template `templates/systemctl.service.j2` and include `sys-service` with `system_service_id`, so the unit also gets the daemon reload, the enable, and the timer, async and flush behaviour. Reserved for units `sys-service` cannot carry, such as a user-scope unit under `$HOME`. | +| `software-identity` | same or above | [test_software_identity_variables.py](../../../../tests/lint/ansible/jinja/test_software_identity_variables.py) | Allows a `.j2` template to write the software's name, domain or URL as a literal. Default path: render `SOFTWARE_NAME`, `SOFTWARE_DOMAIN`, `SOFTWARE_URL` or `ORGANIZATION`, which `group_vars/all/00_general.yml` declares once and derives from each other, so a rename reaches the template too. Reserved for a literal that must survive a rename, such as an identifier a third party issued under the old name. | ## Tool-Native `noqa` Codes ๐Ÿงน diff --git a/docs/contributing/design/role/services/layout.md b/docs/contributing/design/role/services/layout.md index 75c8677b68..809303a385 100644 --- a/docs/contributing/design/role/services/layout.md +++ b/docs/contributing/design/role/services/layout.md @@ -213,6 +213,8 @@ All port data lives under `.ports` in `meta/services.yml` (no `ports:` s relay: # for port-ranges (coturn, BBB, nextcloud TURN) start: end: + onion: + : # forward this category over the node onion ``` ### `internal` / `local` / `public` Split ๐Ÿงญ @@ -222,6 +224,7 @@ All port data lives under `.ports` in `meta/services.yml` (no `ports:` s | `internal` | **Internal container port.** Lives inside the container's network namespace, addressed by other containers on the same role-local network. NOT a host-bound port. Multiple roles MAY legitimately declare the same value (e.g. several nginx-based apps with `internal: { http: 80 }`). | | `local` | **Localhost-bound host port.** Bound on `127.0.0.1` and only reachable through the front-proxy / SSH tunnels. The OS-level binding namespace is shared across all roles, so `local` values MUST be unique across the whole repo. | | `public` | **Public-facing host port.** Bound on `0.0.0.0` and exposed to the public internet (or to whatever the operator's firewall allows). Same uniqueness rule as `local`. | +| `onion` | **Onion opt-in, not a port.** A category-keyed map of booleans naming which of the entity's own categories get a `HiddenServicePort` on the node onion. It declares no numbers: the port is read from `local` when the category is declared there and from `public` otherwise, matching what `container_ports` publishes. Collision detection ignores it. | ### Always Category-Keyed Maps ๐Ÿ—‚๏ธ @@ -256,6 +259,34 @@ coturn: end: 39999 ``` +### `onion` Forwarding Opt-In ๐Ÿง… + +`ports.onion` names the categories that answer on the node onion. Only the named +categories are forwarded. + +The flag is mandatory, enforced by [test_onion_port_flag.py](../../../../../tests/lint/ansible/services/test_onion_port_flag.py): +every category declared under `local` or `public` needs a `true` or a `false`, +and every `false` needs `# nocheck: onion-flag` plus the reason it is false. +Four groups are settled centrally instead and take no flag: the categories a +hidden service cannot carry (`relay`, `media`, `stun_turn`, `stun_turn_tls`), +the proxy-fronted `http`, `sso` and `websocket`, the implicit-TLS variants +(`smtps`, `imaps`, `pop3s`, `ldaps`), and any entity that already answers the +question through `exposed:`. + +```yaml +mailu: + ports: + public: + smtp: 25 + smtps: 465 + imap: 143 + onion: + smtp: true # 25 gets a HiddenServicePort; 465 and 143 do not +``` + +The forward is per-variant: `meta/variants.yml` may drop the flag, and the role +then loses the `HiddenServicePort` in that variant. + ### Multi-Entity Roles ๐ŸŽ›๏ธ Each entity carries its own `ports` block: diff --git a/docs/requirements/035-mcp-proxy-expansion.md b/docs/requirements/035-mcp-proxy-expansion.md index 55ef794b0e..9736a3cf69 100644 --- a/docs/requirements/035-mcp-proxy-expansion.md +++ b/docs/requirements/035-mcp-proxy-expansion.md @@ -488,7 +488,7 @@ A future exception requires a new requirement with a fixed operation list, dedic **`host_execution_boundary` (39):** `desk-bluray-player`, `desk-chromium`, `desk-copyq`, `desk-docker`, `desk-dotlinker`, `desk-firefox`, `desk-git`, `desk-gnome`, `desk-gnome-caffeine`, `desk-gnome-extensions`, `desk-gnome-terminal`, `desk-gnucash`, `desk-jrnl`, `desk-keepassxc`, `desk-libreoffice`, `desk-micro`, `desk-neovim`, `desk-nextcloud`, `desk-obs`, `desk-qbittorrent`, `desk-retroarch`, `desk-spotify`, `desk-ssh`, `desk-torbrowser`, `desk-virtualbox`, `desk-zoom`, `dev-arduino`, `dev-core`, `dev-java`, `dev-locales`, `dev-make`, `dev-nix`, `dev-nodejs`, `dev-python`, `drv-epson-multiprinter`, `drv-intel`, `drv-lid-switch`, `drv-non-free`, `gen-hunspell`. These roles operate a workstation, developer toolchain, device, or host package; bridging them would amount to shared shell, filesystem, browser-session, device, or host execution without an application-specific remote identity. -**`privileged_control_plane` (23):** `svc-bkp-local-2-device`, `svc-bkp-nfs-2-local`, `svc-bkp-remote-2-local`, `svc-bkp-secrets-2-local`, `svc-bkp-volume-2-local`, `svc-dns-unbound`, `svc-net-tor`, `svc-net-wireguard-core`, `svc-net-wireguard-firewalled`, `svc-net-wireguard-plain`, `svc-opt-keyboard-color`, `svc-opt-ssd-hdd`, `svc-opt-swapfile`, `svc-prx-openresty`, `svc-registry-cache`, `svc-registry-docker`, `svc-runner`, `svc-storage-nfs-client`, `svc-storage-nfs-server`, `svc-swarm-manager`, `svc-swarm-node`, `svc-virt-kata`, `update`. +**`privileged_control_plane` (24):** `svc-bkp-local-2-device`, `svc-bkp-nfs-2-local`, `svc-bkp-remote-2-local`, `svc-bkp-secrets-2-local`, `svc-bkp-volume-2-local`, `svc-dns-unbound`, `svc-net-firewall`, `svc-net-tor`, `svc-net-wireguard-core`, `svc-net-wireguard-firewalled`, `svc-net-wireguard-plain`, `svc-opt-keyboard-color`, `svc-opt-ssd-hdd`, `svc-opt-swapfile`, `svc-prx-openresty`, `svc-registry-cache`, `svc-registry-docker`, `svc-runner`, `svc-storage-nfs-client`, `svc-storage-nfs-server`, `svc-swarm-manager`, `svc-swarm-node`, `svc-virt-kata`, `update`. These roles can recover secrets, alter routing or host state, run code, change deployment state, or reach storage/control-plane sockets; they require a separate audited operations gateway and human approval rather than general-purpose application MCP. **`shared_engine_isolation` (4):** `svc-db-memcached`, `svc-db-openldap`, `svc-db-postgres`, `svc-db-rabbitmq`. diff --git a/group_vars/all/05_paths.yml b/group_vars/all/05_paths.yml index 270384ce19..d0a2ab7734 100644 --- a/group_vars/all/05_paths.yml +++ b/group_vars/all/05_paths.yml @@ -5,6 +5,7 @@ DIR_SCRIPTS_ROOT: "/opt/scripts/" DIR_SCRIPTS_SYSTEMCTL: "{{ [ DIR_SCRIPTS_ROOT, 'systemctl' ] | path_join }}" DIR_COMPOSITIONS: "/opt/compose/" DIR_SYSTEM_SERVICE: "/etc/systemd/system" +DIR_SYSTEM_SERVICE_USER: "$HOME/.config/systemd/user" DIR_COMPOSE_PULL_LOCK: "/run/ansible/compose-pull/" DIR_BIN: "/usr/bin" diff --git a/group_vars/all/08_networks.yml b/group_vars/all/08_networks.yml index fa1920561c..f757e83a3e 100644 --- a/group_vars/all/08_networks.yml +++ b/group_vars/all/08_networks.yml @@ -5,16 +5,23 @@ NETWORK_PUBLIC_DNS_RESOLVERS: - 8.8.8.8 # nocheck: hardcoded-dns-resolver - 1.0.0.1 # nocheck: hardcoded-dns-resolver NETWORK_DOCKER_EMBEDDED_DNS_RESOLVER: "127.0.0.11" # nocheck: hardcoded-dns-resolver +NETWORK_DNS_PORT: 53 # Well-known resolver port a host DNS listener binds NETWORK_DOCKER_DNS_NGINX_DIRECTIVE: "resolver {{ NETWORK_DOCKER_EMBEDDED_DNS_RESOLVER }} valid=30s ipv6={{ NETWORK_IPV6_ENABLED | on_off }};" -NETWORK_TRUSTED_PROXY_CIDRS: - - 127.0.0.1 - - "::1" - - 10.0.0.0/8 - - 172.16.0.0/12 - - 192.168.0.0/16 +NETWORK_LOOPBACK_CIDR: "127.0.0.0/8" +NETWORK_PRIVATE_CIDRS: + class_a: "10.0.0.0/8" + class_b: "172.16.0.0/12" + class_c: "192.168.0.0/16" +NETWORK_INTERNAL_CIDRS: "{{ [NETWORK_LOOPBACK_CIDR] + (NETWORK_PRIVATE_CIDRS.values() | list) }}" +NETWORK_ROLE_SUBNET_POOL: "{{ NETWORK_PRIVATE_CIDRS.class_c }}" +NETWORK_ROLE_SUBNET_EXCEPTION_ROLES: + - web-app-bigbluebutton +NETWORK_SWARM_GWBRIDGE_POOL: "{{ NETWORK_PRIVATE_CIDRS.class_b }}" +NETWORK_TRUSTED_PROXY_CIDRS: "{{ ['127.0.0.1', '::1'] + (NETWORK_PRIVATE_CIDRS.values() | list) }}" NETWORK_DOCKER_ADDRESS_POOLS: - base: "10.208.0.0/12" size: 24 +NETWORK_TOR_VIRTUAL_CIDR: "10.240.0.0/12" # Range tor answers for .onion instead of routing; must not overlap the pool above NETWORK_CONTAINER_BRIDGE_RESOLVER: false NETWORK_NESTED_DOCKER_BIP: "192.168.67.1/24" NETWORK_NESTED_DOCKER_ADDRESS_POOLS: @@ -82,6 +89,12 @@ PORT_BANDS: # nocheck: unused-var # consumed by the port-allocation plugin outs start: 9053 end: 9053 public: + http: + start: 80 + end: 80 + https: + start: 443 + end: 443 ssh: start: 2201 end: 2299 diff --git a/group_vars/all/16_ai.yml b/group_vars/all/16_ai.yml index d68976e3c0..5a5c5f2870 100644 --- a/group_vars/all/16_ai.yml +++ b/group_vars/all/16_ai.yml @@ -13,28 +13,33 @@ LITELLM_OPENAI_BASE_LOCAL_URL: "{{ LITELLM_BASE_LOCAL_URL }}/v1" LITELLM_BACKEND_ROLES: "{{ lookup('deployment').inventory }}" +LITELLM_OLLAMA_BACKEND: >- + {{ (lookup('config', 'svc-ai-litellm', 'services.ollama.enabled') | bool) + and (lookup('config', 'svc-ai-litellm', 'services.ollama.shared') | bool) }} + +LITELLM_LMSTUDIO_BACKEND: >- + {{ (lookup('config', 'svc-ai-litellm', 'services.lmstudio.enabled') | bool) + and (lookup('config', 'svc-ai-litellm', 'services.lmstudio.shared') | bool) }} + AI_LOCAL_MODELS: - - alias: llama3:latest - name: llama-3.2-3b-instruct - - alias: mistral:latest - name: mistral-7b-instruct-v0.3 - - alias: nomic-embed-text:latest - name: nomic-embed-text-v1.5 + - alias: qwen2.5:0.5b + name: qwen2.5-0.5b-instruct + source: https://huggingface.co/lmstudio-community/Qwen2.5-0.5B-Instruct-GGUF AI_REMOTE_ALIASES: >- - {{ ['openai/gpt-4o-mini'] * ((API.openai.api_key | string | length > 0) | int) - + ['anthropic/claude-sonnet-4-5'] * ((API.anthropic.api_key | string | length > 0) | int) - + ['openrouter/auto'] * ((API.openrouter.api_key | string | length > 0) | int) }} + {{ ['openai/gpt-4o-mini'] * (lookup('api_enabled', 'openai') | int) + + ['anthropic/claude-sonnet-4-5'] * (lookup('api_enabled', 'anthropic') | int) + + ['openrouter/auto'] * (lookup('api_enabled', 'openrouter') | int) }} LITELLM_CHAT_MODEL: "{{ (lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') | map(attribute='alias') | reject('search', 'embed') | list | first | default('')) if 'svc-ai-ollama' in LITELLM_BACKEND_ROLES else ((lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models') | map(attribute='alias') | reject('search', 'embed') | list | first | default('')) if 'svc-ai-lmstudio' in LITELLM_BACKEND_ROLES else (AI_REMOTE_ALIASES | first | default(''))) }}" LITELLM_CHAT_MODEL_SERVED: >- - {{ (('svc-ai-ollama' in LITELLM_BACKEND_ROLES) + {{ ((LITELLM_OLLAMA_BACKEND | bool) and ((lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') | map(attribute='alias') | reject('search', 'embed') | list) | length > 0)) - or (('svc-ai-lmstudio' in LITELLM_BACKEND_ROLES) + or ((LITELLM_LMSTUDIO_BACKEND | bool) and ((lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models') | map(attribute='alias') | reject('search', 'embed') | list) | length > 0)) or ((AI_REMOTE_ALIASES | length) > 0) }} diff --git a/group_vars/all/18_api.yml b/group_vars/all/18_api.yml index 28ecf9d1fb..5dd167098a 100644 --- a/group_vars/all/18_api.yml +++ b/group_vars/all/18_api.yml @@ -1,10 +1,9 @@ --- # Central SPOT for proprietary / external (non-self-hosted) API credentials. -# Each entry is empty by default; an integration that depends on a provider is -# only activated when its credentials are present (see the addons that gate -# their `enabled` on `API.`). Override per provider in the (vault- -# encrypted) inventory; access ONLY via lookup('api', '.'). -# nocheck: unused-var: consumed by the api lookup plugin (plugins/lookup/api.py) via lookup('api', ...), not referenced directly in YAML/Jinja. +# Each entry is empty by default. Override per provider in the (vault- +# encrypted) inventory; read ONLY via lookup('api', '.') and +# gate ONLY on lookup('api_enabled', '') (see utils/api.py). +# nocheck: unused-var: consumed by the api lookup plugins (plugins/lookup/api.py, plugins/lookup/api_enabled.py) via lookup('api', ...), not referenced directly in YAML/Jinja. API: anthropic: api_key: "" diff --git a/group_vars/all/19_tor.yml b/group_vars/all/19_tor.yml deleted file mode 100644 index 691a0e1a99..0000000000 --- a/group_vars/all/19_tor.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -TOR_ONION_SSH_ENABLED: true - -TOR_EGRESS_ROUTER_ENABLED: true -TOR_EGRESS_VIRTUAL_NET_IPV4: "10.240.0.0/12" - -TOR_ONION_EXTRA_PORTS: "{{ ([{'onion_port': 22, 'target': '127.0.0.1:22'}] if (TOR_ONION_SSH_ENABLED | bool) else []) + lookup('tor_ports') }}" diff --git a/group_vars/all/README.md b/group_vars/all/README.md index 2def1b4732..233e6be0f0 100644 --- a/group_vars/all/README.md +++ b/group_vars/all/README.md @@ -6,7 +6,10 @@ This directory contains the [Ansible](https://docs.ansible.com/ansible/latest/pl Variables in this directory MUST apply across all hosts and all roles. Typical examples are the software identity, deployment toggles, domain defaults, the central network and port registries, OIDC and LDAP endpoints, design tokens, and resource defaults. -Role-specific configuration MUST NOT live here. Such values belong in the corresponding role under `roles//config/main.yml` or `roles//vars/main.yml`. +Role-specific configuration MUST NOT live here, even when only one role reads it today. Such values belong in the corresponding role: + +- `roles//meta/services.yml` for anything an operator may override, because the inventory's `applications.` block is deep-merged on top of it and `lookup('config', application_id, '')` reads the result. +- `roles//vars/main.yml` for values derived from that configuration, and for those the role fixes. Role vars outrank inventory variables, so a value placed here cannot be overridden per host. ## File naming and load order ๐Ÿ”ข diff --git a/inventories/development/default.yml b/inventories/development/default.yml index eb5f74116e..e498260df5 100644 --- a/inventories/development/default.yml +++ b/inventories/development/default.yml @@ -35,10 +35,6 @@ design: colors: base: "#FFA500" -AI_LOCAL_MODELS_CI: - - alias: qwen2.5:0.5b - name: qwen2.5-0.5b-instruct - API: openai: api_key: "{{ lookup('env', 'INFINITO_API_OPENAI_KEY') }}" diff --git a/plugins/lookup/api.py b/plugins/lookup/api.py index 9740e8d16b..a10e26c2f2 100644 --- a/plugins/lookup/api.py +++ b/plugins/lookup/api.py @@ -5,13 +5,16 @@ from typing import Any from ansible.errors import AnsibleError from ansible.plugins.lookup import LookupBase +from utils.api import resolve_api + class LookupModule(LookupBase): """ lookup('api', '.') e.g. lookup('api', 'github.client_id') Single point of truth for proprietary / external API credentials. Resolves a - dotted path against the global ``API`` mapping (group_vars/all/18_api.yml). + dotted path against the effective ``API`` mapping: the declaration in + group_vars/all/18_api.yml with the calling scope's override merged over it. Routing every access through this plugin keeps one place to change how proprietary API credentials are sourced (vault, env, secret manager, a different provider abstraction) without touching the call sites. @@ -31,11 +34,10 @@ class LookupModule(LookupBase): if not path: raise AnsibleError("lookup('api'): empty key path.") - api = variables.get("API", {}) - if templar is not None: - api = templar.template(api) - if not isinstance(api, dict): - raise AnsibleError("lookup('api'): global 'API' is not a mapping.") + try: + api = resolve_api(variables, templar=templar) + except TypeError as error: + raise AnsibleError(f"lookup('api'): {error}") from error node: Any = api for part in path.split("."): diff --git a/plugins/lookup/api_enabled.py b/plugins/lookup/api_enabled.py new file mode 100644 index 0000000000..c5f189070b --- /dev/null +++ b/plugins/lookup/api_enabled.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Any + +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase + +from utils.api import provider_enabled, resolve_api + + +class LookupModule(LookupBase): + """ + lookup('api_enabled', '') e.g. lookup('api_enabled', 'github') + + True when every credential the provider declares in group_vars/all/18_api.yml + carries a value in the effective ``API`` mapping. Integrations that depend on + a proprietary provider gate on this instead of spelling the emptiness test + out per call site, so an added credential key extends the gate everywhere and + the result is a real boolean rather than a string a filter has to coerce. + """ + + def run(self, terms, variables: dict[str, Any] | None = None, **kwargs): + if not terms or len(terms) != 1: + raise AnsibleError( + "lookup('api_enabled') takes exactly one term: '' " + "(e.g. 'github')." + ) + + templar = getattr(self, "_templar", None) + variables = variables or getattr(templar, "available_variables", {}) or {} + + provider = str(terms[0]).strip() + if not provider: + raise AnsibleError("lookup('api_enabled'): empty provider name.") + + try: + api = resolve_api(variables, templar=templar) + return [provider_enabled(api, provider)] + except (KeyError, TypeError) as error: + raise AnsibleError(f"lookup('api_enabled'): {error}") from error diff --git a/plugins/lookup/container_ports.py b/plugins/lookup/container_ports.py index 87ce236006..f6cb5504a2 100644 --- a/plugins/lookup/container_ports.py +++ b/plugins/lookup/container_ports.py @@ -1,33 +1,67 @@ -"""Lookup `container_ports`: build a compose ``ports:`` publish block from -``[service_name, protocol]`` or ``[service_name, protocol, host_ip]`` terms. +"""Lookup `container_ports`: build a compose ``ports:`` block from declared ports. -Single SPOT replacing the repeated, verbose port lookups. Each term is a -two- or three-element list; pass one or more. The optional third element is -the host IP to bind that port to; omit it to publish on every interface: +Single SPOT for every published port in the repo. A term is either the short +list form or the dict form; pass one or more. + +List form, unchanged:: {{ lookup('container_ports', ['gitea', 'http', DOCKER_BIND_HOST], ['gitea', 'ssh']) | indent(4) }} -produces +produces:: ports: - - "::" + - "::<...internal.http>" - ":" -Per term the published (host) port is ``services..ports.local.`` -when declared, else ``services..ports.public.``; the container -port is always ``services..ports.internal.``. A third element -prefixes ``:``; without it the port binds to all interfaces. This -makes every mapping expressible, including binds to a specific public IP -(e.g. ``['mailu', 'smtp', MAILU_IP4_PUBLIC]``). +The published (host) port is ``ports.local.`` when declared, else +``ports.public.``; the container port is ``ports.internal.``. -``application_id`` is read from the play variables unless given as ``application_id=``. +Dict form, for the shapes the list form cannot express:: + + {{ lookup('container_ports', + {'service': 'coturn', 'protocol': 'stun_turn', + 'transport': ['udp', 'tcp'], 'container': 'same'}, + {'service': 'coturn', 'protocol': 'relay', 'transport': 'udp', + 'container': 'same'}, + {'service': 'openresty', 'protocol': 'http', 'mode': 'host'}, + {'service': 'funkwhale', 'protocol': 'api', + 'publish': 'ephemeral'}) | indent(4) }} + +Keys: + +``service``, ``protocol`` + Required, as in the list form. +``ip`` + Host address to bind to; omit to publish on every interface. +``transport`` + ``tcp``, ``udp``, or a list of both. One list item per transport. Omit for + compose's own default, which publishes no suffix. +``container`` + ``internal`` (default) reads ``ports.internal.``; ``same`` reuses the + published port, for services that cannot remap (STUN/TURN, media relays). +``mode`` + ``ingress`` (default) emits the short ``"host:published:container"`` string; + ``host`` emits the long block with ``mode: host``, which is what an edge + role needs in swarm so the client address survives the hop. +``publish`` + ``ephemeral`` publishes the container port on a host port docker picks, for + a service addressed only through the compose network. + +A declared value that is a ``{start, end}`` mapping renders as ``start-end`` on +both sides, so a relay range needs no special term. + +``application_id`` is read from the play variables unless given as +``application_id=``. ``key='expose'`` names the block instead of ``ports``, for +Discourse's launcher file, whose ``expose:`` list is what becomes ``docker run +-p`` there. """ from __future__ import annotations import contextlib +from collections.abc import Mapping from typing import Any from ansible.errors import AnsibleError @@ -37,11 +71,115 @@ from ansible.template import trust_as_template from utils.roles.applications.config import get +_TERM_KEYS = frozenset( + {"service", "protocol", "ip", "transport", "container", "mode", "publish"} +) +_TRANSPORTS = ("tcp", "udp") +_CONTAINER_SOURCES = ("internal", "same") +_MODES = ("ingress", "host") + def _as_str(value: Any) -> str: return "" if value is None else str(value).strip() +def _rendered(value: Any) -> str: + """A declared port value as compose writes it, ranges included.""" + if isinstance(value, Mapping): + start, end = value.get("start"), value.get("end") + if start is None or end is None: + return "" + return f"{_as_str(start)}-{_as_str(end)}" + return _as_str(value) + + +def _transports_of(term: Mapping) -> list[str]: + declared = term.get("transport") + if declared is None: + return [""] + wanted = [declared] if isinstance(declared, str) else list(declared) + for transport in wanted: + if transport not in _TRANSPORTS: + raise AnsibleError( + f"container_ports: transport must be one of {_TRANSPORTS}, " + f"got {transport!r}" + ) + return wanted + + +def _normalised(term: Any) -> dict[str, Any]: + """One term as a dict, accepting the legacy list form.""" + if isinstance(term, (list, tuple)): + if len(term) not in (2, 3): + raise AnsibleError( + "container_ports: a list term must be [service_name, protocol] " + f"or [service_name, protocol, host_ip], got {term!r}" + ) + term = { + "service": term[0], + "protocol": term[1], + **({"ip": term[2]} if len(term) == 3 else {}), + } + if not isinstance(term, Mapping): + raise AnsibleError( + f"container_ports: each term must be a list or a mapping, got {term!r}" + ) + unknown = set(term) - _TERM_KEYS + if unknown: + raise AnsibleError( + f"container_ports: unknown term key(s) {sorted(unknown)}; " + f"valid keys are {sorted(_TERM_KEYS)}" + ) + normalised = { + "service": _as_str(term.get("service")), + "protocol": _as_str(term.get("protocol")), + "ip": _as_str(term.get("ip")), + "transport": term.get("transport"), + "container": _as_str(term.get("container")) or "internal", + "mode": _as_str(term.get("mode")) or "ingress", + "publish": _as_str(term.get("publish")), + } + if not normalised["service"] or not normalised["protocol"]: + raise AnsibleError("container_ports: service and protocol must be non-empty") + if normalised["container"] not in _CONTAINER_SOURCES: + raise AnsibleError( + f"container_ports: container must be one of {_CONTAINER_SOURCES}, " + f"got {normalised['container']!r}" + ) + if normalised["mode"] not in _MODES: + raise AnsibleError( + f"container_ports: mode must be one of {_MODES}, got {normalised['mode']!r}" + ) + if normalised["publish"] and normalised["publish"] != "ephemeral": + raise AnsibleError( + "container_ports: publish accepts only 'ephemeral', " + f"got {normalised['publish']!r}" + ) + if normalised["mode"] == "host" and normalised["ip"]: + raise AnsibleError( + "container_ports: mode 'host' binds the node's own interface and " + "takes no ip" + ) + return normalised + + +def _short_line(term: Mapping, published: str, container: str, transport: str) -> str: + suffix = f"/{transport}" if transport else "" + if term["publish"] == "ephemeral": + return f' - "{container}{suffix}"' + head = f"{term['ip']}:" if term["ip"] else "" + return f' - "{head}{published}:{container}{suffix}"' + + +def _host_lines(published: str, container: str, transport: str) -> list[str]: + return [ + f" - target: {container}", + f" published: {published}", + f" protocol: {transport or 'tcp'}", + " mode: host", + ] + + class LookupModule(LookupBase): def run( self, @@ -58,22 +196,9 @@ class LookupModule(LookupBase): if not terms: raise AnsibleError( "lookup('container_ports', [service_name, protocol[, host_ip]], ...) " - "expects one or more [service_name, protocol] terms" + "expects one or more terms" ) - triples: list[tuple[str, str, str]] = [] - for term in terms: - if not (isinstance(term, (list, tuple)) and len(term) in (2, 3)): - raise AnsibleError( - "container_ports: each term must be a [service_name, protocol] " - f"or [service_name, protocol, host_ip] list, got {term!r}" - ) - service, protocol = _as_str(term[0]), _as_str(term[1]) - host = _as_str(term[2]) if len(term) == 3 else "" - if not service or not protocol: - raise AnsibleError( - "container_ports: service_name and protocol must be non-empty" - ) - triples.append((service, protocol, host)) + parsed = [_normalised(term) for term in terms] templar = getattr(self, "_templar", None) variables = variables or getattr(self._templar, "available_variables", {}) or {} @@ -96,7 +221,7 @@ class LookupModule(LookupBase): ).run([], variables=variables)[0] def _port(service: str, scope: str, protocol: str, *, required: bool) -> str: - value = _as_str( + value = _rendered( get( applications=applications, application_id=application_id, @@ -112,14 +237,24 @@ class LookupModule(LookupBase): ) return value - lines = ["ports:"] - for service, protocol, host in triples: - internal = _port(service, "internal", protocol, required=True) - published = _port(service, "local", protocol, required=False) or _port( - service, "public", protocol, required=True + block_key = _as_str(kwargs.get("key")) or "ports" + if block_key not in ("ports", "expose"): + raise AnsibleError( + f"container_ports: key must be 'ports' or 'expose', got {block_key!r}" ) - if host: - lines.append(f' - "{host}:{published}:{internal}"') + lines = [f"{block_key}:"] + for term in parsed: + service, protocol = term["service"], term["protocol"] + published = _port(service, "local", protocol, required=False) or _port( + service, "public", protocol, required=term["publish"] != "ephemeral" + ) + if term["container"] == "same": + container = published else: - lines.append(f' - "{published}:{internal}"') + container = _port(service, "internal", protocol, required=True) + for transport in _transports_of(term): + if term["mode"] == "host": + lines.extend(_host_lines(published, container, transport)) + else: + lines.append(_short_line(term, published, container, transport)) return ["\n".join(lines)] diff --git a/plugins/lookup/role_subnets.py b/plugins/lookup/role_subnets.py new file mode 100644 index 0000000000..b3a06976a1 --- /dev/null +++ b/plugins/lookup/role_subnets.py @@ -0,0 +1,64 @@ +"""Lookup `role_subnets`: the local subnets other roles declare. + +Single SPOT for "which address block does role X own". The block is declared +once, in that role's ``meta/networks.yml``; anything that has to reason about +the deployment's own address space reads it from here instead of repeating the +literal, which is how a CIDR ends up stated twice and then drifts. + +Usage: + + {{ lookup('role_subnets', 'web-app-bigbluebutton') }} # ['10.7.7.0/24'] + {{ lookup('role_subnets', SOME_ROLE_LIST) }} # one entry per role + +Terms may be role ids or lists of them, mixed. A role that declares no local +subnet raises, because a silently dropped entry would narrow whatever firewall +or trust list consumes the result. +""" + +from __future__ import annotations + +from typing import Any + +from ansible.errors import AnsibleError +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase + +_SUBNET_PATH = "networks.local.subnet" + + +def _flattened(terms: list[Any]) -> list[str]: + roles: list[str] = [] + for term in terms: + if isinstance(term, (list, tuple)): + roles.extend(str(entry).strip() for entry in term) + else: + roles.append(str(term).strip()) + return [role for role in roles if role] + + +class LookupModule(LookupBase): + def run( + self, + terms: list[Any] | None, + variables: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Any]: + roles = _flattened(terms or []) + if not roles: + raise AnsibleError( + "lookup('role_subnets', ) expects " + "at least one role id" + ) + config = lookup_loader.get( + "config", + loader=getattr(self, "_loader", None), + templar=getattr(self, "_templar", None), + ) + subnets: list[str] = [] + for role in roles: + value = config.run([role, _SUBNET_PATH], variables=variables)[0] + text = "" if value is None else str(value).strip() + if not text: + raise AnsibleError(f"role_subnets: '{role}' declares no {_SUBNET_PATH}") + subnets.append(text) + return [subnets] diff --git a/plugins/lookup/sso_oidc_plugin.py b/plugins/lookup/sso_oidc_plugin.py index 3e895dffc2..50c293009c 100644 --- a/plugins/lookup/sso_oidc_plugin.py +++ b/plugins/lookup/sso_oidc_plugin.py @@ -6,9 +6,6 @@ from ansible.errors import AnsibleError from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase -from utils.roles.applications.config import get -from utils.roles.applications.services.sso import get_sso_config - _APPLICATION_ID = "web-app-nextcloud" @@ -40,33 +37,18 @@ class LookupModule(LookupBase): templar = getattr(self, "_templar", None) variables = variables or getattr(self._templar, "available_variables", {}) or {} - applications = lookup_loader.get( - "applications", loader=self._loader, templar=templar - ).run([], variables=variables)[0] + config = lookup_loader.get("config", loader=self._loader, templar=templar) - if not get_sso_config(applications, _APPLICATION_ID)["is_enabled"]: + def _setting(path: str, default: Any) -> Any: + return config.run([_APPLICATION_ID, path, default], variables=variables)[0] + + if not bool(_setting("services.sso.enabled", False)): return [""] - explicit = get( - applications=applications, - application_id=_APPLICATION_ID, - config_path="services.sso.oidc.plugin", - strict=False, - default=None, - skip_missing_app=True, - ) + explicit = _setting("services.sso.oidc.plugin", None) if isinstance(explicit, str) and explicit.strip(): return [explicit.strip()] - ldap_enabled = bool( - get( - applications=applications, - application_id=_APPLICATION_ID, - config_path="services.ldap.enabled", - strict=False, - default=False, - skip_missing_app=True, - ) - ) + ldap_enabled = bool(_setting("services.ldap.enabled", False)) return ["oidc_login" if ldap_enabled else "sociallogin"] diff --git a/plugins/lookup/tor_ports.py b/plugins/lookup/tor_ports.py index ec24f33741..fea32575f8 100644 --- a/plugins/lookup/tor_ports.py +++ b/plugins/lookup/tor_ports.py @@ -1,19 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from pathlib import Path +from typing import Any from ansible.errors import AnsibleError from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase -from utils.cache.base import _resolve_roles_dir -from utils.cache.yaml import load_yaml_any -from utils.roles.mapping import ROLE_FILE_META_SERVICES +UDP_ONLY_CATEGORIES = frozenset({"relay", "media", "stun_turn", "stun_turn_tls"}) +"""Categories a hidden service cannot carry. -_UDP_ONLY_CATEGORIES = frozenset({"relay", "media"}) +``relay`` and ``media`` are UDP. ``stun_turn`` and its TLS variant do speak TCP, +but the allocation they hand out is a UDP relay address the onion client can +never receive on, so forwarding the signalling port buys nothing. +""" def _as_bool(value: object) -> bool: @@ -24,6 +23,10 @@ def _as_bool(value: object) -> bool: return bool(value) +def _mapping(value: object) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + def _collect_local_tcp_ports(port_categories: Any, into: set[int]) -> None: """Add single-int TCP ports of the loopback-published ``ports.local`` group only. An exposed service publishes just its local plaintext port to @@ -36,37 +39,57 @@ def _collect_local_tcp_ports(port_categories: Any, into: set[int]) -> None: if not isinstance(local, dict): return for category, value in local.items(): - if category in _UDP_ONLY_CATEGORIES: + if category in UDP_ONLY_CATEGORIES: continue if isinstance(value, int): into.add(value) -def collect_public_ports( +def collect_onion_ports( + applications: dict[str, Any], deployed_roles: list[str], - roles_dir: Path, ) -> list[int]: - """Single-int TCP ``ports.public`` entries of the deployed roles, sorted.""" + """Single-int TCP ports a deployed role asks for by name under ``ports.onion``. + + ``ports.onion`` is a category-keyed map of booleans alongside ``internal`` / + ``local`` / ``public``: it names which of a service's already-declared ports + should answer on the node onion, and nothing else. The port number comes + from ``ports.local`` when that category is declared there and from + ``ports.public`` otherwise, which is the precedence ``container_ports`` + publishes by, so the forward always targets the port the service is actually + reachable on. + + Naming the category is what makes this safe. Sweeping ``ports.public`` + wholesale forwards ports the deployment never publishes -- ldaps 636 is + declared next to ``network.public: false``, and Mailu's implicit-TLS ports + are dropped from the publish list whenever TLS is off, which an onion + deployment always is. + """ ports: set[int] = set() - for role in deployed_roles: - services_yml = roles_dir / role / ROLE_FILE_META_SERVICES - if not services_yml.is_file(): + deployed = set(deployed_roles) + if not isinstance(applications, dict): + return [] + for app_id, cfg in applications.items(): + if app_id not in deployed: continue - data = load_yaml_any(str(services_yml), default_if_missing={}) - if not isinstance(data, dict): + services = (cfg or {}).get("services") if isinstance(cfg, dict) else None + if not isinstance(services, dict): continue - tor = data.get("tor") - if not (isinstance(tor, dict) and _as_bool(tor.get("enabled"))): - continue - for entity in data.values(): + for entity in services.values(): if not isinstance(entity, dict): continue - public = (entity.get("ports") or {}).get("public") - if not isinstance(public, dict): + declared = entity.get("ports") + if not isinstance(declared, dict): continue - for category, value in public.items(): - if category in _UDP_ONLY_CATEGORIES: + wanted = declared.get("onion") + if not isinstance(wanted, dict): + continue + local = _mapping(declared.get("local")) + public = _mapping(declared.get("public")) + for category, flag in wanted.items(): + if category in UDP_ONLY_CATEGORIES or not _as_bool(flag): continue + value = local.get(category, public.get(category)) if isinstance(value, int): ports.add(value) return sorted(ports) @@ -111,16 +134,18 @@ class LookupModule(LookupBase): Returns the HiddenServicePort mappings for the roles in the current deploy (``group_names``): a list of ``{'onion_port': , 'target': - '127.0.0.1:'}`` dicts, sorted by port. Two sources are unioned: + '127.0.0.1:'}`` dicts, sorted by port. Two opt-ins are unioned: - * every ``ports.public`` TCP port of tor-enabled roles, and - * every port of a service that opts in with ``exposed: true`` (resolved - against the variant-merged applications view, so per-variant). + * every port a service names under ``ports.onion`` in meta/services.yml, + and + * every port of a service that opts in with ``exposed: true``. - Public/exposed ports bind the host interface, so the loopback target reaches - them from svc-net-tor's host-network container. Consumed by - ``TOR_ONION_EXTRA_PORTS`` (group_vars/all/19_tor.yml) and rendered as - ``HiddenServicePort`` lines in svc-net-tor's torrc. + Both are read from the variant-merged applications view, so a variant that + drops the opt-in drops the forward with it. The published port binds the + host interface, so the loopback target reaches it from svc-net-tor's + host-network container. Composed with the flagged forwards by + ``lookup('tor_extra_ports')`` and rendered as ``HiddenServicePort`` lines in + svc-net-tor's torrc. """ def run( @@ -138,17 +163,15 @@ class LookupModule(LookupBase): group_names = [] deployed = [str(g) for g in group_names] - roles_dir = _resolve_roles_dir(roles_dir=kwargs.get("roles_dir")) - ports = set(collect_public_ports(deployed, roles_dir)) - try: applications = lookup_loader.get( "applications", loader=getattr(self, "_loader", None), templar=getattr(self, "_templar", None), ).run([], variables=variables, roles_dir=kwargs.get("roles_dir"))[0] - except Exception: # noqa: BLE001 no merged view -> exposed ports simply unavailable + except Exception: # noqa: BLE001 no merged view -> no ports to forward applications = {} + ports = set(collect_onion_ports(applications, deployed)) ports.update(collect_exposed_ports(applications, deployed)) return [ diff --git a/roles/desk-ssh/tasks/00_core.yml b/roles/desk-ssh/tasks/00_core.yml index 8996de8785..a6c37710c1 100644 --- a/roles/desk-ssh/tasks/00_core.yml +++ b/roles/desk-ssh/tasks/00_core.yml @@ -21,26 +21,49 @@ - name: โœ… Ensure systemd user directory exists file: - path: "$HOME/.config/systemd/user" + path: "{{ DIR_SYSTEM_SERVICE_USER }}" state: directory mode: "0700" become: false -- name: ๐Ÿš€ Deploy ssh-agent systemd unit file - template: # nocheck: stack-host-template (per-host/user workstation systemd unit) - src: ssh-agent.service.j2 - dest: "$HOME/.config/systemd/user/ssh-agent.service" - mode: "0644" +- name: ๐Ÿ” Look for the unversioned ssh-agent unit + stat: + path: "{{ desk_ssh_legacy_unit }}" + register: desk_ssh_legacy_unit_state become: false -- name: ๐ŸŸข Enable and start ssh-agent service - systemd: - name: ssh-agent.service - scope: user - enabled: true - state: started - daemon_reload: true - become: false +- name: ๐Ÿงน Retire the unversioned ssh-agent unit + block: + - name: ๐Ÿ›‘ Stop and disable the unversioned ssh-agent unit + systemd: + name: "{{ desk_ssh_legacy_unit | basename }}" + scope: user + state: stopped + enabled: false + become: false + + - name: ๐Ÿ—‘๏ธ Delete the unversioned ssh-agent unit + file: + path: "{{ desk_ssh_legacy_unit }}" + state: absent + become: false + when: desk_ssh_legacy_unit_state.stat.exists + +- name: ๐Ÿš€ Deploy ssh-agent systemd unit file + include_role: + name: sys-service + vars: + system_service_scope: user + system_service_copy_files: false + system_service_state: started + system_service_tpl_on_failure: "" + system_service_tpl_type: simple + system_service_tpl_runtime: "" + system_service_tpl_before: default.target + system_service_tpl_wanted_by: default.target + system_service_tpl_environment: + - "SSH_AUTH_SOCK=%t/ssh-agent.socket" + system_service_tpl_exec_start: "/usr/bin/ssh-agent -D -a $SSH_AUTH_SOCK" - name: โœ… Ensure ~/.profile exists with common environment lineinfile: diff --git a/roles/desk-ssh/templates/ssh-agent.service.j2 b/roles/desk-ssh/templates/ssh-agent.service.j2 deleted file mode 100644 index 3125724f18..0000000000 --- a/roles/desk-ssh/templates/ssh-agent.service.j2 +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=User SSH Agent -Before=default.target - -[Service] -Type=simple -Environment=SSH_AUTH_SOCK=%t/ssh-agent.socket -ExecStart=/usr/bin/ssh-agent -D -a $SSH_AUTH_SOCK - -[Install] -WantedBy=default.target \ No newline at end of file diff --git a/roles/desk-ssh/vars/main.yml b/roles/desk-ssh/vars/main.yml index 7d866e2329..d8bb7879e0 100644 --- a/roles/desk-ssh/vars/main.yml +++ b/roles/desk-ssh/vars/main.yml @@ -1,3 +1,5 @@ --- application_id: desk-ssh +system_service_id: "{{ application_id }}" desk_ssh_repository: "{{ lookup('config', application_id, 'services.ssh.repository') }}" +desk_ssh_legacy_unit: "{{ [ DIR_SYSTEM_SERVICE_USER, 'ssh-agent.service' ] | path_join }}" diff --git a/roles/svc-ai-litellm/meta/secrets.yml b/roles/svc-ai-litellm/meta/secrets.yml index 47c983f31c..a2fb9ce606 100644 --- a/roles/svc-ai-litellm/meta/secrets.yml +++ b/roles/svc-ai-litellm/meta/secrets.yml @@ -15,14 +15,14 @@ credentials: OpenAI moved from sk- to sk-proj- without notice, and a schema that pinned the prefix would reject a valid key" type: "string" regex: "\\S{20,}" - default: "{{ API.openai.api_key }}" + default: "{{ lookup('api', 'openai.api_key') }}" anthropic_api_key: description: "Anthropic API key; empty leaves the anthropic/* models unpublished" type: "string" regex: "\\S{20,}" - default: "{{ API.anthropic.api_key }}" + default: "{{ lookup('api', 'anthropic.api_key') }}" openrouter_api_key: description: "OpenRouter API key; empty leaves openrouter/auto unpublished" type: "string" regex: "\\S{20,}" - default: "{{ API.openrouter.api_key }}" + default: "{{ lookup('api', 'openrouter.api_key') }}" diff --git a/roles/svc-ai-litellm/tasks/00_core.yml b/roles/svc-ai-litellm/tasks/00_core.yml index db70913e93..9076c123b0 100644 --- a/roles/svc-ai-litellm/tasks/00_core.yml +++ b/roles/svc-ai-litellm/tasks/00_core.yml @@ -21,6 +21,15 @@ - src: "config.yaml.j2" dest: "{{ LITELLM_CONFIG_PATH_HOST }}" +- name: "๐Ÿงฎ Freeze the routing contract the CLI test asserts against" + ansible.builtin.set_fact: + LITELLM_TEST_CONTRACT: + ollama: "{{ LITELLM_OLLAMA_BACKEND | bool }}" + lmstudio: "{{ LITELLM_LMSTUDIO_BACKEND | bool }}" + served: "{{ LITELLM_CHAT_MODEL_SERVED | bool }}" + delegate_to: "{{ STACK_HOST }}" + run_once: true + - name: "๐Ÿ”ง Setup container network for {{ application_id }}" include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/network/routine.yml') }}" vars: diff --git a/roles/svc-ai-litellm/templates/config.yaml.j2 b/roles/svc-ai-litellm/templates/config.yaml.j2 index 6d7d02a8be..c4bed5dcce 100644 --- a/roles/svc-ai-litellm/templates/config.yaml.j2 +++ b/roles/svc-ai-litellm/templates/config.yaml.j2 @@ -1,5 +1,5 @@ -{% set _ollama_models = lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') if 'svc-ai-ollama' in group_names else [] %} -{% set _lmstudio_models = lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models') if 'svc-ai-lmstudio' in group_names else [] %} +{% set _ollama_models = lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') if LITELLM_OLLAMA_BACKEND | bool else [] %} +{% set _lmstudio_models = lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models') if LITELLM_LMSTUDIO_BACKEND | bool else [] %} {% set _remote_models = LITELLM_REMOTE_BACKENDS | selectattr('key') | list %} {% set has_backend = _ollama_models or _lmstudio_models or _remote_models %} model_list:{{ ' []' if not has_backend else '' }} diff --git a/roles/svc-ai-litellm/templates/test.env.j2 b/roles/svc-ai-litellm/templates/test.env.j2 index 47d7508fe7..a3a33b2cc6 100644 --- a/roles/svc-ai-litellm/templates/test.env.j2 +++ b/roles/svc-ai-litellm/templates/test.env.j2 @@ -1,15 +1,15 @@ -{%- set _ollama_models = (lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') | map(attribute='alias') | list) if 'svc-ai-ollama' in group_names else [] -%} +{%- set _ollama_models = (lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') | map(attribute='alias') | list) if LITELLM_TEST_CONTRACT.ollama | bool else [] -%} {%- set _lmstudio_declared = lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models') | map(attribute='alias') | list -%} {%- set _lmstudio_only = _lmstudio_declared | reject('in', _ollama_models) | list -%} {%- set _remote = LITELLM_REMOTE_BACKENDS | selectattr('key') | map(attribute='alias') | list -%} -{%- set _expected = _ollama_models + (_lmstudio_only if 'svc-ai-lmstudio' in group_names else []) + _remote -%} +{%- set _expected = _ollama_models + (_lmstudio_only if LITELLM_TEST_CONTRACT.lmstudio | bool else []) + _remote -%} LITELLM_CONTAINER={{ CLI_LOCAL_CID }} LITELLM_PORT={{ LITELLM_PORT }} LITELLM_MASTER_KEY_B64={{ LITELLM_MASTER_KEY | b64encode }} LITELLM_CHAT_MODEL={{ LITELLM_CHAT_MODEL }} -LITELLM_CHAT_MODEL_SERVED={{ LITELLM_CHAT_MODEL_SERVED | bool | string | lower }} -LITELLM_OLLAMA_ENABLED={{ ('svc-ai-ollama' in group_names) | string | lower }} -LITELLM_LMSTUDIO_ENABLED={{ ('svc-ai-lmstudio' in group_names) | string | lower }} +LITELLM_CHAT_MODEL_SERVED={{ LITELLM_TEST_CONTRACT.served | bool | string | lower }} +LITELLM_OLLAMA_ENABLED={{ LITELLM_TEST_CONTRACT.ollama | bool | string | lower }} +LITELLM_LMSTUDIO_ENABLED={{ LITELLM_TEST_CONTRACT.lmstudio | bool | string | lower }} LITELLM_LMSTUDIO_ALIASES='{{ _lmstudio_only | to_json }}' LITELLM_REMOTE_ALIASES='{{ _remote | to_json }}' LITELLM_EXPECTED_MODELS='{{ _expected | to_json }}' diff --git a/roles/svc-ai-lmstudio/README.md b/roles/svc-ai-lmstudio/README.md index d313883f0a..bb671103ee 100644 --- a/roles/svc-ai-lmstudio/README.md +++ b/roles/svc-ai-lmstudio/README.md @@ -35,7 +35,8 @@ Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional ( - **OpenAI-compatible endpoint:** The headless server answers `/v1` requests on port 1234 inside the container network. - **CPU inference:** The role pins the CPU build of the upstream image and passes no GPU device into the container. - **Persistent model store:** A named volume mounted at `/root/.lmstudio` keeps downloaded models and server settings across redeploys. -- **Preloaded models:** Every entry of `services.lmstudio.preload_models` is downloaded on the hosting node with `lms get`, overlapped and reaped like the Ollama pre-pull. Each entry carries the LM Studio `name` to download and the `alias` the gateway publishes it under. +- **Preloaded models:** Every entry of `services.lmstudio.preload_models` is downloaded on the hosting node with `lms get --gguf`, overlapped and reaped like the Ollama pre-pull. Each entry carries the `source` repository to download, the `name` LM Studio indexes that repository under and the gateway addresses it by, and the `alias` the gateway publishes it as. `source` is a full Hugging Face URL, never a search term: a search term resolves to whichever model the catalogue lists first. +- **Loading:** The pre-pull downloads without loading. LM Studio serves with just-in-time loading, so the first `/v1/chat/completions` naming a `name` loads that model; the gateway's warm task issues that first request at deploy time. - **Gateway backend:** The LiteLLM Gateway publishes those aliases, which are the names Ollama serves too, so the same model name routes to whichever local backend a deployment runs. - **Bounded resources:** The container is capped at 4 CPUs, 8 GB of memory and 2048 processes, and the role declares a minimum of 588 MB free storage for the image; downloaded models grow the volume beyond that. - **Backup integration:** Backup Docker Volumes snapshots the model volume when that role is present, without stopping the container. diff --git a/roles/svc-ai-lmstudio/meta/variants.yml b/roles/svc-ai-lmstudio/meta/variants.yml index 5ffa7b228c..5a12caf234 100644 --- a/roles/svc-ai-lmstudio/meta/variants.yml +++ b/roles/svc-ai-lmstudio/meta/variants.yml @@ -6,6 +6,5 @@ container_backup: enabled: true lmstudio: - preload_models: "{{ AI_LOCAL_MODELS_CI }}" mem_reservation: 1g mem_limit: 2g diff --git a/roles/svc-ai-lmstudio/tasks/01_manager_ops.yml b/roles/svc-ai-lmstudio/tasks/01_manager_ops.yml index d667dca141..b588c180f2 100644 --- a/roles/svc-ai-lmstudio/tasks/01_manager_ops.yml +++ b/roles/svc-ai-lmstudio/tasks/01_manager_ops.yml @@ -6,36 +6,15 @@ resolve_output_prefix: LMSTUDIO resolve_compose_cid: "{{ LMSTUDIO_CONTAINER }}" -- name: โฌ‡๏ธ Pre-pull LM Studio models - ansible.builtin.shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node - container exec -i {{ LMSTUDIO_LOCAL_CID | quote }} lms get --yes --gguf {{ model.name | quote }} - register: lmstudio_pull_result - loop: "{{ LMSTUDIO_PRELOAD_MODELS }}" - loop_control: - loop_var: model - label: "{{ model.alias }}" - # rationale: async; model downloads run overlapped, results reaped below before playwright - async: "{{ LMSTUDIO_PRELOAD_ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - retries: "{{ 0 if ASYNC_ENABLED | bool else 5 }}" - delay: 15 - until: > - (lmstudio_pull_result.rc | default(0)) == 0 or - ('already downloaded' in (lmstudio_pull_result.stdout | default(''))) - changed_when: false - failed_when: >- - (lmstudio_pull_result.failed | default(false)) - and 'already downloaded' not in ((lmstudio_pull_result.stdout | default('')) ~ (lmstudio_pull_result.stderr | default(''))) - delegate_to: "{{ LMSTUDIO_HOST_NODE }}" +- name: "โฌ‡๏ธ Preload the LM Studio models" + block: + - name: "โฌ‡๏ธ Preload the LM Studio models (first pass)" + ansible.builtin.include_tasks: utils/preload.yml + rescue: + - name: "๐Ÿ Re-raise the preload failure unchanged in swarm" # nocheck: rescue-recovery a timed-out transfer leaves the daemon reporting 0.00% for every further pull, and only restarting the container it owns clears that, which the generic capture cannot do; swarm re-raises here instead, because under any_errors_fatal a rescued host keeps running while every other host is already marked failed, and compose ends the block by preloading again so an unrecovered failure still re-raises + ansible.builtin.fail: + msg: "{{ ansible_failed_result.msg | default('LM Studio preload failed') }}" + when: DEPLOYMENT_MODE == 'swarm' -- name: "โณ Reap the LM Studio model pre-pulls" - ansible.builtin.include_tasks: - file: utils/async/reap.yml - apply: - delegate_to: "{{ LMSTUDIO_HOST_NODE }}" - vars: - async_reap_jobs: "{{ lmstudio_pull_result.results | default([]) }}" - async_reap_label: "LM Studio model pre-pull" - async_reap_tolerate: - - "already downloaded" - ASYNC_TIME: "{{ LMSTUDIO_PRELOAD_ASYNC_TIME }}" + - name: "โ™ป๏ธ Recover the wedged transfer and preload again" + ansible.builtin.include_tasks: utils/recover.yml diff --git a/roles/svc-ai-lmstudio/tasks/utils/preload.yml b/roles/svc-ai-lmstudio/tasks/utils/preload.yml new file mode 100644 index 0000000000..a50c35ec9e --- /dev/null +++ b/roles/svc-ai-lmstudio/tasks/utils/preload.yml @@ -0,0 +1,34 @@ +--- +- name: โฌ‡๏ธ Pre-pull LM Studio models + ansible.builtin.shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec -i {{ LMSTUDIO_LOCAL_CID | quote }} lms get --yes --gguf {{ model.source | quote }} + register: lmstudio_pull_result + loop: "{{ LMSTUDIO_PRELOAD_MODELS }}" + loop_control: + loop_var: model + label: "{{ model.alias }}" + # rationale: async; the reap below reads every job back before the play continues, so no model is still downloading when playwright starts + async: "{{ LMSTUDIO_PRELOAD_ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" + poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" + retries: "{{ 0 if ASYNC_ENABLED | bool else 1 }}" + delay: 15 + until: > + (lmstudio_pull_result.rc | default(0)) == 0 or + ('already downloaded' in (lmstudio_pull_result.stdout | default(''))) + changed_when: false + failed_when: >- + (lmstudio_pull_result.failed | default(false)) + and 'already downloaded' not in ((lmstudio_pull_result.stdout | default('')) ~ (lmstudio_pull_result.stderr | default(''))) + delegate_to: "{{ LMSTUDIO_HOST_NODE }}" + +- name: "โณ Reap the LM Studio model pre-pulls" + ansible.builtin.include_tasks: + file: utils/async/reap.yml + apply: + delegate_to: "{{ LMSTUDIO_HOST_NODE }}" + vars: + async_reap_jobs: "{{ lmstudio_pull_result.results | default([]) }}" + async_reap_label: "LM Studio model pre-pull" + async_reap_tolerate: + - "already downloaded" + ASYNC_TIME: "{{ LMSTUDIO_PRELOAD_ASYNC_TIME }}" diff --git a/roles/svc-ai-lmstudio/tasks/utils/recover.yml b/roles/svc-ai-lmstudio/tasks/utils/recover.yml new file mode 100644 index 0000000000..cea5918d25 --- /dev/null +++ b/roles/svc-ai-lmstudio/tasks/utils/recover.yml @@ -0,0 +1,28 @@ +--- +- name: "๐Ÿฉบ Dump the LM Studio transfer record" + ansible.builtin.shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec -i {{ LMSTUDIO_LOCAL_CID | quote }} + cat /root/.lmstudio/.internal/single-downloads-info.json + register: lmstudio_transfer_record + changed_when: false + failed_when: false + delegate_to: "{{ LMSTUDIO_HOST_NODE }}" + +- name: "๐Ÿฉบ Report the LM Studio transfer record" + ansible.builtin.debug: + var: lmstudio_transfer_record.stdout + +- name: "โ™ป๏ธ Restart LM Studio to release the wedged transfer" + ansible.builtin.command: container restart {{ LMSTUDIO_LOCAL_CID }} + changed_when: true + delegate_to: "{{ LMSTUDIO_HOST_NODE }}" + +- name: "๐Ÿ” Re-resolve the {{ application_id }} hosting node + container id" + ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}" + vars: + resolve_service_key: "{{ entity_name }}" + resolve_output_prefix: LMSTUDIO + resolve_compose_cid: "{{ LMSTUDIO_CONTAINER }}" + +- name: "โฌ‡๏ธ Preload the LM Studio models (after the restart)" + ansible.builtin.include_tasks: utils/preload.yml diff --git a/roles/svc-ai-ollama/meta/variants.yml b/roles/svc-ai-ollama/meta/variants.yml index 4e89b5aa75..6b9ad2d919 100644 --- a/roles/svc-ai-ollama/meta/variants.yml +++ b/roles/svc-ai-ollama/meta/variants.yml @@ -11,6 +11,5 @@ container_backup: enabled: true ollama: - preload_models: "{{ AI_LOCAL_MODELS_CI }}" mem_reservation: 1g mem_limit: 2g diff --git a/roles/svc-bkp-remote-2-local/templates/script.sh.j2 b/roles/svc-bkp-remote-2-local/templates/script.sh.j2 index 0000b7c862..4cdffc1dc8 100644 --- a/roles/svc-bkp-remote-2-local/templates/script.sh.j2 +++ b/roles/svc-bkp-remote-2-local/templates/script.sh.j2 @@ -1,4 +1,5 @@ #!/bin/bash +{# nocheck: shell-template grandfathered, values only: the provider list and two paths #} # Pulls the remote backups from multiple hosts hosts="{{ DOCKER_BACKUP_REMOTE_2_LOCAL_BACKUP_PROVIDERS | join(' ') }}" diff --git a/roles/svc-db-elasticsearch/meta/services.yml b/roles/svc-db-elasticsearch/meta/services.yml index 64c6d96b81..6f13b566e6 100644 --- a/roles/svc-db-elasticsearch/meta/services.yml +++ b/roles/svc-db-elasticsearch/meta/services.yml @@ -25,6 +25,9 @@ elasticsearch: elasticsearch: 9200 internal: elasticsearch: 9200 + onion: + # nocheck: onion-flag the index is queried by the search backends of the apps sharing this stack, never by a client off the node + elasticsearch: false lifecycle: beta # nocheck: variants-coverage โ€” sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in tor: diff --git a/roles/svc-db-memcached/meta/services.yml b/roles/svc-db-memcached/meta/services.yml index d47db749a6..1cd0395f56 100644 --- a/roles/svc-db-memcached/meta/services.yml +++ b/roles/svc-db-memcached/meta/services.yml @@ -28,6 +28,9 @@ memcached: memcached: 11211 internal: memcached: 11211 + onion: + # nocheck: onion-flag the session cache speaks an unauthenticated text protocol and is read only by app containers on the role network + memcached: false lifecycle: beta # nocheck: variants-coverage โ€” sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in tor: diff --git a/roles/svc-db-qdrant/meta/services.yml b/roles/svc-db-qdrant/meta/services.yml index fd8ee4690d..50a5a5fb7c 100644 --- a/roles/svc-db-qdrant/meta/services.yml +++ b/roles/svc-db-qdrant/meta/services.yml @@ -28,6 +28,9 @@ qdrant: qdrant: 6333 internal: qdrant: 6333 + onion: + # nocheck: onion-flag the vector store is read by the svc-ai roles in this stack; its MCP sidecar is the surface meant for outside callers + qdrant: false lifecycle: beta # nocheck: playwright-service-flag internal adapter sidecar, covered by the role's MCP integration test # nocheck: dockerfile-custom the Dockerfile is owned by svc-ai-mcp-adapter and staged per instance, so every adapter-backed provider shares one audited build diff --git a/roles/svc-db-rabbitmq/meta/services.yml b/roles/svc-db-rabbitmq/meta/services.yml index 330ec8ef2c..b3183f721d 100644 --- a/roles/svc-db-rabbitmq/meta/services.yml +++ b/roles/svc-db-rabbitmq/meta/services.yml @@ -28,6 +28,9 @@ rabbitmq: rabbitmq: 5672 internal: rabbitmq: 5672 + onion: + # nocheck: onion-flag AMQP carries the apps' own worker queues; a broker reachable off the node would let anyone enqueue jobs + rabbitmq: false lifecycle: beta # nocheck: variants-coverage โ€” sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in tor: diff --git a/roles/svc-db-redis/meta/services.yml b/roles/svc-db-redis/meta/services.yml index b22b73508c..6abd7afc9a 100644 --- a/roles/svc-db-redis/meta/services.yml +++ b/roles/svc-db-redis/meta/services.yml @@ -25,6 +25,9 @@ redis: redis: 6379 internal: redis: 6379 + onion: + # nocheck: onion-flag the shared cache and queue runs without AUTH inside the stack, so its reachability must stay bounded by the role network + redis: false lifecycle: beta # nocheck: variants-coverage โ€” sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in tor: diff --git a/roles/svc-db-typesense/meta/services.yml b/roles/svc-db-typesense/meta/services.yml index fe07bdae91..8304c9e73a 100644 --- a/roles/svc-db-typesense/meta/services.yml +++ b/roles/svc-db-typesense/meta/services.yml @@ -28,6 +28,9 @@ typesense: typesense: 8108 internal: typesense: 8108 + onion: + # nocheck: onion-flag the search index is queried by the app containers holding its API key, and the apps themselves are served through the onion's port 80 + typesense: false lifecycle: beta # nocheck: variants-coverage โ€” sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in tor: diff --git a/roles/svc-dns-unbound/meta/services.yml b/roles/svc-dns-unbound/meta/services.yml index bb8b51983d..e440222e4b 100644 --- a/roles/svc-dns-unbound/meta/services.yml +++ b/roles/svc-dns-unbound/meta/services.yml @@ -21,8 +21,13 @@ unbound: mem_limit: "512m" pids_limit: 256 ports: + internal: + unbound: 53 local: unbound: 53 + onion: + # nocheck: onion-flag a recursive resolver published on the onion is an open resolver, and the containers it serves reach it on the role network + unbound: false lifecycle: beta # nocheck: dynamic-flag - the resolver state volume is backup-disabled rebuildable cache, the backup consumer stays permanently off diff --git a/roles/svc-dns-unbound/templates/compose.yml.j2 b/roles/svc-dns-unbound/templates/compose.yml.j2 index f6371fed79..86f759571d 100644 --- a/roles/svc-dns-unbound/templates/compose.yml.j2 +++ b/roles/svc-dns-unbound/templates/compose.yml.j2 @@ -6,9 +6,9 @@ {{ lookup('container_image', application_id, 'unbound') }} {% include 'roles/sys-svc-container/templates/base.yml.j2' %} {% if DEPLOYMENT_MODE == 'compose' and not (DOCKER_IN_CONTAINER | bool) %} - ports: - - "{{ DOCKER_BIND_HOST }}:{{ UNBOUND_PORT }}:53/udp" # nocheck: compose-ports-must-use-container-ports - - "{{ DOCKER_BIND_HOST }}:{{ UNBOUND_PORT }}:53/tcp" # nocheck: compose-ports-must-use-container-ports + {{ lookup('container_ports', + {'service': 'unbound', 'protocol': 'unbound', 'ip': DOCKER_BIND_HOST, + 'transport': ['udp', 'tcp']}) | indent(4) }} {% endif %} {{ lookup('container_volumes', service_name) | indent(4) }} networks: # nocheck: networks-literal static-IP resolver on its own engine overlay so consumers can point dns: at it diff --git a/roles/svc-dns-unbound/templates/unbound.conf.j2 b/roles/svc-dns-unbound/templates/unbound.conf.j2 index d9f07c33b3..047486d8da 100644 --- a/roles/svc-dns-unbound/templates/unbound.conf.j2 +++ b/roles/svc-dns-unbound/templates/unbound.conf.j2 @@ -7,10 +7,9 @@ server: do-udp: yes do-tcp: yes do-daemonize: no - access-control: 127.0.0.0/8 allow - access-control: 10.0.0.0/8 allow - access-control: 172.16.0.0/12 allow - access-control: 192.168.0.0/16 allow +{% for _cidr in NETWORK_INTERNAL_CIDRS %} + access-control: {{ _cidr }} allow +{% endfor %} directory: "{{ lookup('volume', 'svc-dns-unbound', 'data').target }}" username: unbound auto-trust-anchor-file: trusted-key.key @@ -18,6 +17,9 @@ server: hide-identity: yes hide-version: yes cache-min-ttl: 300 +{% if UNBOUND_ONION_FORWARD_ADDR | length > 0 %} + domain-insecure: "onion." +{% endif %} remote-control: control-enable: yes @@ -30,3 +32,9 @@ forward-zone: forward-addr: {{ resolver }} {% endfor %} {% endif %} +{% if UNBOUND_ONION_FORWARD_ADDR | length > 0 %} + +forward-zone: + name: "onion." + forward-addr: {{ UNBOUND_ONION_FORWARD_ADDR }} +{% endif %} diff --git a/roles/svc-dns-unbound/vars/main.yml b/roles/svc-dns-unbound/vars/main.yml index 918bac40d0..75207698c0 100644 --- a/roles/svc-dns-unbound/vars/main.yml +++ b/roles/svc-dns-unbound/vars/main.yml @@ -4,7 +4,6 @@ entity_name: "{{ application_id | get_entity_name }}" UNBOUND_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.name') }}" UNBOUND_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, UNBOUND_CONTAINER) }}" -UNBOUND_PORT: "{{ lookup('config', application_id, 'services.unbound.ports.local.unbound') }}" UNBOUND_NETWORK_NAME: "{{ entity_name }}" UNBOUND_RESOLVER_IP: "{{ lookup('config', application_id, 'networks.local.dns_resolver') }}" @@ -12,3 +11,7 @@ UNBOUND_VOLUMES_DIR: "{{ lookup('container', application_id, 'directories.volume UNBOUND_CONF_FILE: "{{ [ UNBOUND_VOLUMES_DIR, 'unbound.conf' ] | path_join }}" UNBOUND_FORWARD_DNS: "{{ (DOCKER_IN_CONTAINER | bool) or ('microsoft' in lookup('file', '/proc/version') | lower) }}" + +UNBOUND_ONION_FORWARD_ADDR: >- + {{ (lookup('subnet_gateway') ~ '@' ~ lookup('config', 'svc-net-tor', 'services.tor.ports.local.dns')) + if ('svc-net-tor' in group_names and DEPLOYMENT_MODE == 'compose') else '' }} diff --git a/roles/svc-net-firewall/README.md b/roles/svc-net-firewall/README.md new file mode 100644 index 0000000000..9556984acb --- /dev/null +++ b/roles/svc-net-firewall/README.md @@ -0,0 +1,92 @@ +# Firewall ruleset + +## Description + +[nftables](https://www.netfilter.org/projects/nftables/) is the packet filtering framework of the Linux kernel and the successor to `iptables`. Its rules live in memory: a reboot drops them unless something loads them again. + +## Overview + +This role gives every other role one place to put packet filter rules. A role that needs them writes `templates/nftables.conf.j2` declaring its own nftables table and includes this role; the fragment is rendered to `/etc/nftables.d/.conf`, checked, loaded, and read back, so a deploy fails rather than quietly leaving a gap. Deployed on its own, the role installs nftables, the ruleset that loads every fragment, and the `nftables.service` that replays them at boot. + +One table per role is what makes the rules composable. A fragment replaces its own table and touches no other, so two roles on one host neither overwrite nor merge into each other, and a broken fragment fails alone instead of freezing everyone's rules. + +Replacing beats adding. `nft -f` on an existing table merges, so a rule dropped from the declaration would stay in the kernel and the ruleset could only ever grow. Each fragment therefore creates, deletes and redeclares its table in one transaction, which is also why the table is never observed empty: unlike a flush followed by rules, there is no window in which the boundary is gone. + +## Cosmos + +The diagram places Firewall ruleset in the Infinito.Nexus cosmos: the components it deploys (capabilities), the central services it consumes (dependencies), and its outward reach (federation and bridged external networks). + +```mermaid +flowchart LR + subgraph role [svc-net-firewall ๐Ÿ’ป] + svc_firewall["firewall"] + end +``` + +Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional (enabled only in matching deployments); red `0..0` edges are turned off in this role. Node markers show the role's deploy modes (๐Ÿ’ป host, ๐Ÿณ compose, ๐Ÿ swarm); โŒ marks a service that is explicitly turned off, and โš™๏ธ an Ansible role dependency declared in `meta/main.yml`. + +## Features + +- **Owned per role:** Each consumer declares one table under its own name, so rules are added and retired with the role that needs them. +- **Atomic:** A fragment is applied in a single `nft -f` transaction, so the table is never observed half built. +- **Persistent:** `nftables.service` loads every fragment at boot, so rules outlive a reboot without a unit of this project's own. The ruleset it reads is a distribution decision, so the path follows `os_family`: RedHat reads `/etc/sysconfig/nftables.conf`, the others `/etc/nftables.conf`. +- **Independent of docker:** Docker owns its own tables and never touches these, so no rule has to be inserted into a chain another component controls. The unit is only enabled, never started or restarted: on Debian its `ExecStop` flushes the entire ruleset, docker's tables included, and the fragments are loaded explicitly anyway. +- **Verified:** `nft -c` rejects a fragment before it is written, and the loaded table is read back and refused when it carries no chain. A table that exists is not a rule that runs, so the policy itself is proven by the role that owns it. + +## Use Cases + +- Bounding a service port that carries no access policy of its own, where the packet filter is the only boundary available. +- Redirecting or translating traffic a role owns, scoped to an address range nothing else claims. + +A fragment can only ever be stricter than the host already is. In nftables an `accept` terminates its own base chain and nothing more: the packet still traverses every other chain at that hook, and a `drop` in any of them wins. A rule that has to overrule a policy someone else set, such as the `DROP` docker installs on forwarded packets, does not belong in a fragment at all; it belongs in the chain that carries that policy. + +## Quick Setup + +### Development + +Clone, set up the workstation, and deploy Firewall ruleset onto the local stack: + +```bash +git clone https://github.com/infinito-nexus/core.git +cd core +make onboard +make compose-deploy mode=reinstall apps=svc-net-firewall full_cycle=false +``` + +### Production + +Install Firewall ruleset directly onto the target machine: clone the repository, install the OS prerequisites and the repository toolchain, then deploy against localhost over a local connection (no SSH, no container): + +```bash +git clone https://github.com/infinito-nexus/core.git +cd core +bash scripts/install/package.sh +make install +source scripts/meta/env/load.sh + +APP=svc-net-firewall +DOMAIN= +TLS_MODE=self_signed +SSH_PUBLIC_KEY="" +INVENTORY=inventories/production +infinito administration inventory provision "$INVENTORY" \ + --inventory-file "$INVENTORY/devices.yml" \ + --host localhost \ + --include "$APP" \ + --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" +infinito administration deploy dedicated "$INVENTORY/devices.yml" \ + --password-file "$INVENTORY/.password" \ + --diff -vv +``` + +## Further Resources + +- [nftables Wiki](https://wiki.nftables.org/) +- [nft(8) manual page](https://www.netfilter.org/projects/nftables/manpage.html) +- [Moving from iptables to nftables](https://wiki.nftables.org/wiki-nftables/index.php/Moving_from_iptables_to_nftables) + +## Credits + +Implemented by **[Kevin Veen-Birkenbach](https://www.veen.world)**. +Part of the [Infinito.Nexus Project](https://s.infinito.nexus/code) and maintained by [Kevin Veen-Birkenbach](https://www.veen.world). +Licensed under the [Infinito.Nexus Community License (Non-Commercial)](https://s.infinito.nexus/license). diff --git a/roles/svc-net-firewall/meta/info.yml b/roles/svc-net-firewall/meta/info.yml new file mode 100644 index 0000000000..ca99331c28 --- /dev/null +++ b/roles/svc-net-firewall/meta/info.yml @@ -0,0 +1,5 @@ +--- +logo: + class: fa-solid fa-shield-halved +homepage: https://www.netfilter.org/projects/nftables/ +video: https://www.youtube.com/watch?v=EGKhIljDPCw diff --git a/roles/svc-net-firewall/meta/main.yml b/roles/svc-net-firewall/meta/main.yml new file mode 100644 index 0000000000..93ca3d7d86 --- /dev/null +++ b/roles/svc-net-firewall/meta/main.yml @@ -0,0 +1,27 @@ +--- +galaxy_info: + author: Kevin Veen-Birkenbach + description: nftables packet filter whose rules each role owns as its own table, replaced atomically and reloaded at boot + company: | + Kevin Veen-Birkenbach + https://www.veen.world + license: Infinito.Nexus Community License (Non-Commercial) + min_ansible_version: "2.9" + galaxy_tags: + - firewall + - nftables + - netfilter + - security + issue_tracker_url: https://s.infinito.nexus/issues + platforms: + - name: ArchLinux + versions: [all] + - name: Debian + versions: [all] + - name: EL + versions: [all] + - name: Fedora + versions: [all] + - name: Ubuntu + versions: [all] +dependencies: [] diff --git a/roles/svc-net-firewall/meta/packages.yml b/roles/svc-net-firewall/meta/packages.yml new file mode 100644 index 0000000000..06709ff242 --- /dev/null +++ b/roles/svc-net-firewall/meta/packages.yml @@ -0,0 +1,8 @@ +--- +nftables: + Archlinux: + - nftables + Debian: + - nftables + RedHat: + - nftables diff --git a/roles/svc-net-firewall/meta/services.yml b/roles/svc-net-firewall/meta/services.yml new file mode 100644 index 0000000000..15c0b390d5 --- /dev/null +++ b/roles/svc-net-firewall/meta/services.yml @@ -0,0 +1,16 @@ +--- +# nocheck: playwright-service-flag host packet filter, no container and no web surface +firewall: + modes: + host: + enabled: true + compose: + enabled: false + swarm: + enabled: false + lifecycle: beta + min_storage: "50MB" + cpus: "0.1" + mem_reservation: "32m" + mem_limit: "64m" + pids_limit: 64 diff --git a/roles/svc-net-firewall/tasks/01_host.yml b/roles/svc-net-firewall/tasks/01_host.yml new file mode 100644 index 0000000000..9474747829 --- /dev/null +++ b/roles/svc-net-firewall/tasks/01_host.yml @@ -0,0 +1,32 @@ +--- +- name: "๐Ÿ“ฆ Firewall | Install nftables" + become: true + package_install: + id: nftables + +- name: "๐Ÿ“ Firewall | Ensure the fragment directory exists" + become: true + ansible.builtin.file: + path: "{{ FIREWALL_FRAGMENT_DIR }}" + state: directory + owner: root + group: root + mode: "0750" + +- name: "๐Ÿงฑ Firewall | Install the ruleset every fragment is loaded from" + become: true + ansible.builtin.template: # nocheck: stack-host-template per-host packet filter: every host carrying rules needs the ruleset, not only the stack host + src: main.nft.j2 + dest: "{{ FIREWALL_MAIN_CONF }}" + owner: root + group: root + mode: "0640" + backup: true + +- name: "๐Ÿš€ Firewall | Arm the ruleset for boot without touching the live one" + become: true + ansible.builtin.systemd_service: + name: "{{ FIREWALL_SERVICE }}" + enabled: true + +- include_tasks: utils/once/flag.yml diff --git a/roles/svc-net-firewall/tasks/02_fragment.yml b/roles/svc-net-firewall/tasks/02_fragment.yml new file mode 100644 index 0000000000..5ed9c5900e --- /dev/null +++ b/roles/svc-net-firewall/tasks/02_fragment.yml @@ -0,0 +1,43 @@ +--- +- name: "๐Ÿ” Firewall | Resolve the fragment of '{{ FIREWALL_FRAGMENT_ROLE }}'" + ansible.builtin.set_fact: + firewall_fragment_src: >- + {{ lookup('first_found', + { + 'files': [ FIREWALL_FRAGMENT_SRC ], + 'paths': [ lookup('path_absolute', 'roles', FIREWALL_FRAGMENT_ROLE) ] + } + ) }} + firewall_loaded_table: "{{ FIREWALL_TABLE }}" + +- name: "๐Ÿงฑ Firewall | Stage the ruleset of '{{ FIREWALL_FRAGMENT_ROLE }}'" + become: true + ansible.builtin.template: # nocheck: stack-host-template per-host packet filter: every host carrying the rules needs the fragment, not only the stack host + src: "{{ firewall_fragment_src }}" + dest: "{{ FIREWALL_FRAGMENT_FILE }}" + owner: root + group: root + mode: "0640" + validate: "nft -c -f %s" + +- name: "๐Ÿš€ Firewall | Load the ruleset of '{{ FIREWALL_FRAGMENT_ROLE }}'" + become: true + ansible.builtin.command: + argv: [nft, -f, "{{ FIREWALL_FRAGMENT_FILE }}"] + changed_when: true + +- name: "๐Ÿ” Firewall | Read back the table of '{{ FIREWALL_FRAGMENT_ROLE }}'" + become: true + ansible.builtin.command: + argv: [nft, list, table, inet, "{{ FIREWALL_TABLE }}"] + register: firewall_loaded_ruleset + changed_when: false + +- name: "โœ… Firewall | Refuse a table that carries no chain" + ansible.builtin.assert: + that: + - "'chain ' in firewall_loaded_ruleset.stdout" + fail_msg: >- + {{ FIREWALL_FRAGMENT_ROLE }} loaded {{ FIREWALL_TABLE }} but the table + holds no chain, so nothing it declares is hooked into the packet path. + A table that exists is not a rule that runs. diff --git a/roles/svc-net-firewall/tasks/main.yml b/roles/svc-net-firewall/tasks/main.yml new file mode 100644 index 0000000000..435c971317 --- /dev/null +++ b/roles/svc-net-firewall/tasks/main.yml @@ -0,0 +1,8 @@ +--- +- name: "๐Ÿงฑ Firewall | Prepare the host ruleset" + ansible.builtin.include_tasks: 01_host.yml + when: run_once_svc_net_firewall is not defined + +- name: "๐Ÿงฑ Firewall | Apply the ruleset of '{{ FIREWALL_FRAGMENT_ROLE }}'" + ansible.builtin.include_tasks: 02_fragment.yml + when: FIREWALL_FRAGMENT_ROLE | length > 0 diff --git a/roles/svc-net-firewall/templates/main.nft.j2 b/roles/svc-net-firewall/templates/main.nft.j2 new file mode 100644 index 0000000000..ef967514c8 --- /dev/null +++ b/roles/svc-net-firewall/templates/main.nft.j2 @@ -0,0 +1,3 @@ +#!/usr/sbin/nft -f +# Managed by {{ SOFTWARE_NAME }} (roles/svc-net-firewall). Do not edit by hand. +include "{{ FIREWALL_FRAGMENT_DIR }}/*.conf" diff --git a/roles/svc-net-firewall/vars/main.yml b/roles/svc-net-firewall/vars/main.yml new file mode 100644 index 0000000000..0303fda6d6 --- /dev/null +++ b/roles/svc-net-firewall/vars/main.yml @@ -0,0 +1,15 @@ +--- +application_id: svc-net-firewall + +FIREWALL_MAIN_CONF_BY_OS_FAMILY: + Archlinux: "/etc/nftables.conf" + Debian: "/etc/nftables.conf" + RedHat: "/etc/sysconfig/nftables.conf" + +FIREWALL_MAIN_CONF: "{{ FIREWALL_MAIN_CONF_BY_OS_FAMILY[ansible_facts['os_family']] }}" +FIREWALL_FRAGMENT_DIR: "/etc/nftables.d" +FIREWALL_SERVICE: "nftables.service" +FIREWALL_FRAGMENT_SRC: "templates/nftables.conf.j2" +FIREWALL_FRAGMENT_ROLE: "{{ ansible_parent_role_names | default([]) | first | default('') }}" +FIREWALL_FRAGMENT_FILE: "{{ [ FIREWALL_FRAGMENT_DIR, FIREWALL_FRAGMENT_ROLE ~ '.conf' ] | path_join }}" +FIREWALL_TABLE: "infinito_{{ FIREWALL_FRAGMENT_ROLE | regex_replace('[^a-zA-Z0-9]', '_') }}" diff --git a/roles/svc-net-tor/README.md b/roles/svc-net-tor/README.md index 0dfdf7a21c..1091186daf 100644 --- a/roles/svc-net-tor/README.md +++ b/roles/svc-net-tor/README.md @@ -86,19 +86,20 @@ Run the published image to provision the inventory and deploy Tor Onion Service APP=svc-net-tor HOST= DOMAIN= +DNS= TLS_MODE=self_signed SSH_PUBLIC_KEY="" docker run --rm -it \ -v "$PWD/inventories:/etc/infinito.nexus/inventories" \ - -e APP="$APP" -e HOST="$HOST" -e DOMAIN="$DOMAIN" -e TLS_MODE="$TLS_MODE" -e SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \ + -e APP="$APP" -e HOST="$HOST" -e DOMAIN="$DOMAIN" -e DNS="$DNS" -e TLS_MODE="$TLS_MODE" -e SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \ ghcr.io/infinito-nexus/core/debian bash -c ' INVENTORY=/etc/infinito.nexus/inventories/production infinito administration inventory provision "$INVENTORY" \ --inventory-file "$INVENTORY/devices.yml" \ --host "$HOST" \ --include "$APP" \ - --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" && + --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"networks\": {\"internet\": {\"dns\": \"$DNS\"}}, \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" && infinito administration deploy dedicated "$INVENTORY/devices.yml" \ --password-file "$INVENTORY/.password" \ --diff -vv' diff --git a/roles/svc-net-tor/files/test/onion_ports.py b/roles/svc-net-tor/files/test/onion_ports.py new file mode 100755 index 0000000000..a372b65d47 --- /dev/null +++ b/roles/svc-net-tor/files/test/onion_ports.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Assert every TCP port the node onion forwards actually accepts a connection. + +The ports are the ones roles ask for by name under ports.onion, plus any +service opting in with exposed: true, which is what lookup('tor_ports') returns +and what torrc turns into HiddenServicePort lines. The unconditional SSH forward +is deliberately not probed: it targets a host service this role does not deploy, +so its absence says nothing about the forwards under test. + +The HTTP probe in test.sh only sees the reverse proxy's vhosts, so mail and +every other named port went unchecked, and from outside a forwarded port that +reaches nothing looks exactly like a port that never reached torrc at all. + +A port passes when the SOCKS proxy reports CONNECT success, which it does only +after Tor has established the stream to the target behind the onion. One pass +probes every port and only the failures are retried, so a green run costs one +connect per port and a red one cannot multiply the retry budget by the number of +healthy ports. + +Standard library only: this runs wherever the CLI test runs, not in a container +carrying a test toolchain. + +Env (rendered into test.env from templates/test.env.j2): + TOR_SOCKS SOCKS proxy, host:port + ONION_HOST the node onion host + ONION_PORTS space-separated forwarded ports; empty means there is + nothing to check + ONION_PORT_TIMEOUT seconds per connect attempt. 60 for the same reason the + domain probe uses it: Tor's SocksTimeout default is 120s + and abandoning earlier is destructive, because the next + attempt opens a fresh SOCKS connection and throws away + the descriptor fetches and rendezvous retries the last + one was still working through + ONION_PORT_RETRIES passes over the failing ports. Below the domain probe's + RETRIES because this phase multiplies by the port count, + and meta/tests.yml caps the whole CLI test + SLEEP_SECONDS wait between passes +""" + +from __future__ import annotations + +import os +import socket +import sys +import time + +_GREETING_NO_AUTH = b"\x05\x01\x00" +_GREETING_OK = b"\x05\x00" +_CONNECT_TO_DOMAIN = b"\x05\x01\x00\x03" +_REPLY_SUCCESS = 0x00 + + +class ProbeError(RuntimeError): + """A port did not answer, with the reason a reader needs to act on it.""" + + +def _required(name: str, hint: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise SystemExit(f"pass {name} as env ({hint})") + return value + + +def connect_through_socks(proxy: str, host: str, port: int, timeout: float) -> None: + """Open a SOCKS5 CONNECT to ``host:port`` and raise when it is refused.""" + proxy_host, _, proxy_port = proxy.rpartition(":") + try: + sock = socket.create_connection((proxy_host, int(proxy_port)), timeout=timeout) + except OSError as error: + raise ProbeError(f"socks proxy unreachable: {error}") from None + + with sock: + sock.settimeout(timeout) + try: + sock.sendall(_GREETING_NO_AUTH) + if sock.recv(2) != _GREETING_OK: + raise ProbeError("socks proxy refused the no-auth method") + target = host.encode() + sock.sendall( + _CONNECT_TO_DOMAIN + + bytes([len(target)]) + + target + + port.to_bytes(2, "big") + ) + reply = sock.recv(4) + except OSError as error: + raise ProbeError(f"socks handshake failed: {error}") from None + if len(reply) < 2: + raise ProbeError("socks proxy closed the connection") + if reply[1] != _REPLY_SUCCESS: + raise ProbeError(f"socks CONNECT failed with reply 0x{reply[1]:02x}") + + +def probe( + *, + proxy: str, + host: str, + ports: list[int], + timeout: float, + retries: int, + sleep_seconds: float, + sleeper=time.sleep, +) -> dict[int, str]: + """Return the ports still failing after the retries, with their reason.""" + pending = list(ports) + reasons: dict[int, str] = {} + for attempt in range(1, retries + 1): + still: list[int] = [] + for port in pending: + try: + connect_through_socks(proxy, host, port, timeout) + except ProbeError as error: + reasons[port] = str(error) + still.append(port) + else: + reasons.pop(port, None) + print(f"[OK] {host}:{port} connect") + pending = still + if not pending or attempt == retries: + break + print( + f"[INFO] pass {attempt + 1}/{retries} retries " + f"{len(pending)} port(s) in {sleep_seconds}s" + ) + sleeper(sleep_seconds) + return {port: reasons[port] for port in pending} + + +def main() -> int: + proxy = _required("TOR_SOCKS", "host:port of the node SOCKS proxy") + host = _required("ONION_HOST", "svc-net-tor services.tor.node") + retries = int(_required("ONION_PORT_RETRIES", "passes over the failing ports")) + sleep_seconds = float(_required("SLEEP_SECONDS", "wait between passes")) + timeout = float( + _required("ONION_PORT_TIMEOUT", "seconds per connect, flavor-dependent") + ) + ports = [int(entry) for entry in os.environ.get("ONION_PORTS", "").split()] + + if not ports: + print("[INFO] no onion-forwarded ports declared; nothing to probe") + return 0 + + print( + f"[INFO] probing {len(ports)} onion-forwarded port(s) on {host} " + f"via socks5://{proxy} ({timeout}s per connect)" + ) + failures = probe( + proxy=proxy, + host=host, + ports=ports, + timeout=timeout, + retries=retries, + sleep_seconds=sleep_seconds, + ) + for port, reason in failures.items(): + print(f"[FAIL] {host}:{port} {reason}") + print(f"[INFO] {len(ports)} port(s) probed, {len(failures)} failed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/roles/svc-net-tor/files/test/test.sh b/roles/svc-net-tor/files/test/test.sh index 67bb92e08e..68677e034b 100755 --- a/roles/svc-net-tor/files/test/test.sh +++ b/roles/svc-net-tor/files/test/test.sh @@ -99,4 +99,7 @@ for domain in "${domains[@]}"; do done echo "[INFO] ${#domains[@]} probed, ${failed} failed" + +"$(dirname "${BASH_SOURCE[0]}")/onion_ports.py" || failed=$((failed + 1)) + [[ "${failed}" -eq 0 ]] diff --git a/roles/svc-net-tor/lookup_plugins/tor_egress_clients.py b/roles/svc-net-tor/lookup_plugins/tor_egress_clients.py new file mode 100644 index 0000000000..bd0a9647ae --- /dev/null +++ b/roles/svc-net-tor/lookup_plugins/tor_egress_clients.py @@ -0,0 +1,101 @@ +"""Lookup `tor_egress_clients`: the address space Tor's egress ports admit. + +Deliberately not the three RFC1918 blocks: 10.0.0.0/8 would hand the operator's +whole LAN a resolver and a transparent proxy that carry no access policy of +their own. What is admitted is what this deployment declares for itself, in rule +order: loopback, docker's address pools, the swarm gateway bridge, the role +subnet pool, and the subnets of the roles that sit outside it. + +Usage: + + {{ lookup('tor_egress_clients') }} +""" + +from __future__ import annotations + +from typing import Any + +from ansible.errors import AnsibleError +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase +from ansible.template import trust_as_template + +_REQUIRED = ( + "NETWORK_LOOPBACK_CIDR", + "NETWORK_DOCKER_ADDRESS_POOLS", + "NETWORK_SWARM_GWBRIDGE_POOL", + "NETWORK_ROLE_SUBNET_POOL", + "NETWORK_ROLE_SUBNET_EXCEPTION_ROLES", +) + + +def _rendered(templar: Any, value: Any) -> str: + """A variable's value with its Jinja resolved. + + Group vars reach a lookup as whatever the file holds, and several of these + are references rather than literals. Passing one through unrendered would + put the template text itself into a source address, where it cannot match + and therefore silently narrows the admitted set. + """ + text = "" if value is None else str(value).strip() + if "{{" not in text or templar is None: + return text + rendered = str(templar.template(trust_as_template(text))).strip() + if "{{" in rendered: + raise AnsibleError(f"tor_egress_clients: {text!r} did not render to an address") + return rendered + + +class LookupModule(LookupBase): + def run( + self, + terms: list[Any] | None, + variables: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Any]: + if terms: + raise AnsibleError("lookup('tor_egress_clients') expects no terms.") + variables = variables or getattr(self._templar, "available_variables", {}) or {} + missing = [name for name in _REQUIRED if variables.get(name) is None] + if missing: + raise AnsibleError( + "tor_egress_clients: missing variable(s) " + f"{', '.join(missing)}; an incomplete list would install a guard " + "that admits more than it was asked to" + ) + + templar = getattr(self, "_templar", None) + excepted = lookup_loader.get( + "role_subnets", + loader=getattr(self, "_loader", None), + templar=templar, + ).run( + [variables["NETWORK_ROLE_SUBNET_EXCEPTION_ROLES"]], + variables=variables, + )[0] + + pools = variables["NETWORK_DOCKER_ADDRESS_POOLS"] + if not isinstance(pools, (list, tuple)): + raise AnsibleError( + "tor_egress_clients: NETWORK_DOCKER_ADDRESS_POOLS must be a list" + ) + bases = [] + for pool in pools: + base = pool.get("base") if isinstance(pool, dict) else None + if not base: + raise AnsibleError( + f"tor_egress_clients: address pool {pool!r} declares no base" + ) + bases.append(_rendered(templar, base)) + + ordered = [ + _rendered(templar, variables["NETWORK_LOOPBACK_CIDR"]), + *bases, + _rendered(templar, variables["NETWORK_SWARM_GWBRIDGE_POOL"]), + _rendered(templar, variables["NETWORK_ROLE_SUBNET_POOL"]), + *[_rendered(templar, subnet) for subnet in excepted], + ] + seen: dict[str, None] = {} + for cidr in ordered: + seen.setdefault(cidr, None) + return [list(seen)] diff --git a/roles/svc-net-tor/lookup_plugins/tor_extra_ports.py b/roles/svc-net-tor/lookup_plugins/tor_extra_ports.py new file mode 100644 index 0000000000..db1b84bb45 --- /dev/null +++ b/roles/svc-net-tor/lookup_plugins/tor_extra_ports.py @@ -0,0 +1,80 @@ +"""Lookup `tor_extra_ports`: every port the node onion forwards. + +Two of them are flags rather than derivations, because no inventory carries +them. ``lookup('tor_ports')`` walks ``group_names`` and collects what the roles +listed there declare under ``ports.onion``; SSH belongs to no role at all, and +HTTP belongs to ``svc-prx-openresty``, which a node acquires through +``sys-stk-front-proxy`` and which therefore appears in no dependency list an +inventory holds. A node provisioned with ``--include svc-net-tor`` alone +published an onion forwarding SSH and nothing else, leaving every app it serves +unreachable over that onion. + +Forwarding HTTP is what this role exists for, so it is declared, and a node that +must not answer HTTP over its onion turns ``TOR_ONION_HTTP_ENABLED`` off. + +The derived ports still come from ``tor_ports``; a port named twice collapses, +since ``torrc.j2`` writes one ``HiddenServicePort`` line per entry. + +Usage: + + {{ lookup('tor_extra_ports') }} +""" + +from __future__ import annotations + +from typing import Any + +from ansible.errors import AnsibleError +from ansible.module_utils.parsing.convert_bool import boolean +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase + +FLAGGED_PORTS: tuple[tuple[str, int], ...] = ( + ("TOR_ONION_SSH_ENABLED", 22), + ("TOR_ONION_HTTP_ENABLED", 80), +) + + +def _entry(port: int) -> dict[str, Any]: + """One forward, in the shape ``torrc.j2`` consumes. + + Args: + port: the port the onion answers on; the backend host is substituted by + the template, so the target carries the port alone. + """ + return {"onion_port": port, "target": f"127.0.0.1:{port}"} + + +class LookupModule(LookupBase): + def run( + self, + terms: list[Any] | None, + variables: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Any]: + if terms: + raise AnsibleError("lookup('tor_extra_ports') expects no terms.") + + variables = variables or getattr(self._templar, "available_variables", {}) or {} + + entries: list[dict[str, Any]] = [] + for name, port in FLAGGED_PORTS: + if name not in variables: + raise AnsibleError(f"lookup('tor_extra_ports'): {name} is not defined") + if boolean(variables[name]): + entries.append(_entry(port)) + + derived = lookup_loader.get( + "tor_ports", + loader=getattr(self, "_loader", None), + templar=getattr(self, "_templar", None), + ).run([], variables=variables, **kwargs)[0] + + seen = {entry["onion_port"] for entry in entries} + for entry in derived: + if entry["onion_port"] in seen: + continue + seen.add(entry["onion_port"]) + entries.append(entry) + + return [entries] diff --git a/roles/svc-net-tor/meta/services.yml b/roles/svc-net-tor/meta/services.yml index 0b3345feac..bf18fcf11d 100644 --- a/roles/svc-net-tor/meta/services.yml +++ b/roles/svc-net-tor/meta/services.yml @@ -26,6 +26,11 @@ tor: # nocheck: category-modes node: "" use_entry_guards: true flavor: public + forwards: + ssh: true + http: true + egress: + enabled: true timeout_multipliers: chutney: 2 public: 5 @@ -34,9 +39,16 @@ tor: # nocheck: category-modes socks: 9050 trans: 9040 dns: 9053 + onion: + # nocheck: onion-flag a SOCKS port anyone can reach is an open proxy, and SocksPolicy cannot refuse it because the forward arrives from the node's own address + socks: false + # nocheck: onion-flag the transparent port takes its destination from the caller's routing, which an onion client does not share + trans: false + # nocheck: onion-flag publishing the resolver would answer recursive queries for anyone holding the onion address + dns: false min_storage: 100MB cpus: "0.25" mem_reservation: 128m mem_limit: 256m pids_limit: 128 - lifecycle: alpha + lifecycle: beta diff --git a/roles/svc-net-tor/tasks/00_core.yml b/roles/svc-net-tor/tasks/00_core.yml index 7cf9dfc626..b7cbc59ccb 100644 --- a/roles/svc-net-tor/tasks/00_core.yml +++ b/roles/svc-net-tor/tasks/00_core.yml @@ -125,7 +125,9 @@ file: router.yml apply: become: true - when: TOR_EGRESS_ROUTER_ENABLED | bool + when: + - TOR_EGRESS_ROUTER_ENABLED | bool + - DEPLOYMENT_MODE == 'compose' or (IS_STACK_HOST | bool) - name: "๐Ÿง… Verify the node onion is reachable" ansible.builtin.include_tasks: onion_reach.yml diff --git a/roles/svc-net-tor/tasks/router.yml b/roles/svc-net-tor/tasks/router.yml index 3ab0421d00..08f4119e8e 100644 --- a/roles/svc-net-tor/tasks/router.yml +++ b/roles/svc-net-tor/tasks/router.yml @@ -68,7 +68,7 @@ ansible.builtin.systemd: name: dnsmasq enabled: true - daemon_reload: "{{ _tor_dnsmasq_dropin is changed }}" + daemon_reload: "{{ TOR_DNSMASQ_OWNS_LISTENER | bool and _tor_dnsmasq_dropin is changed }}" state: "{{ 'restarted' if (_tor_dnsmasq_conf is changed or _tor_dnsmasq_confdir is changed) else 'started' }}" - name: "๐Ÿ”€ Egress router | Route host name resolution through dnsmasq" when: TOR_DNSMASQ_OWNS_LISTENER | bool @@ -88,12 +88,22 @@ regexp: "^(hosts:.*\\bresolve)\\s*\\[!UNAVAIL=return\\](.*)$" line: "\\1 [SUCCESS=return NOTFOUND=continue TRYAGAIN=return]\\2" backrefs: true -- name: "๐Ÿ”€ Egress router | Redirect .onion virtual-IP TCP to Tor TransPort" - ansible.builtin.iptables: - table: nat - chain: OUTPUT - protocol: tcp - destination: "{{ TOR_EGRESS_VIRTUAL_NET_IPV4 }}" - jump: REDIRECT - to_ports: "{{ TOR_TRANS_PORT | string }}" - comment: "Tor egress router: .onion virtual range -> TransPort" +- name: "๐Ÿ”€ Egress router | Route .onion traffic and bound the ports that carry no policy" + ansible.builtin.include_role: + name: svc-net-firewall + +- name: "๐Ÿ›ก๏ธ Egress guard | Read back the chain that bounds the ports" + become: true + ansible.builtin.command: + argv: [nft, list, chain, inet, "{{ firewall_loaded_table }}", guard] + register: tor_egress_guard_chain + changed_when: false + +- name: "๐Ÿ›ก๏ธ Egress guard | Refuse a guard that drops nothing" + ansible.builtin.assert: + that: + - "'drop' in tor_egress_guard_chain.stdout" + fail_msg: >- + The guard chain in {{ firewall_loaded_table }} carries no drop, so + TransPort and DNSPort are reachable from every source. These two ports + have no access policy of their own; this chain is the only boundary. diff --git a/roles/svc-net-tor/templates/compose.yml.j2 b/roles/svc-net-tor/templates/compose.yml.j2 index 4284a342ca..81c0ffdaec 100644 --- a/roles/svc-net-tor/templates/compose.yml.j2 +++ b/roles/svc-net-tor/templates/compose.yml.j2 @@ -12,11 +12,13 @@ {{ lookup('compose_only', 'container_name', TOR_CONTAINER) }} {{ lookup('container_image', application_id, 'tor') }} {% if DEPLOYMENT_MODE == 'swarm' %} - ports: - - target: {{ TOR_SOCKS_PORT }} - published: {{ TOR_SOCKS_PORT }} - protocol: tcp - mode: host + {{ lookup('container_ports', + {'service': 'tor', 'protocol': 'socks', 'mode': 'host', + 'container': 'same'}, + {'service': 'tor', 'protocol': 'dns', 'mode': 'host', + 'container': 'same', 'transport': ['udp', 'tcp']}, + {'service': 'tor', 'protocol': 'trans', 'mode': 'host', + 'container': 'same', 'transport': 'tcp'}) | indent(4) }} {{ lookup('container_networks') | indent(4) }} {% else %} network_mode: "host" diff --git a/roles/svc-net-tor/templates/nftables.conf.j2 b/roles/svc-net-tor/templates/nftables.conf.j2 new file mode 100644 index 0000000000..eab3aa5bb0 --- /dev/null +++ b/roles/svc-net-tor/templates/nftables.conf.j2 @@ -0,0 +1,52 @@ +{# Loaded by roles/svc-net-firewall into {{ FIREWALL_TABLE }}. #} +table inet {{ FIREWALL_TABLE }} +delete table inet {{ FIREWALL_TABLE }} +table inet {{ FIREWALL_TABLE }} { + set containers { + type ipv4_addr + flags interval + elements = { {{ TOR_EGRESS_CLIENT_CIDRS | join(', ') }} } + } + + set internal { + type ipv4_addr + flags interval + elements = { {{ NETWORK_INTERNAL_CIDRS | join(', ') }} } + } + +{% set _tcp_guarded = TOR_EGRESS_GUARDED_PORTS | selectattr('protocol', 'equalto', 'tcp') | map(attribute='port') | unique | list %} +{% set _udp_guarded = TOR_EGRESS_GUARDED_PORTS | selectattr('protocol', 'equalto', 'udp') | map(attribute='port') | unique | list %} + chain guard { +{% for entry in TOR_EGRESS_GUARDED_PORTS %} + ip saddr != @{{ entry.admitted }} {{ entry.protocol }} dport {{ entry.port }} drop +{% endfor %} +{% if _tcp_guarded | length > 0 %} + fib saddr . iif oif missing tcp dport { {{ _tcp_guarded | join(', ') }} } drop +{% endif %} +{% if _udp_guarded | length > 0 %} + fib saddr . iif oif missing udp dport { {{ _udp_guarded | join(', ') }} } drop +{% endif %} + } + + chain input { + type filter hook input priority -10; policy accept; + jump guard + } + + chain forward { + type filter hook forward priority -10; policy accept; + jump guard + } + + chain output { + type nat hook output priority -150; policy accept; + ip daddr {{ TOR_EGRESS_VIRTUAL_NET_IPV4 }} meta l4proto tcp redirect to :{{ TOR_TRANS_PORT }} + } +{% if TOR_CONTAINER_DNS_HOST | length > 0 %} + + chain prerouting { + type nat hook prerouting priority -150; policy accept; + ip daddr {{ TOR_EGRESS_VIRTUAL_NET_IPV4 }} meta l4proto tcp dnat ip to {{ TOR_CONTAINER_DNS_HOST }}:{{ TOR_TRANS_PORT }} + } +{% endif %} +} diff --git a/roles/svc-net-tor/templates/test.env.j2 b/roles/svc-net-tor/templates/test.env.j2 index 10028ca3a0..0b58f45bb0 100644 --- a/roles/svc-net-tor/templates/test.env.j2 +++ b/roles/svc-net-tor/templates/test.env.j2 @@ -1,4 +1,8 @@ NGINX_SERVERS_DIR={{ lookup('nginx', 'directories.configuration.servers') }} +ONION_HOST={{ lookup('config', 'svc-net-tor', 'services.tor.node') }} +ONION_PORTS={{ lookup('tor_ports') | map(attribute='onion_port') | unique | sort | join(' ') }} +ONION_PORT_RETRIES=2 +ONION_PORT_TIMEOUT=60 RETRIES=5 SLEEP_SECONDS=10 TOR_DNSMASQ_CONF={{ TOR_DNSMASQ_CONF }} diff --git a/roles/svc-net-tor/templates/torrc.j2 b/roles/svc-net-tor/templates/torrc.j2 index b2167aeea9..20e1dddbbb 100644 --- a/roles/svc-net-tor/templates/torrc.j2 +++ b/roles/svc-net-tor/templates/torrc.j2 @@ -1,13 +1,12 @@ -# Managed by Infinito.Nexus (roles/svc-net-tor) โ€” do not edit by hand. +# Managed by {{ SOFTWARE_NAME }} (roles/svc-net-tor) โ€” do not edit by hand. RunAsDaemon 0 Log notice stdout DataDirectory {{ lookup('volume', 'svc-net-tor', 'tor_data').target }} SocksPort {{ lookup('tor_socks', '0.0.0.0') }} -SocksPolicy accept 127.0.0.0/8 -SocksPolicy accept 10.0.0.0/8 -SocksPolicy accept 172.16.0.0/12 -SocksPolicy accept 192.168.0.0/16 +{% for _cidr in NETWORK_INTERNAL_CIDRS %} +SocksPolicy accept {{ _cidr }} +{% endfor %} SocksPolicy reject * ControlPort 127.0.0.1:9051 CookieAuthentication 1 @@ -24,12 +23,20 @@ UseEntryGuards 0 AutomapHostsOnResolve 1 AutomapHostsSuffixes .onion VirtualAddrNetworkIPv4 {{ TOR_EGRESS_VIRTUAL_NET_IPV4 }} +{% if DEPLOYMENT_MODE == 'swarm' %} +TransPort 0.0.0.0:{{ TOR_TRANS_PORT }} +DNSPort 0.0.0.0:{{ TOR_DNS_PORT }} +{% else %} TransPort 127.0.0.1:{{ TOR_TRANS_PORT }} DNSPort 127.0.0.1:{{ TOR_DNS_PORT }} +{% if TOR_CONTAINER_DNS_HOST | length > 0 %} +TransPort {{ TOR_CONTAINER_DNS_HOST }}:{{ TOR_TRANS_PORT }} +DNSPort {{ TOR_CONTAINER_DNS_HOST }}:{{ TOR_DNS_PORT }} +{% endif %} +{% endif %} {% endif %} HiddenServiceDir /var/lib/tor/infinito/node/ HiddenServiceVersion 3 -HiddenServicePort 80 {{ TOR_HS_BACKEND_HOST }}:80 {% for _p in TOR_ONION_EXTRA_PORTS %} HiddenServicePort {{ _p.onion_port }} {{ TOR_HS_BACKEND_HOST }}:{{ _p.target.rsplit(':', 1) | last }} {% endfor %} diff --git a/roles/svc-net-tor/vars/main.yml b/roles/svc-net-tor/vars/main.yml index 482668303f..f8245d1817 100644 --- a/roles/svc-net-tor/vars/main.yml +++ b/roles/svc-net-tor/vars/main.yml @@ -6,13 +6,40 @@ TOR_IMAGE: "{{ lookup('config', application_id, 'services.tor.image') }}" TOR_VERSION: "{{ lookup('config', application_id, 'services.tor.version') }}" TOR_CONTAINER: "{{ lookup('config', application_id, 'services.tor.name') }}" -TOR_SOCKS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.socks') }}" TOR_NODE_ONION: "{{ lookup('config', application_id, 'services.tor.node', '') }}" TOR_USE_ENTRY_GUARDS: "{{ lookup('config', application_id, 'services.tor.use_entry_guards') | bool }}" TOR_FLAVOR: "{{ lookup('config', application_id, 'services.tor.flavor') }}" +TOR_ONION_SSH_ENABLED: "{{ lookup('config', application_id, 'services.tor.forwards.ssh') | bool }}" +TOR_ONION_HTTP_ENABLED: "{{ lookup('config', application_id, 'services.tor.forwards.http') | bool }}" +TOR_ONION_EXTRA_PORTS: "{{ lookup('tor_extra_ports') }}" +TOR_EGRESS_ROUTER_ENABLED: "{{ lookup('config', application_id, 'services.tor.egress.enabled') | bool }}" +TOR_EGRESS_VIRTUAL_NET_IPV4: "{{ NETWORK_TOR_VIRTUAL_CIDR }}" TOR_TRANS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.trans') }}" TOR_DNS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.dns') }}" +TOR_SOCKS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.socks') }}" +TOR_DNSMASQ_PORT: "{{ NETWORK_DNS_PORT }}" TOR_DNSMASQ_CONF: "/etc/dnsmasq.d/tor-onion.conf" + +TOR_EGRESS_GUARDED_PORTS: + - port: "{{ TOR_TRANS_PORT }}" + protocol: tcp + admitted: containers + - port: "{{ TOR_DNS_PORT }}" + protocol: tcp + admitted: containers + - port: "{{ TOR_DNS_PORT }}" + protocol: udp + admitted: containers + - port: "{{ TOR_SOCKS_PORT }}" + protocol: tcp + admitted: internal + - port: "{{ TOR_DNSMASQ_PORT }}" + protocol: tcp + admitted: internal + - port: "{{ TOR_DNSMASQ_PORT }}" + protocol: udp + admitted: internal +TOR_EGRESS_CLIENT_CIDRS: "{{ lookup('tor_egress_clients') }}" TOR_DNSMASQ_DROPIN: "/etc/systemd/system/dnsmasq.service.d/infinito-restart.conf" # nocheck: host-local-fact meta/services.yml pins this role to the manager, so the fact describes the node the hidden service forwards on TOR_CONTAINER_DNS_HOST: "{{ ansible_facts['docker0']['ipv4']['address'] | default('', true) }}" diff --git a/roles/svc-net-wireguard-firewalled/README.md b/roles/svc-net-wireguard-firewalled/README.md index 90d2eed027..8b42479c3a 100644 --- a/roles/svc-net-wireguard-firewalled/README.md +++ b/roles/svc-net-wireguard-firewalled/README.md @@ -8,7 +8,7 @@ This role adapts iptables rules to enable proper connectivity for a WireGuard cl Optimized for environments with network address translation (NAT), this role: -- Executes shell commands to modify iptables rules. +- Installs its iptables rules through Ansible's `iptables` module, so a redeploy converges instead of appending a duplicate. - Allows traffic from the WireGuard client interface (e.g. `wg0-client`) and sets up NAT masquerading on the external interface (e.g. `eth0`). - Works as an extension to the native WireGuard client role. diff --git a/roles/svc-net-wireguard-firewalled/tasks/00_core.yml b/roles/svc-net-wireguard-firewalled/tasks/00_core.yml index 0c08a0b925..7360c4dc21 100644 --- a/roles/svc-net-wireguard-firewalled/tasks/00_core.yml +++ b/roles/svc-net-wireguard-firewalled/tasks/00_core.yml @@ -1,5 +1,19 @@ --- -- name: ๐Ÿ”ง adapt iptable rules - shell: iptables -A FORWARD -i wg0-client -j ACCEPT && iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +- name: "๐Ÿงฑ Accept forwarded traffic from the wireguard client" + become: true + ansible.builtin.iptables: + chain: FORWARD + in_interface: "{{ WIREGUARD_CLIENT_INTERFACE }}" + jump: ACCEPT + state: present + +- name: "๐Ÿงฑ Masquerade the client behind the uplink address" + become: true + ansible.builtin.iptables: + table: nat + chain: POSTROUTING + out_interface: "{{ WIREGUARD_UPLINK_INTERFACE }}" + jump: MASQUERADE + state: present - include_tasks: utils/once/flag.yml diff --git a/roles/svc-net-wireguard-firewalled/vars/main.yml b/roles/svc-net-wireguard-firewalled/vars/main.yml index 79d2e17b23..2cd2850160 100644 --- a/roles/svc-net-wireguard-firewalled/vars/main.yml +++ b/roles/svc-net-wireguard-firewalled/vars/main.yml @@ -1,2 +1,5 @@ --- application_id: svc-net-wireguard-firewalled + +WIREGUARD_CLIENT_INTERFACE: wg0-client +WIREGUARD_UPLINK_INTERFACE: eth0 diff --git a/roles/svc-net-wireguard-plain/templates/script.sh.j2 b/roles/svc-net-wireguard-plain/templates/script.sh.j2 index 2a0bf75c2d..5412c5143e 100644 --- a/roles/svc-net-wireguard-plain/templates/script.sh.j2 +++ b/roles/svc-net-wireguard-plain/templates/script.sh.j2 @@ -1,4 +1,5 @@ #!/bin/bash +{# nocheck: shell-template grandfathered: one line per internet interface, replaceable by argv #} {% for internet_interface in internet_interfaces %} ip li set mtu 1400 dev {{internet_interface}} {% endfor %} diff --git a/roles/svc-prx-openresty/meta/services.yml b/roles/svc-prx-openresty/meta/services.yml index d94903c97f..749d07cf9e 100644 --- a/roles/svc-prx-openresty/meta/services.yml +++ b/roles/svc-prx-openresty/meta/services.yml @@ -11,6 +11,17 @@ openresty: enabled: true shared: true name: openresty + ports: + internal: + http: 80 + https: 443 + public: + http: 80 + https: 443 + onion: + http: true + # nocheck: onion-flag the hidden service is already the transport encryption, and the onion address has no certificate a TLS listener could present + https: false healthcheck: test: ["CMD", "openresty", "-t", "-q"] min_storage: 264MB diff --git a/roles/svc-prx-openresty/templates/compose.yml.j2 b/roles/svc-prx-openresty/templates/compose.yml.j2 index 7d8f791608..b3bbe4721a 100644 --- a/roles/svc-prx-openresty/templates/compose.yml.j2 +++ b/roles/svc-prx-openresty/templates/compose.yml.j2 @@ -14,15 +14,9 @@ {{ lookup('compose_only', 'container_name', OPENRESTY_CONTAINER) }} {{ lookup('container_image', application_id, 'openresty') }} {% if DEPLOYMENT_MODE == 'swarm' %} - ports: - - target: 80 - published: 80 - protocol: tcp - mode: host - - target: 443 - published: 443 - protocol: tcp - mode: host + {{ lookup('container_ports', + {'service': 'openresty', 'protocol': 'http', 'mode': 'host'}, + {'service': 'openresty', 'protocol': 'https', 'mode': 'host'}) | indent(4) }} {{ lookup('container_extra_hosts', extra_hosts=['host.docker.internal:host-gateway']) | indent(4) }} {{ lookup('container_networks') | indent(4) }} {% else %} diff --git a/roles/svc-registry-cache/meta/services.yml b/roles/svc-registry-cache/meta/services.yml index 582e5433bd..69a32710b8 100644 --- a/roles/svc-registry-cache/meta/services.yml +++ b/roles/svc-registry-cache/meta/services.yml @@ -34,6 +34,9 @@ cache: registry: 5001 internal: registry: 5000 + onion: + # nocheck: onion-flag the pull-through mirror exists to serve this node's own image pulls; publishing it would relay Docker Hub for strangers + registry: false proxy: remote_url: "https://registry-1.docker.io" # nocheck: url lifecycle: beta diff --git a/roles/svc-registry-docker/meta/services.yml b/roles/svc-registry-docker/meta/services.yml index 5b2d65525d..66774e620e 100644 --- a/roles/svc-registry-docker/meta/services.yml +++ b/roles/svc-registry-docker/meta/services.yml @@ -34,6 +34,9 @@ docker: registry: 5000 internal: registry: 5000 + onion: + # nocheck: onion-flag the registry accepts unauthenticated pushes from the node that builds into it, so its reach must stay the node + registry: false lifecycle: beta # nocheck: dynamic-flag - locally built images are rebuildable, the backup consumer stays permanently off container_backup: diff --git a/roles/svc-storage-nfs-server/templates/exports.j2 b/roles/svc-storage-nfs-server/templates/exports.j2 index 471a61869d..830dfceee0 100644 --- a/roles/svc-storage-nfs-server/templates/exports.j2 +++ b/roles/svc-storage-nfs-server/templates/exports.j2 @@ -1,4 +1,4 @@ -# Managed by Infinito.Nexus Ansible role svc-storage-nfs-server. +# Managed by {{ SOFTWARE_NAME }} Ansible role svc-storage-nfs-server. {% for ip in svc_storage_nfs_server_allowed_ips %} {{ STORAGE_NFS_STATE_PATH }} {{ ip }}({{ SVC_STORAGE_NFS_SERVER_STATE_EXPORT_OPTIONS }},fsid=0) {% endfor %} diff --git a/roles/sys-ctl-alm-compose/templates/script.sh.j2 b/roles/sys-ctl-alm-compose/templates/script.sh.j2 index d9007ff9d5..ff85bcf637 100644 --- a/roles/sys-ctl-alm-compose/templates/script.sh.j2 +++ b/roles/sys-ctl-alm-compose/templates/script.sh.j2 @@ -1,4 +1,5 @@ #!/bin/bash +{# nocheck: shell-template grandfathered: one start block per subservice, each naming a unit only unit_name can resolve #} err=0 set -u start_timeout="{{ SYSTEMCTL_ALARM_COMPOSER_START_TIMEOUT_SECONDS }}" diff --git a/roles/sys-ctl-alm-email/templates/script.sh.j2 b/roles/sys-ctl-alm-email/templates/script.sh.j2 index b8b89bf89d..c9ec0bdf61 100644 --- a/roles/sys-ctl-alm-email/templates/script.sh.j2 +++ b/roles/sys-ctl-alm-email/templates/script.sh.j2 @@ -1,4 +1,5 @@ #!/bin/bash +{# nocheck: shell-template grandfathered, values only: the mail envelope, charset and timeout #} set -u # Reverse systemd escaping for human-readable mail subject/body friendly="$(systemd-escape --unescape "$1")" diff --git a/roles/sys-ctl-alm-telegram/templates/script.sh.j2 b/roles/sys-ctl-alm-telegram/templates/script.sh.j2 index 32a1fb0f94..726a842e4f 100644 --- a/roles/sys-ctl-alm-telegram/templates/script.sh.j2 +++ b/roles/sys-ctl-alm-telegram/templates/script.sh.j2 @@ -1,4 +1,5 @@ #!/bin/bash +{# nocheck: shell-template grandfathered, values only: bot token, chat id and the curl filter's timeout #} set -u # Make the instance text human readable again (reverse systemd escaping) diff --git a/roles/sys-ctl-cln-docker/templates/script.sh.j2 b/roles/sys-ctl-cln-docker/templates/script.sh.j2 index 4a7bd76017..5d66f0f6d7 100644 --- a/roles/sys-ctl-cln-docker/templates/script.sh.j2 +++ b/roles/sys-ctl-cln-docker/templates/script.sh.j2 @@ -1,4 +1,5 @@ #!/bin/sh +{# nocheck: shell-template grandfathered, values only: the versioned unit name it starts #} # Cleans up anonymous Docker volumes and performs a full Docker system prune. set -e diff --git a/roles/sys-ctl-hlth-csp/lookup_plugins/csp_accept_status.py b/roles/sys-ctl-hlth-csp/lookup_plugins/csp_accept_status.py new file mode 100644 index 0000000000..56a815d8ab --- /dev/null +++ b/roles/sys-ctl-hlth-csp/lookup_plugins/csp_accept_status.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase + +from utils.roles.applications.config import get +from utils.roles.applications.status_codes import declared_status_codes + +_CHECKER_ACCEPTS_BELOW = 300 + + +def _domains(value: Any) -> list[str]: + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, Mapping): + out: list[str] = [] + for entry in value.values(): + out.extend(_domains(entry)) + return out + if isinstance(value, (list, tuple, set)): + out = [] + for entry in value: + out.extend(_domains(entry)) + return out + return [] + + +class LookupModule(LookupBase): + """Return ``=[,]`` for every declared non-2xx status. + + The checker treats anything outside 2xx as unreachable, so a vhost that + serves a 4xx by design needs its code named. Naming it keeps the page + under CSP inspection, which excluding the domain would not. + + The declaration is per vhost: ``server.status_codes`` is keyed like + ``domains.canonical``, so two domains of one role can differ. + """ + + def run( + self, + terms: list[Any] | None, + variables: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[list[str]]: + applications = lookup_loader.get( + "applications", + loader=self._loader, + templar=getattr(self, "_templar", None), + ).run( + [], + variables=variables + or getattr(self._templar, "available_variables", {}) + or {}, + )[0] + + if not isinstance(applications, Mapping): + return [[]] + + accepted: dict[str, set[int]] = {} + for app_id in applications: + domains = _domains( + get(applications, app_id, "domains.canonical", strict=False, default=[]) + ) + _domains( + get(applications, app_id, "domains.aliases", strict=False, default=[]) + ) + for domain in domains: + codes = { + code + for code in declared_status_codes(applications, app_id, domain) + if code >= _CHECKER_ACCEPTS_BELOW + } + if codes: + accepted.setdefault(domain, set()).update(codes) + + return [ + [ + f"{domain}={','.join(str(code) for code in sorted(codes))}" + for domain, codes in sorted(accepted.items()) + ] + ] diff --git a/roles/sys-ctl-hlth-csp/lookup_plugins/csp_skip_domains.py b/roles/sys-ctl-hlth-csp/lookup_plugins/csp_skip_domains.py index 08d344c073..16af34bded 100644 --- a/roles/sys-ctl-hlth-csp/lookup_plugins/csp_skip_domains.py +++ b/roles/sys-ctl-hlth-csp/lookup_plugins/csp_skip_domains.py @@ -32,21 +32,6 @@ def _to_list(x: Any) -> list[str]: return [] -def _has_code_ge_400(codes: Any) -> bool: - if codes is None: - return False - if not isinstance(codes, (list, tuple, set)): - codes = [codes] - for c in codes: - try: - n = int(c) - except (TypeError, ValueError): - continue - if n >= 400: - return True - return False - - def _selection_from(group_names: Any) -> set[str]: if isinstance(group_names, (list, set, tuple)): return {str(x) for x in group_names if str(x)} @@ -58,11 +43,7 @@ def _selection_from(group_names: Any) -> set[str]: class LookupModule(LookupBase): """Return domains the CSP probe should skip. - Skips canonical + alias domains of every selected application whose - ``server.status_codes.default`` declares any HTTP code >= 400 - (e.g. federation-only roles that legitimately serve 4xx at ``/``). - - Additionally skips canonical domains owned by a disabled service: a + Skips canonical domains owned by a disabled service: a service entry may declare ``domains: [, ...]``; when its ``enabled`` resolves falsy those canonicals get no vhost (e.g. the seaweedfs filer/master frontend on an onion node), so probing them can @@ -118,37 +99,7 @@ class LookupModule(LookupBase): skip: set[str] = set() for app_id in applications: - if selection and app_id not in selection: - continue - - skip |= self._service_disabled_domains(applications, app_id) - - default = get( - applications, - app_id, - "server.status_codes.default", - strict=False, - default=None, - ) - if not _has_code_ge_400(default): - continue - - canonical = get( - applications, - app_id, - "domains.canonical", - strict=False, - default=[], - ) - aliases = get( - applications, - app_id, - "domains.aliases", - strict=False, - default=[], - ) - for d in _to_list(canonical) + _to_list(aliases): - if d: - skip.add(d) + if not selection or app_id in selection: + skip |= self._service_disabled_domains(applications, app_id) return [sorted(skip)] diff --git a/roles/sys-ctl-hlth-csp/meta/services.yml b/roles/sys-ctl-hlth-csp/meta/services.yml index acec2b10aa..bb5a8b194a 100644 --- a/roles/sys-ctl-hlth-csp/meta/services.yml +++ b/roles/sys-ctl-hlth-csp/meta/services.yml @@ -8,4 +8,4 @@ csp: categories: [web] csp-checker: image: ghcr.io/kevinveenbirkenbach/csp-checker - version: 2.3.0 + version: 3.0.0 diff --git a/roles/sys-ctl-hlth-csp/tasks/00_core.yml b/roles/sys-ctl-hlth-csp/tasks/00_core.yml index 4ca506c279..2d965b1a5c 100644 --- a/roles/sys-ctl-hlth-csp/tasks/00_core.yml +++ b/roles/sys-ctl-hlth-csp/tasks/00_core.yml @@ -60,6 +60,10 @@ '--skip-domain ' ~ (HEALTH_CSP_SKIP_DOMAINS | join(' ')), '' ), + (HEALTH_CSP_ACCEPT_STATUS | length > 0) | ternary( + '--accept-status ' ~ (HEALTH_CSP_ACCEPT_STATUS | join(' ')), + '' + ), (lookup('config', 'svc-net-tor', 'services.tor.node', '') | length > 0) | ternary( '--tor-proxy=socks5://' ~ lookup('tor_socks') ~ ' --onion-timeout=' ~ HEALTH_CSP_ONION_TIMEOUT_MS, diff --git a/roles/sys-ctl-hlth-csp/vars/main.yml b/roles/sys-ctl-hlth-csp/vars/main.yml index 61cad831c2..ea8d4a0ce0 100644 --- a/roles/sys-ctl-hlth-csp/vars/main.yml +++ b/roles/sys-ctl-hlth-csp/vars/main.yml @@ -3,6 +3,7 @@ system_service_id: sys-ctl-hlth-csp HEALTH_CSP_ONION_TIMEOUT_MS: "{{ (HEALTH_CSP_TIMEOUT_MS | int) * (lookup('config', 'svc-net-tor', 'services.tor.timeout_multipliers')[lookup('config', 'svc-net-tor', 'services.tor.flavor')] | int) }}" HEALTH_CSP_ONION_DOMAIN_SECONDS: "{{ ((HEALTH_CSP_ONION_TIMEOUT_MS | int) // 1000) + (HEALTH_CSP_DOMAIN_RESERVE_SECONDS | int) }}" +HEALTH_CSP_ACCEPT_STATUS: "{{ lookup('csp_accept_status') }}" HEALTH_CSP_SKIP_DOMAINS: >- {{ ( lookup('csp_skip_domains', group_names=lookup('deployment').deployed) diff --git a/roles/sys-service/handlers/refresh_systemctl_service.yml b/roles/sys-service/handlers/refresh_systemctl_service.yml index 879a7dd6e9..ac19505e55 100644 --- a/roles/sys-service/handlers/refresh_systemctl_service.yml +++ b/roles/sys-service/handlers/refresh_systemctl_service.yml @@ -2,9 +2,10 @@ - name: "๐Ÿ”ง Enable systemctl service" systemd: name: "{{ system_service_name }}" + scope: "{{ system_service_scope }}" enabled: true daemon_reload: true - become: true + become: "{{ system_service_unit_become }}" # rationale: async; opt-out per caller, a role that reads the unit state sets system_service_force_linear_sync async: "{{ system_service_async }}" poll: "{{ system_service_poll }}" @@ -12,8 +13,9 @@ - name: "๐Ÿ”ง Set systemctl service state" systemd: name: "{{ system_service_name }}" + scope: "{{ system_service_scope }}" state: "{{ system_service_state }}" - become: true + become: "{{ system_service_unit_become }}" # rationale: async; the Result gate below only runs meaningfully for callers that set system_service_force_linear_sync async: "{{ system_service_async }}" poll: "{{ system_service_poll }}" @@ -23,28 +25,14 @@ - name: ๐Ÿ” sys-service | Read unit Result (oneshot-safe) ansible.builtin.command: - argv: - - systemctl - - --no-pager - - show - - -p - - Result - - --value - - "{{ system_service_name }}" + argv: "{{ system_service_systemctl_show + ['Result', system_service_name] }}" register: system_service_result changed_when: false failed_when: false - name: ๐Ÿ” sys-service | Read unit ExecMainStatus (oneshot-safe) ansible.builtin.command: - argv: - - systemctl - - --no-pager - - show - - -p - - ExecMainStatus - - --value - - "{{ system_service_name }}" + argv: "{{ system_service_systemctl_show + ['ExecMainStatus', system_service_name] }}" register: system_service_exec_status changed_when: false failed_when: false diff --git a/roles/sys-service/tasks/06_service.yml b/roles/sys-service/tasks/06_service.yml index 639c21ba32..a89a63ad6d 100644 --- a/roles/sys-service/tasks/06_service.yml +++ b/roles/sys-service/tasks/06_service.yml @@ -28,10 +28,11 @@ - name: "๐Ÿ”ง setup systemctl '{{ system_service_id }}'" template: # nocheck: stack-host-template (per-host systemd service unit) src: "{{ system_service_template_src }}" - dest: "{{ [ DIR_SYSTEM_SERVICE, system_service_name ] | path_join }}" - owner: root - group: root + dest: "{{ [ system_service_unit_dir, system_service_name ] | path_join }}" + owner: "{{ system_service_unit_owner }}" + group: "{{ system_service_unit_owner }}" mode: "0644" + become: "{{ system_service_unit_become }}" notify: "{{ 'reload-system-daemon' if system_service_is_template | bool else 'refresh-systemctl-service' }}" - name: ๐Ÿ”„ refresh systemctl service when SYS_SERVICE_ALL_ENABLE diff --git a/roles/sys-service/tasks/07_timer.yml b/roles/sys-service/tasks/07_timer.yml index 1701ebaaf3..4814d6ad92 100644 --- a/roles/sys-service/tasks/07_timer.yml +++ b/roles/sys-service/tasks/07_timer.yml @@ -1,4 +1,14 @@ --- +- name: ๐Ÿ›‘ Fail if a timer is asked for outside the system scope + assert: + that: + - system_service_scope == 'system' + fail_msg: >- + '{{ system_service_id }}' asks for a timer in the '{{ system_service_scope }}' + scope, but sys-timer writes into {{ DIR_SYSTEM_SERVICE }} and drives the unit + in the system scope, so the timer would look for a service that only exists + for the user. + - name: ๐Ÿ›‘ Fail if system_service_id contains "@" assert: that: diff --git a/roles/sys-service/tasks/main.yml b/roles/sys-service/tasks/main.yml index ff13725fc0..c759025647 100644 --- a/roles/sys-service/tasks/main.yml +++ b/roles/sys-service/tasks/main.yml @@ -6,7 +6,9 @@ when: run_once_sys_service is defined - include_tasks: 00_core.yml - when: run_once_sys_service is not defined + when: + - run_once_sys_service is not defined + - system_service_scope == 'system' - name: "โ–ถ๏ธ Execute service routines for '{{ system_service_id }}'" block: diff --git a/roles/sys-service/templates/systemctl.service.j2 b/roles/sys-service/templates/systemctl.service.j2 index 4ec2bcb6b8..5f9a7dd7d1 100644 --- a/roles/sys-service/templates/systemctl.service.j2 +++ b/roles/sys-service/templates/systemctl.service.j2 @@ -1,6 +1,16 @@ [Unit] Description={{ SOFTWARE_NAME }} - Service for role '{{ system_service_id }}' -{{ system_service_tpl_on_failure | systemd_directive('OnFailure') }} +{% for key, val in [ + ('OnFailure', system_service_tpl_on_failure), + ('After', system_service_tpl_after), + ('Before', system_service_tpl_before), + ('Wants', system_service_tpl_wants), +] %} +{% set directive = val | systemd_directive(key) %} +{% if directive %} +{{ directive }} +{% endif %} +{% endfor %} StartLimitIntervalSec=60 StartLimitBurst=3 OnFailureJobMode=replace-irreversibly @@ -10,13 +20,21 @@ Type={{ system_service_tpl_type }} {% for _environment in system_service_tpl_environment %} Environment={{ _environment }} {% endfor %} - {% for key, val in [ + ('RemainAfterExit', system_service_tpl_remain_after_exit), ('TimeoutStartSec', system_service_tpl_timeout_start_sec), ('ExecStartPre', system_service_tpl_exec_start_pre), ('ExecStart', system_service_tpl_exec_start), ('ExecStartPost', system_service_tpl_exec_start_post), ('RuntimeMaxSec', system_service_tpl_runtime), ] %} -{{ val | systemd_directive(key) }} +{% set directive = val | systemd_directive(key) %} +{% if directive %} +{{ directive }} +{% endif %} {% endfor %} +{% if system_service_tpl_wanted_by | length > 0 %} + +[Install] +WantedBy={{ system_service_tpl_wanted_by }} +{% endif %} diff --git a/roles/sys-service/vars/main.yml b/roles/sys-service/vars/main.yml index 2a91e808bf..24b8e3fae9 100644 --- a/roles/sys-service/vars/main.yml +++ b/roles/sys-service/vars/main.yml @@ -4,6 +4,12 @@ UNIT_SUFFIX_REMOVER_PACKAGE: "unsure" system_service_name: "{{ lookup('unit_name', system_service_id) }}" system_service_is_template: "{{ system_service_id.endswith('@') }}" +system_service_scope: "system" # 'system' for root units, 'user' for per-login units +system_service_unit_dir: "{{ DIR_SYSTEM_SERVICE if system_service_scope == 'system' else DIR_SYSTEM_SERVICE_USER }}" +system_service_unit_owner: "{{ 'root' if system_service_scope == 'system' else omit }}" +system_service_unit_become: "{{ system_service_scope == 'system' }}" +system_service_systemctl_show: "{{ ['systemctl'] + (['--user'] if system_service_scope == 'user' else []) + ['--no-pager', 'show', '--value', '-p'] }}" + system_service_role_name: "{{ system_service_id | regex_replace('@','') }}" system_service_role_dir: "{{ lookup('path_absolute', 'roles', system_service_role_name) }}" system_service_script_dir: "{{ [ DIR_SCRIPTS_SYSTEMCTL, system_service_id ] | path_join }}" @@ -25,7 +31,12 @@ system_service_script_inter: "{{ '/bin/bash' if system_service_script_type == 's system_service_script_exec: "{{ system_service_script_inter }} {{ system_service_id | get_service_script_path( system_service_script_type ) }}" system_service_tpl_on_failure: "{{ SYS_SERVICE_ON_FAILURE_COMPOSE }}" +system_service_tpl_after: "" +system_service_tpl_before: "" +system_service_tpl_wants: "" system_service_tpl_type: "oneshot" +system_service_tpl_remain_after_exit: "" +system_service_tpl_wanted_by: "" # Empty installs no [Install] section, which leaves the unit static system_service_tpl_environment: [] # Environment variables system_service_tpl_runtime: "{{ '' if system_service_tpl_type == 'oneshot' else SYS_SERVICE_DEFAULT_RUNTIME }}" system_service_tpl_exec_start_pre: "" diff --git a/roles/test-e2e-cli/tasks/main.yml b/roles/test-e2e-cli/tasks/main.yml index 78e2190aea..cfb3092bf3 100644 --- a/roles/test-e2e-cli/tasks/main.yml +++ b/roles/test-e2e-cli/tasks/main.yml @@ -44,7 +44,7 @@ ansible.builtin.include_tasks: run_one.yml loop: "{{ _cli_apps }}" loop_control: - loop_var: application_id + loop_var: application_id # nocheck: loop-var-shadow the item IS the role id, so run_one reads the same value the tested role declares label: "{{ application_id }}" - name: โœ… Fail if there were CLI test failures diff --git a/roles/test-e2e-playwright/tasks/01_setup/orchestrate.yml b/roles/test-e2e-playwright/tasks/01_setup/orchestrate.yml index 0a0db3cd4b..9580f19f34 100644 --- a/roles/test-e2e-playwright/tasks/01_setup/orchestrate.yml +++ b/roles/test-e2e-playwright/tasks/01_setup/orchestrate.yml @@ -18,7 +18,7 @@ ansible.builtin.include_tasks: 02_run_one.yml loop: "{{ TEST_E2E_PLAYWRIGHT_APPS }}" loop_control: - loop_var: application_id + loop_var: application_id # nocheck: loop-var-shadow the item IS the role id, so 02_run_one reads the same value the tested role's own vars declare label: "{{ application_id }}" - name: ๐Ÿ” Stash first-pass failures for one flake retry @@ -31,7 +31,7 @@ ansible.builtin.include_tasks: 02_run_one.yml loop: "{{ _playwright_retry_roles | default([]) }}" loop_control: - loop_var: application_id + loop_var: application_id # nocheck: loop-var-shadow same loop body, same identity between item and role id label: "{{ application_id }} (retry)" - name: ๐Ÿ›‘ Fail if there were Playwright failures diff --git a/roles/user-backup/templates/ssh-wrapper.sh.j2 b/roles/user-backup/templates/ssh-wrapper.sh.j2 index fdffae7363..86e03d9763 100644 --- a/roles/user-backup/templates/ssh-wrapper.sh.j2 +++ b/roles/user-backup/templates/ssh-wrapper.sh.j2 @@ -1,9 +1,10 @@ #!/bin/sh +{# nocheck: shell-template grandfathered, values only: the backup directory and the repository list #} # log command if [ -n "$SSH_ORIGINAL_COMMAND" ] then - echo "`/bin/date`: $SSH_ORIGINAL_COMMAND" | systemd-cat -t "ssh-wrapper.sh" + echo "$(/bin/date): $SSH_ORIGINAL_COMMAND" | systemd-cat -t "ssh-wrapper.sh" fi get_hashed_machine_id="sha256sum /etc/machine-id"; diff --git a/roles/web-app-bigbluebutton/templates/compose.override.yml.j2 b/roles/web-app-bigbluebutton/templates/compose.override.yml.j2 index 58c4a51d5e..70439e95fa 100644 --- a/roles/web-app-bigbluebutton/templates/compose.override.yml.j2 +++ b/roles/web-app-bigbluebutton/templates/compose.override.yml.j2 @@ -90,12 +90,12 @@ services: {% set service_name = 'coturn' %} {{ service_name }}: {{ lookup('container_volumes', service_name) | indent(4) }} - ports: - - "{{ BBB_TURN_PORT }}:{{ BBB_TURN_PORT }}/udp" - - "{{ BBB_TURN_PORT }}:{{ BBB_TURN_PORT }}/tcp" - - "{{ BBB_STUN_PORT }}:{{ BBB_STUN_PORT }}/udp" - - "{{ BBB_STUN_PORT }}:{{ BBB_STUN_PORT }}/tcp" - - "{{ BBB_RELAY_PORT_RANGE }}/udp" + {{ lookup('container_ports', + {'service': 'bigbluebutton', 'protocol': 'stun_turn', + 'transport': ['udp', 'tcp'], 'container': 'same'}, + {'service': 'bigbluebutton', 'protocol': 'relay', + 'transport': 'udp', 'container': 'same', + 'publish': 'ephemeral'}) | indent(4) }} command: >- --use-auth-secret --static-auth-secret=${TURN_SECRET} diff --git a/roles/web-app-bigbluebutton/vars/main.yml b/roles/web-app-bigbluebutton/vars/main.yml index 359128cfe8..b7eafd9df5 100644 --- a/roles/web-app-bigbluebutton/vars/main.yml +++ b/roles/web-app-bigbluebutton/vars/main.yml @@ -33,7 +33,6 @@ BBB_STUN_PORT: "{{ lookup('config', application_id, 'services.bigbluebutton.port 'services.coturn.ports.public.stun_turn_tls') }}" BBB_RELAY_PORT_START: "{{ lookup('config', application_id, 'services.bigbluebutton.ports.public.relay.start') }}" BBB_RELAY_PORT_END: "{{ lookup('config', application_id, 'services.bigbluebutton.ports.public.relay.end') }}" -BBB_RELAY_PORT_RANGE: "{{ BBB_RELAY_PORT_START }}-{{ BBB_RELAY_PORT_END }}" BBB_COLLABORA_ENABLED: "{{ not (lookup('config',application_id, 'services.collabora.shared')) }}" BBB_COLLABORA_PORT: "{{ lookup('config', 'web-svc-collabora', 'services.collabora.ports.internal.http') }}" diff --git a/roles/web-app-checkmk/meta/services.yml b/roles/web-app-checkmk/meta/services.yml index 16a28d1907..ab52dec53c 100644 --- a/roles/web-app-checkmk/meta/services.yml +++ b/roles/web-app-checkmk/meta/services.yml @@ -83,6 +83,9 @@ checkmk: http: 8087 sso: 16509 agent: 8500 + onion: + # nocheck: onion-flag the monitoring server dials its agents, so nothing connects inward here, and the agent speaks no authentication of its own + agent: false run_after: - web-app-keycloak - svc-db-openldap diff --git a/roles/web-app-dashboard/templates/compose.yml.j2 b/roles/web-app-dashboard/templates/compose.yml.j2 index fe3ae4bc30..619e8c6420 100644 --- a/roles/web-app-dashboard/templates/compose.yml.j2 +++ b/roles/web-app-dashboard/templates/compose.yml.j2 @@ -1,12 +1,10 @@ {% include 'roles/sys-svc-compose/templates/base.yml.j2' %} {% set service_name = 'dashboard' %} - {% set container_port = lookup('config', application_id, 'services.' ~ service_name ~ '.ports.internal.http') %} {{ service_name }}: {% include 'roles/sys-svc-container/templates/deploy.yml.j2' %} {{ lookup('container_image', application_id, 'dashboard') }} {{ lookup('compose_only', 'container_name', DASHBOARD_CONTAINER) }} - ports: - - {{ DOCKER_BIND_HOST }}:{{ http_port }}:{{ container_port }} + {{ lookup('container_ports', ['dashboard', 'http', DOCKER_BIND_HOST]) | indent(4) }} {{ lookup('container_volumes', service_name) | indent(4) }} {{ lookup('compose_restart') }} {{ lookup('container_networks') | indent(4) }} diff --git a/roles/web-app-dashboard/templates/javascript/oidc.js.j2 b/roles/web-app-dashboard/templates/javascript/oidc.js.j2 index 2d641ae521..52b28a94a4 100644 --- a/roles/web-app-dashboard/templates/javascript/oidc.js.j2 +++ b/roles/web-app-dashboard/templates/javascript/oidc.js.j2 @@ -8,7 +8,7 @@ 1) Configuration (filled by Jinja/Ansible) ======================= */ const KC_CONFIG = { - url: "{{ OIDC.URL }}", // e.g. https://auth.infinito.nexus + url: "{{ OIDC.URL }}", // e.g. https://auth.{{ SOFTWARE_DOMAIN }} realm: "{{ OIDC.CLIENT.REALM }}", // e.g. cymais.cloud clientId: "{{ OIDC.CLIENT.ID }}", // e.g. cymais.cloud redirectUri: "{{ DASHBOARD_APP_BASE_URL }}", // canonical app URL for login/logout round-trips diff --git a/roles/web-app-discourse/files/ruby/ensure_admin_user.rb b/roles/web-app-discourse/files/ruby/ensure_admin_user.rb index 553ed9e5c8..15bb55151e 100644 --- a/roles/web-app-discourse/files/ruby/ensure_admin_user.rb +++ b/roles/web-app-discourse/files/ruby/ensure_admin_user.rb @@ -7,9 +7,7 @@ password = ENV.fetch("DISCOURSE_ADMIN_PASSWORD") user = User.find_by_email(email) || User.new(email: email) user.username = username -user.password = password -user.save! - +user.password = password unless user.confirm_password?(password) user.active = true user.save! diff --git a/roles/web-app-discourse/templates/config.yml.j2 b/roles/web-app-discourse/templates/config.yml.j2 index 1312e2281d..f09ede8f90 100644 --- a/roles/web-app-discourse/templates/config.yml.j2 +++ b/roles/web-app-discourse/templates/config.yml.j2 @@ -15,8 +15,7 @@ templates: ## which TCP/IP ports should this container expose? ## If you want Discourse to share a port with another webserver like Apache or nginx, ## see https://meta.discourse.org/t/17247 for details -expose: - - "{{ DOCKER_BIND_HOST }}:{{ lookup('config', application_id, 'services.discourse.ports.local.http') }}:80" # http +{{ lookup('container_ports', ['discourse', 'http', DOCKER_BIND_HOST], key='expose') }} params: db_default_text_search_config: "pg_catalog.english" diff --git a/roles/web-app-funkwhale/templates/compose.yml.j2 b/roles/web-app-funkwhale/templates/compose.yml.j2 index 5d91f8e574..813ccd6228 100644 --- a/roles/web-app-funkwhale/templates/compose.yml.j2 +++ b/roles/web-app-funkwhale/templates/compose.yml.j2 @@ -30,8 +30,9 @@ {{ lookup('container_image', application_id, 'api') }} command: gunicorn {{ lookup('container_volumes', service_name) | indent(4) }} - ports: - - "{{ FUNKWHALE_DOCKER_API_PORT }}" + {{ lookup('container_ports', + {'service': 'api', 'protocol': 'http', + 'publish': 'ephemeral'}) | indent(4) }} {{ lookup('container_depends_on', application_id, extra={ 'migrate': {'condition': 'service_completed_successfully'}, }) | indent(4) }} diff --git a/roles/web-app-gitea/meta/services.yml b/roles/web-app-gitea/meta/services.yml index 751acb0134..d9d25c94e3 100644 --- a/roles/web-app-gitea/meta/services.yml +++ b/roles/web-app-gitea/meta/services.yml @@ -76,6 +76,9 @@ gitea: http: 8002 public: ssh: 2201 + onion: + # nocheck: onion-flag git over the onion runs on the HTTP transport the proxy already serves on port 80; a second sshd on the onion would need its own host key policy + ssh: false run_after: - web-app-matomo - web-app-keycloak diff --git a/roles/web-app-gitlab/meta/services.yml b/roles/web-app-gitlab/meta/services.yml index 0eaf633988..146427958e 100644 --- a/roles/web-app-gitlab/meta/services.yml +++ b/roles/web-app-gitlab/meta/services.yml @@ -63,6 +63,9 @@ gitlab: internal: http: 8181 ssh: 2222 + onion: + # nocheck: onion-flag GitLab Shell is published for clearnet git clients and authenticates them against the internal API by host key; onion clients clone over the HTTP transport on port 80 instead + ssh: false proxy: service_key: workhorse run_after: diff --git a/roles/web-app-jenkins/templates/casc.yaml.j2 b/roles/web-app-jenkins/templates/casc.yaml.j2 index b40e863375..e66a916a60 100644 --- a/roles/web-app-jenkins/templates/casc.yaml.j2 +++ b/roles/web-app-jenkins/templates/casc.yaml.j2 @@ -5,7 +5,7 @@ # Keycloak group / LDAP group memberships onto Jenkins authorities. jenkins: - systemMessage: "Managed by Infinito.Nexus." + systemMessage: "Managed by {{ SOFTWARE_NAME }}." numExecutors: 2 mode: NORMAL diff --git a/roles/web-app-jitsi/templates/compose.yml.j2 b/roles/web-app-jitsi/templates/compose.yml.j2 index b893265663..d69c1d1fca 100644 --- a/roles/web-app-jitsi/templates/compose.yml.j2 +++ b/roles/web-app-jitsi/templates/compose.yml.j2 @@ -28,8 +28,9 @@ {{ lookup('compose_only', 'container_name', JITSI_JVB_CONTAINER) }} {% include 'roles/sys-svc-container/templates/base.yml.j2' %} {{ lookup('depends_on', {JITSI_PROSODY_SERVICE: 'service_started'}) }} - ports: - - "{{ JITSI_JVB_MEDIA_PORT }}:{{ JITSI_JVB_MEDIA_PORT }}/udp" # nocheck: compose-ports-must-use-container-ports + {{ lookup('container_ports', + {'service': 'jitsi', 'protocol': 'media', + 'transport': 'udp', 'container': 'same'}) | indent(4) }} {{ lookup('container_volumes', service_name) | indent(4) }} {{ lookup('container_networks') | indent(4) }} diff --git a/roles/web-app-joomla/tasks/main.yml b/roles/web-app-joomla/tasks/main.yml index d26681fb8d..0c2adc2ad8 100644 --- a/roles/web-app-joomla/tasks/main.yml +++ b/roles/web-app-joomla/tasks/main.yml @@ -4,7 +4,7 @@ name: sys-stk-front-proxy loop: "{{ JOOMLA_DOMAINS }}" loop_control: - loop_var: domain + loop_var: domain # nocheck: loop-var-shadow sys-stk-front-proxy takes the vhost through this name, see its tasks/main.yml front_proxy_domain vars: http_port: "{{ lookup('config', application_id, 'services.joomla.ports.local.http') }}" webserver_client_max_body_size: "{{ JOOMLA_POST_MAX_SIZE }}" diff --git a/roles/web-app-keycloak/templates/sso_proxy/container.yml.j2 b/roles/web-app-keycloak/templates/sso_proxy/container.yml.j2 index 9ca1d59d3b..806fb3cd63 100644 --- a/roles/web-app-keycloak/templates/sso_proxy/container.yml.j2 +++ b/roles/web-app-keycloak/templates/sso_proxy/container.yml.j2 @@ -8,6 +8,7 @@ labels: infinito.sso-proxy.config-sha256: "{{ lookup('template', lookup('path_absolute', 'roles/web-app-keycloak/templates/sso_proxy/oauth2-proxy-keycloak.cfg.j2'), template_vars={'sso_proxy_application_id': application_id}) | hash('sha256') }}" ports: + # nocheck: compose-ports-must-use-container-ports the published port belongs to the consuming role's own entity and the container port to web-app-keycloak's sso_proxy, so the mapping spans two application_ids and no single container_ports call can read both - {{ DOCKER_BIND_HOST }}:{{ lookup('config', application_id, 'services.' ~ (application_id | get_entity_name) ~ '.ports.local.sso') }}:{{ lookup('config', 'web-app-keycloak', 'services.sso_proxy.ports.internal.http') }}/tcp volumes: - "{{ lookup('container', application_id, 'directories.volumes') }}{{ lookup('config', 'web-app-keycloak', 'services.sso_proxy.configuration_file') }}:/oauth2-proxy.cfg" diff --git a/roles/web-app-kix/templates/zzz_auth_ldap.xml.j2 b/roles/web-app-kix/templates/zzz_auth_ldap.xml.j2 index a5cd11055c..209028dcf8 100644 --- a/roles/web-app-kix/templates/zzz_auth_ldap.xml.j2 +++ b/roles/web-app-kix/templates/zzz_auth_ldap.xml.j2 @@ -1,12 +1,12 @@ - Infinito.Nexus LDAP backend (svc-db-openldap) for the web-app-kix-administrator role group. Authenticates KIX agents via Auth::LDAP and provisions the contact via Auth::Sync::LDAP. + {{ SOFTWARE_NAME }} LDAP backend (svc-db-openldap) for the web-app-kix-administrator role group. Authenticates KIX agents via Auth::LDAP and provisions the contact via Auth::Sync::LDAP. Framework [ { - "Name": "Infinito.Nexus svc-db-openldap (Agent, web-app-kix-administrator)", + "Name": "{{ SOFTWARE_NAME }} svc-db-openldap (Agent, web-app-kix-administrator)", "Enabled": 1, "UsageContext": "Agent", "Module": "Kernel::System::Auth::LDAP", @@ -57,12 +57,12 @@ - Second Infinito.Nexus LDAP backend for the web-app-kix-user role group. KIX iterates over Authentication entries; if the administrator backend (###100) refuses (user not in admin group), this one tries the user group. The OAuth2-proxy `allowed_groups` filter still lets only members of either group through, so this backend never sees a user without an Infinito.Nexus role. + Second {{ SOFTWARE_NAME }} LDAP backend for the web-app-kix-user role group. KIX iterates over Authentication entries; if the administrator backend (###100) refuses (user not in admin group), this one tries the user group. The OAuth2-proxy `allowed_groups` filter still lets only members of either group through, so this backend never sees a user without an {{ SOFTWARE_NAME }} role. Framework [ { - "Name": "Infinito.Nexus svc-db-openldap (Agent, web-app-kix-user)", + "Name": "{{ SOFTWARE_NAME }} svc-db-openldap (Agent, web-app-kix-user)", "Enabled": 1, "UsageContext": "Agent", "Module": "Kernel::System::Auth::LDAP", diff --git a/roles/web-app-littlejs/templates/html/nav/bottom.html.j2 b/roles/web-app-littlejs/templates/html/nav/bottom.html.j2 index 709d70be95..94b2eac154 100644 --- a/roles/web-app-littlejs/templates/html/nav/bottom.html.j2 +++ b/roles/web-app-littlejs/templates/html/nav/bottom.html.j2 @@ -7,13 +7,13 @@ LittleJS Repository - + {{ LITTLEJS_HEADLINE }} Code diff --git a/roles/web-app-mailu/meta/services.yml b/roles/web-app-mailu/meta/services.yml index 99eb7abc1d..6a830b3e08 100644 --- a/roles/web-app-mailu/meta/services.yml +++ b/roles/web-app-mailu/meta/services.yml @@ -61,6 +61,13 @@ mailu: imap: 143 imaps: 993 sieve: 4190 + onion: + smtp: true + submission: true + imap: true + pop3: true + # nocheck: onion-flag ManageSieve edits server-side filters, which is administration rather than the sending and receiving the onion transport is for + sieve: false proxy: service_key: front run_after: diff --git a/roles/web-app-mastodon/tasks/00_core.yml b/roles/web-app-mastodon/tasks/00_core.yml index 53b877980b..4e60f56b9b 100644 --- a/roles/web-app-mastodon/tasks/00_core.yml +++ b/roles/web-app-mastodon/tasks/00_core.yml @@ -6,7 +6,7 @@ name: sys-stk-front-proxy loop: "{{ lookup('domains', 'web-app-mastodon') }}" loop_control: - loop_var: domain + loop_var: domain # nocheck: loop-var-shadow sys-stk-front-proxy takes the vhost through this name, see its tasks/main.yml front_proxy_domain vars: http_port: "{{ lookup('config', application_id, 'services.mastodon.ports.local.http') }}" webserver_websocket_location: "/api/v1/streaming" diff --git a/roles/web-app-matrix/meta/services.yml b/roles/web-app-matrix/meta/services.yml index 77e592c100..024e4179be 100644 --- a/roles/web-app-matrix/meta/services.yml +++ b/roles/web-app-matrix/meta/services.yml @@ -55,6 +55,9 @@ synapse: http: 8021 public: federation: 8448 + onion: + # nocheck: onion-flag federation is dialled by clearnet homeservers resolving this server_name; serving it on the onion needs an onion server_name, which is a different deployment + federation: false min_storage: 311MB cpus: "1.5" mem_reservation: "1g" diff --git a/roles/web-app-matrix/templates/flavor/ansible/services.yml.j2 b/roles/web-app-matrix/templates/flavor/ansible/services.yml.j2 index d0bb2d80ea..3746b3eb25 100644 --- a/roles/web-app-matrix/templates/flavor/ansible/services.yml.j2 +++ b/roles/web-app-matrix/templates/flavor/ansible/services.yml.j2 @@ -35,7 +35,8 @@ - /run - /run/lock - /tmp - ports: - - "{{ DOCKER_BIND_HOST }}:{{ MATRIX_SYNAPSE_PORT }}:8008" + {{ lookup('container_ports', ['synapse', 'http', DOCKER_BIND_HOST]) | indent(4) }} + # nocheck: compose-ports-must-use-container-ports matrix-docker-ansible-deploy fixes element on 8080 while this repo declares element.ports.internal.http 80; the two disagree and the playbook owns the container - "{{ DOCKER_BIND_HOST }}:{{ MATRIX_ELEMENT_PORT }}:8080" + # nocheck: compose-ports-must-use-container-ports the upstream playbook serves federation on 8048 and no ports.internal.federation is declared for it - "{{ DOCKER_BIND_HOST }}:{{ MATRIX_FEDERATION_PORT }}:8048" diff --git a/roles/web-app-matrix/templates/flavor/compose/services.yml.j2 b/roles/web-app-matrix/templates/flavor/compose/services.yml.j2 index 16e4954ca0..e24e06d6f3 100644 --- a/roles/web-app-matrix/templates/flavor/compose/services.yml.j2 +++ b/roles/web-app-matrix/templates/flavor/compose/services.yml.j2 @@ -1,7 +1,6 @@ {% set service_name = 'synapse' %} {{ service_name }}: {% include 'roles/sys-svc-container/templates/deploy.yml.j2' %} -{% set container_port = MATRIX_SYNAPSE_CONTAINER_PORT %} {{ lookup('container_image', application_id, 'synapse') }} {{ lookup('compose_only', 'container_name', MATRIX_SYNAPSE_NAME) }} {{ lookup('compose_restart') }} @@ -11,8 +10,7 @@ environment: - SYNAPSE_SERVER_NAME={{ MATRIX_SYNAPSE_DOMAIN }} - SYNAPSE_REPORT_STATS=no - ports: - - "{{ DOCKER_BIND_HOST }}:{{lookup('config', 'web-app-matrix', 'services.synapse.ports.local.http')}}:{{ container_port }}" + {{ lookup('container_ports', ['synapse', 'http', DOCKER_BIND_HOST]) | indent(4) }} {{ lookup('container_extra_hosts') | indent(4) }} {{ lookup('container_healthcheck', service_name) | indent(4) }} {{ lookup('container_depends_on', application_id) | indent(4) }} @@ -20,13 +18,11 @@ {% set service_name = 'element' %} {{ service_name }}: {% include 'roles/sys-svc-container/templates/deploy.yml.j2' %} -{% set container_port = lookup('config', application_id, 'services.' ~ service_name ~ '.ports.internal.http') %} {{ lookup('container_image', application_id, 'element') }} {{ lookup('compose_only', 'container_name', MATRIX_ELEMENT_NAME) }} {{ lookup('compose_restart') }} {{ lookup('container_volumes', service_name) | indent(4) }} - ports: - - "{{ DOCKER_BIND_HOST }}:{{ MATRIX_ELEMENT_PORT }}:{{ container_port }}" + {{ lookup('container_ports', ['element', 'http', DOCKER_BIND_HOST]) | indent(4) }} {{ lookup('container_healthcheck', service_name) | indent(4) }} {{ lookup('container_networks') | indent(4) }} diff --git a/roles/web-app-mattermost/templates/javascript.js.j2 b/roles/web-app-mattermost/templates/javascript.js.j2 index b96dc1378e..4dec5e539a 100644 --- a/roles/web-app-mattermost/templates/javascript.js.j2 +++ b/roles/web-app-mattermost/templates/javascript.js.j2 @@ -5,7 +5,7 @@ window.addEventListener("DOMContentLoaded", function () { // Rename if Mattermost ever renders the GitLab button natively document.querySelectorAll("a[href*='/oauth/gitlab/']").forEach(function (el) { if (el.textContent.trim() === "GitLab") { - el.textContent = "SSO with Infinito.Nexus"; + el.textContent = "SSO with {{ SOFTWARE_NAME }}"; } }); @@ -19,7 +19,7 @@ window.addEventListener("DOMContentLoaded", function () { var a = document.createElement("a"); a.href = "/oauth/gitlab/login"; - a.textContent = "SSO with Infinito.Nexus"; + a.textContent = "SSO with {{ SOFTWARE_NAME }}"; a.className = "btn btn-primary login-body-card-form-button-submit large"; a.style.cssText = "display:flex;align-items:center;justify-content:center;width:100%;box-sizing:border-box;margin:20px 0 0 0;"; btn.parentNode.insertBefore(a, btn.nextSibling); diff --git a/roles/web-app-mattermost/templates/playwright.env.j2 b/roles/web-app-mattermost/templates/playwright.env.j2 index 44e5be9c6e..4f807ed924 100644 --- a/roles/web-app-mattermost/templates/playwright.env.j2 +++ b/roles/web-app-mattermost/templates/playwright.env.j2 @@ -36,7 +36,7 @@ MCP_SERVICE_ENABLED={{ lookup('config', application_id, 'mcp.enabled') | string OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }} -# nocheck: persona-block-rationale โ€” Mattermost Team Edition has no native OIDC provider, so this role piggybacks Keycloak onto the GitLab OAuth slot (vars/main.yml, MM_GITLABSETTINGS_* in templates/env.j2); the entry point is a[href='/oauth/gitlab/login'] relabelled "SSO with Infinito.Nexus" by templates/javascript.js.j2, which the shared helper's name-based login matcher does not match, and Mattermost v11+ first serves a /landing interstitial that only clears once localStorage.__landingPageSeen__ is seeded before navigation โ€” the shared helper has no init-script hook (see README "Persona contract opt-outs"). +# nocheck: persona-block-rationale โ€” Mattermost Team Edition has no native OIDC provider, so this role piggybacks Keycloak onto the GitLab OAuth slot (vars/main.yml, MM_GITLABSETTINGS_* in templates/env.j2); the entry point is a[href='/oauth/gitlab/login'] relabelled "SSO with {{ SOFTWARE_NAME }}" by templates/javascript.js.j2, which the shared helper's name-based login matcher does not match, and Mattermost v11+ first serves a /landing interstitial that only clears once localStorage.__landingPageSeen__ is seeded before navigation โ€” the shared helper has no init-script hook (see README "Persona contract opt-outs"). PERSONA_ADMINISTRATOR_BLOCKED=true # nocheck: persona-block-rationale โ€” see PERSONA_ADMINISTRATOR_BLOCKED above. diff --git a/roles/web-app-mediawiki/templates/style.css.j2 b/roles/web-app-mediawiki/templates/style.css.j2 index b5b5fcfb1d..68379bc125 100644 --- a/roles/web-app-mediawiki/templates/style.css.j2 +++ b/roles/web-app-mediawiki/templates/style.css.j2 @@ -1,4 +1,4 @@ -/* MediaWiki (Vector 2022 + OOUI) color remap via Infinito.Nexus palette +/* MediaWiki (Vector 2022 + OOUI) color remap via {{ SOFTWARE_NAME }} palette - Keep changes scoped and variable-driven - Prefer variables over hard values - Avoid !important unless an inline style or strong OOUI rule requires it diff --git a/roles/web-app-moodle/templates/compose.yml.j2 b/roles/web-app-moodle/templates/compose.yml.j2 index 5ce0b89800..0bb8966b57 100644 --- a/roles/web-app-moodle/templates/compose.yml.j2 +++ b/roles/web-app-moodle/templates/compose.yml.j2 @@ -40,8 +40,7 @@ {{ lookup('compose_only', 'container_name', MOODLE_NGINX_CONTAINER) }} {{ lookup('container_image', application_id, 'nginx') }} {{ lookup('depends_on', {'moodle': 'service_healthy'}) }} - ports: - - {{ DOCKER_BIND_HOST }}:{{ lookup('config', application_id, 'services.moodle.ports.local.http') }}:{{ MOODLE_NGINX_PORT }} + {{ lookup('container_ports', ['moodle', 'http', DOCKER_BIND_HOST]) | indent(4) }} {{ lookup('container_volumes', service_name) | indent(4) }} {{ lookup('compose_restart') }} {{ lookup('container_networks') | indent(4) }} diff --git a/roles/web-app-nextcloud/meta/addons/integration_github.yml b/roles/web-app-nextcloud/meta/addons/integration_github.yml index 5e3f59fab0..b409c441a0 100644 --- a/roles/web-app-nextcloud/meta/addons/integration_github.yml +++ b/roles/web-app-nextcloud/meta/addons/integration_github.yml @@ -1,5 +1,5 @@ --- -enabled: "{{ (lookup('api', 'github.client_id') | length > 0) and (lookup('api', 'github.client_secret') | length > 0) }}" +enabled: "{{ lookup('api_enabled', 'github') }}" required: true mechanism: plugin source: upstream diff --git a/roles/web-app-nextcloud/meta/addons/integration_google.yml b/roles/web-app-nextcloud/meta/addons/integration_google.yml index 98e6def4dd..c96349ab13 100644 --- a/roles/web-app-nextcloud/meta/addons/integration_google.yml +++ b/roles/web-app-nextcloud/meta/addons/integration_google.yml @@ -1,5 +1,5 @@ --- -enabled: "{{ (lookup('api', 'google.client_id') | length > 0) and (lookup('api', 'google.client_secret') | length > 0) }}" +enabled: "{{ lookup('api_enabled', 'google') }}" required: true mechanism: plugin source: upstream diff --git a/roles/web-app-nextcloud/meta/addons/integration_jira.yml b/roles/web-app-nextcloud/meta/addons/integration_jira.yml index 0b08bd9d25..94c1f2a096 100644 --- a/roles/web-app-nextcloud/meta/addons/integration_jira.yml +++ b/roles/web-app-nextcloud/meta/addons/integration_jira.yml @@ -1,5 +1,5 @@ --- -enabled: "{{ (lookup('api', 'jira.client_id') | length > 0) and (lookup('api', 'jira.client_secret') | length > 0) }}" +enabled: "{{ lookup('api_enabled', 'jira') }}" required: true mechanism: plugin source: upstream diff --git a/roles/web-app-nextcloud/templates/compose.yml.j2 b/roles/web-app-nextcloud/templates/compose.yml.j2 index cb6e1b68a1..c4a1edb7ee 100644 --- a/roles/web-app-nextcloud/templates/compose.yml.j2 +++ b/roles/web-app-nextcloud/templates/compose.yml.j2 @@ -75,11 +75,13 @@ init: true {% if NEXTCLOUD_HPB_NETWORK_MODE == 'bridge' %} {% if NEXTCLOUD_HPB_TURN_ONBOARD_ENABLED | bool and NEXTCLOUD_HPB_TURN_ONBOARD_PUBLISH_PORTS | bool %} - ports: - - {{ NEXTCLOUD_HPB_TURN_ONBOARD_BIND_HOST }}:{{ NEXTCLOUD_HPB_TURN_ONBOARD_PORT }}:{{ NEXTCLOUD_HPB_TURN_ONBOARD_PORT }}/tcp # nocheck: compose-ports-must-use-container-ports - - {{ NEXTCLOUD_HPB_TURN_ONBOARD_BIND_HOST }}:{{ NEXTCLOUD_HPB_TURN_ONBOARD_PORT }}:{{ NEXTCLOUD_HPB_TURN_ONBOARD_PORT }}/udp # nocheck: compose-ports-must-use-container-ports - - {{ NEXTCLOUD_HPB_TURN_ONBOARD_BIND_HOST }}:{{ NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_START }}-{{ NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_END }}:{{ NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_START }}-{{ NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_END }}/udp # nocheck: compose-ports-must-use-container-ports - - {{ NEXTCLOUD_HPB_TURN_ONBOARD_BIND_HOST }}:{{ NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_START }}-{{ NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_END }}:{{ NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_START }}-{{ NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_END }}/tcp # nocheck: compose-ports-must-use-container-ports + {{ lookup('container_ports', + {'service': 'nextcloud', 'protocol': 'stun_turn', + 'ip': NEXTCLOUD_HPB_TURN_ONBOARD_BIND_HOST, + 'transport': ['tcp', 'udp'], 'container': 'same'}, + {'service': 'nextcloud', 'protocol': 'relay', + 'ip': NEXTCLOUD_HPB_TURN_ONBOARD_BIND_HOST, + 'transport': ['udp', 'tcp'], 'container': 'same'}) | indent(4) }} {% endif %} expose: - "{{ container_port }}" diff --git a/roles/web-app-peertube/meta/services.yml b/roles/web-app-peertube/meta/services.yml index 9132986606..504da92f83 100644 --- a/roles/web-app-peertube/meta/services.yml +++ b/roles/web-app-peertube/meta/services.yml @@ -79,6 +79,9 @@ peertube: http: 8011 public: rtmp: 1935 + onion: + # nocheck: onion-flag RTMP is an ingest the broadcaster pushes in, and the encoders that push it speak no SOCKS + rtmp: false internal: http: 9000 rtmp: 1935 diff --git a/roles/web-app-peertube/tasks/00_core.yml b/roles/web-app-peertube/tasks/00_core.yml index 596c18e492..e4aa750ef9 100644 --- a/roles/web-app-peertube/tasks/00_core.yml +++ b/roles/web-app-peertube/tasks/00_core.yml @@ -6,7 +6,7 @@ name: sys-stk-front-proxy loop: "{{ lookup('domains', application_id) }}" loop_control: - loop_var: domain + loop_var: domain # nocheck: loop-var-shadow sys-stk-front-proxy takes the vhost through this name, see its tasks/main.yml front_proxy_domain vars: http_port: "{{ lookup('config', application_id, 'services.peertube.ports.local.http') }}" websocket_port: "{{ lookup('config', application_id, 'services.peertube.ports.local.http') }}" diff --git a/roles/web-app-pixelfed/templates/playwright.env.j2 b/roles/web-app-pixelfed/templates/playwright.env.j2 index 7cf2bd23f0..df0ab60782 100644 --- a/roles/web-app-pixelfed/templates/playwright.env.j2 +++ b/roles/web-app-pixelfed/templates/playwright.env.j2 @@ -7,7 +7,7 @@ ADMIN_USERNAME={{ lookup('users', 'administrator').username | dotenv_quote }} APP_BASE_URL={{ lookup('tls', application_id, 'url.base') }} -BIBER_OIDC_EMAIL_OVERRIDE={{ (lookup('users', 'biber').username ~ '@infinito.nexus') | dotenv_quote }} +BIBER_OIDC_EMAIL_OVERRIDE={{ (lookup('users', 'biber').username ~ '@' ~ SOFTWARE_DOMAIN) | dotenv_quote }} BIBER_PASSWORD={{ lookup('users', 'biber').password | dotenv_quote }} diff --git a/roles/web-app-wordpress/tasks/00_core.yml b/roles/web-app-wordpress/tasks/00_core.yml index 4aa2ed8fa5..2e3806e1a2 100644 --- a/roles/web-app-wordpress/tasks/00_core.yml +++ b/roles/web-app-wordpress/tasks/00_core.yml @@ -10,7 +10,7 @@ name: sys-stk-front-proxy loop: "{{ WORDPRESS_DOMAINS }}" loop_control: - loop_var: domain + loop_var: domain # nocheck: loop-var-shadow sys-stk-front-proxy takes the vhost through this name, see its tasks/main.yml front_proxy_domain vars: webserver_extra_configuration: "client_max_body_size {{ WORDPRESS_MAX_UPLOAD_SIZE }};" http_port: "{{ lookup('config', application_id, 'services.wordpress.ports.local.http') }}" diff --git a/roles/web-svc-coturn/templates/compose.yml.j2 b/roles/web-svc-coturn/templates/compose.yml.j2 index c54c7efdfd..e9b4b8dc58 100644 --- a/roles/web-svc-coturn/templates/compose.yml.j2 +++ b/roles/web-svc-coturn/templates/compose.yml.j2 @@ -18,13 +18,23 @@ {{ lookup('compose_only', 'container_name', COTURN_CONTAINER) }} network_mode: {{ COTURN_NETWORK_MODE }} {% if COTURN_NETWORK_MODE == 'bridge' %} - ports: - - "{{ COTURN_STUN_TURN_PORT }}:{{ COTURN_STUN_TURN_PORT }}/udp" # nocheck: compose-ports-must-use-container-ports - - "{{ COTURN_STUN_TURN_PORT }}:{{ COTURN_STUN_TURN_PORT }}/tcp" # nocheck: compose-ports-must-use-container-ports - {% if COTURN_TLS_ENABLED| bool %} - - "{{ COTURN_STUN_TURN_PORT_TLS }}:{{ COTURN_STUN_TURN_PORT_TLS }}/tcp" # nocheck: compose-ports-must-use-container-ports - {% endif %} - - "{{ COTURN_RELAY_PORT_RANGE }}/udp" # nocheck: compose-ports-must-use-container-ports + {% if COTURN_TLS_ENABLED | bool %} + {{ lookup('container_ports', + {'service': 'coturn', 'protocol': 'stun_turn', + 'transport': ['udp', 'tcp'], 'container': 'same'}, + {'service': 'coturn', 'protocol': 'stun_turn_tls', + 'transport': 'tcp', 'container': 'same'}, + {'service': 'coturn', 'protocol': 'relay', + 'transport': 'udp', 'container': 'same', + 'publish': 'ephemeral'}) | indent(4) }} + {% else %} + {{ lookup('container_ports', + {'service': 'coturn', 'protocol': 'stun_turn', + 'transport': ['udp', 'tcp'], 'container': 'same'}, + {'service': 'coturn', 'protocol': 'relay', + 'transport': 'udp', 'container': 'same', + 'publish': 'ephemeral'}) | indent(4) }} + {% endif %} {{ lookup('container_networks') | indent(4) }} {% endif %} {{ lookup('container_volumes', service_name) | indent(4) }} diff --git a/roles/web-svc-coturn/vars/main.yml b/roles/web-svc-coturn/vars/main.yml index c1bb58cc5f..1fd172c9aa 100644 --- a/roles/web-svc-coturn/vars/main.yml +++ b/roles/web-svc-coturn/vars/main.yml @@ -11,7 +11,6 @@ COTURN_STUN_TURN_PORT: "{{ lookup('config', application_id, 'services.coturn.por COTURN_STUN_TURN_PORT_TLS: "{{ lookup('config', application_id, 'services.coturn.ports.public.stun_turn_tls') }}" COTURN_RELAY_PORT_START: "{{ lookup('config', application_id, 'services.coturn.ports.public.relay.start') }}" COTURN_RELAY_PORT_END: "{{ lookup('config', application_id, 'services.coturn.ports.public.relay.end') }}" -COTURN_RELAY_PORT_RANGE: "{{ COTURN_RELAY_PORT_START }}-{{ COTURN_RELAY_PORT_END }}" COTURN_USER_NAME: "{{ lookup('users', 'coturnconsumer').username }}" COTURN_USER_PASSWORD: "{{ lookup('users', 'coturnconsumer').password }}" diff --git a/roles/web-svc-xmpp/meta/services.yml b/roles/web-svc-xmpp/meta/services.yml index 4fbbdadc6e..6229ec6578 100644 --- a/roles/web-svc-xmpp/meta/services.yml +++ b/roles/web-svc-xmpp/meta/services.yml @@ -38,6 +38,11 @@ xmpp: public: c2s: 5222 s2s: 5269 + onion: + # nocheck: onion-flag onion clients reach the same sessions over the BOSH and websocket endpoints the proxy serves on port 80 + c2s: false + # nocheck: onion-flag server-to-server dials clearnet peers that resolve this domain in DNS, which an onion address does not answer + s2s: false internal: http: 5280 c2s: 5222 diff --git a/scripts/docker/cache/package-frontend/certs.sh b/scripts/docker/cache/package-frontend/certs.sh index 22af789401..2e8f338f00 100755 --- a/scripts/docker/cache/package-frontend/certs.sh +++ b/scripts/docker/cache/package-frontend/certs.sh @@ -19,6 +19,11 @@ fi : "${INFINITO_CACHE_PACKAGE_FRONTEND_INIT_IMAGE:?Source scripts/meta/env/load.sh first}" ALPINE_IMAGE="${INFINITO_CACHE_PACKAGE_FRONTEND_INIT_IMAGE}" +if ! MAX_ATTEMPTS=3 RETRY_DELAY_SECONDS=10 \ + "${REPO_ROOT}/scripts/github/runner/pull_with_retry.sh" "${ALPINE_IMAGE}"; then + echo "[package-frontend-certs] could not pull ${ALPINE_IMAGE}; falling back to a cached copy" >&2 +fi + exec docker run --rm \ -v "${INFINITO_CACHE_PACKAGE_FRONTEND_CA_DIR}:/ca" \ -v "${INFINITO_CACHE_PACKAGE_FRONTEND_CERTS_DIR}:/certs" \ diff --git a/scripts/github/guide/compose_deploy.sh b/scripts/github/guide/compose_deploy.sh index b7fdcc16d8..ff6a82fd9f 100755 --- a/scripts/github/guide/compose_deploy.sh +++ b/scripts/github/guide/compose_deploy.sh @@ -16,4 +16,5 @@ test -s /tmp/run.sh source scripts/meta/env/load.sh sed -i "s##ssh-ed25519 AAAA_TEST_DUMMY_KEY github-ci-dummy@infinito#" /tmp/run.sh sed -i "s##${INFINITO_DOMAIN}#" /tmp/run.sh +sed -i "s##${INFINITO_DNS_IP}#" /tmp/run.sh docker exec -i "${INFINITO_CONTAINER}" bash -s +{% if application_needs_clearnet_resolver -%} +DNS= +{% endif -%} TLS_MODE=self_signed SSH_PUBLIC_KEY="" INVENTORY=inventories/production @@ -91,7 +94,7 @@ infinito administration inventory provision "$INVENTORY" \ --inventory-file "$INVENTORY/devices.yml" \ --host localhost \ --include "$APP" \ - --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" + --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", {% if application_needs_clearnet_resolver %}\"networks\": {\"internet\": {\"dns\": \"$DNS\"}}, {% endif %}\"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" infinito administration deploy dedicated "$INVENTORY/devices.yml" \ --password-file "$INVENTORY/.password" \ --diff -vv @@ -103,19 +106,22 @@ Run the published image to provision the inventory and deploy {{ application_nam APP={{ application_id }} HOST= DOMAIN= +{% if application_needs_clearnet_resolver -%} +DNS= +{% endif -%} TLS_MODE=self_signed SSH_PUBLIC_KEY="" docker run --rm -it \ -v "$PWD/inventories:/etc/infinito.nexus/inventories" \ - -e APP="$APP" -e HOST="$HOST" -e DOMAIN="$DOMAIN" -e TLS_MODE="$TLS_MODE" -e SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \ + -e APP="$APP" -e HOST="$HOST" -e DOMAIN="$DOMAIN" {% if application_needs_clearnet_resolver %}-e DNS="$DNS" {% endif %}-e TLS_MODE="$TLS_MODE" -e SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \ ghcr.io/infinito-nexus/core/debian bash -c ' INVENTORY=/etc/infinito.nexus/inventories/production infinito administration inventory provision "$INVENTORY" \ --inventory-file "$INVENTORY/devices.yml" \ --host "$HOST" \ --include "$APP" \ - --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" && + --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", {% if application_needs_clearnet_resolver %}\"networks\": {\"internet\": {\"dns\": \"$DNS\"}}, {% endif %}\"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" && infinito administration deploy dedicated "$INVENTORY/devices.yml" \ --password-file "$INVENTORY/.password" \ --diff -vv' diff --git a/tests/integration/ansible/jinja/test_variable_definitions.py b/tests/integration/ansible/jinja/test_variable_definitions.py index 605fd2ebd5..0092ae1603 100644 --- a/tests/integration/ansible/jinja/test_variable_definitions.py +++ b/tests/integration/ansible/jinja/test_variable_definitions.py @@ -246,6 +246,7 @@ class TestVariableDefinitions(unittest.TestCase): "ansible_memtotal_mb", "omit", "group_names", + "ansible_parent_role_names", "ansible_processor_vcpus", "ansible_service_mgr", "ansible_distribution_release", @@ -293,6 +294,7 @@ class TestVariableDefinitions(unittest.TestCase): "ansible_memtotal_mb", "omit", "group_names", + "ansible_parent_role_names", "ansible_processor_vcpus", "ansible_distribution_release", "ansible_distribution", diff --git a/tests/integration/meta/workflows/test_variant_payload_scope.py b/tests/integration/meta/workflows/test_variant_payload_scope.py new file mode 100644 index 0000000000..00a9d18967 --- /dev/null +++ b/tests/integration/meta/workflows/test_variant_payload_scope.py @@ -0,0 +1,96 @@ +"""The swarm matrix bakes a variant payload for every app it deploys.""" + +from __future__ import annotations + +import unittest +from typing import ClassVar + +from cli.administration.deploy.development.inventory.payload import ( + _resolve_variant_payloads, + get_variant_overrides_only, +) +from cli.administration.deploy.development.inventory.planner import ( + plan_dev_inventory_matrix, +) +from utils.cache.files import PROJECT_ROOT +from utils.tests.swarm.derive_includes import ( + _EXPLICIT_INCLUDES, + derive_includes, + variant_scope, +) + +_ROLES_DIR = str(PROJECT_ROOT / "roles") + + +def _rounds(primary: str) -> list[tuple]: + return plan_dev_inventory_matrix( + roles_dir=_ROLES_DIR, + primary_apps=[primary], + base_inventory_dir="/tmp/variant-payload-scope", + ) + + +class TestVariantPayloadScope(unittest.TestCase): + PRIMARIES: ClassVar[tuple[str, ...]] = ( + "svc-ai-litellm", + "web-app-bigbluebutton", + "web-app-peertube", + "web-app-nextcloud", + ) + + def test_every_deployed_app_receives_its_declared_override(self) -> None: + overrides = get_variant_overrides_only(roles_dir=_ROLES_DIR) + for primary in self.PRIMARIES: + for round_index, _inv, round_variants, _include, *_ in _rounds(primary): + scope = variant_scope(primary, variants=round_variants) + payloads = _resolve_variant_payloads( + roles_dir=_ROLES_DIR, + include=scope, + active_variants=round_variants, + ) + for app in derive_includes(primary, variants=round_variants): + declared = overrides.get(app) or [] + if not declared: + continue + index = round_variants.get(app, 0) + if not 0 <= index < len(declared): + index = 0 + if not declared[index] or app in _EXPLICIT_INCLUDES: + continue + self.assertIn( + app, + payloads, + f"{primary}#{round_index} deploys {app}, whose variants.yml " + f"declares an override, but the inventory bakes none, so it " + f"silently runs on its base config", + ) + + def test_a_dependency_keeps_its_override_when_the_include_collapses(self) -> None: + primary = "svc-ai-litellm" + for round_index, _inv, round_variants, round_include, *_ in _rounds(primary): + if "web-app-keycloak" not in variant_scope( + primary, variants=round_variants + ): + continue + payloads = _resolve_variant_payloads( + roles_dir=_ROLES_DIR, + include=variant_scope(primary, variants=round_variants), + active_variants=round_variants, + ) + realm = ( + payloads.get("web-app-keycloak", {}) + .get("services", {}) + .get("keycloak", {}) + .get("realm", {}) + ) + self.assertIs( + realm.get("totp_enabled"), + False, + f"{primary}#{round_index} deploys keycloak with " + f"{len(round_include)} planned app(s); without its variant override " + f"the realm demands CONFIGURE_TOTP and every SSO persona times out", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/roles/svc-net-tor/test_container_dns_wiring.py b/tests/integration/roles/svc-net-tor/test_container_dns_wiring.py index 7f0dc0d3c7..4ed72e9dd0 100644 --- a/tests/integration/roles/svc-net-tor/test_container_dns_wiring.py +++ b/tests/integration/roles/svc-net-tor/test_container_dns_wiring.py @@ -1,5 +1,6 @@ import unittest +from ansible.module_utils.parsing.convert_bool import boolean as ansible_boolean from jinja2 import Environment, FileSystemLoader from tests.integration.roles import PROJECT_ROOT @@ -143,9 +144,24 @@ class TestStaticBindSurvivesBoot(unittest.TestCase): names = [t.get("name") for t in tasks] start = next(t for t in tasks if t.get("name") == START_TASK) self.assertLess(names.index(DROPIN_BLOCK), names.index(START_TASK)) + + reload_expression = start["ansible.builtin.systemd"]["daemon_reload"] + env = Environment(autoescape=False) # noqa: S701 - renders a boolean, not markup + env.filters["bool"] = ansible_boolean + env.tests["changed"] = lambda result: bool(result.get("changed", False)) + self.assertEqual( - start["ansible.builtin.systemd"]["daemon_reload"], - "{{ _tor_dnsmasq_dropin is changed }}", + env.from_string(reload_expression).render( + TOR_DNSMASQ_OWNS_LISTENER=True, _tor_dnsmasq_dropin={"changed": True} + ), + "True", + "a rewritten drop-in has to reach systemd before the unit restarts", + ) + self.assertEqual( + env.from_string(reload_expression).render(TOR_DNSMASQ_OWNS_LISTENER=False), + "False", + "the drop-in task is gated on owning the listener, so the reload " + "expression must not reach for its register when it was skipped", ) def test_the_bridge_fact_is_live_before_anything_binds_it(self): diff --git a/tests/lint/ansible/jinja/test_compose_ports_use_container_ports_lookup.py b/tests/lint/ansible/jinja/test_compose_ports_use_container_ports_lookup.py index 8e1758182f..d66c3464c9 100644 --- a/tests/lint/ansible/jinja/test_compose_ports_use_container_ports_lookup.py +++ b/tests/lint/ansible/jinja/test_compose_ports_use_container_ports_lookup.py @@ -7,8 +7,8 @@ declared ``ports.local`` / ``ports.public`` (host) and ``ports.internal`` (container) values, so the published and container ports stay one source of truth: - {{ lookup('container_ports', ['gitea', 'http'], ['gitea', 'ssh'], - ip=DOCKER_BIND_HOST) | indent(4) }} + {{ lookup('container_ports', ['gitea', 'http', DOCKER_BIND_HOST], + ['gitea', 'ssh']) | indent(4) }} Hand-writing the mapping ``- "{{ ... }}:{{ lookup('config', ..., 'services..ports.local.') }}:..."`` @@ -37,31 +37,94 @@ from . import PROJECT_ROOT _RULE = "compose-ports-must-use-container-ports" -_PORT_SEGMENT = re.compile(r"\A(?:\{\{.*\}\}|[\d.]+|\d+/(?:tcp|udp))\Z") +_PORT_SEGMENT = re.compile( + r"\A(?:\{\{.*\}\}|\$\{[^}]*\}|[\d.]+|[\d-]+)(?:/(?:tcp|udp))?\Z", +) -_LIST_ITEM = re.compile(r'-\s*"([^"]*)"') +_LIST_ITEM = re.compile(r"""-\s*["']?(.*?)["']?\s*(?:\#.*)?\Z""") + +_LONG_SYNTAX_KEY = re.compile(r"\A-?\s*(?:target|published|mode|protocol):") + +_BLOCK_KEY = re.compile(r"\A(ports|expose):\s*(.*)\Z") _PAIR = re.compile(r"\[\s*'([\w-]+)'\s*,\s*'([\w-]+)'\s*\]") def _is_scan_target(rel_path: str) -> bool: - if not rel_path.startswith("roles/") or "/templates/" not in rel_path: - return False - name = Path(rel_path).name - return name.endswith(".yml.j2") and "compose" in name + """Every role template that can carry a compose ``ports:`` block. + + ``compose.yml.j2`` and its ``compose..yml.j2`` siblings, plus the + ``*.yml.j2`` fragments they include: a mapping hand-written in an included + file publishes exactly the same port as one written inline. + """ + return ( + rel_path.startswith("roles/") + and "/templates/" in rel_path + and rel_path.endswith(".yml.j2") + ) -def _is_raw_port_mapping(line: str) -> bool: +def _is_raw_port_mapping(line: str, *, mapping_only: bool = False) -> bool: + """Whether *line* publishes a port by hand, quoted or not. + + ``mapping_only`` drops the single-segment form. Under ``expose:`` that form + names a container port for documentation and publishes nothing, so only a + ``host:container`` mapping there is a publication. + """ match = _LIST_ITEM.match(line.strip()) if match is None: return False - inner = match.group(1) - if "=" in inner: + inner = match.group(1).strip() + if not inner or "=" in inner: return False - segments = inner.split(":") - return len(segments) >= 2 and all( - _PORT_SEGMENT.match(segment.strip()) for segment in segments - ) + segments = [segment.strip() for segment in inner.split(":")] + if not all(_PORT_SEGMENT.match(segment) for segment in segments): + return False + if len(segments) >= 2: + return True + return not mapping_only and bool(_PORT_SEGMENT.match(inner)) + + +def _raw_port_lines(lines: list[str]) -> list[int]: + """1-based line numbers that publish a port by hand. + + The block is tracked by the indentation of its ``ports:`` / ``expose:`` key + rather than by the next key seen. Keying on the next line closed the block + on anything shaped like a key, which the long-syntax members ``target:``, + ``published:``, ``protocol:`` and ``mode:`` are, so the block could never + end while it was being read; blank, comment and ``{% %}`` lines equally have + no say in where a YAML block ends. + """ + found: list[int] = [] + block_indent: int | None = None + mapping_only = False + for number, line in enumerate(lines, start=1): + stripped = line.strip() + if not stripped or stripped.startswith(("#", "{%")): + continue + indent = len(line) - len(line.rstrip("\n").lstrip()) + if ( + block_indent is not None + and indent <= block_indent + and not stripped.startswith("- ") + ): + block_indent = None + if block_indent is None: + opener = _BLOCK_KEY.match(stripped) + if opener: + block_indent = indent + mapping_only = opener.group(1) == "expose" + inline = opener.group(2).strip().strip("[]") + if inline and _is_raw_port_mapping( + "- " + inline, mapping_only=mapping_only + ): + found.append(number) + continue + if _LONG_SYNTAX_KEY.match(stripped) or _is_raw_port_mapping( + line, mapping_only=mapping_only + ): + found.append(number) + return found class TestComposePortsUseContainerPortsLookup(unittest.TestCase): @@ -75,27 +138,11 @@ class TestComposePortsUseContainerPortsLookup(unittest.TestCase): if not _is_scan_target(rel): continue lines = content.splitlines() - in_ports_block = False - for idx, line in enumerate(lines): - stripped = line.strip() - if not stripped: - continue - if stripped.startswith("- "): - if ( - in_ports_block - and _is_raw_port_mapping(line) - and not is_suppressed_at( - lines, idx + 1, _RULE, mode="same-or-above" - ) - ): - findings.append((rel, idx + 1, stripped)) - continue - if stripped.startswith("{{"): - in_ports_block = "container_ports" in stripped - continue - key = re.match(r"[\w-]+:", stripped) - if key: - in_ports_block = key.group(0) == "ports:" + findings.extend( + (rel, number, lines[number - 1].strip()) + for number in _raw_port_lines(lines) + if not is_suppressed_at(lines, number, _RULE, mode="same-or-above") + ) if findings: formatted = "\n".join( @@ -107,8 +154,8 @@ class TestComposePortsUseContainerPortsLookup(unittest.TestCase): "Build the `ports:` block with the container_ports lookup instead, " "so the published (local/public) and container (internal) ports stay " "a single declared source of truth:\n\n" - " {{ lookup('container_ports', ['', ''],\n" - " ip=DOCKER_BIND_HOST) | indent(4) }}\n\n" + " {{ lookup('container_ports',\n" + " ['', '', DOCKER_BIND_HOST]) | indent(4) }}\n\n" "One ['', ''] pair per published port; the lookup reads " "ports.local/public for the host side and ports.internal for the " "container side. Mark with " diff --git a/tests/lint/ansible/jinja/test_firewall_fragment_verdicts.py b/tests/lint/ansible/jinja/test_firewall_fragment_verdicts.py new file mode 100644 index 0000000000..7c0fefc9a8 --- /dev/null +++ b/tests/lint/ansible/jinja/test_firewall_fragment_verdicts.py @@ -0,0 +1,79 @@ +"""Lint: a firewall fragment may tighten, never overrule. + +A fragment declares its own nftables table. In nftables an ``accept`` verdict +terminates that table's base chain and nothing else: the packet still traverses +every other base chain registered at the same hook, and a ``drop`` in any of +them wins. Docker sets the filter FORWARD policy to ``drop`` by default, so a +fragment that accepts is not permitting anything, it is only declining to +decide. + +Measured, not assumed: converting a role's ``iptables -A FORWARD -i wg0 -j +ACCEPT`` into a fragment rule at priority -10 dropped every forwarded packet +under that policy, 0 of 3 against 3 of 3 for the rule it replaced, with the +fragment's own counter showing the accept had matched all three. The deploy was +green either way. + +A rule that has to overrule a policy another component set belongs in the chain +that carries that policy, not in a fragment. + +Suppression (see ``docs/contributing/actions/testing/suppression.md``): + +* ``nocheck: firewall-fragment-accept`` on the rule's line or the one above it, + for an accept whose only job is to skip the fragment's own later rules. +""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +from utils.annotations.suppress import is_suppressed_at +from utils.cache.files import iter_project_files, read_text + +from . import PROJECT_ROOT + +_RULE = "firewall-fragment-accept" +_FRAGMENT = "templates/nftables.conf.j2" +_POLICY = re.compile(r"\bpolicy\s+accept\b") +_VERDICT = re.compile(r"\baccept\s*$") + + +def _fragments() -> list[str]: + suffix = "/" + _FRAGMENT + return sorted( + path + for path in iter_project_files(extensions=(".j2",), exclude_tests=True) + if path.endswith(suffix) + ) + + +class TestFirewallFragmentVerdicts(unittest.TestCase): + def test_no_fragment_rule_accepts(self) -> None: + findings: list[str] = [] + for path in _fragments(): + rel = Path(path).relative_to(PROJECT_ROOT).as_posix() + lines = read_text(path).splitlines() + for index, line in enumerate(lines): + body = line.split("#")[0].rstrip().rstrip(";") + if _POLICY.search(body) or not _VERDICT.search(body): + continue + if is_suppressed_at(lines, index + 1, _RULE, mode="same-or-above"): + continue + findings.append(f"- {rel}:{index + 1}: {line.strip()}") + + if findings: + self.fail( + "These fragment rules end in an accept verdict, which terminates " + "only this table's base chain. Another chain at the same hook can " + "still drop the packet, so the rule permits nothing:\n" + + "\n".join(findings) + + "\n\nFix: express the intent as a drop of what must not pass, or " + "put the rule in the chain that carries the policy it has to " + f"overrule. Mark it `nocheck: {_RULE}` when the accept only skips " + "the fragment's own later rules." + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lint/ansible/jinja/test_shell_templates_are_valid.py b/tests/lint/ansible/jinja/test_shell_templates_are_valid.py new file mode 100644 index 0000000000..921e159c9e --- /dev/null +++ b/tests/lint/ansible/jinja/test_shell_templates_are_valid.py @@ -0,0 +1,145 @@ +"""Lint: a shell script shipped as a Jinja template still gets checked. + +``make lint-shellcheck`` walks files on disk, so a ``*.sh.j2`` is invisible to +it: the thing that actually runs on the host is the render, and nothing looks at +the render until it fails on a node. That is an unpleasant place to discover a +quoting mistake in a script which runs as root and rewrites the host packet +filter. + +Every ``roles/*/templates/*.sh.j2`` is rendered here and handed to ``bash -n``, +plus ``shellcheck`` where it is installed. A value the lint does not know +renders as a placeholder token and an unknown collection renders as empty, so +the check judges the shape of the script rather than what a particular +deployment puts in it. Where a template's structure only becomes real with +concrete values, those live in ``_SAMPLE``. + +``SC2034`` is excluded: emptying a conditional branch orphans the variables that +branch used, so a partial render cannot tell a genuinely unused variable from +one whose only reader was not rendered. +""" + +from __future__ import annotations + +import shlex +import shutil +import subprocess +import unittest +from pathlib import Path + +import jinja2 + +from utils.cache.files import iter_project_files, read_text + +from . import PROJECT_ROOT + +_PARTIAL_RENDER_NOISE = "SC2034" + + +class _PassthroughFilters(dict): + """Any filter this environment does not know leaves the value untouched. + + The repo's own filter plugins live in ansible, not in bare jinja2, and the + check is about the shape of the resulting shell rather than about what a + filter computes. + """ + + def __missing__(self, name: str): + def _identity(value: object, *_args: object, **_kwargs: object) -> object: + return value + + return _identity + + def __contains__(self, name: object) -> bool: + """Claim every filter, because jinja resolves them at compile time.""" + return True + + def get(self, name: object, default: object = None) -> object: + """Jinja looks filters up with ``get``, which skips ``__missing__``.""" + return self[name] if name in dict.keys(self) else self.__missing__(str(name)) + + +class _Placeholder(jinja2.Undefined): + """An unknown value that keeps the rendered script syntactically whole.""" + + def __str__(self) -> str: + return "placeholder" + + def __iter__(self): + return iter(()) + + def __bool__(self) -> bool: + return False + + def __getattr__(self, name: str) -> _Placeholder: + return self + + def __getitem__(self, key: object) -> _Placeholder: + return self + + +_SAMPLE: dict[str, object] = {} + + +def _shell_templates() -> list[Path]: + found = [] + for path_str in iter_project_files(extensions=(".j2",)): + rel = Path(path_str).relative_to(PROJECT_ROOT).as_posix() + if rel.startswith("roles/") and rel.endswith(".sh.j2"): + found.append(Path(path_str)) + return sorted(found) + + +class TestShellTemplatesAreValid(unittest.TestCase): + def test_every_shell_template_renders_to_valid_bash(self) -> None: + templates = _shell_templates() + self.assertTrue(templates, "no roles/*/templates/*.sh.j2 found to check") + shellcheck = shutil.which("shellcheck") + for path in templates: + rel = path.relative_to(PROJECT_ROOT).as_posix() + with self.subTest(template=rel): + environment = jinja2.Environment( + undefined=_Placeholder, + keep_trailing_newline=True, + autoescape=False, # noqa: S701 shell, not markup + ) + environment.filters = _PassthroughFilters(environment.filters) + environment.filters["quote"] = shlex.quote + environment.globals["lookup"] = lambda *_args, **_kwargs: "placeholder" + rendered = environment.from_string(read_text(str(path))).render( + **_SAMPLE + ) + syntax = subprocess.run( + ["bash", "-n"], + input=rendered, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + syntax.returncode, + 0, + f"{rel} does not render to valid bash:\n{syntax.stderr}", + ) + if shellcheck is None: + continue + report = subprocess.run( + [ + shellcheck, + "--shell=bash", + f"--exclude={_PARTIAL_RENDER_NOISE}", + "-", + ], + input=rendered, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + report.returncode, + 0, + f"{rel} fails shellcheck once rendered:\n{report.stdout}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lint/ansible/jinja/test_software_identity_variables.py b/tests/lint/ansible/jinja/test_software_identity_variables.py new file mode 100644 index 0000000000..dde53d1aef --- /dev/null +++ b/tests/lint/ansible/jinja/test_software_identity_variables.py @@ -0,0 +1,91 @@ +"""Lint: a template spells the software's identity through its variables. + +``SOFTWARE_NAME``, ``SOFTWARE_DOMAIN``, ``SOFTWARE_URL`` and ``ORGANIZATION`` +are declared once in ``group_vars/all/00_general.yml`` and derive from each +other. A template that writes the value instead of the variable forks that +single point: a rename reaches the four declarations and every consumer that +reads them, and silently misses the literal. + +The check needs one literal only. ``SOFTWARE_DOMAIN`` is ``SOFTWARE_NAME | +lower``, ``SOFTWARE_URL`` is ``https://`` plus the domain and ``ORGANIZATION`` +is the name again, so a case-insensitive search for the name's value finds the +value of all four. The value is read from the declaration rather than written +here, so renaming the software does not leave this lint hunting the old string. + +Suppression (see ``docs/contributing/actions/testing/suppression.md``): + +* ``nocheck: software-identity`` on the offending line or the one above it, + for a literal that must survive a rename, such as an identifier a third + party already issued under the old name. +""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +from utils.annotations.suppress import is_suppressed_at +from utils.cache.files import iter_project_files, read_text +from utils.cache.yaml import load_yaml_any + +from . import PROJECT_ROOT + +_RULE = "software-identity" +_SPOT = "group_vars/all/00_general.yml" +_NAME_KEY = "SOFTWARE_NAME" + + +def _software_name() -> str: + """The literal value of ``SOFTWARE_NAME``, read from its declaration.""" + declared = load_yaml_any(str(PROJECT_ROOT / _SPOT)) + if not isinstance(declared, dict) or _NAME_KEY not in declared: + raise AssertionError(f"{_SPOT} declares no {_NAME_KEY}") + name = declared[_NAME_KEY] + if not isinstance(name, str) or "{{" in name: + raise AssertionError( + f"{_SPOT}: {_NAME_KEY} is no longer a plain literal ({name!r}), so " + "this lint can no longer derive what to search for" + ) + return name + + +def _variable_for(line: str, start: int, matched: str) -> str: + if line[:start].endswith(("https://", "http://")): + return "SOFTWARE_URL" + if matched.islower(): + return "SOFTWARE_DOMAIN" + return f"{_NAME_KEY} (or ORGANIZATION)" + + +class TestSoftwareIdentityVariables(unittest.TestCase): + def test_no_template_spells_out_the_software_identity(self) -> None: + pattern = re.compile(re.escape(_software_name()), re.IGNORECASE) + + findings: list[str] = [] + for path in iter_project_files(extensions=(".j2",), exclude_tests=True): + rel = Path(path).relative_to(PROJECT_ROOT).as_posix() + lines = read_text(path).splitlines() + for index, line in enumerate(lines): + match = pattern.search(line) + if match is None: + continue + if is_suppressed_at(lines, index + 1, _RULE, mode="same-or-above"): + continue + variable = _variable_for(line, match.start(), match.group(0)) + findings.append( + f"- {rel}:{index + 1}: {match.group(0)!r} -> {{{{ {variable} }}}}" + ) + + if findings: + self.fail( + "These templates write the software's identity as a literal, so a " + f"rename in {_SPOT} would leave them behind:\n" + + "\n".join(findings) + + f"\n\nFix: render the declared variable instead. Mark it " + f"`nocheck: {_RULE}` when the literal must survive a rename." + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lint/ansible/modules/test_systemd_units_via_sys_service.py b/tests/lint/ansible/modules/test_systemd_units_via_sys_service.py new file mode 100644 index 0000000000..4edb292bfa --- /dev/null +++ b/tests/lint/ansible/modules/test_systemd_units_via_sys_service.py @@ -0,0 +1,80 @@ +"""Lint: a systemd unit is installed through sys-service, not by hand. + +``sys-service`` owns what it takes to make a unit real: resolving the template, +writing it into the system unit directory, reloading the daemon, enabling and +starting it, the timer variant, and the async and flush behaviour the deploy +relies on. A role that renders its own ``*.service.j2`` with a bare ``template:`` +gets the file and none of the rest, and every one of those omissions is silent โ€” +a unit that exists but was never enabled looks installed until the host reboots. + +It is also how the same decisions end up answered differently per role. That is +not hypothetical: the role this lint was written alongside rendered its own unit +and its own reload handler before anyone noticed sys-service already did both. + +Suppression (see ``docs/contributing/actions/testing/suppression.md``): + +* ``# nocheck: systemd-via-sys-service`` on the ``src:`` line or anywhere in the + task body, for a unit sys-service genuinely cannot carry. +""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +from utils.annotations.suppress import is_suppressed_at +from utils.cache.files import iter_project_files_with_content + +from . import PROJECT_ROOT + +_RULE = "systemd-via-sys-service" +_OWNER = "sys-service" +_UNIT_SRC = re.compile(r"^\s*src:\s*[\"']?(?P[^\"'\s]+\.service\.j2)[\"']?") + + +def _is_task_file(rel_path: str) -> bool: + return ( + rel_path.startswith("roles/") + and "/tasks/" in rel_path + and rel_path.endswith((".yml", ".yaml")) + ) + + +def _role_of(rel_path: str) -> str: + return Path(rel_path).parts[1] + + +class TestSystemdUnitsViaSysService(unittest.TestCase): + def test_no_role_installs_its_own_unit(self) -> None: + findings: list[str] = [] + for path_str, content in iter_project_files_with_content( + extensions=(".yml", ".yaml"), + exclude_tests=True, + ): + rel = Path(path_str).relative_to(PROJECT_ROOT).as_posix() + if not _is_task_file(rel) or _role_of(rel) == _OWNER: + continue + lines = content.splitlines() + for index, line in enumerate(lines): + match = _UNIT_SRC.match(line) + if match is None: + continue + if is_suppressed_at(lines, index + 1, _RULE, mode="same-or-above"): + continue + findings.append(f"- {rel}:{index + 1}: {match.group('src')}") + + if findings: + self.fail( + "These roles render a systemd unit themselves instead of handing " + "it to sys-service, which also reloads the daemon, enables the " + "unit, and carries the timer, async and flush behaviour:\n" + + "\n".join(sorted(set(findings))) + + "\n\nFix: name the template templates/systemctl.service.j2 and " + "include the sys-service role with system_service_id, or mark the " + f"task `# nocheck: {_RULE}` when sys-service cannot carry it." + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lint/ansible/roles/meta/test_role_subnet_pool.py b/tests/lint/ansible/roles/meta/test_role_subnet_pool.py new file mode 100644 index 0000000000..b18f584947 --- /dev/null +++ b/tests/lint/ansible/roles/meta/test_role_subnet_pool.py @@ -0,0 +1,97 @@ +"""Lint: a role's local subnet comes out of the declared pool. + +``NETWORK_ROLE_SUBNET_POOL`` in ``group_vars/all/08_networks.yml`` names the +block role networks are carved from. Until it existed the block was an emergent +property: the allocator in ``cli contributing network address suggest`` derives +its umbrella from the subnets already in use, so the tree defined the rule +instead of the rule defining the tree. Anything that consumes "our own address +space" then has to guess, and a guess that is one role out of date silently +excludes that role. That is not hypothetical: a firewall range derived from the +pool by eye would have cut BigBlueButton off, because its subnet is the one +that sits outside. + +A role that genuinely cannot choose its block is named in +``NETWORK_ROLE_SUBNET_EXCEPTION_ROLES``. The list carries role ids rather than +CIDRs on purpose: the block is already declared in that role's +``meta/networks.yml``, and repeating it here would be the same duplication one +layer up. Consumers resolve the ids through ``lookup('role_subnets', ...)``. +""" + +from __future__ import annotations + +import ipaddress +import unittest + +import jinja2 + +from utils.cache.yaml import load_yaml_any +from utils.meta.scan import iter_subnets + +from . import PROJECT_ROOT + +NETWORKS_FILE = PROJECT_ROOT / "group_vars" / "all" / "08_networks.yml" + + +def _declared_space() -> tuple[list[ipaddress.IPv4Network], list[str]]: + """The pool plus the blocks of the excepted roles, with their raw strings.""" + data = load_yaml_any(str(NETWORKS_FILE), default_if_missing={}) or {} + pool = data.get("NETWORK_ROLE_SUBNET_POOL") + if isinstance(pool, str) and "{{" in pool: + pool = jinja2.Template(pool).render(**data) + roles = data.get("NETWORK_ROLE_SUBNET_EXCEPTION_ROLES") or [] + if not isinstance(pool, str) or not isinstance(roles, list): + raise TypeError( + f"{NETWORKS_FILE} must declare NETWORK_ROLE_SUBNET_POOL as a string " + "and NETWORK_ROLE_SUBNET_EXCEPTION_ROLES as a list of role ids" + ) + declared = dict(iter_subnets()) + raw = [pool] + for role in roles: + subnet = declared.get(str(role)) + if subnet is None: + raise AssertionError( + f"{NETWORKS_FILE}: NETWORK_ROLE_SUBNET_EXCEPTION_ROLES names " + f"'{role}', which declares no networks.local.subnet" + ) + raw.append(str(subnet)) + return [ipaddress.ip_network(entry, strict=True) for entry in raw], raw + + +class TestRoleSubnetPool(unittest.TestCase): + def test_every_role_subnet_sits_in_the_declared_space(self) -> None: + allowed, raw = _declared_space() + findings = [ + f"- {role}: {subnet}" + for role, subnet in sorted(iter_subnets(), key=lambda item: item[0]) + if not any(subnet.subnet_of(block) for block in allowed) + ] + if findings: + self.fail( + "These roles declare networks.local.subnet outside the address " + "space this deployment declares for itself:\n" + + "\n".join(findings) + + "\n\nDeclared space: " + + ", ".join(raw) + + "\n\nFix: pick a subnet inside the pool via " + "`infinito contributing network address suggest`, or, when the " + "block is pinned by something outside this repository, name the " + "role in NETWORK_ROLE_SUBNET_EXCEPTION_ROLES in " + "group_vars/all/08_networks.yml." + ) + + def test_the_exceptions_are_not_already_covered(self) -> None: + """An exception inside the pool is dead weight that outlives its reason.""" + allowed, raw = _declared_space() + pool = allowed[0] + redundant = [entry for entry in allowed[1:] if entry.subnet_of(pool)] + if redundant: + self.fail( + "NETWORK_ROLE_SUBNET_EXCEPTION_ROLES names role(s) whose subnet " + f"already sits inside {raw[0]}: " + f"{', '.join(str(entry) for entry in redundant)}. " + "Remove them; the pool already admits those addresses." + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lint/ansible/roles/test_addon_enabled_renders_to_bool.py b/tests/lint/ansible/roles/test_addon_enabled_renders_to_bool.py new file mode 100644 index 0000000000..b69de637d4 --- /dev/null +++ b/tests/lint/ansible/roles/test_addon_enabled_renders_to_bool.py @@ -0,0 +1,137 @@ +"""Every addon ``enabled`` must render to a boolean, even on a bare scope. + +Rationale +========= +``meta/addons/*.yml`` is rendered inside the applications SPOT and consumed as +``plugin_value.enabled | bool``. An expression that cannot resolve does not +fail: ``utils.templating.ansible._templar_render_best_effort`` returns the +string untouched, and for a value carrying a non-env ``lookup(`` it refuses the +fallback as well. The template text reaches the filter, which coerces it to +False and emits:: + + [DEPRECATION WARNING]: The `bool` filter coerced invalid value + "{{ ... }}" (str) to False. + +The deploy gate fails on that warning, and the addon is off in every run that +did not warn loudly enough to be noticed. Run 34893363518, job 104153194109 +lost web-app-nextcloud this way. + +The check renders the whole applications map against a scope carrying only what +every run has -- group_vars plus the deployed role names -- and blanks ``API`` +to the most hostile override an inventory may legally write. Anything that +survives as template text resolves only against something the render scope does +not carry. +""" + +from __future__ import annotations + +import unittest +from typing import Any + +from ansible.parsing.dataloader import DataLoader +from ansible.template import Templar + +from utils.cache.applications import ( + _MERGED_APPLICATIONS_CACHE, + get_merged_applications, +) +from utils.cache.yaml import load_yaml_any + +from . import PROJECT_ROOT + +_ADDONS_GLOB = "*/meta/addons/*.yml" +_BOOL_LITERALS = frozenset( + {"true", "false", "yes", "no", "on", "off", "1", "0", "y", "n"} +) + + +def _role_names() -> list[str]: + return sorted( + path.name for path in (PROJECT_ROOT / "roles").iterdir() if path.is_dir() + ) + + +def _hostile_scope() -> dict[str, Any]: + variables: dict[str, Any] = {} + for path in sorted((PROJECT_ROOT / "group_vars" / "all").glob("*.yml")): + data = load_yaml_any(path) or {} + if isinstance(data, dict): + variables.update(data) + variables["group_names"] = _role_names() + variables["API"] = {} + return variables + + +def _rendered_addons() -> dict[str, dict[str, Any]]: + variables = _hostile_scope() + templar = Templar(loader=DataLoader(), variables=dict(variables)) + templar.available_variables = dict(variables) + + _MERGED_APPLICATIONS_CACHE.clear() + try: + applications = get_merged_applications( + variables=dict(variables), templar=templar + ) + finally: + _MERGED_APPLICATIONS_CACHE.clear() + + return { + app_id: config["addons"] + for app_id, config in applications.items() + if isinstance(config, dict) and isinstance(config.get("addons"), dict) + } + + +def _verdict(value: Any) -> str: + if isinstance(value, bool): + return "" + if not isinstance(value, str): + return f"is {type(value).__name__}, not a boolean" + if "{{" in value or "{%" in value: + return f"stayed template text: {value}" + if value.strip().lower() not in _BOOL_LITERALS: + return f"is not a boolean literal: {value!r}" + return "" + + +class TestAddonEnabledRendersToBool(unittest.TestCase): + def test_every_addon_enabled_resolves(self) -> None: + findings = [ + f" - roles/{app_id}/meta/addons/{addon_id}.yml: 'enabled' {verdict}" + for app_id, addons in _rendered_addons().items() + for addon_id, spec in sorted(addons.items()) + if isinstance(spec, dict) + for verdict in [_verdict(spec.get("enabled"))] + if verdict + ] + + if findings: + self.fail( + f"{len(findings)} addon gate(s) do not render to a boolean on a " + "scope without inventory extras, so '| bool' coerces the leftover " + "text to False and the deploy fails on the deprecation warning:\n" + + "\n".join(findings) + + "\n\nResolve the value through a lookup that carries its own " + "source instead of a variable the render scope may not hold: " + "lookup('api_enabled', '') gates on credentials, " + "lookup('config', ...) on another application's settings." + ) + + def test_the_scan_reaches_templated_gates(self) -> None: + """A scope that resolved nothing, or addons without gates, passes for free.""" + templated = [ + path + for path in sorted((PROJECT_ROOT / "roles").glob(_ADDONS_GLOB)) + if "{{" in str((load_yaml_any(path) or {}).get("enabled", "")) + ] + self.assertTrue( + templated, + "no addon declares a templated 'enabled', so the rule above is vacuous", + ) + + rendered = _rendered_addons() + self.assertTrue(rendered, "no application carries addons; nothing was checked") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lint/ansible/roles/test_no_no_log_in_test_roles.py b/tests/lint/ansible/roles/test_no_no_log_in_test_roles.py new file mode 100644 index 0000000000..a4528978cf --- /dev/null +++ b/tests/lint/ansible/roles/test_no_no_log_in_test_roles.py @@ -0,0 +1,100 @@ +"""Lint: a test role must never carry ``no_log``. + +``roles/test-*`` run only in CI, against a throwaway cluster whose credentials +are generated per deploy and thrown away with it. ``no_log`` there protects +nothing and costs the one thing a red test job is for: the output that says +what failed. A masked task reports ``the output has been hidden`` and the +operator has to re-run the whole matrix round to learn what a single assertion +saw. + +The rule is absolute, so it carries no ``nocheck`` escape hatch. When a test +task's output looks too sensitive to print, extract the field the assertion +needs instead of blinding the task. +""" + +from __future__ import annotations + +import re +import unittest +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from utils.annotations.message import in_github_actions, warning +from utils.cache.files import read_text + +from . import PROJECT_ROOT + +if TYPE_CHECKING: + from pathlib import Path + +_NO_LOG_RE = re.compile(r"^\s*no_log\s*:") + + +@dataclass(frozen=True) +class Finding: + path: str + line: int + text: str + + +def _collect_findings(root: Path) -> list[Finding]: + findings: list[Finding] = [] + for yaml_file in sorted(root.glob("roles/test-*/**/*.yml")): + try: + lines = read_text(str(yaml_file)).splitlines() + except OSError: + continue + rel = yaml_file.relative_to(root).as_posix() + findings.extend( + Finding(rel, number, line.strip()) + for number, line in enumerate(lines, start=1) + if _NO_LOG_RE.match(line) + ) + return findings + + +def _fix_hint(rel: str) -> str: + return ( + f"{rel} uses no_log in a test role. Test roles run in CI against " + "throwaway credentials, so no_log hides the failure output without " + "protecting anything. Drop it; if a value is genuinely too large or " + "noisy to print, extract the field the assertion reads." + ) + + +class TestNoNoLogInTestRoles(unittest.TestCase): + def test_test_roles_carry_no_no_log(self) -> None: + findings = _collect_findings(PROJECT_ROOT) + + for finding in findings: + warning( + _fix_hint(finding.path), + title="no_log in a test role", + file=finding.path, + line=finding.line, + ) + + if findings and not in_github_actions(): + print() + print(f"[WARNING] no_log in test roles ({len(findings)}):") + for finding in findings: + print(f"- {finding.path}:{finding.line}: {finding.text}") + + if findings: + self.fail( + f"{len(findings)} no_log entr(ies) in roles/test-*:\n" + + "\n".join(f"{f.path}:{f.line}: {_fix_hint(f.path)}" for f in findings) + ) + + def test_the_scan_reaches_the_test_roles(self) -> None: + """A glob that matches nothing would pass vacuously forever.""" + scanned = sorted(PROJECT_ROOT.glob("roles/test-*/**/*.yml")) + self.assertTrue( + scanned, + "no YAML found under roles/test-*; the glob no longer reaches the " + "test roles, so the rule above cannot fire", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lint/ansible/services/test_onion_port_flag.py b/tests/lint/ansible/services/test_onion_port_flag.py new file mode 100644 index 0000000000..04b839edc6 --- /dev/null +++ b/tests/lint/ansible/services/test_onion_port_flag.py @@ -0,0 +1,235 @@ +"""Lint: every host-bound port states whether it answers on the node onion. + +A port under ``ports.local`` or ``ports.public`` binds an address on the stack +host, which is what a ``HiddenServicePort`` can forward to. Whether it should be +forwarded is a decision with a cost in both directions: silently off leaves a +service unreachable that the operator believes is on the onion, and silently on +publishes a database, an admin interface or a SOCKS proxy to everyone holding +the onion address. Neither belongs to a default, so the flag is mandatory and +every ``false`` carries the reason it is false. + +The flag lives next to the numbers it decides about:: + + ports: + public: + smtp: 25 + smtps: 465 + onion: + smtp: true + smtps: false # nocheck: onion-flag no implicit-TLS listener when TLS is off + +Five groups are out of scope, because for them the answer is architectural +rather than per-role, and a hundred-odd copies of one sentence state it worse +than this paragraph does: + +* ``ports.internal`` lives in the container's network namespace, where no + host-side forward reaches it. +* the categories a hidden service cannot carry, listed and reasoned once in + ``plugins.lookup.tor_ports.UDP_ONLY_CATEGORIES``. +* ``http``, ``sso`` and ``websocket``, because the node onion already forwards + port 80 to the reverse proxy, which routes every vhost, every oauth2 hop and + every socket upgrade behind it. A direct forward there would bypass the proxy, + its auth and its CSP. +* the implicit-TLS variants, because Tor is already the transport encryption and + ``container_ports`` drops those listeners entirely when TLS is off, which an + onion deployment always is. +* an entity declaring ``exposed:``, which is the older opt-in for the same + decision and is tested per variant. Two flags for one decision is the trap. + +A flag naming a category the entity never declares is reported too: it reads as +a live forward and forwards nothing. + +Suppression (see ``docs/contributing/actions/testing/suppression.md``): + +* ``# nocheck: onion-flag`` plus a reason, on or directly above a ``false`` flag. +""" + +from __future__ import annotations + +import re +import unittest +from collections.abc import Mapping + +from plugins.lookup.tor_ports import UDP_ONLY_CATEGORIES +from utils.annotations.suppress import line_has_rule +from utils.cache.files import read_text +from utils.cache.yaml import load_yaml_any +from utils.meta.scan import iter_role_dirs +from utils.roles.mapping import ROLE_FILE_META_SERVICES + +_RULE = "onion-flag" +_HOST_BOUND_SCOPES = ("local", "public") +_PROXY_FRONTED_CATEGORIES = frozenset({"http", "sso", "websocket"}) +_IMPLICIT_TLS_CATEGORIES = frozenset({"smtps", "imaps", "pop3s", "ldaps"}) +_OUT_OF_SCOPE = ( + UDP_ONLY_CATEGORIES | _PROXY_FRONTED_CATEGORIES | _IMPLICIT_TLS_CATEGORIES +) +_MIN_REASON_CHARS = 10 + +_MARKER_RE = re.compile( + r"(?:noqa|nocheck)\s*:\s*" + r"(?:[a-z0-9][a-z0-9\-]*)(?:\s*,\s*[a-z0-9][a-z0-9\-]*)*(.*)$", + re.IGNORECASE, +) + + +def _host_bound_categories(ports: Mapping) -> set[str]: + """Category names of *ports* that bind a host address as a single TCP port. + + The proxy-fronted categories are skipped under ``local`` and kept under + ``public``, which is the difference between riding behind the edge proxy and + being it: 128 roles declare ``http``/``sso``/``websocket`` on the loopback, + and exactly one publishes ``http`` on the host. + """ + found: set[str] = set() + for scope in _HOST_BOUND_SCOPES: + declared = ports.get(scope) + if not isinstance(declared, Mapping): + continue + skipped = UDP_ONLY_CATEGORIES | _IMPLICIT_TLS_CATEGORIES + if scope == "local": + skipped = skipped | _PROXY_FRONTED_CATEGORIES + found.update( + category + for category, value in declared.items() + if isinstance(value, int) + and not isinstance(value, bool) + and category not in skipped + ) + return found + + +def _flag_line(lines: list[str], entity: str, category: str) -> int | None: + """1-based line of ``:`` inside *entity*'s ``onion:`` block.""" + in_entity = False + onion_indent = -1 + for number, raw in enumerate(lines, start=1): + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue + indent = len(raw) - len(raw.lstrip()) + if indent == 0: + in_entity = stripped.startswith(f"{entity}:") + onion_indent = -1 + continue + if not in_entity: + continue + if onion_indent >= 0 and indent <= onion_indent: + onion_indent = -1 + if stripped.startswith("onion:"): + onion_indent = indent + continue + if onion_indent >= 0 and stripped.startswith(f"{category}:"): + return number + return None + + +def _reason_for(lines: list[str], line_no: int) -> str | None: + """The suppression reason governing *line_no*, or None when unsuppressed.""" + candidates = [line_no - 1] + previous = line_no - 2 + while previous >= 0 and not lines[previous].strip(): + previous -= 1 + candidates.append(previous) + for index in candidates: + if index < 0 or not line_has_rule(lines[index], _RULE): + continue + match = _MARKER_RE.search(lines[index]) + return match.group(1).strip().lstrip("-:").strip() if match else "" + return None + + +class TestOnionPortFlag(unittest.TestCase): + def test_every_host_bound_port_declares_an_onion_flag(self) -> None: + findings: list[str] = [] + for role_dir in iter_role_dirs(): + path = role_dir / ROLE_FILE_META_SERVICES + if not path.is_file(): + continue + services = load_yaml_any(str(path), default_if_missing={}) + if not isinstance(services, Mapping): + continue + lines = read_text(str(path)).splitlines() + for entity, config in services.items(): + if not isinstance(config, Mapping): + continue + ports = config.get("ports") + if not isinstance(ports, Mapping) or "exposed" in config: + continue + findings.extend( + self._entity_findings(role_dir.name, entity, ports, lines) + ) + if findings: + self.fail( + "Every host-bound port must state whether it answers on the node " + "onion, via a boolean of the same category name under " + "`ports.onion`. A `false` flag needs `# nocheck: onion-flag` and " + "a reason on or above it.\n" + "\n".join(findings) + ) + + def _entity_findings( + self, + role: str, + entity: str, + ports: Mapping, + lines: list[str], + ) -> list[str]: + declared = _host_bound_categories(ports) + flags = ports.get("onion") + flags = flags if isinstance(flags, Mapping) else {} + where = f"roles/{role}/{ROLE_FILE_META_SERVICES}: {entity}" + findings = [ + f"{where}.ports.onion.{category}: missing, declare true or false" + for category in sorted(declared - set(flags)) + ] + findings.extend( + f"{where}.ports.onion.{category}: " + + ( + "out of scope, remove the flag" + if category in _OUT_OF_SCOPE + else "no such port is declared under " + " or ".join(_HOST_BOUND_SCOPES) + ) + for category in sorted(set(flags) - declared) + ) + for category in sorted(set(flags) & declared): + findings.extend( + self._flag_findings(where, entity, category, flags[category], lines) + ) + return findings + + def _flag_findings( + self, + where: str, + entity: str, + category: str, + flag: object, + lines: list[str], + ) -> list[str]: + at = f"{where}.ports.onion.{category}" + if not isinstance(flag, bool): + return [f"{at}: must be a literal true or false, not {flag!r}"] + if flag: + return [] + line_no = _flag_line(lines, entity, category) + if line_no is None: + return [f"{at}: is false but its line could not be located"] + reason = _reason_for(lines, line_no) + if reason is None: + return [ + ( + f"{at} (line {line_no}): false needs `# nocheck: {_RULE}` and " + f"a reason on or above it" + ) + ] + if len(reason) < _MIN_REASON_CHARS: + return [ + ( + f"{at} (line {line_no}): `# nocheck: {_RULE}` states no reason " + f"why the port stays off the onion" + ) + ] + return [] + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lint/ansible/tasks/test_loop_var_shadowing.py b/tests/lint/ansible/tasks/test_loop_var_shadowing.py new file mode 100644 index 0000000000..414434dad4 --- /dev/null +++ b/tests/lint/ansible/tasks/test_loop_var_shadowing.py @@ -0,0 +1,103 @@ +"""Lint: a ``loop_var`` may not take the name of a declared variable. + +Ansible warns and carries on: + + [WARNING]: The variable 'application_id' is already in use. + You should set the `loop_var` value in the `loop_control` option for the + task to something else to avoid variable collisions and unexpected + behavior. + +Carrying on is the problem. Inside the loop the original value is gone, so +every lookup, template and nested include that reads the name sees the loop +item instead. The task still succeeds, which is why this shows up as a warning +in a log nobody greps rather than as a failure. + +``application_id`` is the sharp case here: almost every role declares it and +``include_role`` carries it into whatever the loop body reaches, so a loop over +applications that names its item ``application_id`` silently repoints every +config lookup in the nested role. + +Declared means: a key in ``group_vars/**`` or in any role's ``vars/main.yml`` +or ``defaults/main.yml``. Cross-role includes are routine in this repository, +so a name another role declares is a name that can be in scope here. + +Suppression (see ``docs/contributing/actions/testing/suppression.md``): + +* ``nocheck: loop-var-shadow`` on the ``loop_var`` line or the one above it, + when the shadowing is deliberate and the loop body provably reads no other + meaning of the name. +""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +from utils.annotations.suppress import is_suppressed_at +from utils.cache.files import iter_project_files, read_text +from utils.cache.yaml import load_yaml_any + +from . import PROJECT_ROOT + +_RULE = "loop-var-shadow" +_LOOP_VAR = re.compile(r"^\s*loop_var:\s*[\"']?(?P[A-Za-z_][A-Za-z0-9_]*)") + + +def _declared_names() -> set[str]: + """Every variable name the project declares in a file Ansible loads.""" + names: set[str] = set() + for path in iter_project_files(extensions=(".yml", ".yaml"), exclude_tests=True): + rel = Path(path).relative_to(PROJECT_ROOT).as_posix() + is_group_var = rel.startswith("group_vars/") + is_role_var = re.fullmatch(r"roles/[^/]+/(vars|defaults)/main\.ya?ml", rel) + if not (is_group_var or is_role_var): + continue + loaded = load_yaml_any(path) + if isinstance(loaded, dict): + names.update(key for key in loaded if isinstance(key, str)) + return names + + +def _task_files() -> list[str]: + return sorted( + path + for path in iter_project_files(extensions=(".yml", ".yaml"), exclude_tests=True) + if "/tasks/" in Path(path).relative_to(PROJECT_ROOT).as_posix() + ) + + +class TestLoopVarShadowing(unittest.TestCase): + def test_no_loop_var_shadows_a_declared_variable(self) -> None: + declared = _declared_names() + self.assertTrue( + declared, "no variable declarations were found to compare against" + ) + + findings: list[str] = [] + for path in _task_files(): + rel = Path(path).relative_to(PROJECT_ROOT).as_posix() + lines = read_text(path).splitlines() + for index, line in enumerate(lines): + match = _LOOP_VAR.match(line) + if match is None or match.group("name") not in declared: + continue + if is_suppressed_at(lines, index + 1, _RULE, mode="same-or-above"): + continue + findings.append(f"- {rel}:{index + 1}: loop_var: {match.group('name')}") + + if findings: + self.fail( + "These loops name their item after a variable the project " + "declares, so inside the loop that variable carries the item " + "instead of its own value and every nested lookup reads the " + "wrong thing:\n" + + "\n".join(findings) + + "\n\nFix: give the loop item a name of its own, for example " + f"`loop_var: _item`, or mark it `nocheck: {_RULE}` when the " + "loop body provably reads no other meaning of the name." + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lint/ansible/variables/test_api_accessed_via_lookup.py b/tests/lint/ansible/variables/test_api_accessed_via_lookup.py new file mode 100644 index 0000000000..803ab4177d --- /dev/null +++ b/tests/lint/ansible/variables/test_api_accessed_via_lookup.py @@ -0,0 +1,105 @@ +"""``API`` is reachable through its lookups only, never as a bare variable. + +Rationale +========= +A bare ``API.github.client_id`` resolves against whatever the scope happens to +hold, and an inventory override replaces the declaration rather than merging +into it -- ``utils/api.py`` carries the mechanism and the consequences. +``lookup('api', '.')`` and ``lookup('api_enabled', '')`` +merge the override over ``group_vars/all/18_api.yml``, so an override narrows +values and never the provider set. Routing every access through them also keeps +one place to change how credentials are sourced. + +Scope +===== +Jinja in ``roles/``, ``group_vars/`` and ``inventories/``. An ``API:`` key +being *declared* is not an access; only a dereference inside ``{{ }}`` or +``{% %}`` is. Test fixtures legitimately build the bare form to exercise the +renderer, so ``tests/`` is out of scope. +""" + +from __future__ import annotations + +import re +import unittest +from dataclasses import dataclass +from pathlib import Path + +from utils.cache.files import iter_project_files_with_content + +from . import PROJECT_ROOT + +_SCOPES = ("roles", "group_vars", "inventories") +_SUFFIXES = (".yml", ".yaml", ".j2") +_JINJA_BLOCK = re.compile(r"\{\{(.*?)\}\}|\{%(.*?)%\}", re.DOTALL) +_BARE_API = re.compile(r"(? list[tuple[str, str]]: + prefixes = tuple(f"{scope}/" for scope in _SCOPES) + scoped: list[tuple[str, str]] = [] + for path, text in iter_project_files_with_content( + extensions=_SUFFIXES, exclude_tests=True + ): + rel = Path(path).relative_to(PROJECT_ROOT).as_posix() + if rel.startswith(prefixes): + scoped.append((rel, text)) + return sorted(scoped) + + +def _findings() -> list[Finding]: + findings: list[Finding] = [] + for rel, text in _scoped_files(): + for match in _JINJA_BLOCK.finditer(text): + expression = match.group(1) or match.group(2) or "" + if not _BARE_API.search(expression): + continue + line = text.count("\n", 0, match.start()) + 1 + findings.append(Finding(rel, line, " ".join(expression.split()))) + return findings + + +class TestApiAccessedViaLookup(unittest.TestCase): + def test_no_jinja_dereferences_api_directly(self) -> None: + findings = _findings() + + if findings: + self.fail( + f"{len(findings)} expression(s) read 'API' as a bare variable, which " + "breaks against an inventory that overrides only some providers:\n" + + "\n".join( + f" - {f.path}:{f.line}: {{{{ {f.expression} }}}}" for f in findings + ) + + "\n\nRead the value with lookup('api', '.') and gate " + "on lookup('api_enabled', '')." + ) + + def test_the_scan_reaches_jinja_in_every_scope(self) -> None: + """A suffix or scope typo would make the rule above pass forever.""" + scanned = _scoped_files() + for scope in _SCOPES: + with self.subTest(scope=scope): + blocks = sum( + len(_JINJA_BLOCK.findall(text)) + for rel, text in scanned + if rel.startswith(f"{scope}/") + ) + self.assertTrue(blocks, f"no Jinja found under {scope}/") + + def test_the_pattern_separates_a_dereference_from_a_declaration(self) -> None: + self.assertTrue(_BARE_API.search("API.github.client_id | length")) + self.assertTrue(_BARE_API.search("API['github']")) + self.assertFalse(_BARE_API.search("lookup('api', 'github.client_id')")) + self.assertFalse(_BARE_API.search("LITELLM_API.url")) + self.assertFalse(_BARE_API.search("item.API.key")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lint/filesystem/shell/test_no_shell_templates.py b/tests/lint/filesystem/shell/test_no_shell_templates.py new file mode 100644 index 0000000000..512c1656c5 --- /dev/null +++ b/tests/lint/filesystem/shell/test_no_shell_templates.py @@ -0,0 +1,64 @@ +"""Lint: a shell script ships as ``.sh``, not as ``.sh.j2``. + +A templated script is opaque to every tool that reads shell. ``shellcheck`` +parses Jinja as syntax errors, the repo's own ``.sh`` guards (``|| true``, +``sh -c pipefail``, inline PHP) skip the file because it is not a ``.sh``, and +nothing can execute it to test it: what runs on the host never exists in the +repository. A defect in the rendered half is found by deploying it. + +The variability belongs in the invocation, not in the text. ``sys-service`` +resolves ``files/shell/script.sh`` alongside ``templates/script.sh.j2``, and a +static script takes its values the way this repo's Python scripts already do: +as arguments on ``ExecStart``, or through ``Environment=``. + +Suppression (see ``docs/contributing/actions/testing/suppression.md``): + +* ``nocheck: shell-template`` anywhere in the first 30 lines, for a script whose + shape itself is generated rather than its values. Put the marker BELOW the + shebang, never above it, and prefer the Jinja comment form ``{# ... #}`` so it + does not land in the rendered script. State what is generated. + +None of the templates present when this guard landed meets that bar: each +interpolates values into a fixed script, and each is grandfathered with a marker +saying so, because converting a deployed script to argv changes what runs on the +host. The guard exists to stop the next one. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +from utils.annotations.suppress import is_suppressed_in_head +from utils.cache.files import iter_project_files, read_text + +from . import PROJECT_ROOT + +_RULE = "shell-template" +_SUFFIX = ".sh.j2" + + +class TestNoShellTemplates(unittest.TestCase): + def test_no_shell_script_is_a_template(self) -> None: + findings: list[str] = [] + for path in iter_project_files(extensions=(_SUFFIX,), exclude_tests=True): + lines = read_text(path).splitlines() + if is_suppressed_in_head(lines, _RULE): + continue + findings.append(f"- {Path(path).relative_to(PROJECT_ROOT).as_posix()}") + + if findings: + self.fail( + "These shell scripts are Jinja templates, so shellcheck, the " + "repository's .sh guards and every test read something other " + "than what runs on the host:\n" + + "\n".join(sorted(findings)) + + "\n\nFix: move the script to files/shell/ as a plain .sh and " + "pass its values as ExecStart arguments or Environment= entries, " + f"or mark it `nocheck: {_RULE}` in the file head when the script's " + "shape, not just its values, is generated." + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/group_vars/test_litellm_chat_model.py b/tests/unit/python/group_vars/test_litellm_chat_model.py index a348fb6268..67a8c4c902 100644 --- a/tests/unit/python/group_vars/test_litellm_chat_model.py +++ b/tests/unit/python/group_vars/test_litellm_chat_model.py @@ -11,6 +11,12 @@ from utils.cache.yaml import load_yaml _AI_VARS = Path(PROJECT_ROOT) / "group_vars" / "all" / "16_ai.yml" +def _ansible_bool(value): + if isinstance(value, str): + return value.strip().lower() in ("true", "yes", "on", "1") + return bool(value) + + def _stub_lookup(preload_models, lmstudio_models): def lookup(kind, role, path, *args): if (kind, path) == ("config", "services.ollama.preload_models"): @@ -38,6 +44,7 @@ class TestLitellmChatModel(unittest.TestCase): cls.env.tests["search"] = lambda value, pattern: bool( re.search(pattern, str(value)) ) + cls.env.filters["bool"] = _ansible_bool cls.source = load_yaml(_AI_VARS) def _render(self, name, *, roles, api_key, preload_models=(), lmstudio_models=()): @@ -45,6 +52,8 @@ class TestLitellmChatModel(unittest.TestCase): self.env.from_string(self.source[name]) .render( LITELLM_BACKEND_ROLES=list(roles), + LITELLM_OLLAMA_BACKEND=str("svc-ai-ollama" in roles), + LITELLM_LMSTUDIO_BACKEND=str("svc-ai-lmstudio" in roles), AI_REMOTE_ALIASES=(["openrouter/auto"] if api_key else []), lookup=_stub_lookup( [{"alias": alias, "name": alias} for alias in preload_models], diff --git a/tests/unit/python/plugins/lookup/test_api.py b/tests/unit/python/plugins/lookup/test_api.py index eaa1ecedbb..f9dc81bb27 100644 --- a/tests/unit/python/plugins/lookup/test_api.py +++ b/tests/unit/python/plugins/lookup/test_api.py @@ -90,9 +90,19 @@ class TestApiLookup(unittest.TestCase): self.lookup.run(["cloudflare.api_token"], variables={"API": "nope"}) self.assertIn("mapping", str(ctx.exception)) - def test_missing_api_global_raises_unknown_key(self): + def test_missing_api_global_falls_back_to_the_declaration(self): + self.assertEqual(self.lookup.run(["cloudflare.api_token"], variables={})[0], "") + + def test_a_provider_the_scope_omits_still_resolves(self): + out = self.lookup.run( + ["github.client_id"], + variables={"API": {"cloudflare": {"api_token": "cf"}}}, + ) + self.assertEqual(out, [""]) + + def test_an_undeclared_provider_still_raises(self): with self.assertRaises(AnsibleError): - self.lookup.run(["cloudflare.api_token"], variables={}) + self.lookup.run(["nope.api_key"], variables=self.vars) def test_templar_resolves_jinja_values(self): self.lookup._templar = _FakeTemplar(mapping={"{{ vault_cf }}": "secret-cf"}) diff --git a/tests/unit/python/plugins/lookup/test_container_ports.py b/tests/unit/python/plugins/lookup/test_container_ports.py index cf854230f9..385946855a 100644 --- a/tests/unit/python/plugins/lookup/test_container_ports.py +++ b/tests/unit/python/plugins/lookup/test_container_ports.py @@ -119,5 +119,133 @@ class TestContainerPortsLookup(unittest.TestCase): self.assertEqual(out, ['ports:\n - "10.0.0.1:8002:3000"']) +def _edge_apps(): + return { + "web-svc-coturn": { + "services": { + "coturn": { + "ports": { + "public": { + "stun_turn": 3481, + "relay": {"start": 20000, "end": 39999}, + } + } + }, + "openresty": { + "ports": { + "internal": {"http": 80}, + "public": {"http": 80}, + } + }, + "api": {"ports": {"internal": {"http": 5000}}}, + } + } + } + + +def _run_edge(terms): + lookup = LookupModule() + lookup._loader = mock.MagicMock() + apps = _edge_apps() + with patch("plugins.lookup.container_ports.lookup_loader") as loader_mock: + loader_mock.get.return_value = mock.MagicMock(run=lambda *_a, **_k: [apps]) + return lookup.run(terms, variables={"application_id": "web-svc-coturn"})[0] + + +class TestContainerPortsDictTerms(unittest.TestCase): + def test_one_list_item_per_transport(self): + out = _run_edge( + [ + { + "service": "coturn", + "protocol": "stun_turn", + "transport": ["udp", "tcp"], + "container": "same", + } + ] + ) + self.assertEqual(out, 'ports:\n - "3481:3481/udp"\n - "3481:3481/tcp"') + + def test_a_range_renders_on_both_sides(self): + out = _run_edge( + [ + { + "service": "coturn", + "protocol": "relay", + "transport": "udp", + "container": "same", + } + ] + ) + self.assertEqual(out, 'ports:\n - "20000-39999:20000-39999/udp"') + + def test_host_mode_emits_the_long_block(self): + out = _run_edge([{"service": "openresty", "protocol": "http", "mode": "host"}]) + self.assertEqual( + out, + "ports:\n - target: 80\n published: 80\n" + " protocol: tcp\n mode: host", + ) + + def test_ephemeral_publishes_only_the_container_port(self): + out = _run_edge( + [{"service": "api", "protocol": "http", "publish": "ephemeral"}] + ) + self.assertEqual(out, 'ports:\n - "5000"') + + def test_host_mode_rejects_an_ip(self): + with self.assertRaises(AnsibleError): + _run_edge( + [ + { + "service": "openresty", + "protocol": "http", + "mode": "host", + "ip": "10.0.0.1", + } + ] + ) + + def test_an_unknown_term_key_is_rejected(self): + with self.assertRaises(AnsibleError): + _run_edge([{"service": "openresty", "protocol": "http", "protokol": "tcp"}]) + + def test_key_names_the_block(self): + lookup = LookupModule() + lookup._loader = mock.MagicMock() + with patch("plugins.lookup.container_ports.lookup_loader") as loader_mock: + loader_mock.get.return_value = mock.MagicMock( + run=lambda *_a, **_k: [_edge_apps()] + ) + out = lookup.run( + [["openresty", "http"]], + variables={"application_id": "web-svc-coturn"}, + key="expose", + ) + self.assertEqual(out, ['expose:\n - "80:80"']) + + def test_an_unknown_block_key_is_rejected(self): + lookup = LookupModule() + lookup._loader = mock.MagicMock() + with ( + patch("plugins.lookup.container_ports.lookup_loader") as loader_mock, + self.assertRaises(AnsibleError), + ): + loader_mock.get.return_value = mock.MagicMock( + run=lambda *_a, **_k: [_edge_apps()] + ) + lookup.run( + [["openresty", "http"]], + variables={"application_id": "web-svc-coturn"}, + key="publish", + ) + + def test_an_unknown_transport_is_rejected(self): + with self.assertRaises(AnsibleError): + _run_edge( + [{"service": "openresty", "protocol": "http", "transport": "sctp"}] + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/python/plugins/lookup/test_sso_oidc_plugin.py b/tests/unit/python/plugins/lookup/test_sso_oidc_plugin.py index 8f6dc8c580..af94622a39 100644 --- a/tests/unit/python/plugins/lookup/test_sso_oidc_plugin.py +++ b/tests/unit/python/plugins/lookup/test_sso_oidc_plugin.py @@ -66,9 +66,28 @@ class OidcFlavorLookupTests(unittest.TestCase): self._patcher = mock.patch.object(self.mod, "lookup_loader") self._loader_mock = self._patcher.start() self._stub_payload = None - self._loader_mock.get.return_value = mock.MagicMock( - run=lambda *_a, **_k: [self._stub_payload] - ) + self._loader_mock.get.side_effect = self._stub_lookup + + def _stub_lookup(self, name, *_args, **_kwargs): + """Stand in for the lookups the plugin consumes. + + ``config`` resolves a dotted path against the payload the way + ``lookup('config', app, path, default)`` does with a default supplied; + anything else answers with the payload itself. + """ + if name == "config": + return mock.MagicMock( + run=lambda terms, **_kw: [self._stub_config(*terms)], + ) + return mock.MagicMock(run=lambda *_a, **_kw: [self._stub_payload]) + + def _stub_config(self, application_id, config_path, default=None): + node = (self._stub_payload or {}).get(application_id) + for part in str(config_path).split("."): + if not isinstance(node, dict) or part not in node: + return default + node = node[part] + return node def tearDown(self): self._patcher.stop() diff --git a/tests/unit/python/plugins/lookup/test_tor_ports.py b/tests/unit/python/plugins/lookup/test_tor_ports.py index 5be68ffe0a..67b7a5ad72 100644 --- a/tests/unit/python/plugins/lookup/test_tor_ports.py +++ b/tests/unit/python/plugins/lookup/test_tor_ports.py @@ -9,7 +9,7 @@ from ansible.errors import AnsibleError from plugins.lookup.tor_ports import ( LookupModule, collect_exposed_ports, - collect_public_ports, + collect_onion_ports, ) from utils.cache import _reset_cache_for_tests from utils.cache.yaml import dump_yaml_str @@ -33,7 +33,15 @@ class TestTorPortsLookup(unittest.TestCase): _write_services( self.roles_dir, "web-app-gitea", - {"gitea": {"ports": {"public": {"ssh": 2201}, "local": {"http": 8002}}}}, + { + "gitea": { + "ports": { + "public": {"ssh": 2201}, + "local": {"http": 8002}, + "onion": {"ssh": True}, + } + } + }, ) _write_services( self.roles_dir, @@ -68,40 +76,159 @@ class TestTorPortsLookup(unittest.TestCase): _reset_cache_for_tests() self._tmpdir.cleanup() - def test_collects_public_single_ints_sorted(self) -> None: - ports = collect_public_ports( - ["web-app-gitea", "svc-db-openldap"], self.roles_dir + @staticmethod + def _apps(**roles: dict) -> dict: + """The variant-merged applications view for the given roles. + + Every entity keeps its own ``ports``; the top-level ``tor:`` block of + ``meta/services.yml`` merges in as one more entity alongside them. + """ + return {role: {"services": dict(entities)} for role, entities in roles.items()} + + def test_only_named_categories_are_forwarded_sorted(self) -> None: + apps = self._apps( + **{ + "web-app-gitea": { + "gitea": { + "ports": {"public": {"ssh": 2201}, "onion": {"ssh": True}} + } + }, + "web-app-mailu": { + "mailu": { + "ports": { + "public": {"smtp": 25, "smtps": 465, "imap": 143}, + "onion": {"smtp": True}, + } + } + }, + } ) - self.assertEqual(ports, [636, 2201]) - def test_relay_ranges_excluded(self) -> None: - ports = collect_public_ports(["web-svc-coturn"], self.roles_dir) - self.assertEqual(ports, [3481, 5351]) + ports = collect_onion_ports(apps, ["web-app-gitea", "web-app-mailu"]) - def test_media_category_excluded(self) -> None: - _write_services( - self.roles_dir, - "web-app-jitsi", - {"jitsi": {"ports": {"public": {"media": 10000}, "local": {"http": 8084}}}}, + self.assertEqual(ports, [25, 2201]) + + def test_local_wins_over_public_for_the_same_category(self) -> None: + """``container_ports`` publishes the local port when the category has + one, so the forward must target that and not the public alias.""" + apps = self._apps( + **{ + "web-app-x": { + "x": { + "ports": { + "local": {"http": 8080}, + "public": {"http": 80}, + "onion": {"http": True}, + } + } + } + } ) - self.assertEqual(collect_public_ports(["web-app-jitsi"], self.roles_dir), []) - def test_roles_without_public_ports_yield_nothing(self) -> None: - self.assertEqual(collect_public_ports(["web-app-hugo"], self.roles_dir), []) + self.assertEqual(collect_onion_ports(apps, ["web-app-x"]), [8080]) - def test_tor_disabled_role_excluded(self) -> None: - _write_services( - self.roles_dir, - "web-app-optout", - { - "optout": {"ports": {"public": {"ssh": 2299}}}, - "tor": {"enabled": False}, - }, + def test_a_falsy_or_unrendered_flag_forwards_nothing(self) -> None: + """The input must be the rendered view: template text left in the flag + is not quietly read as enabled.""" + apps = self._apps( + **{ + "web-app-a": { + "a": {"ports": {"public": {"ssh": 22}, "onion": {"ssh": False}}} + }, + "web-app-b": { + "b": { + "ports": { + "public": {"ssh": 23}, + "onion": {"ssh": "{{ 'svc-net-tor' in group_names }}"}, + } + } + }, + } ) - self.assertEqual(collect_public_ports(["web-app-optout"], self.roles_dir), []) - def test_unknown_role_ignored(self) -> None: - self.assertEqual(collect_public_ports(["web-app-nope"], self.roles_dir), []) + self.assertEqual(collect_onion_ports(apps, ["web-app-a", "web-app-b"]), []) + + def test_udp_only_categories_are_never_forwarded(self) -> None: + apps = self._apps( + **{ + "web-svc-coturn": { + "coturn": { + "ports": { + "public": { + "media": 10000, + "stun_turn": 3481, + "stun_turn_tls": 5351, + "ssh": 2299, + "relay": {"start": 20000, "end": 39999}, + }, + "onion": { + "media": True, + "relay": True, + "stun_turn": True, + "stun_turn_tls": True, + "ssh": True, + }, + } + } + } + } + ) + + self.assertEqual(collect_onion_ports(apps, ["web-svc-coturn"]), [2299]) + + def test_a_category_with_no_declared_port_is_skipped(self) -> None: + apps = self._apps( + **{ + "web-app-x": { + "x": {"ports": {"public": {"ssh": 2201}, "onion": {"typo": True}}} + } + } + ) + + self.assertEqual(collect_onion_ports(apps, ["web-app-x"]), []) + + def test_a_role_without_an_onion_block_forwards_nothing(self) -> None: + apps = self._apps( + **{"web-app-hugo": {"hugo": {"ports": {"local": {"http": 8008}}}}} + ) + + self.assertEqual(collect_onion_ports(apps, ["web-app-hugo"]), []) + + def test_the_port_flag_is_the_gate_not_the_tor_flag(self) -> None: + """``services.tor.enabled`` is the dependency edge onto svc-net-tor, not + the port forward; ``ports.onion`` alone decides what gets a + HiddenServicePort.""" + apps = self._apps( + **{ + "web-app-gitea": { + "tor": {"enabled": False}, + "gitea": { + "ports": {"public": {"ssh": 2201}, "onion": {"ssh": True}} + }, + } + } + ) + + self.assertEqual(collect_onion_ports(apps, ["web-app-gitea"]), [2201]) + + def test_role_outside_the_deploy_is_ignored(self) -> None: + apps = self._apps( + **{ + "web-app-gitea": { + "gitea": { + "ports": {"public": {"ssh": 2201}, "onion": {"ssh": True}} + } + } + } + ) + + self.assertEqual(collect_onion_ports(apps, ["web-app-nope"]), []) + + def test_collect_onion_ports_ignores_non_mappings(self) -> None: + self.assertEqual(collect_onion_ports({}, ["a"]), []) + self.assertEqual(collect_onion_ports(None, ["a"]), []) + apps = {"a": {"services": {"s": {"ports": {"onion": True}}}}} + self.assertEqual(collect_onion_ports(apps, ["a"]), []) def test_collect_exposed_ports_gated_and_deployed(self) -> None: apps = { @@ -151,6 +278,14 @@ class TestTorPortsLookup(unittest.TestCase): )[0] self.assertEqual(result, [{"onion_port": 2201, "target": "127.0.0.1:2201"}]) + def test_run_forwards_nothing_without_an_onion_opt_in(self) -> None: + result = self.lookup.run( + [], + variables={"group_names": ["svc-db-openldap", "web-svc-coturn"]}, + roles_dir=str(self.roles_dir), + )[0] + self.assertEqual(result, []) + def test_empty_group_names(self) -> None: result = self.lookup.run([], variables={}, roles_dir=str(self.roles_dir))[0] self.assertEqual(result, []) diff --git a/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py b/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py index ef5fd8c093..e1728d0e72 100644 --- a/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py +++ b/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py @@ -28,6 +28,12 @@ OLLAMA_URL = "http://ollama:11434" LMSTUDIO_URL = "http://lmstudio:1234" +def _ansible_bool(value): + if isinstance(value, str): + return value.strip().lower() in ("true", "yes", "on", "1") + return bool(value) + + def _stub_lookup(ollama_models, lmstudio_models): def lookup(kind, role, path, *args): if (kind, path) == ("config", "services.ollama.preload_models"): @@ -66,14 +72,11 @@ def render(*, ollama=(), lmstudio=(), keys=None): lmstudio: preload entries svc-ai-lmstudio declares; empty means not deployed. keys: env-name -> provider key for the remote backends. """ - group_names = ["svc-ai-litellm"] - if ollama: - group_names.append("svc-ai-ollama") - if lmstudio: - group_names.append("svc-ai-lmstudio") env = Environment(undefined=StrictUndefined, autoescape=False) # noqa: S701 - YAML, not markup + env.filters["bool"] = _ansible_bool rendered = env.from_string(read_text(str(TEMPLATE))).render( - group_names=group_names, + LITELLM_OLLAMA_BACKEND=str(bool(ollama)), + LITELLM_LMSTUDIO_BACKEND=str(bool(lmstudio)), lookup=_stub_lookup(list(ollama), list(lmstudio)), OLLAMA_BASE_LOCAL_URL=OLLAMA_URL, LMSTUDIO_BASE_LOCAL_URL=LMSTUDIO_URL, diff --git a/tests/unit/python/roles/svc-net-firewall/__init__.py b/tests/unit/python/roles/svc-net-firewall/__init__.py new file mode 100644 index 0000000000..8ee16d7567 --- /dev/null +++ b/tests/unit/python/roles/svc-net-firewall/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/python/roles/svc-net-firewall/test_fragments.py b/tests/unit/python/roles/svc-net-firewall/test_fragments.py new file mode 100644 index 0000000000..99152196b5 --- /dev/null +++ b/tests/unit/python/roles/svc-net-firewall/test_fragments.py @@ -0,0 +1,236 @@ +"""Every role's nftables fragment parses, and replaces its table instead of merging. + +``nft -f`` on ``table inet x { ... }`` MERGES into an existing table: a rule +dropped from the declaration stays in the kernel, and the ruleset can only ever +grow. The three-line idiom below is what makes a fragment a replacement: + + table inet x <- creates it when absent, so the delete cannot fail + delete table inet x <- removes whatever the last deploy left + table inet x { ... } <- the declaration, applied in the same transaction + +``nft -f`` is atomic, so the table is never observed empty. Without the first +line the delete fails on a fresh host; without the second the fragment merges. + +The checks run the real ``nft`` over the rendered text, so a fragment that is +valid Jinja but invalid nftables fails here rather than on the host. They render +every deploy mode, because a fragment may branch on one, and feed plausible +rather than real values: nft needs shapes, not this host's addresses. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import tempfile +import unittest +from contextlib import contextmanager +from functools import lru_cache +from pathlib import Path + +import jinja2 + +from utils.cache.files import iter_project_files, read_text +from utils.cache.yaml import load_yaml_any +from utils.roles.mapping import ROLE_FILE_VARS_MAIN + +from . import PROJECT_ROOT + +FRAGMENT = "templates/nftables.conf.j2" +TABLE = "infinito_probe" + +PROBE_PORTS = { + "TOR_TRANS_PORT": "9040", + "TOR_DNS_PORT": "9053", + "TOR_SOCKS_PORT": "9050", + "TOR_DNSMASQ_PORT": "53", +} + +_PORT_VARIABLE = re.compile(r"\{\{\s*([A-Z][A-Z0-9_]*)\s*\}\}") + + +def _guarded_ports() -> list[dict]: + """The role's own guard list, with its port variables filled from stand-ins. + + The shape has to come from the declaration: an entry that gains a key, a + protocol or a whole port must reach the rendered table here, or this check + keeps passing on a fixture the role stopped shipping. + """ + declared = load_yaml_any( + str(PROJECT_ROOT / "roles" / "svc-net-tor" / ROLE_FILE_VARS_MAIN) + )["TOR_EGRESS_GUARDED_PORTS"] + return [ + {**entry, "port": PROBE_PORTS[_PORT_VARIABLE.search(entry["port"]).group(1)]} + for entry in declared + ] + + +COMMON = { + "FIREWALL_TABLE": TABLE, + "TOR_EGRESS_CLIENT_CIDRS": ["127.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"], + "TOR_EGRESS_GUARDED_PORTS": _guarded_ports(), + "TOR_EGRESS_VIRTUAL_NET_IPV4": "10.192.0.0/10", + **PROBE_PORTS, + "TOR_CONTAINER_DNS_HOST": "172.17.0.1", + "NETWORK_INTERNAL_CIDRS": [ + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ], +} + +MODES = ("compose", "swarm") + + +def _run(argv: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(argv, capture_output=True, text=True, check=False) + + +@lru_cache(maxsize=1) +def _nft_can_check() -> bool: + """Whether `nft -c` works here. + + It is not a parser: it opens netlink and builds a cache against the live + kernel first, so on a runner without CAP_NET_ADMIN it fails on a valid + ruleset. Probing the binary's presence would make this suite red there. + """ + if shutil.which("nft") is None: + return False + with tempfile.NamedTemporaryFile("w", suffix=".conf") as handle: + handle.write("table inet infinito_capability_probe\n") + handle.flush() + return _run(["nft", "-c", "-f", handle.name]).returncode == 0 + + +@contextmanager +def _network_namespace(name: str): + """An empty namespace, so a loaded ruleset cannot reach the live one.""" + created = _run(["ip", "netns", "add", name]) + if created.returncode != 0: + raise unittest.SkipTest(f"cannot create a network namespace: {created.stderr}") + try: + yield ["ip", "netns", "exec", name] + finally: + _run(["ip", "netns", "del", name]) + + +def _fragments() -> list[str]: + suffix = "/" + FRAGMENT + return sorted( + path + for path in iter_project_files(extensions=(".j2",), exclude_tests=True) + if path.endswith(suffix) + ) + + +def _render(path: str, mode: str = "swarm") -> str: + environment = jinja2.Environment( + keep_trailing_newline=True, + trim_blocks=True, + autoescape=False, # noqa: S701 nftables, not markup + ) + return environment.from_string(read_text(path)).render( + DEPLOYMENT_MODE=mode, **COMMON + ) + + +class TestFirewallFragments(unittest.TestCase): + def test_at_least_one_fragment_exists(self) -> None: + """Otherwise every assertion below passes over an empty list.""" + self.assertTrue(_fragments(), "no role declares templates/nftables.conf.j2") + + def test_each_fragment_replaces_its_table(self) -> None: + for path in _fragments(): + rel = Path(path).relative_to(PROJECT_ROOT).as_posix() + with self.subTest(fragment=rel): + lines = [ + line.strip() for line in _render(path).splitlines() if line.strip() + ] + declarations = [ + line for line in lines if re.match(r"^(delete )?table inet ", line) + ] + self.assertEqual( + declarations[:3], + [ + f"table inet {TABLE}", + f"delete table inet {TABLE}", + f"table inet {TABLE} {{", + ], + f"{rel} must open with the create, delete and declare idiom, " + "or nft merges the new rules into the old table", + ) + + def test_each_fragment_parses(self) -> None: + if not _nft_can_check(): + self.skipTest("nft -c needs CAP_NET_ADMIN, which this runner lacks") + nft = shutil.which("nft") + for path in _fragments(): + rel = Path(path).relative_to(PROJECT_ROOT).as_posix() + for mode in MODES: + with self.subTest(fragment=rel, mode=mode): + with tempfile.NamedTemporaryFile("w", suffix=".conf") as handle: + handle.write(_render(path, mode)) + handle.flush() + result = subprocess.run( + [nft, "-c", "-f", handle.name], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + result.returncode, + 0, + f"{rel} is not valid nftables in {mode}:\n{result.stderr}", + ) + + def test_each_fragment_loads_and_reloads(self) -> None: + """A dry run cannot show what happens on the second deploy. + + Loading the same fragment twice is what proves the idiom: the second + pass meets the table the first one left and must replace it, which is + exactly the case ``nft -c`` never sees. + """ + if not _nft_can_check() or shutil.which("ip") is None: + self.skipTest("loading a ruleset needs nft with CAP_NET_ADMIN and ip") + for index, path in enumerate(_fragments()): + rel = Path(path).relative_to(PROJECT_ROOT).as_posix() + for mode in MODES: + with self.subTest(fragment=rel, mode=mode): + with ( + tempfile.NamedTemporaryFile("w", suffix=".conf") as handle, + _network_namespace(f"nftprobe{os.getpid()}x{index}") as netns, + ): + handle.write(_render(path, mode)) + handle.flush() + listings = [] + for attempt in ("first", "second"): + loaded = _run([*netns, "nft", "-f", handle.name]) + self.assertEqual( + loaded.returncode, + 0, + f"{rel} failed to load on the {attempt} pass " + f"in {mode}:\n{loaded.stderr}", + ) + listed = _run( + [*netns, "nft", "list", "table", "inet", TABLE] + ) + self.assertEqual( + listed.returncode, + 0, + f"{rel} loaded but left no table on the {attempt} " + f"pass in {mode}:\n{listed.stderr}", + ) + listings.append(listed.stdout) + self.assertIn(f"table inet {TABLE}", listings[0]) + self.assertEqual( + listings[0], + listings[1], + f"{rel} grew on the second pass in {mode}, so it merges " + "into the table it found instead of replacing it", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/svc-net-tor/test_egress_clients.py b/tests/unit/python/roles/svc-net-tor/test_egress_clients.py new file mode 100644 index 0000000000..52a558a9a9 --- /dev/null +++ b/tests/unit/python/roles/svc-net-tor/test_egress_clients.py @@ -0,0 +1,101 @@ +"""The egress guard admits every address a container can come from. + +The guard drops any source outside ``tor_egress_clients``, and containers reach +the TransPort and the DNSPort through exactly those rules. So the list is not a +convenience: if it ever stops covering docker's own address pools, every +container loses the resolver and the transparent proxy at once, and it fails in +production rather than here. + +This runs the real lookup over the repository's real network declarations. A +narrowed pool, a renamed key or a dropped entry in the lookup all surface as a +failure of the property the rules depend on. +""" + +from __future__ import annotations + +import importlib.util +import sys +import unittest +from unittest import mock + +import jinja2 + +from utils.cache.yaml import load_yaml_any + +from . import PROJECT_ROOT + +NETWORKS = PROJECT_ROOT / "group_vars/all/08_networks.yml" +LOOKUP = "roles/svc-net-tor/lookup_plugins/tor_egress_clients.py" + + +def _load_lookup(): + path = PROJECT_ROOT / LOOKUP + spec = importlib.util.spec_from_file_location("tor_egress_clients_lookup", path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules["tor_egress_clients_lookup"] = module + spec.loader.exec_module(module) + return module + + +class _Templar: + """Resolves the group_vars cross-references the lookup renders.""" + + def __init__(self, variables: dict): + self.available_variables = variables + + def template(self, value): + return ( + jinja2.Environment(autoescape=False) # noqa: S701 - renders CIDRs, not markup + .from_string(str(value)) + .render(**self.available_variables) + ) + + +class TestEgressClients(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.module = _load_lookup() + cls.variables = load_yaml_any(str(NETWORKS)) + + def _admitted(self) -> list[str]: + lookup = self.module.LookupModule() + lookup._templar = _Templar(self.variables) + lookup._loader = mock.MagicMock() + with mock.patch.object(self.module, "lookup_loader") as loader: + loader.get.return_value = mock.MagicMock( + run=mock.MagicMock(return_value=[[]]) + ) + return lookup.run([], variables=self.variables)[0] + + def test_every_docker_address_pool_is_admitted(self) -> None: + admitted = self._admitted() + pools = self.variables["NETWORK_DOCKER_ADDRESS_POOLS"] + self.assertTrue(pools, "no docker address pool is declared") + for pool in pools: + with self.subTest(pool=pool["base"]): + self.assertIn( + pool["base"], + admitted, + "containers draw their address from this pool and reach the " + "guarded ports through the guard, so dropping it from the " + "admitted list takes the resolver away from every container", + ) + + def test_loopback_is_admitted(self) -> None: + """The host resolves .onion through its own loopback.""" + self.assertIn(self.variables["NETWORK_LOOPBACK_CIDR"], self._admitted()) + + def test_the_lan_is_not_admitted_wholesale(self) -> None: + """The guarded ports carry no access policy, so the LAN is not a client. + + Admitting 10.0.0.0/8 would hand a transparent proxy and a resolver to + every host the operator's network happens to contain. + """ + self.assertNotIn( + self.variables["NETWORK_PRIVATE_CIDRS"]["class_a"], self._admitted() + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/svc-net-tor/test_extra_ports.py b/tests/unit/python/roles/svc-net-tor/test_extra_ports.py new file mode 100644 index 0000000000..2086e5ca12 --- /dev/null +++ b/tests/unit/python/roles/svc-net-tor/test_extra_ports.py @@ -0,0 +1,105 @@ +"""The node onion forwards HTTP whether or not the proxy is in the inventory. + +``lookup('tor_ports')`` collects what the roles in ``group_names`` declare under +``ports.onion``. Port 80 belongs to ``svc-prx-openresty``, which a node acquires +through ``sys-stk-front-proxy`` and which therefore appears in no dependency +list an inventory holds. A node provisioned with ``--include svc-net-tor`` alone +published an onion forwarding SSH and nothing else. + +These run the real lookup over a stubbed ``tor_ports``, so a flag that stops +reaching the forward list, or a derived port that starts duplicating a flagged +one, fails here rather than on the node. +""" + +from __future__ import annotations + +import importlib.util +import sys +import unittest +from typing import Any +from unittest import mock + +from ansible.errors import AnsibleError + +from . import PROJECT_ROOT + +LOOKUP = "roles/svc-net-tor/lookup_plugins/tor_extra_ports.py" +SSH_ENTRY = {"onion_port": 22, "target": "127.0.0.1:22"} +HTTP_ENTRY = {"onion_port": 80, "target": "127.0.0.1:80"} +ENABLED = {"TOR_ONION_SSH_ENABLED": True, "TOR_ONION_HTTP_ENABLED": True} + + +def _load_lookup(): + path = PROJECT_ROOT / LOOKUP + spec = importlib.util.spec_from_file_location("tor_extra_ports_lookup", path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules["tor_extra_ports_lookup"] = module + spec.loader.exec_module(module) + return module + + +class _DerivedPorts: + """Stands in for the tor_ports lookup the plugin composes with.""" + + def __init__(self, entries: list[dict[str, Any]]): + self.entries = entries + + def run(self, _terms, **_kwargs): + return [self.entries] + + +class TestExtraPorts(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.module = _load_lookup() + + def _ports(self, derived: list[dict], **variables) -> list[dict]: + lookup = self.module.LookupModule() + lookup._templar = None + lookup._loader = None + with mock.patch.object( + self.module.lookup_loader, "get", return_value=_DerivedPorts(derived) + ): + return lookup.run([], variables={**ENABLED, **variables})[0] + + def test_http_is_forwarded_without_the_proxy_in_the_inventory(self) -> None: + self.assertIn( + HTTP_ENTRY, + self._ports([]), + "a node whose inventory lists only svc-net-tor still serves its apps " + "over the onion, so the forward cannot depend on the proxy being listed", + ) + + def test_a_derived_port_does_not_duplicate_a_flagged_one(self) -> None: + ports = self._ports([HTTP_ENTRY]) + self.assertEqual( + [entry for entry in ports if entry["onion_port"] == 80], + [HTTP_ENTRY], + "torrc writes one HiddenServicePort line per entry, so a duplicate " + "publishes the same forward twice", + ) + + def test_derived_ports_still_reach_the_forward_list(self) -> None: + onion_dns = {"onion_port": 9053, "target": "127.0.0.1:9053"} + self.assertIn( + onion_dns, + self._ports([onion_dns]), + "the flags add to what the services declare, they do not replace it", + ) + + def test_a_flag_turns_its_forward_off(self) -> None: + ports = self._ports([], TOR_ONION_HTTP_ENABLED=False) + self.assertNotIn(HTTP_ENTRY, ports) + self.assertIn(SSH_ENTRY, ports, "the other flag is untouched") + + def test_an_undeclared_flag_is_refused(self) -> None: + lookup = self.module.LookupModule() + lookup._templar = None + with self.assertRaises(AnsibleError) as raised: + lookup.run([], variables={"TOR_ONION_SSH_ENABLED": True}) + self.assertIn("TOR_ONION_HTTP_ENABLED", str(raised.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/svc-net-tor/test_guarded_ports.py b/tests/unit/python/roles/svc-net-tor/test_guarded_ports.py new file mode 100644 index 0000000000..f3c2c0ba88 --- /dev/null +++ b/tests/unit/python/roles/svc-net-tor/test_guarded_ports.py @@ -0,0 +1,130 @@ +"""Every port the egress router binds locally is dropped for foreign sources. + +The guard chain is the only thing between these listeners and anyone who can +route a packet to the node. A port that is bound but not named in a drop rule +answers the whole reachable network: the transparent proxy relays, the DNSPort +resolves, and the split-DNS forwarder both resolves recursively and hands out +the node's own ``.onion`` records. + +This walks the real declaration chain rather than a fixture: ``meta/services.yml`` +names the ports tor itself binds, ``vars/main.yml`` turns each into a +``TOR_*_PORT`` variable, and the guard list plus the fragment template spend +those variables on drop rules. A port added to the service declaration without a +rule fails the first test. + +The forwarder sits outside that chain because it is not tor's own port: it takes +the well-known resolver port from ``NETWORK_DNS_PORT``, and the host already +assigns that number to whichever resolver owns it. The second test covers it by +name instead. +""" + +from __future__ import annotations + +import re +import unittest + +from utils.cache.files import read_text +from utils.cache.yaml import load_yaml_any +from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_VARS_MAIN + +from . import PROJECT_ROOT + +ROLE = PROJECT_ROOT / "roles/svc-net-tor" +SERVICES = ROLE / ROLE_FILE_META_SERVICES +VARS = ROLE / ROLE_FILE_VARS_MAIN +FRAGMENT = ROLE / "templates/nftables.conf.j2" + +_VARIABLE = re.compile(r"\{\{\s*([A-Z][A-Z0-9_]*)\s*\}\}") +_PORT_KEY = re.compile(r"services\.tor\.ports\.local\.([a-z0-9_]+)") +_DROP_RULE = re.compile(r"^\s*(?!#).*\b(?Ptcp|udp)\s+dport\b.*\bdrop\b") + + +def _declared_ports() -> dict[str, int]: + return load_yaml_any(str(SERVICES))["tor"]["ports"]["local"] + + +def _port_variables() -> dict[str, str]: + """Maps each ``TOR_*_PORT`` variable to the port key it reads.""" + declared = load_yaml_any(str(VARS)) + names = {} + for name, value in declared.items(): + if not isinstance(value, str): + continue + key = _PORT_KEY.search(value) + if key is not None: + names[name] = key.group(1) + return names + + +def _entries_for(variable: str) -> list[dict]: + """Guard entries whose port comes from *variable*.""" + return [ + entry + for entry in load_yaml_any(str(VARS))["TOR_EGRESS_GUARDED_PORTS"] + if _VARIABLE.search(str(entry["port"])).group(1) == variable + ] + + +def _guarded_keys() -> set[str]: + """Port keys that reach a drop rule, through the vars list or the template.""" + variables = _port_variables() + guarded: set[str] = set() + + for entry in load_yaml_any(str(VARS))["TOR_EGRESS_GUARDED_PORTS"]: + name = _VARIABLE.search(str(entry["port"])) + if name is not None and name.group(1) in variables: + guarded.add(variables[name.group(1)]) + + for line in read_text(str(FRAGMENT)).splitlines(): + if _DROP_RULE.match(line) is None: + continue + for name in _VARIABLE.findall(line): + if name in variables: + guarded.add(variables[name]) + + return guarded + + +class TestGuardedPorts(unittest.TestCase): + def test_every_locally_bound_port_reaches_a_drop_rule(self) -> None: + declared = _declared_ports() + self.assertTrue(declared, f"{SERVICES} declares no local ports") + + unguarded = sorted(set(declared) - _guarded_keys()) + self.assertEqual( + unguarded, + [], + "these ports are bound on the node but no guard rule drops a " + "foreign source for them, so they answer everything that can route " + f"to the node: {', '.join(f'{key} ({declared[key]})' for key in unguarded)}", + ) + + def test_the_forwarder_is_guarded_on_both_transports(self) -> None: + entries = _entries_for("TOR_DNSMASQ_PORT") + self.assertEqual( + {entry["protocol"] for entry in entries}, + {"tcp", "udp"}, + "dnsmasq answers on both transports, and a TCP query returns the " + "same records a UDP one does", + ) + + def test_the_forwarder_admits_every_source_the_node_calls_internal(self) -> None: + """The resolver may not be stricter than the proxy it sits next to. + + ``internal`` is ``NETWORK_INTERNAL_CIDRS``, the same list ``torrc.j2`` + spends on ``SocksPolicy accept``. ``containers`` is narrower: out of + ``10.0.0.0/8`` it carries only the docker pool and the exception role, + which leaves out swarm's ingress network and every overlay docker + allocates without an ipam block. Guarding the resolver with the narrow + set hands a source a full SOCKS proxy and refuses it a name lookup. + """ + self.assertEqual( + {entry["admitted"] for entry in _entries_for("TOR_DNSMASQ_PORT")}, + {"internal"}, + "the resolver carries ordinary name resolution for everything the " + "node routes, so it takes the internal set, not the container pools", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/svc-net-tor/test_onion_ports_probe.py b/tests/unit/python/roles/svc-net-tor/test_onion_ports_probe.py new file mode 100644 index 0000000000..e9ab2bb600 --- /dev/null +++ b/tests/unit/python/roles/svc-net-tor/test_onion_ports_probe.py @@ -0,0 +1,162 @@ +"""The onion port probe speaks SOCKS5 and reports only real successes. + +The handshake used to live in a heredoc inside a shell script, where no linter +and no test could reach it: forty lines assembling protocol bytes, checked by +nothing. These tests run it against a fake proxy that answers by script, so the +bytes on the wire and the verdict drawn from the reply are both pinned. +""" + +from __future__ import annotations + +import importlib.util +import socket +import threading +import unittest + +from . import PROJECT_ROOT + + +def _load_probe(): + path = PROJECT_ROOT / "roles" / "svc-net-tor" / "files" / "test" / "onion_ports.py" + spec = importlib.util.spec_from_file_location("onion_ports_probe", str(path)) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +PROBE = _load_probe() + + +class _FakeSocks(threading.Thread): + """A one-shot SOCKS5 proxy that replies with a scripted CONNECT code.""" + + def __init__(self, reply_code: int | None, greeting: bytes = b"\x05\x00") -> None: + super().__init__(daemon=True) + self._reply_code = reply_code + self._greeting = greeting + self._server = socket.socket() + self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server.bind(("127.0.0.1", 0)) + self._server.listen(1) + self.request = b"" + + @property + def endpoint(self) -> str: + host, port = self._server.getsockname() + return f"{host}:{port}" + + def run(self) -> None: + conn, _ = self._server.accept() + with conn: + conn.recv(3) + conn.sendall(self._greeting) + if self._greeting != b"\x05\x00": + return + self.request = conn.recv(512) + if self._reply_code is None: + return + conn.sendall(bytes([0x05, self._reply_code, 0x00, 0x01])) + self._server.close() + + +class TestConnectThroughSocks(unittest.TestCase): + def test_a_success_reply_passes_and_carries_the_target(self) -> None: + proxy = _FakeSocks(reply_code=0x00) + proxy.start() + PROBE.connect_through_socks(proxy.endpoint, "example.onion", 25, 5) + proxy.join(timeout=5) + self.assertTrue( + proxy.request.startswith(b"\x05\x01\x00\x03"), + f"the CONNECT request is malformed: {proxy.request!r}", + ) + self.assertIn(b"example.onion", proxy.request) + self.assertTrue( + proxy.request.endswith((25).to_bytes(2, "big")), + "the port must travel big-endian in the last two bytes", + ) + + def test_a_refusal_reply_fails(self) -> None: + proxy = _FakeSocks(reply_code=0x05) + proxy.start() + with self.assertRaises(PROBE.ProbeError) as raised: + PROBE.connect_through_socks(proxy.endpoint, "example.onion", 25, 5) + self.assertIn("0x05", str(raised.exception)) + + def test_a_refused_auth_method_fails(self) -> None: + proxy = _FakeSocks(reply_code=None, greeting=b"\x05\xff") + proxy.start() + with self.assertRaises(PROBE.ProbeError): + PROBE.connect_through_socks(proxy.endpoint, "example.onion", 25, 5) + + def test_a_closed_connection_fails(self) -> None: + proxy = _FakeSocks(reply_code=None) + proxy.start() + with self.assertRaises(PROBE.ProbeError): + PROBE.connect_through_socks(proxy.endpoint, "example.onion", 25, 5) + + def test_an_unreachable_proxy_fails(self) -> None: + with self.assertRaises(PROBE.ProbeError) as raised: + PROBE.connect_through_socks("127.0.0.1:1", "example.onion", 25, 1) + self.assertIn("unreachable", str(raised.exception)) + + +class TestProbe(unittest.TestCase): + def test_only_the_failures_are_retried(self) -> None: + attempts: list[int] = [] + + def _connect(_proxy, _host, port, _timeout): + attempts.append(port) + if port == 25: + raise PROBE.ProbeError("still down") + + original = PROBE.connect_through_socks + PROBE.connect_through_socks = _connect + try: + failures = PROBE.probe( + proxy="p:9050", + host="example.onion", + ports=[25, 143], + timeout=1, + retries=3, + sleep_seconds=0, + sleeper=lambda _seconds: None, + ) + finally: + PROBE.connect_through_socks = original + + self.assertEqual(failures, {25: "still down"}) + self.assertEqual( + attempts.count(143), + 1, + "a healthy port must not be probed again on a retry pass", + ) + self.assertEqual(attempts.count(25), 3) + + def test_a_port_that_recovers_is_not_reported(self) -> None: + seen: list[int] = [] + + def _connect(_proxy, _host, port, _timeout): + seen.append(port) + if len(seen) == 1: + raise PROBE.ProbeError("first pass fails") + + original = PROBE.connect_through_socks + PROBE.connect_through_socks = _connect + try: + failures = PROBE.probe( + proxy="p:9050", + host="example.onion", + ports=[25], + timeout=1, + retries=2, + sleep_seconds=0, + sleeper=lambda _seconds: None, + ) + finally: + PROBE.connect_through_socks = original + + self.assertEqual(failures, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/sys-ctl-hlth-csp/lookup_plugins/test_csp_accept_status.py b/tests/unit/python/roles/sys-ctl-hlth-csp/lookup_plugins/test_csp_accept_status.py new file mode 100644 index 0000000000..3378cc06e2 --- /dev/null +++ b/tests/unit/python/roles/sys-ctl-hlth-csp/lookup_plugins/test_csp_accept_status.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import importlib.util +import sys +import unittest +import unittest.mock as mock +from unittest.mock import patch + +from . import PROJECT_ROOT + + +def _load_module(rel_path: str, name: str): + path = PROJECT_ROOT / rel_path + spec = importlib.util.spec_from_file_location(name, str(path)) + mod = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +class _DummyTemplar: + def __init__(self, available_variables: dict | None = None): + self.available_variables = available_variables or {} + + +class CspAcceptStatusLookupTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = _load_module( + "roles/sys-ctl-hlth-csp/lookup_plugins/csp_accept_status.py", + "csp_accept_status_lookup", + ) + + def _run(self, applications, **kwargs): + lm = self.module.LookupModule() + lm._templar = _DummyTemplar() + lm._loader = mock.MagicMock() + with patch.object(self.module, "lookup_loader") as loader_mock: + loader_mock.get.return_value = mock.MagicMock( + run=lambda *_a, **_k: [applications] + ) + return lm.run(terms=[], variables={}, **kwargs)[0] + + def test_empty_applications_returns_empty_list(self): + self.assertEqual(self._run({}), []) + + def test_non_mapping_applications_returns_empty(self): + self.assertEqual(self._run([]), []) + + def test_a_declared_4xx_is_named_with_its_domain(self): + apps = { + "web-svc-mirror": { + "domains": {"canonical": ["mirror.example.com"]}, + "server": {"status_codes": {"default": [404]}}, + } + } + self.assertEqual(self._run(apps), ["mirror.example.com=404"]) + + def test_aliases_are_named_too(self): + apps = { + "web-app-bridgy": { + "domains": { + "canonical": ["bridgy.example.com"], + "aliases": ["fed.example.com"], + }, + "server": {"status_codes": {"default": [200, 404]}}, + } + } + self.assertEqual( + self._run(apps), + ["bridgy.example.com=404", "fed.example.com=404"], + ) + + def test_a_2xx_only_declaration_names_nothing(self): + apps = { + "web-app-foo": { + "domains": {"canonical": ["foo.example.com"]}, + "server": {"status_codes": {"default": [200]}}, + } + } + self.assertEqual(self._run(apps), []) + + def test_a_redirect_is_named_because_the_checker_rejects_it(self): + apps = { + "web-app-foo": { + "domains": {"canonical": ["foo.example.com"]}, + "server": {"status_codes": {"default": [200, 301]}}, + } + } + self.assertEqual(self._run(apps), ["foo.example.com=301"]) + + def test_two_vhosts_of_one_role_keep_their_own_codes(self): + apps = { + "web-app-split": { + "domains": { + "canonical": { + "api": "api.example.com", + "web": "web.example.com", + } + }, + "server": { + "status_codes": { + "api": [405], + "web": [200], + "default": [200], + } + }, + } + } + self.assertEqual(self._run(apps), ["api.example.com=405"]) + + def test_several_codes_of_one_domain_are_joined(self): + apps = { + "web-app-foo": { + "domains": {"canonical": ["foo.example.com"]}, + "server": {"status_codes": {"default": [200, 403, 404]}}, + } + } + self.assertEqual(self._run(apps), ["foo.example.com=403,404"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/python/roles/sys-ctl-hlth-csp/lookup_plugins/test_csp_skip_domains.py b/tests/unit/python/roles/sys-ctl-hlth-csp/lookup_plugins/test_csp_skip_domains.py index 2ab1e503f5..8fcc53d0c1 100644 --- a/tests/unit/python/roles/sys-ctl-hlth-csp/lookup_plugins/test_csp_skip_domains.py +++ b/tests/unit/python/roles/sys-ctl-hlth-csp/lookup_plugins/test_csp_skip_domains.py @@ -24,6 +24,13 @@ class _DummyTemplar: self.available_variables = available_variables or {} +def _app(service_enabled, domain="filer.example.com"): + return { + "server": {"domains": {"canonical": {"filer": domain}}}, + "services": {"filer": {"enabled": service_enabled, "domains": ["filer"]}}, + } + + class CspSkipDomainsLookupTests(unittest.TestCase): @classmethod def setUpClass(cls): @@ -45,89 +52,47 @@ class CspSkipDomainsLookupTests(unittest.TestCase): def test_empty_applications_returns_empty_list(self): self.assertEqual(self._run({}), []) - def test_app_with_status_code_ge_400_skips_canonical_and_aliases(self): - apps = { - "web-app-bridgy": { - "domains": { - "canonical": ["bridgy.example.com"], - "aliases": ["fed.example.com"], - }, - "server": { - "status_codes": {"default": [200, 404]}, - }, - } - } - self.assertEqual(self._run(apps), ["bridgy.example.com", "fed.example.com"]) + def test_non_mapping_applications_returns_empty(self): + self.assertEqual(self._run([]), []) - def test_app_without_status_code_ge_400_is_not_skipped(self): + def test_a_disabled_service_hides_its_canonical_domain(self): + self.assertEqual( + self._run({"web-app-seaweedfs": _app(False)}), + ["filer.example.com"], + ) + + def test_an_enabled_service_keeps_its_domain_under_probe(self): + self.assertEqual(self._run({"web-app-seaweedfs": _app(True)}), []) + + def test_a_service_without_domains_is_ignored(self): apps = { "web-app-foo": { - "domains": {"canonical": ["foo.example.com"]}, - "server": { - "status_codes": {"default": [200]}, - }, + "server": {"domains": {"canonical": {"web": "foo.example.com"}}}, + "services": {"worker": {"enabled": False}}, } } self.assertEqual(self._run(apps), []) - def test_app_without_status_codes_field_is_not_skipped(self): - apps = {"web-app-foo": {"domains": {"canonical": ["foo.example.com"]}}} - self.assertEqual(self._run(apps), []) - - def test_group_names_selection_filters_apps(self): + def test_the_selection_filters_which_apps_are_inspected(self): apps = { - "web-app-bridgy": { - "domains": {"canonical": ["bridgy.example.com"]}, - "server": { - "status_codes": {"default": [404]}, - }, - }, - "web-app-other": { - "domains": {"canonical": ["other.example.com"]}, - "server": { - "status_codes": {"default": [404]}, - }, - }, + "web-app-a": _app(False, "a.example.com"), + "web-app-b": _app(False, "b.example.com"), } self.assertEqual( - self._run(apps, group_names=["web-app-bridgy"]), - ["bridgy.example.com"], + self._run(apps, group_names=["web-app-a"]), + ["a.example.com"], ) def test_group_names_csv_string_is_accepted(self): apps = { - "a": { - "domains": {"canonical": ["a.example.com"]}, - "server": { - "status_codes": {"default": [404]}, - }, - }, - "b": { - "domains": {"canonical": ["b.example.com"]}, - "server": { - "status_codes": {"default": [404]}, - }, - }, + "web-app-a": _app(False, "a.example.com"), + "web-app-b": _app(False, "b.example.com"), } self.assertEqual( - self._run(apps, group_names="a,b"), + self._run(apps, group_names="web-app-a,web-app-b"), ["a.example.com", "b.example.com"], ) - def test_non_4xx_codes_dont_count(self): - apps = { - "web-app-foo": { - "domains": {"canonical": ["foo.example.com"]}, - "server": { - "status_codes": {"default": [200, 301, 302]}, - }, - } - } - self.assertEqual(self._run(apps), []) - - def test_non_mapping_applications_returns_empty(self): - self.assertEqual(self._run([]), []) - if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/unit/python/roles/web-app-nextcloud/test_oidc_addon_follows_sso.py b/tests/unit/python/roles/web-app-nextcloud/test_oidc_addon_follows_sso.py new file mode 100644 index 0000000000..3fa9e82bee --- /dev/null +++ b/tests/unit/python/roles/web-app-nextcloud/test_oidc_addon_follows_sso.py @@ -0,0 +1,68 @@ +"""The OIDC addon must not be on when Nextcloud has no SSO provider. + +``meta/addons/oidc_login.yml`` gates on ``lookup('sso_oidc_plugin')``, which the +applications render evaluates while it is still in flight. Under that re-entry +guard the merged view hands out service flags as their own Jinja text, and +``bool("{{ ... }}")`` is True: the lookup read ``services.ldap.enabled`` as +enabled on a deployment that has neither Keycloak nor OpenLDAP and announced +``oidc_login``. Nextcloud then advertised a login hand-off to a provider that is +not deployed, while ``templates/config/oidc.config.php.j2`` re-read the same +setting at task time and correctly wrote nothing. + +The stubbed lookup tests next to the plugin cannot see this: a stub resolves the +path but does not render it. This renders for real. +""" + +from __future__ import annotations + +import unittest + +from utils.cache import _reset_cache_for_tests +from utils.cache.applications import get_merged_applications + + +def _templar(variables: dict): + from ansible.parsing.dataloader import DataLoader + from ansible.template import Templar + + templar = Templar(loader=DataLoader(), variables=dict(variables)) + templar.available_variables = dict(variables) + return templar + + +def _addon_flags(*deployed: str) -> dict: + variables = {"group_names": list(deployed), "applications": {}} + _reset_cache_for_tests() + try: + applications = get_merged_applications( + variables=variables, templar=_templar(variables) + ) + finally: + _reset_cache_for_tests() + nextcloud = applications["web-app-nextcloud"] + addons = nextcloud.get("addons") or {} + services = nextcloud.get("services") or {} + return { + "sso": (services.get("sso") or {}).get("enabled"), + "ldap": (services.get("ldap") or {}).get("enabled"), + **{ + name: (addons.get(name) or {}).get("enabled") + for name in ("oidc_login", "user_oidc", "sociallogin") + }, + } + + +class TestOidcAddonFollowsSso(unittest.TestCase): + def test_no_provider_means_no_oidc_addon(self) -> None: + flags = _addon_flags("web-app-nextcloud") + + self.assertEqual(flags["sso"], False) + self.assertEqual( + (flags["oidc_login"], flags["user_oidc"], flags["sociallogin"]), + (False, False, False), + "no OIDC plugin may be announced without an SSO provider", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/utils/cache/test_domains.py b/tests/unit/python/utils/cache/test_domains.py index ae7b2b7c35..c884e16db2 100644 --- a/tests/unit/python/utils/cache/test_domains.py +++ b/tests/unit/python/utils/cache/test_domains.py @@ -337,5 +337,71 @@ class TestOnionDomainInjection(unittest.TestCase): self.assertEqual(out["web-app-x"], ["infinito.test", self.ONION]) +class TestOnionFlagsAreRendered(unittest.TestCase): + """`services.tor.*` reaches this map as Jinja in 99 of the 105 roles that + declare it, and as its own source text whenever the applications render is + still in flight. Reading that text as a boolean answers False and drops the + onion domains for every app, which is what froze a clearnet asset URL into + the applications payload of run 34922994823. + """ + + ONION = TestOnionDomainInjection.ONION + JINJA_ON = "{{ 'svc-net-tor' in group_names }}" + + _apps = TestOnionDomainInjection._apps + + def _templar(self, **variables): + from ansible.parsing.dataloader import DataLoader + from ansible.template import Templar + + templar = Templar(loader=DataLoader(), variables=dict(variables)) + templar.available_variables = dict(variables) + return templar + + def _inject(self, apps, **variables): + return cache_domains._inject_onion_domains( + {"web-app-x": ["x.infinito.test"]}, + apps, + "infinito.test", + self.ONION, + templar=self._templar(**variables), + variables=dict(variables), + ) + + def test_a_templated_enabled_flag_still_injects_the_onion(self): + out = self._inject( + self._apps(enabled=self.JINJA_ON), + group_names=["svc-net-tor", "web-app-x"], + ) + + self.assertEqual(out["web-app-x"], ["x.infinito.test", f"x.{self.ONION}"]) + + def test_a_templated_flag_resolving_false_leaves_the_clearnet_domain(self): + out = self._inject(self._apps(enabled=self.JINJA_ON), group_names=["web-app-x"]) + + self.assertEqual(out["web-app-x"], ["x.infinito.test"]) + + def test_templated_exclusive_replaces_the_clearnet_domain(self): + out = self._inject( + self._apps(enabled=self.JINJA_ON, exclusive=self.JINJA_ON), + group_names=["svc-net-tor", "web-app-x"], + ) + + self.assertEqual(out["web-app-x"], [f"x.{self.ONION}"]) + + def test_a_flag_that_cannot_be_rendered_is_an_error(self): + """Without a templar the text cannot resolve, and answering False + would drop the onion domains without saying so.""" + with self.assertRaisesRegex(ValueError, "services.tor.enabled"): + cache_domains._inject_onion_domains( + {"web-app-x": ["x.infinito.test"]}, + self._apps(enabled=self.JINJA_ON), + "infinito.test", + self.ONION, + templar=None, + variables={"group_names": ["svc-net-tor"]}, + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/python/utils/test_api.py b/tests/unit/python/utils/test_api.py new file mode 100644 index 0000000000..1a48d495b9 --- /dev/null +++ b/tests/unit/python/utils/test_api.py @@ -0,0 +1,51 @@ +import unittest + +from utils.api import declared_api, provider_enabled, resolve_api + + +class TestResolveApi(unittest.TestCase): + def test_a_partial_override_keeps_the_declared_providers(self) -> None: + declared = declared_api() + self.assertIn("github", declared) + + resolved = resolve_api({"API": {"openai": {"api_key": "sk-test"}}}) + + self.assertEqual(set(resolved), set(declared)) + self.assertEqual(resolved["openai"]["api_key"], "sk-test") + self.assertEqual(resolved["github"]["client_id"], "") + + def test_an_override_wins_per_key(self) -> None: + resolved = resolve_api({"API": {"github": {"client_id": "ID42"}}}) + + self.assertEqual(resolved["github"]["client_id"], "ID42") + self.assertEqual(resolved["github"]["client_secret"], "") + + def test_a_missing_api_falls_back_to_the_declaration(self) -> None: + self.assertEqual(resolve_api({}), declared_api()) + + def test_a_non_mapping_api_is_an_error(self) -> None: + with self.assertRaises(TypeError): + resolve_api({"API": "nope"}) + + +class TestProviderEnabled(unittest.TestCase): + def test_every_credential_must_carry_a_value(self) -> None: + api = {"github": {"client_id": "ID42", "client_secret": "SEC42"}} + + self.assertTrue(provider_enabled(api, "github")) + + def test_one_empty_credential_disables_the_provider(self) -> None: + api = {"github": {"client_id": "ID42", "client_secret": " "}} + + self.assertFalse(provider_enabled(api, "github")) + + def test_a_provider_without_credentials_is_disabled(self) -> None: + self.assertFalse(provider_enabled({"github": {}}, "github")) + + def test_an_undeclared_provider_is_an_error(self) -> None: + with self.assertRaises(KeyError): + provider_enabled({}, "github") + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/api.py b/utils/api.py new file mode 100644 index 0000000000..b7c0f95891 --- /dev/null +++ b/utils/api.py @@ -0,0 +1,112 @@ +"""Proprietary API credential SPOT, read from group_vars/all/18_api.yml. + +``API`` is a plain mapping, so an inventory that configures three providers +replaces the whole declaration instead of merging into it, and every provider it +does not name disappears. Resolving a hidden provider then raises, and a raising +lookup inside a value the applications renderer is templating leaves the raw +``{{ ... }}`` text in place: the consumer stores template text, and ``| bool`` +coerces it to False with a deprecation warning that fails the deploy on the +warning gate. + +Merging the scope's override over the declared defaults keeps every declared +provider resolvable, so an override narrows values, never the provider set. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from utils import PROJECT_ROOT +from utils.cache.base import _deep_merge +from utils.cache.yaml import load_yaml_any + +_API_SPOT_FILE = str(PROJECT_ROOT / "group_vars" / "all" / "18_api.yml") + + +def declared_api() -> dict[str, Any]: + """The ``API`` mapping as declared in the group_vars SPOT. + + Returns: + Provider name to credential mapping, every value a plain string. + + Raises: + TypeError: the SPOT does not declare ``API`` as a mapping. + """ + document = load_yaml_any(_API_SPOT_FILE, default_if_missing={}) or {} + declared = document.get("API") if isinstance(document, Mapping) else None + if not isinstance(declared, Mapping): + raise TypeError(f"{_API_SPOT_FILE} does not declare 'API' as a mapping.") + return dict(declared) + + +def resolve_api( + variables: Mapping[str, Any] | None, templar: Any = None +) -> dict[str, Any]: + """The effective ``API`` mapping for a render scope. + + Args: + variables: the scope's variables; its ``API`` entry overrides the SPOT. + templar: optional templar; renders the override and the merged values, + so an inventory may source a credential from an env lookup. + + Returns: + The SPOT declaration with the scope's override merged over it. + + Raises: + TypeError: the scope's ``API`` is present but not a mapping. + """ + declared = declared_api() + override: Any = (variables or {}).get("API") + if override is not None and not isinstance(override, Mapping) and templar: + override = _templated(templar, override) + if override is None: + return declared + if not isinstance(override, Mapping): + raise TypeError("'API' is defined but is not a mapping.") + merged = _deep_merge(declared, dict(override)) + if templar is None: + return merged + rendered = _templated(templar, merged) + return dict(rendered) if isinstance(rendered, Mapping) else merged + + +def _templated(templar: Any, value: Any) -> Any: + from ansible.errors import AnsibleError + + try: + return templar.template(value, fail_on_undefined=False) + except TypeError: + pass + except (AnsibleError, ValueError): + return value + + try: + return templar.template(value) + except (AnsibleError, TypeError, ValueError): + return value + + +def provider_enabled(api: Mapping[str, Any], provider: str) -> bool: + """Whether every credential of a provider carries a value. + + Args: + api: an effective ``API`` mapping, as returned by ``resolve_api``. + provider: the provider name, e.g. ``github``. + + Returns: + True when the provider declares at least one credential and none of + them is empty. + + Raises: + KeyError: the provider is not declared in the SPOT. + """ + credentials = api.get(provider) + if not isinstance(credentials, Mapping): + raise KeyError( + f"unknown API provider {provider!r}; declare it under 'API' in " + f"{_API_SPOT_FILE}." + ) + if not credentials: + return False + return all(str(value).strip() for value in credentials.values()) diff --git a/utils/cache/domains.py b/utils/cache/domains.py index 93defc8919..ee4bd569a4 100644 --- a/utils/cache/domains.py +++ b/utils/cache/domains.py @@ -32,6 +32,54 @@ def _as_bool(value: Any) -> bool: return bool(value) +def _resolved_flag( + value: Any, + *, + app: str, + name: str, + templar: Any, + variables: Any, + apps: Any, +) -> bool: + """A tor service flag as a boolean, never as the text of its own template. + + ``services.tor.*`` is declared as Jinja in 99 of the 105 roles that carry + it. When this map is built from an applications tree that is still being + rendered (``utils.cache.applications`` returns the raw tree while its own + render is in flight) the flag arrives as its source text, which every + boolean coercion reads as False. The onion injection would then silently + not happen and the clearnet domain would be frozen into whatever value the + render was producing. + + Args: + value: the declared flag, rendered or not. + app: application id, for the error message. + name: flag name under ``services.tor``, for the error message. + templar: templar to render with; ``None`` renders nothing. + variables: scope the template resolves against. + apps: applications tree seeded into the nested render. + + Returns: + The flag as a boolean. + + Raises: + ValueError: the flag still carries template text after rendering. + """ + if isinstance(value, str) and ("{{" in value or "{%" in value): + from utils.cache.base import _render_with_templar + + value = _render_with_templar( + value, templar=templar, variables=variables, raw_applications=apps + ) + if isinstance(value, str) and ("{{" in value or "{%" in value): + raise ValueError( + f"get_merged_domains: '{app}' services.tor.{name} stayed template " + f"text ({value!r}). Reading it as a boolean would drop the onion " + "domains without saying so." + ) + return _as_bool(value) + + def _onion_of(domain: str, primary: str, node_onion: str) -> str | None: """Return ``domain`` with the clearnet ``primary`` suffix swapped for the node onion, or None when the domain is not under ``primary``.""" @@ -48,6 +96,9 @@ def _inject_onion_domains( apps: dict[str, Any], primary: str, node_onion: str, + *, + templar: Any = None, + variables: Any = None, ) -> dict[str, Any]: """Add ``.`` domains for apps opting into onion routing. @@ -63,21 +114,38 @@ def _inject_onion_domains( ) registry = build_service_registry_from_applications(apps) + + def _flag(value: Any, app: str, name: str) -> bool: + return _resolved_flag( + value, + app=app, + name=name, + templar=templar, + variables=variables, + apps=apps, + ) + out: dict[str, Any] = {} for app, domains in merged.items(): tor = ((apps.get(app) or {}).get("services") or {}).get("tor") or {} - if not isinstance(domains, (list, dict)) or not _as_bool(tor.get("enabled")): + if not isinstance(domains, (list, dict)) or not _flag( + tor.get("enabled"), app, "enabled" + ): out[app] = domains continue - exclusive = _as_bool( + exclusive = _flag( resolve_service_config( apps, app, "tor", "exclusive", default=False, service_registry=registry - ) + ), + app, + "exclusive", ) - is_primary = _as_bool( + is_primary = _flag( resolve_service_config( apps, app, "tor", "primary", default=False, service_registry=registry - ) + ), + app, + "primary", ) if isinstance(domains, dict): out[app] = _inject_onion_into_dict( @@ -141,7 +209,9 @@ def get_merged_domains( issued while the applications render is in progress sees the unrendered tree, whose service flags are still Jinja strings; its map is cached under its own key so the render's own call sites reuse it without it ever being - served as the finished map. + served as the finished map. The tor flags are rendered before they are + read (``_resolved_flag``), so that mid-render map carries the same onion + domains the finished one does. """ from plugins.filter.canonical_domains_map import ( FilterModule as _CanonicalDomainsFilter, @@ -200,7 +270,14 @@ def get_merged_domains( node_onion = str(_render_raw(node_raw or "", "services.tor.node") or "").strip() group_names = variables.get("group_names") or [] if node_onion and "svc-net-tor" in group_names: - merged = _inject_onion_domains(merged, apps, str(primary_domain), node_onion) + merged = _inject_onion_domains( + merged, + apps, + str(primary_domain), + node_onion, + templar=templar, + variables=variables, + ) _MERGED_DOMAINS_CACHE[cache_key] = merged return merged diff --git a/utils/tests/swarm/derive_includes.py b/utils/tests/swarm/derive_includes.py index 70cbf04f5f..c2b7cd5e2e 100755 --- a/utils/tests/swarm/derive_includes.py +++ b/utils/tests/swarm/derive_includes.py @@ -193,6 +193,21 @@ def derive_includes( return sorted(found) +def variant_scope(app_id: str, *, variants: dict[str, int] | None = None) -> list[str]: + """Deployed roles for APP_ID that must carry their own variant config. + + Args: + app_id: role whose closure is resolved. + variants: active ``{app_id: variant_index}`` map, as ``derive_includes``. + """ + explicit = set(_EXPLICIT_INCLUDES) - {app_id} + return [ + role + for role in derive_includes(app_id, variants=variants) + if role not in explicit + ] + + def _disabled_provider_roles(service_registry: dict[str, Any]) -> set[str]: """Provider roles the ``disable`` env removes from the closure, so the swarm include set matches what the provision-time services_disabler diff --git a/utils/tests/swarm/matrix.py b/utils/tests/swarm/matrix.py index 3edf062f6b..4cd6fd4154 100644 --- a/utils/tests/swarm/matrix.py +++ b/utils/tests/swarm/matrix.py @@ -29,7 +29,7 @@ import time from utils import PROJECT_ROOT from utils.env.runtime import mem_available_mb, mem_stall_pct, mem_total_mb from utils.storage.constrained import host_storage_constrained -from utils.tests.swarm.derive_includes import derive_includes +from utils.tests.swarm.derive_includes import derive_includes, variant_scope from utils.tests.swarm.write.extras import ensure_swarm_keypairs _SWARM_DIR = PROJECT_ROOT / "scripts" / "tests" / "deploy" / "swarm" @@ -385,7 +385,7 @@ def main(argv: list[str] | None = None) -> int: round_index, inv_dir, round_variants, - round_include, + _round_include, round_purge_set, ) in enumerate(plan): inv_root = inv_dir.rstrip("/") @@ -397,7 +397,7 @@ def main(argv: list[str] | None = None) -> int: variant_payloads = _resolve_variant_payloads( roles_dir=_ROLES_DIR, - include=round_include, + include=variant_scope(app_id, variants=round_variants), active_variants=round_variants, ) from utils.tests.swarm.backup_repos import backup_provider_ips