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:
2026-09-09 01:05:52 +03:00
parent 5e476c4c2f
commit d5e827112a
23 changed files with 2067 additions and 72 deletions
+25
View File
@@ -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);
}
}
+35
View File
@@ -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);
}
}
+19
View File
@@ -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
};
}
+90
View File
@@ -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();
}
}
+17
View File
@@ -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}"));
}
+80
View File
@@ -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();
}
+13
View File
@@ -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);
}
}
+82
View File
@@ -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;
}
}
+229
View File
@@ -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);
}
+51
View File
@@ -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);
}