Merge branch 'feature/mqtt-connection-type'
This commit is contained in:
@@ -501,4 +501,112 @@ public class ConfigProcessorTests
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
|
||||
opts.HasTls.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ─── MQTT config ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_ListenHostAndPort()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("mqtt.conf"));
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.Host.ShouldBe("10.0.0.1");
|
||||
opts.Mqtt.Port.ShouldBe(1883);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_NoAuthUser()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("mqtt.conf"));
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.NoAuthUser.ShouldBe("mqtt_default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_Authorization()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("mqtt.conf"));
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.Username.ShouldBe("mqtt_user");
|
||||
opts.Mqtt.Password.ShouldBe("mqtt_pass");
|
||||
opts.Mqtt.Token.ShouldBe("mqtt_token");
|
||||
opts.Mqtt.AuthTimeout.ShouldBe(3.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_Tls()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("mqtt.conf"));
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.TlsCert.ShouldBe("/path/to/mqtt-cert.pem");
|
||||
opts.Mqtt.TlsKey.ShouldBe("/path/to/mqtt-key.pem");
|
||||
opts.Mqtt.TlsCaCert.ShouldBe("/path/to/mqtt-ca.pem");
|
||||
opts.Mqtt.TlsVerify.ShouldBeTrue();
|
||||
opts.Mqtt.TlsTimeout.ShouldBe(5.0);
|
||||
opts.Mqtt.HasTls.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_QosSettings()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("mqtt.conf"));
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.AckWait.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
opts.Mqtt.MaxAckPending.ShouldBe((ushort)2048);
|
||||
opts.Mqtt.JsApiTimeout.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_JetStreamSettings()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("mqtt.conf"));
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.JsDomain.ShouldBe("mqtt-domain");
|
||||
opts.Mqtt.StreamReplicas.ShouldBe(3);
|
||||
opts.Mqtt.ConsumerReplicas.ShouldBe(1);
|
||||
opts.Mqtt.ConsumerMemoryStorage.ShouldBeTrue();
|
||||
opts.Mqtt.ConsumerInactiveThreshold.ShouldBe(TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_MaxAckPendingValidation_ReportsError()
|
||||
{
|
||||
var ex = Should.Throw<ConfigProcessorException>(() =>
|
||||
ConfigProcessor.ProcessConfig("""
|
||||
mqtt {
|
||||
max_ack_pending: 70000
|
||||
}
|
||||
"""));
|
||||
ex.Errors.ShouldContain(e => e.Contains("max_ack_pending"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_Aliases()
|
||||
{
|
||||
// Test alias keys: "ackwait" (alias for "ack_wait"), "net" (alias for "host"),
|
||||
// "max_inflight" (alias for "max_ack_pending"), "consumer_auto_cleanup" (alias)
|
||||
var opts = ConfigProcessor.ProcessConfig("""
|
||||
mqtt {
|
||||
net: "127.0.0.1"
|
||||
port: 1884
|
||||
ackwait: "45s"
|
||||
max_inflight: 500
|
||||
api_timeout: "8s"
|
||||
consumer_auto_cleanup: "10m"
|
||||
}
|
||||
""");
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.Host.ShouldBe("127.0.0.1");
|
||||
opts.Mqtt.Port.ShouldBe(1884);
|
||||
opts.Mqtt.AckWait.ShouldBe(TimeSpan.FromSeconds(45));
|
||||
opts.Mqtt.MaxAckPending.ShouldBe((ushort)500);
|
||||
opts.Mqtt.JsApiTimeout.ShouldBe(TimeSpan.FromSeconds(8));
|
||||
opts.Mqtt.ConsumerInactiveThreshold.ShouldBe(TimeSpan.FromMinutes(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_Absent_ReturnsNull()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("port: 4222");
|
||||
opts.Mqtt.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,4 +588,279 @@ public class JwtAuthenticatorTests
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// allowed_connection_types tests
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Allowed_connection_types_allows_standard_context()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}",
|
||||
"allowed_connection_types":["STANDARD"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
ConnectionType = "STANDARD",
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(userPub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Allowed_connection_types_rejects_mqtt_only_for_standard_context()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT only allows MQTT connections
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}",
|
||||
"allowed_connection_types":["MQTT"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
ConnectionType = "STANDARD",
|
||||
};
|
||||
|
||||
// Should reject: STANDARD is not in allowed_connection_types
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Allowed_connection_types_allows_known_even_with_unknown_values()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT allows STANDARD and an unknown type
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}",
|
||||
"allowed_connection_types":["STANDARD","SOME_NEW_TYPE"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
ConnectionType = "STANDARD",
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(userPub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Allowed_connection_types_rejects_when_only_unknown_values_present()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT only allows an unknown connection type
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}",
|
||||
"allowed_connection_types":["SOME_NEW_TYPE"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
ConnectionType = "STANDARD",
|
||||
};
|
||||
|
||||
// Should reject: STANDARD is not in allowed_connection_types
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Allowed_connection_types_is_case_insensitive_for_input_values()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT allows "standard" (lowercase)
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}",
|
||||
"allowed_connection_types":["standard"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
ConnectionType = "STANDARD",
|
||||
};
|
||||
|
||||
// Should allow: case-insensitive match of "standard" == "STANDARD"
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(userPub);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,51 @@ public class MonitorTests : IAsyncLifetime
|
||||
closed.Reason.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connz_filters_by_mqtt_client_for_open_connections()
|
||||
{
|
||||
// Connect a regular NATS client (no MQTT ID)
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
|
||||
using var stream = new NetworkStream(sock);
|
||||
var buf = new byte[4096];
|
||||
_ = await stream.ReadAsync(buf);
|
||||
await stream.WriteAsync("CONNECT {}\r\n"u8.ToArray());
|
||||
await Task.Delay(200);
|
||||
|
||||
// Query for an MQTT client ID that no connection has
|
||||
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?mqtt_client=some-id");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var connz = await response.Content.ReadFromJsonAsync<Connz>();
|
||||
connz.ShouldNotBeNull();
|
||||
connz.NumConns.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connz_filters_by_mqtt_client_for_closed_connections()
|
||||
{
|
||||
// Connect then disconnect a client so it appears in closed list
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
|
||||
using var stream = new NetworkStream(sock);
|
||||
var buf = new byte[4096];
|
||||
_ = await stream.ReadAsync(buf);
|
||||
await stream.WriteAsync("CONNECT {}\r\n"u8.ToArray());
|
||||
await Task.Delay(200);
|
||||
sock.Shutdown(SocketShutdown.Both);
|
||||
sock.Dispose();
|
||||
await Task.Delay(500);
|
||||
|
||||
// Query closed connections with an MQTT client ID that no connection has
|
||||
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?state=closed&mqtt_client=missing-id");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var connz = await response.Content.ReadFromJsonAsync<Connz>();
|
||||
connz.ShouldNotBeNull();
|
||||
connz.NumConns.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connz_sort_by_stop_requires_closed_state()
|
||||
{
|
||||
@@ -226,6 +271,23 @@ public class MonitorTests : IAsyncLifetime
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Varz_includes_mqtt_section()
|
||||
{
|
||||
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var varz = await response.Content.ReadFromJsonAsync<Varz>();
|
||||
varz.ShouldNotBeNull();
|
||||
varz.Mqtt.ShouldNotBeNull();
|
||||
varz.Mqtt.Host.ShouldBe("");
|
||||
varz.Mqtt.Port.ShouldBe(0);
|
||||
varz.Mqtt.NoAuthUser.ShouldBe("");
|
||||
varz.Mqtt.JsDomain.ShouldBe("");
|
||||
varz.Mqtt.AckWait.ShouldBe(0L);
|
||||
varz.Mqtt.MaxAckPending.ShouldBe((ushort)0);
|
||||
}
|
||||
|
||||
private static int GetFreePort()
|
||||
{
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
|
||||
28
tests/NATS.Server.Tests/TestData/mqtt.conf
Normal file
28
tests/NATS.Server.Tests/TestData/mqtt.conf
Normal file
@@ -0,0 +1,28 @@
|
||||
mqtt {
|
||||
listen: "10.0.0.1:1883"
|
||||
no_auth_user: "mqtt_default"
|
||||
|
||||
authorization {
|
||||
user: "mqtt_user"
|
||||
pass: "mqtt_pass"
|
||||
token: "mqtt_token"
|
||||
timeout: 3.0
|
||||
}
|
||||
|
||||
tls {
|
||||
cert_file: "/path/to/mqtt-cert.pem"
|
||||
key_file: "/path/to/mqtt-key.pem"
|
||||
ca_file: "/path/to/mqtt-ca.pem"
|
||||
verify: true
|
||||
timeout: 5.0
|
||||
}
|
||||
|
||||
ack_wait: "60s"
|
||||
max_ack_pending: 2048
|
||||
js_domain: "mqtt-domain"
|
||||
js_api_timeout: "10s"
|
||||
stream_replicas: 3
|
||||
consumer_replicas: 1
|
||||
consumer_memory_storage: true
|
||||
consumer_inactive_threshold: "5m"
|
||||
}
|
||||
Reference in New Issue
Block a user