Merge branch 'feature/mqtt-connection-type'

This commit is contained in:
Joseph Doherty
2026-02-23 06:15:32 -05:00
18 changed files with 794 additions and 4 deletions

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View 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"
}