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 the request goes on from the browser as before. /// case 4719: in every kind of install the page holds only placeholders for the renderer's token and download key (a /// template's script could send the real ones anywhere); the real values are put in here, only on requests to the API URL /// 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"; //case 4719: what the page sees instead of the renderer's token and download key internal const string TOKEN_PLACEHOLDER = "ay-report-render-token"; internal const string DOWNLOAD_KEY_PLACEHOLDER = "ay-report-render-download-key"; 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 handles the page's requests under the API URL: puts the real token /// and download key in place of the placeholders, then relays the request to the target (inside IIS) or lets the /// browser make it (target null). Returns the relay's HTTP client to dispose when the page is done (null without a target) /// internal static async Task AttachAsync(IPage page, string apiUrl, ReportApiTarget target, string authorization, string downloadKey, TimeSpan timeout, Action pageLog, ILogger log) { HttpClient client = null; if (target != null) { 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; } } }; client = new HttpClient(handler) { Timeout = timeout }; } var pageUrl = apiUrl + PAGE_PATH; var where = target == null ? "browser" : target.ToString(); 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; } //anywhere else as it is, without credentials (a template may load its own images, fonts or scripts) if (!request.Url.StartsWith(apiUrl, StringComparison.OrdinalIgnoreCase)) { await request.ContinueAsync(); return; } //case 4719: the real token and download key, only here var url = downloadKey == null ? request.Url : request.Url.Replace("t=" + DOWNLOAD_KEY_PLACEHOLDER, "t=" + downloadKey); var requestHeaders = new Dictionary(request.Headers, StringComparer.OrdinalIgnoreCase); if (authorization != null && requestHeaders.TryGetValue("Authorization", out var auth) && auth.Contains(TOKEN_PLACEHOLDER)) requestHeaders["Authorization"] = authorization; if (client == null) { await request.ContinueAsync(new Payload { Url = url, Headers = requestHeaders }); return; } using var message = new HttpRequestMessage(request.Method, target.BaseUrl + url.Substring(apiUrl.Length)); if (request.HasPostData) message.Content = new StringContent(request.PostData ?? await request.FetchPostDataAsync() ?? string.Empty); foreach (var header in requestHeaders) { 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 {where} failed: {reason}"); log.LogWarning($"Report API request {request.Method} {request.Url} via {where} failed: {reason}"); try { await request.AbortAsync(); } catch { } } }; await page.GoToAsync(pageUrl); return client; } } }