From cddbc0f8de07c0eb93b1ea9504effdd1eb32afc4 Mon Sep 17 00:00:00 2001 From: conanssam Date: Fri, 19 Jun 2026 08:50:46 +0900 Subject: [PATCH 1/2] fix(setup): poll for port release instead of fixed delay after WSL shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, after `wsl --terminate` + `wsl --shutdown`, the localhost proxy process that forwards 127.0.0.1:18789 → WSL2 may take longer than the previous fixed 3-second delay to release the port. When that happened the next step (`preflight-port`) immediately hard-failed with AddressAlreadyInUse and `CanRetry = false`, leaving the user stuck. Changes: - Add `PreflightPortStep.WaitForPortFreeAsync` — polls CanBind every 500 ms up to a configurable ceiling (default 20 s) and returns as soon as the port is free, logging elapsed time when it had to wait. - Replace the fixed `Task.Delay(3000)` in `CleanupStaleDistroStep` with a call to `WaitForPortFreeAsync` so cleanup waits only as long as needed and never longer than necessary. - `PreflightPortStep.ExecuteAsync` also calls `WaitForPortFreeAsync` (10 s ceiling) before the hard check as belt-and-suspenders for the case where cleanup was skipped and something else holds the port briefly. - Expose `CanBind` as `internal static` to enable reuse across the two classes and in new tests. - Add three unit tests covering: immediate return when port is free, successful poll until port is released mid-wait, and full `ExecuteAsync` succeeding after a simulated 300 ms proxy teardown lag. Co-Authored-By: Claude Sonnet 4.6 --- src/OpenClaw.SetupEngine/SetupSteps.cs | 48 +++++++++++++++-- .../SetupStepsTests.cs | 54 +++++++++++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index ef5b3e0f2..6a61c85c9 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -276,7 +276,7 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati // Wait for port to be released ctx.Logger.Info("Waiting for port release after distro termination..."); - await Task.Delay(3000, ct); + await PreflightPortStep.WaitForPortFreeAsync(ctx.Config.GatewayPort, ctx.Config.Gateway.Bind, ctx.Logger, ct); return StepResult.Ok($"Unregistered stale distro '{distro}'"); } @@ -596,23 +596,61 @@ public sealed class PreflightPortStep : SetupStep public override string DisplayName => "Check gateway port available"; public override bool CanRetry => false; - public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) { var port = ctx.Config.GatewayPort; var addresses = ctx.Config.Gateway.Bind.Equals("lan", StringComparison.OrdinalIgnoreCase) ? new[] { IPAddress.Any, IPAddress.IPv6Any } : [IPAddress.Loopback]; + // Poll briefly in case WSL port forwarding proxy hasn't fully released the + // port yet (e.g. after wsl --shutdown in a prior cleanup step). + await WaitForPortFreeAsync(port, ctx.Config.Gateway.Bind, ctx.Logger, ct, maxWaitSeconds: 10); + foreach (var address in addresses) { if (!CanBind(address, port, out var error)) - return Task.FromResult(StepResult.Fail($"Port {port} is already in use for {DescribeBind(address)} ({error.SocketErrorCode})")); + return StepResult.Fail($"Port {port} is already in use for {DescribeBind(address)} ({error.SocketErrorCode})"); + } + + return StepResult.Ok($"Port {port} is available"); + } + + /// + /// Polls until all required addresses for can be bound, + /// or until elapses. Silently returns if the + /// port never frees — will still hard-fail in that case. + /// + internal static async Task WaitForPortFreeAsync( + int port, string bind, SetupLogger logger, CancellationToken ct, + int maxWaitSeconds = 20) + { + var addresses = bind.Equals("lan", StringComparison.OrdinalIgnoreCase) + ? new[] { IPAddress.Any, IPAddress.IPv6Any } + : [IPAddress.Loopback]; + + var deadline = DateTime.UtcNow.AddSeconds(maxWaitSeconds); + var attempt = 0; + + while (DateTime.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + + if (addresses.All(a => CanBind(a, port, out _))) + { + if (attempt > 0) + logger.Info($"Port {port} became free after {attempt * 500}ms"); + return; + } + + attempt++; + await Task.Delay(500, ct); } - return Task.FromResult(StepResult.Ok($"Port {port} is available")); + logger.Warn($"Port {port} still in use after {maxWaitSeconds}s poll — proceeding to hard check"); } - private static bool CanBind(IPAddress address, int port, out SocketException error) + internal static bool CanBind(IPAddress address, int port, out SocketException error) { var listener = new TcpListener(address, port) { diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index a5b877893..7b0177bf3 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -212,6 +212,60 @@ public async Task PreflightPort_Lan_FailsWhenAnyBindPortInUse() } } + [Fact] + public async Task WaitForPortFree_ReturnsImmediately_WhenPortIsAlreadyFree() + { + var port = GetFreeTcpPort(); + var logger = new SetupLogger(filePath: null, LogLevel.Trace); + + // Should complete well within 1 second because the port is already free + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await PreflightPortStep.WaitForPortFreeAsync(port, "loopback", logger, cts.Token, maxWaitSeconds: 10); + // No assertion needed — completing without cancellation/timeout is the success condition + } + + [Fact] + public async Task WaitForPortFree_PollsUntilPortReleased() + { + var listener = new TcpListener(IPAddress.Loopback, 0) { ExclusiveAddressUse = true }; + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var logger = new SetupLogger(filePath: null, LogLevel.Trace); + + // Release the port after a short delay (simulates WSL proxy teardown lag) + _ = Task.Run(async () => + { + await Task.Delay(400); + listener.Stop(); + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await PreflightPortStep.WaitForPortFreeAsync(port, "loopback", logger, cts.Token, maxWaitSeconds: 5); + + // Port should now be free + Assert.True(PreflightPortStep.CanBind(IPAddress.Loopback, port, out _)); + } + + [Fact] + public async Task PreflightPort_Loopback_SucceedsAfterPortReleasedDuringPoll() + { + var listener = new TcpListener(IPAddress.Loopback, 0) { ExclusiveAddressUse = true }; + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + // Release after 300ms — simulates a slow WSL proxy shutdown + _ = Task.Run(async () => + { + await Task.Delay(300); + listener.Stop(); + }); + + var ctx = CreateContext(new SetupConfig { GatewayPort = port }); + var result = await new PreflightPortStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess); + } + [Fact] public async Task InstallCli_RejectsHttpUrl() { From d2fbe02d65f6678f78623a226bd24fd960916f9a Mon Sep 17 00:00:00 2001 From: conanssam Date: Thu, 25 Jun 2026 11:44:02 +0900 Subject: [PATCH 2/2] fix(setup): sync Windows CA certs to WSL before CLI install On corporate/enterprise networks a TLS-intercepting proxy injects a self-signed certificate that WSL Ubuntu's curl does not trust, causing `install-cli` to fail with exit 60: curl: (60) SSL certificate problem: self-signed certificate in certificate chain Fix: add `SyncWindowsCaCertsStep` between `ValidateWslLockdownStep` and `InstallCliStep` that: 1. Reads the Windows LocalMachine\Root and \CA certificate stores using X509Store and serialises them to PEM format. 2. Writes the bundle directly to the WSL distro filesystem via the `\wsl$\\usr\local\share\ca-certificates\` UNC share that WSL2 exposes on Windows (no stdin piping required). 3. Runs `update-ca-certificates` as root inside WSL so curl picks up the new trust anchors for the install-script download. The step is intentionally non-fatal: if the Windows cert store cannot be read, the UNC path is unreachable, or update-ca-certificates warns, the step logs and continues so non-proxied setups are not affected. RollbackAsync removes the written bundle file on uninstall. Co-Authored-By: Claude Sonnet 4.6 --- src/OpenClaw.SetupEngine/SetupPipeline.cs | 1 + src/OpenClaw.SetupEngine/SetupSteps.cs | 104 ++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/src/OpenClaw.SetupEngine/SetupPipeline.cs b/src/OpenClaw.SetupEngine/SetupPipeline.cs index 8f2b6feef..e8ce757fc 100644 --- a/src/OpenClaw.SetupEngine/SetupPipeline.cs +++ b/src/OpenClaw.SetupEngine/SetupPipeline.cs @@ -50,6 +50,7 @@ public static List BuildDefaultSteps() new CreateWslInstanceStep(), new ConfigureWslInstanceStep(), new ValidateWslLockdownStep(), + new SyncWindowsCaCertsStep(), new InstallCliStep(), new ConfigureGatewayStep(), new InstallGatewayServiceStep(), diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index 6a61c85c9..e26087a72 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -3,6 +3,8 @@ using System.Net.Http; using System.Net.Sockets; using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Text.Json; using OpenClaw.Connection; using OpenClaw.Shared; @@ -1159,6 +1161,108 @@ private static void ValidateConfValue(Dictionary +/// Exports Windows trusted root/intermediate CA certificates into the WSL +/// distro's system trust store. This resolves curl exit-60 failures that +/// occur on corporate networks where a TLS-intercepting proxy injects a +/// self-signed certificate that the WSL Ubuntu instance does not trust. +/// The step is non-fatal: if the Windows store cannot be read or the WSL +/// filesystem cannot be reached, it logs a warning and continues so that +/// setups on non-proxied networks are not blocked. +/// +public sealed class SyncWindowsCaCertsStep : SetupStep +{ + public override string Id => "sync-ca-certs"; + public override string DisplayName => "Sync Windows CA certificates to WSL"; + public override bool CanRetry => false; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + + string pemBundle; + try + { + pemBundle = ExportWindowsCaCerts(); + } + catch (Exception ex) + { + ctx.Logger.Warn($"Could not read Windows CA store: {ex.Message} — skipping CA sync"); + return StepResult.Ok("Skipped: could not read Windows CA store"); + } + + if (string.IsNullOrWhiteSpace(pemBundle)) + { + ctx.Logger.Warn("Windows CA store returned no certificates — skipping CA sync"); + return StepResult.Ok("Skipped: Windows CA store is empty"); + } + + // Write directly to the WSL filesystem via the UNC share WSL exposes on Windows. + // This avoids needing stdin piping through RunInWslAsync. + var wslCertDir = $@"\\wsl$\{distro}\usr\local\share\ca-certificates"; + try + { + Directory.CreateDirectory(wslCertDir); + File.WriteAllText(Path.Combine(wslCertDir, "windows-ca-bundle.crt"), pemBundle); + } + catch (Exception ex) + { + ctx.Logger.Warn($"Could not write CA bundle to WSL filesystem: {ex.Message} — skipping CA sync"); + return StepResult.Ok("Skipped: could not write to WSL filesystem"); + } + + var result = await ctx.Commands.RunInWslAsync( + distro, + "update-ca-certificates", + TimeSpan.FromSeconds(30), + ct: ct, + user: "root"); + + if (result.ExitCode != 0) + { + ctx.Logger.Warn($"update-ca-certificates exited {result.ExitCode}: {result.Stderr.Trim()}"); + // Non-fatal: the bundle is written; curl may still work depending on the error. + return StepResult.Ok($"CA bundle written but update-ca-certificates warned (exit {result.ExitCode})"); + } + + ctx.Logger.Info("Windows CA certificates synced to WSL trust store"); + return StepResult.Ok("Windows CA certificates synced to WSL"); + } + + public override Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var wslCertPath = $@"\\wsl$\{distro}\usr\local\share\ca-certificates\windows-ca-bundle.crt"; + if (File.Exists(wslCertPath)) + { + File.Delete(wslCertPath); + ctx.Logger.Info("[Rollback] Removed windows-ca-bundle.crt from WSL"); + } + return Task.CompletedTask; + } + + private static string ExportWindowsCaCerts() + { + var sb = new StringBuilder(); + var storeNames = new[] { StoreName.Root, StoreName.CertificateAuthority }; + + foreach (var storeName in storeNames) + { + using var store = new X509Store(storeName, StoreLocation.LocalMachine); + store.Open(OpenFlags.ReadOnly); + + foreach (var cert in store.Certificates) + { + sb.AppendLine("-----BEGIN CERTIFICATE-----"); + sb.AppendLine(Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks)); + sb.AppendLine("-----END CERTIFICATE-----"); + } + } + + return sb.ToString(); + } +} + public sealed class InstallCliStep : SetupStep { public override string Id => "install-cli";