mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-26 05:29:43 +00:00
e0ad51b965
Every well-known host -- own subdomains, both blob accounts, the API origin, Graph, Entra login -- now lives in exactly one registry per component: Constants.cs (*BaseUrl) for C#, utils/config.ts for the web app, src/config.ts for the MCP server. lib/hostRouting.ts derives its hostnames from the registry and middleware.ts imports from it, retiring what turned out to be a fourth independent copy of the www/portal host pair. Three new guard tests (backend xUnit, web vitest, MCP vitest) scan non-test source and fail on any enforced host outside its registry. This is the lesson of the EU cutover: the registry existed then too, but LatestVersionsService (feeds the portal's version badge) and ValidateBootstrapCodeFunction carried their own literal copies of the legacy blob host and were missed -- kept working only by the fail-soft legacy mirror in build-agent.yml, whose silent failure would have pinned badge and self-update to stale bits with a green release. Both guards proved themselves on their first run, flagging the middleware host copy and a literal in an MCP tool description. Test files stay exempt on purpose: literals there are independent oracles, and ValidateBootstrapCodeResponseShapeTests now pins the full alias URL against the Constants-built one so registry changes must be acknowledged consciously. The two legacy readers plus the agent SelfUpdater now use Constants.AgentDownloadBaseUrl (download.autopilotmonitor.com/agent); the alias was verified live for version.json, version-v2.json and both ZIPs before the switch. The portal's bootstrap allow-list accepts the alias AND the legacy host (AGENT_DOWNLOAD_HOSTNAMES) for already-deployed customer scripts, which imposes a deploy order: web before or with backend. The legacy keepalive probes and the release mirror stay untouched -- they exist for exactly this transition. Also: the bootstrap short-link now targets www directly instead of riding the registrar's apex 301, and the welcome-mail docs button points at docs. instead of bouncing through www./docs. Suites: backend 3194, agent core 1925, DecisionCore 474, web 582, MCP 449 -- all green; next build verified (it, not tsc, proves the next.config.ts registry import). Docs: docs/url-registry.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
116 lines
5.5 KiB
C#
116 lines
5.5 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 — called by the Next.js /go/{code} route (server-side).
|
|
/// 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;
|
|
}
|
|
}
|
|
}
|
|
}
|