Skip to content

Commit f02da79

Browse files
committed
feat: fetch hostname suffix from API
1 parent 9e50acd commit f02da79

File tree

6 files changed

+304
-5
lines changed

6 files changed

+304
-5
lines changed

App/App.xaml.cs

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public App()
7272
new WindowsCredentialBackend(WindowsCredentialBackend.CoderCredentialsTargetName));
7373
services.AddSingleton<ICredentialManager, CredentialManager>();
7474
services.AddSingleton<IRpcController, RpcController>();
75+
services.AddSingleton<IHostnameSuffixGetter, HostnameSuffixGetter>();
7576

7677
services.AddOptions<MutagenControllerConfig>()
7778
.Bind(builder.Configuration.GetSection(MutagenControllerConfigSection));

App/Services/HostnameSuffixGetter.cs

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using Coder.Desktop.App.Models;
5+
using Coder.Desktop.CoderSdk.Coder;
6+
using Coder.Desktop.Vpn.Utilities;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace Coder.Desktop.App.Services;
10+
11+
public interface IHostnameSuffixGetter
12+
{
13+
public event EventHandler<string> SuffixChanged;
14+
15+
public string GetCachedSuffix();
16+
}
17+
18+
public class HostnameSuffixGetter : IHostnameSuffixGetter
19+
{
20+
private const string DefaultSuffix = ".coder";
21+
22+
private readonly ICredentialManager _credentialManager;
23+
private readonly ICoderApiClientFactory _clientFactory;
24+
private readonly ILogger<HostnameSuffixGetter> _logger;
25+
26+
// _lock protects all private (non-readonly) values
27+
private readonly RaiiSemaphoreSlim _lock = new(1, 1);
28+
private string _domainSuffix = DefaultSuffix;
29+
private bool _dirty = false;
30+
private bool _getInProgress = false;
31+
private CredentialModel _credentialModel = new() { State = CredentialState.Invalid };
32+
33+
public event EventHandler<string>? SuffixChanged;
34+
35+
public HostnameSuffixGetter(ICredentialManager credentialManager, ICoderApiClientFactory apiClientFactory,
36+
ILogger<HostnameSuffixGetter> logger)
37+
{
38+
_credentialManager = credentialManager;
39+
_clientFactory = apiClientFactory;
40+
_logger = logger;
41+
credentialManager.CredentialsChanged += HandleCredentialsChanged;
42+
HandleCredentialsChanged(this, _credentialManager.GetCachedCredentials());
43+
}
44+
45+
~HostnameSuffixGetter()
46+
{
47+
_credentialManager.CredentialsChanged -= HandleCredentialsChanged;
48+
}
49+
50+
private void HandleCredentialsChanged(object? sender, CredentialModel credentials)
51+
{
52+
using var _ = _lock.Lock();
53+
_logger.LogDebug("credentials updated with state {state}", credentials.State);
54+
_credentialModel = credentials;
55+
if (credentials.State != CredentialState.Valid) return;
56+
57+
_dirty = true;
58+
if (!_getInProgress)
59+
{
60+
_getInProgress = true;
61+
Task.Run(Refresh).ContinueWith(MaybeRefreshAgain);
62+
}
63+
}
64+
65+
private async Task Refresh()
66+
{
67+
_logger.LogDebug("refreshing domain suffix");
68+
CredentialModel credentials;
69+
using (_ = await _lock.LockAsync())
70+
{
71+
credentials = _credentialModel;
72+
if (credentials.State != CredentialState.Valid)
73+
{
74+
_logger.LogDebug("abandoning refresh because credentials are now invalid");
75+
return;
76+
}
77+
78+
_dirty = false;
79+
}
80+
81+
var client = _clientFactory.Create(credentials.CoderUrl!.ToString());
82+
client.SetSessionToken(credentials.ApiToken!);
83+
using var timeoutSrc = new CancellationTokenSource(TimeSpan.FromSeconds(10));
84+
var connInfo = await client.GetAgentConnectionInfoGeneric(timeoutSrc.Token);
85+
86+
// older versions of Coder might not set this
87+
var suffix = string.IsNullOrEmpty(connInfo.HostnameSuffix)
88+
? DefaultSuffix
89+
// and, it doesn't include the leading dot.
90+
: "." + connInfo.HostnameSuffix;
91+
92+
var changed = false;
93+
using (_ = await _lock.LockAsync(CancellationToken.None))
94+
{
95+
if (_domainSuffix != suffix) changed = true;
96+
_domainSuffix = suffix;
97+
}
98+
99+
if (changed)
100+
{
101+
_logger.LogInformation("got new domain suffix '{suffix}'", suffix);
102+
// grab a local copy of the EventHandler to avoid TOCTOU race on the `?.` null-check
103+
var del = SuffixChanged;
104+
del?.Invoke(this, suffix);
105+
}
106+
else
107+
{
108+
_logger.LogDebug("domain suffix unchanged '{suffix}'", suffix);
109+
}
110+
}
111+
112+
private async Task MaybeRefreshAgain(Task prev)
113+
{
114+
if (prev.IsFaulted)
115+
{
116+
_logger.LogError(prev.Exception, "failed to query domain suffix");
117+
// back off here before retrying. We're just going to use a fixed, long
118+
// delay since this just affects UI stuff; we're not in a huge rush as
119+
// long as we eventually get the right value.
120+
await Task.Delay(TimeSpan.FromSeconds(10));
121+
}
122+
123+
using var l = await _lock.LockAsync(CancellationToken.None);
124+
if ((_dirty || prev.IsFaulted) && _credentialModel.State == CredentialState.Valid)
125+
{
126+
// we still have valid credentials and we're either dirty or the last Get failed.
127+
_logger.LogDebug("retrying domain suffix query");
128+
_ = Task.Run(Refresh).ContinueWith(MaybeRefreshAgain);
129+
return;
130+
}
131+
132+
// Getting here means either the credentials are not valid or we don't need to
133+
// refresh anyway.
134+
// The next time we get new, valid credentials, HandleCredentialsChanged will kick off
135+
// a new Refresh
136+
_getInProgress = false;
137+
return;
138+
}
139+
140+
public string GetCachedSuffix()
141+
{
142+
using var _ = _lock.Lock();
143+
return _domainSuffix;
144+
}
145+
}

App/ViewModels/TrayWindowViewModel.cs

+24-5
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
3535
private readonly IRpcController _rpcController;
3636
private readonly ICredentialManager _credentialManager;
3737
private readonly IAgentViewModelFactory _agentViewModelFactory;
38+
private readonly IHostnameSuffixGetter _hostnameSuffixGetter;
3839

3940
private FileSyncListWindow? _fileSyncListWindow;
4041

@@ -91,15 +92,14 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
9192

9293
[ObservableProperty] public partial string DashboardUrl { get; set; } = DefaultDashboardUrl;
9394

94-
private string _hostnameSuffix = DefaultHostnameSuffix;
95-
9695
public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController,
97-
ICredentialManager credentialManager, IAgentViewModelFactory agentViewModelFactory)
96+
ICredentialManager credentialManager, IAgentViewModelFactory agentViewModelFactory, IHostnameSuffixGetter hostnameSuffixGetter)
9897
{
9998
_services = services;
10099
_rpcController = rpcController;
101100
_credentialManager = credentialManager;
102101
_agentViewModelFactory = agentViewModelFactory;
102+
_hostnameSuffixGetter = hostnameSuffixGetter;
103103

104104
// Since the property value itself never changes, we add event
105105
// listeners for the underlying collection changing instead.
@@ -139,6 +139,9 @@ public void Initialize(DispatcherQueue dispatcherQueue)
139139

140140
_credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialModel(credentialModel);
141141
UpdateFromCredentialModel(_credentialManager.GetCachedCredentials());
142+
143+
_hostnameSuffixGetter.SuffixChanged += (_, suffix) => HandleHostnameSuffixChanged(suffix);
144+
HandleHostnameSuffixChanged(_hostnameSuffixGetter.GetCachedSuffix());
142145
}
143146

144147
private void UpdateFromRpcModel(RpcModel rpcModel)
@@ -195,7 +198,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
195198
this,
196199
uuid,
197200
fqdn,
198-
_hostnameSuffix,
201+
_hostnameSuffixGetter.GetCachedSuffix(),
199202
connectionStatus,
200203
credentialModel.CoderUrl,
201204
workspace?.Name));
@@ -214,7 +217,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
214217
// Workspace ID is fine as a stand-in here, it shouldn't
215218
// conflict with any agent IDs.
216219
uuid,
217-
_hostnameSuffix,
220+
_hostnameSuffixGetter.GetCachedSuffix(),
218221
AgentConnectionStatus.Gray,
219222
credentialModel.CoderUrl,
220223
workspace.Name));
@@ -273,6 +276,22 @@ private void UpdateFromCredentialModel(CredentialModel credentialModel)
273276
DashboardUrl = credentialModel.CoderUrl?.ToString() ?? DefaultDashboardUrl;
274277
}
275278

279+
private void HandleHostnameSuffixChanged(string suffix)
280+
{
281+
// Ensure we're on the UI thread.
282+
if (_dispatcherQueue == null) return;
283+
if (!_dispatcherQueue.HasThreadAccess)
284+
{
285+
_dispatcherQueue.TryEnqueue(() => HandleHostnameSuffixChanged(suffix));
286+
return;
287+
}
288+
289+
foreach (var agent in Agents)
290+
{
291+
agent.ConfiguredHostnameSuffix = suffix;
292+
}
293+
}
294+
276295
public void VpnSwitch_Toggled(object sender, RoutedEventArgs e)
277296
{
278297
if (sender is not ToggleSwitch toggleSwitch) return;

CoderSdk/Coder/CoderApiClient.cs

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public partial interface ICoderApiClient
4949
public void SetSessionToken(string token);
5050
}
5151

52+
[JsonSerializable(typeof(AgentConnectionInfo))]
5253
[JsonSerializable(typeof(BuildInfo))]
5354
[JsonSerializable(typeof(Response))]
5455
[JsonSerializable(typeof(User))]

CoderSdk/Coder/WorkspaceAgents.cs

+13
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ namespace Coder.Desktop.CoderSdk.Coder;
33
public partial interface ICoderApiClient
44
{
55
public Task<WorkspaceAgent> GetWorkspaceAgent(string id, CancellationToken ct = default);
6+
public Task<AgentConnectionInfo> GetAgentConnectionInfoGeneric(CancellationToken ct = default);
7+
}
8+
9+
public class AgentConnectionInfo
10+
{
11+
public string HostnameSuffix { get; set; } = string.Empty;
12+
// note that we're leaving out several fields including the DERP Map because
13+
// we don't use that information, and it's a complex object to define.
614
}
715

816
public class WorkspaceAgent
@@ -35,4 +43,9 @@ public Task<WorkspaceAgent> GetWorkspaceAgent(string id, CancellationToken ct =
3543
{
3644
return SendRequestNoBodyAsync<WorkspaceAgent>(HttpMethod.Get, "/api/v2/workspaceagents/" + id, ct);
3745
}
46+
47+
public Task<AgentConnectionInfo> GetAgentConnectionInfoGeneric(CancellationToken ct = default)
48+
{
49+
return SendRequestNoBodyAsync<AgentConnectionInfo>(HttpMethod.Get, "/api/v2/workspaceagents/connection", ct);
50+
}
3851
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using Coder.Desktop.App.Models;
3+
using Coder.Desktop.App.Services;
4+
using Coder.Desktop.CoderSdk.Coder;
5+
using Microsoft.Extensions.Hosting;
6+
using Microsoft.Extensions.Logging;
7+
using Moq;
8+
using Serilog;
9+
10+
namespace Coder.Desktop.Tests.App.Services;
11+
12+
[TestFixture]
13+
public class HostnameSuffixGetterTest
14+
{
15+
const string coderUrl = "https://coder.test/";
16+
17+
[SetUp]
18+
public void SetupMocks()
19+
{
20+
Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.NUnitOutput().CreateLogger();
21+
var builder = Host.CreateApplicationBuilder();
22+
builder.Services.AddSerilog();
23+
_logger = (ILogger<HostnameSuffixGetter>)builder.Build().Services
24+
.GetService(typeof(ILogger<HostnameSuffixGetter>))!;
25+
26+
_mCoderApiClientFactory = new Mock<ICoderApiClientFactory>(MockBehavior.Strict);
27+
_mCredentialManager = new Mock<ICredentialManager>(MockBehavior.Strict);
28+
_mCoderApiClient = new Mock<ICoderApiClient>(MockBehavior.Strict);
29+
_mCoderApiClientFactory.Setup(m => m.Create(coderUrl)).Returns(_mCoderApiClient.Object);
30+
}
31+
32+
private Mock<ICoderApiClientFactory> _mCoderApiClientFactory;
33+
private Mock<ICredentialManager> _mCredentialManager;
34+
private Mock<ICoderApiClient> _mCoderApiClient;
35+
private ILogger<HostnameSuffixGetter> _logger;
36+
37+
[Test(Description = "Mainline no errors")]
38+
[CancelAfter(10_000)]
39+
public async Task Mainline(CancellationToken ct)
40+
{
41+
_mCredentialManager.Setup(m => m.GetCachedCredentials())
42+
.Returns(new CredentialModel() { State = CredentialState.Invalid });
43+
var hostnameSuffixGetter =
44+
new HostnameSuffixGetter(_mCredentialManager.Object, _mCoderApiClientFactory.Object, _logger);
45+
46+
// initially, we return the default
47+
Assert.That(hostnameSuffixGetter.GetCachedSuffix(), Is.EqualTo(".coder"));
48+
49+
// subscribed to suffix changes
50+
var suffixCompletion = new TaskCompletionSource<string>();
51+
hostnameSuffixGetter.SuffixChanged += (_, suffix) => suffixCompletion.SetResult(suffix);
52+
53+
// set the client to return "test" as the suffix
54+
_mCoderApiClient.Setup(m => m.SetSessionToken("test-token"));
55+
_mCoderApiClient.Setup(m => m.GetAgentConnectionInfoGeneric(It.IsAny<CancellationToken>()))
56+
.Returns(Task.FromResult(new AgentConnectionInfo() { HostnameSuffix = "test" }));
57+
58+
_mCredentialManager.Raise(m => m.CredentialsChanged += null, _mCredentialManager.Object, new CredentialModel
59+
{
60+
State = CredentialState.Valid,
61+
CoderUrl = new Uri(coderUrl),
62+
ApiToken = "test-token",
63+
});
64+
var gotSuffix = await TaskOrCancellation(suffixCompletion.Task, ct);
65+
Assert.That(gotSuffix, Is.EqualTo(".test"));
66+
67+
// now, we should return the .test domain going forward
68+
Assert.That(hostnameSuffixGetter.GetCachedSuffix(), Is.EqualTo(".test"));
69+
}
70+
71+
[Test(Description = "Retries if error")]
72+
[CancelAfter(30_000)]
73+
// TODO: make this test not have to actually wait for the retry.
74+
public async Task RetryError(CancellationToken ct)
75+
{
76+
_mCredentialManager.Setup(m => m.GetCachedCredentials())
77+
.Returns(new CredentialModel() { State = CredentialState.Invalid });
78+
var hostnameSuffixGetter =
79+
new HostnameSuffixGetter(_mCredentialManager.Object, _mCoderApiClientFactory.Object, _logger);
80+
81+
// subscribed to suffix changes
82+
var suffixCompletion = new TaskCompletionSource<string>();
83+
hostnameSuffixGetter.SuffixChanged += (_, suffix) => suffixCompletion.SetResult(suffix);
84+
85+
// set the client to fail once, then return successfully
86+
_mCoderApiClient.Setup(m => m.SetSessionToken("test-token"));
87+
var connectionInfoCompletion = new TaskCompletionSource<AgentConnectionInfo>();
88+
_mCoderApiClient.SetupSequence(m => m.GetAgentConnectionInfoGeneric(It.IsAny<CancellationToken>()))
89+
.Returns(Task.FromException<AgentConnectionInfo>(new Exception("a bad thing happened")))
90+
.Returns(Task.FromResult(new AgentConnectionInfo() { HostnameSuffix = "test" }));
91+
92+
_mCredentialManager.Raise(m => m.CredentialsChanged += null, _mCredentialManager.Object, new CredentialModel
93+
{
94+
State = CredentialState.Valid,
95+
CoderUrl = new Uri(coderUrl),
96+
ApiToken = "test-token",
97+
});
98+
var gotSuffix = await TaskOrCancellation(suffixCompletion.Task, ct);
99+
Assert.That(gotSuffix, Is.EqualTo(".test"));
100+
101+
// now, we should return the .test domain going forward
102+
Assert.That(hostnameSuffixGetter.GetCachedSuffix(), Is.EqualTo(".test"));
103+
}
104+
105+
/// <summary>
106+
/// TaskOrCancellation waits for either the task to complete, or the given token to be canceled.
107+
/// </summary>
108+
internal static async Task<TResult> TaskOrCancellation<TResult>(Task<TResult> task,
109+
CancellationToken cancellationToken)
110+
{
111+
var cancellationTask = new TaskCompletionSource<TResult>();
112+
await using (cancellationToken.Register(() => cancellationTask.TrySetCanceled()))
113+
{
114+
// Wait for either the task or the cancellation
115+
var completedTask = await Task.WhenAny(task, cancellationTask.Task);
116+
// Await to propagate exceptions, if any
117+
return await completedTask;
118+
}
119+
}
120+
}

0 commit comments

Comments
 (0)