using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using PuppeteerSharp; namespace AyaNova.Util { /// /// Where the report page's API calls are sent when AyaNova runs inside IIS: the address and port a user's request /// arrived on, with the host name and scheme of that request /// internal sealed class ReportApiTarget { public string Scheme { get; set; } public string Host { get; set; }//host header value, with the port when the request had one public string Address { get; set; } public int Port { get; set; } internal string BaseUrl => $"{Scheme}://{Host}/api/{AyaNovaVersion.CurrentApiVersion}/"; public override string ToString() => $"{Scheme}://{Host} via {Address}:{Port}"; } /// /// case 4632: inside IIS (in-process hosting) AyaNova has no port of its own: nothing listens on AYANOVA_USE_URLS, and /// the port of the user's request is IIS's, often https-only and bound to a host name. So the report page could not /// reach the API at http://127.0.0.1:port (user reports, or notification reports at the AYANOVA_USE_URLS port), and the /// override setting only helped where the server can reach itself by its public name. Under IIS the report page's /// requests to the API URL are intercepted and made here instead, to the local address and port a user's request /// arrived on, with that request's host name and scheme: the IIS binding and certificate match, and no DNS or router /// is involved. Outside IIS nothing changes (the page calls http://127.0.0.1:port itself, which works with Kestrel) /// internal static class ReportApiRelay { //path the page is first opened at, so it has the API's origin and its API calls are same-origin (no CORS preflight, //which the browser would send itself, past the interception) private const string PAGE_PATH = "ay-report-render-page"; private static volatile ReportApiTarget _lastSeen; /// /// The address, port, host name and scheme this request arrived with, for relaying a report started by it /// internal static ReportApiTarget FromRequest(HttpContext context) { var address = context.Connection.LocalIpAddress; if (address == null) return null; if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4(); //the IIS server variable says if this connection is TLS; Request.Scheme may come from a proxy's X-Forwarded-Proto var https = string.Equals(context.GetServerVariable("HTTPS"), "on", StringComparison.OrdinalIgnoreCase); return new ReportApiTarget { Scheme = https ? "https" : "http", Host = context.Request.Host.HasValue ? context.Request.Host.Value : address.ToString(), Address = address.ToString(), Port = context.Connection.LocalPort }; } /// /// Keeps the target of the latest signed-in request, for reports rendered without one (customer notifications) /// internal static void Remember(HttpContext context) { if (!ServerBootConfig.HOSTED_IN_IIS) return; var target = FromRequest(context); var last = _lastSeen; if (target != null && (last == null || last.ToString() != target.ToString())) _lastSeen = target; } /// /// Target for a report rendered without a user request: the latest signed-in request, else the override URL /// sent to this machine; null outside IIS or when neither is known /// internal static ReportApiTarget ForBackgroundRender() { if (!ServerBootConfig.HOSTED_IN_IIS) return null; if (_lastSeen != null) return _lastSeen; if (Uri.TryCreate(ServerBootConfig.AYANOVA_REPORT_RENDER_API_URL_OVERRIDE?.Trim(), UriKind.Absolute, out var o)) return new ReportApiTarget { Scheme = o.Scheme, Host = o.Authority, Address = IPAddress.Loopback.ToString(), Port = o.Port }; return null; } /// /// Opens the page at the API URL's origin and relays the page's requests under the API URL to the target. /// Returns the HTTP client to dispose when the page is done /// internal static async Task AttachAsync(IPage page, string apiUrl, ReportApiTarget target, TimeSpan timeout, Action pageLog, ILogger log) { var handler = new SocketsHttpHandler { AllowAutoRedirect = false, UseCookies = false, UseProxy = false, //always the local address, whatever the URL's host name resolves to; TLS still checks the certificate for that name ConnectCallback = async (context, cancel) => { var address = IPAddress.Parse(target.Address); var socket = new Socket(address.AddressFamily, SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp) { NoDelay = true }; try { await socket.ConnectAsync(new IPEndPoint(address, target.Port), cancel); return new NetworkStream(socket, ownsSocket: true); } catch { socket.Dispose(); throw; } } }; var client = new HttpClient(handler) { Timeout = timeout }; var pageUrl = apiUrl + PAGE_PATH; await page.SetRequestInterceptionAsync(true); page.Request += async (sender, e) => { var request = e.Request; try { if (request.Url == pageUrl) { await request.RespondAsync(new ResponseData { Status = HttpStatusCode.OK, ContentType = "text/html", Body = "" }); return; } if (!request.Url.StartsWith(apiUrl, StringComparison.OrdinalIgnoreCase)) { await request.ContinueAsync(); return; } using var message = new HttpRequestMessage(request.Method, target.BaseUrl + request.Url.Substring(apiUrl.Length)); if (request.HasPostData) message.Content = new StringContent(request.PostData ?? await request.FetchPostDataAsync() ?? string.Empty); foreach (var header in request.Headers) { if (header.Key.Equals("Host", StringComparison.OrdinalIgnoreCase) || header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) continue; if (!message.Headers.TryAddWithoutValidation(header.Key, header.Value) && message.Content != null) { message.Content.Headers.Remove(header.Key); message.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); } } using var response = await client.SendAsync(message); var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var header in response.Headers.Concat(response.Content.Headers)) { if (header.Key.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) || header.Key.Equals("Connection", StringComparison.OrdinalIgnoreCase) || header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) continue; headers[header.Key] = string.Join(", ", header.Value); } await request.RespondAsync(new ResponseData { Status = response.StatusCode, Headers = headers, BodyData = await response.Content.ReadAsByteArrayAsync() }); } catch (Exception ex) { var reason = ex.InnerException != null ? $"{ex.Message} ({ex.InnerException.Message})" : ex.Message; pageLog($"ERROR: AyaNova API request {request.Method} {request.Url} via {target} failed: {reason}"); log.LogWarning($"Report API request {request.Method} {request.Url} via {target} failed: {reason}"); try { await request.AbortAsync(); } catch { } } }; await page.GoToAsync(pageUrl); return client; } } }