using System; using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; using System.Text; using System.Threading; using System.Threading.Tasks; class Program { static string USER = "user"; static string PASS = "pass"; static readonly string UPSTREAM_USER = ResolveUpstreamUser(); static readonly string UPSTREAM_PASS = Environment.GetEnvironmentVariable("UPSTREAM_PASS") ?? string.Empty; static readonly bool FORCE_UPSTREAM_BASIC = string.Equals( Environment.GetEnvironmentVariable("FORCE_UPSTREAM_BASIC"), "1", StringComparison.OrdinalIgnoreCase); static readonly string UPSTREAM_PROXY_AUTH_HEADER = BuildUpstreamProxyAuthHeader(); static long _connectionSeq; static async Task Main() { int port = 8888; RegisterGlobalExceptionHandlers(); Log.Info("=== LOCAL PROXY ==="); Log.Info($"http://127.0.0.1:{port}"); Log.Info($"login: {USER}"); Log.Info($"pass : {PASS}"); Log.Info($"upstream auth user: {UPSTREAM_USER}"); Log.Info($"force upstream basic: {(FORCE_UPSTREAM_BASIC ? "on" : "off")}"); var listener = new TcpListener(IPAddress.Any, port); listener.Start(); Log.Info($"Listening on {listener.LocalEndpoint}"); while (true) { try { var client = await listener.AcceptTcpClientAsync(); var connectionId = Interlocked.Increment(ref _connectionSeq); Log.Info($"[conn:{connectionId}] accepted from {client.Client.RemoteEndPoint}"); _ = Task.Run(() => HandleClient(client, connectionId)); } catch (Exception ex) { Log.Error("AcceptTcpClientAsync failed", ex); } } } static void RegisterGlobalExceptionHandlers() { AppDomain.CurrentDomain.UnhandledException += (_, args) => Log.Error("Unhandled exception", args.ExceptionObject as Exception); TaskScheduler.UnobservedTaskException += (_, args) => { Log.Error("Unobserved task exception", args.Exception); args.SetObserved(); }; } static async Task HandleClient(TcpClient client, long connectionId) { using (client) { try { var stream = client.GetStream(); var request = await ReadHeaders(stream, $"conn:{connectionId} client-request"); if (request == null) { Log.Warn($"[conn:{connectionId}] empty or invalid request"); return; } Log.Info($"[conn:{connectionId}] {request.Method} {request.Target}"); if (!CheckAuth(request.Raw)) { Log.Warn($"[conn:{connectionId}] proxy auth failed"); await Write407(stream); return; } if (request.Method == "CONNECT") await HandleConnect(stream, request.Target, connectionId); else await HandleHttp(stream, request, connectionId); } catch (Exception ex) { Log.Error($"[conn:{connectionId}] client handling failed", ex); } finally { Log.Info($"[conn:{connectionId}] closed"); } } } // ================= HTTP ================= static async Task HandleHttp(NetworkStream clientStream, HttpRequest req, long connectionId) { var proxyUri = WinHttpHelper.GetProxyForUrl(new Uri(req.Url)); if (proxyUri == null) { Log.Error($"[conn:{connectionId}] no upstream proxy for {req.Url}"); await Write502(clientStream); return; } Log.Info($"[conn:{connectionId}] HTTP upstream {proxyUri.Host}:{proxyUri.Port} for {req.Url}"); LogUpstreamUser(connectionId, proxyUri.Host); using var upstream = new TcpClient(); await upstream.ConnectAsync(proxyUri.Host, proxyUri.Port); var upstreamStream = upstream.GetStream(); var outbound = BuildUpstreamHttpRequest(req); await upstreamStream.WriteAsync(outbound); await Pump(upstreamStream, clientStream, $"conn:{connectionId} HTTP upstream->client"); } // ================= CONNECT ================= static async Task HandleConnect(NetworkStream clientStream, string target, long connectionId) { var parts = target.Split(':'); if (parts.Length != 2 || !int.TryParse(parts[1], out int port)) { Log.Error($"[conn:{connectionId}] invalid CONNECT target: {target}"); await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 400 Bad Request\r\n\r\n")); return; } string host = parts[0]; var proxyUri = WinHttpHelper.GetProxyForUrl(new Uri($"https://{host}:{port}")); if (proxyUri == null) { Log.Error($"[conn:{connectionId}] no upstream proxy for CONNECT {host}:{port}"); await Write502(clientStream); return; } Log.Info($"[conn:{connectionId}] CONNECT {host}:{port} via {proxyUri.Host}:{proxyUri.Port}"); LogUpstreamUser(connectionId, proxyUri.Host); using var upstream = new TcpClient(); await upstream.ConnectAsync(proxyUri.Host, proxyUri.Port); var upstreamStream = upstream.GetStream(); var connectResp = await PerformUpstreamConnectHandshake( upstreamStream, host, port, proxyUri.Host, connectionId); int statusCode = ParseStatusCode(connectResp?.Raw); if (connectResp == null || statusCode != 200) { var status = connectResp?.Raw?.Split("\r\n", StringSplitOptions.None).FirstOrDefault() ?? ""; Log.Error($"[conn:{connectionId}] upstream CONNECT rejected ({statusCode}): {status}"); await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 502 Bad Gateway\r\n\r\n")); return; } await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 200 Connection Established\r\n\r\n")); var t1 = Pump(upstreamStream, clientStream, $"conn:{connectionId} tunnel upstream->client"); var t2 = Pump(clientStream, upstreamStream, $"conn:{connectionId} tunnel client->upstream"); await Task.WhenAny(t1, t2); } // ================= AUTH ================= static bool CheckAuth(string raw) { string expected = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{USER}:{PASS}")); return raw.Contains($"Proxy-Authorization: Basic {expected}"); } static async Task Write407(NetworkStream stream) { string resp = "HTTP/1.1 407 Proxy Authentication Required\r\n" + "Proxy-Authenticate: Basic realm=\"proxy\"\r\n\r\n"; await stream.WriteAsync(Encoding.ASCII.GetBytes(resp)); } static async Task Write502(NetworkStream stream) { const string resp = "HTTP/1.1 502 Bad Gateway\r\n\r\n"; await stream.WriteAsync(Encoding.ASCII.GetBytes(resp)); } // ================= PARSER ================= class HttpRequest { public string Method; public string Target; public string Url; public string Raw; public byte[] RawBytes; public int HeaderLength; } static async Task ReadHeaders(Stream stream, string context) { try { var rawBytes = await ReadHeaderBytes(stream); if (rawBytes == null || rawBytes.Length == 0) return null; string raw = Encoding.ASCII.GetString(rawBytes); var lines = raw.Split("\r\n"); var first = lines[0].Split(' ', StringSplitOptions.RemoveEmptyEntries); if (first.Length < 2) { Log.Error($"[{context}] invalid request line: {lines[0]}"); return null; } var req = new HttpRequest { Method = first[0], Target = first[1], Raw = raw, RawBytes = rawBytes, HeaderLength = GetHeaderLength(raw) }; if (req.Method != "CONNECT") { string host = lines .FirstOrDefault(l => l.StartsWith("Host:", StringComparison.OrdinalIgnoreCase)) ?.Split(':', 2)[1] .Trim(); req.Url = req.Target.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? req.Target : $"http://{host}{req.Target}"; } return req; } catch (Exception ex) { Log.Error($"[{context}] failed to read/parse headers", ex); return null; } } static async Task ReadHeaderBytes(Stream stream) { const int maxHeaderBytes = 64 * 1024; var data = new List(1024); var one = new byte[1]; while (data.Count < maxHeaderBytes) { int read = await stream.ReadAsync(one, 0, 1); if (read <= 0) break; data.Add(one[0]); int n = data.Count; if (n >= 4 && data[n - 4] == (byte)'\r' && data[n - 3] == (byte)'\n' && data[n - 2] == (byte)'\r' && data[n - 1] == (byte)'\n') break; } return data.Count == 0 ? null : data.ToArray(); } // ================= STREAM ================= static async Task Pump(Stream from, Stream to, string context) { var buffer = ArrayPool.Shared.Rent(8192); try { while (true) { int read = await from.ReadAsync(buffer); if (read <= 0) break; await to.WriteAsync(buffer.AsMemory(0, read)); } } catch (Exception ex) { if (IsExpectedDisconnect(ex)) Log.Info($"[{context}] stream closed: {OneLine(ex.Message)}"); else Log.Error($"[{context}] stream pump failed", ex); } finally { ArrayPool.Shared.Return(buffer); } } static bool IsExpectedDisconnect(Exception ex) { if (ex is ObjectDisposedException) return true; if (ex is OperationCanceledException) return true; if (ex is IOException io && io.InnerException is SocketException se) { return se.SocketErrorCode == SocketError.ConnectionAborted || se.SocketErrorCode == SocketError.ConnectionReset || se.SocketErrorCode == SocketError.Shutdown || se.SocketErrorCode == SocketError.OperationAborted || se.SocketErrorCode == SocketError.TimedOut; } return false; } static string OneLine(string message) { if (string.IsNullOrEmpty(message)) return string.Empty; return message.Replace("\r", " ").Replace("\n", " ").Trim(); } static int ParseStatusCode(string rawHeaders) { if (string.IsNullOrWhiteSpace(rawHeaders)) return 0; var line = rawHeaders.Split("\r\n", StringSplitOptions.None).FirstOrDefault(); if (string.IsNullOrWhiteSpace(line)) return 0; var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length < 2) return 0; return int.TryParse(parts[1], out int code) ? code : 0; } static int GetHeaderLength(string raw) { int idx = raw.IndexOf("\r\n\r\n", StringComparison.Ordinal); return idx >= 0 ? idx + 4 : raw.Length; } static byte[] BuildUpstreamHttpRequest(HttpRequest req) { var lines = req.Raw.Split("\r\n", StringSplitOptions.None); var sb = new StringBuilder(); sb.Append(BuildUpstreamRequestLine(req, lines[0])).Append("\r\n"); for (int i = 1; i < lines.Length; i++) { var line = lines[i]; if (line.Length == 0) break; if (line.StartsWith("Proxy-Authorization:", StringComparison.OrdinalIgnoreCase)) continue; // local proxy auth, must not go upstream if (line.StartsWith("Proxy-Connection:", StringComparison.OrdinalIgnoreCase)) continue; sb.Append(line).Append("\r\n"); } if (!string.IsNullOrEmpty(UPSTREAM_PROXY_AUTH_HEADER)) sb.Append(UPSTREAM_PROXY_AUTH_HEADER); sb.Append("\r\n"); var headerBytes = Encoding.ASCII.GetBytes(sb.ToString()); if (req.HeaderLength >= req.RawBytes.Length) return headerBytes; int bodyLen = req.RawBytes.Length - req.HeaderLength; var result = new byte[headerBytes.Length + bodyLen]; Buffer.BlockCopy(headerBytes, 0, result, 0, headerBytes.Length); Buffer.BlockCopy(req.RawBytes, req.HeaderLength, result, headerBytes.Length, bodyLen); return result; } static string BuildUpstreamRequestLine(HttpRequest req, string originalLine) { if (string.IsNullOrWhiteSpace(originalLine)) return originalLine ?? string.Empty; var parts = originalLine.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length < 3) return originalLine; string method = parts[0]; string version = parts[2]; string url = req.Url; if (string.IsNullOrWhiteSpace(url)) return originalLine; // Upstream forward proxy expects absolute-form: METHOD http://host/path HTTP/1.1 return $"{method} {url} {version}"; } static string BuildUpstreamProxyAuthHeader() { if (!FORCE_UPSTREAM_BASIC) return string.Empty; if (string.IsNullOrWhiteSpace(UPSTREAM_USER)) { Log.Warn("FORCE_UPSTREAM_BASIC=1 but UPSTREAM_USER is empty; upstream Basic auth is disabled"); return string.Empty; } string pass = UPSTREAM_PASS ?? string.Empty; string encoded = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{UPSTREAM_USER}:{pass}")); Log.Info("Upstream Basic auth header enabled"); return $"Proxy-Authorization: Basic {encoded}\r\n"; } static string ResolveUpstreamUser() { var fromEnv = Environment.GetEnvironmentVariable("UPSTREAM_USER"); if (!string.IsNullOrWhiteSpace(fromEnv)) return fromEnv; var domain = Environment.UserDomainName; var user = Environment.UserName; if (!string.IsNullOrWhiteSpace(domain) && !string.IsNullOrWhiteSpace(user)) return $"{domain}\\{user}"; return user ?? string.Empty; } static void LogUpstreamUser(long connectionId, string proxyHost) { if (string.Equals(proxyHost, "proxy.syktsu.ru", StringComparison.OrdinalIgnoreCase)) { Log.Info($"[conn:{connectionId}] upstream auth login for {proxyHost}: '{UPSTREAM_USER}' (password: empty)"); } } static async Task PerformUpstreamConnectHandshake( NetworkStream upstreamStream, string targetHost, int targetPort, string proxyHost, long connectionId) { // 1) First CONNECT without auth (browser-like flow). await SendConnectRequest(upstreamStream, targetHost, targetPort, null); var resp = await ReadHeaders(upstreamStream, $"conn:{connectionId} upstream-connect-response-1"); int code = ParseStatusCode(resp?.Raw); if (code == 200 || resp == null) return resp; var challenges = ParseProxyAuthenticateValues(resp.Raw); Log.Info($"[conn:{connectionId}] upstream challenges: {string.Join(", ", challenges)}"); if (code != 407 || challenges.Count == 0) return resp; await DrainResponseBodyIfAny(upstreamStream, resp.Raw, connectionId, "initial-407"); // 2) Basic fallback. if (!string.IsNullOrEmpty(UPSTREAM_PROXY_AUTH_HEADER)) { await SendConnectRequest(upstreamStream, targetHost, targetPort, UPSTREAM_PROXY_AUTH_HEADER.TrimEnd('\r', '\n')); var basicResp = await ReadHeaders(upstreamStream, $"conn:{connectionId} upstream-connect-response-basic"); if (ParseStatusCode(basicResp?.Raw) == 200) return basicResp; resp = basicResp ?? resp; challenges = ParseProxyAuthenticateValues(resp.Raw); Log.Info($"[conn:{connectionId}] upstream challenges after basic: {string.Join(", ", challenges)}"); await DrainResponseBodyIfAny(upstreamStream, resp.Raw, connectionId, "basic-407"); } // 3) Negotiate/NTLM over SSPI (as browsers do for AD proxies). bool hasNegotiate = challenges.Any(c => c.StartsWith("Negotiate", StringComparison.OrdinalIgnoreCase)); bool hasNtlm = challenges.Any(c => c.StartsWith("NTLM", StringComparison.OrdinalIgnoreCase)); if (!hasNegotiate && !hasNtlm) return resp; string scheme = hasNegotiate ? "Negotiate" : "NTLM"; using var sspi = new SspiProxyAuthenticator(hasNegotiate ? "Negotiate" : "NTLM", proxyHost); string inToken = ExtractSchemeToken(challenges, scheme); for (int step = 0; step < 4; step++) { string outToken = sspi.NextToken(inToken); if (string.IsNullOrEmpty(outToken)) break; await SendConnectRequest( upstreamStream, targetHost, targetPort, $"Proxy-Authorization: {scheme} {outToken}"); var authResp = await ReadHeaders(upstreamStream, $"conn:{connectionId} upstream-connect-response-{scheme}-{step + 1}"); int authCode = ParseStatusCode(authResp?.Raw); if (authCode == 200) return authResp; if (authCode != 407 || authResp == null) return authResp ?? resp; challenges = ParseProxyAuthenticateValues(authResp.Raw); Log.Info($"[conn:{connectionId}] upstream challenges after {scheme} step {step + 1}: {string.Join(", ", challenges)}"); inToken = ExtractSchemeToken(challenges, scheme); await DrainResponseBodyIfAny(upstreamStream, authResp.Raw, connectionId, $"{scheme}-{step + 1}-407"); resp = authResp; } return resp; } static async Task SendConnectRequest(NetworkStream stream, string host, int port, string proxyAuthHeader) { var sb = new StringBuilder(); sb.Append($"CONNECT {host}:{port} HTTP/1.1\r\n"); sb.Append($"Host: {host}:{port}\r\n"); sb.Append("Proxy-Connection: Keep-Alive\r\n"); if (!string.IsNullOrWhiteSpace(proxyAuthHeader)) sb.Append(proxyAuthHeader).Append("\r\n"); sb.Append("\r\n"); await stream.WriteAsync(Encoding.ASCII.GetBytes(sb.ToString())); } static List ParseProxyAuthenticateValues(string rawHeaders) { var result = new List(); if (string.IsNullOrWhiteSpace(rawHeaders)) return result; foreach (var line in rawHeaders.Split("\r\n", StringSplitOptions.None)) { if (line.StartsWith("Proxy-Authenticate:", StringComparison.OrdinalIgnoreCase)) { var value = line.Substring("Proxy-Authenticate:".Length).Trim(); if (value.Length > 0) result.Add(value); } } return result; } static string ExtractSchemeToken(List values, string scheme) { foreach (var v in values) { if (!v.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)) continue; var parts = v.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 2) return parts[1].Trim(); return null; } return null; } static async Task DrainResponseBodyIfAny(NetworkStream stream, string rawHeaders, long connectionId, string stage) { if (string.IsNullOrWhiteSpace(rawHeaders)) return; int contentLength = 0; bool chunked = false; foreach (var line in rawHeaders.Split("\r\n", StringSplitOptions.None)) { if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) { var value = line.Substring("Content-Length:".Length).Trim(); if (int.TryParse(value, out var len) && len > 0) contentLength = len; } if (line.StartsWith("Transfer-Encoding:", StringComparison.OrdinalIgnoreCase) && line.IndexOf("chunked", StringComparison.OrdinalIgnoreCase) >= 0) { chunked = true; } } if (contentLength > 0) { await ReadExactly(stream, contentLength); Log.Info($"[conn:{connectionId}] drained {contentLength} response bytes ({stage})"); return; } if (chunked) { int drained = await DrainChunkedBody(stream); Log.Info($"[conn:{connectionId}] drained chunked body bytes={drained} ({stage})"); } } static async Task ReadExactly(Stream stream, int count) { var buffer = ArrayPool.Shared.Rent(2048); try { int remain = count; while (remain > 0) { int read = await stream.ReadAsync(buffer, 0, Math.Min(buffer.Length, remain)); if (read <= 0) break; remain -= read; } } finally { ArrayPool.Shared.Return(buffer); } } static async Task DrainChunkedBody(Stream stream) { int total = 0; while (true) { string sizeLine = await ReadAsciiLine(stream); if (string.IsNullOrWhiteSpace(sizeLine)) continue; string hex = sizeLine.Split(';', 2)[0].Trim(); if (!int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out int size)) break; if (size == 0) { await ReadAsciiLine(stream); // trailing CRLF after last chunk/trailers simplified break; } await ReadExactly(stream, size); total += size; await ReadExactly(stream, 2); // CRLF } return total; } static async Task ReadAsciiLine(Stream stream) { var bytes = new List(64); var one = new byte[1]; while (bytes.Count < 8192) { int read = await stream.ReadAsync(one, 0, 1); if (read <= 0) break; bytes.Add(one[0]); int n = bytes.Count; if (n >= 2 && bytes[n - 2] == '\r' && bytes[n - 1] == '\n') break; } if (bytes.Count == 0) return string.Empty; return Encoding.ASCII.GetString(bytes.ToArray()).TrimEnd('\r', '\n'); } } sealed class SspiProxyAuthenticator : IDisposable { const int SECPKG_CRED_OUTBOUND = 2; const int SECURITY_NATIVE_DREP = 0x00000010; const int ISC_REQ_CONNECTION = 0x00000800; const int ISC_REQ_ALLOCATE_MEMORY = 0x00000100; const int SECBUFFER_VERSION = 0; const int SECBUFFER_TOKEN = 2; const uint SEC_I_CONTINUE_NEEDED = 0x00090312; const uint SEC_E_OK = 0x00000000; SecHandle _cred; SecHandle _ctx; bool _credAcquired; bool _ctxInitialized; readonly string _package; readonly string _targetSpn; uint _lastStatus; public SspiProxyAuthenticator(string package, string proxyHost) { _package = package; _targetSpn = $"HTTP/{proxyHost}"; AcquireCredentials(); } public string NextToken(string incomingBase64Token) { if (!_credAcquired) { Log.Error($"SSPI AcquireCredentialsHandle failed for package {_package} (status=0x{_lastStatus:X8})"); return null; } byte[] inBytes = null; if (!string.IsNullOrWhiteSpace(incomingBase64Token)) { try { inBytes = Convert.FromBase64String(incomingBase64Token); } catch { inBytes = null; } } var outBuffer = new SecBuffer { cbBuffer = 0, BufferType = SECBUFFER_TOKEN, pvBuffer = IntPtr.Zero }; var outDesc = new SecBufferDesc(); IntPtr outBufPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); try { Marshal.StructureToPtr(outBuffer, outBufPtr, false); outDesc.ulVersion = SECBUFFER_VERSION; outDesc.cBuffers = 1; outDesc.pBuffers = outBufPtr; uint attrs; FILETIME expiry; uint status; IntPtr inDescPtr = IntPtr.Zero; IntPtr inBufPtr = IntPtr.Zero; IntPtr inTokenPtr = IntPtr.Zero; try { if (inBytes != null && inBytes.Length > 0) { inTokenPtr = Marshal.AllocHGlobal(inBytes.Length); Marshal.Copy(inBytes, 0, inTokenPtr, inBytes.Length); var inBuf = new SecBuffer { cbBuffer = inBytes.Length, BufferType = SECBUFFER_TOKEN, pvBuffer = inTokenPtr }; inBufPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); Marshal.StructureToPtr(inBuf, inBufPtr, false); var inDesc = new SecBufferDesc { ulVersion = SECBUFFER_VERSION, cBuffers = 1, pBuffers = inBufPtr }; inDescPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); Marshal.StructureToPtr(inDesc, inDescPtr, false); } if (_ctxInitialized) { status = InitializeSecurityContext( ref _cred, ref _ctx, _targetSpn, ISC_REQ_CONNECTION | ISC_REQ_ALLOCATE_MEMORY, 0, SECURITY_NATIVE_DREP, inDescPtr, 0, out _ctx, out outDesc, out attrs, out expiry); } else { status = InitializeSecurityContext( ref _cred, IntPtr.Zero, _targetSpn, ISC_REQ_CONNECTION | ISC_REQ_ALLOCATE_MEMORY, 0, SECURITY_NATIVE_DREP, inDescPtr, 0, out _ctx, out outDesc, out attrs, out expiry); } } finally { if (inTokenPtr != IntPtr.Zero) Marshal.FreeHGlobal(inTokenPtr); if (inBufPtr != IntPtr.Zero) Marshal.FreeHGlobal(inBufPtr); if (inDescPtr != IntPtr.Zero) Marshal.FreeHGlobal(inDescPtr); } _ctxInitialized = true; if (status != SEC_E_OK && status != SEC_I_CONTINUE_NEEDED) { Log.Warn($"SSPI InitializeSecurityContext failed (package={_package}, status=0x{status:X8})"); return null; } var finalOut = Marshal.PtrToStructure(outBufPtr); if (finalOut.cbBuffer <= 0 || finalOut.pvBuffer == IntPtr.Zero) { Log.Warn($"SSPI InitializeSecurityContext returned empty token (package={_package}, status=0x{status:X8})"); return null; } var token = new byte[finalOut.cbBuffer]; Marshal.Copy(finalOut.pvBuffer, token, 0, token.Length); FreeContextBuffer(finalOut.pvBuffer); return Convert.ToBase64String(token); } finally { Marshal.FreeHGlobal(outBufPtr); } } void AcquireCredentials() { FILETIME expiry; uint status = AcquireCredentialsHandle( null, _package, SECPKG_CRED_OUTBOUND, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, out _cred, out expiry); _lastStatus = status; _credAcquired = status == SEC_E_OK; } public void Dispose() { if (_ctxInitialized) DeleteSecurityContext(ref _ctx); if (_credAcquired) FreeCredentialsHandle(ref _cred); } [DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)] static extern uint AcquireCredentialsHandle( string pszPrincipal, string pszPackage, int fCredentialUse, IntPtr pvLogonId, IntPtr pAuthData, IntPtr pGetKeyFn, IntPtr pvGetKeyArgument, out SecHandle phCredential, out FILETIME ptsExpiry); [DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)] static extern uint InitializeSecurityContext( ref SecHandle phCredential, IntPtr phContext, string pszTargetName, int fContextReq, int Reserved1, int TargetDataRep, IntPtr pInput, int Reserved2, out SecHandle phNewContext, out SecBufferDesc pOutput, out uint pfContextAttr, out FILETIME ptsExpiry); [DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)] static extern uint InitializeSecurityContext( ref SecHandle phCredential, ref SecHandle phContext, string pszTargetName, int fContextReq, int Reserved1, int TargetDataRep, IntPtr pInput, int Reserved2, out SecHandle phNewContext, out SecBufferDesc pOutput, out uint pfContextAttr, out FILETIME ptsExpiry); [DllImport("secur32.dll", SetLastError = false)] static extern uint DeleteSecurityContext(ref SecHandle phContext); [DllImport("secur32.dll", SetLastError = false)] static extern uint FreeCredentialsHandle(ref SecHandle phCredential); [DllImport("secur32.dll", SetLastError = false)] static extern uint FreeContextBuffer(IntPtr pvContextBuffer); [StructLayout(LayoutKind.Sequential)] struct SecHandle { public IntPtr dwLower; public IntPtr dwUpper; } [StructLayout(LayoutKind.Sequential)] struct SecBuffer { public int cbBuffer; public int BufferType; public IntPtr pvBuffer; } [StructLayout(LayoutKind.Sequential)] struct SecBufferDesc { public int ulVersion; public int cBuffers; public IntPtr pBuffers; } } // ================= WINHTTP ================= static class WinHttpHelper { const int WINHTTP_ACCESS_TYPE_NO_PROXY = 1; const int WINHTTP_AUTOPROXY_AUTO_DETECT = 0x00000001; const int WINHTTP_AUTOPROXY_CONFIG_URL = 0x00000002; const int WINHTTP_AUTO_DETECT_TYPE_DHCP = 0x00000001; const int WINHTTP_AUTO_DETECT_TYPE_DNS_A = 0x00000002; [DllImport("winhttp.dll", SetLastError = true)] static extern IntPtr WinHttpOpen(string agent, int accessType, string proxy, string bypass, int flags); [DllImport("winhttp.dll", SetLastError = true)] static extern bool WinHttpGetProxyForUrl( IntPtr hSession, string url, ref WINHTTP_AUTOPROXY_OPTIONS options, out WINHTTP_PROXY_INFO proxyInfo); [DllImport("winhttp.dll")] static extern bool WinHttpCloseHandle(IntPtr handle); [DllImport("winhttp.dll", SetLastError = true)] static extern bool WinHttpGetDefaultProxyConfiguration(out WINHTTP_PROXY_INFO proxyInfo); [DllImport("winhttp.dll", SetLastError = true)] static extern bool WinHttpGetIEProxyConfigForCurrentUser(out WINHTTP_CURRENT_USER_IE_PROXY_CONFIG proxyConfig); [DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr GlobalFree(IntPtr hMem); static readonly Uri ForcedUpstreamProxy = ReadForcedUpstreamProxy(); public static Uri GetProxyForUrl(Uri url) { if (ForcedUpstreamProxy != null) { Log.Info($"Using forced upstream proxy {ForcedUpstreamProxy.Host}:{ForcedUpstreamProxy.Port} for {url}"); return ForcedUpstreamProxy; } IntPtr session = WinHttpOpen("proxy", WINHTTP_ACCESS_TYPE_NO_PROXY, null, null, 0); if (session == IntPtr.Zero) { Log.Error($"WinHttpOpen failed (Win32={Marshal.GetLastWin32Error()})"); return null; } WINHTTP_PROXY_INFO autoInfo = default; WINHTTP_PROXY_INFO defaultInfo = default; WINHTTP_CURRENT_USER_IE_PROXY_CONFIG ieConfig = default; try { if (WinHttpGetIEProxyConfigForCurrentUser(out ieConfig)) { var autoConfigUrl = PtrToString(ieConfig.lpszAutoConfigUrl); bool hasSupportedAutoConfigUrl = !string.IsNullOrWhiteSpace(autoConfigUrl) && (autoConfigUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || autoConfigUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(autoConfigUrl) && !hasSupportedAutoConfigUrl) { Log.Warn($"IE PAC URL has unsupported scheme for WinHTTP: {autoConfigUrl}"); } var options = new WINHTTP_AUTOPROXY_OPTIONS { dwFlags = 0, dwAutoDetectFlags = WINHTTP_AUTO_DETECT_TYPE_DHCP | WINHTTP_AUTO_DETECT_TYPE_DNS_A, lpszAutoConfigUrl = hasSupportedAutoConfigUrl ? ieConfig.lpszAutoConfigUrl : IntPtr.Zero, fAutoLogonIfChallenged = true }; if (hasSupportedAutoConfigUrl) options.dwFlags |= WINHTTP_AUTOPROXY_CONFIG_URL; if (ieConfig.fAutoDetect) options.dwFlags |= WINHTTP_AUTOPROXY_AUTO_DETECT; if (options.dwFlags != 0) { if (WinHttpGetProxyForUrl(session, url.ToString(), ref options, out autoInfo)) { var proxyUri = ParseProxyInfo(autoInfo, url.Scheme); if (proxyUri != null) { return proxyUri; } } else { int err = Marshal.GetLastWin32Error(); Log.Warn($"WinHttpGetProxyForUrl failed for {url} (Win32={err})"); } } var ieStaticProxyRaw = PtrToString(ieConfig.lpszProxy); var ieStaticProxy = ParseProxyString(ieStaticProxyRaw, url.Scheme); if (ieStaticProxy != null) { Log.Info($"Using IE static proxy {ieStaticProxy.Host}:{ieStaticProxy.Port} for {url}"); return ieStaticProxy; } } else { Log.Warn($"WinHttpGetIEProxyConfigForCurrentUser failed (Win32={Marshal.GetLastWin32Error()})"); } if (WinHttpGetDefaultProxyConfiguration(out defaultInfo)) { var fallbackProxy = ParseProxyInfo(defaultInfo, url.Scheme); if (fallbackProxy != null) { Log.Info($"Using default WinHTTP proxy {fallbackProxy.Host}:{fallbackProxy.Port} for {url}"); return fallbackProxy; } } else { Log.Warn($"WinHttpGetDefaultProxyConfiguration failed (Win32={Marshal.GetLastWin32Error()})"); } Log.Error($"No system upstream proxy resolved for {url}"); return null; } finally { FreeWinHttpProxyInfo(autoInfo); FreeWinHttpProxyInfo(defaultInfo); FreeIeConfig(ieConfig); WinHttpCloseHandle(session); } } static string PtrToString(IntPtr ptr) { if (ptr == IntPtr.Zero) return null; return Marshal.PtrToStringUni(ptr); } static Uri ParseProxyInfo(WINHTTP_PROXY_INFO info, string scheme) { var raw = PtrToString(info.lpszProxy); return ParseProxyString(raw, scheme); } static Uri ParseProxyString(string raw, string scheme) { if (string.IsNullOrWhiteSpace(raw)) return null; var parts = raw.Split(';', StringSplitOptions.RemoveEmptyEntries) .Select(p => p.Trim()) .ToArray(); string schemeToken = parts.FirstOrDefault(p => p.StartsWith($"{scheme}=", StringComparison.OrdinalIgnoreCase)); string genericToken = parts.FirstOrDefault(p => p.StartsWith("http=", StringComparison.OrdinalIgnoreCase)) ?? parts.FirstOrDefault(p => p.StartsWith("https=", StringComparison.OrdinalIgnoreCase)); string first = parts.FirstOrDefault(); string token = schemeToken ?? genericToken ?? first; if (string.IsNullOrWhiteSpace(token)) return null; var value = token.Contains('=') ? token.Split('=', 2)[1].Trim() : token; if (value.StartsWith("PROXY ", StringComparison.OrdinalIgnoreCase)) value = value.Substring(6).Trim(); if (value.Equals("DIRECT", StringComparison.OrdinalIgnoreCase)) return null; var normalized = value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? value : "http://" + value; if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) || string.IsNullOrWhiteSpace(uri.Host)) { Log.Warn($"Cannot parse proxy value '{raw}'"); return null; } return uri; } static Uri ReadForcedUpstreamProxy() { var value = Environment.GetEnvironmentVariable("UPSTREAM_PROXY"); if (string.IsNullOrWhiteSpace(value)) return null; var normalized = value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? value : "http://" + value; if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) || string.IsNullOrWhiteSpace(uri.Host)) { Log.Error($"Invalid UPSTREAM_PROXY value: '{value}'"); return null; } return uri; } static void FreeWinHttpProxyInfo(WINHTTP_PROXY_INFO info) { if (info.lpszProxy != IntPtr.Zero) GlobalFree(info.lpszProxy); if (info.lpszProxyBypass != IntPtr.Zero) GlobalFree(info.lpszProxyBypass); } static void FreeIeConfig(WINHTTP_CURRENT_USER_IE_PROXY_CONFIG config) { if (config.lpszAutoConfigUrl != IntPtr.Zero) GlobalFree(config.lpszAutoConfigUrl); if (config.lpszProxy != IntPtr.Zero) GlobalFree(config.lpszProxy); if (config.lpszProxyBypass != IntPtr.Zero) GlobalFree(config.lpszProxyBypass); } struct WINHTTP_AUTOPROXY_OPTIONS { public int dwFlags; public int dwAutoDetectFlags; public IntPtr lpszAutoConfigUrl; public IntPtr lpvReserved; public int dwReserved; public bool fAutoLogonIfChallenged; } struct WINHTTP_PROXY_INFO { public int dwAccessType; public IntPtr lpszProxy; public IntPtr lpszProxyBypass; } struct WINHTTP_CURRENT_USER_IE_PROXY_CONFIG { [MarshalAs(UnmanagedType.Bool)] public bool fAutoDetect; public IntPtr lpszAutoConfigUrl; public IntPtr lpszProxy; public IntPtr lpszProxyBypass; } } static class Log { static readonly object _sync = new(); public static void Info(string message) => Write("INF", message); public static void Warn(string message) => Write("WRN", message); public static void Error(string message, Exception ex = null) { Write("ERR", ex == null ? message : $"{message}{Environment.NewLine}{ex}"); } static void Write(string level, string message) { lock (_sync) { Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{level}] {message}"); } } }