diff --git a/dart/lib/src/protobuf_client.dart b/dart/lib/src/protobuf_client.dart index 2dac763..9e45395 100644 --- a/dart/lib/src/protobuf_client.dart +++ b/dart/lib/src/protobuf_client.dart @@ -14,6 +14,32 @@ abstract class ProtobufClient extends Communicator { late int _heartbeatIntervalTime; late int _heartbeatWaitTime; final Socket _socket; + + /// Plain TCP connection. + /// + /// ```dart + /// final socket = await ProtobufClient.connect('localhost', 9090); + /// final client = MyClient(socket); + /// ``` + static Future connect(String host, int port) => + Socket.connect(host, port); + + /// SSL/TLS connection. + /// + /// ```dart + /// final socket = await ProtobufClient.connectSecure('example.com', 9090); + /// final client = MyClient(socket); + /// ``` + /// + /// For self-signed or custom certificates, provide a [SecurityContext]: + /// ```dart + /// final ctx = SecurityContext()..setTrustedCertificates('ca.crt'); + /// final socket = await ProtobufClient.connectSecure('host', 9090, context: ctx); + /// ``` + static Future connectSecure(String host, int port, + {SecurityContext? context}) => + SecureSocket.connect(host, port, + context: context ?? SecurityContext.defaultContext); late ResponseChecker? _heartbeatChecker = null; int? _length = null; bool _isAlive = false; diff --git a/dart/lib/src/protobuf_server.dart b/dart/lib/src/protobuf_server.dart index 4602ffd..f8153a9 100644 --- a/dart/lib/src/protobuf_server.dart +++ b/dart/lib/src/protobuf_server.dart @@ -8,28 +8,54 @@ import 'protobuf_client.dart'; abstract class ProtobufServer { List _clientList = []; bool _started = false; - late ServerSocket? _server; + ServerSocket? _server; + SecureServerSocket? _secureServer; final String _host; final int _port; + final SecurityContext? _securityContext; final ProtobufClient Function(Socket) _createNewClient; bool get started => _started; + bool get isSecure => _securityContext != null; - ProtobufServer(this._host, this._port, this._createNewClient); + /// Plain TCP server. + ProtobufServer(this._host, this._port, this._createNewClient) + : _securityContext = null; + + /// SSL/TLS server. + /// + /// [securityContext] must have a certificate and private key loaded: + /// ```dart + /// final ctx = SecurityContext() + /// ..useCertificateChain('server.crt') + /// ..usePrivateKey('server.key'); + /// final server = MyServer.secure('0.0.0.0', 9090, ctx, (s) => MyClient(s)); + /// ``` + ProtobufServer.secure( + this._host, this._port, SecurityContext securityContext, this._createNewClient) + : _securityContext = securityContext; Future start() async { - _server = await ServerSocket.bind(_host, _port); - _started = true; - _server?.listen((Socket socket) { - var client = _createNewClient(socket); - client.addDisconnectListener(onDisconnectedClient); - client.addListener((_) => onClientHeartBeat(client)); - _clientList.add(client); - onClientConnected(client); - }); + if (_securityContext != null) { + _secureServer = await SecureServerSocket.bind(_host, _port, _securityContext!); + _started = true; + _secureServer?.listen(_onClientSocket); + } else { + _server = await ServerSocket.bind(_host, _port); + _started = true; + _server?.listen(_onClientSocket); + } return Future.value(); } + void _onClientSocket(Socket socket) { + var client = _createNewClient(socket); + client.addDisconnectListener(onDisconnectedClient); + client.addListener((_) => onClientHeartBeat(client)); + _clientList.add(client); + onClientConnected(client); + } + // implement in child void onClientConnected(ProtobufClient client); @@ -50,6 +76,7 @@ abstract class ProtobufServer { Future stop() async { await _server?.close(); + await _secureServer?.close(); _started = false; return Future.value(); } diff --git a/dart/test/certs/server.crt b/dart/test/certs/server.crt new file mode 100644 index 0000000..ef9ff6d --- /dev/null +++ b/dart/test/certs/server.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUS6bQFlfAUTyEJcWHni/uLmDV5QYwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDQwNTA0NTUwM1oXDTM2MDQw +MjA0NTUwM1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAqRXXGrJkOdQc/Ob7fuXHmYm5QWplwnvu/y/qgjVzCe3M ++TTsoQUEJFCKc3J/riJd/Td9wX16uINRDvmciN1p2xjY+BRNULt0ElArFDAgHR35 +OniCq4EkuKDsXvHZ5pZBF+ZDMJS/v7gSWQ36qDumKvJcQgsDz7eSUGd9TwFSQE0a +CHXsyeRBwg3xsOsfm/v0TkEAjraifg5S5nuJlK0fZV+s+yFe7KoMI3cV2I5yzFgB +6vkWwNuM6bk/E/vzBuUnbWLAQIGFFFw7Fz9xylBwNDqHn8v9olqe6pVRpKMm6LON +VoxWENIXFPJ2kyC18l4DK2nGAfF0hm6zrZ1yQkSCqQIDAQABo28wbTAdBgNVHQ4E +FgQUDzMwNEwoYhF213Lu2izSiwjNGkQwHwYDVR0jBBgwFoAUDzMwNEwoYhF213Lu +2izSiwjNGkQwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARhwR/AAABgglsb2Nh +bGhvc3QwDQYJKoZIhvcNAQELBQADggEBAIuKQm6sb/i6vrnKjjFgAb0RdoUIR06b +ByOAyd+sIo8CUsI27j36egaPuA6VBxhLZsbGWwPiZnxiFRKAPdSq2c2JebLECJtu +Brh5SJJplubMWf4TYZ7TW1t7IJetSj74Ut5fQbfboU6rbKL5g9OY9LxoSFRdYokl +N0olJV870Jh3gZmxCx/TrQqQedpOvHJUdeht4MRTXjmPtqYPgZCeZvleKp3fSps1 +GFFlcerb597GMiNCckb1cEAnyraXZrHwkb4uoH1sWdaY4DOMZaXLoWftcd/iNpY0 +0qILRMCiQaXFaHnUdN8F9BhSM0WU8w1YFlcBH0nGEk9scNrlzbWNAAA= +-----END CERTIFICATE----- diff --git a/dart/test/certs/server.key b/dart/test/certs/server.key new file mode 100644 index 0000000..015ad21 --- /dev/null +++ b/dart/test/certs/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpFdcasmQ51Bz8 +5vt+5ceZiblBamXCe+7/L+qCNXMJ7cz5NOyhBQQkUIpzcn+uIl39N33BfXq4g1EO ++ZyI3WnbGNj4FE1Qu3QSUCsUMCAdHfk6eIKrgSS4oOxe8dnmlkEX5kMwlL+/uBJZ +DfqoO6Yq8lxCCwPPt5JQZ31PAVJATRoIdezJ5EHCDfGw6x+b+/ROQQCOtqJ+DlLm +e4mUrR9lX6z7IV7sqgwjdxXYjnLMWAHq+RbA24zpuT8T+/MG5SdtYsBAgYUUXDsX +P3HKUHA0Ooefy/2iWp7qlVGkoybos41WjFYQ0hcU8naTILXyXgMracYB8XSGbrOt +nXJCRIKpAgMBAAECggEACr+Oqu3IHTz0kscEGa71nzb4BcaDrXc/XA1ptNk89Nae +/wB0QlAVUVGlW21d3G3m15/daJ1XXb9LOc54OuMIRZswv6RavdUMrdVWx7O/dtpe +626Zr9lHwkzIeciZ92R5wtEqWD48ai2DTRHsayFPkM9TOgeFOIEM1fueJZWJ6vhh +QTBqaklzV4Z6i6zdW4uwf4M4yGBwnHd3KLurCNykj4yRqxUOPwVMOQpOs/eL/9AZ +HJOaoplh8lOiY6bk/wktSo+Rwv6l2b9aRmKfHQpAtMQiJxrZAtv6LB64bBNjllc3 +jjWEqyswTkzLxQBBaKqy0MP+bwdtWECO6sZS3DGb+QKBgQDa0X1brHMfopgHwK5L +LQHnvGFDXbsPKM0wFqBUjKcIXiCutXm3BYhc7wwpEZ5qFYi3tUwAKPJKwliOA1xM +RyazBhSDz63RJTbyt8yqxt3toMJalO7QL6bfi9HBDJrICGGI0kIJ0yfi1jS1A6u+ +pMaATBPbcVsuneXFHoklCFG95wKBgQDF0PwISNVxNdbglq4UcGMgKO9UQnt0D5DM +R0OQMLkws9nFowOgR0RjelCj4Xf2kHWELUZkf9JdlAv4RIgDyc+j+1jJIE1xyjOp +HGm2sEh+dS89b1WcsLSq1wcgVmp3GqX26QCBC6dJak2LX8UxvyUAI90ntLph8vzB +IIQprTMI7wKBgH3WhcoHpXkm71leBJ309yiGnES6jY3NQBsMmA2niZN1VkRC1wla +5C3Vx1+C42BMnABSAbAB3D0EARtCcXzoWigQMhIPh/1D8pKpAsmfbhdvIPYouiH9 +lXDvnqPvlL++miCuEg5GYaTA3TTQNJ+BcSptFepYCUEIyw+OXP5wB1o3AoGAOx1+ +A/fIGWHuigVdlmwTo2u4QeTwQbnZGsL9NNzqqtxEaySRE9fYXyYRbTgXAo8fH1Xs +YGI2epKglRvdzcwEiku1t704h5XWpGYCTX8W2vuoF2LrIb1I8Hj7/zTz8g37pPLy +nJ3f6zeiXtFK+9fUddtB3vjKbjUQRaKy/EVvJ+8CgYBGI9YMFMtMrtMo/zpsuNFu +Mu3p7w5kdrE0S8hYmS1jy2M6L/2yCWY/G1Rly/VWfPUWpvBZO8pi5mg+dIdGPk4z +FEg5+qrDojg5QNx4WKjf5ybLCbiKKoiIFYtzW+OsK3bPfp/t9+smoiGHWWNPNzff +2sgiy+qXLf//H3KbjG8cwg== +-----END PRIVATE KEY----- diff --git a/dart/test/socket_test.dart b/dart/test/socket_test.dart index 69ab138..fa7f6f7 100644 --- a/dart/test/socket_test.dart +++ b/dart/test/socket_test.dart @@ -5,7 +5,10 @@ import 'package:test/test.dart'; import 'package:toki_socket/toki_socket.dart'; const _testPort = 19090; +const _testPortSsl = 19091; const _host = 'localhost'; +const _certPath = 'test/certs/server.crt'; +const _keyPath = 'test/certs/server.key'; // ── 테스트 전용 픽스처 ────────────────────────────────────────── @@ -29,10 +32,26 @@ class _TestServer extends ProtobufServer { } } +class _TestServerSsl extends ProtobufServer { + final receivedMessages = []; + final connectedClients = []; + + _TestServerSsl(SecurityContext ctx) + : super.secure(_host, _testPortSsl, ctx, (socket) => _TestClient(socket)); + + @override + void onClientConnected(ProtobufClient client) { + connectedClients.add(client); + client.addListener((data) => receivedMessages.add(data)); + } +} + // ── 테스트 ────────────────────────────────────────────────────── void main() { - group('ProtobufServer', () { + // ── Plain TCP ──────────────────────────────────────────────── + + group('ProtobufServer (plain)', () { late _TestServer server; setUp(() async { @@ -46,19 +65,19 @@ void main() { test('서버가 정상 시작된다', () { expect(server.started, isTrue); + expect(server.isSecure, isFalse); }); }); - group('ProtobufClient', () { + group('ProtobufClient (plain)', () { late _TestServer server; - late Socket clientSocket; late _TestClient client; setUp(() async { server = _TestServer(); await server.start(); - clientSocket = await Socket.connect(_host, _testPort); - client = _TestClient(clientSocket); + final socket = await ProtobufClient.connect(_host, _testPort); + client = _TestClient(socket); await Future.delayed(const Duration(milliseconds: 100)); }); @@ -73,11 +92,9 @@ void main() { }); test('TestData 메시지를 서버가 수신한다', () async { - final msg = TestData() + await client.send(TestData() ..index = 42 - ..message = 'hello toki-socket'; - - await client.send(msg); + ..message = 'hello toki-socket'); await Future.delayed(const Duration(milliseconds: 200)); expect(server.receivedMessages, hasLength(1)); @@ -104,8 +121,7 @@ void main() { client.addListener((data) => completer.complete(data)); await Future.delayed(const Duration(milliseconds: 100)); - final serverClient = server.connectedClients.first; - await serverClient.send(TestData() + await server.connectedClients.first.send(TestData() ..index = 99 ..message = 'from server'); @@ -115,17 +131,10 @@ void main() { }); test('nonce가 송신마다 증가한다', () async { - final receivedNonces = []; - server.connectedClients.first.addListener((data) {}); - - // 서버에서 수신한 PacketBase의 nonce를 검증하기 위해 - // 클라이언트에서 3개의 메시지를 순서대로 전송 for (var i = 1; i <= 3; i++) { await client.send(TestData()..index = i); } await Future.delayed(const Duration(milliseconds: 200)); - - // 3개 수신 확인 (nonce 단조 증가는 wire level 검증 — 여기선 수신 순서로 간접 확인) expect(server.receivedMessages, hasLength(3)); }); @@ -137,18 +146,107 @@ void main() { client.dispose(); await completer.future.timeout(const Duration(seconds: 2)); - // completer가 완료되면 disconnect 콜백이 정상 호출된 것 expect(completer.isCompleted, isTrue); }); test('HeartBeat interval 동안 연결이 유지된다', () async { - // heartbeat interval(5초)보다 짧은 시간 대기 후 메시지 송수신 확인 await Future.delayed(const Duration(seconds: 2)); - - await client.send(TestData()..index = 1..message = 'alive check'); + await client.send(TestData() + ..index = 1 + ..message = 'alive check'); await Future.delayed(const Duration(milliseconds: 200)); - expect(server.receivedMessages, hasLength(1)); }); }); + + // ── SSL/TLS ────────────────────────────────────────────────── + + group('ProtobufServer (SSL)', () { + late _TestServerSsl server; + + setUp(() async { + final ctx = SecurityContext() + ..useCertificateChain(_certPath) + ..usePrivateKey(_keyPath); + server = _TestServerSsl(ctx); + await server.start(); + }); + + tearDown(() async { + await server.stop(); + }); + + test('SSL 서버가 정상 시작된다', () { + expect(server.started, isTrue); + expect(server.isSecure, isTrue); + }); + }); + + group('ProtobufClient (SSL)', () { + late _TestServerSsl server; + late _TestClient client; + + setUp(() async { + final serverCtx = SecurityContext() + ..useCertificateChain(_certPath) + ..usePrivateKey(_keyPath); + server = _TestServerSsl(serverCtx); + await server.start(); + + final clientCtx = SecurityContext() + ..setTrustedCertificates(_certPath); + final socket = await ProtobufClient.connectSecure( + _host, _testPortSsl, + context: clientCtx, + ); + client = _TestClient(socket); + await Future.delayed(const Duration(milliseconds: 100)); + }); + + tearDown(() async { + client.dispose(); + await Future.delayed(const Duration(milliseconds: 100)); + await server.stop(); + }); + + test('SSL 클라이언트가 서버에 연결된다', () { + expect(server.connectedClients, isNotEmpty); + }); + + test('SSL TestData 메시지를 서버가 수신한다', () async { + await client.send(TestData() + ..index = 7 + ..message = 'hello over ssl'); + await Future.delayed(const Duration(milliseconds: 200)); + + expect(server.receivedMessages, hasLength(1)); + expect(server.receivedMessages.first.index, equals(7)); + expect(server.receivedMessages.first.message, equals('hello over ssl')); + }); + + test('SSL 서버에서 클라이언트로 메시지를 전송한다', () async { + final completer = Completer(); + client.addListener((data) => completer.complete(data)); + + await Future.delayed(const Duration(milliseconds: 100)); + await server.connectedClients.first.send(TestData() + ..index = 55 + ..message = 'from ssl server'); + + final received = await completer.future.timeout(const Duration(seconds: 2)); + expect(received.index, equals(55)); + expect(received.message, equals('from ssl server')); + }); + + test('SSL disconnect 시 서버 콜백이 호출된다', () async { + final completer = Completer(); + server.connectedClients.first.addDisconnectListener((_) { + if (!completer.isCompleted) completer.complete(); + }); + + client.dispose(); + await completer.future.timeout(const Duration(seconds: 2)); + expect(completer.isCompleted, isTrue); + }); + }); }