feat(config): add system account, SIGHUP reload, and auth change propagation (E6+E7+E8)
E6: Add IsSystemAccount property to Account, mark $SYS account as system, add IsSystemSubject/IsSubscriptionAllowed/GetSubListForSubject helpers to route $SYS.> subjects to the system account's SubList and block non-system accounts from subscribing. E7: Add ConfigReloader.ReloadAsync and ApplyDiff for structured async reload, add ConfigReloadResult/ConfigApplyResult types. SIGHUP handler already wired via PosixSignalRegistration in HandleSignals. E8: Add PropagateAuthChanges to re-evaluate connected clients after auth config reload, disconnecting clients whose credentials no longer pass authentication with -ERR 'Authorization Violation'.
This commit is contained in:
497
tests/NATS.Server.Tests/LeafNodes/LeafSubjectFilterTests.cs
Normal file
497
tests/NATS.Server.Tests/LeafNodes/LeafSubjectFilterTests.cs
Normal file
@@ -0,0 +1,497 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.LeafNodes;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.LeafNodes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for leaf node subject filtering via DenyExports and DenyImports.
|
||||
/// Go reference: leafnode.go:470-507 (newLeafNodeCfg), opts.go:230-231
|
||||
/// (DenyImports/DenyExports fields in RemoteLeafOpts).
|
||||
/// </summary>
|
||||
public class LeafSubjectFilterTests
|
||||
{
|
||||
// ── LeafHubSpokeMapper.IsSubjectAllowed Unit Tests ────────────────
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Literal_deny_export_blocks_outbound_subject()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: ["secret.data"],
|
||||
denyImports: []);
|
||||
|
||||
mapper.IsSubjectAllowed("secret.data", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Literal_deny_import_blocks_inbound_subject()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: [],
|
||||
denyImports: ["internal.status"]);
|
||||
|
||||
mapper.IsSubjectAllowed("internal.status", LeafMapDirection.Inbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("external.status", LeafMapDirection.Inbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Wildcard_deny_export_blocks_matching_subjects()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: ["admin.*"],
|
||||
denyImports: []);
|
||||
|
||||
mapper.IsSubjectAllowed("admin.users", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("admin.config", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("admin.deep.nested", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Fwc_deny_import_blocks_all_matching_subjects()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: [],
|
||||
denyImports: ["_SYS.>"]);
|
||||
|
||||
mapper.IsSubjectAllowed("_SYS.heartbeat", LeafMapDirection.Inbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("_SYS.a.b.c", LeafMapDirection.Inbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("user.data", LeafMapDirection.Inbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Bidirectional_filtering_applies_independently()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: ["export.denied"],
|
||||
denyImports: ["import.denied"]);
|
||||
|
||||
// Export deny does not affect inbound direction
|
||||
mapper.IsSubjectAllowed("export.denied", LeafMapDirection.Inbound).ShouldBeTrue();
|
||||
mapper.IsSubjectAllowed("export.denied", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
|
||||
// Import deny does not affect outbound direction
|
||||
mapper.IsSubjectAllowed("import.denied", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
mapper.IsSubjectAllowed("import.denied", LeafMapDirection.Inbound).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Multiple_deny_patterns_all_evaluated()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: ["admin.*", "secret.>", "internal.config"],
|
||||
denyImports: []);
|
||||
|
||||
mapper.IsSubjectAllowed("admin.users", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("secret.key.value", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("internal.config", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Empty_deny_lists_allow_everything()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: [],
|
||||
denyImports: []);
|
||||
|
||||
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Inbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Account_mapping_still_works_with_subject_filter()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string> { ["HUB_ACCT"] = "SPOKE_ACCT" },
|
||||
denyExports: ["denied.>"],
|
||||
denyImports: []);
|
||||
|
||||
var outbound = mapper.Map("HUB_ACCT", "foo.bar", LeafMapDirection.Outbound);
|
||||
outbound.Account.ShouldBe("SPOKE_ACCT");
|
||||
outbound.Subject.ShouldBe("foo.bar");
|
||||
|
||||
var inbound = mapper.Map("SPOKE_ACCT", "foo.bar", LeafMapDirection.Inbound);
|
||||
inbound.Account.ShouldBe("HUB_ACCT");
|
||||
inbound.Subject.ShouldBe("foo.bar");
|
||||
|
||||
mapper.IsSubjectAllowed("denied.test", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Default_constructor_allows_everything()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(new Dictionary<string, string>());
|
||||
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Inbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ── Integration: DenyExports blocks hub→leaf message forwarding ────
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public async Task DenyExports_blocks_message_forwarding_hub_to_leaf()
|
||||
{
|
||||
// Start a hub with DenyExports configured
|
||||
var hubOptions = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
DenyExports = ["secret.>"],
|
||||
},
|
||||
};
|
||||
|
||||
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
|
||||
var hubCts = new CancellationTokenSource();
|
||||
_ = hub.StartAsync(hubCts.Token);
|
||||
await hub.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var spokeOptions = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Remotes = [hub.LeafListen!],
|
||||
},
|
||||
};
|
||||
|
||||
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
|
||||
var spokeCts = new CancellationTokenSource();
|
||||
_ = spoke.StartAsync(spokeCts.Token);
|
||||
await spoke.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for leaf connection
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
|
||||
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
|
||||
await leafConn.ConnectAsync();
|
||||
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
|
||||
await hubConn.ConnectAsync();
|
||||
|
||||
// Subscribe on spoke for allowed and denied subjects
|
||||
await using var allowedSub = await leafConn.SubscribeCoreAsync<string>("public.data");
|
||||
await using var deniedSub = await leafConn.SubscribeCoreAsync<string>("secret.data");
|
||||
await leafConn.PingAsync();
|
||||
|
||||
// Wait for interest propagation
|
||||
await Task.Delay(500);
|
||||
|
||||
// Publish from hub
|
||||
await hubConn.PublishAsync("public.data", "allowed-msg");
|
||||
await hubConn.PublishAsync("secret.data", "denied-msg");
|
||||
|
||||
// The allowed message should arrive
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed-msg");
|
||||
|
||||
// The denied message should NOT arrive
|
||||
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await deniedSub.Msgs.ReadAsync(leakCts.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await spokeCts.CancelAsync();
|
||||
spoke.Dispose();
|
||||
spokeCts.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await hubCts.CancelAsync();
|
||||
hub.Dispose();
|
||||
hubCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public async Task DenyImports_blocks_message_forwarding_leaf_to_hub()
|
||||
{
|
||||
// Start hub with DenyImports — leaf→hub messages for denied subjects are dropped
|
||||
var hubOptions = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
DenyImports = ["private.>"],
|
||||
},
|
||||
};
|
||||
|
||||
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
|
||||
var hubCts = new CancellationTokenSource();
|
||||
_ = hub.StartAsync(hubCts.Token);
|
||||
await hub.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var spokeOptions = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Remotes = [hub.LeafListen!],
|
||||
},
|
||||
};
|
||||
|
||||
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
|
||||
var spokeCts = new CancellationTokenSource();
|
||||
_ = spoke.StartAsync(spokeCts.Token);
|
||||
await spoke.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for leaf connection
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
|
||||
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
|
||||
await hubConn.ConnectAsync();
|
||||
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
|
||||
await leafConn.ConnectAsync();
|
||||
|
||||
// Subscribe on hub for both allowed and denied subjects
|
||||
await using var allowedSub = await hubConn.SubscribeCoreAsync<string>("public.data");
|
||||
await using var deniedSub = await hubConn.SubscribeCoreAsync<string>("private.data");
|
||||
await hubConn.PingAsync();
|
||||
|
||||
// Wait for interest propagation
|
||||
await Task.Delay(500);
|
||||
|
||||
// Publish from spoke (leaf)
|
||||
await leafConn.PublishAsync("public.data", "allowed-msg");
|
||||
await leafConn.PublishAsync("private.data", "denied-msg");
|
||||
|
||||
// The allowed message should arrive on hub
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed-msg");
|
||||
|
||||
// The denied message should NOT arrive
|
||||
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await deniedSub.Msgs.ReadAsync(leakCts.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await spokeCts.CancelAsync();
|
||||
spoke.Dispose();
|
||||
spokeCts.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await hubCts.CancelAsync();
|
||||
hub.Dispose();
|
||||
hubCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public async Task DenyExports_with_wildcard_blocks_pattern_matching_subjects()
|
||||
{
|
||||
var hubOptions = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
DenyExports = ["admin.*"],
|
||||
},
|
||||
};
|
||||
|
||||
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
|
||||
var hubCts = new CancellationTokenSource();
|
||||
_ = hub.StartAsync(hubCts.Token);
|
||||
await hub.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var spokeOptions = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Remotes = [hub.LeafListen!],
|
||||
},
|
||||
};
|
||||
|
||||
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
|
||||
var spokeCts = new CancellationTokenSource();
|
||||
_ = spoke.StartAsync(spokeCts.Token);
|
||||
await spoke.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
|
||||
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
|
||||
await leafConn.ConnectAsync();
|
||||
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
|
||||
await hubConn.ConnectAsync();
|
||||
|
||||
// admin.users should be blocked; admin.deep.nested should pass (* doesn't match multi-token)
|
||||
await using var blockedSub = await leafConn.SubscribeCoreAsync<string>("admin.users");
|
||||
await using var allowedSub = await leafConn.SubscribeCoreAsync<string>("admin.deep.nested");
|
||||
await leafConn.PingAsync();
|
||||
await Task.Delay(500);
|
||||
|
||||
await hubConn.PublishAsync("admin.users", "blocked");
|
||||
await hubConn.PublishAsync("admin.deep.nested", "allowed");
|
||||
|
||||
// The multi-token subject passes because * matches only single token
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed");
|
||||
|
||||
// The single-token subject is blocked
|
||||
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await blockedSub.Msgs.ReadAsync(leakCts.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await spokeCts.CancelAsync();
|
||||
spoke.Dispose();
|
||||
spokeCts.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await hubCts.CancelAsync();
|
||||
hub.Dispose();
|
||||
hubCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wire-level: DenyExports blocks LS+ propagation ──────────────
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public async Task DenyExports_blocks_subscription_propagation()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
var options = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
DenyExports = ["secret.>"],
|
||||
};
|
||||
|
||||
var manager = new LeafNodeManager(
|
||||
options,
|
||||
new ServerStats(),
|
||||
"HUB1",
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<LeafNodeManager>.Instance);
|
||||
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, options.Port);
|
||||
|
||||
// Exchange handshakes — inbound connections send LEAF first, then read response
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await WriteLineAsync(remoteSocket, "LEAF SPOKE1", cts.Token);
|
||||
var line = await ReadLineAsync(remoteSocket, cts.Token);
|
||||
line.ShouldStartWith("LEAF ");
|
||||
|
||||
await Task.Delay(200);
|
||||
|
||||
// Propagate allowed subscription
|
||||
manager.PropagateLocalSubscription("$G", "public.data", null);
|
||||
await Task.Delay(100);
|
||||
var lsLine = await ReadLineAsync(remoteSocket, cts.Token);
|
||||
lsLine.ShouldBe("LS+ $G public.data");
|
||||
|
||||
// Propagate denied subscription — should NOT appear on wire
|
||||
manager.PropagateLocalSubscription("$G", "secret.data", null);
|
||||
|
||||
// Send a PING to verify nothing else was sent
|
||||
manager.PropagateLocalSubscription("$G", "allowed.check", null);
|
||||
await Task.Delay(100);
|
||||
var nextLine = await ReadLineAsync(remoteSocket, cts.Token);
|
||||
nextLine.ShouldBe("LS+ $G allowed.check");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await manager.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
if (single[0] == (byte)'\n')
|
||||
break;
|
||||
if (single[0] != (byte)'\r')
|
||||
bytes.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||
}
|
||||
Reference in New Issue
Block a user