245 lines
11 KiB
C#
245 lines
11 KiB
C#
using System.Net;
|
|
using System.Net.WebSockets;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using System.Text.Json;
|
|
using System.Threading.RateLimiting;
|
|
using System.Threading.Channels;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using MusicBridge.Agent.Media;
|
|
|
|
namespace MusicBridge.Agent.Network;
|
|
|
|
internal sealed class RemoteServer : IAsyncDisposable
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { MaxDepth = 8 };
|
|
private readonly WebApplication app;
|
|
private readonly IMediaService media;
|
|
private readonly PairingService pairing;
|
|
private readonly SemaphoreSlim clients = new(8, 8);
|
|
private readonly CancellationTokenSource stop = new();
|
|
private Task? polling;
|
|
private MediaState latest = new();
|
|
private readonly Channel<bool> refresh = CreateSignal();
|
|
private readonly TimeSpan refreshInterval;
|
|
private event Action? Published;
|
|
|
|
public MediaState Latest => Volatile.Read(ref latest);
|
|
public IEnumerable<string> Addresses => app.Urls;
|
|
public CancellationToken Stopping => app.Lifetime.ApplicationStopping;
|
|
|
|
public RemoteServer(IMediaService media, PairingService pairing, X509Certificate2 certificate, bool lan, int port,
|
|
TimeSpan? refreshInterval = null)
|
|
{
|
|
this.media = media;
|
|
this.pairing = pairing;
|
|
this.refreshInterval = refreshInterval ?? TimeSpan.FromMilliseconds(500);
|
|
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { Args = [] });
|
|
builder.Logging.ClearProviders(); // Never log pairing bodies or bearer tokens.
|
|
builder.WebHost.ConfigureKestrel(options =>
|
|
{
|
|
options.Limits.MaxRequestBodySize = 4096;
|
|
options.Limits.MaxConcurrentConnections = 32;
|
|
options.Limits.MaxConcurrentUpgradedConnections = 8;
|
|
options.Listen(lan ? IPAddress.Any : IPAddress.Loopback, port, endpoint => endpoint.UseHttps(certificate));
|
|
});
|
|
builder.Services.Configure<HostOptions>(options => options.ShutdownTimeout = TimeSpan.FromSeconds(5));
|
|
builder.Services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(_ =>
|
|
RateLimitPartition.GetFixedWindowLimiter("agent", _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 60, Window = TimeSpan.FromSeconds(1), QueueLimit = 0
|
|
}));
|
|
});
|
|
app = builder.Build();
|
|
app.UseRateLimiter();
|
|
app.UseWebSockets(new WebSocketOptions
|
|
{
|
|
KeepAliveInterval = TimeSpan.FromSeconds(20), KeepAliveTimeout = TimeSpan.FromSeconds(10)
|
|
});
|
|
app.Use(async (context, next) =>
|
|
{
|
|
context.Response.Headers.CacheControl = "no-store";
|
|
// Native clients have no Origin. Browser pages are deliberately unsupported.
|
|
if (context.Request.Headers.ContainsKey("Origin"))
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
return;
|
|
}
|
|
if (context.Request.Path != "/v1/pair")
|
|
{
|
|
var header = context.Request.Headers.Authorization.ToString();
|
|
if (!header.StartsWith("Bearer ", StringComparison.Ordinal) || !pairing.Authorize(header[7..]))
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
return;
|
|
}
|
|
}
|
|
try { await next(context); }
|
|
catch (JsonException)
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
await context.Response.WriteAsJsonAsync(new { code = "invalid_json" }, context.RequestAborted);
|
|
}
|
|
});
|
|
app.MapPost("/v1/pair", async (HttpContext context) =>
|
|
{
|
|
var request = await JsonSerializer.DeserializeAsync<PairRequest>(context.Request.Body, JsonOptions, context.RequestAborted);
|
|
var result = pairing.Pair(request?.Code, request?.DeviceName);
|
|
return Results.Json(result, statusCode: result.Success ? 200 : result.Code == "storage_failed" ? 503 : 403);
|
|
});
|
|
app.MapGet("/v1/state", () => Latest);
|
|
app.MapGet("/v1/info", () => DiscoveryDescriptor.Create(certificate.GetCertHashString(System.Security.Cryptography.HashAlgorithmName.SHA256)));
|
|
app.MapGet("/v1/artwork/{id}", (string id, HttpContext context) =>
|
|
{
|
|
var image = media.GetArtwork(id);
|
|
if (image is null) return Results.NotFound();
|
|
context.Response.Headers.XContentTypeOptions = "nosniff";
|
|
return Results.Bytes(image.Bytes, image.ContentType);
|
|
});
|
|
app.MapPost("/v1/command", async (HttpContext context) =>
|
|
{
|
|
var command = await JsonSerializer.DeserializeAsync<MediaCommand>(context.Request.Body, JsonOptions, context.RequestAborted);
|
|
if (command is null) return Results.BadRequest(new { code = "invalid_command" });
|
|
var invalid = CommandValidation.Validate(command);
|
|
if (invalid is not null) return Results.BadRequest(invalid with { Id = command.Id });
|
|
var result = await media.ExecuteAsync(command, context.RequestAborted);
|
|
return Results.Json(result);
|
|
});
|
|
app.MapGet("/v1/events", StreamAsync);
|
|
}
|
|
|
|
public async Task StartAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
media.Changed += RequestRefresh;
|
|
Volatile.Write(ref latest, await ReadSafeAsync(cancellationToken));
|
|
await app.StartAsync(cancellationToken);
|
|
polling = PollAsync(stop.Token);
|
|
}
|
|
|
|
private async Task PollAsync(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
deadline.CancelAfter(refreshInterval);
|
|
try { await refresh.Reader.ReadAsync(deadline.Token); }
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { }
|
|
// Coalesce bursts of Windows events without accumulating work.
|
|
await Task.Delay(25, cancellationToken);
|
|
while (refresh.Reader.TryRead(out _)) { }
|
|
var state = await ReadSafeAsync(cancellationToken);
|
|
Volatile.Write(ref latest, state);
|
|
Published?.Invoke();
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
|
|
}
|
|
|
|
private async Task<MediaState> ReadSafeAsync(CancellationToken cancellationToken)
|
|
{
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeout.CancelAfter(TimeSpan.FromSeconds(5));
|
|
try { return await media.ReadAsync(timeout.Token); }
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
|
catch (OperationCanceledException) { return new MediaState { MediaError = "Плеер не ответил вовремя. Повторяем подключение." }; }
|
|
catch (Exception ex) { return new MediaState { MediaError = $"Плеер временно недоступен: {ex.Message}" }; }
|
|
}
|
|
|
|
// WSS is a state stream; commands use HTTPS so each has its own response.
|
|
private async Task StreamAsync(HttpContext context)
|
|
{
|
|
if (!context.WebSockets.IsWebSocketRequest)
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
return;
|
|
}
|
|
if (!await clients.WaitAsync(0, context.RequestAborted))
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
using var socket = await context.WebSockets.AcceptWebSocketAsync();
|
|
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, stop.Token, Stopping);
|
|
var token = context.Request.Headers.Authorization.ToString()[7..];
|
|
var sender = SendStatesAsync(socket, token, lifetime.Token);
|
|
var receiver = ReceiveCloseAsync(socket, lifetime.Token);
|
|
await Task.WhenAny(sender, receiver);
|
|
await lifetime.CancelAsync();
|
|
try { await Task.WhenAll(sender, receiver); }
|
|
catch (OperationCanceledException) { }
|
|
catch (WebSocketException) { }
|
|
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
|
{
|
|
using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
|
try
|
|
{
|
|
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "State stream closed", closeTimeout.Token);
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
catch (WebSocketException) { }
|
|
}
|
|
}
|
|
finally { clients.Release(); }
|
|
}
|
|
|
|
private async Task SendStatesAsync(WebSocket socket, string token, CancellationToken cancellationToken)
|
|
{
|
|
var pending = CreateSignal();
|
|
void Signal() => pending.Writer.TryWrite(true);
|
|
Published += Signal;
|
|
pairing.TrustChanged += Signal;
|
|
Signal();
|
|
try
|
|
{
|
|
while (await pending.Reader.WaitToReadAsync(cancellationToken))
|
|
{
|
|
while (pending.Reader.TryRead(out _)) { }
|
|
if (!pairing.Authorize(token)) return;
|
|
var bytes = JsonSerializer.SerializeToUtf8Bytes(new { type = "state", state = Latest }, JsonOptions);
|
|
using var sendTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
sendTimeout.CancelAfter(TimeSpan.FromSeconds(5));
|
|
await socket.SendAsync(bytes.AsMemory(), WebSocketMessageType.Text, true, sendTimeout.Token);
|
|
}
|
|
}
|
|
finally { Published -= Signal; pairing.TrustChanged -= Signal; }
|
|
}
|
|
|
|
private static Channel<bool> CreateSignal() => Channel.CreateBounded<bool>(new BoundedChannelOptions(1)
|
|
{
|
|
FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false
|
|
});
|
|
private void RequestRefresh() => refresh.Writer.TryWrite(true);
|
|
|
|
private static async Task ReceiveCloseAsync(WebSocket socket, CancellationToken cancellationToken)
|
|
{
|
|
var buffer = new byte[1];
|
|
// A receive observes disconnects. No commands or client payloads are accepted here.
|
|
await socket.ReceiveAsync(buffer.AsMemory(), cancellationToken);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
media.Changed -= RequestRefresh;
|
|
await stop.CancelAsync();
|
|
if (polling is not null) await polling;
|
|
await app.StopAsync();
|
|
await app.DisposeAsync();
|
|
stop.Dispose();
|
|
clients.Dispose();
|
|
}
|
|
|
|
private sealed record PairRequest(string? Code, string? DeviceName);
|
|
}
|