Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit be72f80

Browse files
authoredMay 20, 2025··
feat: fetch hostname suffix from API (#103)
Fixes #49 Adds support to query the hostname suffix from Coder server, and then propagates any changes to the agent view models.
1 parent 9e50acd commit be72f80

File tree

7 files changed

+316
-6
lines changed

7 files changed

+316
-6
lines changed
 

‎App/App.xaml.cs

Lines changed: 1 addition & 0 deletions
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/Models/CredentialModel.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using Coder.Desktop.CoderSdk.Coder;
23

34
namespace Coder.Desktop.App.Models;
45

@@ -14,7 +15,7 @@ public enum CredentialState
1415
Valid,
1516
}
1617

17-
public class CredentialModel
18+
public class CredentialModel : ICoderApiClientCredentialProvider
1819
{
1920
public CredentialState State { get; init; } = CredentialState.Unknown;
2021

@@ -33,4 +34,14 @@ public CredentialModel Clone()
3334
Username = Username,
3435
};
3536
}
37+
38+
public CoderApiClientCredential? GetCoderApiClientCredential()
39+
{
40+
if (State != CredentialState.Valid) return null;
41+
return new CoderApiClientCredential
42+
{
43+
ApiToken = ApiToken!,
44+
CoderUrl = CoderUrl!,
45+
};
46+
}
3647
}

‎App/Services/HostnameSuffixGetter.cs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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);
82+
using var timeoutSrc = new CancellationTokenSource(TimeSpan.FromSeconds(10));
83+
var connInfo = await client.GetAgentConnectionInfoGeneric(timeoutSrc.Token);
84+
85+
// older versions of Coder might not set this
86+
var suffix = string.IsNullOrEmpty(connInfo.HostnameSuffix)
87+
? DefaultSuffix
88+
// and, it doesn't include the leading dot.
89+
: "." + connInfo.HostnameSuffix;
90+
91+
var changed = false;
92+
using (_ = await _lock.LockAsync(CancellationToken.None))
93+
{
94+
if (_domainSuffix != suffix) changed = true;
95+
_domainSuffix = suffix;
96+
}
97+
98+
if (changed)
99+
{
100+
_logger.LogInformation("got new domain suffix '{suffix}'", suffix);
101+
// grab a local copy of the EventHandler to avoid TOCTOU race on the `?.` null-check
102+
var del = SuffixChanged;
103+
del?.Invoke(this, suffix);
104+
}
105+
else
106+
{
107+
_logger.LogDebug("domain suffix unchanged '{suffix}'", suffix);
108+
}
109+
}
110+
111+
private async Task MaybeRefreshAgain(Task prev)
112+
{
113+
if (prev.IsFaulted)
114+
{
115+
_logger.LogError(prev.Exception, "failed to query domain suffix");
116+
// back off here before retrying. We're just going to use a fixed, long
117+
// delay since this just affects UI stuff; we're not in a huge rush as
118+
// long as we eventually get the right value.
119+
await Task.Delay(TimeSpan.FromSeconds(10));
120+
}
121+
122+
using var l = await _lock.LockAsync(CancellationToken.None);
123+
if ((_dirty || prev.IsFaulted) && _credentialModel.State == CredentialState.Valid)
124+
{
125+
// we still have valid credentials and we're either dirty or the last Get failed.
126+
_logger.LogDebug("retrying domain suffix query");
127+
_ = Task.Run(Refresh).ContinueWith(MaybeRefreshAgain);
128+
return;
129+
}
130+
131+
// Getting here means either the credentials are not valid or we don't need to
132+
// refresh anyway.
133+
// The next time we get new, valid credentials, HandleCredentialsChanged will kick off
134+
// a new Refresh
135+
_getInProgress = false;
136+
return;
137+
}
138+
139+
public string GetCachedSuffix()
140+
{
141+
using var _ = _lock.Lock();
142+
return _domainSuffix;
143+
}
144+
}

‎App/ViewModels/TrayWindowViewModel.cs

Lines changed: 24 additions & 5 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 13 additions & 0 deletions
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
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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(It.IsAny<ICoderApiClientCredentialProvider>()))
30+
.Returns(_mCoderApiClient.Object);
31+
}
32+
33+
private Mock<ICoderApiClientFactory> _mCoderApiClientFactory;
34+
private Mock<ICredentialManager> _mCredentialManager;
35+
private Mock<ICoderApiClient> _mCoderApiClient;
36+
private ILogger<HostnameSuffixGetter> _logger;
37+
38+
[Test(Description = "Mainline no errors")]
39+
[CancelAfter(10_000)]
40+
public async Task Mainline(CancellationToken ct)
41+
{
42+
_mCredentialManager.Setup(m => m.GetCachedCredentials())
43+
.Returns(new CredentialModel() { State = CredentialState.Invalid });
44+
var hostnameSuffixGetter =
45+
new HostnameSuffixGetter(_mCredentialManager.Object, _mCoderApiClientFactory.Object, _logger);
46+
47+
// initially, we return the default
48+
Assert.That(hostnameSuffixGetter.GetCachedSuffix(), Is.EqualTo(".coder"));
49+
50+
// subscribed to suffix changes
51+
var suffixCompletion = new TaskCompletionSource<string>();
52+
hostnameSuffixGetter.SuffixChanged += (_, suffix) => suffixCompletion.SetResult(suffix);
53+
54+
// set the client to return "test" as the suffix
55+
_mCoderApiClient.Setup(m => m.SetSessionToken("test-token"));
56+
_mCoderApiClient.Setup(m => m.GetAgentConnectionInfoGeneric(It.IsAny<CancellationToken>()))
57+
.Returns(Task.FromResult(new AgentConnectionInfo() { HostnameSuffix = "test" }));
58+
59+
_mCredentialManager.Raise(m => m.CredentialsChanged += null, _mCredentialManager.Object, new CredentialModel
60+
{
61+
State = CredentialState.Valid,
62+
CoderUrl = new Uri(coderUrl),
63+
ApiToken = "test-token",
64+
});
65+
var gotSuffix = await TaskOrCancellation(suffixCompletion.Task, ct);
66+
Assert.That(gotSuffix, Is.EqualTo(".test"));
67+
68+
// now, we should return the .test domain going forward
69+
Assert.That(hostnameSuffixGetter.GetCachedSuffix(), Is.EqualTo(".test"));
70+
}
71+
72+
[Test(Description = "Retries if error")]
73+
[CancelAfter(30_000)]
74+
// TODO: make this test not have to actually wait for the retry.
75+
public async Task RetryError(CancellationToken ct)
76+
{
77+
_mCredentialManager.Setup(m => m.GetCachedCredentials())
78+
.Returns(new CredentialModel() { State = CredentialState.Invalid });
79+
var hostnameSuffixGetter =
80+
new HostnameSuffixGetter(_mCredentialManager.Object, _mCoderApiClientFactory.Object, _logger);
81+
82+
// subscribed to suffix changes
83+
var suffixCompletion = new TaskCompletionSource<string>();
84+
hostnameSuffixGetter.SuffixChanged += (_, suffix) => suffixCompletion.SetResult(suffix);
85+
86+
// set the client to fail once, then return successfully
87+
_mCoderApiClient.Setup(m => m.SetSessionToken("test-token"));
88+
var connectionInfoCompletion = new TaskCompletionSource<AgentConnectionInfo>();
89+
_mCoderApiClient.SetupSequence(m => m.GetAgentConnectionInfoGeneric(It.IsAny<CancellationToken>()))
90+
.Returns(Task.FromException<AgentConnectionInfo>(new Exception("a bad thing happened")))
91+
.Returns(Task.FromResult(new AgentConnectionInfo() { HostnameSuffix = "test" }));
92+
93+
_mCredentialManager.Raise(m => m.CredentialsChanged += null, _mCredentialManager.Object, new CredentialModel
94+
{
95+
State = CredentialState.Valid,
96+
CoderUrl = new Uri(coderUrl),
97+
ApiToken = "test-token",
98+
});
99+
var gotSuffix = await TaskOrCancellation(suffixCompletion.Task, ct);
100+
Assert.That(gotSuffix, Is.EqualTo(".test"));
101+
102+
// now, we should return the .test domain going forward
103+
Assert.That(hostnameSuffixGetter.GetCachedSuffix(), Is.EqualTo(".test"));
104+
}
105+
106+
/// <summary>
107+
/// TaskOrCancellation waits for either the task to complete, or the given token to be canceled.
108+
/// </summary>
109+
internal static async Task<TResult> TaskOrCancellation<TResult>(Task<TResult> task,
110+
CancellationToken cancellationToken)
111+
{
112+
var cancellationTask = new TaskCompletionSource<TResult>();
113+
await using (cancellationToken.Register(() => cancellationTask.TrySetCanceled()))
114+
{
115+
// Wait for either the task or the cancellation
116+
var completedTask = await Task.WhenAny(task, cancellationTask.Task);
117+
// Await to propagate exceptions, if any
118+
return await completedTask;
119+
}
120+
}
121+
}

0 commit comments

Comments
 (0)
Please sign in to comment.