diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index 3e1e9762..47ce73e5 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -89,6 +89,7 @@ public class TunnelManagementClient : ITunnelManagementClient private readonly HttpClient httpClient; private readonly Func> userTokenCallback; + private readonly bool isCustomDomain; private class EventInfo { @@ -223,6 +224,8 @@ public TunnelManagementClient( $"Invalid tunnel service URI: {tunnelServiceUri}", nameof(tunnelServiceUri)); } + this.isCustomDomain = tunnelServiceUri.Host.StartsWith("cp."); + // 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 @@ -235,6 +238,33 @@ public TunnelManagementClient( }; } + /// + /// Creates a 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 + /// because routing is handled at the infrastructure level. + /// + /// The custom domain (e.g., "app.github.dev"). + /// User agents. + /// Optional authentication callback. + /// Optional HTTP handler. + /// API version. + /// A configured instance. + public static TunnelManagementClient ForCustomDomain( + string customDomain, + ProductInfoHeaderValue[] userAgents, + Func>? 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); + } + /// /// Gets or sets a value indicating whether events reporting is enabled. /// @@ -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); } diff --git a/cs/test/TunnelsSDK.Test/TunnelManagementClientTests.cs b/cs/test/TunnelsSDK.Test/TunnelManagementClientTests.cs index 0bcf1f8e..7e361f7b 100644 --- a/cs/test/TunnelsSDK.Test/TunnelManagementClientTests.cs +++ b/cs/test/TunnelsSDK.Test/TunnelManagementClientTests.cs @@ -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 diff --git a/go/tunnels/manager.go b/go/tunnels/manager.go index 2e776aa6..3ea47475 100644 --- a/go/tunnels/manager.go +++ b/go/tunnels/manager.go @@ -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. @@ -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. @@ -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" && diff --git a/go/tunnels/manager_test.go b/go/tunnels/manager_test.go index 3d0fc94c..eb9dc40e 100644 --- a/go/tunnels/manager_test.go +++ b/go/tunnels/manager_test.go @@ -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()) + } +} diff --git a/go/tunnels/tunnels.go b/go/tunnels/tunnels.go index fa7b93f0..fc6953f2 100644 --- a/go/tunnels/tunnels.go +++ b/go/tunnels/tunnels.go @@ -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{ diff --git a/java/src/main/java/com/microsoft/tunnels/management/TunnelManagementClient.java b/java/src/main/java/com/microsoft/tunnels/management/TunnelManagementClient.java index 8ab1e43e..9c8c7f9d 100644 --- a/java/src/main/java/com/microsoft/tunnels/management/TunnelManagementClient.java +++ b/java/src/main/java/com/microsoft/tunnels/management/TunnelManagementClient.java @@ -87,6 +87,7 @@ public class TunnelManagementClient implements ITunnelManagementClient { private final Supplier> userTokenCallback; private final String baseAddress; private final String apiVersion; + private final boolean isCustomDomain; public static final String[] ApiVersions = { "2023-09-27-preview" @@ -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. + * + *

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> 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 CompletableFuture requestAsync( @@ -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 + ".")) { diff --git a/java/src/test/java/com/microsoft/tunnels/TunnelManagementClientTests.java b/java/src/test/java/com/microsoft/tunnels/TunnelManagementClientTests.java index 4c4742bb..0a51ecf5 100644 --- a/java/src/test/java/com/microsoft/tunnels/TunnelManagementClientTests.java +++ b/java/src/test/java/com/microsoft/tunnels/TunnelManagementClientTests.java @@ -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; @@ -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. diff --git a/rs/src/management/http_client.rs b/rs/src/management/http_client.rs index 20986bf7..3053729d 100644 --- a/rs/src/management/http_client.rs +++ b/rs/src/management/http_client.rs @@ -32,6 +32,7 @@ pub struct TunnelManagementClient { pub(crate) user_agent: HeaderValue, environment: TunnelServiceProperties, api_version: String, + is_custom_domain: bool, } const TUNNELS_API_PATH: &str = "/tunnels"; @@ -52,6 +53,7 @@ impl TunnelManagementClient { user_agent: self.user_agent.clone(), environment: self.environment.clone(), api_version: self.api_version.clone(), + is_custom_domain: self.is_custom_domain, } } @@ -411,7 +413,7 @@ impl TunnelManagementClient { if let Some(cluster_id) = cluster_id { let hostname = uri.host_str().unwrap_or(""); - if !hostname.starts_with(&format!("{}.", cluster_id)) { + if !self.is_custom_domain && !hostname.starts_with(&format!("{}.", cluster_id)) { let new_hostname = format!("{}.{}", cluster_id, hostname).replace("global.", ""); uri.set_host(Some(&new_hostname)).unwrap(); } @@ -542,6 +544,7 @@ pub struct TunnelClientBuilder { user_agent: HeaderValue, environment: TunnelServiceProperties, api_version: String, + is_custom_domain: bool, } /// Creates a new tunnel client builder. You can set options, then use `into()` @@ -557,9 +560,28 @@ pub fn new_tunnel_management(user_agent: &str) -> TunnelClientBuilder { user_agent: HeaderValue::from_str(&full_user_agent).unwrap(), environment: env_production(), api_version: API_VERSIONS[0].to_owned(), + is_custom_domain: false, } } +/// Creates a new tunnel client builder 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. +pub fn new_tunnel_management_for_custom_domain( + user_agent: &str, + custom_domain: &str, +) -> TunnelClientBuilder { + let mut builder = new_tunnel_management(user_agent); + builder.environment = TunnelServiceProperties { + service_uri: format!("https://cp.{}", custom_domain), + service_app_id: String::new(), + service_internal_app_id: String::new(), + github_app_client_id: String::new(), + }; + builder.is_custom_domain = true; + builder +} + fn create_full_user_agent(user_agent: &str) -> String { let pkg_version = PKG_VERSION.unwrap_or("unknown"); let os = os_info::get(); @@ -607,6 +629,10 @@ impl TunnelClientBuilder { } pub fn environment(&mut self, environment: TunnelServiceProperties) -> &mut Self { + self.is_custom_domain = Url::parse(&environment.service_uri) + .ok() + .and_then(|u| u.host_str().map(|h| h.starts_with("cp."))) + .unwrap_or(false); self.environment = environment; self } @@ -620,6 +646,7 @@ impl From for TunnelManagementClient { user_agent: builder.user_agent, environment: builder.environment, api_version: builder.api_version, + is_custom_domain: builder.is_custom_domain, } } } @@ -821,4 +848,27 @@ mod tests { assert!(url.query().unwrap().contains("includePorts=true")); } + + #[test] + fn custom_domain_does_not_modify_hostname() { + let mut builder = super::new_tunnel_management_for_custom_domain( + "rs-sdk-tests", + "app.github.dev", + ); + let client: super::TunnelManagementClient = builder.into(); + let url = client.build_uri(Some("usw2"), "/tunnels/tnnl0001"); + assert_eq!(url.host_str().unwrap(), "cp.app.github.dev"); + } + + #[test] + fn standard_service_uri_replaces_cluster_id() { + let builder = super::new_tunnel_management("rs-sdk-tests"); + let client: super::TunnelManagementClient = builder.into(); + let url = client.build_uri(Some("usw2"), "/tunnels/tnnl0001"); + assert!( + url.host_str().unwrap().starts_with("usw2."), + "Expected hostname to start with usw2., got {}", + url.host_str().unwrap() + ); + } } diff --git a/ts/src/connections/package.json b/ts/src/connections/package.json index 58e713a3..fc202c1d 100644 --- a/ts/src/connections/package.json +++ b/ts/src/connections/package.json @@ -18,8 +18,8 @@ "buffer": "^5.2.1", "debug": "^4.1.1", "vscode-jsonrpc": "^4.0.0", - "@microsoft/dev-tunnels-contracts": "^1.3.36", - "@microsoft/dev-tunnels-management": "^1.3.36", + "@microsoft/dev-tunnels-contracts": "^1.3.38", + "@microsoft/dev-tunnels-management": "^1.3.38", "uuid": "^3.3.3", "await-semaphore": "^0.1.3", "websocket": "^1.0.28", diff --git a/ts/src/management/package.json b/ts/src/management/package.json index ca50a529..35490905 100644 --- a/ts/src/management/package.json +++ b/ts/src/management/package.json @@ -18,7 +18,7 @@ "buffer": "^5.2.1", "debug": "^4.1.1", "vscode-jsonrpc": "^4.0.0", - "@microsoft/dev-tunnels-contracts": "^1.3.36", + "@microsoft/dev-tunnels-contracts": "^1.3.38", "axios": "^1.8.4" } } diff --git a/ts/src/management/tunnelManagementHttpClient.ts b/ts/src/management/tunnelManagementHttpClient.ts index 94d8c150..506b57d8 100644 --- a/ts/src/management/tunnelManagementHttpClient.ts +++ b/ts/src/management/tunnelManagementHttpClient.ts @@ -129,6 +129,7 @@ export class TunnelManagementHttpClient implements TunnelManagementClient { private readonly baseAddress: string; private readonly userTokenCallback: () => Promise; private readonly userAgents: string; + private readonly isCustomDomain: boolean; private readonly reportProgressEmitter = new Emitter(); @@ -243,6 +244,37 @@ export class TunnelManagementHttpClient implements TunnelManagementClient { } this.baseAddress = tunnelServiceUri; + this.isCustomDomain = new URL(tunnelServiceUri).hostname.startsWith('cp.'); + } + + /** + * Creates a `TunnelManagementHttpClient` 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 + * because routing is handled at the infrastructure level. + * + * @param customDomain The custom domain (e.g., "app.github.dev"). + * @param userAgents User agent(s). + * @param apiVersion API version. + * @param userTokenCallback Optional authentication callback. + * @param httpsAgent Optional HTTPS agent. + * @param adapter Optional axios adapter. + */ + public static forCustomDomain( + customDomain: string, + userAgents: (ProductHeaderValue | string)[] | ProductHeaderValue | string, + apiVersion: ManagementApiVersions, + userTokenCallback?: () => Promise, + httpsAgent?: https.Agent, + adapter?: AxiosAdapter, + ): TunnelManagementHttpClient { + if (!customDomain) { + throw new TypeError('Custom domain must be a non-empty string.'); + } + const serviceUri = `https://cp.${customDomain}/`; + return new TunnelManagementHttpClient( + userAgents, apiVersion, userTokenCallback, serviceUri, httpsAgent, adapter); } public async listTunnels( @@ -959,7 +991,7 @@ export class TunnelManagementHttpClient implements TunnelManagementClient { } } let baseAddress = this.baseAddress; - if (clusterId) { + if (clusterId && !this.isCustomDomain) { const url = new URL(baseAddress); const portNumber = parseInt(url.port, 10); diff --git a/ts/test/tunnels-test/tunnelManagementTests.ts b/ts/test/tunnels-test/tunnelManagementTests.ts index bc23fcb5..ec877066 100644 --- a/ts/test/tunnels-test/tunnelManagementTests.ts +++ b/ts/test/tunnels-test/tunnelManagementTests.ts @@ -346,4 +346,61 @@ export class TunnelManagementTests { assert.match(error.message, /firewall/); assert.match(error.message, new RegExp(new URL(TunnelManagementTests.testServiceUri).host)); } + + @test + public async customDomainDoesNotModifyHostname() { + const client = TunnelManagementHttpClient.forCustomDomain( + 'app.github.dev', + 'test/0.0.0', + ManagementApiVersions.Version20230927preview, + ); + + let capturedUri: string | undefined; + (client).axiosRequest = async (config: AxiosRequestConfig) => { + capturedUri = config.url; + return { + data: { tunnelId: 'tnnl0001', clusterId: 'usw2' }, + status: 200, + statusText: 'OK', + headers: {}, + config, + } as AxiosResponse; + }; + + const tunnel: Tunnel = { tunnelId: 'tnnl0001', clusterId: 'usw2' }; + await client.getTunnel(tunnel); + + assert.ok(capturedUri); + const url = new URL(capturedUri!); + assert.strictEqual(url.hostname, 'cp.app.github.dev'); + } + + @test + public async standardServiceUriReplacesClusterIdInHostname() { + const client = new TunnelManagementHttpClient( + 'test/0.0.0', + ManagementApiVersions.Version20230927preview, + undefined, + TunnelManagementTests.testServiceUri, + ); + + let capturedUri: string | undefined; + (client).axiosRequest = async (config: AxiosRequestConfig) => { + capturedUri = config.url; + return { + data: { tunnelId: 'tnnl0001', clusterId: 'usw2' }, + status: 200, + statusText: 'OK', + headers: {}, + config, + } as AxiosResponse; + }; + + const tunnel: Tunnel = { tunnelId: 'tnnl0001', clusterId: 'usw2' }; + await client.getTunnel(tunnel); + + assert.ok(capturedUri); + const url = new URL(capturedUri!); + assert.ok(url.hostname.startsWith('usw2.'), `Expected hostname to start with usw2., got ${url.hostname}`); + } }