Skip to content

Commit aca7f1f

Browse files
authored
Merge pull request #3 from Sire/claude/review-project-practices-011CULPt6CJZr5NwAcwscQoM
Upgrades and new features
2 parents 94562bc + 4d89eeb commit aca7f1f

File tree

6 files changed

+244
-40
lines changed

6 files changed

+244
-40
lines changed

.github/workflows/create-release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
- name: Set up .NET
2424
uses: actions/setup-dotnet@v4
2525
with:
26-
dotnet-version: '6.0.x'
26+
dotnet-version: '8.0.x'
2727

2828
- name: Restore dependencies
2929
run: dotnet restore

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,4 +361,10 @@ MigrationBackup/
361361

362362
# Fody - auto-generated XML schema
363363
FodyWeavers.xsd
364+
365+
366+
# Custom
364367
/Properties/launchSettings.json
368+
369+
# Private todo list
370+
TODO.md

Commands/PingCommand.cs

Lines changed: 177 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
using System;
2-
using System.Data.SqlClient;
2+
using Microsoft.Data.SqlClient;
33
using System.Reflection;
44
using System.Text;
5-
using System.Threading;
5+
using System.Text.RegularExpressions;
66
using System.Threading.Tasks;
77
using Microsoft.Extensions.Logging;
88
using Spectre.Console;
@@ -19,62 +19,96 @@ public class PingCommand : AsyncCommand<ConsoleSettings>
1919
public override async Task<int> ExecuteAsync(CommandContext context, ConsoleSettings settings)
2020
{
2121
if (settings.SQLCommand == null)
22-
settings.SQLCommand = $@"SELECT @@SERVERNAME AS ""Server"", name as ""Database"", state_desc AS ""State"", replica_id AS ""Replica"" FROM sys.databases WHERE name = '{settings.Database}' ";
22+
settings.SQLCommand = @"SELECT @@SERVERNAME AS ""Server"", name as ""Database"", state_desc AS ""State"", replica_id AS ""Replica"" FROM sys.databases WHERE name = @DatabaseName";
2323

24-
//Logger.LogInformation("Connection string: {Mandatory}", connectionString);
25-
//Logger.LogInformation("SQL Command: {Optional}", settings.SQLCommand);
26-
//Logger.LogInformation("CommandOptionFlag: {CommandOptionFlag}", settings.CommandOptionFlag);
27-
//Logger.LogInformation("CommandOptionValue: {CommandOptionValue}", settings.CommandOptionValue);
24+
// Apply secure credential handling
25+
ApplySecureCredentials(settings);
2826

2927
var connString = GetConnectionString(settings);
3028

31-
//Logger.LogInformation("");
32-
AnsiConsole.MarkupLine($"ConnectionString: [teal]{connString.EscapeMarkup()}[/]");
29+
AnsiConsole.MarkupLine($"ConnectionString: [teal]{RedactConnectionString(connString).EscapeMarkup()}[/]");
3330
AnsiConsole.MarkupLine($"SQL Query : [teal]{settings.SQLCommand.EscapeMarkup()}[/]");
3431

32+
// Helpful warning if using IP
33+
if (LooksLikeIp(settings.Server)
34+
&& (settings.TrustServerCertificate != true)
35+
&& string.IsNullOrWhiteSpace(settings.HostNameInCertificate))
36+
{
37+
AnsiConsole.MarkupLine("[yellow]Hint:[/] You're connecting by IP. TLS certificate name validation usually fails with IPs unless the cert has the IP in SAN. Use a DNS name, set [teal]--hostname-in-certificate[/], or [teal]--trust-server-certificate true[/] for dev.");
38+
}
39+
3540
bool running = true;
3641

3742
while (running)
3843
{
39-
4044
int sec = settings.Wait;
41-
AnsiConsole.Status()
45+
await AnsiConsole.Status()
4246
.AutoRefresh(true)
43-
.Spinner(Spinner.Known.Dots) // https://jsfiddle.net/sindresorhus/2eLtsbey/embedded/result/
47+
.Spinner(Spinner.Known.Dots)
4448
.SpinnerStyle(Style.Parse("green bold"))
45-
.Start("Please wait...", ctx =>
49+
.StartAsync("Please wait...", async ctx =>
4650
{
47-
// Simulate some work
4851
ctx.Status($"Trying to connect to server [teal]{settings.Server.EscapeMarkup()}[/]...");
49-
CallDatabase(connString, settings);
52+
await CallDatabaseAsync(connString, settings);
5053

51-
// Update the status and spinner
5254
ctx.Status($"Waiting [teal]{sec}[/] seconds...");
5355

5456
if (settings.NonStop)
55-
Thread.Sleep(TimeSpan.FromSeconds(sec));
57+
await Task.Delay(TimeSpan.FromSeconds(sec));
5658
else
5759
running = false;
5860
});
5961
}
6062

61-
//Console.WriteLine("\nDone. Press enter.");
62-
//Console.ReadLine();
63-
6463
return await Task.FromResult(0);
6564
}
6665

6766
// Validate as part of the command. This is a good way of validating options if you require any injected services.
6867
public override ValidationResult Validate(CommandContext context, ConsoleSettings settings)
6968
{
70-
//if (settings.Wait < 1)
71-
// return ValidationResult.Error("...");
7269
return ValidationResult.Success();
7370
}
7471

72+
private static void ApplySecureCredentials(ConsoleSettings settings)
73+
{
74+
// Check for credentials from environment variables first
75+
var envUsername = Environment.GetEnvironmentVariable("SQLPING_USERNAME");
76+
var envPassword = Environment.GetEnvironmentVariable("SQLPING_PASSWORD");
77+
78+
bool passwordProvidedViaCommandLine = !string.IsNullOrEmpty(settings.Password);
79+
80+
// Use environment variables if command line values are not provided
81+
if (string.IsNullOrEmpty(settings.Username) && !string.IsNullOrEmpty(envUsername))
82+
{
83+
settings.Username = envUsername;
84+
AnsiConsole.MarkupLine("[yellow]Using username from SQLPING_USERNAME environment variable[/]");
85+
}
86+
87+
if (string.IsNullOrEmpty(settings.Password) && !string.IsNullOrEmpty(envPassword))
88+
{
89+
settings.Password = envPassword;
90+
AnsiConsole.MarkupLine("[yellow]Using password from SQLPING_PASSWORD environment variable[/]");
91+
}
92+
93+
// If username is provided but password is not, prompt for password
94+
if (!string.IsNullOrEmpty(settings.Username) && string.IsNullOrEmpty(settings.Password))
95+
{
96+
settings.Password = AnsiConsole.Prompt(
97+
new TextPrompt<string>("[yellow]Password:[/]")
98+
.PromptStyle("red")
99+
.Secret());
100+
}
101+
102+
// Warn if password was provided via command line (security risk)
103+
if (passwordProvidedViaCommandLine)
104+
{
105+
AnsiConsole.MarkupLine("[yellow]WARNING: Password provided via command line is visible in process list and shell history.[/]");
106+
AnsiConsole.MarkupLine("[yellow]Consider using SQLPING_PASSWORD environment variable or interactive prompt instead.[/]");
107+
}
108+
}
109+
75110
private static string GetConnectionString(ConsoleSettings settings) {
76111
SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder();
77-
//builder.ConnectionString = settings.ConnectionString;
78112
builder.DataSource = settings.Server;
79113
if (!string.IsNullOrEmpty(settings.Username)) {
80114
builder.UserID = settings.Username;
@@ -94,44 +128,155 @@ private static string GetConnectionString(ConsoleSettings settings) {
94128
builder.WorkstationID = Environment.MachineName;
95129
builder.ApplicationName = Assembly.GetExecutingAssembly().FullName;
96130

131+
// TLS related options
132+
if (settings.TrustServerCertificate.HasValue)
133+
builder.TrustServerCertificate = settings.TrustServerCertificate.Value;
134+
135+
if (!string.IsNullOrWhiteSpace(settings.Encrypt))
136+
{
137+
var encValue = settings.Encrypt.Trim().ToLowerInvariant();
138+
if (encValue is "true" or "false" or "strict")
139+
{
140+
builder["Encrypt"] = settings.Encrypt;
141+
}
142+
else
143+
{
144+
AnsiConsole.MarkupLine("[yellow]Invalid --encrypt value. Use true|false|strict. Ignoring.[/]");
145+
}
146+
}
147+
148+
if (!string.IsNullOrWhiteSpace(settings.HostNameInCertificate))
149+
{
150+
builder["HostNameInCertificate"] = settings.HostNameInCertificate;
151+
}
152+
153+
if (settings.NoTransparentNetworkIPResolution)
154+
{
155+
builder["TransparentNetworkIPResolution"] = "false";
156+
}
157+
97158
string connString = builder.ConnectionString;
98159
return connString;
99160
}
100161

101-
private void CallDatabase(string connString, ConsoleSettings settings)
162+
private static string RedactConnectionString(string connectionString)
163+
{
164+
try
165+
{
166+
SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder(connectionString);
167+
168+
// Redact password if present
169+
if (!string.IsNullOrEmpty(builder.Password))
170+
{
171+
builder.Password = "***REDACTED***";
172+
}
173+
174+
return builder.ConnectionString;
175+
}
176+
catch
177+
{
178+
// If parsing fails, return a generic redacted message
179+
return "***CONNECTION STRING REDACTED***";
180+
}
181+
}
182+
183+
private async Task CallDatabaseAsync(string connString, ConsoleSettings settings)
102184
{
103185
AnsiConsole.MarkupLine($"[teal]{DateTime.Now.ToLocalTime()}[/] Connecting to {settings.Server.EscapeMarkup()}... ");
104186

105187
try
106188
{
107-
108-
using (SqlConnection connection = new SqlConnection(connString))
189+
190+
await using (SqlConnection connection = new SqlConnection(connString))
109191
{
110-
connection.Open();
192+
await connection.OpenAsync();
193+
194+
// Ensure we are in the requested database even if Initial Catalog was not applied for any reason
195+
if (!string.IsNullOrWhiteSpace(settings.Database))
196+
{
197+
try
198+
{
199+
connection.ChangeDatabase(settings.Database);
200+
}
201+
catch (Exception dbEx)
202+
{
203+
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Failed to change database to '[teal]{settings.Database.EscapeMarkup()}[/]': {dbEx.Message.EscapeMarkup()}");
204+
}
205+
}
206+
111207
var sb = new StringBuilder();
112-
using (SqlCommand command = new SqlCommand(settings.SQLCommand, connection))
208+
await using (SqlCommand command = new SqlCommand(settings.SQLCommand!, connection))
113209
{
114-
using (SqlDataReader reader = command.ExecuteReader())
210+
// Add parameter if the query contains @DatabaseName placeholder
211+
if (settings.SQLCommand != null && settings.SQLCommand.Contains("@DatabaseName"))
212+
{
213+
command.Parameters.AddWithValue("@DatabaseName", settings.Database);
214+
}
215+
216+
await using (SqlDataReader reader = await command.ExecuteReaderAsync())
115217
{
116-
while (reader.Read())
218+
while (await reader.ReadAsync())
117219
{
118220
for (int i = 0; i < reader.FieldCount; i++)
119221
if (reader.GetValue(i) != DBNull.Value)
120222
sb.Append($"{reader.GetName(i)}: {Convert.ToString(reader.GetValue(i))} ");
121-
//sb.AppendLine();
122223
}
123224
}
124225
}
125-
AnsiConsole.MarkupLine($" [green]SUCCESS[/] {sb.ToString().EscapeMarkup()}");
226+
227+
// Try to show connection encryption info, but ignore permission errors
228+
string info;
229+
string currentDbName = connection.Database;
230+
try
231+
{
232+
await using var infoCmd = new SqlCommand(
233+
"SELECT encrypt_option, net_transport FROM sys.dm_exec_connections WHERE session_id = @@SPID;", connection);
234+
await using var infoReader = await infoCmd.ExecuteReaderAsync();
235+
if (await infoReader.ReadAsync())
236+
{
237+
var encryptOption = Convert.ToString(infoReader["encrypt_option"]);
238+
var transport = Convert.ToString(infoReader["net_transport"]);
239+
info = $" (db={currentDbName}, encrypt_option={encryptOption}, net_transport={transport})";
240+
}
241+
else
242+
{
243+
info = $" (db={currentDbName})";
244+
}
245+
}
246+
catch (SqlException)
247+
{
248+
info = $" (db={currentDbName}, connection details unavailable: requires VIEW SERVER STATE)";
249+
}
250+
251+
AnsiConsole.MarkupLine($" [green]SUCCESS[/] {sb.ToString().EscapeMarkup()}{info}");
126252
}
127253
}
128254
catch (SqlException ex)
129255
{
130256
AnsiConsole.MarkupLine($" [red]ERROR: {ex.Message.EscapeMarkup()}[/] ");
257+
258+
if (ex.Message.IndexOf("certificate chain was issued by an authority that is not trusted", StringComparison.OrdinalIgnoreCase) >= 0)
259+
{
260+
AnsiConsole.MarkupLine("[yellow]Troubleshooting tips:[/]");
261+
AnsiConsole.MarkupLine("- Ensure SQL Server uses a certificate trusted by this machine's Trusted Root store.");
262+
AnsiConsole.MarkupLine("- Connect using a DNS name that matches the certificate's CN/SAN.");
263+
AnsiConsole.MarkupLine("- Or set [teal]--hostname-in-certificate[/] to the certificate subject.");
264+
AnsiConsole.MarkupLine("- For dev only, use [teal]--trust-server-certificate true[/] to bypass validation.");
265+
AnsiConsole.MarkupLine("- If name keeps flipping to an IP, try [teal]--no-tnir[/].");
266+
}
131267
}
132268

133269
}
134270

271+
private static bool LooksLikeIp(string server)
272+
{
273+
// Accept formats: "x.x.x.x" or "x.x.x.x,port"
274+
var parts = server.Split('\\')[0]; // ignore instance suffix
275+
var ipAndPort = parts.Split(',');
276+
var ip = ipAndPort[0].Trim();
277+
return Regex.IsMatch(ip, @"^\d{1,3}(\.\d{1,3}){3}$");
278+
}
279+
135280
public PingCommand(ILogger<PingCommand> logger)
136281
{
137282
Logger = logger;

Commands/Settings/ConsoleSettings.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,24 @@ public override ValidationResult Validate()
6262
[DefaultValue(10)]
6363
public int Wait { get; set; }
6464

65+
// TLS-related options
66+
[CommandOption("--encrypt <true|false|strict>")]
67+
[Description("Encrypt mode: true|false|strict. Default follows Microsoft.Data.SqlClient (true).")]
68+
public string? Encrypt { get; set; }
69+
70+
[CommandOption("--trust-server-certificate|-T <true|false>")]
71+
[Description("Set TrustServerCertificate=true (DEV ONLY). Bypasses cert validation but keeps encryption.")]
72+
public bool? TrustServerCertificate { get; set; }
73+
74+
[CommandOption("--hostname-in-certificate|-H <name>")]
75+
[Description("Override HostNameInCertificate for TLS validation (use the subject/SAN on the server certificate).")]
76+
public string? HostNameInCertificate { get; set; }
77+
78+
[CommandOption("--no-tnir")]
79+
[Description("Disable TransparentNetworkIPResolution to avoid host->IP substitution that breaks TLS name matching.")]
80+
[DefaultValue(false)]
81+
public bool NoTransparentNetworkIPResolution { get; set; } = false;
82+
6583

6684

6785
//[CommandOption("--command-option-value <value>")]

README.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,49 @@ This is a .NET console app and should run fine on [all supported operating syste
1515
-h, --help Prints help information
1616
-d, --database master Database name
1717
-u, --username SQL Username, leave empty for Windows integrated security
18-
-p, --password Password
18+
-p, --password Password (NOT RECOMMENDED - see Security Notes below)
1919
-t, --timeout 3 Connection timeout in seconds
2020
-c, --command SQL Command (default will return database information)
2121
-f, --failover This is a failover cluster / availability group, use MultiSubnetFailover=true
2222
-a, --failoverpartner Use a custom failover partner
2323
-n, --nonstop Set this to true to continously ping the server. Default is ping once
2424
-w, --wait <SECONDS> 10 How long to wait, in seconds, between non-stop pings
2525

26+
## SECURITY NOTES:
27+
28+
### Credential Handling
29+
30+
SQLPing supports multiple ways to provide credentials, listed from most secure to least secure:
31+
32+
**1. Interactive Password Prompt (RECOMMENDED)**
33+
```bash
34+
# Provide username, password will be prompted securely
35+
SQLPing myserver -u myusername
36+
Password: ********
37+
```
38+
39+
**2. Environment Variables (RECOMMENDED)**
40+
```bash
41+
# Set environment variables (more secure than command line)
42+
export SQLPING_USERNAME="myusername"
43+
export SQLPING_PASSWORD="mypassword"
44+
SQLPing myserver
45+
```
46+
47+
**3. Command Line Arguments (NOT RECOMMENDED)**
48+
```bash
49+
# WARNING: Password visible in process list and shell history
50+
SQLPing myserver -u myusername -p mypassword
51+
```
52+
53+
**Important Security Considerations:**
54+
- Passwords provided via command line (`-p`) are visible in:
55+
- Shell history
56+
- Process lists (`ps aux`, Task Manager)
57+
- Logs and monitoring tools
58+
- Always prefer environment variables or interactive prompts for production use
59+
- Connection strings in output have passwords redacted automatically
60+
2661

2762
# Todo
2863

0 commit comments

Comments
 (0)