Files
raven/server/AyaNova/util/ReportApiRelay.cs
John Cardinal a6447ecb37 case 4632 - reports render under IIS: the report page's API calls are relayed by the server
Inside IIS (in-process) AyaNova has no port of its own, so the report page
could not reach the API: user reports called http://127.0.0.1:<request port>
(reset on https), notification reports 127.0.0.1:7575 (refused). Under IIS the
page's requests under the API URL are now intercepted and made by the server
(util/ReportApiRelay.cs) to the local address and port a user's request
arrived on, with its host name and scheme; notification reports use the
latest signed-in request, else the override. Outside IIS nothing changes.
The notification token check requires a request from this machine, no longer
host 127.0.0.1. Manual (IIS install, override page) and changelog updated.
Deferred: emailed PDF content type application/pdf/pdf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:04:24 -07:00

177 lines
9.1 KiB
C#

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
{
/// <summary>
/// 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
/// </summary>
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}";
}
/// <summary>
/// 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)
/// </summary>
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;
/// <summary>
/// The address, port, host name and scheme this request arrived with, for relaying a report started by it
/// </summary>
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
};
}
/// <summary>
/// Keeps the target of the latest signed-in request, for reports rendered without one (customer notifications)
/// </summary>
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;
}
/// <summary>
/// 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
/// </summary>
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;
}
/// <summary>
/// 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
/// </summary>
internal static async Task<IDisposable> AttachAsync(IPage page, string apiUrl, ReportApiTarget target, TimeSpan timeout, Action<string> 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 = "<!DOCTYPE html><html><head></head><body></body></html>" });
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<string, object>(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;
}
}
}