bssh 2.4.2

Parallel SSH command execution tool for cluster management
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
# Changelog

All notable changes to bssh will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.4.2] - 2026-08-14

Republishes the macOS binaries with a working code signature. No source changed between 2.4.1 and 2.4.2, so the Linux binaries behave identically and only macOS users need this release.

### Fixed
- **Sign the macOS binaries with a Developer ID Application certificate and notarize them** (#264). Every macOS release through 2.4.1 was signed with an `Apple Distribution` certificate, an App Store submission identity whose leaf carries the App Store extension (1.2.840.113635.100.6.1.7) but not the Developer ID extension (1.2.840.113635.100.6.1.13) that Gatekeeper requires outside the App Store, and none was ever submitted to notarytool. `codesign --sign "Distribution"` matches identity names by substring, so it picked that certificate out of the keychain. Downloads were refused with a security warning for eight months; when the certificate was revoked on 2026-08-12, the failure escalated from a warning to `CSSMERR_TP_CERT_REVOKED`, and macOS began killing installed binaries on launch and deleting them as malware, which broke every Homebrew installation. All three released binaries (`bssh`, `bssh-server`, `bssh-keygen`) are now signed with a Developer ID Application certificate under the hardened runtime and notarized. A bare Mach-O cannot be stapled, so the ticket stays on Apple's servers and Gatekeeper resolves it online.

### CI/CD
- **Fail the release when the signing certificate is the wrong type** (#264). Nothing in the release workflow inspected the resulting signature, which is why the wrong certificate shipped unnoticed for eight months. Signing now runs through two composite actions under `.github/actions/`: the setup action rejects a p12 that holds no Developer ID Application certificate before anything is signed, and the signing action asserts the `Developer ID Application` authority, the hardened runtime flag, and the pinned code signature identifier after signing, then gates on notarization reaching `status: Accepted`. rcodesign replaces Apple's `codesign` so the identity comes from the supplied certificate rather than from a substring match against the keychain.

## [2.4.1] - 2026-08-03

Closes the four items listed as known issues in the 2.4.0 release notes.

### Security
- **Stop `accept-new` from disabling verification entirely when no known_hosts path can be determined** (#242). `get_check_method` returned `ServerCheckMethod::NoCheck` after printing a warning whenever `get_default_known_hosts_path()` yielded `None`, so the CLI default mode accepted any server key unconditionally in containers and service environments where `HOME` is unset and no passwd entry supplies a home directory. A new `AcceptNewInMemory` mode applies there instead: the first key seen for a normalized `host:port` is pinned for the lifetime of the process, and a different key offered later in the same run is rejected with the same `HostKeyChanged` error the file-backed path uses. Nothing persists past the process, since there is no trust state to record against, so this remains weaker than file-backed TOFU; it is no longer an unconditional accept-all. Strict (`yes`) mode was already fail-closed here and is unchanged.
- **Fail closed when the known_hosts path exists but cannot serve as trust state** (#242). `russh::keys::known_hosts::known_host_keys_path` returns `Ok(vec![])` whenever `File::open` fails for any reason, so a known_hosts file that was unreadable, a dangling symlink, or a directory in its place was indistinguishable from one that did not exist: in accept-new mode every host looked like a genuine first use, its key was accepted, and the recording attempt then failed silently and repeated on the next connection, while in strict mode every host looked unknown. Both modes now probe the path before consulting it and reject the connection when it names a non-regular file, a file that cannot be opened, or a symlink that cannot be resolved, naming which of the three it was. A genuinely absent file still behaves as an empty one, so first use on a fresh account is unaffected.
- **Serialize the accept-new check-then-record window across bssh processes, not just within one** (#242). The lock added in 2.4.0 was a process-wide mutex, so two bssh processes connecting to the same new host at the same time could both read known_hosts before either wrote, and both append an entry. A sibling advisory lock file (`known_hosts.lock` next to the known_hosts file, created 0600 under a 0700 parent) now wraps the critical section in every file-backed accept-new path, and the path probe, marker scan, and host lookup all run again under the lock so a decision reached before the lock was held is never acted on.
- **Allow `@cert-authority` known_hosts lines to be rejected rather than only warned about** (#242). A matching `@cert-authority` line prints a warning and falls through to ordinary TOFU, because bssh has no CA signature validation and failing closed would break every working CA setup with no workaround. That default is unchanged, but setting `BSSH_CERT_AUTHORITY_POLICY=reject` now turns a match into a hard rejection, so a deployment that knows its hosts are certificate-signed can refuse instead of silently pinning a bare key for a host it expected a CA to vouch for.

### Fixed
- **Send forwarding target hostnames to the server instead of resolving them on the client** (#257). bssh resolved `-L` targets, SOCKS5 `-D` domain requests, later jump-chain hops, and the destination behind a jump chain through the *local* resolver and put a numeric address in the `direct-tcpip` channel request, where OpenSSH sends the name as written and lets the server resolve it. Any target whose name resolves only from the server's network position therefore failed before a single byte of SSH traffic was sent, which is one of the primary reasons to use a bastion at all, and a name that resolved differently on both sides silently connected to the client's answer. With no forced address family, all four paths now send the hostname unresolved and the remote sshd performs the lookup and the connect. Forcing `-4` or `-6` (or ssh_config `AddressFamily inet|inet6`) still resolves locally, filters to the requested family, and sends the matching numeric address, since that is the only way the family request can affect a connection the server makes; the 2.4.0 note that this filtering is best-effort applies to the forced path only. This also removes the reason the filtering was described as advisory in the unforced default.
- **Honor the address family preference in SOCKS4 dynamic forwarding** (#255). `handle_socks4_connection` called the family-agnostic `open_direct_tcpip_channel`, which hardcodes `AddressFamily::Any`, so `-4`, `-6`, and the ssh_config `AddressFamily` keyword were ignored on that path alone while every other path in 2.4.0 honored them. A single invocation was inconsistent with itself: `bssh -6 -D 1080 user@host` hard-failed every other path when IPv6 was unavailable and still handed back a SOCKS4 proxy that tunneled IPv4 with no signal that the flag had not applied. The resolved family is now threaded from the dynamic connection handler into the SOCKS4 handler, and a SOCKS4 request under forced IPv6 is refused with the protocol's own 0x5B rejection reply before an SSH channel is opened, rather than being tunneled. `AddressFamily::Any` and forced IPv4 are unchanged, since a SOCKS4 destination is a literal IPv4 address by protocol definition.
- **Parse SOCKS5 IPv6 destination literals instead of refusing them** (#256). The SOCKS5 request parser implemented address types 0x01 (IPv4 literal) and 0x03 (domain name); the 0x04 (IPv6 literal) arm read none of the request body and replied 0x08 "address type not supported", making it identical to the catch-all arm except for its error message. Any SOCKS5 client sending an IPv6 destination was refused, which directly contradicted the IPv6 work in 2.4.0 and made `bssh -6 -D 1080` self-contradictory: the user forced IPv6 and the proxy then rejected every IPv6 destination. The 0x04 arm now consumes its 16 address bytes and 2 port bytes and forwards the destination as a bracketed `[ipv6]:port` target. The success reply keeps the OpenSSH-style `0.0.0.0:0` BND.ADDR placeholder, which RFC 1928 permits and which clients already accept from the existing paths.
- **Record one recordable hostname for a socket address slice instead of a comma-joined list** (#243). `ToSocketAddrsWithHostname for &[SocketAddr]` built its hostname by joining every address with commas, so a multi-address target produced a string like `192.0.2.1,192.0.2.2`. A comma is the known_hosts host-list separator, so such a name would have pinned one key across a fabricated list of hosts had it reached a recording path, and it is now rejected outright by the hostname validation added in 2.4.0, turning a diagnostic into a connection failure. The implementation now reports the first address, matching what the connection actually used.
- **Stop a freshly created known_hosts file from starting with a blank line** (#243). `learn_known_hosts_path` prepends a newline when the file it opens is not empty, and it cannot distinguish "not empty" from the zero-length file bssh had just created with restrictive permissions, so a first-ever recording left a leading blank line. The line is now removed after the write, only when bssh created the file itself. A file that already existed is untouched, and the blank line was cosmetic rather than a parse problem.

### Performance
- **Skip the known_hosts write locks when the offered key already matches a recorded entry** (#243). Accept-new took the process-wide mutex on every connection, including the common case of an already-recorded host whose key has not changed, so parallel connections to a known cluster serialized on a lock none of them needed, and the cross-process file lock added above would have made every one of them touch the filesystem as well. The marker scan and host lookup now run first without either lock and return immediately on a definite match. Both locks are taken only for a host that still looks unknown, and the path probe, marker scan, and lookup all run again under them, so the fast path cannot race a concurrent first-time recording.

### Tests
- **Fix the nondeterministic `test_expand_path_with_tilde` failure under the full library suite** (#243). Seven tests repoint the process-global `HOME` through `EnvGuard`, and cargo runs tests in parallel threads, so `HOME` could change between `expand_path`'s internal `dirs::home_dir()` call and the assertion's own call, failing an assertion on identical code that passed in isolation. `EnvGuard` now takes a dedicated `HOME` mutex whenever it sets or removes that variable, and `EnvGuard::lock_home()` gives read-only tests the same lock for the length of a `HOME`-sensitive assertion. This was a test-harness defect; no library behavior changed.

### Changed
- **Lib API, additive only: no source break from 2.4.0.** `ServerCheckMethod` gained `AcceptNewKnownHostsFile(String)` and `AcceptNewInMemory` in both the `bssh::ssh::tokio_client` enum and its `bssh::shared::auth_types` mirror, with `From` conversions in both directions; both enums are `#[non_exhaustive]`, so an exhaustive downstream match already needed a wildcard arm. `ToSocketAddrsWithHostname` gained a `host_port` method with a default body, so external implementors compile unchanged.

### Dependencies
- **Refresh the locked dependency graph.** No `Cargo.toml` requirement changed and no crate entered or left the graph; seven resolved versions moved in `Cargo.lock`, including `darling` 0.23.0 to 0.24.0, `aho-corasick` 1.1.4 to 1.1.5, `data-encoding` 2.11.0 to 2.11.1, `instability` 0.3.12 to 0.3.13, and `line-clipping` 0.3.7 to 0.3.8.

## [2.4.0] - 2026-08-03

### Security
- **Implement real TOFU verification for the default `accept-new` host key mode, which previously performed no verification at all** (#239). `--strict-host-key-checking accept-new` (the documented, recommended default) mapped to `NoCheck`: any server key was accepted unconditionally, nothing was ever written to `~/.ssh/known_hosts`, and changed keys were never rejected, contrary to the help text's "Accept new hosts, reject changed keys". accept-new now checks the offered key against known_hosts, records unknown hosts (creating `~/.ssh` and `known_hosts` with their final restrictive permissions, printing the OpenSSH-style "Permanently added" notice, and serializing parallel first-time connects behind a process-wide lock so the same new host is recorded exactly once), and rejects changed keys with the OpenSSH-style warning banner including the offered key's SHA256 fingerprint, the offending file and line, and a remediation hint naming the actual `[host]:port`-qualified entry, without overwriting the recorded entry. Strict (`yes`) mode no longer downgrades to `NoCheck` when the known_hosts file is missing or its path cannot be determined; a missing file now behaves as an empty one, so unknown hosts are rejected. Changed keys are reported through a dedicated `HostKeyChanged` error carrying the host, port, and conflicting line in every known_hosts-based mode instead of collapsing into the generic "Server check failed", and `no` mode is unchanged. The fix applies to every connection path since they all flow through the same handler: direct, jump-chain (first hop, intermediate hops, and the destination through the tunnel), SFTP transfers, port forwarding, and interactive mode. Entries use the `[host]:port` form for non-standard ports and round-trip through subsequent checks.
- **Accept a host key that matches any of a host's recorded known_hosts entries, instead of rejecting as soon as one same-algorithm entry differs** (#239). `russh::keys::check_known_hosts_path` collects its per-line results and short-circuits on the first non-matching same-algorithm entry, so a host with two or more keys of one algorithm, a stale key kept beside a rotated one, or a cluster-style `node1,node2,node3 <shared key>` line beside a per-node `node2 <node2's own key>` line, was rejected with the full "REMOTE HOST IDENTIFICATION HAS CHANGED" banner even while offering a key that a later line in the same file held, a false man-in-the-middle alarm whose own remediation (`ssh-keygen -R`) would have deleted the legitimate pin and turned the next connection into an unguarded first use. accept-new and strict (`yes`)/`DefaultKnownHostsFile` mode now share one lookup that applies OpenSSH's rule instead: any recorded key equal to the offered key verifies, and only a host whose recorded keys none of them match counts as changed; that also still catches the alternate-algorithm bypass closed earlier in this branch, where a key offered under an algorithm the host had not used yet was recorded and accepted alongside the existing entry, since SSH negotiation lets the server steer which algorithm gets offered and russh cannot reorder its per-host proposal to prefer already-known types the way OpenSSH does. The changed-key banner now prefers the entry with the same algorithm as the offered key when naming the offending line, and `DefaultKnownHostsFile` resolves its own path once instead of letting russh and the banner text resolve it independently.
- **Reject a hostname that cannot round-trip through a known_hosts entry, instead of recording it verbatim** (#239). `learn_known_hosts_path` appends `{host} <key>` (or `[{host}]:{port} <key>`) with no escaping, and russh's parser splits each line on `' '`, reads `,` as the host-list separator, skips a line starting with `#`, and reads a leading `|1|` as a hashed host field, none of which any hostname source reaching the connect handler actually validated against (`-H/--hosts` validation was discarded in favor of the unvalidated `~/.ssh/config` `HostName`, `BACKENDAI_CLUSTER_HOSTS` was only trimmed, and the port-forwarding path skipped hostname validation entirely). A hostname containing a newline could pin an attacker's key against a second host the user never named, a space made the key field unparsable so the host became permanently unconnectable, a leading `#` made the line invisible to every later lookup so it re-recorded on every connection, and a comma silently shared one key across a whole host list. accept-new and strict mode now both reject an empty, over-255-byte, whitespace/control-character, or `#`/`,`/`|`/`*`/`?`/`!`/`\`-containing hostname before anything is looked up or written, failing closed since a hostname that cannot be recorded cannot be verified on any later connection either; ordinary names, IPv4/IPv6 literals, zone identifiers, and the bracketed `[::1]` form are unaffected.
- **Honor known_hosts `@revoked`/`@cert-authority` marker lines, match hostnames case-insensitively, and close the window between creating `~/.ssh`/`known_hosts` and restricting their permissions** (#239). russh's known_hosts parser reads a marker line's own `@revoked`/`@cert-authority` token as the literal host field, so it never matched the real host and both accept-new and strict mode silently ignored these lines, meaning a key an operator had explicitly revoked was recorded and accepted like any ordinary first use. A dedicated scan now runs before the ordinary lookup in every known_hosts-based mode: a `@revoked` line whose host matches (the exact, comma-list, or `*`/`?` glob form, erring toward matching when in doubt since failing to honor a revocation is worse than an unnecessary rejection) and whose key equals the offered key is a hard rejection with a new `HostKeyRevoked` error, while a matching `@revoked` line naming a *different* key does not block the offered one; a `@cert-authority` match only prints a loud warning, since bssh has no CA signature validation to fall back to and failing closed there would break every working CA setup with no workaround, and falls through to ordinary TOFU. Hostname matching is also normalized to lowercase once at the point of verification, for both the lookup and the recorded entry, matching OpenSSH's case-insensitive known_hosts comparison (russh's own matcher is a plain byte compare), so `NODE1.example.com` and `node1.example.com` now hit the same pin instead of the differently-cased spelling looking unknown and re-recording. Finally, `~/.ssh` and `known_hosts` are created with mode 0700/0600 directly (`DirBuilder`/`OpenOptions` with an explicit mode) before `learn_known_hosts_path` runs, rather than letting it create them with the process umask and tightening the mode only afterward, closing the brief window in which an unusually permissive umask could leave either one group- or world-writable.

### Changed
- **Breaking, lib API: `bssh::commands::ping::ping_nodes` now returns `Result<PingOutcome>` instead of `Result<()>`** (#245). The per-host tally has to survive the return for the caller to compute an exit code from it. `PingOutcome` carries `total`, `succeeded`, and `failed`, and `PingOutcome::exit_code()` applies the 0/1/255 contract. Callers that ignored the old `Ok(())` keep compiling; callers that matched on it exhaustively update the pattern.
- **Breaking, lib API: address family selection and per-target ssh_config resolution changed the signatures of several public library functions** (#246, #249). Every call site needed the new values anyway, so the maintainer chose a source break over back-compat wrapper overloads; consumers of the `bssh` library crate update call sites when picking up this version.
  - `ForwardingSpec::parse_local`, `parse_dynamic`, and `parse` now take an `AddressFamily` argument.
  - The four public `SshClient::*_with_jump_hosts` helpers (`upload_file_with_jump_hosts`, `download_file_with_jump_hosts`, `upload_dir_with_jump_hosts`, `download_dir_with_jump_hosts`) now take a `&SshConnectionConfig` and an `Option<&SshConnectionConfigResolver>` in place of their former trailing argument. #246 first added a bare `AddressFamily` parameter here; #249 replaced it with the full resolved config before either shape shipped, so a consumer upgrading from 2.3.1 sees only this final form.
  - `ForwardingConfig` gained a public `address_family` field.
  - `bssh::ssh::tokio_client::Error` gained a `NoAddressForFamily` variant.
  - `bssh::ssh::tokio_client` exports a new `SshConnectionConfigResolver`, which combines command line overrides, YAML defaults, and ssh_config `Host` blocks for a given target.

### Fixed
- **Make `bssh ping` report an exit code, instead of always exiting 0 while its help text promised otherwise** (#245). `ping_nodes` counted successes and failures only to print them, returned `Ok(())` unconditionally, and nothing downstream translated the counts, so `bssh -H unreachable-host ping; echo $?` printed 0 and every script that branched on `bssh ping` succeeding passed unconditionally. Ping now follows a 0/1/255 contract: 0 when every targeted host connected and authenticated, 1 when at least one host was reachable and at least one failed, and 255 when no host succeeded or when bssh failed before it could attempt any connection (configuration file that could not be loaded, host list that resolved to nothing). The 0/1 boundary is `ExitCodeStrategy::RequireAllSuccess`, since a health check is green only when every node is green; `MainRank` does not apply because ping runs no user command whose status could be forwarded. The 255 value follows OpenSSH, which reserves it for "ssh itself encountered an error", and it keeps a partially degraded cluster distinct from one that could not be reached at all. This is a user-visible behavior change in both directions: the all-unreachable case previously exited 0 and now exits 255, which is also not the 1 the old help text implied. Command-line usage errors are still rejected by the argument parser with its own exit code 2, unchanged and identical across subcommands. The `ping` help text also claimed it "Reports connection status, authentication success, and response times" while printing no timing at all; the claim is removed rather than implemented, and per-host timing remains a separate proposal.
- **Wire up the `-4` / `-6` address family flags and the ssh_config `AddressFamily` keyword, which were parsed and then discarded** (#246). Both were advertised in `--help` and the man page and had no effect: `bssh -6` against a dual-stack host could still connect over IPv4, and `AddressFamily any|inet|inet6` round-tripped through the config layer without ever being read. A single `AddressFamily` preference is now resolved once per dispatch path with OpenSSH precedence (command line flag over config keyword over the `any` default), carried on `SshConnectionConfig`, and applied when the connect path filters resolved addresses. It covers direct connections for `exec`, interactive sessions, `ping`, and SFTP `upload`/`download`, plus the first hop of a `-J` chain, which shares that path. Forcing a family that has no resolved address is a hard failure with no fallback to the other family, matching OpenSSH, reported as `no IPv6 address found for <host>` (or the IPv4 equivalent) instead of the generic `could not resolve to any addresses`. An unrecognized `AddressFamily` value warns and is treated as `any` rather than failing a configuration file OpenSSH would accept. With neither the flag nor the keyword set, every resolved address is still tried in resolver order, unchanged.
- **Apply the address family preference to port forwarding, changing the default `-L`/`-D` listen address under `-6`** (#246). This is a user-visible default change: with `-6`, a `-L` or `-D` specification that does not name a bind address now listens on `::1` instead of `127.0.0.1`, and the `*:port` wildcard form listens on `::` instead of `0.0.0.0`. A specification that names a bind address explicitly is unaffected, as are `-4` and the no-flag default, which both keep the IPv4 loopback. Scripts that pass `-6` and assume an IPv4 loopback listener should name the bind address explicitly (for example `-L 127.0.0.1:8080:example.com:80`). Forwarding *targets* are also filtered by the forced family for `-L` and SOCKS5 `-D` requests, but the remote server performs the actual connect, so that filtering is a best-effort hint; SOCKS4 requests carry a literal IPv4 destination by protocol definition and are passed through unfiltered. The `-R` listener lives on the server and is not governed by the local flag. Jump hops past the first one also resolve their target locally through the same `direct-tcpip` mechanism; #247 left those unfiltered as a scope limitation, closed within this release by #248 below.
- **Apply the address family preference to jump hops past the first one, which could silently tunnel over the wrong family** (#248). The rationale recorded in #247 for leaving later hops out of scope, that the remote server resolves those addresses so bssh cannot influence the family, was wrong. `src/jump/chain/tunnel.rs` opens each later hop and the chained destination with `open_direct_tcpip_channel`, which resolves the target locally and sends a literal IP string in the `direct-tcpip` request, so bssh does pick the family and simply was not applying the user's constraint: a `-6` invocation could tunnel to an IPv4 address for every hop after the first. Both call sites now use the family-aware `open_direct_tcpip_channel_with_family` with the resolved `SshConnectionConfig` address family, the same mechanism #247 already used for `-L` forwarding targets. `ARCHITECTURE.md` and the man page no longer document later hops as unfiltered.
- **Resolve ssh_config connection settings against the actual target host instead of once per dispatch** (#249). `build_ssh_connection_config` looked settings up with a hostname that was `Some(..)` only in `is_ssh_mode()`, so every other path queried the literal `"*"` and missed per-host blocks: `bssh -H v6node uptime` ignored a `Host v6node` / `AddressFamily inet6` stanza, and the same miss applied to `ping`, `upload`, and `download`. This was not introduced by #247; `Compression` and the `ServerAliveInterval` / `ServerAliveCountMax` pair already had it, and `AddressFamily` joined an existing defect. A new `SshConnectionConfigResolver` now combines command line overrides, YAML defaults, and ssh_config `Host` blocks, and runs at the `ParallelExecutor` per-node seam so all four settings resolve per target. It is threaded through `SshClient` and `JumpHostChain`, so a jump hop resolves against its own `Host bastion` block rather than inheriting the destination's settings, closing the second facet of the same bug. SFTP transfer paths carry the full resolved `SshConnectionConfig` instead of only the address family.
- **Accept bracketed IPv6 address literals in host specifications, which were unusable in every form** (#251). `-H '[::1]'`, `[::1]:22`, `user@[::1]`, and `user@[::1]:22` all died in the hostlist expander before any connection was attempted (`unmatched closing bracket in '[::1]'`), because the expander uses `[` and `]` as its range expression delimiters (`node[1-3]`) and a bracketed literal collided with that syntax; unbracketed `::1` reached the connect path and then failed resolution, since a bare `::1` cannot be parsed as `host:port`. A bracket group is now disambiguated by parsing its full contents as an IPv6 address, so `[::1]` is a literal and `node[1-3]` remains a range. Bracketed hosts are normalized before resolver calls, with brackets stripped from the returned host so `(host, port)` passes straight to a resolver, and a bare literal is rejected with specific guidance (`IPv6 address literals must be enclosed in brackets, for example '[::1]'`) rather than a resolution failure, since an unbracketed trailing numeric segment is indistinguishable from a port. The same rules apply to `-H`, SSH-style destinations, and both simple and detailed cluster node entries. Hostnames that resolve to IPv6 were never affected and are unchanged. The accepted forms and the disambiguation rule are documented in the CLI help and the man page.
- **Show the full error context chain when an interactive connection fails** (#238). Interactive mode printed only anyhow's outermost context, so a jump-host failure surfaced as `Failed to establish jump host connection to <host>` with no indication of which hop failed or why, leaving first-jump authentication failures, destination TCP refusals, and destination authentication failures indistinguishable. Both interactive execution paths (PTY and traditional) and the directory-download failure message now render the whole chain through a shared `format_connection_error` helper that uses anyhow's alternate `Display` form, matching the format the parallel exec path already used. Tracing verbosity and exec-mode output are unchanged.
- **Fix three pre-existing error-message defects that the #238 chain rendering would otherwise have made user-visible**. `src/jump/chain.rs`'s intermediate jump-hop context interpolated the jump host twice, printing `Failed to connect to jump host bastion (hop 2): bastion` instead of `Failed to connect to jump host bastion (hop 2)`. The direct-connect error in `src/ssh/client/connection.rs` and the file-transfer connect error in `src/ssh/client/file_transfer.rs` both built their anyhow chain backwards as `anyhow!(friendly_message).context(e)`, which makes `.context()`'s argument the *outer* layer, so the raw SSH error rendered first and the friendly message rendered underneath it as a near-duplicate cause; both now build `anyhow::Error::new(e).context(friendly_message)` so the friendly message renders first and `e` renders as the cause. `src/commands/ping.rs`'s error-chain print now reuses the shared `format_connection_error` helper instead of its own inline `format!("{e:#}")` for consistency; its per-line indentation is unchanged.
- **Stop repeating the same wording twice in a rendered connection-error chain**. Because anyhow's `{:#}` form joins layers with `": "`, a context message that restates its own cause prints the same text twice on one line. The direct-connect path returned a context layer for every error variant, so `PasswordWrong` rendered as `Password authentication failed.: Password authentication failed` and `SshError` interpolated the underlying russh error that the variant's own `Display` already carried; it now returns a context layer only for variants whose `Display` does not already say everything, and those messages add remediation guidance without echoing the cause. `KeyInvalid`'s context in both the direct-connect and file-transfer paths no longer re-interpolates the inner key error. Context messages also no longer end with a period, which previously rendered as the sequence `.: ` mid-line. Fixed the `occured` misspelling and the `Ssh` capitalization in the `SshError` and `SftpError` messages, which are now shown to the user directly rather than behind a wrapping context layer. The jump-chain handler-address resolver (`resolve_handler_address`) no longer restates `host:port` on top of the `.with_context()` every call site already adds, closing the same class of duplication for `-J` chains.

### Documentation
- **Remove release history and migration guidance from the man page.** The man page is reference documentation for how bssh behaves now, so two NOTES subsections that were pure changelog content are gone: "Breaking Changes (v0.5.3+)", recording the `-c` to `-C` cluster flag rename and the `-p` to `--parallel` split, which was missing from this file and is moved to the 0.5.3 entry rather than dropped; and "Breaking Changes (v1.2.0)", recording the old and new exit code behavior, which the 1.2.0 entry already covers in full. "Main Rank Detection" was nested under the second block despite describing current behavior, and is promoted to its own subsection.

### Dependencies
- **Refresh the locked dependency graph.** No `Cargo.toml` requirement changed; only resolved versions in `Cargo.lock` moved, including russh 0.62.1 to 0.62.5, tokio 1.52.3 to 1.53.1, clap 4.6.1 to 4.6.5, serde 1.0.228 to 1.0.229, and ratatui 0.30.0 to 0.30.2. The transitive set shifted with them: `ratatui-termina`, `termina`, `palette`, `sponge-cursor`, and `approx` enter, while `scc`, `sdd`, `prettyplease`, and the `wit-bindgen` / `wasm-encoder` / `wasmparser` toolchain crates drop out.

## [2.3.1] - 2026-07-29

### Added
- **Preserve Kitty keyboard state owned by an outer TUI across interactive PTY sessions** (#236). bssh now queries both terminal screens before reading input, isolates the remote shell from the captured state, forwards unrelated or concurrently typed bytes, and restores the original screen and keyboard modes during every cleanup path.

### Fixed
- **Restore ordinary keyboard input after interactive PTY disconnects** (#234). Cleanup now drains leaked Kitty keyboard stacks on both terminal screens, clears active flags, and disables xterm `modifyOtherKeys`, preventing key presses from appearing as CSI-u fragments such as `;1:3u` or `:1C`.
- **Prevent Homebrew tap updates from corrupting formulae or publishing invalid checksums** (#233). The workflow matches artifact stanzas instead of relying on line positions, rejects failed or malformed downloads, and validates formula syntax, style, version, URLs, and checksums before committing.

### Changed
- **Raise the workspace MSRV and Debian/Launchpad build toolchain from Rust 1.93 to 1.96** (#231, #232). Jammy, Noble, and Resolute now use the `rustc-release` dependency PPA, and `vendor_rust` is pinned to 1.96.0 to match the published toolchain.

### Documentation
- **Remove stale vendored-russh attribution and bound README release history** (#230). NOTICE now lists only source redistributed in-tree, and README points to this changelog instead of maintaining a duplicate full version list.
- **Fix the file-transfer filter example so it compiles as a doctest.** The example now imports `TransferFilter` before calling its `check` method.

## [2.3.0] - 2026-07-18

### Performance
- **Roughly double bssh-server single-connection SFTP write throughput by fixing channel fragmentation and the serial write path** (#187). Three independent bottlenecks identified in the issue's code audit are addressed. First, `build_russh_config` now advertises a 65535-byte `maximum_packet_size` (the russh cap; library default 32768) and an 8 MiB `window_size` (library default 2 MiB), both configurable via `server.maximum_packet_size` / `server.window_size` (env: `BSSH_MAX_PACKET_SIZE` / `BSSH_WINDOW_SIZE`), so a 256 KiB SFTP write is no longer chopped into 8 CHANNEL_DATA packets that each pay the full cipher/copy/scheduling cost. Second, the SFTP server loop in `bssh-russh-sftp` is restructured from a strict read-process-write-flush cycle into a reader task with a bounded read-ahead queue plus an in-order processor: responses are flushed once per burst instead of once per request, and consecutive queued `SSH_FXP_WRITE` requests to the same handle at sequential offsets are coalesced into a single handler call (bounded by `max_write_coalesce_len`, default 256 KiB) while every merged request id still receives its own status reply, preserving SFTP response ordering and error semantics. The bssh write/read handlers additionally track each open file's cursor and elide the per-chunk `seek` for sequential transfers (append handles are exempt since O_APPEND ignores the cursor). Third, the workspace gains a tuned `[profile.release]` (`lto = "fat"`, `codegen-units = 1`) and documented `RUSTFLAGS` target-CPU guidance for self-builds. Local before/after benchmark (loopback, OpenSSH sftp client, 1 GiB random file, 3-run averages, 20-core Linux box): upload 71 MiB/s to 437 MiB/s (6.2x), download 73 MiB/s to 282 MiB/s (3.9x); an ablation with the code changes but library-default channel sizing lands at 333 / 291 MiB/s, attributing most of the gain to the pipelined write path and the rest of the upload gain to the larger packets. Numbers on the reporter's NIC-limited Xeon will differ; the methodology is documented in the PR. Remaining plan items (per-packet copies inside russh, AES-CTR internals) live in upstream russh since #212 removed the fork and are out of scope here.

### Added
- **Make server-side SSH compression configurable instead of hard-disabled** (#220). The #215 workaround forced the server to advertise only `none` compression for every deployment, with no way for operators to opt back in. A new `server.compression` option (YAML `server.compression: true`, env `BSSH_COMPRESSION`, builder `ServerConfigBuilder::compression`) now controls whether `build_russh_config` advertises russh's default compression list (`none`, `zlib`, `zlib@openssh.com`) or only `none`. The default stays off, so behavior is unchanged unless explicitly enabled, and enabling it logs a warning: russh's delayed-zlib (`zlib@openssh.com`) transport still desyncs a few packets after compression activates post-auth (reproduced on russh 0.61.1 and 0.62.1), dropping clients that negotiate it mid-session. Both settings are covered by unit tests, and the option plus the desync caveat are documented in the man page and config docs.

### Fixed
- **Fix an SFTP session deadlock under paramiko's unbounded READ prefetch, and stop stalling sequential SFTP round trips on Nagle's algorithm** (#227). paramiko's `SFTPClient.get()` prefetches by firing READ requests for the whole file with no outstanding-request cap (2048 requests for a 64 MiB file), and any download to such a client froze at exactly the client's initial 2 MiB channel window, with the unread WINDOW_ADJUST messages sitting in the server socket's receive queue. The cycle: the SFTP processor blocks writing a response once the channel window is exhausted; the count-bounded (16) read-ahead queue fills; the reader task stops draining the channel stream; the russh channel rx buffer (100 messages) fills; and the russh session event loop blocks delivering channel data, so it stops reading the socket and never processes the WINDOW_ADJUST that would refill the window. OpenSSH's `sftp` caps outstanding requests at 64 and sshj uses bounded read-ahead, which is why only paramiko (the stack under Ansible and many Python tools) tripped it; the bug predates the #224 pipelining, whose serial predecessor coupled intake to response progress the same way. The SFTP intake queue is now unbounded in request count and bounded in bytes (`max_buffered_request_bytes`, default 8 MiB; a READ request is ~50 bytes, so legitimate pipelining stays far under it): the reader never stops draining while under budget, and a client that floods past it has its session terminated instead of deadlocked. Separately, `build_russh_config` now sets `nodelay: true` on accepted sockets (russh defaults to Nagle on; OpenSSH always sets TCP_NODELAY), removing a ~30 ms delayed-ACK stall per strictly sequential round trip. With both fixes the previously hanging paramiko `get()` completes with byte-for-byte integrity (64 MiB in 0.4 s, 2 GiB verified), and paramiko sequential reads (`prefetch=False`) reach parity with OpenSSH sshd on the same host (15.3 s vs 13.9 s for 64 MiB; the residual per-request latency is client-side, since paramiko itself never sets TCP_NODELAY). Client-initiated disconnects (`Connection reset by peer` and friends) are also no longer logged at ERROR, and regression tests cover the deadlock shape (verified to fail against the previous code), budget-overflow termination, and the disconnect classification.
- **Make `sftp.root`/`scp.root` chroot usable by re-anchoring client paths under the chroot root** (#214). With a chroot configured, directory listing worked but every path resolution failed: `cd subdir`, `get file`, and even `get` of a file sitting directly at the chroot root were rejected as `path outside root` or `not found`, so only bare `/` and `readdir` functioned. Under chroot the client's coordinate space is rooted at `/` (this is what `realpath` reports and what the client sends back), but `resolve_chroot` treated a client absolute path such as `/subdir` as a *host* path and rejected anything not starting with the host root, so `/subdir` (meaning `<root>/subdir`) never matched. A prior change had introduced this to avoid "path doubling", but real clients never send the host path; they send chroot-relative absolute paths, so the guard broke the normal case. The SFTP and SCP resolvers now both interpret absolute and relative client paths relative to `root` (OpenSSH `ChrootDirectory` re-rooting), ignoring a leading `/`, dropping `.`, and clamping `..` so traversal still cannot escape. Containment is verified: `..` stays pinned at the root and a client `/etc/passwd` maps to `<root>/etc/passwd` inside the jail, never the host file. SCP previously kept the old "reject absolute outside root" behavior, so it is unified here to match `sftp.root`. Verified end-to-end with the OpenSSH `sftp` client (`cd`/`get`/root-level files/Unicode names all work; escape attempts stay confined) and covered by updated unit and integration tests.
- **Advertise only `none` SSH compression so clients that negotiate `zlib@openssh.com` no longer drop mid-session** (#215). Cyberduck, OpenSSH `sftp -C`, and any client that prefers delayed zlib completed the SFTP handshake (`INIT` / `REALPATH` / `STAT` all succeeded) and then the connection died, with the server logging `SshEncoding: length invalid` on the next inbound packet. The root cause is russh's delayed-zlib (`zlib@openssh.com`) transport: the flate2 stream desyncs a few packets after compression activates post-auth, so russh decodes the following packet's length prefix out of corrupted plaintext. It reproduces on both russh 0.61.1 and 0.62.1, which rules out the channel-close path and pins it to compression rather than the SFTP layer (OpenSSH's default `sftp`, FileZilla, and paramiko all negotiate `none` and were unaffected). `build_russh_config` now sets `russh::Preferred { compression: Cow::Borrowed(&[compression::NONE]), ..DEFAULT }`, so the server advertises only `none` and every client falls back to the uncompressed transport, matching the Dropbear / OpenSSH `sftp-server` defaults used in Backend.AI kernel containers, where compression was never in play. Verified with `sftp -C` (now negotiates `compression: none` and lists/downloads cleanly) and a live Cyberduck 9.5 session. The underlying russh delayed-zlib desync is left to be fixed upstream.
- **Wire the ssh_config `Compression` directive into the russh client instead of silently ignoring it** (#219). `Compression yes`/`no` was parsed and resolved (`SshHostConfig::compression`) but `to_russh_config` never read it, so it had no effect on the actual connection regardless of the setting. `SshConnectionConfig` gains a `compression` field and `with_compression` builder, and `to_russh_config` now sets `russh::Preferred.compression` from it: `false` (the default, matching `Compression no`/unset) advertises only `none`; `true` (`Compression yes`) advertises eager `zlib` ahead of `none`. `zlib@openssh.com` is deliberately never advertised even when compression is enabled, because the delayed-zlib desync fixed server-side in #215 lives in russh's codec and would equally corrupt a bssh client session. `build_ssh_connection_config` in the dispatcher now resolves `Compression` from ssh_config via the new `SshConfig::get_compression` and feeds it through, so the setting reaches every connection path that shares `SshConnectionConfig` (direct connections, jump-host chains, interactive sessions).

### Security
- **Confine absolute SFTP symlink targets to the chroot** (#214). Once `resolve_chroot` stopped rejecting out-of-root absolute paths, the SFTP `symlink` handler's containment guard became a no-op, so a chrooted client could create a link whose on-disk target was an absolute *host* path (for example `symlink /link /etc/passwd`). Because bssh uses a virtual chroot with no `chroot(2)`, that link resolved to the real host filesystem. Absolute symlink targets are now re-anchored under the chroot before the link is written, so a created link can never point outside the jail; relative targets keep OpenSSH-compatible verbatim storage.

### Changed
- **Raise the workspace minimum supported Rust version from 1.88 to 1.93.** The declared 1.88 floor was already inaccurate: `rustyline` 18 uses `File::lock` (the `file_lock` feature, stabilized in Rust 1.89), so the workspace has not built on 1.88 since `rustyline` was adopted (`cargo +1.88.0 check` fails with E0658; 1.89 and 1.93.0 build clean). CI only built on stable and never caught the drift. `rust-version` is now 1.93, aligned with the Launchpad PPA build toolchain (rustc-1.93 on jammy/noble, in the Ubuntu 26.04 archive on resolute), and a new CI job reads `rust-version` from `Cargo.toml` and runs `cargo check --workspace --locked` on that toolchain so the declared MSRV can no longer silently drift. The Debian packaging `Build-Depends` and the Launchpad PPA vendored toolchain move to rustc-1.93 to match.
- **Unify the auxiliary binaries on the "Broadcast SSH" name** (#210). `bssh-server` and `bssh-keygen` still surfaced "Backend.AI SSH" in their `--help`/version output while the main `bssh` CLI already said "Broadcast SSH"; both are switched to "Broadcast SSH" so all three tools are consistent. `ARCHITECTURE.md` keeps "Broadcast SSH / Backend.AI SSH" to preserve the Backend.AI lineage in the design doc, and the `LICENSE` copyright span is extended to 2024-2026 to match active maintenance.

### Dependencies
- **Drop the vendored `bssh-russh` fork and build against upstream russh 0.62.1** (#212). Both reasons the fork existed are now upstream: the high-frequency PTY output fix (`Handle::data` from spawned tasks, upstream #731 in russh 0.62.0) and the SHA-1 MAC exclusion from `Preferred::DEFAULT` (upstream #690 in russh 0.60.2). The dependency switches from the `path` fork to the crates.io `russh` 0.62.1 and the 66-file vendored crate is removed. `ssh-key` is bumped `=0.7.0-rc.10` to `=0.7.0-rc.11` to match russh 0.62.x, and `channel_open_session` is adapted to the new 0.62 handler signature (a `ChannelOpenHandle` reply plus `Result<()>` instead of `Result<bool>`, accepting the channel via `reply.accept()`). The `.cargo/audit.toml` note for RUSTSEC-2023-0071 is reworded now that no vendored russh fork remains. Verified with a green build, clippy, fmt, and full suite (1233 lib tests, PTY stress 10/10, streaming 28/28).

### Documentation
- **Add an Apache-2.0 `NOTICE` file** (#210) as recommended by Apache License 2.0 section 4(d). It records bssh's own copyright and attributes the vendored upstream forks whose source is redistributed in-tree: russh (originally Thrussh by Pierre-Étienne Meunier, maintained by Eugeny Pankov) and russh-sftp (by AspectUnk), both Apache-2.0. Neither upstream ships a `NOTICE` file, so there is nothing to propagate; the attribution covers the vendored copies and the bssh-specific patches.
- **Document that native Windows client execution is not supported** (#221). A new README "Platform Support" section states that Linux and macOS are fully supported, native Windows client execution is not currently supported, and WSL2 is the recommended path today, linking to #213 for the blocker list; `docs/architecture/ssh-client.md` cross-references it from the existing "SSH agent not supported on Windows" note. No code changes; the native Windows port remains a separately scoped follow-up.

### Tooling
- **Add `tools/bench/`, a self-contained SFTP benchmark, flamegraph, and interop harness** (#228). `bench.sh` measures `bssh-server` SFTP throughput relative to OpenSSH on the same host (with optional before/after comparison via `BSSH_BASELINE_BIN` and single-core pinning to emulate the CPU-bound container scenario from #187); `profile.sh` captures a `perf` plus inferno flamegraph of the upload path behind a `perf_event_paranoid` guard; `interop/` validates round trips and negotiation against sshj and paramiko. Servers bind to loopback only, nothing touches the user's real SSH config, and every transfer is verified byte-for-byte. This harness produced the #187 throughput measurements and surfaced the paramiko read-path deadlock later fixed in #227. No shipped code is touched.

## [2.2.3] - 2026-05-25

### Security
- **Patch RUSTSEC-2026-0009, a stack-exhaustion denial of service in `time`** (#208). `cargo audit` flagged `time` 0.3.45 (medium, 6.8), pulled transitively via `ratatui` 0.30 to `ratatui-widgets` to `time`. Bumped `time` to 0.3.47 (with `num-conv` 0.1.0 to 0.2.2 and `time-core` 0.1.7 to 0.1.8), a lockfile-only change since `ratatui`'s requirement already permits it; `cargo audit` now reports 0 vulnerabilities. Because `time` 0.3.47 requires Rust 1.88, the workspace minimum supported Rust version (`rust-version`) is raised from 1.85 to 1.88; this keeps the MSRV-aware resolver from reverting `time` to the vulnerable 0.3.45 on a future `cargo update`.

### Dependencies
- **Sync both internal russh forks to their latest upstream releases and unify `ssh-key`** (#207). `bssh-russh` advances from a russh 0.60.3 base to **0.61.1**, adopting the new RustCrypto generation upstream migrated to: `sha2` / `sha1` 0.10 to 0.11, `hmac` 0.12 to 0.13, `aes` 0.8 to 0.9, `cbc` 0.1 to 0.2, `ctr` 0.9 to 0.10, `digest` 0.10 to 0.11, `pbkdf2` 0.12 to 0.13, `ssh-key` to 0.7.0-rc.10, and `ssh-encoding` to 0.3.0-rc.9. These cannot be bumped standalone because russh's source targets the old `cipher` 0.4 / `digest` 0.10 API; upstream moved the whole cohort together in 0.61. The high-frequency PTY `Handle::data()` drain fix is re-ported onto the new `server/session.rs` (confirmed still absent upstream in 0.61.1, so the fork remains necessary), three patches now merged upstream are removed (`channel-write-ordering`, `agent-frame-length-cap`, `sha1-mac-exclude`) leaving only `handle-data-fix.patch`, and a PTY regression test is added at `crates/bssh-russh/tests/pty_handle_data.rs`. `bssh-russh-sftp` does a full source sync from upstream 2.1.2 to **2.3.0** with the two pipelined File I/O helpers (`write_all_pipelined` / `read_to_writer_pipelined`) re-applied on top; the bssh SFTP server is adapted to the 2.3.0 `server::Handler::Error` change (now `Into<StatusReply>`, which also surfaces the human-readable error message in `SSH_FXP_STATUS`). In the main crate, `ssh-key` is unified to `=0.7.0-rc.10` so the workspace resolves a single `ssh-key` version instead of 0.6 and 0.7-rc side by side, `argon2` gains its `std` feature (restoring `rand_core`'s `OsRng` after the generation shift), and transitive deps are refreshed via `cargo update`. Both fork crates are published to crates.io at `bssh-russh` 0.61.1 and `bssh-russh-sftp` 2.3.0.

### Changed
- **Fork maintenance tooling and docs refreshed** (#207). Both forks' `create-patch.sh` are now self-contained: they clone the relevant upstream into a temp directory instead of relying on a gitignored `references/` checkout, write the correct current patch names, and fall back to the default branch where upstream publishes no git tags. `sync-upstream.sh` applies every `patches/*.patch` with reverse-apply upstream detection, and both fork READMEs are rewritten for the new versions.

### CI/CD
- Bump `action-gh-release` to v3 for the Node 24 runtime, and adjust the download shield badge.

## [2.2.2] - 2026-05-25

### Fixed
- **Keep idle SSH sessions alive** (#206). Lower the default keepalive interval (`--server-alive-interval`) from 60s to 30s so bssh sends keepalive traffic before common one-minute idle reapers (load balancers, NAT gateways, sshd `ClientAliveInterval`) close otherwise-healthy sessions. Normalize `--server-alive-interval 0` to fully disabled keepalive instead of constructing a zero-duration russh timer, treating `Some(0)` the same as `None` in both the russh client config and the TCP `SO_KEEPALIVE` path. Leave the client-side `inactivity_timeout` disabled unconditionally so a healthy interactive session that legitimately produces no inbound data for a long time (tmux, an idle shell, a long-running REPL) is never torn down locally by bssh; russh's keepalive counter remains the sole liveness detector when keepalive is enabled. Dead-peer detection now resolves in about 120s (three unanswered 30s probes plus the next timer tick that observes them) rather than the previous 180s.

## [2.2.1] - 2026-05-19

### Security
- **Forward-port the SSH-agent half of CVE-2026-46673 (compression ZIP-bomb DoS) into the bssh-russh fork** (#203). Upstream russh v0.60.3 includes the fix as commit `a2d48a7`, but our fork was based on v0.60.1 and did not. Adds `MAX_AGENT_FRAME_LEN = 256 * 1024` and a new `read_frame()` helper in `src/keys/agent/{client,server}.rs` that rejects peer-supplied frame lengths above the cap with `Error::AgentProtocolError` before resizing the receive buffer, blocking the OOM-via-oversized-length-prefix vector. Recorded as `crates/bssh-russh/patches/agent-frame-length-cap.patch` so the next `sync-upstream.sh` run will auto-skip via the reverse-apply dry-run check. The matching `russh-cryptovec` 0.59.0 to 0.60.3 bump in the same PR brings in the cryptovec hardening half of the same CVE (checked-capacity allocation paths, mlock null-pointer guard).
- `cargo audit` against the latest 1090-advisory RustSec database reports **0 vulnerabilities and 0 unmaintained/yanked warnings** after the bumps.

### Dependencies
- **Bump workspace dependencies and sync both russh forks to upstream stable** (#203). Main bssh: `lru` 0.17 to 0.18 (lifetime fix in `get_or_insert_mut_ref`), `signal-hook` 0.3 to 0.4 (only the unused `low_level::pipe` API changed), `opentelemetry` / `opentelemetry_sdk` / `opentelemetry-otlp` 0.31 to 0.32 (`ExportConfig` / `HasExportConfig` removal does not touch our LogExporter / SdkLoggerProvider / Resource / LogRecord call sites), `nix` 0.31.2 to 0.31.3, plus transitive `pin-project`, `tower-http`, `zerofrom` patches. The `bssh-russh` fork advances 0.60.1 to 0.60.3, picking up `aws-lc-rs` 1.16.3 to 1.17.0 and the upstream v0.60.2 unreleased fixes our PR #193 had already forward-ported (#690 SHA-1 MAC exclusion, #693 channel write ordering). The `bssh-russh-sftp` fork does a full source sync from upstream 2.1.1 to 2.1.2: upstream absorbed the original `serde_bytes` perf annotations on `protocol::{Write,Data}.data` plus the matching `serialize_bytes` implementation, so the patch is moved to `patches/historical/` for provenance; the fork's remaining value-add is the two pipelined File I/O helpers (`write_all_pipelined` / `read_to_writer_pipelined`), now re-ported on top of upstream 2.1.2's new `Features` API with chunk sizing derived from `features.limits.{write,read}_len` or `features.max_packet_len.saturating_sub({WRITE,READ}_OVERHEAD_LENGTH)` instead of the removed `MAX_*_LENGTH` constants. `crates/bssh-russh-sftp/Cargo.toml` swaps `flurry` for `dashmap` 6.1.0 and adds `serde_bytes` as a direct dep to match upstream.

### Fixed
- **Add the missing `[dev-dependencies]` block to the bssh-russh fork so its inline test target actually compiles** (#204). The fork's `src/client/test.rs`, `src/keys/mod.rs`, and `src/tests.rs` were imported verbatim from upstream russh during the initial sync (commit `508aa3f0`, "Sync bssh-russh fork with upstream russh 0.60.0"), but the matching `[dev-dependencies]` were never copied across, so `cargo test -p bssh-russh` has failed with E0433 on `env_logger` / `tempfile` and cascading E0282 type-inference errors from day one. PR #203 first surfaced this via `cargo clippy -p bssh-russh --all-targets`. Adds a minimal block covering exactly what the imported tests reference: `env_logger = "0.11"`, `tempfile = "3"`, and `tokio = { version = "1.52.1", features = ["process", "macros"] }` as an additive merge with the main tokio dep (the `process` feature is needed by the spawn-ssh-agent helpers, `macros` by `#[tokio::test]`). 75 tests now run, covering the agent client/server round-trip, PKCS#8 / OpenSSH key decoding, channel lifecycle, GEX, compression, future certificate auth, and server kex junk handling. The agent tests directly exercise the new frame-length cap from PR #203 (the CVE-2026-46673 mitigation) by spawning a real `ssh-agent` over a Unix-domain socket. Workspace aggregate climbs from 1796 to 1871 passed / 0 failed / 10 ignored.
- **Drop a redundant `.into_iter()` in the synced SFTP session loop to satisfy rustc 1.95's stricter `clippy::useless_conversion` lint** (#205). `Iterator::chain` already accepts any `IntoIterator`, so `.chain(files.into_iter())` at `crates/bssh-russh-sftp/src/client/session.rs:194` was redundant. The lint became more aggressive in rustc 1.95.0, breaking CI after the PR #203 sync (the line was imported verbatim from upstream russh-sftp 2.1.2). PR #203 was developed on rustc 1.93.1 where this case did not lint-fire. One-line fix: `.chain(files)` instead of `.chain(files.into_iter())`, functionally identical. Worth filing upstream against `AspectUnk/russh-sftp` so future syncs do not have to re-port this.

## [2.2.0] - 2026-05-18

### Added
- `BSSH_PASSWORD` environment variable for non-interactive password authentication (#201). Documented in the man page and the README environment-variables section. Discouraged for production deployments; recommended for automated test pipelines and CI scenarios where SSH agent or key-based auth is not feasible.

### Fixed
- **`--password` prompt is now collected once up-front and shared across all parallel connection tasks** (#201, closes #200). Previously each per-node SSH task prompted for the password independently via `rpassword::prompt_password()` inside `password_auth()`, racing each other for stdin and interleaving with the indicatif progress UI rendering. The dispatcher now collects the password before any executor or `MultiProgress` is initialized and threads an `Arc<Password>` (backed by `secrecy::SecretString`, auto-zeroized on drop) to every per-node auth task, matching the existing `-S` (`SudoPassword`) pattern. The pre-collected password is also threaded through the jump-host chain (`jump::chain::auth::determine_auth_method`), the SFTP `*_with_jump_hosts` helpers, the download glob-resolution path (`SshClient::connect_and_execute_with_host_check`), and the legacy `execute_command_with_forwarding` path. The in-task `rpassword::prompt_password()` call remains only as the fallback for the OpenSSH-style "all key methods failed" opportunistic prompt path, which has no dispatcher-side collection.
- **`-S` was silently dropped by `ping`, `upload`, and `download`** (#201, closes #200). The dispatcher only read `cli.sudo_password` in the `exec` and interactive branches, so users could pass `-S` to `ping`/`upload`/`download`/`list`/`cache-stats` without any error or feedback. These subcommands now emit a stderr warning explaining that `-S` has no effect, while `exec` and the SSH-mode interactive path continue to honor `-S` as before.
- **`-S` ignored-warning routes to stderr instead of stdout** (#201, follow-up). The previous `tracing::warn!` landed on stdout under the default tracing subscriber, breaking `bssh ... ping | grep ...` pipelines. Switched to `eprintln!` to match the existing pattern used for the `BSSH_PASSWORD` env warning.

### Changed
- Tightened sudo-password collection so it only applies to exec paths that can inject sudo responses, and avoid unused SSH password collection for local-only dispatcher paths.

### Dependencies
- **Drop five stale or redundant direct dependencies** (#199). After auditing the full direct dependency set: `arrayvec` (declared but unused anywhere in the source tree), `ctrlc` (replaced with `tokio::signal::ctrl_c` inside a `tokio::spawn`, guarded by `Handle::try_current` to preserve best-effort semantics outside a runtime), `directories::ProjectDirs`/`BaseDirs` (replaced with `dirs::config_dir`/`dirs::home_dir`, unifying on `dirs` which was already used in 16 other sites and produces equivalent platform paths), `signal-hook` 0.4.4 (downgraded to 0.3 so the project shares the same instance `crossterm` already pulls in via `signal-hook-mio`; usage signatures are identical across both major lines), plus the macOS `objc2`/`block2`/`dispatch2` chain pulled in by `ctrlc`. Three more crates (`lazy_static`, `once_cell`, `fastrand`) remain transitively but are no longer directly referenced after migrating to `std::sync::LazyLock`/`OnceLock` and `rand::random_range`. `Cargo.lock` loses 76 lines including the entire `signal-hook` 0.4.4 subtree.
- **Replace the unmaintained `atty` crate with `std::io::IsTerminal`** (stdlib since Rust 1.70) across PTY, logging, ssh::auth, and the interactive connection (#198). Drops RUSTSEC-2024-0375 (unmaintained) and RUSTSEC-2021-0145 (unsound unaligned read) advisories at once.
- Pick up 33 transitive patch bumps from `cargo update` within current semver constraints (#198): tokio 1.52.1 to 1.52.3, rustls 0.23.39 to 0.23.40, h2 0.4.13 to 0.4.14, digest 0.11.2 to 0.11.3, rpassword 7.4.0 to 7.5.2, and others.

### Documentation
- Document `BSSH_PASSWORD` env var and the new single-prompt password-collection behavior in the README and `bssh.1` man page, including the updated `--password` flag description.

### Security
- Add `.cargo/audit.toml` ignoring RUSTSEC-2023-0071 (RSA Marvin Attack) with an explanatory comment (#198). Both `rsa` 0.9.10 (via `ssh-key` 0.6.x) and `rsa` 0.10.0-rc.17 (via the vendored `bssh-russh` fork) are affected, and no fixed upstream `rsa` release exists. Bumping to 0.10.0-rc.18 conflicts with the `bssh-russh` `pkcs5 = "=0.8.0-rc.13"` pin, so the project accepts the advisory at the audit layer and documents Ed25519/ECDSA as the recommended mitigation for users handling untrusted hosts.

## [2.1.4] - 2026-05-10

### Performance
- **Stream SFTP uploads/downloads instead of buffering whole files in memory** (#195). Previously `upload_file`/`upload_dir_recursive` loaded the entire local file into a `Vec<u8>` via `tokio::fs::read` before calling `write_all`, and `download_file`/`download_dir_recursive` called `read_to_end` into a pooled buffer plus a `clone()` to a separate `Vec` before writing locally. Multi-GB transfers therefore had peak RSS that scaled with file size and large files OOM'd the client. Each path now uses a small `stream_copy()` helper looping on 255 KiB reads/writes through the existing `AsyncRead`/`AsyncWrite` impls on `tokio::fs::File` and `russh_sftp::client::fs::File`. Buffer size matches the SFTP `MAX_WRITE_LENGTH` so each chunk maps to a single SFTP packet without further fragmentation. Verified locally on macOS arm64 against `bssh-server` v2.1.3 over loopback with a 1 GiB file: upload peak RSS drops from ~3.23 GB to ~20 MB and wall time from 38.6 s to 3.5 s; download peak RSS drops from ~2.17 GB to ~16 MB and wall time from 3.93 s to 3.41 s.
- **Pipeline up to 64 concurrent SFTP requests for upload and download** (#196). Bounded pipelined SFTP upload/download helpers replace the previous strictly-sequential request/response loop. Review follow-ups also: cap server-advertised read/write lengths against local maxima to avoid oversized allocations from untrusted SFTP metadata, bound the download reorder queue across both in-flight and pending out-of-order responses, use `fstat` size information where available to avoid reads past EOF and validate unexpected short reads, and preserve remote download handle shutdown after syncing with main. Added SFTP crate tests for chunk-size capping and in-memory pipelined upload/download behavior.
- **Raise bssh-server SFTP `MAX_READ_SIZE` to the 255 KiB SFTP standard** (#197). The server previously hard-capped every SFTP `READ` reply at 64 KiB regardless of what the client requested, while `bssh-russh-sftp` and OpenSSH's `sftp-server` both use the SFTP standard `MAX_READ_LENGTH = 261120` (255 KiB) for request sizing. A client asking for a 256 KiB chunk only ever got 64 KiB back, forcing four extra requests per byte stream. Bumped to 261120 so server replies match the chunk size used by the rest of the stack. Combined with client-side pipelining (#196), this directly cuts the per-MiB request count on downloads from 16 to 4. Memory exposure stays bounded: handles are still capped at `MAX_HANDLES = 1000` per session and each in-flight read still uses a single per-request buffer of this size.

## [2.1.3] - 2026-04-30

### Added
- Internal fork of `russh-sftp` as `crates/bssh-russh-sftp` with a `serde_bytes` performance fix for `SSH_FXP_WRITE` and `SSH_FXP_DATA` packets. The upstream serde derive routes `Vec<u8>` through `deserialize_seq` (byte-by-byte), accounting for ~42% of server CPU during 1 GiB SFTP uploads in `perf` profiling. Annotating the `data` fields with `#[serde(with = "serde_bytes")]` and implementing wire-compatible `serialize_bytes` on the SFTP `Serializer` routes through the existing bulk `deserialize_byte_buf`/`try_get_bytes` path. Measured impact on a CPU-bound host (Xeon Silver 4214): 1 GiB SFTP upload throughput improves from 74.8 MiB/s to 96.4 MiB/s (+29%), closing the gap to OpenSSH `sftp-server` from ~26% to ~5%. (#188)
- `scp.root` configuration field. SCP transfers now honor a chroot setting separate from SFTP. When unset, SCP falls back to `sftp.root`, so a single top-level chroot setting governs both subsystems unless an admin explicitly wants them split. (#186)

### Changed
- Switched the top-level `russh-sftp` dependency from crates.io `russh-sftp = "2.1.1"` to `russh-sftp = { package = "bssh-russh-sftp", version = "2.1.1", path = "crates/bssh-russh-sftp" }`. All existing `use russh_sftp::...` imports continue to work unchanged. (#188)
- **Default file-transfer behavior is no longer chrooted to the user's home directory.** With `sftp.root`/`scp.root` unset (the default), absolute client paths are honored verbatim and relative paths resolve from the user's home directory, matching OpenSSH `sftp-server`/`scp` defaults. Deployments that intentionally want chroot-at-home-dir must now set `sftp.root: <home dir>` (or equivalent) explicitly. (#186)
- Forward-ported unreleased upstream `russh` fixes (#193): exclude SHA-1 MACs from `Preferred::DEFAULT`/`COMPRESSED` (upstream russh #690) and fix channel write ordering when `pending_data` is non-empty (upstream russh #693). Refactored `sync-upstream.sh` to iterate `patches/` and reverse-apply `--dry-run` first so already-merged patches are auto-skipped.
- Bumped dependencies: `tokio` 1.52.1, `clap` 4.6.1, `tracing` 0.1.44, `lru` 0.17, `uuid` 1.23.1, `tokio-util` 0.7.18, `aws-lc-rs` 1.16.3, `ecdsa` rc.17, `elliptic-curve` rc.31, `p256`/`p384`/`p521` rc.9. Pinned `pkcs5="=0.8.0-rc.13"` because `pkcs8` 0.11.0-rc.11 still calls the rc.13-era `Parameters::recommended` API. (#193)

### Fixed
- **bssh-server SCP/SFTP path doubling on absolute client paths** (#186). `ScpHandler::resolve_path` and `SftpHandler::resolve_path_static` previously re-rooted every absolute client path under the user's home directory, so `scp local user@host:/home/work/file.bin` wrote to `/home/work/home/work/file.bin` and `bssh upload local /abs/remote.bin` failed with `No such file`. The resolver now treats absolute client paths verbatim when no chroot is configured and rejects out-of-chroot absolute paths with `permission_denied` when one is. Path-traversal and symlink-escape protections continue to apply.
- **SCP single-file destinations no longer have the source filename appended** (#186). `ScpHandler::receive_file` now consults `target_is_directory` (parsed from `-d`/`-r`) and the filesystem state of the resolved target. `scp local.bin user@host:/tmp/dest.bin` now writes to `/tmp/dest.bin` instead of `/tmp/dest.bin/local.bin`. Directory destinations (`/tmp/dir/`, existing directory, or `-d`/`-r` flag) keep the previous filename-appending behavior.
- **Configured `sftp.root` is no longer dead code** (#186). The handler-construction sites in `SshHandler` previously hard-coded `user_info.home_dir` as the chroot root and ignored `config.sftp.root` entirely. Setting `sftp.root` in the YAML configuration now actually changes the SFTP chroot. The same plumbing now exists for `scp.root`.
- **Chroot bypass via intermediate-directory symlink**. The chroot resolver previously checked only lexical containment for paths whose final component did not exist (typical for new-file creates and `mkdir`). A symlink inside the chroot pointing to a directory outside the chroot would let a client target `chroot/escape/newfile` and have `open(...)`/`create_dir(...)` follow the symlink, writing outside the chroot. Both `ScpHandler::resolve_path` and `SftpHandler::resolve_path_static` now canonicalize the closest existing ancestor of the target path and verify it stays inside the canonicalized chroot, blocking the parent-symlink escape. Found during PR #194 review.

### Documentation
- Standardize man page trailers across `bssh.1`, `bssh-keygen.1`, and `bssh-server.8` into a consistent BUGS / AUTHORS / COPYRIGHT / SEE ALSO order matching common Unix manual conventions. Author attribution, contact email, Apache-2.0 license notice, and project homepage link are now uniform across all three pages. (#192)
- Document `sftp.root` and `scp.root` in `bssh-server.8` configuration sections, and add intermediate-directory-symlink chroot protection to SECURITY CONSIDERATIONS.

### CI/CD
- Bump GitHub Actions to Node.js 24-compatible versions to address Node.js 20 deprecation warnings: `actions/checkout` v4 -> v6, `actions/cache` v4 -> v5, `actions/upload-artifact` v4 -> v7, `apple-actions/import-codesign-certs` v3 -> v7. (#191)

## [2.1.2] - 2026-04-27

### Fixed
- **PTY session mouse tracking leak**: after a PTY session disconnects (normal exit, Ctrl+C, network drop, or panic), the local terminal no longer prints raw SGR mouse escape sequences when the mouse is moved. All cleanup paths (`TerminalStateGuard::Drop`, `force_terminal_cleanup`, and the panic hook via `TerminalGuard`) now emit the full set of mouse-tracking-off sequences (modes 1000, 1002, 1003, 1006, 1015) plus cursor-show and alternate-screen-exit on teardown. (#189, #190)
- **Panic-hook safety in terminal cleanup**: `force_terminal_cleanup()` now uses `try_lock()` instead of `lock()` so the panic hook path (`TerminalGuard::restore_terminal` → `force_terminal_cleanup`) cannot deadlock if the panicking thread already holds `TERMINAL_MUTEX`, and a previously poisoned mutex no longer triggers a secondary panic. The lock only serializes concurrent teardown; the underlying stdout writes and `disable_raw_mode` are individually safe. (#190)

### Changed
- Centralized terminal teardown logic: `TerminalGuard::restore_terminal()` in `interactive_signal.rs` now delegates to `force_terminal_cleanup()` instead of carrying its own incomplete cleanup, closing the gap on the panic-hook path.

### Tests
- Added unit tests for `force_terminal_cleanup()` covering idempotency, poisoned-mutex resilience, and held-mutex resilience (using local `Mutex` instances rather than the global `TERMINAL_MUTEX` to keep the global state undisturbed).

### CI/CD
- Trigger the Homebrew formula update workflow only after the official release: `release.yml` now calls `update_homebrew_formula.yml` via `workflow_call` from the `publish-release` job (which converts pre-release to official), instead of `workflow_run` firing on every Release workflow completion (including pre-release builds).
- Prevent the release workflow from being triggered twice: removed the `published` event type from the trigger list since `publish-release` already handles the pre-release → official conversion. `workflow_dispatch` continues to cover manual runs.

## [2.1.1] - 2026-04-17

### Fixed
- **bssh-server panic on client connection**: `new_client_with_addr` called `block_on()` inside the tokio async runtime to check the auth rate limiter ban list, causing an immediate "Cannot start a runtime from within a runtime" panic on every incoming connection. Added non-blocking `AuthRateLimiter::try_is_banned()` using `RwLock::try_read()`. (#185)
- **bssh-server auth rejection after successful verification**: All `SshHandler` constructors created a local `SessionInfo` that was never registered with `SessionManager`. After public key or password verification succeeded, `authenticate_session()` returned `SessionNotFound`, rejecting the client. Fixed by deferring session creation to the authentication flow via `SessionManager::create_session()`. (#185)

### Changed
- Improved Launchpad PPA packaging for Rust 2024 edition: updated `debian/rules` variants, added `debian/sanitize-vendor.py` for vendored crate checksum sanitization, fixed hidden path handling
- Added `.pyc` files to `.gitignore`

## [2.1.0] - 2026-04-14

### Added
- `EnvGuard` RAII wrapper (`src/test_helpers/env_guard.rs`) for safe environment variable handling in tests, with Drop-based restore semantics and soundness contract documentation (#179, #181)
- Test Environment-Variable Mutation Pattern section in ARCHITECTURE.md documenting the `EnvGuard` soundness contract and `#[serial]` usage

### Changed
- Migrated bssh and the bundled `bssh-russh` crate to the Rust 2024 edition
- Applied 2024 edition clippy improvements: collapsed 38 nested `if let` statements into guard-clause form (`if let Some(x) = y && condition`) across `bssh-russh` client/server/keys/kex modules
- Replaced 177 ad-hoc `unsafe { env::set_var/remove_var }` call sites across 17 test-bearing files with `EnvGuard::set` / `EnvGuard::remove` plus `#[serial]` from the `serial_test` crate
- Removed hand-rolled `ENV_MUTEX` / `once_cell::sync::Lazy<Mutex<()>>` pattern in integration tests in favor of `#[serial]`
- Pinned `bytes` dependency to v1.11.1

### Fixed
- Fixed pattern matching for Rust 2024 edition: removed explicit `ref` / `ref mut` bindings in `if let` patterns in `src/server/handler.rs` and `src/server/shell.rs`

## [2.0.1] - 2026-04-13

### Added
- bssh-keygen now included in Debian package build pipeline for all architectures

### Changed
- Bumped bssh-russh from 0.60.0 to 0.60.1 with security updates

### Fixed
- Fixed GitHub Actions debian_build.yml distro configuration (questing -> resolute)
- Fixed debian_build.yml matrix validation issue causing workflow failures

### Security
- Updated rsa to 0.10.0-rc.17 (fixes RUSTSEC-2023-0071 RSA Marvin Attack)
- Updated RC dependencies to latest versions: elliptic-curve 0.14.0-rc.30, p256/p384/p521 0.14.0-rc.8, ml-kem 0.3.0-rc.2, spki 0.8.0 (stable)

## [2.0.0] - 2026-04-13

### Added
- **bssh-server SSH Server** - A lightweight SSH server designed for container environments
  - Full SSH, SFTP, and SCP protocol support
  - PTY/shell session support with terminal handling
  - Password and public key authentication
  - YAML-based comprehensive configuration system
  - Command execution handler with security controls

- **Audit Logging Infrastructure**
  - File-based audit exporter (JSON Lines format)
  - OpenTelemetry audit exporter for observability platforms
  - Logstash audit exporter for ELK stack integration
  - Configurable audit event types and logging levels

- **Security Features**
  - IP-based access control (allow/deny lists)
  - Authentication rate limiting (fail2ban-like protection)
  - Session management and connection limits
  - Path traversal prevention in SFTP handler

- **File Transfer Filtering**
  - Path-based filter rules for upload/download control
  - Pattern-based filtering with glob support
  - Configurable filter actions (allow/deny)

- **bssh-keygen Tool**
  - SSH key pair generation utility
  - Support for RSA, Ed25519, and ECDSA key types
  - Configurable key sizes and comments

- **Server Configuration**
  - Per-jump-host SSH private key configuration
  - SSH config Host alias reference in jump_host configuration
  - SSH keepalive settings in interactive mode

- **Separate Packaging**
  - bssh and bssh-server now distributed as separate packages
  - Independent Debian packages for client and server
  - Separate Homebrew formulas for each component

### Changed
- **Documentation**: Added comprehensive server configuration manual and manpages
- **CI/CD**: Updated release workflow for dual-package distribution; Teams release notification added
- **Dependencies**: Synced bssh-russh fork with upstream `warp-tech/russh` v0.60.0, which brings the RustCrypto chain migration — rand 0.9 → **0.10 stable**, signature 3.0.0-rc.10, ed25519-dalek 3.0.0-pre.6, elliptic-curve 0.14.0-rc.28, p256/p384/p521 0.14.0-rc.7, ecdsa 0.17.0-rc.16, curve25519-dalek 5.0.0-pre.6, der 0.8, sec1 0.8, pkcs8 0.11.0-rc.11, pkcs5 0.8.0-rc.13, spki 0.8.0-rc.4, ml-kem 0.3.0-rc.1, ssh-key 0.6.18, tokio 1.51.1, socket2 0.6.3, signal-hook 0.4.4, fastrand 2.4.1
- **Cluster flag help**: Help examples and man pages now correctly show `-C` (uppercase) for the cluster flag; running the previous `-c` examples failed with clap's "unexpected argument" error

### Fixed
- **SSH idle disconnects**: Interactive sessions could disconnect inconsistently after idle time — sometimes within minutes, sometimes ~10 minutes
  - `russh::Config::inactivity_timeout` (10-minute client-side ceiling) is now explicitly set to `None` when keepalive is enabled so the keepalive mechanism alone decides peer liveness
  - TCP-level `SO_KEEPALIVE` (via `socket2::TcpKeepalive`) is now applied to every SSH socket so the kernel can detect broken paths even when SSH keepalive replies are dropped by middleboxes
  - The exec-mode code path previously dropped the user-configured `SshConnectionConfig` at the `ConnectionConfig` boundary; it now flows through `connect_direct` / `connect_via_jump_hosts` / the jump chain, so `server_alive_interval` actually takes effect in non-interactive runs
- **bssh-server**: Use consistent source package name in Debian `control` file so dual-package builds resolve correctly
- **bssh-server**: Use type inference for `ioctl` to support both glibc and musl builds

### Technical Details
- Shared module structure for client/server code reuse
- russh-based SSH server handler implementation
- Modular audit exporter architecture with trait-based design
- `SshConnectionConfig::to_russh_config()` now overrides `inactivity_timeout` based on keepalive state; `to_tcp_keepalive()` derives a kernel TCP keepalive config from the same settings
- `Client::connect_with_config` rewritten around `russh::client::connect_stream`, building the `TcpStream` manually so SO_KEEPALIVE can be applied before the SSH handshake
- Dropped the `ssh_key::rand_core::OsRng` workaround in key-generation sites; now pass `&mut rand::rng()` (rand 0.10's thread RNG implements the new `rand_core 0.10` `CryptoRng` trait directly)

## [1.7.0] - 2026-01-09

### Added
- **SSH Keepalive Support** (Issue #122)
  - `--server-alive-interval` option: Configure keepalive interval in seconds (default: 60, 0 to disable)
  - `--server-alive-count-max` option: Maximum keepalive messages without response before disconnect (default: 3)
  - Configuration support in config.yaml via `server_alive_interval` and `server_alive_count_max` fields
  - Helps maintain long-running sessions through firewalls and NATs that drop idle connections
  - Full integration with exec, interactive, and file transfer modes

### Changed
- **Documentation**: Added GitHub downloads badge to README

### Dependencies
- Updated russh from 0.55.0 to 0.56.0
- Updated ratatui from 0.29.0 to 0.30.0
- Updated signal-hook from 0.3.18 to 0.4.1
- Updated whoami from 1.6.1 to 2.0.1
- Updated unicode-width from 0.2.0 to 0.2.2

### Technical Details
- Implemented SSH keepalive packet sending at configurable intervals
- Automatic connection termination after max retries without response
- Keepalive settings work with jump host connections
- Adapted to whoami 2.0 API changes (username() now returns Result)

## [1.6.0] - 2025-12-19

### Added
- **Jump Host Configuration Support in YAML** (Issue #115, PR #120)
  - Global defaults level: `defaults.jump_host` for all clusters
  - Cluster level: `clusters.<name>.jump_host` for cluster-specific settings
  - Node level: Per-node `jump_host` in detailed node configuration
  - Environment variable expansion supported (`${VAR}` or `$VAR` syntax)
  - Empty string (`""`) explicitly disables jump host inheritance
  - CLI `-J` option always takes precedence over configuration

### Changed
- **SSH Config ProxyJump Directive** (Issue #117, PR #119)
  - ProxyJump directive from SSH config now properly applied when `-J` option not specified
  - Priority order: CLI `-J` > config.yaml jump_host > SSH config ProxyJump
- **Documentation Improvements**
  - Added comprehensive jump_host configuration documentation to README.md
  - Updated docs/architecture/ssh-jump-hosts.md with detailed architecture
  - Updated example-config.yaml with all jump_host configuration patterns
- **CI/CD**
  - Updated GitHub workflows

### Fixed
- **Jump Host Authentication** (Issue #116, PR #118)
  - Properly handle empty SSH agent when authenticating through jump hosts
  - Fall back to key-based authentication when agent has no identities
- **Config Fallback** (PR #120)
  - Environment variables now properly expanded in jump_host values via expand_env_vars
  - Configuration jump_host properly used in exec and interactive modes

### Technical Details
- Added `ConfigResolver::resolve_jump_host()` method for centralized jump host resolution
- Jump host priority: CLI > Node > Cluster > Global defaults
- Comprehensive test coverage: 424 lines of tests for jump_host configuration
- Integration tests for all priority levels and edge cases

## [1.5.1] - 2025-12-18

### Fixed
- **SSH Disconnect Error Handling** (Issue #113, PR #114)
  - Handle SshError(Disconnect) during authentication for password fallback
  - Fixed handling of SSH disconnect errors during authentication phase
  - Enables proper password fallback when SSH connection is disconnected during auth

## [1.5.0] - 2025-12-18

### Added
- **pdsh Compatibility Mode** (Issues #100-103, #105, #107, #110)
  - Full pdsh-style command line compatibility when invoked as `pdsh` or with `--pdsh-compat`
  - `-w hosts` option mapped to `-H hosts` for target host specification
  - `-x hosts` option mapped to `--exclude hosts` for host exclusion
  - `-f N` option mapped to `--parallel N` for fanout control
  - `-l user` option for remote username
  - `-t N` option mapped to `--connect-timeout N` for connection timeout
  - `-u N` option mapped to `--timeout N` for command timeout
  - `-N` option mapped to `--no-prefix` for disabling hostname prefix in output
  - `-b` option mapped to `--batch` for single Ctrl+C termination
  - `-k` option mapped to `--fail-fast` for stop on first failure
  - `-q` query mode to show target hosts and exit
  - `-S` option mapped to `--any-failure` for returning largest exit code

- **Hostlist Expressions** (Issue #107)
  - pdsh-style range expansion: `node[1-5]` → node1, node2, node3, node4, node5
  - Zero-padded ranges: `node[01-05]` → node01, node02, node03, node04, node05
  - Comma-separated values: `node[1,3,5]` → node1, node3, node5
  - Cartesian product: `rack[1-2]-node[1-3]` → 6 hosts
  - Domain suffix support: `web[1-3].example.com`
  - User and port preservation: `admin@db[01-03]:5432`
  - File input with `^/path/to/hostfile`

- **In-TUI Log Panel** (Issue #106)
  - Toggle visibility with `l` key
  - Color-coded by level: ERROR (red), WARN (yellow), INFO (white), DEBUG (gray)
  - Configurable buffer size via `BSSH_TUI_LOG_MAX_ENTRIES` (default: 1000, max: 10000)
  - Panel height adjustable from 3-10 lines with `+`/`-` keys
  - Scroll with `j`/`k` keys, toggle timestamps with `t`

- **--fail-fast Option** (Issue #103)
  - `-k` / `--fail-fast` flag to stop immediately on first failure
  - Compatible with pdsh `-k` option
  - Cancels pending commands when any node fails
  - Can be combined with `--require-all-success` for strict error handling

- **--batch Option** (Issue #102)
  - `-b` / `--batch` flag for single Ctrl+C termination
  - Compatible with pdsh `-b` option
  - Useful for non-interactive scripts and CI/CD pipelines

- **--exclude Option** (Issue #100)
  - `--exclude` / `-x` for host exclusion
  - Supports wildcards, glob patterns, and hostlist expressions
  - Applied after `--filter` option

- **--no-prefix Option** (Issue #101)
  - `-N` / `--no-prefix` for disabling hostname prefix in output
  - Compatible with pdsh `-N` option
  - Works with both stream mode and file mode

- **--connect-timeout Option** (PR #103)
  - Separate connection timeout from command execution timeout
  - Default: 30 seconds, minimum: 1 second
  - Useful for fast failure detection on unreachable hosts

### Changed
- **CI Workflow Simplification**
  - Merged multiple jobs into single pipeline for efficiency

### Fixed
- **--timeout 0 Handling** (Issue #112)
  - Fixed --timeout 0 to correctly treat as unlimited execution time
  - Previously the 0 value was being ignored, causing unexpected timeout behavior
  - Added explicit CLI test to prevent regression

- **Environment Variable Test Race Conditions**
  - Added `#[serial]` attribute to env var tests to prevent race conditions
  - Tests now run sequentially when accessing shared environment state

- **Connect Timeout Propagation**
  - Fixed connect_timeout not being propagated through all SSH connection paths

### Documentation
- **Architecture Restructure** (Issue #109)
  - Restructured ARCHITECTURE.md into modular documentation
  - Removed residual dates and fixed incomplete sentences

- **pdsh Compatibility Documentation** (Issue #110)
  - Added comprehensive pdsh migration guide (docs/pdsh-migration.md)
  - Added pdsh options reference (docs/pdsh-options.md)
  - Added pdsh usage examples (docs/pdsh-examples.md)
  - Added installation scripts for pdsh symlink setup

## [1.4.2] - 2025-12-16

### Fixed
- **PTY Session Terminal Handling** (PR #90)
  - Fixed terminal escape sequence responses displayed on first prompt when starting tmux
  - Improved terminal compatibility with multiplexers
- **PTY Session Paste** (PR #89)
  - Fixed paste not working in PTY sessions
  - Improved clipboard/paste functionality in interactive terminal sessions

### Changed
- **Dependencies**
  - Bumped dependencies to latest versions for security and compatibility

## [1.4.1] - 2025-12-16

### Added
- **Comprehensive Test Suite** (Issue #82, PR #86)
  - tests/tui_snapshot_tests.rs: 20 tests for TUI rendering using ratatui's TestBackend
  - tests/tui_event_tests.rs: 36 tests for keyboard navigation, scroll behavior, and view transitions
  - tests/streaming_integration_tests.rs: 28 tests for NodeStream, MultiNodeStreamManager, and streaming execution
  - benches/large_output_benchmark.rs: Performance benchmarks for large output handling
  - Total: 84 new tests added

### Fixed
- **SSH Agent Password Fallback** (Issue #84, PR #85)
  - Extended password fallback to handle all SSH agent authentication failures
  - Now correctly triggers for: AgentAuthenticationFailed, AgentNoIdentities, AgentConnectionFailed, AgentRequestIdentitiesFailed
  - Added `is_auth_error_for_password_fallback()` helper function for testability
  - Added unit tests and integration tests for all error types

### Documentation
- **TUI Module Documentation** (Issue #81, PR #83)
  - Added comprehensive TUI architecture section to ARCHITECTURE.md
  - Enhanced README.md with keyboard shortcuts reference table
  - Added view modes description table
  - Added TUI activation conditions and requirements
  - Added missing "1-9: Jump to node N" shortcut in detail view help text

### Dependencies
- Added insta 1.34 (snapshot testing)
- Added criterion 0.5 (benchmarking)
- Added mockall 0.12 (mocking for integration tests)

### Technical Details
- Refactored test code quality: removed unused functions, replaced panic! with matches! macro
- Added Unicode test cases (Korean, Chinese, Emoji)
- Improved assertion messages for better debugging

## [1.4.0] - 2025-12-15

### Added
- **Sudo Password Support** (Issue #74, PR #78)
  - `-S/--sudo-password` flag for automated sudo authentication
  - Securely prompts for sudo password before command execution
  - Automatically detects and responds to sudo password prompts
  - Works with both streaming and non-streaming execution modes
  - `BSSH_SUDO_PASSWORD` environment variable support (with security warnings)
  - Uses `secrecy` crate for secure memory handling
  - Password cleared from memory immediately after use
- **Developer Tooling**
  - Added githooks for development workflow
  - Setup script for githooks configuration

### Fixed
- **Password Fallback** (PR #80)
  - Improved SSH debugging for better compatibility
  - Enhanced password authentication fallback logic
- Fixed clippy warnings for useless_vec and same_item_push

## [1.3.0] - 2025-12-10

### Added
- **Interactive TUI (Terminal User Interface)** (Phase 3 of #68)
  - Summary view: All nodes at a glance with progress bars
  - Detail view (1-9): Full output from specific node with scrolling
  - Split view (s): Monitor 2-4 nodes simultaneously
  - Diff view (d): Compare output from two nodes side-by-side
  - Auto-scroll (f): Toggle automatic scrolling
  - Navigation: Arrow keys, PgUp/PgDn, Home/End
  - Help (?): Show keyboard shortcuts
  - Press `q` to quit
- **Multi-node Stream Management** (Phase 2 of #68)
  - Real-time output modes for multi-node operations
  - Stream mode with [node] prefixes for real-time monitoring
  - Enhanced streaming infrastructure for real-time updates

### Fixed
- **PTY Escape Sequence Filtering** (PR #77)
  - Filter terminal escape sequence responses in PTY sessions
  - Fixed issue with terminal response codes appearing in output

### Technical Details
- Implemented ratatui-based TUI rendering
- Added multi-node output aggregation and display
- Performance optimizations for real-time output streaming

## [1.2.2] - 2025-10-29

### Fixed
- **Backend.AI Auto-detection** (PR #66)
  - Improved host heuristics for Backend.AI environments
  - Added localhost and localhost.localdomain detection
  - Added IPv4 address validation (127.0.0.1, 192.168.x.x, etc.)
  - Enhanced detection for user@host, host:port, FQDN, IPv6 patterns
  - Users can now use `bssh localhost "command"` naturally in Backend.AI

### Technical Details
- Extracted testable `looks_like_host_specification()` function
- Added `is_ipv4_address()` helper with strict validation
- Performance optimized with early returns
- 16 comprehensive tests added for host detection logic

## [1.2.1] - 2025-10-28

### Fixed
- **Password Authentication Fallback in Interactive Mode** (PR #65)
  - Re-implemented password authentication fallback logic for interactive mode
  - Fixed issue where password prompt was not appearing after key-based authentication failed
  - Ensured proper authentication flow in interactive sessions
- **Test Race Condition** (commit d2e8ce9)
  - Added `#[serial]` attribute to tests calling `RankDetector` to prevent environment variable race conditions
  - Tests now run sequentially when accessing shared environment state
  - Prevents intermittent test failures due to concurrent environment variable access

## [1.2.0] - 2025-10-27

### BREAKING CHANGES
- **Exit code behavior changed** (Issue #62)
  - **Old behavior (v1.0-v1.1)**: Returns 0 only if all nodes succeeded, 1 if any failed
  - **New behavior (v1.2.0+)**: Returns main rank's actual exit code (matches MPI standard: mpirun, srun, mpiexec)
  - **Impact**: Users relying on "all nodes must succeed" semantics must add `--require-all-success` flag
  - **Benefit**: Preserves actual exit codes (139=SIGSEGV, 137=OOM, 124=timeout) for better diagnostics
  - **MPI users**: No changes needed - behavior improved and matches standard tools
  - **Health checks**: Add `--require-all-success` flag to preserve old behavior

### Added
- **Exit Code Strategy** (Issue #62)
  - Main rank exit code returned by default (matches MPI standard: mpirun, srun, mpiexec)
  - Preserves actual exit codes: 139 (SIGSEGV), 137 (OOM), 124 (timeout), etc.
  - `--require-all-success` flag for v1.0-v1.1 behavior (returns 0 only if all nodes succeed)
  - `--check-all-nodes` flag for hybrid mode (main rank code + all-node check)
  - Automatic main rank detection via `BACKENDAI_CLUSTER_ROLE` environment variable
  - Example scripts: `examples/mpi_exit_code.sh`, `examples/health_check.sh`

### Changed
- Exit code behavior now aligns with HPC and distributed computing best practices
- Enables sophisticated error handling in shell scripts and CI/CD pipelines

## [1.1.0] - 2025-10-24

### Added
- **macOS Keychain Integration** (Issue #59, PR #61)
  - Automatic passphrase storage in macOS Keychain after successful SSH key authentication
  - Automatic passphrase retrieval before prompting user
  - Secure memory handling with `Zeroizing` for all sensitive data
  - Integration with SSH config `UseKeychain` option per host
  - New module `src/ssh/keychain_macos.rs` with complete Keychain API wrapper
- **ProxyUseFdpass SSH Option** (Issue #58, PR #60)
  - Added `ProxyUseFdpass` SSH configuration option support
  - Optimizes ProxyCommand usage by passing connected file descriptors back to ssh
  - Reduces overhead from lingering processes and extra read/write operations
- **Password Authentication Fallback**
  - Automatic password retry when publickey authentication fails
  - Matches OpenSSH standard behavior for seamless user experience
  - Interactive terminal detection with TTY checks
  - Works for both exec and interactive modes

### Security
- **SSH Key File Ownership Validation** (PR #61)
  - Prevents storing passphrases for SSH keys owned by other users
  - Added macOS user ID checks using libc
  - World-readable SSH key permission warnings
- **User Consent for Password Fallback** (PR #61)
  - Explicit user consent prompt before attempting password authentication
  - 30-second timeout for consent prompt
  - Prevents unexpected password prompts that could lead to credential exposure
- **Rate Limiting** (PR #61)
  - 100ms delay before initial connection attempts
  - 1 second delay before password fallback
  - Prevents brute-force attacks and fail2ban triggers

### Improved
- **Code Quality** (PR #61)
  - Eliminated 251 lines of code duplication in connection logic
  - Created `establish_connection()` helper function
  - Centralized authentication logic in auth module
  - 60% reduction in connection.rs complexity
- **Cross-Platform Support**
  - All macOS-specific code properly isolated with `#[cfg(target_os = "macos")]`
  - Conditional imports to prevent unused code warnings on non-macOS platforms
  - Stub functions for API consistency across platforms

### Fixed
- Fixed clippy warnings on non-macOS platforms (unused_mut, unused_imports, dead_code)
  - Variable shadowing for macOS-specific code paths
  - Conditional imports for platform-specific functions
- Fixed interactive mode missing `use_keychain` field causing authentication failures
- Fixed password prompt not appearing when connecting to new servers in interactive mode

### Dependencies
- Added `security-framework = "2.12.1"` for macOS Keychain API integration
- Added `libc` for macOS user ID checks (conditional on macOS)

## [1.0.0] - 2025-10-24

### Added
- **SSH Configuration: Certificate Authentication Options**
  - `CertificateFile` - Specify SSH certificate files for PKI authentication (maximum 100 certificates)
  - `CASignatureAlgorithms` - Define CA signature algorithms for certificate validation (maximum 50 algorithms)
  - `HostbasedAuthentication` - Enable/disable host-based authentication
  - `HostbasedAcceptedAlgorithms` - Specify accepted algorithms for host-based authentication (maximum 50 algorithms)

- **SSH Configuration: Advanced Port Forwarding Control**
  - `GatewayPorts` - Control remote port forwarding access (yes/no/clientspecified)
  - `ExitOnForwardFailure` - Terminate connection when port forwarding fails
  - `PermitRemoteOpen` - Specify allowed destinations for remote TCP port forwarding (maximum 1000 entries)

- **SSH Configuration: Command Execution and Automation Options**
  - `PermitLocalCommand` - Allow execution of local commands after successful SSH connection (yes/no, default: no)
  - `LocalCommand` - Execute local command after connection with token substitution support (%h, %H, %n, %p, %r, %u, %%)
  - `RemoteCommand` - Execute command on remote host instead of starting interactive shell
  - `KnownHostsCommand` - Execute command to obtain host keys dynamically (supports token substitution)
  - `ForkAfterAuthentication` - Fork SSH process to background after successful authentication (yes/no)
  - `SessionType` - Specify session type: none (port forwarding only), subsystem (e.g., SFTP), or default (shell)
  - `StdinNull` - Redirect stdin from /dev/null for background operations and scripting (yes/no)

- **SSH Configuration: Host Key Verification & Security Options**
  - `NoHostAuthenticationForLocalhost` - Skip host key verification for localhost connections (convenient for local development, default: no)
  - `HashKnownHosts` - Hash hostnames in known_hosts file to prevent hostname disclosure if compromised (default: no)
  - `CheckHostIP` - Check host IP address in known_hosts for DNS spoofing detection (deprecated in OpenSSH 8.5+, retained for legacy compatibility)
  - `VisualHostKey` - Display ASCII art of host key fingerprint for visual verification (default: no)
  - `HostKeyAlias` - Specify alias for host key lookup in known_hosts (useful for load-balanced services with shared keys)
  - `VerifyHostKeyDNS` - Verify host keys using DNS SSHFP records (yes/no/ask, default: no)
  - `UpdateHostKeys` - Accept updated host keys from server automatically (yes/no/ask, default: no)

- **SSH Configuration: Additional Authentication Options**
  - `NumberOfPasswordPrompts` - Control password authentication retry attempts (valid range: 1-10, default: 3)
  - `EnableSSHKeysign` - Enable ssh-keysign for host-based authentication (yes/no, default: no)

- **SSH Configuration: Network & Connection Options**
  - `BindInterface` - Bind SSH connection to specific network interface (alternative to BindAddress for multi-homed hosts)
  - `IPQoS` - Set IP type-of-service/DSCP values for interactive and bulk traffic (e.g., "lowdelay throughput")
  - `RekeyLimit` - Control SSH session key renegotiation frequency (format: "data [time]", e.g., "1G 1h")

- **SSH Configuration: X11 Forwarding Options**
  - `ForwardX11Timeout` - Set timeout for untrusted X11 forwarding connections (time interval, default: 0 = no timeout)
  - `ForwardX11Trusted` - Enable trusted X11 forwarding with full display access (yes/no, default: no)

- **Security Enhancements**
  - Path validation to prevent usage of sensitive system files (e.g., /etc/passwd, /etc/shadow)
  - Memory exhaustion prevention with entry limits for certificates and forwarding rules
  - Algorithm list validation with maximum entry limits
  - Deduplication for certificate files and remote forwarding destinations
  - Command injection prevention for LocalCommand and KnownHostsCommand
  - Token validation to prevent invalid substitution patterns
  - Dangerous character detection in command strings (semicolons, backticks, pipes, etc.)

### Changed
- **SSH Config Parser**: Refactored into modular structure for better maintainability
  - Split oversized parser.rs (1706 lines) into category-based modules (~200-350 lines each)
  - Organized option parsing by categories: authentication, security, forwarding, connection, etc.
  - Improved code organization and maintainability

### Technical Details
- Enhanced SSH configuration merging logic with proper priority handling
- Support for both "Option Value" and "Option=Value" syntax
- Scalar options override in later blocks, vector options accumulate with deduplication
- **SSH Configuration Coverage**: ~71 options (~69% of OpenSSH's 103 options)
  - Basic options + Include + Match directives (structural)
  - Certificate authentication and port forwarding (7 options)
  - Command execution and automation (7 options)
  - Host key verification, authentication, network, and X11 options (15 options)
- Comprehensive test coverage: 278 tests including parser, resolver, integration, and security tests
- Validation: NumberOfPasswordPrompts range checking (1-10), CheckHostIP deprecation warnings

## [0.9.1] - 2025-10-14

### Added
None

### Changed
- **PTY Terminal Modes**: Complete implementation of PTY terminal modes for better interactive session support
- **Shift Key Input Support**: Full Shift key input handling in PTY mode for proper terminal behavior

### Fixed
- Terminal mode implementation for PTY sessions
- Shift key input handling in interactive mode

### Technical Details
- Enhanced terminal mode settings for PTY allocation
- Implemented proper terminal flag handling for interactive sessions
- Improved keyboard input processing for special keys

## [0.9.0] - 2025-10-14

### Added
- **Configurable Jump Host Limit**: Maximum number of jump hosts can now be configured via environment variable
  - `BSSH_MAX_JUMP_HOSTS` environment variable for dynamic limit configuration
  - Default: 10 jump hosts, Absolute maximum: 30 (security cap)
  - Invalid/zero values fall back to default with warning logs
  - Example: `BSSH_MAX_JUMP_HOSTS=20 bssh -J host1,...,host20 target`
  - Prevents resource exhaustion attacks while allowing flexible configurations

- **Jump Host File Transfer Support**: Added complete file transfer operations through SSH jump hosts
  - `upload_file_with_jump_hosts()` - Upload single files through jump host chains
  - `download_file_with_jump_hosts()` - Download single files through jump hosts
  - `upload_dir_with_jump_hosts()` - Upload directories recursively through jump hosts
  - `download_dir_with_jump_hosts()` - Download directories through jump hosts
  - All file transfer operations now fully support multi-hop SSH connections

- **Jump Host Interactive Mode Support**: Interactive shell sessions now work through jump hosts
  - Added `jump_hosts` field to `InteractiveCommand` structure
  - Dynamic timeout calculation based on hop count (30s base + 15s per hop)
  - Prevents premature timeouts on multi-hop connections
  - Full authentication support (SSH keys, agent, password) for each hop

- **Parallel Executor Integration**: Jump host support across all parallel operations
  - Updated `executor.rs` to propagate jump_hosts to all node operations
  - Maintains backward compatibility with `Option<&str>` type
  - All `*_to_node()` functions now accept `jump_hosts` parameter

### Changed
- **Interactive Mode**: Now includes jump host support with automatic timeout adjustment
  - Connection timeout scales with hop count for reliability
  - Example timeouts: Direct (30s), 1 hop (45s), 2 hops (60s), 3 hops (75s)

- **Test Coverage**: Updated all test files to include jump_hosts parameter
  - `tests/interactive_test.rs`: Added `jump_hosts: None` to test cases
  - `tests/interactive_integration_test.rs`: Updated all 9 test instances
  - `examples/interactive_demo.rs`: Updated example with jump_hosts field

- **Dependencies**: Updated various dependencies for security patches and stability

### Fixed
- Interactive mode timeout issues when connecting through jump hosts
- File transfer operations not working with jump host chains

### Security
- Added `serial_test` dependency for thread-safe environment variable testing
- Comprehensive test coverage for environment variable functionality (6 new tests)

### Technical Details
- **Files Modified**: 8 files
- **Lines Added**: +623
- **Lines Removed**: -26
- **Net Change**: +597 lines
- **Test Results**: All 132 tests passing

## [0.8.0] - 2025-09-12

### Added
- Comprehensive SSH port forwarding support
  - Local port forwarding (`-L`) for tunneling to remote services
  - Remote port forwarding (`-R`) for exposing local services
  - Dynamic port forwarding (`-D`) for SOCKS4/5 proxy functionality
- Improved error messages with better context and recovery suggestions

### Changed
- Removed dangerous `unwrap()` calls throughout codebase
- Enhanced error handling with detailed failure reasons

## [0.7.0] - 2025-08-30

### Added
- SSH jump host infrastructure (`-J` option)
  - OpenSSH ProxyJump format parsing
  - Multiple jump hosts support (comma-separated)
  - IPv6 address handling with bracket notation
  - Jump host chain management and connection establishment

### Changed
- Improved Ubuntu PPA support with better packaging
- Fixed deprecated GitHub Actions workflows

## [0.6.1] - 2025-08-28

### Changed
- Rebranded from "Backend.AI SSH" to "Broadcast SSH"
  - Emphasizes core broadcast/parallel functionality
  - Better reflects the tool's primary purpose

## [0.6.0] - 2025-08-28

### Added
- SSH configuration file support (`-F` option)
  - Auto-loads from `~/.ssh/config` by default
  - Supports 40+ SSH directives
  - Wildcard pattern matching and negation
  - Environment variable expansion in paths
- PTY allocation for interactive sessions (`-t`/`-T` options)
- SSH configuration caching for improved performance
  - LRU cache with configurable size and TTL
  - File modification detection
  - 10-100x faster repeated operations

### Changed
- Enhanced security with improved host key verification
- Performance improvements across core operations
- SSH-compatible command-line interface (drop-in replacement)

## [0.5.4] - 2025-08-27

### Fixed
- Parallel configuration value handling issues
- Interactive mode authentication alignment with exec mode

## [0.5.3] - 2025-08-27

### Changed
- Backend.AI cluster auto-detection now uses cluster SSH key configuration

### Breaking Changes
- **Cluster option**: changed from lowercase `-c` to uppercase `-C`, to avoid conflicting with SSH's `-c` (cipher) option. Scripts using `-c` for cluster selection must be updated.
- **Parallel option**: `-p` now specifies the port, for SSH compatibility. Use `--parallel` for parallel connection count.

## [0.5.2] - 2025-08-27

### Fixed
- Configuration file loading priority issues
- Backend.AI environment variable handling improvements

### Changed
- Now uses cluster SSH key configuration when available

## [0.5.1] - 2025-08-25

### Added
- Configurable command timeout support
  - Set timeout via `--timeout` flag or configuration file
  - Support for unlimited execution time (`timeout=0`)
  - Default timeout: 300 seconds (5 minutes)

## [0.5.0] - 2025-08-22

### Added
- Interactive mode with PTY support
  - Single-node mode for focused interaction
  - Multiplex mode for parallel command execution
  - Node switching commands (`!node1`, `!node2`, etc.)
  - Broadcast command (`!broadcast <cmd>`)
  - Visual status indicators (● active, ○ inactive)
  - Command history with rustyline
  - Configurable prompts and settings

### Changed
- Improved Backend.AI cluster auto-detection
- Enhanced interactive shell capabilities

## [0.4.0] - 2025-08-22

### Added
- Password authentication support (`-P` flag)
- SSH key passphrase support with secure prompting
- Modern UI with semantic colors and Unicode symbols
- Debian package distribution (`.deb`)

### Changed
- XDG Base Directory specification compliance
- Improved configuration management
- Enhanced visual feedback and progress indicators

## [0.3.0] - 2025-08-22

### Added
- Native SFTP directory operations
- Recursive file transfer support
  - Upload directories with `-r` flag
  - Download entire directory trees
  - Glob pattern support for batch operations

## [0.2.0] - 2025-08-21

### Added
- Backend.AI multi-node session support
  - Automatic cluster detection from environment variables
  - Default SSH port 2200 for Backend.AI clusters
- SSH authentication enhancements
  - SSH agent authentication with auto-detection
  - Host key verification with known_hosts support
  - Multiple authentication method fallback
- Environment variable expansion in configuration files
- Connection and command timeout configuration
- SFTP file transfer (upload/download)
  - SCP-compatible file copy functionality
  - Progress tracking for file operations

### Changed
- Improved error messages and diagnostics
- Enhanced security with host key verification

## [0.1.0] - 2025-08-21

### Added
- Initial release of bssh
- Parallel SSH command execution across multiple nodes
- Cluster configuration management via YAML files
- Node specification via CLI (`-H` flag)
- SSH key-based authentication
- Real-time progress tracking with progress bars
- Per-node output collection and aggregation
- Configurable parallel execution limits
- Connectivity testing (`ping` command)
- Cluster listing (`list` command)

### Features
- Built with Rust for performance and safety
- Async/await pattern for maximum concurrency
- Tokio runtime for efficient I/O operations
- russh library for native SSH implementation
- Cross-platform support (Linux and macOS)

[2.4.2]: https://github.com/lablup/bssh/compare/v2.4.1...v2.4.2
[2.4.1]: https://github.com/lablup/bssh/compare/v2.4.0...v2.4.1
[2.4.0]: https://github.com/lablup/bssh/compare/v2.3.1...v2.4.0
[2.3.1]: https://github.com/lablup/bssh/compare/v2.3.0...v2.3.1
[2.3.0]: https://github.com/lablup/bssh/compare/v2.2.3...v2.3.0
[2.2.3]: https://github.com/lablup/bssh/compare/v2.2.2...v2.2.3
[2.2.2]: https://github.com/lablup/bssh/compare/v2.2.1...v2.2.2
[2.2.1]: https://github.com/lablup/bssh/compare/v2.2.0...v2.2.1
[2.2.0]: https://github.com/lablup/bssh/compare/v2.1.4...v2.2.0
[2.1.4]: https://github.com/lablup/bssh/compare/v2.1.3...v2.1.4
[2.1.3]: https://github.com/lablup/bssh/compare/v2.1.2...v2.1.3
[2.1.2]: https://github.com/lablup/bssh/compare/v2.1.1...v2.1.2
[2.1.1]: https://github.com/lablup/bssh/compare/v2.1.0...v2.1.1
[2.1.0]: https://github.com/lablup/bssh/compare/v2.0.1...v2.1.0
[2.0.1]: https://github.com/lablup/bssh/compare/v2.0.0...v2.0.1
[2.0.0]: https://github.com/lablup/bssh/compare/v1.7.0...v2.0.0
[1.7.0]: https://github.com/lablup/bssh/compare/v1.6.0...v1.7.0
[1.6.0]: https://github.com/lablup/bssh/compare/v1.5.1...v1.6.0
[1.5.1]: https://github.com/lablup/bssh/compare/v1.5.0...v1.5.1
[1.5.0]: https://github.com/lablup/bssh/compare/v1.4.2...v1.5.0
[1.4.2]: https://github.com/lablup/bssh/compare/v1.4.1...v1.4.2
[1.4.1]: https://github.com/lablup/bssh/compare/v1.4.0...v1.4.1
[1.4.0]: https://github.com/lablup/bssh/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/lablup/bssh/compare/v1.2.2...v1.3.0
[1.2.2]: https://github.com/lablup/bssh/compare/v1.2.1...v1.2.2
[1.2.1]: https://github.com/lablup/bssh/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/lablup/bssh/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/lablup/bssh/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/lablup/bssh/compare/v0.9.1...v1.0.0
[0.9.1]: https://github.com/lablup/bssh/compare/v0.9.0...v0.9.1
[0.9.0]: https://github.com/lablup/bssh/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/lablup/bssh/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/lablup/bssh/compare/v0.6.1...v0.7.0
[0.6.1]: https://github.com/lablup/bssh/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/lablup/bssh/compare/v0.5.4...v0.6.0
[0.5.4]: https://github.com/lablup/bssh/compare/v0.5.3...v0.5.4
[0.5.3]: https://github.com/lablup/bssh/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/lablup/bssh/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/lablup/bssh/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/lablup/bssh/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/lablup/bssh/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/lablup/bssh/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/lablup/bssh/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/lablup/bssh/releases/tag/v0.1.0