Initial commit
This commit is contained in:
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto
|
||||||
|
*.sh text eol=lf
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
*.retry
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2026, Eric Meehan
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
3. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
may be used to endorse or promote products derived from this software
|
||||||
|
without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
# Luanti WASM Kubernetes role
|
||||||
|
|
||||||
|
Deploy the **browser client**, not a Luanti game server. This follows the neighboring
|
||||||
|
Owncast role's controller-local, `kubernetes.core.k8s`, role defaults, Jinja manifest,
|
||||||
|
and deployment-playbook conventions. There is no Helm release, PVC, database,
|
||||||
|
game-server Deployment, or mutable server configuration.
|
||||||
|
|
||||||
|
## Architecture and prerequisites
|
||||||
|
|
||||||
|
- A stateless Deployment puts the nginx **web runtime target** and **lwproxy
|
||||||
|
sidecar in the same pod**. nginx serves TCP 8080 and forwards `/proxy` WebSocket
|
||||||
|
requests to `http://127.0.0.1:7777`. Browser game traffic becomes UDP to the
|
||||||
|
explicitly allowed external game destination.
|
||||||
|
- One **ClusterIP-only Service**, with **only TCP 8080**. Port 7777 is never
|
||||||
|
exposed by a Service, hostPort, NodePort, LoadBalancer, or Ingress.
|
||||||
|
- The proxy listens on `0.0.0.0:7777` so kubelet TCP startup/readiness/liveness
|
||||||
|
probes can reach the pod IP. Binding only to localhost would make those probes
|
||||||
|
fail. The mandatory NetworkPolicy permits ingress **only to TCP 8080**;
|
||||||
|
normal kubelet/node health-probe traffic is exempt from Kubernetes network
|
||||||
|
isolation. Confirm your CNI's node-probe behavior in staging.
|
||||||
|
- Kubernetes with `apps/v1`, `networking.k8s.io/v1` and a **NetworkPolicy-enforcing
|
||||||
|
CNI** is required. The role cannot detect a CNI that silently ignores policy;
|
||||||
|
there is deliberately **no policy-disable or unsupported-CNI bypass**.
|
||||||
|
- Use a Linux Ansible controller (including WSL), Python 3.10+ and ansible-core
|
||||||
|
2.16–2.19. Ansible 2.19 needs Python 3.11+ on the controller. Install
|
||||||
|
[requirements.txt](requirements.txt) in your controller environment and the
|
||||||
|
collection in [requirements.yml](requirements.yml). The version is pinned to
|
||||||
|
match the neighboring role; the small CIDR filter uses only Python's standard
|
||||||
|
library, not `ansible.utils` or `netaddr`.
|
||||||
|
- The controller must have a working kubeconfig or `K8S_AUTH_*` settings and
|
||||||
|
permissions to manage namespaces, ConfigMaps, Deployments, Services, Ingresses,
|
||||||
|
and NetworkPolicies. No kubeconfig, credentials, RBAC binding, or service-account
|
||||||
|
token is placed in the workload. Kubernetes API certificate verification stays
|
||||||
|
enabled; use your normal trusted controller configuration.
|
||||||
|
- Optional ingress needs a WebSocket-capable ingress controller, DNS, and a
|
||||||
|
**browser-trusted TLS certificate**. cert-manager is optional. With no issuer,
|
||||||
|
create the named TLS Secret yourself in the workload namespace. A private
|
||||||
|
`ca-issuer` requires distributing that CA's trust to every browser.
|
||||||
|
|
||||||
|
## Build and supply real images
|
||||||
|
|
||||||
|
There are **no default image names**: the application CI does not publish these
|
||||||
|
runtime images. Build, scan, and push both images to a registry you control. The
|
||||||
|
following commands are conceptual, run **from your Luanti application checkout**;
|
||||||
|
set `REGISTRY`, `RELEASE`, and `GAME_HOST` to your actual values first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build --target web \
|
||||||
|
--build-arg LUANTI_SERVER_ADDRESS="$GAME_HOST" \
|
||||||
|
--build-arg LUANTI_SERVER_PORT=30000 \
|
||||||
|
-t "$REGISTRY/luanti-web:$RELEASE" .
|
||||||
|
docker build -f docker/proxy.Dockerfile -t "$REGISTRY/lwproxy:$RELEASE" .
|
||||||
|
docker push "$REGISTRY/luanti-web:$RELEASE"
|
||||||
|
docker push "$REGISTRY/lwproxy:$RELEASE"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the application's `web` target, **not** its `build` or `artifacts` target.
|
||||||
|
The confirmed image contracts are:
|
||||||
|
|
||||||
|
| Image | Runtime contract |
|
||||||
|
| --- | --- |
|
||||||
|
| web | nginx entrypoint; `/usr/share/nginx/html`; `/etc/nginx/nginx.conf`; numeric UID/GID 101; TCP 8080 |
|
||||||
|
| proxy | `lwproxy --config /etc/lwproxy.toml`; numeric UID/GID 65532; TCP 7777 |
|
||||||
|
|
||||||
|
ConfigMap mounts replace exactly those two config paths. Preserve these contracts
|
||||||
|
in custom images. Root filesystems are read-only, nginx writes only to a bounded
|
||||||
|
memory-backed `/tmp`, and the proxy requires no writable volume.
|
||||||
|
|
||||||
|
The game server **hostname and port are embedded at image build time** through
|
||||||
|
`LUANTI_SERVER_ADDRESS` / `LUANTI_SERVER_PORT`. The role does not edit or mount
|
||||||
|
`server.conf`, and changing role variables does **not** change the embedded
|
||||||
|
destination. Rebuild the web image to change it. Keep the DNS A/AAAA addresses,
|
||||||
|
`luanti_wasm_game_cidrs`, and `luanti_wasm_game_port` aligned with the destination
|
||||||
|
and address families your proxy actually uses. The IPv4 proxy listener can still
|
||||||
|
serve a dual-stack pod via nginx loopback; egress IPv6 requires cluster support.
|
||||||
|
|
||||||
|
Both image variables require a non-`latest` explicit tag or a complete
|
||||||
|
`@sha256:` digest (64 lowercase hex characters). Mutable non-latest tags are
|
||||||
|
accepted, but **digests are recommended** for reproducibility and supply-chain
|
||||||
|
verification. Use admission signature/provenance policies and scan your builds.
|
||||||
|
Reusing a mutable tag alone does not trigger a rollout. Image pull credentials
|
||||||
|
must be pre-created Kubernetes Secrets, referenced by name, never embedded here.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
From the role checkout, install dependencies and make the parent directory a
|
||||||
|
role search path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
ansible-galaxy collection install -r requirements.yml
|
||||||
|
export ANSIBLE_ROLES_PATH="$(dirname "$PWD")${ANSIBLE_ROLES_PATH:+:$ANSIBLE_ROLES_PATH}"
|
||||||
|
ansible-playbook -i localhost, playbooks/deploy-eom.yml -e @deployment-vars.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Create your own deployment variables file. This example contains intentionally
|
||||||
|
nonexistent registry names and documentation-only IPs; **replace every fixture
|
||||||
|
with your pushed images and resolved game destination**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
eom_luanti_wasm_web_image: registry.example.invalid/your-team/luanti-web:your-release
|
||||||
|
eom_luanti_wasm_proxy_image: registry.example.invalid/your-team/lwproxy:your-release
|
||||||
|
eom_luanti_wasm_game_cidrs:
|
||||||
|
- 192.0.2.15/32
|
||||||
|
- 2001:db8::15/128
|
||||||
|
# Optional; only approved content-service addresses, never 0.0.0.0/0 or ::/0.
|
||||||
|
eom_luanti_wasm_https_cidrs: []
|
||||||
|
```
|
||||||
|
|
||||||
|
[playbooks/deploy-eom.yml](playbooks/deploy-eom.yml) requires the three
|
||||||
|
`eom_luanti_wasm_*` image/game inputs before any Kubernetes operation. It enables
|
||||||
|
TLS ingress at **luanti.eom.dev**, ingress class **nginx**, using the
|
||||||
|
**ca-issuer ClusterIssuer** and the **luanti-wasm-tls** Secret. It assumes the
|
||||||
|
image was built for your intended server (the app currently defaults to
|
||||||
|
luanti.eom.dev:30000); it does not create that server. If sharing this hostname,
|
||||||
|
ensure its web ingress and external game UDP routing resolve as intended.
|
||||||
|
|
||||||
|
For another site, invoke the role directly:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Deploy browser game client
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
luanti_wasm_web_image: "{{ my_pushed_web_image }}"
|
||||||
|
luanti_wasm_proxy_image: "{{ my_pushed_proxy_image }}"
|
||||||
|
luanti_wasm_game_cidrs: "{{ my_game_server_cidrs }}"
|
||||||
|
luanti_wasm_ingress_enabled: true
|
||||||
|
luanti_wasm_hostname: play.example.org
|
||||||
|
luanti_wasm_ingress_class: nginx
|
||||||
|
luanti_wasm_tls_secret_name: browser-game-tls
|
||||||
|
# Empty issuer: supply the browser-trusted TLS Secret externally.
|
||||||
|
luanti_wasm_cluster_issuer: ""
|
||||||
|
luanti_wasm_ingress_peers:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: ingress-nginx
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/component: controller
|
||||||
|
roles:
|
||||||
|
- ansible-role-luanti-wasm
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect your controller labels before using the example ingress selectors.
|
||||||
|
`namespaceSelector` and `podSelector` in **one peer** are ANDed; separate peer
|
||||||
|
entries are ORed. A podSelector alone selects pods in the workload namespace.
|
||||||
|
Ingress peers may also use validated `ipBlock` CIDRs with contained `except`
|
||||||
|
networks. An empty peer list omits `from`, allowing any source to TCP 8080 only.
|
||||||
|
|
||||||
|
## Configuration reference
|
||||||
|
|
||||||
|
All settings are in [defaults/main.yml](defaults/main.yml).
|
||||||
|
|
||||||
|
| Variable(s), prefixed `luanti_wasm_` | Default / purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `namespace`, `name` | `luanti-wasm`; DNS labels, at most 53 characters to reserve suffix space; name starts with a letter |
|
||||||
|
| `web_image`, `proxy_image` | Empty, **required**; distinct runtime image inputs |
|
||||||
|
| `image_pull_policy`, `image_pull_secrets` | `IfNotPresent`, `[]`; secrets are `{name: existing-secret}` objects |
|
||||||
|
| `replicas` | Integer `1`; may scale to a positive integer, but see WebSocket caveats below |
|
||||||
|
| `web_resources`, `proxy_resources` | Each requests `100m` CPU / `64Mi`; limits `500m` / `256Mi`; require both requests and limits |
|
||||||
|
| `tmp_size_limit` | `64Mi`; positive whole `Ki`, `Mi`, or `Gi` memory-backed nginx scratch space, charged to pod/container memory usage |
|
||||||
|
| `node_selector`, `tolerations` | `{}`, `[]` |
|
||||||
|
| `wait`, `wait_timeout` | `true`, `300` seconds; wait for Deployment readiness, configurable for GitOps/slow starts |
|
||||||
|
| `ingress_enabled`, `hostname`, `ingress_class` | `false`, empty, empty; disabled ingress is removed on subsequent runs |
|
||||||
|
| `tls_enabled`, `tls_secret_name` | `true`, name plus `-tls`; **TLS must be true whenever ingress is enabled** |
|
||||||
|
| `cluster_issuer` | Empty; optional cert-manager ClusterIssuer name |
|
||||||
|
| `ingress_annotations` | nginx read/send timeout `3600` seconds and SSL redirect enabled; replace/extend the dictionary for your controller |
|
||||||
|
| `websocket_timeout_seconds` | Integer `3600`; nginx side of the long-lived WebSocket; also adjust ingress timeouts to match |
|
||||||
|
| `ingress_peers` | `[]`, any source on **8080 only**; restrict to your ingress controller in production |
|
||||||
|
| `game_cidrs`, `game_port` | Empty, **required nonempty**, and UDP `30000`; canonical IPv4/IPv6 CIDRs only |
|
||||||
|
| `https_cidrs` | `[]`, no external TCP 443 by default |
|
||||||
|
| `http_allowed_hosts` | Exact `content.luanti.org`, `servers.luanti.org`; JSON-escaped array written as valid TOML |
|
||||||
|
|
||||||
|
Resource quantities are ultimately validated by the Kubernetes API; tune limits
|
||||||
|
for actual traffic and account for nginx tmpfs. Requests must not exceed limits.
|
||||||
|
Role assertions validate identities, image references, scalars, collection/peer
|
||||||
|
shapes, ingress hostname/TLS, exact proxy hostnames, and canonical CIDRs. Scoped
|
||||||
|
IPv6, host bits, malformed addresses, and `/0` wildcard networks are rejected.
|
||||||
|
Use narrow `/32` and `/128` destination rules where possible. The role does not
|
||||||
|
make DNS lookups to silently broaden or update an allowlist.
|
||||||
|
|
||||||
|
## Security and operational boundaries
|
||||||
|
|
||||||
|
- NetworkPolicy is applied **before the Deployment**, selecting its pod labels.
|
||||||
|
Ingress permits TCP 8080 only; egress permits **only** DNS TCP/UDP 53 to
|
||||||
|
`kube-system` pods labeled `k8s-app: kube-dns`, game UDP to the explicit CIDRs
|
||||||
|
and port, and optional TCP 443 to explicit HTTPS CIDRs. There are no empty
|
||||||
|
egress peers or wildcard destinations. This assumes CoreDNS/kube-dns with those
|
||||||
|
labels. NodeLocal DNSCache, custom DNS namespaces, NAT, host-network ingress,
|
||||||
|
and CNI-specific service-IP handling require operator review and testing;
|
||||||
|
do not work around mismatches by opening arbitrary egress.
|
||||||
|
- Kubernetes NetworkPolicies are **additive**: another policy can widen access.
|
||||||
|
Audit all policies selecting these pods, node firewall rules, and namespace
|
||||||
|
access. NetworkPolicy is pod-wide, not per-container; nginx and lwproxy share
|
||||||
|
the same narrow egress permissions. It does not protect against privileged
|
||||||
|
node access or enforce loopback isolation.
|
||||||
|
- Kubernetes standard NetworkPolicy **does not support FQDN egress**. The proxy
|
||||||
|
host allowlist is application-layer filtering, not protection from **DNS
|
||||||
|
rebinding**. Keep CIDRs narrow; allowlisted hosts resolving outside them will
|
||||||
|
fail closed. Never add pod, service, node, metadata, or internal control-plane
|
||||||
|
networks to external content allowlists. Content/CDN addresses change; maintain
|
||||||
|
approved CIDRs yourself, or use a separately reviewed CNI-specific FQDN/egress
|
||||||
|
gateway policy outside this role. Do not use `0.0.0.0/0` or `::/0` as a shortcut.
|
||||||
|
- Without HTTPS CIDRs, content/server-list HTTP proxy requests are intentionally
|
||||||
|
blocked even though those hostnames appear in TOML. DNS and game traffic still
|
||||||
|
work. Redirects, CDN aliases, extra ports, or new hostnames require explicit
|
||||||
|
review; no automatic unrestricted egress is added. Browser-direct external
|
||||||
|
fetches are not governed by pod NetworkPolicy and must satisfy COEP/CORS too.
|
||||||
|
- Web and proxy run as numeric non-root users **101** and **65532**, drop **ALL**
|
||||||
|
capabilities, disallow privilege escalation, use read-only roots, and inherit
|
||||||
|
`seccompProfile: RuntimeDefault`. The pod never mounts an API token. There are
|
||||||
|
no embedded credentials or certificates: reference existing pull/TLS Secrets
|
||||||
|
and authenticate only on the controller to avoid leaking secrets into source.
|
||||||
|
- TLS terminates at ingress; keep the in-cluster HTTP hop within a trusted,
|
||||||
|
isolated cluster network. Game UDP is the native Luanti protocol, not added
|
||||||
|
TLS. Configure cluster ingress TLS policy/ciphers and certificate renewal in
|
||||||
|
your ingress infrastructure. SharedArrayBuffer/threaded WASM needs a **secure
|
||||||
|
context** and the preserved COOP `same-origin`, COEP `require-corp`, and
|
||||||
|
`X-Content-Type-Options: nosniff` headers on pages/workers. Do not strip them at
|
||||||
|
ingress. If ingress is disabled, provide trusted HTTPS elsewhere (or use
|
||||||
|
localhost port-forwarding for local testing), not remote plain HTTP.
|
||||||
|
- Non-release paths use `no-cache`; only paths matching the app's immutable
|
||||||
|
**12-lowercase-hex release directory** regex get one-year immutable caching.
|
||||||
|
A SHA256 checksum of the full rendered ConfigMap changes the pod template
|
||||||
|
whenever either runtime config changes, because `subPath` mounts do not
|
||||||
|
update in-place. Upstream runtime/image contract changes need revalidation.
|
||||||
|
- Existing WebSocket sessions belong to one proxy pod. Scaling is supported for
|
||||||
|
new sessions, not session migration; rolling updates/restarts disconnect active
|
||||||
|
games. A startup/readiness TCP probe proves the listener is reachable, not
|
||||||
|
successful game UDP connectivity. Deployment wait does not prove ingress TLS,
|
||||||
|
NetworkPolicy enforcement, content access, or an actual browser game session.
|
||||||
|
|
||||||
|
## Offline tests and staging validation
|
||||||
|
|
||||||
|
With dependencies installed, run from the role checkout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh tests/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
[tests/test.yml](tests/test.yml) includes only `tasks_from: validate.yml` (with
|
||||||
|
`public: true`) and `tasks_from: render.yml`, never the deployment tasks. It uses
|
||||||
|
documentation-network and nonexistent-registry fixtures, renders and parses real
|
||||||
|
YAML, and checks:
|
||||||
|
|
||||||
|
- same-pod layout, ClusterIP 8080-only routing, actual image/config mount contracts;
|
||||||
|
- headers, immutable-cache regex, loopback WebSocket upstream and proxy bind;
|
||||||
|
- security contexts, probes, tmpfs limits, resources, and absence of PVCs;
|
||||||
|
- default-deny port isolation and exact DNS/game/optional HTTPS destinations;
|
||||||
|
- deterministic config checksum and config-triggered rollout changes;
|
||||||
|
- actual EOM playbook variables, TLS ingress, optional cert-manager and annotations;
|
||||||
|
- rejection of empty/unversioned/latest images, malformed/wildcard CIDRs, bad
|
||||||
|
names/hostnames, peer shapes, missing TLS, invalid resources/types and timeouts.
|
||||||
|
|
||||||
|
[tests/run.sh](tests/run.sh) also syntax-checks the deployment playbook and runs
|
||||||
|
its missing-input preflight, verifying that it fails before namespace creation.
|
||||||
|
It runs standard-library unit tests in [tests/test_network.py](tests/test_network.py)
|
||||||
|
for canonical IPv4/IPv6 parsing and strict ingress CIDR exception containment.
|
||||||
|
Negative test assertion failures are **expected and rescued**; the final recap
|
||||||
|
must still show `failed=0` and the runner's success message. No cluster, image
|
||||||
|
pull, container build, or WASM compiler is required. Run in a network-disabled
|
||||||
|
sandbox for extra assurance.
|
||||||
|
|
||||||
|
Before production, separately verify admission/server-side schema acceptance,
|
||||||
|
actual image startup with read-only roots, CNI drop behavior on 7777 and unwanted
|
||||||
|
egress, allowed UDP game connectivity, trusted TLS/HTTPS redirects, and browser
|
||||||
|
`crossOriginIsolated` plus WebSocket game join. These runtime properties cannot
|
||||||
|
be established by offline manifest rendering alone.
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
# Uses the controller's kubeconfig / K8S_AUTH_* authentication; never disables TLS verification.
|
||||||
|
luanti_wasm_namespace: luanti-wasm
|
||||||
|
luanti_wasm_name: luanti-wasm
|
||||||
|
# Build and publish your own images. Upstream does not publish these runtime images.
|
||||||
|
luanti_wasm_web_image: ""
|
||||||
|
luanti_wasm_proxy_image: ""
|
||||||
|
luanti_wasm_image_pull_policy: IfNotPresent
|
||||||
|
luanti_wasm_image_pull_secrets: []
|
||||||
|
luanti_wasm_replicas: 1
|
||||||
|
luanti_wasm_web_resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
luanti_wasm_proxy_resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
luanti_wasm_tmp_size_limit: 64Mi
|
||||||
|
luanti_wasm_node_selector: {}
|
||||||
|
luanti_wasm_tolerations: []
|
||||||
|
luanti_wasm_wait: true
|
||||||
|
luanti_wasm_wait_timeout: 300
|
||||||
|
|
||||||
|
luanti_wasm_ingress_enabled: false
|
||||||
|
luanti_wasm_hostname: ""
|
||||||
|
luanti_wasm_ingress_class: ""
|
||||||
|
luanti_wasm_ingress_annotations:
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
luanti_wasm_tls_enabled: true
|
||||||
|
luanti_wasm_tls_secret_name: "{{ luanti_wasm_name }}-tls"
|
||||||
|
# Empty means a pre-existing, externally managed TLS Secret.
|
||||||
|
luanti_wasm_cluster_issuer: ""
|
||||||
|
luanti_wasm_websocket_timeout_seconds: 3600
|
||||||
|
|
||||||
|
# NetworkPolicy is unconditional, with no unsupported-CNI bypass.
|
||||||
|
# [] omits 'from', allowing traffic from any source, but only to TCP 8080.
|
||||||
|
luanti_wasm_ingress_peers: []
|
||||||
|
# REQUIRED: literal, canonical, non-/0 IPv4/IPv6 networks for the built-in game destination.
|
||||||
|
luanti_wasm_game_cidrs: []
|
||||||
|
luanti_wasm_game_port: 30000
|
||||||
|
# Empty disables external HTTPS egress; DNS + game UDP remain the only egress rules.
|
||||||
|
luanti_wasm_https_cidrs: []
|
||||||
|
luanti_wasm_http_allowed_hosts:
|
||||||
|
- content.luanti.org
|
||||||
|
- servers.luanti.org
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
"""Strict CIDR checks without the ansible.utils / netaddr dependency."""
|
||||||
|
|
||||||
|
from ipaddress import ip_network
|
||||||
|
|
||||||
|
|
||||||
|
def valid_cidr(value: object) -> bool:
|
||||||
|
"""Accept canonical IPv4/IPv6 networks, never a wildcard or scoped address."""
|
||||||
|
if not isinstance(value, str) or "/" not in value or "%" in value:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
network = ip_network(value, strict=True)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return network.prefixlen > 0 and str(network) == value
|
||||||
|
|
||||||
|
|
||||||
|
def valid_ip_block(value: object) -> bool:
|
||||||
|
"""Validate a Kubernetes ipBlock, including contained exception networks."""
|
||||||
|
if not isinstance(value, dict) or set(value) - {"cidr", "except"}:
|
||||||
|
return False
|
||||||
|
if not valid_cidr(value.get("cidr")):
|
||||||
|
return False
|
||||||
|
exceptions = value.get("except", [])
|
||||||
|
if not isinstance(exceptions, list):
|
||||||
|
return False
|
||||||
|
parent = ip_network(value["cidr"])
|
||||||
|
for exception in exceptions:
|
||||||
|
if not valid_cidr(exception):
|
||||||
|
return False
|
||||||
|
child = ip_network(exception)
|
||||||
|
if (
|
||||||
|
child.version != parent.version
|
||||||
|
or child == parent
|
||||||
|
or int(child.network_address) < int(parent.network_address)
|
||||||
|
or int(child.broadcast_address) > int(parent.broadcast_address)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class FilterModule:
|
||||||
|
"""Expose validation predicates to Ansible templates and assertions."""
|
||||||
|
|
||||||
|
def filters(self):
|
||||||
|
return {
|
||||||
|
"luanti_wasm_valid_cidr": valid_cidr,
|
||||||
|
"luanti_wasm_valid_ip_block": valid_ip_block,
|
||||||
|
}
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
galaxy_info:
|
||||||
|
role_name: luanti_wasm
|
||||||
|
author: Eric Meehan
|
||||||
|
description: Deploy a stateless Luanti WASM web client and isolated lwproxy sidecar on Kubernetes
|
||||||
|
license: BSD-3-Clause
|
||||||
|
min_ansible_version: "2.16"
|
||||||
|
dependencies: []
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
# Uses the controller's kubeconfig. Required inputs have no invented image defaults.
|
||||||
|
- name: Deploy eom.dev Luanti WASM client
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
luanti_wasm_namespace: luanti-wasm
|
||||||
|
luanti_wasm_name: luanti-wasm
|
||||||
|
luanti_wasm_web_image: "{{ eom_luanti_wasm_web_image | default('') }}"
|
||||||
|
luanti_wasm_proxy_image: "{{ eom_luanti_wasm_proxy_image | default('') }}"
|
||||||
|
luanti_wasm_game_cidrs: "{{ eom_luanti_wasm_game_cidrs | default([]) }}"
|
||||||
|
luanti_wasm_https_cidrs: "{{ eom_luanti_wasm_https_cidrs | default([]) }}"
|
||||||
|
luanti_wasm_ingress_enabled: true
|
||||||
|
luanti_wasm_hostname: luanti.eom.dev
|
||||||
|
luanti_wasm_ingress_class: nginx
|
||||||
|
luanti_wasm_tls_enabled: true
|
||||||
|
luanti_wasm_tls_secret_name: luanti-wasm-tls
|
||||||
|
luanti_wasm_cluster_issuer: ca-issuer
|
||||||
|
pre_tasks:
|
||||||
|
- name: Require explicit eom build images and destination networks
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- eom_luanti_wasm_web_image | default('') | length > 0
|
||||||
|
- eom_luanti_wasm_proxy_image | default('') | length > 0
|
||||||
|
- eom_luanti_wasm_game_cidrs | default([]) | length > 0
|
||||||
|
fail_msg: Supply eom_luanti_wasm_web_image, eom_luanti_wasm_proxy_image, and eom_luanti_wasm_game_cidrs.
|
||||||
|
roles:
|
||||||
|
- ansible-role-luanti-wasm
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
ansible-core>=2.16,<2.20
|
||||||
|
kubernetes>=24.2.0,<35
|
||||||
|
PyYAML>=6.0,<7
|
||||||
|
jsonpatch>=1.32,<2
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
collections:
|
||||||
|
- name: kubernetes.core
|
||||||
|
version: 6.0.0
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
- name: Validate Luanti configuration before contacting Kubernetes
|
||||||
|
ansible.builtin.import_tasks: validate.yml
|
||||||
|
|
||||||
|
- name: Render Luanti resources before contacting Kubernetes
|
||||||
|
ansible.builtin.import_tasks: render.yml
|
||||||
|
|
||||||
|
- name: Create Luanti namespace
|
||||||
|
kubernetes.core.k8s:
|
||||||
|
state: present
|
||||||
|
definition:
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: "{{ luanti_wasm_namespace }}"
|
||||||
|
|
||||||
|
# Apply isolation before creating any pods. Enforcement requires a capable CNI.
|
||||||
|
- name: Apply mandatory Luanti network policy and configuration
|
||||||
|
kubernetes.core.k8s:
|
||||||
|
state: present
|
||||||
|
definition: "{{ _luanti_wasm_manifests[item] }}"
|
||||||
|
loop:
|
||||||
|
- networkpolicy
|
||||||
|
- configmap
|
||||||
|
|
||||||
|
- name: Deploy Luanti web and proxy sidecar
|
||||||
|
kubernetes.core.k8s:
|
||||||
|
state: present
|
||||||
|
definition: "{{ _luanti_wasm_manifests.deployment }}"
|
||||||
|
wait: "{{ luanti_wasm_wait }}"
|
||||||
|
wait_timeout: "{{ luanti_wasm_wait_timeout }}"
|
||||||
|
|
||||||
|
- name: Create Luanti private web service
|
||||||
|
kubernetes.core.k8s:
|
||||||
|
state: present
|
||||||
|
definition: "{{ _luanti_wasm_manifests.service }}"
|
||||||
|
|
||||||
|
- name: Reconcile optional Luanti TLS ingress
|
||||||
|
kubernetes.core.k8s:
|
||||||
|
state: "{{ 'present' if luanti_wasm_ingress_enabled else 'absent' }}"
|
||||||
|
definition: "{{ _luanti_wasm_manifests.ingress }}"
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
# Also used by the offline regression suite; no Kubernetes API access.
|
||||||
|
- name: Render Luanti Kubernetes resources
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_luanti_wasm_manifests:
|
||||||
|
configmap: "{{ lookup('ansible.builtin.template', 'configmap.yml.j2') | from_yaml }}"
|
||||||
|
deployment: "{{ lookup('ansible.builtin.template', 'deployment.yml.j2') | from_yaml }}"
|
||||||
|
service: "{{ lookup('ansible.builtin.template', 'service.yml.j2') | from_yaml }}"
|
||||||
|
networkpolicy: "{{ lookup('ansible.builtin.template', 'networkpolicy.yml.j2') | from_yaml }}"
|
||||||
|
ingress: "{{ lookup('ansible.builtin.template', 'ingress.yml.j2') | from_yaml }}"
|
||||||
Executable
+129
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
- name: Validate Luanti identity and scalar settings
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- luanti_wasm_name is string
|
||||||
|
- luanti_wasm_name is match('^[a-z]([-a-z0-9]*[a-z0-9])?$')
|
||||||
|
- luanti_wasm_name | length <= 53
|
||||||
|
- luanti_wasm_namespace is string
|
||||||
|
- luanti_wasm_namespace is match('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')
|
||||||
|
- luanti_wasm_namespace | length <= 53
|
||||||
|
- luanti_wasm_replicas is integer and luanti_wasm_replicas is not boolean
|
||||||
|
- luanti_wasm_replicas >= 1
|
||||||
|
- luanti_wasm_game_port is integer and luanti_wasm_game_port is not boolean
|
||||||
|
- luanti_wasm_game_port >= 1 and luanti_wasm_game_port <= 65535
|
||||||
|
- luanti_wasm_websocket_timeout_seconds is integer and luanti_wasm_websocket_timeout_seconds is not boolean
|
||||||
|
- luanti_wasm_websocket_timeout_seconds > 0
|
||||||
|
- luanti_wasm_wait_timeout is integer and luanti_wasm_wait_timeout is not boolean
|
||||||
|
- luanti_wasm_wait_timeout > 0
|
||||||
|
- luanti_wasm_wait is boolean
|
||||||
|
- luanti_wasm_ingress_enabled is boolean
|
||||||
|
- luanti_wasm_tls_enabled is boolean
|
||||||
|
- luanti_wasm_image_pull_policy in ['Always', 'IfNotPresent', 'Never']
|
||||||
|
- luanti_wasm_tmp_size_limit is string
|
||||||
|
- luanti_wasm_tmp_size_limit is match('^[1-9][0-9]*(Ki|Mi|Gi)$')
|
||||||
|
- luanti_wasm_node_selector is mapping
|
||||||
|
- luanti_wasm_ingress_annotations is mapping
|
||||||
|
fail_msg: Invalid Luanti identity, integer/boolean settings, image pull policy, or tmp size.
|
||||||
|
|
||||||
|
- name: Validate explicit versioned Luanti images
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item is string
|
||||||
|
- item | length > 0
|
||||||
|
- item is match('^(?:[a-z0-9]+(?:[.-][a-z0-9]+)*(?::[0-9]+)?/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$')
|
||||||
|
- item is search(':[A-Za-z0-9_][A-Za-z0-9_.-]*$') or item is search('@sha256:[a-f0-9]{64}$')
|
||||||
|
- item is not search('(?i):latest(?:@|$)')
|
||||||
|
fail_msg: Supply separate web/proxy image references with an explicit non-latest tag or sha256 digest.
|
||||||
|
loop: "{{ [luanti_wasm_web_image, luanti_wasm_proxy_image] }}"
|
||||||
|
|
||||||
|
- name: Validate Luanti list settings
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item is sequence and item is not string and item is not mapping
|
||||||
|
loop: "{{ [luanti_wasm_image_pull_secrets, luanti_wasm_tolerations, luanti_wasm_ingress_peers, luanti_wasm_game_cidrs, luanti_wasm_https_cidrs, luanti_wasm_http_allowed_hosts] }}"
|
||||||
|
|
||||||
|
- name: Require an explicit game destination
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- luanti_wasm_game_cidrs | length > 0
|
||||||
|
fail_msg: Supply nonempty luanti_wasm_game_cidrs matching the game destination built into the web image.
|
||||||
|
|
||||||
|
- name: Validate literal Luanti egress CIDRs
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item | luanti_wasm_valid_cidr
|
||||||
|
fail_msg: Egress requires canonical IPv4/IPv6 CIDRs with no host bits, zone IDs, or /0 wildcard.
|
||||||
|
loop: "{{ luanti_wasm_game_cidrs + luanti_wasm_https_cidrs }}"
|
||||||
|
|
||||||
|
- name: Validate Luanti ingress peers
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item is mapping
|
||||||
|
- item | length > 0
|
||||||
|
- item.keys() | difference(['namespaceSelector', 'podSelector', 'ipBlock']) | length == 0
|
||||||
|
- "'namespaceSelector' not in item or item.namespaceSelector is mapping"
|
||||||
|
- "'podSelector' not in item or item.podSelector is mapping"
|
||||||
|
- "'ipBlock' not in item or (item | length == 1 and item.ipBlock | luanti_wasm_valid_ip_block)"
|
||||||
|
loop: "{{ luanti_wasm_ingress_peers }}"
|
||||||
|
|
||||||
|
- name: Validate Luanti resources
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item is mapping
|
||||||
|
- item.requests is defined
|
||||||
|
- item.limits is defined
|
||||||
|
- item.requests is mapping
|
||||||
|
- item.limits is mapping
|
||||||
|
- item.requests.cpu is defined and item.requests.memory is defined
|
||||||
|
- item.limits.cpu is defined and item.limits.memory is defined
|
||||||
|
loop: "{{ [luanti_wasm_web_resources, luanti_wasm_proxy_resources] }}"
|
||||||
|
|
||||||
|
- name: Validate pull secret references
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item is mapping
|
||||||
|
- item.name is defined
|
||||||
|
- item.name is string
|
||||||
|
- item.name | length <= 253
|
||||||
|
- item.name is match('^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$')
|
||||||
|
loop: "{{ luanti_wasm_image_pull_secrets }}"
|
||||||
|
|
||||||
|
- name: Validate toleration objects
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item is mapping
|
||||||
|
loop: "{{ luanti_wasm_tolerations }}"
|
||||||
|
|
||||||
|
- name: Validate Luanti annotations
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item.key is string
|
||||||
|
- item.value is string
|
||||||
|
loop: "{{ luanti_wasm_ingress_annotations | dict2items }}"
|
||||||
|
|
||||||
|
- name: Validate proxy HTTP host allowlist
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item is string
|
||||||
|
- item | length <= 253
|
||||||
|
- item is match('^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$')
|
||||||
|
fail_msg: HTTP proxy allowlist accepts exact DNS names, not URLs or wildcards.
|
||||||
|
loop: "{{ luanti_wasm_http_allowed_hosts }}"
|
||||||
|
|
||||||
|
- name: Validate Luanti ingress TLS settings
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- luanti_wasm_tls_enabled
|
||||||
|
- luanti_wasm_hostname is string
|
||||||
|
- luanti_wasm_hostname is match('^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$')
|
||||||
|
- luanti_wasm_tls_secret_name is string
|
||||||
|
- luanti_wasm_tls_secret_name | length <= 253
|
||||||
|
- luanti_wasm_tls_secret_name is match('^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$')
|
||||||
|
- luanti_wasm_ingress_class is string
|
||||||
|
- luanti_wasm_ingress_class | length == 0 or luanti_wasm_ingress_class is match('^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$')
|
||||||
|
- luanti_wasm_cluster_issuer is string
|
||||||
|
- luanti_wasm_cluster_issuer | length <= 253
|
||||||
|
- luanti_wasm_cluster_issuer | length == 0 or luanti_wasm_cluster_issuer is match('^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$')
|
||||||
|
fail_msg: Ingress requires a valid DNS hostname and TLS secret; TLS cannot be disabled for WASM secure contexts.
|
||||||
|
when: luanti_wasm_ingress_enabled
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ (luanti_wasm_name ~ '-config') | to_json }}
|
||||||
|
namespace: {{ luanti_wasm_namespace | to_json }}
|
||||||
|
data:
|
||||||
|
nginx.conf: {{ lookup('ansible.builtin.template', 'nginx.conf.j2') | to_json }}
|
||||||
|
lwproxy.toml: {{ lookup('ansible.builtin.template', 'proxy.toml.j2') | to_json }}
|
||||||
Executable
+110
@@ -0,0 +1,110 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ luanti_wasm_name | to_json }}
|
||||||
|
namespace: {{ luanti_wasm_namespace | to_json }}
|
||||||
|
spec:
|
||||||
|
replicas: {{ luanti_wasm_replicas }}
|
||||||
|
revisionHistoryLimit: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: {{ luanti_wasm_name | to_json }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: {{ luanti_wasm_name | to_json }}
|
||||||
|
annotations:
|
||||||
|
checksum/config: {{ lookup('ansible.builtin.template', 'configmap.yml.j2') | hash('sha256') | to_json }}
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
fsGroup: 101
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
imagePullSecrets: {{ luanti_wasm_image_pull_secrets | to_json }}
|
||||||
|
nodeSelector: {{ luanti_wasm_node_selector | to_json }}
|
||||||
|
tolerations: {{ luanti_wasm_tolerations | to_json }}
|
||||||
|
containers:
|
||||||
|
- name: web
|
||||||
|
image: {{ luanti_wasm_web_image | to_json }}
|
||||||
|
imagePullPolicy: {{ luanti_wasm_image_pull_policy | to_json }}
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 101
|
||||||
|
runAsGroup: 101
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8080
|
||||||
|
protocol: TCP
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: http
|
||||||
|
periodSeconds: 2
|
||||||
|
failureThreshold: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: http
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: http
|
||||||
|
periodSeconds: 20
|
||||||
|
resources: {{ luanti_wasm_web_resources | to_json }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: /etc/nginx/nginx.conf
|
||||||
|
subPath: nginx.conf
|
||||||
|
readOnly: true
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
- name: proxy
|
||||||
|
image: {{ luanti_wasm_proxy_image | to_json }}
|
||||||
|
imagePullPolicy: {{ luanti_wasm_image_pull_policy | to_json }}
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
runAsGroup: 65532
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
ports:
|
||||||
|
- name: proxy
|
||||||
|
containerPort: 7777
|
||||||
|
protocol: TCP
|
||||||
|
startupProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: proxy
|
||||||
|
periodSeconds: 2
|
||||||
|
failureThreshold: 30
|
||||||
|
readinessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: proxy
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: proxy
|
||||||
|
periodSeconds: 20
|
||||||
|
resources: {{ luanti_wasm_proxy_resources | to_json }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: /etc/lwproxy.toml
|
||||||
|
subPath: lwproxy.toml
|
||||||
|
readOnly: true
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: {{ (luanti_wasm_name ~ '-config') | to_json }}
|
||||||
|
defaultMode: 0444
|
||||||
|
- name: tmp
|
||||||
|
emptyDir:
|
||||||
|
medium: Memory
|
||||||
|
sizeLimit: {{ luanti_wasm_tmp_size_limit | to_json }}
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ luanti_wasm_name | to_json }}
|
||||||
|
namespace: {{ luanti_wasm_namespace | to_json }}
|
||||||
|
{% if luanti_wasm_ingress_enabled %}
|
||||||
|
annotations: {{ (luanti_wasm_ingress_annotations | combine({'cert-manager.io/cluster-issuer': luanti_wasm_cluster_issuer} if luanti_wasm_cluster_issuer | length > 0 else {})) | to_json }}
|
||||||
|
spec:
|
||||||
|
{% if luanti_wasm_ingress_class | length > 0 %}
|
||||||
|
ingressClassName: {{ luanti_wasm_ingress_class | to_json }}
|
||||||
|
{% endif %}
|
||||||
|
tls:
|
||||||
|
- hosts: [{{ luanti_wasm_hostname | to_json }}]
|
||||||
|
secretName: {{ luanti_wasm_tls_secret_name | to_json }}
|
||||||
|
rules:
|
||||||
|
- host: {{ luanti_wasm_hostname | to_json }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ luanti_wasm_name | to_json }}
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
|
{% endif %}
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: {{ luanti_wasm_name | to_json }}
|
||||||
|
namespace: {{ luanti_wasm_namespace | to_json }}
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: {{ luanti_wasm_name | to_json }}
|
||||||
|
policyTypes: [Ingress, Egress]
|
||||||
|
ingress:
|
||||||
|
- ports:
|
||||||
|
- port: 8080
|
||||||
|
protocol: TCP
|
||||||
|
{% if luanti_wasm_ingress_peers | length > 0 %}
|
||||||
|
from: {{ luanti_wasm_ingress_peers | to_json }}
|
||||||
|
{% endif %}
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: kube-system
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
k8s-app: kube-dns
|
||||||
|
ports:
|
||||||
|
- port: 53
|
||||||
|
protocol: UDP
|
||||||
|
- port: 53
|
||||||
|
protocol: TCP
|
||||||
|
- to:
|
||||||
|
{% for cidr in luanti_wasm_game_cidrs %}
|
||||||
|
- ipBlock:
|
||||||
|
cidr: {{ cidr | to_json }}
|
||||||
|
{% endfor %}
|
||||||
|
ports:
|
||||||
|
- port: {{ luanti_wasm_game_port }}
|
||||||
|
protocol: UDP
|
||||||
|
{% if luanti_wasm_https_cidrs | length > 0 %}
|
||||||
|
- to:
|
||||||
|
{% for cidr in luanti_wasm_https_cidrs %}
|
||||||
|
- ipBlock:
|
||||||
|
cidr: {{ cidr | to_json }}
|
||||||
|
{% endfor %}
|
||||||
|
ports:
|
||||||
|
- port: 443
|
||||||
|
protocol: TCP
|
||||||
|
{% endif %}
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
worker_processes auto;
|
||||||
|
pid /tmp/nginx.pid;
|
||||||
|
error_log /dev/stderr warn;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
access_log /dev/stdout;
|
||||||
|
sendfile on;
|
||||||
|
server_tokens off;
|
||||||
|
client_body_temp_path /tmp/client_temp;
|
||||||
|
proxy_temp_path /tmp/proxy_temp;
|
||||||
|
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||||
|
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||||
|
scgi_temp_path /tmp/scgi_temp;
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
map $uri $cache_control {
|
||||||
|
default "no-cache";
|
||||||
|
"~^/[0-9a-f]{12}/" "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
add_header Cross-Origin-Opener-Policy same-origin always;
|
||||||
|
add_header Cross-Origin-Embedder-Policy require-corp always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Cache-Control $cache_control always;
|
||||||
|
|
||||||
|
location = /proxy {
|
||||||
|
proxy_pass http://127.0.0.1:7777;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout {{ luanti_wasm_websocket_timeout_seconds }}s;
|
||||||
|
proxy_send_timeout {{ luanti_wasm_websocket_timeout_seconds }}s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
[[serve]]
|
||||||
|
protocol = "ws"
|
||||||
|
# Pod-IP binding is required for kubelet TCP probes. NetworkPolicy denies ingress
|
||||||
|
# to 7777; nginx talks to this sidecar through the shared pod loopback interface.
|
||||||
|
bind_address = "0.0.0.0:7777"
|
||||||
|
|
||||||
|
[http_proxy]
|
||||||
|
# JSON string arrays are valid TOML, including escaping untrusted strings.
|
||||||
|
allowed_hosts = {{ luanti_wasm_http_allowed_hosts | to_json }}
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ luanti_wasm_name | to_json }}
|
||||||
|
namespace: {{ luanti_wasm_namespace | to_json }}
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app: {{ luanti_wasm_name | to_json }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8080
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
- name: Apply invalid test settings
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
"{{ setting.key }}": "{{ setting.value }}"
|
||||||
|
loop: "{{ invalid_settings | dict2items }}"
|
||||||
|
loop_control:
|
||||||
|
loop_var: setting
|
||||||
|
|
||||||
|
- name: Verify validation rejects the input
|
||||||
|
block:
|
||||||
|
- name: Run role preflight with invalid settings
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: ansible-role-luanti-wasm
|
||||||
|
tasks_from: validate.yml
|
||||||
|
- name: Fail if invalid configuration was accepted
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: "Invalid configuration was accepted: {{ invalid_settings }}"
|
||||||
|
rescue:
|
||||||
|
- name: Require a real assertion failure, not a template error or test sentinel
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- ansible_failed_task.action == 'ansible.builtin.assert'
|
||||||
|
- ansible_failed_task.name is match('Validate|Require an explicit')
|
||||||
|
- (ansible_failed_result.results | default([ansible_failed_result]) | selectattr('failed', 'defined') | selectattr('failed') | list | length) > 0
|
||||||
|
- name: Check each failed assertion, including loop result envelopes
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- rejection.assertion is defined
|
||||||
|
- rejection.evaluated_to is defined
|
||||||
|
- not rejection.evaluated_to
|
||||||
|
loop: "{{ ansible_failed_result.results | default([ansible_failed_result]) | selectattr('failed', 'defined') | selectattr('failed') | list }}"
|
||||||
|
loop_control:
|
||||||
|
loop_var: rejection
|
||||||
|
always:
|
||||||
|
- name: Restore valid settings
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
"{{ setting.key }}": "{{ _luanti_baseline[setting.key] }}"
|
||||||
|
loop: "{{ invalid_settings | dict2items }}"
|
||||||
|
loop_control:
|
||||||
|
loop_var: setting
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
export ANSIBLE_ROLES_PATH="$(dirname "$PWD")${ANSIBLE_ROLES_PATH:+:$ANSIBLE_ROLES_PATH}"
|
||||||
|
python3 -m unittest discover -s tests -p test_network.py
|
||||||
|
ansible-playbook -i localhost, playbooks/deploy-eom.yml --syntax-check
|
||||||
|
ansible-playbook -i localhost, tests/test.yml
|
||||||
|
|
||||||
|
# This real deployment entrypoint must fail before any Kubernetes task is run.
|
||||||
|
log=$(mktemp)
|
||||||
|
trap 'rm -f "$log"' EXIT HUP INT TERM
|
||||||
|
if ansible-playbook -i localhost, playbooks/deploy-eom.yml > "$log" 2>&1; then
|
||||||
|
echo 'Deployment accepted missing required inputs' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q 'Supply eom_luanti_wasm_web_image' "$log"
|
||||||
|
if grep -q 'TASK \[ansible-role-luanti-wasm : Create Luanti namespace\]' "$log"; then
|
||||||
|
echo 'Missing inputs reached Kubernetes tasks' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo 'Luanti WASM offline checks passed'
|
||||||
Executable
+267
@@ -0,0 +1,267 @@
|
|||||||
|
---
|
||||||
|
- name: Validate Luanti WASM role without a cluster
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
# Non-routable documentation fixtures only; never pulled or deployed.
|
||||||
|
luanti_wasm_web_image: registry.invalid/fixture/luanti-web:test-1
|
||||||
|
luanti_wasm_proxy_image: "registry.invalid/fixture/lwproxy@sha256:{{ 'a' * 64 }}"
|
||||||
|
luanti_wasm_game_cidrs: [192.0.2.15/32, '2001:db8::15/128']
|
||||||
|
tasks:
|
||||||
|
- name: Load defaults and validate fixture settings
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: ansible-role-luanti-wasm
|
||||||
|
tasks_from: validate.yml
|
||||||
|
public: true
|
||||||
|
|
||||||
|
- name: Render the actual role templates
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: ansible-role-luanti-wasm
|
||||||
|
tasks_from: render.yml
|
||||||
|
|
||||||
|
- name: Check private stateless defaults and service isolation
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- not luanti_wasm_ingress_enabled
|
||||||
|
- _luanti_wasm_manifests.deployment.apiVersion == 'apps/v1'
|
||||||
|
- _luanti_wasm_manifests.deployment.spec.replicas == 1
|
||||||
|
- _luanti_wasm_manifests.deployment.spec.template.spec.containers | length == 2
|
||||||
|
- _luanti_wasm_manifests.service.spec.type == 'ClusterIP'
|
||||||
|
- _luanti_wasm_manifests.service.spec.ports | map(attribute='port') | list == [8080]
|
||||||
|
- _luanti_wasm_manifests.service.spec.ports[0].targetPort == 'http'
|
||||||
|
- _luanti_wasm_manifests.service.spec.selector == _luanti_wasm_manifests.deployment.spec.template.metadata.labels
|
||||||
|
- "'hostNetwork' not in _luanti_wasm_manifests.deployment.spec.template.spec"
|
||||||
|
- "'spec' not in _luanti_wasm_manifests.ingress"
|
||||||
|
- _luanti_wasm_manifests.deployment.spec.template.spec.volumes | length == 2
|
||||||
|
- _luanti_wasm_manifests.deployment.spec.template.spec.volumes | selectattr('persistentVolumeClaim', 'defined') | list | length == 0
|
||||||
|
|
||||||
|
- name: Check pod security, bounded memory temporary volume, and probes
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- not pod.automountServiceAccountToken
|
||||||
|
- pod.securityContext.runAsNonRoot
|
||||||
|
- pod.securityContext.seccompProfile.type == 'RuntimeDefault'
|
||||||
|
- pod.securityContext.fsGroup == 101
|
||||||
|
- pod.volumes[1].emptyDir == {'medium':'Memory', 'sizeLimit':'64Mi'}
|
||||||
|
- pod.containers[0].securityContext.runAsUser == 101
|
||||||
|
- pod.containers[1].securityContext.runAsUser == 65532
|
||||||
|
- pod.containers[0].readinessProbe.httpGet == {'path':'/', 'port':'http'}
|
||||||
|
- pod.containers[0].livenessProbe.httpGet.port == 'http'
|
||||||
|
- pod.containers[1].readinessProbe.tcpSocket.port == 'proxy'
|
||||||
|
- pod.containers[1].livenessProbe.tcpSocket.port == 'proxy'
|
||||||
|
- pod.containers[0].image == luanti_wasm_web_image
|
||||||
|
- pod.containers[1].image == luanti_wasm_proxy_image
|
||||||
|
- pod.containers[0].volumeMounts[0].mountPath == '/etc/nginx/nginx.conf'
|
||||||
|
- pod.containers[1].volumeMounts[0].mountPath == '/etc/lwproxy.toml'
|
||||||
|
- pod.containers[1].volumeMounts[0].subPath == 'lwproxy.toml'
|
||||||
|
vars:
|
||||||
|
pod: "{{ _luanti_wasm_manifests.deployment.spec.template.spec }}"
|
||||||
|
|
||||||
|
- name: Check both containers are hardened and resource bounded
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- item.securityContext.runAsNonRoot
|
||||||
|
- item.securityContext.readOnlyRootFilesystem
|
||||||
|
- not item.securityContext.allowPrivilegeEscalation
|
||||||
|
- item.securityContext.capabilities.drop == ['ALL']
|
||||||
|
- item.resources.requests == {'cpu':'100m', 'memory':'64Mi'}
|
||||||
|
- item.resources.limits.memory == '256Mi'
|
||||||
|
- item.volumeMounts[0].readOnly
|
||||||
|
- item.startupProbe.failureThreshold == 30
|
||||||
|
- item.ports | selectattr('hostPort', 'defined') | list | length == 0
|
||||||
|
loop: "{{ _luanti_wasm_manifests.deployment.spec.template.spec.containers }}"
|
||||||
|
|
||||||
|
- name: Check WASM headers, release cache contract, and localhost routing
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- "'add_header Cross-Origin-Opener-Policy same-origin always;' in nginx"
|
||||||
|
- "'add_header Cross-Origin-Embedder-Policy require-corp always;' in nginx"
|
||||||
|
- "'add_header X-Content-Type-Options nosniff always;' in nginx"
|
||||||
|
- "'~^/[0-9a-f]{12}/' in nginx"
|
||||||
|
- "'public, max-age=31536000, immutable' in nginx"
|
||||||
|
- "'default \"no-cache\";' in nginx"
|
||||||
|
- "'proxy_pass http://127.0.0.1:7777;' in nginx"
|
||||||
|
- "'proxy_set_header Upgrade $http_upgrade;' in nginx"
|
||||||
|
- "'proxy_read_timeout 3600s;' in nginx"
|
||||||
|
- "'proxy_buffering off;' in nginx"
|
||||||
|
- "'bind_address = \"0.0.0.0:7777\"' in proxy"
|
||||||
|
- "'allowed_hosts = [\"content.luanti.org\", \"servers.luanti.org\"]' in proxy"
|
||||||
|
- _luanti_wasm_manifests.configmap.metadata.name == 'luanti-wasm-config'
|
||||||
|
- _luanti_wasm_manifests.deployment.spec.template.spec.volumes[0].configMap.name == _luanti_wasm_manifests.configmap.metadata.name
|
||||||
|
vars:
|
||||||
|
nginx: "{{ _luanti_wasm_manifests.configmap.data['nginx.conf'] }}"
|
||||||
|
proxy: "{{ _luanti_wasm_manifests.configmap.data['lwproxy.toml'] }}"
|
||||||
|
|
||||||
|
- name: Check mandatory policy and absence of wildcard egress
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- policy.policyTypes == ['Ingress', 'Egress']
|
||||||
|
- policy.podSelector.matchLabels == _luanti_wasm_manifests.service.spec.selector
|
||||||
|
- policy.ingress == [{'ports':[{'port':8080, 'protocol':'TCP'}]}]
|
||||||
|
- policy.egress | length == 2
|
||||||
|
- policy.egress[0].to == [{'namespaceSelector':{'matchLabels':{'kubernetes.io/metadata.name':'kube-system'}}, 'podSelector':{'matchLabels':{'k8s-app':'kube-dns'}}}]
|
||||||
|
- policy.egress[0].ports == [{'port':53, 'protocol':'UDP'}, {'port':53, 'protocol':'TCP'}]
|
||||||
|
- policy.egress[1].to | map(attribute='ipBlock.cidr') | list == luanti_wasm_game_cidrs
|
||||||
|
- policy.egress[1].ports == [{'port':30000, 'protocol':'UDP'}]
|
||||||
|
vars:
|
||||||
|
policy: "{{ _luanti_wasm_manifests.networkpolicy.spec }}"
|
||||||
|
|
||||||
|
- name: Preserve initial checksum
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_initial_checksum: "{{ _luanti_wasm_manifests.deployment.spec.template.metadata.annotations['checksum/config'] }}"
|
||||||
|
|
||||||
|
- name: Re-render identical settings
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: ansible-role-luanti-wasm
|
||||||
|
tasks_from: render.yml
|
||||||
|
|
||||||
|
- name: Check deterministic SHA256 checksum
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- _initial_checksum is match('^[a-f0-9]{64}$')
|
||||||
|
- _initial_checksum == _luanti_wasm_manifests.deployment.spec.template.metadata.annotations['checksum/config']
|
||||||
|
|
||||||
|
- name: Configure optional egress and ingress restrictions
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
luanti_wasm_https_cidrs: [198.51.100.0/24, '2001:db8:1::/64']
|
||||||
|
luanti_wasm_ingress_peers:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: ingress-nginx
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/component: controller
|
||||||
|
- ipBlock:
|
||||||
|
cidr: 203.0.113.0/24
|
||||||
|
except: [203.0.113.1/32]
|
||||||
|
luanti_wasm_http_allowed_hosts: [content.luanti.org]
|
||||||
|
luanti_wasm_websocket_timeout_seconds: 7200
|
||||||
|
luanti_wasm_replicas: 2
|
||||||
|
|
||||||
|
- name: Validate optional settings
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: ansible-role-luanti-wasm
|
||||||
|
tasks_from: validate.yml
|
||||||
|
|
||||||
|
- name: Render optional settings
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: ansible-role-luanti-wasm
|
||||||
|
tasks_from: render.yml
|
||||||
|
|
||||||
|
- name: Check optional rules never widen ports or open arbitrary egress
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- _luanti_wasm_manifests.deployment.spec.replicas == 2
|
||||||
|
- _luanti_wasm_manifests.networkpolicy.spec.ingress[0]['from'] == luanti_wasm_ingress_peers
|
||||||
|
- _luanti_wasm_manifests.networkpolicy.spec.ingress[0].ports == [{'port':8080, 'protocol':'TCP'}]
|
||||||
|
- _luanti_wasm_manifests.networkpolicy.spec.egress | length == 3
|
||||||
|
- _luanti_wasm_manifests.networkpolicy.spec.egress[2].ports == [{'port':443, 'protocol':'TCP'}]
|
||||||
|
- _luanti_wasm_manifests.networkpolicy.spec.egress[2].to | map(attribute='ipBlock.cidr') | list == luanti_wasm_https_cidrs
|
||||||
|
- _initial_checksum != _luanti_wasm_manifests.deployment.spec.template.metadata.annotations['checksum/config']
|
||||||
|
- "'proxy_read_timeout 7200s;' in _luanti_wasm_manifests.configmap.data['nginx.conf']"
|
||||||
|
|
||||||
|
- name: Supply fixture eom inputs
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
eom_luanti_wasm_web_image: registry.invalid/fixture/eom-web:release-1
|
||||||
|
eom_luanti_wasm_proxy_image: registry.invalid/fixture/eom-proxy:release-1
|
||||||
|
eom_luanti_wasm_game_cidrs: [192.0.2.15/32]
|
||||||
|
|
||||||
|
- name: Load real eom playbook variables
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
# Rendering an entire YAML file quotes its embedded list expressions;
|
||||||
|
# restore those two list types, as native playbook evaluation does.
|
||||||
|
"{{ item.key }}": "{{ (item.value | from_yaml) if item.key in ['luanti_wasm_game_cidrs', 'luanti_wasm_https_cidrs'] else item.value }}"
|
||||||
|
loop: "{{ (lookup('ansible.builtin.template', '../playbooks/deploy-eom.yml') | from_yaml)[0].vars | dict2items }}"
|
||||||
|
|
||||||
|
- name: Validate eom deployment settings without deploying
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: ansible-role-luanti-wasm
|
||||||
|
tasks_from: validate.yml
|
||||||
|
|
||||||
|
- name: Render eom deployment settings
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: ansible-role-luanti-wasm
|
||||||
|
tasks_from: render.yml
|
||||||
|
|
||||||
|
- name: Check TLS ingress and eom integration
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- _luanti_wasm_manifests.ingress.apiVersion == 'networking.k8s.io/v1'
|
||||||
|
- _luanti_wasm_manifests.ingress.spec.ingressClassName == 'nginx'
|
||||||
|
- _luanti_wasm_manifests.ingress.spec.rules[0].host == 'luanti.eom.dev'
|
||||||
|
- _luanti_wasm_manifests.ingress.spec.tls == [{'hosts':['luanti.eom.dev'], 'secretName':'luanti-wasm-tls'}]
|
||||||
|
- _luanti_wasm_manifests.ingress.spec.rules[0].http.paths[0].backend.service.port.number == 8080
|
||||||
|
- _luanti_wasm_manifests.ingress.metadata.annotations['cert-manager.io/cluster-issuer'] == 'ca-issuer'
|
||||||
|
- _luanti_wasm_manifests.ingress.metadata.annotations['nginx.ingress.kubernetes.io/proxy-read-timeout'] == '3600'
|
||||||
|
- _luanti_wasm_manifests.deployment.spec.template.spec.containers[0].image == eom_luanti_wasm_web_image
|
||||||
|
|
||||||
|
- name: Exercise external TLS and configurable annotations
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
luanti_wasm_cluster_issuer: ""
|
||||||
|
luanti_wasm_ingress_annotations:
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "7200"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "7200"
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
|
||||||
|
- name: Render external TLS configuration
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: ansible-role-luanti-wasm
|
||||||
|
tasks_from: render.yml
|
||||||
|
|
||||||
|
- name: Check cert-manager is optional but TLS is not
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- "'cert-manager.io/cluster-issuer' not in _luanti_wasm_manifests.ingress.metadata.annotations"
|
||||||
|
- _luanti_wasm_manifests.ingress.spec.tls | length == 1
|
||||||
|
- _luanti_wasm_manifests.ingress.metadata.annotations['nginx.ingress.kubernetes.io/proxy-read-timeout'] == '7200'
|
||||||
|
|
||||||
|
- name: Capture valid baseline for rejection tests
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
_luanti_baseline: "{{ vars | dict2items | selectattr('key', 'match', '^luanti_wasm_') | items2dict }}"
|
||||||
|
|
||||||
|
- name: Reject invalid settings at role validation, never at Kubernetes
|
||||||
|
ansible.builtin.include_tasks: invalid.yml
|
||||||
|
loop:
|
||||||
|
- {luanti_wasm_web_image: ""}
|
||||||
|
- {luanti_wasm_proxy_image: ""}
|
||||||
|
- {luanti_wasm_web_image: 'registry.invalid/team/web'}
|
||||||
|
- {luanti_wasm_proxy_image: 'registry.invalid/team/proxy:latest'}
|
||||||
|
- {luanti_wasm_web_image: 'registry.invalid/team/web:LaTeSt'}
|
||||||
|
- {luanti_wasm_proxy_image: 'registry.invalid/team/proxy@sha256:abc'}
|
||||||
|
- {luanti_wasm_web_image: 'https://registry.invalid/team/web:1'}
|
||||||
|
- {luanti_wasm_game_cidrs: []}
|
||||||
|
- {luanti_wasm_game_cidrs: ['0.0.0.0/0']}
|
||||||
|
- {luanti_wasm_https_cidrs: ['::/0']}
|
||||||
|
- {luanti_wasm_game_cidrs: ['192.0.2.999/32']}
|
||||||
|
- {luanti_wasm_game_cidrs: ['192.0.2.1/24']}
|
||||||
|
- {luanti_wasm_game_cidrs: ['2001:db8:::/64']}
|
||||||
|
- {luanti_wasm_game_cidrs: ['2001:db8::/129']}
|
||||||
|
- {luanti_wasm_game_cidrs: ['luanti.eom.dev/32']}
|
||||||
|
- {luanti_wasm_game_cidrs: '192.0.2.0/24'}
|
||||||
|
- {luanti_wasm_ingress_peers: [{}]}
|
||||||
|
- {luanti_wasm_ingress_peers: ['not-an-object']}
|
||||||
|
- {luanti_wasm_ingress_peers: [{namespaceSelector: 'not-an-object'}]}
|
||||||
|
- {luanti_wasm_ingress_peers: [{ports: [7777]}]}
|
||||||
|
- {luanti_wasm_ingress_peers: [{ipBlock: {cidr: '0.0.0.0/0'}}]}
|
||||||
|
- {luanti_wasm_ingress_peers: [{ipBlock: {cidr: '192.0.2.0/24', except: ['198.51.100.0/24']}}]}
|
||||||
|
- {luanti_wasm_tls_enabled: false}
|
||||||
|
- {luanti_wasm_tls_secret_name: ""}
|
||||||
|
- {luanti_wasm_hostname: 'https://luanti.eom.dev/'}
|
||||||
|
- {luanti_wasm_hostname: '*.eom.dev'}
|
||||||
|
- {luanti_wasm_name: 'Invalid_Name'}
|
||||||
|
- {luanti_wasm_name: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}
|
||||||
|
- {luanti_wasm_namespace: 'bad.namespace'}
|
||||||
|
- {luanti_wasm_replicas: 0}
|
||||||
|
- {luanti_wasm_replicas: '2'}
|
||||||
|
- {luanti_wasm_replicas: true}
|
||||||
|
- {luanti_wasm_game_port: 65536}
|
||||||
|
- {luanti_wasm_websocket_timeout_seconds: '3600; bad'}
|
||||||
|
- {luanti_wasm_ingress_enabled: 'true'}
|
||||||
|
- {luanti_wasm_image_pull_policy: Sometimes}
|
||||||
|
- {luanti_wasm_ingress_annotations: {example.org/key: false}}
|
||||||
|
- {luanti_wasm_http_allowed_hosts: ['*.luanti.org']}
|
||||||
|
- {luanti_wasm_web_resources: {requests: {cpu: 100m}}}
|
||||||
|
- {luanti_wasm_tmp_size_limit: unbounded}
|
||||||
|
loop_control:
|
||||||
|
loop_var: invalid_settings
|
||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
"""Standard-library regression tests for the role's CIDR predicates."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from filter_plugins.luanti_wasm_network import valid_cidr, valid_ip_block
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkValidationTests(unittest.TestCase):
|
||||||
|
def test_canonical_ipv4_and_ipv6(self):
|
||||||
|
for cidr in ("192.0.2.0/24", "192.0.2.1/32", "2001:db8::/64", "2001:db8::1/128"):
|
||||||
|
with self.subTest(cidr=cidr):
|
||||||
|
self.assertTrue(valid_cidr(cidr))
|
||||||
|
|
||||||
|
def test_rejects_malformed_or_unbounded_cidrs(self):
|
||||||
|
for value in (
|
||||||
|
None, True, 123, [], {}, "", "192.0.2.1", "192.0.2.1/24",
|
||||||
|
"999.0.0.0/8", "192.0.2.0/33", "0.0.0.0/0", "::/0",
|
||||||
|
"2001:db8:::/64", "2001:db8::/129", "fe80::%eth0/64",
|
||||||
|
"2001:DB8::/64", "host.example/32", "192.0.2.0/255.255.255.0",
|
||||||
|
):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.assertFalse(valid_cidr(value))
|
||||||
|
|
||||||
|
def test_contained_exceptions(self):
|
||||||
|
self.assertTrue(valid_ip_block({"cidr": "192.0.2.0/24"}))
|
||||||
|
self.assertTrue(valid_ip_block({"cidr": "192.0.2.0/24", "except": ["192.0.2.1/32"]}))
|
||||||
|
self.assertTrue(valid_ip_block({"cidr": "2001:db8::/64", "except": ["2001:db8::1/128"]}))
|
||||||
|
|
||||||
|
def test_rejects_invalid_ip_blocks(self):
|
||||||
|
for value in (
|
||||||
|
None, [], {}, {"cidr": "::/0"}, {"cidr": "192.0.2.0/24", "extra": True},
|
||||||
|
{"cidr": "192.0.2.0/24", "except": "192.0.2.1/32"},
|
||||||
|
{"cidr": "192.0.2.0/24", "except": ["192.0.2.0/24"]},
|
||||||
|
{"cidr": "192.0.2.0/24", "except": ["198.51.100.0/24"]},
|
||||||
|
{"cidr": "192.0.2.0/24", "except": ["2001:db8::/64"]},
|
||||||
|
{"cidr": "2001:db8::/64", "except": ["2001:db8::/48"]},
|
||||||
|
{"cidr": "2001:db8::/64", "except": ["2001:db8:1::/64"]},
|
||||||
|
{"cidr": "2001:db8::/64", "except": ["192.0.2.1/32"]},
|
||||||
|
):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.assertFalse(valid_ip_block(value))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user