diff --git a/MareSynchronosServer/MareSynchronosAuthService/Controllers/DiscoveryController.cs b/MareSynchronosServer/MareSynchronosAuthService/Controllers/DiscoveryController.cs index 17b95ae..7ef5742 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/Controllers/DiscoveryController.cs +++ b/MareSynchronosServer/MareSynchronosAuthService/Controllers/DiscoveryController.cs @@ -23,19 +23,21 @@ public class DiscoveryController : Controller public sealed class QueryRequest { [JsonPropertyName("hashes")] public string[] Hashes { get; set; } = Array.Empty(); + [JsonPropertyName("salt")] public string SaltB64 { get; set; } = string.Empty; } public sealed class QueryResponseEntry { [JsonPropertyName("hash")] public string Hash { get; set; } = string.Empty; - [JsonPropertyName("token")] public string Token { get; set; } = string.Empty; + [JsonPropertyName("token")] public string? Token { get; set; } + [JsonPropertyName("uid")] public string Uid { get; set; } = string.Empty; [JsonPropertyName("displayName")] public string? DisplayName { get; set; } } [HttpPost("query")] public IActionResult Query([FromBody] QueryRequest req) { - if (_provider.IsExpired()) + if (_provider.IsExpired(req.SaltB64)) { return BadRequest(new { code = "DISCOVERY_SALT_EXPIRED" }); } @@ -47,10 +49,10 @@ public class DiscoveryController : Controller List matches = new(); foreach (var h in req.Hashes.Distinct(StringComparer.Ordinal)) { - var (found, token, displayName) = _presence.TryMatchAndIssueToken(uid, h); + var (found, token, targetUid, displayName) = _presence.TryMatchAndIssueToken(uid, h); if (found) { - matches.Add(new QueryResponseEntry { Hash = h, Token = token, DisplayName = displayName }); + matches.Add(new QueryResponseEntry { Hash = h, Token = token, Uid = targetUid, DisplayName = displayName }); } } @@ -101,16 +103,64 @@ public class DiscoveryController : Controller return BadRequest(new { code = "INVALID_TOKEN" }); } + public sealed class AcceptNotifyDto + { + [JsonPropertyName("targetUid")] public string TargetUid { get; set; } = string.Empty; + [JsonPropertyName("displayName")] public string? DisplayName { get; set; } + } + + // Accept notification relay (sender -> auth -> main) + [HttpPost("acceptNotify")] + public async Task AcceptNotify([FromBody] AcceptNotifyDto req) + { + if (string.IsNullOrEmpty(req.TargetUid)) return BadRequest(); + try + { + var fromUid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty; + var fromAlias = string.IsNullOrEmpty(req.DisplayName) + ? (User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Alias)?.Value ?? string.Empty) + : req.DisplayName; + + using var http = new HttpClient(); + var baseUrl = $"{Request.Scheme}://{Request.Host.Value}"; + var url = new Uri(new Uri(baseUrl), "/main/discovery/notifyAccept"); + var serverToken = HttpContext.RequestServices.GetRequiredService().Token; + http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", serverToken); + var payload = System.Text.Json.JsonSerializer.Serialize(new { targetUid = req.TargetUid, fromUid, fromAlias }); + var resp = await http.PostAsync(url, new StringContent(payload, System.Text.Encoding.UTF8, "application/json")); + if (!resp.IsSuccessStatusCode) + { + var txt = await resp.Content.ReadAsStringAsync(); + HttpContext.RequestServices.GetRequiredService>() + .LogWarning("notifyAccept failed: {code} {reason} {body}", (int)resp.StatusCode, resp.ReasonPhrase, txt); + } + } + catch { /* ignore */ } + + return Accepted(); + } + public sealed class PublishRequest { [JsonPropertyName("hashes")] public string[] Hashes { get; set; } = Array.Empty(); [JsonPropertyName("displayName")] public string? DisplayName { get; set; } + [JsonPropertyName("salt")] public string SaltB64 { get; set; } = string.Empty; + [JsonPropertyName("allowRequests")] public bool AllowRequests { get; set; } = true; + } + + [HttpPost("disable")] + public IActionResult Disable() + { + var uid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty; + if (string.IsNullOrEmpty(uid)) return Accepted(); + _presence.Unpublish(uid); + return Accepted(); } [HttpPost("publish")] public IActionResult Publish([FromBody] PublishRequest req) { - if (_provider.IsExpired()) + if (_provider.IsExpired(req.SaltB64)) { return BadRequest(new { code = "DISCOVERY_SALT_EXPIRED" }); } @@ -118,7 +168,7 @@ public class DiscoveryController : Controller if (string.IsNullOrEmpty(uid) || req?.Hashes == null || req.Hashes.Length == 0) return Accepted(); - _presence.Publish(uid, req.Hashes, req.DisplayName); + _presence.Publish(uid, req.Hashes, req.DisplayName, req.AllowRequests); return Accepted(); } } diff --git a/MareSynchronosServer/MareSynchronosAuthService/Services/Discovery/IDiscoveryPresenceStore.cs b/MareSynchronosServer/MareSynchronosAuthService/Services/Discovery/IDiscoveryPresenceStore.cs index 710377b..aed958c 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/Services/Discovery/IDiscoveryPresenceStore.cs +++ b/MareSynchronosServer/MareSynchronosAuthService/Services/Discovery/IDiscoveryPresenceStore.cs @@ -4,8 +4,9 @@ namespace MareSynchronosAuthService.Services.Discovery; public interface IDiscoveryPresenceStore : IDisposable { - void Publish(string uid, IEnumerable hashes, string? displayName = null); - (bool Found, string Token, string? DisplayName) TryMatchAndIssueToken(string requesterUid, string hash); + void Publish(string uid, IEnumerable hashes, string? displayName = null, bool allowRequests = true); + void Unpublish(string uid); + (bool Found, string? Token, string TargetUid, string? DisplayName) TryMatchAndIssueToken(string requesterUid, string hash); bool ValidateToken(string token, out string targetUid); } diff --git a/MareSynchronosServer/MareSynchronosAuthService/Services/Discovery/InMemoryPresenceStore.cs b/MareSynchronosServer/MareSynchronosAuthService/Services/Discovery/InMemoryPresenceStore.cs index 7ff0e43..8280105 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/Services/Discovery/InMemoryPresenceStore.cs +++ b/MareSynchronosServer/MareSynchronosAuthService/Services/Discovery/InMemoryPresenceStore.cs @@ -4,7 +4,7 @@ namespace MareSynchronosAuthService.Services.Discovery; public sealed class InMemoryPresenceStore : IDiscoveryPresenceStore { - private readonly ConcurrentDictionary _presence = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _presence = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _tokens = new(StringComparer.Ordinal); private readonly TimeSpan _presenceTtl; private readonly TimeSpan _tokenTtl; @@ -35,25 +35,43 @@ public sealed class InMemoryPresenceStore : IDiscoveryPresenceStore } } - public void Publish(string uid, IEnumerable hashes, string? displayName = null) + public void Publish(string uid, IEnumerable hashes, string? displayName = null, bool allowRequests = true) { var exp = DateTimeOffset.UtcNow.Add(_presenceTtl); foreach (var h in hashes.Distinct(StringComparer.Ordinal)) { - _presence[h] = (uid, exp, displayName); + _presence[h] = (uid, exp, displayName, allowRequests); } } - public (bool Found, string Token, string? DisplayName) TryMatchAndIssueToken(string requesterUid, string hash) + public void Unpublish(string uid) + { + // Remove all presence hashes owned by this uid + foreach (var kv in _presence.ToArray()) + { + if (string.Equals(kv.Value.Uid, uid, StringComparison.Ordinal)) + { + _presence.TryRemove(kv.Key, out _); + } + } + } + +public (bool Found, string? Token, string TargetUid, 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); + if (string.Equals(entry.Uid, requesterUid, StringComparison.Ordinal)) + return (false, null, string.Empty, null); + + // Visible but requests disabled → no token + if (!entry.AllowRequests) + return (true, null, entry.Uid, entry.DisplayName); + var token = Guid.NewGuid().ToString("N"); _tokens[token] = (entry.Uid, DateTimeOffset.UtcNow.Add(_tokenTtl)); - return (true, token, entry.DisplayName); + return (true, token, entry.Uid, entry.DisplayName); } - return (false, string.Empty, null); + return (false, null, string.Empty, null); } public bool ValidateToken(string token, out string targetUid) diff --git a/MareSynchronosServer/MareSynchronosAuthService/Services/Discovery/RedisPresenceStore.cs b/MareSynchronosServer/MareSynchronosAuthService/Services/Discovery/RedisPresenceStore.cs index 51fe70e..d287982 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/Services/Discovery/RedisPresenceStore.cs +++ b/MareSynchronosServer/MareSynchronosAuthService/Services/Discovery/RedisPresenceStore.cs @@ -24,8 +24,9 @@ public sealed class RedisPresenceStore : IDiscoveryPresenceStore private static string KeyForHash(string hash) => $"nd:hash:{hash}"; private static string KeyForToken(string token) => $"nd:token:{token}"; + private static string KeyForUidSet(string uid) => $"nd:uid:{uid}"; - public void Publish(string uid, IEnumerable hashes, string? displayName = null) + public void Publish(string uid, IEnumerable hashes, string? displayName = null, bool allowRequests = true) { var entries = hashes.Distinct(StringComparer.Ordinal).ToArray(); if (entries.Length == 0) return; @@ -33,30 +34,85 @@ public sealed class RedisPresenceStore : IDiscoveryPresenceStore foreach (var h in entries) { var key = KeyForHash(h); - var payload = JsonSerializer.Serialize(new Presence(uid, displayName), _jsonOpts); + var payload = JsonSerializer.Serialize(new Presence(uid, displayName, allowRequests), _jsonOpts); batch.StringSetAsync(key, payload, _presenceTtl); + // Index this hash under the publisher uid for fast unpublish + batch.SetAddAsync(KeyForUidSet(uid), h); + batch.KeyExpireAsync(KeyForUidSet(uid), _presenceTtl); } batch.Execute(); _logger.LogDebug("RedisPresenceStore: published {count} hashes", entries.Length); } - public (bool Found, string Token, string? DisplayName) TryMatchAndIssueToken(string requesterUid, string hash) + public void Unpublish(string uid) + { + try + { + var setKey = KeyForUidSet(uid); + var members = _db.SetMembers(setKey); + if (members is { Length: > 0 }) + { + var batch = _db.CreateBatch(); + foreach (var m in members) + { + var hash = (string)m; + var key = KeyForHash(hash); + // Defensive: only delete if the hash is still owned by this uid + var val = _db.StringGet(key); + if (val.HasValue) + { + try + { + var p = JsonSerializer.Deserialize(val!); + if (p != null && string.Equals(p.Uid, uid, StringComparison.Ordinal)) + { + batch.KeyDeleteAsync(key); + } + } + catch { /* ignore corrupted */ } + } + } + // Remove the uid index set itself + batch.KeyDeleteAsync(setKey); + batch.Execute(); + } + else + { + // No index set: best-effort, just delete the set key in case it exists + _db.KeyDelete(setKey); + } + _logger.LogDebug("RedisPresenceStore: unpublished all hashes for uid {uid}", uid); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "RedisPresenceStore: Unpublish failed for uid {uid}", uid); + } + } + + public (bool Found, string? Token, string TargetUid, string? DisplayName) TryMatchAndIssueToken(string requesterUid, string hash) { var key = KeyForHash(hash); var val = _db.StringGet(key); - if (!val.HasValue) return (false, string.Empty, null); + if (!val.HasValue) return (false, null, string.Empty, null); try { var p = JsonSerializer.Deserialize(val!); - if (p == null || string.IsNullOrEmpty(p.Uid)) return (false, string.Empty, null); - if (string.Equals(p.Uid, requesterUid, StringComparison.Ordinal)) return (false, string.Empty, null); + if (p == null || string.IsNullOrEmpty(p.Uid)) return (false, null, string.Empty, null); + if (string.Equals(p.Uid, requesterUid, StringComparison.Ordinal)) return (false, null, string.Empty, null); + + // Visible but requests disabled → return without token + if (!p.AllowRequests) + { + return (true, null, p.Uid, p.DisplayName); + } + var token = Guid.NewGuid().ToString("N"); _db.StringSet(KeyForToken(token), p.Uid, _tokenTtl); - return (true, token, p.DisplayName); + return (true, token, p.Uid, p.DisplayName); } catch { - return (false, string.Empty, null); + return (false, null, string.Empty, null); } } @@ -70,6 +126,5 @@ public sealed class RedisPresenceStore : IDiscoveryPresenceStore return true; } - private sealed record Presence(string Uid, string? DisplayName); + private sealed record Presence(string Uid, string? DisplayName, bool AllowRequests); } - diff --git a/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryPresenceService.cs b/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryPresenceService.cs index 776725b..54ac0f6 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryPresenceService.cs +++ b/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryPresenceService.cs @@ -27,15 +27,22 @@ public class DiscoveryPresenceService : IHostedService, IDisposable return Task.CompletedTask; } - public void Publish(string uid, IEnumerable hashes, string? displayName = null) + public void Publish(string uid, IEnumerable hashes, string? displayName = null, bool allowRequests = true) { - _store.Publish(uid, hashes, displayName); + _store.Publish(uid, hashes, displayName, allowRequests); _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) + public void Unpublish(string uid) { - return _store.TryMatchAndIssueToken(requesterUid, hash); + _store.Unpublish(uid); + _logger.LogDebug("Discovery presence unpublished for {uid}", uid); + } + + public (bool Found, string? Token, string TargetUid, string? DisplayName) TryMatchAndIssueToken(string requesterUid, string hash) + { + var res = _store.TryMatchAndIssueToken(requesterUid, hash); + return (res.Found, res.Token, res.TargetUid, res.DisplayName); } public bool ValidateToken(string token, out string targetUid) diff --git a/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryWellKnownProvider.cs b/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryWellKnownProvider.cs index 6989e45..1b2cd2d 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryWellKnownProvider.cs +++ b/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryWellKnownProvider.cs @@ -12,6 +12,9 @@ public class DiscoveryWellKnownProvider : IHostedService private readonly object _lock = new(); private byte[] _currentSalt = Array.Empty(); private DateTimeOffset _currentSaltExpiresAt; + private byte[] _previousSalt = Array.Empty(); + private DateTimeOffset _previousSaltExpiresAt; + private readonly TimeSpan _gracePeriod = TimeSpan.FromMinutes(5); private Timer? _rotationTimer; private readonly TimeSpan _saltTtl = TimeSpan.FromDays(30 * 6); private readonly int _refreshSec = 86400; // 24h @@ -48,16 +51,30 @@ public class DiscoveryWellKnownProvider : IHostedService { lock (_lock) { + if (_currentSalt.Length > 0) + { + _previousSalt = _currentSalt; + _previousSaltExpiresAt = DateTimeOffset.UtcNow.Add(_gracePeriod); + } _currentSalt = RandomNumberGenerator.GetBytes(32); _currentSaltExpiresAt = DateTimeOffset.UtcNow.Add(_saltTtl); } } - public bool IsExpired() + public bool IsExpired(string providedSaltB64) { lock (_lock) { - return DateTimeOffset.UtcNow > _currentSaltExpiresAt; + var now = DateTimeOffset.UtcNow; + var provided = Convert.FromBase64String(providedSaltB64); + + if (_currentSalt.SequenceEqual(provided) && now <= _currentSaltExpiresAt) + return false; + + if (_previousSalt.Length > 0 && _previousSalt.SequenceEqual(provided) && now <= _previousSaltExpiresAt) + return false; + + return true; } } @@ -87,11 +104,13 @@ public class DiscoveryWellKnownProvider : IHostedService SaltB64 = Convert.ToBase64String(salt), SaltExpiresAt = exp, RefreshSec = _refreshSec, + GraceSec = (int)_gracePeriod.TotalSeconds, Endpoints = new() { Publish = $"{httpScheme}://{host}/discovery/publish", Query = $"{httpScheme}://{host}/discovery/query", - Request = $"{httpScheme}://{host}/discovery/request" + Request = $"{httpScheme}://{host}/discovery/request", + Accept = $"{httpScheme}://{host}/discovery/acceptNotify" }, Policies = new() { @@ -128,6 +147,7 @@ public class DiscoveryWellKnownProvider : IHostedService [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("grace_sec")] public int GraceSec { get; set; } [JsonPropertyName("endpoints")] public Endpoints Endpoints { get; set; } = new(); [JsonPropertyName("policies")] public Policies Policies { get; set; } = new(); } @@ -137,6 +157,7 @@ public class DiscoveryWellKnownProvider : IHostedService [JsonPropertyName("publish")] public string? Publish { get; set; } [JsonPropertyName("query")] public string? Query { get; set; } [JsonPropertyName("request")] public string? Request { get; set; } + [JsonPropertyName("accept")] public string? Accept { get; set; } } private sealed class Policies diff --git a/MareSynchronosServer/MareSynchronosServer/Controllers/DiscoveryNotifyController.cs b/MareSynchronosServer/MareSynchronosServer/Controllers/DiscoveryNotifyController.cs index 3786b30..d798c4d 100644 --- a/MareSynchronosServer/MareSynchronosServer/Controllers/DiscoveryNotifyController.cs +++ b/MareSynchronosServer/MareSynchronosServer/Controllers/DiscoveryNotifyController.cs @@ -37,4 +37,22 @@ public class DiscoveryNotifyController : Controller await _hub.Clients.User(dto.TargetUid).Client_ReceiveServerMessage(MareSynchronos.API.Data.Enum.MessageSeverity.Information, msg); return Accepted(); } + + public sealed class NotifyAcceptDto + { + [JsonPropertyName("targetUid")] public string TargetUid { get; set; } = string.Empty; + [JsonPropertyName("fromUid")] public string FromUid { get; set; } = string.Empty; + [JsonPropertyName("fromAlias")] public string? FromAlias { get; set; } + } + + [HttpPost("notifyAccept")] + public async Task NotifyAccept([FromBody] NotifyAcceptDto dto) + { + if (string.IsNullOrEmpty(dto.TargetUid)) return BadRequest(); + var name = string.IsNullOrEmpty(dto.FromAlias) ? dto.FromUid : dto.FromAlias; + var msg = $"Nearby Accept: {name} [{dto.FromUid}]"; + _logger.LogInformation("Discovery notify accept to {target} from {from}", dto.TargetUid, name); + await _hub.Clients.User(dto.TargetUid).Client_ReceiveServerMessage(MareSynchronos.API.Data.Enum.MessageSeverity.Information, msg); + return Accepted(); + } }