Skip to content

Commit 3afa871

Browse files
committed
feat: fetch hostname suffix from API
1 parent 0f8201e commit 3afa871

File tree

6 files changed

+304
-5
lines changed

6 files changed

+304
-5
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/Services/HostnameSuffixGetter.cs

Lines changed: 145 additions & 0 deletions
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

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: 120 additions & 0 deletions
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)
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