Skip to content

Commit be72f80

Browse files
authored
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
}

0 commit comments

Comments
 (0)