Skip to content

Commit f1c6a02

Browse files
committed
feat: add SSH identification customization and validation
1 parent 4c50a3d commit f1c6a02

4 files changed

Lines changed: 181 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
## [2.15.0] - yyyy-mm-dd
2-
- Updated `pointycastle` dependency to `^4.0.0` [#131]. Thanks @vicajilau.
3-
- Added foundational X11 forwarding support with session x11-req API, incoming x11 channel handling, and protocol tests [#1]. Thanks @vicajilau.
2+
- Updated `pointycastle` dependency to `^4.0.0` [#131]. Thanks [@vicajilau].
3+
- Added foundational X11 forwarding support with session x11-req API, incoming x11 channel handling, and protocol tests [#1]. Thanks [@vicajilau].
4+
- Exposed SSH ident configuration from `SSHClient` [#135]. Thanks [@Remulic] and [@vicajilau].
45

56
## [2.14.0] - 2026-03-19
6-
- 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.
7-
- Adds a new forwardLocalUnix() function, which is an equivalent of ssh -L localPort:remoteSocketPath [#140]. Thanks @isegal.
7+
- 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].
8+
- Adds a new forwardLocalUnix() function, which is an equivalent of ssh -L localPort:remoteSocketPath [#140]. Thanks [@isegal].
89

910
## [2.13.0] - 2025-06-22
1011
- docs: Update NoPorts naming [#115]. [@XavierChanth].
@@ -179,6 +180,10 @@
179180

180181
- Initial release.
181182

183+
[#141]: https://github.com/TerminalStudio/dartssh2/pull/141
184+
[#140]: https://github.com/TerminalStudio/dartssh2/pull/140
185+
[#135]: https://github.com/TerminalStudio/dartssh2/pull/135
186+
[#131]: https://github.com/TerminalStudio/dartssh2/pull/131
182187
[#127]: https://github.com/TerminalStudio/dartssh2/pull/127
183188
[#126]: https://github.com/TerminalStudio/dartssh2/pull/126
184189
[#125]: https://github.com/TerminalStudio/dartssh2/pull/125
@@ -201,4 +206,5 @@
201206
[@XavierChanth]: https://github.com/XavierChanth
202207
[@MarBazuz]: https://github.com/MarBazuz
203208
[@reinbeumer]: https://github.com/reinbeumer
204-
[@alexander-irion]: https://github.com/alexander-irion
209+
[@alexander-irion]: https://github.com/alexander-irion
210+
[@Remulic]: https://github.com/Remulic

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ void main() async {
119119

120120
> `SSHSocket` is an interface and it's possible to implement your own `SSHSocket` if you want to use a different underlying transport rather than standard TCP socket. For example WebSocket or Unix domain socket.
121121
122+
### Customize client SSH identification
123+
124+
If your jump host or SSH gateway restricts client versions, you can customize the
125+
software version part of the identification string (`SSH-2.0-<ident>`):
126+
127+
```dart
128+
void main() async {
129+
final client = SSHClient(
130+
await SSHSocket.connect('localhost', 22),
131+
username: '<username>',
132+
onPasswordRequest: () => '<password>',
133+
ident: 'MyClient_1.0',
134+
);
135+
}
136+
```
137+
138+
`ident` defaults to `DartSSH_2.0`.
139+
122140
### Spawn a shell on remote host
123141

124142
```dart

lib/src/ssh_client.dart

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,8 @@ class SSHClient {
187187
this.onX11Forward,
188188
this.keepAliveInterval = const Duration(seconds: 10),
189189
this.disableHostkeyVerification = false,
190-
this.ident = 'DartSSH_2.0',
191-
}) {
190+
String ident = 'DartSSH_2.0',
191+
}) : ident = _validateIdent(ident) {
192192
_transport = SSHTransport(
193193
socket,
194194
isServer: false,
@@ -216,6 +216,26 @@ class SSHClient {
216216
}
217217
}
218218

219+
static String _validateIdent(String ident) {
220+
if (ident.isEmpty) {
221+
throw ArgumentError.value(
222+
ident,
223+
'ident',
224+
'must not be empty',
225+
);
226+
}
227+
228+
if (ident.contains('\r') || ident.contains('\n')) {
229+
throw ArgumentError.value(
230+
ident,
231+
'ident',
232+
'must not contain carriage return or newline characters',
233+
);
234+
}
235+
236+
return ident;
237+
}
238+
219239
late final SSHTransport _transport;
220240

221241
/// A [Completer] that completes when the client has authenticated, or
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
import 'dart:typed_data';
4+
5+
import 'package:dartssh2/dartssh2.dart';
6+
import 'package:test/test.dart';
7+
8+
void main() {
9+
group('SSHClient.ident', () {
10+
test('uses default ident when not provided', () async {
11+
final socket = _FakeSSHSocket();
12+
final client = SSHClient(
13+
socket,
14+
username: 'demo',
15+
);
16+
17+
await Future<void>.delayed(Duration.zero);
18+
19+
expect(client.ident, 'DartSSH_2.0');
20+
expect(socket.writes, contains('SSH-2.0-DartSSH_2.0\r\n'));
21+
22+
client.close();
23+
});
24+
25+
test('uses custom ident when provided', () async {
26+
final socket = _FakeSSHSocket();
27+
final client = SSHClient(
28+
socket,
29+
username: 'demo',
30+
ident: 'MyClient_1.0',
31+
);
32+
33+
await Future<void>.delayed(Duration.zero);
34+
35+
expect(client.ident, 'MyClient_1.0');
36+
expect(socket.writes, contains('SSH-2.0-MyClient_1.0\r\n'));
37+
38+
client.close();
39+
});
40+
41+
test('throws when ident is empty', () {
42+
expect(
43+
() => SSHClient(
44+
_FakeSSHSocket(),
45+
username: 'demo',
46+
ident: '',
47+
),
48+
throwsA(isA<ArgumentError>()),
49+
);
50+
});
51+
52+
test('throws when ident contains newline characters', () {
53+
expect(
54+
() => SSHClient(
55+
_FakeSSHSocket(),
56+
username: 'demo',
57+
ident: 'Bad\nIdent',
58+
),
59+
throwsA(isA<ArgumentError>()),
60+
);
61+
62+
expect(
63+
() => SSHClient(
64+
_FakeSSHSocket(),
65+
username: 'demo',
66+
ident: 'Bad\rIdent',
67+
),
68+
throwsA(isA<ArgumentError>()),
69+
);
70+
});
71+
});
72+
}
73+
74+
class _FakeSSHSocket implements SSHSocket {
75+
final _inputController = StreamController<Uint8List>();
76+
final _doneCompleter = Completer<void>();
77+
final writes = <String>[];
78+
79+
@override
80+
Stream<Uint8List> get stream => _inputController.stream;
81+
82+
@override
83+
StreamSink<List<int>> get sink => _RecordingSink(writes);
84+
85+
@override
86+
Future<void> get done => _doneCompleter.future;
87+
88+
@override
89+
Future<void> close() async {
90+
if (!_doneCompleter.isCompleted) {
91+
_doneCompleter.complete();
92+
}
93+
await _inputController.close();
94+
}
95+
96+
@override
97+
void destroy() {
98+
if (!_doneCompleter.isCompleted) {
99+
_doneCompleter.complete();
100+
}
101+
unawaited(_inputController.close());
102+
}
103+
}
104+
105+
class _RecordingSink implements StreamSink<List<int>> {
106+
_RecordingSink(this._writes);
107+
108+
final List<String> _writes;
109+
110+
@override
111+
void add(List<int> data) {
112+
_writes.add(latin1.decode(data));
113+
}
114+
115+
@override
116+
void addError(Object error, [StackTrace? stackTrace]) {}
117+
118+
@override
119+
Future<void> addStream(Stream<List<int>> stream) async {
120+
await for (final data in stream) {
121+
add(data);
122+
}
123+
}
124+
125+
@override
126+
Future<void> close() async {}
127+
128+
@override
129+
Future<void> get done async {}
130+
}

0 commit comments

Comments
 (0)