using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using MareSynchronos.Services.Mediator; using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.WebAPI.SignalR; using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; namespace MareSynchronos.Services.AutoDetect; public class DiscoveryConfigProvider { private readonly ILogger _logger; private readonly ServerConfigurationManager _serverManager; private readonly TokenProvider _tokenProvider; private WellKnownRoot? _config; private DateTimeOffset _lastLoad = DateTimeOffset.MinValue; public DiscoveryConfigProvider(ILogger logger, ServerConfigurationManager serverManager, TokenProvider tokenProvider) { _logger = logger; _serverManager = serverManager; _tokenProvider = tokenProvider; } public bool HasConfig => _config != null; public bool NearbyEnabled => _config?.NearbyDiscovery?.Enabled ?? false; public byte[]? Salt => _config?.NearbyDiscovery?.SaltBytes; public DateTimeOffset? SaltExpiresAt => _config?.NearbyDiscovery?.SaltExpiresAt; public int RefreshSec => _config?.NearbyDiscovery?.RefreshSec ?? 300; public int MinQueryIntervalMs => _config?.NearbyDiscovery?.Policies?.MinQueryIntervalMs ?? 2000; public int MaxQueryBatch => _config?.NearbyDiscovery?.Policies?.MaxQueryBatch ?? 100; public string? PublishEndpoint => _config?.NearbyDiscovery?.Endpoints?.Publish; public string? QueryEndpoint => _config?.NearbyDiscovery?.Endpoints?.Query; public string? RequestEndpoint => _config?.NearbyDiscovery?.Endpoints?.Request; public bool TryLoadFromStapled() { try { var json = _tokenProvider.GetStapledWellKnown(_serverManager.CurrentApiUrl); if (string.IsNullOrEmpty(json)) return false; var root = JsonSerializer.Deserialize(json!); if (root == null) return false; root.NearbyDiscovery?.Hydrate(); _config = root; _lastLoad = DateTimeOffset.UtcNow; _logger.LogInformation("Loaded Nearby well-known (stapled), enabled={enabled}, expires={exp}", NearbyEnabled, _config?.NearbyDiscovery?.SaltExpiresAt); return true; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to parse stapled well-known"); return false; } } public async Task TryFetchFromServerAsync(CancellationToken ct = default) { try { var baseUrl = _serverManager.CurrentApiUrl .Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase) .Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase); // Try likely candidates based on nginx config string[] candidates = [ "/.well-known/Umbra/client", // matches provided nginx "/.well-known/umbra", // lowercase variant ]; using var http = new HttpClient(); try { var ver = Assembly.GetExecutingAssembly().GetName().Version!; http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("MareSynchronos", $"{ver.Major}.{ver.Minor}.{ver.Build}")); } catch { } foreach (var path in candidates) { try { var uri = new Uri(new Uri(baseUrl), path); var json = await http.GetStringAsync(uri, ct).ConfigureAwait(false); if (string.IsNullOrEmpty(json)) continue; var root = JsonSerializer.Deserialize(json); if (root == null) continue; root.NearbyDiscovery?.Hydrate(); _config = root; _lastLoad = DateTimeOffset.UtcNow; _logger.LogInformation("Loaded Nearby well-known (http {path}), enabled={enabled}", path, NearbyEnabled); return true; } catch (Exception ex) { _logger.LogDebug(ex, "Nearby well-known fetch failed for {path}", path); } } _logger.LogInformation("Nearby well-known not found via HTTP candidates"); return false; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch Nearby well-known via HTTP"); return false; } } public bool IsExpired() { if (_config?.NearbyDiscovery?.SaltExpiresAt == null) return false; return DateTimeOffset.UtcNow > _config.NearbyDiscovery.SaltExpiresAt; } // DTOs for well-known JSON private sealed class WellKnownRoot { [JsonPropertyName("features")] public Features? Features { get; set; } [JsonPropertyName("nearby_discovery")] public Nearby? NearbyDiscovery { get; set; } } private sealed class Features { [JsonPropertyName("nearby_discovery")] public bool NearbyDiscovery { get; set; } } private sealed class Nearby { [JsonPropertyName("enabled")] public bool Enabled { get; set; } [JsonPropertyName("hash_algo")] public string? HashAlgo { get; set; } [JsonPropertyName("salt_b64")] public string? SaltB64 { get; set; } [JsonPropertyName("salt_expires_at")] public string? SaltExpiresAtRaw { get; set; } [JsonPropertyName("refresh_sec")] public int RefreshSec { get; set; } = 300; [JsonPropertyName("endpoints")] public Endpoints? Endpoints { get; set; } [JsonPropertyName("policies")] public Policies? Policies { get; set; } [JsonIgnore] public byte[]? SaltBytes { get; private set; } [JsonIgnore] public DateTimeOffset? SaltExpiresAt { get; private set; } public void Hydrate() { try { SaltBytes = string.IsNullOrEmpty(SaltB64) ? null : Convert.FromBase64String(SaltB64!); } catch { SaltBytes = null; } if (DateTimeOffset.TryParse(SaltExpiresAtRaw, out var dto)) SaltExpiresAt = dto; } } private sealed class Endpoints { [JsonPropertyName("publish")] public string? Publish { get; set; } [JsonPropertyName("query")] public string? Query { get; set; } [JsonPropertyName("request")] public string? Request { get; set; } } private sealed class Policies { [JsonPropertyName("max_query_batch")] public int MaxQueryBatch { get; set; } = 100; [JsonPropertyName("min_query_interval_ms")] public int MinQueryIntervalMs { get; set; } = 2000; [JsonPropertyName("rate_limit_per_min")] public int RateLimitPerMin { get; set; } = 30; [JsonPropertyName("token_ttl_sec")] public int TokenTtlSec { get; set; } = 120; } }