feat: SSL/TLS support for both client and server
- ProtobufServer: named constructor .secure() for SSL mode - SecurityContext parameter for certificate/key loading - SecureServerSocket 분기 처리, stop()에서 양쪽 close - isSecure getter 추가 - ProtobufClient: static factory 메서드 추가 - connect() — plain TCP - connectSecure() — SSL/TLS (SecureSocket extends Socket이라 내부 무수정) - 테스트: SSL 그룹 4개 추가 (총 13개, 전부 통과) - 자체 서명 인증서로 실제 SSL 연결 검증 - plain / SSL 동일 시나리오 병렬 커버 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7fe90e4ea7
commit
d89b25948a
5 changed files with 232 additions and 34 deletions
|
|
@ -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<Socket> 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<Socket> 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;
|
||||
|
|
|
|||
|
|
@ -8,28 +8,54 @@ import 'protobuf_client.dart';
|
|||
abstract class ProtobufServer {
|
||||
List<ProtobufClient> _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<HeartBeat>((_) => 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<HeartBeat>((_) => 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();
|
||||
}
|
||||
|
|
|
|||
19
dart/test/certs/server.crt
Normal file
19
dart/test/certs/server.crt
Normal file
|
|
@ -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-----
|
||||
28
dart/test/certs/server.key
Normal file
28
dart/test/certs/server.key
Normal file
|
|
@ -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-----
|
||||
|
|
@ -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 = <TestData>[];
|
||||
final connectedClients = <ProtobufClient>[];
|
||||
|
||||
_TestServerSsl(SecurityContext ctx)
|
||||
: super.secure(_host, _testPortSsl, ctx, (socket) => _TestClient(socket));
|
||||
|
||||
@override
|
||||
void onClientConnected(ProtobufClient client) {
|
||||
connectedClients.add(client);
|
||||
client.addListener<TestData>((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<TestData>((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 = <int>[];
|
||||
server.connectedClients.first.addListener<TestData>((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<TestData>();
|
||||
client.addListener<TestData>((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<void>();
|
||||
server.connectedClients.first.addDisconnectListener((_) {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
});
|
||||
|
||||
client.dispose();
|
||||
await completer.future.timeout(const Duration(seconds: 2));
|
||||
expect(completer.isCompleted, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue