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
/proxyWebSocket requests tohttp://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:7777so 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/v1and 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 in your controller environment and the
collection in requirements.yml. The version is pinned to
match the neighboring role; the small CIDR filter uses only Python's standard
library, not
ansible.utilsornetaddr. - 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-issuerrequires 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:
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 an explicit tag (including latest) or a complete
@sha256: digest (64 lowercase hex characters). Tags are mutable; digests are
recommended for reproducibility and supply-chain verification. For latest,
set luanti_wasm_image_pull_policy: Always so Kubernetes checks the registry on
each container start; the role default is IfNotPresent, which can reuse a
cached image. Reusing a mutable tag alone does not trigger a rollout. Use
admission signature/provenance policies and scan your builds. 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:
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:
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 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:
- 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.
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-systempods labeledk8s-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/0or::/0as 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, COEPrequire-corp, andX-Content-Type-Options: nosniffheaders 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, becausesubPathmounts 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 tests/run.sh
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;
- acceptance of
latesttags and rejection of empty/unversioned images, malformed/wildcard CIDRs, bad names/hostnames, peer shapes, missing TLS, invalid resources/types and timeouts.
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
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.