Skip to content
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
32 changes: 31 additions & 1 deletion cs/src/Management/TunnelManagementClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ public class TunnelManagementClient : ITunnelManagementClient

private readonly HttpClient httpClient;
private readonly Func<Task<AuthenticationHeaderValue?>> userTokenCallback;
private readonly bool isCustomDomain;

private class EventInfo
{
Expand Down Expand Up @@ -223,6 +224,8 @@ public TunnelManagementClient(
$"Invalid tunnel service URI: {tunnelServiceUri}", nameof(tunnelServiceUri));
}

this.isCustomDomain = tunnelServiceUri.Host.StartsWith("cp.");
Comment thread
DavidObando marked this conversation as resolved.

// The `SocketsHttpHandler` or `HttpClientHandler` automatic redirection is disabled
// because they do not keep the Authorization header when redirecting. This handler
// will keep all headers when redirecting, and also supports switching the behavior
Expand All @@ -235,6 +238,33 @@ public TunnelManagementClient(
};
}

/// <summary>
/// Creates a <see cref="TunnelManagementClient"/> configured for a custom domain.
/// </summary>
/// <remarks>
/// When a custom domain is configured (e.g., "app.github.dev"), control plane calls
/// are routed to "cp.{domain}" and cluster ID hostname manipulation is skipped
/// because routing is handled at the infrastructure level.
/// </remarks>
/// <param name="customDomain">The custom domain (e.g., "app.github.dev").</param>
/// <param name="userAgents">User agents.</param>
/// <param name="userTokenCallback">Optional authentication callback.</param>
/// <param name="httpHandler">Optional HTTP handler.</param>
/// <param name="apiVersion">API version.</param>
/// <returns>A configured <see cref="TunnelManagementClient"/> instance.</returns>
public static TunnelManagementClient ForCustomDomain(
Comment thread
DavidObando marked this conversation as resolved.
string customDomain,
ProductInfoHeaderValue[] userAgents,
Func<Task<AuthenticationHeaderValue?>>? userTokenCallback = null,
HttpMessageHandler? httpHandler = null,
ManagementApiVersions apiVersion = DefaultApiVersion)
{
Requires.NotNullOrEmpty(customDomain, nameof(customDomain));
var serviceUri = new Uri($"https://cp.{customDomain}/");
return new TunnelManagementClient(
userAgents, userTokenCallback, serviceUri, httpHandler, apiVersion);
}

/// <summary>
/// Gets or sets a value indicating whether events reporting is enabled.
/// </summary>
Expand Down Expand Up @@ -836,7 +866,7 @@ private Uri BuildUri(
var baseAddress = this.httpClient.BaseAddress!;
var builder = new UriBuilder(baseAddress);

if (baseAddress.HostNameType == UriHostNameType.Dns)
if (baseAddress.HostNameType == UriHostNameType.Dns && !this.isCustomDomain)
{
builder.Host = ReplaceTunnelServiceHostnameClusterId(builder.Host, clusterId);
}
Expand Down
59 changes: 59 additions & 0 deletions cs/test/TunnelsSDK.Test/TunnelManagementClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,65 @@ public async Task HandlePolicyFailureResponse()
(r) => Assert.Equal(policyRequirement2, r));
}

[Fact]
public async Task CustomDomainDoesNotModifyHostname()
{
Uri capturedUri = null;
var tunnel = new Tunnel
{
TunnelId = TunnelId,
ClusterId = ClusterId,
};

var handler = new MockHttpMessageHandler(
(message, ct) =>
{
capturedUri = message.RequestUri;
var result = new HttpResponseMessage(HttpStatusCode.OK);
result.Content = JsonContent.Create(tunnel);
return Task.FromResult(result);
});

var client = TunnelManagementClient.ForCustomDomain(
"app.github.dev",
new[] { this.userAgent },
httpHandler: handler);

await client.GetTunnelAsync(tunnel, options: null, this.timeout);

Assert.NotNull(capturedUri);
Assert.Equal("cp.app.github.dev", capturedUri.Host);
}

[Fact]
public async Task StandardServiceUriReplacesClusterIdInHostname()
{
Uri capturedUri = null;
var tunnel = new Tunnel
{
TunnelId = TunnelId,
ClusterId = ClusterId,
};

var handler = new MockHttpMessageHandler(
(message, ct) =>
{
capturedUri = message.RequestUri;
var result = new HttpResponseMessage(HttpStatusCode.OK);
result.Content = JsonContent.Create(tunnel);
return Task.FromResult(result);
});

var client = new TunnelManagementClient(
this.userAgent,
tunnelServiceUri: new Uri("https://global.rel.tunnels.api.visualstudio.com/"),
httpHandler: handler);

await client.GetTunnelAsync(tunnel, options: null, this.timeout);

Assert.NotNull(capturedUri);
Assert.StartsWith($"{ClusterId}.", capturedUri.Host);
}


private sealed class MockHttpMessageHandler : DelegatingHandler
Expand Down
19 changes: 17 additions & 2 deletions go/tunnels/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ type Manager struct {
additionalHeaders map[string]string
userAgents []UserAgent
apiVersion string
isCustomDomain bool
}

// Creates a new Manager used for interacting with the Tunnels APIs.
Expand Down Expand Up @@ -128,7 +129,21 @@ func NewManager(userAgents []UserAgent, tp tokenProviderfn, tunnelServiceUrl *ur
client = httpHandler
}

return &Manager{tokenProvider: tp, httpClient: client, uri: tunnelServiceUrl, userAgents: userAgents, apiVersion: apiVersion}, nil
return &Manager{tokenProvider: tp, httpClient: client, uri: tunnelServiceUrl, userAgents: userAgents, apiVersion: apiVersion, isCustomDomain: strings.HasPrefix(tunnelServiceUrl.Hostname(), "cp.")}, nil
}

// NewManagerForCustomDomain creates a Manager configured for a custom domain.
// When a custom domain is configured (e.g., "app.github.dev"), control plane calls
// are routed to "cp.{domain}" and cluster ID hostname manipulation is skipped.
func NewManagerForCustomDomain(customDomain string, userAgents []UserAgent, tp tokenProviderfn, httpHandler *http.Client, apiVersion string) (*Manager, error) {
if customDomain == "" {
return nil, fmt.Errorf("custom domain cannot be empty")
}
serviceUrl, err := url.Parse(fmt.Sprintf("https://cp.%s/", customDomain))
if err != nil {
return nil, fmt.Errorf("error parsing custom domain URL: %w", err)
}
return NewManager(userAgents, tp, serviceUrl, httpHandler, apiVersion)
}

// Lists tunnels owned by the authenticated user.
Expand Down Expand Up @@ -871,7 +886,7 @@ func (m *Manager) getAccessToken(tunnel *Tunnel, tunnelRequestOptions *TunnelReq

func (m *Manager) buildUri(clusterId string, path string, options *TunnelRequestOptions, query string) *url.URL {
baseAddress := m.uri
if clusterId != "" {
if clusterId != "" && !m.isCustomDomain {
// tunnels.local.api.visualstudio.com resolves to localhost (for local development).
if baseAddress.Host != "localhost" &&
baseAddress.Host != "tunnels.local.api.visualstudio.com" &&
Expand Down
45 changes: 45 additions & 0 deletions go/tunnels/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -995,3 +995,48 @@ func TestValidTokenScopes(t *testing.T) {
t.Errorf("Multiple scopes should not be valid without allowMultiple flag")
}
}

func TestCustomDomainDoesNotModifyHostname(t *testing.T) {
manager, err := NewManagerForCustomDomain(
"app.github.dev",
userAgentManagerTest,
getUserToken,
nil,
"2023-09-27-preview",
)
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}

tunnel := &Tunnel{
TunnelID: "tnnl0001",
ClusterID: "usw2",
}
uri := manager.buildUri(tunnel.ClusterID, fmt.Sprintf("%s/%s", tunnelsApiPath, tunnel.TunnelID), nil, "")
if uri.Hostname() != "cp.app.github.dev" {
t.Errorf("Expected hostname cp.app.github.dev, got %s", uri.Hostname())
}
}

func TestStandardServiceUriReplacesClusterId(t *testing.T) {
serviceUrl, _ := url.Parse(ServiceProperties.ServiceURI)
manager, err := NewManager(
userAgentManagerTest,
getUserToken,
serviceUrl,
nil,
"2023-09-27-preview",
)
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}

tunnel := &Tunnel{
TunnelID: "tnnl0001",
ClusterID: "usw2",
}
uri := manager.buildUri(tunnel.ClusterID, fmt.Sprintf("%s/%s", tunnelsApiPath, tunnel.TunnelID), nil, "")
if !strings.HasPrefix(uri.Hostname(), "usw2.") {
t.Errorf("Expected hostname to start with usw2., got %s", uri.Hostname())
}
}
2 changes: 1 addition & 1 deletion go/tunnels/tunnels.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"github.com/rodaine/table"
)

const PackageVersion = "0.1.22"
const PackageVersion = "0.1.23"

func (tunnel *Tunnel) requestObject() (*Tunnel, error) {
convertedTunnel := &Tunnel{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public class TunnelManagementClient implements ITunnelManagementClient {
private final Supplier<CompletableFuture<String>> userTokenCallback;
private final String baseAddress;
private final String apiVersion;
private final boolean isCustomDomain;

public static final String[] ApiVersions = {
"2023-09-27-preview"
Expand Down Expand Up @@ -130,6 +131,35 @@ public TunnelManagementClient(
this.baseAddress = tunnelServiceUri != null ? tunnelServiceUri : prodServiceUri;
this.httpClient = HttpClient.newHttpClient();
this.apiVersion = apiVersion;
try {
this.isCustomDomain = new URI(this.baseAddress).getHost().startsWith("cp.");
} catch (URISyntaxException e) {
throw new IllegalArgumentException("Invalid tunnel service URI: " + this.baseAddress, e);
}
}

/**
* Creates a TunnelManagementClient configured for a custom domain.
*
* <p>When a custom domain is configured (e.g., "app.github.dev"), control plane calls
* are routed to "cp.{domain}" and cluster ID hostname manipulation is skipped.
*
* @param customDomain The custom domain (e.g., "app.github.dev").
* @param userAgents User agents.
* @param userTokenCallback Optional authentication callback.
* @param apiVersion API version.
* @return A configured TunnelManagementClient instance.
*/
public static TunnelManagementClient forCustomDomain(
String customDomain,
ProductHeaderValue[] userAgents,
Supplier<CompletableFuture<String>> userTokenCallback,
String apiVersion) {
if (StringUtils.isBlank(customDomain)) {
throw new IllegalArgumentException("Custom domain cannot be blank");
}
String serviceUri = "https://cp." + customDomain;
return new TunnelManagementClient(userAgents, userTokenCallback, serviceUri, apiVersion);
}

private <T, U> CompletableFuture<U> requestAsync(
Expand Down Expand Up @@ -301,7 +331,7 @@ private URI buildUri(String clusterId,
int port = baseAddress.getPort();

// tunnels.local.api.visualstudio.com resolves to localhost (for local development).
if (StringUtils.isNotBlank(clusterId)) {
if (StringUtils.isNotBlank(clusterId) && !this.isCustomDomain) {
if (!baseAddress.getHost().equals("localhost")
&& !baseAddress.getHost().equals("tunnels.local.api.visualstudio.com")
&& !baseAddress.getHost().startsWith(clusterId + ".")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import com.microsoft.tunnels.contracts.TunnelPort;
import com.microsoft.tunnels.contracts.TunnelProtocol;
import com.microsoft.tunnels.management.HttpResponseException;
import com.microsoft.tunnels.management.ProductHeaderValue;
import com.microsoft.tunnels.management.TunnelManagementClient;
import com.microsoft.tunnels.management.TunnelRequestOptions;

import java.util.Arrays;
Expand All @@ -27,6 +29,25 @@
*/
public class TunnelManagementClientTests extends TunnelTest {

@Test
public void forCustomDomainCreatesClient() {
var client = TunnelManagementClient.forCustomDomain(
"app.github.dev",
new ProductHeaderValue[] { userAgent },
null,
"2023-09-27-preview");
assertNotNull(client);
}

@Test(expected = IllegalArgumentException.class)
public void forCustomDomainRejectsBlank() {
TunnelManagementClient.forCustomDomain(
"",
new ProductHeaderValue[] { userAgent },
null,
"2023-09-27-preview");
}

@Test
public void createTunnel() {
// Set up tunnel access control.
Expand Down
Loading
Loading