Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
33 changes: 33 additions & 0 deletions lib/src/ssh_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,25 @@ class SSHChannelController {
return await _requestReplyQueue.next;
}

Future<bool> 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<bool> sendSubsystem(String subsystem) async {
sendMessage(
SSH_Message_Channel_Request.subsystem(
Expand Down Expand Up @@ -415,6 +434,20 @@ class SSHChannel {
return await _controller.sendShell();
}

Future<bool> 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,
Expand Down
98 changes: 98 additions & 0 deletions lib/src/ssh_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8List>);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -154,6 +180,7 @@ class SSHClient {
this.onUserInfoRequest,
this.onUserauthBanner,
this.onAuthenticated,
this.onX11Forward,
this.keepAliveInterval = const Duration(seconds: 10),
this.disableHostkeyVerification = false,
}) {
Expand Down Expand Up @@ -316,6 +343,7 @@ class SSHClient {
Future<SSHSession> execute(
String command, {
SSHPtyConfig? pty,
SSHX11Config? x11,
Map<String, String>? environment,
}) async {
await _authenticated.future;
Expand All @@ -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();
Expand All @@ -355,6 +396,7 @@ class SSHClient {
/// used to read, write and control the pty on the remote side.
Future<SSHSession> shell({
SSHPtyConfig? pty = const SSHPtyConfig(),
SSHX11Config? x11,
Map<String, String>? environment,
}) async {
await _authenticated.future;
Expand All @@ -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');
Expand Down Expand Up @@ -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}');
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions lib/src/ssh_forward.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
48 changes: 48 additions & 0 deletions test/src/message/msg_channel_test.dart
Original file line number Diff line number Diff line change
@@ -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');
});
});
}
Loading