Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ValidateBootstrapCodeFunction.cs
T
Oliver Kieselbach 26bcab9054 feat: move OOBE bootstrap script endpoint into the backend (go.autopilotmonitor.com)
The portal's Next.js /go/[code] route was the web app's only request-time
server code and blocked the planned static export. The script generation
now lives in the Function App as GET /api/bootstrap/go/{code}
(GetBootstrapScriptFunction), reached by customers as
irm https://go.autopilotmonitor.com/{code} | iex via a Front Door custom
domain whose /* route rewrites onto the API path.

- New Constants.BootstrapGoBaseUrl; CreateBootstrapSessionFunction now
  hands out the go URL (previously unguarded — CreateBootstrapSessionUrlShapeTests
  pins it now). go host added to both URL-guard enforced lists.
- BootstrapScriptValueValidator ports the bootstrapValidation.ts injection
  invariants (exact GUIDs, https + host allowlist + /agent/*.zip path,
  expiry <= 14d) as defense-in-depth for in-process values.
- OobeBootstrapScriptGenerator ports the PS template as a Replace-token
  raw string; golden test pins byte parity with the TS template
  (TestData/oobe-bootstrap-golden.ps1, captured from route.ts).
- Always-HTTP-200 text/plain contract incl. rate limit (own bucket
  bootstrap-script:{ip} 20/min); disabled tenant indistinguishable from
  unknown code. Catalog: PublicAnonymous; NoStoreCacheMiddleware covers
  /api/bootstrap/go/ (script inlines the bearer token).
- Web: BOOTSTRAP_GO_URL registry entry, sessions list shows the go host,
  legacy route marked LEGACY (alive for pre-migration URLs <= 168h,
  removal in the static-export PR).
- Docs: new OKF backend/oobe-bootstrap-go-flow.md, url-registry go
  section, index/log entries.

Backend tests 3611/3611, web vitest 651/651, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:42:56 +02:00

118 lines
5.7 KiB
C#

using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// GET /api/bootstrap/validate/{code} — Validate a bootstrap code and return the token.
/// Anonymous endpoint — legacy consumer is the Next.js /go/{code} route (alive for
/// pre-migration URLs until the static-export cutover); the canonical script path
/// is GetBootstrapScriptFunction, which validates in-process instead.
/// Rate-limited by IP to prevent brute-force enumeration.
/// </summary>
public class ValidateBootstrapCodeFunction
{
private readonly ILogger<ValidateBootstrapCodeFunction> _logger;
private readonly BootstrapSessionService _bootstrapService;
private readonly RateLimitService _rateLimitService;
private readonly TenantConfigurationService _configService;
public ValidateBootstrapCodeFunction(
ILogger<ValidateBootstrapCodeFunction> logger,
BootstrapSessionService bootstrapService,
RateLimitService rateLimitService,
TenantConfigurationService configService)
{
_logger = logger;
_bootstrapService = bootstrapService;
_rateLimitService = rateLimitService;
_configService = configService;
}
[Function("ValidateBootstrapCode")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "bootstrap/validate/{code}")] HttpRequestData req,
string code)
{
try
{
// Rate limit by source IP (prevent brute-force enumeration of short codes).
// Use the rightmost X-Forwarded-For entry — App Service appends the real
// client/proxy hop there; leftmost values are attacker-controlled and would
// let any caller rotate the rate-limit key for free.
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
var rateLimitKey = $"bootstrap-validate:{clientIp}";
var rateLimitResult = _rateLimitService.CheckRateLimit(rateLimitKey, 20); // 20 req/min
if (!rateLimitResult.IsAllowed)
{
var tooMany = req.CreateResponse(HttpStatusCode.TooManyRequests);
if (rateLimitResult.RetryAfter.HasValue)
tooMany.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
await tooMany.WriteAsJsonAsync(new { success = false, message = "Rate limit exceeded. Try again later." });
return tooMany;
}
// Validate code format (6 chars, alphanumeric)
if (string.IsNullOrEmpty(code) || code.Length < 4 || code.Length > 10)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new { success = false, message = "Invalid code format" });
return badReq;
}
var session = await _bootstrapService.ValidateCodeAsync(code);
if (session == null)
{
_logger.LogWarning("Bootstrap code validation failed for code {Code} from IP {ClientIp}", code, clientIp);
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { success = false, message = "Code not found, expired, or revoked" });
return notFound;
}
// Check if bootstrap token feature is enabled for the session's tenant
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!tenantConfig.BootstrapTokenEnabled)
{
_logger.LogWarning("Bootstrap code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
var disabled = req.CreateResponse(HttpStatusCode.NotFound);
await disabled.WriteAsJsonAsync(new { success = false, message = "Code not found, expired, or revoked" });
return disabled;
}
var responseData = new ValidateBootstrapCodeResponse
{
Success = true,
TenantId = session.TenantId,
Token = session.Token,
// Download alias — the portal's bootstrapValidation.ts allowlists this
// host (plus the legacy blob host for transition); keep the two in sync.
AgentDownloadUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentZipFileName}",
ExpiresAt = session.ExpiresAt,
Message = "Bootstrap code validated"
};
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(responseData);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error validating bootstrap code {Code}", code);
var error = req.CreateResponse(HttpStatusCode.InternalServerError);
await error.WriteAsJsonAsync(new { success = false, message = "Validation failed" });
return error;
}
}
}
}