Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs
T
Oliver Kieselbach a70c5de667 feat(plan): rename Enterprise plan to Pro; Pro includes Bootstrap by default and Unrestricted Mode on request
- FeatureEntitlementCatalog: TenantEdition.Pro, canonical tier "pro", legacy
  stored "enterprise" stays readable as Pro (no data migration); new
  entitlements BootstrapIncluded + UnrestrictedModeAvailable
- TenantEntitlementService: IsBootstrapEnabled (Pro-included OR additive GA
  flag) and IsUnrestrictedModeActive (Pro AND GA gate AND tenant toggle,
  read-time - trial expiry/downgrade re-arms guardrails fail-closed)
- All bootstrap endpoint gates + SecurityValidator use the effective check;
  /auth/me and feature-flags expose effective availability values
- PlanManagement accepts community|pro; AlreadyPro; Pro trial wording
- Web: edition.ts accepts legacy "enterprise" (deploy-order safety), Pro
  labels/cards + new Pro feature bullets, effective section gating,
  terms/privacy renamed with Last-updated bump
- Tests: catalog matrix, effective-gate payload tests, helper tests incl.
  trial-expiry re-gate (backend 3692 green, web 612 green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:04:47 +02:00

143 lines
7.6 KiB
C#

using System;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// GET /api/bootstrap/go/{code} — the OOBE bootstrap script endpoint.
/// Customer-facing as <c>irm https://go.autopilotmonitor.com/{code} | iex</c>
/// (Front Door rewrites /{code} on the go domain to this route). Successor of the
/// portal's Next.js /go/[code] route, moved here so the portal can become a static
/// export; validation happens in-process (BootstrapSessionService), no HTTP hop.
///
/// Contract: ALWAYS HTTP 200 with text/plain — error conditions return a small
/// PowerShell script whose Write-Host surfaces the message, so irm | iex shows the
/// error on the OOBE console instead of crashing on a non-2xx status.
/// Cache-Control: no-store comes from NoStoreCacheMiddleware (the script embeds the
/// bearer token); X-Content-Type-Options: nosniff from SecurityHeadersMiddleware.
/// </summary>
public class GetBootstrapScriptFunction
{
private readonly ILogger<GetBootstrapScriptFunction> _logger;
private readonly BootstrapSessionService _bootstrapService;
private readonly RateLimitService _rateLimitService;
private readonly TenantConfigurationService _configService;
// Same gate the portal route enforced: 4-10 alphanumeric chars, checked before
// any service call. Stricter than the length-only check in
// ValidateBootstrapCodeFunction on purpose.
private static readonly Regex CodeFormatRegex = new(
"^[a-z0-9]{4,10}$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
internal static bool IsValidCodeFormat(string? code) =>
!string.IsNullOrEmpty(code) && CodeFormatRegex.IsMatch(code);
public GetBootstrapScriptFunction(
ILogger<GetBootstrapScriptFunction> logger,
BootstrapSessionService bootstrapService,
RateLimitService rateLimitService,
TenantConfigurationService configService)
{
_logger = logger;
_bootstrapService = bootstrapService;
_rateLimitService = rateLimitService;
_configService = configService;
}
[Function("GetBootstrapScript")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "bootstrap/go/{code}")] HttpRequestData req,
string code)
{
try
{
// Rate limit by source IP (prevent brute-force enumeration of short codes).
// Own bucket, deliberately not shared with bootstrap-validate. AFD-aware
// extraction: traffic arrives via go.autopilotmonitor.com, where the trusted
// hop is Front Door egress — X-Azure-ClientIP is honored under FDID proof.
// On breach the response is still a 200 error script (irm | iex must surface
// the message); Retry-After is informational for non-iex clients.
var clientIp = ClientIpExtractor.GetRateLimitClientIp(req);
var rateLimitResult = _rateLimitService.CheckRateLimit($"bootstrap-script:{clientIp}", 20); // 20 req/min
if (!rateLimitResult.IsAllowed)
{
_logger.LogWarning("Bootstrap script rate limit hit from IP {ClientIp}", clientIp);
var limited = await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Rate limit exceeded. Try again later."));
if (rateLimitResult.RetryAfter.HasValue)
limited.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
return limited;
}
if (!IsValidCodeFormat(code))
{
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Invalid bootstrap code format."));
}
var session = await _bootstrapService.ValidateCodeAsync(code);
if (session == null)
{
_logger.LogWarning("Bootstrap script request failed for code {Code} from IP {ClientIp}", code, clientIp);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap code not found, expired, or revoked."));
}
// Feature gate — same generic message as not-found so a disabled tenant is
// indistinguishable from an unknown code (no enumeration oracle).
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
{
_logger.LogWarning("Bootstrap script for code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap code not found, expired, or revoked."));
}
var agentDownloadUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentZipFileName}";
var expiresAtUtc = session.ExpiresAt.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(session.ExpiresAt, DateTimeKind.Utc)
: session.ExpiresAt.ToUniversalTime();
if (!BootstrapScriptValueValidator.TryValidate(
session.TenantId, session.Token, agentDownloadUrl, expiresAtUtc,
out var values, out var reason))
{
// Log the failure category only — never the offending value, to keep
// potential attack payloads out of Application Insights.
_logger.LogWarning("Bootstrap script value validation failed for code {Code}: {Reason}", code, reason);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap response failed validation. Contact support."));
}
var script = OobeBootstrapScriptGenerator.GenerateSuccessScript(values, DateTime.UtcNow);
return await ScriptResponseAsync(req, script);
}
catch (Exception ex)
{
// Never inline exception detail into the script — it would execute on the
// OOBE console and could leak internals.
_logger.LogError(ex, "Error generating bootstrap script for code {Code}", code);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Failed to generate bootstrap script. Please try again."));
}
}
private static async Task<HttpResponseData> ScriptResponseAsync(HttpRequestData req, string script)
{
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
await response.WriteStringAsync(script);
return response;
}
}
}