fix: companion remote discovery leak, reconnect, and auth hardening
- Dispose old discovery service before creating new one in loadRecentSessions() - Host waits for client reconnect instead of calling joinSession - Clear stale error messages on successful reconnect - Rate limit auth attempts (5 tries, 30s lockout) - Use Random.secure() for peer ID - Extract shared ACK helper methods
This commit is contained in:
@@ -45,6 +45,7 @@ class CompanionRemoteProvider with ChangeNotifier {
|
|||||||
StreamSubscription<void>? _deviceDisconnectedSubscription;
|
StreamSubscription<void>? _deviceDisconnectedSubscription;
|
||||||
StreamSubscription<RemotePeerError>? _errorSubscription;
|
StreamSubscription<RemotePeerError>? _errorSubscription;
|
||||||
StreamSubscription<RemoteSessionStatus>? _statusSubscription;
|
StreamSubscription<RemoteSessionStatus>? _statusSubscription;
|
||||||
|
StreamSubscription<List<RecentRemoteSession>>? _recentSessionsSubscription;
|
||||||
|
|
||||||
CommandReceivedCallback? onCommandReceived;
|
CommandReceivedCallback? onCommandReceived;
|
||||||
DeviceApprovalCallback? onDeviceApprovalRequired;
|
DeviceApprovalCallback? onDeviceApprovalRequired;
|
||||||
@@ -136,6 +137,15 @@ class CompanionRemoteProvider with ChangeNotifier {
|
|||||||
clearConnectedDevice: true,
|
clearConnectedDevice: true,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
} else if (isHost) {
|
||||||
|
// Host keeps the server running — the client will reconnect on its own
|
||||||
|
_session = _session?.copyWith(
|
||||||
|
status: RemoteSessionStatus.reconnecting,
|
||||||
|
clearConnectedDevice: true,
|
||||||
|
clearErrorMessage: true,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
appLogger.d('CompanionRemote: Host waiting for client to reconnect');
|
||||||
} else {
|
} else {
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.reconnecting);
|
_session = _session?.copyWith(status: RemoteSessionStatus.reconnecting);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -314,7 +324,7 @@ class CompanionRemoteProvider with ChangeNotifier {
|
|||||||
|
|
||||||
await _peerService!.joinSession(_lastSessionId!, _lastPin!, _deviceName, _platform, _lastHostAddress!);
|
await _peerService!.joinSession(_lastSessionId!, _lastPin!, _deviceName, _platform, _lastHostAddress!);
|
||||||
|
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
_session = _session?.copyWith(status: RemoteSessionStatus.connected, clearErrorMessage: true);
|
||||||
_reconnectAttempts = 0;
|
_reconnectAttempts = 0;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
appLogger.d('CompanionRemote: Reconnected successfully');
|
appLogger.d('CompanionRemote: Reconnected successfully');
|
||||||
@@ -451,10 +461,15 @@ class CompanionRemoteProvider with ChangeNotifier {
|
|||||||
/// Load recent sessions
|
/// Load recent sessions
|
||||||
Future<void> loadRecentSessions() async {
|
Future<void> loadRecentSessions() async {
|
||||||
try {
|
try {
|
||||||
|
// Dispose previous discovery service and subscription to avoid leaks
|
||||||
|
_recentSessionsSubscription?.cancel();
|
||||||
|
_recentSessionsSubscription = null;
|
||||||
|
_discoveryService?.dispose();
|
||||||
|
|
||||||
_discoveryService = CompanionRemoteDiscoveryService();
|
_discoveryService = CompanionRemoteDiscoveryService();
|
||||||
|
|
||||||
// Listen for recent sessions updates
|
// Listen for recent sessions updates
|
||||||
_discoveryService!.recentSessions.listen((sessions) {
|
_recentSessionsSubscription = _discoveryService!.recentSessions.listen((sessions) {
|
||||||
_recentSessions.clear();
|
_recentSessions.clear();
|
||||||
_recentSessions.addAll(sessions);
|
_recentSessions.addAll(sessions);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -526,6 +541,7 @@ class CompanionRemoteProvider with ChangeNotifier {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
leaveSession();
|
leaveSession();
|
||||||
|
_recentSessionsSubscription?.cancel();
|
||||||
_discoveryService?.dispose();
|
_discoveryService?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ class CompanionRemotePeerService {
|
|||||||
|
|
||||||
Timer? _pingTimer;
|
Timer? _pingTimer;
|
||||||
|
|
||||||
|
// Auth rate limiting
|
||||||
|
int _failedAuthAttempts = 0;
|
||||||
|
DateTime? _authLockoutUntil;
|
||||||
|
static const int _maxFailedAuthAttempts = 5;
|
||||||
|
static const Duration _authLockoutDuration = Duration(seconds: 30);
|
||||||
|
|
||||||
Stream<RemoteCommand> get onCommandReceived => _commandReceivedController.stream;
|
Stream<RemoteCommand> get onCommandReceived => _commandReceivedController.stream;
|
||||||
Stream<RemoteDevice> get onDeviceConnected => _deviceConnectedController.stream;
|
Stream<RemoteDevice> get onDeviceConnected => _deviceConnectedController.stream;
|
||||||
Stream<void> get onDeviceDisconnected => _deviceDisconnectedController.stream;
|
Stream<void> get onDeviceDisconnected => _deviceDisconnectedController.stream;
|
||||||
@@ -198,12 +204,21 @@ class CompanionRemotePeerService {
|
|||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// First message must be authentication
|
// First message must be authentication
|
||||||
if (json['type'] == 'auth') {
|
if (json['type'] == 'auth') {
|
||||||
|
// Rate limiting: reject if locked out
|
||||||
|
if (_authLockoutUntil != null && DateTime.now().isBefore(_authLockoutUntil!)) {
|
||||||
|
appLogger.w('CompanionRemote: Auth attempt rejected (rate limited)');
|
||||||
|
socket.add(jsonEncode({'type': 'authFailed', 'message': 'Too many attempts. Try again later.'}));
|
||||||
|
socket.close(4005, 'Rate limited');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final sessionId = json['sessionId'] as String?;
|
final sessionId = json['sessionId'] as String?;
|
||||||
final pin = json['pin'] as String?;
|
final pin = json['pin'] as String?;
|
||||||
final deviceName = json['deviceName'] as String?;
|
final deviceName = json['deviceName'] as String?;
|
||||||
final platform = json['platform'] as String?;
|
final platform = json['platform'] as String?;
|
||||||
|
|
||||||
if (sessionId == _sessionId && pin == _pin) {
|
if (sessionId == _sessionId && pin == _pin) {
|
||||||
|
_failedAuthAttempts = 0;
|
||||||
isAuthenticated = true;
|
isAuthenticated = true;
|
||||||
authTimeout?.cancel();
|
authTimeout?.cancel();
|
||||||
|
|
||||||
@@ -234,7 +249,12 @@ class CompanionRemotePeerService {
|
|||||||
|
|
||||||
// Note: Client sends keepalive pings, host only responds with pongs
|
// Note: Client sends keepalive pings, host only responds with pongs
|
||||||
} else {
|
} else {
|
||||||
appLogger.w('CompanionRemote: Invalid credentials');
|
_failedAuthAttempts++;
|
||||||
|
if (_failedAuthAttempts >= _maxFailedAuthAttempts) {
|
||||||
|
_authLockoutUntil = DateTime.now().add(_authLockoutDuration);
|
||||||
|
appLogger.w('CompanionRemote: Too many failed auth attempts, locked out for ${_authLockoutDuration.inSeconds}s');
|
||||||
|
}
|
||||||
|
appLogger.w('CompanionRemote: Invalid credentials (attempt $_failedAuthAttempts/$_maxFailedAuthAttempts)');
|
||||||
socket.add(jsonEncode({'type': 'authFailed', 'message': 'Invalid session ID or PIN'}));
|
socket.add(jsonEncode({'type': 'authFailed', 'message': 'Invalid session ID or PIN'}));
|
||||||
socket.close(4003, 'Invalid credentials');
|
socket.close(4003, 'Invalid credentials');
|
||||||
}
|
}
|
||||||
@@ -247,18 +267,8 @@ class CompanionRemotePeerService {
|
|||||||
final command = RemoteCommand.fromJson(json);
|
final command = RemoteCommand.fromJson(json);
|
||||||
appLogger.d('CompanionRemote: Received command: ${command.type}');
|
appLogger.d('CompanionRemote: Received command: ${command.type}');
|
||||||
|
|
||||||
// Send acknowledgment for non-ping/pong/ack commands
|
if (_shouldSendAck(command)) {
|
||||||
if (command.type != RemoteCommandType.ping &&
|
_sendAck(command, hostDeviceName);
|
||||||
command.type != RemoteCommandType.pong &&
|
|
||||||
command.type != RemoteCommandType.ack &&
|
|
||||||
command.type != RemoteCommandType.deviceInfo) {
|
|
||||||
final ackCommand = RemoteCommand(
|
|
||||||
type: RemoteCommandType.ack,
|
|
||||||
deviceId: _myPeerId ?? 'unknown',
|
|
||||||
deviceName: hostDeviceName,
|
|
||||||
data: {'originalCommand': command.type.toString()},
|
|
||||||
);
|
|
||||||
socket.add(jsonEncode(ackCommand.toJson()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_commandReceivedController.add(command);
|
_commandReceivedController.add(command);
|
||||||
@@ -304,7 +314,7 @@ class CompanionRemotePeerService {
|
|||||||
_sessionId = sessionId.toUpperCase();
|
_sessionId = sessionId.toUpperCase();
|
||||||
_pin = pin;
|
_pin = pin;
|
||||||
_hostAddress = hostAddress;
|
_hostAddress = hostAddress;
|
||||||
_myPeerId = 'remote-${Random().nextInt(99999)}';
|
_myPeerId = 'remote-${Random.secure().nextInt(99999)}';
|
||||||
|
|
||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
|
|
||||||
@@ -364,18 +374,8 @@ class CompanionRemotePeerService {
|
|||||||
final command = RemoteCommand.fromJson(json);
|
final command = RemoteCommand.fromJson(json);
|
||||||
appLogger.d('CompanionRemote: Received command: ${command.type}');
|
appLogger.d('CompanionRemote: Received command: ${command.type}');
|
||||||
|
|
||||||
// Send acknowledgment for non-ping/pong/ack commands
|
if (_shouldSendAck(command)) {
|
||||||
if (command.type != RemoteCommandType.ping &&
|
_sendAck(command, deviceName);
|
||||||
command.type != RemoteCommandType.pong &&
|
|
||||||
command.type != RemoteCommandType.ack &&
|
|
||||||
command.type != RemoteCommandType.deviceInfo) {
|
|
||||||
final ackCommand = RemoteCommand(
|
|
||||||
type: RemoteCommandType.ack,
|
|
||||||
deviceId: _myPeerId ?? 'unknown',
|
|
||||||
deviceName: deviceName,
|
|
||||||
data: {'originalCommand': command.type.toString()},
|
|
||||||
);
|
|
||||||
_channel!.sink.add(jsonEncode(ackCommand.toJson()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_commandReceivedController.add(command);
|
_commandReceivedController.add(command);
|
||||||
@@ -451,6 +451,23 @@ class CompanionRemotePeerService {
|
|||||||
_pingTimer = null;
|
_pingTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _shouldSendAck(RemoteCommand command) {
|
||||||
|
return command.type != RemoteCommandType.ping &&
|
||||||
|
command.type != RemoteCommandType.pong &&
|
||||||
|
command.type != RemoteCommandType.ack &&
|
||||||
|
command.type != RemoteCommandType.deviceInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendAck(RemoteCommand command, String deviceName) {
|
||||||
|
final ackCommand = RemoteCommand(
|
||||||
|
type: RemoteCommandType.ack,
|
||||||
|
deviceId: _myPeerId ?? 'unknown',
|
||||||
|
deviceName: deviceName,
|
||||||
|
data: {'originalCommand': command.type.toString()},
|
||||||
|
);
|
||||||
|
sendCommand(ackCommand);
|
||||||
|
}
|
||||||
|
|
||||||
void _sendPong(String deviceName, String platform) {
|
void _sendPong(String deviceName, String platform) {
|
||||||
sendCommand(
|
sendCommand(
|
||||||
RemoteCommand(
|
RemoteCommand(
|
||||||
|
|||||||
Reference in New Issue
Block a user