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>
This commit is contained in:
2026-10-25 19:04:24 -07:00
parent ce5fb9e11c
commit a6447ecb37
10 changed files with 227 additions and 24 deletions

View File

@@ -214,7 +214,9 @@ namespace AyaNova.Api.Controllers
return BadRequest(new ApiErrorResponse(ModelState));
var httpConnectionFeature = HttpContext.Features.Get<IHttpConnectionFeature>();
var API_URL = $"http://127.0.0.1:{httpConnectionFeature.LocalPort}/api/{AyaNovaVersion.CurrentApiVersion}/";
var result = await biz.RequestRenderReport(reportRequest, DateTime.UtcNow.AddMinutes(ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT), API_URL, UserNameFromContext.Name(HttpContext.Items));
//case 4632: inside IIS the report page's API calls are relayed to where this request arrived
var relayTarget = ServerBootConfig.HOSTED_IN_IIS ? ReportApiRelay.FromRequest(HttpContext) : null;
var result = await biz.RequestRenderReport(reportRequest, DateTime.UtcNow.AddMinutes(ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT), API_URL, UserNameFromContext.Name(HttpContext.Items), relayTarget);
if (result == null)
return BadRequest(new ApiErrorResponse(biz.Errors));
else

View File

@@ -325,6 +325,11 @@ namespace AyaNova
//Store a reference to the dependency injection service for static classes
ServiceProviderProvider.Provider = app.ApplicationServices;
//case 4632: inside IIS the report page's API calls are relayed (ReportApiRelay)
ServerBootConfig.HOSTED_IN_IIS = app.ApplicationServices.GetService<Microsoft.AspNetCore.Hosting.Server.IServer>()?.GetType().FullName?.Contains(".IIS.", StringComparison.Ordinal) == true;
if (ServerBootConfig.HOSTED_IN_IIS)
_newLog.LogInformation("Running inside IIS: report API calls are relayed to the address of the users' requests");
//Enable ability to handle reverse proxy
//case 4732: a proxy on this computer (loopback) is always trusted; proxies on other computers only when named in AYANOVA_TRUSTED_PROXIES
var forwardedHeadersOptions = new ForwardedHeadersOptions
@@ -472,23 +477,21 @@ namespace AyaNova
{
//It may be a local report render request from a job
//case 4632: from this machine only; the host name is no longer required to be 127.0.0.1, inside IIS the
//request is relayed to the site's own address and host name (ReportApiRelay)
bool bLocalReportRenderRequest = false;
if (context.Request.Host.Host == "127.0.0.1")
if (context.Request.IsLocal())
{
//check if token has j value set
if (context.Request.IsLocal())
//if this is a generate report internal token this will be the override language set
//and it's existance is enough to indicate it was set internally by this server
var rpl = context.User.Claims.FirstOrDefault(c => c.Type == "rpl");
if (rpl != null)
{
//if this is a generate report internal token this will be the override language set
//and it's existance is enough to indicate it was set internally by this server
var rpl = context.User.Claims.FirstOrDefault(c => c.Type == "rpl");
if (rpl != null)
{
//it's a local request, the jwt already passed earlier so we issued it and it has the correct claim set so allow it
bLocalReportRenderRequest = true;
//set override language
context.Request.HttpContext.Items["AY_TRANSLATION_ID"] = Convert.ToInt64(rpl.Value);
await next.Invoke();
}
//it's a local request, the jwt already passed earlier so we issued it and it has the correct claim set so allow it
bLocalReportRenderRequest = true;
//set override language
context.Request.HttpContext.Items["AY_TRANSLATION_ID"] = Convert.ToInt64(rpl.Value);
await next.Invoke();
}
}
if (!bLocalReportRenderRequest)
@@ -517,6 +520,8 @@ namespace AyaNova
}));
return;
}
//case 4632: inside IIS, where this signed-in request arrived is where notification reports send their API calls
ReportApiRelay.Remember(context);
await next.Invoke();
}
}

View File

@@ -518,7 +518,8 @@ namespace AyaNova.Biz
DataListReportRequest reportRequest,
DateTime renderTimeOutExpiry,
string apiUrl,
string userName
string userName,
ReportApiTarget relayTarget = null
)
{
var log = AyaNova.Util.ApplicationLogging.CreateLogger(
@@ -676,6 +677,7 @@ What could be the issue here?
requestIsCustomerWorkOrderReport = RequestIsCustomerWorkOrderReport,
apiUrl = apiUrl,
userName = userName,
relayTarget = relayTarget,
}
);
@@ -711,6 +713,8 @@ What could be the issue here?
.ToObject<bool>();
var apiUrl = jobData["apiUrl"].ToObject<string>();
var userName = jobData["userName"].ToObject<string>();
//case 4632: set when running inside IIS, see ReportApiRelay
var relayTarget = jobData["relayTarget"]?.Type == JTokenType.Object ? jobData["relayTarget"].ToObject<ReportApiTarget>() : null;
var report = await ct.Report.FirstOrDefaultAsync(z => z.Id == reportRequest.ReportId);
if (report == null)
@@ -910,6 +914,7 @@ What could be the issue here?
await HandleBrowserFailure(job, log, $"started from \"{lo.ExecutablePath}\" with arguments '{string.Join(" ", lo.Args)}' (on Linux check for missing libraries with ldd)", ex);
return;
}
IDisposable relayClient = null;
using (var browser = launchedBrowser)
using (var page = (await browser.PagesAsync())[0])
{
@@ -946,6 +951,13 @@ What could be the issue here?
}
};
//case 4632: inside IIS the page's API calls are relayed by the server, see ReportApiRelay
if (relayTarget != null)
{
log.LogDebug($"Relaying report API calls under {apiUrl} to {relayTarget}");
relayClient = await ReportApiRelay.AttachAsync(page, apiUrl, relayTarget, TimeSpan.FromMinutes(ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT), s => PageLog.AppendLine(s), log);
}
log.LogDebug($"Preparing page: adding base reporting scripts to page");
if (!ReportRenderManager.KeepGoing(job.GId))
return;
@@ -1296,6 +1308,7 @@ What could be the issue here?
$"Calling ReportRenderManager.RemoveJob to stop tracking this job/process"
);
await ReportRenderManager.RemoveJob(job.GId, log, false);
relayClient?.Dispose();
}
}

View File

@@ -336,7 +336,9 @@ namespace AyaNova.Biz
ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT
),
API_URL,
ServerBootConfig.CUSTOMER_NOTIFICATION_ATTACHED_REPORT_RENDER_USERNAME
ServerBootConfig.CUSTOMER_NOTIFICATION_ATTACHED_REPORT_RENDER_USERNAME,
//case 4632: inside IIS nothing listens on that port, the API calls are relayed to where users' requests arrive
ReportApiRelay.ForBackgroundRender()
);
if (jobid == null)
{

View File

@@ -0,0 +1,176 @@
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;
}
}
}

View File

@@ -28,6 +28,9 @@ namespace AyaNova.Util
internal const string CUSTOMER_NOTIFICATION_ATTACHED_REPORT_RENDER_USERNAME =
"CUSTOMER NOTIFICATION - NO USER";
//case 4632: true when running inside IIS (in-process hosting), where the report page's API calls are relayed (ReportApiRelay)
internal static bool HOSTED_IN_IIS { get; set; }
//############################################################################################################
//############################