Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/OpenClaw.SetupEngine/SetupPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public static List<SetupStep> BuildDefaultSteps()
new CreateWslInstanceStep(),
new ConfigureWslInstanceStep(),
new ValidateWslLockdownStep(),
new SyncWindowsCaCertsStep(),
new InstallCliStep(),
new ConfigureGatewayStep(),
new InstallGatewayServiceStep(),
Expand Down
152 changes: 147 additions & 5 deletions src/OpenClaw.SetupEngine/SetupSteps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -276,7 +278,7 @@ public override async Task<StepResult> 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}'");
}

Expand Down Expand Up @@ -596,23 +598,61 @@ public sealed class PreflightPortStep : SetupStep
public override string DisplayName => "Check gateway port available";
public override bool CanRetry => false;

public override Task<StepResult> ExecuteAsync(SetupContext ctx, CancellationToken ct)
public override async Task<StepResult> 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 Task.FromResult(StepResult.Ok($"Port {port} is available"));
return StepResult.Ok($"Port {port} is available");
}

private static bool CanBind(IPAddress address, int port, out SocketException error)
/// <summary>
/// Polls until all required addresses for <paramref name="port"/> can be bound,
/// or until <paramref name="maxWaitSeconds"/> elapses. Silently returns if the
/// port never frees — <see cref="ExecuteAsync"/> will still hard-fail in that case.
/// </summary>
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);
}

logger.Warn($"Port {port} still in use after {maxWaitSeconds}s poll — proceeding to hard check");
}

internal static bool CanBind(IPAddress address, int port, out SocketException error)
{
var listener = new TcpListener(address, port)
{
Expand Down Expand Up @@ -1121,6 +1161,108 @@ private static void ValidateConfValue(Dictionary<string, Dictionary<string, stri
// GATEWAY INSTALL STEPS
// ═══════════════════════════════════════════════════════════════════

/// <summary>
/// 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.
/// </summary>
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<StepResult> 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";
Expand Down
54 changes: 54 additions & 0 deletions tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading