File: NativeServerConfiguration.cs
Web Access
Project: src\src\Aspire.Hosting.Native.Server\Aspire.Hosting.Native.Server.csproj (Aspire.Hosting.Native.Server)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Connections;
using Microsoft.Extensions.Configuration;
using Aspire.Hosting.Native.Rpc;
 
namespace Aspire.Hosting.Native.Server;
 
/// <summary>Projects configuration once, before lifecycle services consume typed values.</summary>
internal sealed record NativeServerConfiguration(
    NativeServerOptions Options,
    string RpcToken,
    bool TraceOperations,
    string? SocketPath,
    string? DcpPath,
    string? DashboardPath,
    int? DashboardPort,
    string ApplicationName,
    string? BackchannelPath,
    string? AppHostPath,
    NativeDashboardCredentials DashboardCredentials,
    NativeCliProcessMetadata CliProcess,
    string? GeneratedSdkDirectory)
{
    public bool IsPublishing { get; init; }
    public override string ToString() => nameof(NativeServerConfiguration);
 
    public static NativeServerConfiguration Parse(IConfiguration configuration, bool launchedByCli)
    {
        ArgumentNullException.ThrowIfNull(configuration);
        var options = NativeServerOptions.Load(configuration["ASPIRE_NATIVE_SERVER_OPTIONS_PATH"]);
        // A CLI launch supplies its own session token, regardless of inherited
        // native-testing configuration in the caller's shell.
        var token = (launchedByCli ? configuration["ASPIRE_REMOTE_APPHOST_TOKEN"] :
            configuration["ASPIRE_NATIVE_RPC_TOKEN"] ?? configuration["ASPIRE_REMOTE_APPHOST_TOKEN"])
            ?? throw new InvalidOperationException(launchedByCli ?
                "ASPIRE_REMOTE_APPHOST_TOKEN is required for a CLI server launch." :
                "Set ASPIRE_NATIVE_RPC_TOKEN to authenticate native server connections.");
        NativeRpcProtocol.ValidateAuthenticationToken(token);
        int? port = null;
        if (configuration["ASPIRE_NATIVE_DASHBOARD_PORT"] is { } value)
        {
            if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) || number is < 0 or > 65535)
            {
                throw new ArgumentException("ASPIRE_NATIVE_DASHBOARD_PORT must be a local port or zero.");
            }
            port = number;
        }
 
        return new(options, token, configuration["ASPIRE_NATIVE_TRACE_OPERATIONS"] == "1",
            configuration["REMOTE_APP_HOST_SOCKET_PATH"], configuration["ASPIRE_NATIVE_DCP_PATH"],
            configuration["ASPIRE_DASHBOARD_PATH"], port,
            configuration["ASPIRE_NATIVE_APPLICATION_NAME"] ?? "Native AppHost", configuration["ASPIRE_BACKCHANNEL_PATH"],
            configuration["AppHost:FilePath"] ?? configuration["ASPIRE_APPHOST_FILEPATH"] ??
                configuration["AppHost:Path"] ?? configuration["ASPIRE_NATIVE_APPHOST_PATH"],
            new(configuration["ASPIRE_NATIVE_DASHBOARD_API_KEY"] ?? NewCredential(),
                configuration["AppHost:BrowserToken"] ?? NewCredential(),
                configuration["AppHost:DashboardApiKey"] ?? NewCredential(), NewCredential()),
            NativeCliProcessMetadata.Parse(configuration),
            configuration["ASPIRE_APPHOST_SERVER_V2_SDK_DIRECTORY"])
        {
            IsPublishing = configuration["ASPIRE_NATIVE_OPERATION"] is "publish" or "inspect"
        };
    }
 
    private static string NewCredential() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
}
 
/// <summary>Keeps independently scoped Dashboard credentials out of runtime observations and diagnostics.</summary>
internal sealed record NativeDashboardCredentials(string ResourceServiceApiKey, string BrowserToken, string DashboardApiKey, string OtlpApiKey)
{
    public override string ToString() => nameof(NativeDashboardCredentials);
}
 
/// <summary>Parses the CLI's optional process identity metadata without ambient environment reads.</summary>
internal sealed record NativeCliProcessMetadata(int? ProcessId, DateTimeOffset? StartedAt, DateTimeOffset? StableStartedAt, string? LogFilePath)
{
    /// <summary>Disarms parent detection on disposal and prefers the stable PID-reuse identity.</summary>
    public IAsyncDisposable? MonitorParent(Action onParentExited, TimeProvider timeProvider)
    {
        if (ProcessId is not { } pid)
        {
            return null;
        }
 
        return ParentProcessLivenessMonitor.Start(pid,
            StableStartedAt?.ToUnixTimeMilliseconds() ?? StartedAt?.ToUnixTimeSeconds(),
            _ =>
            {
                onParentExited();
                return Task.CompletedTask;
            }, timeProvider, useRuntimeStartTime: StableStartedAt is null && StartedAt is not null);
    }
 
    public static NativeCliProcessMetadata Parse(IConfiguration configuration) => new(
        int.TryParse(configuration["ASPIRE_CLI_PID"], NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid) ? pid : null,
        ParseTimestamp(configuration["ASPIRE_CLI_STARTED"], milliseconds: false),
        ParseTimestamp(configuration["ASPIRE_CLI_STARTED_STABLE"], milliseconds: true),
        configuration["ASPIRE_CLI_LOG_FILE"]);
 
    internal static DateTimeOffset? ParseTimestamp(string? raw, bool milliseconds)
    {
        // CLI metadata uses decimal Unix timestamps: ASPIRE_CLI_STARTED=1770000000
        // (seconds), ASPIRE_CLI_STARTED_STABLE=1770000000123 (milliseconds).
        // Missing, malformed and out-of-range optional identities remain absent.
        if (!long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
        {
            return null;
        }
        try
        {
            return milliseconds ? DateTimeOffset.FromUnixTimeMilliseconds(value) : DateTimeOffset.FromUnixTimeSeconds(value);
        }
        catch (ArgumentOutOfRangeException)
        {
            return null;
        }
    }
}
 
/// <summary>Parses only the native server's supported command-line transport shapes.</summary>
internal static class NativeStartupArguments
{
    public static EndPoint Parse(string[] arguments, string? configuredSocket) => arguments switch
    {
        ["server", "--contentRoot", var contentRoot] when Directory.Exists(contentRoot) &&
            configuredSocket is { } path => LocalEndpoint(path),
        [] when configuredSocket is { } socketPath => LocalEndpoint(socketPath),
        ["--socket", var path] => LocalEndpoint(path),
        ["--port", var port] when int.TryParse(port, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) &&
            number is >= 0 and <= 65535 => new IPEndPoint(IPAddress.Loopback, number),
        _ => throw new ArgumentException("Use --socket <Unix socket path or Windows pipe name> or --port <loopback-port>.")
    };
 
    private static EndPoint LocalEndpoint(string path)
    {
        if (OperatingSystem.IsWindows())
        {
            // The guest RPC address is a pipe name such as apphost.sock.<random-id>,
            // not a filesystem path. Node and the CLI add their native pipe prefix.
            if (string.IsNullOrWhiteSpace(path) || path.Length > 128 || path.IndexOfAny(['/', '\\', ':', '\0']) >= 0)
            {
                throw new ArgumentException("The Windows RPC endpoint must be a local pipe name.");
            }
 
            return new NamedPipeEndPoint(path);
        }
        if (!Path.IsPathFullyQualified(path))
        {
            throw new ArgumentException("The Unix RPC socket path must be absolute.");
        }
 
        return new UnixDomainSocketEndPoint(path);
    }
}