diff --git a/CHANGELOG.md b/CHANGELOG.md index b14cac6..bf57a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [2.15.0] - yyyy-mm-dd - Updated `pointycastle` dependency to `^4.0.0` [#131]. Thanks @vicajilau. -- CI now runs tests for pull requests targeting `master`. +- Added foundational X11 forwarding support with session x11-req API, incoming x11 channel handling, and protocol tests [#1]. Thanks @vicajilau. ## [2.14.0] - 2026-03-19 - Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks @shihuili1218. diff --git a/lib/src/ssh_channel.dart b/lib/src/ssh_channel.dart index 20f0fa8..76fb49b 100644 --- a/lib/src/ssh_channel.dart +++ b/lib/src/ssh_channel.dart @@ -120,6 +120,25 @@ class SSHChannelController { return await _requestReplyQueue.next; } + Future sendX11Req({ + bool singleConnection = false, + String authenticationProtocol = 'MIT-MAGIC-COOKIE-1', + required String authenticationCookie, + int screenNumber = 0, + }) async { + sendMessage( + SSH_Message_Channel_Request.x11( + recipientChannel: remoteId, + wantReply: true, + singleConnection: singleConnection, + x11AuthenticationProtocol: authenticationProtocol, + x11AuthenticationCookie: authenticationCookie, + x11ScreenNumber: screenNumber.toString(), + ), + ); + return await _requestReplyQueue.next; + } + Future sendSubsystem(String subsystem) async { sendMessage( SSH_Message_Channel_Request.subsystem( @@ -415,6 +434,20 @@ class SSHChannel { return await _controller.sendShell(); } + Future sendX11Req({ + bool singleConnection = false, + String authenticationProtocol = 'MIT-MAGIC-COOKIE-1', + required String authenticationCookie, + int screenNumber = 0, + }) async { + return await _controller.sendX11Req( + singleConnection: singleConnection, + authenticationProtocol: authenticationProtocol, + authenticationCookie: authenticationCookie, + screenNumber: screenNumber, + ); + } + void sendTerminalWindowChange({ required int width, required int height, diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index fd7b92b..c34347f 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -41,6 +41,8 @@ typedef SSHAuthenticatedHandler = void Function(); typedef SSHRemoteConnectionFilter = bool Function(String host, int port); +typedef SSHX11ForwardHandler = void Function(SSHX11Channel channel); + // /// Function called when the host has sent additional host keys after the initial // /// key exchange. // typedef SSHHostKeysHandler = void Function(List); @@ -74,6 +76,27 @@ class SSHPtyConfig { }); } +class SSHX11Config { + /// Whether only a single forwarded X11 connection should be accepted. + final bool singleConnection; + + /// X11 authentication protocol name. + final String authenticationProtocol; + + /// X11 authentication cookie value. + final String authenticationCookie; + + /// X11 screen number. + final int screenNumber; + + const SSHX11Config({ + required this.authenticationCookie, + this.singleConnection = false, + this.authenticationProtocol = 'MIT-MAGIC-COOKIE-1', + this.screenNumber = 0, + }); +} + class SSHClient { final SSHSocket socket; @@ -122,6 +145,9 @@ class SSHClient { /// Function called when authentication is complete. final SSHAuthenticatedHandler? onAuthenticated; + /// Function called when the server opens an incoming forwarded X11 channel. + final SSHX11ForwardHandler? onX11Forward; + /// The interval at which to send a keep-alive message through the [ping] /// method. Set this to null to disable automatic keep-alive messages. final Duration? keepAliveInterval; @@ -154,6 +180,7 @@ class SSHClient { this.onUserInfoRequest, this.onUserauthBanner, this.onAuthenticated, + this.onX11Forward, this.keepAliveInterval = const Duration(seconds: 10), this.disableHostkeyVerification = false, }) { @@ -316,6 +343,7 @@ class SSHClient { Future execute( String command, { SSHPtyConfig? pty, + SSHX11Config? x11, Map? environment, }) async { await _authenticated.future; @@ -342,6 +370,19 @@ class SSHClient { } } + if (x11 != null) { + final x11Ok = await channelController.sendX11Req( + singleConnection: x11.singleConnection, + authenticationProtocol: x11.authenticationProtocol, + authenticationCookie: x11.authenticationCookie, + screenNumber: x11.screenNumber, + ); + if (!x11Ok) { + channelController.close(); + throw SSHChannelRequestError('Failed to request x11 forwarding'); + } + } + final success = await channelController.sendExec(command); if (!success) { channelController.close(); @@ -355,6 +396,7 @@ class SSHClient { /// used to read, write and control the pty on the remote side. Future shell({ SSHPtyConfig? pty = const SSHPtyConfig(), + SSHX11Config? x11, Map? environment, }) async { await _authenticated.future; @@ -381,6 +423,19 @@ class SSHClient { } } + if (x11 != null) { + final x11Ok = await channelController.sendX11Req( + singleConnection: x11.singleConnection, + authenticationProtocol: x11.authenticationProtocol, + authenticationCookie: x11.authenticationCookie, + screenNumber: x11.screenNumber, + ); + if (!x11Ok) { + channelController.close(); + throw SSHChannelRequestError('Failed to request x11 forwarding'); + } + } + if (!await channelController.sendShell()) { channelController.close(); throw SSHChannelRequestError('Failed to start shell'); @@ -701,6 +756,8 @@ class SSHClient { switch (message.channelType) { case 'forwarded-tcpip': return _handleForwardedTcpipChannelOpen(message); + case 'x11': + return _handleX11ChannelOpen(message); } printDebug?.call('unknown channelType: ${message.channelType}'); @@ -765,6 +822,47 @@ class SSHClient { ); } + void _handleX11ChannelOpen(SSH_Message_Channel_Open message) { + printDebug?.call('SSHClient._handleX11ChannelOpen'); + + if (onX11Forward == null) { + final reply = SSH_Message_Channel_Open_Failure( + recipientChannel: message.senderChannel, + reasonCode: 1, // SSH_OPEN_ADMINISTRATIVELY_PROHIBITED + description: 'x11 forwarding not enabled', + ); + _sendMessage(reply); + return; + } + + final localChannelId = _channelIdAllocator.allocate(); + + final confirmation = SSH_Message_Channel_Confirmation( + recipientChannel: message.senderChannel, + senderChannel: localChannelId, + initialWindowSize: _initialWindowSize, + maximumPacketSize: _maximumPacketSize, + data: Uint8List(0), + ); + + _sendMessage(confirmation); + + final channelController = _acceptChannel( + localChannelId: localChannelId, + remoteChannelId: message.senderChannel, + remoteInitialWindowSize: message.initialWindowSize, + remoteMaximumPacketSize: message.maximumPacketSize, + ); + + onX11Forward!( + SSHX11Channel( + channelController.channel, + originatorIP: message.originatorIP ?? '', + originatorPort: message.originatorPort ?? 0, + ), + ); + } + /// Finds a remote forward that matches the given host and port. SSHRemoteForward? _findRemoteForward(String host, int port) { final result = _remoteForwards.where( diff --git a/lib/src/ssh_forward.dart b/lib/src/ssh_forward.dart index 2494df3..f2b462b 100644 --- a/lib/src/ssh_forward.dart +++ b/lib/src/ssh_forward.dart @@ -39,3 +39,17 @@ class SSHForwardChannel implements SSHSocket { _channel.destroy(); } } + +class SSHX11Channel extends SSHForwardChannel { + /// Originator address reported by the SSH server for this X11 channel. + final String originatorIP; + + /// Originator port reported by the SSH server for this X11 channel. + final int originatorPort; + + SSHX11Channel( + super.channel, { + required this.originatorIP, + required this.originatorPort, + }); +} diff --git a/test/src/message/msg_channel_test.dart b/test/src/message/msg_channel_test.dart new file mode 100644 index 0000000..12b08f7 --- /dev/null +++ b/test/src/message/msg_channel_test.dart @@ -0,0 +1,48 @@ +import 'package:dartssh2/src/message/msg_channel.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSH_Message_Channel_Open', () { + test('x11 encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open.x11( + senderChannel: 7, + initialWindowSize: 1024, + maximumPacketSize: 32768, + originatorIP: '127.0.0.1', + originatorPort: 6123, + ); + + final decoded = SSH_Message_Channel_Open.decode(message.encode()); + + expect(decoded.channelType, 'x11'); + expect(decoded.senderChannel, 7); + expect(decoded.initialWindowSize, 1024); + expect(decoded.maximumPacketSize, 32768); + expect(decoded.originatorIP, '127.0.0.1'); + expect(decoded.originatorPort, 6123); + }); + }); + + group('SSH_Message_Channel_Request', () { + test('x11-req encode/decode roundtrip', () { + final message = SSH_Message_Channel_Request.x11( + recipientChannel: 5, + wantReply: true, + singleConnection: true, + x11AuthenticationProtocol: 'MIT-MAGIC-COOKIE-1', + x11AuthenticationCookie: 'deadbeef', + x11ScreenNumber: '0', + ); + + final decoded = SSH_Message_Channel_Request.decode(message.encode()); + + expect(decoded.requestType, SSHChannelRequestType.x11); + expect(decoded.recipientChannel, 5); + expect(decoded.wantReply, isTrue); + expect(decoded.singleConnection, isTrue); + expect(decoded.x11AuthenticationProtocol, 'MIT-MAGIC-COOKIE-1'); + expect(decoded.x11AuthenticationCookie, 'deadbeef'); + expect(decoded.x11ScreenNumber, '0'); + }); + }); +}