diff --git a/CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs b/CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs index db40cef..b972506 100644 --- a/CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs @@ -9,6 +9,7 @@ namespace CosmosShell.Tests.CommandTests; using Azure.Data.Cosmos.Shell.Lsp.Semantics; using Azure.Data.Cosmos.Shell.Parser; using Microsoft.Azure.Cosmos; +using System.Net.Http; public class ConnectCommandTests { @@ -68,6 +69,74 @@ public void ConnectCommand_VSCodeCredentialOption_DoesNotProduceUnknownOptionDia Assert.DoesNotContain(model.Diagnostics, diagnostic => diagnostic.Code == "SEM002"); } + [Fact] + public async Task ConnectCommand_EmulatorOption_BindsFlag() + { + var command = await BindConnectCommandAsync("connect --emulator"); + + Assert.Null(command.ConnectionString); + Assert.True(command.Emulator); + } + + [Fact] + public async Task ConnectCommand_EmulatorShortOption_BindsFlag() + { + var command = await BindConnectCommandAsync("connect -e"); + + Assert.True(command.Emulator); + } + + [Fact] + public async Task ConnectCommand_EmulatorWithExplicitEndpoint_BindsBoth() + { + var command = await BindConnectCommandAsync("connect --emulator https://localhost:9000/"); + + Assert.Equal("https://localhost:9000/", command.ConnectionString); + Assert.True(command.Emulator); + } + + [Fact] + public async Task ConnectAsync_EmulatorAgainstNonLocalEndpoint_Throws() + { + using var shell = ShellInterpreter.CreateInstance(); + + var ex = await Assert.ThrowsAsync(() => shell.ConnectAsync( + "https://contoso.documents.azure.com:443/", + forceEmulator: true, + token: CancellationToken.None)); + Assert.Contains("emulator", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void IsTlsHandshakeFailure_DetectsAuthenticationException() + { + var ex = new HttpRequestException("send failed", new System.Security.Authentication.AuthenticationException("inner")); + Assert.True(ShellInterpreter.IsTlsHandshakeFailure(ex)); + } + + [Fact] + public void IsTlsHandshakeFailure_DetectsResetSocket() + { + var ex = new HttpRequestException("send failed", new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.ConnectionReset)); + Assert.True(ShellInterpreter.IsTlsHandshakeFailure(ex)); + } + + [Fact] + public void IsTlsHandshakeFailure_IgnoresGenericHttpRequestException() + { + // No type-based marker => not a TLS handshake failure (was previously matched by the + // brittle "SSL" message substring check). + var ex = new HttpRequestException("Connection refused (SSL inside the message text only)"); + Assert.False(ShellInterpreter.IsTlsHandshakeFailure(ex)); + } + + [Fact] + public void IsTlsHandshakeFailure_IgnoresUnrelatedSocketErrors() + { + var ex = new HttpRequestException("send failed", new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.HostUnreachable)); + Assert.False(ShellInterpreter.IsTlsHandshakeFailure(ex)); + } + private static async Task BindConnectCommandAsync(string commandText) { var parser = new StatementParser(commandText); diff --git a/CosmosDBShell.Tests/UtilTest/ParseDocDBConnectionTests.cs b/CosmosDBShell.Tests/UtilTest/ParseDocDBConnectionTests.cs index 6b47104..4b4395a 100644 --- a/CosmosDBShell.Tests/UtilTest/ParseDocDBConnectionTests.cs +++ b/CosmosDBShell.Tests/UtilTest/ParseDocDBConnectionTests.cs @@ -233,6 +233,28 @@ public void TestIsLocalEmulatorEndpoint_ConnectionStringWithoutKey() Assert.True(ParsedDocDBConnectionString.IsLocalEmulatorEndpoint("AccountEndpoint=https://localhost:8081/;")); } + [Theory] + [InlineData("https://notlocalhost.com/")] + [InlineData("https://localhost.contoso.com/")] + [InlineData("https://contoso.localhost.com/")] + [InlineData("https://10.127.0.0.1.example.com/")] + [InlineData("AccountEndpoint=https://notlocalhost.com/;")] + [InlineData("AccountEndpoint=https://contoso.documents.azure.com:443/;AccountKey=k;")] + public void TestIsLocalEmulatorEndpoint_NonLocalHostsAreRejected(string input) + { + Assert.False(ParsedDocDBConnectionString.IsLocalEmulatorEndpoint(input)); + } + + [Theory] + [InlineData("https://localhost:8081/")] + [InlineData("https://LOCALHOST:8081/")] + [InlineData("https://127.0.0.1:8081/")] + [InlineData("http://[::1]:8081/")] + public void TestIsLocalEmulatorEndpoint_LoopbackHostsAreAccepted(string input) + { + Assert.True(ParsedDocDBConnectionString.IsLocalEmulatorEndpoint(input)); + } + [Fact] public void TestPlainLocalhostUrlNotParsedAsConnectionString() { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs index 692eb62..8faa3ba 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs @@ -15,6 +15,7 @@ namespace Azure.Data.Cosmos.Shell.Commands; [CosmosCommand("connect")] [CosmosExample("connect", Description = "Show current connection information and mode")] [CosmosExample("connect \"AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=mykey;\"", Description = "Connect using connection string with account key")] +[CosmosExample("connect --emulator", Description = "Connect to the local Cosmos DB Emulator on https://localhost:8081 (falls back to HTTP if the TLS handshake fails)")] [CosmosExample("connect https://localhost:8081", Description = "Connect to the local Cosmos DB Emulator (uses well-known key and gateway mode)")] [CosmosExample("connect https://myaccount.documents.azure.com:443/ -hint=user@contoso.com", Description = "Connect using Entra ID authentication with login hint")] [CosmosExample("connect https://myaccount.documents.azure.com:443/ -tenant= -mode=gateway", Description = "Connect using Entra ID with gateway connection mode")] @@ -46,10 +47,13 @@ internal partial class ConnectCommand : CosmosCommand [CosmosOption("vscode-credential", "connect-vscode-credential", Hidden = true)] public bool UseVSCodeCredential { get; init; } + [CosmosOption("emulator", "e")] + public bool Emulator { get; init; } + public async override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) { - // If no connection string provided, show current connection info - if (this.ConnectionString is null) + // If no connection string and not using --emulator, show current connection info + if (this.ConnectionString is null && !this.Emulator) { return await PrintConnectionInfoAsync(shell, commandState, token); } @@ -85,12 +89,14 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co try { - await shell.ConnectAsync(this.ConnectionString, this.LoginHint, connectionMode, tenantId: this.TenantId, authorityHost: this.AuthorityHost, managedIdentityClientId: this.ManagedIdentityClientId, useVSCodeCredential: this.UseVSCodeCredential, token: token); + await shell.ConnectAsync(this.ConnectionString, this.LoginHint, connectionMode, tenantId: this.TenantId, authorityHost: this.AuthorityHost, managedIdentityClientId: this.ManagedIdentityClientId, useVSCodeCredential: this.UseVSCodeCredential, forceEmulator: this.Emulator, token: token); var returnState = new CommandState { IsPrinted = true, }; - var endpoint = ParsedDocDBConnectionString.ExtractEndpoint(this.ConnectionString); + var endpoint = this.ConnectionString is null + ? null + : ParsedDocDBConnectionString.ExtractEndpoint(this.ConnectionString); var resultElement = JsonSerializer.SerializeToElement(new Dictionary { ["connected state"] = endpoint, diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 7460313..3c5e067 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -567,7 +567,7 @@ internal async Task RunCommandAsync(CommandState currentState, str return currentState; } - internal async Task ConnectAsync(string connectionString, string? loginHint = null, ConnectionMode? mode = null, string? tenantId = null, string? authorityHost = null, string? managedIdentityClientId = null, bool useVSCodeCredential = false, CancellationToken token = default) + internal async Task ConnectAsync(string? connectionString, string? loginHint = null, ConnectionMode? mode = null, string? tenantId = null, string? authorityHost = null, string? managedIdentityClientId = null, bool useVSCodeCredential = false, bool forceEmulator = false, CancellationToken token = default) { token.ThrowIfCancellationRequested(); @@ -583,8 +583,25 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu CosmosClient? client = null; // Step 1: Resolve account key (from connection string, env variable, or emulator well-known key) - bool isEmulator = ParsedDocDBConnectionString.IsLocalEmulatorEndpoint(connectionString); - if (isEmulator) + if (forceEmulator) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + connectionString = "https://localhost:8081/"; + } + else if (!ParsedDocDBConnectionString.IsLocalEmulatorEndpoint(connectionString)) + { + throw new ShellException(MessageService.GetString("command-connect-emulator-non_local")); + } + } + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ShellException(MessageService.GetString("command-connect-error-no_endpoint")); + } + + bool isEmulator = forceEmulator || ParsedDocDBConnectionString.IsLocalEmulatorEndpoint(connectionString); + if (isEmulator && !forceEmulator) { WriteLine(MessageService.GetString("command-connect-emulator-detected")); } @@ -625,26 +642,21 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu { WriteLine(MessageService.GetString("shell-connect-key-auth")); var keyMode = mode ?? (isEmulator ? ConnectionMode.Gateway : ConnectionMode.Direct); - var keyOptions = CreateClientOptions(connectionString, keyMode); - client = new CosmosClient(connectionString, keyOptions); - AccountProperties keyProps; - try - { - keyProps = await ReadAccountAsync(client, token); - } - catch (OperationCanceledException) when (token.IsCancellationRequested) - { - client.Dispose(); - throw; - } - catch (Exception ex) + (CosmosClient connectedClient, AccountProperties keyProps, string finalEndpoint) = await ConnectWithAccountKeyAsync( + connectionString, + keyMode, + isEmulator, + token); + client = connectedClient; + + WriteLine(MessageService.GetArgsString("command-connect-connected", "account", keyProps.Id)); + + if (isEmulator) { - client.Dispose(); - throw new ShellException(MessageService.GetString("error-connection_failed"), ex); + ReportEmulatorProtocol(finalEndpoint); } - WriteLine(MessageService.GetArgsString("command-connect-connected", "account", keyProps.Id)); this.Connect(client); return; } @@ -903,6 +915,118 @@ private static async Task ReadAccountAsync(CosmosClient clien return await client.ReadAccountAsync().WaitAsync(token); } + /// + /// Connects with an account key, with an emulator-only HTTPS to HTTP fallback when the + /// underlying TLS handshake fails. Returns the live client, the account properties, and + /// the endpoint that was actually used. + /// + private static async Task<(CosmosClient Client, AccountProperties Properties, string Endpoint)> ConnectWithAccountKeyAsync( + string connectionString, + ConnectionMode keyMode, + bool isEmulator, + CancellationToken token) + { + var endpoint = ParsedDocDBConnectionString.ExtractEndpoint(connectionString); + var keyOptions = CreateClientOptions(connectionString, keyMode); + var client = new CosmosClient(connectionString, keyOptions); + + try + { + var properties = await ReadAccountAsync(client, token); + return (client, properties, endpoint); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + client.Dispose(); + throw; + } + catch (Exception ex) + { + client.Dispose(); + + if (isEmulator && IsTlsHandshakeFailure(ex) && + Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) && + endpointUri.Scheme == Uri.UriSchemeHttps) + { + var httpEndpoint = new UriBuilder(endpointUri) { Scheme = Uri.UriSchemeHttp, Port = endpointUri.Port }.Uri.ToString(); + WriteLine(MessageService.GetString("command-connect-emulator-https_failed")); + + var fallbackConnectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString( + httpEndpoint, + ParsedDocDBConnectionString.TryParseDocDBConnectionString(connectionString, out var parsed) ? parsed?.MasterKey : null); + var fallbackOptions = CreateClientOptions(fallbackConnectionString, keyMode); + var fallbackClient = new CosmosClient(fallbackConnectionString, fallbackOptions); + try + { + var fallbackProperties = await ReadAccountAsync(fallbackClient, token); + return (fallbackClient, fallbackProperties, httpEndpoint); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + fallbackClient.Dispose(); + throw; + } + catch (Exception fallbackEx) + { + fallbackClient.Dispose(); + var aggregated = new AggregateException( + MessageService.GetString("command-connect-emulator-fallback-failed"), + ex, + fallbackEx); + throw new ShellException(MessageService.GetString("error-connection_failed"), aggregated); + } + } + + throw new ShellException(MessageService.GetString("error-connection_failed"), ex); + } + } + + /// + /// Detects TLS handshake / certificate-validation failures in the exception chain. Used to + /// decide whether an emulator HTTPS attempt should fall back to HTTP. Uses type-based checks + /// so the decision is not affected by localized exception messages. + /// + internal static bool IsTlsHandshakeFailure(Exception ex) + { + for (var current = ex; current != null; current = current.InnerException) + { + switch (current) + { + case System.Security.Authentication.AuthenticationException: + case System.Security.Cryptography.CryptographicException: + return true; + case System.Net.Sockets.SocketException socketEx + when socketEx.SocketErrorCode is + System.Net.Sockets.SocketError.ConnectionReset or + System.Net.Sockets.SocketError.ConnectionAborted: + // The TLS layer typically surfaces handshake failures from a non-TLS server + // as a reset/aborted socket while the request is still in the TLS handshake + // phase, before any HTTP exchange has happened. + return true; + } + } + + return false; + } + + private static void ReportEmulatorProtocol(string endpoint) + { + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + { + return; + } + + if (uri.Scheme == Uri.UriSchemeHttps) + { + WriteLine(MessageService.GetArgsString("command-connect-emulator-using_https", "endpoint", endpoint)); + } + else + { + WriteLine(MessageService.GetArgsString("command-connect-emulator-using_http", "endpoint", endpoint)); + WriteLine(MessageService.GetString("command-connect-emulator-http_tip")); + } + } + /// /// Connects to a client & disposes old state. /// diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/LocalizableSentenceBuilder.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/LocalizableSentenceBuilder.cs index cdccd53..080404e 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/LocalizableSentenceBuilder.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/LocalizableSentenceBuilder.cs @@ -33,6 +33,8 @@ public class LocalizableSentenceBuilder : SentenceBuilder public static string ConnectVSCodeCredential => MessageService.GetString("help-ConnectVSCodeCredential"); + public static string ConnectEmulator => MessageService.GetString("help-ConnectEmulator"); + public static string Command => MessageService.GetString("help-cmd"); public static string EnableMcpServer => MessageService.GetString("help-EnableMcpServer"); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/ParsedDocDBConnectionString.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/ParsedDocDBConnectionString.cs index 21ef152..3165aef 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/ParsedDocDBConnectionString.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/ParsedDocDBConnectionString.cs @@ -64,8 +64,33 @@ public static bool IsLocalEmulatorEndpoint(string? connectionStringOrEndpoint) return false; } - return connectionStringOrEndpoint.Contains("localhost", StringComparison.OrdinalIgnoreCase) - || connectionStringOrEndpoint.Contains("127.0.0.1", StringComparison.OrdinalIgnoreCase); + // Resolve to a clean endpoint URL whether the input is a full connection string + // ("AccountEndpoint=...;...") or a plain URL. + string? endpoint; + if (TryParseDocDBConnectionString(connectionStringOrEndpoint, out var parsed)) + { + endpoint = parsed!.Endpoint; + } + else if (IsPlainUrl(connectionStringOrEndpoint)) + { + endpoint = connectionStringOrEndpoint; + } + else + { + return false; + } + + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + { + return false; + } + + if (string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return System.Net.IPAddress.TryParse(uri.Host, out var ip) && System.Net.IPAddress.IsLoopback(ip); } /// diff --git a/CosmosDBShell/Program.cs b/CosmosDBShell/Program.cs index e2d2132..247a087 100644 --- a/CosmosDBShell/Program.cs +++ b/CosmosDBShell/Program.cs @@ -113,7 +113,7 @@ public static async Task Main(string[] args) }; ShellInterpreter.Instance.Options = o; - if (o.ConnectionString != null) + if (o.ConnectionString != null || o.ConnectEmulator) { using var connectTokenSource = ShellInterpreter.UserCancellationTokenSource; var connectToken = connectTokenSource.Token; @@ -127,6 +127,7 @@ await ShellInterpreter.Instance.ConnectAsync( authorityHost: o.ConnectAuthorityHost, managedIdentityClientId: o.ConnectManagedIdentity, useVSCodeCredential: o.ConnectVSCodeCredential, + forceEmulator: o.ConnectEmulator, token: connectToken); } catch (OperationCanceledException) when (connectToken.IsCancellationRequested) @@ -352,6 +353,9 @@ public class CosmosShellOptions [Option("connect-vscode-credential", Required = false, HelpText = "ConnectVSCodeCredential", ResourceType = typeof(LocalizableSentenceBuilder), Hidden = true)] public bool ConnectVSCodeCredential { get; set; } + [Option("connect-emulator", Required = false, HelpText = "ConnectEmulator", ResourceType = typeof(LocalizableSentenceBuilder))] + public bool ConnectEmulator { get; set; } + [Option("mcp", Required = false, HelpText = "McpPort", ResourceType = typeof(LocalizableSentenceBuilder))] public int? McpPort { get; set; } diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 50c60b8..47a662b 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -319,9 +319,16 @@ command-connect-description-mode = Connection mode: 'direct' (default) or 'gatew command-connect-description-tenant = The Entra ID tenant ID to authenticate against. command-connect-description-authority-host = The authority host URL (The default is https://login.microsoftonline.com/). command-connect-description-managed-identity = The client ID of a user-assigned managed identity to authenticate with. +command-connect-description-emulator = Connect to the local Cosmos DB Emulator using its well-known account key. Defaults the endpoint to https://localhost:8081/ and falls back to HTTP if the TLS handshake fails. command-connect-error-no_endpoint = An account endpoint or connection string must be specified. command-connect-connected = Connected to account '{ $account }' command-connect-emulator-detected = Emulator endpoint detected, using well-known account key and gateway mode. +command-connect-emulator-non_local = --emulator can only be used with a local endpoint (localhost or 127.0.0.1). +command-connect-emulator-https_failed = HTTPS handshake failed for emulator endpoint, retrying over HTTP... +command-connect-emulator-fallback-failed = Emulator HTTPS connect failed and the HTTP fallback also failed. See inner exceptions for the original TLS error and the fallback error. +command-connect-emulator-using_https = Connected to emulator over HTTPS at { $endpoint }. +command-connect-emulator-using_http = Connected to emulator over HTTP at { $endpoint }. +command-connect-emulator-http_tip = Tip: the Cosmos DB emulator Docker image accepts --protocol [https|http]; passing --protocol http skips TLS certificate setup. command-connect-switching = Disconnecting from '{ $endpoint }'... command-connect-not_connected = Not connected to any Cosmos DB account. command-connect-info-title = Connection Information @@ -474,6 +481,7 @@ help-ConnectHint = Login hint for browser authentication at startup. help-ConnectAuthorityHost = The authority host URL at startup (default: https://login.microsoftonline.com/). help-ConnectManagedIdentity = The client ID of a user-assigned managed identity at startup. help-ConnectVSCodeCredential = Use Visual Studio Code credential for authentication at startup. +help-ConnectEmulator = Connect to the local Cosmos DB Emulator at startup using its well-known key. help-EnableMcpServer = Enable MCP server for programmatic control of the shell help-EnableLspServer = Enable Language Server Protocol (LSP) server for editor integration help-McpPort = Enable MCP HTTP server. Optionally specify a port with --mcp ; default is 6128. diff --git a/README.md b/README.md index 2aeccfe..0357fbc 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Packaging runs produce preview versions in the form `1.0.-preview.` | `--connect-hint ` | Login hint for interactive login | | `--connect-authority-host ` | Authority host (e.g. sovereign clouds) | | `--connect-managed-identity ` | Use a user-assigned managed identity | +| `--connect-emulator` | Connect to the local Cosmos DB Emulator (HTTPS, with HTTP fallback) | | `--mcp [port]` | Enable MCP server on the given port, or `6128` by default | | `--verbose` | Print full exception details | | `--cs ` | Colors: 0=off, 1=standard, 2=truecolor | diff --git a/docs/connect.md b/docs/connect.md index b30d594..ab8620a 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -36,10 +36,19 @@ connect https://myaccount.documents.azure.com:443/ ### Emulator ```bash -# Plain URL — automatically uses well-known emulator key + gateway mode +# Shortcut: assumes https://localhost:8081/, well-known key, gateway mode, +# and falls back to HTTP if the TLS handshake fails. +connect --emulator + +# Equivalent at startup +cosmosdbshell --connect-emulator + +# Or pass an explicit emulator endpoint connect https://localhost:8081 ``` +After a successful emulator connection the shell prints the protocol that was actually used (HTTPS or HTTP). When HTTP is used the shell points to the Docker emulator's `--protocol [https|http]` flag, which lets you skip TLS certificate setup. + ### Managed Identity (User-Assigned) ```bash