Skip to content
9 changes: 7 additions & 2 deletions docs/spec/SPEC-007-guided-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,13 @@ Define the guided onboarding flow for first-time Netclaw setup.

### Step 5: Security Profile

- choose exposure mode (`local`, `tailscale-serve`, `tailscale-funnel`,
`cloudflare-tunnel`)
- choose exposure mode (`local`, `reverse-proxy`, `tailscale-serve`,
`tailscale-funnel`, `cloudflare-tunnel`)
- for `reverse-proxy`: collect `Daemon.Host` (must be non-loopback) and
`Daemon.TrustedProxies` (≥1 IP or CIDR entry required to advance — matches
the daemon's startup validator so the wizard cannot emit a non-startable
config), then show an informational notice with the resulting serving URL
(`http://{Host}:{Port}`) before continuing
- enforce policy prerequisites for selected mode

### Step 6: Final Validation
Expand Down
13 changes: 12 additions & 1 deletion feeds/skills/.system/files/netclaw-operations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: netclaw-operations
description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance."
metadata:
author: netclaw
version: "2.6.0"
version: "2.7.0"
---

# Netclaw Operations
Expand Down Expand Up @@ -766,6 +766,17 @@ Exposure diagnostics are fail-closed:
`Daemon.SkipTunnelProcessCheck=true` is an explicit opt-in only for sidecar or
host-managed tunnel topologies; all other exposure requirements still apply.

The `netclaw init` wizard's Network Exposure step offers all five modes —
`local`, `reverse-proxy`, `tailscale-serve`, `tailscale-funnel`,
`cloudflare-tunnel`. Selecting `reverse-proxy` adds two follow-up prompts that
collect `Daemon.Host` (must be non-loopback) and `Daemon.TrustedProxies` (≥1
entry required, comma-separated). The wizard refuses to advance past the
trusted-proxies prompt with an empty list — the same minimum the daemon
validator enforces at startup — so an operator who does not yet know their
proxy IP should choose `local` and re-run `netclaw init` later, supplying the
bind address and trusted proxies on the second pass once the proxy topology
is known.

Config files: `~/.netclaw/config/netclaw.json` (daemon-owned base config,
including `Daemon.Host`, `Daemon.Port`, `Daemon.ExposureMode`),
`~/.netclaw/client/config.json` (local CLI endpoint state),
Expand Down
2 changes: 1 addition & 1 deletion scripts/smoke/run-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ SMOKE_LOG_DIR="${SMOKE_LOG_DIR:-${ROOT_DIR}/smoke-logs}"

# Cheapest harness checks first so a harness-level break fails fast
# before paying for the wizard + probe tapes.
LIGHT_TAPES=(help init-wizard provider-add provider-rename tui-cleanup)
LIGHT_TAPES=(help init-wizard init-wizard-reverse-proxy provider-add provider-rename tui-cleanup)
FULL_TAPES=("${LIGHT_TAPES[@]}")

LIGHT_SCENARIOS=(
Expand Down
205 changes: 205 additions & 0 deletions src/Netclaw.Cli.Tests/Tui/Wizard/ExposureModeStepViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ public void IsHighRisk_MatchesExpectation(ExposureMode mode, bool expected)

[Theory]
[InlineData(ExposureMode.Local, 2, 1)]
[InlineData(ExposureMode.ReverseProxy, 5, 4)]
[InlineData(ExposureMode.TailscaleServe, 3, 2)]
[InlineData(ExposureMode.TailscaleFunnel, 3, 2)]
[InlineData(ExposureMode.CloudflareTunnel, 3, 2)]
Expand All @@ -286,6 +287,210 @@ public void SubStepCount_And_WebhookSubStep_MatchMode(ExposureMode mode, int exp
Assert.Equal(expectedWebhookSubStep, step.WebhookSubStep);
}

// ── Reverse proxy — config emission ──────────────────────────────────────

[Fact]
public void ContributeConfig_ReverseProxy_WritesHostAndTrustedProxies()
{
using var step = new ExposureModeStepViewModel();
step.SelectedMode = ExposureMode.ReverseProxy;
step.Host = "10.0.0.5";
step.TrustedProxies = ["10.0.0.0/24", "192.168.1.5"];

var builder = new WizardConfigBuilder(Context.Paths);
step.ContributeConfig(builder);

Assert.NotNull(builder.Daemon);
Assert.Equal(ExposureMode.ReverseProxy, builder.Daemon.ExposureMode);
Assert.Equal("10.0.0.5", builder.Daemon.Host);
Assert.Equal(["10.0.0.0/24", "192.168.1.5"], builder.Daemon.TrustedProxies);
}

[Theory]
[InlineData(ExposureMode.TailscaleServe)]
[InlineData(ExposureMode.TailscaleFunnel)]
[InlineData(ExposureMode.CloudflareTunnel)]
public void ContributeConfig_NonReverseProxy_DoesNotEmitHostOrTrustedProxies(ExposureMode mode)
{
// Even if Host / TrustedProxies were collected on a previous reverse-proxy
// pass and the operator backed out to switch modes, we must not leak them
// into a tailscale/cloudflare Daemon section.
using var step = new ExposureModeStepViewModel();
step.Host = "10.0.0.5";
step.TrustedProxies = ["10.0.0.0/24"];
step.SelectedMode = mode;

var builder = new WizardConfigBuilder(Context.Paths);
step.ContributeConfig(builder);

Assert.NotNull(builder.Daemon);
Assert.Null(builder.Daemon.Host);
Assert.Empty(builder.Daemon.TrustedProxies);
}

[Fact]
public void BuildConfigDictionary_ReverseProxy_WritesHostAndTrustedProxies()
{
var builder = new WizardConfigBuilder(Context.Paths)
{
Daemon = new DaemonConfigSection
{
ExposureMode = ExposureMode.ReverseProxy,
Host = "10.0.0.5",
TrustedProxies = ["10.0.0.0/24", "192.168.1.5"],
}
};

var config = builder.BuildConfigDictionary();

Assert.True(config.ContainsKey("Daemon"));
var daemon = (Dictionary<string, object>)config["Daemon"];
Assert.Equal("reverse-proxy", daemon["ExposureMode"]);
Assert.Equal("10.0.0.5", daemon["Host"]);
Assert.Equal(new[] { "10.0.0.0/24", "192.168.1.5" }, (IEnumerable<string>)daemon["TrustedProxies"]);
}

[Fact]
public void BuildConfigDictionary_ReverseProxy_EmptyTrustedProxies_OmitsTrustedProxiesKey()
{
var builder = new WizardConfigBuilder(Context.Paths)
{
Daemon = new DaemonConfigSection
{
ExposureMode = ExposureMode.ReverseProxy,
Host = "10.0.0.5",
TrustedProxies = [],
}
};

var config = builder.BuildConfigDictionary();

var daemon = (Dictionary<string, object>)config["Daemon"];
Assert.Equal("reverse-proxy", daemon["ExposureMode"]);
Assert.Equal("10.0.0.5", daemon["Host"]);
Assert.False(daemon.ContainsKey("TrustedProxies"));
}

[Fact]
public void BuildConfigDictionary_NonReverseProxy_OmitsHostAndTrustedProxies()
{
// Defensive: even if a caller populates Host/TrustedProxies on the builder
// for a non-reverse-proxy mode, the serializer should still emit them when
// explicitly present — guarding against leakage is the ViewModel's job
// (see ContributeConfig_NonReverseProxy_DoesNotEmitHostOrTrustedProxies).
var builder = new WizardConfigBuilder(Context.Paths)
{
Daemon = new DaemonConfigSection { ExposureMode = ExposureMode.TailscaleServe }
};

var config = builder.BuildConfigDictionary();

var daemon = (Dictionary<string, object>)config["Daemon"];
Assert.False(daemon.ContainsKey("Host"));
Assert.False(daemon.ContainsKey("TrustedProxies"));
}

// ── Reverse proxy — sub-step navigation ──────────────────────────────────

[Fact]
public void TryAdvance_ReverseProxy_WalksMode_Host_TrustedProxies_Notice_Webhook()
{
using var step = new ExposureModeStepViewModel();
step.OnEnter(Context, NavigationDirection.Forward);
step.SelectedMode = ExposureMode.ReverseProxy;

Assert.True(step.TryAdvance());
Assert.Equal(step.ReverseProxyHostSubStep, step.CurrentSubStep);

Assert.True(step.TryAdvance());
Assert.Equal(step.ReverseProxyTrustedProxiesSubStep, step.CurrentSubStep);

// Gate: blocked on empty trusted proxies. Returns true ("handled — staying put")
// so the orchestrator does NOT interpret it as step-complete and skip ahead.
// The sub-step pointer must not move.
Assert.True(step.TryAdvance());
Assert.Equal(step.ReverseProxyTrustedProxiesSubStep, step.CurrentSubStep);

step.TrustedProxies = ["10.0.0.0/24"];

Assert.True(step.TryAdvance());
Assert.Equal(step.NoticeSubStep, step.CurrentSubStep);

Assert.True(step.TryAdvance());
Assert.Equal(step.WebhookSubStep, step.CurrentSubStep);

Assert.False(step.TryAdvance()); // step complete
}

[Fact]
public void TryGoBack_ReverseProxy_FromWebhook_WalksBackThroughEachSubStep()
{
using var step = new ExposureModeStepViewModel();
step.OnEnter(Context, NavigationDirection.Forward);
step.SelectedMode = ExposureMode.ReverseProxy;
step.Host = "10.0.0.5";
step.TrustedProxies = ["10.0.0.0/24"];

step.TryAdvance(); // host
step.TryAdvance(); // trusted proxies
step.TryAdvance(); // notice
step.TryAdvance(); // webhook
Assert.Equal(step.WebhookSubStep, step.CurrentSubStep);

Assert.True(step.TryGoBack());
Assert.Equal(step.NoticeSubStep, step.CurrentSubStep);

Assert.True(step.TryGoBack());
Assert.Equal(step.ReverseProxyTrustedProxiesSubStep, step.CurrentSubStep);

Assert.True(step.TryGoBack());
Assert.Equal(step.ReverseProxyHostSubStep, step.CurrentSubStep);

Assert.True(step.TryGoBack());
Assert.Equal(0, step.CurrentSubStep);

Assert.False(step.TryGoBack());
}

[Fact]
public void OnEnter_Back_AfterModeDowngrade_ClampsToNewSubStepCount()
{
// Operator selects reverse-proxy, walks to webhook (sub-step 4),
// leaves this wizard step, comes back via Back, switches mode to Local.
// The high-water mark from reverse-proxy (4) must NOT restore us past
// Local's max sub-step index (SubStepCount - 1 == 1).
using var step = new ExposureModeStepViewModel();
step.OnEnter(Context, NavigationDirection.Forward);
step.SelectedMode = ExposureMode.ReverseProxy;
step.TrustedProxies = ["10.0.0.0/24"];
step.TryAdvance();
step.TryAdvance();
step.TryAdvance();
step.TryAdvance();
Assert.Equal(4, step.CurrentSubStep);

step.OnLeave();
step.SelectedMode = ExposureMode.Local;
step.OnEnter(Context, NavigationDirection.Back);

Assert.InRange(step.CurrentSubStep, 0, step.SubStepCount - 1);
}

[Fact]
public void ContributeSecrets_ReverseProxy_AddsDeviceToken()
{
// Reverse proxy is non-local, so the bootstrap device must still be
// generated to give the operator a way to pair the first remote client.
using var step = new ExposureModeStepViewModel();
step.SelectedMode = ExposureMode.ReverseProxy;

var builder = new WizardSecretsBuilder(Context.Paths);
step.ContributeSecrets(builder);

Assert.NotNull(step.BootstrapRawToken);
Assert.NotNull(step.BootstrapDevice);
}

// ── Bootstrap device pairing (#540) ──────────────────────────────────────

[Fact]
Expand Down
7 changes: 5 additions & 2 deletions src/Netclaw.Cli/Tui/Wizard/IWizardStepViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,11 @@ public interface IWizardStepViewModel : IDisposable
string GetHelpText();

/// <summary>
/// Attempt to advance within the step (next sub-step or validation trigger).
/// Returns <c>true</c> if the step handled the advance internally (sub-step change).
/// Attempt to advance within the step.
/// Returns <c>true</c> if the step handled the advance internally — either by moving
/// to the next sub-step OR by staying on the current sub-step because an in-step
/// validation gate blocked the advance. In both cases the orchestrator should NOT
/// move to the next wizard step.
/// Returns <c>false</c> when the step is complete and the orchestrator should move forward.
/// </summary>
bool TryAdvance();
Expand Down
Loading
Loading