Support AutoDetect

This commit is contained in:
2025-09-11 15:43:11 +02:00
parent c23a43c84c
commit 5366cfbc60
9 changed files with 469 additions and 27 deletions

View File

@@ -0,0 +1,115 @@
using System.Text.Json.Serialization;
using MareSynchronosAuthService.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MareSynchronosAuthService.Controllers;
[Authorize]
[ApiController]
[Route("discovery")]
public class DiscoveryController : Controller
{
private readonly DiscoveryWellKnownProvider _provider;
private readonly DiscoveryPresenceService _presence;
public DiscoveryController(DiscoveryWellKnownProvider provider, DiscoveryPresenceService presence)
{
_provider = provider;
_presence = presence;
}
public sealed class QueryRequest
{
[JsonPropertyName("hashes")] public string[] Hashes { get; set; } = Array.Empty<string>();
}
public sealed class QueryResponseEntry
{
[JsonPropertyName("hash")] public string Hash { get; set; } = string.Empty;
[JsonPropertyName("token")] public string Token { get; set; } = string.Empty;
[JsonPropertyName("displayName")] public string? DisplayName { get; set; }
}
[HttpPost("query")]
public IActionResult Query([FromBody] QueryRequest req)
{
if (_provider.IsExpired())
{
return BadRequest(new { code = "DISCOVERY_SALT_EXPIRED" });
}
var uid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty;
if (string.IsNullOrEmpty(uid) || req?.Hashes == null || req.Hashes.Length == 0)
return Json(Array.Empty<QueryResponseEntry>());
List<QueryResponseEntry> matches = new();
foreach (var h in req.Hashes.Distinct(StringComparer.Ordinal))
{
var (found, token, displayName) = _presence.TryMatchAndIssueToken(uid, h);
if (found)
{
matches.Add(new QueryResponseEntry { Hash = h, Token = token, DisplayName = displayName });
}
}
return Json(matches);
}
public sealed class RequestDto
{
[JsonPropertyName("token")] public string Token { get; set; } = string.Empty;
}
[HttpPost("request")]
public async Task<IActionResult> RequestPair([FromBody] RequestDto req)
{
if (string.IsNullOrEmpty(req.Token)) return BadRequest();
if (_presence.ValidateToken(req.Token, out var targetUid))
{
// Phase 3 (minimal): notify target via mare-server internal controller
try
{
var fromUid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty;
var fromAlias = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Alias)?.Value ?? string.Empty;
using var http = new HttpClient();
// Use same host as public (goes through nginx)
var baseUrl = $"{Request.Scheme}://{Request.Host.Value}";
var url = new Uri(new Uri(baseUrl), "/main/discovery/notifyRequest");
// Generate internal JWT
var serverToken = HttpContext.RequestServices.GetRequiredService<MareSynchronosShared.Utils.ServerTokenGenerator>().Token;
http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", serverToken);
var payload = System.Text.Json.JsonSerializer.Serialize(new { targetUid, fromUid, fromAlias });
var resp = await http.PostAsync(url, new StringContent(payload, System.Text.Encoding.UTF8, "application/json"));
// ignore response content, just return Accepted to caller
}
catch { /* ignore */ }
return Accepted();
}
return BadRequest(new { code = "INVALID_TOKEN" });
}
public sealed class PublishRequest
{
[JsonPropertyName("hashes")] public string[] Hashes { get; set; } = Array.Empty<string>();
[JsonPropertyName("displayName")] public string? DisplayName { get; set; }
}
[HttpPost("publish")]
public IActionResult Publish([FromBody] PublishRequest req)
{
if (_provider.IsExpired())
{
return BadRequest(new { code = "DISCOVERY_SALT_EXPIRED" });
}
var uid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty;
if (string.IsNullOrEmpty(uid) || req?.Hashes == null || req.Hashes.Length == 0)
return Accepted();
_presence.Publish(uid, req.Hashes, req.DisplayName);
return Accepted();
}
}

View File

@@ -134,10 +134,13 @@ public class JwtController : Controller
var tokenContent = tokenResponse as ContentResult;
if (tokenContent == null)
return tokenResponse;
var provider = HttpContext.RequestServices.GetService<DiscoveryWellKnownProvider>();
var wk = provider?.GetWellKnownJson(Request.Scheme, Request.Host.Value)
?? _configuration.GetValueOrDefault(nameof(AuthServiceConfiguration.WellKnown), string.Empty);
return Json(new AuthReplyDto
{
Token = tokenContent.Content,
WellKnown = _configuration.GetValueOrDefault(nameof(AuthServiceConfiguration.WellKnown), string.Empty),
WellKnown = wk,
});
}
@@ -189,5 +192,4 @@ public class JwtController : Controller
var handler = new JwtSecurityTokenHandler();
return handler.CreateJwtSecurityToken(token);
}
}
}

View File

@@ -0,0 +1,25 @@
using MareSynchronosAuthService.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MareSynchronosAuthService.Controllers;
[AllowAnonymous]
[ApiController]
public class WellKnownController : Controller
{
private readonly DiscoveryWellKnownProvider _provider;
public WellKnownController(DiscoveryWellKnownProvider provider)
{
_provider = provider;
}
[HttpGet("/.well-known/Umbra/client")]
public IActionResult Get()
{
var json = _provider.GetWellKnownJson(Request.Scheme, Request.Host.Value);
return Content(json, "application/json");
}
}

View File

@@ -4,30 +4,16 @@
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultContentItems>false</EnableDefaultContentItems>
</PropertyGroup>
<ItemGroup>
<Content Remove="appsettings.Development.json" />
<Content Remove="appsettings.json" />
</ItemGroup>
<ItemGroup>
<None Include="appsettings.Development.json" />
<None Include="appsettings.json">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Content Remove="appsettings.Development.json" />
<Content Remove="appsettings.json" />
</ItemGroup>
<ItemGroup>
<None Include="appsettings.Development.json" />
<None Include="appsettings.json">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>
<Content Include="appsettings.Development.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,88 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MareSynchronosAuthService.Services;
public class DiscoveryPresenceService : IHostedService
{
private readonly ILogger<DiscoveryPresenceService> _logger;
// hash -> (uid, expiresAt, displayName?)
private readonly ConcurrentDictionary<string, (string Uid, DateTimeOffset ExpiresAt, string? DisplayName)> _presence = new(StringComparer.Ordinal);
// token -> (targetUid, expiresAt)
private readonly ConcurrentDictionary<string, (string TargetUid, DateTimeOffset ExpiresAt)> _tokens = new(StringComparer.Ordinal);
private Timer? _cleanupTimer;
private readonly TimeSpan _presenceTtl = TimeSpan.FromMinutes(5);
private readonly TimeSpan _tokenTtl = TimeSpan.FromMinutes(2);
public DiscoveryPresenceService(ILogger<DiscoveryPresenceService> logger)
{
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_cleanupTimer = new Timer(_ => Cleanup(), null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_cleanupTimer?.Dispose();
return Task.CompletedTask;
}
private void Cleanup()
{
var now = DateTimeOffset.UtcNow;
foreach (var kv in _presence.ToArray())
{
if (kv.Value.ExpiresAt <= now) _presence.TryRemove(kv.Key, out _);
}
foreach (var kv in _tokens.ToArray())
{
if (kv.Value.ExpiresAt <= now) _tokens.TryRemove(kv.Key, out _);
}
}
public void Publish(string uid, IEnumerable<string> hashes, string? displayName = null)
{
var exp = DateTimeOffset.UtcNow.Add(_presenceTtl);
foreach (var h in hashes.Distinct(StringComparer.Ordinal))
{
_presence[h] = (uid, exp, displayName);
}
_logger.LogDebug("Discovery presence published for {uid} with {n} hashes", uid, hashes.Count());
}
public (bool Found, string Token, string? DisplayName) TryMatchAndIssueToken(string requesterUid, string hash)
{
if (_presence.TryGetValue(hash, out var entry))
{
if (string.Equals(entry.Uid, requesterUid, StringComparison.Ordinal)) return (false, string.Empty, null);
var token = Guid.NewGuid().ToString("N");
_tokens[token] = (entry.Uid, DateTimeOffset.UtcNow.Add(_tokenTtl));
return (true, token, entry.DisplayName);
}
return (false, string.Empty, null);
}
public bool ValidateToken(string token, out string targetUid)
{
targetUid = string.Empty;
if (_tokens.TryGetValue(token, out var info))
{
if (info.ExpiresAt > DateTimeOffset.UtcNow)
{
targetUid = info.TargetUid;
return true;
}
_tokens.TryRemove(token, out _);
}
return false;
}
}

View File

@@ -0,0 +1,149 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MareSynchronosAuthService.Services;
public class DiscoveryWellKnownProvider : IHostedService
{
private readonly ILogger<DiscoveryWellKnownProvider> _logger;
private readonly object _lock = new();
private byte[] _currentSalt = Array.Empty<byte>();
private DateTimeOffset _currentSaltExpiresAt;
private Timer? _rotationTimer;
private readonly TimeSpan _saltTtl = TimeSpan.FromDays(30 * 6);
private readonly int _refreshSec = 86400; // 24h
public DiscoveryWellKnownProvider(ILogger<DiscoveryWellKnownProvider> logger)
{
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
RotateSalt();
var period = _saltTtl;
if (period.TotalMilliseconds > uint.MaxValue - 1)
{
_logger.LogInformation("DiscoveryWellKnownProvider: salt TTL {ttl} exceeds timer limit, skipping rotation timer in beta", period);
_rotationTimer = new Timer(_ => { }, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
}
else
{
_rotationTimer = new Timer(_ => RotateSalt(), null, period, period);
}
_logger.LogInformation("DiscoveryWellKnownProvider started. Salt expires at {exp}", _currentSaltExpiresAt);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_rotationTimer?.Dispose();
return Task.CompletedTask;
}
private void RotateSalt()
{
lock (_lock)
{
_currentSalt = RandomNumberGenerator.GetBytes(32);
_currentSaltExpiresAt = DateTimeOffset.UtcNow.Add(_saltTtl);
}
}
public bool IsExpired()
{
lock (_lock)
{
return DateTimeOffset.UtcNow > _currentSaltExpiresAt;
}
}
public string GetWellKnownJson(string scheme, string host)
{
var isHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase);
var wsScheme = isHttps ? "wss" : "ws";
var httpScheme = isHttps ? "https" : "http";
byte[] salt;
DateTimeOffset exp;
lock (_lock)
{
salt = _currentSalt.ToArray();
exp = _currentSaltExpiresAt;
}
var root = new WellKnownRoot
{
ApiUrl = $"{wsScheme}://{host}",
HubUrl = $"{wsScheme}://{host}/mare",
Features = new() { NearbyDiscovery = true },
NearbyDiscovery = new()
{
Enabled = true,
HashAlgo = "sha256",
SaltB64 = Convert.ToBase64String(salt),
SaltExpiresAt = exp,
RefreshSec = _refreshSec,
Endpoints = new()
{
Publish = $"{httpScheme}://{host}/discovery/publish",
Query = $"{httpScheme}://{host}/discovery/query",
Request = $"{httpScheme}://{host}/discovery/request"
},
Policies = new()
{
MaxQueryBatch = 100,
MinQueryIntervalMs = 2000,
RateLimitPerMin = 30,
TokenTtlSec = 120
}
}
};
return JsonSerializer.Serialize(root, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
}
private sealed class WellKnownRoot
{
[JsonPropertyName("api_url")] public string ApiUrl { get; set; } = string.Empty;
[JsonPropertyName("hub_url")] public string HubUrl { get; set; } = string.Empty;
[JsonPropertyName("skip_negotiation")] public bool SkipNegotiation { get; set; } = true;
[JsonPropertyName("transports")] public string[] Transports { get; set; } = new[] { "websockets" };
[JsonPropertyName("features")] public Features Features { get; set; } = new();
[JsonPropertyName("nearby_discovery")] public Nearby NearbyDiscovery { get; set; } = new();
}
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; } = "sha256";
[JsonPropertyName("salt_b64")] public string SaltB64 { get; set; } = string.Empty;
[JsonPropertyName("salt_expires_at")] public DateTimeOffset SaltExpiresAt { get; set; }
[JsonPropertyName("refresh_sec")] public int RefreshSec { get; set; }
[JsonPropertyName("endpoints")] public Endpoints Endpoints { get; set; } = new();
[JsonPropertyName("policies")] public Policies Policies { get; set; } = new();
}
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; }
[JsonPropertyName("min_query_interval_ms")] public int MinQueryIntervalMs { get; set; }
[JsonPropertyName("rate_limit_per_min")] public int RateLimitPerMin { get; set; }
[JsonPropertyName("token_ttl_sec")] public int TokenTtlSec { get; set; }
}
}

View File

@@ -16,6 +16,7 @@ using System.Text;
using MareSynchronosShared.Data;
using Microsoft.EntityFrameworkCore;
using Prometheus;
using Microsoft.AspNetCore.HttpOverrides;
using MareSynchronosShared.Utils.Configuration;
namespace MareSynchronosAuthService;
@@ -35,6 +36,12 @@ public class Startup
{
var config = app.ApplicationServices.GetRequiredService<IConfigurationService<MareConfigurationBase>>();
// Respect X-Forwarded-* headers from the reverse proxy so generated links use the public scheme/host
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedFor
});
app.UseRouting();
app.UseHttpMetrics();
@@ -65,8 +72,8 @@ public class Startup
ConfigureRedis(services, mareConfig);
services.AddSingleton<SecretKeyAuthenticatorService>();
services.AddSingleton<AccountRegistrationService>();
services.AddScoped<SecretKeyAuthenticatorService>();
services.AddScoped<AccountRegistrationService>();
services.AddSingleton<GeoIPService>();
services.AddHostedService(provider => provider.GetRequiredService<GeoIPService>());
@@ -75,6 +82,11 @@ public class Startup
services.Configure<MareConfigurationBase>(_configuration.GetRequiredSection("MareSynchronos"));
services.AddSingleton<ServerTokenGenerator>();
// Nearby discovery services (well-known + presence)
services.AddSingleton<DiscoveryWellKnownProvider>();
services.AddHostedService(p => p.GetRequiredService<DiscoveryWellKnownProvider>());
services.AddSingleton<DiscoveryPresenceService>();
services.AddHostedService(p => p.GetRequiredService<DiscoveryPresenceService>());
ConfigureAuthorization(services);
@@ -88,8 +100,11 @@ public class Startup
services.AddControllers().ConfigureApplicationPartManager(a =>
{
a.FeatureProviders.Remove(a.FeatureProviders.OfType<ControllerFeatureProvider>().First());
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(JwtController)));
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(JwtController), typeof(WellKnownController), typeof(DiscoveryController)));
});
services.AddSingleton<DiscoveryWellKnownProvider>();
services.AddHostedService(p => p.GetRequiredService<DiscoveryWellKnownProvider>());
}
private static void ConfigureAuthorization(IServiceCollection services)