Add MusicBridge protocol documentation and implement system volume control
- Created PROTOCOL.md to outline the MusicBridge protocol v1, detailing setup, TLS, pairing, state management, and command structure. - Implemented SystemVolume class for managing system audio levels, including methods for setting, reading, changing, and muting volume. - Added unit tests for the MusicBridge agent, covering pairing, identity storage, command validation, and media state management. - Included tests for real-time media reading from Windows and ensured proper handling of artwork caching and retrieval.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
internal static class AgentCertificate
|
||||
{
|
||||
public static X509Certificate2 Create()
|
||||
{
|
||||
using var key = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=MusicBridge Agent", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
|
||||
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
|
||||
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection { new("1.3.6.1.5.5.7.3.1") }, false));
|
||||
var names = new SubjectAlternativeNameBuilder();
|
||||
names.AddDnsName("localhost");
|
||||
names.AddIpAddress(IPAddress.Loopback);
|
||||
request.CertificateExtensions.Add(names.Build());
|
||||
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddYears(5));
|
||||
// Reload so Schannel can use the private key independently of the RSA object.
|
||||
return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null, X509KeyStorageFlags.Exportable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
// QR payload contract only. Rendering/scanning and the native client are separate steps.
|
||||
internal sealed record ConnectionInvitation(int Version, string Endpoint, string CertificateSha256,
|
||||
string Code, DateTimeOffset ExpiresAt)
|
||||
{
|
||||
public static ConnectionInvitation Create(Uri endpoint, string fingerprint, PairingWindow window, DateTimeOffset now)
|
||||
{
|
||||
if (!IsLocalEndpoint(endpoint)) throw new ArgumentException("Нужен HTTPS IPv4-адрес домашней сети без пути и параметров.");
|
||||
if (fingerprint.Length != 64 || !fingerprint.All(Uri.IsHexDigit)) throw new ArgumentException("Неверный отпечаток сертификата.");
|
||||
if (window.Code.Length != 8 || !window.Code.All(char.IsAsciiDigit) || window.ExpiresAt <= now)
|
||||
throw new ArgumentException("Окно сопряжения закрыто или код неверен.");
|
||||
return new(1, endpoint.GetLeftPart(UriPartial.Authority), fingerprint.ToUpperInvariant(), window.Code, window.ExpiresAt);
|
||||
}
|
||||
|
||||
public string ToQrPayload()
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(this, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
var data = Convert.ToBase64String(json).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
return "musicbridge://pair?data=" + data;
|
||||
}
|
||||
|
||||
public static bool IsLocalEndpoint(Uri endpoint)
|
||||
{
|
||||
if (!endpoint.IsAbsoluteUri || endpoint.Scheme != "https" || endpoint.UserInfo != ""
|
||||
|| endpoint.AbsolutePath != "/" || endpoint.Query != "" || endpoint.Fragment != ""
|
||||
|| !IPAddress.TryParse(endpoint.Host, out var ip)) return false;
|
||||
var bytes = ip.GetAddressBytes();
|
||||
return bytes.Length == 4 && (bytes[0] == 10 || bytes[0] == 172 && bytes[1] is >= 16 and <= 31
|
||||
|| bytes[0] == 192 && bytes[1] == 168);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
// Data for a future DNS-SD publisher. Does not open sockets or advertise pairing secrets.
|
||||
internal sealed record DiscoveryDescriptor(int ProtocolVersion, string AgentId, string ServiceType, string InstanceName)
|
||||
{
|
||||
public static DiscoveryDescriptor Create(string certificateSha256)
|
||||
{
|
||||
if (certificateSha256.Length != 64 || !certificateSha256.All(Uri.IsHexDigit))
|
||||
throw new ArgumentException("Неверный идентификатор сертификата.");
|
||||
var id = certificateSha256.ToUpperInvariant();
|
||||
return new(1, id, "_musicbridge._tcp.local.", "MusicBridge-" + id[..12]);
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> TxtRecords() => new Dictionary<string, string>
|
||||
{
|
||||
["v"] = ProtocolVersion.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["id"] = AgentId
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
internal sealed record TrustedDevice(string Id, string Name, string TokenHash, DateTimeOffset CreatedAt);
|
||||
|
||||
internal sealed class IdentityStore : IDisposable
|
||||
{
|
||||
private sealed record Identity(int Version, byte[] Certificate, TrustedDevice[] Devices);
|
||||
private readonly FileStream owner;
|
||||
private readonly string path;
|
||||
private Identity identity = null!;
|
||||
public X509Certificate2 Certificate { get; private set; } = null!;
|
||||
public IReadOnlyList<TrustedDevice> Devices => identity.Devices;
|
||||
public static string DefaultDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MusicBridge");
|
||||
|
||||
public IdentityStore(string directory)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
path = Path.Combine(directory, "identity.dat");
|
||||
// Prevent two agents from racing the certificate or overwriting trusted devices.
|
||||
owner = new FileStream(Path.Combine(directory, "identity.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
if (new FileInfo(path).Length > 1024 * 1024) throw new InvalidDataException("Хранилище слишком большое.");
|
||||
var clear = UserProtection.Unprotect(File.ReadAllBytes(path));
|
||||
try { identity = JsonSerializer.Deserialize<Identity>(clear) ?? throw new InvalidDataException("Пустое хранилище."); }
|
||||
finally { CryptographicOperations.ZeroMemory(clear); }
|
||||
if (identity.Version != 1 || identity.Certificate is null || identity.Devices is null || identity.Devices.Length > 8
|
||||
|| identity.Devices.Any(d => d is null || !Guid.TryParseExact(d.Id, "N", out _) || string.IsNullOrWhiteSpace(d.Name)
|
||||
|| d.Name.Length > 64 || d.TokenHash is null || d.TokenHash.Length != 64 || !d.TokenHash.All(Uri.IsHexDigit))
|
||||
|| identity.Devices.Select(d => d.Id).Distinct().Count() != identity.Devices.Length)
|
||||
throw new InvalidDataException("Неверный формат хранилища.");
|
||||
Certificate = X509CertificateLoader.LoadPkcs12(identity.Certificate, null);
|
||||
if (!Certificate.HasPrivateKey || Certificate.NotAfter.ToUniversalTime() <= DateTime.UtcNow)
|
||||
throw new InvalidDataException("Сертификат недоступен или истёк. Хранилище сохранено без изменений.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Certificate = AgentCertificate.Create();
|
||||
identity = new(1, Certificate.Export(X509ContentType.Pfx), []);
|
||||
Save(identity);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Certificate?.Dispose();
|
||||
if (identity?.Certificate is { } bytes) CryptographicOperations.ZeroMemory(bytes);
|
||||
owner.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveDevices(TrustedDevice[] devices)
|
||||
{
|
||||
var updated = identity with { Devices = devices };
|
||||
Save(updated); // Commit to memory only after the protected file is safely replaced.
|
||||
identity = updated;
|
||||
}
|
||||
|
||||
private void Save(Identity value)
|
||||
{
|
||||
var clear = JsonSerializer.SerializeToUtf8Bytes(value);
|
||||
byte[] encrypted;
|
||||
try { encrypted = UserProtection.Protect(clear); }
|
||||
finally { CryptographicOperations.ZeroMemory(clear); }
|
||||
var temporary = path + ".tmp";
|
||||
try
|
||||
{
|
||||
using (var file = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
file.Write(encrypted);
|
||||
file.Flush(flushToDisk: true);
|
||||
}
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Certificate.Dispose();
|
||||
CryptographicOperations.ZeroMemory(identity.Certificate);
|
||||
owner.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
internal static class LocalAddresses
|
||||
{
|
||||
public static IPAddress[] Read() => NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(nic => nic.OperationalStatus == OperationalStatus.Up && nic.SupportsMulticast
|
||||
&& nic.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||
.SelectMany(nic => nic.GetIPProperties().UnicastAddresses)
|
||||
.Select(a => a.Address)
|
||||
.Where(IsEligible).Distinct().OrderBy(a => a.ToString(), StringComparer.Ordinal).ToArray();
|
||||
|
||||
public static bool IsEligible(IPAddress ip) => ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork
|
||||
&& ConnectionInvitation.IsLocalEndpoint(new Uri($"https://{ip}"));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net;
|
||||
using Makaretu.Dns;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
// All lifecycle calls run on the console loop, not on network-change callback threads.
|
||||
internal sealed class MdnsPublisher(string fingerprint, int port) : IDisposable
|
||||
{
|
||||
private MulticastService? multicast;
|
||||
private ServiceDiscovery? discovery;
|
||||
private ServiceProfile? profile;
|
||||
private string addressesKey = "";
|
||||
private DateTimeOffset nextAnnouncement;
|
||||
public string Status { get; private set; } = "mDNS: ожидание локальной сети.";
|
||||
|
||||
public static ServiceProfile CreateProfile(string fingerprint, int port, IPAddress[] addresses)
|
||||
{
|
||||
if (port is < 1 or > 65535 || addresses.Length == 0 || addresses.Any(a => !LocalAddresses.IsEligible(a)))
|
||||
throw new ArgumentException("Неверный порт или адрес объявления mDNS.");
|
||||
var descriptor = DiscoveryDescriptor.Create(fingerprint);
|
||||
var result = new ServiceProfile(descriptor.InstanceName, "_musicbridge._tcp", (ushort)port, addresses);
|
||||
foreach (var pair in descriptor.TxtRecords()) result.AddProperty(pair.Key, pair.Value);
|
||||
foreach (var record in result.Resources) record.TTL = TimeSpan.FromSeconds(120);
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Refresh(IPAddress[] addresses)
|
||||
{
|
||||
try
|
||||
{
|
||||
var key = string.Join(",", addresses.Select(a => a.ToString()));
|
||||
if (discovery is null || key != addressesKey)
|
||||
{
|
||||
Stop();
|
||||
if (addresses.Length == 0) { Status = "mDNS: нет адреса домашней сети."; return; }
|
||||
multicast = new MulticastService(nics => nics.Where(nic => nic.GetIPProperties().UnicastAddresses.Any(a => addresses.Contains(a.Address))))
|
||||
{
|
||||
UseIpv4 = true, UseIpv6 = false
|
||||
};
|
||||
profile = CreateProfile(fingerprint, port, addresses);
|
||||
discovery = new ServiceDiscovery(multicast);
|
||||
discovery.Advertise(profile);
|
||||
multicast.Start();
|
||||
addressesKey = key;
|
||||
nextAnnouncement = DateTimeOffset.MinValue;
|
||||
}
|
||||
if (DateTimeOffset.UtcNow >= nextAnnouncement)
|
||||
{
|
||||
var message = new Message { QR = true, AA = true };
|
||||
message.Answers.Add(new PTRRecord
|
||||
{
|
||||
Name = profile!.QualifiedServiceName, DomainName = profile.FullyQualifiedName, TTL = TimeSpan.FromSeconds(120)
|
||||
});
|
||||
foreach (var record in profile.Resources) message.Answers.Add(record);
|
||||
multicast!.SendAnswer(message, checkDuplicate: false);
|
||||
nextAnnouncement = DateTimeOffset.UtcNow.AddSeconds(30);
|
||||
}
|
||||
Status = $"mDNS: объявляется {profile!.FullyQualifiedName}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Stop();
|
||||
Status = $"mDNS недоступен: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void Stop()
|
||||
{
|
||||
try { if (profile is not null) discovery?.Unadvertise(profile); }
|
||||
catch (Exception) { /* Best-effort goodbye when a network has disappeared. */ }
|
||||
discovery?.Dispose();
|
||||
multicast?.Dispose();
|
||||
discovery = null;
|
||||
multicast = null;
|
||||
profile = null;
|
||||
addressesKey = "";
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using QRCoder;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
internal static class PairingQr
|
||||
{
|
||||
public static byte[] Render(ConnectionInvitation invitation)
|
||||
{
|
||||
using var data = QRCodeGenerator.GenerateQrCode(invitation.ToQrPayload(), QRCodeGenerator.ECCLevel.M);
|
||||
using var renderer = new PngByteQRCode(data);
|
||||
return renderer.GetGraphic(6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
internal sealed record PairingWindow(string Code, DateTimeOffset ExpiresAt);
|
||||
internal sealed record PairingResult(bool Success, string? Token, string Code, string? DeviceId = null);
|
||||
|
||||
internal sealed class PairingService(TimeProvider? clock = null, IdentityStore? store = null)
|
||||
{
|
||||
private readonly TimeProvider clock = clock ?? TimeProvider.System;
|
||||
private readonly object gate = new();
|
||||
private TrustedDevice[] devices = store?.Devices.ToArray() ?? [];
|
||||
public event Action? TrustChanged;
|
||||
public IReadOnlyList<TrustedDevice> Devices { get { lock (gate) return devices.ToArray(); } }
|
||||
private PairingWindow? window;
|
||||
private int attempts;
|
||||
|
||||
public PairingWindow Open()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
attempts = 0;
|
||||
return window = new(RandomNumberGenerator.GetInt32(100_000_000).ToString("D8"),
|
||||
clock.GetUtcNow().AddMinutes(5));
|
||||
}
|
||||
}
|
||||
|
||||
public PairingWindow? Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
return window is not null && window.ExpiresAt > clock.GetUtcNow() && attempts < 10 ? window : null;
|
||||
}
|
||||
}
|
||||
|
||||
public PairingResult Pair(string? code, string? deviceName = null)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (window is null || window.ExpiresAt <= clock.GetUtcNow() || attempts >= 10)
|
||||
return new(false, null, "pairing_closed");
|
||||
attempts++;
|
||||
if (code is null || code.Length != 8 || !CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(code), Encoding.UTF8.GetBytes(window.Code)))
|
||||
return new(false, null, "invalid_code");
|
||||
if (deviceName is not null && (string.IsNullOrWhiteSpace(deviceName) || deviceName.Length > 64 || deviceName.Any(char.IsControl)))
|
||||
return new(false, null, "invalid_name");
|
||||
if (devices.Length >= 8) return new(false, null, "device_limit");
|
||||
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
var device = new TrustedDevice(Guid.NewGuid().ToString("N"), deviceName?.Trim() ?? "iPhone",
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))), clock.GetUtcNow());
|
||||
var updated = devices.Append(device).ToArray();
|
||||
try { store?.SaveDevices(updated); }
|
||||
catch (Exception) { return new(false, null, "storage_failed"); }
|
||||
devices = updated;
|
||||
window = null; // One successful pairing per locally opened window.
|
||||
return new(true, token, "ok", device.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Authorize(string? token)
|
||||
{
|
||||
if (token is null || token.Length != 64) return false;
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
lock (gate) return devices.Any(candidate => CryptographicOperations.FixedTimeEquals(Convert.FromHexString(candidate.TokenHash), hash));
|
||||
}
|
||||
|
||||
public bool Revoke(string id)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var updated = devices.Where(d => d.Id != id).ToArray();
|
||||
if (updated.Length == devices.Length) return false;
|
||||
store?.SaveDevices(updated);
|
||||
devices = updated;
|
||||
}
|
||||
TrustChanged?.Invoke();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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) });
|
||||
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 media.ReadAsync(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 media.ReadAsync(cancellationToken);
|
||||
Volatile.Write(ref latest, state);
|
||||
Published?.Invoke();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
|
||||
}
|
||||
|
||||
// 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);
|
||||
await socket.SendAsync(bytes.AsMemory(), WebSocketMessageType.Text, true, cancellationToken);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
// Windows DPAPI, scoped to the signed-in user. No UI or machine-wide key.
|
||||
internal static class UserProtection
|
||||
{
|
||||
public static byte[] Protect(byte[] bytes) => Transform(bytes, true);
|
||||
public static byte[] Unprotect(byte[] bytes) => Transform(bytes, false);
|
||||
|
||||
private static byte[] Transform(byte[] bytes, bool protect)
|
||||
{
|
||||
var input = new Blob { Size = bytes.Length, Data = Marshal.AllocHGlobal(bytes.Length) };
|
||||
var output = new Blob();
|
||||
try
|
||||
{
|
||||
Marshal.Copy(bytes, 0, input.Data, bytes.Length);
|
||||
var ok = protect
|
||||
? CryptProtectData(ref input, null, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 1, out output)
|
||||
: CryptUnprotectData(ref input, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 1, out output);
|
||||
if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error(), "Не удалось открыть защищённое хранилище Windows.");
|
||||
var result = new byte[output.Size];
|
||||
Marshal.Copy(output.Data, result, 0, output.Size);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.Copy(new byte[input.Size], 0, input.Data, input.Size);
|
||||
Marshal.FreeHGlobal(input.Data);
|
||||
if (output.Data != IntPtr.Zero)
|
||||
{
|
||||
Marshal.Copy(new byte[output.Size], 0, output.Data, output.Size);
|
||||
LocalFree(output.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Blob { public int Size; public IntPtr Data; }
|
||||
[DllImport("crypt32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CryptProtectData(ref Blob input, string? description, IntPtr entropy, IntPtr reserved,
|
||||
IntPtr prompt, uint flags, out Blob output);
|
||||
[DllImport("crypt32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CryptUnprotectData(ref Blob input, IntPtr description, IntPtr entropy, IntPtr reserved,
|
||||
IntPtr prompt, uint flags, out Blob output);
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern IntPtr LocalFree(IntPtr memory);
|
||||
}
|
||||
Reference in New Issue
Block a user