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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
176
server/AyaNova/util/ReportApiRelay.cs
Normal file
176
server/AyaNova/util/ReportApiRelay.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
//############################################################################################################
|
||||
|
||||
//############################
|
||||
|
||||
Reference in New Issue
Block a user