Skip to content
Merged
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,10 @@ involved.
to authenticate with (required), and optional split-tunnel routes
(`10.0.0.0/8`, `fd00::/8`, …).
4. Select it and **Connect**. The status panel shows the assigned IP, gateway,
routes, and the live iroh connection path once connected.
routes, and the live iroh connection path once connected. A server that is
down, or a connection that drops, is retried by the core with backoff (1s
doubling to 60s) for as long as it takes; while that runs the panel says
how many attempts have failed, when the next one is due, and the last error.
5. **Disconnect** tears down the tunnel and routes.

Keys are shared across profiles: several profiles can authenticate with the same
Expand Down
4 changes: 2 additions & 2 deletions native/native.targets
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@
next to the app's own version (EzvpnAppVersion in Directory.Build.props,
which is independent of this pin). A local core build (EZVPN_LOCAL_DLL)
still reports the pinned number: the local DLL carries none. -->
<EzvpnReleaseTag Condition="'$(EzvpnReleaseTag)' == ''">v0.0.48</EzvpnReleaseTag>
<EzvpnReleaseTag Condition="'$(EzvpnReleaseTag)' == ''">v0.0.49</EzvpnReleaseTag>
<EzvpnCoreVersion>$(EzvpnReleaseTag.TrimStart('v'))</EzvpnCoreVersion>
<EzvpnDllZipUrl>https://github.com/flexaccessdev/ezvpn/releases/download/$(EzvpnReleaseTag)/ezvpn-windows.dll.zip</EzvpnDllZipUrl>
<EzvpnDllZipSha256>029c7fe78eab91b81467c60d3eff7dd2feece227c105d956406a1a09c7bf6333</EzvpnDllZipSha256>
<EzvpnDllZipSha256>edc79a2a607ba2a56609d51e8f78064ae6850fc3c63e610476ae882602dcd048</EzvpnDllZipSha256>

<!-- Local core build used when EZVPN_LOCAL_DLL=1 (see ..\ezvpn\build-windows.ps1).
In that mode ezvpn.dll is consumed straight from the sibling dist — it is
Expand Down
6 changes: 6 additions & 0 deletions src/Ezvpn.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@
Title="Connection error" Message="{Binding ErrorText}"
IsClosable="False" />

<!-- While the core's reconnect loop is between attempts: how far it
has got and when it tries again, plus the last attempt's error. -->
<InfoBar IsOpen="{Binding IsReconnecting}" Severity="Warning"
Title="{Binding ReconnectText}" Message="{Binding ReconnectErrorText}"
IsClosable="False" />

<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="ConnectButton" Content="Connect"
Style="{ThemeResource AccentButtonStyle}"
Expand Down
41 changes: 41 additions & 0 deletions src/Ezvpn.App/ViewModels/TunnelViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ private set

public string StateText => State switch
{
// The core retries every failed attempt itself; say so once one has
// failed, so a backoff wait reads as waiting rather than stuck.
ConnectionState.Connecting when IsReconnecting => "Reconnecting…",
ConnectionState.Connecting => "Connecting…",
ConnectionState.Connected => "Connected",
ConnectionState.Error => "Error",
Expand Down Expand Up @@ -128,6 +131,39 @@ private set

public bool HasError => State == ConnectionState.Error && !string.IsNullOrEmpty(Error);

/// <summary>
/// True while the core's reconnect loop is between attempts after at least
/// one failure (the status reports <c>failed_attempts</c> &gt; 0).
/// </summary>
public bool IsReconnecting => State == ConnectionState.Connecting && _status?.FailedAttempts > 0;

/// <summary>
/// How far the retry loop has got, e.g. "3 failed attempts, next in 8s"
/// (the same line <c>ezvpn client status</c> prints), or "trying now" while
/// an attempt is in progress.
/// </summary>
public string ReconnectText
{
get
{
if (_status is null || _status.FailedAttempts == 0)
{
return "";
}
var n = _status.FailedAttempts;
var attempts = n == 1 ? "1 failed attempt" : $"{n} failed attempts";
var next = _status.NextAttemptSecs switch
{
> 0 and var secs => $"next in {FormatWait(TimeSpan.FromSeconds(secs))}",
_ => "trying now",
};
return $"{attempts}, {next}";
}
}

/// <summary>The last attempt's error while reconnecting, or "" when unknown.</summary>
public string ReconnectErrorText => IsReconnecting ? _status?.LastError ?? "" : "";

public string ConnectedSinceText =>
_status?.ConnectedSinceSecs is { } secs
? FormatElapsed(TimeSpan.FromSeconds(secs))
Expand All @@ -138,6 +174,11 @@ private set
private static string FormatElapsed(TimeSpan t) =>
$"{(int)t.TotalHours:00}:{t.Minutes:00}:{t.Seconds:00}";

// A backoff wait is at most a minute or so ("8s", "1m 0s"), so a compact
// form reads better than the clock-style elapsed time above.
private static string FormatWait(TimeSpan t) =>
t.TotalMinutes >= 1 ? $"{(int)t.TotalMinutes}m {t.Seconds}s" : $"{t.Seconds}s";

// --- State transitions ----------------------------------------------------

public void SetConnecting()
Expand Down
15 changes: 15 additions & 0 deletions src/Ezvpn.Core/ClientStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ public sealed class ClientStatus

public IReadOnlyList<string> BypassAddrs => StrList("bypass_addrs");

/// <summary>
/// Consecutive failed connection attempts in the current outage; 0 while
/// connected or before the first failure.
/// </summary>
public int FailedAttempts => Int("failed_attempts") ?? 0;

/// <summary>
/// Seconds until the core's reconnect loop tries again: 0 while an attempt
/// is in progress, null when no retry is pending.
/// </summary>
public ulong? NextAttemptSecs => ULong("next_attempt_secs");

/// <summary>The error that ended the last failed attempt, while reconnecting.</summary>
public string? LastError => Str("last_error");

public IReadOnlyList<CustomRelayStatus> CustomRelays
{
get
Expand Down
32 changes: 32 additions & 0 deletions tests/Ezvpn.Core.Tests/ClientStatusTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,35 @@ public void Parse_ConnectedDualStack()
Assert.Single(status.CustomRelays);
Assert.True(status.CustomRelays[0].Working);
Assert.Equal("https://relay.example/", status.CustomRelays[0].Url);
Assert.Equal(0, status.FailedAttempts);
Assert.Null(status.NextAttemptSecs);
Assert.Null(status.LastError);
}

// The reconnect loop's progress while down: consecutive failures, when it
// tries again (0 while an attempt is in progress), and the last error.
[Fact]
public void Parse_ReconnectingReportsProgress()
{
const string json = """
{"role":"client","instance":"work","state":"disconnected","mode":"none",
"server_node_id":"node","device_id":"x",
"failed_attempts":3,"next_attempt_secs":8,
"last_error":"Signaling error: Failed to connect to server"}
""";
var status = ClientStatus.Parse(json);
Assert.NotNull(status);
Assert.False(status!.IsConnected);
Assert.Equal(3, status.FailedAttempts);
Assert.Equal(8ul, status.NextAttemptSecs);
Assert.Equal("Signaling error: Failed to connect to server", status.LastError);

const string trying = """
{"state":"disconnected","failed_attempts":1,"next_attempt_secs":0,"last_error":"Connection lost"}
""";
var mid = ClientStatus.Parse(trying);
Assert.Equal(1, mid!.FailedAttempts);
Assert.Equal(0ul, mid.NextAttemptSecs);
}

[Fact]
Expand All @@ -50,6 +79,9 @@ public void Parse_Disconnected()
Assert.False(status!.IsConnected);
Assert.Null(status.AssignedIp);
Assert.Empty(status.Routes);
Assert.Equal(0, status.FailedAttempts);
Assert.Null(status.NextAttemptSecs);
Assert.Null(status.LastError);
}

[Theory]
Expand Down
Loading