From 8dcadec8b09c315430e49e77260501fe48f17a6d Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Fri, 5 Jun 2026 09:38:39 +0800 Subject: [PATCH] Bind tentacle healthz to loopback on Windows to stop firewall prompt The health-check server bound http://*:port/ (all interfaces). On Windows that pops a Defender Firewall "allow access" prompt, and the blue-green versioned layout gives each upgrade a new binary path, so the path-keyed firewall prompts on every upgrade -- bad for unattended SYSTEM-service agents. Default the bind host to loopback on Windows (127.0.0.1 -- firewall-exempt, and the only Windows consumer is the upgrade script, which probes 127.0.0.1). Keep "*" on non-Windows so Linux/docker localhost probes and the Kubernetes agent's kubelet httpGet liveness/readiness probes (over the pod network) still work; the agent's Helm chart sets HealthCheckBindHost=+ explicitly. Configurable via Tentacle__HealthCheckBindHost. --- .../templates/deployment.yaml | 6 ++++ deploy/helm/kubernetes-agent/values.yaml | 4 +++ .../Configuration/TentacleSettings.cs | 10 +++++++ src/Squid.Tentacle/Core/TentacleApp.cs | 6 ++-- .../Health/HealthCheckServer.cs | 16 ++++++++-- .../Core/TentacleAppTests.cs | 14 ++++----- .../Health/HealthCheckServerTests.cs | 30 +++++++++++++++++++ 7 files changed, 73 insertions(+), 13 deletions(-) diff --git a/deploy/helm/kubernetes-agent/templates/deployment.yaml b/deploy/helm/kubernetes-agent/templates/deployment.yaml index fb5d3c24..0cefbd7c 100644 --- a/deploy/helm/kubernetes-agent/templates/deployment.yaml +++ b/deploy/helm/kubernetes-agent/templates/deployment.yaml @@ -86,6 +86,12 @@ spec: value: {{ join "," .Values.tentacle.roles | quote }} - name: Tentacle__Flavor value: "{{ .Values.tentacle.flavor }}" + # Bind the health-check server to all interfaces so kubelet's httpGet + # liveness/readiness probes reach it over the pod network. Service-based + # tentacles default to loopback (avoids a Windows Firewall prompt), so the + # Kubernetes agent must opt back in here. + - name: Tentacle__HealthCheckBindHost + value: "{{ .Values.tentacle.healthCheckBindHost | default "+" }}" - name: Tentacle__MachineName value: "{{ .Values.tentacle.machineName }}" - name: Tentacle__ReleaseName diff --git a/deploy/helm/kubernetes-agent/values.yaml b/deploy/helm/kubernetes-agent/values.yaml index 318df53a..52a00c84 100644 --- a/deploy/helm/kubernetes-agent/values.yaml +++ b/deploy/helm/kubernetes-agent/values.yaml @@ -28,6 +28,10 @@ tentacle: cpu: "500m" healthCheckPort: 8080 + # Host the in-pod health-check server binds. "+" = all interfaces so kubelet's + # httpGet liveness/readiness probes can reach it. (The tentacle binary defaults + # to loopback for service installs to avoid a Windows Firewall prompt.) + healthCheckBindHost: "+" startupProbe: failureThreshold: 100 diff --git a/src/Squid.Tentacle/Configuration/TentacleSettings.cs b/src/Squid.Tentacle/Configuration/TentacleSettings.cs index c89cf80e..5db0bd27 100644 --- a/src/Squid.Tentacle/Configuration/TentacleSettings.cs +++ b/src/Squid.Tentacle/Configuration/TentacleSettings.cs @@ -38,6 +38,16 @@ public class TentacleSettings public string WorkspacePath { get; set; } = string.Empty; public string CertsPath { get; set; } = string.Empty; public int HealthCheckPort { get; set; } = 8080; + + // Host the local health-check HTTP server binds. On Windows it defaults to the + // loopback address so the listener is NOT network-exposed -- binding all + // interfaces pops a Defender Firewall "allow access" prompt on every + // versioned-path upgrade, and the only Windows consumer of healthz is the + // upgrade script (which probes 127.0.0.1). On non-Windows it keeps "*" (all + // interfaces) so Linux/docker localhost probes and the Kubernetes agent's + // kubelet httpGet liveness/readiness probes (over the pod network) still work. + public string HealthCheckBindHost { get; set; } = OperatingSystem.IsWindows() ? "127.0.0.1" : "*"; + public int ListeningPort { get; set; } = 10933; public string ListeningHostName { get; set; } = string.Empty; diff --git a/src/Squid.Tentacle/Core/TentacleApp.cs b/src/Squid.Tentacle/Core/TentacleApp.cs index 5526d5a9..5a9328a6 100644 --- a/src/Squid.Tentacle/Core/TentacleApp.cs +++ b/src/Squid.Tentacle/Core/TentacleApp.cs @@ -100,7 +100,7 @@ public async Task RunAsync( return true; }; - await using var healthServer = _dependencies.HealthCheckServerFactory(tentacleSettings.HealthCheckPort, readinessCheck); + await using var healthServer = _dependencies.HealthCheckServerFactory(tentacleSettings.HealthCheckPort, tentacleSettings.HealthCheckBindHost, readinessCheck); try { @@ -213,8 +213,8 @@ public sealed class TentacleAppDependencies public Func HalibutHostFactory { get; init; } = (cert, scriptService, settings, capabilitiesService) => new TentacleHalibutHost(cert, scriptService, settings, capabilitiesService); - public Func, IHealthCheckServer> HealthCheckServerFactory { get; init; } = - (port, readiness) => new HealthCheckServer(port, readiness); + public Func, IHealthCheckServer> HealthCheckServerFactory { get; init; } = + (port, bindHost, readiness) => new HealthCheckServer(port, readiness, bindHost); public static TentacleAppDependencies CreateDefault() => new(); } diff --git a/src/Squid.Tentacle/Health/HealthCheckServer.cs b/src/Squid.Tentacle/Health/HealthCheckServer.cs index e40ea6ba..1309a172 100644 --- a/src/Squid.Tentacle/Health/HealthCheckServer.cs +++ b/src/Squid.Tentacle/Health/HealthCheckServer.cs @@ -10,11 +10,18 @@ public class HealthCheckServer : IHealthCheckServer private CancellationTokenSource _cts; private Task _listenTask; - public HealthCheckServer(int port, Func readinessCheck) + public HealthCheckServer(int port, Func readinessCheck, string? bindHost = null) { _readinessCheck = readinessCheck; _listener = new HttpListener(); - _listener.Prefixes.Add($"http://*:{port}/"); + // Default: loopback on Windows (firewall-exempt -- the only Windows consumer, + // the upgrade script, probes 127.0.0.1), all interfaces elsewhere (Linux / + // docker localhost probes + kubelet httpGet over the pod network). Mirrors + // TentacleSettings.HealthCheckBindHost; the Kubernetes agent passes "+". + var host = string.IsNullOrWhiteSpace(bindHost) + ? (OperatingSystem.IsWindows() ? "127.0.0.1" : "*") + : bindHost; + _listener.Prefixes.Add($"http://{host}:{port}/"); } public void Start() @@ -29,7 +36,10 @@ public void Start() private int GetPort() { var prefix = _listener.Prefixes.FirstOrDefault() ?? ""; - var uri = new Uri(prefix.Replace("*", "localhost")); + // "*"/"+" are valid HttpListener wildcard hosts but not valid URI hosts, + // so normalise them to a parseable host before extracting the port. + var parseable = prefix.Replace("://*:", "://localhost:").Replace("://+:", "://localhost:"); + var uri = new Uri(parseable); return uri.Port; } diff --git a/tests/Squid.Tentacle.Tests/Core/TentacleAppTests.cs b/tests/Squid.Tentacle.Tests/Core/TentacleAppTests.cs index 54dc14c2..8fd77062 100644 --- a/tests/Squid.Tentacle.Tests/Core/TentacleAppTests.cs +++ b/tests/Squid.Tentacle.Tests/Core/TentacleAppTests.cs @@ -44,7 +44,7 @@ public async Task RunAsync_Starts_And_Wires_Core_Lifecycle_Then_Shuts_Down_On_Ca BuiltInFlavorsProvider = () => [flavor], FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors), HalibutHostFactory = (_, _, _, _) => halibutHost, - HealthCheckServerFactory = (_, _) => healthServer + HealthCheckServerFactory = (_, _, _) => healthServer }); using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); @@ -93,7 +93,7 @@ public async Task RunAsync_ListeningMode_Calls_StartListening_With_Correct_Port( BuiltInFlavorsProvider = () => [flavor], FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors), HalibutHostFactory = (_, _, _, _) => halibutHost, - HealthCheckServerFactory = (_, _) => healthServer + HealthCheckServerFactory = (_, _, _) => healthServer }); using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); @@ -130,7 +130,7 @@ public async Task RunAsync_ListeningMode_Falls_Back_To_Settings_Port_When_Runtim BuiltInFlavorsProvider = () => [flavor], FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors), HalibutHostFactory = (_, _, _, _) => halibutHost, - HealthCheckServerFactory = (_, _) => healthServer + HealthCheckServerFactory = (_, _, _) => healthServer }); using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); @@ -163,7 +163,7 @@ public async Task RunAsync_Continues_When_HealthServer_Start_Fails() BuiltInFlavorsProvider = () => [flavor], FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors), HalibutHostFactory = (_, _, _, _) => halibutHost, - HealthCheckServerFactory = (_, _) => healthServer + HealthCheckServerFactory = (_, _, _) => healthServer }); using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); @@ -206,7 +206,7 @@ public async Task RunAsync_ExternalSubscriptionId_OverridesTakesPrecedence() BuiltInFlavorsProvider = () => [flavor], FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors), HalibutHostFactory = (_, _, _, _) => halibutHost, - HealthCheckServerFactory = (_, _) => healthServer + HealthCheckServerFactory = (_, _, _) => healthServer }); using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); @@ -242,7 +242,7 @@ public async Task Shutdown_FlipsReadinessToFalse() BuiltInFlavorsProvider = () => [flavor], FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors), HalibutHostFactory = (_, _, _, _) => halibutHost, - HealthCheckServerFactory = (port, readiness) => + HealthCheckServerFactory = (port, _, readiness) => { capturedReadiness = readiness; return healthServer; @@ -284,7 +284,7 @@ public async Task Shutdown_CallsDrainOnScriptBackend() BuiltInFlavorsProvider = () => [flavor], FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors), HalibutHostFactory = (_, _, _, _) => halibutHost, - HealthCheckServerFactory = (_, _) => healthServer + HealthCheckServerFactory = (_, _, _) => healthServer }); using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); diff --git a/tests/Squid.Tentacle.Tests/Health/HealthCheckServerTests.cs b/tests/Squid.Tentacle.Tests/Health/HealthCheckServerTests.cs index 2c60080f..cb75b51e 100644 --- a/tests/Squid.Tentacle.Tests/Health/HealthCheckServerTests.cs +++ b/tests/Squid.Tentacle.Tests/Health/HealthCheckServerTests.cs @@ -26,6 +26,36 @@ public async Task Liveness_Endpoints_Return_200() alias.Body.ShouldContain("alive"); } + [Fact] + public void HealthCheckBindHost_DefaultsToLoopbackOnWindows_AllInterfacesElsewhere() + { + // Windows default = loopback so the healthz listener isn't network-exposed + // and doesn't pop a Defender Firewall prompt on each versioned-path upgrade + // (the only Windows consumer, the upgrade script, probes 127.0.0.1). + // Non-Windows keeps "*" so Linux/docker localhost probes + kubelet httpGet + // (over the pod network) still reach it. + var settings = new Squid.Tentacle.Configuration.TentacleSettings(); + + if (OperatingSystem.IsWindows()) + settings.HealthCheckBindHost.ShouldBe("127.0.0.1"); + else + settings.HealthCheckBindHost.ShouldBe("*"); + } + + [Fact] + public async Task ExplicitLoopbackBindHost_IsReachableViaIpv4Loopback() + { + // The firewall-fix path: an explicit loopback bind still serves the upgrade + // script's 127.0.0.1 health probe. + var port = TcpPortAllocator.GetEphemeralPort(); + await using var server = new HealthCheckServer(port, () => true, "127.0.0.1"); + server.Start(); + + using var client = new HttpClient(); + var result = await GetAsync(client, port, "/healthz"); + result.StatusCode.ShouldBe(HttpStatusCode.OK); + } + [Fact] public async Task Readiness_Endpoints_Reflect_Callback_State() {