diff --git a/App.xaml b/App.xaml deleted file mode 100644 index a620f35..0000000 --- a/App.xaml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/App.xaml.cs b/App.xaml.cs deleted file mode 100644 index 5f61e58..0000000 --- a/App.xaml.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Configuration; -using System.Data; -using System.Windows; - -namespace msOps; - -/// -/// Interaction logic for App.xaml -/// -public partial class App : Application -{ -} - diff --git a/Assets/App.ico b/Assets/App.ico new file mode 100644 index 0000000..b6a9a35 Binary files /dev/null and b/Assets/App.ico differ diff --git a/MainWindow.xaml b/MainWindow.xaml deleted file mode 100644 index 594b873..0000000 --- a/MainWindow.xaml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs deleted file mode 100644 index c5f2725..0000000 --- a/MainWindow.xaml.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Text; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; -using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Navigation; -using System.Windows.Shapes; - -namespace msOps; - -/// -/// Interaction logic for MainWindow.xaml -/// -public partial class MainWindow : Window -{ - public MainWindow() - { - InitializeComponent(); - } -} \ No newline at end of file diff --git a/NetKit.csproj b/NetKit.csproj new file mode 100644 index 0000000..8dff929 --- /dev/null +++ b/NetKit.csproj @@ -0,0 +1,33 @@ + + + + WinExe + net8.0-windows + enable + enable + true + true + NetKit + NetKit + + Assets\App.ico + + + + + + + + + App.xaml + + + MainWindow.xaml + + + + + + + + diff --git a/README.MD b/README.MD index 0dba219..2ff40b7 100644 --- a/README.MD +++ b/README.MD @@ -1,69 +1,27 @@ -# DevOps Helper – Windows System Tray App +# NetKit -A lightweight Windows system tray application built with **C# (.NET + WPF)** to speed up common DevOps tasks. -Initial features: -- Run fast HTTP(S) requests against a domain (using `HttpClient`). -- Check SSL certificate expiry natively in C# (no OpenSSL/WSL required). +A tiny Windows tray app for quick network checks. It runs in the system tray and pops open with a global hotkey. ---- +## Features +- HTTP GET/POST with headers and body +- SSL/TLS certificate details and expiry +- Redirect chain viewer +- DNS lookups (A, AAAA, CNAME, MX, TXT, NS, PTR) -## 🚀 Plan & Setup +## Download & Run +- Windows 10/11. +- Download the latest Release ZIP, extract, and run `NetKit.exe`. +- A tray icon appears. Press `Win + ' (apostrophe)` to show/hide the window or double click the tray icon. +- To stop the app just right-click the tray icon and click on Close. -### 1. Tech Stack -- **C# (.NET 8)** – modern runtime. -- **WPF** – flexible, stylable UI (borderless, rounded corners). -- **NotifyIcon** (via WinForms interop) – system tray integration. -- **HttpClient** – native HTTP(S) requests (faster & cleaner than `curl.exe`). +## Notes +- Settings are saved to `%AppData%\\NetKit\\settings.json`. ---- +## Build From Source +- Prerequisite: .NET 8 SDK. +- Restore/build: `dotnet restore` then `dotnet build -c Release`. -### 2. Project Bootstrap -1. Create a new **WPF App (.NET)** in Visual Studio or via CLI: - ```bash - dotnet new wpf -n msOps ---- +## Optional single-file build (can be larger and slower to start for WPF): + - `dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true` -### 3. System Tray & Hotkey -- Add tray icon context menu: **Open UI** | **Exit**. -- Register a **global hotkey** (e.g. `Ctrl+Alt+Space`) → opens the UI instantly. - ---- - -### 4. Popup UI -- Create a **borderless WPF window** styled like a MacOS panel: - - Rounded corners, shadows. - - Input field for domain. - - Toggle for `http` / `https`. - - Buttons for **Curl** and **SSL Check**. - ---- - -### 5. Features -- **HTTP Request** - - Use `HttpClient` to send requests directly from C#. - - Capture and display headers or response. - -- **SSL Expiry Check** - - Use `TcpClient` + `SslStream` to fetch the server certificate. - - Display expiry date + remaining days. - ---- - -### 6. Quality of Life -- Persist last used domain (e.g. in `Properties.Settings`). -- Optionally auto-run on Windows startup. -- Error handling for bad domains / network issues. - ---- - -### 7. Polish -- Improve styling (minimal, clean, MacOS-like). -- Add keyboard navigation. -- Add tray notifications (e.g. SSL expiring soon). - ---- - -### 8. Future Extensions -- Add utilities like **ping** or **DNS lookup**. -- Integrate with cloud CLIs (AWS, Azure, GCP). -- Support “favorite domains” or quick actions. \ No newline at end of file + ## \ No newline at end of file diff --git a/Services/DnsLookup.cs b/Services/DnsLookup.cs new file mode 100644 index 0000000..0ab4df0 --- /dev/null +++ b/Services/DnsLookup.cs @@ -0,0 +1,534 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Threading.Tasks; +using DnsClient; + +namespace NetKit; + +public class DnsResult +{ + public bool IsSuccess { get; set; } + public string Error { get; set; } = string.Empty; + public string Domain { get; set; } = string.Empty; + public string RecordType { get; set; } = string.Empty; + public List Records { get; set; } = new(); + public TimeSpan QueryTime { get; set; } + public string DnsServer { get; set; } = string.Empty; +} + +public class DnsRecord +{ + public string Type { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public int TTL { get; set; } + public int Priority { get; set; } // For MX records +} + +public class DnsLookup : IDisposable +{ + private readonly LookupClient _dnsClient; + private readonly SemaphoreSlim _concurrencySemaphore; + private bool _disposed; + private CancellationTokenSource? _shutdownCts; + + public DnsLookup() + { + // Configure DNS client with timeouts and limits + var options = new LookupClientOptions + { + Timeout = TimeSpan.FromSeconds(10), // Prevent hanging queries + Retries = 2, + UseCache = true, + CacheFailedResults = true + }; + + _dnsClient = new LookupClient(options); + + // Limit concurrent DNS operations to prevent resource exhaustion + _concurrencySemaphore = new SemaphoreSlim(5, 5); + _shutdownCts = new CancellationTokenSource(); + } + + public async Task LookupAsync(string domain, string recordType = "A") + { + var result = new DnsResult + { + Domain = domain, + RecordType = recordType + }; + + var startTime = DateTime.UtcNow; + + // Check if we're disposed/shutting down + if (_disposed || _shutdownCts?.Token.IsCancellationRequested == true) + { + result.Error = "DNS lookup cancelled - service is shutting down"; + return result; + } + + // Acquire semaphore to limit concurrent operations + if (!await _concurrencySemaphore.WaitAsync(TimeSpan.FromSeconds(5), _shutdownCts?.Token ?? CancellationToken.None).ConfigureAwait(false)) + { + result.Error = "DNS lookup timeout - too many concurrent operations"; + return result; + } + + try + { + if (string.IsNullOrWhiteSpace(domain)) + { + result.Error = "Domain cannot be empty"; + return result; + } + + // Validate domain length to prevent memory issues + if (domain.Length > 255) + { + result.Error = "Domain name too long (max 255 characters)"; + return result; + } + + // Clean up domain (remove protocol if present) + domain = domain.Replace("http://", "").Replace("https://", "").Split('/')[0]; + + result.DnsServer = GetDnsServers().FirstOrDefault() ?? "System Default"; + + switch (recordType.ToUpper()) + { + case "A": + await LookupARecords(domain, result); + break; + case "AAAA": + await LookupAAAARecords(domain, result); + break; + case "CNAME": + await LookupCNAMERecords(domain, result); + break; + case "MX": + await LookupMXRecords(domain, result); + break; + case "TXT": + await LookupTXTRecords(domain, result); + break; + case "NS": + await LookupNSRecords(domain, result); + break; + case "PTR": + await LookupPTRRecords(domain, result); + break; + case "ALL": + await LookupAllRecords(domain, result); + break; + default: + result.Error = $"Unsupported record type: {recordType}"; + return result; + } + + result.QueryTime = DateTime.UtcNow - startTime; + result.IsSuccess = result.Records.Count > 0 || string.IsNullOrEmpty(result.Error); + } + catch (OperationCanceledException) + { + result.Error = "DNS lookup was cancelled"; + result.QueryTime = DateTime.UtcNow - startTime; + } + catch (ObjectDisposedException) + { + result.Error = "DNS service is disposed"; + result.QueryTime = DateTime.UtcNow - startTime; + } + catch (Exception ex) + { + result.Error = $"DNS lookup failed: {ex.Message}"; + result.QueryTime = DateTime.UtcNow - startTime; + } + finally + { + // Always release the semaphore + _concurrencySemaphore?.Release(); + } + + return result; + } + + private async Task LookupARecords(string domain, DnsResult result) + { + try + { + // Use ConfigureAwait(false) for better performance in async contexts + var addresses = await Dns.GetHostAddressesAsync(domain).ConfigureAwait(false); + foreach (var address in addresses.Where(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)) + { + result.Records.Add(new DnsRecord + { + Type = "A", + Name = domain, + Value = address.ToString(), + TTL = 0 // .NET doesn't provide TTL info easily + }); + } + } + catch (Exception ex) + { + result.Error = ex.Message; + } + } + + private async Task LookupAAAARecords(string domain, DnsResult result) + { + try + { + var addresses = await Dns.GetHostAddressesAsync(domain).ConfigureAwait(false); + foreach (var address in addresses.Where(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)) + { + result.Records.Add(new DnsRecord + { + Type = "AAAA", + Name = domain, + Value = address.ToString(), + TTL = 0 + }); + } + } + catch (Exception ex) + { + result.Error = ex.Message; + } + } + + private async Task LookupCNAMERecords(string domain, DnsResult result) + { + try + { + var hostEntry = await Dns.GetHostEntryAsync(domain).ConfigureAwait(false); + if (hostEntry.HostName != domain && !string.IsNullOrEmpty(hostEntry.HostName)) + { + result.Records.Add(new DnsRecord + { + Type = "CNAME", + Name = domain, + Value = hostEntry.HostName, + TTL = 0 + }); + } + } + catch (Exception ex) + { + result.Error = ex.Message; + } + } + + private async Task LookupMXRecords(string domain, DnsResult result) + { + try + { + _shutdownCts?.Token.ThrowIfCancellationRequested(); + + var queryResult = await _dnsClient.QueryAsync(domain, QueryType.MX, cancellationToken: _shutdownCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + + const int maxRecords = 50; // Limit to prevent memory issues + var recordCount = 0; + + foreach (var record in queryResult.Answers.MxRecords()) + { + if (recordCount >= maxRecords) + { + result.Error = $"Too many MX records (showing first {maxRecords})"; + break; + } + + // Validate record data to prevent issues + var exchangeValue = record.Exchange?.Value ?? "Invalid Exchange"; + if (exchangeValue.Length > 500) + { + exchangeValue = exchangeValue.Substring(0, 497) + "..."; + } + + result.Records.Add(new DnsRecord + { + Type = "MX", + Name = domain, + Value = exchangeValue, + TTL = Math.Max(0, Math.Min(record.TimeToLive, int.MaxValue)), + Priority = Math.Max(0, Math.Min((int)record.Preference, 65535)) + }); + + recordCount++; + } + + if (!result.Records.Any() && string.IsNullOrEmpty(result.Error)) + { + result.Error = "No MX records found"; + } + } + catch (OperationCanceledException) + { + result.Error = "MX lookup was cancelled"; + } + catch (Exception ex) + { + result.Error = $"MX lookup failed: {ex.Message}"; + } + } + + private async Task LookupTXTRecords(string domain, DnsResult result) + { + try + { + _shutdownCts?.Token.ThrowIfCancellationRequested(); + + var queryResult = await _dnsClient.QueryAsync(domain, QueryType.TXT, cancellationToken: _shutdownCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + + const int maxRecords = 25; // Lower limit for TXT as they can be very large + var recordCount = 0; + + foreach (var record in queryResult.Answers.TxtRecords()) + { + if (recordCount >= maxRecords) + { + result.Error = $"Too many TXT records (showing first {maxRecords})"; + break; + } + + // TXT records can be extremely large, limit them + var txtValue = string.Join(" ", record.Text); + if (txtValue.Length > 2000) // Prevent huge TXT records from consuming memory + { + txtValue = txtValue.Substring(0, 1997) + "..."; + } + + result.Records.Add(new DnsRecord + { + Type = "TXT", + Name = domain, + Value = txtValue, + TTL = Math.Max(0, Math.Min(record.TimeToLive, int.MaxValue)) + }); + + recordCount++; + } + + if (!result.Records.Any() && string.IsNullOrEmpty(result.Error)) + { + result.Error = "No TXT records found"; + } + } + catch (OperationCanceledException) + { + result.Error = "TXT lookup was cancelled"; + } + catch (Exception ex) + { + result.Error = $"TXT lookup failed: {ex.Message}"; + } + } + + private async Task LookupNSRecords(string domain, DnsResult result) + { + try + { + var queryResult = await _dnsClient.QueryAsync(domain, QueryType.NS).ConfigureAwait(false); + + foreach (var record in queryResult.Answers.NsRecords()) + { + result.Records.Add(new DnsRecord + { + Type = "NS", + Name = domain, + Value = record.NSDName.Value, + TTL = Math.Max(0, Math.Min(record.TimeToLive, int.MaxValue)) + }); + } + + if (!result.Records.Any()) + { + result.Error = "No NS records found"; + } + } + catch (Exception ex) + { + result.Error = $"NS lookup failed: {ex.Message}"; + } + } + + private async Task LookupPTRRecords(string domain, DnsResult result) + { + try + { + // Assume domain is an IP address for PTR lookup + if (IPAddress.TryParse(domain, out var ipAddress)) + { + var hostEntry = await Dns.GetHostEntryAsync(ipAddress).ConfigureAwait(false); + result.Records.Add(new DnsRecord + { + Type = "PTR", + Name = domain, + Value = hostEntry.HostName, + TTL = 0 + }); + } + else + { + result.Error = "PTR lookup requires an IP address"; + } + } + catch (Exception ex) + { + result.Error = ex.Message; + } + } + + private async Task LookupAllRecords(string domain, DnsResult result) + { + // Run all lookups concurrently for better performance + var tasks = new List> + { + LookupRecordTypeAsync(domain, "A"), + LookupRecordTypeAsync(domain, "AAAA"), + LookupRecordTypeAsync(domain, "CNAME"), + LookupRecordTypeAsync(domain, "MX"), + LookupRecordTypeAsync(domain, "TXT"), + LookupRecordTypeAsync(domain, "NS"), + LookupRecordTypeAsync(domain, "PTR") // Try reverse lookup too if it's an IP + }; + + var results = await Task.WhenAll(tasks); + + // Combine all successful results + foreach (var lookupResult in results) + { + if (lookupResult.Records.Count > 0) + { + result.Records.AddRange(lookupResult.Records); + } + } + + // Clear error if we got some results + if (result.Records.Count > 0) + { + result.Error = string.Empty; + } + else + { + result.Error = "No records found for any record type"; + } + } + + private async Task LookupRecordTypeAsync(string domain, string recordType) + { + var tempResult = new DnsResult + { + Domain = domain, + RecordType = recordType + }; + + try + { + switch (recordType.ToUpper()) + { + case "A": + await LookupARecords(domain, tempResult); + break; + case "AAAA": + await LookupAAAARecords(domain, tempResult); + break; + case "CNAME": + await LookupCNAMERecords(domain, tempResult); + break; + case "MX": + await LookupMXRecords(domain, tempResult); + break; + case "TXT": + await LookupTXTRecords(domain, tempResult); + break; + case "NS": + await LookupNSRecords(domain, tempResult); + break; + case "PTR": + await LookupPTRRecords(domain, tempResult); + break; + } + } + catch + { + // Ignore individual lookup failures for ALL mode + } + + return tempResult; + } + + private List GetDnsServers() + { + var dnsServers = new List(); + + try + { + var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces(); + foreach (var networkInterface in networkInterfaces) + { + if (networkInterface.OperationalStatus == OperationalStatus.Up) + { + var ipProperties = networkInterface.GetIPProperties(); + foreach (var dnsAddress in ipProperties.DnsAddresses) + { + if (!dnsServers.Contains(dnsAddress.ToString())) + { + dnsServers.Add(dnsAddress.ToString()); + } + } + } + } + } + catch + { + // Fallback to common DNS servers + dnsServers.AddRange(new[] { "8.8.8.8", "1.1.1.1" }); + } + + return dnsServers; + } + + public void Shutdown() + { + try + { + // Signal all operations to cancel + _shutdownCts?.Cancel(); + } + catch + { + // Ignore cancellation exceptions during shutdown + } + } + + public void Dispose() + { + if (!_disposed) + { + try + { + // Cancel any ongoing operations + Shutdown(); + + // Wait briefly for operations to complete + Task.Delay(1000).Wait(); + + // Dispose resources (LookupClient doesn't implement IDisposable) + _concurrencySemaphore?.Dispose(); + _shutdownCts?.Dispose(); + } + catch + { + // Ignore exceptions during disposal + } + finally + { + _disposed = true; + } + } + } +} \ No newline at end of file diff --git a/Services/GlobalHotkey.cs b/Services/GlobalHotkey.cs new file mode 100644 index 0000000..6651633 --- /dev/null +++ b/Services/GlobalHotkey.cs @@ -0,0 +1,111 @@ +using System; +using System.Runtime.InteropServices; +using System.Windows.Forms; +using System.Windows.Interop; + +namespace NetKit; + +public class GlobalHotkey : IDisposable +{ + private const int WM_HOTKEY = 0x0312; + private const int MOD_CONTROL = 0x0002; + private const int MOD_ALT = 0x0001; + private const int MOD_WIN = 0x0008; + + private readonly int _id; + private readonly IntPtr _hWnd; + private bool _disposed = false; + private bool _isRegistered = false; + + private int _currentModifiers = MOD_WIN; + private int _currentVirtualKey = 0xDE; + + public event Action? HotkeyPressed; + + [DllImport("user32.dll")] + private static extern bool RegisterHotKey(IntPtr hWnd, int id, int fsModifiers, int vlc); + + [DllImport("user32.dll")] + private static extern bool UnregisterHotKey(IntPtr hWnd, int id); + + public GlobalHotkey(IntPtr windowHandle, int hotkeyId = 1) + { + _hWnd = windowHandle; + _id = hotkeyId; + } + + public bool Register() + { + return Register(_currentModifiers, _currentVirtualKey); + } + + public bool Register(int modifiers, int virtualKey) + { + try + { + // Unregister existing hotkey if registered + if (_isRegistered) + { + UnregisterHotKey(_hWnd, _id); + _isRegistered = false; + } + + _currentModifiers = modifiers; + _currentVirtualKey = virtualKey; + + var success = RegisterHotKey(_hWnd, _id, modifiers, virtualKey); + _isRegistered = success; + return success; + } + catch + { + return false; + } + } + + public bool UpdateHotkey(bool useCtrl, bool useAlt, bool useWin, int virtualKey) + { + int modifiers = 0; + if (useCtrl) modifiers |= MOD_CONTROL; + if (useAlt) modifiers |= MOD_ALT; + if (useWin) modifiers |= MOD_WIN; + + return Register(modifiers, virtualKey); + } + + public void Unregister() + { + try + { + if (_isRegistered) + { + UnregisterHotKey(_hWnd, _id); + _isRegistered = false; + } + } + catch + { + // Ignore unregister errors + } + } + + public bool ProcessHotkey(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg == WM_HOTKEY && wParam.ToInt32() == _id) + { + HotkeyPressed?.Invoke(); + handled = true; + return true; + } + return false; + } + + public void Dispose() + { + if (!_disposed) + { + Unregister(); + _disposed = true; + } + } +} \ No newline at end of file diff --git a/Services/HttpService.cs b/Services/HttpService.cs index e47fd09..66fe915 100644 --- a/Services/HttpService.cs +++ b/Services/HttpService.cs @@ -1,6 +1,112 @@ -namespace msOps; +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace NetKit; public class HttpService { + private readonly HttpClient _httpClient; + + public HttpService() + { + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "NetKit/1.0"); + } + + private string EnsureProtocol(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + url = url.Trim(); + + if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return $"https://{url}"; + } + + return url; + } + + public async Task GetAsync(string url) + { + try + { + url = EnsureProtocol(url); + var response = await _httpClient.GetAsync(url); + var content = await response.Content.ReadAsStringAsync(); + + return new HttpResult + { + IsSuccess = response.IsSuccessStatusCode, + StatusCode = (int)response.StatusCode, + StatusText = response.ReasonPhrase ?? "", + Headers = response.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)), + ContentHeaders = response.Content.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)), + Content = content, + ResponseTime = TimeSpan.Zero // We'll add timing later + }; + } + catch (Exception ex) + { + return new HttpResult + { + IsSuccess = false, + Error = ex.Message + }; + } + } + public async Task PostAsync(string url, object? data = null) + { + try + { + url = EnsureProtocol(url); + + var content = data != null + ? new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json") + : new StringContent(""); + + var response = await _httpClient.PostAsync(url, content); + var responseContent = await response.Content.ReadAsStringAsync(); + + return new HttpResult + { + IsSuccess = response.IsSuccessStatusCode, + StatusCode = (int)response.StatusCode, + StatusText = response.ReasonPhrase ?? "", + Headers = response.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)), + ContentHeaders = response.Content.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)), + Content = responseContent, + ResponseTime = TimeSpan.Zero + }; + } + catch (Exception ex) + { + return new HttpResult + { + IsSuccess = false, + Error = ex.Message + }; + } + } + + public void Dispose() + { + _httpClient?.Dispose(); + } +} + +public class HttpResult +{ + public bool IsSuccess { get; set; } + public int StatusCode { get; set; } + public string StatusText { get; set; } = ""; + public Dictionary Headers { get; set; } = new(); + public Dictionary ContentHeaders { get; set; } = new(); + public string Content { get; set; } = ""; + public TimeSpan ResponseTime { get; set; } + public string? Error { get; set; } } diff --git a/Services/RedirectChecker.cs b/Services/RedirectChecker.cs new file mode 100644 index 0000000..4ab014e --- /dev/null +++ b/Services/RedirectChecker.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; + +namespace NetKit; + +public class RedirectResult +{ + public bool IsSuccess { get; set; } + public string Error { get; set; } = string.Empty; + public List RedirectChain { get; set; } = new(); + public string FinalUrl { get; set; } = string.Empty; + public int TotalRedirects { get; set; } + public TimeSpan TotalTime { get; set; } +} + +public class RedirectStep +{ + public string FromUrl { get; set; } = string.Empty; + public string ToUrl { get; set; } = string.Empty; + public int StatusCode { get; set; } + public string StatusText { get; set; } = string.Empty; + public TimeSpan ResponseTime { get; set; } + public Dictionary Headers { get; set; } = new(); +} + +public class RedirectChecker : IDisposable +{ + private readonly HttpClient _httpClient; + private bool _disposed; + + public RedirectChecker() + { + var handler = new HttpClientHandler() + { + AllowAutoRedirect = false // We want to handle redirects manually + }; + + _httpClient = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(30) + }; + + _httpClient.DefaultRequestHeaders.Add("User-Agent", + "NetKit-RedirectChecker/1.0 (Windows)"); + } + + public async Task CheckRedirectsAsync(string url, int maxRedirects = 10) + { + var result = new RedirectResult(); + var startTime = DateTime.UtcNow; + + try + { + if (string.IsNullOrWhiteSpace(url)) + { + result.Error = "URL cannot be empty"; + return result; + } + + // Ensure URL has a protocol + if (!url.StartsWith("http://") && !url.StartsWith("https://")) + { + url = "https://" + url; + } + + var currentUrl = url; + var redirectCount = 0; + + while (redirectCount < maxRedirects) + { + var stepStartTime = DateTime.UtcNow; + + try + { + using var response = await _httpClient.GetAsync(currentUrl); + var stepTime = DateTime.UtcNow - stepStartTime; + + var step = new RedirectStep + { + FromUrl = currentUrl, + StatusCode = (int)response.StatusCode, + StatusText = response.StatusCode.ToString(), + ResponseTime = stepTime + }; + + // Capture important headers + foreach (var header in response.Headers) + { + step.Headers[header.Key] = string.Join(", ", header.Value); + } + + // Check for redirect status codes + if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400) + { + var locationHeader = response.Headers.Location; + if (locationHeader != null) + { + var nextUrl = locationHeader.IsAbsoluteUri + ? locationHeader.ToString() + : new Uri(new Uri(currentUrl), locationHeader).ToString(); + + step.ToUrl = nextUrl; + result.RedirectChain.Add(step); + + currentUrl = nextUrl; + redirectCount++; + continue; + } + else + { + result.Error = $"Redirect response {response.StatusCode} without Location header"; + return result; + } + } + else + { + // Final destination reached + step.ToUrl = currentUrl; + result.RedirectChain.Add(step); + result.FinalUrl = currentUrl; + result.TotalRedirects = redirectCount; + result.TotalTime = DateTime.UtcNow - startTime; + result.IsSuccess = true; + return result; + } + } + catch (HttpRequestException ex) + { + result.Error = $"HTTP error at step {redirectCount + 1}: {ex.Message}"; + return result; + } + catch (TaskCanceledException ex) + { + result.Error = ex.InnerException is TimeoutException + ? "Request timeout" + : "Request canceled"; + return result; + } + } + + // Max redirects reached - still show the chain + result.TotalRedirects = redirectCount; + result.TotalTime = DateTime.UtcNow - startTime; + result.IsSuccess = true; // We want to show the results even if max reached + result.FinalUrl = currentUrl; + + // Add a final step showing the redirect limit was reached + result.RedirectChain.Add(new RedirectStep + { + FromUrl = currentUrl, + ToUrl = "Maximum redirect limit reached", + StatusCode = 0, + StatusText = "ERR_TOO_MANY_REDIRECTS", + ResponseTime = TimeSpan.Zero + }); + + return result; + } + catch (Exception ex) + { + result.Error = $"Unexpected error: {ex.Message}"; + return result; + } + } + + public void Dispose() + { + if (!_disposed) + { + _httpClient?.Dispose(); + _disposed = true; + } + } +} \ No newline at end of file diff --git a/Services/SettingsService.cs b/Services/SettingsService.cs new file mode 100644 index 0000000..ca07bbe --- /dev/null +++ b/Services/SettingsService.cs @@ -0,0 +1,98 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace NetKit; + +public class HotkeySettings +{ + public bool UseCtrl { get; set; } + public bool UseAlt { get; set; } + public bool UseWin { get; set; } = true; + public int VirtualKeyCode { get; set; } = 0xDE; // Default to apostrophe + public string KeyDisplay { get; set; } = "'"; +} + +public class AppSettings +{ + public HotkeySettings Hotkey { get; set; } = new(); +} + +public class SettingsService +{ + private readonly string _settingsPath; + private AppSettings _settings; + + public AppSettings Settings => _settings; + + public SettingsService() + { + var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var netKitFolder = Path.Combine(appDataPath, "NetKit"); + + if (!Directory.Exists(netKitFolder)) + { + Directory.CreateDirectory(netKitFolder); + } + + _settingsPath = Path.Combine(netKitFolder, "settings.json"); + _settings = LoadSettings(); + } + + private AppSettings LoadSettings() + { + try + { + if (File.Exists(_settingsPath)) + { + var json = File.ReadAllText(_settingsPath); + var settings = JsonSerializer.Deserialize(json); + return settings ?? new AppSettings(); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to load settings: {ex.Message}"); + } + + return new AppSettings(); + } + + public void SaveSettings() + { + try + { + var json = JsonSerializer.Serialize(_settings, new JsonSerializerOptions + { + WriteIndented = true + }); + File.WriteAllText(_settingsPath, json); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to save settings: {ex.Message}"); + } + } + + public void UpdateHotkey(bool useCtrl, bool useAlt, bool useWin, int virtualKeyCode, string keyDisplay) + { + _settings.Hotkey.UseCtrl = useCtrl; + _settings.Hotkey.UseAlt = useAlt; + _settings.Hotkey.UseWin = useWin; + _settings.Hotkey.VirtualKeyCode = virtualKeyCode; + _settings.Hotkey.KeyDisplay = keyDisplay; + SaveSettings(); + } + + public string GetHotkeyDisplayText() + { + var modifiers = new List(); + + if (_settings.Hotkey.UseCtrl) modifiers.Add("Ctrl"); + if (_settings.Hotkey.UseAlt) modifiers.Add("Alt"); + if (_settings.Hotkey.UseWin) modifiers.Add("Win"); + + var modifierText = modifiers.Count > 0 ? string.Join(" + ", modifiers) + " + " : ""; + return $"{modifierText}{_settings.Hotkey.KeyDisplay}"; + } +} \ No newline at end of file diff --git a/Services/SslChecker.cs b/Services/SslChecker.cs index 6e1e1e2..5d58a59 100644 --- a/Services/SslChecker.cs +++ b/Services/SslChecker.cs @@ -1,6 +1,100 @@ -namespace msOps; +using System.Diagnostics; +using System.Net.Http; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; + +namespace NetKit; public class SslChecker { + public async Task CheckSslCertificateAsync(string hostname, int port = 443) + { + try + { + using var tcpClient = new TcpClient(); + await tcpClient.ConnectAsync(hostname, port); + + using var sslStream = new SslStream(tcpClient.GetStream()); + await sslStream.AuthenticateAsClientAsync(hostname); + + var certificate = sslStream.RemoteCertificate as X509Certificate2; + if (certificate == null) + { + return new SslCheckResult + { + IsValid = false, + Error = "No certificate found" + }; + } + + return new SslCheckResult + { + IsValid = true, + Subject = certificate.Subject, + Issuer = certificate.Issuer, + NotBefore = certificate.NotBefore, + NotAfter = certificate.NotAfter, + IsExpired = certificate.NotAfter < DateTime.Now, + DaysUntilExpiry = (certificate.NotAfter - DateTime.Now).Days, + Thumbprint = certificate.Thumbprint + }; + } + catch (Exception ex) + { + return new SslCheckResult + { + IsValid = false, + Error = ex.Message + }; + } + } + + public async Task CheckHttpConnectionAsync(string hostname, int port = 80) + { + try + { + var stopwatch = Stopwatch.StartNew(); + + using var httpClient = new HttpClient(); + httpClient.Timeout = TimeSpan.FromSeconds(10); + var url = $"http://{hostname}:{port}"; + var response = await httpClient.GetAsync(url); + + stopwatch.Stop(); + + var serverHeader = response.Headers.Server?.ToString() ?? ""; + + return new SslCheckResult + { + IsValid = true, + ResponseTimeMs = (int)stopwatch.ElapsedMilliseconds, + ServerHeader = serverHeader + }; + } + catch (Exception ex) + { + return new SslCheckResult + { + IsValid = false, + Error = ex.Message + }; + } + } +} + +public class SslCheckResult +{ + public bool IsValid { get; set; } + public string? Subject { get; set; } + public string? Issuer { get; set; } + public DateTime NotBefore { get; set; } + public DateTime NotAfter { get; set; } + public bool IsExpired { get; set; } + public int DaysUntilExpiry { get; set; } + public string? Thumbprint { get; set; } + public string? Error { get; set; } + public int ResponseTimeMs { get; set; } + public string? ServerHeader { get; set; } } diff --git a/Tray/TrayIconManager.cs b/Tray/TrayIconManager.cs index ee8ca05..b469111 100644 --- a/Tray/TrayIconManager.cs +++ b/Tray/TrayIconManager.cs @@ -1,6 +1,85 @@ -namespace msOps; +using System; +using System.Drawing; +using System.Reflection; +using System.Windows.Forms; -public class TrayIconManager +namespace NetKit; + +public class TrayIconManager : IDisposable { + private NotifyIcon? _notifyIcon; + + public event Action? ShowWindow; + public event Action? ExitApplication; + + public TrayIconManager() + { + _notifyIcon = new NotifyIcon + { + Icon = LoadAppIcon() ?? SystemIcons.Application, + Visible = true, + Text = "NetKit (Win+' to toggle)" + }; + + var contextMenu = new ContextMenuStrip(); + contextMenu.Items.Add("Show/Hide Window (Win+')", null, OnShowHideClicked); + contextMenu.Items.Add("-"); // Separator + contextMenu.Items.Add("Exit", null, OnExitClicked); + + _notifyIcon.ContextMenuStrip = contextMenu; + _notifyIcon.DoubleClick += OnDoubleClick; + } + + private static Icon? LoadAppIcon() + { + try + { + var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + if (!string.IsNullOrEmpty(exe)) + { + var icon = Icon.ExtractAssociatedIcon(exe); + return icon; + } + } + catch + { + // ignore + } + return null; + } + + public void UpdateHotkeyText(string hotkeyText) + { + if (_notifyIcon != null) + { + _notifyIcon.Text = $"NetKit ({hotkeyText} to toggle)"; + + // Update context menu item text too + if (_notifyIcon.ContextMenuStrip?.Items.Count > 0) + { + _notifyIcon.ContextMenuStrip.Items[0].Text = $"Show/Hide Window ({hotkeyText})"; + } + } + } + + private void OnShowHideClicked(object? sender, EventArgs e) + { + ShowWindow?.Invoke(); + } + + private void OnDoubleClick(object? sender, EventArgs e) + { + ShowWindow?.Invoke(); + } + + private void OnExitClicked(object? sender, EventArgs e) + { + ExitApplication?.Invoke(); + } + public void Dispose() + { + _notifyIcon?.Dispose(); + GC.SuppressFinalize(this); + } } diff --git a/UI/App.xaml b/UI/App.xaml new file mode 100644 index 0000000..b33e520 --- /dev/null +++ b/UI/App.xaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/UI/App.xaml.cs b/UI/App.xaml.cs new file mode 100644 index 0000000..a802344 --- /dev/null +++ b/UI/App.xaml.cs @@ -0,0 +1,134 @@ +using System; +using System.Windows; +using System.Windows.Interop; + +namespace NetKit; + +/// +/// Interaction logic for App.xaml +/// +public partial class App : System.Windows.Application +{ + private GlobalHotkey? _globalHotkey; + private SettingsService? _settingsService; + private TrayIconManager? _trayIconManager; + private MainWindow? _mainWindow; + + protected override void OnStartup(StartupEventArgs e) + { + try + { + base.OnStartup(e); + + // Initialize services first + _settingsService = new SettingsService(); + + // Create main window but start hidden + _mainWindow = new MainWindow(_settingsService); + this.MainWindow = _mainWindow; + _mainWindow.Closed += (s, _) => { _mainWindow = null; }; + + // Initialize tray icon + _trayIconManager = new TrayIconManager(); + _trayIconManager.ShowWindow += ShowOrCreateMainWindow; + _trayIconManager.ExitApplication += () => this.Shutdown(); + + // Set up global hotkey with a hidden helper window + SetupGlobalHotkey(); + + // Don't call Show() - window starts hidden + } + catch (Exception ex) + { + System.Windows.MessageBox.Show($"Startup Error: {ex.Message}\n\nStack Trace: {ex.StackTrace}", "Application Error"); + this.Shutdown(1); + } + } + + private void SetupGlobalHotkey() + { + try + { + // Create a hidden helper window just for the hotkey handle + var helperWindow = new Window + { + Width = 1, + Height = 1, + WindowStyle = WindowStyle.None, + ShowInTaskbar = false, + Visibility = Visibility.Hidden + }; + + // Force creation of window handle + helperWindow.Show(); + var windowHandle = new WindowInteropHelper(helperWindow).Handle; + helperWindow.Hide(); + + // Initialize global hotkey + _globalHotkey = new GlobalHotkey(windowHandle); + var settings = _settingsService!.Settings.Hotkey; + + // Connect hotkey to show/hide functionality + _globalHotkey.HotkeyPressed += ShowOrCreateMainWindow; + + // Add hook for hotkey messages + var source = HwndSource.FromHwnd(windowHandle); + source?.AddHook(WndProc); + + // Register the hotkey with saved settings + if (_globalHotkey.UpdateHotkey(settings.UseCtrl, settings.UseAlt, settings.UseWin, settings.VirtualKeyCode)) + { + _trayIconManager?.UpdateHotkeyText(_settingsService.GetHotkeyDisplayText()); + } + else + { + // If saved hotkey fails, try default Win+' + if (_globalHotkey.UpdateHotkey(false, false, true, 0xDE)) + { + _settingsService.UpdateHotkey(false, false, true, 0xDE, "'"); + _trayIconManager?.UpdateHotkeyText(_settingsService.GetHotkeyDisplayText()); + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Hotkey registration failed: {ex.Message}"); + } + } + + private void ShowOrCreateMainWindow() + { + try + { + if (_mainWindow == null || !_mainWindow.IsLoaded) + { + _mainWindow = new MainWindow(_settingsService!); + _mainWindow.Closed += (s, _) => { _mainWindow = null; }; + this.MainWindow = _mainWindow; + } + + _mainWindow.ShowWindow(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to show/create MainWindow: {ex.Message}"); + } + } + + private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + _globalHotkey?.ProcessHotkey(hwnd, msg, wParam, lParam, ref handled); + return IntPtr.Zero; + } + + public GlobalHotkey? GetGlobalHotkey() => _globalHotkey; + public SettingsService? GetSettingsService() => _settingsService; + public TrayIconManager? GetTrayIconManager() => _trayIconManager; + + protected override void OnExit(ExitEventArgs e) + { + _globalHotkey?.Dispose(); + _trayIconManager?.Dispose(); + base.OnExit(e); + } +} diff --git a/UI/CustomMessageBox.xaml b/UI/CustomMessageBox.xaml new file mode 100644 index 0000000..2ba4cad --- /dev/null +++ b/UI/CustomMessageBox.xaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + +