From 2896b60d3cf72a55b6e58d827b6a2f7cd3bc4e1c Mon Sep 17 00:00:00 2001 From: Andrew Stoltz Date: Tue, 19 May 2026 12:04:12 -0500 Subject: [PATCH] Tighten RemoteDesktop network policies --- README.md | 1 + apps/fc-desktop/network-policies.yaml | 75 +++++++-------- apps/guacamole/guacamole.yaml | 62 +++++++++++++ .../RemoteDesktopNetworkPolicyTests.cs | 93 +++++++++++++++++++ 4 files changed, 190 insertions(+), 41 deletions(-) create mode 100644 tests/bluejay-infra-lint/RemoteDesktopNetworkPolicyTests.cs diff --git a/README.md b/README.md index fb17335..781694f 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ curl -sk -X DELETE https://dns.iamworkin.lan/api/v1/servers//zones/iam - **Public read-only hosts**: if a public host fronts a service that also exposes admin writes internally, add a Traefik route match like `Host(...) && (Method(GET) || Method(HEAD))` on the public edge instead of trusting the app to reject unsafe methods. - **Public read-write allowlist hosts**: if a public host accepts a tightly bounded write surface (e.g. bootstrap-JWT POST), pin the allowlist as `(Method(GET) || Method(HEAD) || Method(POST) || Method(OPTIONS))`. PUT/PATCH/DELETE must still 404 at the route. Track A's `updatecenter.iamworkin.lan` / `updates.iamworkin.lan` are the canonical example. The lint test enforces this invariant. - **Traefik VIP netpols**: when a `NetworkPolicy` allows `10.0.56.200`, also allow the post-DNAT backend ports (`8443` for TLS plus `8080` or `8000` for HTTP) or Calico will drop the rewritten flow. +- **RemoteDesktop isolation**: `apps/fc-desktop/network-policies.yaml` intentionally keeps desktop pod egress to named CoreDNS, `intranet-web:5300/TCP`, and noc1 step-ca `10.0.56.10:9000/9443` only. Guacamole display egress is owned separately by `apps/guacamole/guacamole.yaml` through `guacd-desktop-egress` on `5901/TCP`. - **Auth-safe probes**: services behind API-key or global auth middleware should prefer `tcpSocket` probes unless `/health` is explicitly exempted before the middleware runs. - **ArgoCD must use internal Gitea URL**: `http://gitea-clusterip.gitea.svc.cluster.local:3000/bluejay/bluejay-infra.git`, not the external HTTPS URL (step-ca cert isn't trusted by ArgoCD). The `ApplicationSet` and any hand-created `Application` must both use the internal URL. diff --git a/apps/fc-desktop/network-policies.yaml b/apps/fc-desktop/network-policies.yaml index 6ea22b0..37b89da 100644 --- a/apps/fc-desktop/network-policies.yaml +++ b/apps/fc-desktop/network-policies.yaml @@ -20,9 +20,12 @@ # 1) desktop-isolation — Browser Lab session pods. # # Locks down pods labeled `app.kubernetes.io/name=remote-desktop` (every -# session pod regardless of template). Allows guacd ingress for the VNC/RDP -# display lane and remotedesktop-web's pre-handoff probing. Egress: NFS to -# Synology, DNS, Traefik (cluster + LB VIP), Intranet (Browser Lab home). +# session pod regardless of template). Allows guacd ingress for the display +# lane and remotedesktop-web's pre-handoff probing. Egress is deliberately +# narrow: named CoreDNS, direct Intranet web, and noc1 step-ca only. There is +# no broad Traefik/VIP or internet egress from desktop sessions. If a future +# Browser Lab path needs a public-style host, prefer an explicit Service rule +# or include the post-DNAT backend port per the Traefik VIP lint. apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: @@ -65,51 +68,22 @@ spec: - port: 5901 protocol: TCP egress: - # NFS to Synology + # CoreDNS only. The old to: [] DNS rule accidentally allowed any DNS + # listener in any namespace or routed network. - to: - - ipBlock: - cidr: 10.0.58.3/32 - ports: - - port: 2049 - protocol: TCP - - port: 2049 - protocol: UDP - - port: 111 - protocol: TCP - - port: 111 - protocol: UDP - - to: - - ipBlock: - cidr: 10.0.58.3/32 - ports: - - port: 445 - protocol: TCP - - to: [] - ports: - - port: 53 - protocol: UDP - - port: 53 - protocol: TCP - - to: - - ipBlock: - cidr: 10.0.56.200/32 - - ipBlock: - cidr: 10.43.33.87/32 - namespaceSelector: matchLabels: - kubernetes.io/metadata.name: traefik-system + kubernetes.io/metadata.name: kube-system podSelector: matchLabels: - app.kubernetes.io/name: traefik + k8s-app: kube-dns ports: - - port: 80 - protocol: TCP - - port: 443 - protocol: TCP - - port: 8000 - protocol: TCP - - port: 8443 + - port: 53 + protocol: UDP + - port: 53 protocol: TCP + # Browser Lab home / internal docs target. Use the real service port + # directly rather than public Traefik host aliases. - to: - namespaceSelector: matchLabels: @@ -120,6 +94,17 @@ spec: ports: - port: 5300 protocol: TCP + # noc1 step-ca ACME endpoint. The lane brief called out 9000/TCP; the live + # ACME directory currently answers on 9443/TCP, so both stay pinned to the + # same host rather than reopening Traefik or internet egress. + - to: + - ipBlock: + cidr: 10.0.56.10/32 + ports: + - port: 9000 + protocol: TCP + - port: 9443 + protocol: TCP --- # 2) fc-desktop-default-deny — namespace-wide catch-all. # @@ -330,3 +315,11 @@ spec: protocol: UDP - port: 53 protocol: TCP + - to: + - ipBlock: + cidr: 10.0.56.10/32 + ports: + - port: 9000 + protocol: TCP + - port: 9443 + protocol: TCP diff --git a/apps/guacamole/guacamole.yaml b/apps/guacamole/guacamole.yaml index d9b6933..926eb04 100644 --- a/apps/guacamole/guacamole.yaml +++ b/apps/guacamole/guacamole.yaml @@ -254,6 +254,68 @@ spec: targetPort: 4822 name: guacd --- +# Guacd display egress isolation. +# +# Guacamole web talks to guacd on TCP/4822. Guacd then opens the desktop +# display connection to the per-session pod. Keep that second hop at raw VNC +# 5901/TCP for the current RemoteDesktop Browser Lab/openSUSE images. Do not +# grant guacd broad fc-desktop namespace egress; desktop-to-desktop lateral +# paths remain blocked by apps/fc-desktop/network-policies.yaml. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: guacd-desktop-egress + namespace: guacamole + labels: + app.kubernetes.io/part-of: remotedesktop + app.kubernetes.io/component: display-isolation +spec: + podSelector: + matchLabels: + app: guacd + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + app: guacamole + ports: + - port: 4822 + protocol: TCP + 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 + # kubectl-proxy sidecar reaches the Kubernetes API; keep it explicit + # because this NetworkPolicy selects the whole guacd pod. + - to: [] + ports: + - port: 443 + protocol: TCP + - port: 6443 + protocol: TCP + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: fc-desktop + podSelector: + matchLabels: + app.kubernetes.io/name: remote-desktop + ports: + - port: 5901 + protocol: TCP +--- # Guacamole Web Application apiVersion: apps/v1 kind: Deployment diff --git a/tests/bluejay-infra-lint/RemoteDesktopNetworkPolicyTests.cs b/tests/bluejay-infra-lint/RemoteDesktopNetworkPolicyTests.cs new file mode 100644 index 0000000..8aeaff9 --- /dev/null +++ b/tests/bluejay-infra-lint/RemoteDesktopNetworkPolicyTests.cs @@ -0,0 +1,93 @@ +using FluentAssertions; +using Xunit; + +namespace BluejayInfraLint.Tests; + +[Trait("Category", "Unit")] +public sealed class RemoteDesktopNetworkPolicyTests +{ + private static readonly ManifestInventory Inventory = ManifestInventory.Load(); + + [Fact] + public void LiveDesktopIsolation_AllowsOnlyCoreDnsIntranetAndStepCaEgress() + { + var policy = NetworkPolicy("fc-desktop", "desktop-isolation"); + var ports = policy.EgressPorts().ToHashSet(StringComparer.Ordinal); + + ports.Should().BeEquivalentTo("53", "5300", "9000", "9443"); + policy.AllScalars().Should().Contain(new[] + { + "kube-system", + "kube-dns", + "intranet", + "intranet-web", + "10.0.56.10/32" + }); + } + + [Fact] + public void LiveDesktopIsolation_RemovesInternetNfsAndTraefikEgress() + { + var policy = NetworkPolicy("fc-desktop", "desktop-isolation"); + var scalars = policy.AllScalars().ToList(); + var ports = policy.EgressPorts().ToHashSet(StringComparer.Ordinal); + + scalars.Should().NotContain(new[] { "10.0.58.3/32", "10.0.56.200/32", "10.43.33.87/32", "traefik-system" }); + ports.Should().NotContain(new[] { "80", "443", "445", "111", "2049", "8000", "8080", "8443" }); + policy.MappingSequence("spec", "egress") + .Should() + .NotContain(rule => EgressRuleHasEmptyTo(rule), "desktop sessions must not use to: [] internet-style egress"); + } + + [Fact] + public void LiveGuacdIsolation_AllowsRawVncToDesktopPodsOnly() + { + var policy = NetworkPolicy("guacamole", "guacd-desktop-egress"); + var scalars = policy.AllScalars().ToList(); + var ports = policy.EgressPorts().ToHashSet(StringComparer.Ordinal); + + ports.Should().Contain("5901"); + scalars.Should().Contain(new[] { "fc-desktop", "remote-desktop" }); + ports.Should().NotContain(new[] { "3000", "3001", "3389", "80", "8080", "8443" }); + } + + [Fact] + public void LiveGuacdIsolation_KeepsGuacamoleWebIngressOnGuacdPort() + { + var policy = NetworkPolicy("guacamole", "guacd-desktop-egress"); + + policy.Scalar("spec", "podSelector", "matchLabels", "app").Should().Be("guacd"); + policy.AllScalars().Should().Contain(new[] { "guacamole", "4822" }); + } + + [Fact] + public void HelperSmoke_FindsExpectedRemoteDesktopPolicies() + { + NetworkPolicy("fc-desktop", "desktop-isolation").Name.Should().Be("desktop-isolation"); + NetworkPolicy("guacamole", "guacd-desktop-egress").Name.Should().Be("guacd-desktop-egress"); + } + + [Fact] + public void HelperSmoke_EgressPortExtractionKeepsDistinctPorts() + { + var ports = NetworkPolicy("fc-desktop", "desktop-isolation") + .EgressPorts() + .ToHashSet(StringComparer.Ordinal); + + ports.Should().HaveCount(4); + ports.Should().Contain(new[] { "53", "5300", "9000", "9443" }); + } + + private static ManifestDocument NetworkPolicy(string ns, string name) + => Inventory.Documents.Single(document => + document.Kind == "NetworkPolicy" + && string.Equals(document.Namespace, ns, StringComparison.Ordinal) + && string.Equals(document.Name, name, StringComparison.Ordinal)); + + private static bool EgressRuleHasEmptyTo(YamlDotNet.RepresentationModel.YamlMappingNode rule) + => rule.Children.Any(entry => + entry.Key is YamlDotNet.RepresentationModel.YamlScalarNode key + && string.Equals(key.Value, "to", StringComparison.Ordinal) + && entry.Value is YamlDotNet.RepresentationModel.YamlSequenceNode sequence + && sequence.Children.Count == 0); +} -- 2.49.1