Skip to content

fix: opt-in tailscale vpn loop prevention #148

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 28, 2025
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
2 changes: 1 addition & 1 deletion App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ public App()

services.AddSingleton<IDispatcherQueueManager>(_ => this);
services.AddSingleton<IDefaultNotificationHandler>(_ => this);
services.AddSingleton<ISettingsManager<CoderConnectSettings>, SettingsManager<CoderConnectSettings>>();
services.AddSingleton<ICredentialBackend>(_ =>
new WindowsCredentialBackend(WindowsCredentialBackend.CoderCredentialsTargetName));
services.AddSingleton<ICredentialManager, CredentialManager>();
Expand Down Expand Up @@ -120,7 +121,6 @@ public App()
// FileSyncListMainPage is created by FileSyncListWindow.
services.AddTransient<FileSyncListWindow>();

services.AddSingleton<ISettingsManager<CoderConnectSettings>, SettingsManager<CoderConnectSettings>>();
services.AddSingleton<IStartupManager, StartupManager>();
// SettingsWindow views and view models
services.AddTransient<SettingsViewModel>();
Expand Down
13 changes: 11 additions & 2 deletions App/Models/Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ public class CoderConnectSettings : ISettings<CoderConnectSettings>
/// </summary>
public bool ConnectOnLaunch { get; set; }

/// <summary>
/// When this is true Coder Connect will not attempt to protect against Tailscale loopback issues.
/// </summary>
public bool EnableCorporateVpnSupport { get; set; }

/// <summary>
/// CoderConnect current settings version. Increment this when the settings schema changes.
/// In future iterations we will be able to handle migrations when the user has
Expand All @@ -46,17 +51,21 @@ public CoderConnectSettings()
Version = VERSION;

ConnectOnLaunch = false;

EnableCorporateVpnSupport = false;
}

public CoderConnectSettings(int? version, bool connectOnLaunch)
public CoderConnectSettings(int? version, bool connectOnLaunch, bool enableCorporateVpnSupport)
{
Version = version ?? VERSION;

ConnectOnLaunch = connectOnLaunch;

EnableCorporateVpnSupport = enableCorporateVpnSupport;
}

public CoderConnectSettings Clone()
{
return new CoderConnectSettings(Version, ConnectOnLaunch);
return new CoderConnectSettings(Version, ConnectOnLaunch, EnableCorporateVpnSupport);
}
}
10 changes: 4 additions & 6 deletions App/Services/CredentialManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,9 @@ private async Task<CredentialModel> LoadCredentialsInner(CancellationToken ct)
};
}

// Grab the lock again so we can update the state.
using (await _opLock.LockAsync(ct))
// Grab the lock again so we can update the state. Don't use the CT
// here as it may have already been canceled.
using (await _opLock.LockAsync(TimeSpan.FromSeconds(5), CancellationToken.None))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bug I noticed on startup when the server was unreachable, it would timeout the CT and then never set the status to "invalid" so the tray window would always be stuck in the loading state

{
// Prevent new LoadCredentials calls from returning this task.
if (_loadCts != null)
Expand All @@ -242,11 +243,8 @@ private async Task<CredentialModel> LoadCredentialsInner(CancellationToken ct)
if (latestCreds is not null) return latestCreds;
}

// If there aren't any latest credentials after a cancellation, we
// most likely timed out and should throw.
ct.ThrowIfCancellationRequested();

UpdateState(model);
ct.ThrowIfCancellationRequested();
return model;
}
}
Expand Down
6 changes: 5 additions & 1 deletion App/Services/RpcController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,18 @@ public interface IRpcController : IAsyncDisposable
public class RpcController : IRpcController
{
private readonly ICredentialManager _credentialManager;
private readonly ISettingsManager<CoderConnectSettings> _settingsManager;

private readonly RaiiSemaphoreSlim _operationLock = new(1, 1);
private Speaker<ClientMessage, ServiceMessage>? _speaker;

private readonly RaiiSemaphoreSlim _stateLock = new(1, 1);
private readonly RpcModel _state = new();

public RpcController(ICredentialManager credentialManager)
public RpcController(ICredentialManager credentialManager, ISettingsManager<CoderConnectSettings> settingsManager)
{
_credentialManager = credentialManager;
_settingsManager = settingsManager;
}

public event EventHandler<RpcModel>? StateChanged;
Expand Down Expand Up @@ -156,6 +158,7 @@ public async Task StartVpn(CancellationToken ct = default)
using var _ = await AcquireOperationLockNowAsync();
AssertRpcConnected();

var coderConnectSettings = await _settingsManager.Read(ct);
var credentials = _credentialManager.GetCachedCredentials();
if (credentials.State != CredentialState.Valid)
throw new RpcOperationException(
Expand All @@ -175,6 +178,7 @@ public async Task StartVpn(CancellationToken ct = default)
{
CoderUrl = credentials.CoderUrl?.ToString(),
ApiToken = credentials.ApiToken,
TunnelUseSoftNetIsolation = coderConnectSettings.EnableCorporateVpnSupport,
},
}, ct);
}
Expand Down
21 changes: 20 additions & 1 deletion App/ViewModels/SettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty]
public partial bool ConnectOnLaunch { get; set; }

[ObservableProperty]
public partial bool DisableTailscaleLoopProtection { get; set; }

[ObservableProperty]
public partial bool StartOnLoginDisabled { get; set; }

Expand All @@ -31,6 +34,7 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager<Cod
_connectSettings = settingsManager.Read().GetAwaiter().GetResult();
StartOnLogin = startupManager.IsEnabled();
ConnectOnLaunch = _connectSettings.ConnectOnLaunch;
DisableTailscaleLoopProtection = _connectSettings.EnableCorporateVpnSupport;

// Various policies can disable the "Start on login" option.
// We disable the option in the UI if the policy is set.
Expand All @@ -43,6 +47,21 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager<Cod
}
}

partial void OnDisableTailscaleLoopProtectionChanged(bool oldValue, bool newValue)
{
if (oldValue == newValue)
return;
try
{
_connectSettings.EnableCorporateVpnSupport = DisableTailscaleLoopProtection;
_connectSettingsManager.Write(_connectSettings);
}
catch (Exception ex)
{
_logger.LogError($"Error saving Coder Connect {nameof(DisableTailscaleLoopProtection)} settings: {ex.Message}");
}
}

partial void OnConnectOnLaunchChanged(bool oldValue, bool newValue)
{
if (oldValue == newValue)
Expand All @@ -54,7 +73,7 @@ partial void OnConnectOnLaunchChanged(bool oldValue, bool newValue)
}
catch (Exception ex)
{
_logger.LogError($"Error saving Coder Connect settings: {ex.Message}");
_logger.LogError($"Error saving Coder Connect {nameof(ConnectOnLaunch)} settings: {ex.Message}");
}
}

Expand Down
6 changes: 6 additions & 0 deletions App/Views/Pages/SettingsMainPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
>
<ToggleSwitch IsOn="{x:Bind ViewModel.ConnectOnLaunch, Mode=TwoWay}" />
</controls:SettingsCard>
<controls:SettingsCard Description="This setting loosens some VPN loop protection checks in Coder Connect, allowing traffic to flow to a Coder deployment behind a corporate VPN. We only recommend enabling this option if Coder Connect doesn't work with your Coder deployment behind a corporate VPN."
Header="Enable support for corporate VPNs"
HeaderIcon="{ui:FontIcon Glyph=&#xE705;}"
>
<ToggleSwitch IsOn="{x:Bind ViewModel.DisableTailscaleLoopProtection, Mode=TwoWay}" />
</controls:SettingsCard>
</StackPanel>
</Grid>
</ScrollViewer>
Expand Down
4 changes: 2 additions & 2 deletions App/Views/SettingsWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
xmlns:winuiex="using:WinUIEx"
mc:Ignorable="d"
Title="Coder Settings"
Width="600" Height="350"
MinWidth="600" MinHeight="350">
Width="600" Height="500"
MinWidth="600" MinHeight="500">

<Window.SystemBackdrop>
<MicaBackdrop/>
Expand Down
24 changes: 6 additions & 18 deletions App/Views/TrayWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
using Coder.Desktop.App.Views.Pages;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI;
using Microsoft.UI.Input;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Documents;
using Microsoft.UI.Xaml.Media.Animation;
using System;
using System.Collections.Generic;
Expand All @@ -19,6 +17,7 @@
using Windows.Graphics;
using Windows.System;
using Windows.UI.Core;
using Microsoft.UI.Input;
using WinRT.Interop;
using WindowActivatedEventArgs = Microsoft.UI.Xaml.WindowActivatedEventArgs;

Expand All @@ -41,7 +40,6 @@ public sealed partial class TrayWindow : Window

private readonly IRpcController _rpcController;
private readonly ICredentialManager _credentialManager;
private readonly ISyncSessionController _syncSessionController;
private readonly IUpdateController _updateController;
private readonly IUserNotifier _userNotifier;
private readonly TrayWindowLoadingPage _loadingPage;
Expand All @@ -51,15 +49,13 @@ public sealed partial class TrayWindow : Window

public TrayWindow(
IRpcController rpcController, ICredentialManager credentialManager,
ISyncSessionController syncSessionController, IUpdateController updateController,
IUserNotifier userNotifier,
IUpdateController updateController, IUserNotifier userNotifier,
TrayWindowLoadingPage loadingPage,
TrayWindowDisconnectedPage disconnectedPage, TrayWindowLoginRequiredPage loginRequiredPage,
TrayWindowMainPage mainPage)
{
_rpcController = rpcController;
_credentialManager = credentialManager;
_syncSessionController = syncSessionController;
_updateController = updateController;
_userNotifier = userNotifier;
_loadingPage = loadingPage;
Expand All @@ -74,9 +70,7 @@ public TrayWindow(

_rpcController.StateChanged += RpcController_StateChanged;
_credentialManager.CredentialsChanged += CredentialManager_CredentialsChanged;
_syncSessionController.StateChanged += SyncSessionController_StateChanged;
SetPageByState(_rpcController.GetState(), _credentialManager.GetCachedCredentials(),
_syncSessionController.GetState());
SetPageByState(_rpcController.GetState(), _credentialManager.GetCachedCredentials());

// Setting these directly in the .xaml doesn't seem to work for whatever reason.
TrayIcon.OpenCommand = Tray_OpenCommand;
Expand Down Expand Up @@ -127,8 +121,7 @@ public TrayWindow(
};
}

private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel,
SyncSessionControllerStateModel syncSessionModel)
private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel)
{
if (credentialModel.State == CredentialState.Unknown)
{
Expand Down Expand Up @@ -201,18 +194,13 @@ private void MaybeNotifyUser(RpcModel rpcModel)

private void RpcController_StateChanged(object? _, RpcModel model)
{
SetPageByState(model, _credentialManager.GetCachedCredentials(), _syncSessionController.GetState());
SetPageByState(model, _credentialManager.GetCachedCredentials());
MaybeNotifyUser(model);
}

private void CredentialManager_CredentialsChanged(object? _, CredentialModel model)
{
SetPageByState(_rpcController.GetState(), model, _syncSessionController.GetState());
}

private void SyncSessionController_StateChanged(object? _, SyncSessionControllerStateModel model)
{
SetPageByState(_rpcController.GetState(), _credentialManager.GetCachedCredentials(), model);
SetPageByState(_rpcController.GetState(), model);
}

// Sadly this is necessary because Window.Content.SizeChanged doesn't
Expand Down
7 changes: 5 additions & 2 deletions Tests.App/Services/CredentialManagerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,10 @@ public async Task SetDuringLoad(CancellationToken ct)
var loadTask = manager.LoadCredentials(ct);
// Then fully perform a set.
await manager.SetCredentials(TestServerUrl, TestApiToken, ct).WaitAsync(ct);
// The load should have been cancelled.
Assert.ThrowsAsync<TaskCanceledException>(() => loadTask);

// The load should complete with the new valid credentials
var result = await loadTask;
Assert.That(result.State, Is.EqualTo(CredentialState.Valid));
Assert.That(result.CoderUrl?.ToString(), Is.EqualTo(TestServerUrl));
}
}
3 changes: 2 additions & 1 deletion Vpn.Proto/vpn.proto
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ message ServiceMessage {
StartResponse start = 2;
StopResponse stop = 3;
Status status = 4; // either in reply to a StatusRequest or broadcasted
StartProgress start_progress = 5; // broadcasted during startup
StartProgress start_progress = 5; // broadcasted during startup (used exclusively by Windows)
}
}

Expand Down Expand Up @@ -214,6 +214,7 @@ message NetworkSettingsResponse {
// StartResponse.
message StartRequest {
int32 tunnel_file_descriptor = 1;
bool tunnel_use_soft_net_isolation = 8;
string coder_url = 2;
string api_token = 3;
// Additional HTTP headers added to all requests
Expand Down
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy